367 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
karim 9133c0961d Übergabe-Dokument: truck-Integration Profil-Extrusion (docs/design/truck-plan.md) 2026-07-05 22:54:17 +02:00
karim b640bbe606 Ribbon: feste Höhe 60px statt min-height — kein Springen beim Tab-Wechsel 2026-07-05 22:50:26 +02:00
karim 0b56d777bc Ribbon 3D-Tab: Kamera-Presets + Darstellungsart; truck-Integration geplant 2026-07-07 2026-07-05 22:43:38 +02:00
karim 1c09b6e7c2 Attribute: Aufbau-Umschalter Einschichtig→Solid (kürzer), Mehrschichtig bleibt 2026-07-05 22:27:10 +02:00
karim 4b77047916 Attribute: Höhen-Bindung kürzer benannt (Gebunden/Eigene statt An Geschoss gebunden/Eigene Höhe), Wand+Decke 2026-07-05 22:18:30 +02:00
karim 670823e98d Attribute-Panel: verschobene Element-Abschnitte dem sauberen .attr-Grid-Look angeglichen (Sektion-Balken, Zeilentrenner, Label-Spalte, füllende Controls) 2026-07-05 22:13:56 +02:00
karim 71e75b99f0 Pendenzen: Objektinfo→Attribute-Merge erledigt → Ribbon-UI komplett 2026-07-05 21:50:22 +02:00
karim 3a2cef3886 ObjectInfo→Attribute: element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) ins Attribute-Panel; ObjectInfo nur noch Bezugspunkt + Masse 2026-07-05 21:50:03 +02:00
karim d01cb82a89 Pendenzen: Fenster-Anschlag-Striche vermerkt 2026-07-05 21:43:00 +02:00
karim 8d688b982e Bauteile: Fenster-Anschlag-Striche (Laibung, fein) analog zur Tür, mit Tests 2026-07-05 21:42:40 +02:00
karim 5b60136a19 Pendenzen: Schnitt/Ansicht Phase 2 (Decken-Überkopf gestrichelt) vermerkt 2026-07-05 21:32:29 +02:00
karim 39ddd9b501 Schnitt/Ansicht Phase 2: Decken-Überkopf-Umriss als gestrichelte Haarlinie (über Schnittebene), mit Test 2026-07-05 21:31:48 +02:00
karim 9c911e6d43 Schnitt/Ansicht Phase 1: Wand unter Schnittebene als Ansichts-Umriss (viewOnly, keine Poché), mit Tests 2026-07-05 21:22:51 +02:00
karim cdc71621c4 Pendenzen: Bauteile Gruppe A (2D) als komplett verifiziert vermerkt; Rest = Gruppe B/C + Schnitt/Ansicht 2026-07-05 21:17:45 +02:00
karim b9b75f7ad1 Pendenzen: Kreis+Bogen-Werkzeuge als komplett erledigt vermerkt 2026-07-05 21:11:25 +02:00
karim e82d4b084c Ribbon Phase 4: modulare Custom-Bar als eigener Tab (Eigene) mit +-Picker, localStorage-persistiert 2026-07-05 20:57:13 +02:00
karim 12441d36fb Ribbon Phase 3: Werkzeug-Sidebar aus Default-Layout, Wandtyp-Picker ins Attribute-Panel, Attribute volle Höhe (LAYOUT_VERSION 8) 2026-07-05 20:50:05 +02:00
karim 2c8ad8fe34 Fix: Kreise/Bögen im WebGL-Renderer sichtbar (drawingCircle/drawingArc tesselliert; Regression aus 4ac99d3) 2026-07-05 20:31:04 +02:00
karim 3697b7e2d8 Snap: Center-/Quadrant-Snaps für Kreise + Bögen (Bogen nur im Spannbereich) + Bogen-Endpunkte, mit Tests 2026-07-05 20:19:34 +02:00
karim a750e87517 Native macOS-Titelleiste: titleBarStyle Overlay (echte Ampeln + gerundete Ecken), Zeile links eingerückt; WindowControls Electron-only 2026-07-05 20:14:10 +02:00
karim 456bf29984 Headless-Titelleiste (Tauri decorations:false): Fensterknöpfe links + Drag-Region, Tauri-Fenstersteuerung + Window-Capabilities 2026-07-05 19:42:48 +02:00
karim c40764a25f Linienstile aufgeräumt: redundante 0.13-Volllinien zusammengeführt, weight-only als Volllinie benannt, Referenzen remappt 2026-07-05 19:32:38 +02:00
karim ff68ec3a5c Pendenzen: Textur-Spike als erledigt verifiziert (0ca3b1d), PBR-Restlücken dokumentiert 2026-07-05 19:23:00 +02:00
karim 72a40ccaaf Pendenzen: Ribbon-Feinschliff + Bogen-Werkzeug als erledigt vermerkt 2026-07-05 19:20:51 +02:00
karim fe22cbf0df Ribbon: Text + Ansichten auf eine Leiste (Ansichten-Tab), als Standard-Tab zuerst 2026-07-05 19:18:23 +02:00
karim 4e3b074f5c TopBar OCS-Stil: kleine Wortmarke + Quick-Access-Icons (alle Datei-/Export-Aktionen), Zeile auf 26px, Burger-Menu raus 2026-07-05 18:37:18 +02:00
karim 033cabe80f Ribbon: Text-Formatierung in eigenen Text-Tab; TopBar-Zeile auf 40px verschlankt 2026-07-05 18:10:14 +02:00
karim 456ebc8098 Ribbon-Tabs in die TopBar-Zeile verlegt: eine Leiste (Chrome + Tabs), Inhalt darunter 2026-07-05 18:04:48 +02:00
karim 13c3294f4d Doku: TopBar→Ansichten-Tab-Merge in Ribbon-Plan + Pendenzen vermerkt 2026-07-05 17:58:23 +02:00
karim 85011cb8ef Ribbon: TopBar-Ansichtssteuerung in Ansichten-Tab gemergt; TopBar auf schmale globale Leiste reduziert 2026-07-05 17:57:17 +02:00
karim 3a206060e3 Pendenzen: Ribbon Phase 1 + Attribut-Grid als erledigt vermerkt 2026-07-05 17:01:06 +02:00
karim 9d6e86d4c0 Ribbon-UI Phase 1: datengetriebene Tab-Leiste (2D/3D/BIM/Ansichten), additiv unter TopBar 2026-07-05 17:00:09 +02:00
karim ace0dc62a8 Attribute-Panel: ein durchgehendes Grid, volle Breite, doppelten Titel raus 2026-07-05 16:54:04 +02:00
karim fe9f396750 Doku: Ribbon-UI-Plan (2D/3D/BIM/Ansichten, datengetrieben, modulare Custom-Bar) + Pendenzen 2026-07-05 15:55:55 +02:00
karim 00733d8180 UI: Eigenschaften-Panel als OCS-artiges Grid (Sektion-Balken, Zeilentrenner, fuellende Wertfelder) 2026-07-05 15:48:42 +02:00
karim 077e774303 Fix: Dokument-Overscroll gesperrt (Viewport-Zoom-Wheel zog die UI leicht mit, macOS) 2026-07-05 15:45:32 +02:00
karim bd2b12bfb7 Werkzeuge: Bogen-Werkzeug (arcCommand, 3-Klick CCW) + Toolbar/Icon/i18n verdrahtet 2026-07-05 15:44:05 +02:00
karim 5130a050ff Pendenzen: Kreis-Toolbar erledigt vermerkt 2026-07-05 15:25:10 +02:00
karim e454eab1a8 Werkzeuge: Kreis-Toolbar verdrahtet (circleCommand gekoppelt, Icon, i18n) 2026-07-05 15:24:45 +02:00
karim 67195d7dd5 Pendenzen: DXF-Kurven/Copy-Paste/Platzierung/UX-Fixes vermerkt 2026-07-05 15:18:10 +02:00
karim dd76ec89fd DXF-Import: Platzierungsoption (relativ zu 0 ODER in Ansichtsmitte) 2026-07-05 15:17:09 +02:00
karim 376aa67566 Zeichnungen: Ctrl/Cmd+C/V — 2D-Elemente ueber Geschosse kopieren/einfuegen (aktives Geschoss) 2026-07-05 15:10:58 +02:00
karim 4ac99d37cb DXF-Import: CIRCLE/ARC als echte glatte Kreis-/Bogen-Formen (statt tesselliertem Vieleck) 2026-07-05 15:06:25 +02:00
karim 45e19b7293 Tauri: dragDropEnabled=false — HTML-Drag&Drop (Datei-Import) im Fenster wieder aktiv 2026-07-05 14:56:15 +02:00
karim c794feef9e Layout: Befehlszeile in die Mitte-Spalte (nur Viewport-breit, Docks gewinnen Hoehe) 2026-07-05 14:48:37 +02:00
karim c5b5ca600c Import-Befehl: autoRun statt onConfirm — Datei-Dialog oeffnet synchron in der Geste 2026-07-05 14:48:37 +02:00
karim 4ef73c9198 Pendenzen: HATCH-Ellipse/Spline-Kanten + TEXT/MTEXT-Import erledigt 2026-07-05 14:29:55 +02:00
karim 4b93ac9cbb DXF-Import: TEXT/MTEXT -> editierbarer Plan-Text (Drawing2D text-Primitiv, SVG-Render) 2026-07-05 14:29:05 +02:00
karim 05bc5aa6e1 DXF-HATCH: Ellipsen- + Spline-Randkanten tesselliert (B-Spline-Sampling extrahiert) 2026-07-05 14:18:49 +02:00
karim d36c84689f Doku: Sprache in Planungs- und Konventionsdokumenten vereinheitlicht 2026-07-05 14:14:40 +02:00
karim 8f9123ce0d Pendenzen: HATCH-Import erledigt vermerkt 2026-07-05 14:13:24 +02:00
karim a44572bf7b DXF-Import: HATCH via Custom-Handler -> gefuellte geschlossene Drawing2D-Flaeche 2026-07-05 14:12:59 +02:00
karim 79ea6b0b3b Pendenzen: SPLINE/INSERT-Import erledigt vermerkt 2026-07-05 13:59:41 +02:00
karim 83da278ed2 DXF-Import: SPLINE (De-Boor-B-Spline) + INSERT (Block-Expansion mit Transform/Array/Verschachtelung) 2026-07-05 13:59:15 +02:00
karim 33c2c6c22e Pendenzen: DXF-Kurven-Import erledigt, Import-Ist-Stand klargestellt 2026-07-05 13:51:37 +02:00
karim b7551d4930 DXF-Import: ARC/CIRCLE/ELLIPSE als tessellierte Konturen (Abdeckungsluecke geschlossen) 2026-07-05 13:51:08 +02:00
karim 25daec6be9 Pendenzen: Join-WASM-Durchstich verworfen (gemessen: TS schneller <100 Waende) 2026-07-05 13:40:58 +02:00
karim 5229174551 Treppe: Vorkonfiguration vor Erstellung (Breite/Referenz/Trittmass)
- Referenz (links/mitte/rechts) + Trittmass-Modus (mit/ohne) als togglebare
  Optionen, Breite als Tab-Feld — bereits in der Idle-Phase (vor dem 1. Punkt).
- Trittmass-Modus 'mit': stepsFromTread(auftritt, runLength) leitet Stufenzahl
  aus Ziel-Auftritt ab (Default IDEAL_TREAD); Feld Auftritt statt Stufen.
- appendStair schreibt Stair.referenz; Geometrie/Outline versetzt entsprechend.
- stairDefaults (modulweit) merkt zuletzt gewaehlte Werte fuer die naechste
  Treppe (Session-RAM; localStorage/projectSlice waere Folgeschritt).
- 10 Tests (stair.tread.test.ts), i18n de/en. vitest 302/302, tsc sauber.
2026-07-05 13:14:06 +02:00
karim df0e5e432a Pendenzen: DWG/DXF-Import-Spike (acadrust wasm32) vermerkt + Folgeschritte 2026-07-05 03:13:04 +02:00
karim 715e950d7c DWG/DXF-Import-Spike: acadrust baut zu wasm32 (Crate dwgimport)
- src-tauri/dwgimport: eigenstaendiges Crate (cdylib+rlib, eigener [workspace]),
  dep acadrust 0.4 (MPL-2.0, pure Rust). parse_dxf_summary(bytes) via
  DxfReader::from_reader + Cursor<Vec<u8>> -> JSON (version/entityCount/
  entityTypeCounts/layerNames). wasm-Fassade parse_dxf_summary_json.
- getrandom wasm_js-Feature noetig (transitiv via ahash) -> als Dep gesetzt.
- Kernbeweis: acadrust kompiliert nach wasm32 (839 KB), DXF-Parse headless
  getestet (5 Entities korrekt). Beweist den DWG/DXF-Import-Weg im Rust/WASM-Kern.
- build:dwgimport-Script, src-tauri/Cargo.toml exclude erweitert.
2026-07-05 03:12:32 +02:00
karim dbf78a9d75 UI: Gruppe-A-Felder in Objekt-Info-Panel verdrahtet
- Treppe: Dropdown Referenz (Links/Mitte/Rechts) vor Laufrichtung.
- Fenster: Feld Fluegel (1-4) nach Bruestung.
- Tuer: Dropdowns Typ (Normal/Wandoeffnung) + Sturzlinien (Keine/Innen/Aussen/
  Beide) vor Anschlag.
- host.ts/selectionInfo.ts Durchreichung, App.tsx-Handler (kind-guard +
  updateOpening/updateStair), i18n de/en. Rueckwaertskompatibel.
  tsc sauber, vitest 292/292.
2026-07-05 03:01:35 +02:00
karim 26fbd11b73 Bauteile Gruppe A (4-6): Treppe-Referenz, Fenster-Fluegel, Tuer-wandoeffnung
- Treppe referenz links/mitte/rechts (Stair.referenz, Default mitte): Perp-Versatz
  referenzMidShift auf start/center, Lauflinie/Pfeil bleiben visuell zentriert.
- Fenster Fluegel-Mittelpfosten (Opening.wingCount 1-4, Default 1): wingCount-1
  Querlinien im Rahmen (window-mullion), nur mittel/fein.
- Tuer-Typ wandoeffnung (Opening.doorType, Default normal): ueberspringt
  Tuerblatt + Schwenkbogen, Laibung/Sturz bleiben.
- Alle Modellfelder optional/rueckwaertskompatibel. 17 neue Tests.
  vitest 292/292, tsc sauber.
2026-07-05 02:33:48 +02:00
karim 6e5998ce72 Bauteile Gruppe A: Treppe-Aussenlinie, Fenster-Bruestung, Tuer-Sturz (Rhino-Vorbild)
- Treppe Aussenlinie/Outline (stair.ts stairOutline): gerade=4-Punkt-Rechteck,
  L=6-Punkt-Polygon via lineIntersect2d, Wendel=2 Boegen+2 Radiallinien; in
  addStairSymbol als durchgezogene Umrisslinie. (Rhino _aussen_gerade/_l/_wendel)
- Fenster Bruestungslinie: bei sillHeight>0 gepunktete Linie auf der Wandachse
  zwischen den Pfosten (Klasse window-sill).
- Tuer Sturzlinien (SIA): gestrichelte Linien an Wand-Innen/-Aussenkante, neues
  optionales Opening-Feld lintelLines (keine/innen/aussen/beide, Default beide),
  rueckwaertskompatibel.
- 12 neue Tests (stairOutline.test.ts). vitest 275/275, tsc sauber.
2026-07-05 01:56:07 +02:00
karim b65c676643 Research: Bauteile Treppe/Fenster/Tuer — Rhino-Plugin als Vorbild
RESEARCH_BAUTEILE_RHINO.md: Gap-Vergleich Rhino (treppe.py 1784Z + fenster/tuer)
vs. TS-Ist, priorisierte Anhebungs-Ansaetze (Gruppe A 2D-sofort / B 2D-mittel /
C 3D). Kern-Gaps: Treppe-Aussenlinie, Fenster-Bruestungslinie, Tuer-Sturzlinien
(SIA, gestrichelt) — fallen mit Schnitt/Ansichts-Darstellung zusammen.
PENDENZEN: Bauteil-Item im Backlog + Phase 5 abgehakt (a608a59).
2026-07-05 01:33:35 +02:00
karim ae18766b01 kernel2d-Port Phase 5: roomArea/ceiling/roomBoundary/stair
- roomArea: polygonArea/perimeter/centroid.
- ceiling: normalizeOutline/isValidOutline/ceilingArea/outlineBBox/
  outlineCentroid/pointInOutline (+ BBox-Struct, serde camelCase).
- stair: defaultStepCount/stairGeometry (gerade/L/Wendel)/stairCut/stairBBox/
  pointHitsStair (StairParams-Struct, strukturgleich; Nullguard ||1e-9 wie TS).
- roomBoundary: detectRooms/roomFromPointInside(Faces)/pointInPolygon
  (planarer Graph, Half-Edge-Faces, Miter-Offset; WallSegment/WallFace).
- Batch-Fassaden + Harness-Slices je Modul (Struktur exakt + Werte).

Verifiziert: vitest 263/263 (33 Parity), tsc sauber, build:kernel2d sauber.
Bekannte Teil-Deckung: detectRooms nur mit Rechtecken (1 Face) getestet —
komplexe Topologie-Reihenfolge nicht mit Zufallsgraphen abgesichert.
2026-07-05 01:30:11 +02:00
karim c7128fe669 Pendenzen: Modellnamen-Referenz in Erledigt-Zeile neutralisiert (Trace-Konvention) 2026-07-05 01:09:39 +02:00
karim b37c9f467b Research: CAD-Ansaetze aus OpenCADStudio/truck/acadrust (am Code studiert)
RESEARCH_CAD_APPROACHES.md: konkrete, priorisierte Ansaetze fuer 'von BIM-Tool
zu echtem CAD' — direkt an geklonten Repos gelernt.
- OpenCADStudio (GPL-3.0, Referenz): generisches Entity-Trait-Modell,
  modeless StepInput-Command-System, 70KB Snap-Engine, universelle Grips,
  CadDocument=DWG/DXF-Objektmodell.
- truck (Apache-2.0): Rust-B-Rep/NURBS-Kernel, Geometrie-Crates WASM-faehig
  und vom wgpu-Rendering trennbar; Booleans/Fillets noch instabil.
- acadrust (MPL-2.0!): pure-Rust DWG/DXF R13-R2018 read+write, alle Deps
  pure Rust -> WASM-tauglich; Kandidat fuers DWG/DXF-Rueckgrat.
- Web-Import-Landkarte (web-ifc/occt-wasm/shpjs/loaders.gl/...) fuer GEO-BLOCK.
PENDENZEN: Strategie-Item im Backlog verlinkt.
2026-07-05 01:08:08 +02:00
karim 08b0b23a68 Pendenzen: kernel2d-Port Phase 4 erledigt (28471c1) 2026-07-05 01:02:00 +02:00
karim 4a26c34db1 kernel2d-Port Phase 4: Trim/Split/Join (Loewenanteil)
Portiert (1:1, reihenfolge-/strukturtreu):
- splitSegmentByCutters, trimSegment, trimPolyline (+ span/nearestParamOnChain),
  extendSegment.
- splitPolylineAtParam, splitClosedByChord (+ dedupeRing), removeSegment.
- splitAtIntersections (+ polylineEdgesAuto, splitOpenByEdgeHits/AtHits, EdgeHit).
- joinChains: greedy i<j-erster-Treffer-dann-Neustart, exakt wie TS
  (splice-Semantik via remove(j)+open[i]=combined).
- Polyline-Struct {pts,closed}; stabile (edge,t)-Sortierung, 1e-6-Dedup.
- 9 Batch-Fassaden + 4 native Unit-Tests.

Harness: 5 neue Paritaets-Bloecke (Struktur EXAKT + Werte), Cutter-/Polylinien-
Generatoren, joinChains mit re-mergebaren Ketten. cargo test 18/18, vitest 247
(5 neu, davon 17 Parity) gruen, tsc sauber, build:kernel2d sauber.
2026-07-05 01:01:03 +02:00
karim 903dc19cec kernel2d-Port Phase 3: Offset (Miter+1e-9-Fallback) + Fillet
- offsetSegment / offsetPolyline: Gehrung via line_intersect, Fallback auf
  verschobenen Endpunkt an EXAKT 1e-9 (nicht EPS) — Selbstschnitte ungeheilt
  wie TS. Dedup der Eingabe innerhalb EPS.
- filletCorner + Fillet-Struct (serde camelCase): acos/tan/sin/atan2-Kette,
  Klemmung cos∈[-1,1], None bei kollinear (<1e-4 / π-θ<1e-4) oder zu kurzem
  Schenkel. Winkel im Diff-Test abs 1e-7 rad (libm-ULP-Drift), Struktur exakt.
- 3 Batch-Fassaden (offset_segment/offset_polyline/fillet_corner) + 4 Unit-Tests.
- Harness: Offset (rel 1e-9) + Fillet (halb kontrolliert/halb Zufall, Winkel
  1e-7) + Golden (L-Ecke, rechter Winkel, kollinear/zu-gross → null).

cargo test 14/14, vitest 242 (3 neu, davon 12 Parity) gruen, tsc sauber.
2026-07-05 00:50:38 +02:00
karim 09c5178c85 Pendenzen: kernel2d-Port als In-Arbeit-Item mit Phasen-Fortschritt (1+2 erledigt) 2026-07-05 00:43:29 +02:00
karim 8750c8f547 kernel2d-Port Phase 2: Primitive/Schnitt/Flaeche/Kreis + Differential-Harness
Rust-Port (1:1 aus kernel2d.ts, exakte Term-Reihenfolge/EPS-Politik):
- Primitive: dist, vecEqual, projectParam, closestPointOnSegment,
  pointSegmentDistance.
- Schnitt: Hit, segmentIntersect, lineSegmentIntersect, polylineEdges,
  segmentPolylineHits (stabile Sortierung, 1e-6-Dedup).
- Kreis: lineCircleIntersect (Disc B*B-4*A*C + Klemmung), segmentCircleIntersect,
  circleCircleIntersect.
- Flaeche: signedArea (Shoelace, identische Vertex-Reihenfolge), isCCW.
- 11 Batch-WASM-Fassaden (JSON rein/raus) + 10 native Unit-Tests.

Differential-Harness (src/geometry/kernel2d.parity.test.ts):
- Rust-WASM (initSync, readFileSync) gegen TS-Referenz kernel2d.ts, seed-basierte
  Zufallseingaben (Cluster nahe 0 / an Schwellen) + Golden-Grenzfaelle
  (parallel/kollinear/Null-Laenge, Tangente, konzentrisch/getrennt/innen-tangential,
  Null-Flaeche). Struktur exakt, dann Werte mit op-Epsilon (coord/param abs-rel 1e-9,
  Flaeche rel 1e-9).
- Skippt sauber ohne gebautes pkgKernel2d (git-ignoriert), bricht die Suite nicht.

cargo test 10/10, vitest 239 (9 neu) gruen, tsc sauber, build:kernel2d sauber.
2026-07-05 00:43:03 +02:00
karim 028a3637b4 Pendenzen: Textur-Spike visuelle Abnahme bestanden 2026-07-05 00:33:39 +02:00
karim 74eecf7a73 Pendenzen: Textur-Spike render3d erledigt (8556037), aus Queue in Verlauf 2026-07-05 00:30:58 +02:00
karim 0ca3b1dd57 render3d Textur-Spike: RenderStyle::Textured real (Bild-Textur auf Waenden)
- Zweiter Vertex-Pfad [pos,normal,uv] via build_walls_mesh_textured, ABGELEITET
  aus dem fertigen Mesh (Positionen/Normalen/Indizes 1:1) -> Alt-Pfad
  [pos,normal,color] bitgleich; per Regressionstest belegt.
- Prozedurale 256x256-Schachbrett-Textur (kein Asset, kein image-Crate),
  Sampler Linear/Repeat, Textur-Bind-Group group 1 (Globals bleibt group 0).
- MESH_TEXTURED_WGSL: gleiche Beleuchtung wie MESH_WGSL, Albedo aus textureSample.
  Pipeline in Depth/MSAA/Color-Target bitidentisch zur Haupt-Pipeline.
- UV planar in Metern: Mantel u=entlang Achse/v=Hoehe, Deckel u=x/v=z;
  weltraumstabil, keine Verzerrung an Gehrungen. 1 Kachel = 1 m.
- spike3d: Taste T schaltet Shaded <-> Textured zur Laufzeit (kein Re-Meshing).
- Feature-gegatet, Default-Build/-Darstellung unveraendert. cargo test 58 (default)
  / 59 (--features render, inkl. naga-Test MESH_TEXTURED_WGSL) gruen.
2026-07-05 00:30:34 +02:00
karim ecefe61611 Pendenzen: Textur-Spike render3d in Queue (Als Naechstes)
- SPIKE_TEXTUR_render3d.md: Auftrag/Uebergabe fuer den kleinsten ehrlichen
  Durchstich (RenderStyle::Textured real: UVs, prozedurales Schachbrett,
  Textur-Bind-Group, MESH_TEXTURED_WGSL, spike3d umschaltbar).
- PENDENZEN: Spike als konkretes Item unter 'Als Naechstes', verlinkt am
  bestehenden Textur-/PBR-Backlog-Eintrag als dessen erster Durchstich.
2026-07-05 00:12:06 +02:00
karim 2d96a864da kernel2d-Port Phase 1: Crate-Skelett + WASM-Fassade + build:kernel2d
- src-tauri/kernel2d: eigenstaendiges Crate (cdylib+rlib, eigener leerer
  [workspace]), Feature web (wasm-bindgen) und additives robust-predicates.
- Vektor-Helfer 1:1 aus src/model/geometry.ts portiert (hypot-len,
  normalize-Nullguard, hartkodiertes 1e-9 in line_intersect) + Unit-Tests.
- Leere Batch-Fassade kernel2d_normalize_json als WASM-Grenzen-Ping.
- package.json: build:kernel2d; src-tauri/Cargo.toml: workspace-exclude.
- PORT_PLAN.md: Portierungsplan (Scope, Crate-vs-Port, Diff-Harness, Phasen).
2026-07-05 00:09:12 +02:00
karim dec431579e geometry-Crate zu WASM baubar (Feature web, compute_joins_json); aus cad-tauri-Workspace ausgeschlossen
Erster Schritt der Rust-Kern-Migration: die Join-Geometrie wird per wasm-pack zu
WASM gebaut (build:geometry) und exportiert compute_joins_json (JSON rein/raus).
Damit kann das TS-Frontend kuenftig die EINE Rust-Implementierung aufrufen statt
des TS-Duplikats. Crate wie render2d/render3d aus dem Workspace excludet, bleibt
per Pfad-Dep fuer den nativen Host nutzbar. Verhalten unveraendert (noch nicht
verdrahtet). geometry cargo test 8/8, cad-tauri cargo check ok.
2026-07-04 23:44:18 +02:00
karim 9520191bde Pendenzen: Darstellungs-Slots cut/Aufsicht/Untersicht ergaenzt (Deckenspiegel-Fall) 2026-07-04 23:29:02 +02:00
karim 9136cad0ee Pendenzen: Bug-Fixes verbucht; Backlog-Item Schnitt- vs. Ansichtslinie nach Schnitthoehe 2026-07-04 23:27:24 +02:00
karim c7e080f639 Grundriss: Decken-Umriss an Wand-Footprints clippen (Decke liegt darunter)
Die Decke ist ein Slab ueber der Schnittebene; ihre Umrisslinie soll dort, wo
eine Wand darueber steht, NICHT durch die Wand-Poche schlagen (glPlan zeichnet
alle Fuellungen, dann alle Linien -> Kontur landete sonst ueber den Waenden).
Die Fuellflaeche wird jetzt strokelos gezeichnet, der Umriss nur ueber die
Teilstuecke, die KEIN Wand-Footprint (OBB) verdeckt. Deckungsgleiche Decke
(Umriss = Wand-Mittellinien) -> Umriss entfaellt ganz; nur Ueberstaende bleiben.
2026-07-04 23:26:00 +02:00
karim aa4205bf0b T-Stoss: materialgleicher Nah-Putz verschmilzt ohne L-Trennnaht (TS+Rust)
Die L-Seitenlinie am T-Stoss wird nur noch gezeichnet, wenn der getrimmte
Abzweig-Putz materialFREMD zum durchgehenden Nah-Putz der Durchgangswand ist.
Bei materialgleichem Putz (z. B. iw-Innenputz auf iw-Innenputz) fuellt der
Nah-Putz die Putzflanken durchgehend (spanCutout schneidet nur die Kernbreite)
-> durchgehendes Putz-L ohne Naht. Tests entsprechend gezogen.
2026-07-04 23:17:18 +02:00
karim 9a80d9cf42 Snap-Farbe Sora #5FA1C9 als Default festgeschrieben; Pendenzen-Queue auf verifizierten Stand 2026-07-04 22:46:21 +02:00
karim ea86cf7456 Doku: Recovery-Stand 2026-07-04 (alle drei 3b-Slices gelandet)
PENDENZEN: Oeffnungen als Boolean-Loecher (1407c68) nach Erledigt. HANDOVER:
Slice 3 committet, Environment-Behebung + Push-Blocker dokumentiert.
2026-07-04 22:19:19 +02:00
karim 05cfade483 Nordstern-3D: Fenster/Tueren als echte Boolean-Loecher in EINEM Wandkoerper
Bisher zerlegte emitWall eine Wand um jede Oeffnung in Pfeiler/Bruestung/Sturz-
Teilquader -> sichtbare Segment-Naehte im 3D, Oeffnung war semantisch kein Loch,
und versetzt uebereinanderliegende Fenster liessen sich gar nicht abbilden. Neu:
im 3D-Pfad (layered=true) EIN RWall pro Schicht-Band ueber die volle Achse mit
allen Oeffnungen als rechteckige holes; render3d stanzt sie per achsparalleler
Rechteck-Gitter-Zerlegung der Langseiten aus und setzt 4 Laibungsquads je Loch.
holes und openings schliessen sich im Emitter gegenseitig aus. Tuer = Loch bis
zum Wand-zBottom. Schnitt-Pfad (layered=false) segmentiert unveraendert weiter
(wallSegmentOwners bleibt synchron, toSection.ts unangetastet).

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

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

vitest 227 (+5), cargo 8 (+1), tsc sauber.
2026-07-04 19:22:32 +02:00
karim 45ded83294 Nordstern-3D: Locked-Iso — freie Kamera bleibt nach Ortho-Preset orthografisch
Iso/Front/Top/Side kippten bei der ersten Kamerabewegung sofort in Perspektive,
weil orbitCamera() Projektion und Ortho-Halbhoehe hartkodiert hatte
(perspective:true / dist*0.5). OrbitState fuehrt jetzt ortho:boolean +
orthoHalfHeight:number: der Preset-Effekt setzt ortho = view3d!=='perspective'
und seedet orthoHalfHeight auf dist*tan(FOV_Y/2) (perspektiv-aequivalente
Bildhoehe an der eingepassten Distanz -> kein Sprung beim ersten Move). Pan
rechnet worldPerPx in Ortho aus der Halbhoehe statt aus dist; Zoom skaliert im
Ortho-Modus orthoHalfHeight multiplikativ (0.05-500) und laesst dist/eye
unangetastet. Perspektive-Preset unveraendert.
2026-07-04 18:51:35 +02:00
karim 77f32f14ce Nordstern-3D: Live-Schnittebene mit schraffierten Schnittflaechen
Der 3D-Viewport kann das Modell jetzt live an einer vertikalen Ebene
aufschneiden (Overlay-Button 'Schnittebene', MVP: Ebene durch die
Modellmitte, Blick +Y):
- Globals um section_plane vec4 erweitert (xyz=Normale, w=-n*p; Enable
  in mode.y); MESH_/GRID_WGSL discarden Fragmente vor der Ebene
  (world_pos als neuer Varying) - Flaechen, Kanten, Grid, Highlight.
- section_fill.rs (neu): build_cut_caps ruft cut_section und trianguliert
  jedes (u,v)-Rechteck zurueck in Weltkoordinaten ([x,y,z,u,v]).
- CAP_WGSL: prozedurale 45-Grad-Diagonalschraffur aus (u,v) in Modell-
  Metern (fwidth-AA), Tinte auf Papier wie die 2D-Konvention; eigene
  cap_pipeline (CullMode::None, Depth-Bias Richtung Kamera gegen
  Z-Fighting), gezeichnet nach den Flaechen, nicht geclippt.
- web.rs cached walls/slabs; set_section_plane(active, p, n) aktualisiert
  Uniform + Caps; set_model baut Caps bei aktivem Schnitt neu.
- useWasm3dRenderer.setSectionPlane + Toggle in Wasm3DViewport.

Bekannte Luecken (dokumentiert): Oeffnungen im 3D-Solid nicht ausgespart
(cut_section spart aus -> Mismatch bei Schnitt durchs Fenster; wird mit
dem Boolean-Loecher-Slice geloest), nur vertikale Ebenen, ein globales
Muster (per-Bauteil = Stufe 2). cargo 56 Tests + naga-WGSL-Validierung,
222 vitest gruen, WASM neu gebaut (pkg3d gitignored).
2026-07-04 15:08:41 +02:00
karim 07042ebc75 Joins Phase 2: Merge-Regel im Schnitt (gleiche Komponente verschmilzt)
Die Schnitt-Dominanz kannte nur 'strikt hoeher schneidet schwaecher'.
Neu: Baender GLEICHER joinPriority UND GLEICHER Komponente, die sich
beruehren/ueberlappen, verschmelzen zu EINEM Rechteck (rectUnionIfRect:
Union nur, wenn das Ergebnis exakt ein Rechteck ist; iterativ bis zum
Fixpunkt) - keine innere Trennlinie mehr durch gleichartiges Material,
analog resolveJoinPriority 'merge' im Grundriss. SectionCutPolygon
traegt dafuer componentId (Schicht-Split + repraesentatives Bauteil bei
einschichtiger Poche). Verschiedene Komponenten gleicher Prioritaet
koexistieren unveraendert; Subtraktion bit-identisch. +6 Tests (222).
2026-07-04 15:06:19 +02:00
karim ecf262ef07 HANDOVER: Stand 2026-07-04 (Prioritaets-Joins, Schnitt-Sanierung) 2026-07-04 15:00:55 +02:00
karim 9acce9d7cd Joins Phase 1b: T-Stoss-Kern sticht nur durch den Nah-Putz (keine Achs-Ueberlappung)
Der verschmelzende Abzweig-Kern (Backstein) lief seit Phase 1 bis zur
ACHSE der Durchgangswand und ueberlappte deren Koerper. Jetzt stoppt er
an der Nahflaeche des Durchgangs-Rueckgrats: mergeCut bei
offT + sign*(tT/2 - nearPlaster), wobei nearPlaster die Summe der
Durchgangs-Schichten zwischen Nahseite und Rueckgrat-Kern ist. Die
L-Seitenlinien der Putzschichten liegen ebenfalls auf dieser Tiefe.
Naht zwischen den beiden Kernen entfaellt ueber die bestehenden
Mechanismen (stroke:none am Fuellband, noStrokeEdges an den Stirnkanten,
Zeichenreihenfolge bricht die Nahflaechenlinie auf) - kein
generatePlan-Umbau noetig. Rust-Paritaet in geometry/lib.rs analog
(Aggregat-Cuts unveraendert, parity.test gruen). 216 Tests + cargo 7/7.
2026-07-04 14:55:51 +02:00
karim cf8b638f33 Schnitt: wandrelative Schraffuren drehen mit der geschnittenen Wand
splitWallLayers uebergab resolveHatch keinen Wandachsen-Winkel, wodurch
wandrelative Muster (relativeToWall: Daemmung, Backstein) im Schnitt
absolut blieben — die Daemmung lief vertikal statt quer zur Wanddicke.
Die geschnittene Wand steht im Schnitt vertikal (Bandachse entlang v,
Modellwinkel 90 Grad); dieser Winkel wird jetzt wie im Grundriss
(addWallPoche) mitgegeben -> Daemmung horizontal, wie an den Waenden
im Grundriss. 216 Tests gruen.
2026-07-04 14:54:10 +02:00
karim 20cf870c4c Schnitt: materialspezifische SIA-Schraffuren wiederhergestellt
de7a26b lieferte projectToModel3d pro SCHICHT eine eigene Voll-Box; der
Schnitt bekam damit segments*layers Cut-Polygone, waehrend
wallSegmentOwners weiter nur oeffnungsbasiert segmentiert -> Index-
Versatz, falscher Owner, splitWallLayers zerteilte bereits einschichtige
Baender erneut -> einheitliches Diagonal-Muster statt SIA-Muster.

Fix: Model3dOptions { layeredWalls } an projectToModel3d (Default true,
3D-Viewer unveraendert); computeSection ruft mit layeredWalls:false ->
eine Voll-Box pro Wand, Owner-Mapping wieder 1:1, splitWallLayers
erzeugt die per-Material-Baender (Daemmung-Haarlinien, Backstein-
Diagonale, Beton-Kreuz) wie im Grundriss. 216 Tests gruen.
2026-07-04 14:44:36 +02:00
karim e095b51d15 Schraffuren monochrom: Vordergrund #0f0f0f, Hintergrund #f0f0f0
Alle Schraffuren (Schnitt- UND Oberflaechen-/Ansichtsschraffur) rendern
jetzt einheitlich in Tinte #0f0f0f auf Papier #f0f0f0 statt in der
Materialfarbe des Bauteils (Backstein braun, Beton grau). HATCH_INK/
HATCH_PAPER ersetzen POCHE_FILL_BLACK/WHITE; resolveHatch liefert immer
HATCH_INK als Linienfarbe. Der generische Schnitt-Fallback nutzte bisher
die rohe 3D-Albedo (Ursache des Backstein-Brauns bei nicht aufloesbarer
Schicht) -> jetzt HATCH_PAPER. Print-Mono-Modus unberuehrt. 216 Tests gruen.
2026-07-04 14:33:35 +02:00
karim 65d6e843fb Fix Regression: 3D-Wandschichten auf linke Normale (Schnitt = Grundriss)
de7a26b versetzte die per-Schicht-Boxen entlang (uy,-ux) = RECHTE Normale,
waehrend Grundriss/clippedBand die LINKE Normale leftNormal(u)=(-uy,ux)
nutzt -> Schichten im 3D UND im davon abgeleiteten 2D-Schnitt gespiegelt
(Backstein aussen statt innen). pushSegment nutzt jetzt die linke Normale.
Wandlage unveraendert (Stack bleibt symmetrisch um die Achse), nur die
Schichtseiten stimmen wieder mit dem Grundriss. raycast3d unberuehrt
(symmetrische Box). 216 Tests gruen, kein WASM-Rebuild.
2026-07-04 14:22:12 +02:00
karim c79d15dda4 Joins Phase 1: materialbewusster T-Stoss (Backstein verschmilzt, Putz-L)
Am T-Stoss wird der Abzweig nicht mehr naiv ueber die volle Dicke an der
Durchgangswand-Flaeche gekappt. Neu (joinPriority-basiert):
- resolveJoinPriority(a,b): merge/coexist/trim je nach Prioritaet+Komponente.
- WallCuts.layerCuts (additiv): pro Schicht eine Cut-Linie + optionale L-Seiten-
  linie. Gleiche/hoehere Prioritaet wie der Durchgangs-Backbone -> kein Cut
  (Schicht laeuft durch = Merge); schwaechere Schicht -> Nahflaechen-Cut + L.
- addWallPoche nutzt layerCuts pro Schicht (Fallback: alte Aggregat-Cuts);
  neue 'layer-joint-l'-Linie als L-Rueckschnitt.
- Rust-Paritaet in geometry/lib.rs additiv gespiegelt (LayerInput serde default,
  Aggregat-Cuts bit-identisch -> parity.test gruen).

Einschichtige/nicht-passende T-Stoesse pixel-identisch. +8 Tests (216 gesamt),
cargo 7/7.
2026-07-04 14:13:55 +02:00
karim 6035b8a8c2 3D: Wand an Deckenunterkante trimmen (Prioritaets-Trim, kein Z-Fighting)
Wo eine Decke (hoehere joinPriority, z.B. Beton 100) buendig auf der
Wandoberkante sitzt, durchdrangen sich Wand- und Deckenvolumen exakt
ueber die Deckendicke -> Z-Fighting im 3D-Viewport. emitWall trimmt jetzt
zTop der Wand auf die Deckenunterkante, wenn eine Decke gleichen
Geschosses mit Footprint-Ueberlapp hoehere Prioritaet hat. Footprint-Test
ist eine Bounding-Box-Naeherung (dokumentiert). Rein TS-seitig vor dem
RWall-Export -> kein WASM-Rebuild. +3 Tests (208 gesamt).
2026-07-04 14:02:22 +02:00
karim 6c04935711 TopBar: Massstab-Cluster-Hoehe angleichen + mehr vertikale Luft
Der Massstab/Zoom-Cluster war hoeher als die uebrigen zweizeiligen
Kombos: die Stat-Pille hatte grid-row:1/span 2, aber die rechte Seite
ist ein einzelner Flex-Container -> leere Phantom-Zeile + Gap (+6px).
Cluster von Grid auf einfaches Flex umgestellt (jetzt 51px, buendig mit
den 52px-Kombos). Topbar-Hoehe 56->60px, Padding 4->6px fuer etwas
ausgewogenere Luft oben/unten.
2026-07-04 13:56:19 +02:00
karim 8c96a5dfb9 Renderer-Umschalter in die Settings; Nordstern als Default
Der WebGL2<->Nordstern-Umschalter wandert aus der Statusleiste in den
Settings-Dialog. Default ist jetzt Nordstern (WASM), sofern WebGPU
verfuegbar ist; WebGL2 nur noch als expliziter Fallback. So sieht man
nicht mehr versehentlich die Darstellungsfehler des WebGL2-Viewers.
styles.css: .sb-renderer-toggle align-self:flex-start, damit das Pill
im Settings-Dialog nicht auf volle Breite zieht.
2026-07-04 13:47:27 +02:00
karim 8547d38e9a Sample: innere Querwand W9 (Mauerwerk verputzt) als T-Stoss-Demo
Neuer Innenwandtyp 'iw' (Innenputz/Backstein/Innenputz 15 cm) und eine
Querwand W9 (x=2.4) im EG, die den Raum teilt und mit beiden Enden mittig
auf die Innenflaechen von W1/W3 stoesst -> zwei Mittelspannen-T-Stoesse.
Tuer + EG-Fenster so positioniert, dass beidseits von W9 massives
Mauerwerk sichtbar bleibt (klarer Anschluss-Nachweis).
2026-07-04 13:33:00 +02:00
karim 41d8fafa4e Geometry-Crate: T-Stoss-Verschneidung nach Rust portiert (TS<->Rust-Paritaet)
compute_joins behandelt jetzt wie die TS-Referenz auch T-Knoten (3 Enden,
kollineares Paar = Durchgang, Abzweig an dessen Flaeche geschnitten) und
Mittelspannen-Stoesse (freies Ende trifft Wandseite). miter_line/L-Ecke
bit-identisch. Rust-Tests aktualisiert (t_junction_branch_gets_face_cut,
mid_span_tee_free_end_hits_wall_side). cargo test + parity.test.ts gruen.
2026-07-04 13:33:00 +02:00
karim 0d3a0a081f Wand-T-Verbindungen im Grundriss: T-Knoten + Mittelspannen-Stoss
computeJoins behandelt jetzt neben L-Ecken auch T-Stoesse:
- T-Knoten (3 Wandenden am selben Punkt): das kollineare Paar bildet
  die Durchgangswand und bleibt ungeschnitten; das abzweigende Ende
  wird an der zugewandten Flaeche der Durchgangswand abgeschnitten.
- Mittelspannen-Stoss (freies Wandende trifft die Seite einer anderen
  Wand): das Ende wird an deren zugewandter Flaeche geschnitten.

L-Ecken bleiben bit-identisch (miterLine unveraendert, ends===2-Zweig
Zeile fuer Zeile gleich). Die Schnittlinien sind strukturell identisch
zu den bisherigen (Line|null) und werden von generatePlan/clippedBand
ohne Aenderung angewendet. +3 Tests (205 gesamt gruen), tsc clean.
2026-07-04 13:12:00 +02:00
karim d1df9d4d5c Boolean-Ops: vereinigte Form erbt volle Attribute des ersten Elements
ringDrawing kopierte nur color/hatchId/fillColor/lineStyleId/weightMm —
die neueren Attribut-Felder (background = Nachfolger von fillColor,
foreground + die By-Layer/By-Object-Quellen) gingen bei Union/Difference/
Intersection verloren. Dadurch verlor z. B. eine über das Attribut-Panel
rot gefüllte Fläche ihre Füllung nach dem Vereinigen. Jetzt erbt die
zusammengesetzte Form das volle Erscheinungsbild des ZUERST gewählten
Elements (Auswahl-Reihenfolge = Index 0). Füll-Attribute nur auf dem
gefüllten Aussenring, Strich-/Ebenen-Attribute auf jedem Ring.
2026-07-04 12:58:10 +02:00
karim e11743d6e7 HANDOVER: weitere Commits (z-Anordnen, Wand-Bänder, A6-CSV, Glasscheiben) ergänzt 2026-07-04 12:53:22 +02:00
karim c54a2f916c 3D: Glasscheiben in Fenstern (AUDIT A5-Teilschritt)
Jede Fensteröffnung bekommt im 3D eine dünne, leicht bläuliche
Glasscheibe (RMesh, mittig in der Wanddicke, UK=baseElevation+sillHeight
bis OK), damit Fenster als Fenster lesbar sind statt als Löcher. Türen
bleiben offen. Nur meshes-Ebene → pickGeometry/Highlight unberührt.
2026-07-04 12:53:01 +02:00
karim fde27f6838 AUDIT A6: Bauteil-Schedule als CSV exportieren
Neuer Datei-Menü-Eintrag „Bauteilliste (CSV)": eine Zeile je Wand/Decke
(Typ, ID, Bauteil, Geschoss, Länge, Höhe, Dicke, Fläche) plus Aggregat
je Bauteil-Typ, als CSV-Download. Kennwerte aus dem Modell abgeleitet
(Wandlänge aus Achse, wallTypeThickness, polygonArea), RFC-4180-Escaping.
2026-07-04 12:47:00 +02:00
karim 00c90857ad 3D: mehrschichtige Wände zeigen ihren Schichtaufbau
Statt eines Vollkörpers in der repräsentativen Farbe wird jede
Materiallage der Wand als eigener extrudierter Teilquader in ihrer
Component-Farbe emittiert (quer zur Wanddicke gestapelt, zentriert um
die Achse). Gilt auch für die Öffnungs-Teilquader (Pfeiler/Brüstung/
Sturz). Jede Schicht-Box trägt die wallId der Ursprungswand → Klick-
Auswahl + Highlight umfassen weiter die ganze Wand.
2026-07-04 12:40:25 +02:00
karim 018fef56bf 2D-Plan: z-Anordnen (nach vorn/hinten holen)
Selektierte 2D-Zeichnungselemente lassen sich jetzt in der Zeichen-
reihenfolge umsortieren — Kontextmenü „Ganz nach vorn / Nach vorn /
Nach hinten / Ganz nach hinten". Reine Array-Umsortierung in
project.drawings2d (spätere Position = optisch oben), Undo-fähig über
setProject. reorderDrawings(ids, front|forward|backward|back).
2026-07-04 12:40:25 +02:00
karim 1db661f19a HANDOVER: Stand 2026-07-03 (TopBar-Redesign + 3D-Editierbarkeit R1-R5) + 3D-Rest notiert 2026-07-04 06:32:48 +02:00
karim 6ea562ceda 3D: Bauteil-/Materialfarbe statt Einheitsgrau für Wände & Decken
projectToModel3d trug bisher für alle Wände WALL_RGB und alle Decken
SLAB_RGB (konstant grau), obwohl die Component-Materialfarbe verfügbar
ist. Jetzt: repräsentative Farbe der dicksten Schicht (tragende/
dominante Lage) je Wandtyp bzw. Deckentyp, Fallback auf die Konstanten.
Reine Albedo-Änderung — Geometrie, Picking und Highlight unberührt.
Schicht-Teilquader je Materiallage (echter 3D-Wandaufbau) bleibt als
Folgeschritt vermerkt.
2026-07-04 06:31:48 +02:00
karim e137353b95 Nordstern-3D: Auswahl-Highlight (orange Outline, immer sichtbar)
Ein selektiertes Bauteil wird jetzt im 3D-Viewport mit einer orangen
Umrisslinie markiert, die dank No-Depth-Pipeline auch hinter Wänden
durchscheint (klare Selektions-Rückmeldung). Zusammen mit Objekt-Info
(numerisch) und Attribute-Panel ist die 3D-Auswahl damit vollständig.

- Engine: set_highlight_lines(vertices) + highlight_pipeline
  (LineList, depth_compare Always, kein Depth-Write), als letzter
  Draw-Call obenauf. Reuse der Grid-Linien-Infrastruktur.
- TS: selectionHighlightLines() baut die Quader-/Prisma-Kanten der
  Auswahl in Akzentfarbe; App berechnet sie per useMemo aus der
  Store-Auswahl und reicht sie durch.
2026-07-04 06:26:29 +02:00
karim 16d32223a4 Nordstern-3D: Klick-Auswahl von Bauteilen (Raycast)
Links-Klick auf eine Wand/Decke im 3D-Viewport selektiert das Bauteil
(TS-Raycast gegen OBB der Wände + extrudierte Deckenprismen), setzt die
Store-Selektion (setSelectedWallIds/setSelectedCeilingIds, Shift/Ctrl =
additiv) → Attribute- und Objekt-Info-Panel zeigen das Bauteil. Erster
Schritt zur 3D-Editierbarkeit.

- raycast3d.ts: reine Kamera-Strahl-/Schnitt-Mathematik (10 Unit-Tests).
- toWalls3d.ts: wallId/ceilingId in die 3D-Records + pickGeometry();
  Serde-Pfad unberührt (keine deny_unknown_fields, Extrafelder werden
  Rust-seitig ignoriert).
- Klick-vs-Drag-Schwelle (4px) trennt Auswahl von Orbit; Leertreffer
  leert die Auswahl.
2026-07-04 06:17:45 +02:00
karim 8f4fac633f Nordstern-3D: View-Styles wireframe + hidden-line + schönere Schattierung
- Neue set_render_style(style)-Methode: shaded/white/wireframe/hidden
  (textured fällt vorerst auf shaded zurück, bis die Textur-Pipeline
  steht). Voll durchverdrahtet vom TopBar-Darstellungs-Dropdown zum
  Nordstern-Viewport (setRenderStyle statt nur setWhiteMode).
- Feature-Edge-Extraktion (edges.rs): Kanten aus dem Mesh, dedupliziert
  + Crease-Erkennung (nur Silhouette/Knickkanten, keine Triangulierungs-
  Diagonalen) → sauberes Architektur-Drahtgitter. Reuse der Grid-
  LineList-Pipeline.
- wireframe = nur Kanten; hidden = flach-weisse Flächen (Depth-Bias) +
  sichtbare Kanten obenauf (klassischer Hidden-Line-Look).
- Grundschattierung: dezentes Gegenlicht (Fill-Light), damit abgewandte
  Flächen nicht mehr in Schwarz absaufen.
2026-07-04 06:05:11 +02:00
karim d4065c5a41 Nordstern-3D: Boden-Referenzraster + bessere Kamera-Steuerung
- Boden-Grid-Fläche auf y = baseElevation des aktiven Geschosses,
  ein-/ausschaltbar (Overlay-Button im Viewport, State in viewSlice
  grid3dVisible). Eigene LineList-Pipeline in der Engine (grid.rs,
  GRID_WGSL, geteilte View-Projection/Depth mit der Mesh-Pipeline),
  neue set_ground_grid(visible, elevation, extent, spacing)-Methode.
  Minor-Linien dezent, jede 5. als Major betont.
- Kamera-Steuerung überarbeitet (vorher nur Mitte=Orbit, Pan auf
  Shift+Mitte versteckt): Links=Orbit, Mitte/Rechts=Pan (bewegen),
  Rad=Zoom zum Cursor. Laptop-freundlich, konsistent mit der
  2D-Plan-Navigation (dort Mitte=Pan).
2026-07-04 05:54:16 +02:00
karim d0b94a75ec Zeilenhöhe-Regler statt "+ Text"; Linienstil editierbar; DOSSIER-Audit gesichert
- "+ Text"-Knopf entfernt (war ohnehin immer deaktiviert, kein Werkzeug
  dahinter — Text wird ein eigenständiges Zeichenwerkzeug, AUDIT B1).
  An seiner Stelle ein Zeilenhöhe-Regler (Stepper, Vielfaches der
  Schriftgrösse) — neues Paragraph.lineHeight, durchgereicht bis in
  HTML-Vorschau (line-height) und SVG-Render (kumulierte Zeilen-
  Vorschübe statt festem lineGap).
- Linienstil im Attribute-Panel war rein informativ (kein Setter im
  Host-Kontrakt). onSetSelectionLineStyle ergänzt (nur Drawing2D),
  jetzt echtes Dropdown statt "—"-Anzeige.
- docs/design/dossier-feature-audit.md: der ausführliche A1-A6/B1-B4/
  C1-C3/D1-D3/E-Auditbericht aus einer alten Session gesichert (lag
  bisher nur im Transkript, nicht im Repo) — Quelle der HANDOVER.md-
  AUDIT-Kürzel.
2026-07-04 05:36:18 +02:00
karim 8b6b9291a1 15 gebündelte Open-Source-Schriften statt Systemfonts
Text-Werkzeug nutzte bisher OS-abhängige Systemfonts (Helvetica/Arial/
Times/Georgia/Courier) — Darstellung und PDF-Export waren damit nicht
plattformunabhängig deterministisch. Jetzt 15 kuratierte SIL-OFL-
Schriften lokal als WOFF2 gebündelt (je mit OFL.txt-Lizenz):
Inter, Work Sans, IBM Plex Sans/Mono, Source Sans 3, Space Grotesk,
Manrope, Outfit, DM Sans, Public Sans, Karla, Rubik, Jost, Archivo,
Source Serif 4. Ein späterer Einstellungen-Dialog kann weitere
Schriften nachladen.
2026-07-04 05:19:53 +02:00
karim 7e8764b21b Hex-Feld breiter + helleren Text (voller Hex-Code sichtbar) 2026-07-04 05:17:29 +02:00
karim efc9dd87ce Farbfelder: Hex-Code separat editierbar, Swatch öffnet die Palette
ColorHexField (neu, geteilt) trennt die zwei Klickziele: Swatch/
natives Farb-Input öffnet weiter den Picker, der Hex-Text daneben ist
jetzt ein eigenes Eingabefeld (Enter übernimmt, Esc verwirft, ungültige
Eingabe fällt auf den letzten gültigen Wert zurück). Ersetzt die
bisherige reine Text-Anzeige in SettingsDialog + AttributesPanel.
2026-07-04 05:15:42 +02:00
karim f724c088e0 TopBar-Redesign: Marke, Zoom-Cluster, Datei-Menü, Fenstersteuerung, Dark-Theme fest
- Marke (DOSSIER-Wortmarke) + Ressourcen-Icon ganz links, wie im
  Rhino-Plugin; Settings-Icon folgt über den neuen Einstellungs-Dialog.
- Massstab/Zoom-Cluster bereinigt: doppelte Massstab-Dropdown entfernt,
  Zoom-Aktionen unter dem Massstab-Dropdown gestapelt (gleiche Breite),
  Ring bei "am Massstab" statt Flächen-Aufhellung.
- PDF/DXF-Export aus der Leiste raus, zusammen mit Speichern/Öffnen/
  Import in einem neuen Datei-Burger-Menü ganz rechts. Speichern/Öffnen
  sind echte JSON-Download/-Upload-Handler fürs ganze Projekt.
- Einstellungs-Fenster (neu): Accent-Palette, Auswahlrahmen-/Snap-Farbe
  editierbar (freier Picker + Presets), Projekt-Referenzhöhe (m ü. M.)
  als Feld.
- LayoutMenu auf die eigene Dropdown-Komponente umgestellt (war noch
  natives <select>).
- Custom Fenstersteuerung (_/□/X) für die randlose Electron-Shell via
  Preload+IPC, eigener Look statt OS-Chrome; Oberleiste als Drag-Region.
- Dark-Theme ist jetzt fester Standard (vorher an OS-Präferenz
  gekoppelt, zeigte je nach Umgebung fälschlich Light) — Light bleibt
  als Opt-in (`[data-theme="light"]`) für einen künftigen Umschalter.
  Alle Oberleisten-Pillen (Dropdown-Trigger, Segmente, Icon-Knöpfe,
  Ansichts-Icons) auf dieselbe dunkle Fläche wie das Kontextmenü
  vereinheitlicht. Panel-Köpfe nicht mehr separat aufgehellt, feste
  Höhe (40px) unabhängig von optionalem Darstellungsmodus-Dropdown.
- 2D-Zoom-Grenzen deutlich erweitert (20000x/0.005x statt 50x/0.2x).
2026-07-04 05:10:44 +02:00
karim 44942e6976 Undo/Redo für Projekt-Änderungen
setProject wird jetzt von einer History gewrappt (undoStack/redoStack,
Limit 100). Echte Aenderungen (Referenzvergleich) landen auf dem
Undo-Stack, eine neue Aktion leert den Redo-Stack. Hochfrequente
Drag-Mutatoren (Griffe/Body-Move) bekommen einen coalesceKey, damit
ein Drag nicht hunderte Einzelschritte erzeugt. Tastatur-Bindung
(Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y) folgt im TopBar-Commit (App.tsx).
2026-07-04 05:01:15 +02:00
karim e0d71691e1 Native Selects auf einheitliche Dropdown-Komponente umgestellt
Mehrere Stellen (ResourceManager SelectField + Material-Browser,
ImportDialog, AttributesPanel, SitePanel, ToolsPanel,
DisplayModeSelect, RichTextEditor) nutzten noch native <select>, deren
Optionsliste vom Betriebssystem gerendert wird und optisch nicht zur
eigenen Dropdown-Komponente (dunkles Kontextmenü-Popover) passt. Jetzt
durchgaengig dieselbe Optik.
2026-07-04 05:01:03 +02:00
karim 9f6d4e8858 Nordstern: Geo-Kontext-Meshes (Terrain/Import) rendern
Bisher zeigte nur three.js importierte Gebaeude/DXF-Meshes und das
Terrain-TIN (Project.context). Neuer MeshInput-Typ + Kontext-Mesh-Pfad
in render3d (Flat-Normalen, doppelseitig gegen unbekanntes Winding),
projectToModel3d speist project.context jetzt in beide Renderer
(nativ + WASM) ein. Nebenbei: fehlendes layers-Feld in demo_walls()
behoben, das den native3d-Feature-Build zuvor schon brach.
2026-07-04 04:38:00 +02:00
karim 19d002d403 ResourceManager: Bauteile-Tab auf Master-Detail-Layout umgestellt
Wie bei Schraffuren/Linien/Wandstile: Liste links, Detail-Panel rechts.
Vorarbeit vor der eigentlichen Bauteil-Logik. Alte Tabellen-Bausteine
(ResTable/ResRow/ResCell/AddButton) entfernt, da nicht mehr genutzt.
2026-07-04 04:25:11 +02:00
karim 2c9633438d Nordstern: Iso-Praeset auf echte orthografische Projektion korrigiert
CameraPreset::Iso war faelschlich als Perspective definiert; three.js
zeigt bei Iso bereits korrekt orthografisch (parallele Projektion, wie
bei einer echten Isometrie definiert). Front/Top/Side/Iso sind jetzt
konsistent orthografisch, nur Perspective bleibt perspektivisch.
2026-07-04 04:24:02 +02:00
karim 19fcafbcab Ebene-Schraffur editierbar im Kategorie-Dialog
Nach-Ebene-Schraffur war in der Resolve-Logik bereits fertig, aber
LayerCategory.hatch liess sich im Kategorie-Editor nicht setzen.
Dropdown mit Vorschau ergaenzt, patcht ueber patchCategory.
2026-07-04 04:19:55 +02:00
karim 4aed168760 HANDOVER: 3 ungeklärte Punkte festhalten (Isometrie, TopBar Detail, DOSSIER konkret) 2026-07-04 02:30:56 +02:00
karim 2eeafb9aca HANDOVER: Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen, offener Backlog) 2026-07-04 02:23:33 +02:00
karim 7d3e0943e1 Snap an Wand-Schichttrennlinien
Die Grenzlinien zwischen den Wandschichten (achsparallel, aus der addWallPoche-
Offset-Logik gespiegelt: refOff-total/2, je Schicht +thickness, n+1 Linien inkl.
Aussenflaechen) werden als Snap-Segmente in collectSegments eingereiht. Damit
fangen die bestehenden Snap-Arten (Endpunkt/Mittelpunkt/Schnittpunkt/Lot)
automatisch daran — Decken/Elemente koennen an einzelnen Wandschichten fangen.
Kein neuer SnapKind/Toggle noetig (Default-aktiv). 4 neue Tests, 156 gruen.
2026-07-04 02:23:33 +02:00
karim d0f26cd874 Attribute: By-Layer/By-Object-Quelle fuer Farbe, Strichstaerke, Schraffur
Vordergrund, Hintergrund, Strichstaerke und Schraffur je Element (Wand/Decke/
Drawing2D) haben jetzt einen 3-Wege-Quellen-Dropdown: Nach Ebene / Nach
Bauteil / eigener Wert. Aufloesungsreihenfolge: expliziter Wert > (Quelle
'layer' => LayerCategory-Wert color/lw/hatch) > Bauteil/LineStyle-Default.
Neue *Source-Felder + strokeWeight/hatchId-Overrides am Modell (additiv,
optional), getLayerCategory-Accessor, resolveHatchId/resolveStrokeWeight;
resolveForeground/Background um category+source erweitert. Sample rendert
identisch (kein Override/Source gesetzt => Default 'object' = altes Verhalten).
Offen: LayerCategory-Schraffur noch nicht im Kategorie-Dialog editierbar.
2026-07-04 02:19:38 +02:00
karim 777e02c927 ResourceManager: Wandstile + Deckenstile im Master-Detail-Layout
Wand- und Deckenstile bekommen dasselbe Master-Detail wie Schraffuren/Linien:
Liste links mit Querschnitt-Thumbnail + Name, Detail rechts mit grossem
Querschnitt und Aufbau-Editor (Schichten: Bauteil/Dicke/Fugenlinie,
hinzufuegen/entfernen/umordnen), Inline-Rename, Trash-Delete, Neu anlegen.
Neuer WallTypeSwatch (hatchPreview) zeichnet die geschichtete Wand/Decke als
proportionale Baender mit den Bauteil-Schnittschraffuren. Add/Delete-Handler
(addWallType/deleteWallType/addCeilingType/deleteCeilingType, In-Use-Schutz)
in App.tsx + host ergaenzt.
2026-07-04 01:53:52 +02:00
karim c34b7e5771 Schnitt: Boolean-Dominanz der Schichten nach joinPriority
Wo sich Schicht-Baender verschiedener Elemente im Schnitt ueberlappen, gewinnt
die staerkere Schicht (hoehere joinPriority) und schneidet die schwaechere per
Rechteck-Subtraktion weg (elementuebergreifend: die Betondecke schneidet den
Wand-Putz). Gleiche Prioritaet koexistiert (durchgehende Betonflaeche Wand+
Decke). attachCutStyles sammelt jetzt alle Baender mit joinPriority und laesst
subtractDominantBands global gegen alle staerkeren Cutter subtrahieren; die
ueberlappungsfreie Zerlegung liefert exakt die Material-/Fugenlinien zwischen
den Schichten. Neuer subtractRect-Helfer, 11 neue Tests, 152 gruen.
2026-07-04 01:46:01 +02:00
karim b55ab9eb8f Schnitt: Wandschichten nach echter Aussen/Innen-Richtung orientieren
splitWallLayers legte die Schichtbaender bisher fix layers[0]->uMin, ohne die
tatsaechliche Wandorientierung — gegenueberliegende Waende sahen identisch aus
statt gespiegelt. Jetzt wird die Aufbaurichtung aus der Grundriss-Konvention
gespiegelt (Schichten stapeln entlang leftNormal der Wandachse), gegen die
Schnitt-u-Weltachse (aus der SectionPlane) projiziert; ist das Skalarprodukt
negativ, wird die Bandreihenfolge umgekehrt (layers[0]->uMax). Damit liegt die
Daemmung aussen, der Backstein innen, und gegenueberliegende Waende sind
spiegelbildlich. Neue Helfer sectionUAxisModel/wallLayersReversedInU, additiv
durch attachCutStyles durchgereicht. 6 neue Tests, 141 gruen.
2026-07-04 01:39:26 +02:00
karim bd13495923 swisstopo: Gebaeude als Volumen + Terrain-Mesh in die 3D-Ansicht
Gebaeude-Footprints (swisstopo vec25) werden jetzt zu Volumen extrudiert
(z=0 bis Hoehe; Hoehe aus OSM height/building:levels, sonst 9 m; Deckel/
Boden via Delaunay, konkave Grundrisse) und als importedMesh gerendert — der
flache Footprint-contourSet bleibt fuer den 2D-Plan erhalten. Neuer
Terrain-Abruf (swissALTI3D ueber den CORS-faehigen profile.json-Dienst,
zeilenweise parallel) liefert ein N-Raster, das terrainMeshFromGrid
trianguliert; Hoehen aufs Zentrum bezogen, lagerichtig zu den Gebaeuden.
Neue Quelle 'Swisstopo-Gelaende' im Import-Dialog. Viewport3D unveraendert
(bestehender importedMesh/terrainMesh-Pfad). 8 neue Tests.
2026-07-04 01:33:51 +02:00
karim 683242ba78 ResourceManager: schwebendes, nicht-modales Fenster statt Modal
Der Ressourcen-Editor ist von einem blockierenden Modal (dunkler Backdrop,
Canvas gesperrt) zu einem frei schwebenden Fenster geworden: verschiebbar
ueber die Kopfzeile (Pointer-Capture-Drag), unten rechts resizable, KEIN
Backdrop — der Plan bleibt bedienbar, sodass Schraffur-/Linien-Aenderungen
live am Modell sichtbar sind. Der Ressourcen-Button bleibt Toggle, Esc
schliesst weiter. FloatingPanel-Bausteine wiederverwendet; die interne
Tab-/Master-Detail-Struktur unveraendert.
2026-07-04 01:30:13 +02:00
karim e54bb318bb Statusleiste: Renderer-Umschalter 'Engine' -> 'Nordstern' 2026-07-04 01:28:26 +02:00
karim a302d512d9 Linien: modulares Segment-System (Strich/Punkt/Luecke) + Loop-Vorschau
Der Linien-Editor ist modular: eine Linie ist eine geordnete, loopende Folge
aus Segmenten Strich (Laenge), Punkt (Dot) und Luecke (Laenge) — beliebige
Sequenzen (Volllinie/Strichlinie/Punktlinie/Strich-Punkt als Presets, plus
frei), die Schluss-Luecke ist die letzte Luecke. Datenbasis bleibt
LineStyle.dash (mm, alternierend); ein Punkt ist ein 0-Laengen-AN-Segment.
Enthaelt dash eine 0, wird die Linie mit runder Kappe gezeichnet, damit
Punkte als Dots erscheinen (Live + Print; GL/DXF Folgearbeit). Neue reine
Segment-Logik in ui/lineSegments.ts. LineSwatch zeigt den ersten Loop dunkel
und 2 weitere grau (Loop-Kontext). 14 neue Tests, 127 gruen.
2026-07-04 01:21:04 +02:00
karim f09ba0e49f Random-Schraffur modellraum-verankert + Motiv-Editor fuer Custom-Linien
Random-Verankerung (Bugfix): die Streu-Striche haengen nicht mehr an der
Polygon-Bounding-Box, sondern an einem absoluten Modellraum-Gitter
(hashCell aus absoluten Zell-Indizes + hatch.seed). Beim Vergroessern der
Flaeche bleiben bestehende Striche stehen, am Rand kommen neue dazu, die
Dichte bleibt konstant, Verschieben wandert nicht; 'Neu wuerfeln' (neuer
Seed) verschiebt das ganze Feld. Alle Renderpfade ziehen aus derselben
Funktion. Verankerungs- und Verschiebe-Dichte-Test ergaenzt.

Motiv-Editor: neuer wiederverwendbarer MotifEditor (Punkte setzen/ziehen in
einer Einheitszelle, Live-Loop-Vorschau). LineStyle.kind 'custom' + motif
(points/length), additiv durchgereicht (analog zigzag) und in allen
Renderpfaden gekachelt (motifPoints, Verallgemeinerung von zigzagPoints).
ResourceManager-Umschalter Vollinie/Strich/Zickzack/Motiv, LineSwatch-
Vorschau. Insgesamt 5 neue Tests, 113 gruen.
2026-07-04 01:09:00 +02:00
karim 07475cbe6c Linien-/Random-Editor: Strich-Segmente, Vollinie, Random-Kontrollen; Weight raus
Linien-Editor: Umschalter Vollinie (dash null) / Strich mit editierbarer
Segment-Liste (mm, alternierend Strich/Luecke, hinzufuegen/entfernen), Live-
Vorschau. Das Strichstaerken-Feld ist aus dem Linien-Editor entfernt —
LineStyle.weight ist @deprecated (bleibt Render-Fallback), die echte
per-Element-Aufloesung (Attribut->Ebene->Default) folgt separat.

Random-Schraffur: additive HatchStyle-Parameter seed/density/lengthMin/Max +
Neu-wuerfeln-Button (neuer Seed nur zur Editzeit). buildRandomHatchRuns mischt
den expliziten Seed in den Hash und nutzt Dichte/Laenge — Determinismus
gewahrt (gleicher Seed+Flaeche => gleiche Streuung), ueber alle Renderpfade
konsistent, da alle aus derselben Funktion ziehen. resolveHatch/HatchRender
reichen die Parameter rein additiv durch. 2 neue Tests.
2026-07-04 00:53:19 +02:00
karim 87ef7988d1 ResourceManager: Master-Detail-Editoren fuer Schraffur- und Linien-Typen
Schraffuren- und Linien-Tab im Master-Detail-Layout (DOSSIER-Vorbild): Liste
links mit Live-SVG-Thumbnail + Name, Detail rechts mit grosser Live-Vorschau,
Inline-Rename, Trash-Delete, Typ-Umschalter. Schraffur: Vektor (parallel/
zufaellig) oder Bild (Upload -> Data-URL, scaleX/scaleY/rotation); kein
Farbfeld mehr (Farbe kommt von Bauteil/Attribut). Linie: Strich oder Zickzack
(Amplitude/Wellenlaenge). Bauteil-Tabelle: Vordergrund + Hintergrund statt
einer Farbe. Neuer eigenstaendiger Vorschau-Helfer ui/hatchPreview.tsx (kein
Renderer-Import).
2026-07-04 00:41:09 +02:00
karim f3966f99e9 Schraffur/Linien: Rendering der neuen Typen (Bild, Random, Zickzack)
Bild-Schraffur als getiltes <pattern>/<image> (scaleX/scaleY/rotation) im
Live-SVG- und Print-Pfad; GL/WASM/DXF vorerst neutraler Fallback (Folgearbeit,
im Code vermerkt). Random-Vektor-Schraffur als deterministische Streu-Striche
(mulberry32-Seed aus Flaechen-Bounding-Box, kein Math.random) in allen Pfaden.
Zickzack-Linie als getilteter Pfad; LineStyle.kind/zigzag additiv durch die
Linien-Emission (generatePlan) bis zu den Renderern durchgereicht. Geteilte
Geometrie in glPlanHatch (scatterStrokes/buildRandomHatchRuns/zigzagPoints) —
eine Wahrheit fuer Live/Print/GL/DXF. 8 neue Tests.
2026-07-04 00:41:09 +02:00
karim f3639cfb15 Attribut-Panel: Vordergrund/Hintergrund mit 'Nach System'
Die Attribut-Sektion 'Fuellung' zeigt statt der einen Fuellfarbe jetzt zwei
Felder: Vordergrund (Muster-/Schraffurfarbe) und Hintergrund (Fuellung),
jeweils per Element ueberschreibbar. Leerer Override = 'Nach System' (erbt);
Farbe waehlen setzt den Override, x loescht ihn wieder. Schreibt
Wall/Ceiling/Drawing2D.foreground/background ueber neue Store-Mutationen
(setElementForeground/Background), gespiegelt zum bestehenden Fuellfarbe-Pfad.
Die Resolve-Schicht (generatePlan) liest diese Overrides bereits, wirkt also
sofort im Plan.
2026-07-04 00:26:32 +02:00
karim 6288f755a8 Schraffur/Linien: Typmodell-Fundament (additiv, backward-kompatibel)
Vorbereitung fuer Vektor-/Bild-Schraffuren, Strich-/Zickzack-Linien und
Vordergrund/Hintergrund-Farben ohne Verhaltensaenderung. Alle neuen Felder
optional, Sample rendert identisch:
- HatchStyle: kind (vector/image), lines (parallel/random), image
  (src/scaleX/scaleY/rotation); color deprecated, bleibt Resolve-Fallback.
- LineStyle: kind (dash/zigzag), zigzag (amplitude/wavelength).
- Component: foreground (Muster) + background (Fuellung); color bleibt
  Fallback und 3D-Albedo.
- Wall/Ceiling/Drawing2D: foreground/background-Overrides (undefined = Nach
  System).
- generatePlan: HatchRender um kind/lines/image erweitert; resolveForeground/
  resolveBackground mit dokumentierter Reihenfolge (Override -> Component ->
  Alt-Farbe); toSection reicht Vordergrund je Schicht durch.
2026-07-04 00:17:28 +02:00
karim 724db0f9bf Kantenverschieben faengt an aktiven Geometrie-Snaps
Beim Verschieben einer Kante ueber den Dreiecks-Griff wird der Cursor jetzt
durch die Snap-Engine (snapFor) gefuehrt: bei einem Geometrie-Snap
(Endpunkt/Mittelpunkt/Schnittpunkt) wird der gefangene Punkt statt des rohen
Cursors auf die Kanten-Normale projiziert — die Kante rastet an reale
Geometrie statt nur ans Raster. Ohne Geometrie-Snap bleibt die bisherige
Raster-Rundung. Der freie Delta-Fall bleibt unveraendert.
2026-07-04 00:17:28 +02:00
karim c2d6893a59 Verschiebe-Dreieck flacher (Apex ~124 statt ~60 Grad) 2026-07-04 00:07:41 +02:00
karim c6d2641664 Tool-Shortcuts 1..0 (Vectorworks-Stil)
Nummerntasten waehlen Zeichenwerkzeuge: 2 Linie, 4 Rechteck, 5 Polylinie,
6 Wand, 7 Decke, 8 Fenster, 9 Tuere, 0 Raum. Nur auf Geschoss-Tabs, nicht
beim Tippen in Feldern und nicht mit Ctrl/Cmd/Alt. 1 (Text) und 3 (Kreis)
bleiben vorerst frei — dafuer fehlt noch ein Werkzeug.
2026-07-04 00:07:41 +02:00
karim 3d4c4985a9 Zoom-Prozent relativ zum angewandten Massstab statt zur Einpassung
Die Zoom-Anzeige band 100% bisher an die eingepasste Default-Ansicht, nicht
an den angewandten Papier-Massstab — der Wert driftete willkürlich. Er wird
jetzt in App.tsx aus angewandtem Nenner und Live-Nenner abgeleitet
(scaleDenominator / liveScale * 100): im angewandten Massstab exakt 100%,
hineinzoomen >100%, herauszoomen <100%. Die fit-basierte onZoom-Meldekette
aus PlanView entfällt (Single Source).
2026-07-04 00:02:50 +02:00
karim 912f0bc16d Gehrung (Rust): Flächenpaarung orientierungsbasiert (Parität zu joins.ts)
Der Rust-Port der Wandecken-Gehrung nutzte weiter die Distanz-Heuristik und
hatte damit denselben Spitzwinkel-Bug wie der TS-Pfad vor a2c8fcd. Die
Paarung erfolgt jetzt vorzeichenbasiert ueber die Achs-Auslaufrichtungen
(dot(n, d)) — Aussen mit Aussen, Innen mit Innen. Rechte/stumpfe Ecken
bleiben bit-identisch; native und WASM/TS-Renderpfad stimmen bei spitzen
Ecken wieder ueberein. Neuer Spitzwinkel-Test.
2026-07-03 23:57:08 +02:00
karim ac7af9b9ef TopBar: Text-Gruppe zwischen Ebenen-Kombis und Detailgrad/Massstab 2026-07-03 23:57:08 +02:00
karim 400ec890b0 Grundriss-Gehrung: Flächenpaarung orientierungsbasiert statt per Distanz
An spitzen Wandecken wählte die Distanz-Heuristik in miterLine die falsche
B-Fläche und lieferte eine um 90° verdrehte Gehrungslinie — die Poché-Bänder
überlappten kreuzweise. Die Paarung erfolgt jetzt orientierungsbasiert: über
die Auslaufrichtungen der Achsen (dot(n, d)) wird Aussenfläche mit Aussen-,
Innen mit Innenfläche verschnitten, nie über Kreuz — für jeden Winkel. Rechte
und stumpfe Ecken bleiben bit-identisch (Rust-Parität gewahrt). Kein
Miter-Limit; die Gehrungslinie ist geometrisch exakt. Neuer Test joins.test.ts
mit rechtwinkligem und spitzwinkligem Fall (fängt den alten Bug).
2026-07-03 23:53:25 +02:00
karim 558a24cf98 Schnitt: Wandquerschnitte an den gemitterten Fussabdruck koppeln
Der Schnitt-Extraktor schnitt die Wand-Cut-Polygone bisher gegen
achsparallele Einzelwand-Rechtecke ohne Eckverschneidung — an einer
Wandecke ueberlappten dadurch zwei Querschnitte. mesh.rs exponiert jetzt
den gemitterten Fussabdruck (wall_mitered_footprints, dieselbe Gehrung wie
das 3D-Mesh) als geteilte Wahrheit; section.rs bildet die Cut-Polygone
gegen diesen Fussabdruck. Die achsparallele Naeherung fuer Verdeckung/
Projektionskanten bleibt bewusst unveraendert.
2026-07-03 23:52:33 +02:00
karim e29f5a2a7c TopBar/Footer: Referenzlinien in die Statusleiste, Ansichten bleiben sichtbar
Der Referenzlinien-Schalter wandert aus der TopBar in die Statusleiste
(neben Fang) — dort passt er besser zu den übrigen Anzeige-/Fang-Kontrollen.
Die doppelt gezeigten Werte Massstab und Zoom fallen aus der Statusleiste
weg; sie stehen weiterhin im TopBar-Cluster. Die Ansichts-Icons blenden sich
auf Nicht-Geschoss-Ebenen (Schnitt/Ansicht/2D) nicht mehr komplett aus,
sondern bleiben sichtbar und werden nur deaktiviert.
2026-07-03 23:52:26 +02:00
karim b03614c35b Dockbare Panel-Tabs nur als Symbol
Die Tab-Leiste zeigt je Panel nur noch ein Inline-SVG-Icon; der Titel
wandert in Tooltip (title) und aria-label. PanelDef bekommt ein optionales
icon-Feld, die sieben eingebauten Panels je ein schlichtes stroke-Icon
(currentColor, erbt die Tab-Farbe). Fehlt ein Icon (Plugin), fällt der Tab
auf den Titeltext zurück. Tabs sind jetzt kompakt und quadratisch; Klick-,
Drag- und Dock-Logik unverändert.
2026-07-03 23:42:39 +02:00
karim 1086225f7b Mehrschichtige Wände im Schnitt als Schicht-Bänder
Analog zur Decke (splitSlabLayers) wird ein mehrschichtiger Wandtyp im
Schnitt nicht mehr mit einer repräsentativen Schraffur über die ganze
Dicke gefüllt, sondern in Schicht-Bänder zerlegt — quer zur Dicke entlang
u (volle Höhe je Band), jedes Band mit der Schnittschraffur seines
Bauteils. Einschicht-Wände laufen unverändert über resolveWallSectionStyle.
Seitenorientierung (aussen/innen) vorerst deterministisch layers[0]→uMin;
korrekte Orientierung via Wandnormale bleibt Folge-Iteration.
2026-07-03 23:36:06 +02:00
karim ca1ed1b0d2 Zeichnungsebenen-Panel: vier einklappbare Kategorien
Die flache Ebenenliste ist in vier einklappbare Kategorien gegliedert —
Geschosse/Schnitte/Ansichten/2D-Zeichnungen nach drawingLevel.kind. Jeder
Kopf trägt ein Chevron und rechtsbündig ein blankes +-Glyph (kein
Button-Chrome), das je Kategorie eine neue Ebene der passenden Art anlegt;
leere Kategorien bleiben mit Kopf und + sichtbar. Für Schnitt und Ansicht
sind dafür neue Platzhalter-Aktionen addSection/addElevation ergänzt
(analog addFloor/addDrawing, noch ohne Schnittlinie).
2026-07-03 23:31:39 +02:00
karim 04c7334cc4 Bauteil: Ansichtsschraffur neben Schnittschraffur
Bauteil trägt jetzt zwei Schraffuren: die bestehende hatchId als
Schnittschraffur (nur wo tatsächlich aufgeschnitten) und ein neues
optionales Feld viewHatchId als Ansichtsschraffur für die frontale,
ungeschnittene Sicht. Die Deckenpoché im Grundriss — die Decke liegt
über der horizontalen Schnittebene, wird also frontal gesehen — nutzt
nun die Ansichtsschraffur (Default weiss/leer) statt pauschal keiner.
Schnitt-Pfade und die echt geschnittene Wand-Grundriss-Poché bleiben
auf der Schnittschraffur. Editierbar im Ressourcen-Manager.
2026-07-03 23:30:48 +02:00
karim 93b70fa95a Deckenpoché im Grundriss ohne Schraffur
Die Decke wird in der Aufsicht frontal gesehen, nicht aufgeschnitten
— Bauteil-Schraffuren gehoeren nur auf Schnittflaechen. Die neue
3-schichtige Betondecke zeigte sonst ihre dichte Schraffur ueber dem
ganzen Raum im Grundriss.
2026-07-03 23:11:42 +02:00
karim fae4f6fcb6 Deckenstile: solide oder mehrschichtige Decke
Analog zu den Wandstilen erhalten Decken jetzt einen eigenen
Deckentyp mit Schichtaufbau (CeilingType, ceilingTypeId). Der
Schichtaufbau erscheint nur im Schnitt als gestapelte Bänder von OK
bis UK; der Grundriss bleibt eine flächige Poché wie bisher, da die
Schichtung von oben ohnehin nicht sichtbar ist. Auswahl solide/
mehrschichtig im Objektinfo-Panel wie bei Wänden, neuer
"Deckenstile"-Tab im Ressourcen-Manager mit Fugenlinien je Schicht.
2026-07-03 22:09:02 +02:00
karim d65d84183a Zeichnungsebenen merken sich eigenen 2D/3D-Zustand
Bisher war viewType (Grundriss/Perspektive) ein einziger globaler
Wert. Beim Tab-Wechsel blieb er unverändert stehen, was wirkte, als
würde die Ansicht "durchsickern". Jede Ebene merkt sich jetzt ihren
zuletzt gewählten Ansichtstyp und stellt ihn beim Zurückwechseln
wieder her.
2026-07-03 21:51:51 +02:00
karim 1f6da09b2b render3d: Wandverschneidung an Ecken + sichtbare Schichten
mesh.rs berechnet die Ecken-Verschneidung jetzt selbst (portiert aus dem
Web-Kern computeJoins/clippedBand): Wandenden werden per Rundungs-Key
gruppiert, ueber Hoehen-Ueberlappung geclustert (gestapelte Geschosse mit
gleichem Grundriss verschneiden sich NICHT), und Zweier-Cluster ueber die
gemeinsame Gehrungslinie geschnitten. T-/X-Stoesse und freie Enden bleiben
stumpf, wie im Web-Kern. Endkappen-Normalen geometrisch aus dem Kreuzprodukt.

WallInput bekommt optional layers (WallLayer{thickness,color}, serde-default,
rueckwaertskompatibel): jede Schicht wird als eigenes Band ueber dieselbe
gehrte Grundflaeche extrudiert und materialgefaerbt. toWalls3d.ts loest die
WallType-Schichten je Wand auf (hexToRgb) und reicht sie durch.

39/39 Rust-Tests (7 neue: Gehrung, T-Stoss, freies Ende, Schichten, Kombi).
2026-07-03 21:45:01 +02:00
karim 5fd0161bc7 Kopfzeile der Zeichenflaeche: Dokument-Tabs statt Geschoss-Eigenschaften
Die alte Eigenschaften-Leiste (Geschosshoehe/Schnitthoehe/OKFF) weicht einer
Tab-Leiste 'Zeichnungen': ein Tab je Geschoss-Grundriss, Klick wechselt die
aktive Zeichnungsebene. Bindet an activeLevelId/setActiveLevelId — dieselbe
Store-Quelle wie das Panel 'Zeichnungsebenen', beide bleiben synchron.
Geschosshoehe/Schnitthoehe werden in den Geschoss-Einstellungen gepflegt.

Der Drawing-Typ (kind: 'plan') und der switch beim Aktivieren lassen spaeter
Schnitt-/Layout-Tabs zu, ohne die Leiste umzubauen.
2026-07-03 21:39:56 +02:00
karim f4e70902d0 Raumstempel: Ausrichtung pro Zeile + Anker als Snappunkt
Jede Stempelzeile (Name, Name-2, Bodenflaeche, Nutzung) hat jetzt eine
eigene Ausrichtung links/mitte/rechts, im Stempel-Editor pro Zeile
waehlbar und in SVG- wie nativer Darstellung honoriert (text-anchor bzw.
align). Bodenflaeche/Nutzung fallen auf mitte zurueck (bisheriges Bild).

Der Stempel-Anker (stampAnchor bzw. Schwerpunkt) meldet sich zusaetzlich
als Endpunkt-Snapkandidat an — gefiltert nach Geschoss und sichtbarer
Kategorie; der ziehbare Griff war bereits verdrahtet.
2026-07-03 21:29:13 +02:00
karim af0b044fca render2d: Rundungen an Strichen — Round-Caps und Round-Joins wie glPlan
stroke_polyline tesselliert Segmente jetzt einzeln (Butt-Enden) und
setzt an Innenknoten Fächer-Bögen auf der Aussenseite sowie an offenen
Enden Halbkreis-Kappen. Fächerdichte 18°/Schritt, identisch zur
WebGL2-Darstellung. Geschlossene Ringe: Bögen an allen Knoten, keine
Kappen; offene Polylinien inkl. Strich-Segmente: Kappen an den Enden.
2026-07-03 21:24:28 +02:00
karim 692dd3f719 Engine-3D-Presets ueber die TopBar + orthografisch (Top/Front/Seite), Iso korrekt
Die Kamera-Presets liefen zuvor als Viewport-Overlay mit eigenen JS-yaw/pitch-
Winkeln, rein perspektivisch (Iso falsch, keine Parallelprojektion). Jetzt
haengt der Engine-Viewport am app-weiten view3d-State (TopBar-CameraMenu, wie
three.js) und nutzt die vorhandene Rust-preset_camera:

- web.rs: set_view_preset(preset) + model_bounds/fit; Front/Top/Side echt
  orthografisch, Iso/Perspektive perspektivisch - wie das native Fenster.
  default_fov() pub(crate), damit Fit denselben FOV nutzt.
- useWasm3dRenderer: applyViewPreset; render(null) ueberschreibt die
  Preset-Kamera nicht.
- Wasm3DViewport: konsumiert view3d/renderMode als Props (Overlay + lokaler
  Umschalter entfernt, wasm3dOverlay.css geloescht); Orbit bleibt frei
  (Preset = Sprung, kein Lock), three.js-Verhalten gespiegelt.
- Viewport3D-Switcher reicht view3d/renderMode an den Engine-Viewport durch
  (kein three.js-Szenencode angefasst).
2026-07-03 21:13:53 +02:00
karim 4d721a62f7 Raeume: Umriss auf die Wand-Innenflaeche + neutral statt farbig
- Sample-Raum R1: boundary auf die echte Innenkante der Aussenwaende
  (Achse +/- halbe Wanddicke 0.1725 bei Wandtyp aw 0.345 m) statt 0.1-Inset -
  der Umriss lag bisher ~7 cm im Wandkoerper.
- Raumfarbe #5a7a9a -> neutrales Grau #6b7280 (Umriss + Stempel).
- ROOM_FILL_ALPHA 33 -> 00: keine transluzente Farbfuellung mehr
  (Plandarstellung schwarz/grau); das Polygon bleibt fuer die Auswahl
  erhalten (Hit-Test laeuft ueber pointInPolygon, nicht ueber die Fuellung).

Offen: automatische Ableitung der Raum-Boundary aus den Wand-Innenflaechen
fuer spaeter gezeichnete Raeume.
2026-07-03 21:07:11 +02:00
karim b028fc6b31 Engine-3D: Kamera-Presets + Weiss-Modus im render3d-Viewport
Der Engine-3D-Viewport (render3d/wgpu) war reines Ansehen (Orbit/Zoom). Jetzt:

- Kamera-Presets als Overlay: Top/Unten/Vorne/Hinten/Links/Rechts/Iso.
  Richtungen exakt wie der three.js-Pfad (applyView3d); framen die aktuelle
  Modell-Bounding-Box (fitTargetDist) und springen auf den Preset-Winkel,
  danach Orbit/Pan/Zoom weiter frei. Rein JS.
- Render-Modus Schattiert/Weiss: mode-Uniform in Globals (WGSL/Rust),
  Weiss = beleuchtetes Clay-Grau; wasm-Binding set_render_mode_white,
  durchgereicht via useWasm3dRenderer.
- Overlay-CSS self-contained (wasm3dOverlay.css), Theme-aware.

three.js unangetastet. Auswahl/Grips/Zeichnen + Wireframe/HLR bewusst noch
offen (naechste Scheibe).
2026-07-03 20:56:56 +02:00
karim 581ddccf1a Wandstile: Schichtfuge pro Fuge als LineStyle waehlbar + ResourceManager-Abteilung
Mehrschichtige Waende zeichnen die Trennlinie an jeder Materialfuge jetzt mit
einem pro Fuge waehlbaren LineStyle statt einer festen Haarlinie.

- Layer.jointLineStyleId (optional): LineStyle der Fuge an der Innenkante
  dieser Schicht; fehlt er, gilt die Default-Haarlinie (0.02) in Wandfarbe.
- generatePlan: Schicht-Baender tragen nur noch Fuellung + Schraffur (kein
  Band-Umriss); jede innere Fuge wird als eigene Linie mit ihrem LineStyle
  (Gewicht/Farbe/Dash, x Detailfaktor) gezeichnet. Wand-Umriss (0.35) und
  grob-Pfad unveraendert.
- ResourceManager: neue Abteilung "Wandstile" - je Wandtyp die Schichten
  mit Fugen-LineStyle-Dropdown.
- Sample: LineStyle "Schichtfuge 0.13"; der Daemmung-Backstein-Fuge (massiv/
  massiv) zugewiesen, Putzfugen bleiben Haarlinie.
- 2 Joint-Tests ergaenzt.
2026-07-03 20:55:07 +02:00
karim 1fd688896c glPlan: bildschirm-adaptive Bogen-Tessellierung (LOD)
Boegen (Tuer-Schwenkbogen) wurden mit fester Segmentzahl (16/90 Grad) beim
Kompilieren tesselliert und beim Zoom nur per Matrix neu gezeichnet - beim
Reinzoomen daher sichtbare Facetten. Jetzt skaliert die Segmentzahl mit der
Bildschirmgroesse des Bogens: Sagitta < 0.5 px (segs = clamp(ceil(|dPhi|/theta),
8, 2048)), theta = 2*acos(1 - 0.5/rPx). Weit weg wenige Segmente (Perf),
nah rund.

Anti-Thrash per LOD-Bucket round(log2(pxPerMeter)): Neu-Tessellierung nur
bei Bucket-Wechsel, sonst bleibt Zoom/Pan reiner Matrix-Update. Bogen laeuft
weiter durch strokePolyline - runde Caps/Joins bleiben erhalten.
2026-07-03 20:29:12 +02:00
karim f9a5e763fd Plandarstellung schwarz: Tür/Fenster-Symbole als Haarlinie, Decke weiss hinterlegt
Plandarstellung ist schwarz/grau; Farbe bleibt spaeteren Schemaelementen
vorbehalten.

- Tuerblatt, Schwenkbogen, Fensterscheibe und Anschlagstriche: einheitlich
  #1a1a1a statt blau (#2b3039/#4a86c7/#6b7280), im render2d/PDF-Pfad
  (CLS_STROKE) und im SVG-CSS.
- Schwenkbogen als Volllinie (Strichelung entfernt).
- Tuer-/Fenstersymbol-Linien auf Haarlinie 0.02 mm (SYMBOL_HAIRLINE_MM).
- Decken-Poche: Schraffur auf weissem Grund wie mehrschichtige Waende
  (pocheFill), solid schwarz - nicht mehr die Bauteil-Fuellfarbe.
2026-07-03 20:28:03 +02:00
karim ec2568276d PDF/Print: Haarlinien unter der Stift-Leiter exakt drucken
quantizePen hob bisher jede Breite <= 0.13 mm auf die unterste Stift-Stufe
(0.13 mm) an. Dadurch druckte die 0.02-mm-Schraffur/Schichtfuge im PDF und
im SVG-Print-Modus als 0.13 mm - im Viewport duenn, im Export dick.
Breiten unter MIN_PEN_MM gelten jetzt als bewusste Haarlinien und werden
exakt (unquantelt) durchgereicht; ab 0.13 mm rundet die Leiter wie gehabt.
2026-07-03 20:15:10 +02:00
karim 2b6b99059d Fang-Optionen und Display/Print-Umschalter in die Footerbar
Der Linien-Modus (Display/Print) wandert aus der Oberleiste und die
Fang-Optionen aus der Werkzeug-Palette in die Statusleiste unten:

- StatusBar: segmentierter Display/Print-Umschalter (Stil wie der
  Renderer-Umschalter) plus Fang-Pill mit nach oben öffnendem Popover
  (Master-Schalter + Endpunkt/Mittelpunkt/Schnittpunkt/Kante/Raster/
  Ortho + Rastermass).
- TopBar: Linien-Modus-Gruppe entfernt.
- ToolsPanel: Fang-Abschnitt entfernt.
- App reicht lineMode/snap an die StatusBar durch.
2026-07-03 20:08:03 +02:00
karim 658536029b Schraffur + Schichtfugen als 0.02-mm-Haarlinien im einheitlichen Neutralschwarz
Schraffurlinien verlassen den screenPx-Sonderpfad und laufen wie jede
andere Linie durch die Papier-mm-Pipeline (glPlan, SVG, PDF): im Display
konstante Haarlinie (paperScaleForGl-Kehrwert von meet), im Print echte
0.02 mm - deckungsgleich mit den Schichtfugen in beiden Modi.

- POCHE_STROKE #2b3039 -> #1a1a1a: kein Blaustich mehr, gleicher
  Neutralton wie die Schraffur (Wände/Öffnungen/Decken/Treppen).
- Schichtfuge LAYER_LINE_MM 0.13 -> 0.02 (Haarlinie).
- Schraffur-Stift hatch-hair/hatch-dash 0.05 -> 0.02.
- Wand-Umriss Kategorie 20 lw 0.5 -> 0.35 (Standard-Aussenlinie war zu dick).
- SVG-Schraffur nutzt HAIRLINE_PX/printStrokeVb statt hatchStrokePx;
  PDF-Export echte mm statt widthScreen.
2026-07-03 20:02:18 +02:00
karim 675c68de55 3D-View: Engine (render3d/wgpu) als Standard-Renderer, three.js nur Fallback
WASM_ENGINE_ACTIVE nimmt die Engine, sofern nicht explizit WebGL2 gewählt ist
(?engine=webgl2 bzw. localStorage cad.rendererMode==="webgl2") oder WebGPU
fehlt. three.js bleibt der stille Fallback. Der Engine-3D-Viewport ist vorerst
nur Ansehen (Orbit/Zoom); Auswahl/Griffe/Zeichnen folgen.
2026-07-03 19:47:03 +02:00
karim e9dc423a12 SIA-Wandschraffuren: Beton-Kreuz, Backstein/Dämmung wandrelativ, hairline
- HatchStyle.relativeToWall: der Wandwinkel (aus wall.start/end) wird in
  addWallPoche und resolveWallSectionStyle auf hatch.angle addiert (Vorzeichen
  zur SVG-CW-Konvention passend), sodass die Schraffur der Wandachse folgt.
- sampleProject: sia-concrete (crosshatch 45° absolut), sia-brick (diagonal
  wandrelativ), sia-insulation (diagonal 90° wandrelativ, solide Haarlinie);
  Komponenten Beton/Backstein/Dämmung darauf umgehängt. Dichte Muster (scale
  0.5/0.5/0.45), hairline (Strichgewicht 0.05 → 0.6px-Boden).
- Poché-Hinterlegung: mehrschichtige Wände (und einschichtige) weiss, solid
  schwarz (Fill + Schraffurfarbe), konsistent in SVG- und glPlan-Fill; Mono
  behält Schwarz für solid. Gilt auch für den Schnitt-Cut.
- ResourceManager: Wandbezug-Schalter im Hatch-Manager. exportDxf: Dash-
  Splitting für gestrichelte Schraffuren.
2026-07-03 19:47:03 +02:00
karim 6aaef8f46a glPlan: runde Linien-Caps/Joins + Schraffurbreite bildschirm-konstant
- Runde Enden und Ecken statt Butt-Caps/Miter: jedes Segment als eigenes
  Quad, an Innenecken ein Dreiecks-Fächer über den Aussenwinkel (addRoundJoin),
  an offenen Enden ein Halbkreis-Fächer (addRoundCap). Gilt für Wandumrisse,
  Linien, Bögen und Schraffurläufe — Look wie das Vektor-PDF. Keine Shader-
  Änderung nötig (Radius weiterhin zur Render-Zeit aus strokePx*strokeScale).
- Schraffur-Dicke-Bug behoben: glPlan schickte die Schraffur-mm durch die
  Papier-mm-Pipeline (strokeMm*mmToDevicePx); im Display-Modus ist der darin
  steckende paperScaleN ein zoom-abgeleiteter, nicht auf 0.13/mm kalibrierter
  Wert → Breite explodierte. Jetzt hatchStrokeVb = max(0.6, hatchMm/0.13) als
  screenPx-Batch (skaliert nur mit meet/Zoom) — byte-genau wie SVG-<pattern>
  und der PDF-Export (widthScreen). Wandumrisse unverändert (echte mm).
2026-07-03 19:47:03 +02:00
karim eaf6d8ed7b render3d: 4× MSAA im wgpu-Renderer — glatte 3D-Kanten
Multisampled Farbtarget (SAMPLE_COUNT=4) + resolve_target in die Surface-View je
Frame; Depth-Textur-sample_count synchron auf 4; MSAA-/Depth-Textur werden bei
Resize gemeinsam neu erzeugt; multisample.count=4 auf der Render-Pipeline. Nur
gpu.rs — mesh/section/openings unberührt.

Gates: cargo check (window) + wasm32 --features web grün, cargo test 32/32,
--features render 33/33.
2026-07-03 19:23:21 +02:00
karim d30f79e162 Vektor-PDF: Schraffurbreite skalenunabhängig (Audit-Fund #4)
appendStroke rechnete widthScreen-Schraffurbreiten über mmPerM=1000/
scaleDenominator um → die gedruckte mm-Breite hing vom Export-Massstab ab
(0.25mm bei 1:50 vs 0.13mm bei 1:200 für dieselbe Quellbreite). Jetzt
widthMm * HATCH_DENSITY_MM (0.13) — der exakte Kehrwert von toRenderScene
hatchPx = max(0.6, hatchMm/0.13) → identische Papier-mm bei jedem Massstab,
deckungsgleich zur Bildschirmvorschau. Ungenutzten mmPerM-Parameter entfernt.
2026-07-03 19:06:28 +02:00
karim bde1f3f8d1 PDF-Export: echten Massstab an planToRenderScene durchreichen (Audit-Fund #3, Abschluss)
buildPlanPdf ruft planToRenderScene(plan, opts.scaleDenominator) statt des
Default 1:100 → der Stempel-Zeilenabstand mehrzeiliger Texte ist jetzt auch im
PDF bei Nicht-1:100-Massstab papier-mm-genau (identischer Nenner wie an
sceneToPrintSvg). Schliesst den in der vorigen Änderung offen gelassenen
Aufrufer-Punkt.
2026-07-03 18:56:08 +02:00
karim 2dd7fe339d Stempeltext: Zeilenabstand skalenfest statt fix 1:100 (Audit-Fund #3)
planToRenderScene(plan, paperScaleN=100) leitet den Zeilenabstand mehrzeiliger
Stempeltexte jetzt aus dem tatsächlichen Massstab-Nenner ab (mmToM = paperScaleN
/1000) statt aus der bisher fest verdrahteten 1:100-Konstante. Deckt sich mit dem
nRef der SVG-Text-Formel in PlanView. Viewport/WASM-Aufrufer behalten den Default
100 (deren Referenzmassstab). Der PDF-Export reicht den echten Massstab im
Folgeschritt durch.
2026-07-03 18:54:39 +02:00
karim 3c4d983a7d Print-Vorschau: Strichbreiten auf die PEN_STEPS-Leiter quantisieren (Audit-Fund #2)
Die SVG-Print-Vorschau reichte rohe mm-Breiten in printStrokeVb, während der
PDF-Export jede Breite via quantizePen() auf die Stiftleiter (0.13/0.18/0.25/…)
rundete → Vorschau ≠ Druck. quantizePen ist jetzt aus sceneToPrintSvg.ts
exportiert und wird in den zwei Print-Zweigen von PlanView (DrawingRunShape,
renderPrimitive-weight) verwendet. Nur EINE Leiter, keine Kopie. Haarlinien-Pfad
und Dash-Arrays unangetastet (das PDF quantisiert Dashes ebenfalls nicht).
2026-07-03 18:54:39 +02:00
karim 094d8b4504 Schnitt: Cut-Polygone nutzen die echte Bauteil-Schraffur statt Albedo
Die geschnittenen Flächen erhielten bisher die rohe Albedo-Farbe der Engine plus
eine generische Diagonalschraffur. Jetzt löst attachCutStyles() jede Cut-Fläche
über den index-parallelen ComponentRef auf ihr Quell-Bauteil auf:
- toSection.ts: wallSegmentOwners()/slabSegmentOwners() bilden die exakte
  Segment-Zerlegung aus projectToModel3d nach (gleiche EPS-/Öffnungs-Logik) und
  liefern je emittiertem Segment das Quell-Wall/Ceiling — index-gleich zur
  WASM-Elementreihenfolge (per Test bewiesen: W1,W1,W1,W2).
- generatePlan.ts: resolveWallSectionStyle/resolveCeilingSectionStyle wählen die
  Komponente mit höchster joinPriority (wie die Grundriss-Poché) bzw. die erste
  Deckenschicht und liefern fill+HatchRender über resolveHatch. generateSectionPlan
  nutzt diese, im Mono-Modus weiss/SECTION_INK. Fallback bei fehlendem Bauteil =
  bisheriges Verhalten (Albedo + generische Schraffur).

Gates: tsc -b 0, build grün.
2026-07-03 18:50:31 +02:00
karim 330c01bbbd PlanView: Haarlinien-Modus auch im GPU-Pfad zoom-konstant (Audit-Fund #1)
paperScaleForGl nutzt im hairline-Modus den live aus dem Ausschnitt abgeleiteten
scaleFromView(v) statt des stabilen paperScale. Der GL-Term mm_to_device_px =
N/1000·PX_PER_M·meet wuchs bisher über meet mit dem Zoom, obwohl Display/Haarlinie
konstante Strichbreiten verspricht. scaleFromView ist der exakte Kehrwert von meet
→ N·meet kürzt sich, die Breite in Geräte-px bleibt zoom-unabhängig (dieselbe
Wirkung wie non-scaling-stroke im SVG-Pfad). Print-Modus unverändert.

Restlücke (separat, bräuchte Shader-Änderung): noch nicht wörtlich uniform 1px wie
der SVG-Haarlinienpfad — Breiten bleiben gewichts-proportional, aber zoom-konstant.
2026-07-03 18:47:40 +02:00
karim d2503c1191 render3d: Öffnungen im 3D-Vollkörper (mesh.rs) — geteilte Zerlegung mit section.rs
- openings.rs (neu): split_range_by_voids() — die bisher nur in section.rs
  liegende Pfeiler/Brüstung/Sturz-Zerlegung, herausgezogen als koordinaten-
  neutraler Helfer. section.rs und mesh.rs teilen jetzt EIN Öffnungsmodell.
- section.rs: wall_cut_rectangles delegiert den Sortier-/Rechteck-Teil an
  openings::split_range_by_voids (plane-spezifisches Clipping bleibt lokal).
- mesh.rs: extrude_wall emittiert nicht mehr einen Vollblock je Wand, sondern
  Teilkörper (Pfeiler in voller Höhe + Brüstung/Sturz je Öffnung) → echtes Loch;
  Tür (sill==0) lässt die Brüstung weg und reicht bis zum Boden. Ohne Öffnung
  exakt EIN Block, byte-identische Geometrie zu vorher (Alt-Tests unverändert grün).

Gates: cargo test 32/32 (29+3 neue), wasm32 --features web check grün, nativ grün.
2026-07-03 18:47:40 +02:00
karim e3f460c9f8 Doku: mm-Strichbreiten-Audit über den gesamten Engine-Pfad (Nordstern 1)
Traceanalyse der Strichbreiten von Modell → Bildschirm-bei-Massstab → Druck-mm
über alle vier Pfade (SVG-Display, SVG-Print, Vektor-PDF, render2d/WASM).
Befunde nach Schwere geordnet inkl. konkreter Fix-Vorschläge (Datei:Zeile) und
Verifikations-Rezept. Kernbefund: Hairline/Display-Modus wird von beiden
GPU-Renderern ignoriert; PEN_STEPS-Quantisierung existiert nur im PDF-Pfad.
2026-07-03 18:37:49 +02:00
karim 28b5492def Schnitt/Ansicht: SectionOutput aus render3d an die Zeichenebenen anbinden
- render3d/web.rs: GPU-freies WASM-Binding cut_section_json(model_json, p, n)
  → JSON {cutPolygons, visibleEdges, hiddenEdges} in (u,v)-Metern.
- src/plan/toSection.ts (neu): SectionOutput-Typen, sectionPlaneFromLevel()
  leitet die Schnittebene aus linePoints/directionSign ab, computeSection()
  flacht über projectToModel3d ab und ruft das Binding.
- src/engine/engine3d.ts (neu): geteilter memoisierter pkg3d-Loader, damit
  3D-Viewport und Schnittpfad EINE mod.default()-Init teilen.
- generatePlan.ts: generateSectionPlan() übersetzt SectionOutput in
  bestehende Primitive (Cut = polygon mit Poché-Schraffur, sichtbare Kante =
  line solid, verdeckte Kante = line dashed) — keine neue Primitive-Art nötig.
- App.tsx: SectionPlanView ersetzt den Stub für Ebenen vom Typ Schnitt/Ansicht;
  rendert dasselbe PlanView, mit lokalisierten Hinweisen (lädt/keine Linie/Fehler).
- i18n de/en: section.*-Schlüssel.
- Öffnungen erscheinen korrekt (Brüstung/Sturz-Teilrechtecke) über die
  projectToModel3d-Teilkörper-Zerlegung.

Gates: tsc -b 0, build grün (WASM = eigener Lazy-Chunk), render3d cargo test 29/29,
wasm32 --features web check grün.
2026-07-03 18:37:49 +02:00
karim 4a62dbf83c HANDOVER: Stand 2026-07-03 — Nordstern 2/3/4/5 gelandet, Schnitt-UI-Anbindung offen 2026-07-03 18:18:52 +02:00
karim 8a79f6cbaa wgpu 22 → 29, glyphon 0.6 → 0.11: requestDevice-Shim entfällt
glyphon 0.11 ist die neueste zu wgpu 29 passende Version (wgpu 30 existiert
bereits, glyphon pinnt aber ^29). Das 22er-Limit maxInterStageShaderComponents
wird von 29 nicht mehr in requiredLimits gesendet — src/engine/requestDeviceShim.ts
komplett entfernt, beide Hook-Importstellen (useWasmPlanRenderer, useWasm3dRenderer)
angepasst. pollster 0.3→0.4, naga 22→29 mitgezogen. Alle Draw-/Text-Pfade
(draw_sequence, glyphon ColorMode::Web, widthScreen-Polylinien, Headless/Golden)
unverändert funktionsfähig.

Verifiziert: cargo check nativ (render2d/render3d/src-tauri) grün, cargo test
render2d 17/17 + Golden bit-exakt, render3d 29/29, wasm32 --features web für
beide grün, npx tsc -b + npm run build grün.
2026-07-03 18:17:41 +02:00
karim 9371b65b3b Vektor-PDF aus der RenderScene: eine Szene, zwei Targets
exportPdf baut jetzt planToRenderScene(plan) — identischer Aufruf wie der
Engine-Viewport — und serialisiert über den neuen sceneToPrintSvg nach
Papier-mm: z-stabile Reihenfolge wie compile_scene, PEN_STEPS-Quantisierung
wie bisher, widthScreen-Schraffurbreiten via (widthPx/PX_PER_M)*(1000/N)
in mm umgerechnet, Texte neu als echte Vektor-Texte (alter Pfad liess sie
weg). planToPrintSvg bleibt als Referenz, im Kopf als abgeloest markiert.
probe-pdf.mjs: Icon-Button-Selektor nachgezogen, neue Assertions (Schraffur-
Polylinien vorhanden, alle stroke-widths auf PEN_STEPS). Messung: 3666
Vektor-Pfadoperatoren, 7 Text-Operatoren, 0 Rasterbilder; Inhalt per
pdftoppm 53.51x43.69 mm vs. erwartet 53.45x43.45 bei 1:100.
2026-07-03 08:46:35 +02:00
karim e8f5e7272d HANDOVER: Stand Stand 2026-07-03 (Engine-Meilensteine, offene Arbeit) 2026-07-03 08:42:27 +02:00
karim f95df75ea1 render3d-Schnitt: Öffnungen — Brüstung/Sturz-Teilpolygone und Durchblick
WallInput bekommt openings (from/to/sill/height, serde-default). Der Cut
durch eine Öffnung liefert mehrere einfache Rechtecke (Brüstung, Sturz,
Leibungen) statt Lochpolygon — kompatibel zu render2d::FillPolygon. Der
Verdeckungstest behandelt Öffnungen als Durchblick (u-Zuordnung exakt im
Elevationsfall, konservativ undurchsichtig bei kantennaher Ausrichtung);
Fensterrahmen-Kanten werden als projizierte Kanten ausgegeben. Tests
26 auf 29, Beweis-SVG mit Fenster + dahinterliegender Wand neu generiert
(sichtbares Band exakt im Fensterausschnitt). mesh.rs (3D-Vollkoerper)
beruecksichtigt Öffnungen weiterhin nicht — dokumentierte Luecke.
2026-07-03 08:36:35 +02:00
karim 918f60c498 render2d: Headless-Rendering — PNG ohne Fenster + Golden-Image-Test
HeadlessRenderer (Feature headless) baut Instance/Adapter/Device ohne
Surface (Vulkan, kein Display-Server); render_to_image rendert in eine
Rgba8Unorm-Texture (non-sRGB wie ColorMode::Web) und liest mit 256-Byte-
Row-Padding zurueck. Draw-Code unveraendert geteilt mit dem Fenster-Pfad.
CLI-Bin render_png (Demo-Szene, 990x630); Demo additiv um 45-Grad-
Schraffur, Tuerblatt und Schwenkbogen erweitert, damit das Golden alle
Pfade abdeckt. Golden-Test mit eingechecktem Referenzbild: 0/623700 Pixel
Abweichung, bit-exakt reproduzierbar; bei Abweichung Diff-Dump nach
target/. Doku in docs/design/engine-headless.md.
2026-07-03 08:31:38 +02:00
karim 23410f9b9e 2D-Engine-Parität: WASM-Pfad rendert deckungsgleich zum SVG-Referenzpfad
Sechs Lücken geschlossen: Text-Massstab vom Geometrie-Massstab entkoppelt
(set_text_scale, spiegelt SVG-Referenzskala); glyphon auf ColorMode::Web
(Text war linear-konvertiert zu dunkel); z-basierte Maler-Reihenfolge
(interleavte draw_sequence statt fills-vor-lines, Alt-Szenen unverändert);
CSS-Klassenfarben/-Opacities in toRenderScene gespiegelt (Türschwenk etc.);
greyed-Dimmung 0.3 auf allen Primitiven; Dämmschraffur am Modell-Ursprung
verankert (userSpaceOnUse), exakte Bézier-Wellenform statt Sinus und
kachelgekoppelte Strichbreite (widthScreen-Modus). Probe
scripts/probe-engine-parity.mjs vergleicht ?gl=0 gegen ?engine=wasm;
Rest-Diff nur AA/Glyphen-Rasterung. cargo 17/17, vitest 94/94, Builds grün.
2026-07-03 08:17:01 +02:00
karim 98994c96aa render3d: Schnitt-Modul — Cut-Polygone + sichtbare/verdeckte Kanten aus Prismen
Analytische Eigen-Engine-Alternative zum OCCT-HLR-Spike: Schnittebene
(Punkt+Normale) gegen extrudierte Fussabdruck-Prismen; Cut-Polygone in
(u,v)-Schnittkoordinaten mit Komponenten-Referenz (spaetere Schraffur),
Projektion der dahinterliegenden Kanten mit Verdeckungstest, getrennt
visible/hidden. SectionOutput serde-serialisierbar (Meter). 26 Tests,
Example section_svg schreibt docs/welle-c-hlr-spike/section-engine-proof.svg
(L-Wand+Bodenplatte: Poché, durchgezogene sichtbare, gestrichelte verdeckte
Kanten). Offene Punkte in docs/design/engine-section-pipeline.md.
2026-07-03 08:13:57 +02:00
karim 0e484746de render3d im Browser: WASM/WebGPU-3D-Viewport hinter ?engine=wasm
Feature web (wasm-bindgen) + cdylib analog render2d; WebModelRenderer
mit Canvas-Surface, set_model (walls/slabs wie der native Push) und
set_camera. Projektion liefert bereits [0,1]-Clip-Z, math.rs unveraendert.
wgpu-22-requestDevice-Shim in src/engine/requestDeviceShim.ts geteilt.
Neuer Hook useWasm3dRenderer + Wasm3DViewport (Orbit/Pan/Zoom wie three.js-
Sicht); Viewport3D dispatcht per ?engine=wasm bzw. localStorage, three.js
bleibt Default. Build-Script build:engine3d (wasm-pack, src/engine/pkg3d).
Verifiziert headful per scripts/probe-engine3d.mjs (37 % Geometrie-Pixel);
headless praesentiert Chromium keine WebGPU-Frames (auch bei render2d).
2026-07-03 07:41:37 +02:00
karim 87d12b976f Resource Manager: Materialien-Tab mit PBR-Kugel-Vorschau, Suche und Kategorie-Chips
Geteilter Offscreen-three.js-Renderer (ein Kontext, serielle Queue, Cache
per Map-Signatur) rendert 128px-Kugeln lazy via IntersectionObserver.
Kategorie-Chips aus der Bibliothek abgeleitet, uneinheitliche Manifest-
Schreibweisen normalisiert; Suche und Kategorie kombinierbar. Kachel-Klick
markiert aktiv (gleiche Sprache wie MaterialPicker). Puppeteer-Probe
scripts/probe-material-tiles.mjs prüft Grid, Lazy-Render und Filter.
2026-07-03 02:04:33 +02:00
karim 519735c782 README: kritisch überarbeitet — Parametric Walls, PDF/DXF-Export, Materials-Lib, HLR-Spike-Stand ergänzt; Dev-Port korrigiert 2026-07-03 00:19:54 +02:00
karim 47f1c24bb3 README: keine Browser-App mehr, sondern Electron-Desktop-Shell mit eigener Engine 2026-07-03 00:10:24 +02:00
karim 1bbcc7038f HANDOVER: Electron-WebGPU-Befund korrigieren (funktioniert, kein Fallback)
Der vorherige Eintrag ging von einem WebGL2-Fallback aus; genauere Messung
(GPU-Status erst nach Initialisierung abfragen statt sofort bei whenReady)
zeigt: WebGPU/?engine=wasm laeuft in Electron einwandfrei, per Screenshot
verifiziert (RENDERER-Anzeige zeigt Engine aktiv).
2026-07-03 00:00:00 +02:00
karim e6466a5027 Electron-Prototyp: eigenes App-Fenster statt Tauri/WebKitGTK
Ersetzt den Tauri-Webview-Weg testweise durch eine Electron-Shell (randlos,
ohne native Menüleiste, wie chromium-shell.sh), damit die WASM/WebGPU-Engine
zuverlässig laeuft statt in WebKitGTK. Rust-Backend nicht noetig, da
compute_joins einen TS-Fallback hat. npm run electron zum Starten.
2026-07-02 23:51:23 +02:00
karim 1ab8c7e496 Renderer-Umschalter: Wahl in localStorage persistieren (uebersteht Neustart ohne URL-Param) 2026-07-02 23:38:05 +02:00
karim 0e839b50d6 UI: Renderer-Umschalter (WebGL2 / eigene Engine) in der Statusleiste 2026-07-02 23:35:01 +02:00
karim 623b243306 WASM-Viewport: SVG-Raumtext bei aktiver Engine unterdruecken (kein Doppeltext) 2026-07-02 23:28:25 +02:00
karim 6a7bbeec2f HANDOVER: naechster Schritt = Electron-Prototyp 2026-07-02 23:18:27 +02:00
karim cbf54e6b65 HANDOVER: Richtungsentscheidung Electron/Chromium+WASM statt Tauri; all-native als Fernziel 2026-07-02 23:02:35 +02:00
karim 543d06adb5 HANDOVER: Engine-Nordstern — exakte Breiten, Druck=Bildschirm, Headless, HLR, WebKit-Unabhaengigkeit 2026-07-02 22:31:27 +02:00
karim a3b25da96d render2d im Browser: WASM/WebGPU-Viewport hinter ?engine=wasm
Integrations-Spike: die native 2D-Engine (src-tauri/render2d) laeuft jetzt als
WASM/WebGPU-Canvas in der App-UI, mit derselben Szene (planToRenderScene) und
ViewBox wie der WebGL2-Pfad; das SVG-Overlay (Text/Griffe/Auswahl) bleibt drueber.

- render2d: neues Feature "web" (wasm-bindgen/web-sys), Fassade WebPlanRenderer
  (new/set_scene/set_view_box/set_paper_scale/resize/render) auf Canvas-Surface.
  Geteilte GPU-Schicht (gpu.rs), native Fensterschicht (winit) unberuehrt.
- Font: cosmic-text findet auf wasm keine Systemfonts -> Inter-Bytes eingebettet
  (assets/Inter.ttf), Renderer.load_font + text_family aus der geladenen Familie.
- Frontend: useWasmPlanRenderer (gleiche Schnittstelle wie useGlPlanRenderer);
  PlanView waehlt die Engine per ?engine=wasm + navigator.gpu, sonst WebGL2/SVG.
- Build: npm-Script build:engine (wasm-pack, target web -> src/engine/pkg,
  gitignored). chromium-shell.sh mit WebGPU-Flags (--enable-unsafe-webgpu/Vulkan).
- Kompat-Shim: wgpu 22 sendet das spec-entfernte Limit maxInterStageShaderComponents,
  das neuere Chromium ablehnen -> im Hook vor requestDevice entfernt.

Verifiziert: cargo test -p render2d gruen (18), tsc gruen, wasm-Build gruen;
Headless-Chromium (?engine=wasm) zeichnet den Grundriss (Waende/Schraffur/
Tuerschwenk/Treppe/Raumtext) im WASM-Viewport, Fallback ohne Flag unveraendert.
2026-07-02 22:30:47 +02:00
karim 2a2b3b294c 2D-Bögen analytisch: exakter Kreis-Shader statt Segment-Tessellierung
Bögen im nativen 2D-wgpu-Renderer werden nicht mehr zoomabhängig in Segmente
zerlegt, sondern per SDF-Fragment-Shader (ARC_WGSL) mathematisch exakt rund
gerendert — bei jeder Zoomstufe ein echter Kreis, kein Vieleck, ohne
Neu-Tessellierung.

- compile_scene sammelt je Bogen EINE analytische Instanz (ArcInstanceData,
  Bildschirm-Raum-Parameter + Dash in Modell-Metern), zoom-invariant.
- Eigene Arc-Pipeline (ein Frame-Uniform, Quad je Instanz aus vertex_index):
  radiale Kante, Butt-Cap-Winkelclamp (beide Sweep-Vorzeichen) und Dash
  (Bogenlänge modulo Muster) analytisch antialiased; Strichbreite mit derselben
  mm->px-Formel wie die Linien.
- tessellate_arc + Zoom-Retessellierungs-Cache (last_scene/arc_px_per_m/
  maybe_retessellate) entfernt; upload_scene ohne px_per_m.
- Tests auf die neue Semantik umgeschrieben (Winkel-Parität, Bounding-Box,
  Dash-Mapping), ARC_WGSL per naga validiert.
2026-07-02 22:07:18 +02:00
karim 926dedca40 2D-Plan: Strichmuster (Umriss/Linie/Bogen) im nativen wgpu-Renderer zeichnen
Papier-mm-Strichmuster (dash) wurden bisher komplett ignoriert und immer
durchgezogen gerendert — im nativen 2D-Fenster gab es dafür bislang gar keinen
Mechanismus. `split_dash` portiert den bereits für Schraffuren bewährten
`applyDashRuns`-Algorithmus (glPlanHatch.ts) nach Rust und zerlegt einen
Linienzug in seine "an"-Teilstücke; die Phase läuft dabei über bereits
verkettete Zeichnungszüge UND über die neu adaptiv tessellierten Bögen
hinweg durch, sodass z. B. der gestrichelte Türschwenk-Bogen gleichmäßig
gestrichelt bleibt statt an jedem Segment neu anzusetzen.
2026-07-02 21:45:57 +02:00
karim 4311a7dfbd 2D-Plan-Qualität: Türschwenk-Bögen adaptiv rund tessellieren (nativer wgpu-Renderer)
Bögen (Türschwenke) wurden bisher einmalig in JS in eine feste Facettenzahl
zerlegt; beim Hineinzoomen wurden die Facetten sichtbar. Die Zerlegung
(`tessellate_arc`) wandert nach Rust und läuft jetzt zoomabhängig anhand einer
Sehnenabweichungs-Toleranz (Sagitta ≤0.3 Geräte-px), Segmentzahl auf 8..512
geklemmt. Die native GPU-Szene bekommt dafür einen eigenen `Arc`-Primitiv-Typ
(unvortessellliert); der Renderer merkt sich die zuletzt hochgeladene Szene und
tessellliert Bögen automatisch neu, sobald sich der Zoom seit dem letzten
Upload um mehr als Faktor 1.3 verändert hat.
2026-07-02 21:43:55 +02:00
karim 31ac91b2a7 3D: Geschossdecken als extrudierte Polygone + hemisphärisches Licht
Deckenplatten (SlabInput) werden im render3d aus dem Grundriss-Umriss per
Ear-Clipping trianguliert und über die Deckendicke extrudiert (Deckel/Boden/
Mantel mit robust nach außen orientierten Normalen). Payload erweitert auf
{ walls, slabs } — blanke Wand-Arrays bleiben kompatibel. Beleuchtung auf
hemisphärisches Ambient (Himmel/Boden) + Directional-Sonne umgestellt, dezente
Kantenbetonung, hellerer Hintergrund (#f5f5f5). Beispiel-Geschossdecke im EG.
2026-07-02 21:08:34 +02:00
karim 0189eed5ac 3D-Politur: ACES-Tonemapping, PMREM-Environment, Standard-Materialien
Viewport3D nutzt jetzt ACESFilmicToneMapping + sRGB-Ausgabe (weiche
Lichter statt hartem Clipping) und eine neutrale Raum-Umgebung
(PMREMGenerator + RoomEnvironment) als IBL-Quelle für plausible
Reflexionen. Wände/Decken/Öffnungsrahmen/Glas/Türblatt/Treppen und das
Auswahl-Highlight laufen von MeshLambertMaterial auf MeshStandardMaterial
um (Rauigkeit/Metallizität je Bauteilart, moderate envMapIntensity);
direktes Licht entsprechend zurückgenommen, da die Umgebung nun mit-
trägt. Hintergrundfarbe, Hidden-Line- und Clay-Modus unverändert.
2026-07-02 21:04:22 +02:00
karim 45f9d0c381 3D-Wände mit Öffnungen: Türen/Fenster als Teilquader (Pfeiler+Brüstung+Sturz) 2026-07-02 21:00:19 +02:00
karim faa84e98af Echte Fonts im nativen 2D: glyphon-Glyphenatlas statt Browser-Overlay
Raumstempel-Text rendert jetzt im nativen wgpu-Fenster mit echten
TrueType-Glyphen (Inter, Systemfonts via cosmic-text) ueber einen
GPU-Glyphen-Atlas — im selben 4x-MSAA-Pass nach der Geometrie.
Schriftgroesse in Papier-mm (gleiche Formel wie Strichbreiten, skaliert
mit dem Zoom); die Bridge flacht Rich-Text-Stempel + Zusatzzeilen
zeilenweise auf serde-kompatible texts ab (Layout wie der SVG-Pfad).
2026-07-02 20:28:40 +02:00
karim e82f0b6de1 2D-Umrisse polygonuebergreifend naehen: Gehrung an Wand-zu-Wand-Ecken 2026-07-02 20:12:29 +02:00
karim 128c04a5d0 Natives 2D/3D live: Webview pusht Szene/Waende per Tauri-Command
Die nativen wgpu-Fenster spiegeln jetzt das LIVE-Modell statt des
eingefrorenen JSON-Snapshots: die Webview schiebt bei jeder Aenderung
(debounced 200 ms) den sichtbaren Plan (planToRenderScene) bzw. die
Projekt-Waende (projectToWalls3d) per push_native_scene/push_native_walls
an einen EventLoopProxy<UserEvent> der winit-Loop. Ansicht wird nur neu
eingepasst, solange im Fenster noch nicht gepannt/gezoomt/orbitiert
wurde; die JSON-Assets bleiben Start-Fallback. Ohne Tauri: No-op.
2026-07-02 20:10:38 +02:00
karim 19f99382f6 Chromium-App-Shell: fluessiger Launcher fuer die CAD-Oberflaeche
npm run shell startet den Vite-Dev-Server bei Bedarf und oeffnet ihn in
einem randlosen Chromium-Fenster, um den WebKitGTK-Cairo-Flaschenhals
von tauri:dev zu umgehen.
2026-07-02 19:54:11 +02:00
karim ec181998ca render2d: 4x MSAA fuer glatte Linien (wie im Browser)
Beide Pipelines (Fill/Line) rendern mit sample_count=4 in eine lazily verwaltete
Multisample-Textur (ensure_msaa, Muster analog render3d::ensure_depth) und
resolven in die Surface-View. Kantige Haarlinien im nativen 2D-Viewport werden
dadurch knackscharf; Renderer-API unveraendert.
2026-07-02 19:44:14 +02:00
karim c400a96575 Nativer 2D-Viewport: Raumstempel-Text wieder entfernen
Eine Einstrich-Vektorschrift sieht neben dem echten Browser-Font schlecht aus;
Text bleibt dem Browser-/Overlay-Pfad ueberlassen. strokeFont.ts entfernt,
toRenderScene ueberspringt Text-Primitive wieder. Schraffuren/Poche/Ecken bleiben.
2026-07-02 19:40:19 +02:00
karim b4c3c2de4a Nativer 2D-Viewport: Raumstempel-Text (Einstrich-Vektorschrift)
Neues strokeFont.ts (kompakte Einstrich-Schrift: A-Z, 0-9, Symbole inkl. ²/·).
toRenderScene wickelt Text-Primitive (Doc-Zeilen + Live-Zusatzzeilen) zu Glyph-
Polylinien in Modell-Metern ab (vertikal um den Anker zentriert, Ausrichtung je
Absatz) und speist sie wie die Schraffuren in die render2d-Szene. Erste Fassung
in Versalien; render2d bleibt textrenderer-frei. Damit zeigt der native Grundriss
den Raumstempel (Name/Fläche/SIA) analog zum Browser.
2026-07-02 19:05:01 +02:00
karim 788f4d58ca Nativer 2D-Viewport: Schraffuren wie im Browser
toRenderScene emittiert jetzt die aufs Polygon geclippten Musterlinien (glPlanHatch:
buildHatchRuns + applyDashRuns) als Polylinien — Daemmung/Diagonal/Kreuz erscheinen
im nativen wgpu-Grundriss identisch zum WebGL-Pfad. 'none'/'solid' brauchen keine
Linien (solid deckt die Fuellung farbig ab).
2026-07-02 18:58:13 +02:00
karim 229169cea1 Native wgpu-Viewports (2D+3D) im Tauri-Prozess: echtes Modell + gehrte Ecken
- native.rs: EINE winit-EventLoop hostet 2D- und 3D-Fenster (winit erlaubt nur
  eine Loop pro Prozess) — loest den RecreationAttempt-Panic zweier Loops; ersetzt
  native2d.rs/native3d.rs. Feature-gegated (native2d/native3d, einzeln oder zusammen).
- render2d/render3d laden das ECHTE Modell aus assets/native2d_scene.json bzw.
  native3d_walls.json (Demo-Szene als Fallback); initialer Ausschnitt/Kamera aus
  den Modell-Grenzen gerahmt, initialer Redraw + gesetzte Fenstergroesse.
- TS-Konverter toRenderScene/toWalls3d + scripts/dump-native-scene erzeugen die
  JSON aus sampleProject/generatePlan (npm run dump:native).
- render2d: Scene.polylines fuer zusammenhaengende Umriss-/Zeichnungslaeufe →
  Gehrung statt Stumpfkappen an Wandecken/2D-Geometrien; MITER_LIMIT 4→8
  (deckungsgleich mit SVG stroke-miterlimit:8 und WebGL2).
- native3d-Feature + render3d-Pfad-Dep in der Tauri-Crate.
2026-07-02 08:54:26 +02:00
karim f4cd16b7ac Add native2d feature: wgpu 2D viewport window inside Tauri process
Spawns a GTK-free winit window with its own wgpu surface from Tauri's
setup hook on a background thread, sidestepping the WebKitGTK surface
contention on Wayland. Reuses render2d's renderer via a shared demo
module. Opt-in behind the native2d cargo feature; default build
unaffected.
2026-07-02 01:26:07 +02:00
karim e7ea1eeddd 2D-Plan-Qualitaet: saubere Wandecken + glatte Dämmschraffur
Wandecken (generatePlan/glPlanCompile/PlanView): die diagonale Miter-Stirnkante
am L-Stoss wurde als Umriss gestrokt -> Barb-Ueberstand am Aussenapex + 45deg-
Naht zwischen gleichen Schichten. Fix: interne Join-Stirnkanten per neuem
noStrokeEdges vom Umriss ausnehmen (Fuellung bleibt volle Flaeche, laengs
laufende Materialfugen bleiben). Ecke = sauberer Miter ohne Ueberstand, gleiche
Schichten verschmelzen nahtlos ueber die Ecke. GPU- und SVG-Pfad teilen sich
noStrokeEdges.

Dämmschraffur (glPlanHatch): die Sinuswelle wurde pro Segment einzeln aufs
Polygon geclippt und mit Butt-Caps gestrokt -> fransige Fragmente an jeder
Wellenbiegung. Fix: kontinuierliche Punkt-Laeufe (clipPolylineToPolygon) als EIN
gehrter Streifen; Dash laeuft ueber die Laeufe. STEPS 12->22. Gerade Scharen
(diagonal/crosshatch) unveraendert.
2026-07-02 00:54:41 +02:00
karim c96239a794 Nativer 3D-wgpu-Renderer (render3d, M0+M1): Wand-Extrusion, Kamera, Licht
Eigenstaendige Crate wie render2d (render/window-Stufung, serde-only Mesh-
schicht headless testbar). Wand-Extrusion (Band via Links-Normale, Quader mit
nach aussen zeigenden Normalen), handgerechnete Mat4 (perspektiv+ortho, wgpu-
Clip-Z [0,1], 5 Kamera-Presets), Directional-Light + Tiefenpuffer + Backface-
Culling. Orbit-Spike (cargo run --features window --bin spike3d). Plus Port-
Briefing mit M2..M9-Milestones (three.js-Viewport-Bestandsaufnahme).
2026-07-02 00:29:02 +02:00
karim 260320af2e GPU-Schraffuren im WebGL2-Grundriss: diagonal/crosshatch/insulation + Dash
Muster in Modell-Metern erzeugt (massstabstreu wie SVG-<pattern>), konkav-
faehig auf das Fuellpolygon geclippt (even-odd-Scanline, halb-offene Kanten-
Konvention). Strichbreite in Papier-mm; Dash geometrisch aufgeloest. Emittiert
nach der Fuellung, vor dem Umriss (Stapelreihenfolge wie SVG). 12 Unit-Tests.
2026-07-02 00:28:11 +02:00
karim f08ef13fe8 Nativer 2D-wgpu-Renderer (render2d): Ear-Clipping-Fuellungen, gehrte Papier-mm-Linien, GPU-Pan/Zoom
Eigenstaendige Crate (render/window-Feature-Stufung), serde-only Tessellier-
schicht headless testbar. Linien als EIN gehrter Streifen (Miter-Bisektor +
1/cos-Laengenfaktor) statt Butt-Cap-Quads pro Segment -> saubere Ecken.
Standalone-Spike-Fenster via winit (cargo run --features window --bin spike).
2026-07-02 00:27:46 +02:00
karim 3d2d4d6321 2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
2026-07-02 00:12:39 +02:00
karim cfe5249440 Add parametric walls unit tests
50 Vitest-Tests für die parametrische Wand-Engine (src/model/parametricWalls.ts):
GridRule, ModuleRule, ConditionalThicknessRule, ReferenceLineRule, SequenceRule,
deduplicateWalls, matchesCondition/matchesTarget, Integration via resolveParametricWall.
Vitest als devDependency + Testskript in package.json + vitest.config.ts ergänzt.
2026-07-01 20:33:38 +02:00
karim b9731a4979 Add parametric walls design documentation 2026-07-01 20:32:07 +02:00
karim ce3b594403 Add parametric wall types and engine skeleton 2026-07-01 20:06:19 +02:00
karim 94a5af6b6f HANDOVER: Koordinations- und Arbeitsregeln ergaenzt 2026-06-30 21:45:10 +02:00
karim ca859c4aa4 Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge
Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
2026-06-30 20:52:27 +02:00
252 changed files with 30364 additions and 29599 deletions
-17
View File
@@ -15,20 +15,3 @@ scripts/*.png
# Generierte native-Viewport-Szenen (aus sampleProject via scripts/dump-native-scene.mjs)
src-tauri/assets/native2d_scene.json
src-tauri/assets/native3d_walls.json
# Interne Doku (Handover/Pendenzen/Design-Notizen) — nur lokal, nicht im
# öffentlichen Gitea-Code-Browser. README.md bleibt als Repo-Beschreibung.
/ARCHITECTURE.md
/CONVENTIONS.md
/HANDOVER.md
/PENDENZEN.md
/PORT_PLAN.md
/RESEARCH_BAUTEILE_RHINO.md
/RESEARCH_CAD_APPROACHES.md
/ROADMAP.md
/SPIKE_TEXTUR_render3d.md
/STATUS.md
/docs/*.md
/docs/design/*.md
/docs/research/*.md
/docs/welle-c-hlr-spike/*.md
+390
View File
@@ -0,0 +1,390 @@
# Architektur — Dossier (Desktop-CAAD)
> 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** 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.
**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 (`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. 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 · drawingLevels · │
│ layers · walls/doors/openings/stairs/… │
└──────────────┬───────────────────────────┘
│ pure derive()
┌───────────────────────┼────────────────────────────┬───────────────┐
▼ ▼ ▼ ▼
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 ─────────────┘
```
---
## 1. Repo-Struktur (IST-Zustand)
```
src/
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 (unverändert gegenüber der Vision)
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 …`).
### 2.2 Elemente — typisierte Arrays statt `Element[]`-Union
Anders als ursprünglich geplant hält `Project` (`src/model/types.ts:2095`)
**pro Bauteiltyp ein eigenes (meist optionales) Array**:
```ts
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?
}
```
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 Ressourcen & Aufbauten (unverändert gegenüber der Vision)
```ts
interface Resources { lineStyles: LineStyle[]; hatches: Hatch[]; components: Component[]; }
interface WallType { id; name; layers: { componentId; thickness }[]; }
```
`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 Layouts / Ausschnitte
`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 — eigener `useSyncExternalStore`, nicht Zustand
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)`.
**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.
### 3.2 Persistenz
- **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
Eigener History-Ring im `projectSlice` (kein DOSSIER-Sticky-Bus, kein
Cache-Stale-Problem, da alle Sichten pure Ableitungen sind).
---
## 4. Rendering
### 4.1 Zwei 3D-Viewports
- **`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:
```
App.tsx (section3dCutId/section3dPlane)
→ Wasm3DViewport.tsx (section3d-Prop → setSectionPlane)
→ src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}
```
`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.
### 4.4 Öffnungen als Löcher (kein Mesh-Boolean)
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.
### 4.5 Materialien
`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
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.
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).
**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 → Dossier — Mapping-Tabelle (Kernkonzepte)
| DOSSIER (Rhino-Plugin) | Dossier (Tauri) | Anmerkung |
|---|---|---|
| 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 übernommen wurde — und was bewusst anders lief
**Ü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).
**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 (weiterhin gültig):** keine Abstraktion ohne konkretes
Problem; erst lesen, dann editieren; Geometrie nie „nebenbei" refactoren.
---
## 8. Reihenfolge bei Code-Arbeit
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.
+78
View File
@@ -0,0 +1,78 @@
# Projekt-Konventionen — Dossier
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)
- **Alle Bezeichner im Code sind ENGLISCH** — Funktionen, Variablen, Typen, Felder,
Datei-/Modulnamen. Keine deutschen Bezeichner. (Beispiel: `computeJoins`, nicht
`verschneidungBerechnen`.)
- **UI-Texte und Kommentare dürfen Deutsch sein** (Nutzeroberfläche ist deutsch).
- **Domänen-Begriffe** möglichst nach Vectorworks-Terminologie benennen (englisch):
Design Layer, Sheet/Drawing Layer, Component, Class, Hatch, Wall Style, Viewport.
- **Einheiten:** intern alles in **Metern** (number). Anzeige via `formatM`.
- **Geometrie-Konventionen:** Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`; bei
CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (−T/2) → innen (+T/2)
gestapelt.
## Code-Struktur (kein God-Component — bisher nur teilweise erreicht)
- **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).
## UI-Konventionen
- **Listen-/Manager-Ansichten als saubere Tabellen:** eine Kopfzeile mit
Spaltentiteln (sticky), darunter kompakte Datenzeilen mit Inline-Edit pro Zelle.
KEINE wiederholten Feld-Beschriftungen pro Zeile. Gilt für Component-/Hatch-/
Line-Manager und ähnliche Listen.
- Dunkler DOSSIER-Stil; kompakt, ruhig, viel Inhalt pro Fläche.
- **UI-Text immer übersetzbar (i18n):** KEINE hartcodierten sichtbaren Strings im
JSX. Alle Texte über eine Übersetzungsfunktion `t('key')` aus einem Wörterbuch
(Default-Sprache Deutsch). Keys wie bei DOSSIER (`common.delete`, `layers.settings`,
`topbar.resources`). Neue Komponenten gleich mit `t(...)` schreiben. Identifier/Keys
bleiben englisch; nur die Wörterbuch-Werte sind die übersetzbaren Texte.
## Native-App-Verhalten (kein Browser-Standard)
Die App soll sich wie ein natives Programm anfühlen, nicht wie eine Webseite:
- **Browser-Kontextmenü global unterdrücken** (`document` `contextmenu` → `preventDefault`).
Nur unser eigenes `ContextMenu` erscheint; auf Flächen ohne eigenes Menü passiert nichts.
- **Keine Textauswahl / „Alles markieren":** `user-select: none` global; `user-select: text`
NUR in echten Eingaben (`input`, `textarea`, `[contenteditable]`). Ctrl+A außerhalb von
Eingaben unterbinden.
- Bild-/Element-Drag aus (`draggable=false` wo nötig); keine Browser-Drag-Gesten.
## Architektur-Prinzip
Ein **semantisches Modell** ist die einzige Wahrheit; jede Ansicht (3D, Grundriss,
Schnitt) wird **abgeleitet**. Darstellung (Detailgrad, Stile, Schraffuren) wird beim
Rendern angewandt, nie in die Geometrie eingebacken.
## Arbeitsweise (für Beiträge)
- Substanzielle, mehrstufige Arbeit schrittweise in isolierten Schritten angehen.
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
- 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).
+255
View File
@@ -0,0 +1,255 @@
> **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 1–4 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.
> **➡️ Aufgaben-Queue steht in `PENDENZEN.md`** (Single Source of Truth, priorisierte Checkliste + Arbeitsprotokoll). HANDOVER = nur noch Kontext/Konventionen/Environment/Zielmodelle, KEIN Backlog mehr. Neue Pendenzen/Rückfragen dort eintragen, nicht hier.
## ⚠️ NACHTRAG 2026-07-04 abends — Unterbruch, 3 Slices verloren
> **STAND-UPDATE (Session 4, neues Gerät):** **Environment behoben** — Toolchain vorhanden (`node`/`npm`/`cargo` unter `/usr/bin`, `wasm-pack` via `npx` 0.15.0; es war die Flatpak-Sandbox); fehlendes Rust-Target per `sudo dnf install rust-std-static-wasm32-unknown-unknown` (+ lld) nachinstalliert; beide WASM-Engines gebaut → App läuft. **Slice 1 (Locked-Iso `cb8fae5`) + Slice 2 (Joins Phase 1c `c5a344d`) committet.** **Slice 3 (Öffnungen als Boolean-Löcher + Deckentrim-nur-3D) ✅ committet `1407c68`:** Fenster/Türen = 1 Wandkörper pro Schicht-Band mit rechteckigen `holes` (render3d stanzt sie per achsparalleler Rechteck-Gitter-Zerlegung + 4 Laibungsquads/Loch), Segment-Boxen weg; `holes`/`openings` schliessen sich im Emitter aus; Schnitt-Pfad (layered=false) segmentiert unverändert weiter. Deckentrim `trimWallTopForCeilings` gilt nur noch im 3D-Pfad, Schnitt-Pfad bekommt volle Wandhöhe. Alle Datei-Edits waren gelandet — unabhängig verifiziert: cargo test 56 (inkl. beider Pflicht-Tests versetzt-überlappende Fenster + Tür-Loch-bis-Boden), vitest 230, build:engine3d + tsc sauber, Trace clean. **Damit sind alle drei verlorenen 3b-Slices rekonstruiert.** **Memory-Direktiven `priority-based-joins.md` + `openings-as-booleans.md` wiederhergestellt.**
>
> **⚠️ PUSH BLOCKIERT:** keine Git-Credentials auf diesem Gerät (kein Helper, keine `~/.git-credentials`, kein SSH-Key; Remote HTTPS `git.openbureau.ch/karim/DOSSIER-STANDALONE`). `cb8fae5` + `c5a344d` (+ folgender Öffnungen-Commit) sind **lokal committet, NICHT gepusht** (`ahead of origin/master`). Sobald Gitea-PAT (repo-write) oder SSH-Key da ist: `git push origin master`. Bis dahin holt das andere Gerät die Arbeit nicht.
>
> Der folgende Block bleibt als historischer Kontext/Vorlage für den offenen Slice 3 stehen.
Die Arbeit wurde unerwartet unterbrochen — mehrere Tasks liefen parallel. Diese Übergabe wurde nachträglich rekonstruiert. **Der tatsächliche HEAD-Commit ist `03f0c40`** (Nordstern-3D Live-Schnittebene) — alles danach existierte nur im Arbeitsbaum der alten Maschine und ist auf **diesem** Checkout (neues Gerät, `git status` sauber bis auf `package-lock.json`) **nicht vorhanden**. Diese drei Slices waren in echter Arbeit (nicht nur geplant) und gelten als verloren — neu beauftragen, nicht nur „prüfen ob gelandet":
1. **Locked-Iso-Fix** (`src/viewport/Wasm3DViewport.tsx`, `OrbitState` um `ortho: boolean` + `orthoHalfHeight: number` erweitern, `orbitCamera()`/Preset-Effect/Pan/Zoom entsprechend anpassen) — Bug: Iso-Ansicht kippt bei der ersten Kamerabewegung sofort in Perspektive statt orthografisch (parallel) zu bleiben. War fertig gebaut und quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit, 225/225 Tests grün), aber **nicht mehr committet**, bevor der Unterbruch kam. Kleinster, unabhängigster der drei Fixes — zuerst neu machen.
2. **Joins Phase 1c — Durchgangswand echt aufbrechen** (`spanCutouts` in `src/model/joins.ts` + `src/plan/generatePlan.ts` + Rust-Parität `src-tauri/geometry/src/lib.rs`): Nutzer-Befund nach Phase 1b war, dass der Innenputz der DURCHGANGSWAND am T-Stoss nur überdeckt wird (Zeichenreihenfolge), nicht geometrisch ausgeschnitten — Fugen-/Nahflächenlinien laufen noch durch den Backstein-Durchstoss, es „liest" noch nicht wie ein einziger Join. Das war die **explizite Priorität des Nutzers** für diese Session. Die Umsetzung war weit fortgeschritten (schrieb bereits in `joins.ts`), dann abgebrochen. **Nicht committet, nicht im Baum.** Vollständige Aufgabenbeschreibung mit allen Datei:Zeile-Ankern steht im Transkript bei „Agent:Phase 1c: Durchgangswand aufbrechen" — 1:1 als Vorlage für den Neu-Auftrag wiederverwenden.
3. **Öffnungen als echte Boolean-Löcher** (`RWall.holes`, `src/plan/toWalls3d.ts`, `src-tauri/render3d/src/mesh.rs`, additiv `types.rs`): EIN Wandkörper mit rechteckigen Löchern statt der heutigen Pfeiler/Brüstung/Sturz-Ersatzkörper (sichtbare Segment-Nähte, keine echten Löcher). Pflicht-Testfall (Nutzer-Direktive, Kernmotivation des Slices): zwei Fenster in derselben Wand mit überlappendem Achsen-Intervall, aber unterschiedlicher Höhe (z. B. u=[1.0,2.0]/z=[0.9,2.1] und u=[1.5,2.5]/z=[0.3,0.7]) — die alte Segment-Zerlegung kann das nicht abbilden, echte Löcher schon. Zusatz-Auftrag im selben Slice: `trimWallTopForCeilings` (Phase-3-Deckentrim, Commit `e4b8df6`) darf **nur** im 3D-Viewer-Pfad (`layeredWalls:true`) gelten, **nicht** im Schnitt-Pfad (`layeredWalls:false`) — der Schnitt braucht die volle, ungetrimmte Wandhöhe für seine eigene schichtweise Prioritäts-Subtraktion (`subtractDominantBands`), sonst zeigt der Schnitt die Wände fälschlich abgeschnitten. Fortschritt bei Abbruch **unklar** (letztes Status-Update deutete auf „noch in der Kartierungsphase" hin) — vermutlich am wenigsten weit von den dreien; komplett neu beauftragen. Vollständiger Auftragstext im Transkript bei „Agent:Oeffnungen als Boolean-Loecher" + die zwei Zusatz-Direktiven (Decken-Trim-Fix, Pflicht-Testfall versetzte Fenster) kurz danach.
**Zwei Engine-Direktiven vom alten Stand fehlen** (`priority-based-joins.md`, `openings-as-booleans.md`): die in Punkt 2/3 beschriebenen Zielverhalten waren gesichert (u. a. „Trennlinien an Merge-Kontakten verschwinden immer", „Öffnungen dürfen nie wieder zu Segmenten vereinfacht werden"). Beim Neu-Beauftragen diese Direktiven wieder explizit festhalten.
**Environment-Lücke auf diesem Gerät:** `node`/`npm`/`cargo`/`wasm-pack` waren zum Zeitpunkt dieser Übergabe **nicht auf dem PATH** (geprüft: kein Treffer, auch nicht unter der VSCodium-Flatpak-Datenlage). **Vermutliche Ursache: VSCodium lief als Flatpak** — Flatpak-Sandboxing blendet system-installierte Toolchains (Node/Rust unter `/usr/...` oder `~/.cargo`) typischerweise aus, auch wenn sie auf dem Host tatsächlich installiert sind. Der Nutzer wechselt gerade auf **natives VSCodium** (kein Flatpak mehr) — das könnte die Lücke von selbst beheben, MUSS aber nach dem Wechsel neu geprüft werden (`which node npm cargo wasm-pack`), bevor man von "Toolchain fehlt" auf "Toolchain neu installieren" schließt. Erst wenn nach dem Wechsel auf natives VSCodium immer noch nichts gefunden wird, wirklich neu installieren (Node LTS + Rust via rustup + `wasm-pack`, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml` durchlaufen lassen). Ohne Toolchain ist die in `CONVENTIONS.md` vorgeschriebene Verifikation (`tsc`/`vitest`/`cargo test`/`wasm-pack`) nicht möglich — das zuerst klären, bevor einer der drei Slices neu beauftragt wird, sonst baut ein Agent blind ohne Verifikation.
**Aufräum-Hinweis:** `Edit MEMORY md Added 1.txt` (lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung waren dirty/untracked. Die txt-Datei gehört nicht ins Repo — außerhalb verschieben oder löschen. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
### Empfohlene Reihenfolge zum Wiederaufsetzen
1. Nach dem Wechsel auf natives VSCodium zuerst `which node npm cargo wasm-pack` prüfen — evtl. war es nur die Flatpak-Sandbox, die die Toolchain versteckt hat. Nur falls immer noch nichts gefunden wird: Node + Rust + wasm-pack installieren + `npm install`. Blocker für alles Weitere.
2. Locked-Iso-Fix neu bauen (klein, unabhängig, Spezifikation im Transkript vollständig vorhanden).
3. Joins Phase 1c (Nutzer-Priorität dieser Session) — Direktive zuerst wieder ins Memory schreiben, dann Agent mit dem Auftragstext aus dem Transkript beauftragen.
4. Öffnungen als Boolean-Löcher + Deckentrim-nur-3D-Zusatz (dieselbe Rust-Crate wie Phase 1c — nacheinander, nicht parallel, wegen Datei-Overlap in `mesh.rs`/`toWalls3d.ts`).
5. Repo aufräumen (txt-Datei raus, `package-lock.json` klären), danach diesen Abschnitt auf „erledigt" aktualisieren.
## NACHTRAG Session 3 (2026-07-04 mittags) — Prioritaets-Joins + Schnitt-Sanierung
**Prioritäts-Join-Epic (Kern-Direktive, siehe unten „Verschneidungs-Zielmodell"):** Joins werden nach `Component.joinPriority` materialbewusst aufgelöst — konsistent in Grundriss, Schnitt UND 3D. Gelandet:
- `e1698b7` **Wand-T-Verbindungen** (T-Knoten 3 Enden + Mittelspannen-Stoss) in `computeJoins`; generatePlan wendet die Cuts unverändert an. `0ddf99d` Rust-Parität (geometry-Crate). `d74dda1` Sample: Querwand **W9** (neuer Wandtyp `iw` Innenputz/Backstein/Innenputz) als T-Demo, Tür/Fenster beiseite gerückt.
- `fc0a76f` **Phase 1 materialbewusst**: `resolveJoinPriority` (merge/coexist/trim), `WallCuts.layerCuts` (per-Schicht-Cuts + L-Seitenlinien), Backstein-Kern verschmilzt, Putz bricht als L. `bf97d50` **Phase 1b**: Kern sticht nur durch den Nah-Putz und stoppt an der Rückgrat-Nahfläche (mergeCut bei `offT + sign*(tT/2 − nearPlaster)`), KEINE Achs-Überlappung; Naht entfällt über stroke:none/noStrokeEdges/Zeichenreihenfolge. Rust jeweils synchron.
- `e4b8df6` **Phase 3 (3D)**: Wand wird an der Deckenunterkante getrimmt, wenn eine Decke höherer joinPriority bündig aufliegt (`trimWallTopForCeilings` in toWalls3d, BBox-Footprint-Näherung dokumentiert) → kein Z-Fighting Wand/Decke.
**Schnitt-Sanierung (Regressionen aus der 3D-Schicht-Umstellung `de7a26b` behoben):**
- `da39f0c` per-Schicht-3D-Bänder nutzten die RECHTE Normale statt `leftNormal` → Schichten in 3D+Schnitt gespiegelt (Backstein aussen). Eine Zeile in `pushSegment` gedreht.
- `6c47355` Schnitt bekam durch die per-Schicht-Boxen segments×layers Cut-Polygone → Owner-Index-Versatz → einheitliches Diagonal-Muster. Fix: `projectToModel3d(project, { layeredWalls:false })` für `computeSection` (Voll-Box pro Wand); 3D-Viewer behält Default true.
- `5ed3549` **Alle Schraffuren monochrom**: HATCH_INK `#0f0f0f` / HATCH_PAPER `#f0f0f0` (resolveHatch erzwingt Ink; Albedo-Fallback im Schnitt entfernt). Print-Mono separat/unangetastet.
- `c36a7e0` Wandrelative Muster drehen im Schnitt mit der Wand (`SECTION_WALL_AXIS_ANGLE_DEG=90` an resolveHatch) → Dämmung horizontal quer zur Dicke.
**Sonstiges:** `1855717` Renderer-Umschalter aus der Statusleiste in die **Settings**, Default **Nordstern** (WebGL2 nur explizit/Fallback). `32497bc` TopBar: Zoom-Cluster von Grid auf Flex (Phantom-Zeile +6px weg, jetzt bündig mit Zweizeilern), Bar 56→60px.
**IN FLIGHT bei Checkpoint:** (1) **3D-Live-Schnitt** (Opus-Agent): Clip-Plane-Uniform + discard in MESH/GRID_WGSL, `section_fill.rs` Cut-Caps aus `cut_section` mit prozeduraler 45°-Schraffur (CAP_WGSL), `set_section_plane` (web.rs cached walls/slabs), Viewport-Toggle; WASM-Rebuild nötig. (2) **Joins Phase 2** (Opus-Agent): Merge-Regel (gleiche Prio+Komponente) in der Schnitt-Dominanz `subtractDominantBands`. — `git status` prüfen, verifizieren, committen.
**NÄCHSTER Engine-Slice (Nutzer-Direktive, queued):** **Öffnungen als echte Boolean-Löcher** — Fenster/Türen sind heute Pfeiler/Brüstung/Sturz-Ersatzkörper (keine CSG, Triangulator lochfrei) → sichtbare Segment-Nähte. Ziel: EIN Wandkörper, Löcher via earcut-with-holes + Laibungsflächen; behebt auch die dokumentierte mesh↔cut_section-Öffnungs-Inkonsistenz. Läuft im selben Crate wie der 3D-Schnitt → erst danach starten.
**Verschneidungs-Zielmodell (Nutzer, verbindlich):** gleiche Komponente+Priorität = verschmelzen (Naht weg); schwächere Schicht bricht auf und schliesst als L; höhere Priorität schneidet bauteilübergreifend (Decke>Wand); Joins IMMER in allen drei Sichten identisch. Schraffur-Modell: Schnitt-Schraffur vs. Oberflächen-Schraffur, aktuell ALLE monochrom fg #0f0f0f auf bg #f0f0f0.
## NACHTRAG Session 2 (2026-07-04 nachmittags/abends) — TopBar-Redesign + 3D-Editierbarkeit
**TopBar/UI gelandet:** DOSSIER-Wortmarke + Ressourcen-Icon links (Rhino-Stil), Massstab/Zoom-Cluster bereinigt (Duplikat weg, Zoom-Aktionen unter Massstab-Dropdown gestapelt, gleiche Breite, Ring bei „am Massstab" statt Flächen-Aufhellung), PDF/DXF raus → **Datei-Burger-Menü** rechts (Speichern/Öffnen als echter Projekt-JSON-Download/-Upload, Import, Export). **Einstellungs-Fenster** (`SettingsDialog.tsx`): Accent-Palette (`src/theme/accents.ts`), Auswahlrahmen-Farbe (Default Yuyake #CEB188) + Snap-Farbe (vorläufig **Sora #5FA1C9** gewählt — „aki"-Rückfrage bleibt offen, per Picker änderbar), Projekt-MüM-Feld (`project.referenceElevationMasl`, nur gespeichert, Draping folgt). **Custom Fenstersteuerung** (_/□/X) für Electron (`electron-preload.cjs` + IPC, `WindowControls.tsx`, TopBar = Drag-Region). **Dark-Theme ist jetzt fester Standard** (vorher an OS-`prefers-color-scheme` gekoppelt → zeigte fälschlich Light; Light bleibt Opt-in via `:root[data-theme="light"]`). Alle TopBar-Pillen auf dieselbe stets-dunkle Kontextmenü-Fläche vereinheitlicht; Panel-Köpfe flach (kein hellerer Balken) + feste 40px-Höhe. **Undo/Redo** (`historySlice.ts`, Ctrl+Z/Y, Coalescing für Drags). **15 gebündelte OFL-Schriften** (`public/fonts/`, `src/text/fonts.css`) statt Systemfonts. **Farbfelder**: Hex separat editierbar (`ColorHexField.tsx`). **Text-Zeilenhöhe-Regler** ersetzt „+ Text" (Text wird eigenes Werkzeug, AUDIT B1). **Linienstil** im Attribute-Panel editierbar. 2D-Zoom-Grenzen stark erweitert (20000×/0.005×).
**3D-Editierbarkeit gelandet (Nordstern/render3d, NICHT three.js — Free=three.js-Viewer, Premium=editierbarer Nordstern, siehe Memory):**
- **R1** `85a721f` Boden-Referenzraster auf y=baseElevation des aktiven Geschosses, ein-/ausschaltbar (Overlay-Button, `viewSlice.grid3dVisible`; eigene LineList-Pipeline `grid.rs`, `set_ground_grid`). Kamera: Links=Orbit, **Mitte/Rechts=Pan** (vorher Pan nur auf Shift+Mitte versteckt), Rad=Zoom-to-Cursor.
- **R2** `6ef57b6` View-Styles **wireframe** + **hidden-line** + Fill-Light (`set_render_style`, `edges.rs` = Feature-Edge-Extraktion mit Crease-Dedup, Hidden-Line via Depth-Bias-Flächen + Kanten obenauf). `textured` → vorerst wie shaded (keine Textur-Pipeline).
- **R3** `ac0d9ef` **Klick-Auswahl** im 3D (`raycast3d.ts`: OBB-Wand-/Prisma-Slab-Schnitt → Store-Selektion → Attribute+Objekt-Info-Panel). `toWalls3d.pickGeometry()` mit wallId/ceilingId.
- **R4** `fabf97a` **Auswahl-Highlight**: orange Outline, immer sichtbar (No-Depth-Linien-Pipeline `set_highlight_lines`, `selectionHighlightLines()`).
- **R5** `02bd261` **Materialfarben** im 3D: Wände/Decken in Farbe der dicksten Schicht statt Einheitsgrau.
- **weiter:** `8bc472a` 2D **z-Anordnen** (Kontextmenü nach vorn/hinten, `reorderDrawings`); `de7a26b` **per-Schicht Wand-Bänder** (jede Materiallage als eigener farbiger Quader, wallId bleibt → Pick/Highlight ganze Wand); `e42c336` **AUDIT A6 Bauteil-Schedule-CSV** (Datei-Menü, `exportSchedule.ts`); `7dd324d` **Glasscheiben in Fenstern** (A5-Teil, RMesh in jeder Fensteröffnung).
- Alle Runden **visuell im Browser verifiziert** (Playwright + WebGPU-Flags: `--enable-unsafe-webgpu --enable-features=Vulkan --use-gl=angle --use-angle=vulkan --no-sandbox`, URL `?engine=wasm`, Iso-View klicken).
**3D-REST (engine-schwer, bewusst NICHT blind gemacht — mit Nutzer angehen):**
- **Schnitt-Schraffuren** (2D-Poché/Schraffur auf 3D-Schnittflächen; `section.rs`, ~66KB, komplex).
- **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern).
- **Wand-Schicht-Bänder** in 3D (Option B aus R5: jede Materiallage als eigener extrudierter Teilquader statt nur repräsentativer Farbe — pro-Schicht-Farben liegen in `RWall.layers` schon vor).
- **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; für Front/Top/Side-Presets vor der ersten Navigation leicht ungenau).
- **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
WASM-Workflow: `src/engine/pkg3d/` ist gitignore't → nach Rust-Änderung `npm run build:engine3d`, nur die `.rs` committen. DOSSIER-Audit-Details jetzt in `docs/design/dossier-feature-audit.md` (A1–A6/B1–B4/C/D/E) + `ROADMAP.md` §11.
## Konventionen (ZWINGEND — zuerst lesen)
- **Keine Fremd-Tool-Spuren** im Repo: vor jedem Commit Trace-Scan (`git diff | grep -iE "co-authored|generated with"`) → muss leer sein. Commits deutsch, sachlich, KEINE Co-Authored-By-Trailer.
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
- **Verifikation:** `tsc --noEmit` clean + `vitest run` + trace-scan vor jedem Commit. Nur Task-Dateien stagen (Kollisionen: parallele Tasks disjunkte Datei-Lanes geben; i18n exklusiv einem Task).
- **Engine = „Nordstern"** (render2d/render3d/WASM). Referenz DOSSIER-Rhino: `https://git.openbureau.ch/karim/dossier` (Alias git.kgva.ch), geklont `/tmp/dossier-ref`.
## In dieser Session gelandet (Auswahl Commits)
Schraffur/Linien-Epic komplett: Typmodell (vector/image Hatch, dash/zigzag/custom Line, Component Vordergrund/Hintergrund), ResourceManager **Master-Detail** (Schraffuren/Linien/Wandstile/Deckenstile, `9695748`), Rendering neuer Typen (Bild-`<pattern>`, random modellraum-verankert, Zickzack, Custom-Motiv-Editor `src/ui/MotifEditor.tsx`), modulares Linien-Segment-System (Strich/Punkt/Lücke), Attribut Vordergrund/Hintergrund + **By-Layer/By-Object-Quellen** (Nach Ebene/Bauteil/eigener Wert für Vordergrund/Hintergrund/Strichstärke/Schraffur, `d02781e`).
Weiter: Gehrung spitze Winkel (2D+Schnitt+Rust), TopBar/Footer-Reorg, Zoom-% am Massstab, Tool-Shortcuts 1..0, Verschiebe-Dreieck flacher + Snap, Statusleiste „Nordstern", ResourceManager als **floating nicht-modales Fenster**, **swisstopo** Gebäude(extrudiert)+Terrain-Mesh in 3D (`f22970f`), Schnitt-Wandschicht-Orientierung (`7569524`), **Schnitt-Boolean-Dominanz** nach joinPriority (`23d81be`).
## Backlog & Rückfragen → `PENDENZEN.md`
Der frühere „Offener Backlog"/„Offene Rückfragen"/„IN FLIGHT"-Abschnitt ist vollständig nach `PENDENZEN.md` migriert (Single Source of Truth). Dort steht auch das Arbeitsprotokoll.
## Verifikation
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
+300
View File
@@ -0,0 +1,300 @@
# PENDENZEN — Aufgaben-Queue (Single Source of Truth)
> **Diese Datei ist die einzige verbindliche Aufgabenliste.** HANDOVER.md enthält nur
> noch Kontext/Konventionen/Environment/Zielmodelle — keinen Backlog mehr.
## Arbeitsprotokoll (ZWINGEND)
**Bearbeiter:**
1. **Zuerst diese Datei lesen**, dann `CONVENTIONS.md` + verlinkte Detailquellen des Items.
2. Oberstes nicht-blockiertes Item aus **🔧 In Arbeit** bzw. sonst **⏭️ Als Nächstes** nehmen, **fertig** machen, verifizieren (`npx tsc --noEmit` + `npx vitest run` + trace-scan), abhaken, eine Ergebniszeile nach **✅ Erledigt** schreiben (mit Commit-Hash, sobald committet).
3. **Rückfrage-Recht:** Bei Unklarheit/Design-Entscheid NICHT raten — Item mit `❓` markieren, kurze Frage darunter notieren, nächstes freies Item nehmen. Fragen sammeln sich unter **❓ Offene Rückfragen**.
4. **Kein Arbeitsschritt endet ohne aktualisierte Liste.** Status hier ist immer aktuell, sonst driftet es wieder.
5. Bearbeiter **committet nicht selbst** und delegiert Unteraufgaben nicht weiter.
**Planer** (mit dem Nutzer, kuratiert):
- Nimmt Nutzer-Feedback, pflegt daraus die Liste (rein/umpriorisieren/aufspalten). Führt selbst keine Queue-Items aus.
- Prüft **❓**-Zeilen mit dem Nutzer, wandelt sie in konkrete Items.
---
## ⛔ 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 71–74) 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: ~4–6 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.0–0.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`. ✅ **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).
## ⚠️ Zu prüfen (evtl. schon erledigt / Status unklar)
- [x] ~~**Snap an Wand-Schichttrennlinien**~~ — **gelandet `339202b`** (`wallLayerBoundarySegments` in `src/tools/snapping.ts`), verifiziert vorhanden.
## ⏭️ Als Nächstes
- [ ] **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: Cyrus–Beck 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 1–6) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, 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 R13–R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen** — `parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt** — `parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT** — `parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings` → `{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen** — `Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
- [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.**
- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).** ✅ **Phase 1 (Wand unter Schnittebene) erledigt (2026-07-05):** `generatePlan.addWallPoche` hat einen `viewOnly`-Zweig — erreicht eine Wand die Grundriss-Schnitthöhe nicht (`wall.height < floor.cutHeight`, z. B. 0.3-m-Brüstung bei 1 m), wird sie nur als Ansichts-Umriss (Haarlinie, `fill:"none"`, keine Schraffur) gezeichnet statt als Schnitt-Poché; normale Wände (≥ Schnitthöhe) unberührt. Segment-/Gehrungs-/Öffnungs-Logik geteilt. +2 Tests (`generatePlan.viewwall.test.ts`, Suite 336). ✅ **Phase 2 (Decke über Ebene = gestrichelte Überkopf-Linie) erledigt (2026-07-05, `39ddd9b`):** der freie Decken-Umriss (Überstände/Balkone, wo keine Wand verdeckt) ist jetzt gestrichelte Haarlinie (`OVERHEAD_DASH`) statt kräftiger Volllinie — BIM-Konvention „Aufsicht auf Bauteil über einem". Decken-FLÄCHE war schon Ansicht (viewHatchId, weiß). +1 Test. **Offen (Phase 3):** per-Component View-/Cut-Weight + eigene View-Schraffur (Slot-Trennung); cut/Aufsicht/Untersicht (Deckenspiegel/Reflected Ceiling Plan); Unterzüge; Feinheiten. Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m):
- Bauteil wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten).
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
- **Passt konsistent zum bereits existierenden `viewHatchId` (Ansichts-Schraffur) vs. Schnitt-Schraffur** — die Linienstärke-Dualität (View-/Cut-Weight je Component) ist die natürliche Erweiterung derselben Logik.
- **Nicht nur cut/view, sondern cut / AUFSICHT / UNTERSICHT (Nutzer):** ein Bauteil sieht von oben anders aus als von unten. Der Grundriss (Blick nach unten) zeigt Bauteile UNTER der Ebene in **Aufsicht** (Oberseite); ein **Deckenspiegel/Reflected Ceiling Plan** (Blick nach oben) zeigt Bauteile ÜBER der Ebene in **Untersicht** (Unterseite — z. B. Kassettendecke, Leuchten). Also je Component potenziell **drei** Darstellungs-Slots (Schnitt / Aufsicht / Untersicht) × {Schraffur + Linienstärke}. Das heutige `viewHatchId` ist faktisch EIN View-Slot und vermischt Auf-/Untersicht; sauber wäre die Trennung. WELCHER Slot gilt, entscheidet die **Blickrichtung der Sicht** (Grundriss ↓ / Deckenspiegel ↑) UND die z-Lage relativ zur Schnittebene.
- **Verallgemeinert den Decken-Footprint-Clip** (`a2f6923`): „Decke unter Wand verdeckt" ist ein Spezialfall von „Ansichtsbauteil vs. schneidende/überdeckende Bauteile".
- Aufwand: mittel–groß, phasenweise machbar (1: z-Extent-vs-Schnitthöhe-Klassifikation cut/above/below; 2: Ansichtslinie-Weight je Component + Haarlinie/gestrichelt; 3: 30-cm-Wände & Slabs verdrahten). Nicht zu komplex im Konzept — es ist der reguläre CAD/BIM-Weg (ArchiCAD/Vectorworks/Revit). Mit Nutzer Detailgrade/Defaults festlegen.
- [x] ~~**Ebene-Schraffur editierbar**~~ — **gelandet `dcb6ed5`**: Kategorie-Dialog (`App.tsx`, `editor.hatch`-Feld) hat `<select>` auf `cat.hatch` + `HatchSwatch`-Vorschau. Verifiziert vorhanden.
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
- ✅ **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.
- **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.
- [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 A0–A6+B0–B6 (`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,my−base)`) 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).
- [ ] **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:
- **Auth + Projekt-Liste:** Supabase Auth (Email/Magic-Link), Projektübersicht, Öffnen/Schliessen.
- **Lock-Service:** Tabelle `object_locks (project_id, object_id, user_id, locked_at)` mit Row-Level Security; `reserveObjects(ids[])` / `releaseObjects(ids[])` als Supabase-RPC; Optimistisches Check-in via DB-Constraint (doppelte Reserve → Fehler → UI-Feedback).
- **Realtime-Sync:** Supabase Realtime-Channel pro Projekt; bei Freigabe werden die veränderten Objekte (JSON-Patch oder ganzes Objekt-Payload, TBD) gepusht; lokaler Store merged incoming changes sofort.
- **Presence:** Wer ist online, wer hat welche Objekte reserviert (farbige User-Badges an reservierten Elementen im Plan).
- **Offline-Guard:** Beim Verbindungsabbruch Locks automatisch nach Timeout freigeben (DB-seitig: `locked_at + interval` prüfen).
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
- **Aufwand:** gross (2–4 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
- [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)
- [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 (~15–25 PT):** Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~5–8 · Mipmaps (Blit-Pass) + anisotropes Filtern ~1–2 · Bild-Datei-Laden serverseitig statt Browser-Dekodierung ~1–2 · nativer Tauri-Textur-Push ~2–3 · UI/Persistenz-Feinschliff ~5–8. **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.
- [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)
- [x] ~~**„aki"** Snap/Endpunkt-Farbe~~ — **entschieden 2026-07-04: Sora #5FA1C9** als endgültiger Default festgeschrieben (`src/theme/accents.ts` `DEFAULT_SNAP_COLOR`, Doc aktualisiert). Frage geschlossen.
- [ ] Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT)
- [ ] Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering?
- [ ] Verifizieren/klären: Isometrie „echte" orthographische Iso (teilw. durch Locked-Iso `cb8fae5` adressiert)? · TopBar-Detailgrad (zoom% ganz aus Footer)? · DOSSIER-Audit welche Features konkret übernommen?
---
## ✅ Erledigt (Verlauf, neueste oben)
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
- [x] 2026-07-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 71–74) 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. 36–38 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, Sutherland–Hodgman-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 `zTop−thickness` 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.
- [x] 2026-07-05 **Ribbon Phase 3 (Teil): Werkzeug-Sidebar raus** — `DEFAULT_LEFT_GROUPS` nur noch `attributes` (volle linke Höhe), `LAYOUT_VERSION` 7→8 (gespeicherte Layouts fallen auf neuen Default zurück → sichtbar). Wandtyp-/Deckentyp-Picker von `ToolsPanel` ins `AttributesPanel` verschoben (Nutzer-Entscheid „ins Attribute-Panel") — erscheint bei aktivem Wand-/Decken-Werkzeug (Sektion „Neues Bauteil", auch ohne Auswahl), Host-State unverändert. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), `Dropdown`-Import dort entfernt. tsc + Suite 334 grün. **Offen:** Objektinfo unter Attribute mergen.
- [x] 2026-07-05 **Fix: gezeichnete/importierte Kreise+Bögen im WebGL unsichtbar** — der WebGL-Compiler (`glPlan/glPlanCompile.ts`, Default-Renderer) dispatchte `polygon`/`line`/`arc`, aber NICHT `drawingCircle`/`drawingArc` → seit `4ac99d3` (Kreise als echtes `drawingCircle`-Primitiv statt 64-Eck-Polygon) fielen sie im GL still raus (SVG-/WASM-Pfad hatten sie, GL nicht). Zweig ergänzt: bildschirm-adaptive Tessellierung wie beim `arc`-Primitiv (Kreis geschlossen + optionale Vollton-Füllung, Bogen a0..a1 offen). tsc + Suite 334 grün. **Nutzer-Report** („zeichne Kreis, bleibt nicht sichtbar").
- [x] 2026-07-05 **Textur-Spike verifiziert erledigt** (`0ca3b1d`) — bei der Backlog-Abarbeitung festgestellt, dass die render3d-Textur-Spike (`RenderStyle::Textured`, Schachbrett, planare Meter-UVs, `MESH_TEXTURED_WGSL` group 1, `spike3d`-`T`-Toggle) bereits vollständig umgesetzt + committet war; `cargo test` 58/59 grün, keine neuen Deps, Alt-Pfad bitgleich. „Als Nächstes"-Eintrag war veraltet → abgehakt; PBR-Restlückenliste (~18–29 PT) in 3D-REST übernommen.
- [x] 2026-07-05 **Ribbon-Feinschliff (OCS-Zeile)** — Tabs in die TopBar-Zeile verlegt (`456ebc8`, eine Leiste Chrome+Tabs, Inhalt darunter; Tab-State in App); OCS-Chrome: kleine Wortmarke „dossier." + Quick-Access-Icons für ALLE Datei-/Export-Aktionen statt Burger-Menü, Zeile auf 26px (`4e3b074`); Text-/Font-Formatierung mit der Ansichts-/Zoom-Steuerung auf EINE Leiste gelegt, „Ansichten" als Standard-Tab zuerst + initial aktiv (`fe22cbf`). Alle tsc + 331 Tests grün. Visuelle Abnahme (26px, Icon-Sitz) noch offen.
- [x] 2026-07-05 **Ribbon Phase 2: TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll mergen") — die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid+Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart) als `ViewRibbonTab` (in `TopBar.tsx`) in den Ansichten-Tab verschoben; App reicht sie als `viewsContent`-Node an `RibbonBar` (analog Layout-Menü). TopBar ist jetzt schmale globale Leiste (Marke/Ressourcen · Text · Datei/Export/Einstellungen · Fensterknöpfe). `TopBarProps` entsprechend verschlankt. Visuelle Feinabstimmung offen.
- [x] 2026-07-05 **Ribbon-UI Phase 1** (`9d6e86d`) — datengetriebene Tab-Leiste (`src/ui/ribbon/`: `ribbonItems.ts` Registry, `RibbonBar.tsx`, `CommandIcon.tsx`) unter der TopBar, additiv (Sidebar bleibt). Tabs 2D·3D·BIM·Ansichten; 2D=Zeichnen(select/line/polyline/rect/circle/arc)+Ändern(move/copy/mirror/offset/trim/join), BIM=Bauteile(wall/window/door/stair/ceiling/room); 3D/Ansichten noch leer. Ein Aktivierungs-Pfad: tool→`onSelectTool`, command→`onRunCommand`(engine.start); Aktiv-Highlight über `activeTool`/`engineView.commandName`. `ToolIcon` aus ToolsPanel exportiert (wiederverwendet).
- [x] 2026-07-05 **Attribut-Panel: ein Grid, volle Breite** (`ace0dc6`) — drei getrennte Grids zu EINEM durchgehenden Zwei-Spalten-Grid gemerged (Wertspalte über alle Sektionen bündig), Dropdown-Pills füllen die Wertspalte (fixe Breiten raus), doppeltes Eigen-Padding + zweiter „Attribute"-Titel entfernt. **Nutzer-Report.**
- [x] 2026-07-05 **Eigenschaften-Grid OCS-Stil** (`00733d8`) — `.attr-*` als klares Grid: Sektion-Balken, Zeilentrenner, füllende linksbündige Wertfelder (Texte/Zahlen/Dropdowns einheitlich). Erste Stufe der Ribbon-UI-Vision.
- [x] 2026-07-05 **Zoom-Scroll-Fix** (`077e774`) — Dokument-Overscroll/Rubberband gesperrt (`html,body,#root { overflow:hidden; overscroll-behavior:none }`); Viewport-Zoom zieht die UI nicht mehr mit (macOS). **Nutzer-Report.**
- [x] 2026-07-05 **Kreis- + Bogen-Werkzeug** (`e454eab`, `bd2b12b`) — Kreis-Toolbar (circleCommand gekoppelt) + neues `arcCommand` (3-Klick CCW), beide mit Toolbar-Icon/i18n; rendern glatt via `drawingCircle`/`drawingArc`.
- [x] 2026-07-05 **DXF-Import: Platzierungsoption** (`dd76ec8`) — ImportDialog fragt „relativ zum Nullpunkt" ODER „in die Mitte der aktuellen Ansicht"; `PlanViewHandle.viewCenterModel()` neu; `runImport` verschiebt den Gesamt-Umriss (bbox-Mitte → Ansichtsmitte). tsc + Suite 331 grün.
- [x] 2026-07-05 **Zeichnungen Copy/Paste über Geschosse** (`376aa67`) — Ctrl/Cmd+C kopiert gewählte Drawing2D tief; Ctrl/Cmd+V fügt Klone (neue IDs) auf dem AKTIVEN Geschoss ein und wählt sie. Für z. B. importiertes Mobiliar. Textfeld-Fokus bleibt natives Copy/Paste.
- [x] 2026-07-05 **Tauri Drag&Drop + Import-Dialog-Fix** (`c5b5ca6`, `45e19b7`) — `import`-Befehl als `autoRun` (Datei-Dialog öffnet synchron in der Geste, HMR-Falle via Config-Relaunch behoben); `dragDropEnabled:false` (Tauris natives Drag-Drop fing HTML-Drops ab). **Nutzer-bestätigt: Dialog + Text gehen.**
- [x] 2026-07-05 **Layout: Befehlszeile in Mitte-Spalte** (`c794fee`) — nur Viewport-breit, Docks gewinnen Höhe. **Nutzer-bestätigt „sieht clean aus".**
- [x] 2026-07-05 **Textur-Spike render3d** (`RenderStyle::Textured` real) — zweiter Vertex-Pfad `[pos,normal,uv]` aus dem Mesh abgeleitet (Alt-Pfad bitgleich, per Test belegt), prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus Sampler), Pipeline bitidentisch zur Haupt-Pipeline, `spike3d` per `T` umschaltbar. Verifiziert: cargo test 58/59 (inkl. naga-Test) grün, alle 4 Builds sauber. **Visuelle Fenster-Abnahme durch Nutzer bestanden 2026-07-05** (Schachbrett korrekt, `T`-Umschaltung ok). Erster Durchstich der Textur-/PBR-Pipeline; ehrliche Lückenliste im Bericht — `8556037`
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
- [x] 2026-07-04 **Snap-Farbe entschieden: Sora #5FA1C9** als endgültiger Default festgeschrieben (`DEFAULT_SNAP_COLOR`, Doc), „aki"-❓ geschlossen.
- [x] 2026-07-04 **Mac-Build via Tauri + Queue-Abgleich** — Toolchain auf macOS-Gerät verifiziert, `cad.app`/`cad_0.1.0_aarch64.dmg` gebaut & gestartet. Verifikations-Baseline grün (tsc/vitest 230/cargo 56). Queue-Reconciliation: Toolchain-Blocker, Snap-Slice (`339202b`), Ebene-Schraffur (`dcb6ed5`), Bauteile-Tab-Master-Detail, Einstellungs-Verdrahtung UND Wand-Schicht-Bänder 3D (Option B, `toWalls3d.ts`) waren allesamt **bereits gelandet**, aber in PENDENZEN noch als offen gelistet → abgehakt. (kein Code-Commit)
- [x] 2026-07-04 **Öffnungen als echte Boolean-Löcher** (+ Deckentrim-nur-3D) — Fenster/Türen = 1 Wandkörper mit rechteckigen `holes` (Rechteck-Gitter-Zerlegung + Laibungsquads), Segment-Boxen weg; Deckentrim nur noch im 3D-Pfad, Schnitt volle Höhe. Pflicht-Testfall versetzt-überlappende Fenster grün. cargo test 56, vitest 230, build:engine3d + tsc sauber — `1407c68`
- [x] 2026-07-04 **Joins Phase 1c** Durchgangswand am T-Stoss echt aufbrechen (spanCutouts) — `c5a344d`
- [x] 2026-07-04 **Locked-Iso-Fix** freie Kamera bleibt nach Ortho-Preset orthografisch — `cb8fae5`
- [x] 2026-07-04 **3D-Live-Schnittebene** mit schraffierten Schnittflächen — `03f0c40`
- [x] 2026-07-04 **Joins Phase 2** Merge-Regel im Schnitt (gleiche Komponente verschmilzt) — `82d354f`
+124
View File
@@ -0,0 +1,124 @@
# PORT_PLAN — kernel2d nach Rust/WASM (hinter identischem TS-Interface)
Stand: 2026-07-04. **Dieser Plan ist der geforderte erste Schritt — noch kein Code.**
Ziel: `src/geometry/kernel2d.ts` (+ reine Geometrie aus room/ceiling/opening/stair) in
ein Rust-Crate portieren, zu WASM bauen, hinter einer TS-Fassade mit *exakt gleichen*
Signaturen einhängen. TS-Kernel bleibt als Referenz (`kernel2d.legacy.ts`).
Differential-Test: Rust-WASM == TS-Legacy auf identischen Eingaben (epsilon je Funktion).
---
## 1. Scope-Inventar (was wirklich portiert wird)
**Externe JS-Geometrie-Libs: KEINE.** Der Kernel ist handgeschriebene f64-Mathematik,
einzige Abhängigkeit sind die Vektor-Helfer aus `src/model/geometry.ts`
(`add/sub/scale/len/normalize/leftNormal/cross/dot/lineIntersect`) — trivial mit zu
portieren. `polygon-clipping`/`delaunator` werden im Kernel **nicht** genutzt.
### Voll im Scope (reine Geometrie)
- **`kernel2d.ts`** — komplett: Primitive, Schnitt, Offset, Trim/Split/Join, Kreis, Fläche/Orientierung, Fillet.
- **`roomBoundary.ts`** — komplett: `detectRooms`, `roomFromPointInside(Faces)`, `pointInPolygon`, `WallSegment`/`WallFace`/`DetectRoomsOptions` (generische Geometrie-Typen, `thickness: number`).
- **`ceiling.ts`** — komplett: `normalizeOutline`, `isValidOutline`, `ceilingArea`, `outlineBBox`, `outlineCentroid`, `pointInOutline` (generische Polygon-Utilities; Name irreführend, keine Decken-Semantik).
### Teilweise im Scope
- **`roomArea.ts`** — NUR `signedArea`, `polygonArea`, `perimeter`, `centroid`. Alles ab `SiaCategory` (`siaLabel`, `evaluateRoom`, `balance`, `roomsToCsv`) ist SIA-416-Domänenlogik/CSV → **bleibt TS**.
- **`stair.ts`** — portierbar, aber `stairGeometry` nimmt `Stair`. „Scheinkopplung": es werden nur geometrische Felder gelesen (`shape/start/dir/runLength/width/stepCount/…`), `totalRise` kommt bereits aufgelöst vom Aufrufer. Port via Rust-Struct `StairParams` (strukturgleich, null Model-Semantik).
### ⚠️ Scope-Spannung: `opening.ts` (im Auftrag genannt, aber stark model-gekoppelt)
7 von 9 Exporten binden `Wall`/`Opening` direkt ein; `getWallType`/`wallTypeThickness`/`wallReferenceOffset`/`wallVerticalExtent` lösen gegen `Project` auf.
- **Portierbar mit abgeflachter Signatur** (Vec2/number statt Wall/Opening): `wallAxisLength`, `openingInterval`, `wallAxisFrame`, `openingJambs`, `openingCenter`, `doorSymbol`, `openingGapQuad`/`windowSymbol` (letztere brauchen `thickness`/`refOff` als `number`-Parameter).
- **Bleibt TS** (braucht `Project`/Geschoss-Auflösung): `openingVerticalExtent` (via `wallVerticalExtent` → `Project.drawingLevels`).
- **→ Entscheidung nötig** (siehe §7): Der Auftrag verbietet Änderungen an Aufrufstellen außer dem Import-Pfad. Ein Port von `opening` würde die *Signaturen* ändern (Wall→Vec2/number) und damit die Aufrufstellen brechen — das widerspricht „keine Änderung an Aufrufstellen". Empfehlung: **opening in Phase 1 ausklammern**, nur den echt reinen Kern (kernel2d/roomBoundary/ceiling/roomArea-Flächen/stair) portieren.
---
## 2. Crate-vs-Portieren — pro Operation
Akzeptanzkriterium ist **Differential-Parität gegen die naive TS-Routine**, nicht „geometrisch besser". Jedes Fremd-Crate mit anderem Algorithmus bricht die Parität per Konstruktion → Default = **PORTIEREN** (TS-Routinen sind 5–40 Zeilen f64, 1:1 übersetzbar).
| Operation | geprüftes Crate | Entscheidung | Grund |
|---|---|---|---|
| `offsetPolyline`/`offsetSegment` | cavalier_contours 0.6 | **PORTIEREN** | Crate liefert **Arcs (bulge)** statt `Vec2[]`, **heilt Selbstschnitte**, gibt mehrere Polylinien — TS heilt bewusst NICHT. Semantik nicht angleichbar. |
| Segment/Line-Schnitt | geo / robust | **PORTIEREN** | Cramer-Formel; jede andere denom-/Epsilon-Politik driftet in Parallel-Grenzfällen. |
| Trim/Split/`segmentPolylineHits` | geo `Relate` | **PORTIEREN** | Projektspezifisch (t-Dedup `1e-6`, Pick-nächster-Bogen, Wrap-Logik). |
| Kreis-Schnitte | — | **PORTIEREN** | Quadratik mit projekt-EPS-Disc-Klemmung. |
| `signedArea`/`isCCW` | geo `Area` | **PORTIEREN** | Shoelace-Summierung in **identischer Vertex-Reihenfolge** (f64 nicht assoziativ). |
| `filletCorner` | — | **PORTIEREN** | `acos/atan2/tan(θ/2)`-Kette + projekt-Cutoffs; liefert projekt-spezifische `Fillet`-Struktur. |
| `detectRooms` / `joinChains` | geo / i_overlay | **PORTIEREN** | Topologie-/reihenfolgeabhängig; andere Kantendurchlauf-Reihenfolge → andere (gleich gültige) Ringe → Parität bricht. |
| point-in-polygon | robust | **PORTIEREN** (robust nur intern, optional) | Siehe §3. |
Fremd-Crates (cavalier_contours/geo/i_overlay) wären für einen späteren *Tier-2-Rewrite* (echter Arc-Offset, Boolean-Ops) wertvoll — das ist ein **anderes Produkt**, nicht dieser paritätserhaltende Port.
---
## 3. `robust`-Prädikate vs. Differential-Parität (Spannung auflösen)
TS testet Orientierung überall via naives `cross()` gegen `EPS`. `robust::orient2d` liefert das **exakte** Vorzeichen — weicht von naiv **nur in der nahe-degenerierten Zone** ab (fast-parallele Segmente, Null-Fläche-Polygone, Punkt-auf-Kante). Genau dort schlägt der Diff-Test am ehesten an. Man kann nicht gleichzeitig „bit-Parität gegen naiv" und „robuste Prädikate" im *selben* Vergleich haben.
**Entscheidung:**
1. **v1 portiert die naiven `cross`-Vergleiche 1:1** (KEIN `robust`) → Zufalls-Diff-Test wird bit-nah grün. Robustheit kommt aus denselben f64-Formeln + demselben `EPS` wie TS.
2. **Additiv, getrennt:** `robust` nur *intern* in `detectRooms`/point-in-polygon hinter optionalem Feature `robust-predicates`, flankiert von **Golden-Cases, die die KORREKTE (robuste) Antwort asserten** (nicht TS-Parität). Diese Fälle sind aus dem Zufalls-Diff-Test ausgenommen.
3. Zwei Testklassen, nie gemischt: **Zufalls-Parität = naiv**, **Golden-Korrektheit = robust**.
---
## 4. Crate-Setup & Vite (exakter Klon von `src-tauri/geometry`)
Ort: **`src-tauri/kernel2d`** (analog render2d/render3d/geometry; das gesamte Tooling ist auf `src-tauri/<crate>` + `../../src/engine/pkg<X>` verdrahtet). *Namens-Hinweis:* Auftrag sagt `crates/kernel2d` — siehe §7.
- `Cargo.toml`: `crate-type = ["cdylib","rlib"]`; Features `default=[]`, `web=[wasm-bindgen, serde_json, console_error_panic_hook]`, `robust-predicates=[robust]` (additiv, nicht in web-Default).
- `src-tauri/Cargo.toml`: `exclude = [..., "kernel2d"]` erweitern (sonst „multiple workspace roots").
- `src/lib.rs`: reiner f64-Rechenkern feature-frei (`cargo test`-bar, headless) + `#[cfg(feature="web")]` JSON-**Batch**-Fassade pro Operation (`offset_polylines_json`, `intersect_batch_json`, `fillet_batch_json`, `circle_intersect_batch_json`, `detect_rooms_json`), Muster `compute_joins_json`.
- `examples/parity.rs`: Klon von `geometry/examples/parity.rs` (Batch-JSON stdin→stdout) — nativer Diff-Kanal.
- `package.json`: `"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web"`.
- **Vite: kein Config-Eintrag nötig** — `--target web`-Pakete werden als normales ES-Modul importiert, Vite bündelt `kernel2d_bg.wasm` automatisch (`new URL(..., import.meta.url)`). `pkgKernel2d/` ist git-ignoriert (self-`.gitignore = *`) → vor `vitest`/`build` muss `build:kernel2d` laufen (CI-Schritt).
- **TS-Fassade** `src/geometry/kernel2d.ts` wird dünner Wrapper (init-WASM, JSON-Marshalling, gleiche Exports); Alt-Impl → `src/geometry/kernel2d.legacy.ts` (nicht löschen, ist die Diff-Referenz).
---
## 5. Differential-Test-Harness
- **Grobkörnige WASM-Grenze:** eine Batch-Funktion je Operation (N Polylinien rein, N Ergebnisse raus). Keine Per-Punkt-Calls (jeder Call marshallt einen String = O(n)-Kopie).
- **Diff-Kanal:** Der Auftrag verlangt **Rust-WASM** vs. TS-Legacy → primär WASM (via `initSync`). Zusätzlich `cargo run --example parity` (nativ) als schneller Sekundär-Kanal (bit-identisch zu WASM für `+ - * / sqrt`; **Ausnahme** `atan2/acos/tan` in Fillet: libm nativ ≠ wasm um letzte ULP → Winkel-Epsilon).
- **vitest-Init synchron:** `initSync({ module: readFileSync(pkgKernel2d/kernel2d_bg.wasm) })` (kein `fetch`), einmal in `beforeAll`.
- **Zufallsgeneratoren:** seed-basiert (Seed im Testnamen), Polylinien 3–20 Vertices, Koordinaten `[-100,100] m`, `closed`/`d` zufällig; zusätzlich Cluster nahe `0` und `1e-6..1e-3`, um Toleranzschwellen zu treffen.
- **Vergleichsreihenfolge:** zuerst **Struktur exakt** (Array-Längen, closed-Flags, Punktzahl, null/nicht-null), dann Werte mit op-Epsilon. Struktur ist der schärfste Paritäts-Wächter.
### Epsilon pro Funktion
| Größe | Toleranz | Grund |
|---|---|---|
| Punktkoordinaten (Offset/Trim/Split/Kreis) | `abs 1e-9` | Bestehender Paritätstest nutzt `1e-9` und besteht bit-nah. |
| Fläche (`signedArea`) | `rel 1e-9·max(1,\|A\|)` | Shoelace ∝ coord² → absolute ULP wächst mit Flächengröße; relativ skaliert korrekt. |
| Winkel (`filletCorner`) | `abs 1e-7 rad` | `atan2/acos` libm-abhängig (nativ↔wasm ULP-Drift); 1e-7 rad ≈ 5.7e-6°, weit unter Zeichenrelevanz. |
| Parameter t/s | `abs 1e-9` | TS dedupliziert erst ab `1e-6` → kleinere Diffs ändern nie die Struktur. |
| Struktur | **exakt** | Kein Epsilon. Hier bricht ein Fremd-Crate. |
### Golden-Cases (explizit, aus Zufallstest teils ausgenommen)
kollineare Tripel · Null-Länge-Segmente (Dublett-Vertex) · Offset-Selbstschnitt (enges U, großes d — hier bräche cavalier_contours) · spitze Fillet-Winkel (θ→0) · fast-paralleler Schnitt (denom knapp <>EPS → **Korrektheits-Golden/robust**) · konzentrische/tangentiale Kreise · Punkt exakt auf Polygonkante (**Korrektheits-Golden**).
---
## 6. Kritische Paritäts-Details (MÜSSEN exakt repliziert werden)
- `len` = `Math.hypot` → **`f64::hypot`** (nicht `(x²+y²).sqrt()`).
- `normalize` Null-Guard: `len || 1` → `if l==0.0 {1.0} else {l}` (Ergebnis `{0,0}`, kein NaN).
- **Zwei Epsilons:** `EPS=1e-7` (kernel2d) UND hartkodiert **`1e-9`** in `lineIntersect` (Offset-Miter-Fallback hängt daran — geometrischer Sprung, nicht epsilon).
- Dedup-Schwelle `1e-6`, Fillet-Kollinearität `1e-4` (nicht EPS).
- **Stabile Sortierung** (JS `Array.sort` ist stabil): Rust `sort_by`, nicht `sort_unstable_by`.
- Term-Reihenfolge in `cross`, `signedArea`-Summierung, Kreis-Diskriminante `B*B−4*A*C` exakt beibehalten (f64 nicht assoziativ; kein Kahan/Reorder).
- Modulo-Indizierung `(i+len-1)%len` (usize-Unterlauf vermeiden).
- `joinChains`: greedy `i<j`-erster-Treffer-dann-Neustart exakt nachbilden (reihenfolgeabhängiges Ergebnis).
---
## 7. Offene Entscheidungen (vor Coding klären)
1. **`opening` im Scope?** Portieren würde Signaturen (Wall→Vec2/number) und damit Aufrufstellen ändern — widerspricht „keine Änderung an Aufrufstellen außer Import-Pfad". **Empfehlung: opening in Phase 1 ausklammern.**
2. **Crate-Ort:** Auftrag `crates/kernel2d` vs. Repo-Konvention `src-tauri/kernel2d` (analog render2d/render3d). **Empfehlung: `src-tauri/kernel2d`** (Tooling passt out-of-the-box).
3. **Diff-Kanal:** Auftrag verlangt Rust-**WASM**; nativer `parity`-Kanal ist schneller (kein wasm-pack in CI) und bit-identisch außer Fillet-Transzendente. **Empfehlung: WASM primär (Auftragskonform) + nativ sekundär.**
## 8. Phasen (Reihenfolge)
1. Crate-Skelett `src-tauri/kernel2d` + Build-Script + Workspace-exclude + leere WASM-Fassade → `build:kernel2d` grün.
2. Primitive + Schnitt + Fläche + Kreis portieren (trivial–mittel) + Batch-Fassade + Diff-Test-Harness (Zufall+Golden) → grün.
3. Offset (Miter+1e-9-Fallback) + Fillet portieren → Golden für Selbstschnitt/spitze Winkel grün.
4. Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — der Löwenanteil) → Struktur-Golden grün.
5. `roomBoundary` (`detectRooms`) + `ceiling` + `roomArea`-Flächen + `stair` (StairParams).
6. TS-Fassade umstellen (Alt → `.legacy.ts`), Import-Pfade der Aufrufstellen unverändert lassen, bestehende Suite grün, `npm run build` + WASM-Build sauber.
+26 -17
View File
@@ -24,8 +24,9 @@ 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. Schnitte und Ansichten laufen
über eine eigene, analytische Rust-Pipeline (kein Hidden-Line-Removal nötig)
und sind seit Juli live im 3D-Viewport verdrahtet, nicht nur ein Spike.
ü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
@@ -33,10 +34,11 @@ 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. ~91.000 Zeilen TypeScript + ~19.500 Zeilen
Rust (ohne Tests), 891 Vitest-Tests, alle grün.
Plansätze, native Tauri-Fenster. Eine vollständige, ehrliche Bestandsaufnahme
(inklusive offener Punkte und bekannter technischer Schulden) steht in
**[STATUS.md](STATUS.md)**.
**Funktioniert (Auszug, nicht abschliessend):**
**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.
@@ -63,7 +65,7 @@ Rust (ohne Tests), 891 Vitest-Tests, alle grün.
- **Resource Manager** (Component/Hatch/Line/Typ-Editoren), regelbasierte
Overrides, Panel-System (dockbar/floatend), i18n (de/en).
**Bewusst noch offen** (Auszug):
**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.
@@ -88,9 +90,11 @@ Rust (ohne Tests), 891 Vitest-Tests, alle grün.
| Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser |
| Materialien | `jszip` (ambientCG-Zip-Entpacken), `three.js` `TextureLoader` |
Der ursprünglich geplante OCCT/`opencascade.js`-HLR-Pfad für Schnitte ist
komplett entfernt (weder Dependency noch Code) — abgelöst durch die
analytische Rust-Schnitt-Pipeline (siehe Grundgedanke oben).
`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
@@ -126,16 +130,17 @@ src/
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
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/ 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
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/ 5 eigenständige Rust-Crates (render2d/render3d/kernel2d/trucksolid/
dwgimport, je headless UND per wasm-pack baubar) + der Tauri-Host selbst
src-tauri/ 6 Rust-Crates (render2d/render3d/geometry/kernel2d/trucksolid/dwgimport,
je headless UND per wasm-pack baubar) + der Tauri-Host selbst
```
## Konventionen
@@ -144,14 +149,18 @@ src-tauri/ 5 eigenständige Rust-Crates (render2d/render3d/kernel2d/trucksol
(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`.
- Verbindliches in [CONVENTIONS.md](CONVENTIONS.md).
## Weiterlesen
Dieses README ist die einzige Doku im öffentlichen Repo — `STATUS.md`,
`ARCHITECTURE.md`, `ROADMAP.md`, `HANDOVER.md`, `PENDENZEN.md`,
`CONVENTIONS.md` und `docs/` sind bewusst per `.gitignore` ausgeschlossen
(interne Arbeitsnotizen/Backlog, kein öffentlicher Anspruch auf Vollständigkeit
oder Aktualität) und daher hier absichtlich **nicht** verlinkt.
- [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
+43
View File
@@ -0,0 +1,43 @@
# RESEARCH — Bauteile Treppe/Fenster/Tür: Rhino-Plugin als Vorbild
Stand: 2026-07-05. Vergleich der authoritativen Rhino-Plugin-Implementierung
(`/Users/karim/PROJECTS/DOSSIER/rhino/`, C#/Python + RhinoCommon-B-Rep) mit
DOSSIER-STANDALONEs vereinfachter TS-Umsetzung. Ziel: Bauteile aufs Rhino-Niveau
heben. Belege = Rhino-Funktionsnamen.
**Grundsatz:** Rhino hat echten B-Rep-Kernel (3D). DOSSIER hat extrudierte Meshes +
`kernel2d` (2D). Darum getrennt: **2D-Symbol-Logik** (leicht in TS/kernel2d
übernehmbar) vs. **3D** (braucht Mesh-Pipeline bzw. später truck).
## Treppe (Rhino `treppe.py`, 1784 Z. — massiv reicher als `stair.ts`)
- **Typen:** gerade / L (3- und 4-Punkt mit Podest) / Wendel (inkl. Spindel-Cone bei r_in<0.05). TS hat alle drei, aber simpler.
- **3D-Querschnitt-Modi** `massiv`/`flach`/`plattenrand` (`_treppe_profile_2d`) — TS: nur Stufenboxen. **fehlt.**
- **2D-Plansymbol** (`_make_treppe_2d_symbol`): Tritte, Lauflinie (4 Pfeil-Styles: klassisch/filled/breit/voll, visuell zentriert via `mid_off`), **Aussenlinie/Outline** (alle 3 Typen), Bruchlinie, Podest-Hexagon. TS: Tritte+Bruch+Lauflinie(1 Style) da; **Aussenlinie fehlt komplett**, Referenz links/mitte/rechts fehlt.
- **Soll-Schrittmass** (2S+A, Lock), Show-Flags, Grips — fehlen in TS.
## Fenster (Rhino `elements.py` `_make_oeffnung_pieces`)
- **3D:** Rahmen (BooleanDiff), Mittelpfosten je Flügel (1–4), Glas (einfach 12mm / Doppel 2×6+16), Sims aussen (4 Styles), Rahmen-Offset innen/mitte/aussen. TS: nur 2D-Rahmen+Glaslinien, Wand-Boolean-Loch (`1407c68`); kein Rahmen-3D.
- **2D:** TS hat Rahmen + 1–2 Glaslinien (grob/mittel/fein). **Fehlt:** Brüstungslinie im Plan, Flügel-Mittelpfosten, Anschlag-Striche (nur Tür hat sie), Flügelanzahl.
## Tür (Rhino `elements.py`)
- **Typen:** `normal` / `wandoeffnung` (reiner Durchbruch, kein Blatt). **Rahmen:** `zarge` (3-seitig, im Wandquerschnitt) / `block` (+5cm Überhang).
- **2D-Schwung** (`_make_tuer_swing_curves`): Blatt+Arc, hinge_side/open_angle/aussenseite/swing_invert; Bogen-Anlage am Rahmen (standard/detail). **2D-Sturzlinien** (`_make_tuer_sturz_curves`, gestrichelt, Modi keine/innen/aussen/beide — SIA: Sturz über Schnittebene → gestrichelt). TS: Blatt+Arc+Anschlag (fein) da; **Sturzlinien fehlen komplett**, `wandoeffnung`-Typ + swing_invert fehlen.
---
## Gesamtreihenfolge „was zuerst" (Agenten-Empfehlung)
**Gruppe A — 2D-leicht, hoher Plan-Wert, kernel2d-kompatibel:**
1. **Treppe Aussenlinie/Outline** (alle 3 Typen; gerade=4 Linien, L via Linienschnitt `_line_intersect_xy`, Wendel=2 Bögen+radiale). Ohne sie wirkt der Plan unfertig.
2. **Fenster Brüstungslinie** im Plan (1 gepunktete Linie bei sillHeight>0). XS.
3. **Tür Sturzlinien** (gestrichelt, SIA) — Schulfall der geplanten Schnitt/Ansichts-Logik.
4. **Treppe Referenz links/mitte/rechts** + Lauflinie visuell zentriert.
5. **Fenster Flügel-Mittelpfosten** (2D, `wingCount` ins Modell).
6. **Tür `wandoeffnung`-Typ** (reine Öffnung ohne Blatt/Symbol).
7. Treppe Show-Flags + `obere_dashed`; 8. Fenster Anschlag-Striche.
**Gruppe B — 2D mittel:** Tür-Schwung am Rahmen; Treppe Pfeil-Style `filled`; Fenster/Tür Styles/Presets; swing_invert.
**Gruppe C — 3D (nach Mesh-/B-Rep-Pipeline):** Treppe `flach`-Querschnitt; Rahmen als Mesh (Fenster/Tür); Türblatt+Glas; Sims; L-Podest-Hexagon; Wendel helikoidale Unterseite (truck).
**Bezug PENDENZEN:** Gruppe-A-Items 1–3 (Aussenlinie/Brüstung/Sturz) fallen in das Backlog-Item „Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe" — Sturzlinie = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion. Gruppe C wartet auf den Textur-/Mesh-Pipeline-Nachfolger bzw. truck.
+162
View File
@@ -0,0 +1,162 @@
# RESEARCH — Von BIM-Tool zu „echtem CAD": Ansätze aus fortgeschrittenen Programmen
Stand: 2026-07-05. Direkt am Quellcode studiert (Repos nach `/tmp/cad-study` geklont,
liegen NICHT im Projekt). Ziel: konkrete, verfolgbare Ansätze, um DOSSIER von einem
spezialisierten BIM/Architektur-Werkzeug zu einem allgemeineren CAD zu erweitern.
## Studierte Programme
| Repo | Was | Lizenz | Stack | Für uns |
|---|---|---|---|---|
| [OpenCADStudio](https://github.com/HakanSeven12/OpenCADStudio) | Eigenständige 2D/3D-CAD-App, ~200k LOC Rust | GPL-3.0 | Iced (GUI) + wgpu + WASM-Web-Build | **App-Architektur-Blaupause** (Entity-Modell, Command-System, Snap, Grips). Fast unser Stack. |
| [truck](https://github.com/ricosjp/truck) | Reiner Rust-CAD-**Kernel** (NURBS/B-Rep/Mesh/Boolean/STEP) | Apache-2.0 | 16 Crates, wgpu-Rendering + `truck-js` (WASM) | **3D-Kernel-Blaupause**. OpenCADStudio nutzt ihn als 3D-Backend. |
| [acadrust](https://github.com/HakanSeven12/acadrust) | Pure-Rust **DWG+DXF** R13–R2018 read+write, 41 Entity-Typen | **MPL-2.0** | nom/byteorder/flate2/nalgebra/encoding_rs (alles pure Rust) | **DWG/DXF-Rückgrat**, MPL-2.0 nutzbar, sehr wahrscheinlich WASM-baubar. |
**Lizenz-Klarstellung:** `acadrust` ist **MPL-2.0** (file-level copyleft — für uns nutzbar,
keine GPL-Ansteckung), NICHT GPL. `truck` ist Apache-2.0 (frei nutzbar). Nur OpenCADStudio
selbst ist GPL-3.0 — wir lesen es als **Architektur-Referenz**, kopieren keinen Code.
---
## Die zentrale Lektion: was ein „echtes CAD" architektonisch ausmacht
DOSSIER ist heute **domänenzentriert** (Wand/Öffnung/Component/Plan-Pipeline, TS-Modell).
Ein echtes CAD (Rhino/AutoCAD-artig) hat stattdessen einen **generischen Kern**. Aus
OpenCADStudios `src/` destilliert:
### 1. Generisches Entity-Modell mit Traits (statt Wand-Spezialfall)
`src/entities/` = 41 Entity-Typen (line, arc, circle, lwpolyline, spline, ellipse, hatch,
text, mtext, dimension, leader, insert/block, table, viewport, mesh, solid3d, …). Jeder
Typ implementiert ein **Trait-Set** statt einer Sonderbehandlung:
- `TruckConvertible` → 3D-Kernel-Geometrie
- `FallbackTess` → Display-Geometrie (Punkte/Snap-Vertices)
- `Grippable` → editierbare Griffe + Hover-Menü (Add Vertex / Convert to Arc / Reverse / Lengthen…)
- `PropertyEditable` → Eigenschaften-Panel
- `Transformable` → Move/Rotate/Scale/Mirror
- `MassProps` → Fläche/Umfang für Abfragen
→ **DOSSIER-Lehre:** ein generisches `Entity`-Modell mit Trait-Dispatch neben das BIM-Modell
stellen. Wir haben Ansätze (`Drawing2D`, splitJoin-Editoren, kernel2d), aber keine einheitliche
Entity-Taxonomie mit Traits.
### 2. Modeless Command-System — der eine `StepInput`-Step-Machine
`src/command.rs`: EIN Enum `StepInput { Point / Text / EntityPick / StructurePick /
SelectionComplete }`. **Jede** Eingabequelle (Kommandozeile, Viewport-Klick, Pick, Selektion,
Dynamic Input, Plugin-API, Headless-Script) übersetzt in `StepInput` und läuft durch **eine**
`feed_command`-Funktion → treibt die aktive `CadCommand`-Step-Machine, egal woher der Schritt kam.
→ **DOSSIER-Lehre:** Das ist der größte UX-Unterschied. Echtes CAD = „Befehl tippen → Punkte
picken → Optionen". DOSSIER ist heute Tool-Button + Direktmanipulation. Ein `StepInput`-Funnel +
Kommandozeile wäre der Hebel, um beliebige Werkzeuge einheitlich, scriptbar und headless-testbar
zu machen.
### 3. Object-Snap-Engine (eigenständig, groß)
`src/snap.rs` = 70 KB nur für OSNAP (endpoint/midpoint/center/intersection/tangent/
perpendicular/…). Wir haben Teil-Snapping; ein echtes CAD hat eine dedizierte, vollständige
Snap-Schicht.
### 4. Universelle Grip-Editierung
Jede Entity liefert Griffe + kontextuelle Griff-Menüs mit teils numerischem Follow-up
(„Lengthen" fragt Wert an der Kommandozeile ab). Einheitlich über das `Grippable`-Trait.
### 5. `CadDocument` als Dokumentmodell = das DWG/DXF-Objektmodell
OpenCADStudio nutzt `acadrust::CadDocument` DIREKT als sein Datenmodell (Entities + Objects +
Tables: Layer/Linetype/Style/Blocks + XData). D. h. das native CAD-Austauschformat IST das
interne Modell → verlustfreier Round-Trip „for free".
---
## truck — 3D-Kernel-Blaupause (+ harte WASM-Realität)
Saubere Schichtung (Ship-of-Theseus, kleine ersetzbare Crates):
```
truck-base Basistraits/Toleranz (cgmath)
truck-geotrait ParametricCurve / ParametricSurface Traits
truck-geometry Knot-Vektor, B-Spline, NURBS ← Kurven/Flächen
truck-topology vertex/edge/wire/face/shell/solid ← B-Rep-Topologie
truck-modeling Geometrie + Topologie integriert ← Solids bauen
truck-polymesh Polygon-Datenstruktur + Meshing
truck-meshalgo Tessellation der Shapes
truck-shapeops Boolean-Ops auf Solids (v0.4 — früh)
truck-stepio STEP read/write (v0.3)
truck-platform wgpu-Grafik-Utility ┐ Rendering — brauchen wir NICHT
truck-rendimpl Shape/Mesh-Visualisierung ┘ (wir haben eigenen wgpu-Renderer „Nordstern")
truck-js WASM-Wrapper (v0.2)
```
**WASM-Realität (zwei Befunde abgeglichen):**
- OpenCADStudio pinnt `truck-meshalgo` 0.4 und schaltet den `solid3d`-Feature **im WASM-Build ab**,
weil dessen ACIS-/vtkio-Pfad über `xz2 → lzma-sys` (C-Bibliothek) nicht nach wasm32 kommt →
**im Web ist OpenCADStudio nur 2D.**
- Neuere `truck-meshalgo` (0.6) gated `rayon`/`vtkio` selbst per `cfg(not(target_arch="wasm32"))`
aus → **baut zu wasm32, aber single-threaded**. Der reine Geometrie-Stack
(base → geotrait → geometry → topology → polymesh → meshalgo → modeling → shapeops) hat keine
native-only-Deps und ist **sauber vom wgpu-Rendering trennbar** (truck-platform/-rendimpl weglassen).
**Reifegrad (Apache-2.0, aktiv, ~1 Hauptentwickler; ausdrücklich KEIN Produktionskern):**
- NURBS/B-Spline + B-Rep-Topologie: **stabil** (v0.5/0.6).
- Booleans (`truck-shapeops`): `and`/`or` vorhanden, aber **instabil bei coincident geometry**
(Issue #114); **`difference` fehlt** (#85); **Fillets fehlen komplett**. Der Fork
[`monstertruck`](https://github.com/virtualritz/monstertruck) hat difference + rolling-ball-Fillet.
→ Für unsere Wandöffnungen (boolean holes) haben wir das ohnehin schon selbst gelöst (`spanCutouts`).
- `truck-stepio`: STEP **schreiben** (aber NICHT für boolean-operierte Shapes) + **lesen** (v0.6 beta,
neues `truck_stepio::in`).
- `truck-js`: fertiger wasm-bindgen-Wrapper (kein npm-Paket → selbst per `wasm-pack` bauen), bündelt
modeling/shapeops/meshalgo/stepio; `IntoWasm`-Muster.
---
## acadrust — DWG/DXF-Rückgrat
Pure Rust, **MPL-2.0**, `CadDocument`-Objektmodell (entities/objects/tables/xdata/classes),
41 Entity-Typen, DWG „208/208 roundtrip-perfect", serde optional, failsafe-Parsing, ~40 Codepages.
**Alle Deps pure Rust** (nom/byteorder/flate2-miniz/nalgebra/indexmap/ahash/encoding_rs) → sehr
wahrscheinlich zu wasm32 baubar (Datei-I/O-API müsste auf Bytes/Reader statt `std::fs` umgestellt
werden — vermutlich schon vorhanden). Heute nutzen wir JS `dxf-parser` (nur 2D) + `libredwg-web`.
---
## Web-Import-Landkarte (für den GEO-BLOCK / Import-Backlog)
Pro Format die aktive, web-/WASM-taugliche Lib (Stand Mitte 2026):
| Format | Empfohlene Lib | Lizenz | Anmerkung |
|---|---|---|---|
| DWG/DXF (browser) | `@mlightcad/cad-viewer` / `libredwg-web` (nutzen wir) **oder** `acadrust` (Rust) | MIT / GPL-3.0 / **MPL-2.0** | acadrust = Rust-Weg, MPL-2.0 |
| STEP/IGES/BREP | `occt-wasm` (OCCT V8, ~4.5 MB) oder unser `opencascade.js` | LGPL-2.1 | occt-wasm = moderne TS-API |
| **IFC** (BIM!) | `web-ifc` (ThatOpen) | MPL-2.0 | De-facto-Standard, read+write, sehr aktiv |
| SHP/GIS | `shpjs` + `proj4` | MIT | direkt für GEO-BLOCK (SWISSIMAGE/Terrain) |
| Punktwolken LAS/LAZ | `@loaders.gl/las` (+ `laz-perf`) | MIT/Apache | Rendering: `potree-core` oder deck.gl |
| E57 | `e57-js` (Emscripten/libE57Format) | MIT | neu, funktional, sehr obskur |
| glTF/OBJ/STL | three.js-Loader (haben wir via `three`) | MIT | |
| 3MF | `lib3mf` (WASM) oder `THREE.3MFLoader` | BSD-2 / MIT | |
| PDF-Vektor | `pdfjs-dist` `getOperatorList()` | Apache-2.0 | kein fertiges PDF→DXF; roher Pfad-Stream |
---
## Priorisierte Ansätze für DOSSIER (die eigentliche Antwort)
Reihenfolge = Nutzen × Machbarkeit, ohne die BIM-Stärke zu opfern.
1. **Generisches Entity-Modell + Trait-Dispatch** (Fundament). Neben das BIM-Modell eine
generische Entity-Schicht (line/arc/circle/polyline/spline/text/dimension/hatch/block) mit
Traits à la OpenCADStudio (Display/Grips/Props/Transform). Unser `kernel2d` liefert schon die
Geometrie-Operationen dafür. **Größter struktureller Hebel.**
2. **Modeless Command-System** (`StepInput`-Funnel + Kommandozeile). Ein Eingabe-Enum, eine
`feed_command`-Schleife, `CadCommand`-Step-Machines. Macht Werkzeuge einheitlich, scriptbar,
headless-testbar. **Größter UX-Hebel Richtung „echtes CAD".**
3. **DWG/DXF-Round-Trip über `acadrust`** (Rust/WASM). MPL-2.0, pure Rust → passt in unseren
neuen `src-tauri`-Kern. Ersetzt langfristig die JS-Parser, bringt echten Import/Export.
Erst: WASM-Baubarkeit + Bytes-API verifizieren (Spike wie beim Textur-Spike).
4. **Vollständige Object-Snap-Schicht** als eigenes Modul (endpoint/mid/center/intersection/
tangent/perp/…), aufbauend auf `kernel2d`.
5. **3D-B-Rep später, selektiv** (truck-geometry/-topology/-modeling als reine Crates, OHNE
truck-Rendering — wir haben Nordstern). NUR wenn wir echte NURBS/Solids/Booleans brauchen; der
Meshing-/Boolean-Pfad ist WASM-problematisch (native C-Deps) und Booleans sind noch früh.
Bis dahin: truck als **Referenz** lesen, nicht einbinden.
**Kernaussage:** DOSSIERs Rust-nach-WASM-Kurs (den wir mit `kernel2d` gerade gehen) ist genau
richtig — OpenCADStudio bestätigt ihn. Der Weg zu „echtem CAD" führt über (1) generisches
Entity-Modell und (2) modeless Command-System; (3) `acadrust` bringt echten DWG/DXF-Austausch.
3D-B-Rep (truck) ist ein späterer, selektiver Schritt mit klaren WASM-Vorbehalten.
+392
View File
@@ -0,0 +1,392 @@
# Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap
> **Historisches Dokument (Tag-1-Vision, Stand 2026-06-28).** Vieles hier als
> „Phase 2–5"/„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
## 1. Produktvision
Ein **browserbasiertes BIM-Werkzeug** für Wohnbau, das zwei Dinge verbindet:
1. **Einfaches 3D-Gebäudemodell** — aus semantischen Bauteilen: Wände, Türen, Fenster, Treppen, Decken, Dächer, Räume.
2. **Schöne, normgerechte 2D-Pläne** — Grundrisse, Schnitte, Ansichten — automatisch aus dem Modell abgeleitet.
**Kernversprechen:** *Das schönste und einfachste Werkzeug, um ein Wohnhaus zu modellieren und daraus perfekte Pläne zu ziehen.* Nicht Revit nachbauen — radikaler Fokus auf Wohnbau + Plan-Qualität.
**Markt-Beleg:** Arcol, Snaptrude, TestFit zeigen, dass browserbasiertes BIM real ist und Nutzer schlanke, schöne Tools wollen statt der schwerfälligen Giganten (Revit/ArchiCAD).
---
## 2. Das mentale Modell — warum BIM anders ist als CAD
| | mechanisches CAD | **BIM (unser Weg)** |
|---|---|---|
| Bausteine | generische Volumenkörper | **semantische Bauteile** (Wand, Tür, Fenster…) |
| Beziehungen | keine | Tür *hostet* in Wand & schneidet Öffnung; Wände *verbinden* sich |
| Geschosse | — | **Stockwerke** als erste Klasse |
| 2D-Plan | Hidden-Line-Projektion | **symbolische Darstellung** (Schwenkbögen, Schraffuren, Lauflinien) |
| Standard | STEP | **IFC** |
---
## 2b. Kern-Prinzip: ein Modell, viele Darstellungen
Das **wichtigste Architektur-Prinzip**: Das semantische Modell ist die *eine
Wahrheit*; jede Ansicht (3D, Grundriss, Schnitt) ist eine **abgeleitete
Darstellung**. Im Spike steht das bereits. Daraus folgen direkt die Kern-Wünsche:
- **Modelldarstellungen / Detailgrade** — derselbe Tür/Fenster wird je nach
`detailLevel` (grob / mittel / fein) unterschiedlich gezeichnet (≙ Revit
„Detailgrad", ArchiCAD „Modelldarstellung"). Grob: Öffnung + Linie. Fein:
Rahmen, Blatt, Schwenkbogen, Anschlag.
- **Editierbare Stile** — Wandfarben, Linienstärken, Türlinien, Schraffuren als
**Stil-Schicht**, erst beim Rendern angewandt (nicht in die Geometrie
eingebacken). Pro Kategorie *und* pro Element überschreibbar.
- **Mehrschichtige Bauteile** — Wände/Decken mit **Schichtaufbau** (`layers[]`:
Material + Dicke + Priorität). 3D und Plan lesen dieselben Schichten.
- **In 2D *und* 3D zeichnen** — beide sind editierbare Sichten auf *ein* Modell;
Werkzeuge mutieren das Modell, alle Sichten re-derivieren reaktiv.
Schwierigkeit: Detailgrade/Stile/Schichten sind 🟢 gut machbar; 2D+3D-Editieren
🟡 mittel; **mehrschichtige Wand-Verschneidung** 🔴 der härteste Teil (Risiko #1).
---
## 2c. Arbeitsweise & Dokumentmodell (DOSSIER-Modell) ⭐
Referenz: **DOSSIER** (Rhino-Plugin des Nutzers, https://git.kgva.ch/karim/DOSSIER).
Dieses Projekt ist die **Standalone-Browser-Variante** davon. Zwei *unabhängige* Achsen:
- **Zeichnungsebenen** — die obersten Dokument-Abschnitte, zwei Arten:
- **Geschosse** (EG, 1OG …): `hoehe` (Geschosshöhe), `schnitthoehe` (Schnitthöhe),
`okff` (Niveau, akkumuliert), `visible`/`locked`.
- **Schnitte / Ansichten** (`type:"schnitt"`): Schnittlinie `linePts`, Richtung
`dirSign`, Höhenbereich, Tiefe.
- Ein **Geschoss** wird **im 3D-View ODER im Plan-View** betrachtet (Umschalter,
*keine* getrennten Daten). Plan-View = Clipping-Ebene auf `okff + schnitthoehe`.
- **Ebenen** — das **Grafik-Kategorien-Schema**, in *jedem* Geschoss vorhanden;
Baum-Knoten mit pro Ebene einstellbaren **Darstellungseinstellungen** (in den
„Ebeneneinstellungen…"): **Stift** = Typ/Linienstil + Farbe + Dicke (lw);
**Schraffur** = Typ + Skalierung + Rotation + **Stiftstärke der Schraffurlinien**.
Modell: `{code, name, visible, locked, lineStyleId|{type,color,lw}, hatchId|{type,scale,angle,lineWeight}, children}`.
Codes 1:1 wie DOSSIER:
`00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster) · 30 Decken · 31 Dächer
· 40 Treppen (└41 Treppen-2D) · 50 Tragwerk · 60 Räume · 80 Plangrafik …`
- **Elemente** (Wand, Decke, Treppe, Öffnung, Plangrafik, Text) liegen **auf den
Ebenen** und kennen ihr **Geschoss** *und* ihre **Ebene (Code)**.
- **2D-Zeichnen** (Linie, Polylinie, Rechteck, Kreis, Bogen, Text) findet auf der
Ebene `80 Plangrafik` (bzw. passender Kategorie) statt.
**Ansichtstypen = Kamera-Projektion + optionaler Schnitt** (vereinheitlicht):
| Typ | Projektion | Schnitt |
|---|---|---|
| **Grundriss** | Top-View (orthogonal) | horizontal auf `okff + schnitthöhe` |
| **Schnitt** | Front-View in eine Richtung (orthogonal) | vertikale Schnittebene (Geschnittenes + dahinter) |
| **Ansicht** | Front-View in eine Richtung (orthogonal) | kein Schnitt (Fassade außen) |
| **Perspektive** | 3D perspektivisch | — |
Sichtbarkeit pro Ansicht über Ein-/Ausschalten von Ebenen & Zeichnungsebenen.
Persistenz (DOSSIER): zwei getrennte JSON-Bäume `dossier_zeichnungsebenen` und
`dossier_ebenen`; Elemente tragen `geschoss`-id + Ebenen-`code`. Plan-View nutzt
eine Clipping-Ebene; weitere Konzepte: Overrides (regelbasiert), Ausschnitte
(View-Snapshots), Massstab (pro Viewport), Layer-Kombinationen, SIA-Räume.
## 2d. Resource Manager & Prioritäts-Verschneidung 🔴
Verwaltete Ressourcen-Bibliotheken wie in Vectorworks, jeweils mit eigenem Manager:
- **Line Manager** — Linienstile (Stärke, Farbe, Strichelung), wiederverwendbar.
- **Hatch Manager** — Schraffurstile (Muster, Maßstab, Winkel, Linienstil).
- **Component Manager** — Baustoffe mehrschichtiger Bauteile. Pro Component:
**Schraffur** (→ Hatch Manager), **3D-Textur**, Farbe und
**Verschneidungs-Priorität** (`joinPriority`).
Alles verweist per id auf diese Bibliotheken (Components nutzen Hatches, Hatches
nutzen Linienstile, 2D-Objekte & Ebenen-Defaults nutzen Linienstile/Schraffuren) —
zentral änderbar.
Regel: **höhere Priorität verschneidet sich zuerst / läuft durch.** Beispiel an
einer T-Ecke (Beton-Wand mit Innen- und Außenputz):
- **Beton** (höchste Prio) läuft in der Mitte **durch** den Stoß.
- **Putze** (niedrige Prio) **verbinden** sich jeweils auf ihrer Seite mit dem
angrenzenden Putz, gehen aber **nirgends durch** den Beton.
Das ist die anspruchsvollste Verschneidungs-Logik (Revit „Layer Priority /
Wrapping", Vectorworks „Component-Verschneidung"). Wir bauen sie stufenweise auf
der bereits funktionierenden L-Ecken-Gehrung auf.
---
## 3. Zwei Wege zum 2D-Plan (zentrale Architektur-Erkenntnis)
Architektur-Pläne entstehen auf **zwei verschiedenen Wegen** — das prägt die ganze Engine:
**A) Grundriss = aus dem semantischen 2D-Footprint + Symbolik**
Ein Grundriss ist ein horizontaler Schnitt auf ~1 m. Statt ein 3D-Mesh zu zerschneiden, generieren wir ihn **direkt aus den Parametern**: Wand-Achsen + Dicken → Linien; Öffnungen → Lücken + Tür-/Fenstersymbol; Treppe → Lauflinie. Schnell, exakt, sauber, vektorbasiert.
**B) Schnitt & Ansicht = aus 3D-Projektion (HLR)**
Vertikale Schnitte und Ansichten brauchen echte 3D-Projektion mit verdeckten Kanten (Hidden Line Removal) durch das zusammengebaute Gebäude.
→ Wir brauchen **beides**: einen sauberen 2D-Symbol-Renderer *und* einen Projektions-Pfad.
---
## 4. Tech-Stack
| Schicht | Wahl | Begründung |
|---|---|---|
| BIM-Datenmodell | **eigenes parametrisches Gebäudemodell** (TS) | web-ifc ist stark beim *Lesen/Anzeigen* von IFC, schwächer beim *Authoring/Editieren*. Für ein Editier-Tool brauchen wir ein eigenes, editierbares Modell. |
| IFC-Interop | **web-ifc** (ThatOpen, WASM) | Import/Export nach IFC — Brücke zu Revit/ArchiCAD. |
| 3D-Rendering | **Three.js** | Standard; ThatOpen baut darauf auf, also kompatibel. |
| Geometrie-Booleans | **OpenCascade.js** (Öffnungen) *oder* Manifold | Tür/Fenster schneidet Loch in Wand. OCC = exakt (B-Rep), Manifold = schnell (Mesh). Entscheidung in Phase 0. |
| Projektion/HLR | **OpenCascade.js** (`HLRBRep`) | Saubere Linien für Schnitte/Ansichten. |
| 2D-Pläne | **SVG** + eigener Symbol-Renderer | Vektor, druckbar, exportierbar (DXF/PDF). |
| Frontend | **React + TypeScript + Vite** | Schnelles HMR, großes Ökosystem. |
| Worker-Bridge | **Comlink** | Schwere Geometrie im Web Worker, UI bleibt flüssig. |
| State | **Zustand** o.ä. | Passt zum komplexen Dokumentmodell. |
| Persistenz (später) | Postgres + Object Storage | Versionierbare Projekte. |
---
## 5. Datenmodell (grob)
Vectorworks-orientiert: **Design Layers** (Modell-Eingabe) und **Drawing Layers**
(abgeleitete Ausgabe). Bauteile beziehen ihren Aufbau aus **Components** (verwaltet
im Component Manager).
```
Project
├─ Resources // verwaltete Bibliotheken (Vectorworks-Stil)
│ ├─ LineStyles[] // Line Manager: { id, name, weight, color, dash }
│ ├─ Hatches[] // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId }
│ └─ Components[] // Component Manager: wiederverwendbare Baustoffe
│ Component { id, name, hatchId, texture3d, color, joinPriority }
│ // joinPriority: höher = verschneidet sich zuerst (geht durch)
├─ Grids (Achsraster) (optional)
├─ Types // mehrschichtige Aufbauten
│ ├─ WallType { id, name, layers: Layer[] }
│ └─ SlabType { id, name, layers: Layer[] }
│ Layer = { componentId, thickness } // Priorität liegt am Component
│
├─ DesignLayers ("Ebenen") // hier wird modelliert & 2D gezeichnet
│ DesignLayer { id, name, elevation(z), height(Δz),
│ defaultLineStyle, defaultHatch,
│ elements: Wall | Door | Window | Slab | Stair | Roof | Space ,
│ draw2d: Line | Polyline | Rect | Circle | Arc | Text }
│ Wall { axis, wallTypeId, height }
│ Door { hostWall, position, width, height, swing, symbolId }
│ Window { hostWall, position, width, height, sill }
│ Slab { boundary, slabTypeId } · Space { boundary, name } // Fläche auto
│
└─ DrawingLayers ("Zeichnungsebenen") // abgeleitete Ausgabe
DrawingLayer { id, name,
type: plan | section | elevation | drawing,
cutHeight(z), // bei plan: Schnitthöhe
sectionLine, // bei section
sourceDesignLayers[],
detailLevel: coarse|medium|fine,
scale, styleOverrides, dims[], labels[], annotations[] }
```
3D *und* jede Drawing Layer werden **aus den Design Layers abgeleitet**. `cutHeight`,
`detailLevel`, `Styles` und `Component`-Eigenschaften steuern, *wie* abgeleitet wird.
---
## 6. Die harten Risiken (früh angehen)
1. **Wand-Verbindungen / Cleanup** ⚠️ — Wo Wände aufeinandertreffen, müssen sie sauber verschneiden. **L-Ecken-Gehrung: ✅ erledigt.** Offen & berüchtigt schwer: **Prioritäts-basierte T-/X-Stöße bei mehrschichtigen Wänden** (Beton durch, Putz verbindet seitlich, geht nicht durch — siehe 2d). → stufenweise auf der Gehrung aufbauen.
2. **Gehostete Öffnungen** — Tür/Fenster muss synchron mit der Wand bleiben (verschieben, schneiden). → Saubere Host-Beziehung im Modell.
3. **Symbolischer Plan-Renderer** — normgerechte Darstellung (Schwenkbögen, Schraffuren der geschnittenen Bauteile, Lauflinien). → Eigenes Regelwerk; früh prototypen.
4. **Schnitt-/Ansichts-Projektion (HLR)** durch ganzes Gebäude — Performance. → Worker + Caching.
5. **IFC-Treue** — verlustarmer Round-Trip. → Früh mit echten IFC-Dateien testen.
6. **Geschoss-übergreifende Elemente** (Treppen, Lufträume).
---
## 7. Phasen-Roadmap
### Phase 0 — Spike: das größte Risiko zuerst
**Ziel:** Beweisen, dass der symbolische Plan-Pfad im Browser funktioniert.
- Eine Wand zeichnen, eine Tür platzieren → Öffnung wird geschnitten (3D).
- Daraus **Grundriss als SVG** generieren: Wand-Schnittlinien + Tür-**Schwenkbogen**.
- ✅ *Erfolg:* sauberer, schöner Grundriss-Ausschnitt aus einem semantischen Modell.
### Phase 1 — MVP: durchgehende Wohnbau-Scheibe
- **Dokumentmodell (Vectorworks-Stil):** Design Layers ("Ebenen") + Drawing Layers
("Zeichnungsebenen", Typ plan/section/elevation, mit `cutHeight`).
- **Resource Manager:** Line Manager, Hatch Manager, Component Manager (mit
`joinPriority`, Schraffur, 3D-Textur).
- **Wände** mehrschichtig (✅) mit Eck-Gehrung (✅); **Prioritäts-T-Stöße** (Risiko #1).
- **Türen & Fenster** gehostet in Wänden (Risiko #2).
- **2D-Zeichnen:** Linie, Polylinie, Rechteck, Kreis, Bogen mit Stilen.
- **Decken/Böden** (Slabs); 3D-Viewport + **live Grundriss**, Basis-Bemaßung.
- → aus DOSSIER (§11): Wand-Referenzlage (mid/left/right), Öffnungs-Detailgrad mit Dokument-Override, Decken-Aussparungen, Element-Übersicht (BIM-Tree).
- ✅ Ein einfaches Haus modellieren → saubere Pläne pro Geschoss.
### Phase 2 — Vollständiger Bauteil-Satz Wohnbau
- **Treppen** (mit Lauflinie im Plan), **Dächer**, Geländer.
- **Räume/Spaces** mit automatischer Flächenberechnung & Raumstempel.
- Stützen/Unterzüge (falls nötig).
- Materialien & einfache Visualisierung.
- → aus DOSSIER (§11): Treppen-Typen (gerade/L/Wendel) + geschoss­übergreifend, Dach-Typen (Pult/Sattel/Walm/Mansarde), Stützen-Profile, **SIA-416-Räume** + CSV, Raumstempel-Builder, Stil-Kataloge (Wände/Öffnungen).
### Phase 3 — Plan-/Dokumentations-Modul ⭐ (Differenzierung)
**Hier gewinnen wir. Maximale Politur.**
- **Grundrisse, Schnitte (HLR, Risiko #4), Ansichten.**
- **Automatische Bemaßung** (Außenketten, Achsen, Öffnungen) + manuelle.
- Schraffuren geschnittener Bauteile, Raumstempel, Beschriftungen, Symbole.
- **Plansätze/Sheets** mit Titelblock, Maßstäben, Layout.
- Schöne Typografie & Linienführung — genau das, was die Großen vermasseln.
- → aus DOSSIER (§11): **Massstab pro Viewport** (Auto-DPI, Plotweight-/Schraffur-Skalierung), Section-Style (3D-Schnittflächen), Ausschnitte (View-Snapshots) + Layer-Kombinationen, Kamera-Presets (Kardinal/Iso, **Norden-Rotation**), Detail-Bindung an Ausschnitt, Multi-Page-PDF @DPI, regelbasierte Overrides, Rich-Text-Annotationen.
### Phase 4 — Interop (Import/Export)
- **Import:** **DWG/DXF** (2D-Pläne/Bestand), **IFC** (BIM-Bestand), **STL/OBJ**
(Mesh-Referenzmodelle), **XYZ** (Punktwolken aus Vermessung).
- **Export:** **IFC** (Brücke zu Revit/ArchiCAD), **DWG/DXF**, **glTF/OBJ**.
- Round-Trip-Tests mit echten Dateien (Risiko #5).
- → aus DOSSIER (§11): **Swisstopo-Import** (swissBUILDINGS3D / swissALTI3D / SWISSIMAGE, LV95↔WGS84) ⭐ CH, OSM-Overpass-Kontext, Terrain-Mesh-Generator.
### Phase 5 — Persistenz & Konten
- Accounts, Projekte speichern/laden, Versionierung, Auto-Save.
- → aus DOSSIER (§11): Projekt-Persistierung auf Browser-Storage migrieren (IndexedDB statt doc.Strings); Presets/Favoriten cross-projekt (LocalStorage, Export/Import).
### Phase 6 — Export & Kollaboration
- Export: **PDF**, **DXF** (Pläne); **IFC**, **glTF** (3D).
- Teilen per Link, Kommentare, später Echtzeit-Co-Editing.
### Phase 7 — Produktisierung
- Performance-Härtung (große Modelle), Onboarding, Pricing, PWA/Offline.
---
## 8. Offene Fragen
- Booleans: OpenCascade (exakt) vs. Manifold (schnell) — Entscheidung in Phase 0.
- Welche Normen für Plandarstellung (SIA / DIN / …)? → beeinflusst Symbolik. *(Hinweis: User ist in der Schweiz → SIA prüfen.)*
- Wie viel Statik/Bauphysik (gar nicht / später)?
- Pricing-Modell (Freemium, pro Seat?).
---
## 9. Nächster konkreter Schritt
**Phase 0 starten:** Projekt scaffolden + Spike bauen — Wand + Tür mit geschnittener Öffnung → schöner Grundriss-Ausschnitt als SVG (mit Schwenkbogen). Das entschärft Risiko #1–#3 (Modell, Hosting, Symbolik) auf einmal.
---
## 10. Leitentscheidungen & UI-Architektur
### 10a. Leitentscheidungen aus der Recherche (Details in `docs/`, Index `docs/README.md`)
1. **Pure-Ableitungs-Architektur** als Fundament — ein semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern (Store + Undo früh).
2. **OCCT/replicad im Web Worker** früh als Spike — kritischer Pfad für Schnitt/Ansicht (HLR), exakte Wand-Booleans und IFC. WASM-Größe + HLR-Kosten (pro Ansicht cachen) validieren.
3. **Component-getriebene Prioritäts-Verschneidung** (`joinPriority` am Component): höchstes gemeinsames Material läuft durch, Rest mitert. 2D-Plan analytisch, exakte 3D-Booleans im Worker.
4. **SVG/Paper-Space-Maßstabsmodell** — Strichstärke/Text/Hatch in mm, `dpi=96·devicePixelRatio`, ein Serializer für Bildschirm + PDF + DXF.
5. **CH-Spezifika als Differenzierer** ohne Backend — SIA-416 (reine Logik) + serverloser Swisstopo-Flow (CORS-offen, Parzelle/EGRID, Norden-Rotation, Origin-Shift).
### 10b. Panel-System (dockbar, Tabs, erweiterbar) — NEU
- **Docks links & rechts**; Panels in **Tab-Strips** gruppierbar (z. B. Zeichnungsebenen & Ebenen als Tabs eines Docks).
- **Panel-Registry** → eigene Panels und spätere **Plugins** registrieren sich und erscheinen als Panel.
- **Verschiebbar & floatend (am Tab gegriffen):** Ein Panel wird **am Tab selbst** (im Tab-Strip) gezogen → innerhalb des Docks **umsortieren**, ins andere Dock ziehen, oder aus dem Dock lösen. Andocken am **linken/rechten Rand** (Andock-Zonen beim Ziehen hervorheben); wird nicht angedockt, **schwebt** das Panel als freies (verschieb- und größenveränderbares) **Floating-Fenster** über dem Arbeitsbereich. Float-Position/Größe + Dock-Zustand werden gespeichert.
- **Fenster-Layouts speicherbar** (localStorage, benannte Layouts; Standard-Layout als Default).
- Der **Ressourcen-Manager** wird ebenfalls ein Panel (rechtes Dock).
- Pro Layer-Panel oben ein **Anzeige-Modus-Dropdown**: *nur aktive · alle anzeigen · aktive + andere grau* (DOSSIER/Vectorworks „Layer Options") — wirkt auf Plan & 3D.
### 10c. Plan-Navigation
- Grundriss/Schnitt/Ansicht: **Pan** (ziehen), **Zoom** (Mausrad zum Cursor), **Einpassen** — analog zum 3D-Viewport. SVG-`viewBox`-Transform.
### 10d. Backend & Kollaboration (Details: `docs/backend.md`)
- **Jetzt:** client-only (IndexedDB + Datei-Export/Import), kein Server. Modell JSON-serialisierbar + Edits als Operationen → **CRDT-fähig** halten.
- **Phase 5:** **Supabase self-hosted** (Docker Compose: Postgres + Auth + Storage) für Konten/Projekte/Dateien.
- **Phase 6:** **Yjs + Hocuspocus** (CRDT-Sync-Container, persistiert nach Postgres) für Echtzeit-Kollaboration. Alles self-hosted.
- Empfehlung: Stack **noch nicht** aufsetzen (würde den Modellierer ausbremsen); Weiche ist gestellt, Einführung additiv.
### 10e. Top-Bar & Footer/Status-Leiste (Vectorworks-Stil) — NEU
- **Top-Bar (Oberleiste, wie DOSSIER `toolbar.py`/`ToolbarApp.jsx`):** Ansichts-Umschalter (Grundriss/Perspektive/Schnitt/Ansicht), Render-/Darstellungsmodus, aktives Geschoss + aktive Ebene, Snapping-Schalter, Massstab, Werkzeug-Kontext, Einstellungen/Ressourcen.
- **Footer/Status-Leiste (wie Vectorworks unten):** Cursor-Koordinaten **X/Y/Z**, Einheit, aktueller **Massstab** (1:N) + **Zoom %**, aktives Geschoss/Ebene, **Snap-Status**, kurzer Werkzeug-Hinweis links.
- Beide an das Panel-/Dock-Layout angedockt; Inhalte aus dem Modell abgeleitet.
### 10f. Maus-Interaktion & Kontextmenü — NEU
- **Maus-Schema:** **Mitte = navigieren** (Plan: Pan · 3D: Orbit, `Shift`+Mitte: Pan) · **Links = Auswahl** (Einzelklick + **Markierrahmen/Aufziehrahmen** für Mehrfachauswahl im 2D) · **Rechts = Kontextmenü** · **Rad = Zoom** (zum Cursor).
- Marquee: Aufziehen von links→rechts = nur vollständig umschlossene Elemente; rechts→links = auch berührte (wie CAD-üblich).
- **Eigenes Kontextmenü-System** (gestylt, dunkel, wiederverwendbar) — kein Browser-Menü.
- **Ebenen-Kontextmenü 1:1 wie DOSSIER** (Einträge aus `layers_panel.py`/`DrawingLevelsApp.jsx` übernehmen) — auch auf Zeichnungsebenen.
- Kontextmenü generisch, damit Plan-Elemente, Panels & spätere Plugins eigene Einträge registrieren können.
---
## 11. Aus DOSSIER übernehmen — Backlog
Konkret im DOSSIER-Rhino-Plugin umgesetzte Features, die sich für den Standalone-Port lohnen. Bereits abgedeckt (Layer-Modell, mehrschichtige Wände, Prioritäts-Stöße, Component-/Line-/Hatch-Manager, Ansichtstypen, Detailgrade) ist hier **nicht** erneut gelistet — nur das Zusätzliche. Aufwand: S/M/L. Phase verweist auf §7.
> **⭐ CH-Schätze (Schweiz-spezifisch, kaum woanders verfügbar):**
> - **Swisstopo-Geodaten** — swissBUILDINGS3D (3D-Bestand), swissALTI3D (präzises Höhenmodell), SWISSIMAGE (10-cm-Orthofoto), offene STAC-APIs ohne Auth, inkl. **LV95↔WGS84**-Transformation. Echter Standort-Kontext per Knopfdruck statt manuellem CAD-Import.
> - **SIA-416-Flächen** — Raum-Klassifikation (HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV/Excel-Export. Pflicht für CH-Energie-/Flächennachweise.
> - **Norden-Rotation** bei Kamera-Presets — Georeferenzierung passend zu Swisstopo/swissBUILDINGS.
### Bauteile
| Feature | Nutzen | Aufwand | Phase |
|---|---|---|---|
| Wand-Referenzlage (mid/left/right) | Achse intuitiv auf Aussenkante/Mitte legen; hilft beim Import fremder Dateien | S | 1 |
| Öffnungs-Detailgrad + Dokument-Override (`aktive_darstellung`) | LoD je Massstab (1:500 Rechteck → 1:50 Glas/Sims) global umschaltbar; kritisch für Mixed-Scale | M | 2 |
| Fenster/Tür mit Rahmen, Brüstung, Sims, Glas, Flügelzahl | Öffnung als vollwertiges Bauteil statt nur Loch; Render-Realismus | M | 2 |
| Tür-Schwenkbogen (Öffnungswinkel + Anschlagseite) | Öffnungsbahnen für Möblierung/Kollision; Standard-Plansymbol | M | 2 |
| Decken-Aussparungen (Treppenauge, Schächte, Kamin) | Konstruktiv echte Deckenöffnungen, nicht nur sichtbar | M | 1–2 |
| Decken UK/OK-Override | Abhängungen, schräge Brüstungen, abweichende Raumhöhen | S | 1 |
| Treppen-Typen gerade/L/Wendel + Stufen/Lauflinie/Podest | Volle Vertikalerschliessung, volumetrisch korrekt, Plan-Symbole | M | 2 |
| Treppe geschoss­übergreifend (`geschoss_end`, Höhen-Override) | Atrien, Rampen, Mehr-Geschoss-Läufe (Risiko #6) | S | 2 |
| Treppen-2D-Symbol mit Auf-/Abpfeil + Schnitt | Normgerechtes Plansymbol (Richtung, Stufenzahl, Lauflinie) | M | 2 |
| Dach-Typen Pult/Sattel/Walm/Mansarde + Neigung(en) | 3D-Volumen mit Gefälle, Kubatur, Material; Mansarde später (L) | M–L | 2–3 |
| Stützen-Profile (Quadrat/Rechteck/Rund/I/Rohr) + Drehung | Beton- und Stahltragwerk mit echtem Querschnitt | M | 2 |
| Träger achs-basiert, hängt unter Decken-OK | Unterzug folgt Deckenoberkante, weniger Fehler bei Updates | S | 2 |
| **SIA-416-Räume** (HNF/NNF/VF/FF/GF/AGF) + Bilanz-CSV ⭐ | CH-Flächennachweis, Excel-Export | M | 2 |
| Raum-Stempel-Builder (Drag-&-Drop-Felder) + Fläche-Rundung + Personen | Projekt-eigene Stempel-Layouts ohne Code; lesbare Listen; Brandschutz | M | 2–3 |
| Element-Übersicht (BIM-Tree Geschoss→Kind→Element, Suche, Zoom) | Inhaltsverzeichnis bei 100+ Elementen; Shift-Klick = Zoom | S | 1 |
| Stil-Kataloge Wände & Öffnungen (Presets) | Standard-Typen 1-Klick; globaler Stilwechsel | M | 2 |
| Grip-Editing (Wand-Endpunkte, Schnitt-Symbole im Plan) | Direktes Ziehen statt Dialog; 2D/3D-Sync; wichtig im Browser | L | 3–4 |
### Darstellung / Ressourcen
| Feature | Nutzen | Aufwand | Phase |
|---|---|---|---|
| Regelbasierte Overrides (Layer-/Tag-/Name-Regel, Priorität, Templates) | Automatische Farb-/Strich-/Linientyp-Anpassung; wiederverwendbar (vertieft §2c „Overrides") | M | 3 |
| Section-Style für 3D-Schnittflächen (Schraffur + Schnittkante/Silhouette) | 3D-Schnitt-Rendering im Viewport, nicht nur 2D-Plan | M | 4 |
| Massstabs-abhängige Linientyp-/Plotweight-Skalierung | Linientypen & Strichstärken bei 1:N korrekt sichtbar; PDF-Treue | M | 3–4 |
| Material-Bibliothek mit PBR (Rauheit/Reflexion/Transparenz) + Templates | Vertieft Component-Manager um Renderqualität; Seeds Beton/Holz/Dämmung | M | 3 |
| Rich-Text-Annotationen (Bold/Italic/Hoch-/Tiefstellung, Maskierung, Rahmen) | Bemaßungs-Indizes, formatierte Beschriftungen auf Canvas | M | 3 |
| LoD-bewusste Stil-UI (zeigt nur passende Controls je Geometrie-Typ) | Weniger kognitive Last (keine Füll-Optionen bei 3D-Auswahl) | S | 1 |
### Pläne / Output
| Feature | Nutzen | Aufwand | Phase |
|---|---|---|---|
| **Massstab pro Viewport** mit Auto-DPI + Schraffur-/Strich-Skalierung | Exakte Masse & lesbare Strichstärken ohne manuelle Kalibrierung | M | 3 |
| Ausschnitte / View-Snapshots (Kamera + Darstellung + Massstab) | Navigation über 50+ Ansichten; ersetzt Ordner-Wildwuchs (vertieft §2c) | M | 3 |
| Layer-Kombinationen als Presets (live oder eingefroren) | Bauphasen/Varianten/MEP per Klick statt manuellem Toggling (vertieft §2c) | S | 3 |
| Kamera-Presets (Kardinal N/O/S/W, Iso-Oktanten, **Norden-Rotation** ⭐) | Schnelle Ansichtswechsel; Georeferenzierung für Swisstopo | S | 3 |
| Detail↔Ausschnitt-Bindung + „Alle aktualisieren" | Titelblock/Detail synchron umbenennen; 1-Klick-Sync aller Schnitte | M | 3 |
| Multi-Page-PDF-Export @DPI (Vektor) | Druckfertige Plansätze — Kern-Output (ergänzt Phase-3-Sheets) | M | 3 |
| 9-Punkt-Bemaßung/Objekt-Info (lesen + verschieben/skalieren/rotieren) | Direktes Dimensionieren ohne Properties-Panel | M | 3 |
### Kontext / Daten
| Feature | Nutzen | Aufwand | Phase |
|---|---|---|---|
| **Swisstopo-Import** (swissBUILDINGS3D/ALTI3D/SWISSIMAGE) ⭐ CH | Authentischer Standort-Kontext, offene APIs, kein Auth | M | 4 |
| LV95↔WGS84-Transformation ⭐ CH | Karten-Anzeige + präzise CH-Koordinaten; Formeln direkt portierbar | S | 4 |
| OSM-Overpass-Import (Strassen/Gebäude/Wasser/Grün, 7 Kategorien) | Weltweiter, kostenloser Kontext; ergänzt Swisstopo | M | 4 |
| Terrain-Mesh-Generator (Mesh/TIN/NURBS-Patch/Höhenlinien, Volumen für Schnitt) | Geländemodell aus Höhendaten; Section-Cut-Füllung; portierbar zu Three.js | L | 4 |
| Auto-Zoom auf Import + Nullpunkt-Verschiebung (LV95→0/0/0) | Modellierungsgenauigkeit trotz Millionen-Koordinaten; UX-Standard | S | 4 |
| Projekt-Persistierung browser-nativ (IndexedDB statt doc.Strings) | Projekt kapselt seine Einstellungen lokal | M | 5 |
| Cross-Projekt-Presets (LocalStorage-Favoriten, Export/Import, Team-Sharing) | Einmal speichern, überall nutzen | S | 5 |
+137
View File
@@ -0,0 +1,137 @@
# SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen)
Stand: 2026-07-05. **Auftrag/Übergabe für einen Agenten. Kleinster ehrlicher
Durchstich — kein Produktfeature, keine Integration.**
Ziel: Beweisen, dass der bestehende wgpu-3D-Renderer echte **Bild-Texturen** auf
Wandflächen darstellen kann, sichtbar im `spike3d`-Fenster. Am Ende steht eine
belastbare Aussage, wie viel Arbeit „richtig gutes texturiertes 3D" wirklich ist —
statt Spekulation.
---
## 0. Ausgangslage (verifiziert am 2026-07-05)
- `render3d` ist **kein** Three.js-Wrapper, sondern ein eigenständiger wgpu-Renderer
(~6400 LOC): echte GPU-Pipeline (wgpu 29), Tiefenpuffer, MSAA, WGSL-Shader.
Läuft nativ (winit-Spike), headless (naga-validiert) und im Browser (WebGPU).
- **`RenderStyle::Textured` existiert bereits als Stub** (`gpu.rs:34` Enum-Variante,
`gpu.rs:47` Parse aus `"textured"`) — es gibt aber **kein echtes Texturing**:
kein Sampler, keine Textur-Bind-Group, keine Bilddaten.
- Die `cap_pipeline` mit Layout `[pos vec3, uv vec2]` + `CAP_WGSL` ist **nicht** für
Bildtexturen, sondern für die **Schnittflächen-Kappen** (prozedurale Schraffur);
die UVs steuern dort den Schraffur-Abstand. **Nicht damit verwechseln.**
- **Günstig für uns:** Die Muster, die der Spike braucht, existieren schon —
ein UV-tragendes Vertex-Layout und eine zweite/dritte Pipeline, die sich die
`Globals`-Bind-Group teilt (`grid`, `cap`). Der texturierte Mesh-Pfad reiht sich
1:1 in dieses Muster ein.
---
## 1. Echte Symbole — vor dem Coding lesen
| Was | Ort |
|---|---|
| Vertex heute interleaved `[pos.xyz, normal.xyz, color.rgb]`, `FLOATS_PER_VERTEX`, `Mesh`-Struct | `src-tauri/render3d/src/types.rs:322` ff. |
| Quad-Emitter (Normale + Farbe je Vertex, Reihenfolge `(0,1,2)+(0,2,3)`) | `src-tauri/render3d/src/mesh.rs:886` |
| Wand-Extrusion / Mesh-Bau | `src-tauri/render3d/src/mesh.rs` — `extrude_wall` (`:91`), `build_walls_mesh` (`:911`) |
| Haupt-Pipeline + `Globals`-Bind-Group (group 0) | `src-tauri/render3d/src/gpu.rs:232`–`:320` |
| Vorlage „zweite Pipeline teilt sich Globals" — Grid | `src-tauri/render3d/src/gpu.rs:329` |
| Vorlage „Pipeline mit UV-Layout `[pos vec3, uv vec2]`" — Cap | `src-tauri/render3d/src/gpu.rs:408` |
| `RenderStyle`-Enum + Stub `Textured` | `src-tauri/render3d/src/gpu.rs:34`, `:47` |
| Pipeline-Bindung im Render-Pass (Muster für Stil-Umschaltung) | `src-tauri/render3d/src/gpu.rs:847` |
| Shader als WGSL-Konstanten (`MESH_WGSL`, `CAP_WGSL`) | `src-tauri/render3d/src/shaders.rs` |
| Beleuchtungsmodell (hemisphärisch + Directional + Fill) — Doku | `src-tauri/render3d/src/shaders.rs:1`–`40` |
| naga-WGSL-Validierung headless (Test-Vorlage) | `src-tauri/render3d/src/lib.rs:924` (`cap_module`) |
| Fenster-Spike mit Orbit-Kamera | `src-tauri/render3d/src/bin/spike3d.rs` |
---
## 2. Umfang — exakt das, nicht mehr
### 2.1 UVs auf Wandflächen
- In der Quad-Emitter-Funktion (`mesh.rs:886`) je Vertex eine **UV** berechnen:
planare Projektion in **Metern** — `u` = Distanz entlang der Wandachse,
`v` = Höhe (z). Textur-Raster damit weltmassstäblich (z. B. 1 Kachel = 1 m).
- Den bestehenden `[pos, normal, color]`-Pfad **bitgleich unangetastet** lassen.
Zwei zulässige Wege (Agent wählt begründet):
1. **Separates additives UV-Array** in `Mesh` (Default leer/None), oder
2. **Paralleler `build_walls_mesh_textured`** → interleaved
`[pos.xyz, normal.xyz, uv.xy]`.
- Regressionstests für den Alt-Pfad müssen grün bleiben (siehe §4).
### 2.2 Test-Textur prozedural (kein Asset, keine `image`-Crate)
- Ein **256×256 RGBA-Schachbrett/Grid im Code** generieren (`Vec<u8>`).
- `device.create_texture` + `queue.write_texture` + `Sampler`
(`FilterMode::Linear`, `AddressMode::Repeat`). Mipmaps optional (nice-to-have für
flache Blickwinkel; kein Muss für den Spike).
- Selbstständig, damit der Spike ohne Dateipfade/Asset-Pipeline läuft.
### 2.3 Textur-Bind-Group (group 1)
- Neue Bind-Group-Layout mit `texture_view` (`TextureSampleType::Float`) +
`sampler`. **`Globals` bleibt group 0** und unverändert.
### 2.4 Textured-Pipeline + WGSL (`MESH_TEXTURED_WGSL`)
- Vertex-Layout `[pos vec3, normal vec3, uv vec2]`, `TriangleList`.
- **Dieselbe Beleuchtung wie `MESH_WGSL`** (hemisphärisches Ambient + Directional +
Fill) — nur **Albedo = `textureSample(tex, samp, uv)`** statt Vertex-Farbe.
- Depth-Format, MSAA (`SAMPLE_COUNT`) und Color-Target **identisch** zur
Haupt-Pipeline (sonst inkompatibler Render-Pass).
- Pipeline-Layout bindet group 0 (Globals) **und** group 1 (Textur).
### 2.5 Verdrahten
- Bei `RenderStyle::Textured` im Render-Pass die neue Pipeline + beide Bind-Groups
setzen (Muster: `cap_pipeline`-Bindung bei `gpu.rs:847`).
### 2.6 Spike sichtbar machen
- `spike3d.rs` so erweitern, dass der Stil auf `Textured` schaltbar ist
(Tastendruck, z. B. `T`, **oder** Startkonstante). Die Demo-Wände sollen
texturiert im Orbit erscheinen.
---
## 3. Randbedingungen (hart)
- **Nur** die `render3d`-Crate. `src/web.rs` und die Tauri-/`native3d`-Oberfläche
**nicht** anfassen.
- Feature-gegatet unter dem bestehenden `render`/`window`-Feature.
**Default-Build und Default-Darstellung bleiben unverändert.**
- **Keine neuen Dependencies** (insbesondere **kein `image`-Crate**) für den Spike.
- Term-/Reihenfolge-sensible Geometrie (Parität) wird **nicht** berührt — es kommt
nur additiv ein UV-Kanal + ein zweiter Render-Pfad dazu.
- Kommentar-Stil und Sprache (Deutsch, ausführliche Begründungs-Kommentare) wie im
umgebenden Code beibehalten.
---
## 4. Akzeptanz / Verifikation
1. `cargo test` (im Crate-Verzeichnis `src-tauri/render3d`) **grün**, inklusive:
- bestehende Mesh-Regression (Alt-Pfad `[pos,normal,color]` unverändert),
- **neuer naga-Validierungstest** für `MESH_TEXTURED_WGSL` (Vorlage:
`lib.rs:924`).
2. `cargo run --features window --bin spike3d` zeigt die Demo-Wände mit
**erkennbarer, korrekt gemappter** Schachbrett-Textur:
- Raster weltmassstäblich (in Metern), keine Verzerrung an Gehrungen/Ecken,
- beleuchtet wie im Shaded-Modus (Volumen bleibt ablesbar).
3. Umschalten Shaded ↔ Textured zur Laufzeit (oder per Startkonstante) funktioniert
ohne Re-Meshing-Crash.
---
## 5. Abschlussbericht (vom Agenten am Ende zu liefern)
- Welche Dateien geändert/hinzugefügt wurden und warum.
- Wie die UVs projiziert werden (Achswahl, Massstab, Verhalten an Gehrungen).
- Welcher der beiden UV-Wege (§2.1) gewählt wurde und weshalb.
- **Ehrliche Lückenliste für „richtig gutes" Texturing:** Asset-/Bild-Datei-Laden,
Material→Textur-Zuordnung (Wandtyp/Layer → Material), Normal-/Roughness-Maps
(PBR), anisotropes Filtern + Mipmaps, Web-Pfad (`web.rs`/WebGPU), UI zum
Zuweisen. Grobschätzung Aufwand je Punkt.
---
## 6. Nicht im Scope
Asset-/Bild-Datei-Laden · Material-System · mehrere Texturen gleichzeitig ·
PBR/Normal-Maps · Web-Pfad (`web.rs`) · jegliche UI · Anbindung unter die Webview.
+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 2–5 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.
+175
View File
@@ -0,0 +1,175 @@
# ARCHITEKTUR-BRIEFING: Tauri + wgpu (Korrigiert)
**Stand:** 2026-07-01 — **KORREKTUR** (vorherige Dokumente waren unvollständig)
---
## Die Entscheidung (FINAL)
**Browser-CAD (alt) → Desktop Tauri-App mit Rust-Backend + wgpu-Rendering (neu)**
| Aspekt | Alt | Neu |
|---|---|---|
| **App-Form** | Browser-Tab | Desktop Tauri-Window |
| **Frontend** | React/Vite (TS) | React/Vite (TS) — UNVERÄNDERT |
| **2D-Rendering** | SVG-Plan | SVG-Plan — UNVERÄNDERT |
| **3D-Rendering** | three.js/WebGL | **wgpu** (Vulkan/Metal/DX12) |
| **Rechenintensive Ops** | TS in Browser | **Rust im Backend** |
| **Betriebssystem** | cross-platform browser | Windows/macOS/Linux Desktop App |
---
## Warum dieser Umstieg?
**Problem mit three.js/WebGL:**
- komplexe Möbel = 100k+ Polygone
- mehrere parallele Ops (kernel2d, bool-Ops, DXF-Parser, SIA-Raumerkennung)
- WebGL hat harte Limits (GPU VRAM, draw calls, single-threaded)
- → Laggy, nicht skalierbar für Professional CAD
**Lösung: Tauri + wgpu + Rust**
- **wgpu** = low-level GPU API (direkt zu Vulkan/Metal/DX12, nicht WebGL)
- **Rust** = rechenintensive Ops parallelisiert + native performance
- **Desktop** = native App, nicht Browser (bessere Kontrolle, bessere Perf)
- **React-Frontend bleibt** = UI/Sketching/Panels unverändert (wgpu nur für 3D-Display)
---
## Der neue Stack (FINAL)
```
┌─────────────────────────────────────────────────────┐
│ Desktop Tauri-Window │
├─────────────────────────────────────────────────────┤
│ │
│ React/Vite (UI-Shell, State, Panels) │
│ ├─ SVG 2D-Plan (Grundriss) │
│ └─ wgpu 3D-Viewport (3D-Ansicht + Rendering) │
│ │
│ ↓ invoke (IPC) ↓ │
│ │
│ Rust-Backend (src-tauri/) │
│ ├─ computeJoins() — Wand-Eckverbindungen │
│ ├─ kernel2d() — Offset/Trim/Extend/… │
│ ├─ parseShapeFromDwg() — Geometrie-Import │
│ ├─ detectRooms() — SIA-Raumerkennung │
│ └─ booleanOps() — Union/Differenz/Schnitt │
│ │
└─────────────────────────────────────────────────────┘
```
**Nicht verändert:**
- App.tsx, state/, commands/, panels/, model/types.ts
- UI-Logik, Befehlssystem, Sketching-Tools
**Neu/geändert:**
- `src-tauri/` — Rust-Crate für Backend
- `src/compute/index.ts` — Boundary (invoke + TS-Fallback)
- Rendering-Engine: **three.js → wgpu**
- Build: `npm run tauri:build` statt `npm run build`
---
## Die Aufgabe (vier Agents)
**Siehe: `docs/design/tauri-migration-plan.md`**
Vier parallele Agents:
1. **Agent: Tauri-Shell Setup** → `src-tauri/` + Cargo.toml + main.rs (Tauri window registrieren)
2. **Agent: Compute-Boundary (TS)** → `src/compute/index.ts` (invoke-Wrapper + TS-Fallbacks)
3. **Agent: Rust compute_joins Impl** → `src-tauri/src/geometry.rs` (erste Op, Proof-of-Concept)
4. **Agent: Vite/Package-Integration** → vite.config.ts + package.json (Tauri-Plugins, scripts)
**Deliverable:** lauffähige Desktop-App, wand-Edit triggert Rust-Op, Output identisch TS-Version.
---
## Dev-Workflow (post-Tauri)
```bash
# Dev: Terminal 1 (Vite)
npm run dev # localhost:5173
# Dev: Terminal 2 (Tauri)
npm run tauri:dev # öffnet Desktop-Window, zeigt auf localhost:5173
# Build
npm run tauri:build # → Windows .exe / macOS .app / Linux .deb
```
---
## Rendering: three.js → wgpu (Wichtig!)
**3D-Viewport wird NEU implementiert in wgpu:**
- Nicht: "drei.js mit Rust-Fallback"
- **Ja:** wgpu als native GPU-Renderer (skaliert auf 100k+ Polygone)
**2D-Plan bleibt SVG** (keine Änderung).
**Timeline für wgpu-Impl:**
- Milestone 1 (jetzt): Tauri-Shell + Rust-Compute-Ops
- Milestone 2 (nächst): wgpu 3D-Viewport-Impl (ersetzt drei.js/WebGL)
- Milestone 3: Live clipping/sectioning in wgpu
---
## Migration-Reihenfolge
**Phase 1 (Tauri-Shell + Proof-of-Concept):**
- [ ] `computeJoins` (Wand-Ecken) → Rust
**Phase 2 (Rendering-Umbau):**
- [ ] wgpu Viewport-Impl (ersetzt drei.js)
- [ ] Instancing/LOD für Möbel-Geometrie
**Phase 3 (weitere Ops):**
- [ ] `kernel2d` (Offset/Trim) → Rust
- [ ] DXF/DWG-Parser → Rust
- [ ] detectRooms → Rust
- [ ] booleanOps → Rust
---
## Was sich NICHT ändert
- `src/App.tsx`, `src/state/`, `src/commands/`
- UI-Panels, Zeichenwerkzeuge, Befehlssystem
- Semantisches Modell (types.ts)
- i18n, Styling
## Was ändert sich
- **Engine:** three.js/WebGL → wgpu
- **Backend:** TS-Only → Tauri + Rust
- **Distribution:** Browser → Desktop App
- **Build-Prozess:** `npm run build` → `npm run tauri:build`
---
## Fehlerquellen (zur Klarheit)
❌ **FALSCH:** "Wir bleiben bei three.js"
✅ **RICHTIG:** "three.js → wgpu, Rust-Compute + Desktop Tauri"
❌ **FALSCH:** "Tauri + three.js als Frontend-Engine"
✅ **RICHTIG:** "Tauri-Shell + React UI + wgpu 3D-Rendering + Rust-Compute"
---
## Nächste Schritte
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
2. **Spawn:** vier Agents (parallel, unabhängig)
3. **Verifizieren:** `npm run tauri:dev` → App läuft, erste Op in Rust funktioniert
4. **Aktualisieren:** HANDOVER.md mit Milestone-1-Status
---
## Fragen?
- **"Warum Desktop statt Browser?"** → Skalierbarkeit (native GPU + Rust-Compute)
- **"Warum wgpu statt drei.js?"** → wgpu skaliert auf 100k+ Polygone, three.js/WebGL hat Limits
- **"Bleibt React?"** → Ja, React/Vite UI bleibt, nur 3D-Engine wechselt
- **"Wann ist wgpu-Impl fertig?"** → Milestone 2 (nach Tauri-Shell)
+167
View File
@@ -0,0 +1,167 @@
# Dokumentation — Standalone Browser-BIM (cad)
> 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
eigenständige Browser-Variante des DOSSIER-Rhino-Plugins (React + TypeScript +
Three.js + SVG, alles client-side). **Leitprinzip aller Dokumente:** ein
semantisches Modell ist die einzige Wahrheit; jede Sicht (3D, Grundriss, Schnitt)
wird **abgeleitet**, Darstellung erst beim Rendern angewandt. Bezeichner im Code
englisch (Vectorworks-Terminologie), Prosa deutsch, Einheiten intern in Metern.
Die Dokumente sind in vier Gruppen geordnet: **Tech** (Bibliotheken/Kernel),
**Architektur/Design** (Aufbau & Bauteile), **UX** (Oberfläche & Interaktion),
**Swisstopo/SIA** (CH-Geodaten & Flächenstandards).
---
## Tech — Technologie- & Bibliotheksauswahl
### [research/tech-selection.md](research/tech-selection.md)
Evaluiert den kompletten Client-Stack für ein server­loses BIM-Werkzeug und
empfiehlt **`replicad`** (idiomatische TS-Schicht über `opencascade.js`/OCCT, MIT)
als primären B-Rep-Kernel im Web Worker, ergänzt durch **`Manifold`** (Apache-2.0)
für schnelle, robuste Mesh-Booleans auf Importgeometrie — denn nur ein echter
B-Rep-Kernel liefert exakte 2D-Ableitungen, und genau das löst replicads
`drawProjection` (OCC-HLR, `{visible, hidden}`-Kanten direkt im Browser). Weitere
Wahl: Import via **web-ifc + Fragments** (IFC), `dxf-parser` (DXF) und
`libredwg-web` (DWG, aber **GPL-3.0 → vorab klären/kapseln**); Vektor-Export über
**`svg2pdf.js` + `jsPDF`** (PDF) und **`@tarikjabiri/dxf`** (echte Hatch-Entities);
Schraffuren als SVG-`<pattern>` mit `userSpaceOnUse` (maßstabskorrekt); Rendering
über **`three/webgpu`** mit automatischem WebGL2-Fallback. Top-Risiken: DWG-Lizenz,
OCCT-WASM-Größe, HLR-Kosten (pro Ansicht cachen), WebGPU vor Migration benchmarken.
---
## Architektur/Design — Aufbau, Datenmodell & Bauteile
### [../ARCHITECTURE.md](../ARCHITECTURE.md)
Die übergreifende Standalone-Architektur und die systematische Übersetzung jedes
DOSSIER-Konzepts in ein Browser-Äquivalent (30-zeilige **Rhino→Browser-Mapping-
Tabelle**). Kern: das semantische `Project` (JSON) als einzige Wahrheit mit pure
`derive()` zu Scene3D/Plan/Section; ein **Zwei-Achsen-Datenmodell**
(`drawingLevels` × `layers`) plus `Resources`/`WallType`/`Element`/`Sheet`; ein
**Zustand-Store** ersetzt DOSSIERs `sc.sticky`-Bus, **`.cad.json`** (File System
Access API) + IndexedDB-Autosave ersetzen `doc.Strings`, und ein **Immer-Patch-
Undo/Redo** eliminiert die Cache-Stale-Bugs strukturell. Ziel-Repo-Struktur mit
**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering
über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt.
### [design/parametric-walls.md](design/parametric-walls.md)
Regelbasierte Wandgenerierung als Alternative zum Direktzeichnen. Vier Regel-Varianten
(`GridRule`, `ModuleRule`, `ConditionalRule`, `PolylineRule`) erzeugen `Wall[]`-Arrays
über einen reinen Auflöser (`resolveParametricWall`). Deckungsbereich: Schweizer 3-m-
Wohnraster, bedingte Außen-/Innenwand-Dicken, 6-m-Jochbauweise. Phase A: Typsystem +
Resolver isoliert, kein UI. Phase B: Command + Formular-Editor. Phase C: Grid-Ressource
und IFC-Export.
### [design/elements.md](design/elements.md)
Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste
Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone-
Algorithmus, Port von `_t_junction_layer_overrides`) — das höchstpriorisierte
gemeinsame Material läuft durch, der Rest mitert an; Priorität sitzt am **Component**
(`joinPriority` als Daten, nicht Hardcode). Deckt zudem gehostete Öffnungen mit
LoD-Stufen (`_OEFF_PIECE_DEFS`), Decken mit Aussparungen, Treppen (gerade/L/Wendel,
geschossübergreifend, normgerechtes 2D-Symbol), Dächer, Tragwerk und **SIA-416-Räume**
(Shoelace-Fläche, Stempel, Färbung über Override-Preset) ab; das `Tool`-Interface +
Snap-Engine ersetzt DOSSIERs Rhino-Command-Aliases.
### [design/plans-output.md](design/plans-output.md)
Der Weg zu **schönen, normgerechten, druckfertigen 2D-Plänen** (Vektor-PDF). Zentrale
Erkenntnis: Ansichten = Kamera + optionaler Schnitt, und es gibt **zwei Plan-Pfade**
(symbolischer Grundriss aus Parametern vs. Schnitt/Ansicht via **HLR im Worker**,
gecacht). Empfiehlt SVG/Paper-Space als Maßstabsmodell — Strichstärke/Schraffur sind
direkt in mm definiert (`dpi = 96·devicePixelRatio`, Hatch-Faktor `sqrt(N)/10`), was
DOSSIERs fragiles Plotweight-Rescaling überflüssig macht. Behandelt außerdem
Ausschnitte/View-Snapshots, Layer-Kombinationen, Kamera-Presets + Norden-Rotation,
Bemaßung sowie Sheets + Vektor-PDF-Export (`svg2pdf.js`/`jsPDF`, `PAPER_MM`).
### [design/resources-graphics.md](design/resources-graphics.md)
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Component-/Hatch-/Line-
Manager, alles per id referenziert), die `resolveStyle`-Kette
(ByLayer → Element-Style → Override) und die **regelbasierte Overrides-Engine**.
Schlüssel-Empfehlung: Overrides als **reine Render-Reads** modellieren (kein
Backup/Restore wie in DOSSIER, da nichts mutiert wird) — inklusive eines
**SIA-416-Presets** statt hartcodierter Färbung. Ergänzt Symbol-Bibliothek,
Rich-Text-Annotationen, den LoD-Resolver (`resolveDetail`) und den Section-Style für
geschnittene Bauteile; eine Tabelle zeigt, was der Browser hier gegenüber DOSSIER
vereinfacht.
---
## UX — Oberfläche, Interaktion & gefühlte Geschwindigkeit
### [research/ux-patterns.md](research/ux-patterns.md)
Untersucht UX-Muster moderner Browser-CAD/BIM-Tools (Arcol, Snaptrude, TestFit,
Onshape, Vectorworks, Figma) und leitet **priorisierte Leitplanken** ab. Empfehlung
für die Grundstruktur: eine feste, Figma-artige **3-Zonen-Shell**
(Navigator/Viewport/Inspector) — explizit gegen Paletten-Wildwuchs —, mit
Vectorworks-Navigation-Tabs für unsere zwei Achsen und einem zwei/drei-spaltigen
Resource-Manager als Vorbild. Größte Differenzierungs-Hebel laut Doku:
**Snapping/Inferencing** im Onshape-Stil (Vertex-Highlights, Achsenlinien, Shift
unterdrückt) und **Grip-Editing über Sicht-Grenzen** (Schnittlinie im Plan ziehen);
dazu perceived-performance-Muster (Skeletons, optimistic UI, 150-ms-Delay-then-show),
eine Command-Palette (Cmd/Ctrl-K) und learn-by-doing-Onboarding am Sample-Projekt.
---
## Swisstopo/SIA — Schweizer Geodaten & Flächenstandards
### [research/swisstopo-sia.md](research/swisstopo-sia.md)
Dokumentiert die **live getesteten** geo.admin.ch-Dienste und die SIA-Flächenlogik.
Überraschendster Befund: **alles ist ohne eigenen Backend-Proxy nutzbar** — alle vier
Hosts senden `access-control-allow-origin: *`, und der Height-Service antwortet
faktisch frei. Schlüssel fürs Browser-Gelände-Mesh ist **swissALTI3D als Cloud-
Optimized GeoTIFF** (Range-Requests via `geotiff.js`, kein Full-Download); die
**Parzelle** kommt direkt als LV95-Polygon + EGRID aus dem Identify-Service. Empfiehlt
einen konkreten Library-Satz (`proj4`, `geotiff`, `3DTilesRendererJS`/`loaders.gl`)
und ordnet die Umsetzung in ROADMAP-Phasen ein (Phase 2 SIA-Räume = reine Logik →
Phase 4a Koordinaten → 4b Gelände/Orthofoto → 4c Nachbargebäude). SIA-Teil:
verifizierte SIA-416-Formeln, DOSSIERs SIA-Logik 1:1 portierbar (Shoelace,
`compute_sia_bilanz`, CSV mit BOM); Origin-Shift (LV95 → 0/0/0) ist Pflicht wegen
float32-Jitter, Caching über IndexedDB.
---
## Top-5 Querschnitts-Empfehlungen für die ROADMAP
Diese fünf Punkte tauchen in mehreren Dokumenten auf und sollten die ROADMAP-Planung
und Priorisierung leiten:
1. **Pure-Ableitungs-Architektur als unverhandelbares Fundament** — ein
semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern.
Trägt ARCHITECTURE.md, beide Plan-/Stil-Designs und die UX-Doku (billiger
Split-View, optimistic Edits, kein Cache-Stale-/Override-Restore-Aufwand). Muss
früh stehen (Store + Undo, Phase 0–1), weil sie alles Spätere prägt.
2. **OCCT/replicad im Web Worker früh als Spike absichern** — der B-Rep-Kernel und
sein `drawProjection`-HLR sind der kritische Pfad für Schnitt/Ansicht (Risiko #4)
*und* für exakte Wand-Booleans (Risiko #1) *und* für IFC. WASM-Größe, HLR-Kosten
(pro Ansicht cachen) und das Worker-Pattern sollten vor Phase 3 mit einer echten
Szene validiert werden.
3. **Component-getriebene Prioritäts-Verschneidung (Backbone-T/X) als zentrales
Geometrie-Risiko** — `joinPriority` als Daten am Component; höchstes gemeinsames
Material läuft durch, Rest mitert. Verbindet elements.md + resources-graphics.md;
2D-Plan rein analytisch, exakte 3D-Booleans im Worker. Stufenweise umsetzen
(Risiko #1, Phase 1).
4. **SVG/Paper-Space-Maßstabsmodell + maßstabskorrekte Schraffuren durchgängig** —
Strichstärke/Text/Hatch in mm, `dpi = 96·devicePixelRatio`, Hatch `sqrt(N)/10`,
SVG-`<pattern>` mit `userSpaceOnUse`. Eliminiert DOSSIERs Plotweight-Rescaling und
speist denselben Serializer für Bildschirm, PDF und DXF (tech-selection +
plans-output + resources-graphics).
5. **Schweiz-Spezifika als Differenzierer ohne Backend-Last** — SIA-416-Bilanz
(reine Logik, Phase 2, ⭐) und der serverlose Swisstopo-Flow (CORS-offen,
COG-Terrain, Parzelle/EGRID, Norden-Rotation, Origin-Shift). Klein im Aufwand,
groß im CH-Marktwert; SIA-Färbung läuft über das Override-Preset, nicht über
Sonderpfade.
+43
View File
@@ -0,0 +1,43 @@
# Backend & Kollaboration — Architekturentscheidung
> Stand: 2026-06-29 · Ziel: komplett self-hosted, kollaborations-offen
## Grundsatz
So lange wie möglich **client-only** bleiben; das Backend additiv einführen, ohne
den Kern umzubauen. Die Pure-Ableitungs-Architektur (ein serialisierbares Modell,
alle Sichten abgeleitet) ist bereits kollaborations-freundlich.
## Phasen
| Phase | Persistenz / Backend |
|---|---|
| **0–3** (Modellierer) | **Client-only**: IndexedDB + Datei-Export/Import (JSON). Offline-fähig (PWA möglich). Kein Server. |
| **5** (Konten/Persistenz) | **Supabase self-hosted** (Docker Compose): Postgres + Auth + Storage. Projekte, Versionen, Dateien (IFC/Pläne/Assets). Row-Level-Security pro Nutzer/Projekt. |
| **6** (Kollaboration) | **Yjs (CRDT)** + **Hocuspocus** Sync-Server (Container), persistiert Snapshots nach Postgres. Presence/Cursors. Optional Supabase-Realtime nur für leichte Broadcasts. |
## Warum Yjs/Hocuspocus statt reinem Supabase-Realtime
Gleichzeitiges Editieren eines strukturierten Dokuments braucht Konfliktauflösung
(CRDT). Yjs ist dafür Standard; Hocuspocus ist der self-hostbare Server dazu und
kann nach Postgres (Supabase) persistieren. Supabase-Realtime allein wäre nur
Pub/Sub ohne Merge-Semantik.
## Was wir JETZT schon richtig machen (damit Collab nicht blockiert)
- Dokumentmodell rein **JSON-serialisierbar**, keine Zyklen, stabile IDs.
- Edits immutable über `setProject` → später leicht auf Yjs-Doc abbildbar
(`Y.Map`/`Y.Array` je Sammlung: drawingLevels, layers, components, walls …).
- Kein Wahrheits-Zustand im Three.js-Scene-Graph oder im DOM — alles ableitbar.
- Ressourcen (Components/Hatches/Lines) als referenzierte Bibliotheken (IDs) →
gut mergebar.
## Self-hosted Stack (Skizze, Phase 5/6)
```
docker-compose:
supabase (postgres, gotrue auth, storage, kong gateway, studio)
hocuspocus (yjs websocket sync, persist -> postgres)
web (vite build, statisch via nginx/caddy)
```
Alles auf eigener Infrastruktur lauffähig; keine externe Cloud nötig.
## Offene Punkte
- Granularität der CRDT-Struktur (pro Sammlung vs. pro Element).
- Datei-Storage (Supabase Storage vs. S3-kompatibel/MinIO im selben Stack).
- Auth-Modell (E-Mail, OIDC/SSO fürs Büro).
+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 P0–P3-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
1–7 fehlende oder falsch dargestellte Kernfunktionen betreffen.
10. **Gauben, L-Grundriss/Kehle, Dachflächenfenster** — grösste Einzel-
Aufwände (8–10+ PT), architektonisch am voraussetzungsreichsten (Boolean-
Werkzeug bzw. neue Geometrie-Engine); erst nach 1–9 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.
+54
View File
@@ -0,0 +1,54 @@
# Kontextmenü & Anzeige-Modi — 1:1 wie DOSSIER
> Quelle: DOSSIER `src/components/ContextMenu.jsx` + `DrawingLevelsApp.jsx`/Ebenen-Panel.
> Maus-Schema (unsere Festlegung): **Mitte = navigieren** (Plan Pan / 3D Orbit, Shift+Mitte Pan) ·
> **Links = Auswahl** · **Rechts = Kontextmenü** · **Rad = Zoom**.
## ContextMenu-Komponente (generisch, wiederverwendbar)
`ContextMenu({ x, y, items, onClose, title })`
- **item**: `{ label, icon?, onClick, disabled?, danger?, shortcut?, divider? }`
- Fixed-Position mit Rand-Clamp (4px); min-width 200px; Radius 13px; weicher Schatten;
Mount-Animation `scale(.94) translateY(-5px)` 100ms; Item-Hover = `--accent-dim`;
`danger` = rote Schrift; `divider` = 1px Trenner; Titel oben (caps, 10px, muted).
- **Schließen:** Klick außerhalb · Escape · erneuter Rechtsklick · nach Item-Klick.
- z-index ~300 (unter Modals).
## Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile)
1. **Ebeneneinstellungen…** (`settings`) — Ebenen-Dialog *(Rhino-spez. → vorerst Stub)*
2. — Trenner —
3. **Sub-Ebene hinzufügen…** (`add`)
4. **Selektion hierher übertragen** (`move_down`) *(braucht Auswahl → später)*
5. — Trenner —
6. **Duplizieren** (`content_copy`) — Klon mit Suffix „ KOPIE"
7. **Eigenschaften kopieren** (`colorize`) — Farbe + Linienstärke
8. **Eigenschaften einfügen** (`format_paint`) — disabled wenn Clipboard leer
9. — Trenner —
10. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
Titel = Ebenenname/-code.
## Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung)
1. **Einstellungen…** (`settings`)
2. — Trenner —
3. **Duplizieren** (`content_copy`) — Klon mit Suffix „ Kopie"
4. — Trenner —
5. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
Titel = Name.
## „+"-Menü (Zeichnungsebenen)
**Geschoss** (`layers`) · **Schnitt / Ansicht** (`content_cut`) · — · **Zeichnung** (`edit_note`)
## Anzeige-Modi (Dropdown oben im Panel) — **5 Modi** (für Ebenen UND Zeichnungsebenen)
| Wert | Label | Verhalten |
|---|---|---|
| `all_force` | **Alle anzeigen** | alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge → wechselt zu „Ausgewählte" |
| `all` | **Ausgewählte** | sichtbar nach per-Zeile-Flag |
| `active` | **Nur aktive** | nur aktive sichtbar; andere stark gedimmt |
| `grey` | **Andere grau** | aktive normal, andere 45% (Sichtbarkeits-Flags gelten) |
| `grey_locked` | **Andere grau & gesperrt** | wie grey + andere gesperrt |
Regel: Klick aufs Auge in `all_force`/`active` schaltet automatisch auf `all`.
*(Hinweis: Panel-System-Workflow hatte vorerst nur 3 Modi — beim Kontextmenü-Build auf diese 5 angleichen.)*
## Migration (was sofort geht / was Stub bleibt)
- Sofort: ContextMenu-Komponente 1:1; Duplizieren, Eigenschaften kopieren/einfügen, Löschen,
+Menü, 5 Anzeige-Modi (alles reine JSON-State-Operationen).
- Stub/später: „Ebeneneinstellungen…"/„Einstellungen…" (Dialog), „Sub-Ebene hinzufügen", „Selektion hierher übertragen".
+110
View File
@@ -0,0 +1,110 @@
# DOSSIER-Feature-Audit (A1–A6, B1–B4, C1–C3, D1–D3, E)
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
Einzeilern verweist. Abgleich DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen
den aktuellen Browser-Port. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
## A — Grösserer Funktionsumfang
**A1 · ❌ Grafische Overrides — Regel-Engine** (`overrides.py:7-40`)
ArchiCAD/Vectorworks-Stil: Regeln der Form `condition{type: layer_name|user_string|
object_name, operator: equals|contains|starts_with|not_equals, value, key} →
actions{color, lineweight, linetype}`, additiv angewendet, oberste Regel gewinnt,
reversibel. Cross-Doc-Presets + Rule-Templates (`list_presets:147`,
`list_rule_templates:218`). Der Port hat nur manuelle Per-Instanz-Farbe (By-Layer/
By-Object/eigener Wert, kein Regelwerk). Wert: enorm für Architektur-Pläne
(Bestand grau, Brandabschnitte, Bauphasen farblich markieren). Aufwand: M-L.
**A2 · ❌ Ausschnitte / View-Snapshots** (`ausschnitte.py:466-531`)
Benannte, gespeicherte Ansichten, die Kamera-Zustand + Layer-Sichtbarkeit +
Massstab + Detailgrad einfangen und wiederherstellen; organisiert in Ordnern +
Presets (`_capture_camera:78`, `_capture_layers:157`, `_capture:466`). Wert:
zentral für Wiederverwendbarkeit und Voraussetzung für A3. Aufwand: L.
**A3 · ❌ Print-Layout-Blätter** (`layouts.py:7-8`)
Layout-Seiten mit mehreren Details, jedes Detail an einen Ausschnitt-Snapshot
gebunden (`_BIND_KEY:28`), Papierformate A4/A3, Ordnerstruktur, PDF-Export pro
Layout-Blatt. Der Port exportiert aktuell nur eine einzelne Zeichnung nach PDF.
Aufwand: L, hängt an A2.
**A4 · ❌ Tragwerk-Elemente** (`elemente.py`)
Stütze, Träger, Unterzug, I-Profil als eigene Elementtypen. Der Port kennt nur
Wand/Decke/Öffnung/Treppe/Raum. Wert: vervollständigt den BIM-Elementsatz.
Aufwand: M je Typ.
**A5 · 🟡 Öffnungen viel reicher** (`elemente.py:59-68`)
Mehrere Flügel (`OEFF_FLUEGEL`), Sims innen+aussen mit eigenen Stilen
(`SIMS_AUS`/`SIMS_IN`), Glas-Toggle (`OEFF_GLAS`), Rahmen-Lage aussen/mittig/innen
(`RAHMEN_POS`), Rahmen-Profilbreite/-tiefe, Detailgrad einfach/standard/detail =
SIA-400-Darstellung (`OEFF_DARSTELLUNG:68`). Der Port hat nur swing/hinge/
frameDepth/frameThickness. Aufwand: M.
**A6 · 🟡 Element-Schedule / Bauteilliste** (`elemente_uebersicht.py:45,214`)
Voller Element-Überblick + SIA-Flächenbilanz + CSV-Export
(`_export_bilanz:214`). Der Port hat nur einen Raum-CSV-Export. Aufwand: M.
## B — Werkzeuge & Objekt-Info
**B1 · ❌ Text-Platzierungs-Werkzeug** (`text_create.py:972,847,105`)
On-Canvas-Text-Objekte als eigenständiges Zeichenwerkzeug (nicht nur
Raumstempel): Textstile, Fonts, Rich-Text fett/kursiv/unterstrichen,
Ausrichtung, Symbol-Einfügung, „auf Selektion anwenden". Der Port hat den
Rich-Text-Editor (`src/text/RichTextEditor.tsx`) bereits, aber kein
Platzierungs-Werkzeug, um damit ein freistehendes Textobjekt zu zeichnen
(Shortcut „1" ist bewusst noch unbelegt). Aufwand: M.
**B2 · 🟡 Object-Info numerisch erweitern** (`dimensions.py:342-350,240,267`)
Position, Rotation um Z-Achse (`_rotate_around_axis:240`), Kreis-Radius,
Linien-Länge (`_set_line_length:267`), Rechteck Breite/Höhe,
Koordinatensystem World/CPlane, 9-Punkt-Referenz. Der Port kann nur per
Anker-Griff skalieren. Aufwand: S-M.
**B3 · ❌ Kamera-Presets + Nordwinkel** (`kamera.py:28,36-45,87`)
Nicht in HANDOVER.md gelistet, aber im Audit gefunden: Kardinal-Presets
(N/O/S/W), Iso-Oktanten, Rotation um einen einstellbaren Nordwinkel
(georeferenziert, relevant für Swisstopo-Kontext).
**B4 · ❌ Massstab-Toolbar-Funktionen** (nicht separat referenziert, Teil der
Toolbar-Logik) — Zoom 1:1/auf Selektion, Linienstärken-Set, Grid/Ortho/
Referenzlinien-Toggles direkt aus der Symbolleiste.
## C — Zusätzlich gefunden, nicht in HANDOVER.md
**C1 · LoD pro Ansicht** — Darstellung einfach/standard/detail je Ansicht
umschaltbar (SIA-400-Detailgrad-Konvention), nicht nur global.
**C2 · Override-Presets + Rule-Templates als Bibliothek** — vertieft A1: die
Regeln selbst sind wiederverwendbare, benannte Presets/Templates, nicht nur
Ad-hoc-Zustand pro Dokument.
**C3 · Mass-Style-Presets** — Dezimalstellen/Rundung als benannte,
wiederverwendbare Bemassungs-Stile.
## D — Weitere Backlog-Ergänzungen aus dem Audit
**D1** Per-Layout-PDF-Export (vertieft A3).
**D2** Bauteil-CSV mit vollem Element-Set (vertieft A6).
**D3** Komponenten-Thumbnails (visuelle Vorschau im Component-Manager).
## E — Bewusst NICHT zu portieren
Rhino-/Windows-gebundene Implementierungsdetails ohne Browser-Äquivalent:
Window-Layout-XML-Persistierung, Auto-DPI via CoreGraphics, Custom-Grips-Code
(Rhino-SDK-spezifisch), nativer `.3dm`-Geometrie-Import.
## Priorisierung (aus dem Original-Audit)
1. A1 (Override-Regel-Engine)
2. A2 (View-Snapshots)
3. A3 (Print-Layout-Blätter)
4. A5 (reichere Öffnungen)
5. B1 (Text-Platzierungs-Werkzeug)
6. A4 (Tragwerk)
7. B2 (Object-Info numerisch)
8. A6 (Bauteil-Schedule)
Siehe auch `ROADMAP.md` §11 für eine noch breitere, unabhängig entstandene
Backlog-Liste (4-Wege-Survey über Bauteile/Darstellung/Pläne/Kontext) mit
teilweiser Überschneidung.
+760
View File
@@ -0,0 +1,760 @@
# Aktive Zeichen- und Bearbeitungs-Werkzeuge
Status: Entwurf. Dieses Dokument spezifiziert das **Tool-System** für das aktive
Erzeugen von Modell-Elementen durch Zeichnen im Grundriss: Wände (Achs-Polylinie
→ `Wall` eines `WallType`) sowie reine 2D-Geometrie (Linie, Polylinie, Rechteck,
Kreis, Bogen, Text). Es definiert die Werkzeug-Zustandsmaschine, die Live-Vorschau
(Rubber-Band), das **Snapping** mit Bildschirm-Markern, die Ebenen-/Kategorie-/
Stil-Zuordnung neuer Elemente und das neue Element `Drawing2D` samt Ableitung in
`generatePlan`.
Bezugsdokumente: [elements.md](elements.md) (Wand-/Tür-Modell),
[resources-graphics.md](resources-graphics.md) (Stil-Auflösung),
[plans-output.md](plans-output.md) (Papier-Maßstab, mm-Strichstärken),
[context-menu.md](context-menu.md) (Maus-Schema).
## 0. Architektur-Prinzip (Bezug zum Repo)
Die App folgt der Regel **ein semantisches Modell ist die einzige Wahrheit; jede
Ansicht ist abgeleitet** (CONVENTIONS.md, `App.tsx`). Werkzeuge greifen darum NUR über
`setProject` immutabel auf das `Project`-Modell zu; sie schreiben NIE Geometrie
direkt in den Plan. Der `PlanView` bleibt eine reine Darstellungs-/Eingabe-
Schicht. Das Tool-System setzt genau an der bestehenden Naht in `PlanView` an:
- **Modell↔Screen.** `PlanView` rechnet bereits Cursor-Pixel → viewBox-Einheiten
(`clientToView`) → Modell-Meter (`viewToModel`). Diese Umrechnung ist die
Grundlage; Werkzeuge arbeiten ausschließlich in **Modell-Metern** (CONVENTIONS.md:
intern alles in Metern). Für Snap-Marker brauchen Werkzeuge zusätzlich die
Rückrichtung Modell → viewBox (`toScreen`, existiert bereits) bzw. Modell →
Client-Pixel.
- **Pointer-Handling.** `PlanView` besitzt heute drei Gesten an der linken Taste/
Mitte/rechts: Auswahl/Marquee, Pan, Kontextmenü. Das Tool-System schiebt sich
VOR diese Logik: ist ein aktives Zeichenwerkzeug gewählt (≠ `select`), übernimmt
das Werkzeug `pointerdown/move/up`; das `select`-Werkzeug delegiert an die heute
schon vorhandene Auswahl-/Marquee-Logik (kein Verhaltensbruch).
- **Pan/Zoom bleiben immer aktiv.** Mittlere Maustaste (Pan) und Mausrad (Zoom)
laufen unverändert weiter, auch während ein Zeichenwerkzeug aktiv ist — sonst
kann man beim Zeichnen nicht navigieren.
## 1. Datenfluss-Überblick
```
TopBar (Werkzeugleiste) --activeTool--> App-State
│
┌──── activeTool, wallTypeId, defaultCategoryCode ────┐
▼ ▼
PlanView ── pointerdown/move/up (Modellpunkt) ──> ToolController
▲ │
Snap-Marker + Rubber-Band-Overlay <── DraftState (Vorschau) ──┘
│ │
└──────────────── commit ──> onToolCommit(Element) ──> setProject
```
`activeTool` und die Werkzeug-Parameter (aktiver `WallType`, Default-Kategorie)
liegen als **View-State** in `App.tsx` — wie `viewType`, `detail`, `selectedWallIds`
bereits dort liegen. Der `ToolController` ist **frameworkfrei** (reines TS, kein
React-State pro Mausbewegung — analog zu `drag`/`marquee` als `useRef` in
`PlanView`), damit die Live-Vorschau ohne Re-Render des ganzen Baums läuft. Nur
beim **Commit** wird `setProject` (Re-Render) ausgelöst.
## 2. Koordinaten & Hilfsfunktionen
`PlanView` exportiert künftig zwei reine Konverter (heute intern vorhanden),
plus die effektive Pixel-pro-Meter-Skala für die Snap-Toleranz:
```ts
// PlanView-intern bereits da; wird als stabile Callbacks nach außen gereicht.
type ToModel = (clientX: number, clientY: number) => Vec2; // Pixel → Meter
type ToClient = (m: Vec2) => { x: number; y: number }; // Meter → Pixel
type PxPerMeter = () => number; // aktuelle meet-Skala * PX_PER_M (Snap-Toleranz)
```
`PxPerMeter` ergibt sich aus `meetScale(view) * PX_PER_M` (beides in `PlanView`
vorhanden). Snap-Toleranzen werden in **Bildschirm-Pixeln** definiert (z. B. 10 px)
und über `pxPerMeter` in Meter umgerechnet — so ist der Fangradius zoom-unabhängig
konstant am Bildschirm.
## 3. Tool-System
### 3.1 Werkzeug-Identität und Registry
```ts
export type ToolId =
| "select" // Default: Auswahl/Marquee (heutiges Verhalten)
| "wall" // Wand-Achs-Polylinie → Wall je Segment
| "line" // einzelne 2D-Strecke
| "polyline" // offene 2D-Polylinie
| "rect" // 2D-Rechteck (zwei Ecken)
| "circle" // 2D-Kreis (Zentrum + Radius)
| "arc" // 2D-Bogen (3-Punkt oder Zentrum-Start-Ende)
| "text"; // 2D-Textmarke
/** Live-Kontext, den ein Werkzeug bei jedem Schritt erhält. */
export interface ToolContext {
project: Project;
/** Aktives Geschoss/Zeichnungsebene (Ziel der neuen Elemente). */
level: DrawingLevel;
/** Default-Kategorie-Code für neue Elemente (siehe §6). */
defaultCategoryCode: string;
/** Aktiver Wandtyp für das Wand-Werkzeug. */
activeWallTypeId: string;
/** Aktiver Linienstil-Code für 2D-Primitive (Line Manager). */
activeLineStyleId: string;
/** Snapping-Einstellungen (an/aus je Typ, ortho, grid). */
snap: SnapSettings;
/** Pixel pro Meter (für Snap-Toleranz in Metern). */
pxPerMeter: number;
}
/** Ein an einer Modellposition ausgelöstes Pointer-Ereignis. */
export interface ToolPointer {
/** Roher Modellpunkt (vor Snapping), in Metern. */
raw: Vec2;
/** Gesnappter Punkt + Marker-Info (siehe §5). null = kein Snap. */
snap: SnapResult | null;
/** Effektiver Punkt = snap?.point ?? raw. */
point: Vec2;
/** Modifikatoren (Shift = Ortho erzwingen, Ctrl = Snap aus, Alt = …). */
shift: boolean;
ctrl: boolean;
alt: boolean;
button: number; // 0 links, 2 rechts
}
/** Was ein Werkzeug-Schritt nach außen meldet. */
export interface ToolResult {
/** Neuer Vorschau-Zustand (Rubber-Band-Geometrie); null = nichts zu zeigen. */
draft: ToolDraft | null;
/** Bei Abschluss: Mutation, die App über setProject anwendet. */
commit?: (p: Project) => Project;
/** true → Werkzeug ist fertig und kehrt in seinen Ruhezustand zurück. */
done?: boolean;
}
/** Die Werkzeug-Schnittstelle (reine Funktionen über einen internen State). */
export interface Tool {
id: ToolId;
/** UI-Label-Key (i18n), z. B. "tool.wall". */
labelKey: string;
/** Material-Symbol-Name für die Werkzeugleiste. */
icon: string;
/** Statuszeilen-Hinweis-Key je Phase (z. B. "tool.wall.firstPoint"). */
hintKey: (state: ToolState) => string;
/** Initialer Ruhezustand. */
init(): ToolState;
/** Klick/Tap (pointerdown→up ohne Drag, bzw. „setze Punkt"). */
onClick(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
/** Bewegung (Hover/Drag): nur Vorschau, nie Commit. */
onMove(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
/** Doppelklick/Enter: mehrteilige Werkzeuge abschließen (z. B. Polylinie). */
onCommitGesture(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
/** Esc: aktuellen Entwurf verwerfen, zurück in den Ruhezustand. */
onCancel(state: ToolState): [ToolState, ToolResult];
/** Backspace: letzten gesetzten Punkt zurücknehmen (mehrteilig). */
onUndoPoint?(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
}
```
`ToolState` ist je Werkzeug ein Discriminated Union (Beispiel Wand in §4). Der
`ToolController` hält genau eine aktive `Tool`-Instanz + deren `ToolState` in
einem `useRef` und ist die einzige Stelle, die diese Methoden aufruft.
### 3.2 Vorschau-Geometrie (Rubber-Band)
```ts
/** Darstellbare Vorschau — dieselben Primitive wie der Plan, plus Marker. */
export interface ToolDraft {
/** Vorschau-Primitive (gestrichelt/halbtransparent gezeichnet). */
preview: Primitive[];
/** Bereits gesetzte „feste" Stützpunkte (kleine Quadrate). */
vertices: Vec2[];
/** Optionaler Maß-/Winkel-Text am Cursor (z. B. "3.20 m, 90°"). */
hud?: { at: Vec2; text: string };
}
```
Wichtig: Die Vorschau benutzt **dieselben `Primitive`-Typen** wie `generatePlan`
(`polygon | line | arc`). Damit kann der Vorschau-Layer mit derselben
`PrimitiveShape`-Renderlogik gezeichnet werden (DRY) — nur mit einer
Vorschau-CSS-Klasse (gestrichelt, Akzentfarbe). Für die Wand-Vorschau kann das
Werkzeug sogar `generatePlan` auf einem **temporären Projekt** (Original + die in
Bau befindliche Wand) aufrufen, um echte gehrte Poché live zu zeigen; in der
ersten Phase reicht eine einfache Bandvorschau (`wallCorners`).
### 3.3 Zustandsmaschine (allgemein)
Jedes Werkzeug ist eine kleine Maschine über `pointerdown → move → up`. Da
`PlanView` Pointer-Capture nutzt, kommen `move`/`up` zuverlässig an. Generisches
Muster:
```
ruht ──pointerdown──> (Werkzeug setzt 1. Punkt / startet Drag)
▲ │
│ ├──move──> Vorschau (rubber-band), kein Commit
│ │
│ (mehrteilig) pointerdown──> Punkt anhängen, Vorschau weiter
│ │
└──Esc/Cancel─────────────┤
▼
Doppelklick/Enter/letzter Punkt ──> commit(project) ──> ruht
```
- **Klick-vs-Drag.** Wie heute in `PlanView` (`MARQUEE_THRESHOLD_PX`): unter der
Schwelle ist es ein „Punkt setzen" (Klick), darüber ein Drag. Rechteck/Kreis/
Linie unterstützen BEIDE Bedienarten: Zwei-Klick (Punkt, Punkt) ODER Drücken-
Ziehen-Loslassen. Polyline/Wall sind reine Klickfolgen mit Abschluss per
Doppelklick/Enter.
- **Esc** verwirft den Entwurf (`onCancel`) und bleibt im selben Werkzeug.
Zweites Esc (im Ruhezustand) schaltet zurück auf `select`.
- **Rechtsklick** während eines aktiven Entwurfs = „abschließen/abbrechen"
(CAD-üblich), KEIN Kontextmenü; im Ruhezustand öffnet Rechtsklick wie bisher
das Plan-Kontextmenü.
### 3.4 Einbettung in PlanView (Pointer-Routing)
`PlanView` bekommt zwei neue Props:
```ts
interface PlanViewProps {
// … bisherige Props …
/** Aktives Werkzeug; "select" = bisheriges Verhalten. */
activeTool?: ToolId;
/**
* Werkzeug-Treiber. PlanView ruft diese Callbacks mit fertig gesnappten
* Modellpunkten auf und rendert den zurückgegebenen Draft als Overlay.
*/
toolHandlers?: {
onToolDown(p: ToolPointer): void;
onToolMove(p: ToolPointer): void;
onToolUp(p: ToolPointer): void;
onToolDoubleClick(): void;
/** liefert die zu zeichnende Vorschau (von App/Controller gehalten). */
draft: ToolDraft | null;
};
}
```
Routing in `onPointerDown` (Ergänzung der bestehenden Methode):
```
onPointerDown(e):
if e.button === 1: → bestehender Pan (unverändert)
if e.button === 0:
if activeTool === "select": → bestehende Auswahl-/Marquee-Geste
else:
setPointerCapture
p = makeToolPointer(e) // raw → snap → point (§5)
toolHandlers.onToolDown(p)
if e.button === 2 (rechts):
if activeTool !== "select" && entwurf aktiv: toolHandlers.onToolUp({button:2,…}) // abschließen
else: bestehendes Kontextmenü
```
`onPointerMove`/`onPointerUp` analog: bei aktivem Zeichenwerkzeug an
`onToolMove`/`onToolUp` routen statt an Pan/Marquee. Der Cursor wird auf
`crosshair` gesetzt. `makeToolPointer` führt das Snapping aus (§5) und liefert den
fertigen `ToolPointer`.
Die **Snap-Marker** und der **Draft** werden als zusätzliche SVG-Gruppe NACH den
Plan-Primitiven, aber vor der Auswahl-Hervorhebung gerendert (immer obenauf,
`pointerEvents="none"`). Marker werden in viewBox-Einheiten über `toScreen`
positioniert (existiert bereits).
## 4. Werkzeug: Wand (Wall)
Das Wand-Werkzeug zeichnet eine **Achs-Polylinie**; jedes Segment wird zu einem
eigenständigen `Wall`-Element des aktiven `WallType` auf dem aktiven Geschoss.
Aufeinanderfolgende Segmente teilen sich einen Knoten → die bestehende
`computeJoins`-Verschneidung (in `generatePlan`) erzeugt automatisch saubere
Gehrungen an den Ecken. Kein zusätzlicher Join-Code nötig.
### 4.1 Zustand
```ts
type WallToolState =
| { phase: "idle" }
| {
phase: "drawing";
/** Bisher gesetzte Achs-Knoten (in Metern). */
points: Vec2[];
/** Aktuelle Cursor-Position (gesnappt) für die Rubber-Band-Vorschau. */
cursor: Vec2 | null;
};
```
### 4.2 Pseudocode
```
WallTool.onClick(state, p, ctx):
if state.phase === "idle":
return [{phase:"drawing", points:[p.point], cursor:p.point}, {draft: draftFor([p.point], p.point, ctx)}]
else: // weiteren Knoten anhängen
pts = [...state.points, p.point]
# Ortho/Snap haben p.point bereits ausgerichtet (§5).
return [{phase:"drawing", points: pts, cursor: p.point}, {draft: draftFor(pts, p.point, ctx)}]
WallTool.onMove(state, p, ctx):
if state.phase !== "drawing": return [state, {draft:null}]
return [{...state, cursor:p.point}, {draft: draftFor(state.points, p.point, ctx)}]
WallTool.onCommitGesture(state, ctx): // Doppelklick / Enter / Rechtsklick
if state.phase !== "drawing" || state.points.length < 2:
return [{phase:"idle"}, {draft:null, done:true}]
pts = state.points
return [{phase:"idle"}, {
draft: null, done: true,
commit: (proj) => appendWalls(proj, pts, ctx)
}]
WallTool.onCancel(state):
return [{phase:"idle"}, {draft:null, done:true}]
WallTool.onUndoPoint(state):
if state.phase==="drawing" && state.points.length>1:
return [{...state, points: state.points.slice(0,-1)}, {draft: …}]
return [{phase:"idle"}, {draft:null}]
```
`draftFor` baut die Vorschau: feste Segmente zwischen `points` + ein „lebendes"
Segment `points[last] → cursor`. Pro Segment werden die vier Band-Eckpunkte über
`wallCorners(a, b, thickness)` (vorhanden) berechnet und als Vorschau-`polygon`
gezeichnet; zusätzlich ein HUD mit Länge `|b−a|` und Winkel. `thickness =
wallTypeThickness(getWallType(...))`.
### 4.3 Commit ins Modell
```
appendWalls(project, pts, ctx):
newWalls = []
for i in 0 .. pts.length-2:
a = pts[i]; b = pts[i+1]
if |b-a| < EPS: continue // Null-Segmente überspringen
newWalls.push({
id: uniqueId("W"), // siehe §8 (ID-Vergabe)
type: "wall",
floorId: ctx.level.id, // aktives Geschoss
categoryCode: ctx.defaultCategoryCode, // §6
start: a, end: b,
wallTypeId: ctx.activeWallTypeId,
height: ctx.level.floorHeight ?? 2.6, // Geschosshöhe als Default
})
return { ...project, walls: [...project.walls, ...newWalls] }
```
Hinweise:
- **Höhe** erbt die lichte Geschosshöhe (`DrawingLevel.floorHeight`), Fallback 2.6 m.
- **Geschossbindung**: Das Wand-Werkzeug ist nur aktiv, wenn `level.kind === "floor"`
(sonst gibt es keine Wände). In `drawing`-Ebenen ist das Wand-Werkzeug
deaktiviert (nur 2D-Werkzeuge); siehe §6.
- Die Wicklung wird NICHT erzwungen — `leftNormal`-Konvention (CONVENTIONS.md) und
`computeJoins` arbeiten richtungsunabhängig pro Segment.
## 5. Snapping
Snapping läuft in `makeToolPointer` (PlanView) BEVOR der Punkt an das Werkzeug
geht. Es prüft mehrere Snap-Quellen, wählt die nächstgelegene innerhalb der
Toleranz und liefert sowohl den gefangenen Punkt als auch eine **Marker-Art** für
die Bildschirmdarstellung.
### 5.1 Typen
```ts
export type SnapKind =
| "endpoint" // Wand-Achsenende, Polylinien-Knoten, Primitiv-Endpunkt
| "midpoint" // Mitte einer Strecke/Wandachse
| "intersection" // Schnittpunkt zweier Achsen/Linien
| "center" // Kreis-/Bogenzentrum
| "quadrant" // Kreis-Quadrantenpunkte (0/90/180/270°)
| "onEdge" // nächster Punkt AUF einer Wandachse/Linie (Lot)
| "grid" // Rasterpunkt
| "ortho" // orthogonal/winkelrastriert zum vorigen Punkt
| "extension"; // Verlängerung einer Achse (gestrichelte Hilfslinie)
export interface SnapResult {
point: Vec2; // gefangener Punkt (Meter)
kind: SnapKind;
/** Quell-Element (für Marker/Hilfslinien), optional. */
refA?: Vec2;
refB?: Vec2;
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
distPx: number;
}
export interface SnapSettings {
enabled: boolean; // Master-Schalter (Ctrl invertiert temporär)
endpoint: boolean;
midpoint: boolean;
intersection: boolean;
center: boolean;
onEdge: boolean;
grid: boolean;
gridSize: number; // Rasterweite in Metern, z. B. 0.10
ortho: boolean; // Shift erzwingt zusätzlich
angleStep: number; // Winkelraster in Grad (z. B. 45)
tolerancePx: number; // Fangradius am Bildschirm, z. B. 10
}
```
### 5.2 Snap-Kandidaten sammeln
Quellen pro Geschoss (gefiltert auf sichtbare Kategorien, wie der Plan):
| Snap | Quelle |
|------|--------|
| endpoint | `wall.start`, `wall.end` aller sichtbaren Wände; Knoten bereits gesetzter Draft-Punkte; `Drawing2D`-Vertices |
| midpoint | Mitte jeder Wandachse und jedes 2D-Segments |
| intersection | paarweise `lineIntersect` der Wandachsen (nur Paare, deren Boxen sich am Cursor nähern) |
| center/quadrant | Kreise/Bögen aus `Drawing2D` |
| onEdge | Lotfußpunkt des Cursors auf jede nahe Wandachse/2D-Linie |
| grid | Rundung des Cursors auf `gridSize` |
| ortho | Ausrichtung relativ zum letzten Draft-Punkt (§5.4) |
Performance: Kandidaten werden je `move` neu erzeugt, aber **früh nach
Bildschirm-Distanz gefiltert** (nur Punkte innerhalb ~`2·tolerancePx`). Bei
großen Modellen kann eine grobe Bounding-Box-Vorauswahl je Wand vorgeschaltet
werden; in den ersten Phasen genügt lineares Scannen (Wandzahl ist klein).
### 5.3 Auswahl-Pseudocode
```
computeSnap(rawModel, ctx, draftPoints, lastPoint):
if ctrl(): return null # Snap temporär aus
s = ctx.snap
tolM = s.tolerancePx / ctx.pxPerMeter # px-Toleranz → Meter
cands: SnapResult[] = []
if s.endpoint: cands += endpoints(...) filtered to within tolM
if s.midpoint: cands += midpoints(...)
if s.intersection: cands += intersections(...)
if s.center: cands += centers/quadrants(...)
if s.onEdge: cands += perpendicularFeet(...) # niedrigere Priorität
# Punkt-Snaps haben Vorrang vor Linien-/Raster-Snaps:
pick = argmin(cands, by distPx within tolM, tie-break by priority)
if pick: rawModel = pick.point
# Ortho/Winkelraster wirkt RELATIV zum letzten Punkt und ÜBERLAGERT:
if (s.ortho || shift()) && lastPoint:
rawModel = applyAngleConstraint(lastPoint, rawModel, s.angleStep)
# Wenn dabei auch ein Punkt-Snap nahe der Ortho-Linie liegt → bevorzugen.
if !pick && s.grid:
g = snapToGrid(rawModel, s.gridSize)
if dist(g, rawModel) within tolM: return {point:g, kind:"grid", …}
return pick ?? null
```
Prioritätsreihenfolge bei gleichem Abstand: `endpoint > intersection > midpoint >
center/quadrant > onEdge > grid`. Ortho/Winkelraster ist eine **Projektion**, kein
Punkt-Kandidat: es verschiebt den (ggf. schon gesnappten) Punkt auf die nächste
erlaubte Richtung vom letzten Knoten.
### 5.4 Ortho / Winkelraster
```
applyAngleConstraint(from, to, stepDeg):
d = to - from
ang = atan2(d.y, d.x)
k = round(ang / rad(stepDeg)) * rad(stepDeg)
len = |d|
return from + (cos(k), sin(k)) * len
```
Mit `stepDeg = 90` ist das klassisches Ortho (H/V); `45` erlaubt Diagonalen.
`Shift` erzwingt Ortho temporär unabhängig von der Einstellung.
### 5.5 Bildschirm-Marker
Pro aktivem Snap zeichnet `PlanView` ein Marker-Glyph an `toScreen(snap.point)`
(`pointerEvents="none"`, eigene CSS-Klassen, papierkonstante Größe via
non-scaling):
- `endpoint` → kleines Quadrat ▫
- `midpoint` → Dreieck �△
- `intersection` → ✕
- `center` → ○, `quadrant` → ◇
- `onEdge` → ⟂-Glyph
- `grid` → feiner Punkt
- `ortho`/`extension` → zusätzlich eine **gestrichelte Hilfslinie** von `refA`
(Bezugspunkt) zum Cursor
Marker erscheinen NUR während ein Zeichenwerkzeug aktiv ist. i18n-Tooltips/Status
(„Endpunkt", „Mittelpunkt", …) über `t('snap.endpoint')` etc.
## 6. Ebene, Kategorie und Stil neuer Elemente
Neue Elemente brauchen eine **Zeichnungsebene** (DrawingLevel) und eine
**Kategorie** (LayerCategory `code`) sowie — bei 2D-Primitiven — einen Stift/
Schraffur-Stil.
### 6.1 Zeichnungsebene (Ziel)
- Ziel ist **immer das aktive Geschoss/die aktive Zeichnungsebene** (`activeLevelId`
in `App.tsx`). Wände nur auf `kind === "floor"`. 2D-Primitive (`Drawing2D`) auf
jeder Ebene, also auch auf `kind === "drawing"` (freie 2D-Zeichnung).
### 6.2 Kategorie (categoryCode)
- Es gibt eine **aktive Kategorie** als View-State (`activeCategoryCode` in App,
neu). Default beim Start: der Code der gewählten Wand-Kategorie (im Sample „20"
Wände), bzw. die erste sichtbare Kategorie. Die Statusleiste zeigt heute schon
die „aktive Ebene" (`activeLayerName`); diese wird künftig von `activeCategoryCode`
gespeist statt nur aus der Auswahl abgeleitet.
- Neue Wände: `categoryCode = activeCategoryCode` (z. B. „20").
- Neue 2D-Primitive: ebenfalls `activeCategoryCode`. Sinnvoll ist eine eigene
2D-/Hilfslinien-Kategorie (z. B. „90 Zeichnung"); diese wird über die
Kategorie-Auswahl in der Statusleiste/Werkzeugleiste gesetzt.
- Die Kategorie liefert Farbe + Strichstärke (`LayerCategory.color`, `.lw`), genau
wie `generatePlan` es heute für Wände via `categoryLwMap` nutzt.
### 6.3 Stift/Schraffur
- **Wände** erhalten KEINEN eigenen Stift — ihr Erscheinungsbild kommt aus dem
`WallType` (Component → Hatch → LineStyle) und der Kategorie-`lw` (bestehender
Pfad in `generatePlan`).
- **2D-Primitive** referenzieren optional einen `LineStyle` aus dem Line Manager
(`activeLineStyleId`). Ohne expliziten Stil erben sie Farbe/Strichstärke aus der
Kategorie (`color`, `lw`). Flächige 2D-Primitive (geschlossenes Rechteck/Kreis/
Polyline) können optional eine Schraffur (`hatchId`) tragen.
## 7. Speicherung der 2D-Primitive: `Drawing2D`
2D-Geometrie wird als neues Modell-Element `Drawing2D` gespeichert — analog zu
`Wall`/`Door` ein semantisches Element, das beim Rendern abgeleitet wird (KEINE
vorab erzeugten Primitive im Modell). Damit bleibt die Architektur „Modell →
abgeleitete Ansicht" intakt.
### 7.1 Typ
```ts
/** Geometrie-Form eines 2D-Zeichenelements. */
export type Drawing2DGeom =
| { shape: "line"; a: Vec2; b: Vec2 }
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
| { shape: "rect"; min: Vec2; max: Vec2 } // achsparallel
| { shape: "circle"; center: Vec2; r: number }
| {
shape: "arc";
center: Vec2;
r: number;
/** Start-/Endwinkel in Radiant (math. Konvention, CCW positiv). */
a0: number;
a1: number;
}
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
export interface Drawing2D {
id: string;
type: "drawing2d";
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
levelId: string;
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
categoryCode: string;
geom: Drawing2DGeom;
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
lineStyleId?: string;
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
hatchId?: string;
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
color?: string;
}
```
Ergänzung am `Project`:
```ts
export interface Project {
// … bisher …
drawings2d: Drawing2D[]; // NEU
}
export type Element = Wall | Door | Drawing2D; // erweitert
```
`sampleProject` bekommt ein leeres `drawings2d: []`. Lösch-/Referenz-Regeln:
beim Löschen einer Zeichnungsebene werden auch deren `Drawing2D` entfernt (analog
zur bestehenden Wand-/Tür-Bereinigung in `deleteLevel`).
### 7.2 Ableitung in `generatePlan`
`generatePlan` rendert künftig zusätzlich die `Drawing2D` des Geschosses (gefiltert
wie Wände auf sichtbare Kategorien + `categoryDisplay`). Neue Funktion
`addDrawing2D(out, project, d, greyed, lwMm)`:
```
addDrawing2D(out, project, d):
color = d.color ?? categoryColor(d.categoryCode)
weight = lineStyle(d.lineStyleId)?.weight ?? categoryLw(d.categoryCode)
dash = lineStyle(d.lineStyleId)?.dash ?? null
switch d.geom.shape:
"line": out.push({kind:"line", a, b, cls:"draw2d", weightMm:weight, dash})
"polyline": for each segment → line-Primitive (closed → Schluss-Segment)
"rect": vier Kanten als line-Primitive (oder polygon, falls hatchId)
"circle": → als zwei 180°-Bögen (arc-Primitive) ODER neues Primitiv (s. u.)
"arc": → arc-Primitive (center/from/to/r aus a0,a1)
"text": → neues text-Primitiv (s. u.)
```
Dabei wird, wo möglich, der **vorhandene** `Primitive`-Vorrat (`line`, `arc`,
`polygon`) wiederverwendet — die Strichstärke kommt in mm Papier (wie der Rest des
Plans), Farbe über eine CSS-Klasse oder ein neues optionales `color`-Feld am
`line`-Primitive.
Zwei `Primitive`-Erweiterungen sind nötig:
```ts
// kreisförmige Vollkurve (Kreis) — sonst muss man sie in zwei Bögen zerlegen:
| { kind: "circle"; center: Vec2; r: number; cls: string; weightMm: number;
dash?: number[] | null; fill?: string; greyed?: boolean }
// Textmarke:
| { kind: "text"; at: Vec2; text: string; heightMm: number; angle: number;
cls: string; color?: string; greyed?: boolean }
```
`PlanView.renderPrimitive` bekommt entsprechende `case`-Zweige (`<circle>`,
`<text>`). Text wird in **Papier-Millimetern** dimensioniert (Höhe → `mmToPx`,
non-scaling), damit die Schrifthöhe beim Zoomen papierkonstant bleibt (analog zu
Strichstärken in `plans-output.md`).
Das `arc`-Primitiv zeichnet heute nur Kurzbögen (≤180°, `large-arc=0`). Für
beliebige 2D-Bögen wird es um ein `largeArc`-Flag erweitert (aus `|a1−a0|`
berechnet); abwärtskompatibel (Default 0).
## 8. ID-Vergabe & Immutabilität
- Neue IDs über einen kleinen Helfer `uniqueId(prefix)` (z. B.
`\`${prefix}-${Date.now()}-${counter++}\``), konsistent mit der bestehenden
Praxis in `App.tsx` (`floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix
„dr2d".
- Alle Mutationen laufen über `setProject` immutabel (CONVENTIONS.md / App-Konvention).
Der `commit(project)` eines Werkzeugs ist eine reine Funktion `Project →
Project`; App ruft `setProject(prev => result.commit(prev))`.
## 9. App- und PlanView-Verdrahtung (konkret)
Neuer View-State in `App.tsx`:
```ts
const [activeTool, setActiveTool] = useState<ToolId>("select");
const [activeCategoryCode, setActiveCategoryCode] = useState<string>(/* erste Wand-Kat */);
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(project.wallTypes[0].id);
const [activeLineStyleId, setActiveLineStyleId] = useState<string>(project.lineStyles[0].id);
const [snap, setSnap] = useState<SnapSettings>(DEFAULT_SNAP);
const toolStateRef = useRef<ToolState>(getTool(activeTool).init());
const [draft, setDraft] = useState<ToolDraft | null>(null);
```
Der `ToolController` ist eine kleine Hook/Klasse, die `toolStateRef` hält und die
`PlanView.toolHandlers` implementiert:
```
onToolDown(p): [st, res] = tool.onClick(toolStateRef.current, p, ctx)
toolStateRef.current = st; setDraft(res.draft)
if res.commit: setProject(res.commit)
if res.done: toolStateRef.current = tool.init()
onToolMove(p): [st, res] = tool.onMove(...); toolStateRef.current=st; setDraft(res.draft)
onToolDoubleClick(): [st,res]=tool.onCommitGesture(...); apply commit/done; setDraft(null)
```
Keyboard (global, nur wenn ein Zeichenwerkzeug aktiv ist):
`Esc → onCancel`, `Enter → onCommitGesture`, `Backspace → onUndoPoint`. Beim
Wechsel von `activeLevelId`/`viewType` wird der laufende Entwurf verworfen (analog
zur bestehenden Auswahl-Bereinigung in den `useEffect`s).
`ctx` (ToolContext) wird in App via `useMemo` aus Project + aktiven Selektionen
gebaut und an PlanView/Controller gereicht.
### 9.1 Werkzeugleiste (TopBar)
Eine neue Werkzeug-Gruppe in der `TopBar` (links, vor den Ansichts-Toggles), als
i18n-beschriftete Icon-Buttons (`t('tool.select')`, `t('tool.wall')`, …). Aktiv-
Zustand hervorgehoben. Daneben: Auswahl des aktiven `WallType` (für Wand) und der
aktiven Kategorie/des Linienstils (Dropdowns), sowie Snap-Toggles (kleines
Snap-Menü mit Checkboxen je `SnapKind`, Grid-Größe, Winkelraster). Wand-/2D-
Werkzeuge werden je nach `level.kind` aktiviert/deaktiviert (Tooltip nennt den
Grund — wie die bestehenden disabled-Menüpunkte in `App.tsx`).
### 9.2 i18n-Keys (neu, Auszug)
```
tool.select / tool.wall / tool.line / tool.polyline / tool.rect /
tool.circle / tool.arc / tool.text
tool.wall.firstPoint / tool.wall.nextPoint / tool.wall.finish
snap.endpoint / snap.midpoint / snap.intersection / snap.center /
snap.quadrant / snap.onEdge / snap.grid / snap.ortho
snap.settings / snap.gridSize / snap.angleStep
status.activeWallType / status.activeCategory / status.activeTool
```
Alle sichtbaren Strings über `t(...)` (CONVENTIONS.md). Identifier bleiben englisch.
## 10. Übrige Werkzeuge (Kurzspezifikation)
| Werkzeug | Eingabe | Zustand | Commit |
|----------|---------|---------|--------|
| **Line** | 2 Punkte (Klick-Klick oder Drag) | `{a?}` | `Drawing2D{shape:"line"}` |
| **Polyline** | n Punkte, Abschluss Doppelklick/Enter; `closed` per „C" oder Klick auf Start | `{pts}` | `Drawing2D{shape:"polyline"}` |
| **Rectangle** | 2 Ecken (Drag oder Klick-Klick) | `{p0?}` | `Drawing2D{shape:"rect"}` (min/max sortiert) |
| **Circle** | Zentrum + Radius-Punkt | `{center?}` | `Drawing2D{shape:"circle"}` |
| **Arc** | 3 Punkte (Start, durch, Ende) ODER Zentrum-Start-Ende (Modus-Toggle) | `{p0?,p1?}` | `Drawing2D{shape:"arc"}` (a0/a1 aus Punkten) |
| **Text** | 1 Punkt → Inline-Eingabefeld (wie `InlineEditor` in App) | `{at?}` | `Drawing2D{shape:"text"}` |
Alle nutzen dasselbe `Tool`-Interface, dasselbe Snapping und denselben Draft-/
Commit-Pfad. Text öffnet beim Setzen des Ankerpunkts ein kleines Overlay-Eingabe-
feld (an `toClient(at)` positioniert) und committet bei Enter/Blur.
3-Punkt-Bogen → Zentrum: Umkreismittelpunkt der drei Punkte (Schnitt der
Mittelsenkrechten via `lineIntersect`), `r`, `a0/a1` aus Start-/Endwinkel; Drehsinn
aus dem mittleren Punkt.
## 11. Phasenplan
**Phase 1 — Gerüst + Select + Wall + Line (MVP).**
1. `ToolId`, `Tool`, `ToolContext`, `ToolPointer`, `ToolDraft`, `ToolResult`,
`SnapResult`, `SnapSettings`, `Drawing2D`(+`Project.drawings2d`) als Typen.
2. `PlanView`: `toScreen`/`viewToModel`/`pxPerMeter` als Callbacks nach außen;
Pointer-Routing für `activeTool !== "select"`; Draft-/Marker-Overlay-Rendering;
Crosshair-Cursor.
3. `ToolController` + App-State (`activeTool`, `activeCategoryCode`,
`activeWallTypeId`, `snap`) + Keyboard (Esc/Enter/Backspace).
4. **WallTool** voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD,
Commit via `appendWalls`). Verschneidung kommt automatisch aus `computeJoins`.
5. **LineTool** als erstes 2D-Werkzeug; `generatePlan.addDrawing2D` für `line`;
`Drawing2D`-Löschung beim Geschoss-Löschen.
6. **Snapping Stufe 1**: endpoint + grid + ortho (Shift), mit Bildschirm-Markern.
7. TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl;
i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie.
8. Verifizieren: `npx tsc -b`, `npm run build`, Screenshot via `scripts/probe.mjs`
(Wand zeichnen, Gehrung prüfen).
**Phase 2 — Snapping vervollständigen + 2D-Grundformen.**
- Snap: midpoint, intersection, onEdge (Lot), extension-Hilfslinien, Winkelraster
(45°), Snap-Einstellungsmenü in der TopBar.
- Werkzeuge: Polyline, Rectangle (inkl. optionaler Schraffur für geschlossene
Formen). `Primitive`-Erweiterung nur für tatsächlich gebrauchte Formen.
**Phase 3 — Kurven + Text.**
- `Primitive` um `circle` (+ `arc` `largeArc`) und `text` erweitern; PlanView-
Renderzweige; Text papierkonstant.
- Werkzeuge: Circle, Arc (3-Punkt), Text (Inline-Eingabe). Snap: center/quadrant.
**Phase 4 — Bearbeitung (Folge-Doku).**
- Grips/Editieren bestehender Elemente (Wand-Enden ziehen, 2D-Vertices verschieben),
Verschieben/Kopieren/Rotieren der Auswahl, numerische Direkteingabe von
Länge/Winkel im HUD. Baut auf demselben Snapping + Draft-Pfad auf. (Eigenes
Design-Dokument; hier nur als Ausblick.)
## 12. Architektur-Garantien (Checkliste)
- Modell bleibt einzige Wahrheit; Werkzeuge schreiben nur `Project`, nie Plan-
Primitive. Ansichten (Plan/3D) leiten ab.
- Alle Bezeichner englisch; alle UI-Texte über `t(...)`; Einheiten in Metern,
Anzeige via `formatM`; Strichstärken/Texthöhen in mm Papier (non-scaling).
- Native-App-Verhalten: kein Browser-Kontextmenü während des Zeichnens; keine
Textauswahl (außer Text-Eingabefeld); Pan/Zoom immer verfügbar.
- DRY: Vorschau nutzt dieselben `Primitive` + Renderlogik wie der Plan; Snapping
und Commit-Pfad sind werkzeugübergreifend geteilt.
</content>
</invoke>
+424
View File
@@ -0,0 +1,424 @@
# Design — Bauteile (Elements)
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
> Output/Pläne: [plans-output.md](plans-output.md). Ressourcen/Stile:
> [resources-graphics.md](resources-graphics.md).
Dieses Dokument legt die **Daten**, die **Generierung** (3D-Geometrie + Plan-
Symbolik) und das **Grip-Editing** je Bauteil fest und übersetzt DOSSIERs
`elemente.py` (7244 LOC, Monolith) in **kleine Bauteil-Module** (`src/model/
elements/wall.ts`, `opening.ts`, …). Bezeichner englisch, Prosa deutsch, Meter.
DOSSIERs Architektur dort: pro Element eine **Achse/Outline-Source** (editierbar)
+ ein **auto-generiertes Volumen** (`wand_axis`+`wand_volume`, Outline+Brep).
Browser-Äquivalent: das **semantische Element ist die Source**; Geometrie wird per
`generate*()` **abgeleitet** (nie persistiert). Das ist sauberer als DOSSIERs
zwei-Objekt-Modell und kennt kein Cache-Stale.
---
## 0. Gemeinsames Fundament
```ts
// src/model/elements/base.ts
interface ElementBase {
id: string;
type: ElementType; // "wall" | "window" | "door" | "slab" | "stair" | "roof"
// | "column" | "beam" | "space" | "draw2d"
floorId: string; // Zeichnungsebene (Geschoss); bei gehosteten via Host
categoryCode: string; // Ebene (Grafik-Kategorie), z.B. "20"
styleId?: string; // optionaler Element-Override-Stil (resources-graphics.md)
name?: string;
}
```
**Geometrie-Konvention** (aus CONVENTIONS.md, im Spike etabliert): Wand-Normale
`n = leftNormal(u) = (-u.y, u.x)`; bei CCW-Wicklung zeigt `+n` nach innen.
Schichten werden außen (`-T/2`) → innen (`+T/2`) gestapelt (`generatePlan.addWallPoche`,
`Viewport3D.addWallMeshes`).
**Detailgrad** (LoD) — DOSSIERs `darstellung` (`auto|einfach|standard|detail`):
```ts
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
// Auflösung: Element-Wert "auto" → Dokument-/Snapshot-Wert; sonst Element-Wert.
function resolveDetail(el: ElementBase, doc: { detailLevel: DetailLevel }): DetailLevel
```
≙ DOSSIER `_resolve_oeff_darstellung` + `get_aktive_darstellung`. Steuert, *wie
viel* Symbolik gezeichnet wird (1:500 Rechteck → 1:50 Glas/Sims/Schwenkbogen).
**Generierungs-Signaturen** (jedes Modul exportiert beides):
```ts
function build3d(project, el, ctx): THREE.Object3D // Volumen (Schichten/Brep)
function generatePlan(project, el, ctx, lod): Primitive[] // Schnittflächen + Symbol
// ctx trägt baseElevation, joins, sichtbare Codes, resolver für Components/Styles
```
---
## 1. Wand (Wall) — mehrschichtig
### 1.1 Daten
```ts
interface Wall extends ElementBase {
type: "wall";
start: Vec2; end: Vec2; // Achse (Centerline) im Grundriss [im Spike]
wallTypeId: string; // → WallType.layers (außen→innen)
height: number;
reference: "mid" | "left" | "right"; // Referenzlage der Achse (DOSSIER _wand_referenz)
baseOffset?: number; // UK relativ zu OKFF (default 0)
topOffset?: number; // OK-Override (default = floorHeight)
jointRole?: "auto" | "through" | "butt"; // T-Stoss-Rolle (DOSSIER wand_joint_rolle)
// Mehrsegment-Wände (Polyline): optional axisPoints statt start/end
axisPoints?: Vec2[];
}
```
`reference` verschiebt die Achse auf Außenkante/Mitte (DOSSIER
`_wall_offsets_from_referenz`): hilft beim Modellieren *und* beim Import fremder
Pläne (ROADMAP §11). Offsets: `mid → [+T/2, -T/2]`, `left → [0, -T]`, `right → [+T, 0]`.
### 1.2 Generierung — 3D + Plan (Status: im Spike, einschichtig→mehrschichtig ✅)
Beide Sichten extrudieren/füllen **dasselbe gehrte Band-Polygon** pro Schicht.
Heute schon vorhanden:
- `geometry.clippedBand(start, end, offA, offB, startCut, endCut)` — Band mit
Gehrungsschnitt.
- `generatePlan.addWallPoche` — pro Schicht ein gefülltes Polygon (Component-Fill +
Schraffur), Öffnungen ausgespart.
- `Viewport3D.addLayerPrism` — dasselbe Polygon via `ExtrudeGeometry`.
### 1.3 Wand-Verschneidung (Joins) — Risiko #1
**Status: L-Ecken-Gehrung ✅** (`joins.computeJoins` → `miterLine`, robust gegen
Wicklung + ungleiche Dicken). **Offen: Prioritäts-T-/X-Stöße** bei mehrschichtigen
Wänden.
DOSSIERs gelöste Logik (`elemente._t_junction_layer_overrides`, `_wand_should_apply_t_miter`),
die wir portieren:
1. **Knoten finden:** Endpunkte auf Gitter runden (`roundKey`, existiert),
gruppieren. `==1` freies Ende, `==2` L-Ecke (Gehrung, ✅), `>2` T/X.
2. **Through-Wand bestimmen:** an einem T-Stoß läuft genau **eine** Wand durch.
Auswahl nach `jointRole` (DOSSIER-Regel), sonst nach Component-`joinPriority`:
```
my.role="through" → ich laufe durch (kein Miter)
my.role="butt" → ich stoße an (Miter)
beide "auto" → höhere joinPriority = Through-Wand
```
3. **Schicht-Durchdringung (Backbone):** **nur das Material mit der höchsten
gemeinsamen `joinPriority`** in *beiden* Wänden läuft durch und unioniert
(T-Form). Beispiel ROADMAP §2d: Beton (800) läuft mittig durch; Putze (100)
verbinden sich seitlich, gehen aber nirgends durch den Beton. Alle Nicht-
Backbone-Schichten der anstoßenden Wand mitern an der Through-Außenkante
(`standard_miter`). Ergebnis: gleichfarbige Außenlagen bilden automatisch
saubere L-Stöße.
```ts
// joins.ts — Erweiterung der bestehenden API
interface WallCuts { startCut: Line|null; endCut: Line|null;
// neu: pro-Schicht Overrides am T-Stoss
layerExtensions?: number[]; // wie weit jede Schicht in Through-Body drillt
layerMiters?: (Line|null)[]; // pro-Schicht Mitre (null = Backbone, läuft durch)
}
function computeJoins(project, walls): Map<string, WallCuts> // erweitert
```
**Implementierungsplan (stufenweise, Risiko #1):**
- (a) ✅ L-Gehrung bleibt.
- (b) T-Stoß ohne Schichten: Backbone = ganze Wand; Through union, Stem mitert.
- (c) T-Stoß mit Schichten: Backbone-Material-Logik wie oben (Port von
`_t_junction_layer_overrides`).
- (d) X-Stoß: paarweise als zwei T behandeln.
- **Booleans:** Union/Extension der Backbone-Säule via **OpenCascade.js/Manifold**
im Worker (`workers/geometry.worker.ts`), nur für 3D + exakten B-Rep-Export; der
2D-Plan bleibt rein analytisch (Polygon-Clipping, kein Kernel) — schnell.
- **Validierung:** Screenshot-Probe der T-Ecke (Beton durch, Putz seitlich).
### 1.4 Grip-Editing (Risiko #L, Phase 3–4)
DOSSIER: Display-Conduit zeichnet dicke Marker an Achs-Endpunkten, MouseCallback
fängt Klick → `GetPoint` mit Snap → `_replace_axis_vertex` → Volumen regeneriert
(`wand_grips.py`). Browser-Port:
- **Marker:** SVG-Kreise (r ≈ 7 px) an Endpunkten/Knicks der *selektierten* Wand,
als Overlay über dem Plan (unabhängig von Ebenen-Sichtbarkeit) — exakt DOSSIERs
Conduit-Idee.
- **Hit-Test:** Pointer-Distanz < 14 px (DOSSIER `_HIT_RADIUS_PX`).
- **Drag:** `pointerdown` auf Marker → Live-Preview-Linien zu Nachbar-Vertices →
Snap (Endpunkt/Ortho/Raster) → `pointerup` → `store.apply(p => wall.start = newPt)`.
Abgeleitete Sichten (Plan + 3D) re-derivieren reaktiv — kein manuelles Regen.
- Funktioniert für Line (2 Grips) und Polyline (jeder Knick ein Grip), wie DOSSIER.
---
## 2. Öffnungen (Window / Door) — gehostet, LoD
### 2.1 Daten
```ts
interface OpeningBase extends ElementBase {
hostWallId: string; // Host-Wand (Geschoss ergibt sich daraus) [im Spike]
position: number; // Abstand entlang Wandachse vom Wand-Start (m)
width: number; height: number;
reference: "mid" | "left" | "right"; // Lage des Klickpunkts in der Öffnung
detailLevel: DetailLevel | "auto";
frame?: { width: number; depth: number; pos: "outer"|"mid"|"inner"; offset: number };
outerSide: "left" | "right"; // welche Wandseite ist außen
}
interface Window extends OpeningBase {
type: "window";
sill: number; // Brüstungshöhe
sashes: 1|2|3|4; // Flügelzahl
sillProfileOut?: "none"|"narrow"|"standard"|"wide"; // Sims außen (DOSSIER _OEFF_SIMS_STYLES)
sillProfileIn?: "none"|"narrow"|"standard"|"wide";
glass: boolean;
}
interface Door extends OpeningBase {
type: "door";
swing: "left" | "right"; // Anschlagseite [im Spike]
hinge: "start" | "end"; // Scharnierpfosten [im Spike]
openAngle: number; // Plan-Öffnungswinkel 0–180 (default 90)
doorType: "normal" | "wall-opening"; // Wandöffnung = ohne Blatt
frameType: "casing" | "block"; // Zarge | Blockrahmen
lintel?: "none"|"inner"|"outer"|"both";// Sturzlinien-Anzeige (DOSSIER _OEFF_STURZ)
}
```
Felder 1:1 aus DOSSIERs `_OEFF_*`-Keys + `_OEFF_STYLE_FIELDS`. Presets (Fenster
Standard/Gross/Bandlage, Tür Innen/Eingang/Verglast, Wandöffnung) als
Style-Katalog (resources-graphics.md), seed wie `_OEFF_DEFAULT_STYLES`.
### 2.2 Host-Beziehung (Risiko #2)
Die Öffnung kennt ihre Wand (`hostWallId`); ihre Geometrie wird **relativ zur
Wandachse** berechnet (`opening.axisFrame(wall, position)` → Punkt + Tangente +
Normale, ≙ DOSSIER `_oeff_axis_frame`). Verschiebt sich die Wand, folgt die
Öffnung automatisch (sie hält keinen absoluten Punkt). Beim Plan/3D wird die
Wand an `[position, position+width]` ausgespart — steht im Spike (`addWallPoche`
Segmentierung, `addWallMeshes` Sturz).
### 2.3 Generierung nach LoD
| LoD | Plan-Symbol | 3D |
|---|---|---|
| **coarse** (1:200/500) | Öffnung als Lücke + dünne Linie | Aussparung, kein Rahmen |
| **medium** (1:100) | + Rahmenlinien, Tür-Schwenkbogen (`addDoorSymbol` ✅), Sturz gestrichelt | Aussparung + einfacher Rahmen-Quader |
| **fine** (1:50) | + Glas-Doppellinie, Sims, Flügel-Teilung, Anschlag | Rahmen + Blatt + Glas (transparent) + Sims (DOSSIER `_OEFF_PIECE_DEFS`) |
- **Tür-Schwenkbogen:** im Spike (`generatePlan.addDoorSymbol` — Blatt + Arc).
Ausbau: `openAngle`, lichte vs. volle Breite je LoD (Port von
`_make_tuer_swing_curves`).
- **3D-Stücke** (Rahmen/Glas/Flügel/Sims/Sturz) ≙ DOSSIER `_make_oeffnung_pieces`
/ `_OEFF_PIECE_DEFS` — jeweils eigene Component (Farbe + Transparenz: Glas
α≈0.88, IOR 1.5). Pieces landen auf Unter-Ebenen von `21 Türen/Fenster`.
---
## 3. Decke / Boden (Slab) — mit Aussparungen
### 3.1 Daten
```ts
interface Slab extends ElementBase {
type: "slab";
boundary: Vec2[]; // geschlossener Umriss (CCW)
slabTypeId: string; // mehrschichtig (analog WallType)
openings?: Vec2[][]; // Aussparungen: Treppenauge, Schacht, Kamin (DOSSIER aussp)
ukOverride?: number; okOverride?: number; // UK/OK statt auto (Abhängung, schräge Brüstung)
}
```
### 3.2 Generierung
- **Z-Auflösung:** `okOverride ?? (baseElevation_oberes_Geschoss)`,
`ukOverride ?? (ok - thickness)` — Port von `_resolve_decke_z`. Decke sitzt
standardmäßig zwischen zwei Geschossen.
- **3D:** `boundary` als `THREE.Shape`, Aussparungen als `shape.holes` (`THREE.Path`),
`ExtrudeGeometry` über die Schichten (≙ `_make_decke_volume(outline, holes)`).
- **Plan:** im Schnitt unter `cutHeight` meist nur Kante; Aussparungs-Ränder als
Linien; geschnittene Decke (in Schnitt-Ansicht) bekommt Schraffur.
- **Aussparung↔Decke:** Aussparung als geschlossene Curve, die räumlich in der
Decke liegt (`_find_decke_containing_point` / `_find_aussparungen_for_decke`).
Bei uns: `Slab.openings` direkt im Slab — keine separate Source nötig (einfacher
als DOSSIERs Parent-Child).
---
## 4. Treppe (Stair) — Typen, Lauflinie, geschossübergreifend
### 4.1 Daten
```ts
interface Stair extends ElementBase {
type: "stair";
kind: "straight" | "l-shaped" | "spiral"; // gerade | L | Wendel (DOSSIER _TREPPE_ARTEN)
run: Vec2[]; // Lauflinien-Stützpunkte (gerade: 2; L: 3; Wendel: Zentrum+Start)
width: number;
reference: "mid" | "left" | "right"; // Lage der Lauflinie zur Treppe
steps: number; // Anzahl Steigungen
mode: "solid" | "flat" | "slab-edge"; // massiv | flach | Plattenrand
runSlabThickness?: number; // Lauf-Plattendicke
floorEndId?: string; // Zielgeschoss (geschossübergreifend, Risiko #6)
heightOverride?: number; ukOverride?: number;
rules?: { riser:[lo,hi,on]; tread:[lo,hi,on]; stepGo:[lo,hi,on] }; // SIA-Komfortregeln
lockRiser?: { value: number }; // Schrittmass-Lock (S fix, N passt sich an)
// Plan-Symbol-Flags (DOSSIER _KEY_TREPPE_SHOW_*)
show?: { treads; runLine; outline; breakLine };
upperDashed?: boolean; // obere Stufen gestrichelt (über Schnitthöhe)
arrowStyle?: "classic"|"filled"|"double"|"line";
}
```
### 4.2 Generierung
- **Steigung/Auftritt:** `riser = height/steps`; `tread` aus Lauflinienlänge /
(steps−1). SIA-Komfort: `2·riser + tread ∈ [0.60, 0.65]` (DOSSIER
`_TREPPE_SOLL_DEFAULT`). Lock: ist `lockRiser` gesetzt, wird `steps` neu
berechnet statt `riser` zu ändern.
- **3D je `kind`:** gerade → Stapel von Tritt-Quadern oder massive Rampe;
L → zwei Läufe + Podest (`podestMin`); Wendel → um Zentrum rotierte Tritte
(Port `_make_treppe_*_preview` / Volume-Funktionen). `mode` steuert massiv vs.
Lauf-Platte.
- **Geschossübergreifend (Risiko #6):** Höhe = `(baseElevation[floorEndId] -
baseElevation[floorId])` falls `floorEndId` gesetzt; sonst Geschosshöhe. Treppe
taucht dann in beiden Geschoss-Grundrissen auf (mit Schnitt an `cutHeight`).
- **Plan-Symbol (normgerecht):** Lauflinie mit **Auf-/Abpfeil** (`arrowStyle`),
Stufenkanten, Bruchlinie an `cutHeight` (untere durchgezogen, obere gestrichelt
via `upperDashed`), Außenkante. ≙ DOSSIERs 2D-Treppensymbol; liegt auf Ebene
`40 Treppen`/`41 Treppen-2D`.
### 4.3 Grip-Editing
Lauflinien-Stützpunkte als Grips (wie Wand-Vertices, §1.4); Ziehen ändert
Geometrie + Stufenzahl reaktiv.
---
## 5. Dach (Roof)
### 5.1 Daten
```ts
interface Roof extends ElementBase {
type: "roof";
outline: Vec2[]; // Grundriss-Umriss
roofType: "mono" | "gable" | "hip" | "mansard"; // Pult|Sattel|Walm|Mansarde
thickness: number;
slope: number; // Grad (Hauptneigung)
eaveIndex?: number; // Index der Traufkante (Pult)
ridge?: "long" | "short"; // Firstrichtung (Sattel)
// Mansarde:
slopeLower?: number; kinkHeight?: number;
mansardVariant?: "hip" | "gable" | "hip-gable";
}
```
### 5.2 Generierung
Port von DOSSIERs `_make_pultdach/_satteldach/_walmdach/_mansardendach*` +
`_thicken_roof_inward`. Aufwand M–L (Mansarde später). Reihenfolge: Pult →
Sattel → Walm → Mansarde. 3D als Brep/Mesh über OpenCascade.js (Worker), da
Schräg-Verschneidung Booleans braucht. Plan: Firstlinien + Traufe + ggf.
Höhenkoten.
---
## 6. Tragwerk (Column / Beam)
### 6.1 Daten
```ts
interface ProfileDef {
shape: "square"|"rect"|"round"|"i-beam"|"tube"; // DOSSIER _TRAG_PROFILE
b?: number; h?: number; d?: number; t?: number; // Breite/Höhe/Durchm./Wanddicke
angle: number; // Rotation um Z
}
interface Column extends ElementBase { type:"column"; point: Vec2; profile: ProfileDef;
uk?: number; ok?: number; }
interface Beam extends ElementBase { type:"beam"; axis:[Vec2,Vec2]; profile: ProfileDef;
zTop?: number; // hängt unter Decken-OK (zTop = ok der Decke)
}
```
### 6.2 Generierung
- **Querschnitt:** `profileCurve(shape, b,h,d,t, angle)` (Port `_trag_profile_curve`)
→ für Stütze entlang Z extrudieren (`_make_stuetze_volume`), für Träger entlang
der Achse (`_make_traeger_volume`, Profil in der Schnitt-Ebene).
- **Träger achs-basiert unter Decke:** `zTop` default = OK der darüberliegenden
Decke → Unterzug folgt automatisch (weniger Update-Fehler, ROADMAP §11).
- Stützen liegen auf `25 Stützen`, Träger auf `35 Träger`.
---
## 7. Raum (Space) — SIA-416 + Stempel
### 7.1 Daten
```ts
interface Space extends ElementBase {
type: "space";
boundary: Vec2[]; // geschlossener Umriss
number?: string; spaceName?: string; function?: string;
sia?: "" | "HNF"|"NNF"|"VF"|"FF"|"GF"|"AGF"; // SIA-416-Klasse
persons?: number; // Personenbelegung (Brandschutz)
areaRounding: "exact"|"0.01"|"0.1"|"0.5"|"1";
stamp: StampConfig; // Raumstempel-Layout (s.u.)
fill?: string; // Füll-Hatch-Id (optional)
}
interface StampConfig { // ≙ DOSSIER Stempel-Builder
layout: FieldId[][]; // Zeilen × Felder, z.B. [["number","name"],["function"],["area"]]
font; bold; italic; textHeight; textMode:"fixed"|"scale"; align:"left"|"mid"|"right";
offset: Vec2; // Stempel-Position relativ zum Centroid (User-Move)
}
type FieldId = "number"|"name"|"function"|"area"|"sia";
```
### 7.2 Generierung & Bilanz
- **Fläche:** Shoelace-Formel über `boundary`, gerundet nach `areaRounding`
(`_resolve_raum_rundung`). Umfang analog.
- **Stempel:** als SVG-Text-Block aus `layout`-Zeilen am Centroid + `offset`
(User kann verschieben; Offset persistiert wie DOSSIER `stamp_dx/dy`).
`textMode:"scale"` → Texthöhe in Paper-mm × Massstab (plans-output.md).
- **SIA-Färbung:** über die **Overrides-Engine** (regelbasiert), nicht hartcodiert
— DOSSIER `_build_sia_preset_rules` erzeugt 4 Regeln `userString sia == hnf|nnf|vf|ff`
→ Farbe + Solid-Hatch. Bei uns: ein Override-Preset „SIA-416" (resources-graphics.md),
das auf `space.sia` matcht. Toggle = Preset aktivieren.
- **SIA-Bilanz + CSV:** `panels/SiaBalance.tsx` summiert Flächen je Klasse je
Geschoss → Tabelle + CSV-Export (`HNF/NNF/VF/FF/GF/AGF`). Pflicht für
CH-Flächennachweis (ROADMAP ⭐).
---
## 8. Werkzeuge (Tools) — ersetzt Rhino-Command-Aliases
DOSSIER hat pro Bauteil ein Command-Alias (`rhino/aliases/cmd/wand.py`, `tuer.py`,
`treppe.py`, …) das `GetPoint`-Interaktionen fährt. Browser: ein **Tool-Interface**
mit Pointer-Handlern + Snap.
```ts
interface Tool {
id: ToolId;
onPointerDown(pt: Vec2, snap: SnapResult, state): void;
onPointerMove(pt: Vec2, snap: SnapResult, state): Primitive[]; // Live-Preview
onPointerUp(pt: Vec2, snap: SnapResult, state): void;
commit(store): void; // ruft store.apply()
}
```
| Tool | DOSSIER-Alias | Kurzbeschrieb |
|---|---|---|
| `wall` | `cmd/wand` | Achse zeichnen (Linie/Polyline), Dicke/Referenz/Typ aus „last used" |
| `door`/`window` | `cmd/tuer`,`fenster` | Punkt auf Wandachse → hosten (Snap an Wand) |
| `slab` | `cmd/decke` | Umriss klicken; Aussparung als Loch |
| `stair` | `cmd/treppe` | Lauflinie + Breite + Stufen |
| `roof` | `cmd/dach` | Umriss + Typ + Neigung |
| `column`/`beam` | `cmd/stuetze`,`traeger` | Punkt / Achse + Profil |
| `space` | `cmd/raum` | Umriss → Fläche auto, Stempel |
| `draw2d` | `cmd/symbol`,`stempel` | Linie/Polyline/Rect/Kreis/Bogen/Text auf `60 Plangrafik` |
| `pipette` | `cmd/pipette` | Stil/Typ von Element übernehmen |
**Snap-Engine** (`tools/snap.ts`): Endpunkt, Mitte, Schnitt, senkrecht, Raster,
Ortho — ersetzt Rhinos OSnap. T-Snap an andere Wandachsen (Port
`_t_snap_to_wand_axis`, `_snap_endpoint_to_other_wand_axis`) sorgt für saubere
Knoten.
---
## 9. Element-Übersicht (BIM-Tree)
`panels/ElementTree.tsx`: Baum Geschoss → Bauteiltyp → Element, mit Suche und
Shift-Klick = Zoom (DOSSIER ELEMENTE-ÜBERSICHT). Inhaltsverzeichnis bei 100+
Elementen — reine Ableitung aus `project.elements`.
---
## 10. Reihenfolge der Umsetzung (verweist auf ROADMAP-Phasen)
1. **Phase 1:** Wand mehrschichtig ✅ + L-Gehrung ✅ → **Prio-T-Stoß** (§1.3);
Tür/Fenster gehostet (§2); Decke + Aussparung (§3); Wand-Referenzlage (§1.1);
Element-Übersicht (§9).
2. **Phase 2:** Treppe (§4), Dach (§5), Tragwerk (§6), SIA-Räume + Stempel (§7),
Stil-Kataloge.
3. **Phase 3–4:** Grip-Editing (§1.4/§4.3), exakte B-Rep-Booleans im Worker.
+137
View File
@@ -0,0 +1,137 @@
# Engine-Nordstern — Headless-Rendering (PNG-Export & Golden-Image-Tests)
> Betrifft `src-tauri/render2d` (Feature `headless`). Bezug: HANDOVER.md,
> Abschnitt ENGINE-NORDSTERN, Punkt 3 ("deterministisches Headless-Rendering,
> PNG-Export und Golden-Image-Tests ohne Fenster").
## Warum
`render2d` trennt die serde-only-Tessellierung (Feature-los, headless testbar)
von der GPU-Schicht (Feature `render`, wgpu). Der Fenster-Pfad (`gpu::Renderer`,
Feature `window`) braucht dafür bislang eine `wgpu::Surface` — also ein echtes
Fenster mit Wayland-/X11-Session. Für deterministische PNG-Exporte und
Golden-Image-Tests (CI, Regressions-Screenshots) ist das unnötig: wgpu kann
genauso gut in eine `wgpu::Texture` rendern, ganz ohne Surface/Fenster.
`gpu::Renderer::render` nahm bereits vorher nur `Device`/`Queue`/`TextureView`
entgegen — Surface- oder Offscreen-Textur macht für den Draw-Code keinen
Unterschied. Der Offscreen-Pfad (`src/headless.rs`) dupliziert daher NICHTS,
sondern baut nur Device/Queue ohne Surface sowie eine eigene Ziel-Textur +
Buffer-Readback drumherum.
## Feature-Gating
Neues Cargo-Feature `headless = ["render", "dep:image"]`:
- zieht `render` (wgpu/glyphon/bytemuck/pollster) plus die `image`-Crate
(nur der PNG-Codec, `default-features = false, features = ["png"]`).
- **berührt den wasm/web-Build nicht**: `cargo check --target wasm32-unknown-unknown
--no-default-features --features web` zieht `image` nicht mit.
- Binary `render_png` und Test `golden` sind zusätzlich per
`required-features = ["headless"]` in `Cargo.toml` abgesichert.
## API
```rust
pub struct HeadlessRenderer { /* Device, Queue, gpu::Renderer */ }
impl HeadlessRenderer {
/// Instance/Adapter/Device OHNE Surface (Backend fest auf Vulkan gepinnt).
/// `Err`, wenn kein Adapter verfügbar ist (z.B. CI-Runner ohne GPU) — die
/// Aufrufer (CLI, Golden-Test) behandeln das, statt zu paniken.
pub fn new() -> Result<Self, String>;
/// Rendert `scene` in ein `width`x`height`-Bild (Papier-Maßstab
/// `paper_scale_n`, z.B. `100.0` für 1:100). Lädt die Szene bei jedem Aufruf
/// neu hoch (kein Zwischenzustand nötig für CLI-/Test-Anwendungsfall).
pub fn render_to_image(
&mut self,
scene: &Scene,
width: u32,
height: u32,
view_box: ViewBox,
paper_scale_n: f32,
) -> RgbaImage; // { width, height, pixels: Vec<u8> (straff gepackt, RGBA8) }
}
impl RgbaImage {
pub fn encode_png(&self) -> Vec<u8>;
/// Gegenstück für den Golden-Test: Referenz-PNG -> straff gepacktes RGBA8.
pub fn decode_png(bytes: &[u8]) -> Result<Self, String>;
}
```
Backend bewusst auf **Vulkan** gepinnt (nicht das Standard-Backend-Set): Vulkan
rendert offscreen ohne jede Fenster-/Display-Server-Abhängigkeit. GL bräuchte auf
Linux i.d.R. einen EGL/GLX-Kontext, der ohne aktive Display-Session (X11/Wayland)
Sonderfälle hat — auf dieser Maschine ist ohnehin ein echter Vulkan-ICD (RADV)
vorhanden, daher kein Rückgriff auf `lavapipe`/`llvmpipe` nötig gewesen.
## Row-Alignment-Falle (wichtig für zukünftige Agenten)
`wgpu::Queue::copy_texture_to_buffer` / `CommandEncoder::copy_texture_to_buffer`
verlangt, dass `bytes_per_row` im `ImageDataLayout` ein Vielfaches von
`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT` (256) ist. Bei RGBA8 (4 Byte/Pixel) trifft
das **nur zufällig** zu — z.B. Breite 300 px → 1200 Byte/Zeile, kein Vielfaches
von 256, der Copy schlägt sonst mit einem Validierungsfehler fehl (oder liefert
verzerrte Zeilen, je nach Backend).
Lösung in `headless::HeadlessRenderer::read_pixels`:
1. `padded_bytes_per_row = align_up(width * 4, 256)` — der Zielpuffer wird auf
diese (größere oder gleiche) Zeilenbreite alloziert.
2. Nach dem Mapping wird **jede Zeile einzeln** von `padded_bytes_per_row` auf
die echte Breite (`width * 4`) zurückgeschnitten und in einen straff
gepackten `Vec<u8>` kopiert — sonst hätte das Ausgabebild pro Zeile
Garbage-Padding am rechten Rand.
## Referenzbild neu erzeugen
Bei einer **gewollten** Rendering-Änderung (z.B. neue Shader, neue Demo-Szene,
geänderte Antialiasing-Parameter) weicht das Golden-Bild ab. Referenzbild
danach bewusst neu erzeugen:
```sh
cd src-tauri/render2d
cargo run --features headless --bin render_png -- --width 990 --height 630 --out target/headless-demo.png
cp target/headless-demo.png tests/golden/demo.png
```
Vorher unbedingt das erzeugte PNG (`target/headless-demo.png`) visuell prüfen
(z.B. mit einem Bildbetrachter oder Read-Tool eines Agenten) — nicht blind
kopieren. Bei einem fehlgeschlagenen Testlauf schreibt der Golden-Test
automatisch ein Diff-Bild nach `target/golden-diff.png` (abweichende Pixel
signalrot markiert, Rest unverändert) — das hilft beim Einordnen, ob die
Abweichung erwartet (Layout-/Farbänderung) oder ein Bug ist (z.B. verschobene
Geometrie, fehlender Text).
## Golden-Test
`tests/golden.rs`, Test `demo_szene_entspricht_referenzbild`:
- rendert die Demo-Szene (`demo::demo_scene`, dieselbe wie der Fenster-Spike)
in 990×630 und vergleicht sie gegen `tests/golden/demo.png`.
- Toleranz: ein Pixel gilt als "abweichend", wenn irgendein Kanal-Delta > 2 ist
(deckt AA-Rundungsrauschen ab); der Test schlägt fehl, wenn mehr als 0,5%
aller Pixel abweichen.
- `#[ignore]`, weil eine Vulkan-fähige GPU auf CI-Runnern nicht garantiert ist
(und ein Software-Rasterizer wie llvmpipe anderes AA liefern würde als das
Referenzbild). Lokal explizit ausführen:
`cargo test --features headless -- --include-ignored`.
- Bei Abweichung über der Toleranz schreibt der Test zusätzlich zum Diff-Bild
auch das Ist-Bild nach `target/golden-actual.png` — nach visueller Prüfung
ist das die Vorlage für eine gewollte Referenz-Aktualisierung.
- `HeadlessRenderer::new()` liefert bei fehlendem Adapter `Err` statt Panik —
der Test überspringt sich dann sauber (`eprintln!` + `return`) statt rot zu
schlagen, falls doch mal ohne `--ignored`/ohne GPU ausgeführt.
## Offener Punkt: Text-Determinismus zwischen GPU-Treibern
Der Textpass läuft über `glyphon`/`cosmic-text` (Systemfonts, `Inter`). Die
exakte Glyphen-Rasterisierung (Subpixel-Antialiasing, Hinting) kann je nach
installierter Font-Version, Fontconfig-Konfiguration und GPU-Treiber leicht
variieren — auch bei identischer Geometrie. Auf **derselben** Maschine (gleicher
Treiber, gleiche Font-Version) ist der Test wie erwartet bit-nah deterministisch
(hier: 0 abweichende Pixel bei zwei aufeinanderfolgenden Läufen). Bei einem
Wechsel der Maschine/des Treibers/der Fontconfig-Version ist nicht
auszuschließen, dass die 0,5%-Toleranz für Textkanten knapp wird — sollte das
in der Praxis auftreten: entweder Toleranz für den Text-Bereich lockern, oder
den Text testweise aus der Golden-Szene ausklammern und separat (z.B. nur
Zeilenbreite/Position, nicht Pixel) prüfen.
+511
View File
@@ -0,0 +1,511 @@
# Engine-Nordstern 1 — Strichbreiten-Audit (Papier-mm end-to-end)
> Bezug: HANDOVER.md, Abschnitt ENGINE-NORDSTERN Punkt 1 ("Papier-mm-exakte
> Strichbreiten überall — Bildschirm bei jedem Massstab/Zoom = Druck"). Dieses
> Dokument ist ein Prüfbericht, kein Umbau — es fasst zusammen, wie die
> Strichbreite heute vom Modell bis zum Papier läuft, wo sie divergieren kann,
> und was konkret zu tun wäre. Nichts hieraus wurde umgesetzt.
Betroffene Pfade: `src/plan/PlanView.tsx` (SVG-Default + Print-Vorschau),
`src/plan/glPlan/*` (WebGL2, aktueller Default-GPU-Pfad), `src-tauri/render2d`
(WASM/WebGPU, `?engine=wasm`), `src/export/sceneToPrintSvg.ts` +
`src/export/exportPdf.ts` (Vektor-PDF). Referenz-Baseline für die
2D-Engine-Parität ist laut Commit `ce6bd26` **`?gl=0`** (reiner SVG-Pfad) —
NICHT der App-Default (der ist WebGL2, siehe Finding 1).
## 1. Modell der Wahrheit
So sollte eine Strichbreite in diesem Code korrekt fliessen:
1. **Quelle**: `generatePlan.ts` legt jede Strichbreite als `weightMm` /
`strokeWidthMm` in **echten Papier-Millimetern** ab (Kommentarkopf,
`generatePlan.ts:76-79`: "Alle Stricharten sind in mm Papier definiert").
Diese Zahl ist unabhängig von Zoom, Massstab und Render-Pfad — eine 0.18 mm
Wand-Umrisslinie bleibt 0.18 mm, ganz gleich wo sie später landet.
2. **Bildschirm bei Massstab 1:N**: 1 Modell-Meter entspricht auf Papier
`1000/N` mm. Der Bildschirm zeigt `PX_PER_M = 90` viewBox-Einheiten je
Modell-Meter (`PlanView.tsx:23`). Eine `mm`-Breite belegt daher
`mm · N/1000 · PX_PER_M` viewBox-Einheiten — das ist exakt
`printStrokeVb()` (`PlanView.tsx:2598-2600`). Multipliziert mit der
Geräte-px-je-viewBox-Einheit-Skala (`meet`, `PlanView.tsx:2074-2078`) ergibt
das die tatsächliche Bildschirmbreite in Geräte-Pixeln. Reinzoomen (kleinere
viewBox-Breite, grösseres `meet`) macht die Linie dicker — genau wie beim
Herausvergrössern eines gedruckten Plans mit der Lupe. Das gilt für JEDE
Zoomstufe gleichermassen: 250 % Zoom bei 1:50 zeigt exakt die 5-fache
Pixelbreite von 100 % Zoom bei 1:50, und bei gegebenem Zoom ist 1:50 exakt
doppelt so dick wie 1:100 (halber Nenner → doppelt so viele Weltmeter je
Papiermm).
3. **Druck/PDF**: dieselbe Formel, nur ohne Geräte-px-Zwischenschritt — direkt
`mm` bleibt `mm` — die Seite ist direkt in echten
Papiermillimetern aufgespannt (`sceneToPrintSvg.ts:99-125`). Zusätzlich wird
auf ISO-nahe Stiftstufen gerundet (`PEN_STEPS`,
`sceneToPrintSvg.ts:44`), weil ein reales Zeichengerät/Plotter nur endlich
viele Stiftbreiten kennt.
4. **Eine Wahrheit**: Seit `382771b` bauen sowohl der Viewport
(`useWasmPlanRenderer.ts`/`nativeSync.ts`) als auch der PDF-Export
(`exportPdf.ts:73`) dieselbe `planToRenderScene(plan)`-Szene — der PDF-Pfad
ist nur ein anderes *Ziel* derselben Szene, kein zweiter Interpret.
Korrekt hiesse also: **jede** der vier Anzeige-/Exportarten (SVG-Default,
SVG-Print-Vorschau, GPU-Viewport, PDF) muss aus **derselben** `weightMm`, für
**denselben** `N`, exakt dieselbe Papier-mm-Breite ergeben — bis auf die
bewusste PEN_STEPS-Rundung im Druckpfad, die dokumentiert und überall
gleichermassen sichtbar sein sollte (ist sie nicht, siehe Finding 2).
Ausdrücklich **kein** Teil dieses Modells: der Haarlinien-Modus
(`lineMode: "display"`, App-Default, `viewSlice.ts:38,74`). Er ist als
bewusster Papier-mm-*Ausstieg* gedacht ("Display: all lines as constant
hairlines (calm editing)", `en.ts:173`) — 1 Geräte-px, konstant, unabhängig
von Zoom/Massstab. Er muss also NICHT der Papier-mm-Formel folgen, aber er
muss in JEDEM Render-Pfad *gleichermassen* als Ausstieg wirken. Tut er nicht
(Finding 1).
## 2. Pfad-für-Pfad-Trace
### 2a. SVG-Default (App-Start ohne `?gl=0`, `lineMode:"display"`)
Nur aktiv, wenn WebGL2 fehlschlägt (`wantGl` ist sonst `true`, s. Finding 1) —
de facto der reine Fallback-Pfad.
- `PlanView.tsx:2613`: `print = !hairline && paperScale != null && paperScale > 0`
— mit `hairline=true` (Default) ist `print` immer `false`.
- `PlanView.tsx:2617-2618`:
```ts
const weight = (mm: number): number =>
hairline ? HAIRLINE_PX : print ? printStrokeVb(mm, paperScale!) : mmToPx(mm);
```
`HAIRLINE_PX = 1` (`PlanView.tsx:2583`), Einheit: Geräte-unabhängiger
CSS-Pixel, gezeichnet mit `vector-effect="non-scaling-stroke"`
(`PlanView.tsx:2621`, `vfx`) → bleibt beim Pan/Zoom optisch exakt 1 px, egal
wie stark reingezoomt wird. Papier-mm spielt hier explizit KEINE Rolle.
### 2b. SVG-Print-Vorschau (`lineMode:"print"`, weiterhin `?gl=0` oder
WebGL2-Fallback)
- `print = true`, `weight(mm) = printStrokeVb(mm, paperScale!)`
(`PlanView.tsx:2598-2600`):
```ts
function printStrokeVb(mm: number, n: number): number {
return Math.max(1e-4, (mm * n) / 1000) * PX_PER_M;
}
```
`vectorEffect` entfällt (`vfx = undefined`, `PlanView.tsx:2621`) — die Linie
skaliert MIT der Geometrie beim Zoomen, wie physisches Papier unter der
Lupe. `paperScale` selbst ist der **stabile, gemessene** 1:N-Nenner
(`PlanView.tsx:458-460`), nicht der live aus jedem Radzoom abgeleitete Wert
— sonst würde Reinzoomen die Linien nicht dicker, sondern konstant halten
(das wäre wieder Haarlinien-Verhalten). Keine PEN_STEPS-Rundung — die rohe
`mm`-Zahl wird 1:1 in viewBox-Einheiten übersetzt.
### 2c. GPU-Viewport (WebGL2 `useGlPlanRenderer.ts` — App-DEFAULT; WASM
`useWasmPlanRenderer.ts` bei `?engine=wasm`)
- Beide Hooks reichen `paperScaleN` unverändert an die Engine durch:
`useWasmPlanRenderer.ts:131-149` (`render(viewBox, paperScaleN=100,
textScaleN=100)` → `r.set_paper_scale(paperScaleN)`); WebGL2 analog
(`useGlPlanRenderer.ts:102-116`, kein `textScaleN`-Parameter überhaupt, s.
Finding 3).
- `PlanView.tsx` ruft in JEDEM Render-Aufruf
(`PlanView.tsx:494`,`502`,`1080`) `renderGl(view, paperScaleForGl(view),
textScaleForGl())`. `paperScaleForGl` (`PlanView.tsx:475-476`):
```ts
const paperScaleForGl = (v: ViewBox): number =>
paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100;
```
**Kein `hairline`-Zweig.** Egal ob `lineMode` "display" oder "print" ist,
hier kommt immer ein echter 1:N-Papier-Nenner heraus.
- Rust-Seite (`gpu.rs:606-616`):
```rust
let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);
```
`mm_to_device_px` (`ortho.rs:96-99`) reproduziert exakt `printStrokeVb`:
`(paper_scale_n/1000.0) * PX_PER_M * meet` — `PX_PER_M = 90.0`
(`tessellate.rs:22`, identisch zu TS). Die reine Mathematik ist also
deckungsgleich zum SVG-Print-Pfad (2b) — aber sie läuft **immer**, auch wenn
der Nutzer "Display: Haarlinien" gewählt hat. Siehe Finding 1.
- Stiftbreite pro Batch im Shader (`shaders.rs:91`, `LINE_WGSL`):
`width_px = max(0.6, stroke_px * stroke_scale) * miter` — harte Untergrenze
0.6 Geräte-px, die weder die SVG- noch die PDF-Seite kennt (Finding 6).
Identische Formel für Bögen (`shaders.rs:209`, `ARC_WGSL`) und im
WebGL2-Pfad (`glPlanRender.ts:243-249`, Kommentar "klemmt bei ~0.6 px").
- Schraffur-Musterlinien laufen NICHT über `mm_px`, sondern über
`width_screen`/`px_per_screen` (`gpu.rs:661-665`): Geräte-px = Breite ×
`meet`-Skala statt × mm→px — bewusst zoom-skalierend wie das SVG-`<pattern>`,
nicht papierkonstant (s. `toRenderScene.ts:371-375`, Finding 4).
### 2d. Vektor-PDF (`exportPdf.ts` → `sceneToPrintSvg.ts`)
- `exportPdf.ts:73`: `const scene = planToRenderScene(plan);` — dieselbe
Szene wie 2c, unabhängig vom aktuell gewählten `lineMode` der Ansicht.
- `sceneToPrintSvg.ts:243-244`:
```ts
const effectiveMm = widthScreen ? (widthMm / PX_PER_M) * mmPerM : widthMm;
const strokeMm = quantizePen(effectiveMm);
```
`PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0]`, `MIN_PEN_MM = 0.13`
(`sceneToPrintSvg.ts:44,47`). JEDE Linie/Umriss/Bogen/Schraffur wird auf die
nächsthöhere Stufe gerundet, bevor sie ins SVG/PDF geht
(`sceneToPrintSvg.ts:251,274,301`). Das ist der EINZIGE der vier Pfade, der
überhaupt quantisiert.
- `widthScreen`-Konvertierung: `widthMm` (hier eigentlich viewBox-Einheiten,
siehe 2c) wird über `/PX_PER_M * mmPerM` zurück in Weltmeter und dann in
Papier-mm beim GEWÄHLTEN `opts.scaleDenominator` übersetzt — nicht beim
Massstab, den die Live-Ansicht gerade zeigt (Finding 4).
- Text: `sizeMm` kommt unverändert aus `RText.sizeMm`
(`sceneToPrintSvg.ts:325`, `t.sizeMm`, keine Nachskalierung) — aber die
VERTIKALE Zeilenposition dieser Texte wurde in `toRenderScene.ts` mit einem
festen `STAMP_REF_N = 100` in Weltmeter umgerechnet (`toRenderScene.ts:163-165,
481-483`), unabhängig vom tatsächlich gewählten Export-`N` (Finding 3).
## 3. Findings (nach Schwere geordnet)
### 1. Haarlinien-Modus (App-Default) wird vom GPU-Renderer komplett ignoriert — betrifft den Standard-Zustand der App
`lineMode` startet auf `"display"` (`viewSlice.ts:74`), und WebGL2 ist der
Default-Renderer (`wantGl` ist `true`, ausser `?gl=0`,
`PlanView.tsx:433-437`) — **d. h. im frisch geladenen, unkonfigurierten App-
Zustand rendert bereits der GPU-Pfad, und die "Display: Haarlinien"-Option
tut nichts.** `paperScaleForGl` (`PlanView.tsx:475-476`) liest nur
`paperScaleRef.current ?? scaleFromView(...) ?? 100` — der `hairline`-Boolean
aus den Props (`PlanView.tsx:2571`) wird an dieser Stelle nie geprüft, obwohl
das benachbarte `textScaleForGl` (`PlanView.tsx:485-488`) exakt diesen Zweig
korrekt hat:
```ts
const textScaleForGl = (): number => {
const print = !hairline && paperScale != null && paperScale > 0;
return print ? paperScale! : 100;
};
```
Ergebnis: Text (Raumstempel) fällt im Haarlinien-Modus korrekt auf die
Referenzskala 100 zurück, aber jede Wand-/Tür-/2D-Zeichenlinie wird trotzdem
mit dem echten (gemessenen oder geschätzten) `paperScale` gezeichnet — sie
wird beim Reinzoomen dicker statt konstant zu bleiben, exakt das Gegenteil
dessen, was der Menüpunkt verspricht ("calm editing", `en.ts:173`). Der Bug
betrifft sowohl `useGlPlanRenderer.ts` (Default) als auch
`useWasmPlanRenderer.ts` (`?engine=wasm`) — keiner der beiden `render()`-
Aufrufe kennt einen `hairline`-Parameter überhaupt.
### 2. PEN_STEPS-Quantisierung existiert NUR im PDF-Export — beide Live-Vorschauen (SVG-Print und GPU) zeigen unquantisierte Rohwerte
Konkret, mit den tatsächlichen Konstanten aus `generatePlan.ts`:
- `LAYER_LINE_MM = 0.13` (`generatePlan.ts:82`), `LAYER_DETAIL_FACTOR.fein =
0.7` (`generatePlan.ts:90-93`) → Schichtfuge im Detailgrad "fein":
`0.13 · 0.7 = 0.091 mm` (`generatePlan.ts:1022`,
`strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]`). Die
Bildschirm-Print-Vorschau zeigt genau diese 0.091 mm (`printStrokeVb(0.091,
N)`); der PDF-Export klemmt via `quantizePen` auf `MIN_PEN_MM = 0.13` mm —
**+43 % dicker im Druck als in der Vorschau.**
- Türschwenkbogen: `doorLwMm * 0.6` (`generatePlan.ts:902`) mit dem üblichen
Fallback `doorLwMm = LAYER_LINE_MM = 0.13` (`generatePlan.ts:354`) ergibt
`0.078 mm`. Screen zeigt 0.078 mm (nahezu unsichtbar dünn bei kleinen
Massstäben), PDF klemmt auf 0.13 mm — **+67 %.**
- Wand-Umriss im Detailgrad "grob": `outlineMm = wallLwMm ·
OUTLINE_DETAIL_FACTOR.grob (1.6)` (`generatePlan.ts:84-88, 648, 796`); beim
häufigen Fallback `WALL_FALLBACK_MM = 0.18` (`generatePlan.ts:97`) ergibt das
`0.288 mm`. `quantizePen(0.288)` rundet auf die nächste Stufe `0.35`
(`PEN_STEPS`, da `0.25 < 0.288 ≤ 0.35`) — **+21.5 % im Druck.**
Das systematische Muster: der Detailgrad "fein" existiert explizit, um
Nebenlinien DÜNNER zu machen (`generatePlan.ts:52-58`,
`LAYER_DETAIL_FACTOR.fein = 0.7`) — aber sobald der berechnete Wert unter
`MIN_PEN_MM` fällt, hebt die PDF-Quantisierung ihn wieder auf die Untergrenze
an, ohne dass die Bildschirm-Vorschau (weder SVG-Print noch GPU) davon
irgendetwas zeigt. Wer die Druckvorschau am Bildschirm beurteilt, sieht NICHT,
was tatsächlich gedruckt wird.
### 3. Stempel-Zeilenabstand ist nur bei Massstab 1:100 exakt — SVG- und RenderScene-Pfad nutzen zwei verschiedene Formeln für dieselbe Grösse
Bereits als bekannte Alt-Lücke in HANDOVER.md (Commit `382771b`, Punkt 6)
vermerkt; hier präzise verortet:
- SVG (`PlanView.tsx:2738-2739`):
```ts
const nRef = print ? paperScale! : 100;
const unitPerPt = (1 / 72) * 0.0254 * nRef * PX_PER_M;
```
`nRef` folgt im Druckmodus dem TATSÄCHLICH aktiven `paperScale` — der
Zeilenabstand (`lineGap = baseFs * 1.3`, `PlanView.tsx:2741`) ist für jedes
`N` korrekt in viewBox-Einheiten, weil er direkt aus `unitPerPt` (das `N`
enthält) abgeleitet wird.
- RenderScene (`toRenderScene.ts:163-165`):
```ts
const STAMP_REF_N = 100;
const MM_TO_M = STAMP_REF_N / 1000;
```
und (`toRenderScene.ts:481-483`):
```ts
const baseH = baseMm * MM_TO_M; // Basisgröße in Modell-Metern
const lineGap = baseH * 1.3;
```
Hier ist der Umrechnungsfaktor `MM_TO_M` FEST auf `N = 100` verdrahtet,
unabhängig davon, mit welchem `N` der WASM-Viewport oder der PDF-Export
tatsächlich rendert (`paper_scale_n` in `gpu.rs`, `opts.scaleDenominator` in
`sceneToPrintSvg.ts`). Bei `N = 100` sind beide Formeln identisch (das war
offenbar der Verifikationsfall in `382771b`); bei jedem anderen `N` — z. B.
1:50 — driftet der vertikale Zeilenabstand der Stempel-Mehrzeiler
proportional zum Verhältnis `N_wahr/100` auseinander, während die einzelne
Zeilenhöhe (`sizeMm`) selbst korrekt bleibt. Betroffen: WASM-Viewport (2c)
UND PDF-Export (2d) gleichermassen (beide beziehen `RText` aus derselben
`toRenderScene`-Funktion); die WebGL2-Ansicht ist NICHT betroffen, weil sie
Text grundsätzlich nicht selbst zeichnet, sondern die SVG-Overlay-Ebene
weiterverwendet (`PlanView.tsx:1592`,
`useGpuRenderer ? wantWasm || p.kind !== "text" : ...` — bei WebGL2
(`wantWasm=false`) werden Text-Primitive NIE aus dem SVG-Rendering
herausgefiltert).
### 4. Schraffur-Strichbreite im PDF hängt vom GEWÄHLTEN Export-Massstab ab, nicht vom Massstab der Live-Vorschau
`hatchPx` wird in `toRenderScene.ts:374-375` genau einmal, massstabsunabhängig
berechnet:
```ts
const hatchMm = p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13;
const hatchPx = Math.max(0.6, hatchMm * (1 / 0.13));
```
— eine reine viewBox-Grösse (analog zum SVG-`<pattern>`-Strich, der
absichtlich mit dem Zoom mitskaliert, damit die Schraffur bei jedem Zoom
gleich dicht aussieht: Kommentar `toRenderScene.ts:371-373`). Erst beim
Export wird daraus in `sceneToPrintSvg.ts:243`
`effectiveMm = (widthMm / PX_PER_M) * mmPerM` — und `mmPerM = 1000 /
opts.scaleDenominator` (`sceneToPrintSvg.ts:101`) verwendet den vom
Export-Dialog gewählten Massstab, NICHT den `paperScale`, den der Nutzer
gerade in der "Print"-Live-Vorschau sieht (`exportPdf.ts` ruft
`sceneToPrintSvg` mit `opts.scaleDenominator` aus den Export-Optionen,
`exportPdf.ts:61-79`, völlig unabhängig vom `PlanView`-State).
Beispiel: `lineWeight = 0.13` mm (Standard-Fuge, `getHatch`-Default) →
`hatchPx = max(0.6, 0.13 · (1/0.13)) = 1.0` viewBox-Einheiten.
- Export bei 1:50 (`mmPerM = 20`): `effectiveMm = (1.0/90)·20 = 0.222 mm` →
`quantizePen` → **0.25 mm**.
- Export bei 1:200 (`mmPerM = 5`): `effectiveMm = (1.0/90)·5 = 0.056 mm` →
auf `MIN_PEN_MM` geklemmt → **0.13 mm**.
Fast die doppelte Strichstärke allein durch die Wahl des Export-Massstabs,
bei UNVERÄNDERTER Quell-`lineWeight` — und ohne dass die Bildschirm-
"Print"-Vorschau (die ja mit dem live gemessenen `paperScale` arbeitet,
nicht mit `opts.scaleDenominator`) das anzeigen könnte, solange beide Werte
nicht zufällig übereinstimmen.
### 5. Dieselbe Formel existiert vierfach, nur durch Kommentare (nicht durch Typen/Code) synchron gehalten
`PX_PER_M = 90` ist unabhängig deklariert in: `PlanView.tsx:23` (SVG),
`glPlan/glPlanRender.ts:12` (WebGL2), `tessellate.rs:22` (Rust, geteilt
zwischen WASM-Viewport und Headless/Golden-Test) und `sceneToPrintSvg.ts:64`
(PDF-Serializer, mit explizitem Kommentar "MUSS mit dem dortigen
uebereinstimmen, sonst driftet die Schraffur-Dichte des PDFs vom Viewport
ab", `sceneToPrintSvg.ts:61-62`). Die Hatch-Dichte-Formel
`Math.max(0.6, weightMm · (1/0.13))` ist separat dupliziert in
`PlanView.tsx:2344-2345` (`hatchStrokePx`, SVG-`<pattern>`) und
`toRenderScene.ts:375` (`hatchPx`, GPU + PDF) — beide mit demselben
"magischen" Faktor `1/0.13`, ohne gemeinsame Konstante. Aktuell sind alle vier
Kopien konsistent; das Risiko ist rein prospektiv (nächste Tuning-Änderung an
einer Stelle vergisst die anderen drei) — aber genau das ist der
Mechanismus, über den Findings wie 1-4 überhaupt erst entstehen können, ohne
dass ein Typfehler oder Test anschlägt.
### 6. Drei unabhängige, nicht aufeinander abgestimmte Mindestbreiten-Politiken
- SVG-Print: `Math.max(1e-4, ...)` (`PlanView.tsx:2599`) — praktisch keine
Untergrenze, verlässt sich auf das Antialiasing des Browsers.
- GPU (WebGL2 UND WASM, Linien UND Bögen): harter Floor von **0.6
Geräte-Pixel** (`shaders.rs:91,209`; `glPlanRender.ts:243-249`,
Kommentar "klemmt bei ~0.6 px").
- PDF: **`MIN_PEN_MM = 0.13` mm** (`sceneToPrintSvg.ts:38` in
`planToPrintSvg.ts`, äquivalent `sceneToPrintSvg.ts:47`) — eine
Papier-mm-Grösse, kein Pixelwert, konzeptionell etwas anderes als die
GPU-Pixel-Untergrenze.
Auswirkung: bei sehr kleinem Massstab (weit rausgezoomt oder grosses `N`,
z. B. Übersichtsplan 1:500) hält der GPU-Pfad sehr dünne Linien künstlich bei
0.6 px sichtbar, während dieselbe Linie im SVG-Pfad fast verschwindet und im
PDF auf eine ganz andere (mm-basierte, massstabsabhängige) Grösse geklemmt
wird. Kein Pfad kennt die Politik der anderen beiden. Niedrigere Priorität
als 1-4, weil hier keine der drei Politiken die *exportierte* Papier-mm-Wahrheit
verändert (die bleibt PDF-exklusiv über `MIN_PEN_MM`) — es geht nur um
Bildschirm-Konsistenz zwischen SVG/GPU bei Extremzoom.
### 7. Toter Zweitpfad `planToPrintSvg.ts` dupliziert PEN_STEPS/mm-Logik komplett, unbenutzt aber vorhanden
`src/export/planToPrintSvg.ts` trägt seit `382771b` einen Kopfkommentar
"ERSETZT durch sceneToPrintSvg.ts … wird vom PDF-Export NICHT MEHR
verwendet" (`planToPrintSvg.ts:1-6`) und ist tatsächlich nirgends mehr
importiert (verifiziert per Grep über `src/`). Er enthält jedoch weiterhin
eine eigene, unabhängige Kopie von `PEN_STEPS`/`MIN_PEN_MM`/`quantizePen`
(`planToPrintSvg.ts:35,38,40-45`) und einer kompletten Plan→SVG-Serialisierung
inkl. eigener `PX_PER_M`-Handhabung (`planToPrintSvg.ts:326`). Kein aktiver
Bug, aber eine Falle: Copy-Paste-Wiederverwendung dieses Altpfads (z. B. für
einen zukünftigen DXF/PNG-Export) würde eine dritte, potenziell abweichende
Quantisierungs-Tabelle in den Baum ziehen.
## 4. Vorgeschlagene Fixes
### zu Finding 1 (Haarlinien-Modus im GPU-Pfad)
Kleinste, korrekte Lösung: den `hairline`-Zustand als eigenen Parameter bis
in den Shader durchreichen, analog zu `paper_scale_n`/`text_scale_n`.
- **Rust (`src-tauri/render2d/src/gpu.rs`)**: neues Feld
`pub hairline: bool` auf `Renderer` (Default `false`, neben `paper_scale_n`
bei `gpu.rs:422`). In `Renderer::render` (`gpu.rs:606-616`): wenn
`self.hairline`, `mm_px` NICHT aus `mm_to_device_px(...)` berechnen, sondern
auf einen konstanten Geräte-px-Wert setzen (z. B. `1.0`), UND dafür sorgen,
dass der Shader `width_mm` in diesem Fall ignoriert (sonst bleibt die
RELATIVE Differenz zwischen z. B. 0.13 mm und 0.35 mm bestehen, nur global
skaliert — nicht das gewünschte "alle Linien exakt 1 px"). Sauberster Weg:
ein neues `hairline: u32`-Feld in die pro-Frame-Uniform (`Globals`,
`shaders.rs:20-23` und `ArcGlobals`), im Fragment-/Vertex-Shader
(`shaders.rs:91`, `LINE_WGSL`; `shaders.rs:209`, `ARC_WGSL`) per
`select(...)` auf einen festen `1.0`-px-Wert umschalten statt
`max(0.6, stroke_px * stroke_scale)`.
- **`src-tauri/render2d/src/web.rs`**: neue Methode `set_hairline(&mut self,
on: bool)` neben `set_paper_scale`/`set_text_scale`
(`web.rs:146-155`-Nachbarschaft).
- **`src/plan/useWasmPlanRenderer.ts`**: `render()`
(`useWasmPlanRenderer.ts:131-155`) um einen `hairline: boolean`-Parameter
erweitern, `r.set_hairline(hairline)` vor `r.render()` aufrufen.
- **`src/plan/useGlPlanRenderer.ts`**: analog — `render()`
(Signatur aktuell `(viewBox, paperScaleN=100)`) um `hairline` erweitern;
im WebGL2-Shader-Uniform-Pfad (`glPlanRender.ts:159-165,213-215`)
`mmToDevicePx` bei `hairline===true` durch einen konstanten Wert ersetzen
und im Fragment-Shader denselben `select`-Trick wie oben anwenden
(`glPlanShaders.ts`, dort wo "~0.6 px"-Klemmung passiert, siehe Finding 6).
- **`src/plan/PlanView.tsx`**: `paperScaleForGl` (`PlanView.tsx:475-476`)
bleibt wie sie ist (wird weiter für den Massstab-Nenner gebraucht, sobald
der Nutzer zurück auf "Print" schaltet); stattdessen an allen drei
`renderGl(...)`-Aufrufstellen (`PlanView.tsx:494,502,1080`) zusätzlich
`hairline` durchreichen: `renderGl(view, paperScaleForGl(view),
textScaleForGl(), hairline)`.
### zu Finding 2 (PEN_STEPS nur im PDF)
Zwei mögliche Richtungen, je nach gewünschtem Produktverhalten:
- **Option A (Vorschau matcht Druck)**: `quantizePen`
(`sceneToPrintSvg.ts:50-54`) in ein gemeinsames Modul auslagern (z. B.
`src/plan/penSteps.ts`, exportiert `PEN_STEPS`, `MIN_PEN_MM`,
`quantizePen`), von `sceneToPrintSvg.ts` UND von `PlanView.tsx`
(`printStrokeVb`, `PlanView.tsx:2598-2600`) importieren und dort ebenfalls
anwenden: `printStrokeVb(mm, n) = Math.max(1e-4, (quantizePen(mm) * n) /
1000) * PX_PER_M`. Für den GPU-Pfad müsste die Quantisierung dann VOR dem
Scene-Bau passieren (in `toRenderScene.ts`, auf `widthMm` jeder
`RLine`/`ROutline`/`RPolyline`/`RArc`, ausgenommen `widthScreen`-Einträge),
damit WASM/WebGL2-Viewport dieselben Stufen zeigen wie SVG und PDF.
- **Option B (Vorschau bleibt Rohgrösse, aber sichtbar gemacht)**: falls die
feinkörnige Vorschau bewusst erhalten bleiben soll, zumindest den
Stiftstufen-Sprung an der Stelle sichtbar machen, an der die Detailgrad-
Faktoren definiert werden (`generatePlan.ts:84-93`) — z. B. ein
Entwickler-/Lint-Test, der bei jedem `OUTLINE_DETAIL_FACTOR`/
`LAYER_DETAIL_FACTOR`-Wert prüft, ob das Produkt mit den üblichen
Fallback-`weightMm`-Werten (`WALL_FALLBACK_MM`, `LAYER_LINE_MM`) exakt auf
einer `PEN_STEPS`-Stufe landet, und sonst warnt.
Empfehlung: Option A — sie erfüllt den Nordstern wörtlich ("Bildschirm bei
jedem Massstab = Druck").
### zu Finding 3 (STAMP_REF_N)
In `toRenderScene.ts` `STAMP_REF_N = 100` (`toRenderScene.ts:163`) durch den
tatsächlichen Ziel-Massstab ersetzen. `planToRenderScene(plan)` kennt aktuell
keinen `N`-Parameter (`exportPdf.ts:73` ruft es ohne Massstabsangabe auf) —
die Funktion müsste ein optionales `paperScaleN`-Argument bekommen (Default
100, um den Viewport-Aufruf ohne Massstabskontext — `nativeSync.ts` — nicht
zu brechen, dort ist 100 ohnehin die richtige Referenz für den WASM-Viewport
im Anzeigemodus, s. `gpu.rs:422`), und `exportPdf.ts:73` müsste
`planToRenderScene(plan, opts.scaleDenominator)` aufrufen. `MM_TO_M`
(`toRenderScene.ts:165`) dann aus diesem Parameter statt aus der Konstante
ableiten.
### zu Finding 4 (Hatch-mm im PDF folgt dem Export-N, nicht dem Vorschau-N)
Kein Bug im engeren Sinn (die Formel ist in sich konsistent — die Schraffur
soll ja pro *gewähltem Blatt-Massstab* eine sinnvolle Dichte haben), aber die
Bildschirm-„Print"-Vorschau sollte denselben `N` verwenden, den der
Export-Dialog tatsächlich benutzen wird, sonst lügt die Vorschau. Fix:
`paperScale` in `PlanView.tsx` beim Öffnen des Export-Dialogs (bzw. der
Export-Dialog selbst) mit `opts.scaleDenominator` vorbelegen/synchronisieren,
statt zwei unabhängige State-Quellen zu pflegen — Ort: dort, wo der
Export-Dialog `scaleDenominator` initialisiert (App.tsx, PDF-Export-UI) einen
Default aus `paperScale`/`liveScale` (`App.tsx`, `onScale`-Callback,
`PlanView.tsx:794`) übernehmen.
### zu Finding 5 (vierfache Formel-Duplikation)
Eine gemeinsame TS-Konstantendatei `src/plan/renderConstants.ts` mit
`PX_PER_M = 90`, `HATCH_DENSITY_FACTOR = 1/0.13`, `HATCH_MIN_PX = 0.6`
anlegen; `PlanView.tsx`, `glPlan/glPlanRender.ts`, `toRenderScene.ts`,
`sceneToPrintSvg.ts` importieren daraus statt eigener Literale. Für die
Rust-Seite (`tessellate.rs:22`, `shaders.rs:142`) ist echtes Teilen über die
Sprachgrenze hinweg nicht trivial — dort bleibt nur ein Kommentar-Link auf die
TS-Konstante plus ein Parity-Test (siehe Abschnitt 5), der bei Abweichung
fehlschlägt statt bei stillem Drift.
### zu Finding 6 (drei Mindestbreiten-Politiken)
Niedrige Priorität, aber falls angegangen: den 0.6-px-Floor aus den Shadern
(`shaders.rs:91,209`, `glPlanShaders.ts`) als benannte Konstante
exportieren/dokumentieren und explizit von `1e-4` (SVG) und `MIN_PEN_MM=0.13`
(PDF) abgrenzen — mindestens per Kommentar klarstellen, dass die drei
UNTERSCHIEDLICHE Zwecke haben (GPU: Pixel-Sichtbarkeits-Floor;
PDF: Papier-mm-Stiftgrenze) und NICHT synchronisiert werden müssen, damit
niemand versehentlich versucht, sie anzugleichen und dabei die jeweils
andere Semantik bricht.
### zu Finding 7 (toter Pfad)
`src/export/planToPrintSvg.ts` entfernen (nach kurzer Rücksprache, ob der
Kopfkommentar-Vermerk "bleibt nur als Referenz stehen" noch gewollt ist) oder,
falls er als Referenz bleiben soll, seine `PEN_STEPS`/`quantizePen`-Kopie
durch einen Import aus der in Finding 2 vorgeschlagenen gemeinsamen
`penSteps.ts` ersetzen.
## 5. Verifikations-Rezept
Was schon existiert:
- `scripts/probe-engine-parity.mjs` vergleicht `?gl=0` gegen `?engine=wasm`
rein visuell (Screenshot-Diff von Auge, `probe-parity-svg.png` vs.
`probe-parity-wasm.png`) — prüft NICHT quantitativ, ob Strichbreiten in
Pixeln übereinstimmen, und deckt weder den Haarlinien-Modus noch den
WebGL2-Default-Pfad noch den PDF-Export ab.
- `src-tauri/render2d/tests/golden.rs` vergleicht die Demo-Szene
Pixel-für-Pixel gegen `tests/golden/demo.png` (Toleranz: Kanal-Delta > 2 auf
< 0.5 % der Pixel, `docs/design/engine-headless.md`) — ein reiner
GPU-Regressionstest, ohne SVG- oder PDF-Vergleich.
- Die einzige bisher dokumentierte mm-genaue Messung ist manuell:
HANDOVER.md, Commit `382771b` — `pdftoppm`-Messung eines exportierten PDFs
(53.51×43.69 mm gegen erwartete 53.45×43.45 mm bei 1:100), einmalig, nicht
als wiederholbares Skript hinterlegt.
Um den Nordstern ("Bildschirm bei jedem Zoom/Massstab = Druck") tatsächlich
beweisbar zu machen, fehlt ein quantitativer End-to-End-Test, der:
1. Für eine feste Test-Szene (idealerweise die bestehende `demo::demo_scene`
aus `src-tauri/render2d/src/demo.rs`, die bereits sowohl einen `width_mm`-
als auch einen `width_screen`-Strich enthält, `demo.rs:34,49-50`) bei
mehreren `N` (1:10, 1:50, 1:100) UND mehreren Zoomstufen:
- den PDF-Export erzeugt und via `pdftoppm -r <dpi>` in ein PNG rendert,
dort die Strichbreite in Pixeln misst und in mm zurückrechnet (bekannte
DPI ⇒ bekannte mm/px);
- denselben Frame headless über `HeadlessRenderer::render_to_image`
(`src-tauri/render2d/src/headless.rs`) mit identischem `paper_scale_n`
rendert und dieselbe Pixel-Breite misst;
- beide mm-Werte gegen den erwarteten `mm · N/1000`-Sollwert UND
gegeneinander vergleicht, mit einer Toleranz, die die PEN_STEPS-Rundung
(Finding 2, sobald behoben: siehe Option A) einbezieht.
2. Den Haarlinien-Modus separat abdeckt: ein Screenshot-Vergleich bei zwei
verschiedenen Zoomstufen im `lineMode:"display"` — die gemessene
Pixelbreite MUSS bei beiden Zoomstufen identisch sein (Beweis, dass
Finding 1 behoben ist), sowohl für WebGL2 (`?gl` ohne `=0`, App-Default)
als auch für WASM (`?engine=wasm`).
3. `probe-engine-parity.mjs` um eine tatsächliche Pixel-Differenz-Metrik
erweitert (statt nur Kindanzahl/Warnungen zu loggen wie aktuell
`probe-engine-parity.mjs:36-44`) — z. B. eine bekannte Referenzlinie im
Testmodell an fester Bildschirmposition, deren Strichbreite in beiden
Screenshots per Pixel-Sampling gemessen und verglichen wird.
Bis dieser Test existiert, bleibt jede Aussage über "Bildschirm = Druck" eine
Behauptung, die nur durch manuelles `pdftoppm`-Nachmessen einzelner
Stichproben gedeckt ist — die in diesem Audit gefundenen Divergenzen (v. a.
Finding 1 und 2) hätten mit den heutigen Probes nicht auffallen können, weil
keiner von ihnen den Default-Zustand der App (`lineMode:"display"`, WebGL2)
gegen den Druckpfad misst.
+186
View File
@@ -0,0 +1,186 @@
# Schnitt/Ansicht-Pipeline: analytische Prismen-Projektion (`render3d::section`)
> Gegenstueck zum OCCT-WASM-HLR-Spike (`docs/welle-c-hlr-spike/FEASIBILITY.md`):
> statt eines generischen CAD-Kernels nutzt dieser Ansatz eine Invariante des
> Modells, um Schnitt/Ansicht rein analytisch (kein Hidden-Line-Removal-Solver)
> zu berechnen. Implementiert in `src-tauri/render3d/src/section.rs`.
## Ansatz
Jedes Bauteil in diesem Modell ist ein **Prisma**: ein 2D-Grundriss-Fussabdruck-
Polygon (Wand-Band bzw. Decken-Umriss, siehe `mesh.rs`), konstant extrudiert
ueber ein Hoehenintervall `[z0, z1]`. Diese Einschraenkung — konstanter
Querschnitt ueber die gesamte Hoehe — macht die Schnittgeometrie trivial im
Vergleich zu generischem HLR:
- **Schnitt einer vertikalen Ebene mit einem Prisma** = 2D-Geraden/Polygon-
Clipping IM GRUNDRISS (die Schnittebene projiziert im Grundriss auf eine
Linie) → ein oder mehrere u-Intervalle, in denen die Linie das Fussabdruck-
Polygon durchquert. Jedes Intervall × `[z0, z1]` ist das Cut-Rechteck. Da die
Hoehe unabhaengig von der Grundriss-Position ist, ist das Ergebnis **immer
ein Rechteck**, nie ein Trapez — auch bei diagonalen Wandachsen.
- **Projektion/Ansicht** (Kanten hinter der Ebene, in Blickrichtung) reduziert
sich auf ein **2D-Sichtbarkeitsproblem im Grundriss** kombiniert mit einem
Hoehen-Ueberlappungstest: da alle Seitenflaechen der Prismen vertikal sind,
genuegt ein Sichtstrahl-Test im Grundriss (verdeckt ein naeheres Prisma-
Fussabdruckpolygon die Sichtlinie?) plus Ueberlappung der Hoehenintervalle.
Das ersetzt einen 66-MB-WASM-CAD-Kernel durch closed-form Arithmetik in reinem
Rust, ohne zusaetzliches Laufzeitgewicht ueber `render3d` hinaus.
## `SectionOutput`-Format
Modul: `render3d::section`. Alle Groessen in **Metern**.
```rust
pub struct SectionPlane { pub point: [f32; 3], pub normal: [f32; 3] }
pub struct SectionOutput {
pub cut_polygons: Vec<CutPolygon>, // JSON: "cutPolygons"
pub visible_edges: Vec<SectionEdge>, // JSON: "visibleEdges"
pub hidden_edges: Vec<SectionEdge>, // JSON: "hiddenEdges"
}
pub struct CutPolygon { pub component: ComponentRef, pub color: Rgb, pub pts: Vec<[f32; 2]> }
pub struct SectionEdge { pub component: ComponentRef, pub a: [f32; 2], pub b: [f32; 2] }
pub struct ComponentRef { pub kind: ComponentKind /* Wall | Slab */, pub index: usize }
```
**Koordinatensystem der Ausgabe (u, v):**
- Ursprung: `SectionPlane::point`, projiziert.
- `u` (horizontal): Strecke entlang der Schnittebene, senkrecht zur
Blickrichtung, berechnet als `normalize(cross(normal, world_up))` — dieselbe
rechtshaendige Konvention wie `math::look_at`s `right`-Vektor. Bei den vier
Standard-Konstruktoren (`looking_plus_x/minus_x/plus_y/minus_y`) ist `u`
direkt die jeweils andere Grundriss-Achse.
- `v` (vertikal, "Hoehe"): `v = world.y`, ABSOLUT. Diese Codebasis ist
durchgaengig Y-up (`types.rs`/`mesh.rs`/`math.rs`: „world.y = Hoehe"); `v`
folgt bewusst dieser etablierten Konvention statt einer wortwoertlichen
„world.z"-Lesart, um modulübergreifend konsistent zu bleiben.
- Nur **vertikale** Schnittebenen (Normale ohne Hoehen-Komponente) sind
unterstuetzt — Grundriss-/Horizontalschnitte bleiben Sache der bestehenden
2D-Plan-Pipeline.
`cut_polygons` sind bei diesem Modell immer Rechtecke (siehe oben), aber als
generischer Punktering abgelegt — kompatibel zu `render2d::types::FillPolygon`
(`pts: Vec<Point>`), dem vorgesehenen Zielformat fuer die spaetere 1:1-
Uebersetzung in die Plan-/Schnitt-Ansicht.
## Oeffnungen (Tueren/Fenster)
`WallInput` hat seit diesem Nachtrag ein Feld `openings: Vec<Opening>`
(`types.rs`), unabhaengig von der reinen Achse/Dicke/Hoehe: je Oeffnung ein
Intervall entlang der Wandachse (`from`/`to`, Meter ab `start`) plus Bruestungs-
und Kopfhoehe (`sill`/`height`, relativ zu `base_elevation`). Eine Tuer ist der
Sonderfall `sill == 0.0`.
**Cut-Polygone.** Trifft die Schnittlinie eine Wand exakt im Bereich einer
Oeffnung, splittet `wall_cut_rectangles` das sonst einzelne Vollrechteck
`[z0,z1]` in bis zu ZWEI Teil-Rechtecke: Bruestung `[z0, z0+sill]` und Sturz
`[z0+sill+height, z1]`. Gewaehlt wurde diese Mehrfach-Rechteck-Darstellung
BEWUSST gegenueber einem Loch-Polygon (Ring mit Aussparung): mehrere einfache,
konvexe Ringe sind direkt kompatibel zum Zielformat `render2d::types::
FillPolygon` (nur einfache Ringe, kein Loch-Format), waehrend ein
Loch-Polygon eine zusaetzliche Datenstruktur (Ring-mit-Loch) noetig gemacht
haette, die die Zielstruktur (noch) nicht kennt. Ausserhalb einer Oeffnung
bleibt es beim einzelnen Vollrechteck (Regressionsfall).
Die Zuordnung "welche Wandachsen-Position entspricht dieser Schnittposition"
ist fuer den STANDARDFALL (Schnittebene senkrecht zur Wandachse — der uebliche
architektonische Wandquerschnitt) EXAKT konstant ueber das Cut-Intervall. Fuer
schraege Wand/Ebene-Kombinationen wird sie als AFFINE Funktion (`axis_map`)
exakt mitgefuehrt (keine Naeherung noetig, da die Abbildung linear ist).
**Projektion/Ansicht.** Eine Wand mit Oeffnungen bekommt zusaetzlich zur
Boxen-Drahtsilhouette die vier Rahmenkanten jeder Oeffnung (zwei Leibungen,
Sturz, Bruestung), berechnet auf der Wandachsen-Mittellinie (dieselbe
Vereinfachung wie die Bounding-Box-Verdeckung generell — keine eigene
Dicken-Aufloesung der Leibungsflaeche). Fuer die Verdeckung wird jede
Wand-Bounding-Box um ihre Oeffnungen als "Loch" reduziert
(`PrismBounds::opening_voids`): ein anderes (oder dasselbe) Bauteil hinter der
Wand wird in genau dem (u, Hoehe)-Rechteck der Oeffnung NICHT von dieser Wand
verdeckt — "Durchblick". Die eigenen Rahmenkanten einer Oeffnung werden dabei
NICHT gegen das eigene Bauteil auf Verdeckung geprueft (ein Loch kann sich
nicht selbst verdecken); gegen alle anderen Bauteile gilt die normale
Verdeckungslogik unveraendert (inkl. der bereits dokumentierten
Selbstverdeckung der Rueckseite eines ANDEREN Bauteils durch dessen eigene
Vorderseite).
GENAUIGKEIT (bewusste Vereinfachung): die u-Zuordnung fuer den Durchblick ist
nur DANN aussagekraeftig, wenn die Wandachse hinreichend parallel zur u-Achse
der Schnittebene steht — das ist GENAU der Fall, in dem man die Wand als
Elevation/Ansicht von vorne sieht (und ein Fenster ueberhaupt als Durchblick
sichtbar waere). Steht die Wand naeher an "senkrecht zur u-Achse" (Wand auf
Kante gesehen bzw. der reine Cut-Fall), wird die Durchblick-Berechnung
uebersprungen und die Wand bleibt fuer die Verdeckung VOLL UNDURCHSICHTIG
(konservativ hidden) — Schwelle `AXIS_ALIGN_EPS = 1e-3` in `section.rs`. Ein
Kante-auf-Kante gesehenes Fenster liefert ohnehin keine sinnvolle
Durchblick-Flaeche in der Projektion.
Getestet in `section::tests` (u. a. `schnitt_durch_fenster_liefert_bruestung_
und_sturz`, `schnitt_neben_dem_fenster_liefert_volles_rechteck`,
`ansicht_zeigt_fensterrahmen_sichtbar_und_durchblick_bei_dahinterliegender_
kante`) und im Beweis-SVG (`examples/section_svg.rs`): Schenkel A der L-Wand
hat dort ein Fenster, eine kurze zusaetzliche Wand steht dahinter genau im
Fensterband und erscheint im SVG als durchgezogene (sichtbare) Linie zwischen
Bruestungs- und Sturzhoehe, gestrichelt (verdeckt) darueber/darunter.
## Bekannte Luecken
- **Keine Wandknoten-Verschneidung (T-/X-Stoesse).** Wie in `mesh.rs`
(M1-Stand) werden Waende als eigenstaendige, stumpf abgeschlossene Quader
behandelt, die sich an Ecken UEBERLAPPEN statt sich zu vereinen (kein
Miter-Join). Der Schnitt-Extraktor erbt das: an einem L-/T-Knoten kann eine
Wand als „in die andere eingebettet" verdeckt erscheinen (im Testmodul
`section::tests` bewusst als reales, erwartetes Verhalten dokumentiert und
geprueft — kein Bug dieses Moduls, sondern ein Artefakt der fehlenden
Verschneidungslogik weiter oben in der Pipeline).
- **Oeffnungen sind NICHT in der Vollkoerper-Extrusion (`mesh.rs`)
nachgezogen.** `wall_prism`/`wall_cut_rectangles`/die Verdeckung in
`section.rs` werten `WallInput::openings` vollstaendig aus (siehe Abschnitt
"Oeffnungen" oben), aber `mesh::extrude_wall` extrudiert weiterhin die volle
Wandflaeche ohne Aussparung (`mesh.rs`-Moduldoc: „Oeffnungen kommen in
spaeteren Milestones"). Cut-/Ansichts-Pipeline und 3D-Solid-Mesh sind bis zum
Nachziehen von `mesh.rs` also bewusst inkonsistent — eine bekannte, separate
Luecke (nicht Gegenstand dieses Nachtrags).
- **Oeffnungs-Durchblick nutzt dieselbe achsparallele Bounding-Box-Naeherung**
wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche
gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe
"senkrecht zur u-Achse" konservativ auf "kein Durchblick" zurueckgestuft
(siehe Abschnitt "Oeffnungen" oben, `AXIS_ALIGN_EPS`).
- **Keine echte Component-/Material-Id.** `WallInput`/`SlabInput` haben aktuell
keine eigene Id; `ComponentRef` referenziert daher nur `(Art, Index im
Eingabe-Array)` + die rohe Albedo-Farbe als Material-Platzhalter. Sobald ein
echtes Ressourcen-/Material-System existiert, sollte `ComponentRef` auf eine
stabile Id umgestellt werden (Indizes sind nicht stabil ueber Modell-Edits).
- **Verdeckungstest nutzt Bounding-Boxen, nicht die exakte Fussabdruckform.**
Fuer Wand-Baender und (i. d. R. konvexe) Deckenumrisse ist das exakt; bei
stark konkaven oder diagonalen Grundrissen kann es zu Ueberverdeckung
fuehren (ein Prisma blockiert dann auch Bereiche seiner eigenen „Nischen").
- **Kein generisches HLR.** Der Ansatz funktioniert NUR, weil alle Bauteile
Prismen mit konstantem Querschnitt sind. Fuer echte gekrümmte oder nicht-
prismatische Geometrie (Bogenwaende, Freiformdaecher, …) waere er nicht
anwendbar — dafuer bliebe der OCCT-Weg (oder eine eigene, generischere
HLR-Implementierung) die Referenz.
- **Keine robuste Sonderfallbehandlung fuer Vertices exakt auf der
Schnittlinie** (Toleranz-basiert, kein Tie-Breaking/Pertubation) — die
Testszenarien vermeiden diesen Fall bewusst.
## Vergleich zum OCCT-WASM-Spike
| | OCCT-WASM (`docs/welle-c-hlr-spike`) | Dieser Ansatz (`render3d::section`) |
|---|---|---|
| Verfahren | Generisches HLR (`HLRAppli_ReflectLines`) | Analytische Prismen-Projektion |
| Anwendbarkeit | Beliebige BREP-Geometrie | Nur Prismen (konstanter Querschnitt über Höhe) |
| Zusaetzliches Gewicht | ~62,8 MB WASM (~19,6 MB gzip), separater Lazy-Chunk | Keins — reines Rust in `render3d`, kein weiteres WASM-Asset |
| Modul-Init | ~450 ms (Browser) / ~680 ms (Node) | Kein Initialisierungsschritt (kein Fremd-Modul zu laden) |
| Rechenzeit (L-Wand + Platte) | ~21 ms (reiner HLR-Lauf, gemessen im Browser) | Nicht separat gemessen (kein Millisekunden-Timer im Test); die Operationen sind reine Vektor-/Intervall-Arithmetik über wenige Kanten (O(Anzahl-Prismen × Kanten-pro-Prisma) mit kleinen Konstanten) und liegen der Groessenordnung nach klar unter 1 ms fuer Szenen dieser Groesse — eine belastbare Messung steht noch aus |
| Sichtbarkeit (verdeckte Kanten) | Exakt (echter HLR-Solver) | Naeherung über Bounding-Boxen im Grundriss + Hoehenintervall-Ueberlappung; exakt fuer achsparallele/konvexe Fussabdruecke |
| Reifegrad | Isolierter Spike, nicht verdrahtet | Isolierter Spike (dieses Modul), nicht in `render2d`/die Plan-Ansicht verdrahtet |
**Fazit:** Fuer den ueberwiegenden Regelfall dieses Projekts (Waende, Decken —
alles Prismen) ist die analytische Loesung der pragmatischere Weg: kein
zusaetzliches WASM-Gewicht, keine Fremd-Bibliothek, headless testbar wie der
Rest von `render3d`. Der OCCT-Weg bleibt die Referenz, falls/sobald echte
generische Volumenkoerper (Booleans, gekrümmte Flaechen) ins Modell kommen.
+498
View File
@@ -0,0 +1,498 @@
# Design — Ebenen-Darstellung (Layer Display Settings)
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
> Ressourcen/Stile: [resources-graphics.md](resources-graphics.md). Output/Pläne:
> [plans-output.md](plans-output.md). Kontextmenü/Inline-Editor:
> [context-menu.md](context-menu.md).
Dieses Dokument spezifiziert die **per-Ebene Darstellungseinstellungen** auf der
`LayerCategory` (Grafik-Kategorie) und den dazugehörigen Editor
„Ebeneneinstellungen…", der aus dem Ebenen-Kontextmenü geöffnet wird.
Heute trägt jede `LayerCategory` nur eine flache Strichstärke (`lw`), eine `color`
und eine optionale `hatch` (ein freier String, der nirgends aufgelöst wird). Das
reicht nicht: Eine Ebene soll — wie in Vectorworks/DOSSIER — einen vollständigen
**Stift (PEN)** und eine vollständige **Standard-Schraffur (HATCH)** definieren,
die beim Rendern angewandt werden. Bezeichner englisch, Prosa/UI-Text deutsch
(CONVENTIONS.md).
---
## 1. Zielbild
Jede Ebene definiert zwei Darstellungs-Aspekte, die in den Grundriss-Generator
einfließen:
- **PEN** — Linienstil der Ebene: `type` (durchgezogen / gestrichelt / …),
`color` und `lw` (Strichstärke in mm Papier). Steuert alle Umriss-/Symbol-Linien
der Elemente dieser Ebene (Wand-Umriss, Tür-Symbol, Referenzlinie).
- **HATCH** — Standard-Schraffur der Ebene: `pattern`, `scale`, `angle` und die
`lineWeight` der Musterlinien. Wird angewandt, wo ein Element keine eigene
Schraffur aus einem `Component` mitbringt (z. B. einschichtige/„grob"-Flächen,
reine 2D-Zeichnungsobjekte einer Ebene).
Beides folgt dem Architektur-Prinzip: **Darstellung wird beim Rendern aufgelöst,
nie in die Geometrie eingebacken.**
---
## 2. Reference vs. Inline — Entscheidung
Es gibt drei Modelle, ein Datum für PEN/HATCH einer Ebene zu halten:
1. **Pure inline** — die Ebene trägt `{type,color,lw}` und `{pattern,scale,angle,
lineWeight}` direkt. Einfach, aber: kein Wiederverwenden, kein zentrales
Ändern; widerspricht der Ressourcen-Architektur (resources-graphics.md §1:
„alles verweist per id, zentral änderbar").
2. **Pure reference** — die Ebene trägt nur `lineStyleId` / `hatchId`. Konsistent,
zentral, aber unflexibel: Eine Ebene kann z. B. nicht „den Stil X, aber in
ihrer eigenen Farbe" wollen, ohne einen Klon-Stil anzulegen.
3. **Reference + optionale per-Ebene Overrides** (EMPFOHLEN) — die Ebene
**verweist** auf eine `LineStyle`- bzw. `HatchStyle`-Ressource und darf
**einzelne Felder lokal überschreiben**. Das ist exakt das DOSSIER/Vectorworks-
Muster: ein Stil als Basis, regelbasierte/lokale Overrides obendrauf
(resources-graphics.md, `overrides.py`).
### Empfehlung: Reference + optionale Overrides
Begründung:
- **Zentrale Pflege bleibt erhalten:** Ändert man den Linienstil „Wand stark" im
Line Manager, ziehen alle Ebenen nach, die ihn referenzieren und das jeweilige
Feld nicht überschreiben.
- **Lokale Freiheit ohne Stil-Wildwuchs:** Eine Ebene kann punktuell `color` oder
`lw` anpassen (häufigster Fall: gleiche Strichart, andere Farbe), ohne einen
fast identischen Stil zu duplizieren.
- **Migrationsfähig:** Die heutige flache `{color, lw}` der Ebene wird zu reinen
Overrides über einem neutralen Basis-Stil — verlustfrei (siehe §6).
- **Konsistent mit der bestehenden Kette:** `Component → Hatch → LineStyle`
verweist bereits per id; Ebenen reihen sich nahtlos ein.
Die Overrides sind **sparse**: nur gesetzte Felder überschreiben. Ein leeres
Override-Objekt (oder `undefined`) bedeutet „komplett dem Stil folgen".
---
## 3. Datenmodell (TS)
### 3.1 LineStyle erweitern um `type`
`LineStyle` trägt heute schon `weight`, `color`, `dash`. Wir machen die
Strichart explizit benennbar (statt nur via `dash`-Array), damit der Editor ein
sauberes Dropdown anbietet und `dash` daraus ableiten kann.
```ts
/** Benannte Strichart eines Stifts (für UI-Dropdown). */
export type LineKind = "solid" | "dashed" | "dotted" | "dashdot";
/** mm-Strichmuster je Strichart (relativ zur Papier-mm). */
export const LINE_DASH: Record<LineKind, number[] | null> = {
solid: null,
dashed: [0.6, 0.4],
dotted: [0.1, 0.25],
dashdot: [0.6, 0.25, 0.1, 0.25],
};
export interface LineStyle {
id: string;
name: string;
/** NEU: benannte Strichart; `dash` wird daraus abgeleitet, falls nicht gesetzt. */
kind: LineKind;
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */
weight: number;
color: string;
/** Strichmuster in mm; `null` = durchgezogen. Optional — sonst aus `kind`. */
dash: number[] | null;
}
```
> Hinweis: `kind` ist additiv; bestehende `LineStyle`-Daten setzen es per Migration
> aus `dash` (§6).
### 3.2 PEN- und HATCH-Override-Typen
```ts
/**
* Per-Ebene Stift (PEN). Verweist auf einen LineStyle; einzelne Felder dürfen
* lokal überschrieben werden. Alle Override-Felder optional (sparse).
*/
export interface LayerPen {
/** Basis-Linienstil (Line Manager). */
lineStyleId: string;
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
override?: {
kind?: LineKind;
color?: string;
/** Strichstärke in mm Papier. */
lw?: number;
};
}
/**
* Per-Ebene Standard-Schraffur (HATCH). Verweist auf einen HatchStyle; einzelne
* Felder dürfen lokal überschrieben werden. `enabled=false` = Ebene hat keine
* Default-Schraffur (Umriss-only).
*/
export interface LayerHatch {
/** Aktiv? false = keine Default-Schraffur dieser Ebene. */
enabled: boolean;
/** Basis-Schraffur (Hatch Manager). */
hatchId: string;
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
override?: {
pattern?: HatchPattern;
scale?: number;
/** Drehung in Grad. */
angle?: number;
color?: string;
/** Strichstärke der Musterlinien in mm Papier. */
lineWeight?: number;
};
}
```
### 3.3 LayerCategory erweitern
```ts
export interface LayerCategory {
code: string;
name: string;
visible: boolean;
locked: boolean;
// ── NEU: vollständige Darstellung ──────────────────────────────────────────
/** Stift der Ebene (PEN) — Linien aller Elemente dieser Ebene. */
pen: LayerPen;
/** Standard-Schraffur der Ebene (HATCH). */
hatch: LayerHatch;
/** Unterkategorien (Baum). */
children?: LayerCategory[];
// ── DEPRECATED (nur Übergang; siehe Migration §6) ──────────────────────────
/** @deprecated → pen.override.color. */
color?: string;
/** @deprecated → pen.override.lw. */
lw?: number;
}
```
`color` und `lw` bleiben als optionale, deprecatete Felder bestehen, bis alle
Lesepfade auf den Resolver (§4) umgestellt sind, und werden dann entfernt. Die
Panel-Swatch (`LayersPanel`) liest künftig die **aufgelöste** Stift-Farbe.
---
## 4. Resolver — vom Modell zur Render-Entscheidung
Der Resolver löst PEN/HATCH einer Ebene gegen die Ressourcen-Bibliotheken auf und
wendet die Overrides an. Er ist die **einzige** Stelle, an der „Stil + Override"
zusammenfließen; Generator und Panel rufen nur ihn.
### 4.1 Aufgelöste Render-Typen
`HatchRender` existiert bereits in `generatePlan.ts`. Wir ergänzen ein paralleles
`PenRender` und exportieren beide Resolver aus einem neuen Modul
`src/model/layerStyle.ts` (damit Panel und Generator teilen).
```ts
/** Aufgelöster Stift einer Ebene — alles, was die Linie zu zeichnen braucht. */
export interface PenRender {
color: string;
/** Strichstärke in mm Papier. */
lw: number;
/** Strichmuster in mm Papier; null = durchgezogen. */
dash: number[] | null;
}
// HatchRender: bereits in generatePlan.ts definiert (pattern, scale, angle,
// color, lineWeight, dash). Wird nach layerStyle.ts gezogen und re-exportiert.
```
### 4.2 Resolver-Funktionen (Pseudocode)
```ts
function resolvePen(project: Project, layer: LayerCategory): PenRender {
const ls = getLineStyle(project, layer.pen.lineStyleId); // wirft, falls fehlend
const o = layer.pen.override ?? {};
const kind = o.kind ?? ls.kind;
return {
color: o.color ?? ls.color,
lw: o.lw ?? ls.weight,
// Override-kind setzt das dash neu; sonst Stil-dash bzw. aus kind abgeleitet.
dash: o.kind ? LINE_DASH[o.kind] : (ls.dash ?? LINE_DASH[ls.kind]),
};
}
function resolveLayerHatch(project: Project, layer: LayerCategory): HatchRender | null {
if (!layer.hatch.enabled) return null; // Ebene ohne Default-Schraffur
const h = getHatch(project, layer.hatch.hatchId); // wirft, falls fehlend
const o = layer.hatch.override ?? {};
// Musterlinien-Stärke: Override > LineStyle der Schraffur > Default 0.13 mm.
const baseLs = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
return {
pattern: o.pattern ?? h.pattern,
scale: o.scale ?? h.scale,
angle: o.angle ?? h.angle,
color: o.color ?? h.color,
lineWeight: o.lineWeight ?? baseLs?.weight ?? 0.13,
dash: baseLs?.dash ?? null,
};
}
```
Beide bauen eine `Map<code, …>` über den ganzen Baum, analog zur heutigen
`categoryLwMap`:
```ts
export function penMap(project: Project): Map<string, PenRender> {
const m = new Map<string, PenRender>();
for (const c of flattenCategories(project.layers)) m.set(c.code, resolvePen(project, c));
return m;
}
export function layerHatchMap(project: Project): Map<string, HatchRender | null> {
const m = new Map<string, HatchRender | null>();
for (const c of flattenCategories(project.layers))
m.set(c.code, resolveLayerHatch(project, c));
return m;
}
```
---
## 5. Einfluss auf `generatePlan`
Heute (generatePlan.ts):
- `categoryLwMap(project.layers)` liefert nur `lw` je Code; die Umriss-Strichstärke
kommt daraus, **Farbe** der Umrisse ist fest `POCHE_STROKE`.
- Schraffur kommt ausschließlich aus dem `Component` der jeweiligen Schicht
(`resolveHatch(project, comp.hatchId)`); die Ebenen-`hatch` wird **nicht** genutzt.
Änderungen (minimal-invasiv, additiv):
### 5.1 Pens ersetzen `lwByCode`
```ts
const pens = penMap(project); // statt categoryLwMap
const layerHatches = layerHatchMap(project);
…
const pen = pens.get(wall.categoryCode) ?? FALLBACK_PEN; // {color, lw, dash}
```
`addWallPoche` und `addDoorSymbol` bekommen statt `wallLwMm: number` /
`doorLwMm: number` jeweils das ganze `pen: PenRender`:
- **Wand-Umrisslinie:** `stroke: pen.color` (statt fix `POCHE_STROKE`),
`strokeWidthMm: pen.lw * OUTLINE_DETAIL_FACTOR[detail]`, `dash: pen.dash`.
→ Das `Primitive` „polygon" braucht ein optionales `dash?: number[] | null`
(Schichtfugen bleiben durchgezogen; nur die Umriss-Kontur nutzt `pen.dash`).
- **Schichtfugen:** behalten `POCHE_STROKE` und ihre dünne `LAYER_LINE_MM`
(interne Hilfslinien sind bewusst neutral, nicht stift-gefärbt).
- **Tür-Symbol / Referenzlinie:** `cls` bleibt, aber `weightMm` aus `pen.lw`,
und die PlanView darf die Stift-Farbe nutzen (`door-leaf` etc. erhalten optional
ein `stroke`-Feld am line/arc-Primitive; ansonsten greift die CSS-Klasse wie
bisher).
### 5.2 Default-Schraffur der Ebene
Die Ebenen-Schraffur greift dort, wo **keine Component-Schraffur** vorliegt:
- **`detail === "grob"`** (eine Sammelfläche, heute `NO_HATCH`): statt `NO_HATCH`
nun `layerHatches.get(wall.categoryCode) ?? NO_HATCH`. So bekommt die grobe
Poché die Standard-Schraffur der Ebene (z. B. ein leichtes Diagonalmuster),
falls die Ebene eine definiert; sonst bleibt sie ungeschraffiert.
- **mittel/fein, mehrschichtig:** unverändert — die Component-Schraffur je Schicht
hat Vorrang (spezifischer als die Ebene). Die Ebenen-Schraffur ist der
*Fallback*, nicht der Default-Override.
- **Reine 2D-Zeichnungsobjekte** (künftige `drawing`-Ebenen-Elemente ohne
Component): nutzen direkt `resolveLayerHatch` als ihre Füllschraffur.
Auflöse-Reihenfolge der Schraffur einer gezeichneten Fläche:
```
Component.hatch > LayerCategory.hatch (enabled) > keine Schraffur
```
### 5.3 Geänderte Signaturen (Zusammenfassung)
```ts
// vorher: addWallPoche(out, project, wall, doors, cuts, greyed, detail, wallLwMm)
function addWallPoche(out, project, wall, doors, cuts, greyed, detail,
pen: PenRender, layerHatch: HatchRender | null): void
// vorher: addDoorSymbol(out, wall, door, greyed, detail, doorLwMm)
function addDoorSymbol(out, wall, door, greyed, detail, pen: PenRender): void
```
`Primitive` (polygon) erhält optional `dash?: number[] | null`; line/arc erhalten
optional `stroke?: string`, damit Pen-Farbe durchschlagen kann (CSS-Klasse bleibt
Default).
---
## 6. Editor „Ebeneneinstellungen…"
Geöffnet wie heute über `layerMenuItems → openLayerEditor(code)` →
`setEditor({ kind: "layer", code, x, y })`. Der bestehende `InlineEditor`-Rahmen
(dunkel, am Anker, Esc/Außenklick schließt) und die `EditorField`-Zeilen bleiben;
der Inhalt wächst von 3 Feldern auf zwei kompakte Abschnitte **PEN** und **HATCH**.
Da der Editor jetzt mehr Felder trägt, wird er als **kompakte Sektions-Form**
gestaltet (zwei Gruppen mit Trenn-Überschrift), gemäß CONVENTIONS.md UI-Konventionen
(saubere Form, keine wiederholten Beschriftungen, DOSSIER-Stil, alles via `t()`).
### 6.1 Aufbau
```
┌ Ebene 20 ───────────────── ×
│ Name [ Wände ]
│
│ ── Stift (PEN) ──────────────
│ Linienstil [ Wand stark ▾ ] ← Dropdown über project.lineStyles
│ Strichart [ durchgezogen ▾ ] ← override.kind (leer = "vom Stil")
│ Farbe [■] [↺] ← override.color; ↺ = Override entfernen
│ Stärke [ 0.35 ] mm [↺] ← override.lw
│
│ ── Schraffur (HATCH) ────────
│ [✓] aktiv
│ Schraffur [ Beton ▾ ] ← Dropdown über project.hatches
│ Muster [ vom Stil ▾ ] ← override.pattern
│ Maßstab [ 1.00 ] [↺]
│ Drehung [ 45 ] ° [↺]
│ Farbe [■] [↺]
│ Linienst. [ 0.13 ] mm [↺]
└──────────────────────────────
```
- **Override-Semantik im UI:** Jedes Override-Feld zeigt entweder „vom Stil"
(Override leer → Platzhalter mit dem aufgelösten Stil-Wert als Hint) oder einen
konkreten Wert. Ein kleiner **Reset-Knopf `↺`** je Override-Feld löscht das
Override (setzt es zurück auf `undefined` → Feld folgt wieder dem Stil).
- **Live, kein Bestätigen:** wie der heutige Editor — jede Änderung ruft sofort
`patchCategory(code, patch)`.
- **i18n:** alle Labels über `t()`. Neue Keys (Beispiele):
`editor.pen`, `editor.lineStyle`, `editor.lineKind`, `editor.color`,
`editor.lineWeight`, `editor.hatch`, `editor.hatchEnabled`, `editor.pattern`,
`editor.scale`, `editor.rotation`, `editor.fromStyle`, `editor.resetOverride`.
Strichart-/Muster-Werte: `lineKind.solid`, `lineKind.dashed`, …,
`hatchPattern.solid`, `hatchPattern.diagonal`, … . Menü-Label bleibt
`ctx.layerSettings`.
### 6.2 Patch-Helfer
`patchCategory(code, patch: Partial<LayerCategory>)` bleibt die Schnittstelle.
Für die verschachtelten Overrides nutzt der Editor schmale Helfer (im App-Scope),
die sparse mergen und leere Overrides auf `undefined` kollabieren:
```ts
function setPenOverride(cat: LayerCategory, patch: Partial<LayerPen["override"]>) {
const next = pruneEmpty({ ...cat.pen.override, ...patch });
patchCategory(cat.code, { pen: { ...cat.pen, override: next } });
}
function setHatchOverride(cat, patch) { /* analog für cat.hatch.override */ }
// pruneEmpty: entfernt undefined-Felder; gibt undefined zurück, wenn leer.
```
`setLineStyleId` / `setHatchId` setzen nur die Referenz; `hatch.enabled` ist ein
Checkbox-Patch.
### 6.3 „Eigenschaften kopieren / einfügen"
Der bestehende `layerClipboard` (heute `{ color, lw }`) wird auf die volle
Darstellung erweitert: `{ pen, hatch }` (die Override-tragenden Strukturen, ohne
`code/name/visible/locked`). „Kopieren" liest `{ pen, hatch }` der Quelle,
„Einfügen" patcht sie auf das Ziel. So überträgt sich der komplette Stift +
Schraffur einer Ebene auf eine andere.
---
## 7. Migration bestehender Beispieldaten
Bestehende Projekte/Sample-Daten haben `LayerCategory { color, lw, hatch?: string }`
und `LineStyle { weight, color, dash }` (ohne `kind`). Eine reine Lese-Zeit-
Migration (`migrateProject(project)`), idempotent, beim Laden:
1. **LineStyle.kind ableiten** — aus `dash`:
```
dash == null || dash.length === 0 → "solid"
sonst, wenn min(dash) sehr klein → "dotted" (heuristisch)
sonst → "dashed"
```
(Eine genaue Zuordnung ist nicht nötig; `dash` bleibt führend, `kind` ist nur
für das Dropdown.)
2. **Neutralen Basis-Linienstil sicherstellen** — falls die Bibliothek noch keinen
generischen „Standard"-Stift hat, einen `lineStyle` mit
`{ id: "ls-default", name: "Standard", kind: "solid", weight: <Ebenen-lw>, color: "#000", dash: null }`
anlegen. (Pro Ebene wird der Stift referenziert; die Ebenen-spezifischen
`color`/`lw` wandern in das **Override**, nicht in den Stil — so bleibt der
Stil wiederverwendbar.)
3. **Pro LayerCategory `pen` bauen:**
```ts
pen = {
lineStyleId: "ls-default",
override: pruneEmpty({ color: cat.color, lw: cat.lw }),
}
```
Damit ist die Darstellung **pixelgenau wie vorher** (gleiche Farbe, gleiche lw),
nur jetzt über die Resolver-Kette.
4. **Pro LayerCategory `hatch` bauen** — aus dem alten `hatch?: string`:
- War `hatch` ein gültiger `HatchStyle.id` → `{ enabled: true, hatchId: hatch }`.
- War es ein Pattern-Name oder leer/unbekannt → `{ enabled: false, hatchId:
<erste Hatch-id der Bibliothek> }` (Referenz muss existieren, aber inaktiv).
So entsteht **keine** unbeabsichtigte Schraffur (Default heute: keine).
5. **Deprecated-Felder belassen** für eine Übergangsphase; nach Umstellung aller
Lesepfade (`generatePlan`, `LayersPanel`-Swatch, Clipboard) in einem zweiten
Schritt `color`/`lw` aus `LayerCategory` und der alte `hatch: string` entfernen.
Migration ist **idempotent**: Liegt `pen`/`hatch` bereits vor, wird die Ebene
unverändert durchgereicht.
---
## 8. Build-Plan (phasiert)
**Phase 1 — Datenmodell & Resolver (keine UI-Sichtbarkeit).**
- `LineKind` + `LINE_DASH`, `LineStyle.kind`, `LayerPen`, `LayerHatch`,
`LayerCategory.pen/hatch` in `types.ts`.
- `src/model/layerStyle.ts`: `PenRender`, `resolvePen`, `resolveLayerHatch`,
`penMap`, `layerHatchMap`; `HatchRender` hierher ziehen + re-exportieren.
- `migrateProject()` (Schritte §7) + Aufruf beim Laden/Seed.
- `npx tsc -b` grün.
**Phase 2 — Generator umstellen.**
- `generatePlan` nutzt `penMap`/`layerHatchMap` statt `categoryLwMap`.
- `Primitive`-polygon `dash?`, line/arc `stroke?` ergänzen; `addWallPoche`/
`addDoorSymbol`-Signaturen auf `PenRender` + `HatchRender|null`.
- Ebenen-Default-Schraffur in „grob" und für Schicht-lose Flächen verdrahten.
- Visuell prüfen via `node scripts/probe.mjs` (Geometrie unverändert, Farben/lw
identisch zur Migration).
**Phase 3 — Panel.**
- `LayersPanel`-Swatch liest aufgelöste Stift-Farbe (`resolvePen(...).color`).
**Phase 4 — Editor.**
- `InlineEditor`-Inhalt für `kind: "layer"` auf die PEN/HATCH-Sektionen erweitern
(§6), mit Dropdowns über `project.lineStyles` / `project.hatches`, Reset-Knöpfen,
neuen i18n-Keys.
- `layerClipboard` auf `{ pen, hatch }` erweitern; Kopieren/Einfügen anpassen.
**Phase 5 — Aufräumen.**
- Deprecatete `color`/`lw`/`hatch: string` aus `LayerCategory` entfernen, sobald
kein Lesepfad sie mehr nutzt; Sample-Daten direkt im neuen Format ablegen.
---
## 9. Offene Punkte / bewusst nicht jetzt
- **Pro-Geschoss-Overrides der Ebene** (eine Ebene anders je `DrawingLevel`):
nicht in dieser Iteration; das Schema gilt geschossübergreifend (types.ts).
Falls später nötig, als zweite Override-Ebene über demselben Resolver.
- **Regelbasierte Overrides** (resources-graphics.md, `overrides.py`): orthogonal;
würden nach der Ebenen-Auflösung greifen.
- **Linienstil-Endkappen/Joins** und feinere Dash-Skalierung: bleiben in der
PlanView (Darstellung), nicht im Modell.
+672
View File
@@ -0,0 +1,672 @@
# Parametrische Wände
Status: Implementiert (Phase A — Typ-System und Resolver in `src/model/`, kein UI).
Dieses Dokument spezifiziert die **Parametrischen Wände**: regelbasierte Definitionen,
die beim Auflösen eine Liste von `Wall`-Elementen erzeugen, anstatt sie einzeln vom
Nutzer zeichnen zu lassen.
Bezugsdokumente: [elements.md](elements.md) (Wand-/Türmodell),
[drawing-tools.md](drawing-tools.md) (Werkzeugsystem, Direktzeichnen),
[state-architecture.md](state-architecture.md) (Projekt-Store),
[resources-graphics.md](resources-graphics.md) (WallType/Component-Auflösung).
Implementierungsdateien:
- `src/model/types.ts` — `ParametricWall`, `ParametricRule` und alle Regel-Varianten.
- `src/model/parametricWalls.ts` — `resolveParametricWall()`, `applyRule()` und
Hilfsfunktionen.
---
## 0. Überblick
Eine **parametrische Wand** (`ParametricWall`) ist kein festes `Wall`-Element, sondern
ein **Regelwerk**, das beim Auflösen (`resolveParametricWall`) eine Menge von `Wall[]`-
Elementen generiert. Die erzeugten Wände sind gewöhnliche `Wall`-Objekte; sie
unterscheiden sich lediglich in ihrer Herkunft. Das semantische Modell (`Project`)
bleibt die einzige Wahrheit — parametrische Wände sind eine Ressource in der
Ressourcen-Bibliothek, nicht eine separate Laufzeit-Geometrie-Schicht.
```
Project.parametricWalls: ParametricWall[]
│
│ resolveParametricWall(pw, floorId, context, defaultWallType)
▼
Wall[] ──→ normales Rendering über generatePlan / Viewport3D
```
Erzeugte Wände können entweder **temporär** (zur Laufzeit, als Ergänzung zu
`project.walls` im Rendering-Pfad) oder **eingebacken** (als `Wall[]` fest in
`Project.walls` gespeichert) behandelt werden. Phase A legt nur den Auflöser fest;
die Auswahl liegt bei der aufrufenden Komponente.
---
## 1. Motivation
### 1.1 Schnellere Modellierung von Regelgrundrissen
Schweizer Wohnbauten folgen häufig einem 3-m-Achsraster (SIA-Norm, Modul-/
Skelettbauweise). Zwanzig Wände eines Rasters von Hand zu zeichnen ist fehleranfällig
und verhindert spätere parametrische Änderungen (z. B. Geschossanzahl, Rasterweite,
Wandtyp).
Eine `GridRule` erzeugt dieses Muster aus wenigen Parametern (Achsabstand, Richtung,
Bereich) und lässt sich mit einer einzigen Zahl auf „4-m-Büroraster" umstellen.
### 1.2 Kongruenz mit FreeCAD BIM / IFC
FreeCAD BIM kennt **ParametricObjects**, die ihre Geometrie aus Regeln ableiten (z. B.
`ArchWall` mit `Length`, `Width`, `Height`). Obwohl das Datenformat hier kein IFC ist,
schafft ein ähnliches Abstraktionsniveau eine spätere Brücke: Beim IFC-Export können
parametrische Wände als `IfcWallStandardCase` mit konstanten Attributen exportiert
werden — kein Informationsverlust gegenüber manuell gezeichneten Wänden.
### 1.3 Bedingte Wandtypen ohne manuelle Klassifizierung
Außenwände sind dicker als Innenwände; Trennwände zwischen Einheiten erfordern
Schallschutz. Eine `ConditionalThicknessRule` (`condition: "exterior" → thickType`)
weist den richtigen Wandtyp automatisch aus der geometrischen Lage zu — ohne dass der
Nutzer jeden Wandabschnitt einzeln klassifizieren muss.
---
## 2. Architektur
### 2.1 Typen (`src/model/types.ts`)
```ts
/**
* Eine parametrische Wand-Regel — generiert automatisch Wall[]-Einträge für
* ein gegebenes Geschoss. Lebt in Project.parametricWalls[].
*/
export interface ParametricWall {
id: string;
name: string;
description?: string;
/**
* Geordnete Liste der anzuwendenden Regeln. Spätere Regeln können die
* Ausgabe früherer verfeinern (z. B. Dickenzuweisung nach Raster).
*/
rules: ParametricRule[];
/**
* Rückfall-Wandtyp, falls eine Regel keinen eigenen `wallTypeId` nennt.
*/
defaultWallTypeId: string;
}
/** Diskriminierte Union aller Regel-Varianten. */
export type ParametricRule =
| GridRule
| ModuleRule
| ConditionalThicknessRule
| ReferenceLineRule
| SequenceRule;
```
### 2.2 Einbettung ins Projekt
```ts
export interface Project {
// … bestehende Felder …
/**
* Parametrische Wanddefinitionen (Ressourcen-Bibliothek). Optional, damit
* bestehende Projekte/Tests ohne `parametricWalls` gültig bleiben.
*/
parametricWalls?: ParametricWall[];
}
```
### 2.3 Resolver-Kontext (`src/model/parametricWalls.ts`)
```ts
export interface ParametricContext {
/** Das Ziel-Geschoss. */
floor: DrawingLevel;
/**
* Optionale Rasterachsen (Phase C: verlinkter Grid-Ressource). Fehlen sie,
* berechnet die Engine die Achsen aus GridRule.spacing.
*/
gridAxes?: { x: number[]; y: number[] };
/**
* Optionales Clipping-Polygon (Meter). Fehlt es, reicht das Raster über
* einen Standardbereich (0 … spacing × 10).
*/
boundaryGeometry?: { boundary: Vec2[] };
/**
* Bereits im Projekt vorhandene Wände des Geschosses. Werden von
* refinierenden Regeln (ConditionalThicknessRule, ReferenceLineRule) genutzt.
*/
existingWalls?: Wall[];
}
```
---
## 3. Regel-Varianten
### 3.1 GridRule — Achsraster
Erzeugt parallele Wände auf einem gleichmäßigen Raster. Typischer Einsatz: Schweizer
Wohnbau-Achsraster (3 m), Büro-Konstruktionsraster (6 m), strukturelle Raster mit
fester Stützweite.
```ts
export interface GridRule {
type: "grid";
/**
* Optionaler Verweis auf eine Grid-Ressource (Phase C). Für Phase A wird
* stattdessen `spacing` genutzt.
*/
gridId?: string;
/** Rasterabstand in Metern (Default: 3.0). */
spacing?: number;
/**
* Achsrichtungen: „x" = nur Wände entlang der Y-Achse,
* „y" = nur Wände entlang der X-Achse, „both" = Vollraster.
*/
directions: "x" | "y" | "both";
/** Optionaler Verweis auf Clipping-Polygon. */
boundaryId?: string;
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
wallTypeId?: string;
/** Lage der Wandachse über die Dicke (Vectorworks-Stil). */
referenceLine?: WallReferenceLine;
/** Optionale Höhenübersteuerung in Metern; sonst Geschosshöhe. */
height?: number;
}
```
**Geometrieausgabe (top-down Grundriss):**
```
directions: "x", spacing: 3.0, Bereich 0…12 m:
y
│
12 ──────────────────────────
│
9 ──────────────────────────
│
6 ──────────────────────────
│
3 ──────────────────────────
│
0 ──────────────────────────
│
└──────────────────────────► x
0 12
```
**Wann verwenden:**
- Tragende Wände auf fester Stützweite (Wohnbau 3 m, Büro 6 m).
- Vollraster (`"both"`) für strukturelle Rastersysteme.
- In Kombination mit `ConditionalThicknessRule` zur automatischen Außen/Innen-Klassifizierung.
**Beispiel: Schweizer 3-m-Wohnraster**
```ts
const pw: ParametricWall = {
id: "pw-eg-raster",
name: "EG Längswände 3m-Raster",
defaultWallTypeId: "wt-innen-15",
rules: [
{
type: "grid",
spacing: 3.0,
directions: "x", // Wände in X-Richtung (y = 0, 3, 6, 9, 12)
wallTypeId: "wt-innen-15",
},
],
};
// Auflösung:
const walls = resolveParametricWall(pw, "floor-eg", {
floor: egFloor,
boundaryGeometry: { boundary: rectBoundary(0, 0, 12, 12) },
}, defaultWallType);
// → 5 Wände bei y = 0, 3, 6, 9, 12, je 12 m lang
```
---
### 3.2 ModuleRule — Bay-/Jochbauweise
Unterteilt eine Referenzspanne in gleiche Module und erzeugt Querwände an jedem
Teilungspunkt. Typisch für Bürogebäude (6-m-Joch) oder Reihenhäuser mit modularer
Erschließung.
```ts
export interface ModuleRule {
type: "module";
/** Modulmaß in Metern (z. B. 6.0, 3.6). */
moduleSize: number;
/** Ausrichtung der Trennwände: „x" = Querwände senkrecht zu X, „y" = zu Y. */
direction: "x" | "y";
/**
* Optionaler Verweis auf eine Referenzwand, die die Spannweite definiert.
* Fehlt er, wird die Geschoss-Ausdehnung (Bounding-Box) genutzt.
*/
referenceWallId?: string;
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
wallTypeId?: string;
referenceLine?: WallReferenceLine;
height?: number;
}
```
**Wann verwenden:**
- Wenn sich Querwände aus einer Referenzspanne (Fassade, Achswand) ergeben.
- Vorzug vor `GridRule`, wenn nur in eine Richtung unterteilt wird und eine
Referenzwand die Spanne definiert.
**Beispiel: 6-m-Bay-Bürogebäude**
```ts
const pw: ParametricWall = {
id: "pw-buero-joch",
name: "Büro 6m-Joch",
defaultWallTypeId: "wt-beton-20",
rules: [
{
type: "module",
moduleSize: 6.0,
direction: "x", // Querwände senkrecht zur X-Achse
// referenceWallId: "W-sudfassade" → Spanne aus der Südwand ableiten
},
],
};
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30 (bei 36-m-Fassade)
```
---
### 3.3 ConditionalThicknessRule — Bedingte Wandtypen
Weist bereits erzeugten Wänden (aus vorherigen Regeln in der Sequenz) einen anderen
Wandtyp zu — abhängig von einer Bedingung. Gibt modifizierte **Kopien** zurück; die
Eingabe-Wände werden nicht mutiert.
```ts
export interface ConditionalThicknessRule {
type: "conditional-thickness";
/**
* Bedingung für den Treffer:
* • „exterior" — Wand liegt am Außenrand (Bounding-Box des Kontexts).
* • „interior" — Wand liegt im Inneren.
* • „bearing" — tragende Wand (Heuristikum: Wand läuft ±10° zu X/Y-Achse).
* • beliebiger String — benutzerdefiniertes Tag (Phase C: Wall.tags[]).
*/
condition: "exterior" | "interior" | "bearing" | string;
/** Ziel-Wandtyp, der bei Treffer gesetzt wird. */
wallTypeId: string;
}
```
**Wann verwenden:**
- Immer in Kombination mit `GridRule` oder `ModuleRule` (als zweite Regel in
`ParametricWall.rules`): Raster erzeugt, Dicke verfeinert.
- Wenn Außen- und Innenwände denselben geometrischen Ursprung haben, aber
verschiedene Aufbauten benötigen.
**Beispiel: Außen dick, Innen dünn**
```ts
const pw: ParametricWall = {
id: "pw-eg-komplett",
name: "EG Vollraster mit Außenwand-Differenzierung",
defaultWallTypeId: "wt-innen-15",
rules: [
{
type: "grid", spacing: 3.0, directions: "both",
wallTypeId: "wt-innen-15",
},
{
type: "conditional-thickness",
condition: "exterior",
wallTypeId: "wt-aussen-36", // Außenwände erhalten dicken Aufbau
},
],
};
```
---
### 3.4 ReferenceLineRule — Wandachsen-Lage
Setzt `referenceLine` bei passenden Wänden einheitlich (Vectorworks-Stil: Achse
links/rechts/mittig). Gibt modifizierte Kopien zurück.
```ts
export interface ReferenceLineRule {
type: "reference-line";
/** Neue Lage der Wandachse, die einheitlich gesetzt wird. */
referenceLine: WallReferenceLine; // "left" | "center" | "right"
/**
* Filterziel:
* • „all" — alle Wände im aktuellen Satz.
* • „exterior" — nur Außenwände.
* • beliebiger String — benutzerdefiniertes Tag (Phase C).
*/
target: "all" | "exterior" | string;
}
```
**Wann verwenden:**
- Außenwände auf `"left"` setzen (Achse liegt auf der Fassadenfläche).
- Als abschließende Regel in einer `SequenceRule` nach Raster und Dickenzuweisung.
---
### 3.5 SequenceRule — Zusammenfassung von Unterregeln
Fasst mehrere Regeln als atomare Einheit zusammen. Jede Unterregel erhält die Ausgabe
der vorherigen als `existingWalls` — so können spätere Regeln frühere verfeinern.
```ts
export interface SequenceRule {
type: "sequence";
rules: ParametricRule[];
/**
* Wenn true: Abbruch nach der ersten Unterregel, die mindestens eine Wand
* generiert/verändert hat (Short-Circuit-Fallback).
*/
stopOnMatch?: boolean;
}
```
**Wann verwenden:**
- Um eine zusammengehörige Kombination (Raster → Dicke → Referenzlinie) als
Untermodul wiederzuverwenden — z. B. in unterschiedlichen Geschossen mit leicht
abweichenden Parametern.
---
## 4. Resolver-API (`src/model/parametricWalls.ts`)
```ts
/**
* Löst ein ParametricWall-Regelwerk zu einem Wall[]-Array für ein gegebenes
* Geschoss auf.
*
* Ablauf:
* 1. Regelwerk sequenziell ausführen; jede Regel erhält die Ausgabe der
* vorherigen als existingWalls (ermöglicht Verfeinerung).
* 2. Duplikate (gleicher Start-/Endpunkt innerhalb tolerance) entfernen.
* 3. Bereinigte Wall[]-Liste zurückgeben.
*
* Die Ausgabe ist sofort bereit zur Einfügung in project.walls. Es werden
* keine Seiteneffekte erzeugt — kein Store, kein Dispatch, kein React.
*
* @param parametricWall Das Regelwerk.
* @param floorId ID des Ziel-Geschosses.
* @param context Kontext (Geschoss-Objekt, Grid-Achsen, Grenzen, …).
* @param defaultWallType Fallback-Wandtyp, wenn eine Regel keinen nennt.
* @param tolerance Näherungstoleranz für Duplikat-Erkennung (Meter, Default 0.01).
* @returns Wall[]-Array, bereit zur Einfügung.
*/
export function resolveParametricWall(
parametricWall: ParametricWall,
floorId: string,
context: ParametricContext,
defaultWallType: WallType,
tolerance?: number,
): Wall[];
/**
* Dispatcher: delegiert eine Regel an die passende Implementierung.
* Exportiert für Unit-Tests und erweiterbare Regeltypen.
*/
export function applyRule(rule: ParametricRule, ctx: RuleCtx): Wall[];
/**
* Entfernt doppelte Wände: zwei Wände gelten als Duplikat, wenn Start- und
* Endpunkt jeweils innerhalb tolerance übereinstimmen (vorwärts und rückwärts).
*/
export function deduplicateWalls(walls: Wall[], tolerance?: number): Wall[];
```
### 4.1 Höhenauflösung
Die Wandhöhe (`Wall.height`) ergibt sich nach folgender Priorität:
1. `rule.height`, falls an der einzelnen Regel gesetzt.
2. `context.floor.floorHeight` des Zielgeschosses.
3. Fallback: 2.6 m (globaler Default, CONVENTIONS.md).
### 4.2 ID-Schema
```
"pw-<floorId>-gx-<counter>" // GridRule, X-Achse
"pw-<floorId>-gy-<counter>" // GridRule, Y-Achse
"pw-<floorId>-mx-<counter>" // ModuleRule, X-Teilung
"pw-<floorId>-ct-<counter>" // ConditionalThicknessRule
"pw-<floorId>-rl-<counter>" // ReferenceLineRule
```
IDs sind sessionlokal (Zähler startet bei 0 je Modullade). Eingebrannte Wände
erhalten beim Commit neue stabile IDs über `uniqueId("W")` — konsistent mit dem
Wand-Werkzeug (vgl. [drawing-tools.md §8](drawing-tools.md#8-id-vergabe--immutabilität)).
### 4.3 Duplikat-Erkennung
`deduplicateWalls` vergleicht Start-/Endpunkte beider Wände (vorwärts: A→B == A→B,
und rückwärts: A→B == B→A) innerhalb einer Toleranz von 1 cm (0.01 m). Die **erste**
Instanz wird behalten; spätere Duplikate werden verworfen. Dies ist wichtig bei
Vollrastern (`"both"`), bei denen X- und Y-Wände exakt auf einem Rasterpunkt
zusammentreffen könnten.
### 4.4 Verhalten bei ungültigen Eingaben
| Situation | Verhalten |
|-----------|-----------|
| `spacing <= 0` oder `moduleSize <= 0` | `[]` |
| `referenceWallId` nicht in `existingWalls` | Fallback auf Bounding-Box, kein Fehler |
| Unbekannter `condition`-String | `matchesCondition` gibt `false` zurück (kein Treffer) |
| Unbekannter `SequenceRule`-Untertyp | TypeScript exhaustiveness-Guard, `[]` |
| Segment mit `|end - start| < 1e-6` m | Kann durch deduplicateWalls entfernt werden |
---
## 5. Integration ins Projekt
### 5.1 Ressourcen-Speicherung
`ParametricWall`-Einträge leben unter `Project.parametricWalls` (optionales Array).
Sie sind Teil des `.cad.json`-Dokuments und werden mit dem Rest des Projekts gespeichert.
```ts
// sampleProject.ts — Beispieleintrag
export const sampleProject: Project = {
// …
parametricWalls: [
{
id: "pw-eg-raster",
name: "EG Längswände 3m-Raster",
defaultWallTypeId: "wt-innen-15",
rules: [
{ type: "grid", spacing: 3.0, directions: "x" },
{ type: "conditional-thickness", condition: "exterior",
wallTypeId: "wt-aussen-36" },
],
},
],
};
```
### 5.2 Rendering ohne UI (Phase A)
In Phase A werden parametrische Wände **nicht** automatisch gerendert. Der Auflöser
ist eine reine Funktion; Aufrufer müssen ihn explizit einbinden. Mögliche Verwendung
in `generatePlan` oder `Viewport3D`:
```ts
// generatePlan.ts (Ergänzung, Phase A)
const defaultWallType = project.wallTypes[0];
const extraWalls = (project.parametricWalls ?? []).flatMap((pw) =>
resolveParametricWall(pw, activeLevelId, {
floor: activeFloor,
boundaryGeometry: projectBoundary,
}, defaultWallType)
);
const allWalls = [...project.walls, ...extraWalls];
// … allWalls statt project.walls in der Rendering-Pipeline verwenden
```
### 5.3 Keine UI in Phase A
Kein Command, kein Panel, kein Formular. `ParametricWall`-Einträge werden in Phase A
ausschließlich **programmatisch** (Unit-Tests, `sampleProject`, direkte JSON-Bearbeitung
des Projekts) erstellt.
---
## 6. Ausblick: Folge-Phasen
### Phase B — UI und Command-Schnittstelle
- Neues Command (z. B. `PWWALL`) oder Ressourcen-Manager-Tab „Parametrische Wände"
mit Formular-Editor je Regeltyp.
- „Einbrennen" (Flatten): `ParametricWall` → feste `Wall[]` in `Project.walls`
einfügen und den `ParametricWall`-Eintrag entfernen (unidirektional, Undo über Store).
- Auswahl parametrischer Wände im Plan (als Gruppe); Grip-Editing der Raster-Parameter
und Spannweiten.
### Phase C — Grid-Ressource und Schnittpunkt-Clipping
- `GridResource`: ein projektweites, benanntes Koordinatenraster (LV95-Offset,
Rasterweite, Drehung), auf das mehrere `GridRule`-Instanzen via `gridId` verweisen.
- Präzises Clipping: erzeugte Wände werden am Gebäudeumriss getrimmt — exakte
`lineIntersect`-Berechnung statt Bounding-Box-Approximation.
- Benutzerdefinierte Tags (`Wall.tags[]`) für komplexe `ConditionalThicknessRule`-
Bedingungen jenseits von „exterior/interior/bearing".
- IFC-Export: `ParametricWall`-Gruppen → `IfcWallStandardCase` mit parametrischen
Attributen und `IfcRelDefinesByType`.
---
## 7. Vollständige Anwendungsbeispiele
### 7.1 Schweizer Wohnbau: 3-m-Raster EG + 1.OG
Zwei-Geschoss-Wohnhaus, typisches CH-Wohnbauraster. Die Längswände beider Geschosse
entstehen aus zwei `ParametricWall`-Einträgen mit identischen Regeln, unterschieden
nur durch `floorId` beim Auflösen:
```
Top-down (Grundriss):
y=12 ──────────────────────────── (W5)
y=9 ──────────────────────────── (W4)
y=6 ──────────────────────────── (W3)
y=3 ──────────────────────────── (W2)
y=0 ──────────────────────────── (W1)
↑
x=0 x=12
```
```ts
const rasterRegel: ParametricWall = {
id: "pw-laengswand-raster",
name: "Längswände 3m-Raster",
defaultWallTypeId: "wt-innen-15",
rules: [
{ type: "grid", spacing: 3.0, directions: "x" },
// Außenwände (y=0 und y=12) erhalten den dicken Aufbau:
{ type: "conditional-thickness", condition: "exterior",
wallTypeId: "wt-aussen-36" },
// Außenwände: Achse liegt auf der Fassadenfläche:
{ type: "reference-line", referenceLine: "left", target: "exterior" },
],
};
// EG auflösen:
const wallsEG = resolveParametricWall(rasterRegel, "floor-eg",
{ floor: egFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
project.wallTypes[0]);
// 1.OG auflösen (gleiche Regel, anderes Geschoss):
const wallsOG = resolveParametricWall(rasterRegel, "floor-og1",
{ floor: ogFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
project.wallTypes[0]);
// Änderung spacing: 3.5 → beide Geschosse sofort konsistent.
```
### 7.2 Vollraster mit Außen/Innen-Differenzierung
Gebäudeumriss als Rechteck; die Randwände erhalten automatisch den dicken
Außenwand-Typ, alle anderen den dünnen Innenwand-Typ:
```ts
const vollraster: ParametricWall = {
id: "pw-eg-vollraster",
name: "EG Vollraster mit Differenzierung",
defaultWallTypeId: "wt-innen-15",
rules: [
{ type: "grid", spacing: 3.0, directions: "both" },
{ type: "conditional-thickness", condition: "exterior",
wallTypeId: "wt-aussen-36" },
{ type: "conditional-thickness", condition: "interior",
wallTypeId: "wt-innen-15" },
{ type: "reference-line", referenceLine: "left", target: "exterior" },
],
};
```
```
─────┬─────┬─────┬─────
│ │ │ │ │
─────┼─────┼─────┼─────
│ │ │ │ │
─────┴─────┴─────┴─────
Rand-Segmente: wt-aussen-36 (dicker Aufbau)
Innen-Segmente: wt-innen-15 (dünner Aufbau)
```
### 7.3 Modulbauweise: 6-m-Joch, Bürogebäude
Längliches Bürogebäude, 36 m × 12 m, 6-m-Joch. Querwände entstehen automatisch;
Entwurfsänderung (z. B. auf 7.2-m-Joch) erfordert eine einzige Zahl:
```ts
const joch: ParametricWall = {
id: "pw-buero-joch",
name: "Büro 6m-Joch",
defaultWallTypeId: "wt-beton-20",
rules: [
{
type: "module",
moduleSize: 6.0,
direction: "x", // Querwände senkrecht zur X-Achse
// referenceWallId: "W-sudfassade" → Spanne aus Referenzwand
},
],
};
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30
// (bei Bounding-Box minX=0, maxX=36, Enden selbst ausgespart)
// Änderung auf 7.2-m-Joch: moduleSize: 7.2
// → 4 Trennwände bei x ≈ 7.2, 14.4, 21.6, 28.8 — automatisch neu berechnet.
```
---
## 8. Architektur-Garantien
- **Modell bleibt einzige Wahrheit.** `ParametricWall`-Definitionen sind Daten in
`Project.parametricWalls`; `resolveParametricWall` ist eine **reine Funktion** ohne
Side-Effects. Keine globale Laufzeit-Geometrie-Schicht.
- **Erzeugte Wände sind gewöhnliche `Wall`-Objekte.** Alle nachgelagerten Systeme
(`generatePlan`, `Viewport3D`, `computeJoins`) arbeiten unverändert; sie müssen
nicht zwischen „parametrisch erzeugten" und „direkt gezeichneten" Wänden
unterscheiden.
- **Fehlertoleranz statt Absturz.** Unbekannte Regeltypen liefern `[]`; der TypeScript-
exhaustiveness-Guard fängt fehlende `case`-Zweige zur Compilezeit. Unbekannte
Bedingungsstrings in `ConditionalThicknessRule` geben `false` (kein Treffer) statt
zu werfen.
- **Keine vorzeitige Generalisierung.** Phase A liefert fünf Regel-Varianten und
einen Auflöser. UI, Command-Schnittstelle und Grid-Ressource folgen in Phase B/C.
- **Immutabilität.** Verfeinerungsregeln (`ConditionalThicknessRule`,
`ReferenceLineRule`) geben modifizierte **Kopien** zurück; `existingWalls` werden
nie mutiert — konsistent mit der `setProject`-Konvention (CONVENTIONS.md).
+338
View File
@@ -0,0 +1,338 @@
# Design — Pläne & Output
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
> Bauteile: [elements.md](elements.md). Ressourcen/Stile: [resources-graphics.md](resources-graphics.md).
Hier gewinnen wir (ROADMAP §3, Phase 3 ⭐): **schöne, normgerechte 2D-Pläne**,
automatisch aus dem Modell abgeleitet, druckfertig als Vektor-PDF. Dieses Dokument
übersetzt DOSSIERs `schnitte.py`, `massstab.py`, `ausschnitte.py`, `kamera.py`,
`dimensionen.py`, `layouts.py` in Browser-Module. Bezeichner englisch, Prosa
deutsch, Meter.
---
## 1. Ansichtstypen = Kamera-Projektion + optionaler Schnitt
Vereinheitlichtes Modell (ROADMAP §2c, im Spike als `DrawingLevelKind` angelegt):
| Typ | Projektion | Schnitt | Erzeugung |
|---|---|---|---|
| **Grundriss** | Ortho Top | horizontal auf `okff + cutHeight` | symbolisch aus Footprint (Pfad A) |
| **Schnitt** | Ortho Front (Richtung) | vertikale Schnittebene + Tiefe | 3D-Projektion/HLR (Pfad B) |
| **Ansicht** | Ortho Front (Richtung) | kein Schnitt (Fassade außen) | 3D-Projektion/HLR (Pfad B) |
| **Perspektive** | 3D perspektivisch | — | Three.js direkt |
```ts
type ViewType = "plan" | "section" | "elevation" | "perspective";
interface DerivedView { // was der Viewport gerade zeigt
type: ViewType;
levelId?: string; // Geschoss (plan) bzw. Schnitt/Ansicht (DrawingLevel)
camera: CameraState;
cut?: CutSpec; // Clipping-Spezifikation (s.u.)
detailLevel: DetailLevel;
}
interface CutSpec {
planes: { point: Vec3; normal: Vec3 }[]; // 1 (plan/elevation) oder 2 (section: cut+back)
}
```
**Zwei Wege zum Plan** (zentrale Architektur-Erkenntnis, ROADMAP §3) — wir bauen
**beide**:
- **A) Grundriss = symbolisch** aus den Parametern (`plan/generatePlan.ts`, im
Spike). Schnell, exakt, vektorbasiert. Kein Mesh-Schnitt.
- **B) Schnitt & Ansicht = 3D-Projektion mit Hidden-Line-Removal** (`plan/
generateSection.ts`, §4). Durch das zusammengebaute Gebäude.
---
## 2. Schnitt & Ansicht — Datenmodell & Aktivierung
DOSSIER speichert Schnitte als Zeichnungsebenen-Eintrag (`type:"schnitt"`) mit
`linePts/dirSign/depthBack/cutAtLine/heightMin/heightMax/projection`
(`schnitte.create_schnitt_entry`). Im Spike sind die Felder als `DrawingLevel`
(`kind:"section"|"elevation"`, `linePoints`, `directionSign`) angelegt — wir
ergänzen:
```ts
interface SectionLevel extends DrawingLevel { // kind: "section" | "elevation"
linePoints: [Vec2, Vec2];
directionSign: 1 | -1; // Blickrichtung (Pfeil im Plan)
depthBack: number; // Tiefe hinter der Schnittlinie (default 8)
cutAtLine: boolean; // true=Schnitt (cut+back), false=Ansicht (nur back)
heightMin: number; heightMax: number;
projection: "parallel" | "perspective";
}
```
**Aktivierung** (Port `schnitte.activate_schnitt`):
1. `view_dir` = senkrecht zur Linie in XY, Richtung = `directionSign`.
2. **3D-Vorschau:** `THREE.Plane`s setzen —
- Cut (nur `cutAtLine`): auf der Linie, Normale `+view_dir`.
- Back (immer): um `depthBack` in `+view_dir` versetzt, Normale `−view_dir`.
- via `renderer.localClippingEnabled = true`, `material.clippingPlanes`.
3. **Kamera:** `OrthographicCamera`, Position `mid − view_dir·dist`, Target `mid`,
Up `+Z`; Zoom auf BBox (`linePoints` + Höhenbereich + `depthBack`). Bei
`perspective`: `PerspectiveCamera` + FOV.
4. **Vektor-Ergebnis:** HLR (§4).
**2D-Plan-Symbol** (Schnittmarke im Grundriss, Port `make_schnitt_symbol`): Linie
+ Endpfeile in `view_dir`, Beschriftung. Bleibt im Grundriss sichtbar (liegt auf
einer eigenen Ebene, z.B. `18 Schnittlinien`). **Doppelklick** auf das Symbol
aktiviert den Schnitt (`onDoubleClick` auf das SVG-Symbol → `setActiveLevel(id)`,
≙ DOSSIER `_SchnittDoubleClickHandler`).
**Grip-Editing der Schnittlinie:** Endpunkte als Grips im Grundriss; Ziehen
aktualisiert `linePoints` + Symbol + (falls aktiv) Clipping — ohne Re-Zoom der
3D-View (DOSSIER `skip_view`-Flag-Äquivalent: Drag aktualisiert nur die Clip-
Ebenen, nicht die Kamera).
---
## 3. Massstab (Scale) — pro Viewport, Auto-DPI
### 3.1 Mathematik (Port `massstab._compute_scale`, identisch im Browser)
```
frustumWidth_world = ortho-Kamera-Breite in Modell-Einheiten (Meter)
frustumWidth_mm = frustumWidth_world * 1000 (Meter→mm)
screenWidth_mm = canvasWidthCssPx * 25.4 / dpi
N (1:N) = frustumWidth_mm / screenWidth_mm
```
- **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER).
- **DPI:** Browser kennt das nativ — `dpi = 96 * window.devicePixelRatio` (CSS
definiert 1 px = 1/96 inch). Das ersetzt DOSSIERs CoreGraphics-JXA-Detection
komplett und ist exakter. Optional manuell kalibrierbar (Eingabe in den
Settings), persistiert pro Projekt.
- **Massstab setzen** (1:N → Zoom): `frustumWidth_world = screenWidth_mm · N /
1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx /
(frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen.
```ts
// plan/scale.ts
function computeScale(view: { frustumWidthWorld; canvasCssWidthPx; dpi }): number|null // 1:N
function applyScale(camera: THREE.OrthographicCamera, n: number, canvasCssWidthPx, dpi): void
const SCALE_PRESETS = [1,5,10,20,25,50,100,200,500,1000]; // 1:N Dropdown
```
### 3.2 Massstabs-abhängige Skalierung (DOSSIER-Stärke)
Bei 1:N müssen **Strichstärken** und **Schraffuren** lesbar bleiben:
- **Plotweight → SVG stroke-width:** `strokeWidthPx = lwMm / 25.4 · dpi`
(Welt-unabhängig; die Linie ist im Plan immer z.B. 0.25 mm dick). DOSSIER
skaliert dafür die PlotWeights (`_apply_scaled_lineweights`); im SVG-Modell
rechnen wir die mm-Strichstärke direkt in Pixel — **viel einfacher**, da SVG
von Natur aus papierbezogen ist.
- **Schraffur-Skalierung:** DOSSIER nutzt `factor = sqrt(N)/10` (1:100 ⇒ 1.0,
1:50 ⇒ 0.71, 1:500 ⇒ 2.24; `apply_scaled_hatches`). Port: SVG `<pattern>`-
`patternTransform="scale(factor)"` bzw. `patternUnits` so wählen, dass das Muster
die gewünschte Paper-Dichte hat. Formel 1:1 übernehmen.
- **Linetype-Dash:** `stroke-dasharray` in mm→px, ebenfalls papierbezogen.
> **Kernvorteil gegenüber DOSSIER:** Weil der Plan **SVG/Paper-Space** ist,
> entfällt das fragile Welt↔Bildschirm-Plotweight-Rescaling (DOSSIER `write_plotweight`,
> `read_plotweight`, Print-Display-Toggle). Strichstärke und Maßlinien sind direkt
> in mm definiert und werden 1:1 gedruckt.
---
## 4. Schnitt/Ansicht-Projektion (HLR) — Risiko #4
Vertikale Schnitte/Ansichten brauchen **echte 3D-Projektion mit verdeckten
Kanten** durch das zusammengebaute Gebäude.
```ts
// plan/generateSection.ts (läuft im Web Worker via Comlink)
interface SectionRequest { meshes: SerializedBrep[]; cut: CutSpec; camera: CameraState; }
interface SectionResult {
cutLines: Primitive[]; // Schnittkanten (dick) — geschnittene Bauteile
cutFaces: Primitive[]; // Schnittflächen → Component-Schraffur (Poché)
visibleLines: Primitive[]; // sichtbare Projektion (dünn)
hiddenLines?: Primitive[]; // verdeckte (gestrichelt, optional)
}
function generateSection(req: SectionRequest): SectionResult
```
- **Kernel:** **OpenCascade.js** `HLRBRep_Algo` / `HLRBRep_HLRToShape` (B-Rep →
sichtbare/verdeckte Kanten). Eingabe = die Bauteil-Breps (Wände/Decken/Treppen…),
Projektionsrichtung aus `camera`. Alternativ Mesh-basiert (langsamer, weniger
sauber).
- **Schnittflächen-Schraffur (Section-Style):** wo die Cut-Plane ein Bauteil
durchschneidet, entsteht eine Fläche → mit der Component-Schraffur füllen
(resources-graphics.md). ≙ DOSSIER `SectionStyle` (Hatch + Schnittkante +
Silhouette), nur dass wir es als SVG-Fill rendern statt als Rhino-Layer-Property.
- **Performance:** schwer → **Worker + Cache**. Cache-Key =
hash(sichtbare Element-IDs + Geometrie-Hash + CutSpec + camera). Nur neu rechnen,
wenn sich relevante Eingaben ändern (ROADMAP Risiko #4). Geschnittene vs. dahinter
liegende Geometrie über die Back-Plane begrenzen (`depthBack`).
- **Stufenweise:** (a) Ansicht ohne Verdeckung (einfache Projektion) → (b) HLR
sichtbar → (c) verdeckte Kanten gestrichelt → (d) Schnittflächen-Poché.
---
## 5. Ausschnitte (View-Snapshots)
Navigation über 50+ Ansichten ohne Ordner-Wildwuchs (DOSSIER `ausschnitte.py`).
Ein Snapshot speichert **Kamera + Sichtbarkeit + Massstab + Darstellung + Overrides**.
```ts
// in Project: viewSnapshots: ViewSnapshot[]
interface ViewSnapshot {
id; name; folder?: string;
camera: CameraState; // pos/target/up/parallel/fov + frustumWidth (Zoom!)
scale: number; // 1:N (DOSSIER speichert "1:50"-String)
detailLevel: DetailLevel; // LoD-Override (DOSSIER darstellung)
visibility: VisibilityState; // pro Geschoss + pro Ebene visible/locked
layerCombinationId?: string; // ODER Verweis auf Layer-Kombi (live) — s.u.
overrides?: { presetId?: string; enabled: boolean };
}
interface CameraState { position; target; up; parallel; fov?; frustumWidth?; }
```
- **Save:** aktuellen `ui`-Zustand einfrieren (Port `_capture`: Kamera inkl.
Frustum-Breite für exakten Zoom-Restore, Layer-Sichtbarkeit, Massstab, LoD).
- **Restore:** Snapshot → `ui` + ggf. `project`-Sichtbarkeit anwenden (Port
`_restore`): Kamera, Sichtbarkeit (oder referenzierte Layer-Kombi), LoD,
optional Overrides-Preset. Da alles im Store liegt, ist das ein einfacher
State-Set — kein Multi-Panel-Force-Send-Tanz wie in DOSSIER.
- **Ordner, Umbenennen, Duplizieren, Settings-Drawer** wie DOSSIER (`_duplicate`,
`_set_field`, `_open_settings_window` → React-Drawer statt Eto-Form).
### 5.1 Layer-Kombinationen (Presets)
```ts
interface LayerCombination { id; name; visibility: VisibilityState; }
```
Bauphasen/Varianten/MEP per Klick (DOSSIER `_save_preset`/`apply_layer_preset_by_name`).
Snapshot kann **live** auf eine Kombi verweisen (folgt Änderungen) **oder**
eingefroren den `visibility`-Stand halten — genau DOSSIERs Wahl (`layerCombination`
vs. `layers`).
---
## 6. Kamera-Presets & Norden-Rotation ⭐
Port `kamera.py`. Schnelle Ansichtswechsel + Georeferenzierung (Swisstopo, Phase 4).
```ts
// viewport/camera.ts
function setCardinal(cam, dir: "N"|"E"|"S"|"W", northAngle: number): void
function setIso(cam, octant: "NE"|"SE"|"SW"|"NW"|..., northAngle: number): void
function setTop(cam, northAngle: number): void // Plan-Norden zeigt nach oben
// northAngle = Grad im Uhrzeigersinn von +Y (DOSSIER dossier_north_angle, default 0)
const north = (deg) => ({ x: Math.sin(rad(deg)), y: Math.cos(rad(deg)) });
interface CameraPreset { id; name; camera: CameraState; } // benutzerdefiniert, gespeichert
```
- **Norden-Rotation:** alle Kardinal-/Iso-Richtungen werden um `northAngle`
rotiert (Port `set_cardinal_view`, `_set_iso`, `set_top_view`). `northAngle`
liegt im `Project` (georeferenziert zu swissBUILDINGS).
- **Benutzer-Presets:** speichern/laden wie DOSSIER (`_load_presets`/`_save_presets`).
---
## 7. Bemaßung (Dimensions)
Port `dimensionen.py`. Maße werden **aus dem Modell abgeleitet** (Wand-Dicken,
Geschoss-Höhen, Öffnungen) + manuelle Maßketten.
```ts
interface Dimension {
id; floorId; categoryCode; // liegt auf einer Ebene
kind: "linear" | "chain" | "aligned" | "level"; // Einzel|Kette|ausgerichtet|Höhenkote
refs: DimRef[]; // Bezugspunkte (frei ODER an Element gebunden)
offset: number; // Abstand der Maßlinie vom Objekt
style: DimStyleId; // Pfeile, Texthöhe, Einheiten
}
type DimRef = { point: Vec2 } | { elementId: string; anchor: "start"|"end"|"jamb"|... };
```
- **Auto-Bemaßung** (Phase 3): Außenketten (Gebäude-Hülle), Achsketten (Achsraster),
Öffnungs-Ketten — aus der Geometrie generiert, dann editierbar.
- **9-Punkt-Objekt-Info** (DOSSIER ROADMAP §11): Bounding-Box-Maße lesen +
Element via Greifen verschieben/skalieren/rotieren — direkt im Plan.
- **Rich-Text-Indizes** (Bold/Hoch-/Tiefstellung) für Maßzahlen — als SVG
`<tspan>` mit `baseline-shift` (resources-graphics.md §Rich-Text).
- **Massstabsbezug:** Texthöhe/Pfeilgröße in **Paper-mm**, rendern × Massstab —
konsistent mit §3.2.
---
## 8. Plansätze (Sheets) & PDF-Export
DOSSIER nutzt Rhinos `RhinoPageView` + `Detail`-Viewports + `FilePdf`
(`layouts.py`). Browser-Äquivalent: eigenes Sheet-Modell + SVG → PDF.
### 8.1 Datenmodell
```ts
interface Sheet {
id; name; folder?;
paper: "A0"|"A1"|"A2"|"A3"|"A4"|"Letter"; landscape: boolean;
viewports: SheetViewport[];
titleBlock?: TitleBlock; // Titelblock (Projekt/Plan/Massstab/Datum)
}
interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt
id; rect: { x; y; w; h }; // Position auf dem Blatt (mm)
source: { kind: "level"; levelId } | { kind: "snapshot"; snapshotId };
scale: number; // 1:N
clipToRect: boolean;
}
const PAPER_MM = { A0:[841,1189], A1:[594,841], A2:[420,594], A3:[297,420],
A4:[210,297], Letter:[216,279] }; // Port PAPER_SIZES_MM
```
### 8.2 Sheet-Editor
`sheets/SheetEditor.tsx`: Blatt als SVG in mm, Viewports per Drag platzieren/
skalieren, Quelle (Geschoss/Snapshot) + Massstab zuweisen. Ein Viewport rendert
den abgeleiteten Plan/Schnitt **bei seinem Massstab** in sein `rect` (≙ DOSSIER
`apply_snapshot_to_detail`). Bei Änderung der Quelle re-derivieren (live), kein
manuelles Re-Sync nötig (DOSSIER war Snapshot-Mode).
### 8.3 Detail↔Ausschnitt-Bindung
`SheetViewport.source.snapshotId` ist die Bindung (DOSSIER `_BIND_KEY`). „Alle
aktualisieren" = alle Viewports neu rendern; weil rein abgeleitet, ist das
automatisch. Umbenennen synchronisiert Titelblock + Schnitt-Symbol (DOSSIER
Detail↔Ausschnitt-Sync).
### 8.4 PDF-Export (Vektor, Multi-Page, @DPI)
Port `layouts._export_pdf`, aber **vektorbasiert** (DOSSIER rasterte via
`ViewCaptureToFile` @DPI — wir bleiben Vektor → schärfer, kleiner):
```ts
// sheets/exportPdf.ts
async function exportSheetsPdf(sheets: Sheet[], opts: { vector: boolean }): Promise<Blob>
```
- **Vektor-Pfad (bevorzugt):** jeder Sheet-Viewport rendert seinen Plan als SVG;
SVG → PDF via **`svg2pdf.js` + `jsPDF`** (oder `pdf-lib` mit eigenem Pfad-
Emit). Eine PDF-Seite pro Sheet, Größe = `PAPER_MM`. Strichstärken/Schraffuren
sind bereits in mm (§3.2) → 1:1 druckbar.
- **Raster-Fallback** (Perspektiven/3D-Inhalte): Three.js `renderer` → Canvas →
PNG @DPI → in PDF-Seite (`px = mm/25.4·dpi`, Port der DOSSIER-Pixelrechnung).
- **Speichern:** Blob → File System Access API (`showSaveFilePicker`) / Download.
---
## 9. Primitive & SVG-Serializer (gemeinsame Basis)
Alle Pläne (Grundriss, Schnitt, Ansicht, Sheet-Viewport) sprechen dieselbe
`Primitive`-Sprache (heute in `generatePlan.ts`), erweitert um Schraffur/Text:
```ts
type Primitive =
| { kind:"polygon"; pts:Vec2[]; fill:string; stroke:string; strokeWidthMm:number; hatchId?:string }
| { kind:"line"; a:Vec2; b:Vec2; styleId:string } // styleId → LineStyle (mm, dash)
| { kind:"arc"; center:Vec2; from:Vec2; to:Vec2; r:number; styleId:string }
| { kind:"text"; at:Vec2; text:string; heightMm:number; align; font; rich?:RichRun[] }
| { kind:"symbol"; at:Vec2; symbolId:string; scale:number; angle:number }; // Symbol-Bibliothek
interface Plan { primitives: Primitive[]; bounds: Rect; }
```
- **SVG-Serializer** (`plan/primitives.ts`): Primitive → SVG-Elemente.
`strokeWidthMm` → px via `mm·dpi/25.4`; `hatchId` → `<pattern>`-Referenz;
`styleId` → `stroke`/`stroke-dasharray`. Derselbe Serializer für Bildschirm
*und* PDF.
- **DXF-Export** (Phase 4): dieselben Primitive → DXF-Entities (`dxf`-Writer-lib).
---
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
1. **Phase 1 (MVP):** Grundriss-Generator ✅ ausbauen (Schraffuren, LoD), Live-
Grundriss neben 3D, Basis-Bemaßung; Massstab pro Viewport (§3).
2. **Phase 3 ⭐:** Schnitt/Ansicht via HLR (§4, Worker), Auto-Bemaßung (§7),
Ausschnitte + Layer-Kombinationen (§5), Kamera-Presets + Norden (§6),
Sheets + Vektor-PDF (§8).
3. **Phase 4:** DXF-Export (§9), Detail↔Ausschnitt-Sync-Politur.
+293
View File
@@ -0,0 +1,293 @@
# Design — Ressourcen & Grafik
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
> Bauteile: [elements.md](elements.md). Output/Pläne: [plans-output.md](plans-output.md).
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Vectorworks-Stil),
regelbasierte Overrides, Symbol-/Text-Bibliotheken und Detailgrad-Steuerung. Sie
wird **beim Rendern angewandt, nie in die Geometrie eingebacken** (ROADMAP §2b).
Dieses Dokument übersetzt DOSSIERs `styles.py`/`gestaltung.py`, `mass_style.py`,
`overrides.py`, `library.py`, `text_create.py`. Bezeichner englisch, Prosa
deutsch.
---
## 1. Resource Manager — verwaltete Bibliotheken
Drei Bibliotheken im `Project.resources`-Block; **alles verweist per id**, zentral
änderbar (ROADMAP §2d). Verweis-Kette: 2D-Objekte/Ebenen → LineStyle/Hatch;
Hatch → LineStyle; Component → Hatch (+3D-Material).
```ts
interface Resources {
lineStyles: LineStyle[];
hatches: Hatch[];
components: Component[];
}
interface LineStyle { // Line Manager
id; name;
weight: number; // Strichstärke in mm (≙ Rhino PlotWeight)
color: string; // hex
dash: number[]; // Strichmuster in mm ([] = durchgezogen)
}
interface Hatch { // Hatch Manager
id; name;
pattern: PatternId; // "solid" | "diagonal" | "insulation" | "concrete" | ...
scale: number; // Grundmaßstab des Musters
angle: number; // Grad
lineStyleId: string; // Linien der Schraffur → Line Manager
}
interface Component { // Component Manager (= DOSSIER-Material, erweitert)
id; name;
hatchId: string; // Schnitt-Schraffur → Hatch Manager
color3d: string; // 3D-Diffusfarbe
texture3d?: TextureRef; // optionale PBR-Textur (Phase 3)
pbr?: { roughness; metalness; opacity; ior }; // Material-Bibliothek (ROADMAP §11)
joinPriority: number; // Verschneidungs-Rang (DOSSIER _MATERIAL_PRIO als Daten)
}
```
**Migration vom heutigen Stand:** Der Spike hat `Material { color, planFill, hatch }`
und `Layer { materialId, thickness, priority }`. Ziel: `Material → Component`
(`color→color3d`, `planFill→` Fill aus `hatch.pattern==solid`+Farbe, `hatch`-Enum
→ `hatchId`), `Layer.priority → Component.joinPriority` (Priorität wandert vom
Layer zum Component, damit man sie nur einmal pflegt — siehe elements.md §1.3).
### 1.1 Manager-UI
`managers/ComponentManager.tsx`, `HatchManager.tsx`, `LineManager.tsx` — je eine
Liste mit CRUD + Vorschau (Three-Sphere für Component-3D, SVG-Swatch für
Hatch/Line). **Seeds** beim ersten Projekt (DOSSIER-Defaults):
- LineStyles: 0.13 / 0.18 / 0.25 / 0.35 / 0.50 mm (aus `DEFAULT_LAYER_SCHEMA`-lw).
- Hatches: `solid`, `diagonal`, `concrete`, `insulation` (Dämmung).
- Components: Stahlbeton (prio 800), Beton (800), Mauerwerk (600), Ziegel (550),
Holzständer (400), Dämmung (200), Putz (100) — exakt DOSSIER `_MATERIAL_PRIO`
(elements.md §1.3). Plus Glas (transparent), Holz-Türblatt (DOSSIER
`_OEFF_PIECE_DEFS`).
### 1.2 Render-Anwendung
- **3D:** Component → `MeshStandardMaterial` (`color3d`/`pbr`), pro `componentId`
gecacht (`viewport/scene.ts`).
- **Plan/Schnitt:** geschnittene Schicht → Polygon mit `fill` (Component-Farbe) +
`<pattern>` aus `hatchId`. Pattern als SVG `<pattern>` mit `patternTransform`
für Massstab (plans-output.md §3.2). Der Hatch nutzt seinen `lineStyleId` für
die Musterlinien.
---
## 2. Mehrschichtige Aufbauten ↔ Ressourcen
`WallType.layers[].componentId` / `SlabType.layers[].componentId` verweisen auf
Components. 3D und Plan lesen dieselben Schichten (elements.md §1). Die
**Prioritäts-Verschneidung** (Risiko #1) liest `Component.joinPriority`:
höhere Priorität läuft am Stoß durch (Backbone), niedrigere stößt seitlich an —
Algorithmus in elements.md §1.3 (Port DOSSIER `_t_junction_layer_overrides`).
---
## 3. Stile & Element-Override (Selektions-Attribute)
DOSSIERs GESTALTUNG-Panel (`styles.py`) setzt Farbe/Lineweight/Linetype/Hatch auf
die *Selektion*. Browser-Äquivalent — zwei Ebenen, in Render-Reihenfolge:
```
ByLayer (Ebenen-Default) → Element-Style (styleId) → Override-Regeln → gerendert
```
```ts
// Effektiver Stil eines Elements (resolve beim Rendern, nie persistiert)
interface EffectiveStyle { color; lineStyleId; hatchId?; }
function resolveStyle(project, el, doc): EffectiveStyle {
// 1) Default aus LayerCategory(categoryCode)
// 2) überschrieben durch el.styleId (Element-Override, optional)
// 3) überschrieben durch passende Override-Regeln (§4)
}
```
- **Wall-/Opening-Stil-Kataloge** (Presets, ROADMAP §11): benannte Sätze von
Default-Werten (Wandtyp + Farbe + lw; Öffnung mit Rahmen/Sims/…). 1-Klick-
Anwendung, globaler Stilwechsel. Speicherung: pro Projekt + cross-Projekt
(LocalStorage), Seed wie DOSSIER `_OEFF_DEFAULT_STYLES` (elements.md §2.1).
- **LoD-bewusste Stil-UI** (DOSSIER ROADMAP §11): das Stil-Panel zeigt nur
passende Controls je Geometrietyp (keine Füll-Optionen bei einer 3D-/Linien-
Auswahl). `panels/StylePanel.tsx` schaltet Felder nach `selection`-Typ.
- **Pipette:** Stil/Typ von einem Element auf ein anderes übernehmen (DOSSIER
`cmd/pipette`).
---
## 4. Regelbasierte Overrides (Engine)
Port `overrides.py` (ArchiCAD Graphical Overrides / Vectorworks
Datenvisualisierung). Im Browser **viel einfacher**, weil Overrides reine
**Render-Transformationen** sind — kein UserString-Backup/Restore nötig (DOSSIER
musste Originalwerte sichern, weil es echte Rhino-Objekte mutierte; wir mutieren
nichts).
```ts
interface OverrideConfig { enabled: boolean; rules: OverrideRule[]; activePresetId?: string; }
interface OverrideRule {
id; name; enabled: boolean;
conditions: Condition[]; conditionsLogic: "and" | "or";
actions: { color?: string; lineWeight?: number; lineStyleId?: string;
hatchId?: string; hatchScale?: number };
}
interface Condition {
type: "category" | "userField" | "name" | "elementType"; // ≙ layer_name/user_string/object_name
operator: "equals"|"notEquals"|"contains"|"startsWith"|"endsWith";
value: string;
key?: string; // nur für userField (z.B. "sia")
}
```
**Auswertung** (Port `_compose_overrides`):
```ts
function composeOverrides(el, project, cfg): Partial<Actions> {
// additive: Actions aller matchenden, aktiven Regeln kombinieren;
// bei Konflikt für dieselbe Property gewinnt die Regel WEITER OBEN (kleinerer Index).
}
```
- **Anwendung:** `resolveStyle` (§3) ruft `composeOverrides` — Override liegt
über Element-Style. Reines Read beim Rendern → kein `apply_all`/`restore_all`,
kein Backup, **keine reversibilität nötig**. Toggle `enabled` rendert neu.
- **Live:** Da abgeleitet, schlägt jede Modell-/Regel-Änderung sofort durch (kein
`install_listeners`/`AddRhinoObject`-Hook wie DOSSIER).
- **Presets & Templates** (cross-Projekt): Preset = Satz Regeln, Template =
einzelne Regel; LocalStorage statt `~/Library/.../override_presets.json`
(`save_preset`/`load_preset`/`list_rule_templates` → `resources/overridePresets.ts`).
- **SIA-416-Preset** (elements.md §7): vier Regeln `userField sia == HNF|NNF|VF|FF`
→ Farbe + Solid-Hatch, Port `_build_sia_preset_rules`. Aktivieren = Preset
`activePresetId` setzen.
```ts
// resources/overrides.ts
function composeOverrides(el, project, cfg): Partial<OverrideAction>
function setActivePreset(project, presetId): Project // immutabel
const PRESETS_NS = "cad.presets.overrides"; // LocalStorage
```
---
## 5. Symbol-Bibliothek
Port `library.py` + `cmd/symbol`. Wiederverwendbare 2D-Symbole (Möbel, Sanitär,
Bäume, Nordpfeil, Pflanzen) für den Plan.
```ts
interface Symbol {
id; name; category: string; // "furniture" | "sanitary" | "vegetation" | "annotation"
svgPath: string; // Pfad-/Gruppen-Markup im Symbol-Koordinatensystem (m)
defaultScale: number;
}
interface SymbolInstance extends ElementBase { // type:"draw2d", subtype:"symbol"
symbolId: string; at: Vec2; scale: number; angle: number;
}
```
- **Speicherung:** mitgelieferte Symbole als statische Assets (SVG); Nutzer-
Symbole im Projekt + cross-Projekt (LocalStorage). DOSSIER nutzt Block-
Definitionen; bei uns SVG-Definition + Instanz-Transform (`<use>`-artig).
- **Picker:** `panels/SymbolPicker.tsx` (≙ DOSSIER `SymbolPicker.jsx`) — Grid mit
Vorschau, Drag in den Plan; Instanz auf Ebene `60 Plangrafik` (bzw. `22 Möbel`).
- **Render:** `Primitive{ kind:"symbol", ... }` → SVG `<g transform>` mit dem
Symbol-Markup (plans-output.md §9).
---
## 6. Text & Rich-Text-Annotationen
Port `text_create.py` + `text_editor.py`. Formatierte Beschriftungen auf Canvas.
```ts
interface TextElement extends ElementBase { // type:"draw2d", subtype:"text"
at: Vec2; runs: RichRun[];
heightMm: number; // Paper-mm (rendern × Massstab) ODER Modell-m
heightMode: "paper" | "model"; // DOSSIER raum_txt_modus fix|masstab
font: string; align: "left"|"mid"|"right"; angle: number;
mask?: boolean; // Hintergrund-Maskierung (verdeckt Linien darunter)
frame?: boolean; // Rahmen um den Text
}
interface RichRun {
text: string;
bold?; italic?; super?; sub?; // Hoch-/Tiefstellung (Maß-Indizes)
}
interface TextStyle { id; name; font; heightMm; bold; italic; } // Text-Presets
```
- **Render:** `<text>` mit `<tspan>` pro Run; `super/sub` via `baseline-shift` +
kleinerer `font-size`; `mask` via weißem `<rect>` darunter; `frame` via `<rect>`.
- **Editor:** Inline-Rich-Text-Editor (contentEditable oder leichter Custom-Editor)
→ `RichRun[]`. Fonts aus einer kuratierten Web-Font-Liste + System-Fonts
(DOSSIER `_list_system_fonts` mit Preferred-Liste DM Mono/Krungthep/…); im
Browser via `document.fonts` / `queryLocalFonts()` (wo verfügbar) + gebündelte
Web-Fonts.
- **Massstabsbezug:** `heightMode:"paper"` → Texthöhe in mm, gerendert × Massstab
(plans-output.md §3) — Beschriftung bleibt bei jedem Massstab lesbar.
---
## 7. Detailgrad (Level of Detail)
Querschnittsthema (ROADMAP §2b „Modelldarstellungen"). Drei Stufen, Dokument-/
Snapshot-weiter Override mit Per-Element-Ausnahme — DOSSIER
`darstellung`/`aktive_darstellung`.
```ts
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
// Auflösung (Port _resolve_oeff_darstellung):
function resolveDetail(el, doc): DetailLevel {
const v = el.detailLevel ?? "auto";
return v === "auto" ? doc.detailLevel : v; // doc-Level hat IMMER konkreten Wert
}
```
- **Dokument-Ebene:** `Project.detailLevel` (Default `coarse`/einfach = 1:100,
DOSSIER `_DARSTELLUNG_DEFAULT_GLOBAL`). In der TopBar global umschaltbar; ein
ViewSnapshot kann ihn pro Ansicht überschreiben (plans-output.md §5).
- **Wirkung:** jedes Bauteil-`generatePlan`/`build3d` liest den aufgelösten LoD und
zeichnet entsprechend (elements.md: Tür coarse=Lücke, fine=Glas/Schwenkbogen/
Sims). Kritisch für Mixed-Scale-Pläne (1:50 Detail neben 1:200 Übersicht).
- **Schnitt-Schraffur** koppelt an LoD: grob ggf. nur Umriss, fein voll schraffiert.
---
## 8. Section-Style (3D-Schnittflächen)
Port DOSSIER `_apply_section_style` (`layer_builder.py`). Wo die Schnittebene ein
Bauteil durchschneidet: Schnittfläche bekommt die **Component-Schraffur**, die
Schnittkante einen dicken Rand, optional eine Silhouette.
```ts
interface SectionStyle { // pro Component (oder Ebene) ableitbar
hatchId?: string; hatchScale; hatchAngle;
boundaryShow: boolean; boundaryLineStyleId; boundaryWidthScale;
fillBackground: boolean;
}
```
- **3D-Viewport:** Three.js hat keinen nativen „Schnittflächen-Cap". Cap-Geometrie
selbst erzeugen: Schnittpolygon der Cut-Plane mit den Breps → Fläche mit
Hatch-Material (oder Stencil-Cap-Technik). Phase 4.
- **2D-Schnitt (SVG):** `generateSection` (plans-output.md §4) liefert `cutFaces`
→ mit `SectionStyle.hatchId` füllen. Das ist der Hauptweg; der 3D-Cap ist Bonus.
---
## 9. Was Browser hier einfacher macht (vs. DOSSIER)
| DOSSIER-Aufwand | entfällt im Browser, weil … |
|---|---|
| UserString-Backup/Restore bei Overrides (`_backup_original`/`_restore_original`) | Overrides sind reine Render-Reads — nichts wird mutiert |
| Hatch-Curve-Link über Sticky (`gestaltung_curve_hatch`, Pending-TTL) | Schraffur ist eine Eigenschaft des Polygons, kein separates Objekt |
| Plotweight-Welt↔Bildschirm-Rescaling (`write/read_plotweight`) | Strichstärke ist in mm im SVG-Paper-Space (plans-output.md §3.2) |
| `install_listeners` für Live-Override-Reapply | reaktiver Store re-rendert automatisch |
| SectionStyle-API-Reflection über Rhino-Versionen | wir definieren das Rendering selbst (SVG/Three) |
| Cross-doc Presets als Dateien im User-Home | LocalStorage + Export/Import |
---
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
1. **Phase 1:** Component/Hatch/Line-Manager (§1) + `resolveStyle` (§3) +
LoD-Grundgerüst (§7) + LoD-bewusste Stil-UI.
2. **Phase 2:** Stil-Kataloge (Wände/Öffnungen), Material-Seeds mit `joinPriority`.
3. **Phase 3:** Overrides-Engine + SIA-Preset (§4), Symbol-Bibliothek (§5),
Rich-Text (§6), PBR-Material-Bibliothek; massstabsabhängige Hatch-/Linetype-
Skalierung (plans-output.md §3.2).
4. **Phase 4:** Section-Style 3D-Cap (§8).
+370
View File
@@ -0,0 +1,370 @@
# Handoff — Rhino-artiges Befehlssystem + Modellier-Werkzeuge
> Für die Instanz, die das Befehlssystem (Tab-getriggert) und die Rhino-artigen
> Modellierfunktionen baut. Stand: 2026-06-30. Zuerst lesen: `CONVENTIONS.md`,
> `ROADMAP.md`, `HANDOVER.md`, `docs/design/drawing-tools.md`. Volle Autonomie,
> selbst bestätigen (Memory `proceed-autonomously`, `wire-dont-stub`, `prefer-agents`).
Dieses Dokument hat drei Teile:
1. **Was schon steht** (worauf du aufbaust — exakte Dateien/Typen/Actions).
2. **Rhino-Referenz** (Interaktionsmodell, das nachzubilden ist).
3. **Konkreter Bauplan für DIESE Codebase** (Architektur, Dateien, Reihenfolge).
---
## TL;DR — die Kernidee
Es gibt **kein** Befehlssystem, keine Command-Line, keinen Tab-Handler. Das baust du
greenfield. **Aber:** Das Werkzeug-System ist bereits eine saubere Pure-Function-Registry
(`src/tools/`) mit generischem Controller in `App.tsx`, und die Mutations-Schicht
(`projectSlice`) ist umfassend. Ein neues Modellier-Tool steckst du durch Hinzufügen eines
`Tool`-Objekts ein — **PlanView muss dafür nicht angefasst werden**.
Das Befehlssystem ist im Kern eine **State-Machine-Engine über prompt → pick/type →
options**, plus eine **Command-Line-UI** (Statusleiste), plus ein **Koordinaten-Parser**.
Befehle dispatchen auf bestehende Store-Actions + `setActiveTool`/`setProject`.
**Wichtigster konzeptioneller Sprung:** Die heutigen Tools haben je eine *eigene*
ad-hoc-Phasenlogik (`onClick`/`onMove`). Rhino-Feel verlangt eine **gemeinsame
Prompt/Option/Numerik-Engine**, die alle Befehle teilen. Plane das als Verallgemeinerung
des bestehenden `Tool`-Interfaces, nicht als Parallelwelt daneben (sonst zwei Eingabe-Pfade,
die divergieren).
---
# TEIL 1 — Was schon steht (Baufundament)
Stack: React 18 + TS + Vite, `three` 0.169 (nur 3D-Display). Einheiten intern **Meter**.
Eigener winziger Store (`useSyncExternalStore`, kein Redux/Zustand). Identifier englisch,
UI-Text deutsch via `t()`. Strict tsc (`noUnusedLocals` → ungenutzte Vars brechen den Build).
## 1.1 Werkzeug-System — `src/tools/`
- **`Tool`-Interface** `src/tools/types.ts:139` — reine Funktionen über internen `ToolState`
(Discriminated Union je Tool). Handler geben `[nextState, ToolResult]` zurück. Tools
schreiben NIE Plan-Primitive; sie geben `commit(project) => project` zurück.
- `ToolId` `types.ts:11`: `"select" | "wall" | "line" | "polyline" | "rect"`.
- `ToolContext` `types.ts:72`: `{ project, level, defaultCategoryCode, activeWallTypeId, activeLineStyleId }`.
- `ToolPointer` `types.ts:85`: `{ raw, snap, point (=snap?.point ?? raw), shift, ctrl, alt, button }`.
- `ToolResult` `types.ts:121`: `{ draft, commit?, done? }`. `ToolDraft` `types.ts:109`:
`{ preview: DraftShape[], vertices: Vec2[], snap?, hud?: {at,text} }`.
- **Registry** `src/tools/tools.ts:375`: `TOOLS: Record<ToolId,Tool>`, `getTool(id)` `:384`,
`TOOL_ORDER` `:389`. Implementiert: select (Platzhalter), wall, line, polyline, rect.
- `uniqueId(prefix)` `types.ts:188` — ID-Generator.
## 1.2 Controller / Verdrahtung — alles in `App.tsx` (NICHT in den Tool-Dateien)
- Aktives Tool: `const [activeTool,setActiveTool]=useState<ToolId>("select")` `App.tsx:197`.
- Laufender Zustand in **Ref** `toolStateRef` `App.tsx:205` (kein Re-Render je Mausschritt).
Live-Vorschau `const [draft,setDraft]` `App.tsx:206`.
- **Controller** `runToolStep(kind,raw,pxPerMeter,mods)` `App.tsx:350`: baut `ToolContext`
(`toolCtx` `:294`), snappt via `snapFor` `:322` (→ `computeSnap`), baut `ToolPointer`,
ruft `tool.onClick/onMove`, speichert State in Ref, `applyToolResult` `:339` wendet
draft/commit/done an. `toolHandlers` `App.tsx:419` verbindet PlanView↔Controller.
- Tool-Tasten `App.tsx:448`: Esc=Abbruch/zurück-zu-select, Enter=Commit-Geste, Backspace=Punkt zurück.
## 1.3 Semantisches Modell — `src/model/types.ts`
- `type Element = Wall | Door | Drawing2D` `:251`. `Vec2={x,y}`. **Kein Slab/Stair-Typ.**
- `Project` `:254`: `{ ..., wallTypes[], drawingLevels[], layers[], walls[], doors[], drawings2d[] }`.
- `Wall` `:150`: `{ id,type:"wall", floorId, categoryCode, start, end, wallTypeId, height, color? }`
(Mittellinie + mehrschichtiger `WallType`).
- **Plan-Primitive `Drawing2DGeom`** `:200` — die Geom-Typen existieren bereits ALLE:
`line | polyline | rect | circle | arc | text`. Aber Tools erzeugen heute nur line/polyline/rect,
und `drawingVertices` (Grips) kennt nur diese drei. **circle/arc/text sind im Typ da, aber
nicht durchgängig gerendert/editierbar** — Lücke, kein Neubau nötig.
- `Drawing2D` `:209`: `{ id,type:"drawing2d", levelId, categoryCode, geom, lineStyleId?, hatchId?, color?, fillColor?, weightMm? }`.
## 1.4 Geometrie — `src/model/geometry.ts`
`sub,add,scale,len,normalize`; `leftNormal(a)={x:-a.y,y:a.x}` `:17` (Wand-Normale-Konvention);
`cross`, `lineIntersect(a,da,b,db)`, `along`, `wallBand`, `wallCorners` `:70`, `clippedBand` `:87`.
`src/model/joins.ts`: `computeJoins(project,walls)` `:44` (nur L-Ecken gehrt; T/X eckig).
**Für Offset/Trim/Fillet (Rhino-Kern) gibt es NOCH KEIN 2D-Geometrie-Kernel** — Kurven/Kurven-
Schnitt, Polylinien-Offset usw. musst du ergänzen (siehe Bauplan §3.4).
## 1.5 Store — `src/state/`
- `createStore` `store.ts:51` über `useSyncExternalStore`. **Actions leben IM State**
(`useStore(s=>s.action)`, referenzstabil). `RootState = Project & Selection & View & Layout`
`appStore.ts:21`. Exports `useStore`, `getState`, `setState`.
- **projectSlice**: `project` + `setProject(next|(p)=>p)`. Mutationen u.a. `addFloor`,
`addCategory`, `setElementColor/Weight/Fill`, `resizeElement`, `moveGripOf`, `moveElementByOf`,
`moveEdgeOf`, `commitTransformOn`. **Es gibt keine generische „addWall/addDrawing2d"-Action** —
Tools committen via `setProject`. (Beim Befehlssystem ggf. saubere Actions ergänzen.)
- **Aktive Zeichenebene + aktive Kategorie liegen im viewSlice**, NICHT in selection:
`activeLevelId` `viewSlice.ts:46`/`setActiveLevelId`, `activeCategoryCode` `:42`/`setActiveCategoryCode`.
- selectionSlice: `selectedWallIds[]`, `selectedDrawingId` + Setter/`clearSelection`.
## 1.6 Views, Eingabe, Koordinaten — `src/plan/PlanView.tsx` (SVG-Vektor)
- Modell → `Plan`-Primitive via `generatePlan` `src/plan/generatePlan.ts:203`. `Primitive` =
`polygon|line|arc` (polygons tragen `wallId`/`drawingId` für Hit-Test).
- **Transform (entscheidend):** `PX_PER_M=90` `:20`; `toScreen(p)={x:p.x*90,y:-p.y*90}` `:31`
(fixer Welt-Ursprung 0,0; Y flippt). Invers `viewToModel` `:440`. SVG `viewBox`=State `view`;
Pan/Zoom ändern nur `view`, nie das Modell↔Screen-Mapping.
- **`rawModelAt(clientX,clientY)`** `:446` = aktuelle Mauswelt-Position in Meter (der Eine-Aufruf,
den ein Tool/Befehl braucht). `currentPxPerMeter()` `:488`.
- **Pointer-Events** alle am `<svg>` `:910`: down `:553`, move `:625`, up `:715`, wheel `:820`,
dblclick `:847`, contextmenu `:857`. Schema: Mitte=Pan, Links=Select/Marquee/Tool, Rechts=Menü.
Bei `toolActive` `:268` routen Links-Events zu `toolHandlers`. **PlanView meldet bereits
`(rawModelAt, currentPxPerMeter, toolMods)` nach oben** — neue Tools brauchen hier NICHTS.
- **Snapping** `src/tools/snapping.ts`: `computeSnap(input)` `:88` — endpoint/midpoint/intersection/
onEdge/grid/ortho mit Prioritätstabelle. Wird in App (`snapFor`) konsumiert, nicht in PlanView.
`applyAngleConstraint` für Ortho. `SnapSettings`/`DEFAULT_SNAP` in `tools/types.ts:36/55`.
- 3D `src/viewport/Viewport3D.tsx` (three.js, Raycaster): nur Anzeige+Auswahl, **keine
Zeichenwerkzeuge**. 3D-Authoring = eigene spätere Phase (Raycast auf Arbeitsebene).
## 1.7 Tastatur / globale Eingabe — **kein Dispatch-System**
- `main.tsx:38` globaler `contextmenu`→preventDefault; `:42` blockt Ctrl/Cmd+A außerhalb Inputs;
`isTextEntry(el)` `:24`.
- App-useEffects mit `window.addEventListener("keydown")`: Tool-Tasten `:448`, Delete `:604`,
Transform-Shortcuts m/s/d + u/i/o/p `:637`. **Jeder Guard wiederholt inline den
INPUT/TEXTAREA/contentEditable-Check** — es gibt keine geteilte Keymap. Dein Tab-Handler +
Command-Input kommt als neuer globaler `keydown` dazu (siehe §3.2).
## 1.8 UI-Shell + i18n
- `App.tsx` (~2200 Z., enthält noch ToolController/Grips/Transform). JSX `:1043`: TopBar → body
(Dock links, Content-View-Router, TransformBar, Dock rechts, Floating) → StatusBar →
ResourceManager → ContextMenu → InlineEditor. Panel-Daten via `PanelHostContext` (`baseHost`
`App.tsx:729`, Typ `host.ts`).
- `StatusBar.tsx` — Footer: links `hint` (Tool-Hinweis), rechts X/Y, Einheit, Massstab 1:N, Zoom,
aktives Geschoss, aktive Ebene. **Bester Ort für die Command-Line** (Rhino hat sie klassisch unten).
- **i18n** `src/i18n/`: `t(key,params?)` `index.ts:67`, `useT()` `:84`. Flaches `as const`-Dict,
Punkt-Namespaces (`tool.*`,`snap.*`,`transform.*`,`status.*`…). `de.ts` (Quelle, ~309 Keys) +
`en.ts`; `TranslationKey=keyof typeof de` erzwingt Parität. **Neue Keys IMMER in beide Dateien.**
Keine hartcodierten JSX-Strings.
## 1.9 Verifizieren
- `npx tsc -b` · `npm run build` · Dev `npm run dev` (Vite 5173, `host:true`).
- Screenshot `node scripts/probe.mjs` → `scripts/probe.png` (Puppeteer headless, `deviceScaleFactor:2`,
URL via `PROBE_URL`). Viele task-Probes existieren (`probe-tools.mjs`, `probe-line.mjs`,
`probe-transform.mjs` …) — gute Vorlagen, um Tools/Befehle programmatisch zu treiben.
**Screenshot ansehen + Geometrie prüfen**, nicht nur „kompiliert".
---
# TEIL 2 — Rhino-Referenz (das Interaktionsmodell)
## 2.1 Die Command-Line ist das Rückgrat
**Alles ist ein Befehl**, und die Command-Line **hört immer zu**: Tastenanschläge gehen an die
Command-Line, wenn sie nicht von einem Feld konsumiert werden. Kein „Tool aktiv vs. Eingabe aktiv".
Die Zeile hat gleichzeitig drei Rollen: **Eingabe** (Befehl/Wert tippen), **Prompt**
(„Start of line", „Next point"), **Optionen** (eckige, klickbare Inline-Optionen).
## 2.2 Befehl aufrufen
- Namen tippen, z. B. `Line`. **Präfix-Autocomplete** (case-insensitiv): `L`→`Li`→`Lin` zeigt
Kandidatenliste mit Best-Match. **Tab/Pfeile** akzeptieren Vorschlag, **Enter/Leertaste** führt aus.
- **Aliase**: nutzerdefinierte Kürzel → Makro (z. B. `L`→`!_Line`, `cp`→`!_Copy`). Werden VOR
Autocomplete gematcht. (Minimal: Einzelbuchstabe→Befehl.)
## 2.3 Enter / Leertaste / Rechtsklick (leicht falsch gemacht)
- **Enter = Leertaste** in der Command-Line. Beide: Befehl ausführen / Default akzeptieren /
mehrteiligen Befehl **beenden** / bei **leerer** Zeile **letzten Befehl wiederholen**.
- **Rechtsklick im Viewport = Enter.** Also: Rechtsklick beendet Polyline UND wiederholt bei
leerer Zeile den letzten Befehl. → `lastCommand` speichern, bei Leer-Enter/Rechtsklick neu starten.
## 2.4 Inline-Optionen (klickbare Klammern)
```
Start of line ( BothSides=No Chamfer Mode=Distance ):
```
- Jede Option **klickbar UND tippbar** (genug Buchstaben zur Eindeutigkeit + Enter).
- **Toggle** `Name=Value` flippt beim Klick. **Value**-Option fragt Unterwert ab. **Action**-Option
(ohne `=`) verzweigt sofort.
- Optionen sind **innerhalb des Befehls persistent**, viele **über Aufrufe hinweg** (letzte
Offset-Distanz, Array-Anzahl, Fillet-Radius merken). **Zuletzt benutzte Optionswerte je Befehl
persistieren** — Nutzer erwarten das.
## 2.5 Sub-Prompts = State-Machine
Befehle laufen Prompts ab. `Line`: „Start of line:" → Punkt → „End of line:" → Punkt → fertig.
`Polyline`: „Start" → „Next point ( Close Undo ):" → … → **Enter** beendet. Prompt-Text ist
sichtbar und lehrreich („Next point. Press Enter when done") — literal nachbilden.
## 2.6 Transparente/verschachtelbare Befehle
Manche Befehle (Zoom/Pan, Osnap-Toggle, alles mit `'`-Präfix) laufen **innerhalb** eines anderen,
ohne ihn abzubrechen, und kehren zum Original-Prompt zurück. → Command-Runner braucht einen **Stack**.
## 2.7 Koordinaten- & Numerik-Eingabe (Herz der Präzision)
| Eingabe | Bedeutung |
|---|---|
| `5,3` / `5,3,2` | absolut X,Y(,Z) |
| `r5,3` | **relativ** zum letzten Punkt (das `r`-Idiom) |
| `<45` | Winkel-Constraint auf 45°, dann Maus/Distanz |
| `5<45` | **polar**: Distanz 5 unter 45° vom letzten Punkt |
| Zahl tippen während Drag | **Distanz-Lock**: Richtung per Maus, Länge per Zahl+Enter (meistgenutzte Geste) |
| Zahl + **Tab** | Lock umschalten (Länge fix → Winkel folgt Maus, oder umgekehrt) |
Das Feld parst **kontextabhängig**: Befehlsname / Optionsbuchstabe / Koordinate / nackte Zahl —
je nach Befehlszustand. Das Live-Tool muss **einen primären Skalar** (Länge/Radius/Distanz)
exponieren, an den eine getippte Zahl bindet.
## 2.8 Osnaps + Ortho + Gumball
- **Osnaps** (persistente Toggles): End, Mid, Cen, Int, Perp, Near, Quad, Tan, Point. Pro Mausschritt
gegen nahe Geometrie geprüft (Pixel-Toleranz), Marker+Label am Cursor; liefert **exakte
Modellkoordinate** (nie Roh-Maus, wenn Snap aktiv). One-Shot-Osnap überschreibt für den nächsten Pick.
- **Ortho** (F8): Winkelraster (90°/konfigurierbar), **Shift** togglet temporär. **Grid Snap** (F9).
**SmartTrack**: temporäre Hilfslinien aus zuletzt gehoverten Punkten.
- **Gumball**: On-Object-Widget (Pfeile=Move, Bögen=Rotate, Handles=Scale); Handle klicken →
Zahl tippen für exakten Transform. Direkt-Manipulations-Gegenstück zu getippten Befehlen.
> Präzisionsmodell = **(Snap ODER getippte Koordinate) × (Ortho/Winkel-Constraint) ×
> (Distanz-Constraint)**, in EINEM Pick komponierbar.
## 2.9 Auswahl-Modell (links/rechts-Regel exakt)
- Klick = wählen; Shift+Klick add; Ctrl+Klick remove.
- **Links→rechts = Window** (nur voll umschlossene; **durchgezogenes** Rechteck).
- **Rechts→links = Crossing** (auch berührte; **gestricheltes** Rechteck). Richtung bestimmt
Modus — starke Konvention, exakt nachbilden.
- **SelLast** (zuletzt erzeugte/gewählte erneut wählen) ist enorm nützlich („erzeugen, dann sofort
bewegen"). Min. `SelLast`, `SelAll`, `SelNone`, `Invert`.
## 2.10 Befehls-Prompt-Sequenzen (Kurz)
2D: **Line** (2 Pkt) · **Polyline** (Close/Undo, Enter beendet) · **Rectangle** (Ecke+Ecke, oder
Breite/Höhe tippen; 3Point/Center) · **Circle** (Center+Radius; 2P/3P/Tan) · **Arc** (Center-Start-End /
3Point) · **Offset** (Kurve wählen → Seite klicken/Distanz tippen; Distanz persistent) ·
**Fillet/Chamfer** (Kurve1→Kurve2, Radius/Distances persistent) · **Trim** (Schneider wählen→Enter→
wegzuschneidendes Stück klicken) · **Split** · **Extend** · **Join** · **Explode** ·
**Move/Copy/Rotate/Scale/Mirror** (Auswahl→Basispunkt→Ziel; Copy-Option) · **ArrayRect/ArrayPolar** ·
**Group/Ungroup**.
3D (braucht CSG, später): **ExtrudeCrv** (geschlossene Kurve→Solid, Cap) · **Box** · **Boolean
Union/Difference/Intersection** · **Cap** · **Gumball-Face-Drag = PushPull** · Loft/Sweep/Revolve.
---
# TEIL 3 — Bauplan für DIESE Codebase
> Ziel: nutzbarer 2D-Architektur-Drafter mit Rhino-Feel, dann einfaches Massing. Halte das
> ROADMAP-Prinzip: **ein semantisches Modell → Sichten abgeleitet**; Extrusionshöhe ist eine
> Eigenschaft, nie eingebackene Geometrie.
## 3.0 Kuratierungs-Prinzip (WICHTIG — Nutzer-Vorgabe)
**NICHT den ganzen Rhino-Katalog stumpf portieren.** Wir bauen ein **Wohnbau-BIM**, keinen
NURBS-Allzweck-Modeller. Nimm nur, was dem Wohnbau-Workflow dient; lass den Rest weg, bis er
konkret gebraucht wird. Faustregel: *Brauche ich das, um ein Einfamilienhaus zu zeichnen und
daraus Pläne zu ziehen?* Wenn nein → weglassen.
**Bewusst WEGLASSEN (vorerst):** Loft / Sweep1+2 / Revolve (Sonderformen, kaum Wohnbau) ·
freie NURBS-Kurven (`Curve`/`InterpCrv` Grad>1, Deformable, FromFoci) · Ellipse · Tangent/
Bisector/4Point-Linienvarianten · SmartTrack (nett, nicht kritisch) · der volle `Sel*`-Zoo
(nur SelLast/SelAll/SelNone/Invert) · Knot/Vertex/Tan-Osnaps. Alle leicht später additiv
nachrüstbar — kein Grund, sie jetzt mitzuschleppen.
**Booleans sind KEIN „nice to have später"** — sie werden gebraucht, **sobald Tür/Fenster als
echte 3D-Öffnung** kommen (heute schneidet `Door` nur eine Plan-Lücke, kein 3D-Boolean, siehe
HANDOVER). Darum: CSG/Booleans an die **Tür/Fenster-Phase koppeln** und dann reinnehmen — nicht
ans Ende schieben. ABER (das ist der „nicht stumpf"-Teil):
> Für **rechteckige** Öffnungen in extrudierten Wänden braucht es **keinen allgemeinen
> Boolean-Kernel**. Eine analytische **Wand-minus-Box-Subtraktion** (Öffnung als parametrische
> Aussparung im Wand-Solid) ist einfacher, robuster und für 95 % Wohnbau ausreichend. Den
> allgemeinen CSG-Boolean (`rhino3dm`) erst ziehen, wenn schräge/runde/verschnittene Fälle
> wirklich auftreten. Also: **Öffnungen zuerst analytisch, allgemeine Booleans erst bei Bedarf.**
## 3.1 Leitentscheidung: Engine verallgemeinern, nicht parallel bauen
Baue eine gemeinsame **Command-Engine**, die das bestehende `Tool`-Interface erweitert/ablöst,
sodass es **einen** Eingabepfad gibt (Maus + Tastatur + Command-Line speisen dieselbe Maschine).
Konkret: ein `Command`-Modell, das je Schritt einen **Prompt** (Text), erwartete **Eingabearten**
(Punkt | Zahl | Option | Auswahl) und **Optionen** beschreibt. Die heutigen Tools werden zu
Befehlen dieser Engine (wall/line/polyline/rect lassen sich 1:1 portieren — ihre Phasenlogik ist
schon eine Mini-State-Machine).
**Warum nicht das alte Tool-Interface unangetastet lassen und Command-Line nur draufsetzen?**
Weil die Command-Line getippte Koordinaten/Optionen in denselben Schritt einspeisen muss, in dem
die Maus pickt. Zwei getrennte Pfade divergieren garantiert (Snapping, Constraints, HUD doppelt).
## 3.2 Neue Dateien (Vorschlag)
- `src/commands/engine.ts` — Command-Runner: aktiver Befehl, Prompt-Stack (für transparente
Befehle §2.6), `lastCommand`-Wiederholung, Routing von Maus-Pick / getippter Eingabe / Option-Klick
in den aktuellen Schritt. Hält `CommandState`.
- `src/commands/types.ts` — `Command`-Interface (Verallgemeinerung von `Tool`): Schritte mit
`prompt: TranslationKey`, `accepts: ("point"|"number"|"option"|"selection")[]`, `options: CmdOption[]`,
`onInput(state,input,ctx): [state, CommandResult]`. `CommandResult` wie `ToolResult` (+`commit`).
- `src/commands/parseInput.ts` — Koordinaten-Parser (§2.7): `5,3` · `r5,3` · `5<45` · `<45` ·
nackte Zahl (Distanz-Lock) · Optionsbuchstabe. Liefert eine Discriminated Union, die die Engine
in einen Modellpunkt/Constraint auflöst (mit `lastPoint` für `r`/polar).
- `src/commands/registry.ts` — `COMMANDS: Record<string,Command>` + Aliase + Autocomplete (Präfix).
- `src/ui/CommandLine.tsx` — die Command-Line-UI **in/über der Statusleiste** (`StatusBar.tsx`):
zeigt Prompt + klickbare Optionen + Texteingabe; Autocomplete-Dropdown. Tab fokussiert sie.
- (später) `src/geometry/kernel2d.ts` — 2D-Kernel für Offset/Trim/Fillet/Schnitt (§3.4).
- (viel später) `src/geometry/solid3d.ts` o. `rhino3dm`-Anbindung für Massing/Booleans (§3.5).
## 3.3 Verdrahtung (minimal-invasiv)
- **Globaler Tab-Handler**: neuer `window.keydown` in App (gleicher Guard wie `App.tsx:448` —
INPUT/TEXTAREA/contentEditable überspringen). Tab → Command-Line fokussieren/öffnen. Jeder
getippte Buchstabe ohne aktives Tool startet den Befehlsmodus (Rhino „hört immer zu" — optional
in Phase 2; Phase 1 reicht Tab).
- **Command-Line → Engine → Store**: Befehle dispatchen auf `setActiveTool` (für tool-artige) bzw.
direkt auf Store-Actions / `setProject`. Nutze `getState()/setState()` (referenzstabil) aus
`appStore.ts`.
- **Pick-Eingabe**: die Engine konsumiert dieselben `(rawModelAt, currentPxPerMeter, toolMods)`,
die PlanView schon hochmeldet (`ToolHandlers`). `computeSnap` für Punktfang wiederverwenden.
→ PlanView braucht im Idealfall **keine Änderung** (höchstens: Window/Crossing-Marquee-Visual
durchgezogen vs. gestrichelt nach Drag-Richtung, §2.9 — heute evtl. nur ein Modus).
- **Prompt/HUD**: Prompt-Text in die Statusleiste (`StatusBar` `hint` existiert schon). Distanz/
Winkel-HUD am Cursor existiert in `ToolDraft.hud`.
## 3.4 Reihenfolge (Tiers — strikt 2D zuerst)
**Tier 0 — Substrat (VOR jedem Befehl; das ist der „Feel"):**
1. Command-Runner + Command-Line-UI (Prompt → pick/type → Optionen; Enter/Space/Rechtsklick =
bestätigen/beenden/wiederholen; `lastCommand`).
2. Koordinaten-Parser (`x,y` · `rdx,dy` · `dist<angle` · nackte-Zahl-Lock).
3. Osnaps (End/Mid/Cen/Int/Perp/Near) — `computeSnap` ist da, ggf. Cen/Perp/Near ergänzen.
4. Ortho (90°/45°, Shift-Toggle) + Grid-Snap — teils vorhanden (`applyAngleConstraint`).
5. Auswahl: Klick, Shift/Ctrl add/remove, **Window vs. Crossing** (durchgezogen/gestrichelt,
links/rechts-Regel).
**Tier 1 — 2D-Pflicht (reines SVG/2D), grobe Baufolge:**
6. **Line** (validiert die ganze pick/snap/constrain-Schleife) → 7. **Polyline** (Close/Undo) →
8. **Rectangle** (Ecke + Center/3Point) → 9. **Circle** (Center+Radius). Diese vier portieren die
heutigen Tools auf die Engine + numerische Eingabe.
10. **Move** → 11. **Copy** (wiederholend) → 12. **Offset** (persistente Distanz — DAS Architektur-
Primitiv) → 13. **Trim** + **Split** → 14. **Join** + **Explode**. **Undo/Redo** durchgängig
annehmen (heute? — prüfen; ggf. Command-History/Undo-Stack im Store ergänzen).
**Tier 2 — 2D stark nützlich:** Rotate/Scale/Mirror (Copy-Option) · Fillet/Chamfer · Arc ·
Extend · ArrayRect/ArrayPolar · Group/Ungroup · Gumball(2D) · Sel*-Helfer (min. SelLast).
**Tier 3 — Massing + Öffnungen (an Tür/Fenster-Phase gekoppelt):**
- **Öffnungen zuerst analytisch:** Tür/Fenster als parametrische Aussparung im Wand-Solid
(Wand-Extrude minus Öffnungs-Box) — KEIN allgemeiner Boolean-Kernel nötig (§3.0). Das ist der
kritische, roadmap-markierte 🔴-Teil (echte 3D-Öffnung statt nur Plan-Lücke) und kommt MIT
Tür/Fenster, nicht danach.
- **Massing-Befehle:** ExtrudeCrv (geschlossene Plan-Kurve → gecapptes Solid) → Box →
Gumball-Face-Drag-PushPull.
- **Allgemeine Booleans** (Union/Difference/Intersection) **erst bei Bedarf** (schräge/runde/
verschnittene Fälle): **kein eigener Kernel — `rhino3dm` (WASM-openNURBS)** als `src/io/`-Schicht
(Roadmap-Entscheid, HANDOVER). Bis dahin reicht die analytische Subtraktion.
- **Weggelassen:** Loft/Sweep/Revolve/OffsetSrf (§3.0 — Sonderformen, kaum Wohnbau).
## 3.5 Was sauber 2D ist vs. was hart ist
- **Sauber SVG/2D:** Line, Polyline, Rect, Circle, Arc, Move/Copy/Rotate/Scale/Mirror, Array, Group,
Control-Point-Edit, Gumball(2D). Affine Transforms + Kurven-Schnitt.
- **Echte Arbeit (2D-Kernel nötig):** **Offset, Trim, Fillet** brauchen kompetenten Kurven-Schnitt
und Polylinien-Offset — dafür Zeit einplanen (`src/geometry/kernel2d.ts`).
- **Braucht 3D/CSG:** Extrude, Box, Boolean*, Cap, OffsetSrf, Loft/Sweep/Revolve, Face-Drag. Booleans
sind das Korrektheits-Zentrum → `rhino3dm`.
## 3.6 Gotchas (aus Rhino-Verhalten + dieser Codebase)
- **Command-Line hört immer zu** — Tasten global routen, aber die `isTextEntry`-Disziplin
(`main.tsx:24`) + die Native-App-Regeln (kein Ctrl+A/keine Textauswahl, CONVENTIONS.md) wahren.
- **Zuletzt benutzte Optionswerte je Befehl persistieren** (Offset-Distanz, Array-Anzahl, Fillet-Radius).
- **Enter = Rechtsklick = Wiederholen/Bestätigen/Mehrteiliges-Beenden** — alle drei auf EIN Signal.
- **Distanz-Lock:** Live-Befehl muss EINEN primären Skalar exponieren, an den eine getippte Zahl bindet.
- **Window vs. Crossing** über Drag-Richtung + durchgezogen/gestrichelt — nicht global ein Modus.
- **Osnap liefert exakte Modellkoordinate** — nie Roh-Maus, wenn Snap aktiv (`ToolPointer.point`).
- **Strict tsc** (`noUnusedLocals`) — ungenutzte Vars/Parameter brechen `npm run build`.
- **i18n**: jeder sichtbare String über `t('key')`, Keys in `de.ts` UND `en.ts` (Parität erzwungen).
- **Keine generische addWall/addDrawing2d-Action** — entweder via `setProject` committen (wie heute)
oder beim Refactor saubere Actions im `projectSlice` ergänzen (besser für Undo/Redo).
- **App.tsx ist bereits ~2200 Z.** (God-Component-Kritik in HANDOVER). Lege Command-Engine in
`src/commands/`, halte App-Verdrahtung dünn (nur Tab-Handler + CommandLine-Mount + Dispatch-Brücke).
## 3.7 Verifikations-Drehbuch
Pro Tier eine Probe (Vorlage: `scripts/probe-tools.mjs`/`probe-transform.mjs`): Befehl per Command-
Line tippen → Punkte/Werte tippen → Screenshot → Geometrie visuell prüfen. Tier 0 zuerst headless
treiben (Tab → „line" → „0,0" Enter → „r3,0" Enter → Linie im PNG sichtbar). `npx tsc -b` +
`npm run build` grün halten. **Screenshot ansehen**, nicht nur Kompilat vertrauen (Memory `wire-dont-stub`).
---
## Anhang — Minimaler erster Meilenstein (konkret)
1. `src/commands/types.ts` + `engine.ts` + `parseInput.ts` (Tier 0.1/0.2).
2. `src/ui/CommandLine.tsx`, in `StatusBar` gemountet; Tab-Handler in App.
3. `Line` als erster Command (portiert `lineTool`), inkl. getippter `0,0` / `r3,0` / `3<45`.
4. Probe `scripts/probe-command-line.mjs`: Tab→line→zwei getippte Koordinaten→PNG prüfen.
5. Dann Polyline/Rect/Circle, danach Move/Copy/Offset.
Damit steht der Rhino-Feel-Kern, und jeder weitere Befehl ist additiv (neues `Command`-Objekt in
`registry.ts`, keine PlanView-/App-Änderung).
+74
View File
@@ -0,0 +1,74 @@
# Ribbon-UI + modulare Bars — Plan
Nutzer-Vision (2026-07-05, mit OCS/AutoCAD-Ribbon als Referenz). Ziel: klare,
einheitliche Werkzeug-/Eigenschaften-Darstellung; Werkzeug-Sidebar entfällt.
## Zielbild
- **Ribbon-Oberleiste mit Tabs: 2D · 3D · BIM · Ansichten.** Jeder Tab zeigt
gruppierte Werkzeug-/Aktions-Icons (wie OCS Draw/Model/Insert/Annotate/View).
- **2D**: Zeichnen (Select/Linie/Polylinie/Rechteck/Kreis/Bogen/Text) · Ändern
(Move/Copy/Mirror/Offset/Trim/Join/Boolean).
- **3D**: Volumen/Boolean (vorhandene 3D-Aktionen), Kamera-Presets.
- **BIM**: Wand/Fenster/Tür/Treppe/Decke/Raum (die Bauteile).
- **Ansichten**: Geschoss/Schnitt/Ansicht-Wechsel, Zoom/Einpassen, Render-Modi.
- **Werkzeug-Sidebar entfällt** → rechtes Panel (Attribute) bekommt volle Höhe.
- **Objektinfo unter die Attribute** mergen (Wandstil-Definition etc. zusammen).
- **XYZ-Referenzpunkt-Box oben rechts bleibt** (vom Nutzer ausdrücklich gewünscht).
- **Eigenschaften-Grid im OCS-Stil** — ✅ bereits erledigt (`00733d8`): Sektion-
Balken, Zeilentrenner, füllende linksbündige Wertfelder (`.attr-*`).
## Architektur — DATENGETRIEBEN (ermöglicht modulare Custom-Bar)
Kern: EINE Registry beschreibt alle Ribbon-Elemente; Ribbon-Tabs UND eine
benutzerdefinierte Custom-Bar sind bloß zwei Ansichten über dieselbe Registry.
```
RibbonItem =
| { kind: "tool"; id: ToolId } // aktiviert ein Werkzeug (onSelectTool)
| { kind: "command"; name: string } // startet einen Engine-Befehl (engine.start)
| { kind: "action"; id: string; run: () => void } // freie App-Aktion (Zoom, Render-Modus …)
RibbonGroup = { titleKey: string; items: RibbonItem[] }
RibbonTab = { id: "2d"|"3d"|"bim"|"views"; labelKey: string; groups: RibbonGroup[] }
RIBBON: RibbonTab[]
```
- Icons: `ToolIcon` (vorhanden) für `tool`-Items; für `command`/`action` eine
kleine Icon-Map (SVG) analog `ToolsPanel`.
- Aktivierung: `tool` → `onSelectTool(id)` · `command` → `engine.start(name)` ·
`action` → `run()`. Aktiv-Highlight über `activeTool` bzw. laufenden Befehl.
- **Custom-Bar (modular)**: der Nutzer wählt beliebige `RibbonItem`s in eine
persistierte Liste (`viewSlice`/Projekt); eine `CustomBar`-Komponente rendert
genau diese. Gleiche Item-Typen, gleiche Aktivierung — kein Sonderweg.
## Phasen
1. ✅ **Gerüst + 2D/BIM-Tab** (`9d6e86d`). `RibbonBar` unter der TopBar (additiv).
Datengetriebene Registry (`src/ui/ribbon/ribbonItems.ts`), 2D-Tab (Zeichnen +
Ändern) und BIM-Tab (Bauteile) gefüllt, Tab-State lokal, Modify-Icons ergänzt.
2. ✅ **TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll
mergen"). Die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid +
Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart)
sind als `ViewRibbonTab` (in `TopBar.tsx`) in den „Ansichten"-Tab gewandert;
App reicht sie als `viewsContent`-Node an `RibbonBar` (wie das Layout-Menü).
Die **Tab-Reiter sitzen in der TopBar-Zeile** (`RibbonTabs`, in TopBar
gerendert; Tab-State in App), der Ribbon-Inhalt (`RibbonBar`) folgt darunter →
nur ZWEI Bänder. Die TopBar ist eine **schmale Leiste** (Höhe 40px): Marke/
Ressourcen · Tabs · Datei/Export/Einstellungen · Fensterknöpfe. Die Text-/
Font-Formatierung (`TextGroup`) liegt im eigenen **„Text"-Tab** (Reihenfolge
2D·3D·BIM·Text·Ansichten; `RibbonBar.tabContent` trägt „text" + „views").
- **Offen (visuell iterieren):** ob Zoom/Massstab zusätzlich als Dauer-Anzeige
(Statusleiste) sichtbar sein soll; Default-Tab (aktuell 2D); 3D-Tab noch
leer (Kamera-Presets/3D-Aktionen füllen).
3. **Sidebar raus** (ToolsPanel aus Default-Layout) + rechtes Panel volle Höhe;
Objektinfo unter Attribute mergen.
4. **Modulare Custom-Bar**: Item-Picker + persistierte Custom-Bar-Ansicht.
## Hinweise / Randbedingungen
- Additiv bauen: bestehende Kommandozeile + Nummern-Shortcuts + `ToolsPanel`
bleiben funktionsfähig, bis Phase 3 die Sidebar bewusst entfernt.
- Werkzeug-Kopplung Tool↔Befehl existiert bereits (`TOOL_COMMAND`) — Ribbon nutzt
denselben `onSelectTool`/`engine.start`-Pfad (EIN Pfad, keine Duplikate).
- Empfehlung: Phase 1+ in fokussierter Session mit visueller Iteration im Tauri.
+52
View File
@@ -0,0 +1,52 @@
# Zustands-Architektur & Code-Aufteilung (App.tsx entschlacken)
> Ziel: `App.tsx` von „God-Component" zu dünnem Shell. Globaler Zustand in einen
> Store, Features in eigene Module → **wartbar + parallel bearbeitbar**.
## Problem
`App.tsx` hält aktuell: Projekt-State, Auswahl, View-State (viewType/detailLevel/
renderMode/referenceLines/scale/activeLevel), Layout, ALLE Mutations-Handler
(floors/layers/components/hatches/lineStyles/walls/doors), Kontextmenü-Builder,
Inline-Editoren, Layout-Menü, View-Routing. → Risiko + Flaschenhals (jedes Feature
fasst App.tsx an → kein paralleles Arbeiten).
## Zielstruktur
```
src/state/
store.ts // Store (Zustand) — kombiniert die Slices, ein useStore-Hook
projectSlice.ts // project + alle Mutationen (floors, layers, components,
// hatches, lineStyles, walls, doors) inkl. recompute/refs-aware delete
selectionSlice.ts // selectedWallIds (+ marquee-Ergebnis)
viewSlice.ts // viewType, detailLevel, renderMode, referenceLines, activeLevelId, scale
layoutSlice.ts // wraps src/panels/layout.ts (docks + floating)
src/views/ // LevelPlanView, PerspectiveView, SectionStub, DrawingView (+ ViewRouter)
src/editors/ // FloorSettingsEditor, LayerSettingsEditor, … (heute inline in App)
src/menus/ // layerContextMenu(), levelContextMenu(), planContextMenu() — bauen ContextMenu-Items
src/ui/ // TopBar, StatusBar, ResourceManager, ContextMenu (bestehen)
src/panels/ // Docks/Panels (bestehen)
App.tsx // DÜNN: Store-Provider · TopBar · (Docks + ViewRouter) · StatusBar
// · Floating-Panels · Ressourcen-Overlay
```
## Store-Wahl: Zustand (empfohlen)
- Winzige Lib, kein Boilerplate, **Slices** gut teilbar, Selektoren verhindern
Re-Render-Sturm, kein Prop-Drilling. Passt zu „verschiedene Features = verschiedene
Slice-Dateien" → Parallelität.
- Alternative ohne Dependency: Context + useReducer oder `useSyncExternalStore`
(wie i18n). Mehr Boilerplate; bei der State-Menge ist Zustand ergonomischer.
- Komponenten: `const walls = useStore(s => s.walls)` / `useStore(s => s.addFloor)`.
PanelHostContext entfällt (Panels lesen direkt aus dem Store).
## Vorgehen (reiner Refactor — Verhalten MUSS identisch bleiben)
1. Store + Slices anlegen, Projekt-State + Mutationen aus App.tsx hierher ziehen
(1:1, gleiche Logik inkl. recomputeFloorElevations, refs-aware delete).
2. View-/Selection-/Layout-State in ihre Slices.
3. Inline-Editoren, Kontextmenü-Builder, View-Routing in `src/editors/`,`src/menus/`,`src/views/` extrahieren; sie lesen den Store.
4. App.tsx auf den Shell reduzieren.
5. Verifizieren: tsc + build + Screenshots — **pixel-/funktionsgleich** zu vorher
(Auswahl, Massstab, Kontextmenü, Panels, 3D/Plan, i18n). Reiner Umbau, kein
Feature-Wechsel.
## Auszahlung
- Danach editiert ein Wand-Feature `projectSlice`/`views`, ein Panel-Feature `panels`,
ein Editor `editors` — **disjunkte Dateien → mehrere Code-Workflows parallel** möglich.
+306
View File
@@ -0,0 +1,306 @@
# Architektur-Pivot: Tauri + Rust-Backend (2026-07-01)
## Entscheidung
**Alte Welt:** Browser-CAD (React/Vite + WebGL/three.js)
**Neue Welt:** Desktop Tauri-App (React/Vite Frontend + Rust-Backend + **wgpu 3D-Rendering**)
**Grund:** Komplexe Möbel mit vielen Polygonen (100k+) + mehrere parallele rechenintensive Ops → WebGL/three.js wird zum Bottleneck. **wgpu** (low-level GPU-API auf Vulkan/Metal/DX12) + Rust-Compute skaliert native.
**Scope:** Desktop-only Release (Milestone 1). WASM-Fallback für Browser später wenn gebraucht.
---
## Post-Migration Stack
### Frontend (React/Vite — Komponenten + State, unverändert)
```
src/
App.tsx ← Shell-Komponente
compute/index.ts ← Compute-Boundary (neu)
model/types.ts ← Semantisches Modell
commands/ ← Befehlssystem
panels/ ← UI-Panels
plan/PlanView.tsx ← 2D-SVG-Rendering
viewport/Viewport3D.tsx ← three.js 3D-Display
ui/ ← Topbar, Dialogs, etc.
state/ ← Redux-Slices (project, selection, view, layout)
...
```
**Rolle:** User-Input-Handling, 2D/3D-Darstellung (Display-Layer), State-Management.
### Backend (Rust/Tauri — neu)
```
src-tauri/
src/
main.rs ← Tauri window + invoke-handler registration
geometry.rs ← compute_joins(), kernel2d(), etc.
parsers/
dwg.rs ← DXF/DWG-Geometrie-Parsing
dxf.rs
sia/
room_detection.rs ← detectRooms() (SIA-416)
...
Cargo.toml ← Dependencies (serde, tauri, …)
```
**Rolle:** Rechenintensive Ops, Geometrie-Kernel, Parsing, SIA-Raumerkennung.
### IPC: Tauri invoke (async, serde)
```typescript
// Frontend ruft Rust auf
const result = await invoke<JoinInfo[]>('compute_joins', { walls, joints });
// Rust bearbeitet + serialisiert Ergebnis
#[tauri::command]
async fn compute_joins(input: JoinInput) -> Result<JoinOutput, String> {
geometry::compute_joins(input).map_err(|e| e.to_string())
}
```
---
## Compute-Boundary (Key Design)
**Neue Datei:** `src/compute/index.ts` — einziger Eingang für rechenintensive Ops.
```typescript
// Beispiel-Schnittstellen
export async function computeJoins(walls: Wall[], joints: Array<[number, number]>): Promise<JoinInfo[]> { … }
export async function computeKernel2D(op: 'offset'|'trim', geom: Polyline, …): Promise<Polyline[]> { … }
export async function detectRooms(…): Promise<Room[]> { … }
export async function parseShapeFromDwg(…): Promise<DwgGeometry> { … }
```
**Hinter der Boundary:**
1. Versuche Tauri invoke zu Rust (`#[tauri::command]`)
2. Fallback auf lokale TS-Impl wenn Rust nicht verfügbar (während Migration)
**Effekt:** Aufrufort im Code bleibt stabil; Caller sieht nicht, ob Op schon in Rust ist oder noch TS.
**Migrationsfluss:**
```
1. TS-Impl existiert (z.B. src/model/joins.ts)
2. Neue Op in Compute-Boundary mit Invoke+Fallback
3. Parallel: Rust-Impl in src-tauri/src/geometry.rs
4. Tests: Rust-Output == TS-Output (Parität)
5. TS-Impl bleibt (Fallback, wird nicht entfernt bis Rust stable)
6. Eventuell: TS-Impl löschen wenn Rust bewährt
```
---
## Dev-Workflow (post-Tauri)
### Development
```bash
# Terminal 1: Vite dev-server
npm run dev # localhost:5173
# Terminal 2: Tauri dev
npm run tauri:dev # öffnet Tauri-Fenster, zeigt auf localhost:5173
# Rust hot-reload + TS hot-reload gleichzeitig
```
**Voraussetzungen:**
- Node.js + npm (wie heute)
- Rust + Cargo (neu)
- Tauri CLI: `npm install -D @tauri-apps/cli`
### Build
```bash
# Single command
npm run tauri:build
# Erzeugt:
# - Windows: src-tauri/target/release/cad.exe
# - macOS: src-tauri/target/release/bundle/macos/cad.app
# - Linux: src-tauri/target/release/bundle/deb/cad_*.deb (oder Flatpak)
```
### Testing
```bash
# Rust-Unit-Tests
cargo test # in src-tauri/
# TS-Tests (unverändert)
npm run test
# Integration-Test: App starten + Aktion prüfen
npm run tauri:dev # manuell testen oder Puppeteer-Probe erweitern
```
---
## GPU-Strategy (für später)
**Milestone 1 (jetzt):** CPU-Ops in Rust (kernel2d, joins, parsing, SIA).
**Milestone 2 (später):** GPU-Compute via wgpu
- Tauri + wgpu Renderer (optional, nicht erforderlich)
- ODER drei.js bleibt, Rust handelt CPU-Ops, three.js handelt Display
- GPU-Heavy-Ops (z.B. große Boolean-Operationen) können in wgpu laufen, aber MVP braucht das nicht
**Aktueller Plan:** three.js bleibt für 3D-Display (skaliert ausreichend für Möbel-Geometrie mit Instancing + LOD).
---
## Migration Strategy: Ops nach Priorisierung
**Phase 1 (aktuell — Tauri-Shell + Proof-of-Concept):**
- [ ] `computeJoins` (Wand-Eckverbindungen) → Rust
- Gründe: klein, häufig, zeigt invoke-Flow
**Phase 2 (nächst):**
- [ ] `kernel2d` (Offset/Trim/Extend/Fillet) → Rust
- Gründe: Rechenlast ⭐⭐, Frequenz hoch
- [ ] DXF/DWG-Parser → Rust (Geometrie-Extraktion)
- Gründe: Rechenlast ⭐⭐, Frequenz mittel (Import-Dialog)
**Phase 3 (später):**
- [ ] `detectRooms` (SIA-416 Raumerkennung) → Rust
- Gründe: Rechenlast ⭐, async-freundlich
- [ ] `booleanOps` (Union/Differenz/Schnitt) → Rust
- Gründe: Rechenlast ⭐⭐, Frequenz gering (ad-hoc)
---
## Folgen für bestehenden Code
### Was ändert sich NICHT
- `src/model/types.ts` — semantisches Modell bleibt in TS (Frontend kennt es)
- `src/state/` — Redux-Store unverändert
- `src/ui/` — Komponenten unverändert
- `src/plan/PlanView.tsx` — SVG-Rendering unverändert
- `src/viewport/Viewport3D.tsx` — three.js-Rendering unverändert
- `src/commands/` — Befehlssystem unverändert
### Was ändert sich
- **Neue `src/compute/index.ts`** — alle rechenintensiven Ops laufen durch hier
- **Neue `src-tauri/`** — Rust-Backend
- **Vite-Config:** Tauri plugin hinzufügen
- **Package.json:** tauri scripts hinzufügen
- **Build-Prozess:** `npm run tauri:build` statt `npm run build`
### Was wird migriert (schrittweise)
- `src/model/joins.ts` → `src-tauri/src/geometry.rs` (Phase 1)
- `src/geometry/kernel2d.ts` → `src-tauri/src/geometry.rs` (Phase 2)
- `src/io/{dxfParser, dwgParser}.ts` → `src-tauri/src/parsers/` (Phase 2)
- `src/geometry/{roomArea, roomBoundary}.ts` → `src-tauri/src/sia/room_detection.rs` (Phase 3)
- `src/editors/booleanOps.ts` → `src-tauri/src/geometry.rs` (Phase 3)
**Wichtig:** TS-Versionen bleiben als Fallback (nicht gelöscht).
---
## Distribution (später)
### Desktop Binaries (post-Tauri)
- **Windows:** `.exe` (standalone executable)
- **macOS:** `.app` bundle (code-signed)
- **Linux:** `.deb` package ODER **Flatpak** (preferred)
- Flatpak = moderne WebKitGTK6 immer dabei, unabhängig von Distro-Alter
### Browser (wenn gebraucht)
- **WASM-Fallback** für `src/compute/` Ops (Rust → WASM via wasm-bindgen)
- Later-phase feature, nicht Milestone 1
---
## Technische Details
### Serialisierung (TS ↔ Rust)
**serde + serde_json** für Geometrie-Typen:
```rust
// Rust
#[derive(Serialize, Deserialize)]
pub struct Vec2 { pub x: f64, pub y: f64 }
#[derive(Serialize, Deserialize)]
pub struct Wall {
pub id: String,
pub start: Vec2,
pub end: Vec2,
// …
}
```
```typescript
// TS (type-safe invoke)
interface Vec2 { x: number; y: number }
interface Wall { id: string; start: Vec2; end: Vec2; /* … */ }
await invoke<JoinInfo[]>('compute_joins', { walls: Wall[] })
```
### Tauri Security (default)
- Invoke-Handler sind Rust-side validiert
- Whitelist-Makro (`#[tauri::command]`) registered nur explizit erlaubte Functions
- CORS/CSP Policy default secure
- Keine arbitrary-Script-Execution (native app)
---
## Abhängigkeiten (neu post-Tauri)
### Frontend (npm)
- Bestehende: react, vite, three.js, redux, etc.
- Neu: `@tauri-apps/api` (JS-SDK für invoke)
- Optional später: `@tauri-apps/cli` dev-dependency (bereits in package.json)
### Backend (Cargo)
```toml
[dependencies]
tauri = { version = "2", features = ["webkit2gtk-6.0"] } # GTK4
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# später: wgpu, delaunator, opencascade-sys, etc.
```
### System
- Rust 1.70+
- GTK4 dev libraries (Linux only, auto-handled by Tauri)
- Xcode Command Line Tools (macOS, auto-checked)
---
## Next Steps (Koordination)
**Aufgabe für nächste Phase:**
→ Siehe `docs/design/tauri-migration-plan.md` (Schritt-für-Schritt, vier parallele Agents)
**HANDOVER.md:** wird aktualisiert nach Tauri-Shell stabil.
---
## FAQ
**Q: Läuft die App noch im Browser?**
A: Nein (Milestone 1). Desktop-only. WASM-Fallback für Browser später wenn gebraucht.
**Q: Was passiert mit dem existing TS-Code?**
A: Bleibt unverändert (außer neue Compute-Boundary). TS-Implementierungen = Fallback bis Rust stabil.
**Q: Muss ich Rust können um das Projekt zu verstehen?**
A: Nein. Frontend bleibt React/TS. Rust ist "blackbox" hinter invoke. Aber bei Rust-Bugs muss man rein.
**Q: Wann ist Tauri-Shell fertig?**
A: Nach den vier Agents (Schritt 1–4 in tauri-migration-plan.md), ~1–2 Wochen.
**Q: Kann ich lokal testen?**
A: Ja, `npm run tauri:dev` öffnet lokale App. Alles wie heute, nur Rust hinten dran.
+243
View File
@@ -0,0 +1,243 @@
# Tauri-Migration + Compute-Boundary — Aufgabe für nächste Instanz
**Entscheidung (fix, 2026-07-01):** Browser-CAD → **Desktop Tauri-App mit Rust-Backend + wgpu-Rendering.**
**Grund:** komplexe Möbel mit vielen Polygonen (100k+) + mehrere parallele rechenintensive Ops → WebGL/three.js wird zum Blocker. **wgpu** (low-level GPU-API) + Rust-Compute skaliert native.
**Scope:** Desktop-only Release (Milestone 1). WASM-Fallback für Browser später wenn gebraucht.
**Rendering-Engine:** three.js → **wgpu** (Milestone 2)
---
## Aufgabe: Shell aufsetzen + Compute-Boundary + erste Op migrieren
### Schritt 0 — Compute-Boundary (TS-Kontrakt)
**Neu:** `src/compute/index.ts` — die einzige Stelle, durch die alle rechenintensiven Ops laufen.
```typescript
// src/compute/index.ts — einheitliche Schnittstelle
export async function computeKernel2D(op: 'offset'|'trim', …): Promise<Polyline[]> { … }
export async function detectRooms(…): Promise<Room[]> { … }
export async function computeJoins(…): Promise<JoinInfo[]> { … } // ← erste Op
export async function parseShapeFromDwg(…): Promise<DwgGeometry> { … }
```
**Hinter der Boundary:**
- Erst Tauri-invoke zu Rust `#[tauri::command]`
- Fallback auf lokale TS-Impl (bleibt unberührt, bis Rust stabil)
- `catch(err) → console.warn('Rust failed, using TS fallback'); return tsImpl(…)`
**Effekt:** Aufrufort im Code bleibt stabil; Caller sieht nicht, ob Op schon in Rust ist oder noch TS.
---
### Schritt 1 — Tauri-Shell aufsetzen
**Struktur:**
```
repo/
src-tauri/ ← neue Rust-Seite (Tauri-Konvention)
src/
main.rs ← Tauri window + invoke handlers
geometry.rs ← compute_joins() + weitere Ops später
...
Cargo.toml
src/ ← React/TS (unverändert)
compute/
index.ts ← Compute-Boundary
...
vite.config.ts ← Tauri plugin integrieren
package.json ← tauri scripts
```
**Setup:**
1. `cargo init --name cad-tauri src-tauri` (oder `src-tauri` manuell anlegen)
2. `Cargo.toml`: Tauri v2 einbinden mit Feature `webkit2gtk-6.0` (GTK4)
```toml
[dependencies]
tauri = { version = "2", features = ["webkit2gtk-6.0"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
```
3. `src-tauri/src/main.rs`: Minimal-Fenster, invoke-Handler registrieren
```rust
#[tauri::command]
async fn compute_joins(input: JoinInput) -> Result<JoinOutput, String> {
// Rust-Impl
geometry::compute_joins(input).map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![compute_joins])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
4. `vite.config.ts`: Tauri plugin + dev-server-Integration
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 5173 } // Tauri dev zeigt hier drauf
})
```
5. `package.json`: Tauri scripts hinzufügen
```json
"scripts": {
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
}
```
---
### Schritt 2 — Erste Op migrieren: `computeJoins` (Wand-Eckverbindungen)
**Warum `computeJoins` zuerst?**
- Läuft häufig (bei jedem Wall-Edit)
- Klein und fokussiert (~50 Zeilen Kernlogik)
- Proof-of-Concept für Tauri-invoke-Flow
- Danach `kernel2d` parallel hochfahren
**Rust-Impl:** `src-tauri/src/geometry.rs`
```rust
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Vec2 { pub x: f64, pub y: f64 }
#[derive(Serialize, Deserialize)]
pub struct WallJoinInput {
pub walls: Vec<Wall>,
pub joints: Vec<(usize, usize)>, // wall indices
}
#[derive(Serialize, Deserialize)]
pub struct JoinInfo { /* … */ }
pub fn compute_joins(input: WallJoinInput) -> Result<Vec<JoinInfo>, Box<dyn std::error::Error>> {
// Port der Logik aus src/model/joins.ts
// L-Ecken, T-Stösse, +-Kreuzungen
Ok(vec![]) // Placeholder
}
```
**TS-Fallback bleibt:** `src/model/joins.ts` (LS vor Rust-Port)
**Compute-Boundary:** `src/compute/index.ts`
```typescript
export async function computeJoins(walls: Wall[], joints: Array<[number, number]>): Promise<JoinInfo[]> {
try {
return await invoke<JoinInfo[]>('compute_joins', { walls, joints });
} catch (err) {
console.warn('Rust compute_joins failed, using TS fallback:', err);
return joinsTS(walls, joints); // Fallback
}
}
```
---
### Schritt 3 — Migrations-Parität testen
**Test-Fixtures:**
- Aus `src/model/sampleProject.ts` exportieren: Wand-Arrays mit bekannten L/T/±-Konfigurationen
- Rust-Unit-Tests: gleiche Fixtures → gleiche JoinInfo-Outputs
- Vergleich: `actual == expected`
**Beispiel (Rust):**
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_l_corner() {
let input = WallJoinInput { /* L-shaped walls */ };
let result = compute_joins(input).unwrap();
assert_eq!(result[0].kind, JoinKind::LCorner);
}
}
```
---
### Schritt 4 — Vite/Tauri-Integration
**Dev-Workflow:**
```bash
npm run tauri:dev
# → Vite dev-server (localhost:5173) lädt React-App
# → Tauri-window zeigt auf :5173
# → invoke() ruft Rust-Commands auf
```
**Build-Workflow:**
```bash
npm run build # Vite → dist/
npm run tauri:build # Tauri packt dist/ + Rust-Binary
```
---
## Deliverable (Proof-of-Concept)
**Git-Stand nach dieser Aufgabe:**
- [ ] `src-tauri/` Verzeichnis mit `Cargo.toml` + `src/main.rs` + `src/geometry.rs`
- [ ] `Cargo.toml` buildet sauber (`cargo check` 0 Fehler)
- [ ] `src/compute/index.ts` mit `computeJoins()` Schnittstelle (invoke + TS-fallback)
- [ ] Rust `compute_joins()` implementiert, Tests pass (`cargo test`)
- [ ] `package.json` `tauri` scripts hinzugefügt
- [ ] **App läuft:** `npm run tauri:dev` → Tauri-Fenster öffnet, Wand-Edit triggert Rust-Op, Output identisch TS-Version
- [ ] **Trace-Scan sauber** (kein TS/Rust-Code übrig, beide Impl. aktiv)
- [ ] HANDOVER.md aktualisiert: `computeJoins` migriert, nächste Ops in Queue
**Verification:**
```bash
# Build-Check
cargo check # 0 Fehler
# Rust-Tests
cargo test
# App end-to-end
npm run tauri:dev
# → Wand zeichnen + editieren → computeJoins() über Rust aufgerufen
# → Plan + 3D aktualisiert wie vorher
```
---
## Nächste Ops (Priorisierung)
Nach `computeJoins` stabil:
1. **`kernel2d`** (Offset/Trim/Extend) — großer Hebel, läuft häufig
2. **DXF/DWG-Parser** (Geometrie) — heavy, aber niedrige Frequenz
3. **`detectRooms`** (SIA-Raumerkennung) — async, kann auf Hintergrund ziehen
4. **`booleanOps`** (Union/Differenz/Schnitt) — Kandidat für später
---
## Offene Punkte (NICHT jetzt)
- **GPU-Compute (wgpu):** Erst nach CPU-Ops stabil (kernel2d, joins, parsing)
- **WASM-Fallback:** Nur wenn Browser-Support nötig wird
- **Flatpak-Distribution:** Nach Tauri-Shell stable + erste Ops migriert
---
## Referenzen
- Tauri v2 Docs: https://tauri.app/v1/guides/getting-started/prerequisites
- Serde: https://serde.rs/
- CONVENTIONS.md: Identifiers englisch, UI-Text via `t()`, …
- Commit-Regel: kein AI-Attribution im Repo
+151
View File
@@ -0,0 +1,151 @@
# Oberleiste – Angleichung an DOSSIER (Umsetzungs-Spezifikation)
Verbindliche Vorlage für den Umbau der Top-Bar (`src/ui/TopBar.tsx`,
`src/styles.css`, Verdrahtung in `src/App.tsx`). Quelle: das DOSSIER-Rhino-
Plugin (`ToolbarApp.jsx`, `components/BarControls.jsx`, `TextEditorApp.jsx`).
Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
## 0. Grundprimitive (neu, DOSSIER-konform)
Alle Leisten-Controls teilen dieselbe Höhe und Pillenform.
- `BAR_H = 22px` Basis-Höhe. Segmentierte Pillen `BAR_H + 2 = 24px`
(`box-sizing:border-box`, 1px Rand inbegriffen).
- Pille: `border:1px solid var(--border)`, `border-radius:999px`,
`background:var(--input)`. Hover (interaktiv): `border-color:var(--accent-border)`,
`background:var(--accent-dim)`.
- Aktiver Zustand (Toggle AN / aktive Segmentzelle): `background:var(--accent)`,
`color:#fff`.
### BarCombo (Pillen-Dropdown)
Wir haben bereits `src/ui/Dropdown.tsx`. Der Dropdown-Trigger MUSS optisch der
Pille entsprechen (Höhe 24, radius 999, obige Farben). Ein optionales Icon sitzt
LINKS **ausserhalb** der Pille (18px breit, `var(--muted)`), ein optionaler
Zahnrad-Knopf („settings") sitzt rechts **innerhalb** der Pille. Prüfen, ob
`Dropdown` bereits so aussieht; falls nicht → Trigger-CSS angleichen (Klasse
`tb-dd-trigger`). KEINE zweite Dropdown-Implementierung bauen.
### Segmentpille (3er/4er)
Aussencontainer `display:inline-flex; height:24px; border:1px solid var(--border);
border-radius:999px; overflow:hidden`. Zellen ohne eigenen Radius; interne Trenner
über `border-left:1px solid var(--border)` (erste Zelle ohne). Aktive Zelle
`var(--accent)`/#fff, inaktiv `var(--input)`/`var(--ink)`, Hover
`var(--accent-dim)`/`var(--accent)`. Genutzt für: Ansichts-Icons, Zoom (%/fit/center),
**B/I/U**, **L/C/R**.
### BarButton (quadratischer Icon-Knopf)
22×22, `border-radius:999px`, sonst wie Pille. Aktiv = Akzentfüllung, Icon #fff.
## 1. Reihenfolge der Gruppen (links → rechts)
1. Marke (bestehend, unverändert).
2. Ansichts-Gruppe (bestehend `view-grid`; Zellen auf Segmentpillen-Look bringen).
3. Sichtbarkeits-Kombinationen (bestehend, `BarCombo`-Look).
4. Detailgrad + Massstab (gestapelt, `BarCombo`-Look).
5. **Massstab/Zoom-Cluster NEU** (siehe §2) — ersetzt die heutige Gruppe mit der
DOPPELTEN Zoom-Anzeige.
6. Darstellungsart (bestehend, `BarCombo`).
7. **Text-Gruppe NEU** (siehe §3) — die zentrale neue Leiste.
8. Referenzlinien / Linien-Modus (bestehend).
9. Rechts: Layout · Ressourcen · Projektname.
## 2. Massstab/Zoom-Cluster (ersetzt Doppel-Zoom-Bug)
HEUTE FALSCH: In `TopBar.tsx` wird `tb-zoom` (Zoom %) ZWEIMAL gerendert
(einmal im `tb-zoomstack`, einmal darunter als eigener `<span>`). Die zweite,
lose `<span className="tb-zoom">…%</span>` ersatzlos ENTFERNEN.
NEUES Layout — 2×2-Raster (`display:grid; grid-template-columns:auto auto;
gap:4px 6px; align-items:center`):
- **Spalte 1, beide Zeilen** (`grid-row:1 / span 2`): EINE kombinierte Stat-Pille,
`width:70px`, Höhe `BAR_H*2+6 = 50px`, `border-radius:14px` (NICHT 999),
`border:1px solid var(--border)`, `background:var(--input)`, Innen zwei Zeilen
mittig, getrennt durch 1px-Linie (`var(--border)`):
- oben: Live-Massstab `1:N` (Akzentfarbe, `var(--font-mono)`, 11px, 700)
- unten: Zoom `NN%` (`var(--ink-2)`, mono, 11px)
- „am Massstab" (Zoom==gewählter Massstab): Pille `background:var(--accent-dim)`,
`border-color:var(--accent)`, Text `var(--accent)`.
- Nicht-Plan-Ansicht: beide Werte „—".
- **Spalte 2, Zeile 1**: Massstab-Dropdown (`BarCombo`, ~140px, mono) + Print/PDF
bleibt separat. (Massstab-Dropdown ist der bestehende `scaleOptions`-Dropdown.)
- **Spalte 2, Zeile 2**: Zoom-Segmentpille mit 3 Zellen — `%` (=`onZoom100`,
Label „1:1"/100 %), `fit_screen` (=`onFit`), `center_focus_strong`
(=`onFitSelection`). Material-Icons. Daneben ggf. Referenzlinien-BarButton.
Export-Knöpfe (PDF/DXF) wandern in eine eigene kleine BarButton-Reihe rechts vom
Cluster (Icons `picture_as_pdf` / `download`) ODER bleiben Pillen — Hauptsache
NICHT mehr Teil des Zoom-Blocks, damit der Cluster ruhig bleibt.
## 3. Text-Gruppe in der Oberleiste (NEU – Kern dieser Aufgabe)
Immer sichtbar. 3×2-Raster (`grid-template-columns:110px 130px 80px; gap:4px 6px`).
Setzt Defaults für neuen Text UND formatiert die aktuelle Auswahl live.
Zeile 1:
- **Stil-Preset** `BarCombo` (110px): Optionen aus `DEFAULT_PRESETS`
(Titel/Untertitel/Label/Notiz) + „— Stil —".
- **Font** `BarCombo` (130px): Systemfont-Liste (mind. Helvetica, Arial, Inter,
Times New Roman, Georgia, Courier New). `applyMark(doc,range,'font',v)`.
- **Grösse** `BarCombo` (80px): Presets in pt `[8,9,10,11,12,14,18,24,36,48]`
+ „Eigene…" → Zahl-Input-Pille. `applyMark(...,'sizePt',n)`.
Zeile 2:
- **B/I/U** Segmentpille (110px, Icons `format_bold`/`format_italic`/
`format_underlined`): `toggleMark(doc,range,'bold'|'italic'|'underline')`.
Aktiv-Zustand aus `isMarkActive(doc,range,mark)`.
- **L/C/R** Segmentpille (130px, Icons `format_align_left`/`_center`/`_right`):
setzt `paragraph.align` im Bereich.
- **„+"-Text-Button** (80px, BarButton/Pille, Icon `add`, Label „Text"): startet
das Text-Werkzeug (neues Textobjekt platzieren). Falls das Text-Annotation-
Werkzeug noch nicht existiert, Button vorerst `disabled` mit Tooltip
(kein stiller No-Op) — aber Verdrahtung vorbereiten.
**Auswahl-Bewusstsein (WICHTIG):** Prop `textTarget` (oder aus App-State): entweder
`null` (nichts Text-artiges selektiert → Controls setzen nur Defaults, Ränder
normal) ODER `{ doc: RichTextDoc, range: TextRange|null, apply: (doc)=>void }`
für den aktuell selektierten Raumstempel/Text. Ist `textTarget != null`, tragen
die Zeile-2-Pillen `border-color:var(--accent)` (Akzent-Glow), und alle Aktionen
wirken auf `textTarget.doc` via `textTarget.apply(newDoc)`. Ohne aktive Range
(nur Objekt selektiert, kein Editor offen) wirkt Formatierung auf das GANZE Doc.
Quelle der `textTarget`-Daten: der Raum-Agent exponiert Stempel-Doc + Setter
(`setRoomStampDoc(roomId, doc)`) im App-State (siehe Peer-Absprache). App leitet
für den selektierten Raum `{doc: room.stampDoc, range: activeStampRange,
apply: d => setRoomStampDoc(room.id, d)}` an die Text-Gruppe.
## 4. Text-Inhalt bearbeiten: kleines Fenster (kein Footer)
Doppelklick auf einen Raumstempel/ein Textobjekt öffnet ein **schwebendes
Dialog-Fenster** (nicht den Footer, kein Panel-Aufklappen). Umsetzung: neue
Komponente `src/ui/TextEditorDialog.tsx` — ein zentriertes/абgesetztes Fenster
(~560×420, `--shadow-3`, `border-radius:8px`, Titel „Text bearbeiten",
Kopf mit Schliessen-✕), Body = der bestehende `src/text/RichTextEditor.tsx`
(er bringt seine eigene Mini-Toolbar mit — das ist hier ok, weil es ein eigenes
Fenster ist), Fuss = „Abbrechen" / „Übernehmen". „Übernehmen" ruft
`setRoomStampDoc(roomId, editedDoc)`.
Der Doppelklick-Handler lebt in App (Plan-View/Viewport → onDoubleClick auf
Stempel-Hit → `openTextEditor(roomId)`), NICHT im Footer. Falls der Raum-Agent
den Footer benutzt hat: diesen Pfad entfernen und durch den Dialog ersetzen.
## 5. Farb-/Stil-Tokens
Bestehende CSS-Variablen weiterverwenden (`--panel`,`--input`,`--border`,
`--accent`,`--accent-dim`,`--accent-border`,`--ink`,`--ink-2`,`--muted`,
`--font-mono`,`--shadow-1..3`). KEINE neuen Farbwerte hart kodieren. Falls ein
Token fehlt (z. B. `--accent-border`), prüfen und ggf. aus bestehenden ableiten.
## 6. i18n
Neue Keys in de.ts UND en.ts: `text.style`, `text.font`, `text.size`,
`text.size.custom`, `text.bold/italic/underline`, `text.align.left/center/right`,
`text.add`, `text.add.hint`, `text.editTitle`, `text.apply`, `text.cancel`,
`text.selectedHint`. Presets-Namen über bestehende `rt.*`/Preset-Keys, sofern da.
## 7. Gate (Pflicht)
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
Trace-Scan (grep auf Co-Authored/Generated), Boot-Probe
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
KEIN Commit.
+43
View File
@@ -0,0 +1,43 @@
# Top-Bar (Oberleiste) & Footer/Status-Leiste — Design
> Referenz: DOSSIER `rhino/toolbar.py` + `src/ToolbarApp.jsx`. Hier auf unseren
> Standalone-Stack (React+TS, eigenes Modell) übersetzt. DOSSIER hat KEINEN Footer
> (delegiert an Rhinos eigene Leiste) — den Footer ergänzen wir neu (Vectorworks-Stil).
## Top-Bar — Gruppen (links → rechts)
1. **Marke/Logo** + Settings-Icons (Projekt-Einstellungen, App-Einstellungen).
2. **Ansicht** — Umschalter: Grundriss · Perspektive · Schnitt · Ansicht.
Später: 3D-Views Top/Iso + Himmelsrichtungen N/O/S/W (mit Nordwinkel-Rotation).
3. **Darstellung** — Render-/Anzeigemodus (Wireframe/Shaded/…) + **Detailgrad**
(grob/mittel/fein, ≙ DOSSIER „Darstellung" Einfach/Standard/Detail).
4. **Massstab & Zoom** — Live-Anzeige „1:N" + Dropdown (1:1,1:5,…,1:1000, frei) +
**Plan-Ansicht-Toggle** (Linienstärken für Druck) + Zoom-Buttons: 100% · Einpassen ·
Auswahl. Quelle: viewBox-Skala / `dpi = 96·devicePixelRatio` (siehe docs/design/plans-output.md).
5. **Overrides** (regelbasiert, Toggle + Preset) · **Masse** (Bemaßungs-Preset) — später.
6. **Anordnen (Z-Order)** — nach vorne/hinten (für 2D-Plangrafik) — später.
7. **Snapping** — Master-Osnap + Modi (End/Mitte/Schnittpunkt/Lot/Zentrum/Nah) +
**Raster** an/aus + **Referenzlinien** (Wandachsen) an/aus — wenn Zeichenwerkzeuge da sind.
8. **Text** — Stil/Font/Größe + B/I/U + Ausrichtung + „+" — mit den 2D-Werkzeugen.
### MVP jetzt (zu vorhandenem Modell)
- Ansichts-Umschalter (haben wir, ausbauen) · Detailgrad-Dropdown · **Massstab 1:N +
Zoom: Einpassen/Auswahl/100%** · Render-Modus · Referenzlinien-Toggle · Ressourcen.
## Footer / Status-Leiste (neu, unten, ~22 px)
`[Werkzeug-Hinweis] … [Cursor X/Y/Z] · [Einheit] · [Massstab 1:N] · [Zoom %] · [Geschoss] · [Ebene] · [Snap] … [Auswahl: n]`
- **Cursor X/Y/Z** — live aus der Plan-/3D-Position (Plan: aus viewBox-Inverse der Maus).
- **Einheit** — m (aus Projekt). **Massstab** 1:N + **Zoom %** — aus der View-Transform.
- **Aktives Geschoss** + **aktive Ebene** — aus Selection/State.
- **Snap-Status** — aktive Fänge (später). **Auswahl: n** — Anzahl selektierter Objekte.
- **Werkzeug-Hinweis** links — kontextueller Text des aktiven Werkzeugs.
### MVP jetzt
- Cursor X/Y (im Grundriss), Einheit, Massstab 1:N, Zoom %, aktives Geschoss + Ebene.
Snap/Werkzeug/Auswahl kommen mit den Zeichenwerkzeugen.
## Anbindung
Beide Leisten docken an das Panel-/Dock-Layout an (Top über den Docks, Footer darunter),
Inhalte rein aus dem Modell + View-State abgeleitet (keine Sonderzustände).
+403
View File
@@ -0,0 +1,403 @@
# truck-Integration — Profil-Extrusion (B-Rep → 3D-Mesh)
> Übergabe-Dokument für die Implementierung. Lies zuerst CONVENTIONS.md.
## Ziel
Nutzer zeichnen im Grundriss ein geschlossenes Polygon (z. B. L-Profil einer Stütze)
und können es als 3D-Körper auf eine Höhe extrudieren. Das Ergebnis erscheint sofort
im 3D-Viewport neben Wänden und Decken.
**MVP-Scope (dieser Auftrag):**
- Neue Rust/WASM-Crate `trucksolid` mit zwei Funktionen: `extrude_polygon` + `extrude_circle`
- Neue TS-Wrapper-Datei `src/engine/truckSolid.ts` (WASM laden + typisierte API)
- KEIN neues Werkzeug, KEIN UI — nur die Geometrie-Schicht. UI kommt in einem
separaten Folgeauftrag.
---
## Architektur-Kontext
```
src-tauri/
render3d/ ← bestehend: wgpu-Renderer (wasm-pack → pkg3d/)
kernel2d/ ← bestehend: 2D-Geometrie-Kern
trucksolid/ ← NEU (dieser Auftrag)
Cargo.toml
src/
lib.rs
src/engine/
pkg3d/ ← render3d WASM-Output
pkgTruck/ ← NEU: trucksolid WASM-Output (wasm-pack Ziel)
truckSolid.ts ← NEU: TS-Wrapper
src/plan/
toWalls3d.ts ← bestehend: baut RenderScene; emitMeshes() muss erweitert werden
```
Das Koordinatensystem in DOSSIER ist **Modell-Meter: X = rechts, Y = oben (Grundriss),
Z = Höhe**. render3d erwartet `positions` im gleichen System — `mesh.rs` macht intern
`(x, y, z) → (x, z, y)` für wgpu (Y-up world). Truck arbeitet in 3D; wir bauen
das Polygon in der XY-Ebene (z=0) und extrudieren nach +Z.
---
## 1 — Rust-Crate `src-tauri/trucksolid/`
### 1.1 Cargo.toml
Exakt dasselbe Muster wie `src-tauri/kernel2d/Cargo.toml`:
```toml
[workspace] # entkoppelt vom cad-tauri-Workspace (Pflicht)
[package]
name = "trucksolid"
version = "0.1.0"
edition = "2021"
description = "Profil-Extrusion via truck (B-Rep → tesselliertes Mesh für render3d)"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
web = [
"dep:wasm-bindgen",
"dep:serde_json",
"dep:console_error_panic_hook",
]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
# truck: nur die stabilen Crates (KEIN truck-modeling — Booleans instabil)
truck-geometry = "0.3"
truck-topology = "0.3"
truck-rendimesh = "0.3"
[dev-dependencies]
serde_json = "1"
```
**Wichtig:** `truck-modeling` (die Crate mit Boolean-Operatoren) wird NICHT eingebunden.
Nur `truck-geometry`, `truck-topology` und `truck-rendimesh` — die sind stabil.
### 1.2 src/lib.rs — Kern-Logik
#### Eingabe/Ausgabe-Typen
```rust
use serde::{Deserialize, Serialize};
/// Input: flaches Array [x0,y0, x1,y1, ...] in Modell-Metern (Grundriss-XY).
/// Muss ≥ 3 Punkte enthalten; Wiederholung des ersten Punkts am Ende optional.
/// Reihenfolge CCW oder CW — truck normalisiert selbst.
#[derive(Deserialize)]
pub struct ExtrudePolyInput {
pub points: Vec<f64>, // flat: [x0,y0, x1,y1, ...]
pub height: f64, // Extrusionshöhe in Metern (> 0)
}
/// Output: trianguliertes Mesh, kompatibel mit render3d::types::MeshInput.
/// positions: flat [x0,y0,z0, x1,y1,z1, ...] in Modell-Metern
/// indices: Dreiecks-Indizes (je 3 = 1 Dreieck), 0-basiert
#[derive(Serialize)]
pub struct MeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
}
```
#### Polygon-Extrusion (truck-API)
```rust
use truck_geometry::prelude::*;
use truck_topology::*;
pub fn extrude_polygon_core(pts: &[(f64, f64)], height: f64) -> Result<MeshOutput, String> {
if pts.len() < 3 { return Err("min 3 Punkte".into()); }
if height <= 0.0 { return Err("height muss > 0 sein".into()); }
// 1. Punkte in der XY-Ebene (z=0) als truck-Vertices
let verts: Vec<Vertex<Point3>> = pts.iter()
.map(|(x, y)| Vertex::new(Point3::new(*x, *y, 0.0)))
.collect();
// 2. Kanten: je zwei aufeinanderfolgende Vertices verbinden, Ring schliessen
let edges: Vec<Edge<_, Line<Point3>>> = verts.windows(2)
.chain(std::iter::once([verts.last().unwrap(), &verts[0]].as_slice()))
.map(|w| {
let p0 = *w[0].point();
let p1 = *w[1].point();
Edge::new(&w[0], &w[1], Line(p0, p1))
})
.collect();
// 3. Wire (geschlossener Kantenzug)
let wire = Wire::from_iter(edges);
// 4. Planare Face aus dem Wire
// truck-topology::Face::new braucht die äussere Boundary + optional Holes
let face = Face::new(vec![wire]);
// 5. Lineare Extrusion: tsweep entlang +Z um `height`
let solid = face.tsweep(&Vector3::new(0.0, 0.0, height));
// 6. Tessellieren (chord-tolerance in Metern — 0.005 = 5 mm)
use truck_rendimesh::MeshedShape;
let mesh = solid.triangulation(0.005).to_polygon();
// 7. positions + indices extrahieren
let positions: Vec<f32> = mesh.positions().iter()
.flat_map(|p| [p.x as f32, p.y as f32, p.z as f32])
.collect();
let indices: Vec<u32> = mesh.tri_faces().iter()
.flat_map(|tri| tri.iter().map(|idx| idx.pos as u32))
.collect();
Ok(MeshOutput { positions, indices })
}
```
> **Achtung:** Die exakte truck-API (Methoden-Namen, Trait-Imports, tsweep-Signatur)
> kann je nach veröffentlichter Version leicht abweichen. Prüfe die Docs von
> `truck-topology 0.3` und `truck-rendimesh 0.3` auf docs.rs. Die Struktur oben
> ist das Ziel-Pattern — passe Methoden-Signaturen an, wenn der Compiler es verlangt.
> Ändere NICHT die Eingabe/Ausgabe-JSON-Struktur.
#### Kreis-Extrusion (Zylinder)
```rust
pub fn extrude_circle_core(cx: f64, cy: f64, r: f64, height: f64) -> Result<MeshOutput, String> {
// Kreis in XY-Ebene tessellieren (N Segmente je nach r),
// dann wie extrude_polygon_core aufrufen.
// Alternativ: truck-geometry::Ellipse/Circle direkt nutzen, falls vorhanden.
let n = (2.0 * std::f64::consts::PI * r / 0.02).ceil().max(16.0) as usize;
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
extrude_polygon_core(&pts, height)
}
```
#### WASM-Bindings (Feature "web")
```rust
#[cfg(feature = "web")]
mod web {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
/// Extrudiert ein Polygon-Profil.
/// `input_json`: `{ "points": [x0,y0,…], "height": 2.5 }`
/// Rückgabe: `{ "positions": […], "indices": […] }` oder throws JsError.
#[wasm_bindgen]
pub fn extrude_polygon(input_json: &str) -> Result<String, JsError> {
let input: ExtrudePolyInput = serde_json::from_str(input_json)
.map_err(|e| JsError::new(&e.to_string()))?;
let pts: Vec<(f64, f64)> = input.points.chunks(2)
.map(|c| (c[0], c[1]))
.collect();
let mesh = extrude_polygon_core(&pts, input.height)
.map_err(|e| JsError::new(&e))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Extrudiert einen Kreis-Querschnitt (Zylinder).
/// `input_json`: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0 }`
#[wasm_bindgen]
pub fn extrude_circle(input_json: &str) -> Result<String, JsError> {
#[derive(serde::Deserialize)]
struct In { cx: f64, cy: f64, r: f64, height: f64 }
let i: In = serde_json::from_str(input_json)
.map_err(|e| JsError::new(&e.to_string()))?;
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height)
.map_err(|e| JsError::new(&e))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
}
```
### 1.3 Tests (headless, ohne Feature "web")
Mindestens diese drei Tests müssen mit `cargo test` grün sein:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quad_extrusion_vertex_count() {
// 1m × 1m Quadrat, 2m hoch → 8 Eckpunkte minimum (6 Flächen × 2 Dreiecke)
let pts = vec![(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)];
let m = extrude_polygon_core(&pts, 2.0).unwrap();
assert!(m.positions.len() >= 8 * 3); // ≥ 8 Vertices × 3 floats
assert_eq!(m.indices.len() % 3, 0); // vollständige Dreiecke
assert!(m.indices.len() >= 12 * 3); // ≥ 12 Dreiecke (Quader)
}
#[test]
fn l_profile_extrusion() {
// L-Profil: 6 Punkte
let pts = vec![
(0.0,0.0),(0.3,0.0),(0.3,0.1),(0.1,0.1),(0.1,0.3),(0.0,0.3),
];
let m = extrude_polygon_core(&pts, 3.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(m.positions.len() > 0);
}
#[test]
fn cylinder_extrusion() {
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(m.positions.len() > 0);
}
#[test]
fn rejects_too_few_points() {
assert!(extrude_polygon_core(&[(0.0,0.0),(1.0,0.0)], 1.0).is_err());
}
#[test]
fn rejects_zero_height() {
let pts = vec![(0.0,0.0),(1.0,0.0),(0.5,1.0)];
assert!(extrude_polygon_core(&pts, 0.0).is_err());
}
}
```
---
## 2 — Build-Script (package.json)
Füge in `package.json` unter `"scripts"` hinzu:
```json
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
```
Und in `src-tauri/Cargo.toml` unter `exclude`:
```toml
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
```
---
## 3 — TS-Wrapper `src/engine/truckSolid.ts`
Gleiche Lade-Pattern wie `src/plan/useWasmPlanRenderer.ts` (render2d) und
`src/viewport/useWasm3dRenderer.ts` (render3d):
```typescript
// Lazy-Singleton: WASM einmalig laden, dann gecacht.
let modulePromise: Promise<typeof import("../../engine/pkgTruck/trucksolid")> | null = null;
async function getModule() {
if (!modulePromise) {
modulePromise = import("../../engine/pkgTruck/trucksolid").then(async (m) => {
await m.default(); // WASM-Binary initialisieren
return m;
});
}
return modulePromise;
}
export interface ExtrudedMesh {
positions: number[];
indices: number[];
}
/** Extrudiert ein geschlossenes Polygon-Profil (Modell-Meter XY) um `height` m. */
export async function extrudePolygon(
points: number[], // flat [x0,y0, x1,y1, ...]
height: number,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_polygon(JSON.stringify({ points, height }));
return JSON.parse(json) as ExtrudedMesh;
}
/** Extrudiert einen Kreis-Querschnitt (Zylinder). */
export async function extrudeCircle(
cx: number, cy: number, r: number, height: number,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height }));
return JSON.parse(json) as ExtrudedMesh;
}
```
---
## 4 — Integration in toWalls3d.ts (Vorbereitung)
In `src/plan/toWalls3d.ts` ist `RMeshKind` bereits definiert:
```typescript
export type RMeshKind = "terrain" | "imported";
```
Erweitere auf `"extrusion"` (damit der Renderer später eine eigene Farbe/Darstellung
wählen kann, auch wenn heute noch kein Unterschied besteht):
```typescript
export type RMeshKind = "terrain" | "imported" | "extrusion";
```
Die `emitMeshes()`-Funktion liest bereits `project.drawings2d` und ähnliche Arrays.
Für extrudierte Körper wird es später ein `project.extrudedSolids`-Array geben
(oder ähnlich — das ist Teil des UI-Folgeauftrags). Die `emitMeshes()`-Erweiterung
kommt dann.
---
## 5 — Verifikations-Checkliste
Bevor du als fertig meldest, müssen alle Punkte grün sein:
- [ ] `cd src-tauri/trucksolid && cargo test` → alle 5 Tests grün, keine Warnings
- [ ] `npm run build:truck` → `src/engine/pkgTruck/trucksolid.js` + `.wasm` erzeugt
- [ ] `npx tsc --noEmit` (aus Root) → 0 Fehler
- [ ] `npx vitest run` → alle bestehenden Tests weiterhin grün (keine Regression)
- [ ] `src-tauri/Cargo.toml` hat `"trucksolid"` im `exclude`-Array
- [ ] `package.json` hat `"build:truck"` im `"scripts"`-Block
---
## 6 — Was NICHT in diesem Auftrag
- Kein neues UI/Werkzeug (kommt später)
- Kein `truck-modeling` (Boolean-Operationen — instabil upstream)
- Kein STEP-Export (kommt in Folgeauftrag wenn Basisschicht steht)
- Kein Eintrag in `project.extrudedSolids` oder Store (UI-Auftrag)
- Keine Änderungen an render3d, generatePlan, App.tsx
---
## 7 — Koordinatensystem-Reminder
| DOSSIER-Modell | truck (im Code) | render3d (wgpu) |
|---|---|---|
| X = rechts | X = rechts | X = rechts |
| Y = oben (Grundriss) | Y = oben (Grundriss) | Z = oben (Y-up swapped) |
| Z = Höhe | Z = Höhe | Y = Höhe |
`mesh.rs` in render3d macht bereits `(model.x, model.y, model.z) → (x, z, y)` beim
Hochladen. Du musst also in truck DOSSIER-Koordinaten verwenden (XY-Ebene = Grundriss,
Z = Höhe) — das ist konsistent mit der bestehenden `MeshInput.positions`-Konvention.
+636
View File
@@ -0,0 +1,636 @@
# Design — Prioritätsbasierte mehrschichtige Wand-Verschneidung (T/X)
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
> Aufbauend auf der bestehenden **L-Ecken-Gehrung** in `src/model/joins.ts`
> (`computeJoins`, `miterLine`) und `src/model/geometry.ts` (`clippedBand`,
> `lineIntersect`). Plan-Verbraucher: `src/plan/generatePlan.ts` (`addWallPoche`).
> Bezeichner **englisch**, Prosa deutsch, Einheiten **Meter**.
> **Hinweis Referenz:** Die im Auftrag genannte DOSSIER-Datei
> `rhino/wand_grips.py` ist in dieser Umgebung **nicht vorhanden** (das einzige
> auffindbare `dossier` ist ein Typst-Portfolio, kein Rhino-Plugin). Das Design
> stützt sich daher auf (a) den bestehenden Code, (b) die in CONVENTIONS.md/elements.md
> festgehaltenen Geometrie-Konventionen und (c) die etablierte Bau-Semantik der
> prioritätsbasierten Schichtverschneidung (Vectorworks „Komponenten-Verbindung",
> Revit „Layer Priority", ArchiCAD „Composite Priority"). Sobald `wand_grips.py`
> verfügbar ist, sollte Abschnitt 7 (Priorität/Backbone) gegen DOSSIERs konkrete
> Rangregel abgeglichen werden.
---
## 0. Problem & Leitbild
Heute (`computeJoins`) wird **nur die L-Ecke** behandelt: genau zwei Wandenden
treffen sich in einem Knoten, eine gemeinsame **Gehrungslinie** (`miterLine`)
schneidet beide Wände sauber. T-/X-Stöße (≥ 3 Enden) bleiben rechtwinklig
gekappt (`if (ends.length !== 2) continue;`) — die durchgehende Wand wird von der
ankommenden Wand nicht durchdrungen, und die Schichten überlappen sich oder
klaffen.
**Ziel:** An jedem Knoten — L (2 Enden), T (3), X/Kreuz (4+) — soll **jede
Schicht jeder Wand** genau bis zu der Fläche reichen, die ihre Verschneidungs-
Semantik vorgibt:
- Die **gemeinsame, höchstpriorisierte Schicht** (Backbone, z. B. Stahlbeton)
läuft **durch** den Stoß.
- **Niederpriorisierte Schichten** (z. B. Putz, Dämmung) **stoßen** an die nächst-
höhere Schicht des Nachbarn und **enden** dort.
- Putz **läuft auf jeder Seite** bis zur Betonfläche und endet dort; Putz
**überquert nie** den Beton (kanonisches Beispiel, siehe §7.1).
**Architektur-Prinzip (CONVENTIONS.md):** Das semantische Modell ist die einzige
Wahrheit. Die Verschneidung ist eine **reine Ableitung** (`Project + Wall[]` →
Trimm-Linien je Schicht-Band), nichts wird in die Geometrie eingebacken. Sie wird
beim Generieren des Plans angewandt und ist **deterministisch** und **idempotent**.
---
## 1. Geometrie-Konventionen (Wiederholung, verbindlich)
Aus CONVENTIONS.md und `geometry.ts`:
- Achsrichtung `u = normalize(end - start)`.
- Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`.
- Schichten werden **außen → innen** gestapelt: Offset entlang `n` läuft von
`-T/2` (linke/„außen"-Kante) nach `+T/2` (rechte/„innen"-Kante). Eine Schicht `k`
belegt das Intervall `[off_k, off_k + thickness_k]` mit `off_0 = -T/2`.
- Eine **unendliche Gerade** ist `Line { point, dir }`; Schnittpunkt zweier
Geraden via `lineIntersect(a, da, b, db)` (null bei parallel).
- Ein Schicht-**Band** zwischen Offsets `offA, offB` wird über `clippedBand(start,
end, offA, offB, startCut, endCut)` gebildet; jede Bandlängskante wird mit einer
optionalen Schnittlinie verschnitten statt rechtwinklig gekappt.
---
## 2. Datenmodell — was schon da ist, was neu kommt
### 2.1 Vorhanden (`types.ts`)
```ts
interface Component { …; joinPriority: number; } // höher = läuft am Stoß durch
interface Layer { componentId: string; thickness: number; }
interface WallType { id; name; layers: Layer[]; } // layers: außen → innen
interface Wall { start: Vec2; end: Vec2; wallTypeId; height; … }
```
`joinPriority` ist bereits **pro Bauteil (Component)** definiert — genau richtig:
Priorität ist eine **Material-Eigenschaft**, nicht eine Schicht-Eigenschaft. Damit
verschneidet sich Beton mit Beton unabhängig vom Wandtyp.
### 2.2 Neue, abgeleitete Strukturen (keine neuen persistenten Felder)
Die heutige `WallCuts`-Struktur (eine Schnittlinie je **Wandende**) reicht für L
(Gehrung) — aber **nicht** für T/X, weil dort verschiedene Schichten **verschiedene**
Trimm-Linien brauchen (Beton läuft durch, Putz stoppt früher). Wir erweitern auf
**pro Schicht, pro Ende** eine eigene Trimm-Linie:
```ts
// src/model/joins.ts (erweitert)
/** Eine gerichtete Halbkante eines Wandendes an einem Knoten. */
interface WallEnd {
wallId: string;
end: "start" | "end";
}
/** Trimm-Linie + Klassifikation für GENAU EIN Schicht-Band an EINEM Ende. */
interface LayerCut {
/** Schnittgerade in Weltkoordinaten; null = rechtwinklig kappen (Default). */
line: Line | null;
/**
* "miter" – Gehrung (L): geteilte Diagonale mit dem Nachbarn.
* "butt" – Band stößt stumpf an eine Nachbarfläche (niedrigere Priorität).
* "through" – Band läuft durch den Knoten (höchste Priorität / Backbone).
* "square" – freies Ende, rechtwinklig (line === null).
*/
kind: "miter" | "butt" | "through" | "square";
}
/** Pro Wandende: eine Trimm-Linie je Schicht-Index (parallel zu WallType.layers). */
interface EndCuts {
/** layerCuts[k] gilt für layers[k]; Länge === wt.layers.length. */
layerCuts: LayerCut[];
}
/** Ersetzt die alte WallCuts: jetzt schichtweise an beiden Enden. */
interface WallJoin {
start: EndCuts;
end: EndCuts;
}
export type JoinMap = Map<string /*wallId*/, WallJoin>;
```
**Abwärtskompatibilität:** Die alte L-Gehrung ist der Spezialfall „alle
`layerCuts[k].line` an einem Ende sind dieselbe `miter`-Linie". `clippedBand`
bleibt unverändert; der Plan-Generator ruft es jetzt **je Schicht mit der
schichtspezifischen Linie** auf (siehe §8).
### 2.3 Knoten-Repräsentation
```ts
/** Ein Verschneidungsknoten: alle Wandenden, die sich (gerundet) berühren. */
interface Junction {
key: string; // roundKey(p)
p: Vec2; // Knotenposition (Mittel der Enden)
arms: Arm[]; // sortiert nach Außenwinkel (CCW)
}
/** Ein „Arm" = ein Wandende, gesehen als Strahl, der vom Knoten WEGzeigt. */
interface Arm {
we: WallEnd;
wall: Wall;
/** Richtung VOM Knoten weg in die Wand hinein (immer normiert). */
dirOut: Vec2; // = end==="start" ? +u : -u
/** Außenwinkel atan2(dirOut.y, dirOut.x) für Sortierung. */
angle: number;
total: number; // Gesamtdicke der Wand
/** Schicht-Profil, vom Knoten aus gesehen (siehe §3.2). */
layers: ArmLayer[];
}
/** Eine Schicht eines Arms, mit ihren beiden Längs-Flächengeraden am Knoten. */
interface ArmLayer {
index: number; // Index in wt.layers
componentId: string;
priority: number; // Component.joinPriority
/** Offsets entlang n der Wand: [innerEdge..outerEdge] des Bandes. */
off0: number; off1: number;
/** Die zwei Längsflächen als Geraden (point am Knoten, dir = u der Wand). */
faceA: Line; faceB: Line;
}
```
---
## 3. Knotenerkennung (Junction Detection)
### 3.1 Knoten clustern
Identisch zur heutigen Logik, nur ohne die `length !== 2`-Abbruchbedingung:
```
function buildJunctions(walls): Junction[]
map = Map<key, WallEnd[]>
for w in walls:
map.push(roundKey(w.start), {wallId:w.id, end:"start"})
map.push(roundKey(w.end), {wallId:w.id, end:"end"})
out = []
for (key, ends) in map:
if ends.length < 2: continue // freies Ende → alle Schichten "square"
arms = ends.map(buildArm)
arms.sort(by angle) // CCW um den Knoten
out.push({ key, p: avgEndPoint(ends), arms })
return out
```
`roundKey` (vorhanden) gruppiert Endpunkte auf ein 0.1-mm-Gitter. **Wichtig für
T-Stöße:** Bei einem echten T endet die ankommende Wand **auf der Achse** der
durchgehenden Wand, nicht an deren Endpunkt. Solche Knoten werden über die
Endpunkt-Gruppierung **nicht** gefunden, wenn die durchgehende Wand dort kein Ende
hat. Daher zusätzlich (siehe §3.3) eine **Achs-Auf-Achs-Inzidenz**.
### 3.2 Arm-Schichtprofil (kanonische Orientierung)
Damit Schichten zweier Arme vergleichbar sind, muss jeder Arm seine Schichten in
**konsistenter Welt-Orientierung** kennen. Wir speichern je Schicht ihre beiden
**Längsflächen** als Geraden mit Stützpunkt am Knoten und Richtung `u`:
```
function buildArm(we): Arm
wall = byId(we.wallId); u = dirOf(wall); n = leftNormal(u)
dirOut = we.end==="start" ? u : negate(u) // vom Knoten in die Wand
j = we.end==="start" ? wall.start : wall.end
total = wallTypeThickness(wt)
off = -total/2
layers = []
for (k, layer) in wt.layers:
off0 = off; off1 = off + layer.thickness
faceA = { point: j + n*off0, dir: u }
faceB = { point: j + n*off1, dir: u }
layers.push({ index:k, componentId, priority, off0, off1, faceA, faceB })
off = off1
return { we, wall, dirOut, angle: atan2(dirOut), total, layers }
```
### 3.3 T-Stoß-Erkennung (Achs-auf-Achs)
Zusätzlich zu Endpunkt-Clustern: ein Wandende `e` (Punkt `P`) bildet einen
**T-Stoß** mit Wand `B`, wenn `P` (innerhalb Toleranz) auf der **Strecke** `B.start
→ B.end` liegt, aber **nicht** auf deren Endpunkten:
```
function findTeeIncidences(walls):
for endpoint P of each wall A (as WallEnd e):
for each wall B != A:
if pointOnSegment(P, B.start, B.end, tol) and not nearEndpoint(P, B):
// virtueller Knoten: A endet, B läuft durch.
registerTee(P, armOf(e), passThroughWall=B)
```
`B` wird hier als **durchgehender Strang** behandelt (kein Ende am Knoten); im
Junction-Modell taucht `B` als zwei kollineare „Arme" auf (Richtung `+u` und
`−u`), die der Prioritätsalgorithmus (§5) automatisch als „durchlaufend" erkennt
(zwei kollineare Arme gleicher Wand → ihre Schichten enden nie gegeneinander).
**MVP-Vereinfachung (Phase 1, §10):** T-Stöße zunächst NUR über koinzidente
Endpunkte (B hat dort tatsächlich einen Eckpunkt, z. B. weil die Wand dort geteilt
wurde). Echte Achs-auf-Achs-T-Stöße (B durchgehend) folgen in Phase 3.
---
## 4. Winkel-Sektoren & Nachbar-Flächen
Für die Prioritätsauflösung muss man wissen, **welche Fläche eines Arms welcher
Fläche des Nachbarn gegenübersteht**. Die Arme sind CCW nach `angle` sortiert.
Zwischen zwei aufeinanderfolgenden Armen `arms[i]` und `arms[i+1]` (zyklisch) liegt
ein **Sektor** (Keil). Jeder Sektor wird von **einer Längsfläche jedes der beiden
Arme** begrenzt:
```
arm[i+1]
\ Sektor S_i
\ /
faceR(i+1)\ ___ faceL(i)
X (Knoten)
/
/
arm[i]
```
Konvention: pro Arm hat die **äußere** Schicht (Index 0, Offset `-T/2`) die Fläche,
die in den **CCW-vorausgehenden** Sektor zeigt; die **innere** Schicht den
**nachfolgenden**. Konkret bestimmen wir die „dem Sektor zugewandte" Fläche jeder
Schicht über das Vorzeichen von `cross(dirOut, sektorrichtung)` — analog zur
`lbCloser`-Heuristik in `miterLine`, aber pro Sektor statt global.
```
function sectorFaces(armLeft, armRight):
// armRight ist CCW vor armLeft (Sektor liegt zwischen ihnen).
// Wähle für jeden Arm die Schicht-Flächen, die in den Sektor zeigen.
faceOf(arm, towards): pick faceA or faceB of each layer by sign of
cross(arm.dirOut, towards - knoten)
```
Diese Sektor-Sicht verallgemeinert die bestehende `miterLine`: bei genau zwei
Armen (L) gibt es zwei Sektoren (innen/außen), und die Mittel-Gehrungslinie ergibt
sich wie bisher aus dem Schnitt der gegenüberliegenden Außen- bzw. Innenflächen.
---
## 5. Prioritätsauflösung — das Kernstück
### 5.1 Idee
An einem Knoten konkurrieren Schichten verschiedener Arme um denselben Raum. Regel:
> Eine Schicht **läuft durch** (`through`), wenn sie zur **höchsten am Knoten
> präsenten Priorität** gehört **und** auf der „gegenüberliegenden" Seite eine
> Schicht **gleichen Materials** (oder ≥ gleicher Priorität) existiert, an die sie
> nahtlos anschließt. Andernfalls **stößt** sie (`butt`) an die nächsthöher-
> priorisierte Nachbarfläche und endet dort.
Praktisch lösen wir das **fläche-gegen-fläche** je Schicht: Für jede Schicht `L`
eines Arms suchen wir die **Trimm-Fläche**, die ihr Band beendet. Das ist die
**erste** (vom Knoten aus, entlang `dirOut`) der gegenüberliegenden Flächen mit
**echt höherer Priorität**; existiert keine, läuft die Schicht bis zur
Knoten-Mittelachse durch.
### 5.2 Pro Schicht: Trimm-Fläche finden
```
function resolveLayerCut(arm, layer, junction): LayerCut
// Kandidaten: alle Schichten ALLER ANDEREN Arme, deren Band den
// Halbraum dieses Layers überlappt (Offset-Überlapp im gemeinsamen
// Sektor) UND deren Priorität die Trimm-Entscheidung bestimmt.
candidates = []
for other in junction.arms where other.wall.id-end != arm.id-end:
for ol in other.layers:
if overlapsInSector(layer, ol, arm, other):
candidates.push({ other, ol, face: facingFace(ol, towards arm) })
// Höchste am Knoten präsente Priorität (global, für "through").
maxPrio = max over all candidate.ol.priority and layer.priority
if layer.priority == maxPrio and hasCollinearSamePrio(arm, layer, junction):
// Backbone: läuft durch bis zur Mittel-/Gehrungslinie.
return { line: miterMidline(arm, layer, junction), kind: "through" }
// Sonst: stoße an die NÄCHSTE Fläche höherer Priorität entlang dirOut.
blockers = candidates.filter(c => c.ol.priority > layer.priority)
if blockers.empty:
// niemand höher → Gehrung mit dem Nachbarn gleicher Stufe (L-Fall)
return { line: miterMidline(arm, layer, junction), kind: "miter" }
nearest = argmin over blockers of distanceAlong(dirOut, face)
return { line: nearest.face, kind: "butt" }
```
Hilfsbegriffe:
- `overlapsInSector(layer, ol, …)` — projiziert beide Bänder auf die **Normale des
Sektors** und prüft Intervall-Überlapp `[off0,off1] ∩ [off0',off1'] ≠ ∅`. Nur
überlappende Bänder können sich gegenseitig trimmen.
- `facingFace(ol, towards arm)` — die der `arm`-Seite zugewandte Längsfläche von
`ol` (die `faceA`/`faceB` von §3.2), als `Line`.
- `distanceAlong(dirOut, face)` — Abstand des Schnittpunkts `faceLine ∩ layerAxis`
vom Knoten, gemessen entlang `dirOut`. Negative bzw. hinter dem Knoten liegende
Treffer werden verworfen (eine Trimm-Fläche muss **vor** dem Band liegen).
- `miterMidline(...)` — die gemeinsame Diagonale für gleichrangige Begegnung; für
zwei Arme exakt die heutige `miterLine`.
- `hasCollinearSamePrio(...)` — true, wenn auf der „anderen Seite" des Knotens eine
Schicht **gleichen Materials/Priorität** existiert, in die das Band nahtlos
übergeht (Backbone-Kontinuität, §7).
### 5.3 Resultat in `LayerCut.line` schreiben
`resolveLayerCut` liefert für `layers[k]` eine `Line | null`. Diese wird in
`WallJoin[end].layerCuts[k]` abgelegt. `clippedBand` kappt damit **dieses eine
Band** an genau dieser Linie — alle anderen Schichten derselben Wand können andere
Linien (oder `null`) haben. Das ist die ganze Verkabelung zum Renderer.
---
## 6. Master-Pseudocode `computeJoins` (neu)
```
function computeJoins(project, walls): JoinMap
result = init each wall → { start:{layerCuts:[square…]}, end:{layerCuts:[square…]} }
junctions = buildJunctions(walls) // §3.1
// (Phase 3: junctions += findTeeIncidences(walls)) // §3.3
for J in junctions:
if J.arms.length == 1: continue // freies Ende: alles "square"
if J.arms.length == 2:
resolveL(J, project, result) // bestehende Gehrung, schichtweise
else:
resolveTorX(J, project, result) // §5, pro Arm pro Schicht
return result
function resolveTorX(J, project, result):
for arm in J.arms:
cuts = []
for layer in arm.layers:
cuts[layer.index] = resolveLayerCut(arm, layer, J) // §5.2
writeEnd(result, arm.we, cuts) // start oder end
function resolveL(J, project, result):
[a, b] = J.arms
// Wie heute: eine gemeinsame Gehrungslinie pro Sektor; aber wenn die
// Dicken/Schichten ungleich sind, kann pro Schicht über §5.2 verfeinert werden.
// MVP: eine miterLine für alle Schichten beider Arme (= heutiges Verhalten,
// schichtweise dupliziert).
line = miterLine(project, a.wall, a.we.end, b.wall)
for arm in [a,b]:
cuts = arm.layers.map(_ => ({ line, kind:"miter" }))
writeEnd(result, arm.we, cuts)
```
So bleibt die **L-Ecke bit-genau wie heute** (Regressionssicherheit), und die
neue Logik greift nur bei ≥ 3 Armen.
---
## 7. Prioritäts-Semantik im Detail (das Putz/Beton-Beispiel)
### 7.1 Kanonisches Beispiel
Wandtyp „Aussenwand" (außen → innen):
| k | Component | thickness | joinPriority |
|---|------------|-----------|--------------|
| 0 | Putz aussen| 0.02 | 1 |
| 1 | Stahlbeton | 0.18 | 9 |
| 2 | Putz innen | 0.015 | 1 |
Drei solche Wände treffen in einem T-Knoten (zwei kollinear durchgehend „BB",
eine ankommend „A"):
1. **Beton (prio 9)** der durchgehenden Wände `BB` läuft durch — beide
Beton-Bänder sind kollinear gleicher Priorität → `hasCollinearSamePrio` =
true → `through`. Der Beton von `A` (prio 9) **stößt** an die **Betonfläche**
von `BB` (gegenüberliegende Fläche, gleiche höchste Priorität, aber kein
kollinearer Partner für `A`) → `butt` an Betonaußenfläche von `BB`.
2. **Putz (prio 1)** jeder Wand stößt an die **erste höherpriorisierte Fläche**
entlang `dirOut`. Für die Putzschichten von `A` ist das die **Betonfläche von
`BB`**: Putz **läuft auf jeder Seite bis zum Beton** und endet dort (`butt`).
3. Putz **überquert nie** den Beton, weil eine `butt`-Trimm-Linie immer die
**nächste** höhere Fläche ist — sie liegt vor dem Beton-Durchlauf.
Ergebnis exakt wie gefordert: *Beton durch, Putz schließt beidseitig an, Putz
kreuzt Beton nicht.*
### 7.2 Warum Priorität pro Component (nicht pro Layer)
Beton trifft Beton → gleiche Priorität → nahtlos. Würde Priorität pro Schicht
vergeben, müsste man sie pro Wandtyp neu pflegen. `joinPriority` am Component löst
das materialweise — Stahlbeton hat **immer** 9, egal in welchem Wandtyp.
### 7.3 Backbone-Erkennung für „through"
`hasCollinearSamePrio(arm, layer, junction)`:
```
for other in junction.arms where other != arm:
if collinear(arm.dirOut, other.dirOut) (antiparallel, gleiche Achse) and
other has a layer ol with ol.priority == layer.priority and
bandsAlign(layer, ol): // gleiche Offsets relativ zur gemeinsamen Achse
return true
return false
```
Nur wenn das Band auf der **gegenüberliegenden** Achse einen passenden Partner
gleicher Priorität und Lage hat, läuft es wirklich **durch**. Sonst (z. B. der
ankommende Beton von `A`) **stößt** es an — exakt das gewünschte Verhalten.
---
## 8. Layer-Wrapping (Umschlagen niederpriorisierter Schichten)
Ein subtiler, aber wichtiger Fall: An einem T-Stoß endet die **innere** Putzschicht
der ankommenden Wand `A` an der Betonfläche von `BB`. Damit der Putz **um die Ecke
herum sichtbar bleibt** (auf der Innenseite der durchgehenden Wand zieht der innere
Putz von `BB` durch), ist nichts Zusätzliches nötig — `BB`s innerer Putz läuft als
eigenes durchgehendes Band weiter.
Wo Wrapping aktiv nötig ist: wenn eine **niederpriorisierte Außenschicht** an einer
Ecke **um eine höhere Schicht herumgeführt** werden soll (z. B. Dämmung, die außen
um die Stütze läuft). Modellierung:
- Standard (MVP): kein Wrapping — jede Schicht endet an ihrer Trimm-Fläche
(`butt`). Das deckt T/X korrekt ab.
- Optional (Phase 4): Ein `wrap`-Flag pro Component (`Component.wrapAtEnds?:
boolean`). Ist es gesetzt, erzeugt der Generator an der Trimm-Fläche ein
**zusätzliches kurzes Stirnband** quer (Offset-Intervall der Schicht, Länge =
Tiefe bis zur nächsten Fläche), sodass die Schicht ihre eigene Stirnseite
„umschließt". Geometrisch ein weiteres `clippedBand` mit getauschten Achsen.
Wrapping ist bewusst **nachgelagert**; es ändert die Trimm-Logik (§5) nicht,
sondern fügt nur Zusatzpolygone hinzu.
---
## 9. Anbindung an den Plan-Generator (`generatePlan.addWallPoche`)
Heute ruft `addWallPoche` `clippedBand(p1, p2, off, off+thickness, startCut,
endCut)` mit **einer** `WallCuts` je Ende. Neu:
```ts
// joins: JoinMap (neu)
const join = joins.get(wall.id) ?? emptyJoin(wt.layers.length);
…
let off = -total / 2;
wt.layers.forEach((layer, k) => {
const startCut = (s <= 1e-6) ? join.start.layerCuts[k].line : null;
const endCut = (e >= axisLen - 1e-6) ? join.end.layerCuts[k].line : null;
out.push({ kind:"polygon",
pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut),
fill: comp.color, hatch: resolveHatch(...), … });
off += layer.thickness;
});
```
- Die **Umrisslinie** über die volle Dicke (`-T/2..+T/2`) nutzt für ihre beiden
Kanten die Cuts der **äußersten** bzw. **innersten** Schicht — oder wird, falls
Schichten unterschiedlich getrimmt sind, durch eine **abgeleitete Outline**
(Vereinigung der Schicht-Polygone, §11) ersetzt. MVP: weiterhin volle-Dicke-Band
mit den Cuts von Schicht 0 / letzter Schicht.
- `grob`-Detailgrad nutzt nur die **Backbone-Schicht** → ihr `through`/`miter`-Cut
für die ganze Sammelfläche (entspricht der bestehenden `backboneColor`-Logik).
**Keine Signaturänderung an `clippedBand`** — es bleibt der zentrale Trimmer; wir
füttern es nur schichtweise mit verschiedenen Linien.
---
## 10. 2D-analytisch jetzt vs. exakte 3D-Booleans später (OCCT)
### 10.1 Jetzt — 2D-analytisch (dieses Design)
- **Plan/Grundriss** ist eine reine 2D-Ableitung (vgl. `generatePlan.ts`-Kopf:
„nicht durch Zerschneiden eines 3D-Meshes, sondern direkt aus den Parametern").
- Trimmen = **Geraden-Schnitt** (`lineIntersect`) + Polygon-Kappen (`clippedBand`).
Kein Polygon-Boolean nötig, solange Schichten als **konvexe Bänder** mit
schrägen Stirnflächen modelliert werden. Das ist exakt, schnell (O(Arme² ·
Schichten) je Knoten), und deterministisch.
- **3D-Wand** im Viewport: Extrusion der getrimmten 2D-Schichtpolygone in `z`
(Höhe). Damit stimmen Plan und 3D ohne separaten Kernel überein.
Grenzen der 2D-Analytik: nicht-konvexe Stirnprofile, echte Materialdurchdringung in
`z` (z. B. wenn Wände unterschiedlicher Höhe / Sturz / Brüstung interagieren),
Verschneidung Wand × Decke × Stütze. Das braucht echte Volumen-Booleans.
### 10.2 Später — exakte 3D-Booleans (OCCT / opencascade.js)
- **Bibliothek:** `opencascade.js` (WASM-Port von OCCT) — `BRepAlgoAPI_Cut/Common`,
`BRepPrimAPI_MakePrism` für Extrusionen. Alternativ `manifold-3d` (schneller,
robuster für reine Mesh-Booleans, aber ohne B-Rep/Fillets).
- **Modell bleibt gleich:** Priorität (`joinPriority`) bestimmt die **Cut-
Reihenfolge**. Algorithmus: baue je Schicht einen über-langen Solid (Band ×
Höhe, über den Knoten hinaus verlängert); subtrahiere von jeder niederpriori-
sierten Schicht die Solids **aller höherpriorisierten** Schichten am Knoten
(`result = layerSolid − ⋃ higherPrioritySolids`). Gleiche Priorität: kein Cut
(Beton bleibt an Beton). Das ist die **3D-Verallgemeinerung exakt derselben
Prioritätsregel** — die 2D-`butt`/`through`-Klassifikation aus §5 ist die
2D-Projektion dieser Boolean-Reihenfolge.
- Die Datenstrukturen (§2) bleiben unverändert; nur der **Trimmer** wird
ausgetauscht (`clippedBand` → OCCT-Cut). Deshalb ist das 2D-Design bereits
„OCCT-ready".
---
## 11. Robuste Outline (optional, Phase 4)
Wenn Schichten an einem Knoten unterschiedlich getrimmt sind, ist die „volle
Dicke"-Umrisslinie nicht mehr ein einfaches Band. Saubere Lösung: die
**Wand-Outline** als **Vereinigung aller Schicht-Polygone** berechnen
(Polygon-Boolean in 2D). Empfohlene Library: **`polygon-clipping`** (Martinez-
Rueda, robust, klein) oder **`@flatten-js/boolean-op`**. Damit wird der Umriss
exakt die Außenkontur der getrimmten Bänder — auch bei X-Knoten. MVP verzichtet
darauf und nutzt die Schicht-0/N-Cuts (visuell für die meisten Fälle ausreichend).
---
## 12. Edge Cases
1. **Gleiche Priorität, nicht kollinear** (z. B. zwei Betonwände treffen im rechten
Winkel im T): kein „through" (kein kollinearer Partner), aber auch kein
`butt`-Blocker höherer Priorität → Fallback **`miter`** (gemeinsame Gehrung wie
im L-Fall). Bei drei gleichrangigen Armen: paarweise Gehrung pro Sektor; die
resultierenden Stirnflächen sind die Sektor-Halbierenden.
2. **Kollinear (180°)** — zwei Wände in einer Linie: `lineIntersect` liefert null
(parallel). Behandlung wie heute: **kein Schnitt**, Bänder laufen gerade durch
(bei gleichem Typ nahtlos). Bei ungleichem Typ: die schmalere Wand stößt an die
breitere; pro Schicht über §5 auflösbar.
3. **> 2 Schichten / asymmetrische Wandtypen**: Der Algorithmus ist in der Schicht-
anzahl generisch (`resolveLayerCut` läuft je Schicht-Index). Treffen Wände
verschiedener Typen (verschiedene Schichtzahl/-dicke), entscheidet **nur die
Priorität pro Fläche**, nicht der Index — deshalb arbeiten wir mit
`ArmLayer.faceA/faceB` (Welt-Flächen), nicht mit Schicht-Indizes über Wände
hinweg.
4. **Backbone fehlt** (alle Prioritäten gleich): degeneriert sauber zu reinen
Gehrungen (Fall 1).
5. **Sehr spitze Winkel**: Trimm-Schnittpunkte können weit vom Knoten wegwandern.
Begrenzung: `distanceAlong` auf `≤ maxReach` (z. B. `3 · total`) clampen; sonst
rechtwinklig kappen (`square`), um Artefakte zu vermeiden.
6. **Öffnungen am Wandende** (`addWallPoche`-Segmentierung): Cuts gelten nur für das
echte Wandende (`s≤ε` / `e≥len−ε`), wie heute. Türnahe Segmentenden bleiben
`square`.
7. **Numerische Knoten-Toleranz**: `roundKey`-Gitter (0.1 mm) und ε in
`pointOnSegment` müssen konsistent sein, sonst „flackernde" Knoten. Toleranz
zentral als Konstante (`JOIN_EPS = 1e-4` m).
8. **Mehr als 2 Wände gleicher Achse** (degenerierter X, alle kollinear): als ein
durchgehender Strang behandeln; Prioritätsregel pro Schicht greift normal.
---
## 13. Staged Build-Plan
**Phase 1 — Single-Layer T (Fundament).**
- `JoinMap`/`WallJoin`/`EndCuts`/`LayerCut` einführen; `WallCuts` darin als
Spezialfall abbilden. `clippedBand` unverändert.
- `buildJunctions` ohne `length!==2`-Abbruch; L-Fall ruft bestehende `miterLine`
(schichtweise dupliziert) → **Regressionsgleichheit** zu heute.
- T-Knoten **nur über koinzidente Endpunkte** (§3.3 MVP). Für **einschichtige**
Wände: die durchgehende Wand läuft durch, die ankommende stößt rechtwinklig an
deren nächste Fläche. Verifizieren: `npx tsc -b`, `npm run build`,
`node scripts/probe.mjs`, Screenshot prüfen.
**Phase 2 — Prioritäts-Auflösung mehrschichtig (T).**
- `ArmLayer`-Profil (§3.2), Sektor-Flächen (§4), `resolveLayerCut` (§5) inkl.
`through`/`butt`/`miter`-Klassifikation und `hasCollinearSamePrio`.
- `addWallPoche` schichtweise verkabeln (§9). Putz/Beton-Testszene (§7.1) anlegen
und visuell prüfen.
**Phase 3 — X-Knoten & echte Achs-T-Stöße.**
- `findTeeIncidences` (Achs-auf-Achs, §3.3): durchgehende Wand wird nicht geteilt.
- 4+-Arm-Sektorlogik vollständig; Edge Cases 1/5/8 absichern.
**Phase 4 — Politur.**
- Robuste Outline via Polygon-Boolean (§11); optionales Layer-Wrapping (§8,
`Component.wrapAtEnds`).
**Phase 5 — Exakte 3D-Booleans (OCCT).**
- `opencascade.js` integrieren; Trimmer-Interface so abstrahieren, dass 2D
(`clippedBand`) und 3D (OCCT-Cut) dieselbe Prioritäts-Reihenfolge nutzen (§10.2).
Plan bleibt 2D-analytisch; nur das 3D-Volumen nutzt Booleans.
---
## 14. Trimmer-Abstraktion (für §10.2-Migration)
Damit Phase 5 nicht den Aufrufer ändert, kapseln wir das Trimmen hinter einer
Schnittstelle. 2D nutzt `clippedBand`; 3D nutzt OCCT — beide konsumieren dieselbe
`JoinMap`.
```ts
interface LayerTrimmer {
/** 2D: getrimmtes Bandpolygon. 3D-Variante liefert stattdessen einen Solid. */
trimBand(p1: Vec2, p2: Vec2, offA: number, offB: number,
startCut: Line | null, endCut: Line | null): Vec2[];
}
```
Die Prioritätslogik (`computeJoins`) bleibt der **gemeinsame, kernel-unabhängige**
Kopf; nur der `LayerTrimmer` wird ausgetauscht. Das hält das Design dem
Architektur-Prinzip treu: ein semantisches Modell, viele abgeleitete Ansichten.
+581
View File
@@ -0,0 +1,581 @@
# WebGL2 GPU-Accelerated 2D Plan Renderer — Architecture
## Executive Summary
A WebGL2 canvas renderer for PlanView's heavy geometry (polygons, lines, hatches) with CPU fallback. Geometry tessellates once per plan and caches; pan/zoom only updates a transform-matrix uniform. Screen-space stroke width via vertex shader normal expansion. Thin SVG overlay handles text, grips, snap markers, tool preview.
**No npm dependencies** — raw WebGL2 + TypeScript.
---
## Current State (SVG Bottleneck)
**PlanView.tsx** renders `Primitive[]` (polygon/line/arc/text) → SVG DOM:
- ~2500 LOC: pan/zoom via viewBox, toScreen() scaling (1 meter = 90 viewBox units)
- `Primitive` types (generatePlan.ts:133):
- **polygon**: `pts: Vec2[]`, fill/stroke/strokeWidthMm, hatch (solid/insulation/diagonal/crosshatch)
- **line**: a/b endpoints, className, weightMm, dash[], optional color
- **arc**: center, from/to points, r, className, weightMm, dash[]
- **text**: anchor, RichTextDoc, roomStamp metadata
- Bottleneck: **pan/zoom re-renders entire SVG DOM** → Cairo rasterizes geometry at 144 Hz
**Key constants:**
- `PX_PER_M = 90` (viewBox units per meter; Modell-Y up → SVG-Y down via negation)
- `PAD = 60` (margin in viewBox units)
- `mmToPx(mm) = (mm / 25.4) * dpi()` (stroke width: constant screen-px via non-scaling-stroke)
- `ZOOM_MAX/MIN = 50/0.2` (pan/zoom bounds)
---
## Architecture: PlanRenderer (WebGL2 + Fallback SVG)
### Module Structure
```
src/plan/
├── PlanRenderer.ts (Main GPU/CPU dispatcher)
├── glPlan/
│ ├── glPlanCompile.ts (Tessellation & buffer upload)
│ ├── glPlanShaders.ts (Vertex/fragment sources + compilation)
│ ├── glPlanRender.ts (Draw loop: matrix uniform, state mgmt)
│ └── glPlanTypes.ts (TypeScript interfaces for GPU data)
└── PlanView.tsx (React wrapper, unchanged API)
```
### High-Level Flow
```
PlanView.tsx
↓ [receives plan: Plan]
↓
PlanRenderer (new abstraction)
↓
├─→ GPU path [if WebGL2 available && flag=true]
│ ├─ glPlanCompile() → upload tessellated geometry to VRAM
│ ├─ glPlanRender() → draw with pan/zoom matrix uniform
│ └─ [fast pan/zoom via matrix only]
│
└─→ Fallback: SVG [if WebGL fails || flag=false]
└─ existing PlanView render path (toScreen + DOM)
```
---
## GPU Path: Tessellation & Shaders
### 1. Tessellation Strategy
#### **Polygon** → Fans + Ear Clipping
- **Input**: Primitive.polygon = { pts: Vec2[], fill, stroke, strokeWidthMm, hatch }
- **Output**: Indexed triangle mesh
- **Algorithm**: Earcut2D (existing JS library logic, inlined to avoid npm)
- Convert polygon pts to 2D float32 array in **world space** (meters)
- Earcut → triangle indices
- Store: `{ vertices: Float32Array, indices: Uint32Array, color: vec4, hasHatch: bool }`
- **Hatch rendering**: Bake hatch as texture or re-implement in fragment shader (MVP: solid fill only; hatch deferred)
#### **Line** → Quad Expansion (Screen-Space Width)
- **Input**: Primitive.line = { a, b, weightMm, cls, dash?, color }
- **Output**: Degenerate quad (2 triangles) with screen-space normal offset
- **Strategy**:
1. Vertex shader receives `{ pos: vec2, side: float }` (side = ±1 for left/right edge)
2. Transform pos to clip space via matrix uniform
3. Compute screen-space perpendicular via `dFdx/dFdy` or pre-compute normal in CPU
4. Expand by `(weightMm / 25.4) * dpi * (screenPixelsPerClipUnit)` in clip space
5. Fragment shader: solid color (no dash MVP; dashing deferred or CPU pre-tessellation)
#### **Arc** → Line Segments (Polyline → Quads)
- **Input**: Primitive.arc = { center, from, to, r, weightMm, cls, dash }
- **Output**: Tessellate arc to ~30 line segments (adaptive based on radius/zoom), expand each as quad
- Fallback: SVG arc for MVP
#### **Text, Grips, Snap-Markers, Tool-Preview**
- **Stays in SVG overlay** (thin, non-bottleneck)
- Render above WebGL canvas at z-order 1
---
### 2. Shader Sources (GLSL 3.00 ES)
#### **Vertex Shader: Solid Fill (polygon)**
```glsl
#version 300 es
precision highp float;
uniform mat4 viewProjection; // pan/zoom as 2×3 affine (expand to mat4)
layout(location=0) in vec2 position; // world-space (meters)
layout(location=1) in vec4 color; // fill color
out VS_OUT {
flat vec4 vertexColor;
} vs_out;
void main() {
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
gl_Position = clipPos;
vs_out.vertexColor = color;
}
```
#### **Vertex Shader: Screen-Space Stroked Line**
```glsl
#version 300 es
precision highp float;
uniform mat4 viewProjection; // world → clip space
uniform vec2 screenSize; // canvas (width, height) in pixels
uniform float strokeWidthMm; // millimeters
uniform float dpi; // 96 * devicePixelRatio
layout(location=0) in vec2 position; // world-space endpoint
layout(location=1) in float sideFlag; // ±1.0 (left/right edge)
layout(location=2) in vec4 lineColor; // stroke color
out VS_OUT {
flat vec4 vertexColor;
} vs_out;
void main() {
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
// Convert stroke width (mm) → screen pixels
float strokePx = (strokeWidthMm / 25.4) * dpi;
// Convert screen pixels → normalized device coords (NDC)
// NDC ∈ [-1,1]²; screen (0,screenSize) → NDC [-1,1]
float strokeNdc = (strokePx / screenSize.x) * 2.0;
// Expand in clip space (simple; assumes aspect ≈ 1)
vec4 expanded = clipPos + vec4(sideFlag * strokeNdc, 0.0, 0.0, 0.0);
gl_Position = expanded;
vs_out.vertexColor = lineColor;
}
```
#### **Fragment Shader (both)**
```glsl
#version 300 es
precision highp float;
in VS_OUT {
flat vec4 vertexColor;
} fs_in;
out vec4 fragColor;
void main() {
fragColor = fs_in.vertexColor;
}
```
---
### 3. GPU Data Structures (TypeScript)
**glPlanTypes.ts:**
```typescript
export interface GLGeometryBatch {
/** Vertex buffer: interleaved (x, y, [z if 3D], ...) in world space. */
vertexBuffer: WebGLBuffer;
vertexCount: number;
/** Index buffer (triangles for fill, degenerate quads for strokes). */
indexBuffer: WebGLBuffer;
indexCount: number;
/** Vertex Array Object (VAO) binds VBO + IBO. */
vao: WebGLVertexArrayObject;
/** Per-batch metadata. */
batches: Array<{
kind: "polygon" | "line" | "arc";
indexStart: number;
indexCount: number;
color: [r: number, g: number, b: number, a: number]; // RGBA [0,1]
hasHatch: boolean;
hatchPattern?: "solid" | "insulation" | "diagonal" | "crosshatch";
strokeWidthMm?: number;
}>;
}
export interface GLPlanRenderState {
// Pan/zoom transform: world (meters) → clip space
viewMatrix: Matrix3 | Matrix4; // 2×3 affine
projMatrix: Matrix4; // orthographic
// Viewport size & DPI for screen-space stroke width
screenWidth: number;
screenHeight: number;
dpi: number;
// Compiled shaders
solidFillProgram: WebGLProgram;
strokeProgram: WebGLProgram;
// Geometry cache (tessellated once per plan)
geometryBatch: GLGeometryBatch | null;
}
```
---
## MVP API: PlanRenderer Class
### Interface
```typescript
export class PlanRenderer {
/**
* Create renderer with WebGL2 context + fallback config.
*/
constructor(
canvas: HTMLCanvasElement,
options?: {
enableGpu?: boolean; // default: true
enableGpuFallback?: boolean; // SVG fallback if GL fails
}
);
/**
* Compile and cache geometry from primitives.
* Call once per plan change.
*/
compilePlan(plan: Plan): Promise<void>;
/**
* Set pan/zoom transform matrix.
* Call on every view change (pan, zoom, fit).
*/
setViewMatrix(viewBox: { x, y, w, h }, canvasSize: { w, h }): void;
/**
* Render one frame: clear, draw batches, composite.
* Called from requestAnimationFrame loop.
*/
render(): void;
/**
* Release WebGL resources.
*/
dispose(): void;
/**
* Query GPU availability / fallback state.
*/
isGpuReady(): boolean;
isFallbackActive(): boolean;
}
```
### Usage in PlanView
**Before** (SVG only):
```tsx
function PlanView({ plan, ... }) {
return (
<svg ref={svgRef}>
<defs>{hatches}</defs>
{plan.primitives.map((p, i) => <PrimitiveShape ... />)}
</svg>
);
}
```
**After** (GPU + SVG fallback):
```tsx
function PlanView({ plan, ... }) {
const rendererRef = useRef<PlanRenderer | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
rendererRef.current = new PlanRenderer(canvas, { enableGpu: true });
rendererRef.current.compilePlan(plan);
}, [plan]);
useEffect(() => {
rendererRef.current?.setViewMatrix(view, { w: canvasWidth, h: canvasHeight });
}, [view, canvasWidth, canvasHeight]);
useEffect(() => {
const frame = () => {
rendererRef.current?.render();
rafId = requestAnimationFrame(frame);
};
rafId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(rafId);
}, []);
return (
<div style={{ position: "relative" }}>
{/* GPU canvas (or SVG fallback if GL unavailable) */}
<canvas ref={canvasRef} style={{ position: "absolute" }} />
{/* Thin SVG overlay: text, grips, snap-markers, tool preview */}
<svg ref={svgRef} style={{ position: "absolute", zIndex: 1 }}>
{/* text, grips, snaps only; geometry stays in WebGL */}
</svg>
</div>
);
}
```
---
## Data Flow: From Primitives → GPU
### 1. **Compile Phase** (glPlanCompile.ts)
```typescript
export function compilePlan(gl: WebGL2RenderingContext, plan: Plan): GLGeometryBatch {
const batches: BatchInfo[] = [];
const vertices: number[] = [];
const indices: number[] = [];
let indexOffset = 0;
for (const prim of plan.primitives) {
if (prim.kind === "polygon") {
const { verts, inds } = tessellatePolygon(prim.pts);
const color = parseColor(prim.fill);
batches.push({
kind: "polygon",
indexStart: indexOffset,
indexCount: inds.length,
color,
hasHatch: prim.hatch.pattern !== "none",
hatchPattern: prim.hatch.pattern,
});
vertices.push(...verts);
indices.push(...inds.map((i) => i + indexOffset));
indexOffset += verts.length / 2;
} else if (prim.kind === "line") {
const { verts, inds } = tessellateLineQuad(prim.a, prim.b);
const color = parseColor(prim.color || "black");
batches.push({
kind: "line",
indexStart: indexOffset,
indexCount: inds.length,
color,
strokeWidthMm: prim.weightMm,
});
vertices.push(...verts);
indices.push(...inds.map((i) => i + indexOffset));
indexOffset += verts.length / 2;
}
// arc → polyline → quads (deferred for MVP)
}
const vbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
const ibo = gl.createBuffer()!;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
const vao = gl.createVertexArray()!;
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0); // position
gl.enableVertexAttribArray(0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
return { vertexBuffer: vbo, indexBuffer: ibo, vao, batches, vertexCount: vertices.length, indexCount: indices.length };
}
```
### 2. **Render Phase** (glPlanRender.ts)
```typescript
export function renderPlan(
gl: WebGL2RenderingContext,
state: GLPlanRenderState,
batch: GLGeometryBatch
): void {
gl.clearColor(1, 1, 1, 1); // white background
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(state.solidFillProgram);
const mvpLoc = gl.getUniformLocation(state.solidFillProgram, "viewProjection");
const mvp = mat4.multiply(state.projMatrix, state.viewMatrix);
gl.uniformMatrix4fv(mvpLoc, false, mvp);
gl.bindVertexArray(batch.vao);
for (const b of batch.batches) {
const colorLoc = gl.getUniformLocation(state.solidFillProgram, "vertexColor");
gl.uniform4f(colorLoc, b.color[0], b.color[1], b.color[2], b.color[3]);
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.indexStart * 4);
}
}
```
---
## Tessellation Details
### Earcut (Polygon Triangulation)
**Inlined earcut logic (no npm):**
```typescript
function tessellatePolygon(pts: Vec2[]): { verts: number[]; inds: number[] } {
// Convert Vec2[] → flat float array
const coords = pts.flatMap((p) => [p.x, p.y]);
// Earcut2D: robust polygon triangulation
// → Returns index array (triplets = triangles)
const triangles = earcut(coords);
// Vertex buffer: just positions (x, y) in world space (meters)
const verts = coords;
return { verts, inds: triangles };
}
// Simplified earcut (full version ~200 LOC; reference libtess2 or earcut.js)
function earcut(data: number[], hole?: number[], dim?: number): number[] {
// ... iterative ear clipping, complexity O(n²) worst-case
// Returns Uint32Array of triangle indices
}
```
### Line Quad Expansion
```typescript
function tessellateLineQuad(
a: Vec2, b: Vec2,
widthMm: number = 0.5
): { verts: number[]; inds: number[] } {
// World-space endpoints; width (mm) will be expanded in vertex shader
// Create a degenerate quad: 2 triangles
// Vertices: [a_left, a_right, b_left, b_right]
// (normal expansion happens in VS)
const verts = [
a.x, a.y, 0.0, // vertex 0: a, left flag
a.x, a.y, 1.0, // vertex 1: a, right flag
b.x, b.y, 0.0, // vertex 2: b, left flag
b.x, b.y, 1.0, // vertex 3: b, right flag
];
// Two triangles: (0, 1, 2) and (1, 3, 2)
const inds = [0, 1, 2, 1, 3, 2];
return { verts, inds };
}
```
---
## Pan/Zoom Matrix Transform
### View Box → Clip Space
```typescript
function buildViewMatrix(
viewBox: { x, y, w, h },
canvasSize: { w, h }
): Matrix4 {
// 1. World space (meters, origin at model 0,0) → viewBox units (PX_PER_M=90)
const scale = PX_PER_M; // 1 meter → 90 viewBox units
// 2. ViewBox viewport: x,y,w,h in viewBox units → NDC [-1,+1]²
// Orthographic projection (no perspective).
const ortho = mat4.ortho(
viewBox.x,
viewBox.x + viewBox.w,
viewBox.y,
viewBox.y + viewBox.h,
-1, 1
);
// 3. Scale from viewBox units → world (invert PX_PER_M)
const scaleMatrix = mat4.scale(mat4.identity(), [1/scale, 1/scale, 1]);
return mat4.multiply(ortho, scaleMatrix);
}
```
Whenever PlanView calls `setView(viewBox)` or `onWheel()` → call `setViewMatrix()` → GPU re-renders with new matrix uniform (no tessellation).
---
## Fallback Strategy: SVG Renderer Flag
**Global flag** in PlanView or app state:
```typescript
const [useGpuRenderer, setUseGpuRenderer] = useState(true);
```
**Render path branching:**
```typescript
return useGpuRenderer && rendererRef.current?.isGpuReady()
? <canvas ref={canvasRef} />
: <svg ref={svgRef}>{/* existing SVG rendering */}</svg>;
```
**When GL fails** (e.g., no WebGL2 support, Out-Of-Memory):
1. Renderer catches error in `compilePlan()`
2. Sets internal `fallbackActive = true`
3. Returns gracefully (app renders SVG path instead)
4. User sees same plan, slower but functional
---
## Implementation Order (MVP → Iteration)
### Phase 1: Core (Week 1)
1. **glPlanTypes.ts** — TypeScript interfaces for GPU state
2. **glPlanShaders.ts** — Compile vertex/fragment shaders, handle GL errors
3. **glPlanCompile.ts** — Tessellation (earcut inlined), buffer upload
4. **glPlanRender.ts** — Draw loop, matrix uniform, clear/present
5. **PlanRenderer.ts** — Main class, dispatcher (GPU vs SVG fallback)
6. **PlanView.tsx** — Wire renderer, canvas overlay, canvas lifecycle
### Phase 2: Hatches & Lines (Week 2)
- Improve line tessellation: proper screen-space width (dFdx/dFdy or pre-computed normals)
- Hatch patterns: texture-based or procedural fragment shader (diagonal/insulation)
- Arc tessellation: polyline → quads
### Phase 3: Polish (Week 3)
- Stroke dashing via geometry or fragment shader
- Greyed opacity blending
- Hit testing integration (point-in-triangle for GPU)
- Performance profiling, batch merging
---
## Performance Targets
| Operation | SVG (Current) | GPU (Target) | Notes |
|-----------|---------------|--------------|-------|
| **Tessellation** | — | 10–50 ms | Once per plan |
| **Pan/Zoom 60 Hz** | 16 ms (re-render SVG) | <1 ms (matrix uniform) | Matrix upload negligible |
| **Pan/Zoom 144 Hz** | 7 ms (bottleneck) | <0.5 ms | 28× speedup expected |
| **Geometry: 1000 polygons** | 50–100 ms SVG render | 1–5 ms GPU draw | CPU tessellation pipelined |
User: AMD RX 7800 XT → easily capable of 4K+ geometry at 144 Hz.
---
## Known Deferred Items (Post-MVP)
- **Hatches**: Solid fill only MVP; insulation/diagonal/crosshatch in Phase 2 via texture or procedural shader
- **Dashing**: Not in MVP (complex with screen-space strokes); either CPU pre-tessellation or fragment shader alpha-discard
- **Arcs**: Fallback to SVG for MVP; GPU polyline expansion in Phase 2
- **Text, Grips, Snaps**: Stay in SVG overlay indefinitely (no GPU benefit; text rendering nontrivial)
- **Hit Testing**: Keep in CPU/SVG for MVP; GPU pick-buffer deferred
- **Color/Opacity Blending**: Basic for MVP; advanced (multiply, screen, dodge) deferred
---
## References
- **Earcut.js**: https://github.com/mapbox/earcut — polygon triangulation (logic to inline)
- **three.js line expansion**: https://github.com/mrdoob/three.js/blob/master/src/renderers/webgl/WebGLGeometries.js
- **OpenGL Perspective Division**: https://en.wikibooks.org/wiki/OpenGL_Programming/Modern_OpenGL_Tutorial_Polygon_offset
- **Screen-Space Stroke Width**: https://forum.libcinder.org/topic/smooth-line-rendering-using-geometry-shaders
- PlanView source: `/home/karim/cad/src/plan/PlanView.tsx` (2500 LOC)
- Primitive types: `/home/karim/cad/src/plan/generatePlan.ts:133`
+80
View File
@@ -0,0 +1,80 @@
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
> Für den Entwickler, der den nativen GPU-Renderer baut. Isoliert vom
> Web-Renderer; koordiniert über dieses Dokument.
## COMMIT-REGEL (verbindlich)
Dieses Repo darf **keinerlei Fremd-Tool-Hinweise** enthalten — nicht im Code,
in Kommentaren oder in der Git-Historie. Kommentare deutsch, Identifier englisch.
**Kein Commit ohne Absprache.**
## Warum
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
ist das 144-Hz-flüssig, aber **Tauris Linux-Webview = WebKitGTK bremst** (langsamer
Compositor, vermutlich 60-Hz-rAF-Cap). Bewiesen: dieselbe App im `chromium --app`-
Fenster = butterweich, im Tauri-Fenster = zäh — trotz imperativem Pan, CSS-transform-
Overlay, DMABUF-Tweaks. Siehe Memo `webkitgtk-bottleneck`.
**Lösung:** die schwere Grafik **nativ mit wgpu** rendern (Rust), die Webview macht
nur noch UI-Chrome (Panels/Buttons). So bleibt Tauri (natives Rust-Backend, winziges
Bundle) UND wir umgehen den Webview-Compositor komplett.
## Referenz-Implementierung (NICHT wegwerfen — 1:1 portierbar)
Der WebGL-Renderer unter `src/plan/glPlan/` ist die **arbeitende Referenz**. Die
harte Arbeit ist dort schon gelöst und portiert konzeptuell direkt nach wgpu:
- `glPlanCompile.ts` — **Earcut-Triangulierung** (konkav-fähig), Linien→Quads mit
Normale+Seiten-Flag, Batch-Merging, Farb-Parsing. Unit-Tests: `glPlanCompile.test.ts`.
- `glPlanShaders.ts` — Vertex/Fragment (GLSL 300 es). **WGSL ≈ GLSL** — direkt übersetzbar.
- Füll-VS: Bildschirm-Position → Clip via `viewProj`-Matrix.
- Linien-VS: bildschirmkonstante bzw. papier-mm-Breite via Normalen-Offset im Clip
(siehe `strokeScale`/`strokePx`-Logik — Papier-mm × Massstab N × meet-Skala).
- `glPlanRender.ts` — Ortho-Matrix (aspekt-korrekt = SVG `xMidYMid meet`), Fill-/Line-
Pass, `computeOrthoMatrix`.
- Koordinaten-Konvention: BILDSCHIRM-Raum `sx = mx·PX_PER_M, sy = -my·PX_PER_M`
(`PX_PER_M=90`), Modell-Y hoch → Bildschirm-Y runter.
- Strichbreite = **echte Papier-mm im Massstab**: `Breite_px = mm · N/1000 · PX_PER_M ·
meetSkala` (repliziert SVG `printStrokeVb`). Am Beispiel 0.35 mm @ 1:100 = 0.35 mm.
Die **Daten** kommen aus `src/plan/generatePlan.ts` → `Plan.primitives` (Union-Typ
`Primitive`: polygon/line/arc/text). Text bleibt Overlay (nicht wgpu).
## Die EINE harte Frage (zuerst spiken/recherchieren)
**Wie rendert wgpu in das Tauri-Fenster neben der Webview?** Optionen recherchieren:
1. Natives Child-Surface unter der Webview (raw-window-handle), Webview transparent
drüber für UI-Chrome. Vermutlich der Zielweg.
2. Eigenes wgpu-Fenster (entkoppelt) — nur für den Spike, um Rendering von der
Fenster-Integration zu trennen.
3. wgpu→Textur→Webview — verwirft den Zweck (Copy-Overhead), nur Notnagel.
**Empfehlung:** Rendering-Spike ZUERST entkoppelt (Option 2, standalone `winit`+wgpu-
Fenster), das die `Primitive` als gefüllte Polygone + Striche zeichnet und Pan/Zoom
per Matrix-Uniform macht. Fenster-Integration in Tauri ist der zweite, separate Schritt.
## Meilensteine
- **M0 (Research):** kurzer Bericht `docs/design/wgpu-integration-findings.md` — wie
wgpu-Surface + Tauri/webview koexistieren (raw-window-handle, Transparenz, Z-Order,
Input-Routing). Quellen verlinken.
- **M1 (Rendering-Spike, standalone):** neue Crate `src-tauri/render2d/` (serde-Input
= geflachte Primitive), wgpu + WGSL:
- Earcut-Port (oder `lyon`/`earcutr` Crate evaluieren) → Dreiecke.
- Füll-Pipeline (gefüllte Polygone, Ortho-Matrix-Uniform).
- Linien-Pipeline (Quad-Expansion, echte Papier-mm-Breite).
- `cargo test` für die Tessellierung (Muster: `src-tauri/geometry` hat 4/4 Tests —
z. B. konkaves L flächentreu, wie `glPlanCompile.test.ts`).
- `cargo build` grün. (Visuelle Fenster-Verifikation braucht Display → an Mensch/
Display-Session übergeben; im Bericht vermerken.)
- **M2 (Tauri-Integration):** Surface unter die Webview, Pan/Zoom-Input aus der
Webview an den Renderer, Parität mit dem WebGL-Pfad messen (144 Hz).
- **M3:** Schraffur, Bögen, Auswahl-Highlight; danach 3D (three.js→wgpu).
## Koordination (WICHTIG)
- **Eigener Branch** (z. B. `feature/wgpu-renderer`), NICHT auf `master`/
`feature/parametric-walls` committen. Isolierter Worktree.
- **Nur** `src-tauri/` + neue Rust-Module + `docs/`. **NICHT** `src/App.tsx`,
`src/plan/*`, `types.ts` anfassen (dort laufen parallel Features).
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
- Ergebnisse/Diffs zurückliefern; Projektleitung committet.
## Gates
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
`npx tsc -b` + `npm run build` müssen unberührt grün bleiben (keine Web-Änderungen).
+263
View File
@@ -0,0 +1,263 @@
# Briefing — Nativer wgpu-3D-Renderer (M0 Design + M1 Spike)
> Schwester-Dokument zu `wgpu-2d-renderer-briefing.md`. Beschreibt den Weg vom
> jetzigen three.js-3D-View (WebGL im WebKitGTK-Webview) zu einer nativen
> wgpu-Engine (Rust). Dieses Dokument ist der ANFANG: M0 (Bestandsaufnahme +
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
## Commit-/Spuren-Regel
Wie im ganzen Repo: keine Fremd-Tool-Hinweise im Code, in Kommentaren oder der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne Absprache.
## Warum
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
2D-Plan bremst dieser Compositor unter Linux (siehe `wgpu-2d-renderer-briefing.md`
und Memo `webkitgtk-bottleneck`). Die schwere 3D-Grafik soll daher **nativ mit wgpu**
gerendert werden (Rust), die Webview macht nur noch UI-Chrome. Das umgeht den
Webview-Compositor komplett und teilt sich die Toolchain mit dem 2D-Renderer
(`src-tauri/render2d/`, gleiche Feature-Stufung, gleiche Test-Muster).
---
## 1. Bestandsaufnahme — was der three.js-View rendert
Referenz: `src/viewport/Viewport3D.tsx` (analysiert), plus die Geometrie-Grundlage
in `src/model/geometry.ts`, `src/model/wall.ts`, `src/model/joins.ts`,
`src/geometry/opening.ts`, `src/geometry/stair.ts`. Zeilenangaben beziehen sich auf
den Stand der Analyse.
### Koordinaten-Konvention (verbindlich)
Das Modell ist 2D in Metern (`x`, `y`) plus Hoehe `z`. Die 3D-Welt ist **Y-up**:
```
world.x = model.x
world.y = Hoehe (z)
world.z = model.y
```
D.h. **der Grundriss liegt in der XZ-Ebene, die Extrusion laeuft entlang +Y.**
Belegt u.a. in `Viewport3D.tsx`:
- Kommentar (~Z. 473): „Modell (x,y,z) → Three (x, z, y) (Z = Hoehe nach oben)".
- Kontext-Mesh (~Z. 1679–1684): `verts[i]=pos.x; verts[i+1]=pos.z (Hoehe); verts[i+2]=pos.y`.
- Wand-Griffe (~Z. 764): `new THREE.Vector3(wall.start.x, zBottom, wall.start.y)`.
- Workplane-Raycast (~Z. 627): `{ x: hit.x, y: hit.z }` (Three → Modell).
Diese Konvention ist in `render3d` 1:1 uebernommen (`types.rs`, `mesh.rs`).
### Waende (der Kern)
- Funktion `addWallMeshes()` / `addLayerPrism()` (~Z. 1939–2039, 2237–2271).
- **Mesh-Weg:** `THREE.ExtrudeGeometry` (~Z. 2248) ueber die 2D-Bandform, die
`clippedBand(p1, p2, offA, offB, startCut, endCut)` liefert (~Z. 2237; Funktion in
`src/model/geometry.ts:87`). Extrudiert wird um `depth = zTop - zBottom`.
- Die Bandform kommt aus Achse + Dicke: `wallBand`/`wallCorners`
(`geometry.ts:53`/`:70`) versetzen die Achse um `thickness/2` entlang der
**Links-Normale** `leftNormal(u) = (-u.y, u.x)` (`geometry.ts:17`), CCW-Umlauf.
- `ExtrudeGeometry` liegt in der XY-Ebene und waechst entlang +Z; three.js dreht das
Prisma daher um +90 Grad um X und setzt es auf `topY` (~Z. 2266–2271). In wgpu
extrudieren wir direkt in world (XZ-Grundriss, +Y-Hoehe) und sparen die Drehung.
- **Hoehe/Basis:** `wallVerticalExtent(project, wall)` (`wall.ts:56`) liefert
absolute `zBottom`/`zTop` (aus `wall.bottom`/`wall.top`-Ankern bzw. Geschoss-
`baseElevation + wall.height`).
- **Mehrschichtig:** je `wt.layers`-Schicht ein eigenes Prisma mit Dicken-Offset
(~Z. 1991–2015). M1 extrudiert vereinfacht EINE Schicht (Gesamtdicke).
- **Ecken/Gehrung:** `computeJoins()` (`joins.ts:45`) berechnet Schnittlinien
(`startCut`/`endCut`), die `clippedBand` an L-Ecken auf Gehrung zieht
(`miterLine`, `joins.ts:101`). M1 laesst das noch weg (stumpfe Enden).
### Oeffnungen (Fenster/Tueren)
- `addOpeningMeshes()` (~Z. 2056–2169) + Segmentierung in `addWallMeshes` (~Z.
1968–2035). **Kein CSG/Boolean:** die Wand wird entlang der Achse in Segmente
zerlegt (`openingInterval`, `geometry/opening.ts:31`), und je Oeffnung entstehen
bis zu drei Prismen: Wand DAVOR, **Bruestung** unter dem Fenster (`sillRel`),
**Sturz** ueber der Oeffnung (`headRel`). Rahmen/Fluegel als `BoxGeometry`;
Glas semitransparent, Tuerfluegel um `swingAngle` gedreht.
### Treppen
- `addStairMeshes()` (~Z. 2357–2409). `stairGeometry()` (`geometry/stair.ts`)
liefert Trittflaechen (Footprint + Steig-Hoehe) + optionalen Podest-Umriss; jede
Stufe als extrudierter Block (`ExtrudeGeometry`), Hoehe = `stairVerticalExtent`.
### Decken/Platten
- `addCeilingMesh()` (~Z. 2290–2347). `ceiling.outline` als `ExtrudeGeometry`, Tiefe
= Deckenstaerke, waechst nach unten von `zTop` (`ceilingVerticalExtent`, `wall.ts:80`).
### Raeume
- Nicht als eigenstaendige 3D-Koerper gerendert (2D-Grundriss-Repraesentation).
### Kontext/Gelaende
- `buildContext()` (~Z. 1651–1718). Terrain/importierte Meshes als rohe
`BufferGeometry` (Positions/Indices, Koordinaten-Swap wie oben); Hoehenlinien als
`LineSegments`. Dazu ein `GridHelper` (~Z. 421) auf OKFF-Hoehe.
### Materialien
- `MeshLambertMaterial` (Waende/Oeffnungen/Treppen, per Komponente eingefaerbt,
~Z. 2176–2206), `MeshStandardMaterial` (Weiss-/Textur-Modus + Terrain, PBR:
`roughness`/`metalness`/`aoMap`, ~Z. 445–491, 2001–2015), `MeshBasicMaterial`
(Hidden-Line-Flaechen + immer-oben-Marker), `LineBasicMaterial` (Kanten/2D-
Zeichnungen). Render-Modi: shaded / white / textured / wireframe / hidden-line.
### Beleuchtung
- `AmbientLight(0xffffff, 0.6)` (~Z. 416) + `DirectionalLight(0xffffff, 1.1)` bei
`(6, 12, 4)` (~Z. 417). **Keine Schatten** konfiguriert. Keine Hemisphere/Point-
Lights.
### Kamera + Presets
- Zwei Kameras: `PerspectiveCamera(fov, 1, 0.1, 1000)` (~Z. 367) und
`OrthographicCamera(-1,1,1,-1, 0.1, 5000)` (~Z. 374). `applyView3d()` (~Z.
1566–1633) setzt fuenf Presets:
- **front** — Richtung `(0,0,1)`, orthografisch.
- **side** — Richtung `(1,0,0)`, orthografisch.
- **top** — Richtung `(0,1,~0)`, orthografisch (Rotation gesperrt).
- **iso** — Richtung `(1,1,1)` normiert, orthografisch.
- **perspective** — Richtung `(0.62,0.5,0.7)` normiert, perspektivisch.
- Umschalten perspektiv/ortho ueber `active = perspective ? camera : orthoCamera`
(~Z. 1602); Ortho-Frustum aus den Modell-Bounds (`updateOrthoFrustum`, ~Z. 1522).
- **OrbitControls** (~Z. 391–413): Mitteltaste orbit, Shift+Mitte pan, Rad zoom
(linke/rechte Taste fuer Auswahl/Kontextmenue umgewidmet).
### Griffe / Gizmos
- Editier-Griffe (`SphereGeometry`, ~Z. 711–814): Endpunkt (orange), Hoehe (blau),
Verschieben (gruen); `depthTest:false` (immer sichtbar). Drag ueber Workplane-
Raycast. Fuer den nativen Renderer spaeter relevant (eigener Overlay-Pass).
### Schnittebene
- **Nicht implementiert:** keine `renderer.clippingPlanes` / `localClippingEnabled`.
Schnitte laufen aktuell 2D. Fuer wgpu ein eigenständiger spaeterer Milestone
(Clip-Distances im Shader oder Stencil-Capping).
### Tiefe / Culling
- Tiefentest three.js-Standard aktiv. Backface-Culling per Default (Ausnahme:
Terrain/Import `DoubleSide`). Diverse Overlays mit `depthTest:false`.
---
## 2. Port-Plan nach wgpu
### Datenfluss
Web-Modell → **geflachte Eingabe** (`WallInput`, spaeter Oeffnungen/Treppen/Decken)
→ `render3d`-Mesh-Erzeugung → GPU-Buffers → Draw. Analog zum 2D-Pfad (`Scene` →
Tessellierung → Buffers). Die Eingabe ist bewusst serde-only und GPU-frei, damit die
Mesh-Logik headless testbar bleibt.
### Mesh-Erzeugung (Waende extrudieren)
- Band aus Achse + Dicke ueber die Links-Normale (`(-u.y, u.x) * thickness/2`,
CCW), exakt wie `wallCorners`. Extrusion in world: XZ-Grundriss, +Y von
`base_elevation` bis `+height`.
- Ein Quader = 6 Seiten, je eigene Vertices mit Flaechen-Normale (flaches Shading,
korrektes Backface-Culling). 24 Vertices / 36 Indizes je Wand.
- Spaeter: mehrschichtige Waende (je Schicht ein Prisma), Gehrung
(`computeJoins`/`clippedBand`-Port), Oeffnungs-Segmentierung (Bruestung/Sturz).
### Kamera (View/Projektion, Presets)
- `look_at` (right-handed, Kamera blickt entlang -Z im View-Raum), `perspective`
und `orthographic` — beide auf **Clip-Z in [0,1]** (wgpu-Konvention, NICHT [-1,1]).
- Fuenf Presets (`preset_camera`): front/top/side orthografisch achsparallel,
iso/persp perspektivisch. `top` mit up=-Z, damit Modell-Y im Bild nach unten
zeigt (wie die 2D-Sicht).
- Orbit-Kamera aus Yaw/Pitch/Distanz (`orbit_eye`, Pitch geklemmt gegen Pol-Flip).
### Beleuchtung
- Zunaechst EIN Directional-Light (Richtung ZUM Licht) + ambienter Sockel im
Fragment-Shader (WGSL) — das GPU-Aequivalent zu `AmbientLight(0.6)` +
`DirectionalLight(1.1)@(6,12,4)`. Diffuses Lambert. PBR (Rauheit/Metallik/
Texturen/AO) spaeter.
### Tiefenpuffer + Culling
- `Depth32Float`-Attachment, `depth_compare = Less`, `depth_write = true`.
- `front_face = Ccw`, `cull_mode = Back` (die Extrusion liefert konsistent nach
aussen zeigende CCW-Flaechen).
### Matrix-Mathematik
- Handgerechnet (kein `glam`) in der serde-only Schicht — begruendet in `math.rs`:
die Standard-Schicht soll wie in render2d ohne Zusatz-Crates headless test-/baubar
bleiben; der Umfang (perspective/ortho/look_at + Orbit) ist klein und exakt
testbar. Ein spaeterer Wechsel zu `glam` (nur in der GPU-Schicht) bleibt moeglich,
ohne die Kamera-Tests anzufassen. Alles spalten-major, direkt als Uniform ladbar.
### Milestones M2..Mn
- **M2 — Mehrschichtige Waende + Gehrung:** Port von `computeJoins`/`miterLine` +
`clippedBand` → gehrte Bandformen je Schicht; Farben/Materialien je Komponente.
- **M3 — Oeffnungen:** Achsen-Segmentierung (Bruestung/Sturz) + Rahmen/Glas/Fluegel
als eigene Meshes; Tuerschwenk-Winkel.
- **M4 — Treppen + Decken:** Port von `stairGeometry`/`ceilingVerticalExtent`.
- **M5 — Kontext/Gelaende:** rohe Terrain-/Import-Meshes + Hoehenlinien + Grid.
- **M6 — Materialien (PBR):** `MeshStandardMaterial`-Aequivalent (roughness/
metalness/albedo/AO-Textur); Render-Modi shaded/white/textured/wireframe/hidden.
- **M7 — Schnittebene:** Clip-Distances im Shader oder Stencil-Capping (Feature, das
der three.js-View gar nicht hat — echter Mehrwert).
- **M8 — Griffe/Gizmos + Picking:** Overlay-Pass (immer-oben) + GPU-/Ray-Picking.
- **M9 — Tauri-Integration:** Surface unter der Webview (raw-window-handle,
Z-Order, Input-Routing) — siehe `wgpu-2d-renderer-briefing.md` M2 (gleiches
Integrations-Problem; einmal loesen, fuer 2D+3D nutzen). Kamera-Presets/Orbit-
Input aus der Webview an den Renderer.
---
## 3. Was in `src-tauri/render3d/` steht (M1)
Neue, eigenstaendige Crate (eigener leerer `[workspace]`-Block, wie render2d), damit
`cargo test`/`build` unabhaengig vom Tauri-Workspace laufen. Feature-Stufung 1:1 wie
render2d:
- `Cargo.toml` — Features `default` (serde-only) / `render` (wgpu) / `window`
(winit-Spike). `[[bin]] spike3d` mit `required-features = ["window"]`.
- `src/types.rs` — serde-only Eingabe: `WallInput { start, end, thickness, height,
base_elevation, color }`, `Camera` (+ `Projection`), `CameraPreset`; Ausgabe
`Mesh` (interleaved `[pos.xyz, normal.xyz, color.rgb]` + Indizes) mit
`vertex_count`/`triangle_count`/`bounds`. Koordinaten-Konvention dokumentiert.
- `src/mesh.rs` — Wand-Extrusion: `extrude_wall`/`build_walls_mesh`. Band ueber
Links-Normale, Quader mit sechs eigenen Seiten, nach aussen zeigende Normalen.
- `src/math.rs` — `Mat4` (spalten-major), `perspective`/`orthographic` (Clip-Z
[0,1]), `look_at`, `view_projection`, `orbit_eye`, `preset_camera` (fuenf Presets).
- `src/shaders.rs` — WGSL (`MESH_WGSL`): View-Projektion-Uniform + Directional-
Light + ambienter Sockel im Fragment-Shader.
- `src/gpu.rs` (Feature `render`) — `Renderer`: eine Pipeline mit Tiefenpuffer,
View-Projektions-Uniform, Backface-Culling. `upload_walls` → GPU-Buffers,
`render(camera, viewport)`.
- `src/bin/spike3d.rs` (Feature `window`) — winit-Fenster mit Demo-Raum (5
extrudierte Waende) + **Orbit-Kamera** (linke Maustaste dreht Yaw/Pitch, Rad
zoomt Abstand). Matrix-getrieben, kein Re-Meshing beim Kamera-Wechsel.
- `src/lib.rs` — Modul-Deklarationen, Re-Exports, Tests.
### Tests (`cargo test`, default-Feature)
Muster wie render2d/`glPlanCompile.test.ts`:
- Quader-Zaehlung (eine Wand → 24 Vertices / 36 Indizes / 12 Dreiecke).
- Mehrere Waende addieren sich.
- Bounding-Box deckt Laenge/Dicke/Hoehe ab; `base_elevation` verschiebt in Y.
- Deckel-Normale = +Y; **alle Mantel-Normalen zeigen nach aussen** (Dot mit
„Vertex − Zentrum" ≥ 0 → Backface-Culling korrekt).
- Diagonale Wand; degenerierte Wand (Start==Ende) erzeugt nichts (kein Absturz).
- Kamera: `look_at` setzt Ziel auf view-z=-dist; Perspektive klemmt z in [0,1];
`orbit_eye` haelt den Abstand; Presets setzen die richtige Projektionsart.
- Mit `--features render`: WGSL headless via `naga` (Parser + Validator) validiert.
---
## 4. Build-/Test-Ergebnis
Alle Gates gruen (Toolchain: cargo 1.96, wgpu 22, winit 0.30):
- `cargo test` (default) — **12/12** gruen (Mesh + Kamera).
- `cargo test --features render` — **13/13** gruen (inkl. WGSL-naga-Validierung).
- `cargo build` (default), `--features render`, `--features window` — je gruen,
**keine Warnungen**.
- Trace-Scan sauber (keine KI-Spuren).
- Web-Gates unberuehrt (nur `src-tauri/` + `docs/` angefasst; `src/` nur gelesen).
**Visuelle Fenster-Verifikation** ist headless NICHT moeglich. Auf einer aktiven
Display-Session pruefbar mit:
```
cargo run --features window --bin spike3d
```
Erwartet: ein Raum aus extrudierten Waenden mit diffuser Beleuchtung; linke
Maustaste dreht die Orbit-Kamera, das Rad zoomt.
---
## 5. Naechste Schritte
1. **M2** starten: `computeJoins`/`clippedBand`-Port für gehrte, mehrschichtige
Waende (die Bandmath ist im Web bereits verifiziert — gleiche Tests portieren).
2. Oeffnungs-Segmentierung (M3) auf demselben Extrusions-Kern.
3. Die **Tauri-Integration (M9)** gemeinsam mit dem 2D-Renderer loesen (ein Surface-
Unterbau, ein Input-Routing) — das ist der eigentliche Engpass, nicht das
Rendering. Erst standalone spiken (dieser Stand), dann unter die Webview.
@@ -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 36–42. 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).
+566
View File
@@ -0,0 +1,566 @@
# Swisstopo-Geodaten & SIA-Flächenstandards im Browser-BIM
> Stand: 2026-06-29 · Recherche für das Standalone-Browser-BIM (React + TS + Three.js),
> Port von **DOSSIER** (Rhino-Plugin). Ziel: (A) Schweizer Geodaten (Höhenmodell,
> Orthofoto, 3D-Gebäude, Parzellen) direkt im Browser laden, (B) Standort-Kontext
> (Gelände + Parzelle + Nachbargebäude) für ein Projekt importieren, (C) SIA-416-
> Flächen/Volumen + Raumschemata berechnen wie in DOSSIER.
>
> Bezug zur ROADMAP: Swisstopo/Terrain/OSM = **Phase 4** (Kontext/Daten); SIA-416-
> Räume + Bilanz-CSV = **Phase 2**. Beide sind dort bereits als ⭐-Features gelistet.
**Alle in diesem Dokument genannten geo.admin.ch-Endpunkte wurden am 2026-06-29 live
gegen die echte API getestet** (curl + CORS-Header-Check). Wo „verifiziert" steht,
liegt eine echte Antwort vor.
---
## Teil A — Swisstopo-APIs & Dienste aus dem Browser
### A.0 Das Wichtigste vorweg: CORS & Lizenz
Zwei Fragen entscheiden, ob ein Dienst *ohne Backend-Proxy* aus einer reinen
Browser-App nutzbar ist: CORS und Lizenz. Beide sind hier günstig.
**CORS (live verifiziert):** Alle relevanten Hosts senden `access-control-allow-origin: *`:
| Host | Dienst | CORS | Range-Requests |
|---|---|---|---|
| `api3.geo.admin.ch` | REST (height, profile, identify, find, search) | ✅ `*` | — |
| `data.geo.admin.ch` | STAC-API + Daten-Assets (COG-GeoTIFF, XYZ.zip) | ✅ `*` | ✅ `206 Partial Content`, `accept-ranges`/`content-range` vorhanden |
| `wmts.geo.admin.ch` | WMTS-Kacheln | ✅ `*` | — |
| `3d.geo.admin.ch` | 3D-Tiles (`tileset.json` + glTF) | ✅ `*` | — |
→ **Konsequenz:** Höhenabfrage, Geocoding, Parzellen-Identify, Karten-/Orthofoto-
Kacheln, **COG-GeoTIFF-Höhenmodell per Range-Request** und 3D-Tiles sind **direkt aus
dem Browser ohne eigenen Proxy** abrufbar. Das ist ein großer Vorteil gegenüber vielen
anderen nationalen Geodiensten.
**Lizenz:** swisstopo/geo.admin.ch ist **Open Government Data**: „The acquisition and
use of data or services is free of charge, subject to the provisions on fair use."
Kommerzielle Nutzung ist erlaubt, Einbindung in (auch kommerzielle) Web-Apps explizit
gedeckt. Pflicht-Attribution: **`© swisstopo`** (bzw. „© Data: swisstopo"). „Fair use"
= z.B. Web-App mit Ø 20'000 Nutzern/Tag ok; aggressives Bot-Scraping vermeiden. Haftung
ausgeschlossen, ~98% Verfügbarkeit. [Terms of use FSDI](https://www.geo.admin.ch/en/general-terms-of-use-fsdi)
> ⚠️ **Korrektur zu DOSSIER & zur Doku:** Die offizielle REST-Doku notiert beim
> *Height*-Service „This service is not freely accessible (fee required)". Das ist
> **in der Praxis falsch / veraltet**: Der Endpunkt antwortet anonym, ohne Key, mit
> `200` und CORS `*` (verifiziert, siehe A.1). DOSSIERs Aussage „alle APIs offen, ohne
> Auth, ohne Key" deckt sich mit der gemessenen Realität. Wir verlassen uns aber nicht
> blind darauf, sondern behandeln 402/429 defensiv (Retry/Backoff, Cache).
### A.1 Höhenabfrage — Height-Service (Einzelpunkt)
Punkt-Höhe (DTM) aus swissALTI3D/DTM. **Verifiziert:**
```
GET https://api3.geo.admin.ch/rest/services/height?easting=2600000&northing=1200000&sr=2056
→ {"height":"555.5"}
```
Parameter:
- `easting`, `northing` — LV95 (`sr=2056`) oder LV03 (`sr=21781`). **Pflicht.**
- `sr` — `2056` (LV95) angeben, sonst Default `21781`.
- `elevation_model` — `DTM2` (= swissALTI3D, 2 m), `DTM25` (Default), `COMB`.
(Im Tal lieferten DTM2/DTM25/COMB denselben Wert; im Steilgelände kann DTM2 genauer sein.)
- `callback` — JSONP (brauchen wir wegen CORS nicht).
Nutzung im Tool: **Projekt-Nullpunkt-Z** bzw. „Gebäude auf Gelände setzen" — eine
einzelne Höhe an der Projekt-Koordinate. Antwortzeit ~50–150 ms. Quelle:
[GeoAdmin REST – Height](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
### A.2 Höhenprofil — Profile-Service (Schnittlinie)
Höhen entlang einer Polylinie — ideal für **Geländeschnitt** unter einem
Gebäude-Schnitt. **Verifiziert** (echte Werte zurück):
```
GET https://api3.geo.admin.ch/rest/services/profile.json
?geom={"type":"LineString","coordinates":[[2600000,1200000],[2600200,1200000]]}
&sr=2056&nb_points=3
→ [{"alts":{"COMB":555.5,"DTM2":555.5,"DTM25":555.5},"dist":0,"easting":2600000,"northing":1200000},
{"alts":{...},"dist":100,...}, {"dist":200,...}]
```
Parameter: `geom` (GeoJSON-LineString, max 6'000 Punkte), `sr`, `nb_points` (Anzahl
Stützpunkte, Default 200), `elevation_models`, `offset` (Glättung). Auch als
`profile.csv`. → Für 2D-Geländeschnitte **ohne** Mesh-Download. Quelle:
[GeoAdmin REST – Profile](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
### A.3 swissALTI3D — Höhenmodell als COG-GeoTIFF (Mesh-Quelle) ⭐
Das ist der **Schlüssel für das Gelände-Mesh im Browser**. swissALTI3D ist das präzise
DTM der Schweiz (ohne Vegetation/Bebauung), Auflösung 0.5 m / 2 m, alle 6 Jahre
aktualisiert ([swissALTI3D](https://www.swisstopo.admin.ch/en/height-model-swissalti3d)).
Bezug über die **STAC-API** (verifiziert — Tile `swissalti3d_2019_2599-1198`):
```
GET https://data.geo.admin.ch/api/stac/v1/collections/ch.swisstopo.swissalti3d/items
?bbox=<lonMin,latMin,lonMax,latMax>&limit=...
```
Jedes 1×1-km-Tile liefert pro Auflösung **zwei** Asset-Typen:
| Asset | Typ | Browser-tauglich? |
|---|---|---|
| `..._0.5_2056_5728.tif` / `..._2_2056_5728.tif` | **Cloud-Optimized GeoTIFF**, **EPSG:2056** | ✅ **direkt** via `geotiff.js` + Range |
| `..._0.5_2056_5728.xyz.zip` / `..._2_..._xyz.zip` | ASCII-XYZ (E N Z) in ZIP | ✅ via `fflate` entpacken (so macht es DOSSIER) |
**Verifiziert:** `data.geo.admin.ch` liefert auf das `.tif` ein `206 Partial Content`
mit `content-range` bei `Range:`-Header und CORS `*`. Das bedeutet: **`geotiff.js`
liest nur den benötigten Ausschnitt eines COG per HTTP-Range, ohne das ganze File zu
laden** — perfekt für eine Browser-App. Bbox in WGS84 für STAC, Tile-Daten dann in
LV95-Metern (kein Reprojizieren der Z-Werte nötig). Quelle:
[STAC tech docs](https://docs.geo.admin.ch/) · COG-Tile live geprüft.
### A.4 SWISSIMAGE / Karten — WMTS-Kacheln
Orthofoto (10 cm) und Landeskarten als Kacheln. RESTful-URL-Template:
```
https://wmts.geo.admin.ch/1.0.0/<Layer>/default/<Time>/<TileMatrixSet>/<z>/<TileCol>/<TileRow>.<ext>
```
Beispiel-Layer:
- `ch.swisstopo.swissimage` — Orthofoto, `.jpeg`
- `ch.swisstopo.pixelkarte-farbe` — Landeskarte farbig, `.jpeg`
- `ch.kantone.cadastralwebmap-farbe` — **Katasterplan (AV)**, `.png`
TileMatrixSets: **`2056` (LV95)**, `21781`, `3857` (Web-Mercator), `4326`. Zoom 0–28
(4000 m → 0.1 m); Zoom 27/28 nur für wenige Layer (swissimage, Kataster). Für 3D in
Three.js am einfachsten **`3857`** (Standard-Slippy-Map-Schema, z/x/y), z.B.
```
https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg
```
Für planimetrisch exakte 2D-Arbeit besser **`2056`**. CORS `*` (verifiziert). Quellen:
[WMTS docs](https://docs.geo.admin.ch/visualize-data/wmts.html) ·
[WMTS service](https://wmts.geo.admin.ch/) ·
[WMTS EPSG:2056 CodePen](https://codepen.io/geoadmin/pen/GZKEam).
> Es gibt zusätzlich klassisches **WMS** (`https://wms.geo.admin.ch/`, GetMap mit
> beliebiger BBox/Größe, ebenfalls EPSG:2056). Für ein einzelnes georeferenziertes
> Orthofoto-Rechteck unter dem Modell ist ein WMS-GetMap manchmal praktischer als
> WMTS-Kacheln zu stitchen. [WMS docs](https://docs.geo.admin.ch/visualize-data/wms.html)
### A.5 swissBUILDINGS3D — Nachbargebäude (3D)
Zwei Wege:
**(a) 3D-Tiles (Streaming, Cesium-Format)** — `glTF`/`tileset.json`, für große Gebiete:
```
https://3d.geo.admin.ch/<Layer>/<Version>/<Time>/tileset.json
```
Layer u.a. `ch.swisstopo.swissbuildings3d.3d`, `ch.swisstopo.swisstlm3d.3d`,
`ch.swisstopo.swissnames3d.3d`, `ch.swisstopo.vegetation.3d`. `Version` = `v1`, `Time`
optional (ISO `YYYYMMDD`, weglassen = aktuellste). CORS `*` (verifiziert auf
`tileset.json`). Direkt für CesiumJS gedacht; in **reinem Three.js** über
`@loaders.gl/3d-tiles` oder den `3DTilesRendererJS` (NASA-AMMOS/`three.js`-Community)
ladbar. [3D-Tiles docs](https://docs.geo.admin.ch/visualize-data/3d-tiles.html) ·
[Switzerland in 3D](https://www.swisstopo.admin.ch/en/switzerland-in-3d)
**(b) STAC-Tiles als CAD/Mesh-Datei (Download pro Tile)** — so macht es DOSSIER:
Collections `ch.swisstopo.swissbuildings3d_3_0` (neu; in Städten z.T. >700 MB Tiles)
und `ch.swisstopo.swissbuildings3d_2` (1-km-Tiles, ~50 MB, stabil). Assets in
`.dxf/.dwg/.obj/.ifc` (+ `.zip`), Varianten `solid`/`separated`. Für den **Import als
echte, editierbare Massen** ins eigene Modell ist der OBJ/IFC-Tile-Weg besser als
3D-Tiles (die sind read-only Visualisierung). swissBUILDINGS3D: >3 Mio Gebäude,
Lage-/Höhengenauigkeit 30–50 cm.
→ **Empfehlung:** Für „Nachbarschaft als Kontext anzeigen" (Phase 4 Start) **3D-Tiles
streamen** (kein Download, kein Parsing). Wenn der Nutzer Nachbargebäude als Geometrie
*braucht* (Verschattung, Abstand), **STAC-OBJ-Tile** laden und als Mesh importieren.
### A.6 Parzelle / Kataster (AV) — Identify-Service ⭐
**Verifiziert** — Parzellen-Polygon aus einer Koordinate, in LV95:
```
GET https://api3.geo.admin.ch/rest/services/api/MapServer/identify
?geometry=2600423,1199521&geometryType=esriGeometryPoint
&imageDisplay=100,100,96&mapExtent=2600323,1199421,2600523,1199621
&tolerance=2&layers=all:ch.kantone.cadastralwebmap-farbe
&returnGeometry=true&geometryFormat=geojson&sr=2056
→ {"results":[{"type":"Feature","bbox":[...],
"geometry":{"type":"Polygon","coordinates":[[ [2600377.1,1199523.7], ... ]]},
"attributes":{"number":"698","egris_egrid":"CH507635214670","ak":"BE", ...}}]}
```
- Parzellen-Layer: **`ch.kantone.cadastralwebmap-farbe`** (liefert `number`,
`egris_egrid` = EGRID, Kanton; mit `returnGeometry=true&geometryFormat=geojson` das
**Parzellen-Polygon in LV95-Metern** → direkt als Grundstücksgrenze importierbar).
- Gebäudeadressen: `ch.swisstopo.amtliches-gebaeudeadressverzeichnis`; Gebäude-/
Wohnungsregister `ch.bfs.gebaeude_wohnungs_register` (EGID).
- Pflichtparameter: `geometry`, `geometryType` (`esriGeometryPoint|...Polygon|...Envelope`),
`mapExtent`, `imageDisplay`, `tolerance`; `sr=2056`; max 50 Features/Request.
Quellen: [Identify features](https://docs.geo.admin.ch/access-data/identify-features.html) ·
[GeoAdmin REST – Identify/Find](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html) ·
Live-Antwort oben.
### A.7 Geocoding — SearchServer (Adresse → LV95)
**Verifiziert** (Adresse → Koordinate, deckt sich mit DOSSIERs `geocode()`):
```
GET https://api3.geo.admin.ch/rest/services/api/SearchServer
?searchText=Bundesplatz 3 Bern&type=locations&origins=address&sr=2056&limit=1
→ results[0].attrs: { label:"Bundesplatz 3 <b>3011 Bern</b>", lat:46.94677, lon:7.44419,
geom_st_box2d:"BOX(2600423.26 1199521.11, ...)", origin:"address", ... }
```
- `type=locations`, `origins` aus `{address, parcel, gg25, gazetteer, zipcode, district,
kantone}`, `sr=2056`. **Im LV95-Modus liefert die Geo-Admin-Konvention `y`=East,
`x`=North** (DOSSIER liest genau so: `e=attrs.y`, `n=attrs.x`). Labels enthalten
`<b>`-Tags (strippen).
- `type=featuresearch` + `features=<layer>` durchsucht Attribute (z.B. Parzellennummer).
Quelle: [Search](https://docs.geo.admin.ch/access-data/search.html) · Live-Antwort oben.
### A.8 Koordinatensystem LV95 / EPSG:2056 & Transformationen
Intern rechnet das BIM-Tool in **Metern** (ROADMAP-Konvention) und verschiebt den
Projekt-Ursprung nahe (0,0,0); LV95-Koordinaten sind ~2.6 Mio / 1.2 Mio Meter groß und
würden bei `float32` (Three.js) zu **Jitter** führen → **Origin-Shift Pflicht** (siehe B.3).
DOSSIER macht genau das (`origin_shift`/`shift_lv95`, typ. bbox-Center → 0/0/0).
Transformations-Optionen:
1. **`proj4` (npm `proj4@2.20.9`)** — universell, exakt. EPSG:2056-Definition:
```js
proj4.defs("EPSG:2056",
"+proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 "+
"+x_0=2600000 +y_0=1200000 +ellps=bessel "+
"+towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs +type=crs");
const [e,n] = proj4("EPSG:4326","EPSG:2056",[lon,lat]); // WGS84→LV95
```
Genauigkeit mit dieser 3-Parameter-`towgs84` ~1 m (für Kontext-Import völlig
ausreichend). Types: `@types/proj4`. Quellen:
[epsg.io/2056](https://epsg.io/2056) · [proj4js](https://github.com/proj4js/proj4js).
2. **Näherungsformeln (CH1903→WGS84, swisstopo)** — DOSSIERs Ansatz, ~1 m genau,
**0 Dependencies** (zwei kleine Funktionen `lv95_to_wgs84`/`wgs84_to_lv95`).
1:1 nach TS portierbar; gut, wenn man `proj4` nicht ziehen will. Reicht, weil
STAC-Queries ohnehin nur eine grobe WGS84-Bbox brauchen und alle *Daten* schon in
LV95 kommen.
3. **swisstopo REFRAME Web-API** — cm-genaue offizielle Umrechnung (LV95↔WGS84,
LN02↔Bessel). Nur nötig, wenn Vermessungs-Genauigkeit verlangt wird. REST, online.
[REFRAME Web](https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web)
**Empfehlung:** `proj4` mit fester EPSG:2056-Def (eine Abhängigkeit, exakt genug,
wartungsarm) — oder, wenn Dependency-Geiz, DOSSIERs Formeln portieren. REFRAME nur bei
Bedarf nachrüsten.
### A.9 Endpoint-Übersicht (Spickzettel)
| Zweck | Endpoint | Frei/CORS | Format |
|---|---|---|---|
| Punkt-Höhe | `api3…/rest/services/height` | ✅ ✅ | JSON |
| Höhenprofil (Schnitt) | `api3…/rest/services/profile.json` | ✅ ✅ | JSON/CSV |
| Gelände-Mesh (COG) | STAC `…/swissalti3d/items` → `.tif` (COG, 2056) | ✅ ✅ Range | GeoTIFF |
| Gelände (ASCII) | STAC `…/swissalti3d` → `.xyz.zip` | ✅ ✅ | XYZ in ZIP |
| Orthofoto/Karte | `wmts…/1.0.0/<layer>/…/{z}/{x}/{y}.jpeg` | ✅ ✅ | Kacheln |
| 3D-Nachbargebäude (stream) | `3d…/ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` | ✅ ✅ | 3D-Tiles/glTF |
| 3D-Gebäude (Datei) | STAC `…/swissbuildings3d_2` → `.obj/.ifc` | ✅ ✅ | OBJ/IFC |
| Parzelle/Kataster | `api3…/MapServer/identify` `layers=all:ch.kantone.cadastralwebmap-farbe` | ✅ ✅ | GeoJSON |
| Geocoding | `api3…/SearchServer?type=locations` | ✅ ✅ | JSON |
---
## Teil B — Standort-Kontext importieren (Terrain + Parzelle + Nachbargebäude)
So bekommt ein Projekt seinen realen Kontext „auf Knopfdruck". Der Ablauf folgt
DOSSIER (`rhino/swisstopo.py`), übersetzt auf Browser-Libs.
### B.1 Pipeline (End-to-End)
```
Adresse/Parzelle ──SearchServer──▶ Zentrum (E,N) in LV95
│
├─ radius r ──▶ bbox_LV95 (E±r, N±r) ──proj4/Formeln──▶ bbox_WGS84
│
├─[Parzelle] identify(cadastralwebmap, point) ─▶ Polygon (LV95) ─▶ Grundstücksgrenze (Ebene 01 Vermessung)
│
├─[Gelände] STAC(swissalti3d, bbox_WGS84) ─▶ COG .tif(2056)
│ └─ geotiff.js readRasters(window) ─▶ Höhen-Grid (E,N,Z, m)
│ └─ Three.js BufferGeometry (Grid→Mesh) [optional: TIN, Höhenlinien, Volumen]
│
├─[Orthofoto] WMTS swissimage ─▶ Textur auf Gelände-Mesh ODER georef. Plane
│
└─[Nachbarn] 3D-Tiles streamen (Anzeige) ODER STAC swissbuildings3d_2 .obj ─▶ Mesh-Import
(Weltweit/ausserhalb CH: OSM-Overpass als Fallback, siehe B.5)
──▶ alle Geometrien um origin_shift (bbox-Center→0/0/0) verschoben, Z aus ALTI3D
```
### B.2 Gelände-Mesh aus swissALTI3D (Kern, Phase 4)
**Empfohlener Browser-Weg (COG + geotiff.js):**
1. STAC-Query mit `bbox_WGS84` → Liste der überlappenden Tiles; pro Tile das
gewünschte COG-Asset (`_2_2056_` für 2 m, `_0.5_2056_` für 0.5 m).
2. `geotiff.js`: `const tiff = await fromUrl(href)` → COG; `image.readRasters({window})`
liest **nur den Ausschnitt** (Range-Requests, da CORS+Range bestätigt). Ergebnis ist
ein reguläres Z-Raster mit bekanntem Origin/PixelScale (LV95-Meter) aus den
GeoKeys/`image.getOrigin()`/`image.getResolution()`.
3. Raster → **`THREE.BufferGeometry`**: ein Vertex pro Rasterpunkt
`(E−shiftE, N−shiftN, Z−shiftZ)`, Faces als zwei Dreiecke pro Zelle (DOSSIER:
`mesh_from_grid`, gleiche Logik), `computeVertexNormals()`. Bei 0.5 m wird das Mesh
groß → bei Bedarf raumräumlich sub-samplen (DOSSIER macht ganzzahliges Sub-Sampling
auf dem **globalen** LV95-Raster, damit Nachbar-Tiles nahtlos zusammenpassen).
4. Mehrere Tiles: erst zu **einem** Grid mergen (gemeinsamer Origin/Step), dann meshen —
sonst entstehen Nähte (DOSSIER: `merge_grids`).
**Alternativweg (XYZ, exakt wie DOSSIER):** `.xyz.zip` laden → mit **`fflate`**
(npm, schnellster Inflate im Browser) entpacken → ASCII `E N Z` parsen → gleiches Grid.
Robust, aber überträgt mehr Bytes als der COG-Range-Weg. Für den Port empfehle ich
**COG primär, XYZ als Fallback**.
Bibliotheken: `geotiff@3.0.5`, `fflate` (XYZ-Variante), `three@0.185.0`.
[geotiff.js](https://github.com/geotiffjs/geotiff.js/) ·
[3D-Terrain aus GeoTIFF mit Three.js](https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/).
**Ableitungen wie in DOSSIER** (alle aus dem Grid, in Phase 4 portierbar):
- **Höhenlinien** (Marching-Squares auf dem Grid; npm `d3-contour` oder
`marchingsquares`) — für 2D-Plan.
- **TIN / Patch** (Delaunay aus den Punkten; npm `delaunator`) — alternatives Mesh.
- **Geschlossenes Gelände-Volumen** (Boden N m unter tiefstem Punkt) → gefüllte
Querschnitte beim Schnitt-Cut (DOSSIER `terrainVolume`/`terrainVolumeDepth`).
### B.3 Origin-Shift (Pflicht)
LV95-Koordinaten (~2.6e6) sprengen `float32`. Beim Import einmal
`shift = (eCenter, nCenter, zRef)` festlegen, **alle** Geometrien `−shift` rechnen, und
`shift` am Projekt persistieren (für Re-Import / Geo-Referenz / Norden). DOSSIER:
`origin_shift`/`shift_lv95`, plus „Auto-Zoom auf Import" (ROADMAP §11). Damit bleibt das
Modell metergenau und der Rückweg in echte LV95-Koordinaten (Export, weitere
swisstopo-Abfragen) ist `+ shift`.
### B.4 Parzelle + Orthofoto
- **Parzelle:** `identify(...cadastralwebmap..., returnGeometry=true, geometryFormat=geojson)`
→ Polygon (LV95) → `−shift` → als geschlossene Polylinie auf Ebene **`01 Vermessung`**.
Attribute `number`/`egris_egrid` am Objekt/Projekt speichern.
- **Orthofoto:** WMTS `swissimage`-Kacheln über die Modell-Bbox stitchen → eine Textur,
als Material auf das Gelände-Mesh **oder** auf eine georeferenzierte Plane (DOSSIER:
`add_ortho_plane`, mit UV-Shift gegen Tile-Nähte). Für 3D ist `3857` einfacher, für
exakte 2D-Lage `2056`.
### B.5 OSM-Overpass als weltweiter Fallback (Phase 4)
Ausserhalb der Schweiz (oder wenn nur 2D-Footprints/Straßen reichen): DOSSIER hat einen
**Overpass-Importer** (`https://overpass-api.de/api/interpreter`, POST) mit 7 Kategorien
(Straßen/Gebäude/Wasser/Wasserläufe/Grün/Wege). Liefert OSM-Ways → Polylinien. Im
Browser identisch nutzbar (`fetch` POST). Overpass koordiniert in WGS84 → mit
`proj4`→LV95→`−shift`. Hinweis: Overpass-CORS ist beim Haupt-Server meist offen, kann
aber je nach Mirror variieren; ggf. anderen Mirror wählen.
[Overpass API](https://overpass-api.de/).
### B.6 Was sich von DOSSIER **nicht** 1:1 portieren lässt
- **Rhino-`_-Import`** für DXF/DWG/OBJ und **`_-MeshPatch`/Delaunay**-Commands gibt es im
Browser nicht → ersetzen durch JS-Parser/Algorithmen (OBJ: `three`-`OBJLoader`;
Delaunay: `delaunator`; Contours: `d3-contour`).
- **Filesystem-Cache neben der `.3dm`** → Browser: **IndexedDB**-Cache (Cache-API für
Kacheln). Passt zur ROADMAP-Phase 5 (IndexedDB-Persistenz).
- IFC-Import von swissBUILDINGS3D 3.0 → über **web-ifc** (ohnehin im Stack, Phase 4).
---
## Teil C — SIA 416 (Flächen/Volumen) & SIA 421 + DOSSIER-Logik
### C.1 SIA 416 — Flächen- und Volumenhierarchie
**SIA 416:2003** „Flächen und Volumen von Gebäuden" ist die in der CH gültige Norm und
Berechnungsbasis für Kostenplanung/Flächennachweise. Sie kennt vier Bereiche:
**GSF** (Grundstück), **GF** (Geschossflächen), **AGF** (Aussengeschossflächen), **GV**
(Volumen). Maßgeblich ist die effektive Geometrie (keine fiktiven Zuschläge mehr).
Quellen: [SIA 416 Übersicht (siworks/DBV)](https://diebauherrenvertretung.ch/sia146/) ·
[SIA-Shop 416/2003](https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product) ·
[Flächenkennzahlen SIA 416 (Ginesta, PDF)](https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf).
**Hierarchie & Formeln (verifiziert):**
```
GSF Grundstücksfläche
GF Geschossfläche = KF + NGF
├─ KF Konstruktionsfläche (Wände/Stützen; tragend KFT + nicht tragend KFN)
└─ NGF Nettogeschossfläche = NF + VF + FF
├─ NF Nutzfläche = HNF + NNF
│ ├─ HNF Hauptnutzfläche (zweckbestimmte Hauptnutzung: Wohnen, Büro …)
│ └─ NNF Nebennutzfläche (Lager, Bad/WC, Abstell-, Nebenräume)
├─ VF Verkehrsfläche (Erschließung: Flure, Treppen, Lifte)
└─ FF Funktionsfläche (Gebäudetechnik: Heizung, Lüftung, Technik)
AGF Aussengeschossfläche (Balkone, Terrassen, gedeckte Aussenflächen)
GV Gebäudevolumen [m³]
```
| Abk. | Deutsch | Inhalt (Kurz) |
|---|---|---|
| GSF | Grundstücksfläche | Parzellenfläche (aus Kataster, Teil A.6) |
| GF | Geschossfläche | allseits umschlossene + überdeckte Grundrissflächen, geschossweise |
| KF | Konstruktionsfläche | Bauteile (Wände/Stützen), nicht begehbar; KFT tragend / KFN nicht tragend |
| NGF | Nettogeschossfläche | begehbare Fläche innerhalb der Umschließung = NF+VF+FF |
| NF | Nutzfläche | tatsächlich nutzbar = HNF+NNF |
| HNF | Hauptnutzfläche | zweckbestimmte Hauptnutzung |
| NNF | Nebennutzfläche | dienende Nebenräume (Lager, Bad, WC) |
| VF | Verkehrsfläche | horizontale/vertikale Erschließung |
| FF | Funktionsfläche | Gebäudetechnik |
| AGF | Aussengeschossfläche | Balkone/Terrassen u.ä. |
| GV | Gebäudevolumen | umbauter Raum [m³] |
> **Versionshinweis:** Es kursiert eine Revision **SIA 416:2017** mit präzisierten
> Begriffen, in der Praxis wird aber breit weiter **416:2003** referenziert. Für unser
> Tool reicht die Klassifikation **HNF/NNF/VF/FF/GF/AGF + Bilanz**; die genaue Auflage
> nur als Label dokumentieren. Verbindliche Definitionen stehen im kostenpflichtigen
> Normtext (SIA-Shop) — die hier zitierten freien Quellen stimmen in der Struktur überein.
### C.2 SIA 421 — Flächengliederung für Bewirtschaftung/Vermietung
**SIA 421:2006** „Flächengliederung und Mengenangaben" (Korrigenda C1:2014) baut auf der
SIA-416-Systematik auf und **gliedert Flächen für Immobilien-Bewirtschaftung und
Vermietung** (Mietflächen, Nutzungseinheiten, Zuordnung von VF/FF zu Mietern). Relevant,
sobald wir **Mietflächen-/Bewirtschaftungs-Auswertungen** wollen (über den reinen
Architektur-Nachweis hinaus). Für den ersten Wurf **nicht zwingend** — SIA 416 reicht
für Flächennachweis und Raumschema. SIA 421 ist die natürliche Erweiterung, wenn
Property-Management-Features kommen. Quellen:
[SIA 421:2006 (PDF Inhalt)](https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang) ·
[Korrigenda C1:2014 (PDF)](https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf).
### C.3 Wie DOSSIER SIA macht (Vorlage für den Port)
Quellcode: `rhino/elemente.py` (Räume) + `rhino/elemente_uebersicht.py` (Bilanz/CSV).
Kernpunkte, die wir 1:1 übernehmen:
**Datenmodell pro Raum.** Ein Raum ist eine **geschlossene Outline-Curve** + ein
**Text-Stempel**. Klassifikation über das Feld `dossier_raum_sia` mit Werten
`{"", hnf, nnf, vf, ff, gf, agf}` (`_RAUM_SIA_KINDS`). Weitere Felder: `name`, `nummer`,
`funktion` (`wohnen|schlafen|bad|kueche|essen|flur|…`), `personen` (für
Personenbelegung/Brandschutz), Rundung, Stempel-Layout.
**Flächen-/Umfangsberechnung** (`_raum_amp`): Fläche aus `AreaMassProperties` (≙ in JS
**Shoelace-Formel** über das Polygon), Umfang = Kurvenlänge, plus Zentroid für den
Stempel. Im Browser: Polygon-Fläche selbst rechnen (Shoelace), kein Mesh nötig — exakt
und schnell. Rundungsstufen (`_format_area`): `exakt|0.01|0.1|0.5|1` (z.B. `0.5` =
`round(a*2)/2`).
**SIA-Bilanz** (`compute_sia_bilanz`, `scope = total | geschoss:<id>`): summiert
Raumflächen je Klasse, dann:
```
NF = HNF + NNF
NGF = NF + VF + FF (GF/AGF separat aggregiert, zählen nicht in NGF)
```
(genau die Formeln aus C.1). Ergebnis je Geschoss + Total.
**Farb-/Darstellungs-Konvention** (`_SIA_COLORS_HEX`, Pastell): HNF rot `#e8a8a8`,
NNF orange `#e8c498`, VF gelb `#e8d878`, FF hellblau `#a8c8e0`, GF grau `#d0d0d0`,
AGF hellgrün `#c0d8c0`. Umgesetzt als **regelbasierte Overrides** (`_build_sia_preset_rules`,
Preset „SIA-Raeume"): Bedingung `user_string == code` → Outline-Farbe + Solid-Hatch.
→ Passt 1:1 zur geplanten **Overrides-Engine** (ROADMAP §2c/§11).
**Export** (`_cmd_export_raeume`, `_export_bilanz`): CSV, **Semikolon + UTF-8-BOM**
(CH/DE-Excel), Dezimal-Komma. Raumliste: Nummer; Name; Geschoss; Funktion; SIA; Fläche;
Fläche gerundet; Umfang. Bilanz: eine Spalte je Geschoss + Total, Zeilen je Kategorie.
→ Im Browser: Blob + Download (kein SaveFileDialog), gleiche CSV-Struktur. Optional
direkt `.xlsx` via `sheetjs`/`exceljs`.
**Layer-Routing:** GF→`61_GF`, AGF→`62_AGF`, Rest→`60_RAEUME` (`_layer_path_for_raum_sia`)
— damit Geschossflächen-Outlines getrennt schalt-/exportierbar sind. Übersetzt sich auf
unsere Ebenen-Codes (`60 Räume`).
**Was wir im Port besser/anders machen:**
- Fläche per **Shoelace** statt Rhino-Mass-Props (0 Deps).
- Bilanz **reaktiv** aus dem semantischen Modell (Zustand-Store) statt Doc-Scan.
- **Space = Slab-/Raum-Polygon mit `siaClass`** im Datenmodell (ROADMAP:
`Space { boundary, name }`), Bilanz als abgeleitete Sicht.
---
## Teil D — Umsetzungsplan (Endpunkte · Libs · Phase)
Reihenfolge orientiert sich an der ROADMAP (SIA = Phase 2, Geo = Phase 4) und an „größter
Nutzen zuerst, geringste Abhängigkeit zuerst".
### Phase 2 — SIA-Räume (kein Netz, reine Logik) ⭐
**Endpunkte:** keine. **Libs:** keine (Shoelace selbst), optional `exceljs`/`sheetjs` für
.xlsx.
1. `Space`-Modell: `{ boundary[], geschossId, name, nummer, funktion, siaClass∈{hnf,nnf,vf,ff,gf,agf}, personen }`.
2. `computeArea` (Shoelace) + Umfang; Rundungsstufen (`exakt|0.01|0.1|0.5|1`) wie DOSSIER `_format_area`.
3. `computeSiaBilanz(scope)` → `{hnf,nnf,nf,vf,ff,ngf,gf,agf,count,personen}` mit
`nf=hnf+nnf`, `ngf=nf+vf+ff`.
4. SIA-Farbpalette + Stil-Override (Outline-Farbe/Solid-Fill) in der Overrides-Engine.
5. Raumstempel-Renderer (Felder-Layout) + **CSV-Export** (Semikolon, UTF-8-BOM, Komma)
für Raumliste **und** Bilanz.
*Ergebnis:* SIA-416-Flächennachweis + Raumschema, Excel-kompatibel — vor jeder Geo-Arbeit nutzbar.
### Phase 4a — Geo-Grundlage: Koordinaten + Standortabfrage
**Endpunkte:** `SearchServer` (Geocoding), `height` (Punkt-Z), `identify`
(Parzelle, `cadastralwebmap-farbe`). **Libs:** `proj4@2.20.9` (+ `@types/proj4`).
1. `proj4`-EPSG:2056-Def + Helfer `lv95↔wgs84`, `bboxLv95→bboxWgs84` (DOSSIER-Logik).
2. **Origin-Shift**-Mechanik + Persistenz am Projekt (`shift = bbox-Center`), Auto-Zoom.
3. Adresssuche → Zentrum; „Gelände-Höhe holen" (height); „Parzelle holen" → Polygon auf
Ebene `01 Vermessung` (+ EGRID/Nummer am Projekt).
*Ergebnis:* Projekt ist georeferenziert; Parzelle + Adresse + Geländehöhe vorhanden.
### Phase 4b — Gelände-Mesh + Orthofoto
**Endpunkte:** STAC `swissalti3d` (COG `.tif`, 2056) + `profile.json`; WMTS `swissimage`.
**Libs:** `geotiff@3.0.5`, `three@0.185.0`, optional `fflate` (XYZ-Fallback),
`d3-contour`/`marchingsquares` (Höhenlinien), `delaunator` (TIN).
1. STAC-Query (bbox) → COG-Tiles; `geotiff.js` Range-Read → Grid; Tiles mergen.
2. Grid → `THREE.BufferGeometry` (DOSSIER `mesh_from_grid`/`merge_grids`); Sub-Sampling
auf globalem LV95-Raster; Normalen.
3. Optional: Höhenlinien (2D-Plan), TIN, geschlossenes Volumen (Schnitt-Füllung).
4. WMTS-`swissimage`-Kacheln → Textur auf Mesh/Plane (DOSSIER `add_ortho_plane`).
5. Geländeschnitt im 2D-Plan via `profile.json` entlang der Schnittlinie.
*Ergebnis:* echtes Gelände mit Orthofoto unter dem Gebäude; Geländeschnitte.
### Phase 4c — Nachbargebäude + weltweiter Fallback
**Endpunkte:** 3D-Tiles `ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` (Anzeige)
**oder** STAC `swissbuildings3d_2` `.obj/.ifc` (Import); OSM `overpass-api.de`.
**Libs:** `@loaders.gl/3d-tiles@4.4.3` **oder** `3DTilesRendererJS`; `three`-`OBJLoader`;
`web-ifc` (für 3.0-IFC); `proj4`.
1. Kontext-Anzeige: 3D-Tiles in den Three-Scenegraph streamen (kein Download).
2. Bedarf an echter Geometrie (Verschattung/Abstand): STAC-OBJ-Tile → Mesh-Import → `−shift`.
3. Ausserhalb CH / nur 2D: Overpass-POST → Ways → Polylinien (Ebene `70 OSM`).
*Ergebnis:* Nachbarschaftskontext (CH 3D, weltweit OSM).
### Querschnitt (alle Geo-Phasen)
- **Caching:** IndexedDB für STAC-Antworten + COG-Bytes + Kacheln (ersetzt DOSSIERs
Disk-Cache); Cache-Schlüssel = Tile-ID/URL.
- **Defensive HTTP:** Timeouts, Retry/Backoff, 402/429 abfangen, Größen-Limit pro Tile
(DOSSIER: 200-MB-Guard).
- **Attribution:** „© swisstopo" sichtbar einblenden, „© OpenStreetMap-Mitwirkende" bei OSM.
- **Worker:** GeoTIFF-Parsing + Mesh-Bau im Web-Worker (Comlink, ROADMAP-Stack), UI bleibt flüssig.
### Empfohlener Library-Satz (npm, aktuell)
`proj4@2.20.9` · `geotiff@3.0.5` · `three@0.185.0` · `fflate` (XYZ) ·
`@loaders.gl/3d-tiles@4.4.3` *oder* `3DTilesRendererJS` · `delaunator` ·
`d3-contour` · `web-ifc` (im Stack) · `exceljs`/`sheetjs` (optional, .xlsx).
---
## Quellen
- GeoAdmin REST (height/profile/identify/find/search): https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html
- GeoAdmin Tech-Docs (Hub): https://docs.geo.admin.ch/
- Identify Features: https://docs.geo.admin.ch/access-data/identify-features.html
- Search: https://docs.geo.admin.ch/access-data/search.html
- WMTS: https://docs.geo.admin.ch/visualize-data/wmts.html · https://wmts.geo.admin.ch/
- WMS: https://docs.geo.admin.ch/visualize-data/wms.html
- 3D-Tiles: https://docs.geo.admin.ch/visualize-data/3d-tiles.html
- swissALTI3D: https://www.swisstopo.admin.ch/en/height-model-swissalti3d
- Switzerland in 3D / swissBUILDINGS3D: https://www.swisstopo.admin.ch/en/switzerland-in-3d
- Terms of use (FSDI / OGD): https://www.geo.admin.ch/en/general-terms-of-use-fsdi
- REFRAME Web-API: https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web
- EPSG:2056 Definition: https://epsg.io/2056
- proj4js: https://github.com/proj4js/proj4js
- geotiff.js: https://github.com/geotiffjs/geotiff.js/
- Three.js-Terrain aus GeoTIFF: https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/
- WMTS EPSG:2056 Beispiel: https://codepen.io/geoadmin/pen/GZKEam
- Overpass API: https://overpass-api.de/
- SIA 416 (Übersicht): https://diebauherrenvertretung.ch/sia146/ · https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product
- SIA 416 Flächenkennzahlen (PDF): https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf
- SIA 421:2006 (PDF) + Korrigenda C1:2014: https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang · https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf
- DOSSIER-Quellcode (Vorlage): `rhino/swisstopo.py`, `rhino/osm.py`, `rhino/elemente.py` (Räume/SIA), `rhino/elemente_uebersicht.py` (Bilanz/CSV)
+197
View File
@@ -0,0 +1,197 @@
# Technologie-Auswahl: Browser-basiertes BIM-Tool (DOSSIER-Port)
> **Kontext:** Standalone, browser-basiertes BIM-Werkzeug (React + TypeScript + Three.js) als Port des DOSSIER Rhino-Plugins. Keine Server-Abhaengigkeit gewuenscht (alles client-side). Recherchestand: Juni 2026.
>
> **Leitprinzip:** Wo immer moeglich auf einem echten B-Rep-Geometriekernel (OCCT) aufbauen, weil ein BIM-Werkzeug exakte 2D-Ableitungen (Schnitte, verdeckte Kanten, Bemassung) braucht — das ist mit reinen Dreiecksnetzen nicht sauber loesbar. Mesh-Booleans (Manifold) als schnelle Ergaenzung fuer Importgeometrie und Vorschau.
---
## 1. Geometriekernel im Browser (Solids + Booleans)
Das ist das schwierigste und zugleich wichtigste Problem. Drei ernsthafte Optionen, die alle im Browser (WASM) laufen.
### Optionen
**A) opencascade.js (OCCT als WASM)**
Port des vollstaendigen OpenCASCADE-Kernels (OCCT) nach WebAssembly via Emscripten. Voller B-Rep-Kernel: NURBS-Flaechen, exakte boolesche Operationen, Fillets/Chamfers, STEP/IGES-Import/-Export, Meshing. TypeScript-Bindings vorhanden. Die neueren Versionen (V3-Linie) zielen explizit auf moderne Bundler.
- Repo: <https://github.com/donalffons/opencascade.js/>
- Doku: <https://opencascade-js.vercel.app/>
- npm: <https://www.npmjs.com/package/opencascade.js>
- **Lizenz:** OCCT steht unter **LGPL-2.1 mit OCCT-Exception** (seit 6.7.0). Kommerzielle Nutzung ohne Lizenzgebuehren/Royalties erlaubt, sofern man (a) sichtbar darauf hinweist, dass die Software OCCT nutzt, und (b) eine Kopie der OCCT-Lizenz mitliefert. Die Exception entschaerft das statische-Linking-Problem fuer Header/Templates. Quellen: <https://dev.opencascade.org/resources/licensing>, <https://spdx.org/licenses/OCCT-exception-1.0.html>
- **Trade-offs:** Sehr grosse WASM-Binaries (zweistelliger MB-Bereich je nach Custom-Build), steile Lern- und API-Kurve (rohe OCCT-C++-API durchgereicht), Build-Pflege aufwendig.
**B) replicad (Abstraktion ueber opencascade.js)** — *empfohlene Basis*
replicad ist eine schlanke, idiomatische TypeScript-Schicht ueber opencascade.js. Es liefert genau die High-Level-Bausteine, die ein BIM-Tool braucht: Sketches/Blueprints, Extrude/Revolve/Loft, Booleans, Fillet/Chamfer — und entscheidend: **HLR-Projektionen** (`drawProjection`) und 2D-Drawings mit SVG-Export. Laeuft per Design im **Web Worker** und gibt Dreiecksnetze an den Main-Thread fuer Three.js zurueck.
- Doku/Library-Guide: <https://replicad.xyz/docs/use-as-a-library/>
- API: <https://replicad.xyz/docs/api/>
- **Lizenz:** **MIT** (eigene Schicht) — der OCCT/LGPL-Hinweis gilt weiterhin fuer das eingebettete WASM. Repo: <https://github.com/sgenoud/replicad>
- **Trade-offs:** Erbt OCCT-WASM-Groesse und -Robustheitsgrenzen; kleineres Team/Bus-Faktor als OCCT selbst. Man kann jederzeit „unter die Haube" auf rohes opencascade.js durchgreifen, wenn die Abstraktion nicht ausreicht.
**C) Manifold (manifold-3d, WASM)** — *empfohlen als schnelle Mesh-Ergaenzung*
Geometrie-Bibliothek fuer topologisch robuste **Dreiecksnetze**. Bietet den (laut Autor) ersten garantiert mannigfaltigen Mesh-Boolean-Algorithmus — extrem schnell und robust gegen Randfaelle. Stark parallelisiert.
- Repo: <https://github.com/elalish/manifold>
- npm: <https://www.npmjs.com/package/manifold-3d> (aktuell v3.5.x, Juni 2026)
- **Lizenz:** **Apache-2.0** (sehr permissiv, ideal). Bestaetigt: <https://github.com/elalish/manifold>
- **Trade-offs:** **Nur Meshes, kein B-Rep** — keine exakten NURBS-Flaechen, keine echten Fillets auf Krümmungen, kein STEP. Fuer ein BIM-Tool, das exakte Plaene/Schnitte ableiten will, alleine nicht ausreichend, aber unschlagbar fuer schnelle Booleans auf importierter Mesh-Geometrie und Live-Vorschau. **Wichtig:** unterstuetzt `slice(z)` und `project()` (siehe Abschnitt 3).
**D) three-bvh-csg (nur erwähnt, nicht empfohlen als Kernel)**
Sehr schnelle CSG direkt auf Three.js-BufferGeometry (auf three-mesh-bvh). >100x schneller als BSP-basierte Three.js-CSG-Libs. Aber: erklaert selbst, dass Resultate „aufgrund numerischer Praezision nicht garantiert 2-mannigfaltig" sind und verweist fuer CAD-Robustheit ausdruecklich auf Manifold.
- Repo: <https://github.com/gkjohnson/three-bvh-csg>
- Forum: <https://discourse.threejs.org/t/three-bvh-csg-a-library-for-performing-fast-csg-operations/42713>
- **Trade-offs:** Gut fuer Live-Vorschau/visuelles Schneiden, ungeeignet als verlaesslicher Modellierkernel.
### Empfehlung (Kernel)
**replicad (= opencascade.js) als primaerer B-Rep-Kernel im Web Worker; Manifold als schneller Mesh-Boolean-Pfad fuer Import-/Vorschaugeometrie.** Diese Zweiteilung deckt sowohl „exakte BIM-Geometrie + 2D-Ableitung" (OCCT) als auch „schnell + robust auf beliebigen Meshes" (Manifold) ab. three-bvh-csg nur, falls man interaktives Echtzeit-Schneiden visuell braucht.
---
## 2. 2D-Ableitung aus 3D: verdeckte Kanten (HLR) + Schnittgenerierung
Kernfrage des DOSSIER-Ports: aus 3D-Solids saubere 2D-Zeichnungen (sichtbare/verdeckte Kanten, Schnitte) erzeugen — im Browser.
### Optionen
**A) OCC HLRBRep via replicad `drawProjection` — empfohlen**
OCCT enthaelt zwei HLR-Algorithmen: `HLRBRep_Algo` (exakt, auf der echten B-Rep) und `HLRBRep_PolyAlgo` (auf polyederisierter Naeherung, schneller, aber polygonal). Quellen: <https://dev.opencascade.org/doc/refman/html/class_h_l_r_b_rep.html>, <https://dev.opencascade.org/doc/occt-7.7.0/refman/html/class_h_l_r_b_rep___poly_algo.html>
replicad macht genau das im Browser nutzbar: `drawProjection(shape, camera)` liefert ein Objekt mit **`{ visible, hidden }`** — getrennte sichtbare und verdeckte Kantenzuege, die man unterschiedlich stylen kann (z.B. verdeckt = gestrichelt). Konkretes Beispiel aus der Doku:
```js
const { drawProjection, ProjectionCamera } = replicad;
const camera = new ProjectionCamera(corner).lookAt(center);
const { visible, hidden } = drawProjection(shape, camera);
// visible/hidden sind Drawings -> .toSVG()
```
- Beispiel: <https://replicad.xyz/docs/examples/projections/>
- Verwandte API: `makeProjectedEdges`, `ProjectionCamera`, `Drawing.toSVG()` / `toSVGPaths()` (<https://replicad.xyz/docs/api/classes/Drawing/>)
- **Das ist der entscheidende Grund, replicad/OCCT zu nehmen:** exakte verdeckte-Kanten-Berechnung auf echtem B-Rep ist mit Mesh-Tools nicht serioes machbar.
- **Trade-offs:** HLR ist rechenintensiv (deshalb Worker + Caching pro Ansicht/Kamera); `HLRBRep_Algo` exakt aber langsam, `PolyAlgo` schneller aber genaehert.
**B) Schnitte (Sections) via OCCT**
Echte Schnitte ueber Schnitt mit einer Ebene/Halbraum (`BRepAlgoAPI_Section` bzw. Boolean mit Schnittkoerper) ergeben exakte Schnittkanten als B-Rep-Edges, die wiederum nach SVG/DXF gehen. In replicad ueber Booleans + Projektion abbildbar.
**C) Manifold `slice()` / `project()` — schnelle Mesh-Variante**
`Manifold.slice(z)` gibt den Querschnitt parallel zur X-Y-Ebene auf Hoehe `z` als `CrossSection` (2D-Polygone, intern Clipper2); `Manifold.project()` die projizierte Aussenkontur. Quellen: <https://manifoldcad.org/docs/jsapi/>, <https://manifoldcad.org/docs/html/classmanifold_1_1_cross_section.html>
- **Trade-offs:** liefert **keine** verdeckte/sichtbare-Kanten-Trennung und keine Innenkanten-Semantik wie HLR — nur die geometrische Schnitt-/Projektionskontur des Meshes. Gut fuer Plan-Schnittkonturen (siehe Abschnitt 3), ungenuegend fuer vollwertige Ansichts-Zeichnungen mit verdeckten Kanten.
### Empfehlung (2D-Ableitung)
**HLR und Ansichts-Zeichnungen ueber replicad `drawProjection` (OCC HLRBRep) im Worker, mit Caching pro Kamera/Ansicht. Echte Schnitte ueber OCCT-Section-Boolean. Manifold `slice()` als schneller Pfad nur fuer reine Schnittkonturen (z.B. Plan-Cut auf Mesh-Importen).**
---
## 3. Plan-Ansicht: Clipping bei `cutHeight`
Verhalten: horizontaler Schnitt auf einstellbarer Hoehe (Grundriss), darüber Abschneiden, Schnittflaechen markieren.
### Optionen
**A) Three.js Clipping Planes (`clippingPlanes` / `localClippingEnabled`)**
Three.js bietet globale (`renderer.clippingPlanes`) und material-lokale (`material.clippingPlanes`) Schnittebenen; `renderer.localClippingEnabled` ist standardmaessig aus (Null-Kosten, bis aktiviert). Quellen: <https://threejs.org/docs/#api/en/materials/Material.clippingPlanes>, <https://threejs.org/examples/webgl_clipping.html>
- **Pro:** GPU-seitig, dynamisch (Slider auf `cutHeight` = `plane.constant` aendern, kein Geometrie-Rebuild), sehr fluessig.
- **Contra:** Clipping schneidet nur visuell — es entstehen **offene** Querschnitte (keine Deckflaeche). Fuer „Schnittflaeche fuellen/markieren" braucht man entweder einen Stencil-Cap-Trick oder eine echte Schnittkontur (Abschnitt 2). `clipIntersection = true` kann Material-Reinitialisierung pro Frame und FPS-Einbrueche verursachen — moeglichst vermeiden. Quelle: <https://github.com/mrdoob/three.js/issues/18675>
**B) Object Culling / Sichtbarkeit nach Hoehe**
Elemente oberhalb `cutHeight` per Bounding-Box/Etagen-Metadaten ausblenden (`object.visible = false`).
- **Pro:** trivial, keine Shader-Kosten, nutzt BIM-Etagensemantik.
- **Contra:** grobkoernig (ganze Objekte, kein praeziser Schnitt mitten durch ein Bauteil).
**C) Echte Schnittgeometrie pro Etage (OCCT/Manifold) fuer den 2D-Plan**
Fuer die exportierbare 2D-Grundriss-Zeichnung den echten Schnitt auf `cutHeight` rechnen (OCCT-Section bzw. `Manifold.slice(z)`), Schnittflaechen schraffieren (Abschnitt 6).
### Empfehlung (Plan-Clipping)
**Hybrid:** Im 3D-Viewport **Three.js Clipping Planes** fuer das interaktive Abschneiden (Slider direkt auf `plane.constant`), kombiniert mit **Object-Culling** ueber Etagen-Metadaten fuer Grob-Performance. Schnittflaechen-Caps via Stencil-Technik. Fuer die **exportierbare** 2D-Grundriss-Zeichnung die **echte** Schnittkontur ueber OCCT/Manifold berechnen statt nur GPU-Clipping. `clipIntersection` meiden.
---
## 4. Import: DWG/DXF, IFC, STL, OBJ, XYZ-Punktwolken
### DXF / DWG
- **DXF lesen:** `dxf-parser` (gdsestimating) — robust, weit verbreitet, parst DXF-Strings zu JS-Objekten. Repo: <https://github.com/gdsestimating/dxf-parser>. Zum reinen 2D-Anzeigen: `dxf-viewer` (vagran). <https://github.com/vagran/dxf-viewer>
- **DWG lesen (binaer!):** `@mlightcad/libredwg-web` — LibreDWG nach WASM, parst **DWG** (und DXF) direkt im Browser/Node ohne Backend. Aktuell v3.x. Repo: <https://github.com/mlightcad/libredwg-web>, npm: <https://www.npmjs.com/package/@mlightcad/libredwg-web>
- **Achtung Qualitaet/Limits:** DWG-Parsing ist speicherintensiv (kann >2 GB RAM ziehen); libredwg-web erzwingt WASM-Heap-Limits, sehr grosse DWGs koennen scheitern. Im Default-Build sind DXF-Parsing und DWG-Schreiben deaktiviert, um die WASM-Groesse zu senken — DXF also lieber mit `dxf-parser` lesen. Quelle: <https://medium.com/@mlightcad/parsing-autocad-dwg-files-in-the-browser-without-relying-on-the-backend-9067c5d9abf0>
- **Lizenz:** LibreDWG ist **GPL-3.0** — das ist fuer ein proprietaeres Produkt heikel. Wenn das Produkt nicht GPL sein soll, DWG-Import entweder ueber einen separaten Out-of-Process-Konverter kapseln oder Nutzer bitten, vorab nach DXF zu exportieren. **DWG-Lizenzfrage vor Integration klaeren.**
- **Referenz-Implementierung:** `cad-viewer` (mlightcad) zeigt vollstaendigen browser-only DXF/DWG-Viewer/Editor. <https://github.com/mlightcad/cad-viewer>
### IFC (BIM-Kern)
- **web-ifc (ThatOpen/engine_web-ifc):** IFC lesen/schreiben in JS „at native speeds" via WASM. De-facto-Standard fuer Open-BIM im Browser. Repo: <https://github.com/ThatOpen/engine_web-ifc>, Doku: <https://thatopen.github.io/engine_web-ifc/docs/>. **Lizenz: MPL-2.0** (datei-basiertes Copyleft, fuer proprietaere Apps i.d.R. unkritisch). Quelle: <https://spdx.org/licenses/MPL-2.0.html>
- **ThatOpen Components + Fragments:** Hoehere Ebene — `components` (Tools für BIM-Apps), `fragments` (kompaktes Binaerformat auf Google FlatBuffers). Typisch: ~100 MB IFC -> ~10 MB Fragments, >10x schnelleres Laden; Konvertierung lauft worker-basiert. Doku: <https://docs.thatopen.com/Tutorials/Fragments/Fragments/IfcImporter/>, Repo: <https://github.com/ThatOpen/engine_fragment>. IfcImporter setzt web-ifc (>=0.0.72) voraus.
- **Empfehlung:** IFC einmal mit web-ifc parsen, in **Fragments** cachen, danach aus Fragments laden.
### STL / OBJ / XYZ-Punktwolken
- **Standard-Three.js-Loader** decken alles ab: `STLLoader` (ASCII+Binaer), `OBJLoader`, `XYZLoader` (XYZ/XYZRGB -> BufferGeometry), `PCDLoader`. Quellen: <https://threejs.org/docs/#examples/en/loaders/PCDLoader>, <https://deepwiki.com/mrdoob/three.js/4.2-model-format-loaders>. Three.js ist **MIT**.
- **Punktwolken-Performance:** Naive Darstellung skaliert nicht — ~17 Mio. Punkte ruckeln deutlich. Strategien: Downsampling, **LOD/Culling**, Hintergrund-/Streaming-Laden. Fuer sehr grosse Wolken Out-of-Core-Octree-Renderer (z.B. Potree-Ansatz) erwaegen statt eines einzelnen `Points`-Objekts. Quellen: <https://discourse.threejs.org/t/render-large-point-cloud-data-in-threejs/57331>, <https://discourse.threejs.org/t/performance-issues-rendering-large-ply-point-cloud-in-three-js-downsampling-and-background-loading/69135>
### Empfehlung (Import)
**IFC: web-ifc + Fragments (ThatOpen).** **DXF: dxf-parser.** **DWG: libredwg-web — aber GPL-Lizenz vorab klaeren / kapseln.** **STL/OBJ/XYZ/PCD: native Three.js-Loader, mit LOD/Downsampling fuer grosse Punktwolken (Potree-Pattern bei Bedarf).**
---
## 5. Vektor-Export: SVG -> PDF (Print) und DXF
### SVG -> PDF
- **svg2pdf.js (yWorks) + jsPDF — empfohlen.** Reine JS-Loesung, laeuft im Browser, erhaelt **echte Vektoren** (kein Rasterisieren via html2canvas!), was fuer druckfaehige Plaene entscheidend ist. Integriert sich ueber `doc.svg(element, ...)`. Kompatibel mit jsPDF v2/v3/v4. Repo: <https://github.com/yWorks/svg2pdf.js/>. Lizenz: svg2pdf.js **MIT**, jsPDF **MIT**.
- **Trade-off:** SVG-Feature-Abdeckung ist sehr gut, aber nicht 100% — exotische Filter/Pattern koennen abweichen; Schraffuren als explizite Linien (statt CSS-Filter) exportieren erhoeht Treffsicherheit.
- **Alternative/ergaenzend:** `pdf-lib` (MIT) fuer Seitenmontage, Mehrseitigkeit, Metadaten, Zusammenfuehren — kann mit jsPDF-Output kombiniert werden. (`html2canvas`+jsPDF bewusst **vermeiden**, da Raster statt Vektor.)
### DXF-Export
- **@tarikjabiri/dxf (dxfjs/writer) — empfohlen.** Moderner, in TypeScript geschriebener DXF-Generator fuer Node + Browser. Unterstuetzt u.a. **Blocks, Hatches, Insert, Image** — d.h. Schraffuren lassen sich als echte DXF-Hatches exportieren (wichtig fuer CAD-Weiterverarbeitung). npm: <https://www.npmjs.com/package/@tarikjabiri/dxf>, Doku: <https://dxf.vercel.app/>, Repo: <https://github.com/tarikjabiri/js-dxf>. Lizenz: MIT.
- Einfachere Alternative: `dxf-writer` (Vorlaeufer, weniger Features).
### Empfehlung (Export)
**SVG -> PDF: svg2pdf.js + jsPDF (Vektor, nicht Raster), optional pdf-lib fuer Seitenmontage. DXF-Export: @tarikjabiri/dxf mit echten Hatch-Entities.** Interner Zwischenschritt: 2D-Geometrie als SVG-Paths halten (replicad `Drawing.toSVGPaths()`), daraus sowohl PDF als auch DXF erzeugen.
---
## 6. Schraffuren / Muster in SVG/Canvas bei Massstab
### Optionen & Erkenntnisse
- **SVG `<pattern>` mit `patternUnits="userSpaceOnUse"`** ist der richtige Weg fuer massstaebliche Schraffuren: das Muster skaliert NICHT mit der Form (im Gegensatz zu `objectBoundingBox`), sondern bleibt im Weltkoordinaten-Raster — exakt, was man fuer „mm pro Linie im Plan" braucht. Dichte/Winkel ueber `patternTransform`. Quellen: <https://www.w3.org/TR/2015/WD-SVG2-20150915/pservers.html>, <https://www.codegenes.net/blog/simple-fill-pattern-in-svg-diagonal-hatching/>
- **Performance:** Pattern-Layer werden bei jedem Layout-/Zoom-Schritt neu gerechnet; `userSpaceOnUse` vermeidet teures Rescaling und ist hier zugleich der schnellere und der korrekte Weg. Quelle: <https://oreillymedia.github.io/Using_SVG/extras/ch19-performance.html>
- **SVG vs. Canvas:** Fuer technische Zeichnungen ist SVG qualitativ klar ueberlegen (Vektor, exporttauglich nach PDF/DXF). Canvas wird erst bei sehr vielen einfachen Elementen schneller. Quellen: <https://felt.com/blog/from-svg-to-canvas-part-1-making-felt-faster>, <https://www.yworks.com/blog/svg-canvas-webgl>
- **Skalierungs-Strategie:** Bei sehr dichten Schraffuren ueber grosse Flaechen kann die Linienzahl explodieren -> entweder als Pattern-Kachel rendern (eine Definition, vielfach referenziert, statt tausende Einzellinien) **oder** beim Export Schraffuren in echte Linien/Hatch-Entities aufloesen (DXF-Hatch, Abschnitt 5).
### Empfehlung (Schraffuren)
**SVG `<pattern>` mit `patternUnits="userSpaceOnUse"` als primaerer Renderpfad** (massstabskorrekt, exportierbar, performant durch Kachel-Referenzierung). Bei extrem grossen/dichten Flaechen Canvas-Overlay nur fuer die reine Bildschirm-Vorschau erwaegen; fuer Export immer SVG -> svg2pdf.js bzw. echte DXF-Hatches.
---
## 7. WebGPU vs. WebGL + Worker-Offloading der WASM-Geometrie
### Rendering: WebGPU vs. WebGL
- **Reifegrad:** Seit Three.js r171 (Sept. 2025) ist der **WebGPURenderer produktionsreif** mit `import * as THREE from 'three/webgpu'` und **automatischem WebGL2-Fallback** — kein eigener Fallback-Code noetig. Quelle: <https://www.utsubo.com/blog/webgpu-threejs-migration-guide>
- **Browser-Abdeckung:** Chrome/Edge 113 (Mai 2023), Safari 26.0 (Sept. 2025), Firefox 141 (Juli 2025). ~95% der Nutzer WebGPU-faehig, restliche ~5% bekommen WebGL2-Fallback. Quelle: <https://vr.org/articles/webgpu-baseline-2026-three-js-webxr-default>
- **Performance (nuanciert):** Bei draw-call-lastigen Szenen (viele Bauteile/Etagen) gewinnt WebGPU deutlich (bei ~10'000 Draw-Calls ~50 FPS WebGPU vs. ~30 FPS WebGL); bei wenigen grossen Meshes kann WebGL noch gleichauf oder schneller sein. Compute-Shader (Punktwolken, Culling) sind ein WebGPU-Alleinstellungsmerkmal. Quellen: <https://medium.com/@sudenurcevik/upgrading-performance-moving-from-webgl-to-webgpu-in-three-js-4356e84e4702>, <https://altersquare.io/three-js-vs-webgpu-2026-large-scale-construction-viewers/>
- **Vorsicht:** Es gibt weiterhin Szenarien, in denen WebGPU langsamer ist als WebGL — daher messen, nicht blind migrieren. Quelle: <https://github.com/mrdoob/three.js/issues/31055>
### Worker-Offloading der WASM-Geometrie
- **Pflicht, nicht optional:** OCCT/replicad-Berechnungen (Booleans, HLR, Section) gehoeren in einen **Web Worker**, sonst blockiert die UI. replicad ist genau dafuer gebaut (WASM im Worker, Mesh zurueck an den Main-Thread). Quelle: <https://replicad.xyz/docs/use-as-a-library/>
- **Muster:** Worker laedt das (grosse) WASM einmal; Kommunikation via Comlink o.ae.; Geometrie als Transferable (ArrayBuffer) zuruecksenden, um Kopierkosten zu sparen. Manifold (WASM) ebenso im Worker betreiben; Manifold ist intern stark parallelisiert.
### Empfehlung (Performance)
**Three.js `three/webgpu`-Renderer mit automatischem WebGL2-Fallback** (gratis Abwaertskompatibilitaet, Vorteil bei vielen Draw-Calls/Etagen). **Alle WASM-Geometrie (OCCT/replicad + Manifold) konsequent in Web Worker(n)**, Ergebnis als Transferables. WebGPU-Compute fuer Punktwolken-/Culling-Beschleunigung als spaeteres Optimierungs-Upside. Vor groesserer WebGPU-Optimierung mit der echten Szene benchmarken.
---
## Empfehlung (Zusammenfassung)
| Thema | Wahl | Warum |
|---|---|---|
| **Geometriekernel (Solids/Booleans)** | **replicad** (= opencascade.js/OCCT) im Worker; **Manifold** als Mesh-Boolean-Ergaenzung | Echter B-Rep-Kernel noetig fuer exakte BIM-Geometrie + 2D-Ableitung; Manifold (Apache-2.0) schnell+robust fuer Mesh-Importe/Vorschau |
| **2D-Ableitung (HLR/Schnitt)** | **replicad `drawProjection`** (OCC HLRBRep, liefert `{visible, hidden}`); OCCT-Section fuer Schnitte | Einziger seriöser Weg fuer verdeckte/sichtbare Kanten auf echtem B-Rep im Browser; Manifold `slice()` nur fuer reine Konturen |
| **Plan-Clipping (`cutHeight`)** | **Three.js Clipping Planes** + **Object-Culling** (Etagen); echte Schnittkontur (OCCT/Manifold `slice`) fuer Export | GPU-Clipping fluessig & dynamisch fuer Viewport; Culling fuer Grob-Performance; exakte Kontur nur fuer druckbaren Plan |
| **Import IFC** | **web-ifc + Fragments** (ThatOpen) | De-facto Open-BIM-Standard, native Speed, ~10x kleineres/schnelleres Fragments-Caching; MPL-2.0 |
| **Import DXF / DWG** | **dxf-parser** (DXF) / **libredwg-web** (DWG) | Bewaehrt & browser-only; **DWG = GPL-3.0 -> Lizenz vorab klaeren/kapseln**, RAM-Limits bei grossen Dateien |
| **Import STL/OBJ/XYZ/PCD** | **Native Three.js-Loader** + LOD/Downsampling | Out of the box (MIT); grosse Punktwolken brauchen Octree/Potree-Pattern |
| **SVG -> PDF (Print)** | **svg2pdf.js + jsPDF** (+ pdf-lib optional) | Echte Vektoren statt Raster -> druckfaehig; MIT-Lizenzen |
| **DXF-Export** | **@tarikjabiri/dxf** | TS, Browser-faehig, echte Hatch-/Block-Entities fuer CAD-Weiterverarbeitung; MIT |
| **Schraffuren/Muster** | **SVG `<pattern>` mit `userSpaceOnUse`** | Massstabskorrekt (skaliert nicht mit Form), exportierbar, performant via Kachel-Referenz |
| **Rendering** | **Three.js `three/webgpu`** mit WebGL2-Fallback | Produktionsreif seit r171, ~95% Abdeckung, Vorteil bei vielen Draw-Calls; gratis Fallback |
| **WASM-Offloading** | **Web Worker** fuer OCCT/replicad + Manifold, Transferables | UI bleibt reaktiv; replicad ist dafuer gebaut; spart Kopierkosten |
---
### Wichtigste Risiken / offene Punkte
1. **DWG-Lizenz (LibreDWG = GPL-3.0):** Vor Integration klaeren — sonst proprietaeres Produkt gefaehrdet. Option: separater Konvertierungs-Service oder DXF-Pflicht beim Import.
2. **OCCT-WASM-Groesse & Build-Pflege:** zweistellige MB; Custom-Build/Tree-Shaking und Lazy-Loading im Worker einplanen.
3. **HLR-Kosten:** `drawProjection` pro Ansicht cachen; ggf. `PolyAlgo` fuer schnelle Vorschau, `HLRBRep_Algo` fuer den finalen Plan.
4. **WebGPU nicht blind:** mit echter Szene benchmarken — es gibt Faelle, in denen WebGL noch fuehrt.
+590
View File
@@ -0,0 +1,590 @@
# UX/UI-Patterns moderner Browser-CAD/BIM-Tools — Research & Leitplanken
> Recherche für unser Browser-BIM (React + TS + Three.js, DOSSIER-Port, Wohnbau,
> CH/SIA-Kontext). Ziel: konkrete, **übernehmbare** Interaktions- und UI-Muster.
> Stand: 2026-06-29.
>
> Untersucht: **Arcol**, **Snaptrude**, **TestFit**, **Onshape** (Browser-CAD),
> **Vectorworks** (Resource/Navigation-Modell), **Figma** (Canvas-Interaktion,
> Inspector). Querschnitt: Snapping/Inferencing, Grip-Editing, perceived
> performance, Command-Palette, Onboarding.
>
> Jeder externe Claim ist mit Quelle verlinkt. Am Ende:
> **„Leitplanken für unsere UI"** — priorisierte Empfehlungen.
---
## 0. Kurzfazit (TL;DR)
Die ganze Klasse moderner Browser-CAD/BIM-Tools konvergiert auf ein **gemeinsames
Set von Mustern**, das wir fast 1:1 übernehmen sollten:
1. **Ein Modell, viele synchrone Sichten** (2D-Plan ⇄ 3D ⇄ Daten/Sheets), Änderung
in einer Sicht propagiert sofort in alle anderen. (Arcol, Snaptrude)
2. **Kontextuelle UI** statt voller Werkzeugleisten: Buttons/Felder erscheinen nur,
wenn die aktuelle Auswahl sie erlaubt. (Arcol, Onshape, Figma)
3. **Drei-Zonen-Layout**: links Navigator/Layer-Baum, Mitte Canvas + schwebende
Tool-Palette, rechts Inspector. (Figma, Vectorworks, Onshape)
4. **Aggressives Snapping/Inferencing** mit Live-Glyphen + Modifier zum
Unterdrücken. (Onshape) — für Maus-im-Browser unverzichtbar.
5. **Direkte Manipulation per Grips** statt Dialogen. (Figma, DOSSIER-Backlog)
6. **Perceived performance** über Skeletons, optimistic UI und gescopte
Ladezustände — im Browser-3D-Kontext ein Differenzierungs-Hebel.
7. **Command-Palette (Ctrl/Cmd-K)** als Discovery- und Speed-Layer.
8. **Onboarding via „learn by doing"** an einem mitgelieferten Sample-Projekt +
progressive disclosure.
Unsere bestehende Architektur (semantisches Modell als Single Source of Truth,
abgeleitete Sichten, Vectorworks-Terminologie) ist exakt der richtige Unterbau für
diese Muster — die meiste Arbeit liegt in der **UI-Schicht**, nicht im Datenmodell.
---
## 1. Gesamtlayout & Navigator-/Layer-Panels
### 1.1 Was die Tools machen
**Figma** strukturiert die Fläche in vier Zonen: eine Toolbar, **zwei Panels** und
einen scrollbaren Canvas. Links das **Navigation-Panel** mit Layern und Pages,
rechts das **Properties-Panel**; der Layer-Baum „enthält und organisiert alle
Elemente auf dem Canvas … und zeigt, wie Elemente verbunden sind"
([Figma: left sidebar](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar),
[Figma: Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)).
**Vectorworks** trennt sauber zwei Konzepte, die für uns 1:1 relevant sind:
- Die **Navigation Palette** gibt Zugriff auf *Classes, Design Layers, Sheet
Layers, Viewports, Saved Views, References* — jeweils als eigener Tab mit Liste.
Sichtbarkeit wird per Klick in einer **Visibility-Spalte** gesetzt; Doppelklick
**aktiviert** einen Layer/eine Class. Die Zeichenfläche bleibt nutzbar, während
die Palette offen ist
([VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)).
- Paletten sind **andockbar/ein-/ausblendbar pro Workspace**
([VW Palettes & Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)).
**Arcol** baut beim Modellieren einen **Model Tree** im Menü auf — pro
BIM-Komponente wächst der Baum mit
([AEC Magazine: Arcol BIM in browser](https://aecmag.com/bim/arcol-bim-cloud-browser/)).
**Onshape** zeigt links den **Feature-/Assembly-Baum** (parametrische Historie:
Sketches, Features, Mates) — die Bauhistorie ist die primäre Navigation
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
### 1.2 Übernahme für uns
Unser Dokumentmodell hat **zwei unabhängige Achsen** (siehe ROADMAP §2c):
**Zeichnungsebenen** (Geschosse + Schnitte/Ansichten) und **Ebenen**
(Grafik-Kategorien-Baum `00 Raster … 80 Plangrafik`). Das mappt fast wörtlich auf
das Vectorworks-Navigations-Modell:
- **Linke Sidebar, getabbt** wie die VW-Navigation-Palette:
- Tab **„Geschosse / Ansichten"** (= unsere Zeichnungsebenen): EG/1OG/…,
Schnitte, Ansichten. Mit **Visibility-Toggle**, **Lock**, und **Doppelklick =
aktiv setzen** (welches Geschoss editiert wird).
- Tab **„Ebenen"** (= Grafik-Kategorien-Baum, in *jedem* Geschoss gleich): Baum
mit Code, Name, Farb-Swatch, Linienstärke, Visibility, Hatch. Pro Ansicht
schaltbar (das ist genau VWs „Sichtbarkeit pro Viewport/Saved View").
- Tab **„BIM-Tree / Elemente"** (aus DOSSIER-Backlog §11: *Element-Übersicht
Geschoss→Kind→Element, Suche, Shift-Klick = Zoom*). Das ist unser Pendant zu
Arcols Model Tree + Onshapes Feature-Baum.
- **Visibility-Spalte als Erstklass-Interaktion** (VW-Muster): Auge-Icon je Zeile,
Klick togglet sofort, kein Dialog. (Wir haben bereits `EyeIcon.tsx` — das ist die
Keimzelle.)
- **Wichtig:** Geschoss wird **im 3D *oder* Plan** betrachtet, *keine* getrennten
Daten — der View-Umschalter (siehe §2) gehört in die Geschoss-Auswahl, nicht in
separate Dokumente.
> **Anti-Pattern vermeiden:** Vectorworks selbst leidet unter
> **Paletten-Wildwuchs** (viele frei schwebende Fenster). Für ein fokussiertes
> Wohnbau-Tool: **feste 3-Zonen-Shell** (links Navigator, rechts Inspector), nicht
> N frei schwebende Palettenfenster. Figmas Striktheit schlägt VWs Flexibilität für
> unsere Zielgruppe (Architekt:innen, die schnell ein EFH zeichnen wollen).
---
## 2. 3D ⇄ Plan-View-Umschaltung (das Herzstück)
### 2.1 Was die Tools machen
- **Snaptrude:** Nutzer arbeiten **in 2D *und* 3D gleichzeitig**; Änderung in einer
Sicht spiegelt sich automatisch in der anderen. Objekte sind **nach Geschossen
klassifiziert**, was es erlaubt, „3D-Geometrie zu zeichnen, während man in einer
2D-Grundriss-Ansicht arbeitet". Push-an-einer-Fläche „fühlt sich an wie eine
Linie ziehen", berechnet aber sofort Flächen/BIM-Daten neu
([ArchDaily: Snaptrude](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work),
[Snaptrude](https://www.snaptrude.com/)).
- **Arcol:** „Every view is 3D in Arcol" — und **Boards** (Präsentations-Layouts)
sind **live-synced**: ändert sich das Gebäude, aktualisieren sich die Layouts
automatisch (kein statischer PDF-Export)
([AEC Magazine: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
- **TestFit:** explizites **Expand/Collapse von Optionen** — man klappt Varianten
auf zum Vergleichen und wieder zu, um sich aufs Detail-Editieren im Canvas zu
konzentrieren („smoother flow between setting up a site, reviewing options, and
refining a design")
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
### 2.2 Übernahme für uns
Das ist **genau unsere Kern-Architektur** (ROADMAP §2c: „Ansichtstyp = Kamera-
Projektion + optionaler Schnitt"). Konkrete UI-Muster:
- **View-Switcher als segmented control** direkt am Canvas (oben links oder
oben mittig): `3D | Grundriss | Schnitt | Ansicht`. Plan-View = orthogonale
Top-Kamera + Clipping-Ebene auf `okff + schnitthöhe` (steht bereits so im Modell).
- **Kein** Moduswechsel der *Daten*, nur der **Kamera + Schnitt** — visuell als
weicher Übergang (Kamera-Animation) kommunizieren, damit Nutzer Orientierung
behalten (Snaptrude/Arcol-Gefühl: „dieselbe Sache aus anderem Winkel").
- **Optional, stark differenzierend:** **Split-View** (3D links, Plan rechts) wie
Snaptrudes „2D + 3D simultan". Da unsere Sichten ohnehin reaktiv aus *einem*
Modell abgeleitet werden (Zustand-Store → SVG-Plan + Three-Scene), ist Split-View
technisch billig und ein Wow-Moment im Onboarding.
- **Kamera-Presets** (aus DOSSIER-Backlog): Kardinal N/O/S/W, Iso-Oktanten,
**Norden-Rotation** (CH/Swisstopo-Georeferenz) — als kleine Würfel-/Kompass-Gizmo
oben rechts im 3D (ViewCube-Muster, bekannt aus Onshape/CAD allgemein
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm))).
- **Drawing Layers (Schnitte/Ansichten) sind „Saved Views"**: Auswahl in der linken
Sidebar = Kamera + Schnitt + Maßstab + Layer-Kombination springt an (VW „Saved
Views" / DOSSIER „Ausschnitte"). Das ersetzt Ordner-Wildwuchs bei 50+ Ansichten.
---
## 3. Zeichnen & Editieren: Snapping, Inferencing, Grips, Tool-Paletten
### 3.1 Snapping / Inferencing — das wichtigste Detail im Browser
**Onshape** ist hier die Referenz (sehr konkret dokumentiert,
[Onshape Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)):
- **Inference-Typen:** horizontal, vertikal, **midpoint**, parallel, coincident,
Ausrichtung zum Origin/zu anderen Entities, Tangente, Perpendikular.
- **Visuelles Feedback:**
- **gelbe Highlights** auf Vertices/Mittelpunkten beim Hovern,
- **orange gestrichelte Linie** zeigt eine vorgeschlagene H/V-Ausrichtung,
- **orange Highlight** verwandter Geometrie beim Ziehen (relationales Feedback).
- **Steuerung:** Linksklick **akzeptiert** die vorgeschlagene Bedingung; **Shift
gedrückt halten unterdrückt** Inferencing temporär (loslassen = wieder an).
- **„Wake-up"-Inferences:** kurzes Verweilen über einer Geometrie „weckt" deren
Bezugslinien (z. B. erst Mittelpunkt antippen, dann woanders zeichnen → bekommt
Ausrichtung zu diesem Mittelpunkt).
- **Post-hoc:** vorhandene Geometrie ziehen triggert erneut Inferencing (Center
eines Kreises vertikal zum Origin ziehen → fügt automatisch vertikale Constraint).
**Constraint-Sichtbarkeit:** Constraint-Icons sind farbcodiert (blau =
externe/Referenz, weiß = intern) und ein-/ausblendbar
([Onshape Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
### 3.2 Übernahme für uns (Snapping)
Für ein Maus-bedientes Browser-Tool ist **gutes Snapping der Unterschied zwischen
„Spielzeug" und „Werkzeug".** Konkret:
- **Snap-Targets** (Wohnbau-relevant): Wand-Endpunkte/Achsen, Wand-Mittelpunkte,
Rechtwinklig/Parallel zu bestehender Wand, **Raster** (Achsraster `00 Raster`),
Öffnungs-Achsen, vorhandene 2D-Linien-Endpunkte, Schnittpunkte.
- **Feedback exakt wie Onshape übernehmen:** Snap-Glyph am Cursor (●
Endpunkt, △ Mitte, ⟂ rechtwinklig), **gestrichelte Hilfslinie** für
Achsen-Alignment, Hover-Highlight des Snap-Ziels.
- **Modifier:** **Shift unterdrückt Snapping** (Onshape-Konvention) — Nutzer
erwarten das bereits aus anderen Tools. Zusätzlich **Ortho-Modus** (z. B. Shift
für 0/45/90° beim Linienziehen — Figma-Konvention) sauber davon trennen oder per
Toggle.
- **Live-Maßeingabe beim Zeichnen** (CAD-Standard, auch Onshape): während des
Ziehens Länge/Winkel tippbar (Tab zwischen Feldern). Das ersetzt nachträgliches
Dimensionieren und ist für Architekt:innen Pflicht.
- Wir brauchen **kein** volles Constraint-Solver-System wie Onshape (mechanisches
parametrisches CAD). BIM-Wände sind achs-basiert; **Inferencing beim Setzen**
genügt, persistente geometrische Constraints sind Overkill für Wohnbau.
### 3.3 Grip-Editing / direkte Manipulation
- **Figma:** Auswahl zeigt Bounding-Box mit **Resize-Handles**; ziehen
manipuliert direkt; Smart-Guides/Maße erscheinen relativ zu Nachbarn beim Bewegen
([Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)).
- **Snaptrude:** Push/Pull an Flächen als primäre 3D-Edit-Geste
([ArchDaily](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)).
- **DOSSIER-Backlog (§11)** listet **Grip-Editing** (Wand-Endpunkte,
Schnitt-Symbole im Plan) explizit als Aufwand L, Phase 3–4 — und das **9-Punkt-
Objekt-Info** (lesen + verschieben/skalieren/rotieren direkt).
**Übernahme:** Grips sind der wichtigste „pro feel"-Hebel.
- **Wand-Endpunkt-Grips** im Plan *und* 3D, mit Snapping (s. o.) und Live-Maß.
- **Öffnungs-Grips** (Position entlang Wand, Breite) — Host-Beziehung bleibt erhalten.
- **Schnittlinien-Grips im Plan** (Schnittlinie ziehen → Schnitt-Ansicht
re-deriviert) — das ist Grip-Editing über die Sicht-Grenze hinweg, ein starkes
Differenzierungsmerkmal.
- Selektion → **bounding handles** (Figma-Muster) für 2D-Plangrafik (Linien,
Rechtecke, Text).
### 3.4 Tool-Palette & kontextuelle Werkzeuge
**Kontextuelle UI ist das durchgehende Muster:**
- **Arcol:** „buttons appear only when they can be used" — Loft/Sweep erscheinen bei
Auswahl zweier Sketches, Boolean bei zwei Extrusions
([AEC: Arcol sneak peek](https://aecmag.com/bim/arcol-a-sneak-peek/)).
- **Onshape:** Sketch-Toolbar erscheint beim Betreten des Sketch-Modus; Tools in
Gruppen (vertikale Trennlinien), Dropdown-Pfeile für Varianten; Dialoge mit
**blau hinterlegtem Feld**, das eine Auswahl im Graphics-Bereich verlangt
([Onshape sketch toolbar](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm),
[Onshape Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
- **Figma:** schmale Bottom-/Top-Toolbar mit den Kern-Tools; alles Weitere
kontextuell rechts.
**Übernahme:**
- **Schlanke Tool-Palette** (Figma-artig), gruppiert nach unserer Domäne:
*Wand · Tür/Fenster · Decke · Treppe · Dach · Raum* (BIM) und *Linie · Polylinie
· Rechteck · Kreis · Bogen · Text · Bemaßung* (2D auf `80 Plangrafik`).
- **Modus-bewusste Tools:** im Grundriss andere Defaults als im 3D; im
Schnitt/Ansicht nur Annotation/2D-Tools.
- **Contextual action bar bei Auswahl** (Arcol-Muster): selektiere eine Wand →
schwebende Mini-Toolbar „Tür einsetzen / Fenster / Wandtyp / verschneiden".
Selektiere zwei Wände → „verschneiden / verlängern".
- **Aktives Tool sticky + ESC bricht ab**, Leertaste = Pan, Scroll = Zoom
(Figma/CAD-Konventionen — Nutzer bringen Muskelgedächtnis mit).
- **LoD-bewusste Tool-/Stil-UI** (DOSSIER-Backlog: *zeigt nur passende Controls je
Geometrie-Typ*) — keine Füll-Optionen bei 3D-Auswahl. Deckt sich mit Figmas
„controls appear based on layer type".
---
## 4. Property-/Inspector-Panel
### 4.1 Was die Tools machen
**Figma — rechte Sidebar** (für uns das Vorbild,
[Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)):
- Tabs **Design / Prototype** (bei Edit), **Inspect / Properties** (bei View-only).
- Kategorien: **Alignment/Rotation/Position → Dimensions → Constraints/Layout →
Appearance (Fill, Stroke, Effects) → Export**.
- **Controls erscheinen je Layer-Typ** (kontextuell).
- **Dev-Mode/Inspect** liefert konkrete Werte + Abstände zwischen Objekten + Code.
**Arcol — rechtes Panel** zeigt **Gebäude-Metriken** kontextuell: Geschossfläche,
Anzahl Geschosse, GFZ/FAR, Unit-Count, Standortfläche, Kostenschätzung
([Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
**TestFit — ein zentrales Parameter-Panel:** „alle Schlüsselparameter an einem
Ort" (Unit-Counts, Parkplatz-Ziele, Gebäudegrößen-Limits) → sofortige Wirkung auf
generierte Optionen
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
**Onshape — Feature-Dialoge:** Erstellen/Editieren über Dialoge mit Pflicht-
Selektionsfeldern (blau)
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
### 4.2 Übernahme für uns
- **Rechter Inspector, kontextuell nach Element-Typ** (Figma-Muster):
- **Wand** → Wandtyp (→ WallType-Bibliothek), Referenzlage mid/left/right
(DOSSIER-Backlog), Höhe, Achs-Endpunkte (9-Punkt/Maße), Stil-Overrides.
- **Tür/Fenster** → Breite/Höhe, Brüstung, Schwenkbogen/Anschlag, Detailgrad,
Symbol.
- **Decke/Slab** → SlabType, UK/OK-Override, Aussparungen.
- **Raum** → SIA-416-Kategorie (HNF/NNF/VF/FF/GF/AGF), Fläche (read-only,
berechnet), Stempel-Felder.
- **2D-Element** → Linienstil (→ Line Manager), Hatch (→ Hatch Manager), Farbe.
- **Sektionen kollabierbar** (Figma) — Wohnbau-Inspector kann lang werden;
Default-Sektionen offen, Fortgeschrittenes (Overrides) zugeklappt
(= progressive disclosure, s. §7).
- **Mixed-value-Handling bei Mehrfachauswahl** (Figma): bei Mehrfachauswahl
abweichende Werte als „Mixed/—" zeigen, gemeinsames Editieren erlauben. Wichtig
z. B. „alle EG-Wände auf Wandtyp X".
- **Live-Metriken-Block** (Arcol-Muster) — selbst im Wohnbau wertvoll:
Bruttogeschossfläche, **SIA-416-Bilanz**, Raumzahl, Volumen. Im Inspector wenn
nichts selektiert ist = „Projekt-Übersicht" (vgl. Figmas Canvas-Level-Optionen
bei leerer Auswahl).
- **Read-only-Felder klar markieren** (berechnete Flächen/Volumen) vs. editierbar.
---
## 5. Resource-Manager (Bibliotheken)
### 5.1 Was Vectorworks macht — unser direktes Vorbild
Der **Resource Manager** ist „ein zentraler Ort für Assets" (Symbole, Linientypen,
Texturen, Materialien …)
([VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)):
- **Zwei-/Drei-Pane-Modell:** **File-Browser** (offene Dateien, Favoriten,
VW-Libraries, User-/Workgroup-Libraries) → **Resource-Viewer** (Ressourcen der
gewählten Datei) → optional **Preview/Metadaten**
([VW File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm),
[VW Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)).
- **Organisation mehrdimensional:** nach Quelle, **nach Typ** (Dropdown-Filter),
nach Ordnerstruktur.
- **Ansichten:** Thumbnails / List / Thumbnails-List; **Suchfeld** mit Filtern.
- **Resource Selector**: dieselbe Bibliothek erscheint **in Dialogen** und zeigt
dort nur **kontextuell passende** Ressourcen.
### 5.2 Übernahme für uns
Unsere ROADMAP §2d definiert bereits **Line Manager / Hatch Manager / Component
Manager**, mit Verweis-per-id-Architektur (Components → Hatches → LineStyles). Das
Vectorworks-Modell passt perfekt:
- **Ein gemeinsames Resource-Browser-Pattern** für alle Bibliotheken (Linienstile,
Schraffuren, Components/Baustoffe, später Wand-/Öffnungs-Stil-Kataloge,
Material-PBR, Raumstempel-Layouts). Eine wiederverwendbare React-Komponente,
parametrisiert nach Ressourcentyp.
- **Zwei Erscheinungsformen** (wie VW):
1. **Manager-Ansicht** (großes Panel/Modal) zum Anlegen/Editieren/Duplizieren.
2. **Inline-Resource-Selector** im Inspector — beim Setzen eines Wandtyps/Hatch
öffnet sich ein kompakter Picker mit Thumbnails, gefiltert auf den passenden
Typ. (Figma macht das analog mit „Styles/Variables".)
- **Thumbnails sind im CAD-Kontext kritisch**: Hatch-Vorschau, Component-
Schichtaufbau, Linienstil-Strich als gerenderte Mini-Previews.
- **Zentrale Änderung propagiert** (unsere id-Referenz-Architektur): Component-Farbe
ändern → alle Wände mit diesem Component aktualisieren live. Das ist Arcols
„single source of truth" auf Ressourcen-Ebene.
- **Cross-Projekt-Presets/Favoriten** (DOSSIER-Backlog, LocalStorage; VW-Favoriten):
„einmal speichern, überall nutzen".
---
## 6. Perceived Performance (gefühlte Geschwindigkeit)
Browser-3D + WASM-Geometrie (web-ifc, OpenCascade) + HLR-Projektion = **echte
Latenz** an mehreren Stellen. Gefühlte Performance ist hier ein
Differenzierungs-Hebel gegenüber schwerfälligem Revit/ArchiCAD.
### 6.1 Belegte Muster
- **Skeleton-Screens** lassen Apps **20–30 % schneller** wirken als Spinner bei
identischer realer Ladezeit
([LogRocket: skeleton screens](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/),
[UI Deploy](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)).
- **Indikator zur Situation passen:** Spinner für kurze Waits, Skeleton für
Content, Progress-Bar für messbare Operationen, **optimistic UI** für
„instant-feeling" Aktionen
([Onething: skeleton vs spinner](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
- **Optimistic UI**: UI sofort aktualisieren, Server-Bestätigung abwarten, nur bei
Fehler zurückrollen — ideal für häufige, risikoarme Aktionen
([Smart Interface Design Patterns](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)).
- **Verzögerung vor Indikator (100–200 ms):** schließt die Operation vorher ab,
gar kein Indikator → kein Flackern
([Onething](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
- **Gescopte Ladezustände** (React Suspense / Next loading.tsx): nur der betroffene
Bereich lädt, der Rest bleibt interaktiv; `aria-busy`, Live-Regions,
reduced-motion respektieren
([LogRocket](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)).
### 6.2 Übernahme für uns (konkret)
- **Optimistic Model-Edits:** Geometrie-Mutation (Wand ziehen, Tür setzen) **sofort**
im Zustand-Store + 3D anzeigen; **schwere Booleans/HLR im Web Worker** (Comlink,
bereits geplant) nachziehen. Wand erscheint sofort, die *exakte* verschnittene
Öffnung/Schnittlinie „schärft nach". UI bleibt flüssig.
- **Progressive Plan-Generierung:** SVG-Grundriss zuerst grob (Achsen/Linien),
Schraffuren/Symbole nachladen — Skeleton/„low-detail first" statt Spinner.
- **HLR-Schnitte (Risiko #4):** Worker + Caching (ROADMAP §6). UI: **Skeleton der
Schnitt-Ansicht** + „berechne verdeckte Kanten…" mit Progress, restliche App
bleibt nutzbar (gescopter Ladezustand).
- **Delay-then-show** für alle Worker-Tasks (150 ms-Schwelle), sonst Flicker beim
schnellen Editieren.
- **Three.js-Disziplin:** stabile 60 fps beim Orbit/Pan ist selbst „perceived
performance" — instanziertes Rendering, Frustum-Culling, LoD für ferne Geometrie
(deckt sich mit ROADMAP-Phase-7-Performance-Härtung). Lieber 60 fps bei grober
Geometrie als ruckelnde Präzision.
- **Auto-Save-Status klar, unaufdringlich** kommunizieren („Gespeichert"/„Speichern…"),
optimistic — nie blockierend (Figma-Muster).
---
## 7. Onboarding & Discoverability
### 7.1 Belegte Muster
- **Progressive Disclosure:** zuerst nur Essenzielles zeigen, Komplexität schrittweise
enthüllen — reduziert kognitive Last; drei Typen: step-by-step, conditional,
contextual
([IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure),
[UXPin](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)).
- **„Learn by doing" an Sample-Dokument:** Grammarly startet Nutzer mit einem
Beispiel-Dokument mit Fehlern; Hotspots/Tooltips führen durch Features
([Userpilot: onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)).
- **Stufenweises Aufdecken fortgeschrittener Features** (Asana: erst Projekt
anlegen, später Dependencies/Kanban/Gantt)
([Userpilot: progressive disclosure](https://userpilot.com/blog/progressive-disclosure-examples/)).
- **Command-Palette als Discovery-Layer:** durchsuchbare Befehlsliste hilft, Features
zu entdecken — „incredible effect on exploration and feature discoverability",
besonders für neue/seltene Nutzer
([Mobbin: command palette](https://mobbin.com/glossary/command-palette),
[Untitled UI: command menus](https://www.untitledui.com/components/command-menus)).
- **Arcol** wirbt explizit mit „low barrier to entry, gentle learning curve … clean,
intuitive, requires minimal training"
([AEC: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
### 7.2 Übernahme für uns
- **Mitgeliefertes Sample-Projekt** (wir haben bereits `sampleProject.ts`!) als
Onboarding-Bühne: ein kleines EFH, fertig modelliert. Nutzer **manipuliert echtes
Modell** statt leerem Canvas (Grammarly-Muster). Erste Geste: „zieh diese Wand"
→ sieht 3D + Plan live mitlaufen (unser Kern-Wow).
- **Progressive Disclosure im Inspector & Tool-Palette:** Default zeigt
Wohnbau-Essenz (Wand/Tür/Fenster/Decke/Raum). Fortgeschrittenes (Prioritäts-
Verschneidung, Overrides, Detailgrade, Section-Styles) **zugeklappt / hinter
„Erweitert"**. Das passt zu unserem radikalen Wohnbau-Fokus.
- **Contextual coachmarks** statt langem Tutorial: beim ersten Selektieren einer
Wand ein kleiner Tooltip „Endpunkt ziehen zum Verlängern, Doppelklick für Wandtyp".
- **Command-Palette (Ctrl/Cmd-K)** — siehe §8 — doppelt als Onboarding: alle
Werkzeuge/Befehle durchsuchbar = lebende Feature-Liste.
- **Tastatur-Kürzel sichtbar machen** (in Tooltips, in der Palette) — schult
beiläufig pro Workflows.
---
## 8. Command-Palette & Tastatur
### 8.1 Belegte Muster
- **Ctrl/Cmd-K** ist die De-facto-Konvention (Linear, Figma [Cmd-P], Notion,
Vercel, Raycast, Slack, Superhuman); VS Code nutzt Cmd-Shift-P
([Mobbin](https://mobbin.com/glossary/command-palette),
[Superhuman: command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)).
- Zwei Haupt-Use-Cases: **Navigation/Suche** und **Shortcuts/Quick Actions**
([Outdraw Academy: command palette](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)).
- Trigger kann **sichtbar** (Button/Suchleiste) oder nur per Shortcut sein —
für Discovery besser **auch sichtbar**
([Mobbin](https://mobbin.com/glossary/command-palette)).
### 8.2 Übernahme für uns
- **Cmd/Ctrl-K-Palette** für: Werkzeug aktivieren („Wand", „Tür"), Ansicht
springen („Grundriss EG", „Schnitt A-A"), Ressource öffnen („Hatch Manager"),
globale Aktionen („Norden rotieren", „PDF exportieren", „SIA-Bilanz").
- **Sichtbarer Trigger** (Such-/Befehlsfeld in der Top-Bar) für Entdeckung +
Shortcut für Speed.
- **Konsistente, dokumentierte Shortcuts** (Figma-Disziplin): W=Wand, T=Tür,
L=Linie, Space=Pan, Scroll=Zoom, Shift=Snap aus, Esc=Abbrechen, G=Grundriss-Toggle.
In Tooltips + Palette anzeigen.
---
## 9. Leitplanken für unsere UI (priorisierte Empfehlungen)
> Sortiert nach **Hebel × Aufwand**. „P#" = grobe Phasen-Zuordnung zur ROADMAP.
### A. Sofort / Fundament (Phase 0–1) — billig, prägt alles
1. **Feste 3-Zonen-Shell.** Links **Navigator** (Tabs: *Geschosse/Ansichten · Ebenen
· BIM-Tree*, je mit Visibility-Toggle wie VW Navigation Palette). Mitte **Canvas**
mit schwebender Tool-Palette + View-Switcher. Rechts **Inspector** (kontextuell).
*Keine* frei schwebenden Palettenfenster (Anti-VW-Wildwuchs).
2. **View-Switcher als segmented control** `3D | Grundriss | Schnitt | Ansicht`
direkt am Canvas; weiche Kamera-Übergänge; *eine* Datenquelle (kein Daten-
Moduswechsel). Nutzt unsere bestehende „abgeleitete Sichten"-Architektur.
3. **Visibility/Lock pro Zeile** als Erstklass-Interaktion (1 Klick, kein Dialog) —
`EyeIcon.tsx` ausbauen.
4. **Kontextueller Inspector**: Controls nur für den selektierten Element-Typ
(Figma/Arcol); berechnete Felder read-only markiert; Sektionen kollabierbar.
5. **Tastatur-Grundlagen + Konventionen festnageln**: Space=Pan, Scroll=Zoom,
Esc=Abbrechen, Shift=Snap aus. Früh festlegen → Muskelgedächtnis.
### B. Kern-„Pro-Feel" (Phase 1–2) — der eigentliche Wert
6. **Snapping/Inferencing nach Onshape-Vorbild**: Snap-Glyphen am Cursor,
gestrichelte Achsen-Hilfslinien, Hover-Highlight, **Shift = unterdrücken**,
Wake-up-Inferences. Targets: Wand-Enden/Achsen/Mitten, Raster, Rechtwinklig/
Parallel, Öffnungs-Achsen. **Höchste Priorität für „Werkzeug-Gefühl".**
7. **Live-Maßeingabe beim Zeichnen** (Länge/Winkel tippbar, Tab zwischen Feldern).
8. **Kontextuelle Action-Bar bei Auswahl** (Arcol): Wand selektiert → „Tür/Fenster
einsetzen / Wandtyp / verschneiden". Buttons erscheinen nur, wenn anwendbar.
9. **Schlanke domänen-gruppierte Tool-Palette** (BIM-Bauteile + 2D-Zeichnen),
modus-bewusst je Ansicht.
10. **Optimistic Edits + Worker-Nachzug**: Mutation sofort sichtbar, schwere
Booleans/HLR im Worker (Comlink); Delay-then-show (150 ms) statt Spinner-Flicker.
### C. Differenzierung (Phase 3) — hier gewinnen wir
11. **Grip-Editing** (Wand-Endpunkte, Öffnungs-Position/-Breite, **Schnittlinie im
Plan**) mit Snapping + Live-Maß, in 2D *und* 3D. (DOSSIER-Backlog, Aufwand L —
aber Kern-Differenzierer.)
12. **Saved Views / Ausschnitte** in der linken Sidebar = Kamera + Schnitt + Maßstab
+ Layer-Kombination per Klick (VW „Saved Views" / DOSSIER). Skaliert auf 50+
Ansichten.
13. **Wiederverwendbares Resource-Browser-Pattern** für Line/Hatch/Component-Manager:
Zwei-Pane (File-Browser → Viewer mit Thumbnails) als Manager *und* als inline
Picker im Inspector (VW-Modell). Zentrale Änderung propagiert (id-Referenzen).
14. **Perceived-performance-Politik festschreiben**: Skeletons für Plan-/Schnitt-
Generierung, gescopte Ladezustände (restliche App bleibt nutzbar),
reduced-motion/`aria-busy` respektieren.
15. **Split-View 3D|Plan** (Snaptrude-Muster) — billig dank reaktiver Sichten,
starker Wow-Effekt; auch fürs Onboarding.
### D. Adoption & Politur (Phase 1 fortlaufend → 7)
16. **Onboarding via Sample-Projekt** (`sampleProject.ts` als fertiges EFH);
„learn by doing", erste Geste zeigt 3D⇄Plan-Live-Sync. Progressive Disclosure:
Wohnbau-Essenz default, Fortgeschrittenes (Prioritäts-Verschneidung, Overrides,
Detailgrade) zugeklappt.
17. **Command-Palette (Cmd/Ctrl-K)** + sichtbarer Trigger: Werkzeuge/Ansichten/
Ressourcen/Aktionen durchsuchbar; doppelt als Discovery-Layer.
18. **Live-Metriken-Block** im Inspector (Arcol): BGF, **SIA-416-Bilanz**, Räume,
Volumen — bei leerer Auswahl als Projekt-Übersicht.
19. **CH-Spezifika UI-seitig vorsehen**: **Norden-Rotation**-Gizmo (ViewCube/Kompass),
SIA-Raumkategorien im Raum-Inspector, später Swisstopo-Import-Flow mit Auto-Zoom
+ Nullpunkt-Verschiebung.
### Übergreifende Designprinzipien (gelten immer)
- **Kontextualität vor Vollständigkeit** — zeige nur, was jetzt anwendbar ist
(Arcol/Onshape/Figma). Direkt verzahnt mit unserem „LoD-bewusste Stil-UI"-Backlog.
- **Direkte Manipulation vor Dialogen** — Grips/Drag/Inline-Edit schlägt
Properties-Dialog (Figma/Snaptrude/DOSSIER).
- **Eine Wahrheit, viele Sichten** — niemals Sicht-spezifische Daten; alles aus dem
semantischen Modell ableiten (deckt sich exakt mit unserem Architektur-Prinzip).
- **Konventionen respektieren** — Pan/Zoom/Snap/Esc/Cmd-K wie die etablierten Tools;
Nutzer bringen Muskelgedächtnis aus Figma/CAD mit.
- **Gefühlte > tatsächliche Geschwindigkeit** — optimistic + Skeletons + 60 fps;
im schweren Browser-3D-Geometrie-Kontext ein echter Wettbewerbsvorteil.
---
## Quellen
**Arcol**
- [Arcol unleashed – BIM 2.0 (AEC Magazine)](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)
- [Arcol – BIM in a browser (AEC Magazine)](https://aecmag.com/bim/arcol-bim-cloud-browser/)
- [Arcol: a sneak peek (AEC Magazine)](https://aecmag.com/bim/arcol-a-sneak-peek/)
- [The Arcol Manifesto](https://arcol.io/blog/the-arcol-manifesto)
**Snaptrude**
- [Snaptrude – browser-based BIM tool (ArchDaily)](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)
- [Snaptrude (offiziell)](https://www.snaptrude.com/)
**TestFit**
- [TestFit 5.19: A New Generative Design Workflow](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)
- [TestFit (offiziell)](https://www.testfit.io/)
**Onshape**
- [Onshape: User Interface Basics](https://cad.onshape.com/help/Content/ui-basics.htm)
- [Onshape: Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)
- [Onshape: Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)
- [Onshape: Sketch Basics](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm)
**Vectorworks**
- [VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)
- [VW Resource Manager: File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm)
- [VW Resource Manager: Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)
- [VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)
- [VW Palettes and Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)
**Figma**
- [Figma: right sidebar / layer properties](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)
- [Figma: left sidebar (layers & pages)](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar)
- [Figma for Everyone: The Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)
**Perceived Performance**
- [LogRocket: Skeleton loading screen design](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)
- [UI Deploy: Skeleton Screens vs. Spinners](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)
- [Onething: Skeleton Screens vs Loading Spinners](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)
- [Smart Interface Design Patterns: Loading & Progress UX](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)
**Command Palette**
- [Mobbin: Command Palette](https://mobbin.com/glossary/command-palette)
- [Untitled UI: Command menus (Cmd-K)](https://www.untitledui.com/components/command-menus)
- [Superhuman: How to build a remarkable command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)
- [Outdraw Academy: Command Palette UX pattern](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)
**Onboarding / Progressive Disclosure**
- [IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure)
- [UXPin: What Is Progressive Disclosure](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)
- [Userpilot: User onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)
- [Userpilot: Progressive disclosure examples](https://userpilot.com/blog/progressive-disclosure-examples/)
+124
View File
@@ -0,0 +1,124 @@
# Welle C — HLR-Spike: Feasibility-Report
> 2D-Vektor-Schnitte/-Ansichten aus dem 3D-Modell via Hidden-Line-Removal (HLR)
> mit opencascade.js (OCCT-WASM). Isolierter De-Risking-Spike, NICHT in die App
> verdrahtet. Einheiten: Meter.
## Ergebnis (kurz)
**HLR funktioniert in diesem Browser-Projekt.** opencascade.js initialisiert im
Vite-Build, und der HLR-Lauf liefert korrekte, getrennte sichtbare/verdeckte
2D-Kanten. Verifiziert im echten Browser (Chromium via Vite-Dev-Server) an einer
L-Wand + Bodenplatte, Front-Ansicht:
| Messwert | Wert |
|---|---|
| Sichtbare Kanten (sharp) | **9** |
| Verdeckte Kanten | **16** |
| Reine HLR-Rechenzeit | **~21 ms** |
| WASM-Größe (unkomprimiert) | **62.8 MB** (65 864 037 Bytes) |
| WASM-Größe (gzip) | **~19.6 MB** |
| WASM-Fetch (lokal, Dev) | ~113 ms |
| Modul-Init (Emscripten instanziieren) | ~450 ms (Browser) / ~680 ms (Node) |
Beweis-Artefakte: `hlr-elevation-proof.svg` / `.png` in diesem Ordner — sichtbare
Kanten durchgezogen, verdeckte gestrichelt. Die Zeichnung liest sich als korrekte
Hidden-Line-Ansicht (Silhouette + sichtbare Front-Kanten voll, verdeckte hinten
gestrichelt).
## Der genutzte API-Pfad (wichtig!)
Der geplante klassische Pfad (`HLRBRep_Algo`/`HLRBRep_PolyAlgo` +
`HLRBRep_HLRToShape`/`HLRBRep_PolyHLRToShape`) ist im **prebuilt Vollbuild
1.1.1 NICHT verfügbar**: diese Klassen sind nur als `Handle_…`-Smart-Pointer
gebunden, ohne konstruierbare Roh-Klasse und ohne den `HLRToShape`-Extraktor.
Ein direkter Aufbau darüber ist damit nicht möglich.
**Verwendeter, funktionierender Pfad:** `HLRAppli_ReflectLines` — der High-Level-
OCCT-Wrapper, der intern GENAU den exakten HLR-Algorithmus (`HLRBRep_Algo`)
fährt. Er ist als Klasse gebunden und liefert getrennt sichtbare/verdeckte
Kanten nach Kanten-Typ:
```
const rl = new oc.HLRAppli_ReflectLines(shape);
rl.SetAxes(dirX, dirY, dirZ, atX, atY, atZ, upX, upY, upZ); // Ortho-Projektion
rl.Perform();
const T = oc.HLRBRep_TypeOfResultingEdge;
// (typ, visible, in3d) → in3d=false liefert 2D-projizierte Kanten (Z≈0)
const visSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false);
const visOutline = rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false);
const hidSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false);
```
Kanten-Extraktion: `TopExp_Explorer_2(compound, TopAbs_EDGE, TopAbs_SHAPE)` →
`TopoDS.Edge_1(current)` → `new BRepAdaptor_Curve_2(edge)` →
`FirstParameter/LastParameter/Value(u)` (`gp_Pnt` mit `.X() .Y() .Z()`).
**embind-Überladungen sind versioniert** (`_1`, `_2`, …) und die Nummerierung im
Vollbuild folgt NICHT der Argumentzahl. Empirisch verifiziert:
`BRepPrimAPI_MakeBox_1(dx,dy,dz)`, `BRepPrimAPI_MakeBox_3(gp_Pnt, gp_Pnt)`,
`BRepAlgoAPI_Fuse_3(a, b)`, `gp_Pnt_3(x,y,z)`. Bei einem Versionswechsel des
Pakets müssen diese Suffixe neu geprüft werden (Fehlermeldung nennt die
erwartete Parameterzahl).
## Vite-/package.json-Änderungen (genau)
- `package.json`: Dependency `opencascade.js@^1.1.1` (Vollbuild).
- `vite.config.ts` (additiv, analog zum bestehenden LibreDWG-Muster):
- Zwei Aliase → virtuelle Module auf die echten Paket-Dateien:
`virtual:occt-glue` → `dist/opencascade.wasm.js`,
`virtual:occt-wasm-url` → `dist/opencascade.wasm.wasm?url`.
- `optimizeDeps.exclude: ["opencascade.js"]` (esbuild kommt mit dem
UMD-Wrapper + Node-Shims der Glue nicht klar → Vor-Bündeln ausschließen).
- `src/section/occt-wasm.d.ts`: Ambient-Stubs für die zwei virtuellen Module.
- Geladen wird per **dynamischem Import** in `src/section/occt.ts` → die 62-MB-
WASM landet NICHT im Haupt-Bundle, sondern als separater Lazy-Chunk + Asset.
Verifiziert: `vite build` der App bleibt grün und unverändert groß (kein
OCCT-Chunk, da der Spike nicht verdrahtet ist). Ein Lib-Build, der `hlr.ts`
referenziert, splittet OCCT sauber in einen eigenen Glue-Chunk + WASM-Asset ab.
`tsc` ist für die neuen Dateien grün. (`npm run build` schlägt aktuell in
`tsc -b` fehl — ausschließlich wegen fehlender i18n-Keys in den parallel
bearbeiteten Dateien `commands/cmds/stair.ts` / `state/*`, NICHT wegen dieses
Spikes.)
## Dateien dieses Spikes
- `src/section/hlr.ts` — Kernmodul: Box-Specs → Fuse → HLR → 2D-Polylinien
(`hlrFromBoxes`, `hlrShape`, `VIEWS`, `hlrToSvg`).
- `src/section/occt.ts` — Lazy-Loader + schmale Typ-Fassade + Lade-Metriken.
- `src/section/occt-wasm.d.ts` — Ambient-Deklarationen der virtuellen Module.
- `docs/welle-c-hlr-spike/` — dieser Report + Proof-SVG/-PNG.
## Bewertung für Welle C
**Viabel.** Der exakte HLR liefert saubere, normgerechte Vektor-Kanten mit
korrekter Sichtbarkeit — genau das, was Schnitt/Ansicht brauchen und was reines
three.js-Kanten-Projizieren nicht robust liefert (dort fehlt echte
Flächen-Verdeckung). Die 2D-Polylinien passen direkt in die bestehende
SVG-Plan-Pipeline (`Pt2`/`Vec2`-kompatibel).
**Der Kostenpunkt ist die WASM-Größe (62.8 MB / ~19.6 MB gzip).** Für einen
Spike akzeptabel; für Produktion zu groß, um sie eager zu laden.
### Empfohlener Integrations- + Größenreduktions-Plan
1. **Lazy laden** (bereits so gebaut): OCCT erst bei erster Schnitt-/Ansichts-
Erzeugung dynamisch importieren; Ladezustand in der UI anzeigen. Der
Grundriss läuft weiter ohne OCCT (parametrisch, wie bisher).
2. **Web-Worker**: HLR im Worker fahren, damit der UI-Thread frei bleibt (die
WASM ist groß, aber der HLR-Lauf selbst ist mit ~20 ms günstig).
3. **Größe reduzieren — lohnt sich klar**: ein **Custom-Build** von
opencascade.js (das Paket unterstützt `make.py`/Docker-Build mit einer
Symbol-Whitelist). Für Welle C reicht ein minimaler Satz:
`BRepPrimAPI_*` (bzw. der spätere Solid-Erzeuger), `BRepAlgoAPI_Fuse/Common`,
`HLRAppli_ReflectLines` + `HLRBRep_TypeOfResultingEdge`, `TopExp_Explorer`,
`TopoDS`, `BRepAdaptor_Curve`, `gp_*`. Erfahrungswerte solcher Minimal-Builds
liegen bei **~5–15 MB WASM** statt 62 MB — Aufwand: ein reproduzierbarer
Docker-Build in CI, der die `.wasm`/`.js` als Projekt-Asset eincheckt.
Alternativ die **beta 2.0** prüfen (moderneres Build-System, evtl. bereits
schlankere Module + die klassischen HLR-Klassen konstruierbar).
4. **Fallback, falls die Größe untragbar bleibt**: three.js-basierte
Kanten-Extraktion (`EdgesGeometry`) + eigene Sichtbarkeit via Depth-Peeling /
GPU-Occlusion. Deutlich mehr Eigenaufwand, weniger robust bei Verschneidungen
— daher nur zweite Wahl; OCCT-HLR bleibt der empfohlene Weg.
+90
View File
@@ -0,0 +1,90 @@
# M2 — nativer wgpu-2D-Renderer im Tauri-Fenster: Ansatzwahl
> Ziel: den bereits fluessig laufenden standalone `render2d`-Renderer aus dem
> ECHTEN Tauri-Prozess heraus mit nativer GPU-Performance anzeigen — nicht in der
> WebKitGTK-Webview (Perf-Flaschenhals), nicht in einem Chromium-Workaround.
> Maschine: Linux, Wayland, AMD.
## Gewaehlter Ansatz: **B — separates natives Fenster (winit) im Tauri-Prozess**
Der Tauri-Hauptthread haelt weiterhin die GTK-Hauptschleife + das Webview-Fenster
(HTML-Chrome). Aus dem Tauri-`setup`-Hook startet der Prozess auf einem
Hintergrund-Thread ein **eigenes natives winit-Fenster** mit eigener wgpu-Surface,
das die `render2d`-Demo-Szene (konkaves L + Raum + Papier-mm-Linien) rendert,
pan-/zoombar. Renderer/Szene werden NICHT reimplementiert — es ist exakt der Code
des verifizierten standalone `spike`-Bins (`render2d::gpu::Renderer` +
`render2d::demo_scene`).
### Warum B (und was gegen A/C spricht)
- **Kein geteilter Wayland-Surface → keine Contention/Flicker.** winit spricht auf
Linux DIREKT Wayland/X11 (Crates `wayland-client`/`x11rb`), es benutzt **kein
GTK**. Das native Fenster bekommt eine eigene, von WebKitGTK voellig getrennte
Wayland-Surface. Genau das umgeht das dokumentierte Problem aus der Vorrecherche
(Roh-wgpu-Surface UNTER der Webview im selben Fenster → Flicker/Schwarzbild auf
Wayland).
- **Zwei Fenster-Stacks, aber KEIN Event-Loop-Konflikt.** tao (Tauri) fuehrt eine
GTK-Hauptschleife auf dem Hauptthread; winit fuehrt eine eigene Schleife.
winit 0.30 erlaubt die Event-Loop explizit auf einem Nicht-Haupt-Thread via
`EventLoopBuilderExtWayland::with_any_thread(true)` (X11-Pendant analog). Der
native Renderer laeuft daher auf `std::thread` „cad-native2d", der Tauri-/GTK-
Hauptthread bleibt frei. Da winit nicht an GTK gebunden ist, kollidieren die
beiden Schleifen nicht (getrennte Stacks, getrennte fds).
- **Ansatz A (wgpu-Surface im SELBEN Tauri-Fenster, Unter-/Overlay):** verworfen.
taos `raw_window_handle_rwh_06()` liefert auf Wayland den `WaylandWindowHandle`
der GTK-`ApplicationWindow` — also DIESELBE Surface, in die WebKitGTK
komponiert. Eine zweite wgpu-Surface darauf ist genau die Contention, die die
Vorrecherche als flackernd/schwarz auf diesem Setup markiert hat. Hoechstes
Wayland-Risiko, fuer den Spike ungeeignet.
- **Ansatz C (wgpu als Haupt-Surface, HTML nur duennes Overlay):** verworfen fuer
den Spike. Sauberste Perf-Story, aber groesster Umbau: die ganze App muesste auf
eine winit/tao-getriebene Haupt-Schleife umziehen und die Tauri-Webview zur
Nebenrolle degradiert werden. Kein „minimaler realer Spike" mehr. Bleibt die
Ziel-Endarchitektur-Option, aber nach B.
## Crates / Versionen (verifiziert aus der Lock-Datei)
| Rolle | Crate | Version |
|---|---|---|
| Tauri | `tauri` / `tauri-runtime-wry` | 2.11.5 / 2.11.4 |
| Fenster (Webview-Seite) | `tao` | 0.35.3 |
| Webview | `wry` | 0.55.1 |
| Natives Renderfenster | `winit` | 0.30.13 |
| GPU | `wgpu` / `wgpu-hal` | 22.1.0 / 22.0.0 |
| Handle-Bruecke | `raw-window-handle` | **0.6.2 (identisch fuer tao UND wgpu/winit)** |
| Renderer | `render2d` (Pfad) | 0.1.0, Feature `window` |
Der entscheidende Kompatibilitaets-Glueckstreffer: tao 0.35 und wgpu 22/winit 0.30
teilen sich `raw-window-handle 0.6.2` — keine rwh-Versionsbruecke noetig. (Fuer
Ansatz B wird der tao-Handle gar nicht gebraucht, weil das native Fenster eine
eigene winit-Surface hat; die Versions-Parität ist aber die Voraussetzung fuer ein
spaeteres A/Compositing, falls je gewuenscht.)
## Wayland-Caveats (fuer den Start auf dieser Maschine)
- **`WEBKIT_DISABLE_DMABUF_RENDERER=1`** bleibt fuer die Webview-Sichtbarkeit auf
diesem Wayland/AMD-Setup noetig (Vorrecherche). Betrifft die WebKitGTK-Seite,
nicht das wgpu-Fenster.
- Das native winit-Fenster laeuft ueber Vulkan; GLES/EGL-Fallback wurde standalone
ebenfalls funktionierend gesehen. `WGPU_BACKEND=gl` erzwingt bei Vulkan-Zicken
den GL-Pfad.
- `with_any_thread(true)` ist zwingend — ohne die Freigabe panict winit, weil es
die Event-Loop sonst nur auf dem Hauptthread zulaesst (den haelt hier GTK).
## Bau-Isolierung
Alles hinter Cargo-Feature **`native2d`** in `cad-tauri` (opt-in). Der normale
Build (`cargo build`, `npm run tauri:dev`) zieht weder winit noch wgpu und bleibt
unveraendert. `render2d` bleibt ohne Tauri-/GTK-Abhaengigkeiten headless baubar
und testbar.
## Naechster Schritt Richtung Vollbild-App (2D-Viewport + HTML-Chrome)
1. Szene aus dem echten Modell speisen: `Plan.primitives` (TS) → serde-`Scene` →
ueber einen Tauri-`emit`/Command an den native2d-Thread (statt `demo_scene`).
2. Pan/Zoom-State zwischen Webview-Chrome und native2d-Fenster synchronisieren
(Tauri-Events beidseitig).
3. Fenster-Kopplung: das native Fenster als Kind/als angedockten Bereich neben der
Webview positionieren (Layout), spaeter ggf. Umstieg auf Ansatz C, wenn ein
einziges Fenster gefordert ist.
+9 -321
View File
@@ -1,27 +1,22 @@
{
"name": "dossier",
"name": "cad",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dossier",
"name": "cad",
"version": "0.0.0",
"license": "AGPL-3.0-or-later",
"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",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
"jszip": "^3.10.1",
"pdfjs-dist": "^6.2.108",
"opencascade.js": "^1.1.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -875,271 +870,6 @@
"node": ">=20"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
"integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.3",
"@napi-rs/canvas-darwin-arm64": "1.0.3",
"@napi-rs/canvas-darwin-x64": "1.0.3",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
"@napi-rs/canvas-linux-arm64-musl": "1.0.3",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
"@napi-rs/canvas-linux-x64-gnu": "1.0.3",
"@napi-rs/canvas-linux-x64-musl": "1.0.3",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
"@napi-rs/canvas-win32-x64-msvc": "1.0.3"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
"integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
"integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
"integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
"integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -2132,42 +1862,6 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -3520,6 +3214,12 @@
"node": ">=12.20.0"
}
},
"node_modules/opencascade.js": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/opencascade.js/-/opencascade.js-1.1.1.tgz",
"integrity": "sha512-lw6/vOl86+CkJ8d3V01mlbGAC0A49gc1HbwGcqGeKjk5SGRLiF15jyUuA8aYEvizcPNTu4Ta4A+Ut2DJgsa7AQ==",
"license": "LGPL-2.1-only"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
@@ -3543,18 +3243,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.2.108",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+2 -5
View File
@@ -19,6 +19,7 @@
"electron": "scripts/electron-shell.sh",
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web",
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web",
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
@@ -28,15 +29,11 @@
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
"jszip": "^3.10.1",
"pdfjs-dist": "^6.2.108",
"opencascade.js": "^1.1.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+13 -778
View File
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -1,13 +1,14 @@
[workspace]
members = ["."]
# render2d/render3d sind eigenstaendige Crates, damit sie headless ohne
# render2d/render3d/geometry sind eigenstaendige Crates, damit sie headless ohne
# Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie
# liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace —
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
# nutzen kann. 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", "kernel2d", "dwgimport", "trucksolid"]
# nutzen kann. geometry: neu ausgeschlossen (war Member), seit es zu WASM gebaut
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
[package]
name = "cad-tauri"
@@ -38,12 +39,9 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-http = "2"
tauri-plugin-shell = "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).
+2 -13
View File
@@ -1,7 +1,7 @@
{
"$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). 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. Dialog/Fs: nativer Oeffnen- UND Speichern-Dialog fuer Projektdateien (.obp/.json) plus Lesen/Schreiben der gewaehlten Datei (io/projectFile.ts, io/saveFile.ts) - open+read fuer Projekt laden, save+write fuer Projekt/Export speichern. Updater/Process: Selbst-Update-Check beim Start (src/update/checkForUpdate.ts) + Neustart nach Installation. Http: Versionsverlauf (versions.json vom Gitea-Release) ohne Browser-CORS, Scope auf den eigenen Gitea-Host begrenzt. Shell:allow-open: oeffnet den DMG-Download einer aelteren Version im System-Browser (Rollback). allow-set-size/allow-center: kompaktes Splash-/Startbildschirm-Fenster vor dem Editor (App.tsx bootPhase).",
"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",
@@ -13,21 +13,10 @@
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-create",
"core:window:allow-set-size",
"core:window:allow-center",
"core:webview:allow-create-webview-window",
"core:menu:default",
"core:event:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"updater:default",
"process:allow-restart",
{
"identifier": "http:default",
"allow": [{ "url": "https://git.openbureau.ch/*" }]
},
"shell:allow-open"
"fs:allow-write-text-file"
]
}
+75
View File
@@ -0,0 +1,75 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "geometry"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "geometry"
version = "0.1.0"
edition = "2021"
description = "Serde-only Geometrie-Kern (Wand-Verschneidungen/Joins) — headless und per wasm-pack (Feature \"web\") zu WASM baubar; Single Source of Truth fuer TS + nativen Host."
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul benoetigt. rlib:
# damit die Crate weiterhin als Pfad-Abhaengigkeit (cad-tauri) und im Test-Build
# nutzbar bleibt. Der cdylib-Artefakt-Build auf nativen Zielen ist harmlos (leere
# Export-Oberflaeche ohne Feature "web"). Muster: render2d/render3d.
[lib]
crate-type = ["cdylib", "rlib"]
[features]
# Standard: reine serde-Geometrie, headless per `cargo test` pruefbar.
default = []
# Browser-Bindings: dieselbe Geometrie hinter einer wasm-bindgen-Fassade
# (`compute_joins_json`), aus TS via wasm-pack aufgerufen. Baut nur fuer
# target wasm32-unknown-unknown sinnvoll. Muster: render3d/Cargo.toml.
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
[dev-dependencies]
serde_json = "1"
+16
View File
@@ -0,0 +1,16 @@
// Paritaets-Harness: liest ein JoinInput-JSON von stdin, rechnet compute_joins
// und schreibt das WallCuts-JSON nach stdout. Dient dem TS<->Rust-Vergleich
// (siehe src/compute/parity.test.ts) — identische Eingabe, identische Ausgabe.
use std::io::Read;
fn main() {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.expect("stdin lesen");
let input: geometry::JoinInput = serde_json::from_str(&buf).expect("JoinInput parsen");
let cuts = geometry::compute_joins(input);
let out = serde_json::to_string(&cuts).expect("WallCuts serialisieren");
println!("{out}");
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+7 -23
View File
@@ -1711,23 +1711,9 @@ impl Renderer {
multiview_mask: None,
});
// Live-Schnitt aktiv? Dann erzwingen wir die Modell-Kanten ZUSAETZLICH
// zum aktuellen Stil (unabhaengig von `draws_edges()`) — Nutzer-Report:
// duenne, schraeg angeschnittene Bauteile (Zwischenwaende, Giebel-Enden,
// einzelne Material-Schichten) haben aus vielen Blickwinkeln eine fast
// unsichtbare (kantige) Stirnflaeche, waehrend ihre (flache) Schnitt-
// kappe voll sichtbar bleibt — das wirkt wie ein abgeloestes,
// "schwebendes" Element. Die dunklen Kanten (bereits vorhanden fuer
// wireframe/hidden/shaded-edges, `edges::build_mesh_edges`, tiefen-
// getestet + von der Schnittebene gekappt wie das Mesh selbst) zeichnen
// jede Bauteilkante nach, auch wenn ihre Flaeche praktisch auf Null
// Bildschirmbreite zusammenfaellt — das verbindet Kappe und Bauteil
// sichtbar, ohne die Kamera zu bewegen oder Geometrie zu aendern.
let force_edges_for_section = self.globals.mode[1] > 0.5;
// 1) Gefuellte Flaechen — in allen Stilen ausser "wireframe". Im
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
// die folgenden Kanten sauber obenauf liegen. Bei aktivem Schnitt
// ebenso (die Kanten werden dann erzwungen, s.o.).
// die folgenden Kanten sauber obenauf liegen.
if self.style.draws_faces() {
if self.style == RenderStyle::Textured {
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
@@ -1744,9 +1730,7 @@ impl Renderer {
pass.draw_indexed(0..t.index_count, 0, 0..1);
}
} else if let Some(m) = &self.mesh {
let face_pipeline = if self.style.needs_biased_face_pipeline()
|| force_edges_for_section
{
let face_pipeline = if self.style.needs_biased_face_pipeline() {
&self.hidden_face_pipeline
} else {
&self.pipeline
@@ -1814,11 +1798,11 @@ impl Renderer {
pass.draw(0..cl.vertex_count, 0..1);
}
// 2) Modell-Kanten (wireframe/hidden, ODER erzwungen bei aktivem
// Schnitt, s.o.) — LineList-Pipeline (wie Grid). Tiefengetestet
// gegen die (gebiasten) Flaechen -> verdeckte Kanten fallen weg.
// Im Wireframe ohne Flaechen -> alle Kanten sichtbar.
if self.style.draws_edges() || force_edges_for_section {
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
// Flaechen -> alle Kanten sichtbar.
if self.style.draws_edges() {
if let Some(e) = &self.edges {
pass.set_pipeline(&self.grid_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
+12 -13
View File
@@ -1,4 +1,5 @@
// Tauri-v2-Einstieg: die Befehls-Bruecke und der App-Start.
// Tauri-v2-Einstieg. Die eigentliche Geometrie liegt im serde-only Crate
// `geometry`; hier nur die Befehls-Bruecke und der App-Start.
// M2: native wgpu-Viewports (2D und/oder 3D) im Tauri-Prozess. EIN Modul, EINE
// winit-Event-Loop fuer beide Fenster (winit erlaubt nur eine Loop pro Prozess).
@@ -31,6 +32,14 @@ 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(
input: geometry::JoinInput,
) -> Result<Vec<geometry::WallCuts>, String> {
Ok(geometry::compute_joins(input))
}
// Live-Spiegelung: die Webview schiebt bei jeder Modellaenderung (debounced)
// die aktuelle 2D-Szene bzw. die 3D-Waende hierher; native.rs stellt sie ueber
// einen EventLoopProxy in die winit-Loop zu. Die Payload kommt als rohes JSON
@@ -63,18 +72,6 @@ pub fn run() {
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
// Selbst-Update: prueft beim Start gegen `endpoints` aus tauri.conf.json
// (Gitea-Release-Asset `latest.json`), Installation via JS-Seite
// (@tauri-apps/plugin-updater). `tauri_plugin_process` liefert `relaunch()`
// fuer den Neustart nach der Installation.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
// HTTP-Requests aus der Webview (Versionsverlauf, versions.json vom
// selben Gitea-Release) ohne Browser-CORS-Einschraenkung; Shell: oeffnet
// den DMG-Download einer aelteren Version im System-Browser (Rollback,
// s. src/update/checkForUpdate.ts).
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
.manage(lock::LockState::default())
.setup(|_app| {
@@ -87,6 +84,7 @@ pub fn run() {
#[cfg(any(feature = "native2d", feature = "native3d"))]
let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins,
push_native_scene,
push_native_walls,
acquire_project_lock,
@@ -94,6 +92,7 @@ pub fn run() {
]);
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins,
acquire_project_lock,
release_project_lock
]);
+3 -18
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "dossier",
"productName": "Dossier",
"version": "0.1.0",
"identifier": "ch.dossier.cad",
"build": {
@@ -11,7 +11,7 @@
"windows": [
{
"label": "main",
"title": "dossier",
"title": "Dossier",
"width": 1400,
"height": 900,
"dragDropEnabled": false,
@@ -27,21 +27,6 @@
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYyRDk3NkZGODQwRUJBNTQKUldSVXVnNkUvM2JaOHV2QWp0L05qZnpEUmt2TkN3ZWJBeFQwb1I0cExWNEpuZWU0UjZiazdQNU0K",
"endpoints": [
"https://git.openbureau.ch/karim/DOSSIER-STANDALONE/releases/download/latest/latest.json"
]
}
"icon": ["icons/icon.png"]
}
}
+3451 -1024
View File
File diff suppressed because it is too large Load Diff
-97
View File
@@ -1,97 +0,0 @@
/**
* `arrayCommand` — dupliziert die Auswahl `count`-mal entlang eines Vektors
* in einer Geste. Baut auf dem bestehenden `mode:"array"` von
* `tools/transform.ts` auf (bisher ohne eigenen Befehl erreichbar).
*/
import { describe, it, expect } from "vitest";
import { arrayCommand } from "./array";
import type { CommandContext, Project } from "../types";
import type { Drawing2D } from "../../model/types";
function project(extra: Drawing2D[] = []): 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: extra,
context: [],
};
}
function makeCtx(p: Project, drawingId: string | null): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId },
};
}
const source: Drawing2D = {
id: "src",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "circle", center: { x: 0, y: 0 }, r: 1 },
};
describe("arrayCommand", () => {
it("4 Kopien entlang +x im Abstand 3m (Anzahl per Option auf 4 gesetzt)", () => {
const p = project([source]);
const ctx = makeCtx(p, "src");
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
// Anzahl-Option 3× drücken: 3(default egal)→…→4 (zyklt 2..12, wir setzen sie
// deterministisch, indem wir bis 4 zyklen unabhängig vom Startwert testen wir
// stattdessen direkt über den committeten Output).
for (let i = 0; i < 12; i++) {
[state] = arrayCommand.onInput(state, { kind: "option", id: "count" }, ctx);
const opts = arrayCommand.options(state);
if (opts[0].value === "4") break;
}
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 3, y: 0 } }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
// Original bleibt + 4 neue Kopien.
expect(next.drawings2d.length).toBe(5);
const centers = next.drawings2d
.map((d) => d.geom)
.filter((g) => g.shape === "circle")
.map((g) => (g as { center: { x: number; y: number } }).center.x)
.sort((a, b) => a - b);
expect(centers).toEqual([0, 3, 6, 9, 12]);
});
it("leere Auswahl committet nichts", () => {
const p = project();
const ctx = makeCtx(p, null);
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 5, y: 0 } }, ctx);
expect(result.commit).toBeUndefined();
expect(result.done).toBe(true);
});
it("Zielpunkt gleich Basispunkt (Nullvektor) committet nichts", () => {
const p = project([source]);
const ctx = makeCtx(p, "src");
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 2, y: 2 } }, ctx);
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 2, y: 2 } }, ctx);
expect(result.commit).toBeUndefined();
});
});
-168
View File
@@ -1,168 +0,0 @@
// Array (Feld) — dupliziert die AKTUELLE Auswahl `count`-mal entlang eines
// Vektors, in EINER Geste (baut auf dem bestehenden Transform-Modus
// `"array"` auf, der bisher nie über einen Befehl erreichbar war — s.
// `tools/transform.ts::copyTs`). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt ( Anzahl ):" → Punkt/Zahl (Tab length/angle) → committet
// ALLE `count` Kopien auf einmal (Original bleibt). „Anzahl" (Option,
// persistent über Aufrufe wie bei Multiline) zyklt 2..12.
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
// wie move/copy). Leere Auswahl → No-op.
import { commitTransform, transformPreview } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import type {
CmdOption,
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
const DEG = Math.PI / 180;
const MIN_COUNT = 2;
const MAX_COUNT = 12;
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;
/** Persistente Anzahl über Aufrufe hinweg (wie `lastCount` bei Multiline). */
let lastCount = 3;
const ARRAY_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.array.count" };
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
interface ArrBase extends CommandState {
phase: "base";
}
interface ArrTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
type ArrState = ArrBase | ArrTarget;
/** Vorschau ALLER `lastCount` Kopien bei der aktuellen Cursor-Lage. */
function arrayDraft(project: Project, sel: CommandSelection, base: Vec2, target: Vec2): ToolDraft {
const preview = transformPreview(project, toTransformSel(sel), "move", [base, target], "array", lastCount);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m × ${lastCount}`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "base", lastPoint: null } as ArrBase,
{ draft: null, done: true },
];
export const arrayCommand: Command = {
name: "array",
labelKey: "cmd.array.label",
prompt: (s) => ((s as ArrState).phase === "target" ? "cmd.array.target" : "cmd.array.base"),
accepts: (s) => ((s as ArrState).phase === "target" ? ["point", "number", "option"] : ["point"]),
options: (s) =>
(s as ArrState).phase === "target" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : [],
init: (): ArrBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ArrState;
if (!hasSelection(ctx.selection)) return idle();
if (s.phase !== "target") {
if (input.kind !== "point") return [s, { draft: null }];
const next: ArrTarget = {
phase: "target",
sel: ctx.selection,
base: input.point,
cursor: input.point,
lastPoint: input.point,
};
return [next, { draft: { preview: [], vertices: [input.point] } }];
}
if (input.kind === "option") {
if (input.id === "count") {
lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1;
return [s, { draft: s.cursor ? arrayDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
let target: Vec2 | null = null;
if (input.kind === "point") target = input.point;
else if (input.kind === "number") target = { x: s.base.x + input.value, y: s.base.y };
if (!target || segLen(s.base, target) < 1e-6) return idle();
const { base, sel } = s;
return [
idle()[0],
{ draft: null, done: true, commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, target!], "array", lastCount) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as ArrState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: ArrTarget = { ...s, cursor: point };
return [ns, { draft: arrayDraft(ctx.project, s.sel, s.base, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
fields: (state) => ((state as ArrState).phase === "target" ? ARRAY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as ArrState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as ArrState;
if (s.phase !== "target" || !cursor) return {};
return {
length: segLen(s.base, cursor),
angle: ((segAngleDeg(s.base, cursor) % 360) + 360) % 360,
};
},
};
-96
View File
@@ -1,96 +0,0 @@
/**
* `bezierCommand` — manuelles Kontrollpunkt-Werkzeug: Anker → Griff 1 →
* Griff 2 → Endpunkt je Segment, „Fertig" committet als `shape:"bezier"`.
*/
import { describe, it, expect } from "vitest";
import { bezierCommand } from "./bezier";
import type { CommandContext, Project } from "../types";
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(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("bezierCommand", () => {
it("ein Segment (4 Klicks) + Fertig → shape:bezier mit 4 Kontrollpunkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 3 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
const [, result] = bezierCommand.onConfirm(state, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("bezier");
if (g.shape === "bezier") {
expect(g.pts).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 3 },
{ x: 3, y: 3 },
{ x: 4, y: 0 },
]);
}
});
it("zwei Segmente hängen am gemeinsamen Anker (7 Punkte)", () => {
const p = project();
const ctx = makeCtx(p);
const clicks = [
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 },
{ x: 4, y: -1 }, { x: 5, y: -1 }, { x: 6, y: 0 },
];
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: clicks[0] }, ctx);
for (let i = 1; i < clicks.length; i++) {
[state] = bezierCommand.onInput(state, { kind: "point", point: clicks[i] }, ctx);
}
const [, result] = bezierCommand.onInput(state, { kind: "option", id: "finish" }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("bezier");
if (g.shape === "bezier") expect(g.pts).toEqual(clicks);
});
it("weniger als ein volles Segment: Enter bricht ohne commit ab", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 1 } }, ctx);
const [, result] = bezierCommand.onConfirm(state, ctx);
expect(result.commit).toBeUndefined();
});
});
-157
View File
@@ -1,157 +0,0 @@
// Bezier — klassisches Kontrollpunkt-Werkzeug (manuell gesetzte Griffe, kein
// Auto-Glätten wie `spline`). Jedes Segment braucht 3 Klicks nach dem Anker:
// 1) „Startpunkt:" → Punkt (erster Anker)
// 2) „Griff 1:" → Punkt (Kontrollpunkt 1 dieses Segments)
// 3) „Griff 2:" → Punkt (Kontrollpunkt 2 dieses Segments)
// 4) „Endpunkt:" → Punkt (Segment-Ende, = Anker des nächsten Segments)
// Nach ≥1 fertigem Segment kann „Fertig" (Enter) den Zug abschliessen; jeder
// weitere Anker startet automatisch ein neues Segment (Schritte 2–4).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { sampleBezierChain } from "../../tools/curves";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
interface BezierStart extends CommandState {
phase: "start";
}
interface BezierC1 extends CommandState {
phase: "c1";
pts: Vec2[]; // abgeschlossene Punkte (Anker·Griff·Griff·Anker·…)
anchor: Vec2; // letzter Anker (= pts[pts.length-1])
cursor: Vec2 | null;
}
interface BezierC2 extends CommandState {
phase: "c2";
pts: Vec2[];
anchor: Vec2;
c1: Vec2;
cursor: Vec2 | null;
}
interface BezierEnd extends CommandState {
phase: "end";
pts: Vec2[];
anchor: Vec2;
c1: Vec2;
c2: Vec2;
cursor: Vec2 | null;
}
type BezierState = BezierStart | BezierC1 | BezierC2 | BezierEnd;
const FINISH: CmdOption = { id: "finish", labelKey: "cmd.bezier.finish" };
/** Vorschau: abgeschlossene Kurve (tessellierte Segmente) + Gummiband-Linien des laufenden Segments. */
function bezierDraft(pts: Vec2[], rubber: Vec2[]): ToolDraft {
const preview: DraftShape[] = [];
if (pts.length >= 4) preview.push({ kind: "poly", pts: sampleBezierChain(pts), closed: false });
for (let i = 0; i < rubber.length - 1; i++) preview.push({ kind: "line", a: rubber[i], b: rubber[i + 1] });
return { preview, vertices: pts.length > 0 ? [pts[0]] : [] };
}
function appendBezier(p: Project, pts: Vec2[], ctx: CommandContext): Project {
if (pts.length < 4) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "bezier", pts },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as BezierStart,
{ draft: null, done: true },
];
const finish = (pts: Vec2[], ctx: CommandContext): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as BezierStart,
{ draft: null, done: true, commit: (p) => appendBezier(p, pts, ctx) },
];
export const bezierCommand: Command = {
name: "bezier",
labelKey: "cmd.bezier.label",
prompt: (s) => {
const phase = (s as BezierState).phase;
return phase === "c1"
? "cmd.bezier.c1"
: phase === "c2"
? "cmd.bezier.c2"
: phase === "end"
? "cmd.bezier.end"
: "cmd.bezier.start";
},
accepts: () => ["point", "option"],
options: (s) => ((s as BezierState).phase === "c1" && (s as BezierC1).pts.length >= 4 ? [FINISH] : []),
init: (): BezierStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as BezierState;
if (input.kind === "option") {
if (input.id === "finish" && s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
return [s, { draft: null }];
}
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase === "start") {
const ns: BezierC1 = { phase: "c1", pts: [pt], anchor: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(ns.pts, [pt]) }];
}
if (s.phase === "c1") {
const ns: BezierC2 = { phase: "c2", pts: s.pts, anchor: s.anchor, c1: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, pt]) }];
}
if (s.phase === "c2") {
const ns: BezierEnd = {
phase: "end",
pts: s.pts,
anchor: s.anchor,
c1: s.c1,
c2: pt,
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, s.c1, pt]) }];
}
// phase "end": Segment abschliessen, direkt das nächste Segment beginnen.
const pts = [...s.pts, s.c1, s.c2, pt];
const ns: BezierC1 = { phase: "c1", pts, anchor: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(pts, [pt]) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as BezierState;
if (s.phase === "start") return [s, { draft: null }];
if (s.phase === "c1") {
const ns: BezierC1 = { ...s, cursor: point };
return [ns, { draft: bezierDraft(s.pts, [s.anchor, point]) }];
}
if (s.phase === "c2") {
const ns: BezierC2 = { ...s, cursor: point };
return [ns, { draft: bezierDraft(s.pts, [s.anchor, s.c1, point]) }];
}
const ns: BezierEnd = { ...s, cursor: point };
// Endschritt: die tatsächliche Bezier-Kurve bis zum Cursor live anzeigen.
return [ns, { draft: bezierDraft([...s.pts, s.c1, s.c2, point], []) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as BezierState;
if (s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
};
-119
View File
@@ -1,119 +0,0 @@
/**
* `chamferCommand` — die Kernel-Geometrie (`chamferCorner`) ist neu (existierte
* bisher gar nicht, anders als filletCorner). Test: eine rechtwinklige
* Polylinien-Ecke wird mit einer Distanz gefast (gerader Schnitt statt Bogen).
*/
import { describe, it, expect } from "vitest";
import { chamferCommand } from "./chamfer";
import type { CommandContext, Project } from "../types";
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: [
// L-förmige offene Polylinie: (0,0) → (2,0) → (2,2). Ecke bei (2,0).
{
id: "poly",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "polyline", pts: [{ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 }], closed: false },
},
],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("chamferCommand", () => {
it("fast die Ecke mit getippter Distanz (Zahl-Eingabe)", () => {
const p = project();
const ctx = makeCtx(p);
// Schritt 1: Ecke wählen (Klick nahe (2,0)).
const [afterPick, r1] = chamferCommand.onInput(
chamferCommand.init(),
{ kind: "point", point: { x: 2, y: 0 } },
ctx,
);
expect(r1.done).toBeFalsy();
// Schritt 2: Distanz getippt (0.5m) → commit.
const [, r2] = chamferCommand.onInput(afterPick, { kind: "number", value: 0.5 }, ctx);
expect(r2.done).toBe(true);
expect(r2.commit).toBeDefined();
const next = r2.commit!(p);
const poly = next.drawings2d.find((d) => d.id === "poly")!;
expect(poly.geom.shape).toBe("polyline");
if (poly.geom.shape !== "polyline") return;
const pts = poly.geom.pts;
// Die scharfe Ecke (2,0) darf nicht mehr vorkommen — durch die Fasung ersetzt.
expect(pts.some((p) => p.x === 2 && p.y === 0)).toBe(false);
// Anfangs-/Endpunkt der ganzen Kette bleiben unverändert.
expect(pts[0]).toEqual({ x: 0, y: 0 });
expect(pts[pts.length - 1]).toEqual({ x: 2, y: 2 });
// Exakt zwei neue Punkte (gerade Fasung, keine Tessellierung): 0.5m vom
// Eck entlang jedes Schenkels.
expect(pts.length).toBe(4);
const close = (a: { x: number; y: number }, x: number, y: number) =>
Math.hypot(a.x - x, a.y - y) < 1e-9;
expect(close(pts[1], 1.5, 0)).toBe(true);
expect(close(pts[2], 2, 0.5)).toBe(true);
});
it("daneben geklickt → Befehl bleibt im pick-Zustand (kein Treffer)", () => {
const p = project();
const ctx = makeCtx(p);
const [state, result] = chamferCommand.onInput(
chamferCommand.init(),
{ kind: "point", point: { x: 50, y: 50 } },
ctx,
);
expect(result.commit).toBeUndefined();
expect(result.done).toBeFalsy();
expect((state as { phase: string }).phase).toBe("pick");
});
it("Distanz zu gross für die Schenkellänge → No-op (kein commit-Effekt)", () => {
const p = project();
const ctx = makeCtx(p);
const [afterPick] = chamferCommand.onInput(
chamferCommand.init(),
{ kind: "point", point: { x: 2, y: 0 } },
ctx,
);
// Schenkel sind 2m lang; 10m Distanz passt auf keinen der beiden.
const [, r2] = chamferCommand.onInput(afterPick, { kind: "number", value: 10 }, ctx);
expect(r2.commit).toBeDefined();
const next = r2.commit!(p);
expect(next).toBe(p); // unverändert
});
});
-210
View File
@@ -1,210 +0,0 @@
// Chamfer — fast die Ecke einer 2D-Kurve (polyline/rect) mit einer geraden
// Abschrägung (Rhino/AutoCAD-artig, Gegenstück zu Fillet ohne Bogen).
// Gleicher Aufbau wie fillet.ts (siehe dort für die ausführlichere
// Begründung), nur mit `chamferCorner` statt `filletCorner`:
// 1) „Ecke wählen:" → Punkt (nächstgelegener Eckpunkt einer polyline/rect)
// 2) „Distanz:" → Punkt (Abstand von der Ecke) ODER getippte Zahl → commit
//
// Anders als Fillet braucht Chamfer KEINE Bogen-Tessellierung — die Fasung
// ist von Natur aus gerade, also ein exaktes (nicht approximiertes) Ergebnis.
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { chamferCorner, type Chamfer } from "../../geometry/kernel2d";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const HIT_TOL = 0.3; // Modell-Meter: Pick-Toleranz für die Eckenwahl
const DEFAULT_DISTANCE = 0.1; // m — Startwert, bis der Nutzer per Maus/Tab einen eigenen setzt
const EPS = 1e-6;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine chamfer-fähige 2D-Form (polyline/rect) in eine Punktliste oder null. */
function geomToCurve(g: Drawing2DGeom): Curve | null {
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
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 }],
closed: true,
};
}
return null; // line (keine eigene Ecke), circle/arc/text: nicht chamfer-fähig
}
/** Ein Treffer: das Element + der Eckpunkt-Index (mit gültigen beiden Nachbarn). */
interface CornerHit {
drawing: Drawing2D;
curve: Curve;
index: number;
}
/** Nächster Eckpunkt (mit zwei Nachbarn) einer chamfer-fähigen Drawing2D zum Klickpunkt. */
function pickCornerAt(project: Project, levelId: string, pt: Vec2): CornerHit | null {
let best: CornerHit | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const curve = geomToCurve(d.geom);
if (!curve) continue;
const n = curve.pts.length;
for (let i = 0; i < n; i++) {
// Offene Polylinie: die beiden Endpunkte haben keinen zweiten Nachbarn.
if (!curve.closed && (i === 0 || i === n - 1)) continue;
const dd = dist(pt, curve.pts[i]);
if (dd < bestD) {
bestD = dd;
best = { drawing: d, curve, index: i };
}
}
}
return best;
}
/** Fasungs-Geometrie für einen Treffer + Distanz (oder null, falls unmöglich). */
function chamferFor(hit: CornerHit, distance: number): Chamfer | null {
const n = hit.curve.pts.length;
const prev = hit.curve.pts[(hit.index - 1 + n) % n];
const next = hit.curve.pts[(hit.index + 1) % n];
const corner = hit.curve.pts[hit.index];
return chamferCorner(corner, prev, next, distance);
}
/** Ersetzt den Eckpunkt durch die beiden Fasungs-Punkte (Schenkel↔Schenkel). */
function applyChamferPoints(hit: CornerHit, c: Chamfer): Vec2[] {
return [
...hit.curve.pts.slice(0, hit.index),
c.a,
c.b,
...hit.curve.pts.slice(hit.index + 1),
];
}
function chamferDraft(hit: CornerHit, d: number, at: Vec2 | null): ToolDraft {
const c = d > EPS ? chamferFor(hit, d) : null;
const preview: DraftShape[] = c ? [{ kind: "poly", pts: [c.a, c.b], closed: false }] : [];
const draft: ToolDraft = { preview, vertices: [hit.curve.pts[hit.index]] };
if (at) draft.hud = { at, text: `D: ${d.toFixed(3)}m${c ? "" : " ✗"}` };
return draft;
}
function commitChamfer(p: Project, hit: CornerHit, distance: number): Project {
const c = chamferFor(hit, distance);
if (!c) return p; // Distanz zu gross für die Schenkellänge, oder Ecke (fast) gerade → No-op
const pts = applyChamferPoints(hit, c);
const geom: Drawing2DGeom = { shape: "polyline", pts, closed: hit.curve.closed };
return {
...p,
drawings2d: p.drawings2d.map((d) => (d.id === hit.drawing.id ? { ...d, geom } : d)),
};
}
// ── Zustand ───────────────────────────────────────────────────────────────────
const DISTANCE_FIELDS: CommandField[] = [{ id: "distance", labelKey: "cmd.field.distance" }];
interface ChamferPick extends CommandState {
phase: "pick";
}
interface ChamferDistance extends CommandState {
phase: "distance";
hit: CornerHit;
distance: number;
}
interface ChamferDone extends CommandState {
phase: "done";
}
type ChamferState = ChamferPick | ChamferDistance | ChamferDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ChamferDone,
{ draft: null, done: true },
];
export const chamferCommand: Command = {
name: "chamfer",
labelKey: "cmd.chamfer.label",
prompt: (s) =>
(s as ChamferState).phase === "distance" ? "cmd.chamfer.distance" : "cmd.chamfer.pick",
accepts: (s) => ((s as ChamferState).phase === "distance" ? ["point", "number"] : ["point"]),
options: () => [],
fields: (s) => ((s as ChamferState).phase === "distance" ? DISTANCE_FIELDS : []),
init: (): ChamferPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => {
const s = state as ChamferState;
if (s.phase === "done") return finish();
if (s.phase === "pick") {
if (input.kind !== "point") return [s, { draft: null }];
const hit = pickCornerAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }]; // daneben geklickt → aktiv bleiben
const next: ChamferDistance = {
phase: "distance",
hit,
distance: DEFAULT_DISTANCE,
lastPoint: input.point,
};
return [next, { draft: chamferDraft(hit, next.distance, input.point) }];
}
// phase === "distance"
let d = s.distance;
if (input.kind === "number") d = Math.max(0, input.value);
else if (input.kind === "point") d = Math.max(0, dist(s.hit.curve.pts[s.hit.index], input.point));
else return [s, { draft: null }];
return [
{ phase: "done", lastPoint: null } as ChamferDone,
{ draft: null, done: true, commit: (p) => commitChamfer(p, s.hit, d) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as ChamferState;
if (s.phase === "pick") {
const hit = pickCornerAt(ctx.project, ctx.level.id, point);
if (!hit) return [s, { draft: null }];
return [s, { draft: chamferDraft(hit, DEFAULT_DISTANCE, point) }];
}
if (s.phase !== "distance") return [s, { draft: null }];
const d = Math.max(0, dist(s.hit.curve.pts[s.hit.index], point));
const ns: ChamferDistance = { ...s, distance: d };
return [ns, { draft: chamferDraft(s.hit, d, point) }];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
// Tab-Feld-Zyklus nur im „distance"-Schritt, analog zu fillet.ts/circle.ts.
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as ChamferState;
if (s.phase !== "distance" || !cursor) return {};
return { distance: dist(s.hit.curve.pts[s.hit.index], cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as ChamferState;
if (s.phase !== "distance") return null;
const corner = s.hit.curve.pts[s.hit.index];
const d = "distance" in locks ? Math.max(0, locks.distance) : cursor ? dist(corner, cursor) : 0;
if (cursor) {
const dd = dist(corner, cursor);
if (dd > EPS) {
return {
x: corner.x + ((cursor.x - corner.x) / dd) * d,
y: corner.y + ((cursor.y - corner.y) / dd) * d,
};
}
}
return { x: corner.x + d, y: corner.y };
},
};
-2
View File
@@ -45,7 +45,6 @@ function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
@@ -54,7 +53,6 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
-263
View File
@@ -1,263 +0,0 @@
/**
* `dimensionCommand` — Punktreihe (wie Polylinie) + EIN Offset-Klick → EIN
* `shape:"dimension"`-Element für die ganze Kette (Reihenbemassung, SIA 400
* Figur 14). Ist beim Start bereits eine Kette selektiert, ergänzt der erste
* Klick sie (verlängert ODER fügt dazwischen ein) statt eine neue zu
* beginnen. Die Masszahl wird NICHT gespeichert (aus dist(a,b) beim Rendern
* abgeleitet).
*/
import { describe, it, expect } from "vitest";
import { dimensionCommand } from "./dimension";
import type { CommandContext, Project } from "../types";
import type { Drawing2D } from "../../model/types";
function project(extra: Drawing2D[] = []): 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: extra,
context: [],
};
}
function makeCtx(p: Project, selectedDrawingId: string | null = null): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: selectedDrawingId },
};
}
describe("dimensionCommand — neu zeichnen", () => {
it("zwei Punkte + Enter + Offset-Klick (links positiv) → ein shape:dimension mit korrektem Vorzeichen", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
// Cursor bei y=2 (oberhalb von a→b, entlang +x) → Links-Normale zeigt nach +y.
const [, result] = dimensionCommand.onInput(state, { kind: "point", point: { x: 5, y: 2 } }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("dimension");
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
expect(g.offset).toBeCloseTo(2, 6);
}
});
it("Fertig-Option (statt Enter) beendet die Punktreihe identisch", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "option", id: "finish" }, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1.5 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") expect(g.offset).toBe(1.5);
});
it("Kette aus drei Punkten (Reihenbemassung) → EIN Element mit drei Punkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 2 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
expect(g.offset).toBe(2);
}
});
it("dritter Punkt ausserhalb der Geraden wird auf sie projiziert (keine Polylinie, immer gerade)", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
// Richtung: entlang +x (y=0).
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
// Dritter Klick liegt bewusst NICHT auf der Geraden (y=3) → wird projiziert.
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 3 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts[2].y).toBeCloseTo(0, 6); // auf die Gerade y=0 projiziert
expect(g.pts[2].x).toBeCloseTo(10, 6); // x-Komponente bleibt (Projektion entlang x)
}
});
it("Zurück-Option entfernt den letzten Punkt", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 9, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "option", id: "undo" }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
});
it("entartete Kette (identische Punkte) committet nichts", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 3, y: 3 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(0);
});
it("Enter mit nur einem Punkt bricht ohne commit ab", () => {
const p = project();
const ctx = makeCtx(p);
const [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
const [, result] = dimensionCommand.onConfirm(state, ctx);
expect(result.commit).toBeUndefined();
expect(result.done).toBe(true);
});
});
describe("dimensionCommand — bestehende Kette ergänzen (erst selektieren, dann Befehl)", () => {
function chain(pts: { x: number; y: number }[], offset = 1): Drawing2D {
return {
id: "dim-existing",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "dimension", pts, offset },
};
}
it("Klick jenseits des letzten Punkts verlängert die Kette (dasselbe Element)", () => {
const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, "dim-existing");
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 10, y: 0 } },
ctx,
);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1); // kein neues Element
expect(next.drawings2d[0].id).toBe("dim-existing");
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
}
});
it("Klick vor dem ersten Punkt verlängert rückwärts (wird vorangestellt)", () => {
const existing = chain([{ x: 4, y: 0 }, { x: 10, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, "dim-existing");
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
}
});
it("Klick zwischen zwei bestehenden Punkten fügt DAZWISCHEN ein", () => {
const existing = chain([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, "dim-existing");
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 4, y: 0 } },
ctx,
);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
}
});
it("keine Selektion (drawingId null) startet stattdessen eine neue Kette", () => {
const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, null);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 20, y: 20 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 25, y: 20 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(2); // bestehendes Element bleibt, neues kommt dazu
});
});
-285
View File
@@ -1,285 +0,0 @@
// Bemassung (lineare Masslinie(n), SIA 400 B.5.3, Figur 14 „Reihenbemassung").
// Bedienung wie eine Polylinie (beliebig viele Klicks, Doppelklick/Enter
// beendet), das ERGEBNIS ist aber IMMER eine GERADE Punktreihe: der erste
// Klick setzt den Startpunkt, der zweite legt die Richtung fest — jeder
// weitere Klick wird auf diese Gerade projiziert (Reihenbemassung misst
// mehrere Punkte entlang EINER Referenzlinie, z. B. einer Wand, nie einen
// Zickzack-Pfad). Die ganze Kette ist EIN Element (`pts.length` ≥ 2).
//
// Neu zeichnen:
// 1) „Erster Punkt:" → Punkt
// 2) „Nächster Punkt ( Fertig Zurück ):" → Punkt … (ab dem 3. Punkt auf die
// durch Punkt 1/2 definierte Gerade projiziert)
// 3) „Lage der Masslinie:" → Punkt/Zahl → commit neues Element
//
// Ergänzen einer bestehenden Kette (Rhino-Muster: erst selektieren, dann
// Befehl — s. `ctx.selection`): ist beim Start eine `dimension`-Kette
// gewählt, lädt der Befehl deren Punkte/Offset vor und der erste Klick wird
// per Geraden-Projektion in die Kette EINSORTIERT (`insertIntoChain`) — vor
// dem ersten, nach dem letzten (verlängert) ODER zwischen zwei bestehenden
// Punkten (fügt ein). Der Commit ersetzt dasselbe Element (keine Kopie).
//
// Die Masszahl wird beim Rendern aus dist(a,b) abgeleitet (nicht gespeichert)
// — bleibt beim späteren Griff-Ziehen automatisch korrekt.
import type { Drawing2D } from "../../model/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
/** Vorzeichenbehafteter Normalabstand von `pt` zur Geraden a→b (links positiv). */
function signedOffset(a: Vec2, b: Vec2, pt: Vec2): number {
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return 0;
const nx = -dy / len, ny = dx / len;
return (pt.x - a.x) * nx + (pt.y - a.y) * ny;
}
/** Projiziert `pt` auf die Gerade durch `p0` in Richtung `p1` (Kette bleibt gerade ab Punkt 3). */
function projectOnLine(p0: Vec2, p1: Vec2, pt: Vec2): Vec2 {
const dx = p1.x - p0.x, dy = p1.y - p0.y;
const len2 = dx * dx + dy * dy;
if (len2 < EPS) return pt;
const t = ((pt.x - p0.x) * dx + (pt.y - p0.y) * dy) / len2;
return { x: p0.x + dx * t, y: p0.y + dy * t };
}
/**
* Sortiert `raw` (auf die Kettengerade projiziert) an die richtige Stelle in
* `pts` ein — VOR dem ersten (verlängert rückwärts), NACH dem letzten
* (verlängert vorwärts) oder ZWISCHEN zwei bestehenden Punkten (fügt ein).
* `pts` muss bereits kollinear sein (≥ 2 Punkte); die Skalarposition entlang
* der durch `pts[0]`/`pts[1]` festgelegten Geraden entscheidet die Reihenfolge.
*/
function insertIntoChain(pts: Vec2[], raw: Vec2): Vec2[] {
if (pts.length < 2) return [...pts, raw];
const p0 = pts[0], p1 = pts[1];
const dx = p1.x - p0.x, dy = p1.y - p0.y;
const len2 = dx * dx + dy * dy || 1;
const tOf = (p: Vec2): number => ((p.x - p0.x) * dx + (p.y - p0.y) * dy) / len2;
const placed = projectOnLine(p0, p1, raw);
const newT = tOf(placed);
let idx = pts.findIndex((p) => newT < tOf(p));
if (idx === -1) idx = pts.length;
const out = [...pts];
out.splice(idx, 0, placed);
return out;
}
/** Bestehende, selektierte Masskette (Rhino: erst selektieren, dann Befehl) oder null. */
function selectedChain(ctx: CommandContext): { id: string; pts: Vec2[]; offset: number } | null {
const id = ctx.selection.drawingId;
if (!id) return null;
const d = ctx.project.drawings2d.find((x) => x.id === id);
if (!d || d.geom.shape !== "dimension") return null;
return { id: d.id, pts: d.geom.pts, offset: d.geom.offset };
}
/** Endpunkte der um `offset` parallel verschobenen Masslinie (Strecke a-b). */
function offsetPoints(a: Vec2, b: Vec2, offset: number): { a: Vec2; b: Vec2 } {
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = (-dy / len) * offset, ny = (dx / len) * offset;
return { a: { x: a.x + nx, y: a.y + ny }, b: { x: b.x + nx, y: b.y + ny } };
}
interface DimStart extends CommandState {
phase: "start";
}
interface DimNext extends CommandState {
phase: "next";
pts: Vec2[];
cursor: Vec2 | null;
/** ID der bearbeiteten Kette (Ergänzen-Modus) — null = neue Kette. */
editId: string | null;
}
interface DimOffset extends CommandState {
phase: "offset";
pts: Vec2[];
cursor: Vec2 | null;
editId: string | null;
}
type DimState = DimStart | DimNext | DimOffset;
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.dimension.undo" };
const FINISH: CmdOption = { id: "finish", labelKey: "cmd.dimension.finish" };
const OFFSET_FIELDS: CommandField[] = [{ id: "offset", labelKey: "cmd.field.offset" }];
/** Vorschau: Punktreihe (Gummiband) während des Sammelns. */
function chainDraft(pts: Vec2[], cursor: Vec2 | null): ToolDraft {
const shown = cursor ? [...pts, cursor] : pts;
const draft: ToolDraft = { preview: [{ kind: "poly", pts: shown, closed: false }], vertices: pts };
const last = pts[pts.length - 1];
if (cursor && last && dist(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
/** Vorschau: alle Masslinien-Segmente bei gegebenem Offset (grob — Massstriche/Text rendert generatePlan). */
function offsetDraft(pts: Vec2[], offset: number, at: Vec2 | null): ToolDraft {
const preview: DraftShape[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
if (Math.abs(offset) > EPS) {
const off = offsetPoints(a, b, offset);
preview.push({ kind: "line", a, b: off.a });
preview.push({ kind: "line", a: b, b: off.b });
preview.push({ kind: "line", a: off.a, b: off.b });
} else {
preview.push({ kind: "line", a, b });
}
}
const draft: ToolDraft = { preview, vertices: pts };
if (at) {
const total = pts.reduce((sum, p, i) => (i === 0 ? 0 : sum + dist(pts[i - 1], p)), 0);
draft.hud = { at, text: `Σ${total.toFixed(3)}m · Δ${offset.toFixed(3)}m` };
}
return draft;
}
/** Committet eine Kette: neu anhängen (editId null) ODER dasselbe Element ersetzen. */
function commitChain(p: Project, pts: Vec2[], offset: number, editId: string | null, ctx: CommandContext): Project {
const cleaned = pts.filter((pt, i) => i === 0 || dist(pts[i - 1], pt) >= EPS);
if (cleaned.length < 2) return p; // zu wenig gültige Punkte → nichts committen/ändern
if (editId) {
return {
...p,
drawings2d: p.drawings2d.map((d) =>
d.id === editId ? { ...d, geom: { shape: "dimension", pts: cleaned, offset } } : d,
),
};
}
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "dimension", pts: cleaned, offset },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
];
export const dimensionCommand: Command = {
name: "dimension",
labelKey: "cmd.dimension.label",
prompt: (s) => {
const phase = (s as DimState).phase;
return phase === "next" ? "cmd.dimension.next" : phase === "offset" ? "cmd.dimension.offset" : "cmd.dimension.start";
},
accepts: (s) => ((s as DimState).phase === "offset" ? ["point", "number"] : ["point", "option"]),
options: (s) => {
const ds = s as DimState;
if (ds.phase !== "next") return [];
return ds.pts.length >= 2 ? [FINISH, UNDO] : [UNDO];
},
init: (): DimStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as DimState;
if (input.kind === "option" && s.phase === "next") {
if (input.id === "undo") {
const pts = s.pts.slice(0, -1);
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
const ns: DimNext = { phase: "next", pts, cursor: s.cursor, editId: s.editId, lastPoint: pts[pts.length - 1] };
return [ns, { draft: chainDraft(pts, s.cursor) }];
}
if (input.id === "finish" && s.pts.length >= 2) {
const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint };
return [ns, { draft: offsetDraft(s.pts, 0, null) }];
}
return [s, { draft: chainDraft(s.pts, s.cursor) }];
}
if (s.phase !== "offset") {
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase !== "next") {
// Erster Klick: bei selektierter Kette wird er EINSORTIERT (Ergänzen);
// sonst startet er eine neue Kette.
const existing = selectedChain(ctx);
const pts = existing ? insertIntoChain(existing.pts, pt) : [pt];
const editId = existing ? existing.id : null;
const ns: DimNext = { phase: "next", pts, cursor: pt, editId, lastPoint: pt };
return [ns, { draft: chainDraft(pts, pt) }];
}
// Ab dem dritten Punkt (bzw. jedem weiteren Klick im Ergänzen-Modus):
// auf die durch Punkt 1/2 festgelegte Gerade projizieren/einsortieren.
const pts = s.pts.length >= 2 ? insertIntoChain(s.pts, pt) : [...s.pts, pt];
const ns: DimNext = { phase: "next", pts, cursor: pt, editId: s.editId, lastPoint: pt };
return [ns, { draft: chainDraft(pts, pt) }];
}
// Offset-Schritt: Zahl = direkter Abstand, Punkt = Abstand zum Cursor.
let offset: number;
if (input.kind === "number") offset = input.value;
else if (input.kind === "point") offset = signedOffset(s.pts[0], s.pts[1], input.point);
else return [s, { draft: offsetDraft(s.pts, 0, null) }];
const pts = s.pts;
const editId = s.editId;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => commitChain(p, pts, offset, editId, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as DimState;
if (s.phase === "next") {
const cursor = s.pts.length >= 2 ? projectOnLine(s.pts[0], s.pts[1], point) : point;
const ns: DimNext = { ...s, cursor };
return [ns, { draft: chainDraft(s.pts, cursor) }];
}
if (s.phase === "offset") {
// Offset relativ zum ERSTEN Segment (repräsentativ für die ganze Kette).
const off = signedOffset(s.pts[0], s.pts[1], point);
const ns: DimOffset = { ...s, cursor: point };
return [ns, { draft: offsetDraft(s.pts, off, point) }];
}
return [s, { draft: null }];
},
// Enter/Space/Rechtsklick: im "next"-Schritt (≥2 Punkte) wie Doppelklick →
// Punktreihe abschliessen; im "offset"-Schritt bricht es ab (kein sinnvoller
// Default-Abstand ohne Zeigegeste).
onConfirm: (state): [CommandState, CommandResult] => {
const s = state as DimState;
if (s.phase === "next" && s.pts.length >= 2) {
const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint };
return [ns, { draft: offsetDraft(s.pts, 0, null) }];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
fields: (state) => ((state as DimState).phase === "offset" ? OFFSET_FIELDS : []),
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as DimState;
if (s.phase !== "offset" || !cursor) return {};
return { offset: signedOffset(s.pts[0], s.pts[1], cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as DimState;
if (s.phase !== "offset") return null;
const offset = "offset" in locks ? locks.offset : cursor ? signedOffset(s.pts[0], s.pts[1], cursor) : 0;
return offsetPoints(s.pts[0], s.pts[1], offset).a;
},
};
-88
View File
@@ -1,88 +0,0 @@
/**
* `ellipseCommand` — zwei Bounding-Box-Ecken → achsparallele Ellipse
* (shape:"ellipse", rotation 0).
*/
import { describe, it, expect } from "vitest";
import { ellipseCommand } from "./ellipse";
import type { CommandContext, Project } from "../types";
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(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("ellipseCommand", () => {
it("zwei Ecken → Zentrum + Halbachsen aus der Bounding-Box, rotation 0", () => {
const p = project();
const ctx = makeCtx(p);
const [afterCorner1] = ellipseCommand.onInput(
ellipseCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
const [, result] = ellipseCommand.onInput(
afterCorner1,
{ kind: "point", point: { x: 8, y: 4 } },
ctx,
);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("ellipse");
if (g.shape === "ellipse") {
expect(g.center).toEqual({ x: 4, y: 2 });
expect(g.rx).toBeCloseTo(4, 6);
expect(g.ry).toBeCloseTo(2, 6);
expect(g.rotation).toBe(0);
}
});
it("entartete Bbox (Punkt=Punkt) committet nichts", () => {
const p = project();
const ctx = makeCtx(p);
const [afterCorner1] = ellipseCommand.onInput(
ellipseCommand.init(),
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
const [, result] = ellipseCommand.onInput(
afterCorner1,
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(0);
});
});
-106
View File
@@ -1,106 +0,0 @@
// Ellipse — zwei gegenüberliegende Ecken einer Bounding-Box (achsparallel,
// wie das 2-Punkt-Rechteck). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"ellipse", rotation 0)
//
// Bewusst nur die einfache Bounding-Box-Methode (kein Dreh-Winkel über das
// Werkzeug) — `rotation` bleibt 0, das Feld existiert im Modell für spätere
// Dreh-Bearbeitung/andere Quellen (s. `model/types.ts`).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import { ellipsePts } from "../../tools/curves";
const EPS = 1e-6;
interface EllipseIdle extends CommandState {
phase: "corner1";
}
interface EllipseDrawing extends CommandState {
phase: "corner2";
p0: Vec2;
cursor: Vec2 | null;
}
type EllipseState = EllipseIdle | EllipseDrawing;
/** Zentrum + Halbachsen aus zwei gegenüberliegenden Bounding-Box-Ecken. */
function ellipseFromCorners(p0: Vec2, p1: Vec2): { center: Vec2; rx: number; ry: number } {
return {
center: { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 },
rx: Math.abs(p1.x - p0.x) / 2,
ry: Math.abs(p1.y - p0.y) / 2,
};
}
/** Vorschau (tessellierte Ellipse) + HUD (Breite×Höhe). */
function ellipseDraft(p0: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [p0] };
const { center, rx, ry } = ellipseFromCorners(p0, cursor);
if (rx < EPS || ry < EPS) return { preview: [], vertices: [p0] };
const preview: DraftShape[] = [{ kind: "poly", pts: ellipsePts(center, rx, ry, 0), closed: true }];
return {
preview,
vertices: [p0],
hud: { at: cursor, text: `${(rx * 2).toFixed(3)}×${(ry * 2).toFixed(3)}m` },
};
}
function appendEllipse(p: Project, center: Vec2, rx: number, ry: number, ctx: CommandContext): Project {
if (rx < EPS || ry < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "ellipse", center, rx, ry, rotation: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true },
];
export const ellipseCommand: Command = {
name: "ellipse",
labelKey: "cmd.ellipse.label",
prompt: (s) => ((s as EllipseState).phase === "corner2" ? "cmd.ellipse.corner2" : "cmd.ellipse.corner1"),
accepts: () => ["point"],
options: () => [],
init: (): EllipseIdle => ({ phase: "corner1", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as EllipseState;
if (input.kind !== "point") return [s, { draft: null }];
if (s.phase !== "corner2") {
const ns: EllipseDrawing = { phase: "corner2", p0: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: ellipseDraft(input.point, null) }];
}
const { center, rx, ry } = ellipseFromCorners(s.p0, input.point);
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendEllipse(p, center, rx, ry, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as EllipseState;
if (s.phase !== "corner2") return [s, { draft: null }];
const ns: EllipseDrawing = { ...s, cursor: point };
return [ns, { draft: ellipseDraft(s.p0, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
-85
View File
@@ -1,85 +0,0 @@
/**
* `extendCommand` — Gegenstück zu Trim (`extendSegment` im Kernel existierte
* bereits, hatte aber keinen Aufrufer). Test: eine kurze Linie wird bis zu
* einer kreuzenden Cutter-Linie verlängert.
*/
import { describe, it, expect } from "vitest";
import { extendCommand } from "./extend";
import type { CommandContext, Project } from "../types";
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: [
// Cutter: vertikale Linie bei x=5, deckt y=-10..10 ab.
{ id: "cutter", type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 5, y: -10 }, b: { x: 5, y: 10 } } },
// Ziel: kurze horizontale Linie von (0,0) bis (3,0) — reicht nicht bis zum Cutter.
{ id: "target", type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 3, y: 0 } } },
],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("extendCommand", () => {
it("verlängert das nähere Ende der Linie bis zum nächsten Cutter", () => {
const p = project();
const ctx = makeCtx(p);
const [, result] = extendCommand.onInput(
extendCommand.init(),
{ kind: "point", point: { x: 3, y: 0 } }, // nahe am Ende (3,0) der Ziellinie
ctx,
);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
const target = next.drawings2d.find((d) => d.id === "target")!;
expect(target.geom.shape).toBe("line");
if (target.geom.shape === "line") {
expect(target.geom.a).toEqual({ x: 0, y: 0 }); // unverändertes Ende bleibt stehen
expect(target.geom.b.x).toBeCloseTo(5, 9); // bis zum Cutter (x=5) verlängert
expect(target.geom.b.y).toBeCloseTo(0, 9);
}
});
it("kein Cutter getroffen → No-op (kein commit-Effekt)", () => {
const p = project();
// Cutter entfernen: nichts zum Verlängern-bis da.
p.drawings2d = p.drawings2d.filter((d) => d.id !== "cutter");
const ctx = makeCtx(p);
const [, result] = extendCommand.onInput(
extendCommand.init(),
{ kind: "point", point: { x: 3, y: 0 } },
ctx,
);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next).toBe(p); // unverändert (No-op)
});
});
-180
View File
@@ -1,180 +0,0 @@
// Extend — verlängert eine OFFENE 2D-Kurve (line/offene polyline) bis zur
// nächsten anderen Kurve (Rhino/AutoCAD-artig, Gegenstück zu Trim). Modell:
// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene
// (wie bei Trim — kein separater Cutter-Auswahlschritt).
// • Ein Schritt: der Nutzer klickt auf die zu verlängernde Kurve, NAHE dem
// Ende, das verlängert werden soll (das Ende, das dem Klick am nächsten
// liegt, wird verlängert — nicht zwingend das nächste per Cursor-Distanz
// zur ganzen Kurve, sondern zum jeweiligen Endpunkt).
// • Trifft die Verlängerung keinen Cutter, passiert nichts (kein Fehler,
// Befehl bleibt aktiv).
// • Wiederholend, bis Esc/Enter.
//
// Geschlossene Formen (rect, geschlossene polyline) und reine Punktformen
// (circle/arc/text) sind nicht extend-fähig — ein geschlossener Ring hat kein
// "Ende". Reine Geometrie liegt im Kernel (`extendSegment`); hier nur
// Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der Commit.
//
// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md).
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import {
extendSegment,
pointSegmentDistance,
polylineEdges,
} from "../../geometry/kernel2d";
import type {
Command,
CommandResult,
CommandState,
DraftShape,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine extend-fähige 2D-Form (offen) in eine Punktliste oder null. */
function geomToOpenCurve(g: Drawing2DGeom): Curve | null {
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
if (g.shape === "polyline" && !g.closed) return { pts: g.pts, closed: false };
return null; // rect/geschlossene polyline/circle/arc/text: kein Ende zum Verlängern
}
/** Alle trim/extend-fähigen Drawing2D der Ebene als Cutter-Kurven (auch geschlossene). */
function cuttersFor(project: Project, levelId: string, exceptId: string): Curve[] {
const out: Curve[] = [];
for (const d of project.drawings2d) {
if (d.id === exceptId) continue;
if (d.levelId !== levelId) continue;
const g = d.geom;
if (g.shape === "line") out.push({ pts: [g.a, g.b], closed: false });
else if (g.shape === "polyline") out.push({ pts: g.pts, closed: g.closed });
else if (g.shape === "rect") {
out.push({
pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }],
closed: true,
});
}
}
return out;
}
/** Nächste extend-fähige (offene) Drawing2D der Ebene zum Klickpunkt. */
function pickOpenCurveAt(project: Project, levelId: string, pt: Vec2): Drawing2D | null {
let best: Drawing2D | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const curve = geomToOpenCurve(d.geom);
if (!curve) continue;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
}
return best;
}
/**
* Verlängert die Kurve am Ende, das dem Klickpunkt am nächsten liegt, bis zum
* nächsten Schnitt mit den Cuttern (jenseits dieses Endes). Liefert die neuen
* Punkte, oder `null`, wenn kein Cutter getroffen wird (kein Effekt).
*/
function extendCurve(curve: Curve, cutters: Curve[], pick: Vec2): Vec2[] | null {
const n = curve.pts.length;
if (n < 2) return null;
const distStart = Math.hypot(pick.x - curve.pts[0].x, pick.y - curve.pts[0].y);
const distEnd = Math.hypot(pick.x - curve.pts[n - 1].x, pick.y - curve.pts[n - 1].y);
const extendStart = distStart <= distEnd;
const idxA = extendStart ? 1 : n - 2;
const idxB = extendStart ? 0 : n - 1;
const result = extendSegment(curve.pts[idxA], curve.pts[idxB], "end", cutters);
if (!result) return null;
const next = [...curve.pts];
next[idxB] = result[1];
return next;
}
/** Wendet Extend auf das Projekt an: ersetzt die getroffene Kurve durch ihre verlängerte Form. */
function applyExtend(p: Project, target: Drawing2D, pick: Vec2): Project {
const curve = geomToOpenCurve(target.geom);
if (!curve) return p;
const cutters = cuttersFor(p, target.levelId, target.id);
const next = extendCurve(curve, cutters, pick);
if (!next) return p;
const geom: Drawing2DGeom =
next.length === 2 && target.geom.shape === "line"
? { shape: "line", a: next[0], b: next[1] }
: { shape: "polyline", pts: next, closed: false };
return {
...p,
drawings2d: p.drawings2d.map((d) => (d.id === target.id ? { ...d, geom } : d)),
};
}
/** Vorschau-Form der verlängerten Kurve (für den Draft). */
function extendPreview(project: Project, target: Drawing2D, pick: Vec2): DraftShape[] {
const curve = geomToOpenCurve(target.geom);
if (!curve) return [];
const cutters = cuttersFor(project, target.levelId, target.id);
const next = extendCurve(curve, cutters, pick);
if (!next) return [];
return [{ kind: "poly", pts: next, closed: false }];
}
// ── Zustand ───────────────────────────────────────────────────────────────────
// Einziger Schritt „pick": auf die zu verlängernde Kurve (nahe dem Ende) klicken;
// wiederholend, bis Esc/Enter.
interface ExtendPick extends CommandState {
phase: "pick";
}
interface ExtendDone extends CommandState {
phase: "done";
}
type ExtendState = ExtendPick | ExtendDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExtendDone,
{ draft: null, done: true },
];
export const extendCommand: Command = {
name: "extend",
labelKey: "cmd.extend.label",
prompt: () => "cmd.extend.pick",
accepts: () => ["point"],
options: () => [],
init: (): ExtendPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ExtendState;
if (s.phase === "done") return finish();
if (input.kind !== "point") return [s, { draft: null }];
const target = pickOpenCurveAt(ctx.project, ctx.level.id, input.point);
if (!target) return [s, { draft: null }];
const pick = input.point;
return [s, { draft: null, commit: (p) => applyExtend(p, target, pick) }];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as ExtendState;
if (s.phase !== "pick") return [s, { draft: null }];
const target = pickOpenCurveAt(ctx.project, ctx.level.id, point);
if (!target) return [s, { draft: null }];
const preview = extendPreview(ctx.project, target, point);
return [s, { draft: { preview, vertices: [], hud: { at: point, text: "↗" } } }];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};
-103
View File
@@ -1,103 +0,0 @@
/**
* `filletCommand` — die Kernel-Geometrie (`filletCorner`) existierte bereits
* und war getestet, hatte aber KEINEN Aufrufer (kein Command, kein Tool).
* Test: eine rechtwinklige Polylinien-Ecke wird mit einem Radius verrundet.
*/
import { describe, it, expect } from "vitest";
import { filletCommand } from "./fillet";
import type { CommandContext, Project } from "../types";
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: [
// L-förmige offene Polylinie: (0,0) → (2,0) → (2,2). Ecke bei (2,0).
{
id: "poly",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "polyline", pts: [{ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 }], closed: false },
},
],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("filletCommand", () => {
it("verrundet die Ecke mit getipptem Radius (Zahl-Eingabe)", () => {
const p = project();
const ctx = makeCtx(p);
// Schritt 1: Ecke wählen (Klick nahe (2,0)).
const [afterPick, r1] = filletCommand.onInput(
filletCommand.init(),
{ kind: "point", point: { x: 2, y: 0 } },
ctx,
);
expect(r1.done).toBeFalsy();
// Schritt 2: Radius getippt (0.5m) → commit.
const [, r2] = filletCommand.onInput(afterPick, { kind: "number", value: 0.5 }, ctx);
expect(r2.done).toBe(true);
expect(r2.commit).toBeDefined();
const next = r2.commit!(p);
const poly = next.drawings2d.find((d) => d.id === "poly")!;
expect(poly.geom.shape).toBe("polyline");
if (poly.geom.shape !== "polyline") return;
const pts = poly.geom.pts;
// Die scharfe Ecke (2,0) darf nicht mehr exakt in den Punkten vorkommen —
// sie wurde durch den Bogen ersetzt.
expect(pts.some((p) => p.x === 2 && p.y === 0)).toBe(false);
// Anfangs-/Endpunkt der ganzen Kette bleiben unverändert.
expect(pts[0]).toEqual({ x: 0, y: 0 });
expect(pts[pts.length - 1]).toEqual({ x: 2, y: 2 });
// Die Tangentenpunkte (0.5m vom Eck entlang jedes Schenkels) müssen vorkommen.
const hasNear = (x: number, y: number) =>
pts.some((p) => Math.hypot(p.x - x, p.y - y) < 1e-6);
expect(hasNear(1.5, 0)).toBe(true); // Tangente auf dem ersten Schenkel
expect(hasNear(2, 0.5)).toBe(true); // Tangente auf dem zweiten Schenkel
});
it("daneben geklickt → Befehl bleibt im pick-Zustand (kein Treffer)", () => {
const p = project();
const ctx = makeCtx(p);
const [state, result] = filletCommand.onInput(
filletCommand.init(),
{ kind: "point", point: { x: 50, y: 50 } },
ctx,
);
expect(result.commit).toBeUndefined();
expect(result.done).toBeFalsy();
expect((state as { phase: string }).phase).toBe("pick");
});
});
-227
View File
@@ -1,227 +0,0 @@
// Fillet — verrundet die Ecke einer 2D-Kurve (polyline/rect) mit einem Radius
// (Rhino/AutoCAD-artig). Die reine Geometrie (`filletCorner`) existierte
// bereits im Kernel, hatte aber KEINEN Aufrufer — reine Verdrahtungsarbeit,
// kein neues Geometrie-Problem. Schritte:
// 1) „Ecke wählen:" → Punkt (nächstgelegener Eckpunkt einer polyline/rect
// wird gewählt; ein einzelnes `line`-Element hat keine eigene Ecke)
// 2) „Radius:" → Punkt (Abstand von der Ecke) ODER getippte Zahl → commit
//
// EINSCHRÄNKUNG (ehrlich, nicht verschwiegen): Polylinien kennen im Modell
// bisher keine Bogen-Segmente (kein „Bulge" — siehe PENDENZEN.md, 2D-
// Vervollständigung). Die Verrundung wird daher als kurze Punktfolge entlang
// des Bogens TESSELLIERT (approximiert), nicht als echtes Arc-Segment
// gespeichert — optisch rund, aber kein editierbares Bogen-Objekt. Ein rect
// wird beim ersten Fillet zu einer polyline (kein reines Rechteck mehr).
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { filletCorner, type Fillet } from "../../geometry/kernel2d";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const HIT_TOL = 0.3; // Modell-Meter: Pick-Toleranz für die Eckenwahl
const DEFAULT_RADIUS = 0.1; // m — Startwert, bis der Nutzer per Maus/Tab einen eigenen setzt
const EPS = 1e-6;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine fillet-fähige 2D-Form (polyline/rect) in eine Punktliste oder null. */
function geomToCurve(g: Drawing2DGeom): Curve | null {
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
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 }],
closed: true,
};
}
return null; // line (keine eigene Ecke), circle/arc/text: nicht fillet-fähig
}
/** Ein Treffer: das Element + der Eckpunkt-Index (mit gültigen beiden Nachbarn). */
interface CornerHit {
drawing: Drawing2D;
curve: Curve;
index: number;
}
/** Nächster Eckpunkt (mit zwei Nachbarn) einer fillet-fähigen Drawing2D zum Klickpunkt. */
function pickCornerAt(project: Project, levelId: string, pt: Vec2): CornerHit | null {
let best: CornerHit | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const curve = geomToCurve(d.geom);
if (!curve) continue;
const n = curve.pts.length;
for (let i = 0; i < n; i++) {
// Offene Polylinie: die beiden Endpunkte haben keinen zweiten Nachbarn.
if (!curve.closed && (i === 0 || i === n - 1)) continue;
const dd = dist(pt, curve.pts[i]);
if (dd < bestD) {
bestD = dd;
best = { drawing: d, curve, index: i };
}
}
}
return best;
}
/** Verrundungs-Geometrie für einen Treffer + Radius (oder null, falls unmöglich). */
function filletFor(hit: CornerHit, r: number): Fillet | null {
const n = hit.curve.pts.length;
const prev = hit.curve.pts[(hit.index - 1 + n) % n];
const next = hit.curve.pts[(hit.index + 1) % n];
const corner = hit.curve.pts[hit.index];
return filletCorner(corner, prev, next, r);
}
/** Bogen (center/radius/start-/endAngle) als kurze Punktfolge tesselliert (kürzester Bogen). */
function tessellateFillet(f: Fillet, segments = 12): Vec2[] {
const TAU = Math.PI * 2;
let delta = f.endAngle - f.startAngle;
while (delta > Math.PI) delta -= TAU;
while (delta < -Math.PI) delta += TAU;
const pts: Vec2[] = [];
for (let i = 0; i <= segments; i++) {
const a = f.startAngle + (delta * i) / segments;
pts.push({ x: f.center.x + f.radius * Math.cos(a), y: f.center.y + f.radius * Math.sin(a) });
}
return pts;
}
/** Ersetzt den Eckpunkt durch die tessellierte Bogen-Punktfolge (Tangente↔Tangente). */
function applyFilletPoints(hit: CornerHit, f: Fillet): Vec2[] {
const arc = tessellateFillet(f);
return [
...hit.curve.pts.slice(0, hit.index),
...arc,
...hit.curve.pts.slice(hit.index + 1),
];
}
function filletDraft(hit: CornerHit, r: number, at: Vec2 | null): ToolDraft {
const f = r > EPS ? filletFor(hit, r) : null;
const preview: DraftShape[] = f
? [{ kind: "poly", pts: tessellateFillet(f), closed: false }]
: [];
const draft: ToolDraft = { preview, vertices: [hit.curve.pts[hit.index]] };
if (at) draft.hud = { at, text: `R: ${r.toFixed(3)}m${f ? "" : " ✗"}` };
return draft;
}
function commitFillet(p: Project, hit: CornerHit, r: number): Project {
const f = filletFor(hit, r);
if (!f) return p; // Radius zu gross für die Schenkellänge, oder Ecke (fast) gerade → No-op
const pts = applyFilletPoints(hit, f);
const geom: Drawing2DGeom = { shape: "polyline", pts, closed: hit.curve.closed };
return {
...p,
drawings2d: p.drawings2d.map((d) => (d.id === hit.drawing.id ? { ...d, geom } : d)),
};
}
// ── Zustand ───────────────────────────────────────────────────────────────────
const RADIUS_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }];
interface FilletPick extends CommandState {
phase: "pick";
}
interface FilletRadius extends CommandState {
phase: "radius";
hit: CornerHit;
radius: number;
}
interface FilletDone extends CommandState {
phase: "done";
}
type FilletState = FilletPick | FilletRadius | FilletDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as FilletDone,
{ draft: null, done: true },
];
export const filletCommand: Command = {
name: "fillet",
labelKey: "cmd.fillet.label",
prompt: (s) => ((s as FilletState).phase === "radius" ? "cmd.fillet.radius" : "cmd.fillet.pick"),
accepts: (s) => ((s as FilletState).phase === "radius" ? ["point", "number"] : ["point"]),
options: () => [],
fields: (s) => ((s as FilletState).phase === "radius" ? RADIUS_FIELDS : []),
init: (): FilletPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => {
const s = state as FilletState;
if (s.phase === "done") return finish();
if (s.phase === "pick") {
if (input.kind !== "point") return [s, { draft: null }];
const hit = pickCornerAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }]; // daneben geklickt → aktiv bleiben
const next: FilletRadius = { phase: "radius", hit, radius: DEFAULT_RADIUS, lastPoint: input.point };
return [next, { draft: filletDraft(hit, next.radius, input.point) }];
}
// phase === "radius"
let r = s.radius;
if (input.kind === "number") r = Math.max(0, input.value);
else if (input.kind === "point") r = Math.max(0, dist(s.hit.curve.pts[s.hit.index], input.point));
else return [s, { draft: null }];
return [
{ phase: "done", lastPoint: null } as FilletDone,
{ draft: null, done: true, commit: (p) => commitFillet(p, s.hit, r) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as FilletState;
if (s.phase === "pick") {
const hit = pickCornerAt(ctx.project, ctx.level.id, point);
if (!hit) return [s, { draft: null }];
return [s, { draft: filletDraft(hit, DEFAULT_RADIUS, point) }];
}
if (s.phase !== "radius") return [s, { draft: null }];
const r = Math.max(0, dist(s.hit.curve.pts[s.hit.index], point));
const ns: FilletRadius = { ...s, radius: r };
return [ns, { draft: filletDraft(s.hit, r, point) }];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
// Tab-Feld-Zyklus nur im „radius"-Schritt (ein Radius-Feld), analog zu
// circle.ts: der gelockte Wert bestimmt den Punkt auf der Winkelhalbierenden
// Richtung Cursor (bzw. +X ohne Cursor), dist(Ecke, Punkt) = radius.
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as FilletState;
if (s.phase !== "radius" || !cursor) return {};
return { radius: dist(s.hit.curve.pts[s.hit.index], cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as FilletState;
if (s.phase !== "radius") return null;
const corner = s.hit.curve.pts[s.hit.index];
const r = "radius" in locks ? Math.max(0, locks.radius) : cursor ? dist(corner, cursor) : 0;
if (cursor) {
const d = dist(corner, cursor);
if (d > EPS) {
return {
x: corner.x + ((cursor.x - corner.x) / d) * r,
y: corner.y + ((cursor.y - corner.y) / d) * r,
};
}
}
return { x: corner.x + r, y: corner.y };
},
};
-86
View File
@@ -1,86 +0,0 @@
/**
* `koteCommand` — Punkt + getippter Höhenwert → `shape:"kote"` (SIA 400
* B.5.4, Figur 18). Default-Art „OK fertig"; per Option zyklisch umschaltbar.
*/
import { describe, it, expect } from "vitest";
import { koteCommand } from "./kote";
import type { CommandContext, Project } from "../types";
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(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("koteCommand", () => {
it("Punkt + Zahl → shape:kote mit Default-Art OK fertig, rotation 0", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 2, y: 3 } }, ctx);
const [, result] = koteCommand.onInput(state, { kind: "number", value: 3.25 }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("kote");
if (g.shape === "kote") {
expect(g.at).toEqual({ x: 2, y: 3 });
expect(g.value).toBe(3.25);
expect(g.kind).toBe("okFertig");
expect(g.rotation).toBe(0);
}
});
it("Art-Option zyklt OK fertig → UK fertig → OK roh → UK roh → OK fertig", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
const seen: string[] = [];
for (let i = 0; i < 4; i++) {
[state] = koteCommand.onInput(state, { kind: "option", id: "kind" }, ctx);
const opts = koteCommand.options(state);
seen.push(opts[0].value!);
}
expect(seen).toEqual(["ukFertig", "okRoh", "ukRoh", "okFertig"]);
});
it("negativer Wert wird unverändert übernommen (Formatierung passiert erst beim Rendern)", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
const [, result] = koteCommand.onInput(state, { kind: "number", value: -0.1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "kote") expect(g.value).toBe(-0.1);
});
});
-111
View File
@@ -1,111 +0,0 @@
// Höhenkote (SIA 400 B.5.4, Figur 18) — Dreieck-Symbol + Höhenwert an einem
// Punkt. Schritte:
// 1) „Punkt:" → Punkt (Dreieck-Spitze)
// 2) „Höhenwert ( Art ):" → Zahl (Meter, z. B. 3.25 oder -0.1) →
// commit. „Art" (Option, zyklisch: OK fertig/UK fertig/OK roh/UK roh)
// wechselt VOR dem Tippen die Dreieck-Variante (Default OK fertig).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import type { TranslationKey } from "../../i18n";
type KoteKind = "okFertig" | "ukFertig" | "okRoh" | "ukRoh";
const KIND_ORDER: KoteKind[] = ["okFertig", "ukFertig", "okRoh", "ukRoh"];
const nextKind = (k: KoteKind): KoteKind => KIND_ORDER[(KIND_ORDER.indexOf(k) + 1) % KIND_ORDER.length];
const KIND_LABEL: Record<KoteKind, TranslationKey> = {
okFertig: "cmd.kote.kind.okFertig",
ukFertig: "cmd.kote.kind.ukFertig",
okRoh: "cmd.kote.kind.okRoh",
ukRoh: "cmd.kote.kind.ukRoh",
};
const TRI_SIZE = 0.15; // Modell-Meter, Vorschau-Grösse (Rendering: DIM_… in drawing2d.ts)
const HALF_W = TRI_SIZE * 0.6;
/** Dreieck-Punkte für die Vorschau: `at` = Spitze; OK zeigt nach unten, UK nach oben. */
function triPts(at: Vec2, kind: KoteKind): Vec2[] {
const up = kind === "okFertig" || kind === "okRoh" ? 1 : -1; // Basis liegt in +up-Richtung von `at`
return [
at,
{ x: at.x - HALF_W, y: at.y + up * TRI_SIZE },
{ x: at.x + HALF_W, y: at.y + up * TRI_SIZE },
];
}
interface KotePoint extends CommandState {
phase: "point";
}
interface KoteValue extends CommandState {
phase: "value";
at: Vec2;
kind: KoteKind;
}
type KoteState = KotePoint | KoteValue;
function kindOption(kind: KoteKind): CmdOption {
return { id: "kind", labelKey: KIND_LABEL[kind], value: kind };
}
function valueDraft(at: Vec2, kind: KoteKind): ToolDraft {
const preview: DraftShape[] = [{ kind: "poly", pts: triPts(at, kind), closed: true }];
return { preview, vertices: [at] };
}
function appendKote(p: Project, at: Vec2, value: number, kind: KoteKind, ctx: CommandContext): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "kote", at, value, kind, rotation: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null },
{ draft: null, done: true },
];
export const koteCommand: Command = {
name: "kote",
labelKey: "cmd.kote.label",
prompt: (s) => ((s as KoteState).phase === "value" ? "cmd.kote.value" : "cmd.kote.point"),
accepts: (s) => ((s as KoteState).phase === "value" ? ["number", "option"] : ["point"]),
options: (s) => ((s as KoteState).phase === "value" ? [kindOption((s as KoteValue).kind)] : []),
init: (): KotePoint => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as KoteState;
if (s.phase === "point") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: KoteValue = { phase: "value", at: input.point, kind: "okFertig", lastPoint: input.point };
return [ns, { draft: valueDraft(input.point, "okFertig") }];
}
if (input.kind === "option" && input.id === "kind") {
const ns: KoteValue = { ...s, kind: nextKind(s.kind) };
return [ns, { draft: valueDraft(s.at, ns.kind) }];
}
if (input.kind !== "number") return [s, { draft: valueDraft(s.at, s.kind) }];
const { at, kind } = s;
return [
{ phase: "point", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendKote(p, at, input.value, kind, ctx) },
];
},
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
-2
View File
@@ -35,7 +35,6 @@ function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
@@ -46,7 +45,6 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
-2
View File
@@ -52,7 +52,6 @@ function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
@@ -63,7 +62,6 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
-101
View File
@@ -1,101 +0,0 @@
/**
* `multilineCommand` — zeichnet eine Basislinie + N-1 parallele Kopien in
* einer Geste (baut auf `offsetSegment` auf, wie der bestehende `offset`-
* Befehl). Test: Start → Ende → Abstand (getippt) → commit erzeugt die
* erwartete Anzahl paralleler Linien.
*/
import { describe, it, expect } from "vitest";
import { multilineCommand } from "./multiline";
import type { CommandContext, Project } from "../types";
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(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("multilineCommand", () => {
it("Basislinie + 2 Kopien (Default-Anzahl 3) bei getipptem Abstand", () => {
const p = project();
const ctx = makeCtx(p);
const [afterStart] = multilineCommand.onInput(
multilineCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
const [afterEnd] = multilineCommand.onInput(
afterStart,
{ kind: "point", point: { x: 10, y: 0 } },
ctx,
);
const [, result] = multilineCommand.onInput(afterEnd, { kind: "number", value: 1 }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(3); // Basislinie + 2 Kopien (Default-Count)
for (const d of next.drawings2d) {
expect(d.geom.shape).toBe("line");
if (d.geom.shape === "line") {
// Alle Linien laufen horizontal von x=0 bis x=10 (nur y verschoben).
expect(d.geom.a.x).toBeCloseTo(0, 9);
expect(d.geom.b.x).toBeCloseTo(10, 9);
expect(d.geom.a.y).toBeCloseTo(d.geom.b.y, 9);
}
}
// Die y-Werte sind 0, ±1, ±2 (je nach Seite) — auf jeden Fall drei
// unterschiedliche Werte im Abstand von genau 1m.
const ys = next.drawings2d
.map((d) => (d.geom.shape === "line" ? d.geom.a.y : NaN))
.sort((a, b) => a - b);
expect(ys[1] - ys[0]).toBeCloseTo(1, 9);
expect(ys[2] - ys[1]).toBeCloseTo(1, 9);
});
it("Null-Strecke (Start=Ende) → No-op, Befehl beendet ohne Elemente", () => {
const p = project();
const ctx = makeCtx(p);
const [afterStart] = multilineCommand.onInput(
multilineCommand.init(),
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
const [, result] = multilineCommand.onInput(
afterStart,
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
expect(result.done).toBe(true);
expect(result.commit).toBeUndefined();
});
});
-227
View File
@@ -1,227 +0,0 @@
// Multiline — zeichnet in EINER Geste eine gerade Basislinie PLUS N−1 dazu
// parallel versetzte Kopien (gleicher Abstand, gleiche Seite), z. B. für
// Doppellinien/Leitungstrassen/Abstandslinien. Baut auf derselben Offset-
// Geometrie wie der bestehende `offset`-Befehl auf (`offsetSegment`), erzeugt
// aber alle Kopien auf einmal statt einzeln nacheinander offsetten zu müssen.
// Schritte:
// 1) „Startpunkt:" → Punkt
// 2) „Endpunkt:" → Punkt (Tab-Felder Länge/Winkel wie bei Line)
// 3) „Abstand/Seite:" → Distanz tippen (persistent über Aufrufe, wie bei
// Offset) ODER Seite per Klick (Vorzeichen aus der Klickseite) → commit
// ALLER `count` Linien (Basislinie + count−1 Kopien bei d, 2d, …).
// Die Anzahl `count` zyklt über eine Inline-Option (2..6, dann zurück zu 2).
//
// Reine gerade Basislinien (kein Polylinien-Multiline) — deckt den häufigsten
// Anwendungsfall ab (Grenzlinie+Abstand, Leitungspaar, Randlinien), ohne die
// Komplexität einer mehrsegmentigen Variante.
import type { Drawing2D } from "../../model/types";
import { segmentHud, uniqueId } from "../../tools/types";
import { leftNormal, normalize, sub } from "../../model/geometry";
import { offsetSegment } from "../../geometry/kernel2d";
import type {
CmdOption,
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const MIN_COUNT = 2;
const MAX_COUNT = 6;
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;
/** Persistente Einstellungen über Aufrufe hinweg (wie `lastDistance` bei Offset). */
let lastSpacing = 0.3;
let lastSign = 1; // +1 = links, −1 = rechts
let lastCount = 3;
const LINE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
function endFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? a;
const length = "length" in locks ? locks.length : segLen(a, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
const ang = angleDeg * DEG;
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
}
/** Vorzeichen (+links / −rechts) für einen Klickpunkt relativ zur Basislinie a→b. */
function sideSign(a: Vec2, b: Vec2, pt: Vec2): number {
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const u = normalize(sub(b, a));
const n = leftNormal(u);
const rel = sub(pt, mid);
return rel.x * n.x + rel.y * n.y >= 0 ? 1 : -1;
}
/** Die `count` parallelen Linien (Index 0 = Basislinie) als Punktpaare. */
function multilinePairs(a: Vec2, b: Vec2, spacing: number, sign: number, count: number): [Vec2, Vec2][] {
const out: [Vec2, Vec2][] = [[a, b]];
for (let k = 1; k < count; k++) {
out.push(offsetSegment(a, b, sign * spacing * k));
}
return out;
}
function appendMultiline(p: Project, a: Vec2, b: Vec2, spacing: number, sign: number, count: number, ctx: CommandContext): Project {
if (segLen(a, b) < EPS) return p;
const pairs = multilinePairs(a, b, spacing, sign, count);
const copies: Drawing2D[] = pairs.map(([pa, pb]) => ({
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "line", a: pa, b: pb },
}));
return { ...p, drawings2d: [...p.drawings2d, ...copies] };
}
const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.multiline.count" };
// ── Zustand ───────────────────────────────────────────────────────────────────
interface MlStart extends CommandState {
phase: "start";
}
interface MlEnd extends CommandState {
phase: "end";
a: Vec2;
cursor: Vec2 | null;
}
interface MlSpacing extends CommandState {
phase: "spacing";
a: Vec2;
b: Vec2;
cursor: Vec2 | null;
}
type MlState = MlStart | MlEnd | MlSpacing;
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as MlStart,
{ draft: null, done: true },
];
/** Vorschau aller `lastCount` Linien bei gegebenem Abstand+Seite. */
function spacingDraft(a: Vec2, b: Vec2, spacing: number, sign: number, at: Vec2 | null): ToolDraft {
const pairs = multilinePairs(a, b, spacing, sign, lastCount);
const preview: DraftShape[] = pairs.map(([pa, pb]) => ({ kind: "line", a: pa, b: pb }));
const draft: ToolDraft = { preview, vertices: [a, b] };
if (at) draft.hud = { at, text: `${spacing.toFixed(2)} m × ${lastCount}` };
return draft;
}
export const multilineCommand: Command = {
name: "multiline",
labelKey: "cmd.multiline.label",
prompt: (s) => {
const ms = s as MlState;
if (ms.phase === "end") return "cmd.multiline.end";
if (ms.phase === "spacing") return "cmd.multiline.spacing";
return "cmd.multiline.start";
},
accepts: (s) => ((s as MlState).phase === "spacing" ? ["point", "number", "option"] : ["point"]),
options: (s) => ((s as MlState).phase === "spacing" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : []),
init: (): MlStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => {
const s = state as MlState;
if (s.phase === "start") {
if (input.kind !== "point") return [s, { draft: null }];
const next: MlEnd = { phase: "end", a: input.point, cursor: input.point, lastPoint: input.point };
return [next, { draft: { preview: [], vertices: [input.point] } }];
}
if (s.phase === "end") {
if (input.kind !== "point") return [s, { draft: spacingDraftForEnd(s) }];
if (segLen(s.a, input.point) < EPS) return idle();
const next: MlSpacing = { phase: "spacing", a: s.a, b: input.point, cursor: input.point, lastPoint: input.point };
return [next, { draft: spacingDraft(next.a, next.b, lastSpacing, lastSign, input.point) }];
}
// phase === "spacing"
if (input.kind === "option") {
if (input.id === "count") {
lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1;
const sign = s.cursor ? sideSign(s.a, s.b, s.cursor) : lastSign;
return [s, { draft: spacingDraft(s.a, s.b, lastSpacing, sign, s.cursor) }];
}
return [s, { draft: null }];
}
if (input.kind === "number") {
const mag = Math.abs(input.value);
if (mag < EPS) return [s, { draft: null }];
lastSpacing = mag;
lastSign = input.value < 0 ? -1 : lastSign;
const { a, b } = s;
return [
idle()[0],
{ draft: null, done: true, commit: (p) => appendMultiline(p, a, b, lastSpacing, lastSign, lastCount, ctx) },
];
}
if (input.kind === "point") {
lastSign = sideSign(s.a, s.b, input.point);
const { a, b } = s;
return [
idle()[0],
{ draft: null, done: true, commit: (p) => appendMultiline(p, a, b, lastSpacing, lastSign, lastCount, ctx) },
];
}
return [s, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as MlState;
if (s.phase === "end") {
const next: MlEnd = { ...s, cursor: point };
const preview: DraftShape[] = segLen(s.a, point) >= EPS ? [{ kind: "line", a: s.a, b: point }] : [];
const draft: ToolDraft = { preview, vertices: [s.a] };
if (segLen(s.a, point) >= EPS) draft.hud = segmentHud(s.a, point);
return [next, { draft }];
}
if (s.phase === "spacing") {
const sign = sideSign(s.a, s.b, point);
const next: MlSpacing = { ...s, cursor: point };
return [next, { draft: spacingDraft(s.a, s.b, lastSpacing, sign, point) }];
}
return [s, { draft: null }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „end"-Schritt (Basislinie): Länge + Winkel, wie line.ts.
fields: (state) => ((state as MlState).phase === "end" ? LINE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as MlState;
if (s.phase !== "end") return null;
return endFromFields(s.a, locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as MlState;
if (s.phase !== "end" || !cursor) return {};
return {
length: segLen(s.a, cursor),
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
};
},
};
/** Vorschau der Basislinie im „end"-Schritt (Fallback für Nicht-Punkt-Eingaben). */
function spacingDraftForEnd(s: MlEnd): ToolDraft {
if (!s.cursor || segLen(s.a, s.cursor) < EPS) return { preview: [], vertices: [s.a] };
return { preview: [{ kind: "line", a: s.a, b: s.cursor }], vertices: [s.a], hud: segmentHud(s.a, s.cursor) };
}
-77
View File
@@ -1,77 +0,0 @@
/**
* `splineCommand` — Wegpunkte klicken (wie polyline), Enter/„Schliessen"
* committet als `shape:"spline"` mit den ROHEN Wegpunkten (Tessellierung
* passiert erst beim Rendern/Snapping, s. `tools/curves.ts`).
*/
import { describe, it, expect } from "vitest";
import { splineCommand } from "./spline";
import type { CommandContext, Project } from "../types";
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(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("splineCommand", () => {
it("drei Punkte + Enter → offene Spline mit den drei Wegpunkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 2, y: 3 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 5, y: 1 } }, ctx);
const [, result] = splineCommand.onConfirm(state, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("spline");
if (g.shape === "spline") {
expect(g.closed).toBe(false);
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }]);
}
});
it("Schliessen-Option committet als Ring (closed: true)", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 4 } }, ctx);
const [, result] = splineCommand.onInput(state, { kind: "option", id: "close" }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("spline");
if (g.shape === "spline") expect(g.closed).toBe(true);
});
});
-160
View File
@@ -1,160 +0,0 @@
// Spline — glatte Kurve DURCH mehrere Wegpunkte (uniformer Catmull-Rom),
// Bedienung 1:1 wie `polyline` (s. dort), nur dass die Vorschau/das Ergebnis
// zwischen den Punkten rundet statt gerade Kanten zu ziehen. Schritte:
// 1) „Startpunkt:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
import type { Drawing2D } from "../../model/types";
import { segmentHud, uniqueId } from "../../tools/types";
import { sampleCatmullRom } from "../../tools/curves";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Zug
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
interface SplineIdle extends CommandState {
phase: "start";
closed: boolean;
}
interface SplineDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
closed: boolean;
}
type SplineState = SplineIdle | SplineDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
const closedOption = (closed: boolean): CmdOption => ({
id: "mode",
labelKey: "cmd.polyline.closedOpt",
value: closed ? "on" : "off",
});
/** Vorschau (geglätteter Zug + Gummiband zum Cursor) + HUD. */
function splineDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = closed && pts.length >= 3;
const curve = sampleCatmullRom(pts, showClosed);
const preview: DraftShape[] = [{ kind: "poly", pts: curve, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
function appendSpline(p: Project, pts: Vec2[], closed: boolean, ctx: CommandContext): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "spline", pts, closed },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (closed = false): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, closed } as SplineIdle,
{ draft: null, done: true },
];
export const splineCommand: Command = {
name: "spline",
labelKey: "cmd.spline.label",
prompt: (s) => ((s as SplineState).phase === "next" ? "cmd.spline.next" : "cmd.spline.start"),
accepts: () => ["point", "option"],
options: (s) => {
const ps = s as SplineState;
const toggle = closedOption(ps.closed);
if (ps.phase !== "next") return [toggle];
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
},
init: (): SplineIdle => ({ phase: "start", lastPoint: null, closed: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as SplineState;
if (input.kind === "option") {
if (input.id === "mode") {
const ns = { ...s, closed: !s.closed } as SplineState;
return [ns, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, ns.closed) : null }];
}
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle, { draft: null }];
const ns: SplineDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
closed: s.closed,
};
return [ns, { draft: splineDraft(pts, s.cursor, s.closed) }];
}
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
];
}
return [s, { draft: splineDraft(s.points, s.cursor, s.closed) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, s.closed) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: SplineDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: splineDraft([pt], pt, s.closed) }];
}
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
];
}
const points = [...s.points, pt];
const ns: SplineDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: splineDraft(points, pt, s.closed) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as SplineState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: SplineDrawing = { ...s, cursor: point };
return [ns, { draft: splineDraft(s.points, point, s.closed) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as SplineState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const close = s.closed && pts.length >= 3;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, close, ctx) },
];
}
return idle(s.closed);
},
onCancel: (state): [CommandState, CommandResult] => idle((state as SplineState).closed),
};
-71
View File
@@ -1,71 +0,0 @@
/**
* `textCommand` — committet seit der Umstellung auf Inline-Editing (kein
* Dialog/keine Befehlszeilen-Eingabe mehr, Nutzer-Wunsch) SOFORT ein leeres
* Text-Element nach dem Ankerpunkt und meldet `focusDrawingId`, damit die UI
* (PlanView) direkt den Inline-Rich-Text-Editor öffnet.
*/
import { describe, it, expect } from "vitest";
import { textCommand } from "./text";
import type { CommandContext, Project } from "../types";
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(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("textCommand", () => {
it("committet sofort ein leeres Text-Element und meldet focusDrawingId", () => {
const p = project();
const ctx = makeCtx(p);
const [, result] = textCommand.onInput(
textCommand.init(),
{ kind: "point", point: { x: 3, y: 4 } },
ctx,
);
expect(result.done).toBe(true);
expect(result.commit).toBeDefined();
expect(result.focusDrawingId).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const d = next.drawings2d[0];
expect(d.id).toBe(result.focusDrawingId); // dieselbe ID wie im Commit erzeugt
expect(d.geom.shape).toBe("text");
if (d.geom.shape === "text") {
expect(d.geom.text).toBe("");
expect(d.geom.at).toEqual({ x: 3, y: 4 });
expect(d.geom.width).toBeUndefined(); // Text-Werkzeug: KEIN Wortumbruch
}
});
});
+34 -24
View File
@@ -1,16 +1,21 @@
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
// Committet SOFORT ein leeres Text-Element und meldet `focusDrawingId` —
// die UI (PlanView) öffnet daraufhin direkt den Inline-Rich-Text-Editor AUF
// der Zeichenfläche (kein Dialog, keine Befehlszeilen-Eingabe). Ohne
// Spaltenbreite bricht der Text NICHT automatisch um: einzeilig, bis der
// Nutzer selbst Enter drückt (siehe `textbox` fürs InDesign-artige
// Wortumbruch-Textfeld). Bleibt der Editor leer, verwirft `commitTextEdit`
// (App.tsx) das Element wieder.
// 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";
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;
@@ -18,45 +23,50 @@ const DEFAULT_TEXT_HEIGHT_M = 0.25;
interface TextIdle extends CommandState {
phase: "point";
}
type TextState = TextIdle;
interface TextLabel extends CommandState {
phase: "label";
at: Vec2;
}
type TextState = TextIdle | TextLabel;
function appendEmptyText(p: Project, id: string, at: Vec2, ctx: CommandContext): Project {
function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id,
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: "", height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
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 } as TextIdle,
{ phase: "point", lastPoint: null },
{ draft: null, done: true },
];
export const textCommand: Command = {
name: "text",
labelKey: "cmd.text.label",
prompt: () => "cmd.text.point",
accepts: () => ["point"],
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 id = uniqueId("dr2d");
const at = input.point;
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 } as TextIdle,
{
draft: null,
done: true,
commit: (p) => appendEmptyText(p, id, at, ctx),
focusDrawingId: id,
},
{ phase: "point", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) },
];
},

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