Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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)
|
||||
|
||||
- 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
|
||||
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
|
||||
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
|
||||
|
||||
+31
-33
@@ -2,35 +2,6 @@
|
||||
|
||||
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
|
||||
|
||||
> **➡️ 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
|
||||
|
||||
> **STAND-UPDATE (Session 4, neues Gerät):** **Environment behoben** — Toolchain vorhanden (`node`/`npm`/`cargo` unter `/usr/bin`, `wasm-pack` via `npx` 0.15.0; es war die Flatpak-Sandbox); fehlendes Rust-Target per `sudo dnf install rust-std-static-wasm32-unknown-unknown` (+ lld) nachinstalliert; beide WASM-Engines gebaut → App läuft. **Slice 1 (Locked-Iso `cb8fae5`) + Slice 2 (Joins Phase 1c `c5a344d`) committet.** **Slice 3 (Öffnungen als Boolean-Löcher + Deckentrim-nur-3D) ✅ committet `1407c68`:** Fenster/Türen = 1 Wandkörper pro Schicht-Band mit rechteckigen `holes` (render3d stanzt sie per achsparalleler Rechteck-Gitter-Zerlegung + 4 Laibungsquads/Loch), Segment-Boxen weg; `holes`/`openings` schliessen sich im Emitter aus; Schnitt-Pfad (layered=false) segmentiert unverändert weiter. Deckentrim `trimWallTopForCeilings` gilt nur noch im 3D-Pfad, Schnitt-Pfad bekommt volle Wandhöhe. Alle Datei-Edits waren gelandet — unabhängig verifiziert: cargo test 56 (inkl. beider Pflicht-Tests versetzt-überlappende Fenster + Tür-Loch-bis-Boden), vitest 230, build:engine3d + tsc sauber, Trace clean. **Damit sind alle drei verlorenen 3b-Slices rekonstruiert.** **Memory-Direktiven `priority-based-joins.md` + `openings-as-booleans.md` wiederhergestellt.**
|
||||
>
|
||||
> **⚠️ PUSH BLOCKIERT:** keine Git-Credentials auf diesem Gerät (kein Helper, keine `~/.git-credentials`, kein SSH-Key; Remote HTTPS `git.openbureau.ch/karim/DOSSIER-STANDALONE`). `cb8fae5` + `c5a344d` (+ folgender Öffnungen-Commit) sind **lokal committet, NICHT gepusht** (`ahead of origin/master`). Sobald Gitea-PAT (repo-write) oder SSH-Key da ist: `git push origin master`. Bis dahin holt das andere Gerät die Arbeit nicht.
|
||||
>
|
||||
> Der folgende Block bleibt als historischer Kontext/Vorlage für den offenen Slice 3 stehen.
|
||||
|
||||
Die Arbeit wurde unerwartet unterbrochen — mehrere Tasks liefen parallel. Diese Übergabe wurde nachträglich rekonstruiert. **Der tatsächliche HEAD-Commit ist `03f0c40`** (Nordstern-3D Live-Schnittebene) — alles danach existierte nur im Arbeitsbaum der alten Maschine und ist auf **diesem** Checkout (neues Gerät, `git status` sauber bis auf `package-lock.json`) **nicht vorhanden**. Diese drei Slices waren in echter Arbeit (nicht nur geplant) und gelten als verloren — neu beauftragen, nicht nur „prüfen ob gelandet":
|
||||
|
||||
1. **Locked-Iso-Fix** (`src/viewport/Wasm3DViewport.tsx`, `OrbitState` um `ortho: boolean` + `orthoHalfHeight: number` erweitern, `orbitCamera()`/Preset-Effect/Pan/Zoom entsprechend anpassen) — Bug: Iso-Ansicht kippt bei der ersten Kamerabewegung sofort in Perspektive statt orthografisch (parallel) zu bleiben. War fertig gebaut und quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit, 225/225 Tests grün), aber **nicht mehr committet**, bevor der Unterbruch kam. Kleinster, unabhängigster der drei Fixes — zuerst neu machen.
|
||||
2. **Joins Phase 1c — Durchgangswand echt aufbrechen** (`spanCutouts` in `src/model/joins.ts` + `src/plan/generatePlan.ts` + Rust-Parität `src-tauri/geometry/src/lib.rs`): Nutzer-Befund nach Phase 1b war, dass der Innenputz der DURCHGANGSWAND am T-Stoss nur überdeckt wird (Zeichenreihenfolge), nicht geometrisch ausgeschnitten — Fugen-/Nahflächenlinien laufen noch durch den Backstein-Durchstoss, es „liest" noch nicht wie ein einziger Join. Das war die **explizite Priorität des Nutzers** für diese Session. Die Umsetzung war weit fortgeschritten (schrieb bereits in `joins.ts`), dann abgebrochen. **Nicht committet, nicht im Baum.** Vollständige Aufgabenbeschreibung mit allen Datei:Zeile-Ankern steht im Transkript bei „Agent:Phase 1c: Durchgangswand aufbrechen" — 1:1 als Vorlage für den Neu-Auftrag wiederverwenden.
|
||||
3. **Öffnungen als echte Boolean-Löcher** (`RWall.holes`, `src/plan/toWalls3d.ts`, `src-tauri/render3d/src/mesh.rs`, additiv `types.rs`): EIN Wandkörper mit rechteckigen Löchern statt der heutigen Pfeiler/Brüstung/Sturz-Ersatzkörper (sichtbare Segment-Nähte, keine echten Löcher). Pflicht-Testfall (Nutzer-Direktive, Kernmotivation des Slices): zwei Fenster in derselben Wand mit überlappendem Achsen-Intervall, aber unterschiedlicher Höhe (z. B. u=[1.0,2.0]/z=[0.9,2.1] und u=[1.5,2.5]/z=[0.3,0.7]) — die alte Segment-Zerlegung kann das nicht abbilden, echte Löcher schon. Zusatz-Auftrag im selben Slice: `trimWallTopForCeilings` (Phase-3-Deckentrim, Commit `e4b8df6`) darf **nur** im 3D-Viewer-Pfad (`layeredWalls:true`) gelten, **nicht** im Schnitt-Pfad (`layeredWalls:false`) — der Schnitt braucht die volle, ungetrimmte Wandhöhe für seine eigene schichtweise Prioritäts-Subtraktion (`subtractDominantBands`), sonst zeigt der Schnitt die Wände fälschlich abgeschnitten. Fortschritt bei Abbruch **unklar** (letztes Status-Update deutete auf „noch in der Kartierungsphase" hin) — vermutlich am wenigsten weit von den dreien; komplett neu beauftragen. Vollständiger Auftragstext im Transkript bei „Agent:Oeffnungen als Boolean-Loecher" + die zwei Zusatz-Direktiven (Decken-Trim-Fix, Pflicht-Testfall versetzte Fenster) kurz danach.
|
||||
|
||||
**Zwei Engine-Direktiven vom alten Stand fehlen** (`priority-based-joins.md`, `openings-as-booleans.md`): die in Punkt 2/3 beschriebenen Zielverhalten waren gesichert (u. a. „Trennlinien an Merge-Kontakten verschwinden immer", „Öffnungen dürfen nie wieder zu Segmenten vereinfacht werden"). Beim Neu-Beauftragen diese Direktiven wieder explizit festhalten.
|
||||
|
||||
**Environment-Lücke auf diesem Gerät:** `node`/`npm`/`cargo`/`wasm-pack` waren zum Zeitpunkt dieser Übergabe **nicht auf dem PATH** (geprüft: kein Treffer, auch nicht unter der VSCodium-Flatpak-Datenlage). **Vermutliche Ursache: VSCodium lief als Flatpak** — Flatpak-Sandboxing blendet system-installierte Toolchains (Node/Rust unter `/usr/...` oder `~/.cargo`) typischerweise aus, auch wenn sie auf dem Host tatsächlich installiert sind. Der Nutzer wechselt gerade auf **natives VSCodium** (kein Flatpak mehr) — das könnte die Lücke von selbst beheben, MUSS aber nach dem Wechsel neu geprüft werden (`which node npm cargo wasm-pack`), bevor man von "Toolchain fehlt" auf "Toolchain neu installieren" schließt. Erst wenn nach dem Wechsel auf natives VSCodium immer noch nichts gefunden wird, wirklich neu installieren (Node LTS + Rust via rustup + `wasm-pack`, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml` durchlaufen lassen). Ohne Toolchain ist die in `CONVENTIONS.md` vorgeschriebene Verifikation (`tsc`/`vitest`/`cargo test`/`wasm-pack`) nicht möglich — das zuerst klären, bevor einer der drei Slices neu beauftragt wird, sonst baut ein Agent blind ohne Verifikation.
|
||||
|
||||
**Aufräum-Hinweis:** `Edit MEMORY md Added 1.txt` (lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung waren dirty/untracked. Die txt-Datei gehört nicht ins Repo — außerhalb verschieben oder löschen. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
|
||||
|
||||
### Empfohlene Reihenfolge zum Wiederaufsetzen
|
||||
1. Nach dem Wechsel auf natives VSCodium zuerst `which node npm cargo wasm-pack` prüfen — evtl. war es nur die Flatpak-Sandbox, die die Toolchain versteckt hat. Nur falls immer noch nichts gefunden wird: Node + Rust + wasm-pack installieren + `npm install`. Blocker für alles Weitere.
|
||||
2. Locked-Iso-Fix neu bauen (klein, unabhängig, Spezifikation im Transkript vollständig vorhanden).
|
||||
3. Joins Phase 1c (Nutzer-Priorität dieser Session) — Direktive zuerst wieder ins Memory schreiben, dann Agent mit dem Auftragstext aus dem Transkript beauftragen.
|
||||
4. Öffnungen als Boolean-Löcher + Deckentrim-nur-3D-Zusatz (dieselbe Rust-Crate wie Phase 1c — nacheinander, nicht parallel, wegen Datei-Overlap in `mesh.rs`/`toWalls3d.ts`).
|
||||
5. Repo aufräumen (txt-Datei raus, `package-lock.json` klären), danach diesen Abschnitt auf „erledigt" aktualisieren.
|
||||
|
||||
## NACHTRAG Session 3 (2026-07-04 mittags) — Prioritaets-Joins + Schnitt-Sanierung
|
||||
|
||||
**Prioritäts-Join-Epic (Kern-Direktive, siehe unten „Verschneidungs-Zielmodell"):** Joins werden nach `Component.joinPriority` materialbewusst aufgelöst — konsistent in Grundriss, Schnitt UND 3D. Gelandet:
|
||||
@@ -75,17 +46,44 @@ 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.
|
||||
|
||||
## 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.
|
||||
- **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`.
|
||||
|
||||
## In dieser Session gelandet (Auswahl Commits)
|
||||
Schraffur/Linien-Epic komplett: Typmodell (vector/image Hatch, dash/zigzag/custom Line, Component Vordergrund/Hintergrund), ResourceManager **Master-Detail** (Schraffuren/Linien/Wandstile/Deckenstile, `9695748`), Rendering neuer Typen (Bild-`<pattern>`, random modellraum-verankert, Zickzack, Custom-Motiv-Editor `src/ui/MotifEditor.tsx`), modulares Linien-Segment-System (Strich/Punkt/Lücke), Attribut Vordergrund/Hintergrund + **By-Layer/By-Object-Quellen** (Nach Ebene/Bauteil/eigener Wert für Vordergrund/Hintergrund/Strichstärke/Schraffur, `d02781e`).
|
||||
Weiter: Gehrung spitze Winkel (2D+Schnitt+Rust), TopBar/Footer-Reorg, Zoom-% am Massstab, Tool-Shortcuts 1..0, Verschiebe-Dreieck flacher + Snap, Statusleiste „Nordstern", ResourceManager als **floating nicht-modales Fenster**, **swisstopo** Gebäude(extrudiert)+Terrain-Mesh in 3D (`f22970f`), Schnitt-Wandschicht-Orientierung (`7569524`), **Schnitt-Boolean-Dominanz** nach joinPriority (`23d81be`).
|
||||
|
||||
## Backlog & Rückfragen → `PENDENZEN.md`
|
||||
Der frühere „Offener Backlog"/„Offene Rückfragen"/„IN FLIGHT"-Abschnitt ist vollständig nach `PENDENZEN.md` migriert (Single Source of Truth). Dort steht auch das Arbeitsprotokoll.
|
||||
## IN FLIGHT bei Session-Ende (PRÜFEN!)
|
||||
- **Snap an Wand-Schichttrennlinien** (Agent Sonnet): sollte `src/tools/snapping.ts` (+ ggf. `src/model/geometry.ts` + Test) geändert haben. `git status` prüfen — falls dirty: `tsc`+`vitest`+trace, committen. Falls nicht gelandet: neu beauftragen (Schicht-Offsetlinien aus `addWallPoche`-Logik in generatePlan spiegeln [nur LESEN], als Snap-Targets in snapping.ts einspeisen).
|
||||
|
||||
## Offener Backlog (Priorität grob absteigend)
|
||||
1. **Zu verifizieren/klären**:
|
||||
- Isometrie — „echte" orthographische Isometrie? (nicht perspektivisch; alle Längen gleich dargestellt?) — Status unklar.
|
||||
- TopBar Detailgrad — zoom% + zoom/scale komplett aus Footer? Details der Reorg prüfen.
|
||||
- DOSSIER Audit — welche Features konkret übernommen (über AUDIT Punkt 9 hinaus)?
|
||||
2. **Ebene-Schraffur editierbar**: „Nach Ebene"-Schraffur ist in der Resolve fertig, aber `LayerCategory.hatch` ist im Kategorie-Dialog (App.tsx ~3350, editiert nur name/color/lw) noch nicht editierbar. Kleine Folge-Arbeit.
|
||||
2. **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
|
||||
- Reale Höhen + **Projekt-MüM**: Gebäude/Terrain sind auf z=0 (Terrain zentriert, Gebäude-Basis z=0). Nutzer will Projekt-Setting für MüM (EG-Referenzhöhe), Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert.
|
||||
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
||||
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + eine **Gelände-Ebene**; Kontext liegt in `project.context` (siteSlice) ohne floorId/categoryCode.
|
||||
- **Nordstern-Geo-Rendering**: importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh` in Viewport3D) auch in `projectToModel3d` einspeisen — Nordstern zeigt sie sonst nicht.
|
||||
- **3D-Mesh-DXF/DWG-Import** für präzise Gebäude (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
|
||||
3. **ResourceManager Bauteile-Tab** auf Master-Detail (wie Wandstile) — BEVOR Bauteil-Logik angefasst wird.
|
||||
4. **Einstellungs-Fenster**: Auswahlrahmen Default **Yuyake #CEB188**; Snap/Endpunkt Default = RÜCKFRAGE **„aki"** (nicht in Accents: Ajisai/Sakura/Suna/Ichigo/Yuyake/Sora/Kusa/Kori/Amagumo/Yuki); freier Picker + Accents-Presets; verdrahten in PlanView (Selektionsrahmen + Snap-Marker). Dazu Projekt-MüM-Feld.
|
||||
5. **Bildschraffur**: ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||
6. **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||
7. **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
|
||||
8. 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
||||
9. **AUDIT (DOSSIER-Studie)**: A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch; A6 Bauteil-Schedule-CSV. Belege in `/tmp/dossier-ref/rhino/*.py`.
|
||||
10. Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||
|
||||
## Offene Rückfragen
|
||||
- **„aki"** = welcher Accent für Snap/Endpunkt-Farbe.
|
||||
- Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT).
|
||||
- Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering.
|
||||
|
||||
## Verifikation
|
||||
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
# PENDENZEN — Aufgaben-Queue (Single Source of Truth)
|
||||
|
||||
> **Diese Datei ist die einzige verbindliche Aufgabenliste.** HANDOVER.md enthält nur
|
||||
> noch Kontext/Konventionen/Environment/Zielmodelle — keinen Backlog mehr.
|
||||
|
||||
## Arbeitsprotokoll (ZWINGEND)
|
||||
|
||||
**Bearbeiter:**
|
||||
1. **Zuerst diese Datei lesen**, dann `CONVENTIONS.md` + verlinkte Detailquellen des Items.
|
||||
2. Oberstes nicht-blockiertes Item aus **🔧 In Arbeit** bzw. sonst **⏭️ Als Nächstes** nehmen, **fertig** machen, verifizieren (`npx tsc --noEmit` + `npx vitest run` + trace-scan), abhaken, eine Ergebniszeile nach **✅ Erledigt** schreiben (mit Commit-Hash, sobald committet).
|
||||
3. **Rückfrage-Recht:** Bei Unklarheit/Design-Entscheid NICHT raten — Item mit `❓` markieren, kurze Frage darunter notieren, nächstes freies Item nehmen. Fragen sammeln sich unter **❓ Offene Rückfragen**.
|
||||
4. **Kein Arbeitsschritt endet ohne aktualisierte Liste.** Status hier ist immer aktuell, sonst driftet es wieder.
|
||||
5. Bearbeiter **committet nicht selbst** und delegiert Unteraufgaben nicht weiter.
|
||||
|
||||
**Planer** (mit dem Nutzer, kuratiert):
|
||||
- Nimmt Nutzer-Feedback, pflegt daraus die Liste (rein/umpriorisieren/aufspalten). Führt selbst keine Queue-Items aus.
|
||||
- Prüft **❓**-Zeilen mit dem Nutzer, wandelt sie in konkrete Items.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Geplant
|
||||
|
||||
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)** — **Start: 2026-07-07 (Dienstag, nach Reset).** Nutzer zeichnet 2D-Querschnitt (L-Profil, T-Träger, Freiform) → truck-Extrusion → 3D-Körper + Boolean gegen Wand/Decke. Voraussetzung: truck-Booleans stabil + WASM-Integration. Umfang: ~4–6 Wochen. Mit Nutzer Scope/MVP klären vor Start.
|
||||
|
||||
## ⛔ Blocker (zuerst klären)
|
||||
|
||||
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
||||
|
||||
## 🔧 In Arbeit
|
||||
|
||||
- [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt:
|
||||
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d` — `a78e7c7`
|
||||
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
|
||||
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
|
||||
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
|
||||
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Offen in Phase 5:** `opening` (mit Aufrufstellen-Änderung) — noch nicht portiert.
|
||||
- [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären.
|
||||
- **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung).
|
||||
|
||||
## ⚠️ Zu prüfen (evtl. schon erledigt / Status unklar)
|
||||
|
||||
- [x] ~~**Snap an Wand-Schichttrennlinien**~~ — **gelandet `339202b`** (`wallLayerBoundarySegments` in `src/tools/snapping.ts`), verifiziert vorhanden.
|
||||
|
||||
## ⏭️ Als Nächstes
|
||||
|
||||
- [ ] **Ribbon-UI + modulare Bars** (Nutzer-Vision 2026-07-05, bestätigt: Tab-Schema **2D·3D·BIM·Ansichten**). Voller Plan + datengetriebene Architektur: **[docs/design/ribbon-ui-plan.md](docs/design/ribbon-ui-plan.md)**. Ribbon-Oberleiste mit Tabs ersetzt die Werkzeug-Sidebar; datengetriebene Registry (RibbonItem = tool|command|action) ermöglicht auch eine **modulare Custom-Bar** (gleiche Items, vom Nutzer gewählt). Attribute bekommen volle Höhe, Objektinfo darunter gemergt; XYZ-Box oben rechts bleibt. **Phasen:** ~~(1) Gerüst + 2D/BIM-Tab~~ ✅ (`9d6e86d`), ~~(2) TopBar → Ansichten-Tab mergen~~ ✅ (`85011cb`), ~~(2b) Tabs in die TopBar-Zeile~~ ✅ (`456ebc8`), ~~(2c) OCS-Chrome (kleine Wortmarke + Quick-Access-Icons statt Burger, Zeile 26px)~~ ✅ (`4e3b074`), ~~(2d) Text + Ansichten auf eine Leiste, „Ansichten" als Standard-Tab zuerst~~ ✅ (`fe22cbf`), (3) **teilweise** ✅: Werkzeug-Sidebar aus Default-Layout raus + Attribute volle linke Höhe + Wandtyp-/Deckentyp-Picker ins Attribute-Panel verschoben (Nutzer-Entscheid), `LAYOUT_VERSION`→8; ~~**offen:** Objektinfo unter Attribute mergen~~ ✅ (`3a2cef3`): element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) + Wand-Referenzlinie ins Attribute-Panel verschoben; ObjectInfo trägt nur noch Bezugspunkt + Masse (Nutzer-Wunsch). ~~(4) modulare Custom-Bar~~ ✅ (Tab „Eigene" + +-Picker, localStorage-persistiert). **Offen:** 3D-Tab füllen (aktuell leer; ggf. 3D-spezifisch statt Doppelung mit Ansichten), Band-Höhe/Abstände + 26px-Zeile visuell im Tauri abnehmen, Dauer-Zoom-Anzeige (Statusleiste?) klären. ✅ Vorarbeit: Eigenschaften-Grid im OCS-Stil (`00733d8`), Kreis+Bogen-Werkzeuge (`e454eab`/`bd2b12b`). **Nächster Schritt: im Tauri visuell prüfen (Band-Höhe, Icons, Aktiv-Highlight), dann Phase 2/3.**
|
||||
|
||||
- [x] ~~**SPIKE — Bild-Texturen in `render3d`**~~ — **erledigt `0ca3b1d`** (verifiziert 2026-07-05): `RenderStyle::Textured` real, prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), UVs planar in Metern, Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`), `spike3d` per `T` umschaltbar. Alt-Vertexpfad `[pos,normal,color]` bitgleich (Regressionstest). `cargo test` 58 grün (59 mit `--features render`, inkl. naga-Test); `spike3d`-Build sauber; keine neuen Deps, Default-Build unverändert. Auftrag: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md). **Lücken bis „richtig gutes" Texturing → siehe 3D-REST unten.**
|
||||
|
||||
## 📋 Backlog (Priorität grob absteigend)
|
||||
|
||||
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~ — **komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
|
||||
- [x] ~~**Kreis-Toolbar** (trivial)~~ — **erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
|
||||
- [x] ~~**Bogen-Werkzeug** (mittel)~~ — **erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
|
||||
|
||||
- [ ] **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, Items 1–6) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Treppen-Pfeil-Style `filled`, Fenster/Tür-Presets, `swing_invert`) — Feinpolish. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).**
|
||||
- [ ] **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. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT** — `parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings` → `{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen** — `Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** 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).** ✅ **Phase 1 (Wand unter Schnittebene) erledigt (2026-07-05):** `generatePlan.addWallPoche` hat einen `viewOnly`-Zweig — erreicht eine Wand die Grundriss-Schnitthöhe nicht (`wall.height < floor.cutHeight`, z. B. 0.3-m-Brüstung bei 1 m), wird sie nur als Ansichts-Umriss (Haarlinie, `fill:"none"`, keine Schraffur) gezeichnet statt als Schnitt-Poché; normale Wände (≥ Schnitthöhe) unberührt. Segment-/Gehrungs-/Öffnungs-Logik geteilt. +2 Tests (`generatePlan.viewwall.test.ts`, Suite 336). ✅ **Phase 2 (Decke über Ebene = gestrichelte Überkopf-Linie) erledigt (2026-07-05, `39ddd9b`):** der freie Decken-Umriss (Überstände/Balkone, wo keine Wand verdeckt) ist jetzt gestrichelte Haarlinie (`OVERHEAD_DASH`) statt kräftiger Volllinie — BIM-Konvention „Aufsicht auf Bauteil über einem". Decken-FLÄCHE war schon Ansicht (viewHatchId, weiß). +1 Test. **Offen (Phase 3):** per-Component View-/Cut-Weight + eigene View-Schraffur (Slot-Trennung); cut/Aufsicht/Untersicht (Deckenspiegel/Reflected Ceiling Plan); Unterzüge; Feinheiten. Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m):
|
||||
- Bauteil wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten).
|
||||
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
|
||||
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
|
||||
- **Passt konsistent zum bereits existierenden `viewHatchId` (Ansichts-Schraffur) vs. Schnitt-Schraffur** — die Linienstärke-Dualität (View-/Cut-Weight je Component) ist die natürliche Erweiterung derselben Logik.
|
||||
- **Nicht nur cut/view, sondern cut / AUFSICHT / UNTERSICHT (Nutzer):** ein Bauteil sieht von oben anders aus als von unten. Der Grundriss (Blick nach unten) zeigt Bauteile UNTER der Ebene in **Aufsicht** (Oberseite); ein **Deckenspiegel/Reflected Ceiling Plan** (Blick nach oben) zeigt Bauteile ÜBER der Ebene in **Untersicht** (Unterseite — z. B. Kassettendecke, Leuchten). Also je Component potenziell **drei** Darstellungs-Slots (Schnitt / Aufsicht / Untersicht) × {Schraffur + Linienstärke}. Das heutige `viewHatchId` ist faktisch EIN View-Slot und vermischt Auf-/Untersicht; sauber wäre die Trennung. WELCHER Slot gilt, entscheidet die **Blickrichtung der Sicht** (Grundriss ↓ / Deckenspiegel ↑) UND die z-Lage relativ zur Schnittebene.
|
||||
- **Verallgemeinert den Decken-Footprint-Clip** (`a2f6923`): „Decke unter Wand verdeckt" ist ein Spezialfall von „Ansichtsbauteil vs. schneidende/überdeckende Bauteile".
|
||||
- Aufwand: mittel–groß, phasenweise machbar (1: z-Extent-vs-Schnitthöhe-Klassifikation cut/above/below; 2: Ansichtslinie-Weight je Component + Haarlinie/gestrichelt; 3: 30-cm-Wände & Slabs verdrahten). Nicht zu komplex im Konzept — es ist der reguläre CAD/BIM-Weg (ArchiCAD/Vectorworks/Revit). Mit Nutzer Detailgrade/Defaults festlegen.
|
||||
|
||||
|
||||
- [x] ~~**Ebene-Schraffur editierbar**~~ — **gelandet `dcb6ed5`**: Kategorie-Dialog (`App.tsx`, `editor.hatch`-Feld) hat `<select>` auf `cat.hatch` + `HatchSwatch`-Vorschau. Verifiziert vorhanden.
|
||||
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
|
||||
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
|
||||
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
||||
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
|
||||
- **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen.
|
||||
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
|
||||
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~ — **bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
|
||||
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers.
|
||||
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||
- [x] ~~**Linienstile aufräumen**~~ — **erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line`→`thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive`→`thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
|
||||
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
||||
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`.
|
||||
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||
|
||||
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
|
||||
- **Auth + Projekt-Liste:** Supabase Auth (Email/Magic-Link), Projektübersicht, Öffnen/Schliessen.
|
||||
- **Lock-Service:** Tabelle `object_locks (project_id, object_id, user_id, locked_at)` mit Row-Level Security; `reserveObjects(ids[])` / `releaseObjects(ids[])` als Supabase-RPC; Optimistisches Check-in via DB-Constraint (doppelte Reserve → Fehler → UI-Feedback).
|
||||
- **Realtime-Sync:** Supabase Realtime-Channel pro Projekt; bei Freigabe werden die veränderten Objekte (JSON-Patch oder ganzes Objekt-Payload, TBD) gepusht; lokaler Store merged incoming changes sofort.
|
||||
- **Presence:** Wer ist online, wer hat welche Objekte reserviert (farbige User-Badges an reservierten Elementen im Plan).
|
||||
- **Offline-Guard:** Beim Verbindungsabbruch Locks automatisch nach Timeout freigeben (DB-seitig: `locked_at + interval` prüfen).
|
||||
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
|
||||
- **Aufwand:** gross (2–4 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
|
||||
|
||||
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
|
||||
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich erledigt** (`0ca3b1d`, Schachbrett-Textur auf Wänden, group-1-Bind-Group, `MESH_TEXTURED_WGSL`, planare Meter-UVs). **Verbleibende Lücken bis „richtig gut" (Grobschätzung ~18–29 PT):** Bild-Datei-Laden (`image`-Crate, PNG/JPG→GPU) ~1–2 · Material→Textur-Zuordnung (Wandtyp/Layer→Material→Textur-Set, Cache/Atlas oder Bind-Group je Material) ~3–5 · Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~5–8 · Mipmaps (Blit-Pass) + anisotropes Filtern ~1–2 · Web-Pfad (`web.rs`/WebGPU, Bild-Upload via ImageBitmap) ~3–4 · UI zum Zuweisen + Persistenz im Dokumentmodell ~5–8.
|
||||
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~ — **bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
|
||||
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
|
||||
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
||||
|
||||
## ❓ Offene Rückfragen (an den Nutzer)
|
||||
|
||||
- [x] ~~**„aki"** Snap/Endpunkt-Farbe~~ — **entschieden 2026-07-04: Sora #5FA1C9** als endgültiger Default festgeschrieben (`src/theme/accents.ts` `DEFAULT_SNAP_COLOR`, Doc aktualisiert). Frage geschlossen.
|
||||
- [ ] Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT)
|
||||
- [ ] Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering?
|
||||
- [ ] Verifizieren/klären: Isometrie „echte" orthographische Iso (teilw. durch Locked-Iso `cb8fae5` adressiert)? · TopBar-Detailgrad (zoom% ganz aus Footer)? · DOSSIER-Audit welche Features konkret übernommen?
|
||||
|
||||
---
|
||||
|
||||
## ✅ Erledigt (Verlauf, neueste oben)
|
||||
|
||||
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
||||
|
||||
- [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
|
||||
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie** — `addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
|
||||
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
|
||||
- [x] 2026-07-05 **Ribbon Phase 3 (Teil): Werkzeug-Sidebar raus** — `DEFAULT_LEFT_GROUPS` nur noch `attributes` (volle linke Höhe), `LAYOUT_VERSION` 7→8 (gespeicherte Layouts fallen auf neuen Default zurück → sichtbar). Wandtyp-/Deckentyp-Picker von `ToolsPanel` ins `AttributesPanel` verschoben (Nutzer-Entscheid „ins Attribute-Panel") — erscheint bei aktivem Wand-/Decken-Werkzeug (Sektion „Neues Bauteil", auch ohne Auswahl), Host-State unverändert. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), `Dropdown`-Import dort entfernt. tsc + Suite 334 grün. **Offen:** Objektinfo unter Attribute mergen.
|
||||
- [x] 2026-07-05 **Fix: gezeichnete/importierte Kreise+Bögen im WebGL unsichtbar** — der WebGL-Compiler (`glPlan/glPlanCompile.ts`, Default-Renderer) dispatchte `polygon`/`line`/`arc`, aber NICHT `drawingCircle`/`drawingArc` → seit `4ac99d3` (Kreise als echtes `drawingCircle`-Primitiv statt 64-Eck-Polygon) fielen sie im GL still raus (SVG-/WASM-Pfad hatten sie, GL nicht). Zweig ergänzt: bildschirm-adaptive Tessellierung wie beim `arc`-Primitiv (Kreis geschlossen + optionale Vollton-Füllung, Bogen a0..a1 offen). tsc + Suite 334 grün. **Nutzer-Report** („zeichne Kreis, bleibt nicht sichtbar").
|
||||
- [x] 2026-07-05 **Textur-Spike verifiziert erledigt** (`0ca3b1d`) — bei der Backlog-Abarbeitung festgestellt, dass die render3d-Textur-Spike (`RenderStyle::Textured`, Schachbrett, planare Meter-UVs, `MESH_TEXTURED_WGSL` group 1, `spike3d`-`T`-Toggle) bereits vollständig umgesetzt + committet war; `cargo test` 58/59 grün, keine neuen Deps, Alt-Pfad bitgleich. „Als Nächstes"-Eintrag war veraltet → abgehakt; PBR-Restlückenliste (~18–29 PT) in 3D-REST übernommen.
|
||||
- [x] 2026-07-05 **Ribbon-Feinschliff (OCS-Zeile)** — Tabs in die TopBar-Zeile verlegt (`456ebc8`, eine Leiste Chrome+Tabs, Inhalt darunter; Tab-State in App); OCS-Chrome: kleine Wortmarke „dossier." + Quick-Access-Icons für ALLE Datei-/Export-Aktionen statt Burger-Menü, Zeile auf 26px (`4e3b074`); Text-/Font-Formatierung mit der Ansichts-/Zoom-Steuerung auf EINE Leiste gelegt, „Ansichten" als Standard-Tab zuerst + initial aktiv (`fe22cbf`). Alle tsc + 331 Tests grün. Visuelle Abnahme (26px, Icon-Sitz) noch offen.
|
||||
- [x] 2026-07-05 **Ribbon Phase 2: TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll mergen") — die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid+Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart) als `ViewRibbonTab` (in `TopBar.tsx`) in den Ansichten-Tab verschoben; App reicht sie als `viewsContent`-Node an `RibbonBar` (analog Layout-Menü). TopBar ist jetzt schmale globale Leiste (Marke/Ressourcen · Text · Datei/Export/Einstellungen · Fensterknöpfe). `TopBarProps` entsprechend verschlankt. Visuelle Feinabstimmung offen.
|
||||
- [x] 2026-07-05 **Ribbon-UI Phase 1** (`9d6e86d`) — datengetriebene Tab-Leiste (`src/ui/ribbon/`: `ribbonItems.ts` Registry, `RibbonBar.tsx`, `CommandIcon.tsx`) unter der TopBar, additiv (Sidebar bleibt). Tabs 2D·3D·BIM·Ansichten; 2D=Zeichnen(select/line/polyline/rect/circle/arc)+Ändern(move/copy/mirror/offset/trim/join), BIM=Bauteile(wall/window/door/stair/ceiling/room); 3D/Ansichten noch leer. Ein Aktivierungs-Pfad: tool→`onSelectTool`, command→`onRunCommand`(engine.start); Aktiv-Highlight über `activeTool`/`engineView.commandName`. `ToolIcon` aus ToolsPanel exportiert (wiederverwendet).
|
||||
- [x] 2026-07-05 **Attribut-Panel: ein Grid, volle Breite** (`ace0dc6`) — drei getrennte Grids zu EINEM durchgehenden Zwei-Spalten-Grid gemerged (Wertspalte über alle Sektionen bündig), Dropdown-Pills füllen die Wertspalte (fixe Breiten raus), doppeltes Eigen-Padding + zweiter „Attribute"-Titel entfernt. **Nutzer-Report.**
|
||||
- [x] 2026-07-05 **Eigenschaften-Grid OCS-Stil** (`00733d8`) — `.attr-*` als klares Grid: Sektion-Balken, Zeilentrenner, füllende linksbündige Wertfelder (Texte/Zahlen/Dropdowns einheitlich). Erste Stufe der Ribbon-UI-Vision.
|
||||
- [x] 2026-07-05 **Zoom-Scroll-Fix** (`077e774`) — Dokument-Overscroll/Rubberband gesperrt (`html,body,#root { overflow:hidden; overscroll-behavior:none }`); Viewport-Zoom zieht die UI nicht mehr mit (macOS). **Nutzer-Report.**
|
||||
- [x] 2026-07-05 **Kreis- + Bogen-Werkzeug** (`e454eab`, `bd2b12b`) — Kreis-Toolbar (circleCommand gekoppelt) + neues `arcCommand` (3-Klick CCW), beide mit Toolbar-Icon/i18n; rendern glatt via `drawingCircle`/`drawingArc`.
|
||||
|
||||
- [x] 2026-07-05 **DXF-Import: Platzierungsoption** (`dd76ec8`) — ImportDialog fragt „relativ zum Nullpunkt" ODER „in die Mitte der aktuellen Ansicht"; `PlanViewHandle.viewCenterModel()` neu; `runImport` verschiebt den Gesamt-Umriss (bbox-Mitte → Ansichtsmitte). tsc + Suite 331 grün.
|
||||
- [x] 2026-07-05 **Zeichnungen Copy/Paste über Geschosse** (`376aa67`) — Ctrl/Cmd+C kopiert gewählte Drawing2D tief; Ctrl/Cmd+V fügt Klone (neue IDs) auf dem AKTIVEN Geschoss ein und wählt sie. Für z. B. importiertes Mobiliar. Textfeld-Fokus bleibt natives Copy/Paste.
|
||||
- [x] 2026-07-05 **Tauri Drag&Drop + Import-Dialog-Fix** (`c5b5ca6`, `45e19b7`) — `import`-Befehl als `autoRun` (Datei-Dialog öffnet synchron in der Geste, HMR-Falle via Config-Relaunch behoben); `dragDropEnabled:false` (Tauris natives Drag-Drop fing HTML-Drops ab). **Nutzer-bestätigt: Dialog + Text gehen.**
|
||||
- [x] 2026-07-05 **Layout: Befehlszeile in Mitte-Spalte** (`c794fee`) — nur Viewport-breit, Docks gewinnen Höhe. **Nutzer-bestätigt „sieht clean aus".**
|
||||
|
||||
- [x] 2026-07-05 **Textur-Spike render3d** (`RenderStyle::Textured` real) — zweiter Vertex-Pfad `[pos,normal,uv]` aus dem Mesh abgeleitet (Alt-Pfad bitgleich, per Test belegt), prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus Sampler), Pipeline bitidentisch zur Haupt-Pipeline, `spike3d` per `T` umschaltbar. Verifiziert: cargo test 58/59 (inkl. naga-Test) grün, alle 4 Builds sauber. **Visuelle Fenster-Abnahme durch Nutzer bestanden 2026-07-05** (Schachbrett korrekt, `T`-Umschaltung ok). Erster Durchstich der Textur-/PBR-Pipeline; ehrliche Lückenliste im Bericht — `8556037`
|
||||
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
|
||||
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
|
||||
- [x] 2026-07-04 **Snap-Farbe entschieden: Sora #5FA1C9** als endgültiger Default festgeschrieben (`DEFAULT_SNAP_COLOR`, Doc), „aki"-❓ geschlossen.
|
||||
- [x] 2026-07-04 **Mac-Build via Tauri + Queue-Abgleich** — Toolchain auf macOS-Gerät verifiziert, `cad.app`/`cad_0.1.0_aarch64.dmg` gebaut & gestartet. Verifikations-Baseline grün (tsc/vitest 230/cargo 56). Queue-Reconciliation: Toolchain-Blocker, Snap-Slice (`339202b`), Ebene-Schraffur (`dcb6ed5`), Bauteile-Tab-Master-Detail, Einstellungs-Verdrahtung UND Wand-Schicht-Bänder 3D (Option B, `toWalls3d.ts`) waren allesamt **bereits gelandet**, aber in PENDENZEN noch als offen gelistet → abgehakt. (kein Code-Commit)
|
||||
- [x] 2026-07-04 **Öffnungen als echte Boolean-Löcher** (+ Deckentrim-nur-3D) — Fenster/Türen = 1 Wandkörper mit rechteckigen `holes` (Rechteck-Gitter-Zerlegung + Laibungsquads), Segment-Boxen weg; Deckentrim nur noch im 3D-Pfad, Schnitt volle Höhe. Pflicht-Testfall versetzt-überlappende Fenster grün. cargo test 56, vitest 230, build:engine3d + tsc sauber — `1407c68`
|
||||
- [x] 2026-07-04 **Joins Phase 1c** Durchgangswand am T-Stoss echt aufbrechen (spanCutouts) — `c5a344d`
|
||||
- [x] 2026-07-04 **Locked-Iso-Fix** freie Kamera bleibt nach Ortho-Preset orthografisch — `cb8fae5`
|
||||
- [x] 2026-07-04 **3D-Live-Schnittebene** mit schraffierten Schnittflächen — `03f0c40`
|
||||
- [x] 2026-07-04 **Joins Phase 2** Merge-Regel im Schnitt (gleiche Komponente verschmilzt) — `82d354f`
|
||||
-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)
|
||||
|
||||
@@ -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)
|
||||
2. **Spawn:** vier Agents (parallel, unabhängig)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# DOSSIER-Feature-Audit (A1–A6, B1–B4, C1–C3, D1–D3, E)
|
||||
|
||||
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
|
||||
Einzeilern verweist. Abgleich DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen
|
||||
den aktuellen Browser-Port. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
|
||||
Einzeilern verweist. Ursprünglich von einem Opus-Subagent erarbeitet, der das
|
||||
DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen den aktuellen Browser-Port
|
||||
abgeglichen hat — das Ergebnis lag bisher nur in einem Session-Transkript, nicht
|
||||
im Repo. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
|
||||
|
||||
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Ribbon-UI + modulare Bars — Plan
|
||||
|
||||
Nutzer-Vision (2026-07-05, mit OCS/AutoCAD-Ribbon als Referenz). Ziel: klare,
|
||||
einheitliche Werkzeug-/Eigenschaften-Darstellung; Werkzeug-Sidebar entfällt.
|
||||
|
||||
## Zielbild
|
||||
|
||||
- **Ribbon-Oberleiste mit Tabs: 2D · 3D · BIM · Ansichten.** Jeder Tab zeigt
|
||||
gruppierte Werkzeug-/Aktions-Icons (wie OCS Draw/Model/Insert/Annotate/View).
|
||||
- **2D**: Zeichnen (Select/Linie/Polylinie/Rechteck/Kreis/Bogen/Text) · Ändern
|
||||
(Move/Copy/Mirror/Offset/Trim/Join/Boolean).
|
||||
- **3D**: Volumen/Boolean (vorhandene 3D-Aktionen), Kamera-Presets.
|
||||
- **BIM**: Wand/Fenster/Tür/Treppe/Decke/Raum (die Bauteile).
|
||||
- **Ansichten**: Geschoss/Schnitt/Ansicht-Wechsel, Zoom/Einpassen, Render-Modi.
|
||||
- **Werkzeug-Sidebar entfällt** → rechtes Panel (Attribute) bekommt volle Höhe.
|
||||
- **Objektinfo unter die Attribute** mergen (Wandstil-Definition etc. zusammen).
|
||||
- **XYZ-Referenzpunkt-Box oben rechts bleibt** (vom Nutzer ausdrücklich gewünscht).
|
||||
- **Eigenschaften-Grid im OCS-Stil** — ✅ bereits erledigt (`00733d8`): Sektion-
|
||||
Balken, Zeilentrenner, füllende linksbündige Wertfelder (`.attr-*`).
|
||||
|
||||
## Architektur — DATENGETRIEBEN (ermöglicht modulare Custom-Bar)
|
||||
|
||||
Kern: EINE Registry beschreibt alle Ribbon-Elemente; Ribbon-Tabs UND eine
|
||||
benutzerdefinierte Custom-Bar sind bloß zwei Ansichten über dieselbe Registry.
|
||||
|
||||
```
|
||||
RibbonItem =
|
||||
| { kind: "tool"; id: ToolId } // aktiviert ein Werkzeug (onSelectTool)
|
||||
| { kind: "command"; name: string } // startet einen Engine-Befehl (engine.start)
|
||||
| { kind: "action"; id: string; run: () => void } // freie App-Aktion (Zoom, Render-Modus …)
|
||||
|
||||
RibbonGroup = { titleKey: string; items: RibbonItem[] }
|
||||
RibbonTab = { id: "2d"|"3d"|"bim"|"views"; labelKey: string; groups: RibbonGroup[] }
|
||||
RIBBON: RibbonTab[]
|
||||
```
|
||||
|
||||
- Icons: `ToolIcon` (vorhanden) für `tool`-Items; für `command`/`action` eine
|
||||
kleine Icon-Map (SVG) analog `ToolsPanel`.
|
||||
- Aktivierung: `tool` → `onSelectTool(id)` · `command` → `engine.start(name)` ·
|
||||
`action` → `run()`. Aktiv-Highlight über `activeTool` bzw. laufenden Befehl.
|
||||
- **Custom-Bar (modular)**: der Nutzer wählt beliebige `RibbonItem`s in eine
|
||||
persistierte Liste (`viewSlice`/Projekt); eine `CustomBar`-Komponente rendert
|
||||
genau diese. Gleiche Item-Typen, gleiche Aktivierung — kein Sonderweg.
|
||||
|
||||
## Phasen
|
||||
|
||||
1. ✅ **Gerüst + 2D/BIM-Tab** (`9d6e86d`). `RibbonBar` unter der TopBar (additiv).
|
||||
Datengetriebene Registry (`src/ui/ribbon/ribbonItems.ts`), 2D-Tab (Zeichnen +
|
||||
Ändern) und BIM-Tab (Bauteile) gefüllt, Tab-State lokal, Modify-Icons ergänzt.
|
||||
2. ✅ **TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll
|
||||
mergen"). Die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid +
|
||||
Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart)
|
||||
sind als `ViewRibbonTab` (in `TopBar.tsx`) in den „Ansichten"-Tab gewandert;
|
||||
App reicht sie als `viewsContent`-Node an `RibbonBar` (wie das Layout-Menü).
|
||||
Die **Tab-Reiter sitzen in der TopBar-Zeile** (`RibbonTabs`, in TopBar
|
||||
gerendert; Tab-State in App), der Ribbon-Inhalt (`RibbonBar`) folgt darunter →
|
||||
nur ZWEI Bänder. Die TopBar ist eine **schmale Leiste** (Höhe 40px): Marke/
|
||||
Ressourcen · Tabs · Datei/Export/Einstellungen · Fensterknöpfe. Die Text-/
|
||||
Font-Formatierung (`TextGroup`) liegt im eigenen **„Text"-Tab** (Reihenfolge
|
||||
2D·3D·BIM·Text·Ansichten; `RibbonBar.tabContent` trägt „text" + „views").
|
||||
- **Offen (visuell iterieren):** ob Zoom/Massstab zusätzlich als Dauer-Anzeige
|
||||
(Statusleiste) sichtbar sein soll; Default-Tab (aktuell 2D); 3D-Tab noch
|
||||
leer (Kamera-Presets/3D-Aktionen füllen).
|
||||
3. **Sidebar raus** (ToolsPanel aus Default-Layout) + rechtes Panel volle Höhe;
|
||||
Objektinfo unter Attribute mergen.
|
||||
4. **Modulare Custom-Bar**: Item-Picker + persistierte Custom-Bar-Ansicht.
|
||||
|
||||
## Hinweise / Randbedingungen
|
||||
|
||||
- Additiv bauen: bestehende Kommandozeile + Nummern-Shortcuts + `ToolsPanel`
|
||||
bleiben funktionsfähig, bis Phase 3 die Sidebar bewusst entfernt.
|
||||
- Werkzeug-Kopplung Tool↔Befehl existiert bereits (`TOOL_COMMAND`) — Ribbon nutzt
|
||||
denselben `onSelectTool`/`engine.start`-Pfad (EIN Pfad, keine Duplikate).
|
||||
- Empfehlung: Phase 1+ in fokussierter Session mit visueller Iteration im Tauri.
|
||||
@@ -146,6 +146,6 @@ Neue Keys in de.ts UND en.ts: `text.style`, `text.font`, `text.size`,
|
||||
## 7. Gate (Pflicht)
|
||||
|
||||
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
|
||||
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.
|
||||
KEIN Commit.
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
# truck-Integration — Profil-Extrusion (B-Rep → 3D-Mesh)
|
||||
|
||||
> Übergabe-Dokument für die Implementierung. Lies zuerst CONVENTIONS.md.
|
||||
|
||||
## Ziel
|
||||
|
||||
Nutzer zeichnen im Grundriss ein geschlossenes Polygon (z. B. L-Profil einer Stütze)
|
||||
und können es als 3D-Körper auf eine Höhe extrudieren. Das Ergebnis erscheint sofort
|
||||
im 3D-Viewport neben Wänden und Decken.
|
||||
|
||||
**MVP-Scope (dieser Auftrag):**
|
||||
- Neue Rust/WASM-Crate `trucksolid` mit zwei Funktionen: `extrude_polygon` + `extrude_circle`
|
||||
- Neue TS-Wrapper-Datei `src/engine/truckSolid.ts` (WASM laden + typisierte API)
|
||||
- KEIN neues Werkzeug, KEIN UI — nur die Geometrie-Schicht. UI kommt in einem
|
||||
separaten Folgeauftrag.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Kontext
|
||||
|
||||
```
|
||||
src-tauri/
|
||||
render3d/ ← bestehend: wgpu-Renderer (wasm-pack → pkg3d/)
|
||||
kernel2d/ ← bestehend: 2D-Geometrie-Kern
|
||||
trucksolid/ ← NEU (dieser Auftrag)
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs
|
||||
|
||||
src/engine/
|
||||
pkg3d/ ← render3d WASM-Output
|
||||
pkgTruck/ ← NEU: trucksolid WASM-Output (wasm-pack Ziel)
|
||||
truckSolid.ts ← NEU: TS-Wrapper
|
||||
|
||||
src/plan/
|
||||
toWalls3d.ts ← bestehend: baut RenderScene; emitMeshes() muss erweitert werden
|
||||
```
|
||||
|
||||
Das Koordinatensystem in DOSSIER ist **Modell-Meter: X = rechts, Y = oben (Grundriss),
|
||||
Z = Höhe**. render3d erwartet `positions` im gleichen System — `mesh.rs` macht intern
|
||||
`(x, y, z) → (x, z, y)` für wgpu (Y-up world). Truck arbeitet in 3D; wir bauen
|
||||
das Polygon in der XY-Ebene (z=0) und extrudieren nach +Z.
|
||||
|
||||
---
|
||||
|
||||
## 1 — Rust-Crate `src-tauri/trucksolid/`
|
||||
|
||||
### 1.1 Cargo.toml
|
||||
|
||||
Exakt dasselbe Muster wie `src-tauri/kernel2d/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace] # entkoppelt vom cad-tauri-Workspace (Pflicht)
|
||||
|
||||
[package]
|
||||
name = "trucksolid"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Profil-Extrusion via truck (B-Rep → tesselliertes Mesh für render3d)"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
web = [
|
||||
"dep:wasm-bindgen",
|
||||
"dep:serde_json",
|
||||
"dep:console_error_panic_hook",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
# truck: nur die stabilen Crates (KEIN truck-modeling — Booleans instabil)
|
||||
truck-geometry = "0.3"
|
||||
truck-topology = "0.3"
|
||||
truck-rendimesh = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
**Wichtig:** `truck-modeling` (die Crate mit Boolean-Operatoren) wird NICHT eingebunden.
|
||||
Nur `truck-geometry`, `truck-topology` und `truck-rendimesh` — die sind stabil.
|
||||
|
||||
### 1.2 src/lib.rs — Kern-Logik
|
||||
|
||||
#### Eingabe/Ausgabe-Typen
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Input: flaches Array [x0,y0, x1,y1, ...] in Modell-Metern (Grundriss-XY).
|
||||
/// Muss ≥ 3 Punkte enthalten; Wiederholung des ersten Punkts am Ende optional.
|
||||
/// Reihenfolge CCW oder CW — truck normalisiert selbst.
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtrudePolyInput {
|
||||
pub points: Vec<f64>, // flat: [x0,y0, x1,y1, ...]
|
||||
pub height: f64, // Extrusionshöhe in Metern (> 0)
|
||||
}
|
||||
|
||||
/// Output: trianguliertes Mesh, kompatibel mit render3d::types::MeshInput.
|
||||
/// positions: flat [x0,y0,z0, x1,y1,z1, ...] in Modell-Metern
|
||||
/// indices: Dreiecks-Indizes (je 3 = 1 Dreieck), 0-basiert
|
||||
#[derive(Serialize)]
|
||||
pub struct MeshOutput {
|
||||
pub positions: Vec<f32>,
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Polygon-Extrusion (truck-API)
|
||||
|
||||
```rust
|
||||
use truck_geometry::prelude::*;
|
||||
use truck_topology::*;
|
||||
|
||||
pub fn extrude_polygon_core(pts: &[(f64, f64)], height: f64) -> Result<MeshOutput, String> {
|
||||
if pts.len() < 3 { return Err("min 3 Punkte".into()); }
|
||||
if height <= 0.0 { return Err("height muss > 0 sein".into()); }
|
||||
|
||||
// 1. Punkte in der XY-Ebene (z=0) als truck-Vertices
|
||||
let verts: Vec<Vertex<Point3>> = pts.iter()
|
||||
.map(|(x, y)| Vertex::new(Point3::new(*x, *y, 0.0)))
|
||||
.collect();
|
||||
|
||||
// 2. Kanten: je zwei aufeinanderfolgende Vertices verbinden, Ring schliessen
|
||||
let edges: Vec<Edge<_, Line<Point3>>> = verts.windows(2)
|
||||
.chain(std::iter::once([verts.last().unwrap(), &verts[0]].as_slice()))
|
||||
.map(|w| {
|
||||
let p0 = *w[0].point();
|
||||
let p1 = *w[1].point();
|
||||
Edge::new(&w[0], &w[1], Line(p0, p1))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. Wire (geschlossener Kantenzug)
|
||||
let wire = Wire::from_iter(edges);
|
||||
|
||||
// 4. Planare Face aus dem Wire
|
||||
// truck-topology::Face::new braucht die äussere Boundary + optional Holes
|
||||
let face = Face::new(vec![wire]);
|
||||
|
||||
// 5. Lineare Extrusion: tsweep entlang +Z um `height`
|
||||
let solid = face.tsweep(&Vector3::new(0.0, 0.0, height));
|
||||
|
||||
// 6. Tessellieren (chord-tolerance in Metern — 0.005 = 5 mm)
|
||||
use truck_rendimesh::MeshedShape;
|
||||
let mesh = solid.triangulation(0.005).to_polygon();
|
||||
|
||||
// 7. positions + indices extrahieren
|
||||
let positions: Vec<f32> = mesh.positions().iter()
|
||||
.flat_map(|p| [p.x as f32, p.y as f32, p.z as f32])
|
||||
.collect();
|
||||
let indices: Vec<u32> = mesh.tri_faces().iter()
|
||||
.flat_map(|tri| tri.iter().map(|idx| idx.pos as u32))
|
||||
.collect();
|
||||
|
||||
Ok(MeshOutput { positions, indices })
|
||||
}
|
||||
```
|
||||
|
||||
> **Achtung:** Die exakte truck-API (Methoden-Namen, Trait-Imports, tsweep-Signatur)
|
||||
> kann je nach veröffentlichter Version leicht abweichen. Prüfe die Docs von
|
||||
> `truck-topology 0.3` und `truck-rendimesh 0.3` auf docs.rs. Die Struktur oben
|
||||
> ist das Ziel-Pattern — passe Methoden-Signaturen an, wenn der Compiler es verlangt.
|
||||
> Ändere NICHT die Eingabe/Ausgabe-JSON-Struktur.
|
||||
|
||||
#### Kreis-Extrusion (Zylinder)
|
||||
|
||||
```rust
|
||||
pub fn extrude_circle_core(cx: f64, cy: f64, r: f64, height: f64) -> Result<MeshOutput, String> {
|
||||
// Kreis in XY-Ebene tessellieren (N Segmente je nach r),
|
||||
// dann wie extrude_polygon_core aufrufen.
|
||||
// Alternativ: truck-geometry::Ellipse/Circle direkt nutzen, falls vorhanden.
|
||||
let n = (2.0 * std::f64::consts::PI * r / 0.02).ceil().max(16.0) as usize;
|
||||
let pts: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
|
||||
(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
extrude_polygon_core(&pts, height)
|
||||
}
|
||||
```
|
||||
|
||||
#### WASM-Bindings (Feature "web")
|
||||
|
||||
```rust
|
||||
#[cfg(feature = "web")]
|
||||
mod web {
|
||||
use super::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// Extrudiert ein Polygon-Profil.
|
||||
/// `input_json`: `{ "points": [x0,y0,…], "height": 2.5 }`
|
||||
/// Rückgabe: `{ "positions": […], "indices": […] }` oder throws JsError.
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_polygon(input_json: &str) -> Result<String, JsError> {
|
||||
let input: ExtrudePolyInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let pts: Vec<(f64, f64)> = input.points.chunks(2)
|
||||
.map(|c| (c[0], c[1]))
|
||||
.collect();
|
||||
let mesh = extrude_polygon_core(&pts, input.height)
|
||||
.map_err(|e| JsError::new(&e))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Extrudiert einen Kreis-Querschnitt (Zylinder).
|
||||
/// `input_json`: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0 }`
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_circle(input_json: &str) -> Result<String, JsError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct In { cx: f64, cy: f64, r: f64, height: f64 }
|
||||
let i: In = serde_json::from_str(input_json)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height)
|
||||
.map_err(|e| JsError::new(&e))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Tests (headless, ohne Feature "web")
|
||||
|
||||
Mindestens diese drei Tests müssen mit `cargo test` grün sein:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quad_extrusion_vertex_count() {
|
||||
// 1m × 1m Quadrat, 2m hoch → 8 Eckpunkte minimum (6 Flächen × 2 Dreiecke)
|
||||
let pts = vec![(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)];
|
||||
let m = extrude_polygon_core(&pts, 2.0).unwrap();
|
||||
assert!(m.positions.len() >= 8 * 3); // ≥ 8 Vertices × 3 floats
|
||||
assert_eq!(m.indices.len() % 3, 0); // vollständige Dreiecke
|
||||
assert!(m.indices.len() >= 12 * 3); // ≥ 12 Dreiecke (Quader)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l_profile_extrusion() {
|
||||
// L-Profil: 6 Punkte
|
||||
let pts = vec![
|
||||
(0.0,0.0),(0.3,0.0),(0.3,0.1),(0.1,0.1),(0.1,0.3),(0.0,0.3),
|
||||
];
|
||||
let m = extrude_polygon_core(&pts, 3.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(m.positions.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_extrusion() {
|
||||
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(m.positions.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_few_points() {
|
||||
assert!(extrude_polygon_core(&[(0.0,0.0),(1.0,0.0)], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_height() {
|
||||
let pts = vec![(0.0,0.0),(1.0,0.0),(0.5,1.0)];
|
||||
assert!(extrude_polygon_core(&pts, 0.0).is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2 — Build-Script (package.json)
|
||||
|
||||
Füge in `package.json` unter `"scripts"` hinzu:
|
||||
|
||||
```json
|
||||
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
|
||||
```
|
||||
|
||||
Und in `src-tauri/Cargo.toml` unter `exclude`:
|
||||
|
||||
```toml
|
||||
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3 — TS-Wrapper `src/engine/truckSolid.ts`
|
||||
|
||||
Gleiche Lade-Pattern wie `src/plan/useWasmPlanRenderer.ts` (render2d) und
|
||||
`src/viewport/useWasm3dRenderer.ts` (render3d):
|
||||
|
||||
```typescript
|
||||
// Lazy-Singleton: WASM einmalig laden, dann gecacht.
|
||||
let modulePromise: Promise<typeof import("../../engine/pkgTruck/trucksolid")> | null = null;
|
||||
|
||||
async function getModule() {
|
||||
if (!modulePromise) {
|
||||
modulePromise = import("../../engine/pkgTruck/trucksolid").then(async (m) => {
|
||||
await m.default(); // WASM-Binary initialisieren
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
export interface ExtrudedMesh {
|
||||
positions: number[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/** Extrudiert ein geschlossenes Polygon-Profil (Modell-Meter XY) um `height` m. */
|
||||
export async function extrudePolygon(
|
||||
points: number[], // flat [x0,y0, x1,y1, ...]
|
||||
height: number,
|
||||
): Promise<ExtrudedMesh> {
|
||||
const m = await getModule();
|
||||
const json = m.extrude_polygon(JSON.stringify({ points, height }));
|
||||
return JSON.parse(json) as ExtrudedMesh;
|
||||
}
|
||||
|
||||
/** Extrudiert einen Kreis-Querschnitt (Zylinder). */
|
||||
export async function extrudeCircle(
|
||||
cx: number, cy: number, r: number, height: number,
|
||||
): Promise<ExtrudedMesh> {
|
||||
const m = await getModule();
|
||||
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height }));
|
||||
return JSON.parse(json) as ExtrudedMesh;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4 — Integration in toWalls3d.ts (Vorbereitung)
|
||||
|
||||
In `src/plan/toWalls3d.ts` ist `RMeshKind` bereits definiert:
|
||||
|
||||
```typescript
|
||||
export type RMeshKind = "terrain" | "imported";
|
||||
```
|
||||
|
||||
Erweitere auf `"extrusion"` (damit der Renderer später eine eigene Farbe/Darstellung
|
||||
wählen kann, auch wenn heute noch kein Unterschied besteht):
|
||||
|
||||
```typescript
|
||||
export type RMeshKind = "terrain" | "imported" | "extrusion";
|
||||
```
|
||||
|
||||
Die `emitMeshes()`-Funktion liest bereits `project.drawings2d` und ähnliche Arrays.
|
||||
Für extrudierte Körper wird es später ein `project.extrudedSolids`-Array geben
|
||||
(oder ähnlich — das ist Teil des UI-Folgeauftrags). Die `emitMeshes()`-Erweiterung
|
||||
kommt dann.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Verifikations-Checkliste
|
||||
|
||||
Bevor du als fertig meldest, müssen alle Punkte grün sein:
|
||||
|
||||
- [ ] `cd src-tauri/trucksolid && cargo test` → alle 5 Tests grün, keine Warnings
|
||||
- [ ] `npm run build:truck` → `src/engine/pkgTruck/trucksolid.js` + `.wasm` erzeugt
|
||||
- [ ] `npx tsc --noEmit` (aus Root) → 0 Fehler
|
||||
- [ ] `npx vitest run` → alle bestehenden Tests weiterhin grün (keine Regression)
|
||||
- [ ] `src-tauri/Cargo.toml` hat `"trucksolid"` im `exclude`-Array
|
||||
- [ ] `package.json` hat `"build:truck"` im `"scripts"`-Block
|
||||
|
||||
---
|
||||
|
||||
## 6 — Was NICHT in diesem Auftrag
|
||||
|
||||
- Kein neues UI/Werkzeug (kommt später)
|
||||
- Kein `truck-modeling` (Boolean-Operationen — instabil upstream)
|
||||
- Kein STEP-Export (kommt in Folgeauftrag wenn Basisschicht steht)
|
||||
- Kein Eintrag in `project.extrudedSolids` oder Store (UI-Auftrag)
|
||||
- Keine Änderungen an render3d, generatePlan, App.tsx
|
||||
|
||||
---
|
||||
|
||||
## 7 — Koordinatensystem-Reminder
|
||||
|
||||
| DOSSIER-Modell | truck (im Code) | render3d (wgpu) |
|
||||
|---|---|---|
|
||||
| X = rechts | X = rechts | X = rechts |
|
||||
| Y = oben (Grundriss) | Y = oben (Grundriss) | Z = oben (Y-up swapped) |
|
||||
| Z = Höhe | Z = Höhe | Y = Höhe |
|
||||
|
||||
`mesh.rs` in render3d macht bereits `(model.x, model.y, model.z) → (x, z, y)` beim
|
||||
Hochladen. Du musst also in truck DOSSIER-Koordinaten verwenden (XY-Ebene = Grundriss,
|
||||
Z = Höhe) — das ist konsistent mit der bestehenden `MeshInput.positions`-Konvention.
|
||||
@@ -1,12 +1,13 @@
|
||||
# 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.
|
||||
|
||||
## COMMIT-REGEL (verbindlich)
|
||||
Dieses Repo darf **keinerlei Fremd-Tool-Hinweise** enthalten — nicht im Code,
|
||||
in Kommentaren oder in der Git-Historie. Kommentare deutsch, Identifier englisch.
|
||||
**Kein Commit ohne Absprache.**
|
||||
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — nicht im Code,
|
||||
in Kommentaren oder in der Git-Historie. Keine `Co-Authored-By`-Trailer, keine
|
||||
„Generated with"-Zeilen, keine Modell-/Agenten-Namen. Kommentare deutsch,
|
||||
Identifier englisch. **Kein Commit ohne Ansage der Hauptinstanz.**
|
||||
|
||||
## Warum
|
||||
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`/
|
||||
`feature/parametric-walls` committen. Isolierter Worktree.
|
||||
- **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).
|
||||
- Ergebnisse/Diffs zurückliefern; Projektleitung committet.
|
||||
- Ergebnisse/Diffs zurückliefern; die Hauptinstanz committet und pflegt das Memory.
|
||||
|
||||
## Gates
|
||||
`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.
|
||||
|
||||
## 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
|
||||
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",
|
||||
"electron": "scripts/electron-shell.sh",
|
||||
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web",
|
||||
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
|
||||
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
|
||||
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
|
||||
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web"
|
||||
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
|
||||
Generated
-2
@@ -1488,10 +1488,8 @@ dependencies = [
|
||||
name = "geometry"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-10
@@ -1,14 +1,10 @@
|
||||
[workspace]
|
||||
members = ["."]
|
||||
# render2d/render3d/geometry sind eigenstaendige Crates, damit sie headless ohne
|
||||
# Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie
|
||||
# liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace —
|
||||
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
|
||||
# nutzen kann. geometry: neu ausgeschlossen (war Member), seit es zu WASM gebaut
|
||||
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
|
||||
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
|
||||
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
|
||||
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"]
|
||||
members = [".", "geometry"]
|
||||
# render2d/render3d sind eigenstaendige Crates (jeweils eigener [workspace]), damit
|
||||
# sie headless ohne Tauri-Toolchain baubar/testbar bleiben. Sie liegen zwar im
|
||||
# src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace — hier ausschliessen,
|
||||
# damit `cad-tauri` sie dennoch per Pfad als (optionale) Abhaengigkeit nutzen kann.
|
||||
exclude = ["render2d", "render3d"]
|
||||
|
||||
[package]
|
||||
name = "cad-tauri"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
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"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Serde-only Geometrie-Kern (Wand-Verschneidungen/Joins) — headless und per wasm-pack (Feature \"web\") zu WASM baubar; Single Source of Truth fuer TS + nativen Host."
|
||||
|
||||
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul benoetigt. rlib:
|
||||
# damit die Crate weiterhin als Pfad-Abhaengigkeit (cad-tauri) und im Test-Build
|
||||
# nutzbar bleibt. Der cdylib-Artefakt-Build auf nativen Zielen ist harmlos (leere
|
||||
# Export-Oberflaeche ohne Feature "web"). Muster: render2d/render3d.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
# Standard: reine serde-Geometrie, headless per `cargo test` pruefbar.
|
||||
default = []
|
||||
# Browser-Bindings: dieselbe Geometrie hinter einer wasm-bindgen-Fassade
|
||||
# (`compute_joins_json`), aus TS via wasm-pack aufgerufen. Baut nur fuer
|
||||
# target wasm32-unknown-unknown sinnvoll. Muster: render3d/Cargo.toml.
|
||||
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
|
||||
[dev-dependencies]
|
||||
|
||||
+21
-165
@@ -9,20 +9,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Browser-Fassade (Feature "web"): JSON rein/raus, damit das TS-Frontend die
|
||||
/// EINE Rust-Join-Implementierung per wasm-pack aufrufen kann (Single Source of
|
||||
/// Truth statt TS-Duplikat). `input_json` = serialisiertes `JoinInput`, Rueckgabe
|
||||
/// = serialisiertes `Vec<WallCuts>`.
|
||||
#[cfg(feature = "web")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn compute_joins_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||
console_error_panic_hook::set_once();
|
||||
let input: JoinInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))?;
|
||||
let out = compute_joins(input);
|
||||
serde_json::to_string(&out).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct Vec2 {
|
||||
pub x: f64,
|
||||
@@ -81,20 +67,6 @@ pub struct LayerCuts {
|
||||
pub end_side: Vec<Option<Line>>,
|
||||
}
|
||||
|
||||
/// Aussparung der DURCHGANGSWAND am materialbewussten T-Stoss -- Rust-Spiegel
|
||||
/// von `SpanCutout` aus `model/joins.ts`. `from`/`to` = Achsen-Intervall (Meter
|
||||
/// ab `wall.start`), `offA`/`offB` = Quer-Offsetbereich (Nah-Putz-Zone) in der
|
||||
/// Offset-Konvention der Schicht-Baender; `offA` ist stets die Nahflaeche.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct SpanCutout {
|
||||
pub from: f64,
|
||||
pub to: f64,
|
||||
#[serde(rename = "offA")]
|
||||
pub off_a: f64,
|
||||
#[serde(rename = "offB")]
|
||||
pub off_b: f64,
|
||||
}
|
||||
|
||||
/// Schnittlinien einer Wand an ihren beiden Achsenden.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WallCuts {
|
||||
@@ -110,11 +82,6 @@ pub struct WallCuts {
|
||||
/// weiterhin `start_cut`/`end_cut` fuer alle Schichten.
|
||||
#[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")]
|
||||
pub layer_cuts: Option<LayerCuts>,
|
||||
/// ADDITIV (Durchgangswand-Seite): Nah-Putz-Aussparungen, durch die ein
|
||||
/// Abzweig-Kern sticht. Leer -> von serde bei Serialisierung weggelassen
|
||||
/// (`skip_serializing_if`), Wand wird unveraendert gezeichnet.
|
||||
#[serde(rename = "spanCutouts", skip_serializing_if = "Vec::is_empty")]
|
||||
pub span_cutouts: Vec<SpanCutout>,
|
||||
}
|
||||
|
||||
/// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von
|
||||
@@ -168,13 +135,6 @@ fn apply_layer_cuts(
|
||||
through_dir: Vec2,
|
||||
sign: f64,
|
||||
off_t: f64,
|
||||
// Durchgangswand-Aussparung (Phase 1c): Index/Start der Durchgangswand,
|
||||
// Achsrichtung + Referenzversatz des Abzweigs -- fuer die Projektion der
|
||||
// Kernbreite auf die Durchgangsachse.
|
||||
through_wall_idx: usize,
|
||||
through_start: Vec2,
|
||||
branch_dir: Vec2,
|
||||
branch_ref_off: f64,
|
||||
) {
|
||||
if branch_layers.is_empty() || through_layers.is_empty() {
|
||||
return;
|
||||
@@ -239,76 +199,17 @@ fn apply_layer_cuts(
|
||||
let merge_cut = Line { point: merge_point, dir: through_dir };
|
||||
|
||||
// Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser
|
||||
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). In einem Block, damit die
|
||||
// mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die
|
||||
// Durchgangswand unten endet.
|
||||
// Nah-Putz-Komponenten der Durchgangswand (dieselbe sign-Seite wie
|
||||
// near_plaster oben): ist die getrimmte Abzweig-Schicht materialgleich mit
|
||||
// einer davon, fuellt der Nah-Putz den Abzweig-Putz an dieser Stelle
|
||||
// durchgehend (spanCutout schneidet nur die KERN-Breite aus, nicht die
|
||||
// schmalen Putzflanken) -> Putz-L OHNE Trennnaht, also KEINE L-Seitenlinie.
|
||||
let near_plaster_comps: Vec<&str> = through_layers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| if sign > 0.0 { *i > backbone_idx } else { *i < backbone_idx })
|
||||
.map(|(_, l)| l.component_id.as_str())
|
||||
.collect();
|
||||
|
||||
{
|
||||
let side_line = merge_cut;
|
||||
let (face_arr, side_arr) = match branch_end {
|
||||
WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
|
||||
WallEndKind::End => (&mut lc.end, &mut lc.end_side),
|
||||
};
|
||||
for i in 0..n {
|
||||
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
|
||||
let adj_merged =
|
||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
|
||||
let merges_with_near_plaster =
|
||||
near_plaster_comps.contains(&branch_layers[i].component_id.as_str());
|
||||
side_arr[i] = if adj_merged && !merges_with_near_plaster {
|
||||
Some(side_line)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand ueber
|
||||
// die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur noetig,
|
||||
// wenn davor Nah-Putz liegt (near_plaster > 0).
|
||||
if near_plaster > 1e-9 {
|
||||
let total_branch: f64 = branch_layers.iter().map(|l| l.thickness).sum();
|
||||
let mut b_off = branch_ref_off - total_branch / 2.0;
|
||||
let mut core_lo = f64::INFINITY;
|
||||
let mut core_hi = f64::NEG_INFINITY;
|
||||
for (i, l) in branch_layers.iter().enumerate() {
|
||||
if merged[i] {
|
||||
if b_off < core_lo {
|
||||
core_lo = b_off;
|
||||
}
|
||||
if b_off + l.thickness > core_hi {
|
||||
core_hi = b_off + l.thickness;
|
||||
}
|
||||
}
|
||||
b_off += l.thickness;
|
||||
}
|
||||
if core_hi > core_lo {
|
||||
// Projektion der Kern-Offsethuelle auf die Durchgangsachse.
|
||||
let n_branch = left_normal(branch_dir);
|
||||
let proj = dot(n_branch, through_dir);
|
||||
let axis_at = dot(sub(axis_point, through_start), through_dir);
|
||||
let e1 = axis_at + core_lo * proj;
|
||||
let e2 = axis_at + core_hi * proj;
|
||||
let off_a = off_t + sign * (t_t / 2.0);
|
||||
let off_b = off_t + sign * (t_t / 2.0 - near_plaster);
|
||||
result[through_wall_idx].span_cutouts.push(SpanCutout {
|
||||
from: e1.min(e2),
|
||||
to: e1.max(e2),
|
||||
off_a,
|
||||
off_b,
|
||||
});
|
||||
}
|
||||
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse).
|
||||
let side_line = merge_cut;
|
||||
let (face_arr, side_arr) = match branch_end {
|
||||
WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
|
||||
WallEndKind::End => (&mut lc.end, &mut lc.end_side),
|
||||
};
|
||||
for i in 0..n {
|
||||
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
|
||||
let adj_merged =
|
||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
|
||||
side_arr[i] = if adj_merged { Some(side_line) } else { None };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +305,6 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
|
||||
start_cut: None,
|
||||
end_cut: None,
|
||||
layer_cuts: None,
|
||||
span_cutouts: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -585,10 +485,6 @@ fn apply_t_junction(
|
||||
u_t,
|
||||
sign,
|
||||
off_t,
|
||||
through_wall_idx,
|
||||
through_wall.start,
|
||||
dir_of(branch_wall),
|
||||
branch_wall.reference_offset,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -679,10 +575,6 @@ fn apply_mid_span_tee(
|
||||
u_w,
|
||||
sign,
|
||||
off_w,
|
||||
w_idx,
|
||||
w.start,
|
||||
dir_of(free_wall),
|
||||
free_wall.reference_offset,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -968,8 +860,8 @@ mod tests {
|
||||
// Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom
|
||||
// W9-artigen Wandtyp: der Backstein-Kern (Index 1) sticht durch den
|
||||
// Nah-Putz und stoppt an der Kern-Nahflaeche (y=0.06), die beiden
|
||||
// Innenputz-Schichten (Index 0/2) werden getrimmt. Da der Nah-Putz der
|
||||
// Durchgangswand materialgleich ist, entsteht KEINE L-Trennnaht.
|
||||
// Innenputz-Schichten (Index 0/2) werden getrimmt und bekommen
|
||||
// zusaetzlich die seitliche L-Linie an derselben Flaeche.
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w_iw("A", 0.0, 0.0, 5.0, 0.0),
|
||||
@@ -1004,54 +896,18 @@ mod tests {
|
||||
let l2 = lc.start[2].expect("Schicht 2 getrimmt");
|
||||
assert!((l0.point.y - 0.075).abs() < 1e-9);
|
||||
assert!((l2.point.y - 0.075).abs() < 1e-9);
|
||||
// ... aber KEINE seitliche L-Linie: der Innenputz der Durchgangswand ist
|
||||
// materialgleich und fuellt den getrimmten Abzweig-Putz durchgehend
|
||||
// (Putz-L ohne Trennnaht). Nur bei materialFREMDEM Nah-Putz stuende hier
|
||||
// eine L-Linie.
|
||||
assert!(lc.start_side[0].is_none());
|
||||
assert!(lc.start_side[2].is_none());
|
||||
// ... UND die seitliche L-Linie an der Rueckgrat-Nahflaeche (y=0.06,
|
||||
// parallel zur x-Achse).
|
||||
let s0 = lc.start_side[0].expect("Schicht 0 hat L-Seitenlinie");
|
||||
let s2 = lc.start_side[2].expect("Schicht 2 hat L-Seitenlinie");
|
||||
assert!(dist(s0, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
|
||||
// Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
|
||||
// Die kollineare Durchgangswand bleibt unangetastet (kein layerCuts).
|
||||
let ca = find(&out, "A");
|
||||
let cc = find(&out, "C");
|
||||
assert!(ca.layer_cuts.is_none());
|
||||
assert!(cc.layer_cuts.is_none());
|
||||
|
||||
// ... bekommt aber die Durchgangswand-Aussparung (Phase 1c): die als
|
||||
// Durchgang gewaehlte Haelfte A wird ueber die Backstein-Kernbreite (0.12)
|
||||
// im Nah-Putz-Bereich (y in [0.06,0.075]) ausgeschnitten. Das Intervall
|
||||
// ist die Projektion der Kernbreite auf die Achse, zentriert am Knoten x=5.
|
||||
assert_eq!(ca.span_cutouts.len(), 1, "A (Durchgang) hat genau eine Aussparung");
|
||||
let sc = ca.span_cutouts[0];
|
||||
assert!((sc.from - 4.94).abs() < 1e-9, "from = 5 - 0.06");
|
||||
assert!((sc.to - 5.06).abs() < 1e-9, "to = 5 + 0.06");
|
||||
assert!(((sc.to - sc.from) - 0.12).abs() < 1e-9, "Intervallbreite = Kernbreite");
|
||||
assert!((sc.off_a - 0.075).abs() < 1e-9, "offA = Nahflaeche");
|
||||
assert!((sc.off_b - 0.06).abs() < 1e-9, "offB = Rueckgrat-Nahflaeche");
|
||||
// Der Abzweig B traegt keine Aussparung (die gehoert der Durchgangswand).
|
||||
assert!(cb.span_cutouts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_span_tee_through_wall_gets_span_cutout() {
|
||||
// Mittelspannen-T-Stoss (wie der reale W9-Fall): Durchgangswand W als EINE
|
||||
// Wand (0,0)->(10,0), Abzweig B trifft mit seinem freien Ende (4,0) mittig
|
||||
// auf ihre Seite. W bekommt EINE Aussparung um x=4, Breite = Kernbreite.
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w_iw("W", 0.0, 0.0, 10.0, 0.0),
|
||||
w_iw("B", 4.0, 3.0, 4.0, 0.0),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
let cw = find(&out, "W");
|
||||
assert_eq!(cw.span_cutouts.len(), 1, "W hat genau eine Aussparung");
|
||||
let sc = cw.span_cutouts[0];
|
||||
assert!((sc.from - 3.94).abs() < 1e-9);
|
||||
assert!((sc.to - 4.06).abs() < 1e-9);
|
||||
assert!((sc.off_a - 0.075).abs() < 1e-9);
|
||||
assert!((sc.off_b - 0.06).abs() < 1e-9);
|
||||
// Der Abzweig selbst hat keine Aussparung.
|
||||
assert!(find(&out, "B").span_cutouts.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -32,7 +32,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
height: 1.0,
|
||||
}],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -43,7 +42,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
},
|
||||
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
|
||||
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
|
||||
@@ -59,7 +57,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
|
||||
@@ -13,14 +13,13 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use render3d::gpu::{RenderStyle, Renderer};
|
||||
use render3d::gpu::Renderer;
|
||||
use render3d::math::orbit_eye;
|
||||
use render3d::types::{Camera, Projection, WallInput};
|
||||
|
||||
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::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
/// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen
|
||||
@@ -39,7 +38,6 @@ fn demo_walls() -> Vec<WallInput> {
|
||||
color: grey,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
// Raum 6 x 4 m.
|
||||
vec![
|
||||
@@ -197,9 +195,6 @@ struct App {
|
||||
orbit: Orbit,
|
||||
dragging: bool,
|
||||
last_cursor: (f64, f64),
|
||||
/// Darstellung: false = Shaded (Default, unveraendert), true = Textured
|
||||
/// (prozedurales Schachbrett auf den Wandflaechen). Per Taste `T` umschaltbar.
|
||||
textured: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
@@ -227,27 +222,6 @@ impl ApplicationHandler for App {
|
||||
state.resize(size.width, size.height);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::KeyboardInput {
|
||||
event:
|
||||
KeyEvent {
|
||||
physical_key: PhysicalKey::Code(KeyCode::KeyT),
|
||||
state: ElementState::Pressed,
|
||||
repeat: false,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => {
|
||||
// `T` schaltet Shaded <-> Textured um (Laufzeit, kein Re-Meshing:
|
||||
// der Renderer haelt beide Vertex-Puffer bereit).
|
||||
self.textured = !self.textured;
|
||||
let style = if self.textured {
|
||||
RenderStyle::Textured
|
||||
} else {
|
||||
RenderStyle::Shaded
|
||||
};
|
||||
state.renderer.set_render_style(style);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::MouseInput { state: s, button, .. } => {
|
||||
if button == MouseButton::Left {
|
||||
self.dragging = s == ElementState::Pressed;
|
||||
|
||||
@@ -15,13 +15,11 @@ use wgpu::util::DeviceExt;
|
||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||
use crate::math::{view_projection, Mat4};
|
||||
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh};
|
||||
use crate::mesh::{build_scene_mesh, build_walls_mesh};
|
||||
use crate::section::SectionPlane;
|
||||
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::types::{
|
||||
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_WGSL};
|
||||
use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX};
|
||||
|
||||
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
|
||||
/// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und
|
||||
@@ -32,9 +30,7 @@ pub enum RenderStyle {
|
||||
Shaded,
|
||||
/// Clay-Look: einheitlich helles Material, beleuchtet.
|
||||
White,
|
||||
/// Echte Bild-Textur (prozedurales Schachbrett) auf den Wandflaechen,
|
||||
/// beleuchtet wie `Shaded` (eigene Pipeline + Textur-Bind-Group group 1,
|
||||
/// siehe `textured_pipeline`).
|
||||
/// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake).
|
||||
Textured,
|
||||
/// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen.
|
||||
Wireframe,
|
||||
@@ -136,55 +132,6 @@ struct MeshBuffers {
|
||||
index_count: u32,
|
||||
}
|
||||
|
||||
/// GPU-seitige Puffer des TEXTURIERTEN Wand-Meshes (`RenderStyle::Textured`).
|
||||
/// Eigenes Vertex-Layout `[pos vec3, normal vec3, uv vec2]`
|
||||
/// (`TEXTURED_FLOATS_PER_VERTEX`), sonst wie `MeshBuffers`. Zweiter, additiver
|
||||
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
|
||||
struct TexturedMeshBuffers {
|
||||
vbo: wgpu::Buffer,
|
||||
ibo: wgpu::Buffer,
|
||||
index_count: u32,
|
||||
}
|
||||
|
||||
/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln.
|
||||
const TEXTURE_SIZE: u32 = 256;
|
||||
|
||||
/// Erzeugt ein 256x256-RGBA-Schachbrett IM CODE (kein Asset, keine `image`-Crate):
|
||||
/// 8x8 Felder abwechselnd hell/dunkel, mit einem duennen Raster als Kachel-Grenze.
|
||||
/// Rueckgabe = flaches RGBA8-Byte-Array (`4 * TEXTURE_SIZE * TEXTURE_SIZE`), direkt
|
||||
/// fuer `queue.write_texture` (Zeilen sind `4 * TEXTURE_SIZE` Byte breit, ohne
|
||||
/// Padding, weil `256 * 4 = 1024` bereits ein Vielfaches von 256 ist). Bewusst
|
||||
/// selbststaendig, damit der Spike ohne Dateipfade/Asset-Pipeline laeuft.
|
||||
fn build_checker_texture() -> Vec<u8> {
|
||||
let size = TEXTURE_SIZE as usize;
|
||||
// 8 Felder je Kante -> jedes Feld 32 Texel breit. Das macht 1 Textur-Kachel
|
||||
// (= 1 m Welt, siehe UV-Massstab) in ein 8x8-Schachbrett sichtbar.
|
||||
let cell = size / 8;
|
||||
let mut data = vec![0u8; size * size * 4];
|
||||
// Zwei Grautoene als Schachbrett + eine dunkle Rasterlinie an den Feldgrenzen.
|
||||
let light = [220u8, 220, 224, 255];
|
||||
let dark = [150u8, 152, 160, 255];
|
||||
let line = [70u8, 72, 80, 255];
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let checker = ((x / cell) + (y / cell)) % 2 == 0;
|
||||
// Duenne Rasterlinie (2 Texel) exakt auf den Feldkanten — betont die
|
||||
// weltmassstaebliche Kachelung an Gehrungen/Ecken sichtbar.
|
||||
let on_line = (x % cell) < 2 || (y % cell) < 2;
|
||||
let px = if on_line {
|
||||
line
|
||||
} else if checker {
|
||||
light
|
||||
} else {
|
||||
dark
|
||||
};
|
||||
let o = (y * size + x) * 4;
|
||||
data[o..o + 4].copy_from_slice(&px);
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// GPU-seitiger Vertex-Buffer einer LineList (kein Indexpuffer). Format
|
||||
/// [pos vec3, color vec3] — genutzt fuer das Bodengitter UND die Modell-Kanten
|
||||
/// (identisches Layout/Shader, siehe grid.rs / edges.rs).
|
||||
@@ -225,29 +172,9 @@ pub struct Renderer {
|
||||
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
||||
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
||||
cap_pipeline: wgpu::RenderPipeline,
|
||||
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
|
||||
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
|
||||
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
|
||||
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
||||
/// Shader/Layout unterscheiden sich.
|
||||
textured_pipeline: wgpu::RenderPipeline,
|
||||
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler.
|
||||
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende).
|
||||
texture_bind_group: wgpu::BindGroup,
|
||||
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim
|
||||
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine
|
||||
/// Queue bekommt.
|
||||
texture: wgpu::Texture,
|
||||
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim
|
||||
/// ersten `render` einmalig gesetzt (lazy Upload).
|
||||
texture_uploaded: bool,
|
||||
bind_group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
mesh: Option<MeshBuffers>,
|
||||
/// Texturiertes Wand-Mesh (`[pos, normal, uv]`) — zweiter, additiver Puffer
|
||||
/// neben `mesh`. Nur im Stil `Textured` gezeichnet; bei `upload_*` zusammen mit
|
||||
/// `mesh` erzeugt. None = kein Mesh geladen.
|
||||
textured_mesh: Option<TexturedMeshBuffers>,
|
||||
/// Modell-Kanten (Feature-Edges, edges.rs) als LineList. Einmalig bei
|
||||
/// `set_model`/`upload_*` erzeugt und gecacht (nicht je Frame). Gezeichnet in
|
||||
/// den Stilen wireframe/hidden.
|
||||
@@ -535,144 +462,6 @@ impl Renderer {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// ── Texturierter Wand-Pfad (RenderStyle::Textured) ──────────────────────
|
||||
// Bild-Textur-Bind-Group-Layout (group 1): sampelbare 2D-Float-Textur +
|
||||
// Filter-Sampler. `Globals` bleibt group 0 (oben) UND unveraendert.
|
||||
let texture_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("texture.layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Pipeline-Layout des texturierten Pfades: group 0 = Globals (wie oben),
|
||||
// group 1 = Textur. Reihenfolge = Bindungs-Index, deshalb beide hier.
|
||||
let textured_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("3d.textured.layout"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout), Some(&texture_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let textured_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("mesh_textured.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(MESH_TEXTURED_WGSL.into()),
|
||||
});
|
||||
// Vertex-Layout: [pos vec3, normal vec3, uv vec2], stride 8*4.
|
||||
let textured_vertex_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: (TEXTURED_FLOATS_PER_VERTEX * 4) as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
|
||||
};
|
||||
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
|
||||
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
|
||||
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
|
||||
// Pipeline-State kompatibel sein (sonst wgpu-Validierungspanik). Einziger
|
||||
// Unterschied: Shader/Vertex-Layout und das zusaetzliche group-1-Layout.
|
||||
let textured_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("mesh.textured.pipeline"),
|
||||
layout: Some(&textured_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &textured_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[textured_vertex_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &textured_module,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: SAMPLE_COUNT,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb,
|
||||
// damit die im Shader gesampelten Werte im linearen Raum landen (die
|
||||
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
|
||||
// Hochladen der Texel (queue.write_texture) passiert einmalig beim ersten
|
||||
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
|
||||
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
|
||||
// spike3d) unveraendert.
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("checker.texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: TEXTURE_SIZE,
|
||||
height: TEXTURE_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("checker.sampler"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
address_mode_w: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("texture.bind"),
|
||||
layout: &texture_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&texture_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("globals.buffer"),
|
||||
size: std::mem::size_of::<Globals>() as u64,
|
||||
@@ -694,14 +483,9 @@ impl Renderer {
|
||||
grid_pipeline,
|
||||
highlight_pipeline,
|
||||
cap_pipeline,
|
||||
textured_pipeline,
|
||||
texture_bind_group,
|
||||
texture,
|
||||
texture_uploaded: false,
|
||||
bind_group,
|
||||
uniform,
|
||||
mesh: None,
|
||||
textured_mesh: None,
|
||||
edges: None,
|
||||
grid: None,
|
||||
highlight: None,
|
||||
@@ -745,7 +529,6 @@ impl Renderer {
|
||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
|
||||
if mesh.indices.is_empty() {
|
||||
self.mesh = None;
|
||||
self.textured_mesh = None;
|
||||
self.edges = None;
|
||||
return;
|
||||
}
|
||||
@@ -781,27 +564,6 @@ impl Renderer {
|
||||
ibo,
|
||||
index_count: mesh.indices.len() as u32,
|
||||
});
|
||||
|
||||
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je
|
||||
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten
|
||||
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad,
|
||||
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit.
|
||||
let tex_mesh = textured_from_mesh(&mesh);
|
||||
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.textured.vbo"),
|
||||
contents: bytemuck::cast_slice(&tex_mesh.verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let tibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.textured.ibo"),
|
||||
contents: bytemuck::cast_slice(&tex_mesh.indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
self.textured_mesh = Some(TexturedMeshBuffers {
|
||||
vbo: tvbo,
|
||||
ibo: tibo,
|
||||
index_count: tex_mesh.indices.len() as u32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Setzt die Richtung ZUM Directional-Light (Sonne, world). Das hemisphaerische
|
||||
@@ -1021,33 +783,6 @@ impl Renderer {
|
||||
self.globals.mode[0] = self.style.mesh_mode();
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
|
||||
|
||||
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
|
||||
// Queue bekommt — Signatur unveraendert). Danach steht sie fuer den Stil
|
||||
// `Textured` bereit; kostet ab dem zweiten Frame nichts mehr.
|
||||
if !self.texture_uploaded {
|
||||
let texel_data = build_checker_texture();
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&texel_data,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(4 * TEXTURE_SIZE),
|
||||
rows_per_image: Some(TEXTURE_SIZE),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: TEXTURE_SIZE,
|
||||
height: TEXTURE_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
self.texture_uploaded = true;
|
||||
}
|
||||
|
||||
self.ensure_depth(device, w, h);
|
||||
self.ensure_msaa(device, w, h);
|
||||
let depth_view = &self.depth.as_ref().unwrap().view;
|
||||
@@ -1090,20 +825,7 @@ impl Renderer {
|
||||
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
|
||||
// die folgenden Kanten sauber obenauf liegen.
|
||||
if self.style.draws_faces() {
|
||||
if self.style == RenderStyle::Textured {
|
||||
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
|
||||
// ([pos, normal, uv]) + zusaetzliche Textur-Bind-Group (group 1).
|
||||
// Globals bleiben group 0. Fehlt der texturierte Puffer (kein
|
||||
// Mesh geladen), wird nichts gezeichnet.
|
||||
if let Some(t) = &self.textured_mesh {
|
||||
pass.set_pipeline(&self.textured_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_bind_group(1, &self.texture_bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, t.vbo.slice(..));
|
||||
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
||||
}
|
||||
} else if let Some(m) = &self.mesh {
|
||||
if let Some(m) = &self.mesh {
|
||||
let face_pipeline = if self.style == RenderStyle::Hidden {
|
||||
&self.hidden_face_pipeline
|
||||
} else {
|
||||
|
||||
@@ -38,27 +38,23 @@ pub use math::{
|
||||
view_matrix, view_projection, Mat4,
|
||||
};
|
||||
pub use mesh::{
|
||||
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh,
|
||||
build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate,
|
||||
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab,
|
||||
extrude_wall, triangulate,
|
||||
};
|
||||
pub use section::{
|
||||
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
|
||||
};
|
||||
pub use types::{
|
||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput,
|
||||
TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
|
||||
FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mesh::{
|
||||
build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
|
||||
};
|
||||
use super::types::{
|
||||
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
|
||||
use super::types::{Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX};
|
||||
|
||||
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
||||
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
|
||||
@@ -71,7 +67,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,67 +79,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]
|
||||
fn eine_wand_hat_quader_zaehlung() {
|
||||
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
|
||||
@@ -170,7 +104,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
},
|
||||
]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
|
||||
@@ -249,7 +182,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
extrude_wall(&mut mesh, &w);
|
||||
@@ -271,7 +203,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
@@ -375,97 +306,6 @@ mod tests {
|
||||
assert_eq!(mesh.indices.len(), INDICES_PER_BOX);
|
||||
}
|
||||
|
||||
// --- Echte Loecher in EINEM Wandkoerper (WallInput::holes) -----------------
|
||||
|
||||
#[test]
|
||||
fn zwei_versetzte_ueberlappende_loecher_in_einem_koerper() {
|
||||
// KERNFALL: EINE Wand (Laenge 4, Hoehe 2.5) mit ZWEI Loechern, deren
|
||||
// Achsen-Intervalle sich UEBERLAPPEN, aber auf UNTERSCHIEDLICHER Hoehe
|
||||
// liegen — Fenster A u=[1.0,2.0] z=[0.9,2.1], Fenster B u=[1.5,2.5]
|
||||
// z=[0.3,0.7]. Das kann die Segment-Zerlegung nicht abbilden; als Loecher
|
||||
// in EINEM Koerper muss es sauber zerfallen (keine Geometrie in den
|
||||
// Loechern, Aussenmasse erhalten, beide Laibungen vollstaendig).
|
||||
let mut w = wall_x(4.0, 0.2, 2.5);
|
||||
w.holes = vec![
|
||||
Hole { from: 1.0, to: 2.0, z_bottom: 0.9, z_top: 2.1 },
|
||||
Hole { from: 1.5, to: 2.5, z_bottom: 0.3, z_top: 0.7 },
|
||||
];
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
|
||||
// Mehr als ein Vollkoerper (Loecher fuegen Teilrechtecke + Laibungen hinzu).
|
||||
assert!(mesh.vertex_count() > VERTS_PER_BOX);
|
||||
// Index-Integritaet.
|
||||
let max_idx = *mesh.indices.iter().max().unwrap();
|
||||
assert!((max_idx as usize) < mesh.vertex_count());
|
||||
|
||||
// Aussenmasse unveraendert (die soliden Teilrechtecke decken den Rand ab).
|
||||
let (min, max) = mesh.bounds();
|
||||
assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5, "u 0..4");
|
||||
assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5, "z 0..2.5");
|
||||
assert!((min[2] + 0.1).abs() < 1e-5 && (max[2] - 0.1).abs() < 1e-5, "Dicke +/-0.1");
|
||||
|
||||
// KEIN Vertex liegt (mit Sicherheitsabstand) im Inneren eines der Loecher.
|
||||
let holes = [(1.0f32, 2.0f32, 0.9f32, 2.1f32), (1.5, 2.5, 0.3, 0.7)];
|
||||
let m = 0.05;
|
||||
let n = mesh.vertex_count();
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
for (a0, a1, zb, zt) in holes {
|
||||
let inside =
|
||||
p[0] > a0 + m && p[0] < a1 - m && p[1] > zb + m && p[1] < zt - m;
|
||||
assert!(!inside, "Vertex {i} bei {p:?} liegt im Loch [{a0},{a1}]x[{zb},{zt}]");
|
||||
}
|
||||
}
|
||||
|
||||
// Beide Loecher besitzen ihre vier inneren Laibungen: fuer jede Loch-Kante
|
||||
// muss mindestens ein Vertex GENAU auf der Kante (in u bzw. z) UND im
|
||||
// jeweils anderen Loch-Intervall liegen (die Laibungsflaeche).
|
||||
let has_vert = |pred: &dyn Fn([f32; 3]) -> bool| (0..n).any(|i| pred(vert(&mesh, i).0));
|
||||
for (a0, a1, zb, zt) in holes {
|
||||
// linke/rechte Laibung: Vertex bei u==a0 bzw. u==a1, z im Loch.
|
||||
assert!(
|
||||
has_vert(&|p| (p[0] - a0).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
|
||||
"linke Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
assert!(
|
||||
has_vert(&|p| (p[0] - a1).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
|
||||
"rechte Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
// untere/obere Laibung: Vertex bei z==zb bzw. z==zt, u im Loch.
|
||||
assert!(
|
||||
has_vert(&|p| (p[1] - zb).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
|
||||
"untere Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
assert!(
|
||||
has_vert(&|p| (p[1] - zt).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
|
||||
"obere Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuer_loch_bis_zum_boden_ohne_untere_laibung() {
|
||||
// Tuer-Loch reicht bis zum Boden (z_bottom == base_elevation 0): die
|
||||
// untere Kante liegt am Wandrand -> KEINE untere Laibung, stattdessen eine
|
||||
// Luecke im Boden-Streifen. Unter der Kopfhoehe (z_top 2.1) darf im
|
||||
// Tuerbereich keine Geometrie liegen.
|
||||
let mut w = wall_x(4.0, 0.2, 2.5);
|
||||
w.holes = vec![Hole { from: 1.0, to: 1.8, z_bottom: 0.0, z_top: 2.1 }];
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
let n = mesh.vertex_count();
|
||||
let m = 0.05;
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
let in_span = p[0] > 1.0 + m && p[0] < 1.8 - m;
|
||||
if in_span {
|
||||
assert!(
|
||||
p[1] < m || p[1] > 2.1 - 1e-4,
|
||||
"Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe im Loch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ecken-Verschneidung (Gehrung) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -485,7 +325,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
let b = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
@@ -496,7 +335,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
let mesh = build_walls_mesh(&[a, b]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
|
||||
@@ -556,7 +394,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0]),
|
||||
@@ -585,7 +422,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0], 0.0),
|
||||
@@ -674,7 +510,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: layers(),
|
||||
holes: vec![],
|
||||
};
|
||||
let mut b = a.clone();
|
||||
b.end = [0.0, 3.0];
|
||||
@@ -992,14 +827,5 @@ mod tests {
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&cap_module)
|
||||
.unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
|
||||
// Texturierter Wand-Shader (RenderStyle::Textured, Bild-Textur in group 1)
|
||||
// ebenso headless validieren — faengt Textur-/Sampler-Bindungsfehler ab.
|
||||
let tex_src = super::shaders::MESH_TEXTURED_WGSL;
|
||||
let tex_module = naga::front::wgsl::parse_str(tex_src)
|
||||
.unwrap_or_else(|e| panic!("textured: WGSL-Parse-Fehler: {e:?}"));
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&tex_module)
|
||||
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,7 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::openings::{split_range_by_voids, MIN_SPAN};
|
||||
use crate::types::{
|
||||
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, TexturedMesh, WallInput, WallLayer,
|
||||
FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
|
||||
@@ -110,16 +109,7 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
|
||||
|
||||
let z0 = wall.base_elevation;
|
||||
let z1 = wall.base_elevation + wall.height;
|
||||
// Mit echten Loechern (`holes`) wird die Wand NICHT an Oeffnungen segmentiert,
|
||||
// sondern als EIN voller Achsen-Abschnitt extrudiert; die Loecher stanzt
|
||||
// `extrude_layer_segment_with_holes` in die Langseiten (holes/openings
|
||||
// schliessen sich aus, siehe `types::WallInput::holes`).
|
||||
let has_holes = !wall.holes.is_empty();
|
||||
let segments = if has_holes {
|
||||
vec![(0.0, length, z0, z1)]
|
||||
} else {
|
||||
wall_solid_segments(length, &wall.openings, z0, z1)
|
||||
};
|
||||
let segments = wall_solid_segments(length, &wall.openings, z0, z1);
|
||||
|
||||
// Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus
|
||||
// der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten.
|
||||
@@ -149,20 +139,10 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
|
||||
// Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig).
|
||||
let start_cut = if a0 <= 1e-6 { miters.start } else { None };
|
||||
let end_cut = if a1 >= length - 1e-6 { miters.end } else { None };
|
||||
if has_holes {
|
||||
// Voller Achsen-Abschnitt (a0=0, a1=length) mit echten Loechern
|
||||
// in den Langseiten — pro Schicht-Band dieselben Loecher (quer
|
||||
// gestapelt bilden ihre Laibungen die volle Loch-Tiefe).
|
||||
extrude_layer_segment_with_holes(
|
||||
mesh, wall, n, length, y0, y1, off_a, off_b, start_cut, end_cut, layer.color,
|
||||
&wall.holes,
|
||||
);
|
||||
} else {
|
||||
extrude_layer_segment(
|
||||
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
|
||||
layer.color,
|
||||
);
|
||||
}
|
||||
extrude_layer_segment(
|
||||
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
|
||||
layer.color,
|
||||
);
|
||||
}
|
||||
off_a = off_b;
|
||||
}
|
||||
@@ -304,328 +284,6 @@ fn extrude_layer_segment(
|
||||
push_quad(mesh, b3, b0, t0, t3, start_normal, color);
|
||||
}
|
||||
|
||||
/// Haengt ein Quad (vier Ecken als Ring, beliebige Reihenfolge) als zwei
|
||||
/// orientierte Dreiecke an — Winding via `push_tri_oriented` an `want_normal`
|
||||
/// ausgerichtet (robust gegen die Eck-Reihenfolge, anders als `push_quad`).
|
||||
/// Genutzt fuer die Loch-Zerlegung (Langseiten-Teilrechtecke, Deckel-/Boden-
|
||||
/// Streifen, Laibungen), wo die Ecken aus Gitter-Koordinaten kommen.
|
||||
fn push_quad_oriented(
|
||||
mesh: &mut Mesh,
|
||||
a: [f32; 3],
|
||||
b: [f32; 3],
|
||||
c: [f32; 3],
|
||||
d: [f32; 3],
|
||||
want_normal: [f32; 3],
|
||||
color: Rgb,
|
||||
) {
|
||||
push_tri_oriented(mesh, a, b, c, want_normal, color);
|
||||
push_tri_oriented(mesh, a, c, d, want_normal, color);
|
||||
}
|
||||
|
||||
/// Zerlegt das Rechteck `[u_lo,u_hi] x [z_lo,z_hi]` MINUS der `holes`
|
||||
/// (`(u0,u1,z0,z1)`) in achsparallele, paarweise disjunkte Teilrechtecke (die
|
||||
/// SOLIDEN Bereiche). Verfahren: Koordinaten-Kompression — alle Loch-Kanten in u
|
||||
/// und z bilden ein Gitter; jede Gitterzelle, deren Mittelpunkt in KEINEM Loch
|
||||
/// liegt, ist ein solides Teilrechteck. Dadurch koennen sich Loecher in u
|
||||
/// ueberlappen und auf unterschiedlicher Hoehe liegen (Kernfall zweier versetzt
|
||||
/// uebereinanderliegender Fenster), ohne dass degenerierte oder ueberlappende
|
||||
/// Teilrechtecke entstehen.
|
||||
fn solid_subrects(
|
||||
u_lo: f32,
|
||||
u_hi: f32,
|
||||
z_lo: f32,
|
||||
z_hi: f32,
|
||||
holes: &[(f32, f32, f32, f32)],
|
||||
) -> Vec<(f32, f32, f32, f32)> {
|
||||
let sort_dedup = |mut v: Vec<f32>| -> Vec<f32> {
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
v.dedup_by(|a, b| (*a - *b).abs() <= MIN_SPAN);
|
||||
v
|
||||
};
|
||||
let mut us = vec![u_lo, u_hi];
|
||||
let mut zs = vec![z_lo, z_hi];
|
||||
for (a0, a1, hb, ht) in holes {
|
||||
us.push(*a0);
|
||||
us.push(*a1);
|
||||
zs.push(*hb);
|
||||
zs.push(*ht);
|
||||
}
|
||||
let us = sort_dedup(us);
|
||||
let zs = sort_dedup(zs);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..us.len().saturating_sub(1) {
|
||||
let (ua, ub) = (us[i], us[i + 1]);
|
||||
if ub - ua <= MIN_SPAN {
|
||||
continue;
|
||||
}
|
||||
for j in 0..zs.len().saturating_sub(1) {
|
||||
let (za, zb) = (zs[j], zs[j + 1]);
|
||||
if zb - za <= MIN_SPAN {
|
||||
continue;
|
||||
}
|
||||
let cu = (ua + ub) * 0.5;
|
||||
let cz = (za + zb) * 0.5;
|
||||
let in_hole = holes
|
||||
.iter()
|
||||
.any(|(a0, a1, hb, ht)| cu > *a0 && cu < *a1 && cz > *hb && cz < *ht);
|
||||
if !in_hole {
|
||||
out.push((ua, ub, za, zb));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Zieht die (ggf. ueberlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert
|
||||
/// die verbleibenden Teilintervalle (sortiert, disjunkt). Fuer die Deckel-/
|
||||
/// Boden-/Kappen-Flaechen, in denen ein randstaendiges Loch eine Luecke reisst.
|
||||
fn subtract_intervals(lo: f32, hi: f32, gaps: &[(f32, f32)]) -> Vec<(f32, f32)> {
|
||||
let mut gs: Vec<(f32, f32)> = gaps
|
||||
.iter()
|
||||
.map(|(a, b)| (a.max(lo), b.min(hi)))
|
||||
.filter(|(a, b)| b - a > MIN_SPAN)
|
||||
.collect();
|
||||
gs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = lo;
|
||||
for (a, b) in gs {
|
||||
if a > cursor + MIN_SPAN {
|
||||
out.push((cursor, a));
|
||||
}
|
||||
cursor = cursor.max(b);
|
||||
}
|
||||
if hi > cursor + MIN_SPAN {
|
||||
out.push((cursor, hi));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extrudiert EIN Schicht-Band (volle Achse `0..length`, Hoehe `[y0,y1]`,
|
||||
/// Dicken-Versatz `[off_a,off_b]`) mit ECHTEN rechteckigen LOECHERN (`holes`,
|
||||
/// siehe `types::Hole`) statt der Oeffnungs-Segmentierung. Aufbau:
|
||||
/// • Langseiten (+n bei `off_a`, −n bei `off_b`): das Flaechen-Rechteck MINUS
|
||||
/// der Loecher, per `solid_subrects` in Teilrechtecke zerlegt (je 2 Dreiecke).
|
||||
/// • Deckel (`y1`) / Boden (`y0`): voller Streifen minus der u-Intervalle der
|
||||
/// Loecher, die die jeweilige Kante beruehren (z. B. eine Tuer am Boden).
|
||||
/// • End-Kappen (`a=0`/`a=length`, gemitert): volle Hoehe minus der z-Intervalle
|
||||
/// randstaendiger Loecher (Loecher liegen normalerweise im Inneren → volle
|
||||
/// Kappe, unveraendert gegenueber der Gehrung).
|
||||
/// • Laibungen je Loch: vier Quads (links/rechts/unten/oben) — aber nur fuer die
|
||||
/// Seiten, die im Wand-INNEREN liegen (die randstaendige Seite hat stattdessen
|
||||
/// die Luecke in Deckel/Boden/Kappe, keine Laibung).
|
||||
/// Winding/Normalen konsistent ueber `push_quad_oriented`/`push_tri_oriented`
|
||||
/// (Backface-Culling + Beleuchtung korrekt).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn extrude_layer_segment_with_holes(
|
||||
mesh: &mut Mesh,
|
||||
wall: &WallInput,
|
||||
n: Point2,
|
||||
length: f32,
|
||||
y0: f32,
|
||||
y1: f32,
|
||||
off_a: f32,
|
||||
off_b: f32,
|
||||
start_cut: Option<MiterLine>,
|
||||
end_cut: Option<MiterLine>,
|
||||
color: Rgb,
|
||||
holes: &[Hole],
|
||||
) {
|
||||
let ux = wall.end[0] - wall.start[0];
|
||||
let uz = wall.end[1] - wall.start[1];
|
||||
let ul = (ux * ux + uz * uz).sqrt().max(1e-9);
|
||||
let u: Point2 = [ux / ul, uz / ul];
|
||||
|
||||
let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s, p[1] + n[1] * s] };
|
||||
let clip = |base: Point2, cut: Option<MiterLine>| -> Point2 {
|
||||
match cut {
|
||||
None => base,
|
||||
Some(c) => line_intersect_2(base, u, c.point, c.dir).unwrap_or(base),
|
||||
}
|
||||
};
|
||||
// Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv`; NUR an den echten
|
||||
// Wandenden (a≈0 / a≈length) gegen die Gehrungslinie verschnitten — innere
|
||||
// Loch-Kanten bleiben rechtwinklig (unabhaengig von der Gehrung).
|
||||
let g_at = |a: f32, offv: f32| -> Point2 {
|
||||
let base = off(axis_point(wall, length, a), offv);
|
||||
if a <= 1e-6 {
|
||||
clip(base, start_cut)
|
||||
} else if a >= length - 1e-6 {
|
||||
clip(base, end_cut)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
};
|
||||
let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] };
|
||||
|
||||
// Loecher auf [0,length] x [y0,y1] klemmen, entartete verwerfen.
|
||||
let mut hs: Vec<(f32, f32, f32, f32)> = Vec::new();
|
||||
for h in holes {
|
||||
let a0 = h.from.max(0.0);
|
||||
let a1 = h.to.min(length);
|
||||
let zb = h.z_bottom.max(y0);
|
||||
let zt = h.z_top.min(y1);
|
||||
if a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN {
|
||||
hs.push((a0, a1, zb, zt));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Langseiten (+n / −n): Rechteck-Gitter minus Loecher. ──────────────────
|
||||
let np = [n[0], 0.0, n[1]];
|
||||
let nn = [-n[0], 0.0, -n[1]];
|
||||
for (ua, ub, za, zb) in solid_subrects(0.0, length, y0, y1, &hs) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_a), za),
|
||||
w(g_at(ub, off_a), za),
|
||||
w(g_at(ub, off_a), zb),
|
||||
w(g_at(ua, off_a), zb),
|
||||
np,
|
||||
color,
|
||||
);
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_b), za),
|
||||
w(g_at(ub, off_b), za),
|
||||
w(g_at(ub, off_b), zb),
|
||||
w(g_at(ua, off_b), zb),
|
||||
nn,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Deckel (y1) und Boden (y0): voller Streifen minus randstaendiger Loecher.
|
||||
let top_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(_, _, _, zt)| *zt >= y1 - MIN_SPAN)
|
||||
.map(|(a0, a1, _, _)| (*a0, *a1))
|
||||
.collect();
|
||||
for (ua, ub) in subtract_intervals(0.0, length, &top_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_a), y1),
|
||||
w(g_at(ub, off_a), y1),
|
||||
w(g_at(ub, off_b), y1),
|
||||
w(g_at(ua, off_b), y1),
|
||||
[0.0, 1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
let bot_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(_, _, zb, _)| *zb <= y0 + MIN_SPAN)
|
||||
.map(|(a0, a1, _, _)| (*a0, *a1))
|
||||
.collect();
|
||||
for (ua, ub) in subtract_intervals(0.0, length, &bot_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_a), y0),
|
||||
w(g_at(ub, off_a), y0),
|
||||
w(g_at(ub, off_b), y0),
|
||||
w(g_at(ua, off_b), y0),
|
||||
[0.0, -1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
// ── End-Kappen (a=0 / a=length): volle Hoehe minus randstaendiger Loecher. ─
|
||||
// Aussen-Normale je Kappe geometrisch aus den vollen Kappen-Ecken (wie im
|
||||
// rechtwinkligen/gehrten Fall in `extrude_layer_segment`).
|
||||
let start_normal = quad_normal(
|
||||
w(g_at(0.0, off_b), y0),
|
||||
w(g_at(0.0, off_a), y0),
|
||||
w(g_at(0.0, off_b), y1),
|
||||
);
|
||||
let start_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(a0, _, _, _)| *a0 <= MIN_SPAN)
|
||||
.map(|(_, _, zb, zt)| (*zb, *zt))
|
||||
.collect();
|
||||
for (za, zb) in subtract_intervals(y0, y1, &start_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(0.0, off_b), za),
|
||||
w(g_at(0.0, off_a), za),
|
||||
w(g_at(0.0, off_a), zb),
|
||||
w(g_at(0.0, off_b), zb),
|
||||
start_normal,
|
||||
color,
|
||||
);
|
||||
}
|
||||
let end_normal = quad_normal(
|
||||
w(g_at(length, off_a), y0),
|
||||
w(g_at(length, off_b), y0),
|
||||
w(g_at(length, off_a), y1),
|
||||
);
|
||||
let end_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(_, a1, _, _)| *a1 >= length - MIN_SPAN)
|
||||
.map(|(_, _, zb, zt)| (*zb, *zt))
|
||||
.collect();
|
||||
for (za, zb) in subtract_intervals(y0, y1, &end_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(length, off_a), za),
|
||||
w(g_at(length, off_b), za),
|
||||
w(g_at(length, off_b), zb),
|
||||
w(g_at(length, off_a), zb),
|
||||
end_normal,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ──────────
|
||||
for &(a0, a1, zb, zt) in &hs {
|
||||
// Linke Laibung (bei a0), Normale +u (in die Oeffnung).
|
||||
if a0 > MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a0, off_a), zb),
|
||||
w(g_at(a0, off_b), zb),
|
||||
w(g_at(a0, off_b), zt),
|
||||
w(g_at(a0, off_a), zt),
|
||||
[u[0], 0.0, u[1]],
|
||||
color,
|
||||
);
|
||||
}
|
||||
// Rechte Laibung (bei a1), Normale −u.
|
||||
if a1 < length - MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a1, off_a), zb),
|
||||
w(g_at(a1, off_b), zb),
|
||||
w(g_at(a1, off_b), zt),
|
||||
w(g_at(a1, off_a), zt),
|
||||
[-u[0], 0.0, -u[1]],
|
||||
color,
|
||||
);
|
||||
}
|
||||
// Untere Laibung (bei zb = Bruestungsoberkante), Normale +Y.
|
||||
if zb > y0 + MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a0, off_a), zb),
|
||||
w(g_at(a1, off_a), zb),
|
||||
w(g_at(a1, off_b), zb),
|
||||
w(g_at(a0, off_b), zb),
|
||||
[0.0, 1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
// Obere Laibung (bei zt = Sturzunterkante), Normale −Y.
|
||||
if zt < y1 - MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a0, off_a), zt),
|
||||
w(g_at(a1, off_a), zt),
|
||||
w(g_at(a1, off_b), zt),
|
||||
w(g_at(a0, off_b), zt),
|
||||
[0.0, -1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aussen-Normale eines planaren, CCW-von-aussen georderten Quads `(a,b,_,d)`
|
||||
/// (nur die drei ersten Ecken werden gebraucht: `d` ist die vierte, ueber `a`
|
||||
/// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe
|
||||
@@ -917,72 +575,6 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
|
||||
mesh
|
||||
}
|
||||
|
||||
/// TEXTURIERTER Wand-Pfad (`RenderStyle::Textured`): liefert DIESELBE Geometrie wie
|
||||
/// `build_walls_mesh` — Positionen und Normalen bitgleich, gleiche Vertex-/Dreiecks-
|
||||
/// Reihenfolge — aber mit einer planaren, WELTMASSSTAEBLICHEN UV je Vertex statt der
|
||||
/// Bauteilfarbe. Bewusst ALS ABLEITUNG aus dem fertigen `Mesh` implementiert (nicht
|
||||
/// als parallele Re-Extrusion): so bleibt die paritaets-/reihenfolgesensible
|
||||
/// Geometrie (Gehrungen, Oeffnungen, Loecher, Schichten) die EINE Quelle der
|
||||
/// Wahrheit und der Alt-Pfad `[pos, normal, color]` bleibt bitgleich unangetastet —
|
||||
/// wir tauschen nur den color-Kanal gegen den aus Position+Normale abgeleiteten
|
||||
/// UV-Kanal aus.
|
||||
///
|
||||
/// UV-PROJEKTION (planar, in Metern, damit das Textur-Raster weltmassstaeblich ist,
|
||||
/// z. B. 1 Kachel = 1 m; kein Verzug an Gehrungen/Ecken, weil jede Flaeche planar
|
||||
/// projiziert wird und benachbarte Waende dieselbe Welt-Projektion fortsetzen —
|
||||
/// die Kacheln stossen im Weltraum sauber aneinander):
|
||||
/// - Senkrechte Flaechen (Wand-Mantel, Normale ~ horizontal): `u` = Weglaenge
|
||||
/// entlang der horizontalen Flaechen-Tangente `t = (-n.z, 0, n.x)`
|
||||
/// (`u = dot(pos.xz, t.xz)`), `v` = Hoehe `pos.y`.
|
||||
/// - Waagerechte Flaechen (Deckel/Boden, Normale ~ +/-Y): direkt aus dem
|
||||
/// Grundriss `u = pos.x`, `v = pos.z`.
|
||||
/// Die Wahl der Achse allein aus der Flaechen-Normale (statt aus der Wand-Achse)
|
||||
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
|
||||
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
|
||||
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
|
||||
textured_from_mesh(&build_walls_mesh(walls))
|
||||
}
|
||||
|
||||
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
|
||||
/// (`[pos, normal, uv]`) um: Positionen/Normalen/Indizes werden 1:1 uebernommen,
|
||||
/// die Farbe je Vertex wird durch die planare Welt-UV ersetzt (siehe
|
||||
/// `build_walls_mesh_textured` fuer die Achswahl). Indizes bleiben unveraendert,
|
||||
/// weil sich die Vertex-Anzahl/-Reihenfolge nicht aendert. `pub`, damit die
|
||||
/// GPU-Schicht (`gpu.rs`) das texturierte Pendant AUS DEMSELBEN `Mesh` ableiten
|
||||
/// kann, das sie ohnehin schon gebaut/hochgeladen hat (eine Geometrie-Quelle).
|
||||
pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
|
||||
let n = mesh.vertex_count();
|
||||
let mut verts: Vec<f32> = Vec::with_capacity(n * TEXTURED_FLOATS_PER_VERTEX);
|
||||
for i in 0..n {
|
||||
let b = i * FLOATS_PER_VERTEX;
|
||||
let px = mesh.verts[b];
|
||||
let py = mesh.verts[b + 1];
|
||||
let pz = mesh.verts[b + 2];
|
||||
let nx = mesh.verts[b + 3];
|
||||
let ny = mesh.verts[b + 4];
|
||||
let nz = mesh.verts[b + 5];
|
||||
// Waagerecht (Deckel/Boden) vs. senkrecht (Mantel) an der Vertikal-
|
||||
// Komponente der Normale unterscheiden. Waende sind vertikal extrudiert,
|
||||
// ihre Mantelnormale ist streng horizontal (ny == 0) — die Schwelle 0.5
|
||||
// trennt sauber Deckel/Boden (|ny| == 1) von Mantelflaechen (ny == 0).
|
||||
let (u, v) = if ny.abs() > 0.5 {
|
||||
// Grundriss-Projektion: u = x, v = z (Meter).
|
||||
(px, pz)
|
||||
} else {
|
||||
// Horizontale Flaechen-Tangente t = (-n.z, 0, n.x): u = Weglaenge
|
||||
// entlang t, v = Hoehe. `t` ist ein Einheitsvektor, weil (n.x, n.z)
|
||||
// fuer Mantelflaechen normiert ist.
|
||||
let u = px * (-nz) + pz * nx;
|
||||
(u, py)
|
||||
};
|
||||
verts.extend_from_slice(&[px, py, pz, nx, ny, nz, u, v]);
|
||||
}
|
||||
TexturedMesh {
|
||||
verts,
|
||||
indices: mesh.indices.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
|
||||
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
||||
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
||||
|
||||
@@ -1016,7 +1016,6 @@ mod tests {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -1027,7 +1026,6 @@ mod tests {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
@@ -1284,7 +1282,6 @@ mod tests {
|
||||
height: 1.0,
|
||||
}],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1370,7 +1367,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
};
|
||||
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
|
||||
let plane = SectionPlane::looking_plus_y(-2.0);
|
||||
|
||||
@@ -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:
|
||||
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
||||
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
||||
|
||||
@@ -66,46 +66,6 @@ pub struct WallInput {
|
||||
/// Verhalten unveraendert).
|
||||
#[serde(default)]
|
||||
pub layers: Option<Vec<WallLayer>>,
|
||||
/// Rechteckige LOECHER (Fenster/Tueren) als ECHTE Aussparungen in EINEM
|
||||
/// Wandkoerper (siehe `Hole` + `mesh::extrude_wall*`). Default leer.
|
||||
///
|
||||
/// Anders als `openings` zerlegt das die Wand NICHT in Pfeiler/Bruestung/
|
||||
/// Sturz-Teilquader, sondern stanzt die Loecher in die beiden Langseiten
|
||||
/// (Rechteck-Gitter-Zerlegung + Laibungsflaechen). So bildet EIN Koerper
|
||||
/// beliebig (auch versetzt uebereinander) angeordnete Oeffnungen ab — das
|
||||
/// kann die Segment-Zerlegung nicht. Genutzt vom 3D-Viewer-Pfad der
|
||||
/// TS-Ableitung (`toWalls3d`, `layeredWalls:true`); der Schnitt-Pfad
|
||||
/// (`layeredWalls:false`) bleibt bei `openings`/Segmentierung.
|
||||
///
|
||||
/// `holes` und `openings` schliessen sich im TS-Emitter gegenseitig aus; ist
|
||||
/// `holes` gesetzt, ueberspringt `mesh.rs` die Oeffnungs-Segmentierung und
|
||||
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
|
||||
#[serde(default)]
|
||||
pub holes: Vec<Hole>,
|
||||
}
|
||||
|
||||
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
|
||||
/// `WallInput::holes`.
|
||||
///
|
||||
/// KONVENTION: `from`/`to` sind Achsen-Meter ab `WallInput::start` (0..Wandlaenge,
|
||||
/// dieselbe Achse wie `Opening::from`/`to`); `z_bottom`/`z_top` sind ABSOLUTE
|
||||
/// Weltmeter (NICHT relativ zu `base_elevation` — anders als `Opening::sill`).
|
||||
/// Eine Tuer ist der Sonderfall `z_bottom == base_elevation` (Loch bis zur
|
||||
/// Wand-Unterkante). Loecher liegen definitionsgemaess im Wand-INNEREN und
|
||||
/// beruehren die gemiterten Enden nicht — die Gehrung (`compute_wall_miters`)
|
||||
/// bleibt davon unberuehrt (siehe `mesh`-Moduldoc).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct Hole {
|
||||
/// Start des Loch-Intervalls entlang der Wandachse (Meter ab `start`).
|
||||
pub from: f32,
|
||||
/// Ende des Loch-Intervalls entlang der Wandachse (Meter ab `start`).
|
||||
pub to: f32,
|
||||
/// Absolute Unterkante des Lochs (Weltmeter).
|
||||
#[serde(default, rename = "zBottom")]
|
||||
pub z_bottom: f32,
|
||||
/// Absolute Oberkante des Lochs (Weltmeter).
|
||||
#[serde(default, rename = "zTop")]
|
||||
pub z_top: f32,
|
||||
}
|
||||
|
||||
/// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`.
|
||||
@@ -333,30 +293,6 @@ pub struct Mesh {
|
||||
/// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe.
|
||||
pub const FLOATS_PER_VERTEX: usize = 9;
|
||||
|
||||
/// Ausgabe der TEXTURIERTEN Mesh-Erzeugung (`RenderStyle::Textured`): interleaved
|
||||
/// [pos.xyz, normal.xyz, uv.xy] je Vertex plus Indexpuffer. Ein ZWEITER, additiver
|
||||
/// Vertex-Pfad neben `Mesh` — der bestehende `[pos, normal, color]`-Pfad (`Mesh`)
|
||||
/// bleibt dadurch bitgleich unangetastet. Genutzt nur mit den GPU-Features
|
||||
/// (`render`/`window`); serde-frei (reine GPU-Zwischenform), damit der Default-Bau
|
||||
/// ohne GPU nichts davon zieht.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TexturedMesh {
|
||||
/// Interleaved Vertices: je 8 f32 = [px,py,pz, nx,ny,nz, u,v].
|
||||
pub verts: Vec<f32>,
|
||||
/// Dreiecks-Indizes (0-basiert auf die Vertices).
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
|
||||
pub const TEXTURED_FLOATS_PER_VERTEX: usize = 8;
|
||||
|
||||
impl TexturedMesh {
|
||||
/// Anzahl Vertices im Puffer.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
self.verts.len() / TEXTURED_FLOATS_PER_VERTEX
|
||||
}
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Anzahl Vertices im Puffer.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
|
||||
@@ -10,13 +10,9 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "cad",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"dragDropEnabled": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+94
-300
@@ -69,9 +69,7 @@ import type { Pick3dHit } from "./viewport/Wasm3DViewport";
|
||||
import { ResourceManager } from "./ui/ResourceManager";
|
||||
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
|
||||
import { HatchSwatch } from "./ui/hatchPreview";
|
||||
import { TopBar, ViewRibbonTab, Ribbon3dTab } from "./ui/TopBar";
|
||||
import { RibbonBar } from "./ui/ribbon/RibbonBar";
|
||||
import type { RibbonTabId } from "./ui/ribbon/ribbonItems";
|
||||
import { TopBar } from "./ui/TopBar";
|
||||
import { Dropdown } from "./ui/Dropdown";
|
||||
import type { DropdownItem } from "./ui/Dropdown";
|
||||
import { TextEditorDialog } from "./ui/TextEditorDialog";
|
||||
@@ -214,8 +212,6 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
|
||||
line: "line",
|
||||
polyline: "polyline",
|
||||
rect: "rect",
|
||||
circle: "circle",
|
||||
arc: "arc",
|
||||
};
|
||||
/** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */
|
||||
function coupledCommandFor(id: ToolId): string | null {
|
||||
@@ -230,68 +226,6 @@ function promptCount(): number | null {
|
||||
return Number.isFinite(n) && n >= 1 ? n : null;
|
||||
}
|
||||
|
||||
/** localStorage-Schlüssel der modularen Custom-Bar (Ribbon-Tab „Eigene"). */
|
||||
const RIBBON_CUSTOM_KEY = "dossier.ribbon.customItems";
|
||||
|
||||
/** Persistierte Custom-Bar-Item-Schlüssel lesen (leer bei Fehler/Erststart). */
|
||||
function loadCustomItems(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RIBBON_CUSTOM_KEY);
|
||||
const arr = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Custom-Bar-Item-Schlüssel persistieren (Fehler still schlucken). */
|
||||
function saveCustomItems(items: string[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(RIBBON_CUSTOM_KEY, JSON.stringify(items));
|
||||
} catch {
|
||||
// localStorage nicht verfügbar (privater Modus o. ä.) → ignorieren.
|
||||
}
|
||||
}
|
||||
|
||||
/** Umriss (bbox) aller Drawing2D-Geometrien in Modell-Metern (null = leer). */
|
||||
function drawingsBBox(
|
||||
drawings: Drawing2D[],
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const acc = (x: number, y: number) => {
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||
};
|
||||
for (const d of drawings) {
|
||||
const g = d.geom;
|
||||
if (g.shape === "line") { acc(g.a.x, g.a.y); acc(g.b.x, g.b.y); }
|
||||
else if (g.shape === "polyline") for (const p of g.pts) acc(p.x, p.y);
|
||||
else if (g.shape === "rect") { acc(g.min.x, g.min.y); acc(g.max.x, g.max.y); }
|
||||
else if (g.shape === "circle" || g.shape === "arc") {
|
||||
acc(g.center.x - g.r, g.center.y - g.r);
|
||||
acc(g.center.x + g.r, g.center.y + g.r);
|
||||
} else if (g.shape === "text") acc(g.at.x, g.at.y);
|
||||
}
|
||||
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
|
||||
}
|
||||
|
||||
/** Ein Drawing2D um (dx,dy) in Modell-Metern verschieben (alle Formen). */
|
||||
function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
|
||||
const mv = (p: Vec2): Vec2 => ({ x: p.x + dx, y: p.y + dy });
|
||||
const g = d.geom;
|
||||
switch (g.shape) {
|
||||
case "line": return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
||||
case "polyline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||||
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||
default: return d;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache
|
||||
// umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t).
|
||||
@@ -381,22 +315,6 @@ export default function App() {
|
||||
// Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je
|
||||
// Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView).
|
||||
const [activeTool, setActiveTool] = useState<ToolId>("select");
|
||||
// Aktiver Ribbon-Tab (2D/3D/BIM/Ansichten). Liegt hier (nicht in der RibbonBar),
|
||||
// weil die Tab-Reiter in der TopBar-Zeile sitzen und der Ribbon-Inhalt darunter
|
||||
// — beide teilen sich denselben aktiven Tab.
|
||||
const [ribbonTab, setRibbonTab] = useState<RibbonTabId>("views");
|
||||
// Modulare Custom-Bar (Ribbon-Tab „Eigene"): persistierte Item-Schlüssel. Der
|
||||
// Picker im Tab wählt Werkzeuge/Befehle an/ab (localStorage-persistiert).
|
||||
const [customItems, setCustomItems] = useState<string[]>(loadCustomItems);
|
||||
const onToggleCustomItem = (key: string) => {
|
||||
setCustomItems((prev) => {
|
||||
const next = prev.includes(key)
|
||||
? prev.filter((k) => k !== key)
|
||||
: [...prev, key];
|
||||
saveCustomItems(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(
|
||||
project.wallTypes[0].id,
|
||||
);
|
||||
@@ -431,11 +349,6 @@ export default function App() {
|
||||
selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null;
|
||||
const setSelectedDrawingId = (id: string | null) =>
|
||||
setSelectedDrawingIds(id ? [id] : []);
|
||||
// Zwischenablage für 2D-Zeichenelemente (Ctrl/Cmd+C/V). Kopiert die GEWÄHLTEN
|
||||
// Drawing2D tief; Einfügen legt Klone mit neuen IDs auf dem AKTIVEN Geschoss
|
||||
// (Zeichnungsebene) an — so lässt sich z. B. importiertes Mobiliar über
|
||||
// Geschosse hinweg wiederverwenden. Kategorie/Stil bleiben erhalten.
|
||||
const [drawingClipboard, setDrawingClipboard] = useState<Drawing2D[]>([]);
|
||||
// Bequemer Einzel-Selektor (erste gewählte Wand).
|
||||
const selectedWallId = selectedWallIds[0] ?? null;
|
||||
// Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine"
|
||||
@@ -853,16 +766,6 @@ export default function App() {
|
||||
setActiveTool(id);
|
||||
};
|
||||
|
||||
// Ribbon-Befehl (Ändern-Gruppe u. a.): startet einen Engine-Befehl OHNE
|
||||
// eigenes Werkzeug (kein ToolId). Läuft über DIESELBE Engine wie das Tippen des
|
||||
// Befehls; das Werkzeug-Highlight fällt auf „Auswahl" zurück, das Aktiv-
|
||||
// Highlight des Ribbon-Befehls spiegelt der laufende Befehl (engineView).
|
||||
const onRunCommand = (name: string) => {
|
||||
engine.start(name);
|
||||
setActiveTool("select");
|
||||
requestAnimationFrame(() => commandLineRef.current?.focus());
|
||||
};
|
||||
|
||||
// 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf
|
||||
// die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern
|
||||
// ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick).
|
||||
@@ -1678,46 +1581,6 @@ export default function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDrawingIds, engine]);
|
||||
|
||||
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt
|
||||
// die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit
|
||||
// neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so
|
||||
// wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im
|
||||
// Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== "c" && k !== "v") return;
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
if (k === "c") {
|
||||
const ids = new Set(selectedDrawingIds);
|
||||
if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy)
|
||||
const picked = project.drawings2d.filter((d) => ids.has(d.id));
|
||||
if (picked.length === 0) return;
|
||||
e.preventDefault();
|
||||
setDrawingClipboard(picked.map((d) => structuredClone(d)));
|
||||
return;
|
||||
}
|
||||
// Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt.
|
||||
if (drawingClipboard.length === 0) return;
|
||||
e.preventDefault();
|
||||
const stamp = Date.now();
|
||||
const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({
|
||||
...structuredClone(d),
|
||||
id: `drawing-${stamp}-${i}`,
|
||||
levelId: activeLevelId,
|
||||
}));
|
||||
importDrawings(pasted);
|
||||
setSelectedDrawingIds(pasted.map((p) => p.id));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDrawingIds, drawingClipboard, activeLevelId, project.drawings2d]);
|
||||
|
||||
// Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten
|
||||
// geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2
|
||||
// geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.)
|
||||
@@ -2228,24 +2091,12 @@ export default function App() {
|
||||
decision.target.kind === "new"
|
||||
? addDrawingLevel(decision.target.name)
|
||||
: decision.target.levelId;
|
||||
let drawings = buildDrawings(
|
||||
const drawings = buildDrawings(
|
||||
decision,
|
||||
levelId,
|
||||
project,
|
||||
activeCategoryCode,
|
||||
);
|
||||
// Platzierung „viewCenter": den Gesamt-Umriss so verschieben, dass seine
|
||||
// Mitte auf die Mitte des aktuellen Ausschnitts fällt (sonst bleibt alles an
|
||||
// den DXF-Koordinaten relativ zum Nullpunkt).
|
||||
if (decision.placement === "viewCenter" && drawings.length) {
|
||||
const center = planViewRef.current?.viewCenterModel();
|
||||
const bb = drawingsBBox(drawings);
|
||||
if (center && bb) {
|
||||
const dx = center.x - (bb.minX + bb.maxX) / 2;
|
||||
const dy = center.y - (bb.minY + bb.maxY) / 2;
|
||||
drawings = drawings.map((d) => translateDrawing(d, dx, dy));
|
||||
}
|
||||
}
|
||||
if (drawings.length) importDrawings(drawings);
|
||||
if (decision.importMeshes && decision.parsed.meshes.length) {
|
||||
addContextObjects(decision.parsed.meshes);
|
||||
@@ -2486,16 +2337,6 @@ export default function App() {
|
||||
onSetOpeningDir: (openingDir) => {
|
||||
if (selection?.kind === "opening") updateOpening(selection.id, { openingDir });
|
||||
},
|
||||
onSetOpeningWingCount: (wingCount) => {
|
||||
if (selection?.kind === "opening")
|
||||
updateOpening(selection.id, { wingCount: Math.max(1, Math.min(4, Math.round(wingCount))) });
|
||||
},
|
||||
onSetOpeningDoorType: (doorType) => {
|
||||
if (selection?.kind === "opening") updateOpening(selection.id, { doorType });
|
||||
},
|
||||
onSetOpeningLintelLines: (lintelLines) => {
|
||||
if (selection?.kind === "opening") updateOpening(selection.id, { lintelLines });
|
||||
},
|
||||
// ── Treppen-Attribute (nur bei selektierter Treppe) ────────────────────
|
||||
onSetStairShape: (shape) => {
|
||||
if (selection?.kind === "stair") updateStair(selection.id, { shape });
|
||||
@@ -2515,9 +2356,6 @@ export default function App() {
|
||||
onSetStairUp: (up) => {
|
||||
if (selection?.kind === "stair") updateStair(selection.id, { up });
|
||||
},
|
||||
onSetStairReferenz: (referenz) => {
|
||||
if (selection?.kind === "stair") updateStair(selection.id, { referenz });
|
||||
},
|
||||
// ── Raum-Attribute (nur bei selektiertem Raum) ─────────────────────────
|
||||
onSetRoomName: (name) => {
|
||||
if (selection?.kind === "room") updateRoom(selection.id, { name });
|
||||
@@ -3233,17 +3071,55 @@ export default function App() {
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{/* Schmale globale Leiste: Marke/Ressourcen · Text-Formatierung ·
|
||||
Datei/Export/Einstellungen. Ansichts-/Zoom-/Darstellungs-Steuerung ist
|
||||
in den „Ansichten"-Ribbon-Tab gewandert (ViewRibbonTab, unten). */}
|
||||
<TopBar
|
||||
project={project}
|
||||
ribbonTab={ribbonTab}
|
||||
onRibbonTab={setRibbonTab}
|
||||
viewToggleEnabled={activeLevel.kind === "floor"}
|
||||
viewType={viewType}
|
||||
onViewType={setViewType}
|
||||
view3d={view3d}
|
||||
onView3d={setView3d}
|
||||
fov={fov}
|
||||
onFov={setFov}
|
||||
detail={detail}
|
||||
onDetail={setDetail}
|
||||
detailEnabled={planActive}
|
||||
scaleDenominator={scaleDenominator}
|
||||
onApplyScale={applyScale}
|
||||
liveScale={planActive ? liveScale : null}
|
||||
zoomPercent={zoomPercent}
|
||||
zoomEnabled={planActive}
|
||||
onFit={fitPlan}
|
||||
onFitSelection={fitSelection}
|
||||
onZoom100={zoomToApplied}
|
||||
onExportPdf={() => setExportOpen(true)}
|
||||
onExportDxf={() => setExportDxfOpen(true)}
|
||||
onExportSchedule={onExportSchedule}
|
||||
renderMode={renderMode}
|
||||
onRenderMode={setRenderMode}
|
||||
renderModeEnabled={viewType === "perspektive"}
|
||||
planColorMode={planColorMode}
|
||||
onPlanColorMode={setPlanColorMode}
|
||||
combos={{
|
||||
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
|
||||
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
|
||||
listLayerCombos,
|
||||
saveLayerCombo: (name) =>
|
||||
saveLayerCombo(name, snapshotLayerVisibility()),
|
||||
loadLayerCombo: (name) => {
|
||||
const codes = loadLayerCombo(name);
|
||||
if (codes) applyLayerVisibility(codes);
|
||||
},
|
||||
deleteLayerCombo,
|
||||
// Zeichnungs-Kombinationen: analog über drawingLevels[].visible.
|
||||
listDrawingCombos,
|
||||
saveDrawingCombo: (name) =>
|
||||
saveDrawingCombo(name, snapshotDrawingVisibility()),
|
||||
loadDrawingCombo: (name) => {
|
||||
const ids = loadDrawingCombo(name);
|
||||
if (ids) applyDrawingVisibility(ids);
|
||||
},
|
||||
deleteDrawingCombo,
|
||||
}}
|
||||
resourcesOpen={resourcesOpen}
|
||||
onToggleResources={() => setResourcesOpen((v) => !v)}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
@@ -3256,85 +3132,7 @@ export default function App() {
|
||||
onApply={(l) => setLayout(reconcileLayout(l))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Ribbon-Oberleiste (2D · 3D · BIM · Ansichten). Der „Ansichten"-Tab trägt
|
||||
die aus der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung
|
||||
(ViewRibbonTab); die Werkzeug-Sidebar bleibt vorerst erhalten. */}
|
||||
<RibbonBar
|
||||
tab={ribbonTab}
|
||||
activeTool={activeTool}
|
||||
activeCommand={engineView.commandName}
|
||||
toolsEnabled={activeLevel.kind === "floor"}
|
||||
onSelectTool={onSelectTool}
|
||||
onRunCommand={onRunCommand}
|
||||
customItems={customItems}
|
||||
onToggleCustomItem={onToggleCustomItem}
|
||||
tabContent={{
|
||||
views: (
|
||||
<ViewRibbonTab
|
||||
viewToggleEnabled={activeLevel.kind === "floor"}
|
||||
viewType={viewType}
|
||||
onViewType={setViewType}
|
||||
view3d={view3d}
|
||||
onView3d={setView3d}
|
||||
fov={fov}
|
||||
onFov={setFov}
|
||||
detail={detail}
|
||||
onDetail={setDetail}
|
||||
detailEnabled={planActive}
|
||||
scaleDenominator={scaleDenominator}
|
||||
onApplyScale={applyScale}
|
||||
liveScale={planActive ? liveScale : null}
|
||||
zoomPercent={zoomPercent}
|
||||
zoomEnabled={planActive}
|
||||
onFit={fitPlan}
|
||||
onFitSelection={fitSelection}
|
||||
onZoom100={zoomToApplied}
|
||||
renderMode={renderMode}
|
||||
onRenderMode={setRenderMode}
|
||||
renderModeEnabled={viewType === "perspektive"}
|
||||
planColorMode={planColorMode}
|
||||
onPlanColorMode={setPlanColorMode}
|
||||
combos={{
|
||||
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
|
||||
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
|
||||
listLayerCombos,
|
||||
saveLayerCombo: (name) =>
|
||||
saveLayerCombo(name, snapshotLayerVisibility()),
|
||||
loadLayerCombo: (name) => {
|
||||
const codes = loadLayerCombo(name);
|
||||
if (codes) applyLayerVisibility(codes);
|
||||
},
|
||||
deleteLayerCombo,
|
||||
// Zeichnungs-Kombinationen: analog über drawingLevels[].visible.
|
||||
listDrawingCombos,
|
||||
saveDrawingCombo: (name) =>
|
||||
saveDrawingCombo(name, snapshotDrawingVisibility()),
|
||||
loadDrawingCombo: (name) => {
|
||||
const ids = loadDrawingCombo(name);
|
||||
if (ids) applyDrawingVisibility(ids);
|
||||
},
|
||||
deleteDrawingCombo,
|
||||
}}
|
||||
textTarget={textTarget}
|
||||
/>
|
||||
),
|
||||
"3d": (
|
||||
<Ribbon3dTab
|
||||
viewToggleEnabled={activeLevel.kind === "floor"}
|
||||
viewType={viewType}
|
||||
onViewType={setViewType}
|
||||
view3d={view3d}
|
||||
onView3d={setView3d}
|
||||
fov={fov}
|
||||
onFov={setFov}
|
||||
renderMode={renderMode}
|
||||
onRenderMode={setRenderMode}
|
||||
renderModeEnabled={viewType === "perspektive"}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
textTarget={textTarget}
|
||||
/>
|
||||
|
||||
<div className="body">
|
||||
@@ -3357,10 +3155,6 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mitte-Spalte: Hauptansicht + Befehlszeile DARUNTER. So ist die
|
||||
Befehlszeile nur so breit wie der Viewport (nicht die ganze App),
|
||||
und die Docks nehmen die volle Body-Höhe ein (inkl. der Zeile). */}
|
||||
<div className="center-col">
|
||||
{/* Hauptansicht (Mitte) — füllt den verbleibenden Platz. Die Plan-
|
||||
Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt
|
||||
das Zoom-Handle entgegen. */}
|
||||
@@ -3425,54 +3219,6 @@ export default function App() {
|
||||
onEdit3dWallTop={onEdit3dWallTop}
|
||||
onEdit3dEnd={onEdit3dEnd}
|
||||
/>
|
||||
{/* Command-Line (Rhino-artig) UNTER dem Viewport — nur Viewport-breit.
|
||||
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
|
||||
<CommandLine
|
||||
ref={commandLineRef}
|
||||
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
|
||||
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
|
||||
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
|
||||
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
|
||||
active={gripEdit ? true : activeTransform ? true : engineView.active}
|
||||
promptKey={
|
||||
gripEdit
|
||||
? "cmd.edit.point"
|
||||
: activeTransform
|
||||
? `transform.hint.${activeTransform.op}.${Math.min(
|
||||
activeTransform.points.length,
|
||||
activeTransform.op === "rotate" ? 2 : 1,
|
||||
)}`
|
||||
: engineView.promptKey
|
||||
}
|
||||
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
|
||||
fields={
|
||||
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
|
||||
}
|
||||
onSubmit={(text) => {
|
||||
if (gripEdit) {
|
||||
const v = parseFloat(text.replace(",", "."));
|
||||
if (isFinite(v)) submitGripEditValue(v);
|
||||
return;
|
||||
}
|
||||
if (activeTransform) {
|
||||
const v = parseFloat(text.replace(",", "."));
|
||||
if (isFinite(v)) submitTransformValue(v);
|
||||
return;
|
||||
}
|
||||
engine.submitText(text);
|
||||
}}
|
||||
onOption={(id) =>
|
||||
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
|
||||
}
|
||||
onCycleField={() =>
|
||||
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
|
||||
}
|
||||
onCancel={() =>
|
||||
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
|
||||
}
|
||||
suggest={(prefix) => engine.suggest(prefix)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes
|
||||
Fenster). Nur gerendert, wenn es Gruppen hat — sonst nutzt die Mitte
|
||||
@@ -3541,6 +3287,54 @@ export default function App() {
|
||||
{/* Drag-Overlay (Geist + Tab-Einfüge-Caret) während eines Tab-Ziehens. */}
|
||||
<DragOverlay drag={drag} />
|
||||
|
||||
{/* Command-Line (Rhino-artig) ÜBER der Statusleiste: Prompt + klickbare
|
||||
Inline-Optionen + Texteingabe + Autocomplete. Tab fokussiert sie. */}
|
||||
<CommandLine
|
||||
ref={commandLineRef}
|
||||
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
|
||||
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
|
||||
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
|
||||
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
|
||||
active={gripEdit ? true : activeTransform ? true : engineView.active}
|
||||
promptKey={
|
||||
gripEdit
|
||||
? "cmd.edit.point"
|
||||
: activeTransform
|
||||
? `transform.hint.${activeTransform.op}.${Math.min(
|
||||
activeTransform.points.length,
|
||||
activeTransform.op === "rotate" ? 2 : 1,
|
||||
)}`
|
||||
: engineView.promptKey
|
||||
}
|
||||
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
|
||||
fields={
|
||||
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
|
||||
}
|
||||
onSubmit={(text) => {
|
||||
if (gripEdit) {
|
||||
const v = parseFloat(text.replace(",", "."));
|
||||
if (isFinite(v)) submitGripEditValue(v);
|
||||
return;
|
||||
}
|
||||
if (activeTransform) {
|
||||
const v = parseFloat(text.replace(",", "."));
|
||||
if (isFinite(v)) submitTransformValue(v);
|
||||
return;
|
||||
}
|
||||
engine.submitText(text);
|
||||
}}
|
||||
onOption={(id) =>
|
||||
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
|
||||
}
|
||||
onCycleField={() =>
|
||||
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
|
||||
}
|
||||
onCancel={() =>
|
||||
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
|
||||
}
|
||||
suggest={(prefix) => engine.suggest(prefix)}
|
||||
/>
|
||||
|
||||
{/* Footer / Status-Leiste (unten). Werte rein aus Modell + View-State.
|
||||
Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst „—"). */}
|
||||
<StatusBar
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// Arc — Mittelpunkt + Startpunkt + Endpunkt. Schritte:
|
||||
// 1) „Mittelpunkt:" → Punkt
|
||||
// 2) „Startpunkt:" → Punkt (setzt Radius r = |P1−Center| UND Startwinkel a0)
|
||||
// 3) „Endpunkt:" → Punkt (setzt Endwinkel a1; Radius bleibt) → commit
|
||||
//
|
||||
// Der Bogen läuft CCW von a0 nach a1 (wie DXF-ARC und das `{shape:"arc"}`-
|
||||
// Rendering in PlanView). Der Endpunkt wird nur für seinen WINKEL genutzt (auf
|
||||
// den Kreis mit Radius r projiziert). In generatePlan/PlanView als glatter
|
||||
// SVG-Bogen (`drawingArc`) gerendert.
|
||||
|
||||
import type { Drawing2D } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
interface ArcCenter extends CommandState {
|
||||
phase: "center";
|
||||
}
|
||||
interface ArcStart extends CommandState {
|
||||
phase: "start";
|
||||
center: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
interface ArcEnd extends CommandState {
|
||||
phase: "end";
|
||||
center: Vec2;
|
||||
r: number;
|
||||
a0: number;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type ArcState = ArcCenter | ArcStart | ArcEnd;
|
||||
|
||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const angle = (c: Vec2, p: Vec2): number => Math.atan2(p.y - c.y, p.x - c.x);
|
||||
|
||||
/** CCW-Spanne von a0 nach a1, normiert auf (0, 2π]. */
|
||||
function sweepOf(a0: number, a1: number): number {
|
||||
const TAU = Math.PI * 2;
|
||||
const s = ((a1 - a0) % TAU + TAU) % TAU;
|
||||
return s < EPS ? TAU : s;
|
||||
}
|
||||
|
||||
/** Punkte eines Bogens (CCW a0→a1) für die Vorschau. */
|
||||
function arcPts(center: Vec2, r: number, a0: number, a1: number): Vec2[] {
|
||||
const sweep = sweepOf(a0, a1);
|
||||
const N = Math.max(2, Math.ceil(sweep / (Math.PI / 32)));
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = a0 + (sweep * i) / N;
|
||||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Punkte einer vollen Kreis-Approximation (Radius-Vorschau im „start"-Schritt). */
|
||||
function circlePts(center: Vec2, r: number): Vec2[] {
|
||||
const N = 48;
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = (i / N) * Math.PI * 2;
|
||||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */
|
||||
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
|
||||
if (r < EPS) return { preview: [], vertices: [center] };
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
|
||||
const draft: ToolDraft = { preview, vertices: [center] };
|
||||
if (at) {
|
||||
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
|
||||
draft.hud = { at, text: `${deg.toFixed(0)}°` };
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function appendArc(p: Project, center: Vec2, r: number, a0: number, a1: number, ctx: CommandContext): Project {
|
||||
if (r < EPS) return p;
|
||||
const d: Drawing2D = {
|
||||
id: uniqueId("dr2d"),
|
||||
type: "drawing2d",
|
||||
levelId: ctx.level.id,
|
||||
categoryCode: ctx.defaultCategoryCode,
|
||||
geom: { shape: "arc", center, r, a0, a1 },
|
||||
};
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "center", lastPoint: null },
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const arcCommand: Command = {
|
||||
name: "arc",
|
||||
labelKey: "cmd.arc.label",
|
||||
prompt: (s) => {
|
||||
const phase = (s as ArcState).phase;
|
||||
return phase === "start" ? "cmd.arc.start" : phase === "end" ? "cmd.arc.end" : "cmd.arc.center";
|
||||
},
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
init: (): ArcCenter => ({ phase: "center", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as ArcState;
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const pt = input.point;
|
||||
if (s.phase === "center") {
|
||||
const ns: ArcStart = { phase: "start", center: pt, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: { preview: [], vertices: [pt] } }];
|
||||
}
|
||||
if (s.phase === "start") {
|
||||
const r = dist(s.center, pt);
|
||||
if (r < EPS) return [s, { draft: null }];
|
||||
const a0 = angle(s.center, pt);
|
||||
const ns: ArcEnd = { phase: "end", center: s.center, r, a0, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: arcDraft(s.center, r, a0, a0, pt) }];
|
||||
}
|
||||
// Endpunkt: Endwinkel setzen, Bogen committen.
|
||||
const a1 = angle(s.center, pt);
|
||||
const { center, r, a0 } = s;
|
||||
return [
|
||||
{ phase: "center", lastPoint: null },
|
||||
{ draft: null, done: true, commit: (p) => appendArc(p, center, r, a0, a1, ctx) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as ArcState;
|
||||
if (s.phase === "start") {
|
||||
const r = dist(s.center, point);
|
||||
const ns: ArcStart = { ...s, cursor: point };
|
||||
return [
|
||||
ns,
|
||||
{
|
||||
draft: {
|
||||
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
|
||||
vertices: [s.center],
|
||||
hud: { at: point, text: `r ${r.toFixed(2)} m` },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (s.phase === "end") {
|
||||
const a1 = angle(s.center, point);
|
||||
const ns: ArcEnd = { ...s, cursor: point };
|
||||
return [ns, { draft: arcDraft(s.center, s.r, s.a0, a1, point) }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
};
|
||||
@@ -26,16 +26,6 @@ export const importCommand: Command = {
|
||||
options: () => [],
|
||||
init: () => ({ ...IDLE }),
|
||||
|
||||
// Sofort-Befehl (wie Join/Split): `startCommand` ruft `autoRun` SYNCHRON im
|
||||
// selben Aufruf-Stack wie die Enter-/Klick-Geste auf. Genau das braucht der
|
||||
// Datei-Dialog — ein `<input type=file>.click()` öffnet den OS-Dialog nur
|
||||
// innerhalb einer echten Nutzer-Geste; ein State-Update/async-Hop dazwischen
|
||||
// (wie beim reinen onConfirm/onInput-Weg) lässt ihn verzögern oder ausbleiben.
|
||||
autoRun: (): CommandResult => {
|
||||
openDxfImport();
|
||||
return { draft: null, done: true };
|
||||
},
|
||||
|
||||
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
|
||||
onInput: () => open(),
|
||||
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+22
-175
@@ -4,12 +4,9 @@
|
||||
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
|
||||
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
|
||||
//
|
||||
// Optionen (togglebar, IMMER sichtbar): Grundform gerade/L/Wendel, Auf-/Ab-
|
||||
// Umschalter, Referenz (links/mitte/rechts), Trittmass-Modus (mit/ohne).
|
||||
// Tab-Felder in BEIDEN Phasen: Breite (idle + run). Im Laufschritt zusätzlich:
|
||||
// • Trittmass-Modus „ohne": Stufen + Steighöhe (heutiges Verhalten).
|
||||
// • Trittmass-Modus „mit" : Auftritt (Soll-Trittmass); Stufenzahl wird daraus
|
||||
// abgeleitet: steps = clamp(round(runLength / auftritt), MIN_STEPS, …).
|
||||
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
|
||||
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
|
||||
// zeigt die Tritt-Linien + die Lauflinie.
|
||||
//
|
||||
// Herkunft der Treppen-Felder (CommandContext):
|
||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||
@@ -20,7 +17,7 @@
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
||||
|
||||
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 type {
|
||||
Command,
|
||||
@@ -41,35 +38,6 @@ const STAIR_CATEGORY = "40";
|
||||
const DEF_WIDTH = 1.0;
|
||||
const EPS = 1e-6;
|
||||
|
||||
// ── Trittmass-Modus ──────────────────────────────────────────────────────────
|
||||
/** Trittmass-Modus: „with" = Soll-Auftritt vorgegeben, Stufen abgeleitet;
|
||||
* „without" = heutiges Verhalten (freies Stufen-Feld). */
|
||||
type TreadMode = "with" | "without";
|
||||
|
||||
// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ────
|
||||
/** Letzte Nutzer-Einstellungen für Treppen; wird von jeder neuen Instanz gelesen
|
||||
* und nach jedem Commit aktualisiert. */
|
||||
const stairDefaults: {
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
referenz: "links" | "mitte" | "rechts";
|
||||
treadMode: TreadMode;
|
||||
auftritt: number; // Soll-Trittmass (Meter)
|
||||
steps: number | null;
|
||||
rise: number | null;
|
||||
} = {
|
||||
shape: "straight",
|
||||
up: true,
|
||||
width: DEF_WIDTH,
|
||||
referenz: "mitte",
|
||||
treadMode: "without",
|
||||
auftritt: IDEAL_TREAD,
|
||||
steps: null,
|
||||
rise: null,
|
||||
};
|
||||
|
||||
// ── Vektor-Helfer ─────────────────────────────────────────────────────────────
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const normv = (a: Vec2): Vec2 => {
|
||||
@@ -97,45 +65,21 @@ function floorRise(ctx: CommandContext): number {
|
||||
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
|
||||
}
|
||||
|
||||
// ── Trittmass-Ableitung ───────────────────────────────────────────────────────
|
||||
/**
|
||||
* Leitet die Stufenzahl aus Auftritt (Soll-Trittmass) + Lauflänge ab.
|
||||
* Klemmt auf [MIN_STEPS, maxByRun] (mind. 0.24 m Auftrittstiefe).
|
||||
* Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler).
|
||||
*/
|
||||
export function stepsFromTread(auftritt: number, runLength: number): number {
|
||||
const a = Math.max(0.05, auftritt);
|
||||
const raw = Math.round(runLength / a);
|
||||
// Auftrittstiefe aus Stufenzahl darf nicht unter 0.24 m sinken.
|
||||
const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : raw;
|
||||
return Math.max(MIN_STEPS, Math.min(raw, maxByRun));
|
||||
}
|
||||
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
|
||||
const RUN_FIELDS: CommandField[] = [
|
||||
{ id: "width", labelKey: "cmd.field.width" },
|
||||
{ id: "steps", labelKey: "cmd.stair.stepsField" },
|
||||
{ id: "rise", labelKey: "cmd.stair.riseField" },
|
||||
];
|
||||
|
||||
// ── Felder (Tab-Zyklus) ───────────────────────────────────────────────────────
|
||||
/** Tab-Feld: Breite (immer, ab idle). */
|
||||
const FIELD_WIDTH: CommandField = { id: "width", labelKey: "cmd.field.width" };
|
||||
/** Tab-Feld: Stufenanzahl (run-Phase, nur Trittmass-Modus „ohne"). */
|
||||
const FIELD_STEPS: CommandField = { id: "steps", labelKey: "cmd.stair.stepsField" };
|
||||
/** Tab-Feld: Steighöhe (run-Phase, nur Trittmass-Modus „ohne"). */
|
||||
const FIELD_RISE: CommandField = { id: "rise", labelKey: "cmd.stair.riseField" };
|
||||
/** Tab-Feld: Auftritt (run-Phase, nur Trittmass-Modus „mit"). */
|
||||
const FIELD_AUFTRITT: CommandField = { id: "auftritt", labelKey: "cmd.stair.auftrittField" };
|
||||
|
||||
// ── Optionen (immer sichtbar) ─────────────────────────────────────────────────
|
||||
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
|
||||
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
|
||||
const REFERENZ: CmdOption = { id: "referenz", labelKey: "cmd.stair.referenz", value: "mitte" };
|
||||
const TREAD_MODE: CmdOption = { id: "treadMode", labelKey: "cmd.stair.treadMode", value: "without" };
|
||||
|
||||
// ── Zustands-Typen ────────────────────────────────────────────────────────────
|
||||
interface StairIdle extends CommandState {
|
||||
phase: "start";
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
referenz: "links" | "mitte" | "rechts";
|
||||
treadMode: TreadMode;
|
||||
auftritt: number;
|
||||
steps: number | null; // null = automatisch
|
||||
rise: number | null; // null = Geschosshöhe
|
||||
}
|
||||
@@ -144,9 +88,6 @@ interface StairRun extends CommandState {
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
referenz: "links" | "mitte" | "rechts";
|
||||
treadMode: TreadMode;
|
||||
auftritt: number;
|
||||
steps: number | null;
|
||||
rise: number | null;
|
||||
start: Vec2;
|
||||
@@ -154,30 +95,14 @@ interface StairRun extends CommandState {
|
||||
}
|
||||
type StairCmdState = StairIdle | StairRun;
|
||||
|
||||
/** Neuer Ruhezustand aus den modulweiten Defaults (oder überschrieben). */
|
||||
function initState(override?: Partial<typeof stairDefaults>): StairIdle {
|
||||
const d = { ...stairDefaults, ...override };
|
||||
return {
|
||||
phase: "start",
|
||||
lastPoint: null,
|
||||
shape: d.shape,
|
||||
up: d.up,
|
||||
width: d.width,
|
||||
referenz: d.referenz,
|
||||
treadMode: d.treadMode,
|
||||
auftritt: d.auftritt,
|
||||
steps: d.steps,
|
||||
rise: d.rise,
|
||||
};
|
||||
function initState(shape: StairShape = "straight"): StairIdle {
|
||||
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
|
||||
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
|
||||
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
|
||||
*
|
||||
* Im Trittmass-Modus „mit" wird die Stufenzahl aus dem Soll-Auftritt + der
|
||||
* aktuellen Lauflänge abgeleitet (stepsFromTread), kein freies steps-Feld.
|
||||
*/
|
||||
function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
const rise = s.rise ?? floorRise(ctx);
|
||||
@@ -185,15 +110,7 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
const dist = Math.max(0.1, lenOf(d));
|
||||
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
|
||||
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
|
||||
|
||||
// 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 steps = s.steps ?? defaultStepCount(rise, runLength);
|
||||
const base: Stair = {
|
||||
id: uniqueId("ST"),
|
||||
type: "stair",
|
||||
@@ -204,7 +121,6 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
dir,
|
||||
runLength,
|
||||
width: s.width,
|
||||
referenz: s.referenz,
|
||||
totalRise: rise,
|
||||
stepCount: steps,
|
||||
up: s.up,
|
||||
@@ -240,42 +156,18 @@ function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
|
||||
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 {
|
||||
const stair = buildStair(s, ctx);
|
||||
// Zuletzt genutzte Einstellungen für die nächste Treppe merken.
|
||||
stairDefaults.shape = s.shape;
|
||||
stairDefaults.up = s.up;
|
||||
stairDefaults.width = s.width;
|
||||
stairDefaults.referenz = s.referenz;
|
||||
stairDefaults.treadMode = s.treadMode;
|
||||
stairDefaults.auftritt = s.auftritt;
|
||||
stairDefaults.steps = s.steps;
|
||||
stairDefaults.rise = s.rise;
|
||||
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
|
||||
return { ...p, stairs };
|
||||
}
|
||||
|
||||
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
|
||||
initState(from ? {
|
||||
shape: from.shape,
|
||||
up: from.up,
|
||||
width: from.width,
|
||||
referenz: from.referenz,
|
||||
treadMode: from.treadMode,
|
||||
auftritt: from.auftritt,
|
||||
steps: from.steps,
|
||||
rise: from.rise,
|
||||
} : undefined),
|
||||
initState(from?.shape ?? "straight"),
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
// ── Referenz-Toggle-Zyklus ────────────────────────────────────────────────────
|
||||
function nextReferenz(r: "links" | "mitte" | "rechts"): "links" | "mitte" | "rechts" {
|
||||
return r === "links" ? "mitte" : r === "mitte" ? "rechts" : "links";
|
||||
}
|
||||
|
||||
// ── Command-Export ────────────────────────────────────────────────────────────
|
||||
export const stairCommand: Command = {
|
||||
name: "stair",
|
||||
labelKey: "cmd.stair.label",
|
||||
@@ -285,20 +177,14 @@ export const stairCommand: Command = {
|
||||
(s as StairCmdState).phase === "run"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
|
||||
/** Optionen werden IMMER angezeigt (idle + run): Grundform, Auf/Ab, Referenz,
|
||||
* Trittmass-Modus. */
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as StairCmdState;
|
||||
return [
|
||||
{ ...SHAPE, value: st.shape },
|
||||
{ ...UPDOWN, value: st.up ? "up" : "down" },
|
||||
{ ...REFERENZ, value: st.referenz },
|
||||
{ ...TREAD_MODE, value: st.treadMode },
|
||||
];
|
||||
},
|
||||
|
||||
init: (): StairIdle => initState(),
|
||||
init: (): StairIdle => initState("straight"),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
@@ -309,12 +195,6 @@ export const stairCommand: Command = {
|
||||
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
|
||||
} else if (input.id === "updown") {
|
||||
next.up = !s.up;
|
||||
} else if (input.id === "referenz") {
|
||||
next.referenz = nextReferenz(s.referenz);
|
||||
} else if (input.id === "treadMode") {
|
||||
next.treadMode = s.treadMode === "with" ? "without" : "with";
|
||||
// Im „mit"-Modus freie Stufenzahl zurücksetzen (wird abgeleitet).
|
||||
if (next.treadMode === "with") next.steps = null;
|
||||
}
|
||||
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
|
||||
}
|
||||
@@ -331,9 +211,6 @@ export const stairCommand: Command = {
|
||||
shape: s.shape,
|
||||
up: s.up,
|
||||
width: s.width,
|
||||
referenz: s.referenz,
|
||||
treadMode: s.treadMode,
|
||||
auftritt: s.auftritt,
|
||||
steps: s.steps,
|
||||
rise: s.rise,
|
||||
start: pt,
|
||||
@@ -372,50 +249,20 @@ export const stairCommand: Command = {
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
|
||||
|
||||
/**
|
||||
* Tab-Felder:
|
||||
* • idle-Phase: [Breite] (Konfiguration VOR dem ersten Punkt).
|
||||
* • run-Phase, Trittmass-Modus „ohne": [Breite, Stufen, Steighöhe].
|
||||
* • run-Phase, Trittmass-Modus „mit" : [Breite, Auftritt].
|
||||
*/
|
||||
fields: (state): CommandField[] => {
|
||||
const s = state as StairCmdState;
|
||||
if (s.phase === "start") {
|
||||
return [FIELD_WIDTH];
|
||||
}
|
||||
// run-Phase
|
||||
if (s.treadMode === "with") {
|
||||
return [FIELD_WIDTH, FIELD_AUFTRITT];
|
||||
}
|
||||
return [FIELD_WIDTH, FIELD_STEPS, FIELD_RISE];
|
||||
},
|
||||
|
||||
/** Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt. */
|
||||
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
|
||||
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
|
||||
pointFromFields: (state, locks) => {
|
||||
const s = state as StairCmdState;
|
||||
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
||||
if (s.phase === "run") {
|
||||
if ("auftritt" in locks && locks.auftritt > 0) s.auftritt = locks.auftritt;
|
||||
// Stufen + Steighöhe nur im „ohne"-Modus als freie Felder.
|
||||
if (s.treadMode === "without") {
|
||||
if ("steps" in locks && locks.steps >= MIN_STEPS) s.steps = Math.round(locks.steps);
|
||||
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
|
||||
}
|
||||
}
|
||||
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
|
||||
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
|
||||
return null;
|
||||
},
|
||||
|
||||
fieldValues: (state, _locks, _cursor): Record<string, number> => {
|
||||
const s = state as StairCmdState;
|
||||
const out: Record<string, number> = { width: s.width };
|
||||
if (s.phase === "run") {
|
||||
if (s.treadMode === "with") {
|
||||
out.auftritt = s.auftritt;
|
||||
} else {
|
||||
if (s.steps != null) out.steps = s.steps;
|
||||
if (s.rise != null) out.rise = s.rise;
|
||||
}
|
||||
}
|
||||
if (s.steps != null) out.steps = s.steps;
|
||||
if (s.rise != null) out.rise = s.rise;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { stairCommand } from "./cmds/stair";
|
||||
import { roomCommand } from "./cmds/room";
|
||||
import { rectCommand } from "./cmds/rect";
|
||||
import { circleCommand } from "./cmds/circle";
|
||||
import { arcCommand } from "./cmds/arc";
|
||||
import { moveCommand } from "./cmds/move";
|
||||
import { mirrorCommand } from "./cmds/mirror";
|
||||
import { joinCommand } from "./cmds/join";
|
||||
@@ -36,7 +35,6 @@ export const COMMANDS: Record<string, Command> = {
|
||||
polyline: polylineCommand,
|
||||
rect: rectCommand,
|
||||
circle: circleCommand,
|
||||
arc: arcCommand,
|
||||
move: moveCommand,
|
||||
mirror: mirrorCommand,
|
||||
join: joinCommand,
|
||||
@@ -69,8 +67,6 @@ export const ALIASES: Record<string, string> = {
|
||||
pl: "polyline",
|
||||
rec: "rect",
|
||||
c: "circle",
|
||||
a: "arc",
|
||||
bogen: "arc",
|
||||
m: "move",
|
||||
s: "mirror",
|
||||
spiegeln: "mirror",
|
||||
|
||||
@@ -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[];
|
||||
/** Glaslinien-Segmente quer über die Lücke (1–2 parallele Linien). */
|
||||
glassLines: [Vec2, Vec2][];
|
||||
/**
|
||||
* Flügel-Mittelpfosten (`wingCount − 1` Querlinien), jeweils von einer
|
||||
* Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur
|
||||
* Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei
|
||||
* `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces`
|
||||
* des Rhino-Plugins (`oeff_fluegel`).
|
||||
*/
|
||||
mullionLines: [Vec2, Vec2][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
|
||||
* Wanddicke) plus 1–2 Glaslinien (dünne parallele Linien in der Wandmitte,
|
||||
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
|
||||
* (`wingCount − 1` Querlinien).
|
||||
* `count` steuert die Anzahl je Detailgrad).
|
||||
*/
|
||||
export function windowSymbol(
|
||||
project: Project,
|
||||
@@ -214,21 +205,5 @@ export function windowSymbol(
|
||||
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
||||
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
||||
}
|
||||
// Flügel-Mittelpfosten: (wingCount − 1) Querlinien gleichmäßig zwischen den
|
||||
// Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf ~60 % der
|
||||
// Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — analog Rhino, wo
|
||||
// die Pfosten x_mid = inner_l + span·(i/fluegel) über die Rahmentiefe sitzen.
|
||||
const mullionLines: [Vec2, Vec2][] = [];
|
||||
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
|
||||
if (wings > 1) {
|
||||
const postDepth = total * 0.6;
|
||||
const pOuter = refOff + postDepth / 2;
|
||||
const pInner = refOff - postDepth / 2;
|
||||
for (let i = 1; i < wings; i++) {
|
||||
const t = i / wings; // gleichmäßige Teilung entlang der Achse
|
||||
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
|
||||
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
|
||||
}
|
||||
}
|
||||
return { frame, glassLines, mullionLines };
|
||||
return { frame, glassLines };
|
||||
}
|
||||
|
||||
+5
-177
@@ -27,32 +27,6 @@ const norm = (a: Vec2): Vec2 => {
|
||||
/** Linke Normale (n = (−u.y, u.x)) — quer zur Laufrichtung. */
|
||||
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
|
||||
|
||||
/**
|
||||
* Perp-Versatz von der Referenzkante zur visuellen Treppen-MITTE, entlang der
|
||||
* linken Normale `n = leftN(u)`. Entspricht `mid_off` aus `_treppe_2d_side_offsets`
|
||||
* im Rhino-Plugin (Konvention: `perp` = CCW-Normale = linke Normale):
|
||||
* • referenz "links" → Achse ist die LINKE Kante; die Mitte liegt eine
|
||||
* halbe Breite in −n-Richtung (rechts) → mid_off = −width/2.
|
||||
* • referenz "rechts" → Achse ist die RECHTE Kante; die Mitte liegt eine
|
||||
* halbe Breite in +n-Richtung (links) → mid_off = +width/2.
|
||||
* • referenz "mitte"/undefined → Achse ist bereits die Mitte → 0.
|
||||
*
|
||||
* ANNAHME (selbstkritisch): „links" heisst „linke Kante in Laufrichtung", und die
|
||||
* linke Kante liegt in +n (n = leftN(u), 90° links der Laufrichtung). Von der
|
||||
* linken Kante zur Mitte geht es also nach −n, daher mid_off = −width/2. Das
|
||||
* deckt sich mit `_treppe_2d_side_offsets` (referenz="links" → off_a=0, off_b=−b,
|
||||
* Mitte = −b/2·perp). Wird die Richtung im UI als seitenverkehrt empfunden, ist
|
||||
* hier das Vorzeichen zu drehen — die Geometrie bleibt in sich konsistent.
|
||||
*/
|
||||
function referenzMidShift(stair: Stair, u: Vec2): Vec2 {
|
||||
const ref = stair.referenz ?? "mitte";
|
||||
if (ref === "mitte") return { x: 0, y: 0 };
|
||||
const n = leftN(u);
|
||||
const halfW = Math.max(0.05, stair.width / 2);
|
||||
const midOff = ref === "links" ? -halfW : halfW;
|
||||
return scale(n, midOff);
|
||||
}
|
||||
|
||||
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
|
||||
export const IDEAL_RISER = 0.17;
|
||||
export const IDEAL_TREAD = 0.29;
|
||||
@@ -131,22 +105,13 @@ export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
|
||||
const u = norm(stair.dir);
|
||||
const n = leftN(u);
|
||||
|
||||
// Referenz (links/mitte/rechts): die gespeicherte Achse wird auf die visuelle
|
||||
// Treppen-MITTE versetzt, danach greift die bestehende, um die Achse zentrierte
|
||||
// Tritt-/Lauflinien-Logik unverändert. So bleibt die Lauflinie samt Pfeil stets
|
||||
// auf der Mitte (nicht auf der Referenzkante). "mitte"/undefined ⇒ Nullversatz.
|
||||
const c = referenzMidShift(stair, u);
|
||||
const s = stair.referenz && stair.referenz !== "mitte"
|
||||
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
|
||||
: stair;
|
||||
|
||||
if (s.shape === "spiral") {
|
||||
return spiralGeometry(s, totalRise, steps, riserHeight, halfW);
|
||||
if (stair.shape === "spiral") {
|
||||
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
|
||||
}
|
||||
if (s.shape === "L") {
|
||||
return lGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
|
||||
if (stair.shape === "L") {
|
||||
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. */
|
||||
@@ -444,140 +409,3 @@ export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
|
||||
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Treppen-Aussenlinie (Plan-Outline) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Schliessendes Umriss-Polygon einer geraden Treppe im Grundriss (4 Ecken CCW).
|
||||
* Entspricht `_aussen_gerade` im Rhino-Plugin (Rechteck über Laufbreite + Lauflänge).
|
||||
* Liefert null bei entarteter Geometrie.
|
||||
*/
|
||||
function straightOutlinePoints(stair: Stair, halfW: number): Vec2[] | null {
|
||||
const u = norm({ x: stair.dir.x, y: stair.dir.y });
|
||||
const n = leftN(u);
|
||||
const L = Math.max(1e-4, stair.runLength);
|
||||
const s = stair.start;
|
||||
// Vier Ecken: Start-links → Start-rechts → End-rechts → End-links (CCW).
|
||||
return [
|
||||
add(s, scale(n, halfW)),
|
||||
add(s, scale(n, -halfW)),
|
||||
add(add(s, scale(u, L)), scale(n, -halfW)),
|
||||
add(add(s, scale(u, L)), scale(n, halfW)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D-Linien-Schnittpunkt. Liefert null bei Parallelität (det < ε).
|
||||
* Entspricht `_line_intersect_xy` im Rhino-Plugin.
|
||||
*/
|
||||
function lineIntersect2d(p1: Vec2, d1: Vec2, p2: Vec2, d2: Vec2): Vec2 | null {
|
||||
const det = d1.x * (-d2.y) - d1.y * (-d2.x);
|
||||
if (Math.abs(det) < 1e-9) return null;
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const t = (dx * (-d2.y) - dy * (-d2.x)) / det;
|
||||
return { x: p1.x + d1.x * t, y: p1.y + d1.y * t };
|
||||
}
|
||||
|
||||
/**
|
||||
* Umriss-Polygon einer L-Treppe im Grundriss. Entspricht `_aussen_l_polygon`
|
||||
* im Rhino-Plugin: Linien-Schnittpunkte liefern die Ecken des L-Polygons.
|
||||
* Liefert null bei entarteter Geometrie.
|
||||
*/
|
||||
function lOutlinePoints(stair: Stair, halfW: number): Vec2[] | null {
|
||||
const u1 = norm(stair.dir);
|
||||
const n1 = leftN(u1);
|
||||
const L1 = Math.max(1e-4, stair.runLength);
|
||||
const turn = stair.turn ?? 1;
|
||||
const u2: Vec2 = turn > 0 ? n1 : scale(n1, -1);
|
||||
const n2 = leftN(u2);
|
||||
const L2 = Math.max(1e-4, stair.run2Length ?? stair.runLength);
|
||||
|
||||
// Podest-Zentrum am Ende des ersten Laufs (Mitte = halfW hinter dem Lauf-Ende).
|
||||
const corner = add(stair.start, scale(u1, L1 + halfW));
|
||||
const run2Start = add(corner, scale(u2, halfW));
|
||||
|
||||
// Lauf-1-Anfang je Seite
|
||||
const p0l = add(stair.start, scale(n1, halfW));
|
||||
const p0r = add(stair.start, scale(n1, -halfW));
|
||||
// Lauf-2-Ende je Seite
|
||||
const endRun2 = add(run2Start, scale(u2, L2));
|
||||
const p3l = add(endRun2, scale(n2, halfW));
|
||||
const p3r = add(endRun2, scale(n2, -halfW));
|
||||
// Innere Eck-Punkte: Schnittpunkt der parallelen Seitenlinien je Seite.
|
||||
const cornerL = lineIntersect2d(p0l, u1, p3l, scale(u2, -1)) ?? add(corner, scale(n1, halfW));
|
||||
const cornerR = lineIntersect2d(p0r, u1, p3r, scale(u2, -1)) ?? add(corner, scale(n1, -halfW));
|
||||
// CCW-Polygon: p0l → p0r → cornerR → p3r → p3l → cornerL
|
||||
return [p0l, p0r, cornerR, p3r, p3l, cornerL];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiral-Outline: Innenbogen + Aussenbogen + zwei radiale Schliesslinien.
|
||||
* Entspricht `_aussen_wendel` im Rhino-Plugin (immer vollständig, ohne Cut).
|
||||
* Die Bögen werden als Kreisbogen-Parameter geliefert (cx/cy/r/a0/a1), die
|
||||
* radialen Schliesslinien als Segment-Paare.
|
||||
*/
|
||||
export interface SpiralOutlineSegments {
|
||||
/** Radiale Abschlusslinien (jeweils Innenpunkt → Aussenpunkt). */
|
||||
lines: [Vec2, Vec2][];
|
||||
/** Kreisbogen-Parameter: Innen- und Aussenkreis. */
|
||||
arcs: { cx: number; cy: number; r: number; a0: number; a1: number }[];
|
||||
}
|
||||
|
||||
function spiralOutline(stair: Stair, halfW: number): SpiralOutlineSegments | null {
|
||||
const center = stair.center ?? stair.start;
|
||||
const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width);
|
||||
const sweep = (stair.sweep ?? 270) * (Math.PI / 180);
|
||||
const startVec = sub(stair.start, center);
|
||||
const a0 = Math.atan2(startVec.y, startVec.x);
|
||||
const a1 = a0 + sweep;
|
||||
const rIn = Math.max(0.02, radius - halfW);
|
||||
const rOut = radius + halfW;
|
||||
|
||||
const ptAt = (r: number, a: number): Vec2 => ({
|
||||
x: center.x + r * Math.cos(a),
|
||||
y: center.y + r * Math.sin(a),
|
||||
});
|
||||
|
||||
return {
|
||||
lines: [
|
||||
[ptAt(rIn, a0), ptAt(rOut, a0)],
|
||||
[ptAt(rIn, a1), ptAt(rOut, a1)],
|
||||
],
|
||||
arcs: [
|
||||
{ cx: center.x, cy: center.y, r: rIn, a0, a1 },
|
||||
{ cx: center.x, cy: center.y, r: rOut, a0, a1 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aussenlinie einer Treppe im Grundriss je nach Form:
|
||||
* • gerade → geschlossenes Rechteck als Polygon-Punkte.
|
||||
* • L → L-Polygon via Linien-Schnittpunkten.
|
||||
* • Wendel → `SpiralOutlineSegments` (Bögen + radiale Linien).
|
||||
*
|
||||
* Genau einer von `polygon` / `spiral` ist nicht null.
|
||||
*/
|
||||
export function stairOutline(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
): { polygon: Vec2[] | null; spiral: SpiralOutlineSegments | null } {
|
||||
void totalRise; // Signatur-Konsistenz mit stairGeometry; derzeit ungenutzt
|
||||
const halfW = Math.max(0.05, stair.width / 2);
|
||||
// Referenz (links/mitte/rechts): die gespeicherte Achse zuerst auf die
|
||||
// visuelle Treppen-Mitte versetzen, danach die um die Mitte zentrierte
|
||||
// Aussenlinien-Logik unverändert anwenden (identisch zu `stairGeometry`).
|
||||
const u = norm(stair.dir);
|
||||
const c = referenzMidShift(stair, u);
|
||||
const s = stair.referenz && stair.referenz !== "mitte"
|
||||
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
|
||||
: stair;
|
||||
if (s.shape === "spiral") {
|
||||
return { polygon: null, spiral: spiralOutline(s, halfW) };
|
||||
}
|
||||
if (s.shape === "L") {
|
||||
return { polygon: lOutlinePoints(s, halfW), spiral: null };
|
||||
}
|
||||
return { polygon: straightOutlinePoints(s, halfW), spiral: null };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+5
-49
@@ -116,23 +116,6 @@ export const de = {
|
||||
"tool.line": "Linie",
|
||||
"tool.polyline": "Polylinie",
|
||||
"tool.rect": "Rechteck",
|
||||
"tool.circle": "Kreis",
|
||||
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
||||
"tool.arc": "Bogen",
|
||||
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
|
||||
// Ribbon-Oberleiste (Tabs + Gruppen)
|
||||
"ribbon.tab.2d": "2D",
|
||||
"ribbon.tab.3d": "3D",
|
||||
"ribbon.tab.bim": "BIM",
|
||||
"ribbon.tab.views": "Ansichten",
|
||||
"ribbon.tab.custom": "Eigene",
|
||||
"ribbon.group.draw": "Zeichnen",
|
||||
"ribbon.group.modify": "Ändern",
|
||||
"ribbon.group.components": "Bauteile",
|
||||
"ribbon.group.custom": "Eigene Leiste",
|
||||
"ribbon.custom.add": "+ Hinzufügen",
|
||||
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
||||
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
|
||||
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
|
||||
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
|
||||
"tool.door.hint": "Tür: Wand wählen, dann Position setzen",
|
||||
@@ -225,7 +208,6 @@ export const de = {
|
||||
// Attribute-Palette (AttributesPanel)
|
||||
"attr.title": "Attribute",
|
||||
"attr.empty": "Keine Auswahl",
|
||||
"attr.newComponent": "Neues Bauteil",
|
||||
"attr.stroke": "Stift",
|
||||
"attr.color": "Farbe",
|
||||
"attr.weight": "Strichstärke (mm)",
|
||||
@@ -273,7 +255,7 @@ export const de = {
|
||||
"objinfo.wall.refLine.center": "Mitte",
|
||||
"objinfo.wall.refLine.right": "Innen (rechts)",
|
||||
"objinfo.wall.buildup": "Aufbau",
|
||||
"objinfo.wall.single": "Solid",
|
||||
"objinfo.wall.single": "Einschichtig",
|
||||
"objinfo.wall.multi": "Mehrschichtig",
|
||||
"objinfo.wall.thickness": "Dicke",
|
||||
"objinfo.wall.preset": "Wandaufbau",
|
||||
@@ -282,8 +264,8 @@ export const de = {
|
||||
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
|
||||
"objinfo.wall.bottom": "Unterkante (UK)",
|
||||
"objinfo.wall.top": "Oberkante (OK)",
|
||||
"objinfo.wall.anchorFloor": "Gebunden",
|
||||
"objinfo.wall.anchorCustom": "Eigene",
|
||||
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
|
||||
"objinfo.wall.anchorCustom": "Eigene Höhe",
|
||||
"objinfo.wall.height": "Höhe",
|
||||
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
||||
"objinfo.ceiling.section": "Decke",
|
||||
@@ -295,8 +277,8 @@ export const de = {
|
||||
"objinfo.ceiling.bottom": "Unterkante (UK)",
|
||||
"objinfo.ceiling.height": "Dicke (OK−UK)",
|
||||
"objinfo.ceiling.area": "Fläche",
|
||||
"objinfo.ceiling.anchorFloor": "Gebunden",
|
||||
"objinfo.ceiling.anchorCustom": "Eigene",
|
||||
"objinfo.ceiling.anchorFloor": "An Geschoss gebunden",
|
||||
"objinfo.ceiling.anchorCustom": "Eigene Höhe",
|
||||
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Öffnung",
|
||||
"objinfo.opening.kind": "Art",
|
||||
@@ -319,16 +301,6 @@ export const de = {
|
||||
"objinfo.opening.dir.out": "Außen",
|
||||
"objinfo.opening.bottom": "Unterkante (UK)",
|
||||
"objinfo.opening.top": "Oberkante (OK)",
|
||||
// Neue Öffnungs-Felder
|
||||
"objinfo.opening.wingCount": "Flügel",
|
||||
"objinfo.opening.doorType": "Typ",
|
||||
"objinfo.opening.doorType.normal": "Normal",
|
||||
"objinfo.opening.doorType.wandoeffnung": "Wandöffnung",
|
||||
"objinfo.opening.lintelLines": "Sturzlinien",
|
||||
"objinfo.opening.lintelLines.keine": "Keine",
|
||||
"objinfo.opening.lintelLines.innen": "Innen",
|
||||
"objinfo.opening.lintelLines.aussen": "Außen",
|
||||
"objinfo.opening.lintelLines.beide": "Beide",
|
||||
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
|
||||
"objinfo.stair.section": "Treppe",
|
||||
"objinfo.stair.shape": "Grundform",
|
||||
@@ -346,11 +318,6 @@ export const de = {
|
||||
"objinfo.stair.refFloor": "Referenzgeschoss",
|
||||
"objinfo.stair.bottom": "Unterkante (UK)",
|
||||
"objinfo.stair.top": "Oberkante (OK)",
|
||||
// Neues Treppen-Feld
|
||||
"objinfo.stair.referenz": "Referenz",
|
||||
"objinfo.stair.referenz.links": "Links",
|
||||
"objinfo.stair.referenz.mitte": "Mitte",
|
||||
"objinfo.stair.referenz.rechts": "Rechts",
|
||||
// ── Raum-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||
"objinfo.room.section": "Raum",
|
||||
"objinfo.room.name": "Name",
|
||||
@@ -702,9 +669,6 @@ export const de = {
|
||||
"cmd.stair.updown": "Auf/Ab",
|
||||
"cmd.stair.stepsField": "Stufen",
|
||||
"cmd.stair.riseField": "Steighöhe",
|
||||
"cmd.stair.referenz": "Referenz",
|
||||
"cmd.stair.treadMode": "Trittmass",
|
||||
"cmd.stair.auftrittField": "Auftritt",
|
||||
"cmd.line.label": "Linie",
|
||||
"cmd.line.start": "Startpunkt der Linie:",
|
||||
"cmd.line.end": "Endpunkt der Linie:",
|
||||
@@ -725,10 +689,6 @@ export const de = {
|
||||
"cmd.circle.label": "Kreis",
|
||||
"cmd.circle.center": "Mittelpunkt:",
|
||||
"cmd.circle.radius": "Radius:",
|
||||
"cmd.arc.label": "Bogen",
|
||||
"cmd.arc.center": "Mittelpunkt:",
|
||||
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
||||
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
||||
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
||||
"cmd.move.label": "Verschieben",
|
||||
"cmd.move.base": "Basispunkt:",
|
||||
@@ -801,7 +761,6 @@ export const de = {
|
||||
"import.file": "Datei",
|
||||
"import.summary.heading": "Inhalt",
|
||||
"import.summary.contours": "{n} Konturen",
|
||||
"import.summary.texts": "{n} Texte",
|
||||
"import.summary.meshes": "{n} Meshes",
|
||||
"import.summary.layers": "Layer: {layers}",
|
||||
"import.summary.noLayers": "keine Layer-Namen",
|
||||
@@ -813,9 +772,6 @@ export const de = {
|
||||
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
|
||||
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
|
||||
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
|
||||
"import.placement.heading": "Platzierung",
|
||||
"import.placement.origin": "An DXF-Koordinaten (relativ zum Nullpunkt)",
|
||||
"import.placement.viewCenter": "In die Mitte der aktuellen Ansicht",
|
||||
"import.mesh.heading": "Meshes",
|
||||
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
|
||||
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
|
||||
|
||||
+5
-47
@@ -115,23 +115,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"tool.line": "Line",
|
||||
"tool.polyline": "Polyline",
|
||||
"tool.rect": "Rectangle",
|
||||
"tool.circle": "Circle",
|
||||
"tool.circle.hint": "Circle: set center, then radius",
|
||||
"tool.arc": "Arc",
|
||||
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
|
||||
// Ribbon top bar (tabs + groups)
|
||||
"ribbon.tab.2d": "2D",
|
||||
"ribbon.tab.3d": "3D",
|
||||
"ribbon.tab.bim": "BIM",
|
||||
"ribbon.tab.views": "Views",
|
||||
"ribbon.tab.custom": "Custom",
|
||||
"ribbon.group.draw": "Draw",
|
||||
"ribbon.group.modify": "Modify",
|
||||
"ribbon.group.components": "Components",
|
||||
"ribbon.group.custom": "Custom bar",
|
||||
"ribbon.custom.add": "+ Add",
|
||||
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
||||
"ribbon.empty": "No tools in this tab yet",
|
||||
"tool.select.hint": "Select — click/drag elements",
|
||||
"tool.window.hint": "Window: pick a wall, then set the position",
|
||||
"tool.door.hint": "Door: pick a wall, then set the position",
|
||||
@@ -224,7 +207,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
// Attribute-Palette (AttributesPanel)
|
||||
"attr.title": "Attributes",
|
||||
"attr.empty": "No selection",
|
||||
"attr.newComponent": "New component",
|
||||
"attr.stroke": "Pen",
|
||||
"attr.color": "Color",
|
||||
"attr.weight": "Line weight (mm)",
|
||||
@@ -272,7 +254,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.wall.refLine.center": "Center",
|
||||
"objinfo.wall.refLine.right": "Interior (right)",
|
||||
"objinfo.wall.buildup": "Build-up",
|
||||
"objinfo.wall.single": "Solid",
|
||||
"objinfo.wall.single": "Single layer",
|
||||
"objinfo.wall.multi": "Multi layer",
|
||||
"objinfo.wall.thickness": "Thickness",
|
||||
"objinfo.wall.preset": "Wall style",
|
||||
@@ -281,8 +263,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.wall.linkAbove": "Bind to floor above",
|
||||
"objinfo.wall.bottom": "Bottom (UK)",
|
||||
"objinfo.wall.top": "Top (OK)",
|
||||
"objinfo.wall.anchorFloor": "Bound",
|
||||
"objinfo.wall.anchorCustom": "Custom",
|
||||
"objinfo.wall.anchorFloor": "Bound to floor",
|
||||
"objinfo.wall.anchorCustom": "Custom height",
|
||||
"objinfo.wall.height": "Height",
|
||||
"objinfo.wall.floorAboveNone": "No floor above",
|
||||
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
||||
@@ -295,8 +277,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.ceiling.bottom": "Bottom (UK)",
|
||||
"objinfo.ceiling.height": "Thickness (OK−UK)",
|
||||
"objinfo.ceiling.area": "Area",
|
||||
"objinfo.ceiling.anchorFloor": "Bound",
|
||||
"objinfo.ceiling.anchorCustom": "Custom",
|
||||
"objinfo.ceiling.anchorFloor": "Bound to floor",
|
||||
"objinfo.ceiling.anchorCustom": "Custom height",
|
||||
// ── Opening attributes (object info panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Opening",
|
||||
"objinfo.opening.kind": "Type",
|
||||
@@ -319,15 +301,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.opening.dir.out": "Out",
|
||||
"objinfo.opening.bottom": "Bottom (UK)",
|
||||
"objinfo.opening.top": "Top (OK)",
|
||||
"objinfo.opening.wingCount": "Wings",
|
||||
"objinfo.opening.doorType": "Type",
|
||||
"objinfo.opening.doorType.normal": "Normal",
|
||||
"objinfo.opening.doorType.wandoeffnung": "Wall opening",
|
||||
"objinfo.opening.lintelLines": "Lintel lines",
|
||||
"objinfo.opening.lintelLines.keine": "None",
|
||||
"objinfo.opening.lintelLines.innen": "Inside",
|
||||
"objinfo.opening.lintelLines.aussen": "Outside",
|
||||
"objinfo.opening.lintelLines.beide": "Both",
|
||||
"objinfo.stair.section": "Stair",
|
||||
"objinfo.stair.shape": "Shape",
|
||||
"objinfo.stair.shape.straight": "Straight",
|
||||
@@ -344,10 +317,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.stair.refFloor": "Reference floor",
|
||||
"objinfo.stair.bottom": "Bottom (UK)",
|
||||
"objinfo.stair.top": "Top (OK)",
|
||||
"objinfo.stair.referenz": "Reference",
|
||||
"objinfo.stair.referenz.links": "Left",
|
||||
"objinfo.stair.referenz.mitte": "Centre",
|
||||
"objinfo.stair.referenz.rechts": "Right",
|
||||
// ── Room attributes (object info panel) ──────────────────────────────────
|
||||
"objinfo.room.section": "Room",
|
||||
"objinfo.room.name": "Name",
|
||||
@@ -692,9 +661,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.stair.updown": "Up/Down",
|
||||
"cmd.stair.stepsField": "Steps",
|
||||
"cmd.stair.riseField": "Rise",
|
||||
"cmd.stair.referenz": "Reference",
|
||||
"cmd.stair.treadMode": "Tread",
|
||||
"cmd.stair.auftrittField": "Tread depth",
|
||||
"cmd.line.label": "Line",
|
||||
"cmd.line.start": "Start of line:",
|
||||
"cmd.line.end": "End of line:",
|
||||
@@ -715,10 +681,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.circle.label": "Circle",
|
||||
"cmd.circle.center": "Center:",
|
||||
"cmd.circle.radius": "Radius:",
|
||||
"cmd.arc.label": "Arc",
|
||||
"cmd.arc.center": "Center:",
|
||||
"cmd.arc.start": "Start point (radius + start angle):",
|
||||
"cmd.arc.end": "End point (end angle):",
|
||||
"cmd.move.label": "Move",
|
||||
"cmd.move.base": "Base point:",
|
||||
"cmd.move.target": "Target point:",
|
||||
@@ -790,7 +752,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"import.file": "File",
|
||||
"import.summary.heading": "Contents",
|
||||
"import.summary.contours": "{n} contours",
|
||||
"import.summary.texts": "{n} texts",
|
||||
"import.summary.meshes": "{n} meshes",
|
||||
"import.summary.layers": "Layers: {layers}",
|
||||
"import.summary.noLayers": "no layer names",
|
||||
@@ -802,9 +763,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"import.layers.heading": "Layer handling (DXF layers)",
|
||||
"import.layers.asCategories": "Keep DXF layers as categories",
|
||||
"import.layers.toActive": "Everything onto the active category ({code})",
|
||||
"import.placement.heading": "Placement",
|
||||
"import.placement.origin": "At DXF coordinates (relative to origin)",
|
||||
"import.placement.viewCenter": "Centered in the current view",
|
||||
"import.mesh.heading": "Meshes",
|
||||
"import.mesh.asContext": "Import meshes as context geometry (3D)",
|
||||
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
|
||||
|
||||
@@ -1,539 +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, textsToDrawings } 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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── TEXT / MTEXT ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** TEXT-Entity: Position, Höhe, Rotation (Grad), String. */
|
||||
function textEnt(x: number, y: number, h: number, rotDeg: number, s: string): string[] {
|
||||
return ["0", "TEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", `${rotDeg}`, "1", s];
|
||||
}
|
||||
|
||||
/** MTEXT-Entity: Position, Höhe, String (Rotation 0). */
|
||||
function mtextEnt(x: number, y: number, h: number, s: string): string[] {
|
||||
return ["0", "MTEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", "0", "1", s];
|
||||
}
|
||||
|
||||
describe("parseDxf — TEXT/MTEXT", () => {
|
||||
it("TEXT → ImportedText mit Position/Höhe/Winkel (Grad→Radiant)", () => {
|
||||
const res = parseDxf(dxf(textEnt(5, 3, 0.5, 90, "Hallo")));
|
||||
expect(res.texts).toHaveLength(1);
|
||||
const t0 = res.texts![0];
|
||||
expect(t0.at.x).toBeCloseTo(5, 9);
|
||||
expect(t0.at.y).toBeCloseTo(3, 9);
|
||||
expect(t0.height).toBeCloseTo(0.5, 9);
|
||||
expect(t0.angle).toBeCloseTo(Math.PI / 2, 9);
|
||||
expect(t0.text).toBe("Hallo");
|
||||
});
|
||||
|
||||
it("MTEXT → Formatcodes gesäubert (\\P → Leerzeichen)", () => {
|
||||
const res = parseDxf(dxf(mtextEnt(1, 2, 0.3, "Welt\\Pzeile2")));
|
||||
expect(res.texts![0].text).toBe("Welt zeile2");
|
||||
expect(res.texts![0].at.x).toBeCloseTo(1, 9);
|
||||
expect(res.texts![0].height).toBeCloseTo(0.3, 9);
|
||||
});
|
||||
|
||||
it("leerer Text wird übersprungen", () => {
|
||||
const res = parseDxf(dxf(textEnt(0, 0, 0.2, 0, "")));
|
||||
expect(res.texts ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fehlende Höhe → positiver Default", () => {
|
||||
const g = ["0", "TEXT", "8", "0", "10", "0", "20", "0", "30", "0", "1", "X"];
|
||||
const res = parseDxf(dxf(g));
|
||||
expect(res.texts![0].height).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("textsToDrawings", () => {
|
||||
it("ImportedText → Drawing2D {shape:\"text\"}", () => {
|
||||
const drawings = textsToDrawings(
|
||||
[{ at: { x: 1, y: 2 }, text: "Hi", height: 0.4, angle: 0, layer: "L" }],
|
||||
"lvl", "active", "CODE", (s) => s,
|
||||
);
|
||||
expect(drawings).toHaveLength(1);
|
||||
const g = drawings[0].geom;
|
||||
expect(g.shape).toBe("text");
|
||||
if (g.shape === "text") {
|
||||
expect(g.text).toBe("Hi");
|
||||
expect(g.height).toBeCloseTo(0.4, 9);
|
||||
expect(g.at.x).toBeCloseTo(1, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDxf — Kurven behalten echte Geometrie (curve)", () => {
|
||||
it("CIRCLE trägt curve {kind:circle}", () => {
|
||||
const c = onlyContour(dxf(circle(10, 20, 5, 4)));
|
||||
expect(c.curve?.kind).toBe("circle");
|
||||
expect(c.curve?.cx).toBeCloseTo(10, 9);
|
||||
expect(c.curve?.cy).toBeCloseTo(20, 9);
|
||||
expect(c.curve?.r).toBeCloseTo(4, 9);
|
||||
});
|
||||
|
||||
it("ARC trägt curve {kind:arc} mit Winkeln (Radiant)", () => {
|
||||
const c = onlyContour(dxf(arc(0, 0, 10, 0, 90)));
|
||||
expect(c.curve?.kind).toBe("arc");
|
||||
expect(c.curve?.r).toBeCloseTo(10, 9);
|
||||
expect(c.curve?.a0).toBeCloseTo(0, 9);
|
||||
expect(c.curve?.a1).toBeCloseTo(Math.PI / 2, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contoursToDrawings — Kurven → glatte Formen", () => {
|
||||
it("curve circle → {shape:\"circle\"}", () => {
|
||||
const set: ContourSet = {
|
||||
id: "s", type: "contourSet", name: "c",
|
||||
contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }], curve: { kind: "circle", cx: 1, cy: 2, r: 3, a0: 0, a1: Math.PI * 2 } }],
|
||||
};
|
||||
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
|
||||
expect(d.geom.shape).toBe("circle");
|
||||
if (d.geom.shape === "circle") {
|
||||
expect(d.geom.r).toBeCloseTo(3, 9);
|
||||
expect(d.geom.center.x).toBeCloseTo(1, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it("curve arc → {shape:\"arc\"}", () => {
|
||||
const set: ContourSet = {
|
||||
id: "s", type: "contourSet", name: "c",
|
||||
contours: [{ z: 0, closed: false, pts: [{ x: 0, y: 0 }], curve: { kind: "arc", cx: 0, cy: 0, r: 5, a0: 0, a1: 1 } }],
|
||||
};
|
||||
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
|
||||
expect(d.geom.shape).toBe("arc");
|
||||
if (d.geom.shape === "arc") {
|
||||
expect(d.geom.r).toBeCloseTo(5, 9);
|
||||
expect(d.geom.a1).toBeCloseTo(1, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+32
-714
@@ -17,17 +17,9 @@
|
||||
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
|
||||
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
|
||||
// • LINE → Kontur (zwei-Punkt-Linienzug).
|
||||
// • ARC → Kontur (offener Bogen, tesselliert).
|
||||
// • CIRCLE → Kontur (geschlossener Kreis, tesselliert).
|
||||
// • ELLIPSE → Kontur (Ellipsenbogen/-umlauf, tesselliert).
|
||||
// • SPLINE → Kontur (B-Spline via De Boor; Fallback fitPoints).
|
||||
// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt).
|
||||
// • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler,
|
||||
// Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`).
|
||||
// • TEXT / MTEXT → ImportedText (Position/Höhe/Winkel) → Drawing2D `{shape:"text"}`.
|
||||
|
||||
import DxfParser from "dxf-parser";
|
||||
import type { Contour, ContourSet, ImportedMesh, ImportedText, Vec2 } from "../model/types";
|
||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||
|
||||
/**
|
||||
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl)
|
||||
@@ -42,12 +34,10 @@ export interface ImportDiagnostics {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze + Texte. */
|
||||
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
|
||||
export interface DxfImportResult {
|
||||
meshes: ImportedMesh[];
|
||||
contours: ContourSet[];
|
||||
/** Importierte TEXT/MTEXT-Elemente (optional; der DWG-Parser füllt sie nicht). */
|
||||
texts?: ImportedText[];
|
||||
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
|
||||
diagnostics?: ImportDiagnostics;
|
||||
}
|
||||
@@ -71,57 +61,10 @@ interface DxfEntity {
|
||||
faces?: number[][];
|
||||
isPolyfaceMesh?: boolean;
|
||||
is3dPolygonMesh?: boolean;
|
||||
// Kurven-Entities (ARC/CIRCLE/ELLIPSE). Winkel liefert dxf-parser in RADIANT
|
||||
// (ARC/CIRCLE Grad→rad umgerechnet; ELLIPSE-Parameterwinkel roh in Radiant).
|
||||
center?: DxfVertex;
|
||||
radius?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
/** ELLIPSE: Hauptachsen-Endpunkt RELATIV zum Center. */
|
||||
majorAxisEndPoint?: DxfVertex;
|
||||
/** ELLIPSE: Verhältnis Neben-/Hauptachse (b/a). */
|
||||
axisRatio?: number;
|
||||
// SPLINE-Felder. Winkel/Parameter roh; Knoten/Grad definieren die B-Spline.
|
||||
controlPoints?: DxfVertex[];
|
||||
fitPoints?: DxfVertex[];
|
||||
knotValues?: number[];
|
||||
degreeOfSplineCurve?: number;
|
||||
closed?: boolean;
|
||||
// INSERT-Felder (Block-Referenz). `position` (Einfügepunkt) wird tolerant
|
||||
// gelesen (das Feld ist oben als MESH-Vertexliste getippt) — daher hier NICHT
|
||||
// erneut deklariert; `rotation` ist in GRAD (anders als ARC/CIRCLE).
|
||||
name?: string;
|
||||
xScale?: number;
|
||||
yScale?: number;
|
||||
rotation?: number;
|
||||
columnCount?: number;
|
||||
rowCount?: number;
|
||||
columnSpacing?: number;
|
||||
rowSpacing?: number;
|
||||
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
|
||||
rawCodes?: RawCode[];
|
||||
// TEXT/MTEXT. `position` (MTEXT-Einfügepunkt) wird tolerant gelesen (oben als
|
||||
// MESH-Vertexliste getippt). `rotation` (Grad) ist bereits oben deklariert.
|
||||
startPoint?: DxfVertex; // TEXT-Einfügepunkt
|
||||
textHeight?: number; // TEXT-Höhe (Code 40)
|
||||
height?: number; // MTEXT-Höhe (Code 40)
|
||||
text?: string; // Textinhalt (Code 1, MTEXT auch 3)
|
||||
[k: string]: unknown;
|
||||
}
|
||||
/** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */
|
||||
interface RawCode {
|
||||
code: number;
|
||||
value: number | string;
|
||||
}
|
||||
/** Ein Block (BLOCKS-Tabelle): Basispunkt `position` + eigene Entity-Liste. */
|
||||
interface DxfBlock {
|
||||
name?: string;
|
||||
position?: DxfVertex;
|
||||
entities?: DxfEntity[];
|
||||
}
|
||||
interface DxfDocument {
|
||||
entities?: DxfEntity[];
|
||||
blocks?: Record<string, DxfBlock>;
|
||||
}
|
||||
|
||||
let importSeq = 0;
|
||||
@@ -135,45 +78,46 @@ const nextId = (prefix: string) =>
|
||||
*/
|
||||
export function parseDxf(text: string): DxfImportResult {
|
||||
const parser = new DxfParser();
|
||||
// dxf-parser bringt keinen HATCH-Handler mit (verwirft HATCH sonst stumm).
|
||||
// Eigenen registrieren, der die rohen Gruppencodes einsammelt; die Auswertung
|
||||
// der Randpfade läuft separat und testbar in `hatchContours`.
|
||||
(parser as unknown as {
|
||||
registerEntityHandler: (h: new () => unknown) => void;
|
||||
}).registerEntityHandler(HatchHandler);
|
||||
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
|
||||
const doc = parser.parseSync(text) as unknown as DxfDocument;
|
||||
const entities = doc?.entities ?? [];
|
||||
const blocks = doc?.blocks ?? {};
|
||||
|
||||
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
|
||||
const meshIndices: number[] = [];
|
||||
const contours: Contour[] = [];
|
||||
const texts: ImportedText[] = [];
|
||||
|
||||
for (const e of entities) {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
// Mesh-Entities in die Dreiecks-Puffer; POLYLINE nur als Mesh-Variante.
|
||||
if (type === "3DFACE") {
|
||||
addFace(meshTriangles, meshIndices, e);
|
||||
continue;
|
||||
switch (type) {
|
||||
case "3DFACE":
|
||||
addFace(meshTriangles, meshIndices, e);
|
||||
break;
|
||||
case "MESH":
|
||||
addMesh(meshTriangles, meshIndices, e);
|
||||
break;
|
||||
case "POLYLINE":
|
||||
// POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur.
|
||||
if (isMeshPolyline(e)) {
|
||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||
} else {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
}
|
||||
break;
|
||||
case "LWPOLYLINE": {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "LINE": {
|
||||
const ct = lineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||
break;
|
||||
}
|
||||
if (type === "MESH") {
|
||||
addMesh(meshTriangles, meshIndices, e);
|
||||
continue;
|
||||
}
|
||||
if (type === "POLYLINE" && isMeshPolyline(e)) {
|
||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||
continue;
|
||||
}
|
||||
if (type === "TEXT" || type === "MTEXT") {
|
||||
const it = textEntity(e, type);
|
||||
if (it) texts.push(it);
|
||||
continue;
|
||||
}
|
||||
// Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über
|
||||
// den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion.
|
||||
collectContours(e, blocks, contours, 0);
|
||||
}
|
||||
|
||||
const meshes: ImportedMesh[] = [];
|
||||
@@ -197,43 +141,7 @@ export function parseDxf(text: string): DxfImportResult {
|
||||
});
|
||||
}
|
||||
|
||||
return { meshes, contours: contourSets, texts };
|
||||
}
|
||||
|
||||
// ── TEXT / MTEXT ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fallback-Schrifthöhe (Meter), wenn die DXF-Höhe fehlt/0 ist. */
|
||||
const DEFAULT_TEXT_HEIGHT = 0.25;
|
||||
|
||||
/**
|
||||
* MTEXT-Inhalt von Formatierungscodes säubern (grober erster Wurf): \P → Leer-
|
||||
* zeichen, \~ → Leerzeichen, `\f…;`/`\H…;`/`\C…;`/`\A…;`-Sequenzen und die {}-
|
||||
* Gruppenklammern entfernen, `\{`/`\}`/`\\` entschärfen. Komplexe Verschachte-
|
||||
* lungen werden flachgeklopft (dokumentierte Grenze).
|
||||
*/
|
||||
function sanitizeMText(s: string): string {
|
||||
return s
|
||||
.replace(/\\P/gi, " ")
|
||||
.replace(/\\~/g, " ")
|
||||
.replace(/\\[A-Za-z][^;\\]*;/g, "")
|
||||
.replace(/[{}]/g, "")
|
||||
.replace(/\\([{}\\])/g, "$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** TEXT/MTEXT-Entity → ImportedText (Position/Höhe/Winkel), oder null bei leer. */
|
||||
function textEntity(e: DxfEntity, type: string): ImportedText | null {
|
||||
const rawText = typeof e.text === "string" ? e.text : "";
|
||||
const content = type === "MTEXT" ? sanitizeMText(rawText) : rawText;
|
||||
if (!content) return null;
|
||||
const at: Vec2 =
|
||||
type === "MTEXT"
|
||||
? { x: coord(e.position, "x"), y: coord(e.position, "y") }
|
||||
: { x: e.startPoint?.x ?? 0, y: e.startPoint?.y ?? 0 };
|
||||
const rawHeight = type === "MTEXT" ? numOr(e.height, 0) : numOr(e.textHeight, 0);
|
||||
const height = rawHeight > 0 ? rawHeight : DEFAULT_TEXT_HEIGHT;
|
||||
const angle = (numOr(e.rotation, 0) * Math.PI) / 180; // DXF: Grad CCW → Radiant
|
||||
return { at, text: content, height, angle, layer: e.layer };
|
||||
return { meshes, contours: contourSets };
|
||||
}
|
||||
|
||||
// ── Mesh-Entities ────────────────────────────────────────────────────────────
|
||||
@@ -400,593 +308,3 @@ function lineContour(e: DxfEntity): Contour | null {
|
||||
layer: e.layer,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Kurven-Entities (ARC/CIRCLE/ELLIPSE) → tessellierte Konturen ──────────────
|
||||
|
||||
/** Winkelauflösung der Tessellierung (~5.6° pro Segment). */
|
||||
const CURVE_STEP = Math.PI / 32;
|
||||
/** Obergrenze der Segmentzahl (Schutz gegen entartete Eingaben). */
|
||||
const CURVE_MAX_SEG = 256;
|
||||
|
||||
/** Segmentzahl für eine Winkelspanne (Radiant): mind. 2, gedeckelt. */
|
||||
function segmentsFor(sweep: number): number {
|
||||
return Math.max(2, Math.min(CURVE_MAX_SEG, Math.ceil(Math.abs(sweep) / CURVE_STEP)));
|
||||
}
|
||||
|
||||
/**
|
||||
* ARC → offener Bogen-Linienzug. `startAngle`/`endAngle` in Radiant (dxf-parser
|
||||
* rechnet Grad→rad). Bögen laufen CCW; eine nicht-positive Spanne wird um 2π
|
||||
* ergänzt (voller-Kreis-Fall bleibt 2π).
|
||||
*/
|
||||
function arcContour(e: DxfEntity): Contour | null {
|
||||
const c = e.center;
|
||||
const r = e.radius;
|
||||
if (
|
||||
!c ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
typeof r !== "number" ||
|
||||
!Number.isFinite(r) ||
|
||||
r <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const start = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
|
||||
const end = Number.isFinite(e.endAngle) ? (e.endAngle as number) : Math.PI * 2;
|
||||
let sweep = end - start;
|
||||
if (sweep <= 0) sweep += Math.PI * 2;
|
||||
const cx = c.x ?? 0;
|
||||
const cy = c.y ?? 0;
|
||||
const z = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
const segs = segmentsFor(sweep);
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i <= segs; i++) {
|
||||
const t = start + (sweep * i) / segs;
|
||||
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
||||
}
|
||||
return {
|
||||
z,
|
||||
pts,
|
||||
closed: false,
|
||||
layer: e.layer,
|
||||
curve: { kind: "arc", cx, cy, r, a0: start, a1: start + sweep },
|
||||
};
|
||||
}
|
||||
|
||||
/** 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,
|
||||
curve: { kind: "circle", cx, cy, r, a0: 0, a1: Math.PI * 2 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
+3
-54
@@ -15,7 +15,7 @@
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom, ImportedText } from "../model/types";
|
||||
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
|
||||
|
||||
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
|
||||
export type CategoryMode = "active" | "byLayer";
|
||||
@@ -23,9 +23,6 @@ export type CategoryMode = "active" | "byLayer";
|
||||
let drawingSeq = 0;
|
||||
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
|
||||
|
||||
/** Default-Füllfarbe für aus HATCH importierte Flächen (neutral, restylebar). */
|
||||
const DXF_HATCH_FILL = "#c8c8c8";
|
||||
|
||||
/**
|
||||
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
|
||||
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
|
||||
@@ -48,68 +45,20 @@ export function contoursToDrawings(
|
||||
categoryMode === "byLayer" && layerName
|
||||
? layerToCode(layerName)
|
||||
: fallbackCode;
|
||||
const drawing: Drawing2D = {
|
||||
out.push({
|
||||
id: nextId(),
|
||||
type: "drawing2d",
|
||||
levelId,
|
||||
categoryCode,
|
||||
geom,
|
||||
};
|
||||
// Aus einer HATCH stammende Kontur → geschlossene, gefüllte Form. Vollton-
|
||||
// Füllung als restylebarer Default (neutrales Grau); Umriss bleibt Kategorie.
|
||||
if (contour.filled && geom.shape === "polyline") {
|
||||
drawing.fillColor = DXF_HATCH_FILL;
|
||||
}
|
||||
out.push(drawing);
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt importierte Texte in Drawing2D-`text`-Elemente um — analog
|
||||
* `contoursToDrawings` (gleiche Layer→Kategorie-Zuordnung). Winkel/Höhe kommen
|
||||
* 1:1 aus `ImportedText` (Höhe in Modell-Metern, Winkel in Radiant).
|
||||
*/
|
||||
export function textsToDrawings(
|
||||
texts: ImportedText[],
|
||||
levelId: string,
|
||||
categoryMode: CategoryMode,
|
||||
fallbackCode: string,
|
||||
layerToCode: (layerName: string) => string,
|
||||
): Drawing2D[] {
|
||||
const out: Drawing2D[] = [];
|
||||
for (const it of texts) {
|
||||
const layerName = it.layer ?? "";
|
||||
const categoryCode =
|
||||
categoryMode === "byLayer" && layerName ? layerToCode(layerName) : fallbackCode;
|
||||
out.push({
|
||||
id: nextId(),
|
||||
type: "drawing2d",
|
||||
levelId,
|
||||
categoryCode,
|
||||
geom: {
|
||||
shape: "text",
|
||||
at: { ...it.at },
|
||||
text: it.text,
|
||||
height: it.height,
|
||||
angle: it.angle,
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
|
||||
function contourGeom(contour: Contour): Drawing2DGeom | null {
|
||||
// Echte Kurve (CIRCLE/ARC) → GLATTE Kreis-/Bogen-Form statt tesselliertem Vieleck.
|
||||
const c = contour.curve;
|
||||
if (c) {
|
||||
if (c.kind === "circle") {
|
||||
return { shape: "circle", center: { x: c.cx, y: c.cy }, r: c.r };
|
||||
}
|
||||
return { shape: "arc", center: { x: c.cx, y: c.cy }, r: c.r, a0: c.a0, a1: c.a1 };
|
||||
}
|
||||
const pts = contour.pts;
|
||||
if (pts.length < 2) return null;
|
||||
if (pts.length === 2 && !contour.closed) {
|
||||
|
||||
+10
-68
@@ -267,7 +267,7 @@ function iwWall(id: string, start: Vec2, end: Vec2): Wall {
|
||||
}
|
||||
|
||||
describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
||||
it("W9-artiger T-Knoten: Backstein-Kern des Abzweigs sticht bis zur Rückgrat-Nahfläche durch, Putz wird getrimmt — 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),
|
||||
// alle vom selben Wandtyp "iw" (Innenputz/Backstein/Innenputz, T=0.15).
|
||||
const proj = layeredWallProject(
|
||||
@@ -303,76 +303,18 @@ describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
||||
expect(lc.start[2]).not.toBeNull();
|
||||
expect(distToLine(lc.start[0]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||
expect(distToLine(lc.start[2]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||
// ... aber KEINE L-Seitenlinie: der Nah-Putz der Durchgangswand ist
|
||||
// materialgleicher Innenputz und füllt den getrimmten Abzweig-Putz an den
|
||||
// Flanken durchgehend (der spanCutout schneidet nur die Kernbreite aus).
|
||||
// Es entsteht ein durchgehendes Putz-L ohne Trennnaht. Nur bei material-
|
||||
// FREMDEM Nah-Putz stünde hier eine L-Linie.
|
||||
expect(lc.startSide[0]).toBeNull();
|
||||
expect(lc.startSide[2]).toBeNull();
|
||||
// ... UND bekommen die zusätzliche L-Seitenlinie an der Rückgrat-Nahfläche
|
||||
// (y = 0.06, parallel zur Durchgangsachse) — dort endet der durchstechende
|
||||
// Kern.
|
||||
expect(lc.startSide[0]).not.toBeNull();
|
||||
expect(lc.startSide[2]).not.toBeNull();
|
||||
expect(distToLine(lc.startSide[0]!, { x: 5, y: 0.06 })).toBeCloseTo(0, 6);
|
||||
expect(distToLine(lc.startSide[0]!, { x: 0, y: 0.06 })).toBeCloseTo(0, 6);
|
||||
|
||||
// Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
|
||||
// unangetastet (keine Pro-Schicht-Cuts für sie).
|
||||
// Die kollinearen Hälften der Durchgangswand bleiben unangetastet
|
||||
// (unverändertes Verhalten, keine layerCuts für sie).
|
||||
expect(joins.get("WA1")!.layerCuts).toBeUndefined();
|
||||
expect(joins.get("WA2")!.layerCuts).toBeUndefined();
|
||||
|
||||
// Die als Durchgang gewählte Wandhälfte bekommt eine Durchgangswand-Aussparung
|
||||
// (Phase 1c): über die Backstein-Kernbreite (0.12) im Nah-Putz-Bereich
|
||||
// (Offset 0.06…0.075). Welche Hälfte den Durchgang bildet, hängt von der
|
||||
// Knoten-Reihenfolge ab — wir prüfen die, die den Cutout trägt.
|
||||
const throughCuts = joins.get("WA1")!.spanCutouts ?? joins.get("WA2")!.spanCutouts;
|
||||
expect(throughCuts).toBeDefined();
|
||||
expect(throughCuts!.length).toBe(1);
|
||||
const sc = throughCuts![0];
|
||||
// Achsen-Intervall: Kernbreite 0.12, zentriert am Knoten (x=5 → [4.94,5.06]).
|
||||
expect(sc.to - sc.from).toBeCloseTo(0.12, 6);
|
||||
// offA = Nahfläche (0.075), offB = Rückgrat-Nahfläche (0.06).
|
||||
expect(sc.offA).toBeCloseTo(0.075, 6);
|
||||
expect(sc.offB).toBeCloseTo(0.06, 6);
|
||||
// Der Abzweig selbst trägt keine Aussparung (die gehört der Durchgangswand).
|
||||
expect(joins.get("WB")!.spanCutouts).toBeUndefined();
|
||||
// L-Ecke bzw. Wände ohne T bleiben cutout-frei (die kollineare Nicht-Durchgangs-
|
||||
// Hälfte hat keinen Cutout).
|
||||
expect(
|
||||
joins.get("WA1")!.spanCutouts === undefined ||
|
||||
joins.get("WA2")!.spanCutouts === undefined,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Mittelspannen-T-Stoss: die Durchgangswand bekommt einen spanCutout mit korrekter Kernbreite und Nah-Putz-Offsetzone", () => {
|
||||
// Realer W9-Fall: Durchgangswand als EINE Wand W (0,0)→(10,0), Abzweig WB
|
||||
// trifft mit seinem freien Ende (4,0) mittig auf ihre Seite. Beide iw.
|
||||
const proj = layeredWallProject(
|
||||
iwWall("W", { x: 0, y: 0 }, { x: 10, y: 0 }),
|
||||
iwWall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }),
|
||||
iwWall("Filler", { x: 100, y: 100 }, { x: 100, y: 103 }), // unbeteiligt
|
||||
);
|
||||
const joins = computeJoins(proj, proj.walls);
|
||||
|
||||
const wCuts = joins.get("W")!;
|
||||
expect(wCuts.spanCutouts).toBeDefined();
|
||||
expect(wCuts.spanCutouts!.length).toBe(1);
|
||||
const sc = wCuts.spanCutouts![0];
|
||||
// Intervall um x=4, Breite = Kernbreite 0.12 → [3.94, 4.06].
|
||||
expect(sc.from).toBeCloseTo(3.94, 6);
|
||||
expect(sc.to).toBeCloseTo(4.06, 6);
|
||||
expect(sc.offA).toBeCloseTo(0.075, 6);
|
||||
expect(sc.offB).toBeCloseTo(0.06, 6);
|
||||
// Der Abzweig und die unbeteiligte Wand tragen keine Aussparung.
|
||||
expect(joins.get("WB")!.spanCutouts).toBeUndefined();
|
||||
expect(joins.get("Filler")!.spanCutouts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("L-Ecke und einfacher (nicht materialbewusster) Stoss bekommen KEINEN spanCutout", () => {
|
||||
// Reine L-Ecke zweier Wände: keine Durchgangswand, kein Durchstoss.
|
||||
const proj = twoWallProject(
|
||||
wall("L1", { x: 0, y: 0 }, { x: 5, y: 0 }),
|
||||
wall("L2", { x: 5, y: 0 }, { x: 5, y: 4 }),
|
||||
0.4,
|
||||
);
|
||||
const joins = computeJoins(proj, proj.walls);
|
||||
expect(joins.get("L1")!.spanCutouts).toBeUndefined();
|
||||
expect(joins.get("L2")!.spanCutouts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => {
|
||||
|
||||
+1
-90
@@ -35,29 +35,6 @@ export interface LayerCuts {
|
||||
endSide: (Line | null)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aussparung der DURCHGANGSWAND an einem materialbewussten T-Stoss: über das
|
||||
* Achsen-Intervall `[from,to]` (Meter ab `wall.start` entlang der Achse) und den
|
||||
* Quer-Offsetbereich `[offA,offB]` wird die Nah-Putz-Zone der Durchgangswand
|
||||
* echt ausgeschnitten, damit der durchstechende Abzweig-Kern (Phase 1b) die
|
||||
* Durchgangswand nicht nur übermalt, sondern geometrisch aufbricht.
|
||||
*
|
||||
* OFFSET-KONVENTION (identisch zu den Schicht-Bändern in `addWallPoche`):
|
||||
* `offA`/`offB` sind Querabstände von der Wandachse ENTLANG der linken Normalen
|
||||
* `leftNormal(dir)`, inkl. Referenzversatz — also im selben Bereich wie
|
||||
* `refOff − total/2` (außen) … `refOff + total/2` (innen). `offA` ist stets die
|
||||
* NAHFLÄCHE (Wandoberfläche, `refOff + sign·total/2`), `offB` die dem Abzweig
|
||||
* zugewandte Fläche des Rückgrat-KERNS (`refOff + sign·(total/2 − nearPlaster)`)
|
||||
* — die Zone dazwischen ist exakt der Nah-Putz, den der Abzweig-Kern durchstösst.
|
||||
* `from ≤ to` ist die Projektion der Abzweig-Kernbreite auf die Durchgangsachse.
|
||||
*/
|
||||
export interface SpanCutout {
|
||||
from: number;
|
||||
to: number;
|
||||
offA: number;
|
||||
offB: number;
|
||||
}
|
||||
|
||||
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
|
||||
export interface WallCuts {
|
||||
startCut: Line | null;
|
||||
@@ -69,13 +46,6 @@ export interface WallCuts {
|
||||
* verschmilzt (siehe {@link resolveJoinPriority}).
|
||||
*/
|
||||
layerCuts?: LayerCuts;
|
||||
/**
|
||||
* Additive Erweiterung (Durchgangswand-Seite): Aussparungen der Nah-Putz-Zone,
|
||||
* durch die ein Abzweig-Kern sticht. Fehlt/leer ⇒ die Wand wird unverändert
|
||||
* gezeichnet. Wird nur an der DURCHGANGSWAND gesetzt (nie am Abzweig), analog
|
||||
* additiv zu {@link layerCuts}. Siehe {@link SpanCutout}.
|
||||
*/
|
||||
spanCutouts?: SpanCutout[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,70 +391,11 @@ function applyLayerCuts(
|
||||
// Nachbarschicht kappt ihre Breite.
|
||||
const sideLine: Line = mergeCut;
|
||||
|
||||
// Materialien des durchgehenden Nah-Putzes der Durchgangswand (dieselbe
|
||||
// sign-Seite wie die nearPlaster-Summe oben). Ist die getrimmte Abzweig-Schicht
|
||||
// materialgleich mit einer davon, füllt der Nah-Putz den Abzweig-Putz an dieser
|
||||
// Stelle durchgehend (der spanCutout schneidet nur die KERN-Breite aus, nicht
|
||||
// die schmalen Putzflanken) → Putz-L OHNE Trennnaht, also KEINE L-Seitenlinie.
|
||||
const nearPlasterComps = new Set<string>();
|
||||
for (let i = 0; i < throughWt.layers.length; i++) {
|
||||
if (sign > 0 ? i > backboneIdx : i < backboneIdx)
|
||||
nearPlasterComps.add(throughWt.layers[i].componentId);
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
faceArr[i] = merged[i] ? mergeCut : faceCut;
|
||||
const adjMerged =
|
||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
|
||||
const mergesWithNearPlaster = nearPlasterComps.has(branchWt.layers[i].componentId);
|
||||
sideArr[i] = adjMerged && !mergesWithNearPlaster ? sideLine : null;
|
||||
}
|
||||
|
||||
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
|
||||
// die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur nötig,
|
||||
// wenn davor überhaupt Nah-Putz liegt (nearPlaster > 0) — sonst schliesst der
|
||||
// Kern flächenbündig an und es gibt nichts aufzubrechen.
|
||||
if (nearPlaster > 1e-9) {
|
||||
// Kernbreite des Abzweigs im Quer-Offset (Offset-Konvention der Bänder:
|
||||
// gestapelt entlang leftNormal ab refOff − total/2). Die verschmelzenden
|
||||
// Schichten (`merged`) bilden den Kern; Lo/Hi = ihre Offset-Hülle.
|
||||
const totalBranch = wallTypeThickness(branchWt);
|
||||
const refOffBranch = wallReferenceOffset(branchWall, totalBranch);
|
||||
let bOff = refOffBranch - totalBranch / 2;
|
||||
let coreLo = Infinity;
|
||||
let coreHi = -Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const th = branchWt.layers[i].thickness;
|
||||
if (merged[i]) {
|
||||
if (bOff < coreLo) coreLo = bOff;
|
||||
if (bOff + th > coreHi) coreHi = bOff + th;
|
||||
}
|
||||
bOff += th;
|
||||
}
|
||||
if (coreHi > coreLo) {
|
||||
// Projektion der Kern-Offsethülle auf die Durchgangsachse: der Achsenpunkt
|
||||
// liegt bei `axisAt` (Meter ab Durchgangs-Start), ein Abzweig-Offset `o`
|
||||
// verschiebt sich entlang der Achse um `o · (nBranch · uT)`.
|
||||
const nBranch = leftNormal(dirOf(branchWall));
|
||||
const proj = dot(nBranch, throughDir);
|
||||
const axisAt = dot(sub(axisPoint, throughWall.start), throughDir);
|
||||
const e1 = axisAt + coreLo * proj;
|
||||
const e2 = axisAt + coreHi * proj;
|
||||
// Quer-Offsetbereich = Nah-Putz-Zone der Durchgangswand: Nahfläche (offA)
|
||||
// bis Rückgrat-Nahfläche (offB, = mergeCut-Offset). Beide in derselben
|
||||
// Offset-Konvention wie die Schicht-Bänder der Durchgangswand.
|
||||
const offA = offT + sign * (tT / 2);
|
||||
const offB = offT + sign * (tT / 2 - nearPlaster);
|
||||
const throughCuts = result.get(throughWall.id);
|
||||
if (throughCuts) {
|
||||
(throughCuts.spanCutouts ??= []).push({
|
||||
from: Math.min(e1, e2),
|
||||
to: Math.max(e1, e2),
|
||||
offA,
|
||||
offB,
|
||||
});
|
||||
}
|
||||
}
|
||||
sideArr[i] = adjMerged ? sideLine : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-14
@@ -16,18 +16,22 @@ export const sampleProject: Project = {
|
||||
id: "demo",
|
||||
name: "Demo-Haus",
|
||||
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
|
||||
// Linienstile: aufgeräumt auf kanonische VOLLLINIEN je Strichstärke (dash=null)
|
||||
// + eine gestrichelte Linie. Früher gab es drei funktional identische 0.13-
|
||||
// Volllinien ("thin"/"hatch-line"/"joint-massive") — zusammengeführt auf "thin"
|
||||
// ("Volllinie 0.13"); Schraffur-/Schichtfugen-Referenzen zeigen jetzt darauf.
|
||||
// (Die ids bleiben stabil, damit geladene Projekte + interne Referenzen halten.)
|
||||
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line"
|
||||
// ist der dünne Strich für Schraffur-Muster.
|
||||
lineStyles: [
|
||||
{ id: "hatch-hair", name: "Volllinie 0.02", weight: 0.02, color: "#1a1a1a", dash: null },
|
||||
{ id: "thin", name: "Volllinie 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
{ id: "medium", name: "Volllinie 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
|
||||
{ id: "thick", name: "Volllinie 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
|
||||
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
|
||||
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
|
||||
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren).
|
||||
{ id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
// Feinste Haarlinie für die SIA-Schnittschraffuren (Beton/Backstein/Dämmung):
|
||||
// Gewicht unter der Bildschirm-Untergrenze, damit die Musterlinien als dünnste
|
||||
// Haarlinie (Stroke-Minimum) rendern.
|
||||
{ id: "hatch-hair", name: "Schraffur-Haarlinie", weight: 0.02, color: "#1a1a1a", dash: null },
|
||||
// Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke).
|
||||
{ id: "hatch-dash", name: "Dämmungsstrich", weight: 0.02, color: "#1a1a1a", dash: [0.35, 0.3] },
|
||||
// Kräftige Schichtfuge, wo zwei massive Materialien aneinanderstoßen (Wandstile).
|
||||
{ id: "joint-massive", name: "Schichtfuge 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
],
|
||||
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
|
||||
// "none" = keine Schraffur (nur Component-Füllung).
|
||||
@@ -42,7 +46,7 @@ export const sampleProject: Project = {
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "thin",
|
||||
lineStyleId: "hatch-line",
|
||||
},
|
||||
{
|
||||
id: "diagonal",
|
||||
@@ -51,7 +55,7 @@ export const sampleProject: Project = {
|
||||
scale: 1,
|
||||
angle: 45,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "thin",
|
||||
lineStyleId: "hatch-line",
|
||||
},
|
||||
{
|
||||
id: "crosshatch",
|
||||
@@ -60,7 +64,7 @@ export const sampleProject: Project = {
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "thin",
|
||||
lineStyleId: "hatch-line",
|
||||
},
|
||||
{
|
||||
id: "solid-concrete",
|
||||
@@ -129,7 +133,7 @@ export const sampleProject: Project = {
|
||||
layers: [
|
||||
{ componentId: "render-ext", thickness: 0.02 },
|
||||
// Innere Fuge der Dämmung trifft auf den Backstein (massiv↔massiv) → 0.13 mm.
|
||||
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "thin" },
|
||||
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "joint-massive" },
|
||||
{ componentId: "brick", thickness: 0.15 },
|
||||
{ componentId: "render-int", thickness: 0.015 },
|
||||
],
|
||||
@@ -156,7 +160,7 @@ export const sampleProject: Project = {
|
||||
// Estrich (schwimmend) → Trittschalldämmung.
|
||||
{ componentId: "screed", thickness: 0.06 },
|
||||
// Innere Fuge der Dämmung trifft auf die Betondecke (massiv) → 0.13 mm.
|
||||
{ componentId: "insulation", thickness: 0.04, jointLineStyleId: "thin" },
|
||||
{ componentId: "insulation", thickness: 0.04, jointLineStyleId: "joint-massive" },
|
||||
// Betondecke (Rohdecke, tragend) — innerste/unterste Schicht.
|
||||
{ componentId: "concrete", thickness: 0.2 },
|
||||
],
|
||||
|
||||
@@ -731,22 +731,6 @@ export interface Opening {
|
||||
height: number;
|
||||
/** Brüstungshöhe (Unterkante der Öffnung) über der Wand-UK; 0 bei Türen. */
|
||||
sillHeight: number;
|
||||
/**
|
||||
* Nur Fenster: Anzahl der Flügel (1–4). Bei > 1 werden im Plan `wingCount − 1`
|
||||
* Mittelpfosten (Querlinien quer zur Öffnungsrichtung) gleichmäßig innerhalb des
|
||||
* Rahmens gezeichnet — analog den Mittelpfosten in `_make_oeffnung_pieces` des
|
||||
* Rhino-Plugins (`oeff_fluegel`). Fehlt es, gilt 1 (= heutiges Verhalten: keine
|
||||
* Pfosten).
|
||||
*/
|
||||
wingCount?: number;
|
||||
/**
|
||||
* Nur Tür: Tür-Typ (analog `oeff_tuer_typ` im Rhino-Plugin).
|
||||
* • "normal" — Türblatt + Schwenkbogen (Default = heutiges Verhalten).
|
||||
* • "wandoeffnung" — reiner Wanddurchbruch: KEIN Türblatt, KEIN Schwenkbogen;
|
||||
* nur die Öffnung/Laibung (Wandlücke + ggf. Sturz-/Anschlaglinien) bleibt.
|
||||
* Fehlt es, gilt "normal".
|
||||
*/
|
||||
doorType?: "normal" | "wandoeffnung";
|
||||
/** Nur Tür: an welchem Pfosten das Scharnier sitzt. */
|
||||
hinge?: "start" | "end";
|
||||
/** Nur Tür: auf welche Seite der Wandachse das Blatt aufschlägt. */
|
||||
@@ -757,15 +741,6 @@ export interface Opening {
|
||||
openingDir?: "in" | "out";
|
||||
/** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */
|
||||
frameThickness?: number;
|
||||
/**
|
||||
* Nur Tür: Sturzlinien (SIA, gestrichelt) quer über die Öffnung an der Wand-
|
||||
* Innen- und/oder Aussenkante. Zeigt die Überkopf-Projektion des Sturzes.
|
||||
* "keine" → keine Sturzlinien
|
||||
* "innen" → eine Linie an der Wand-Innenkante
|
||||
* "aussen"→ eine Linie an der Wand-Aussenkante
|
||||
* "beide" → beide Linien (Default wenn nicht gesetzt)
|
||||
*/
|
||||
lintelLines?: "keine" | "innen" | "aussen" | "beide";
|
||||
/**
|
||||
* Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die
|
||||
* Kategorie-Farbe.
|
||||
@@ -830,19 +805,6 @@ export interface Stair {
|
||||
sweep?: number;
|
||||
/** Laufbreite in Metern (quer zur Laufrichtung). */
|
||||
width: number;
|
||||
/**
|
||||
* Lage der gespeicherten Achse (`start`/`dir` bzw. Wendel-Bogen) über die
|
||||
* Laufbreite — analog `treppe_referenz` im Rhino-Plugin. Bestimmt, ob die Achse
|
||||
* die LINKE, MITTLERE oder RECHTE Kante der Treppe repräsentiert (in
|
||||
* Laufrichtung gesehen). Fehlt sie, gilt "mitte" (= heutiges Verhalten: Achse =
|
||||
* Treppen-Mitte).
|
||||
* • "links" — Achse = linke Kante; die Treppe liegt rechts der Achse.
|
||||
* • "mitte" — Achse = Mitte (Default).
|
||||
* • "rechts" — Achse = rechte Kante; die Treppe liegt links der Achse.
|
||||
* Die Lauflinie samt Pfeil bleibt IMMER auf der visuellen Treppen-Mitte
|
||||
* (nicht auf der Referenzkante) — siehe `stairGeometry`.
|
||||
*/
|
||||
referenz?: "links" | "mitte" | "rechts";
|
||||
/**
|
||||
* Gesamt-Steighöhe in Metern (OKFF → OKFF nächstes Geschoss). Fehlt sie, gilt
|
||||
* beim Auflösen die Geschosshöhe des zugehörigen Geschosses.
|
||||
@@ -1093,38 +1055,6 @@ export interface Contour {
|
||||
closed: boolean;
|
||||
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
|
||||
layer?: string;
|
||||
/**
|
||||
* Gefüllte Fläche (aus einer DXF-HATCH). Beim Drawing-Import wird daraus eine
|
||||
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
|
||||
*/
|
||||
filled?: boolean;
|
||||
/**
|
||||
* Wahre Kurvengeometrie (aus CIRCLE/ARC). `pts` bleibt tesselliert (Kontext/
|
||||
* 3D), aber der Drawing-Import baut daraus eine GLATTE `{shape:"circle"|"arc"}`-
|
||||
* Form statt eines Vielecks. Winkel in RADIANT (CCW); Kreis = a0..a1 über 2π.
|
||||
*/
|
||||
curve?: {
|
||||
kind: "circle" | "arc";
|
||||
cx: number;
|
||||
cy: number;
|
||||
r: number;
|
||||
a0: number;
|
||||
a1: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein importiertes Text-Element (DXF TEXT/MTEXT). `at` = Einfügepunkt in Metern,
|
||||
* `height` = Schrifthöhe in Modell-Metern, `angle` = Drehung in RADIANT (CCW).
|
||||
* Wird beim Drawing-Import zu einem `{shape:"text"}`-Drawing2D.
|
||||
*/
|
||||
export interface ImportedText {
|
||||
at: Vec2;
|
||||
text: string;
|
||||
height: number;
|
||||
angle: number;
|
||||
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
|
||||
layer?: string;
|
||||
}
|
||||
|
||||
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
|
||||
|
||||
@@ -30,16 +30,6 @@ import type { AttributeSource } from "../model/types";
|
||||
import { usePanelHost } from "./host";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
import { ColorHexField } from "../ui/ColorHexField";
|
||||
// Element-spezifische Attribut-Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) —
|
||||
// aus ObjectInfoPanel hierher verschoben (Ribbon Phase 3: ObjectInfo trägt nur
|
||||
// noch Bezugspunkt + Masse).
|
||||
import {
|
||||
WallSection,
|
||||
CeilingSection,
|
||||
OpeningSection,
|
||||
StairSection,
|
||||
RoomSection,
|
||||
} from "./ObjectInfoPanel";
|
||||
|
||||
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
|
||||
* „custom" ist abgeleitet aus „ist ein expliziter Wert gesetzt?", nicht
|
||||
@@ -57,38 +47,11 @@ export function AttributesPanel() {
|
||||
const sel = host.selection;
|
||||
const { project } = host;
|
||||
|
||||
// Wandtyp-/Deckentyp-Wahl für das NEUE Bauteil (was gezeichnet wird) — früher
|
||||
// in der Werkzeug-Sidebar (ToolsPanel), seit deren Entfernung hier. Erscheint
|
||||
// nur bei aktivem Wand-/Decken-Werkzeug (unabhängig von einer Auswahl); Wand und
|
||||
// Decke teilen sich denselben Aufbau-Typ (WallType) + Host-State.
|
||||
const drawingComponent =
|
||||
host.activeTool === "wall" || host.activeTool === "ceiling";
|
||||
const typeSection = drawingComponent ? (
|
||||
<>
|
||||
<div className="attr-section-label">{t("attr.newComponent")}</div>
|
||||
<div className="attr-grid">
|
||||
<span className="attr-key">
|
||||
{t(host.activeTool === "wall" ? "tool.wallType" : "tool.ceilingType")}
|
||||
</span>
|
||||
<span className="attr-val">
|
||||
<Dropdown
|
||||
value={host.activeWallTypeId}
|
||||
disabled={!host.toolsEnabled}
|
||||
onChange={(v) => host.onActiveWallTypeId(v)}
|
||||
options={project.wallTypes.map((wt) => ({ value: wt.id, label: wt.name }))}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// Leerzustand: der Typ-Picker (falls ein Bauteil-Werkzeug aktiv ist), sonst ein
|
||||
// kompakter Hinweis.
|
||||
// Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist.
|
||||
if (sel === null) {
|
||||
return (
|
||||
<div className="attr-panel">
|
||||
{typeSection}
|
||||
{!typeSection && <div className="attr-empty">{t("attr.empty")}</div>}
|
||||
<div className="attr-empty">{t("attr.empty")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,6 +80,7 @@ export function AttributesPanel() {
|
||||
onChange: (next: UiSource) => void,
|
||||
) => (
|
||||
<Dropdown
|
||||
width={104}
|
||||
value={ui}
|
||||
disabled={!editable}
|
||||
onChange={(v) => onChange(v as UiSource)}
|
||||
@@ -146,7 +110,7 @@ export function AttributesPanel() {
|
||||
) => (
|
||||
<>
|
||||
<span className="attr-key">{t(labelKey)}</span>
|
||||
<span className="attr-val">
|
||||
<span className="attr-val" style={{ gap: 6 }}>
|
||||
{ui === "custom" && valueEditor}
|
||||
{sourceSelect(editable, ui, onSource)}
|
||||
</span>
|
||||
@@ -195,15 +159,11 @@ export function AttributesPanel() {
|
||||
|
||||
return (
|
||||
<div className="attr-panel">
|
||||
{/* Typ-Picker für das neue Bauteil (nur bei aktivem Wand-/Decken-Werkzeug). */}
|
||||
{typeSection}
|
||||
{/* EIN durchgehendes Grid über alle Sektionen — dieselben zwei Spalten
|
||||
(Label | Wert) für Stift/Füllung/Maße, damit die Wertfelder überall
|
||||
bündig in EINER Spalte stehen. Sektion-Balken spannen beide Spalten. */}
|
||||
<div className="attr-grid">
|
||||
{/* ── Stift/Strich ──────────────────────────────────────────── */}
|
||||
<div className="attr-section-label">{t("attr.stroke")}</div>
|
||||
<div className="attr-title">{t("attr.title")}</div>
|
||||
|
||||
{/* ── Stift/Strich ──────────────────────────────────────────────── */}
|
||||
<div className="attr-section-label">{t("attr.stroke")}</div>
|
||||
<div className="attr-grid">
|
||||
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
|
||||
<span className="attr-key">{t("attr.color")}</span>
|
||||
<span className="attr-val">
|
||||
@@ -248,6 +208,7 @@ export function AttributesPanel() {
|
||||
onChange={(v) =>
|
||||
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
|
||||
}
|
||||
width={120}
|
||||
options={[
|
||||
{ value: "__none__", label: t("attr.lineStyle.none") },
|
||||
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
|
||||
@@ -257,13 +218,14 @@ export function AttributesPanel() {
|
||||
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────── */}
|
||||
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/
|
||||
Poché. Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) /
|
||||
Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. */}
|
||||
<div className="attr-section-label">{t("attr.fill")}</div>
|
||||
|
||||
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────────── */}
|
||||
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/Poché.
|
||||
Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) / Nach Bauteil
|
||||
(erbt vom Component-Manager, Default) / eigener Wert (Override). */}
|
||||
<div className="attr-section-label">{t("attr.fill")}</div>
|
||||
<div className="attr-grid">
|
||||
{sourceRow(
|
||||
"attr.foreground",
|
||||
pocheEditable,
|
||||
@@ -286,6 +248,7 @@ export function AttributesPanel() {
|
||||
hatchUi,
|
||||
onHatchSourceChange,
|
||||
<Dropdown
|
||||
width={104}
|
||||
value={sel.fillHatchId ?? ""}
|
||||
onChange={(v) => host.onSetSelectionFill(v || null)}
|
||||
options={[
|
||||
@@ -294,23 +257,16 @@ export function AttributesPanel() {
|
||||
]}
|
||||
/>,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Maße (informativ, aus der bbox) ───────────────────────── */}
|
||||
<div className="attr-section-label">{t("attr.size")}</div>
|
||||
{/* ── Maße (informativ, aus der bbox) ───────────────────────────── */}
|
||||
<div className="attr-section-label">{t("attr.size")}</div>
|
||||
<div className="attr-grid">
|
||||
<span className="attr-key">{t("attr.width")}</span>
|
||||
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
|
||||
<span className="attr-key">{t("attr.height")}</span>
|
||||
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
|
||||
</div>
|
||||
|
||||
{/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben) —
|
||||
nur der jeweils passende erscheint. Nutzen weiter die `objinfo-*`-
|
||||
Darstellung + die Host-Handler. */}
|
||||
{sel.wall && <WallSection wall={sel.wall} host={host} />}
|
||||
{sel.ceiling && <CeilingSection ceiling={sel.ceiling} host={host} />}
|
||||
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
|
||||
{sel.stair && <StairSection stair={sel.stair} host={host} />}
|
||||
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,14 +82,40 @@ export function ObjectInfoPanel() {
|
||||
host.onResizeSelection(nextWidth, nextHeight, anchor);
|
||||
}
|
||||
|
||||
const wall = sel.wall;
|
||||
const ceiling = sel.ceiling;
|
||||
const opening = sel.opening;
|
||||
const stair = sel.stair;
|
||||
const room = sel.room;
|
||||
|
||||
return (
|
||||
<div className="objinfo-panel">
|
||||
{/* Kopfzeile: Typ + Kategorie. Die element-spezifischen Attribut-Abschnitte
|
||||
(Wand/Decke/Öffnung/Treppe/Raum) liegen jetzt im Attribute-Panel;
|
||||
ObjectInfo trägt nur noch Bezugspunkt + Masse. */}
|
||||
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das
|
||||
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */}
|
||||
<div className="objinfo-head">
|
||||
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
|
||||
<span className="objinfo-cat">{sel.categoryCode}</span>
|
||||
{wall ? (
|
||||
<div className="objinfo-head-ref" title={t("objinfo.wall.refLine")}>
|
||||
<span className="objinfo-head-ref-label">
|
||||
{t("objinfo.wall.refLine")}
|
||||
</span>
|
||||
<Dropdown
|
||||
value={wall.referenceLine}
|
||||
onChange={(v) =>
|
||||
host.onSetWallReferenceLine(v as WallReferenceLine)
|
||||
}
|
||||
options={[
|
||||
{ value: "left", label: t("objinfo.wall.refLine.left") },
|
||||
{ value: "center", label: t("objinfo.wall.refLine.center") },
|
||||
{ value: "right", label: t("objinfo.wall.refLine.right") },
|
||||
]}
|
||||
title={t("objinfo.wall.refLine")}
|
||||
width={120}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="objinfo-cat">{sel.categoryCode}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
@@ -148,9 +174,21 @@ export function ObjectInfoPanel() {
|
||||
value={height}
|
||||
onCommit={(h) => commitSize(width, h)}
|
||||
/>
|
||||
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
|
||||
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
||||
die exportierten *Section-Komponenten). */}
|
||||
|
||||
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
|
||||
{wall && <WallSection wall={wall} host={host} />}
|
||||
|
||||
{/* ── Decken-Abschnitt (nur bei genau einer Decke) ─────────────────── */}
|
||||
{ceiling && <CeilingSection ceiling={ceiling} host={host} />}
|
||||
|
||||
{/* ── Öffnungs-Abschnitt (nur bei genau einer Öffnung) ─────────────── */}
|
||||
{opening && <OpeningSection opening={opening} host={host} />}
|
||||
|
||||
{/* ── Treppen-Abschnitt (nur bei genau einer Treppe) ───────────────── */}
|
||||
{stair && <StairSection stair={stair} host={host} />}
|
||||
|
||||
{/* ── Raum-Abschnitt (nur bei genau einem Raum) ────────────────────── */}
|
||||
{room && <RoomSection room={room} roomId={sel.id} host={host} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +197,7 @@ export function ObjectInfoPanel() {
|
||||
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
|
||||
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
|
||||
// Stempel-Editor öffnet. Alle Werte/Setter über den Host.
|
||||
export function RoomSection({
|
||||
function RoomSection({
|
||||
room,
|
||||
roomId,
|
||||
host,
|
||||
@@ -231,7 +269,7 @@ export function RoomSection({
|
||||
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
|
||||
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
|
||||
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
|
||||
export function StairSection({
|
||||
function StairSection({
|
||||
stair,
|
||||
host,
|
||||
}: {
|
||||
@@ -259,22 +297,6 @@ export function StairSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Referenzpunkt der Laufbreite (links/mitte/rechts). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.referenz")}</span>
|
||||
<Dropdown
|
||||
value={stair.referenz ?? "mitte"}
|
||||
onChange={(v) => host.onSetStairReferenz(v as "links" | "mitte" | "rechts")}
|
||||
options={[
|
||||
{ value: "links", label: t("objinfo.stair.referenz.links") },
|
||||
{ value: "mitte", label: t("objinfo.stair.referenz.mitte") },
|
||||
{ value: "rechts", label: t("objinfo.stair.referenz.rechts") },
|
||||
]}
|
||||
title={t("objinfo.stair.referenz")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Laufrichtung (aufwärts/abwärts). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
|
||||
@@ -347,7 +369,7 @@ export function StairSection({
|
||||
// ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
|
||||
// Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen
|
||||
// zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only.
|
||||
export function OpeningSection({
|
||||
function OpeningSection({
|
||||
opening,
|
||||
host,
|
||||
}: {
|
||||
@@ -404,55 +426,16 @@ export function OpeningSection({
|
||||
onCommit={(v) => host.onSetOpeningHeight(v)}
|
||||
/>
|
||||
{!isDoor && (
|
||||
<>
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.sill")}
|
||||
value={opening.sillHeight}
|
||||
onCommit={(v) => host.onSetOpeningSill(v)}
|
||||
/>
|
||||
{/* Fenster-spezifisch: Flügelanzahl (1–4). */}
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.wingCount")}
|
||||
value={opening.wingCount ?? 1}
|
||||
min={1}
|
||||
onCommit={(v) => host.onSetOpeningWingCount(Math.max(1, Math.min(4, Math.round(v))))}
|
||||
/>
|
||||
</>
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.sill")}
|
||||
value={opening.sillHeight}
|
||||
onCommit={(v) => host.onSetOpeningSill(v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tür-spezifisch: Typ / Sturzlinien / Anschlag / Aufschlagseite / Richtung / Winkel. */}
|
||||
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
|
||||
{isDoor && (
|
||||
<>
|
||||
{/* Tür-Typ (normal / Wandöffnung). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.doorType")}</span>
|
||||
<Dropdown
|
||||
value={opening.doorType ?? "normal"}
|
||||
onChange={(v) => host.onSetOpeningDoorType(v as "normal" | "wandoeffnung")}
|
||||
options={[
|
||||
{ value: "normal", label: t("objinfo.opening.doorType.normal") },
|
||||
{ value: "wandoeffnung", label: t("objinfo.opening.doorType.wandoeffnung") },
|
||||
]}
|
||||
title={t("objinfo.opening.doorType")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
{/* Sturzlinien-Darstellung (keine/innen/aussen/beide). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.lintelLines")}</span>
|
||||
<Dropdown
|
||||
value={opening.lintelLines ?? "beide"}
|
||||
onChange={(v) => host.onSetOpeningLintelLines(v as "keine" | "innen" | "aussen" | "beide")}
|
||||
options={[
|
||||
{ value: "keine", label: t("objinfo.opening.lintelLines.keine") },
|
||||
{ value: "innen", label: t("objinfo.opening.lintelLines.innen") },
|
||||
{ value: "aussen", label: t("objinfo.opening.lintelLines.aussen") },
|
||||
{ value: "beide", label: t("objinfo.opening.lintelLines.beide") },
|
||||
]}
|
||||
title={t("objinfo.opening.lintelLines")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
|
||||
<Dropdown
|
||||
@@ -519,7 +502,7 @@ export function OpeningSection({
|
||||
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
|
||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
|
||||
export function CeilingSection({
|
||||
function CeilingSection({
|
||||
ceiling,
|
||||
host,
|
||||
}: {
|
||||
@@ -632,7 +615,7 @@ export function CeilingSection({
|
||||
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
|
||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
|
||||
export function WallSection({
|
||||
function WallSection({
|
||||
wall,
|
||||
host,
|
||||
}: {
|
||||
@@ -663,23 +646,6 @@ export function WallSection({
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
|
||||
|
||||
{/* Referenzlinie (wo die Achse über die Dicke liegt) — früher im ObjectInfo-
|
||||
Kopf, jetzt hier bei den Wand-Attributen. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.refLine")}</span>
|
||||
<Dropdown
|
||||
value={wall.referenceLine}
|
||||
onChange={(v) => host.onSetWallReferenceLine(v as WallReferenceLine)}
|
||||
options={[
|
||||
{ value: "left", label: t("objinfo.wall.refLine.left") },
|
||||
{ value: "center", label: t("objinfo.wall.refLine.center") },
|
||||
{ value: "right", label: t("objinfo.wall.refLine.right") },
|
||||
]}
|
||||
title={t("objinfo.wall.refLine")}
|
||||
width={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Aufbau-Typ-Segment. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
|
||||
|
||||
+40
-18
@@ -13,12 +13,13 @@ import { t } from "../i18n";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { ToolId } from "./host";
|
||||
import { TOOL_ORDER, getTool } from "../tools/tools";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
|
||||
// ── Icons ──────────────────────────────────────────────────────────────────
|
||||
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
|
||||
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
|
||||
// Zeile übernimmt.
|
||||
export function ToolIcon({ id }: { id: ToolId }) {
|
||||
function ToolIcon({ id }: { id: ToolId }) {
|
||||
const common = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
@@ -110,20 +111,6 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
||||
<rect x="2.5" y="3.5" width="11" height="9" />
|
||||
</svg>
|
||||
);
|
||||
case "circle":
|
||||
// Kreis-Umriss.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="8" cy="8" r="5.5" />
|
||||
</svg>
|
||||
);
|
||||
case "arc":
|
||||
// Offener Halbbogen.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -161,9 +148,44 @@ export function ToolsPanel() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
|
||||
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
|
||||
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
|
||||
<div className="tools-sep" />
|
||||
|
||||
{/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */}
|
||||
{host.activeTool === "wall" && (
|
||||
<label className="tools-field">
|
||||
<span>{t("tool.wallType")}</span>
|
||||
<Dropdown
|
||||
value={host.activeWallTypeId}
|
||||
disabled={!host.toolsEnabled}
|
||||
onChange={(v) => host.onActiveWallTypeId(v)}
|
||||
options={host.project.wallTypes.map((wt) => ({
|
||||
value: wt.id,
|
||||
label: wt.name,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{host.activeTool === "wall" && <div className="tools-sep" />}
|
||||
|
||||
{/* Deckentyp-Auswahl — nur sichtbar, wenn das Decken-Werkzeug aktiv ist.
|
||||
Nutzt denselben Aufbau-Typ (WallType) wie die Wand. */}
|
||||
{host.activeTool === "ceiling" && (
|
||||
<label className="tools-field">
|
||||
<span>{t("tool.ceilingType")}</span>
|
||||
<Dropdown
|
||||
value={host.activeWallTypeId}
|
||||
disabled={!host.toolsEnabled}
|
||||
onChange={(v) => host.onActiveWallTypeId(v)}
|
||||
options={host.project.wallTypes.map((wt) => ({
|
||||
value: wt.id,
|
||||
label: wt.name,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{host.activeTool === "ceiling" && <div className="tools-sep" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,12 +236,6 @@ export interface PanelHostValue {
|
||||
onSetOpeningSwing: (swing: "left" | "right") => void;
|
||||
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
|
||||
onSetOpeningDir: (dir: "in" | "out") => void;
|
||||
/** Setzt die Flügelanzahl des Fensters (1–4). */
|
||||
onSetOpeningWingCount: (wingCount: number) => void;
|
||||
/** Setzt den Tür-Typ (normal / Wandöffnung). */
|
||||
onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void;
|
||||
/** Setzt die Sturzlinien-Darstellung der Tür. */
|
||||
onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void;
|
||||
|
||||
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
|
||||
/** Setzt die Grundform (gerade/L/Wendel). */
|
||||
@@ -254,8 +248,6 @@ export interface PanelHostValue {
|
||||
onSetStairRise: (totalRise: number) => void;
|
||||
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
||||
onSetStairUp: (up: boolean) => void;
|
||||
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
|
||||
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
|
||||
|
||||
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
|
||||
/** Setzt den Raum-Namen. */
|
||||
|
||||
+6
-13
@@ -31,10 +31,7 @@ import type {
|
||||
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
|
||||
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
|
||||
*/
|
||||
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
|
||||
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
|
||||
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
|
||||
export const LAYOUT_VERSION = 8;
|
||||
export const LAYOUT_VERSION = 7;
|
||||
|
||||
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
|
||||
const CURRENT_KEY = "cad.layout";
|
||||
@@ -58,12 +55,9 @@ const NAMED_PREFIX = "cad.layouts.";
|
||||
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
|
||||
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
|
||||
*/
|
||||
// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default-
|
||||
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im
|
||||
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar),
|
||||
// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe.
|
||||
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
||||
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 },
|
||||
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
|
||||
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
|
||||
];
|
||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
|
||||
@@ -92,10 +86,9 @@ function cloneGroups(groups: DockGroup[]): DockGroup[] {
|
||||
// ── Standard ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liefert den eingebauten Startzustand: links das Attribute-Panel (volle Höhe),
|
||||
* rechts Objektinfo/Raumbilanz oben + Zeichnungsebenen/Ebenen/Umgebung unten.
|
||||
* Die Werkzeuge liegen im Ribbon (keine Sidebar mehr). Jeder Aufruf erzeugt
|
||||
* frische Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
|
||||
* Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben,
|
||||
* Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische
|
||||
* Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
|
||||
*/
|
||||
export function defaultLayout(): LayoutState {
|
||||
return {
|
||||
|
||||
+1
-85
@@ -131,8 +131,6 @@ export interface PlanViewHandle {
|
||||
applyScale: (n: number) => void;
|
||||
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */
|
||||
pxPerMeter: () => number;
|
||||
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
|
||||
viewCenterModel: () => Vec2;
|
||||
}
|
||||
|
||||
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
|
||||
@@ -633,11 +631,6 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
if (n > 0) setPaperScale(n);
|
||||
},
|
||||
pxPerMeter: () => currentPxPerMeter(),
|
||||
viewCenterModel: () => {
|
||||
// Mitte der frischen viewBox (aus Ref) → Modell (inverse toScreen).
|
||||
const v = viewRef.current;
|
||||
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
@@ -1647,15 +1640,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
|
||||
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
|
||||
// Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien.
|
||||
// `drawingText` (importierter Einzeltext) rendert IMMER im SVG — der GL-
|
||||
// wie der WASM-Renderer zeichnet ihn nicht. Der Raum-Stempel (`text`)
|
||||
// bleibt: unter WASM rastert ihn render2d selbst (sonst doppelt).
|
||||
(useGpuRenderer
|
||||
? wantWasm
|
||||
? p.kind !== "drawingText"
|
||||
: p.kind !== "text" && p.kind !== "drawingText"
|
||||
: p.kind === "line" && p.drawingId)
|
||||
? null : (
|
||||
(useGpuRenderer ? wantWasm || p.kind !== "text" : p.kind === "line" && p.drawingId) ? null : (
|
||||
<PrimitiveShape
|
||||
key={i}
|
||||
p={p}
|
||||
@@ -3080,74 +3065,5 @@ function renderPrimitive(
|
||||
</text>
|
||||
);
|
||||
}
|
||||
case "drawingText": {
|
||||
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
|
||||
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
|
||||
const c = toScreen(p.at);
|
||||
const fontSize = p.heightM * PX_PER_M;
|
||||
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
|
||||
const deg = -(p.angle * 180) / Math.PI;
|
||||
return (
|
||||
<text
|
||||
x={c.x}
|
||||
y={c.y}
|
||||
fontSize={fontSize}
|
||||
fontFamily="var(--font-ui, sans-serif)"
|
||||
fill={p.color}
|
||||
opacity={p.greyed ? 0.45 : undefined}
|
||||
textAnchor="start"
|
||||
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
|
||||
style={{ pointerEvents: "none", userSelect: "none" }}
|
||||
>
|
||||
{p.text}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
case "drawingCircle": {
|
||||
// Echtes `<circle>` — rund bei jedem Zoom (kein Vieleck). Füllung optional.
|
||||
const c = toScreen(p.center);
|
||||
const r = p.r * PX_PER_M;
|
||||
return (
|
||||
<circle
|
||||
cx={c.x}
|
||||
cy={c.y}
|
||||
r={r}
|
||||
fill={p.fill}
|
||||
stroke={p.stroke}
|
||||
strokeWidth={weight(p.weightMm)}
|
||||
strokeDasharray={dashOf(p.dash)}
|
||||
vectorEffect={vfx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "drawingArc": {
|
||||
// Glatter SVG-Bogen. Winkel in Radiant (CCW im Modell); Screen-Y ist
|
||||
// gespiegelt → ein modell-CCW-Bogen läuft am Bildschirm im Uhrzeigersinn
|
||||
// (SVG sweep-flag 1). large-arc aus der Spannweite (>π).
|
||||
const TAU = Math.PI * 2;
|
||||
const rPx = p.r * PX_PER_M;
|
||||
const from = toScreen({
|
||||
x: p.center.x + p.r * Math.cos(p.a0),
|
||||
y: p.center.y + p.r * Math.sin(p.a0),
|
||||
});
|
||||
const to = toScreen({
|
||||
x: p.center.x + p.r * Math.cos(p.a1),
|
||||
y: p.center.y + p.r * Math.sin(p.a1),
|
||||
});
|
||||
const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU;
|
||||
const largeArc = sweep > Math.PI ? 1 : 0;
|
||||
const d = `M ${from.x} ${from.y} A ${rPx} ${rPx} 0 ${largeArc} 1 ${to.x} ${to.y}`;
|
||||
return (
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={p.stroke}
|
||||
strokeWidth={weight(p.weightMm)}
|
||||
strokeDasharray={dashOf(p.dash)}
|
||||
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
|
||||
vectorEffect={vfx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +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);
|
||||
});
|
||||
});
|
||||
|
||||
/** Zählt Fenster-Anschlag-/Laibungsstriche (cls "window-frame") im Plan. */
|
||||
function windowRevealCount(p: Project, detail: "grob" | "mittel" | "fein") {
|
||||
return generatePlan(p, "eg", visible, undefined, detail).primitives.filter(
|
||||
(pr) => pr.kind === "line" && pr.cls === "window-frame",
|
||||
).length;
|
||||
}
|
||||
|
||||
describe("generatePlan — Fenster Anschlag-Striche (Item 8)", () => {
|
||||
it("erzeugt bei 'fein' zwei Laibungsstriche (je Pfosten einen)", () => {
|
||||
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "fein")).toBe(2);
|
||||
});
|
||||
|
||||
it("erzeugt bei 'mittel' KEINE (nur im Detailgrad fein)", () => {
|
||||
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "mittel")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,8 @@
|
||||
* • Der Backstein-Kern des Abzweigs sticht durch den Nah-Putz der Durchgangs-
|
||||
* wand und stoppt an deren Kern-Nahfläche (kein Durchlaufen bis zur Achse,
|
||||
* kein Überlappen) — "durchstechender Kern".
|
||||
* • Die Innenputz-Schichten des Abzweigs enden an der Nahfläche. Ist der
|
||||
* Nah-Putz der Durchgangswand materialgleich, füllt er die Flanken durchgehend
|
||||
* → durchgehendes Putz-L OHNE Trennnaht (keine "layer-joint-l"-Linie).
|
||||
* • Die Innenputz-Schichten des Abzweigs enden weiterhin an der Nahfläche
|
||||
* UND bekommen eine zusätzliche seitliche L-Linie (cls "layer-joint-l").
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
@@ -97,147 +96,35 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => {
|
||||
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 lLines = plan.primitives.filter(
|
||||
(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
|
||||
// an der Flanke auf den materialgleichen Innenputz der Durchgangswand (der
|
||||
// spanCutout schneidet nur die Kernbreite aus, nicht die Putzflanken). Der
|
||||
// Putz läuft dort durchgehend ums Eck (L) — eine Trennnaht würde eine falsche
|
||||
// Materialgrenze suggerieren. Daher KEINE L-Linie.
|
||||
expect(lLines.length).toBe(0);
|
||||
// Genau zwei L-Linien (eine je Innenputz-Schicht); der Backstein-Kern
|
||||
// bekommt keine (er verschmilzt, kein Sprung im Material).
|
||||
expect(lLines.length).toBe(2);
|
||||
|
||||
// Jede L-Linie verläuft an der Rückgrat-Nahfläche (y=0.06, parallel zur
|
||||
// x-Achse) und spannt genau die Breite EINER Putzschicht (0.015 m) auf.
|
||||
for (const l of lLines) {
|
||||
expect(l.a.y).toBeCloseTo(0.06, 6);
|
||||
expect(l.b.y).toBeCloseTo(0.06, 6);
|
||||
expect(Math.abs(l.a.x - l.b.x)).toBeCloseTo(0.015, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => {
|
||||
// Am T-Knoten wird eine der kollinearen Hälften als Durchgangswand gewählt
|
||||
// und bekommt eine Aussparung; ihr Innenputz-Band auf der Durchstoss-Seite
|
||||
// (y∈[0.06,0.075]) wird über die Backstein-Breite [4.94,5.06] gekürzt (der
|
||||
// Cutout reicht bis ans Knoten-Ende x=5). Backstein-Kern + Aussenputz bleiben
|
||||
// voll (kein Überlappungsbereich).
|
||||
it("lässt die kollineare Durchgangswand unverändert (keine L-Linien, normale Bänder)", () => {
|
||||
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
|
||||
// Die Durchgangs-Hälfte ist die mit spanCutouts — finde sie über ihre
|
||||
// gekürzten Bänder: sammle die Bänder beider Hälften.
|
||||
const bandsOf = (id: string) =>
|
||||
plan.primitives.filter(
|
||||
(p): p is Extract<typeof p, { kind: "polygon" }> =>
|
||||
p.kind === "polygon" && p.wallId === id && p.stroke === "none",
|
||||
);
|
||||
const wa1 = bandsOf("WA1");
|
||||
const wa2 = bandsOf("WA2");
|
||||
// Beide Hälften haben weiterhin drei Bänder (der Cutout reicht bis ans
|
||||
// Wandende, splittet also nicht in zwei, sondern kürzt nur).
|
||||
expect(wa1.length).toBe(3);
|
||||
expect(wa2.length).toBe(3);
|
||||
// Genau EINE Hälfte trägt ein gekürztes Nah-Putz-Band: das innere Putzband
|
||||
// (y-Nahfläche 0.075) endet nicht mehr am Knoten x=5, sondern bei 4.94 bzw.
|
||||
// (für die andere Orientierung) bei 5.06.
|
||||
// Nah-Putz-Band = Offsetzone y∈[0.06,0.075] (offA=0.06 → pts[0/1], offB=0.075
|
||||
// → pts[2/3]); dessen X-Kanten verraten die Kürzung.
|
||||
const isNearPlaster = (b: { pts: { y: number }[] }) => {
|
||||
const ys = b.pts.map((p) => p.y);
|
||||
return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6;
|
||||
};
|
||||
const nearPlasterEnds = [...wa1, ...wa2]
|
||||
.filter(isNearPlaster)
|
||||
.flatMap((b) => b.pts.map((p) => p.x));
|
||||
// Mindestens eine Kante liegt bei 4.94 oder 5.06 (Backstein-Breite ab Knoten).
|
||||
const shortened = nearPlasterEnds.some(
|
||||
(x) => Math.abs(x - 4.94) < 1e-6 || Math.abs(x - 5.06) < 1e-6,
|
||||
);
|
||||
expect(shortened).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** Mittelspannen-Variante: EINE Durchgangswand W, Abzweig WB trifft mittig. */
|
||||
function midSpanProject(): Project {
|
||||
const base = tJunctionProject();
|
||||
const wall = (id: string, start: Wall["start"], end: Wall["end"]): Wall => ({
|
||||
id,
|
||||
type: "wall",
|
||||
floorId: "eg",
|
||||
categoryCode: "20",
|
||||
start,
|
||||
end,
|
||||
wallTypeId: "iw",
|
||||
height: 2.6,
|
||||
});
|
||||
base.walls = [
|
||||
wall("W", { x: 0, y: 0 }, { x: 10, y: 0 }),
|
||||
wall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }),
|
||||
];
|
||||
return base;
|
||||
}
|
||||
|
||||
describe("generatePlan — Durchgangswand-Aussparung am Mittelspann-Stoss (Phase 1c)", () => {
|
||||
it("splittet das Innenputz-Band der Durchgangswand über dem Durchstoss in zwei Teil-Bänder", () => {
|
||||
const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel");
|
||||
const bands = plan.primitives.filter(
|
||||
const wa1Bands = plan.primitives.filter(
|
||||
(p): p is Extract<typeof p, { kind: "polygon" }> =>
|
||||
p.kind === "polygon" && p.wallId === "W" && p.stroke === "none",
|
||||
p.kind === "polygon" && p.wallId === "WA1" && p.stroke === "none",
|
||||
);
|
||||
// Innenputz-Band (Nahfläche y=0.075) wird über [3.94,4.06] entfernt → ZWEI
|
||||
// Teil-Bänder statt einem. Backstein (2 Bänder? nein 1) + Aussenputz bleiben
|
||||
// je ein Band → insgesamt 4 Bänder (1 Aussenputz + 1 Backstein + 2 Innenputz).
|
||||
const innerPlaster = bands.filter((b) => {
|
||||
const ys = b.pts.map((p) => p.y);
|
||||
return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6;
|
||||
});
|
||||
expect(innerPlaster.length).toBe(2);
|
||||
// Das linke Teil-Band endet bei x=3.94, das rechte beginnt bei x=4.06.
|
||||
const xs = innerPlaster.flatMap((b) => b.pts.map((p) => p.x)).sort((a, b) => a - b);
|
||||
// Erwartete Kanten: 0, 3.94 (linkes Band) und 4.06, 10 (rechtes Band).
|
||||
expect(xs.some((x) => Math.abs(x - 3.94) < 1e-6)).toBe(true);
|
||||
expect(xs.some((x) => Math.abs(x - 4.06) < 1e-6)).toBe(true);
|
||||
// Insgesamt bleiben Backstein-Kern (y-Nahfläche 0.06) und Aussenputz (−0.06)
|
||||
// ganze Bänder → 4 Bänder gesamt.
|
||||
expect(bands.length).toBe(4);
|
||||
});
|
||||
|
||||
it("unterbricht die Schichtfuge im Merge-Bereich (keine Trennlinie an der Verschmelzungsfläche)", () => {
|
||||
const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel");
|
||||
// Fugen der Durchgangswand W: layer-joint-Linien parallel zur x-Achse.
|
||||
const joints = plan.primitives.filter(
|
||||
(p): p is Extract<typeof p, { kind: "line" }> =>
|
||||
p.kind === "line" && p.cls === "layer-joint",
|
||||
);
|
||||
// Die Fuge an der Rückgrat-Nahfläche y=0.06 (Merge-Kontakt) darf NICHT über
|
||||
// den Durchstoss [3.94,4.06] laufen: kein Fugen-Segment enthält einen Punkt
|
||||
// mit y≈0.06 und x im offenen Intervall (3.94, 4.06).
|
||||
for (const l of joints) {
|
||||
if (Math.abs(l.a.y - 0.06) < 1e-6 && Math.abs(l.b.y - 0.06) < 1e-6) {
|
||||
const lo = Math.min(l.a.x, l.b.x);
|
||||
const hi = Math.max(l.a.x, l.b.x);
|
||||
// Das Segment darf den Durchstoss nicht überspannen/betreten.
|
||||
expect(lo >= 4.06 - 1e-9 || hi <= 3.94 + 1e-9).toBe(true);
|
||||
}
|
||||
expect(wa1Bands.length).toBe(3);
|
||||
// Kein Cut an ihrem gemeinsamen Knoten-Ende (endCut bleibt null, s. joins.ts) →
|
||||
// alle drei Bänder enden exakt am Achsenpunkt (5,0), keine Gehrung nötig.
|
||||
for (const b of wa1Bands) {
|
||||
expect(b.pts[1].x).toBeCloseTo(5, 6);
|
||||
}
|
||||
// Und es MUSS Fugen-Segmente auf beiden Seiten geben (die Fuge existiert weiter,
|
||||
// nur unterbrochen).
|
||||
const at006 = joints.filter(
|
||||
(l) => Math.abs(l.a.y - 0.06) < 1e-6 && Math.abs(l.b.y - 0.06) < 1e-6,
|
||||
);
|
||||
expect(at006.some((l) => Math.max(l.a.x, l.b.x) <= 3.94 + 1e-6)).toBe(true);
|
||||
expect(at006.some((l) => Math.min(l.a.x, l.b.x) >= 4.06 - 1e-6)).toBe(true);
|
||||
});
|
||||
|
||||
it("unterbricht die Nahflächen-Umrisslinie über dem Durchstoss", () => {
|
||||
const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel");
|
||||
// Die separat gezeichneten Nahkanten-Segmente (cls "wall-outline") liegen bei
|
||||
// y=0.075 und lassen [3.94,4.06] frei.
|
||||
const outline = plan.primitives.filter(
|
||||
(p): p is Extract<typeof p, { kind: "line" }> =>
|
||||
p.kind === "line" && p.cls === "wall-outline",
|
||||
);
|
||||
expect(outline.length).toBe(2); // links + rechts vom Durchstoss
|
||||
for (const l of outline) {
|
||||
expect(Math.abs(l.a.y - 0.075) < 1e-6).toBe(true);
|
||||
expect(Math.abs(l.b.y - 0.075) < 1e-6).toBe(true);
|
||||
}
|
||||
// Ein Segment endet bei 3.94, das andere beginnt bei 4.06.
|
||||
const allX = outline.flatMap((l) => [l.a.x, l.b.x]);
|
||||
expect(allX.some((x) => Math.abs(x - 3.94) < 1e-6)).toBe(true);
|
||||
expect(allX.some((x) => Math.abs(x - 4.06) < 1e-6)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+90
-601
@@ -38,20 +38,12 @@ import { docFromText } from "../text/richText";
|
||||
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
|
||||
import type { RoomStampLine } from "../model/roomStamp";
|
||||
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
|
||||
import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
|
||||
import type { SpiralOutlineSegments } from "../geometry/stair";
|
||||
import {
|
||||
doorSymbol,
|
||||
openingInterval,
|
||||
openingJambs,
|
||||
wallAxisFrame,
|
||||
windowSymbol,
|
||||
} from "../geometry/opening";
|
||||
import { stairGeometry, stairCut } from "../geometry/stair";
|
||||
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
|
||||
import {
|
||||
add,
|
||||
along,
|
||||
clippedBand,
|
||||
dot,
|
||||
leftNormal,
|
||||
lineIntersect,
|
||||
normalize,
|
||||
@@ -98,10 +90,6 @@ const MONO_INK = "#111111";
|
||||
const LAYER_LINE_MM = 0.02;
|
||||
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
|
||||
const SYMBOL_HAIRLINE_MM = 0.02;
|
||||
/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der
|
||||
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
|
||||
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
|
||||
const OVERHEAD_DASH: number[] = [0.4, 0.25];
|
||||
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
|
||||
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
|
||||
grob: 1.6, // dickere Sammellinie
|
||||
@@ -272,8 +260,6 @@ export type Primitive =
|
||||
dash?: number[] | null;
|
||||
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
|
||||
openingId?: string;
|
||||
/** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */
|
||||
stairId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
@@ -298,52 +284,6 @@ export type Primitive =
|
||||
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
|
||||
roomId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
// Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`,
|
||||
// z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt
|
||||
// er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert.
|
||||
kind: "drawingText";
|
||||
/** Ankerpunkt im Modell (Meter). */
|
||||
at: Vec2;
|
||||
text: string;
|
||||
/** Schrifthöhe in Modell-Metern. */
|
||||
heightM: number;
|
||||
/** Drehung in RADIANT (CCW im Modell). */
|
||||
angle: number;
|
||||
color: string;
|
||||
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
|
||||
drawingId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
// Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als
|
||||
// echtes `<circle>` gerendert (rund bei jedem Zoom), nicht als Vieleck.
|
||||
kind: "drawingCircle";
|
||||
center: Vec2;
|
||||
/** Radius in Modell-Metern. */
|
||||
r: number;
|
||||
/** Vollton-Füllfarbe oder "none". */
|
||||
fill: string;
|
||||
stroke: string;
|
||||
weightMm: number;
|
||||
dash?: number[] | null;
|
||||
drawingId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
// Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als
|
||||
// SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell).
|
||||
kind: "drawingArc";
|
||||
center: Vec2;
|
||||
r: number;
|
||||
a0: number;
|
||||
a1: number;
|
||||
stroke: string;
|
||||
weightMm: number;
|
||||
dash?: number[] | null;
|
||||
drawingId?: string;
|
||||
greyed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -634,17 +574,16 @@ export function generatePlan(
|
||||
visibleCodes.has(c.categoryCode) &&
|
||||
categoryDisplay(c.categoryCode).render,
|
||||
);
|
||||
// Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse:
|
||||
// die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort,
|
||||
// wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig,
|
||||
// wenn es überhaupt Decken gibt.
|
||||
const wallFootprints = ceilings.length ? buildWallFootprints(project, walls) : [];
|
||||
for (const ceiling of ceilings) {
|
||||
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
||||
const category = catByCode.get(ceiling.categoryCode);
|
||||
// Kein lwMm mehr: der Decken-Umriss ist eine feste Überkopf-Haarlinie
|
||||
// (gestrichelt), unabhängig von der Kategorie-Strichstärke.
|
||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
|
||||
const lwMm = resolveStrokeWeight(
|
||||
ceiling.strokeWeight,
|
||||
ceiling.strokeWeightSource,
|
||||
category,
|
||||
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
|
||||
);
|
||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category);
|
||||
}
|
||||
|
||||
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
||||
@@ -677,14 +616,7 @@ export function generatePlan(
|
||||
const openings = openingsOfWall(project, wall.id);
|
||||
const gaps = wallGaps(wall, doors, openings);
|
||||
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
|
||||
// Schnitt vs. Ansicht nach Schnitthöhe (BIM-Standard): erreicht die Wand die
|
||||
// Grundriss-Schnittebene nicht (Höhe < Schnitthöhe des Geschosses, z. B. eine
|
||||
// Brüstung/ein Podestrand), wird sie nur als Ansichts-Umriss gezeichnet —
|
||||
// keine Schnitt-Poché. Normale Wände (Höhe ≥ Schnitthöhe) sind unberührt.
|
||||
const wallViewOnly = wall.height < (floor?.cutHeight ?? 1.0);
|
||||
addWallPoche(
|
||||
primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory, wallViewOnly,
|
||||
);
|
||||
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory);
|
||||
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
|
||||
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
|
||||
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
|
||||
@@ -1053,48 +985,20 @@ function addDrawing2D(
|
||||
seg(c4, c1);
|
||||
break;
|
||||
}
|
||||
case "circle":
|
||||
// Echtes Kreis-Primitiv → glattes `<circle>` (rund bei jedem Zoom) mit
|
||||
// optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation.
|
||||
out.push({
|
||||
kind: "drawingCircle",
|
||||
center: g.center,
|
||||
r: g.r,
|
||||
fill: background ?? d.fillColor ?? "none",
|
||||
stroke: color,
|
||||
weightMm,
|
||||
dash,
|
||||
greyed,
|
||||
drawingId: d.id,
|
||||
});
|
||||
break;
|
||||
case "arc":
|
||||
// Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant).
|
||||
out.push({
|
||||
kind: "drawingArc",
|
||||
center: g.center,
|
||||
r: g.r,
|
||||
a0: g.a0,
|
||||
a1: g.a1,
|
||||
stroke: color,
|
||||
weightMm,
|
||||
dash,
|
||||
greyed,
|
||||
drawingId: d.id,
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
out.push({
|
||||
kind: "drawingText",
|
||||
at: g.at,
|
||||
text: g.text,
|
||||
heightM: g.height,
|
||||
angle: g.angle,
|
||||
color,
|
||||
greyed,
|
||||
drawingId: d.id,
|
||||
});
|
||||
case "circle": {
|
||||
// Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die
|
||||
// vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv.
|
||||
const N = 64;
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = (i / N) * Math.PI * 2;
|
||||
pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) });
|
||||
}
|
||||
fillPoly(pts);
|
||||
for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]);
|
||||
break;
|
||||
}
|
||||
// arc/text: Primitive-Erweiterungen folgen später.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -1141,120 +1045,6 @@ export function wallGaps(
|
||||
return gaps.sort((a, b) => a.from - b.from);
|
||||
}
|
||||
|
||||
/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */
|
||||
const CUTOUT_EPS = 1e-9;
|
||||
|
||||
/**
|
||||
* Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die
|
||||
* verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden
|
||||
* auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst „ganz
|
||||
* ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes
|
||||
* Verhalten für Wände ohne Aussparung).
|
||||
*/
|
||||
function subtractIntervals(
|
||||
s: number,
|
||||
e: number,
|
||||
holes: Array<[number, number]>,
|
||||
): Array<[number, number]> {
|
||||
const clipped = holes
|
||||
.map(([a, b]): [number, number] => [
|
||||
Math.max(s, Math.min(a, b)),
|
||||
Math.min(e, Math.max(a, b)),
|
||||
])
|
||||
.filter(([a, b]) => b > a + CUTOUT_EPS)
|
||||
.sort((p, q) => p[0] - q[0]);
|
||||
const parts: Array<[number, number]> = [];
|
||||
let cursor = s;
|
||||
for (const [a, b] of clipped) {
|
||||
if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]);
|
||||
if (b > cursor) cursor = b;
|
||||
}
|
||||
if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]);
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wand-Footprint als orientiertes Rechteck (OBB) für das Verdecken der
|
||||
* darunterliegenden Decke im Grundriss: Achs-Startpunkt + Einheits-Richtung `u`
|
||||
* und -Normale `m`, Halbdicke, Referenz-Querversatz (Wandachse↔Bandmitte) und
|
||||
* Achslänge. Die u-Ausdehnung wird beim Test an beiden Enden um die Halbdicke
|
||||
* verlängert, damit auch die Eck-Überlappe (Gehrungen) sicher abgedeckt sind.
|
||||
*/
|
||||
interface WallFootprint {
|
||||
a: Vec2;
|
||||
u: Vec2;
|
||||
m: Vec2;
|
||||
len: number;
|
||||
half: number;
|
||||
refOff: number;
|
||||
}
|
||||
|
||||
/** Baut die Wand-Footprints (OBBs) aus den gerenderten Wänden. Nicht auflösbarer
|
||||
* Wandtyp ⇒ Default-Dicke 0.2 m (wie der Vollkörper-Fallback im 3D-Emitter). */
|
||||
function buildWallFootprints(project: Project, walls: Wall[]): WallFootprint[] {
|
||||
const out: WallFootprint[] = [];
|
||||
for (const w of walls) {
|
||||
let t = 0.2;
|
||||
try {
|
||||
t = wallTypeThickness(getWallType(project, w));
|
||||
} catch {
|
||||
// Default-Dicke behalten.
|
||||
}
|
||||
const u = normalize(sub(w.end, w.start));
|
||||
out.push({
|
||||
a: w.start,
|
||||
u,
|
||||
m: leftNormal(u),
|
||||
len: Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y),
|
||||
half: t / 2,
|
||||
refOff: wallReferenceOffset(w, t),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametrisches Intervall [t0,t1] ⊆ [0,1], über das die Strecke `pa→pb`
|
||||
* INNERHALB des Wand-Footprints liegt (also von der Wand verdeckt). `null`, wenn
|
||||
* die Strecke den Footprint nicht schneidet. OBB-Test über zwei Slabs: Projektion
|
||||
* auf die Achse `u` (Bereich [−half, len+half]) und auf die Normale `m` (Bereich
|
||||
* [refOff−half, refOff+half]); das Schnitt-Intervall beider Slabs ∩ [0,1] ist die
|
||||
* verdeckte Strecke.
|
||||
*/
|
||||
function segmentInsideWallBand(
|
||||
pa: Vec2,
|
||||
pb: Vec2,
|
||||
fp: WallFootprint,
|
||||
): [number, number] | null {
|
||||
const d = sub(pb, pa);
|
||||
const rel = sub(pa, fp.a);
|
||||
const clipSlab = (
|
||||
f0: number,
|
||||
fd: number,
|
||||
lo: number,
|
||||
hi: number,
|
||||
): [number, number] | null => {
|
||||
if (Math.abs(fd) < 1e-12)
|
||||
return f0 >= lo - CUTOUT_EPS && f0 <= hi + CUTOUT_EPS ? [0, 1] : null;
|
||||
let ta = (lo - f0) / fd;
|
||||
let tb = (hi - f0) / fd;
|
||||
if (ta > tb) [ta, tb] = [tb, ta];
|
||||
return [ta, tb];
|
||||
};
|
||||
const su = clipSlab(dot(rel, fp.u), dot(d, fp.u), -fp.half, fp.len + fp.half);
|
||||
if (!su) return null;
|
||||
const sm = clipSlab(
|
||||
dot(rel, fp.m),
|
||||
dot(d, fp.m),
|
||||
fp.refOff - fp.half,
|
||||
fp.refOff + fp.half,
|
||||
);
|
||||
if (!sm) return null;
|
||||
const t0 = Math.max(0, su[0], sm[0]);
|
||||
const t1 = Math.min(1, su[1], sm[1]);
|
||||
return t1 > t0 + CUTOUT_EPS ? [t0, t1] : null;
|
||||
}
|
||||
|
||||
function addWallPoche(
|
||||
out: Primitive[],
|
||||
project: Project,
|
||||
@@ -1265,10 +1055,6 @@ function addWallPoche(
|
||||
detail: DetailLevel,
|
||||
wallLwMm: number,
|
||||
category: LayerCategory | undefined,
|
||||
// Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei
|
||||
// 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE
|
||||
// Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`.
|
||||
viewOnly: boolean,
|
||||
): void {
|
||||
const wt = getWallType(project, wall);
|
||||
const total = wallTypeThickness(wt);
|
||||
@@ -1309,13 +1095,6 @@ function addWallPoche(
|
||||
// (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist.
|
||||
const simpleFill = backbonePocheFill(project, wt);
|
||||
|
||||
// Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer-
|
||||
// Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten
|
||||
// (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die
|
||||
// Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges
|
||||
// Verhalten (Wand ohne T-Durchstoss).
|
||||
const spanCutouts = cuts.spanCutouts ?? [];
|
||||
|
||||
// Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten).
|
||||
// clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end)
|
||||
// ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche.
|
||||
@@ -1337,24 +1116,6 @@ function addWallPoche(
|
||||
const startCut = s <= 1e-6 ? cuts.startCut : null;
|
||||
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
|
||||
|
||||
if (viewOnly) {
|
||||
// Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke
|
||||
// als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik
|
||||
// bleibt dieselbe wie im Schnittfall (nur eben ohne Poché).
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: layerLineMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
noStrokeEdges: joinEdges(startCut, endCut),
|
||||
wallId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (detail === "grob") {
|
||||
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
|
||||
out.push({
|
||||
@@ -1376,15 +1137,6 @@ function addWallPoche(
|
||||
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
|
||||
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
|
||||
const bandJoinEdges = joinEdges(startCut, endCut);
|
||||
// Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren.
|
||||
const segCutouts = spanCutouts.filter(
|
||||
(c) =>
|
||||
Math.min(c.from, c.to) < e - CUTOUT_EPS &&
|
||||
Math.max(c.from, c.to) > s + CUTOUT_EPS,
|
||||
);
|
||||
// Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern).
|
||||
const axisPt = (d: number): Vec2 =>
|
||||
d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d);
|
||||
// Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts):
|
||||
// liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den
|
||||
// einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe
|
||||
@@ -1409,38 +1161,20 @@ function addWallPoche(
|
||||
const layerSolid = layerHatch.pattern === "solid";
|
||||
const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null;
|
||||
const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null;
|
||||
// Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes
|
||||
// [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band
|
||||
// entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das
|
||||
// Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band
|
||||
// [s,e] (unverändertes Verhalten).
|
||||
const bandLo = off;
|
||||
const bandHi = off + layer.thickness;
|
||||
const bandHoles: Array<[number, number]> = [];
|
||||
for (const c of segCutouts) {
|
||||
const cLo = Math.min(c.offA, c.offB);
|
||||
const cHi = Math.max(c.offA, c.offB);
|
||||
if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS)
|
||||
bandHoles.push([c.from, c.to]);
|
||||
}
|
||||
for (const [a, b] of subtractIntervals(s, e, bandHoles)) {
|
||||
const sc = a <= s + CUTOUT_EPS ? layerStartCut : null;
|
||||
const ec = b >= e - CUTOUT_EPS ? layerEndCut : null;
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec),
|
||||
fill: layerSolid ? HATCH_INK : HATCH_PAPER,
|
||||
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
|
||||
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
|
||||
stroke: "none",
|
||||
strokeWidthMm: 0,
|
||||
hatch: layerHatch,
|
||||
greyed,
|
||||
// Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos).
|
||||
noStrokeEdges: bandJoinEdges,
|
||||
wallId,
|
||||
});
|
||||
}
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: clippedBand(p1, p2, off, off + layer.thickness, layerStartCut, layerEndCut),
|
||||
fill: layerSolid ? HATCH_INK : HATCH_PAPER,
|
||||
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
|
||||
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
|
||||
stroke: "none",
|
||||
strokeWidthMm: 0,
|
||||
hatch: layerHatch,
|
||||
greyed,
|
||||
// Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos).
|
||||
noStrokeEdges: bandJoinEdges,
|
||||
wallId,
|
||||
});
|
||||
// L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine
|
||||
// verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt
|
||||
// zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie
|
||||
@@ -1488,95 +1222,43 @@ function addWallPoche(
|
||||
// Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB
|
||||
// dieser Wand und bleibt daher an der bisherigen Nahfläche.
|
||||
if (li < wt.layers.length - 1) {
|
||||
// clippedBand(p1, p2, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
|
||||
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
|
||||
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
|
||||
const jq = clippedBand(p1, p2, off, off, startCut, endCut);
|
||||
const jls = layer.jointLineStyleId
|
||||
? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
|
||||
: undefined;
|
||||
// Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz-
|
||||
// Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt)
|
||||
// —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der
|
||||
// Verschmelzungsfläche keine Trennlinie stehen.
|
||||
const fugeHoles: Array<[number, number]> = [];
|
||||
for (const c of segCutouts) {
|
||||
const cLo = Math.min(c.offA, c.offB);
|
||||
const cHi = Math.max(c.offA, c.offB);
|
||||
if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS)
|
||||
fugeHoles.push([c.from, c.to]);
|
||||
}
|
||||
for (const [a, b] of subtractIntervals(s, e, fugeHoles)) {
|
||||
const sc = a <= s + CUTOUT_EPS ? startCut : null;
|
||||
const ec = b >= e - CUTOUT_EPS ? endCut : null;
|
||||
// clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
|
||||
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
|
||||
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
|
||||
const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec);
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: jq[0],
|
||||
b: jq[1],
|
||||
cls: "layer-joint",
|
||||
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
|
||||
color: jls ? jls.color : stroke,
|
||||
dash: jls ? jls.dash : null,
|
||||
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
|
||||
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
|
||||
// Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen.
|
||||
motif: jls?.kind === "custom" ? jls.motif : undefined,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: jq[0],
|
||||
b: jq[1],
|
||||
cls: "layer-joint",
|
||||
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
|
||||
color: jls ? jls.color : stroke,
|
||||
dash: jls ? jls.dash : null,
|
||||
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
|
||||
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
|
||||
// Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen.
|
||||
motif: jls?.kind === "custom" ? jls.motif : undefined,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
|
||||
// damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
|
||||
// Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe.
|
||||
// Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante
|
||||
// (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht
|
||||
// und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe
|
||||
// die Umrisslinie über die Durchstoss-Breite.
|
||||
const offLo = refOff - total / 2;
|
||||
const offHi = refOff + total / 2;
|
||||
const holesHi: Array<[number, number]> = [];
|
||||
const holesLo: Array<[number, number]> = [];
|
||||
for (const c of segCutouts) {
|
||||
// `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der
|
||||
// passenden Längskante (offHi bzw. offLo) zu.
|
||||
if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]);
|
||||
else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]);
|
||||
}
|
||||
const outlineNoStroke = [...bandJoinEdges];
|
||||
// clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi),
|
||||
// B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante.
|
||||
if (holesHi.length) outlineNoStroke.push(2);
|
||||
if (holesLo.length) outlineNoStroke.push(0);
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut),
|
||||
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: outlineMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
noStrokeEdges: outlineNoStroke,
|
||||
noStrokeEdges: bandJoinEdges,
|
||||
wallId,
|
||||
});
|
||||
const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => {
|
||||
for (const [a, b] of subtractIntervals(s, e, holes)) {
|
||||
const sc = a <= s + CUTOUT_EPS ? startCut : null;
|
||||
const ec = b >= e - CUTOUT_EPS ? endCut : null;
|
||||
const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec);
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: jq[0],
|
||||
b: jq[1],
|
||||
cls: "wall-outline",
|
||||
weightMm: outlineMm,
|
||||
color: stroke,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (holesHi.length) drawNearEdge(offHi, holesHi);
|
||||
if (holesLo.length) drawNearEdge(offLo, holesLo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1691,36 +1373,29 @@ function addOpeningSymbol(
|
||||
if (o.kind === "door") {
|
||||
const sym = doorSymbol(wall, o);
|
||||
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({
|
||||
kind: "line",
|
||||
a: sym.hinge,
|
||||
b: sym.openEnd,
|
||||
cls: "door-leaf",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
// mittel + fein: Schwenkbogen von geschlossen → offen.
|
||||
if (detail !== "grob") {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: sym.hinge,
|
||||
b: sym.openEnd,
|
||||
cls: "door-leaf",
|
||||
kind: "arc",
|
||||
center: sym.hinge,
|
||||
from: sym.closedEnd,
|
||||
to: sym.openEnd,
|
||||
r: sym.radius,
|
||||
cls: "door-swing",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
// mittel + fein: Schwenkbogen von geschlossen → offen.
|
||||
if (detail !== "grob") {
|
||||
out.push({
|
||||
kind: "arc",
|
||||
center: sym.hinge,
|
||||
from: sym.closedEnd,
|
||||
to: sym.openEnd,
|
||||
r: sym.radius,
|
||||
cls: "door-swing",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
|
||||
if (detail === "fein") {
|
||||
@@ -1737,57 +1412,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;
|
||||
}
|
||||
|
||||
@@ -1807,20 +1431,6 @@ function addOpeningSymbol(
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
// Flügel-Mittelpfosten (wingCount − 1) — reine Querlinien innerhalb des
|
||||
// Rahmens (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema.
|
||||
for (const [a, b] of sym.mullionLines) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a,
|
||||
b,
|
||||
cls: "window-mullion",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
color: o.color ?? POCHE_STROKE,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [a, b] of sym.glassLines) {
|
||||
out.push({
|
||||
@@ -1833,50 +1443,6 @@ function addOpeningSymbol(
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
// fein: Anschlag-/Laibungsstriche an beiden Pfosten (kurz quer zur Wand) —
|
||||
// analog zur Tür, damit Fenster im Detailplan gleich „fertig" wirken (bisher
|
||||
// hatten nur Türen sie). Haarlinie, 6 cm Andeutung je Seite.
|
||||
if (detail === "fein") {
|
||||
const jambs = openingJambs(wall, o);
|
||||
const dir = sub(wall.end, wall.start);
|
||||
const len = Math.hypot(dir.x, dir.y);
|
||||
if (jambs && len > 1e-9) {
|
||||
const n = { x: -dir.y / len, y: dir.x / len };
|
||||
const reveal = 0.06;
|
||||
for (const jamb of [jambs.jambStart, jambs.jambEnd]) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: add(jamb, scale(n, reveal)),
|
||||
b: add(jamb, scale(n, -reveal)),
|
||||
cls: "window-frame",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
color: o.color ?? POCHE_STROKE,
|
||||
greyed,
|
||||
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). */
|
||||
@@ -1928,14 +1494,15 @@ function addCeilingPoche(
|
||||
ceiling: Ceiling,
|
||||
greyed: boolean,
|
||||
detail: DetailLevel,
|
||||
lwMm: number,
|
||||
category: LayerCategory | undefined,
|
||||
wallFootprints: WallFootprint[],
|
||||
): void {
|
||||
const pts = ceiling.outline;
|
||||
if (pts.length < 3) return;
|
||||
const wt = getCeilingType(project, ceiling);
|
||||
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
|
||||
const stroke = ceiling.color ?? POCHE_STROKE;
|
||||
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
|
||||
const ceilingId = ceiling.id;
|
||||
|
||||
// Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss
|
||||
@@ -1961,50 +1528,27 @@ function addCeilingPoche(
|
||||
resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
|
||||
)
|
||||
: NO_HATCH;
|
||||
// Gefüllte Fläche OHNE eigenen Umriss (stroke:"none"): die Decke liegt unter
|
||||
// der Wand-Poché; ihre Kante wird separat als verdeckungs-geclippte Linie
|
||||
// gezeichnet (s. u.), damit sie nicht durch die darüberstehenden Wände schlägt.
|
||||
// Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche.
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: comp ? pocheFill(hatch.pattern) : "none",
|
||||
stroke: "none",
|
||||
strokeWidthMm: 0,
|
||||
stroke,
|
||||
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||||
hatch,
|
||||
greyed,
|
||||
ceilingId,
|
||||
});
|
||||
// Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede
|
||||
// Umriss-Kante gegen die Wand-Footprints clippen und die freien Teilstücke als
|
||||
// Linien ausgeben. Liegt die Decke deckungsgleich unter den Wänden (Regelfall:
|
||||
// Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt der Umriss ganz; nur
|
||||
// Überstände (Vordach/Balkon jenseits der Wand) bleiben sichtbar.
|
||||
const n = pts.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
const holes: Array<[number, number]> = [];
|
||||
for (const fp of wallFootprints) {
|
||||
const iv = segmentInsideWallBand(a, b, fp);
|
||||
if (iv) holes.push(iv);
|
||||
}
|
||||
const d = sub(b, a);
|
||||
for (const [t0, t1] of subtractIntervals(0, 1, holes)) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: add(a, scale(d, t0)),
|
||||
b: add(a, scale(d, t1)),
|
||||
cls: "ceiling-outline",
|
||||
// Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) →
|
||||
// gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention).
|
||||
weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||||
dash: OVERHEAD_DASH,
|
||||
color: stroke,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: outlineMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
ceilingId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2094,40 +1638,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):
|
||||
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
|
||||
@@ -2232,27 +1742,6 @@ function addStairSymbol(
|
||||
const [h1, tip, h2] = geo.arrow.head;
|
||||
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
|
||||
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
|
||||
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
|
||||
// Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im
|
||||
// Rhino-Plugin. Stets durchgezogen, volle Strichstärke.
|
||||
const outline = stairOutline(stair, totalRise);
|
||||
if (outline.polygon && outline.polygon.length >= 3) {
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: outline.polygon,
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: lwMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
}
|
||||
if (outline.spiral) {
|
||||
_addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId);
|
||||
}
|
||||
}
|
||||
|
||||
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Unit-Tests für „Schnitt vs. Ansicht nach Schnitthöhe" (generatePlan):
|
||||
* Eine Wand, die die Grundriss-Schnittebene NICHT erreicht (Höhe < `cutHeight`,
|
||||
* z. B. eine Brüstung), wird nur als Ansichts-Umriss gezeichnet — Poché-Polygone
|
||||
* OHNE Füllung (`fill:"none"`), keine Schraffur. Eine normale Wand (Höhe ≥
|
||||
* Schnitthöhe) behält die geschnittene Poché (gefüllte Bänder).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generatePlan } from "./generatePlan";
|
||||
import type { Project, Wall } from "../model/types";
|
||||
|
||||
/** Minimalprojekt: eine normale Wand (2.6 m) + eine niedrige Wand (0.3 m). */
|
||||
function project(): Project {
|
||||
const wall = (id: string, start: Wall["start"], end: Wall["end"], height: number): Wall => ({
|
||||
id,
|
||||
type: "wall",
|
||||
floorId: "eg",
|
||||
categoryCode: "20",
|
||||
start,
|
||||
end,
|
||||
wallTypeId: "aw",
|
||||
height,
|
||||
});
|
||||
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.2 }] }],
|
||||
drawingLevels: [
|
||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||
],
|
||||
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls: [
|
||||
wall("HI", { x: 0, y: 0 }, { x: 4, y: 0 }, 2.6), // erreicht Schnitt → Poché
|
||||
wall("LOW", { x: 0, y: 2 }, { x: 4, y: 2 }, 0.3), // Brüstung → nur Ansicht
|
||||
],
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings: [],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
const visible = new Set(["20"]);
|
||||
|
||||
describe("generatePlan — Schnitt vs. Ansicht nach Schnitthöhe", () => {
|
||||
it("zeichnet die niedrige Wand nur als Ansichts-Umriss (fill none), die normale mit Poché", () => {
|
||||
const plan = generatePlan(project(), "eg", visible, undefined, "mittel");
|
||||
const wallPolys = (id: string) =>
|
||||
plan.primitives.filter(
|
||||
(p): p is Extract<typeof p, { kind: "polygon" }> =>
|
||||
p.kind === "polygon" && p.wallId === id,
|
||||
);
|
||||
|
||||
const hi = wallPolys("HI");
|
||||
const low = wallPolys("LOW");
|
||||
expect(hi.length).toBeGreaterThan(0);
|
||||
expect(low.length).toBeGreaterThan(0);
|
||||
|
||||
// Normale Wand: mindestens ein gefülltes Poché-Band (Bauteil-Farbe).
|
||||
expect(hi.some((p) => p.fill !== "none")).toBe(true);
|
||||
// Niedrige Wand: ALLE Polygone ohne Füllung (reiner Ansichts-Umriss).
|
||||
expect(low.every((p) => p.fill === "none")).toBe(true);
|
||||
});
|
||||
|
||||
it("behandelt die niedrige Wand als geschnitten, wenn die Schnitthöhe darunter liegt", () => {
|
||||
const p = project();
|
||||
p.drawingLevels[0].cutHeight = 0.2; // Schnittebene unter 0.3 m → auch LOW geschnitten
|
||||
const plan = generatePlan(p, "eg", visible, undefined, "mittel");
|
||||
const low = plan.primitives.filter(
|
||||
(pr): pr is Extract<typeof pr, { kind: "polygon" }> =>
|
||||
pr.kind === "polygon" && pr.wallId === "LOW",
|
||||
);
|
||||
expect(low.some((pr) => pr.fill !== "none")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Decke über der Schnittebene → gestrichelte Überkopf-Umrisslinie ──────────
|
||||
|
||||
/** Minimalprojekt mit EINER Decke (Slab) ohne Wände → voller Umriss sichtbar. */
|
||||
function ceilingProject(): Project {
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
|
||||
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
|
||||
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
|
||||
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.2 }] }],
|
||||
drawingLevels: [
|
||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||
],
|
||||
layers: [{ code: "30", name: "Decken", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls: [],
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings: [
|
||||
{
|
||||
id: "C1",
|
||||
type: "ceiling",
|
||||
floorId: "eg",
|
||||
categoryCode: "30",
|
||||
outline: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 2, y: 2 },
|
||||
{ x: 0, y: 2 },
|
||||
],
|
||||
wallTypeId: "aw",
|
||||
},
|
||||
],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePlan — Decke = gestrichelte Überkopf-Ansicht", () => {
|
||||
it("zeichnet den freien Decken-Umriss gestrichelt (Überkopf-Konvention)", () => {
|
||||
const plan = generatePlan(ceilingProject(), "eg", new Set(["30"]), undefined, "mittel");
|
||||
const outlines = plan.primitives.filter(
|
||||
(p): p is Extract<typeof p, { kind: "line" }> =>
|
||||
p.kind === "line" && p.cls === "ceiling-outline",
|
||||
);
|
||||
expect(outlines.length).toBeGreaterThan(0);
|
||||
// JEDE Decken-Umrisslinie ist gestrichelt (dash gesetzt, nicht null).
|
||||
expect(outlines.every((p) => Array.isArray(p.dash) && p.dash.length >= 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -476,54 +476,6 @@ export function compilePrimitivesToGpu(
|
||||
arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
|
||||
}
|
||||
strokePolyline(arcPts, false, color, strokeMm);
|
||||
} else if (prim.kind === 'drawingCircle' || prim.kind === 'drawingArc') {
|
||||
// 2D-Zeichnungs-Kreis/-Bogen (Kreis-/Bogen-Werkzeug ODER DXF-Import). Der
|
||||
// SVG-Pfad zeichnet sie als glattes <circle>/<path>; die WebGL-Ebene kennt
|
||||
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
|
||||
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
|
||||
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
|
||||
const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1];
|
||||
const strokeMm = prim.weightMm || 0.18;
|
||||
const c = prim.center;
|
||||
const TAU = Math.PI * 2;
|
||||
const a0 = prim.kind === 'drawingArc' ? prim.a0 : 0;
|
||||
const sweep =
|
||||
prim.kind === 'drawingArc'
|
||||
? ((prim.a1 - prim.a0) % TAU + TAU) % TAU || TAU
|
||||
: TAU;
|
||||
// Segmentzahl wie im 'arc'-Zweig: Sagitta < ~0.5 px (LOD über pxPerMeter).
|
||||
let segs: number;
|
||||
if (pxPerMeter > 0) {
|
||||
const rPx = prim.r * pxPerMeter;
|
||||
const theta = rPx > 0.5 ? 2 * Math.acos(1 - 0.5 / rPx) : Math.PI;
|
||||
segs = Math.min(2048, Math.max(8, Math.ceil(sweep / theta)));
|
||||
} else {
|
||||
segs = Math.max(8, Math.ceil((sweep / (Math.PI / 2)) * 16));
|
||||
}
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i <= segs; i++) {
|
||||
const t = a0 + (sweep * i) / segs;
|
||||
pts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
|
||||
}
|
||||
// Vollton-Füllung nur beim geschlossenen Kreis (Bogen ist offen).
|
||||
if (prim.kind === 'drawingCircle') {
|
||||
const fill = parseColor(prim.fill);
|
||||
if (fill) {
|
||||
const ring = pts.slice(0, segs); // ohne den doppelten Schlusspunkt
|
||||
const tris = triangulate(ring);
|
||||
if (tris.length) {
|
||||
const base = fillPos.length / 2;
|
||||
for (const pt of ring) {
|
||||
const s = toScreen(pt);
|
||||
fillPos.push(s.x, s.y);
|
||||
track(pt.x, pt.y);
|
||||
}
|
||||
for (const t of tris) fillIdx.push(base + t);
|
||||
addFillBatch(tris.length, fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
strokePolyline(pts, prim.kind === 'drawingCircle', stroke, strokeMm);
|
||||
}
|
||||
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
|
||||
}
|
||||
|
||||
@@ -486,22 +486,7 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
|
||||
widthMm: p.weightMm,
|
||||
dash: p.dash ?? null,
|
||||
});
|
||||
} else if (p.kind === "drawingCircle" || p.kind === "drawingArc") {
|
||||
// Der native Renderer hat kein Kreis-/Bogen-Primitiv → hier tessellieren
|
||||
// (der SVG-Pfad zeichnet sie glatt). Kreis = voller Umlauf, Bogen = a0..a1.
|
||||
flushRun();
|
||||
const col = withOpacity(toRgba(p.stroke, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed);
|
||||
const TAU = Math.PI * 2;
|
||||
const a0 = p.kind === "drawingArc" ? p.a0 : 0;
|
||||
const sweep = p.kind === "drawingArc" ? (((p.a1 - p.a0) % TAU) + TAU) % TAU : TAU;
|
||||
const N = Math.max(8, Math.ceil(sweep / (Math.PI / 32)));
|
||||
const pts: RPoint[] = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = a0 + (sweep * i) / N;
|
||||
pts.push([p.center.x + p.r * Math.cos(t), p.center.y + p.r * Math.sin(t)]);
|
||||
}
|
||||
polylines.push({ pts, color: col, widthMm: p.weightMm, dash: p.dash ?? null, z: zc++ });
|
||||
} else if (p.kind === "text") {
|
||||
} else {
|
||||
// "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust-
|
||||
// Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie
|
||||
// wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case
|
||||
|
||||
@@ -63,96 +63,12 @@ describe("selectionHighlightLines", () => {
|
||||
expect(two.length).toBe(one.length * 2);
|
||||
});
|
||||
|
||||
it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => {
|
||||
// NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
|
||||
// NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
|
||||
// Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
|
||||
// genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
|
||||
// Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches
|
||||
// durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.)
|
||||
const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
|
||||
const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
|
||||
expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
|
||||
expect(w1.length).toBe(w2.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:true)", () => {
|
||||
// Wand W1 (EG, Wandtyp "aw" = 4 Schichten) mit ZWEI Fenstern, deren Achsen-
|
||||
// Intervalle sich ÜBERLAPPEN, aber auf UNTERSCHIEDLICHER Höhe liegen:
|
||||
// Fenster A: u=[1.0,2.0] (position 1.0, width 1.0), z=[0.9,2.1] (sill 0.9, h 1.2)
|
||||
// Fenster B: u=[1.5,2.5] (position 1.5, width 1.0), z=[0.3,0.7] (sill 0.3, h 0.4)
|
||||
// Die alte Segment-Zerlegung kann diesen versetzt-überlappenden Fall nicht
|
||||
// abbilden; als Löcher in EINEM Körper müssen beide sauber als `holes`
|
||||
// erscheinen. W1: start (0,0)->(5,0), floor "eg" (baseElevation 0).
|
||||
const twoOffsetWindows: Project = {
|
||||
...sampleProject,
|
||||
doors: [],
|
||||
openings: [
|
||||
{
|
||||
id: "WA",
|
||||
type: "opening",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
position: 1.0,
|
||||
width: 1.0,
|
||||
height: 1.2,
|
||||
sillHeight: 0.9,
|
||||
},
|
||||
{
|
||||
id: "WB",
|
||||
type: "opening",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
position: 1.5,
|
||||
width: 1.0,
|
||||
height: 0.4,
|
||||
sillHeight: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => {
|
||||
const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1");
|
||||
// Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen.
|
||||
expect(w1.length).toBe(4);
|
||||
for (const body of w1) {
|
||||
// Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper).
|
||||
expect(body.holes).toBeDefined();
|
||||
expect(body.holes!.length).toBe(2);
|
||||
const byFrom = [...body.holes!].sort((a, b) => a.from - b.from);
|
||||
// Fenster A: u=[1,2], z=[0.9,2.1].
|
||||
expect(byFrom[0].from).toBeCloseTo(1.0, 6);
|
||||
expect(byFrom[0].to).toBeCloseTo(2.0, 6);
|
||||
expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6);
|
||||
expect(byFrom[0].zTop).toBeCloseTo(2.1, 6);
|
||||
// Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer.
|
||||
expect(byFrom[1].from).toBeCloseTo(1.5, 6);
|
||||
expect(byFrom[1].to).toBeCloseTo(2.5, 6);
|
||||
expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6);
|
||||
expect(byFrom[1].zTop).toBeCloseTo(0.7, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
|
||||
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
|
||||
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
|
||||
// Boxen als die 4 Schicht-Bänder des 3D-Pfads) und setzt NIE `holes`.
|
||||
expect(w1.length).toBeGreaterThan(4);
|
||||
for (const seg of w1) expect(seg.holes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Tür = Loch bis zur Wand-UK (zBottom == baseElevation)", () => {
|
||||
// Eine Tür (D1 auf W1) muss im 3D-Pfad als Loch bis zum Boden erscheinen:
|
||||
// zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung.
|
||||
const p: Project = { ...sampleProject, openings: [] };
|
||||
const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
|
||||
expect(w1.length).toBe(4);
|
||||
const door = w1[0].holes?.find((h) => h.zBottom < 1e-6);
|
||||
expect(door).toBeDefined();
|
||||
expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK.
|
||||
it("eine Wand MIT Öffnungen -> mehrere Teilquader (mehr als 12 Kanten)", () => {
|
||||
// W1 trägt Tür D1 + Fenster O1 -> Pfeiler/Brüstung/Sturz-Segmente, also
|
||||
// ein Vielfaches von 12 Kanten (mehr als ein einzelner Quader).
|
||||
const lines = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
|
||||
expect(lines.length).toBeGreaterThan(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
|
||||
expect(lines.length % (12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+4
-83
@@ -59,24 +59,6 @@ export interface RLayer {
|
||||
color: RRgb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper — matcht 1:1
|
||||
* render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab
|
||||
* `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle);
|
||||
* `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders
|
||||
* als `Opening.sillHeight`). Eine Tür ist der Sonderfall
|
||||
* `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die
|
||||
* Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter-
|
||||
* Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu
|
||||
* zerlegen.
|
||||
*/
|
||||
export interface RHole {
|
||||
from: number;
|
||||
to: number;
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
}
|
||||
|
||||
export interface RWall {
|
||||
start: RVec2;
|
||||
end: RVec2;
|
||||
@@ -97,19 +79,6 @@ export interface RWall {
|
||||
* serde verwirft unbekannte Felder).
|
||||
*/
|
||||
wallId: string;
|
||||
/**
|
||||
* Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe
|
||||
* {@link RHole}). Nur im 3D-Viewer-Pfad ({@link projectToModel3d} mit
|
||||
* `layeredWalls:true`) gesetzt: dort emittiert {@link emitWall} EINEN RWall pro
|
||||
* Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der
|
||||
* Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt
|
||||
* übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der
|
||||
* Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert
|
||||
* unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/
|
||||
* leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes`
|
||||
* und die Segment-Zerlegung schliessen sich damit gegenseitig aus.
|
||||
*/
|
||||
holes?: RHole[];
|
||||
}
|
||||
|
||||
/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
|
||||
@@ -255,13 +224,6 @@ function resolveWallBands(project: Project, wall: Wall, layered = true): WallBan
|
||||
* mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0)
|
||||
* werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne
|
||||
* Nullquader.
|
||||
*
|
||||
* Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe
|
||||
* {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe
|
||||
* Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides
|
||||
* unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur
|
||||
* senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur
|
||||
* im 3D-Pfad (voller Achsen-Quader) gesetzt.
|
||||
*/
|
||||
function pushSegment(
|
||||
out: RWall[],
|
||||
@@ -274,7 +236,6 @@ function pushSegment(
|
||||
normalOffset: number,
|
||||
color: RRgb,
|
||||
wallId: string,
|
||||
holes?: RHole[],
|
||||
): void {
|
||||
if (to - from <= EPS) return;
|
||||
if (zTop - zBottom <= EPS) return;
|
||||
@@ -308,7 +269,6 @@ function pushSegment(
|
||||
color,
|
||||
layers: [],
|
||||
wallId,
|
||||
...(holes && holes.length > 0 ? { holes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -462,31 +422,11 @@ function trimWallTopForCeilings(
|
||||
return Math.max(zBottom, top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
|
||||
*
|
||||
* ZWEI PFADE je `layered`:
|
||||
* • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die
|
||||
* VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in
|
||||
* die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in
|
||||
* EINEM Körper (keine Segment-Nähte).
|
||||
* • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/
|
||||
* Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die
|
||||
* `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners`
|
||||
* passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher
|
||||
* werden hier NICHT emittiert.
|
||||
*/
|
||||
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
|
||||
function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void {
|
||||
const bands = resolveWallBands(project, wall, layered);
|
||||
const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall);
|
||||
// Decken-Trim (kappt die Wand an der Unterkante einer dominanteren Decke) NUR
|
||||
// im 3D-Viewer-Pfad (layered=true): dort vermeidet das Kappen das Z-Fighting
|
||||
// Wand/Decke am bündigen Anschluss. Der Schnitt-Pfad (layered=false) will die
|
||||
// UNGEKAPPTE volle Wandhöhe, weil er seine EIGENE prioritätsbasierte Schicht-
|
||||
// Dominanz (toSection.ts::subtractDominantBands) auf Rohgeometrie anwendet —
|
||||
// ein vorab gekürzter Wandkopf würde dort Schichteinzug/Bodenaufbau am Wand-
|
||||
// Decken-Anschluss verfälschen.
|
||||
const zTop = layered ? trimWallTopForCeilings(project, wall, zBottom, rawZTop) : rawZTop;
|
||||
const zTop = trimWallTopForCeilings(project, wall, zBottom, rawZTop);
|
||||
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||||
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
|
||||
|
||||
@@ -521,27 +461,8 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v
|
||||
}
|
||||
cutouts.sort((a, b) => a.from - b.from);
|
||||
|
||||
// ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
|
||||
// Achse mit allen Öffnungen als echte Löcher. Die Segment-Zerlegung entfällt
|
||||
// komplett — render3d stanzt die Löcher (Rechteck-Gitter + Laibungen). Türen
|
||||
// sind Löcher bis zur Wand-UK (`oBottom == zBottom`), Fenster ab Brüstung. So
|
||||
// bildet EIN Körper auch versetzt übereinanderliegende Fenster ab.
|
||||
if (layered) {
|
||||
const holes: RHole[] = cutouts.map((c) => ({
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
zBottom: c.oBottom,
|
||||
zTop: c.oTop,
|
||||
}));
|
||||
for (const b of bands) {
|
||||
pushSegment(out, wall, 0, axisLen, zBottom, zTop, b.thickness, b.offset, b.color, wall.id, holes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz-
|
||||
// Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners).
|
||||
// Ohne Aussparungen: ein durchgehender Quader je Schicht.
|
||||
// Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber
|
||||
// in Lagen aufgeteilt).
|
||||
if (cutouts.length === 0) {
|
||||
emitSeg(0, axisLen, zBottom, zTop);
|
||||
return;
|
||||
|
||||
@@ -137,12 +137,6 @@ export interface OpeningInfo {
|
||||
swing?: "left" | "right";
|
||||
swingAngle?: number;
|
||||
openingDir?: "in" | "out";
|
||||
/** Nur Fenster: Anzahl der Flügel (1–4). Fehlt → 1 (Default). */
|
||||
wingCount?: number;
|
||||
/** Nur Tür: Tür-Typ (normal / Wandöffnung). Fehlt → "normal". */
|
||||
doorType?: "normal" | "wandoeffnung";
|
||||
/** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */
|
||||
lintelLines?: "keine" | "innen" | "aussen" | "beide";
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
@@ -166,8 +160,6 @@ export interface StairInfo {
|
||||
treadDepth: number;
|
||||
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
||||
up: boolean;
|
||||
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
||||
referenz?: "links" | "mitte" | "rechts";
|
||||
/** Geschoss, dem die Treppe zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
@@ -486,9 +478,6 @@ function openingSelection(project: Project, o: Opening): Selection {
|
||||
swing: o.swing,
|
||||
swingAngle: o.swingAngle,
|
||||
openingDir: o.openingDir,
|
||||
wingCount: o.wingCount,
|
||||
doorType: o.doorType,
|
||||
lintelLines: o.lintelLines,
|
||||
zBottom: ext.zBottom,
|
||||
zTop: ext.zTop,
|
||||
};
|
||||
@@ -524,7 +513,6 @@ function stairSelection(project: Project, s: Stair): Selection {
|
||||
riserHeight: geo.riserHeight,
|
||||
treadDepth: geo.treadDepth,
|
||||
up: s.up !== false,
|
||||
referenz: s.referenz,
|
||||
floorId: s.floorId,
|
||||
floorName: floor?.name ?? s.floorId,
|
||||
zBottom,
|
||||
|
||||
+25
-330
@@ -109,11 +109,6 @@ body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Dokument selbst NIE scrollen/bouncen: das Zoom-Wheel im Viewport darf die
|
||||
App-Chrome nicht mitziehen (macOS-Overscroll/Rubberband). Interne Panels
|
||||
scrollen weiterhin über ihre eigenen overflow-Container. */
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Native-App-Verhalten: KEINE Textauswahl / „Alles markieren" in der App-Chrome.
|
||||
@@ -157,9 +152,9 @@ body {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
height: 26px;
|
||||
gap: 10px;
|
||||
padding: 6px 14px;
|
||||
height: 60px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
|
||||
nach unten → trennt sich klar von der Zeichenfläche (Tiefe). */
|
||||
@@ -174,12 +169,6 @@ body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
/* Tauri/macOS: native Ampeln (titleBarStyle "Overlay") liegen oben links über
|
||||
dem Inhalt → Zeile links einrücken, damit `dossier.` frei bleibt (~3 Ampeln +
|
||||
Rand). */
|
||||
.topbar.topbar-native-inset {
|
||||
padding-left: 78px;
|
||||
}
|
||||
/* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen
|
||||
(`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */
|
||||
.topbar::-webkit-scrollbar {
|
||||
@@ -561,12 +550,10 @@ body {
|
||||
line-height: 1;
|
||||
}
|
||||
.brand-word {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.01em;
|
||||
text-transform: lowercase;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-dot {
|
||||
color: var(--accent);
|
||||
@@ -1476,18 +1463,6 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mitte-Spalte: Viewport (.content) + Befehlszeile (.cmdline) darunter. Nimmt in
|
||||
der horizontalen .body den Platz ZWISCHEN den Docks ein und stapelt vertikal —
|
||||
so ist die Befehlszeile nur so breit wie der Viewport, und die Docks reichen
|
||||
über die volle Body-Höhe (inkl. der Befehlszeilen-Zeile). */
|
||||
.center-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Dokument-Tabs „Zeichnungen/Blätter" — Kopfzeile über der Zeichenfläche.
|
||||
Leiste auf Ebene 1 (--panel), der aktive Tab hebt sich auf Ebene 2 an und
|
||||
verschmilzt unten mit dem Grund der Zeichenfläche darunter (--bg), wie bei
|
||||
@@ -3917,64 +3892,6 @@ body {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Element-Abschnitte IM Attribute-Panel dem .attr-Grid-Look angleichen ──────
|
||||
Die aus ObjectInfo verschobenen Sektionen (Wand/Decke/Öffnung/Treppe/Raum)
|
||||
nutzen weiter die `objinfo-*`-Klassen; hier bekommen sie — NUR innerhalb
|
||||
`.attr-panel` — denselben sauberen Look wie das obere Attribut-Grid: Sektion-
|
||||
Balken, Zeilentrenner, einheitliche Label-Spalte, linksbündig füllende Felder.
|
||||
Das eigenständige ObjectInfo-Panel (Bezugspunkt/Masse) bleibt unberührt. */
|
||||
.attr-panel .objinfo-section-label {
|
||||
color: var(--label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 8px 0 2px;
|
||||
padding: 3px 6px;
|
||||
background: var(--panel-2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* Sektion-Balken trennen bereits → die dünnen Extra-Striche entfallen. */
|
||||
.attr-panel .objinfo-sep {
|
||||
display: none;
|
||||
}
|
||||
/* Zeile: Label-Spalte | Wert/Control, mit dezentem Zeilentrenner (wie .attr-*). */
|
||||
.attr-panel .objinfo-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 2px 2px 2px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.attr-panel .objinfo-flabel {
|
||||
flex: 0 0 104px;
|
||||
width: 104px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 8px;
|
||||
color: var(--label);
|
||||
}
|
||||
/* Werte/Controls füllen die Zelle linksbündig (statt rechtsbündig). */
|
||||
.attr-panel .objinfo-fval {
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
.attr-panel .objinfo-input {
|
||||
text-align: left;
|
||||
}
|
||||
.attr-panel .objinfo-field .tb-dd-trigger {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
/* Segment-Umschalter + verschachtelte Felder füllen ebenfalls die Wert-Spalte. */
|
||||
.attr-panel .objinfo-segment,
|
||||
.attr-panel .objinfo-subfield {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Wand-Abschnitt (Object-Info) ─────────────────────────────────────── */
|
||||
/* Referenzlinie-Dropdown rechts oben im Kopf. */
|
||||
.objinfo-head-ref {
|
||||
@@ -4045,6 +3962,10 @@ body {
|
||||
|
||||
/* ── Attribute-Palette (AttributesPanel) ─────────────────────────────── */
|
||||
.attr-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -4055,66 +3976,44 @@ body {
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
/* OCS-artiges Eigenschaften-Grid: EIN durchgehendes Zwei-Spalten-Grid über alle
|
||||
Sektionen (Label | Wert), damit die Wertfelder über Stift/Füllung/Maße hinweg
|
||||
in EINER Spalte bündig stehen. Sektion-Balken spannen beide Spalten. Zeilen mit
|
||||
dezenten Trennlinien; Wertfelder füllen ihre Zelle linksbündig über die volle
|
||||
Panel-Breite — einheitlicher Look für Texte/Zahlen/Dropdowns. */
|
||||
.attr-title {
|
||||
font-weight: 600;
|
||||
color: var(--label);
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
.attr-section-label {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 8px 0 2px;
|
||||
padding: 3px 6px;
|
||||
background: var(--panel-2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.attr-section-label:first-child {
|
||||
margin-top: 0;
|
||||
margin-top: 6px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.attr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, max-content) 1fr;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
padding: 2px;
|
||||
}
|
||||
.attr-key {
|
||||
color: var(--label);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px 3px 2px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.attr-val {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
/* Wertfelder (Dropdown-Trigger, Zahlenfeld) füllen die Wert-Spalte — der
|
||||
Dropdown wächst in die verfügbare Breite (kein fixes width mehr im TSX). */
|
||||
.attr-val .tb-dd-trigger {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.attr-readonly {
|
||||
color: var(--label);
|
||||
opacity: 0.85;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.attr-num,
|
||||
.attr-select {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
width: 110px;
|
||||
background: var(--panel-2);
|
||||
color: var(--label);
|
||||
border: 1px solid var(--line);
|
||||
@@ -5381,16 +5280,15 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
/* Ganz links in der randlosen Titelleiste (macOS-Konvention). */
|
||||
margin-right: 4px;
|
||||
margin-left: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.win-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 22px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -5407,206 +5305,3 @@ body {
|
||||
background: #e5484d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Ribbon (2D · 3D · BIM · Ansichten) ────────────────────────────────────
|
||||
Die Tab-REITER (`.ribbon-tabs`) sitzen in der TopBar-Zeile; hier folgt nur der
|
||||
INHALT (`.ribbon`/`.ribbon-body`) des aktiven Tabs darunter. So gibt es genau
|
||||
zwei Bänder: TopBar-Zeile (Chrome + Tabs) oben, Ribbon-Inhalt darunter. */
|
||||
.ribbon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Tab-Reiter — leben in der TopBar-Zeile: volle Zeilenhöhe, Aktiv-Unterstrich
|
||||
sitzt an der Unterkante (verbindet optisch mit dem Inhalt darunter). */
|
||||
.ribbon-tabs {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
}
|
||||
.ribbon-tab {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
/* Quick-Access-Leiste (OCS-Stil) — kleine Icon-Knöpfe in der TopBar-Zeile. */
|
||||
.tb-qa {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tb-qa-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.tb-qa-btn:hover:not(:disabled) {
|
||||
background: var(--panel-2);
|
||||
color: var(--label);
|
||||
}
|
||||
.tb-qa-btn.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tb-qa-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tb-qa-btn .tb-ico {
|
||||
font-size: 16px;
|
||||
}
|
||||
.tb-qa-sep {
|
||||
width: 1px;
|
||||
height: 15px;
|
||||
background: var(--line);
|
||||
margin: 0 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ribbon-tab:hover {
|
||||
color: var(--label);
|
||||
}
|
||||
.ribbon-tab.active {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--accent-border, var(--accent));
|
||||
}
|
||||
.ribbon-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 4px 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ribbon-body::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.ribbon-body::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
.ribbon-body:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* Eine Gruppe: Buttons oben, kleiner Gruppentitel darunter; rechts ein dezenter
|
||||
Trenner (wie OCS-Ribbon-Gruppen). */
|
||||
.ribbon-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 8px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.ribbon-group:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.ribbon-items {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
.ribbon-group-title {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding-top: 2px;
|
||||
}
|
||||
/* Ein Ribbon-Button: Icon oben, Label darunter (kompakt, wie eine Palette). */
|
||||
.ribbon-btn {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--label);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 52px;
|
||||
padding: 4px 2px 3px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.ribbon-btn span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ribbon-btn:hover:not(:disabled) {
|
||||
background: var(--panel-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.ribbon-btn.active {
|
||||
background: var(--accent-dim, var(--panel-2));
|
||||
border-color: var(--accent-border, var(--accent));
|
||||
color: var(--ink);
|
||||
}
|
||||
.ribbon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ribbon-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 0 8px;
|
||||
}
|
||||
/* Custom-Bar-Picker („+ Hinzufügen") — kompakter, unauffälliger Trigger, der
|
||||
sich neben die Custom-Buttons stellt. */
|
||||
.ribbon-add.tb-dd-trigger {
|
||||
align-self: center;
|
||||
height: 24px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
border-style: dashed;
|
||||
}
|
||||
.ribbon-add.tb-dd-trigger:hover {
|
||||
color: var(--label);
|
||||
}
|
||||
/* „Ansichten"-Tab: die aus der TopBar gemergten Cluster (View-Grid, Kombis,
|
||||
Detail, Zoom, Darstellung) in EINER Zeile — nutzt die bestehenden `.tb-*`-
|
||||
Stile unverändert weiter. */
|
||||
.ribbon-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -39,12 +39,11 @@ export const ACCENT_SWATCHES: AccentSwatch[] = [
|
||||
export const DEFAULT_MARQUEE_COLOR = "#CEB188";
|
||||
|
||||
/**
|
||||
* Snap-/Endpunkt-Default: Akzent „Sora" (#5FA1C9). Vom Nutzer als endgültiger
|
||||
* Default festgelegt (2026-07-04) — die frühere „aki"-Rückfrage ist damit
|
||||
* geschlossen. Bewusst ein kühler, heller Ton: hebt sich klar vom warmen
|
||||
* Yuyake-Auswahlrahmen UND von den meist neutral-dunklen Wand-/Linienfarben ab,
|
||||
* bleibt aber (anders als das sehr blasse Kori) auch auf hellem
|
||||
* Papier-Hintergrund gut sichtbar. Per Picker im Einstellungs-Fenster änderbar.
|
||||
* Snap-/Endpunkt-Default: Akzent „Sora" (vorläufig — die offene Rückfrage
|
||||
* „aki" im HANDOVER-Backlog ist noch nicht geklärt). Bewusst ein kühler,
|
||||
* heller Ton: hebt sich klar vom warmen Yuyake-Auswahlrahmen UND von den
|
||||
* meist neutral-dunklen Wand-/Linienfarben ab, bleibt aber (anders als das
|
||||
* sehr blasse Kori) auch auf hellem Papier-Hintergrund gut sichtbar.
|
||||
*/
|
||||
export const DEFAULT_SNAP_COLOR = "#5FA1C9";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSnap, wallLayerBoundarySegments } from "./snapping";
|
||||
import { DEFAULT_SNAP } from "./types";
|
||||
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
||||
import type { Project, Vec2, Wall } from "../model/types";
|
||||
|
||||
/** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */
|
||||
function wallProject(wall: Wall): Project {
|
||||
@@ -134,95 +134,3 @@ describe("computeSnap — fängt auf Wand-Schichttrennlinien", () => {
|
||||
expect(result!.point.y).toBeCloseTo(0.025, 6);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Kreis-/Bogen-Snaps (Center/Quadrant/Bogen-Endpunkt) ──────────────────────
|
||||
|
||||
/** Minimalprojekt mit gegebenen 2D-Geometrien auf dem Geschoss „eg". */
|
||||
function drawProject(geoms: Drawing2D["geom"][]): Project {
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
|
||||
wallTypes: [],
|
||||
drawingLevels: [
|
||||
{
|
||||
id: "eg",
|
||||
name: "EG",
|
||||
kind: "floor",
|
||||
visible: true,
|
||||
locked: false,
|
||||
floorHeight: 2.6,
|
||||
cutHeight: 1.0,
|
||||
baseElevation: 0,
|
||||
},
|
||||
],
|
||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls: [],
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings: [],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: geoms.map((geom, i) => ({
|
||||
id: `d${i}`,
|
||||
type: "drawing2d" as const,
|
||||
levelId: "eg",
|
||||
categoryCode: "20",
|
||||
geom,
|
||||
})),
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
function snapAt(project: Project, raw: Vec2, settings = DEFAULT_SNAP) {
|
||||
return computeSnap({
|
||||
raw,
|
||||
project,
|
||||
levelId: "eg",
|
||||
visibleCodes: new Set(["20"]),
|
||||
settings,
|
||||
draftPoints: [],
|
||||
lastPoint: null,
|
||||
pxPerMeter: 100,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("computeSnap — Kreis/Bogen", () => {
|
||||
const withCenter = { ...DEFAULT_SNAP, center: true };
|
||||
|
||||
it("fängt Mittelpunkt + Quadranten eines Kreises (center aktiv)", () => {
|
||||
const p = drawProject([{ shape: "circle", center: { x: 0, y: 0 }, r: 1 }]);
|
||||
const c = snapAt(p, { x: 0, y: 0 }, withCenter);
|
||||
expect(c?.kind).toBe("center");
|
||||
expect(c!.point.x).toBeCloseTo(0, 6);
|
||||
expect(c!.point.y).toBeCloseTo(0, 6);
|
||||
const q = snapAt(p, { x: 1, y: 0 }, withCenter);
|
||||
expect(q?.kind).toBe("quadrant");
|
||||
expect(q!.point.x).toBeCloseTo(1, 6);
|
||||
expect(q!.point.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it("fängt Bogen-Endpunkte (endpoint per Default aktiv)", () => {
|
||||
// Viertelbogen um (5,0), r=1, 0…90° → Endpunkte (6,0) und (5,1).
|
||||
const p = drawProject([
|
||||
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
|
||||
]);
|
||||
const e = snapAt(p, { x: 6, y: 0 });
|
||||
expect(e?.kind).toBe("endpoint");
|
||||
expect(e!.point.x).toBeCloseTo(6, 6);
|
||||
expect(e!.point.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it("bietet Quadranten AUSSERHALB der Bogen-Spanne NICHT an", () => {
|
||||
// Viertelbogen 0…90°: der West-Quadrant (4,0) liegt NICHT im Bogen.
|
||||
const p = drawProject([
|
||||
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
|
||||
]);
|
||||
const r = snapAt(p, { x: 4, y: 0 }, withCenter);
|
||||
expect(r?.kind).not.toBe("quadrant");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,28 +108,6 @@ function collectSegments(input: SnapInput): Seg[] {
|
||||
return segs;
|
||||
}
|
||||
|
||||
/** Kreis-/Bogen-Geometrie für Center-/Quadrant-/Endpunkt-Snaps. `arc=null` =
|
||||
* Vollkreis (keine Endpunkte, alle vier Quadranten). */
|
||||
interface CircleGeom {
|
||||
center: Vec2;
|
||||
r: number;
|
||||
arc: { a0: number; a1: number } | null;
|
||||
}
|
||||
|
||||
/** Sammelt sichtbare Kreise/Bögen des aktiven Geschosses (aus `drawings2d`). */
|
||||
function collectCircles(input: SnapInput): CircleGeom[] {
|
||||
const out: CircleGeom[] = [];
|
||||
for (const d of input.project.drawings2d) {
|
||||
if (d.levelId !== input.levelId) continue;
|
||||
if (!input.visibleCodes.has(d.categoryCode)) continue;
|
||||
const g = d.geom;
|
||||
if (g.shape === "circle") out.push({ center: g.center, r: g.r, arc: null });
|
||||
else if (g.shape === "arc")
|
||||
out.push({ center: g.center, r: g.r, arc: { a0: g.a0, a1: g.a1 } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
|
||||
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
@@ -206,34 +184,6 @@ export function computeSnap(input: SnapInput): SnapResult | null {
|
||||
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
|
||||
}
|
||||
|
||||
// Kreis-/Bogen-Snaps: Mittelpunkt + Quadranten (Ost/Nord/West/Süd); bei Bögen
|
||||
// nur die Quadranten IM Bogen-Bereich, plus die beiden Bogen-Endpunkte. Center/
|
||||
// Quadrant hängen an der `center`-Einstellung, die Endpunkte an `endpoint`.
|
||||
if (settings.center || settings.endpoint) {
|
||||
const TAU = Math.PI * 2;
|
||||
const norm = (x: number) => ((x % TAU) + TAU) % TAU;
|
||||
const onCircle = (c: CircleGeom, ang: number): Vec2 => ({
|
||||
x: c.center.x + c.r * Math.cos(ang),
|
||||
y: c.center.y + c.r * Math.sin(ang),
|
||||
});
|
||||
for (const c of collectCircles(input)) {
|
||||
if (settings.center) {
|
||||
consider(c.center, "center");
|
||||
// Volle CCW-Spanne (0 ⇒ Vollkreis); Quadrant nur, wenn im Bogen liegt.
|
||||
const sweep = c.arc ? norm(c.arc.a1 - c.arc.a0) || TAU : TAU;
|
||||
for (let q = 0; q < 4; q++) {
|
||||
const ang = q * (Math.PI / 2);
|
||||
if (c.arc && norm(ang - c.arc.a0) > sweep + 1e-9) continue;
|
||||
consider(onCircle(c, ang), "quadrant");
|
||||
}
|
||||
}
|
||||
if (settings.endpoint && c.arc) {
|
||||
consider(onCircle(c, c.arc.a0), "endpoint");
|
||||
consider(onCircle(c, c.arc.a1), "endpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best) return best;
|
||||
|
||||
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
|
||||
|
||||
@@ -526,34 +526,6 @@ const rectTool: Tool = {
|
||||
onCancel: () => [{ phase: "idle" }, { draft: null, done: true }],
|
||||
};
|
||||
|
||||
// Kreis: Platzhalter-Werkzeug (analog window/door), gekoppelt an den
|
||||
// `circle`-Befehl (TOOL_COMMAND in App.tsx). Der eigentliche Ablauf
|
||||
// (Mittelpunkt → Radius) läuft über die Command-Engine — EIN Pfad für
|
||||
// Toolbar-Klick und getipptes „circle". Nicht floorOnly (freie 2D-Zeichnung).
|
||||
const circleTool: Tool = {
|
||||
id: "circle",
|
||||
labelKey: "tool.circle",
|
||||
hintKey: () => "tool.circle.hint",
|
||||
init: () => ({ phase: "idle" }),
|
||||
onClick: (s) => [s, { draft: null }],
|
||||
onMove: (s) => [s, { draft: null }],
|
||||
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||
onCancel: (s) => [s, { draft: null, done: true }],
|
||||
};
|
||||
|
||||
// Bogen: Platzhalter-Werkzeug, gekoppelt an den `arc`-Befehl (3 Klicks:
|
||||
// Mitte → Start/Radius → Ende). Wie Kreis nicht floorOnly.
|
||||
const arcTool: Tool = {
|
||||
id: "arc",
|
||||
labelKey: "tool.arc",
|
||||
hintKey: () => "tool.arc.hint",
|
||||
init: () => ({ phase: "idle" }),
|
||||
onClick: (s) => [s, { draft: null }],
|
||||
onMove: (s) => [s, { draft: null }],
|
||||
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||
onCancel: (s) => [s, { draft: null, done: true }],
|
||||
};
|
||||
|
||||
// ── Registry ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLS: Record<ToolId, Tool> = {
|
||||
@@ -567,8 +539,6 @@ const TOOLS: Record<ToolId, Tool> = {
|
||||
line: lineTool,
|
||||
polyline: polylineTool,
|
||||
rect: rectTool,
|
||||
circle: circleTool,
|
||||
arc: arcTool,
|
||||
};
|
||||
|
||||
/** Liefert das Werkzeug zur ID. */
|
||||
@@ -588,6 +558,4 @@ export const TOOL_ORDER: ToolId[] = [
|
||||
"line",
|
||||
"polyline",
|
||||
"rect",
|
||||
"circle",
|
||||
"arc",
|
||||
];
|
||||
|
||||
+1
-3
@@ -18,9 +18,7 @@ export type ToolId =
|
||||
| "room"
|
||||
| "line"
|
||||
| "polyline"
|
||||
| "rect"
|
||||
| "circle"
|
||||
| "arc";
|
||||
| "rect";
|
||||
|
||||
// ── Snapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+3
-52
@@ -21,7 +21,6 @@ import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
|
||||
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
|
||||
import {
|
||||
contoursToDrawings,
|
||||
textsToDrawings,
|
||||
countContours,
|
||||
distinctLayers,
|
||||
} from "../io/dxfToDrawings";
|
||||
@@ -36,12 +35,6 @@ export interface ImportDecision {
|
||||
categoryMode: CategoryMode;
|
||||
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
|
||||
importMeshes: boolean;
|
||||
/**
|
||||
* Platzierung der importierten Geometrie: `origin` = 1:1 an den DXF-Koordinaten
|
||||
* (relativ zum Nullpunkt); `viewCenter` = der Gesamt-Umriss wird in die Mitte
|
||||
* des aktuellen Ausschnitts geschoben.
|
||||
*/
|
||||
placement: "origin" | "viewCenter";
|
||||
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
|
||||
parsed: DxfImportResult;
|
||||
}
|
||||
@@ -114,11 +107,9 @@ export function ImportDialog({
|
||||
|
||||
const contourSets = parsed?.contours ?? [];
|
||||
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
|
||||
const texts = parsed?.texts ?? [];
|
||||
const contourCount = countContours(contourSets);
|
||||
const textCount = texts.length;
|
||||
const layers = distinctLayers(contourSets);
|
||||
const hasDrawings = contourCount > 0 || textCount > 0;
|
||||
const hasDrawings = contourCount > 0;
|
||||
const hasMeshes = meshes.length > 0;
|
||||
|
||||
// ── Formular-Zustand ──────────────────────────────────────────────────────
|
||||
@@ -133,7 +124,6 @@ export function ImportDialog({
|
||||
layers.length > 0 ? "byLayer" : "active",
|
||||
);
|
||||
const [importMeshes, setImportMeshes] = useState<boolean>(true);
|
||||
const [placement, setPlacement] = useState<"origin" | "viewCenter">("origin");
|
||||
|
||||
const canImport =
|
||||
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
|
||||
@@ -144,7 +134,7 @@ export function ImportDialog({
|
||||
targetKind === "existing"
|
||||
? ({ kind: "existing", levelId: existingLevelId } as const)
|
||||
: ({ kind: "new", name: newName } as const);
|
||||
onImport({ target, categoryMode, importMeshes, placement, parsed });
|
||||
onImport({ target, categoryMode, importMeshes, parsed });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -197,9 +187,6 @@ export function ImportDialog({
|
||||
</div>
|
||||
<ul className="imp-summary">
|
||||
<li>{t("import.summary.contours", { n: contourCount })}</li>
|
||||
{textCount > 0 && (
|
||||
<li>{t("import.summary.texts", { n: textCount })}</li>
|
||||
)}
|
||||
<li>{t("import.summary.meshes", { n: meshes.length })}</li>
|
||||
<li>
|
||||
{layers.length > 0
|
||||
@@ -302,34 +289,6 @@ export function ImportDialog({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Platzierung: an den DXF-Koordinaten (relativ zu 0) ODER in die
|
||||
Mitte des aktuellen Ausschnitts geschoben. */}
|
||||
{hasDrawings && (
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">
|
||||
{t("import.placement.heading")}
|
||||
</div>
|
||||
<label className="imp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="imp-place"
|
||||
checked={placement === "origin"}
|
||||
onChange={() => setPlacement("origin")}
|
||||
/>
|
||||
<span>{t("import.placement.origin")}</span>
|
||||
</label>
|
||||
<label className="imp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="imp-place"
|
||||
checked={placement === "viewCenter"}
|
||||
onChange={() => setPlacement("viewCenter")}
|
||||
/>
|
||||
<span>{t("import.placement.viewCenter")}</span>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Mesh-Behandlung. */}
|
||||
{hasMeshes && (
|
||||
<section className="imp-section">
|
||||
@@ -389,19 +348,11 @@ export function buildDrawings(
|
||||
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
|
||||
const layerToCode = (layerName: string): string =>
|
||||
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
|
||||
const fromContours = contoursToDrawings(
|
||||
return contoursToDrawings(
|
||||
decision.parsed.contours,
|
||||
levelId,
|
||||
decision.categoryMode,
|
||||
activeCategoryCode,
|
||||
layerToCode,
|
||||
);
|
||||
const fromTexts = textsToDrawings(
|
||||
decision.parsed.texts ?? [],
|
||||
levelId,
|
||||
decision.categoryMode,
|
||||
activeCategoryCode,
|
||||
layerToCode,
|
||||
);
|
||||
return [...fromContours, ...fromTexts];
|
||||
}
|
||||
|
||||
@@ -1847,8 +1847,7 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
|
||||
scale: 1,
|
||||
angle: p.families[0]?.angle ?? 45,
|
||||
color: "#1a1a1a",
|
||||
// Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin").
|
||||
lineStyleId: "thin",
|
||||
lineStyleId: "hatch-line",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+210
-328
@@ -15,8 +15,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
import { RibbonTabs } from "./ribbon/RibbonBar";
|
||||
import type { RibbonTabId } from "./ribbon/ribbonItems";
|
||||
import {
|
||||
applyMark,
|
||||
applyPreset,
|
||||
@@ -86,17 +84,49 @@ const ZOOM_CLUSTER_WIDTH = 140;
|
||||
export interface TopBarProps {
|
||||
project: Project;
|
||||
|
||||
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
|
||||
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
|
||||
ribbonTab: RibbonTabId;
|
||||
onRibbonTab: (id: RibbonTabId) => void;
|
||||
// Ansicht.
|
||||
/** Ob der Ansichts-Umschalter sinnvoll ist (nur für Geschosse). */
|
||||
viewToggleEnabled: boolean;
|
||||
viewType: ViewType;
|
||||
onViewType: (v: ViewType) => void;
|
||||
|
||||
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
|
||||
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
|
||||
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
|
||||
// 3D-Ansicht: kanonischer Blickwinkel + Kamera-Sichtwinkel (FOV). Wirkt nur in
|
||||
// der Perspektive; ein Preset-Klick wechselt nötigenfalls auch dorthin.
|
||||
view3d: View3d;
|
||||
onView3d: (v: View3d) => void;
|
||||
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
|
||||
fov: number;
|
||||
onFov: (n: number) => void;
|
||||
|
||||
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
|
||||
// Zeichenwerkzeuge (Auswahl/Wand/Linie …), Wandtyp-Wahl und Fang-Menü leben
|
||||
// jetzt in der Werkzeug-Palette (ToolsPanel), nicht mehr in der Oberleiste.
|
||||
|
||||
// Detailgrad.
|
||||
detail: DetailLevel;
|
||||
onDetail: (d: DetailLevel) => void;
|
||||
/** Detailgrad wirkt nur, wenn ein Plan gezeichnet wird (sonst deaktiviert). */
|
||||
detailEnabled: boolean;
|
||||
|
||||
// Massstab + Zoom.
|
||||
/**
|
||||
* Gewählter (angewandter) Papier-Massstab-Nenner (1:N) — steuert das Dropdown
|
||||
* und „100 %". Setzt beim Wählen den Plan-Zoom auf exakt 1:N.
|
||||
*/
|
||||
scaleDenominator: number;
|
||||
/** Einen Papier-Massstab anwenden (Preset/frei): setzt N + Plan-Zoom. */
|
||||
onApplyScale: (n: number) => void;
|
||||
/**
|
||||
* LIVE Papier-Massstab-Nenner aus der aktuellen View-Transform (ändert sich
|
||||
* beim Wheel-Zoom) oder `null`, wenn keine Plan-Ansicht aktiv ist.
|
||||
*/
|
||||
liveScale: number | null;
|
||||
/** Live-Zoom in Prozent (aus der Plan-View-Transform). */
|
||||
zoomPercent: number;
|
||||
/** Zoom-Aktionen — nur wirksam, wenn eine Plan-Ansicht aktiv ist. */
|
||||
zoomEnabled: boolean;
|
||||
onFit: () => void;
|
||||
onFitSelection: () => void;
|
||||
onZoom100: () => void;
|
||||
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||
onExportPdf: () => void;
|
||||
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||
@@ -104,6 +134,21 @@ export interface TopBarProps {
|
||||
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
|
||||
onExportSchedule: () => void;
|
||||
|
||||
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
|
||||
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
|
||||
renderMode: RenderMode;
|
||||
onRenderMode: (m: RenderMode) => void;
|
||||
/** Render-Modus nur in der Perspektive sinnvoll (sonst deaktiviert). */
|
||||
renderModeEnabled: boolean;
|
||||
|
||||
// Farb-Modus des Grundrisses (Farbig ↔ Schwarz-Weiss). Nur im Grundriss.
|
||||
planColorMode: PlanColorMode;
|
||||
onPlanColorMode: (m: PlanColorMode) => void;
|
||||
|
||||
// Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen).
|
||||
/** Handler-Satz für die beiden Kombinations-Dropdowns. */
|
||||
combos: ComboMenuHandlers;
|
||||
|
||||
// Ressourcen-Fenster.
|
||||
resourcesOpen: boolean;
|
||||
onToggleResources: () => void;
|
||||
@@ -122,8 +167,14 @@ export interface TopBarProps {
|
||||
/** Projekt aus einer Datei laden. */
|
||||
onOpenProject: () => void;
|
||||
|
||||
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
|
||||
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
|
||||
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
|
||||
/**
|
||||
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
|
||||
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
|
||||
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
|
||||
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
|
||||
*/
|
||||
textTarget: TextTarget | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -501,7 +552,7 @@ const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
|
||||
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
|
||||
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
|
||||
*/
|
||||
export function TextGroup({
|
||||
function TextGroup({
|
||||
textTarget,
|
||||
}: {
|
||||
textTarget: TextTarget | null;
|
||||
@@ -865,145 +916,50 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
|
||||
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
|
||||
* Leiste ruhiger bleibt (GTK-Burger-Stil).
|
||||
*/
|
||||
/** Ein kleiner Icon-Knopf der Quick-Access-Leiste (OCS-Stil). */
|
||||
function QaButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
active,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-qa-btn${active ? " active" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<TbIcon name={icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kleiner Trenner in der Quick-Access-Leiste. */
|
||||
function QaSep() {
|
||||
return <span className="tb-qa-sep" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-Access-Leiste (OCS-Stil) — alle früheren Datei-Burger-Aktionen plus
|
||||
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
|
||||
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
|
||||
*/
|
||||
function QuickAccess({
|
||||
onOpenProject,
|
||||
onSaveProject,
|
||||
function AppMenu({
|
||||
onImport,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
onExportSchedule,
|
||||
onToggleResources,
|
||||
onSaveProject,
|
||||
onOpenProject,
|
||||
onOpenSettings,
|
||||
resourcesOpen,
|
||||
exportEnabled,
|
||||
}: {
|
||||
onOpenProject: () => void;
|
||||
onSaveProject: () => void;
|
||||
onImport: () => void;
|
||||
onExportPdf: () => void;
|
||||
onExportDxf: () => void;
|
||||
onExportSchedule: () => void;
|
||||
onToggleResources: () => void;
|
||||
onSaveProject: () => void;
|
||||
onOpenProject: () => void;
|
||||
onOpenSettings: () => void;
|
||||
resourcesOpen: boolean;
|
||||
exportEnabled: boolean;
|
||||
}) {
|
||||
const items: DropdownItem[] = [
|
||||
{ label: t("file.save"), onSelect: onSaveProject },
|
||||
{ label: t("file.open"), onSelect: onOpenProject },
|
||||
{ divider: true },
|
||||
{ label: t("file.import"), onSelect: onImport },
|
||||
{ divider: true },
|
||||
{ label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled },
|
||||
{ label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled },
|
||||
{ label: t("file.exportSchedule"), onSelect: onExportSchedule },
|
||||
{ divider: true },
|
||||
{ label: t("topbar.settings"), onSelect: onOpenSettings },
|
||||
];
|
||||
return (
|
||||
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
|
||||
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
|
||||
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
|
||||
<QaSep />
|
||||
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
|
||||
<QaButton
|
||||
icon="picture_as_pdf"
|
||||
label={t("file.exportPdf")}
|
||||
onClick={onExportPdf}
|
||||
disabled={!exportEnabled}
|
||||
/>
|
||||
<QaButton
|
||||
icon="output"
|
||||
label={t("file.exportDxf")}
|
||||
onClick={onExportDxf}
|
||||
disabled={!exportEnabled}
|
||||
/>
|
||||
<QaButton
|
||||
icon="table_view"
|
||||
label={t("file.exportSchedule")}
|
||||
onClick={onExportSchedule}
|
||||
/>
|
||||
<QaSep />
|
||||
<QaButton
|
||||
icon="widgets"
|
||||
label={t("topbar.resources")}
|
||||
onClick={onToggleResources}
|
||||
active={resourcesOpen}
|
||||
/>
|
||||
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
|
||||
</div>
|
||||
<Dropdown
|
||||
items={items}
|
||||
placeholder=""
|
||||
title={t("file.menu")}
|
||||
triggerClassName="tb-burger"
|
||||
triggerSuffix={<TbIcon name="menu" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props des „Ansichten"-Tabs — die Ansichts-/Zoom-/Darstellungs-Steuerung, die
|
||||
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
|
||||
*/
|
||||
export interface ViewRibbonTabProps {
|
||||
viewToggleEnabled: boolean;
|
||||
viewType: ViewType;
|
||||
onViewType: (v: ViewType) => void;
|
||||
view3d: View3d;
|
||||
onView3d: (v: View3d) => void;
|
||||
fov: number;
|
||||
onFov: (n: number) => void;
|
||||
detail: DetailLevel;
|
||||
onDetail: (d: DetailLevel) => void;
|
||||
detailEnabled: boolean;
|
||||
scaleDenominator: number;
|
||||
onApplyScale: (n: number) => void;
|
||||
liveScale: number | null;
|
||||
zoomPercent: number;
|
||||
zoomEnabled: boolean;
|
||||
onFit: () => void;
|
||||
onFitSelection: () => void;
|
||||
onZoom100: () => void;
|
||||
renderMode: RenderMode;
|
||||
onRenderMode: (m: RenderMode) => void;
|
||||
renderModeEnabled: boolean;
|
||||
planColorMode: PlanColorMode;
|
||||
onPlanColorMode: (m: PlanColorMode) => void;
|
||||
combos: ComboMenuHandlers;
|
||||
/** Text-/Font-Formatierung auf DERSELBEN Leiste (Raumstempel-Ziel; `null` =
|
||||
* nur Defaults). Liegt bewusst im „Ansichten"-Tab (Nutzer-Wunsch). */
|
||||
textTarget: TextTarget | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Ansichten"-Tab des Ribbons — trägt die aus der TopBar gemergten Mittelcluster:
|
||||
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
|
||||
* Massstab/Zoom-Cluster und Darstellungsart. EIN Ribbon für alles; Handler/Setter
|
||||
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar). Wird von
|
||||
* `RibbonBar` als Inhalt des „Ansichten"-Tabs gerendert.
|
||||
*/
|
||||
export function ViewRibbonTab({
|
||||
export function TopBar({
|
||||
project,
|
||||
viewToggleEnabled,
|
||||
viewType,
|
||||
onViewType,
|
||||
@@ -1022,14 +978,44 @@ export function ViewRibbonTab({
|
||||
onFit,
|
||||
onFitSelection,
|
||||
onZoom100,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
onExportSchedule,
|
||||
renderMode,
|
||||
onRenderMode,
|
||||
renderModeEnabled,
|
||||
planColorMode,
|
||||
onPlanColorMode,
|
||||
combos,
|
||||
resourcesOpen,
|
||||
onToggleResources,
|
||||
onOpenSettings,
|
||||
layoutMenu,
|
||||
onImport,
|
||||
onSaveProject,
|
||||
onOpenProject,
|
||||
textTarget,
|
||||
}: ViewRibbonTabProps) {
|
||||
}: TopBarProps) {
|
||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
||||
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onScroll = () => {
|
||||
el.classList.add("is-scrolling");
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
|
||||
const onScaleChange = (value: string) => {
|
||||
if (value === "__custom__") {
|
||||
@@ -1044,7 +1030,14 @@ export function ViewRibbonTab({
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n) && n > 0) onApplyScale(n);
|
||||
};
|
||||
|
||||
// Aktueller Nenner steht evtl. nicht in den Presets (frei gesetzt) → dann
|
||||
// einen Eintrag „1:N" voranstellen, damit das Select den Wert anzeigt.
|
||||
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
|
||||
|
||||
// Optionsliste des Massstab-Dropdowns: ggf. der frei gesetzte aktuelle Wert
|
||||
// (damit er sichtbar bleibt), dann die Presets, zuletzt „frei …" als
|
||||
// Sonder-Wert, der über onScaleChange den Prompt öffnet (Funktion unverändert).
|
||||
const scaleOptions: DropdownOption[] = [
|
||||
...(inPresets
|
||||
? []
|
||||
@@ -1054,8 +1047,41 @@ export function ViewRibbonTab({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="ribbon-views">
|
||||
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
|
||||
<header className="topbar" ref={headerRef}>
|
||||
{/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben,
|
||||
gestapelt wie im Rhino-Plugin (ToolbarApp.jsx: Wortmarke links,
|
||||
rechts daneben ein schmaler Icon-Stapel). */}
|
||||
<div className="tb-brandgroup">
|
||||
<div className="brand">
|
||||
<span className="brand-word">
|
||||
DOSSIER<span className="brand-dot">.</span>
|
||||
</span>
|
||||
<span className="tag">{t("brand.tag")}</span>
|
||||
</div>
|
||||
<div className="tb-brandicons">
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-brandicon${resourcesOpen ? " active" : ""}`}
|
||||
onClick={onToggleResources}
|
||||
aria-pressed={resourcesOpen}
|
||||
aria-label={t("topbar.resources")}
|
||||
title={`${t("topbar.resources")} — ${t("topbar.resources.hint")}`}
|
||||
>
|
||||
<TbIcon name="widgets" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Ansichten als EIN kompaktes Icon-Grid (Vectorworks/DOSSIER-Stil): der
|
||||
2D-Grundriss UND die 3D-Blicke sind eine durchgängige Icon-Serie —
|
||||
„Grundriss" schaltet auf den 2D-Plan, die übrigen auf die jeweilige
|
||||
3D-Ansicht (Perspektive wird dabei automatisch aktiviert). 4 oben /
|
||||
3 unten. Tooltip nennt jede Ansicht im Klartext. */}
|
||||
{/* Ansichts-Icons bleiben immer sichtbar; auf Nicht-Geschoss-Ebenen
|
||||
(Schnitt/Ansicht/2D-Zeichnung) sind sie deaktiviert, weil dort keine
|
||||
2D/3D-Umschaltung greift — die Serie verschwindet nicht mehr ganz. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
||||
<div className="view-grid view-grid-4">
|
||||
<button
|
||||
@@ -1113,7 +1139,10 @@ export function ViewRibbonTab({
|
||||
</div>
|
||||
<Sep />
|
||||
|
||||
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
|
||||
{/* Sichtbarkeits-Kombinationen (Vectorworks: Ebenen-/Zeichnungs-
|
||||
kombinationen). Sitzt direkt nach dem Ansichts-Umschalter, weil beides
|
||||
„Navigations-/Sicht"-Steuerung ist — ruhige Stelle vor dem Detailgrad.
|
||||
Nur die Dropdowns; eine grafische Überschreibung kommt später. */}
|
||||
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("combo.layers")}</span>
|
||||
@@ -1138,9 +1167,27 @@ export function ViewRibbonTab({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
|
||||
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
|
||||
Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live.
|
||||
Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab.
|
||||
„+ Text" ist raus — Text wird ein eigenständiges Zeichenwerkzeug
|
||||
(HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */}
|
||||
<TextGroup textTarget={textTarget} />
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Zeichenwerkzeuge liegen jetzt in der Werkzeug-Palette (ToolsPanel,
|
||||
dockbar) — nicht mehr in der Oberleiste (Vectorworks-Bild: Werkzeuge
|
||||
in der Palette). Auch Wandtyp-Wahl + Fang-Menü sind dort. */}
|
||||
|
||||
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole +
|
||||
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort
|
||||
deaktiviert mit Tooltip statt stiller No-Op. */}
|
||||
{/* Detailgrad — Massstab lebt nur noch EINMAL im Zoom-Cluster (die
|
||||
Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("topbar.detail")}</span>
|
||||
@@ -1161,9 +1208,14 @@ export function ViewRibbonTab({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Massstab/Zoom-Cluster (2×2). */}
|
||||
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
|
||||
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
|
||||
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. „am
|
||||
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
|
||||
Akzent hervor. */}
|
||||
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
|
||||
<div
|
||||
className={`tb-stat${
|
||||
@@ -1219,9 +1271,13 @@ export function ViewRibbonTab({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
|
||||
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
|
||||
Ansichtstyp abhängen: Grundriss → Farbig/Schwarz-Weiss (planColorMode);
|
||||
Perspektive → Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
|
||||
Linien-Modus (Display/Print) bleibt separat. */}
|
||||
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
|
||||
<label className="tb-field">
|
||||
<span className="tb-label">{t("display.style")}</span>
|
||||
@@ -1252,199 +1308,25 @@ export function ViewRibbonTab({
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<Sep />
|
||||
|
||||
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
|
||||
Ansichten zusammen) — Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
|
||||
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
|
||||
<TextGroup textTarget={textTarget} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Ribbon3dTabProps {
|
||||
viewToggleEnabled: boolean;
|
||||
viewType: ViewType;
|
||||
onViewType: (v: ViewType) => void;
|
||||
view3d: View3d;
|
||||
onView3d: (v: View3d) => void;
|
||||
fov: number;
|
||||
onFov: (n: number) => void;
|
||||
renderMode: RenderMode;
|
||||
onRenderMode: (m: RenderMode) => void;
|
||||
renderModeEnabled: boolean;
|
||||
}
|
||||
|
||||
/** 3D-Tab des Ribbons — Kamera-Presets + Darstellungsart. Kein Duplikat des
|
||||
* Ansichten-Tabs: hier nur 3D-relevante Steuerung (Blickwinkel, Rendermodus). */
|
||||
export function Ribbon3dTab({
|
||||
viewToggleEnabled,
|
||||
viewType,
|
||||
onViewType,
|
||||
view3d,
|
||||
onView3d,
|
||||
fov,
|
||||
onFov,
|
||||
renderMode,
|
||||
onRenderMode,
|
||||
renderModeEnabled,
|
||||
}: Ribbon3dTabProps) {
|
||||
return (
|
||||
<div className="ribbon-views">
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
||||
<div className="view-grid view-grid-4">
|
||||
<button
|
||||
className={`view-icon${viewToggleEnabled && viewType === "grundriss" ? " active" : ""}`}
|
||||
onClick={() => onViewType("grundriss")}
|
||||
disabled={!viewToggleEnabled}
|
||||
title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
|
||||
aria-label={t("topbar.grundriss")}
|
||||
>
|
||||
<ViewIcon name="grundriss" />
|
||||
</button>
|
||||
{(
|
||||
[
|
||||
["top", t("view3d.top")],
|
||||
["iso", t("view3d.iso")],
|
||||
["perspective", t("view3d.perspective")],
|
||||
["front", t("view3d.front")],
|
||||
["side", t("view3d.side")],
|
||||
] as [View3d, string][]
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onView3d(key);
|
||||
if (viewType !== "perspektive") onViewType("perspektive");
|
||||
}}
|
||||
disabled={!viewToggleEnabled}
|
||||
title={viewToggleEnabled ? `${label} — ${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
|
||||
aria-label={label}
|
||||
>
|
||||
<ViewIcon name={key} />
|
||||
</button>
|
||||
))}
|
||||
<CameraMenu
|
||||
fov={fov}
|
||||
onFov={onFov}
|
||||
disabled={!viewToggleEnabled || viewType !== "perspektive"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Sep />
|
||||
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
|
||||
<label className="tb-field">
|
||||
<span className="tb-label">{t("display.style")}</span>
|
||||
<Dropdown
|
||||
value={renderMode}
|
||||
disabled={!renderModeEnabled}
|
||||
onChange={(v) => onRenderMode(v as RenderMode)}
|
||||
title={t("display.style.hint")}
|
||||
options={[
|
||||
{ value: "shaded", label: t("display.style.shaded") },
|
||||
{ value: "white", label: t("display.style.white") },
|
||||
{ value: "textured", label: t("display.style.textured") },
|
||||
{ value: "wireframe", label: t("display.style.wireframe") },
|
||||
{ value: "hidden", label: t("display.style.hidden") },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
project,
|
||||
ribbonTab,
|
||||
onRibbonTab,
|
||||
zoomEnabled,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
onExportSchedule,
|
||||
resourcesOpen,
|
||||
onToggleResources,
|
||||
onOpenSettings,
|
||||
layoutMenu,
|
||||
onImport,
|
||||
onSaveProject,
|
||||
onOpenProject,
|
||||
}: TopBarProps) {
|
||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
||||
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onScroll = () => {
|
||||
el.classList.add("is-scrolling");
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Unter Tauri (native macOS-Ampeln oben links via titleBarStyle "Overlay")
|
||||
// braucht die Zeile links Platz, damit `dossier.` nicht darunter liegt.
|
||||
const tauriNativeTitlebar =
|
||||
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
|
||||
return (
|
||||
// `data-tauri-drag-region`: die Titelleiste ist selbst der Fenster-Ziehbereich
|
||||
// (macOS titleBarStyle "Overlay"). Kind-Buttons/Dropdowns bleiben klickbar;
|
||||
// nur die leeren Zonen ziehen das Fenster. Unter Tauri bekommt die Zeile links
|
||||
// einen Inset, damit `dossier.` nicht unter den nativen macOS-Ampeln liegt.
|
||||
<header
|
||||
className={`topbar${tauriNativeTitlebar ? " topbar-native-inset" : ""}`}
|
||||
ref={headerRef}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{/* Fensterknöpfe NUR unter Electron (randloses frame:false-Fenster) — unter
|
||||
Tauri liefern die NATIVEN macOS-Ampeln Schliessen/Minimieren/Maximieren,
|
||||
daher rendert WindowControls dort null. */}
|
||||
<WindowControls />
|
||||
|
||||
{/* Kleine Wortmarke „dossier" (OCS-Stil, klein/kleingeschrieben) + Quick-
|
||||
Access-Leiste: alle Datei-/Export-Aktionen + Ressourcen/Einstellungen
|
||||
als kleine Icons direkt in der Zeile (ersetzt das Burger-Menü). */}
|
||||
<div className="tb-brandgroup">
|
||||
<span className="brand-word">
|
||||
dossier<span className="brand-dot">.</span>
|
||||
</span>
|
||||
</div>
|
||||
<QuickAccess
|
||||
onOpenProject={onOpenProject}
|
||||
onSaveProject={onSaveProject}
|
||||
onImport={onImport}
|
||||
onExportPdf={onExportPdf}
|
||||
onExportDxf={onExportDxf}
|
||||
onExportSchedule={onExportSchedule}
|
||||
onToggleResources={onToggleResources}
|
||||
onOpenSettings={onOpenSettings}
|
||||
resourcesOpen={resourcesOpen}
|
||||
exportEnabled={zoomEnabled}
|
||||
/>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) — sitzen in dieser Zeile,
|
||||
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI
|
||||
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
|
||||
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
|
||||
|
||||
{/* Rechte Gruppe: Layout-Menü · Projektname. Die Fensterknöpfe sitzen jetzt
|
||||
ganz links (macOS-Titelleisten-Konvention); Datei-/Export-Aktionen in
|
||||
der Quick-Access-Leiste (kein Burger). */}
|
||||
{/* Rechte Gruppe: Layout-Menü · Projektname · Datei-Burger (ganz rechts,
|
||||
GTK-Stil) — Ressourcen sitzt jetzt bei der Marke (s. o.). */}
|
||||
<div className="topbar-right">
|
||||
{layoutMenu}
|
||||
<span className="project-name">{project.name}</span>
|
||||
<AppMenu
|
||||
onImport={onImport}
|
||||
onExportPdf={onExportPdf}
|
||||
onExportDxf={onExportDxf}
|
||||
onExportSchedule={onExportSchedule}
|
||||
onSaveProject={onSaveProject}
|
||||
onOpenProject={onOpenProject}
|
||||
onOpenSettings={onOpenSettings}
|
||||
exportEnabled={zoomEnabled}
|
||||
/>
|
||||
<WindowControls />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
+22
-23
@@ -1,11 +1,11 @@
|
||||
// Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose
|
||||
// ELECTRON-Fenster (frame:false, s. scripts/electron-main.cjs) — dort gibt es
|
||||
// ohne native Titelleiste sonst keine Fenstersteuerung.
|
||||
// Electron-Fenster (frame:false, s. scripts/electron-main.cjs) — ohne native
|
||||
// Titelleiste gibt es sonst keine Möglichkeit, das Fenster zu steuern.
|
||||
//
|
||||
// Unter TAURI werden die NATIVEN macOS-Ampeln verwendet (tauri.conf.json:
|
||||
// titleBarStyle "Overlay" + hiddenTitle, native Dekoration mit gerundeten Ecken),
|
||||
// daher rendert diese Komponente dort NICHT (kein `window.dossierWindow`) — sonst
|
||||
// gäbe es doppelte Knöpfe. Im Browser-Dev-Modus ebenfalls unsichtbar (`null`).
|
||||
// Rendert NUR, wenn `window.dossierWindow` existiert (vom Preload-Skript
|
||||
// injiziert) — also nur unter Electron. In Tauri (native Fensterdekoration)
|
||||
// und im Browser-Dev-Modus (?vite) bleibt die Komponente unsichtbar (`null`),
|
||||
// dort steuert das Betriebssystem bzw. der Browser das Fenster.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -43,25 +43,8 @@ export function WindowControls() {
|
||||
|
||||
if (!api) return null;
|
||||
|
||||
// Reihenfolge (macOS-Titelleiste links): Schliessen · Minimieren · Maximieren.
|
||||
return (
|
||||
<div className="win-controls" role="group" aria-label="Fenster">
|
||||
<button
|
||||
type="button"
|
||||
className="win-btn win-btn-close"
|
||||
onClick={() => api.close()}
|
||||
title="Schliessen"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1 L9 9 M9 1 L1 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="win-btn"
|
||||
@@ -103,6 +86,22 @@ export function WindowControls() {
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="win-btn win-btn-close"
|
||||
onClick={() => api.close()}
|
||||
title="Schliessen"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1 L9 9 M9 1 L1 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// Kleine Inline-SVG-Glyphen für Befehls-Ribbon-Elemente (Ändern-Gruppe u. a.),
|
||||
// die keine ToolId haben und daher nicht über `ToolIcon` abgedeckt sind. Gleicher
|
||||
// Stil wie `ToolIcon` (~16×16, stroke=currentColor, fill=none), damit das Icon
|
||||
// die Textfarbe der Zeile übernimmt. Unbekannte Namen → kein Icon (null).
|
||||
|
||||
const common = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.4,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
|
||||
export function CommandIcon({ name }: { name: string }) {
|
||||
switch (name) {
|
||||
case "move":
|
||||
// Vier-Wege-Verschiebepfeil.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<line x1="8" y1="2.5" x2="8" y2="13.5" />
|
||||
<line x1="2.5" y1="8" x2="13.5" y2="8" />
|
||||
<polyline points="6,4.5 8,2.5 10,4.5" />
|
||||
<polyline points="6,11.5 8,13.5 10,11.5" />
|
||||
<polyline points="4.5,6 2.5,8 4.5,10" />
|
||||
<polyline points="11.5,6 13.5,8 11.5,10" />
|
||||
</svg>
|
||||
);
|
||||
case "copy":
|
||||
// Zwei überlappende Rechtecke (Duplikat).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2.5" y="2.5" width="8" height="8" />
|
||||
<rect x="5.5" y="5.5" width="8" height="8" />
|
||||
</svg>
|
||||
);
|
||||
case "mirror":
|
||||
// Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<line x1="8" y1="2" x2="8" y2="14" strokeDasharray="1.6 1.6" />
|
||||
<path d="M6.5 4 L2.5 8 L6.5 12 Z" />
|
||||
<path d="M9.5 4 L13.5 8 L9.5 12 Z" />
|
||||
</svg>
|
||||
);
|
||||
case "offset":
|
||||
// Zwei parallele Winkel (Ursprung + Versatz).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<polyline points="3,12 3,4 11,4" />
|
||||
<polyline points="6,12 6,7 14,7" />
|
||||
</svg>
|
||||
);
|
||||
case "trim":
|
||||
// Schere (Stutzen).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<line x1="13.5" y1="3" x2="6.5" y2="9" />
|
||||
<circle cx="4" cy="11" r="2" />
|
||||
<line x1="13.5" y1="13" x2="6.5" y2="7" />
|
||||
<circle cx="4" cy="5" r="2" />
|
||||
</svg>
|
||||
);
|
||||
case "join":
|
||||
// Zwei Segmente, die sich an einer Ecke treffen (Verbinden).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<polyline points="2.5,13 8,13 8,3" />
|
||||
<circle cx="8" cy="13" r="1.4" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des
|
||||
// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine
|
||||
// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile
|
||||
// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop
|
||||
// hereingereicht (State liegt in App, geteilt mit der TopBar).
|
||||
//
|
||||
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
|
||||
// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der
|
||||
// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar
|
||||
// gemergt) statt datengetriebener Item-Gruppen.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { ToolIcon } from "../../panels/ToolsPanel";
|
||||
import { getTool } from "../../tools/tools";
|
||||
import type { ToolId } from "../../tools/types";
|
||||
import { getCommand } from "../../commands/registry";
|
||||
import { Dropdown } from "../Dropdown";
|
||||
import type { DropdownItem } from "../Dropdown";
|
||||
import { RIBBON_TABS, ALL_RIBBON_ITEMS, itemKey, itemFromKey } from "./ribbonItems";
|
||||
import type { RibbonItem, RibbonTabId } from "./ribbonItems";
|
||||
import { CommandIcon } from "./CommandIcon";
|
||||
|
||||
/** Aufgelöste Beschriftung eines Items (Werkzeug bzw. Befehl). */
|
||||
function itemLabel(item: RibbonItem): string {
|
||||
if (item.kind === "tool") return t(getTool(item.id).labelKey);
|
||||
const cmd = getCommand(item.name);
|
||||
return cmd ? t(cmd.labelKey) : item.name;
|
||||
}
|
||||
|
||||
export interface RibbonBarProps {
|
||||
/** Aktiver Tab (State liegt in App, geteilt mit den Reitern in der TopBar). */
|
||||
tab: RibbonTabId;
|
||||
/** Aktives Werkzeug (Highlight der `tool`-Elemente). */
|
||||
activeTool: ToolId;
|
||||
/** Name des laufenden Engine-Befehls (Highlight der `command`-Elemente). */
|
||||
activeCommand: string | null;
|
||||
/** Ob Boden-gebundene (`floorOnly`) Werkzeuge nutzbar sind (aktives Geschoss). */
|
||||
toolsEnabled: boolean;
|
||||
/** Werkzeug aktivieren (gleicher Pfad wie die Sidebar). */
|
||||
onSelectTool: (id: ToolId) => void;
|
||||
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
|
||||
onRunCommand: (name: string) => void;
|
||||
/**
|
||||
* Reicher Custom-Inhalt je Tab (statt datengetriebener Item-Gruppen) — z. B.
|
||||
* „Ansichten" (`ViewRibbonTab`) und „Text" (`TextGroup`). Von App geliefert,
|
||||
* damit deren Setter/State dort bleiben. Ein Tab ohne Custom-Inhalt und ohne
|
||||
* Gruppen zeigt den Leer-Hinweis.
|
||||
*/
|
||||
tabContent?: Partial<Record<RibbonTabId, ReactNode>>;
|
||||
/** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */
|
||||
customItems: string[];
|
||||
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
|
||||
onToggleCustomItem: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Aktivierungs-/Highlight-Kontext, den jeder Button-Typ braucht. */
|
||||
interface ButtonCtx {
|
||||
activeTool: ToolId;
|
||||
activeCommand: string | null;
|
||||
toolsEnabled: boolean;
|
||||
onSelectTool: (id: ToolId) => void;
|
||||
onRunCommand: (name: string) => void;
|
||||
}
|
||||
|
||||
/** Ein einzelner Ribbon-Button (Werkzeug ODER Befehl). */
|
||||
function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
|
||||
if (item.kind === "tool") {
|
||||
const tool = getTool(item.id);
|
||||
// Nur Boden-gebundene Werkzeuge sind ohne aktives Geschoss gesperrt; freie
|
||||
// 2D-Werkzeuge (Linie/Kreis/…) bleiben auch auf Zeichnungsebenen nutzbar.
|
||||
const disabled = !!tool.floorOnly && !ctx.toolsEnabled;
|
||||
const active = ctx.activeTool === item.id;
|
||||
const label = t(tool.labelKey);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ribbon-btn${active ? " active" : ""}`}
|
||||
disabled={disabled}
|
||||
title={disabled ? t("tool.floorOnlyDisabled") : label}
|
||||
onClick={() => ctx.onSelectTool(item.id)}
|
||||
>
|
||||
<ToolIcon id={item.id} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// Befehls-Element: Label aus der Befehls-Registry ableiten.
|
||||
const cmd = getCommand(item.name);
|
||||
const label = cmd ? t(cmd.labelKey) : item.name;
|
||||
const active = ctx.activeCommand === item.name;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ribbon-btn${active ? " active" : ""}`}
|
||||
title={label}
|
||||
onClick={() => ctx.onRunCommand(item.name)}
|
||||
>
|
||||
<CommandIcon name={item.name} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Tab-REITER — als eigene Komponente exportiert, weil sie in der TopBar-Zeile
|
||||
* gerendert werden (nicht hier). Rein gesteuert über `tab`/`onTab`.
|
||||
*/
|
||||
export function RibbonTabs({
|
||||
tab,
|
||||
onTab,
|
||||
}: {
|
||||
tab: RibbonTabId;
|
||||
onTab: (id: RibbonTabId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ribbon-tabs" role="tablist">
|
||||
{RIBBON_TABS.map((tt) => (
|
||||
<button
|
||||
key={tt.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tt.id === tab}
|
||||
className={`ribbon-tab${tt.id === tab ? " active" : ""}`}
|
||||
onClick={() => onTab(tt.id)}
|
||||
>
|
||||
{t(tt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulare Custom-Bar (Tab „Eigene") — rendert die vom Nutzer gewählten Items
|
||||
* plus einen „+"-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für
|
||||
* die bereits enthaltenen listet (Klick = an-/abwählen). Gleiche Item-Typen +
|
||||
* gleiche Aktivierung wie die normalen Tabs — kein Sonderweg.
|
||||
*/
|
||||
function CustomBar({
|
||||
customItems,
|
||||
onToggle,
|
||||
ctx,
|
||||
}: {
|
||||
customItems: string[];
|
||||
onToggle: (key: string) => void;
|
||||
ctx: ButtonCtx;
|
||||
}) {
|
||||
const pickerItems: DropdownItem[] = ALL_RIBBON_ITEMS.map((it) => {
|
||||
const key = itemKey(it);
|
||||
return {
|
||||
label: itemLabel(it),
|
||||
active: customItems.includes(key),
|
||||
onSelect: () => onToggle(key),
|
||||
};
|
||||
});
|
||||
const items = customItems
|
||||
.map(itemFromKey)
|
||||
.filter((it): it is RibbonItem => it !== null);
|
||||
return (
|
||||
<div className="ribbon-group">
|
||||
<div className="ribbon-items">
|
||||
{items.length === 0 && (
|
||||
<div className="ribbon-empty">{t("ribbon.custom.empty")}</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<RibbonButton key={`${itemKey(item)}-${i}`} item={item} ctx={ctx} />
|
||||
))}
|
||||
<Dropdown
|
||||
items={pickerItems}
|
||||
placeholder={t("ribbon.custom.add")}
|
||||
title={t("ribbon.custom.add")}
|
||||
triggerClassName="ribbon-add"
|
||||
/>
|
||||
</div>
|
||||
<div className="ribbon-group-title">{t("ribbon.group.custom")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RibbonBar(props: RibbonBarProps) {
|
||||
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
|
||||
const custom = props.tabContent?.[current.id];
|
||||
const ctx: ButtonCtx = {
|
||||
activeTool: props.activeTool,
|
||||
activeCommand: props.activeCommand,
|
||||
toolsEnabled: props.toolsEnabled,
|
||||
onSelectTool: props.onSelectTool,
|
||||
onRunCommand: props.onRunCommand,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ribbon">
|
||||
{/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt („Ansichten",
|
||||
„Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst
|
||||
die datengetriebenen Item-Gruppen. */}
|
||||
<div className="ribbon-body">
|
||||
{current.id === "custom" ? (
|
||||
<CustomBar
|
||||
customItems={props.customItems}
|
||||
onToggle={props.onToggleCustomItem}
|
||||
ctx={ctx}
|
||||
/>
|
||||
) : custom ? (
|
||||
custom
|
||||
) : current.groups.length === 0 ? (
|
||||
<div className="ribbon-empty">{t("ribbon.empty")}</div>
|
||||
) : (
|
||||
current.groups.map((g) => (
|
||||
<div key={g.titleKey} className="ribbon-group">
|
||||
<div className="ribbon-items">
|
||||
{g.items.map((item) => (
|
||||
<RibbonButton
|
||||
key={item.kind === "tool" ? `t:${item.id}` : `c:${item.name}`}
|
||||
item={item}
|
||||
ctx={ctx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="ribbon-group-title">{t(g.titleKey)}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// Ribbon-Registry (docs/design/ribbon-ui-plan.md §Architektur) — EINE
|
||||
// datengetriebene Beschreibung aller Ribbon-Elemente. Die Ribbon-Tabs UND eine
|
||||
// spätere modulare Custom-Bar (Phase 4) sind bloß zwei Ansichten über dieselbe
|
||||
// Registry, ohne Sonderweg pro Element.
|
||||
//
|
||||
// Ein `RibbonItem` ist entweder ein Werkzeug (`tool` → onSelectTool, wie die
|
||||
// Werkzeug-Sidebar) oder ein Engine-Befehl (`command` → engine.start). Beide
|
||||
// münden in exakt DENSELBEN Pfad wie Klick in der alten Sidebar bzw. das Tippen
|
||||
// des Befehlsnamens — keine Duplikate.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Sichtbare
|
||||
// Texte kommen als i18n-Schlüssel und werden erst in der Komponente über t(...)
|
||||
// aufgelöst.
|
||||
|
||||
import type { ToolId } from "../../tools/types";
|
||||
|
||||
/** Ein aktivierbares Ribbon-Element. Bei `command` wird die Beschriftung aus der
|
||||
* Befehls-Registry (`getCommand(name).labelKey`) abgeleitet — kein doppeltes
|
||||
* Pflegen von Labels. */
|
||||
export type RibbonItem =
|
||||
| { kind: "tool"; id: ToolId }
|
||||
| { kind: "command"; name: string };
|
||||
|
||||
/** Eine benannte Gruppe von Elementen innerhalb eines Tabs. */
|
||||
export interface RibbonGroup {
|
||||
titleKey: string;
|
||||
items: RibbonItem[];
|
||||
}
|
||||
|
||||
/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
|
||||
export type RibbonTabId = "views" | "2d" | "3d" | "bim" | "custom";
|
||||
|
||||
/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
|
||||
export interface RibbonTab {
|
||||
id: RibbonTabId;
|
||||
labelKey: string;
|
||||
groups: RibbonGroup[];
|
||||
}
|
||||
|
||||
const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
|
||||
const c = (name: string): RibbonItem => ({ kind: "command", name });
|
||||
|
||||
/**
|
||||
* Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). „Ansichten" ist der
|
||||
* Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus
|
||||
* der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font-
|
||||
* Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` — `groups` bleibt leer). 2D
|
||||
* (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/
|
||||
* Befehlen; 3D ist vorerst leer (spätere Phase).
|
||||
*/
|
||||
export const RIBBON_TABS: RibbonTab[] = [
|
||||
{
|
||||
// Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text.
|
||||
id: "views",
|
||||
labelKey: "ribbon.tab.views",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "2d",
|
||||
labelKey: "ribbon.tab.2d",
|
||||
groups: [
|
||||
{
|
||||
titleKey: "ribbon.group.draw",
|
||||
items: [
|
||||
t("select"),
|
||||
t("line"),
|
||||
t("polyline"),
|
||||
t("rect"),
|
||||
t("circle"),
|
||||
t("arc"),
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "ribbon.group.modify",
|
||||
items: [
|
||||
c("move"),
|
||||
c("copy"),
|
||||
c("mirror"),
|
||||
c("offset"),
|
||||
c("trim"),
|
||||
c("join"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3d",
|
||||
labelKey: "ribbon.tab.3d",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "bim",
|
||||
labelKey: "ribbon.tab.bim",
|
||||
groups: [
|
||||
{
|
||||
titleKey: "ribbon.group.components",
|
||||
items: [
|
||||
t("wall"),
|
||||
t("window"),
|
||||
t("door"),
|
||||
t("stair"),
|
||||
t("ceiling"),
|
||||
t("room"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Modulare Custom-Bar (Phase 4): der Nutzer stellt sich die Elemente selbst
|
||||
// zusammen (persistiert). Gerendert aus `customItems`, nicht aus `groups`.
|
||||
id: "custom",
|
||||
labelKey: "ribbon.tab.custom",
|
||||
groups: [],
|
||||
},
|
||||
];
|
||||
|
||||
/** Stabiler Schlüssel eines Items für die Persistenz der Custom-Bar. */
|
||||
export function itemKey(item: RibbonItem): string {
|
||||
return item.kind === "tool" ? `tool:${item.id}` : `command:${item.name}`;
|
||||
}
|
||||
|
||||
/** Parst einen Item-Schlüssel zurück (oder null bei unbekanntem Präfix). */
|
||||
export function itemFromKey(key: string): RibbonItem | null {
|
||||
const i = key.indexOf(":");
|
||||
if (i < 0) return null;
|
||||
const kind = key.slice(0, i);
|
||||
const rest = key.slice(i + 1);
|
||||
if (kind === "tool") return { kind: "tool", id: rest as ToolId };
|
||||
if (kind === "command") return { kind: "command", name: rest };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle datengetriebenen Items (aus den Werkzeug-/Befehls-Tabs) — die wählbare
|
||||
* Menge für den Custom-Bar-Picker. „views"/„custom" tragen keine `groups`.
|
||||
*/
|
||||
export const ALL_RIBBON_ITEMS: RibbonItem[] = RIBBON_TABS.flatMap((tab) =>
|
||||
tab.groups.flatMap((g) => g.items),
|
||||
);
|
||||
@@ -75,16 +75,6 @@ interface OrbitState {
|
||||
dist: number;
|
||||
/** Blickziel (world). */
|
||||
target: [number, number, number];
|
||||
/** Soll die freie Kamera orthografisch bleiben? (true fuer alle Presets ausser
|
||||
* `perspective`) — sonst kippt die erste Kamerabewegung fälschlich in
|
||||
* Perspektive („locked iso"). */
|
||||
ortho: boolean;
|
||||
/** Orthografische Bild-Halbhoehe (Meter), UNABHAENGIG von `dist` gefuehrt: eine
|
||||
* echte Ortho-Kamera skaliert ihr Bild ueber diese Groesse, nicht ueber den
|
||||
* Augabstand. Wird beim Preset aus `dist*tan(FOV_Y/2)` geseedet (perspektiv-
|
||||
* äquivalente Bildhoehe an der eingepassten Distanz → kein Sprung beim ersten
|
||||
* Move) und beim Zoom in Ortho-Modus multiplikativ skaliert. */
|
||||
orthoHalfHeight: number;
|
||||
}
|
||||
|
||||
/** Fertige (freie, perspektivische) Kamera aus dem Orbit-Zustand (Konvention
|
||||
@@ -101,12 +91,9 @@ function orbitCamera(o: OrbitState): Camera3d {
|
||||
],
|
||||
target: [o.target[0], o.target[1], o.target[2]],
|
||||
up: [0, 1, 0],
|
||||
// Projektion aus dem Orbit-Zustand (war hartkodiert `true`/`dist*0.5` — genau
|
||||
// DAS war der Locked-Iso-Bug: die erste Bewegung nach einem Ortho-Preset kippte
|
||||
// sofort in Perspektive). Ortho behaelt seine eigene, dist-unabhaengige Halbhoehe.
|
||||
perspective: !o.ortho,
|
||||
perspective: true,
|
||||
fovY: FOV_Y,
|
||||
orthoHalfHeight: o.orthoHalfHeight,
|
||||
orthoHalfHeight: o.dist * 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,20 +260,7 @@ export function Wasm3DViewport({
|
||||
applyViewPreset(view3d);
|
||||
const { yaw, pitch } = VIEW3D_ORBIT[view3d];
|
||||
const { target, dist } = fitTargetDist(projectRef.current);
|
||||
// Alle Presets ausser `perspective` sind orthografisch (front/top/side/iso, wie
|
||||
// die Rust-Seite `preset_camera`) → die freie Kamera bleibt danach ebenfalls
|
||||
// ortho („locked iso"). orthoHalfHeight auf die perspektiv-aequivalente Bildhoehe
|
||||
// an der eingepassten Distanz seeden, damit der erste manuelle Move/Zoom groessen-
|
||||
// gleich an das von Rust gezeichnete Preset-Bild anschliesst (kein Sprung).
|
||||
const ortho = view3d !== "perspective";
|
||||
orbitRef.current = {
|
||||
yaw,
|
||||
pitch,
|
||||
dist,
|
||||
target,
|
||||
ortho,
|
||||
orthoHalfHeight: dist * Math.tan(FOV_Y / 2),
|
||||
};
|
||||
orbitRef.current = { yaw, pitch, dist, target };
|
||||
redrawRef.current = () => render(null);
|
||||
redrawRef.current();
|
||||
}, [ready, view3d, applyViewPreset, render]);
|
||||
@@ -431,13 +405,9 @@ export function Wasm3DViewport({
|
||||
Math.min(PITCH_LIMIT, o.pitch + (2 * Math.PI * dy) / h),
|
||||
);
|
||||
} else {
|
||||
// Pan in der Bildebene: Welt-Meter je Pixel. In Ortho aus der (dist-
|
||||
// unabhaengigen) Bild-Halbhoehe, sonst aus Distanz + Sichtwinkel — sonst
|
||||
// driftet die Pan-Geschwindigkeit gegen einen Ortho-Zoom, der `dist` gar
|
||||
// nicht mehr anfasst. Ziel entgegen der Mausrichtung (Szene folgt Cursor).
|
||||
const worldPerPx = o.ortho
|
||||
? (2 * o.orthoHalfHeight) / h
|
||||
: (2 * o.dist * Math.tan(FOV_Y / 2)) / h;
|
||||
// Pan in der Bildebene: Welt-Meter je Pixel aus Distanz + Sichtwinkel;
|
||||
// Ziel entgegen der Mausrichtung verschieben (Szene folgt dem Cursor).
|
||||
const worldPerPx = (2 * o.dist * Math.tan(FOV_Y / 2)) / h;
|
||||
const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch));
|
||||
// Kamera-Basis aus dem Orbit: f = Blickrichtung, s = rechts, u = Kamera-oben.
|
||||
const f = [
|
||||
@@ -487,19 +457,6 @@ export function Wasm3DViewport({
|
||||
if (!o) return;
|
||||
e.preventDefault();
|
||||
useOrbitRedraw();
|
||||
// Ortho-Modus („locked iso"): eine echte Ortho-Kamera skaliert ihr Bild ueber
|
||||
// die Halbhoehe, NICHT ueber den Augabstand — `dist` zu dollyen haette hier
|
||||
// keinen sichtbaren Effekt. Also orthoHalfHeight multiplikativ skalieren und
|
||||
// frueh raus (Eye/dist unangetastet). Zoom-to-Cursor bleibt in Ortho zentrisch
|
||||
// — die cursor-versetzte Dolly-Logik unten ist perspektiv-only.
|
||||
if (o.ortho) {
|
||||
o.orthoHalfHeight = Math.max(
|
||||
0.05,
|
||||
Math.min(500, o.orthoHalfHeight * Math.exp(e.deltaY * 0.0013)),
|
||||
);
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const prevDist = o.dist;
|
||||
// Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt).
|
||||
o.dist = Math.max(0.2, Math.min(2000, o.dist * Math.exp(e.deltaY * 0.0013)));
|
||||
|
||||
Reference in New Issue
Block a user