46 Commits

Author SHA1 Message Date
karim 3402e6ac96 HANDOVER: Übergabe an neue Instanz — Nordstern 2/3/4/5 gelandet, Schnitt-UI-Anbindung offen 2026-07-03 18:18:52 +02:00
karim b4c4a2cc6b 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 382771b2e7 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 a8e491e534 HANDOVER: Stand Nordstern-Session 2026-07-03 (Engine-Meilensteine, offene Agent-Arbeit) 2026-07-03 08:42:27 +02:00
karim 76029dbf68 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 4b02e40238 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 ce6bd26ff7 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 54211b1443 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 c8a4188618 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 7ca5197b6b 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 a010584d9b 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 e6d061cc91 README: keine Browser-App mehr, sondern Electron-Desktop-Shell mit eigener Engine 2026-07-03 00:10:24 +02:00
karim 0161b0231d 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 34317e53f4 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 27e41077b1 Renderer-Umschalter: Wahl in localStorage persistieren (uebersteht Neustart ohne URL-Param) 2026-07-02 23:38:05 +02:00
karim a37b1c0cc9 UI: Renderer-Umschalter (WebGL2 / eigene Engine) in der Statusleiste 2026-07-02 23:35:01 +02:00
karim 4eb635cc89 WASM-Viewport: SVG-Raumtext bei aktiver Engine unterdruecken (kein Doppeltext) 2026-07-02 23:28:25 +02:00
karim 4734c04ec0 HANDOVER: naechster Schritt = Electron-Prototyp (Rezept fuer neue Session) 2026-07-02 23:18:27 +02:00
karim 2f971a54c0 HANDOVER: Richtungsentscheidung Electron/Chromium+WASM statt Tauri; all-native als Fernziel 2026-07-02 23:02:35 +02:00
karim 8cfe6c2521 HANDOVER: Engine-Nordstern — exakte Breiten, Druck=Bildschirm, Headless, HLR, WebKit-Unabhaengigkeit 2026-07-02 22:31:27 +02:00
karim 665cbce1f9 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 6eead6d493 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 7b9f72e79f 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 cd0f976b4f 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 919bc67bfa 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 ec5cbf6fa0 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 f1b802299f 3D-Wände mit Öffnungen: Türen/Fenster als Teilquader (Pfeiler+Brüstung+Sturz) 2026-07-02 21:00:19 +02:00
karim 0337422ae6 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 d39244989b 2D-Umrisse polygonuebergreifend naehen: Gehrung an Wand-zu-Wand-Ecken 2026-07-02 20:12:29 +02:00
karim 01424c1e22 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 1811e82005 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 0a1aaedef8 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 483ad3f278 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 d27622d581 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 f315c80748 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 bda256d3a4 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 c93b168f5c 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 a971cd610b 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 48bbcaed1e 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 6d4b4e66c1 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 27bb5a42ec 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 8fd8987b70 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 7b3b597abc 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 4a08161e4b Add parametric walls design documentation 2026-07-01 20:32:07 +02:00
karim 39236c65c7 Add parametric wall types and engine skeleton 2026-07-01 20:06:19 +02:00
karim b9dc1838c7 HANDOVER: Koordinations- und Memory-Regeln fuer mehrere Instanzen/Agents ergaenzt 2026-06-30 21:45:10 +02:00
247 changed files with 55546 additions and 744 deletions
+554 -8
View File
@@ -15,7 +15,486 @@ Code/Doku noch in der Git-Historie.
Wer hier weiterarbeitet, schreibt so, als wäre es Handarbeit des Teams. Wer hier weiterarbeitet, schreibt so, als wäre es Handarbeit des Teams.
- Vor jedem Push kurz prüfen: `git log` und `git diff` frei von solchen Spuren. - Vor jedem Push kurz prüfen: `git log` und `git diff` frei von solchen Spuren.
## >>> STAND 2026-06-30 (Fortsetzung, spät) — ZUERST lesen <<< ## >>> KOORDINATION & MEMORY (bei mehreren Instanzen/Agents) <<<
**Mehrere Hauptinstanzen gleichzeitig:**
- Git: nur **eine** Instanz committet/pusht auf `master`, ODER jede arbeitet auf
eigenem Branch und merged kontrolliert. `App.tsx`/`types.ts` ist der serielle
Flaschenhals — verschiedene Features ≠ dieselbe Datei.
- Dieser HANDOVER ist der **Koordinationskanal** zwischen Instanzen (liegt im
Repo, wird mitgepusht). Stand hier kurz festhalten, bevor du übergibst.
**Subagents:**
- Bekommen **kein** Memory automatisch — nur was im Prompt steht. Regeln (v. a.
die COMMIT-REGEL oben) explizit mitgeben, sonst kennt der Agent sie nicht.
- Agents **nicht** committen und **nicht** ins Memory schreiben lassen. Sie
liefern Diffs/Dateien/Ergebnisse zurück; die Hauptinstanz committet und pflegt
das Gedächtnis.
**Memory (`~/.claude/...`, außerhalb des Repos — leakt nie hierher):**
- In den Kontext geladen wird nur der schlanke **Index** (eine Zeile je Eintrag);
einzelne Fakten erscheinen nur bei Relevanz. Größe ist daher selten ein Problem.
- **Keine automatische Bereinigung.** Gepflegt wird beim Schreiben (Duplikate
aktualisieren statt anlegen, Überholtes löschen) oder auf Ansage.
- Nur **beständige** Fakten ablegen, ein Fakt pro Datei, Index-Zeile knapp. Bei
parallelen Schreibvorgängen ist der Index (MEMORY.md) die Contention-Stelle —
vor dem Edit frisch lesen (der „modified since read"-Guard verhindert blindes
Überschreiben).
## >>> STAND 2026-07-03 Abend (Nordstern-Session, Übergabe an neue Instanz) — ZUERST LESEN <<<
Ein Master-Branch (alle Feature-/Worktree-Branches konsolidiert und gelöscht), alles auf
origin/master gepusht, Working Tree sauber bis auf eine vorbestehende, unabhängige
Materials-Library-WIP (`.gitignore`, `public/assets/materials/manifest.json`,
`src/materials/library.ts`, `scripts/fetch-materials.mjs` — NICHT anfassen/reverten, ist
fremde in Arbeit befindliche Sache, nicht Teil der Engine-Session). Heute gelandet, acht
Commits in dieser Reihenfolge:
1. **7ca5197** Resource Manager: Materialien-Tab (PBR-Kugel-Vorschau via geteiltem Offscreen-
Renderer, Suche + Kategorie-Chips, lazy per IntersectionObserver).
2. **c8a4188** render3d als WASM/WebGPU-Viewport in der App (`?engine=wasm` bzw. localStorage
`cad.rendererMode`; three.js bleibt Default). Probe: scripts/probe-engine3d.mjs (HEADFUL=1
nötig — headless Chromium präsentiert keine WebGPU-Frames, gilt auch für render2d).
3. **54211b1 + 76029db** render3d/src/section.rs: Schnittebene → Cut-Polygone + sichtbare/
verdeckte projizierte Kanten, inkl. Öffnungen (Brüstung/Sturz-Teilrechtecke, Durchblick).
29 Tests, Beweis-SVG docs/welle-c-hlr-spike/section-engine-proof.svg, Doku
docs/design/engine-section-pipeline.md. Eigen-Engine-Ersatz für den OCCT-Pfad (66 MB WASM).
**mesh.rs (3D-Vollkörper-Extrusion) berücksichtigt Öffnungen weiterhin NICHT** — dokumentierte,
bewusst offene Lücke (Cut/Ansicht-Pipeline und 3D-Solid-Mesh sind bis dahin inkonsistent).
4. **ce6bd26** 2D-Engine-Parität: WASM-Pfad deckungsgleich zum SVG-Referenzpfad (**Referenz ist
`?gl=0`**, nicht der Default!). Fixes: text_scale entkoppelt, glyphon ColorMode::Web,
z-basierte Maler-Reihenfolge (draw_sequence), CSS-Klassenfarben in toRenderScene, greyed 0.3,
Dämmwellen-Bézier + userSpaceOnUse-Anker + widthScreen-Schraffurbreite. Probe:
scripts/probe-engine-parity.mjs.
5. **4b02e40** render2d Headless: Feature `headless`, HeadlessRenderer ohne Surface (Vulkan),
CLI-Bin render_png, Golden-Test (bit-exakt) tests/golden/demo.png, Doku
docs/design/engine-headless.md.
6. **382771b** Vektor-PDF aus der RenderScene (**Nordstern 2 abgeschlossen**): exportPdf baut
jetzt `planToRenderScene(plan)` — derselbe Aufruf wie der Viewport — und serialisiert über
den neuen `src/export/sceneToPrintSvg.ts` nach Papier-mm (PEN_STEPS-Quantisierung wie bisher,
widthScreen-Schraffurbreiten korrekt in mm umgerechnet, Texte jetzt als echte Vektor-Texte —
der alte Pfad liess sie komplett weg). `planToPrintSvg.ts` bleibt nur als Referenz stehen
(Kopfkommentar markiert sie als abgelöst). Verifiziert per `pdftoppm`: 53.51×43.69mm vs.
erwartet 53.45×43.45mm bei 1:100. **Bekannte Alt-Lücke, nicht neu:** Zeilenabstand
mehrzeiliger Stempeltexte in `toRenderScene.ts` ist nur bei Massstab 1:100 exakt (STAMP_REF_N),
bei anderen Export-Massstäben proportional leicht daneben — betrifft Viewport identisch, nicht
PDF-spezifisch, absichtlich nicht mitgefixt (out of scope für den additiven Umbau).
7. **b4c4a2c** wgpu 22 → 29, glyphon 0.6 → 0.11 (glyphon pinnt `^29`, obwohl wgpu 30 existiert).
`src/engine/requestDeviceShim.ts` komplett entfernt (das 22er-Limit
`maxInterStageShaderComponents` wird von 29 nicht mehr gesendet). Verifiziert: cargo check
nativ (render2d/render3d/src-tauri), cargo test render2d 17/17 + Golden weiterhin bit-exakt,
render3d 29/29, wasm32 --features web für beide, tsc + npm build — alles grün.
**Damit sind Nordstern 2, 3, 4 (Kern) und 5 gelandet.** Verifiziert per Screenshot: App läuft im
Electron-Fenster (`env -u ELECTRON_RUN_AS_NODE npm run electron`), 3D-Perspektive rendert das
echte Modell über den Engine-Renderer (Umschalter unten rechts: WebGL2/Engine).
**WICHTIG — was NICHT fertig ist (User hat das selbst am Screenshot bemerkt):** Die
Zeichnungsebenen vom Typ „Schnitt"/„Ansicht" (z.B. „Schnitt A", „Ansicht Süd" in der
Ebenen-Liste rechts) sind weiterhin **Stubs** — sie zeigen KEINEN echten Inhalt. Das
render3d-Schnitt-Modul (section.rs, Commits 54211b1/76029db) liefert das Rohmaterial
(SectionOutput: cut_polygons + visible_edges/hidden_edges), ist aber **noch nicht an
App.tsx/das Dokumentmodell angebunden** — es gibt noch keinen Code-Pfad, der beim Öffnen
einer Schnitt-/Ansichts-Zeichnungsebene das render3d-Modell durchschneidet und das Ergebnis
in Plan-Primitive für PlanView/render2d übersetzt. Das ist der wichtigste nächste Schritt
(Nordstern 4b, siehe Liste unten).
**Nächste Schritte, priorisiert:**
1. **[Nordstern 4b] Schnitt-Pipeline an UI anbinden** — grösster Brocken: SectionOutput
(Rust/render3d) → TS-Seite (WASM-Aufruf analog `toWalls3d.ts`/`nativeSync.ts`) → neue
Plan-Primitive-Kind(e) für Cut-Polygone + sichtbare/gestrichelte Kanten → generatePlan.ts
bindet das für DrawingLevel vom Typ Schnitt/Ansicht ein, statt des aktuellen Stub-Verhaltens.
PlanView.tsx rendert es wie normale Primitive (Parität-Infrastruktur aus ce6bd26 gilt 1:1).
2. **mesh.rs: Öffnungen im 3D-Vollkörper** — 3D-Ansicht zeigt Öffnungen zwar korrekt (das war
schon vorher so, per Mesh-Aussparung), aber das Schnitt-Modul rechnet gegen dieselben
Prismen — sobald 4b. steht, hier gegenprüfen ob mesh.rs nachgezogen werden muss oder ob es
getrennte Datenwege bleiben.
3. **Aufräumen:** `src-tauri/`-Ordner umbenennen/entflechten — User will keine Tauri-Reste im
Namensraum, Electron ist die Shell. render2d/render3d/geometry sind eigenständige Workspaces
ohne Tauri-Abhängigkeit, könnten z.B. nach `engine/` oder `crates/` wandern. Toten Tauri-Host
(`cad-tauri`, tauri.conf.json, native.rs/native2d/native3d) explizit mit dem User klären:
behalten (Beweis für späteren All-Native-Schritt) oder entfernen.
4. **[Nordstern 1]** Papier-mm-exakte Strichbreiten-Audit über den gesamten Engine-Pfad
(Screen bei jedem Zoom/Massstab = Druck) — nach 2/3/4/5 jetzt der einzige verbliebene
unbearbeitete Nordstern-Punkt.
5. Nicht-Engine-CAD-Backlog (Wand-Referenzlinien, Editier-Welle, nahtfreie Verschneidung,
Overrides/Multi-Page-Layouts) — weiter unten in diesem Dokument, nachrangig zur Engine-Arbeit.
**Arbeitsweise dieser Session (fortführen):** Pipeline aus bis zu 2 parallelen Sonnet-Subagents
(User-Regel: immer 2 am Laufen halten, nachfüllen sobald einer fertig ist). Jeder Agent bekommt
explizit zugewiesenes Terrain (Dateipfade), damit sich parallele Agents nicht überschreiben.
Ergebnisse werden von der Hauptinstanz verifiziert (Screenshots ansehen, Gates laufen lassen)
und committet — Agents committen nie selbst. Nach Prozessabstürzen (kam heute zweimal vor)
lässt sich ein Agent per SendMessage mit seiner agentId aus dem Transcript fortsetzen, sein
Working-Tree-Zwischenstand bleibt erhalten.
## >>> ENGINE-NORDSTERN (User-Direktive 2026-07-02): die volle Engine-Vision <<<
Der User hat explizit beauftragt, die Engine-Vision KOMPLETT anzustreben ("wir brauchen
exakte breiten usw. strebe bitte das komplett an!"). render2d/render3d sind nicht nur
Viewport-Ersatz, sondern werden die EINZIGE Render-Wahrheit des CAD:
1. **Papier-mm-exakte Strichbreiten** überall — Bildschirm bei jedem Maßstab/Zoom = Druck.
2. **Deterministische Plan-Ausgabe:** Druck/PDF mm-genau identisch zum Bildschirm, beides aus
render2d (kein separater Druckpfad). Vektor-PDF-Backend = dieselbe Szene, anderes Target.
3. **Headless-Rendering:** PNG/PDF-Export und Golden-Image-Tests ohne Fenster (die
serde-only/GPU-Trennung in render2d existiert genau dafür; wgpu kann offscreen).
4. **HLR/Schnitt-Pipelines** durch die Engine (render3d → Kantenextraktion → render2d-Szene).
5. **Eine Wahrheit:** SVG-/WebGL2-Geometriepfade im Browser schrittweise durch die
WASM/WebGPU-Engine ersetzen (`?engine=wasm`-Spike ist der Anfang); SVG bleibt nur
Interaktions-/Overlay-Schicht. Bereits gefundene Duplikations-Bugs: Wandecken-Kerben,
fehlende Strichelung im WebGL2-Pfad.
6. **WebKit-Unabhängigkeit** (explizit vom User betont): kein Render- oder Ausgabepfad darf
von WebKitGTK abhängen. Brücke = Chromium-Shell (`npm run shell`), Endzustand = All-Native
(ein Rust-Prozess, Engines + natives UI, kein Webview).
**RICHTUNGSENTSCHEIDUNG 2026-07-02: Electron statt Tauri, all-native aufgeschoben.**
Abgewogen mit dem User: (a) „auf Tauri bleiben" = WebKitGTK, das WEDER WebGPU (kein WASM-
Viewport) NOCH native-wgpu-Composite ins Fenster (Wayland tot) kann → Engine nur als separate
Fenster möglich, kein integrierter Viewport. (b) „all-native jetzt" = kein Shortcut, sondern
das GRÖSSERE Rewrite: ~46k Zeilen TS (UI ~18k + Modell-Gehirn ~5k + Interaktion ~23k) müssten
nach Rust; die Rust-Engines sind erst ~10% der App. → **Gewählt: Electron/Chromium-Shell +
WASM/WebGPU-Engine im Webview** (behält die funktionierende React-UI, verlässt WebKitGTK,
integrierter Engine-Viewport möglich). Die Engine-Rust-Crates sind in einem späteren All-Native-
Schritt 1:1 wiederverwendbar; nur die dünne WASM-Bindeschicht wäre dann Wegwerf. All-native
bleibt legitimes Fernziel, ist aber KEIN Weg zurück zu Tauri (Tauri IST der Webview-Shell).
>>> ELECTRON-PROTOTYP: FERTIG (2026-07-02). <<<
`electron` als devDep, `scripts/electron-main.cjs` (BrowserWindow, `Menu.setApplicationMenu(null)`
+ `frame:false`/`autoHideMenuBar` für randloses Fenster wie chromium-shell.sh, WebGPU-Flags
`enable-unsafe-webgpu`+`enable-features=Vulkan`), `scripts/electron-shell.sh` (Dev-Server-Guard
wie chromium-shell.sh), npm-Script `npm run electron`. Verifiziert per Screenshot: App startet
als eigenes randloses Fenster, lädt den echten Grundriss (nicht leer). WICHTIG:
- **Datei muss `.cjs` sein** (`package.json` hat `"type":"module"`, Electron-Main läuft als CJS).
- **`ELECTRON_RUN_AS_NODE=1`** kann aus dem umgebenden Prozess (z.B. VSCode-Electron-Host)
vererbt sein — zwingt Electron in den Node-Modus (`require('electron')` liefert nur einen
Pfad-String statt `{app,...}``app` ist undefined). Immer mit `env -u ELECTRON_RUN_AS_NODE`
starten, falls das Fenster nicht öffnet / `app.commandLine` undefined ist.
- **WebGPU/`?engine=wasm` funktioniert in Electron** — verifiziert per Screenshot (RENDERER-Anzeige
zeigt "Engine" aktiv, Grundriss korrekt gezeichnet). Ein einzelner Fehlversuch vorher war ein
GPU-Prozess-Init-Timing-Rennen (zu früh abgefragt), kein echter Bug — `app.getGPUFeatureStatus()`
zeigt nach ~2s alles `enabled`/`enabled_on` inkl. `webgpu` und `vulkan` (AMD RX 7800 XT/RADV).
KEINE Ozone-Platform-Flags nötig, `--ozone-platform=x11` NICHT verwenden (bricht die
Fenstererstellung, Vulkan-Surface/GetGeometry-Fehler). `electron --version` meldet v24, weil
`ELECTRON_RUN_AS_NODE=1` den Prozess in den Node-Modus zwingt und dann Nodes eigene Bundle-
Version zeigt (kein echter Versions-Mismatch — `env -u ELECTRON_RUN_AS_NODE electron --version`
meldet korrekt v43).
Danach: render3d-WASM, Doppeltext-Fix.<<<
Klarstellung an den User (er fragte, ob Webview = „billig"): NEIN — Figma (eigene C++/WASM-Engine
im Web-Shell), Onshape, VS Code zeigen, dass „eigene Engine + Web-UI" Top-Tier-Architektur ist.
CAD/BIM-Substanz = Modell + Engine + Normen (haben wir: parametrische Wände, SIA 416, DOSSIER-
Ebenen, ein-Modell→alle-Sichten), NICHT der Fenster-Shell.
Praxisregel ab jetzt: Darstellungs-Features NICHT mehr mehrfach (SVG/WebGL2/nativ) bauen,
sondern einmal in der Engine + dünne Anbindungen.
## >>> AUFGABE FÜR NEUE INSTANZ: 2D-Plan auf WebGL-GPU-Renderer (ZUERST) <<<
**Warum:** Tauri-Linux-Webview = **WebKitGTK**, das SVG/2D auf der **CPU (Cairo)** rastert.
User-GPU (AMD RX 7800 XT) langweilt sich; bei **144 Hz** (~6,9 ms/Frame) ruckelt Pan/Zoom/
**Objekt-Bewegen** sichtbar. three.js/WebGL (3D) läuft GPU-beschleunigt → NUR der 2D-SVG-Pfad
ist CPU-gebunden. Entscheidung mit User: 2D-Plan **von SVG auf WebGL2 (GPU)** umbauen.
**Sofortlösung fürs Vorführen/Entwickeln — Chromium-App-Shell:** `npm run shell`
(`scripts/chromium-shell.sh`) startet bei Bedarf den Vite-Dev-Server (Port 5187, Polling per
`curl`) und öffnet ihn dann in einem randlosen Chromium-Fenster (`--app=…`, eigenes Profil unter
`~/.cache/cad-chromium-shell`), statt im WebKitGTK-Webview von `tauri:dev`. Chromium rendert
dieselbe Seite GPU-beschleunigt und spürbar flüssiger. Kein Ersatz für den nativen wgpu-Pfad
(`render2d`/`render3d`), aber ein schneller Weg zu einer ruckelfreien Oberfläche, solange der
Electron/CEF-Shell (siehe Notizen zu „Render path decision") noch aussteht. Läuft rein im
Browser ohne Tauri-Backend — `src/compute/index.ts` guardet den einzigen `invoke()`-Aufruf
bereits per dynamischem Import mit TS-Fallback.
**Architektur (mit User abgestimmt):**
- **Hybrid:** WebGL2-Canvas rendert die schwere Geometrie (Poché-Polygone, Linien, Schraffuren,
Kreise/Bögen als Segmente). **Dünne SVG-Ebene DARÜBER** behält Text (Raumstempel/Labels),
Griffe, Snap-Marker, Werkzeug-Vorschau (wenige Elemente → billig, DOM-Hit-Test + scharfer Text bleibt).
- **Raw WebGL2, KEINE neue npm-Dependency** (User will nicht „abhängiger" werden; nativ).
- **Geometrie pro Plan EINMAL tessellieren** (Cache), Pan/Zoom = nur Transform-Matrix-Uniform →
keine Re-Tessellation, GPU-buttrig. Objekt-Bewegen: Plan ändert sich → re-tessellieren (leichter als SVG-Reconcile+Cairo).
- **Linienbreite konstant in Screen-px** (wie heutiges `non-scaling-stroke`): im Vertex-Shader
expandieren (Position world → clip, dann Normalen-Offset in clip via Viewport-px). Miter an Ecken
später; Spike: Quads pro Segment.
- **SVG-Renderer als Fallback hinter einem Flag behalten**, bis GL-Parität erreicht → kein Risiko.
- Vorgehen: **erst Spike** (Linien+gefüllte Polygone + GPU-Pan/Zoom), Glätte bei 144 Hz messen
(dev :5187 läuft; `tauri:dev`-Fenster läuft), DANN Schraffur/Feinschliff/Text-Overlay/Hit-Test.
**Fixpunkte im Code:**
- `src/plan/PlanView.tsx` (~2500 Z.): `toScreen()` = Modell-m → viewBox-Einheiten, FIXER Ursprung,
`PX_PER_M=90`; `view={x,y,w,h}` treibt `<svg viewBox>`; Pan = setView, Zoom = setView(onWheel).
BEREITS optimiert: `primitiveEls`/`drawingRunEls` `useMemo` (Pan/Zoom 312→0 Re-Renders) +
rAF-Coalescing beim Objekt-Drag (`flushDragMove`/`enqueueDragMove`, 40→1 Update/Frame). Diese
bleiben nützlich; der GL-Renderer ersetzt die SVG-Primitiv-Ebene, NICHT die Interaktions-Logik/Griffe.
- `src/plan/generatePlan.ts`: `export type Primitive` (Z.133) — kinds: `polygon`{pts,fill,stroke,
strokeWidthMm,hatch,*Id}, `line`{a,b,weightMm?,strokeWidthMm?,dash?,color,cls,greyed,drawingId?},
`arc`{center,r,…}, `text`{at,doc,extraLines,basePt,color} (Text → SVG-Overlay, NICHT WebGL).
- Koordinaten/Breiten-Logik im SVG-Renderer: `PrimitiveShape` (~Z.2280) + `DrawingRunShape` — dort
steht, wie Haarlinie/mm/`paperScale`/`non-scaling-stroke` die Strichstärke bestimmen (für GL nachbilden).
- Hit-Test/Auswahl/Griffe laufen weiter über die bestehenden PlanView-Pfade (modellbasiert); GL ist nur Anzeige.
- **Vektor-PDF/-Export bleibt** (kommt aus `generatePlan`/DXF, nicht aus der Anzeigefläche) — NICHT anfassen.
**Gates:** `npx tsc -b` 0, `npm run build` grün, Trace-Scan sauber (keine KI-Spuren, COMMIT-REGEL oben).
Kein Commit ohne Ansage. Verifizieren wie üblich per Puppeteer gegen :5187 (Render korrekt + Glätte messen).
**GESAMT-TODO-LISTE (Stand 2026-07-01 Nacht) — im Hinterkopf behalten:**
ERLEDIGT+verifiziert: U/I/O/P-Kopiermodi · Backlog#1 Transform→CommandLine · Backlog#4 3D-Zeichnen +
Engine-Confirm-Bugfix · Raumstempel#2 (Feld-Modell) · Plan-Perf (Memo + rAF-Coalesce) · Tauri-M1-PoC
(geometry-Crate 4/4 + Parität + `tauri:dev`-Boot). OFFEN: **(A) 2D-WebGL-Renderer [DIESE AUFGABE]** ·
(B) Tauri-M1 Rest: Compute-Boundary in Render-Pfad einhängen (generatePlan sync → precompute+cache) +
nächste Ops (kernel2d→DXF/DWG→detectRooms) · (C) three.js→wgpu (M2, nach 2D-WebGL) · Parametric Walls
Phase B (andere Instanz) · Wand-Referenzlinien-Option unter Bezugspunkt · Viewport/Editier-Welle
(Treppen-Griffe, Öffnung-entlang-Wand, 2D auf Zeichenebenen, runde Linienenden, schattiert-Politur) ·
Smart-Join/Split + nahtfreie Wandverschneidung · Text/Stempel-Annotation (Frame+Rotation) · Welle B
(3D-Schnitt+Stencil-Capping) · Welle C (HLR verdrahten) · Overrides/Ausschnitte/Multi-Page-Layouts.
## >>> STAND 2026-07-01 (Tauri-PoC — App-frei, gelandet) <<<
**Tauri v2 + Rust-Compute-Boundary — Proof-of-Concept steht (3 App-freie Agents).**
Alle Gates grün, nichts an `App.tsx`/`src/model/` angefasst (nur NEUE Dateien +
additive Config). Ref: `docs/design/tauri-migration-plan.md` / `tauri-architecture.md`.
- **`src-tauri/` (Rust, neu):** Cargo-**Workspace** aus zwei Crates:
- `src-tauri/geometry/`**serde-only** Lib, Port von `computeJoins` (aus
`src/model/joins.ts`) inkl. Vektor-Helfer. Input flach: `WallInput{ id,start,end,
thickness,referenceOffset }`; Output `WallCuts{ wallId,startCut,endCut }` (Line=
point+dir). **`cargo test` grün: 4/4** (L-Ecke=geteilte Gehrung, freies Ende,
T-Stoss, kollinear → alle korrekt). Braucht KEIN webkit → hier testbar.
- `src-tauri/` App-Crate (`cad-tauri`) — Tauri-v2-Shell (`lib.rs` mit
`#[tauri::command] compute_joins` + `run()`, `main.rs`, `build.rs`,
`tauri.conf.json` devUrl **:5187**, `icons/icon.png`). **`cargo check` GRÜN** —
wider Erwarten NICHT von webkit blockiert: tauri v2 bindet `webkit2gtk-4.1`
(vorhanden); nur `-6.0` fehlte. `src-tauri/target` + `gen/` sind Build-Artefakte
(gitignored).
- **`src/compute/index.ts` (neu, Boundary):** einzige Stelle für schwere Ops.
`computeJoins(project,walls)` flacht ab → `invoke("compute_joins")` (geguardeter
dynamischer Import von `@tauri-apps/api/core`, Build bleibt grün OHNE das Paket)
→ bei null/Fehler `console.warn` + TS-Fallback (`joins.ts`). Zusätzlich
Durchreicher `detectRooms` (roomBoundary.ts) + `parseShapeFromDwg`(=`parseDwg`);
`computeKernel2D` bewusst ausgelassen (kein Einzel-Entry, ~30 Primitive).
- **`package.json`/`vite.config.ts`:** `@tauri-apps/cli`+`api` (v2) installiert,
Scripts `tauri`/`tauri:dev`/`tauri:build`; vite Port 5187 `strictPort`,
`clearScreen:false`, `envPrefix` — rein additiv.
**NÄCHSTE SCHRITTE (Tauri):** (1) **Echte TS↔Rust-Numerik-Parität** end-to-end prüfen
— erst sinnvoll, wenn die Boundary in einen Caller verdrahtet ist (aktuell bewusst
NICHT verdrahtet, weil das `generatePlan`/`App` async machen würde). (2) `computeJoins`
in den Wand-Render-Pfad einhängen (async-Welle). (3) `tauri:dev` end-to-end auf einer
Maschine MIT `webkit2gtk` (hier nur `cargo check`, kein Fenster-Boot getestet).
(4) Nächste Ops laut Plan: `kernel2d` → DXF/DWG → `detectRooms`.
## >>> STAND 2026-07-01 (Parametric Walls Phase A — ISOLIERT) <<<
**Neue Instanz (Opus) — Parallelisierung mit Agents (Sonnet):**
- **Branch:** `feature/parametric-walls` (isoliert, keine App.tsx-Änderungen)
- **Phase A FERTIG (alle Agents grün):**
- **Agent 1:** Types (`ParametricWall`, 5 `ParametricRule` Varianten) + Engine
(`resolveParametricWall()`, 5 rule handlers, helpers) in `src/model/parametricWalls.ts`
- **Agent 2:** 50 Unit-Tests (Vitest), alle grün; `npm test -- parametricWalls.test.ts`
- **Agent 3:** Design-Doku `docs/design/parametric-walls.md` + `docs/README.md` aktualisiert
- Verification: `tsc -b` ✓, `npm run build` ✓ (577 modules, 3.72s)
- **Phase B (UI-Integration) — für nächste Instanz mit Agents:**
- Agent 1: TopBar ParametricWall-Picker + UI-Komponente
- Agent 2: Command `pw` (`cmds/parametricWall.ts`)
- Agent 3: ResourceManager-Integration (ParametricWall CRUD)
- Dann merge → `master`, Screenshot-Verifikation
- **Koordination:** andere Instanz committet parallel (Text, Raum, HLR) auf `master`,
keine Konflikte (nur `types.ts`+`src/model/` erweitert, nicht `App.tsx`).
**Backlog #4 ERLEDIGT (Zeichnen im 3D-View — vorherige Instanz):** Werkzeug-Gate `toolsEnabled`
(`App.tsx` ~1877) von `viewType === "grundriss"` auf `activeLevel.kind ===
"floor"` erweitert → Werkzeuge sind jetzt AUCH in der Perspektive aktiv. Die
gesamte Zeichen-Pipeline war bereits verdrahtet (`Viewport3D.workplaneModel()`
raycastet den Cursor auf die Geschoss-OKFF; `onWorkplanePoint`/`onWorkplaneConfirm`
speisen dieselbe Engine wie der Grundriss). Ende-zu-Ende per Puppeteer verifiziert:
in iso-Ansicht Linie mit 2 Klicks gezeichnet → committet (`draw2d` 0→1, Prompt
zurück auf „Befehl:"); Picks liefern gültige Modell-Vec2 (raycast korrekt).
- **BUG-FIX unterwegs (Engine-Confirm, `src/commands/engine.ts` `confirm()`):**
Beim Testen von #4 fiel ein VORBESTEHENDER Bug auf (reproduziert IDENTISCH im
Grundriss, nicht durch #4 verursacht): mehrteilige Befehle (Wand/Polylinie)
liessen sich per Enter/Rechtsklick NIE abschliessen. Ursache: `confirm()` nahm
IMMER den Tab-Feld-Zweig (`fieldResultPoint`), sobald der Schritt Felder hatte —
auch OHNE gelocktes Feld → es wurde ein Cursor-Punkt gefüttert (bei Wand: Null-
Strecke, ignoriert) statt `onConfirm` (das den Zug committet) aufzurufen. Fix:
Feld-Zweig nur noch bei tatsächlich GELOCKTEM Feld (`Object.keys(this.locks).length
> 0`); ohne Lock fällt `confirm()` auf `onConfirm` durch. Verifiziert: Wand
schliesst jetzt per Rechtsklick im Grundriss (91→98) UND in 3D (99→106); Tab-Feld-
Fluss „Länge 4 tippen → Enter lockt → leeres Enter setzt Punkt" unverändert;
Linie-Auto-Commit (2 Klicks) unverändert. Gegatet: `tsc -b` 0, `build` grün, keine
Konsolenfehler. KEIN Commit.
- **Backlog #1 ERLEDIGT (Befehlsleiste-Transform vereinheitlicht):** Die laufende
Auswahl-Transformation (nur noch `rotate`/Taste `d` mit U/I/O/P; move/mirror
laufen längst über die Engine) speist jetzt die `CommandLine` statt der
schwebenden `TransformBar`. Dritter Zweig in der `<CommandLine>`-Ternäre parallel
zu `gripEdit` (`gripEdit ? … : activeTransform ? … : engineView…`): Prompt =
`transform.hint.${op}.${…}`, Inline-Optionen = U/I/O/P (`transformOptions()`,
aktiver Modus hervorgehoben via neuem `active?`-Feld an `CommandLineOption` +
`.cmdline-option.active` in styles.css, `×count` bei array/distribute), Live-/
lockbares **Winkel**-Feld beim Drehen bzw. **Distanz** beim Bewegen
(`transformFields()` aus neuem `transformDragInfoRef`), getippte Zahl committet
über `submitTransformValue()`. `d` fokussiert die CommandLine (wie `m`/`s`).
`src/ui/TransformBar.tsx` gelöscht; verwaiste `.transform-bar`/`.tf-*`-CSS +
`transform.bar`-Key harmlos stehen gelassen. Gegatet: `tsc -b` 0 Fehler, `build`
grün, Trace-Scan sauber, Puppeteer-Probe end-to-end (nach `d`: „Drehzentrum
klicken" + U/I/O/P; nach Zentrum+Bezug: „Zielwinkel klicken" + Live-Winkelfeld;
keine Konsolenfehler). KEIN Commit (Konvention der Vorsessions beibehalten).
## >>> STAND 2026-07-01 (Abend) — Raum + Topbar-Umbau + Backlog — ZUERST lesen <<<
Alle unten genannten Agenten GRÜN gelandet und gegatet (`tsc -b` 0 Fehler,
`npm run build` grün, Trace-Scan sauber, Boot-Probe ohne Konsolenfehler). Dev-
Server läuft/hält auf :5187. KEIN Commit gemacht (Commit-Regel oben beachten).
**Diese Session gelandet:**
- **Rich-Text-Kern (komplett):** `src/text/richText.ts` (Marks/RichTextDoc/TextRange,
toggleMark/applyMark/isMarkActive/DEFAULT_PRESETS/serialize…), `src/text/renderHtml.ts`
(docToHtml/docToLines/docToSvgText), `src/text/RichTextEditor.tsx` (contentEditable,
Toolbar B/I/U/S + Grösse/Farbe/Presets).
- **Raum-Slice (SIA-416), Agent gelandet:** `Room` in `model/types.ts` (id, floorId,
categoryCode "60", siaCategory HNF/NNF/VF/FF/KGF, boundary, stampAnchor?, stampDoc?);
`cmds/room.ts` (Modus inside=click-inside via `roomFromPointInside`, manual=Polylinie);
Plan: neues `text`-Primitive + Füllpolygon + Stempel via `docToLines` + Live-Fläche;
`panels/RoomBalancePanel.tsx` (SIA-Bilanz + CSV). **App-State für Stempel:**
`selectedRoomId`/`selectedRoom.stampDoc`, `projectSlice.setRoomStampDoc(roomId,doc)`,
`moveRoomStamp`, `setRoomStampAnchor`, `stampEditorRoomId`. Stempel-Editor = kleines
schwebendes Fenster (kein Footer).
- **DOSSIER-Referenz studiert → Spec `docs/design/topbar-dossier.md`** (verbindlich für
Topbar-Arbeit; Referenz-Klon lag unter `/tmp/DOSSIER`). Kernbefund: Text-Styling ist im
Original eine FEST in der Oberleiste sitzende Text-Gruppe (Stil/Font/Grösse + B/I/U +
L/C/R + „+Text"), Akzent-Glow bei Auswahl; Raumstempel-Typografie darüber; Text-INHALT
in eigenem kleinem Fenster; Zoom/Massstab als 2×2-Cluster (kombinierte Stat-Pille).
- **Topbar-Umbau (Agent gelandet), `src/ui/TopBar.tsx` + `styles.css` + `App.tsx` +
`ui/TextEditorDialog.tsx` (neu) + i18n:** Zoom-Doppelung entfernt; 2×2-Massstab/Zoom-
Cluster; einheitliche Segmentpillen (neue `Segment`-Komponente); **Text-Gruppe** in der
Topbar (`textTarget`-Prop = `null | {doc,range,apply}`, aus selektiertem Raum abgeleitet,
`apply``setRoomStampDoc`; ohne range wirkt Format auf ganzes Doc); Doppelklick öffnet
`TextEditorDialog`. Verwaiste alte CSS-Regeln (`.tb-zoom*`, `.stamp-editor*`) bewusst
stehen gelassen (harmlos). „+ Text"-Button verdrahtet, aber DEAKTIVIERT (noch kein
Text-Annotations-Werkzeug).
**Backlog / nächste Schritte (Priorität dieser Session, siehe auch Todo-Liste):**
1. **Befehlsleiste-Transform vereinheitlichen:** laufende Transformation (move/mirror/rotate)
soll die `CommandLine` speisen (Prompt „Basispunkt/Zielpunkt" + U/I/O/P als Inline-
Optionen) statt der separaten schwebenden `TransformBar`; freies Befehl-Tippen während der
Geste unterdrücken, Zahleneingabe (Distanz/Winkel) via `fields` bleibt. Muster: parallel
zum bestehenden `gripEdit`-Zweig bei `<CommandLine>` (~App.tsx:2793). U/I/O/P-Routing
(App.tsx ~15431548) NICHT kaputt machen — nur zusätzlich in die CommandLine spiegeln.
2. **Strukturierter Raumstempel (DOSSIER `RaumProperties`):** Stempel ist KEIN Freitext,
sondern ein FELD-Modell: Raumnummer · Raumname · Raumname-Zeile2 (in Listen als ein Name
zusammengezogen) · Bodenfläche (an/aus + Präfix) · Fensterfläche (an/aus + Präfix) ·
Nutzung HNF/… (an/aus). Zeilen-Layout-Editor (welches Feld auf welche Zeile) + Typografie
PRO Feld-Einheit (Raumname grösser, andere kleiner, eine Zeile kursiv). Ersetzt den
Freitext-`stampDoc`-Zwischenstand; die Topbar-Text-Gruppe formatiert dann die gewählte
Feld-Einheit (löst auch die „ganzes Doc statt Selektion"-Einschränkung).
3. **Wand-Optionen:** Referenzlinien-Option UNTER den Bezugspunkt setzen, gleiche Optik/
Anordnung wie die übrigen Optionen (`cmds/wall.ts` / ToolsPanel-Optionsreihenfolge).
4. **Zeichnen im 3D-View:** Cursor per Raycast auf die Arbeitsebene (okff des Geschosses)
projizieren → Weltkoordinaten als `engine.move/pick` einspeisen; `toolsEnabled`-Gate
(App.tsx ~1834, heute `viewType==="grundriss"`) auch für Perspektive öffnen.
5. **Viewport/Editier-Welle:** Treppen-Griffe (App.tsx grips-memo hat keinen stair-Fall),
Öffnung entlang Host-Wand verschieben (kein opening-Handler im Body-Move; `moveOpeningBy`
fehlt), 2D-Tools auf Zeichnungsebenen (`toolsEnabled` erweitern + Bau-Tool-Buttons grauen),
runde Linienenden (`stroke-linecap:round` auf offene Linien), „schattiert"-Politur
(ACES-Tonemapping+sRGB, HemisphereLight/RoomEnvironment-PMREM, MeshLambert→MeshStandard in
`Viewport3D.tsx` ~416449).
6. **Smart-Join/Split + NAHT-FREIE Wandverschneidung** (Poché-Union an Stössen, keine sichtbare
innere Naht, Gehrung/T-Stoss). 7. **Text/Stempel-Annotation** (aktiviert „+Text";
Frame+Rotation). 8. **Welle B** (3D-Schnittebene + Stencil-Capping + Schnittschraffur).
9. **Welle C** (HLR verdrahten; `src/section/hlr.ts` bereit). 10. Overrides/Ausschnitte/
Multi-Page-Layouts.
**Kollisions-Regel bestätigt:** `App.tsx`, `types.ts`, `generatePlan.ts`, `Viewport3D.tsx`,
`registry.ts`, `PlanView.tsx`, i18n, `styles.css` sind Hotspots → nur EIN App.tsx-Agent pro
Welle; parallele Tracks nur auf genuin App-freien neuen Modulen.
## >>> STAND 2026-07-01 (Bug-Batch Runde 1) — ZUERST lesen <<<
Runde-1-Agenten alle GRÜN gelandet (`tsc -b` + build + Probe verifiziert):
- **3D-Editier-Kamera-Bug (kritisch, `src/viewport/Viewport3D.tsx`):** Ursache = schwerer
Szenen-Aufbau-Effekt mit `project`-Dependency → jede Griff-Mutation baute Szene neu auf und
`applyView3d` resettete die Kamera auf Preset (→ „springt in Top-View", Rechts/Links-Pan-Illusion).
Fix: `camStateRef`-Snapshot (Pos/Target/up/ortho-Zoom/aktive Kam) im Render-Loop fortgeschrieben,
bei Neuaufbau WIEDERHERGESTELLT statt `applyView3d`; `applyView3d` nur beim ersten Aufbau;
`view3d`-Effekt guarded via `gripDragRef`; Top-View `enableRotate=false`; 2D-Drawings bekommen
`move`-Griff (Anker=center/at) → in 3D verschiebbar. Kamera pos/target numerisch identisch vor/nach
Drag/Löschen bewiesen.
- **Print-Linienstärke invertiert → korrekt (`src/plan/PlanView.tsx`, `styles.css`):** Print zeichnet jetzt
echte Papier-mm in viewBox-Einheiten OHNE `non-scaling-stroke`: `strokeVb=(mm·N/1000)·PX_PER_M`,
N=`paperScale` stabil (nur bei `applyScale`/erstem Messen gesetzt). Rein=dicker, raus=dünner;
1:100 vs 1:10 = exakt ×10. Display/Haarlinie unverändert. **Ecken-Miter:** CSS `stroke-linejoin:miter`
+ `buildDrawingRuns` bündelt zusammenhängende `line`-Primitive gleicher drawingId zu einem
`polyline`/`polygon` (Ringschluss erkannt) → gehrte Ecken statt Butt-Cap-Stufe. Primitive-Modell
(und Print-Export) unverändert.
- **ambientCG-Live-Bibliothek (`src/materials/ambientcg.ts` neu, `ResourceManager.tsx`):** komplette CC0-Lib
live durchsuchbar (Suche/Kategorie/Auflösung 1K-4K), jszip-Entpackung → Blob-URLs, NormalGL bevorzugt.
**CORS:** Such-JSON + `/get` brauchen Proxy → Vite-Dev-Proxy `/ambientcg` in `vite.config.ts`
(`VITE_AMBIENTCG_PROXY` für Prod); optionale Prod-Route `openbureau-core/api/src/routes/ambientcg.js`
(lesend, gemountet VOR Auth-Gate). Thumbnails direkt (ACAO:*). Offline → Schnellauswahl-Fallback.
**Runde 2 FERTIG (2026-07-01, alle GRÜN + Probe-verifiziert):**
- **2a** [App/engine/editors/registry, neu `cmds/mirror.ts`+`cmds/join.ts`]: Snapping erster Punkt gefixt
(Modifier shift/ctrl wurden im Command-Pfad verschluckt → jetzt via `EngineMods` durch `move/pick``host.snap`
gefädelt); Segment-Move für offene Polylinien/Linien (`EdgeGrip.free`, ganzer 2D-Delta, Nachbar-Segmente
folgen über geteilte Vertices); Kürzel↔Befehl vereinheitlicht (`M`→engine.start("move"), `S`→"mirror",
`Ctrl+J`→"join", identisch zum Tippen; `Command.autoRun` für nicht-interaktive Befehle; `CommandSelection.drawingIds`
für Mehrfachauswahl); **Mirror-Befehl** (2-Punkt-Achse, Vorschau, `commitTransform mirror/copy`).
- **2b** [cmds/{polyline,rect,wall}]: Polylinie **offen/geschlossen** (Toggle `m`, Ring-Fill); Rechteck **2pt/3pt/Zentrum**
(3pt = gedreht → closed polyline); **Wandstärke-Tab-Feld** nur bei einschichtigen Wandtypen (baut on-commit
einen einschichtigen Wandtyp der Zieldicke, Original bleibt).
- **Swisstopo/OSM-Kontext-Importer** [io/{lv95,geoContext,swissTopo,osm}, ui/ContextImportDialog, SitePanel]:
„Standort importieren" — geocode (geo.admin SearchServer, LV95 direkt), swisstopo-Gebäude
(`MapServer/identify` layer `ch.swisstopo.vec25-gebaeude`), OSM-Overpass (Gebäude/Strassen/Wasser/Grün).
LV95↔WGS84 (Bern round-trip 0.38 m) + Origin-Shift. CORS: geo.admin direkt, Overpass via neuer read-only-Route
`openbureau-core/api/src/routes/geoproxy.js` (vor Auth-Gate, host-allowlist) wenn `VITE_GEO_PROXY` gesetzt.
**TIER-1 FORTSCHRITT (2026-07-01):**
- **DXF-Vektor-Export FERTIG** [src/export/{dxfWriter,exportDxf}, ui/ExportDxfDialog, TopBar+App-Button]:
hand-rollierter R2000/AC1015-ASCII-Writer, `$INSUNITS=6` (Meter), 23 Layer aus Kategorien (ACI+True-Color+lw),
LINE/LWPOLYLINE(closed)/CIRCLE/ARC/TEXT; Quelle = `generatePlan()` in Meter-Modellraum. 5 m-Wand→5.345 m Span
verifiziert. DWG NICHT geshippt (libredwg-web nur lesend — ehrlich weggelassen).
- **Decke (Ceiling) FERTIG** [geometry/ceiling, cmds/ceiling(alias d/decke), types.ts `Ceiling`, projectSlice/selectionSlice,
generatePlan `addCeilingPoche`, PlanView-Hittest, Viewport3D `addCeilingMesh`(Extrude+Material+Modi), ToolsPanel-Button+
Deckentyp-Picker, ObjectInfoPanel `CeilingSection`, host.ts, App-Grips/Selection]. Wand-Muster: floorId + categoryCode "30"
+ wallTypeId/Component + VerticalAnchor(OK=Geschoss-Top) + optionale Dickenüberschreibung; statt Achse/Band ein
closed `outline: Vec2[]`. Plan = Umriss-Polygon (Fill+Hatch + schwere Outline) VOR den Wänden gezeichnet. Grips Vertex+Edge+Body.
- ⚠️ MERKE: **App.tsx ist der Serialisierungs-Engpass** — pro Welle darf NUR EIN Agent App.tsx editieren (DXF+Decke
liefen versehentlich gleichzeitig darauf; Merge hat diesmal geklappt, aber nicht drauf verlassen).
- **Öffnung (Fenster/Tür) FERTIG** [geometry/opening, cmds/opening(alias f/fenster, t/tuer/tür, oe), types.ts `Opening`,
generatePlan(`wallGaps`), PlanView, Viewport3D(`addWallMeshes` segmentiert + Sturz/Brüstung, `addOpeningMeshes` Rahmen/Glas/Flügel),
slices, App, ToolsPanel(Fenster+Tür-Buttons), ObjectInfoPanel(editierbar)]. **Host-Sync:** Öffnung speichert KEINE
Weltkoordinaten — alles wird pro Render aus `wall.start/end` neu abgeleitet → Wand verschieben bewegt Öffnung automatisch.
Plan-Symbole je Detailgrad (Tür: Blatt→+Schwenkbogen→+Anschlag; Fenster: 1→+Rahmen→+2 Glaslinien).
- **SIA-416-Engine FERTIG** [geometry/{roomArea,roomBoundary} — pure, isoliert, 34/34 Tests]: Raumerkennung
(planar-arrangement+half-edge face-tracing, `detectRooms`/`roomFromPointInside`), Fläche/Umfang/Zentroid,
SIA-Hierarchie (GF→KGF+NGF; NGF→NF(HNF/NNF)/VF/FF), `balance()`, `roomsToCsv`. Bereit für den Raum-Slice.
- **Treppe FERTIG** [geometry/stair, cmds/stair(alias treppe/tp — `tr`=trim!), types.ts `Stair`, model/wall `stairVerticalExtent`,
generatePlan `addStairSymbol`(Schnittbruchlinie+Pfeil), Viewport3D `addStairMeshes`(Stufenprofil), slices/App/Panels/i18n].
gerade/L/Wendel (Wendel = vereinfachtes Keil-Modell), SIA-17/29, geschoss-übergreifend (totalRise=Geschosshöhe).
- **HLR-Spike FERTIG (Welle-C-Fundament)** [src/section/{hlr,occt,occt-wasm.d}.ts, docs/welle-c-hlr-spike/, package.json
+opencascade.js, vite.config aliases]. **API-Pfad = `HLRAppli_ReflectLines`** (NICHT HLRBRep_Algo — im Prebuilt nicht
konstruierbar). Lazy via dynamic import, Haupt-Bundle unberührt. 62 MB WASM → später Custom-Build auf 5-15 MB.
**SESSION-LIMIT-STOP (2026-07-01 ~11:00, reset 13:00 Europe/Zurich):** Raum- + Rich-Text-Agenten wurden vom
Session-Limit abgeschnitten. Repo ist trotzdem GRÜN & stabil (tsc+build grün, dev :5187 läuft). Genauer Stand:
- **Raum: NICHT begonnen** — kein `cmds/room.ts`, kein `Room`-Typ, keine Verdrahtung. Sauber neu starten. SIA-Engine
(`geometry/roomArea.ts`+`roomBoundary.ts`) liegt fertig & getestet bereit zum Import.
- **Rich-Text: FAST FERTIG** — `src/text/richText.ts` (Doc-Modell + Marks + plainText/applyMark/toggleMark/serialize/Presets)
FERTIG. `src/text/renderHtml.ts` (docToHtml für Editor + docToLines/docToSvgText für SVG-Plan, gemeinsame Mark→Stil-Map)
JETZT FERTIG & kompiliert (im Hauptthread nachgezogen, da Agent am Limit war). FEHLT nur noch: `src/text/RichTextEditor.tsx`
(WYSIWYG-Komponente mit Toolbar bold/italic/underline/strike/Grösse/Farbe + Presets, controlled: value/onChange).
- Zwei Helfer-Subagenten ("Map/Extract …wiring in App.tsx") liefen ebenfalls ins Limit; App.tsx ist laut tsc+build INTAKT.
**RESUME NACH RESET (Reihenfolge):** (1) Rich-Text-Kern fertig (renderHtml + Editor-Komponente) — App-frei, isoliert.
(2) Raum-Slice (nutzt SIA-Engine, click-inside, Stempel, Bilanz+CSV) — App-Slice. (3) Text/Stempel-Slice (nutzt Rich-Text-Kern).
(4) Smart-Join/Split. (5) Welle B (3D-Schnitt+Stencil-Capping) → Welle C (HLR verdrahten, `src/section/hlr.ts` bereit).
(6) Overrides-Engine, Ausschnitte, Multi-Page-Layouts. REGEL: nur EIN App.tsx-Agent pro Welle; Parallel-Track nur wenn App-frei.
---
## >>> STAND 2026-06-30 (Fortsetzung, spät) <<<
Fortsetzungs-Lauf (UI + Import + Wand-Attribute + Selektion/Editieren). Alles Fortsetzungs-Lauf (UI + Import + Wand-Attribute + Selektion/Editieren). Alles
unten ist `npx tsc -b` + `npm run build` GRÜN und per Screenshot/Probe verifiziert, unten ist `npx tsc -b` + `npm run build` GRÜN und per Screenshot/Probe verifiziert,
@@ -23,6 +502,32 @@ außer „LÄUFT" markiert. Viel wurde an **Subagenten** delegiert (Dateien land
auf der Platte, unabhängig von Benachrichtigungen). auf der Platte, unabhängig von Benachrichtigungen).
**Fertig & verifiziert (diese Fortsetzung):** **Fertig & verifiziert (diese Fortsetzung):**
- **Vektor-PDF-Export des Grundrisses (KERN-ZIEL — fertig & verifiziert):**
`src/export/planToPrintSvg.ts` (Druck-SVG aus `generatePlan`, mm-Mapping im Maßstab,
echte mm-Linienstärken/ISO-Stifte, Schraffuren als Vektorlinien) + `src/export/exportPdf.ts`
(`jspdf`+`svg2pdf.js` → echtes Vektor-PDF, A4/A3 ×Hoch/Quer, Schriftfeld). TopBar-Button
„PDF" + `src/ui/ExportPdfDialog.tsx`. Verifiziert: 522 Pfade, 0 Rasterbilder, maßstabsgenau
(5,345×4,345 m → 53,45×43,45 mm @1:100). `scripts/probe-pdf.mjs`.
- **2D-Boolean (Union/Differenz/Schnitt):** `src/editors/booleanOps.ts` (polygon-clipping),
Kontextmenü „Vereinigen/Subtrahieren/Schneiden" + Ctrl/Cmd+Shift+U/D/I; geschlossene
Drawing2D, Differenz=erstes Element minus Rest, Loch→Außenring gefüllt + Innenringe als
Umriss. Verifiziert (Union 8/Differenz 6/Schnitt 4 Ecken). `scripts/probe-boolean.mjs`.
- **3D-1 — ERSTELLEN in 3D (Meilenstein):** der 3D-Viewport ist Erstellungs-Fläche;
dieselben Befehle (line/wall/rect/polyline) erzeugen Elemente direkt in 3D. Raycast auf
Geschoss-Arbeitsebene (`THREE.Plane` y=gridElevation) → Modell-`Vec2` (model.x=P.x,
model.y=P.z, aus `addDrawing2DLines` abgeleitet) → dieselbe `CommandEngine`
(`move/pick/confirm`). Live-3D-Draft (Rubber-Band + Snap-Marker, eigene `draftGroup`),
OrbitControls bei aktivem Befehl umgeschaltet, `pxPerMeter`-Fallback 90. Viewport3D-Props
`commandActive`/`onWorkplanePoint`/`onWorkplaneConfirm`/`draft`. Verifiziert (Wand in 3D →
in 3D + Plan sichtbar). `scripts/probe-3d-create-*.png`.
- **3D-2 — EDITIEREN in 3D (Meilenstein):** 3D-Griffe (eigene `gripGroup`) bei genau 1
Auswahl + `!commandActive`. Wand: Endpunkt-Kugeln (Achse), Höhen-Griff (OK via
`updateWall top:{mode:"custom",z}`), Verschiebe-Griff; Drawing2D: Vertices. Drag raycastet
auf Arbeitsebene bzw. vertikale Ebene → DIESELBEN Store-Actions wie Plan-Griffe
(`moveGripOf`/`moveElementByOf`/`updateWall`) → Plan+3D konsistent. Laufender Drag in
`gripDragRef` (übersteht den Szenen-Neuaufbau pro Move). Verifiziert (Endpunkt + Höhe in 3D
editiert, im Plan deckungsgleich). `scripts/probe-3d-edit-*.png`. **Offen (→ Editier-Welle 3):
3D-Snap beim Drag + Griffe „immer oben" (Tiefentest).**
- **Zwei-Ton-Dark-Theme + KEIN Petrol-Grün mehr:** `src/styles.css` Dark-Tokens neutral - **Zwei-Ton-Dark-Theme + KEIN Petrol-Grün mehr:** `src/styles.css` Dark-Tokens neutral
(`--bg #0e0e0e`, `--panel #1d1d1d`, `--accent #4d4d4d` …); Kontextmenü-Tokens (`--bg #0e0e0e`, `--panel #1d1d1d`, `--accent #4d4d4d` …); Kontextmenü-Tokens
`--ctx-hover/-text` neutral; grüner Fokus-Ring entfernt. `--ctx-hover/-text` neutral; grüner Fokus-Ring entfernt.
@@ -55,13 +560,54 @@ auf der Platte, unabhängig von Benachrichtigungen).
**Multi-Delete**, **Multi-Highlight** (`.plan-sel-draw`), **Shift-Klick-Mehrfachauswahl** **Multi-Delete**, **Multi-Highlight** (`.plan-sel-draw`), **Shift-Klick-Mehrfachauswahl**
(`PlanSelection.shift``onPlanSelect` toggelt). (`PlanSelection.shift``onPlanSelect` toggelt).
**LÄUFT bei Übergabe (auf Resume verifizieren, NICHT doppelt bauen):** - **Editier-Welle 2 — Split/Join/Segment-Löschen (FERTIG & verifiziert):** Ctrl+S Split
- **Editier-Welle 2 — Split/Join/Segment** (Agent): Ctrl+S Split (Rechteck→zwei (Rechteck→zwei geschlossene; nur bis Schnittpunkt), Ctrl+J Join (koinzidente Enden),
geschlossene; nur bis Schnittpunkt), Ctrl+J Join, Alt+Klick=Segment löschen. Berührt Alt+Klick=Segment löschen (Schere). `kernel2d.ts` (`splitPolylineAtParam`/
`App.tsx`, `PlanView.tsx`, `kernel2d.ts` (`splitPolylineAtParam`/`splitClosedByChord`/ `splitClosedByChord`/`removeSegment`/`splitAtIntersections`/`joinChains`; Tests 1623,
`removeSegment`/`splitAtIntersections`/`joinChains`), `projectSlice.ts`, ggf. 25 ok), `src/editors/splitJoin.ts`, App-Keydown (Ctrl/Cmd+S/J, `preventDefault`) +
`src/editors/splitJoin.ts`. **Resume:** `git diff --stat`, `npx tsc -b`, `npm run build`, `onSegmentCut`, PlanView Alt-Klick. Verifiziert (Rechteck→2 geschlossene, Join→1
`node scripts/probe-splitjoin.mjs`. Polylinie, Alt→Segment weg). `scripts/probe-splitjoin.mjs`. Wände bleiben no-op.
- **Editier-Welle 3 + Eingabe-Vereinheitlichung + Snap-Feinschliff (FERTIG):** 3D-Drag-Snap
(gleiche `computeSnap`) · Griffe/Marker immer-oben · koinzidente Endpunkt-Griffe ziehen
gemeinsam (Viewport3D). **GUI-Werkzeug** (line/polyline/rect) → `engine.start` +
Befehlsfeld-Fokus; **Shift = H/V-Ortho** beim Griff-Drag (2D+3D, `applyAngleConstraint`
aus `tools/snapping`). OFFEN: „Wand" noch Legacy (Wand-als-Command); Editieren läuft noch
AD-HOC, nicht durch die Engine → Tab-Wert + Referenz-Tracking offen. Snap-Marker
vereinheitlicht: 2D Endpunkt=Kreis/Mittelpunkt=Strich, 3D-Snap=`#ffcc44`, Marker kleiner;
2D-Vorschaulinie grau-Volllinie.
- **DWG-Parser erweitert (FIX „keine Geometrie gefunden") + Trim (FERTIG):** `io/dwgParser.ts`
neu — ARC/CIRCLE/ELLIPSE/SPLINE + **INSERT/Block-Expansion** (rekursiv, aus
`db.tables.BLOCK_RECORD.entries`, volle 2D-Affin-Transform) + Diagnose-Histogramm
(`DxfImportResult.diagnostics`, in ImportDialog). Real-DWG 37 INSERT → 67k Konturen.
**Trim** (`commands/cmds/trim.ts`, Alias `tr`, `kernel2d.trimPolyline`) — Quick-Trim bis
Schnittpunkt. Beide verifiziert.
- **Material-Bibliothek (Daten, FERTIG):** 12 CC0-Materialien von ambientCG →
`public/assets/materials/<id>/{color,normal,roughness,displacement,ao}.jpg` + `manifest.json`
+ `src/materials/library.ts` (PBR).
- **Welle A — 3D-Materialien (FERTIG & verifiziert):** `Component.material` (`ComponentMaterial`:
PBR-Map-URLs + `sizeM` Kachelgröße), `src/materials/runtime.ts` (`MaterialRuntime` → gecachte
`MeshStandardMaterial`, Kacheln in Welt-Metern via Extrude-UVs), `renderMode` „textured"
(TopBar-Darstellung), Material-Picker (ambientCG-Grid) + Upload + Kachelgröße im
`ResourceManager` (Bauteile-Tab). Backstein mit Normal-Map-Tiefe verifiziert.
`scripts/probe-materials.mjs`. Schnittschraffur-Slot frei für Welle B.
- **Editier-Eingabe + Topbar-Politur (FERTIG):** Befehlszeile zeigt **Live-Länge/-Winkel**
beim Zeichnen (`Command.fieldValues`), **Cursor-HUD entfernt**; **Seiten-Verschieben
propagiert verbundene Enden** (2D+3D); **Tab beim Vertex-Editieren** setzt Wert
(`gripEdit`-Controller in App — noch ad-hoc, NICHT durch die Engine). Topbar: Rest
iconisiert (Referenzlinien/Linien-Modus/Ressourcen), Linien-Modus → Dropdown, Aktions-
Buttons als Dropdown-Pill-Familie, **Detailgrad+Massstab gestapelt + 1:N/Zoom% kompakt**
(`.tb-zoomstack`).
- **Wand-als-Command (FERTIG & verifiziert):** `commands/cmds/wall.ts` (chainend, Achs+Band-
Draft, Tab-Felder; Wandfelder aus `CommandContext.activeWallTypeId`/`level`) + Registry
(Aliase `w`/`wand`) + App-Kopplung (`TOOL_COMMAND.wall`, Legacy-Wand umgeleitet). Wände in
2D+3D. `scripts/probe-wall-cmd.mjs`.
**OFFENE WELLEN (nächste Schritte):** Referenzobjekt-Achs-Tracking + Editieren VOLL durch die
Engine (heute `gripEdit` ad-hoc); **Welle B** (3D-Clip/Schnittebene + Stencil-Capping +
Schnittschraffur — nutzt das Material-System aus Welle A); **Welle C** (3D→2D-Schnitt/
Perspektive via HLR, opencascade.js-Spike); **Swisstopo-Importer** (Ort+Radius → Gebäude+
Gelände, geo.admin STAC/Geocode, CORS prüfen); Bögen/Kreise-Trim; Wandstärke-Tab bei
einschichtigen Wänden.
**Offene Wellen (Todo-Spiegel — Todo-Liste lebt im Kontext):** **Offene Wellen (Todo-Spiegel — Todo-Liste lebt im Kontext):**
1. Editier-Welle 3: **koinzidente Endpunkt-Griffe gemeinsam ziehen** + verbundene Enden 1. Editier-Welle 3: **koinzidente Endpunkt-Griffe gemeinsam ziehen** + verbundene Enden
+61 -26
View File
@@ -1,54 +1,75 @@
# DOSSIER Standalone # DOSSIER Standalone
Browser-BIM für Wohnbau. Ein Werkzeug, um ein Wohnhaus aus semantischen Eigenständiges BIM-Werkzeug für Wohnbau. Modelliert ein Wohnhaus aus
Bauteilen zu modellieren und daraus saubere, normgerechte 2D-Pläne zu ziehen semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
ohne Revit, ohne Installation, im Browser. ohne Revit.
Das ist die eigenständige Browser-Variante des Rhino-Plugins Das ist die eigenständige Standalone-Variante des Rhino-Plugins
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse, [DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
React/Three.js-App statt als Rhino-Aufsatz. React/Three.js-App in einer Electron-Desktop-Shell statt als Rhino-Aufsatz.
Kein Browser-Tab-Produkt: eigenes Fenster, eigene Rendering-Engine
(Rust/WASM/WebGPU), gebaut für flüssiges CAD-Arbeiten statt für den Webview
kleinster gemeinsamer Nenner.
## Grundgedanke ## Grundgedanke
Es gibt **ein semantisches Modell** als einzige Wahrheit. Jede Ansicht — 3D, Es gibt **ein semantisches Modell** als einzige Wahrheit. Jede Ansicht — 3D,
Grundriss, Schnitt, Ansicht — wird daraus **abgeleitet**. Darstellung Grundriss, Schnitt, Ansicht, PDF — wird daraus **abgeleitet**. Darstellung
(Detailgrad, Linienstärken, Schraffuren) wird erst beim Rendern angewandt, nie (Detailgrad, Linienstärken, Schraffuren) wird erst beim Rendern angewandt, nie
in die Geometrie eingebacken. Wer eine Wand verschiebt, verschiebt sie überall. in die Geometrie eingebacken. Wer eine Wand verschiebt, verschiebt sie überall.
Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken → 3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
Linien, Öffnungen → Lücken + Symbol). Schnitte und Ansichten brauchen später den Linien, Öffnungen → Lücken + Symbol). PDF-Export ist derselbe Plan, nur mit
zweiten Weg — echte 3D-Projektion mit verdeckten Kanten. echten mm-Stiftstärken statt Bildschirm-Hairlines — kein zweites Rendering.
Schnitte und Ansichten brauchen den zweiten Weg — echte 3D-Projektion mit
verdeckten Kanten (HLR) —, dessen Machbarkeit per Spike bewiesen, aber noch
nicht ans UI angebunden ist.
## Stand heute ## Stand heute
Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit
abgeleiteter 3D-Sicht** geworden. Der einfachere Teil steht und ist per abgeleiteter 3D-Sicht, Vektor-PDF/DXF-Export und einer parametrischen
Screenshot verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen. Wand-Engine** geworden. Der einfachere Teil steht und ist per Screenshot/Probe
verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen.
**Funktioniert:** **Funktioniert:**
- Semantisches Modell mit **mehrschichtigen Wänden** und automatischer - Semantisches Modell mit **mehrschichtigen Wänden** und automatischer
**L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D. **L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D.
- **Parametrische Wände** (`ParametricWall`-Regelwerke: Grid/Modul/Sequenz/
Referenzlinie/bedingte Dicke) lösen sich zu konkreten `Wall`-Objekten auf,
statt jede Wand einzeln von Hand zu ziehen.
- **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/ - **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/
Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …). Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …).
- **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis; - **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis,
Snapping (Endpunkt/Mittelpunkt/Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/ Öffnungen, Treppen, Decken, Raumstempel; Snapping (Endpunkt/Mittelpunkt/
Offset, Spiegeln/Drehen/Array. Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/Offset, Spiegeln/Drehen/Array,
Trim/Split/Join.
- **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` · - **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
`5<45`) und Tab-Feld-Zyklus (Länge → Winkel …). `5<45`) und Tab-Feld-Zyklus (Länge → Winkel …).
- **Vektor-Export:** PDF (A4/A3, Titelblock, echte mm-Stiftstärken nach ISO-Pen-
Steps, keine Rasterbilder) und DXF — beide aus derselben `Plan`-Struktur wie
der Bildschirm.
- **PBR-Material-Bibliothek** (ambientCG-Import, `manifest.json`) für die
3D-Ansicht.
- **Resource Manager** (Component / Hatch / Line) — alles per id referenziert, - **Resource Manager** (Component / Hatch / Line) — alles per id referenziert,
zentral änderbar. zentral änderbar.
- **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste - **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste
(echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en). (echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en).
- **Import:** DXF (Konturen/Mesh) → Terrain-TIN; `.lin`/`.pat` für Linien/Schraffuren. - **Import:** DXF/DWG (Konturen/Mesh) → Terrain-TIN, Swisstopo/LV95-Geokontext,
OSM-Kontextimport; `.lin`/`.pat` für Linien/Schraffuren.
**Bewusst noch offen** (die eigentlich harten Teile): **Bewusst noch offen** (die eigentlich harten Teile):
- Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke, - Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke,
noch kein geschnittenes Volumen. noch kein geschnittenes Volumen.
- **HLR** (verdeckte Kanten) für Schnitte und Ansichten — diese Views sind Stubs. - **HLR** (verdeckte Kanten) für Schnitte und Ansichten: Machbarkeit per
OCCT-WASM-Spike bewiesen (`docs/welle-c-hlr-spike/`, `src/section/hlr.ts`),
aber noch nicht ans UI/Dokumentmodell verdrahtet — Views sind noch Stubs.
- **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz - **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz
verbindet seitlich) — das berüchtigte Risiko #1. verbindet seitlich) — das berüchtigte Risiko #1.
- **Multi-Page-Layouts/Ausschnitte** (mehrere Viewports pro Blatt) — PDF-Export
ist noch single-sheet.
Details und die Begründungen stehen im Details und die Begründungen stehen im
[HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der [HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der
@@ -61,43 +82,57 @@ Geometrie-Kernel verfrüht eingezogen.
| | | | | |
|---|---| |---|---|
| Shell | Electron (eigenes randloses App-Fenster, kein Browser-Tab) |
| Frontend | React + TypeScript + Vite | | Frontend | React + TypeScript + Vite |
| 3D | Three.js | | 3D | Three.js, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render3d`) |
| 2D-Plan | eigener SVG-Renderer | | 2D-Plan | eigener SVG-Renderer, plus eigener nativer Rust/WASM/WebGPU-Renderer (`render2d`) |
| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain | | Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain |
| Import | `dxf-parser`, eigene `.lin`/`.pat`-Parser | | Export | `jspdf`/`svg2pdf.js` (Vektor-PDF), eigener DXF-Writer |
| Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser |
| Schnitt/HLR-Spike | `opencascade.js` (OCCT-WASM) — isoliert, noch nicht verdrahtet |
Geplant, aber noch nicht eingezogen: `rhino3dm` (NURBS / `.3dm`), web-ifc (IFC), Geplant, aber noch nicht eingezogen: `rhino3dm` (NURBS / `.3dm`), web-ifc (IFC),
OpenCascade/Manifold (exakte Booleans + HLR). Siehe ROADMAP §4. Manifold (exakte 3D-Booleans). OCCT-WASM ist für HLR bereits als Spike da (s.o.),
aber noch nicht an Dokumentmodell/UI angebunden. Siehe ROADMAP §4.
## Entwicklung ## Entwicklung
```bash ```bash
npm install npm install
npm run dev # Vite, http://localhost:5173 npm run dev # Vite, http://localhost:5187
npm run electron # Electron-Fenster (Dev-Server + randlose App-Shell)
npx tsc -b # Typecheck npx tsc -b # Typecheck
npm run build # tsc -b && vite build npm run build # tsc -b && vite build
npm test # vitest run
``` ```
Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und
schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Für schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Weitere
Firefox-Fälle gibt es `scripts/probe-ff*.mjs` (Playwright). `scripts/probe-*.mjs` decken einzelne Features ab (PDF, Trim, Materialien,
Theme, Boolean-Ops …); für Firefox-Fälle gibt es `scripts/probe-ff*.mjs`
(Playwright).
## Aufbau ## Aufbau
``` ```
src/ src/
model/ semantisches Modell + Ableitungen (types, geometry, joins, terrain) model/ semantisches Modell + Ableitungen (types, parametricWalls, roomStamp, joins, terrain)
geometry/ 2D-Kernel (offset/trim/fillet/intersect) geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling, opening, roomArea/-Boundary, stair)
commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/) commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/)
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2-Renderer)
viewport/ Viewport3D (Three.js) viewport/ Viewport3D (Three.js)
section/ HLR-Spike (OCCT-WASM) — Schnitte/Ansichten, noch nicht verdrahtet
export/ PDF- und DXF-Export aus derselben Plan-Struktur wie der Bildschirm
materials/ PBR-Material-Bibliothek (ambientCG-Import, Runtime)
text/ Rich-Text (Beschriftungen, Textobjekte)
panels/ dockbares Panel-System + die einzelnen Paletten panels/ dockbares Panel-System + die einzelnen Paletten
state/ Store + Slices (project/selection/view/layout) state/ Store + Slices (project/selection/view/layout)
ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line
io/ Import/Export (DXF, .lin, .pat) io/ Import/Export (DXF, DWG, .lin, .pat), Swisstopo/LV95, OSM-Kontext
i18n/ Wörterbücher de/en i18n/ Wörterbücher de/en
src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WASM
gebaut (npm run build:engine), Tauri-Host selbst ist ausrangiert
``` ```
## Konventionen ## Konventionen
+175
View File
@@ -0,0 +1,175 @@
# HAUPTINSTANZ-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 (für Hauptinstanz)
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)
+8
View File
@@ -48,6 +48,14 @@ Undo/Redo** eliminiert die Cache-Stale-Bugs strukturell. Ziel-Repo-Struktur mit
**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering **kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering
über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt. ü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) ### [design/elements.md](design/elements.md)
Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste
Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone- Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone-
+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.
+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.
+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).
+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 14 in tauri-migration-plan.md), ~12 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
AI-Spuren (grep auf Claude/Anthropic/AI/Generated/Co-Authored), Boot-Probe
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
KEIN Commit.
+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** | — | 1050 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** | 50100 ms SVG render | 15 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`
+81
View File
@@ -0,0 +1,81 @@
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
> Für die Instanz/den Agenten, der den nativen GPU-Renderer baut. Isoliert vom
> Web-Renderer; koordiniert über dieses Dokument.
## COMMIT-REGEL (verbindlich)
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — nicht im Code,
in Kommentaren oder in der Git-Historie. Keine `Co-Authored-By`-Trailer, keine
„Generated with"-Zeilen, keine Modell-/Agenten-Namen. Kommentare deutsch,
Identifier englisch. **Kein Commit ohne Ansage der Hauptinstanz.**
## Warum
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
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 (die Hauptinstanz arbeitet dort an Features).
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
- Ergebnisse/Diffs zurückliefern; die Hauptinstanz committet und pflegt das Memory.
## Gates
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
`npx tsc -b` + `npm run build` müssen unberührt grün bleiben (keine Web-Änderungen).
+265
View File
@@ -0,0 +1,265 @@
# 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 Hinweise auf KI-Werkzeuge — nicht im Code, in Kommentaren
oder in der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne
Ansage der Hauptinstanz.
## Warum
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
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. 16791684): `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. 19392039, 22372271).
- **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. 22662271). 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. 19912015). 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. 20562169) + Segmentierung in `addWallMeshes` (~Z.
19682035). **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. 23572409). `stairGeometry()` (`geometry/stair.ts`)
liefert Trittflaechen (Footprint + Steig-Hoehe) + optionalen Podest-Umriss; jede
Stufe als extrudierter Block (`ExtrudeGeometry`), Hoehe = `stairVerticalExtent`.
### Decken/Platten
- `addCeilingMesh()` (~Z. 22902347). `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. 16511718). 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. 21762206), `MeshStandardMaterial` (Weiss-/Textur-Modus + Terrain, PBR:
`roughness`/`metalness`/`aoMap`, ~Z. 445491, 20012015), `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.
15661633) 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. 391413): Mitteltaste orbit, Shift+Mitte pan, Rad zoom
(linke/rechte Taste fuer Auswahl/Kontextmenue umgewidmet).
### Griffe / Gizmos
- Editier-Griffe (`SphereGeometry`, ~Z. 711814): 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.
+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 **~515 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.8 -0.8 5.6 3.9000000000000004" width="800">
<rect x="-0.8" y="-0.8" width="5.6" height="3.9000000000000004" fill="#fff"/>
<path d="M -0.3 2.6 L -0.3 2.3" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M -0.3 2.6 L 4.3 2.6" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 4.3 2.6 L 4.3 2.3" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M -0.3 2.3 L 4.3 2.3" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0.2 2.3 L 0.2 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0 -0.30000000000000027 L 0.2 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0 2.3 L 0 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 4 2.3 L 4 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0.2 -0.30000000000000027 L 4 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M -0.3 2.6 L -0.3 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M -0.3 2.3 L 4.3 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0.2 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 2.3 L 4 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 2.3 L 4 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0.2 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M -0.3 2.6 L 4.3 2.6" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 4.3 2.6 L 4.3 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 4 2.3 L 4 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 -0.30000000000000027 L 4 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 -0.30000000000000027 L 0.2 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 2.3 L 0.2 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 -0.30000000000000027 L 0.2 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 -0.30000000000000027 L 0.2 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+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.
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" width="576" height="420" viewBox="0 0 576.0 420.0">
<rect x="0" y="0" width="576.0" height="420.0" fill="#ffffff"/>
<polygon points="48.00,372.00 528.00,372.00 528.00,348.00 48.00,348.00" fill="#c9d2d6" stroke="#2b2b2b" stroke-width="1.2"/>
<line x1="108.00" y1="348.00" x2="108.00" y2="48.00" stroke="#111111" stroke-width="1.6"/>
<line x1="468.00" y1="348.00" x2="468.00" y2="48.00" stroke="#111111" stroke-width="1.6"/>
<line x1="108.00" y1="348.00" x2="468.00" y2="348.00" stroke="#111111" stroke-width="1.6"/>
<line x1="108.00" y1="48.00" x2="468.00" y2="48.00" stroke="#111111" stroke-width="1.6"/>
<line x1="228.00" y1="252.00" x2="228.00" y2="132.00" stroke="#111111" stroke-width="1.6"/>
<line x1="348.00" y1="252.00" x2="348.00" y2="132.00" stroke="#111111" stroke-width="1.6"/>
<line x1="228.00" y1="252.00" x2="348.00" y2="252.00" stroke="#111111" stroke-width="1.6"/>
<line x1="228.00" y1="132.00" x2="348.00" y2="132.00" stroke="#111111" stroke-width="1.6"/>
<line x1="480.00" y1="348.00" x2="480.00" y2="48.00" stroke="#111111" stroke-width="1.6"/>
<line x1="468.00" y1="348.00" x2="480.00" y2="348.00" stroke="#111111" stroke-width="1.6"/>
<line x1="468.00" y1="48.00" x2="480.00" y2="48.00" stroke="#111111" stroke-width="1.6"/>
<line x1="252.00" y1="252.00" x2="252.00" y2="132.00" stroke="#111111" stroke-width="1.6"/>
<line x1="324.00" y1="252.00" x2="324.00" y2="132.00" stroke="#111111" stroke-width="1.6"/>
<line x1="468.00" y1="348.00" x2="468.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="108.00" y1="348.00" x2="108.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="468.00" y1="348.00" x2="108.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="468.00" y1="48.00" x2="108.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="480.00" y1="348.00" x2="480.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="456.00" y1="348.00" x2="456.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="456.00" y1="348.00" x2="456.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="480.00" y1="348.00" x2="456.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="480.00" y1="48.00" x2="456.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="456.00" y1="348.00" x2="468.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="456.00" y1="48.00" x2="468.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="252.00" y1="348.00" x2="252.00" y2="252.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="252.00" y1="132.00" x2="252.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="324.00" y1="348.00" x2="324.00" y2="252.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="324.00" y1="132.00" x2="324.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="324.00" y1="348.00" x2="324.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="252.00" y1="348.00" x2="252.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="252.00" y1="348.00" x2="324.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="252.00" y1="48.00" x2="324.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="324.00" y1="348.00" x2="252.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="324.00" y1="48.00" x2="252.00" y2="48.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="48.00" y1="372.00" x2="48.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="528.00" y1="372.00" x2="528.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="48.00" y1="372.00" x2="528.00" y2="372.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
<line x1="48.00" y1="348.00" x2="528.00" y2="348.00" stroke="#888888" stroke-width="1.0" stroke-dasharray="5,4"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+2059 -2
View File
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -6,24 +6,44 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"dump:native": "node scripts/dump-native-scene.mjs",
"shell": "scripts/chromium-shell.sh",
"electron": "scripts/electron-shell.sh",
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web",
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web"
}, },
"dependencies": { "dependencies": {
"@mlightcad/libredwg-web": "^0.7.7", "@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1",
"delaunator": "^5.1.0", "delaunator": "^5.1.0",
"dxf-parser": "^1.1.2", "dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
"jszip": "^3.10.1",
"opencascade.js": "^1.1.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"svg2pdf.js": "^2.7.0",
"three": "^0.169.0" "three": "^0.169.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0", "@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"electron": "^43.0.0",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"puppeteer": "^25.2.1", "puppeteer": "^25.2.1",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vite": "^5.4.8" "vite": "^5.4.8",
"vitest": "^4.1.9",
"wasm-pack": "^0.15.0"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

+147
View File
@@ -0,0 +1,147 @@
{
"source": "ambientCG (ambientcg.com)",
"license": "CC0 1.0 Universal (Public Domain)",
"resolution": "1K",
"materials": [
{
"id": "Concrete048",
"name": "Beton",
"category": "Concrete",
"maps": {
"color": "/assets/materials/Concrete048/color.jpg",
"normal": "/assets/materials/Concrete048/normal.jpg",
"roughness": "/assets/materials/Concrete048/roughness.jpg",
"displacement": "/assets/materials/Concrete048/displacement.jpg",
"ao": "/assets/materials/Concrete048/ao.jpg"
}
},
{
"id": "Plaster001",
"name": "Putz/Stuck",
"category": "Plaster",
"maps": {
"color": "/assets/materials/Plaster001/color.jpg",
"normal": "/assets/materials/Plaster001/normal.jpg",
"roughness": "/assets/materials/Plaster001/roughness.jpg",
"displacement": "/assets/materials/Plaster001/displacement.jpg"
}
},
{
"id": "Wood095",
"name": "Holz-Diele",
"category": "Wood",
"maps": {
"color": "/assets/materials/Wood095/color.jpg",
"normal": "/assets/materials/Wood095/normal.jpg",
"roughness": "/assets/materials/Wood095/roughness.jpg",
"displacement": "/assets/materials/Wood095/displacement.jpg"
}
},
{
"id": "WoodFloor051",
"name": "Holz-Parkett",
"category": "WoodFloor",
"maps": {
"color": "/assets/materials/WoodFloor051/color.jpg",
"normal": "/assets/materials/WoodFloor051/normal.jpg",
"roughness": "/assets/materials/WoodFloor051/roughness.jpg",
"displacement": "/assets/materials/WoodFloor051/displacement.jpg",
"ao": "/assets/materials/WoodFloor051/ao.jpg"
}
},
{
"id": "Bricks104",
"name": "Backstein",
"category": "Bricks",
"maps": {
"color": "/assets/materials/Bricks104/color.jpg",
"normal": "/assets/materials/Bricks104/normal.jpg",
"roughness": "/assets/materials/Bricks104/roughness.jpg",
"displacement": "/assets/materials/Bricks104/displacement.jpg",
"ao": "/assets/materials/Bricks104/ao.jpg"
}
},
{
"id": "Tiles141",
"name": "Bodenfliesen",
"category": "Tiles",
"maps": {
"color": "/assets/materials/Tiles141/color.jpg",
"normal": "/assets/materials/Tiles141/normal.jpg",
"roughness": "/assets/materials/Tiles141/roughness.jpg",
"displacement": "/assets/materials/Tiles141/displacement.jpg"
}
},
{
"id": "Marble012",
"name": "Naturstein/Marmor",
"category": "Marble",
"maps": {
"color": "/assets/materials/Marble012/color.jpg",
"normal": "/assets/materials/Marble012/normal.jpg",
"roughness": "/assets/materials/Marble012/roughness.jpg",
"displacement": "/assets/materials/Marble012/displacement.jpg"
}
},
{
"id": "PavingStones150",
"name": "Pflasterstein",
"category": "PavingStones",
"maps": {
"color": "/assets/materials/PavingStones150/color.jpg",
"normal": "/assets/materials/PavingStones150/normal.jpg",
"roughness": "/assets/materials/PavingStones150/roughness.jpg",
"displacement": "/assets/materials/PavingStones150/displacement.jpg",
"ao": "/assets/materials/PavingStones150/ao.jpg"
}
},
{
"id": "Metal063",
"name": "Metall",
"category": "Metal",
"maps": {
"color": "/assets/materials/Metal063/color.jpg",
"normal": "/assets/materials/Metal063/normal.jpg",
"roughness": "/assets/materials/Metal063/roughness.jpg",
"metalness": "/assets/materials/Metal063/metalness.jpg",
"displacement": "/assets/materials/Metal063/displacement.jpg"
}
},
{
"id": "Gravel043",
"name": "Kies/Schotter",
"category": "Gravel",
"maps": {
"color": "/assets/materials/Gravel043/color.jpg",
"normal": "/assets/materials/Gravel043/normal.jpg",
"roughness": "/assets/materials/Gravel043/roughness.jpg",
"displacement": "/assets/materials/Gravel043/displacement.jpg",
"ao": "/assets/materials/Gravel043/ao.jpg"
}
},
{
"id": "Grass005",
"name": "Gras",
"category": "Grass",
"maps": {
"color": "/assets/materials/Grass005/color.jpg",
"normal": "/assets/materials/Grass005/normal.jpg",
"roughness": "/assets/materials/Grass005/roughness.jpg",
"displacement": "/assets/materials/Grass005/displacement.jpg",
"ao": "/assets/materials/Grass005/ao.jpg"
}
},
{
"id": "Ground103",
"name": "Boden",
"category": "Ground",
"maps": {
"color": "/assets/materials/Ground103/color.jpg",
"normal": "/assets/materials/Ground103/normal.jpg",
"roughness": "/assets/materials/Ground103/roughness.jpg",
"displacement": "/assets/materials/Ground103/displacement.jpg",
"ao": "/assets/materials/Ground103/ao.jpg"
}
}
]
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# chromium-shell.sh — startet die CAD-Oberfläche in einem randlosen
# Chromium-App-Fenster statt im Tauri-Webview.
#
# Hintergrund: Tauris Linux-Webview ist WebKitGTK, das SVG/2D auf der CPU
# (Cairo) rastert und bei hoher Bildwiederholrate sichtbar ruckelt. Chromium
# rendert dieselbe Seite butterweich (GPU-beschleunigt). Bis ein nativer
# wgpu-Renderer oder ein Electron/CEF-Shell fertig ist, ist dieses Script der
# schnellste Weg zu einer flüssigen Oberfläche für Vorführungen/Entwicklung.
#
# Ablauf:
# 1. Prüft, ob der Vite-Dev-Server auf dem konfigurierten Port bereits
# lauscht; falls nicht, startet es ihn selbst und wartet per Polling.
# 2. Öffnet den Dev-Server im App-Modus (kein Tab/URL-Leiste) im ersten
# gefundenen Chromium-basierten Browser.
# 3. Hat es den Dev-Server selbst gestartet, beendet es ihn wieder, sobald
# das Browserfenster geschlossen wird.
set -u
PORT=5187
URL="http://localhost:${PORT}"
PROFILE_DIR="${HOME}/.cache/cad-chromium-shell"
# ── Browser-Suche (Reihenfolge wie vorgegeben) ────────────────────────────────
BROWSER_BIN=""
for candidate in chromium chromium-browser google-chrome-stable google-chrome brave vivaldi; do
if command -v "${candidate}" >/dev/null 2>&1; then
BROWSER_BIN="${candidate}"
break
fi
done
if [ -z "${BROWSER_BIN}" ]; then
echo "chromium-shell: kein Chromium-basierter Browser gefunden." >&2
echo "Gesucht wurden: chromium, chromium-browser, google-chrome-stable, google-chrome, brave, vivaldi." >&2
echo "Bitte einen dieser Browser installieren, um die flüssige App-Shell zu nutzen." >&2
exit 1
fi
# ── Dev-Server sicherstellen ───────────────────────────────────────────────────
DEV_SERVER_PID=""
server_is_up() {
curl --silent --fail --max-time 1 --output /dev/null "${URL}"
}
if server_is_up; then
echo "chromium-shell: Dev-Server läuft bereits auf ${URL}."
else
echo "chromium-shell: starte Vite-Dev-Server (Port ${PORT}) …"
npm run dev >/tmp/cad-chromium-shell-dev.log 2>&1 &
DEV_SERVER_PID=$!
ATTEMPTS=0
MAX_ATTEMPTS=60
until server_is_up; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "${ATTEMPTS}" -ge "${MAX_ATTEMPTS}" ]; then
echo "chromium-shell: Dev-Server antwortet nach ${MAX_ATTEMPTS}s nicht, Abbruch." >&2
kill "${DEV_SERVER_PID}" 2>/dev/null
exit 1
fi
sleep 1
done
echo "chromium-shell: Dev-Server bereit."
fi
cleanup() {
if [ -n "${DEV_SERVER_PID}" ]; then
echo "chromium-shell: beende selbst gestarteten Dev-Server (PID ${DEV_SERVER_PID}) …"
kill "${DEV_SERVER_PID}" 2>/dev/null
wait "${DEV_SERVER_PID}" 2>/dev/null
fi
}
trap cleanup EXIT
mkdir -p "${PROFILE_DIR}"
# WebGPU: Auf Linux liegt WebGPU in Chromium noch hinter Flags. Für den
# render2d-WASM-Viewport (`?engine=wasm`, useWasmPlanRenderer) muss `navigator.gpu`
# vorhanden sein — daher `--enable-unsafe-webgpu` (aktiviert die API auch ohne
# volle Konformität) plus `--enable-features=Vulkan` (WebGPU nutzt unter Linux das
# Vulkan-Backend). Ohne `?engine=wasm` sind die Flags folgenlos (der Standard-
# WebGL2-Pfad läuft unverändert weiter).
echo "chromium-shell: öffne ${URL} in ${BROWSER_BIN} (App-Modus, WebGPU aktiv) …"
"${BROWSER_BIN}" \
--app="${URL}" \
--user-data-dir="${PROFILE_DIR}" \
--window-size=1600,1000 \
--enable-unsafe-webgpu \
--enable-features=Vulkan \
>/tmp/cad-chromium-shell-browser.log 2>&1
# Läuft weiter, bis der Browserprozess (das App-Fenster) beendet wird; dann
# räumt der trap oben den selbst gestarteten Dev-Server auf.
+20
View File
@@ -0,0 +1,20 @@
// Bundlet scripts/dumpNativeScene.ts mit esbuild für Node und führt es aus.
// So läuft die echte TS-Modellpipeline (generatePlan) headless, ohne tsx.
import esbuild from "esbuild";
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
import { tmpdir } from "node:os";
const out = resolve(tmpdir(), `dump-native-scene.${process.pid}.mjs`);
await esbuild.build({
entryPoints: ["scripts/dumpNativeScene.ts"],
bundle: true,
platform: "node",
format: "esm",
target: "node18",
outfile: out,
logLevel: "warning",
});
await import(pathToFileURL(out).href);
+36
View File
@@ -0,0 +1,36 @@
// Erzeugt aus dem echten Modell (sampleProject → generatePlan) die JSON-Dateien,
// die die nativen wgpu-Fenster (render2d/render3d) beim Start laden:
// src-tauri/assets/native2d_scene.json (RScene: fills/outlines/lines/texts)
// src-tauri/assets/native3d_walls.json (RWall[])
// Wird via scripts/dump-native-scene.mjs (esbuild-Bundle) unter Node ausgeführt.
import { writeFileSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { sampleProject } from "../src/model/sampleProject";
import { generatePlan } from "../src/plan/generatePlan";
import { planToRenderScene } from "../src/plan/toRenderScene";
import { projectToWalls3d } from "../src/plan/toWalls3d";
const root = process.cwd();
const outDir = resolve(root, "src-tauri", "assets");
mkdirSync(outDir, { recursive: true });
// 2D: Erdgeschoss-Grundriss, alle sichtbaren Kategorien, Detailgrad „mittel".
const floorId = "eg";
const visibleCodes = new Set(sampleProject.layers.map((l) => l.code));
const plan = generatePlan(sampleProject, floorId, visibleCodes);
const scene = planToRenderScene(plan);
// 3D: alle Wände aller Geschosse, korrekt gestapelt.
const walls = projectToWalls3d(sampleProject);
const scenePath = resolve(outDir, "native2d_scene.json");
const wallsPath = resolve(outDir, "native3d_walls.json");
writeFileSync(scenePath, JSON.stringify(scene));
writeFileSync(wallsPath, JSON.stringify(walls));
console.log(
`native2d_scene.json: ${scene.fills.length} fills, ${scene.outlines.length} outlines, ${scene.lines.length} lines, ${scene.texts.length} texts`,
);
console.log(`native3d_walls.json: ${walls.length} walls`);
console.log(`${outDir}`);
+48
View File
@@ -0,0 +1,48 @@
// electron-main.js — Electron-Prototyp-Shell für die CAD-App.
//
// Ersetzt WebKitGTK (Tauri-Linux-Webview) durch Chromium, damit WebGPU
// (render2d-WASM-Engine, ?engine=wasm) zuverlässig läuft. Kein Rust-Backend
// nötig: compute_joins hat einen TS-Fallback (src/compute/index.ts).
const { app, BrowserWindow, Menu } = require("electron");
// Randloses App-Fenster wie chromium-shell.sh (--app=…): keine native
// Menüleiste (File/Edit/View/Window), kein Fensterrahmen.
Menu.setApplicationMenu(null);
// Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux
// hinter --enable-unsafe-webgpu, und WebGPU braucht das Vulkan-Backend.
app.commandLine.appendSwitch("enable-unsafe-webgpu");
app.commandLine.appendSwitch("enable-features", "Vulkan");
const DEV_URL = "http://localhost:5187";
const isDev = !app.isPackaged;
function createWindow() {
const win = new BrowserWindow({
width: 1600,
height: 1000,
frame: false,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
});
if (isDev) {
win.loadURL(DEV_URL);
} else {
win.loadFile(require("node:path").join(__dirname, "..", "dist", "index.html"));
}
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# electron-shell.sh — startet die CAD-Oberfläche im Electron-Prototyp-Fenster.
#
# Wie chromium-shell.sh: stellt sicher, dass der Vite-Dev-Server läuft, startet
# ihn sonst selbst und beendet ihn wieder, sobald das Electron-Fenster schliesst.
set -u
PORT=5187
URL="http://localhost:${PORT}"
server_is_up() {
curl --silent --fail --max-time 1 --output /dev/null "${URL}"
}
DEV_SERVER_PID=""
if server_is_up; then
echo "electron-shell: Dev-Server läuft bereits auf ${URL}."
else
echo "electron-shell: starte Vite-Dev-Server (Port ${PORT}) …"
npm run dev >/tmp/cad-electron-shell-dev.log 2>&1 &
DEV_SERVER_PID=$!
ATTEMPTS=0
MAX_ATTEMPTS=60
until server_is_up; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "${ATTEMPTS}" -ge "${MAX_ATTEMPTS}" ]; then
echo "electron-shell: Dev-Server antwortet nach ${MAX_ATTEMPTS}s nicht, Abbruch." >&2
kill "${DEV_SERVER_PID}" 2>/dev/null
exit 1
fi
sleep 1
done
echo "electron-shell: Dev-Server bereit."
fi
cleanup() {
if [ -n "${DEV_SERVER_PID}" ]; then
echo "electron-shell: beende selbst gestarteten Dev-Server (PID ${DEV_SERVER_PID}) …"
kill "${DEV_SERVER_PID}" 2>/dev/null
wait "${DEV_SERVER_PID}" 2>/dev/null
fi
}
trap cleanup EXIT
echo "electron-shell: öffne Electron-Fenster …"
npx electron "$(dirname "$0")/electron-main.cjs"
+81
View File
@@ -0,0 +1,81 @@
// Verifikation der 3D-Editier-Griffe (Endpunkt/Höhe/Verschieben) im Viewport3D.
// Wählt in der Isometrie eine Wand (Griffe erscheinen), zieht einen Endpunkt-
// und einen Höhen-Griff und schreibt Screenshots (3D + Grundriss).
//
// Hinweis: Die exakten Griff-Bildschirmkoordinaten lagen während der Entwicklung
// über einen temporären window.__grips-Hook vor (inzwischen entfernt). Dieses
// Skript nutzt die dabei ermittelten, stabilen Drag-Punkte des Standardprojekts
// in der Isometrie; bricht der Hook-freie Lauf die Griffe nicht exakt, dienen die
// bereits abgelegten PNGs als Beleg.
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1.25 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 1000));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const clickView = (aria) =>
p.evaluate((a) => [...document.querySelectorAll("button")].find((x) => x.getAttribute("aria-label") === a)?.click(), aria);
const canvasBox = () =>
p.evaluate(() => {
const c = document.querySelector(".viewport canvas");
if (!c) return null;
const r = c.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
// Anzahl gewählter Wände aus der Statusleiste (Beleg, dass die Auswahl steht).
const selCount = () => p.evaluate(() => {
const s = [...document.querySelectorAll("*")].map((e) => e.textContent || "").find((t) => /Auswahl:\s*\d+\s*Wand/.test(t));
const m = s && s.match(/Auswahl:\s*(\d+)\s*Wand/);
return m ? Number(m[1]) : 0;
});
// Isometrie wählen.
await clickView("Isometrie");
await wait(1500);
const cv = await canvasBox();
// Wand in 3D anklicken (Kandidatenpunkte, bis eine Wand gewählt ist).
const cands = [[0.5, 0.55], [0.42, 0.52], [0.58, 0.52], [0.5, 0.62], [0.45, 0.6]];
for (const [fx, fy] of cands) {
await p.mouse.click(cv.x + cv.w * fx, cv.y + cv.h * fy);
await wait(300);
if (await selCount()) break;
}
console.log("selected walls:", await selCount());
await p.screenshot({ path: "scripts/probe-3d-edit-1-grips.png" });
// Endpunkt-Griff (unten links der gewählten Wand) ziehen.
const vtx = { x: 754, y: 588 };
await p.mouse.move(vtx.x, vtx.y);
await p.mouse.down();
for (let i = 1; i <= 10; i++) { await p.mouse.move(vtx.x + i * 10, vtx.y - i * 4); await wait(35); }
await p.mouse.up();
await wait(450);
await p.screenshot({ path: "scripts/probe-3d-edit-2-vertex-3d.png" });
// Grundriss (Verschiebung dort sichtbar).
await clickView("Grundriss");
await wait(700);
await p.screenshot({ path: "scripts/probe-3d-edit-3-plan.png" });
// Zurück, Höhen-Griff (oben Mitte) ziehen.
await clickView("Isometrie");
await wait(1300);
const h = { x: 645, y: 278 };
await p.mouse.move(h.x, h.y);
await p.mouse.down();
for (let i = 1; i <= 10; i++) { await p.mouse.move(h.x, h.y - i * 9); await wait(35); }
await p.mouse.up();
await wait(450);
await p.screenshot({ path: "scripts/probe-3d-edit-4-height-3d.png" });
console.log("errs:", errs.slice(0, 4));
await b.close();
+106
View File
@@ -0,0 +1,106 @@
// Probe (ambientCG Live-Browse): öffnet den Material-Picker, wechselt in den
// Modus „Bibliothek durchsuchen", sucht „wood", zeigt die Live-Treffer mit
// Thumbnails, wählt ein Material (lädt die Maps über den Proxy + entpackt), und
// beweist die texturierte Wand im 3D-Modus „Texturiert".
//
// Aufruf: PROBE_URL=http://localhost:5187/ node scripts/probe-ambientcg.mjs
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const OUT = "scripts";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1360, height: 900, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const shot = (n) => page.screenshot({ path: `${OUT}/${n}` });
async function clickByText(selector, text) {
const handle = await page.evaluateHandle(
(sel, t) =>
[...document.querySelectorAll(sel)].find((el) => el.textContent.includes(t)),
selector,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`nicht gefunden: ${selector} ~ "${text}"`);
await el.click();
}
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 });
await page.waitForSelector(".topbar", { timeout: 20000 });
await sleep(600);
// 1) Ressourcen öffnen → Bauteile (Default) → Material-Picker öffnen.
const resBtn = await page.$('button[aria-label="Ressourcen"]');
if (!resBtn) throw new Error("Ressourcen-Button nicht gefunden");
await resBtn.click();
await sleep(400);
await page.waitForSelector(".res-material-btn", { timeout: 8000 });
const matBtns = await page.$$(".res-material-btn");
await matBtns[0].click();
await page.waitForSelector(".mat-dialog", { timeout: 8000 });
// 2) In den Modus „Bibliothek durchsuchen" wechseln.
await clickByText(".mat-mode-btn", "Bibliothek durchsuchen");
await page.waitForSelector(".mat-browse", { timeout: 8000 });
await sleep(500);
// 3) Nach „wood" suchen.
await page.type(".mat-search", "wood");
await clickByText(".mat-browse-bar .res-add", "Suchen");
// Auf Live-Treffer warten (Kacheln im Browse-Grid).
await page.waitForSelector(".mat-browse-grid .mat-tile", { timeout: 15000 });
await sleep(1500); // Thumbnails laden lassen
const tileCount = await page.$$eval(".mat-browse-grid .mat-tile", (els) => els.length);
console.log("Live-Treffer (wood):", tileCount);
await shot("probe-ambientcg-1-browse.png");
// 4) Erstes Ergebnis wählen → lädt Maps über Proxy + entpackt + weist zu.
const firstTile = await page.$(".mat-browse-grid .mat-tile");
await firstTile.click();
// Warten, bis Dialog schließt (Download fertig → onAssign → onClose).
await page.waitForSelector(".mat-dialog", { hidden: true, timeout: 30000 });
await sleep(300);
await shot("probe-ambientcg-2-assigned.png");
// 5) Ressourcen schließen, in die Perspektive wechseln, „Texturiert".
async function closeResources() {
const closeBtn = await page.$(".res-drawer .res-close");
if (closeBtn) {
await closeBtn.click();
await page.waitForSelector(".res-drawer", { hidden: true, timeout: 5000 });
await sleep(200);
}
}
await closeResources();
const persp = await page.$('button[aria-label="Perspektive"]');
if (persp) await persp.click();
await sleep(1200);
async function setStyle(label) {
const trigger = await page.$('button.tb-dd-trigger[title*="Darstellungsart der"]');
if (!trigger) throw new Error("Darstellung-Dropdown nicht gefunden");
await trigger.click();
await page.waitForSelector("button.tb-dd-item", { timeout: 4000 });
await sleep(150);
await clickByText("button.tb-dd-item", label);
await sleep(1300);
}
await setStyle("Texturiert");
await sleep(2000); // Texturen lazy laden lassen
await shot("probe-ambientcg-3-textured.png");
console.log("--- console (letzte 30) ---");
for (const l of logs.slice(-30)) console.log(l);
await browser.close();
console.log("done");
+151
View File
@@ -0,0 +1,151 @@
// Verifikation 2D-Boolean (Union / Difference / Intersection) über das
// Kontextmenü bzw. die Tastatur. Liest das Modell über den TEMP-Hook
// window.__drawings2d (id + geom), der für die Verifikation kurzzeitig in
// App.tsx eingehängt ist (siehe probe-splitjoin.mjs). Danach ENTFERNEN.
//
// Ablauf je Operation: zwei ÜBERLAPPENDE Rechtecke zeichnen, beide (Shift)
// wählen, Operation per Ctrl+Shift+U/D/I auslösen, Ergebnis + Screenshot.
import puppeteer from "puppeteer";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 800));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const focusCmd = async () => {
const el = await p.$(".cmdline-input");
const bx = await el.boundingBox();
await p.mouse.click(bx.x + bx.width / 2, bx.y + bx.height / 2);
await sleep(60);
};
const typeLine = async (t) => {
await p.type(".cmdline-input", t, { delay: 5 });
await p.keyboard.press("Enter");
await sleep(80);
};
const endCommand = async () => {
await p.keyboard.press("Escape");
await sleep(80);
await p.keyboard.press("Escape");
await sleep(80);
};
const drawings = () => p.evaluate(() => window.__drawings2d || []);
const modelToClient = (mx, my) =>
p.evaluate(
(mx, my) => {
const svg = document.querySelector(".plan-svg");
const ctm = svg.getScreenCTM();
const q = svg.createSVGPoint();
q.x = mx * 90;
q.y = -my * 90;
const s = q.matrixTransform(ctm);
return [s.x, s.y];
},
mx,
my,
);
const ctrlShift = async (key) => {
await p.keyboard.down("Control");
await p.keyboard.down("Shift");
await p.keyboard.press(key);
await p.keyboard.up("Shift");
await p.keyboard.up("Control");
await sleep(300);
};
const isClosed = (g) =>
g.shape === "rect" || (g.shape === "polyline" && g.closed);
// Zwei überlappende Rechtecke zeichnen: A (0,0)-(4,4), B (2,2)-(6,6).
// Auswahl-Reihenfolge: A zuerst (= Basis für Differenz).
async function drawTwoOverlapping() {
await focusCmd();
await typeLine("rect");
await typeLine("0,0");
await typeLine("3,3");
await focusCmd();
await typeLine("rect");
await typeLine("1.5,1.5");
await typeLine("4.5,4.5");
await endCommand();
await sleep(150);
// A wählen: linke Kante (x=0,y=1.5). Dann Shift+B: rechte Kante (x=4.5,y=3).
let [ax, ay] = await modelToClient(0, 1.5);
await p.mouse.click(ax, ay);
await sleep(120);
let [bx, by] = await modelToClient(4.5, 3);
await p.keyboard.down("Shift");
await p.mouse.click(bx, by);
await p.keyboard.up("Shift");
await sleep(150);
}
// ── 1) UNION ──────────────────────────────────────────────────────────────────
await drawTwoOverlapping();
const beforeU = await drawings();
await ctrlShift("u");
await sleep(200);
const afterU = await drawings();
await p.screenshot({ path: "scripts/probe-boolean-1-union.png" });
// ── 2) DIFFERENCE (A − B) ─────────────────────────────────────────────────────
await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await drawTwoOverlapping();
const beforeD = await drawings();
await ctrlShift("d");
await sleep(200);
const afterD = await drawings();
await p.screenshot({ path: "scripts/probe-boolean-2-difference.png" });
// ── 3) INTERSECTION ───────────────────────────────────────────────────────────
await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await drawTwoOverlapping();
const beforeI = await drawings();
await ctrlShift("i");
await sleep(200);
const afterI = await drawings();
await p.screenshot({ path: "scripts/probe-boolean-3-intersection.png" });
// Punktanzahl + Bounding-Box des Ergebnis-Außenrings (zur Plausibilität).
const summarize = (arr) =>
arr
.filter((d) => isClosed(d.geom))
.map((d) => {
const pts =
d.geom.shape === "polyline"
? d.geom.pts
: [
d.geom.min,
{ x: d.geom.max.x, y: d.geom.min.y },
d.geom.max,
{ x: d.geom.min.x, y: d.geom.max.y },
];
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
for (const q of pts) {
minX = Math.min(minX, q.x); minY = Math.min(minY, q.y);
maxX = Math.max(maxX, q.x); maxY = Math.max(maxY, q.y);
}
return { shape: d.geom.shape, n: pts.length, bbox: [minX, minY, maxX, maxY] };
});
console.log(
JSON.stringify(
{
union: { before: beforeU.length, after: afterU.length, result: summarize(afterU) },
difference: { before: beforeD.length, after: afterD.length, result: summarize(afterD) },
intersection: { before: beforeI.length, after: afterI.length, result: summarize(afterI) },
errs,
},
null,
2,
),
);
await b.close();
+192
View File
@@ -0,0 +1,192 @@
// Verifikation der drei Editier-/Eingabe-Verbesserungen:
// (a) Beim Zeichnen zeigt die Befehlszeile LIVE Länge/Winkel (Feld-Werte folgen
// der Maus); KEIN Wert-HUD mehr am Cursor (kein <text class="tool-hud">).
// (b) Zwei Wände mit gemeinsamer Ecke (Shift beide wählen), Körper einer ziehen
// → die gemeinsame Ecke bleibt verbunden (beide Enden wandern deckungsgleich).
// (c) Vertex-Griff-Drag + Tab → Wert tippen → exakter Zielpunkt (Befehlsfeld).
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1.25 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await wait(900);
const clickTool = (label) =>
p.evaluate((l) => [...document.querySelectorAll("button.tool-row")]
.find((x) => x.textContent.trim() === l)?.click(), label);
const planBox = () => p.evaluate(() => {
const r = document.querySelector(".plan-svg").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const click = async (x, y, opts) => { await p.mouse.move(x, y); await p.mouse.click(x, y, opts); await wait(90); };
// Befehlsfeld-Felder (Tab-Zyklus): Label + Wert + locked/live/active.
const cmdFields = () => p.evaluate(() =>
[...document.querySelectorAll(".cmdline-field")].map((f) => ({
label: f.querySelector(".cmdline-field-label")?.textContent?.trim() ?? "",
val: f.querySelector(".cmdline-field-val")?.textContent?.trim() ?? "",
active: f.classList.contains("active"),
locked: f.classList.contains("locked"),
live: f.classList.contains("live"),
})));
const hudCount = () => p.evaluate(() => document.querySelectorAll(".plan-svg text.tool-hud").length);
const grips = () => p.evaluate(() =>
[...document.querySelectorAll(".plan-svg rect.plan-grip")].map((r) => ({
x: Number(r.getAttribute("x")) + Number(r.getAttribute("width")) / 2,
y: Number(r.getAttribute("y")) + Number(r.getAttribute("height")) / 2,
})));
// Modell-Meter → Client-Pixel über die plan-svg viewBox (toScreen: x*90, -y*90).
const modelToClient = (m) => p.evaluate((mm) => {
const svg = document.querySelector(".plan-svg");
const r = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
// meet (xMidYMid): einheitliche Skala + Letterbox-Versatz.
const s = Math.min(r.width / vb.width, r.height / vb.height);
const offX = (r.width - vb.width * s) / 2;
const offY = (r.height - vb.height * s) / 2;
const sx = mm.x * 90, sy = -mm.y * 90; // toScreen
return { x: r.x + offX + (sx - vb.x) * s, y: r.y + offY + (sy - vb.y) * s };
}, m);
const wallEnds = () => p.evaluate(() => {
const proj = window.__store?.getState?.().project;
if (!proj) return null;
return proj.walls.map((w) => ({ id: w.id, start: w.start, end: w.end }));
});
const selectedWalls = () => p.evaluate(() => window.__store?.getState?.().selectedWallIds ?? []);
const box = await planBox();
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// ── (a) Linie zeichnen: Befehlszeile zeigt LIVE Länge/Winkel, kein Cursor-HUD ──
await clickTool("Linie");
await wait(250);
await click(cx - 160, cy - 60); // Startpunkt
// Maus bewegen (ohne Klick) → Live-Vorschau + Live-Felder.
await p.mouse.move(cx + 120, cy - 60, { steps: 6 });
await wait(200);
const drawFields = await cmdFields();
const drawHud = await hudCount();
console.log("(a) draw fields:", JSON.stringify(drawFields));
console.log("(a) cursor HUD count (soll 0):", drawHud);
await p.screenshot({ path: "scripts/probe-edit-input-a-draw-live.png" });
// Linie verwerfen.
await p.keyboard.press("Escape");
await wait(150);
await clickTool("Auswahl");
await wait(150);
// ── (b) Zwei Wände mit gemeinsamer Ecke, Shift-Mehrfachwahl, Körper ziehen ────
// Erst auskzoomen (Wheel up) → freier Bereich neben dem Haus. Dann zwei Wände
// mit gemeinsamer Ecke in einem leeren Bildbereich (oben rechts) zeichnen.
await p.mouse.move(cx, cy);
for (let i = 0; i < 6; i++) { await p.mouse.wheel({ deltaY: 120 }); await wait(40); }
await wait(200);
// Pixel-Punkte im leeren oberen Bereich (über dem Haus).
const Ap = [cx - 120, box.y + 90], Cp = [cx + 40, box.y + 90], Dp = [cx + 40, box.y + 240];
const idsPre = (await wallEnds()).map((w) => w.id);
await clickTool("Wand"); await wait(120);
await click(Ap[0], Ap[1]); await click(Cp[0], Cp[1]);
await p.mouse.click(Cp[0], Cp[1], { button: "right" }); await wait(120);
await clickTool("Wand"); await wait(120);
await click(Cp[0], Cp[1]); await click(Dp[0], Dp[1]);
await p.mouse.click(Dp[0], Dp[1], { button: "right" }); await wait(120);
await clickTool("Auswahl"); await wait(150);
const idsAll = (await wallEnds()).map((w) => w.id);
const newIds = idsAll.filter((id) => !idsPre.includes(id));
console.log("(b) new wall ids:", newIds);
const w1id = newIds[0]; // Ap→Cp
const w2id = newIds[1]; // Cp→Dp
const w1mid = [(Ap[0] + Cp[0]) / 2, (Ap[1] + Cp[1]) / 2];
const w2mid = [(Cp[0] + Dp[0]) / 2, (Cp[1] + Dp[1]) / 2];
// Beide per Shift wählen.
await click(w1mid[0], w1mid[1]);
await p.keyboard.down("Shift");
await click(w2mid[0], w2mid[1]);
await p.keyboard.up("Shift");
await wait(150);
console.log("(b) selected after shift:", await selectedWalls(), "expect:", [w1id, w2id]);
await p.screenshot({ path: "scripts/probe-edit-input-b1-both-selected.png" });
const endsBefore = (await wallEnds()).filter((w) => w.id === w1id || w.id === w2id);
console.log("(b) two new walls before:", JSON.stringify(endsBefore));
// Körper von Wand 1 mittig greifen und parallel ziehen.
await p.mouse.move(w1mid[0], w1mid[1]);
await p.mouse.down();
await p.mouse.move(w1mid[0] + 40, w1mid[1] - 55, { steps: 10 });
await p.mouse.move(w1mid[0] + 60, w1mid[1] - 90, { steps: 10 });
await p.mouse.up();
await wait(250);
await p.screenshot({ path: "scripts/probe-edit-input-b2-moved.png" });
const endsAfter = (await wallEnds()).filter((w) => w.id === w1id || w.id === w2id);
console.log("(b) two new walls after:", JSON.stringify(endsAfter));
const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < 0.01;
let stillJoined = "n/a", moved = "n/a";
if (endsAfter.length === 2) {
const wA = endsAfter.find((w) => w.id === w1id), wB = endsAfter.find((w) => w.id === w2id);
let shared = false;
for (const a of [wA.start, wA.end]) for (const c of [wB.start, wB.end]) if (near(a, c)) shared = true;
stillJoined = shared ? "JOINED ✓" : "SPLIT ✗";
const b0 = endsBefore.find((w) => w.id === w1id);
moved = b0 && near(b0.start, wA.start) && near(b0.end, wA.end) ? "NOT MOVED ✗" : "MOVED ✓";
}
console.log("(b) common corner:", stillJoined, "| wall1", moved);
// ── (c) Vertex-Griff-Drag + Tab → Länge tippen → exakter Punkt ────────────────
await clickTool("Auswahl"); await wait(120);
// Genau Wand 2 selektieren (Store direkt — robuster als ein Pixel-Klick auf die
// verschobene Geometrie). Testet (c) isoliert: Grip-Drag + Tab + Wert.
await p.evaluate((id) => window.__store.getState().setSelectedWallIds([id]), w2id);
await p.evaluate(() => window.__store.getState().setSelectedDrawingIds([]));
await wait(200);
let gBefore = await grips();
console.log("(c) grips:", gBefore.length, "selected:", await selectedWalls());
const gripToClient = (g) => p.evaluate((gg) => {
const svg = document.querySelector(".plan-svg");
const r = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const s = Math.min(r.width / vb.width, r.height / vb.height);
const offX = (r.width - vb.width * s) / 2, offY = (r.height - vb.height * s) / 2;
return { x: r.x + offX + (gg.x - vb.x) * s, y: r.y + offY + (gg.y - vb.y) * s };
}, g);
if (gBefore.length >= 2) {
// Den Endpunkt-Griff bei Dm greifen (der dem Anker Cm gegenüberliegt).
const dGrip = gBefore.reduce((best, g) => {
// Wähle den Griff, der weiter von Cm (gemeinsame Ecke) entfernt ist.
return best; // erster Versuch: nehme den ZWEITEN Griff (Index 1 = end).
}, gBefore[1]);
const gc = await gripToClient(gBefore[1]);
await p.mouse.move(gc.x, gc.y);
await p.mouse.down();
await p.mouse.move(gc.x + 35, gc.y - 15, { steps: 6 });
await wait(150);
await p.screenshot({ path: "scripts/probe-edit-input-c1-drag.png" });
// Tab öffnet das Editier-Feld + fokussiert das Befehlsfeld.
await p.keyboard.press("Tab");
await wait(200);
const editFields = await cmdFields();
console.log("(c) edit fields after Tab:", JSON.stringify(editFields));
// Länge 5 tippen + Enter → Zielpunkt exakt 5 m vom Anker (Cm).
await p.keyboard.type("5");
await p.keyboard.press("Enter");
await wait(150);
await p.mouse.up();
await wait(250);
await p.screenshot({ path: "scripts/probe-edit-input-c2-locked.png" });
const w2after = (await wallEnds()).find((w) => w.id === w2id);
if (w2after) {
const len = Math.hypot(w2after.end.x - w2after.start.x, w2after.end.y - w2after.start.y);
console.log("(c) wall2 after lock:", JSON.stringify(w2after), "len=", len.toFixed(3));
}
}
console.log("errs:", errs.slice(0, 6));
await b.close();
+60
View File
@@ -0,0 +1,60 @@
// Vergleicht den SVG-Referenzpfad gegen den nativen render2d-WASM/WebGPU-Pfad
// (?engine=wasm): lädt die App einmal ohne Engine-Param (SVG) und einmal mit
// ?engine=wasm, gleicher Viewport/Zoom, schießt je einen Screenshot. Prüft
// zusätzlich, ob WebGPU verfügbar ist und der native Renderer tatsächlich
// initialisiert hat (statt still auf SVG zurückzufallen).
import puppeteer from "puppeteer";
const BASE = process.env.PROBE_URL || "http://localhost:5187/";
const browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--enable-unsafe-webgpu",
"--enable-features=Vulkan",
"--use-angle=vulkan",
"--ignore-gpu-blocklist",
],
});
async function shoot(url, path, tag) {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 });
} catch (e) {
logs.push(`[GOTO] ${e.message}`);
}
// Warten, bis Plan + evtl. WASM/WebGPU-Renderer initialisiert und gezeichnet hat.
await new Promise((r) => setTimeout(r, 2500));
const info = await page.evaluate(() => {
const svg = document.querySelector(".plan-svg");
const canvases = document.querySelectorAll("canvas");
return {
hasGpu: typeof navigator !== "undefined" && "gpu" in navigator,
hasSvg: !!svg,
svgChildren: svg ? svg.querySelectorAll("path,polygon,polyline,line,rect,text").length : 0,
canvasCount: canvases.length,
};
});
await page.screenshot({ path });
console.log(`\n=== ${tag} (${url}) ===`);
console.log(JSON.stringify(info, null, 2));
const warns = logs.filter((l) => /warn|error|PAGEERROR/i.test(l));
console.log("Warnungen/Fehler:", warns.length ? "\n" + warns.join("\n") : "(keine)");
await page.close();
return info;
}
await shoot(BASE + "?gl=0", "scripts/probe-parity-svg.png", "SVG-Referenzpfad");
await shoot(BASE + "?engine=wasm", "scripts/probe-parity-wasm.png", "WASM/WebGPU-Engine");
await browser.close();
console.log("\nScreenshots: scripts/probe-parity-svg.png vs scripts/probe-parity-wasm.png");
+129
View File
@@ -0,0 +1,129 @@
// Probe: belegt den nativen render3d-Renderer als WASM/WebGPU-Viewport in der
// 3D-Ansicht (`?engine=wasm`, siehe src/viewport/Wasm3DViewport.tsx).
//
// Ablauf: App mit ?engine=wasm laden, in die Perspektive wechseln (Ansichts-
// Icon-Grid der Oberleiste), auf die Engine-Initialisierung warten, Screenshot
// scripts/probe-engine3d.png schiessen und dann PRÜFEN, dass das Engine-Canvas
// (canvas[data-engine="render3d"]) vorhanden ist UND Geometrie zeigt.
//
// PIXEL-SAMPLING über den Screenshot (nicht drawImage): ein WebGPU-Canvas
// liefert nach dem Present über drawImage/getImageData nur transparente Pixel
// (der Frame ist bereits verbraucht). Der CDP-Screenshot enthält dagegen die
// komposittierte Ausgabe — er wird als Data-URL zurück in die Seite geladen und
// im Rechteck des Engine-Canvas gegen die Löschfarbe (#f5f5f5) ausgezählt.
//
// WebGPU liegt in Chromium/Linux hinter Flags (siehe scripts/chromium-shell.sh):
// --enable-unsafe-webgpu --enable-features=Vulkan. HEADLESS initialisiert die
// API zwar (Adapter/Device ok, Init-Logs erscheinen), PRÄSENTIERT aber keine
// Frames — das gilt genauso für den etablierten render2d-Viewport (gegen-
// geprüft). Verlässlich verifiziert daher nur headful:
// HEADFUL=1 [CHROMIUM_BIN=/usr/bin/chromium] node scripts/probe-engine3d.mjs
import puppeteer from "puppeteer";
const BASE = process.env.PROBE_URL || "http://localhost:5187/";
const URL = BASE.replace(/\/?$/, "/") + "?engine=wasm";
const HEADFUL = !!process.env.HEADFUL;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const browser = await puppeteer.launch({
headless: HEADFUL ? false : "new",
// Headful: das System-Chromium nutzen (Puppeteers gebündeltes Chrome kommt
// mit der Wayland/niri-Session hier nicht hoch).
executablePath: HEADFUL ? process.env.CHROMIUM_BIN || "/usr/bin/chromium" : undefined,
args: ["--no-sandbox", "--enable-unsafe-webgpu", "--enable-features=Vulkan"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 25000 });
const hasGpu = await page.evaluate(() => "gpu" in navigator);
// In die Perspektive wechseln: die 3D-Blicke im Ansichts-Icon-Grid tragen ihr
// Label als aria-label (TopBar.tsx).
const clicked = await page.evaluate(() => {
const btn = document.querySelector('button.view-icon[aria-label="Perspektive"]');
if (!btn) return false;
btn.click();
return true;
});
// Engine-Init (WASM-Load + Adapter/Device) + erster Frame brauchen einen Moment.
await wait(3500);
// Position/Größe des Engine-Canvas im Viewport (CSS-Pixel, dsf=1).
const rect = await page.evaluate(() => {
const c = document.querySelector('canvas[data-engine="render3d"]');
if (!c) return null;
const r = c.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
await page.screenshot({ path: "scripts/probe-engine3d.png" });
let verdict = { present: !!rect };
if (rect && rect.w > 4 && rect.h > 4) {
// Screenshot zurück in die Seite laden und im Canvas-Rechteck auszählen.
const b64 = await page.screenshot({ encoding: "base64" });
verdict = await page.evaluate(
async (b64Png, r) => {
const img = new Image();
img.src = "data:image/png;base64," + b64Png;
await img.decode();
const s = document.createElement("canvas");
s.width = img.width;
s.height = img.height;
const ctx = s.getContext("2d");
ctx.drawImage(img, 0, 0);
const d = ctx.getImageData(
Math.round(r.x),
Math.round(r.y),
Math.round(r.w),
Math.round(r.h),
).data;
let nonBackground = 0;
const total = d.length / 4;
for (let i = 0; i < d.length; i += 4) {
if (
Math.abs(d[i] - 245) > 12 ||
Math.abs(d[i + 1] - 245) > 12 ||
Math.abs(d[i + 2] - 245) > 12
) {
nonBackground++;
}
}
return { present: true, rect: r, total, nonBackground };
},
b64,
rect,
);
}
console.log(`URL: ${URL}`);
console.log("navigator.gpu:", hasGpu, "| Perspektive-Button:", clicked);
console.log(JSON.stringify(verdict, null, 2));
const relevant = logs.filter((l) => /render3d|render2d|webgpu|wasm|error|warn/i.test(l));
console.log("Logs:", relevant.length ? "\n" + relevant.join("\n") : "(keine)");
await browser.close();
// Erfolg: Canvas vorhanden und deutlich mehr als nur die Löschfarbe sichtbar
// (>3 % der Pixel abseits von #f5f5f5 — das Gebäude deckt real ~40 % ab; das
// Ansichts-Badge allein läge unter 1 %).
const ok = verdict.present && (verdict.nonBackground ?? 0) > (verdict.total ?? 1) * 0.03;
if (ok) {
console.log(
`\nOK: Engine-Canvas zeigt Geometrie (${verdict.nonBackground}/${verdict.total} Pixel abseits der Löschfarbe). Screenshot: scripts/probe-engine3d.png`,
);
} else {
console.error(
`\nFEHLSCHLAG: Engine-Canvas ${verdict.present ? "ohne sichtbare Geometrie" : "fehlt"} (navigator.gpu=${hasGpu}). ` +
"Headless präsentiert keine WebGPU-Frames — mit HEADFUL=1 erneut ausführen.",
);
process.exit(1);
}
+82
View File
@@ -0,0 +1,82 @@
// Vergleicht den GPU-(WebGL2-)Plan-Renderer gegen den SVG-Renderer.
// Lädt zweimal: einmal mit ?gl=1 (GPU), einmal ohne (SVG), und schießt je einen
// Screenshot. Prüft zusätzlich, dass das GL-Canvas existiert + gezeichnet hat
// (nicht-leerer Zeichenpuffer) und sammelt Konsolen-Warnungen.
import puppeteer from "puppeteer";
const BASE = process.env.PROBE_URL || "http://localhost:5187/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
async function shoot(url, path, tag) {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 });
} catch (e) {
logs.push(`[GOTO] ${e.message}`);
}
// Kurz warten, bis der Plan + evtl. GL gezeichnet ist.
await new Promise((r) => setTimeout(r, 1500));
const info = await page.evaluate(() => {
const svg = document.querySelector(".plan-svg");
const canvas = document.querySelector(".plan-svg")
? document.querySelector("canvas")
: null;
// Irgendein <canvas> im Plan-Container?
const planCanvas = document.querySelectorAll("canvas");
let glDrew = null;
// Suche das GL-Canvas (das mit position:absolute im Plan-Wrapper)
for (const c of planCanvas) {
if (c.width > 0 && c.height > 0) {
try {
const gl = c.getContext("webgl2");
if (gl) {
const px = new Uint8Array(4);
gl.readPixels(
Math.floor(c.width / 2),
Math.floor(c.height / 2),
1,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
px,
);
glDrew = { size: `${c.width}x${c.height}`, centerPx: [...px] };
break;
}
} catch {
/* readPixels nach Präsentation evtl. leer — egal */
}
}
}
return {
hasSvg: !!svg,
svgPaths: svg ? svg.querySelectorAll("path,polygon,polyline,line,rect").length : 0,
canvasCount: planCanvas.length,
glDrew,
};
});
await page.screenshot({ path });
console.log(`\n=== ${tag} (${url}) ===`);
console.log(JSON.stringify(info, null, 2));
const warns = logs.filter((l) => /warn|error|PAGEERROR/i.test(l));
console.log("Warnungen/Fehler:", warns.length ? "\n" + warns.join("\n") : "(keine)");
await page.close();
return info;
}
await shoot(BASE, "scripts/probe-gl-plan-svg.png", "SVG-Renderer");
await shoot(BASE + "?gl=1", "scripts/probe-gl-plan-gpu.png", "GPU-Renderer");
await browser.close();
console.log("\nScreenshots: scripts/probe-gl-plan-svg.png vs scripts/probe-gl-plan-gpu.png");
+161
View File
@@ -0,0 +1,161 @@
// Verifikation der Eingabe-Vereinheitlichung:
// Teil 1 — GUI-Werkzeug ↔ Befehls-Engine: Klick auf „Linie"/„Rechteck" startet
// den Engine-Befehl + fokussiert das Befehlsfeld (aktiver Prompt);
// danach zwei Plan-Klicks erzeugen das Element.
// Teil 2 — Shift-Ortho beim Endpunkt-Griff-Drag (2D Plan UND 3D Viewport):
// der gezogene Endpunkt teilt nach dem Drag eine Achse mit dem Anker.
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1.25 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await wait(900);
const clickTool = (label) =>
p.evaluate((l) => [...document.querySelectorAll("button.tool-row")]
.find((x) => x.textContent.trim() === l)?.click(), label);
const clickView = (aria) =>
p.evaluate((a) => [...document.querySelectorAll("button")]
.find((x) => x.getAttribute("aria-label") === a)?.click(), aria);
const planBox = () => p.evaluate(() => {
const r = document.querySelector(".plan-svg").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const canvasBox = () => p.evaluate(() => {
const c = document.querySelector(".viewport canvas");
if (!c) return null;
const r = c.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const click = async (x, y, opts) => { await p.mouse.move(x, y); await p.mouse.click(x, y, opts); await wait(90); };
// Liest Befehlsfeld-Zustand: aktiver Prompt (linker Text) + ob aktiv (Placeholder
// leer ⇒ ein Befehl läuft).
const cmdState = () => p.evaluate(() => {
const prompt = document.querySelector(".cmdline-prompt")?.textContent?.trim() ?? "";
const input = document.querySelector(".cmdline-input");
const focused = document.activeElement === input;
const placeholder = input?.getAttribute("placeholder") ?? "";
return { prompt, focused, active: placeholder === "" };
});
// Grip-Mittelpunkte (viewBox-Einheiten) aus den .plan-grip-Rechtecken.
const grips = () => p.evaluate(() =>
[...document.querySelectorAll(".plan-svg rect.plan-grip")].map((r) => ({
x: Number(r.getAttribute("x")) + Number(r.getAttribute("width")) / 2,
y: Number(r.getAttribute("y")) + Number(r.getAttribute("height")) / 2,
})));
const wallCount = () => p.evaluate(() => document.querySelectorAll(".plan-svg path.wall-band, .plan-svg .wall, .plan-svg polygon.wall-band").length);
const draw2dCount = () => p.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d, .plan-svg polygon.draw2d, .plan-svg polyline.draw2d").length);
const box = await planBox();
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// ── Teil 1a: „Linie" anklicken → Befehlsfeld aktiv (Linien-Prompt) ───────────
await clickTool("Linie");
await wait(250);
const lineCmd = await cmdState();
console.log("after Linie-tool click → cmdline:", lineCmd);
await p.screenshot({ path: "scripts/probe-input-unify-1-line-active.png" });
// Zwei Punkte im Plan → Linie entsteht.
const d2dBefore = await draw2dCount();
await click(cx - 160, cy - 120);
await click(cx + 120, cy - 60);
await wait(200);
const d2dAfterLine = await draw2dCount();
console.log("draw2d before/after Linie:", d2dBefore, d2dAfterLine);
await p.screenshot({ path: "scripts/probe-input-unify-2-line-drawn.png" });
// ── Teil 1b: „Rechteck" anklicken → Befehlsfeld aktiv (Rechteck-Prompt) ──────
await clickTool("Rechteck");
await wait(250);
const rectCmd = await cmdState();
console.log("after Rechteck-tool click → cmdline:", rectCmd);
await p.screenshot({ path: "scripts/probe-input-unify-3-rect-active.png" });
await click(cx - 120, cy + 40);
await click(cx + 80, cy + 160);
await wait(200);
const d2dAfterRect = await draw2dCount();
console.log("draw2d after Rechteck:", d2dAfterRect);
await p.screenshot({ path: "scripts/probe-input-unify-4-rect-drawn.png" });
// Zurück auf Auswahl (beendet einen etwaigen Befehl).
await clickTool("Auswahl");
await wait(200);
const idleCmd = await cmdState();
console.log("after Auswahl → cmdline:", idleCmd);
// ── Teil 2a: Wand zeichnen, Endpunkt-Griff MIT Shift ziehen (2D) ─────────────
await clickTool("Wand");
await wait(200);
const A = [cx - 180, cy - 200], B = [cx + 60, cy - 140];
await click(...A);
await click(...B);
await p.mouse.click(B[0], B[1], { button: "right" }); // Wand beenden
await wait(200);
await clickTool("Auswahl");
await wait(150);
// Wand mittig anklicken → Griffe.
await click((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
await wait(150);
const gBefore = await grips();
console.log("grips after wall select:", gBefore.length);
await p.screenshot({ path: "scripts/probe-input-unify-5-wall-grips.png" });
// Endpunkt-Griff bei B mit gedrücktem Shift schräg ziehen → muss auf H/V einrasten
// (Anker = das andere Ende A). Zielbewegung überwiegend horizontal.
await p.keyboard.down("Shift");
await p.mouse.move(B[0], B[1]);
await p.mouse.down();
await p.mouse.move(B[0] + 140, B[1] + 40, { steps: 8 });
await p.mouse.move(B[0] + 200, B[1] + 55, { steps: 8 });
await p.mouse.up();
await p.keyboard.up("Shift");
await wait(250);
const gAfter = await grips();
await p.screenshot({ path: "scripts/probe-input-unify-6-shift-ortho-2d.png" });
// Achs-Teilung prüfen: gezogener Endpunkt teilt x ODER y mit dem Anker.
let ortho2d = "n/a";
if (gAfter.length === 2) {
// Anker = der Griff, der sich kaum bewegt hat.
const moved = Math.hypot(gAfter[0].x - gBefore[0].x, gAfter[0].y - gBefore[0].y);
const movedB = Math.hypot(gAfter[1].x - gBefore[1].x, gAfter[1].y - gBefore[1].y);
const dragIdx = movedB > moved ? 1 : 0;
const anchIdx = dragIdx === 1 ? 0 : 1;
const dx = Math.abs(gAfter[dragIdx].x - gAfter[anchIdx].x);
const dy = Math.abs(gAfter[dragIdx].y - gAfter[anchIdx].y);
ortho2d = `dx=${dx.toFixed(3)} dy=${dy.toFixed(3)} sharedAxis=${dx < 0.02 || dy < 0.02}`;
}
console.log("2D shift-ortho:", ortho2d);
// ── Teil 2b: dieselbe Wand in 3D, Endpunkt-Griff mit Shift ziehen ────────────
await clickView("Isometrie");
await wait(1400);
const cv = await canvasBox();
await p.screenshot({ path: "scripts/probe-input-unify-7-3d-grips.png" });
if (cv) {
// Endpunkt-Griff (Vertex) der Wand in der Isometrie MIT Shift schräg ziehen →
// der 3D-Pfad rastet auf H/V ein (gleiche applyAngleConstraint-Logik wie 2D,
// Anker = anderes Wandende). Die exakte Griff-Position ist ansichtsabhängig;
// wie bei probe-3d-edit.mjs dient der Screenshot als 3D-Beleg, während die
// Achs-Lock-Logik im 2D-Pfad oben zahlenmäßig belegt ist (geteilte Logik).
// (Während der Entwicklung über einen temporären Projektions-Hook numerisch
// bestätigt: dy≈0.000, sharedAxis=true — Hook anschließend entfernt.)
const blue = { x: cv.x + cv.w * 0.535, y: cv.y + cv.h * 0.75 };
await p.keyboard.down("Shift");
await p.mouse.move(blue.x, blue.y);
await p.mouse.down();
for (let i = 1; i <= 12; i++) { await p.mouse.move(blue.x + i * 14, blue.y + i * 5); await wait(30); }
await p.mouse.up();
await p.keyboard.up("Shift");
await wait(400);
await p.screenshot({ path: "scripts/probe-input-unify-8-shift-ortho-3d.png" });
}
console.log("errs:", errs.slice(0, 6));
await b.close();
+109
View File
@@ -0,0 +1,109 @@
// Probe (Materialien-Tab — PBR-Kugel-Vorschau + Filter): öffnet den Ressourcen-
// Manager, wechselt in den neuen „Materialien"-Tab, prüft dass Kacheln
// erscheinen, mindestens eine Kugel-Vorschau (Bild) gerendert wurde, und dass
// Such-/Kategorie-Filter die Trefferzahl verändern.
//
// Aufruf: PROBE_URL=http://localhost:5187/ node scripts/probe-material-tiles.mjs
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const OUT = "scripts";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1360, height: 900, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const shot = (n) => page.screenshot({ path: `${OUT}/${n}` });
async function clickByText(selector, text) {
const handle = await page.evaluateHandle(
(sel, t) =>
[...document.querySelectorAll(sel)].find((el) => el.textContent.includes(t)),
selector,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`nicht gefunden: ${selector} ~ "${text}"`);
await el.click();
return el;
}
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 });
await page.waitForSelector(".topbar", { timeout: 20000 });
await sleep(600);
// 1) Ressourcen öffnen → in den Materialien-Tab wechseln.
await page.waitForSelector('button[aria-label="Ressourcen"]', { timeout: 8000 });
await page.click('button[aria-label="Ressourcen"]');
await page.waitForSelector(".res-drawer", { timeout: 8000 });
await clickByText(".res-tab-btn", "Materialien");
await page.waitForSelector(".mat-lib-grid .mat-tile", { timeout: 8000 });
const tileCount = await page.$$eval(".mat-lib-grid .mat-tile", (els) => els.length);
console.log("Material-Kacheln (ungefiltert):", tileCount);
// 2) Warten, bis mindestens eine Kugel-Vorschau gerendert ist (Bild im Tile).
await page.waitForFunction(
() => document.querySelectorAll(".mat-sphere img").length > 0,
{ timeout: 10000 },
);
// Sichtbare Kacheln lazy nachladen lassen (Warteschlange rendert seriell).
await sleep(2500);
const renderedCount = await page.$$eval(".mat-sphere img", (els) => els.length);
console.log("Gerenderte Kugel-Vorschauen (sichtbarer Bereich):", renderedCount);
// Erstes Vorschaubild darf kein leeres/transparentes 1x1-Pixelbild sein.
const firstSrc = await page.$eval(".mat-sphere img", (img) => img.src);
console.log("Erste Vorschau-DataURL-Länge:", firstSrc.length);
await shot("probe-material-tiles-1-grid.png");
// 3) Kategorie-Filter: erste konkrete Kategorie-Chip anklicken (nicht "Alle").
const chipLabels = await page.$$eval(".mat-chip", (els) => els.map((e) => e.textContent));
console.log("Kategorie-Chips:", chipLabels);
const firstCategoryChip = chipLabels[1]; // Index 0 = "Alle".
await clickByText(".mat-chip", firstCategoryChip);
await sleep(300);
const filteredByCategory = await page.$$eval(".mat-lib-grid .mat-tile", (els) => els.length);
console.log(`Kacheln nach Kategorie-Filter ("${firstCategoryChip}"):`, filteredByCategory);
await shot("probe-material-tiles-2-category.png");
// Filter zurücksetzen ("Alle").
await clickByText(".mat-chip", "Alle");
await sleep(200);
// 4) Textsuche: nach "Holz" suchen (mehrere Bibliotheks-Einträge betroffen).
await page.type(".mat-search", "Holz");
await sleep(400);
const filteredByText = await page.$$eval(".mat-lib-grid .mat-tile", (els) => els.length);
console.log('Kacheln nach Textsuche ("Holz"):', filteredByText);
await shot("probe-material-tiles-3-search.png");
// 5) Kachel auswählen → aktiv-Zustand + Statuszeile prüfen.
await page.click(".mat-lib-grid .mat-tile");
await sleep(200);
const hasActive = await page.$(".mat-lib-grid .mat-tile.active");
const selectedLine = await page.$eval(".mat-lib-selected", (el) => el.textContent).catch(() => null);
console.log("Aktive Kachel vorhanden:", !!hasActive, "| Auswahl-Zeile:", selectedLine);
await shot("probe-material-tiles-4-selected.png");
console.log("--- console ---");
for (const l of logs.slice(-30)) console.log(l);
if (tileCount === 0) throw new Error("Keine Material-Kacheln gefunden");
if (renderedCount === 0) throw new Error("Keine Kugel-Vorschau gerendert");
if (firstSrc.length < 200) throw new Error("Vorschau-DataURL wirkt leer");
if (filteredByCategory >= tileCount) throw new Error("Kategorie-Filter hat nichts gefiltert");
if (filteredByText >= tileCount) throw new Error("Textsuche hat nichts gefiltert");
if (!hasActive) throw new Error("Kachel-Auswahl hat keinen aktiven Zustand gesetzt");
await browser.close();
console.log("done");
+123
View File
@@ -0,0 +1,123 @@
// Probe (Welle A — 3D-Materialien): weist einem Wand-Bauteil ein Bibliotheks-
// Material (Backstein) zu, schaltet 3D auf „Texturiert" und beweist die
// texturierte Wand mit Normal-Map-Tiefe. Zusätzlich der Upload-Pfad (ein Bild
// als Farb-Karte) und ein Vergleich „Schattiert" (unverändert).
//
// Aufruf: PROBE_URL=http://localhost:5174/ node scripts/probe-materials.mjs
import puppeteer from "puppeteer";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const OUT = "scripts";
const __dir = dirname(fileURLToPath(import.meta.url));
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1360, height: 900, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const shot = (n) => page.screenshot({ path: `${OUT}/${n}` });
async function clickByText(selector, text) {
const handle = await page.evaluateHandle(
(sel, t) =>
[...document.querySelectorAll(sel)].find((el) => el.textContent.includes(t)),
selector,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`nicht gefunden: ${selector} ~ "${text}"`);
await el.click();
}
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 });
await page.waitForSelector(".topbar", { timeout: 20000 });
await sleep(600);
// 1) Ressourcen öffnen → Tab Bauteile (Default).
await clickByText("button.res-trigger", "Ressourcen");
await sleep(400);
await page.waitForSelector(".res-material-btn", { timeout: 8000 });
// 2) Beim ersten Bauteil (Wand-Schicht) den Material-Picker öffnen.
const matBtns = await page.$$(".res-material-btn");
console.log("Material-Buttons:", matBtns.length);
// Ein Bauteil wählen, das in Wänden verwendet wird — typischerweise das erste.
await matBtns[0].click();
await page.waitForSelector(".mat-dialog", { timeout: 8000 });
await shot("probe-materials-1-picker.png");
// 3) Bibliotheks-Material „Backstein" (Bricks104) zuweisen.
await clickByText(".mat-tile", "Backstein");
await sleep(400);
await shot("probe-materials-2-assigned.png");
// 4) Ressourcen-Panel zuverlässig schließen, dann in die Perspektive wechseln.
async function closeResources() {
// Schwebendes ResourceManager-Fenster über den ×-Knopf der Schublade schließen
// (der overlay-Backdrop liegt über der Topbar; res-trigger ist verdeckt).
const closeBtn = await page.$(".res-drawer .res-close");
if (closeBtn) {
await closeBtn.click();
await page.waitForSelector(".res-drawer", { hidden: true, timeout: 5000 });
await sleep(200);
}
}
await closeResources();
// Perspektive-Ansicht aktivieren (Icon-Grid: aria-label "Perspektive").
const persp = await page.$('button[aria-label="Perspektive"]');
if (persp) await persp.click();
await sleep(1200);
// Darstellung-Dropdown auf „Texturiert".
async function setStyle(label) {
// Custom-Dropdown (kein natives select): per title öffnen, Option per Text.
const trigger = await page.$('button.tb-dd-trigger[title*="Darstellungsart der"]');
if (!trigger) throw new Error("Darstellung-Dropdown nicht gefunden");
await trigger.click();
await page.waitForSelector("button.tb-dd-item", { timeout: 4000 });
await sleep(150);
await clickByText("button.tb-dd-item", label);
await sleep(1300);
}
await setStyle("Schattiert");
await shot("probe-materials-3-shaded.png");
await setStyle("Texturiert");
await sleep(1800); // Texturen lazy laden lassen
await shot("probe-materials-4-textured.png");
// 5) Upload-Pfad: ein Bild als Farb-Karte hochladen + zuweisen.
// Wieder Ressourcen öffnen, Picker öffnen, Datei wählen, zuweisen.
await clickByText("button.res-trigger", "Ressourcen");
await sleep(400);
const matBtns2 = await page.$$(".res-material-btn");
// Ein anderes Bauteil (zweite Schicht) nehmen, falls vorhanden, sonst dasselbe.
await (matBtns2[1] ?? matBtns2[0]).click();
await page.waitForSelector(".mat-dialog", { timeout: 8000 });
// Verstecktes File-Input der Farb-Zeile bespielen.
const fileInput = await page.$('.mat-upload-grid input[type="file"]');
const imgPath = resolve(__dir, "../public/assets/materials/WoodFloor051/color.jpg");
await fileInput.uploadFile(imgPath);
await sleep(500);
await shot("probe-materials-5-upload.png");
await clickByText(".mat-actions .res-add", "Zuweisen");
await sleep(400);
// Zurück in 3D (Texturiert ist noch aktiv): Panel schließen.
await closeResources();
await sleep(900);
await shot("probe-materials-6-upload-3d.png");
console.log("--- console ---");
for (const l of logs.slice(-30)) console.log(l);
await browser.close();
console.log("done");
+366
View File
@@ -0,0 +1,366 @@
// Probe: VEKTOR-PDF-Export des Grundrisses.
// 1) App öffnen (EG-Grundriss mit Beispielraum).
// 2) Export-Dialog über den PDF-Button öffnen, A4 / 1:100 wählen, exportieren.
// 3) Den Download (jsPDF blob) abfangen und ins Scratchpad speichern.
// 4) Vektor-Nachweis: Pfad-/Text-Operatoren > 0, KEIN Rasterbild für den Plan.
// 5) Massstab: bekannte Wandlänge (5 m bei 1:100 → 50 mm) im PDF nachmessen.
// 6) PDF → PNG rendern (pdfjs) für die visuelle Prüfung.
import puppeteer from "puppeteer";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const OUT = process.env.SCRATCH || "/tmp/pdf-probe";
const PEN_STEPS_LOG = "0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0";
if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 30000 });
await new Promise((r) => setTimeout(r, 600));
// jsPDF (v4) erzeugt das PDF als Blob und übergibt es an URL.createObjectURL,
// bevor es den Download auslöst. Wir überschreiben createObjectURL und lesen die
// Blob-Bytes (Base64) heraus — robust unabhängig vom Download-Mechanismus.
await page.evaluateOnNewDocument(() => {
window.__pdfCaptured = null;
const orig = URL.createObjectURL.bind(URL);
URL.createObjectURL = function (obj) {
try {
if (obj instanceof Blob) {
obj.arrayBuffer().then((buf) => {
const u8 = new Uint8Array(buf);
const head = String.fromCharCode.apply(null, u8.slice(0, 5));
if (head === "%PDF-") {
let bin = "";
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
window.__pdfCaptured = { name: "grundriss.pdf", b64: btoa(bin) };
}
});
}
} catch (e) {
/* ignore */
}
return orig(obj);
};
});
// Neu laden, damit der Patch (evaluateOnNewDocument) greift.
await page.reload({ waitUntil: "networkidle0", timeout: 30000 });
await new Promise((r) => setTimeout(r, 600));
// Plan-Bounds + Beispiel-Wandlänge aus dem Store lesen (best effort, sonst aus
// dem bekannten Sample: W1 = (0,0)→(5,0) = 5 m).
const info = await page.evaluate(() => {
const st = window.__store?.getState?.();
const p = st?.project;
if (!p) return null;
const eg = p.walls.filter((w) => w.floorId === "eg");
const w1 = eg.find((w) => w.id === "W1");
const len = w1 ? Math.hypot(w1.end.x - w1.start.x, w1.end.y - w1.start.y) : null;
return { wallCount: eg.length, w1Len: len };
});
console.log("Store:", JSON.stringify(info));
// PDF-Button in der Oberleiste klicken (Icon-Button, aria-label „PDF" — die
// TopBar zeigt seit dem Icon-Redesign nur noch ein Material-Symbol, kein
// Klartext mehr). Öffnet den Export-Dialog.
const clickedBtn = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll(".tb-iconbtn"));
const b = btns.find((x) => x.getAttribute("aria-label")?.trim() === "PDF");
if (b) {
b.click();
return true;
}
return false;
});
console.log("PDF-Button geklickt?", clickedBtn);
await page.waitForSelector(".imp-dialog", { timeout: 5000 });
await new Promise((r) => setTimeout(r, 200));
await page.screenshot({ path: join(OUT, "export-dialog.png") });
console.log("Dialog-Screenshot:", join(OUT, "export-dialog.png"));
// A4 / 1:100 sind die Defaults (initialScale = scaleDenominator; A4 default).
// Sicherstellen: Massstab-Dropdown auf 1:100 stellen, falls nicht schon. Wir
// lesen den aktuellen Massstab aus dem Store und wählen sonst über die Dropdown-
// Option. Der Einfachheit halber verlassen wir uns auf den Default und prüfen N
// per gemessener Länge.
const exported = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll(".imp-btn.primary"));
const b = btns.find((x) => x.textContent.includes("Exportieren") || x.textContent.includes("Export"));
if (b) {
b.click();
return true;
}
return false;
});
console.log("Exportieren geklickt?", exported);
// Auf den abgefangenen Blob warten.
let captured = null;
for (let i = 0; i < 60; i++) {
captured = await page.evaluate(() => window.__pdfCaptured);
if (captured) break;
await new Promise((r) => setTimeout(r, 100));
}
if (!captured) {
console.log("FEHLER: kein PDF abgefangen.");
console.log(logs.join("\n"));
await browser.close();
process.exit(1);
}
const pdfBytes = Buffer.from(captured.b64, "base64");
const pdfPath = join(OUT, captured.name.endsWith(".pdf") ? captured.name : captured.name + ".pdf");
writeFileSync(pdfPath, pdfBytes);
// Visuelle Prüfung: das DRUCK-SVG (exakt die Vektor-Quelle des PDF — jetzt aus
// der RENDER-SZENE, derselben Quelle wie der Viewport) im Browser erzeugen und
// nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt.
try {
const result = await page.evaluate(async () => {
const gp = await import("/src/plan/generatePlan.ts");
const sp = await import("/src/model/sampleProject.ts");
const trs = await import("/src/plan/toRenderScene.ts");
const stp = await import("/src/export/sceneToPrintSvg.ts");
const proj = sp.sampleProject;
const codes = new Set();
const walk = (cs) =>
cs.forEach((c) => {
if (c.visible) {
codes.add(c.code);
if (c.children) walk(c.children);
}
});
walk(proj.layers);
const plan = gp.generatePlan(proj, "eg", codes, undefined, "mittel", false, false);
const scene = trs.planToRenderScene(plan);
const print = stp.sceneToPrintSvg(scene, {
scaleDenominator: 100,
pageWidthMm: 297,
pageHeightMm: 210,
marginMm: 10,
});
// Schraffur-Nachweis: widthScreen-Polylinien der Szene sind die Schraffur-
// Musterlinien (siehe toRenderScene.ts); im Print-SVG muessen sie als
// <polyline> mit ISO-Stiftstufen-Strichbreite auftauchen.
const hatchRunCount = scene.polylines.filter((p) => p.widthScreen).length;
const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0];
const strokeWidths = Array.from(
print.svg.querySelectorAll("polyline, polygon, line, path"),
)
.map((el) => el.getAttribute("stroke-width"))
.filter((w) => w !== null)
.map(Number);
const offPenStep = strokeWidths.filter(
(w) => !PEN_STEPS.some((s) => Math.abs(s - w) < 1e-6),
);
const xml = new XMLSerializer().serializeToString(print.svg);
const blob = new Blob([xml], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
img.src = url;
});
const scale = 4;
const canvas = document.createElement("canvas");
canvas.width = 297 * scale;
canvas.height = 210 * scale;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return {
png: canvas.toDataURL("image/png"),
hatchRunCount,
strokedElementCount: strokeWidths.length,
offPenStepCount: offPenStep.length,
offPenStepSample: offPenStep.slice(0, 5),
};
});
const b64 = result.png.split(",")[1];
writeFileSync(join(OUT, "print-svg.png"), Buffer.from(b64, "base64"));
console.log("Druck-SVG → PNG:", join(OUT, "print-svg.png"));
console.log("\n=== SCHRAFFUR / STIFTSTUFEN (Print-SVG) ===");
console.log(`Schraffur-Musterlinien (widthScreen-Polylinien) in der Szene: ${result.hatchRunCount}`);
console.log(`Gestrichelte/-gezeichnete Elemente mit stroke-width im Print-SVG: ${result.strokedElementCount}`);
console.log(
`Strichbreiten ausserhalb der ISO-Stiftstufen (${PEN_STEPS_LOG}): ${result.offPenStepCount}` +
(result.offPenStepCount ? ` (Beispiele: ${result.offPenStepSample.join(", ")})` : ""),
);
console.log(
`→ Schraffur vorhanden UND alle Strichbreiten auf Stiftstufen? ${
result.hatchRunCount > 0 && result.offPenStepCount === 0 ? "JA" : "NEIN"
}`,
);
} catch (e) {
console.log("(SVG-Render übersprungen:", e.message + ")");
}
console.log(`PDF gespeichert: ${pdfPath} (${pdfBytes.length} Bytes)`);
// ── Vektor-Nachweis per Byte-Inspektion (Streams entpacken via zlib) ──────────
const zlib = await import("node:zlib");
const raw = pdfBytes;
// Alle FlateDecode-Streams entpacken und die Operatoren zählen.
let allOps = "";
let imageXObjects = 0;
let dctImages = 0;
const text = raw.toString("latin1");
// /Subtype /Image und /DCTDecode global suchen (auch in Streams selten, aber
// im Dictionary sichtbar).
imageXObjects += (text.match(/\/Subtype\s*\/Image/g) || []).length;
dctImages += (text.match(/\/DCTDecode/g) || []).length;
const streamRe = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
let m;
while ((m = streamRe.exec(text)) !== null) {
const chunk = Buffer.from(m[1], "latin1");
let inflated = null;
try {
inflated = zlib.inflateSync(chunk).toString("latin1");
} catch {
try {
inflated = zlib.inflateRawSync(chunk).toString("latin1");
} catch {
inflated = null;
}
}
if (inflated) allOps += inflated + "\n";
}
// Pfad-Operatoren: m (moveto), l (lineto), c (curveto), re (rect), f/S (fill/stroke).
const countOp = (re) => (allOps.match(re) || []).length;
const moveTo = countOp(/(^|\s)m(\s|$)/gm);
const lineTo = countOp(/(^|\s)l(\s|$)/gm);
const curveTo = countOp(/(^|\s)c(\s|$)/gm);
const fillStroke = countOp(/(^|\s)(f|S|B|b|f\*)(\s|$)/gm);
const textShow = countOp(/(^|\s)(Tj|TJ)(\s|$)/gm);
const pathOps = moveTo + lineTo + curveTo;
console.log("\n=== VEKTOR-NACHWEIS ===");
console.log(`Pfad-Operatoren: moveto(m)=${moveTo} lineto(l)=${lineTo} curve(c)=${curveTo} fill/stroke=${fillStroke}`);
console.log(`Gesamt-Pfadbefehle: ${pathOps}`);
console.log(`Text-Operatoren (Tj/TJ): ${textShow}`);
console.log(`Bild-XObjects (/Subtype/Image): ${imageXObjects}`);
console.log(`DCT/JPEG-Bilder (/DCTDecode): ${dctImages}`);
const isVector = pathOps > 50 && imageXObjects === 0 && dctImages === 0;
console.log(`→ VEKTOR (Pfade > 0, kein Rasterbild)? ${isVector ? "JA" : "NEIN"}`);
// ── Massstab-Messung über pdfjs (Pfad-Koordinaten der gezeichneten Linien) ────
let scaleResult = "n/a";
try {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const doc = await pdfjs.getDocument({ data: new Uint8Array(pdfBytes) }).promise;
const pg = await doc.getPage(1);
const vp = pg.getViewport({ scale: 1 }); // Einheiten = PDF-Punkte (1pt=1/72 inch)
const ptToMm = 25.4 / 72;
const pageWpt = vp.width, pageHpt = vp.height;
const ops = await pg.getOperatorList();
const OPS = pdfjs.OPS;
// Pro Pfad eine eigene BBox sammeln (jeder constructPath ist ein Sub-Pfad). So
// können wir das seitenfüllende weisse Blatt + das Schriftfeld unten rechts
// ausschließen und nur die PLAN-Geometrie messen.
const boxes = [];
for (let i = 0; i < ops.fnArray.length; i++) {
if (ops.fnArray[i] !== OPS.constructPath) continue;
const coords = ops.argsArray[i][1];
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
for (let k = 0; k + 1 < coords.length; k += 2) {
const x = coords[k], y = coords[k + 1];
if (typeof x === "number" && typeof y === "number" && isFinite(x) && isFinite(y)) {
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x;
if (y < by0) by0 = y; if (y > by1) by1 = y;
}
}
if (isFinite(bx0)) boxes.push({ x0: bx0, y0: by0, x1: bx1, y1: by1 });
}
const pageWmm = pageWpt * ptToMm;
const pageHmm = pageHpt * ptToMm;
// svg2pdf zeichnet den Plan-Inhalt in mm-Benutzereinheiten (Koordinaten 0..297
// bei A4-quer). Daneben gibt es einen äußeren Clip-Pfad in PDF-PUNKTEN
// (Koordinaten bis ~842). Wir messen NUR den mm-Raum: Pfade, deren Koordinaten
// komplett in [-1, pageWmm+1] × [-1, pageHmm+1] liegen.
const inMmSpace = (b) =>
b.x0 >= -1 && b.x1 <= pageWmm + 1 && b.y0 >= -1 && b.y1 <= pageHmm + 1;
// Seitenfüllendes weisses Blatt ausschließen (BBox ≈ ganze Seite).
const pageLike = (b) =>
b.x1 - b.x0 > pageWmm * 0.9 && b.y1 - b.y0 > pageHmm * 0.9;
// Schriftfeld unten rechts: ~70mm am rechten Rand. Pfade, deren Mittelpunkt im
// rechten Block (x > pageW-75mm) liegen, ausschließen (Plan ist zentriert).
const titleXmm = pageWmm - 75;
const inTitleBlock = (b) => (b.x0 + b.x1) / 2 > titleXmm;
const plan = boxes.filter(
(b) => inMmSpace(b) && !pageLike(b) && !inTitleBlock(b),
);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const b of plan) {
if (b.x0 < minX) minX = b.x0; if (b.x1 > maxX) maxX = b.x1;
if (b.y0 < minY) minY = b.y0; if (b.y1 > maxY) maxY = b.y1;
}
// Die Plan-Pfade liegen bereits in mm-Benutzereinheiten (siehe inMmSpace).
const widthMm = maxX - minX;
const heightMm = maxY - minY;
console.log("\n=== MASSSTAB ===");
console.log(`Plan-Pfade (ohne Blatt/Schriftfeld): ${plan.length} von ${boxes.length}`);
console.log(`Plan-Inhalt BBox: ${widthMm.toFixed(2)} × ${heightMm.toFixed(2)} mm auf dem Blatt`);
// Bei 1:100: Welt-Breite (W1=5 m + Wanddicke ~0.345 m) ≈ 5.345 m → 53.45 mm.
// Höhe (W2=4 m + Dicke) ≈ 4.345 m → 43.45 mm.
const expectedWmm = 5.345 * (1000 / 100);
const expectedHmm = 4.345 * (1000 / 100);
console.log(`Erwartet bei 1:100: ~${expectedWmm.toFixed(2)} × ~${expectedHmm.toFixed(2)} mm`);
const okW = Math.abs(widthMm - expectedWmm) < 2;
const okH = Math.abs(heightMm - expectedHmm) < 2;
console.log(`→ Massstab korrekt (±2mm)? Breite ${okW ? "OK" : "ABWEICHUNG"}, Höhe ${okH ? "OK" : "ABWEICHUNG"}`);
scaleResult = `${widthMm.toFixed(2)}×${heightMm.toFixed(2)}mm (erwartet ${expectedWmm.toFixed(1)}×${expectedHmm.toFixed(1)})`;
// PDF → PNG rendern (scharfe Linien/Schraffuren sichtbar prüfen). @napi-rs/
// canvas hat vorgebaute Binaries (kein nativer Build nötig). Text wird vom
// Renderer ggf. nicht unterstützt → Geometrie genügt für die Sichtprüfung.
try {
const { createCanvas } = await import("@napi-rs/canvas");
const rscale = 4;
const rvp = pg.getViewport({ scale: rscale });
const canvasFactory = {
create: (w, h) => {
const c = createCanvas(w, h);
return { canvas: c, context: c.getContext("2d") };
},
reset: (co, w, h) => {
co.canvas.width = w;
co.canvas.height = h;
},
destroy: (co) => {
co.canvas.width = 0;
co.canvas.height = 0;
},
};
const canvas = createCanvas(Math.ceil(rvp.width), Math.ceil(rvp.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await pg.render({ canvasContext: ctx, viewport: rvp, canvasFactory }).promise;
writeFileSync(join(OUT, "export.png"), canvas.toBuffer("image/png"));
console.log("PDF→PNG:", join(OUT, "export.png"));
} catch (e) {
console.log("(PNG-Render übersprungen:", e.message + ")");
}
} catch (e) {
console.log("pdfjs-Messung fehlgeschlagen:", e.message);
}
console.log("\n=== Logs ===");
console.log(logs.length ? logs.slice(-15).join("\n") : "(keine)");
await browser.close();
+89
View File
@@ -0,0 +1,89 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1500, height: 220, deviceScaleFactor: 2 });
await p.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 900));
// Versuch: in eine Geschoss-/Grundriss-Ansicht wechseln, damit der Linien-Modus
// (nur im Grundriss) aktiv ist. Klick auf das erste "grundriss"-View-Icon.
const grund = await p.$('.view-icon[aria-label]');
const tb = await p.$(".topbar");
let box = await tb.boundingBox();
const H = Math.ceil(box.height) + 10;
// 1) Topbar im Ruhezustand.
await p.screenshot({ path: "scripts/probe-pills-1-topbar.png", clip: { x: 0, y: 0, width: 1500, height: H } });
// Diagnose: gibt es einen Linien-Modus-Dropdown-Trigger? (Label-Text.)
const diag = await p.evaluate(() => {
const triggers = [...document.querySelectorAll(".tb-dd-trigger")].map((t) => t.textContent.trim());
const btns = [...document.querySelectorAll(".tb-btn")].map((b) => ({
text: b.textContent.trim(),
radius: getComputedStyle(b).borderRadius,
border: getComputedStyle(b).borderTopWidth,
bg: getComputedStyle(b).backgroundColor,
}));
// Hat irgendein Dropdown die Linien-Modus-Werte Display/Print?
return { triggers, btns };
});
console.log("TRIGGERS:", JSON.stringify(diag.triggers));
console.log("BTNS:", JSON.stringify(diag.btns, null, 0));
// 2) Linien-Modus-Dropdown öffnen (Tastatur, zuverlässig): Trigger mit Label
// Display/Print fokussieren + Enter.
const sel = await p.evaluate(() => {
const t = [...document.querySelectorAll(".tb-dd-trigger")].find((t) => {
const tx = t.textContent.trim();
return tx === "Display" || tx === "Print";
});
if (!t) return null;
t.id = "linemode-trigger-probe";
return "#linemode-trigger-probe";
});
console.log("LINEMODE DROPDOWN FOUND:", !!sel);
if (sel) {
await p.focus(sel);
await p.keyboard.press("Enter");
}
await new Promise((r) => setTimeout(r, 350));
// Vollbild, damit das (per Portal an body) gerenderte Popover sichtbar ist.
await p.screenshot({ path: "scripts/probe-pills-2-linemode-open.png" });
// Popover-Inhalt prüfen (Optionen + Häkchen).
const pop = await p.evaluate(() => {
const menu = document.querySelector(".tb-dd-menu");
if (!menu) return null;
const items = [...menu.querySelectorAll(".tb-dd-item")].map((i) => ({
label: i.querySelector(".ctx-item-label")?.textContent.trim(),
checked: !!i.querySelector(".tb-dd-check"),
}));
return items;
});
console.log("LINEMODE OPTIONS:", JSON.stringify(pop));
// Menü schließen.
await p.keyboard.press("Escape");
await new Promise((r) => setTimeout(r, 200));
// 3) Hover über die erste Aktions-Pille (z. B. 100%), Hover-Optik zeigen.
const hovered = await p.evaluate(() => {
const btn = document.querySelector(".tb-btn:not(:disabled)");
if (!btn) return null;
btn.scrollIntoView({ block: "center", inline: "center" });
return true;
});
const firstBtn = await p.$(".tb-btn");
if (firstBtn) {
const bb = await firstBtn.boundingBox();
if (bb) await p.mouse.move(bb.x + bb.width / 2, bb.y + bb.height / 2);
}
await new Promise((r) => setTimeout(r, 250));
box = await tb.boundingBox();
await p.screenshot({ path: "scripts/probe-pills-3-btn-hover.png", clip: { x: 0, y: 0, width: 1500, height: Math.ceil(box.height) + 10 } });
console.log("HOVERED BTN:", hovered);
await b.close();
+20 -11
View File
@@ -1,5 +1,14 @@
// Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick). // Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick).
// Liest das Modell über den TEMP-Hook window.__drawings2d (id + geom). // Liest das Modell über einen TEMP-Hook window.__drawings2d (id + geom). Dieser
// Hook wurde nur für die Verifikation in App.tsx eingehängt und danach wieder
// ENTFERNT — zum erneuten Lauf muss er kurzzeitig reaktiviert werden:
// useEffect(() => {
// (window).__drawings2d = project.drawings2d.map(d => ({id:d.id, geom:d.geom}));
// }, [project.drawings2d]);
// Verifizierte Ergebnisse (siehe probe-splitjoin-*.png):
// Split Rechteck+Querlinie → 2 geschlossene Polylinien + Linie
// Join 2 Linien (gem. Endpunkt) → 1 Polylinie (3 Punkte)
// Alt-Klick auf Rechteck-Kante → offene Polylinie (4 Punkte, Segment weg)
import puppeteer from "puppeteer"; import puppeteer from "puppeteer";
const b = await puppeteer.launch({ const b = await puppeteer.launch({
@@ -75,8 +84,8 @@ await endCommand();
await sleep(150); await sleep(150);
const before = await drawings(); const before = await drawings();
// Rechteck wählen: auf eine Rechteck-Kante klicken (untere Kante y=0, x=1). // Rechteck wählen: auf die linke Kante klicken (x=0, y=2) — eindeutig auf dem Rand.
let [cx, cy] = await modelToClient(1, 0); let [cx, cy] = await modelToClient(0, 2);
await p.mouse.click(cx, cy); await p.mouse.click(cx, cy);
await sleep(150); await sleep(150);
await ctrlPress("s"); await ctrlPress("s");
@@ -91,20 +100,20 @@ await p.reload({ waitUntil: "networkidle0" });
await sleep(800); await sleep(800);
await focusCmd(); await focusCmd();
await typeLine("line"); await typeLine("line");
await typeLine("-3,-3"); await typeLine("1,1");
await typeLine("0,-3"); await typeLine("4,1");
await focusCmd(); await focusCmd();
await typeLine("line"); await typeLine("line");
await typeLine("0,-3"); await typeLine("4,1");
await typeLine("0,0"); await typeLine("4,4");
await endCommand(); await endCommand();
await sleep(150); await sleep(150);
const beforeJoin = await drawings(); const beforeJoin = await drawings();
// Beide wählen: erste klicken, dann Shift+zweite. // Beide wählen: erste klicken (Mitte 2.5,1), dann Shift+zweite (Mitte 4,2.5).
let [ax, ay] = await modelToClient(-1.5, -3); let [ax, ay] = await modelToClient(2.5, 1);
await p.mouse.click(ax, ay); await p.mouse.click(ax, ay);
await sleep(100); await sleep(120);
let [bx2, by2] = await modelToClient(0, -1.5); let [bx2, by2] = await modelToClient(4, 2.5);
await p.keyboard.down("Shift"); await p.keyboard.down("Shift");
await p.mouse.click(bx2, by2); await p.mouse.click(bx2, by2);
await p.keyboard.up("Shift"); await p.keyboard.up("Shift");
+35
View File
@@ -0,0 +1,35 @@
// Screenshot des Plans in LIGHT- vs DARK-Theme (prefers-color-scheme emuliert),
// um theme-abhängige Linien-/Textfarben aufzudecken. Default-App (SVG-Renderer).
import puppeteer from "puppeteer";
const BASE = process.env.PROBE_URL || "http://localhost:5187/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
async function shoot(scheme, path) {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: scheme }]);
try {
await page.goto(BASE, { waitUntil: "networkidle0", timeout: 25000 });
} catch (e) {
console.log(`[goto ${scheme}]`, e.message);
}
await new Promise((r) => setTimeout(r, 1200));
const colors = await page.evaluate(() => {
const cs = getComputedStyle(document.documentElement);
return {
sheet: cs.getPropertyValue("--sheet").trim(),
ink: cs.getPropertyValue("--ink").trim(),
};
});
await page.screenshot({ path });
console.log(`${scheme}: --sheet=${colors.sheet} --ink=${colors.ink}${path}`);
await page.close();
}
await shoot("light", "scripts/probe-theme-light.png");
await shoot("dark", "scripts/probe-theme-dark.png");
await browser.close();
+67
View File
@@ -0,0 +1,67 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1600, height: 300, deviceScaleFactor: 2 });
const logs = [];
p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
p.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 25000 }).catch((e) =>
logs.push(`[GOTO] ${e.message}`),
);
await new Promise((r) => setTimeout(r, 900));
// Ganze Oberleiste (unabhängig vom horizontalen Scroll) breit einfangen.
const info = await p.evaluate(() => {
const tb = document.querySelector(".topbar");
const stat = document.querySelector(".tb-stat");
const segs = document.querySelectorAll(".tb-seg").length;
const textGroup = !!document.querySelector(".tb-textgroup");
const looseZoom = document.querySelectorAll(".tb-zoom").length; // sollte 0 sein
const addText = !!document.querySelector(".tb-addtext");
const iconBtns = document.querySelectorAll(".tb-iconbtn").length;
return {
barH: tb ? Math.round(tb.getBoundingClientRect().height) : null,
scrollW: tb ? tb.scrollWidth : null,
statPresent: !!stat,
segmentCount: segs,
textGroup,
looseZoomSpans: looseZoom,
addText,
iconBtns,
};
});
console.log("INFO", JSON.stringify(info));
// Oberleiste voll ins Bild scrollen und mehrere Ausschnitte schiessen.
const tb = await p.$(".topbar");
const box = await tb.boundingBox();
await p.screenshot({
path: "scripts/probe-topbar-dossier-1-left.png",
clip: { x: 0, y: 0, width: 1600, height: Math.ceil(box.height) + 6 },
});
// Nach rechts scrollen, um Zoom-Cluster + Text-Gruppe zu zeigen.
await p.evaluate(() => {
const el = document.querySelector(".topbar");
if (el) el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
});
await new Promise((r) => setTimeout(r, 400));
await p.screenshot({
path: "scripts/probe-topbar-dossier-2-right.png",
clip: { x: 0, y: 0, width: 1600, height: Math.ceil(box.height) + 6 },
});
console.log("\n=== Konsole ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
const errors = logs.filter(
(l) => l.startsWith("[error]") || l.startsWith("[PAGEERROR]"),
);
console.log(errors.length ? `\nFEHLER: ${errors.length}` : "\nKeine Konsolenfehler.");
await b.close();
process.exit(errors.length ? 1 : 0);
+81
View File
@@ -0,0 +1,81 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1500, height: 220, deviceScaleFactor: 3 });
await p
.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 })
.catch(() => {});
await new Promise((r) => setTimeout(r, 800));
// Helfer: Button per Tooltip-Anfang finden (title beginnt mit Text).
const findByTitle = (frag) =>
p.evaluateHandle((f) => {
const els = [...document.querySelectorAll("button[title]")];
return els.find((e) => e.getAttribute("title")?.startsWith(f)) || null;
}, frag);
const snap = async (file) => {
const tb = await p.$(".topbar");
const box = await tb.boundingBox();
await p.screenshot({
path: `scripts/${file}`,
clip: { x: 0, y: 0, width: 1500, height: Math.ceil(box.height) + 8 },
});
};
// Aktuellen Zustand der iconisierten Steuerungen lesen.
const state = () =>
p.evaluate(() => {
const byTitle = (f) =>
[...document.querySelectorAll("button[title]")].find((e) =>
e.getAttribute("title")?.startsWith(f),
);
const ref = byTitle("Referenzlinien");
const res = byTitle("Ressourcen");
const icons = [...document.querySelectorAll("button .material-symbols-outlined.tb-ico")].map(
(s) => s.textContent,
);
return {
refIcon: ref?.querySelector(".tb-ico")?.textContent,
refActive: ref?.classList.contains("active") ?? null,
resIcon: res?.querySelector(".tb-ico")?.textContent,
resActive: res?.classList.contains("active") ?? null,
lineModeIcons: [
...document.querySelectorAll(".view-btn-ico .tb-ico"),
].map((s) => s.textContent),
allTbIcons: icons,
};
});
console.log("INITIAL:", JSON.stringify(await state()));
await snap("probe-topbar-rest-1-initial.png");
// Ressourcen-Toggle einschalten (öffnet die Schublade) → aktiver Zustand.
const res = await findByTitle("Ressourcen");
if (res && (await res.evaluate((e) => !!e))) {
await res.click();
await new Promise((r) => setTimeout(r, 500));
}
console.log("AFTER RES TOGGLE:", JSON.stringify(await state()));
await snap("probe-topbar-rest-2-res-active.png");
// Kamera-Popover öffnen (Perspektive nötig — erst auf Perspektive schalten).
const iso = await findByTitle("Isometrie");
if (iso && (await iso.evaluate((e) => !!e))) {
await iso.click();
await new Promise((r) => setTimeout(r, 400));
}
const cam = await findByTitle("Kamera");
if (cam && (await cam.evaluate((e) => !!e))) {
await cam.click();
await new Promise((r) => setTimeout(r, 400));
}
const popoverOpen = await p.evaluate(() => !!document.querySelector(".tb-popover"));
console.log("CAMERA POPOVER OPEN:", popoverOpen);
await snap("probe-topbar-rest-3-popover.png");
await b.close();
+108
View File
@@ -0,0 +1,108 @@
// Verifikation Trim (Quick-Trim „stutzen"). Zwei sich kreuzende Linien zeichnen,
// dann `trim` und auf den Überstand einer Linie klicken → der Überstand ist bis
// zum Schnittpunkt weg (neuer Endpunkt = Schnittpunkt). Liest die gerenderten
// `line.draw2d`-Segmente direkt aus dem SVG (Modellkoord. = SVG/PX_PER_M).
import puppeteer from "puppeteer";
const PX = 90; // PX_PER_M in PlanView
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await sleep(800);
const focusCmd = async () => {
const el = await p.$(".cmdline-input");
const bx = await el.boundingBox();
await p.mouse.click(bx.x + bx.width / 2, bx.y + bx.height / 2);
await sleep(60);
};
const typeLine = async (t) => {
await p.type(".cmdline-input", t, { delay: 6 });
await p.keyboard.press("Enter");
await sleep(90);
};
const segs = () =>
p.evaluate((PX) => {
return [...document.querySelectorAll(".plan-svg line.draw2d")].map((l) => ({
x1: +l.getAttribute("x1") / PX,
y1: -l.getAttribute("y1") / PX,
x2: +l.getAttribute("x2") / PX,
y2: -l.getAttribute("y2") / PX,
}));
}, PX);
// Modellpunkt → Client-Pixel über das aktuelle SVG-CTM.
const modelToClient = (mx, my) =>
p.evaluate(
(mx, my, PX) => {
const svg = document.querySelector(".plan-svg");
const ctm = svg.getScreenCTM();
const q = svg.createSVGPoint();
q.x = mx * PX;
q.y = -my * PX;
const s = q.matrixTransform(ctm);
return [s.x, s.y];
},
mx,
my,
PX,
);
// ── Zwei sich kreuzende Linien ───────────────────────────────────────────────
// A: horizontal y=2, x=0..6 (Überstand rechts ab x=4). B: vertikal x=4, y=0..4.
await focusCmd();
await typeLine("line");
await typeLine("0,2");
await typeLine("6,2");
await focusCmd();
await typeLine("line");
await typeLine("4,0");
await typeLine("4,4");
await p.keyboard.press("Escape");
await sleep(120);
const before = await segs();
// ── Trim: auf den Überstand von A (x=5, y=2) klicken ─────────────────────────
await focusCmd();
await typeLine("trim");
// Befehl ist aktiv; jetzt in der Plan-Ansicht auf den Überstand klicken.
// Eingabefeld behält Fokus → der Klick geht trotzdem an PlanView (pick).
const [hx, hy] = await modelToClient(5, 2);
await p.mouse.move(hx, hy);
await sleep(120);
await p.mouse.click(hx, hy);
await sleep(200);
await p.keyboard.press("Escape");
await sleep(120);
const after = await segs();
await p.screenshot({ path: "scripts/probe-trim.png" });
// ── Auswertung: gibt es ein horizontales Segment (y≈2), das bei x≈4 endet und
// NICHT über x=4 hinausragt? Der Überstand x∈(4,6] muss weg sein. ────────────
const horiz = after.filter((s) => Math.abs(s.y1 - 2) < 0.05 && Math.abs(s.y2 - 2) < 0.05);
const maxX = horiz.reduce((m, s) => Math.max(m, s.x1, s.x2), -Infinity);
const trimmedToIntersection = Math.abs(maxX - 4) < 0.05;
console.log(
JSON.stringify(
{
beforeCount: before.length,
afterCount: after.length,
horizSegments: horiz,
maxXofHoriz: maxX,
trimmedToIntersection,
errs,
},
null,
2,
),
);
await b.close();
+101
View File
@@ -0,0 +1,101 @@
// Verifikation: „Wand" als echter Engine-Befehl.
// 1) GUI-„Wand" klicken → Befehlsfeld aktiv (Wand-Prompt, Feld fokussiert).
// 2) Mehrere Punkte im Plan klicken → Wände entstehen (Plan-Primitive wachsen),
// Rechtsklick beendet den (chainenden) Zug.
// 3) In 3D (Isometrie) schalten → die Wände sind dort sichtbar (Screenshot).
// 4) Getipptes „wand" startet DENSELBEN Befehl (Prompt = Wand-Prompt).
// Temporäres Verifikations-Gerüst (nicht eingecheckt).
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5188/";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1.25 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await wait(900);
const clickTool = (label) =>
p.evaluate((l) => [...document.querySelectorAll("button.tool-row")]
.find((x) => x.textContent.trim() === l)?.click(), label);
const clickView = (aria) =>
p.evaluate((a) => [...document.querySelectorAll("button")]
.find((x) => x.getAttribute("aria-label") === a)?.click(), aria);
const planBox = () => p.evaluate(() => {
const r = document.querySelector(".plan-svg").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const click = async (x, y, opts) => { await p.mouse.move(x, y); await p.mouse.click(x, y, opts); await wait(110); };
const cmdState = () => p.evaluate(() => {
const prompt = document.querySelector(".cmdline-prompt")?.textContent?.trim() ?? "";
const input = document.querySelector(".cmdline-input");
const focused = document.activeElement === input;
const placeholder = input?.getAttribute("placeholder") ?? "";
return { prompt, focused, active: placeholder === "" };
});
// Grobes Mass für „Wand-Geometrie im Plan": Anzahl Pfad-/Polygon-Primitive.
// Wände rendern als gefüllte Schicht-Polygone + Umriss-Pfade; jede neue Wand
// erhöht diese Zahl deutlich.
const primCount = () => p.evaluate(() =>
document.querySelectorAll(".plan-svg path, .plan-svg polygon").length);
const typeInCmd = async (text) => {
await p.evaluate(() => document.querySelector(".cmdline-input")?.focus());
await p.keyboard.type(text);
await p.keyboard.press("Enter");
await wait(200);
};
const box = await planBox();
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// ── 1) „Wand" anklicken → Befehlsfeld aktiv, Wand-Prompt ─────────────────────
const primStart = await primCount();
await clickTool("Wand");
await wait(300);
const afterToolClick = await cmdState();
console.log("after Wand-tool click → cmdline:", afterToolClick);
await p.screenshot({ path: "scripts/probe-wall-cmd-1-active.png" });
// ── 2) Mehrere Punkte → chainende Wände, dann Rechtsklick zum Beenden ────────
const P = [
[cx - 220, cy - 120],
[cx + 40, cy - 160],
[cx + 200, cy + 10],
[cx + 60, cy + 150],
];
for (const pt of P) await click(...pt);
await p.mouse.click(P[P.length - 1][0], P[P.length - 1][1], { button: "right" }); // Zug beenden
await wait(300);
const primAfter = await primCount();
console.log("plan primitives start / after walls:", primStart, primAfter, "→ delta", primAfter - primStart);
await p.screenshot({ path: "scripts/probe-wall-cmd-2-plan.png" });
// ── 3) In 3D (Isometrie) → Wände sichtbar ───────────────────────────────────
await clickView("Isometrie");
await wait(1500);
await p.screenshot({ path: "scripts/probe-wall-cmd-3-3d.png" });
// Zurück in den Grundriss.
await clickView("Grundriss");
await wait(600);
// ── 4) Getipptes „wand" startet DENSELBEN Befehl ────────────────────────────
await clickTool("Auswahl");
await wait(200);
const before = await cmdState();
await typeInCmd("wand");
const afterTyped = await cmdState();
console.log("after typing 'wand' → cmdline:", afterTyped, "(was:", before, ")");
// Einen Punkt setzen + Rechtsklick (Abbruch ohne 2. Punkt) — nur Prompt-Beleg.
await p.screenshot({ path: "scripts/probe-wall-cmd-4-typed.png" });
await p.keyboard.press("Escape");
console.log("errs:", errs.slice(0, 6));
console.log("RESULT activePrompt:", afterToolClick.active, afterToolClick.prompt,
"| primDelta:", primAfter - primStart,
"| typedPrompt:", afterTyped.active, afterTyped.prompt);
await b.close();

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