135 Commits

Author SHA1 Message Date
karim 9e73c684c3 App.tsx: Hell/Dunkel-State nach theme/useThemeMode.ts
Reiner Umzug von useState + Setter-Wrapper (persistiert in localStorage via
themeMode.ts) in einen eigenen Hook. Verhaltensgleich.
2026-07-21 16:24:33 +02:00
karim 6637533162 App.tsx: Dialog-Sichtbarkeits-State nach state/useDialogState.ts
Reiner Umzug von neun useState-Deklarationen (Ressourcen-Fenster, Layout-
Ansicht, Import-/Export-/Einstellungs-Dialoge, Drop-Feedback) in einen
eigenen Hook — bewusst KEIN Store-Slice (dieser State ist laut den
Original-Kommentaren explizit lokal/transient, keine Undo/Redo- oder
Cross-Component-Relevanz). Verhaltensgleich.
2026-07-21 16:15:59 +02:00
karim 3b79e49cc6 App.tsx entschlackt: Kontextmenüs nach src/menus/, Views nach src/views/
Reiner Umzug (verhaltensgleich, byte-identische Funktionskörper): Kontextmenü-
Builder (buildMenuItems + layer/level/plan/viewport-Menüs) nach
src/menus/contextMenu.tsx; InlineEditor sowie Content/LevelPlanView/
SectionPlanView (der View-Router der Hauptansicht) nach src/views/. App.tsx
7130 → 5917 Zeilen. Setzt die in CONVENTIONS.md/state-architecture.md
vorgesehene, bisher nie umgesetzte Struktur endlich um (STATUS.md §4.3).

Dazu ein unabhängiger CSS-Fix: .plan-selected (Auswahl-Hervorhebung im 2D-Plan)
fehlte die fill-opacity, die zwei Regeln weiter unten bei .plan-marquee/
.tool-preview-fill schon gesetzt ist. Im Dunkel-Theme ist --accent-dim
transparent (zufällig unauffällig), im neuen Standard-Hell-Theme aber fast
deckend weiss — dadurch verdeckte die Auswahl die Schraffur der gewählten
Wand/Decke/des Raums vollständig.
2026-07-21 14:47:40 +02:00
karim a6c2c04736 Doku: STATUS.md (Codebase-Analyse) + Kern-Docs an den Ist-Zustand angeglichen
Vollständige Bestandsaufnahme der Codebasis als neue STATUS.md (Kennzahlen,
Feature-Inventar, Mist-Liste: toter Code, verwaiste WASM-Crates,
Doku-Widersprüche). ARCHITECTURE.md/README.md/CONVENTIONS.md waren noch auf
dem Tag-1-Planungsstand (Electron/Three.js/OpenCascade/Zustand/HLR-Worker) und
beschrieben nicht mehr, was tatsächlich gebaut wurde (eigene Rust/WASM-Engines,
eigener Store, analytische Rust-Schnitt-Pipeline, Tauri auf macOS + Electron
auf Linux). ROADMAP.md und HANDOVER.md als historisch markiert (Hinweis-Box),
Inhalt unverändert.
2026-07-21 13:35:58 +02:00
karim 956a85d93f Materialbibliothek: statisches Broad-Set entfernt, Live-ambientCG bleibt
Der aus der Desktop-Session übernommene statische 88er-Katalog listete 75
Materialien ohne mitgelieferte Texturen (kaputte Thumbnails in der
Schnellauswahl). Bibliothek auf die 13 tatsächlich gebündelten Starter
gekürzt; Fetch-Script und der zugehörige .gitignore-Block entfernt. Alles
darüber hinaus läuft über die bereits vorhandene Live-Suche (1K/2K/4K).
2026-07-21 13:35:44 +02:00
karim f95674aa09 Fix: doppelten RoofingTiles013A-Eintrag nach Rebase entfernt 2026-07-20 11:05:08 +02:00
karim 2ac5c27fa7 Materials-Feature: Bibliothek + Fetch-Script + Manifest (aus Desktop-WIP übernommen) 2026-07-20 11:03:30 +02:00
karim 68a0459d0e Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie
  (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im
  Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht,
  unsichtbare Ebenen blenden ihre Führungslinie aus
- Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/
  Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste
  (AppMenuBar) neben der Wortmarke
- Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert,
  flackerfrei vor erstem Render gesetzt
- SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene
- UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access-
  Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/
  Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt
- Neues Dachziegel-Material (RoofingTiles013A)
2026-07-20 10:51:37 +02:00
karim 0ab0361e3d Pendenzen: swissBUILDINGS3D-Zuschnitt-Fix nachtragen 2026-07-12 21:35:53 +02:00
karim 31b76a2f02 swissBUILDINGS3D-Import: auf den gesuchten Radius zuschneiden statt die ganze STAC-Kachel zu liefern
Jede STAC-Kachel (DXF) liefert ALLE Gebäude der Kachel als EIN gemeinsames
Mesh (parseDxf sammelt alle 3DFACE/Polyface-Entities eines Files in denselben
Puffer) — ohne Zuschnitt landete bei einem Import IMMER die komplette Kachel
im Modell, teils mehrere Kilometer über den gesuchten Suchradius hinaus.
Live gegen echte STAC-Daten verifiziert: zwei 1.4 km auseinanderliegende
Adressen (gleiche Kachel) lieferten vorher IDENTISCHE, unclipped Geometrie
(62532 Dreiecke); danach jeweils nur die ~400 Dreiecke im eigenen Suchradius.

clipMeshToBbox schneidet + kompaktiert das Mesh (nicht referenzierte Ecken
entfernt, Indizes neu gemappt) — sonst sähe jede Verbraucher-Logik, die roh
über `positions` statt `indices` iteriert (Bounding-Box, Kamera-Fit), weiter
die volle ungeschnittene Ausdehnung.
2026-07-12 21:35:31 +02:00
karim 7d368786ba Pendenzen: aussen/innen-Fix nachtragen 2026-07-12 21:25:11 +02:00
karim a631ddaace Fenster/Tür: 2D und 3D setzen "aussen" jetzt auf dieselbe Wandfläche
Nutzer-Report: "im 2D innen bündig und im 3D aussen bündig". Root Cause: das
2D-Vorzeichen für insetFace:"aussen" (windowSymbol) war gegenüber der echten
Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in
addWallPoche/pushSegment) invertiert — "aussen" landete am inneren
Wandputz statt am äusseren. Dieselbe Vorzeichenverwechslung steckte auch in
addOpeningFrameBand (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten.
Die 3D-Seite (openingAxisBox/resolveFrameNormalRange) war bereits korrekt
(zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und
bleibt unverändert.

+1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte
Aussenschicht der Wand prüft.
2026-07-12 21:24:48 +02:00
karim cc74396854 Fenster: Rahmen-Kontur in Aufsicht separat von den geschnittenen Laibungsblöcken stylebar
Bisher teilten sich die geschnittenen Laibungs-/Stulp-Blöcke (O-Blöcke,
Schnittfläche des Rahmenmaterials) und die durchlaufende Rahmen-Kontur
zwischen ihnen (Aufsicht, nicht geschnitten) dieselbe frameLine-Farbe. Neues
Opening.frameViewLine übersteuert nur noch die Kontur; ohne Angabe fällt sie
weiterhin auf frameLine zurück (bisheriges Verhalten unverändert). frameLine
im UI zu "Rahmen (Schnitt)" umbenannt, um die beiden Kategorien zu unterscheiden.
2026-07-12 20:19:20 +02:00
karim d4575df29f Pendenzen: Georeferenzierung (Neuer Bezugspunkt) als erledigt nachtragen 2026-07-12 20:13:48 +02:00
karim e61634c3b9 Neuer Bezugspunkt: Projekt nach Kataster-/3D-Import auf praktischen Ursprung verschieben
Nach einem Standort-Import landet die Kontext-Geometrie dort, wo der gesuchte
Ort war — das kann weit vom bisherigen Modell-Ursprung liegen. Neuer Befehl
"georef" (Button in SitePanel) verschiebt das GESAMTE Projekt per Klick so,
dass der gewählte Punkt zum neuen Ursprung wird; geoAnchor wandert automatisch
mit, der reale LV95-Bezug bleibt exakt erhalten (rekonstruierbar, auch beim
späteren Export). Bewusst nur Translation, keine Rotation — mehrere Felder
(Roof.ridgeAxis, Column.rotation, Text-/Bild-Rotation) sind achsen-/winkel-
gebunden und bräuchten bei einer Drehung eigene Sorgfalt.
2026-07-12 20:13:22 +02:00
karim dc241ba63e Pendenzen: Georeferenzierung + anwählbare Kontext-Meshes als erledigt nachtragen
Beide GEO-BLOCK-Design-Punkte (persistenter Standort-Anker, Kontext-Meshes im
3D anwählbar) sind umgesetzt; offene Teilaspekte (frei wählbarer Referenzpunkt,
Ebenen-Zuordnung) bleiben dokumentiert.
2026-07-12 19:55:14 +02:00
karim e6a7738170 Kontext-Objekte im 3D-Viewport anwählbar (Klick, Bounding-Box-Highlight, Löschen)
Importierte Gebäude/Terrain-Meshes waren bisher nur ein Eintrag im Geo-Panel,
nicht direkt im 3D anwählbar. Raycast/Pick-Geometrie um Kontext-Meshes
erweitert, Auswahl-Kanal (selectedContextObjectIds) ergänzt, Highlight als
Bounding-Box-Umriss (volles Dreiecks-Wireframe wäre bei grossen Importen zu
dicht), Entf-Taste löscht die Auswahl, SitePanel hebt sie in der Liste hervor.
2026-07-12 19:54:00 +02:00
karim ae47b4f024 Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import
Neues Project.geoAnchor: verbindet EINEN Modell-Punkt mit seiner realen
LV95-Koordinate. Bisher berechnete jeder Standort-Import (Gebäude/Terrain/
OSM) unabhängig einen neuen Bezug aus der jeweils gesuchten Adresse — bei
mehreren Importen mit leicht unterschiedlichen Suchbegriffen landete
importierter Kontext lagefalsch zueinander.

Der erste Import in einem Projekt setzt den Bezug automatisch (Modell-(0,0)
= gesuchter Ort) und speichert ihn; alle weiteren Importe verwenden densel-
ben Bezug, unabhängig vom neu gesuchten Ort (der bestimmt nur noch WOVON
Daten geladen werden, nicht mehr WOHIN sie im Modell platziert werden).
UI: Anzeige des aktiven Bezugs im Import-Dialog mit Zurücksetzen-Option.

Löst das strukturelle Problem noch nicht vollständig (der Bezug sitzt immer
bei Modell-(0,0) — passt nur, wenn das eigene Gebäude dort gezeichnet ist),
aber behebt die akute Inkonsistenz zwischen mehreren Importen.
2026-07-12 19:33:16 +02:00
karim ee0f1dfb07 PENDENZEN.md: alle offenen Punkte aus der Session gesammelt dokumentiert
Georeferenzierung (fehlender Referenzpunkt-Mechanismus), anwählbares Mesh
auf Ebene statt Geo-Panel-Eintrag, Fenster-Rahmenecken/Dach-First ohne
Boolean-Verschneidung, OG-Wandstriche (undiagnostiziert), sowie die
Warteschlange Luftbild/Dokument-Einfügen + Mesh-Rundung als neue "In Arbeit"-
Punkte; swissBUILDINGS3D- und Kanten-Fixes als Erledigt nachgetragen.
2026-07-12 19:25:39 +02:00
karim 9705890fd2 DXF-Import: Polyface-Mesh-Faces (POLYLINE) wurden nie erkannt — swissBUILDINGS3D-Gebäude blieben leer
Root Cause für „Gebäude-Import funktioniert nicht": Die installierte
dxf-parser-Version liefert Face-Vertex-Indizes NICHT als `faces`-Array,
sondern als vier einzelne Felder (Gruppencodes 71–74: faceA/faceB/faceC/
faceD). Unser addPolyfaceMesh/isMeshPolyline prüfte auf ein `faces`-Array,
das in dieser Version nie existiert — jede POLYLINE-Polyface-Mesh (exakt das
Format der swissBUILDINGS3D-DXF-Kacheln) ergab dadurch 0 Dreiecke, obwohl
Positionen geschrieben wurden. Live gegen echte swissBUILDINGS3D-Kacheln
verifiziert: vorher 0 Meshes, jetzt tausende Dreiecke mit realistischen
Schweizer Höhenwerten. +3 Tests (rohes DXF, nicht gemockt, deckt auch die
zugrundeliegende Bibliothek ab), Suite 794 grün.
2026-07-12 19:22:53 +02:00
karim 35a6834072 swissBUILDINGS3D-Import: Absturz bei riesigen Kacheln behoben, Übersprungene sichtbar gemacht
Nutzer-Report „funktioniert nicht": Der Import stürzte mit einem harten
RangeError ab, sobald eine STAC-Kachel entpackt die maximale JS-String-Länge
überschritt (DXF komprimiert stark — eine Kachel unter dem 150-MB-Limit kann
trotzdem >700 MB unkomprimierten Text ergeben, real reproduziert für ein
dicht bebautes Stadtzentrum). downloadAssetText prüft jetzt zusätzlich die
JSZip-interne unkomprimierte Grössenschätzung VOR dem Entpacken und fängt
verbleibende Fehler (Netzwerk/ZIP/String-Länge) sicher ab, statt zu werfen.

Zu grosse/fehlgeschlagene Kacheln wurden bisher stumm übersprungen (0 Gebäude,
keine Erklärung — sah wie ein Bug aus). fetchBuildings3d liefert jetzt
skippedTiles mit; der Dialog zeigt „X Kachel(n) übersprungen, zu gross" statt
eines wortlosen Leer-Ergebnisses.
2026-07-12 19:15:45 +02:00
karim 7dc8f0d5c6 3D-Kanten: falsche Diagonalen auf doppelseitigen Meshes (Dach/Glas) behoben
Kontext-Meshes (Dach, Fenster-Glas/-Rahmen, künftig swissBUILDINGS3D-Import)
werden von append_context_mesh IMMER doppelseitig aufgebaut (jedes Dreieck +
gespiegelte Rückseite mit invertierter Normale), weil die Mesh-Pipeline
Backface-Culling aktiv hat. Die Kanten-Erkennung sah dadurch an JEDER Kante
ein exakt entgegengesetztes Normalen-Paar (Vorder-/Rückseite desselben
Dreiecks) und wertete das fälschlich als Knick — jede Flächen-Innendiagonale
eines doppelseitigen Meshes wurde gezeichnet (Nutzer-Report: sichtbare
Dreiecks-Diagonalen auf Dach und Fensterglas im "Schattiert mit Kanten"-Modus).

Fix: Rückseiten-Duplikate (Normalen-Paar mit dot ≈ -1) werden vor der Rand-/
Knick-Entscheidung zusammengeführt (ein Vertreter je Original-Dreieck) —
danach gilt dieselbe Logik wie bei einseitigen Meshes (Wände), unabhängig
davon ob doppelseitig gerendert wurde. +5 Tests (doppelseitige Varianten der
bestehenden Fälle), 88/88 grün mit --features render.
2026-07-12 19:10:06 +02:00
karim a84dc7ac73 3D-Ansicht: neuer Darstellungsmodus "Schattiert mit Kanten" (BIM-Look)
Neuer RenderStyle::ShadedEdges in render3d: wie "shaded" (echte Bauteilfarben,
beleuchtet), zusätzlich dunkle Modell-Kanten obenauf wie bei "hidden" — der
typische Revit/ArchiCAD-Look, der bisher fehlte (nur reines Weiss+Kanten via
"hidden" oder reine Bauteilfarben ohne Kanten via "shaded" waren möglich).
Nutzt dieselbe tiefengebiaste Flächen-Pipeline wie "hidden", damit die Kanten
sauber obenauf liegen (kein Z-Fighting). +6 Rust-Tests (--features render,
83/83 grün). Als neue Option "shaded-edges" in beiden Darstellungsart-
Dropdowns der 3D-Oberleiste; Three.js-Fallback ignoriert den Wert graceful
(fällt auf shaded zurück, bekommt bewusst keine neuen Features).
2026-07-12 18:59:05 +02:00
karim 6b89ad5bdb Fenster: Linienstärke/Farbe (sashLine) wirkt jetzt auch bei 1:100
Die einzige bei "grob" sichtbare Glaslinie war fest codiert (weder Opening.
color noch die neuen Kategorie-Übersteuerungen griffen dort). Nutzt jetzt
sashLine (Fenster-Kategorie), konsistent mit mittel/fein.
2026-07-12 18:52:08 +02:00
karim 814e9a8656 Fenster: Flügelstoss-Blöcke separat von Laibungs-Enden steuerbar (sashLine statt frameLine)
windowSymbol trug beide Blocktypen (Laibungs-Enden UND Flügelstoss-Marken)
im selben meetingMarks-Array, dadurch übersteuerte frameLine auch die Marke
zwischen den Flügeln — Nutzer wollte diese separat regelbar. Aufgeteilt in
jambMarks (Laibungs-Enden, zählt zu "Rahmen") und meetingMarks (Flügelstoss,
zählt zu "Fenster"/sashLine).
2026-07-12 18:51:09 +02:00
karim a51fd212d3 Attribute: Farbfeld zeigt nur noch ein Quadrat statt zwei
ColorHexField stellte Swatch UND natives <input type="color"> sichtbar
nebeneinander dar (zusätzlich zum separaten Hex-Textfeld) — wirkte wie zwei
Farbfelder pro Zeile. Hex-Textfeld entfernt, natives Color-Input liegt jetzt
unsichtbar über dem Swatch (öffnet die native Palette beim Klick auf das
einzige sichtbare Quadrat). Betrifft alle Attribute-Farbfelder in der App,
nicht nur die neuen Fenster-Linien-Felder.
2026-07-12 18:48:25 +02:00
karim bd8612ee74 PENDENZEN.md: Fenster-Grundriss-Überarbeitung zusammenfassend dokumentieren 2026-07-12 18:46:08 +02:00
karim d2758efc8f Fenster: Farbe/Strichstärke je Linien-Kategorie im Attribute-Panel einstellbar
Neue per-Öffnung-Übersteuerung (Opening.frameLine/sashLine/sillLineStyle,
je {color?, weight?}) für Blendrahmen+Stulp-/Laibungsblöcke, Flügelrahmen/
Sprossen und die Auf-/Untersicht-Sims-Andeutung separat. Fehlt eine
Übersteuerung, gilt weiterhin der bisherige Default (Opening.color/Haarlinie).
UI: drei neue Zeilen (Farbfeld + Strichstärke) im Objekt-Info-Panel bei
selektiertem Fenster.
2026-07-12 18:45:28 +02:00
karim 441e4a319f Grundriss: Oberlicht-Andeutung (opening-transom) entfernt
Zeigte immer eine gestrichelte Linie quer über die Öffnung bei transomHeight>0
— auf der Wandachse, unabhängig davon, ob die horizontale Schnittebene den
Kämpfer überhaupt trifft. Ein Kämpfer/Oberlicht ist ein Höhen-, kein
Grundriss-Merkmal; die Linie war daher architektonisch nicht aussagekräftig
(Nutzer-Report, dieselbe Kategorie Fehler wie die entfernte Brüstungslinie).
Betrifft Fenster UND Türen (gemeinsame addOpeningFrameBand-Funktion).
2026-07-12 18:35:11 +02:00
karim 3e7ad1f4e5 Fenster: konfigurierbare Auf-/Untersicht-Andeutung im Grundriss (WindowType.sillLine)
Ersatz für die entfernte pauschale Brüstungslinie: wählbar aussen/innen/beide
Flächen + Blickrichtung Auf-/Untersicht (Aufsicht dünn gepunktet, Untersicht
gestrichelt wie die übrigen Überkopf-Projektionen), an der tatsächlichen
Rahmenkante statt der Wandachse. Default aus (kein Feld gesetzt = keine Linie).
UI-Feld im Fenstertyp-Editor.
2026-07-12 18:32:19 +02:00
karim fb5ffb457c Fenster-Grundriss: pauschale Brüstungslinie entfernt
Lief immer auf der Wandachse, unabhängig von der tatsächlichen Rahmen-
position/-grösse — Nutzer-Report „liegt falsch". Ersatz kommt als gezielt
konfigurierbare Auf-/Untersicht-Andeutung (nächster Commit).
2026-07-12 18:29:29 +02:00
karim d21af4ac5a Fenster-Grundriss: keine Glaslinie mehr im Feld, auch nicht bei fein
Die dünne Glaslinie (fein: Doppellinie) lief mittig durchs Glasfeld, egal wo
und wie gross das Fenster war — Nutzer wollte dort keine Haarlinie, weder bei
mittel (schon vorher entfernt) noch bei fein. Blendrahmen + Flügelrahmen +
Rahmenblöcke an Laibung/Flügelstoss bleiben als Kontur bestehen.
2026-07-12 18:25:02 +02:00
karim 4a01754b4d Fenster-Grundriss: keine separate Trennlinie mehr neben dem Rahmenblock am Flügelstoss
Der Rahmenblock (voller Profilquerschnitt) und eine dünne window-mullion-
Linie lagen an derselben Stelle übereinander — überflüssig, der Block markiert
den Stoss bereits vollständig. Nur noch für Alt-Fenster ohne Typ (kein Block
verfügbar) bleibt die Linie bestehen.
2026-07-12 18:20:28 +02:00
karim ce7454039f PENDENZEN.md: Dach-Wand-Verschneidung + Fenster-Grundriss-Fixes als erledigt markieren 2026-07-12 18:16:32 +02:00
karim a5849111e3 3D-Wände: Top folgt der Dach-Unterkante statt sie zu durchdringen (Z-Fighting-Fix)
Wände wurden bisher unabhängig von Dächern mit fixer Höhe emittiert, während
Dächer separat gerendert wurden — wo eine geneigte Dachfläche den flachen
Wand-Top kreuzte, überlappten sich beide Volumen (Z-Fighting im 3D-Viewer,
Nutzer-Report mit Screenshot).

roofUndersideAt (geometry/roof.ts) liefert die Dach-Unterkante an einem
Grundriss-Punkt (Ebenengleichung je Dachfläche, dieselbe Herleitung wie im
Vertikalschnitt). clipPieceToRoofs (toWalls3d.ts) zerlegt betroffene Wand-
Achsenstücke in feine Schritte (~15 cm) und klemmt jeden auf die dort lokal
gemessene Dach-Unterkante — eine Treppenstufen-Annäherung an eine echte
geneigte Giebelwand-Stirnfläche (render3d-Wandkörper haben nur einen flachen
Top; eine echte Schrägfläche bräuchte einen neuen Mesh-Pfad). Ohne Dach im
selben Geschoss bleibt das Verhalten unverändert (kein Overhead).
2026-07-12 18:15:22 +02:00
karim 3d66dc8967 Fenster-Grundriss: Rahmen-/Stulp-Blöcke über volle Rahmentiefe, plus Laibungs-Blöcke an den Enden
Die Stulp-Marken an Flügelstössen waren als kleines, von der Rahmentiefe
unabhängiges Quadrat gezeichnet statt als echter Profilquerschnitt über die
ganze Rahmentiefe (aussen bis innen). Zusätzlich fehlten die entsprechenden
Blendrahmen-Querschnittsblöcke an den beiden Laibungs-Enden komplett — jetzt
zeigt jedes Fenster dort einen Block, unabhängig von der Flügelanzahl.
2026-07-12 18:07:09 +02:00
karim e65a6b7de1 Fenster-Grundriss: Sims/Stulp folgen der eingezogenen Rahmenkante, mittel ohne Glaslinie
Sims (Fensterbank) und Anschlag-Kerben nutzten pauschal die volle Wandfläche
statt der tatsächlichen (ggf. per insetFromFace eingezogenen) Rahmen-
Aussenkante — bei rückversetzten Rahmen sass die Fensterbank dadurch sichtbar
falsch. Stulp-Marken nutzen jetzt die bereits korrekt (tiefenbewusst) in
windowSymbol berechneten meetingMarks statt einer zweiten, abweichenden
Neuberechnung. Ausserdem: SIA fig. 37 (1:50) zeigt noch keine Glaslinie, nur
Rahmen + Stulp-Quadrat — die kommt gemäss fig. 38 erst bei 1:20 dazu.
2026-07-12 17:58:27 +02:00
karim 6ac054ea71 PENDENZEN.md: mullionCols + swissBUILDINGS3D/Terrain als erledigt markieren
Korrigiert zugleich die veraltete Nordstern-Geo-Rendering-Notiz (war schon
seit 35299307d erledigt) und dokumentiert die neu gefundene Dach-Wand-
Verschneidungslücke (Z-Fighting) als nächsten Arbeitsschritt.
2026-07-12 17:41:37 +02:00
karim ed724be481 Standort-Import: echte swissBUILDINGS3D-Gebäude (2.0/3.0) + swissALTI3D-Terrain
Ersetzt die stark vereinfachte Box-Extrusion (vec25-Footprint + Pauschalhöhe
9m) durch echte Gebäudegeometrie aus den swissBUILDINGS3D-STAC-Kacheln (Wahl
zwischen Generation 2.0 stabil und 3.0 Beta, DXF-Kacheln über den bestehenden
dxfParser als Mesh eingelesen). Gelände kommt neu aus echten swissALTI3D-XYZ-
Rastern (0.5m/2m wählbare Punktdichte) statt der groben profile.json-Näherung.
Gemeinsames STAC-Client-Modul (stacApi.ts) für beide Quellen.
2026-07-12 17:38:47 +02:00
karim 00bff27b02 Fenster: echte Sprossen-Spalten (mullionCols) analog Kämpfer-Zeilen
Vertikale Sprossenteilung im Glasfeld (mullionCols−1 Spalten), spiegelbildlich
zur bestehenden mullionRows-Logik: 3D-Rahmen-Riegel, Ansichts-Trennlinien +
Glasscheiben als echtes rows×cols-Raster, Eingabefeld im Fenstertyp-Editor und
im ResourceManager.
2026-07-12 17:38:21 +02:00
karim 61313777da PENDENZEN.md: swissBUILDINGS3D-Recherche dokumentieren (STAC-API, Rhino-Referenz, render3d-Zielarchitektur) 2026-07-12 16:46:42 +02:00
karim 7c3a6f3f35 PENDENZEN.md: Wand-Poché-grob-Fix als erledigt vermerken 2026-07-12 16:20:55 +02:00
karim 0240a23a02 Wand-Poché bei "grob" immer vollschwarz (SIA 400 Fig. 36)
Bisher war die Füllung bei Detailgrad "grob" nur schwarz, wenn das
dominante Wandbauteil selbst ein "solid"-Hatch-Pattern hatte — bei
mehrschichtigen Wandtypen (Backstein/Dämmung/Verputz) blieb sie weiss.
SIA 400 kennt bei 1:100 aber keine Materialunterscheidung, die Poché ist
immer vollschwarz. Fix + Regressionstest von Hermes/Qwen3 übernommen und
um den fehlenden Test sowie das Aufräumen der jetzt toten
backbonePocheFill-Hilfsfunktion ergänzt.
2026-07-12 16:20:36 +02:00
karim e309e54af7 Kommentare Öffnungssymbole: DIN- durch SIA-Referenz ersetzen
Die Dreh-/Kipp-/Schiebe-Symbolik in Ansicht und 3D war fälschlich als
"DIN-Konvention" kommentiert (Grundlage ist SIA 400 B.9.1.3). Reine
Kommentar-/Doku-Korrektur, keine Verhaltensänderung.
2026-07-12 16:02:59 +02:00
karim 13cc6a0d6a Fenster im Grundriss: grob/mittel/fein nach SIA 400 klar unterscheiden
grob (1:100) zeigt nur eine schematische Glaslinie, mittel (1:50) einen
Blendrahmen mit Flügel-Trennlinien und Stulp-Quadrat je Flügelstoss, fein
(1:20) zusätzlich verschachtelte Flügelrahmen, Glas als Doppellinie
(Isolierverglasung) und zwei Stulp-Quadrate je Stoss — vorher waren mittel
und fein praktisch identisch. Referenz: SIA 400 Anhang B.9.1, Fig. 36–38
(docs/research/sia400-fenster-tueren.md).
2026-07-11 18:18:37 +02:00
karim 3ecb8c45de Ansicht: Kämpfer-/Sprossen-Zeilen (mullionRows) im Fenster
Die 2D-Ansicht teilte das Glasfeld nur vertikal (Flügel) und beim Oberlicht,
ignorierte aber mullionRows — ein Fenster mit horizontaler Sprossenteilung sah
in der Ansicht ungeteilt aus, im 3D dagegen geteilt. Jetzt splittet die Ansicht
das Glasfeld je Flügel in mullionRows Zeilen mit Trennlinien, konsistent zum 3D.
2026-07-11 13:21:52 +02:00
karim f98c962ba3 Schiebefenster-Öffnungssymbol (DIN-Pfeil) in Ansicht und 3D
Schiebeflügel hatten bisher kein Öffnungssymbol (nur Dreh/Kipp/Drehkipp). DIN
zeichnet für Schiebeelemente keinen Anschlag-Winkel, sondern einen Pfeil in
Laufrichtung — ergänzt in der 2D-Ansicht (toElevation) und im 3D-fein
(toWalls3d), Richtung aus der Griffseite. Test deckt Schiebe/Fest/Drehkipp ab.
2026-07-11 13:11:58 +02:00
karim cdae20acbe Ansicht robuster: exaktes Hidden-Line-Clipping, T-Stoss-Ecken, Öffnungsschatten
Die bisherigen Näherungen versagten bei realen Grundrissen: Bounding-Box-
Occlusion liess Linien hinterer Wände durch die Vorderfassade scheinen, sobald
eine Hinterwand höher/breiter war als die verdeckende; die Eckverlängerung
schloss nur exakt geteilte Endpunkte, keine T-/versetzten Stösse.

- Hidden-Line: jede Umriss-/Kantenlinie wird jetzt exakt gegen die näheren
  opaken Flächen (inkl. Öffnungs-Rahmenquads, da die Fassade um Öffnungen
  ausgespart ist) geclippt statt per Bbox verworfen. Verdeckte Teilstücke
  entfallen, überstehende bleiben — keine Durchsicht mehr.
- Ecken: Wandenden verlängern sich bis zur Aussenfläche jeder anstossenden
  Wand (L/T/X über Körper-Enthaltung), aber nur für fassaden-PARALLELE Wände —
  facaden-senkrechte Innenwände würden sonst als Balken vor die Fassade ragen.
- Öffnungen: Fenster/Türen werfen mit Schatten-Toggle einen Laibungs-/Reveal-
  schatten (Band unter dem Sturz + an der linken Laibung, 45° von links oben),
  sodass sie als Vertiefung lesen. Verdeckte Öffnungen werden ganz gecullt.

Neue Tests decken die konkreten Fehlerbilder ab (deckungsgleiche/höhere
Hinterwand, verdecktes Fenster, Reveal-Schatten an/aus).
2026-07-11 13:10:21 +02:00
karim 1c31e680c1 Fenster-Rahmentiefe: realistischer Default statt volle Wanddicke
Recherche zu Vectorworks/ArchiCAD/realem Fensterbau ergab: ein Rahmen ohne
explizite frameDepth füllte bisher die GESAMTE Wanddicke — echte Fenster sind
unabhängig von der Wanddicke nur ~70-90mm tief und sitzen mit sichtbarer
Laibung/Leibung in der Öffnung, statt als massiver Block über die volle Tiefe.
Neuer Default 70mm (2D-Grundriss und 3D konsistent), Glas-Falzmass von 3cm auf
2cm reduziert und Mehrfachverglasungs-Scheibendicke/-abstand kompakter, damit
Dreifachverglasung noch in den schlankeren Rahmen passt.
2026-07-11 12:50:10 +02:00
karim d34e1cb1c1 3D-Öffnungen: Flügel-/Türblatt-Regression fixen; Ansicht: Eck-Joins, Durchsicht, Farbig-Modus
3D: Flügelrahmen sass fälschlich vor der Fassade (Rahmen-Glas-Rahmen-Sandwich)
und Türen hatten kein Blatt — beides aus dem letzten 'fein'-Merge. Flügelband
jetzt hinter die Blendrahmen-Vorderkante zurückgesetzt, Türblatt (Voll- und
Teilverglasung) ergänzt.

Ansicht: Wandboxen liefen achsenzu-achse ohne Eckverlängerung (dreieckige
Kerbe an jeder Gebäudeecke, sichtbar v.a. im Schlagschatten) — Wände mit
gemeinsamem Endpunkt verlängern sich jetzt um die halbe Nachbardicke. Die
Linien-Pipeline zeichnet alle Konturen in einem eigenen Durchgang über allen
Füllungen, wodurch Öffnungs-/Fugenlinien hinterer Fassaden durch nähere Wände
schienen — verdeckte Flächen/Öffnungen werden jetzt gar nicht mehr emittiert.
Farbig-Modus zeigte bisher nur Weiss statt Bauteilfarben; Wand/Dach/Fenster/
Tür tragen jetzt echte Farben, Mono bleibt die reine Linienzeichnung.
2026-07-11 12:38:13 +02:00
karim 5f1b38a420 PENDENZEN: Schnitt/Ansicht-Block, 3D-fein-Öffnungen, Lineale + Zeichen-Feedback eingetragen 2026-07-11 03:01:24 +02:00
karim 31d7aefcb7 Merge: 3D-Öffnungen auf VW-fein-Niveau
Fenster/Türen im 3D deutlich plastischer (Nutzer-Referenz Vectorworks 'fein'):
- Flügelrahmen als eigener, ~12 mm vorstehender Körper (Blendrahmen →
  Flügelprofil → Glas ablesbar), jetzt ab Detailgrad 'mittel'
- DIN-Öffnungssymbole AUF dem Glas (dünne dunkle Prismen, frei orientiert
  via openingPlaneBox): Dreh/Kipp/Dreh-Kipp wie in der 2D-Ansicht, nur 'fein',
  nur je nicht-festem Flügel
- Fensterbank in 3D: Bank + Tropfkanten-Stufe, seitlich überstehend, aussen
  auskragend (sillBoard-Gating wie 2D)
- Tür-Zarge mit Umgriff: Bekleidungsring vor beiden Wandflächen (nur
  frameKind 'zarge'; Blockrahmen bleibt bündig)
Gating: grob nichts · mittel Rahmen/Flügel/Sims/Zarge · fein + Sprossen +
Symbole. +4 Mesh-Tests.
2026-07-11 03:00:32 +02:00
karim bc2778562c Model3dOptions.detail: Doc-Kommentar an neue Gating-Staffelung angepasst 2026-07-11 02:59:33 +02:00
karim 06803206e5 3D-Öffnungen im VW-Detail 'fein': verschachtelte Flügelrahmen, DIN-Symbole, Sims, Zargen-Umgriff
- Flügelrahmen sitzen in eigenem, ~12 mm nach aussen vorstehenden und flacheren
  Quer-Band (abgesetzte, plastische Schachtelung Blendrahmen -> Flügelrahmen ->
  Glas) statt flach mit dem Blendrahmen verschmolzen; jetzt bei mittel + fein.
- DIN-Öffnungssymbole (Dreh/Kipp/Dreh-Kipp) als sehr dünne, in der Öffnungsebene
  gedrehte dunkle Prismen knapp vor der Glasebene (neuer Helfer openingPlaneBox);
  nur bei fein, nur je nicht-festem Flügel, exakt wie in der 2D-Ansicht.
- Fensterbank (Sims) unter der Öffnung: Bank-Quader + dünnere Tropfkanten-Stufe,
  seitlich ueberstehend, nach aussen auskragend; Gating sillBoard (keine/innen aus,
  Default zeichnen) und DetailLevel (mittel + fein).
- Tuer-Zarge mit Umgriff: schmale Bekleidungs-Platte vor beiden Wandflaechen als
  Ring um die Oeffnung (nur frameKind 'zarge'; Blockrahmen bleibt buendig).
- Mesh-Tests je Feature: Anzahl/Farbe/BBox, sillBoard- und frameKind-Gating,
  Symbole nur fein/nicht-fest, Flügelrahmen-Vorstand.
2026-07-11 02:58:50 +02:00
karim 38a4d8ca3a Schnitt: Wand-Dämmschraffur zurück auf Achswinkel 90 (Sprossen quer)
Der vorige Flip (a612ae5) hatte die Wand mitgedreht — dort stand die
Orientierung aber schon richtig (Sprossen quer zur stehenden Schicht =
horizontal); nur die Decke war falsch. Jetzt empirisch am Demo-Schnitt
verifiziert: Wand 90 (Sprossen horizontal), Decke 90 (Sprossen vertikal) —
beide quer zur jeweiligen Schichtrichtung. 737/737 grün.
2026-07-11 02:53:18 +02:00
karim ee9aeda01e Ansicht-Fenster im VW-Detail: Flügelrahmen, Sims mit Tropfkante, exakte Symbole
Nutzer-Referenz: drei ablesbare Konturen (Blendrahmen → Flügelprofil → Glas)
je Flügel; Fensterbank als zwei seitlich überstehende Bänder (Bank +
Tropfkante, Default an, aus bei sillBoard keine/innen); die DIN-Öffnungs-
symbole starten/enden jetzt EXAKT auf den Glasfeld-Ecken (Dreh: Spitze am
Bandseiten-Mittel des Glasfelds, Kipp: Spitze oben Mitte — wie VW; vorher
sassen die Anker auf der Flügelspanne statt dem Glasfeld). 737/737 grün.
2026-07-11 02:44:56 +02:00
karim a612ae55cd Schnitt: Dämmschraffur-Orientierung + Terminierung bei Decken-Anschluss
Zwei Nutzer-Reports:
1. Wandrelative Muster (Dämmwellen) waren im Schnitt bei Wand UND Decke je
   90° verdreht: die geschnittene Wand STEHT (Achswinkel 0 statt 90), die
   geschnittene Decke LIEGT (Achswinkel 90 statt absolut) — beide Bänder
   bekommen jetzt die richtige Achse.
2. applyWallTermination terminiert jetzt auch bei blossem ANSCHLUSS (±5 cm):
   eine Decke, die nur bis zur Wand-Innenkante gezeichnet ist, beendet die
   Wand trotzdem (vorher liefen Innenputz/Backstein durch, weil die
   Prioritäts-Subtraktion echten Überlapp braucht — den es dort geometrisch
   nicht gibt). Demo-Verhalten (Outline auf Wandachse) unverändert korrekt:
   dort schneidet die Decke exakt die überlappte innere Wandhälfte.
737/737 grün.
2026-07-11 02:42:48 +02:00
karim c02a02cffe Ansicht als Linienzeichnung (VW-Look): weisse Flächen + XOR-Silhouette
Nutzer-Referenz ('so sieht es ordentlich aus'): Architektur-Ansichten sind
Linienzeichnungen, keine Grauflächen. Umsetzung:
- Alle Flächen weiss (Fassade/Dach/Rahmen/Glas/Türblatt); Kanten tragen.
- Frontale Wand-/Deckenflächen je Tiefen-Gruppe: Flächen ohne Kontur, dann
  der Vereinigungs-UMRISS der Gruppe über Kanten-XOR (xorOutlineEdges) —
  Innenkanten der Pfeiler-/Sturz-/Geschoss-Teilboxen heben sich paarweise
  auf, übrig bleibt exakt die Silhouette inklusive der Öffnungslöcher.
- Dach + schräg angeschnittene Flächen behalten ihre eigene Kontur.
Painter-Verdeckung bleibt (weisse Füllung nah überdeckt fern). 737/737 grün;
headless verifiziert — deckungsgleich mit der VW-Referenz.
2026-07-11 02:33:46 +02:00
karim 983d061a0c Schnitt entspiegelt: u-Achse = Betrachter-RECHTS (Rust + TS koordiniert)
Die Schnitt-u-Achse nutzte cross(normal, up) — die look_at-Konvention für
Blick entlang −z. Für einen Schnitt, den man entlang +normal betrachtet,
spiegelte das die Zeichnung (Blick nach Norden zeigte Osten links). Neu:
u = cross(up, normal) — wer nach Norden blickt, hat Osten rechts. Dieselbe
Entspiegelung wie zuvor in der Ansicht (b967146).

Koordiniert geändert: SectionPlane::u_axis (section.rs; section_fill nutzt
dieselbe Methode) + sectionUAxisModel (toSection.ts; Schicht-Orientierung
wallLayersReversedInU und Dach-Schnitt hängen daran und kippen konsistent
mit — die Aussenseite bleibt in der Zeichnung physisch aussen). Rust- und
TS-Tests auf die neue Konvention gespiegelt; Engine neu gebaut.
cargo 76/76, vitest 737/737.
2026-07-11 02:30:35 +02:00
karim 1945fecd11 Lineale oben + links in allen 2D-Ansichten (Vectorworks-Stil)
Screen-fixe Lineal-Leisten über dem sichtbaren Ausschnitt: Major-Ticks mit
Meter-Beschriftung ('5.000m', Schrittweite 1/2/5·10^n nach Zoom), Minor-Ticks,
gedrehte Beschriftung am linken Lineal, gelber Cursor-Marker je Achse, Ecke
oben links. pointerEvents none — Klicks gehen durch. Gilt für Grundriss,
Schnitt, Ansicht und Zeichnungs-Ebenen (PlanRulers in PlanView, Default an,
per showRulers-Prop abschaltbar). 737/737 grün; headless verifiziert.
2026-07-11 02:22:14 +02:00
karim 4c4c9907af Schnitt/Ansicht-Feinschliff: Kantenfilter, Print-Toggle, echte Fenster in der Ansicht
Vier Nutzer-Punkte:
1. Editieren im Schnitt funktioniert (Klick auf die Poché wählt das Bauteil,
   headless verifiziert) — die Störung waren die Kanten GESCHNITTENER
   Bauteile: der Extraktor projizierte auch deren Restkörper (Deckel-/
   Bodenkanten quer durch die eigene Poché). computeSection filtert sie jetzt;
   Ansichtskanten ungeschnittener Bauteile (Rückwand-Fenster etc.) bleiben.
2. Verdeckte Kanten (gestrichelt) sind Standard AUS — zuschaltbar je Ebene
   (DrawingLevel.hiddenLines, Checkbox in der Schnittlinien-Sektion).
3. Display/Print-Umschalter wirkt jetzt in ALLEN 2D-Darstellungen (Grundriss,
   Schnitt, Ansicht, Zeichnung) — war fälschlich auf den Grundriss begrenzt.
4. Fenster/Türen in der Ansicht als echtes Bauteil: Blendrahmen-Fläche →
   Flügelfelder (aus der Flügeltabelle, ungleiche Breiten/Pfosten) → Glas,
   Kämpfer/Oberlicht-Teilung, DIN-Öffnungssymbol (dezent gestrichelt,
   Dreh/Kipp/Drehkipp je Anschlag); Türen mit Rahmen + Blatt-Fläche.
737/737 grün; headless verifiziert (Schnitt A + Ansicht Süd).
2026-07-11 02:18:09 +02:00
karim b96714641a Ansicht poliert: ruhige Fassade, korrekte Orientierung, echte Traufschatten
Nutzer-Feedback 'Ansichten sehen noch schrecklich aus' — drei Ursachen behoben:

1. RUHIGE FLÄCHEN: Rollen-Füllung (Fassade/Decke einheitlich hell, Dach eine
   Stufe dunkler) statt Tiefen-Grau-Patchworks; Wand-/Deckenflächen ohne
   Kontur (kein Linienraster aus Pfeiler-/Sturz-Teilboxen und Geschossfugen
   mehr — der Fassadenrand liest sich über den Kontrast zum Papier), nur das
   Dach behält seine Kontur. Painter-Gleichstand: Decke vor Wand (bündige
   Decken-Stirnstreifen verschwinden unter der Fassade).

2. ENTSPIEGELT: die u-Achse der Ansicht ist jetzt das Blickrichtungs-RECHTS
   (cross(up, N)) statt der Kamera-Formel für Blick entlang −z — wer nach
   Norden schaut, hat Osten rechts. (Die Schnitt-Pipeline behält vorerst die
   Rust-Konvention; die dortige Spiegelung ist als separates Thema notiert.)

3. SCHATTEN REPARIERT: Werfer-Tiefe = nächste Kante (depthMin — bei
   Dachflächen die auskragende Traufe, dadurch wirft das Dach jetzt den
   klassischen Traufschatten) + Kappung MAX_SHADOW_DELTA 1.2 m (keine
   raumhohen Parallelogramme von fernen Innenwänden mehr).

Fusszeile 'render3d' → 'aus dem Modell' (die Ansicht ist reine TS-Projektion).
737/737 grün; headless verifiziert (Demo-Ansicht Süd).
2026-07-11 01:59:11 +02:00
karim 242b850beb Zeichen-Feedback: Δx/Δy beim Rechteck, lange Führungslinien, Winkelbogen
VW-Feinschliff (Nutzer-Screenshots):
- Rechteck (2-Punkt + Zentrum) zeigt Δx/Δy vorzeichenbehaftet statt
  Diagonale+Winkel (deltaHud; Zentrum = volle Rechteckmasse). Die
  width/height-Tab-Felder erscheinen im HUD als Δx/Δy.
- Führungslinie des Winkelrasters läuft jetzt weit über den Cursor hinaus
  (quer über den Ausschnitt statt 40 px).
- Winkelbogen ergänzt: gestrichelter Bogen von der horizontalen 0°-Referenz
  (dezentes Lachsrot, VW-Anlehnung) zur Strahlrichtung, Badge sitzt am
  halben Winkel aussen am Bogen.
737/737 grün; headless verifiziert (Linie 45° + Rechteck-Δ).
2026-07-11 01:44:32 +02:00
karim 7898a0d158 Cursor-HUD + Winkel-Badge im Programm-Look (dunkle Dropdown-Pille)
Statt weissem Kasten mit blauem VW-Rahmen tragen HUD und Winkel-Badge jetzt
die dunkle Pille der Dropdown-Trigger (#2c2c2c/#4a4a4a, helle Schrift,
Pillen-Rundung) — derselbe Look wie die schwebenden Viewport-Knöpfe über dem
Papier. Aktives Tab-Feld als leicht angehobene Fläche, gelockte Werte voll
weiss/fett; Führungslinie neutral grau statt rötlich.
2026-07-11 01:37:25 +02:00
karim 8f85e135ee Zeichnen: getippte Werte live im Cursor-HUD + direktes Zahlen-Tippen
VW-Verhalten vervollständigt: das Cursor-Kästchen spiegelt jetzt den Feld-
Status der Befehlszeile (Tab-Feld-Zyklus) — der getippte Wert erscheint
SOFORT im aktiven Feld oben am Cursor UND unten im Befehl; gelockte Werte
fett/dunkel, das aktive Tab-Ziel als blaue Pille mit weissem Text. Tab
wechselt Länge ↔ Winkel (global, auch ohne Fokus), und eine Ziffer (oder
. , -) beim Zeichnen startet die Werteingabe direkt (beginTyping fokussiert
die Befehlszeile mit dem ersten Zeichen — kein Klick nötig).

- PlanView: HudFieldsState + Segment-HUD (aktiv/gelockt/getippt je Feld)
- CommandLine: onTextChange (Live-Echo) + Handle beginTyping(seed)
- App: cmdTyped-State, hudFields an alle PlanView-Pfade, globaler
  Ziffern-Handler vor dem Tab-Zyklus
737/737 grün.
2026-07-11 00:52:52 +02:00
karim 038054fee6 Demo: Ansicht Süd mit gesäter Ansichtslinie + Schatten an
Die Demo-Ansicht rendert damit sofort (analog zur gesäten Schnitt-A-Linie)
statt den Hinweis 'keine Ansichtslinie' zu zeigen.
2026-07-11 00:43:29 +02:00
karim 3980a06a6c PENDENZEN: Schnitt-Ausbau, Ansicht, Zeichen-HUD, Dach-Pick/Terminierung eingetragen 2026-07-11 00:36:11 +02:00
karim b86fca2c96 Merge: Schnittfunktion ausgebaut (Schnittlinie, Editieren im Schnitt, Tiefe)
- Befehl 'sectionline'/'viewline' (Aliase schnittlinie/ansichtslinie): zwei
  Klicks setzen die Schnitt-/Ansichtslinie, neue Ebene bei Bedarf
- Schnittführungs-Symbol im Grundriss (Strichpunkt, Endmarken, Richtungs-
  pfeile, Label) + Pick-Band, Auswahl + Attribut-Sektion (Name, Richtung
  umkehren, Endpunkte, Tiefe, Löschen)
- Editieren IM Schnitt: Cut-Polygone tragen die Quell-Element-Id (durch
  Schicht-Zerlegung/Terminierung/Dominanz propagiert) → Klick wählt das
  Bauteil, Panels editieren, Schnitt rechnet neu
- Schnitt-Tiefe (DrawingLevel.depth): Ansichtskanten ferner Bauteile werden
  ausgeblendet (Owner-Distanz-Näherung, dokumentiert)

# Conflicts:
#	src/model/types.ts
2026-07-11 00:35:20 +02:00
karim fa59c0a9dc Schnitt-Tiefe: Kanten hinter DrawingLevel.depth ausblenden
computeSection filtert Ansichtskanten (sichtbar/verdeckt), deren Quell-Bauteil
weiter als level.depth hinter der Ebene liegt (Owner-Distanz-Naeherung ueber den
Bauteil-Mittelpunkt; Cut-Polygone bleiben). Naeherung dokumentiert (TODO: exakte
per-Kante-Tiefe aus render3d/section.rs). Feld ist in der Schnittlinien-Sektion
editierbar (Teil 3). Test fuer filterByDepth.
2026-07-11 00:32:03 +02:00
karim a6db682e9d Ansicht: Dächer als Flächen + Giebel + Traufschatten nachgerüstet
Der Ansichts-Generator entstand auf einem Basisstand ohne TS-Dächer —
roofWorldFacesAll liefert jetzt die Dachflächen (Newell-Normale aufwärts) und
Giebel (nach aussen orientiert) aus roofGeometry, projiziert in den Painter-
Strom (rückseiten-gecullt, roofId für spätere Auswahl); Dachflächen wirken
zusätzlich als Schattenwerfer (Traufüberstand → 45°-Schlagschatten auf die
Fassade). +1 Test (Sattel-Süd-Schräge bis Firsthöhe). 722/722 grün.
2026-07-11 00:30:16 +02:00
karim d43e2c151e Editieren im Schnitt: Cut-Polygone tragen die Quell-Element-Id
toSection loest je Cut-Band die Quell-Element-Id (Wand/Decke/Dach) ueber die
Besitzer-Listen auf (cp.sourceId) und propagiert sie durch Schicht-Zerlegung,
Terminierung und Boolean-Dominanz. generateSectionPlan schreibt sie je
component.kind als wallId/ceilingId/roofId ans Plan-Polygon — ein Klick im
Schnitt waehlt damit exakt jenes Bauteil im Modell (bestehende PlanView-Pick-/
Highlight-Logik greift), das Attribut-Panel editiert OK/UK/Hoehe, der Schnitt
rechnet neu. Tests fuer Mapping + sourceId-Propagation.
2026-07-11 00:30:01 +02:00
karim ef7eb295d9 Schnittlinie editierbar: Objektinfo-Sektion + Entf-Taste
Gewaehlte Schnitt-/Ansichtslinie erscheint als SectionLineSection im Attribut-
Panel (ueber host.sectionLine, da eine Linie kein Projekt-Bauteil ist): Name,
Blickrichtung umkehren (directionSign-Flip), Endpunkt-Koordinaten und Schnitt-
Tiefe editierbar; Loeschen/Entf setzt linePoints zurueck (Ebene bleibt). Host-
Kontrakt um sectionLine/onSetSectionLinePatch/onDeleteSectionLine erweitert,
verdrahtet ueber patchLevel. i18n de/en.
2026-07-11 00:26:49 +02:00
karim 456ecc5445 Merge: Ansicht (Elevation) als echte 2D-Darstellung + Schatten-Toggle
toElevation.ts: Painter-Projektion des 3D-Modells auf die Ansichtsebene
(Fassaden-/Deckenflächen fern→nah, Rückseiten-Culling, Fenster/Türen als
Rahmen+Glas, Bodenlinie) + klassischer 45°-Schlagschatten auskragender
Bauteile (Sutherland–Hodgman auf die Empfängerfläche geclippt). Schatten-
Umschalter in der Ansichts-Leiste (DrawingLevel.shadows), App rendert
elevation-Ebenen über den neuen Generator (synchron, ohne WASM).
2026-07-11 00:26:02 +02:00
karim 975ce3f745 Merge: VW-Zeichen-Feedback (Cursor-HUD L/W + weiches Winkel-Einrasten)
Beim Zeichnen erscheinen Länge + Winkel direkt am Cursor (L:/W:-Kästchen),
gängige Winkel (15°-Vielfache) rasten weich ein — mit Winkel-Badge und
gestrichelter Führungslinie. Vorrang: Objekt-Snap > Shift/Ortho > Winkelraster
> Raster. Toggle + Toleranz in der Fang-Leiste. Konflikt tools/types.ts:
erweitertes hud-Feld + measure-Feld koexistieren.

# Conflicts:
#	src/tools/types.ts
2026-07-11 00:25:49 +02:00
karim 4b0f23d123 L/W-HUD in den Zeichenbefehlen (line/polyline/wall/rect/circle/arc)
Alle punktbasierten Zeichenbefehle setzen den Cursor-HUD jetzt einheitlich
über den gemeinsamen segmentHud()-Helper statt eigener Text-Formatierung:

- line/polyline/wall: L/W-Kästchen für das lebende Segment ab dem letzten
  Punkt.
- rect: 2-Punkt/Zentrum-Methode zeigt die Diagonale als L/W; die 3-Punkt-
  Methode zeigt die Basiskante als L/W bzw. Basisbreite+Höhe im Rise-Schritt.
- circle/arc (Radius-Schritt): Radius als „R: …m"-Label statt L/W (kein
  Winkel sinnvoll); arc zeigt im Spannwinkel-Schritt Radius als L und
  Spannwinkel als W.

Alle Werte jetzt mit 3 Nachkommastellen (vorher 2 bzw. 0), konsistent mit
der VW-Konvention der Statusleiste.

Tests: line.test.ts/wall.test.ts prüfen hud.length/angleDeg im onMove-Draft
(inkl. negativer Winkel im Bereich (−180,180] und Segment-Wechsel bei
mehrteiligen Wandzügen).
2026-07-11 00:21:35 +02:00
karim 61c2a48f52 Ansicht: Schnitt-Plan wieder memoisieren (kein Recompute je Render) 2026-07-11 00:21:33 +02:00
karim 39bceedc17 Schnittführungs-Symbol im Grundriss + Schnittlinien-Auswahl
generatePlan zeichnet fuer jede Schnitt-/Ansichtsebene mit linePoints das
klassische Symbol: Strichpunkt-Linie, Endmarken + Richtungspfeile (directionSign),
Kurz-Label an beiden Enden und ein unsichtbares Pick-Band (Polygon mit
sectionLineId). PlanView pickt die Linie (hoechste Prioritaet) und hebt die
Auswahl hervor. Neuer Selektionskanal selectedSectionLineId (Einzel-DrawingLevel-
Id) in selectionSlice; onPlanSelect + alle Reset-Stellen in App gepflegt.
Primitive.sectionLineId, PlanSelection.sectionLineId, Tests.
2026-07-11 00:21:29 +02:00
karim 3f042eb091 Cursor-HUD (VW-Stil) + weiches Winkel-Einrasten im Grundriss
Beim Zeichnen erscheint jetzt direkt am Cursor ein blau umrandetes
Massband-Kästchen mit Länge/Winkel (L: 3.118m  W: 60.000°, Winkel im
Bereich (−180,180]) statt nur in der Befehlszeile. Zusätzlich rastet
der Zugwinkel weich auf gängige Winkel (15°-Vielfache, Toleranz per
Default 2.5°) ein, sobald kein Objekt-Snap greift — mit gelblichem
Winkel-Badge und einer über den Cursor hinaus verlängerten, gestrichelten
Führungslinie (Vectorworks-Vorbild).

- tools/types.ts: ToolDraft.hud um length/angleDeg erweitert (text bleibt
  für Befehle ohne L/W-Paar), neuer segmentHud()-Helper.
- tools/snapping.ts: neue reine Funktion snapCommonAngle() + Einbindung in
  computeSnap (Objekt-Snap > harter Ortho-Zwang > weiches Winkelraster >
  Raster). Neues SnapSettings-Feld commonAngles + commonAngleTolerance,
  neuer SnapKind "angle".
- ui/StatusBar.tsx: Toggle + Toleranz-Eingabe in der Fang-Popover.
- plan/PlanView.tsx: DraftHud- und AngleGuide-Overlay (screen-space,
  zoomunabhängige Größe); die Winkel-Info reitet auf draft.snap mit.
- i18n: snap.commonAngles / snap.commonAngleTolerance (de/en).
- snapping.test.ts: snapCommonAngle-Fälle (exakt/Toleranz/Bereich/
  15°-Raster/Projektion) + Objekt-Snap-Vorrang vor dem Winkelraster.
2026-07-11 00:21:21 +02:00
karim a3a6b51db4 Ansicht: Flächen-Generator einbinden + Schatten-Umschalter
App: kind "elevation" nutzt generateElevationPlan (synchron, ohne WASM)
statt der Schnitt-Pipeline; Fallback-Hinweis bleibt bei fehlender Linie.
Oberleiste (ViewRibbonTab): Umschalter "Schatten", nur bei aktiver Ansicht
sichtbar, schreibt DrawingLevel.shadows. i18n-Keys in de/en.
2026-07-11 00:20:49 +02:00
karim ce728703f6 Ansicht: echter Flächen-Generator (Painter) + Schlagschatten-Kern
generateElevationPlan (toElevation.ts) baut aus projectToModel3d die
Fassadenansicht als gefüllte Flächen statt blossem Kanten-Durchlauf:
Wand-Aussenseiten + Decken-Stirnflächen auf die Ansichtsebene projiziert
(u entlang, v = Höhe), rückseiten-gecullt, vor der Ebene verworfen und per
Painter (fern->nah) emittiert. Fenster/Türen als Rahmen+Glas, Bodenlinie,
tiefengestaffelte Graustufen. Schlagschatten (45°, klassisch): auskragende
Slabs werfen ein geclipptes Schatten-Parallelogramm auf die Fassade dahinter.
Modellfeld DrawingLevel.shadows (additiv). Tests rein geometrisch.
2026-07-11 00:18:46 +02:00
karim 340f950f9c Schnittlinien-Werkzeug: Befehl sectionline/viewline setzt linePoints
Zwei Klicks (Start -> Ende) legen die Grundriss-Schnitt-/Ansichtslinie einer
DrawingLevel fest. Ziel: genau eine Platzhalter-Ebene ohne Linie wird belegt,
sonst neue Ebene (Schnitt B/C ...). Live-Vorschau mit Richtungspfeil, Art
(Schnitt/Ansicht) als Inline-Option umschaltbar. Registry-Aliase schnittlinie/
ansichtslinie, Ribbon-Gruppe 'Schnitte' im BIM-Tab, i18n de/en, Datenpfad-Tests.
2026-07-11 00:12:08 +02:00
karim 0e02c2b20f 2D-Schnitt = 3D: Wand-Terminierung (sliceTermination) auch im Vertikalschnitt
Nutzer-Report: 'Wand unten geschnitten' wirkte im 3D, aber nicht im 2D-Schnitt
— die Terminierung lief nur im layered-3D-Pfad (terminateOrSubtractSpans),
der Schnitt stanzte die Decke nur per Prioritäts-Subtraktion heraus (die Wand
tauchte darüber wieder auf).

Neu: applyWallTermination (Phase 1b in attachCutStyles) spiegelt die 3D-Regel
im (u,v)-Schnittraum: 'below' kappt jedes Wand-Band an der UNTERKANTE der
dominanten Decken-Bänder (strikt höhere joinPriority + u-Überlapp), 'above'
spiegelbildlich an der Oberkante; 'both'/undefined unverändert (heutige
Subtraktion). +6 Tests. 696/696 grün.
2026-07-10 23:59:31 +02:00
karim 355c1d4dd1 Dach im 3D anwählbar: Ray-Dreieck-Pick über die Dachflächen
Der 3D-Pick kannte nur Wand/Decke/Öffnung/Treppe — Dächer waren im
wgpu-Viewport nicht anwählbar (Nutzer-Report). Neu: pickGeometry liefert je
Dach die Dachflächen + Giebel als World-Dreiecke (fan-trianguliert wie
emitRoofs); pickNearest testet sie über das bestehende doppelseitige
rayTriangle (präzise geneigte Flächen statt Bounding-Prisma — kein Klick-
Diebstahl vor der Fassade dahinter). App-Routing: kind 'roof' →
setSelectedRoofIds (exklusiv, wie Treppe/Öffnung); damit greifen Highlight
und die Dach-3D-Griffe (4ef40a0) jetzt auch per 3D-Klick. +1 Test. 690/690.
2026-07-10 23:56:12 +02:00
karim c91b9ab1f2 PENDENZEN: Aussparungen, Dach-Aufsicht/-Schnitt, RoofType-Tab abgehakt 2026-07-10 18:52:33 +02:00
karim f248e2ae80 Dach im Vertikalschnitt: analytischer TS-Schnitt inkl. Schicht-Bändern
toSection behandelte Dächer bislang gar nicht (der Rust-Extraktor kennt nur
Wände + Decken). appendRoofSections schneidet die Schnittebene jetzt TS-seitig
mit den Dachflächen: Grundriss-Spur der Ebene per Cyrus–Beck gegen jedes
konvexe Aufsichts-Polygon der Dachflächen geclippt → u-Intervall; Oberkante
v(u) linear aus der Ebenengleichung (Newell), Unterkante um lotrechte Dicke /
cos(Neigung) tiefer. Mehrschichtige Dächer (roofLayers) werden wie beim
Decken-Schnitt in Bänder aussen→innen zerlegt, jedes mit Bauteil-Schraffur
(Papier-Neutralisierung wie resolveCeilingSectionStyle).

SectionComponentRef um 'roof' erweitert; attachCutStyles reicht Dach-Bänder
unverändert durch (Stile vorab aufgelöst, keine Boolean-Dominanz). Bewusst
nur SCHNITT-Polygone — Ansichts-/Silhouettenkanten des Dachs sind eine eigene
Phase. +4 analytische Tests. 689/689 grün.
2026-07-10 18:51:58 +02:00
karim 9a00c8e2f2 Dach 2D: Ansichts-Poché der Dachaufsicht aus der Eindeckungs-Schicht
Das Dach liegt über der Schnittebene und wird von oben gesehen → das Pick-
Polygon der Traufe-Fläche trägt jetzt die Ansichts-Schraffur (viewHatchId)
der ÄUSSERSTEN Dachtyp-Schicht (Eindeckung), analog dem Decken-Poché in der
Aufsicht. Ohne Dachtyp/viewHatchId weiterhin weiss. +1 Test. 685/685 grün.
2026-07-10 18:46:40 +02:00
karim 83dcdd0501 Decken-Panel: Aussparungen anzeigen + einzeln entfernen
Die Decken-Sektion listet die Aussparungen (BBox-Masse je Loch) mit
Entfernen-Knopf; Anlegen über den Befehl 'Deckenloch'. CeilingInfo.openings,
host.onRemoveCeilingOpening + App-Routing. 684/684 grün.
2026-07-10 18:44:13 +02:00
karim aee884639e Befehl 'Deckenloch': Aussparung als Rechteck in eine Decke zeichnen
Zwei Klicks (Ecke → Gegenecke, Live-Vorschau + Mass-HUD); die Aussparung wird
der Decke des aktiven Geschosses hinzugefügt, die das Rechteck vollständig
enthält (alle 4 Ecken im Umriss), sonst No-op. Aliase 'deckenloch'/
'aussparung', BIM-Ribbon-Eintrag mit eigenem Icon. Komplettiert die Decken-
Aussparungen (cdbef99) um den Zeichenweg. 684/684 grün.
2026-07-10 18:40:30 +02:00
karim cdbef992f3 Decken-Aussparungen: Treppenauge/Schacht in 2D und 3D (Brücken-Trick)
Ceiling.openings (37f4fe8) wird jetzt gerendert. Kern: mergeHoles() fügt
Löcher über schmale Brücken (1e-4) in den Aussenring ein → EIN einfacher
Polygonring, paritätskorrekt für SVG-Fill, Scanline-Hatch, Ear-Clipping
(render3d) und Punkt-in-Polygon-Pick — kein Loch-Support in den Primitiven
nötig. Loch-Orientierung gegenläufig (signedArea), Sichtbarkeitstest für die
Brücke, mehrere Löcher iterativ.

- 2D (addCeilingPoche): Poché-Ring mit Löchern; Brückenkanten via
  noStrokeEdges unsichtbar, Lochränder werden als Ringkanten gestrichen
  (wand-footprint-geclippt wie der Aussenumriss)
- 3D (emitSlabs): derselbe Ring für alle Schichten; Klick ins Loch trifft
  die Decke nicht (Pick paritätskorrekt)
- Ohne openings byte-identischer Codepfad (Regressionstests)
+11 Tests (Ring-Einfachheit, Shoelace-Flächen, 2-Loch-Fall, Regressionen).
684/684 grün. 3D visuell in Tauri zu prüfen.
2026-07-10 18:37:12 +02:00
karim 83abc1d87b Ressourcen: Dachtypen-Tab (Schicht-Editor wie Wand/Decke)
Dachaufbauten sind jetzt im Ressourcen-Manager anleg-/editier-/löschbar
(RoofStylesTab auf dem LayeredStylesTab-Rumpf, Ressource roofTypes):
Schichtliste mit Bauteil/Dicke/Fugen-Linienstil, Löschen geschützt solange
ein Dach den Typ referenziert. Host-/App-Handler (patch/add/deleteRoofType)
+ i18n de/en. Vervollständigt die Dach-Schichtlogik (3a986ec) um die UI.
2026-07-10 18:35:34 +02:00
karim b029ce0139 Objektinfo: Klasse (Kategorie) + Zeichnungsebene editierbar im Kopf
VW-Muster ('Klasse'/'Ebene'): der Objektinfo-Kopf zeigt statt des read-only
Kategorie-Codes zwei Dropdowns — Grafik-Kategorie und Zeichnungsebene
(Geschoss), beide direkt änderbar. Wirkt auf Wand/Decke/Dach/Treppe/Raum/
2D-Zeichnung/Stütze/Extrusion; Öffnungen ohne Ebenen-Dropdown (Ebene kommt
aus der Wirts-Wand), Extrusionen ohne Kategorie (kein categoryCode am Element).

- Selection.floorId (alle Builder), host.onSetSelectionCategory/-Level,
  App-Routing je Elementart (Store-Actions bzw. setProject für Stütze/Extrusion)
- 2D-Zeichnungen auf Nicht-Geschoss-Ebenen behalten ihre Ebene in der Liste
673/673 grün.
2026-07-10 18:31:11 +02:00
karim 37f4fe8947 Deckenmodell: Aussparungen (Ceiling.openings) — Treppenauge/Schacht/Durchbruch
Je Loch ein geschlossener Umriss innerhalb der Decken-Outline (additiv,
Alt-Projekte unverändert). Renderer-Konsum (2D-Poché-Loch + 3D-Slab-Loch)
folgt separat.
2026-07-10 18:23:36 +02:00
karim a8a1835b68 Fix: Detailgrad-Umschalter (grob/mittel/fein) auch in der 3D-Sicht aktiv
Der Umschalter war mit detailEnabled={planActive} in der Perspektive
deaktiviert — ein Relikt von vor der 3D-Verdrahtung (projectToModel3d bekommt
detail längst durchgereicht und re-pusht das Modell bei Änderung). Nutzer-
Report: 'Modelldarstellung funktioniert im 3D immer noch nicht'.
2026-07-10 18:22:49 +02:00
karim 897a2dcd43 Tür: Teilverglasung (glazingRatio) im 3D wirksam
DoorType.glazingRatio wurde bisher von keinem Renderer gelesen (Designdoc).
Bei Glasblatt (leafStyle 'glas') füllt das Glas jetzt nur den oberen
glazingRatio-Anteil des Hauptfelds; darunter bleibt ein blindes Blattfeld
(z. B. Tür mit Oberglas). Fenster unberührt (glazedFraction = 1). +1 Test
(Halbglas: Unterkante angehoben, Oberkante am Sturz). 673/673 grün.
2026-07-10 02:14:33 +02:00
karim db44317d95 PENDENZEN: BIM-Tiefe-Studie + Rest-Prioritäten (Decken-Aussparungen P0 usw.) eingetragen 2026-07-10 02:10:47 +02:00
karim c9baff58b0 Dach: getrennter Überstand Traufe/Ortgang (statt ringsum)
Bisher ein einziger overhang ringsum (Designdoc-Prio #2). Neu Roof.overhangGable
für den Ortgang (Giebelseite, entlang First); overhang gilt für die Traufe
(senkrecht zum First). Fehlt overhangGable, gilt ringsum overhang (rückwärts-
kompatibel). Geometrie mappt die Überstände je nach ridgeAxis auf die Outline-
Achsen. Panel: zweites Feld 'Überstand Ortgang' (ausser flach/zelt). +3 Tests.
672/672 grün.
2026-07-10 02:10:03 +02:00
karim 4319e12e35 Tür: zweiflügelige Türen wirklich rendern (leafCount 2D+3D)
DoorType.leafCount wurde bisher von keinem Renderer gelesen — eine zweiflügelige
Tür sah aus wie eine einflügelige (Designdoc-Prio #3).

- opening.ts: additive doorLeaves(wall, o, leafCount) — einflüglig ein Blatt
  voller Breite, zweiflüglig zwei halbbreite Blätter an gegenüberliegenden
  Pfosten, beide zur selben Seite (symmetrische Doppeltür). doorSymbol bleibt
  unverändert (Parität).
- generatePlan: Tür-Zweig zeichnet je Blatt Linie + Schwenkbogen (leafCount aus
  DoorType).
- toWalls3d: Tür-wingCount = leafCount → mittiger Anschlagpfosten (Stulp) im 3D.
- +1 Test (2 Blätter/Bögen, halber Radius). 669/669 grün.
2026-07-10 02:06:37 +02:00
karim 3a986ecd2f Dach-Schichtlogik: RoofType/Layer[] wie Decken (3D mehrschichtig)
Kernthema der BIM-Tiefe (Designdoc docs/design/bim-elements-depth-study.md, P0):
Dächer bekommen einen mehrschichtigen Aufbau analog WallType/CeilingType.

- Modell: RoofType { layers: Layer[] }, Project.roofTypes, Roof.roofTypeId
  (Roof.thickness bleibt einschichtiger Fallback). Resolver roofLayers()/
  roofTotalThickness() — wiederverwendet den bestehenden Layer-Typ.
- 3D (emitRoofs): je Schicht ein eigenes Mesh mit Bauteilfarbe, entlang der
  Flächennormalen unter die vorige gestapelt (Eindeckung aussen → Verkleidung
  innen). Ohne roofTypeId einschichtiger thickness-Slab (Default-Dachfarbe).
- UI: Dachtyp-Dropdown im Dach-Panel (leer = einschichtig). Default-Dachtyp
  'Steildach gedämmt' in sampleProject.
- +2 Tests (Schicht-Meshes/Stapelung; einschichtig = Slab+Giebel).

Offen (Designdoc): 2D-Schnitt-Poché der Dachschichten, RoofType-ResourceManager-
Tab, Traufe/Ortgang getrennt. 668/668 grün; 3D-Optik vom Nutzer in Tauri prüfen.
2026-07-10 02:03:55 +02:00
karim e99bb2413f Dach 3D: echte Materialdicke (solide Slabs) statt papierdünner Flächen
roof.thickness war im 3D bislang ein totes Feld — Dächer waren einseitige
Flächen. emitRoofs baut jede Dachfläche jetzt als soliden Slab: Oberseite +
um thickness entlang der Flächennormalen (Newell) nach unten versetzte
Unterseite + umlaufende Seitenwände. Giebel bleiben dünne Endfüllung.
Basis für die kommende Dach-Schichtlogik. +1 Test (Unterseite unter der
Oberfläche); Walm-Zähltest auf Slab-Dreiecke aktualisiert. 667/667 grün.
Visuelle 3D-Abnahme in der Tauri-App durch den Nutzer.
2026-07-10 01:58:15 +02:00
karim bfb80b363b Dach: Mansarden-Untertypen (Giebel/Walm/Zelt) + parametrierbarer Knick
Bisher gab es nur die Giebel-Mansarde (2-seitig). Neu über Roof.mansardType:
- 'giebel' (Default): Mansard-Satteldach, Giebel an den Enden (wie bisher)
- 'walm': allseitige Mansarde (Sockel + oberer Walm, kein Giebel) — 8 Flächen
- 'zelt': allseitige Mansarde zur flachen Spitze — 8 Flächen, kein First
Knicklage jetzt parametrierbar (Roof.mansardKneeRatio, Default 0.4 der halben
Spannweite). Dach-Panel: Mansard-Art-Dropdown + Knicklage-Feld (nur bei Mansarde).
+4 Geometrie-Tests (Ratio, Walm-/Zelt-Flächen/Grate).

Nebenbei: Fenster-Dialog-Labels klarer als 'Einbaulage ab / Abstand von Kante'
(Nutzer: wo sitzt das Fenster in der Aussparung, ab Innen-/Aussenkante + wieviel).
666/666 grün.
2026-07-10 01:53:55 +02:00
karim 9a38636bf2 Fenster: echte Schachtelung Blendrahmen → Flügelrahmen → Glas (2D+3D)
Öffenbare vs. feste Flügel sind jetzt unterscheidbar, und die Einbaulage
(Schichteinzug) wirkt auch im 2D-Grundriss.

2D (windowSymbol/generatePlan):
- Der reiche Pfad (typisiertes Fenster) zeichnet den Blendrahmen an der REALEN
  Einbaulage (insetFace/insetFromFace, vorher ignoriert), je Flügel einen
  Flügelrahmen (window-sash) nur wenn ÖFFENBAR, und das Glas darin. Feste
  Flügel: Glas direkt im Blendrahmen.
- Die alte Diagonale quer über die Box (sah aus wie durchgestrichen) entfernt —
  Öffenbarkeit wird jetzt über den Flügelrahmen ausgedrückt.
- Alt-Pfad (kein Typ) byte-identisch → kernel2d-Parität unberührt.

3D (frameMeshesForOpening/resolveOpeningFrame):
- Je öffenbarem Flügel ein Flügelrahmen-Ring (4 Riegel) im Blendrahmen (fein).
- Flügeltabelle konsistent aus explizitem sashes bzw. effektivem wingCount
  (Instanz-Override wirkt jetzt auch auf Flügelrahmen); wingCount daraus abgeleitet.

Tests angepasst/ergänzt (fest vs. öffenbar, Zählungen). 663/663 grün.
2026-07-10 01:47:27 +02:00
karim d131683304 Sonnenschutz 3D: Rollladenkasten als Box über der Öffnung
emitOpeningShading zeichnet für Fenster mit shading.create eine helle 3D-Box
über der Öffnungs-OK, an der Aussenfläche ansetzend und um box.depth hinaus,
box.height hoch, box.width breit (0 ⇒ Öffnungsbreite). Nur mittel/fein.
Vervollständigt das Sonnenschutz-Feature (2D-Kontur war da, 3D-Box war in der
Studie 'später'). +3 Tests (Box-Primitive/Position, Leerfälle, grob).
3D visuell vom Nutzer in der Tauri-App zu prüfen. 662/662 grün.
2026-07-10 01:23:39 +02:00
karim 82d5710ddb Fenster-Dialog: Oberlicht + Kämpfer-/Sprossenzeilen exponiert
Zwei vom Renderer bereits konsumierte Felder im Flügel/Sprossen-Panel
zugänglich gemacht + in der Live-Ansicht dargestellt:
- transomHeight (Oberlicht): 2D-Kämpferlinie im Grundriss + 3D-Oberlichtscheibe
  waren schon da; Kämpferlinie jetzt auch in der Dialog-Vorschau
- mullionRows (Kämpfer-/Sprossenzeilen): 3D-Kämpfer bei 'fein'; waagrechte
  Teiler in der Vorschau

Kein Renderer-Aufwand — reine UI-/Vorschau-Freischaltung. 659/659 grün.
2026-07-10 01:20:40 +02:00
karim e3cdc86f46 PENDENZEN: Fenster-Editor + Dach-3D-Griffe + Messwert-Fix als erledigt eingetragen 2026-07-10 01:18:31 +02:00
karim 2e13ec3097 Fenster-Renderer: Flügeleinteilung, Öffnungslinien, Verglasung, Rollladenkasten
Der 2D/3D-Renderer konsumiert die neuen Fenster-Modellfelder (VW-Studie §6):

- opening.ts: windowSymbol nimmt die Flügeltabelle (sashes); Trennlinien aus
  Flügel-/Pfostenbreiten statt gleichmässiger wingCount-Teilung + Öffnungs-
  andeutung (Diagonale zur Bandseite) je nicht-festem Flügel. Ohne sashes
  exakt das bisherige Verhalten (Regressionssicherheit).
- generatePlan: glazingPanesOf steuert die Glaslinien-Anzahl (mittel/fein),
  grob bleibt bei einer; gestrichelte Rollladenkasten-Kontur über der Öffnung.
- toWalls3d: glassPanesForOpening zeichnet glazingPanes dünne, in Wandrichtung
  versetzte Scheiben mit Luftraum (Ein-/Zwei-/Dreifachverglasung); panes=1 =
  exakt die alte Einzelscheibe.

Verhaltensänderung: window-standard (zweifach) zeigt 3D nun 2 Scheiben statt
einer — 2 Bestandstests entsprechend angepasst (+ Nicht-Überlappungs-Check).
+13 Primitiv-Tests (generatePlan.windowFields, toWalls3d). 659/659 grün.
2026-07-10 01:17:35 +02:00
karim c734802555 Fenster-/Tür-Einstellungsdialog: reicher Editor mit Live-Ansicht + Stil speichern
Das ⚙ der Öffnungs-Sektion öffnet neu einen dedizierten Dialog (VW-Studie §3)
statt in den Ressourcen-Tab zu springen. Drei Zonen: Kategorie-Sidebar,
Parameter-Panel, Live-2D-Frontalansicht (aus den aktuellen Werten gezeichnet:
Rahmen/Flügel/Öffnungslinien/Verglasung/Rollladenkasten).

- OpeningEditorDialog.tsx: Kategorien Basis/Grösse/Rahmen/Flügel/Sonnenschutz
  (Fenster) bzw. Türblatt (Tür); Flügeltabelle (Flügel/Pfosten + Öffnungsart +
  Anschlag je Zeile); Stil-Leiste mit Stilwahl + 'Als Stil speichern …'
- App: openingEditorId-State, Dialog-Render, saveOpeningStyle (klont aktuellen
  Typ als neuen benannten WindowType/DoorType und weist ihn zu)
- host.onOpenOpeningEditor + OpeningInfo.id für den ⚙-Sprung
- Studie docs/design/window-editor-vectorworks-study.md
- Dialog-CSS (.oed-*) + i18n (de/en)

Renderer-Konsum der neuen Felder (sashes/glazingPanes/shading in 2D/3D) folgt separat.
2026-07-10 01:13:15 +02:00
karim 1bbe814ab1 Fenstermodell: SashDef-Flügeleinteilung + Rollladenkasten + render-wirksame Verglasung
Additive P0-Felder für den reichen Fenster-Editor (VW-Studie §4):
- SashDef[] (Flügel/Pfosten mit Breite, Öffnungsart, Anschlag je Flügel) +
  WindowType.sashes — Vorrang vor wingCount, sonst gleichmässig abgeleitet
- WindowType.shading (Rollladen-/Sonnenschutzkasten mit Kastenmassen)
- WindowType.glazingPanes (1/2/3) — Verglasung endlich render-wirksam
- Helfer sashesOfWindowType() (wingCount→SashDef-Fallback) + glazingPanesOf()

Alt-Projekte laden unverändert (alle Felder optional).
2026-07-10 01:04:29 +02:00
karim 4ef40a02c4 Dach-3D-Griffe: Eckpunkt-Resize + Verschieben + First-Griff für Neigung
Gewählte Dächer bekommen im wgpu-Viewport Ziehgriffe wie Wände/Decken:
Eckpunkte (achsparalleles Resize, Gegenecke fix), Verschiebe-Griff im
Schwerpunkt und ein First-Griff am höchsten Dachpunkt — vertikales Ziehen
rechnet aus der neuen First-Höhe die Dachneigung (behält die Firstlage).

- projectSlice: moveRoofGrip/moveRoofBy (coalescing pro Dach)
- Wasm3DViewport: roofpitch-Griff (Render + vertikaler Drag auf Apex-Achse)
- App: edit3dRoofs + roofId-Routing in onEdit3dVertex/Body + onEdit3dRoofPitch
- Viewport3D: editRoofs/onEditRoofPitch durchgereicht
- Tests: roofGrips.test.ts (Resize/Verschieben/Coalescing)
2026-07-10 01:02:47 +02:00
karim 9b6dd80d39 Fix: Messwerte erscheinen im Objekt-Info (Live-Injektion statt eingefrorenem Memo)
Das Objekt-Info-Panel zeigte die Live-Messwerte nie an: `measurement` lag im
memoisierten `baseHost`, dessen Dep-Liste `draft` bewusst NICHT enthält (sonst
würde der ganze Host bei jeder Mausbewegung neu gebaut) → der Wert blieb auf
dem Anfangswert (null) eingefroren. Fix: `measurement` nicht mehr im Memo,
sondern in `hostWithMode` bei jedem Render frisch injiziert (draft?.measure).
tsc + vitest grün.
2026-07-10 00:44:06 +02:00
karim a409e8e6b4 Doku: Dach-Feature komplett (Auswahl/Editieren/Highlight/Resize) 2026-07-10 00:38:02 +02:00
karim 826685ceaf Dach: Grundriss-Masse (Breite/Tiefe) im Panel editierbar
Bisher liess sich der Dach-Grundriss nach dem Platzieren nicht mehr in der
Grösse ändern. RoofSection bekommt Breite/Tiefe-Felder, die das Umriss-
Rechteck neu bilden (untere/linke Ecke bleibt fix). RoofInfo trägt
width/depth/minX/minY. tsc + vitest grün.
2026-07-10 00:37:32 +02:00
karim 64f61797d8 Dach: Auswahl-Hervorhebung auch im 3D (Draht-Umriss der Dachflächen)
selectionHighlightLines bekommt roofIds und emittiert die Kanten aller
Dachflächen + Giebel (roofGeometry, Modell→World) als Akzent-Draht — ein
selektiertes Dach leuchtet jetzt im wgpu-Viewport wie Wand/Decke. App reicht
selectedRoofIds durch. +1 Test. tsc + vitest grün.
2026-07-10 00:35:26 +02:00
karim eaf57e2292 Dach: Auswahl-Hervorhebung im 2D-Grundriss (Traufe-Umriss)
Ein selektiertes Dach wird jetzt im Grundriss mit der Akzentkontur markiert
(highlightRoofPolys über das Traufe-Pick-Polygon), analog Decke/Raum —
selectedRoofIds von App durch die Content-Sichten an PlanView durchgereicht.
tsc + vitest 642 grün.
2026-07-10 00:33:35 +02:00
karim 7cfdf59c29 Dach: Traufhöhe editierbar + Abwählen an allen Reset-Stellen
- RoofSection bekommt ein Traufhöhe-Feld (Roof.baseElevation, absolutes Z) —
  z. B. um das Dach für einen Kniestock über die Geschoss-Oberkante zu heben.
  RoofInfo.baseElevation trägt den aufgelösten Wert (Override oder Default).
- Dach-Auswahl wird jetzt an ALLEN Selektions-Reset-Stellen mitgeleert
  (Kontextmenü-/Ebenen-Operationen), nicht nur bei Klick/Geschosswechsel/
  Marquee/Delete — kein Geister-Dach mehr in der Auswahl.
tsc + vitest grün.
2026-07-10 00:30:21 +02:00
karim 8cfe01837e Doku: Dach-Auswahl/-Editieren erledigt; Fenster-2D/3D-Fixes 2026-07-09 22:19:33 +02:00
karim 80121a3f9a Dächer anwählbar + editierbar + löschbar
Platzierte Dächer sind jetzt vollwertige Auswahl-Elemente (analog Decke):
- 2D-Auswahl per Klick: unsichtbares Traufe-Pick-Polygon (roofId) +
  pickRoof (Punkt-in-Polygon, niedrigste Priorität) in PlanView.
- Auswahl-Kanal selectedRoofIds (selectionSlice), updateRoof (projectSlice),
  RoofInfo + roofSelection + deriveSelection-Dispatch (selectionInfo).
- Attribut-Panel „Dach": Form (Sattel/Walm/Pult/Mansarde/Zelt/Flach),
  Firstrichtung X/Y, Neigung(en), Überstand, Dachdicke — live editierbar über
  host.onSetRoofPatch; dazu abgeleitete Firsthöhe + Grundfläche (read-only).
- Löschen (Delete) + Abwählen bei Geschosswechsel/Marquee/anderer Auswahl.
+2 Tests (Roof-Selection). tsc + vitest 642 + vite build grün.
2026-07-09 22:18:59 +02:00
karim 89112eff9b Fenster: 2D-Doppelrahmen entfernt + 3D-Scheibe an die Aussenfläche
Nutzer-Feedback „Fenster im 2D nicht korrekt / im 3D zu schwach":
- 2D: das Rahmenband (opening-frame) wurde beim Fenster ZUSÄTZLICH zum
  windowSymbol-Rahmen gezeichnet → doppeltes, verschachteltes Rechteck.
  Das Band gilt jetzt nur noch für Türen (deren Symbol nur Blatt+Bogen hat);
  Fenster nutzen allein windowSymbol (Rahmen + Glaslinien). Oberlicht-Andeutung
  (opening-transom) bleibt für beide.
- 3D: die Scheibe sass mittig tief im Reveal und wirkte schwach; sie sitzt
  jetzt knapp (3 cm) hinter der Aussenfläche → klar sichtbar, Laibung zeigt
  nach innen.
Tests angepasst (Band jetzt tür-spezifisch, Einzug am Tür-Zargenband geprüft).
tsc + vitest 640 grün.
2026-07-09 22:03:39 +02:00
karim 7b22f8fdf7 Text-Marks-Test: textDrawing-Helfer (umgeht Union-Verengung, tsc sauber)
Nachzügler-Fix zum Text-Styling-Test: das inline-gespreizte marks-Feld
verengte die Drawing2DGeom-Union und liess tsc meckern; ein Helfer baut das
Text-Drawing typkorrekt. Reine Testdatei.
2026-07-09 21:16:08 +02:00
karim 38bc36d36d Doku: Dächer-Grundfeature + Fenster/Tür-3D-Fixes; Folge-Item Dach-Auswahl 2026-07-09 21:15:10 +02:00
karim 31f2d63362 Dächer: Platzier-Werkzeug + 2D-Plan + 3D-Flächen + Demo-Dach
Dach-Feature end-to-end nutzbar:
- Befehl „Dach" (Alias dach/rf, BIM-Ribbon, Satteldach-Icon): Grundriss als
  Rechteck aufziehen; die Dachform (Sattel/Walm/Pult/Mansarde/Zelt/Flach)
  wählt man als Inline-Option der Befehlszeile vor/während des Aufziehens.
  First entlang der längeren Seite (Standard).
- 2D-Grundriss (generatePlan): Traufe/First/Grat (Walm/Zelt) + gestrichelte
  Knicklinien (Mansarde), Farbe aus roof.color/Kategorie „31 Dächer".
- 3D (toWalls3d emitRoofs): Dachflächen + Giebel als terrakottafarbene
  Meshes (Fan-Triangulierung, Mansarde-Fünfeck sauber).
- Seed: Demo-Satteldach RF1 über dem Baukörper (OG, 5×4, Überstand 0.4).
- i18n de/en für Befehl + Formen.

+10 Tests (2D 7, 3D 3). tsc + vitest 640 grün. Auswahl/Attribut-Editieren
placierter Dächer folgt separat.
2026-07-09 21:13:11 +02:00
karim 1195d2a054 Dach-Modell + Geometrie-Kern (Flach/Pult/Sattel/Walm/Mansarde/Zelt)
Roof-Element (rechteckiger Grundriss + Form/Neigung/Überstand/Firstrichtung)
und Roof/RoofShape im Modell (Project.roofs). Reine Geometrie geometry/roof.ts:
roofGeometry(roof, eavesZ) liefert Grundriss-Linien (Traufe/First/Grat/Knick)
UND die 3D-Dachflächen (+ Giebel) für alle sechs Formen — gerechnet auf der
Bounding-Box (First entlang X/Y, „y" per Transponierung). roofBaseElevation
löst die Traufhöhe auf (Geschoss-Oberkante als Default).
+9 Tests (Firsthöhe/-lage, Flächen-/Grat-/Knick-Zahl je Form, Transponierung,
Überstand). Reine Modell-/Datenschicht; 2D/3D/Werkzeug folgen. tsc + vitest grün.
2026-07-09 21:04:53 +02:00
karim 89e737b25e Platzierte Öffnungen bekommen Standard-Bauteiltyp (Rahmen sofort sichtbar)
Frisch mit dem Fenster-/Tür-Werkzeug platzierte Öffnungen erhielten keinen
typeId → resolveOpeningFrame lieferte null → weder 2D-Rahmenband noch
3D-Rahmen/Laibung wurden gezeichnet (nur Loch + flache Scheibe). Das war
der Grund, warum die vertiefte Fenster-/Tür-Darstellung „im 3D gar nicht
so aussah". Fix: appendOpening weist den ersten Fenster-/Türtyp der
Bibliothek zu (Fenster→windowTypes[0], Tür→doorTypes[0]); ohne Bibliothek
bleibt das typlose Inline-Verhalten.
2026-07-09 21:00:25 +02:00
karim 4beae72eea Fenster/Tür 3D: tiefer, Detailgrad im 3D, kein Fenster→Tür-Umschalten
Nutzer-Feedback:
- Fenster wirkten im 3D flach: die Rahmen-Einbautiefe wurde fälschlich aus
  frameThickness (Profil-Breite, ~6 cm) statt aus der Wanddicke bestimmt.
  Jetzt füllt der Rahmen die VOLLE Wanddicke (tiefe Laibung), die Scheibe
  ist eine dünne Tafel mittig darin → plastische Fenster mit sichtbarer
  Laibung. frameDepth übersteuert weiterhin.
- Detailgrad grob/mittel/fein wirkt jetzt auch im 3D: grob = nur Loch +
  Scheibe, mittel = Rahmen (+Oberlicht-Kämpfer), fein = zusätzlich
  Flügel-Mittelpfosten/Kämpfer-Sprossen. Durchgereicht App → Viewport3D →
  Wasm3DViewport → useWasm3dRenderer → projectToModel3d({detail}).
- Fenster↔Tür-Umschalter im Objekt-Info entfernt (nur noch Anzeige): die
  Gattung wird beim Platzieren festgelegt, nicht nachträglich getauscht.

+1 Test (Detailgrad-Gating grob/mittel/fein). tsc + vitest grün.
2026-07-09 20:59:21 +02:00
karim 586c1c99bf Öffnung: ⚙-Knopf springt in den Tür-/Fenstertyp-Editor
Discoverability für die vertieften Bauteil-Typen: neben dem Typ-Dropdown
einer gewählten Öffnung öffnet ein ⚙-Knopf das Ressourcen-Fenster direkt
beim passenden Typeditor-Tab (Tür→doorStyles, Fenster→windowStyles).
- ResourceManager: initialTab-Prop (springt auch bei bereits offenem
  Fenster auf den angeforderten Tab).
- host.onEditOpeningType(kind) + App-Wiring (resourcesTab-State).
tsc + vitest 620 grün.
2026-07-09 01:44:13 +02:00
karim 4ac2ecb504 Doku: Session 2026-07-09 (Tür/Fenster tief, Messen, Schichttrennlinie, Text-Styling, Decken-3D-Griffe) 2026-07-09 01:40:54 +02:00
karim 973ac6d04f Decken-Griffe im 3D: Eckpunkte + Kanten-Mittelpunkte (wgpu-Viewport)
Gewählte Decken bekommen im wgpu-3D-Viewport editierbare Griffe — analog
Wand/2D-Element:
- Eckpunkt-Griffe (Umriss-Vertices) → moveCeilingGrip.
- Je Seite ein Kanten-Mittelpunkt-Griff (rautenförmig, eigene Farbe) →
  moveCeilingEdge (Nutzer-Wunsch „an allen seiten auch ein punkt").
- Verschiebe-Griff im Schwerpunkt → moveCeilingBy.
Alle drei Store-Actions existierten bereits (2D-Griffe); neu ist nur die
3D-Griffgeometrie (computeGrips), das Kanten-Drag (GripDrag „edge") und die
Prop-Verdrahtung Wasm3DViewport ← Viewport3D ← App (editCeilings/onEditEdge,
Griff-Ziel um ceilingId erweitert). Die three.js-Sicht bleibt unverändert
(Default ist wgpu). tsc + vitest 620 grün.
2026-07-09 01:39:17 +02:00
karim d0b9d22141 Text-Styling-Leiste wirkt auf selektierten Freitext/Textspalte
Die Text-Formatier-Gruppe der Oberleiste formatierte bisher nur den
Raum-Stempel. Jetzt ist auch ein selektierter Freitext/Textspalten-Element
(Drawing2D shape "text") ein Formatier-Ziel:
- Neues optionales Feld Drawing2D-Text.marks (Marks) für einheitliche
  Formatierung (Schriftfamilie, fett/kursiv/unterstrichen, Farbe).
- App.textTarget adaptiert den Text über docFromText/plainText an die
  Rich-Text-Leiste; die GRÖSSE bleibt bewusst über die Modell-Höhe (Meter),
  nicht über pt (massstabslos wäre falsch) — sizePt wird verworfen.
- generatePlan/PlanView rendern die Marks (fontFamily/-weight/-style/
  textDecoration); marks.color hat Vorrang vor der Kategorie-Farbe.
+3 Tests. tsc + vitest grün.
2026-07-09 01:28:37 +02:00
karim 938d6421f9 Schichttrennlinie als Wand-Referenzlinie (mehrschichtige Wände)
Bei mehrschichtigen Wänden lässt sich neben Aussen/Mitte/Innen jetzt auch
eine SCHICHTTRENNLINIE (Fuge zwischen zwei Schichten) als Referenzlinie
wählen — z. B. die Achse auf die Innenkante der Aussenschale legen.
- Neues Feld Wall.referenceOffset (freier Achsversatz entlang +n); wenn
  gesetzt, übersteuert es referenceLine. wallReferenceOffset bleibt die
  EINE Quelle → Grundriss, Schnitt und 3D erben das Verhalten automatisch.
- Object-Info-Panel: das Referenzlinien-Dropdown listet zusätzlich je
  interne Fuge einen Eintrag (Kürzel der angrenzenden Schichten); Auswahl
  setzt den Versatz, Aussen/Mitte/Innen löscht ihn wieder.
- selectionInfo berechnet die Fugen-Versätze (kumulierte Schichtdicken).
+3 Tests. tsc + vitest grün.
2026-07-09 01:21:28 +02:00
karim 9a65900377 Tür/Fenster 3D: Rahmen, Sprossen/Kämpfer, Oberlicht, Schichteinzug als Mesh
Emittiert die vertieften Tür-/Fensterparameter als wgpu-Geometrie:
- emitOpeningFrames: Rahmenriegel (Laibung + Sturz + Sohlbank),
  wingCount−1 senkrechte + mullionRows−1 waagrechte Sprossen, Kämpferbalken
  bei transomHeight>0. Rahmenfarbe hell-warmgrau [0.85,0.85,0.83].
- emitOpeningGlass erweitert: typisierte Öffnungen bekommen einzugs-/
  oberlicht-bewusste Scheiben (Haupt- + Oberlichtscheibe), typlose behalten
  die alte zentrierte Scheibe (Rückwärtskompatibilität).
- Schichteinzug: Rahmen/Glas entlang der Wandnormale ab der gewählten
  Fläche (aussen/innen) um insetFromFace versetzt, auf Wanddicke geklemmt.
+5 Tests. tsc sauber, vitest 614/614 grün.
2026-07-09 01:15:59 +02:00
karim 142ba7ec20 Tür/Fenster 2D: Rahmenband, Blockrahmen, Schichteinzug, Oberlicht-Linie
Setzt die vertieften Tür-/Fensterparameter im Grundriss-Schnitt um:
- opening-frame: Rahmenprofil als Band der Breite frameWidth in der
  Laibung; Blockrahmen (Tür) als kräftigeres, vor die Laibung gesetztes
  Profil, Zarge als schmales Laibungsband.
- Schichteinzug: die gewählte Fläche (aussen/innen) wird um insetFromFace
  nach innen versetzt (Nahkante wandert, Fernkante bleibt).
- opening-transom: gestrichelte Überkopf-Linie über der Öffnung, wenn
  transomHeight > 0.
Typlose Öffnungen zeichnen unverändert (Rückwärtskompatibilität getestet).
+5 Tests. tsc sauber, vitest grün.
2026-07-09 01:14:16 +02:00
karim 492e1f811a Mess-Werkzeug: Polygonzug mit Fläche + Live-Werte im Objekt-Info-Panel
Das Mess-Werkzeug wird von der reinen Strecke zum Polygonzug:
- Jeder Klick setzt einen weiteren Stützpunkt; angezeigt werden letztes
  Segment (Länge · Winkel), aufsummierte Pfadlänge und — ab 3 Ecken —
  die umschlossene Fläche (Gauss).
- Die Messwerte erscheinen dauerhaft im Objekt-Info-Panel (neuer
  Abschnitt „Messung"), nicht nur flüchtig am Cursor-HUD. Kanal:
  ToolDraft.measure → PanelHost.measurement → ObjectInfoPanel.
- Rechtsklick/Enter beendet den aktuellen Pfad und armiert sofort einen
  neuen (mehrere Messungen nacheinander); Rechtsklick auf leerem Pfad
  bzw. Esc beendet das Werkzeug.

+4 Tests (Segment/Winkel, Summe/Fläche, Ketten-Verhalten). tsc sauber.
2026-07-09 01:13:15 +02:00
karim dbe7d374cd LICENSE: offizieller AGPL-3.0-Lizenztext (von gnu.org)
README verweist auf die Lizenz; der vollständige, wörtliche FSF-Text
(AGPL-3.0, 2007-11-19) wird als LICENSE-Datei ergänzt.
2026-07-09 01:05:28 +02:00
karim fa40429e63 Tür/Fenster-Typmodell vertiefen: Rahmenart, Rahmenbreite, Schichteinzug, Oberlicht, Kämpfer
Erweitert DoorType/WindowType um architektonische Detailparameter und
verdrahtet sie im ResourceManager-Typeditor (Tür-/Fenstertypen):
- Tür: frameKind (Zarge/Blockrahmen), frameWidth (Ansichtsbreite Profil),
  insetFromFace + insetFace (Schichteinzug ab Aussen-/Innenfläche),
  transomHeight (Oberlicht über Kämpfer).
- Fenster: mullionRows (horizontale Kämpfer-Teilung), frameWidth,
  insetFromFace + insetFace, transomHeight.
- Seeds: Haustür „Blockrahmen (Oberlicht)" + Fenster „2-flügl. + Oberlicht"
  demonstrieren Einzug/Oberlicht/Kämpfer.
- i18n de/en für alle neuen Felder + Optionen.

Reine Modell-/UI-Schicht; 2D- und 3D-Darstellung folgen separat. Alle
Felder optional → rückwärtskompatibel. tsc sauber.
2026-07-09 01:03:05 +02:00
karim 35299307d6 Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00
karim 889cbb2c12 Wandtyp-Kürzel: Component.abbrev + wallTypeLabel() + Kürzel-Feld im ResourceManager; Geschoss-Dropdown schmal 2026-07-05 23:16:38 +02:00
245 changed files with 54133 additions and 4416 deletions
+4
View File
@@ -11,3 +11,7 @@ scripts/*.png
# Editor / OS
.DS_Store
*.log
# Generierte native-Viewport-Szenen (aus sampleProject via scripts/dump-native-scene.mjs)
src-tauri/assets/native2d_scene.json
src-tauri/assets/native3d_walls.json
+297 -359
View File
@@ -1,452 +1,390 @@
# Architektur — Standalone Browser-BIM (cad)
# Architektur — Dossier (Desktop-CAAD)
> Stand: 2026-06-29
> Vision, Phasen und Backlog: [ROADMAP.md](ROADMAP.md). Konventionen: [CONVENTIONS.md](CONVENTIONS.md).
> Detail-Designs: [docs/design/elements.md](docs/design/elements.md) ·
> [docs/design/plans-output.md](docs/design/plans-output.md) ·
> [docs/design/resources-graphics.md](docs/design/resources-graphics.md).
> Stand: 2026-07-21 (grundlegend überarbeitet — siehe [STATUS.md](STATUS.md) für
> die volle Bestandsaufnahme inkl. Mist-Liste, die diese Überarbeitung begründet).
> Vision/Phasen (historisch, Tag-1-Stand): [ROADMAP.md](ROADMAP.md). Konventionen:
> [CONVENTIONS.md](CONVENTIONS.md). Detail-Designs (teils ebenfalls veraltet,
> siehe Hinweis in [docs/README.md](docs/README.md)): [docs/design/](docs/design/).
Dieses Dokument beschreibt, **wie** die Standalone-Browser-App gebaut wird und
wie sie die Konzepte des DOSSIER-Rhino-Plugins in Browser-Äquivalente übersetzt.
DOSSIER ist ein Rhino-8-Plugin (Python + React-WebView); `cad` ist die *eigen­
ständige* Browser-Variante: kein Rhino-Document, kein `doc.Strings`, kein
IronPython — stattdessen ein **eigenes typisiertes Datenmodell in TypeScript**,
gerendert über **Three.js** (3D) und **SVG** (Plan), gespeichert als **JSON-Datei**.
Dieses Dokument beschreibt, **wie** Dossier tatsächlich gebaut ist — nicht wie
es am ersten Tag geplant war. Dossier ist die eigenständige Neuimplementierung
des Rhino-Plugins [DOSSIER](https://git.openbureau.ch/karim/dossier): dieselbe
Denkweise (Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile,
Prioritäts-Verschneidung), aber als **native Desktop-App** mit einem **eigenen
typisierten Datenmodell in TypeScript** und **zwei eigenen Rust/WASM-Rendering-
Engines** („Nordstern") statt Rhino-Dokument/IronPython.
Alle Bezeichner im Code sind **englisch** (Vectorworks-Terminologie); Prosa und
UI-Texte sind deutsch. Einheiten intern in **Metern**.
**Desktop-Rahmen (plattformabhängig, wegen WebGPU):** auf **macOS Tauri**
(WKWebView unterstützt WebGPU), auf **Linux Electron/Chromium** (Tauris
Linux-Webview WebKitGTK unterstützt WebGPU nicht zuverlässig — die
render2d/render3d-Engines brauchen es). Beide teilen dieselbe React-App und
randlose Titelleiste; Laufzeit-Erkennung über `window.__TAURI__` bzw.
`window.dossierWindow` (Electron-`contextBridge`). Details: STATUS.md §2.7.
Alle Bezeichner im Code sind **englisch**; Prosa und UI-Texte sind deutsch.
Einheiten intern in **Metern**.
---
## 0. Leitprinzip — ein Modell, viele Darstellungen
Das semantische Gebäudemodell ist die **einzige Wahrheit**. Jede Sicht (3D,
Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** (`derive`) daraus.
Das semantische Gebäudemodell (`Project`) ist die **einzige Wahrheit**. Jede
Sicht (3D, Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** daraus.
Darstellung (Detailgrad, Stile, Schraffuren, Overrides) wird **beim Rendern**
angewandt, nie in die Geometrie eingebacken.
angewandt, nie in die Geometrie eingebacken. Dieses Prinzip hat sich über drei
Wochen und ~125.000 Zeilen Code bewährt und wird strikt gehalten — es ist der
einzige Teil der ursprünglichen Architektur-Vision, der **unverändert** Bestand
hat. Alle konkreten Technologie-Entscheidungen darunter (Rendering-Engine,
State-Store, Schnitt-Mechanismus) sind anders gelaufen als am Tag 1 geplant;
Details dazu in [STATUS.md](STATUS.md) §5.
```
┌──────────────────────────────────────────┐
│ Project (semantisches Modell, JSON) │ ← einzige Wahrheit
│ resources · types · designLevels ·
│ layers · elements
│ resources · types · drawingLevels · │
│ layers · walls/doors/openings/stairs/…
└──────────────┬───────────────────────────┘
│ pure derive()
┌───────────────────────┼────────────────────────────┐
▼ ▼ ▼
Scene3D (Three.js) PlanModel → SVG SectionModel → SVG
buildScene() generatePlan() generateSection() (HLR)
│ │ │
└────────── beide lesen dieselben joins/components/styles ──────────┘
┌───────────────────────┼────────────────────────────┬───────────────
▼ ▼ ▼
3D-Viewport 2D-Plan (generatePlan) 3D-Live-Schnitt Export
Viewport3D (three.js) PlanView (SVG) · glPlan (GL2) render3d/section IFC/DXF/
ODER Wasm3DViewport ODER render2d (Rust/WGSL) (Rust, analytisch)PDF/STL
(Rust/wgpu, Default)
│ │ │ │
└────────── alle lesen dieselben joins/components/styles ─────────────┘
```
Das steht im Spike bereits: `generatePlan.ts` und `Viewport3D.tsx` extrudieren
**dasselbe** gehrte Band-Polygon (`clippedBand`). Diese Symmetrie ist der Kern
und wird beim Ausbau strikt gehalten.
---
## 1. Repo-Struktur (Ziel)
Wächst aus dem heutigen `src/` (model/plan/viewport/ui). Module sind **klein und
fachlich geschnitten** — wir vermeiden bewusst den `elemente.py`-Monolithen
(7244 LOC) aus DOSSIER (dort als Schwachstelle #4.1 dokumentiert).
## 1. Repo-Struktur (IST-Zustand)
```
src/
model/
types.ts // Project, DesignLevel, LayerCategory, Element-Union (existiert)
geometry.ts // 2D/3D-Mathe ohne Kernel (existiert)
joins.ts // Wand-Verschneidung (Gehrung; später Prio-T/X) (existiert)
sampleProject.ts // Demo-Haus (existiert)
project.ts // Factory, Defaults, Migrationen
selectors.ts // abgeleitete Reads (visibleCodes, elementsOnLevel…)
ids.ts // newId(prefix) — UUID-Erzeugung
elements/ // pro Bauteil ein Modul (Daten + Generierung)
wall.ts opening.ts slab.ts stair.ts roof.ts structure.ts space.ts
resources/
componentManager.ts hatchManager.ts lineManager.ts symbolLibrary.ts
overrides.ts // regelbasierte Engine (Condition → Action)
store/
store.ts // Zustand-Store: { project, ui }, Aktionen, Undo/Redo
persistence.ts // Datei save/load (JSON), Autosave (IndexedDB)
history.ts // Undo/Redo-Ring
plan/
generatePlan.ts // Grundriss aus Footprint + Symbolik (existiert)
generateSection.ts // Schnitt/Ansicht aus 3D-Projektion (HLR-Worker)
PlanView.tsx // SVG-Renderer + Pan/Zoom/Grips (existiert)
primitives.ts // Primitive-Union, SVG-Serializer, DXF/PDF-Export
viewport/
Viewport3D.tsx // Three.js-Szene aus dem Modell (existiert)
scene.ts // buildScene(project) → THREE.Group (Layer-Spiegel)
clip.ts // Plan-/Schnitt-Clipping über THREE.Plane
camera.ts // Kamera-Presets, Norden-Rotation
sheets/
layout.ts // Sheet/Viewport-Datenmodell
SheetEditor.tsx // Plansatz-Editor
exportPdf.ts // Vektor-PDF (svg → pdf-lib / jsPDF)
workers/
geometry.worker.ts // Booleans (Öffnungen) + HLR via Comlink
ui/
App.tsx Navigator panels // React-Oberfläche (heute in App.tsx, wird gesplittet)
model/ Project-Schema (types.ts, ~2500 LOC), joins.ts (Wand-Gehrung/
-Prio-Stösse), parametricWalls.ts, roomStamp.ts, terrain.ts,
geoRebase.ts, sampleProject.ts
geometry/ 2D-Kernel (kernel2d.ts: offset/trim/fillet/split — LIVE, siehe
§3), ceiling/opening/roomArea/roomBoundary/stair/roof/column.ts,
polygonHoles.ts
commands/ Rhino-artiges Kommandosystem: types/registry/engine/parseInput.ts
+ cmds/ (ein Modul je Kommando: wall, opening, stair, roof,
column, room, line/rect/circle/arc, move/mirror/copy/offset/
trim/join, extrude, import, terrain, measure, georef, …)
tools/ Interaktive Zeichenwerkzeuge, snapping.ts, transform.ts (Grips)
compute/ Compute-Boundary: leitet Ops (aktuell nur computeJoins) unter
Tauri via `invoke` an Rust weiter, sonst TS-Fallback
plan/ generatePlan.ts (2D-Ableitung), PlanView.tsx (SVG + Pan/Zoom/
Grips), glPlan/ (eigener WebGL2-Renderer), toSection.ts/
toElevation.ts (Schnitt/Ansicht-Ableitung), toWalls3d.ts,
toRenderScene.ts (Szene für Rust-render2d), wallMeshCut.ts
viewport/ Viewport3D.tsx (three.js, „Free") UND Wasm3DViewport.tsx
(Rust/wgpu „Nordstern", Default/editierbar), raycast3d.ts
section/ TOTER Code (OCCT-WASM-HLR-Spike, keine Aufrufer mehr) — Schnitt
läuft über render3d/section.rs, siehe §4.3
export/ exportIfc.ts (IFC4), exportDxf.ts/dxfWriter.ts, exportPdf.ts,
layoutPdf.ts (Mehrseiten-PDF pro Ordner), exportMesh.ts (STL/OBJ),
exportSchedule.ts (CSV-Bauteilliste), sceneToPrintSvg.ts
materials/ ambientcg.ts (Live-Suche ambientCG-API), library.ts (13
gebündelte Starter-Materialien), runtime.ts (PBR-Material aus
ComponentMaterial via three.js TextureLoader)
io/ DXF/DWG-Import, Swisstopo (swissBUILDINGS3D/-ALTI3D/SWISSIMAGE,
LV95↔WGS84), OSM-Overpass, .lin/.pat-Parser, projectFile.ts
(.obp Speichern/Laden, Tauri-Lock)
overrides/ Regelbasierte Override-Engine (Bedingung → Aktion)
state/ Eigener Store auf `useSyncExternalStore` (KEIN Zustand/Redux):
store.ts + Slices (project/history/selection/view/layout/site/
notify), appStore.ts komponiert sie
panels/ Dock-/Floating-Panel-System (Dock/FloatingPanel/TabStrip/
registry/layout) + Panels (Tools, Attributes, ObjectInfo, Layers,
DrawingLevels, Site, RoomBalance, Elements, ViewSnapshots, Layouts)
ui/ App.tsx (Shell, **7.130 LOC — noch nicht auf dünne Shell
reduziert**, siehe STATUS.md §4.3), TopBar, ResourceManager.tsx
(Material-/Hatch-/Line-/Typ-Editoren, natives Fenster),
ContextMenu, CommandLine.tsx, LayoutSheet/-Menu, ribbon/
native/ NUR Tauri: native Fenster (Resources/Settings/DrawingLevels/
LayerSettings/ContextImport), macOS-Menüleiste, Fenster-Chrome —
jede Funktion no-opt via `isTauriRuntime()` im Browser
editors/ booleanOps.ts (2D-Boolean via polygon-clipping), splitJoin.ts
text/ Rich-Text (richText.ts, RichTextEditor.tsx, renderHtml.ts)
theme/ Hell/Dunkel, Akzentfarben
i18n/ de.ts/en.ts Wörterbücher, eigener t()-Mechanismus
engine/ Reine WASM-Lade-Glue: engine3d.ts (pkg3d), truckSolid.ts
(pkgTruck), plan/useWasmPlanRenderer.ts (pkg) — pkgGeometry und
pkgDwgImport sind gebaut, aber unbenutzt (siehe STATUS.md §4.1)
src-tauri/
src/ Tauri-Host (Fenster, native Dialoge, fs4-Exklusiv-Lock)
render2d/ 2D-Plan-GPU-Renderer (wgpu/WGSL, + glyphon-Text), auch → WASM
render3d/ „Nordstern" 3D-Engine (wgpu/WGSL): Wandextrusion + Schicht-
bänder + Gehrung, LIVE 2D=3D-Schnitt (section*.rs, analytisch,
kein HLR), Kanten-Extraktion, Materialtextur-Arrays,
Aerial-Drape, Render-Styles (Shaded/White/Textured/Wireframe/
Hidden/ShadedEdges) — auch → WASM
kernel2d/ Rust-Port von geometry/kernel2d.ts — NUR Paritätstest, nicht
produktiv (WASM war < 100 Wänden langsamer als TS)
geometry/ Wand-Join-Mathe — Rust-Port, UNBENUTZT (kein Aufrufer)
trucksolid/ CSG/Extrusion (`truck`-Crate + `csgrs`-Booleans) → WASM,
genutzt vom Extrude-Kommando; Boolean NICHT an Wände/
Öffnungen angeschlossen
dwgimport/ DXF-Parser-Spike (`acadrust`) → WASM, UNBENUTZT (DWG-Import
läuft über npm `@mlightcad/libredwg-web`)
```
Jedes Crate unter `src-tauri/` ausser dem Host ist ein **eigenständiges
Cargo-Package** (kein gemeinsamer Workspace), das sowohl headless
(`cargo test`) als auch per `wasm-pack --features web` baut und dann via
`src/engine/pkg*/` von der TS-Seite geladen wird (`npm run build:engine{,3d,
Geometry,Kernel2d,DwgImport}`/`build:truck`).
---
## 2. Datenmodell
### 2.1 Zwei unabhängige Achsen (DOSSIER-Modell, im Spike umgesetzt)
### 2.1 Zwei unabhängige Achsen (unverändert gegenüber der Vision)
DOSSIER trennt zwei orthogonale Konzepte, persistiert als zwei getrennte
JSON-Bäume (`dossier_zeichnungsebenen`, `dossier_ebenen`). Wir übernehmen das
1:1 in `types.ts` (existiert bereits als `drawingLevels` + `layers`):
1. **Zeichnungsebenen** (`DrawingLevel`) — Geschoss (`kind:"floor"`,
`floorHeight`/`cutHeight`/`baseElevation`), Schnitt/Ansicht
(`kind:"section"|"elevation"`), oder freie Zeichnung (`kind:"drawing"`).
2. **Ebenen** (`LayerCategory`) — das Grafik-Kategorie-Schema (Baum,
`{code, name, color, lw, visible, locked, hatch?, children}`), in jedem
Geschoss gültig. Codes 1:1 aus DOSSIER (`00 Raster · 01 Vermessung ·
20 Wände (└21 Türen/Fenster, 25 Stützen) · 30 Decken · 31 Dächer ·
40 Treppen · 50 Text · 60 Räume · 80 Plangrafik …`).
1. **Zeichnungsebenen** (`DesignLevel` / `DrawingLevel`) — die obersten
Dokument-Abschnitte. Zwei produktive Arten:
- **Geschoss** (`kind:"floor"`): `floorHeight`, `cutHeight`,
`baseElevation` (akkumuliert via `recomputeFloorElevations`), `visible`/`locked`.
- **Schnitt/Ansicht** (`kind:"section"|"elevation"`): `linePoints`,
`directionSign`, Höhenbereich, Tiefe.
- **Zeichnung** (`kind:"drawing"`): freie 2D-Ebene ohne Geschossbezug.
2. **Ebenen** (`LayerCategory`) — das **Grafik-Kategorie-Schema**, das in *jedem*
Geschoss gilt. Baum mit `{code, name, color, lw, visible, locked, hatch?, children}`.
Codes 1:1 wie DOSSIER (`DEFAULT_LAYER_SCHEMA` aus `launcher/src/App.jsx`):
`00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster, 22 Möbel, 25 Stützen) ·
30 Decken · 31 Dächer · 35 Träger · 50 Text · 60 Plangrafik …`
### 2.2 Elemente — typisierte Arrays statt `Element[]`-Union
Ein **Element** kennt sein **Geschoss** (`floorId`) *und* seine **Ebene**
(`categoryCode`). Sichtbarkeit ergibt sich aus dem Schnitt (Geschoss `geschoss × code`
Matrix), genau wie DOSSIERs `apply_visibility(z_mode, e_mode)` in `layer_builder.py`.
> **Begriffsnotiz:** Heute heißt der Typ im Code `DrawingLevel`. ROADMAP §5 nennt
> die Modell-Eingabe (Geschosse) Vectorworks-konform **Design Layer** und die
> abgeleitete Ausgabe **Drawing Layer / Sheet**. Wir behalten `DrawingLevel` als
> Union (kind=floor ≙ Design Layer, kind=section/elevation/drawing ≙ Drawing
> Layer) und führen `Sheet` erst separat ein (§2.5, plans-output.md). Kein Rename
> ohne expliziten Auftrag.
### 2.2 Ressourcen-Bibliotheken (Vectorworks-Stil)
Neuer Block `Project.resources` (heute provisorisch als flache `materials[]`).
Alles verweist **per id** — zentral änderbar. Details: resources-graphics.md.
Anders als ursprünglich geplant hält `Project` (`src/model/types.ts:2095`)
**pro Bauteiltyp ein eigenes (meist optionales) Array**:
```ts
interface Resources {
lineStyles: LineStyle[]; // Line Manager: { id, name, weight(mm), color, dash:number[] }
hatches: Hatch[]; // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId }
components: Component[]; // Component Manager (= DOSSIER-Material erweitert):
// { id, name, hatchId, color3d, texture3d?, joinPriority }
interface Project {
walls: Wall[];
ceilings?: Ceiling[];
roofs?: Roof[];
doors: Door[]; // ÄLTER — siehe openings; bewusste Doppelspur
openings?: Opening[]; // NEUER, allgemeiner: kind:"window"|"door"
stairs?: Stair[];
rooms?: Room[];
columns?: Column[];
extrudedSolids?: ExtrudedSolid[];
drawings2d: Drawing2D[];
context?: ContextObject[]; // Terrain/Importe — NICHT semantisch
parametricWalls?: ParametricWall[];
overrideRules?: OverrideRule[];
viewSnapshots?: ViewSnapshot[];
viewSnapshotFolders?: ViewSnapshotFolder[];
layouts?: Layout[];
masterLayouts?: MasterLayout[];
layoutFolders?: LayoutFolder[];
// + Bibliotheken: lineStyles, hatches, components, wallTypes, roofTypes?,
// doorTypes?, windowTypes?, stairTypes?, ceilingTypes?
// + drawingLevels, layers, geoAnchor?, referenceElevationMasl?
}
```
`joinPriority` ist DOSSIERs `_MATERIAL_PRIO` (Beton 800 … Putz 100) als
**Daten** statt Hardcode — steuert die Prioritäts-T-Verschneidung (Risiko #1).
Ein `Element`-Typalias existiert noch (`kind:"door"|"window"` etc.), wird aber
nirgends im Code referenziert — Selektion/Element-Baum arbeiten direkt auf den
typisierten Arrays. `doors`/`openings` sind eine **bekannte, noch nicht
konsolidierte Doppelspur** (PENDENZEN.md).
### 2.3 Aufbauten (mehrschichtige Typen)
### 2.3 Ressourcen & Aufbauten (unverändert gegenüber der Vision)
```ts
interface Resources { lineStyles: LineStyle[]; hatches: Hatch[]; components: Component[]; }
interface WallType { id; name; layers: { componentId; thickness }[]; }
interface SlabType { id; name; layers: { componentId; thickness }[]; }
```
Schichtdicke liegt am Layer, **Priorität am Component** (so muss man Prio nur
einmal pflegen). 3D und Plan lesen dieselben `layers[]`.
`joinPriority` sitzt am `Component` (Daten statt Hardcode) und steuert die
Prioritäts-T-/X-Verschneidung — **fertig implementiert**, inklusive Rust-Port
für den 3D-Live-Schnitt (`section_boolean.rs` = Port von
`toSection.ts::subtractDominantBands`).
### 2.4 Elemente
### 2.4 Layouts / Ausschnitte
Diskriminierte Union `Element` (heute `Wall | Door`), erweitert um
`Window | Slab | Stair | Roof | Column | Beam | Space | Draw2d`. Jedes Element:
`{ id, type, floorId, categoryCode, ... }`. Gehostete Elemente (Tür/Fenster)
tragen `hostWallId` statt `floorId` (Geschoss ergibt sich aus der Wand). Volle
Felder: elements.md.
### 2.5 Pläne / Output (eigene Achse)
```ts
interface Sheet { // Plansatz-Blatt (≙ DOSSIER Layout/PageView)
id; name; paper: "A0".."A4"|"Letter"; landscape: boolean;
viewports: SheetViewport[]; // platzierte Ableitungen + Titelblock
folder?: string;
}
interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt
id; rect: Rect; sourceViewId: string; // referenziert DrawingLevel oder ViewSnapshot
scale: number; // 1:N
}
interface ViewSnapshot { // ≙ DOSSIER Ausschnitt (View-Snapshot)
id; name; folder?;
camera: CameraState; scale: number; detailLevel: DetailLevel;
visibility: VisibilityState; // pro Geschoss/Ebene
overrides?: { presetId?; enabled };
}
```
`ViewSnapshot` (Kamera/Massstab/Detailgrad/Sichtbarkeiten/Override-Preset) in
`ViewSnapshotFolder`-Bäumen; `Layout` (Papierformat, mehrere
`LayoutViewport`s, freie `LayoutAnnotation`s, optionale `MasterLayout`-Vererbung)
in `LayoutFolder`-Bäumen. Beide gehören ins Dokument (nicht LocalStorage).
---
## 3. State, Persistenz, Undo/Redo
### 3.1 Store — Zustand
### 3.1 Store — eigener `useSyncExternalStore`, nicht Zustand
Heute: `useState<Project>` in `App.tsx`, immutabel via `setProject`. Das skaliert
nicht für Werkzeuge + Undo. Migration auf **Zustand** (ROADMAP §4):
Entgegen der ursprünglichen Empfehlung (Zustand-Library) wurde ein
**abhängigkeitsfreier Store** gebaut (`src/state/store.ts`, gleiches Muster wie
`src/i18n`): `createStore()` komponiert Slice-Fabriken über eine gemeinsame
`RootState`. `appStore.ts` fügt zusammen: `projectSlice` (Projekt + Undo/Redo,
`setProject` als einziger Mutations-Einstiegspunkt), `historySlice`,
`selectionSlice`, `viewSlice`, `layoutSlice`, `siteSlice`, `notifySlice`
(Toast/Confirm-Ersatz für `window.alert`, in Tauris WKWebView deaktiviert).
Komponenten lesen über `useStore(selector)`.
```ts
interface AppState {
project: Project; // das Dokument
ui: { // flüchtig, NICHT persistiert/undobar
activeLevelId; activeViewType; selection: string[];
activeTool: ToolId; hover; cameraByView; planTransform;
};
// Aktionen mutieren via Immer-Producer; jede Modell-Mutation pusht History.
apply(mutator: (p: Project) => void): void;
undo(): void; redo(): void;
}
```
**Nicht umgesetzt** (geplant in `docs/design/state-architecture.md`): die
Extraktion von View-Routing nach `src/views/` und Kontextmenü-Aufbau nach
`src/menus/` — beide Ordner existieren nicht, diese Logik liegt weiterhin
inline in `App.tsx` (7.130 Zeilen). Siehe STATUS.md §4.3.
**Trennung Modell ↔ UI** ist hart: nur `project` ist undobar und wird gespeichert.
`ui` (Auswahl, Kamera, aktives Werkzeug) lebt separat. Das ersetzt DOSSIERs
`sc.sticky`-Bus — wo DOSSIER cross-modul über Sticky-Keys kommuniziert, lesen bei
uns einfach alle Komponenten denselben Store (reaktiv). **Kein Sticky, kein
Bridge-Polling, keine `is not None`-Lücken** (DOSSIER-Schwachstelle #4.4).
### 3.2 Persistenz
### 3.2 Persistenz — JSON-Datei statt `doc.Strings`
DOSSIER speichert *alles* in `doc.Strings` (Key-Value am Rhino-Document) +
globale Presets als JSON-Dateien im User-Home. Browser-Äquivalent:
| DOSSIER | Browser (cad) |
|---|---|
| `doc.Strings.SetString(key, json)` | Feld im `Project`-Objekt (ein JSON-Baum) |
| `.3dm`-Datei mit eingebetteten Strings | **`.cad.json`-Datei** via File System Access API |
| Globale Presets (`~/Library/.../*.json`) | **LocalStorage** (`cad.presets.*`) + Export/Import |
| Launcher schreibt Settings-Datei | **IndexedDB** Autosave (Phase 5) |
```ts
// persistence.ts
const FORMAT_VERSION = 1;
function serialize(p: Project): string // JSON.stringify({ version, project })
function deserialize(text: string): Project // + migrate(version) bis aktuell
async function saveToFile(p: Project): Promise<void> // showSaveFilePicker → .cad.json
async function loadFromFile(): Promise<Project> // showOpenFilePicker; Fallback <input type=file>
async function autosave(p: Project) // IndexedDB, debounced 2 s
```
- **Save/Load:** [File System Access API](https://developer.mozilla.org/docs/Web/API/File_System_Access_API)
(`showSaveFilePicker`/`showOpenFilePicker`); Fallback Download-Blob + `<input type=file>`
für Firefox/Safari.
- **Autosave/Recovery:** IndexedDB (via `idb`), entkoppelt vom expliziten Speichern.
- **Cross-Projekt-Presets:** LocalStorage mit Namespacing (`cad.presets.overrides`,
`cad.presets.wallTypes`, …) + JSON-Export/Import — ersetzt DOSSIERs
`~/Library/.../override_presets.json` (overrides.py).
- **Migrationen:** `migrate(data, fromVersion)`-Kette, additiv. DOSSIER macht das
per Sticky-Prefix (`traite_``pause_``dossier_`); wir machen es versioniert im
Datei-Schema.
- **Datei:** eigenes `.obp`-Format (`src/io/projectFile.ts`), unter Tauri über
native Speichern/Öffnen-Dialoge (`plugin-fs`/`plugin-dialog`), im Browser
Blob-Fallback. Ältere `.json`-Projekte bleiben ladbar.
OS-Level-Exklusiv-Lock (`fs4`-Crate, `src-tauri/src/lock.rs`) verhindert
Doppelöffnen desselben Projekts.
- **Compute-Boundary:** `src/compute/index.ts` leitet einzelne Operationen
(aktuell nur `computeJoins`) unter Tauri via `invoke` an eine native Rust-
Implementierung weiter; im Browser bzw. für nicht migrierte Ops (Room-
Detection, DWG-Parsing) läuft die TS-Implementierung.
### 3.3 Undo/Redo
DOSSIER überlässt Undo Rhino (und hat dadurch Cache-Stale-Bugs, Schwachstelle
#4.3). Wir besitzen das Dokument selbst → **eigener History-Ring**:
```ts
// history.ts — Snapshots des project-Teilbaums (strukturelles Sharing via Immer-Patches)
interface History { undo: Patch[][]; redo: Patch[][]; }
```
Jede `apply()`-Mutation erzeugt Immer-Patches → Push auf `undo`. Da abgeleitete
Sichten **pure** sind, ist nach `undo()` kein Cache zu invalidieren — der
ganze Cache-Stale-Komplex aus DOSSIER (`_JOINTS_CACHE_KEY`, Material-Cache)
entfällt strukturell.
Eigener History-Ring im `projectSlice` (kein DOSSIER-Sticky-Bus, kein
Cache-Stale-Problem, da alle Sichten pure Ableitungen sind).
---
## 4. Rendering
### 4.1 Three.js-Szene spiegelt den Ebenen-Baum
### 4.1 Zwei 3D-Viewports
DOSSIER baut in Rhino eine **Layer-Hierarchie** `Zeichnungsebene → CODE_Name-Sublayer`
(`layer_builder.build_layers`) und hängt jedes Objekt an den passenden Sublayer.
Browser-Spiegel: ein **`THREE.Group`-Baum** mit identischer Struktur, gebaut aus
dem Modell (nicht persistiert — reine Ableitung):
- **`Viewport3D.tsx`** — three.js, die „Free"-Stufe.
- **`Wasm3DViewport.tsx`** — Rust/wgpu, „Nordstern", editierbar, **Default**.
Ein Settings-Schalter wählt die Engine; WebGL2/three.js nur noch als
explizite Wahl oder Fallback.
`render3d` (10.246 LOC) baut Wandextrusion inkl. Schichtbändern und
Prioritäts-Gehrung (`compute_wall_miters`), Materialtextur-Arrays (pro
Wandschicht) + separate Aerial-Drape-Textur, Render-Styles (Shaded/White/
Textured/Wireframe/Hidden/ShadedEdges via Kanten-Extraktion mit
Crease-Erkennung). **Dächer/Treppen/Stützen haben KEINE eigene Rust-Geometrie**
— sie werden vollständig in TypeScript erzeugt (`geometry/roof.ts`,
`emitRoofs`/`emitColumns`) und Rust nur als vorberechnetes Dreiecks-Mesh
(`MeshInput`/`append_context_mesh`, derselbe generische Importpfad wie für
swissBUILDINGS3D/DXF) übergeben.
### 4.2 Grundriss = drei koexistierende Renderer
1. **`PlanView.tsx`** (SVG) — bleibt immer im DOM (Hit-Testing/Grips),
unabhängig vom aktiven Zeichenpfad.
2. **`plan/glPlan/`** — eigener TypeScript-WebGL2-Renderer.
3. **`useWasmPlanRenderer.ts`** → Rust-`render2d` (WGSL, wgpu nativ,
WebGPU/WebGL2-Fallback im Web, echtes Text-Rendering via `glyphon`).
Beide GPU-Pfade fallen bei Init-Fehler still auf SVG zurück.
### 4.3 Schnitt/Ansicht — analytische Rust-Pipeline, KEIN HLR
Der ursprünglich geplante OCCT-WASM-HLR-Pfad (`src/section/hlr.ts`/`occt.ts`)
ist **toter Code** (keine Aufrufer mehr). Der tatsächlich funktionierende
Live-Schnitt nutzt aus, dass jedes Bauteil ein Prisma mit konstantem
Querschnitt ist — eine Schnittebene liefert dadurch **immer** ein
achsparalleles Rechteck, nie ein Trapez, wodurch HLR unnötig wird:
```
scene
└─ levelGroup[floorId] (y-Offset = baseElevation) ← Geschoss
└─ categoryGroup[categoryCode] (userData.code) ← Ebene
└─ element meshes (userData.elementId)
App.tsx (section3dCutId/section3dPlane)
→ Wasm3DViewport.tsx (section3d-Prop → setSectionPlane)
→ src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}
```
```ts
// scene.ts
function buildScene(project: Project, vis: VisibilityState): THREE.Group
function syncScene(root: THREE.Group, project, vis) // diff statt full rebuild (Perf)
```
`section_boolean.rs` ist ein 1:1-Port von `toSection.ts::subtractDominantBands`
— 2D-Plan-Schnitt und 3D-Live-Schnitt nutzen dieselbe Prioritäts-Logik und
stimmen dadurch exakt überein. Kein Worker, kein Comlink (beides war geplant,
existiert nirgends im Projekt) — läuft synchron GPU-seitig.
- **Sichtbarkeit:** `categoryGroup.visible = vis.codes.has(code)` und
`levelGroup.visible = vis.floors.has(floorId)` — entspricht DOSSIERs
`apply_visibility`, aber als simples `.visible`-Toggle statt Layer-Table-Modify.
- **Farbe/Material:** Component → `MeshStandardMaterial` (PBR später), per
`componentId` gecacht (wie heute `layerMaterial`-Cache, aber projektweit).
- **„Grau/gesperrt"-Modi** (DOSSIER `grey`/`grey_locked`): graues Override-Material
auf der Gruppe statt Layer-Color-Tausch.
### 4.4 Öffnungen als Löcher (kein Mesh-Boolean)
### 4.2 Grundriss = Clipping-Ebene + symbolische Generierung
Fenster/Türen schneiden echte achsparallele Rechteck-Löcher aus dem Wandkörper
(`plan/toWalls3d.ts` `subtractSpans`, gespiegelt in `render3d::mesh.rs`).
Ein generisches Mesh-CSG-Boolean existiert bereits (`trucksolid::boolean_mesh`,
`csgrs`, für das Extrude-Kommando genutzt), ist aber nicht an die
Wand/Öffnungs-Pipeline angeschlossen.
DOSSIER zeigt den Grundriss eines Geschosses, indem es eine **Clipping-Plane** auf
`okff + schnitthöhe` legt, Normale Z (`layer_builder.update_clipping_plane`).
Zwei Browser-Pfade — **beide nötig** (ROADMAP §3):
### 4.5 Materialien
1. **3D-Viewport im Plan-Modus:** `THREE.Plane(normal=(0,0,-1), constant=cutZ)` über
`renderer.clippingPlanes` + `material.clippingPlanes`. `clip.ts` setzt die Ebene
pro aktivem Geschoss (`cutZ = baseElevation + cutHeight`), Kamera orthografisch
von oben. So sieht man den **echten geschnittenen Volumenkörper**.
2. **Symbolischer SVG-Grundriss** (der Hauptweg, schnell + sauber): `generatePlan.ts`
erzeugt Vektor-Primitive **direkt aus Parametern** — Wand-Schnittbänder,
Öffnungs-Aussparungen, Tür-Schwenkbögen — *ohne* Mesh zu schneiden. Steht im
Spike. Ausbau: Schraffuren, Lauflinien, Bemaßung, LoD (plans-output.md).
### 4.3 Schnitt/Ansicht = Kamera + Schnittebenen + HLR
DOSSIER setzt 12 Clipping-Planes (Cut + Back), Parallel-Projektion senkrecht zur
Linie, zoomt auf die BBox (`schnitte.activate_schnitt`). Browser:
- **3D-Vorschau:** zwei `THREE.Plane` (Cut auf der Linie, Back in `directionSign`-
Richtung um `depthBack` versetzt) + orthografische Kamera senkrecht zur Linie.
- **Vektor-Ergebnis (Risiko #4):** Hidden-Line-Removal durch das zusammengebaute
Gebäude → saubere Linien als SVG. Via **OpenCascade.js** (`HLRBRep`) im
**Web Worker** (Comlink), Ergebnis gecacht pro (Schnitt, sichtbare Ebenen,
Modell-Hash). Geschnittene Bauteile bekommen Component-Schraffur (Section-Style,
resources-graphics.md). Details: plans-output.md.
### 4.4 SVG-Renderer + Geometrie-Booleans
- **Plan-Output:** SVG (`PlanView.tsx`). `Primitive`-Union (polygon/line/arc/text/
hatch) → SVG-Elemente; derselbe Serializer speist DXF- und PDF-Export.
- **Öffnungs-Booleans (Risiko #2):** Tür/Fenster schneidet Loch in Wand. Für 3D
reicht heute das Aussparen per Segment-Extrusion (im Spike). Für exakte B-Rep-
Verschneidung (und IFC) **OpenCascade.js** *oder* **Manifold** im Worker —
Entscheidung in Phase 0 (ROADMAP §8): OCC = exakt/B-Rep, Manifold = schnell/Mesh.
`materials/library.ts` (13 gebündelte PBR-Starter, ambientCG CC0) +
`materials/ambientcg.ts` (Live-Suche der kompletten ambientCG-Bibliothek,
1K/2K/4K-Auflösungswahl, On-Demand-Download via `jszip`, Proxy wegen CORS) →
`materials/runtime.ts` baut daraus gecachte `THREE.MeshStandardMaterial`s mit
physisch korrekter Kachelgrösse (UV in Weltmetern).
---
## 5. React-Panel-Struktur
DOSSIER ist eine Sammlung **getrennter WebView-Panels** (EBENEN, ELEMENTE,
GESTALTUNG, OBERLEISTE, MASSSTAB, AUSSCHNITTE, DIMENSIONEN, LAYOUTS, OVERRIDES),
die über die Python-Bridge + `sc.sticky` kommunizieren. Im Browser ist alles
**eine SPA** mit einem geteilten Store — die Panels werden zu Docking-Bereichen.
Dock-/Floating-Panel-System (`src/panels/`: Dock, FloatingPanel, TabStrip,
Registry) mit eingebauten Panels: Tools, Attributes, ObjectInfo,
DrawingLevels, Layers, Site (Kontext/Terrain-Import), RoomBalance
(SIA-416-CSV), Elements (Bauteilbaum), ViewSnapshots, Layouts.
```
┌─────────────────────────────────────────────────────────────────────┐
│ TopBar Werkzeuge · Ansichtstyp · Massstab · Snaps · Save/Load │ ≙ OBERLEISTE+MASSSTAB
├──────────────┬──────────────────────────────────────┬───────────────┤
│ Navigator │ Viewport (Three.js oder SVG-Plan) │ Inspector │
│ ┌──────────┐ │ ┌────────────────────────────────┐ │ ┌───────────┐ │
│ │Zeichnungs│ │ │ 3D / Grundriss / Schnitt / │ │ │ aktives │ │ ≙ ELEMENTE/
│ │-ebenen │ │ │ Ansicht — abgeleitet aus dem │ │ │ Element + │ │ GESTALTUNG-
│ ├──────────┤ │ │ Modell │ │ │ Stil │ │ Properties
│ │ Ebenen │ │ └────────────────────────────────┘ │ └───────────┘ │
│ │ (Baum) │ │ │ Manager-Tabs: │
│ │ │ │ │ Components · │ ≙ Resource-
│ └──────────┘ │ │ Hatch · Line │ Manager
├──────────────┴──────────────────────────────────────┴───────────────┤
│ BottomBar Koordinaten · Snaps · Statusmeldungen │
└─────────────────────────────────────────────────────────────────────┘
Modal/Drawer: Ausschnitte · Sheets/Layouts · Overrides · Kamera-Presets · SIA-Bilanz
```
Der **Resource Manager läuft bewusst NICHT als Dock-Panel**, sondern als
eigenständiges (unter Tauri natives) Fenster — ebenso Settings,
DrawingLevels-Detaileditor, LayerSettings und ContextImport
(`src/native/*Window.ts` + `*WindowApp.tsx`), jeweils mit
`isTauriRuntime()`-Gate und ohne separate Browser-Variante (im Browser bleibt
die entsprechende In-App-Overlay-Variante aktiv, wo vorhanden).
**Komponenten-Map** (heute alles in `App.tsx`; wird gesplittet):
| Bereich | DOSSIER-Panel | cad-Komponente |
|---|---|---|
| Zeichnungsebenen-Liste | EBENEN (oben) | `Navigator/DrawingLevels.tsx` |
| Ebenen-Baum | EBENEN (Baum) | `Navigator/LayerTree.tsx` (Spike: `CategoryRow`) |
| Top-Bar (View/Snaps/Massstab) | OBERLEISTE, MASSSTAB | `TopBar.tsx` |
| Element-Eigenschaften | ELEMENTE, ELEMENT-PROPERTIES | `Inspector/ElementProps.tsx` |
| Stil/Attribute der Auswahl | GESTALTUNG | `Inspector/StylePanel.tsx` |
| Component/Hatch/Line | (Project-Settings, mass_style) | `managers/*Manager.tsx` |
| View-Snapshots | AUSSCHNITTE | `panels/Snapshots.tsx` |
| Plansätze | LAYOUTS | `sheets/SheetEditor.tsx` |
| Regelbasierte Overrides | OVERRIDES | `panels/Overrides.tsx` |
| Bemaßung | DIMENSIONEN | `panels/Dimensions.tsx` |
| Element-Übersicht (BIM-Tree) | ELEMENTE-ÜBERSICHT | `panels/ElementTree.tsx` |
| Kamera-Presets | KAMERA | `panels/CameraPresets.tsx` |
**Werkzeug-System** (ersetzt DOSSIERs Rhino-Command-Aliases `cmd/wand.py` etc.):
ein `Tool`-Interface mit `onPointerDown/Move/Up`, das Vorschau-Primitive
zurückgibt und beim Bestätigen `store.apply()` ruft. Pointer-Events auf Canvas/SVG
mit Snap-Engine (Endpunkt/Mitte/Schnitt/Ortho/Raster). Tabelle der Tools:
elements.md §8.
**Werkzeug-System:** `Tool`-Interface (`src/tools/types.ts`) für Zeichenwerkzeuge
mit Snap-Engine; daneben das umfangreichere **Kommandosystem** (`src/commands/`)
für Rhino-artige getippte Eingabe (`5,3`/`r5,3`/`5<45`, Tab-Feld-Zyklus in
`CommandLine.tsx`) — beide koexistieren, decken unterschiedliche
Interaktionsstile ab.
---
## 6. Rhino → Browser — Mapping-Tabelle
## 6. Rhino → Dossier — Mapping-Tabelle (Kernkonzepte)
| DOSSIER (Rhino-Plugin) | cad (Standalone-Browser) | Anmerkung |
| DOSSIER (Rhino-Plugin) | Dossier (Tauri) | Anmerkung |
|---|---|---|
| **Rhino `RhinoDoc`** | `Project` (TS-Objekt im Store) | einzige Wahrheit |
| **`doc.Strings[key]=json`** | Feld im `Project`-JSON | persistiert in `.cad.json` |
| **`.3dm`-Datei** | **`.cad.json`** via File System Access API | + IndexedDB-Autosave |
| **Globale Presets (~/Library/*.json)** | **LocalStorage** + Export/Import | cross-Projekt |
| **`sc.sticky` (cross-modul Bus)** | **Zustand-Store** (reaktiv) | kein Polling, kein None-Bug |
| **`panel_base.BaseBridge` / WebView-IPC** | direkte React-Props/Store | keine `document.title`-Hacks |
| **`document.title="RHINOMSG::"` Polling** | entfällt | SPA, kein WebView |
| **Eto.Forms Satelliten-Fenster** | React-Modal/Drawer | z.B. Ausschnitt-Settings |
| **Rhino Layer-Tabelle (Sublayer-Baum)** | `LayerCategory[]`-Baum + `THREE.Group`-Spiegel | `scene.ts` |
| **`layer_builder.build_layers`** | `buildScene` (Ableitung) | Gruppen statt Layer |
| **`layer.PlotWeight`** | `LineStyle.weight` (mm) → SVG `stroke-width` | massstabsabh. (§plans) |
| **`SectionStyle` (Layer)** | Component-Schraffur beim Schnitt-Rendern | resources-graphics.md |
| **Clipping-Plane (`AddClippingPlane`)** | `THREE.Plane` + `renderer.clippingPlanes` | `clip.ts` |
| **`vp.ChangeToParallelProjection`** | `THREE.OrthographicCamera` | `camera.ts` |
| **`vp.GetFrustum()` → Massstab** | Frustum-Breite (ortho) → 1:N | gleiche Mathe (§plans) |
| **CoreGraphics DPI-Auto-Detect** | `window.devicePixelRatio` + CSS-px | Browser kennt DPI nativ |
| **`Rhino.Geometry.Brep`-Booleans** | OpenCascade.js / Manifold (Worker) | Öffnungen, HLR |
| **`HLRBRep` (über Rhino-Display)** | OpenCascade.js `HLRBRep` (Worker) | Schnitt/Ansicht-Linien |
| **Rhino-Grips + DisplayConduit** | Pointer-Events auf SVG/Canvas + Overlay | grip-editing (elements.md) |
| **`Rhino.Input.Custom.GetPoint`** | `Tool`-Pointer-Handler + Snap | Werkzeug-System |
| **`MouseCallback` (Doppelklick Schnitt)** | `onDoubleClick` auf SVG-Symbol | plans-output.md |
| **Rhino Undo/Redo** | eigener History-Ring (Immer-Patches) | kein Cache-Stale |
| **`HatchPattern`-Tabelle** | `Hatch`-Ressourcen + SVG `<pattern>` | resources-graphics.md |
| **`Linetype`-Tabelle** | `LineStyle.dash[]` → SVG `stroke-dasharray` | resources-graphics.md |
| **`TextEntity` / Rich-Text** | SVG `<text>` / HTML-Annotation | plans-output.md |
| **`RhinoPageView` (Layout)** | `Sheet` + `SheetEditor` | plans-output.md |
| **`FilePdf` Multi-Page-Export** | svg → `pdf-lib`/`jsPDF` (Vektor) | plans-output.md |
| **Swisstopo/OSM via .NET HttpClient** | `fetch()` (CORS-fähige STAC/Overpass-APIs) | Phase 4 |
| **LV95↔WGS84 (Python-Formeln)** | dieselben Formeln in TS portiert | Phase 4 |
| **IronPython 2.7/3 Runtime-Risiken** | entfällt (TS/Browser) | — |
| Rhino `RhinoDoc` | `Project` (TS-Objekt im eigenen Store) | einzige Wahrheit |
| `doc.Strings[key]=json` | Feld im `Project`-JSON | persistiert in `.obp` |
| `.3dm`-Datei | **`.obp`**-Datei (Tauri `plugin-fs`) | + Blob-Fallback im Browser |
| `sc.sticky` (cross-modul Bus) | eigener `useSyncExternalStore`-Store | kein Polling |
| Rhino Layer-Tabelle | `LayerCategory[]`-Baum, Sichtbarkeit als `.visible`-Flag | pro Renderer umgesetzt |
| Clipping-Plane (`AddClippingPlane`) | `render3d::section.rs` (analytisch, Rechteck-Subtraktion) | KEIN HLR |
| `HLRBRep` | — (ungenutzt; OCCT-Spike ist toter Code) | ersetzt durch obiges |
| `Rhino.Geometry.Brep`-Booleans | `trucksolid::boolean_mesh` (csgrs) | existiert, nicht an Wände angeschlossen |
| Rhino-Grips + DisplayConduit | Pointer-Events + Grip-Overlay (2D vollständig, 3D nur Basis, kein Snap) | siehe STATUS.md §4.7 |
| Swisstopo via .NET HttpClient | `fetch()` (CORS-offen) | radiusgenau zugeschnitten (nicht ganze STAC-Kachel) |
| IronPython-Laufzeit-Risiken | entfällt | TS/Rust |
---
## 7. Was wir von DOSSIER übernehmen — und was nicht
## 7. Was übernommen wurde — und was bewusst anders lief
**Übernehmen (bewährte Konzepte):**
- Zwei-Achsen-Dokumentmodell (Zeichnungsebenen × Ebenen).
- Layer-Codes/Farben/lw 1:1 (`DEFAULT_LAYER_SCHEMA`).
- Component/Hatch/Line-Manager mit id-Verweisen + `joinPriority`.
- Ansichtstypen = Kamera + optionaler Schnitt (vereinheitlicht).
- LoD (`darstellung`: einfach/standard/detail) mit Dokument-Override.
- Regelbasierte Overrides (Condition → Action, additive Priorität, reversibel).
- Ausschnitte (View-Snapshots), Layer-Kombinationen, Massstab-pro-Viewport.
- SIA-416-Räume, Norden-Rotation, Stil-Kataloge.
**Übernommen (Prinzipien, bewährt):**
- Zwei-Achsen-Dokumentmodell, Layer-Codes 1:1, Component/Hatch/Line-Manager
mit `joinPriority`, LoD (grob/mittel/fein), regelbasierte Overrides,
Ausschnitte, SIA-416-Räume, Norden-Rotation.
- Pure-Ableitungs-Architektur (kein Cache-Stale, kein Sticky-Bus).
**Bewusst anders (Browser-nativ, Schwachstellen vermeiden):**
- **Kein Sticky-Bus** → ein reaktiver Store (vermeidet DOSSIER #4.4/#4.5/#4.6).
- **Kein Monolith** → Bauteil-Module statt 7244-LOC-`elemente.py` (#4.1).
- **Pure Ableitungen** statt mutierter Doc-Objekte → kein Cache-Stale (#4.3),
kein Undo/Redo-Loch.
- **Versioniertes Datei-Schema** statt UserString-Sticky-Migration.
- **Persistenz im Project-JSON** statt verteilt über `doc.Strings`.
**Anders gelaufen als geplant (siehe STATUS.md §5 für die volle Tabelle):**
- Eigene Rust/WASM-Rendering-Engines statt Three.js/OpenCascade.js/web-ifc.
- Typisierte Arrays pro Bauteiltyp statt `Element[]`-Union.
- Eigener Store statt Zustand-Library.
- Analytische Rust-Schnitt-Pipeline statt HLR/Worker/Comlink.
- App.tsx wurde **nicht** wie geplant auf eine dünne Shell reduziert
(`src/views/`/`src/menus/` wurden nie angelegt) — bekannter, unbereinigter
Punkt, siehe STATUS.md §4.3.
**Anti-Over-Engineering (aus DOSSIER-CONVENTIONS.md übernommen):** keine Abstraktion
ohne konkretes Problem; kein `try/catch: {}` als Bug-Versteck; erst grep/lesen,
dann editieren; Wand-Geometrie nie „nebenbei" refactoren.
**Anti-Over-Engineering (weiterhin gültig):** keine Abstraktion ohne konkretes
Problem; erst lesen, dann editieren; Geometrie nie „nebenbei" refactoren.
---
## 8. Reihenfolge bei Code-Arbeit
1. **Dieses Dokument + das relevante Detail-Design** (elements/plans-output/
resources-graphics) lesen.
2. **Dann das betroffene Modul** lesen (nicht raten).
3. **Erst danach editieren**; `Project` immer immutabel via `store.apply()` ändern.
4. **Verifizieren:** `npx tsc -b`, `npm run build`, Screenshot via
`node scripts/probe.mjs` — Geometrie visuell prüfen.
5. **Dieses Dokument aktuell halten**, wenn sich Patterns/Mapping ändern.
1. **STATUS.md** (aktueller Ist-Zustand) + dieses Dokument lesen.
2. **Das betroffene Modul** lesen (nicht raten) — bei Zweifel: welcher der
mehreren Renderer/Pfade ist gerade aktiv (§4.1/§4.2)?
3. **Erst danach editieren**; `Project` immer immutabel via den Store ändern.
4. **Verifizieren:** `npx tsc -b`, `npm test` (Vitest), `cargo test` in den
betroffenen Crates, bei Rust-Änderungen `npm run build:engine{,3d}` (WASM
neu bauen, nur `.rs` committen — `src/engine/pkg*/` ist gitignored). Bei
render3d/Wasm3DViewport-Änderungen testet der Nutzer selbst in der
Tauri-Dev-App (nicht per Puppeteer/Browser verifizierbar).
5. **Dieses Dokument aktuell halten**, wenn sich Patterns ändern — insbesondere
nicht wieder in „Ziel-Struktur"-Beschreibungen abdriften, die Monate lang
niemand nachführt.
+25 -11
View File
@@ -1,6 +1,8 @@
# Projekt-Konventionen — Browser-BIM (cad)
# Projekt-Konventionen — Dossier
Siehe [ROADMAP.md](ROADMAP.md) für Vision, Architektur und Phasen.
Siehe [ARCHITECTURE.md](ARCHITECTURE.md) für die aktuelle Architektur,
[STATUS.md](STATUS.md) für den vollständigen Ist-Zustand und
[ROADMAP.md](ROADMAP.md) für die ursprüngliche (historische) Produktvision.
## Code-Konventionen (verbindlich)
@@ -15,16 +17,26 @@ Siehe [ROADMAP.md](ROADMAP.md) für Vision, Architektur und Phasen.
CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (T/2) → innen (+T/2)
gestapelt.
## Code-Struktur (kein God-Component)
## Code-Struktur (kein God-Component — bisher nur teilweise erreicht)
- **`App.tsx` bleibt ein dünner Shell** (Store-Provider, Oberleiste, Docks+View-Router,
Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine Geschäftslogik darin.
- **Globaler Zustand in einem Store** (`src/state/`, Slices: project/selection/view/layout).
Komponenten lesen Zustand über Store-Hooks statt Prop-Drilling.
- **Features als eigene Module:** `src/views/` (View-Router-Teile), `src/editors/`
(Inline-Editoren), `src/menus/` (Kontextmenü-Builder), `src/panels/`, `src/ui/`.
- **Ziel:** `App.tsx` bleibt ein dünner Shell (Store-Provider, Oberleiste,
Docks+View-Router, Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine
Geschäftslogik darin. **Realität (Stand 2026-07-21):** `App.tsx` ist mit
~7.100 Zeilen die grösste Datei des Projekts und enthält weiterhin
View-Umschaltung und Kontextmenü-Aufbau inline — die geplante Auslagerung
nach `src/views/`/`src/menus/` (unten) ist **nie passiert**, siehe
[STATUS.md](STATUS.md) §4.3. Neue, grössere Features sollten trotzdem nicht
weiter in `App.tsx` wachsen; wo möglich in `src/panels/`, `src/editors/`
oder ein neues Modul auslagern statt die Datei weiter zu vergrössern.
- **Globaler Zustand in einem Store** (`src/state/`, eigener Store auf
`useSyncExternalStore`**kein** Zustand/Redux/Immer — mit Slices:
project/history/selection/view/layout/site/notify). Komponenten lesen
Zustand über `useStore(selector)` statt Prop-Drilling.
- **Features als eigene Module:** `src/editors/` (Inline-Editoren),
`src/panels/`, `src/ui/`. `src/views/` und `src/menus/` waren geplant, wurden
aber nie angelegt — bei Bedarf gilt das als offener Aufräum-Punkt, nicht als
bestehende Struktur.
- Ziel: modular + parallel bearbeitbar (verschiedene Features ≠ dieselbe Datei).
Siehe `docs/design/state-architecture.md`.
## UI-Konventionen
@@ -61,4 +73,6 @@ Rendern angewandt, nie in die Geometrie eingebacken.
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
- Dev-Server läuft via `npm run dev` (Vite, Port 5173).
- Dev-Server läuft via `npm run dev` (Vite, Port 5187). Nativer Rahmen
plattformabhängig: `npm run tauri:dev` auf macOS, `npm run electron` auf Linux
(WebKitGTK kann kein zuverlässiges WebGPU → dort Chromium/Electron).
+164
View File
@@ -1,3 +1,167 @@
> **Hinweis (2026-07-21):** Dieser Block (Stand 07-09) ist nicht mehr der
> neueste Stand — `git log`/[PENDENZEN.md](PENDENZEN.md) reichen bis 07-20/17.
> Für den aktuellen Ist-Zustand siehe [STATUS.md](STATUS.md). HANDOVER.md bleibt
> ein Append-Log (ältere Sessions unten); es wird nicht rückwirkend aktualisiert
> — bei neuen Übergaben oben einen neuen Block ergänzen, nicht diesen editieren.
# HANDOVER — Stand 2026-07-09 (autonome Session, ALLES committet)
Lange autonome Session (Nutzer-Auftrag: „arbeite die Pendenzen/Funktionen durch,
selbstständig committen, nicht mehr nachfragen"). **Abweichung vom bisherigen
Protokoll: in dieser Session wurde committet** (ausdrücklich beauftragt). Keine
KI-Spuren, deutsche Commits, keine Co-Authored-By-Trailer.
## Verifikations-Baseline (zuletzt grün)
`npx tsc --noEmit` sauber · `npx vitest run` **620**. (Rust/WASM unverändert →
kein Neubau nötig.) Der akkumulierte, über frühere Sessions gewachsene grüne
Stand wurde zuerst als EIN Basis-Commit `3529930` gelandet, danach jedes Feature
einzeln.
## In dieser Session gebaut + committet (neueste zuerst)
- **Dächer** (`1195d2a` Modell+Geometrie, `31f2d63` Werkzeug+2D+3D): neues
Roof-Element + `geometry/roof.ts` (roofGeometry für Flach/Pult/Sattel/Walm/
Mansarde/Zelt, auf der Umriss-BBox, First entlang X/Y). Befehl „Dach" (BIM-
Ribbon, Alias dach/rf): Rechteck aufziehen, Dachform als Inline-Option wählbar.
2D-Plan (Traufe/First/Grat/Knick, generatePlan) + 3D-Flächen (emitRoofs,
terrakotta). Demo-Satteldach RF1 im Seed. +19 Tests. **OFFEN (Folge-Increment):
Auswahl/Attribut-Editieren/Löschen platzierter Dächer** (Form/Neigung/Überstand
im Panel ändern, Klick-Auswahl, Delete) — mirror des Decken-Selektionspfads
über ~12 App.tsx-Stellen + PlanView-Pick + selectionInfo + host + Panel.
- **Fenster/Tür-Feedback** (`4beae72`, `89e737b`): (a) Fenster wirkten im 3D
flach → Rahmen füllt jetzt die VOLLE Wanddicke (tiefe Laibung), Scheibe dünn
mittig; Ursache war frameThickness (Profilbreite) fälschlich als Einbautiefe.
(b) Detailgrad grob/mittel/fein wirkt jetzt auch im 3D (grob=Loch+Scheibe,
mittel=Rahmen, fein=+Sprossen), durchgereicht bis projectToModel3d({detail}).
(c) Fenster↔Tür-Umschalter im Objekt-Info entfernt (nur noch Anzeige).
(d) **Kernursache „sieht im 3D gar nicht so aus": platzierte Öffnungen bekamen
keinen typeId** → resolveOpeningFrame lieferte null → keine Rahmen. appendOpening
weist jetzt den ersten Fenster-/Türtyp zu.
- **Öffnung ⚙-Knopf** (`586c1c9`): springt in den Tür-/Fenstertyp-Editor.
- **Decken-Griffe im 3D** (`973ac6d`): gewählte Decken haben im wgpu-Viewport
Eckpunkt- + Kanten-Mittelpunkt-Griffe (rautenförmig) + Verschiebe-Griff →
moveCeilingGrip/Edge/By. Prop-Kette Wasm3DViewport←Viewport3D←App. three.js-
Sicht unverändert (Default wgpu). **Visuell noch NICHT in Tauri abgenommen.**
- **Text-Styling-Leiste** (`d0b9d22`): selektierter Freitext/Textspalte ist jetzt
Formatier-Ziel der Oberleiste (Schrift/fett/kursiv/Farbe via Drawing2D-Text.marks;
Grösse bleibt über Modell-Höhe, nicht pt). generatePlan/PlanView rendern die Marks.
- **Schichttrennlinie als Wand-Referenzlinie** (`938d642`): Wall.referenceOffset
(freier Achsversatz) übersteuert left/center/right; Object-Info-Dropdown listet
je interne Fuge einen Eintrag. wallReferenceOffset bleibt EINE Quelle (2D/Schnitt/3D).
- **Mess-Werkzeug** (`492e1f8`): Polygonzug mit Länge + FLÄCHE (ab 3 Ecken),
Live-Werte dauerhaft im Objekt-Info-Panel (ToolDraft.measure→PanelHost.measurement),
Rechtsklick beendet Pfad + armiert neuen (mehrere nacheinander).
- **Tür/Fenster tief** (`fa40429` Modell/Seeds/RM-Editor, `142ba7e` 2D, `9a65900` 3D):
DoorType/WindowType um frameKind (Zarge/Blockrahmen), frameWidth, insetFromFace/
insetFace (Schichteinzug), transomHeight (Oberlicht), mullionRows (Kämpfer)
erweitert. 2D: opening-frame/-transom-Primitive. 3D: emitOpeningFrames (Rahmen/
Sprossen/Kämpfer) + einzugs-/oberlicht-bewusste Scheiben. **3D visuell noch NICHT
in Tauri abgenommen.**
- **LICENSE** (`dbe7d37`): offizieller AGPL-3.0-Text von gnu.org.
- **CSV-Element-Set (D2)** war bereits im Baseline-Stand vorhanden (verifiziert).
- **window.prompt/confirm-Sweep**: keine offenen Vorkommen mehr (bereits sauber).
## Offene Fäden / bewusst NICHT gemacht
1. **Tauri-Visualabnahme** der 3D-Änderungen (Tür/Fenster-Rahmen, Decken-Griffe)
steht aus — Nutzer testet selbst (render3d/Viewport nicht browserverifizierbar).
2. **Rechtsklick=Abbruch generell**: bewusst NICHT global von confirm→cancel
umgestellt (würde Polylinien-Abschluss brechen); fürs Mess-Werkzeug via onConfirm
gelöst (commit-frei → confirm≙abbruch).
3. **Bauteil-Einstellungs-Button** (Discoverability: von der gewählten Öffnung in
den RM-Typeditor springen) noch offen — Typeditor selbst ist über das
Ressourcen-Fenster (Tür-/Fenstertypen-Tabs) erreichbar.
4. Grosse Deferred-Items unverändert: 2D-Schnitt editierbar, Schnitt-Verschneidung
(wartet auf `.obp`), Feld-Controller 3D, Schnitt/Ansicht Phase 3, truck-Live-Wiring.
5. **Konventionen bleiben:** ab jetzt wieder Standard = NICHT committen, ausser der
Nutzer beauftragt es erneut. Keine KI-Spuren.
---
# HANDOVER — Stand 2026-07-08 spät (Schnitt-Parität + Projektdatei, ALLES uncommittet)
Fortsetzungs-Session (nach dem älteren 2026-07-08-Handover weiter unten). **Alles uncommittet.** Schwerpunkt: 3D-Live-Schnitt = 2D-Schnitt + eigene Projektdatei.
## Verifikations-Baseline (zuletzt grün)
`npx tsc --noEmit` sauber · `npx vitest run` **588** · `cargo test -p render3d --features render` **76** · `cargo check --features native3d` grün · WASM `npm run build:engine3d` neu gebaut (nötig nach Rust/WGSL-Änderungen). **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren.**
## Was diese Session gebaut wurde
- **3D-Live-Schnitt = 2D-Schnitt (Kernfunktion):** (a) 45°-Schraffur-Verdrehung gefixt (Shader auf 2D-`parallelLines`-Konvention kalibriert, angle 0 = horizontal); (b) geschichteter Bodenaufbau (Decke per-Schicht statt Einkörper, `emitSlabs`); (c) **Prioritäts-Verschneidung** von 2D nach Rust portiert (`section_boolean.rs` = Port von `subtractDominantBands`/`subtractRect`; Bänder tragen `join_priority` via `CutBandMeta`); (d) `collectCeilingCutters` emittiert per-Schicht-Cutter → Backstein (50) schneidet Estrich/Dämmung (30/20) korrekt weg, nur Beton (100) kappt ihn; (e) **einstellbare Schichttrennlinien** aus `jointLineStyleId`; (f) **Schraffur-Strichstärke pro Hatch** aus deren `lineStyleId` (`hatch-hair` 0.02 / `thin` 0.13); (g) `relativeToWall`-Orientierung: Boden-Dämmung vertikal, Wand horizontal (via `resolveHatch(axisAngle)` wie 2D). Betrifft `src-tauri/render3d/src/{shaders,gpu,section,section_fill,section_boolean,types}.rs` + `src/plan/toWalls3d.ts`. `native.rs` `WallInput`-Literal um die additiven Felder ergänzt.
- **Projekt als Einzeldatei `.obp`** („openbureau project"): `src/io/projectFile.ts` (`PROJECT_EXT`, nativer Öffnen/Speichern-Dialog, alte `.json` weiter ladbar) + **OS-Lock gegen Doppelöffnung** (`src-tauri/src/lock.rs`, `fs4` exklusiver flock über gehaltenes Handle → Auto-Freigabe bei Absturz/Exit; `LockConflictDialog`). QA: Lock-Logik korrekt, einzige Lücke: „schreibgeschützt" == „erzwingen" auf Datenebene (kein app-weiter Read-only-Modus).
- **Identität:** Open-Source-CAAD statt „BIM für Wohnbau"; `about.desc` (de/en) + README + `package.json` (`license: AGPL-3.0-or-later`, `author`). **LICENSE-File fehlt noch** (README verlinkt darauf) — offizieller AGPL-Text muss rein (nicht fabrizieren; `curl gnu.org/licenses/agpl-3.0.txt`). About-Dialog-Umbruch gefixt (`.about-dialog { white-space: normal }` — er erbt `nowrap` von `.topbar`).
- **2D:** Text-Platzierungswerkzeug als 2D-Element (`text`-Command + Freitext-Eingabe in der Command-Engine); 2D-Zeichenwerkzeuge auf `"drawing"`-Ebenen freigegeben (ToolsPanel-Gating = `floorOnly`, Parität mit Ribbon).
- **Teil B — 2D-Annotationen auf Layout-Blättern:** `LayoutAnnotation` (line/rect/text in mm) + CRUD (`layoutModel.ts`) + Editor in `LayoutSheet.tsx` (zeichnen/selektieren/verschieben/löschen, `PromptDialog` für Text) + `layoutSheetMath.ts` (Hit-Tests). Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping.
## Offene Fäden
1. **Idee 1 — per-Wand „an Decke enden"** (Terminierung ≠ Priorität): AGENT LÄUFT gerade (per-Wand `sliceTermination`, Default off). Details/Design im Memory `design-schicht-verschneidung-3d-schnitt.md`. **Idee 2 (Schichteinzüge, per-Schicht Endversätze)** = größeres Folge-Feature, noch nicht gebaut.
2. **Ressourcen-Fenster als eigenes OS-Fenster** (Nutzer-Wunsch): machbar (Tauri `WebviewWindow` + BroadcastChannel-Sync, „Ausklappen"-Button, Browser bleibt intern). Noch NICHT gebaut — braucht Store-Sync-Architektur-Entscheidung.
3. **Visuelle Tauri-Abnahme** aller 3D-Schnitt-Änderungen steht aus (render3d nur naga-validiert + WASM gebaut; Nutzer testet selbst). Der Backstein hat prioritäts-korrekt noch ein Stück oberhalb des Betons — das ist Idee 1 (Option), nicht ein Bug.
4. **Konventionen:** wie immer — keine KI-Spuren, nicht committen, disjunkte Agent-Lanes, echte Eigenschaft prüfen (nicht nur grüne Tests).
---
# HANDOVER — Stand 2026-07-08 (Account-/Instanzwechsel, ALLES uncommittet)
Übergabe an eine frische Instanz (Nutzer wechselt Account, Budget aufgebraucht). **Alles unten ist UNCOMMITTET** (Bearbeiter committet nie selbst). Zuerst `PENDENZEN.md` lesen (Single Source of Truth, ✅-Erledigt-Liste + offene Items ganz konkret). Diese Session war eine sehr lange Feature-/Fix-Serie mit vielen Sub-Agents.
## Verifikations-Baseline (zuletzt grün)
`npx tsc --noEmit` sauber · `npx vitest run` **491/491** · `cargo check --manifest-path src-tauri/Cargo.toml` grün · `vite build` OK. (Der letzte Layout-Viewport-Agent meldete 491; nach dem interaktiven Ausschnitte-Fix waren es 465 → 491 ist der aktuelle Stand nach dem Layout-2b-Agenten.) **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren**, da alles uncommittet nebeneinander liegt.
## Was diese Session gebaut wurde (alles in PENDENZEN ✅ dokumentiert)
- **Interop-Export:** IFC4 (`exportIfc.ts`, jetzt `IfcTriangulatedFaceSet` mit ausgeschnittenen Fenster/Tür-Löchern + SOLIDE Wände — Winding-Fix), STL+OBJ (`exportMesh.ts`), gemeinsamer Loch-Ausschnitt `src/plan/wallMeshCut.ts` (Port von render3d `extrude_layer_segment_with_holes`, `isWatertight` via Gauss/Volumen). Schnellexport-Dialog (`ExportSaveDialog`, Format+Name).
- **Nativer Speichern-Dialog:** Tauri `plugin-dialog`+`plugin-fs` (Cargo/lib.rs/capabilities), `src/io/saveFile.ts` (`saveTextFile`: nativ unter Tauri, Blob-Fallback im Browser). App.tsx `downloadTextFile``saveTextFile`.
- **Ausschnitte (View-Snapshots) Phase 1:** `Project.viewSnapshots`, `state/viewSnapshots.ts`, `ViewSnapshotsPanel` (Speicher-Fix: Inline-Input statt window.prompt).
- **Layout-Blätter Phase 2a+2b:** Modell `Layout`/`LayoutViewport`/`MasterLayout` (`Project.layouts`/`masterLayouts`), In-Viewport-Editor (`activeLayoutId` in App.tsx → `LayoutSheetView`/`LayoutSheet.tsx`, Maus-Editing, `layoutSheetMath.ts`), `LayoutsPanel`. Floating `LayoutEditor` wurde ersetzt.
- **Tragwerk-Stützen:** `Column`/`ColumnProfile`, `geometry/column.ts`, Platzieren/2D/3D/Selektion/Attribute/move.
- **Weiteres:** A1 Override-Regel-Engine, kernel2d Phase 5 komplett, Bauteil-CSV volles Element-Set, SIA-416 AGF, Kamera-Presets Kardinal+Iso, Decken-UK/OK, BIM-Tree-Panel, ObjectInfo-Volumen, Basis-Material-Texturen, OSM 7 Kat., IndexedDB-Kern, Rich-Text super/sub, Auto-Zoom-Geo, Raumstempel Personen/Rundung, diverse TopBar-Feinschliffe (Export-Sammelmenü, Über-Dialog, Petrol-Punkt, Detailgrad+Darstellung gestapelt, Arbeitsumgebung in Settings).
## ⚠️ WICHTIG / offene Fäden (Details in PENDENZEN, Abschnitt „In Arbeit"/oben)
1. **NOCH NICHTS VISUELL IN TAURI ABGENOMMEN:** IFC/OBJ/STL-Geometrie, nativer Speichern-Dialog, Layout-im-Viewport, Ausschnitte — alle agent-/testverifiziert, aber der Nutzer hat sie im laufenden Fenster noch NICHT gesehen. **Tauri-Dev NEU STARTEN** (`npm run tauri:dev`, ggf. Vite separat auf Port 5187 — es gibt KEIN `beforeDevCommand`) wegen der neuen Rust-Plugins.
2. **`window.prompt`/`confirm`-SWEEP:** in Tauri-WKWebView DEAKTIVIERT (→ null). Gefixt: ViewSnapshotsPanel, LayoutsPanel. NOCH betroffen: `ComboMenu` (Kombinationen speichern), TopBar Massstab „frei" (`topbar.scale.prompt`), evtl. weitere. Grep `window.prompt|window.confirm` über `src/`, alle auf Inline-Eingaben umstellen.
3. **NÄCHSTES GROSSES ITEM (fertig gescoped, war startbereit als Opus-Agent):** „Layouts als Ordner-/Baumstruktur" — voller Auftrag steht in PENDENZEN (Ordner-Baum, ein „+" für Ordner/Layout/Masterlayout, Inline-Rename, Layout-Erstell-Dialog mit Master-Vorlage/freie Grösse, Masterlayout mit Grössenwahl, Ordner→Mehrseiten-PDF). Direkt umsetzbar.
4. **Konventionen:** keine Fremd-Tool-Spuren/Co-Authored-By im Repo; Sub-Agents in disjunkte Datei-Lanes schicken (i18n de.ts/en.ts + App.tsx sind Hotspots → exklusiv EINEM Agent pro Welle, sonst NUL-Byte-Kollision); grüne Tests NICHT als alleinigen Beweis nehmen (echte Eigenschaft prüfen — z. B. beim IFC-Solidity-Fix war der naive Manifold-Test falsch, Gauss/Volumen war richtig).
---
# HANDOVER — Stand 2026-07-07 abends (Instanzwechsel, alles uncommittet)
Übergabe an eine frische Instanz (Nutzer wechselt bewusst die Instanz). **Alles unten Gelistete ist NOCH UNCOMMITTET** — Bearbeiter committet laut Arbeitsprotokoll nicht selbst. Zuerst `CONVENTIONS.md` + `PENDENZEN.md` lesen (dort steht die volle, aktuelle Aufgaben-Historie inkl. aller Details der truck-Integration/Deep-Review-Fixes unten — hier nur die Kurzfassung + der offene Faden).
## Sofort-Kontext für die neue Instanz
**Environment:** macOS-Gerät, Toolchain vollständig (node/npm/cargo/wasm-pack), `npm run tauri:build` läuft. Kein Blocker.
**Verifikations-Baseline (zuletzt geprüft, alles grün):** `npx tsc --noEmit` sauber · `npx vitest run` 341/341 · `cargo test` in `src-tauri/trucksolid` 15/15 · `cargo test --manifest-path src-tauri/Cargo.toml -p render3d` 58/58.
**Was in dieser Session geschah (chronologisch, Details je in PENDENZEN.md „truck-Integration" Phase 5 / Backlog):**
1. Vorherige Session ist wegen RAM-Absturz des Geräts abgebrochen (lief eine LOKALE LLM auf dem Laptop, nicht diese Instanz) — Stand war: truck-Integration Phasen 14 fertig (Extrusion + Verjüngung/Taper + Boolean-CSG via `csgrs`), aber noch uncommittet.
2. Diese Instanz hat den übernommenen Stand **deep-reviewt** (8-Winkel-Review: Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) und mehrere echte Bugs gefunden + gefixt — volle Liste in PENDENZEN.md unter „truck-Integration" → „Deep-Review + Fixes (2026-07-07)": konkave Profil-Triangulierung (Fächer→Ohr-Clipping), 6 fehlende Selektions-Resets für Extrusionen, Mirror/Copy ignorierten Extrusions-Auswahl, DXF-Export verlor den Layer, `truckMeshCache`-Leak + Flacker-Bug, unnötige `fitTargetDist`-Neuberechnung, fehlende i18n (Schnittebene-Button + Farbwähler-Tooltip).
3. Danach auf Nutzer-Wunsch **eigenständig durch PENDENZEN.md** gearbeitet (nur unblockierte Items, keine Design-Entscheidungen geraten): **Ortho-Ray-Picking gefixt** (`cameraRay` in `src/viewport/raycast3d.ts` konnte nur perspektivisch — jetzt echte parallele Strahlen bei `perspective:false`, rückwärtskompatibel; +2 Tests). Siehe PENDENZEN.md „3D-REST" Abschnitt.
## ✅ ERLEDIGT 2026-07-07 (Folge-Instanz) — Bauteil-CSV volles Element-Set (D2, vertieft A6), umgesetzt exakt nach dem Scope unten; Treppen-Fläche = null (leer), Aggregat-Zellen leer statt 0.00. Details/Status in PENDENZEN.md „✅ Erledigt".
`src/export/exportSchedule.ts` deckt aktuell nur **Wand + Decke** ab (Muster: `evalWall`/`evalCeiling``ScheduleRow`, siehe Datei). PENDENZEN-Backlog-Item „AUDIT (DOSSIER-Studie)" nennt D2 = „Bauteil-CSV mit vollem Element-Set". Ich habe das untersucht und **bewusst gescoped, aber NICHT umgesetzt** (Instanzwechsel kam dazwischen) — der Datei-Zustand ist unverändert/clean (ein Testballon-Import wurde wieder entfernt, `tsc` ist grün). Damit die nächste Instanz nicht neu recherchieren muss, hier die bereits getroffenen, gut begründeten Scope-Entscheidungen:
- **Aufnehmen:** `Door` (Tür), `Opening` (kind="window"→Fenster, kind="door"→Öffnung), `Stair` (Treppe), `ExtrudedSolid` (Extrusion, aus der truck-Integration).
- **NICHT aufnehmen: `Room`** — Räume haben bereits einen eigenen, dedizierten CSV-Export (`roomsToCsv` in `src/geometry/roomArea.ts`, SIA-Flächenbilanz-Spalten). Eine zweite Raum-Zeile in der allgemeinen Bauteilliste wäre Duplikation.
- **Spalten-Mapping** (bestehende feste Header „Länge/Höhe/Dicke/Fläche" — nur dort füllen, wo eine Ableitung wirklich verlässlich ist, sonst leer lassen wie es `evalCeiling` heute schon für Decken macht):
- Door/Opening: `length = width` (lichte Breite), `heightM = height`, `thickness = null` (keine sinnvolle Entsprechung), `area = width * height`. Geschoss auflösen über `hostWallId``project.walls.find(w => w.id === hostWallId)?.floorId` (neuer kleiner Helper nötig, Wand evtl. verwaist behandeln wie bei `evalWall`/`evalCeiling`).
- Stair: `length = runLength + (run2Length ?? 0)` (Näherung, bei Wendel evtl. wenig aussagekräftig — akzeptabel, da nur „verlässlich Ableitbares" Anspruch gilt), `heightM = stair.totalRise ?? null` (KEINE Geschosshöhen-Fallback-Auflösung nachbauen — Kopplung/Risiko vermeiden), `thickness = null`, `area`: **noch nicht entschieden** — eine echte Footprint-Fläche bräuchte `geometry/stair.ts`s `stairGeometry()` (Kopplung an Geschoss-Höhen-Fallback) → einfachster sicherer Weg ist vermutlich `area = null` lassen (ehrlich statt erfunden), aber nochmal gegen die bestehenden Tests/Konventionen prüfen, bevor final entschieden wird.
- ExtrudedSolid: `typeName = "Extrusion"`, Geschoss über `levelId`, `length = null`, `heightM = solid.height`, `thickness = null`, `area = polygonArea(solid.points)` (Import aus `geometry/roomArea.ts`, bereits im File importiert).
- Neue `kind`-Werte in `ScheduleRow`/`TypeAggregate`: `"Tür" | "Fenster" | "Öffnung" | "Treppe" | "Extrusion"` zusätzlich zu `"Wand" | "Decke"`.
- **Aggregat-Zeile (`aggregateByType`/Summary-Block):** die bestehende Zeile `fmtNum(agg.kind === "Wand" ? agg.totalLength : null)` sollte verallgemeinert werden zu `agg.totalLength > 0 ? fmtNum(agg.totalLength) : ""` — dann zeigen Tür/Fenster/Öffnung/Treppe (die jetzt auch eine `length`-Spalte befüllen) automatisch korrekt eine Summe, während Decke/Extrusion (deren `length` immer `null`→0 bleibt) weiterhin korrekt leer bleiben. Kein Sonderfall pro Kind nötig.
- **Tests:** `src/export/exportSchedule.test.ts` folgt einem klaren `fixtureProject()`-Muster (2 Wände + 1 Decke) — beim Erweitern eine Tür/ein Fenster/eine Treppe/eine Extrusion in die Fixture aufnehmen und je einen Zeilen-Assert wie bei den bestehenden Wand-/Decken-Zeilen ergänzen.
- Warum nicht in dieser Session fertig: Instanzwechsel kam mitten in der Umsetzung. **Kein Blocker, keine Nutzer-Entscheidung nötig** — reine Fleissarbeit nach obigem Schema, dann `tsc`/`vitest` grün prüfen und in PENDENZEN.md abhaken.
## Aus dieser Session bewusst NICHT angefasst (recherchiert, aber zu unsicher/blockiert)
- **Treppe Pfeil-Style „filled" + Tür `swing_invert`/`aussenseite`** (PENDENZEN „BAUTEILE… Gruppe B"): die Referenzquelle (`/tmp/dossier-ref/rhino/*.py`) existiert auf **diesem** Gerät/dieser Session NICHT (geprüft, `find` liefert nichts) — ohne das exakte Rhino-Verhalten zu kennen, wäre die visuelle Ausgestaltung geraten. Nicht angefasst, um nicht falsch zu raten.
- **E2b Schraffur-Kachel-Motiv:** `MotifEditor` (`src/ui/MotifEditor.tsx`) wird schon für Linienstil-Motive wiederverwendet (1D, entlang einer Linie) — ein Flächen-Kachel-Motiv für `HatchStyle` wäre ein NEUES Datenmodell (2D-Tile-Grid), kein reines Wiederverwenden. Braucht eigentlich einen Scope-Entscheid.
- Alles, was in PENDENZEN.md explizit „Mit Nutzer klären"/„Nutzer-Entscheid nötig" trägt (Ribbon 3D-Tab, kernel2d Phase 6, Feld-Controller, Schnitt/Ansicht Phase 3, Teamwork-Granularität, Geo-Block-Priorität, Textur-Pipeline-Scope, STRATEGIE-Priorisierung, Bildschraffur-Freigabe) — bewusst übersprungen, nicht neu bewertet.
## Wichtige Memory-Notiz (persistiert, gilt für zukünftige Instanzen dieses Repos)
Der Nutzer lässt teils eine **lokale LLM auf seinem Laptop autonom** an diesem Projekt arbeiten (kann abstürzen, z. B. RAM). Deren Output sah in dieser Session strukturell sauber + gut getestet aus, enthielt aber einen echten geometrischen Bug (Fächer-Triangulierung bricht bei konkaven Profilen), den die eigene Testsuite NICHT gefangen hatte (Tests prüften nur Form/Anzahl, nicht geometrische Korrektheit). **Bei Übernahme von Arbeit aus solchen Sessions: grüne Tests allein NICHT als Beweis nehmen, gezielt nachprüfen ob die Tests die richtige Eigenschaft testen.**
---
# HANDOVER — Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen)
Ü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.
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+171 -13
View File
@@ -18,22 +18,55 @@
---
## 📅 Geplant
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)****Start: 2026-07-07 (Dienstag, nach Reset).** Nutzer zeichnet 2D-Querschnitt (L-Profil, T-Träger, Freiform) → truck-Extrusion → 3D-Körper + Boolean gegen Wand/Decke. Voraussetzung: truck-Booleans stabil + WASM-Integration. Umfang: ~46 Wochen. Mit Nutzer Scope/MVP klären vor Start.
## ⛔ Blocker (zuerst klären)
- [x] ~~**Toolchain prüfen**~~**ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
## 🔧 In Arbeit
- [ ] **GEO-BLOCK Folgepunkte (2026-07-12, nach dem swissBUILDINGS3D-DXF-Fix)** — Nutzer-Report, noch NICHT umgesetzt:
-**Georeferenzierung (`ae47b4f` + `e61634c`):** `Project.geoAnchor` (persistenter Modell↔LV95-Referenzpunkt) ergänzt — der ERSTE Import in einem Projekt setzt ihn, ALLE folgenden Importe verwenden denselben Anker statt je eigenständig `makeOrigin(center)` neu zu berechnen. Zusätzlich neuer Befehl **„georef"** (Button „Neuer Bezugspunkt" in SitePanel): verschiebt per Klick das GESAMTE Projekt (`model/geoRebase.ts::translateProject`, reine Translation über alle Bauteile/2D-Geometrie/Kontext-Schicht/Schnittlinien) so, dass der geklickte Punkt zum neuen Modell-Ursprung wird — `geoAnchor` wandert automatisch mit, der LV95-Bezug bleibt exakt erhalten (rekonstruierbar, auch für einen künftigen georeferenzierten Export). Löst das vom Nutzer beschriebene Workflow: Kataster/3D zuerst importieren (landet ggf. weit vom Ursprung), dann selbst einen praktischen Bezugspunkt am Modell wählen. **Bewusst NUR Translation, keine Rotation:** `Roof.ridgeAxis` ist hart auf `"x"|"y"` beschränkt (keine frei gedrehten Dächer im Datenmodell) und mehrere Winkel-Felder (`Column.rotation`, Text-/Bild-Rotation, Türschwenk) müssten bei einer Drehung mitgeführt werden — eine Rotation, damit „orthogonal zur Parzellengrenze" gezeichnet werden kann, ist ein eigener, grösserer und riskanterer Schritt (Datenmodell-Erweiterung nötig), bewusst nicht mitgemacht. +17 Tests (`geoRebase.test.ts`, `georef.test.ts`), Suite 820 grün.
-**Importierte Gebäude/Terrain sind jetzt anwählbare Meshes im 3D-Viewport (`e6a7738`):** Klick im WASM-3D-Viewport pickt Kontext-Meshes per Raycast (`raycast3d.ts`/`pickGeometry` in `toWalls3d.ts`), Auswahl bekommt einen eigenen Kanal (`selectedContextObjectIds`), Bounding-Box-Umriss als Highlight (volles Dreiecks-Wireframe wäre bei grossen Importen zu dicht), Entf-Taste löscht, `SitePanel`-Liste hebt die Auswahl hervor. **Bewusst NICHT umgesetzt:** die „auf einer Ebene"-Zuordnung (kein `categoryCode`/Layer-Feld an `ImportedMesh`/`TerrainMesh`, keine Sichtbarkeits-Kopplung an die Ebenen-Verwaltung) und keine volle Attribut-Bearbeitung (nur Name-Anzeige/Löschen, keine `deriveSelection()`-Integration ins Attribute-Panel — als zu gross für diesen Schritt eingeschätzt). **Ebenen-Zuordnung bleibt offen, ggf. eigener Design-Entscheid nötig** (eine feste „Import"-Ebene vs. frei zuweisbar).
-**Zwei echte Bugs im swissBUILDINGS3D-Pfad behoben (`35a6834`+`9705890`):** (1) Absturz bei riesigen Kacheln (JSZip „Invalid string length" — DXF komprimiert stark, eine Kachel unter dem 150-MB-Limit kann entpackt trotzdem >700 MB Text ergeben; nur `asset.size` aus der STAC-API zu prüfen reichte nicht, jetzt zusätzlich die JSZip-interne Grössenschätzung + genereller Try/Catch, übersprungene Kacheln landen sichtbar in `skippedTiles`/UI-Meldung statt stumm 0 Gebäude). (2) **Der eigentliche „Import funktioniert nicht"-Bug:** die installierte `dxf-parser`-Version liefert Polyface-Mesh-Face-Indizes als vier einzelne Felder (`faceA`/`faceB`/`faceC`/`faceD`, Gruppencodes 7174) statt als `faces`-Array — unser Code prüfte auf das Array, das nie existierte, jede swissBUILDINGS3D-DXF-Kachel ergab dadurch 0 Dreiecke. Live gegen echte Kacheln verifiziert: jetzt tausende Dreiecke mit realistischen Höhenwerten. **Damit ist der Gebäude-Import technisch lauffähig — die beiden Design-Punkte oben (Georeferenzierung, Ebenen-Zuordnung) bleiben offen.**
- [ ] **3D-Kanten-Folgepunkte ("Schattiert mit Kanten", 2026-07-12)** — nach dem Fix der falschen Flächendiagonalen (`7dc8f0d`) zeigte der Nutzer drei weitere Beobachtungen, NOCH NICHT behoben:
- **Fenster-Rahmenecken überlappen statt sauber zu stossen** (Screenshot: doppelte/kreuzende Linien an den Blendrahmen-Ecken bei „fein"). Vermutlich KEIN Kanten-Rendering-Bug, sondern echte überlappende Geometrie — die Rahmen-/Sprossen-Riegel (`openingAxisBox` in `toWalls3d.ts`) sind separate Boxen ohne Gehrung/Union an den Stössen. **Nutzer-Folgewunsch dazu:** bei „fein" einstellbar machen, ob die Ecke vertikal-dominant, horizontal-dominant oder auf Gehrung gelöst wird.
- **Dach ebenfalls kein sauberer Verschnitt am First/Grat** (Screenshot: kleines Störtriangle exakt am First-Apex). Gleiche Kategorie — mehrere Dachflächen (`emitRoofs`) sind separate, nicht verschnittene Meshes.
- **Im OG viele vertikale Striche auf den Wandflächen** — noch NICHT diagnostiziert (nicht als Textur-Pipeline-Artefakt bestätigt, evtl. eine mehrschichtige Wandtyp-Verkleidung mit vielen dünnen Einzelelementen). **Braucht weitere Untersuchung**, idealerweise mit Angabe welcher Wandtyp/welche Schicht im betroffenen Projekt verwendet wird.
- Alle drei sind vermutlich Fälle für eine echte Boolean-Verschneidung (Kandidat: `csgrs`/`boolean_mesh`, bereits als WASM-Export vorhanden aus der truck-Integration, s. u.) statt nur Kanten-Heuristik — grösserer, eigener Task.
- [ ] **Warteschlange „für danach" (Nutzer 2026-07-12, noch nicht begonnen):**
- Luftbild/SWISSIMAGE wahlweise als Drapierung auf das Terrain-Mesh ODER als eigenständiges „Dokument" einfügbar (Bild/PDF generell als einfügbares Dokument-Objekt — noch keine Anforderungen präzisiert).
- Import-Mesh (swissBUILDINGS3D u. Ä.) wahlweise geglättet („gerundet") oder kantig/eckig belassen, wählbar beim Import oder am Objekt.
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)** (Plan: [docs/design/truck-plan.md](docs/design/truck-plan.md)). Ziel: Nutzer zeichnet 2D-Querschnitt → truck-Extrusion → 3D-Körper + später Boolean gegen Wand/Decke. Umfang gesamt: ~46 Wochen. Fortschritt:
- [x] Phase 1: Geometrie-Schicht — Crate `src-tauri/trucksolid` (`extrude_polygon_core`/`extrude_circle_core`, `truck_modeling` nur zur B-Rep-Validierung/`try_attach_plane`/`tsweep`, Tessellierung manuell aus den Ring-Koordinaten — truck-rendimesh-Tessellierung für Solids in 0.3 nicht verfügbar, daher Abweichung vom Übergabe-Dokument), WASM-Bindings (Feature `web`) + `build:truck`-Script + `exclude`-Eintrag, TS-Wrapper `src/engine/truckSolid.ts` (`extrudePolygon`/`extrudeCircle`), `RMeshKind` um `"extrusion"` erweitert (`toWalls3d.ts`). Verifiziert: `cargo test` 5/5 grün, `npm run build:truck` sauber, `tsc --noEmit` 0 Fehler, `vitest run` 339/339 grün.
- [x] Phase 2 (MVP, Nutzer-Entscheid 2026-07-06: **"Nur Viewer-Wiring"**, kein Werkzeug/Store) — Ende-zu-Ende-Beweis, dass die Pipeline bis ins Bild funktioniert: ein fest verdrahtetes L-Profil (`emitTruckFixture` in `toWalls3d.ts`, 2 m abseits vom Ursprung) wird bei Bedarf per `trucksolid`-WASM extrudiert und erscheint im 3D-Viewport. **Zwei echte Lücken dabei gefunden und geschlossen:** (1) `render3d::types::MeshKind` kannte nur `Terrain`/`Imported` — ein `kind:"extrusion"` ohne passende Rust-Variante hätte die serde-Deserialisierung des GESAMTEN Modell-Pushes zum Absturz gebracht (nicht nur die Fixture); `Extrusion`-Variante + warmes Orange als Default-Farbe ergänzt (`cargo test` render3d 58/58 weiterhin grün). (2) `updateModel(project)` läuft nur bei Projektänderung (`useEffect`-Dep `project`) — die asynchron ladende Fixture hätte trotz Erfolg NIE einen Re-Push ausgelöst; Fix via Browser-Event `TRUCK_FIXTURE_READY_EVENT` (`toWalls3d.ts` dispatcht, `Wasm3DViewport.tsx` hört + stösst `updateModel` erneut an). **Visuell verifiziert** (Playwright, `?engine=wasm`, Chromium mit `--use-angle=metal` für echten WebGPU-Adapter headed): orangene Extrusion sichtbar neben dem Demo-Haus im Viewport. `tsc --noEmit` + `vitest run` 339/339 + `cargo test` render3d 58/58 grün. **Noch uncommittet** (Bearbeiter committet nicht selbst).
- [x] Phase 3: UI-Werkzeug + Store-Integration — **erledigt 2026-07-06:** `extrude`-Befehl (`src/commands/cmds/extrude.ts`, Alias „ex") nimmt Polylinie/Rechteck/Kreis als Profil (vorselektiert ODER Klick), Höhe tippen oder Enter für Default; Quell-Zeichnung wird beim Commit ENTFERNT (nicht dupliziert) — der Grundriss zeigt die Extrusions-Footprint-Kontur (`generatePlan.ts`, warmes Orange `#d98c40`) statt der alten Linie. `project.extrudedSolids`-Array statt Fixture. **Volle Auswahl-Parität mit Wand/Decke/Raum** (Nutzer-Entscheid „Voll: wie Wand/Decke/Raum"): eigener Selektionskanal, Klick-Priorität in `PlanView.tsx`, Attribute-Panel-Sektion (Höhe editierbar → löst Re-Extrusion aus, Grundfläche, Geschoss), Löschen, Ctrl+A, `move`-Befehl (`src/tools/transform.ts`). Kreis-Profil nutzt die echte `extrudeCircle`-WASM-Rundextrusion (kein N-Eck-Fallback) über eine 48-Eck-Tessellierung für 2D/Auswahl. Verifiziert: `tsc`/`vitest` 339/339 grün, Playwright-Durchlauf (zeichnen→extrudieren→auswählen→Höhe ändern→löschen) ohne Konsolenfehler. **Bewusst NICHT gebaut** (Konsistenz mit Raum/Decke/Treppe/Öffnung, die das auch nicht haben): Marquee-Auswahl, Klick-Drag-Verschieben. **Noch uncommittet.**
- [x] Phase 4: Boolean gegen Wand/Decke — Mesh-CSG statt B-Rep-Boolean. **Spike 1, 2026-07-06 (Monstertruck-Fork getestet, nicht nur Recherche):** eigenes Rust-Testprogramm gegen `monstertruck-solid` 0.3.2 (Fork von `ricosjp/truck`, wirbt mit gehärteten Booleans) — der Crate-eigene Smoke-Test (echter Überlapp, versetzt in allen 3 Achsen) läuft sauber (`or`/`and` liefern exakte Volumina). Aber: **jede Konstellation mit deckungsgleichen/berührenden Seitenflächen schlägt fehl** — exakt der Praxisfall „Extrusion flächenbündig an Wand" oder „Extrusion mit gleichem Wandquerschnitt eingebunden" (gleiche y/z-Ausdehnung wie die Wand, nur in x versetzt): 0 Überlapp → `Internal{operation:"or"/"and"}`-Fehler; 1e-4 UND sogar 0.1 m sichtbarer Überlapp bei deckungsgleichen Seitenflächen → derselbe interne Fehler; 1e-9 Gleitkomma-Rauschen (winziger Spalt statt exakt 0) → **kein Fehler, aber STILL FALSCHES Ergebnis** (2 unverschmolzene Shells, Volumen = Summe statt Vereinigung). Sobald y/z zusätzlich leicht versetzt sind (keine deckungsgleichen Seiten mehr), funktioniert derselbe x-Überlapp einwandfrei. **Schluss: B-Rep-Booleans (truck/monstertruck) sind für diesen Projekt-Praxisfall (Extrusion mit Wandbreite/-höhe eingebunden) grundsätzlich ungeeignet** — teils Absturz, teils stiller Falsch-Wert.
**Spike 2, 2026-07-06 (`csgrs`, Mesh-Ebenen-CSG/BSP-Baum statt B-Rep):** strukturell anderer Ansatz — rechnet auf Dreiecks-Ebene statt über analytische Kurven/Flächen-Schnitte, damit unempfindlich gegen genau die koinzidenten-Flächen-Fälle, an denen truck/monstertruck scheiterten. Gegen dieselben Testfälle geprüft: generischer Überlapp exakt korrekt (`or`/`and`); flächenbündig (0 Überlapp) → `or` exakt 2.0 (korrekt verschmolzen, kein doppeltes Volumen), `and` liefert korrekt LEER (als Trimesh-Fehler statt sauberem `None` — muss beim Aufrufer als "leer" interpretiert werden); 1e-9-Rauschen → weiterhin exakt korrekt (kein falsches Doppel-Volumen wie bei monstertruck); 1e-4 winziger Überlapp → exakt korrekt; **der kritische Praxisfall (0.1 m Einbindetiefe, deckungsgleicher Wandquerschnitt)**`or`/`difference` beide exakt korrekt. Jedes Ergebnis analytisch exakt (nicht nur "nah dran").
- Packungs-Caveat: alle crates.io-Releases (0.16.00.20.1) sind wegen einer harten, zurückgezogenen `core2`-Abhängigkeit nicht installierbar — auf dem unveröffentlichten `main`-Branch bereits behoben. **Nutzerautorisiert** ("Git-Fetch für den Spike erlauben", danach "alles klar mach das" zur Umsetzung als echte Abhängigkeit): `trucksolid/Cargo.toml` pinnt `csgrs` auf Commit `5e7a37a8803d4e56617734687edc9b98f4ebeed7` (Git-Dependency, kein crates.io-Release — Risiko: kein durchnummeriertes/geprüftes Release, sollte bei Gelegenheit auf ein echtes `0.21.0`-Release umgestellt werden, sobald verfügbar).
- **Umgesetzt:** `src-tauri/trucksolid/src/boolean.rs``boolean_mesh_core(a, b, op)` baut aus flachen Positions-/Indices-Arrays `csgrs::mesh::Mesh`-Polygone (`Polygon::new`/`Vertex::new`, Normale je Dreieck aus dem Kreuzprodukt, NICHT aus den Eingabe-Normalen — die Ebenen-Orientierung fürs BSP kommt aus der Vertex-REIHENFOLGE/Winding, nicht aus einem mitgelieferten Normalenfeld), ruft `union`/`difference`/`intersection` (Trait `csgrs::csg::CSG`) auf, tesselliert das Ergebnis zurück über `Triangulated3D::visit_triangles`. `empty: bool` im Output normalisiert den "Trimesh-Fehler bei leerem Schnitt"-Fall. WASM-Export `boolean_mesh` (Feature `web`, JSON-Schnittstelle wie `extrude_polygon`) + TS-Wrapper `booleanMesh()` in `src/engine/truckSolid.ts`. **Wichtige Voraussetzung für Aufrufer:** beide Eingabe-Meshes müssen konsistent nach AUSSEN gewundene Dreiecke haben (Rechte-Hand-Regel) — falsches Winding liefert ein falsches Ergebnis, OHNE Fehler zu werfen (im eigenen Test zunächst selbst hineingetappt: alle 6 Quader-Seiten der Test-Fixture waren invertiert, dadurch schlugen 3 von 4 Boolean-Tests fehl, bis das Winding korrigiert wurde — reiner Test-Fixture-Bug, nicht im Produktivcode).
- Verifiziert: `cargo test` in `trucksolid` 11/11 grün (inkl. `wall_minus_embedded_extrusion_matching_cross_section`, `flush_touching_union_is_exact`), `cargo build --target wasm32-unknown-unknown --features web` sauber (csgrs + truck-modeling gemeinsam im selben WASM-Modul, keine Konflikte), `npm run build:truck` (echter `wasm-pack`-Build) sauber — `boolean_mesh` ist reell im generierten `.d.ts` exportiert.
- **Noch offen (bewusst NICHT gebaut, eigene Scope-Entscheidung nötig):** die eigentliche Verdrahtung in die Live-3D-Szene (WANN soll eine Wand automatisch um eine eingebundene Extrusion gekürzt werden? Bei jeder geometrischen Überlappung? Nur bei explizit markierten Fällen?) ist eine UX/Scope-Frage, keine technische — analog zum Feld-Controller-Rückstand unten nicht blind entschieden, sondern auf Nutzer-Entscheid wartend. Aktuell bleibt eine Extrusion weiterhin ein unabhängiges, nicht mit der Wand verschmolzenes Solid im 3D (wie seit Phase 2/3).
- [x] Phase 5: Verjüngung (Taper) — `taper` 0 (Prisma) … 1 (Spitze/Kegel-Pyramide), linear zum Profil-Schwerpunkt skaliert. Rust-Kern (`extrude_polygon_core`/`extrude_circle_core` in `lib.rs`), WASM/TS-Wrapper, dritte Befehls-Phase in `extrude.ts` (Höhe → Verjüngung, Enter für persistenten Default), `ExtrudedSolid.taper?`, Attribute-Panel-Feld (editierbar → Re-Extrusion). Tests: Kegel-Volumen (Divergenzsatz gg. analytisch), Pyramidenstumpf-Schwerpunkt-Skalierung, Range-Check.
- [x] **Deep-Review + Fixes (2026-07-07, nach Geräte-Absturz der vorherigen Session):** 8-Winkel-Review (Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) über den gesamten uncommitteten Stand ergab mehrere echte Bugs, alle gefixt:
- **Konkave Profile falsch trianguliert** (`trucksolid/src/lib.rs`): Deckel/Boden nutzten eine Fächer-Triangulierung ab Vertex 0 — korrekt nur für konvexe/von Vertex 0 aus sternförmige Polygone. Bei einem T-Träger (genau das im Plan genannte Zielprofil) erzeugte das nachweislich Phantom-Dreiecke quer durch die konkave Kerbe (nachgerechnet: 2 von 6 Deckel-Dreiecken lagen mit Schwerpunkt ausserhalb). Fix: Ohr-Clipping-Triangulierung (portiert von der bereits vorhandenen, robusten `render3d::mesh::triangulate`) ersetzt den Fächer; Schwerpunkt-Berechnung für die Verjüngung ebenfalls auf die korrekte flächen-gewichtete Formel umgestellt (vorher reiner Vertex-Mittelwert, bei asymmetrischen Profilen verzerrt). Neuer Regressionstest `t_beam_caps_stay_inside_polygon` (prüft, dass jeder Deckel-/Boden-Dreieck-Schwerpunkt im wahren Polygon liegt). `cargo test` trucksolid 15/15 grün.
- **Auswahl-Zustand (`selectedExtrudedSolidIds`) an 6 Stellen in `App.tsx` nicht zurückgesetzt**, wo alle anderen Auswahl-Arten es bereits werden: Geschosswechsel-Effekt, `onOpenStampEditor`, die vier alten three.js-Viewport-Pick-Handler (Wand/Treppe/Decke/Öffnung), `onViewport3dPick`. Ohne Fix: Extrusion bleibt nach Geschosswechsel/anderer Auswahl "geisterhaft" mit-selektiert (falsches Panel, Löschen träfe das falsche Objekt).
- **Mirror/Copy ignorierten eine reine Extrusions-Auswahl** (`hasSelection`/`toTransformSel` in `mirror.ts`/`copy.ts` kannten `extrudedSolidId` nicht, obwohl `transform.ts`/`move.ts` es längst unterstützen) → stiller No-op statt Spiegeln/Kopieren.
- **DXF-Export verlor den Layer von Extrusions-Footprints**: da `extrude` die Quell-Zeichnung (mit `categoryCode`) entfernt, fiel `layerFor()` in `exportDxf.ts` auf `LAYER_DEFAULT` zurück. Fix: eigener `EXTRUSION`-Layer (gleiches Orange wie 2D/3D-Darstellung).
- **`truckMeshCache` (toWalls3d.ts) ohne Eviction** — wuchs unbegrenzt über eine Session (Extrudieren→Kopieren→Löschen in Serie liess Mesh-Daten toter Körper im Speicher). Fix: gelöschte Ids werden bei jedem `emitExtrudedSolids`-Lauf aus dem Cache entfernt. **Gleichzeitig behoben:** sichtbares Flackern beim Tippen von Höhe/Verjüngung im Attribute-Panel (jeder Tastendruck = neue Signatur = Cache-Eintrag wurde sofort auf `mesh:null` gesetzt, bevor die neue Extrusion geladen war) — die alte Mesh bleibt jetzt sichtbar, bis die neue fertig ist.
- **`fitTargetDist` im 3D-Viewport** (Wasm3DViewport.tsx) rief bei JEDER Projekt-Änderung (auch während eines laufenden Griff-Drags) unnötig die komplette Wand-/Öffnungs-/Decken-Flatten-Pipeline auf, nur um eine Bounding-Box für die Schnittebene zu ziehen — die aber nur bei aktiver Schnittebene gebraucht wird. Fix: Aufruf hinter `sectionActive` gegated.
- **Nutzer-Report währenddessen:** Schnittebene-Umschalter (Wasm3DViewport.tsx) hatte `title`/`aria-label` hart auf Deutsch verdrahtet statt über `t()` — einzige Stelle im 3D-Viewport, die das i18n-System umging. Fix + Audit der ganzen App auf dasselbe Muster (title/aria-label/placeholder ausserhalb `t()`): genau eine weitere Stelle gefunden (`ColorHexField.tsx` Swatch-Tooltip „Farbe wählen"), beide jetzt über neue i18n-Keys (`viewport3d.section.*`, `attr.pickColor`).
- Verifiziert nach jedem Schritt: `tsc --noEmit` sauber, `cargo test` trucksolid grün. `vitest run`/`cargo test render3d` am Ende der ganzen Fix-Reihe erneut komplett gegengeprüft (339/339, 58/58, 15/15).
- [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt:
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d``a78e7c7`
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Offen in Phase 5:** `opening` (mit Aufrufstellen-Änderung) — noch nicht portiert.
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Phase 5 komplett (2026-07-07):** `opening` portiert (`wall_axis_length`/`opening_interval`/`wall_axis_frame`/`opening_jambs`/`opening_gap_quad`/`opening_center`/`door_symbol`/`window_symbol` + `along`-Helper, geflachte Signaturen nach PORT_PLAN; `openingVerticalExtent` bleibt bewusst TS — echte Modell-Kopplung an drawingLevels). +7 Parity-Tests (Suite 40 Parity). `cargo test` kernel2d 18/18, `build:kernel2d` sauber, vitest 348/348. Produktive Aufrufer unverändert (Phase 6 weiter offen/Nutzer-Entscheid). **Noch uncommittet.**
- [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären.
- **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung).
@@ -43,17 +76,34 @@
## ⏭️ Als Nächstes
- [ ] **BIM-Elemente „wirklich 1:1" (Tür/Fenster/Dach/Decke)** — volle Studie + Priorisierung: **[docs/design/bim-elements-depth-study.md](docs/design/bim-elements-depth-study.md)** (623 Zeilen, Referenzmatrix VW/ArchiCAD/Revit/Allplan gegen IST). ✅ **Bereits erledigt 2026-07-10:** Fenster-Schachtelung Blendrahmen→Flügelrahmen→Glas + fest/öffenbar + Einbaulage 2D (`9a38636`); Mansard-Untertypen Giebel/Walm/Zelt + Knick (`bfb80b3`); Dach-Dicke 3D (`e99bb24`) + **Dach-Schichtlogik RoofType/Layer[]** (`3a986ec`); zweiflügelige Türen leafCount 2D+3D (`4319e12`); getrennter Traufe/Ortgang-Überstand (`c9baff5`); Glas/glazingPanes 2D+3D + Rollladenkasten (`2e13ec3`/`d131683`). **Offen (nach Doc-Priorität):**
- [x] ~~**Decken-Aussparungen**~~**erledigt 2026-07-10 (`37f4fe8`+`cdbef99`+`aee8846`+`83dcdd0`):** `Ceiling.openings?: Vec2[][]` + **Brücken-Trick** (`mergeHoles` in `src/geometry/polygonHoles.ts`: Löcher über schmale Brücken in den Aussenring → EIN einfacher Ring, paritätskorrekt für SVG/Hatch/Ear-Clipping/Pick — KEIN Loch-Support in den Primitiven nötig, kein Rust-Change). 2D-Poché-Loch (Brückenkanten via noStrokeEdges) + 3D-Slab-Loch; Befehl „Deckenloch" (Aliase deckenloch/aussparung, BIM-Ribbon); Panel listet Aussparungen mit Entfernen. +11 Tests.
- [x] ~~**Dach in 2D-Aufsicht + Vertikalschnitt**~~**erledigt 2026-07-10 (`9a00c8e`+`f248e2a`):** Grundriss-Aufsicht trägt die Ansichts-Schraffur (viewHatchId) der Eindeckungs-Schicht; Vertikalschnitt via `appendRoofSections` (analytischer TS-Schnitt: CyrusBeck gegen die Aufsichts-Polygone, lineare Oberkante, Dicke/cos(Neigung), Schicht-Bänder aussen→innen mit Bauteil-Schraffur). **Offen:** Ansichts-/Silhouettenkanten des Dachs in Elevationen (nur Schnitt-Polygone bisher).
- [x] ~~**RoofType-ResourceManager-Tab**~~**erledigt `83abc1d`** (RoofStylesTab auf LayeredStylesTab-Rumpf; anlegen/editieren/löschen, Löschen geschützt bei Verwendung).
- [ ] **Decken-Randschicht-Override** (`Ceiling.edgeOverride`, Ringzone anderer Aufbau, Tropfkante/Randdämmung) — P1; **Deckentrenn-Werkzeug** für thermisch getrennte Auskragung (Isokorb, UI-only) — P1.
- [ ] **Tür 1:1 Rest:** `glazingRatio` (Teilverglasung), Kassettentür-Geometrie, `frameKind` im 3D (Zarge vs. Blockrahmen), echtes Schwellenprofil; Alt-`Door[]`-Pfad in `Opening` konsolidieren (technische Schuld, zwei Datenwege).
- [ ] **Fenster 1:1 Rest:** asymmetrische Rahmenbreiten, Bank/Nische, Laibungsverkleidung, Form (Rund/Spitz/Schräg). ~~echtes Sprossengitter~~**erledigt 2026-07-12 (`00bff27`):** `mullionCols` (vertikale Sprossen-Spalten) spiegelbildlich zu `mullionRows` — 3D-Rahmen-Riegel, Ansichts-Trennlinien + Glasscheiben als echtes rows×cols-Raster, Eingabefelder in `OpeningEditorDialog`/`ResourceManager`.
- [ ] **Dach 1:1 Rest:** Kniestock/Drempel, Krüppelwalm, Kehlen bei L-Grundriss (Straight-Skeleton), Gauben/Dachfenster, Aufschieblinge. ~~Dach im Vertikalschnitt~~`f248e2a`.
- [ ] **Geneigte Decke** (Rampe/Gefälle), Deckenspiegel (zweite abgehängte Fläche) — P2/P3.
- **Nicht bauen** (Doc §6): VW-Massketten-Apparat, volle Sichtbarkeitsmatrix, Eck-Fenster/-Dach generisch, Beschlags-Produktbibliothek, IFC-Void-Semantik nachrüsten.
- [ ] **`make2D`-Befehl (Sicht → 2D-Zeichnung mit Füllungen)** (Nutzer-Wunsch 2026-07-10). Aus der AKTUELLEN Sicht — egal ob Grundriss, Schnitt oder 3D — eine flache 2D-Zeichnung aus reinen 2D-Geometrien erzeugen (Linien + Füllungen/Schraffuren, „mit allem"). Zwei Ausgaben: (a) als neue `Drawing2D`-Elemente ins Modell einfügen (auf einer Ziel-Ebene), ODER (b) in die Zwischenablage kopieren (SVG/DXF-Fragment) zum Einfügen anderswo. Vorbild: Vectorworks „2D-Darstellung erzeugen" / Rhino `Make2D`. **Bausteine vorhanden:** Grundriss/Schnitt laufen bereits über `generatePlan()`/`generateSectionPlan()` → RScene → SVG (`sceneToPrintSvg`); für 3D braucht es eine Projektion (HLR/Silhouette) der `projectToModel3d`-Meshes auf die Bildebene (neuer Teil). MVP: Grundriss/Schnitt → Drawing2D + Clipboard; 3D-Projektion als zweite Phase. Scope/Format (SVG vs. DXF vs. native Drawing2D) mit Nutzer schärfen.
- [x] ~~**Dächer: Auswahl + Attribut-Editieren + Löschen**~~**erledigt (`80121a3` + Folge-Commits `7cfdf59`/`eaf57e2`/`64f6179`/`826685c`):** Klick-Auswahl (Traufe-Pick-Polygon + pickRoof), `selectedRoofIds`/`updateRoof`/`RoofInfo`/`roofSelection`; RoofSection-Panel voll editierbar (Form, Firstrichtung X/Y, Neigung(en), **Breite/Tiefe**, Überstand, Dicke, **Traufhöhe**) + Firsthöhe/Fläche read-only; **Auswahl-Hervorhebung im 2D UND 3D** (Draht-Umriss); Löschen; Abwählen an ALLEN Reset-Stellen. **Optional Folge:** ~~3D-Griffe zum Ziehen (Traufe/First)~~**erledigt `4ef40a0`** (Eckpunkt-Resize + Verschieben + First-Griff für Neigung). **Noch offen:** Dachfenster, Kehlen bei nicht-rechteckigem Grundriss (Straight-Skeleton).
- [ ] **Ribbon-UI + modulare Bars** (Nutzer-Vision 2026-07-05, bestätigt: Tab-Schema **2D·3D·BIM·Ansichten**). Voller Plan + datengetriebene Architektur: **[docs/design/ribbon-ui-plan.md](docs/design/ribbon-ui-plan.md)**. Ribbon-Oberleiste mit Tabs ersetzt die Werkzeug-Sidebar; datengetriebene Registry (RibbonItem = tool|command|action) ermöglicht auch eine **modulare Custom-Bar** (gleiche Items, vom Nutzer gewählt). Attribute bekommen volle Höhe, Objektinfo darunter gemergt; XYZ-Box oben rechts bleibt. **Phasen:** ~~(1) Gerüst + 2D/BIM-Tab~~ ✅ (`9d6e86d`), ~~(2) TopBar → Ansichten-Tab mergen~~ ✅ (`85011cb`), ~~(2b) Tabs in die TopBar-Zeile~~ ✅ (`456ebc8`), ~~(2c) OCS-Chrome (kleine Wortmarke + Quick-Access-Icons statt Burger, Zeile 26px)~~ ✅ (`4e3b074`), ~~(2d) Text + Ansichten auf eine Leiste, „Ansichten" als Standard-Tab zuerst~~ ✅ (`fe22cbf`), (3) **teilweise** ✅: Werkzeug-Sidebar aus Default-Layout raus + Attribute volle linke Höhe + Wandtyp-/Deckentyp-Picker ins Attribute-Panel verschoben (Nutzer-Entscheid), `LAYOUT_VERSION`→8; ~~**offen:** Objektinfo unter Attribute mergen~~ ✅ (`3a2cef3`): element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) + Wand-Referenzlinie ins Attribute-Panel verschoben; ObjectInfo trägt nur noch Bezugspunkt + Masse (Nutzer-Wunsch). ~~(4) modulare Custom-Bar~~ ✅ (Tab „Eigene" + -Picker, localStorage-persistiert). **Offen:** 3D-Tab füllen (aktuell leer; ggf. 3D-spezifisch statt Doppelung mit Ansichten), Band-Höhe/Abstände + 26px-Zeile visuell im Tauri abnehmen, Dauer-Zoom-Anzeige (Statusleiste?) klären. ✅ Vorarbeit: Eigenschaften-Grid im OCS-Stil (`00733d8`), Kreis+Bogen-Werkzeuge (`e454eab`/`bd2b12b`). **Nächster Schritt: im Tauri visuell prüfen (Band-Höhe, Icons, Aktiv-Highlight), dann Phase 2/3.**
- [x] ~~**SPIKE — Bild-Texturen in `render3d`**~~**erledigt `0ca3b1d`** (verifiziert 2026-07-05): `RenderStyle::Textured` real, prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), UVs planar in Metern, Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`), `spike3d` per `T` umschaltbar. Alt-Vertexpfad `[pos,normal,color]` bitgleich (Regressionstest). `cargo test` 58 grün (59 mit `--features render`, inkl. naga-Test); `spike3d`-Build sauber; keine neuen Deps, Default-Build unverändert. Auftrag: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md). **Lücken bis „richtig gutes" Texturing → siehe 3D-REST unten.**
## 📋 Backlog (Priorität grob absteigend)
- [x] ~~**Shift-Ortho beim Körper-Verschieben (2D) fehlte**~~**erledigt 2026-07-06:** Nutzer-Report — Shift zum H/V-Einrasten wirkte beim Ziehen eines Vertex-Griffs (`onGripMove`) und beim freien Kanten-Zug (`onEdgeMove`), aber NICHT beim Verschieben eines ganzen Elements per Körper-Griff (`onMoveBody` — Wand/2D-Element/Decke/Treppe/Raum als Ganzes): `PlanView.tsx` reichte `mods` an dieser einen Stelle schlicht nicht durch. Fix: `GripHandlers.onMoveBody` bekommt optionales drittes Argument `mods: ToolMods`, `App.tsx` zwingt bei `mods.shift` das Delta auf die dominante Achse (H/V) — dieselbe Regel wie beim freien Kanten-Zug. `tsc`/`vitest` 339/339 grün.
- [ ] **Feld-Controller (getippte Länge/Winkel relativ zum Ausgangspunkt) fehlt beim Körper-Verschieben + im 3D.** Nutzer-Wunsch 2026-07-06: „allgemein brauchen wir ein System bei 2D- und 3D-Elementen, dass wenn man einen Punkt anwählt, man ihn verschiebt — mit der Option, Länge und Winkel relativ zur alten Position zu wählen." Bestandsaufnahme: **existiert bereits** für den 2D-Vertex-Griff-Drag (`gripEditRef`/„Feld-Controller", Tab öffnet/zykelt Länge↔Winkel-Sperre, `gripEditPoint` in `App.tsx`) — fehlt aber (a) beim 2D-Körper-Verschieben (`onMoveBody`, ganzes Element ziehen) und (b) komplett im neuen 3D-Griffsystem (`Wasm3DViewport.tsx`, s. „3D-Griffe/Editieren" oben — weder Vertex- noch Verschiebe-Griff haben dort eine Locks/HUD-Eingabe). Für (b) zusätzlich zu klären: wie eine getippte Zahl im 3D-Viewport erfasst wird, ohne mit der freien Maus-Navigation (Orbit/Pan) zu kollidieren — eigenes UI-Element (z. B. ein kleines Eingabefeld neben dem gezogenen Griff-Button) naheliegend, aber nicht mit dem 2D-HUD-Muster identisch übertragbar. Mit Nutzer Scope/Reihenfolge klären (2D-Körper zuerst, da mechanische Erweiterung des bestehenden Feld-Controllers; 3D danach als grössere UI-Frage).
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~**komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
- [x] ~~**Kreis-Toolbar** (trivial)~~**erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
- [x] ~~**Bogen-Werkzeug** (mittel)~~**erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 16) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Treppen-Pfeil-Style `filled`, Fenster/Tür-Presets, `swing_invert`) — Feinpolish. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).**
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 16) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Fenster/Tür-Presets) — Feinpolish. ✅ **`swing_invert` gilt als abgedeckt** (2026-07-17): die Aufschlagseite ist über `Opening.swing` (links/rechts) UND der Scharnier-Pfosten über `Opening.hinge` (start/ende) bereits voll im Objekt-Info wählbar (4 Kombinationen = jede Schwenklage), ein separater Invert-Flag wäre redundant. ✅ **Treppen-Pfeil-Style `filled` erledigt 2026-07-17** (uncommittet): `Stair.arrowStyle?:"line"|"filled"` (additiv), gefülltes Dreieck-Polygon statt zwei offener Linien in `addStairSymbol` (`generatePlan.ts`), Segment-Umschalter im Objekt-Info (`onSetStairArrowStyle`), `StairInfo.arrowStyle`, +2 Tests (`generatePlan.stairArrow.test.ts`). tsc/vitest 869 grün. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).**
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen**`parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt**`parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT**`parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings``{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen**`Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
- [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.**
@@ -69,18 +119,65 @@
- [x] ~~**Ebene-Schraffur editierbar**~~**gelandet `dcb6ed5`**: Kategorie-Dialog (`App.tsx`, `editor.hatch`-Feld) hat `<select>` auf `cat.hatch` + `HatchSwatch`-Vorschau. Verifiziert vorhanden.
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
-**swissBUILDINGS3D 1:1 + swissALTI3D-Terrain erledigt 2026-07-12 (`ed724be`):** Root Cause war `swissTopo.ts::fetchBuildings` (generalisierter 1:25'000-Kartografie-Layer, nur Grundriss, `DEFAULT_BUILDING_HEIGHT=9`-Pauschalkiste) + grobe `profile.json`-Terrain-Näherung. Fix: neues `stacApi.ts` (gemeinsamer STAC-Client) + `swissBuildings3d.ts` (echte Wände/Dach-Meshes aus swissBUILDINGS3D-DXF-Kacheln, Generation **2.0 (stabil) UND 3.0 (Beta)** wählbar, über den bestehenden `dxfParser.ts` eingelesen — kein neuer Parser nötig) + `swissAlti3d.ts` (echtes swissALTI3D-Höhenraster, **0.5 m/2 m** wählbare Punktdichte statt Näherung). `ContextImportDialog.tsx` entsprechend erweitert (Gebäude-Modus aus/vereinfacht/2.0/3.0, Terrain-Auflösung). Referenz war das Rhino-Vorgänger-Plugin (`git.kgva.ch/karim/DOSSIER`, `rhino/swisstopo.py`). Pipeline nutzt **render3d** (nicht Three.js, Nutzer-Entscheid 2026-07-12 „scheiss auf three.js darstellung render3d ist fokus").
- ~~**Nordstern-Geo-Rendering**~~ — **war bereits erledigt** (`35299307d`, 2026-07-09, stand hier fälschlich noch als offen): `emitMeshes` in `toWalls3d.ts` liest `project.context` (`importedMesh`/`terrainMesh`) bereits und speist sie in `projectToModel3d` ein — keine weitere Verdrahtung nötig, war Voraussetzung für den swissBUILDINGS3D-Fix oben und stand schon.
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
- **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.
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D bei manuellem Import — der Mesh-Pfad selbst ist über `dxfParser.ts` schon 3D-fähig, s. o.); Building-Draping; höhere DTM-Auflösung.
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~**bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers.
- [x] ~~**Einstellungs-Fenster (Rest)**~~**erledigt:** Verdrahtung war schon **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`); der einzig offene Punkt (Snap/Endpunkt-Default „aki") ist längst entschieden (2026-07-04: Sora #5FA1C9, s. „❓ Offene Rückfragen"). Zeile war stehen geblieben, obwohl die Frage schon geschlossen war.
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
- [x] ~~**Tragwerk-Start: Stützen (Column) — MVP end-to-end**~~ (DOSSIER-Audit A4) — **erledigt 2026-07-07:** `Column`/`ColumnProfile` (rect/round) als platzierte Profil-Extrusion, `Project.columns`, `columnFootprint()` (gemeinsame Geometrie 2D/3D/Selektion/Transform), `columnVerticalExtent` (UK/OK-Anker wie Wand). Platzieren via BIM-Ribbon-Befehl (`column`, Alias stütze; Default Rect 0.3×0.3, Höhe=Geschosshöhe, Rechteck/Rund-Toggle + Live-Vorschau), 2D-Poché auf Kat. 50 (`addColumnPoche`, Component/Hatch-Auflösung), 3D-Prisma (`emitColumns`, synchron, Rust unberührt), eigener Selektionskanal (ALLE Reset-Stellen gespiegelt), `ColumnSection` (Profil/Masse/Höhe/Drehung editierbar), Löschen/move/copy/mirror, DXF-Layer, i18n. +8 Tests (2D-Position/Rotation, 3D-Box/Prisma, vertikale Lage). tsc/vitest 422. **Bewusst ausgelassen:** 3D-Pick (wie ExtrudedSolid), Marquee, Schedule-Zeile, eigene ToolId (Extrude-Muster gefolgt). **Noch uncommittet. Nutzer prüft 3D/2D visuell.**
- [x] ~~**Schnellexport-Dialog (Format + Dateiname) für Topbar-Export**~~ (Nutzer-Wunsch) — **erledigt 2026-07-07:** Klick auf CSV/IFC/OBJ/STL im Topbar-Export-Menü öffnet jetzt `ExportSaveDialog` (Format-Dropdown + Dateiname-Feld, Endung folgt dem Format, Enter/Speichern), statt sofort mit Default-Namen zu laden. PDF/DXF behalten ihre eigenen Options-Dialoge. `runExport(format, filename)` in App.tsx erzeugt+lädt. +i18n `exportSave.*`. tsc/vitest 434. **Offen (bewusst, Nutzer nannte es selbst als Folge):** echter Speicherort-Picker („wo") braucht den nativen Tauri-Save-Dialog (plugin-dialog/fs, Rust) — aktuell Download-Ordner + frei wählbarer Dateiname; die späteren „Ausschnitte" bekommen vordefinierten Namen+Ort. **Noch uncommittet.**
- [x] ~~**Nativer Tauri-Speichern-Dialog für Exporte**~~ (Nutzer „ausnahmsweise JA") — **erledigt 2026-07-07:** `@tauri-apps/plugin-dialog`+`plugin-fs` eingebunden (Cargo/lib.rs/capabilities `dialog:allow-save`+`fs:allow-write-text-file`, `cargo check` grün), Util `src/io/saveFile.ts` (`saveTextFile`: unter Tauri nativer „Speichern unter"-Dialog `save()`+`writeTextFile`, sonst Blob-Fallback mit octet-stream + verzögertem revoke; +2 Tests). App.tsx-`downloadTextFile` ruft jetzt `saveTextFile` (Import ergänzt). **Nutzer testet den nativen Dialog in Tauri.** **Scope-Hinweis:** falls fs-Scope-Fehler, `fs:scope` `$HOME/**` nachrüsten. **Noch uncommittet.**
- [x] ~~**Ausschnitte liessen sich nicht speichern (Tauri)**~~**erledigt 2026-07-08:** Ursache `window.prompt` ist im Tauri-WKWebView DEAKTIVIERT (→ null, kein Name, nichts gespeichert). `ViewSnapshotsPanel` nutzt jetzt In-App-Inline-Eingaben statt prompt/confirm (Speichern + Umbenennen inline, Löschen ohne confirm). tsc/vitest 465.
- [x] ~~**Layouts als Ordner-/Baumstruktur**~~**erledigt 2026-07-08:** Modell additiv `LayoutFolder{id,name,parentId?}` + `Layout.folderId?` + `Project.layoutFolders?`; freie Grösse `customWidthMm?`/`customHeightMm?` an Layout UND MasterLayout (übersteuert paper/orientation), zentraler Helfer `effectiveSheetSizeMm()`. Pure Logik `layoutModel.ts`: `buildLayoutTree`, `collectFolderLayouts`, `deleteFolder` (Kinder reparent auf Elternebene), `folderPdfPages`. `LayoutsPanel.tsx` als Baum (auf/zuklappbare Ordner, Master als eigener Vorlagen-Abschnitt), EIN „+"-Dropdown {Ordner·Layout·Masterlayout}, neues Element landet SOFORT im Inline-Rename (ViewSnapshotsPanel-Muster, kein window.prompt). Zwei Erstell-Dialoge (`LayoutCreateDialogs.tsx`): Layout mit Master-Vorlage-Dropdown ODER freie Grösse (A4/A3/Custom-mm); Masterlayout mit Format/freie Grösse+Ausrichtung. **Ordner→Mehrseiten-PDF funktioniert:** `src/export/layoutPdf.ts` (`buildFolderPdf`/`saveFolderPdf`, jsPDF, `addPage` je Layout mit eigener Grösse, Viewports via generatePlan→planToPrintSvg, Master-Titelblock als Vektor), neuer `saveBinaryFile` in `saveFile.ts` (nativer Speichern-Dialog für PDF-Bytes). +14 Tests (Custom-Grösse, Ordner-CRUD, Baumaufbau inkl. verwaister Refs, Reparenting, PDF-Seitenplan). **Nachtrag (Orchestrator):** `LayoutSheet.tsx` (In-Viewport-Editor, vom Agent bewusst nicht angefasst) nutzte noch `sheetSizeMm(layout.paper,...)` ohne Custom-Grösse zu respektieren — auf `effectiveSheetSizeMm(master ?? layout)` umgestellt (identische Regel wie der PDF-Export), damit Blätter mit freier Grösse auch im Viewport korrekt dargestellt werden. tsc/vitest 505. **Noch uncommittet. Nutzer prüft visuell in Tauri** (-Menü, Ordner-Zuordnung, Dialoge, Mehrseiten-PDF-Reihenfolge/Grössen).
- [x] ~~**Icons/A0-A6+B0-B6/aktiver Ordner/Drag&Drop im Layouts-Baum**~~**erledigt 2026-07-08:** eigene inline-SVG-Icons (`FolderIcon` auf/zu, `LayoutSheetIcon`, `MasterSheetIcon` mit Stern-Akzent), Muster wie die bestehenden Tab-Icons. `LayoutPaperFormat` volle ISO-216-Reihe A0A6+B0B6 (`PAGE_MM`/`PAPER_FORMATS`/`PAPER_FORMAT_GROUPS`), Dropdowns gruppiert A-/B-Reihe. „Aktiver Ordner" war schon korrekt (verifiziert, keine Änderung nötig). HTML5-Drag&Drop (Layout/Ordner zwischen Ordnern, Root-Drop, Ziel-Highlight) mit Zyklus-Schutz (`isDescendant`, `moveFolderToFolder` verwirft Selbst-Verschachtelung als No-op). `layoutPdf.ts`/`LayoutSheet.tsx` bekommen die neuen Formate automatisch (laufen über `effectiveSheetSizeMm`/`PAGE_MM`, kein Hardcoding). +14 Tests. tsc/vitest 519. **Noch uncommittet. Nutzer prüft visuell.**
- [x] ~~**Ausschnitte-Panel: Footer-Bar + Anwahl-Persistenz**~~**erledigt 2026-07-08 (Agent starb erst beim Schluss-Testlauf, aber vollständig verdrahtet + grün):** Footer im ViewSnapshotsPanel zeigt bei angewähltem Ausschnitt Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal `boolMapEqual`/`matchingComboName` gegen gespeicherte Combos), aktive Override-Namen, + Inline-Rename in der Footer-Bar. Anwahl bleibt markiert bis Abweichung: `selectedViewSnapshotId`-State + `snapshotMatchesLiveState`-Vergleich in App.tsx (bei Änderung eines erfassten Feldes → Auswahl weg). host.ts um `selectedViewSnapshotId`/`listLayerCombos`/`loadLayerCombo`/`listDrawingCombos`/`loadDrawingCombo` erweitert. tsc sauber, vitest 532 (+13). **Noch uncommittet. Nutzer prüft visuell.**
- [x] ~~**Ausschnitte-Panel: Ordner wie bei Layouts**~~**erledigt 2026-07-08:** echte Ordner-/Baumstruktur (spiegelbildlich zu Layouts). Modell additiv `ViewSnapshotFolder{id,name,parentId?}` + `ViewSnapshot.folderId?` (altes `folder?`-Namensfeld bleibt LEGACY-kompatibel), `Project.viewSnapshotFolders?`. Pure Baum-Logik `src/state/viewSnapshotFolders.ts` (`buildViewSnapshotTree`/`moveSnapshotToFolder`/`moveFolder` mit Zyklus-Schutz/`deleteFolder`-Reparent, +20 Tests). Panel: auf/zuklappbare Ordner, zwei Header-Buttons (FolderPlus/Plus), aktiver-Ordner-State, Inline-Rename, Löschen, Drag&Drop. **Footer-Bar + Anwahl-Persistenz UNVERÄNDERT erhalten** (unter dem Baum). +i18n. tsc/vitest 558. **Noch uncommittet. Nutzer prüft visuell.**
- [x] **2D-Zeichnen auf „Zeichnung"-Ebenen UND auf Layout-Blättern freigeben; 3D/BIM dort sperren (Nutzer-Wunsch 2026-07-08):****ALLE drei Teile erledigt 2026-07-08.** **TEIL B erledigt 2026-07-08:** 2D-Annotationen (Linie/Rechteck/Text in mm-Papier) direkt auf Layout-Blättern — `LayoutAnnotation` an `Layout` (additiv) + CRUD (`layoutModel.ts`: create/add/remove/patchAnnotation) + Editor in `LayoutSheet.tsx` (Tool-Buttons, zeichnen/Vorschau/commit, `pickAnnotation`/`translateAnnotation` in `layoutSheetMath.ts`, Selektion+Verschieben+Löschen, HUD mit Farbe/Stärke, `PromptDialog` für Text — kein window.prompt) + Handler in App.tsx. Tests: `layoutModel.test.ts` + `layoutSheetMath.test.ts`, Suite 588 grün. Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping, Sheet-Clamping beim Verschieben. **TEIL A erledigt 2026-07-08** — die 2D-Zeichenwerkzeuge (select/line/polyline/rect/circle/arc/text) sind bereits nicht `floorOnly`, die zugehörigen Commands (line/rect/polyline/circle/arc/text) auch nicht → die Command-Engine erlaubt das Zeichnen auf jeder Ebene, und eine `"drawing"`-Ebene rendert schon die volle interaktive `LevelPlanView` (App.tsx:5515). Der einzige echte Blocker war das UI-Gating: die **ToolsPanel-Sidebar** sperrte ALLE Werkzeuge ausser „select", sobald `!toolsEnabled` (App.tsx `activeLevel.kind === "floor"`). Fix: `ToolsPanel.tsx` gatet jetzt per `tool.floorOnly && !toolsEnabled`**Parität mit dem Ribbon** (`RibbonBar.tsx`, das schon so gatete). Damit sind auf „drawing"-Ebenen die 2D-Tools nutzbar, BIM/3D-Tools (wall/ceiling/window/door/stair/room/column, alle `floorOnly`) bleiben grau. `AttributesPanel.tsx:78` bleibt unverändert (gatet nur den Wandtyp/Deckentyp-Picker, der ohnehin nur bei floorOnly-Tools erscheint). Verifiziert: `tsc` sauber, `vitest` 558/558. TEIL B (grösser, OFFEN): **auf Layout-Blättern 2D zeichnen** — der `LayoutSheet`-Viewport-Editor kann bisher nur Viewports platzieren; für Annotationen (Linien/Text/Rechtecke direkt aufs Blatt) fehlt ein Annotations-Datenmodell am `Layout` + das Zeichnen/Rendern auf dem Blatt (mm-Koordinaten). Scope Teil B mit Nutzer/als eigene Phase. **TEIL C erledigt 2026-07-08 („endlich"): Text-Platzierungswerkzeug als 2D-Element.** Das `{shape:"text"}`-Drawing2D-Modell + Rendering (`generatePlan``drawingText`) + Transform (`transform.ts` `at`) existierten schon (DXF-Import-Weg); es fehlte nur das PLATZIER-Werkzeug. Umgesetzt: neues `text`-Command (`src/commands/cmds/text.ts`: Ankerpunkt klicken → Freitext in der Command-Line eingeben → commit `Drawing2D {shape:"text", height:0.25m, angle:0}`), Placeholder-Tool `text` (nicht floorOnly) gekoppelt via `TOOL_COMMAND`, in Registry + Aliase (tx/txt/beschriftung) + `ToolId` + `TOOL_ORDER` + Ribbon-2D-Gruppe + ToolIcon (Serifen-A) + i18n (de/en). Neu: die Command-Engine routet jetzt Freitext-Eingabe (`AcceptKind` um `"text"` erweitert, `engine.ts` `routeTypedInput` reicht bei `accepts:["text"]` die getippte Zeile 1:1 durch — vor der numerischen Auflösung, damit auch Zahlen/Kommas als Label ankommen). Nutzer-Test: Text-Tool wählen → in den Plan klicken → Label tippen → Enter. **Noch uncommittet, WASM-unabhängig (rein TS).**
- [ ] **Masterlayout im Viewport ansehen + Elemente darauf platzieren (Nutzer-Wunsch 2026-07-08) — NACH Rollback+Footer-Agenten (gleiche Dateien LayoutsPanel/LayoutSheet):** ein Masterlayout soll wie ein Layout im `LayoutSheet`-In-Viewport-Editor geöffnet werden können, um Plankopf-Elemente/Rahmen VISUELL darauf zu platzieren (statt nur über ein Formular mit festen Titelblock-Textfeldern). `LayoutSheet.tsx` arbeitet aktuell nur mit `Layout` (hat `viewports: LayoutViewport[]`); für Master fehlt sowohl das Öffnen im Viewport als auch ein Datenmodell für frei platzierbare Grafik-/Text-Elemente (Plankopf-Felder, Rahmen-Linien) auf dem Master. Scope/Umfang mit Nutzer klären, bevor gebaut wird: welche Elementarten (Text-Feld mit Platzhalter-Variable wie {ProjectName}/{Scale}/{Date}, Linie/Rechteck als Rahmen, Logo/Bild?), wie sie sich von den normalen `LayoutViewport`s unterscheiden (kein Ausschnitt-Bezug, rein grafisch), Doppelklick-Navigation vom `LayoutsPanel` aus zum Öffnen eines Masters im Viewport (analog `onOpenLayout`).
- [x] ~~**LayoutsPanel: Doppel-Name + zwei Buttons je Abschnitt + Master in Ordnern**~~**erledigt 2026-07-08:** Doppel-„LAYOUTS" behoben durch Umbenennung des ÄUSSEREN Panel-Titels auf „Mappe" (DE) / „Portfolio" (EN, neuer Key `layouts.panelTitle`); innere Abschnitte bleiben „Layouts"/„Masterlayouts". Kombiniertes „+"-Dropdown ersetzt durch zwei Icon-Buttons je Abschnitt: `FolderPlusIcon` (Ordner) + `PlusIcon` (Layout bzw. Masterlayout). Masterlayouts jetzt in eigenen Ordnern: `MasterLayout.folderId?` + `LayoutFolder.kind?:"layout"|"master"` (fehlt=layout, rückwärtskompatibel), getrennte Bäume (`buildMasterTree`, kind-Filter), eigener `activeMasterFolderId`. Bug gefixt: `deleteFolder`/`moveFolderToFolder` verloren `kind` beim Reparent → `stripParentId`. +6 Tests. PanelFrame unangetastet. tsc/vitest 538. **Bewusst offen:** Drag&Drop im Master-Baum (bestehende Master nur beim Anlegen in Ordner). **Noch uncommittet. Nutzer prüft visuell.**
- [x] ~~**Fixer, unlöschbarer „Masterlayout"-Ordner**~~**VERWORFEN 2026-07-08 (Nutzer-Kurskorrektur):** gebaut, dann sofort zurückgebaut — Nutzer sah die bestehende Lösung (Masterlayouts als separate Kategorie/Abschnitt ausserhalb der Ordnerstruktur, aus dem vorherigen Layouts-Agenten) live und wollte sie explizit BEHALTEN, keinen Ordner. Vollständig zurückgerollt (`layoutModel.ts`/`LayoutsPanel.tsx`/`App.tsx`/i18n), verifiziert tsc sauber + vitest exakt Baseline 519 (keine Regression). Masterlayouts bleiben separater Abschnitt.
- [x] ~~**`window.prompt`/`confirm` überall ersetzen (Tauri-WKWebView deaktiviert sie) — SWEEP**~~**erledigt 2026-07-08:** neue generische `src/ui/PromptDialog.tsx` (Muster ExportSaveDialog: Overlay+Dialog, Enter bestätigt, Esc schliesst) ersetzt ALLE verbliebenen `window.prompt`-Aufrufe: `ComboMenu` (TopBar, Ebenen-/Zeichnungskombination speichern), `LayoutMenu` (Arbeitsumgebung speichern), Text-Grösse „frei" (`TextGroup`), Massstab „frei" (`ViewRibbonTab`), sowie die Tastatur-Flows O/P (Array-Kopien/Verteilen-Anzahl, `App.tsx`: `promptCount()` entfernt, ersetzt durch `pendingCopyMode`-State + Dialog). `window.confirm` war bereits an allen Fundstellen vorher entfernt (ViewSnapshots/LayoutsPanel). Verifiziert: `grep -rn 'window\.prompt(\|window\.confirm('` über src/ liefert NICHTS mehr. tsc sauber, vitest 491/491. **Noch uncommittet.**
- [x] ~~**Layout im Viewport editierbar (wie ArchiCAD/VW/OpenCADStudio) — Phase 2b**~~**erledigt 2026-07-08 (Agent-verifiziert, NICHT visuell abgenommen):** Blatt jetzt erstklassiger Ansichtsmodus im Haupt-Viewport (`activeLayoutId` in App.tsx → `LayoutSheetView` statt Content), schwebendes `LayoutEditor`-Fenster ENTFERNT. Maus-Editing (`LayoutSheet.tsx`): Viewport aufziehen→Ausschnitt-Bindung (Inline-Dropdown, kein prompt), auswählen/verschieben/8-Griff-Resize (Commit bei Pointer-Up, Live-Ghost), löschen (Entf), Massstab-HUD; Pan (Mitte/Space)/Zoom-to-Cursor/Einpassen. Pure Transform+Hit-Test `layoutSheetMath.ts` (+26 Tests). `LayoutsPanel` window.prompt/confirm ebenfalls auf Inline umgestellt. +i18n `layouts.*`, CSS `.layout-sheet-*`. tsc/vitest 491, vite build OK. **NOCH VISUELL IN TAURI ZU PRÜFEN.** Offen: Einrasten an Kanten/Nachbarn, Ribbon-Massstab wirkt im Layout-Modus nicht (nicht deaktiviert). **Noch uncommittet.**
- [x] ~~**Layout-Blätter mit Masterlayout — Phase 2a**~~ (A3, setzt auf Ausschnitte auf) — **erledigt 2026-07-08:** Modell `Layout`/`LayoutViewport`/`MasterLayout` + `Project.layouts`/`masterLayouts` (im Dokument). `LayoutsPanel` (Layouts+Master anlegen/umbenennen/löschen, im Rechts-Dock, `LAYOUT_VERSION` 10→11). `LayoutEditor` (schwebendes Fenster wie ResourceManager): Blatt im mm-Seitenverhältnis (Zoom/Einpassen), Titelblock (Master-Vererbung via `resolveTitleBlock`, leere Felder → Defaults Projektname/Blattname/Massstab/Datum), und jeder Viewport rendert ECHT den Plan seines gebundenen Ausschnitts via `generatePlan``planToPrintSvg` (viewBox-mm = Viewport-mm, 100%-Einpassung + Clipping). Viewport hinzufügen (Ausschnitt-Picker)/entfernen, Position/Grösse/Massstab numerisch; Papier/Ausrichtung/Master pro Blatt. +16 Tests (Blatt-Geometrie, immutables CRUD, Master-Vererbung, mm-Geometrie). +i18n `layouts.*`. **Ehrliche Grenzen:** Live-Viewport-Render nur per Code korrekt, NICHT im Browser abgenommen (Nutzer prüft); Ausschnitte auf Schnitt/Ansicht (activeLevelId≠Geschoss) → leerer Plan (nur Grundriss-Ausschnitte füllen den Viewport); Titelblock-Font ≠ PDF-Helvetica (Phase-2b-PDF baut es separat). **Bewusst Phase 2b:** Multi-Page-PDF-Export, Maus-Drag/Resize der Viewports, „Alle aktualisieren", erweiterte Kamera-POV (Augen-/Zielhöhe). tsc/vitest 465. **Noch uncommittet.**
- [x] ~~**Ausschnitte / View-Snapshots — Phase 1**~~ (A2, Fundament) — **erledigt 2026-07-07:** `ViewSnapshot` (name/folder + viewType/view3d/fov/scaleDenominator/detail/activeLevelId + Ebenen-/Zeichnungs-Sichtbarkeit + aktive Override-Ids) am `Project.viewSnapshots` (im Dokument). Pure Capture/Apply-Logik `src/state/viewSnapshots.ts` (+8 Tests: Roundtrip, defensives Auffüllen, Override-Menge). App.tsx: Capture sammelt View-State + `snapshotLayer/DrawingVisibility` + aktive Overrides; Apply Reihenfolge Geschoss→View→Sichtbarkeit→Overrides, Massstab-Zoom per 1 rAF. Neues `ViewSnapshotsPanel` (Liste/Ordner, Speichern/Umbenennen/Löschen), registriert im Default-Rechts-Dock (`LAYOUT_VERSION` 9→10). +i18n `viewsnap.*`. **Bewusst offen Phase 2:** Layout-Blätter+Masterlayout (A3), Viewport-Platzierung + Ausschnitt-Bindung, „Alle aktualisieren", Multi-Page-PDF, erweiterte Kamera-POV (Augen-/Zielhöhe getrennt), Ordner-Drag&Drop; Apply erzeugt mehrere Undo-Schritte (bekannt). **Noch uncommittet.**
- [x] ~~**IFC-Wände nicht solid (oben/unten offen)**~~**erledigt 2026-07-07:** ECHTE Ursache war eine invertierte Wicklung, NICHT eine fehlende Fläche — der Y-up→Z-up-Achsen-Swap im IFC-Export (`(mx,my,mz)→(mx,mz,mybase)`) ist eine Reflexion (Determinante 1) und kehrte jedes Dreieck um → Normalen nach innen → Viewer cullt Vorderseiten → hohl. Fix: beim Swap Dreieck umdrehen (`i0,i2,i1`). `Closed=.T.` gesetzt, verifiziert über `isWatertight` (`wallMeshCut.ts`): NICHT der naive „jede Kante von 2 Dreiecken"-Test (der meldet die legitimen T-Stösse aus vollen Deckel/Boden-Streifen fälschlich als offen), sondern das T-Stoss-robuste Gauss-/Divergenz-Kriterium `∮n dA=0` + signiertes Volumen >0. STL/OBJ waren korrekt (kein Swap). +15 Tests (Wasserdicht/Winding, inkl. IFC-Face-Set-Volumen aus dem geparsten SPF). tsc/vitest 449, cargo check grün. **Noch uncommittet. Nutzer prüft im Viewer.**
- [x] ~~**Exporte an 3D angleichen: Öffnungen ausschneiden + Joins (STL/OBJ/IFC)**~~ (Nutzer: „es sollte so raus wie es im 3d ist") — **erledigt 2026-07-07:** neuer TS-Helfer `src/plan/wallMeshCut.ts` portiert `render3d/mesh.rs::extrude_layer_segment_with_holes` 1:1 (Koordinaten-Kompression `solidSubrects`, Langseiten/Deckel/Boden/Stirnkappen minus Loch-Intervalle, bis zu 4 Laibungsquads pro Loch, konsistentes Winding). STL/OBJ: Wände mit `holes` → ausgeschnittenes Mesh (sonst klassische Box); Joins kommen aus `pickGeometry`. IFC: Wände als `IfcTriangulatedFaceSet` (IFC4, gespeist aus demselben Loch-Mesh) → sichtbar wie 3D in JEDEM Viewer (behebt „Void nicht subtrahiert"); Tür/Fenster bleiben eigene `IfcDoor`/`IfcWindow`-Objekte, die das Loch füllen; IfcOpeningElement/Void/Fill entfallen (Loch steckt im Mesh). +12 Geometrie-Tests (Loch-Region hat keine Voll-Wand-Dreiecke, Laibungen vorhanden, dangling-refs grün). tsc/vitest 434. **Ehrliche Rest-Lücken:** echte Miter-Gehrungsflächen (Export nutzt die achsparallele pickGeometry-Näherung), Schicht-Farben (IFC-FaceSet ohne per-Vertex-Farbe/IfcStyledItem), Fensterglas/Rahmendetail. **Noch uncommittet. Nutzer prüft im Viewer.**
- [x] ~~**Layout-Auswahl in die Einstellungen, umbenannt „Arbeitsumgebung"**~~**erledigt 2026-07-07:** `LayoutMenu` (Fenster-/Dock-Layouts) aus der TopBar-Zeile in `SettingsDialog` verschoben (neue erste Sektion), `layoutMenu`-Prop von TopBar → SettingsDialog umgehängt (ReactNode-Import in TopBar entfernt, da sonst ungenutzt). i18n `layout.*` umbenannt (DE „Arbeitsumgebung", EN „Workspace") + neue `settings.section.workspace`. tsc/vitest 423. **Noch uncommittet.**
- [x] ~~**Export-Sammelmenü + Über-Dialog + Petrol-Punkt (TopBar-Feinschliff)**~~**erledigt 2026-07-07:** (1) Die 6 einzeln aufgereihten Export-Icons (PDF/DXF/CSV/IFC/OBJ/STL) zu EINEM „Export"-Knopf (`ExportMenu`, `ios_share`) mit Dropdown-Popover zusammengefasst — entschlackt die Chrome-Zeile; Import bleibt separat. (2) Klick auf die Wortmarke „dossier" öffnet einen In-App-„Über"-Dialog (`AboutDialog`: Marke, Version 0.1.0, Kurzbeschreibung, OSS-Lizenzen; Esc/Klick-ausserhalb schliesst). Wortmarke ist jetzt `<button>` (Chrome zurückgesetzt, Optik identisch). +i18n `file.export`/`about.*`, CSS (`tb-menu`/`about-*`). tsc/vitest 422. **Fix (Nutzer-Report „IFC nicht anwählbar"):** Popover per `createPortal` nach body (entkommt dem Topbar-Stacking-Context, der die unteren Einträge unter dem Ribbon verdeckte) + zweiter `popRef` im Outside-Click-Handler (sonst schloss der mousedown das Menü vor dem Klick) — Muster wie `ContextMenu`/`Dropdown`. **Hinweis:** In-App-Dialog statt nativem macOS-About-Panel (das bräuchte ein Rust-Command in src-tauri) — falls der native Panel gewünscht ist, Folge-Item. **Noch uncommittet.**
- [x] ~~**Brand-Punkt petrolgrün**~~**erledigt 2026-07-07:** `.brand-dot` fix `#0f766e` (petrolgrün wie DOSSIER-Rhino-Plugin) statt `var(--accent)`. Feinton per Nutzer justierbar.
- [x] ~~**OSM-Import auf 7 Kategorien**~~**erledigt 2026-07-07:** 3 neue Kontext-Kategorien im Overpass-Import — `parking` (amenity=parking, Fläche), `railway` (railway~rail|tram, Linie), `forest` (landuse=forest + natural=wood, aus `green` herausgelöst; `green` jetzt nur Parks/Wiesen). `OsmSelection`/`buildQuery`/`categorize`/`isClosedCategory` (osm.ts), `GeoCategory`/Labels/Layer (geoContext.ts), 3 Checkboxen (ContextImportDialog.tsx), i18n `ctxImport.src.*`. tsc/vitest 414. **Noch uncommittet.**
- [x] ~~**IndexedDB-Persistenz-Kern**~~**erledigt 2026-07-07:** `src/state/projectStore.ts` — async CRUD `saveProject`/`loadProject`/`listProjects`/`deleteProject` über IndexedDB (DB „dossier"/Store „projects", keyPath name, `{name,savedAt,project}`), guard-sicher (fehlt `indexedDB` → degradiert: load→null/list→[]/save+delete no-op, 1× warn) — läuft daher auch in vitest/node. +7 Guard-Tests (414). **UI-Wiring (Speichern/Öffnen-Menü, Recent-Liste, Auto-Save) NOCH OFFEN — Folge-Item.** **Noch uncommittet.**
- [x] ~~**ObjectInfo Wand: UK/OK-Reihenfolge vertauscht**~~**erledigt 2026-07-07:** im Wand-Abschnitt steht jetzt OK (Oberkante) oben, UK (Unterkante) darunter — räumlich passend (Nutzer-Report „stimmt visuell nicht überein"); Decken-Abschnitt hatte die Reihenfolge schon → jetzt konsistent. Reiner JSX-Reorder. tsc/vitest 414. **Noch uncommittet.**
- [x] ~~**STL- + OBJ-Export (3D-Mesh, Interop)**~~**erledigt 2026-07-07:** pures Modul `src/export/exportMesh.ts` (`exportObj`/`exportStl`, ASCII-STL) aus `pickGeometry(project)` (echte 3D-Geometrie inkl. Joins/Decken-Dominanz): Wand-Bänder als 12-Dreieck-Boxen, Decken/Extrusionen als triangulierte Prismen (bestehender `triangulate()` aus glPlanCompile, kein WASM), y-up rechtshändig. Voll verdrahtet: Quick-Access-Buttons (`view_in_ar`/`landscape`) + `onExportObj`/`onExportStl` in App.tsx (Blob-Download) + i18n `file.exportObj`/`file.exportStl`. 8 Kern-Tests (Index-Bounds, facet/vertex-Konsistenz, Tri-Zahl-Plausibilität). tsc/vitest 407. **Bewusste v1-Limitation:** Öffnungslöcher NICHT ausgeschnitten (Wände volle Boxen) — ladbar in Blender/MeshLab, aber nicht öffnungsgenau; öffnungsgenaue Variante = Folge-Item. Extrusionen nutzen Prisma-Triangulierung statt async truckSolid-WASM (bewusst, wegen synchronem pure-Kern). **Noch uncommittet.** (IFC-Import + DWG-Schreiben bleiben offen.)
- [x] ~~**Raumstempel: Personenzahl + Flächen-Rundung**~~**erledigt 2026-07-07:** `RoomStamp.occupancy?`/`roundingStep?` (additiv, optional). `formatStampArea(area, step?)` in `roomStamp.ts` rundet auf Vielfache von `step` (0.01/0.1/0.5/1 m²), sonst 2 Nachkommastellen wie bisher; Personenzahl als „{n} Pers."-Zeile im Stempel. Editor-Zeilen (Dropdown Rundung + Zahlfeld Personen). Drag&Drop-Builder bleibt out of scope. +6 Tests, +i18n. tsc/vitest 399. **Noch uncommittet.**
- [x] ~~**Auto-Zoom nach Geo-Import**~~**erledigt 2026-07-07:** nach swisstopo/OSM/Terrain-Import (`onAddContextObjects`) passt die Plan-Ansicht automatisch auf die neuen Objekte ein. `PlanViewHandle.fitBounds(pts)` (dünner Wrapper um `fitBoxFor`), `contextObjectsBounds()` bildet die Gesamt-BBox über alle neuen ContextObjects (contourSet-Punkte + Mesh-/Terrain-Positions, Z ignoriert), `addContextObjectsAndFit` in App.tsx verdrahtet. DXF-Import-Pfad (eigene viewCenter-Logik) bewusst unberührt. Kein i18n, host.ts unverändert. tsc/vitest 393. **Noch uncommittet.**
- [x] ~~**Rich-Text Hoch-/Tiefstellung (super/sub)**~~**erledigt 2026-07-07:** zwei Toolbar-Buttons in `RichTextEditor.tsx` (Muster der bestehenden B/I/U/S-Buttons; super/sub-Exklusivität war im Modell `richText.ts::applyMark` schon gelöst); +i18n `rt.super`/`rt.sub`. Teil des ROADMAP-Items „Rich-Text-Annotationen" (Maskierung/Rahmen bleiben offen/blockiert). tsc/vitest 393. **Noch uncommittet.**
- [x] ~~**B2 — Objekt-Info numerisch erweitern**~~**erledigt 2026-07-07:** im ObjectInfo-Panel sind X/Y jetzt editierbar (Commit verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die Koordinate wandert, via neuem Host-Callback `onMoveSelectionBy`), dazu ein Dreh-Feld (`RotateField`, Delta-Grad um den Anker, `onRotateSelectionAround`) und für einzelne Drawing2D-`line`/`circle` ein Längen- bzw. Radius-Feld (`onSetDrawingLineLength`/`onSetDrawingCircleRadius`). Move/Rotate nutzen die bestehende `commitTransform`-Maschinerie (transform.ts unverändert), wirken für wall/drawing2d/extrudedSolid; für ceiling/opening/stair/room ist der Aufruf No-op (Feld rendert, revertiert — gleiches Muster wie das bestehende Breite/Höhe-Resize). +i18n. tsc/vitest 393. **Noch uncommittet.**
- [x] ~~**Komponenten-Thumbnail: PBR-Kugel-Vorschau (D3-Rest)**~~**erledigt 2026-07-07:** ComponentsTab-Listeneintrag + Detail-Preview in `ResourceManager.tsx` zeigen jetzt bei gesetztem `component.material` die live gerenderte PBR-Kugel (`ComponentMaterialSphere`, lazy via IntersectionObserver + `requestMaterialPreview`, Muster von `MaterialLibraryTile`); `materialAssetOf` löst `libraryId` gegen `MATERIAL_LIBRARY` auf, sonst Ad-hoc-Asset aus den eigenen Map-URLs. Fallback-Kette Material→Hatch→Color unverändert. Nur `ResourceManager.tsx`, kein i18n. tsc/vitest 393. **Noch uncommittet.**
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
- [x] ~~**Linienstile aufräumen**~~**erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line``thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive``thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`.
- [ ] **AUSSCHNITTE- + LAYOUT-SYSTEM (Nutzer-Vision 2026-07-07, grosser Hebel).** Zwei zusammenhängende Systeme (DOSSIER A2→A3, ROADMAP §7 Phase 3 + §11 Pläne):
- **Ausschnitte / View-Snapshots (A2):** benannte, gespeicherte Ansichten, die einen kompletten Darstellungszustand einfangen und wiederherstellen. **Kern-Einsicht des Nutzers: das muss NICHT neu erfunden werden — die Bausteine existieren schon und werden nur KOMPONIERT:** ein Ausschnitt = Referenz/Snapshot auf {**Ebenenkombination** (`src/state/visibilitySets.ts` `LayerCombo`) + **Zeichnungsebenen-Kombination/-Einstellungen** (`DrawingCombo` ebd.) + aktive **Overrides**-Regelmenge (`Project.overrideRules`, A1) + Kamera/Ansichtstyp/View3d + **Massstab** + **Detailgrad**}. Also im Wesentlichen ein Container, der diese bereits vorhandenen Zustände bündelt, benennt (Ordner/Presets) und per Klick wiederherstellt. Speicherort: Projekt (nicht localStorage — Ausschnitte gehören zum Dokument). Voraussetzung für die halbe Pläne-Tabelle (Layouts, Multi-Page-PDF, Detail-Bindung, Massstab-pro-Viewport).
- **Layout-System mit Masterlayout + Layouts (A3):** Druck-/Plan-Blätter (A4/A3-Papierformate), auf denen mehrere **Details/Viewports** platziert werden, jedes an einen Ausschnitt-Snapshot gebunden (bei „Alle aktualisieren" ziehen sie nach). Ein **Masterlayout** (gemeinsame Elemente: Titelblock/Planrahmen/Logo/Plankopf, wie Vorlagen-Master in InDesign/ArchiCAD) wird von den einzelnen **Layouts** geerbt/überlagert — Änderung am Master schlägt auf alle Layouts durch. Pro Layout eigener Massstab pro Viewport (Auto-DPI, Plotweight-/Schraffur-Skalierung, `sceneToPrintSvg.ts` als Basis), PDF-Export pro Blatt bzw. Multi-Page.
- **Erweiterte Kamera-Settings als Teil des Ausschnitts (Nutzer 2026-07-07):** mehr einstellbare Kamera-Parameter, die der Ausschnitt MIT speichert — u. a. **Zielhöhe** und **Augenhöhe** (Eye/Target-Z getrennt), FOV, Blickrichtung/Distanz; die Parameter dürfen **pro Projektionsart unterschiedlich** sein (Perspektive vs. Isometrie/Ortho haben je eigene sinnvolle Sets — Iso braucht z. B. orthoHalfHeight statt FOV/Augenhöhe). Heutiger Zustand: nur FOV global (`fov` in App-State, CameraMenu) + Orbit-State intern im `Wasm3DViewport` (`OrbitState` yaw/pitch/dist/target, `orthoHalfHeight`) — nicht als benannte, persistierbare Kamera-Definition nach aussen geführt. Schritt dazu: Kamera-Zustand als serialisierbares Objekt exponieren (get/set am Viewport), UI-Felder (Augenhöhe/Zielhöhe/Distanz) im Kamera-Popover, dann im Ausschnitt-Modell mitführen.
- **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen.
- [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten):
-**Phase 1 erledigt 2026-07-17 (Kopplung + Navigation, KEIN Rust — plan: `.claude/plans/cozy-bubbling-goose.md`):** der 3D-Live-Schnitt (`Wasm3DViewport`) folgt jetzt der gewählten Schnittlinie statt einer fest verdrahteten horizontalen Ebene — die Engine kann beliebige Ebenen längst (`set_section_plane(point,normal)`), es fehlte nur die Bindung. App-State `section3dCutId` + abgeleitete `section3dPlane` via `sectionPlaneFromLevel` (world point+normal aus `linePoints`/`directionSign`, `plan/toSection.ts`) → durch `Viewport3D``Wasm3DViewport` (neue Props `section3d`/`onClearSection3d`). Schalter „Im 3D schneiden" in der Schnittlinien-Sektion des Objekt-Info-Panels (`host.section3dCutActive`/`onToggleSection3dCut`); der Viewport-Overlay-Knopf ist an die aktive Linie gebunden und hebt sie auf. **Doppelklick auf die Schnittlinie im Grundriss → springt in die 2D-Schnittansicht** (`PlanView.onOpenSectionLevel``onSelectLevel`). 2D-Editieren (Endpunkte/Blickrichtung/Tiefe) existierte schon im Objekt-Info. `tsc` + `vitest` 866 grün. **Nutzer prüft visuell in Tauri** (render3d-Pfad).
-**Phase 2 Teil „eigene Ebene / Sichtbarkeit" erledigt 2026-07-17:** eine unsichtbare Schnitt-/Ansichtsebene (`visible===false`) zeigt ihre Führungslinie im Grundriss nicht mehr (`addSectionLines` in `generatePlan.ts`, +1 Test). Ein-/ausblendbar wie andere Ebenen (Zeichnungsebenen-Panel).
- **Offen Phase 2 (engine-schwer, mit Nutzer im Tauri-Loop):** Farbe der 3D-Schnittebene + **Cut-away-Schleier/Schraffur auf der weggeschnittenen Seite** (wgpu-Shader/`section_fill.rs`/`section.rs`) — blind über Nacht bewusst NICHT gebaut (unverifizierbar ohne Tauri-Sicht, s. Arbeitsstil „Nutzer prüft render3d visuell").
- **Offen Phase 3 (engine-schwer):** 3D-POV je Schnitt (Augen-/Zielhöhe/Winkel getrennt, pro Projektionsart) im Datenmodell + eigenes 3D-Schnittfenster (Doppelklick 3D-Symbol → Clip+Kamera); koppelt an das Ausschnitte-/Kamera-System. Kamera-Kopplung an `Wasm3DViewport`-Orbit → Tauri-Verifikation nötig.
- Restliche Vision-Bausteine (unverändert offen):
- **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln).
- **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D.
- **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion.
- **3D-Schnitt-POV-Parameter:** die 3D-Schnittebene definiert zusätzlich Blickpunkt (POV), **Augenhöhe** + **Zielhöhe** (getrennt) und **Blickwinkel** — dieselben erweiterten Kamera-Settings wie im [Ausschnitte-System oben] (dort pro Projektionsart mitgespeichert). Ein Schnitt ist damit ein Ausschnitt mit Clip-Ebene + Kamera-Definition.
- Verknüpfung: baut auf dem bestehenden 2D-Schnitt (`DrawingLevel kind:"section"`, `linePoints`/`directionSign`) + der 3D-Live-Schnittebene (`03f0c40`, Clip-Uniform) auf; koppelt eng mit dem Ausschnitte-/Layout-System (View-Snapshots) und den erweiterten Kamera-Settings. Grosses, phasenweises Feature — mit Nutzer Reihenfolge/Scope festlegen (zuerst: 3D-Schnittebene als 2D-Grundriss-Objekt platzier-/editierbar + Doppelklick-Navigation).
- [ ] **AUDIT (DOSSIER-Studie):** ~~A1 Override-Regel-Engine~~ ✅ (2026-07-07, s. Erledigt); A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt; D2 volles Element-Set erledigt 2026-07-07.) Belege in `/tmp/dossier-ref/rhino/*.py`.
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
@@ -92,11 +189,17 @@
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
- **Aufwand:** gross (24 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
- [x] ~~**2D-Zeichnungen (`drawings2d`) fehlen im nativen render3d/WASM-Pfad bei z=0.**~~**erledigt 2026-07-06:** Nutzer-Report — früher wurden 2D-Plan-Elemente auch im 3D als flache Referenz bei z=0 gezeigt, das gab es nur noch im alten three.js-Fallback (`addDrawing2DLines`), nicht im nativen `render3d`/wgpu-Pfad (heute Standard-Renderer). Fix OHNE Rust-Änderungen: neuer Emitter `emitDrawingLines` (`src/plan/toWalls3d.ts`) baut je 2D-Zeichnung (line/polyline/rect — Parität zu `drawing2DSegments` im three.js-Fallback, Kreis/Bogen/Text bewusst aussen vor) ein dünnes Ribbon-Mesh (2 Dreiecke je Segment, `DRAWING_LINE_HALF_WIDTH` 0.008 m) knapp über der Geschossebene (`DRAWING_LINE_ELEVATION_EPS` 0.01 m), Farbe via `drawingColor3d` (color→LineStyle→Kategorie, wie `drawing2DColor`) → hexToRgb. Läuft über den bereits bestehenden generischen `RMesh`/`MeshInput`-Kanal (`kind:"imported"`, `append_context_mesh` rendert ohnehin doppelseitig) — kein neuer Rust-Typ nötig. `tsc`/`vitest` 339/339 grün. **Nutzer-bestätigt** (2026-07-06, Tauri-Dev-App selbst getestet): Linien aus dem 2D-Grundriss sichtbar in der Perspektive. **Noch uncommittet.**
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich erledigt** (`0ca3b1d`, Schachbrett-Textur auf Wänden, group-1-Bind-Group, `MESH_TEXTURED_WGSL`, planare Meter-UVs). **Verbleibende Lücken bis „richtig gut" (Grobschätzung ~1829 PT):** Bild-Datei-Laden (`image`-Crate, PNG/JPG→GPU) ~12 · Material→Textur-Zuordnung (Wandtyp/Layer→Material→Textur-Set, Cache/Atlas oder Bind-Group je Material) ~35 · Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~58 · Mipmaps (Blit-Pass) + anisotropes Filtern ~12 · Web-Pfad (`web.rs`/WebGPU, Bild-Upload via ImageBitmap) ~34 · UI zum Zuweisen + Persistenz im Dokumentmodell ~58.
- [x] ~~**Wand-Joins in 3D**~~**erledigt 2026-07-07:** `toWalls3d.ts` wendet jetzt die per-Schicht-Cuts aus `computeJoins` (pro Geschoss, `computeJoinsByFloor`) auf den layered-3D-Pfad an — dieselben Zahlen wie generatePlan im 2D: End-Cuts/Merge an L-/T-Stössen (Band-Mittellinie geschnitten, Kern läuft bis zur Rückgrat-Nahfläche durch, Putz trimmt), Durchgangswand-Span-Cutouts (Band zerfällt in Achsen-Teilstücke). **Decken-Dominanz per Schicht** (Scope-Erweiterung): `trimWallTopForCeilings` (kappte die ganze Wand) ersetzt durch per-Schicht-z-Subtraktion analog `subtractDominantBands` — Wand behält volle Höhe, nur Bänder mit strikt niedrigerer joinPriority verlieren das Decken-z-Intervall (Kern läuft durch; dominiertes Band spaltet vertikal in unter/über). Öffnungs-Löcher je Teilstück korrekt rebased. Rust unverändert (Boxen parametrisch). Pick/Highlight jetzt konsistent verschnitten. vitest 383 (25 in toWalls3d.test). **Auslassung ehrlich:** Achsenbox bildet gekippte Miter-Stirnfläche nicht exakt ab (Band-Länge = Mittellinien-Schnitt) — passgenau bei rechten/stumpfen Winkeln, kleine Rest-Approximation an sehr spitzen. **Noch uncommittet.** **Nutzer prüft visuell in der Tauri-App.**
- [x] ~~**Decken-Schnitt in 3D schneidet zu viele Schichten („Kranz durch die Dämmung")**~~**erledigt 2026-07-07:** `collectCeilingCutters`/`emitWall` in `toWalls3d.ts` prüft jetzt PER BAND, ob die Decken-Outline die Band-Mittellinie (`wall.start + u·t + n·offset`) im Grundriss überdeckt — via vorhandenem `pointInOutline` (`geometry/ceiling.ts`), Achse in ~5-cm-Schritten gerastert + Bisektion an den Überdeckungsgrenzen (neue Helfer `bandCoverageIntervals`/`bisectBoundary`); nur überdeckte Achs-Teilstücke bekommen den Decken-z-Schnitt, unbedeckte behalten volle Höhe. Repliziert `subtractDominantBands` (echter geometrischer Überlapp statt wandweiter BBox). Am Sample: Backstein/Innenputz (innen) geschnitten, Dämmung/Aussenputz (aussen) laufen voll durch — kein Kranz. Die 3 falschen Alt-Tests des Vorgängers korrigiert (kodierten „alle Schichten geschnitten"), +2 neue (Voll-Überdeckung schneidet weiterhin alle; Teil-Längen-Überdeckung splittet entlang der Achse). Schnitt-Pfad/L-T-Joins/holes/Rust unberührt. tsc sauber, vitest 393. **Noch uncommittet. Nutzer prüft visuell.**
- [~] **3D-Live-Schnitt: echte Bauteil-Schraffuren statt prozeduralem 45°-Muster (Nutzer-Wunsch 2026-07-07).****implementiert 2026-07-08 (wartet auf visuelle Nutzer-Verifikation nach WASM-Rebuild).** Der ganze Weg von der 2D-Hatch-Definition bis in den wgpu-Cap-Pass ist gelegt: `toWalls3d.ts` bildet je Materiallage/Decke via neuem `hatchPatternId()`/`resolveComponentHatch()` (aus `getHatch(comp.hatchId)`) ein `Hatch{pattern,angle,scale}``WallInput.hatch`/`SlabInput.hatch` (additiv, `#[serde(default)]`) → `section.rs` reicht es über `Prism``CutPolygon.hatch``section_fill.rs` schreibt es je Cap-Vertex (`CAP_FLOATS_PER_VERTEX` 5→8: `[pos,u,v,pattern,angle_rad,scale]`) → `gpu.rs` Cap-Pipeline-Vertexlayout erweitert → `shaders.rs` `CAP_WGSL` wählt prozedural das Muster: `0 none`→weiss, `1 solid`→Vollton (Beton-Poché), `2 diagonal`→45° (bitgleich zum alten Muster bei angle=0/scale=1 → rückwärtskompatibel), `3 crosshatch`→Kreuz, `4 insulation`→Zickzack. Monochrom (Tinte auf Papier, nur MUSTER variiert — bewusst keine Farbe). Da der 3D-Viewer je Schicht EINE Box emittiert (`layeredWalls:true`), trägt jede geschnittene Schicht ihr eigenes Muster; ohne aufgelöste Schraffur → Fallback-Diagonale. `cargo test -p render3d --features render` 63 grün (+2 section_fill-Tests, WGSL-Validierung inkl. Cap-Shader). **WASM (`npm run build:engine3d`) noch NICHT gebaut (brauchte Netz) — Nutzer muss rebuilden + Tauri-Dev neu starten, sonst greift nichts.** `cargo check --target wasm32 --features web` grün. **Noch uncommittet.**
- [ ] **3D-Snapping beim Ziehen (Nutzer-Wunsch 2026-07-07): alle Elemente sollen im 3D auf andere Punkte einrasten.** Heute rastet das 3D-Griff-Ziehen NICHT ein — es projiziert die Maus nur frei auf eine Ebene (`rayPlaneY`/`rayPlane` in `src/viewport/Wasm3DViewport.tsx:576-582`); Snap wurde beim 3D-Griffsystem bewusst weggelassen (three.js-Snap blieb 2D-exklusiv). Nutzer nennt konkret: **Wände** beim Verschieben auf andere Punkte snappen, **Decken** ebenso, „eigentlich alle Elemente". Gewünscht ist also eine gemeinsame 3D-Snap-Schicht (Kandidatenpunkte: Wand-Endpunkte/Ecken, Decken-Eckpunkte, Extrusions-/Öffnungs-Anker, evtl. Rasterpunkte), gegen die der gezogene Griff/Körper im Weltraum einrastet — analog zur 2D-Snap-Infrastruktur (`computeSnap` in `src/tools/snapping.ts`). Zu klären mit Nutzer/Scope: welche Snap-Ziele in 3D (nur Endpunkte oder auch Kanten/Flächen/Raster?), Snap-Radius im Bildschirm- vs. Weltraum, visuelles Feedback (3D-Snap-Marker), und ob die 2D-`computeSnap`-Kandidatenlogik wiederverwendbar ist oder eine eigene 3D-Variante braucht. Betrifft `Wasm3DViewport.tsx` (Drag-Pfad) + neue 3D-Snap-Hilfsschicht; die Store-Callbacks (`onEdit3dVertex`/`onEdit3dBody`/`onEdit3dWallTop`) bleiben.
- [~] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich** (`0ca3b1d`, Schachbrett). ✅ **Farb-Textur-Array implementiert 2026-07-08 (wartet auf visuelle Nutzer-Verifikation nach WASM-Rebuild):** Statt Schachbrett zeigt eine Wand mit zugewiesenem Material jetzt dessen Farb-Map. Voller Weg: `WallInput.materialIndex: Option<u32>` (1-basiert; 0 = Schachbrett-Fallback) → `mesh.rs` `build_scene_mesh_textured()` füllt je Vertex die Material-Ebene (dieselben Extrusionsfns → Geometrie-Parität) → `gpu.rs`: Schachbrett-Textur zu `texture_2d_array` erweitert, `set_material_textures()` baut `[Schachbrett, Mat0, Mat1, …]`, zweiter Vertex-Buffer für die Ebene → `shaders.rs` `MESH_TEXTURED_WGSL` samplt die Array-Ebene je Band (`layer<0`→Schachbrett) → `web.rs` WASM-Export `set_material_textures(rgba, layer_count)` → TS: `wallMaterialColorMaps()`/`resolveComponentMaterialLayer()` (deterministische Array-Ordnung), neuer `src/viewport/materialTextures.ts` dekodiert die Farb-Maps im Browser (ImageBitmap→Canvas→getImageData 256²), `useWasm3dRenderer.updateModel` lädt sie asynchron hoch (Signatur-Cache, Fallback auf Schachbrett bis geladen). **Ehrlich als Rest:** nur Farb-Map (keine Normal-/Roughness-/Metalness → Beleuchtung wie Shaded); der native Tauri-Push (`nativeSync.ts`) lädt keine Texturen → dort Schachbrett-Fallback (das WASM/Nordstern-Viewport im „Texturiert"-Modus hat den vollen Weg). `cargo check --target wasm32 --features web` grün, `cargo test` 63 grün. **WASM noch NICHT gebaut (Netz) — Nutzer: `npm run build:engine3d` + Tauri-Neustart.** **Verbleibende PBR-Lücken (~1525 PT):** Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~58 · Mipmaps (Blit-Pass) + anisotropes Filtern ~12 · Bild-Datei-Laden serverseitig statt Browser-Dekodierung ~12 · nativer Tauri-Textur-Push ~23 · UI/Persistenz-Feinschliff ~58. **Noch uncommittet.**
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~**bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
- [ ] **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).
- [x] ~~**Ortho-Ray-Picking**~~**erledigt 2026-07-07:** `cameraRay` (`src/viewport/raycast3d.ts`) war IMMER ein perspektivischer Pinhole-Strahl (ein Ursprung `eye`, Richtung variiert je Pixel) — für Front/Top/Side/Iso-Presets (die render3d orthografisch zeichnet) nur eine Näherung. Fix: `cameraRay` nimmt jetzt optional `perspective`/`orthoHalfHeight` (neue optionale Felder auf `RayCamera`, rückwärtskompatibel — fehlt `perspective`, bleibt das Verhalten exakt wie vorher) und baut bei `perspective:false` echte PARALLELE Strahlen (Ursprung wandert lateral mit dem Pixel, Richtung immer `f` — exakte Umkehrung von `worldToScreen`s bereits vorhandenem Ortho-Zweig). Beide Aufrufer in `Wasm3DViewport.tsx` (Klick-Pick + Griff-Drag) reichten schon das volle `orbitCamera(o)`-Objekt (inkl. `perspective`/`orthoHalfHeight`) durch — der Bug sass rein in `cameraRay`, keine Änderung an den Aufrufstellen nötig. +2 Tests (`raycast3d.test.ts`: parallele Strahlen, Rückwärtskompatibilität ohne `perspective`-Feld). `tsc`/`vitest` 341/341 grün.
- [x] ~~**3D-Griffe/Editieren im nativen render3d/wasm-Pfad**~~**erledigt 2026-07-06:** existierte bereits vollständig im three.js-Fallback (`Viewport3D.tsx`/`drawGrips`), fehlte aber im nativen wasm-Pfad (`Wasm3DViewport.tsx`, seit `WASM_ENGINE_ACTIVE` der Standard-Renderer) — genau wie beim drawings2d-z0-Fund oben eine „existiert, aber auf dem falschen Pfad"-Lücke. Feasibility-Spike (Nutzer-Entscheid „Spike jetzt starten") zuerst nur Wand-Endpunkte, dann auf volle Parität ausgebaut: Wand-Endpunkt-/Höhen-/Verschiebe-Griff + 2D-Zeichnungs-Vertex-/Verschiebe-Griff, dieselben App.tsx-Callbacks (`onEdit3dVertex`/`onEdit3dBody`/`onEdit3dWallTop`) wie die three.js-Sicht. **Wichtige Design-Korrektur unterwegs:** erster Versuch zeichnete Griff-Marker als In-Szene-Geometrie (3-Achsen-Kreuz, dann Drahtgitter-Kugel) über den bestehenden `setHighlightLines`-Kanal — Nutzer-Feedback zweimal `"immernoch kein GUI button sondern geometrie"`: nicht klar genug als klickbarer Punkt erkennbar UND farblich identisch mit dem Auswahl-Umriss auf derselben Ecke. Gelöst durch Umstieg auf ECHTE DOM-Buttons (`position:absolute`-Kreise über dem Canvas, Farben wie drei.js: Vertex orange/Höhe blau/Verschieben grün), deren Bildschirmposition ein `requestAnimationFrame`-Loop aus der aktuellen Kamera nachführt (`worldToScreen`, neue Umkehrfunktion zu `cameraRay` in `raycast3d.ts`) — kein WASM/GPU-Push nötig, nur 2D-Projektionsmathe. Ziehen läuft über `rayPlaneY`/`rayPlane` (neu in `raycast3d.ts`) + dieselben Store-Callbacks. `drawingGripVertices`/`drawingMoveAnchor` nach `src/viewport/drawingGrips.ts` ausgelagert (gemeinsam von beiden Renderern genutzt, sonst zyklischer Import). **Bewusst NICHT gebaut:** Snap/Koinzidenz-Mitführen beim 3D-Ziehen (bleibt three.js-exklusiv). `tsc`/`vitest` 339/339 grün. **Nutzer-bestätigt** (Wand-Endpunkt-Drag funktional in der Tauri-App getestet, dann Marker-Sichtbarkeit korrigiert). **Noch uncommittet.**
## ❓ Offene Rückfragen (an den Nutzer)
@@ -111,6 +214,61 @@
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
- [x] 2026-07-17 **Schnittebenen 2D↔3D — Phase 1 (Kopplung + Navigation) + Phase-2-Sichtbarkeit** (uncommittet) — der 3D-Live-Schnitt folgt jetzt der gewählten Grundriss-Schnittlinie statt einer fest verdrahteten horizontalen Ebene (`Wasm3DViewport`/`Viewport3D` neue Props `section3d`/`onClearSection3d`; App-State `section3dCutId``sectionPlaneFromLevel`). „Im 3D schneiden"-Schalter im Objekt-Info (`host.section3dCutActive`/`onToggleSection3dCut`), Viewport-Knopf an die Linie gebunden. Doppelklick auf die Schnittlinie → 2D-Schnittansicht (`PlanView.onOpenSectionLevel`). Unsichtbare Schnitt-/Ansichtsebene blendet ihre Grundriss-Linie aus (`addSectionLines`). `tsc` + `vitest` 867 grün (+1). Cut-away-Schleier/Farbe (Phase 2) + POV/3D-Schnittfenster (Phase 3) bewusst offen (engine-schwer, Tauri-Verifikation nötig — s. Backlog-Eintrag). Nutzer prüft Phase 1 visuell in Tauri. UI-Nebenarbeit derselben Session (uncommittet): Werkzeug-Panel (Symbole/Liste-Umschalter, neue SIA-Icons, helle Auswahl), Topbar-Quick-Access-Icons entfernt (dupliziert das native Menü), Zahnrad→Einstellungen in Zeichnungsebenen/Ebenen-Köpfen, Footerbar + Snap-Marker + Maß-HUD auf die helle Pillen-Sprache umgestellt.
- [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün.
- [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün.
- [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen.
- [x] 2026-07-12 **Kontext-Meshes (importierte Gebäude/Terrain) im 3D-Viewport anwählbar** (`e6a7738`) — zweiter Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Raycast/`pickGeometry` (`raycast3d.ts`/`toWalls3d.ts`) um Kontext-Meshes erweitert (rohe Dreiecke aus `project.context`, kaputte Indizes robust übersprungen), neuer Auswahl-Kanal `selectedContextObjectIds` (Store-Slice + `onViewport3dPick`-Zweig, konsistent in ALLE bestehenden Auswahl-Reset-Stellen eingehängt), Highlight als achsenparallele Bounding-Box (volles Dreiecks-Wireframe wäre bei importierten Gebäuden mit tausenden Dreiecken unbrauchbar dicht), Entf-Taste löscht die Auswahl (reused `project.context`-Filter im bestehenden Lösch-Handler), `SitePanel`-Liste hebt die im 3D gewählte Zeile hervor (`host.selectedContextObjectIds`). +9 Tests (`raycast3d.test.ts`, `toWalls3d.test.ts`), Suite 803 grün, `tsc` sauber. **Bewusst nicht umgesetzt:** Ebenen-/Layer-Zuordnung (`categoryCode` an `ImportedMesh`/`TerrainMesh`), volle Attribut-Panel-Integration (`deriveSelection()`) — als eigener, grösserer Schritt eingeschätzt, s. „🔧 In Arbeit" oben.
- [x] 2026-07-12 **3D-Ansicht: „Schattiert mit Kanten" (BIM-Look) + Fix falscher Flächendiagonalen** (`a84dc7a`+`7dc8f0d`) — neuer `RenderStyle::ShadedEdges` (render3d): Bauteilfarben + dunkle Modell-Kanten obenauf, wie Revit/ArchiCAD. Direkt danach Nutzer-Report: sichtbare Dreiecks-Diagonalen auf Dach/Fensterglas im neuen Modus. Root Cause: Kontext-Meshes (Dach/Glas/Rahmen, auch swissBUILDINGS3D-Import) werden wegen aktivem Backface-Culling IMMER doppelseitig aufgebaut (`mesh.rs::push_ctx_tri`, Dreieck + gespiegelte Rückseite) — jede Kante bekam dadurch ein exakt entgegengesetztes Normalen-Paar, das die Knick-Erkennung fälschlich als Kante wertete. Fix in `edges.rs::should_draw`: Rückseiten-Duplikate (dot≈-1) werden vor der Rand-/Knick-Entscheidung zusammengeführt. +11 Rust-Tests, 88/88 grün (`--features render`). **Drei Folgepunkte dabei entdeckt, noch offen** (s. „🔧 In Arbeit" oben): Fenster-Rahmenecken/Dach-First ohne sauberen Verschnitt (vermutlich fehlende Boolean-Union, kein Kanten-Bug), OG-Wandflächen mit vielen vertikalen Strichen (nicht diagnostiziert).
- [x] 2026-07-12 **swissBUILDINGS3D-Import repariert: Absturz + eigentlicher „funktioniert nicht"-Bug** (`35a6834`+`9705890`) — zwei getrennte, echte Bugs gefunden und behoben. (1) Harter Absturz (JSZip „Invalid string length") bei Kacheln, deren ENTPACKTE Grösse (DXF komprimiert stark) die max. JS-String-Länge sprengt, obwohl die ZIP-Grösse selbst unter dem Limit lag — `downloadAssetText` prüft jetzt zusätzlich die JSZip-interne Grössenschätzung und fängt alle Fehler sicher ab; übersprungene Kacheln landen sichtbar in `skippedTiles`/einer UI-Meldung statt eines stummen Leer-Ergebnisses. (2) **Der eigentliche Grund, warum der Import nie Gebäude lieferte:** die installierte `dxf-parser`-Version liefert Polyface-Mesh-Face-Indizes als vier einzelne Felder (`faceA..faceD`, Gruppencodes 7174) statt als erwartetes `faces`-Array — `addPolyfaceMesh` prüfte auf ein Array, das nie existierte, jede swissBUILDINGS3D-DXF-Kachel (exakt dieses Format) ergab dadurch 0 Dreiecke. Live gegen echte Kacheln verifiziert (vorher 0 Meshes, jetzt tausende Dreiecke mit realistischen Höhenwerten). +3 Tests mit rohem DXF-Text (deckt auch die zugrundeliegende Bibliothek ab), Suite 794 grün. **Georeferenzierung und „anwählbares Mesh auf Ebene statt Geo-Panel-Eintrag" bleiben als eigene, ungelöste Design-Fragen offen** (s. „🔧 In Arbeit" oben).
- [x] 2026-07-12 **Fenster-Grundriss komplett überarbeitet** (`e65a6b7`..`d2758ef`, iterativ über mehrere Live-Prüfungsrunden in Tauri) — Nutzer-Report mit SIA-Referenzbildern deckte mehrere übereinanderliegende Probleme auf, alle einzeln gefixt + verifiziert:
- Sims/Anschlag-Kerben nutzten pauschal die volle Wandfläche statt der tatsächlichen (ggf. per `insetFromFace` eingezogenen) Rahmen-Aussenkante.
- Stulp-Marken waren kleine, von der Rahmentiefe unabhängige Quadrate statt Profilquerschnitte über die GANZE Rahmentiefe; die Blendrahmen-Querschnittsblöcke an den Laibungs-Enden fehlten komplett — beide jetzt als `meetingMarks` in `windowSymbol` berechnet (inkl. Laibungs-Enden), `generatePlan.ts` nutzt sie direkt statt einer zweiten, abweichenden Neuberechnung.
- Zusätzliche `window-mullion`-Trennlinie lag redundant NEBEN dem neuen Rahmenblock (entfernt, für typisierte Fenster reicht der Block; Alt-Pfad ohne Typ behält die Linie).
- Glaslinie(n) im Feld (mittel: 1, fein: Doppellinie) komplett entfernt — Nutzer wollte dort keine Haarlinie, unabhängig von Detailgrad/Position.
- Pauschale Brüstungslinie (Wandachse, unabhängig von Rahmenposition) UND die „Oberlicht-Andeutung" (gestrichelt bei `transomHeight>0`, ebenfalls immer auf der Wandachse unabhängig davon ob die Schnittebene den Kämpfer trifft) beide entfernt — architektonisch nicht aussagekräftig für einen horizontalen Grundriss-Schnitt.
- **Ersatz:** `WindowType.sillLine` — konfigurierbare Auf-/Untersicht-Andeutung (Fläche aussen/innen/beide, Blickrichtung Auf-/Untersicht), an der echten Rahmenkante statt der Wandachse.
- **Neu:** `Opening.frameLine`/`sashLine`/`sillLineStyle` — Farbe/Strichstärke je Linien-Kategorie (Blendrahmen+Stulp / Flügelrahmen+Sprossen / Sims) im Objekt-Info-Panel einstellbar, Default = bisheriges Verhalten.
- +~30 Tests über mehrere Dateien, Suite 790 grün.
- [x] 2026-07-12 **Dach-Wand-Verschneidung (Z-Fighting im 3D behoben)** (`a584911`) — Nutzer-Report mit Screenshot (flackerndes Rausch-Muster wo Wand-Oberkante die Dachschräge kreuzt). Root Cause: Wände bekamen ihre Höhe unabhängig vom Dach, `collectCeilingCutters` kappte nur gegen `project.ceilings`, nie gegen `project.roofs` — wo die geneigte Dachfläche den flachen Wand-Top kreuzte, überlappten sich beide Volumen. Fix: `roofUndersideAt` (`geometry/roof.ts`, Ebenengleichung je Dachfläche + Punkt-in-Polygon) liefert die Dach-Unterkante an einem Grundriss-Punkt; `clipPieceToRoofs` (`toWalls3d.ts`) zerlegt betroffene Wand-Achsenstücke in feine ~15-cm-Schritte und klemmt jeden auf die dort gemessene Unterkante — Treppenstufen-Annäherung an eine echte geneigte Giebelwand-Stirnfläche (render3d-Wandkörper haben nur einen flachen Top; eine echte Schrägfläche bräuchte einen neuen Mesh-Pfad, bewusst nicht gebaut). Ohne Dach im selben Geschoss unverändertes Verhalten. +9 Tests (5 `roofUndersideAt`, 3 Integration inkl. Regressionsfund einer legitim gekappten Putz-Gehrungsspitze im Sample-Projekt), Suite 781 grün. Verwandt: [[design-schicht-verschneidung-3d-schnitt]] (gleiche Kategorie wie „Wand endet an Decke", hier für geneigte statt horizontale Cutter).
- [x] 2026-07-12 **Fenster-Grundriss grob/mittel/fein nach SIA 400 klar unterschieden** (`13cc6a0`) — Nutzer-Report „mittel und fein sind genau gleich, grob ist viel zu grob" behoben: `windowSymbol` (`geometry/opening.ts`) staffelt jetzt nach SIA 400 Anhang B.9.1 Fig. 3638 statt DIN — grob (1:100) nur eine Glaslinie, mittel (1:50) Blendrahmen + Flügel-Trennlinien + 1 Stulp-Quadrat je Flügelstoss, fein (1:20) zusätzlich verschachtelte Flügelrahmen + Glas-Doppellinie (Isolierverglasung) + 2 Stulp-Quadrate. `generatePlan.ts` reicht `detail` durch, rendert die neuen Stulp-Marken (`window-stulp`) nur im typisierten Pfad (kein Fake-Stoss bei reinem `wingCount`-Altfall). Referenzdoku `docs/research/sia400-fenster-tueren.md` (Fig.-Transkription aus der SIA-400-PDF, lokal excluded). +9 Tests, Suite 753 grün. Visuell per Puppeteer verifiziert. ✅ **Folge-Fix `e309e54`:** Öffnungssymbol-Kommentare in `toElevation.ts`/`toWalls3d.ts` waren fälschlich „DIN" benannt (Grundlage ist SIA 400 B.9.1.3) — reine Doku-Korrektur, Symbol-Geometrie selbst war bereits korrekt (SIA-PDF zeigt nur Sinnbild-Namen, keine Pfeilgrafiken, daher keine Geometrieänderung). ✅ **Wand-Poché bei „grob" jetzt immer vollschwarz** (`0240a23`, 2026-07-12) — war zuvor nur schwarz, wenn das dominante Bauteil selbst `pattern:"solid"` hatte (bei mehrschichtigen Wandtypen mit Backstein/Dämmung/Verputz blieb es weiss). Fix (unconditional `HATCH_INK` bei grob) von Hermes/Qwen3 geliefert (zweiter Versuch, erster war die o. g. Fehleinschätzung), von mir verifiziert + um fehlenden Regressionstest + Aufräumen der toten `backbonePocheFill`-Hilfsfunktion ergänzt. +2 Tests, Suite 755 grün.
- [x] 2026-07-11 **Schnitt/Ansicht auf VW-Niveau (grosser Block)** — Schnitt ENTSPIEGELT (`983d061`, u-Achse = Betrachter-Rechts, Rust+TS koordiniert, Engine neu gebaut); Kanten geschnittener Bauteile gefiltert + verdeckte Kanten opt-in (`4c4c990`); Wand-Terminierung wirkt im Schnitt auch bei Decken-ANSCHLUSS ±5 cm (`a612ae5`); Dämmschraffur-Orientierung Wand/Decke korrekt (Sprossen quer zur Schicht, empirisch verifiziert, `a612ae5`+`38a4d8c`). Ansicht als LINIENZEICHNUNG (`c02a02c`): weisse Flächen + XOR-Silhouette (xorOutlineEdges — Teilbox-Innenkanten heben sich auf), Fenster mit Blendrahmen→Flügelprofil→Glas, Sims mit Tropfkante, DIN-Symbole exakt auf den Glasfeld-Ecken (`ee9aeda`). **3D-Öffnungen VW-fein** (Merge `31d7aef`): vorstehende Flügelrahmen, DIN-Symbole auf dem Glas (openingPlaneBox-Prismen), 3D-Fensterbank + Tropfkante, Zargen-Umgriff; Staffelung grob/mittel/fein. Display/Print-Umschalter in allen 2D-Darstellungen. **Offen:** Tauri-Visualabnahme 3D; Schnitt-Auswahl-UX (Treffer nur auf der Poché).
- [x] 2026-07-11 **Lineale + VW-Zeichen-Feedback komplett** (`1945fec`, `8f85e13`, `7898a0d`, `242b850`) — Lineale oben/links in allen 2D-Ansichten (Meter-Ticks, Cursor-Marker); Cursor-HUD im Programm-Look mit Live-Echo getippter Werte (oben+unten synchron), Tab-Feldwechsel, Direkt-Tippen; Δx/Δy beim Rechteck; lange Führungslinien + Winkelbogen mit 0°-Referenz.
- [x] 2026-07-11 **Schnitt ausgebaut** (`b86fca2`) — Befehl `sectionline`/`viewline` (Aliase schnittlinie/ansichtslinie, BIM-Ribbon „Schnitte"): zwei Klicks setzen die Schnitt-/Ansichtslinie (neue Ebene bei Bedarf). Schnittführungs-Symbol im Grundriss (Strichpunkt, Endmarken, Richtungspfeile, Label) + Pick-Band; Schnittlinie selektierbar mit Attribut-Sektion (Name, Blickrichtung umkehren, Endpunkte, Tiefe, Löschen/Entf). **Editieren IM Schnitt**: Cut-Polygone tragen die Quell-Element-Id (sourceId, durch Schicht-Zerlegung/Terminierung/Dominanz propagiert) → Klick im Schnitt wählt das Bauteil, Panels editieren, Schnitt rechnet neu. Schnitt-Tiefe `DrawingLevel.depth` (Owner-Distanz-Näherung, TODO exakte Kanten-Tiefe bräuchte section.rs). **Offen:** Endpunkt-Drag-Griffe der Schnittlinie im Grundriss.
- [x] 2026-07-11 **Ansicht (Elevation) als echte 2D-Darstellung** (`456ecc5`+`a6db682`) — `toElevation.ts`: Painter-Projektion (Fassaden/Decken fern→nah, Rückseiten-Culling, Tiefen-Graustaffelung), Fenster/Türen als Rahmen+Glas, Bodenlinie, **Dächer** (Flächen + Giebel aus roofGeometry) und **Schatten ein/aus** (45°-Schlagschatten auskragender Decken UND Dachflächen, SutherlandHodgman-geclippt; Toggle in der Ansichts-Leiste, `DrawingLevel.shadows`). Rein TS/synchron ohne WASM. **Offen:** exakter Hidden-Line statt Painter (dokumentierte Näherung), Material-/viewHatch-Füllungen je Fassade.
- [x] 2026-07-11 **VW-Zeichengefühl: Cursor-HUD + Winkelraster** (`975ce3f`) — beim Zeichnen L/W-Kästchen am Cursor (`L: 3.118m W: 60.000°`, VW-Stil), weiches Einrasten auf 15°-Vielfache (±2.5°, konfigurierbar) mit Winkel-Badge + gestrichelter Führungslinie; Vorrang Objekt-Snap > Shift/Ortho > Winkelraster > Raster; Toggle in der Fang-Leiste. Das bis dahin nie gerenderte `ToolDraft.hud` lebt jetzt (line/polyline/wall/rect/circle/arc).
- [x] 2026-07-11 **Dach im 3D anwählbar** (`355c1d4`) + **2D-Schnitt = 3D Wand-Terminierung** (`0e02c2b`) — Ray-Dreieck-Pick über Dachflächen/Giebel; applyWallTermination spiegelt terminateOrSubtractSpans im (u,v)-Schnittraum (below/above kappen an der Decken-UK/OK).
- [x] 2026-07-10 **Fenster-/Tür-Einstellungsdialog + reiche Öffnungs-Parameter** (`1bbe814`+`c734802`+`2e13ec3`, Studie [docs/design/window-editor-vectorworks-study.md](docs/design/window-editor-vectorworks-study.md)) — Nutzer-Feedback „Fenster/Türen mega mager". Das ⚙ der Öffnungs-Sektion öffnet neu einen dedizierten Dialog (`OpeningEditorDialog.tsx`): Kategorie-Sidebar (Basis/Grösse/Rahmen/Flügel/Sonnenschutz bzw. Türblatt), Flügeltabelle (Flügel/Pfosten + Öffnungsart + Anschlag je Flügel), Live-2D-Frontalansicht, Stil-Leiste mit „Als Stil speichern" (klont Typ → neuer benannter WindowType/DoorType). Modell additiv: `SashDef[]`/`shading`/`glazingPanes` + Helfer `sashesOfWindowType`/`glazingPanesOf`. Renderer konsumiert sie: 2D-Flügeltrennlinien aus Flügelbreiten + Öffnungsandeutung, glazingPanes-Glaslinien, gestrichelte Rollladenkasten-Kontur; 3D mehrscheibige Verglasung. +26 Tests, 659/659 grün. **Offen (P1+, Studie §4):** asymmetrische Rahmenbreiten, Oberlicht/Unterlicht als eigene Felder, Sprossengitter, Bank/Nische, Form (Rund/Spitz), echte wgpu-3D-Vorschau im Dialog, 3D-Rollladenkasten-Box.
- [x] 2026-07-10 **Dach-3D-Ziehgriffe** (`4ef40a0`) — gewählte Dächer haben im wgpu-Viewport Eckpunkt-Griffe (achsparalleles Resize, Gegenecke fix), einen Verschiebe-Griff und einen First-Griff (vertikal ziehen → Dachneigung, Firstlage bleibt). Store `moveRoofGrip`/`moveRoofBy` (coalescing) + `onEdit3dRoofPitch` (Höhe→Neigung). +3 Tests. Schliesst die „optionale Folge" des Dach-Features (s. u.). **Tauri-Visualabnahme offen.**
- [x] 2026-07-10 **Messwert im Objekt-Info** (`9b6dd80`) — Live-Messwerte erschienen nicht im Panel; Ursache war ein eingefrorenes Memo (`baseHost`-Deps ohne `draft`). Fix: `measurement` pro Render live in `hostWithMode` injiziert.
- [x] 2026-07-09 **Dächer (Grundfeature)** (`1195d2a`+`31f2d63`) — Roof-Element + `geometry/roof.ts` (Flach/Pult/Sattel/Walm/Mansarde/Zelt, BBox-basiert, First X/Y). Befehl „Dach" (BIM-Ribbon, Rechteck aufziehen, Form als Inline-Option), 2D-Plan (Traufe/First/Grat/Knick), 3D (emitRoofs, terrakotta), Demo-Dach RF1. +19 Tests. **Offen:** Auswahl/Attribut-Editieren/Löschen (s. „Als Nächstes").
- [x] 2026-07-09 **Fenster/Tür-Feedback** (`4beae72`+`89e737b`) — Fenster im 3D tiefer (Rahmen füllt Wanddicke, dünne Scheibe mittig); Detailgrad grob/mittel/fein wirkt im 3D; Fenster↔Tür-Umschalter entfernt; platzierte Öffnungen bekommen Standard-Typ (sonst kein Rahmen). Kernursache „sieht im 3D nicht so aus" = fehlender typeId behoben.
- [x] 2026-07-09 **Decken-Griffe im 3D** (`973ac6d`) — gewählte Decken haben im wgpu-Viewport Eckpunkt- + Kanten-Mittelpunkt-Griffe (rautenförmig, eigene Farbe) + Verschiebe-Griff → moveCeilingGrip/moveCeilingEdge/moveCeilingBy (Store-Actions existierten schon fürs 2D). Neu: 3D-Griffgeometrie (computeGrips), Kanten-Drag (GripDrag „edge"), Prop-Kette Wasm3DViewport←Viewport3D←App (editCeilings/onEditEdge, Ziel um ceilingId). three.js-Sicht unverändert. **Tauri-Visualabnahme offen.**
- [x] 2026-07-09 **Text-Styling-Leiste wirkt auf Freitext** (`d0b9d22`) — selektierter Freitext/Textspalte (Drawing2D shape „text") ist Formatier-Ziel der Oberleiste: Drawing2D-Text.marks (Schrift/fett/kursiv/Farbe); Grösse bleibt über Modell-Höhe (Meter, nicht pt). App.textTarget adaptiert via docFromText/plainText; generatePlan/PlanView rendern die Marks. +3 Tests.
- [x] 2026-07-09 **Schichttrennlinie als Wand-Referenzlinie** (`938d642`) — Wall.referenceOffset (freier Achsversatz) übersteuert left/center/right; Object-Info-Dropdown listet je interne Fuge einen Eintrag (kumulierte Schichtdicken in selectionInfo). wallReferenceOffset bleibt EINE Quelle → 2D/Schnitt/3D erben es. +3 Tests.
- [x] 2026-07-09 **Mess-Werkzeug: Polygonzug + Fläche + Objekt-Info** (`492e1f8`) — Länge je Segment + aufsummiert + FLÄCHE (ab 3 Ecken, Gauss); Live-Werte dauerhaft im Objekt-Info-Panel (ToolDraft.measure→PanelHost.measurement→ObjectInfoPanel); Rechtsklick beendet Pfad + armiert neuen (mehrere nacheinander). +4 Tests.
- [x] 2026-07-09 **Tür/Fenster tief ausgearbeitet** (`fa40429`/`142ba7e`/`9a65900`) — DoorType/WindowType um frameKind (Zarge/Blockrahmen), frameWidth, insetFromFace/insetFace (Schichteinzug), transomHeight (Oberlicht), mullionRows (Kämpfer). ResourceManager-Typeditor + Seeds (Haustür Blockrahmen/Oberlicht, Fenster 2-flügl.+Oberlicht). 2D: opening-frame/-transom-Primitive. 3D: emitOpeningFrames (Rahmen/Sprossen/Kämpfer) + einzugs-/oberlicht-bewusste Scheiben. +13 Tests. **3D-Visualabnahme offen.**
- [x] 2026-07-09 **LICENSE** (`dbe7d37`) — offizieller AGPL-3.0-Text (gnu.org).
- [x] 2026-07-07 **IFC4-Export (erste Scheibe)** — reiner Kern `src/export/exportIfc.ts` (`exportIfcSpf(project)→string`, kein WASM/Dependency, direkt IFC-SPF-Text, Muster wie exportDxf). Räumliche Hierarchie IfcProject→IfcSite→IfcBuilding→IfcBuildingStorey (je `kind:"floor"`), Elemente via IfcRelContainedInSpatialStructure. Geometrie durchgängig IfcExtrudedAreaSolid: Wand→IfcWall, Decke→IfcSlab, Öffnung→IfcOpeningElement+IfcRelVoidsElement + Tür/Fenster-Füllung+IfcRelFillsElement, Extrusion→IfcBuildingElementProxy, Treppe→IfcStair (vereinfachter Hüllkörper, keine Stufengeometrie). IFC-22-Zeichen-GUIDs deterministisch aus Element-id (FNV→128bit→IFC-Base64, gegen IfcOpenShell-Referenz verifiziert). SI-Einheiten, OwnerHistory. Quick-Access-Button (`deployed_code`) + `onExportIfc` in App.tsx (Blob `model/ifc`, `<name>.ifc`) + i18n `file.exportIfc`. 8 Tests (wichtigster: KEINE dangling refs — jede `#N`-Referenz definiert + eindeutig). vitest 391. **Bewusst offen/vereinfacht:** IfcMaterialLayerSet (DirectionSense/Offset-Semantik ohne Viewer zu riskant — Folge-Item), Treppen-Stufengeometrie, Tür/Fenster nutzt dieselbe Box wie die Öffnung (kein Rahmen/Blatt). `host.ts` bewusst NICHT angefasst (Export-Callbacks leben in TopBarProps, nicht PanelHostValue — konsistent mit DXF/Schedule). **⚠️ Unit-Tests beweisen nur STRUKTURELLE STEP-Validität — Nutzer muss die .ifc in echtem Viewer (BIMcollab Zoom / IFC.js / Revit) gegenprüfen.** **Noch uncommittet.** (STL/OBJ-Export + IFC-Import + DWG-Schreiben bleiben offen — s. Interop-Befund.)
- [x] 2026-07-07 **ObjectInfo: Volumen/Länge bei Wand + Volumen bei Decke** (Nutzer-Wunsch) — `selectionInfo.ts`: WallInfo um `length`/`grossVolume`/`openingVolume`/`netVolume` (Netto = Länge×Dicke×Höhe Σ Öffnungen[Breite×Höhe×Dicke]), CeilingInfo um `volume` (Fläche×Dicke). Panel zeigt bei Wand Länge + Netto-Volumen (Tooltip Brutto−Öffnungen), bei Decke Fläche + Volumen. +i18n `objinfo.volume`/`objinfo.wall.length`/`objinfo.wall.volumeGross`. tsc/vitest 391. **Noch uncommittet.**
- [x] 2026-07-07 **UI-Feinschliff-Runde (Nutzer-Session, Tauri live abgenommen):** (1) Kamera-Settings (FOV-Popover) ZUSÄTZLICH oben in der TopBar-Chrome neben CSV, dafür UNTEN aus beiden Ribbon-Tabs entfernt → unten saubere 2×4 View-Icons (Nutzer-Korrektur: erst waren fälschlich alle Presets mit oben). (2) ObjectInfo: Bezugspunkt-Würfel fix quadratisch (64px, skaliert nicht mehr mit Panelbreite), X/Y/Z als Spalte rechts daneben (`objinfo-refrow`/`objinfo-xyzcol`). (3) Wand/Decken-UK/OK-Unterzeile in normale Label/Wert-Struktur: „Referenzgeschoss" + Dropdown in der Wertspalte, bei Eigene „Eigene Höhe" linksbündig (+2 i18n-Keys `objinfo.anchor.*`). (4) Ansichten-Ribbon: Detailgrad + Darstellung übereinander gestapelt (eine `tb-stack`-Gruppe), alte separate Darstellungs-Gruppe entfernt. tsc sauber, vitest 377/377. **Noch uncommittet.**
- [x] 2026-07-07 **Basis-Materialien mit Default-Textur** (Nutzer-Wunsch „nicht dass ich sie zuweisen muss und jeder Reset zerstört es") — Seed-Components in `src/model/sampleProject.ts` tragen ab Werk `material` (via `materialFromAsset`, exakt der ResourceManager-Zuweisungsweg): Aussen-/Innenputz→Plaster001, Backstein→Bricks104, Beton→Concrete048. Bewusst OHNE: Dämmung + Estrich (kein passendes Asset in der 12er-Bibliothek — nicht geraten). Alt-Projekte unberührt (Feld optional). three.js-Viewport rendert automatisch texturiert; nativer Renderer weiterhin ohne Texturen (bekannter 3D-REST). tsc sauber, vitest 377/377. **Noch uncommittet.**
- [x] 2026-07-07 **Element-Übersicht / BIM-Tree-Panel** (ROADMAP §11, Phase-1) — neues `src/panels/ElementTreePanel.tsx`: dreistufiger Baum Geschoss→Bauteilklasse→Element aus `scheduleRows()` (Textfilter mit Auto-Aufklappen; Klick = selektieren, Shift/Doppelklick = selektieren+zoomen). `ScheduleRow.floorId` additiv (CSV unverändert), Host-Callback `onSelectScheduleRow` (App.tsx setzt korrekten Selektionskanal, wechselt bei Bedarf Geschoss + defert 1 rAF gegen den Geschosswechsel-Reset). Registriert in `builtinPanels.tsx` + Default-Rechts-Dock (`LAYOUT_VERSION` 8→9). Legacy `project.doors` bewusst ausgefiltert (tot; echte Türen sind `Opening kind=door`). +i18n `elements.*`. vitest 377/377, tsc sauber. **Bekannte Grobheit:** Zoom nutzt `fit()` (ganzer Plan) statt `fitSelection()`, weil letzteres nur lokal geklickte Indizes kennt und `PlanView.tsx` gesperrt war — Element wird per Auswahl-Outline sichtbar. **Nebenbei:** NUL-Byte in `exportSchedule.ts` (Kollision paralleler Schreibzugriffe) repariert. **Noch uncommittet.**
- [x] 2026-07-07 **Decken-UK-Verdrahtung nachgezogen** (Orchestrator) — `onSetCeilingBottom`-Handler in App.tsx ergänzt (Muster `onSetCeilingTop`); das UI-Feld aus dem Decken-UK/OK-Item ist damit funktional statt inert. tsc sauber, vitest 377/377.
- [x] 2026-07-07 **Kamera-Presets: Kardinal + Iso-Oktanten** (Milestone B3-Teil, OHNE Nord-Rotation — die bleibt Nutzer-Entscheid) — `View3d` jetzt front/back/side(=Rechts)/left/top/iso + 3 weitere obere Iso-Oktanten (vorne-links/hinten-rechts/hinten-links; untere 4 bewusst weggelassen) + perspective. Rust `CameraPreset`/`preset_camera`/`web.rs` synchron erweitert (+2 Tests, cargo render3d 60/60), three.js-Pfad (`Viewport3D.tsx`) in Parität, `VIEW3D_ORBIT`-Seeds analytisch. UI: 4 Kardinal-Buttons + Iso-Split-Button mit Oktanten-Popover (ViewRibbonTab + Ribbon3dTab). WASM-Rebuild sauber, tsc sauber, vitest 372/372. **Visuelle Abnahme im Tauri offen.** **Noch uncommittet.**
- [x] 2026-07-07 **Decken UK/OK-Override** (ROADMAP §11, Phase-1-Item) — `Ceiling.bottom?: VerticalAnchor` (additiv), `ceilingVerticalExtent` löst bottom-Anchor vor `zTopthickness` auf (exakt das Wand-Muster); wirkt automatisch in 3D (`toWalls3d`) UND Schnitt (`toSection`), da beide denselben Resolver konsumieren. ObjectInfo: UK-Zeile jetzt editierbare `VerticalAnchorRow` (i18n-Key existierte). Neue Tests `model/wall.test.ts` (5). vitest 377/377, tsc sauber. **⚠️ Folge-Zeile:** `onSetCeilingBottom`-Handler in App.tsx (~5 Zeilen, Muster onSetCeilingTop) war ausserhalb der Agent-Lane — wird vom Orchestrator nachverdrahtet, bis dahin ist das UI-Feld inert. **Noch uncommittet.**
- [x] 2026-07-07 **A1 Override-Regel-Engine** (Top-Item des DOSSIER-Audits) — `OverrideRule` (condition: layer_name|object_name × equals|contains|starts_with|not_equals → actions: color/lineweight/linetypeId) additiv an `Project.overrideRules`; pure Engine `src/overrides/engine.ts` (`effectiveOverrides`: additiv, oberste aktive Regel gewinnt pro Feld, case-insensitiv, layer_name matcht Name ODER Code); Einhängung als Render-Dekoration in `generatePlan.ts` NACH der By-Layer/By-Object-Kette (Wände/Decken/Räume/Öffnungen/Treppen/Drawing2D; Projektdaten unverändert, jederzeit reversibel); ResourceManager-Tab „Overrides" (Master-Detail, Prioritätsliste ▲/▼, Aktiv-Checkbox). +18 Tests (engine 12, generatePlan-Integration 6). vitest 372/372, tsc sauber. **Bewusst offen:** user_string-Bedingung (kein Tag-Feld am Element), 3D-/Schnitt-Pfad, Presets/Templates (C2), Drag-Reorder; Overrides-Tab im ANGEDOCKTEN ResourcesPanel-Adapter vorerst nur lesend (Handler optional gehalten — Mini-Folge-Item). **Noch uncommittet.**
- [x] 2026-07-07 **SIA-416: AGF-Kategorie ergänzt** (Lücken-Schluss aus dem Milestone-Abgleich) — `SiaCategory`+`AGF` in `geometry/roomArea.ts`, Rollup NUR in GF (nicht NF/NGF): Bilanz-Reihenfolge HNF·NNF·NF(Σ)·VF·FF·NGF(Σ)·KGF·AGF·GF(Σ), GF = NGF+KGF+AGF. Dropdown/Bilanz-Panel/CSV übernehmen generisch ohne Änderung. Neue Testdatei `roomArea.test.ts` (8 Tests). vitest 366/366 grün. **Noch uncommittet.**
- [x] 2026-07-07 **D2 Bauteil-CSV volles Element-Set**`exportSchedule.ts` deckt jetzt Tür/Fenster/Öffnung/Treppe/Extrusion zusätzlich zu Wand/Decke ab, exakt nach dem in HANDOVER.md hinterlegten Scope (Tür/Öffnung: Breite/Höhe/Fläche, Geschoss via Wirtswand; Treppe: Lauflänge + totalRise, Fläche bewusst leer; Extrusion: Höhe + polygonArea, Geschoss via levelId; Räume weiterhin im eigenen Raum-CSV). Aggregat-Zeile generalisiert (`totalLength`/`totalArea` > 0 ? Wert : leer — kein irreführendes 0.00 bei Treppe). Tests erweitert (Fixture + Zeilen-Asserts je neuem Element). `tsc` sauber, `vitest` 341/341 grün. **Noch uncommittet.**
- [x] 2026-07-06 **`extrude`-Befehl im 3D-Ribbon-Tab** — `Ribbon3dTab` (`src/ui/TopBar.tsx`) kannte nur Kamera/Darstellungsart, nicht den seit Phase 3 existierenden `extrude`-Befehl; neue Gruppe mit Befehls-Button (gleiches Muster wie `RibbonButton`s Befehls-Zweig: `CommandIcon` + Label aus der Registry, deaktiviert ohne aktives Geschoss, aktiv-Highlight bei laufendem Befehl). `onRunCommand`/`activeCommand` neu durchgereicht (App.tsx). tsc sauber.
- [x] 2026-07-06 **truck-Integration Phase 4 (Boolean) — Mesh-CSG via `csgrs` umgesetzt, technisch bewiesen** (Details s. oben bei „Phase 4"): zweiter Spike (`csgrs`, Mesh-Ebenen-BSP statt B-Rep) löst exakt den Fall, an dem `monstertruck-solid` scheiterte; nutzerautorisiert als echte (Git-gepinnte) Abhängigkeit in `trucksolid` eingebaut (`boolean.rs`, WASM-Export `boolean_mesh`, TS-Wrapper `booleanMesh()`). `cargo test` 11/11, `wasm32-unknown-unknown`-Build + echter `wasm-pack`-Build sauber. UI-Verdrahtung (wann automatisch subtrahieren) bewusst offen gelassen — Scope-Entscheidung, kein Technik-Risiko mehr.
- [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie**`addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
+121 -100
View File
@@ -1,16 +1,17 @@
# DOSSIER Standalone
# Dossier
Eigenständiges BIM-Werkzeug für Wohnbau. Modelliert ein Wohnhaus aus
semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
ohne Revit.
Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude
aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
ohne Revit. **Desktop-App** — auf macOS über **Tauri**, auf Linux über eine
**Electron**-Shell (weil Tauris Linux-Webview WebKitGTK kein zuverlässiges
WebGPU bietet, das die Rendering-Engines brauchen); dieselbe Codebasis läuft
auch im Browser (eingeschränkt, ohne native Fenster/Dateisystem-Integration).
Das ist die eigenständige Standalone-Variante des Rhino-Plugins
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
React/Three.js-App in einer Electron-Desktop-Shell statt als Rhino-Aufsatz.
Kein Browser-Tab-Produkt: eigenes Fenster, eigene Rendering-Engine
(Rust/WASM/WebGPU), gebaut für flüssiges CAD-Arbeiten statt für den Webview
kleinster gemeinsamer Nenner.
Das ist die eigenständige Neuimplementierung des Rhino-Plugins
[DOSSIER](https://git.openbureau.ch/karim/dossier): dieselbe Denkweise
(Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-
Verschneidung), aber mit eigenem Datenmodell + eigener Rendering-Engine
(Rust/WASM/WebGPU, intern „Nordstern" genannt) statt Rhino-Aufsatz.
## Grundgedanke
@@ -22,117 +23,124 @@ in die Geometrie eingebacken. Wer eine Wand verschiebt, verschiebt sie überall.
Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
Linien, Öffnungen → Lücken + Symbol). PDF-Export ist derselbe Plan, nur mit
echten mm-Stiftstärken statt Bildschirm-Hairlines — kein zweites Rendering.
Schnitte und Ansichten brauchen den zweiten Weg — echte 3D-Projektion mit
verdeckten Kanten (HLR) —, dessen Machbarkeit per Spike bewiesen, aber noch
nicht ans UI angebunden ist.
echten mm-Stiftstärken statt Bildschirm-Hairlines. Schnitte und Ansichten laufen
über eine eigene, analytische Rust-Pipeline (kein Hidden-Line-Removal nötig,
siehe [ARCHITECTURE.md](ARCHITECTURE.md) §4.3) und sind seit Juli live im
3D-Viewport verdrahtet, nicht nur ein Spike.
## Stand heute
Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit
abgeleiteter 3D-Sicht, Vektor-PDF/DXF-Export und einer parametrischen
Wand-Engine** geworden. Der einfachere Teil steht und ist per Screenshot/Probe
verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen.
Aus dem ursprünglichen Risiko-Spike ist binnen weniger Wochen ein
**funktionsreiches Desktop-BIM-Tool** geworden: eigenes semantisches
Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines, IFC/DXF/PDF/STL/OBJ-
Export, Swisstopo-Import, SIA-416-Flächen, Materialbibliothek, Layouts/
Plansätze, native Tauri-Fenster. Eine vollständige, ehrliche Bestandsaufnahme
(inklusive offener Punkte und bekannter technischer Schulden) steht in
**[STATUS.md](STATUS.md)**.
**Funktioniert:**
- Semantisches Modell mit **mehrschichtigen Wänden** und automatischer
**L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D.
- **Parametrische Wände** (`ParametricWall`-Regelwerke: Grid/Modul/Sequenz/
Referenzlinie/bedingte Dicke) lösen sich zu konkreten `Wall`-Objekten auf,
statt jede Wand einzeln von Hand zu ziehen.
- **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/
Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …).
- **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis,
Öffnungen, Treppen, Decken, Raumstempel; Snapping (Endpunkt/Mittelpunkt/
Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/Offset, Spiegeln/Drehen/Array,
Trim/Split/Join.
- **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
`5<45`) und Tab-Feld-Zyklus (Länge → Winkel …).
- **Vektor-Export:** PDF (A4/A3, Titelblock, echte mm-Stiftstärken nach ISO-Pen-
Steps, keine Rasterbilder) und DXF — beide aus derselben `Plan`-Struktur wie
der Bildschirm.
- **PBR-Material-Bibliothek** (ambientCG-Import, `manifest.json`) für die
3D-Ansicht.
- **Resource Manager** (Component / Hatch / Line) — alles per id referenziert,
zentral änderbar.
- **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste
(echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en).
- **Import:** DXF/DWG (Konturen/Mesh) → Terrain-TIN, Swisstopo/LV95-Geokontext,
OSM-Kontextimport; `.lin`/`.pat` für Linien/Schraffuren.
**Funktioniert (Auszug — volle Liste in STATUS.md §3):**
- Semantisches Modell mit **mehrschichtigen Wänden**, L-Eck-Gehrung **und**
Prioritäts-T-/X-Stössen (`joinPriority` am Component) — konsistent in
Grundriss, 3D-Viewport und 3D-Live-Schnitt.
- **Parametrische Wände**, Decken, Treppen (gerade/L/Wendel), Dächer
(Flach/Pult/Sattel/Walm/Mansarde/Zelt), Stützen, SIA-416-Räume mit
automatischer Bilanz + CSV-Export.
- **Türen & Fenster** gehostet in Wänden, mit Rahmen/Zarge/Kämpfer/Oberlicht,
Detailgrad grob/mittel/fein, echte Rechteck-Löcher im 3D-Wandkörper.
- Rhino-artiges **Kommandosystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
`5<45`) und Tab-Feld-Zyklus; Snapping, Grips, Trim/Split/Join, 2D-Booleans.
- **Live 2D↔3D-Schnitt**: eine Schnittebene im 3D-Viewport folgt derselben
Prioritäts-Logik wie der 2D-Plan-Schnitt (Rust-Port, keine Diskrepanz).
- **Vektor-Export:** PDF (Einzel- **und Mehrseiten-Layouts**), DXF, **IFC4**
(mit echten Fenster-/Tür-Löchern), STL/OBJ, CSV-Bauteilliste.
- **Materialbibliothek**: 13 gebündelte PBR-Starter **plus** Live-Suche der
kompletten ambientCG-Bibliothek (1K/2K/4K, On-Demand-Download).
- **Layouts/Plansätze** mit mehreren Viewports pro Blatt, Ausschnitte
(View-Snapshots), Kamera-Presets, Norden-Rotation.
- **Import:** DXF/DWG, Swisstopo (swissBUILDINGS3D/-ALTI3D/SWISSIMAGE,
radiusgenau zugeschnitten), OSM-Kontextimport, `.lin`/`.pat`.
- **Native Desktop-Integration** (Tauri): eigene randlose Fenster, native
Speichern/Öffnen-Dialoge, eigenes `.obp`-Dateiformat mit OS-Level-Lock gegen
Doppelöffnen, mehrere native Zusatzfenster (Resources, Settings, …).
- **Resource Manager** (Component/Hatch/Line/Typ-Editoren), regelbasierte
Overrides, Panel-System (dockbar/floatend), i18n (de/en).
**Bewusst noch offen** (die eigentlich harten Teile):
- Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke,
noch kein geschnittenes Volumen.
- **HLR** (verdeckte Kanten) für Schnitte und Ansichten: Machbarkeit per
OCCT-WASM-Spike bewiesen (`docs/welle-c-hlr-spike/`, `src/section/hlr.ts`),
aber noch nicht ans UI/Dokumentmodell verdrahtet — Views sind noch Stubs.
- **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz
verbindet seitlich) — das berüchtigte Risiko #1.
- **Multi-Page-Layouts/Ausschnitte** (mehrere Viewports pro Blatt) — PDF-Export
ist noch single-sheet.
Details und die Begründungen stehen im
[HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der
[ROADMAP](ROADMAP.md) (Vision, Phasen, Backlog).
**Bewusst noch offen** (Details + volle Liste: STATUS.md §4.7):
- **Echtes Mesh-Boolean für Öffnungen**funktioniert heute über achsparallele
Rechteck-Löcher; ein generisches CSG-Boolean existiert bereits
(`trucksolid`/`csgrs`), ist aber nicht an die Wand-Pipeline angeschlossen.
- **3D-Griffsystem**: Feld-Controller (Tab-Zyklus) und Snapping fehlen für
3D-Grip-Drag (im 2D vollständig vorhanden).
- **DWG/DXF-Domänenimport** (Entitäten → echte Wände/Öffnungen) — Lesen
funktioniert, das Mapping auf das Modell ist unbegonnen; DWG-Schreiben fehlt.
- **`make2D`**-Kommando (3D-Ansicht → flacher 2D-Plan mit Füllungen).
- Bekannte, noch nicht bereinigte Doppelspur `Door[]`/`Opening[]` im Modell.
## Stack
Bewusst leichtgewichtig — Three.js ist reiner Display-Layer, kein schwerer
Geometrie-Kernel verfrüht eingezogen.
| | |
|---|---|
| Shell | Electron (eigenes randloses App-Fenster, kein Browser-Tab) |
| Shell | **Tauri** auf macOS (WKWebView+WebGPU) · **Electron/Chromium** auf Linux (WebKitGTK kann kein WebGPU) — beide randlos, eigene Titelleiste, gemeinsame React-App |
| Frontend | React + TypeScript + Vite |
| 3D | Three.js, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render3d`) |
| 2D-Plan | eigener SVG-Renderer, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render2d`) |
| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain |
| Export | `jspdf`/`svg2pdf.js` (Vektor-PDF), eigener DXF-Writer |
| 3D | eigener Rust/WASM/WebGPU-Renderer „Nordstern" (`render3d`, Default), Three.js als leichtgewichtiger Zweitpfad |
| 2D-Plan | eigener Rust/WASM/WebGPU-Renderer (`render2d`), plus ein TS/WebGL2-Renderer (`plan/glPlan`), plus SVG (`PlanView.tsx`, immer als Interaktions-/Fallback-Ebene aktiv) |
| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`); ein Rust-Port (`src-tauri/kernel2d`) existiert nur als Paritätstest, läuft nicht produktiv |
| CSG/Extrusion | `trucksolid`-Crate (`truck` + `csgrs`), fürs Extrude-Kommando |
| Export | eigener IFC4-Writer, `jspdf`/`svg2pdf.js` (Vektor-PDF), eigener DXF-Writer, STL/OBJ |
| Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser |
| Schnitt/HLR-Spike | `opencascade.js` (OCCT-WASM) — isoliert, noch nicht verdrahtet |
| Materialien | `jszip` (ambientCG-Zip-Entpacken), `three.js` `TextureLoader` |
Geplant, aber noch nicht eingezogen: `rhino3dm` (NURBS / `.3dm`), web-ifc (IFC),
Manifold (exakte 3D-Booleans). OCCT-WASM ist für HLR bereits als Spike da (s.o.),
aber noch nicht an Dokumentmodell/UI angebunden. Siehe ROADMAP §4.
`opencascade.js` steht noch als Dependency in `package.json`, wird aber nur
noch von totem Code (`src/section/hlr.ts`, superseded durch die Rust-
Schnitt-Pipeline) importiert. Details zu allen Rust-Crates (inkl. zwei
aktuell unbenutzten WASM-Builds) in [ARCHITECTURE.md](ARCHITECTURE.md) §1
und [STATUS.md](STATUS.md) §4.1.
## Entwicklung
```bash
npm install
npm run dev # Vite, http://localhost:5187
npm run electron # Electron-Fenster (Dev-Server + randlose App-Shell)
npx tsc -b # Typecheck
npm run build # tsc -b && vite build
npm test # vitest run
npm run dev # Vite, http://localhost:5187
npm run tauri:dev # Tauri-Fenster (macOS — nativer Zielrahmen dort)
npm run electron # Electron-Fenster (Linux — WebGPU über Chromium statt WebKitGTK)
npx tsc -b # Typecheck
npm run build # tsc -b && vite build
npm test # vitest run
```
Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und
schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Weitere
`scripts/probe-*.mjs` decken einzelne Features ab (PDF, Trim, Materialien,
Theme, Boolean-Ops …); für Firefox-Fälle gibt es `scripts/probe-ff*.mjs`
(Playwright).
WASM-Engines nach Rust-Änderungen neu bauen (nur `.rs` committen,
`src/engine/pkg*/` ist gitignored):
```bash
npm run build:engine # render2d
npm run build:engine3d # render3d
npm run build:truck # trucksolid
```
render3d/Wasm3DViewport-Änderungen sind nicht per Puppeteer/Browser
verifizierbar — im laufenden `tauri:dev`-Fenster selbst testen.
## Aufbau
```
src/
model/ semantisches Modell + Ableitungen (types, parametricWalls, roomStamp, joins, terrain)
geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling, opening, roomArea/-Boundary, stair)
model/ semantisches Modell (types, joins, parametricWalls, roomStamp, terrain)
geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling/opening/roomArea/stair/roof/column)
commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/)
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2-Renderer)
viewport/ Viewport3D (Three.js)
section/ HLR-Spike (OCCT-WASM) — Schnitte/Ansichten, noch nicht verdrahtet
export/ PDF- und DXF-Export aus derselben Plan-Struktur wie der Bildschirm
materials/ PBR-Material-Bibliothek (ambientCG-Import, Runtime)
text/ Rich-Text (Beschriftungen, Textobjekte)
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2) + Rust-Bindings
viewport/ Viewport3D (Three.js) + Wasm3DViewport (Rust/wgpu „Nordstern", Default)
section/ TOTER Code (OCCT-HLR-Spike) — Schnitt läuft über render3d, siehe ARCHITECTURE.md §4.3
export/ IFC4-, PDF-, DXF-, STL/OBJ-, CSV-Export aus derselben Plan-/Modell-Struktur
materials/ ambientCG-Live-Suche + gebündelte Starter-Bibliothek + PBR-Runtime
panels/ dockbares Panel-System + die einzelnen Paletten
state/ Store + Slices (project/selection/view/layout)
ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line
state/ eigener Store (useSyncExternalStore) + Slices (project/selection/view/layout/…)
native/ Tauri-only: native Fenster, macOS-Menüleiste, Fenster-Chrome
ui/ App.tsx-Shell, Top-Bar, Resource Manager, Kontextmenü, Kommandozeile, Ribbon
io/ Import/Export (DXF, DWG, .lin, .pat), Swisstopo/LV95, OSM-Kontext
i18n/ Wörterbücher de/en
src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WASM
gebaut (npm run build:engine), Tauri-Host selbst ist ausrangiert
src-tauri/ 6 Rust-Crates (render2d/render3d/geometry/kernel2d/trucksolid/dwgimport,
je headless UND per wasm-pack baubar) + der Tauri-Host selbst
```
## Konventionen
@@ -141,17 +149,30 @@ src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WAS
(Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer
über `t('key')` — keine hartcodierten Strings im JSX.
- Intern alles in **Metern**; Anzeige via `formatM`.
- `App.tsx` bleibt dünner Shell, Zustand lebt im Store. Verbindliches in
[CONVENTIONS.md](CONVENTIONS.md).
- Verbindliches in [CONVENTIONS.md](CONVENTIONS.md).
## Weiterlesen
- [ROADMAP.md](ROADMAP.md) — Produktvision, Architektur-Entscheidungen, Phasen 07, DOSSIER-Backlog
- [ARCHITECTURE.md](ARCHITECTURE.md) — Technische Architektur im Detail
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …)
- [STATUS.md](STATUS.md) — **vollständige, ehrliche Bestandsaufnahme**: Zahlen,
Feature-Inventar, Mist-Liste, Doku-Widersprüche, Tag-1-Vision vs. heute
- [ARCHITECTURE.md](ARCHITECTURE.md) — technische Architektur im Detail (Ist-Zustand)
- [ROADMAP.md](ROADMAP.md) — ursprüngliche Produktvision (Tag-1-Stand, historisch)
- [HANDOVER.md](HANDOVER.md) / [PENDENZEN.md](PENDENZEN.md) — laufendes
Arbeitsprotokoll bzw. Backlog (Single Source of Truth für offene Punkte)
- [docs/](docs/) — Design-Specs (teils ebenfalls Tag-1-Vision, siehe Hinweis
in `docs/README.md`)
## Lizenz
Copyright © 2026 Karim Gabriele Varano. Veröffentlicht unter der
**GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)**
siehe [LICENSE](LICENSE). Dossier ist Teil der **openbureau**-Suite.
Die AGPL verlangt, dass auch bei Betrieb als Netzwerk-/Webdienst der (ggf.
geänderte) Quellcode für die Nutzer verfügbar gemacht wird. Drittkomponenten
behalten ihre jeweiligen Lizenzen (siehe „Über"-Dialog in der App).
---
Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen,
Swisstopo-Geodaten) sind als Differenzierer eingeplant.
Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten)
sind ein bewusster Differenzierer.
+11 -1
View File
@@ -1,6 +1,16 @@
# Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap
> Arbeitstitel: **cad** (Name später)
> **Historisches Dokument (Tag-1-Vision, Stand 2026-06-28).** Vieles hier als
> „Phase 25"/„Backlog" gelistete ist inzwischen längst gebaut (Treppen, Dächer,
> Stützen, SIA-416, Swisstopo, OSM, Layouts, Ausschnitte, Kamera-Presets …), und
> mehrere Technologie-Entscheidungen liefen anders (eigene Rust/WASM-Engines
> statt Three.js/OpenCascade.js/web-ifc, siehe unten §4/§8). Für den aktuellen
> Ist-Zustand: **[STATUS.md](STATUS.md)** (Bestandsaufnahme + Mist-Liste) und
> **[ARCHITECTURE.md](ARCHITECTURE.md)** (aktuelle Architektur). Dieses Dokument
> bleibt als ursprüngliche Produktvision/Phasenplan stehen, wird aber nicht mehr
> laufend nachgeführt.
>
> Arbeitstitel: **cad** (Name später: **Dossier**)
> Ausrichtung: **BIM-first** · Nische: **Wohnbau / Einfamilienhäuser**
> Stand: 2026-06-28
+342
View File
@@ -0,0 +1,342 @@
# STATUS — Codebase-Analyse
> Stand: 2026-07-21 · Vollständige Bestandsaufnahme von Code + Dokumentation.
> Ersetzt NICHT [ROADMAP.md](ROADMAP.md)/[ARCHITECTURE.md](ARCHITECTURE.md) (die wurden
> im gleichen Zug überarbeitet), sondern begründet die Überarbeitung mit Zahlen und
> Befunden. [HANDOVER.md](HANDOVER.md) und [PENDENZEN.md](PENDENZEN.md) bleiben die
> laufenden Arbeitsprotokolle (nicht rückwirkend umgeschrieben).
## 0. TL;DR
„Dossier" (Arbeitstitel `cad`, Rhino-Vorbild `DOSSIER`) ist in **3 Wochen**
(erster Commit 2026-06-30, 362 Commits bis 2026-07-20) von einem Risiko-Spike zu
einem **funktionsreichen Desktop-CAD/BIM-Tool** gewachsen: eigenes semantisches
Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines („Nordstern"), ein
Rhino-artiges Kommandosystem, IFC/DXF/PDF/STL/OBJ-Export, Swisstopo-Import,
SIA-416-Flächen, Materialbibliothek (statisch + live von ambientCG), Layouts/
Plansätze, Ausschnitte, native Tauri-Fenster. **~125.000 Zeilen Code** (TS+Rust,
inkl. Tests), gebaut über viele autonome Agent-Sessions.
Die drei zentralen Vision-Dokumente (ARCHITECTURE.md, README.md, ROADMAP.md)
stammen aus der **allerersten Woche** (Stand 28./29.6.) und beschreiben einen
Plan, der in der Zwischenzeit an vielen Stellen überholt, anders gelöst oder
längst umgesetzt wurde (z. B. HLR/OCCT→eigene Rust-Schnitt-Pipeline,
„Booleans noch offen"→teilweise längst gelöst). Diese Doku-Drift war der Auslöser
für diese Analyse; die Docs sind im gleichen Zug revidiert worden.
## 1. Kennzahlen
### Code-Umfang
| Bereich | Dateien | LOC (ohne Tests) | Tests |
|---|---:|---:|---:|
| `src/` (TypeScript, gesamt) | ~230 | 87.736 | 869 (Vitest, 71 Dateien) / 16.855 LOC |
| `src-tauri/render3d` („Nordstern" 3D) | 14 | 10.246 | 97 `#[test]` |
| `src-tauri/render2d` (2D-WGSL-Renderer) | 11 | 3.530 | 18 `#[test]` |
| `src-tauri/kernel2d` (Rust-Geometriekern, Paritätstest) | 1 | 3.383 | 18 `#[test]` |
| `src-tauri/geometry` (Wand-Join-Mathe, **unbenutzt**) | 1 | 1.057 | 8 `#[test]` |
| `src-tauri/trucksolid` (CSG/Extrusion, `truck`+`csgrs`) | 2 | 758 | 15 `#[test]` |
| `src-tauri/dwgimport` (DXF-Parser-Spike, **unbenutzt**) | 1 | 284 | 1 `#[test]` |
| `src-tauri/src` (Tauri-Host: Fenster, Dialoge, Lock) | 4 | 1.044 | — |
| **Gesamt** | | **~108.000** (ohne Tests) / **~125.000** (mit Tests) | 869 Vitest + 157 Rust-Tests |
Größte TS-Bereiche: `plan/` (23.626 LOC — Plan-Ableitung + 3 Renderer),
`ui/` (12.897 LOC — App-Shell, ResourceManager, Ribbon), `panels/` (9.538 LOC),
`model/` (7.043 LOC inkl. Tests), `commands/` (6.973 LOC), `io/` (5.299 LOC),
`state/` (5.297 LOC), `geometry/` (5.559 LOC), `viewport/` (5.165 LOC),
`export/` (4.280 LOC). `src/App.tsx` allein ist **7.130 Zeilen**.
### Tempo
362 Commits in 3 Wochen; Woche 27 (30.6.6.7.): 233 Commits, Woche 28: 126,
danach starker Rückgang (Woche 30 bislang 3) — die Session-Dichte hat spürbar
abgenommen, nicht das Projekt gestoppt (siehe PENDENZEN.md, weiterhin aktiv).
## 2. Architektur, wie sie WIRKLICH ist (nicht wie geplant)
### 2.1 Datenmodell — kein `Element[]`-Union, sondern typisierte Arrays
`ARCHITECTURE.md` (alt) plante eine diskriminierte Union `Element = Wall | Door |
Window | …`. Tatsächlich hält `Project` (`src/model/types.ts:2095`) **pro
Bauteiltyp ein eigenes optionales Array**: `walls`, `ceilings?`, `roofs?`,
`doors`, `openings?` (Fenster/Türen gehostet in Wänden, `kind:"window"|"door"`),
`stairs?`, `rooms?`, `columns?`, `extrudedSolids?`, `drawings2d`, `context?`
(Importe/Terrain), plus die Bibliotheks-/Typtabellen (`lineStyles`, `hatches`,
`components`, `wallTypes`, `roofTypes?`, `doorTypes?`, …) und die
Dokument-Ebene (`viewSnapshots?`, `layouts?`, `masterLayouts?`, …). Der Typ-Alias
`Element` (types.ts:1601) existiert zwar noch, wird aber **nirgends** verwendet
(Element-Baum/Selektion arbeiten direkt auf den typisierten Arrays). Praktisch
funktioniert das gut (jeder Bauteiltyp hat sein eigenes, spezifisches Interface),
ist aber eine bewusste Abweichung vom ursprünglichen Uniform-Union-Plan.
### 2.2 State — kein Zustand/Redux/Immer, sondern ein Eigenbau
`docs/design/state-architecture.md` empfahl **Zustand**. Gebaut wurde stattdessen
ein **abhängigkeitsfreier Store auf `useSyncExternalStore`** (`src/state/store.ts`,
gleiches Muster wie `src/i18n`). `createStore()` komponiert Slice-Fabriken
(`projectSlice` inkl. Undo/Redo, `historySlice`, `selectionSlice`, `viewSlice`,
`layoutSlice`, `siteSlice`, `notifySlice`) über eine gemeinsame `RootState`.
Funktioniert, aber: der geplante Folgeschritt „App.tsx wird dünne Shell,
View-Routing nach `src/views/`, Kontextmenüs nach `src/menus/`" ist **nicht**
passiert — `src/views/` und `src/menus/` existieren nicht, View-Umschaltung und
Kontextmenü-Aufbau liegen weiterhin inline in `App.tsx` (7.130 Zeilen). Das ist
der deutlichste Doku-vs-Code-Widerspruch im ganzen Repo (CONVENTIONS.md
verlangt explizit das Gegenteil).
### 2.3 Rendering — drei 2D-Pfade, zwei 3D-Viewports
**2D-Plan:** es gibt tatsächlich **drei** koexistierende Renderer, nicht einen:
1. `PlanView.tsx` (SVG) — Referenz-/Fallback-Pfad, bleibt IMMER im DOM für
Hit-Testing/Grips, unabhängig davon was zeichnet.
2. `plan/glPlan/` — eigener TypeScript-WebGL2-Renderer (`glPlanCompile/-Render/
-Shaders/-Hatch.ts`).
3. `useWasmPlanRenderer.ts` → Rust-`render2d`-Crate (WGSL, nativ via wgpu,
Web via WebGPU/WebGL2-Fallback, inkl. echtem Text-Rendering via `glyphon`).
Beide GPU-Pfade fallen bei Initialisierungsfehler still auf SVG zurück.
**3D:** ebenfalls zwei Viewports: `Viewport3D.tsx` (three.js, „Free"-Stufe) und
`Wasm3DViewport.tsx` (Rust/wgpu „Nordstern", editierbar, **Default**). Ein
Settings-Schalter wählt die Engine.
### 2.4 Schnitt/Section — NICHT über HLR, sondern eigene Rust-Pipeline
`src/section/hlr.ts` + `occt.ts` (der ursprüngliche OpenCascade.js-HLR-Spike aus
Phase 0) hat **keinen einzigen Aufrufer mehr** im gesamten `src/` — toter Code.
Der tatsächliche, funktionierende Live-Schnitt läuft über einen völlig anderen,
analytischen Mechanismus: `App.tsx` (`section3dCutId`/`section3dPlane`) →
`Wasm3DViewport.tsx` (`section3d`-Prop → `setSectionPlane`) →
`src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}`.
Die Rust-Seite nutzt aus, dass jedes Bauteil ein Prisma mit konstantem
Querschnitt ist — eine Schnittebene liefert dadurch immer ein
achsparalleles Rechteck, nie ein Trapez; `section_boolean.rs` ist ein 1:1-Port
von `toSection.ts::subtractDominantBands`, damit 2D-Plan-Schnitt und
3D-Live-Schnitt exakt übereinstimmen. Kein Worker, kein Comlink (beides war
geplant, keines existiert) — läuft synchron/GPU-seitig.
### 2.5 Öffnungen als Löcher — echt, aber kein Mesh-Boolean
Fenster/Türen schneiden echte achsparallele Rechteck-Löcher aus dem Wandkörper
(`plan/toWalls3d.ts` `RHole`/`subtractSpans`, gespiegelt in
`render3d/{mesh.rs,section.rs}`) — funktioniert, ist aber KEIN generisches
Mesh-Boolean. Ein echtes CSG-Boolean existiert bereits (`trucksolid::boolean_mesh`,
`csgrs`-basiert, 15 Rust-Tests) und wird von `src/engine/truckSolid.ts` für das
Extrusions-Kommando genutzt — ist aber **nicht** an die Wand/Öffnungs-Pipeline
angeschlossen (bestätigt: `booleanMesh` hat ausserhalb von `truckSolid.ts`
keinen Aufrufer).
### 2.6 Rust-Workspace: sechs unabhängige Crates, nicht ein Workspace
`src-tauri/Cargo.toml` bindet nur den Tauri-Host (`cad-tauri`) als Workspace-
Mitglied; `render2d/render3d/geometry/kernel2d/trucksolid/dwgimport` sind
**eigenständige Cargo-Packages**, die dem Host nur optional (Features
`native2d`/`native3d`, standardmässig AUS) als Path-Dependency zugespielt
werden. Jedes Crate muss headless (`cargo test`) UND per `wasm-pack --features
web` bauen, ohne den Tauri-Toolchain-Zwang zu erben — bewusst so geschnitten.
Geteilte Abhängigkeiten: `wgpu 29`/`naga 29` (render2d+render3d, versionsgekoppelt
wegen `glyphon 0.11`), `truck-modeling`+`csgrs`(gepinnter Git-Rev)+`nalgebra`
nur in `trucksolid`.
### 2.7 Zwei Desktop-Rahmen: Tauri (macOS) + Electron (Linux)
Die App läuft plattformabhängig in **zwei verschiedenen nativen Rahmen** —
das ist Absicht, kein Wildwuchs, und hängt an **WebGPU**:
- **macOS → Tauri.** WKWebView unterstützt WebGPU, das die render2d/render3d-
WASM-Engines brauchen. `src-tauri/tauri.conf.json` (Identifier
`ch.dossier.cad`, eigene Titelleiste, `trafficLightPosition`) +
`isTauriRuntime()`-Gates an 6+ Stellen in `App.tsx` + vier eigene native
Zusatzfenster (`src/native/`: Resources, Settings, DrawingLevels,
LayerSettings, ContextImport).
- **Linux → Electron.** Tauris Linux-Webview **WebKitGTK unterstützt WebGPU
nicht zuverlässig** → dort läuft die App über eine Electron/Chromium-Shell
(`scripts/electron-main.cjs` + `electron-preload.cjs`, gestartet via
`npm run electron`). Der Kommentar in `electron-main.cjs` sagt es explizit:
„Ersetzt WebKitGTK durch Chromium, damit WebGPU zuverlässig läuft."
Beide teilen sich **dieselbe** React-App und dieselbe randlose eigene
Titelleiste; die Laufzeit erkennt den Host über `window.__TAURI__` (Tauri)
bzw. `window.dossierWindow` (Electron, per `contextBridge` injiziert). Die
Fenstersteuerung (`src/ui/WindowControls.tsx`) ist an beide Wege angebunden.
Kein Rust-Backend nötig auf dem Electron-Pfad — `computeJoins` hat einen
TS-Fallback (`src/compute/index.ts`). Electron ist also **kein** totes Gleis,
sondern der aktive Linux-Zielrahmen.
## 3. Feature-Inventar (was tatsächlich funktioniert)
### Modell & Bauteile
- Mehrschichtige Wände (`WallType.layers[]`) mit L-Eck-Gehrung UND
Prioritäts-T-/X-Stössen (`joinPriority` am Component) — **fertig**, in 2D-Plan,
3D-Viewport UND 3D-Live-Schnitt konsistent (Rust-Port `section_boolean.rs`).
- Parametrische Wände (`ParametricWall`: Grid/Modul/Sequenz/Referenzlinie/
bedingte Dicke) lösen sich zu konkreten `Wall[]` auf.
- Decken (Slabs, `ceilings?`) mit Aussparungen, eigenem Typkatalog.
- Türen/Fenster gehostet in Wänden, mit Rahmen/Zarge/Blockrahmen, Kämpfer,
Oberlicht, Detailgrad grob/mittel/fein (2D UND 3D), Schwenkbogen; daneben
existiert weiterhin ein **älteres, separates `Door[]`** neben `Opening[]` —
laut PENDENZEN.md explizit als offene Doppelspur/Aufräum-Punkt vermerkt.
- Treppen (gerade/L/Wendel), geschossübergreifend, 2D-Symbol mit Lauflinie/Pfeil.
- Dächer (Flach/Pult/Sattel/Walm/Mansarde/Zelt) über Rechteck-Umriss, First/
Traufe/Grat im 2D-Plan.
- Stützen (Column) mit Profilbibliothek (Quadrat/Rechteck/Rund/I/Rohr).
- Räume (SIA-416: HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV-Export,
Raumstempel-Editor (Drag&Drop-Felder).
- Extrudierte Volumenkörper (truck-Integration: konkave Profile, Verjüngung).
- Kontext-Layer (Terrain-TIN, importierte Meshes, Konturen) — semantisch getrennt.
### Zeichnen & Bedienung
- Rhino-artiges Kommandosystem (`commands/`): getippte Koordinaten
(`5,3`/`r5,3`/`5<45`), Tab-Feld-Zyklus für Präzisionseingabe
(`src/ui/CommandLine.tsx`), Alias/Autocomplete, ~25 Kommandos (wall, ceiling,
opening, stair, column, roof, room, line/polyline/rect/circle/arc, text,
move/mirror/copy/offset/trim/join, extrude, import, terrain, measure,
Schnittlinie, Georef).
- Snapping (Endpunkt/Mitte/Schnittpunkt/Lot/Raster/Ortho), Grips, Array,
Trim/Split/Join, 2D-Booleans (Union/Subtract/Intersect via `polygon-clipping`).
- Messwerkzeug (Polygonzug, Länge + Fläche).
- Rich-Text-Annotationen (Bold/Kursiv/Hoch-/Tiefstellung).
### Darstellung / Ressourcen
- Resource Manager: Line/Hatch/Component-Manager, Wand-/Decken-/Tür-/Fenster-/
Treppen-/Dach-Typeditoren — als eigenständiges natives Fenster (nicht als
Dock-Panel).
- Materialbibliothek: 13 fest gebündelte PBR-Starter (ambientCG, lokale
Texturen) **plus** Live-Suche der kompletten ambientCG-Bibliothek (Auflösung
1K/2K/4K, on-demand Download+Entpacken via `jszip`, Proxy wegen CORS) — heute
bereinigt (siehe Commit-Historie dieser Session).
- Regelbasierte Overrides (Bedingung → Farbe/Strichstärke/Schraffur/Sichtbarkeit).
- Detailgrad grob/mittel/fein je Bauteil + Dokument-Override.
- Hell-/Dunkel-Theme, Akzentfarben.
### Pläne / Output
- Ausschnitte (View-Snapshots: Kamera, Massstab, Detailgrad, Sichtbarkeiten,
Override-Preset) in Ordnerstruktur.
- Layout-Blätter (Plansätze): Papierformat/-grösse, mehrere Viewports pro
Blatt, Masterlayout-Vererbung (Titelblock), Ordnerstruktur, freie 2D-Annotationen.
- Vektor-Export: PDF (Einzelblatt UND **Mehrseiten pro Ordner**,
`layoutPdf.ts::buildFolderPdf`), DXF, IFC4 (mit echten Fenster-/Tür-Löchern,
deterministischen GUIDs), STL, OBJ, CSV-Bauteil-Schedule (volles Element-Set).
- Kamera-Presets (Kardinal + Iso), Norden-Rotation.
### Import / Kontext
- DXF (Konturen), DWG (`@mlightcad/libredwg-web`, WASM), `.lin`/`.pat`.
- Swisstopo: swissBUILDINGS3D (radiusgenau zugeschnitten, nicht die ganze
STAC-Kachel), swissALTI3D, SWISSIMAGE-Orthofoto-Draping, LV95↔WGS84,
Georeferenzierung über EINEN Vermessungspunkt (E/N/H, `geoAnchor`).
- OSM/Overpass-Kontextimport (7 Kategorien).
- Terrain-Mesh-Generator.
### Desktop-Integration (Tauri)
- Eigene randlose Fenster mit nativer Titelleiste (macOS-Ampel-Position).
- Native Speichern/Öffnen-Dialoge (`plugin-fs`/`plugin-dialog`), eigenes
`.obp`-Projektdateiformat.
- OS-Level-Exklusiv-Lock gegen Doppelöffnen desselben Projekts (`fs4`-Crate).
- Vier eigenständige native Zusatzfenster (Resources, Settings, DrawingLevels,
LayerSettings, ContextImport) statt Overlay/Modal.
- Native macOS-Menüleiste.
- i18n de/en durchgängig, eigener `t()`-Mechanismus (kein i18next).
## 4. Mist-Liste — Befunde, Doku-Widersprüche, offene Fäden
### 4.1 Verwaiste WASM-Crates (gebaut, aber nirgends importiert)
- **`src-tauri/geometry`** (1.057 LOC, 8 Tests) → `pkgGeometry` — **null**
Importstellen in `src/`. Wand-Join-Mathe existiert redundant als TS
(`src/model/joins.ts`) UND als Rust-Port, aber nur die TS-Version läuft.
- **`src-tauri/dwgimport`** (284 LOC) → `pkgDwgImport` — **null** Importstellen;
DWG-Import läuft stattdessen über `@mlightcad/libredwg-web` (npm-Paket).
- **`src-tauri/kernel2d`** (3.383 LOC, 18 Tests) → `pkgKernel2d` — wird nur von
einem Paritätstest (`kernel2d.parity.test.ts`) konsumiert, nicht produktiv.
Die TS-Version `src/geometry/kernel2d.ts` (886 Zeilen) ist die tatsächlich
laufende Implementierung. Laut PENDENZEN.md bewusst so belassen: ein
Join-Benchmark zeigte WASM unter ~100 Wänden **langsamer** als die naive
TS-Routine. Kein Bug, aber die drei Crates zusammen sind ~4.700 Zeilen Rust
(+44 Tests), die aktuell nichts zur Laufzeit beitragen ausser einem
Korrektheits-Cross-Check für kernel2d.
**Empfehlung:** entweder (a) `geometry`- und `dwgimport`-Crate + ihre
`build:*`-Scripts entfernen (kein Nutzen, nur Wartungslast), oder (b)
explizit als „Referenzimplementierung/Zukunftsoption" in ARCHITECTURE.md
dokumentieren, damit niemand sie für aktiv hält.
### 4.2 Toter Code
- `src/section/hlr.ts` + `occt.ts` + `occt-wasm.d.ts` (473 LOC) — OCCT-WASM-
HLR-Spike aus Phase 0, **keine Aufrufer mehr**. Ersetzt durch die analytische
Rust-Schnitt-Pipeline (§2.4). `opencascade.js` bleibt als npm-Dependency
bestehen, obwohl nur noch dieser tote Code sie importiert.
- `src/export/planToPrintSvg.ts` — Kommentar im Code selbst sagt „ERSETZT durch
`sceneToPrintSvg.ts`", ist aber noch im Baum.
- `Element`-Typalias (`src/model/types.ts:1601`) — definiert, nirgends benutzt.
### 4.3 Das grösste Doku-vs-Code-Problem: App.tsx
CONVENTIONS.md verlangt seit Tag 1 „App.tsx bleibt dünner Shell, keine
Geschäftslogik". `docs/design/state-architecture.md` plante explizit die
Extraktion nach `src/views/` (View-Routing) und `src/menus/`
(Kontextmenü-Aufbau). Beide Ordner **existieren nicht**. `App.tsx` ist mit
**7.130 Zeilen** die grösste Einzeldatei des Projekts und enthält weiterhin
View-Umschaltung und Kontextmenü-Konstruktion inline. Das ist der genaue
„God-Component"-Zustand, den die Doku von Anfang an vermeiden wollte.
### 4.4 Bekannte Doppelspur: `Door[]` vs. `Opening[]`
`Project` führt sowohl ein älteres `doors: Door[]` als auch das neuere,
allgemeinere `openings?: Opening[]` (`kind:"door"|"window"`). Laut
PENDENZEN.md ist das erkannt und als Aufräum-Punkt vorgemerkt, aber nicht
konsolidiert.
### 4.5 Doku-Widersprüche (README/ARCHITECTURE/CONVENTIONS vs. Realität)
| Dokument | Behauptung | Realität |
|---|---|---|
| README.md | Shell = Electron, Tauri „ausrangiert" | Falsch andersrum gedacht: **beide** sind aktiv — Tauri auf macOS (WKWebView+WebGPU), Electron auf Linux (WebKitGTK kann kein WebGPU); siehe §2.7 |
| README.md | „HLR noch nicht ans UI verdrahtet, Views sind Stubs" | Der OCCT-HLR-Pfad stimmt (tot), aber Schnitte/Ansichten funktionieren real über eine andere, eigene Rust-Pipeline |
| README.md | „Prioritäts-T-/X-Stösse … Risiko #1" unter „bewusst offen" | Seit 7.7. erledigt, inkl. Rust-Port |
| README.md | „PDF-Export ist noch single-sheet" | `layoutPdf.ts::buildFolderPdf` erzeugt echte Mehrseiten-PDFs pro Ordner |
| README.md | Engine-Liste nennt nur render2d/render3d | Es gibt 6 Rust-Crates (+kernel2d/geometry/trucksolid/dwgimport) |
| CONVENTIONS.md | Struktur-Ziel `src/views/`, `src/menus/` | Existieren nicht; Logik liegt in `App.tsx` |
| CONVENTIONS.md | Dev-Port 5173 | Tatsächlich 5187 (`vite.config.ts`, `tauri.conf.json`) |
| ARCHITECTURE.md | Ziel-Struktur `store/`, `sheets/`, `workers/geometry.worker.ts` (Comlink) | Tatsächlich `state/`, `panels/layoutModel.ts`; kein Worker/Comlink irgendwo im Projekt |
| ARCHITECTURE.md | HLR „im Web Worker (Comlink)" | Kein Comlink im Projekt; Schnitt läuft synchron GPU-seitig in Rust |
| HANDOVER.md | Neuester Block: „Stand 2026-07-09" | Jüngster Commit + PENDENZEN.md sind vom 17.20.7. — 8+ Tage/mehrere Sessions veraltet |
### 4.6 Codequalität — besser als der Tempo vermuten lässt
Trotz 3 Wochen / 362 Commits über viele autonome Sessions: **keine** FIXME/HACK/
XXX-Marker im ganzen Projekt; nur 2 TODOs (beide bekannt/harmlos: Geländer in
`Viewport3D.tsx:2649`, Kanten-Tiefe in `toSection.ts:1093`); `eslint-disable`
fast ausschliesslich `react-hooks/exhaustive-deps` (bewusst); keine
`_v2`/`_old`/`_backup`-Dateileichen. Die eigentliche Aufgabenliste lebt
diszipliniert in PENDENZEN.md statt in Code-Kommentaren verstreut — gesünder
als der Durchschnitt für dieses Bau-Tempo.
### 4.7 Genuine offene Punkte (Auszug aus PENDENZEN.md, Details dort)
- Geo-Block: Layer-Zuordnung für importierte Gebäude/Terrain, reale Höhen +
Projekt-müM-Draping, SWISSIMAGE-Draping.
- 3D-Feinschliff: Fensterrahmen-Ecken bei „fein" überlappen (kein Gehrungs-
Union), Dach-First/Grat hat unverschmolzene Dreiecke, unerklärte
Vertikalstreifen auf oberen Wandflächen (undiagnostiziert).
- truck-Boolean nicht an die Wand/Öffnungs-Pipeline angeschlossen (Scope-
Entscheid mit Nutzer ausstehend).
- Feld-Controller (Tab-Zyklus) fehlt für Body-Move und für 3D-Griffe generell
(nur 2D-Einzelpunkt-Drag hat ihn); 3D-Griff-Drag hat gar kein Snapping.
- `make2D`-Kommando (3D→flacher 2D-Plan mit Füllungen) ungebaut.
- Ribbon-3D-Tab leer; einige Punkte visuell noch nicht in Tauri abgenommen
(u. a. Materialfarben-Textur-Array, Schraffur-Schnittfüllung — laut
PENDENZEN als „[~] implementiert, aber unverifiziert" markiert).
- DWG/DXF-Domänen-Mapping (Entitäten → Wände/Öffnungen) unbegonnen; DWG-Schreiben
fehlt (Lesen über `libredwg-web` vorhanden).
- Teamwork/Kollaboration (Supabase) bewusst nicht begonnen, gilt als späte Phase.
## 5. Vergleich: Tag-1-Vision vs. heute
| Vision (28./29.6.) | Heute |
|---|---|
| Three.js als einziger 3D-Renderer, OpenCascade.js für Booleans/HLR | Eigene Rust/WASM-Engines („Nordstern") für 2D+3D; three.js nur noch „Free"-Fallback; OCCT-Pfad tot |
| Ein `Element[]`-Union | Typisierte Arrays pro Bauteiltyp auf `Project` |
| Zustand-Store | Eigener `useSyncExternalStore`-Store |
| Web Worker + Comlink für HLR | Synchrone, analytische Rust-GPU-Schnitt-Pipeline |
| „Booleans: Entscheidung in Phase 0" | Trucksolid/csgrs-CSG existiert, ist getestet, aber nicht an Wände angeschlossen |
| web-ifc für IFC | Eigener IFC4-Writer (`exportIfc.ts`) |
| Phase 25 grösstenteils „Backlog" | Treppen, Dächer, Stützen, SIA-416, Swisstopo, OSM, Kamera-Presets, Layouts, Ausschnitte, Terrain — alles bereits gebaut |
Kurz: die **Prinzipien** (ein Modell, viele Ableitungen; Darstellung erst beim
Rendern; keine Cache-Stale-Bugs) haben gehalten und wurden korrekt umgesetzt.
Die **konkreten Technologie-Entscheidungen** sind fast durchgängig anders
gelaufen als geplant — meist zugunsten einer eigenen, schnelleren Rust/WASM-
Lösung statt einer Drittbibliothek.
+8 -1
View File
@@ -1,6 +1,13 @@
# Dokumentation — Standalone Browser-BIM (cad)
> Stand: 2026-06-29 · Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen) ·
> Stand: 2026-06-29 · **Historische Recherche-/Design-Dokumente aus der ersten
> Woche.** Mehrere Kern-Empfehlungen hier (replicad/OCCT als B-Rep-Kernel,
> Manifold, web-ifc, `three/webgpu`) wurden im tatsächlichen Bau **nicht**
> umgesetzt — stattdessen entstanden eigene Rust/WASM-Rendering-Engines
> („Nordstern"). Für den aktuellen Ist-Zustand: **[../STATUS.md](../STATUS.md)**
> und **[../ARCHITECTURE.md](../ARCHITECTURE.md)**. Die Dokumente unten sind als
> Recherche-Hintergrund weiterhin lesenswert, aber nicht mehr aktueller Plan.
> Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen, ebenfalls historisch) ·
> [CONVENTIONS.md](../CONVENTIONS.md) (Konventionen) · [ARCHITECTURE.md](../ARCHITECTURE.md).
Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die
+623
View File
@@ -0,0 +1,623 @@
# BIM-Elementtiefe — Tür, Fenster, Dach, Decke ("wirklich 1:1")
## 0. Zweck und Abgrenzung
DOSSIER modelliert Bauteile heute semantisch (kein reines Zeichenprogramm) und
hat für Wand/Decke bereits eine mehrschichtige Aufbaulogik (`Component[]` via
`WallType`/`CeilingType`). Türen und Fenster haben seit
`docs/design/window-editor-vectorworks-study.md` einen dedizierten,
phasierten Ausbauplan (Flügeltabelle, Verglasung, Sonnenschutz). Was fehlt,
ist die gleiche Tiefe für **Dach** (keine Schichtlogik, keine Mansard-
Untertypen, kein Kehl-/Gaubenmodell) und für die **Decken-Randterminierung**
(Deckenrand als reines Polygon ohne Kantendetail).
Dieses Dokument nimmt die vier Bauteile Tür, Fenster, Dach, Decke und hält
sie gegen die reale Tiefe vollständiger BIM-Programme (Vectorworks Architektur,
ArchiCAD, Revit, Allplan). Ziel ist NICHT, jedes Feature dieser Programme zu
kopieren, sondern zu benennen, welche Lücken einen echten "1:1-Zuwachs" für
ein Einfamilienhaus-/Wohnbau-Tool wie DOSSIER bringen — und welche reiner
Ballast wären (Abschnitt 6).
Für Fenster/Tür dupliziert dieses Dokument NICHT die Mapping-Tabelle aus
`window-editor-vectorworks-study.md` — es referenziert sie und ergänzt, was
dort fehlt (v. a. Tür-Tiefe, die die Studie nur am Rand behandelt, und die
architektonischen Grenzen des heutigen Öffnungsmodells). Für Wand-Schichtlogik
siehe `docs/design/parametric-walls.md` (Raster/Modul-Regeln, nicht
Gegenstand hier) und `docs/design/elements.md` (ältere Gesamtplanung, Stand
vor der aktuellen `Component`/`Layer`-Implementierung — dort abweichende
Typnamen wie `Slab`/`ProfileDef`, hier durchgängig der IST-Code zitiert).
Alle IST-Aussagen sind mit Datei:Zeile belegt (verifiziert per Lesen des
Codes, Stand dieses Commits). Status-Legende der Lücken-Tabellen: **✓**
vorhanden und gerendert · **~** Feld existiert, Renderer liest es nicht oder
nur grob · **✗** fehlt vollständig. Priorität P0 (grösster 1:1-Zuwachs, bald)
… P3 (Ballast, nur auf Nachfrage). Aufwand grob in Personentagen (PT).
---
## 1. Gemeinsames Fundament: das Schicht-Muster
Der zentrale Baustein, den DOSSIER bereits hat und der sich wiederverwenden
lässt, ist `Component`/`Layer`:
- `Component` (`src/model/types.ts:196`) — ein Bauteil-Material: Poché-Farbe
(`color`/`foreground`/`background`), Schnitt-Schraffur (`hatchId`) UND
Ansichts-Schraffur (`viewHatchId`, für unaufgeschnittene Aufsicht),
optionales PBR-Material (`material`), Kürzel (`abbrev`) und ein
Verschneidungs-Rang (`joinPriority`, `types.ts:241`) für die Boolean-
Dominanz am Stoss.
- `Layer` (`types.ts:245`) — eine Schicht: `componentId` + `thickness` +
optionaler Fugen-Linienstil (`jointLineStyleId`).
- `WallType` (`types.ts:260`) und `CeilingType` (`types.ts:273`) sind BEIDE
nur `{ id, name, layers: Layer[] }` — bewusst derselbe Typ, "das
horizontale Gegenstück zum WallType" (Kommentar `types.ts:266-271`).
Das Muster ist also bereits zweimal (Wand, Decke) verifiziert:
`ceilingThickness()` (`types.ts:2104`) summiert die Layer-Dicken,
`emitSlabs()` (`src/plan/toWalls3d.ts:1352-1419`) stapelt sie im 3D als
einzelne `RSlab`-Scheiben proportional in `[zBottom, zTop]`, `addCeilingPoche`
(`src/plan/generatePlan.ts:2453`) zeichnet die Aufsicht mit der
Ansichts-Schraffur der ERSTEN Schicht, und `toSection.ts:633`
(`splitSlabLayers`) zerlegt die Decke im ECHTEN Schnitt in
Einzel-Bänder je Schicht (mit `resolveCeilingSectionStyle`,
`generatePlan.ts:591`, als Schraffur-/Farbquelle).
**Der Dach-Vorschlag in Abschnitt 4 ist im Kern: dasselbe Muster ein drittes
Mal anwenden.** Das ist der günstigste Weg zu echter Dach-1:1-Tiefe, weil
Layer-Resolver, Schraffur-Ketten (`resolveHatch`/`resolveForeground` etc.,
`generatePlan.ts:409-536`) und die 3D-Stapel-Logik bereits bestehen und nur
auf einen neuen Aufbau-Typ angewendet werden müssen statt neu erfunden.
Architektonische Randbemerkung: `Project` führt bereits `wallTypes`,
`ceilingTypes?`, `doorTypes?`, `windowTypes?`, `stairTypes?`
(`types.ts:1936-1958`) als eigene Bibliotheken — aber **kein `roofTypes?`**.
`Roof` (`types.ts:1022`) trägt nur ein einzelnes `thickness: number`
(`types.ts:1044`), keinen Aufbau-Verweis. Das ist die strukturelle Lücke,
die Abschnitt 4 schliesst.
---
## 2. Tür (Door)
### 2.1 Was ein vollständiges BIM-Tool bietet
| Bereich | Typische Parameter (VW/ArchiCAD/Revit/Allplan) |
|---|---|
| Bauart | Dreh-, Schiebe- (auf/vor Wand), Falt-, Pendel-, Karusselltür, reiner Durchbruch |
| Blattzahl/-teilung | 1-/2-flügelig, Gangflügel + Standflügel (unterschiedliche Breite), Seitenteile links/rechts |
| Blattausführung | glatt, kassettiert, Glasfüllung (Anteil/Sprossenbild), Brandschutz-/Schallschutz-Kennwert |
| Rahmen/Zarge | Zarge vs. Blockrahmen, Rahmenbreite je Kante, Zargentiefe, Bekleidung/Abdeckleiste, Falz |
| Schwelle | ohne, Alu-Flachschwelle, Anschlagdichtung, Bodenanschluss/Gefälle bei Aussentüren |
| Sturz/Oberlicht | festverglastes Oberlicht mit eigenem Rahmen, Kämpfer, Sprossenbild |
| Seitenteile | fest verglaste Seitenteile links/rechts, eigene Breite |
| Form | rechteckig, Rundbogen, Segmentbogen, Stichbogen — bei Aussen-/Haustüren verbreitet |
| Beschlag | Drücker/Knauf-Typ, Schild, Schliesszylinder, Bänder sichtbar/verdeckt |
| 2D-Darstellung | Blatt + Schwenkbogen (Grundriss), eigene Ansichtssymbolik in Schnitt/Elevation, Sturzlinien |
| Material/Schichten | Blatt-Kernaufbau (bei Brand-/Schallschutztüren mehrschichtig, analog Wand) |
| IFC-Rolle | `IfcDoor` mit `IfcDoorType` (PredefinedType), `OverallWidth/Height`, `IfcDoorPanelProperties`, Void in der Wirtswand |
### 2.2 IST in DOSSIER
`Opening` mit `kind: "door"` (`src/model/types.ts:1068`) referenziert
optional einen `DoorType` (`types.ts:296`). Vorhanden am Typ: `kind`
("dreh"/"schiebe"/"wandoeffnung", :303), `leafCount` (1|2, :305), `leafStyle`
("glatt"/"kassette"/"glas", :307), `glazingRatio` (:309), `frameThickness`/
`frameDepth`/`frameKind`("zarge"/"blockrahmen")/`frameWidth`
(:311-328), `insetFromFace`/`insetFace` (:335-337), `transomHeight` (:343),
`threshold` (:349). Am Element selbst: `swing`/`hinge`/`swingAngle`/
`openingDir` (:1104-1108), `lintelLines` (Sturzlinien, :1119) und
`doorType: "normal"|"wandoeffnung"` (:1100) — ein zweites, mit `DoorType.kind`
teilweise redundantes Feld.
Renderer-Konsum, real geprüft:
- **2D-Blatt ist IMMER einflügelig.** `addOpeningSymbol` (Tür-Zweig,
`src/plan/generatePlan.ts:2091-2213`) zeichnet genau EINE Blattlinie
(`sym.hinge → sym.openEnd`) und EINEN Schwenkbogen — `DoorType.leafCount`
wird an keiner Stelle in `generatePlan.ts` gelesen (kein Treffer für
`leafCount` im ganzen Plan-Renderer). Eine zweiflügelige Tür sieht im Plan
aus wie eine einflügelige.
- **3D genauso**: `resolveOpeningFrame` (`src/plan/toWalls3d.ts:1861-1886`)
setzt für Türen hart `wingCount: 1`, unabhängig von `leafCount`.
- `leafStyle` wirkt NUR binär: `"glas"` schaltet eine volle Verglasung frei
(`glazed: dt.leafStyle === "glas"`, `toWalls3d.ts:1883`) — `glazingRatio`
(Teilverglasung, z. B. 60 % Glasanteil im oberen Blattbereich) wird an
keiner Stelle gelesen. "kassette" (Kassettentür) hat keine eigene Geometrie,
fällt auf dieselbe Quader-Darstellung wie "glatt" zurück.
`frameKind: "blockrahmen"` wirkt NUR im 2D-Rahmenband
(`generatePlan.ts:1996`, `isBlock`), im 3D gibt es keinen Unterschied zur
Zarge (`frameMeshesForOpening`, `toWalls3d.ts:1936`, kennt kein
`frameKind`).
- `threshold` steuert `hasSill` im 3D-Rahmen (`toWalls3d.ts:1880`), was einen
einfachen Schwellen-Riegel zeichnet — kein eigenes Schwellenprofil,
keine Gefälle-/Dichtungsdarstellung.
- Es gibt eine ZWEITE, ältere Tür-Repräsentation: `Door`
(`types.ts:1333`, `project.doors: Door[]`) mit eigenem Symbol-Renderer
`addDoorSymbol` (`generatePlan.ts:1840-1899`) — strukturell identisch zum
`Opening`-Pfad, aber ohne jeden Typ-Bezug. Zwei parallele Datenwege für
dieselbe Bauteilart sind selbst technische Schuld, nicht nur ein
BIM-Feature-Gap.
- Form (Rundbogen etc.) existiert nicht: `Opening.width`/`height` sind ein
reines Rechteck, `wallGaps`/`buildWallFootprints`
(`generatePlan.ts:1363-1457`) schneiden nur rechteckige Bänder aus der
Wand-Poché.
- IFC-Export: `IfcDoor` wird erzeugt (`src/export/exportIfc.ts:30-31`), aber
als reine Box-Geometrie ohne `IfcDoorType`/`PredefinedType` und ohne
`IfcOpeningElement`-Void (bewusste Design-Entscheidung, siehe Kommentar
`exportIfc.ts:18-31`: das Loch steckt bereits im geschnittenen Wand-Mesh).
### 2.3 Lücken (Tür)
| Feature | Status | Priorität | Aufwand |
|---|---|---|---|
| Zweiflügelige Tür rendert 2 Blätter (2D+3D) | ✗ (`leafCount` ungelesen) | **P0** | 2 PT |
| Teilverglasung nach `glazingRatio` (2D-Linie + 3D-Split) | ✗ | P1 | 1.5 PT |
| Kassettentür eigene Blattgeometrie (Füllungsfelder) | ✗ | P2 | 2 PT |
| `frameKind` (Blockrahmen) auch im 3D wirksam | ~ | P1 | 1 PT |
| Seitenteile (feste Verglasung links/rechts der Tür) | ✗ | P1 | 2 PT |
| Rundbogen-/Segmentbogen-Türform | ✗ | P2 | 4 PT (braucht gekrümmten Wandausschnitt, s. §5.3) |
| Schwellenprofil (Alu-Flachschwelle, Dichtung) statt Riegel | ✗ | P2 | 1 PT |
| `Door`/`Opening`-Doppelpfad konsolidieren | technische Schuld | P1 | 3 PT (Migration) |
| Beschlag (Drücker/Knauf) als Mesh + 2D-Symbol | ✗ | P2 | 1.5 PT |
| IFC `IfcDoorType`/PredefinedType/OverallWidth-Height-Properties | ~ | P2 | 1 PT |
### 2.4 Umsetzungsvorschlag
**Modell**: `DoorType.leafs?: { width: number; hingeSide: "left"|"right";
fixed?: boolean }[]` analog `WindowType.sashes` (`SashDef`, `types.ts:367`) —
bewusst dieselbe Struktur, damit `resolveSashSpans` (`toWalls3d.ts:2019`) UND
die 2D-Pfostenlinien-Logik direkt wiederverwendet werden können, statt eine
Tür-eigene Variante zu bauen. Ein `fixed: true`-Leaf ist das Seitenteil.
`glazingRatio` wandert vom Skalar zu einer klaren Geometrie: Kämpferhöhe
innerhalb des Blatts, gerendert wie das bestehende Oberlicht
(`transomHeight`), nur INNERHALB des Blattrahmens statt darüber.
**2D**: `addOpeningSymbol` (Tür-Zweig) über die Leaf-Liste iterieren statt
einer festen Blattlinie; pro Leaf ein eigenes `hinge`/`swing` (Default:
alternierend wie bei Fenstern, `sashesOfWindowType`, `types.ts:2132`).
**3D**: `resolveOpeningFrame` liefert `wingCount = leafs.length` statt hart 1;
`frameMeshesForOpening` (bereits generisch über `params.sashes`) übernimmt
die Mehrflügel-Darstellung ohne Änderung — das ist der Vorteil der
Struktur-Wiederverwendung.
**UI**: Im Tür-Editor (sofern nach dem Muster von
`window-editor-vectorworks-study.md` gebaut) eine Flügeltabelle wie bei
Fenstern, nur mit Tür-Vokabular (Gangflügel/Standflügel statt Flügel 1/2).
---
## 3. Fenster (Window)
Für Fenster existiert bereits eine vollständige Referenzmatrix in
`docs/design/window-editor-vectorworks-study.md` §2 (Basiseinstellungen,
Grösse, Brüstung, Rahmen, Flügeltabelle, Laibung/Form/Ober-Unterlicht,
Sonnenschutz/Beschlag, Attribute/IFC) mit eigener P0P3-Phasierung. Diese
Studie ist der massgebliche Bezugspunkt; hier nur die Delta-Punkte, die dort
fehlen oder seither vom IST abweichen.
### 3.1 IST-Ergänzung (was die Studie nicht/knapp behandelt)
- `WindowType.glazing` (einfach/zweifach/dreifach) ist bis heute NICHT
renderwirksam — bestätigt weiterhin: `glazingPanesOf()` (`types.ts:2149`)
wird von `glassPanesForOpening` in `toWalls3d.ts` konsumiert, aber die
Studie selbst vermerkt (Zeile 61-65 dort), dass der 2D-Pfad die
Scheibenzahl weiterhin allein aus `DetailLevel` ableitet, nicht aus
`glazingPanes`. Das ist über ein Jahr nach der Studie noch offen — ein
Hinweis, dass P0/P1-Posten aus Fenster-Studien real liegen bleiben, wenn
niemand sie explizit nachzieht.
- `sillBoard` (Fensterbank keine/innen/aussen/beide, `types.ts:447`) ist
weiterhin ungenutzt (kein Treffer für `sillBoard` in `toWalls3d.ts` oder
`generatePlan.ts` ausserhalb der Typ-Definition und des Editors) —
entspricht dem in der Studie als P1 markierten "Fensterbank erstellen".
### 3.2 Architektonische Grenze: gekrümmte Öffnungen
Sowohl die Fenster-Studie (§2.6, "Form Eckig/Schräg/Spitz/Rund", P2, 4 PT)
als auch dieser Auftrag nennen Rundbogen-/Spitzbogenfenster. Das ist teurer,
als die Aufwandschätzung suggeriert, weil das gesamte Öffnungsmodell auf
GERADEN Bändern basiert: `buildWallFootprints`/`wallGaps`
(`generatePlan.ts:1363-1457`) schneiden ein rechteckiges Intervall `[from,to]`
entlang der Wandachse aus der Poché, `openingAxisBox`
(`toWalls3d.ts:1739`) baut im 3D ebenso einen achsparallelen Quader. Eine
Bogenform braucht entweder (a) eine gekrümmte Zusatzkontur, die die
Rechteck-Aussparung oben kappt (2D: zusätzliche Polygon-Boolean gegen die
Poché; 3D: gekrümmte Deckfläche statt ebenem Sturz) oder (b) ein komplett
neues, polygonbasiertes Öffnungsmodell. Vorschlag: (a) zuerst — ein
`headShape: "eckig"|"segment"|"spitz"|"rund"` mit Zusatzparametern
(Stichhöhe/Radius), das NUR die obere Kante der bestehenden Rechteck-Öffnung
ersetzt, während Pfosten/Sohlbank rechteckig bleiben. Deckt die reale
Mehrheit der Fälle (Haustür mit Rundbogen, Dachflächenfenster-Giebel) ohne
das Kernmodell umzubauen.
### 3.3 Lücken (Fenster, Delta zur Studie)
| Feature | Status | Priorität | Aufwand |
|---|---|---|---|
| `glazing`/`glazingPanes` 2D-wirksam (Scheibenzahl statt nur DetailLevel) | ~ (weiter offen seit Studie) | **P0** | 1 PT |
| `sillBoard` gerendert (2D-Kontur + 3D-Box) | ✗ | P1 | 2 PT |
| Kopfform (Rundbogen/Spitzbogen/Schräge) über Zusatzkontur | ✗ | P2 | 5 PT (s. §3.2) |
| Echtes Sprossengitter (Glasteilung UNABHÄNGIG von Flügelrahmen) | ✗ | P1 | 2 PT |
| Alle übrigen Fenster-Lücken | siehe window-editor-vectorworks-study.md §2/§4 | — | — |
---
## 4. Dach (Roof)
### 4.1 Was ein vollständiges BIM-Tool bietet
| Bereich | Typische Parameter |
|---|---|
| Grundform | Pult, Sattel, Walm, Krüppelwalm, Zeltdach, Mansarde (mit Untertyp Giebel-/Walm-/Zeltmansarde), Flach/Terrassendach, Sheddach, Tonnendach, freie Neigungsflächen je Kante |
| Grundriss | beliebiges Polygon (nicht nur Rechteck), automatische Kehlen/Grate über Straight-Skeleton bei L-/T-/U-Grundrissen |
| Neigung | je Dachfläche einzeln editierbar, unterschiedliche Neigungen je Seite |
| Schichtaufbau | Eindeckung (Ziegel/Blech/Bitumen), Lattung, Konterlattung, Unterdach/-spannbahn, Sparren/Dämmung zwischen Sparren, Dampfbremse, Innenverkleidung — analog Wandaufbau, mit Deckenanschluss |
| Überstand | Traufe und Ortgang UNABHÄNGIG editierbar (Betrag + Ausbildung), Aufschiebling (Neigungsknick am Traufende), Ortganddetail (Windbrett, Blech) |
| Kniestock/Drempel | vertikale Wandaufkantung zwischen Deckenoberkante und Dach-Traufpunkt, definiert First-/Trauf-Geometrie mit |
| Öffnungen im Dach | Dachflächenfenster (schräg, in der Dachebene), Dachgauben (Schlepp-, Sattel-, Walm-, Spitzgaube, Fledermausgaube) als eigenständige Sekundärdächer mit eigenem First |
| First/Grat/Kehl/Ortgang | eigene Linientypen in 2D-Ansicht (Dachaufsicht) UND im Schnitt sichtbar (Sparrenlage, Dämmstärke) |
| Material/Poché | Dachfläche in der Aufsicht mit Eindeckungs-Symbol/-Schraffur (Ziegel-Textur o. Ä.), im Schnitt Vollschichten wie eine geneigte Wand |
| IFC-Rolle | `IfcRoof` (aggregiert `IfcRoofType`), Dachflächen selbst oft als `IfcSlab`-artige Elemente mit `IfcMaterialLayerSetUsage`, Gauben als eigene `IfcRoof`/`IfcBuildingElementProxy`-Unterobjekte |
### 4.2 IST in DOSSIER
`Roof` (`src/model/types.ts:1022-1047`) rechnet ausschliesslich auf der
**Bounding-Box** des Umrisses (Kommentar `types.ts:1016-1020`: "First entlang
einer Hauptachse ... die gängige, intuitive Vereinfachung"). Geometrie kommt
aus `roofGeometry()`/`computeCanonical()` (`src/geometry/roof.ts:80-267`),
explizit **"ohne Straight-Skeleton"** (Kommentar `roof.ts:5`). Unterstützte
`RoofShape` (`types.ts:1013`): flach/pult/sattel/walm/mansarde/zelt — je EINE
feste Berechnung, keine Untertypen. Mansarde hat eine feste, nicht editierbare
Knick-Geometrie (`d1 = halfD * 0.4`, `roof.ts:181` — 40 % der Tiefe von der
Traufe, hart codiert, keine Möglichkeit den Umbruchpunkt zu verschieben).
Konkrete, verifizierte Lücken:
- **Kein Schichtaufbau.** `Roof.thickness` (`types.ts:1044`) ist ein
einzelner Skalar. Er wird an KEINER Stelle im Code gelesen (`grep
"roof.thickness"` über `toWalls3d.ts`/`generatePlan.ts`/`roof.ts` liefert
null Treffer) — das Feld existiert im Typ, ist aber komplett tot. Die
3D-Dachfläche ist eine unendlich dünne, einfarbige Fläche
(`emitRoofs()`, `src/plan/toWalls3d.ts:1670-1684`: nur `plane.pts`/
`gable`-Fans, EINE Farbe `ROOF_RGB` bzw. `roof.color`, keine Dicke, keine
Materialschichten).
- **Kein `RoofType`.** `Project` hat `wallTypes`, `ceilingTypes?`,
`doorTypes?`, `windowTypes?`, `stairTypes?` (`types.ts:1936-1958`), aber
kein `roofTypes?`. Es gibt keinen Bauteil-Bibliothekseintrag für Dächer.
- **2D-Grundriss zeigt keine Poché.** `addRoof()`
(`src/plan/generatePlan.ts:2549-2605`) zeichnet AUSSCHLIESSLICH Linien
(Traufe/First/Grat/Knick, je feste Strichstärke `ROOF_EAVES_MM`/
`ROOF_RIDGE_MM`/`ROOF_HIP_MM`) — keine Fläche, keine Schraffur, keine
Materialkennzeichnung. Deckungsgleich mit Wand/Decke, die BEIDE eine
Poché-Füllung mit Schraffur haben (`addWallPoche`, `addCeilingPoche`),
bleibt das Dach in der Aufsicht ein reines Liniendiagramm.
- **Kein Schnitt.** `src/plan/toSection.ts` hat KEINE Roof-Behandlung (kein
Treffer für `roof`/`Roof` im gesamten Datei-Grep). Ein Vertikalschnitt
durch ein Gebäude mit Satteldach zeigt heute keine Dachlinie, keine
Sparrenlage, keine Firstprojektion — ein Kernstück der BIM-1:1-Erwartung
fehlt vollständig.
- **Kein IFC.** `exportIfc.ts` erzeugt `IfcWall`, `IfcSlab`, `IfcDoor`/
`IfcWindow`, `IfcStair`, `IfcBuildingElementProxy` (Kommentar
`exportIfc.ts:18-36`) — `Roof`/`IfcRoof` ist in der Abbildungsliste NICHT
aufgeführt und wird beim Export komplett übersprungen.
- **Kein Straight-Skeleton, kein L-Grundriss mit Kehle.** Ein L-förmiger
Baukörper mit durchgehendem Satteldach (Kehle an der Innenecke) lässt sich
nicht abbilden — die BBox-Rechnung würde ein Rechteck über die ganze
L-Ausdehnung legen.
- **Keine Gauben, keine Dachfenster.** Kein Treffer für Gaube/Dormer im
gesamten `src`-Baum (verifiziert per Suche).
- **Kein Kniestock als Bauteilbeziehung.** `baseElevation` (`types.ts:1042`)
erlaubt zwar, die Traufhöhe manuell über die Geschoss-Oberkante zu heben
(ein Kommentar in `ObjectInfoPanel.tsx:549` erwähnt das explizit als
Nutzungsmuster), aber es gibt keine Wand-Dach-Kopplung, die einen
Drempel/Kniestock als eigenes, vermasstes Bauteil führt — der Nutzer muss
die Zahl manuell abstimmen.
- **Traufe/Ortgang nicht unabhängig.** `overhang` (`types.ts:1038`) ist EIN
Wert "ringsum" — Traufüberstand und Ortgangüberstand (oft unterschiedlich,
z. B. 0.5 m Traufe / 0.3 m Ortgang) sind nicht trennbar.
- **UI**: `ObjectInfoPanel.tsx:479-561` bietet volle Instanz-Bearbeitung
(shape/ridgeAxis/pitch/pitchUpper/width/depth/overhang/thickness/
baseElevation), aber keine Typ-/Stil-Verwaltung (kein `ResourceManager`-
Eintrag für Dächer, anders als Wand/Decke/Tür/Fenster/Treppe).
### 4.3 Dach-Schichtlogik — konkreter Vorschlag (Kernthema dieses Dokuments)
Der Vorschlag überträgt exakt das `WallType`/`CeilingType`-Muster:
```
export interface RoofType {
id: string;
name: string;
/** Aussen (Eindeckung) → innen (Verkleidung), analog WallType.layers. */
layers: Layer[];
}
```
Kein neuer Layer-Typ nötig — `Layer` (`types.ts:245`) ist bereits
"Bauteil + Dicke + optionaler Fugen-Linienstil", unabhängig davon ob sie
horizontal (Decke), vertikal (Wand) oder GENEIGT (Dach) gestapelt wird, weil
die Stapel-Richtung beim jeweiligen Renderer entschieden wird, nicht im
Datentyp. Typische Schichtfolge eines Steildachs (Eindeckung → Konterlattung
→ Lattung → Unterdach/Unterdeckbahn → Sparren+Dämmung → Dampfbremse →
Innenverkleidung/GKB) bildet sich 1:1 auf `Layer[]` ab, jede Schicht bekommt
ein `Component` mit eigener Schraffur/Farbe/Material wie bei Wand/Decke.
`Roof.thickness: number` wird zu `Roof.roofTypeId?: string` (Verweis, analog
`Ceiling.ceilingTypeId`) mit optionaler `thicknessOverride?: number` — exakt
das Muster aus `Ceiling.thickness?` (`types.ts:952-953`, "Optionale
Übersteuerung der Gesamtdicke ... sonst Typ-Dicke"). `Project.roofTypes?:
RoofType[]` ergänzt die Bibliothek.
**3D-Konsum**: `emitRoofs()` (`toWalls3d.ts:1670`) baut heute EINE
Dreiecksfläche je Dachfläche+Giebel. Mit Layern wird daraus — analog
`emitSlabs()` (`toWalls3d.ts:1352-1419`, das bereits genau diese
Proportional-Stapel-Logik für Decken hat) — ein Stapel PARALLEL versetzter
Flächen entlang der Flächennormalen (nicht entlang Z wie bei der Decke,
sondern entlang der Dachflächen-Normalen `n`, siehe `RoofGeometry.planes`
in `roof.ts:19-21`). Jede Schicht wird zum eigenen `RMesh` mit eigener Farbe/
Schraffur-Metadaten (`RCutMeta`, wie bei `emitSlabs`). Der Versatz macht
zugleich die Dachdicke sichtbar (heute unendlich dünn) — ein Nebengewinn ohne
Mehraufwand.
**2D-Konsum (Grundriss/Aufsicht)**: `addRoof()` bekommt eine Poché-Fläche
analog `addCeilingPoche` — Füllung mit der `viewHatchId` der obersten Schicht
(Eindeckungssymbol), gerahmt von den bestehenden Traufe/First/Grat/Knick-
Linien (die bleiben unverändert, sie sind flächen-unabhängig).
**2D-Konsum (Schnitt, der grössere Umbau)**: `toSection.ts` braucht einen
neuen Roof-Zweig. Ansatz: die Dachebene mit der Schnittebene schneiden
(Ebene-Ebene-Schnitt, da `RoofPlane` eben ist), daraus ein Liniensegment je
betroffener Dachfläche gewinnen, dann `Layer[]` senkrecht ZUR
Dachneigung als Bandsequenz auftragen (wie `resolveWallBands`,
`toWalls3d.ts:521`, aber gedreht um den Neigungswinkel `pitchDeg`) und mit
`splitSlabLayers`-Logik (`toSection.ts:633`) füllen. Das ist der aufwendigste
Einzelposten dieses Dokuments (siehe Prioritätsliste), aber ohne ihn bleibt
"Dach im Schnitt" eine reine Lücke.
**UI**: Ein `RoofType`-Eintrag im `ResourceManager`
(`src/ui/ResourceManager.tsx`), identisch zum bestehenden Ceiling-Typ-Editor
(Schicht-Liste, Dicke, Bauteil-Zuweisung) — kein neues UI-Paradigma.
### 4.4 Dach 1:1 — Formen, Kehlen, Gauben (konkreter Vorschlag)
Reihenfolge nach Aufwand/Nutzen, NICHT alles auf einmal:
1. **Traufe/Ortgang trennen**: `overhang` → `{ eaves: number; gable: number
}`. Kleine, lokale Änderung in `roof.ts` (zwei statt einer Offset-Variable
je nach Kantentyp), grosser optischer Gewinn (das ist die häufigste
Rückmeldung "sieht nicht echt aus" bei Steildächern mit gleich langem
Überstand allseitig).
2. **Mansard-Untertyp** (`mansardVariant: "walm"|"giebel"|"zelt"`, wie in der
älteren Planung `elements.md:298` bereits vorgesehen, aber nie gebaut):
steuert nur, wie die STIRNSEITE der Mansarde behandelt wird (heute IMMER
`gables` = vertikale Giebelfläche, `roof.ts:200-203`) — bei "walm" wird
daraus eine geneigte Fläche wie beim Walmdach. Mittlerer Aufwand, da die
Mansard-Berechnung (`roof.ts:176-206`) bereits alle Eckpunkte hat, nur die
Gable-Erzeugung muss konditional werden.
3. **Editierbarer Mansard-Knickpunkt** (`kinkDepthRatio?: number` statt hart
`0.4`, `roof.ts:181`) — 0.5 PT, reine Parametrisierung einer bestehenden
Konstante.
4. **Krüppelwalm** (`hipTruncation?: number`, 0 = voller Walm, 1 = voller
Giebel/Sattel): der First bleibt voll lang, nur ein kleines Walmstück am
First-Ende — technisch eine Variation der bestehenden `walm`-Berechnung
(`roof.ts:147-174`, der First-Verkürzungs-Faktor `halfD` wird
parametrisiert statt fix).
5. **Dachflächenfenster** (kein neues Bauteil — ein `Opening`-ähnliches
Element, das in eine Dachfläche statt eine Wand einschneidet): neuer
`hostRoofId`-Pfad, eigenständiger Vorschlag; lohnt sich erst NACH der
Schichtlogik, weil das Fenster sonst nicht "in die Dämmebene" passt.
6. **Gauben** (Schlepp-/Sattelgaube als eigenständiges Sekundär-`Roof` mit
eigenem `outline`/`baseElevation`, das in die Hauptdachfläche einschneidet):
grösster Einzelposten, weil er eine echte Boolean-Verschneidung zwischen
zwei Dachkörpern braucht (ähnlich der bestehenden Wand-Boolean-Dominanz
über `joinPriority`, aber räumlich in 3D). Realistisch erst nach einem
Mesh-Boolean-Werkzeug (die begonnene truck-Integration,
`src-tauri/trucksolid/`, ist ein Kandidat dafür).
7. **L-/T-Grundriss mit Kehle (Straight-Skeleton)**: bewusst NICHT vor 6,
weil es die Bounding-Box-Vereinfachung komplett ersetzt (neue
Geometrie-Engine, kein inkrementeller Ausbau von `roof.ts`) — separates,
grosses Vorhaben, siehe Prioritätsliste.
### 4.5 Lücken (Dach)
| Feature | Status | Priorität | Aufwand |
|---|---|---|---|
| `RoofType`/`Layer[]`-Schichtaufbau (Modell) | ✗ (`thickness` toter Skalar) | **P0** | 3 PT |
| 3D-Schichten (gestapelte Flächen entlang Normalen) | ✗ | **P0** | 3 PT |
| 2D-Aufsicht-Poché (Eindeckungssymbol) | ✗ | P1 | 1.5 PT |
| Dach im Vertikalschnitt (Ebene-Ebene-Schnitt + Bänder) | ✗ | **P0** | 5 PT |
| Traufe/Ortgang getrennter Überstand | ✗ | **P0** | 1 PT |
| Mansard-Untertyp (Walm/Giebel/Zelt-Stirn) | ✗ | P1 | 2 PT |
| Editierbarer Mansard-Knick | ✗ | P2 | 0.5 PT |
| Krüppelwalm | ✗ | P2 | 1.5 PT |
| Dachflächenfenster | ✗ | P2 | 4 PT |
| Gauben (Boolean-Einschnitt) | ✗ | P3 | 8+ PT |
| L-/T-Grundriss, Straight-Skeleton-Kehle | ✗ | P3 | 10+ PT |
| `RoofType`-Ressourcen-UI | ✗ | P1 (folgt aus RoofType) | 1 PT |
| IFC `IfcRoof`-Export | ✗ | P2 | 1.5 PT |
---
## 5. Decke (Ceiling / Slab)
### 5.1 Was ein vollständiges BIM-Tool bietet
| Bereich | Typische Parameter |
|---|---|
| Grundfläche | beliebiges Polygon inkl. Aussparungen (Treppenauge, Schacht, Kamindurchbruch) |
| Schichtaufbau | Rohdecke, Trittschalldämmung, Estrich, Bodenbelag — analog Wand, mit Deckenspiegel/Untersicht (abgehängte Decke, Akustikplatten) als eigene Schicht(en) |
| Randausbildung | gerader Rand, auskragender Balkon-/Vordachrand mit thermischer Trennung (Isokorb/Randdämmstreifen), Randschalung/Abschalungsprofil, Attika-Anschluss, Tropfkante |
| Deckenspiegel | abgehängte Untersicht mit eigener Höhe/Raster (Akustik-/Gipskarton-Decke), UNABHÄNGIG von der tragenden Rohdecke |
| Öffnungen | Deckenaussparungen als eigene, editierbare Polygone (nicht nur Gesamtumriss) |
| Neigung | geneigte Decke (Garagenrampe, Terrasse mit Gefälle) — nicht nur horizontal |
| 2D-Darstellung | Aufsicht mit Ansichts-Poché (unaufgeschnitten), Schnitt mit Vollschichten je Lage, Deckenspiegel-Plan (reflected ceiling plan) als eigene Zeichnungsart |
| IFC-Rolle | `IfcSlab` (PredefinedType FLOOR/ROOF/BASESLAB), `IfcMaterialLayerSetUsage` für den Schichtaufbau, `IfcCovering` für abgehängte Decken |
### 5.2 IST in DOSSIER
`Ceiling` (`types.ts:928-1000`) hat bereits die stärkste Tiefe der vier
Bauteile in diesem Dokument: geschlossenes Umriss-Polygon (`outline`),
`ceilingTypeId` mit Legacy-Fallback auf `wallTypeId` (`getCeilingType`,
`types.ts:2089`), volle Attribut-Override-Kette (`foreground`/`background`/
`strokeWeight`/`hatchId` + `*Source`, wie bei `Wall`) und unabhängige
vertikale Bindung von OK/UK über `VerticalAnchor` (`top?`/`bottom?`,
`types.ts:990-999` — "floor"-gebunden oder "custom"-Z, exakt wie bei
`Wall.top`/`Wall.bottom`, `types.ts:887-893`). 3D-Schichtstapel ist
implementiert (`emitSlabs`, `toWalls3d.ts:1352-1419`), Schnitt-Schichtsplit
ebenfalls (`splitSlabLayers`, `toSection.ts:633`).
Verifizierte Lücken:
- **Keine Aussparungen.** `Ceiling.outline: Vec2[]` ist EIN geschlossenes
Polygon (`types.ts:936-939`) — kein `openings?: Vec2[][]` für
Treppenauge/Schacht/Kamin. Ein Treppenloch in der Decke muss heute über
die Aussenkontur der Decke "herumgeschnitten" werden (Decke als
komplexes, nicht-konvexes Polygon), nicht als saubere Innenaussparung.
(`elements.md:217` sah dieses Feld in der älteren Planung explizit vor,
es wurde nie in `types.ts` übernommen.)
- **Kein Randdetail.** Die Decke ist über die gesamte `outline` exakt
`thickness` dick, EINHEITLICH. Es gibt kein Feld für eine abweichende
Randausbildung (Aufkantung, Randdämmstreifen, Tropfkante, andere Dicke am
Balkonrand). Ein auskragender Balkon lässt sich zwar über eine erweiterte
`outline` modellieren, bekommt aber zwangsläufig denselben Vollschicht-
Aufbau wie die Innendecke — eine thermisch getrennte Balkonplatte
(Isokorb) ist nicht abbildbar.
- **Kein Deckenspiegel.** Abgehängte Untersicht (Akustik-/GKB-Decke mit
eigener, tieferer Kote) existiert nicht als eigenes Konzept — nur der
tragende Aufbau über `ceilingTypeId`.
Ein "Deckenspiegel-Plan" (reflected ceiling plan) fehlt als Zeichnungsart
komplett (`DrawingLevelKind`, `types.ts:722`, kennt nur "floor"/"section"/
"elevation"/"drawing").
(Randbemerkung: Beleuchtungsplanung/Deckenspiegel ist ein Elektro-Thema und
damit bewusst ausserhalb des DOSSIER-Kernscopes — siehe "nicht bauen",
Abschnitt 6. Die reine Geometrie einer zweiten, tiefer liegenden Fläche
bleibt aber ein legitimer BIM-1:1-Punkt.)
- **Keine Neigung.** `top`/`bottom` sind je EIN `VerticalAnchor` (ein
Z-Wert), keine Neigungsebene — eine geneigte Garagen-/Terrassendecke ist
nicht modellierbar, nur über mehrere ebene Teildecken behelfsweise
annäherbar.
- **2D-Aufsicht nutzt nur die erste Schicht.** `addCeilingPoche`
(`generatePlan.ts:2453-2536`) liest `wt.layers[0]` (Kommentar/Code
`generatePlan.ts:2465`) für die Ansichts-Schraffur — bei einer
mehrschichtigen Decke (z. B. Beton unten, Dämmung oben) zeigt die Aufsicht
immer nur die OBERSTE (erste) Schicht, was für eine unaufgeschnittene
Draufsicht baupraktisch korrekt ist (man sieht von unten die
Untersicht/erste Lage), aber nicht konfigurierbar ist, welche Lage als
"sichtbare" gilt, falls der Deckenaufbau umgekehrt sortiert wäre.
- IFC: `IfcSlab` wird erzeugt (`exportIfc.ts:28`), aber laut Kopfkommentar
(`exportIfc.ts:38-41`) bewusst OHNE `IfcMaterialLayerSet`/-`Usage` — der
Export verliert den Schichtaufbau, den DOSSIER intern bereits hat.
### 5.3 Deckenrand/Randterminierung — vertieft
Der Nutzer nennt explizit "Deckenränder, Stirnabschlüsse, Anschluss an Wand/
Aussenkante, auskragende Ränder, Randabschalung" als Schwerpunkt. IST-Bild:
keines davon existiert als eigenes Konzept — die Decke ist ein reines
Extrusions-Polygon. Konkreter Vorschlag, dreistufig nach Aufwand:
1. **Aussparungen** (`Ceiling.openings?: Vec2[][]`) — niedrigster Aufwand,
grösster praktischer Nutzen (Treppenauge ist im Wohnbau der Regelfall,
nicht die Ausnahme). 2D: zusätzliche Ausschnitts-Polygone in
`addCeilingPoche` (Loch im Fill, zusätzliche Randlinien). 3D: `emitSlabs`
bekommt Löcher im Extrusions-Profil (analog dem bereits vorhandenen
Loch-Schnitt bei Wand-Öffnungen, `wallMeshCut.ts`, als Vorlage).
2. **Randschicht-Override** (`Ceiling.edgeOverride?: { ringOffset: number;
ceilingTypeId: string }` — ein schmaler Innenring der Decke entlang des
Randes bekommt einen ANDEREN Layer-Aufbau, z. B. mit zusätzlicher
Randdämmschicht oder reduzierter Dicke für eine Tropfkante). Technisch:
`emitSlabs` erzeugt für den Ringbereich einen zweiten Layer-Stapel mit dem
Override-Typ, geometrisch als Offset-Polygon-Differenz (`outline` minus
`outline.offset(-ringOffset)`), eine Operation, die für Wandbänder
bereits ähnlich existiert (`buildWallFootprints`).
3. **Thermisch getrennte Auskragung (Isokorb-Fall)**: ein eigenes,
sekundäres `Ceiling`-Objekt für den auskragenden Teil mit eigenem
`ceilingTypeId` (dünnerer/anderer Aufbau) UND eigener `top`/`bottom`-
Bindung, das an die Hauptdecke stösst — kein neues Feld nötig, nur eine
UI-Erleichterung ("Deckenrand abtrennen"-Werkzeug, das die Decke entlang
einer gewählten Kante in zwei `Ceiling`-Objekte teilt). Niedrigster
Modell-Aufwand, weil er das bestehende Mehrfach-Decken-Prinzip nutzt statt
ein neues Konzept einzuführen.
### 5.4 Lücken (Decke)
| Feature | Status | Priorität | Aufwand |
|---|---|---|---|
| Aussparungen (`openings?: Vec2[][]`) in 2D+3D | ✗ | **P0** | 3 PT |
| Randschicht-Override (Ringzone anderer Aufbau) | ✗ | P1 | 3 PT |
| Deckentrenn-Werkzeug für Isokorb-Fall (UI, kein neues Modellfeld) | ✗ | P1 | 1.5 PT |
| Geneigte Decke (Rampe/Gefälle) | ✗ | P2 | 3 PT |
| Deckenspiegel (zweite, abgehängte Fläche) | ✗ | P3 | 2 PT (reine Geometrie) |
| IFC `IfcMaterialLayerSetUsage` für Decke (UND Wand) | ~ (bewusst ausgelassen) | P2 | 2 PT |
| Konfigurierbare "oberste Schicht" für Aufsicht-Poché | ✓ implizit (Layer-Reihenfolge = Sortierung) | — | — |
---
## 6. Konsolidierte Priorisierung (über alle vier Bauteile)
Sortiert nach "1:1-Zuwachs pro Aufwand", nicht nach Aufwand allein:
1. **Dach-Schichtaufbau (Modell + 3D-Stapel)** — schliesst die grösste
strukturelle Lücke (totes `thickness`-Feld, kein `RoofType`) und liefert
sofort sichtbaren Tiefengewinn im 3D (§4.3, ~6 PT gesamt).
2. **Traufe/Ortgang getrennter Überstand** — 1 PT, sofortiger optischer
Sprung bei jedem Steildach-Projekt (§4.4 Punkt 1).
3. **Zweiflügelige Tür rendert wirklich 2 Blätter** — das `leafCount`-Feld
existiert seit der Türtyp-Einführung, wird aber komplett ignoriert; sehr
sichtbarer Bug-artiger Gap (§2.3, 2 PT).
4. **Deckenaussparungen** — Treppenauge ist der Wohnbau-Regelfall, heute nur
über Umweg (nicht-konvexes Aussenpolygon) lösbar (§5.3 Punkt 1, 3 PT).
5. **Fenster-Glazing 2D-wirksam** — seit über einem Jahr als P0 in der
Fenster-Studie dokumentiert und weiterhin offen; niedriger Aufwand,
sollte nicht liegen bleiben (§3.1, 1 PT).
6. **Dach im Vertikalschnitt** — grösster Einzelposten (5 PT), aber ohne ihn
bleibt jeder Gebäudeschnitt mit Steildach unvollständig; das ist die
Art Lücke, die bei einer Bemusterung/Baueingabe sofort auffällt.
7. **Deckenrand-Override / Isokorb-Trennwerkzeug** — folgt danach, weil er
auf demselben Mehrfach-Decken-Prinzip aufbaut wie Punkt 4.
8. **Mansard-Untertyp + editierbarer Knick** — mittlere Priorität, weil
Mansarde in der Schweiz/Süddeutschland baupraktisch häufig ist und die
heutige starre 40 %-Konstante sichtbar unrealistisch wirkt.
9. **Tür/Fenster-Rahmentiefe** (asymmetrische Rahmenbreiten, Beschlag,
Kopfform) — bewusst NACH den strukturellen Lücken, weil sie additive
Detailverbesserungen an einem bereits funktionierenden Pfad sind, während
17 fehlende oder falsch dargestellte Kernfunktionen betreffen.
10. **Gauben, L-Grundriss/Kehle, Dachflächenfenster** — grösste Einzel-
Aufwände (810+ PT), architektonisch am voraussetzungsreichsten (Boolean-
Werkzeug bzw. neue Geometrie-Engine); erst nach 19 angehen.
### 6.1 NICHT bauen (VW/Revit-Ballast ohne Nutzen für DOSSIER)
- **Vollständiger Massketten-/Bezugsapparat** (VW B1..B5/H1..H7,
Roh-/Fertigmass-Umschaltung) — DOSSIER arbeitet mit lichten Massen, das
genügt für ein Schweizer Wohnbau-/Kleinprojekt-Tool (bereits so in
`window-editor-vectorworks-study.md` §5 entschieden, hier bestätigt für
Dach/Decke: keine Dach-Rohmass-/Fertigmass-Unterscheidung).
- **Sichtbarkeitsmatrix 3D-Objekte × Ansichten** (Augen-Tabelle je
Kategorie×Ansicht×Detailstufe) — DOSSIERs Layer-Sichtbarkeit +
`DetailLevel` deckt den praktischen Bedarf; eine volle Matrix ist
Verwaltungsaufwand ohne Mehrwert für Einzelprojekte.
- **Eckfenster/Eckdach als generischer Sonderfall über zwei Wirtsbauteile**
— seltene Geometrie, hoher Modellierungsaufwand (zwei Hosts, ein Element);
bei Bedarf als manueller Workaround (zwei separate Öffnungen) lösbar.
Ebenso: freie Neigungsflächen je Dachkante (VW erlaubt jede Kante einzeln
zu kippen) — für die abgedeckten Standardformen (Pult/Sattel/Walm/
Mansarde/Zelt/Krüppelwalm) nicht nötig; wer eine Freiform-Dachlandschaft
braucht, ist besser mit den `ExtrudedSolid`/truck-Werkzeugen bedient.
- **Vollständige Beschlags-/Baubeschlag-Bibliothek** (Marken-Beschlagsätze,
Schliessplan) — Beschlag als generisches Griff-Mesh (§2.4) genügt für die
visuelle 1:1-Wirkung; eine Beschlags-PRODUKTBIBLIOTHEK ist Kataloggeschäft,
kein CAD-Kernfeature.
- **Deckenspiegel als vollwertige Beleuchtungsplanung** (Leuchtenraster,
Lichtberechnung) — reine Geometrie einer zweiten Fläche ist ok (P3), die
Elektro-/Lichtplanungslogik selbst liegt ausserhalb des Tool-Zwecks.
- **IFC `IfcOpeningElement`/Void-Semantik nachrüsten** — bewusste
Design-Entscheidung im bestehenden Export (`exportIfc.ts:22-27`), NICHT
revidieren: die heutige "Loch steckt im Mesh"-Lösung liefert visuelle
Parität in jedem Viewer ohne Boolean-Pflicht beim Empfänger; der reine
IFC4-Purismus (Wand als parametrische Extrusion + Void) würde
bestehende, bewusst getroffene Trade-offs zunichtemachen.
- **Straight-Skeleton/Kehlen und Gauben SOFORT** — nicht "nicht bauen", aber
bewusst zurückgestellt (§6, Punkt 10): ohne die Schichtlogik (Punkt 1)
vorher zu bauen, würde jede Kehlen-/Gauben-Lösung auf dem unendlich dünnen,
ungeschichteten Dach aufsetzen und müsste bei Einführung der Schichten
ohnehin neu gefasst werden.
@@ -0,0 +1,449 @@
# Fenster-/Tür-Editor — Studie & Designdokument (Referenz: Vectorworks „Fenster bearbeiten")
Status: Studie/Entwurf (KEIN Code). Ziel: den heute als „mega mager" empfundenen
Fenster-/Tür-Editor zu einem eigenständigen, reichen **Einstellungs-Dialog** mit
Kategorie-Sidebar, Live-Vorschau (2D + 3D) und **„Als Stil speichern"** ausbauen.
Diese Datei ordnet die Vectorworks-Referenz dem bestehenden DOSSIER-Modell zu und
schlägt einen realistischen, phasierten Plan vor.
Konvention: Bezeichner englisch, UI-Text/Kommentare deutsch. Meter als Grundmaß.
---
## 1. Executive Summary
**Was ein guter DOSSIER-Fenster-/Tür-Editor sein sollte.** Ein eigener modaler
Dialog „Fenster-/Tür-Einstellungen" — nicht die heutige, in den Ressourcen-Manager
eingebettete Formularspalte (`WindowStylesTab`/`DoorStylesTab` in
`src/ui/ResourceManager.tsx`), die pro Feld nur eine `FieldRow` zeigt und keinerlei
Vorschau bietet. Der neue Dialog hat drei Zonen (wie Vectorworks):
1. **Kategorie-Sidebar** links (Basis, Größe/Position, Rahmen, Flügel/Sprossen,
Oberlicht/Unterlicht, Laibung/Bank, Sonnenschutz/Rollladen, Attribute/Darstellung,
Detaillierung).
2. **Parameter-Panel** in der Mitte (die Controls der gewählten Kategorie).
3. **Live-Vorschau** rechts: eine 2D-Plan-Vorschau (aus `generatePlan()`) und eine
3D-Ansicht/Elevation (aus `projectToModel3d()`), beide sofort aktualisiert.
**Die eine wichtigste strukturelle Änderung.** Der Editier-Primärort wandert vom
Ressourcen-Tab in einen **dedizierten `OpeningEditorDialog`**, der TYP-Parameter
(wiederverwendbarer Stil = `WindowType`/`DoorType`) und INSTANZ-Parameter (dieses
`Opening`) im selben Fenster editiert und oben eine Stil-Leiste trägt:
`Stil: [Dropdown]` · `Fenster speichern…` (aktuelle Konfiguration als neuen
benannten `WindowType`/`DoorType` ablegen) · `Einstellungen zurücksetzen…`
(auf den Stil zurückfallen). Das ⚙ in `ObjectInfoPanel.OpeningSection`
(`src/panels/ObjectInfoPanel.tsx:731`) öffnet künftig DIESEN Dialog statt den
Ressourcen-Manager-Tab.
**Begriffsklärung.** Der Nutzer-Ausdruck „als **Wandstil** speichern" ist ein
Versprecher — gemeint ist „als **Fensterstil/Türstil** (Bauteilstil) speichern",
also ein neuer Eintrag in `project.windowTypes` bzw. `project.doorTypes`, analog
`WallType`/`CeilingType`/`StairType`. Es entsteht KEIN neuer Wandtyp.
**Warum das der Hebel ist.** Alle geplante Tiefe (mehrflügelig, Sprossenraster,
Rollladen, Bank/Nische, Ober-/Unterlicht, Verglasungsanzahl) braucht (a) mehr
Felder auf `WindowType`/`DoorType` und (b) eine UI, die sie ohne Formularwust
zeigt und deren Wirkung sofort sichtbar macht. Ohne Live-Vorschau bleibt ein
reicher Parametersatz unbenutzbar. Erst der Dialog macht die Tiefe zugänglich; die
Modellfelder allein (Abschnitt 4) reichen nicht.
---
## 2. Vollständige Mapping-Tabelle (VW-Referenz → DOSSIER)
Legende — Status: **✓** vorhanden · **~** teilweise (Feld existiert, Renderer liest
es nicht ODER nur grob) · **✗** fehlt. Ebene: **T** = Typ (Stil, wiederverwendbar,
auf `WindowType`/`DoorType`) · **I** = Instanz (auf `Opening`). Priorität P0
(erste reiche Scheibe) … P3 (VW-Ballast). Aufwand grob in Personentagen (PT).
Wichtiger Ist-Befund aus dem Code (Renderer-Konsum-Lücken):
- `WindowType.glazing` (einfach/zweifach/dreifach) ist editierbar, wird aber von
KEINEM Renderer gelesen. 3D zeichnet stets EINE Scheibe (`glassPanesForOpening`
in `src/plan/toWalls3d.ts:1965` ignoriert `glazing`); 2D leitet die Glaslinien-
Anzahl allein aus `DetailLevel` ab (`addOpeningSymbol` in
`src/plan/generatePlan.ts:2214`, `glassCount = detail==="fein" ? 2 : 1`).
- `WindowType.kind`/`DoorType.kind` (dreh/kipp/drehkipp/fest/schiebe …) schlagen
sich NICHT in 2D/3D-Geometrie nieder.
- `DoorType.leafCount` (1/2), `leafStyle`/`glazingRatio` (außer `leafStyle==="glas"`
→ 3D-Verglasung an) sind ungenutzt.
- `WindowType.sillBoard` ungenutzt; `DoorType.threshold``hasSill` genutzt.
- 3D-Mittelpfosten/Kämpfer und Sprossen werden nur bei `detail==="fein"` emittiert
(`frameMeshesForOpening` in `src/plan/toWalls3d.ts:1902`, ab :1938).
### 2.1 Basiseinstellungen
| VW-Control | Status | DOSSIER-Feld (Vorschlag) | 2D-Wirkung | 3D-Wirkung | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Fensterart („Für normale Wand") | ✗ | `WindowType.wallKind?: "normal"\|"eck"` (später) | — | — | T | P3 | 0.5 |
| Öffnungsart (Dreh/Kipp/…) | ~ | `WindowType.kind` existiert, kein Render | Öffnungssymbol/Öffnungslinien | Öffnungslinien 3D | T | P1 | 2 |
| Einfügepunkt längs (Mitte/…) | ✓ | `Opening.position` + Bezugspunkt in `ObjectInfoPanel` | Position im Plan | Position | I | — | — |
| Einfügepunkt quer (Fensterseite außen/…) | ~ | `insetFromFace`/`insetFace` (T) | Band-Lage | Rahmen-Normalenlage (`resolveFrameNormalRange` :1803) | T | P1 | 0.5 |
| Versatz im Fassadenmodul | ✗ | — (Fassadensystem fehlt in DOSSIER) | — | — | — | P3 | — |
| Laibung (wählen) | ~ | `insetFromFace` deckt Teil ab | Laibungsstriche (fein) | Laibungstiefe | T | P2 | 1 |
| Klasse | ~ | `Opening.categoryCode` (LayerCategory) | Farbe/Strich | — | I | — | — |
| Darstellung höchste Detaillierung | ✓ | `Opening.detailLevel` + Ansichts-DetailLevel | Symbolstufe | Meshstufe | I | — | — |
| Eigenes Symbol verwenden | ✗ | `WindowType.symbolId?` (Drawing2D-Ref) | Symbol-Override | — | T | P3 | 3 |
### 2.2 Fenstergröße & Bemaßung
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Fensterbreite (Wert) | ✓ | `Opening.width` (+ `defaultWidth` T) | ✓ | ✓ | I/T | — | — |
| Fensterhöhe (Wert) | ✓ | `Opening.height` (+ `defaultHeight` T) | ✓ | ✓ | I/T | — | — |
| Bezug B1..B5 / H1..H7 (Roh-/Fertigmaß) | ✗ | `WindowType.dimRef?: {...}` | Bemaßungsschema | — | T | P3 | 3+ |
| „Bemaßung automatisch" (außen/innen) | ✗ | — (DOSSIER hat noch keine parametrische Öffnungs-Bemaßung) | Maßketten | — | T | P3 | 5+ |
Der ganze VW-Maßketten-/Bezugsapparat (B1..B5, H1..H7, Roh-/Fertigmaß-Umschaltung)
ist **P3/„nicht bauen"** — siehe Abschnitt 5. DOSSIER trägt lichte Maße
(`width`/`height`), das genügt.
### 2.3 Höhe Brüstung/Sturz
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Position definieren durch (Brüstungshöhe/…) | ✓ | `Opening.sillHeight` (+ `defaultSillHeight` T) | — | vertikale Lage (`openingVerticalExtent`) | I | — | — |
| Abstand Brüstungshöhe (Wert) | ✓ | `Opening.sillHeight` | — | ✓ | I | — | — |
| „bezieht sich auf" (Wandaußenseite/…) | ✗ | — (immer Wand-UK-relativ) | — | — | — | P3 | — |
| „auf Ebenenbasishöhe" | ~ | Wand-UK ergibt sich aus Geschoss (`baseElevation`) | — | ✓ | — | — | — |
### 2.4 Rahmenwerte
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Rahmenbreiten (4 Kanten, asymmetrisch) | ~ | heute nur `frameWidth` (eine Zahl) → `WindowType.frameWidths?: {left,right,top,bottom}` | Rahmenkontur (`windowSymbol`/`addOpeningFrameBand`) | Rahmen-Boxen (`frameMeshesForOpening`) | T | P2 | 2 |
| Symmetrisch-Toggle | ✗ | `WindowType.frameSymmetric?: boolean` | — | — | T | P2 | 0.5 |
| Flügel mittig / Versatz | ✗ | `WindowType.sashOffset?: number` | Aufschlag-Linie | Flügel-Box-Lage | T | P2 | 1 |
| Rahmenstärke quer zur Wand | ✓ | `frameThickness` / `frameDepth` (→ `resolveAcrossWallDepth` :1780) | — | Rahmentiefe | T | — | — |
| Schnitt-/Außenansicht-Rahmenbreiten | ~ | von `frameWidth` mitgezeichnet | Bandbreite | — | T | P2 | 1 |
### 2.5 Flügeleinteilung (KERN-Tiefe — höchster Wert)
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Flügeltabelle (Nr/Typ/Breite/Anschlag/…) | ~ | `wingCount` (Zahl) → **neu** `WindowType.sashes: SashDef[]` (siehe §4) | Pfostenlinien (`mullionLines`) heute nur gleichmäßig | Pfosten-Boxen nur gleichmäßig | T | **P0** | 4 |
| Typ Flügel \| Pfosten | ✗ | `SashDef.kind: "fluegel"\|"pfosten"` | Pfostenlage frei | Pfosten frei | T | P0 | (inkl.) |
| Aut. Breite / Flügelbreite | ~ | `SashDef.autoWidth`/`width` (heute nur gleichmäßig) | Teilungslage | Teilungslage | T | P1 | 1 |
| Pfostenbreite / „alle gleich" | ~ | `SashDef.postWidth`, `WindowType.uniformPosts` | Pfostendicke | Pfostenbox-Breite | T | P1 | 1 |
| Anschlag (Drehbar links/Drehkipp rechts) | ✗ | `SashDef.opening: OpeningKind`, `SashDef.hingeSide: "left"\|"right"` | Öffnungslinien/Pfeil je Flügel | Öffnungslinien 3D | T | **P0** | 2 |
| Aufschlag (Abstand zu Rahmen) | ✗ | `SashDef.rebate?: number` | Aufschlag-Linie | — | T | P2 | 0.5 |
| Winkel / 3D-Öffnung | ✗ | `SashDef.openAngle?: number` | — | Flügel gekippt/offen | T | P2 | 1.5 |
| Griffart | ✗ | `SashDef.handle?: HandleKind` | — | Griff-Mesh (§2.7) | T | P2 | 1 |
| Kämpfer-Zeilen (horizontale Teilung) | ~ | `mullionRows` existiert | Querlinien | Kämpfer-Boxen (fein) | T | P1 | — |
Dies ist die **wertvollste Lücke**: VW modelliert je Flügel Typ, Breite,
Öffnungsrichtung, Anschlag, Griff. DOSSIER hat nur eine gleichmäßige `wingCount`.
Die `SashDef[]`-Tabelle (§4, Phase P0/P1) ist der zentrale Ausbau.
### 2.6 Laibungsverkleidung · Form · Ober-/Unterlicht · Nische/Bank
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Laibungsverkleidung erstellen | ✗ | `WindowType.reveal?: {create, depth, thickness}` | Laibungsband | Laibungs-Boxen | T | P2 | 1.5 |
| Form (Eckig/Schräg/Spitz/Rund) | ✗ | `WindowType.headShape?: HeadShape` + Eckmaße | Öffnungsumriss-Form | Bogen-/Schräg-Mesh | T | P2 | 4 |
| Oberlicht erstellen + Höhe/Rahmen/Sprossen | ~ | `transomHeight` existiert (feste Scheibe) → `WindowType.transom?: {height, frame, grid}` | Kämpferlinie + Feld | zweite Scheibe (`glassPanesForOpening` :1988) | T | P1 | 2 |
| Unterlicht (unteres festes Feld) | ✗ | `WindowType.underlight?: {height, frame, grid}` | Feld | Scheibe unten | T | P2 | 1.5 |
| Sprossen (horiz./vert. im Feld) | ~ | `mullionRows` grob → `WindowType.muntins?: {rows, cols}` je Feld | Sprossengitter | Sprossen-Boxen (fein) | T | P1 | 2 |
| Nische aussparen (außen/innen) | ✗ | `WindowType.niche?: {aussen, innen, ...}` | Nischenkontur | Nischen-Aussparung | T | P2 | 2 |
| Fensterbank erstellen (außen/innen) | ~ | `sillBoard` existiert, ungenutzt → `WindowType.sill?: {aussen, innen, typ, masse}` | Bankkontur | Bank-Box | T | P1 | 2 |
| Banktyp/Winkel/ΔZ/Endverkröpfung | ✗ | Unterfelder von `sill` | Detail | Detail-Mesh | T | P3 | 2 |
### 2.7 Sonnenschutz · Beschlag · Geländer · Heizkörper · Eckfenster
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Sonnenschutz/Rollladen erstellen + Typ | ✗ | `WindowType.shading?: {create, kind: "raffstore"\|"rollladen"\|…, box, projection}` | Kasten-/Kastenlinien im Grundriss | Kastenbox + Auskragung | T | **P0/P1** | 2.5 |
| Rollladenkasten-Maße | ✗ | `shading.box: {w,h,d}` | Rechteck | Box-Mesh | T | P0 | (inkl.) |
| „Nische erzeugen"/„Kasten symm."/Lamellenwinkel | ✗ | `shading`-Unterfelder | — | Detail | T | P2 | 1 |
| Beschlag (Griff/Knauf) Geometrie/Maße | ✗ | `SashDef.handle` + `WindowType.handleGeom?` | — | Griff-/Knauf-Mesh | T | P2 | 2 |
| Geländer (Position/Höhe/Bauteile) | ✗ | `WindowType.railing?: {...}` | Geländerlinien | Geländer-Mesh | T | P3 | 3 |
| Heizkörper | ✗ | — (eigenes Bauteil, nicht Fenster) | — | — | — | P3 | — |
| Eckfenster | ✗ | eigener Sonderfall (zwei Wände) | — | — | T | P3 | 5+ |
### 2.8 Attribute · Schnitte/Ansichten · Detaillierung · IFC/Energos
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|---|---|---|---|---|---|---|---|
| Attribut-Tabelle je Bestandteil (Klasse/Material/Stift/Linie/…) | ~ | DOSSIER hat By-Layer/By-Object-Attribute (`foreground`/`background`/`hatchId`/`strokeWeight` an Wand/Decke, NICHT an `Opening`) → `Opening.foreground?` etc. + evtl. je Bestandteil | Farbe/Strich der Bestandteile | Material | I/T | P2 | 3 |
| „Klassenattribute zuweisen/entfernen" | ~ | `AttributeSource` („layer"/„object") existiert für Wand/Decke | — | — | I | P2 | 1 |
| Automatische 2D-Darstellungen (Horizontal/Ansicht/Querschnitt) | ~ | `generatePlan` erzeugt Grundriss; Schnitt/Ansicht sind Platzhalter (`DrawingLevelKind`) | — | — | — | P3 | — |
| Sichtbarkeitsmatrix 3D-Objekte × Ansichten (Oben/Unten/…/Querschnitt) | ✗ | **nicht bauen** | — | — | — | P3 | — |
| Detaillierung: Option × Kategorie × Detailstufe (Augen-Tabelle) | ~ | `DetailLevel` (grob/mittel/fein) fließt in 2D + 3D | Sichtbarkeit je Stufe | Meshstufe | I | P2 | 2 |
| „Für 3D die Tiefe/Mittlere/Hohe Detaillierung verwenden" | ✓ | `Model3dOptions.detail` (`toWalls3d.ts:2145`) | — | Meshstufe | — | — | — |
| Beschriftung | ✗ | eigenes Text/Tag-System | Label | — | — | P3 | — |
| Infos/IFC-Daten/Infopalette/Energos | ✗ | **nicht bauen** | — | — | — | P3 | — |
| „Mehrere ändern" | ~ | Multi-Selektion patcht bereits gemeinsame Felder | — | — | I | P2 | 1 |
---
## 3. Der dedizierte Dialog
### 3.1 Komponente & Einbettung
Neue Komponente **`src/ui/OpeningEditorDialog.tsx`** (Muster: die bestehenden
`*Dialog.tsx` in `src/ui/`, z. B. `SettingsDialog.tsx`, `TextEditorDialog.tsx`,
und das Overlay-Muster `res-overlay` + `role="dialog"` aus
`ResourceManager.tsx:371`). Props (über `usePanelHost`/App-State geliefert):
```
open: boolean
openingId: string // die editierte Instanz
kind: "window" | "door" // steuert Sidebar-Sätze + welche Type-Liste
project: Project // read (Typ-Listen, Bauteile, LayerCategories)
onPatchOpening(patch) // Instanz-Felder
onPatchType(typeId, patch) // Typ-Felder (aktueller Stil)
onSaveAsStyle(name, snapshot)// „Fenster/Tür speichern…" → neuer WindowType/DoorType
onResetToStyle() // „zurücksetzen…"
onClose()
```
**Öffnen aus dem ⚙.** `ObjectInfoPanel.OpeningSection`
(`src/panels/ObjectInfoPanel.tsx:731`, `onClick → host.onEditOpeningType(...)`)
ruft künftig einen neuen Host-Handler `host.onOpenOpeningEditor(openingId)` statt
`setResourcesTab(...) + setResourcesOpen(true)` (heutige Verdrahtung in
`src/App.tsx:3130`). App hält `openingEditorId: string | null` als State und
rendert `<OpeningEditorDialog open={openingEditorId!=null} .../>`. Der bisherige
Ressourcen-Tab bleibt als Bibliotheks-/Massenpflege bestehen, ist aber nicht mehr
der Primärort — das ⚙ landet direkt im reichen Dialog.
### 3.2 Kategorie-Sidebar (realistischer Teilmenge)
Reihenfolge und Sichtbarkeit je `kind`:
1. **Basis** — Öffnungsart (`kind`), Einfügepunkt quer (`insetFace`/`insetFromFace`),
Klasse (`categoryCode`), Detaillierung (`detailLevel`).
2. **Größe/Position**`width`, `height`, `sillHeight`, `position` (I),
`defaultWidth/Height/SillHeight` (T).
3. **Rahmen**`frameThickness`, `frameDepth`, `frameWidth`/`frameWidths`,
`frameKind` (Tür), `sashOffset`.
4. **Flügel/Sprossen** — die `SashDef[]`-Tabelle (Flügel/Pfosten einfügen,
Öffnungsrichtung/Anschlag je Flügel), Kämpfer-Zeilen, Sprossengitter.
5. **Oberlicht/Unterlicht**`transom`, `underlight` (je Feld: Höhe, Rahmen, Gitter).
6. **Laibung/Bank**`reveal`, `sill` (außen/innen), `niche`.
7. **Sonnenschutz/Rollladen**`shading` (Typ, Kastenmaße, Auskragung).
8. **Attribute/Darstellung** — Bestandteil-Attribute (Farbe/Strich/Material).
9. **Detaillierung** — welche Bestandteile bei grob/mittel/fein sichtbar sind.
Sidebar-Sätze: bei `kind==="door"` entfallen Oberlicht/Unterlicht-Gitter-Details,
Sonnenschutz und Bank; dafür Türblatt (`leafCount`, `leafStyle`, `threshold`,
`swing`/`hinge`/`openingDir`/`swingAngle`).
### 3.3 Live-Vorschau
**2D (der billige, sofort machbare Teil).** DOSSIER hat den kompletten
Plan→SVG-Pfad bereits: `generatePlan()` (`src/plan/generatePlan.ts:632`) →
`toRenderScene.ts` (RScene) → `sceneToPrintSvg()` (`src/export/sceneToPrintSvg.ts:109`)
bzw. `planToPrintSvg()` (`src/export/planToPrintSvg.ts:155`). Für die Vorschau ein
**Mini-Projekt** bauen (eine kurze Wand + das editierte `Opening` mit den aktuellen
Dialog-Werten), durch `generatePlan()` schicken und als SVG in ein Vorschau-`<div>`
rendern — Grundriss oben, optional eine Elevations-Skizze. Reagiert auf jeden
Patch reaktiv (React-State → neu generieren; günstig, da nur ein Element).
**3D/Elevation.** Zwei Optionen, in aufsteigendem Aufwand:
- **(A) günstig, empfohlen für den ersten Wurf:** eine reine 2D-**Ansicht/Elevation**
aus denselben Plan-Primitiven — VW's „Vorschau, schattiert" ist für uns nicht
nötig; eine saubere Frontalansicht (Rahmen/Flügel/Sprossen/Glas als SVG) zeigt
die Flügeleinteilung am aussagekräftigsten und nutzt exakt die neuen Felder.
- **(B) echte 3D-Vorschau:** `projectToModel3d()` (`src/plan/toWalls3d.ts:2149`)
auf das Mini-Projekt anwenden und im wgpu-Viewport (`Wasm3DViewport.tsx`)
darstellen. Teurer (eigener Render-Kontext im Modal, WASM-Instanz) und laut
Projekt-Memo NICHT per Browser/Puppeteer verifizierbar — der Nutzer testet 3D
selbst in der Tauri-Dev-App. Deshalb 3D-Vorschau erst NACH der 2D-Vorschau, als
eigene Phase.
Empfehlung: erste Scheibe nur **2D-Grundriss + 2D-Elevation**; echte wgpu-Vorschau
später.
### 3.4 „Als Stil speichern"-Flow
Drei Aktionen in der Stil-Leiste:
- **Fenster/Tür speichern… (`onSaveAsStyle`)** — nimmt einen Schnappschuss der
aktuellen (Typ-relevanten) Dialogwerte, fragt einen Namen ab (`PromptDialog.tsx`)
und legt einen NEUEN `WindowType`/`DoorType` in `project.windowTypes`/`doorTypes`
an (CRUD-Factories existieren: `addWindowType`/`addDoorType` in `src/App.tsx`
ab :2273/:2304). Das editierte `Opening.typeId` zeigt danach auf den neuen Stil.
- **Update dieses Stils** — `patchWindowType`/`patchDoorType` (App :2319/:2287) auf
den aktuell referenzierten `typeId` anwenden (wirkt auf alle Instanzen des Stils).
- **Einstellungen zurücksetzen… (`onResetToStyle`)** — die instanzseitigen
Overrides verwerfen und wieder die Typ-Defaults ziehen (Instanz-Felder auf
`undefined` setzen, sodass die Resolver `getWindowType`/`getDoorType`
(`src/model/types.ts:2060`/:2064) greifen).
TYP-vs-INSTANZ-Regel im Dialog sichtbar machen: Typ-Felder tragen eine dezente
„Stil"-Markierung; ein instanzseitig übersteuertes Feld zeigt einen „abweichend
vom Stil"-Indikator + „zurücksetzen". Das ist genau VW's Stil-/Override-Logik.
---
## 4. Modell-Erweiterungsplan (phasiert)
Alle Felder additiv/optional (Alt-Projekte laden unverändert — die bestehende
Konvention der Datei, vgl. `wingCount?`, `sliceTermination` Default). Neue Felder
auf `WindowType`/`DoorType` in `src/model/types.ts`.
### P0 — die erste reiche Scheibe (Kern-Tiefe)
```ts
// Ein Flügel- oder Pfosten-Eintrag der Flügeleinteilung.
type OpeningKind = "dreh" | "kipp" | "drehkipp" | "fest" | "schiebe";
interface SashDef {
kind: "fluegel" | "pfosten";
autoWidth?: boolean; // gleichmäßig aufteilen (Default true)
width?: number; // feste Flügelbreite (m), wenn !autoWidth
postWidth?: number; // Pfostenbreite (m), nur kind="pfosten"
opening?: OpeningKind; // Öffnungsart DIESES Flügels
hingeSide?: "left" | "right"; // Anschlag
}
interface WindowType {
// … bestehend …
sashes?: SashDef[]; // ersetzt/erweitert wingCount; fehlt ⇒ aus wingCount abgeleitet
shading?: { // Rollladen/Sonnenschutz-Kasten (P0-Minimalfassung)
create: boolean;
kind: "rollladen" | "raffstore" | "markise";
box: { width: number; height: number; depth: number };
};
glazingPanes?: 1 | 2 | 3; // Verglasung endlich RENDER-wirksam (heute: glazing unbenutzt)
}
```
Rationale/Render-Freischaltung:
- `sashes` — schaltet echte, ungleichmäßige Flügel/Pfosten + Öffnungsrichtung je
Flügel frei; treibt neue Pfostenlinien in `windowSymbol`/`mullionLines`
(`src/geometry/opening.ts`) und Pfosten-Boxen in `frameMeshesForOpening`
(`src/plan/toWalls3d.ts:1938`). Höchster sichtbarer Mehrwert.
- `shading` — der Rollladenkasten ist ein einzelnes Kästchen: eine 2D-Rechteckkontur
über der Öffnung (neuer Zeichenzweig in `addOpeningSymbol`) + eine Box in
`emitOpeningFrames`/eigener Emitter. Wenig Code, große „Reichhaltigkeit".
- `glazingPanes``glassPanesForOpening` (:1965) liest die Scheibenzahl statt
konstant EINE Scheibe zu zeichnen; 2D-Glaslinien-Anzahl analog. Schließt die
auffälligste Konsum-Lücke.
### P1 — Tiefe der Flügel/Felder
```ts
interface SashDef { /* + */ rebate?: number; openAngle?: number; handle?: "drueck"|"knauf"|"none"; }
interface WindowType {
frameWidths?: { left: number; right: number; top: number; bottom: number };
frameSymmetric?: boolean;
uniformPosts?: boolean;
transom?: { height: number; frame?: number; muntins?: { rows: number; cols: number } };
sill?: { aussen?: SillDef; innen?: SillDef };
muntins?: { rows: number; cols: number }; // Sprossen im Hauptfeld
}
```
Rationale: asymmetrische Rahmenbreiten, richtiges Oberlicht (statt nur feste Scheibe
via `transomHeight`), Fensterbank (heute `sillBoard` ungenutzt), Sprossengitter.
Alles hängt an vorhandenen Zeichen-/Mesh-Funktionen; nur Parameterausbau.
### P2 — Detailbestandteile
```ts
interface WindowType {
reveal?: { create: boolean; depth: number; thickness: number }; // Laibungsverkleidung
underlight?: { height: number; frame?: number }; // Unterlicht
niche?: { aussen?: boolean; innen?: boolean; depth: number }; // Nische
headShape?: "eckig" | "schraeg_links" | "schraeg_rechts" | "spitz" | "rund";
handleGeom?: { kind: "quader" | "rund"; masse: Record<string, number> };
}
interface Opening { foreground?: string; background?: string; strokeWeight?: number; } // Bestandteil-Attribute
```
Rationale: Form (Rundbogen/Schräge → neue Öffnungsumriss-Geometrie), Laibung/Nische,
Beschlag-Geometrie, per-Instanz-Attribute (analog Wand/Decke, die es schon haben).
### P3 — VW-Ballast (nur wenn je nachgefragt)
Bemaßungs-Bezugsschemata (B1..B5/H1..H7), Sichtbarkeitsmatrix 3D×Ansichten,
IFC/Energos, Geländer, Heizkörper, Eckfenster, eigenes Symbol, Fassadenmodul-Versatz.
Siehe Abschnitt 5.
**Höchster-Wert-Reihenfolge (verdichtet):** (1) `sashes` inkl. Öffnungsrichtung je
Flügel, (2) mehrscheibige Verglasung, (3) Rollladenkasten, (4) Bank/Nische,
(5) Sprossengitter, (6) Form (Rund/Spitz/Schräg), (7) Ober-/Unterlicht als eigene
Felder.
---
## 5. Was NICHT bauen / Risiken
**Nicht bauen (VW-Komplexität ohne DOSSIER-Nutzen):**
- **Sichtbarkeitsmatrix „3D-Objekte × 9 Ansichten"** (Oben/Unten/Horizontalschnitt/
Vorne/Hinten/Frontalschnitt/Links/Rechts/Querschnitt). DOSSIER rendert heute
primär Grundriss; Schnitt/Ansicht sind `DrawingLevelKind`-Platzhalter. Eine
Matrix mit ~13×9 An/Aus-Zellen ist Pflegehölle ohne Zielrenderer. `DetailLevel`
(grob/mittel/fein) genügt als Sichtbarkeitsachse.
- **Bemaßungs-Bezugssystem (Roh-/Fertigmaß, B1..B5/H1..H7).** DOSSIER trägt lichte
Maße; eine parametrische Öffnungs-Maßkette existiert nicht. Sehr teuer, geringer
Nutzen für ein CAAD-Werkzeug dieser Reife.
- **IFC-Datenmapping-Tiefe, Energos, Infopalette.** Kein IFC-Export-Pfad vorhanden;
bewusst weglassen (kein Schein-Feature, vgl. die Modell-Doku-Konvention).
- **Heizkörper, Geländer, Eckfenster** als Fenster-Unterobjekte — das sind eigene
Bauteile bzw. topologische Sonderfälle (zwei Wände). Nicht ins Fenster packen.
- **Eigenes Symbol verwenden** (Drawing2D-Override je Stil) — erst wenn ein
Symbol-/Blocksystem existiert.
**Risiken:**
- **3D-Vorschau nicht als „korrekt" verkaufen.** Laut Projekt-Memo werden
`render3d`/`Wasm3DViewport`-Änderungen NICHT per Puppeteer/Browser verifiziert;
der Nutzer testet 3D selbst in der Tauri-Dev-App. Der Dialog darf 3D anzeigen,
aber diese Studie/Implementierung behauptet keine visuelle Korrektheit der
3D-Vorschau — nur der 2D-Pfad (`generatePlan`→SVG) ist headless prüfbar.
- **Typ-vs-Instanz-Verwirrung.** Ohne klaren „vom Stil abweichend"-Indikator wird
unklar, ob eine Änderung den Stil (alle Instanzen) oder nur dieses Fenster trifft.
Muss im UI explizit sein (§3.4).
- **`sashes` vs. `wingCount` Migration.** `wingCount` bleibt als Fallback; `sashes`
hat Vorrang, wenn gesetzt. Resolver in `resolveOpeningFrame`
(`toWalls3d.ts:1845`) und `windowSymbol` müssen beide Wege beherrschen, sonst
brechen Alt-Projekte oder die `ObjectInfoPanel`-Flügelanzahl-Eingabe.
- **Modal-Render-Kosten.** Live-Vorschau bei jedem Tastendruck neu generieren ist
ok für ein Mini-Projekt, aber Patches sollten (leicht) entprellt werden.
---
## 6. Empfohlene erste Scheibe (P0, konkret)
Kleinstes End-to-End-Inkrement, das sich schon „reich" anfühlt:
**Umfang:** Dialog-Shell + Live-2D-Vorschau + Flügeleinteilung (n Flügel mit
Öffnungsrichtung je Flügel) + mehrscheibige Verglasung + Rollladenkasten +
„Als Stil speichern".
**Build-Reihenfolge:**
1. **Modell (P0-Felder).** `SashDef`, `WindowType.sashes?`, `WindowType.shading?`,
`WindowType.glazingPanes?` in `src/model/types.ts` ergänzen (additiv). Resolver
`getWindowType` bleibt; `wingCount``sashes`-Ableitung als Helper.
2. **Renderer-Konsum (2D zuerst, headless prüfbar).**
- `windowSymbol`/`mullionLines` (`src/geometry/opening.ts`) auf `sashes` umstellen
(ungleichmäßige Pfostenlagen + Öffnungsrichtung je Flügel als Öffnungslinien).
- `glassPanesForOpening`/2D-Glaslinien auf `glazingPanes` (statt konstant 1/detail).
- neuer Zeichenzweig in `addOpeningSymbol` (`src/plan/generatePlan.ts:2214`) für
die Rollladenkasten-Kontur; 3D-Box später.
- Unit-Tests gegen `generatePlan()`-Output (Primitive zählen) — das ist der
verifizierbare Beweis, nicht nur „grüne Typecheck".
3. **Dialog-Shell.** `src/ui/OpeningEditorDialog.tsx` (Overlay `res-overlay` +
`role="dialog"`), Sidebar mit den Kategorien Basis / Größe/Position / Rahmen /
Flügel-Sprossen / Sonnenschutz. Nur diese fünf für P0.
4. **Live-2D-Vorschau.** Mini-Projekt (kurze Wand + editiertes `Opening`) →
`generatePlan()``sceneToPrintSvg()`/`planToPrintSvg()``<div>`. Grundriss +
Elevations-SVG. Reaktiv auf Patches (leicht entprellt).
5. **Flügel-Tabelle.** UI-Tabelle mit „Flügel einfügen / Pfosten einfügen / Löschen"
und je Zeile Öffnungsart + Anschlag (`SashDef`). Schreibt `WindowType.sashes`.
6. **„Als Stil speichern".** Stil-Leiste + `onSaveAsStyle``addWindowType`
(`src/App.tsx:2304`) mit `PromptDialog`-Namensabfrage; „zurücksetzen" verwirft
Instanz-Overrides.
7. **⚙-Verdrahtung.** `ObjectInfoPanel.OpeningSection` (:731) → neuer Host-Handler
`onOpenOpeningEditor(openingId)`; App-State `openingEditorId`. Ressourcen-Tab
bleibt als Bibliothek erhalten.
**Definition of Done (P0):** Aus dem ⚙ öffnet sich der Dialog; man setzt 3 Flügel,
davon einer Dreh-links / einer Dreh-rechts / einer fest, wählt Dreifachverglasung
und einen Rollladenkasten; die 2D-Vorschau zeigt die Pfosten/Öffnungslinien/Kasten
sofort; „Speichern…" legt einen benannten Fensterstil an, der im Typ-Dropdown der
`OpeningSection` erscheint. Verifikation über `generatePlan()`-Primitiv-Tests
(2D) — 3D-Box erst danach, vom Nutzer in der Tauri-App geprüft.
+82
View File
@@ -0,0 +1,82 @@
# SIA 400 — Fenster-/Türdarstellung (Grundriss, Schnitt, Ansicht)
> Quelle: SIA 400:2000 „Planbearbeitung im Hochbau", Anhang B.9 (Darstellung von
> Bauteilen), Figuren 3642. Das PDF liegt im Projektwurzel (lokal excluded,
> NICHT committen — urheberrechtlich geschützt). Dies ist die verbindliche
> Darstellungsnorm (Schweiz) — NICHT DIN. Massstab = Detailgrad:
> **1:100 = grob, 1:50 = mittel, 1:20 = fein.**
## B.9.1.1 Fenster im Grundriss (Figuren 36/37/38)
Regel (Zitat): „Die Darstellung der Fensterkonstruktionen erfolgt nach denselben
Regeln, unabhängig davon ob es sich um Holz-, Holz-Metall-, Metall- oder
Kunststoff-Fenster handelt."
### Figur 36 — Massstab 1:100 (= grob)
- Wand **voll schwarz** (Poché massiv), keine Schraffur.
- Fenster = **eine dünne Rahmen-/Glasband-Andeutung** über die Öffnung, sehr
schematisch — im Wesentlichen EIN schmales Rechteck/Band mit wenigen Marken,
KEINE Flügel-/Anschlagdetails, KEINE Laibungsmarken.
### Figur 37 — Massstab 1:50 (= mittel)
- Wand **materialschraffiert** (Bänder: Hinterlüftung/Dämmung senkrecht
gestrichelt, Backstein/Struktur diagonal). Drei Wandaufbauten gezeigt
(Backstein+Aussendämmung hinterlüftet / Holzelementbau / Backstein verputzt).
- Fenster: **Blendrahmen als schmales Band** nahe der Aussenfläche, dünne
Kontur; **Glaslinie**; **ein kleines Quadrat mittig** (Stulp/Flügelstoss).
Laibung/Öffnungstiefe sichtbar. Einfach, aber als Fenster lesbar.
### Figur 38 — Massstab 1:20 (= fein)
- Wand voll schraffiert (Kreuzschraffur Backstein, Diagonalschraffur Dämmung).
- Fenster: **volles Rahmenprofil** — Blendrahmen + Flügelrahmen als mehrere
parallele Linien (Profiltiefe); **Glas als Doppellinie** (Isolierverglasung
IV, zwei enge Parallelen); **zwei Stulp-Quadrate mittig** (Flügelstoss der
zwei Flügel); **Anschlag/Laibung** mit Rahmen-Wand-Detail (Falz/Rebate),
gestrichelte Anschlagmarken an den Ecken; Anschlagwinkel an den Laibungen.
## B.9.1.2 Fenster im Schnitt (Figuren 39/40, M 1:50)
- Rahmen im Schnitt mit Brüstung/Sturz, Bemassung (z. B. Brüstung +0.90,
Sturzhöhe). Fenstertüren (Figur 40): Rahmen bis Boden, Schwelle.
## B.9.1.3 Sinnbilder Fenster (Öffnungsart in der ANSICHT)
Liste der Öffnungsarten (SIA-Benennung):
- Fest im Rahmen verglast (kein Symbol)
- Drehflügel einflüglig mit Verschluss, **Band rechts/links** (Dreieck, Spitze zur BANDSEITE)
- Drehflügel fest mit Band und Plattenverschraubung
- Zweiflüglig mit **Öffnungsreihenfolge** (1 = erstöffnend, 2 = zweitöffnend)
- Kippflügel mit Verschluss (Dreieck)
- Kippflügel fest, für Reinigung bedienbar
- Klappflügel mit Verschluss
- Drehkippflügel, Band rechts
- Schwingflügelfenster
- Wendeflügelfenster, Achse in der Mitte
- Vertikales Schiebefenster (nach oben schiebbar, oberer Flügel fest)
Konvention: Dreieck-/Winkelsinnbild, **Spitze zeigt zur Bandseite (Anschlag)**,
Basis zur Griffseite. (Genaues Sinnbild-Blatt S. 38 rechts.)
## B.9.1.4 Kurzzeichen Fenster/Sonnenschutz
KL Klappladen · SL Schiebeladen · ROL Rolladen · LAM Lamellenstoren ·
RAF Rafflamellenstoren · FAL Faltrolladen · K Kurbel · DV Doppelverglasung ·
IV Isolierverglasung · IV3 Dreifachisolierverglasung · BFB Beton-Fensterbank ·
MFB Metall-Fensterbank · FFB Faserzement-Fensterbank.
## B.9.2 Türen im Grundriss (Figuren 41/42) + Sinnbilder (B.9.2.2)
- **Türblatt als Linie ab Band + Viertelkreisbogen** (Schwingbahn). Bandseite =
Bogenmittelpunkt. Zweiflüglig/Doppeltüre = zwei Bögen.
- Zargenarten: Futterrahmen/Zarge, Blockrahmen/Profil, Blendrahmen — je mit
Anschlag-/Schwellenmark. Bei Niveaudifferenz Schwellenstrich.
- Weitere Sinnbilder: Pendeltüre (Bogen beidseitig gestrichelt), Falttüre
(Zickzack), Faltschiebetor, Drehtüre (Kreis mit X), Kipptor, Schiebetüre
(ausserhalb/in der Wand, Pfeil), Harmonikatüre.
## Umsetzung im Code (Soll)
- `generatePlan.ts` Fenster-Zweig + `geometry/opening.ts::windowSymbol`:
drei DEUTLICH getrennte Detailstufen statt heute (mittel≈fein):
- **grob:** 1 Rahmen-/Glasband (Rechteck), sonst nichts.
- **mittel:** Blendrahmen-Rechteck + Glaslinie + 1 Stulp-Quadrat mittig +
Flügel-Trennlinien; keine verschachtelten Flügelrahmen, kein Anschlag.
- **fein:** verschachtelte Blend-+Flügelrahmen (mehrlinig) + Glas-Doppellinie
(IV) + Stulp-Quadrate + Anschlag/Laibungsmarken.
- Öffnungssymbole (Ansicht) auf SIA umstellen (Spitze zur Bandseite), Türbögen
bleiben (schon SIA-konform).
+1 -1
View File
@@ -15,7 +15,7 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232f6df6'/%3E%3Cg fill='white'%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='9' y='3' width='4' height='4'/%3E%3Crect x='3' y='9' width='4' height='4'/%3E%3Crect x='9' y='9' width='4' height='4'/%3E%3C/g%3E%3C/svg%3E"
/>
<title>cad — Phase 0 Spike</title>
<title>Dossier</title>
</head>
<body>
<div id="root"></div>
+20
View File
@@ -10,6 +10,8 @@
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
@@ -1842,6 +1844,24 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+14 -2
View File
@@ -1,8 +1,10 @@
{
"name": "cad",
"name": "dossier",
"private": true,
"version": "0.0.0",
"type": "module",
"license": "AGPL-3.0-or-later",
"author": "Karim Gabriele Varano",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -19,11 +21,14 @@
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web"
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web",
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
},
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
@@ -48,5 +53,12 @@
"vite": "^5.4.8",
"vitest": "^4.1.9",
"wasm-pack": "^0.15.0"
},
"allowScripts": {
"core-js@3.49.0": true,
"esbuild@0.21.5": true,
"fsevents@2.3.3": true,
"fsevents@2.3.2": true,
"wasm-pack@0.15.0": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

+14 -2
View File
@@ -1,7 +1,7 @@
{
"source": "ambientCG (ambientcg.com)",
"license": "CC0 1.0 Universal (Public Domain)",
"resolution": "1K",
"resolution": "2K",
"materials": [
{
"id": "Concrete048",
@@ -61,6 +61,18 @@
"ao": "/assets/materials/Bricks104/ao.jpg"
}
},
{
"id": "RoofingTiles013A",
"name": "Dachziegel",
"category": "Roofing Tiles",
"maps": {
"color": "/assets/materials/RoofingTiles013A/color.jpg",
"normal": "/assets/materials/RoofingTiles013A/normal.jpg",
"roughness": "/assets/materials/RoofingTiles013A/roughness.jpg",
"displacement": "/assets/materials/RoofingTiles013A/displacement.jpg",
"ao": "/assets/materials/RoofingTiles013A/ao.jpg"
}
},
{
"id": "Tiles141",
"name": "Bodenfliesen",
@@ -144,4 +156,4 @@
}
}
]
}
}
+173 -3
View File
@@ -384,6 +384,7 @@ dependencies = [
name = "cad-tauri"
version = "0.1.0"
dependencies = [
"fs4",
"geometry",
"pollster",
"render2d",
@@ -392,6 +393,8 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"wgpu",
"winit",
]
@@ -982,7 +985,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading 0.7.4",
"libloading 0.8.9",
]
[[package]]
@@ -1304,6 +1307,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.52.0",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1690,7 +1703,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.18",
"windows 0.61.3",
"windows 0.62.2",
]
[[package]]
@@ -2892,6 +2905,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
@@ -3705,6 +3719,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@@ -4538,6 +4576,64 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation 0.3.2",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -5946,6 +6042,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5979,13 +6084,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -6025,6 +6147,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -6037,6 +6165,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -6049,12 +6183,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -6067,6 +6213,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -6079,6 +6231,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -6091,6 +6249,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -6103,6 +6267,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
+7 -1
View File
@@ -8,7 +8,7 @@ members = ["."]
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"]
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
[package]
name = "cad-tauri"
@@ -37,9 +37,15 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
geometry = { path = "geometry" }
# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei
# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch
# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig).
fs4 = { version = "0.9", default-features = false, features = ["sync"] }
# --- M2-Spike (nur mit Feature "native2d") -----------------------------------
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
+9 -3
View File
@@ -1,8 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).",
"windows": ["main"],
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss.",
"windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
"permissions": [
"core:default",
"core:window:allow-minimize",
@@ -11,6 +11,12 @@
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-start-dragging"
"core:window:allow-start-dragging",
"core:window:allow-create",
"core:webview:allow-create-webview-window",
"core:menu:default",
"core:event:default",
"dialog:allow-save",
"fs:allow-write-text-file"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 B

After

Width:  |  Height:  |  Size: 12 KiB

+394
View File
@@ -2470,6 +2470,223 @@ pub fn room_from_point_inside_faces(
best
}
// --- Oeffnungs-Geometrie (Port von src/geometry/opening.ts, Slice 4) --------
// `Wall`/`Opening`/`Project`-Kopplung durch Vec2/number-Parameter ersetzt
// (PORT_PLAN §1 "Teilweise im Scope"): `thickness`/`refOffset` werden hier als
// bereits aufgeloeste Zahlen uebergeben (TS: `wallTypeThickness(getWallType(...))`
// bzw. `wallReferenceOffset(wall, total)` gegen ein `Project`). Nur
// `openingVerticalExtent` bleibt TS (haengt an `Project.drawingLevels` via
// `wallVerticalExtent`) und wird hier NICHT portiert.
//
// `DEG` (Grad→Radiant, Port des lokalen `DEG` in opening.ts) ist bereits als
// modul-weite Konstante aus dem stair-Slice vorhanden (identischer Wert).
/// Punkt entlang der Strecke p1→p2 im Abstand d vom Start (Port von `along`
/// aus src/model/geometry.ts).
pub fn along(p1: Vec2, p2: Vec2, d: f64) -> Vec2 {
add(p1, scale(normalize(sub(p2, p1)), d))
}
/// Achslaenge einer Wand (Port von `wallAxisLength`; Wand → start/end).
pub fn wall_axis_length(start: Vec2, end: Vec2) -> f64 {
(end.x - start.x).hypot(end.y - start.y)
}
/// Oeffnungs-Intervall entlang der Achse (Port von `openingInterval`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Interval {
pub from: f64,
pub to: f64,
}
pub fn opening_interval(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Interval> {
let axis = wall_axis_length(start, end);
let from = position.min(axis).max(0.0);
let to = (position + width).min(axis).max(from);
if to - from < 1e-4 {
return None; // entartet: Breite <= 0 oder vollstaendig ausserhalb der Achse
}
Some(Interval { from, to })
}
/// Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse (Port von
/// `wallAxisFrame`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub struct AxisFrame {
pub u: Vec2,
pub n: Vec2,
}
pub fn wall_axis_frame(start: Vec2, end: Vec2) -> AxisFrame {
let u = normalize(sub(end, start));
AxisFrame { u, n: left_normal(u) }
}
/// Die beiden Pfosten-Punkte einer Oeffnung im Grundriss (Port von `openingJambs`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Jambs {
pub jamb_start: Vec2,
pub jamb_end: Vec2,
}
pub fn opening_jambs(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Jambs> {
let iv = opening_interval(start, end, position, width)?;
Some(Jambs {
jamb_start: along(start, end, iv.from),
jamb_end: along(start, end, iv.to),
})
}
/// Luecken-Rechteck-Ecken ueber die volle Wanddicke (Port von `openingGapQuad`).
/// Reihenfolge: jambStart(+n/2) → jambEnd(+n/2) → jambEnd(n/2) → jambStart(n/2).
pub fn opening_gap_quad(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
) -> Option<Vec<Vec2>> {
let jambs = opening_jambs(start, end, position, width)?;
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
Some(vec![
add(jambs.jamb_start, scale(n, outer)),
add(jambs.jamb_end, scale(n, outer)),
add(jambs.jamb_end, scale(n, inner)),
add(jambs.jamb_start, scale(n, inner)),
])
}
/// Mittelpunkt einer Oeffnung im Grundriss (Port von `openingCenter`).
pub fn opening_center(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Vec2> {
let iv = opening_interval(start, end, position, width)?;
Some(along(start, end, (iv.from + iv.to) / 2.0))
}
/// Aufgeloeste Tuer-Symbol-Geometrie im Grundriss (Port von `DoorSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DoorSymbol {
pub hinge: Vec2,
pub open_end: Vec2,
pub closed_end: Vec2,
pub radius: f64,
pub jamb_start: Vec2,
pub jamb_end: Vec2,
pub normal: Vec2,
}
/// Port von `doorSymbol`. `swing`/`opening_dir`/`hinge_side` folgen der TS-
/// `??`-Fallback-Semantik (`None` → TS-Default); `swing_angle` `None` → 90°.
pub fn door_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
swing: Option<&str>,
opening_dir: Option<&str>,
hinge_side: Option<&str>,
swing_angle: Option<f64>,
) -> Option<DoorSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let frame = wall_axis_frame(start, end);
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
// TS berechnet die Breite NEU aus den geklemmten Pfosten (nicht das rohe
// `o.width`) — bewusst repliziert.
let w = (jamb_end.x - jamb_start.x).hypot(jamb_end.y - jamb_start.y);
let swing_sign = if swing.unwrap_or("left") == "left" { 1.0 } else { -1.0 };
let dir_sign = if opening_dir.unwrap_or("in") == "in" { 1.0 } else { -1.0 };
let swing_dir = scale(frame.n, swing_sign * dir_sign);
let hinge_at_start = hinge_side.unwrap_or("start") == "start";
let hinge = if hinge_at_start { jamb_start } else { jamb_end };
let closed_dir = if hinge_at_start { frame.u } else { scale(frame.u, -1.0) };
let angle = swing_angle.unwrap_or(90.0) * DEG;
let open_dir = Vec2 {
x: closed_dir.x * angle.cos() + swing_dir.x * angle.sin(),
y: closed_dir.y * angle.cos() + swing_dir.y * angle.sin(),
};
Some(DoorSymbol {
hinge,
open_end: add(hinge, scale(open_dir, w)),
closed_end: add(hinge, scale(closed_dir, w)),
radius: w,
jamb_start,
jamb_end,
normal: frame.n,
})
}
/// Aufgeloeste Fenster-Linien im Grundriss (Port von `WindowSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct WindowSymbol {
pub frame: Vec<Vec2>,
pub glass_lines: Vec<(Vec2, Vec2)>,
pub mullion_lines: Vec<(Vec2, Vec2)>,
}
/// Port von `windowSymbol`. `thickness`/`ref_offset` sind bereits aufgeloeste
/// Werte (siehe Modul-Kommentar); `wing_count` `None` → TS-Default `1`.
pub fn window_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
wing_count: Option<f64>,
) -> Option<WindowSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
let frame_quad = vec![
add(jamb_start, scale(n, outer)),
add(jamb_end, scale(n, outer)),
add(jamb_end, scale(n, inner)),
add(jamb_start, scale(n, inner)),
];
let mut glass_lines: Vec<(Vec2, Vec2)> = Vec::new();
let count = glass_count.max(1.0);
let mut i: i64 = 0;
while (i as f64) < count {
let spread = thickness / 8.0;
let off = if count == 1.0 {
ref_offset
} else {
ref_offset + ((i as f64) - (count - 1.0) / 2.0) * spread * 2.0
};
glass_lines.push((add(jamb_start, scale(n, off)), add(jamb_end, scale(n, off))));
i += 1;
}
let mut mullion_lines: Vec<(Vec2, Vec2)> = Vec::new();
let wings = wing_count.unwrap_or(1.0).round().min(4.0).max(1.0);
if wings > 1.0 {
let post_depth = thickness * 0.6;
let p_outer = ref_offset + post_depth / 2.0;
let p_inner = ref_offset - post_depth / 2.0;
let wings_n = wings as i64;
for k in 1..wings_n {
let t = k as f64 / wings;
let on_axis = add(jamb_start, scale(sub(jamb_end, jamb_start), t));
mullion_lines.push((add(on_axis, scale(n, p_outer)), add(on_axis, scale(n, p_inner))));
}
}
Some(WindowSymbol { frame: frame_quad, glass_lines, mullion_lines })
}
// --- Batch-Fassaden: roomArea / ceiling (Slice 1) ----------------------------
#[cfg(feature = "web")]
@@ -2741,6 +2958,183 @@ pub fn rb_point_in_polygon_batch_json(input_json: &str) -> Result<String, wasm_b
to_js(&out)
}
// --- Batch-Fassaden: opening (Slice 4) ---------------------------------------
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WallAxisLengthQuery {
start: Vec2,
end: Vec2,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_length_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<f64> = qs.iter().map(|q| wall_axis_length(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_interval_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Interval>> = qs
.iter()
.map(|q| opening_interval(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_frame_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<AxisFrame> = qs.iter().map(|q| wall_axis_frame(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_jambs_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Jambs>> = qs
.iter()
.map(|q| opening_jambs(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningGapQuadQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_gap_quad_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningGapQuadQuery> = from_js(input_json)?;
let out: Vec<Option<Vec<Vec2>>> = qs
.iter()
.map(|q| opening_gap_quad(q.start, q.end, q.position, q.width, q.thickness, q.ref_offset))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_center_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Vec2>> = qs
.iter()
.map(|q| opening_center(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DoorSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
#[serde(default)]
swing: Option<String>,
#[serde(default)]
opening_dir: Option<String>,
#[serde(default)]
hinge: Option<String>,
#[serde(default)]
swing_angle: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn door_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<DoorSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<DoorSymbol>> = qs
.iter()
.map(|q| {
door_symbol(
q.start,
q.end,
q.position,
q.width,
q.swing.as_deref(),
q.opening_dir.as_deref(),
q.hinge.as_deref(),
q.swing_angle,
)
})
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WindowSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
#[serde(default)]
wing_count: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn window_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WindowSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<WindowSymbol>> = qs
.iter()
.map(|q| {
window_symbol(
q.start,
q.end,
q.position,
q.width,
q.thickness,
q.ref_offset,
q.glass_count,
q.wing_count,
)
})
.collect();
to_js(&out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -33,6 +33,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
}],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
WallInput {
start: [0.0, 3.0],
@@ -44,6 +47,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
@@ -60,6 +66,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
];
let slabs = vec![SlabInput {
@@ -67,6 +76,8 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
z_bottom: -0.2,
z_top: 0.0,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}];
(walls, slabs)
}
+3
View File
@@ -40,6 +40,9 @@ fn demo_walls() -> Vec<WallInput> {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
// Raum 6 x 4 m.
vec![
+319 -25
View File
@@ -91,11 +91,21 @@ fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
///
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
/// Leerer Vektor bei leerem/degeneriertem Mesh.
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
/// Eine Kante samt ihrer deduplizierten Repraesentanten-Normalen (Rueckseiten
/// bereits gepaart, s. `representatives`). Grundlage BEIDER Kanten-Stile: die
/// statischen Feature-Edges (`build_mesh_edges`) UND die blickabhaengigen
/// Silhouetten-Konturen (`build_silhouette_edges`).
pub struct EdgeAdj {
pub p0: [f32; 3],
pub p1: [f32; 3],
/// Flaechennormalen der (max. 2) angrenzenden Original-Dreiecke.
pub reps: Vec<[f32; 3]>,
}
/// Baut die Kanten-Adjazenz aus dem Mesh: je eindeutiger Kante die Endpunkte +
/// die deduplizierten Repraesentanten-Normalen. GPU-frei; einmalig bei `set_model`
/// berechnet und gecacht (die Silhouette wird daraus je Blickwinkel neu abgeleitet).
pub fn build_edge_adjacency(mesh: &Mesh) -> Vec<EdgeAdj> {
if mesh.indices.len() < 3 {
return Vec::new();
}
@@ -137,20 +147,83 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
}
}
let mut verts: Vec<f32> = Vec::new();
let mut push = |p: [f32; 3]| {
verts.push(p[0]);
verts.push(p[1]);
verts.push(p[2]);
verts.push(EDGE_COLOR[0]);
verts.push(EDGE_COLOR[1]);
verts.push(EDGE_COLOR[2]);
};
edges
.into_values()
.map(|rec| EdgeAdj {
p0: rec.p0,
p1: rec.p1,
reps: representatives(&rec.normals),
})
.collect()
}
for rec in edges.values() {
if should_draw(&rec.normals) {
push(rec.p0);
push(rec.p1);
fn push_edge(verts: &mut Vec<f32>, p0: [f32; 3], p1: [f32; 3]) {
for p in [p0, p1] {
verts.extend_from_slice(&[p[0], p[1], p[2], EDGE_COLOR[0], EDGE_COLOR[1], EDGE_COLOR[2]]);
}
}
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
///
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
/// Leerer Vektor bei leerem/degeneriertem Mesh.
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
let adj = build_edge_adjacency(mesh);
let mut verts: Vec<f32> = Vec::new();
for a in &adj {
if draws_crease(&a.reps) {
push_edge(&mut verts, a.p0, a.p1);
}
}
verts
}
/// Blickabhaengige Silhouetten-Konturen aus der gecachten Kanten-Adjazenz: gezeichnet
/// werden Randkanten (nur ein Dreieck) UND echte Silhouetten-Kanten — solche, deren
/// zwei angrenzende Flaechen aus Sicht der Kamera GEGENSAETZLICH orientiert sind
/// (eine vorder-, eine rueckseitig). Ergebnis ist der reine Umriss (Gipsmodell-/
/// Konturen-Look) ohne die inneren Knickkanten (Dachgrate). `eye` = Kameraposition
/// (world), `forward` = Blickrichtung (world, von der Kamera ins Bild); `perspective`
/// waehlt die pro-Kante-Blickrichtung (Perspektive) vs. die konstante (ortho).
pub fn build_silhouette_edges(
adj: &[EdgeAdj],
eye: [f32; 3],
forward: [f32; 3],
perspective: bool,
) -> Vec<f32> {
let mut verts: Vec<f32> = Vec::new();
for a in adj {
let draw = match a.reps.len() {
0 => false,
1 => true, // offene Randkante -> immer Teil der Kontur
_ => {
let mid = [
(a.p0[0] + a.p1[0]) * 0.5,
(a.p0[1] + a.p1[1]) * 0.5,
(a.p0[2] + a.p1[2]) * 0.5,
];
// Blickvektor von der Flaeche ZUR Kamera. Perspektive: eye - mid;
// ortho: entgegen der Blickrichtung (konstante Kamera).
let view = if perspective {
sub(eye, mid)
} else {
[-forward[0], -forward[1], -forward[2]]
};
let mut front = false;
let mut back = false;
for n in &a.reps {
let d = dot(*n, view);
if d > 0.0 {
front = true;
} else if d < 0.0 {
back = true;
}
}
front && back // Silhouette: eine Flaeche vorder-, eine rueckseitig
}
};
if draw {
push_edge(&mut verts, a.p0, a.p1);
}
}
verts
@@ -159,15 +232,54 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
/// Entscheidet, ob eine Kante gezeichnet wird: Randkante (nur ein Dreieck) oder
/// Knickkante (zwei angrenzende Dreiecke stehen ueber dem Crease-Winkel zuein-
/// ander). Koplanare geteilte Kanten (Flaechendiagonalen) werden unterdrueckt.
fn should_draw(normals: &[[f32; 3]]) -> bool {
match normals.len() {
///
/// DOPPELSEITIGE MESHES (Kontext/Dach/Fenster-Glas, s. `mesh.rs::push_ctx_tri` —
/// jedes Dreieck wird dort MIT gespiegelter Rueckseite [-Normale] dupliziert,
/// weil die Mesh-Pipeline Backface-Culling aktiv hat): jedes Original-Dreieck
/// liefert dadurch IMMER ein exaktes +n/-n-Paar an jeder seiner Kanten. Ohne
/// Beruecksichtigung wuerde das faelschlich als extremer Knick (dot ≈ -1)
/// gewertet und JEDE Flaechen-Innendiagonale eines doppelseitigen Meshes
/// gezeichnet (Nutzer-Report: Dreiecks-Diagonalen sichtbar auf Dach/Glas).
/// Fix: zuerst Rueckseiten-Partner (dot ≈ -1 zueinander) einander zuordnen und
/// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe
/// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon,
/// ob doppelseitig gerendert wurde oder nicht.
/// Dedupliziert die angrenzenden Normalen zu Repraesentanten: paart die von der
/// doppelseitigen Emission stammenden Rueckseiten (dot ≈ -1) und behaelt je
/// Original-Dreieck EINEN Vertreter (s. Moduldoc `should_draw`-Historie).
fn representatives(normals: &[[f32; 3]]) -> Vec<[f32; 3]> {
let mut reps: Vec<[f32; 3]> = Vec::new();
let mut used = vec![false; normals.len()];
for i in 0..normals.len() {
if used[i] {
continue;
}
used[i] = true;
reps.push(normals[i]);
for j in (i + 1)..normals.len() {
if used[j] {
continue;
}
if dot(normals[i], normals[j]) < -CREASE_COS {
used[j] = true; // Rueckseiten-Duplikat desselben Dreiecks
break;
}
}
}
reps
}
/// Entscheidet fuer die STATISCHEN Feature-Edges, ob eine Kante gezeichnet wird:
/// Randkante (ein Repraesentant) oder Knickkante (zwei stehen ueber dem Crease-
/// Winkel zueinander). Koplanare geteilte Kanten (Flaechendiagonalen) fallen weg.
fn draws_crease(reps: &[[f32; 3]]) -> bool {
match reps.len() {
0 => false,
1 => true, // Silhouette/Rand: gehoert nur einem Dreieck
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
_ => {
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt.
for i in 0..normals.len() {
for j in (i + 1)..normals.len() {
if dot(normals[i], normals[j]) < CREASE_COS {
for i in 0..reps.len() {
for j in (i + 1)..reps.len() {
if dot(reps[i], reps[j]) < CREASE_COS {
return true;
}
}
@@ -247,4 +359,186 @@ mod tests {
let m = Mesh::default();
assert!(build_mesh_edges(&m).is_empty());
}
/// Ein geschlossener Wuerfel: 8 Ecken, 12 Dreiecke (6 Flaechen, aussen).
fn unit_cube() -> Mesh {
let c = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
[0.0, 1.0, 1.0],
];
#[rustfmt::skip]
let idx: Vec<u32> = vec![
0,3,2, 0,2,1, // -Z
4,5,6, 4,6,7, // +Z
0,1,5, 0,5,4, // -Y
3,7,6, 3,6,2, // +Y
0,4,7, 0,7,3, // -X
1,2,6, 1,6,5, // +X
];
mesh(&c, &idx)
}
#[test]
fn cube_feature_edges_are_twelve() {
// Statische Feature-Edges: die 12 Wuerfelkanten (Flaechendiagonalen weg).
let e = build_mesh_edges(&unit_cube());
assert_eq!(segment_count(&e), 12);
}
#[test]
fn cube_silhouette_from_generic_view_is_hexagon() {
// Aus einer generischen Ecken-Sicht ist die Silhouette eines Wuerfels ein
// Sechseck = 6 Kanten (nur der Umriss, KEINE inneren Knickkanten).
let adj = build_edge_adjacency(&unit_cube());
let eye = [5.0, 6.0, 7.0];
let fwd = normalize(sub([0.5, 0.5, 0.5], eye)); // Blick auf die Mitte
let sil = build_silhouette_edges(&adj, eye, fwd, true);
assert_eq!(segment_count(&sil), 6);
}
#[test]
fn cube_silhouette_ortho_is_hexagon_too() {
// Auch orthografisch (konstante Blickrichtung) ergibt sich das Sechseck.
let adj = build_edge_adjacency(&unit_cube());
let fwd = normalize([-1.0, -1.2, -1.4]);
let sil = build_silhouette_edges(&adj, [0.0, 0.0, 0.0], fwd, false);
assert_eq!(segment_count(&sil), 6);
}
}
#[cfg(test)]
mod double_sided_tests {
// Deckt den Nutzer-Report ab (Dreiecks-Diagonalen sichtbar auf Dach/Glas):
// Kontext-Meshes werden von `mesh.rs::push_ctx_tri` IMMER doppelseitig
// aufgebaut (jedes Dreieck + gespiegelte Rueckseite [-Normale], weil die
// Mesh-Pipeline Backface-Culling aktiv hat). Diese Tests bauen genau dieses
// Muster nach (nicht die einfachen einseitigen Test-Meshes oben).
use super::*;
fn mesh_single_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh {
let mut verts = Vec::new();
for p in positions {
verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]);
}
Mesh { verts, indices: indices.to_vec() }
}
/// Baut ein Mesh wie `mesh_single_sided`, aber verdoppelt JEDES Dreieck mit
/// umgekehrter Wicklung (a,c,b statt a,b,c) — exakt das Muster von
/// `mesh.rs::push_ctx_tri` (Vorder- + Rueckseite fuer doppelseitiges Rendering).
fn mesh_double_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh {
let mut verts = Vec::new();
for p in positions {
verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]);
}
let mut doubled = Vec::with_capacity(indices.len() * 2);
for tri in indices.chunks_exact(3) {
doubled.extend_from_slice(&[tri[0], tri[1], tri[2]]);
doubled.extend_from_slice(&[tri[0], tri[2], tri[1]]); // gespiegelte Rueckseite
}
Mesh { verts, indices: doubled }
}
fn segment_count(v: &[f32]) -> usize {
v.len() / EDGE_FLOATS_PER_VERTEX / 2
}
#[test]
fn doubled_coplanar_quad_still_drops_shared_diagonal() {
// Dasselbe ebene Quad wie coplanar_quad_drops_shared_diagonal, aber
// doppelseitig aufgebaut — VOR dem Fix haette dies faelschlich 6 statt
// 4 Segmente geliefert (Diagonale faelschlich als Knick erkannt).
let m = mesh_double_sided(
&[
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
],
&[0, 1, 2, 0, 2, 3],
);
let e = build_mesh_edges(&m);
assert_eq!(segment_count(&e), 4);
}
#[test]
fn doubled_single_triangle_still_has_three_boundary_edges() {
// Silhouette-Kanten eines EINZELNEN Dreiecks muessen trotz Verdopplung
// (Vorder-/Rueckseite) weiterhin als Rand erkannt und gezeichnet werden.
let m = mesh_double_sided(
&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
&[0, 1, 2],
);
let e = build_mesh_edges(&m);
assert_eq!(segment_count(&e), 3);
}
#[test]
fn doubled_folded_quad_still_keeps_crease() {
// Echter 90°-Knick bleibt auch doppelseitig als Kante erhalten (keine
// Ueberkompensation, die auch echte Knicke unterdruecken wuerde).
let m = mesh_double_sided(
&[
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 0.0],
],
&[0, 1, 2, 0, 3, 1],
);
let e = build_mesh_edges(&m);
assert_eq!(segment_count(&e), 5);
}
#[test]
fn real_opening_box_topology_suppresses_face_diagonals_doubled() {
// Exakte Ecken-Reihenfolge/Indizes aus toWalls3d.ts::OPENING_BOX_TRIS,
// diesmal ECHT doppelseitig wie im tatsaechlichen Render-Pfad (die
// Fenster-Glas-/Rahmen-Boxen laufen ueber append_context_mesh).
let from = 0.0f32;
let to = 1.2f32;
let z_bottom = 0.9f32;
let z_top = 2.1f32;
let n_min = -0.03f32;
let n_max = 0.03f32;
let mut positions = Vec::new();
for i in 0..8u32 {
let s = if i & 1 != 0 { to } else { from };
let z = if i & 2 != 0 { z_top } else { z_bottom };
let t = if i & 4 != 0 { n_max } else { n_min };
positions.push([s, -t, z]);
}
let indices: Vec<u32> = vec![
0, 1, 3, 0, 3, 2,
4, 5, 7, 4, 7, 6,
0, 1, 5, 0, 5, 4,
2, 3, 7, 2, 7, 6,
0, 2, 6, 0, 6, 4,
1, 3, 7, 1, 7, 5,
];
let m = mesh_double_sided(&positions, &indices);
let e = build_mesh_edges(&m);
// Ein Quader hat 12 echte Kanten (Silhouette); bei korrekter Diagonalen-
// Unterdrueckung sollten es GENAU 12 sein (nicht 12+6 Diagonalen=18).
assert_eq!(segment_count(&e), 12, "erwartet 12 Kanten (Quader-Silhouette), keine Flaechendiagonalen");
}
#[test]
fn single_sided_helper_matches_module_level_mesh_helper() {
// Absicherung: der lokale mesh_single_sided-Helfer verhaelt sich
// identisch zum bestehenden `tests::mesh` (keine versehentliche
// Abweichung beim Kopieren).
let m = mesh_single_sided(
&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
&[0, 1, 2],
);
let e = build_mesh_edges(&m);
assert_eq!(segment_count(&e), 3);
}
}
File diff suppressed because it is too large Load Diff
+224 -3
View File
@@ -20,6 +20,7 @@ pub mod math;
pub mod mesh;
pub(crate) mod openings;
pub mod section;
pub mod section_boolean;
pub mod section_fill;
pub mod shaders;
pub mod types;
@@ -54,10 +55,13 @@ pub use types::{
#[cfg(test)]
mod tests {
use super::mesh::{
build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
build_free_wall_end_caps, build_scene_mesh_textured, build_walls_mesh,
build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
};
use super::section_fill::CAP_FLOATS_PER_VERTEX;
use super::types::{
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
Hatch, Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX,
TEXTURED_FLOATS_PER_VERTEX,
};
/// Bequemer Bau einer achsparallelen Wand entlang +X.
@@ -72,6 +76,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}
}
@@ -157,6 +164,65 @@ mod tests {
assert!((max_idx as usize) < mesh.vertex_count());
}
/// `wall_x` mit gesetzter Bauteil-Schraffur (Voraussetzung fuer die Kappen).
fn wall_x_hatched(len: f32, thickness: f32, height: f32) -> WallInput {
WallInput {
hatch: Some(Hatch { pattern: 3, angle: 45.0, scale: 1.0, line_weight: 0.13 }),
..wall_x(len, thickness, height)
}
}
#[test]
fn isolierte_wand_ohne_hatch_bekommt_keine_kappen() {
// Bestehende Test-Fixtures (kein `hatch`) bleiben unangetastet: keine
// Kappen-Geometrie, unabhaengig davon, dass beide Enden frei sind.
let caps = build_free_wall_end_caps(&[wall_x(3.0, 0.2, 2.5)]);
assert!(caps.is_empty());
}
#[test]
fn isolierte_wand_mit_hatch_bekommt_kappen_an_beiden_freien_enden() {
// Eine einzelne Wand hat KEINE Nachbarn -> beide Achsenenden sind frei ->
// je Ende eine Schraffur-Kappe (1 Schicht x 2 Dreiecke x 3 Vertices = 6
// Vertices je Ende, 12 insgesamt).
let caps = build_free_wall_end_caps(&[wall_x_hatched(3.0, 0.2, 2.5)]);
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12);
// Jeder Kappen-Vertex traegt die Schraffur-Parameter (Muster 3 = crosshatch).
for v in caps.chunks_exact(CAP_FLOATS_PER_VERTEX) {
assert_eq!(v[5], 3.0, "pattern");
assert!((v[6] - 45.0_f32.to_radians()).abs() < 1e-5, "angle_rad");
}
}
#[test]
fn verbundene_wandenden_bekommen_keine_kappe_dort() {
// Zwei Waende treffen sich rechtwinklig bei (3,0) -> DIESES Ende ist bei
// beiden Waenden NICHT frei (Gehrung/Anschluss), die beiden anderen
// (freien) Enden bekommen weiterhin je eine Kappe -> 2 Kappen gesamt
// (nicht 4), macht 12 Vertices statt 24.
let a = wall_x_hatched(3.0, 0.2, 2.5); // (0,0) frei .. (3,0) verbunden
let b = WallInput {
start: [3.0, 0.0],
end: [3.0, 4.0],
..wall_x_hatched(4.0, 0.2, 2.5) // (3,0) verbunden .. (3,4) frei
};
let caps = build_free_wall_end_caps(&[a, b]);
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12, "nur die 2 freien Enden -> 2 Kappen");
}
#[test]
fn t_stoss_bekommt_ebenfalls_keine_kappe_am_knoten() {
// Drei Waende treffen sich am selben Punkt (T-Stoss, Gehrung bleibt
// rechtwinklig, s. compute_wall_miters) -> trotzdem gilt der Knoten als
// "beruehrt", KEINE der drei dortigen Enden bekommt eine Kappe.
let a = wall_x_hatched(3.0, 0.2, 2.5); // endet bei (3,0)
let b = WallInput { start: [3.0, 0.0], end: [3.0, 4.0], ..wall_x_hatched(4.0, 0.2, 2.5) };
let c = WallInput { start: [3.0, 0.0], end: [6.0, 0.0], ..wall_x_hatched(3.0, 0.2, 2.5) };
let caps = build_free_wall_end_caps(&[a, b, c]);
// Freie Enden: a.start(0,0), b.end(3,4), c.end(6,0) -> 3 Kappen (nicht 6).
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 18);
}
#[test]
fn mehrere_waende_addieren_sich() {
let mesh = build_walls_mesh(&[
@@ -171,6 +237,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -250,6 +319,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w);
@@ -272,6 +344,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0);
@@ -486,6 +561,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let b = WallInput {
start: [0.0, 0.0],
@@ -497,6 +575,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
@@ -557,6 +638,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]),
@@ -586,6 +670,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0),
@@ -675,6 +762,9 @@ mod tests {
openings: vec![],
layers: layers(),
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut b = a.clone();
b.end = [0.0, 3.0];
@@ -705,6 +795,8 @@ mod tests {
z_bottom,
z_top,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}
}
@@ -761,6 +853,8 @@ mod tests {
z_bottom: 0.0,
z_top: 0.3,
color: [0.8, 0.8, 0.8],
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &slab);
@@ -789,7 +883,7 @@ mod tests {
fn entarteter_slab_erzeugt_nichts() {
let mut mesh = Mesh::default();
// <3 Ecken.
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] });
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None });
assert_eq!(mesh.vertex_count(), 0);
// Nullhoehe.
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
@@ -808,6 +902,8 @@ mod tests {
indices: vec![0, 1, 2],
kind,
color,
material_index: None,
vertex_colors: Vec::new(),
}
}
@@ -849,6 +945,73 @@ mod tests {
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
}
#[test]
fn context_mesh_pro_vertex_farbe_wird_uebernommen() {
// Luftbild-Drape: jeder Vertex traegt eine eigene Farbe. Wird 1:1 an die
// Ausgabe-Vertices durchgereicht (statt der Einzelfarbe).
let mut mesh = Mesh::default();
append_context_mesh(
&mut mesh,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Terrain,
color: None,
material_index: None,
vertex_colors: vec![
1.0, 0.0, 0.0, // Vertex 0 rot
0.0, 1.0, 0.0, // Vertex 1 gruen
0.0, 0.0, 1.0, // Vertex 2 blau
],
},
);
// Vorderseite = erste 3 Vertices (Winding a,b,c): Farben 0,1,2.
assert_eq!([mesh.verts[6], mesh.verts[7], mesh.verts[8]], [1.0, 0.0, 0.0]);
assert_eq!([mesh.verts[15], mesh.verts[16], mesh.verts[17]], [0.0, 1.0, 0.0]);
assert_eq!([mesh.verts[24], mesh.verts[25], mesh.verts[26]], [0.0, 0.0, 1.0]);
// Zu kurzes vertex_colors-Array -> Einzelfarbe (kein Panic, kein Teilbezug).
let mut mesh2 = Mesh::default();
append_context_mesh(
&mut mesh2,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Terrain,
color: None,
material_index: None,
vertex_colors: vec![1.0, 0.0, 0.0], // nur 1 Vertex -> ignoriert
},
);
assert_eq!(
[mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]],
MeshKind::Terrain.default_color()
);
}
#[test]
fn kontext_mesh_materialindex_wandert_in_die_textured_layer() {
// Daecher (und potenziell andere Kontext-Meshes) koennen jetzt einen
// Material-Textur-Index tragen (`MeshInput::material_index`) — vorher
// war er fuer ALLE Kontext-Meshes hart auf -1 (Schachbrett) gesetzt, der
// Stil „Textured" konnte also nie eine echte Dachziegel-Textur zeigen.
let m = MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Extrusion,
color: None,
material_index: Some(2),
vertex_colors: Vec::new(),
};
let tex = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m));
assert!(!tex.layers.is_empty());
assert!(tex.layers.iter().all(|&l| l == 2.0), "alle Vertices Ebene 2");
// Ohne material_index bleibt der Fallback (-1 -> Schachbrett).
let m2 = MeshInput { material_index: None, ..m };
let tex2 = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m2));
assert!(tex2.layers.iter().all(|&l| l == -1.0));
}
#[test]
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
let mut mesh = Mesh::default();
@@ -860,6 +1023,8 @@ mod tests {
indices: vec![0, 1, 9],
kind: MeshKind::Imported,
color: None,
material_index: None,
vertex_colors: Vec::new(),
},
);
assert_eq!(mesh.vertex_count(), 0);
@@ -871,6 +1036,8 @@ mod tests {
indices: vec![0, 1, 2],
kind: MeshKind::Imported,
color: None,
material_index: None,
vertex_colors: Vec::new(),
},
);
assert_eq!(mesh.vertex_count(), 0);
@@ -962,6 +1129,51 @@ mod tests {
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
}
#[test]
fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let front = preset_camera(CameraPreset::Front, t, 10.0);
let back = preset_camera(CameraPreset::Back, t, 10.0);
let side = preset_camera(CameraPreset::Side, t, 10.0);
let left = preset_camera(CameraPreset::Left, t, 10.0);
assert_eq!(back.projection, Projection::Orthographic);
assert_eq!(left.projection, Projection::Orthographic);
// Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse).
assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)");
assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)");
}
#[test]
fn presets_iso_oktanten_sind_orthografisch_gleich_gross() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let dist = 10.0;
let iso = preset_camera(CameraPreset::Iso, t, dist);
let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist);
let br = preset_camera(CameraPreset::IsoBackRight, t, dist);
let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist);
for cam in [&iso, &fl, &br, &bl] {
assert_eq!(cam.projection, Projection::Orthographic);
// Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand
// (Modell erscheint in jedem Oktanten gleich gross).
let d = ((cam.eye[0] - t[0]).powi(2)
+ (cam.eye[1] - t[1]).powi(2)
+ (cam.eye[2] - t[2]).powi(2))
.sqrt();
assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist");
// Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt.
assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel");
}
// Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z).
assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts
assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links
assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts
assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links
}
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
/// sonst nicht mitgebaut wird (Muster: render2d).
@@ -1001,5 +1213,14 @@ mod tests {
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&tex_module)
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
// Luftbild-Drape-Shader (Terrain-Overlay, Bild-Textur + Bbox-Uniform in
// group 1) ebenso headless validieren.
let aerial_src = super::shaders::MESH_AERIAL_WGSL;
let aerial_module = naga::front::wgsl::parse_str(aerial_src)
.unwrap_or_else(|e| panic!("aerial: WGSL-Parse-Fehler: {e:?}"));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&aerial_module)
.unwrap_or_else(|e| panic!("aerial: WGSL-Validierung fehlgeschlagen: {e:?}"));
}
}
+53 -11
View File
@@ -185,15 +185,26 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3]
]
}
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target`
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso
/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp
/// ist perspektivisch.
/// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11).
/// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets
/// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel,
/// die Iso-Varianten diagonal); nur Persp ist perspektivisch.
///
/// Achs-Konvention (wie die three.js-Sicht, s. `applyView3d` in Viewport3D.tsx):
/// +Z = vorne (Front blickt von +Z entlang -Z), +X = rechts (Side blickt von
/// +X entlang -X). Back/Left sind exakt die Gegenrichtungen (-Z/-X); es gibt
/// noch KEINEN Nordwinkel — die Kardinalrichtungen sind rein modellrelativ
/// (Vorne/Rechts/Hinten/Links im UI, keine Himmelsrichtungen).
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
let mut cam = Camera {
target,
..Camera::default()
};
// Iso-Distanz-Helfer: Augenabstand-Komponente je Achse, sodass der
// tatsaechliche eye-target-Abstand exakt `dist` bleibt (d*sqrt(3) == dist,
// wie bei den bisherigen Front/Top/Side-Presets), damit alle Iso-Varianten
// das Modell gleich gross zeigen.
let iso_d = dist / 3.0_f32.sqrt();
match preset {
// Blick entlang -Z (von vorne), orthografisch.
CameraPreset::Front => {
@@ -202,6 +213,13 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick entlang +Z (von hinten) — Gegenrichtung von Front.
CameraPreset::Back => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0], target[1], target[2] - dist];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
CameraPreset::Top => {
@@ -210,24 +228,48 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 0.0, -1.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick entlang -X (von der Seite), orthografisch.
// Blick entlang -X (von der rechten Seite), orthografisch.
CameraPreset::Side => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick (orthografisch, Blick von schraeg oben).
// Blick entlang +X (von der linken Seite) — Gegenrichtung von Side.
CameraPreset::Left => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-rechts (Default-Iso).
// Echte Isometrie ist per Definition parallelprojiziert (keine
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
CameraPreset::Iso => {
cam.projection = Projection::Orthographic;
let d = dist / 3.0_f32.sqrt();
cam.eye = [target[0] + d, target[1] + d, target[2] + d];
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] + iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-links.
CameraPreset::IsoFrontLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] + iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-rechts.
CameraPreset::IsoBackRight => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-links.
CameraPreset::IsoBackLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0];
// eye-target-Abstand ist hier (wie bei Front/Top/Side) exakt
// `dist` (d*sqrt(3) == dist), darum dieselbe Skalierung wie dort,
// damit das Modell im Bild aehnlich gross erscheint.
cam.ortho_half_height = dist * 0.5;
}
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
+248 -5
View File
@@ -835,6 +835,160 @@ fn compute_wall_miters(walls: &[WallInput]) -> Vec<WallMiters> {
out
}
/// Ermittelt je Wand, ob ihr Start-/Endpunkt ein FREIES Ende ist — d. h. KEIN
/// anderes Wandende (auch kein T-/X-Stoss) trifft sich dort im selben
/// Hoehenbereich. Dieselbe Punkt-Rundung + Hoehen-Ueberlappungs-Clustering wie
/// `compute_wall_miters`, aber OHNE dessen Zwei-Wandenden-Einschraenkung: jeder
/// Cluster der Groesse ≥ 2 (Gehrung ODER T-/X-Stoss) gilt als "beruehrt", nur
/// ein isolierter Cluster der Groesse 1 (nur die Wand selbst) bleibt frei.
/// Grundlage der Schraffur-Kappen an unverbundenen Wandenden
/// (`build_free_wall_end_caps`) — ein Wandende "ohne Anschluss" (Nutzer-Begriff)
/// zeigt dort die Bauteil-Schraffur statt der Flaechenfarbe, wie ein echter
/// Schnitt.
fn compute_free_wall_ends(walls: &[WallInput]) -> Vec<(bool, bool)> {
let mut out = vec![(true, true); walls.len()];
let mut ends: Vec<EndRef> = Vec::with_capacity(walls.len() * 2);
for (i, w) in walls.iter().enumerate() {
let n = left_normal(w.start, w.end);
if n == [0.0, 0.0] {
continue; // entartete Wand: weder Ende gilt als "frei" (irrelevant)
}
let dir: Point2 = [n[1], -n[0]];
let z0 = w.base_elevation;
let z1 = w.base_elevation + w.height;
ends.push(EndRef { wall: i, is_start: true, point: w.start, dir, z0, z1 });
ends.push(EndRef { wall: i, is_start: false, point: w.end, dir, z0, z1 });
}
let mut groups: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (idx, e) in ends.iter().enumerate() {
groups.entry(round_key(e.point)).or_default().push(idx);
}
for members in groups.into_values() {
if members.len() < 2 {
continue; // einziges Ende an diesem Punkt -> bleibt frei (Default)
}
for cluster in cluster_by_height_overlap(&ends, &members) {
if cluster.len() < 2 {
continue; // eigener, hoehenmaessig isolierter Cluster -> frei
}
for &idx in &cluster {
let e = &ends[idx];
if e.is_start {
out[e.wall].0 = false;
} else {
out[e.wall].1 = false;
}
}
}
}
out
}
/// Haengt EINE Schraffur-Kappe (alle Schicht-Baender gestapelt) an einem
/// Wand-Achsenende an `verts` an — Vertex-Format identisch zu
/// `section_fill::build_cut_caps` (`CAP_FLOATS_PER_VERTEX`), damit dieselbe
/// `cap_pipeline`/`CAP_WGSL` sie zeichnen kann. `(u,v)` = (Quer-Versatz `s` zur
/// Wandachse, Welt-Hoehe) — metrisch, an den Schicht-Grenzen stetig.
#[allow(clippy::too_many_arguments)]
fn push_wall_end_cap(
verts: &mut Vec<f32>,
point: Point2,
n: Point2,
layers: &[WallLayer],
total: f32,
y0: f32,
y1: f32,
pattern: f32,
angle_rad: f32,
scale: f32,
line_weight: f32,
) {
let corner = |s: f32, y: f32| -> [f32; 9] {
let p = [point[0] + n[0] * s, point[1] + n[1] * s];
[p[0], y, p[1], s, y, pattern, angle_rad, scale, line_weight]
};
let mut off_a = total * 0.5;
for layer in layers {
let off_b = off_a - layer.thickness;
let c0 = corner(off_a, y0);
let c1 = corner(off_b, y0);
let c2 = corner(off_b, y1);
let c3 = corner(off_a, y1);
// Fan-Triangulierung des Quads (Winding egal, cap_pipeline kennt kein
// Backface-Culling — siehe section_fill.rs-Moduldoc).
verts.extend_from_slice(&c0);
verts.extend_from_slice(&c1);
verts.extend_from_slice(&c2);
verts.extend_from_slice(&c0);
verts.extend_from_slice(&c2);
verts.extend_from_slice(&c3);
off_a = off_b;
}
}
/// Schraffur-Kappen an FREIEN Wandenden (kein Anschluss an eine andere Wand,
/// s. `compute_free_wall_ends`): additive, ZWEITE Geometrie neben der
/// (unveraendert weiterhin flach eingefaerbten) End-Kappe der normalen
/// Extrusion — gezeichnet mit Tiefen-Bias VOR der Flaeche (analog dem
/// Luftbild-Overlay `gpu::aerial_pipeline`), sodass sie diese optisch
/// ueberdeckt, OHNE `extrude_layer_segment` selbst anzufassen (kein
/// Regressionsrisiko fuer bestehende Vollkoerper-Tests). Nur Waende MIT
/// explizit gesetzter `hatch` (Bauteil-Schraffur, `toWalls3d.ts` loest sie je
/// Materialschicht auf) bekommen eine Kappe — ohne `hatch` (z. B. alle
/// bestehenden Test-Fixtures) bleibt das Alt-Verhalten unangetastet. Bildet NUR
/// die echten Achsen-Enden ab (Wandanfang/-ende), keine Oeffnungs-Laibungen —
/// "Wand endet an Decke" (vertikale Terminierung) ist bewusst NICHT Teil dieser
/// Kappen (separates, noch offenes Thema).
pub fn build_free_wall_end_caps(walls: &[WallInput]) -> Vec<f32> {
let free_ends = compute_free_wall_ends(walls);
let mut verts: Vec<f32> = Vec::new();
for (wi, wall) in walls.iter().enumerate() {
let Some(hatch) = wall.hatch else { continue };
let (free_start, free_end) = free_ends[wi];
if !free_start && !free_end {
continue;
}
let n = left_normal(wall.start, wall.end);
if n == [0.0, 0.0] || wall.height <= 1e-6 {
continue;
}
let y0 = wall.base_elevation;
let y1 = wall.base_elevation + wall.height;
let synthetic;
let layers: &[WallLayer] = match &wall.layers {
Some(ls) if !ls.is_empty() => ls.as_slice(),
_ => {
synthetic = [WallLayer { thickness: wall.thickness, color: wall.color }];
&synthetic
}
};
let total: f32 = layers.iter().map(|l| l.thickness).sum();
let pattern = hatch.pattern as f32;
let angle_rad = hatch.angle.to_radians();
let scale = hatch.scale.max(0.05);
let line_weight = hatch.line_weight.max(0.0);
if free_start {
push_wall_end_cap(
&mut verts, wall.start, n, layers, total, y0, y1, pattern, angle_rad, scale,
line_weight,
);
}
if free_end {
push_wall_end_cap(
&mut verts, wall.end, n, layers, total, y0, y1, pattern, angle_rad, scale,
line_weight,
);
}
}
verts
}
/// Grundriss-Fussabdruck EINER Wand (die vier Band-Eckpunkte g0..g3 in CCW),
/// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion
/// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine
@@ -940,7 +1094,10 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
textured_from_mesh(&build_walls_mesh(walls))
// Ueber den Szenen-Builder (ohne Decken/Kontext) — so traegt der Puffer auch
// die Material-Ebenen je Wand (`layers`), waehrend Geometrie/UV bitgleich zu
// `textured_from_mesh(&build_walls_mesh(walls))` bleiben.
build_scene_mesh_textured(walls, &[], &[])
}
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
@@ -980,9 +1137,65 @@ pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
TexturedMesh {
verts,
indices: mesh.indices.clone(),
// Ohne Material-Zuordnung: leer -> die GPU-Schicht bindet einen konstanten
// `-1`-Ebenenpuffer (Fallback-Schachbrett fuer alle Vertices).
layers: Vec::new(),
}
}
/// TEXTURIERTES Szenen-Mesh MIT Material-Ebenen: baut dieselbe Szene wie
/// `build_scene_mesh` (Waende -> Decken -> Kontext-Meshes, gleiche Reihenfolge/
/// Geometrie) und fuellt zusaetzlich den parallelen `layers`-Puffer (ein Eintrag
/// je Vertex): fuer jede Wand ihre `material_index`-Textur-Ebene (`None` -> `-1`),
/// fuer Decken und Kontext-Meshes `-1` (Fallback-Schachbrett). Die Vertex-Ranges
/// werden waehrend des Baus je Bauteil aus `Mesh::vertex_count()` abgegriffen —
/// dieselben `extrude_*`/`append_*`-Funktionen wie der Shaded-Pfad bleiben die
/// EINE Geometrie-Quelle (Paritaet garantiert), es kommt nur die Ebenen-Zuordnung
/// dazu. Genutzt vom `Textured`-Stil (`gpu::upload_*`).
pub fn build_scene_mesh_textured(
walls: &[WallInput],
slabs: &[SlabInput],
meshes: &[MeshInput],
) -> TexturedMesh {
let mut mesh = Mesh::default();
let mut layers: Vec<f32> = Vec::new();
let fill = |mesh: &Mesh, layers: &mut Vec<f32>, before: usize, layer: f32| {
let added = mesh.vertex_count().saturating_sub(before);
layers.extend(std::iter::repeat(layer).take(added));
};
let miters = compute_wall_miters(walls);
for (w, m) in walls.iter().zip(miters.iter()) {
let before = mesh.vertex_count();
extrude_wall_with_miters(&mut mesh, w, *m);
// Material-Ebene der Wand (1-basiert; 0 ist das Fallback-Schachbrett).
let layer = w.material_index.map(|i| i as f32).unwrap_or(-1.0);
fill(&mesh, &mut layers, before, layer);
}
for s in slabs {
let before = mesh.vertex_count();
extrude_slab(&mut mesh, s);
fill(&mesh, &mut layers, before, -1.0);
}
for m in meshes {
let before = mesh.vertex_count();
append_context_mesh(&mut mesh, m);
// Material-Ebene des Kontext-Meshes (aktuell nur Daecher setzen sie,
// s. `MeshInput::material_index`); ohne -> Fallback-Schachbrett.
let layer = m.material_index.map(|i| i as f32).unwrap_or(-1.0);
fill(&mesh, &mut layers, before, layer);
}
let mut tex = textured_from_mesh(&mesh);
debug_assert_eq!(
layers.len(),
tex.vertex_count(),
"ein Ebenen-Eintrag je Vertex"
);
tex.layers = layers;
tex
}
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
@@ -1007,6 +1220,20 @@ pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[Mesh
mesh
}
/// Baut NUR die Gelaende-Dreiecke (`MeshKind::Terrain`) zu EINEM Mesh — Grundlage
/// des Luftbild-Overlays (`gpu::aerial_pipeline`): dieselbe Geometrie wie im
/// Hauptpuffer, aber separat, damit sie ein zweites Mal texturiert obenauf
/// gezeichnet werden kann. Leer, wenn kein Terrain vorliegt.
pub fn build_terrain_mesh(meshes: &[MeshInput]) -> Mesh {
let mut mesh = Mesh::default();
for m in meshes {
if matches!(m.kind, crate::types::MeshKind::Terrain) {
append_context_mesh(&mut mesh, m);
}
}
mesh
}
// ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ──────────────────────
/// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe
@@ -1029,6 +1256,21 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
let pos = &input.positions;
let vcount = pos.len() / 3;
let color = input.effective_color();
// Pro-Vertex-Farben nur nutzen, wenn genau ein RGB je Vertex vorliegt
// (sonst greift die Einzelfarbe fuers ganze Mesh). Beispiel: Luftbild-Drape.
let per_vertex = input.vertex_colors.len() >= vcount * 3 && vcount > 0;
let vcol = |i: usize| -> Rgb {
if per_vertex {
let b = i * 3;
[
input.vertex_colors[b],
input.vertex_colors[b + 1],
input.vertex_colors[b + 2],
]
} else {
color
}
};
// world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y).
let vworld = |i: usize| -> [f32; 3] {
let b = i * 3;
@@ -1043,6 +1285,7 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
let a = vworld(ia);
let b = vworld(ib);
let c = vworld(ic);
let (ca, cb, cc) = (vcol(ia), vcol(ib), vcol(ic));
// Flaechen-Normale (b-a) x (c-a); entartete Dreiecke ueberspringen.
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
@@ -1057,8 +1300,8 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
}
let n = [gn[0] / len, gn[1] / len, gn[2] / len];
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
push_ctx_tri(mesh, a, b, c, n, color);
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], color);
push_ctx_tri(mesh, a, b, c, n, [ca, cb, cc]);
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], [ca, cc, cb]);
}
}
@@ -1071,10 +1314,10 @@ fn push_ctx_tri(
b: [f32; 3],
c: [f32; 3],
normal: [f32; 3],
color: Rgb,
colors: [Rgb; 3],
) {
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
for p in [a, b, c] {
for (p, color) in [a, b, c].iter().zip(colors.iter()) {
mesh.verts.extend_from_slice(&[
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
]);
+85 -44
View File
@@ -24,12 +24,9 @@
//! - **Ursprung:** `SectionPlane::point`, projiziert.
//! - **u (horizontal in der Zeichnung):** Strecke entlang der Schnittebene,
//! senkrecht zur Blickrichtung, in der Horizontalen. Berechnet als
//! `u_axis = normalize(cross(normal, world_up))` — dieselbe rechtshaendige
//! Kamera-Konvention wie `math::look_at` (dort `s = cross(f, up)`), damit die
//! Vorzeichen-Konvention modulübergreifend konsistent bleibt. Bei den
//! Standard-Konstruktoren entspricht `u` direkt der jeweils ANDEREN
//! Grundriss-Achse (Schnitt entlang X -> u = Modell-Y, Schnitt entlang Y ->
//! u = -Modell-X).
//! `u_axis = normalize(cross(world_up, normal))` — das Blickrichtungs-RECHTS
//! des Betrachters (wer nach Norden blickt, hat Osten rechts). Die fruehere
//! look_at-Konvention `cross(normal, up)` spiegelte die Zeichnung.
//! - **v (vertikal in der Zeichnung, "Hoehe"):** `v = world.y`, ABSOLUT (nicht
//! relativ zur Schnittebene). `world.y` ist in dieser Codebasis durchgaengig
//! die Hoehen-Achse (Y-up, siehe `types.rs`/`mesh.rs`/`math.rs`: "world.y =
@@ -133,7 +130,7 @@
use serde::{Deserialize, Serialize};
use crate::math;
use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput};
use crate::types::{CutBandMeta, Hatch, Opening, Point2, Rgb, SlabInput, WallInput};
/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe).
const EPS: f32 = 1e-4;
@@ -189,8 +186,13 @@ impl SectionPlane {
/// `pub(crate)`, damit `section_fill` die (u,v)->Welt-Ruecktransformation
/// mit EXAKT derselben Achsdefinition (inkl. Entartungs-Fallback) rechnet.
pub(crate) fn u_axis(&self) -> [f32; 3] {
// Blickrichtungs-RECHTS des Betrachters: cross(up, normal). Die alte
// Formel cross(normal, up) (die `look_at`-right-Konvention fuer Blick
// entlang -z) SPIEGELTE den Schnitt — wer nach Norden blickt, hat
// Osten rechts, die Ausgabe zeigte ihn links. Gleiche Entspiegelung
// wie `elevationFrame` (toElevation.ts) auf der TS-Seite.
let up = [0.0, 1.0, 0.0];
let u = math::cross(self.normal, up);
let u = math::cross(up, self.normal);
if math::length(u) > 1e-6 {
math::normalize(u)
} else {
@@ -268,8 +270,19 @@ pub struct ComponentRef {
pub struct CutPolygon {
pub component: ComponentRef,
pub color: Rgb,
/// Schnitt-Schraffur des Ursprungs-Bauteils (siehe `WallInput::hatch`). `None`
/// -> der Cap-Shader zeichnet das Fallback-Diagonalmuster (Alt-Verhalten).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen).
pub pts: Vec<[f32; 2]>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten des Ursprungs-Bandes (siehe
/// `WallInput::cut`). Treibt die Boolean-Dominanz in `section_boolean::
/// subtract_dominant_bands` (die staerkere Schicht schneidet die schwaechere)
/// und die Schnitt-Umrisslinien (`section_fill::build_cut_cap_lines`). `None`
/// -> das Band nimmt an keiner Subtraktion teil (Alt-Verhalten).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter.
@@ -314,9 +327,16 @@ struct Prism {
z0: f32,
z1: f32,
color: Rgb,
/// Schnitt-Schraffur des Bauteils (siehe `WallInput::hatch`). Wandert in die
/// Cut-Polygone, damit `section_fill`/`CAP_WGSL` das richtige Muster zeichnen.
hatch: Option<Hatch>,
/// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer
/// Decken (Slabs kennen keine Oeffnungen).
wall_axis: Option<WallAxis>,
/// Schnitt-Metadaten (Prioritaet/Identitaet/Linienstil), 1:1 aus der
/// `WallInput`/`SlabInput`-Eingabe (`cut`) uebernommen und an jedes erzeugte
/// `CutPolygon` weitergereicht.
cut: Option<CutBandMeta>,
}
/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut-
@@ -399,12 +419,14 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
z0: wall.base_elevation,
z1: wall.base_elevation + wall.height,
color: wall.color,
hatch: wall.hatch,
wall_axis: Some(WallAxis {
start: wall.start,
dir,
length,
openings: wall.openings.clone(),
}),
cut: wall.cut.clone(),
})
}
@@ -425,7 +447,9 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
z0,
z1,
color: slab.color,
hatch: slab.hatch,
wall_axis: None,
cut: slab.cut.clone(),
})
}
@@ -974,7 +998,9 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
index: prism.index,
},
color: prism.color,
hatch: prism.hatch,
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
cut: prism.cut.clone(),
});
}
}
@@ -1017,6 +1043,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
WallInput {
start: [0.0, 3.0],
@@ -1028,6 +1057,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
];
let slabs = vec![SlabInput {
@@ -1035,6 +1067,8 @@ mod tests {
z_bottom: -0.2,
z_top: 0.0,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}];
(walls, slabs)
}
@@ -1067,8 +1101,9 @@ mod tests {
math::dot(u, plane.normal).abs() < 1e-6,
"u-Achse orthogonal zur Blickrichtung"
);
// Fuer looking_plus_x ist u = Modell-Y (world +Z).
assert!((u[2] - 1.0).abs() < 1e-6);
// Fuer looking_plus_x (Blick nach Osten) zeigt das Betrachter-RECHTS
// nach Sueden: u = world Z (Entspiegelung, s. u_axis-Doc).
assert!((u[2] + 1.0).abs() < 1e-6);
}
#[test]
@@ -1108,8 +1143,8 @@ mod tests {
let vs: Vec<f32> = wall_cut.pts.iter().map(|p| p[1]).collect();
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
assert!((u_lo - 2.9).abs() < 1e-4, "u_lo={u_lo}");
assert!((u_hi - 3.1).abs() < 1e-4, "u_hi={u_hi}");
assert!((u_lo + 3.1).abs() < 1e-4, "u_lo={u_lo}");
assert!((u_hi + 2.9).abs() < 1e-4, "u_hi={u_hi}");
assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}");
assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}");
@@ -1122,8 +1157,8 @@ mod tests {
let vs: Vec<f32> = slab_cut.pts.iter().map(|p| p[1]).collect();
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
assert!((u_lo + 0.5).abs() < 1e-4, "u_lo={u_lo}");
assert!((u_hi - 3.5).abs() < 1e-4, "u_hi={u_hi}");
assert!((u_lo + 3.5).abs() < 1e-4, "u_lo={u_lo}");
assert!((u_hi - 0.5).abs() < 1e-4, "u_hi={u_hi}");
assert!((v_lo + 0.2).abs() < 1e-4, "v_lo={v_lo}");
assert!((v_hi - 0.0).abs() < 1e-4, "v_hi={v_hi}");
}
@@ -1179,30 +1214,30 @@ mod tests {
let embedded_corner_hidden = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 1
&& (e.a[0] - 2.9).abs() < 1e-3
&& (e.b[0] - 2.9).abs() < 1e-3
&& (e.a[0] + 2.9).abs() < 1e-3
&& (e.b[0] + 2.9).abs() < 1e-3
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
});
assert!(embedded_corner_hidden, "Eckkante von Schenkel B bei u=2.9 muss hidden sein");
assert!(embedded_corner_hidden, "Eckkante von Schenkel B bei u=-2.9 muss hidden sein");
// Die AEUSSERE Eckkante von Schenkel B (u=3.1, ragt ueber Schenkel A
// hinaus) bleibt dagegen sichtbar.
let outer_corner_visible = out.visible_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 1
&& (e.a[0] - 3.1).abs() < 1e-3
&& (e.b[0] - 3.1).abs() < 1e-3
&& (e.a[0] + 3.1).abs() < 1e-3
&& (e.b[0] + 3.1).abs() < 1e-3
});
assert!(outer_corner_visible, "Aeussere Eckkante von Schenkel B (u=3.1) bleibt sichtbar");
// Die partiell gesplittete Bodenkante (u: 2.9 -> 3.1 bei v=0) zerfaellt
// in einen sichtbaren Anteil ab u=3.0.
// Die partiell gesplittete Bodenkante (u: -3.1 -> -2.9 bei v=0,
// gespiegelte u-Achse) zerfaellt in einen sichtbaren Anteil ab u=-3.0.
let split_visible_present = out.visible_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 1
&& (e.a[1].abs() < 1e-3 && e.b[1].abs() < 1e-3)
&& ((e.a[0] - 3.0).abs() < 1e-3 || (e.b[0] - 3.0).abs() < 1e-3)
&& ((e.a[0] + 3.0).abs() < 1e-3 || (e.b[0] + 3.0).abs() < 1e-3)
});
assert!(split_visible_present, "Sichtbarer Teil der gesplitteten Bodenkante ab u=3.0 fehlt");
}
@@ -1254,14 +1289,14 @@ mod tests {
};
let a = wall_cuts.iter().find(|c| c.component.index == 0).expect("Schenkel A");
let b = wall_cuts.iter().find(|c| c.component.index == 1).expect("Schenkel B");
let (_a_lo, a_hi) = u_bounds(a);
let (b_lo, _b_hi) = u_bounds(b);
let (a_lo, _a_hi) = u_bounds(a);
let (_b_lo, b_hi) = u_bounds(b);
// Beide enden/beginnen an der Gehrungslinie (u ~ 2.95) statt sich zu
// ueberlappen (Schenkel A reichte ungehrt bis u=3.0, Schenkel B ab 2.9).
assert!((a_hi - 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_hi={a_hi}");
assert!((b_lo - 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_lo={b_lo}");
assert!(a_hi <= b_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte");
// Beide enden/beginnen an der Gehrungslinie (u ~ -2.95, gespiegelte
// u-Achse) statt sich zu ueberlappen.
assert!((a_lo + 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_lo={a_lo}");
assert!((b_hi + 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_hi={b_hi}");
assert!(b_hi <= a_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte");
}
// --- Oeffnungen (Tueren/Fenster) -----------------------------------------
@@ -1285,6 +1320,9 @@ mod tests {
}],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}
}
@@ -1371,6 +1409,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0);
@@ -1389,8 +1430,8 @@ mod tests {
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
})
};
assert!(jamb_visible(-1.5), "linke Leibung (u=-1.5) muss sichtbar sein");
assert!(jamb_visible(-2.5), "rechte Leibung (u=-2.5) muss sichtbar sein");
assert!(jamb_visible(1.5), "linke Leibung (u=1.5) muss sichtbar sein");
assert!(jamb_visible(2.5), "rechte Leibung (u=2.5) muss sichtbar sein");
// Bruestungs- und Sturzkante (horizontale Rahmenkanten bei v=1.0/2.0).
let horizontal_edge_visible = |v: f32| {
out.visible_edges.iter().any(|e| {
@@ -1398,8 +1439,8 @@ mod tests {
&& e.component.index == 0
&& (e.a[1] - v).abs() < 1e-3
&& (e.b[1] - v).abs() < 1e-3
&& (e.a[0].min(e.b[0]) + 2.5).abs() < 1e-3
&& (e.a[0].max(e.b[0]) + 1.5).abs() < 1e-3
&& (e.a[0].min(e.b[0]) - 1.5).abs() < 1e-3
&& (e.a[0].max(e.b[0]) - 2.5).abs() < 1e-3
})
};
assert!(horizontal_edge_visible(1.0), "Bruestungskante (v=1.0) muss sichtbar sein");
@@ -1409,21 +1450,21 @@ mod tests {
let frame_hidden = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 0
&& ((e.a[0] + 1.5).abs() < 1e-3 || (e.a[0] + 2.5).abs() < 1e-3)
&& ((e.a[0] - 1.5).abs() < 1e-3 || (e.a[0] - 2.5).abs() < 1e-3)
&& e.a[1] >= 1.0 - 1e-3
&& e.a[1] <= 2.0 + 1e-3
});
assert!(!frame_hidden, "Fensterrahmen darf nicht (teilweise) hidden sein");
// -- Durchblick: die (naeherliegende) Vorderkante von Wand 1 bei
// u=-2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich -2.5..-1.5) muss
// u=2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich 1.5..2.5) muss
// GENAU im Fensterband (v: 1.0..2.0) sichtbar sein, darueber/darunter
// (verdeckt durch Bruestung/Sturz der Wand 0) hidden. --
let w1_visible_window = out.visible_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 1
&& (e.a[0] + 2.0).abs() < 1e-3
&& (e.b[0] + 2.0).abs() < 1e-3
&& (e.a[0] - 2.0).abs() < 1e-3
&& (e.b[0] - 2.0).abs() < 1e-3
&& (e.a[1].min(e.b[1]) - 1.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
});
@@ -1432,8 +1473,8 @@ mod tests {
let w1_hidden_below = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 1
&& (e.a[0] + 2.0).abs() < 1e-3
&& (e.b[0] + 2.0).abs() < 1e-3
&& (e.a[0] - 2.0).abs() < 1e-3
&& (e.b[0] - 2.0).abs() < 1e-3
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 1.0).abs() < 1e-3
});
@@ -1442,20 +1483,20 @@ mod tests {
let w1_hidden_above = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 1
&& (e.a[0] + 2.0).abs() < 1e-3
&& (e.b[0] + 2.0).abs() < 1e-3
&& (e.a[0] - 2.0).abs() < 1e-3
&& (e.b[0] - 2.0).abs() < 1e-3
&& (e.a[1].min(e.b[1]) - 2.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
});
assert!(w1_hidden_above, "Wand 1 oberhalb des Sturzes (v 2.0..2.5) muss hidden sein");
// Kante hinter der GESCHLOSSENEN Wandflaeche (u=-4.0, ausserhalb des
// Kante hinter der GESCHLOSSENEN Wandflaeche (u=4.0, ausserhalb des
// Fensters, x=4-Ende von Wand 1) bleibt vollstaendig verdeckt.
let w1_closed_wall_hidden = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall
&& e.component.index == 1
&& (e.a[0] + 4.0).abs() < 1e-3
&& (e.b[0] + 4.0).abs() < 1e-3
&& (e.a[0] - 4.0).abs() < 1e-3
&& (e.b[0] - 4.0).abs() < 1e-3
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
});
+516
View File
@@ -0,0 +1,516 @@
//! Schnitt-Boolean-Dominanz fuer den 3D-Live-Schnitt — die 1:1-Portierung von
//! `src/plan/toSection.ts::subtractDominantBands`/`subtractRect` nach Rust.
//!
//! GRUND: der 2D-Schnitt (`toSection.ts`) ist die Referenz-Implementierung. Er
//! sammelt alle Cut-Baender (Wand- und Deckenschichten) als achsparallele
//! (u,v)-Rechtecke, taggt jedes mit seiner `Component.joinPriority` und laesst
//! dann die STAERKERE Schicht die schwaechere per Rechteck-Subtraktion wegschneiden
//! (elementuebergreifend). Der 3D-Rust-Cut (`section.rs`) produziert bereits genau
//! dasselbe Format — achsparallele (u,v)-Rechteck-`CutPolygon`s — also laesst sich
//! die Dominanz-Zerlegung hier identisch nachbauen, damit der 3D-Live-Schnitt fuer
//! Wand+Decken-Schichten EXAKT wie der 2D-Schnitt aussieht.
//!
//! Die Prioritaet/Identitaet je Band kommt aus `CutPolygon::cut` (`CutBandMeta`,
//! aus der TS-Emission `toWalls3d`). Baender ohne `join_priority` (unaufloesbares
//! Bauteil) nehmen weder als Basis noch als Cutter teil und bleiben unveraendert.
use crate::section::CutPolygon;
/// Toleranz fuer Ueberlappungs-/Rest-Rechtecke (Meter): Null-/Negativflaechen und
/// Rest-Streifen duenner als EPS werden verworfen (Float-Rauschen der (u,v)-Meter).
/// Identisch zu `RECT_EPS` in `toSection.ts`.
const RECT_EPS: f32 = 1e-6;
/// Achsparalleles Rechteck in (u, v)-Metern — das Rust-Gegenstueck zu `Rect` in
/// `toSection.ts`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub u_min: f32,
pub u_max: f32,
pub v_min: f32,
pub v_max: f32,
}
/// Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `None` bei <3
/// Ecken (analog `rectOfBand`).
pub fn rect_of_poly(poly: &CutPolygon) -> Option<Rect> {
if poly.pts.len() < 3 {
return None;
}
let mut u_min = f32::INFINITY;
let mut u_max = f32::NEG_INFINITY;
let mut v_min = f32::INFINITY;
let mut v_max = f32::NEG_INFINITY;
for p in &poly.pts {
u_min = u_min.min(p[0]);
u_max = u_max.max(p[0]);
v_min = v_min.min(p[1]);
v_max = v_max.max(p[1]);
}
Some(Rect {
u_min,
u_max,
v_min,
v_max,
})
}
/// Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Ueberlappung mit
/// `cutter`, zerlegt in 04 disjunkte Rest-Rechtecke (links/rechts ueber die volle
/// Hoehe, dann oben/unten im mittleren u-Streifen — ueberlappungsfrei). Kein echter
/// Ueberlapp (Flaeche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollstaendige
/// Ueberdeckung ⇒ leere Liste. Rest-Streifen duenner als EPS werden verworfen.
/// 1:1 aus `toSection.ts::subtractRect`.
pub fn subtract_rect(base: Rect, cutter: Rect) -> Vec<Rect> {
let o_min_u = base.u_min.max(cutter.u_min);
let o_max_u = base.u_max.min(cutter.u_max);
let o_min_v = base.v_min.max(cutter.v_min);
let o_max_v = base.v_max.min(cutter.v_max);
// Kein (nennenswerter) Ueberlapp — `base` unveraendert.
if o_max_u - o_min_u <= RECT_EPS || o_max_v - o_min_v <= RECT_EPS {
return vec![base];
}
let mut out = Vec::new();
let mut push = |r: Rect| {
if r.u_max - r.u_min > RECT_EPS && r.v_max - r.v_min > RECT_EPS {
out.push(r);
}
};
// Links / rechts der Ueberlappung, jeweils ueber die volle Basis-Hoehe.
push(Rect {
u_min: base.u_min,
u_max: o_min_u,
v_min: base.v_min,
v_max: base.v_max,
});
push(Rect {
u_min: o_max_u,
u_max: base.u_max,
v_min: base.v_min,
v_max: base.v_max,
});
// Unten / oben im mittleren u-Streifen (nur der von der Ueberlappung befreite Rest).
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: base.v_min,
v_max: o_min_v,
});
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: o_max_v,
v_max: base.v_max,
});
out
}
/// `join_priority` eines Bands (aus `CutBandMeta`), oder `None`.
fn priority_of(poly: &CutPolygon) -> Option<i32> {
poly.cut.as_ref().and_then(|c| c.join_priority)
}
/// `component_id` eines Bands (aus `CutBandMeta`), oder `None`.
fn component_of(poly: &CutPolygon) -> Option<&str> {
poly.cut.as_ref().and_then(|c| c.component_id.as_deref())
}
/// Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz/Metadaten von
/// `src` erbt (analog `bandFromRect`). Ecken-Reihenfolge wie in `section.rs`
/// (CCW-analog, hier v_max oben zuerst — fuer die Fan-Triangulierung irrelevant).
fn poly_from_rect(src: &CutPolygon, r: Rect) -> CutPolygon {
CutPolygon {
component: src.component,
color: src.color,
hatch: src.hatch,
cut: src.cut.clone(),
pts: vec![
[r.u_min, r.v_max],
[r.u_max, r.v_max],
[r.u_max, r.v_min],
[r.u_min, r.v_min],
],
}
}
/// Globale Schnitt-Dominanz: fuer jedes Band wird die Rechteck-Ueberlappung ALLER
/// Baender mit STRIKT hoeherer `join_priority` subtrahiert (elementuebergreifend —
/// z. B. schneidet ein Decken-Beton-Band 100 ein ueberlappendes Wand-Putz-Band 10
/// weg). Gleiche Prioritaet schneidet NICHT (koexistiert → durchgehende Flaeche).
/// Baender ohne `join_priority` nehmen weder als Basis noch als Cutter teil und
/// bleiben unveraendert erhalten. 1:1 aus `toSection.ts::subtractDominantBands`
/// (inklusive der abschliessenden MERGE-Phase).
pub fn subtract_dominant_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let rects: Vec<Option<Rect>> = bands.iter().map(rect_of_poly).collect();
let prios: Vec<Option<i32>> = bands.iter().map(priority_of).collect();
let mut out: Vec<CutPolygon> = Vec::new();
for i in 0..bands.len() {
// Nicht-rechteckig oder ohne Prioritaet: unveraendert durchreichen.
let (base, prio) = match (rects[i], prios[i]) {
(Some(b), Some(p)) => (b, p),
_ => {
out.push(bands[i].clone());
continue;
}
};
let mut pieces = vec![base];
for j in 0..bands.len() {
if pieces.is_empty() {
break;
}
if j == i {
continue;
}
let cutter = match (rects[j], prios[j]) {
(Some(c), Some(op)) if op > prio => c,
_ => continue,
};
let mut next = Vec::new();
for p in &pieces {
next.extend(subtract_rect(*p, cutter));
}
pieces = next;
}
for p in pieces {
out.push(poly_from_rect(&bands[i], p));
}
}
merge_same_component_bands(out)
}
/// Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder
/// ein Rechteck ist — sonst `None`. Enthaltensein oder Achs-Deckung + Beruehrung
/// liefern eine rechteckige Union; Teil-/Eck-Ueberlappungen (L-Form) und Baender
/// mit Spalt liefern `None`. 1:1 aus `toSection.ts::rectUnionIfRect`.
fn rect_union_if_rect(a: Rect, b: Rect) -> Option<Rect> {
const E: f32 = RECT_EPS;
// Enthaltensein (inkl. Deckungsgleichheit): Union = umschliessendes Rechteck.
let a_in_b = b.u_min - a.u_min <= E
&& a.u_max - b.u_max <= E
&& b.v_min - a.v_min <= E
&& a.v_max - b.v_max <= E;
if a_in_b {
return Some(b);
}
let b_in_a = a.u_min - b.u_min <= E
&& b.u_max - a.u_max <= E
&& a.v_min - b.v_min <= E
&& b.v_max - a.v_max <= E;
if b_in_a {
return Some(a);
}
let u_aligned = (a.u_min - b.u_min).abs() <= E && (a.u_max - b.u_max).abs() <= E;
let v_aligned = (a.v_min - b.v_min).abs() <= E && (a.v_max - b.v_max).abs() <= E;
// Beruehrung/Ueberlappung in der jeweils ANDEREN Achse (kein Spalt groesser EPS).
let v_touch = a.v_max >= b.v_min - E && b.v_max >= a.v_min - E;
let u_touch = a.u_max >= b.u_min - E && b.u_max >= a.u_min - E;
if (u_aligned && v_touch) || (v_aligned && u_touch) {
return Some(Rect {
u_min: a.u_min.min(b.u_min),
u_max: a.u_max.max(b.u_max),
v_min: a.v_min.min(b.v_min),
v_max: a.v_max.max(b.v_max),
});
}
None
}
/// MERGE-Phase der Schnitt-Dominanz: Baender mit GLEICHER `component_id` UND
/// gleicher `join_priority`, deren achsparallele Rechtecke sich beruehren/ueberlappen
/// und eine rechteckige Union bilden (`rect_union_if_rect`), werden zu EINEM Band
/// zusammengefasst — so entsteht an der Beruehrkante keine innere Trennlinie durch
/// gleichartiges Material. Baender ohne `component_id`/`join_priority` oder ohne
/// Rechteck-Form nehmen nicht teil. Iterativ bis zum Fixpunkt. 1:1 aus
/// `toSection.ts::mergeSameComponentBands`.
fn merge_same_component_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let mut out = bands;
let mut merged = true;
while merged {
merged = false;
'outer: for i in 0..out.len() {
let (ci, pi) = match (component_of(&out[i]), priority_of(&out[i])) {
(Some(c), Some(p)) => (c.to_string(), p),
_ => continue,
};
let ri = match rect_of_poly(&out[i]) {
Some(r) => r,
None => continue,
};
for j in (i + 1)..out.len() {
if component_of(&out[j]) != Some(ci.as_str()) || priority_of(&out[j]) != Some(pi) {
continue;
}
let rj = match rect_of_poly(&out[j]) {
Some(r) => r,
None => continue,
};
if let Some(u) = rect_union_if_rect(ri, rj) {
out[i] = poly_from_rect(&out[i], u);
out.remove(j);
merged = true; // Fixpunkt-Neustart
break 'outer;
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::section::{ComponentKind, ComponentRef, CutPolygon};
use crate::types::CutBandMeta;
/// Achsparalleles Cut-Band mit Prioritaet (und optionaler Komponenten-Id) —
/// Ecken wie `section.rs` (Rechteck).
fn band(
u_min: f32,
u_max: f32,
v_min: f32,
v_max: f32,
join_priority: i32,
component_id: Option<&str>,
) -> CutPolygon {
CutPolygon {
component: ComponentRef {
kind: ComponentKind::Wall,
index: 0,
},
color: [0.5, 0.5, 0.5],
hatch: None,
pts: vec![
[u_min, v_max],
[u_max, v_max],
[u_max, v_min],
[u_min, v_min],
],
cut: Some(CutBandMeta {
join_priority: Some(join_priority),
component_id: component_id.map(|s| s.to_string()),
line_color: None,
line_weight: None,
}),
}
}
fn rect_of(p: &CutPolygon) -> Rect {
rect_of_poly(p).unwrap()
}
fn area(bands: &[CutPolygon]) -> f32 {
bands
.iter()
.map(|b| {
let r = rect_of(b);
(r.u_max - r.u_min) * (r.v_max - r.v_min)
})
.sum()
}
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-5
}
// --- subtractRect (Referenz: toSection.boolean.test.ts) ------------------
#[test]
fn subtract_rect_kein_overlap_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 2.0, u_max: 3.0, v_min: 2.0, v_max: 3.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_kantenberuehrung_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 0.0, v_max: 1.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_cutter_enthaelt_base_leer() {
let base = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let cut = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
assert!(subtract_rect(base, cut).is_empty());
}
#[test]
fn subtract_rect_cutter_mittig_vier_reste() {
let base = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let rest = subtract_rect(base, cut);
assert_eq!(rest.len(), 4);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 8.0), "9 (base) - 1 (overlap) = 8, ist {a}");
}
#[test]
fn subtract_rect_eck_ueberlappung_zwei_reste() {
// cutter deckt die obere-rechte Ecke von base: overlap [1,2]x[1,2].
let base = Rect { u_min: 0.0, u_max: 2.0, v_min: 0.0, v_max: 2.0 };
let cut = Rect { u_min: 1.0, u_max: 3.0, v_min: 1.0, v_max: 3.0 };
let rest = subtract_rect(base, cut);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 3.0), "4 - 1 = 3, ist {a}");
}
// --- subtractDominantBands: die geometrische Kernaussage ------------------
#[test]
fn decke_100_schneidet_wand_50_im_ueberlappungs_z_intervall_weg() {
// Nutzer-Sample-analog: eine Wandschicht (Backstein, Prio 50, u in [0,0.2],
// volle Hoehe v in [0,3]) und ein Decken-Beton-Band (Prio 100, u in [0,0.2],
// v in [2.6,2.8]) ueberlappen in (u,v). Nach der Subtraktion darf die Wand
// im Decken-z-Intervall KEINE Flaeche mehr haben; Beton bleibt voll.
let brick = band(0.0, 0.2, 0.0, 3.0, 50, Some("brick"));
let concrete = band(0.0, 0.2, 2.6, 2.8, 100, Some("concrete"));
let out = subtract_dominant_bands(vec![brick, concrete]);
// Beton unveraendert (kein staerkeres Band).
let concrete_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("concrete"))
.collect();
assert_eq!(concrete_out.len(), 1);
assert!(approx(
(rect_of(concrete_out[0]).v_max - rect_of(concrete_out[0]).v_min) as f32,
0.2
));
// Backstein: in v in [2.6,2.8] KEINE Flaeche mehr -> zwei Reste [0,2.6] und
// [2.8,3.0]. Kein Rest-Rechteck ragt ins Beton-Intervall.
let brick_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("brick"))
.collect();
assert!(!brick_out.is_empty());
for b in &brick_out {
let r = rect_of(b);
// Kein Backstein-Rest ueberlappt (2.6, 2.8) echt.
let overlaps = r.v_min < 2.8 - 1e-4 && r.v_max > 2.6 + 1e-4;
assert!(!overlaps, "Backstein-Rest {r:?} ragt ins Beton-z-Intervall");
}
// Flaechenbilanz: Backstein 0.2*3 = 0.6, minus Overlap 0.2*0.2 = 0.04 -> 0.56.
assert!(
approx(area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()), 0.56),
"Backstein-Restflaeche"
);
}
#[test]
fn wand_50_schneidet_estrich_30() {
// Estrich (Decke, Prio 30) verliert die Ueberlappung mit der Wand
// (Backstein, Prio 50). Referenz-Zahlen wie im 2D-Test.
let screed = band(0.0, 2.0, 0.0, 3.0, 30, Some("screed"));
let brick = band(0.0, 2.0, 2.0, 4.0, 50, Some("brick")); // Overlap v in [2,3]
let out = subtract_dominant_bands(vec![screed, brick]);
// Brick voll.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
4.0
));
// Estrich: [0,3] minus Overlap [2,3] = [0,2] -> Rest genau v in [0,2].
let screed_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("screed"))
.collect();
assert_eq!(screed_out.len(), 1);
let r = rect_of(screed_out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 2.0), "Estrich-Rest {r:?}");
}
/// SAMPLE-ANALOG (Nutzer-Bug): der Schnitt durch Aussenwand + Geschossdecke.
/// Deckenschichten (u-breit, gestapelt in v nahe OK): Estrich (30, [2.54,2.6]),
/// Dämmung (20, [2.50,2.54]), Beton (100, [2.30,2.50]). Wandschichten (schmal in
/// u, an ihrer Dicken-Position): Backstein (50) läuft als ZWEI Boxen [0,2.3] +
/// [2.5,2.6] durch (Beton kappt das Mittelstück — genau die per-Schicht-Decken-
/// Dominanz aus emitWall). ERWARTUNG: nach der Verschneidung haben Estrich UND
/// Dämmung im u-Bereich des Backsteins KEINE Fläche mehr (Backstein 50 > 30, 20);
/// der Beton bleibt voll und läuft durch. Das ist die Richtung, die der Nutzer als
/// kaputt meldete ("dämmung und zementboden laufen raus").
#[test]
fn sample_analog_backstein_schneidet_estrich_und_daemmung() {
let brick_u = (0.0075_f32, 0.1575_f32); // Backstein-Dickenposition (W4-analog)
let ceil_u = (0.0_f32, 5.0_f32); // Deckenschichten über die volle Raumbreite
let bands = vec![
band(ceil_u.0, ceil_u.1, 2.54, 2.6, 30, Some("screed")),
band(ceil_u.0, ceil_u.1, 2.50, 2.54, 20, Some("insulation-ceiling")),
band(ceil_u.0, ceil_u.1, 2.30, 2.50, 100, Some("concrete")),
band(brick_u.0, brick_u.1, 0.0, 2.3, 50, Some("brick")),
band(brick_u.0, brick_u.1, 2.5, 2.6, 50, Some("brick")),
];
let out = subtract_dominant_bands(bands);
// Kein Estrich-/Dämmung-Rest überlappt den Backstein-u-Bereich ECHT.
for name in ["screed", "insulation-ceiling"] {
for b in out.iter().filter(|b| component_of(b) == Some(name)) {
let r = rect_of(b);
let u_overlap = r.u_min < brick_u.1 - 1e-4 && r.u_max > brick_u.0 + 1e-4;
assert!(
!u_overlap,
"{name}-Rest {r:?} ragt noch in den Backstein-u-Bereich (müsste weg sein)"
);
}
}
// Beton bleibt voll (kein stärkeres Band): Fläche 5*0.2 = 1.0.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("concrete")).cloned().collect::<Vec<_>>()),
1.0
));
// Backstein bleibt in beiden Boxen erhalten (nichts Stärkeres überlappt).
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
0.15 * 2.3 + 0.15 * 0.1
));
}
#[test]
fn gleiche_prioritaet_koexistiert() {
// Beton-Wand + Beton-Decke, gleiche Prio 100, VERSCHIEDENE Komponenten:
// schneiden sich NICHT und verschmelzen NICHT (bleiben beide voll).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton-wand"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton-decke")); // Overlap v[2,3]
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 2);
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-wand")).cloned().collect::<Vec<_>>()),
3.0
));
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-decke")).cloned().collect::<Vec<_>>()),
2.0
));
}
#[test]
fn ohne_prioritaet_keine_teilnahme() {
let stark = band(0.0, 2.0, 0.0, 2.0, 100, None);
let mut ohne = band(0.0, 2.0, 0.0, 2.0, 0, None);
ohne.cut = None; // keine Prioritaet
let out = subtract_dominant_bands(vec![stark, ohne]);
// Beide unveraendert (4 + 4).
assert_eq!(out.len(), 2);
assert!(approx(area(&out), 8.0));
}
#[test]
fn merge_gleiche_komponente_und_prioritaet() {
// Beton-Wand trifft Beton-Decke (u-deckungsgleich), gleiche componentId +
// Prio -> verschmelzen zu EINEM Rechteck v in [0,4] (keine innere Kante).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton"));
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 1);
let r = rect_of(&out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 4.0), "verschmolzen {r:?}");
}
}
+513 -13
View File
@@ -21,19 +21,68 @@
//!
//! ## Ausgabeformat
//!
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32,
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges,
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken,
//! siehe `section.rs`) — Fan-Triangulierung genuegt.
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.
use crate::section::{cut_section, SectionPlane};
use crate::section::{cut_section, CutPolygon, SectionPlane};
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
use crate::types::{SlabInput, WallInput};
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs).
pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
/// Breite (mm-Papier -> Welt) je Schraffur.
pub const CAP_FLOATS_PER_VERTEX: usize = 9;
/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;
/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;
/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];
/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
/// ein Band keinen eigenen `line_weight` traegt.
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;
/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
///
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
/// Schlussbericht/Moduldoc).
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;
/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
const CUT_LINE_LIFT: f32 = 0.002;
/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
/// aufgeloeste Bauteil-Schraffur).
const FALLBACK_PATTERN: f32 = 2.0;
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
@@ -44,12 +93,23 @@ pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
/// nicht von der Normale ab.
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
let out = cut_section(plane, walls, slabs);
subtract_dominant_bands(out.cut_polygons)
}
pub fn build_cut_caps(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let out = cut_section(plane, walls, slabs);
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
@@ -62,14 +122,28 @@ pub fn build_cut_caps(
};
let mut verts: Vec<f32> = Vec::new();
for poly in &out.cut_polygons {
for poly in &polys {
let pts = &poly.pts;
if pts.len() < 3 {
continue;
}
// Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
// Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
// cos/sin rechnen kann.
let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
Some(h) => (
h.pattern as f32,
h.angle.to_radians(),
h.scale.max(0.05),
h.line_weight.max(0.0),
),
None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
};
let mut push_vertex = |uv: [f32; 2]| {
let w = to_world(uv);
verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]);
verts.extend_from_slice(&[
w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
]);
};
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
for i in 1..pts.len() - 1 {
@@ -80,3 +154,429 @@ pub fn build_cut_caps(
}
verts
}
/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
///
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
pub fn build_cut_cap_lines(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
let normal = plane.normal;
// Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
let lift = [
-normal[0] * CUT_LINE_LIFT,
-normal[1] * CUT_LINE_LIFT,
-normal[2] * CUT_LINE_LIFT,
];
let to_world = |uv: [f32; 2]| -> [f32; 3] {
[
point[0] + uv[0] * u_axis[0] + lift[0],
uv[1] + lift[1],
point[2] + uv[0] * u_axis[2] + lift[2],
]
};
let mut verts: Vec<f32> = Vec::new();
for poly in &polys {
let Some(r) = rect_of_poly(poly) else {
continue;
};
// Stil aus den Band-Metadaten (oder Default).
let (color, weight_mm) = match &poly.cut {
Some(meta) => (
meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
),
None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
};
let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);
// Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
let corners = [
[r.u_min, r.v_min],
[r.u_max, r.v_min],
[r.u_max, r.v_max],
[r.u_min, r.v_max],
];
for k in 0..4 {
let a = to_world(corners[k]);
let b = to_world(corners[(k + 1) % 4]);
push_line_ribbon(&mut verts, a, b, normal, half_w, color);
}
}
verts
}
/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
fn push_line_ribbon(
out: &mut Vec<f32>,
a: [f32; 3],
b: [f32; 3],
normal: [f32; 3],
half_w: f32,
color: [f32; 3],
) {
let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
if len < 1e-9 {
return;
}
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
// In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
// steht senkrecht auf der Kante.
let mut perp = [
normal[1] * d[2] - normal[2] * d[1],
normal[2] * d[0] - normal[0] * d[2],
normal[0] * d[1] - normal[1] * d[0],
];
let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
if plen < 1e-9 {
return;
}
perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
let mut push = |p: [f32; 3]| {
out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
};
// Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
push(p0);
push(p1);
push(p2);
push(p0);
push(p2);
push(p3);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Hatch;
/// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
/// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
/// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
#[test]
fn hatch_muster_id_landet_in_den_cap_vertices() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch {
pattern: 3, // crosshatch
angle: 90.0,
scale: 2.0,
line_weight: 0.5,
}),
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
assert_eq!(
verts.len() % CAP_FLOATS_PER_VERTEX,
0,
"Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
);
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
assert!(
(verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
"angle in Radiant"
);
assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
}
}
/// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
/// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
#[test]
fn ohne_hatch_fallback_auf_diagonale() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty());
assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
}
use crate::types::CutBandMeta;
/// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
/// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
/// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
/// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
/// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
#[test]
fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
// Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
// z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
let slab = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 2.0,
z_top: 2.4,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(100),
component_id: Some("concrete".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[slab]);
assert!(!verts.is_empty());
// Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
// (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
// Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut wall_verts = 0;
let mut concrete_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let y = verts[base + 1];
let pattern = verts[base + 5];
if (pattern - 2.0).abs() < 0.5 {
wall_verts += 1;
assert!(
y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
"Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
);
} else if (pattern - 1.0).abs() < 0.5 {
concrete_verts += 1;
}
}
assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
}
/// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
/// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
/// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
/// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
/// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
/// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
/// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
#[test]
fn wand_schneidet_schwaechere_decken_schichten() {
// Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
// volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
// Wand in u ab (Umriss ueber y in [-1,1]).
let screed = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 1.0,
z_top: 1.06,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(30),
component_id: Some("screed".into()),
line_color: None,
line_weight: None,
}),
};
// Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
let insulation = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 0.96,
z_top: 1.0,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(20),
component_id: Some("insulation".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
assert!(!verts.is_empty());
// Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
// Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
// der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut weak_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
let pattern = verts[base + 5];
if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
weak_verts += 1;
assert!(
u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
"Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
[-0.2,0.2] (muesste weggeschnitten sein)"
);
}
}
assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
}
/// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
/// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
/// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
/// die Ribbon-Breite messbar).
#[test]
fn build_cut_cap_lines_kantenzahl_und_stil() {
let color = [0.2, 0.4, 0.9];
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(2.0),
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
assert!(!verts.is_empty());
assert_eq!(
verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
0,
"Vielfaches des Cut-Linien-Strides"
);
// EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
assert_eq!(
verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
24,
"4 Kanten * 6 Vertices je Ribbon"
);
// Farbe je Vertex aus dem Stil (Index 3/4/5).
let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CUT_LINE_FLOATS_PER_VERTEX;
assert!((verts[base + 3] - color[0]).abs() < 1e-6);
assert!((verts[base + 4] - color[1]).abs() < 1e-6);
assert!((verts[base + 5] - color[2]).abs() < 1e-6);
}
// Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
// Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
// world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
// gesamten Geometrie) im Vergleich duenn vs. dick.
let bbox_y = |vs: &[f32]| -> f32 {
let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for i in 0..n {
let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
lo = lo.min(y);
hi = hi.max(y);
}
hi - lo
};
let mut thin = wall.clone();
thin.cut = Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(0.2),
});
let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
assert!(
bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
"dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
);
}
}
+188 -22
View File
@@ -144,15 +144,20 @@ struct Globals {
};
@group(0) @binding(0) var<uniform> globals : Globals;
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) ->
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
@group(1) @binding(0) var tex : texture_2d<f32>;
// Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe
// gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
// Ebene 0 = Fallback-Schachbrett; Ebenen 1..N = per `set_material_textures`
// hochgeladene Material-Farbkarten. Die Ebene je Vertex kommt aus einem ZWEITEN
// Vertex-Buffer (@location(3)); `mode.z` traegt die Anzahl gueltiger Ebenen.
@group(1) @binding(0) var tex : texture_2d_array<f32>;
@group(1) @binding(1) var samp : sampler;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) uv : vec2<f32>,
// Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett).
@location(3) layer : f32,
};
struct VsOut {
@@ -160,6 +165,7 @@ struct VsOut {
@location(0) world_normal : vec3<f32>,
@location(1) uv : vec2<f32>,
@location(2) world_pos : vec3<f32>,
@location(3) @interpolate(flat) layer : f32,
};
@vertex
@@ -169,6 +175,7 @@ fn vs_main(in : VsIn) -> VsOut {
out.world_normal = in.normal;
out.uv = in.uv;
out.world_pos = in.position;
out.layer = in.layer;
return out;
}
@@ -195,8 +202,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
* vec3<f32>(0.24, 0.27, 0.30);
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe.
let albedo = textureSample(tex, samp, in.uv).rgb;
// Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex:
// `layer < 0` (kein Material) -> Ebene 0 (Schachbrett). Sonst auf den
// gueltigen Bereich [0, mode.z-1] geklemmt (Sicherheit gegen ueberzaehlige
// Indizes, falls die Material-Menge kleiner ist als erwartet).
let max_layer = max(i32(globals.mode.z) - 1, 0);
let li = clamp(i32(round(in.layer)), 0, max_layer);
let albedo = textureSample(tex, samp, in.uv, li).rgb;
var shaded = albedo * (ambient + diffuse + fill);
@@ -208,6 +220,90 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
}
"#;
/// WGSL des LUFTBILD-Drapierens aufs Gelände (`gpu::aerial_pipeline`). Zeichnet die
/// TERRAIN-Dreiecke ein zweites Mal, texturiert mit EINEM georeferenzierten Luftbild
/// (SWISSIMAGE). Die UV kommen NICHT aus einem Vertex-Attribut, sondern werden im
/// Fragment aus der WELT-Lage (world.x = Ost, world.z = Nord) und der Bild-Bounding-
/// Box (group 1, Uniform `bbox = (min_x, min_y, max_x, max_y)` in Modell-Metern)
/// berechnet — dadurch ist die Auflösung PIXELSCHARF (unabhängig von der TIN-Dichte,
/// anders als die Pro-Vertex-Färbung). Beleuchtung identisch zu `MESH_WGSL`. Vertex-
/// Layout = das Haupt-Mesh-Layout `[pos, normal, color]` (color wird ignoriert), damit
/// derselbe Vertex-Puffer (Terrain-Overlay) ohne Re-Meshing genutzt werden kann.
/// Bild-Zeile 0 liegt im NORDEN (+Y) -> die Textur-v-Achse wird gespiegelt (1 - v).
pub const MESH_AERIAL_WGSL: &str = r#"
struct Globals {
view_proj : mat4x4<f32>,
light_dir : vec4<f32>,
sky_color : vec4<f32>,
ground_color : vec4<f32>,
sun_color : vec4<f32>,
mode : vec4<f32>,
section_plane : vec4<f32>,
};
@group(0) @binding(0) var<uniform> globals : Globals;
// Luftbild-Textur + Sampler + Bounding-Box (group 1). `bbox = (min_x, min_y,
// max_x, max_y)` in Modell-Metern (x = Ost/world.x, y = Nord/world.z).
@group(1) @binding(0) var tex : texture_2d<f32>;
@group(1) @binding(1) var samp : sampler;
struct Aerial { bbox : vec4<f32>, };
@group(1) @binding(2) var<uniform> aerial : Aerial;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) color : vec3<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) world_normal : vec3<f32>,
@location(1) world_pos : vec3<f32>,
};
@vertex
fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.world_normal = in.normal;
out.world_pos = in.position;
return out;
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
// Live-Schnitt-Kappung identisch zur Mesh-Pipeline (mode.y > 0.5 = aktiv).
if (globals.mode.y > 0.5) {
if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) {
discard;
}
}
let n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz);
// Hemisphaerisches Ambient (identisch zu MESH_WGSL).
let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t);
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
let fill_l = normalize(vec3<f32>(-l.x, abs(l.y) * 0.35 + 0.15, -l.z));
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
* vec3<f32>(0.24, 0.27, 0.30);
// UV aus der Welt-Lage + Bild-Bbox. world.x = Ost, world.z = Nord.
let span = vec2<f32>(aerial.bbox.z - aerial.bbox.x, aerial.bbox.w - aerial.bbox.y);
let u = (in.world_pos.x - aerial.bbox.x) / span.x;
let v = (in.world_pos.z - aerial.bbox.y) / span.y;
// Bild-Zeile 0 = Nord (+Y) -> v spiegeln. Ausserhalb [0,1] -> Randpixel (Clamp
// uebernimmt der Sampler mit ClampToEdge).
let albedo = textureSample(tex, samp, vec2<f32>(u, 1.0 - v)).rgb;
var shaded = albedo * (ambient + diffuse + fill);
let edge = 0.90 + 0.10 * abs(n.y);
shaded = shaded * edge;
return vec4<f32>(shaded, 1.0);
}
"#;
/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline:
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
@@ -261,11 +357,16 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
"#;
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2],
/// Eigene TriangleList-Pipeline: Vertex-Layout [pos vec3, uv vec2, hatch vec3],
/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale
/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern —
/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention.
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE
/// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus
/// dem `hatch`-Vertex-Attribut (pattern, angle_rad, scale; siehe
/// `section_fill.rs`/`types::Hatch`). MONOCHROM (dunkle Tinte auf hellem Papier,
/// passend zur 2D-Schnittkonvention `HATCH_INK`): es variiert das MUSTER, nicht die
/// Farbe. Muster-Ids: 0 = none (leer/weiss), 1 = solid (Vollton),
/// 2 = diagonal (Linienschar, `angle` steuert die Neigung — 0° = horizontal, wie
/// im 2D), 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung).
pub const CAP_WGSL: &str = r#"
struct Globals {
view_proj : mat4x4<f32>,
@@ -281,11 +382,18 @@ struct Globals {
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) uv : vec2<f32>,
// hatch: x = Muster-Id, y = Winkel (Radiant), z = Skalierung, w = Musterlinien-
// Staerke (mm-Papier). Je Cut-Polygon konstant (redundant je Vertex, siehe
// section_fill.rs).
@location(2) hatch : vec4<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) uv : vec2<f32>,
// flat: die Muster-Parameter sind je Flaeche konstant und duerfen nicht
// interpoliert werden (Muster-Id ist eine Ganzzahl-Auswahl).
@location(1) @interpolate(flat) hatch : vec4<f32>,
};
@vertex
@@ -293,27 +401,85 @@ fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.uv = in.uv;
out.hatch = in.hatch;
return out;
}
// Ein AA-gekantetes Linienband entlang der Koordinate `s` (in Einheiten von
// `spacing`): 1 auf der Linie, 0 dazwischen. `fwidth` glaettet blickwinkelrichtig.
fn line_band(s : f32, half_s : f32) -> f32 {
let frac = fract(s);
let dist = min(frac, 1.0 - frac);
let aa = max(fwidth(s), 1e-5);
return 1.0 - smoothstep(half_s, half_s + aa, dist);
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
// 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen
// Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten
// von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm-
// groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel).
let spacing = 0.08; // Linienabstand ~8 cm
let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm)
let ink = vec3<f32>(0.06, 0.06, 0.06);
let paper = vec3<f32>(0.94, 0.94, 0.94);
let s = (in.uv.x + in.uv.y) / spacing;
let frac = fract(s);
let dist = min(frac, 1.0 - frac); // 0 auf der Linie
let half_s = half / spacing;
let aa = max(fwidth(s), 1e-5);
let line = 1.0 - smoothstep(half_s, half_s + aa, dist);
let color = mix(paper, ink, line);
let pattern = i32(round(in.hatch.x));
let angle = in.hatch.y;
let scale = max(in.hatch.z, 0.05);
// none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D.
if (pattern == 0) {
return vec4<f32>(paper, 1.0);
}
// solid (1): Vollton (Poché) — z. B. Beton.
if (pattern == 1) {
return vec4<f32>(ink, 1.0);
}
// Muster-Koordinaten: (u,v) in Modell-Metern, um `angle` gedreht. Konvention
// wie die 2D-Schraffur (`hatchPreview.parallelLines`): `angle == 0` ergibt
// HORIZONTALE Linien (Phasen-Achse = dv), `+angle` dreht die Schar gegen den
// Uhrzeigersinn wie im 2D-Swatch. `du` laeuft ENTLANG der Linienrichtung, `dv`
// ist die Quer-/Phasen-Achse. (Frueher lag hier ein fixer 45°-Versatz, weil das
// Diagonalmuster ueber `du+dv` lief — dadurch war alles 45° verdreht.)
let ca = cos(angle);
let sa = sin(angle);
let du = in.uv.x * ca + in.uv.y * sa; // entlang der Linienrichtung
let dv = -in.uv.x * sa + in.uv.y * ca; // quer (Phase)
let spacing = 0.08 * scale; // Grund-Linienabstand ~8 cm * Skalierung
// Musterlinien-Staerke PRO Schraffur aus `hatch.w` (mm-Papier, aus dem LineStyle
// des Hatch — wie die 2D-Schnitt-Schraffur: SIA `hatch-hair` 0.02 mm vs. `thin`
// 0.13 mm). Dieselbe mm→Welt-Kalibrierung wie die Schicht-Trennlinien
// (`section_fill::CUT_LINE_MM_TO_WORLD` = 0.01, halbe Breite ×0.5 → Faktor 0.005),
// damit Schraffur- und Trennlinien-Staerke konsistent skalieren. Kleiner Floor,
// damit eine Haarlinie nicht ganz verschwindet.
let half = max(in.hatch.w * 0.005, 0.0001);
let half_s = half / spacing;
var line = 0.0;
if (pattern == 2) {
// diagonal: EINE Linienschar quer zur Phasen-Achse (dv). angle==0 →
// horizontal; `hatch.angle` (z. B. 45° bei Backstein) dreht sie wie im 2D.
line = line_band(dv / spacing, half_s);
} else if (pattern == 3) {
// crosshatch: zwei Scharen ueber Kreuz (Phase dv + Phase du = +90°). Bei
// angle==0 horizontal+vertikal, bei 45° (Beton) das gekippte Kreuz — wie 2D.
line = max(
line_band(dv / spacing, half_s),
line_band(du / spacing, half_s)
);
} else {
// insulation (4, Daemmung): Zickzack. Die Quer-Position (dv) folgt einer
// Dreieckswelle entlang der Lauf-Achse (du) — ein durchgehender Zickzack.
let cell = spacing * 2.0;
let a = du / cell;
let b = dv / cell;
let tri = abs(fract(a) - 0.5) * 2.0; // 0..1 Dreieckswelle
let fb = fract(b);
let d = min(abs(fb - tri), min(abs(fb - tri + 1.0), abs(fb - tri - 1.0)));
let aa = max(fwidth(fb) + fwidth(tri), 1e-5);
let hw = half / cell;
line = 1.0 - smoothstep(hw, hw + aa, d);
}
let color = mix(paper, ink, clamp(line, 0.0, 1.0));
return vec4<f32>(color, 1.0);
}
"#;
+152 -4
View File
@@ -20,6 +20,79 @@ pub type Point2 = [f32; 2];
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
pub type Rgb = [f32; 3];
/// Schnitt-Schraffur eines Bauteils (fuer den 3D-Live-Schnitt). Rust-seitiges
/// Minimal-Aequivalent zu `HatchStyle` (src/model/types.ts): nur die drei Felder,
/// die der prozedurale Cap-Shader (`shaders::CAP_WGSL`) auswertet — Muster-Id,
/// Winkel und Skalierung. Die Muster-Farbe bleibt MONOCHROM (Tinte auf Papier,
/// siehe 2D-Direktive `HATCH_INK`) und wandert deshalb bewusst NICHT mit.
///
/// `pattern`: 0 = none (leer/weiss), 1 = solid (Vollton), 2 = diagonal (45°),
/// 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). Die Zuordnung ist
/// mit der TS-Seite (`toWalls3d::hatchPatternId`) und dem Shader synchron zu halten.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Hatch {
/// Muster-Id (siehe oben). Default 0 (none).
#[serde(default)]
pub pattern: u32,
/// Musterdrehung in Grad (Bildschirm-/Schnittebenen-Konvention). Default 0.
#[serde(default)]
pub angle: f32,
/// Grundmassstab (1 = Standardteilung). Default 1.
#[serde(default = "default_hatch_scale")]
pub scale: f32,
/// Strichstaerke der Musterlinien in mm-Papier (aus dem LineStyle des Hatch,
/// `HatchRender.lineWeight`). Der Cap-Shader (`CAP_WGSL`) setzt die Muster-
/// linienbreite daraus (mm->Welt wie die Trennlinien), sodass die Schraffur-
/// Staerke pro Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs.
/// `thin` 0.13 mm). Default 0.13 (Alt-Verhalten/Haarlinie).
#[serde(default = "default_hatch_line_weight", rename = "lineWeight")]
pub line_weight: f32,
}
fn default_hatch_scale() -> f32 {
1.0
}
fn default_hatch_line_weight() -> f32 {
0.13
}
/// Verschneidungs-/Stil-Metadaten EINES geschnittenen Bauteil-Bandes fuer den
/// 3D-Live-Schnitt — das Rust-Gegenstueck zu den Feldern, mit denen `toSection.ts`
/// die Schnitt-Boolean-Dominanz (`subtractDominantBands`) rechnet. Rein additiv
/// (`#[serde(default)]` an jedem Feld); fehlt der ganze Block (`WallInput::cut ==
/// None`), verhaelt sich der Schnitt wie zuvor (Band nimmt an keiner Subtraktion
/// teil).
///
/// Da der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`/`SlabInput`-Box
/// emittiert (`toWalls3d`, layered), traegt jede geschnittene Schicht hier IHRE
/// eigene Prioritaet/Identitaet/Linienstil — die Dominanz-Verschneidung
/// (`section_boolean.rs`) laeuft dann elementuebergreifend auf den erzeugten
/// (u,v)-Rechtecken, exakt wie im 2D-Schnitt.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CutBandMeta {
/// `Component.joinPriority` der Schicht: ein Band mit STRIKT hoeherer
/// Prioritaet schneidet ein ueberlappendes schwaecheres per Rechteck-
/// Subtraktion weg (elementuebergreifend). `None` -> keine Teilnahme.
#[serde(default)]
pub join_priority: Option<i32>,
/// `Component.id` der Schicht — steuert die MERGE-Regel (gleiche Komponente +
/// Prioritaet verschmelzen zu einem Koerper, keine innere Trennlinie). `None`
/// -> keine Verschmelzung.
#[serde(default)]
pub component_id: Option<String>,
/// Farbe (RGB 0..1) der Schnitt-Umrisslinie dieses Bandes (P2, aus dem
/// `jointLineStyleId`-LineStyle). `None` -> Default-Schnittkanten-Stil.
#[serde(default)]
pub line_color: Option<Rgb>,
/// Strichstaerke der Schnitt-Umrisslinie in mm-Papier (aus dem LineStyle).
/// `None` -> Default. Siehe `section_fill::build_cut_cap_lines` fuer die (ehrlich
/// dokumentierte) mm->Welt-Umsetzung.
#[serde(default)]
pub line_weight: Option<f32>,
}
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
@@ -82,6 +155,30 @@ pub struct WallInput {
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
#[serde(default)]
pub holes: Vec<Hole>,
/// Optionaler Material-Textur-Index fuer den Stil `Textured`: verweist auf eine
/// per `Renderer::set_material_textures` hochgeladene Material-Farbkarte
/// (Textur-Array-Ebene, 1-basiert; Ebene 0 ist das Fallback-Schachbrett). Da
/// der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede Schicht so IHRE Material-
/// Textur. `None` -> Fallback-Schachbrett (bisheriges Verhalten). Vom
/// Schnitt/Serde-Pfad (`section.rs`) ignoriert.
#[serde(default, rename = "materialIndex")]
pub material_index: Option<u32>,
/// Optionale Schnitt-Schraffur dieses Wand-Bandes (siehe `Hatch`). Nur der
/// 3D-Live-Schnitt (`section.rs`/`section_fill.rs`) wertet sie aus, um die
/// Schnittflaeche im MUSTER des Bauteils zu schraffieren (statt eines fixen
/// 45°-Musters). `None` -> Fallback-Diagonalmuster (Alt-Verhalten). Da der
/// 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede geschnittene Schicht so
/// ihre eigene Schraffur.
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
/// `CutBandMeta`). Nur der 3D-Live-Schnitt (`section_fill`/`section_boolean`)
/// wertet sie aus; fehlt sie, bleibt das Alt-Verhalten (keine Dominanz-
/// Subtraktion, Default-Schnittkante).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
@@ -162,6 +259,13 @@ pub struct SlabInput {
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
#[serde(default = "default_slab_color")]
pub color: Rgb,
/// Optionale Schnitt-Schraffur der Decke (siehe `Hatch`/`WallInput::hatch`).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe
/// `CutBandMeta`/`WallInput::cut`).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
fn default_slab_color() -> Rgb {
@@ -179,6 +283,9 @@ pub enum MeshKind {
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
#[default]
Imported,
/// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper —
/// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt.
Extrusion,
}
impl MeshKind {
@@ -191,6 +298,8 @@ impl MeshKind {
MeshKind::Terrain => [0.663, 0.690, 0.635],
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
MeshKind::Imported => [0.624, 0.651, 0.682],
// Warmes Orange fuer truck-Profil-Extrusionen (Phase-1-Viewer-Beweis).
MeshKind::Extrusion => [0.85, 0.55, 0.25],
}
}
}
@@ -227,6 +336,20 @@ pub struct MeshInput {
/// Default-Farbe je `kind` (siehe `effective_color`).
#[serde(default)]
pub color: Option<Rgb>,
/// Optionaler Material-Textur-Index fuer den Stil `Textured` (1-basiert,
/// Ebene 0 = Fallback-Schachbrett) — analog `WallInput::material_index`.
/// Bislang nutzen das nur Daecher (`toWalls3d.ts::emitRoofs`); andere
/// Kontext-Meshes (Terrain/Import) bleiben ohne (`None` -> Schachbrett).
#[serde(default, rename = "materialIndex")]
pub material_index: Option<u32>,
/// Optionale PRO-VERTEX-Albedo (flaches `[r,g,b, r,g,b, ...]`, 0..1) — ein
/// Wert je Positions-Vertex. Wird u. a. genutzt, um ein SWISSIMAGE-Luftbild
/// auf das Gelaende-TIN zu „drapieren" (jeder Vertex traegt den Bildpunkt an
/// seiner Lage). Ist das Array leer oder zu kurz, greift die Einzelfarbe
/// (`effective_color`) fuer das ganze Mesh. Die Farben werden im Shaded-Pfad
/// pro Vertex interpoliert (kein Textur-Sampler noetig).
#[serde(default, rename = "vertexColors")]
pub vertex_colors: Vec<f32>,
}
impl MeshInput {
@@ -289,8 +412,15 @@ fn default_ortho_half_height() -> f32 {
fn default_near() -> f32 {
0.05
}
fn default_far() -> f32 {
1000.0
// `pub(crate)`, damit `web::set_camera` denselben Basiswert als Untergrenze fuer
// die modellgroessen-abhaengige Far-Ebene nutzen kann (s. Moduldoc dort).
// War 1000.0 — Nutzer-Report: Teile des Modells (und das Boden-Referenzgitter)
// verschwanden abhaengig von Kamera-Distanz/-Winkel. Grosszuegig angehoben,
// damit im ueblichen Gebrauch (auch grosse/georeferenzierte Situationen) nie
// etwas am Fern-Clipping haengenbleibt; `set_camera` skaliert ohnehin
// zusaetzlich mit der tatsaechlichen Modell-Bounding-Box nach oben.
pub(crate) fn default_far() -> f32 {
50_000.0
}
impl Default for Camera {
@@ -308,14 +438,24 @@ impl Default for Camera {
}
}
/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch).
/// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei
/// perspektivisch; alle anderen sind achsparallel bzw. diagonal orthografisch.
/// `Front`/`Back`/`Side`/`Left` sind die vier Kardinalrichtungen (Vorne/Hinten/
/// Rechts/Links im UI, s. `View3d` in TopBar.tsx — keine Himmelsrichtungen, da
/// es noch keinen Nordwinkel gibt); `Iso`/`IsoFrontLeft`/`IsoBackRight`/
/// `IsoBackLeft` sind die vier OBEREN Iso-Oktanten (untere vier bleiben im
/// Hochbau ungenutzt) — `Iso` (vorne-oben-rechts) ist der bestehende Default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CameraPreset {
Front,
Back,
Top,
Side,
Left,
Iso,
IsoFrontLeft,
IsoBackRight,
IsoBackLeft,
Persp,
}
@@ -345,6 +485,14 @@ pub struct TexturedMesh {
pub verts: Vec<f32>,
/// Dreiecks-Indizes (0-basiert auf die Vertices).
pub indices: Vec<u32>,
/// Optionaler PARALLELER Puffer: EIN f32 je Vertex = die Material-Textur-
/// Ebene (Textur-Array-Index) dieses Vertex, oder `-1` fuer „kein Material"
/// (Fallback-Schachbrett, Ebene 0). Leer, wenn ohne Material-Zuordnung gebaut
/// (`textured_from_mesh`) — dann bindet die GPU-Schicht einen konstanten
/// `-1`-Puffer. Als ZWEITER Vertex-Buffer gehalten (nicht in `verts`
/// interleaved), damit das bestehende `[pos,normal,uv]`-Layout (und die
/// Geometrie-Paritaets-Tests) bitgleich bleiben.
pub layers: Vec<f32>,
}
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
+122 -14
View File
@@ -15,12 +15,13 @@
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete
// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/
// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte
// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung
// nicht leisten kann. Nach einem Praeset-Sprung bleibt die
// weitere Navigation frei ueber `set_camera` (kein Lock).
// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
// `set_camera` (kein Lock).
//
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
@@ -113,7 +114,11 @@ pub struct WebModelRenderer {
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
/// `None` bei leerem Modell.
fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [f32; 3])> {
fn model_bounds(
walls: &[WallInput],
slabs: &[SlabInput],
meshes: &[MeshInput],
) -> Option<([f32; 3], [f32; 3])> {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
let mut grow = |x: f32, y: f32, z: f32| {
@@ -146,6 +151,17 @@ fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [
grow(p[0], s.z_top, p[1]);
}
}
// Meshes (Daecher, Extrusionen, Stuetzen UND importierte Gebaeude/Terrain):
// model (x,y,z=Hoehe) -> world (x, z, y). Ohne sie kann ein Praeset/Fit den
// (evtl. weit entfernten) Import nicht rahmen.
for m in meshes {
let p = &m.positions;
let mut i = 0;
while i + 2 < p.len() {
grow(p[i], p[i + 2], p[i + 1]);
i += 3;
}
}
if !min[0].is_finite() {
return None;
}
@@ -246,7 +262,7 @@ impl WebModelRenderer {
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
let model: ModelInput = serde_json::from_str(json)
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
self.bounds = model_bounds(&model.walls, &model.slabs);
self.bounds = model_bounds(&model.walls, &model.slabs, &model.meshes);
self.renderer
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
@@ -299,6 +315,15 @@ impl WebModelRenderer {
/// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet
/// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp)
/// — siehe `useWasm3dRenderer`/`Wasm3DViewport`.
///
/// FAR-EBENE: die feste `Camera::default()`-Far-Ebene (1000 m) reicht bei
/// grossen georeferenzierten Importen (Suchradius bis 2000 m, Bounding-Kugel
/// entsprechend gross) nicht mehr — Teile des Modells wurden je nach
/// Blickwinkel/Distanz VOM FERN-CLIPPING abgeschnitten (Nutzer-Report: "Teile
/// im 3D nicht sichtbar in einem Winkel"). Die Far-Ebene wird deshalb hier je
/// Aufruf aus der zuletzt hochgeladenen Modell-Bounding-Box abgeleitet: der
/// weiteste Bbox-Eckpunkt vom Auge aus + Sicherheitsmarge, mindestens der
/// bisherige Default (kleine Modelle bleiben unveraendert).
#[allow(clippy::too_many_arguments)]
pub fn set_camera(
&mut self,
@@ -315,6 +340,32 @@ impl WebModelRenderer {
fov_y: f32,
ortho_half_height: f32,
) {
let far = match self.bounds {
Some((min, max)) => {
let corners = [
[min[0], min[1], min[2]],
[max[0], min[1], min[2]],
[min[0], max[1], min[2]],
[max[0], max[1], min[2]],
[min[0], min[1], max[2]],
[max[0], min[1], max[2]],
[min[0], max[1], max[2]],
[max[0], max[1], max[2]],
];
let mut max_dist = 0.0f32;
for c in corners {
let dx = c[0] - eye_x;
let dy = c[1] - eye_y;
let dz = c[2] - eye_z;
let d = (dx * dx + dy * dy + dz * dz).sqrt();
if d > max_dist {
max_dist = d;
}
}
(max_dist * 1.5).max(crate::types::default_far())
}
None => crate::types::default_far(),
};
self.camera = Camera {
eye: [eye_x, eye_y, eye_z],
target: [target_x, target_y, target_z],
@@ -326,23 +377,30 @@ impl WebModelRenderer {
},
fov_y,
ortho_half_height,
far,
..Camera::default()
};
}
/// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht
/// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf
/// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend
/// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch,
/// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
/// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
/// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
/// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der
/// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch,
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
let preset = match preset {
"front" => CameraPreset::Front,
"back" => CameraPreset::Back,
"top" => CameraPreset::Top,
"side" => CameraPreset::Side,
"left" => CameraPreset::Left,
"iso" => CameraPreset::Iso,
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
"isoBackRight" => CameraPreset::IsoBackRight,
"isoBackLeft" => CameraPreset::IsoBackLeft,
"perspective" => CameraPreset::Persp,
other => {
return Err(JsValue::from_str(&format!(
@@ -377,11 +435,14 @@ impl WebModelRenderer {
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
/// (identisch zum bisherigen `set_render_mode_white(true)`).
/// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"`
/// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht.
/// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
/// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
/// - `"shaded-edges"` — "Schattiert mit Kanten" (typischer BIM-Look): wie
/// `"shaded"` (echte Bauteilfarben), ZUSAETZLICH dunkle Modell-Kanten
/// obenauf wie bei `"hidden"`.
/// Unbekannte Werte fallen auf `"shaded"` zurueck. Wirkt erst beim naechsten
/// `render`. Loest `set_render_mode_white` ab (das bleibt kompatibel).
pub fn set_render_style(&mut self, style: &str) -> Result<(), JsValue> {
@@ -431,6 +492,53 @@ impl WebModelRenderer {
Ok(())
}
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch
/// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von
/// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert
/// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt
/// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen
/// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0`
/// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim
/// naechsten `render`.
pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> {
self.renderer
.set_material_textures(&self.device, &self.queue, rgba, layer_count);
Ok(())
}
/// Setzt/ersetzt das aufs Gelaende drapierte Luftbild (SWISSIMAGE). `rgba` =
/// dicht gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von OBEN = Nord),
/// die der JS-Aufrufer aus der Bild-Data-URL dekodiert (siehe
/// `viewport/aerialTexture.ts`). `min_x/min_y/max_x/max_y` = Modell-Meter-
/// Bounding-Box (x = Ost, y = Nord). Ungueltige/leere Eingabe loescht das
/// Luftbild. Wirkt erst beim naechsten `render`.
#[allow(clippy::too_many_arguments)]
pub fn set_aerial_texture(
&mut self,
rgba: &[u8],
width: u32,
height: u32,
min_x: f32,
min_y: f32,
max_x: f32,
max_y: f32,
) -> Result<(), JsValue> {
self.renderer.set_aerial_texture(
&self.device,
&self.queue,
rgba,
width,
height,
[min_x, min_y, max_x, max_y],
);
Ok(())
}
/// Loescht das drapierte Luftbild (Gelaende erscheint wieder einfarbig/shaded).
pub fn clear_aerial(&mut self) {
self.renderer.clear_aerial();
}
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
pub fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1));
+43 -3
View File
@@ -6,6 +6,32 @@
#[cfg(any(feature = "native2d", feature = "native3d"))]
mod native;
// Lock-Datei gegen gleichzeitiges Oeffnen derselben Projektdatei aus zwei
// App-Instanzen (OS-Advisory-Lock via `fs4`, siehe lock.rs).
mod lock;
use std::path::PathBuf;
use tauri::State;
/// Fordert den exklusiven Lock fuer eine Projektdatei an. `Ok(())` wenn frei
/// (oder schon von uns gehalten), sonst `Err(Some(LockInfo))` mit Angaben zur
/// haltenden Instanz (pid/hostname/timestamp), oder `Err(None)` falls die
/// Sidecar-Datei gesperrt, aber ihr Info-Block nicht lesbar war.
#[tauri::command]
fn acquire_project_lock(
path: String,
state: State<lock::LockState>,
) -> Result<(), Option<lock::LockInfo>> {
lock::acquire(&state, &PathBuf::from(path))
}
/// Gibt einen von dieser Instanz gehaltenen Lock frei. No-op, wenn der Pfad
/// nicht gelockt war.
#[tauri::command]
fn release_project_lock(path: String, state: State<lock::LockState>) {
lock::release(&state, &PathBuf::from(path));
}
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
#[tauri::command]
async fn compute_joins(
@@ -40,7 +66,15 @@ fn push_native_walls(walls: serde_json::Value) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default().setup(|_app| {
let builder = tauri::Builder::default()
// Native „Speichern unter"-Dialog + Datei-Schreiben aus der Webview.
// Immer aktiv (unabhaengig von native2d/native3d), damit Datei-Exporte
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
.manage(lock::LockState::default())
.setup(|_app| {
// Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
#[cfg(any(feature = "native2d", feature = "native3d"))]
@@ -52,10 +86,16 @@ pub fn run() {
let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins,
push_native_scene,
push_native_walls
push_native_walls,
acquire_project_lock,
release_project_lock
]);
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
let builder = builder.invoke_handler(tauri::generate_handler![compute_joins]);
let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins,
acquire_project_lock,
release_project_lock
]);
builder
.run(tauri::generate_context!())
+145
View File
@@ -0,0 +1,145 @@
// Lock-Datei gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei
// App-Instanzen. Prinzip: ein OS-Advisory-Lock (Crate `fs4`, gepflegter
// `fs2`-Nachfolger) auf einer Sidecar-Datei `<projektdatei>.lock`, gehalten
// über ein offenes `File`-Handle im Tauri-`State`. Der OS-Lock fällt beim
// Prozessende AUTOMATISCH weg (auch bei Absturz) — kein manuelles Aufräumen
// nötig, keine verwaiste Sperre blockiert dauerhaft: ohne aktiven OS-Lock kann
// die nächste Instanz dieselbe Sidecar-Datei problemlos wieder exklusiv locken,
// auch wenn die Datei selbst liegen bleibt.
//
// Zusätzlich schreiben wir einen kleinen JSON-Info-Block (pid/hostname/
// timestamp) in die Sidecar-Datei, damit eine zweite Instanz bei einem
// Konflikt eine freundliche Meldung zeigen kann ("bereits geöffnet auf …").
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
/// Info-Block in der Lock-Sidecar-Datei — wer hält den Lock gerade.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub pid: u32,
pub hostname: String,
/// Unix-Zeitstempel (Sekunden) als String — reicht für eine Anzeige,
/// keine Notwendigkeit für eine Datumsbibliothek.
pub timestamp: String,
}
impl LockInfo {
fn current() -> Self {
LockInfo {
pid: std::process::id(),
hostname: hostname(),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string()),
}
}
}
/// Bester-Versuch-Hostname ohne zusätzliche Abhängigkeit: erst Umgebungs-
/// variablen (Windows/Unix), sonst das `hostname`-Kommando, sonst "unbekannt".
fn hostname() -> String {
for var in ["COMPUTERNAME", "HOSTNAME"] {
if let Ok(h) = std::env::var(var) {
if !h.is_empty() {
return h;
}
}
}
if let Ok(out) = std::process::Command::new("hostname").output() {
if out.status.success() {
if let Ok(s) = String::from_utf8(out.stdout) {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
}
}
"unbekannt".to_string()
}
/// Alle von DIESER Instanz gehaltenen Locks: Projektdatei-Pfad → offenes
/// File-Handle auf die Sidecar-Lock-Datei. Das Handle hält den OS-Lock, solange
/// es lebt (Drop/Prozessende gibt ihn automatisch frei).
#[derive(Default)]
pub struct LockState(pub Mutex<HashMap<PathBuf, File>>);
fn sidecar_path(project_path: &Path) -> PathBuf {
let mut s = project_path.as_os_str().to_os_string();
s.push(".lock");
PathBuf::from(s)
}
/// Fordert den exklusiven Lock für `project_path` an.
///
/// - Bereits von UNS gehalten (dieselbe Datei nochmal geöffnet) → `Ok(())`.
/// - Frei → OS-Lock holen, eigenen Info-Block reinschreiben, Handle im State
/// behalten, `Ok(())`.
/// - Von einer ANDEREN Instanz gehalten → `Err(Some(info))` mit deren
/// Info-Block (falls lesbar), sonst `Err(None)`.
pub fn acquire(state: &LockState, project_path: &Path) -> Result<(), Option<LockInfo>> {
let mut held = state.0.lock().expect("lock state poisoned");
if held.contains_key(project_path) {
return Ok(());
}
let sidecar = sidecar_path(project_path);
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(&sidecar)
.map_err(|_| None)?;
match FileExt::try_lock_exclusive(&file) {
Ok(()) => {
write_info(&file);
held.insert(project_path.to_path_buf(), file);
Ok(())
}
Err(_) => {
// Gesperrt von einer anderen Instanz -> deren Info-Block lesen (best effort).
Err(read_info(&sidecar))
}
}
}
/// Gibt den von uns gehaltenen Lock für `project_path` frei (schliesst das
/// File-Handle → OS-Lock fällt weg). No-op, wenn wir diesen Pfad nicht halten.
pub fn release(state: &LockState, project_path: &Path) {
let mut held = state.0.lock().expect("lock state poisoned");
held.remove(project_path);
}
/// Schreibt den aktuellen Info-Block (pid/hostname/timestamp dieser Instanz)
/// in die bereits gelockte Sidecar-Datei. Fehler hier sind nicht fatal — der
/// Lock selbst (das OS-Lock) steht unabhängig davon schon.
fn write_info(file: &File) {
let info = LockInfo::current();
let Ok(json) = serde_json::to_string(&info) else {
return;
};
let mut f = file;
let _ = f.set_len(0);
let _ = f.seek(SeekFrom::Start(0));
let _ = f.write_all(json.as_bytes());
let _ = f.flush();
}
/// Liest den Info-Block aus einer (von einer anderen Instanz gesperrten)
/// Sidecar-Datei. `None` wenn die Datei fehlt/kein gültiges JSON enthält.
fn read_info(sidecar: &Path) -> Option<LockInfo> {
let mut contents = String::new();
File::open(sidecar).ok()?.read_to_string(&mut contents).ok()?;
serde_json::from_str(&contents).ok()
}
+6
View File
@@ -344,6 +344,12 @@ fn demo_walls() -> Vec<WallInput> {
color: grey,
openings: vec![],
layers: None,
// Additive Felder (Loecher/Material/Schnitt) — die Demo-Waende nutzen sie
// nicht; Defaults wie im Serde-Pfad.
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
vec![
mk([0.0, 0.0], [6.0, 0.0]),
+4 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "cad",
"productName": "Dossier",
"version": "0.1.0",
"identifier": "ch.dossier.cad",
"build": {
@@ -11,12 +11,13 @@
"windows": [
{
"label": "main",
"title": "cad",
"title": "Dossier",
"width": 1400,
"height": 900,
"dragDropEnabled": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true
"hiddenTitle": true,
"trafficLightPosition": { "x": 20, "y": 22 }
}
],
"security": {
+1488
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
[workspace]
[package]
name = "trucksolid"
version = "0.1.0"
edition = "2021"
description = "Profil-Extrusion via truck B-Rep → tesselliertes Mesh für render3d"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
web = [
"dep:wasm-bindgen",
"dep:serde_json",
"dep:console_error_panic_hook",
]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
# truck-modeling bringt truck-geometry/topology/polymesh transitiv mit (alle
# auf derselben kompatiblen Version). Nur truck-modeling explizit pinnen —
# keine separaten geometry/topology-Einträge, die einen Versions-Konflikt erzeugen.
truck-modeling = "0.3"
# Mesh-Ebenen-CSG (Boolesche Operationen) — bewusst NICHT truck-modeling-Booleans
# (instabil bei koinzidenten Flächen, siehe PENDENZEN.md truck-Integration Phase 4).
# csgrs arbeitet auf Dreiecks-Ebene (BSP-Baum), robust genau in diesem Fall.
# Gepinnt auf einen Git-Commit (crates.io-Veröffentlichungen 0.160.20.1 sind alle
# wegen einer harten, zurückgezogenen core2-Abhängigkeit nicht installierbar; auf
# dem unveröffentlichten main-Branch bereits behoben). Nutzerautorisiert (Spike).
csgrs = { git = "https://github.com/timschmidt/csgrs", rev = "5e7a37a8803d4e56617734687edc9b98f4ebeed7", default-features = false, features = ["f64", "bmesh", "mesh", "earcut"] }
nalgebra = "0.34"
[dev-dependencies]
serde_json = "1"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+212
View File
@@ -0,0 +1,212 @@
// Boolesche Operationen (Union/Differenz/Schnitt) zwischen zwei Dreiecks-Meshes.
// Mesh-Ebenen-CSG via csgrs (BSP-Baum) statt truck-modeling-Booleans — letztere
// sind bei koinzidenten/tangentialen Flächen instabil (siehe PENDENZEN.md,
// truck-Integration Phase 4, monstertruck-solid-Spike). csgrs erwies sich im
// Spike an genau diesem Fall (Extrusion bündig/eingebunden in eine Wand,
// deckungsgleicher Querschnitt) als exakt korrekt.
use csgrs::csg::CSG;
use csgrs::mesh::Mesh as CsgMesh;
use csgrs::polygon::Polygon;
use csgrs::triangulated::Triangulated3D;
use csgrs::vertex::Vertex;
use nalgebra::Point3;
use serde::{Deserialize, Serialize};
/// Eingabe-Mesh für eine boolesche Operation: flache f64-Positionen (Modell-
/// Meter) + Dreiecks-Indizes. Getrennt von `MeshOutput` (dort f32, da Render-
/// Ausgabe) — hier f64, da Eingabe aus Modelldaten und Präzision für die BSP-
/// Klassifikation an koinzidenten Flächen zählt.
#[derive(Deserialize)]
pub struct BooleanMeshInput {
pub a_positions: Vec<f64>,
pub a_indices: Vec<u32>,
pub b_positions: Vec<f64>,
pub b_indices: Vec<u32>,
/// "union" | "difference" | "intersection"
pub op: String,
}
#[derive(Serialize)]
pub struct BooleanMeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
/// Ob das Ergebnis leer ist (z. B. Schnitt zweier sich nur berührender
/// Körper) — csgrs liefert das als Trimesh-Fehler statt eines leeren
/// Meshes zurück, hier auf einen sauberen Fall normalisiert.
pub empty: bool,
}
fn mesh_from_triangles(positions: &[f64], indices: &[u32]) -> Result<CsgMesh<()>, String> {
if indices.len() % 3 != 0 {
return Err("indices müssen Dreiecke sein (Vielfaches von 3)".into());
}
let n_verts = positions.len() / 3;
let mut polygons = Vec::with_capacity(indices.len() / 3);
for tri in indices.chunks(3) {
let mut pts = [Point3::origin(); 3];
for (k, &i) in tri.iter().enumerate() {
let idx = i as usize;
if idx >= n_verts {
return Err(format!("Index {idx} außerhalb der Positions-Liste"));
}
let o = idx * 3;
pts[k] = Point3::new(positions[o], positions[o + 1], positions[o + 2]);
}
let normal = (pts[1] - pts[0]).cross(&(pts[2] - pts[0]));
let verts = vec![
Vertex::new(pts[0], normal),
Vertex::new(pts[1], normal),
Vertex::new(pts[2], normal),
];
polygons.push(Polygon::new(verts, ()));
}
Ok(CsgMesh::from_polygons(&polygons, ()))
}
fn triangles_from_mesh(m: &CsgMesh<()>) -> (Vec<f32>, Vec<u32>) {
let mut positions: Vec<f32> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut next = 0u32;
m.visit_triangles(|tri| {
for v in &tri {
positions.push(v.position.x as f32);
positions.push(v.position.y as f32);
positions.push(v.position.z as f32);
}
indices.push(next);
indices.push(next + 1);
indices.push(next + 2);
next += 3;
});
(positions, indices)
}
pub fn boolean_mesh_core(
a_positions: &[f64],
a_indices: &[u32],
b_positions: &[f64],
b_indices: &[u32],
op: &str,
) -> Result<BooleanMeshOutput, String> {
let a = mesh_from_triangles(a_positions, a_indices)?;
let b = mesh_from_triangles(b_positions, b_indices)?;
let result = match op {
"union" => a.union(&b),
"difference" => a.difference(&b),
"intersection" => a.intersection(&b),
other => return Err(format!("unbekannte boolesche Operation: {other}")),
};
let (positions, indices) = triangles_from_mesh(&result);
Ok(BooleanMeshOutput {
empty: indices.is_empty(),
positions,
indices,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cuboid(x: f64, y: f64, z: f64, w: f64, l: f64, h: f64) -> (Vec<f64>, Vec<u32>) {
let corners = [
(x, y, z),
(x + w, y, z),
(x + w, y + l, z),
(x, y + l, z),
(x, y, z + h),
(x + w, y, z + h),
(x + w, y + l, z + h),
(x, y + l, z + h),
];
let mut positions = Vec::with_capacity(24);
for (cx, cy, cz) in corners {
positions.push(cx);
positions.push(cy);
positions.push(cz);
}
// 12 Dreiecke, konsistent nach außen orientiert (Rechte-Hand-Regel).
let indices: Vec<u32> = vec![
0, 2, 1, 0, 3, 2, // unten (-z)
4, 5, 6, 4, 6, 7, // oben (+z)
0, 5, 4, 0, 1, 5, // -y
1, 6, 5, 1, 2, 6, // +x
2, 7, 6, 2, 3, 7, // +y
3, 4, 7, 3, 0, 4, // -x
];
(positions, indices)
}
fn volume_of(positions: &[f32], indices: &[u32]) -> f64 {
// Divergenztheorem (Tetraeder vom Ursprung), robust für beliebige geschlossene Dreiecksmeshes.
let mut vol = 0.0f64;
for tri in indices.chunks(3) {
let mut p = [[0.0f64; 3]; 3];
for (k, &i) in tri.iter().enumerate() {
let o = i as usize * 3;
p[k] = [
positions[o] as f64,
positions[o + 1] as f64,
positions[o + 2] as f64,
];
}
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
/ 6.0;
}
vol.abs()
}
#[test]
fn union_of_overlapping_cuboids() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "union").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 1.875).abs() < 1e-6);
}
#[test]
fn intersection_of_overlapping_cuboids() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "intersection").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 0.125).abs() < 1e-6);
}
/// Praxisfall: Extrusion 0.1m in eine Wand eingebunden, deckungsgleicher
/// Querschnitt — genau der Fall, an dem monstertruck-solid scheiterte.
#[test]
fn wall_minus_embedded_extrusion_matching_cross_section() {
let wall = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let embedded = cuboid(0.9, 0.0, 0.0, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&wall.0, &wall.1, &embedded.0, &embedded.1, "difference").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 0.9).abs() < 1e-6);
}
#[test]
fn flush_touching_union_is_exact() {
let a = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let b = cuboid(1.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&a.0, &a.1, &b.0, &b.1, "union").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 2.0).abs() < 1e-6);
}
#[test]
fn rejects_non_triangle_indices() {
let (ap, _) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let bad_indices = vec![0u32, 1, 2, 3];
assert!(boolean_mesh_core(&ap, &bad_indices, &ap, &bad_indices, "union").is_err());
}
#[test]
fn rejects_unknown_op() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
assert!(boolean_mesh_core(&ap, &ai, &ap, &ai, "xor").is_err());
}
}
+546
View File
@@ -0,0 +1,546 @@
// Profil-Extrusion via truck B-Rep. Erzeugt ein tesselliertes Mesh aus einem
// 2D-Polygon-Querschnitt (XY-Ebene, Modell-Meter) durch lineare Extrusion
// entlang +Z. Ausgabe ist kompatibel mit render3d::types::MeshInput.
//
// truck-Architektur:
// truck_modeling::builder — Vertex/Edge/Wire/Face bauen + tsweep (Validierung)
// Tessellierung — direkt aus den Eingangskoordinaten (Prismen-Geometrie)
// Bewusst NICHT genutzt: truck-modeling Booleans (instabil upstream).
// truck-polymesh wird nicht benötigt (keine Tess.-API für Solids in 0.3).
use serde::{Deserialize, Serialize};
use truck_modeling::*;
mod boolean;
pub use boolean::{boolean_mesh_core, BooleanMeshInput, BooleanMeshOutput};
/// Flaches [x0,y0, x1,y1, …] Array + Höhe → Extrusion.
#[derive(Deserialize)]
pub struct ExtrudePolyInput {
pub points: Vec<f64>,
pub height: f64,
/// Verjüngung 0.0 (Prisma, Default) … 1.0 (Spitze/Kegel-Pyramide). Fehlt
/// das Feld (ältere Aufrufer), greift serde-Default 0.0 — unverändertes
/// Prisma-Verhalten.
#[serde(default)]
pub taper: f64,
}
/// Tesselliertes Mesh, kompatibel mit render3d::types::MeshInput.
/// positions: flat [x,y,z, …] in Modell-Metern; indices: Dreiecks-Indizes.
#[derive(Serialize)]
pub struct MeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
}
/// Extrudiert ein geschlossenes Polygon (≥3 Punkte) um `height` Meter nach +Z,
/// optional verjüngt (`taper` 0.0 Prisma … 1.0 Spitze/Kegel-Pyramide — linear
/// zum Schwerpunkt skaliert). Nutzt truck::builder zur Validierung der
/// Grundfläche; tesselliert dann direkt aus den Koordinaten.
pub fn extrude_polygon_core(
pts: &[(f64, f64)],
height: f64,
taper: f64,
) -> std::result::Result<MeshOutput, String> {
if pts.len() < 3 {
return Err("min 3 Punkte".into());
}
if height <= 0.0 {
return Err("height muss > 0 sein".into());
}
if !(0.0..=1.0).contains(&taper) {
return Err("taper muss zwischen 0 und 1 liegen".into());
}
// Letzten Punkt entfernen falls er den ersten wiederholt (geschlossener Ring).
let ring: Vec<(f64, f64)> = {
let mut r = pts.to_vec();
if r.len() >= 2 {
let first = r[0];
let last = *r.last().unwrap();
if (first.0 - last.0).abs() < 1e-10 && (first.1 - last.1).abs() < 1e-10 {
r.pop();
}
}
r
};
if ring.len() < 3 {
return Err("min 3 eindeutige Punkte".into());
}
let n = ring.len();
// ── truck B-Rep: try_attach_plane validiert Planarität und Degenerierung ──
let verts: Vec<Vertex> = ring
.iter()
.map(|&(x, y)| builder::vertex(Point3::new(x, y, 0.0)))
.collect();
let edges: Vec<Edge> = (0..n)
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
.collect();
let wire = Wire::from_iter(edges);
// Gibt Err zurück bei degeneriertem / nicht-ebenem Profil.
let face = builder::try_attach_plane(&[wire]).map_err(|e| format!("{e}"))?;
// tsweep erzeugt den Solid — wir nutzen ihn zur Vollständigkeit, auch wenn
// wir ihn für die Tessellierung nicht direkt traversieren (nur fürs
// ungetaperte Prisma sinnvoll validiert; Verjüngung ist reine Tessellierung).
let _solid: Solid = builder::tsweep(&face, Vector3::new(0.0, 0.0, height));
let centroid = polygon_centroid(&ring);
// Deckel-/Boden-Triangulierung fuer BELIEBIGE (auch konkave) Profile per
// Ohr-Clipping — NICHT per Fächer-ab-Vertex-0 (der nur fuer konvexe bzw.
// von Vertex 0 aus sternfoermige Polygone korrekt ist; bei einem T-Traeger
// o.ae. erzeugt ein Fächer Phantom-Dreiecke quer durch die konkave Kerbe).
// `cap_tris` ist bereits in der Konvention orientiert, die der bisherige
// "Fächer ab Vertex 0" fuer KONVEXE Ringe erzeugt hat (a,b,c mit
// aufsteigendem Index), sodass die Deck-/Boden-Zuordnung unten unveraendert
// bleibt.
let cap_tris = ear_triangulate(&ring);
// Volle Verjüngung (taper ≈ 1): Spitze statt entartetem Deck-Ring — Kegel/
// Pyramide als n Boden-Vertices + 1 Spitzen-Vertex, sonst gäbe es
// Nulldreiecke (Deckfläche + halbe Seitenflächen) mit Kreuzprodukt-Normale
// (0,0,0) am Deck.
if taper >= 1.0 - 1e-9 {
let mut positions: Vec<f32> = Vec::with_capacity((n + 1) * 3);
for &(x, y) in &ring {
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
}
positions.extend_from_slice(&[centroid.0 as f32, centroid.1 as f32, height as f32]);
let apex = n as u32;
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() + n) * 3);
// Bodenfläche: wie im Prisma-Fall die zu "oben" umgekehrte Wicklung
// (CCW von unten).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[a as u32, c as u32, b as u32]);
}
// Seitenflächen: ein Dreieck je Kante zur Spitze.
for i in 0..n as u32 {
let j = (i + 1) % n as u32;
indices.extend_from_slice(&[i, j, apex]);
}
return Ok(MeshOutput { positions, indices });
}
// ── Tessellierung (Prisma bei taper=0, sonst Pyramidenstumpf) ──
// Für ein n-Eck ergeben sich:
// Bodenfläche: len(cap_tris) Dreiecke (Ohr-Clipping, konkav-sicher)
// Deckfläche: len(cap_tris) Dreiecke
// Seitenflächen: n * 2 Dreiecke (je Kante ein Rechteck → 2 Dreiecke)
let mut positions: Vec<f32> = Vec::with_capacity(2 * n * 3);
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() * 2 + n * 2) * 3);
// Vertices: Boden (0..n-1) gefolgt von Dach (n..2n-1) — Dach zum
// Schwerpunkt hin um (1-taper) skaliert (taper=0 ⇒ Prisma, unveränderte
// Kontur; 0<taper<1 ⇒ Pyramidenstumpf).
let scale = 1.0 - taper;
for &(x, y) in &ring {
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
}
for &(x, y) in &ring {
let tx = centroid.0 + (x - centroid.0) * scale;
let ty = centroid.1 + (y - centroid.1) * scale;
positions.extend_from_slice(&[tx as f32, ty as f32, height as f32]);
}
let base = 0u32;
let top = n as u32;
// Bodenfläche: umgekehrte Wicklung relativ zur Deckfläche (CCW von unten).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[base + a as u32, base + c as u32, base + b as u32]);
}
// Deckfläche: gleiche Wicklung wie `cap_tris` (CCW von oben).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[top + a as u32, top + b as u32, top + c as u32]);
}
// Seitenflächen: Rechteck pro Kante → 2 Dreiecke
for i in 0..n as u32 {
let j = (i + 1) % n as u32;
// Unteres Dreieck
indices.extend_from_slice(&[base + i, base + j, top + i]);
// Oberes Dreieck
indices.extend_from_slice(&[base + j, top + j, top + i]);
}
Ok(MeshOutput { positions, indices })
}
/// Signierte Fläche (Shoelace) — positiv bei CCW-Umlauf.
fn signed_area_f64(ring: &[(f64, f64)]) -> f64 {
let n = ring.len();
let mut a = 0.0;
let mut j = n - 1;
for i in 0..n {
a += ring[j].0 * ring[i].1 - ring[i].0 * ring[j].1;
j = i;
}
a * 0.5
}
/// Flächen-gewichteter Schwerpunkt eines einfachen Polygons (nicht der reine
/// Vertex-Mittelwert, der bei asymmetrischen/konkaven Profilen verzerrt ist).
/// Fallback auf Vertex-Mittelwert bei (nahezu) entarteter Fläche.
fn polygon_centroid(ring: &[(f64, f64)]) -> (f64, f64) {
let n = ring.len();
let a = signed_area_f64(ring);
if a.abs() < 1e-12 {
let (sx, sy) = ring.iter().fold((0.0, 0.0), |(sx, sy), &(x, y)| (sx + x, sy + y));
return (sx / n as f64, sy / n as f64);
}
let mut cx = 0.0;
let mut cy = 0.0;
for i in 0..n {
let (x0, y0) = ring[i];
let (x1, y1) = ring[(i + 1) % n];
let cross = x0 * y1 - x1 * y0;
cx += (x0 + x1) * cross;
cy += (y0 + y1) * cross;
}
(cx / (6.0 * a), cy / (6.0 * a))
}
/// Kreuzprodukt (b-a) x (c-a) im Grundriss.
#[inline]
fn cross2_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
}
/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
fn point_in_tri_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64), p: (f64, f64)) -> bool {
let d1 = cross2_f64(a, b, p);
let d2 = cross2_f64(b, c, p);
let d3 = cross2_f64(c, a, p);
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(has_neg && has_pos)
}
/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Rings — robust fuer
/// konvexe UND konkave Profile (z. B. T-Traeger, L-Profil, Freiform). Portiert
/// von `render3d::mesh::triangulate` (dort fuer Decken/Slabs genutzt) auf f64.
/// O(n^2), fuer Extrusionsprofile mit wenigen Ecken voellig ausreichend.
/// Liefert Dreiecke als (a,b,c)-Indextripel (0-basiert auf `ring`), in der
/// Wicklung des (intern auf CCW normalisierten) Rings.
fn ear_triangulate(ring: &[(f64, f64)]) -> Vec<(usize, usize, usize)> {
let n = ring.len();
if n < 3 {
return Vec::new();
}
let mut idx: Vec<usize> = (0..n).collect();
if signed_area_f64(ring) < 0.0 {
idx.reverse();
}
let mut tris: Vec<(usize, usize, usize)> = Vec::new();
let mut guard = 0usize;
let max_guard = n * n + 16;
while idx.len() > 3 && guard < max_guard {
guard += 1;
let mut clipped = false;
let m = idx.len();
for i in 0..m {
let i_prev = idx[(i + m - 1) % m];
let i_cur = idx[i];
let i_next = idx[(i + 1) % m];
let a = ring[i_prev];
let b = ring[i_cur];
let c = ring[i_next];
if cross2_f64(a, b, c) <= 0.0 {
continue; // konkav/kollinear -> kein Ohr
}
let mut contains = false;
for &vi in &idx {
if vi == i_prev || vi == i_cur || vi == i_next {
continue;
}
if point_in_tri_f64(a, b, c, ring[vi]) {
contains = true;
break;
}
}
if contains {
continue;
}
tris.push((i_prev, i_cur, i_next));
idx.remove(i);
clipped = true;
break;
}
if !clipped {
break;
}
}
if idx.len() == 3 {
tris.push((idx[0], idx[1], idx[2]));
}
tris
}
/// Zylinder-Extrusion (Kreis-Querschnitt), optional verjüngt (Kegel bei
/// `taper=1.0`). Tesselliert den Kreis in N Segmente und ruft
/// `extrude_polygon_core` auf.
pub fn extrude_circle_core(
cx: f64,
cy: f64,
r: f64,
height: f64,
taper: f64,
) -> std::result::Result<MeshOutput, String> {
if r <= 0.0 {
return Err("Radius muss > 0 sein".into());
}
let n = ((2.0 * std::f64::consts::PI * r / 0.02).ceil() as usize).max(16);
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
extrude_polygon_core(&pts, height, taper)
}
// ── WASM-Bindings (nur mit Feature "web") ────────────────────────────────────
#[cfg(feature = "web")]
mod web {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
/// Extrudiert ein Polygon-Profil.
/// Input JSON: `{ "points": [x0,y0,…], "height": 2.5 }`
/// Output JSON: `{ "positions": […], "indices": […] }` oder JsError.
#[wasm_bindgen]
pub fn extrude_polygon(input_json: &str) -> std::result::Result<String, JsError> {
let input: ExtrudePolyInput =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
if input.points.len() % 2 != 0 {
return Err(JsError::new("points muss x/y-Paare enthalten"));
}
let pts: Vec<(f64, f64)> = input
.points
.chunks(2)
.map(|c| (c[0], c[1]))
.collect();
let mesh = extrude_polygon_core(&pts, input.height, input.taper)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Extrudiert einen Kreis-Querschnitt (Zylinder, oder Kegel bei `taper=1`).
/// Input JSON: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0, "taper": 0.0 }`
#[wasm_bindgen]
pub fn extrude_circle(input_json: &str) -> std::result::Result<String, JsError> {
#[derive(serde::Deserialize)]
struct In {
cx: f64,
cy: f64,
r: f64,
height: f64,
#[serde(default)]
taper: f64,
}
let i: In =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height, i.taper)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Boolesche Operation zwischen zwei Dreiecks-Meshes (Mesh-Ebenen-CSG via
/// csgrs). Input JSON: `{ "a_positions": […], "a_indices": […],
/// "b_positions": […], "b_indices": […], "op": "union"|"difference"|"intersection" }`
/// Output JSON: `{ "positions": […], "indices": […], "empty": bool }`
#[wasm_bindgen]
pub fn boolean_mesh(input_json: &str) -> std::result::Result<String, JsError> {
let input: BooleanMeshInput =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
let mesh = boolean_mesh_core(
&input.a_positions,
&input.a_indices,
&input.b_positions,
&input.b_indices,
&input.op,
)
.map_err(|e| JsError::new(&e))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quad_extrusion_vertex_count() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
let m = extrude_polygon_core(&pts, 2.0, 0.0).unwrap();
// 2 * 4 = 8 Vertices → 24 Floats
assert_eq!(m.positions.len(), 8 * 3, "Vertex-Anzahl");
assert_eq!(m.indices.len() % 3, 0, "unvollständige Dreiecke");
// Quader: 2*(4-2) + 4*2 = 4 + 8 = 12 Dreiecke
assert_eq!(m.indices.len(), 12 * 3, "Dreieck-Anzahl für Quader");
}
#[test]
fn l_profile_extrusion() {
let pts = vec![
(0.0, 0.0),
(0.3, 0.0),
(0.3, 0.1),
(0.1, 0.1),
(0.1, 0.3),
(0.0, 0.3),
];
let m = extrude_polygon_core(&pts, 3.0, 0.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(!m.positions.is_empty());
}
#[test]
fn cylinder_extrusion() {
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0, 0.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(!m.positions.is_empty());
}
#[test]
fn rejects_too_few_points() {
assert!(extrude_polygon_core(&[(0.0, 0.0), (1.0, 0.0)], 1.0, 0.0).is_err());
}
#[test]
fn rejects_zero_height() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
assert!(extrude_polygon_core(&pts, 0.0, 0.0).is_err());
}
#[test]
fn rejects_taper_out_of_range() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
assert!(extrude_polygon_core(&pts, 1.0, 1.5).is_err());
assert!(extrude_polygon_core(&pts, 1.0, -0.1).is_err());
}
/// Kegel-Volumen (taper=1, Kreis-Querschnitt) via Divergenzsatz gegen die
/// analytische Formel V = π·r²·h/3 geprüft.
#[test]
fn cone_taper_one_has_correct_volume() {
let r = 0.5;
let h = 2.0;
let m = extrude_circle_core(0.0, 0.0, r, h, 1.0).unwrap();
// Spitze ist der letzte Vertex — Boden-Vertices sind alle bei z=0, kein
// separater Deck-Ring mehr (kein Nulldreieck-Risiko).
assert_eq!(m.positions.len() % 3, 0);
let n_verts = m.positions.len() / 3;
// Boden-N-Eck + 1 Spitze.
let apex_z = m.positions[(n_verts - 1) * 3 + 2];
assert!((apex_z as f64 - h).abs() < 1e-6, "Spitze muss bei z=height liegen");
let mut vol = 0.0f64;
for tri in m.indices.chunks(3) {
let mut p = [[0.0f64; 3]; 3];
for (k, &i) in tri.iter().enumerate() {
let o = i as usize * 3;
p[k] = [m.positions[o] as f64, m.positions[o + 1] as f64, m.positions[o + 2] as f64];
}
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
/ 6.0;
}
let expected = std::f64::consts::PI * r * r * h / 3.0;
// Kreis ist n-eck-tesselliert (nicht analytisch exakt) → grobe Toleranz.
assert!((vol.abs() - expected).abs() / expected < 0.01, "Kegel-Volumen: {} vs {}", vol.abs(), expected);
}
/// Pyramidenstumpf (taper=0.5, Quadrat-Querschnitt): Deckfläche muss auf
/// die Hälfte der Kantenlänge geschrumpft sein, zentriert um den
/// Schwerpunkt der Grundfläche.
#[test]
fn frustum_taper_half_shrinks_top_toward_centroid() {
let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)];
let m = extrude_polygon_core(&pts, 1.0, 0.5).unwrap();
// Prisma-Topologie bleibt erhalten (2n Vertices, kein Spitzen-Sonderfall).
assert_eq!(m.positions.len(), 8 * 3);
// Deck-Ring: Vertices 4..7. Schwerpunkt der Grundfläche ist (1,1).
// scale=0.5 ⇒ Ecke (0,0)→(1,1)+0.5*((0,0)-(1,1))=(0.5,0.5).
let top0 = (m.positions[4 * 3] as f64, m.positions[4 * 3 + 1] as f64);
assert!((top0.0 - 0.5).abs() < 1e-6 && (top0.1 - 0.5).abs() < 1e-6, "Deck-Ecke: {:?}", top0);
}
/// Punkt-in-Polygon (Ray-Casting) für die Korrektheits-Prüfung unten.
fn point_in_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
let n = poly.len();
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = poly[i];
let (xj, yj) = poly[j];
if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) {
inside = !inside;
}
j = i;
}
inside
}
/// T-Träger-Querschnitt (konkav, NICHT von Vertex 0 aus sternförmig) —
/// genau das Zielprofil aus PENDENZEN/truck-plan.md. Eine Fächer-Triangu-
/// lierung ab Vertex 0 erzeugt hier Phantom-Dreiecke quer durch die
/// konkave Kerbe (nachgerechnet: 2 von 6 Fächer-Dreiecken liegen mit ihrem
/// Schwerpunkt ausserhalb des Profils). Regressionstest fürs Ohr-Clipping.
#[test]
fn t_beam_caps_stay_inside_polygon() {
let pts = vec![
(1.0, 0.0),
(2.0, 0.0),
(2.0, 1.0),
(3.0, 1.0),
(3.0, 2.0),
(0.0, 2.0),
(0.0, 1.0),
(1.0, 1.0),
];
let m = extrude_polygon_core(&pts, 1.0, 0.0).unwrap();
let mut checked = 0;
for tri in m.indices.chunks(3) {
let p: Vec<[f32; 3]> = tri
.iter()
.map(|&i| {
let o = i as usize * 3;
[m.positions[o], m.positions[o + 1], m.positions[o + 2]]
})
.collect();
// Nur Deckel/Boden (alle 3 Ecken auf derselben Höhe) prüfen, nicht
// die Seitenwand-Quads (die spannen z zwischen 0 und height).
if (p[0][2] - p[1][2]).abs() > 1e-6 || (p[1][2] - p[2][2]).abs() > 1e-6 {
continue;
}
let centroid = (
(p[0][0] + p[1][0] + p[2][0]) as f64 / 3.0,
(p[0][1] + p[1][1] + p[2][1]) as f64 / 3.0,
);
assert!(
point_in_polygon(centroid, &pts),
"Deckel-/Boden-Dreieck-Schwerpunkt {:?} liegt ausserhalb des T-Profils",
centroid
);
checked += 1;
}
assert!(checked > 0, "kein Deckel-/Boden-Dreieck gefunden");
}
}
+2291 -1435
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -73,14 +73,14 @@ function circlePts(center: Vec2, r: number): Vec2[] {
return pts;
}
/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */
/** Vorschau (offener Bogen) + HUD (Radius als L, Spanne als W — L/W-Kästchen). */
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) {
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
draft.hud = { at, text: `${deg.toFixed(0)}°` };
draft.hud = { at, length: r, angleDeg: deg };
}
return draft;
}
@@ -148,7 +148,7 @@ export const arcCommand: Command = {
draft: {
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
vertices: [s.center],
hud: { at: point, text: `r ${r.toFixed(2)} m` },
hud: { at: point, text: `R: ${r.toFixed(3)}m` },
},
},
];
+133
View File
@@ -0,0 +1,133 @@
// Deckenloch — Aussparung (Treppenauge/Schacht) als Engine-Befehl: ein RECHTECK
// wird aufgezogen (zwei Klicks: erste Ecke → Gegenecke) und der Decke des
// aktiven Geschosses hinzugefügt, die das Rechteck ENTHÄLT (alle vier Ecken
// innerhalb des Decken-Umrisses). Gerendert wird das Loch in 2D (Poché-Loch +
// Randlinien) und 3D (Loch im Slab), s. Ceiling.openings/mergeHoles.
//
// Schritte:
// 1) „Erste Ecke der Aussparung:" → Punkt
// 2) „Gegenecke:" → Live-Rechteck-Vorschau; Klick committet.
import { pointInOutline } from "../../geometry/ceiling";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Vier Eckpunkte eines Rechtecks aus zwei Gegenecken (CCW). */
function rectCorners(a: Vec2, b: Vec2): Vec2[] {
const x0 = Math.min(a.x, b.x);
const x1 = Math.max(a.x, b.x);
const y0 = Math.min(a.y, b.y);
const y1 = Math.max(a.y, b.y);
return [
{ x: x0, y: y0 },
{ x: x1, y: y0 },
{ x: x1, y: y1 },
{ x: x0, y: y1 },
];
}
interface HoleIdle extends CommandState {
phase: "start";
}
interface HoleRect extends CommandState {
phase: "corner";
a: Vec2;
cursor: Vec2 | null;
}
type HoleState = HoleIdle | HoleRect;
/** Vorschau: aufgezogenes Rechteck + Masse im HUD. */
function holeDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
const pts = cursor ? rectCorners(a, cursor) : [a];
const preview: DraftShape[] = [{ kind: "poly", pts, closed: !!cursor }];
const draft: ToolDraft = { preview, vertices: [a] };
if (cursor) {
draft.hud = {
at: cursor,
text: `${Math.abs(cursor.x - a.x).toFixed(2)} × ${Math.abs(cursor.y - a.y).toFixed(2)} m`,
};
}
return draft;
}
/**
* Fügt die Aussparung der ERSTEN Decke des aktiven Geschosses hinzu, die das
* Rechteck vollständig enthält (alle 4 Ecken im Umriss). Entartete Rechtecke
* und Rechtecke ausserhalb jeder Decke werden verworfen (Projekt unverändert).
*/
function appendCeilingOpening(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
const w = Math.abs(b.x - a.x);
const d = Math.abs(b.y - a.y);
if (w < 0.05 || d < 0.05) return p;
const corners = rectCorners(a, b);
const host = (p.ceilings ?? []).find(
(c) =>
c.floorId === ctx.level.id &&
c.outline.length >= 3 &&
corners.every((pt) => pointInOutline(pt, c.outline)),
);
if (!host) return p;
return {
...p,
ceilings: (p.ceilings ?? []).map((c) =>
c.id === host.id ? { ...c, openings: [...(c.openings ?? []), corners] } : c,
),
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as HoleIdle,
{ draft: null, done: true },
];
export const ceilingOpeningCommand: Command = {
name: "ceilingopening",
labelKey: "cmd.ceilingOpening.label",
floorOnly: true,
prompt: (s) =>
(s as HoleState).phase === "corner" ? "cmd.ceilingOpening.corner" : "cmd.ceilingOpening.start",
accepts: () => ["point"],
options: () => [],
init: (): HoleIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as HoleState;
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner" ? holeDraft(s.a, s.cursor) : null }];
}
if (s.phase !== "corner") {
const ns: HoleRect = {
phase: "corner",
a: input.point,
cursor: input.point,
lastPoint: input.point,
};
return [ns, { draft: holeDraft(input.point, input.point) }];
}
// Zweite Ecke → committen.
const a = s.a;
const b = input.point;
return [
{ phase: "start", lastPoint: null } as HoleIdle,
{ draft: null, done: true, commit: (p) => appendCeilingOpening(p, a, b, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as HoleState;
if (s.phase !== "corner") return [s, { draft: null }];
const ns: HoleRect = { ...s, cursor: point };
return [ns, { draft: holeDraft(s.a, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+1 -1
View File
@@ -68,7 +68,7 @@ function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` };
if (at) draft.hud = { at, text: `R: ${r.toFixed(3)}m` };
return draft;
}
+152
View File
@@ -0,0 +1,152 @@
// Stütze (Column) als Engine-Befehl — DOSSIER-Audit A4 Tragwerk. Ablauf:
// 1) „Stütze setzen:" → Klick setzt den Einfügepunkt (Profil-Mitte) → commit.
//
// Optionen (togglebar, IMMER sichtbar): Profil Rechteck/Rund. Eine getippte Zahl
// setzt das Leitmass (Rechteck: Seitenlänge → quadratisch; Rund: Durchmesser)
// PERSISTENT über Aufrufe — analog `offset`/`extrude`, wo der pick-Schritt auch
// eine Zahl annimmt. Höhe = Geschosshöhe des aktiven Geschosses (Default).
//
// Herkunft der Stützen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "50" (Tragwerk; Fallback ctx.defaultCategoryCode)
// • height ← Geschosshöhe (ctx.level.floorHeight), Fallback DEF_HEIGHT
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Column, ColumnProfile } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { columnFootprint } from "../../geometry/column";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
Project,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Stützen: „50" Tragwerk (Fallback: aktive). */
const COLUMN_CATEGORY = "50";
/** Default-Kantenlänge eines Rechteckprofils (Meter). */
const DEF_SIZE = 0.3;
/** Fallback-Höhe, falls das Geschoss keine floorHeight trägt (Meter). */
const DEF_HEIGHT = 2.6;
// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ────
const columnDefaults: { kind: "rect" | "round"; size: number } = {
kind: "rect",
size: DEF_SIZE,
};
/** Baut das aktuelle Default-Profil aus `columnDefaults`. */
function currentProfile(): ColumnProfile {
if (columnDefaults.kind === "round") {
return { kind: "round", radius: columnDefaults.size / 2 };
}
return { kind: "rect", width: columnDefaults.size, depth: columnDefaults.size };
}
/** Fügt die Stütze immutabel an `project.columns` an. */
function appendColumn(
p: Project,
position: Vec2,
ctx: CommandContext,
): Project {
const height =
ctx.level.floorHeight != null && ctx.level.floorHeight > 0
? ctx.level.floorHeight
: DEF_HEIGHT;
const column: Column = {
id: uniqueId("column"),
type: "column",
floorId: ctx.level.id,
categoryCode: COLUMN_CATEGORY,
position,
profile: currentProfile(),
rotation: 0,
height,
};
return { ...p, columns: [...(p.columns ?? []), column] };
}
interface ColState extends CommandState {
phase: "place" | "done";
}
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true },
];
/** Footprint-Vorschau des aktuellen Default-Profils am Cursor. */
function previewAt(pt: Vec2): CommandResult {
const ghost: Column = {
id: "_preview",
type: "column",
floorId: "",
categoryCode: COLUMN_CATEGORY,
position: pt,
profile: currentProfile(),
rotation: 0,
height: 0,
};
const size =
columnDefaults.kind === "round"
? `Ø${(columnDefaults.size * 100).toFixed(0)}`
: `${(columnDefaults.size * 100).toFixed(0)}×${(columnDefaults.size * 100).toFixed(0)}`;
return {
draft: {
preview: [{ kind: "poly", pts: columnFootprint(ghost), closed: true }],
vertices: [],
hud: { at: pt, text: size },
},
};
}
export const columnCommand: Command = {
name: "column",
labelKey: "cmd.column.label",
prompt: () => "cmd.column.place",
accepts: () => ["point", "number", "option"],
options: (): CmdOption[] => [
{
id: "profile",
labelKey: "cmd.column.profile",
value: columnDefaults.kind === "round" ? "round" : "rect",
},
],
floorOnly: true,
init: (): ColState => ({ phase: "place", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase === "done") return finish();
if (input.kind === "number") {
const v = Math.abs(input.value);
if (v > 1e-4) columnDefaults.size = v;
return [s, { draft: null }];
}
if (input.kind === "option" && input.id === "profile") {
columnDefaults.kind = columnDefaults.kind === "round" ? "rect" : "round";
return [s, { draft: null }];
}
if (input.kind === "point") {
return [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true, commit: (p) => appendColumn(p, input.point, ctx) },
];
}
return [s, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase !== "place") return [s, { draft: null }];
return [s, previewAt(point)];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};
+12 -2
View File
@@ -42,10 +42,20 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
}
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
interface CopyBase extends CommandState {
+287
View File
@@ -0,0 +1,287 @@
// Extrudieren — truck-Integration Phase 3 (docs/design/truck-plan.md, PENDENZEN
// "truck-Integration"). Erzeugt aus einer geschlossenen 2D-Kurve (Polylinie-Ring,
// Rechteck oder Kreis) einen 3D-Körper: Schritte:
// 1) Profil bestimmen: ist genau EIN passendes geschlossenes Drawing2D
// selektiert, wird es genommen; sonst „Profil wählen:" → Klick auf einen Ring.
// 2) „Höhe:" → Zahl tippen (PERSISTENT als Default über Aufrufe) ODER Enter
// bestätigt den zuletzt genutzten Default.
// 3) „Verjüngung:" → Zahl 0..1 tippen (0=Prisma/Default, 1=Spitze — Kegel bei
// Kreis-, Pyramide bei Polygon-Profil) ODER Enter für den zuletzt
// genutzten Default (persistent, wie Höhe). → commit neues ExtrudedSolid.
//
// Die eigentliche Extrusion (truck-WASM) läuft asynchron erst in toWalls3d.ts
// (emitExtrudedSolids); dieser Befehl legt nur die Rohdaten (Punkte + Höhe +
// Verjüngung) im Projekt ab.
import type { Drawing2D, ExtrudedSolid } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { polylineEdges, pointSegmentDistance } from "../../geometry/kernel2d";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Profil-Wahl
/** Persistente Extrusionshöhe über Aufrufe hinweg (Default: Geschosshöhe). */
let lastHeight = 2.6;
/** Persistente Verjüngung über Aufrufe hinweg (Default: Prisma, kein Kegel/Pyramide). */
let lastTaper = 0;
// ── Profil-Abstraktion: geschlossene polyline/rect/circle → Punktliste ─────
/** Kreis-Tessellierung für Footprint/Auswahl/bbox (48-Eck, wie circle.ts). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Ein extrudierfähiges Profil: Tessellierung + optional die echte Kreis-Geometrie. */
interface Profile {
pts: Vec2[];
circle?: { center: Vec2; r: number };
}
/** Wandelt ein extrudierfähiges (geschlossenes) Drawing2D in ein Profil. */
function geomToProfile(d: Drawing2D): Profile | null {
const g = d.geom;
if (g.shape === "polyline" && g.closed && g.pts.length >= 3) return { pts: g.pts };
if (g.shape === "rect") {
return { pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }] };
}
if (g.shape === "circle") {
return { pts: circlePts(g.center, g.r), circle: { center: g.center, r: g.r } };
}
return null;
}
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere sein Profil. */
function singleSelectedProfile(
project: Project,
sel: CommandSelection,
): ({ d: Drawing2D } & Profile) | null {
if (!sel.drawingId || sel.wallIds.length > 0) return null;
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (!d) return null;
const profile = geomToProfile(d);
return profile ? { d, ...profile } : null;
}
/** Nächstes extrudierfähige Drawing2D zum Klickpunkt (innerhalb Toleranz). */
function pickProfileAt(
project: Project,
levelId: string,
pt: Vec2,
): ({ d: Drawing2D } & Profile) | null {
let best: ({ d: Drawing2D } & Profile) | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const profile = geomToProfile(d);
if (!profile) continue;
for (const [a, b] of polylineEdges(profile.pts, true)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = { d, ...profile };
}
}
}
return best;
}
/**
* Hängt den neuen Körper an UND entfernt das Quell-Profil (`sourceId`) aus
* `drawings2d` die Polylinie/das Rechteck WIRD zur Extrusion, sie bleibt
* nicht als unabhängiges Duplikat liegen (sonst zwei Objekte am selben Ort,
* eines davon im Grundriss unsichtbar verwaist).
*/
function appendExtrudedSolid(
p: Project,
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): Project {
const solid: ExtrudedSolid = {
id: uniqueId("extrude"),
type: "extrudedSolid",
levelId: ctx.level.id,
points: profile.pts,
height,
circle: profile.circle,
...(taper > 0 ? { taper } : {}),
};
return {
...p,
drawings2d: p.drawings2d.filter((d) => d.id !== sourceId),
extrudedSolids: [...(p.extrudedSolids ?? []), solid],
};
}
// ── Zustand ───────────────────────────────────────────────────────────────────
interface ExPick extends CommandState {
phase: "pick";
}
interface ExHeight extends CommandState {
phase: "height";
profile: Profile;
/** Id des Quell-Drawing2D, das beim Commit ersetzt (entfernt) wird. */
sourceId: string;
cursor: Vec2 | null;
}
interface ExTaper extends CommandState {
phase: "taper";
profile: Profile;
sourceId: string;
height: number;
cursor: Vec2 | null;
}
interface ExDone extends CommandState {
phase: "done";
}
type ExState = ExPick | ExHeight | ExTaper | ExDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true },
];
/** Höhe entgegengenommen → Verjüngungs-Schritt betreten (HUD zeigt Default). */
const enterTaperPhase = (
profile: Profile,
height: number,
sourceId: string,
): [CommandState, CommandResult] => [
{ phase: "taper", profile, sourceId, height, cursor: null } as ExTaper,
{ draft: null },
];
const commitTaper = (
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true, commit: (p) => appendExtrudedSolid(p, profile, height, taper, sourceId, ctx) },
];
export const extrudeCommand: Command = {
name: "extrude",
labelKey: "cmd.extrude.label",
prompt: (s) => {
const phase = (s as ExState).phase;
if (phase === "height") return "cmd.extrude.height";
if (phase === "taper") return "cmd.extrude.taper";
return "cmd.extrude.pick";
},
// Auch der „pick"-Schritt nimmt eine Zahl an, damit eine getippte Höhe sofort
// greift, wenn ein Profil vorselektiert ist (dann wird „pick" live wie
// „height" behandelt — analog offset.ts).
accepts: () => ["point", "number"],
options: () => [],
floorOnly: true,
init: (): ExPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "done") return finish();
if (s.phase === "pick") {
const pre = singleSelectedProfile(ctx.project, ctx.selection);
if (pre) {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(pre, h, pre.d.id);
}
// Punkt im vorselektierten Fall: Höhen-Schritt betreten (HUD zeigt Default).
const ns: ExHeight = { phase: "height", profile: pre, sourceId: pre.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
if (input.kind === "point") {
const hit = pickProfileAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }];
const ns: ExHeight = { phase: "height", profile: hit, sourceId: hit.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
if (s.phase === "height") {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(s.profile, h, s.sourceId);
}
return [s, { draft: null }];
}
// phase === "taper"
const ts = s as ExTaper;
if (input.kind === "number") {
const t = Math.min(1, Math.max(0, input.value));
lastTaper = t;
return commitTaper(ts.profile, ts.height, t, ts.sourceId, ctx);
}
return [ts, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") {
const ns: ExHeight = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: `${lastHeight.toFixed(2)} m` },
},
},
];
}
if (s.phase === "taper") {
const ns: ExTaper = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: lastTaper.toFixed(2) },
},
},
];
}
return [s, { draft: null }];
},
// Enter/Rechtsklick: Höhen-Schritt → Default-Höhe übernehmen + Verjüngungs-
// Schritt betreten; Verjüngungs-Schritt → Default-Verjüngung übernehmen + committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") return enterTaperPhase(s.profile, lastHeight, s.sourceId);
if (s.phase === "taper") return commitTaper(s.profile, s.height, lastTaper, s.sourceId, ctx);
return finish();
},
onCancel: (): [CommandState, CommandResult] => finish(),
};
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { georefCommand } from "./georef";
import type { CommandContext, Project, Vec2 } from "../types";
const ctx = {} as CommandContext;
const P = (x: number, y: number): Vec2 => ({ x, y });
function projectWith(wallStart: Vec2): Project {
return {
id: "p",
name: "T",
layers: [],
drawingLevels: [],
walls: [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: wallStart,
end: { x: wallStart.x + 5, y: wallStart.y },
wallTypeId: "aw",
height: 2.6,
},
],
drawings2d: [],
} as unknown as Project;
}
describe("georefCommand", () => {
it("Hover zeigt die resultierende Verschiebung im HUD (negativer Klickpunkt)", () => {
const s0 = georefCommand.init();
const [, r] = georefCommand.onMove(s0, P(120, 45), null, ctx);
expect(r.draft?.hud).toEqual({ at: P(120, 45), dx: -120, dy: -45 });
});
it("Klick committet sofort: der geklickte Punkt wird zum neuen Ursprung", () => {
const s0 = georefCommand.init();
const [ns, r] = georefCommand.onInput(s0, { kind: "point", point: P(120, 45) }, ctx);
expect(r.done).toBe(true);
expect((ns as { phase: string }).phase).toBe("start");
const p = projectWith({ x: 120, y: 45 });
const out = r.commit!(p);
// Die Wand begann exakt am geklickten Punkt -> liegt danach auf (0,0).
expect(out.walls[0].start).toEqual({ x: 0, y: 0 });
expect(out.walls[0].end).toEqual({ x: 5, y: 0 });
});
it("Esc/Rechtsklick auf leerem Zustand beendet ohne Commit", () => {
const s0 = georefCommand.init();
const [, r1] = georefCommand.onCancel(s0);
expect(r1.done).toBe(true);
expect(r1.commit).toBeUndefined();
const [, r2] = georefCommand.onConfirm(s0, ctx);
expect(r2.done).toBe(true);
expect(r2.commit).toBeUndefined();
});
});
+64
View File
@@ -0,0 +1,64 @@
// Neuer Bezugspunkt — verschiebt das GESAMTE Projekt (Wände, Decken, Dächer,
// Treppen, Räume, Stützen, Extrusionen, freie 2D-Geometrie, Kontext-Schicht,
// Schnittlinien) so, dass der geklickte Punkt zum neuen Modell-Ursprung (0,0)
// wird. Praktisch nach einem Kataster-/3D-Import (Kontext-Geometrie landet
// dort, wo der gesuchte Standort war — kann weit vom bisherigen Ursprung
// liegen, s. `Project.geoAnchor`): man klickt den gewünschten Bezugspunkt
// (z. B. eine Parzellenecke aus dem Import) und zeichnet danach mit kleinen,
// bequemen Koordinaten weiter. `geoAnchor` wandert automatisch mit (reine
// Translation, s. `model/geoRebase.ts`), der reale LV95-Bezug bleibt exakt
// erhalten — beim Export ist die Verschiebung jederzeit rekonstruierbar.
//
// Ein-Klick-Befehl: der erste (einzige) Punkt committet sofort.
import { translateProject } from "../../model/geoRebase";
import type {
Command,
CommandResult,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
interface GeorefStart extends CommandState {
phase: "start";
}
const IDLE: GeorefStart = { phase: "start", lastPoint: null };
const idle = (): [CommandState, CommandResult] => [{ ...IDLE }, { draft: null, done: true }];
/** HUD am Cursor: zeigt die resultierende Verschiebung, bevor geklickt wird. */
function draft(point: Vec2): ToolDraft {
return {
preview: [],
vertices: [],
hud: { at: point, dx: -point.x, dy: -point.y },
};
}
export const georefCommand: Command = {
name: "georef",
labelKey: "cmd.georef.label",
prompt: () => "cmd.georef.prompt",
accepts: () => ["point"],
options: () => [],
init: (): GeorefStart => ({ ...IDLE }),
onInput: (state, input): [CommandState, CommandResult] => {
if (input.kind !== "point") return [state, { draft: null }];
const { x, y } = input.point;
return [
{ ...IDLE },
{
draft: null,
done: true,
commit: (p: Project) => translateProject(p, -x, -y),
},
];
},
onMove: (state, point): [CommandState, CommandResult] => [state, { draft: draft(point) }],
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+87
View File
@@ -0,0 +1,87 @@
/**
* Unit-Tests für das Cursor-HUD (Länge/Winkel) des `line`-Befehls: nach dem
* Startpunkt muss `onMove` im Draft `hud.length`/`hud.angleDeg` mit den
* korrekten Werten liefern (VW-Stil L/W-Kästchen, PlanView rendert es).
*/
import { describe, it, expect } from "vitest";
import { lineCommand } from "./line";
import type { CommandContext, Project, Vec2 } from "../types";
/** Minimalprojekt — nur die Felder, die der Befehl/Context anfassen. */
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(lastPoint: Vec2 | null): CommandContext {
const p = project();
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint,
selection: { wallIds: [], drawingId: null },
};
}
describe("lineCommand — Cursor-HUD (Länge/Winkel)", () => {
it("liefert hud.length/angleDeg im onMove nach dem Startpunkt", () => {
const start: Vec2 = { x: 0, y: 0 };
const [afterStart] = lineCommand.onInput(
lineCommand.init(),
{ kind: "point", point: start },
makeCtx(null),
);
const [, res] = lineCommand.onMove(afterStart, { x: 3, y: 4 }, null, makeCtx(start));
expect(res.draft?.hud?.length).toBeCloseTo(5, 9);
expect(res.draft?.hud?.angleDeg).toBeCloseTo((Math.atan2(4, 3) * 180) / Math.PI, 9);
});
it("liefert einen negativen Winkel (Bereich (180,180]) für Segmente nach unten-links", () => {
const start: Vec2 = { x: 0, y: 0 };
const [afterStart] = lineCommand.onInput(
lineCommand.init(),
{ kind: "point", point: start },
makeCtx(null),
);
// Richtung (-1,-1) → -135° (wie im VW-Screenshot).
const [, res] = lineCommand.onMove(afterStart, { x: -2, y: -2 }, null, makeCtx(start));
expect(res.draft?.hud?.angleDeg).toBeCloseTo(-135, 9);
expect(res.draft?.hud?.length).toBeCloseTo(Math.hypot(2, 2), 9);
});
it("zeigt KEIN HUD vor dem Startpunkt (phase=start)", () => {
const [, res] = lineCommand.onMove(lineCommand.init(), { x: 1, y: 1 }, null, makeCtx(null));
expect(res.draft).toBeNull();
});
});
+2 -9
View File
@@ -10,7 +10,7 @@
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -61,14 +61,7 @@ function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
const preview: DraftShape[] = [];
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
const draft: ToolDraft = { preview, vertices: [a] };
if (cursor && segLen(a, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(a, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
if (cursor && segLen(a, cursor) >= EPS) draft.hud = segmentHud(a, cursor);
return draft;
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { measureCommand } from "./measure";
import type { CommandContext, Vec2 } from "../types";
// Der Kontext wird vom Mess-Werkzeug nicht gelesen (rein visuell) — Dummy genügt.
const ctx = {} as CommandContext;
const P = (x: number, y: number): Vec2 => ({ x, y });
describe("measureCommand", () => {
it("misst Segment + Winkel ab dem ersten Punkt", () => {
const s0 = measureCommand.init();
const [s1] = measureCommand.onInput(s0, { kind: "point", point: P(0, 0) }, ctx);
const [, r] = measureCommand.onMove(s1, P(3, 0), null, ctx);
expect(r.draft?.measure?.segment).toBeCloseTo(3, 6);
expect(r.draft?.measure?.angle).toBeCloseTo(0, 6);
expect(r.draft?.measure?.area).toBeNull();
expect(r.draft?.measure?.points).toBe(1);
});
it("summiert die Pfadlänge und zeigt die Fläche ab 3 Ecken", () => {
let s = measureCommand.init();
[s] = measureCommand.onInput(s, { kind: "point", point: P(0, 0) }, ctx);
[s] = measureCommand.onInput(s, { kind: "point", point: P(4, 0) }, ctx);
[s] = measureCommand.onInput(s, { kind: "point", point: P(4, 3) }, ctx);
// Cursor zurück Richtung Ursprung → geschlossenes Dreieck 4×3/2 = 6 m².
const [, r] = measureCommand.onMove(s, P(0, 0), null, ctx);
expect(r.draft?.measure?.total).toBeCloseTo(4 + 3 + 5, 6); // 0→4→3→Hypotenuse 5
expect(r.draft?.measure?.area).toBeCloseTo(6, 6);
expect(r.draft?.measure?.points).toBe(3);
});
it("Rechtsklick beendet den Pfad und armiert einen neuen (kein done)", () => {
let s = measureCommand.init();
[s] = measureCommand.onInput(s, { kind: "point", point: P(1, 1) }, ctx);
const [ns, r] = measureCommand.onConfirm(s, ctx);
expect(r.done).not.toBe(true);
expect((ns as { phase: string }).phase).toBe("start");
expect(r.draft).toBeNull();
});
it("Rechtsklick auf leerem Pfad beendet das Werkzeug", () => {
const s = measureCommand.init();
const [, r] = measureCommand.onConfirm(s, ctx);
expect(r.done).toBe(true);
});
});
+133
View File
@@ -0,0 +1,133 @@
// Messen — schnelles, unverbindliches Mess-Werkzeug (kein Element wird erzeugt).
// Ein Mess-Pfad ist ein Polygonzug: jeder Klick setzt einen weiteren Stützpunkt.
// • 1 Punkt gesetzt → Live-Strecke (Länge · Winkel) zum Cursor.
// • ≥ 2 Punkte gesetzt → zusätzlich aufsummierte Pfadlänge; sobald der Zug
// (inkl. Cursor) ≥ 3 Ecken hat, zeigt er auch die umschlossene FLÄCHE.
// Rechtsklick/Enter beendet den aktuellen Pfad und armiert einen NEUEN — so misst
// man mehrere nacheinander. Ein weiterer Rechtsklick auf leerem Pfad (bzw. Esc)
// beendet das Werkzeug. Rein visuell: `commit` bleibt immer leer.
import type {
Command,
CommandResult,
CommandState,
ToolDraft,
Vec2,
} from "../types";
import type { MeasurementReadout } from "../../tools/types";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
/** Aufsummierte Länge eines Polygonzugs (offene Kette). */
function pathLength(pts: Vec2[]): number {
let sum = 0;
for (let i = 1; i < pts.length; i++) sum += segLen(pts[i - 1], pts[i]);
return sum;
}
/** Vorzeichenlose Fläche (Gauss/Schuhband), Zug als geschlossen gedacht. */
function polygonArea(pts: Vec2[]): number {
if (pts.length < 3) return 0;
let a = 0;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
const q = pts[(i + 1) % pts.length];
a += p.x * q.y - q.x * p.y;
}
return Math.abs(a) / 2;
}
interface MeasureStart extends CommandState {
phase: "start";
}
interface MeasureDraw extends CommandState {
phase: "draw";
/** Bereits gesetzte Stützpunkte (≥ 1). */
points: Vec2[];
cursor: Vec2 | null;
}
type MeasureState = MeasureStart | MeasureDraw;
/** Messwerte aus gesetzten Punkten + aktuellem Cursor bilden. */
function readout(points: Vec2[], cursor: Vec2 | null): MeasurementReadout {
const chain = cursor ? [...points, cursor] : points;
const last = points[points.length - 1];
const segment = cursor && last ? segLen(last, cursor) : 0;
const angle = cursor && last ? ((segAngleDeg(last, cursor) % 360) + 360) % 360 : 0;
return {
segment,
total: pathLength(chain),
area: chain.length >= 3 ? polygonArea(chain) : null,
angle,
points: points.length,
};
}
/** Vorschau: gesetzter Zug + Live-Segment zum Cursor + HUD + Messwerte. */
function measureDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const chain = cursor ? [...points, cursor] : points;
const r = readout(points, cursor);
const hudText =
r.area != null
? `${r.segment.toFixed(3)} m · Σ ${r.total.toFixed(3)} m · ${r.area.toFixed(2)}`
: `${r.segment.toFixed(3)} m · ${r.angle.toFixed(1)}°`;
return {
preview: [{ kind: "poly", pts: chain, closed: chain.length >= 3 }],
vertices: points,
hud: cursor ? { at: cursor, text: hudText } : undefined,
measure: r,
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as MeasureStart,
{ draft: null, done: true },
];
export const measureCommand: Command = {
name: "measure",
labelKey: "cmd.measure.label",
prompt: (s) => ((s as MeasureState).phase === "draw" ? "cmd.measure.next" : "cmd.measure.start"),
accepts: () => ["point"],
options: () => [],
init: (): MeasureStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (input.kind !== "point") {
const draft =
s.phase === "draw" ? measureDraft(s.points, s.cursor) : null;
return [s, { draft }];
}
// Klick hängt einen Stützpunkt an den Pfad (bzw. beginnt einen neuen).
const points = s.phase === "draw" ? [...s.points, input.point] : [input.point];
const ns: MeasureDraw = {
phase: "draw",
points,
cursor: input.point,
lastPoint: input.point,
};
return [ns, { draft: measureDraft(points, input.point) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (s.phase !== "draw") return [s, { draft: null }];
const ns: MeasureDraw = { ...s, cursor: point };
return [ns, { draft: measureDraft(s.points, point) }];
},
// Rechtsklick/Enter: laufenden Pfad beenden und einen NEUEN armieren (mehrere
// Messungen nacheinander). Auf leerem Pfad = Werkzeug beenden.
onConfirm: (state): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (s.phase === "draw" && s.points.length > 0) {
return [{ phase: "start", lastPoint: null } as MeasureStart, { draft: null }];
}
return idle();
},
// Esc: Werkzeug ganz beenden.
onCancel: (): [CommandState, CommandResult] => idle(),
};
+12 -2
View File
@@ -32,12 +32,22 @@ const EPS = 1e-6;
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
+12 -2
View File
@@ -49,12 +49,22 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
+7
View File
@@ -187,6 +187,12 @@ function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y);
const width = Math.max(0.1, Math.min(s.width, len));
const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width)));
// Standard-Bauteiltyp zuweisen (erster Tür-/Fenstertyp der Bibliothek), damit
// frisch platzierte Öffnungen SOFORT die volle Rahmen-/Laibungs-Darstellung
// (2D + 3D) bekommen — ohne Typ blieben sie ein blosses Loch mit flacher
// Scheibe. Fehlt eine Bibliothek, bleibt es beim Inline-Verhalten (kein Typ).
const typeId =
s.kind === "door" ? ctx.project.doorTypes?.[0]?.id : ctx.project.windowTypes?.[0]?.id;
const opening: Opening = {
id: uniqueId("O"),
type: "opening",
@@ -197,6 +203,7 @@ function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
width,
height: s.height,
sillHeight: s.kind === "door" ? 0 : s.sill,
...(typeId ? { typeId } : {}),
...(s.kind === "door"
? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle }
: {}),
+2 -9
View File
@@ -6,7 +6,7 @@
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -74,14 +74,7 @@ function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDra
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
+15 -19
View File
@@ -17,7 +17,7 @@
// werden (die Engine löst `r…` relativ zu lastPoint auf).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { deltaHud, segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -196,51 +196,47 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
// ── Vorschau ─────────────────────────────────────────────────────────────────
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
function corners4Draft(pts: Vec2[], hud: ToolDraft["hud"] | null): ToolDraft {
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
const draft: ToolDraft = { preview, vertices: [pts[0]] };
if (hudAt) draft.hud = { at: hudAt, text: hudText };
if (hud) draft.hud = hud;
return draft;
}
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
/** Vorschau des 2-Punkt-Rechtecks (achsparallel) — HUD zeigt die Diagonale a→cursor. */
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
return corners4Draft(pts, segLen(a, cursor) >= EPS ? deltaHud(a, cursor) : null);
}
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
/** Vorschau des Zentrum-Rechtecks (achsparallel) — HUD zeigt die Diagonale center→cursor. */
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [center] };
const g = centerGeom(center, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
return corners4Draft(
pts,
segLen(center, cursor) >= EPS
? { at: cursor, dx: 2 * (cursor.x - center.x), dy: 2 * (cursor.y - center.y) }
: null,
);
}
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
const draft: ToolDraft = { preview: [{ kind: "line", a, b: cursor }], vertices: [a] };
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
};
return draft;
return { preview: [{ kind: "line", a, b: cursor }], vertices: [a], hud: segmentHud(a, cursor) };
}
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil). */
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil) — HUD: Basisbreite + Höhe am Cursor. */
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
const pts = rotatedCorners(a, b, cursor);
const w = segLen(a, b);
const h = Math.abs(riseFrom(a, b, cursor));
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
return corners4Draft(pts, { at: cursor, text: `B: ${w.toFixed(3)}m H: ${h.toFixed(3)}m` });
}
// ── Commit ───────────────────────────────────────────────────────────────────
+173
View File
@@ -0,0 +1,173 @@
// Roof — Dach als Engine-Befehl: der Grundriss wird als RECHTECK aufgezogen
// (zwei Klicks: erste Ecke → Gegenecke), committet ein `Roof`-Bauteil. Die
// Dachform/Neigung wählt man nach dem Platzieren im Attribut-Panel; Default ist
// ein Satteldach mit First entlang der LÄNGEREN Rechteckseite.
//
// Schritte:
// 1) „Erste Dachecke:" → Punkt
// 2) „Gegenecke:" → Live-Rechteck-Vorschau; Klick committet das Dach.
//
// Herkunft der Dach-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "35" (Ebene „Dächer"; Fallback ctx.defaultCategoryCode)
import type { Roof, RoofShape } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Dächer („31 Dächer"). */
const ROOF_CATEGORY = "31";
/** Wählbare Dachformen (Inline-Optionen der Befehlszeile). */
const SHAPE_OPTIONS: CmdOption[] = [
{ id: "sattel", labelKey: "cmd.roof.shape.sattel" },
{ id: "walm", labelKey: "cmd.roof.shape.walm" },
{ id: "pult", labelKey: "cmd.roof.shape.pult" },
{ id: "mansarde", labelKey: "cmd.roof.shape.mansarde" },
{ id: "zelt", labelKey: "cmd.roof.shape.zelt" },
{ id: "flach", labelKey: "cmd.roof.shape.flach" },
];
const SHAPE_IDS = new Set(SHAPE_OPTIONS.map((o) => o.id));
/** Existiert die Dach-Kategorie („35")? Sonst Fallback auf die aktive Kategorie. */
function roofCategory(ctx: CommandContext): string {
const flat: string[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push(c.code);
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.includes(ROOF_CATEGORY) ? ROOF_CATEGORY : ctx.defaultCategoryCode;
}
/** Vier Eckpunkte eines Rechtecks aus zwei Gegenecken (CCW). */
function rectCorners(a: Vec2, b: Vec2): Vec2[] {
const x0 = Math.min(a.x, b.x);
const x1 = Math.max(a.x, b.x);
const y0 = Math.min(a.y, b.y);
const y1 = Math.max(a.y, b.y);
return [
{ x: x0, y: y0 },
{ x: x1, y: y0 },
{ x: x1, y: y1 },
{ x: x0, y: y1 },
];
}
interface RoofIdle extends CommandState {
phase: "start";
shape: RoofShape;
}
interface RoofRect extends CommandState {
phase: "corner";
a: Vec2;
cursor: Vec2 | null;
shape: RoofShape;
}
type RoofState = RoofIdle | RoofRect;
/** Vorschau: aufgezogenes Rechteck + Masse im HUD. */
function roofDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
const pts = cursor ? rectCorners(a, cursor) : [a];
const preview: DraftShape[] = [{ kind: "poly", pts, closed: !!cursor }];
const draft: ToolDraft = { preview, vertices: [a] };
if (cursor) {
draft.hud = {
at: cursor,
text: `${Math.abs(cursor.x - a.x).toFixed(2)} × ${Math.abs(cursor.y - a.y).toFixed(2)} m`,
};
}
return draft;
}
/** Hängt ein Dach ans Projekt (immutabel). Entartete Rechtecke werden verworfen. */
function appendRoof(p: Project, a: Vec2, b: Vec2, shape: RoofShape, ctx: CommandContext): Project {
const w = Math.abs(b.x - a.x);
const d = Math.abs(b.y - a.y);
if (w < 0.1 || d < 0.1) return p;
const roof: Roof = {
id: uniqueId("RF"),
type: "roof",
floorId: ctx.level.id,
categoryCode: roofCategory(ctx),
outline: rectCorners(a, b),
shape,
// Flach/Pult brauchen keine „Firsthöhe"; Sattel/Walm/Mansarde/Zelt ~30°.
pitchDeg: shape === "flach" ? 0 : 30,
overhang: 0.4,
// First entlang der längeren Seite (Standard).
ridgeAxis: w >= d ? "x" : "y",
thickness: 0.2,
};
const roofs = p.roofs ? [...p.roofs, roof] : [roof];
return { ...p, roofs };
}
const idle = (shape: RoofShape = "sattel"): [CommandState, CommandResult] => [
{ phase: "start", shape, lastPoint: null } as RoofIdle,
{ draft: null, done: true },
];
export const roofCommand: Command = {
name: "roof",
labelKey: "cmd.roof.label",
floorOnly: true,
prompt: (s) => ((s as RoofState).phase === "corner" ? "cmd.roof.corner" : "cmd.roof.start"),
accepts: () => ["point", "option"],
// Dachform als Inline-Optionen — vor/während des Aufziehens wählbar.
options: () => SHAPE_OPTIONS,
init: (): RoofIdle => ({ phase: "start", shape: "sattel", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RoofState;
// Dachform-Option: aktualisiert die Form, Phase/Rechteck bleiben.
if (input.kind === "option" && SHAPE_IDS.has(input.id)) {
const ns = { ...s, shape: input.id as RoofShape } as RoofState;
const draft = ns.phase === "corner" ? roofDraft(ns.a, ns.cursor) : null;
return [ns, { draft }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner" ? roofDraft(s.a, s.cursor) : null }];
}
if (s.phase !== "corner") {
const ns: RoofRect = {
phase: "corner",
a: input.point,
cursor: input.point,
shape: s.shape,
lastPoint: input.point,
};
return [ns, { draft: roofDraft(input.point, input.point) }];
}
// Zweite Ecke → committen.
const a = s.a;
const b = input.point;
const shape = s.shape;
return [
{ phase: "start", shape, lastPoint: null } as RoofIdle,
{ draft: null, done: true, commit: (p) => appendRoof(p, a, b, shape, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RoofState;
if (s.phase !== "corner") return [s, { draft: null }];
const ns: RoofRect = { ...s, cursor: point };
return [ns, { draft: roofDraft(s.a, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+100
View File
@@ -0,0 +1,100 @@
// Schnittlinien-Befehl: der reine Datenpfad (commitLine/nextLevelName) — setzt
// die Grundriss-Schnittlinie auf die richtige Ziel-Ebene bzw. legt eine neue an.
import { describe, it, expect } from "vitest";
import { _test } from "./sectionline";
import type { DrawingLevel, Project } from "../../model/types";
const { commitLine, nextLevelName } = _test;
function projectWith(levels: DrawingLevel[]): Project {
return {
id: "p",
name: "T",
layers: [],
drawingLevels: levels,
walls: [],
drawings2d: [],
} as unknown as Project;
}
const a = { x: 0, y: 0 };
const b = { x: 5, y: 0 };
describe("sectionline — commitLine", () => {
it("belegt die EINE Platzhalter-Schnittebene ohne Linie", () => {
const p = projectWith([
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false },
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
]);
const out = commitLine(p, a, b, "section");
const s = out.drawingLevels.find((z) => z.id === "s1")!;
expect(s.linePoints).toEqual([a, b]);
expect(s.directionSign).toBe(1);
// Keine neue Ebene angelegt.
expect(out.drawingLevels).toHaveLength(2);
});
it("legt eine NEUE Schnittebene an, wenn keine Platzhalter-Ebene existiert", () => {
const p = projectWith([
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false },
{
id: "s1",
name: "Schnitt A",
kind: "section",
visible: true,
locked: false,
linePoints: [{ x: 1, y: 1 }, { x: 2, y: 2 }],
},
]);
const out = commitLine(p, a, b, "section");
expect(out.drawingLevels).toHaveLength(3);
const created = out.drawingLevels[out.drawingLevels.length - 1];
expect(created.kind).toBe("section");
expect(created.linePoints).toEqual([a, b]);
// Zweite Ebene → Buchstabe B.
expect(created.name).toContain("B");
});
it("legt bei ZWEI Platzhaltern (mehrdeutig) eine neue Ebene an", () => {
const p = projectWith([
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
{ id: "s2", name: "Schnitt B", kind: "section", visible: true, locked: false },
]);
const out = commitLine(p, a, b, "section");
expect(out.drawingLevels).toHaveLength(3);
// Die bestehenden Platzhalter bleiben unangetastet.
expect(out.drawingLevels[0].linePoints).toBeUndefined();
expect(out.drawingLevels[1].linePoints).toBeUndefined();
});
it("verwirft entartete (zu kurze) Linien", () => {
const p = projectWith([
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
]);
const out = commitLine(p, a, { x: 0.02, y: 0 }, "section");
expect(out).toBe(p);
});
it("trennt Schnitt- und Ansichts-Ebenen (kind)", () => {
const p = projectWith([
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
]);
// Ansichtslinie darf die Schnitt-Platzhalter-Ebene NICHT belegen.
const out = commitLine(p, a, b, "elevation");
expect(out.drawingLevels).toHaveLength(2);
expect(out.drawingLevels[0].linePoints).toBeUndefined();
expect(out.drawingLevels[1].kind).toBe("elevation");
});
});
describe("sectionline — nextLevelName", () => {
it("zählt Buchstaben je Art hoch (A, B, C …)", () => {
const p0 = projectWith([]);
expect(nextLevelName(p0, "section")).toContain("A");
const p1 = projectWith([
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
]);
expect(nextLevelName(p1, "section")).toContain("B");
});
});
+213
View File
@@ -0,0 +1,213 @@
// Schnittlinie — setzt die Grundriss-Schnitt-/Ansichtslinie einer Zeichnungsebene
// (DrawingLevel, kind "section"/"elevation") per zwei Klicks (Start → Ende). Die
// Linie steuert `sectionPlaneFromLevel` (plan/toSection.ts): senkrecht auf ihr
// steht die Blickrichtung (directionSign, Voreinstellung +1).
//
// Schritte:
// 1) „Schnitt-Startpunkt:" → Punkt
// 2) „Schnitt-Endpunkt:" → Live-Linie + Richtungspfeil; Klick committet.
//
// Ziel-Ebene (im Commit bestimmt): existiert GENAU EINE Ebene der gewählten Art
// OHNE gesetzte Linie (frisch angelegter Platzhalter), wird deren Linie gesetzt;
// sonst wird eine NEUE Ebene angelegt (Name „Schnitt B/C…" bzw. „Ansicht …").
//
// Die Art (Schnitt/Ansicht) ist als Inline-Option umschaltbar — dieselbe Mechanik
// für beide, nur `kind` unterscheidet sich (Alias `viewline` startet mit
// „elevation"). Bezeichner englisch, sichtbarer Text via t().
import { t } from "../../i18n";
import type { DrawingLevel, DrawingLevelKind } from "../../model/types";
import { leftNormal, normalize, sub } from "../../model/geometry";
import type {
Command,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Nur Schnitt/Ansicht tragen eine Linie. */
type LineKind = "section" | "elevation";
/** Wählbare Art (Inline-Optionen der Befehlszeile). */
const KIND_OPTIONS: CmdOption[] = [
{ id: "section", labelKey: "cmd.sectionline.kind.section" },
{ id: "elevation", labelKey: "cmd.sectionline.kind.elevation" },
];
const KIND_IDS = new Set(KIND_OPTIONS.map((o) => o.id));
/**
* Richtungspfeil-Vorschau: kurze Linie vom Mittelpunkt der Schnittlinie entlang
* der Blicknormalen (leftNormal der Linie, per directionSign gedreht) plus zwei
* Flügel-Segmente zeigt, wohin der Schnitt blickt".
*/
function directionArrow(a: Vec2, b: Vec2, sign: 1 | -1): DraftShape[] {
const dir = sub(b, a);
const len = Math.hypot(dir.x, dir.y);
if (len < 1e-6) return [];
const u = normalize(dir);
const n = leftNormal(u); // (-uy, ux)
const nx = n.x * sign;
const ny = n.y * sign;
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
// Pfeillänge ~ min(1 m, Viertel der Linienlänge), damit die Vorschau lesbar bleibt.
const arrowLen = Math.min(1, len / 4);
const tip = { x: mid.x + nx * arrowLen, y: mid.y + ny * arrowLen };
const wing = arrowLen * 0.35;
// Flügel: vom Spitzenpunkt zurück, je halb entlang u und +u geneigt.
const back = { x: tip.x - nx * wing * 2, y: tip.y - ny * wing * 2 };
const w1 = { x: back.x + u.x * wing, y: back.y + u.y * wing };
const w2 = { x: back.x - u.x * wing, y: back.y - u.y * wing };
return [
{ kind: "line", a: mid, b: tip },
{ kind: "line", a: tip, b: w1 },
{ kind: "line", a: tip, b: w2 },
];
}
/** Vorschau: Schnittlinie + Richtungspfeil + Länge im HUD. */
function lineDraft(a: Vec2, cursor: Vec2 | null, sign: 1 | -1): ToolDraft {
if (!cursor) return { preview: [{ kind: "poly", pts: [a] }], vertices: [a] };
const preview: DraftShape[] = [
{ kind: "line", a, b: cursor },
...directionArrow(a, cursor, sign),
];
const draft: ToolDraft = { preview, vertices: [a] };
draft.hud = {
at: cursor,
text: `${Math.hypot(cursor.x - a.x, cursor.y - a.y).toFixed(2)} m`,
};
return draft;
}
/** Nächster freier Ebenen-Name der Art (A, B, C … über die Anzahl bestehender). */
function nextLevelName(p: Project, kind: LineKind): string {
const count = p.drawingLevels.filter((z) => z.kind === kind).length;
// Buchstaben-Suffix (A, B, …, Z, dann AA…) — klassische Schnitt-Benennung.
let n = count;
let letters = "";
do {
letters = String.fromCharCode(65 + (n % 26)) + letters;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
const key = kind === "section" ? "cmd.sectionline.newSection" : "cmd.sectionline.newElevation";
return t(key, { letter: letters });
}
/**
* Setzt die Schnittlinie [a, b] auf die Ziel-Ebene (immutabel). Existiert GENAU
* EINE Ebene der Art ohne Linie, wird sie belegt; sonst wird eine neue angelegt.
* Entartete (zu kurze) Linien werden verworfen.
*/
function commitLine(p: Project, a: Vec2, b: Vec2, kind: LineKind): Project {
if (Math.hypot(b.x - a.x, b.y - a.y) < 0.1) return p;
const line: [Vec2, Vec2] = [a, b];
const orphans = p.drawingLevels.filter((z) => z.kind === kind && !z.linePoints);
if (orphans.length === 1) {
const targetId = orphans[0].id;
return {
...p,
drawingLevels: p.drawingLevels.map((z) =>
z.id === targetId ? { ...z, linePoints: line, directionSign: z.directionSign ?? 1 } : z,
),
};
}
const level: DrawingLevel = {
id: `${kind}-${Date.now()}`,
name: nextLevelName(p, kind),
kind: kind as DrawingLevelKind,
visible: true,
locked: false,
linePoints: line,
directionSign: 1,
};
return { ...p, drawingLevels: [...p.drawingLevels, level] };
}
interface LineIdle extends CommandState {
phase: "start";
kind: LineKind;
}
interface LineEnd extends CommandState {
phase: "end";
a: Vec2;
cursor: Vec2 | null;
kind: LineKind;
}
type LineState = LineIdle | LineEnd;
const DIRECTION_SIGN: 1 = 1;
function makeCommand(name: string, startKind: LineKind): Command {
const idle = (kind: LineKind): [CommandState, CommandResult] => [
{ phase: "start", kind, lastPoint: null } as LineIdle,
{ draft: null, done: true },
];
return {
name,
labelKey: "cmd.sectionline.label",
// Nicht floorOnly: eine Schnittlinie zieht man IM Grundriss, wo Bauteile
// liegen — die aktive Ebene ist dann ein Geschoss. Aber der Befehl soll auch
// auf freien Zeichnungsebenen nutzbar bleiben; die Prüfung übernimmt der
// Grundriss-Kontext (keine harte Sperre nötig).
floorOnly: true,
prompt: (s) => ((s as LineState).phase === "end" ? "cmd.sectionline.end" : "cmd.sectionline.start"),
accepts: () => ["point", "option"],
options: () => KIND_OPTIONS,
init: (): LineIdle => ({ phase: "start", kind: startKind, lastPoint: null }),
onInput: (state, input): [CommandState, CommandResult] => {
const s = state as LineState;
if (input.kind === "option" && KIND_IDS.has(input.id)) {
const ns = { ...s, kind: input.id as LineKind } as LineState;
const draft =
ns.phase === "end" ? lineDraft(ns.a, ns.cursor, DIRECTION_SIGN) : null;
return [ns, { draft }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "end" ? lineDraft(s.a, s.cursor, DIRECTION_SIGN) : null },
];
}
if (s.phase !== "end") {
const ns: LineEnd = {
phase: "end",
a: input.point,
cursor: input.point,
kind: s.kind,
lastPoint: input.point,
};
return [ns, { draft: lineDraft(input.point, input.point, DIRECTION_SIGN) }];
}
const a = s.a;
const b = input.point;
const kind = s.kind;
return [
{ phase: "start", kind, lastPoint: null } as LineIdle,
{ draft: null, done: true, commit: (p) => commitLine(p, a, b, kind) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as LineState;
if (s.phase !== "end") return [s, { draft: null }];
const ns: LineEnd = { ...s, cursor: point };
return [ns, { draft: lineDraft(s.a, point, DIRECTION_SIGN) }];
},
onConfirm: (state): [CommandState, CommandResult] => idle((state as LineState).kind),
onCancel: (state): [CommandState, CommandResult] => idle((state as LineState).kind),
};
}
/** Schnittlinie (startet als „section"). */
export const sectionLineCommand: Command = makeCommand("sectionline", "section");
/** Ansichtslinie (startet als „elevation"; identische Mechanik). */
export const viewLineCommand: Command = makeCommand("viewline", "elevation");
// Für Tests: die reinen Datenpfad-Helfer.
export const _test = { commitLine, nextLevelName, directionArrow };
+76
View File
@@ -0,0 +1,76 @@
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
// 2) „Text:" → getippte Zeile (das Label) → commit Drawing2D {shape:"text"}
//
// Der Text-Schritt nimmt Freitext an (accepts:["text"]) — die Engine reicht die
// komplette getippte Zeile 1:1 durch (auch Zahlen/Kommas). Gerendert wird der
// Text in generatePlan als `drawingText` (SVG), analog zum DXF-Import.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
Vec2,
} from "../types";
/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
interface TextIdle extends CommandState {
phase: "point";
}
interface TextLabel extends CommandState {
phase: "label";
at: Vec2;
}
type TextState = TextIdle | TextLabel;
function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null },
{ draft: null, done: true },
];
export const textCommand: Command = {
name: "text",
labelKey: "cmd.text.label",
prompt: (s) => ((s as TextState).phase === "label" ? "cmd.text.enter" : "cmd.text.point"),
accepts: (s) => ((s as TextState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TextIdle => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TextState;
if (s.phase !== "label") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TextLabel = { phase: "label", at: input.point, lastPoint: input.point };
return [ns, { draft: null }];
}
if (input.kind !== "text") return [s, { draft: null }];
const at = s.at;
return [
{ phase: "point", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) },
];
},
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+127
View File
@@ -0,0 +1,127 @@
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite:
// der Text bricht beim Rendern wortweise auf diese Breite um. Schritte:
// 1) „Ankerpunkt:" → Punkt (oben-links der Spalte)
// 2) „Spaltenbreite:" → Punkt (die horizontale Distanz zum Anker = Breite;
// Live-Vorschau der Breitenlinie)
// 3) „Text:" → getippte Zeile → commit Drawing2D {shape:"text", width}
//
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben Selektion,
// Verschieben und Griff automatisch vom Einzeltext; nur die Breite kommt hinzu.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Schrifthöhe einer Textspalte in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
/** Kleinste sinnvolle Spaltenbreite (Meter). */
const MIN_WIDTH_M = 0.2;
interface TbPoint extends CommandState {
phase: "point";
}
interface TbWidth extends CommandState {
phase: "width";
at: Vec2;
cursor: Vec2 | null;
}
interface TbLabel extends CommandState {
phase: "label";
at: Vec2;
width: number;
}
type TbState = TbPoint | TbWidth | TbLabel;
/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */
function widthOf(at: Vec2, cursor: Vec2): number {
return Math.max(MIN_WIDTH_M, Math.abs(cursor.x - at.x));
}
/** Vorschau: waagrechte Breitenlinie am Anker (zeigt die Spaltenbreite). */
function widthDraft(at: Vec2, cursor: Vec2): ToolDraft {
const w = widthOf(at, cursor);
const b: Vec2 = { x: at.x + w, y: at.y };
return {
preview: [{ kind: "line", a: at, b }],
vertices: [at, b],
hud: { at: b, text: `${w.toFixed(2)} m` },
};
}
function appendTextbox(
p: Project,
at: Vec2,
width: number,
text: string,
ctx: CommandContext,
): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true },
];
export const textboxCommand: Command = {
name: "textbox",
labelKey: "cmd.textbox.label",
prompt: (s) => {
const ts = s as TbState;
if (ts.phase === "label") return "cmd.textbox.enter";
if (ts.phase === "width") return "cmd.textbox.width";
return "cmd.textbox.point";
},
accepts: (s) => ((s as TbState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TbPoint => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase === "point") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TbWidth = { phase: "width", at: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: widthDraft(input.point, input.point) }];
}
if (s.phase === "width") {
if (input.kind !== "point") return [s, { draft: s.cursor ? widthDraft(s.at, s.cursor) : null }];
const width = widthOf(s.at, input.point);
const ns: TbLabel = { phase: "label", at: s.at, width, lastPoint: input.point };
return [ns, { draft: null }];
}
// label
if (input.kind !== "text") return [s, { draft: null }];
const { at, width } = s;
return [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true, commit: (p) => appendTextbox(p, at, width, input.text, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase !== "width") return [s, { draft: null }];
const ns: TbWidth = { ...s, cursor: point };
return [ns, { draft: widthDraft(s.at, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+87
View File
@@ -0,0 +1,87 @@
/**
* Unit-Tests für das Cursor-HUD (Länge/Winkel) des `wall`-Befehls: nach dem
* Startpunkt muss `onMove` im Draft `hud.length`/`hud.angleDeg` relativ zum
* ZULETZT gesetzten Achspunkt liefern auch nach mehreren Segmenten
* (Chaining), damit ein mehrteiliger Wandzug je Segment ein frisches HUD zeigt.
*/
import { describe, it, expect } from "vitest";
import { wallCommand } from "./wall";
import type { CommandContext, Project, Vec2 } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [
{ id: "aw", name: "AW", layers: [{ componentId: "c", thickness: 0.2 }] },
],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(lastPoint: Vec2 | null): CommandContext {
const p = project();
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint,
selection: { wallIds: [], drawingId: null },
};
}
describe("wallCommand — Cursor-HUD (Länge/Winkel)", () => {
it("liefert hud.length/angleDeg relativ zum Startpunkt", () => {
const start: Vec2 = { x: 0, y: 0 };
const [afterStart] = wallCommand.onInput(
wallCommand.init(),
{ kind: "point", point: start },
makeCtx(null),
);
const [, res] = wallCommand.onMove(afterStart, { x: 4, y: 0 }, null, makeCtx(start));
expect(res.draft?.hud?.length).toBeCloseTo(4, 9);
expect(res.draft?.hud?.angleDeg).toBeCloseTo(0, 9);
});
it("HUD bezieht sich nach dem zweiten Punkt auf das NEUE (letzte) Segment", () => {
const p0: Vec2 = { x: 0, y: 0 };
const p1: Vec2 = { x: 4, y: 0 };
const [s1] = wallCommand.onInput(wallCommand.init(), { kind: "point", point: p0 }, makeCtx(null));
const [s2] = wallCommand.onInput(s1, { kind: "point", point: p1 }, makeCtx(p0));
// Nächstes Segment ab p1 nach oben (0,3) → Länge 3, Winkel 90°.
const [, res] = wallCommand.onMove(s2, { x: 4, y: 3 }, null, makeCtx(p1));
expect(res.draft?.hud?.length).toBeCloseTo(3, 9);
expect(res.draft?.hud?.angleDeg).toBeCloseTo(90, 9);
});
it("zeigt KEIN HUD vor dem Startpunkt (phase=start)", () => {
const [, res] = wallCommand.onMove(wallCommand.init(), { x: 1, y: 1 }, null, makeCtx(null));
expect(res.draft).toBeNull();
});
});
+2 -9
View File
@@ -18,7 +18,7 @@
import type { Wall, WallType } from "../../model/types";
import { wallTypeThickness } from "../../model/types";
import { wallCorners } from "../../model/geometry";
import { uniqueId } from "../../tools/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -164,14 +164,7 @@ function wallDraft(points: Vec2[], cursor: Vec2 | null, thickness: number): Tool
}
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
+8
View File
@@ -328,6 +328,14 @@ export class CommandEngine {
}
}
// Freitext-Schritt (z. B. Text-Platzierung): der Schritt nimmt beliebigen
// Text als Label — die komplette getippte Zeile geht 1:1 durch (auch Zahlen/
// Kommas), bevor die numerische/Koordinaten-Auflösung greift.
if (accepts.includes("text")) {
this.feed({ kind: "text", text });
return;
}
const parsed = parseInput(text);
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
+33
View File
@@ -10,18 +10,27 @@ import { wallCommand } from "./cmds/wall";
import { ceilingCommand } from "./cmds/ceiling";
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
import { stairCommand } from "./cmds/stair";
import { columnCommand } from "./cmds/column";
import { roofCommand } from "./cmds/roof";
import { ceilingOpeningCommand } from "./cmds/ceilingOpening";
import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc";
import { textCommand } from "./cmds/text";
import { textboxCommand } from "./cmds/textbox";
import { moveCommand } from "./cmds/move";
import { mirrorCommand } from "./cmds/mirror";
import { joinCommand } from "./cmds/join";
import { copyCommand } from "./cmds/copy";
import { offsetCommand } from "./cmds/offset";
import { extrudeCommand } from "./cmds/extrude";
import { trimCommand } from "./cmds/trim";
import { importCommand } from "./cmds/import";
import { terrainCommand } from "./cmds/terrain";
import { measureCommand } from "./cmds/measure";
import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline";
import { georefCommand } from "./cmds/georef";
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
export const COMMANDS: Record<string, Command> = {
@@ -31,20 +40,30 @@ export const COMMANDS: Record<string, Command> = {
fenster: fensterCommand,
tuer: tuerCommand,
stair: stairCommand,
column: columnCommand,
roof: roofCommand,
ceilingopening: ceilingOpeningCommand,
room: roomCommand,
line: lineCommand,
polyline: polylineCommand,
rect: rectCommand,
circle: circleCommand,
arc: arcCommand,
text: textCommand,
textbox: textboxCommand,
move: moveCommand,
mirror: mirrorCommand,
join: joinCommand,
copy: copyCommand,
offset: offsetCommand,
extrude: extrudeCommand,
trim: trimCommand,
import: importCommand,
terrain: terrainCommand,
measure: measureCommand,
sectionline: sectionLineCommand,
viewline: viewLineCommand,
georef: georefCommand,
};
/**
@@ -63,6 +82,12 @@ export const ALIASES: Record<string, string> = {
oe: "opening",
treppe: "stair",
tp: "stair",
stuetze: "column",
stütze: "column",
dach: "roof",
deckenloch: "ceilingopening",
aussparung: "ceilingopening",
rf: "roof",
raum: "room",
rm: "room",
l: "line",
@@ -71,6 +96,9 @@ export const ALIASES: Record<string, string> = {
c: "circle",
a: "arc",
bogen: "arc",
tx: "text",
txt: "text",
beschriftung: "text",
m: "move",
s: "mirror",
spiegeln: "mirror",
@@ -78,9 +106,14 @@ export const ALIASES: Record<string, string> = {
verbinden: "join",
cp: "copy",
o: "offset",
ex: "extrude",
tr: "trim",
imp: "import",
ter: "terrain",
schnittlinie: "sectionline",
ansichtslinie: "viewline",
bezugspunkt: "georef",
nullpunkt: "georef",
};
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
+6 -1
View File
@@ -15,11 +15,12 @@ import type { DrawingLevel } from "../model/types";
import type { SnapResult, ToolDraft } from "../tools/types";
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
export { segmentHud, angleDegOf } from "../tools/types";
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
/** Arten von Eingaben, die ein Schritt annehmen kann. */
export type AcceptKind = "point" | "number" | "option" | "selection";
export type AcceptKind = "point" | "number" | "option" | "text" | "selection";
/**
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
@@ -67,6 +68,10 @@ export interface CommandSelection {
* `drawingId` (falls gesetzt) als einziges Element.
*/
drawingIds?: string[];
/** Gewählter extrudierter Körper (truck-Integration), falls einer selektiert ist. */
extrudedSolidId?: string | null;
/** Gewählte Stütze (Tragwerk), falls eine selektiert ist. */
columnId?: string | null;
}
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
+70
View File
@@ -0,0 +1,70 @@
let modulePromise: Promise<any> | null = null;
async function getModule(): Promise<any> {
if (!modulePromise) {
modulePromise = import("./pkgTruck/trucksolid.js").then(async (m: any) => {
await m.default();
return m;
});
}
return modulePromise;
}
export interface ExtrudedMesh {
positions: number[];
indices: number[];
}
/** `taper` 0 (Prisma, Default) 1 (Spitze/Kegel-Pyramide) linear zum
* Profil-Schwerpunkt skaliert. */
export async function extrudePolygon(
points: number[],
height: number,
taper = 0,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_polygon(JSON.stringify({ points, height, taper }));
return JSON.parse(json) as ExtrudedMesh;
}
export async function extrudeCircle(
cx: number,
cy: number,
r: number,
height: number,
taper = 0,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height, taper }));
return JSON.parse(json) as ExtrudedMesh;
}
export type BooleanOp = "union" | "difference" | "intersection";
export interface BooleanMeshResult {
positions: number[];
indices: number[];
empty: boolean;
}
/** Boolesche Operation zweier Dreiecks-Meshes (Mesh-Ebenen-CSG via csgrs,
* truck-Integration Phase 4). Beide Meshes müssen konsistent nach außen
* gewundene Dreiecke haben (Rechte-Hand-Regel) sonst liefert csgrs ein
* falsches Ergebnis, ohne das zu erkennen. */
export async function booleanMesh(
a: ExtrudedMesh,
b: ExtrudedMesh,
op: BooleanOp,
): Promise<BooleanMeshResult> {
const m = await getModule();
const json = m.boolean_mesh(
JSON.stringify({
a_positions: a.positions,
a_indices: a.indices,
b_positions: b.positions,
b_indices: b.indices,
op,
}),
);
return JSON.parse(json) as BooleanMeshResult;
}
+10
View File
@@ -28,6 +28,10 @@ const LAYER_HATCH = "HATCH";
const LAYER_SYMBOLS = "SYMBOLS";
const LAYER_CONTEXT = "CONTEXT";
const LAYER_DEFAULT = "PLAN";
/** Sammel-Layer für Extrusions-Footprints (truck-Integration) die Quell-
* Drawing2D wird beim Extrudieren entfernt, es gibt also keine categoryCode
* mehr nachzuschlagen; eigener Layer statt Absturz in LAYER_DEFAULT. */
const LAYER_EXTRUSION = "EXTRUSION";
/**
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
@@ -60,6 +64,8 @@ export function buildPlanDxf(
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
// Warmes Orange, identisch zum 3D-/Grundriss-Footprint der Extrusionen (EXTRUSION_STROKE/EXTRUSION_RGB).
dxf.addLayer({ name: LAYER_EXTRUSION, color: aciFromHex("#d98c40"), trueColor: rgbFromHex("#d98c40"), lineWeight: 13 });
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
@@ -68,11 +74,15 @@ export function buildPlanDxf(
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
const codeByDrawing = new Map<string, string>();
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
const codeByColumn = new Map<string, string>();
for (const c of project.columns ?? []) codeByColumn.set(c.id, c.categoryCode);
const layerFor = (p: Primitive): string => {
if (p.kind === "polygon") {
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
if (p.columnId) return layerNameByCode.get(codeByColumn.get(p.columnId) ?? "") ?? LAYER_DEFAULT;
if (p.extrudedSolidId) return LAYER_EXTRUSION;
return LAYER_DEFAULT;
}
if (p.kind === "line") {
+370
View File
@@ -0,0 +1,370 @@
// Unit-Tests für den IFC4-Export (exportIfcSpf) — erste vollständige Scheibe.
// • Strukturelle Validität: KEINE dangling references (jede #N-Referenz ist
// definiert), KEINE doppelten Entity-IDs — der wichtigste Test.
// • Header/FILE_SCHEMA('IFC4') vorhanden.
// • Je "floor"-Geschoss genau ein IfcBuildingStorey.
// • Wandanzahl → IfcWall-Anzahl.
// • Eine Öffnung ⇒ IfcOpeningElement + IfcRelVoidsElement (+ IfcDoor/
// IfcWindow + IfcRelFillsElement).
// • GUID-Format (22 Zeichen, gültiger Zeichensatz), deterministisch.
// • Leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash).
//
// Fixture-Muster gespiegelt von exportSchedule.test.ts.
import { describe, it, expect } from "vitest";
import { exportIfcSpf, ifcGuid } from "./exportIfc";
import type {
Ceiling,
ExtrudedSolid,
Opening,
Project,
Stair,
Wall,
} from "../model/types";
/**
* Minimalprojekt: 2 Geschosse (EG + OG, EG auch ein "section"-Level, das
* NICHT zu einem Storey werden darf) + 2 Wände (Wandtyp T=0.4) + 1 Decke
* (Deckentyp T=0.2) + 1 Tür + 1 Fenster (an W1 gehostet) + 1 Treppe +
* 1 Extrusion.
*/
function fixtureProject(): Project {
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const openings: Opening[] = [
{
id: "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "door",
position: 1,
width: 0.9,
height: 2.1,
sillHeight: 0,
},
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5,
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3,
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
return {
id: "t",
name: "Testprojekt",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
{ id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 2.6 },
{ id: "schnitt-a", name: "Schnitt A", kind: "section", visible: true, locked: false, linePoints: [{ x: 0, y: 0 }, { x: 1, y: 0 }], directionSign: 1 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls,
doors: [],
openings,
ceilings,
stairs,
extrudedSolids,
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Sammelt alle definierten Entity-IDs (`#N=`) und alle referenzierten `#N`. */
function collectIds(spf: string): { defined: Set<number>; referenced: Set<number>; duplicates: number[] } {
const defined = new Set<number>();
const duplicates: number[] = [];
const referenced = new Set<number>();
for (const line of spf.split("\n")) {
const defMatch = /^#(\d+)=/.exec(line);
if (defMatch) {
const id = Number(defMatch[1]);
if (defined.has(id)) duplicates.push(id);
defined.add(id);
}
const refs = line.matchAll(/#(\d+)/g);
for (const r of refs) {
// Der erste Treffer je Zeile ist ggf. die Definition selbst — trotzdem
// harmlos mitgezählt, da sie ja in `defined` steht (Selbstreferenz-Check
// unten prüft nur: JEDE referenzierte ID muss iRGENDWO definiert sein).
referenced.add(Number(r[1]));
}
}
return { defined, referenced, duplicates };
}
describe("exportIfcSpf — IFC4-Export", () => {
it("erzeugt keine dangling references und keine doppelten Entity-IDs", () => {
const spf = exportIfcSpf(fixtureProject());
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
const dangling = [...referenced].filter((id) => !defined.has(id));
expect(dangling).toEqual([]);
expect(defined.size).toBeGreaterThan(0);
});
it("trägt einen gültigen IFC4-Header", () => {
const spf = exportIfcSpf(fixtureProject());
expect(spf).toContain("ISO-10303-21;");
expect(spf).toContain("FILE_SCHEMA(('IFC4'));");
expect(spf).toContain("END-ISO-10303-21;");
});
it("erzeugt je 'floor'-Geschoss genau ein IfcBuildingStorey (Schnitte NICHT)", () => {
const spf = exportIfcSpf(fixtureProject());
const storeyLines = spf.split("\n").filter((l) => l.includes("=IFCBUILDINGSTOREY("));
expect(storeyLines).toHaveLength(2); // EG + OG, NICHT "Schnitt A"
expect(storeyLines.some((l) => l.includes("'EG'"))).toBe(true);
expect(storeyLines.some((l) => l.includes("'OG'"))).toBe(true);
});
it("bildet jede Wand auf genau ein IfcWall ab", () => {
const spf = exportIfcSpf(fixtureProject());
const wallLines = spf.split("\n").filter((l) => l.includes("=IFCWALL("));
expect(wallLines).toHaveLength(2);
});
it("Tür/Fenster bleiben eigene Objekte (IfcDoor/IfcWindow); das Loch steckt im Wand-Mesh (kein IfcOpeningElement/Void/Fill)", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Tür + Fenster als eigene Objekte erhalten.
expect(lines.filter((l) => l.includes("=IFCDOOR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCWINDOW("))).toHaveLength(1);
// Bewusste Abwägung: Wand ist jetzt ein Face-Set mit ausgeschnittenem Loch —
// die frühere IfcOpeningElement-Void/Fill-Semantik entfällt (siehe Dateikopf).
expect(lines.filter((l) => l.includes("=IFCOPENINGELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELVOIDSELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELFILLSELEMENT("))).toHaveLength(0);
});
it("bildet Wände als IfcTriangulatedFaceSet (IfcCartesianPointList3D + CoordIndex) statt Profil-Extrusion ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Genau ein Face-Set + eine Punktliste je Wand (2 Wände).
expect(lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("))).toHaveLength(2);
expect(lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("))).toHaveLength(2);
// Wand-Shape ist als Tessellation deklariert (nicht mehr SweptSolid).
expect(spf).toContain("'Tessellation'");
// Face-Set-CoordIndex referenziert 1-basierte Punkt-Indizes (Tripel-Listen).
const fs = lines.find((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(fs).toMatch(/\(\(\d+,\d+,\d+\)/);
});
it("bildet Decke, Treppe und Extrusion auf die erwarteten Entity-Typen ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
expect(lines.filter((l) => l.includes("=IFCSLAB("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCSTAIR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCBUILDINGELEMENTPROXY("))).toHaveLength(1);
});
it("das Wand-Face-Set enthält das ausgeschnittene Fenster-Loch (Loch-Vertices + volle Dreieckszahl, keine dangling refs)", () => {
// W2 hat das Fenster F1 (position 1, width 1.2, sill 0.9, height 1.5) → das
// Loch liegt voll im Wand-Inneren; das Face-Set der Wand hat exakt die
// Rust-Zerlegung: 8 Langseiten-Teilrechtecke ×2×2 + 8 (Deckel/Boden/Kappen)
// + 8 (4 Laibungen) = 48 Dreiecke, 144 Punkte.
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
// Das Loch-Face-Set hat 48 CoordIndex-Tripel.
const triCounts = faceSets.map((l) => (l.match(/\(\d+,\d+,\d+\)/g) ?? []).length);
expect(triCounts).toContain(48);
// Zugehörige Punktliste hat 144 Punkte (3er-Koordinaten-Tupel).
const pointLists = lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("));
const ptCounts = pointLists.map((l) => (l.match(/\([^()]*,[^()]*,[^()]*\)/g) ?? []).length);
expect(ptCounts).toContain(144);
// Keine dangling refs (der Kern-Invarianten-Check gilt auch mit Face-Sets).
const { defined, referenced } = collectIds(spf);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
it("Wand-Face-Sets sind NACH AUSSEN orientiert (positives Volumen) — Regression gegen die Reflexions-Wicklung, die die Wand hohl machte", () => {
// Der IFC-Achsen-Swap (x,y,z)→(x,z,y) ist eine Reflexion und kehrte die
// Dreiecks-Wicklung um → Normalen zeigten nach INNEN → Viewer cullten die
// Vorderseiten → Wand wirkte oben/unten offen. Nach dem Wicklungs-Ausgleich
// muss das signierte Volumen jedes Wandkörpers POSITIV sein (aussen orientiert).
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const byId = new Map<number, string>();
for (const l of lines) {
const m = /^#(\d+)=/.exec(l);
if (m) byId.set(Number(m[1]), l);
}
const parsePoints = (line: string): number[][] =>
[...line.matchAll(/\(([-\d.]+),([-\d.]+),([-\d.]+)\)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
Number(m[3]),
]);
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(faceSets.length).toBeGreaterThan(0);
let closedCount = 0;
for (const fs of faceSets) {
const ptListId = Number(/=IFCTRIANGULATEDFACESET\(#(\d+),/.exec(fs)![1]);
const closed = /=IFCTRIANGULATEDFACESET\(#\d+,\$,([^,]+),/.exec(fs)![1];
if (closed === ".T.") closedCount++;
const pts = parsePoints(byId.get(ptListId)!);
const tris = [...fs.matchAll(/\((\d+),(\d+),(\d+)\)/g)].map((m) => [
Number(m[1]) - 1,
Number(m[2]) - 1,
Number(m[3]) - 1,
]);
// 6× signiertes Volumen Σ v0·(v1×v2): > 0 ⇒ Normalen zeigen nach aussen.
let vol6 = 0;
for (const [a, b, c] of tris) {
const [ax, ay, az] = pts[a];
const [bx, by, bz] = pts[b];
const [cx, cy, cz] = pts[c];
vol6 += ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx);
}
expect(vol6).toBeGreaterThan(0);
}
// Beide Wände sind geschlossene Prisma-Körper (Fenster = Durchgangsloch,
// Tür = umlaufende П-Kerbe) → Closed=.T. bei beiden.
expect(closedCount).toBe(2);
});
it("GUIDs sind 22 Zeichen lang, nutzen den gültigen IFC-Zeichensatz und sind deterministisch", () => {
const guid = ifcGuid("W1");
expect(guid).toHaveLength(22);
expect(guid).toMatch(/^[0-9A-Za-z_$]{22}$/);
expect(ifcGuid("W1")).toBe(guid); // stabil über Re-Export
expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs
});
it("georeferenziert: mit geoAnchor werden die ECHTEN LV95-Koordinaten geschrieben (lokal bearbeitet, weltweit lagerichtig)", () => {
// Modell lokal nahe (0,0), aber geoAnchor sagt: Modell (0,0) = LV95
// (2600000,1200000). Der Export muss die Profil-/Punkt-Koordinaten auf die
// echten Landeskoordinaten (Millionenbereich) verschoben schreiben.
const local = fixtureProject();
const withoutAnchor = exportIfcSpf(local);
// Ohne Bezug: lokale Koordinaten, KEINE Millionenwerte im Text.
expect(withoutAnchor).not.toMatch(/26000\d\d\./);
const geo: Project = {
...local,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
};
const spf = exportIfcSpf(geo);
// W1.start (0,0) -> LV95 (2600000,1200000); W2.end (5,4) -> (2600005,1200004).
expect(spf).toContain("2600000.");
expect(spf).toContain("1200000.");
expect(spf).toContain("2600005.");
// Struktur bleibt valide (keine dangling refs / Duplikate).
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
it("geoAnchor.height wird als IfcSite.RefElevation geschrieben (Vermessungspunkt-Höhe)", () => {
const local = fixtureProject();
const geo: Project = {
...local,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, height: 683.5, model: { x: 0, y: 0 } },
};
const spf = exportIfcSpf(geo);
// Die IFCSITE-Zeile trägt die reale Datumshöhe (RefElevation, 12. Attribut).
const siteLine = spf.split("\n").find((l) => l.includes("=IFCSITE("));
expect(siteLine).toBeDefined();
expect(siteLine).toContain("683.5");
// Ohne Höhe bleibt RefElevation leer ($) — kein 683.5 irgendwo.
const noH = exportIfcSpf({
...local,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
});
expect(noH).not.toContain("683.5");
});
it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => {
const proj = fixtureProject();
proj.walls = [];
proj.ceilings = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
proj.drawingLevels = [];
const spf = exportIfcSpf(proj);
expect(spf).toContain("=IFCPROJECT(");
expect(spf).toContain("=IFCSITE(");
expect(spf).toContain("=IFCBUILDING(");
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
});
+699
View File
@@ -0,0 +1,699 @@
// IFC4-Export (STEP Physical File / ISO-10303-21) des semantischen Modells.
// Reiner Rechen-/Serialisierungskern: keine UI, kein Datei-IO, kein WASM, keine
// neue Dependency — IFC wird direkt als Text geschrieben (analog exportDxf.ts/
// exportSchedule.ts). Der Download (Blob+Anchor) passiert im App-Layer.
//
// Abbildung (erste vollständige Scheibe):
// Project → IfcProject → IfcSite → IfcBuilding → je "floor"-Geschoss ein
// IfcBuildingStorey (IfcRelAggregates-Kette). Bauteile hängen über
// IfcRelContainedInSpatialStructure am jeweiligen Geschoss (verwaiste
// Geschossreferenzen fallen defensiv auf IfcBuilding zurück).
//
// Decken/Treppen/Extrusionen als IfcExtrudedAreaSolid (unser Modell IST
// Extrusion): Profil = IfcArbitraryClosedProfileDef(IfcPolyline) in der
// XY-Ebene, extrudiert entlang +Z. Die horizontale Objekt-Platzierungskette
// (Site/Building/Storey/Element) trägt bewusst NUR die Z-Verschiebung
// (Geschoss-Elevation); die Profilpunkte tragen direkt die Welt-X/Y-Koordinaten.
//
// • Wand → IfcWall mit ÖFFNUNGSGENAUEM Dreiecks-Mesh (IfcTriangulatedFace
// Set, IFC4: IfcCartesianPointList3D + CoordIndex) statt einer Profil-
// Extrusion. Gespeist aus DEMSELBEN Loch-Ausschnitt-Mesh wie STL/OBJ
// (`pickGeometry` → `plan/wallMeshCut.ts`): Joins/Gehrungen UND ausgeschnittene
// Fenster/Türen (inkl. Laibungen) sind im Körper enthalten. ABWÄGUNG (bewusst,
// Nutzer-Priorität "so wie im 3D"): dadurch verliert die Wand die parametrische
// IfcWall-Profil-Extrusion + IfcOpeningElement-Void-Semantik zugunsten
// VISUELLER PARITÄT in JEDEM Viewer (der Loch schon im Mesh sieht, ohne eine
// Boolean-Subtraktion ausführen zu müssen — genau der Bug des Nutzers: "das
// Fenster ist als Objekt da im IFC, aber die Löcher sind nicht da").
// • Decke → IfcSlab (outline-Polygon, PredefinedType FLOOR).
// • Öffnung → KEIN IfcOpeningElement/Void mehr (das Loch steckt im Wand-Mesh);
// Tür/Fenster bleiben als eigenes Objekt IfcDoor/IfcWindow mit eigener Box-
// Geometrie erhalten (füllt das ausgeschnittene Loch, "sieht aus wie 3D").
// • Extrusion → IfcBuildingElementProxy aus points+height.
// • Treppe → IfcStair, GEOMETRISCH bewusst vereinfacht auf einen
// extrudierten Bounding-Footprint (Lauf-Rechteck bei "straight"; Achsen-
// ausgerichtete Bounding-Box der Kontrollpunkte bei "L"/"spiral") — die
// echte Stufengeometrie ist ausgelassen (siehe stairFootprint()).
//
// Material-Layer (IfcMaterialLayerSet/-Usage) sind NICHT enthalten — die
// korrekte Direction/Offset-Semantik von IfcMaterialLayerSetUsage ließ sich
// ohne Gegenprüfung an einem echten Viewer nicht mit ausreichender Sicherheit
// umsetzen; Geometrie/Hierarchie hatten Vorrang (siehe Bericht/PENDENZEN).
//
// GUIDs: deterministisch aus der Element-ID über einen 128-Bit-Hash (zwei
// FNV-1a-64-Läufe) + Standard-IFC-GUID-Kompression (Base64-Variante,
// Zeichensatz 0-9,A-Z,a-z,_,$) — stabil über Re-Exporte hinweg.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
import type {
Opening,
Project,
Stair,
Vec2,
Wall,
} from "../model/types";
import {
getCeilingType,
getWallType,
openingLabel,
wallTypeThickness,
} from "../model/types";
import { projectToWorldCoords } from "../model/geoRebase";
import {
ceilingVerticalExtent,
stairVerticalExtent,
wallReferenceOffset,
wallVerticalExtent,
} from "../model/wall";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { isWatertight, wallCutMesh } from "../plan/wallMeshCut";
// ── IFC-GUID (Base64-Kompression, 22 Zeichen) ───────────────────────────────
// Standard-Kompressionsalgorithmus (IfcOpenShell guid.compress): das erste
// Byte des 128-Bit-Werts wird auf 2 Zeichen abgebildet, die restlichen 15
// Byte in 5 Dreiergruppen zu je 4 Zeichen — macht 2 + 5×4 = 22 Zeichen.
const IFC_GUID_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
/** Kodiert `v` big-endian in `len` IFC-GUID-Zeichen (Basis 64). */
function ifcGuidB64(v: number, len: number): string {
let out = "";
for (let i = len - 1; i >= 0; i--) {
out += IFC_GUID_CHARS[Math.floor(v / 64 ** i) % 64];
}
return out;
}
/** Komprimiert einen 128-Bit-Wert (32 Hex-Zeichen) zur 22-stelligen IFC-GUID. */
function compressGuidHex(hex32: string): string {
const bytes: number[] = [];
for (let i = 0; i < 32; i += 2) bytes.push(parseInt(hex32.slice(i, i + 2), 16));
let out = ifcGuidB64(bytes[0], 2);
for (let i = 1; i < 16; i += 3) {
const v = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += ifcGuidB64(v, 4);
}
return out;
}
/** FNV-1a-64 (BigInt) — reines Determinismus-/Streuungs-Werkzeug, keine Kryptografie. */
function fnv1a64(str: string, seed: bigint): bigint {
const prime = 0x100000001b3n;
const mask = 0xffffffffffffffffn;
let hash = seed & mask;
for (let i = 0; i < str.length; i++) {
hash ^= BigInt(str.charCodeAt(i));
hash = (hash * prime) & mask;
}
return hash;
}
/** Leitet aus einer stabilen Element-ID einen deterministischen 128-Bit-Hex-Wert ab. */
function idToHex32(id: string): string {
const h1 = fnv1a64(id, 0xcbf29ce484222325n);
const h2 = fnv1a64(`${id}salt`, 0x9e3779b97f4a7c15n);
return h1.toString(16).padStart(16, "0") + h2.toString(16).padStart(16, "0");
}
/** Deterministische 22-stellige IFC-GUID aus einer beliebigen Element-ID. */
export function ifcGuid(id: string): string {
return compressGuidHex(idToHex32(id));
}
// ── STEP-Formatierung ───────────────────────────────────────────────────────
/** STEP-String-Literal ('…', Apostroph verdoppelt, Backslash verdoppelt). */
function S(s: string): string {
const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "''");
return `'${escaped}'`;
}
/** STEP-REAL-Literal — immer mit Dezimalpunkt, ohne unnötige Nachkommastellen. */
function R(x: number): string {
const v = Object.is(x, -0) ? 0 : x;
let s = v.toFixed(6);
s = s.replace(/0+$/, "");
if (s.endsWith(".")) s += "0";
if (!s.includes(".")) s += ".0";
return s;
}
/** STEP-Enumerationswert `.WERT.`. */
function ENUM(v: string): string {
return `.${v}.`;
}
/** STEP-Liste `(a,b,c)`. */
function LIST(items: string[]): string {
return `(${items.join(",")})`;
}
// ── STEP-Writer ──────────────────────────────────────────────────────────────
class StepWriter {
private lines: string[] = [];
private nextId = 1;
/** Schreibt eine neue Entity-Zeile und liefert ihre `#id`. */
add(type: string, params: string): number {
const id = this.nextId++;
this.lines.push(`#${id}=${type}(${params});`);
return id;
}
get entityLines(): readonly string[] {
return this.lines;
}
}
// ── Geometrie-Helfer (reines 2D-Vec2-Rechnen, Welt-Meter) ───────────────────
function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
function normalize(v: Vec2): Vec2 {
const len = Math.hypot(v.x, v.y) || 1;
return { x: v.x / len, y: v.y / len };
}
function leftNormal(u: Vec2): Vec2 {
return { x: -u.y, y: u.x };
}
function addScaled(p: Vec2, d: Vec2, s: number): Vec2 {
return { x: p.x + d.x * s, y: p.y + d.y * s };
}
/** Rechteck-Footprint einer Öffnung im Wandloch (volle Wanddicke tief), CCW. */
function openingFootprint(project: Project, wall: Wall, opening: Opening): Vec2[] {
const u = normalize(sub(wall.end, wall.start));
const n = leftNormal(u);
const t = wallTypeThickness(getWallType(project, wall));
const off = wallReferenceOffset(wall, t);
const inner = -t / 2 + off;
const outer = t / 2 + off;
const a = addScaled(wall.start, u, opening.position);
const b = addScaled(wall.start, u, opening.position + opening.width);
return [
addScaled(a, n, inner),
addScaled(b, n, inner),
addScaled(b, n, outer),
addScaled(a, n, outer),
];
}
/**
* Vereinfachter Bounding-Footprint einer Treppe (bewusst NICHT die echte
* Stufen-/Podestkontur, siehe Dateikopf):
* "straight" echtes, ausgerichtetes Lauf-Rechteck (Länge × Breite).
* "L"/"spiral" achsenausgerichtete Bounding-Box der Kontrollpunkte
* (Start/Eckpunkt/Ende bzw. Wendel-Zentrum±Radius), um die halbe
* Laufbreite erweitert.
*/
function stairFootprint(stair: Stair): Vec2[] {
const halfW = Math.max(stair.width, 0) / 2;
if (stair.shape === "straight") {
const u = normalize(stair.dir);
const n = leftNormal(u);
const end = addScaled(stair.start, u, stair.runLength);
return [
addScaled(stair.start, n, -halfW),
addScaled(end, n, -halfW),
addScaled(end, n, halfW),
addScaled(stair.start, n, halfW),
];
}
const pts: Vec2[] = [stair.start];
const u = normalize(stair.dir);
const corner = addScaled(stair.start, u, stair.runLength);
pts.push(corner);
if (stair.shape === "L" && stair.run2Length && stair.turn) {
const n = leftNormal(u);
const turnDir: Vec2 = { x: n.x * stair.turn, y: n.y * stair.turn };
pts.push(addScaled(corner, turnDir, stair.run2Length));
}
if (stair.shape === "spiral" && stair.center) {
const r = (stair.radius ?? 0) + halfW;
const c = stair.center;
return [
{ x: c.x - r, y: c.y - r },
{ x: c.x + r, y: c.y - r },
{ x: c.x + r, y: c.y + r },
{ x: c.x - r, y: c.y + r },
];
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
minX -= halfW;
minY -= halfW;
maxX += halfW;
maxY += halfW;
return [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
];
}
// ── IFC4-Export ──────────────────────────────────────────────────────────────
interface StoreyRef {
entityId: number;
placementId: number;
baseElevation: number;
}
interface Structure {
entityId: number;
placementId: number;
baseElevation: number;
}
/**
* Baut aus einem Projekt einen vollständigen IFC4-SPF-String (STEP Physical
* File). Reiner Rechenkern kein Datei-IO. Leeres Projekt valider Minimal-
* IFC (Project/Site/Building, kein Crash).
*/
export function exportIfcSpf(projectIn: Project): string {
// Georeferenziert exportieren: ist ein Standort-Bezug gesetzt (geoAnchor),
// wird die gesamte Geometrie auf die ECHTEN LV95-Weltkoordinaten verschoben,
// damit die IFC-Datei in anderer Software lagerichtig sitzt — auch wenn das
// Modell lokal nahe (0,0) bearbeitet wurde (s. projectToWorldCoords / der
// „Neuer Bezugspunkt"-Workflow). Ohne Bezug bleibt alles lokal (Identität).
const project = projectToWorldCoords(projectIn);
const w = new StepWriter();
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
// Placement (Position aller ExtrudedAreaSolid — Profile tragen direkt
// Welt-X/Y, siehe Dateikopf).
const originPoint = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const extrudeDir = w.add("IFCDIRECTION", LIST([R(0), R(0), R(1)]));
const identityAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
// Owner-History (minimal, aber vorhanden — manche Importer verlangen sie).
const org = w.add("IFCORGANIZATION", `$,${S("dossier")},$,$,$`);
const person = w.add(
"IFCPERSON",
`${S("dossier")},$,$,$,$,$,$,$`,
);
const personOrg = w.add("IFCPERSONANDORGANIZATION", `#${person},#${org},$`);
const app = w.add(
"IFCAPPLICATION",
`#${org},${S("1.0")},${S("dossier")},${S("dossier")}`,
);
const ownerHistory = w.add(
"IFCOWNERHISTORY",
`#${personOrg},#${app},$,${ENUM("ADDED")},$,$,$,${Math.floor(Date.now() / 1000)}`,
);
// Einheiten (Meter, Radiant, m², m³).
const lenUnit = w.add("IFCSIUNIT", `*,${ENUM("LENGTHUNIT")},$,${ENUM("METRE")}`);
const areaUnit = w.add("IFCSIUNIT", `*,${ENUM("AREAUNIT")},$,${ENUM("SQUARE_METRE")}`);
const volUnit = w.add("IFCSIUNIT", `*,${ENUM("VOLUMEUNIT")},$,${ENUM("CUBIC_METRE")}`);
const angleUnit = w.add(
"IFCSIUNIT",
`*,${ENUM("PLANEANGLEUNIT")},$,${ENUM("RADIAN")}`,
);
const unitAssignment = w.add(
"IFCUNITASSIGNMENT",
LIST([`#${lenUnit}`, `#${areaUnit}`, `#${volUnit}`, `#${angleUnit}`]),
);
// Geometrischer Kontext (3D, Precision 1e-5).
const context = w.add(
"IFCGEOMETRICREPRESENTATIONCONTEXT",
`$,${S("Model")},3,${R(0.00001)},#${identityAxis},$`,
);
// Räumliche Hierarchie: Project → Site → Building → Storeys.
const siteAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const sitePlacement = w.add("IFCLOCALPLACEMENT", `$,#${siteAxis}`);
const buildingAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const buildingPlacement = w.add(
"IFCLOCALPLACEMENT",
`#${sitePlacement},#${buildingAxis}`,
);
const projectId = w.add(
"IFCPROJECT",
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
);
// RefElevation (m ü. M.): reale Datumshöhe des Standorts aus dem
// Georeferenzierungs-Bezug (wie ArchiCADs Vermessungspunkt). Rein informativ —
// die Geometrie wird NICHT verschoben (das Terrain trägt bereits reale Höhen).
const refElevation = project.geoAnchor?.height;
const siteRefElev = typeof refElevation === "number" ? R(refElevation) : "$";
const siteId = w.add(
"IFCSITE",
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,${siteRefElev},$,$`,
);
const buildingId = w.add(
"IFCBUILDING",
`${S(ifcGuid(`${project.id}:building`))},#${ownerHistory},${S(project.name || "Gebäude")},$,$,#${buildingPlacement},$,$,${ENUM("ELEMENT")},$,$,$`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-site`))},#${ownerHistory},$,$,#${projectId},${LIST([`#${siteId}`])}`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-building`))},#${ownerHistory},$,$,#${siteId},${LIST([`#${buildingId}`])}`,
);
// Je "floor"-Geschoss ein IfcBuildingStorey (Elevation = baseElevation).
const floors = project.drawingLevels.filter((l) => l.kind === "floor");
const storeyByFloorId = new Map<string, StoreyRef>();
const storeyEntityIds: number[] = [];
for (const floor of floors) {
const base = floor.baseElevation ?? 0;
const pt = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(base)]));
const axis = w.add("IFCAXIS2PLACEMENT3D", `#${pt},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${buildingPlacement},#${axis}`);
const entityId = w.add(
"IFCBUILDINGSTOREY",
`${S(ifcGuid(`${floor.id}:storey`))},#${ownerHistory},${S(floor.name)},$,$,#${placementId},$,$,${ENUM("ELEMENT")},${R(base)}`,
);
storeyByFloorId.set(floor.id, { entityId, placementId, baseElevation: base });
storeyEntityIds.push(entityId);
}
if (storeyEntityIds.length > 0) {
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-storeys`))},#${ownerHistory},$,$,#${buildingId},${LIST(storeyEntityIds.map((id) => `#${id}`))}`,
);
}
/** Geschoss → Trägerstruktur (Storey), oder defensiv das Gebäude (verwaiste floorId). */
const resolveStructure = (floorId: string): Structure => {
const s = storeyByFloorId.get(floorId);
if (s) return { entityId: s.entityId, placementId: s.placementId, baseElevation: s.baseElevation };
return { entityId: buildingId, placementId: buildingPlacement, baseElevation: 0 };
};
// Räumliche Eingliederung sammelt sich je Trägerstruktur (Storey/Building)
// und wird am Ende in EINE IfcRelContainedInSpatialStructure je Struktur
// gebündelt (Öffnungen NICHT — die hängen nur über RelVoidsElement an ihrer
// Wand, wie in IFC üblich).
const containment = new Map<number, number[]>();
const addToContainment = (structureId: number, elementId: number): void => {
const arr = containment.get(structureId);
if (arr) arr.push(elementId);
else containment.set(structureId, [elementId]);
};
/** Baut Profil+Extrusion+Shape+Placement für einen geschlossenen Footprint. */
const emitBoxProduct = (
footprint: Vec2[],
zBottomRel: number,
depth: number,
placementRelTo: number,
): { placementId: number; shapeId: number } => {
const closed = [...footprint, footprint[0]];
const ptIds = closed.map((p) => w.add("IFCCARTESIANPOINT", LIST([R(p.x), R(p.y)])));
const polylineId = w.add("IFCPOLYLINE", LIST(ptIds.map((id) => `#${id}`)));
const profileId = w.add(
"IFCARBITRARYCLOSEDPROFILEDEF",
`${ENUM("AREA")},$,#${polylineId}`,
);
const solidId = w.add(
"IFCEXTRUDEDAREASOLID",
`#${profileId},#${identityAxis},#${extrudeDir},${R(Math.max(depth, 0.001))}`,
);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("SweptSolid")},${LIST([`#${solidId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(zBottomRel)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/**
* Baut aus einem Dreiecks-Mesh (`positions` flach x,y,z in IFC-Koordinaten
* bereits Z-up und relativ zur `placementRelTo`-Herkunft, `indices` je 3 =
* 1-basiert-1 CoordIndex-Tripel) ein IfcTriangulatedFaceSet + Shape + Placement.
* IFC4-Tessellierung: IfcCartesianPointList3D (CoordList) + IfcTriangulatedFace
* Set (CoordIndex, 1-basiert). Das Element-Placement sitzt im Ursprung der
* Trägerstruktur (die Punkte tragen die Geometrie bereits absolut in deren Frame).
*/
const emitTriangulatedProduct = (
positions: number[],
indices: number[],
placementRelTo: number,
closed: boolean,
): { placementId: number; shapeId: number } => {
const coords: string[] = [];
for (let i = 0; i < positions.length; i += 3) {
coords.push(`(${R(positions[i])},${R(positions[i + 1])},${R(positions[i + 2])})`);
}
const pointListId = w.add("IFCCARTESIANPOINTLIST3D", `(${coords.join(",")})`);
const tris: string[] = [];
for (let i = 0; i < indices.length; i += 3) {
tris.push(`(${indices[i] + 1},${indices[i + 1] + 1},${indices[i + 2] + 1})`);
}
// Closed=.T. NUR wenn das Mesh nachweislich ein dichtes, aussen orientiertes
// Volumen ist (siehe isWatertight): Wände sind extrudierte Querschnitts-
// Polygone (Fenster = Durchgangsloch, Tür = umlaufende П-Kerbe) → geschlossene
// Körper → `.T.` (Viewer rendern sie als Solid statt als offene Fläche). Der
// Guard fängt echte Defekte ab (invertierte Wicklung → Volumen < 0 → `$`).
// Normals=$ (Viewer leitet sie aus der — jetzt aussen orientierten — Wicklung ab).
const closedFlag = closed ? ".T." : "$";
const faceSetId = w.add("IFCTRIANGULATEDFACESET", `#${pointListId},$,${closedFlag},(${tris.join(",")}),$`);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("Tessellation")},${LIST([`#${faceSetId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/** Baut ein IfcBuildingElement-Subtyp mit dem üblichen 9-Attribut-Flatten. */
const emitBuildingElement = (
type: string,
guid: string,
name: string,
placementId: number,
shapeId: number,
predefinedType: string | null,
): number =>
w.add(
type,
`${S(guid)},#${ownerHistory},${S(name)},$,$,#${placementId},#${shapeId},$,${predefinedType ?? "$"}`,
);
// ── Wände (öffnungsgenaues Dreiecks-Mesh statt Profil-Extrusion) ────────
// Die Wand-Schicht-Bänder kommen aus DEMSELBEN geflachten Modell wie STL/OBJ
// (`pickGeometry`, Joins/Gehrungen + Öffnungs-`holes` bereits aufgelöst). Alle
// Bänder einer Wand-Id werden zu EINEM Face-Set vereint. IFC-Koordinaten: das
// Mesh liegt in Welt (Modell-x, Höhe, Modell-y) mit Y-up → IFC (x, y, z=Höhe)
// mit Z-up, also (mx, mz, my); Z relativ zur Geschoss-UK, damit die Storey-
// Placement-Elevation nicht doppelt zählt.
//
// WICHTIG — WICKLUNG: der Achsen-Swap (x,y,z)→(x,z,y) ist eine REFLEXION
// (Determinante 1) und KEHRT die Dreiecks-Wicklung UM → aus aussen orientierten
// würden innen orientierte Normalen, der Viewer cullt dann die Vorderseiten und
// die Wand wirkt HOHL/offen (genau der gemeldete Bug). Deshalb wird beim Swap
// die Wicklung jedes Dreiecks umgedreht (i0,i2,i1), damit die Aussen-Normalen
// aussen bleiben. Watertightness (isWatertight) misst das anschliessend am
// fertigen IFC-Mesh → treibt das Closed-Flag des Face-Sets.
const bandsByWallId = new Map<string, RWall[]>();
for (const band of pickGeometry(project).walls) {
const list = bandsByWallId.get(band.wallId);
if (list) list.push(band);
else bandsByWallId.set(band.wallId, [band]);
}
const wallEntityIdByWallId = new Map<string, number>();
for (const wall of project.walls ?? []) {
const bands = bandsByWallId.get(wall.id);
if (!bands || bands.length === 0) continue; // degenerierte Wand / keine Geometrie
const structure = resolveStructure(wall.floorId);
const positions: number[] = [];
const indices: number[] = [];
for (const band of bands) {
const cut = wallCutMesh(band);
const base = positions.length / 3;
for (let i = 0; i < cut.positions.length; i += 3) {
positions.push(
cut.positions[i],
cut.positions[i + 2],
cut.positions[i + 1] - structure.baseElevation,
);
}
// Wicklung umkehren (Reflexions-Ausgleich, s. o.): (a,b,c) → (a,c,b).
for (let i = 0; i < cut.indices.length; i += 3) {
indices.push(base + cut.indices[i], base + cut.indices[i + 2], base + cut.indices[i + 1]);
}
}
if (indices.length === 0) continue;
const closed = isWatertight({ positions, indices });
const { placementId, shapeId } = emitTriangulatedProduct(positions, indices, structure.placementId, closed);
let name = wall.id;
try {
name = getWallType(project, wall).name;
} catch {
/* verwaister Wandtyp — Roh-ID als Name */
}
const entityId = emitBuildingElement("IFCWALL", ifcGuid(wall.id), name, placementId, shapeId, null);
wallEntityIdByWallId.set(wall.id, entityId);
addToContainment(structure.entityId, entityId);
}
// ── Decken ─────────────────────────────────────────────────────────────
for (const ceiling of project.ceilings ?? []) {
if (ceiling.outline.length < 3) continue;
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
const structure = resolveStructure(ceiling.floorId);
const { placementId, shapeId } = emitBoxProduct(
ceiling.outline,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
let name: string = ceiling.id;
try {
name = getCeilingType(project, ceiling).name;
} catch {
/* verwaister Deckentyp — Roh-ID als Name */
}
const entityId = emitBuildingElement(
"IFCSLAB",
ifcGuid(ceiling.id),
name,
placementId,
shapeId,
ENUM("FLOOR"),
);
addToContainment(structure.entityId, entityId);
}
// ── Fenster/Türen als eigene Objekte (IfcDoor/IfcWindow) ────────────────
// KEIN IfcOpeningElement/IfcRelVoidsElement/IfcRelFillsElement mehr: das Loch
// steckt bereits im Wand-Face-Set (s. o.). Eine Void-Relation beschriebe eine
// Boolean-Subtraktion gegen einen Swept-Solid, den es nicht mehr gibt — sie
// brächte in den Viewern nur Verwirrung (der Nutzer-Bug war genau, dass die
// Void nicht subtrahiert wurde). Tür/Fenster bleiben als EIGENES Objekt mit
// eigener Box-Geometrie erhalten, die das ausgeschnittene Loch füllt ("sieht
// aus wie 3D"): kein Blatt-/Rahmendetail (bewusste Vereinfachung).
for (const opening of project.openings ?? []) {
const wall = (project.walls ?? []).find((wl) => wl.id === opening.hostWallId);
if (!wall) continue; // verwaiste Wirtswand — keine Geometrie ableitbar
if (!wallEntityIdByWallId.has(wall.id)) continue; // Wirtswand übersprungen (degeneriert)
const wallExtent = wallVerticalExtent(project, wall);
const structure = resolveStructure(wall.floorId);
const footprint = openingFootprint(project, wall, opening);
const zSillAbs = wallExtent.zBottom + opening.sillHeight;
const { placementId: fillPlacement, shapeId: fillShape } = emitBoxProduct(
footprint,
zSillAbs - structure.baseElevation,
opening.height,
structure.placementId,
);
const fillGuid = ifcGuid(`${opening.id}:fill`);
const fillName = openingLabel(opening);
const fillEntityId =
opening.kind === "door"
? w.add(
"IFCDOOR",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("DOOR")},$,$`,
)
: w.add(
"IFCWINDOW",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("WINDOW")},$,$`,
);
addToContainment(structure.entityId, fillEntityId);
}
// ── Treppen (vereinfachter Bounding-Footprint, siehe Dateikopf) ────────
for (const stair of project.stairs ?? []) {
const footprint = stairFootprint(stair);
if (footprint.length < 3) continue;
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const structure = resolveStructure(stair.floorId);
const { placementId, shapeId } = emitBoxProduct(
footprint,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
const predefinedType =
stair.shape === "straight"
? ENUM("STRAIGHT_RUN_STAIR")
: stair.shape === "spiral"
? ENUM("SPIRAL_STAIR")
: ENUM("QUARTER_TURN_STAIR");
const entityId = emitBuildingElement(
"IFCSTAIR",
ifcGuid(stair.id),
"Treppe",
placementId,
shapeId,
predefinedType,
);
addToContainment(structure.entityId, entityId);
}
// ── Extrudierte Körper (truck-Integration) → IfcBuildingElementProxy ───
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3) continue;
const floor = floors.find((f) => f.id === solid.levelId);
const base = floor?.baseElevation ?? 0;
const structure = resolveStructure(solid.levelId);
const { placementId, shapeId } = emitBoxProduct(
solid.points,
base - structure.baseElevation,
solid.height,
structure.placementId,
);
const entityId = emitBuildingElement(
"IFCBUILDINGELEMENTPROXY",
ifcGuid(solid.id),
"Extrusion",
placementId,
shapeId,
null,
);
addToContainment(structure.entityId, entityId);
}
// ── Räumliche Eingliederung (gebündelt je Trägerstruktur) ──────────────
for (const [structureId, elementIds] of containment) {
w.add(
"IFCRELCONTAINEDINSPATIALSTRUCTURE",
`${S(ifcGuid(`contain:${structureId}`))},#${ownerHistory},$,$,${LIST(elementIds.map((id) => `#${id}`))},#${structureId}`,
);
}
// ── Kopf + Zusammenbau ───────────────────────────────────────────────────
const iso = new Date().toISOString().replace(/\.\d+Z$/, "");
const fileName = `${project.name || "modell"}.ifc`;
const header = [
"ISO-10303-21;",
"HEADER;",
`FILE_DESCRIPTION(${LIST([S("")])},${S("2;1")});`,
`FILE_NAME(${S(fileName)},${S(iso)},${LIST([S("dossier")])},${LIST([S("dossier")])},${S("dossier")},${S("dossier")},${S("")});`,
"FILE_SCHEMA(('IFC4'));",
"ENDSEC;",
"",
"DATA;",
];
const footer = ["ENDSEC;", "END-ISO-10303-21;"];
return [...header, ...w.entityLines, ...footer].join("\n") + "\n";
}
+306
View File
@@ -0,0 +1,306 @@
// Unit-Tests für den STL-/OBJ-Mesh-Export (reines Modul, siehe exportMesh.ts).
// • OBJ: ≥1 v/f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich.
// • STL: solid/endsolid-Rahmen, Facettenzahl = Dreieckszahl, je Facette genau 3 vertex-Zeilen.
// • Dreieckszahl-Plausibilität: isolierte Wand-Box (12) und isoliertes N-Eck-Prisma (4N-4).
// • leeres Projekt ⇒ gültige leere Datei (kein Crash).
import { describe, it, expect } from "vitest";
import { exportObj, exportStl } from "./exportMesh";
import type { Project, Wall, Ceiling, ExtrudedSolid, Opening } from "../model/types";
/** Gemeinsame Ressourcen-Basis (Komponente/Wandtyp/Deckentyp/Geschoss/Ebene). */
function baseProject(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
extrudedSolids: [],
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Eine einzelne, frei stehende Wand (kein Nachbar ⇒ kein Gehrungs-/Anschlussschnitt). */
function projectWithOneWall(): Project {
const p = baseProject();
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
p.walls = [wall];
return p;
}
/**
* Eine frei stehende Wand mit EINER Öffnung (Fenster ODER Tür). Das Fenster
* (sillHeight>0) liegt vollständig im Wand-Inneren (vier Laibungen); die Tür
* (sillHeight 0) berührt die Wand-UK (keine untere Laibung).
*/
function projectWithOpening(kind: "window" | "door"): Project {
const p = projectWithOneWall();
const opening: Opening = {
id: kind === "window" ? "F1" : "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind,
position: 2,
width: 1,
height: kind === "window" ? 1.5 : 2.1,
sillHeight: kind === "window" ? 0.9 : 0,
};
p.openings = [opening];
return p;
}
/** Eine einzelne Extrusion mit N-Eck-Profil (kein Wand-/Deckenkontext). */
function projectWithPolygonExtrusion(pts: { x: number; y: number }[], height = 2.5): Project {
const p = baseProject();
const solid: ExtrudedSolid = {
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: pts,
height,
};
p.extrudedSolids = [solid];
return p;
}
/** Kombiniertes Fixture-Projekt: 2 Wände (Eckstoss), 1 Decke, 1 Extrusion. */
function fixtureProject(): Project {
const p = baseProject();
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
p.walls = walls;
p.ceilings = ceilings;
p.extrudedSolids = extrudedSolids;
return p;
}
/** Parst die `v`-Zeilen eines OBJ-Strings zu Koordinaten-Tripeln. */
function parseObjVertices(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("v "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
/** Parst die `f`-Zeilen eines OBJ-Strings zu 1-basierten Index-Tripeln. */
function parseObjFaces(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("f "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
describe("exportObj — Wavefront-OBJ-Export", () => {
it("liefert ≥1 v und ≥1 f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich", () => {
const obj = exportObj(fixtureProject());
const verts = parseObjVertices(obj);
const faces = parseObjFaces(obj);
expect(verts.length).toBeGreaterThan(0);
expect(faces.length).toBeGreaterThan(0);
for (const v of verts) {
expect(v).toHaveLength(3);
for (const c of v) expect(Number.isFinite(c)).toBe(true);
}
for (const f of faces) {
expect(f).toHaveLength(3);
for (const idx of f) {
expect(idx).toBeGreaterThanOrEqual(1);
expect(idx).toBeLessThanOrEqual(verts.length);
}
}
});
it("gruppiert Bauteil-Vorkommen als o-Objekte (Wand/Decke/Extrusion)", () => {
const obj = exportObj(fixtureProject());
expect(obj).toContain("o Wand_W1");
expect(obj).toContain("o Wand_W2");
expect(obj).toContain("o Decke_D1");
expect(obj).toContain("o Extrusion_E1");
});
it("leeres Projekt ⇒ gültige Datei ohne v/f (kein Crash)", () => {
const obj = exportObj(baseProject());
expect(obj.split("\n").some((l) => l.startsWith("v "))).toBe(false);
expect(obj.split("\n").some((l) => l.startsWith("f "))).toBe(false);
expect(obj.length).toBeGreaterThan(0);
});
});
describe("exportStl — ASCII-STL-Export", () => {
it("hat solid/endsolid-Rahmen, je Facette genau 3 vertex-Zeilen, Facettenzahl = Dreieckszahl", () => {
const stl = exportStl(fixtureProject());
const lines = stl.split("\n").filter((l) => l.length > 0);
expect(lines[0]).toBe("solid dossier");
expect(lines[lines.length - 1]).toBe("endsolid dossier");
const facetCount = lines.filter((l) => l.startsWith("facet normal")).length;
const loopCount = lines.filter((l) => l === "outer loop").length;
const endloopCount = lines.filter((l) => l === "endloop").length;
const endfacetCount = lines.filter((l) => l === "endfacet").length;
const vertexCount = lines.filter((l) => l.startsWith("vertex ")).length;
expect(facetCount).toBeGreaterThan(0);
expect(loopCount).toBe(facetCount);
expect(endloopCount).toBe(facetCount);
expect(endfacetCount).toBe(facetCount);
expect(vertexCount).toBe(facetCount * 3);
});
it("leeres Projekt ⇒ gültiges leeres solid-Gerüst (kein Crash)", () => {
const stl = exportStl(baseProject());
expect(stl).toBe("solid dossier\nendsolid dossier\n");
});
it("Dreieckszahl-Plausibilität: eine frei stehende Wand ergibt genau 1 Box (12 Dreiecke, 8 Ecken)", () => {
const stl = exportStl(projectWithOneWall());
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(12);
const obj = exportObj(projectWithOneWall());
expect(parseObjVertices(obj)).toHaveLength(8);
});
it("Dreieckszahl-Plausibilität: ein N-Eck-Profil ergibt ein Prisma mit 4N-4 Dreiecken (2N Ecken)", () => {
// Rechteck (N=4): 2 Kappen à 2 Dreiecke + 4 Seitenquads à 2 Dreiecke = 12 = 4·44.
const rectStl = exportStl(
projectWithPolygonExtrusion([
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 1 },
{ x: 0, y: 1 },
]),
);
expect(rectStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(12);
// Konvexes Fünfeck (N=5): 4·54 = 16 Dreiecke, 10 Ecken.
const pentagon = [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2.5, y: 1.5 },
{ x: 1, y: 2.5 },
{ x: -0.5, y: 1.5 },
];
const pentaStl = exportStl(projectWithPolygonExtrusion(pentagon));
expect(pentaStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(16);
const pentaObj = exportObj(projectWithPolygonExtrusion(pentagon));
expect(parseObjVertices(pentaObj)).toHaveLength(10);
});
it("Fenster wird als echtes Loch ausgeschnitten (Gitter-Zerlegung + 4 Laibungen, 48 Dreiecke statt 12)", () => {
// Fenster [x 2..3] × [y 0.9..2.4] vollständig im Wand-Inneren. Erwartung
// (= Rust-Zerlegung `extrude_layer_segment_with_holes`): 8 solide Langseiten-
// Teilrechtecke (3×3-Gitter minus Loch) × 2 Seiten × 2 Dreiecke = 32,
// + Deckel/Boden/2 Stirnkappen = 8, + 4 Laibungen × 2 = 8 → 48 Dreiecke.
const stl = exportStl(projectWithOpening("window"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(48);
// Die volle Box hätte NUR Ecken bei y∈{0,2.6}, x∈{0,5}. Der Ausschnitt
// führt echte Vertices an den Loch-Rändern ein — Beweis, dass das Loch
// wirklich in der Fläche steckt (nicht bloss eine übergelegte Scheibe).
const verts = parseObjVertices(exportObj(projectWithOpening("window")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
expect(verts.some((v) => near(v[1], 0.9))).toBe(true); // Brüstungs-OK
expect(verts.some((v) => near(v[1], 2.4))).toBe(true); // Sturz-UK
expect(verts.some((v) => near(v[0], 2))).toBe(true); // linke Loch-Kante
expect(verts.some((v) => near(v[0], 3))).toBe(true); // rechte Loch-Kante
// Deutlich mehr Vertices als die 8-Ecken-Vollbox.
expect(verts.length).toBe(144);
});
it("Tür berührt die Wand-UK: keine untere Laibung (36 Dreiecke, keine Brüstungs-Zerlegung)", () => {
// Tür [x 2..3] × [y 0..2.1]: Boden bekommt die Lücke [2,3] (statt einer
// unteren Laibung) → 5 Langseiten-Teilrechtecke × 2 × 2 = 20, Deckel(1)=2,
// Boden(2 Streifen)=4, 2 Stirnkappen=4, 3 Laibungen (links/rechts/oben)=6 → 36.
const stl = exportStl(projectWithOpening("door"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(36);
const verts = parseObjVertices(exportObj(projectWithOpening("door")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
// Sturz-UK bei y=2.1 vorhanden, aber KEIN Brüstungsvertex (Tür sitzt am Boden).
expect(verts.some((v) => near(v[1], 2.1))).toBe(true);
expect(verts.every((v) => v[1] >= -1e-6 && v[1] <= 2.6 + 1e-6)).toBe(true);
});
it("facet-Normalen sind Einheitsvektoren aus dem Kreuzprodukt der Dreiecksecken", () => {
const stl = exportStl(projectWithOneWall());
const facetLines = stl.split("\n").filter((l) => l.startsWith("facet normal"));
for (const line of facetLines) {
const [nx, ny, nz] = line.slice("facet normal ".length).trim().split(/\s+/).map(Number);
const len = Math.hypot(nx, ny, nz);
expect(len).toBeGreaterThan(0.99);
expect(len).toBeLessThan(1.01);
}
});
});
+353
View File
@@ -0,0 +1,353 @@
// OBJ-/STL-Export des 3D-Gebäudemodells als reines Dreiecks-Mesh. Pures Modul
// (kein Datei-IO, kein WASM) — Architektur wie exportDxf.ts/exportSchedule.ts:
// buildGroups() liest das Projekt und liefert reine Zahlen-Arrays, exportObj()/
// exportStl() serialisieren sie synchron zu Text.
//
// TRIANGEL-QUELLE (bewusste v1-Entscheidung, siehe PENDENZEN):
// • Wände: pickGeometry() aus toWalls3d.ts liefert dieselben Schicht-Bänder,
// die auch der 3D-Viewer zeichnet (EIN RWall je Materiallage/Achsen-
// Teilstück, bereits mit End-Cuts/Gehrungen/Decken-Dominanz aufgelöst).
// Bänder mit Öffnungs-`holes` werden über den gemeinsamen Loch-Ausschnitt-
// Generator (`plan/wallMeshCut.ts`, TS-Port von render3ds Gitter-Zerlegung
// `extrude_layer_segment_with_holes`) ÖFFNUNGSGENAU trianguliert — die
// Fenster/Türen sind sichtbar ausgestanzt inkl. Laibungsflächen, genau wie
// im 3D-Viewer. Lochlose Bänder bleiben die klassische geteilte 8-Ecken-Box.
// • Decken: pickGeometry() liefert den Umriss + zBottom/zTop je Decke;
// der Umriss wird mit dem vorhandenen Ear-Clipping-Triangulator
// (`triangulate` aus plan/glPlan/glPlanCompile.ts, bereits getestet und
// konkav-fähig) zu Boden-/Deckenkappen trianguliert, dazwischen ein
// Seitenmantel — ein einfaches Umriss-Prisma.
// • ExtrudedSolids (truck-Profile): `src/engine/truckSolid.ts` liefert zwar
// "echte" truck-Dreiecke, ABER nur asynchron über WASM
// (`extrudePolygon`/`extrudeCircle` geben Promises zurück). Das würde die
// geforderte SYNCHRONE, WASM-freie Signatur `exportObj(project): string`
// brechen. Deshalb nutzt dieser Export stattdessen DIESELBE Prisma-
// Triangulierung wie die Decken (Umriss aus `solid.points` — bei
// Kreisprofilen bereits eine 48-Eck-Tessellierung, siehe
// `ExtrudedSolid.points`-Doc) inkl. `taper`-Verjüngung (linear zum
// Schwerpunkt skaliert, wie die truck-Extrusion es tut). Das ist eine
// bewusste Abweichung vom ursprünglichen Plan ("truckSolid-Dreiecke") zu-
// gunsten der pure-Modul-Architektur — ehrlich dokumentiert, kein
// Stillschweigen.
//
// ACHSEN-KONVENTION: identisch zur bestehenden 3D-Pick-/Highlight-Geometrie
// (siehe `selectionHighlightLines` in toWalls3d.ts): Modell (x, y) → Welt
// (x, Höhe, y), Y-UP, rechtshändig. Ein Weltvertex (vx,vy,vz) entspricht also
// (Modell-x, Meter-Höhe, Modell-y) — exakt wie im 3D-Viewer.
import type { Project, Vec2 } from "../model/types";
import { getFloor } from "../model/types";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { wallCutMesh } from "../plan/wallMeshCut";
import { triangulate } from "../plan/glPlan/glPlanCompile";
/**
* Eine Mesh-Gruppe (ein Bauteil-Vorkommen: Wand/Decke/Extrusion) mit LOKALEN
* (0-basierten) Vertex-/Dreiecks-Indizes. `exportObj` verschiebt die Indizes
* beim Serialisieren auf die globale, 1-basierte OBJ-Nummerierung; `exportStl`
* braucht gar keine Indizes über Gruppen hinweg (STL ist unindiziert).
*/
interface MeshGroup {
name: string;
/** Flaches Weltkoordinaten-Array (x,y,z, x,y,z, …), Meter, Y-up. */
positions: number[];
/** Dreiecks-Indizes (3 je Dreieck), 0-basiert relativ zu `positions`. */
indices: number[];
}
function newGroup(name: string): MeshGroup {
return { name, positions: [], indices: [] };
}
/** OBJ-Gruppennamen dürfen keine Whitespaces/Sonderzeichen enthalten. */
function sanitizeName(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, "_") || "x";
}
/**
* Die 6 Quader-Seiten als Eckpunkt-Quadrupel (Index-Bits: bit0=Achse /+,
* bit1=Höhe /+, bit2=Dicke /+ dieselbe Konvention wie `emitOpeningGlass`/
* `selectionHighlightLines` in toWalls3d.ts), in nach AUSSEN gewundener
* Reihenfolge (für ein rechtshändiges Dreibein Achse×Höhe=Dicke-Normale, wie
* es die Wandbox-Konstruktion unten aufspannt). Jedes Quadrupel wird zu 2
* Dreiecken (0,1,2)+(0,2,3).
*/
const BOX_QUADS: Array<[number, number, number, number]> = [
[4, 6, 7, 5], // Dicke +
[1, 3, 2, 0], // Dicke
[2, 3, 7, 6], // Höhe + (Wandkopf)
[4, 5, 1, 0], // Höhe (Wand-UK)
[1, 5, 7, 3], // Achse + (Wandende)
[2, 6, 4, 0], // Achse (Wandanfang)
];
/**
* Hängt EIN Wand-Schicht-Band (`RWall`, siehe toWalls3d.ts) an eine Mesh-Gruppe
* an: hat das Band echte Öffnungs-`holes`, wird das öffnungsgenaue Loch-Ausschnitt-
* Mesh (siehe {@link wallCutMesh}) verwendet die Fenster/Türen sind sichtbar
* ausgestanzt inkl. Laibungen, exakt wie im 3D-Viewer. Ohne Löcher bleibt es die
* klassische, geteilte 8-Ecken-Box ({@link pushWallBox}) bitgleich zum
* bisherigen Verhalten (Joins/Gehrungen stecken schon in `start`/`end`).
*/
function pushWall(group: MeshGroup, w: RWall): void {
if (w.holes && w.holes.length > 0) {
const cut = wallCutMesh(w);
const base = group.positions.length / 3;
for (const p of cut.positions) group.positions.push(p);
for (const idx of cut.indices) group.indices.push(base + idx);
return;
}
pushWallBox(group, w);
}
/**
* Hängt EINEN vollen Wand-Schicht-Quader (`RWall`) an eine Mesh-Gruppe an Box
* über Achse×Höhe×Dicke, OHNE Öffnungs-Ausschnitt (nur für lochlose Bänder, siehe
* {@link pushWall}). Degenerierte Bänder (Länge/Höhe/Dicke 0) werden übersprungen.
*/
function pushWallBox(group: MeshGroup, w: RWall): void {
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dz = ey - sy;
const len = Math.hypot(dx, dz);
if (len < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return;
const ax = dx / len;
const az = dz / len;
// Normale = Welt-Hoch × Achse (rechtshändig), dieselbe Konvention wie
// `selectionHighlightLines`: normal = (az, 0, ax).
const nx = az;
const nz = -ax;
const halfLen = len / 2;
const halfHeight = w.height / 2;
const halfThick = w.thickness / 2;
const cx = (sx + ex) / 2;
const cz = (sy + ey) / 2;
const cy = w.baseElevation + w.height / 2;
const base = group.positions.length / 3;
for (let i = 0; i < 8; i++) {
const sL = i & 1 ? 1 : -1;
const sH = i & 2 ? 1 : -1;
const sT = i & 4 ? 1 : -1;
group.positions.push(
cx + sL * halfLen * ax + sT * halfThick * nx,
cy + sH * halfHeight,
cz + sL * halfLen * az + sT * halfThick * nz,
);
}
for (const [a, b, c, d] of BOX_QUADS) {
group.indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
}
}
/** Signierte Fläche eines Modell-XY-Umrisses (Shoelace); ≥0 = CCW. */
function signedAreaXY(pts: Vec2[]): number {
let a = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
a += pts[j].x * pts[i].y - pts[i].x * pts[j].y;
}
return a / 2;
}
/**
* Hängt ein extrudiertes Umriss-Prisma (Decke ODER truck-Profil-Extrusion,
* siehe Moduldoc) an eine Mesh-Gruppe an: Boden-/Deckkappe (Ear-Clipping-
* Triangulierung via `triangulate`) + Seitenmantel. `taper` (0..1) verjüngt
* den OBEREN Ring linear zum Flächenschwerpunkt dieselbe Semantik wie
* `ExtrudedSolid.taper`/die truck-Extrusion (0 = Prisma, 1 = Spitze/Pyramide).
* Der Umriss wird intern auf CCW (Modell-XY) normalisiert, damit die
* Seitenmantel-Normalen unabhängig von der Eingabe-Wicklung nach außen zeigen.
*/
function pushPrism(
group: MeshGroup,
outline: Vec2[],
zBottom: number,
zTop: number,
taper = 0,
): void {
if (outline.length < 3 || zTop - zBottom <= 1e-9) return;
const ccw = signedAreaXY(outline) >= 0 ? outline : [...outline].reverse();
const n = ccw.length;
let cx = 0;
let cyModel = 0;
for (const p of ccw) {
cx += p.x;
cyModel += p.y;
}
cx /= n;
cyModel /= n;
const t = Math.min(1, Math.max(0, taper));
const base = group.positions.length / 3;
// Unterer Ring (base + 0..n-1), oberer Ring (base + n..2n-1).
for (const p of ccw) group.positions.push(p.x, zBottom, p.y);
for (const p of ccw) {
const tx = cx + (p.x - cx) * (1 - t);
const ty = cyModel + (p.y - cyModel) * (1 - t);
group.positions.push(tx, zTop, ty);
}
// Kappen: `triangulate` liefert bei CCW-Eingabe stets CCW-in-Modell-XY-
// Dreiecke — eingebettet in die Weltebene (x, const, y) entspricht das einer
// nach UNTEN (Y) zeigenden Flächennormale (Kreuzprodukt-Herleitung siehe
// Moduldoc-Kommentar oben). Die Bodenkappe nutzt diese Wicklung direkt
// (Normale zeigt nach unten = nach außen), die Deckkappe braucht die
// umgekehrte Wicklung (Normale nach oben).
const capTris = triangulate(ccw);
for (let i = 0; i < capTris.length; i += 3) {
const a = capTris[i];
const b = capTris[i + 1];
const c = capTris[i + 2];
group.indices.push(base + a, base + b, base + c);
group.indices.push(base + n + a, base + n + c, base + n + b);
}
// Seitenmantel: je Umrisskante ein Quad (2 Dreiecke), Wicklung so, dass die
// Normale nach außen zeigt (siehe Moduldoc-Herleitung).
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const bi = base + i;
const bj = base + j;
const ti = base + n + i;
const tj = base + n + j;
group.indices.push(bi, ti, tj);
group.indices.push(bi, tj, bj);
}
}
/** Baut alle Mesh-Gruppen (Wände, Decken, Extrusionen) des Projekts. */
function buildGroups(project: Project): MeshGroup[] {
const groups: MeshGroup[] = [];
const { walls, slabs } = pickGeometry(project);
// Wände: alle Schicht-Bänder DERSELBEN Wand-Id in EINE Gruppe (mehrere
// Materiallagen ergeben mehrere Boxen im selben Bauteil-Vorkommen).
const wallGroups = new Map<string, MeshGroup>();
for (const w of walls) {
let g = wallGroups.get(w.wallId);
if (!g) {
g = newGroup(`Wand_${sanitizeName(w.wallId)}`);
wallGroups.set(w.wallId, g);
groups.push(g);
}
pushWall(g, w);
}
// Decken: ein Prisma je Decke.
for (const s of slabs) {
const g = newGroup(`Decke_${sanitizeName(s.ceilingId)}`);
const outline: Vec2[] = s.outline.map(([x, y]) => ({ x, y }));
pushPrism(g, outline, s.zBottom, s.zTop);
groups.push(g);
}
// Extrudierte Profile (truck-Integration): eigenes Prisma je Körper (siehe
// Moduldoc — bewusst NICHT die truck-WASM-Dreiecke, aus Sync-/Pure-Gründen).
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3 || solid.height <= 0) continue;
let baseZ = 0;
try {
baseZ = getFloor(project, solid.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback, wie toWalls3d).
}
const g = newGroup(`Extrusion_${sanitizeName(solid.id)}`);
pushPrism(g, solid.points, baseZ, baseZ + solid.height, solid.taper ?? 0);
groups.push(g);
}
return groups.filter((g) => g.indices.length > 0);
}
/** Kompakte, verlustarme Zahlformatierung (bis 6 Nachkommastellen, ohne Nullen). */
function fmt(v: number): string {
if (!Number.isFinite(v)) return "0.0";
const s = v.toFixed(6);
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0");
}
/**
* Baut einen vollständigen Wavefront-OBJ-String des 3D-Gebäudemodells (Meter,
* rechtshändig, Y-up siehe Moduldoc). Ein `o`-Objekt je Bauteil-Vorkommen
* (Wand/Decke/Extrusion). Leeres Projekt gültige Datei nur mit Kopf-
* Kommentaren, kein `v`/`f` (kein Crash).
*/
export function exportObj(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = [
"# DOSSIER OBJ-Export - Dreiecks-Mesh des 3D-Gebaeudemodells",
"# Achsen: rechtshaendig, Y-up (Y = Hoehe ueber OKFF), wie der 3D-Viewer.",
"# Waende: Schicht-Baender mit oeffnungsgenau ausgeschnittenen Loechern (inkl. Laibungen).",
"# Decken/Extrusionen: Umriss-Prisma (Ear-Clipping-Triangulierung).",
];
let offset = 0;
for (const g of groups) {
const vertCount = g.positions.length / 3;
if (vertCount === 0) continue;
lines.push(`o ${g.name}`);
for (let i = 0; i < g.positions.length; i += 3) {
lines.push(`v ${fmt(g.positions[i])} ${fmt(g.positions[i + 1])} ${fmt(g.positions[i + 2])}`);
}
for (let i = 0; i < g.indices.length; i += 3) {
const a = g.indices[i] + 1 + offset;
const b = g.indices[i + 1] + 1 + offset;
const c = g.indices[i + 2] + 1 + offset;
lines.push(`f ${a} ${b} ${c}`);
}
offset += vertCount;
}
return lines.join("\n") + "\n";
}
/**
* Baut einen vollständigen ASCII-STL-String des 3D-Gebäudemodells (Meter,
* dieselbe Achsen-Konvention wie {@link exportObj}). Jedes Dreieck trägt eine
* aus dem Kreuzprodukt berechnete Facettennormale (konsistente Winding, siehe
* `pushWallBox`/`pushPrism`). Leeres Projekt gültiges leeres `solid`-Gerüst.
*/
export function exportStl(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = ["solid dossier"];
for (const g of groups) {
for (let i = 0; i < g.indices.length; i += 3) {
const ia = g.indices[i] * 3;
const ib = g.indices[i + 1] * 3;
const ic = g.indices[i + 2] * 3;
const ax = g.positions[ia];
const ay = g.positions[ia + 1];
const az = g.positions[ia + 2];
const bx = g.positions[ib];
const by = g.positions[ib + 1];
const bz = g.positions[ib + 2];
const cx = g.positions[ic];
const cy = g.positions[ic + 1];
const cz = g.positions[ic + 2];
const ux = bx - ax;
const uy = by - ay;
const uz = bz - az;
const vx = cx - ax;
const vy = cy - ay;
const vz = cz - az;
let nx = uy * vz - uz * vy;
let ny = uz * vx - ux * vz;
let nz = ux * vy - uy * vx;
const len = Math.hypot(nx, ny, nz);
if (len > 1e-12) {
nx /= len;
ny /= len;
nz /= len;
} else {
nx = 0;
ny = 0;
nz = 0;
}
lines.push(`facet normal ${fmt(nx)} ${fmt(ny)} ${fmt(nz)}`);
lines.push("outer loop");
lines.push(`vertex ${fmt(ax)} ${fmt(ay)} ${fmt(az)}`);
lines.push(`vertex ${fmt(bx)} ${fmt(by)} ${fmt(bz)}`);
lines.push(`vertex ${fmt(cx)} ${fmt(cy)} ${fmt(cz)}`);
lines.push("endloop");
lines.push("endfacet");
}
}
lines.push("endsolid dossier");
return lines.join("\n") + "\n";
}
+200 -15
View File
@@ -1,14 +1,18 @@
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6).
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke).
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6 + D2).
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände, 1 Decke, 1 Tür,
// 1 Fenster, 1 Treppe, 1 Extrusion).
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
import { describe, it, expect } from "vitest";
import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
import type { Project, Wall, Ceiling } from "../model/types";
import type { Project, Wall, Ceiling, Door, Opening, Stair, ExtrudedSolid } from "../model/types";
/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */
/**
* Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2) +
* 1 Tür + 1 Fenster (je an W1 gehostet) + 1 Treppe + 1 Extrusion.
*/
function fixtureProject(): Project {
const walls: Wall[] = [
{
@@ -48,6 +52,61 @@ function fixtureProject(): Project {
ceilingTypeId: "dt",
},
];
const doors: Door[] = [
{
id: "T1",
type: "door",
hostWallId: "W1",
categoryCode: "21",
position: 1,
width: 0.9,
height: 2.1, // Fläche 1.89
swing: "left",
hinge: "start",
},
];
const openings: Opening[] = [
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5, // Fläche 1.80
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3, // Länge 3 (kein 2. Lauf)
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
], // 2×3 = 6 m²
height: 2.5,
},
];
return {
id: "t",
name: "T",
@@ -61,10 +120,11 @@ function fixtureProject(): Project {
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls,
doors: [],
openings: [],
doors,
openings,
ceilings,
stairs: [],
stairs,
extrudedSolids,
rooms: [],
drawings2d: [],
context: [],
@@ -76,8 +136,8 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
const lines = csv.split("\r\n");
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke).
expect(lines).toHaveLength(4);
// Kopfzeile + 7 Bauteil-Zeilen (2 Wände, 1 Decke, 1 Tür, 1 Fenster, 1 Treppe, 1 Extrusion).
expect(lines).toHaveLength(8);
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
@@ -86,19 +146,35 @@ describe("exportScheduleCsv — Bauteilliste", () => {
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
// Tür T1: Breite 0.9, Höhe 2.1, keine Dicke, Fläche 1.89.
expect(lines[4]).toBe("Tür;T1;Tür;EG;0.90;2.10;;1.89");
// Fenster F1: Breite 1.2, Höhe 1.5, keine Dicke, Fläche 1.80.
expect(lines[5]).toBe("Fenster;F1;Fenster;EG;1.20;1.50;;1.80");
// Treppe S1: Lauflänge 3 (kein 2. Lauf), Steighöhe 2.6, keine Dicke/Fläche.
expect(lines[6]).toBe("Treppe;S1;Treppe;EG;3.00;2.60;;");
// Extrusion E1: keine Länge, Höhe 2.5, keine Dicke, Grundrissfläche 6.
expect(lines[7]).toBe("Extrusion;E1;Extrusion;EG;;2.50;;6.00");
});
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
const csv = exportScheduleCsv(fixtureProject());
const lines = csv.split("\r\n");
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8.
expect(lines).toHaveLength(8);
expect(lines[4]).toBe("");
expect(lines[5]).toContain("Zusammenfassung");
// Kopf(1) + 7 Zeilen + Leerzeile + Titel + 6 Aggregate = 16.
expect(lines).toHaveLength(16);
expect(lines[8]).toBe("");
expect(lines[9]).toContain("Zusammenfassung");
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
expect(lines[10]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
expect(lines[11]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
// Tür-Aggregat: 1 Stk, Gesamtbreite 0.9, Fläche 1.89.
expect(lines[12]).toBe("Tür;;Tür;1 Stk;0.90;;;1.89");
// Fenster-Aggregat: 1 Stk, Gesamtbreite 1.2, Fläche 1.80.
expect(lines[13]).toBe("Fenster;;Fenster;1 Stk;1.20;;;1.80");
// Treppen-Aggregat: 1 Stk, Gesamtlänge 3, keine bekannte Fläche (leer statt 0.00).
expect(lines[14]).toBe("Treppe;;Treppe;1 Stk;3.00;;;");
// Extrusions-Aggregat: 1 Stk, keine Länge, Fläche 6.
expect(lines[15]).toBe("Extrusion;;Extrusion;1 Stk;;;;6.00");
});
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
@@ -114,8 +190,117 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const proj = fixtureProject();
proj.walls = [];
proj.ceilings = [];
proj.doors = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
const csv = exportScheduleCsv(proj);
expect(csv.split("\r\n")).toHaveLength(1);
expect(scheduleRows(proj)).toHaveLength(0);
});
});
describe("scheduleRows — Dächer und Stützen (bisher fehlten sie in Liste/Element-Baum)", () => {
it("wertet ein Dach mit Dachtyp aus (Typname aus roofTypes, Dicke aus Schichtsumme via roof.thickness)", () => {
const proj = fixtureProject();
proj.roofTypes = [{ id: "rt", name: "Warmdach", layers: [{ componentId: "c", thickness: 0.35 }] }];
proj.roofs = [
{
id: "R1",
type: "roof",
floorId: "eg",
categoryCode: "31",
outline: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 0, y: 3 },
], // 4×3 = 12 m²
shape: "flach",
pitchDeg: 0,
overhang: 0,
ridgeAxis: "x",
thickness: 0.35,
roofTypeId: "rt",
},
];
const rows = scheduleRows(proj).filter((r) => r.kind === "Dach");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
kind: "Dach",
id: "R1",
typeName: "Warmdach",
floor: "EG",
length: null,
heightM: null,
thickness: 0.35,
area: 12,
});
});
it("wertet eine rechteckige und eine runde Stütze aus (Profil-Masse als Länge/Dicke)", () => {
const proj = fixtureProject();
proj.columns = [
{
id: "C1",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 1, y: 1 },
profile: { kind: "rect", width: 0.3, depth: 0.4 },
rotation: 0,
height: 2.6,
},
{
id: "C2",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 2, y: 2 },
profile: { kind: "round", radius: 0.15 },
rotation: 0,
height: 2.6,
},
];
const rows = scheduleRows(proj).filter((r) => r.kind === "Stütze");
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
id: "C1",
typeName: "Rechteck",
length: 0.3,
heightM: 2.6,
thickness: 0.4,
area: null,
});
expect(rows[1]).toMatchObject({
id: "C2",
typeName: "Rund",
length: null,
heightM: 2.6,
thickness: 0.3, // 2 × Radius
area: null,
});
});
it("Räume bleiben bewusst ausgeschlossen (eigener CSV-Export, siehe Dateikopf)", () => {
const proj = fixtureProject();
proj.rooms = [
{
id: "RM1",
type: "room",
floorId: "eg",
categoryCode: "45",
siaCategory: "HNF",
name: "Wohnen",
boundary: [
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 3 },
{ x: 0, y: 3 },
],
color: "#fff",
},
];
expect(scheduleRows(proj).some((r) => r.id === "RM1")).toBe(false);
});
});
Binary file not shown.
+181
View File
@@ -0,0 +1,181 @@
// Ordner → Mehrseiten-PDF (DOSSIER A3): exportiert alle Layouts eines Ordners
// als EIN gemeinsames PDF, jedes Layout = eine Seite. Das Rendering je Seite
// spiegelt exakt `ui/LayoutSheet.tsx` (Master-Rahmen + Titelblock + echte
// Viewport-Pläne via `generatePlan`→`planToPrintSvg`) und komponiert die Seiten
// per jsPDF (`addPage` je Layout) analog `export/exportPdf.ts` (buildPlanPdf).
//
// Trennung: die Seiten-Sammlung/-Reihenfolge + effektive Blattgrösse ist reine,
// testbare Logik (`folderPdfPages`, layoutModel); das SVG/jsPDF-Rendering
// (`buildFolderPdf`) braucht ein DOM und läuft nur im Browser/Tauri.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { jsPDF } from "jspdf";
import "svg2pdf.js";
import type { MasterLayout, Project } from "../model/types";
import { generatePlan } from "../plan/generatePlan";
import { planToPrintSvg } from "./planToPrintSvg";
import { saveBinaryFile } from "../io/saveFile";
import {
findMaster,
findSnapshot,
folderPdfPages,
resolveTitleBlock,
resolveViewportScale,
visibleCodesFromSnapshot,
type FolderPdfPage,
type ResolvedTitleBlock,
} from "../panels/layoutModel";
export { folderPdfPages, type FolderPdfPage };
/** Titelblock (mm-Vektor) auf die aktuelle jsPDF-Seite — Anmutung wie LayoutSheet. */
function drawTitleBlock(
doc: jsPDF,
sheetW: number,
sheetH: number,
tb: ResolvedTitleBlock,
paper: string,
orientation: string,
): void {
const boxW = 70;
const boxH = 30;
const pad = 6;
const x = sheetW - boxW - pad;
const y = sheetH - boxH - pad;
const fmt = `${paper.toUpperCase()} ${orientation === "landscape" ? "quer" : "hoch"}`;
doc.setFillColor(255, 255, 255);
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(x, y, boxW, boxH, "FD");
doc.setLineWidth(0.2);
doc.line(x, y + 8, x + boxW, y + 8);
doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7);
doc.setTextColor(17, 17, 17);
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.text(clip(tb.projectName, 34), x + 3, y + 5.6);
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.text(clip(tb.sheetName, 36), x + 3, y + 13);
if (tb.author) {
doc.setFontSize(7);
doc.setTextColor(68, 68, 68);
doc.text(clip(tb.author, 40), x + 3, y + 18);
doc.setTextColor(17, 17, 17);
}
doc.setFontSize(8);
doc.text(tb.scale, x + 3, y + boxH - 2.4);
doc.setFontSize(7);
doc.text(`${tb.date} · ${fmt}`, x + boxW - 3, y + boxH - 2.4, { align: "right" });
}
function clip(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
/** Zeichnet die Viewports + Master-Elemente EINER Seite in das jsPDF-Dokument. */
async function renderPage(
doc: jsPDF,
project: Project,
page: FolderPdfPage,
master: MasterLayout | undefined,
): Promise<void> {
const { layout, widthMm, heightMm } = page;
// Echte Viewport-Pläne (wie ViewportPlan in LayoutSheet.tsx).
for (const vp of layout.viewports) {
const snap = findSnapshot(project, vp.snapshotId);
if (!snap) continue;
try {
const codes = visibleCodesFromSnapshot(snap);
const plan = generatePlan(
project,
snap.activeLevelId,
codes,
undefined,
snap.detail,
false,
false,
);
const scaleN = resolveViewportScale(vp, snap);
const print = planToPrintSvg(plan, {
scaleDenominator: scaleN,
pageWidthMm: vp.widthMm,
pageHeightMm: vp.heightMm,
});
await doc.svg(print.svg, {
x: vp.xMm,
y: vp.yMm,
width: vp.widthMm,
height: vp.heightMm,
});
} catch {
// Einzelner Viewport, der nicht rendert, darf die Seite nicht killen.
}
}
// Master: Rahmen + Titelblock (read-only, wie im In-Viewport-Editor).
if (master) {
if (master.border ?? true) {
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(5, 5, widthMm - 10, heightMm - 10);
}
const tb = resolveTitleBlock(project, layout, master);
drawTitleBlock(doc, widthMm, heightMm, tb, layout.paper, layout.orientation);
}
}
/**
* Baut das Mehrseiten-PDF eines Ordners und liefert das jsPDF-Dokument. Jede
* Seite = ein Layout in Baum-Reihenfolge. Gibt `null`, wenn der Ordner keine
* Layouts enthält (der Aufrufer meldet das dann in der UI).
*/
export async function buildFolderPdf(
project: Project,
folderId: string,
): Promise<jsPDF | null> {
const pages = folderPdfPages(project, folderId);
if (pages.length === 0) return null;
const orientOf = (p: FolderPdfPage) =>
p.widthMm > p.heightMm ? "landscape" : "portrait";
const first = pages[0];
const doc = new jsPDF({
unit: "mm",
format: [first.widthMm, first.heightMm],
orientation: orientOf(first),
compress: true,
});
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
if (i > 0) doc.addPage([page.widthMm, page.heightMm], orientOf(page));
const master = findMaster(project, page.layout);
await renderPage(doc, project, page, master);
}
return doc;
}
/**
* Baut das Ordner-PDF und speichert es (nativer Tauri-Dialog bzw.
* Browser-Download). Liefert `false`, wenn nichts zu exportieren war oder der
* Speichern-Dialog abgebrochen wurde.
*/
export async function saveFolderPdf(
project: Project,
folderId: string,
fileName: string,
): Promise<boolean> {
const doc = await buildFolderPdf(project, folderId);
if (!doc) return false;
const bytes = doc.output("arraybuffer");
const name = fileName.replace(/\.pdf$/i, "");
return saveBinaryFile(new Uint8Array(bytes), `${name}.pdf`, "application/pdf");
}
+46
View File
@@ -0,0 +1,46 @@
// Stützen-Geometrie: leitet aus einer Stütze (Column) ihren Grundriss-Querschnitt
// (Poché-/Footprint-Polygon) ab. Reine Funktion, keine Modell-Mutation — sowohl
// der 2D-Grundriss (generatePlan) als auch die 3D-Extrusion (toWalls3d) und die
// Auswahl-/Transform-Vorschau (transform.ts) nutzen dieselbe Kontur.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Column, Vec2 } from "../model/types";
/** Tessellierungs-Segmentzahl eines runden Stützenprofils (Grundriss + 3D-Prisma). */
export const COLUMN_ROUND_SEGMENTS = 32;
/**
* Grundriss-Querschnitt einer Stütze als geschlossenes Polygon (Modell-Meter,
* CCW), um `position` platziert und (bei Rechteck) um `rotation` gedreht. Ein
* rundes Profil wird als regelmäßiges {@link COLUMN_ROUND_SEGMENTS}-Eck
* tesselliert; der Schlusspunkt wird NICHT dupliziert.
*/
export function columnFootprint(col: Column): Vec2[] {
const { x: cx, y: cy } = col.position;
if (col.profile.kind === "round") {
const r = col.profile.radius;
const pts: Vec2[] = [];
for (let i = 0; i < COLUMN_ROUND_SEGMENTS; i++) {
const t = (i / COLUMN_ROUND_SEGMENTS) * Math.PI * 2;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
return pts;
}
// Rechteck: lokale Ecken ±w/2 (X) × ±d/2 (Y), um `rotation` gedreht.
const hw = col.profile.width / 2;
const hd = col.profile.depth / 2;
const rot = col.rotation ?? 0;
const cos = Math.cos(rot);
const sin = Math.sin(rot);
const local: Vec2[] = [
{ x: -hw, y: -hd },
{ x: +hw, y: -hd },
{ x: +hw, y: +hd },
{ x: -hw, y: +hd },
];
return local.map((p) => ({
x: cx + p.x * cos - p.y * sin,
y: cy + p.x * sin + p.y * cos,
}));
}
+325 -1
View File
@@ -60,7 +60,7 @@ import {
stairGeometry,
type StairGeometry as TSStairGeometry,
} from "./stair";
import type { Stair } from "../model/types";
import { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types";
import {
detectRooms,
pointInPolygon as rbPointInPolygon,
@@ -69,6 +69,19 @@ import {
type WallSegment,
type WallFace,
} from "./roomBoundary";
import { wallReferenceOffset } from "../model/wall";
import {
doorSymbol,
openingCenter,
openingGapQuad,
openingInterval,
openingJambs,
wallAxisFrame,
wallAxisLength,
windowSymbol,
type DoorSymbol,
type WindowSymbol,
} from "./opening";
type Poly = { pts: Vec2[]; closed: boolean };
@@ -912,6 +925,317 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBou
});
});
// ── Slice 4: opening ──────────────────────────────────────────────────────────
// `opening.ts` ist model-gekoppelt (Wall/Opening/Project) — die geometrisch
// reinen Kerne wurden mit abgeflachter Signatur (Vec2/number) portiert
// (PORT_PLAN §1). `thickness`/`refOffset` werden hier — wie beim Rust-Port —
// bereits aufgeloest uebergeben (`wallTypeThickness`/`wallReferenceOffset`,
// nicht ueber `getWallType`/ein volles `Project` im WASM-Aufruf).
/** Zufaellige, NICHT entartete Wand (Laenge 0.5..15.5 m). */
function genWall(rng: () => number): Wall {
const start = v(rng);
const angle = rng() * Math.PI * 2;
const length = 0.5 + rng() * 15;
const end = { x: start.x + Math.cos(angle) * length, y: start.y + Math.sin(angle) * length };
const refs = ["left", "center", "right"] as const;
return {
id: "w1",
type: "wall",
floorId: "f1",
categoryCode: "20",
start,
end,
wallTypeId: "wt1",
height: 2.4,
referenceLine: refs[Math.floor(rng() * 3)],
};
}
/** Einschichtiger WallType mit zufaelliger Dicke (0.15..0.5 m). */
function genWallType(rng: () => number): WallType {
return { id: "wt1", name: "wt", layers: [{ componentId: "c1", thickness: 0.15 + rng() * 0.35 }] };
}
/** Minimales Project — `getWallType` liest nur `wallTypes`. */
function makeProject(wallType: WallType): Project {
return { wallTypes: [wallType] } as unknown as Project;
}
/** Zufaellige Oeffnung auf `wall`: position/width teils ausserhalb der Achse
* oder negativ (Strukturabdeckung fuer den null-Zweig von `openingInterval`). */
function genOpening(rng: () => number, wall: Wall, kind: "window" | "door"): Opening {
const axis = wallAxisLength(wall);
const position = (rng() * 1.6 - 0.3) * axis;
const width = rng() < 0.1 ? -0.5 + rng() * 0.5 : 0.3 + rng() * Math.max(0.3, axis);
const swings = ["left", "right"] as const;
const hinges = ["start", "end"] as const;
const dirs = ["in", "out"] as const;
return {
id: "o1",
type: "opening",
hostWallId: wall.id,
categoryCode: "21",
kind,
position,
width,
height: 1.2,
sillHeight: kind === "door" ? 0 : 0.8,
wingCount: rng() < 0.5 ? undefined : 1 + Math.floor(rng() * 4),
hinge: rng() < 0.3 ? undefined : hinges[Math.floor(rng() * 2)],
swing: rng() < 0.3 ? undefined : swings[Math.floor(rng() * 2)],
swingAngle: rng() < 0.3 ? undefined : 30 + rng() * 120,
openingDir: rng() < 0.3 ? undefined : dirs[Math.floor(rng() * 2)],
};
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 4: opening", () => {
it("wallAxisLength (Zufallswaende, rel 1e-9)", () => {
const rng = mulberry32(50);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_length_batch_json(JSON.stringify(qs))) as number[];
walls.forEach((wl, i) => {
expect(closeNum(w[i], wallAxisLength(wl)), `axisLen#${i}`).toBe(true);
});
});
it("openingInterval: Struktur exakt (null-Zweig inklusive) + Werte", () => {
const rng = mulberry32(51);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const w = JSON.parse(K.opening_interval_batch_json(JSON.stringify(qs))) as (
| { from: number; to: number }
| null
)[];
cases.forEach(({ wall, o }, i) => {
const t = openingInterval(wall, o);
expect(w[i] === null, `iv-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeNum(w[i]!.from, t.from), `iv-from#${i}`).toBe(true);
expect(closeNum(w[i]!.to, t.to), `iv-to#${i}`).toBe(true);
}
});
});
it("wallAxisFrame (u/n, rel 1e-9)", () => {
const rng = mulberry32(52);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_frame_batch_json(JSON.stringify(qs))) as { u: Vec2; n: Vec2 }[];
walls.forEach((wl, i) => {
const t = wallAxisFrame(wl);
expect(closeVec(w[i].u, t.u), `frame-u#${i}`).toBe(true);
expect(closeVec(w[i].n, t.n), `frame-n#${i}`).toBe(true);
});
});
it("openingJambs / openingCenter: Struktur exakt (null-Zweig) + Werte", () => {
const rng = mulberry32(53);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const wJ = JSON.parse(K.opening_jambs_batch_json(JSON.stringify(qs))) as (
| { jambStart: Vec2; jambEnd: Vec2 }
| null
)[];
const wC = JSON.parse(K.opening_center_batch_json(JSON.stringify(qs))) as (Vec2 | null)[];
cases.forEach(({ wall, o }, i) => {
const tJ = openingJambs(wall, o);
expect(wJ[i] === null, `jambs-null#${i}`).toBe(tJ === null);
if (tJ && wJ[i]) {
expect(closeVec(wJ[i]!.jambStart, tJ.jambStart), `jambs-start#${i}`).toBe(true);
expect(closeVec(wJ[i]!.jambEnd, tJ.jambEnd), `jambs-end#${i}`).toBe(true);
}
const tC = openingCenter(wall, o);
expect(wC[i] === null, `center-null#${i}`).toBe(tC === null);
if (tC && wC[i]) expect(closeVec(wC[i]!, tC), `center#${i}`).toBe(true);
});
});
it("openingGapQuad / windowSymbol: Struktur exakt + Werte (rel 1e-9)", () => {
const rng = mulberry32(54);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
const wallType = genWallType(rng);
wall.wallTypeId = wallType.id;
const project = makeProject(wallType);
const o = genOpening(rng, wall, "window");
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const glassCount = 1 + Math.floor(rng() * 3);
return { wall, o, project, total, refOffset, glassCount };
});
const qsGap = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
}));
const wGap = JSON.parse(K.opening_gap_quad_batch_json(JSON.stringify(qsGap))) as (Vec2[] | null)[];
cases.forEach((c, i) => {
const t = openingGapQuad(c.project, c.wall, c.o);
expect(wGap[i] === null, `gap-null#${i}`).toBe(t === null);
if (t && wGap[i]) expect(eqPts(wGap[i]!, t), `gap-pts#${i}`).toBe(true);
});
const qsWin = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
glassCount: c.glassCount,
wingCount: c.o.wingCount,
}));
const wWin = JSON.parse(K.window_symbol_batch_json(JSON.stringify(qsWin))) as (WindowSymbol | null)[];
cases.forEach((c, i) => {
const t = windowSymbol(c.project, c.wall, c.o, c.glassCount);
expect(wWin[i] === null, `win-null#${i}`).toBe(t === null);
if (t && wWin[i]) {
expect(eqPts(wWin[i]!.frame, t.frame), `win-frame#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.glassLines, t.glassLines), `win-glass#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.mullionLines, t.mullionLines), `win-mullion#${i}`).toBe(true);
}
});
});
it("doorSymbol: Struktur exakt + Werte (Default-Parameter inklusive)", () => {
const rng = mulberry32(55);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "door") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
swing: o.swing,
openingDir: o.openingDir,
hinge: o.hinge,
swingAngle: o.swingAngle,
}));
const w = JSON.parse(K.door_symbol_batch_json(JSON.stringify(qs))) as (DoorSymbol | null)[];
cases.forEach(({ wall, o }, i) => {
const t = doorSymbol(wall, o);
expect(w[i] === null, `door-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeVec(w[i]!.hinge, t.hinge), `door-hinge#${i}`).toBe(true);
expect(closeVec(w[i]!.openEnd, t.openEnd), `door-open#${i}`).toBe(true);
expect(closeVec(w[i]!.closedEnd, t.closedEnd), `door-closed#${i}`).toBe(true);
expect(closeNum(w[i]!.radius, t.radius), `door-radius#${i}`).toBe(true);
expect(closeVec(w[i]!.jambStart, t.jambStart), `door-jambStart#${i}`).toBe(true);
expect(closeVec(w[i]!.jambEnd, t.jambEnd), `door-jambEnd#${i}`).toBe(true);
expect(closeVec(w[i]!.normal, t.normal), `door-normal#${i}`).toBe(true);
}
});
});
it("Golden: entartetes Intervall (Breite 0 / ausserhalb der Achse) → null; wingCount-Clamp >4 → 3 Pfosten", () => {
const wall: Wall = {
id: "w",
type: "wall",
floorId: "f",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "wt",
height: 2.4,
referenceLine: "center",
};
const wallType: WallType = { id: "wt", name: "wt", layers: [{ componentId: "c", thickness: 0.24 }] };
const project = makeProject(wallType);
// Breite 0 → entartet.
const oZero: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 2,
width: 0,
height: 1.2,
sillHeight: 0.8,
};
const ivZero = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oZero.position, width: oZero.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivZero[0] === null).toBe(true);
expect(ivZero[0] === null).toBe(openingInterval(wall, oZero) === null);
// Vollstaendig ausserhalb der Achse.
const oOut: Opening = { ...oZero, position: 10, width: 1 };
const ivOut = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oOut.position, width: oOut.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivOut[0] === null).toBe(true);
expect(ivOut[0] === null).toBe(openingInterval(wall, oOut) === null);
// wingCount > 4 wird auf 4 geklemmt → 3 Mittelpfosten.
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const oWings: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 1,
width: 2,
height: 1.2,
sillHeight: 0.8,
wingCount: 9,
};
const wWin = JSON.parse(
K.window_symbol_batch_json(
JSON.stringify([
{
start: wall.start,
end: wall.end,
position: oWings.position,
width: oWings.width,
thickness: total,
refOffset,
glassCount: 1,
wingCount: oWings.wingCount,
},
]),
),
) as (WindowSymbol | null)[];
const tWin = windowSymbol(project, wall, oWings, 1);
expect(tWin).not.toBeNull();
expect(wWin[0]).not.toBeNull();
expect(wWin[0]!.mullionLines.length).toBe(tWin!.mullionLines.length);
expect(wWin[0]!.mullionLines.length).toBe(3);
expect(eqPairs(wWin[0]!.mullionLines, tWin!.mullionLines)).toBe(true);
});
});
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
it("parallele/kollineare Strecken → null (beide)", () => {
const qs = [
+313 -17
View File
@@ -11,7 +11,7 @@
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Opening, Project, Vec2, Wall } from "../model/types";
import type { Opening, Project, SashDef, Vec2, Wall } from "../model/types";
import { getWallType, wallTypeThickness } from "../model/types";
import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry";
import { wallReferenceOffset, wallVerticalExtent } from "../model/wall";
@@ -162,35 +162,207 @@ export function doorSymbol(wall: Wall, o: Opening): DoorSymbol | null {
};
}
/** Ein einzelnes Türblatt (Grundriss): Scharnier + geschlossenes/offenes Ende + Radius. */
export interface DoorLeaf {
hinge: Vec2;
openEnd: Vec2;
closedEnd: Vec2;
radius: number;
}
/** Blatt-Geometrie aus Scharnier, geschlossener Richtung, Schwenkrichtung, Winkel, Breite. */
function computeLeaf(
hinge: Vec2,
closedDir: Vec2,
swingDir: Vec2,
angle: number,
width: number,
): DoorLeaf {
const openDir = {
x: closedDir.x * Math.cos(angle) + swingDir.x * Math.sin(angle),
y: closedDir.y * Math.cos(angle) + swingDir.y * Math.sin(angle),
};
return {
hinge,
openEnd: add(hinge, scale(openDir, width)),
closedEnd: add(hinge, scale(closedDir, width)),
radius: width,
};
}
/**
* Türblätter im Grundriss (1 oder 2 Flügel). Einflüglig: ein Blatt über die
* volle Breite (Scharnier je `o.hinge`). Zweiflüglig (`leafCount === 2`): zwei
* halbbreite Blätter, an den GEGENüberliegenden Pfosten angeschlagen, beide zur
* selben Seite schwenkend (symmetrische Doppeltür / Stulptür). `doorSymbol`
* bleibt unverändert (einflügelig) für Bestandspfade/Parität; dies ist additiv.
*/
export function doorLeaves(wall: Wall, o: Opening, leafCount: number): DoorLeaf[] {
const jambs = openingJambs(wall, o);
if (!jambs) return [];
const { u, n } = wallAxisFrame(wall);
const { jambStart, jambEnd } = jambs;
const width = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
const swingSign = (o.swing ?? "left") === "left" ? 1 : -1;
const dirSign = (o.openingDir ?? "in") === "in" ? 1 : -1;
const swingDir = scale(n, swingSign * dirSign);
const angle = (o.swingAngle ?? 90) * DEG;
if (leafCount >= 2) {
const hw = width / 2;
return [
computeLeaf(jambStart, u, swingDir, angle, hw),
computeLeaf(jambEnd, scale(u, -1), swingDir, angle, hw),
];
}
const hinge = (o.hinge ?? "start") === "start" ? jambStart : jambEnd;
const closedDir = (o.hinge ?? "start") === "start" ? u : scale(u, -1);
return [computeLeaf(hinge, closedDir, swingDir, angle, width)];
}
// ── Fenster-Symbol (Plan) ────────────────────────────────────────────────────
/** Aufgelöste Fenster-Linien im Grundriss (Weltkoordinaten). */
export interface WindowSymbol {
/** Rahmen-Rechteck (volle Dicke) als Ecken. */
frame: Vec2[];
/** Glaslinien-Segmente quer über die Lücke (12 parallele Linien). */
/** Glaslinien-Segmente quer über die Lücke (13 parallele Linien). */
glassLines: [Vec2, Vec2][];
/**
* Flügel-Mittelpfosten (`wingCount 1` Querlinien), jeweils von einer
* Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur
* Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei
* `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces`
* des Rhino-Plugins (`oeff_fluegel`).
* Flügel-/Pfosten-Trennlinien, jeweils von einer Laibungsseite zur anderen
* (parallel zu den Laibungslinien, quer zur Öffnungsrichtung). Ist `sashes`
* (dritter Parameter von {@link windowSymbol}) gesetzt, ergeben sich die
* Positionen aus den Flügel-/Pfostenbreiten (siehe `sashSegments`); sonst
* (Alt-Fall) `wingCount 1` gleichmäßig verteilte Querlinien. Entspricht den
* Mittelpfosten in `_make_oeffnung_pieces` des Rhino-Plugins (`oeff_fluegel`).
*/
mullionLines: [Vec2, Vec2][];
/**
* Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel (Dreh/Kipp/Drehkipp/
* Schiebe): ein Rechteck INNERHALB des Blendrahmens (Schachtelung
* Blendrahmen Flügelrahmen Glas), das den öffenbaren Flügel vom festen
* Feld unterscheidet. Feste Flügel (`opening:"fest"`) bekommen KEINEN
* Flügelrahmen dort sitzt das Glas direkt im Blendrahmen. Nur befüllt im
* neuen (spec-)Pfad; leer im Alt-Fall (kein `spec`).
*/
sashFrames: Vec2[][];
/**
* (Alt-Feld, für Rückwärtskompatibilität der Schnittstelle beibehalten) im
* neuen Pfad leer; die Öffenbarkeit wird jetzt über {@link sashFrames}
* ausgedrückt statt über eine Diagonale quer über die Box.
*/
sashOpeningLines: [Vec2, Vec2][];
/**
* Stulp-/Flügelstoss-Marken (SIA-kleines Quadrat mittig", Figur 37/38): je
* Flügel-Trennung ein kleines Rechteck-Polygon auf der Glasebene, das den
* Anschlag der zwei zusammenstossenden Flügelprofile darstellt. Bei mittel"
* ein Quadrat je Stoss, bei fein" zwei (die beiden Stulpprofile). „grob":
* leer. Zählt zur "Fenster"-Linien-Kategorie (Flügel/Sprossen), NICHT zum
* Blendrahmen separat von {@link jambMarks} steuerbar. Der Aufrufer
* zeichnet sie als dünne Umriss-Rechtecke.
*/
meetingMarks: Vec2[][];
/**
* Blendrahmen-Querschnittsblöcke an den beiden Laibungs-Enden (ein Block je
* Seite, unabhängig von Flügelanzahl/Detailgrad) Teil des Blendrahmens,
* zählt zur "Rahmen"-Linien-Kategorie (im Unterschied zu {@link meetingMarks},
* die an inneren Flügelstössen sitzen und zur "Fenster"-Kategorie zählen).
*/
jambMarks: Vec2[][];
}
/**
* Render-Parameter des Fensters für die reiche 2D-Symbolik (aus dem
* referenzierten {@link WindowType}): Flügeltabelle, Profilbreite, Rahmentiefe
* und Schichteinzug. Fehlt der Spec (kein Typ auflösbar), zeichnet
* {@link windowSymbol} das schlichte Alt-Symbol (`o.wingCount`, volle Wanddicke).
*/
export interface WindowRenderSpec {
sashes: SashDef[];
/** Sichtbare Profilbreite (Meter) von Blend-/Flügelrahmen in der Öffnungsebene. */
frameWidth: number;
/** Rahmentiefe quer zur Wand (Meter); fehlt ⇒ {@link DEFAULT_FRAME_DEPTH}. */
frameDepth?: number;
/** Schichteinzug: Abstand der Rahmen-Vorderkante von {@link insetFace} (Meter). */
insetFromFace?: number;
/** Bezugsfläche des Schichteinzugs; Default "aussen". */
insetFace?: "aussen" | "innen";
}
/** Ein Segment (Flügel/Pfosten) der Flügeleinteilung, mit aufgelöster Breite. */
interface SashSegment {
sash: SashDef;
/** Abstand vom Laibungsstart in Metern (0 = jambStart). */
from: number;
/** Abstand vom Laibungsstart in Metern. */
to: number;
}
/** Default-Pfostenbreite (Meter), wenn ein `kind:"pfosten"`-Eintrag keine `postWidth` angibt. */
const DEFAULT_POST_WIDTH = 0.06;
/**
* Default-Rahmentiefe (Meter), wenn ein Fenster-/Türtyp keine explizite
* `frameDepth` trägt: reale Fensterrahmen sind ~7090 mm tief (Holz/PVC/Alu),
* unabhängig von der Wanddicke NICHT die volle Wanddicke (das liess Fenster
* wie einen massiven Block in der Öffnung wirken statt eines schlanken Rahmens
* mit sichtbarer Laibung/Leibung ringsum, s. Recherche zu VW/ArchiCAD-Fenster-
* konstruktion). Deckt sich mit `toWalls3d.ts::DEFAULT_FRAME_DEPTH` (2D=3D).
*/
const DEFAULT_FRAME_DEPTH = 0.07;
/**
* Löst eine Flügeleinteilung ({@link SashDef}-Liste) zu konkreten Segment-
* Intervallen (Meter, gemessen ab Laibungsstart) über die gegebene
* Gesamtbreite auf: `kind:"pfosten"` und Flügel mit `autoWidth === false`
* erhalten ihre feste Breite (`postWidth` bzw. `width`), die restlichen
* (`autoWidth`, Default) teilen sich die verbleibende Breite gleichmäßig.
* Degeneriert bei GLEICHMÄSSIGEN Flügeln (alle `autoWidth`, keine Pfosten) zur
* bisherigen `wingCount`-Gleichverteilung (Regressionssicherheit).
*/
function sashSegments(sashes: SashDef[], totalWidth: number): SashSegment[] {
if (sashes.length === 0) return [];
let fixedSum = 0;
let autoCount = 0;
for (const s of sashes) {
if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH);
else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0);
else autoCount++;
}
const remaining = Math.max(0, totalWidth - fixedSum);
const autoWidth = autoCount > 0 ? remaining / autoCount : 0;
const segs: SashSegment[] = [];
let cursor = 0;
for (const s of sashes) {
const w =
s.kind === "pfosten"
? Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH)
: s.autoWidth === false
? Math.max(0, s.width ?? 0)
: autoWidth;
segs.push({ sash: s, from: cursor, to: cursor + w });
cursor += w;
}
return segs;
}
/**
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
* Wanddicke) plus 12 Glaslinien (dünne parallele Linien in der Wandmitte,
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
* (`wingCount 1` Querlinien).
* Wanddicke) plus 13 Glaslinien (dünne parallele Linien in der Wandmitte,
* `glassCount` steuert die Anzahl) plus optionale Flügel-/Pfosten-Trennlinien
* und Öffnungsandeutungen je Flügel.
*
* `sashes` (optional) ist die aufgelöste Flügeltabelle (siehe
* `sashesOfWindowType`) EINES referenzierten Fenstertyps. Fehlt sie (kein Typ
* referenziert/auflösbar), bleibt die Mittelpfosten-Berechnung beim
* BISHERIGEN Verhalten `o.wingCount` gleichmäßig verteilt, KEINE
* Öffnungsandeutungen exakt wie vor dieser Erweiterung (Rückwärtskompatibilität).
*/
export function windowSymbol(
project: Project,
wall: Wall,
o: Opening,
glassCount: number,
spec?: WindowRenderSpec,
detail: "grob" | "mittel" | "fein" = "fein",
): WindowSymbol | null {
const jambs = openingJambs(wall, o);
if (!jambs) return null;
@@ -200,6 +372,135 @@ export function windowSymbol(
const outer = refOff + total / 2;
const inner = refOff - total / 2;
const { jambStart, jambEnd } = jambs;
// ── Reicher Pfad (typisiertes Fenster mit Flügeltabelle), SIA-Darstellung ─
// Detailstufe = SIA-Massstab (fig. 36/37/38):
// • grob (1:100): NUR Blendrahmen-Band + eine Glaslinie über die ganze
// Öffnung — schematisch (SIA fig. 36).
// • mittel(1:50): Blendrahmen + Flügel-Trennlinien + je Flügel eine Glas-
// linie + kleines Stulp-Quadrat je Stoss (SIA fig. 37). KEINE Flügelrahmen.
// • fein (1:20): zusätzlich verschachtelte Flügelrahmen, Glas als Doppel-
// linie (Isolierverglasung) und je Stoss ZWEI Stulp-Quadrate (SIA fig. 38).
if (spec && spec.sashes.length > 0) {
const fw = Math.max(0.01, spec.frameWidth);
const depth = Math.min(total, spec.frameDepth && spec.frameDepth > 1e-9 ? spec.frameDepth : DEFAULT_FRAME_DEPTH);
const inset = Math.max(0, spec.insetFromFace ?? 0);
const halfT = total / 2;
// "aussen" = die Wandfläche, an der die ERSTE Wandtyp-Schicht (Aussenputz
// etc., "Schichten außen → innen") liegt — das ist die Seite mit dem
// NEGATIVEN Achs-Offset (s. addWallPoche: `off` startet bei `refOff-total/2`
// für die erste Schicht). Bug bis 2026-07-12: hier stand `+1` für "aussen"
// (positiver, NICHT negativer Offset) — der Rahmen landete dadurch bündig
// an der INNEREN statt der äusseren Wandfläche (Nutzer-Report: "im 2D innen
// bündig und im 3D aussen bündig" — das 3D-Pendant nutzt kompensierend eine
// gespiegelte Normale, s. resolveFrameNormalRange/openingAxisBox).
const sign = (spec.insetFace ?? "aussen") === "aussen" ? -1 : 1;
// Rahmen-Vorderkante bündig mit der Bezugsfläche, um `inset` zurück; der
// Körper reicht `depth` zur Wandmitte. Auf die Wanddicke geklemmt (mirror
// von resolveFrameNormalRange im 3D). Alles relativ zur Referenzlinie.
let frontRel = sign * (halfT - inset);
let backRel = frontRel - sign * depth;
frontRel = Math.max(-halfT, Math.min(halfT, frontRel));
backRel = Math.max(-halfT, Math.min(halfT, backRel));
const frameOuterN = refOff + Math.max(frontRel, backRel);
const frameInnerN = refOff + Math.min(frontRel, backRel);
const bandDepth = frameOuterN - frameInnerN;
const gMidBase = (frameOuterN + frameInnerN) / 2;
const frame = [
add(jambStart, scale(n, frameOuterN)),
add(jambEnd, scale(n, frameOuterN)),
add(jambEnd, scale(n, frameInnerN)),
add(jambStart, scale(n, frameInnerN)),
];
const glassLines: [Vec2, Vec2][] = [];
const mullionLines: [Vec2, Vec2][] = [];
const sashFrames: Vec2[][] = [];
const meetingMarks: Vec2[][] = [];
const jambMarks: Vec2[][] = [];
// grob (1:100): nur der Rahmenkasten + EINE Glaslinie über die ganze Öffnung.
if (detail === "grob") {
glassLines.push([add(jambStart, scale(n, gMidBase)), add(jambEnd, scale(n, gMidBase))]);
return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [], meetingMarks, jambMarks };
}
const fein = detail === "fein";
const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
const segs = sashSegments(spec.sashes, axisWidth);
// Öffenbarer Flügel sitzt `reveal` tiefer im Blendrahmen (nur „fein").
const reveal = fein ? Math.min(bandDepth * 0.3, fw * 0.7) : 0;
// Profil-Block über die VOLLE Rahmentiefe (aussen bis innen), Breite `2·halfU`
// entlang der Achse, zentriert bei `uCenter` (Achsabstand ab jambStart) — der
// Rahmenquerschnitt, wie er im Grundriss-Schnitt sichtbar ist (Nutzer:
// „Rahmen aussen als zwei Flächen gleichbreit wie das Fenster [tief]").
const frameBlock = (uCenter: number, halfU: number): Vec2[] => {
const a = along(jambStart, jambEnd, uCenter - halfU);
const b = along(jambStart, jambEnd, uCenter + halfU);
return [
add(a, scale(n, frameOuterN)),
add(b, scale(n, frameOuterN)),
add(b, scale(n, frameInnerN)),
add(a, scale(n, frameInnerN)),
];
};
// Laibungs-Pfosten an beiden Enden (Blendrahmen-Querschnitt an der Wand-
// öffnung) — EIN Block je Seite, unabhängig vom Detailgrad. Teil des
// Blendrahmens (jambMarks), NICHT der Flügelstoss-Marken (meetingMarks) —
// separat steuerbar (Nutzer-Feedback).
jambMarks.push(frameBlock(fw / 2, fw / 2), frameBlock(axisWidth - fw / 2, fw / 2));
// Flügel-Trennlinien (Mittelpfosten) an den inneren Segmentgrenzen +
// Stulp-Block: mittel 1, fein 2 (die beiden zusammenstossenden Profile) —
// je über die volle Rahmentiefe. KEINE zusätzliche dünne Trennlinie mehr
// daneben (Nutzer-Feedback: „keine vertikale Trennlinie, sondern ein
// Rechteck") — der Block selbst markiert den Stoss.
for (let i = 0; i < segs.length - 1; i++) {
const uPos = segs[i].to;
if (fein) {
meetingMarks.push(frameBlock(uPos - fw / 2, fw / 2), frameBlock(uPos + fw / 2, fw / 2));
} else {
meetingMarks.push(frameBlock(uPos, fw / 2));
}
}
for (const seg of segs) {
if (seg.sash.kind === "pfosten") continue;
const clearFrom = seg.from + fw;
const clearTo = seg.to - fw;
if (clearTo - clearFrom <= 1e-6) continue;
const operable = !!seg.sash.opening && seg.sash.opening !== "fest";
// „mittel" (SIA fig. 37) zeigt NUR Blendrahmen + Pfosten/Stulp — noch KEINE
// Glaslinie/Flügelrahmen (Nutzer-Vorgabe: „im 1:50 muss man die Fenster-
// linie noch nicht sehen, nur die Vierecke aussen/innen"). Das kommt erst
// bei „fein" (SIA fig. 38) dazu.
if (!fein) continue;
const gOuterN = frameOuterN - (operable ? reveal : 0);
const gInnerN = frameInnerN + (operable ? reveal : 0);
// Verschachtelter Flügelrahmen NUR bei „fein" (SIA fig. 38); „mittel" bleibt
// beim reinen Blendrahmen-Band (SIA fig. 37).
// Keine Glaslinie mehr im Feld (auch nicht bei „fein") — Nutzer-Feedback:
// eine feine Haarlinie mittig im Glasfeld wird nicht gebraucht, unabhängig
// von der Fensterposition. Der Flügelrahmen (nur bei öffenbaren Flügeln)
// bleibt als einzige zusätzliche Kontur bei „fein" bestehen.
if (operable) {
const sFrom = along(jambStart, jambEnd, clearFrom);
const sTo = along(jambStart, jambEnd, clearTo);
sashFrames.push([
add(sFrom, scale(n, gOuterN)),
add(sTo, scale(n, gOuterN)),
add(sTo, scale(n, gInnerN)),
add(sFrom, scale(n, gInnerN)),
]);
}
}
return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [], meetingMarks, jambMarks };
}
// ── Alt-Pfad (kein Typ auflösbar) ────────────────────────────────────────
// UNVERÄNDERT (Rust-Parität kernel2d): Rahmen an voller Wanddicke, Glaslinien
// mittig, (wingCount1) gleichmäßige Mittelpfosten. Keine Flügelrahmen.
const frame = [
add(jambStart, scale(n, outer)),
add(jambEnd, scale(n, outer)),
@@ -209,15 +510,10 @@ export function windowSymbol(
const glassLines: [Vec2, Vec2][] = [];
const count = Math.max(1, glassCount);
for (let i = 0; i < count; i++) {
// Glaslinien symmetrisch um die Wandmitte verteilt (Abstand = Dicke/8).
const spread = total / 8;
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
}
// Flügel-Mittelpfosten: (wingCount 1) Querlinien gleichmäßig zwischen den
// Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf ~60 % der
// Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — analog Rhino, wo
// die Pfosten x_mid = inner_l + span·(i/fluegel) über die Rahmentiefe sitzen.
const mullionLines: [Vec2, Vec2][] = [];
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
if (wings > 1) {
@@ -225,10 +521,10 @@ export function windowSymbol(
const pOuter = refOff + postDepth / 2;
const pInner = refOff - postDepth / 2;
for (let i = 1; i < wings; i++) {
const t = i / wings; // gleichmäßige Teilung entlang der Achse
const t = i / wings;
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
}
}
return { frame, glassLines, mullionLines };
return { frame, glassLines, mullionLines, sashFrames: [], sashOpeningLines: [], meetingMarks: [], jambMarks: [] };
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Unit-Tests für {@link mergeHoles} (Brücken-Trick: Aussparungen EIN
* einfacher Polygonring). Geprüft werden die drei Kern-Eigenschaften:
* der Ergebnis-Ring ist selbstüberschneidungsfrei (echte Kanten-Kreuzungen
* ausgeschlossen, benachbarte Kanten teilen sich naturgemäss einen Punkt),
* die Shoelace-Fläche entspricht Aussenfläche Lochfläche(n),
* `bridgeEdges` benennt genau die künstlichen Brückenkanten (2 je Loch).
*/
import { describe, it, expect } from "vitest";
import type { Vec2 } from "../model/types";
import { mergeHoles } from "./polygonHoles";
function signedArea(pts: Vec2[]): number {
let s = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
s += a.x * b.y - b.x * a.y;
}
return s / 2;
}
/** Ob sich zwei Strecken ECHT (im Inneren beider) schneiden Test-Kopie der
* Produktionslogik (bewusst unabhängig, damit der Test die Implementierung
* nicht einfach "durchreicht"). */
function properlyCross(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2): boolean {
const dax = a2.x - a1.x, day = a2.y - a1.y;
const dbx = b2.x - b1.x, dby = b2.y - b1.y;
const denom = dax * dby - day * dbx;
if (Math.abs(denom) < 1e-12) return false;
const ex = b1.x - a1.x, ey = b1.y - a1.y;
const t = (ex * dby - ey * dbx) / denom;
const s = (ex * day - ey * dax) / denom;
const eps = 1e-9;
return t > eps && t < 1 - eps && s > eps && s < 1 - eps;
}
/** Prüft, dass KEIN Paar nicht-benachbarter Kanten sich echt schneidet. */
function isSimpleRing(ring: Vec2[]): boolean {
const n = ring.length;
const edges: [Vec2, Vec2][] = [];
for (let i = 0; i < n; i++) edges.push([ring[i], ring[(i + 1) % n]]);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
// Benachbarte Kanten (teilen einen Endpunkt) sind erlaubt.
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
if (properlyCross(edges[i][0], edges[i][1], edges[j][0], edges[j][1])) return false;
}
}
return true;
}
const rect = (x0: number, y0: number, x1: number, y1: number): Vec2[] => [
{ x: x0, y: y0 },
{ x: x1, y: y0 },
{ x: x1, y: y1 },
{ x: x0, y: y1 },
];
describe("mergeHoles", () => {
it("Rechteck 6x4 mit 1x1-Loch mittig -> einfacher Ring, Fläche = Aussen - Loch, 2 Brückenkanten", () => {
const outline = rect(0, 0, 6, 4); // Fläche 24
const hole = rect(2.5, 1.5, 3.5, 2.5); // Fläche 1
const { ring, bridgeEdges } = mergeHoles(outline, [hole]);
expect(ring.length).toBe(outline.length + hole.length + 2);
expect(isSimpleRing(ring)).toBe(true);
expect(Math.abs(signedArea(ring))).toBeCloseTo(24 - 1, 2);
expect(bridgeEdges.length).toBe(2);
// Die Brückenkanten sind kurz (Aussenring↔Loch-Rand), nicht Teil des
// eigentlichen Loch- oder Aussenumrisses.
for (const e of bridgeEdges) {
const a = ring[e];
const b = ring[(e + 1) % ring.length];
const len = Math.hypot(a.x - b.x, a.y - b.y);
expect(len).toBeGreaterThan(0);
expect(len).toBeLessThan(3); // deutlich kürzer als Aussen-/Lochkanten (>=1)
}
});
it("ohne Löcher -> Ring identisch zur Outline, keine Brückenkanten (Regression)", () => {
const outline = rect(0, 0, 6, 4);
const { ring, bridgeEdges } = mergeHoles(outline, []);
expect(ring).toEqual(outline);
expect(bridgeEdges).toEqual([]);
});
it("Loch ausserhalb der Outline wird übersprungen", () => {
const outline = rect(0, 0, 6, 4);
const outsideHole = rect(10, 10, 11, 11);
const { ring, bridgeEdges } = mergeHoles(outline, [outsideHole]);
expect(ring).toEqual(outline);
expect(bridgeEdges).toEqual([]);
});
it("degeneriertes Loch (< 3 Punkte) wird übersprungen", () => {
const outline = rect(0, 0, 6, 4);
const { ring, bridgeEdges } = mergeHoles(outline, [[{ x: 1, y: 1 }, { x: 2, y: 2 }]]);
expect(ring).toEqual(outline);
expect(bridgeEdges).toEqual([]);
});
it("zwei Löcher -> beide eingefügt, Ring einfach, Fläche = Aussen - Loch1 - Loch2, 4 Brückenkanten", () => {
const outline = rect(0, 0, 10, 4);
const holeA = rect(1, 1, 2, 2); // Fläche 1
const holeB = rect(7, 1, 9, 3); // Fläche 4
const { ring, bridgeEdges } = mergeHoles(outline, [holeA, holeB]);
expect(isSimpleRing(ring)).toBe(true);
expect(Math.abs(signedArea(ring))).toBeCloseTo(40 - 1 - 4, 2);
expect(bridgeEdges.length).toBe(4);
});
it("Loch mit CCW-Orientierung (gleich wie Aussenring) wird korrekt gegenläufig eingefügt", () => {
// rect() liefert CCW; die Fläche muss trotzdem korrekt subtrahiert werden,
// unabhängig von der gespeicherten Loch-Wicklung.
const outline = rect(0, 0, 6, 4);
const hole = rect(2.5, 1.5, 3.5, 2.5).reverse(); // CW
const { ring } = mergeHoles(outline, [hole]);
expect(Math.abs(signedArea(ring))).toBeCloseTo(24 - 1, 2);
});
});
+180
View File
@@ -0,0 +1,180 @@
// Brücken-Trick: Aussparungen (Löcher) werden über schmale Brücken in den
// Aussenring eingefügt, sodass EIN einfacher (nicht selbstüberschneidender)
// Polygonring entsteht — kein Loch-Support in den nachgelagerten Primitiven
// nötig. Ein solcher Ring ist paritätsbasiert korrekt für SVG-/Canvas-Fill,
// Scanline-Hatch, Ear-Clipping-Triangulierung UND Punkt-in-Polygon-Pick.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Vec2 } from "../model/types";
import { add, cross, sub } from "../model/geometry";
const EPS = 1e-9;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
/** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW. */
function signedArea(pts: Vec2[]): number {
let s = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
s += a.x * b.y - b.x * a.y;
}
return s / 2;
}
/** Schwerpunkt (einfaches Punkt-Mittel, genügt für den Innen/Aussen-Test). */
function centroid(pts: Vec2[]): Vec2 {
let x = 0;
let y = 0;
for (const p of pts) {
x += p.x;
y += p.y;
}
return { x: x / pts.length, y: y / pts.length };
}
/** Punkt-in-Polygon (Ray-Casting). */
function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i];
const b = poly[j];
const hit =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
if (hit) inside = !inside;
}
return inside;
}
/** Einheits-Normale nach LINKS (90° CCW) eines Vektors ({0,0} bei Nulllänge). */
function leftNormalUnit(d: Vec2): Vec2 {
const l = Math.hypot(d.x, d.y);
if (l < EPS) return { x: 0, y: 0 };
return { x: -d.y / l, y: d.x / l };
}
/** Ob sich zwei Strecken ECHT (im Inneren BEIDER Strecken) schneiden. */
function segmentsProperlyCross(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2): boolean {
const da = sub(a2, a1);
const db = sub(b2, b1);
const denom = cross(da, db);
if (Math.abs(denom) < EPS) return false; // parallel/kollinear — blockiert nicht
const t = cross(sub(b1, a1), db) / denom;
const s = cross(sub(b1, a1), da) / denom;
const e = 1e-9;
return t > e && t < 1 - e && s > e && s < 1 - e;
}
/** Kanten eines geschlossenen Rings als [from,to]-Paare. */
function ringEdges(pts: Vec2[]): [Vec2, Vec2][] {
const out: [Vec2, Vec2][] = [];
for (let i = 0; i < pts.length; i++) out.push([pts[i], pts[(i + 1) % pts.length]]);
return out;
}
/** Ergebnis von {@link mergeHoles}. */
export interface MergedRing {
/** Einfacher (nicht selbstüberschneidender) Polygonring mit eingefügten Löchern. */
ring: Vec2[];
/**
* Kanten-Indizes der eingefügten Brückenkanten im Ergebnis-Ring (Kante i =
* ring[i]ring[(i+1)%n]) für `noStrokeEdges` im 2D (Brückenkanten sind
* künstliche Verbindungen, kein realer Bauteilrand, daher nicht stricheln).
* Je Loch genau 2 Einträge.
*/
bridgeEdges: number[];
}
/**
* Fügt Aussparungen (`holes`) über schmale Brücken in `outline` ein, sodass
* EIN einfacher Polygonring entsteht (der Brücken-Trick). Löcher werden
* ITERATIV in den (wachsenden) Ring eingefügt: je Loch das nächstgelegene
* SICHTBARE Ring-/Loch-Vertex-Paar verbinden (Sichtbarkeit = Verbindungs-
* strecke schneidet keine andere Ring-/Loch-Kante; bei Schnitt wird das
* nächstbeste Paar versucht). Der Loch-Ring wird dabei in GEGENLÄUFIGER
* Orientierung zum Aussenring eingefügt (Shoelace-Fläche des Ergebnisses =
* Aussenfläche Lochfläche). Die Brücke besteht aus ZWEI knapp parallelen
* Kanten (`gap`-Versatz quer zur Brückenrichtung statt identischer Punkte),
* damit der Ring einfach (selbstüberschneidungsfrei) bleibt.
*
* Robustheit: degenerierte Löcher (< 3 Punkte oder ~Nullfläche) sowie Löcher,
* deren Schwerpunkt ausserhalb des aktuellen Rings liegt (Loch ausserhalb der
* Outline, oder Überlappung mit einem bereits eingefügten Loch), werden
* übersprungen. Ohne Löcher (leere Liste) liefert die Funktion `outline`
* unverändert (Kopie) mit leeren `bridgeEdges`.
*/
export function mergeHoles(outline: Vec2[], holes: Vec2[][], gap = 1e-4): MergedRing {
if (outline.length < 3 || holes.length === 0) {
return { ring: outline.slice(), bridgeEdges: [] };
}
const outerCCW = signedArea(outline) > 0;
let ring = outline.slice();
// Referenz-Paare der eingefügten Brückenkanten (Objekt-Identität) — damit
// die Kanten-Indizes robust am fertigen Ring ermittelt werden können,
// unabhängig von Index-Verschiebungen durch spätere Loch-Einfügungen.
const bridgePairs: [Vec2, Vec2][] = [];
for (const holeRaw of holes) {
if (holeRaw.length < 3) continue; // degeneriert
if (Math.abs(signedArea(holeRaw)) < 1e-9) continue; // Nulllinie/Punkt
const c = centroid(holeRaw);
if (!pointInPolygon(c, ring)) continue; // ausserhalb (oder bereits verlocht)
// Loch in Gegenrichtung zum Aussenring einfügen.
const holeCCW = signedArea(holeRaw) > 0;
const hole = holeCCW === outerCCW ? holeRaw.slice().reverse() : holeRaw.slice();
const m = hole.length;
// Nächstgelegenes SICHTBARES Paar (Ring-Vertex i, Loch-Vertex j): nach
// Abstand sortiert, erstes Paar ohne echten Schnitt mit Ring- oder
// Loch-Kanten nehmen (Fallback: nächstbestes Paar in der Reihenfolge,
// notfalls das absolut nächste).
const rEdges = ringEdges(ring);
const hEdges = ringEdges(hole);
const candidates: { i: number; j: number; d: number }[] = [];
for (let i = 0; i < ring.length; i++) {
for (let j = 0; j < m; j++) candidates.push({ i, j, d: dist(ring[i], hole[j]) });
}
candidates.sort((a, b) => a.d - b.d);
let chosen = candidates[0];
for (const cand of candidates) {
const P = ring[cand.i];
const Q = hole[cand.j];
const blocked =
rEdges.some(([e1, e2]) => segmentsProperlyCross(P, Q, e1, e2)) ||
hEdges.some(([e1, e2]) => segmentsProperlyCross(P, Q, e1, e2));
if (!blocked) {
chosen = cand;
break;
}
}
const { i, j } = chosen;
const A = ring[i];
const B = hole[j];
const perp = leftNormalUnit(sub(B, A));
const off: Vec2 = { x: perp.x * gap, y: perp.y * gap };
const A1: Vec2 = add(A, off);
const A2: Vec2 = sub(A, off);
const B1: Vec2 = add(B, off);
const B2: Vec2 = sub(B, off);
const holePart: Vec2[] = [];
for (let k = 1; k < m; k++) holePart.push(hole[(j + k) % m]);
ring = [...ring.slice(0, i), A1, B1, ...holePart, B2, A2, ...ring.slice(i + 1)];
bridgePairs.push([A1, B1], [B2, A2]);
}
const bridgeEdges: number[] = [];
for (let k = 0; k < ring.length; k++) {
const a = ring[k];
const b = ring[(k + 1) % ring.length];
if (bridgePairs.some(([x, y]) => x === a && y === b)) bridgeEdges.push(k);
}
return { ring, bridgeEdges };
}
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect } from "vitest";
import { roofGeometry, roofBBox, roofUndersideAt } from "./roof";
import type { Roof, Vec2 } from "../model/types";
// Rechteck 6×4 (x:0..6, y:0..4), Neigung 45° (tan=1), Traufhöhe 10, kein Überstand.
const RECT: Vec2[] = [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 4 },
{ x: 0, y: 4 },
];
const E = 10;
function roof(over: Partial<Roof>): Roof {
return {
id: "R1",
type: "roof",
floorId: "eg",
categoryCode: "35",
outline: RECT,
shape: "sattel",
pitchDeg: 45,
overhang: 0,
ridgeAxis: "x",
thickness: 0.2,
...over,
};
}
/** Alle 3D-Punkte einer Geometrie (Flächen + Giebel). */
function allZ(g: ReturnType<typeof roofGeometry>): number[] {
const zs: number[] = [];
for (const pl of g.planes) for (const p of pl.pts) zs.push(p[2]);
for (const gp of g.gables) for (const p of gp) zs.push(p[2]);
return zs;
}
describe("roofBBox", () => {
it("liefert die Bounding-Box", () => {
expect(roofBBox(RECT)).toEqual({ x0: 0, y0: 0, x1: 6, y1: 4 });
});
});
describe("roofGeometry — Formen (6×4, 45°, First entlang X)", () => {
it("flach: eine waagrechte Fläche auf Traufhöhe, kein First", () => {
const g = roofGeometry(roof({ shape: "flach" }), E);
expect(g.planes).toHaveLength(1);
expect(g.ridges).toHaveLength(0);
expect(g.ridgeHeight).toBe(0);
expect(allZ(g).every((z) => z === E)).toBe(true);
});
it("sattel: Firsthöhe = halbe Tiefe · tan, First mittig, 2 Flächen + 2 Giebel", () => {
const g = roofGeometry(roof({ shape: "sattel" }), E);
expect(g.ridgeHeight).toBeCloseTo(2, 6); // halfD 2 · tan45 1
expect(g.planes).toHaveLength(2);
expect(g.gables).toHaveLength(2);
expect(g.ridges).toHaveLength(1);
// First auf yc=2, z=E+2.
expect(g.ridges[0][0]).toEqual({ x: 0, y: 2 });
expect(g.ridges[0][1]).toEqual({ x: 6, y: 2 });
expect(Math.max(...allZ(g))).toBeCloseTo(E + 2, 6);
expect(Math.min(...allZ(g))).toBeCloseTo(E, 6);
});
it("walm: First um halbe Tiefe verkürzt, 4 Flächen, 4 Grate, keine Giebel", () => {
const g = roofGeometry(roof({ shape: "walm" }), E);
expect(g.planes).toHaveLength(4);
expect(g.hips).toHaveLength(4);
expect(g.gables).toHaveLength(0);
// ra=2, rb=4.
expect(g.ridges[0][0]).toEqual({ x: 2, y: 2 });
expect(g.ridges[0][1]).toEqual({ x: 4, y: 2 });
expect(g.ridgeHeight).toBeCloseTo(2, 6);
});
it("zelt: Spitze über der Mitte, 4 Flächen, 4 Grate zur Mitte", () => {
const g = roofGeometry(roof({ shape: "zelt" }), E);
expect(g.planes).toHaveLength(4);
expect(g.hips).toHaveLength(4);
expect(g.ridges).toHaveLength(0);
// hz = min(halfW 3, halfD 2)=2.
expect(g.ridgeHeight).toBeCloseTo(2, 6);
// Alle Grate laufen zum Mittelpunkt (3,2).
for (const hip of g.hips) expect(hip[1]).toEqual({ x: 3, y: 2 });
});
it("pult: eine Fläche, hohe Kante = Breite·tan über der Traufe", () => {
const g = roofGeometry(roof({ shape: "pult" }), E);
expect(g.planes).toHaveLength(1);
expect(g.ridgeHeight).toBeCloseTo(4, 6); // Tiefe 4 · tan45
expect(Math.max(...allZ(g))).toBeCloseTo(E + 4, 6);
});
it("mansarde: 4 Flächen, 2 Knicklinien, First mittig, Knick tiefer als First", () => {
const g = roofGeometry(roof({ shape: "mansarde", pitchDeg: 70, pitchUpperDeg: 30 }), E);
expect(g.planes).toHaveLength(4);
expect(g.breaks).toHaveLength(2);
expect(g.ridges).toHaveLength(1);
expect(g.gables).toHaveLength(2); // Pentagon-Giebel
// First höher als 0, Knicklinien innerhalb der Tiefe.
expect(g.ridgeHeight).toBeGreaterThan(0);
expect(g.breaks[0][0].y).toBeCloseTo(0 + 2 * 0.4, 6); // yf = y0 + halfD·0.4
});
it("mansarde giebel: Knick-Ratio parametriert die Knicklinien-Lage", () => {
const g = roofGeometry(
roof({ shape: "mansarde", mansardType: "giebel", mansardKneeRatio: 0.25 }),
E,
);
// yf = y0 + halfD·ratio = 0 + 2·0.25 = 0.5.
expect(g.breaks[0][0].y).toBeCloseTo(0.5, 6);
});
it("mansarde walm: allseitig geknickt -> 8 Flächen, 4 Knicklinien (Rechteck), 8 Grate, keine Giebel", () => {
const g = roofGeometry(roof({ shape: "mansarde", mansardType: "walm" }), E);
expect(g.planes).toHaveLength(8); // 4 Sockel + 2 Walm-Trapeze + 2 Walm-Dreiecke
expect(g.breaks).toHaveLength(4); // inneres Knick-Rechteck
expect(g.hips).toHaveLength(8); // 4 Sockel-Grate + 4 obere Walm-Grate
expect(g.ridges).toHaveLength(1);
expect(g.gables).toHaveLength(0); // Walm hat keine Giebel
expect(g.ridgeHeight).toBeGreaterThan(0);
// Knick-Höhe < First-Höhe (Sockel endet unter dem First).
const kneeZ = g.planes[0].pts[2][2] - E; // z1 - e am inneren Punkt
expect(kneeZ).toBeGreaterThan(0);
expect(kneeZ).toBeLessThan(g.ridgeHeight);
});
it("mansarde zelt: allseitig geknickt zur Spitze -> 8 Flächen, kein First, 8 Grate", () => {
const g = roofGeometry(roof({ shape: "mansarde", mansardType: "zelt" }), E);
expect(g.planes).toHaveLength(8); // 4 Sockel + 4 Spitzen-Dreiecke
expect(g.breaks).toHaveLength(4);
expect(g.hips).toHaveLength(8);
expect(g.ridges).toHaveLength(0); // Zelt hat keinen First
expect(g.gables).toHaveLength(0);
expect(g.ridgeHeight).toBeGreaterThan(0);
});
});
describe("roofGeometry — First entlang Y (Transponierung)", () => {
it("dreht die Sattel-First-Linie auf die X-Mitte", () => {
const g = roofGeometry(roof({ shape: "sattel", ridgeAxis: "y" }), E);
// Firstrichtung Y → First liegt bei x = xc = 3, läuft in Y.
expect(g.ridges[0][0]).toEqual({ x: 3, y: 0 });
expect(g.ridges[0][1]).toEqual({ x: 3, y: 4 });
// Firsthöhe nun aus der halben BREITE (3) · tan = 3.
expect(g.ridgeHeight).toBeCloseTo(3, 6);
});
});
describe("roofGeometry — Überstand", () => {
it("weitet die Traufe um overhang nach aussen", () => {
const g = roofGeometry(roof({ shape: "flach", overhang: 0.5 }), E);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.5, y0: -0.5, x1: 6.5, y1: 4.5 });
});
it("getrennter Traufe/Ortgang-Überstand: First entlang X -> Ortgang weitet X, Traufe Y", () => {
// ridgeAxis "x": First entlang X -> Ortgang (Giebel) an den X-Enden,
// Traufe an den Y-Seiten. overhang(Traufe)=0.3, overhangGable(Ortgang)=0.8.
const g = roofGeometry(
roof({ shape: "sattel", ridgeAxis: "x", overhang: 0.3, overhangGable: 0.8 }),
E,
);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.8, y0: -0.3, x1: 6.8, y1: 4.3 });
});
it("getrennter Überstand: First entlang Y -> Ortgang weitet Y, Traufe X", () => {
const g = roofGeometry(
roof({ shape: "sattel", ridgeAxis: "y", overhang: 0.3, overhangGable: 0.8 }),
E,
);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.3, y0: -0.8, x1: 6.3, y1: 4.8 });
});
it("overhangGable fehlt -> ringsum gleich overhang (rückwärtskompatibel)", () => {
const g = roofGeometry(roof({ shape: "sattel", overhang: 0.4 }), E);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.4, y0: -0.4, x1: 6.4, y1: 4.4 });
});
});
describe("roofUndersideAt — Dach-Unterkante an einem Grundriss-Punkt", () => {
// Sattel 6×4, First entlang X bei yc=2, Neigung 45° (tan=1) -> Firsthöhe 2.
// Fläche 1 (y0=0..yc=2): z(x,y) = 10 + y (linear, unabhängig von x).
const g = roofGeometry(roof({ shape: "sattel" }), E);
it("an der Traufe (y=0): Oberkante 10, Unterkante minus Schichtdicke", () => {
expect(roofUndersideAt(g, 0, 3, 0)).toBeCloseTo(10, 6);
expect(roofUndersideAt(g, 0.2, 3, 0)).toBeCloseTo(9.8, 6);
});
it("am First (y=2): Oberkante 12 (Firsthöhe 10+2)", () => {
expect(roofUndersideAt(g, 0, 3, 2)).toBeCloseTo(12, 6);
});
it("interpoliert linear zwischen Traufe und First (y=1 -> 11)", () => {
expect(roofUndersideAt(g, 0, 3, 1)).toBeCloseTo(11, 6);
});
it("ausserhalb des Dach-Umrisses -> null", () => {
expect(roofUndersideAt(g, 0, 3, -1)).toBeNull();
expect(roofUndersideAt(g, 0, 8, 1)).toBeNull();
});
it("negative/fehlende Schichtdicke wird auf 0 geklemmt (keine Anhebung)", () => {
expect(roofUndersideAt(g, -0.5, 3, 0)).toBeCloseTo(10, 6);
});
});
+416
View File
@@ -0,0 +1,416 @@
// Dach-Geometrie: leitet aus einem (rechteckigen) Grundriss-Umriss + Dachform +
// Neigung die Grundriss-Linien (Traufe/First/Grat/Knick) UND die 3D-Dachflächen
// ab. Bewusst auf der BOUNDING-BOX des Umrisses gerechnet (First entlang einer
// Hauptachse) — die gängige, intuitive Vereinfachung, die alle Standardformen
// (Flach/Pult/Sattel/Walm/Mansarde/Zelt) exakt und ohne Straight-Skeleton abdeckt.
//
// Koordinaten: Grundriss (x, y) in Metern; 3D-Punkte als [x, y, z] mit z = Höhe.
// Reine Datenschicht (kein React/Store), voll unit-testbar.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Project, Roof, Vec2 } from "../model/types";
import { getFloor } from "../model/types";
/** 3D-Punkt [x, y, z=Höhe] in Modell-Metern. */
export type Vec3 = [number, number, number];
/** Eine ebene Dachfläche als Polygon (3D-Eckpunkte in Umlaufreihenfolge). */
export interface RoofPlane {
pts: Vec3[];
}
/** Vollständige Dach-Geometrie (Grundriss-Linien + 3D-Flächen). */
export interface RoofGeometry {
/** Traufe-Umriss (Grundriss, inkl. Überstand) als geschlossener Ring. */
eaves: Vec2[];
/** Firstlinie(n) im Grundriss (leer bei Flach/Zelt). */
ridges: [Vec2, Vec2][];
/** Gratlinien (Walm/Zelt: Ecke → First/Spitze). */
hips: [Vec2, Vec2][];
/** Knicklinien (Mansarde). */
breaks: [Vec2, Vec2][];
/** 3D-Dachflächen. */
planes: RoofPlane[];
/** Giebelflächen (vertikale Polygone an Sattel-/Mansarde-Enden). */
gables: Vec3[][];
/** Firsthöhe über der Traufe (Meter). */
ridgeHeight: number;
}
const DEG = Math.PI / 180;
/** Bounding-Box eines Umrisses. */
export function roofBBox(outline: Vec2[]): { x0: number; y0: number; x1: number; y1: number } {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (const p of outline) {
if (p.x < x0) x0 = p.x;
if (p.y < y0) y0 = p.y;
if (p.x > x1) x1 = p.x;
if (p.y > y1) y1 = p.y;
}
return { x0, y0, x1, y1 };
}
/**
* Traufhöhe (absolutes Z) eines Dachs: `baseElevation`, sonst die OBERKANTE des
* zugehörigen Geschosses (baseElevation + floorHeight) das Dach sitzt also
* standardmäßig auf der Geschossdecke auf.
*/
export function roofBaseElevation(project: Project, roof: Roof): number {
if (roof.baseElevation !== undefined) return roof.baseElevation;
try {
const floor = getFloor(project, roof.floorId);
return (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0);
} catch {
return 0;
}
}
const P = (x: number, y: number): Vec2 => ({ x, y });
/**
* Kanonische Berechnung mit First entlang der X-Achse auf dem Rechteck
* [x0,x1]×[y0,y1] (Traufe, inkl. Überstand) auf Traufhöhe `e`. Die y"-First-
* Richtung wird im Wrapper durch Transponieren (xy) erreicht.
*/
function computeCanonical(
x0: number,
y0: number,
x1: number,
y1: number,
e: number,
roof: Roof,
): RoofGeometry {
const xc = (x0 + x1) / 2;
const yc = (y0 + y1) / 2;
const halfW = (x1 - x0) / 2;
const halfD = (y1 - y0) / 2;
const a = Math.max(0, roof.pitchDeg) * DEG;
const tan = Math.tan(a);
const h = halfD * tan; // Firsthöhe (Sattel/Walm/Mansarde)
const eaves: Vec2[] = [P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)];
const flat = (): RoofGeometry => ({
eaves,
ridges: [],
hips: [],
breaks: [],
planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e], [x0, y1, e]] }],
gables: [],
ridgeHeight: 0,
});
switch (roof.shape) {
case "flach":
return flat();
case "pult": {
// Eine Fläche: tiefe Seite y0 auf Traufe, hohe Seite y1 auf e+H.
const H = (y1 - y0) * tan;
return {
eaves,
ridges: [[P(x0, y1), P(x1, y1)]], // hohe Kante (First-Ersatz)
hips: [],
breaks: [],
planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e + H], [x0, y1, e + H]] }],
gables: [
[[x0, y0, e], [x0, y1, e + H], [x0, y1, e]],
[[x1, y0, e], [x1, y1, e + H], [x1, y1, e]],
],
ridgeHeight: H,
};
}
case "sattel": {
const rz = e + h;
return {
eaves,
ridges: [[P(x0, yc), P(x1, yc)]],
hips: [],
breaks: [],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [x1, yc, rz], [x0, yc, rz]] },
{ pts: [[x0, y1, e], [x1, y1, e], [x1, yc, rz], [x0, yc, rz]] },
],
gables: [
[[x0, y0, e], [x0, y1, e], [x0, yc, rz]],
[[x1, y0, e], [x1, y1, e], [x1, yc, rz]],
],
ridgeHeight: h,
};
}
case "walm": {
const rz = e + h;
// First um die halbe Tiefe an beiden Enden verkürzt (45°-Grate bei
// gleicher Neigung). Bei schmalem Baukörper (halfW<halfD) degeneriert er
// zum Punkt (xc) → Walm wird zeltartig.
let ra = x0 + halfD;
let rb = x1 - halfD;
if (ra > rb) ra = rb = xc;
return {
eaves,
ridges: [[P(ra, yc), P(rb, yc)]],
hips: [
[P(x0, y0), P(ra, yc)],
[P(x0, y1), P(ra, yc)],
[P(x1, y0), P(rb, yc)],
[P(x1, y1), P(rb, yc)],
],
breaks: [],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [rb, yc, rz], [ra, yc, rz]] },
{ pts: [[x0, y1, e], [x1, y1, e], [rb, yc, rz], [ra, yc, rz]] },
{ pts: [[x0, y0, e], [x0, y1, e], [ra, yc, rz]] },
{ pts: [[x1, y0, e], [x1, y1, e], [rb, yc, rz]] },
],
gables: [],
ridgeHeight: h,
};
}
case "mansarde": {
// Mansarde: steile untere Neigung (pitchDeg) bis zur Knicklinie, dann
// flache obere (pitchUpperDeg ?? halbe Hauptneigung). Untertyp steuert, ob
// die Knickform an ZWEI Seiten sitzt (Giebel) oder RINGSUM (Walm/Zelt).
const aLow = a;
const aUp = Math.max(0, roof.pitchUpperDeg ?? roof.pitchDeg / 2) * DEG;
const ratio = Math.min(0.49, Math.max(0.05, roof.mansardKneeRatio ?? 0.4));
const mType = roof.mansardType ?? "giebel";
if (mType === "giebel") {
// Mansard-Satteldach: Knick nur an den beiden Traufseiten, Giebel an den
// Enden (bisheriges Verhalten, jetzt mit parametrierbarem Knick).
const d1 = halfD * ratio;
const z1 = e + d1 * Math.tan(aLow);
const z2 = z1 + (halfD - d1) * Math.tan(aUp);
const yf = y0 + d1;
const yb = y1 - d1;
return {
eaves,
ridges: [[P(x0, yc), P(x1, yc)]],
hips: [],
breaks: [
[P(x0, yf), P(x1, yf)],
[P(x0, yb), P(x1, yb)],
],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [x1, yf, z1], [x0, yf, z1]] },
{ pts: [[x0, yf, z1], [x1, yf, z1], [x1, yc, z2], [x0, yc, z2]] },
{ pts: [[x0, y1, e], [x1, y1, e], [x1, yb, z1], [x0, yb, z1]] },
{ pts: [[x0, yb, z1], [x1, yb, z1], [x1, yc, z2], [x0, yc, z2]] },
],
gables: [
[[x0, y0, e], [x0, yf, z1], [x0, yc, z2], [x0, yb, z1], [x0, y1, e]],
[[x1, y0, e], [x1, yf, z1], [x1, yc, z2], [x1, yb, z1], [x1, y1, e]],
],
ridgeHeight: z2 - e,
};
}
// Allseitige Mansarde (Walm/Zelt): steiler Sockel ringsum bis zur
// Knicklinie (inneres Rechteck), darüber flacher Walm bzw. flache Spitze.
const dLow = Math.min(halfW, halfD) * ratio;
const z1 = e + dLow * Math.tan(aLow);
const ix0 = x0 + dLow;
const iy0 = y0 + dLow;
const ix1 = x1 - dLow;
const iy1 = y1 - dLow;
const ihalfD = (iy1 - iy0) / 2;
const ihalfW = (ix1 - ix0) / 2;
// Sockel-Trapeze (4 Seiten, steil) + Sockel-Grate (Ecken aussen→innen) +
// Knicklinie (inneres Rechteck) sind beiden allseitigen Formen gemein.
const socketPlanes = [
{ pts: [[x0, y0, e], [x1, y0, e], [ix1, iy0, z1], [ix0, iy0, z1]] as Vec3[] },
{ pts: [[x1, y1, e], [x0, y1, e], [ix0, iy1, z1], [ix1, iy1, z1]] as Vec3[] },
{ pts: [[x0, y1, e], [x0, y0, e], [ix0, iy0, z1], [ix0, iy1, z1]] as Vec3[] },
{ pts: [[x1, y0, e], [x1, y1, e], [ix1, iy1, z1], [ix1, iy0, z1]] as Vec3[] },
];
const socketHips: [Vec2, Vec2][] = [
[P(x0, y0), P(ix0, iy0)],
[P(x1, y0), P(ix1, iy0)],
[P(x1, y1), P(ix1, iy1)],
[P(x0, y1), P(ix0, iy1)],
];
const kneeBreaks: [Vec2, Vec2][] = [
[P(ix0, iy0), P(ix1, iy0)],
[P(ix1, iy0), P(ix1, iy1)],
[P(ix1, iy1), P(ix0, iy1)],
[P(ix0, iy1), P(ix0, iy0)],
];
if (mType === "walm") {
// Oberer Walm auf dem inneren Rechteck (First entlang X).
const z2 = z1 + ihalfD * Math.tan(aUp);
let ra = ix0 + ihalfD;
let rb = ix1 - ihalfD;
if (ra > rb) ra = rb = xc;
return {
eaves,
ridges: [[P(ra, yc), P(rb, yc)]],
hips: [
...socketHips,
[P(ix0, iy0), P(ra, yc)],
[P(ix1, iy0), P(rb, yc)],
[P(ix1, iy1), P(rb, yc)],
[P(ix0, iy1), P(ra, yc)],
],
breaks: kneeBreaks,
planes: [
...socketPlanes,
{ pts: [[ix0, iy0, z1], [ix1, iy0, z1], [rb, yc, z2], [ra, yc, z2]] },
{ pts: [[ix1, iy1, z1], [ix0, iy1, z1], [ra, yc, z2], [rb, yc, z2]] },
{ pts: [[ix0, iy1, z1], [ix0, iy0, z1], [ra, yc, z2]] },
{ pts: [[ix1, iy0, z1], [ix1, iy1, z1], [rb, yc, z2]] },
],
gables: [],
ridgeHeight: z2 - e,
};
}
// "zelt": flache Spitze über der Mitte des inneren Rechtecks.
const z2 = z1 + Math.min(ihalfW, ihalfD) * Math.tan(aUp);
return {
eaves,
ridges: [],
hips: [
...socketHips,
[P(ix0, iy0), P(xc, yc)],
[P(ix1, iy0), P(xc, yc)],
[P(ix1, iy1), P(xc, yc)],
[P(ix0, iy1), P(xc, yc)],
],
breaks: kneeBreaks,
planes: [
...socketPlanes,
{ pts: [[ix0, iy0, z1], [ix1, iy0, z1], [xc, yc, z2]] },
{ pts: [[ix1, iy0, z1], [ix1, iy1, z1], [xc, yc, z2]] },
{ pts: [[ix1, iy1, z1], [ix0, iy1, z1], [xc, yc, z2]] },
{ pts: [[ix0, iy1, z1], [ix0, iy0, z1], [xc, yc, z2]] },
],
gables: [],
ridgeHeight: z2 - e,
};
}
case "zelt": {
// Allseitig zur Spitze über der Mitte.
const hz = Math.min(halfW, halfD) * tan;
const az = e + hz;
return {
eaves,
ridges: [],
hips: [
[P(x0, y0), P(xc, yc)],
[P(x1, y0), P(xc, yc)],
[P(x1, y1), P(xc, yc)],
[P(x0, y1), P(xc, yc)],
],
breaks: [],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [xc, yc, az]] },
{ pts: [[x1, y0, e], [x1, y1, e], [xc, yc, az]] },
{ pts: [[x1, y1, e], [x0, y1, e], [xc, yc, az]] },
{ pts: [[x0, y1, e], [x0, y0, e], [xc, yc, az]] },
],
gables: [],
ridgeHeight: hz,
};
}
}
}
const swap2 = (p: Vec2): Vec2 => ({ x: p.y, y: p.x });
const swap3 = (p: Vec3): Vec3 => [p[1], p[0], p[2]];
const swapLine = (l: [Vec2, Vec2]): [Vec2, Vec2] => [swap2(l[0]), swap2(l[1])];
/**
* Dach-Geometrie aus Form/Neigung/Überstand + Traufhöhe `eavesZ`. Rechnet auf
* der Bounding-Box des Umrisses, um `overhang` nach aussen geweitet. Für
* `ridgeAxis:"y"` wird kanonisch (First entlang X) gerechnet und danach xy
* transponiert.
*/
export function roofGeometry(roof: Roof, eavesZ: number): RoofGeometry {
const bb = roofBBox(roof.outline);
const oe = Math.max(0, roof.overhang); // Traufüberstand (senkrecht zum First)
const og = Math.max(0, roof.overhangGable ?? roof.overhang); // Ortgang (entlang First)
// Der First liegt entlang der `ridgeAxis`-Achse: dort wirkt der Ortgang-, quer
// dazu der Traufüberstand. In OUTLINE-Koordinaten je nach Firstrichtung mappen.
const ox = roof.ridgeAxis === "x" ? og : oe;
const oy = roof.ridgeAxis === "x" ? oe : og;
let x0 = bb.x0 - ox;
let y0 = bb.y0 - oy;
let x1 = bb.x1 + ox;
let y1 = bb.y1 + oy;
const flip = roof.ridgeAxis === "y";
if (flip) {
[x0, y0, x1, y1] = [y0, x0, y1, x1];
}
const g = computeCanonical(x0, y0, x1, y1, eavesZ, roof);
if (!flip) return g;
return {
eaves: g.eaves.map(swap2),
ridges: g.ridges.map(swapLine),
hips: g.hips.map(swapLine),
breaks: g.breaks.map(swapLine),
planes: g.planes.map((pl) => ({ pts: pl.pts.map(swap3) })),
gables: g.gables.map((poly) => poly.map(swap3)),
ridgeHeight: g.ridgeHeight,
};
}
/** Punkt-in-Polygon (Ray-Casting), 2D-Aufsicht. */
function pointInPolygon2D(p: Vec2, poly: Vec2[]): boolean {
let inside = false;
const n = poly.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const a = poly[i];
const b = poly[j];
const intersect =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
/**
* Dach-UNTERKANTE (Innenseite der untersten Schicht) an einem Grundriss-Punkt
* (x,y) `null`, wenn der Punkt ausserhalb aller Dachflächen liegt (Aufsicht).
* Ebenengleichung je Fläche über die Newell-Normale (identische Herleitung wie
* `toSection.ts::appendRoofSections`, dort inline für den Vertikalschnitt) minus
* `totalLayerThickness` (Summe aller Dachschicht-Dicken, s. `roofLayers`).
* Dient dem Wand-Zuschnitt unter geneigten Dächern ({@link trimWallTopForRoofs}
* in `toWalls3d.ts`) vermeidet, dass ein flacher Wand-Top die Dachfläche
* durchdringt (Z-Fighting).
*/
export function roofUndersideAt(
geo: RoofGeometry,
totalLayerThickness: number,
x: number,
y: number,
): number | null {
for (const pl of geo.planes) {
const pts = pl.pts;
if (pts.length < 3) continue;
if (!pointInPolygon2D({ x, y }, pts.map((p) => ({ x: p[0], y: p[1] })))) continue;
let A = 0;
let B = 0;
let C = 0;
for (let i = 0; i < pts.length; i++) {
const c = pts[i];
const d = pts[(i + 1) % pts.length];
A += (c[1] - d[1]) * (c[2] + d[2]);
B += (c[2] - d[2]) * (c[0] + d[0]);
C += (c[0] - d[0]) * (c[1] + d[1]);
}
if (Math.abs(C) < 1e-9) continue; // (nahezu) vertikale Fläche
const topZ = pts[0][2] - (A * (x - pts[0][0]) + B * (y - pts[0][1])) / C;
return topZ - Math.max(0, totalLayerThickness);
}
return null;
}
+102
View File
@@ -0,0 +1,102 @@
// Tests für die SIA-416-Flächenbilanz (roomArea.ts): Roll-up-Hierarchie,
// insbesondere die Einhängung der Kategorie AGF (Aussengeschossfläche).
import { describe, expect, it } from "vitest";
import { balance, roomsToCsv, SIA_CATEGORIES, siaLabel } from "./roomArea";
describe("SIA_CATEGORIES", () => {
it("enthält AGF als Blatt-Kategorie", () => {
expect(SIA_CATEGORIES).toContain("AGF");
});
it("liefert das deutsche Label für AGF", () => {
expect(siaLabel("AGF")).toBe("Aussengeschossfläche");
});
});
describe("balance() — Roll-up-Hierarchie", () => {
it("rollt HNF/NNF/VF/FF/KGF wie bisher auf (Referenzfall ohne AGF)", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "NNF", area: 5 },
{ category: "VF", area: 8 },
{ category: "FF", area: 2 },
{ category: "KGF", area: 10 },
]);
expect(bal.byKey.NF).toBeCloseTo(25);
expect(bal.byKey.NGF).toBeCloseTo(35);
expect(bal.byKey.GF).toBeCloseTo(45);
expect(bal.byKey.AGF).toBe(0);
});
it("zählt AGF zur GF, aber NICHT zu NF/NGF", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "AGF", area: 12 },
]);
// NGF bleibt unverändert (nur HNF trägt bei) — AGF fliesst hier nicht ein.
expect(bal.byKey.NF).toBeCloseTo(20);
expect(bal.byKey.NGF).toBeCloseTo(20);
// GF = NGF + KGF + AGF = 20 + 0 + 12
expect(bal.byKey.AGF).toBeCloseTo(12);
expect(bal.byKey.GF).toBeCloseTo(32);
expect(bal.total).toBeCloseTo(32);
});
it("GF = NGF + KGF + AGF bei gemischter Belegung", () => {
const bal = balance([
{ category: "HNF", area: 40 },
{ category: "NNF", area: 6 },
{ category: "VF", area: 9 },
{ category: "FF", area: 3 },
{ category: "KGF", area: 15 },
{ category: "AGF", area: 7.5 },
]);
expect(bal.byKey.NF).toBeCloseTo(46);
expect(bal.byKey.NGF).toBeCloseTo(58);
expect(bal.byKey.KGF).toBeCloseTo(15);
expect(bal.byKey.AGF).toBeCloseTo(7.5);
expect(bal.byKey.GF).toBeCloseTo(bal.byKey.NGF + bal.byKey.KGF + bal.byKey.AGF);
expect(bal.byKey.GF).toBeCloseTo(80.5);
expect(bal.total).toBeCloseTo(80.5);
});
it("führt eine eigene Bilanzzeile für AGF, positioniert nach KGF und vor GF", () => {
const bal = balance([{ category: "AGF", area: 4 }]);
const keys = bal.rows.map((r) => r.key);
expect(keys).toEqual(["HNF", "NNF", "NF", "VF", "FF", "NGF", "KGF", "AGF", "GF"]);
const agfRow = bal.rows.find((r) => r.key === "AGF");
expect(agfRow).toBeDefined();
expect(agfRow?.aggregate).toBe(false);
expect(agfRow?.area).toBeCloseTo(4);
expect(agfRow?.count).toBe(1);
});
});
describe("roomsToCsv() — Bilanzblock mit AGF", () => {
it("weist einen Raum mit Kategorie AGF in Raumliste und Bilanz korrekt aus", () => {
const csv = roomsToCsv([
{ number: "R.01", name: "Wohnen", category: "HNF", area: 20 },
{ number: "R.02", name: "Balkon", category: "AGF", area: 6 },
]);
const lines = csv.split("\r\n");
// Raumliste enthält den AGF-Raum mit Kategorie + Label.
const roomLine = lines.find((l) => l.startsWith("R.02"));
expect(roomLine).toBeDefined();
expect(roomLine).toContain("AGF");
expect(roomLine).toContain("Aussengeschossfläche");
expect(roomLine).toContain("6.00");
// Bilanzblock enthält eine AGF-Zeile mit der Fläche des Raums.
const balanceAgfLine = lines.find((l) => l.includes(";AGF;"));
expect(balanceAgfLine).toBeDefined();
expect(balanceAgfLine).toContain("Aussengeschossfläche");
expect(balanceAgfLine).toContain("6.00");
// GF-Summe berücksichtigt AGF (20 HNF + 6 AGF = 26).
const gfLine = lines.find((l) => l.includes(";GF;"));
expect(gfLine).toBeDefined();
expect(gfLine).toContain("26.00");
});
});
+15 -6
View File
@@ -92,6 +92,7 @@ export function centroid(pts: Vec2[]): Vec2 {
//
// GF Geschossfläche
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
// ├── AGF Aussengeschossfläche (Balkone, Terrassen, Laubengänge …)
// └── NGF Nettogeschossfläche
// ├── NF Nutzfläche
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
@@ -100,10 +101,12 @@ export function centroid(pts: Vec2[]): Vec2 {
// └── FF Funktionsfläche (technische Anlagen, Haustechnik)
//
// Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind
// HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF.
// HNF, NNF, VF, FF, KGF und AGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF+AGF = GF.
// AGF zählt zur Geschossfläche, ist aber ausserhalb der Gebäudehülle (Aussen-
// bereich) und fliesst daher NICHT in NGF (bzw. NF) ein.
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF" | "AGF";
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
export type SiaAggregate = "NF" | "NGF" | "GF";
@@ -118,6 +121,7 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [
"VF",
"FF",
"KGF",
"AGF",
] as const;
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
@@ -134,6 +138,7 @@ const SIA_LABELS: Record<SiaKey, string> = {
VF: "Verkehrsfläche",
FF: "Funktionsfläche",
KGF: "Konstruktionsfläche",
AGF: "Aussengeschossfläche",
NF: "Nutzfläche",
NGF: "Nettogeschossfläche",
GF: "Geschossfläche",
@@ -152,7 +157,8 @@ export function siaLabelFull(key: SiaKey): string {
/**
* Zuordnung Blatt-Kategorie übergeordnete Aggregate. Eine HNF trägt zu
* NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF.
* Wird für den Bilanz-Roll-up genutzt.
* AGF (Aussengeschossfläche) trägt nur zu GF bei sie liegt ausserhalb der
* Gebäudehülle und zählt NICHT zu NF/NGF. Wird für den Bilanz-Roll-up genutzt.
*/
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
HNF: ["NF", "NGF", "GF"],
@@ -160,6 +166,7 @@ const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
VF: ["NGF", "GF"],
FF: ["NGF", "GF"],
KGF: ["GF"],
AGF: ["GF"],
};
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
@@ -242,14 +249,14 @@ export interface Balance {
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
*
* Die zurückgegebenen `rows` sind in kanonischer Reihenfolge:
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ)
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, AGF, GF(=Σ)
* passend für die Bilanz-Tabelle und den CSV-Export.
*/
export function balance(
rooms: { category: SiaCategory; area: number }[],
): Balance {
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
@@ -269,6 +276,7 @@ export function balance(
VF: leaf.VF,
FF: leaf.FF,
KGF: leaf.KGF,
AGF: leaf.AGF,
NF: agg.NF,
NGF: agg.NGF,
GF: agg.GF,
@@ -297,6 +305,7 @@ export function balance(
mkLeaf("FF"),
mkAgg("NGF"),
mkLeaf("KGF"),
mkLeaf("AGF"),
mkAgg("GF"),
];
+655 -8
View File
@@ -15,16 +15,37 @@ export const de = {
"topbar.perspektive": "Perspektive",
"view3d.group": "Ansicht 3D",
"view3d.front": "Front",
"view3d.front": "Vorne",
"view3d.front.hint": "Frontalansicht von vorne",
"view3d.front.abbr": "V",
"view3d.back": "Hinten",
"view3d.back.hint": "Rückansicht von hinten",
"view3d.back.abbr": "H",
"view3d.top": "Oben",
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
"view3d.side": "Seite",
"view3d.side.hint": "Seitenansicht (90° zur Front)",
"view3d.top.abbr": "O",
"view3d.side": "Rechts",
"view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
"view3d.side.abbr": "R",
"view3d.left": "Links",
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
"view3d.left.abbr": "L",
"view3d.perspective": "Perspektive",
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
"topbar.views": "Ansichten",
"topbar.views.menu": "Ansicht wählen (Vorne/Hinten/Links/Rechts/Oben)",
"topbar.axo": "Axonometrie",
"topbar.axo.hint": "3D-Axonometrie (freier 3/4-Blick)",
"view3d.iso": "Isometrie",
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
"view3d.isoFrontLeft": "Vorne-Links",
"view3d.isoFrontLeft.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-links)",
"view3d.isoBackRight": "Hinten-Rechts",
"view3d.isoBackRight.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-rechts)",
"view3d.isoBackLeft": "Hinten-Links",
"view3d.isoBackLeft.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-links)",
"view3d.iso.menu": "Weitere Iso-Ansichten",
"view3d.iso.menu.hint": "Einen der vier oberen Iso-Oktanten wählen",
"view3d.camera": "Kamera",
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
"view3d.fov": "Sichtwinkel",
@@ -72,7 +93,9 @@ export const de = {
"display.style.color": "Farbig",
"display.style.mono": "Schwarz-Weiss",
"display.style.shaded": "Schattiert",
"display.style.shadedEdges": "Schattiert mit Kanten",
"display.style.white": "Weiss",
"display.style.contour": "Nur Konturen",
"display.style.textured": "Texturiert",
"display.style.wireframe": "Drahtgitter",
"display.style.hidden": "Kanten",
@@ -85,13 +108,48 @@ export const de = {
"topbar.resources": "Ressourcen",
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
// ── Menüleiste (Topbar, AppMenuBar) ──────────────────────────────────────
"menu.edit": "Bearbeiten",
"menu.edit.undo": "Rückgängig",
"menu.edit.redo": "Wiederholen",
"menu.edit.delete": "Löschen",
"menu.view": "Ansicht",
"menu.view.floorplan": "Grundriss",
"menu.view.perspective": "Perspektive",
"menu.view.referenceLines": "Referenzlinien",
"menu.window": "Fenster",
"menu.window.resources": "Ressourcen",
"menu.window.settings": "Einstellungen",
"menu.window.drawingLevels": "Zeichnungsebenen (Fenster)",
"menu.window.layerSettings": "Ebeneneinstellungen (aktive Ebene)",
"menu.window.contextImport": "Standort importieren",
// ── Native macOS-Systemmenüleiste (appMenu.ts) — eigener Namensraum, da sie
// ausser Bearbeiten/Ansicht/Fenster (oben) auch Datei/Export/Transformieren
// zeigt, was die In-App-AppMenuBar nicht hat. ─────────────────────────────
"menu.native.file": "Datei",
"menu.native.open": "Öffnen …",
"menu.native.saveAs": "Speichern unter …",
"menu.native.import": "Importieren …",
"menu.native.export": "Exportieren",
"menu.native.exportSchedule": "Bauteilliste (CSV)",
"menu.native.edit": "Bearbeiten",
"menu.native.move": "Bewegen",
"menu.native.mirror": "Spiegeln",
"menu.native.rotate": "Drehen",
"menu.native.view": "Ansicht",
"menu.native.window": "Fenster",
"menu.native.panels": "Panels",
"menu.native.about": "Über Dossier",
"menu.native.quit": "Beenden",
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.saveCurrent": " Aktuelles speichern …",
"layout.title": "Arbeitsumgebung",
"layout.placeholder": "Arbeitsumgebung …",
"layout.saveCurrent": " Aktuelle speichern …",
"layout.loadGroup": "Laden",
"layout.deleteGroup": "Löschen",
"layout.savePrompt": "Layout speichern als:",
"layout.savePrompt": "Arbeitsumgebung speichern als:",
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
"combo.layers": "Ebenen-Komb.",
@@ -120,6 +178,10 @@ export const de = {
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
"tool.text": "Text",
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
"tool.textbox": "Textspalte",
"tool.textbox.hint": "Textspalte: Anker, dann Spaltenbreite ziehen, dann Text eintippen",
// Ribbon-Oberleiste (Tabs + Gruppen)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
@@ -129,7 +191,14 @@ export const de = {
"ribbon.group.draw": "Zeichnen",
"ribbon.group.modify": "Ändern",
"ribbon.group.components": "Bauteile",
"ribbon.group.sections": "Schnitte",
"ribbon.group.custom": "Eigene Leiste",
"tools.view.label": "Darstellung",
"tools.view.list": "Liste",
"tools.view.icons": "Symbole",
"tools.cat.2d": "2D",
"tools.cat.3d": "3D",
"tools.cat.edit": "Bearbeiten",
"ribbon.custom.add": " Hinzufügen",
"ribbon.custom.empty": "Elemente über Hinzufügen wählen",
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
@@ -165,6 +234,8 @@ export const de = {
"snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Rasterweite (m)",
"snap.angleStep": "Winkelraster",
"snap.commonAngles": "Gängige Winkel",
"snap.commonAngleTolerance": "Winkeltoleranz (°)",
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
"transform.bar": "Transformation",
@@ -234,6 +305,7 @@ export const de = {
"attr.fill": "Füllung",
"attr.fillColor": "Füllfarbe",
"attr.clearFill": "Füllfarbe entfernen",
"attr.pickColor": "Farbe wählen",
"attr.foreground": "Vordergrund",
"attr.background": "Hintergrund",
"attr.bySystem": "Nach System",
@@ -249,6 +321,8 @@ export const de = {
// Objekt-Info-Palette (ObjectInfoPanel)
"objinfo.title": "Objekt-Info",
"objinfo.empty": "Kein Objekt gewählt",
"objinfo.category": "Klasse",
"objinfo.level": "Ebene",
"objinfo.refpoint": "Bezugspunkt",
"objinfo.setRefpoint": "Bezugspunkt wählen",
"objinfo.x": "X",
@@ -266,12 +340,70 @@ export const de = {
"objinfo.kind.opening": "Öffnung",
"objinfo.kind.stair": "Treppe",
"objinfo.kind.room": "Raum",
"objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Stütze",
"objinfo.kind.roof": "Dach",
"objinfo.kind.contextObject": "Kontext-Objekt",
// ── Kontext-Objekt-Attribute (Object-Info-Panel) ────────────────────────
"objinfo.contextObject.section": "Georeferenzierung",
"objinfo.contextObject.type": "Art",
"objinfo.contextObject.type.importedMesh": "Importiertes Mesh",
"objinfo.contextObject.type.terrainMesh": "Gelände",
"objinfo.contextObject.type.contourSet": "Höhenlinien",
"objinfo.contextObject.lv95E": "LV95 Ost (E)",
"objinfo.contextObject.lv95N": "LV95 Nord (N)",
"objinfo.contextObject.noAnchor": "Kein Standort-Bezug im Projekt",
// ── Dach-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.roof.section": "Dach",
"objinfo.roof.shape": "Dachform",
"objinfo.roof.shape.flach": "Flachdach",
"objinfo.roof.shape.pult": "Pultdach",
"objinfo.roof.shape.sattel": "Satteldach",
"objinfo.roof.shape.walm": "Walmdach",
"objinfo.roof.shape.mansarde": "Mansarddach",
"objinfo.roof.shape.zelt": "Zeltdach",
"objinfo.roof.ridgeAxis": "Firstrichtung",
"objinfo.roof.ridgeAxis.x": "entlang X",
"objinfo.roof.ridgeAxis.y": "entlang Y",
"objinfo.roof.pitch": "Neigung (°)",
"objinfo.roof.pitchUpper": "Obere Neigung (°)",
"objinfo.roof.type": "Dachtyp",
"objinfo.roof.type.single": "Einschichtig (Dicke)",
"objinfo.roof.mansardType": "Mansard-Art",
"objinfo.roof.mansard.giebel": "Giebel-Mansarde",
"objinfo.roof.mansard.walm": "Walm-Mansarde",
"objinfo.roof.mansard.zelt": "Zelt-Mansarde",
"objinfo.roof.mansardKnee": "Knicklage (% Spannweite)",
"objinfo.roof.width": "Breite (m)",
"objinfo.roof.depth": "Tiefe (m)",
"objinfo.roof.overhang": "Überstand Traufe (m)",
"objinfo.roof.overhangGable": "Überstand Ortgang (m)",
"objinfo.roof.thickness": "Dachdicke (m)",
"objinfo.roof.baseElevation": "Traufhöhe (m)",
"objinfo.roof.ridgeHeight": "Firsthöhe",
"objinfo.roof.area": "Grundfläche",
// ── Stützen-Attribute (Object-Info-Panel) ───────────────────────────────
"objinfo.column.section": "Stütze",
"objinfo.column.profile": "Profil",
"objinfo.column.profile.rect": "Rechteck",
"objinfo.column.profile.round": "Rund",
"objinfo.column.width": "Breite",
"objinfo.column.depth": "Tiefe",
"objinfo.column.radius": "Radius",
"objinfo.column.height": "Höhe",
"objinfo.column.rotation": "Drehung (°)",
"objinfo.column.floor": "Geschoss",
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.wall.section": "Wand",
"objinfo.wall.refLine": "Referenzlinie",
"objinfo.wall.refLine.left": "Außen (links)",
"objinfo.wall.refLine.center": "Mitte",
"objinfo.wall.refLine.right": "Innen (rechts)",
"objinfo.wall.refLine.joint": "Fuge",
"objinfo.wall.sliceTermination": "An Decke enden",
"objinfo.wall.sliceTermination.both": "Aus",
"objinfo.wall.sliceTermination.below": "Unten",
"objinfo.wall.sliceTermination.above": "Oben",
"objinfo.wall.buildup": "Aufbau",
"objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Mehrschichtig",
@@ -284,6 +416,11 @@ export const de = {
"objinfo.wall.top": "Oberkante (OK)",
"objinfo.wall.anchorFloor": "Gebunden",
"objinfo.wall.anchorCustom": "Eigene",
"objinfo.anchor.refFloor": "Referenzgeschoss",
"objinfo.anchor.customHeight": "Eigene Höhe",
"objinfo.wall.length": "Länge",
"objinfo.wall.volumeGross": "Brutto",
"objinfo.volume": "Volumen",
"objinfo.wall.height": "Höhe",
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
"objinfo.ceiling.section": "Decke",
@@ -294,11 +431,88 @@ export const de = {
"objinfo.ceiling.top": "Oberkante (OK)",
"objinfo.ceiling.bottom": "Unterkante (UK)",
"objinfo.ceiling.height": "Dicke (OKUK)",
"objinfo.ceiling.openings": "Aussparungen",
"objinfo.ceiling.removeOpening": "Aussparung entfernen",
"objinfo.ceiling.area": "Fläche",
"objinfo.ceiling.anchorFloor": "Gebunden",
"objinfo.ceiling.anchorCustom": "Eigene",
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
"objinfo.opening.section": "Öffnung",
"objinfo.opening.type": "Typ",
"objinfo.opening.editType": "Einstellungen …",
// Fenster-/Tür-Einstellungsdialog (OpeningEditorDialog).
"oed.title.window": "Fenster-Einstellungen",
"oed.title.door": "Tür-Einstellungen",
"oed.style": "Stil",
"oed.saveStyle": "Als Stil speichern …",
"oed.saveStyle.hint": "Aktuelle Einstellungen als neuen, wiederverwendbaren Stil ablegen",
"oed.saveStyle.prompt": "Name des neuen Stils:",
"oed.saveStyle.copySuffix": "(Kopie)",
"oed.categories": "Kategorien",
"oed.noStyle": "Kein Stil zugewiesen — über die Stil-Leiste einen Stil wählen oder speichern.",
"oed.preview": "Ansicht",
"oed.cat.basis": "Basis",
"oed.cat.groesse": "Grösse/Position",
"oed.cat.rahmen": "Rahmen",
"oed.cat.fluegel": "Flügel/Sprossen",
"oed.cat.sonnenschutz": "Sonnenschutz",
"oed.cat.tuerblatt": "Türblatt",
"oed.f.kind": "Öffnungsart",
"oed.f.glazing": "Verglasung",
"oed.f.insetFace": "Einbaulage ab",
"oed.f.insetFromFace": "Abstand von Kante (m)",
"oed.f.width": "Breite (m)",
"oed.f.height": "Höhe (m)",
"oed.f.sillHeight": "Brüstungshöhe (m)",
"oed.f.position": "Position (m)",
"oed.f.frameThickness": "Rahmenstärke quer (m)",
"oed.f.frameDepth": "Rahmentiefe (m)",
"oed.f.frameWidth": "Rahmenbreite Ansicht (m)",
"oed.f.transomHeight": "Oberlichthöhe (m)",
"oed.f.mullionRows": "Kämpfer-/Sprossenzeilen",
"oed.f.mullionCols": "Sprossen-Spalten",
"oed.f.sillLineFaces": "Auf-/Untersicht-Linie",
"oed.f.sillLineView": "Blickrichtung",
"oed.sillLine.off": "Keine",
"oed.sillLine.beide": "Beide",
"oed.sillLine.auf": "Aufsicht (von oben)",
"oed.sillLine.unter": "Untersicht (von unten)",
"oed.kind.dreh": "Dreh",
"oed.kind.kipp": "Kipp",
"oed.kind.drehkipp": "Dreh-Kipp",
"oed.kind.fest": "Fest",
"oed.kind.schiebe": "Schiebe",
"oed.glazing.1": "Einfach",
"oed.glazing.2": "Zweifach",
"oed.glazing.3": "Dreifach",
"oed.insetFace.aussen": "Aussenfläche",
"oed.insetFace.innen": "Innenfläche",
"oed.fluegel.hint": "Flügel und Pfosten von links nach rechts. Öffnungsart und Anschlag je Flügel.",
"oed.fluegel.col.kind": "Typ",
"oed.fluegel.col.opening": "Öffnung",
"oed.fluegel.col.hinge": "Anschlag",
"oed.fluegel.type.fluegel": "Flügel",
"oed.fluegel.type.pfosten": "Pfosten",
"oed.fluegel.remove": "Entfernen",
"oed.hinge.left": "Links",
"oed.hinge.right": "Rechts",
"oed.shading.create": "Rollladen-/Sonnenschutzkasten",
"oed.shading.kind": "Art",
"oed.shading.rollladen": "Rollladen",
"oed.shading.raffstore": "Raffstore",
"oed.shading.markise": "Markise",
"oed.shading.boxHeight": "Kastenhöhe (m)",
"oed.shading.boxDepth": "Kastentiefe (m)",
"oed.door.leafCount": "Türblätter",
"oed.door.leaf1": "Einflüglig",
"oed.door.leaf2": "Zweiflüglig",
"oed.door.leafStyle": "Blattart",
"oed.door.glatt": "Glatt",
"oed.door.kassette": "Kassette",
"oed.door.glas": "Glas",
"oed.door.wandoeffnung": "Wandöffnung",
"objinfo.stair.type": "Typ",
"objinfo.type.none": "(kein Typ)",
"objinfo.opening.kind": "Art",
"objinfo.opening.kind.window": "Fenster",
"objinfo.opening.kind.door": "Tür",
@@ -329,6 +543,11 @@ export const de = {
"objinfo.opening.lintelLines.innen": "Innen",
"objinfo.opening.lintelLines.aussen": "Außen",
"objinfo.opening.lintelLines.beide": "Beide",
"objinfo.opening.frameLine": "Rahmen (Schnitt)",
"objinfo.opening.frameViewLine": "Rahmen (Aufsicht)",
"objinfo.opening.sashLine": "Fenster (Flügel/Sprossen)",
"objinfo.opening.sillLine": "Sims",
"objinfo.lineWeight": "Strichstärke (mm)",
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
"objinfo.stair.section": "Treppe",
"objinfo.stair.shape": "Grundform",
@@ -343,6 +562,9 @@ export const de = {
"objinfo.stair.direction": "Laufrichtung",
"objinfo.stair.direction.up": "Aufwärts",
"objinfo.stair.direction.down": "Abwärts",
"objinfo.stair.arrow": "Pfeil",
"objinfo.stair.arrow.line": "Offen",
"objinfo.stair.arrow.filled": "Gefüllt",
"objinfo.stair.refFloor": "Referenzgeschoss",
"objinfo.stair.bottom": "Unterkante (UK)",
"objinfo.stair.top": "Oberkante (OK)",
@@ -359,6 +581,29 @@ export const de = {
"objinfo.room.perimeter": "Umfang",
"objinfo.room.refFloor": "Referenzgeschoss",
"objinfo.room.editStamp": "Stempel bearbeiten …",
// ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ─────────
"objinfo.sectionLine.section": "Schnittlinie",
"objinfo.sectionLine.kind.section": "Schnitt",
"objinfo.sectionLine.kind.elevation": "Ansicht",
"objinfo.sectionLine.name": "Name",
"objinfo.sectionLine.flip": "Blickrichtung umkehren",
"objinfo.sectionLine.cut3d": "Im 3D schneiden",
"objinfo.sectionLine.startX": "Start X",
"objinfo.sectionLine.startY": "Start Y",
"objinfo.sectionLine.endX": "Ende X",
"objinfo.sectionLine.endY": "Ende Y",
"objinfo.sectionLine.depth": "Schnitt-Tiefe (m)",
"objinfo.sectionLine.hiddenLines": "Verdeckte Kanten",
"objinfo.sectionLine.depthHint": "leer = unbegrenzt",
"objinfo.sectionLine.delete": "Schnittlinie löschen",
"objinfo.extrudedSolid.section": "Extrusion",
"objinfo.extrudedSolid.height": "Höhe",
"objinfo.extrudedSolid.taper": "Verjüngung",
"objinfo.extrudedSolid.area": "Grundfläche",
"objinfo.extrudedSolid.floor": "Geschoss",
"objinfo.rotate": "Drehen",
"objinfo.drawing.length": "Länge",
"objinfo.drawing.radius": "Radius",
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
"levels.addFloor": "+ Geschoss",
"levels.addSection": "+ Schnitt",
@@ -377,6 +622,8 @@ export const de = {
"nav.show": "Einblenden",
"nav.categoryVisible": "{name} sichtbar",
"nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)",
"nav.categorySettings": "Ebeneneinstellungen für {name}",
"nav.drawingLevelsWindow": "Zeichnungsebenen als eigenes Fenster öffnen",
// ── Badges (Art einer Zeichnungsebene) ───────────────────────────────────
"badge.floor": "Geschoss",
@@ -391,6 +638,7 @@ export const de = {
"display.active": "Nur aktive",
"display.grey": "Andere grau",
"display.grey_locked": "Andere grau & gesperrt",
"display.locked": "Andere gesperrt",
// ── Dock ─────────────────────────────────────────────────────────────────
"dock.resize": "Breite anpassen",
@@ -461,7 +709,9 @@ export const de = {
"section.loading": "{kind} wird berechnet …",
"section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
"section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)",
"section.legend": "{kind} · abgeleitete Projektion (render3d)",
"section.legend": "{kind} · abgeleitete Projektion aus dem Modell",
"elevation.shadows": "Schatten",
"elevation.shadows.hint": "Schlagschatten auskragender Bauteile (45°) in der Ansicht ein-/ausblenden",
"props.floorHeight": "Geschosshöhe",
"props.cutHeight": "Schnitthöhe",
"props.okff": "OKFF",
@@ -489,6 +739,10 @@ export const de = {
"default.ceilingTypeSingle": "Decke {thickness} m",
"default.wallTypeName": "Neuer Wandtyp",
"default.ceilingTypeName": "Neuer Deckentyp",
"default.roofTypeName": "Neuer Dachtyp",
"default.doorTypeName": "Neuer Türtyp",
"default.windowTypeName": "Neuer Fenstertyp",
"default.stairTypeName": "Neuer Treppentyp",
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
@@ -497,17 +751,54 @@ export const de = {
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
"alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.",
"alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.",
"alert.roofTypeInUse": "Dachtyp „{name}\" wird von einem Dach verwendet und kann nicht gelöscht werden.",
"alert.doorTypeInUse": "Türtyp „{name}\" wird von einer Tür verwendet und kann nicht gelöscht werden.",
"alert.windowTypeInUse": "Fenstertyp „{name}\" wird von einem Fenster verwendet und kann nicht gelöscht werden.",
"alert.stairTypeInUse": "Treppentyp „{name}\" wird von einer Treppe verwendet und kann nicht gelöscht werden.",
// ── Ressourcen-Manager ───────────────────────────────────────────────────
"resources.title": "Ressourcen",
"resources.label": "Ressourcen-Manager",
"resources.close": "Schließen",
"resources.loading": "Verbinde mit dem Hauptfenster …",
"resources.tab.components": "Bauteile",
"resources.tab.hatches": "Schraffuren",
"resources.tab.lines": "Linien",
"resources.tab.wallStyles": "Wandstile",
"resources.tab.ceilingStyles": "Deckenstile",
"resources.tab.roofStyles": "Dachtypen",
"resources.tab.doorStyles": "Türtypen",
"resources.tab.windowStyles": "Fenstertypen",
"resources.tab.stairStyles": "Treppentypen",
"resources.tab.materials": "Materialien",
"resources.tab.overrides": "Overrides",
// Grafische Overrides (Regel-Engine).
"resources.overrides.hint":
"Regeln übersteuern Farbe/Strichstärke/Linienstil beim Rendern (Elementdaten bleiben unverändert). Reihenfolge = Priorität: pro Feld gewinnt die oberste aktive Regel.",
"resources.overrides.empty": "Noch keine Override-Regeln.",
"resources.overrides.placeholder": "Regel",
"resources.overrides.defaultName": "Neue Regel",
"resources.overrides.delete": "Regel löschen",
"resources.overrides.moveUp": "Regel nach oben (höhere Priorität)",
"resources.overrides.moveDown": "Regel nach unten (tiefere Priorität)",
"resources.overrides.enabled": "Regel aktiv",
"resources.overrides.condition": "Bedingung",
"resources.overrides.condType": "Kriterium",
"resources.overrides.condType.layer": "Ebene (Name/Code)",
"resources.overrides.condType.object": "Objektname",
"resources.overrides.operator": "Operator",
"resources.overrides.op.equals": "ist gleich",
"resources.overrides.op.contains": "enthält",
"resources.overrides.op.startsWith": "beginnt mit",
"resources.overrides.op.notEquals": "ist ungleich",
"resources.overrides.value": "Wert",
"resources.overrides.value.placeholder": "z. B. Wände oder 20",
"resources.overrides.actions": "Aktionen",
"resources.overrides.action.color": "Farbe",
"resources.overrides.action.lineweight": "Strichstärke (mm)",
"resources.overrides.action.linetype": "Linienstil",
"resources.overrides.linetype.none": "— (unverändert)",
"resources.wallStyles.hint":
"Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
@@ -517,6 +808,91 @@ export const de = {
"resources.ceilingStyles.empty": "Noch keine Deckentypen.",
"resources.wallStyles.placeholder": "Wandtyp",
"resources.ceilingStyles.placeholder": "Deckentyp",
"resources.roofStyles.hint":
"Je Dachtyp (Schichtaufbau aussen/Eindeckung → innen/Verkleidung) die Schichtfugen als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
"resources.roofStyles.empty": "Noch keine Dachtypen.",
"resources.roofStyles.placeholder": "Dachtyp",
// Bauteil-Typen (Tür/Fenster/Treppe) — Formular-Bibliotheken.
"resources.doorTypes.hint":
"Wiederverwendbare Türtypen mit Bauart, Blattausführung und Standardmaßen für neu platzierte Türen.",
"resources.doorTypes.empty": "Noch keine Türtypen.",
"resources.doorTypes.placeholder": "Türtyp",
"resources.windowTypes.hint":
"Wiederverwendbare Fenstertypen mit Öffnungsart, Verglasung und Standardmaßen für neu platzierte Fenster.",
"resources.windowTypes.empty": "Noch keine Fenstertypen.",
"resources.windowTypes.placeholder": "Fenstertyp",
"resources.stairTypes.hint":
"Wiederverwendbare Treppentypen mit Tragart, Stufenausbildung und Geländer für neu platzierte Treppen.",
"resources.stairTypes.empty": "Noch keine Treppentypen.",
"resources.stairTypes.placeholder": "Treppentyp",
"resources.delete.doorType": "Türtyp „{name}\" löschen",
"resources.delete.windowType": "Fenstertyp „{name}\" löschen",
"resources.delete.stairType": "Treppentyp „{name}\" löschen",
"resources.field.doorKind": "Bauart",
"resources.field.leafCount": "Flügelzahl",
"resources.field.leafStyle": "Blattausführung",
"resources.field.glazingRatio": "Glasanteil",
"resources.field.frameThickness": "Zargenstärke (m)",
"resources.field.frameDepth": "Zargentiefe (m)",
"resources.field.defaultWidth": "Lichtbreite (m)",
"resources.field.defaultHeight": "Lichthöhe (m)",
"resources.field.threshold": "Bodenschwelle",
"resources.field.frameKind": "Rahmenart",
"resources.field.frameWidth": "Rahmenbreite (m)",
"resources.field.insetFromFace": "Schichteinzug (m)",
"resources.field.insetFace": "Einzug ab Fläche",
"resources.field.innerLayerRecess": "Laibungs-Rücksprung (innerste Schicht)",
"resources.field.innerLayerRecess.hint": "Die innerste Wandschicht (meist Gips/Holzverkleidung) weicht an diesem Fenster rundum um ihre eigene Dicke von der Laibung zurück, statt bündig mit den übrigen Schichten abzuschliessen — ändert die 3D-Wandgeometrie, nicht nur die Rahmenposition.",
"resources.field.transomHeight": "Oberlicht-Höhe (m)",
"resources.field.mullionRows": "Kämpfer-Zeilen",
"resources.frameKind.zarge": "Zarge",
"resources.frameKind.blockrahmen": "Blockrahmen",
"resources.insetFace.aussen": "Aussen",
"resources.insetFace.innen": "Innen",
"resources.field.windowKind": "Öffnungsart",
"resources.field.wingCount": "Flügelzahl",
"resources.field.glazing": "Verglasung",
"resources.field.defaultSillHeight": "Brüstungshöhe (m)",
"resources.field.sillBoard": "Fensterbank",
"resources.field.structure": "Tragart",
"resources.field.closedRisers": "Setzstufen geschlossen",
"resources.field.treadThickness": "Trittstufendicke (m)",
"resources.field.nosing": "Trittkanten-Überstand (m)",
"resources.field.railing": "Geländer",
"resources.field.stairWidth": "Laufbreite (m)",
"resources.doorKind.dreh": "Drehflügel",
"resources.doorKind.schiebe": "Schiebe",
"resources.doorKind.wandoeffnung": "Wandöffnung",
"resources.leafCount.1": "1-flügelig",
"resources.leafCount.2": "2-flügelig",
"resources.leafStyle.glatt": "Glatt",
"resources.leafStyle.kassette": "Kassette",
"resources.leafStyle.glas": "Glas",
"resources.windowKind.dreh": "Dreh",
"resources.windowKind.kipp": "Kipp",
"resources.windowKind.drehkipp": "Dreh-Kipp",
"resources.windowKind.fest": "Fest",
"resources.windowKind.schiebe": "Schiebe",
"resources.glazing.einfach": "Einfach",
"resources.glazing.zweifach": "Zweifach",
"resources.glazing.dreifach": "Dreifach",
"resources.sillBoard.keine": "Keine",
"resources.sillBoard.innen": "Innen",
"resources.sillBoard.aussen": "Außen",
"resources.sillBoard.beide": "Beide",
"resources.structure.massiv": "Massiv",
"resources.structure.beton": "Betontreppe (Laufplatte)",
"resources.structure.wange": "Wange",
"resources.structure.aufgesattelt": "Aufgesattelt",
"resources.structure.spindel": "Spindel",
"resources.railing.keine": "Keine",
"resources.railing.links": "Links",
"resources.railing.rechts": "Rechts",
"resources.railing.beide": "Beide",
"resources.col.layer": "Schicht",
"resources.col.component": "Bauteil",
"resources.col.thickness": "Dicke (mm)",
@@ -545,6 +921,9 @@ export const de = {
"resources.components.placeholder": "Bauteil",
"resources.col.name": "Name",
"resources.col.color": "Farbe",
"resources.col.abbrev": "Kürzel",
"resources.col.abbrev.hint": "Kurz-Code für Wandtyp-Dropdowns (z. B. «BET», «HLZ»)",
"resources.col.abbrev.placeholder": "z. B. BET",
"resources.col.hatch": "Schnittschraffur",
"resources.col.viewHatch": "Ansichtsschraffur",
"resources.viewHatch.none": "(keine)",
@@ -610,6 +989,7 @@ export const de = {
"resources.delete.hatch": "Schraffur „{name}\" löschen",
"resources.delete.wallType": "Wandtyp „{name}\" löschen",
"resources.delete.ceilingType": "Deckentyp „{name}\" löschen",
"resources.delete.roofType": "Dachtyp „{name}\" löschen",
// Schraffur-/Linien-Typ (Master-Detail-Editor).
"resources.field.type": "Typ",
@@ -684,6 +1064,25 @@ export const de = {
"cmd.ceiling.label": "Decke",
"cmd.ceiling.start": "Erster Umrisspunkt der Decke:",
"cmd.ceiling.next": "Nächster Punkt ( Schliessen ):",
"cmd.roof.label": "Dach",
"cmd.roof.start": "Erste Dachecke:",
"cmd.roof.corner": "Gegenecke:",
"cmd.ceilingOpening.label": "Deckenloch",
"cmd.ceilingOpening.start": "Erste Ecke der Aussparung (in einer Decke):",
"cmd.ceilingOpening.corner": "Gegenecke:",
"cmd.roof.shape.sattel": "Satteldach",
"cmd.roof.shape.walm": "Walmdach",
"cmd.roof.shape.pult": "Pultdach",
"cmd.roof.shape.mansarde": "Mansarddach",
"cmd.roof.shape.zelt": "Zeltdach",
"cmd.roof.shape.flach": "Flachdach",
"cmd.sectionline.label": "Schnittlinie",
"cmd.sectionline.start": "Schnitt-Startpunkt:",
"cmd.sectionline.end": "Schnitt-Endpunkt:",
"cmd.sectionline.kind.section": "Schnitt",
"cmd.sectionline.kind.elevation": "Ansicht",
"cmd.sectionline.newSection": "Schnitt {letter}",
"cmd.sectionline.newElevation": "Ansicht {letter}",
"cmd.opening.label": "Öffnung",
"cmd.opening.windowLabel": "Fenster",
"cmd.opening.doorLabel": "Tür",
@@ -695,6 +1094,9 @@ export const de = {
"cmd.opening.dir": "Richtung",
"cmd.opening.sillField": "Brüstung",
"cmd.opening.swingField": "Winkel",
"cmd.column.label": "Stütze",
"cmd.column.place": "Stütze setzen:",
"cmd.column.profile": "Profil",
"cmd.stair.label": "Treppe",
"cmd.stair.start": "Startpunkt der Treppe:",
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
@@ -729,11 +1131,30 @@ export const de = {
"cmd.arc.center": "Mittelpunkt:",
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
"cmd.arc.end": "Endpunkt (Endwinkel):",
"cmd.text.label": "Text",
"cmd.text.point": "Ankerpunkt:",
"cmd.text.enter": "Text eingeben:",
"cmd.textbox.label": "Textspalte",
"cmd.textbox.point": "Ankerpunkt:",
"cmd.textbox.width": "Spaltenbreite (zweiter Punkt):",
"cmd.textbox.enter": "Text eingeben:",
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
"cmd.move.label": "Verschieben",
"cmd.move.base": "Basispunkt:",
"cmd.move.target": "Zielpunkt:",
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.rotate.label": "Drehen",
"cmd.measure.label": "Messen",
"cmd.measure.start": "Messen von:",
"cmd.measure.to": "bis (Distanz · Winkel):",
"cmd.measure.next": "nächster Punkt (Rechtsklick = neuer Pfad):",
"objinfo.measure.title": "Messung",
"objinfo.measure.segment": "Segment",
"objinfo.measure.total": "Länge gesamt",
"objinfo.measure.area": "Fläche",
"objinfo.measure.angle": "Winkel",
"objinfo.measure.hint": "Klick = Punkt · Rechtsklick = neuer Pfad · Esc = Ende",
"transform.needSelection": "Erst Objekte wählen, dann drehen.",
"cmd.mirror.label": "Spiegeln",
"cmd.mirror.first": "Erster Punkt der Spiegelachse:",
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
@@ -746,6 +1167,10 @@ export const de = {
"cmd.offset.label": "Versatz",
"cmd.offset.pick": "Kurve wählen:",
"cmd.offset.side": "Seite oder Distanz:",
"cmd.extrude.label": "Extrudieren",
"cmd.extrude.pick": "Geschlossenes Profil wählen:",
"cmd.extrude.height": "Höhe:",
"cmd.extrude.taper": "Verjüngung (01, 1=Spitze):",
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
"cmd.trim.label": "Stutzen",
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
@@ -769,6 +1194,8 @@ export const de = {
"cmd.import.prompt": "DXF-Datei wählen …",
"cmd.terrain.label": "Gelände",
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
"cmd.georef.label": "Neuer Bezugspunkt",
"cmd.georef.prompt": "Neuer Nullpunkt (verschiebt das ganze Projekt):",
// ── Flächenbilanz (SIA 416) ──────────────────────────────────────────────
"nav.roomBalance": "Flächenbilanz",
@@ -789,9 +1216,15 @@ export const de = {
"site.contourSet": "Kontur-Satz",
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
"site.delete": "Entfernen",
"site.deleteLayer": "Ganze Ebene entfernen",
"site.group.imported": "Gebäude",
"site.group.terrain": "Terrain",
"site.group.contours": "Höhenlinien",
"site.type.mesh": "Mesh",
"site.type.contours": "Konturen",
"site.type.terrain": "Gelände",
"site.type.image": "Luftbild",
"site.group.image": "Luftbild",
"site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.",
"site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
@@ -860,12 +1293,47 @@ export const de = {
"file.exportPdf": "Als PDF exportieren…",
"file.exportDxf": "Als DXF exportieren…",
"file.exportSchedule": "Bauteilliste (CSV)…",
"file.exportIfc": "IFC exportieren",
"file.exportObj": "OBJ exportieren (3D-Mesh)",
"file.exportStl": "STL exportieren (3D-Mesh)",
"file.export": "Exportieren",
"file.openFailed": "Projekt-Datei konnte nicht gelesen werden.",
// ── Lock-Konflikt (Projektdatei bereits in anderer Instanz geöffnet) ────
"lock.title": "Projekt bereits geöffnet",
"lock.message":
"Diese Projektdatei ist bereits in einer anderen Instanz geöffnet.",
"lock.messageInfo": "Geöffnet auf {host} (Prozess {pid}).",
"lock.openReadOnly": "Schreibgeschützt öffnen",
"lock.forceOpen": "Trotzdem erzwingen",
"lock.forceOpenWarning":
"Achtung: Öffnet zum Schreiben, obwohl eine andere Instanz die Datei hält — Änderungen können sich gegenseitig überschreiben.",
"exportSave.title": "Exportieren",
"exportSave.format": "Format",
"exportSave.filename": "Dateiname",
"exportSave.locationNote": "Wird in den Standard-Download-Ordner gespeichert.",
"exportSave.fmt.csv": "Bauteilliste (CSV)",
"exportSave.fmt.ifc": "IFC (BIM)",
"exportSave.fmt.obj": "OBJ (3D-Mesh)",
"exportSave.fmt.stl": "STL (3D-Mesh)",
"about.title": "Über dossier",
"about.version": "Version",
"about.desc": "Open-Source-CAAD (Computer Aided Architecture Design) — schöne, normgerechte Pläne aus einem semantischen Gebäudemodell. Als Desktop-App (vollständige Fassung) und im Browser zugänglich.",
"about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "Lizenz: AGPL-3.0-or-later",
"about.suite": "Teil der openbureau-Suite",
"about.licenses": "Open-Source-Lizenzen (Drittkomponenten)",
"about.close": "Schließen",
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
"topbar.settings": "Einstellungen",
"topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)",
"settings.title": "Einstellungen",
"settings.section.workspace": "Arbeitsumgebung",
"settings.section.display": "Darstellung",
"settings.theme": "Erscheinungsbild",
"settings.theme.light": "Hell",
"settings.theme.dark": "Dunkel",
"settings.marqueeColor": "Auswahlrahmen-Farbe",
"settings.marqueeColor.hint":
"Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.",
@@ -888,8 +1356,15 @@ export const de = {
// ── Standort-Import (geo.admin / OSM) ─────────────────────────────────────
"site.importLocation": "Standort importieren",
"site.setGeoref": "Neuer Bezugspunkt",
"site.setGeoref.hint": "Klick im Grundriss verschiebt das ganze Projekt — der geklickte Punkt wird zum neuen Ursprung (0,0). Der reale Standort-Bezug bleibt erhalten.",
"ctxImport.title": "Standort importieren",
"ctxImport.close": "Schließen",
"ctxImport.geoAnchor": "Georeferenziert ab: {label}",
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
"ctxImport.geoAnchor.reset": "Zurücksetzen",
"ctxImport.geoAnchor.reset.hint": "Löscht den Standort-Bezug — der nächste Import setzt ihn neu.",
"ctxImport.rawHint": "Wird an den echten LV95-Koordinaten importiert (weit vom Nullpunkt). Danach mit „Neuer Bezugspunkt“ an einen bequemen Modellpunkt holen — der IFC-/DXF-Export schreibt trotzdem die echten Koordinaten.",
"ctxImport.location": "Ort / Adresse",
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
"ctxImport.search": "Suchen",
@@ -897,13 +1372,31 @@ export const de = {
"ctxImport.noResults": "Keine Treffer.",
"ctxImport.searchError": "Suche fehlgeschlagen.",
"ctxImport.radius": "Radius",
"ctxImport.refPoint": "Bezugspunkt (Modell-Nullpunkt)",
"ctxImport.refPoint.e": "LV95 Ost (E)",
"ctxImport.refPoint.n": "LV95 Nord (N)",
"ctxImport.refPoint.h": "Höhe (m ü. M.)",
"ctxImport.refPoint.hint": "Optional: reale Koordinate des Bezugspunkts (wie ArchiCADs Vermessungspunkt). Ost/Nord (LV95) legen fest, welcher Punkt im Modell auf (0,0) liegt; die Höhe (m ü. M.) wird informativ verankert und beim IFC-Export als Standort-Datumshöhe geschrieben. Leer = das Suchzentrum wird der Nullpunkt.",
"ctxImport.refPoint.anchorLabel": "Bezugspunkt {e} / {n}",
"ctxImport.sources": "Quellen",
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
"ctxImport.buildings.off": "Aus",
"ctxImport.buildings.flach": "Vereinfacht (Umriss, schnell)",
"ctxImport.buildings.v2": "Echt 2.0 (stabil)",
"ctxImport.buildings.v3": "Echt 3.0 (Beta)",
"ctxImport.src.swissTerrain": "Swisstopo-Gelände",
"ctxImport.src.swissImage": "SWISSIMAGE-Luftbild (2D-Hintergrund)",
"ctxImport.src.swissImageDrape": "Luftbild aufs Gelände legen (3D)",
"ctxImport.terrain.resolution": "Auflösung",
"ctxImport.terrain.res2": "2 m (schnell)",
"ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)",
"ctxImport.src.osmBuildings": "OSM-Gebäude",
"ctxImport.src.osmRoads": "OSM-Strassen",
"ctxImport.src.osmWater": "OSM-Wasser",
"ctxImport.src.osmGreen": "OSM-Grün",
"ctxImport.src.osmParking": "OSM-Parkplätze",
"ctxImport.src.osmRailway": "OSM-Bahn",
"ctxImport.src.osmForest": "OSM-Wald",
"ctxImport.cancel": "Abbrechen",
"ctxImport.confirm": "Importieren",
"ctxImport.importing": "Importiere…",
@@ -911,6 +1404,8 @@ export const de = {
"ctxImport.empty": "Keine Geometrie im gewählten Bereich gefunden.",
"ctxImport.importError": "Import fehlgeschlagen.",
"ctxImport.imported": "{count} Objekte in {sets} Sätzen importiert.",
"ctxImport.importedWithSkipped": "{count} Objekte in {sets} Sätzen importiert ({skipped} Gebäude-Kachel(n) übersprungen, zu gross).",
"ctxImport.buildingsSkipped": "Keine Gebäude geladen — {count} Kachel(n) waren zu gross (dicht bebautes Gebiet). Kleineren Radius versuchen oder Generation wechseln.",
// ── Raum-Befehl ─────────────────────────────────────────────────────────────
"cmd.room.label": "Raum",
@@ -947,6 +1442,9 @@ export const de = {
"roomStamp.showFloorArea": "Bodenfläche anzeigen",
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
"roomStamp.showUsage": "Nutzung anzeigen",
"roomStamp.roundingStep": "Flächen-Rundung",
"roomStamp.roundingStepDefault": "Standard (2 Nachkommastellen)",
"roomStamp.occupancy": "Personenzahl",
"text.selectedHint": "Formatierung wirkt auf den ausgewählten Text",
// ── Rich-Text-Editor ────────────────────────────────────────────────────────
@@ -955,6 +1453,8 @@ export const de = {
"rt.underline": "Unterstrichen",
"rt.strike": "Durchgestrichen",
"rt.strikethrough": "Durchgestrichen",
"rt.super": "Hochgestellt",
"rt.sub": "Tiefgestellt",
"rt.fontSize": "Schriftgrösse",
"rt.color": "Farbe",
"rt.preset": "Vorlage",
@@ -966,6 +1466,153 @@ export const de = {
"WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.",
"viewport3d.grid.show": "Bodenraster einblenden",
"viewport3d.grid.hide": "Bodenraster ausblenden",
"viewport3d.section.label": "Schnittebene",
"viewport3d.section.show": "Schnittebene ein",
"viewport3d.section.hide": "Schnittebene aus",
"viewport3d.section.bound": "3D-Schnitt aufheben (an Schnittlinie gebunden)",
"viewport3d.grip.vertex": "Wandende ziehen",
"viewport3d.grip.height": "Höhe ziehen",
"viewport3d.grip.move": "Verschieben",
"viewport3d.grip.edge": "Kante ziehen",
"viewport3d.grip.roofpitch": "Dachneigung ziehen",
// ── Element-Baum (Elemente-Panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elemente",
"elements.search": "Elemente suchen…",
"elements.empty": "Keine Bauteile im Projekt.",
"elements.noMatch": "Keine Treffer.",
"elements.kind.wall": "Wand",
"elements.kind.ceiling": "Decke",
"elements.kind.window": "Fenster",
"elements.kind.opening": "Öffnung",
"elements.kind.stair": "Treppe",
"elements.kind.extrusion": "Extrusion",
"elements.kind.roof": "Dach",
"elements.kind.column": "Stütze",
// Ausschnitte / View-Snapshots (DOSSIER A2).
"viewsnap.title": "Ausschnitte",
"viewsnap.saveCurrent": " Aktuellen Ausschnitt speichern …",
"viewsnap.savePrompt": "Ausschnitt speichern als:",
"viewsnap.renamePrompt": "Ausschnitt umbenennen:",
"viewsnap.rename": "Umbenennen",
"viewsnap.delete": "Löschen",
"viewsnap.deleteConfirm": "Ausschnitt „{name}\" löschen?",
"viewsnap.empty": "Noch keine Ausschnitte. Ansicht einrichten und speichern.",
"viewsnap.applyHint": "Ausschnitt „{name}\" wiederherstellen",
"viewsnap.add.folder": "Ordner erstellen",
"viewsnap.add.snapshot": "Ausschnitt speichern",
"viewsnap.folder.defaultName": "Neuer Ordner",
"viewsnap.folder.rename": "Ordner umbenennen",
"viewsnap.folder.delete": "Ordner löschen",
"viewsnap.folder.empty": "Leerer Ordner",
"viewsnap.tree.empty": "Noch keine Ordner oder Ausschnitte. Über „+\" anlegen.",
"viewsnap.footer.scale": "Massstab",
"viewsnap.footer.layerCombo": "Ebenenkombi",
"viewsnap.footer.drawingCombo": "Zeichnungskombi",
"viewsnap.footer.overrides": "Overrides",
"viewsnap.footer.none": "—",
// Layout-Blätter mit Masterlayout (DOSSIER A3).
"layouts.panelTitle": "Mappe",
"layouts.title": "Layouts",
"layouts.new": "Anlegen",
"layouts.namePlaceholder": "Blattname …",
"layouts.defaultName": "Neues Layout",
"layouts.rename": "Umbenennen",
"layouts.delete": "Löschen",
"layouts.renamePrompt": "Umbenennen:",
"layouts.deleteConfirm": "„{name}\" löschen?",
"layouts.openHint": "Layout „{name}\" öffnen",
"layouts.empty": "Noch keine Layouts. Blatt anlegen und öffnen.",
"layouts.paper": "Papierformat",
"layouts.orientation": "Ausrichtung",
"layouts.portrait": "Hoch",
"layouts.landscape": "Quer",
"layouts.border": "Rahmen",
"layouts.masters": "Masterlayouts",
"layouts.newMaster": " Master",
"layouts.newMasterPrompt": "Neues Masterlayout:",
"layouts.defaultMasterName": "Master",
"layouts.noMasters": "Noch keine Masterlayouts.",
"layouts.master": "Master",
"layouts.noMaster": "Kein Master",
"layouts.tb.projectName": "Projekt",
"layouts.tb.sheetName": "Blattname",
"layouts.tb.scale": "Massstab",
"layouts.tb.date": "Datum",
"layouts.tb.author": "Bearbeiter",
"layouts.tb.autoPlaceholder": "(automatisch)",
// Editor
"layouts.editorTitle": "Layout-Editor",
"layouts.fit": "Einpassen",
"layouts.addViewport": "Viewport hinzufügen",
"layouts.pickSnapshot": "Ausschnitt wählen …",
"layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.",
"layouts.viewportProps": "Viewport-Eigenschaften",
"layouts.noViewports": "Noch keine Viewports. Ausschnitt wählen und hinzufügen.",
"layouts.selectViewport": "Viewport auf dem Blatt anklicken.",
"layouts.missingSnapshot": "Ausschnitt fehlt",
"layouts.xMm": "X (mm)",
"layouts.yMm": "Y (mm)",
"layouts.widthMm": "Breite (mm)",
"layouts.heightMm": "Höhe (mm)",
"layouts.scale": "Massstab",
"layouts.scaleReset": "Massstab vom Ausschnitt",
"layouts.removeViewport": "Viewport entfernen",
"layouts.backToModel": "Zurück zum Modell",
"layouts.placeViewport": "Viewport aufziehen",
"layouts.placeHint": "Rechteck auf dem Blatt aufziehen, dann Ausschnitt binden.",
"layouts.bindSnapshot": "Ausschnitt binden",
"layouts.emptyViewports":
"Noch keine Viewports. „Viewport aufziehen\" wählen und ein Rechteck auf dem Blatt ziehen.",
// Annotationen (Linie/Rechteck/Text) direkt auf dem Blatt
"layouts.annotLine": "Linie zeichnen",
"layouts.annotRect": "Rechteck zeichnen",
"layouts.annotText": "Text setzen",
"layouts.annotTextTitle": "Text",
"layouts.annotTextLabel": "Textinhalt:",
"layouts.annotEditText": "Text ändern",
"layouts.annotColor": "Farbe",
"layouts.annotWeight": "Strichstärke (mm)",
"layouts.annotRemove": "Annotation entfernen",
"layouts.annot.line": "Linie",
"layouts.annot.rect": "Rechteck",
"layouts.annot.text": "Text",
// Baum / „+"-Menü / Ordner
"layouts.add": "Neu …",
"layouts.addFolder": "Ordner erstellen",
"layouts.addLayout": "Layout erstellen",
"layouts.addMaster": "Masterlayout erstellen",
"layouts.add.folder": "Ordner erstellen",
"layouts.add.layout": "Layout erstellen",
"layouts.add.master": "Masterlayout erstellen",
"layouts.folder.defaultName": "Neuer Ordner",
"layouts.folder.rename": "Ordner umbenennen",
"layouts.folder.delete": "Ordner löschen",
"layouts.folder.export": "Ordner als PDF exportieren",
"layouts.folder.empty": "Leerer Ordner",
"layouts.folder.exportEmpty": "Ordner enthält keine Layouts.",
"layouts.tree.empty": "Noch keine Ordner oder Layouts. Über „+\" anlegen.",
// Dialog „Neues Layout"
"layouts.newLayout.title": "Neues Layout",
"layouts.newLayout.source": "Vorlage",
"layouts.newLayout.fromMaster": "Masterlayout als Vorlage",
"layouts.newLayout.freeSize": "Freie Grösse",
"layouts.newLayout.pickMaster": "Masterlayout wählen …",
"layouts.newLayout.noMasters": "Keine Masterlayouts vorhanden.",
// Dialog „Neues Masterlayout"
"layouts.newMaster.title": "Neues Masterlayout",
// Gemeinsame Grössen-Auswahl (beide Dialoge)
"layouts.size.format": "Format",
"layouts.size.custom": "Freie Grösse",
"layouts.size.a4": "A4",
"layouts.size.a3": "A3",
"layouts.paperGroup.a": "A-Reihe",
"layouts.paperGroup.b": "B-Reihe",
"layouts.size.widthMm": "Breite (mm)",
"layouts.size.heightMm": "Höhe (mm)",
"layouts.size.create": "Erstellen",
} as const;
export type TranslationKey = keyof typeof de;
+651 -6
View File
@@ -16,14 +16,35 @@ export const en: Record<TranslationKey, string> = {
"view3d.group": "3D view",
"view3d.front": "Front",
"view3d.front.hint": "Front view (frontal)",
"view3d.front.abbr": "F",
"view3d.back": "Back",
"view3d.back.hint": "Back view (rear)",
"view3d.back.abbr": "B",
"view3d.top": "Top",
"view3d.top.hint": "Top view (straight down)",
"view3d.side": "Side",
"view3d.side.hint": "Side view (90° to front)",
"view3d.top.abbr": "T",
"view3d.side": "Right",
"view3d.side.hint": "Right side view (90° to the front)",
"view3d.side.abbr": "R",
"view3d.left": "Left",
"view3d.left.hint": "Left side view (90° to the front, opposite Right)",
"view3d.left.abbr": "L",
"view3d.perspective": "Perspective",
"view3d.perspective.hint": "Free perspective (3/4 view)",
"topbar.views": "Views",
"topbar.views.menu": "Pick a view (Front/Back/Left/Right/Top)",
"topbar.axo": "Axonometric",
"topbar.axo.hint": "3D axonometric (free 3/4 view)",
"view3d.iso": "Isometric",
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
"view3d.isoFrontLeft": "Front Left",
"view3d.isoFrontLeft.hint": "Isometric view (equiangular from front-top-left)",
"view3d.isoBackRight": "Back Right",
"view3d.isoBackRight.hint": "Isometric view (equiangular from back-top-right)",
"view3d.isoBackLeft": "Back Left",
"view3d.isoBackLeft.hint": "Isometric view (equiangular from back-top-left)",
"view3d.iso.menu": "More isometric views",
"view3d.iso.menu.hint": "Pick one of the four upper isometric octants",
"view3d.camera": "Camera",
"view3d.camera.hint": "Camera settings (field of view)",
"view3d.fov": "Field of view",
@@ -71,7 +92,9 @@ export const en: Record<TranslationKey, string> = {
"display.style.color": "Color",
"display.style.mono": "Black & white",
"display.style.shaded": "Shaded",
"display.style.shadedEdges": "Shaded with edges",
"display.style.white": "White",
"display.style.contour": "Contours only",
"display.style.textured": "Textured",
"display.style.wireframe": "Wireframe",
"display.style.hidden": "Hidden line",
@@ -84,13 +107,47 @@ export const en: Record<TranslationKey, string> = {
"topbar.resources": "Resources",
"topbar.resources.hint": "Show/hide the resources window",
// Menu bar (Topbar, AppMenuBar).
"menu.edit": "Edit",
"menu.edit.undo": "Undo",
"menu.edit.redo": "Redo",
"menu.edit.delete": "Delete",
"menu.view": "View",
"menu.view.floorplan": "Floor plan",
"menu.view.perspective": "Perspective",
"menu.view.referenceLines": "Reference lines",
"menu.window": "Window",
"menu.window.resources": "Resources",
"menu.window.settings": "Settings",
"menu.window.drawingLevels": "Drawing Levels (Window)",
"menu.window.layerSettings": "Layer Settings (active layer)",
"menu.window.contextImport": "Import Location",
// Native macOS system menu bar (appMenu.ts) — separate namespace since it
// also shows File/Export/Transform, unlike the in-app AppMenuBar.
"menu.native.file": "File",
"menu.native.open": "Open…",
"menu.native.saveAs": "Save As…",
"menu.native.import": "Import…",
"menu.native.export": "Export",
"menu.native.exportSchedule": "Schedule (CSV)",
"menu.native.edit": "Edit",
"menu.native.move": "Move",
"menu.native.mirror": "Mirror",
"menu.native.rotate": "Rotate",
"menu.native.view": "View",
"menu.native.window": "Window",
"menu.native.panels": "Panels",
"menu.native.about": "About Dossier",
"menu.native.quit": "Quit",
// Layout menu.
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.title": "Workspace",
"layout.placeholder": "Workspace …",
"layout.saveCurrent": " Save current …",
"layout.loadGroup": "Load",
"layout.deleteGroup": "Delete",
"layout.savePrompt": "Save layout as:",
"layout.savePrompt": "Save workspace as:",
// Visibility combinations (topbar).
"combo.layers": "Layer comb.",
@@ -119,6 +176,10 @@ export const en: Record<TranslationKey, string> = {
"tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
"tool.text": "Text",
"tool.text.hint": "Text: set anchor point, then type the text",
"tool.textbox": "Text column",
"tool.textbox.hint": "Text column: anchor, drag column width, then type the text",
// Ribbon top bar (tabs + groups)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
@@ -128,7 +189,14 @@ export const en: Record<TranslationKey, string> = {
"ribbon.group.draw": "Draw",
"ribbon.group.modify": "Modify",
"ribbon.group.components": "Components",
"ribbon.group.sections": "Sections",
"ribbon.group.custom": "Custom bar",
"tools.view.label": "Display",
"tools.view.list": "List",
"tools.view.icons": "Icons",
"tools.cat.2d": "2D",
"tools.cat.3d": "3D",
"tools.cat.edit": "Modify",
"ribbon.custom.add": " Add",
"ribbon.custom.empty": "Pick elements via “+ Add”",
"ribbon.empty": "No tools in this tab yet",
@@ -164,6 +232,8 @@ export const en: Record<TranslationKey, string> = {
"snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Grid size (m)",
"snap.angleStep": "Angle step",
"snap.commonAngles": "Common angles",
"snap.commonAngleTolerance": "Angle tolerance (°)",
// Transform (move/mirror/rotate) + mode bar.
"transform.bar": "Transform",
@@ -233,6 +303,7 @@ export const en: Record<TranslationKey, string> = {
"attr.fill": "Fill",
"attr.fillColor": "Fill color",
"attr.clearFill": "Remove fill color",
"attr.pickColor": "Choose color",
"attr.foreground": "Foreground",
"attr.background": "Background",
"attr.bySystem": "By system",
@@ -248,6 +319,8 @@ export const en: Record<TranslationKey, string> = {
// Objekt-Info-Palette (ObjectInfoPanel)
"objinfo.title": "Object Info",
"objinfo.empty": "No object selected",
"objinfo.category": "Class",
"objinfo.level": "Layer",
"objinfo.refpoint": "Reference point",
"objinfo.setRefpoint": "Set reference point",
"objinfo.x": "X",
@@ -265,12 +338,70 @@ export const en: Record<TranslationKey, string> = {
"objinfo.kind.opening": "Opening",
"objinfo.kind.stair": "Stair",
"objinfo.kind.room": "Room",
"objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Column",
"objinfo.kind.roof": "Roof",
"objinfo.kind.contextObject": "Context object",
// ── Context object attributes (object info panel) ───────────────────────
"objinfo.contextObject.section": "Georeferencing",
"objinfo.contextObject.type": "Type",
"objinfo.contextObject.type.importedMesh": "Imported mesh",
"objinfo.contextObject.type.terrainMesh": "Terrain",
"objinfo.contextObject.type.contourSet": "Contours",
"objinfo.contextObject.lv95E": "LV95 East (E)",
"objinfo.contextObject.lv95N": "LV95 North (N)",
"objinfo.contextObject.noAnchor": "No location reference in project",
// ── Roof attributes (object info panel) ─────────────────────────────────
"objinfo.roof.section": "Roof",
"objinfo.roof.shape": "Roof shape",
"objinfo.roof.shape.flach": "Flat roof",
"objinfo.roof.shape.pult": "Mono-pitch roof",
"objinfo.roof.shape.sattel": "Gable roof",
"objinfo.roof.shape.walm": "Hip roof",
"objinfo.roof.shape.mansarde": "Mansard roof",
"objinfo.roof.shape.zelt": "Pyramid roof",
"objinfo.roof.ridgeAxis": "Ridge direction",
"objinfo.roof.ridgeAxis.x": "along X",
"objinfo.roof.ridgeAxis.y": "along Y",
"objinfo.roof.pitch": "Pitch (°)",
"objinfo.roof.pitchUpper": "Upper pitch (°)",
"objinfo.roof.type": "Roof type",
"objinfo.roof.type.single": "Single layer (thickness)",
"objinfo.roof.mansardType": "Mansard type",
"objinfo.roof.mansard.giebel": "Gable mansard",
"objinfo.roof.mansard.walm": "Hip mansard",
"objinfo.roof.mansard.zelt": "Pavilion mansard",
"objinfo.roof.mansardKnee": "Knee position (% span)",
"objinfo.roof.width": "Width (m)",
"objinfo.roof.depth": "Depth (m)",
"objinfo.roof.overhang": "Eaves overhang (m)",
"objinfo.roof.overhangGable": "Rake overhang (m)",
"objinfo.roof.thickness": "Roof thickness (m)",
"objinfo.roof.baseElevation": "Eaves height (m)",
"objinfo.roof.ridgeHeight": "Ridge height",
"objinfo.roof.area": "Footprint area",
// ── Column attributes (object info panel) ────────────────────────────────
"objinfo.column.section": "Column",
"objinfo.column.profile": "Profile",
"objinfo.column.profile.rect": "Rectangle",
"objinfo.column.profile.round": "Round",
"objinfo.column.width": "Width",
"objinfo.column.depth": "Depth",
"objinfo.column.radius": "Radius",
"objinfo.column.height": "Height",
"objinfo.column.rotation": "Rotation (°)",
"objinfo.column.floor": "Floor",
// ── Wall attributes (object info panel) ──────────────────────────────────
"objinfo.wall.section": "Wall",
"objinfo.wall.refLine": "Reference line",
"objinfo.wall.refLine.left": "Exterior (left)",
"objinfo.wall.refLine.center": "Center",
"objinfo.wall.refLine.right": "Interior (right)",
"objinfo.wall.refLine.joint": "Joint",
"objinfo.wall.sliceTermination": "End at ceiling",
"objinfo.wall.sliceTermination.both": "Off",
"objinfo.wall.sliceTermination.below": "Below",
"objinfo.wall.sliceTermination.above": "Above",
"objinfo.wall.buildup": "Build-up",
"objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Multi layer",
@@ -283,6 +414,11 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.top": "Top (OK)",
"objinfo.wall.anchorFloor": "Bound",
"objinfo.wall.anchorCustom": "Custom",
"objinfo.anchor.refFloor": "Reference storey",
"objinfo.anchor.customHeight": "Custom height",
"objinfo.wall.length": "Length",
"objinfo.wall.volumeGross": "Gross",
"objinfo.volume": "Volume",
"objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above",
// ── Ceiling attributes (object info panel) ───────────────────────────────
@@ -294,11 +430,88 @@ export const en: Record<TranslationKey, string> = {
"objinfo.ceiling.top": "Top (OK)",
"objinfo.ceiling.bottom": "Bottom (UK)",
"objinfo.ceiling.height": "Thickness (OKUK)",
"objinfo.ceiling.openings": "Openings",
"objinfo.ceiling.removeOpening": "Remove opening",
"objinfo.ceiling.area": "Area",
"objinfo.ceiling.anchorFloor": "Bound",
"objinfo.ceiling.anchorCustom": "Custom",
// ── Opening attributes (object info panel) ──────────────────────────────
"objinfo.opening.section": "Opening",
"objinfo.opening.type": "Type",
"objinfo.opening.editType": "Settings …",
// Window/door settings dialog (OpeningEditorDialog).
"oed.title.window": "Window settings",
"oed.title.door": "Door settings",
"oed.style": "Style",
"oed.saveStyle": "Save as style …",
"oed.saveStyle.hint": "Store the current settings as a new, reusable style",
"oed.saveStyle.prompt": "Name of the new style:",
"oed.saveStyle.copySuffix": "(copy)",
"oed.categories": "Categories",
"oed.noStyle": "No style assigned — choose or save one via the style bar.",
"oed.preview": "View",
"oed.cat.basis": "Basics",
"oed.cat.groesse": "Size/position",
"oed.cat.rahmen": "Frame",
"oed.cat.fluegel": "Sashes/muntins",
"oed.cat.sonnenschutz": "Shading",
"oed.cat.tuerblatt": "Leaf",
"oed.f.kind": "Opening type",
"oed.f.glazing": "Glazing",
"oed.f.insetFace": "Position from",
"oed.f.insetFromFace": "Distance from edge (m)",
"oed.f.width": "Width (m)",
"oed.f.height": "Height (m)",
"oed.f.sillHeight": "Sill height (m)",
"oed.f.position": "Position (m)",
"oed.f.frameThickness": "Frame depth across (m)",
"oed.f.frameDepth": "Frame depth (m)",
"oed.f.frameWidth": "Frame face width (m)",
"oed.f.transomHeight": "Transom height (m)",
"oed.f.mullionRows": "Transom/muntin rows",
"oed.f.mullionCols": "Muntin columns",
"oed.f.sillLineFaces": "Top-/bottom-view line",
"oed.f.sillLineView": "View direction",
"oed.sillLine.off": "None",
"oed.sillLine.beide": "Both",
"oed.sillLine.auf": "Top view (from above)",
"oed.sillLine.unter": "Bottom view (from below)",
"oed.kind.dreh": "Casement",
"oed.kind.kipp": "Tilt",
"oed.kind.drehkipp": "Turn-tilt",
"oed.kind.fest": "Fixed",
"oed.kind.schiebe": "Sliding",
"oed.glazing.1": "Single",
"oed.glazing.2": "Double",
"oed.glazing.3": "Triple",
"oed.insetFace.aussen": "Outer face",
"oed.insetFace.innen": "Inner face",
"oed.fluegel.hint": "Sashes and posts from left to right. Opening type and hinge per sash.",
"oed.fluegel.col.kind": "Type",
"oed.fluegel.col.opening": "Opening",
"oed.fluegel.col.hinge": "Hinge",
"oed.fluegel.type.fluegel": "Sash",
"oed.fluegel.type.pfosten": "Post",
"oed.fluegel.remove": "Remove",
"oed.hinge.left": "Left",
"oed.hinge.right": "Right",
"oed.shading.create": "Roller shutter / shading box",
"oed.shading.kind": "Kind",
"oed.shading.rollladen": "Roller shutter",
"oed.shading.raffstore": "Venetian blind",
"oed.shading.markise": "Awning",
"oed.shading.boxHeight": "Box height (m)",
"oed.shading.boxDepth": "Box depth (m)",
"oed.door.leafCount": "Door leaves",
"oed.door.leaf1": "Single leaf",
"oed.door.leaf2": "Double leaf",
"oed.door.leafStyle": "Leaf style",
"oed.door.glatt": "Flush",
"oed.door.kassette": "Panelled",
"oed.door.glas": "Glass",
"oed.door.wandoeffnung": "Wall opening",
"objinfo.stair.type": "Type",
"objinfo.type.none": "(no type)",
"objinfo.opening.kind": "Type",
"objinfo.opening.kind.window": "Window",
"objinfo.opening.kind.door": "Door",
@@ -328,6 +541,11 @@ export const en: Record<TranslationKey, string> = {
"objinfo.opening.lintelLines.innen": "Inside",
"objinfo.opening.lintelLines.aussen": "Outside",
"objinfo.opening.lintelLines.beide": "Both",
"objinfo.opening.frameLine": "Frame (cut)",
"objinfo.opening.frameViewLine": "Frame (view)",
"objinfo.opening.sashLine": "Window (sash/muntins)",
"objinfo.opening.sillLine": "Sill",
"objinfo.lineWeight": "Line weight (mm)",
"objinfo.stair.section": "Stair",
"objinfo.stair.shape": "Shape",
"objinfo.stair.shape.straight": "Straight",
@@ -341,6 +559,9 @@ export const en: Record<TranslationKey, string> = {
"objinfo.stair.direction": "Run direction",
"objinfo.stair.direction.up": "Upwards",
"objinfo.stair.direction.down": "Downwards",
"objinfo.stair.arrow": "Arrow",
"objinfo.stair.arrow.line": "Open",
"objinfo.stair.arrow.filled": "Filled",
"objinfo.stair.refFloor": "Reference floor",
"objinfo.stair.bottom": "Bottom (UK)",
"objinfo.stair.top": "Top (OK)",
@@ -356,6 +577,29 @@ export const en: Record<TranslationKey, string> = {
"objinfo.room.perimeter": "Perimeter",
"objinfo.room.refFloor": "Reference floor",
"objinfo.room.editStamp": "Edit stamp …",
// ── Extruded solid attributes (truck integration, object info panel) ────
"objinfo.sectionLine.section": "Section line",
"objinfo.sectionLine.kind.section": "Section",
"objinfo.sectionLine.kind.elevation": "Elevation",
"objinfo.sectionLine.name": "Name",
"objinfo.sectionLine.flip": "Flip view direction",
"objinfo.sectionLine.cut3d": "Cut in 3D",
"objinfo.sectionLine.startX": "Start X",
"objinfo.sectionLine.startY": "Start Y",
"objinfo.sectionLine.endX": "End X",
"objinfo.sectionLine.endY": "End Y",
"objinfo.sectionLine.depth": "Section depth (m)",
"objinfo.sectionLine.hiddenLines": "Hidden lines",
"objinfo.sectionLine.depthHint": "empty = unlimited",
"objinfo.sectionLine.delete": "Delete section line",
"objinfo.extrudedSolid.section": "Extrusion",
"objinfo.extrudedSolid.height": "Height",
"objinfo.extrudedSolid.taper": "Taper",
"objinfo.extrudedSolid.area": "Footprint area",
"objinfo.extrudedSolid.floor": "Floor",
"objinfo.rotate": "Rotate",
"objinfo.drawing.length": "Length",
"objinfo.drawing.radius": "Radius",
"levels.addFloor": "+ Floor",
"levels.addSection": "+ Section",
"levels.addElevation": "+ Elevation",
@@ -373,6 +617,8 @@ export const en: Record<TranslationKey, string> = {
"nav.show": "Show",
"nav.categoryVisible": "{name} visible",
"nav.categorySelect": "Make {name} the active layer (drawing target)",
"nav.categorySettings": "Layer settings for {name}",
"nav.drawingLevelsWindow": "Open drawing levels as its own window",
// Badges.
"badge.floor": "Floor",
@@ -387,6 +633,7 @@ export const en: Record<TranslationKey, string> = {
"display.active": "Active only",
"display.grey": "Others grey",
"display.grey_locked": "Others grey & locked",
"display.locked": "Others locked",
// Dock.
"dock.resize": "Adjust width",
@@ -456,7 +703,9 @@ export const en: Record<TranslationKey, string> = {
"section.loading": "Computing {kind} …",
"section.empty": "{kind} — no section line set in the plan",
"section.error": "{kind} — computation failed (rebuild render3d WASM?)",
"section.legend": "{kind} · derived projection (render3d)",
"section.legend": "{kind} · projection derived from the model",
"elevation.shadows": "Shadows",
"elevation.shadows.hint": "Toggle 45° cast shadows of overhanging elements in the elevation",
"props.floorHeight": "Floor height",
"props.cutHeight": "Cut height",
"props.okff": "FFL",
@@ -484,6 +733,10 @@ export const en: Record<TranslationKey, string> = {
"default.ceilingTypeSingle": "Ceiling {thickness} m",
"default.wallTypeName": "New wall type",
"default.ceilingTypeName": "New ceiling type",
"default.roofTypeName": "New roof type",
"default.doorTypeName": "New door type",
"default.windowTypeName": "New window type",
"default.stairTypeName": "New stair type",
// Alerts.
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
@@ -492,17 +745,54 @@ export const en: Record<TranslationKey, string> = {
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
"alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.",
"alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.",
"alert.roofTypeInUse": "Roof type “{name}” is used by a roof and cannot be deleted.",
"alert.doorTypeInUse": "Door type “{name}” is used by a door and cannot be deleted.",
"alert.windowTypeInUse": "Window type “{name}” is used by a window and cannot be deleted.",
"alert.stairTypeInUse": "Stair type “{name}” is used by a stair and cannot be deleted.",
// Resource manager.
"resources.title": "Resources",
"resources.label": "Resource manager",
"resources.close": "Close",
"resources.loading": "Connecting to the main window…",
"resources.tab.components": "Components",
"resources.tab.hatches": "Hatches",
"resources.tab.lines": "Lines",
"resources.tab.wallStyles": "Wall styles",
"resources.tab.ceilingStyles": "Ceiling styles",
"resources.tab.roofStyles": "Roof types",
"resources.tab.doorStyles": "Door types",
"resources.tab.windowStyles": "Window types",
"resources.tab.stairStyles": "Stair types",
"resources.tab.materials": "Materials",
"resources.tab.overrides": "Overrides",
// Graphic overrides (rule engine).
"resources.overrides.hint":
"Rules override color/lineweight/line style at render time (element data stays untouched). Order = priority: per field, the topmost active rule wins.",
"resources.overrides.empty": "No override rules yet.",
"resources.overrides.placeholder": "Rule",
"resources.overrides.defaultName": "New rule",
"resources.overrides.delete": "Delete rule",
"resources.overrides.moveUp": "Move rule up (higher priority)",
"resources.overrides.moveDown": "Move rule down (lower priority)",
"resources.overrides.enabled": "Rule active",
"resources.overrides.condition": "Condition",
"resources.overrides.condType": "Criterion",
"resources.overrides.condType.layer": "Layer (name/code)",
"resources.overrides.condType.object": "Object name",
"resources.overrides.operator": "Operator",
"resources.overrides.op.equals": "equals",
"resources.overrides.op.contains": "contains",
"resources.overrides.op.startsWith": "starts with",
"resources.overrides.op.notEquals": "not equal to",
"resources.overrides.value": "Value",
"resources.overrides.value.placeholder": "e.g. Walls or 20",
"resources.overrides.actions": "Actions",
"resources.overrides.action.color": "Color",
"resources.overrides.action.lineweight": "Lineweight (mm)",
"resources.overrides.action.linetype": "Line style",
"resources.overrides.linetype.none": "— (unchanged)",
"resources.wallStyles.hint":
"Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.",
@@ -512,6 +802,91 @@ export const en: Record<TranslationKey, string> = {
"resources.ceilingStyles.empty": "No ceiling types yet.",
"resources.wallStyles.placeholder": "Wall type",
"resources.ceilingStyles.placeholder": "Ceiling type",
"resources.roofStyles.hint":
"Per roof type (layer build-up outside/covering → inside/lining) the layer joints as a selectable line style. Without assignment: hairline 0.02 mm.",
"resources.roofStyles.empty": "No roof types yet.",
"resources.roofStyles.placeholder": "Roof type",
// Component types (door/window/stair) — form-based libraries.
"resources.doorTypes.hint":
"Reusable door types with construction, leaf style and default dimensions for newly placed doors.",
"resources.doorTypes.empty": "No door types yet.",
"resources.doorTypes.placeholder": "Door type",
"resources.windowTypes.hint":
"Reusable window types with opening style, glazing and default dimensions for newly placed windows.",
"resources.windowTypes.empty": "No window types yet.",
"resources.windowTypes.placeholder": "Window type",
"resources.stairTypes.hint":
"Reusable stair types with structure, tread style and railing for newly placed stairs.",
"resources.stairTypes.empty": "No stair types yet.",
"resources.stairTypes.placeholder": "Stair type",
"resources.delete.doorType": "Delete door type “{name}”",
"resources.delete.windowType": "Delete window type “{name}”",
"resources.delete.stairType": "Delete stair type “{name}”",
"resources.field.doorKind": "Construction",
"resources.field.leafCount": "Leaf count",
"resources.field.leafStyle": "Leaf style",
"resources.field.glazingRatio": "Glazing ratio",
"resources.field.frameThickness": "Frame thickness (m)",
"resources.field.frameDepth": "Frame depth (m)",
"resources.field.defaultWidth": "Clear width (m)",
"resources.field.defaultHeight": "Clear height (m)",
"resources.field.threshold": "Threshold",
"resources.field.frameKind": "Frame type",
"resources.field.frameWidth": "Frame width (m)",
"resources.field.insetFromFace": "Layer inset (m)",
"resources.field.insetFace": "Inset from face",
"resources.field.innerLayerRecess": "Reveal recess (innermost layer)",
"resources.field.innerLayerRecess.hint": "The innermost wall layer (usually plasterboard/wood lining) steps back all around this window's reveal by its own thickness, instead of finishing flush with the other layers — changes the 3D wall geometry, not just the frame position.",
"resources.field.transomHeight": "Transom height (m)",
"resources.field.mullionRows": "Transom rows",
"resources.frameKind.zarge": "Lining",
"resources.frameKind.blockrahmen": "Block frame",
"resources.insetFace.aussen": "Outer",
"resources.insetFace.innen": "Inner",
"resources.field.windowKind": "Opening style",
"resources.field.wingCount": "Wing count",
"resources.field.glazing": "Glazing",
"resources.field.defaultSillHeight": "Sill height (m)",
"resources.field.sillBoard": "Sill board",
"resources.field.structure": "Structure",
"resources.field.closedRisers": "Closed risers",
"resources.field.treadThickness": "Tread thickness (m)",
"resources.field.nosing": "Nosing (m)",
"resources.field.railing": "Railing",
"resources.field.stairWidth": "Run width (m)",
"resources.doorKind.dreh": "Hinged",
"resources.doorKind.schiebe": "Sliding",
"resources.doorKind.wandoeffnung": "Wall opening",
"resources.leafCount.1": "Single-leaf",
"resources.leafCount.2": "Double-leaf",
"resources.leafStyle.glatt": "Flush",
"resources.leafStyle.kassette": "Panelled",
"resources.leafStyle.glas": "Glazed",
"resources.windowKind.dreh": "Turn",
"resources.windowKind.kipp": "Tilt",
"resources.windowKind.drehkipp": "Turn-tilt",
"resources.windowKind.fest": "Fixed",
"resources.windowKind.schiebe": "Sliding",
"resources.glazing.einfach": "Single",
"resources.glazing.zweifach": "Double",
"resources.glazing.dreifach": "Triple",
"resources.sillBoard.keine": "None",
"resources.sillBoard.innen": "Inside",
"resources.sillBoard.aussen": "Outside",
"resources.sillBoard.beide": "Both",
"resources.structure.massiv": "Solid",
"resources.structure.beton": "Concrete (waist slab)",
"resources.structure.wange": "String",
"resources.structure.aufgesattelt": "Saddled",
"resources.structure.spindel": "Spiral",
"resources.railing.keine": "None",
"resources.railing.links": "Left",
"resources.railing.rechts": "Right",
"resources.railing.beide": "Both",
"resources.col.layer": "Layer",
"resources.col.component": "Component",
"resources.col.thickness": "Thickness (mm)",
@@ -539,6 +914,9 @@ export const en: Record<TranslationKey, string> = {
"resources.components.placeholder": "Component",
"resources.col.name": "Name",
"resources.col.color": "Color",
"resources.col.abbrev": "Code",
"resources.col.abbrev.hint": "Short code for wall-type dropdowns (e.g. «CON», «BRK»)",
"resources.col.abbrev.placeholder": "e.g. CON",
"resources.col.hatch": "Section hatch",
"resources.col.viewHatch": "View hatch",
"resources.viewHatch.none": "(none)",
@@ -603,6 +981,7 @@ export const en: Record<TranslationKey, string> = {
"resources.delete.hatch": "Delete hatch “{name}”",
"resources.delete.wallType": "Delete wall type “{name}”",
"resources.delete.ceilingType": "Delete ceiling type “{name}”",
"resources.delete.roofType": "Delete roof type “{name}”",
// Hatch/line type (master-detail editor).
"resources.field.type": "Type",
@@ -674,6 +1053,25 @@ export const en: Record<TranslationKey, string> = {
"cmd.ceiling.label": "Ceiling",
"cmd.ceiling.start": "First outline point of ceiling:",
"cmd.ceiling.next": "Next point ( Close ):",
"cmd.roof.label": "Roof",
"cmd.roof.start": "First roof corner:",
"cmd.roof.corner": "Opposite corner:",
"cmd.ceilingOpening.label": "Slab opening",
"cmd.ceilingOpening.start": "First corner of the opening (inside a slab):",
"cmd.ceilingOpening.corner": "Opposite corner:",
"cmd.roof.shape.sattel": "Gable roof",
"cmd.roof.shape.walm": "Hip roof",
"cmd.roof.shape.pult": "Mono-pitch roof",
"cmd.roof.shape.mansarde": "Mansard roof",
"cmd.roof.shape.zelt": "Pyramid roof",
"cmd.roof.shape.flach": "Flat roof",
"cmd.sectionline.label": "Section line",
"cmd.sectionline.start": "Section start point:",
"cmd.sectionline.end": "Section end point:",
"cmd.sectionline.kind.section": "Section",
"cmd.sectionline.kind.elevation": "Elevation",
"cmd.sectionline.newSection": "Section {letter}",
"cmd.sectionline.newElevation": "Elevation {letter}",
"cmd.opening.label": "Opening",
"cmd.opening.windowLabel": "Window",
"cmd.opening.doorLabel": "Door",
@@ -685,6 +1083,9 @@ export const en: Record<TranslationKey, string> = {
"cmd.opening.dir": "Direction",
"cmd.opening.sillField": "Sill",
"cmd.opening.swingField": "Angle",
"cmd.column.label": "Column",
"cmd.column.place": "Place column:",
"cmd.column.profile": "Profile",
"cmd.stair.label": "Stair",
"cmd.stair.start": "Start point of the stair:",
"cmd.stair.run": "Run direction / end ( Shape · Up/Down ):",
@@ -719,10 +1120,29 @@ export const en: Record<TranslationKey, string> = {
"cmd.arc.center": "Center:",
"cmd.arc.start": "Start point (radius + start angle):",
"cmd.arc.end": "End point (end angle):",
"cmd.text.label": "Text",
"cmd.text.point": "Anchor point:",
"cmd.text.enter": "Enter text:",
"cmd.textbox.label": "Text column",
"cmd.textbox.point": "Anchor point:",
"cmd.textbox.width": "Column width (second point):",
"cmd.textbox.enter": "Enter text:",
"cmd.move.label": "Move",
"cmd.move.base": "Base point:",
"cmd.move.target": "Target point:",
"cmd.move.empty": "Nothing selected (select objects first).",
"cmd.rotate.label": "Rotate",
"cmd.measure.label": "Measure",
"cmd.measure.start": "Measure from:",
"cmd.measure.to": "to (distance · angle):",
"cmd.measure.next": "next point (right-click = new path):",
"objinfo.measure.title": "Measurement",
"objinfo.measure.segment": "Segment",
"objinfo.measure.total": "Total length",
"objinfo.measure.area": "Area",
"objinfo.measure.angle": "Angle",
"objinfo.measure.hint": "Click = point · right-click = new path · Esc = end",
"transform.needSelection": "Select objects first, then rotate.",
"cmd.mirror.label": "Mirror",
"cmd.mirror.first": "First point of mirror axis:",
"cmd.mirror.second": "Second point of mirror axis:",
@@ -735,6 +1155,10 @@ export const en: Record<TranslationKey, string> = {
"cmd.offset.label": "Offset",
"cmd.offset.pick": "Select curve:",
"cmd.offset.side": "Side or distance:",
"cmd.extrude.label": "Extrude",
"cmd.extrude.pick": "Select closed profile:",
"cmd.extrude.height": "Height:",
"cmd.extrude.taper": "Taper (01, 1=point):",
// Trim (quick-trim): click the part to cut away; repeats until Esc.
"cmd.trim.label": "Trim",
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
@@ -758,6 +1182,8 @@ export const en: Record<TranslationKey, string> = {
"cmd.import.prompt": "Choose DXF file …",
"cmd.terrain.label": "Terrain",
"cmd.terrain.prompt": "Generate terrain from contours …",
"cmd.georef.label": "New reference point",
"cmd.georef.prompt": "New origin (shifts the whole project):",
// Area balance (SIA 416).
"nav.roomBalance": "Area balance",
@@ -778,9 +1204,15 @@ export const en: Record<TranslationKey, string> = {
"site.contourSet": "Contour set",
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
"site.delete": "Remove",
"site.deleteLayer": "Remove entire layer",
"site.group.imported": "Buildings",
"site.group.terrain": "Terrain",
"site.group.contours": "Contours",
"site.type.mesh": "Mesh",
"site.type.contours": "Contours",
"site.type.terrain": "Terrain",
"site.type.image": "Aerial",
"site.group.image": "Aerial imagery",
"site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.",
"site.terrainFailed": "No terrain could be generated (too few/collinear contour points).",
@@ -849,12 +1281,46 @@ export const en: Record<TranslationKey, string> = {
"file.exportPdf": "Export as PDF…",
"file.exportDxf": "Export as DXF…",
"file.exportSchedule": "Component schedule (CSV)…",
"file.exportIfc": "Export IFC",
"file.exportObj": "Export OBJ (3D mesh)",
"file.exportStl": "Export STL (3D mesh)",
"file.export": "Export",
"file.openFailed": "Could not read the project file.",
// Lock conflict (project file already open in another instance).
"lock.title": "Project already open",
"lock.message": "This project file is already open in another instance.",
"lock.messageInfo": "Opened on {host} (process {pid}).",
"lock.openReadOnly": "Open read-only",
"lock.forceOpen": "Force open anyway",
"lock.forceOpenWarning":
"Warning: opens for writing even though another instance holds the file — changes may overwrite each other.",
"exportSave.title": "Export",
"exportSave.format": "Format",
"exportSave.filename": "File name",
"exportSave.locationNote": "Saved to the default downloads folder.",
"exportSave.fmt.csv": "Component schedule (CSV)",
"exportSave.fmt.ifc": "IFC (BIM)",
"exportSave.fmt.obj": "OBJ (3D mesh)",
"exportSave.fmt.stl": "STL (3D mesh)",
"about.title": "About dossier",
"about.version": "Version",
"about.desc": "Open-source CAAD (Computer Aided Architecture Design) — beautiful, standard-compliant drawings from a semantic building model. Available as a desktop app (full version) and in the browser.",
"about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "License: AGPL-3.0-or-later",
"about.suite": "Part of the openbureau suite",
"about.licenses": "Open-source licenses (third-party)",
"about.close": "Close",
// Settings dialog.
"topbar.settings": "Settings",
"topbar.settings.hint": "Open settings (display colors, project)",
"settings.title": "Settings",
"settings.section.workspace": "Workspace",
"settings.section.display": "Display",
"settings.theme": "Appearance",
"settings.theme.light": "Light",
"settings.theme.dark": "Dark",
"settings.marqueeColor": "Selection box color",
"settings.marqueeColor.hint":
"Color of the marquee selection box drawn while dragging a selection in the plan view.",
@@ -877,8 +1343,15 @@ export const en: Record<TranslationKey, string> = {
// Location import (geo.admin / OSM).
"site.importLocation": "Import location",
"site.setGeoref": "New reference point",
"site.setGeoref.hint": "Click in the plan to shift the whole project — the clicked point becomes the new origin (0,0). The real-world reference is preserved.",
"ctxImport.title": "Import location",
"ctxImport.close": "Close",
"ctxImport.geoAnchor": "Georeferenced from: {label}",
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
"ctxImport.geoAnchor.reset": "Reset",
"ctxImport.geoAnchor.reset.hint": "Clears the location reference — the next import sets it again.",
"ctxImport.rawHint": "Imported at its real LV95 coordinates (far from the origin). Then use \"New reference point\" to bring it to a convenient model point — the IFC/DXF export still writes the real coordinates.",
"ctxImport.location": "Place / address",
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
"ctxImport.search": "Search",
@@ -886,13 +1359,31 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.noResults": "No matches.",
"ctxImport.searchError": "Search failed.",
"ctxImport.radius": "Radius",
"ctxImport.refPoint": "Reference point (model origin)",
"ctxImport.refPoint.e": "LV95 East (E)",
"ctxImport.refPoint.n": "LV95 North (N)",
"ctxImport.refPoint.h": "Elevation (m a.s.l.)",
"ctxImport.refPoint.hint": "Optional: real coordinate of the reference point (like ArchiCAD's survey point). East/North (LV95) set which model point maps to (0,0); the elevation (m a.s.l.) is anchored informationally and written as the site datum elevation on IFC export. Empty = the search centre becomes the origin.",
"ctxImport.refPoint.anchorLabel": "Reference point {e} / {n}",
"ctxImport.sources": "Sources",
"ctxImport.src.swissBuildings": "Swisstopo buildings",
"ctxImport.buildings.off": "Off",
"ctxImport.buildings.flach": "Simplified (footprint, fast)",
"ctxImport.buildings.v2": "Real 2.0 (stable)",
"ctxImport.buildings.v3": "Real 3.0 (Beta)",
"ctxImport.src.swissTerrain": "Swisstopo terrain",
"ctxImport.src.swissImage": "SWISSIMAGE aerial (2D backdrop)",
"ctxImport.src.swissImageDrape": "Drape aerial onto terrain (3D)",
"ctxImport.terrain.resolution": "Resolution",
"ctxImport.terrain.res2": "2 m (fast)",
"ctxImport.terrain.res05": "0.5 m (fine, larger tiles)",
"ctxImport.src.osmBuildings": "OSM buildings",
"ctxImport.src.osmRoads": "OSM roads",
"ctxImport.src.osmWater": "OSM water",
"ctxImport.src.osmGreen": "OSM greenery",
"ctxImport.src.osmParking": "OSM parking",
"ctxImport.src.osmRailway": "OSM railway",
"ctxImport.src.osmForest": "OSM forest",
"ctxImport.cancel": "Cancel",
"ctxImport.confirm": "Import",
"ctxImport.importing": "Importing…",
@@ -900,6 +1391,8 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.empty": "No geometry found in the selected area.",
"ctxImport.importError": "Import failed.",
"ctxImport.imported": "Imported {count} objects in {sets} sets.",
"ctxImport.importedWithSkipped": "Imported {count} objects in {sets} sets ({skipped} building tile(s) skipped, too large).",
"ctxImport.buildingsSkipped": "No buildings loaded — {count} tile(s) were too large (dense built-up area). Try a smaller radius or switch generation.",
// Room command.
"cmd.room.label": "Room",
@@ -937,6 +1430,9 @@ export const en: Record<TranslationKey, string> = {
"roomStamp.showFloorArea": "Show floor area",
"roomStamp.floorAreaPrefix": "Floor area prefix",
"roomStamp.showUsage": "Show usage",
"roomStamp.roundingStep": "Area rounding",
"roomStamp.roundingStepDefault": "Default (2 decimal places)",
"roomStamp.occupancy": "Occupancy",
"text.selectedHint": "Formatting applies to the selected text",
"rt.bold": "Bold",
@@ -944,6 +1440,8 @@ export const en: Record<TranslationKey, string> = {
"rt.underline": "Underline",
"rt.strike": "Strikethrough",
"rt.strikethrough": "Strikethrough",
"rt.super": "Superscript",
"rt.sub": "Subscript",
"rt.fontSize": "Font size",
"rt.color": "Color",
"rt.preset": "Preset",
@@ -955,4 +1453,151 @@ export const en: Record<TranslationKey, string> = {
"WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.",
"viewport3d.grid.show": "Show ground grid",
"viewport3d.grid.hide": "Hide ground grid",
"viewport3d.section.label": "Section plane",
"viewport3d.section.show": "Section plane on",
"viewport3d.section.hide": "Section plane off",
"viewport3d.section.bound": "Clear 3D section (bound to section line)",
"viewport3d.grip.vertex": "Drag wall endpoint",
"viewport3d.grip.edge": "Drag edge",
"viewport3d.grip.height": "Drag height",
"viewport3d.grip.move": "Move",
"viewport3d.grip.roofpitch": "Drag roof pitch",
// ── Element tree (Elements panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elements",
"elements.search": "Search elements…",
"elements.empty": "No components in the project.",
"elements.noMatch": "No matches.",
"elements.kind.wall": "Wall",
"elements.kind.ceiling": "Ceiling",
"elements.kind.window": "Window",
"elements.kind.opening": "Opening",
"elements.kind.stair": "Stair",
"elements.kind.extrusion": "Extrusion",
"elements.kind.roof": "Roof",
"elements.kind.column": "Column",
// Views / view snapshots (DOSSIER A2).
"viewsnap.title": "Views",
"viewsnap.saveCurrent": " Save current view …",
"viewsnap.savePrompt": "Save view as:",
"viewsnap.renamePrompt": "Rename view:",
"viewsnap.rename": "Rename",
"viewsnap.delete": "Delete",
"viewsnap.deleteConfirm": "Delete view “{name}”?",
"viewsnap.empty": "No views yet. Set up a view and save it.",
"viewsnap.applyHint": "Restore view “{name}”",
"viewsnap.add.folder": "Create folder",
"viewsnap.add.snapshot": "Save view",
"viewsnap.folder.defaultName": "New folder",
"viewsnap.folder.rename": "Rename folder",
"viewsnap.folder.delete": "Delete folder",
"viewsnap.folder.empty": "Empty folder",
"viewsnap.tree.empty": "No folders or views yet. Add via “+”.",
"viewsnap.footer.scale": "Scale",
"viewsnap.footer.layerCombo": "Layer combo",
"viewsnap.footer.drawingCombo": "Drawing combo",
"viewsnap.footer.overrides": "Overrides",
"viewsnap.footer.none": "—",
// Layout sheets with master layout (DOSSIER A3).
"layouts.panelTitle": "Portfolio",
"layouts.title": "Layouts",
"layouts.new": "Create",
"layouts.namePlaceholder": "Sheet name …",
"layouts.defaultName": "New layout",
"layouts.rename": "Rename",
"layouts.delete": "Delete",
"layouts.renamePrompt": "Rename:",
"layouts.deleteConfirm": "Delete “{name}”?",
"layouts.openHint": "Open layout “{name}”",
"layouts.empty": "No layouts yet. Create a sheet and open it.",
"layouts.paper": "Paper size",
"layouts.orientation": "Orientation",
"layouts.portrait": "Portrait",
"layouts.landscape": "Landscape",
"layouts.border": "Border",
"layouts.masters": "Master layouts",
"layouts.newMaster": " Master",
"layouts.newMasterPrompt": "New master layout:",
"layouts.defaultMasterName": "Master",
"layouts.noMasters": "No master layouts yet.",
"layouts.master": "Master",
"layouts.noMaster": "No master",
"layouts.tb.projectName": "Project",
"layouts.tb.sheetName": "Sheet name",
"layouts.tb.scale": "Scale",
"layouts.tb.date": "Date",
"layouts.tb.author": "Author",
"layouts.tb.autoPlaceholder": "(automatic)",
// Editor
"layouts.editorTitle": "Layout editor",
"layouts.fit": "Fit",
"layouts.addViewport": "Add viewport",
"layouts.pickSnapshot": "Pick a view …",
"layouts.noSnapshots": "No views available. Save a view first.",
"layouts.viewportProps": "Viewport properties",
"layouts.noViewports": "No viewports yet. Pick a view and add it.",
"layouts.selectViewport": "Click a viewport on the sheet.",
"layouts.missingSnapshot": "View missing",
"layouts.xMm": "X (mm)",
"layouts.yMm": "Y (mm)",
"layouts.widthMm": "Width (mm)",
"layouts.heightMm": "Height (mm)",
"layouts.scale": "Scale",
"layouts.scaleReset": "Scale from view",
"layouts.removeViewport": "Remove viewport",
"layouts.backToModel": "Back to model",
"layouts.placeViewport": "Draw viewport",
"layouts.placeHint": "Drag a rectangle on the sheet, then bind a snapshot.",
"layouts.bindSnapshot": "Bind snapshot",
"layouts.emptyViewports":
"No viewports yet. Choose “Draw viewport” and drag a rectangle on the sheet.",
// Annotations (line/rect/text) drawn directly on the sheet
"layouts.annotLine": "Draw line",
"layouts.annotRect": "Draw rectangle",
"layouts.annotText": "Place text",
"layouts.annotTextTitle": "Text",
"layouts.annotTextLabel": "Text content:",
"layouts.annotEditText": "Edit text",
"layouts.annotColor": "Color",
"layouts.annotWeight": "Line weight (mm)",
"layouts.annotRemove": "Remove annotation",
"layouts.annot.line": "Line",
"layouts.annot.rect": "Rectangle",
"layouts.annot.text": "Text",
// Tree / “+” menu / folders
"layouts.add": "New …",
"layouts.addFolder": "Create folder",
"layouts.addLayout": "Create layout",
"layouts.addMaster": "Create master layout",
"layouts.add.folder": "Create folder",
"layouts.add.layout": "Create layout",
"layouts.add.master": "Create master layout",
"layouts.folder.defaultName": "New folder",
"layouts.folder.rename": "Rename folder",
"layouts.folder.delete": "Delete folder",
"layouts.folder.export": "Export folder as PDF",
"layouts.folder.empty": "Empty folder",
"layouts.folder.exportEmpty": "Folder contains no layouts.",
"layouts.tree.empty": "No folders or layouts yet. Add one via “+”.",
// “New layout” dialog
"layouts.newLayout.title": "New layout",
"layouts.newLayout.source": "Template",
"layouts.newLayout.fromMaster": "Master layout as template",
"layouts.newLayout.freeSize": "Free size",
"layouts.newLayout.pickMaster": "Pick a master layout …",
"layouts.newLayout.noMasters": "No master layouts available.",
// “New master layout” dialog
"layouts.newMaster.title": "New master layout",
// Shared size picker (both dialogs)
"layouts.size.format": "Format",
"layouts.size.custom": "Free size",
"layouts.size.a4": "A4",
"layouts.size.a3": "A3",
"layouts.paperGroup.a": "A series",
"layouts.paperGroup.b": "B series",
"layouts.size.widthMm": "Width (mm)",
"layouts.size.heightMm": "Height (mm)",
"layouts.size.create": "Create",
};
+62
View File
@@ -537,3 +537,65 @@ describe("contoursToDrawings — HATCH-Füllung", () => {
expect(d.fillColor).toBeUndefined();
});
});
describe("parseDxf — Polyface-Mesh (POLYLINE Flag 64, swissBUILDINGS3D-Format)", () => {
// Regression: die installierte dxf-parser-Version liefert Face-Vertex-
// Indizes NICHT als `faces`-Array, sondern als vier einzelne Felder
// (Gruppencodes 7174, faceA..faceD) — reale swissBUILDINGS3D-DXF-Kacheln
// ergaben dadurch 0 Dreiecke (Nutzer-Report „Gebäude-Import funktioniert
// nicht"). Rohes DXF (keine gemockte Entity-Form), damit auch die
// zugrundeliegende Bibliothek mitgeprüft wird.
it("ein Dreieck (3 Geometrie-Vertices + 1 Face-Record) ergibt genau 1 Dreieck", () => {
const lines = [
"0", "SECTION", "2", "ENTITIES",
"0", "POLYLINE", "8", "0", "70", "64",
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "0.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "0.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "1.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "128", "10", "0.0", "20", "0.0", "30", "0.0",
"71", "1", "72", "2", "73", "3",
"0", "SEQEND",
"0", "ENDSEC", "0", "EOF",
];
const res = parseDxf(lines.join("\n"));
expect(res.meshes).toHaveLength(1);
expect(res.meshes[0].positions.length / 3).toBe(3);
expect(res.meshes[0].indices).toEqual([0, 1, 2]);
});
it("ein Viereck (4 Geometrie-Vertices + 1 Face-Record mit faceD) ergibt 2 Dreiecke (Fan)", () => {
const lines = [
"0", "SECTION", "2", "ENTITIES",
"0", "POLYLINE", "8", "0", "70", "64",
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "0.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "0.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "1.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "1.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "128", "10", "0.0", "20", "0.0", "30", "0.0",
"71", "1", "72", "2", "73", "3", "74", "4",
"0", "SEQEND",
"0", "ENDSEC", "0", "EOF",
];
const res = parseDxf(lines.join("\n"));
expect(res.meshes).toHaveLength(1);
expect(res.meshes[0].positions.length / 3).toBe(4);
expect(res.meshes[0].indices.length / 3).toBe(2);
});
it("negative (unsichtbare-Kante) Face-Indizes werden per abs() aufgelöst", () => {
const lines = [
"0", "SECTION", "2", "ENTITIES",
"0", "POLYLINE", "8", "0", "70", "64",
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "0.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "0.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "1.0", "30", "0.0",
"0", "VERTEX", "8", "0", "70", "128", "10", "0.0", "20", "0.0", "30", "0.0",
"71", "1", "72", "-2", "73", "3",
"0", "SEQEND",
"0", "ENDSEC", "0", "EOF",
];
const res = parseDxf(lines.join("\n"));
expect(res.meshes).toHaveLength(1);
expect(res.meshes[0].indices).toEqual([0, 1, 2]);
});
});
+26 -10
View File
@@ -58,8 +58,17 @@ interface DxfVertex {
x?: number;
y?: number;
z?: number;
// Polyface-Mesh: 1-basierte, ggf. negative Vertex-Indizes (negativ = unsichtbare Kante).
faces?: number[];
// Polyface-Mesh-Face-Record (VERTEX mit polyfaceMeshVertex-Flag): 1-basierte,
// ggf. negative Vertex-Indizes (negativ = unsichtbare Kante) — die installierte
// dxf-parser-Version liefert sie NICHT als `faces`-Array, sondern als vier
// EINZELNE Felder (Gruppencodes 7174, ein Dreieck nutzt nur faceAfaceC,
// ein Viereck alle vier). `polyfaceMeshVertex` markiert einen Face- statt
// Geometrie-Vertex (Gruppencode-70-Bit 128, s. isMeshPolyline/addPolyfaceMesh).
faceA?: number;
faceB?: number;
faceC?: number;
faceD?: number;
polyfaceMeshVertex?: boolean;
}
interface DxfEntity {
type?: string;
@@ -295,18 +304,25 @@ function addMesh(positions: number[], indices: number[], e: DxfEntity): void {
}
}
/** Rohe Face-Vertex-Indizes eines VERTEX-Records (faceA..faceD, nur gesetzte,
* von Null verschiedene Felder ein Dreieck belegt nur faceAfaceC). */
function faceIndicesOf(v: DxfVertex): number[] {
return [v.faceA, v.faceB, v.faceC, v.faceD].filter(
(i): i is number => typeof i === "number" && i !== 0,
);
}
/** Ob eine POLYLINE eine Mesh-Variante ist (Polyface- oder Polygon-Gitter). */
function isMeshPolyline(e: DxfEntity): boolean {
if (e.isPolyfaceMesh === true || e.is3dPolygonMesh === true) return true;
// Heuristik: enthält irgendein Vertex eine `faces`-Liste → Polyface-Mesh.
return (e.vertices ?? []).some(
(v) => Array.isArray(v.faces) && v.faces.length > 0,
);
// Heuristik: enthält irgendein Vertex Face-Indizes → Polyface-Mesh.
return (e.vertices ?? []).some((v) => faceIndicesOf(v).length > 0);
}
/**
* Polyface-Mesh (POLYLINE, Flag 64): erst Geometrie-Vertices, dann Face-Records
* mit 1-basierten (ggf. negativen = unsichtbare Kante) Vertex-Indizes. Wir
* (VERTEX mit `polyfaceMeshVertex`-Flag, s. {@link DxfVertex}) mit 1-basierten
* (ggf. negativen = unsichtbare Kante) Vertex-Indizes in faceA..faceD. Wir
* trennen Geometrie- von Face-Vertices und fan-triangulieren jede Face.
*/
function addPolyfaceMesh(
@@ -315,8 +331,8 @@ function addPolyfaceMesh(
e: DxfEntity,
): void {
const all = e.vertices ?? [];
const geom = all.filter((v) => !(Array.isArray(v.faces) && v.faces.length));
const faceRecs = all.filter((v) => Array.isArray(v.faces) && v.faces.length);
const geom = all.filter((v) => faceIndicesOf(v).length === 0);
const faceRecs = all.filter((v) => faceIndicesOf(v).length > 0);
if (geom.length < 3) return;
const base = positions.length / 3;
for (const v of geom) {
@@ -324,7 +340,7 @@ function addPolyfaceMesh(
}
for (const fr of faceRecs) {
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
const ring = (fr.faces ?? [])
const ring = faceIndicesOf(fr)
.map((i) => Math.abs(i))
.filter((i) => i >= 1)
.map((i) => base + (i - 1));
+15 -2
View File
@@ -2,7 +2,7 @@
//
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün/Parkplatz/Bahn/Wald). Die UI wandelt diese in
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
@@ -12,7 +12,14 @@ import type { GeoOrigin } from "./lv95";
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
/** Fachliche Kategorie einer importierten Kontext-Linie. */
export type GeoCategory = "building" | "road" | "water" | "green";
export type GeoCategory =
| "building"
| "road"
| "water"
| "green"
| "parking"
| "railway"
| "forest";
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
export interface GeoFeature {
@@ -44,6 +51,9 @@ const CATEGORY_LABEL: Record<GeoCategory, string> = {
road: "Strassen",
water: "Gewässer",
green: "Grünflächen",
parking: "Parkplätze",
railway: "Bahn",
forest: "Wald",
};
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
@@ -52,6 +62,9 @@ const CATEGORY_LAYER: Record<GeoCategory, string> = {
road: "context-roads",
water: "context-water",
green: "context-green",
parking: "context-parking",
railway: "context-railway",
forest: "context-forest",
};
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
+28 -8
View File
@@ -1,7 +1,8 @@
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
//
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze /
// Bahnlinien / Wald und wandelt sie in Polylinien in LOKALEN Modell-Metern (über
// LV95 + Origin, siehe
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
@@ -24,6 +25,9 @@ export interface OsmSelection {
roads: boolean;
water: boolean;
green: boolean;
parking: boolean;
railway: boolean;
forest: boolean;
}
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
@@ -55,7 +59,12 @@ function buildQuery(
}
if (sel.green) {
parts.push(`way["leisure"="park"](${b});`);
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
parts.push(`way["landuse"~"grass|meadow|recreation_ground"](${b});`);
}
if (sel.parking) parts.push(`way["amenity"="parking"](${b});`);
if (sel.railway) parts.push(`way["railway"~"rail|tram"](${b});`);
if (sel.forest) {
parts.push(`way["landuse"="forest"](${b});`);
parts.push(`way["natural"="wood"](${b});`);
}
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
@@ -69,20 +78,23 @@ function categorize(
if (!tags) return null;
if (sel.buildings && tags.building) return "building";
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
if (sel.forest && (tags.natural === "wood" || tags.landuse === "forest"))
return "forest";
if (
sel.green &&
(tags.leisure === "park" ||
tags.natural === "wood" ||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
/grass|meadow|recreation_ground/.test(tags.landuse ?? ""))
)
return "green";
if (sel.parking && tags.amenity === "parking") return "parking";
if (sel.railway && /rail|tram/.test(tags.railway ?? "")) return "railway";
if (sel.roads && tags.highway) return "road";
return null;
}
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */
function isClosedCategory(cat: GeoCategory): boolean {
return cat !== "road";
return cat !== "road" && cat !== "railway";
}
/**
@@ -118,7 +130,15 @@ export async function fetchOsm(
originIn?: GeoOrigin,
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
const origin = originIn ?? makeOrigin(center);
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
if (
!sel.buildings &&
!sel.roads &&
!sel.water &&
!sel.green &&
!sel.parking &&
!sel.railway &&
!sel.forest
) {
return { origin, features: [] };
}

Some files were not shown because too many files have changed in this diff Show More