Compare commits

...

5 Commits

Author SHA1 Message Date
karim db6f3ae0a0 Doku: Session-4-Recovery-Stand (alle drei 3b-Slices gelandet)
PENDENZEN: Oeffnungen als Boolean-Loecher (1407c68) nach Erledigt. HANDOVER:
Slice 3 committet, Environment-Behebung + Push-Blocker dokumentiert.
2026-07-04 22:19:19 +02:00
karim 1407c68232 Nordstern-3D: Fenster/Tueren als echte Boolean-Loecher in EINEM Wandkoerper
Bisher zerlegte emitWall eine Wand um jede Oeffnung in Pfeiler/Bruestung/Sturz-
Teilquader -> sichtbare Segment-Naehte im 3D, Oeffnung war semantisch kein Loch,
und versetzt uebereinanderliegende Fenster liessen sich gar nicht abbilden. Neu:
im 3D-Pfad (layered=true) EIN RWall pro Schicht-Band ueber die volle Achse mit
allen Oeffnungen als rechteckige holes; render3d stanzt sie per achsparalleler
Rechteck-Gitter-Zerlegung der Langseiten aus und setzt 4 Laibungsquads je Loch.
holes und openings schliessen sich im Emitter gegenseitig aus. Tuer = Loch bis
zum Wand-zBottom. Schnitt-Pfad (layered=false) segmentiert unveraendert weiter
(wallSegmentOwners bleibt synchron, toSection.ts unangetastet).

Zusatz: trimWallTopForCeilings (Deckentrim, e4b8df6) gilt jetzt NUR im 3D-Pfad
(layered=true, Z-Fighting-Vermeidung) und wird im Schnitt-Pfad uebersprungen -
der Schnitt braucht die volle Wandhoehe fuer seine schichtweise Prioritaets-
Subtraktion (Kontrolle von Schichteinzug/Bodenaufbau am Wand-Decken-Anschluss).

RWall.holes / WallInput.holes additiv (#[serde(default)]). Pflicht-Testfaelle:
zwei ueberlappende, hoehenversetzte Fenster in einem Koerper; Tuer-Loch bis Boden
ohne untere Laibung. cargo test 56 gruen, vitest 230 (+3), build:engine3d + tsc
sauber.
2026-07-04 22:17:21 +02:00
karim dd9984d3c4 Pendenzen: zentrale Aufgaben-Queue (PENDENZEN.md), HANDOVER auf Kontext geschlankt
PENDENZEN.md als Single Source of Truth fuer Aufgaben eingefuehrt
(priorisierte Checkliste + Arbeitsprotokoll: Worker liest zuerst,
arbeitet top-down, Rueckfrage-Recht). Backlog/Rueckfragen/IN-FLIGHT
vollstaendig aus HANDOVER migriert; HANDOVER enthaelt nur noch
Kontext/Konventionen/Environment/Zielmodelle. Stand-Korrektur:
Locked-Iso (cb8fae5) und Joins Phase 1c (c5a344d) sind gelandet,
nur Oeffnungen-als-Boolean-Loecher noch offen.
2026-07-04 20:00:48 +02:00
karim c5a344d3f2 Joins Phase 1c: Durchgangswand am T-Stoss echt aufbrechen (spanCutouts)
Am materialbewussten T-Stoss wurde die Durchgangswand bisher nur uebermalt
(Zeichenreihenfolge), nicht geometrisch ausgeschnitten: ihr Nah-Putz-Band, die
Schichtfugen- und die Nahflaechenlinie liefen weiter ueber die Durchstoss-Breite
des Abzweig-Backsteins. Neu liefert computeJoins erstmals auch Cuts fuer die
DURCHGANGSWAND: WallCuts.spanCutouts (Achsen-Intervall = Projektion der Abzweig-
Kernbreite, Quer-Offsetzone = Nahflaeche bis Rueckgrat-Nahflaeche = Nah-Putz-
Tiefe aus Phase 1b). addWallPoche splittet betroffene Schicht-Baender entlang der
Achse in Teil-Baender vor/nach dem Intervall, unterbricht die Schichtfugen im
Merge-Bereich und macht die Nahflaechen-Umrisskante ueber dem Durchstoss
strokelos (separate Segmente davor/danach) — keine Trennlinie an der
Verschmelzungsflaeche, der T-Stoss liest als EIN Join. Rust-Paritaet additiv
(span_cutouts), Aggregat-startCut/endCut unveraendert -> parity gruen.

vitest 227 (+5), cargo 8 (+1), tsc sauber.
2026-07-04 19:22:32 +02:00
karim cb8fae5cb4 Nordstern-3D: Locked-Iso — freie Kamera bleibt nach Ortho-Preset orthografisch
Iso/Front/Top/Side kippten bei der ersten Kamerabewegung sofort in Perspektive,
weil orbitCamera() Projektion und Ortho-Halbhoehe hartkodiert hatte
(perspective:true / dist*0.5). OrbitState fuehrt jetzt ortho:boolean +
orthoHalfHeight:number: der Preset-Effekt setzt ortho = view3d!=='perspective'
und seedet orthoHalfHeight auf dist*tan(FOV_Y/2) (perspektiv-aequivalente
Bildhoehe an der eingepassten Distanz -> kein Sprung beim ersten Move). Pan
rechnet worldPerPx in Ortho aus der Halbhoehe statt aus dist; Zoom skaliert im
Ortho-Modus orthoHalfHeight multiplikativ (0.05-500) und laesst dist/eye
unangetastet. Perspektive-Preset unveraendert.
2026-07-04 18:51:35 +02:00
16 changed files with 1369 additions and 109 deletions
+31 -28
View File
@@ -2,6 +2,35 @@
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen. Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
> **➡️ Aufgaben-Queue steht in `PENDENZEN.md`** (Single Source of Truth, priorisierte Checkliste + Arbeitsprotokoll). HANDOVER = nur noch Kontext/Konventionen/Environment/Zielmodelle, KEIN Backlog mehr. Neue Pendenzen/Rückfragen dort eintragen, nicht hier.
## ⚠️ NACHTRAG Session 3b (2026-07-04 abends) — Session hart abgebrochen (Spend-Limit), 3 Slices mitten in der Arbeit VERLOREN
> **STAND-UPDATE (Session 4, neues Gerät):** **Environment behoben** — Toolchain vorhanden (`node`/`npm`/`cargo` unter `/usr/bin`, `wasm-pack` via `npx` 0.15.0; es war die Flatpak-Sandbox); fehlendes Rust-Target per `sudo dnf install rust-std-static-wasm32-unknown-unknown` (+ lld) nachinstalliert; beide WASM-Engines gebaut → App läuft. **Slice 1 (Locked-Iso `cb8fae5`) + Slice 2 (Joins Phase 1c `c5a344d`) committet.** **Slice 3 (Öffnungen als Boolean-Löcher + Deckentrim-nur-3D) ✅ committet `1407c68`:** Fenster/Türen = 1 Wandkörper pro Schicht-Band mit rechteckigen `holes` (render3d stanzt sie per achsparalleler Rechteck-Gitter-Zerlegung + 4 Laibungsquads/Loch), Segment-Boxen weg; `holes`/`openings` schliessen sich im Emitter aus; Schnitt-Pfad (layered=false) segmentiert unverändert weiter. Deckentrim `trimWallTopForCeilings` gilt nur noch im 3D-Pfad, Schnitt-Pfad bekommt volle Wandhöhe. Der zuständige Agent stallte kurz vor Schluss (Watchdog), aber alle Datei-Edits waren gelandet — Hauptinstanz hat unabhängig verifiziert: cargo test 56 (inkl. beider Pflicht-Tests versetzt-überlappende Fenster + Tür-Loch-bis-Boden), vitest 230, build:engine3d + tsc sauber, Trace clean. **Damit sind alle drei verlorenen 3b-Slices rekonstruiert.** **Memory-Direktiven `priority-based-joins.md` + `openings-as-booleans.md` wiederhergestellt.**
>
> **⚠️ PUSH BLOCKIERT:** keine Git-Credentials auf diesem Gerät (kein Helper, keine `~/.git-credentials`, kein SSH-Key; Remote HTTPS `git.openbureau.ch/karim/DOSSIER-STANDALONE`). `cb8fae5` + `c5a344d` (+ folgender Öffnungen-Commit) sind **lokal committet, NICHT gepusht** (`ahead of origin/master`). Sobald Gitea-PAT (repo-write) oder SSH-Key da ist: `git push origin master`. Bis dahin holt das andere Gerät die Arbeit nicht.
>
> Der folgende Block bleibt als historischer Kontext/Vorlage für den offenen Slice 3 stehen.
Die letzte Session endete nicht durch normales Handover, sondern weil das monatliche Spend-Limit des KI-Anbieters **mitten in laufender Arbeit** griff (mehrere Agenten liefen parallel, „arbeite die nächsten 2h selbstständig"-Direktive des Nutzers). Diese Übergabe wurde nachträglich aus dem rohen Chat-Transkript (`Edit MEMORY md Added 1.txt`, lag im Repo-Root, siehe Aufräum-Hinweis unten) rekonstruiert. **Der tatsächliche HEAD-Commit ist `03f0c40`** (Nordstern-3D Live-Schnittebene) — alles danach existierte nur im Arbeitsbaum der alten Maschine und ist auf **diesem** Checkout (neues Gerät, `git status` sauber bis auf `package-lock.json`) **nicht vorhanden**. Diese drei Slices waren in echter Arbeit (nicht nur geplant) und gelten als verloren — neu beauftragen, nicht nur „prüfen ob gelandet":
1. **Locked-Iso-Fix** (`src/viewport/Wasm3DViewport.tsx`, `OrbitState` um `ortho: boolean` + `orthoHalfHeight: number` erweitern, `orbitCamera()`/Preset-Effect/Pan/Zoom entsprechend anpassen) — Bug: Iso-Ansicht kippt bei der ersten Kamerabewegung sofort in Perspektive statt orthografisch (parallel) zu bleiben. War von einem Agenten (kleineres Modell) fertig gebaut UND bereits quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit per Screenshot-Analyse, 225/225 Tests grün), aber **nicht mehr committet**, bevor die Session starb — der komplette Fix-Ansatz inkl. Diff-Beschreibung steht im Transkript ab „Bash Verify locked-iso file isolated". Kleinster, unabhängigster der drei Fixes — zuerst neu machen.
2. **Joins Phase 1c — Durchgangswand echt aufbrechen** (`spanCutouts` in `src/model/joins.ts` + `src/plan/generatePlan.ts` + Rust-Parität `src-tauri/geometry/src/lib.rs`): Nutzer-Befund nach Phase 1b war, dass der Innenputz der DURCHGANGSWAND am T-Stoss nur überdeckt wird (Zeichenreihenfolge), nicht geometrisch ausgeschnitten — Fugen-/Nahflächenlinien laufen noch durch den Backstein-Durchstoss, es „liest" noch nicht wie ein einziger Join. Das war die **explizite Priorität des Nutzers** für diese Session. Der Agent (grosses Modell) war weit in der Umsetzung (schrieb bereits in `joins.ts`), letzte sichtbare Nachricht vor dem Abbruch: „The join looks correct — W9's brick runs through and W1's near plaster is notched" (Agent wollte gerade einen finalen Bestätigungs-Screenshot holen) — dann `failed` durch API-Fehler (Spend-Limit). **Nicht committet, nicht im Baum.** Vollständige Aufgabenbeschreibung mit allen Datei:Zeile-Ankern steht im Transkript bei „Agent:Phase 1c: Durchgangswand aufbrechen" — 1:1 als Vorlage für den Neu-Auftrag wiederverwenden.
3. **Öffnungen als echte Boolean-Löcher** (`RWall.holes`, `src/plan/toWalls3d.ts`, `src-tauri/render3d/src/mesh.rs`, additiv `types.rs`): EIN Wandkörper mit rechteckigen Löchern statt der heutigen Pfeiler/Brüstung/Sturz-Ersatzkörper (sichtbare Segment-Nähte, keine echten Löcher). Pflicht-Testfall (Nutzer-Direktive, Kernmotivation des Slices): zwei Fenster in derselben Wand mit überlappendem Achsen-Intervall, aber unterschiedlicher Höhe (z. B. u=[1.0,2.0]/z=[0.9,2.1] und u=[1.5,2.5]/z=[0.3,0.7]) — die alte Segment-Zerlegung kann das nicht abbilden, echte Löcher schon. Zusatz-Auftrag im selben Slice: `trimWallTopForCeilings` (Phase-3-Deckentrim, Commit `e4b8df6`) darf **nur** im 3D-Viewer-Pfad (`layeredWalls:true`) gelten, **nicht** im Schnitt-Pfad (`layeredWalls:false`) — der Schnitt braucht die volle, ungetrimmte Wandhöhe für seine eigene schichtweise Prioritäts-Subtraktion (`subtractDominantBands`), sonst zeigt der Schnitt die Wände fälschlich abgeschnitten. Fortschritt bei Abbruch **unklar** (letztes Status-Update deutete auf „noch in der Kartierungsphase" hin) — vermutlich am wenigsten weit von den dreien; komplett neu beauftragen. Vollständiger Auftragstext im Transkript bei „Agent:Oeffnungen als Boolean-Loecher" + die zwei Zusatz-Direktiven (Decken-Trim-Fix, Pflicht-Testfall versetzte Fenster) kurz danach.
**Zwei Memory-Direktiven aus der alten Session sind ebenfalls weg** (`priority-based-joins.md`, `openings-as-booleans.md` — auf der alten Maschine im Instanz-Memory-Verzeichnis abgelegt, existieren auf diesem Gerät nicht): die in Punkt 2/3 beschriebenen Zielverhalten waren dort als dauerhafte Engine-Direktiven gesichert (u. a. „Trennlinien an Merge-Kontakten verschwinden immer", „Öffnungen dürfen nie wieder zu Segmenten vereinfacht werden"). Beim Neu-Beauftragen diese Direktiven wieder explizit ins Memory dieser Instanz schreiben, nicht nur einmalig in den Auftragstext — sonst geht dieselbe Information beim nächsten Abbruch wieder verloren.
**Environment-Lücke auf diesem Gerät:** `node`/`npm`/`cargo`/`wasm-pack` waren zum Zeitpunkt dieser Übergabe **nicht auf dem PATH** (geprüft: kein Treffer, auch nicht unter der VSCodium-Flatpak-Datenlage). **Vermutliche Ursache: VSCodium lief als Flatpak** — Flatpak-Sandboxing blendet system-installierte Toolchains (Node/Rust unter `/usr/...` oder `~/.cargo`) typischerweise aus, auch wenn sie auf dem Host tatsächlich installiert sind. Der Nutzer wechselt gerade auf **natives VSCodium** (kein Flatpak mehr) — das könnte die Lücke von selbst beheben, MUSS aber nach dem Wechsel neu geprüft werden (`which node npm cargo wasm-pack`), bevor man von "Toolchain fehlt" auf "Toolchain neu installieren" schließt. Erst wenn nach dem Wechsel auf natives VSCodium immer noch nichts gefunden wird, wirklich neu installieren (Node LTS + Rust via rustup + `wasm-pack`, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml` durchlaufen lassen). Ohne Toolchain ist die in `CONVENTIONS.md` vorgeschriebene Verifikation (`tsc`/`vitest`/`cargo test`/`wasm-pack`) nicht möglich — das zuerst klären, bevor einer der drei Slices neu beauftragt wird, sonst baut ein Agent blind ohne Verifikation.
**Aufräum-Hinweis:** `Edit MEMORY md Added 1.txt` (roher Chat-Export dieser Session, lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung (vermutlich von einem `npm install` auf diesem Gerät) waren dirty/untracked. Die txt-Datei gehört NICHT ins Repo (Konvention: keine KI-Spuren) — außerhalb des Repos verschieben oder löschen, sobald nichts mehr daraus gebraucht wird. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
### 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 ## 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: **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:
@@ -56,34 +85,8 @@ WASM-Workflow: `src/engine/pkg3d/` ist gitignore't → nach Rust-Änderung `npm
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`). 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`). 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`).
## IN FLIGHT bei Session-Ende (PRÜFEN!) ## Backlog & Rückfragen → `PENDENZEN.md`
- **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). Der frühere „Offener Backlog"/„Offene Rückfragen"/„IN FLIGHT"-Abschnitt ist vollständig nach `PENDENZEN.md` migriert (Single Source of Truth). Dort steht auch das Arbeitsprotokoll (Worker liest zuerst PENDENZEN, arbeitet top-down, hakt ab, Rückfrage-Recht via ❓).
## 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 ## Verifikation
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit. `npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
+78
View File
@@ -0,0 +1,78 @@
# 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)
**Worker-Instanz** (führt aus):
1. **Zuerst diese Datei lesen**, dann `CONVENTIONS.md` + verlinkte Detailquellen des Items.
2. Oberstes nicht-blockiertes Item aus **🔧 In Arbeit** bzw. sonst **⏭️ Als Nächstes** nehmen, **fertig** machen, verifizieren (`npx tsc --noEmit` + `npx vitest run` + trace-scan), abhaken, eine Ergebniszeile nach **✅ Erledigt** schreiben (mit Commit-Hash, sobald committet).
3. **Rückfrage-Recht:** Bei Unklarheit/Design-Entscheid NICHT raten — Item mit `❓` markieren, kurze Frage darunter notieren, nächstes freies Item nehmen. Fragen sammeln sich unter **❓ Offene Rückfragen**.
4. **Kein Arbeitsschritt endet ohne aktualisierte Liste.** Status hier ist immer aktuell, sonst driftet es wieder.
5. Worker **committet nicht selbst** und delegiert nicht weiter (Agent/Task verboten) — das macht die Hauptinstanz. (Konvention HANDOVER.)
**Planungs-Instanz** (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.
---
## ⛔ Blocker (zuerst klären)
- [ ] **Toolchain prüfen:** `which node npm cargo wasm-pack`. Auf dem alten Gerät fehlten sie (vermutlich Flatpak-Sandbox von VSCodium). Nutzer wechselt auf natives VSCodium → evtl. behoben. Nur falls immer noch nichts: Node LTS + Rust (rustup) + `wasm-pack` installieren, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml`. **Ohne Toolchain keine Verifikation → keinen Engine-Slice blind bauen.** | Detail: HANDOVER §Nachtrag-3b, Environment-Lücke
## 🔧 In Arbeit
_(leer — Worker trägt hier ein, was er gerade anpackt)_
## ⚠️ Zu prüfen (evtl. schon erledigt / Status unklar)
- [ ] **Snap an Wand-Schichttrennlinien** (war Agent-Slice, Session 3): `git status`/`git log` prüfen — sollte `src/tools/snapping.ts` (+ ggf. `src/model/geometry.ts` + Test) geändert haben. Falls nicht gelandet: neu beauftragen (Schicht-Offsetlinien aus `addWallPoche`-Logik in generatePlan spiegeln [nur LESEN], als Snap-Targets einspeisen). | Detail: HANDOVER §IN FLIGHT Session-Ende
## ⏭️ Als Nächstes
_(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
## 📋 Backlog (Priorität grob absteigend)
- [ ] **Ebene-Schraffur editierbar:** „Nach Ebene"-Schraffur ist im Resolve fertig, aber `LayerCategory.hatch` im Kategorie-Dialog (`App.tsx` ~3350, editiert nur name/color/lw) noch nicht editierbar. Kleine Folge-Arbeit.
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
- **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen.
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
- [ ] **ResourceManager Bauteile-Tab** auf Master-Detail (wie Wandstile) — BEVOR Bauteil-Logik angefasst wird.
- [ ] **Einstellungs-Fenster:** Auswahlrahmen Default **Yuyake #CEB188**; Snap/Endpunkt Default = ❓„aki" (s.u.); freier Picker + Accents-Presets; verdrahten in PlanView (Selektionsrahmen + Snap-Marker). Dazu Projekt-MüM-Feld.
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
- [ ] **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
- [ ] 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.
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern).
- [ ] **Wand-Schicht-Bänder in 3D** Option B (jede Materiallage als eigener extrudierter Teilquader statt repräsentativer Farbe — pro-Schicht-Farben in `RWall.layers` liegen vor).
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
## ❓ Offene Rückfragen (an den Nutzer)
- [ ] **„aki"** = welcher Accent für Snap/Endpunkt-Farbe? (nicht in Accents: Ajisai/Sakura/Suna/Ichigo/Yuyake/Sora/Kusa/Kori/Amagumo/Yuki; vorläufig Sora #5FA1C9)
- [ ] 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-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`
+115 -2
View File
@@ -67,6 +67,20 @@ pub struct LayerCuts {
pub end_side: Vec<Option<Line>>, 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. /// Schnittlinien einer Wand an ihren beiden Achsenden.
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct WallCuts { pub struct WallCuts {
@@ -82,6 +96,11 @@ pub struct WallCuts {
/// weiterhin `start_cut`/`end_cut` fuer alle Schichten. /// weiterhin `start_cut`/`end_cut` fuer alle Schichten.
#[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")] #[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")]
pub layer_cuts: Option<LayerCuts>, 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 /// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von
@@ -135,6 +154,13 @@ fn apply_layer_cuts(
through_dir: Vec2, through_dir: Vec2,
sign: f64, sign: f64,
off_t: 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() { if branch_layers.is_empty() || through_layers.is_empty() {
return; return;
@@ -199,7 +225,10 @@ fn apply_layer_cuts(
let merge_cut = Line { point: merge_point, dir: through_dir }; let merge_cut = Line { point: merge_point, dir: through_dir };
// Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser // Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). // 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.
{
let side_line = merge_cut; let side_line = merge_cut;
let (face_arr, side_arr) = match branch_end { let (face_arr, side_arr) = match branch_end {
WallEndKind::Start => (&mut lc.start, &mut lc.start_side), WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
@@ -211,6 +240,44 @@ fn apply_layer_cuts(
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1])); !merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
side_arr[i] = if adj_merged { Some(side_line) } else { None }; side_arr[i] = if adj_merged { 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,
});
}
}
} }
// --- Vektor-Helfer ----------------------------------------------------------- // --- Vektor-Helfer -----------------------------------------------------------
@@ -305,6 +372,7 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
start_cut: None, start_cut: None,
end_cut: None, end_cut: None,
layer_cuts: None, layer_cuts: None,
span_cutouts: Vec::new(),
}) })
.collect(); .collect();
@@ -485,6 +553,10 @@ fn apply_t_junction(
u_t, u_t,
sign, sign,
off_t, off_t,
through_wall_idx,
through_wall.start,
dir_of(branch_wall),
branch_wall.reference_offset,
); );
} }
@@ -575,6 +647,10 @@ fn apply_mid_span_tee(
u_w, u_w,
sign, sign,
off_w, off_w,
w_idx,
w.start,
dir_of(free_wall),
free_wall.reference_offset,
); );
} }
@@ -904,10 +980,47 @@ mod tests {
assert!(dist(s0, Vec2 { x: 0.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); assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
// Die kollineare Durchgangswand bleibt unangetastet (kein layerCuts). // Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
let ca = find(&out, "A"); let ca = find(&out, "A");
let cc = find(&out, "C"); let cc = find(&out, "C");
assert!(ca.layer_cuts.is_none()); assert!(ca.layer_cuts.is_none());
assert!(cc.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());
} }
} }
@@ -32,6 +32,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
height: 1.0, height: 1.0,
}], }],
layers: None, layers: None,
holes: vec![],
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -42,6 +43,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im // also groessere Tiefe von der Betrachter-Ebene aus), genau im
@@ -57,6 +59,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
]; ];
let slabs = vec![SlabInput { let slabs = vec![SlabInput {
+1
View File
@@ -38,6 +38,7 @@ fn demo_walls() -> Vec<WallInput> {
color: grey, color: grey,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
// Raum 6 x 4 m. // Raum 6 x 4 m.
vec![ vec![
+101 -1
View File
@@ -54,7 +54,7 @@ pub use types::{
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX}; use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
use super::types::{Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX}; use super::types::{Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX};
/// Bequemer Bau einer achsparallelen Wand entlang +X. /// Bequemer Bau einer achsparallelen Wand entlang +X.
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput { fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
@@ -67,6 +67,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
} }
} }
@@ -104,6 +105,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
]); ]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -182,6 +184,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w); extrude_wall(&mut mesh, &w);
@@ -203,6 +206,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let mesh = build_walls_mesh(&[w]); let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0); assert_eq!(mesh.vertex_count(), 0);
@@ -306,6 +310,97 @@ mod tests {
assert_eq!(mesh.indices.len(), INDICES_PER_BOX); 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) ----------------------------------------- // --- Ecken-Verschneidung (Gehrung) -----------------------------------------
#[test] #[test]
@@ -325,6 +420,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let b = WallInput { let b = WallInput {
start: [0.0, 0.0], start: [0.0, 0.0],
@@ -335,6 +431,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let mesh = build_walls_mesh(&[a, b]); let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader"); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
@@ -394,6 +491,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let walls = vec![ let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]), mk([0.0, 0.0], [3.0, 0.0]),
@@ -422,6 +520,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let walls = vec![ let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0), mk([0.0, 0.0], [3.0, 0.0], 0.0),
@@ -510,6 +609,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: layers(), layers: layers(),
holes: vec![],
}; };
let mut b = a.clone(); let mut b = a.clone();
b.end = [0.0, 3.0]; b.end = [0.0, 3.0];
+344 -2
View File
@@ -59,7 +59,8 @@ use std::collections::HashMap;
use crate::openings::{split_range_by_voids, MIN_SPAN}; use crate::openings::{split_range_by_voids, MIN_SPAN};
use crate::types::{ use crate::types::{
Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, FLOATS_PER_VERTEX, Hole, 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 /// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
@@ -109,7 +110,16 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
let z0 = wall.base_elevation; let z0 = wall.base_elevation;
let z1 = wall.base_elevation + wall.height; let z1 = wall.base_elevation + wall.height;
let segments = wall_solid_segments(length, &wall.openings, z0, z1); // 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)
};
// Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus // Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus
// der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten. // der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten.
@@ -139,11 +149,21 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
// Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig). // Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig).
let start_cut = if a0 <= 1e-6 { miters.start } else { None }; let start_cut = if a0 <= 1e-6 { miters.start } else { None };
let end_cut = if a1 >= length - 1e-6 { miters.end } 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( extrude_layer_segment(
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut, mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
layer.color, layer.color,
); );
} }
}
off_a = off_b; off_a = off_b;
} }
} }
@@ -284,6 +304,328 @@ fn extrude_layer_segment(
push_quad(mesh, b3, b0, t0, t3, start_normal, color); 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)` /// 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` /// (nur die drei ersten Ecken werden gebraucht: `d` ist die vierte, ueber `a`
/// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe /// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe
+4
View File
@@ -1016,6 +1016,7 @@ mod tests {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -1026,6 +1027,7 @@ mod tests {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
]; ];
let slabs = vec![SlabInput { let slabs = vec![SlabInput {
@@ -1282,6 +1284,7 @@ mod tests {
height: 1.0, height: 1.0,
}], }],
layers: None, layers: None,
holes: vec![],
} }
} }
@@ -1367,6 +1370,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter. // Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0); let plane = SectionPlane::looking_plus_y(-2.0);
+40
View File
@@ -66,6 +66,46 @@ pub struct WallInput {
/// Verhalten unveraendert). /// Verhalten unveraendert).
#[serde(default)] #[serde(default)]
pub layers: Option<Vec<WallLayer>>, 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`. /// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`.
+60 -2
View File
@@ -311,10 +311,68 @@ describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
expect(distToLine(lc.startSide[0]!, { x: 5, y: 0.06 })).toBeCloseTo(0, 6); 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); expect(distToLine(lc.startSide[0]!, { x: 0, y: 0.06 })).toBeCloseTo(0, 6);
// Die kollinearen Hälften der Durchgangswand bleiben unangetastet // Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
// (unverändertes Verhalten, keine layerCuts für sie). // unangetastet (keine Pro-Schicht-Cuts für sie).
expect(joins.get("WA1")!.layerCuts).toBeUndefined(); expect(joins.get("WA1")!.layerCuts).toBeUndefined();
expect(joins.get("WA2")!.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", () => { it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => {
+77
View File
@@ -35,6 +35,29 @@ export interface LayerCuts {
endSide: (Line | null)[]; 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. */ /** Schnittlinien einer Wand an ihren beiden Achsenden. */
export interface WallCuts { export interface WallCuts {
startCut: Line | null; startCut: Line | null;
@@ -46,6 +69,13 @@ export interface WallCuts {
* verschmilzt (siehe {@link resolveJoinPriority}). * verschmilzt (siehe {@link resolveJoinPriority}).
*/ */
layerCuts?: LayerCuts; 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[];
} }
/** /**
@@ -397,6 +427,53 @@ function applyLayerCuts(
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1])); !merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
sideArr[i] = adjMerged ? sideLine : null; sideArr[i] = adjMerged ? 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,
});
}
}
}
} }
/** /**
+126 -9
View File
@@ -114,17 +114,134 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => {
} }
}); });
it("lässt die kollineare Durchgangswand unverändert (keine L-Linien, normale Bänder)", () => { 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).
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel"); const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
const wa1Bands = plan.primitives.filter( // 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): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === "WA1" && p.stroke === "none", p.kind === "polygon" && p.wallId === id && p.stroke === "none",
); );
expect(wa1Bands.length).toBe(3); const wa1 = bandsOf("WA1");
// Kein Cut an ihrem gemeinsamen Knoten-Ende (endCut bleibt null, s. joins.ts) → const wa2 = bandsOf("WA2");
// alle drei Bänder enden exakt am Achsenpunkt (5,0), keine Gehrung nötig. // Beide Hälften haben weiterhin drei Bänder (der Cutout reicht bis ans
for (const b of wa1Bands) { // Wandende, splittet also nicht in zwei, sondern kürzt nur).
expect(b.pts[1].x).toBeCloseTo(5, 6); 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(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === "W" && 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);
}
}
// 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);
}); });
}); });
+125 -7
View File
@@ -1045,6 +1045,38 @@ export function wallGaps(
return gaps.sort((a, b) => a.from - b.from); 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;
}
function addWallPoche( function addWallPoche(
out: Primitive[], out: Primitive[],
project: Project, project: Project,
@@ -1095,6 +1127,13 @@ function addWallPoche(
// (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist. // (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist.
const simpleFill = backbonePocheFill(project, wt); 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). // 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) // 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. // ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche.
@@ -1137,6 +1176,15 @@ function addWallPoche(
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede // auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet. // innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
const bandJoinEdges = joinEdges(startCut, endCut); 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): // Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts):
// liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den // liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den
// einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe // einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe
@@ -1161,9 +1209,26 @@ function addWallPoche(
const layerSolid = layerHatch.pattern === "solid"; const layerSolid = layerHatch.pattern === "solid";
const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null; const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null;
const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : 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({ out.push({
kind: "polygon", kind: "polygon",
pts: clippedBand(p1, p2, off, off + layer.thickness, layerStartCut, layerEndCut), pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec),
fill: layerSolid ? HATCH_INK : HATCH_PAPER, fill: layerSolid ? HATCH_INK : HATCH_PAPER,
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht // Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten). // am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
@@ -1175,6 +1240,7 @@ function addWallPoche(
noStrokeEdges: bandJoinEdges, noStrokeEdges: bandJoinEdges,
wallId, wallId,
}); });
}
// L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine // L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine
// verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt // verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt
// zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie // zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie
@@ -1222,13 +1288,27 @@ function addWallPoche(
// Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB // Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB
// dieser Wand und bleibt daher an der bisherigen Nahfläche. // dieser Wand und bleibt daher an der bisherigen Nahfläche.
if (li < wt.layers.length - 1) { 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 const jls = layer.jointLineStyleId
? project.lineStyles.find((l) => l.id === layer.jointLineStyleId) ? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
: undefined; : 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({ out.push({
kind: "line", kind: "line",
a: jq[0], a: jq[0],
@@ -1245,20 +1325,58 @@ function addWallPoche(
}); });
} }
} }
}
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung), // 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- // damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
// Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe. // 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({ out.push({
kind: "polygon", kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut),
fill: "none", fill: "none",
stroke, stroke,
strokeWidthMm: outlineMm, strokeWidthMm: outlineMm,
hatch: NO_HATCH, hatch: NO_HATCH,
greyed, greyed,
noStrokeEdges: bandJoinEdges, noStrokeEdges: outlineNoStroke,
wallId, 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);
} }
} }
+90 -6
View File
@@ -63,12 +63,96 @@ describe("selectionHighlightLines", () => {
expect(two.length).toBe(one.length * 2); expect(two.length).toBe(one.length * 2);
}); });
it("eine Wand MIT Öffnungen -> mehrere Teilquader (mehr als 12 Kanten)", () => { it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => {
// W1 trägt Tür D1 + Fenster O1 -> Pfeiler/Brüstung/Sturz-Segmente, also // NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
// ein Vielfaches von 12 Kanten (mehr als ein einzelner Quader). // NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
const lines = selectionHighlightLines(sampleProject, ["W1"], [], RGB); // Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
expect(lines.length).toBeGreaterThan(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); // genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
expect(lines.length % (12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX)).toBe(0); // 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.
}); });
}); });
+83 -4
View File
@@ -59,6 +59,24 @@ export interface RLayer {
color: RRgb; 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 { export interface RWall {
start: RVec2; start: RVec2;
end: RVec2; end: RVec2;
@@ -79,6 +97,19 @@ export interface RWall {
* serde verwirft unbekannte Felder). * serde verwirft unbekannte Felder).
*/ */
wallId: string; 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. */ /** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
@@ -224,6 +255,13 @@ function resolveWallBands(project: Project, wall: Wall, layered = true): WallBan
* mit Schicht-`thickness`/`color`. Entartete Stücke (Länge 0 oder Höhe 0) * mit Schicht-`thickness`/`color`. Entartete Stücke (Länge 0 oder Höhe 0)
* werden übersprungen so überleben Öffnungen dicht an den Wandenden ohne * werden übersprungen so überleben Öffnungen dicht an den Wandenden ohne
* Nullquader. * 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( function pushSegment(
out: RWall[], out: RWall[],
@@ -236,6 +274,7 @@ function pushSegment(
normalOffset: number, normalOffset: number,
color: RRgb, color: RRgb,
wallId: string, wallId: string,
holes?: RHole[],
): void { ): void {
if (to - from <= EPS) return; if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return; if (zTop - zBottom <= EPS) return;
@@ -269,6 +308,7 @@ function pushSegment(
color, color,
layers: [], layers: [],
wallId, wallId,
...(holes && holes.length > 0 ? { holes } : {}),
}); });
} }
@@ -422,11 +462,31 @@ function trimWallTopForCeilings(
return Math.max(zBottom, top); return Math.max(zBottom, top);
} }
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */ /**
* 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.
*/
function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void { function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void {
const bands = resolveWallBands(project, wall, layered); const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall); const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall);
const zTop = trimWallTopForCeilings(project, wall, zBottom, rawZTop); // 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 axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
@@ -461,8 +521,27 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v
} }
cutouts.sort((a, b) => a.from - b.from); cutouts.sort((a, b) => a.from - b.from);
// Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber // ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
// in Lagen aufgeteilt). // 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.
if (cutouts.length === 0) { if (cutouts.length === 0) {
emitSeg(0, axisLen, zBottom, zTop); emitSeg(0, axisLen, zBottom, zTop);
return; return;
+49 -6
View File
@@ -75,6 +75,16 @@ interface OrbitState {
dist: number; dist: number;
/** Blickziel (world). */ /** Blickziel (world). */
target: [number, number, number]; 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 /** Fertige (freie, perspektivische) Kamera aus dem Orbit-Zustand (Konvention
@@ -91,9 +101,12 @@ function orbitCamera(o: OrbitState): Camera3d {
], ],
target: [o.target[0], o.target[1], o.target[2]], target: [o.target[0], o.target[1], o.target[2]],
up: [0, 1, 0], up: [0, 1, 0],
perspective: true, // 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,
fovY: FOV_Y, fovY: FOV_Y,
orthoHalfHeight: o.dist * 0.5, orthoHalfHeight: o.orthoHalfHeight,
}; };
} }
@@ -260,7 +273,20 @@ export function Wasm3DViewport({
applyViewPreset(view3d); applyViewPreset(view3d);
const { yaw, pitch } = VIEW3D_ORBIT[view3d]; const { yaw, pitch } = VIEW3D_ORBIT[view3d];
const { target, dist } = fitTargetDist(projectRef.current); const { target, dist } = fitTargetDist(projectRef.current);
orbitRef.current = { yaw, pitch, dist, target }; // 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),
};
redrawRef.current = () => render(null); redrawRef.current = () => render(null);
redrawRef.current(); redrawRef.current();
}, [ready, view3d, applyViewPreset, render]); }, [ready, view3d, applyViewPreset, render]);
@@ -405,9 +431,13 @@ export function Wasm3DViewport({
Math.min(PITCH_LIMIT, o.pitch + (2 * Math.PI * dy) / h), Math.min(PITCH_LIMIT, o.pitch + (2 * Math.PI * dy) / h),
); );
} else { } else {
// Pan in der Bildebene: Welt-Meter je Pixel aus Distanz + Sichtwinkel; // Pan in der Bildebene: Welt-Meter je Pixel. In Ortho aus der (dist-
// Ziel entgegen der Mausrichtung verschieben (Szene folgt dem Cursor). // unabhaengigen) Bild-Halbhoehe, sonst aus Distanz + Sichtwinkel — sonst
const worldPerPx = (2 * o.dist * Math.tan(FOV_Y / 2)) / h; // 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;
const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch)); const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch));
// Kamera-Basis aus dem Orbit: f = Blickrichtung, s = rechts, u = Kamera-oben. // Kamera-Basis aus dem Orbit: f = Blickrichtung, s = rechts, u = Kamera-oben.
const f = [ const f = [
@@ -457,6 +487,19 @@ export function Wasm3DViewport({
if (!o) return; if (!o) return;
e.preventDefault(); e.preventDefault();
useOrbitRedraw(); 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; const prevDist = o.dist;
// Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt). // 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))); o.dist = Math.max(0.2, Math.min(2000, o.dist * Math.exp(e.deltaY * 0.0013)));