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).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent cfe5249440
commit 3d2d4d6321
184 changed files with 29421 additions and 669 deletions
+363 -1
View File
@@ -41,7 +41,302 @@ Code/Doku noch in der Git-Historie.
vor dem Edit frisch lesen (der „modified since read"-Guard verhindert blindes
Überschreiben).
## >>> STAND 2026-06-30 (Fortsetzung, spät) — ZUERST lesen <<<
## >>> 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.
**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
unten ist `npx tsc -b` + `npm run build` GRÜN und per Screenshot/Probe verifiziert,
@@ -49,6 +344,32 @@ außer „LÄUFT" markiert. Viel wurde an **Subagenten** delegiert (Dateien land
auf der Platte, unabhängig von Benachrichtigungen).
**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
(`--bg #0e0e0e`, `--panel #1d1d1d`, `--accent #4d4d4d` …); Kontextmenü-Tokens
`--ctx-hover/-text` neutral; grüner Fokus-Ring entfernt.
@@ -88,6 +409,47 @@ auf der Platte, unabhängig von Benachrichtigungen).
25 ok), `src/editors/splitJoin.ts`, App-Keydown (Ctrl/Cmd+S/J, `preventDefault`) +
`onSegmentCut`, PlanView Alt-Klick. Verifiziert (Rechteck→2 geschlossene, Join→1
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):**
1. Editier-Welle 3: **koinzidente Endpunkt-Griffe gemeinsam ziehen** + verbundene Enden
+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)
+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).
+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

+244
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
@@ -21,6 +22,7 @@
"three": "^0.169.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
@@ -1539,6 +1541,248 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tauri-apps/api": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+6 -1
View File
@@ -8,10 +8,14 @@
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
@@ -24,6 +28,7 @@
"three": "^0.169.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.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"
}
}
]
}
+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();
+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();
+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");
+325
View File
@@ -0,0 +1,325 @@
// 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";
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 (Text „PDF"). Öffnet den Export-Dialog.
const clickedBtn = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll(".tb-btn"));
const b = btns.find((x) => x.textContent.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) im Browser
// erzeugen und nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt.
try {
const svgPng = await page.evaluate(async () => {
const gp = await import("/src/plan/generatePlan.ts");
const sp = await import("/src/model/sampleProject.ts");
const ptp = await import("/src/export/planToPrintSvg.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 print = ptp.planToPrintSvg(plan, {
scaleDenominator: 100,
pageWidthMm: 297,
pageHeightMm: 210,
marginMm: 10,
});
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 canvas.toDataURL("image/png");
});
const b64 = svgPng.split(",")[1];
writeFileSync(join(OUT, "print-svg.png"), Buffer.from(b64, "base64"));
console.log("Druck-SVG → PNG:", join(OUT, "print-svg.png"));
} 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).
// 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";
const b = await puppeteer.launch({
@@ -75,8 +84,8 @@ await endCommand();
await sleep(150);
const before = await drawings();
// Rechteck wählen: auf eine Rechteck-Kante klicken (untere Kante y=0, x=1).
let [cx, cy] = await modelToClient(1, 0);
// Rechteck wählen: auf die linke Kante klicken (x=0, y=2) — eindeutig auf dem Rand.
let [cx, cy] = await modelToClient(0, 2);
await p.mouse.click(cx, cy);
await sleep(150);
await ctrlPress("s");
@@ -91,20 +100,20 @@ await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await focusCmd();
await typeLine("line");
await typeLine("-3,-3");
await typeLine("0,-3");
await typeLine("1,1");
await typeLine("4,1");
await focusCmd();
await typeLine("line");
await typeLine("0,-3");
await typeLine("0,0");
await typeLine("4,1");
await typeLine("4,4");
await endCommand();
await sleep(150);
const beforeJoin = await drawings();
// Beide wählen: erste klicken, dann Shift+zweite.
let [ax, ay] = await modelToClient(-1.5, -3);
// Beide wählen: erste klicken (Mitte 2.5,1), dann Shift+zweite (Mitte 4,2.5).
let [ax, ay] = await modelToClient(2.5, 1);
await p.mouse.click(ax, ay);
await sleep(100);
let [bx2, by2] = await modelToClient(0, -1.5);
await sleep(120);
let [bx2, by2] = await modelToClient(4, 2.5);
await p.keyboard.down("Shift");
await p.mouse.click(bx2, by2);
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();
+4
View File
@@ -0,0 +1,4 @@
/target
/gen/schemas
# Build-Artefakte aller (Sub-)Crates, unabhängig von der Verschachtelung.
target/
+4399
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[workspace]
members = [".", "geometry"]
[package]
name = "cad-tauri"
version = "0.1.0"
edition = "2021"
[lib]
# Der Tauri-v2-Bibliotheksname; staticlib/cdylib fuer Mobile, rlib fuer Desktop.
name = "cad_tauri_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
geometry = { path = "geometry" }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+75
View File
@@ -0,0 +1,75 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "geometry"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "geometry"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
[dev-dependencies]
serde_json = "1"
+16
View File
@@ -0,0 +1,16 @@
// Paritaets-Harness: liest ein JoinInput-JSON von stdin, rechnet compute_joins
// und schreibt das WallCuts-JSON nach stdout. Dient dem TS<->Rust-Vergleich
// (siehe src/compute/parity.test.ts) — identische Eingabe, identische Ausgabe.
use std::io::Read;
fn main() {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.expect("stdin lesen");
let input: geometry::JoinInput = serde_json::from_str(&buf).expect("JoinInput parsen");
let cuts = geometry::compute_joins(input);
let out = serde_json::to_string(&cuts).expect("WallCuts serialisieren");
println!("{out}");
}
+357
View File
@@ -0,0 +1,357 @@
// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Waende aufeinander-
// treffen, sollen sich die Schicht-Baender nicht ueberlappen, sondern an einer
// gemeinsamen Gehrungslinie sauber stossen. Dieses Modul berechnet pro Wand
// die optionalen Schnittlinien an Start- und Endpunkt.
//
// Reine 2D-Mathematik, kein externer Kernel noetig. Portiert aus dem
// TS-Modell (joins.ts / geometry.ts); die Vektor-Helfer add/sub/scale/
// normalize/leftNormal/len/lineIntersect sind unten dupliziert.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Vec2 {
pub x: f64,
pub y: f64,
}
/// Eine unendliche Gerade als Stuetzpunkt + Richtung.
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Line {
pub point: Vec2,
pub dir: Vec2,
}
#[derive(Serialize, Deserialize)]
pub struct WallInput {
pub id: String,
pub start: Vec2,
pub end: Vec2,
pub thickness: f64,
#[serde(rename = "referenceOffset")]
pub reference_offset: f64,
}
#[derive(Serialize, Deserialize)]
pub struct JoinInput {
pub walls: Vec<WallInput>,
}
/// Schnittlinien einer Wand an ihren beiden Achsenden.
#[derive(Serialize, Deserialize)]
pub struct WallCuts {
#[serde(rename = "wallId")]
pub wall_id: String,
#[serde(rename = "startCut")]
pub start_cut: Option<Line>,
#[serde(rename = "endCut")]
pub end_cut: Option<Line>,
}
// --- Vektor-Helfer -----------------------------------------------------------
fn sub(a: Vec2, b: Vec2) -> Vec2 {
Vec2 { x: a.x - b.x, y: a.y - b.y }
}
fn add(a: Vec2, b: Vec2) -> Vec2 {
Vec2 { x: a.x + b.x, y: a.y + b.y }
}
fn scale(a: Vec2, s: f64) -> Vec2 {
Vec2 { x: a.x * s, y: a.y * s }
}
fn len(a: Vec2) -> f64 {
a.x.hypot(a.y)
}
fn normalize(a: Vec2) -> Vec2 {
let l = len(a);
let l = if l == 0.0 { 1.0 } else { l };
Vec2 { x: a.x / l, y: a.y / l }
}
/// Linke Normale (90 Grad gegen den Uhrzeigersinn gedreht).
fn left_normal(a: Vec2) -> Vec2 {
Vec2 { x: -a.y, y: a.x }
}
/// Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren.
fn cross(p: Vec2, q: Vec2) -> f64 {
p.x * q.y - p.y * q.x
}
/// Schnittpunkt der Geraden (a + t*da) mit (b + s*db).
/// Liefert None bei (nahezu) parallelen Richtungen.
fn line_intersect(a: Vec2, da: Vec2, b: Vec2, db: Vec2) -> Option<Vec2> {
let denom = cross(da, db);
if denom.abs() < 1e-9 {
return None; // parallel -> kein Schnitt
}
let t = cross(sub(b, a), db) / denom;
Some(add(a, scale(da, t)))
}
// --- Verschneidung -----------------------------------------------------------
/// Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren.
fn round_key(p: Vec2) -> String {
let r = |v: f64| (v * 1e4).round() / 1e4;
format!("{},{}", r(p.x), r(p.y))
}
#[derive(Clone, Copy, PartialEq)]
enum WallEndKind {
Start,
End,
}
struct WallEnd {
wall_id: String,
end: WallEndKind,
}
/// Achsrichtung start->end, normalisiert.
fn dir_of(w: &WallInput) -> Vec2 {
normalize(sub(w.end, w.start))
}
/**
* Berechnet fuer jede Wand die Gehrungs-Schnittlinien.
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie
* Enden und T-/X-Stoesse bleiben rechtwinklig.
*/
pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
let walls = input.walls;
// Ergebnis: pro Wand ein Eintrag, Reihenfolge wie in der Eingabe.
let mut result: Vec<WallCuts> = walls
.iter()
.map(|w| WallCuts {
wall_id: w.id.clone(),
start_cut: None,
end_cut: None,
})
.collect();
// Index Wand-Id -> Position im Ergebnis/Eingabe.
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for (i, w) in walls.iter().enumerate() {
index.insert(w.id.as_str(), i);
}
// Knotenkarte: gerundeter Endpunkt -> Liste der dort endenden Wandenden.
// BTreeMap fuer deterministische Reihenfolge.
let mut junctions: std::collections::BTreeMap<String, Vec<WallEnd>> =
std::collections::BTreeMap::new();
let push = |p: Vec2, we: WallEnd, m: &mut std::collections::BTreeMap<String, Vec<WallEnd>>| {
m.entry(round_key(p)).or_default().push(we);
};
for w in &walls {
push(
w.start,
WallEnd { wall_id: w.id.clone(), end: WallEndKind::Start },
&mut junctions,
);
push(
w.end,
WallEnd { wall_id: w.id.clone(), end: WallEndKind::End },
&mut junctions,
);
}
for (_key, ends) in &junctions {
// Freies Ende -> kein Schnitt.
if ends.len() == 1 {
continue;
}
// T-/X-Stoesse (>2 Enden): vorerst rechtwinklig lassen.
if ends.len() != 2 {
continue;
}
let a_idx = match index.get(ends[0].wall_id.as_str()) {
Some(i) => *i,
None => continue,
};
let b_idx = match index.get(ends[1].wall_id.as_str()) {
Some(i) => *i,
None => continue,
};
let a = &walls[a_idx];
let b = &walls[b_idx];
let cut = match miter_line(a, ends[0].end, b) {
Some(c) => c,
None => continue, // kollinear -> kein Schnitt
};
set_cut(&mut result[a_idx], ends[0].end, cut);
set_cut(&mut result[b_idx], ends[1].end, cut);
}
result
}
/// Traegt eine Schnittlinie am passenden Ende einer Wand ein.
fn set_cut(cuts: &mut WallCuts, end: WallEndKind, cut: Line) {
match end {
WallEndKind::Start => cuts.start_cut = Some(cut),
WallEndKind::End => cuts.end_cut = Some(cut),
}
}
/**
* Gemeinsame Gehrungslinie zweier Waende A, B, die sich im Knoten J treffen.
* Robust gegen beliebige Wicklung und ungleiche Dicken: A's Aussenflaeche wird
* mit der NAECHSTGELEGENEN Flaeche von B verschnitten, A's Innenflaeche mit der
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung.
*
* Dicke und Referenzversatz kommen direkt aus WallInput (bereits flach).
*/
fn miter_line(a: &WallInput, a_end: WallEndKind, b: &WallInput) -> Option<Line> {
let j = match a_end {
WallEndKind::Start => a.start,
WallEndKind::End => a.end,
};
let t_a = a.thickness;
let t_b = b.thickness;
let u_a = dir_of(a);
let u_b = dir_of(b);
let n_a = left_normal(u_a);
let n_b = left_normal(u_b);
// Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden
// Wandflaechen um diesen Betrag entlang +n verschoben. Fuer "center"
// (Default) ist off=0 -> unveraendert.
let off_a = a.reference_offset;
let off_b = b.reference_offset;
// Flaechen-Stuetzpunkte am Knoten (linke/rechte Wandseite), inkl. Versatz.
let p_la = add(j, scale(n_a, off_a + t_a / 2.0));
let p_ra = add(j, scale(n_a, off_a - t_a / 2.0));
let p_lb = add(j, scale(n_b, off_b + t_b / 2.0));
let p_rb = add(j, scale(n_b, off_b - t_b / 2.0));
// Fuer A's linke Flaeche die naehere B-Flaeche waehlen; A's rechte die andere.
let lb_closer = len(sub(p_la, p_lb)) <= len(sub(p_la, p_rb));
let b_for_left = if lb_closer { p_lb } else { p_rb };
let b_for_right = if lb_closer { p_rb } else { p_lb };
let c1 = line_intersect(p_la, u_a, b_for_left, u_b)?;
let c2 = line_intersect(p_ra, u_a, b_for_right, u_b)?;
let dir = sub(c2, c1);
if len(dir) < 1e-9 {
return None;
}
Some(Line { point: c1, dir })
}
// --- Tests -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn w(id: &str, sx: f64, sy: f64, ex: f64, ey: f64, thickness: f64) -> WallInput {
WallInput {
id: id.to_string(),
start: Vec2 { x: sx, y: sy },
end: Vec2 { x: ex, y: ey },
thickness,
reference_offset: 0.0,
}
}
fn find<'a>(cuts: &'a [WallCuts], id: &str) -> &'a WallCuts {
cuts.iter().find(|c| c.wall_id == id).expect("wall id present")
}
#[test]
fn l_corner_shared_miter() {
// Wand A (0,0)-(5,0) und Wand B (5,0)-(5,4) treffen sich in (5,0).
let input = JoinInput {
walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
w("B", 5.0, 0.0, 5.0, 4.0, 0.2),
],
};
let out = compute_joins(input);
assert_eq!(out.len(), 2);
let ca = find(&out, "A");
let cb = find(&out, "B");
// A endet im Knoten -> endCut gesetzt; B startet dort -> startCut gesetzt.
let a_cut = ca.end_cut.expect("A endCut set");
let b_cut = cb.start_cut.expect("B startCut set");
assert!(ca.start_cut.is_none(), "A startCut is free end");
assert!(cb.end_cut.is_none(), "B endCut is free end");
// Beide Waende teilen sich dieselbe Gehrungslinie.
assert!((a_cut.point.x - b_cut.point.x).abs() < 1e-9);
assert!((a_cut.point.y - b_cut.point.y).abs() < 1e-9);
assert!((a_cut.dir.x - b_cut.dir.x).abs() < 1e-9);
assert!((a_cut.dir.y - b_cut.dir.y).abs() < 1e-9);
// Sanity: die Gehrung einer 90-Grad-Ecke gleicher Dicke ist die
// Diagonale durch (5,0), Richtung parallel zu (1,1) oder (-1,-1).
let d = normalize(a_cut.dir);
assert!(
(d.x.abs() - d.y.abs()).abs() < 1e-6,
"45-Grad-Gehrung erwartet"
);
}
#[test]
fn free_end_no_cut() {
let input = JoinInput {
walls: vec![w("A", 0.0, 0.0, 5.0, 0.0, 0.2)],
};
let out = compute_joins(input);
assert_eq!(out.len(), 1);
let ca = find(&out, "A");
assert!(ca.start_cut.is_none());
assert!(ca.end_cut.is_none());
}
#[test]
fn t_junction_no_cut() {
// Drei Enden treffen sich in (5,0): rechtwinklig lassen.
let input = JoinInput {
walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
w("B", 5.0, 0.0, 5.0, 4.0, 0.2),
w("C", 5.0, 0.0, 10.0, 0.0, 0.2),
],
};
let out = compute_joins(input);
assert_eq!(out.len(), 3);
for id in ["A", "B", "C"] {
let c = find(&out, id);
assert!(c.start_cut.is_none(), "{id} startCut none");
assert!(c.end_cut.is_none(), "{id} endCut none");
}
}
#[test]
fn collinear_pair_no_cut() {
// A (0,0)-(5,0) und B (5,0)-(10,0): parallel -> None.
let input = JoinInput {
walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
w("B", 5.0, 0.0, 10.0, 0.0, 0.2),
],
};
let out = compute_joins(input);
assert_eq!(out.len(), 2);
for id in ["A", "B"] {
let c = find(&out, id);
assert!(c.start_cut.is_none(), "{id} startCut none");
assert!(c.end_cut.is_none(), "{id} endCut none");
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

+18
View File
@@ -0,0 +1,18 @@
// Tauri-v2-Einstieg. Die eigentliche Geometrie liegt im serde-only Crate
// `geometry`; hier nur die Befehls-Bruecke und der App-Start.
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
#[tauri::command]
async fn compute_joins(
input: geometry::JoinInput,
) -> Result<Vec<geometry::WallCuts>, String> {
Ok(geometry::compute_joins(input))
}
#[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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
cad_tauri_lib::run()
}
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "cad",
"version": "0.1.0",
"identifier": "ch.dossier.cad",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5187"
},
"app": {
"windows": [
{
"title": "cad",
"width": 1400,
"height": 900
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.png"]
}
}
+1461 -109
View File
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
// Ceiling — Decke als echter Engine-Befehl (analog polyline: ein GESCHLOSSENER
// Umriss), committet aber ein einzelnes `Ceiling`-Bauteil statt einer 2D-Form.
// Schritte:
// 1) „Erster Umrisspunkt der Decke:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … Der Umriss schließt per
// Klick auf den Startpunkt, Option „Schliessen" (C) oder Enter (≥3 Punkte).
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
// derselbe Eingabepfad wie Polylinie/Wand. Die fertige Decke rendert über die
// bestehende Decken-Darstellung (generatePlan / 3D); der Draft zeigt den Umriss.
//
// Herkunft der Decken-Felder (CommandContext):
// • wallTypeId ← ctx.activeWallTypeId (Fallback: erster Projekt-Wandtyp) — der
// Schichtaufbau (Bauteil/Schraffur/Material) wird identisch zur Wand aufgelöst.
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "30" (Ebene „Decken"; Fallback ctx.defaultCategoryCode)
import type { Ceiling } from "../../model/types";
import { normalizeOutline, ceilingArea } from "../../geometry/ceiling";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
/** Default-Ebene (Kategorie) für Decken. */
const CEILING_CATEGORY = "30";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
const CEIL_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
function nextFromFields(
last: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/** Aktiver Aufbau-Typ-ID; Fallback auf den ersten Projekt-Wandtyp. */
function activeTypeId(ctx: CommandContext): string {
const wt =
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0];
return wt ? wt.id : ctx.activeWallTypeId;
}
/** Existiert die Decken-Kategorie („30")? Sonst Fallback auf die aktive Kategorie. */
function ceilingCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === CEILING_CATEGORY)
? CEILING_CATEGORY
: ctx.defaultCategoryCode;
}
interface CeilIdle extends CommandState {
phase: "start";
}
interface CeilDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
}
type CeilState = CeilIdle | CeilDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Vorschau: geschlossener Umriss-Ring (ab 3 Punkten) + Gummiband + HUD. */
function ceilingDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/** Hängt eine Decke ans Projekt (immutabel). Entartete Umrisse werden verworfen. */
function appendCeiling(p: Project, pts: Vec2[], ctx: CommandContext): Project {
const outline = normalizeOutline(pts);
if (!outline || ceilingArea(outline) < 1e-4) return p;
const ceiling: Ceiling = {
id: uniqueId("C"),
type: "ceiling",
floorId: ctx.level.id,
categoryCode: ceilingCategory(ctx),
outline,
wallTypeId: activeTypeId(ctx),
};
const ceilings = p.ceilings ? [...p.ceilings, ceiling] : [ceiling];
return { ...p, ceilings };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true },
];
export const ceilingCommand: Command = {
name: "ceiling",
labelKey: "cmd.ceiling.label",
// Decken leben auf Geschossen — wie die Wand nur dort aktiv.
floorOnly: true,
prompt: (s) =>
(s as CeilState).phase === "next" ? "cmd.ceiling.next" : "cmd.ceiling.start",
accepts: (s) =>
(s as CeilState).phase === "next"
? ["point", "number", "option"]
: ["point", "number"],
options: (s) => {
const ps = s as CeilState;
if (ps.phase !== "next") return [];
return ps.points.length >= 3 ? [CLOSE, UNDO] : [UNDO];
},
init: (): CeilIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (input.kind === "option") {
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null } as CeilIdle, { draft: null }];
const ns: CeilDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
};
return [ns, { draft: ceilingDraft(pts, s.cursor) }];
}
// Sofort schließen: committet die Decke (unabhängig, ≥3 Punkte).
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "next" ? ceilingDraft(s.points, s.cursor) : null },
];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: CeilDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen (committet die Decke).
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
// Null-Strecke (Klick auf denselben Punkt) ignorieren.
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
const points = [...s.points, pt];
const ns: CeilDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: CeilDrawing = { ...s, cursor: point };
return [ns, { draft: ceilingDraft(s.points, point) }];
},
// Enter/Space/Rechtsklick: Umriss schließen + committen (≥3 Punkte).
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase === "next" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
fields: (state) => ((state as CeilState).phase === "next" ? CEIL_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0) return null;
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+5
View File
@@ -133,6 +133,11 @@ export const circleCommand: Command = {
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CircleState;
if (s.phase !== "radius" || !cursor) return {};
return { radius: dist(s.center, cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as CircleState;
if (s.phase !== "radius") return null;
+64
View File
@@ -0,0 +1,64 @@
// Join — verbindet die gewählten 2D-Zeichenelemente an koinzidenten Endpunkten zu
// Polylinien (geschlossen, wenn sich eine Kette schließt). Sofort-Befehl (kein
// Punkt-Picken): läuft direkt auf der bestehenden Mehrfachauswahl. So ist „Ctrl+J"
// und getipptes „join" EIN Pfad (§Task C).
//
// Wände werden nicht verbunden (2D-Editier-Operation); nicht-polyline Formen
// (circle/arc/text) reicht `joinDrawings` unverändert durch.
import { joinDrawings } from "../../editors/splitJoin";
import type { Drawing2D } from "../../model/types";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
} from "../types";
/** Alle gewählten 2D-Element-IDs (Mehrfachauswahl mit Fallback aufs Einzelelement). */
function selectedDrawingIds(sel: CommandSelection): string[] {
if (sel.drawingIds && sel.drawingIds.length) return sel.drawingIds;
return sel.drawingId ? [sel.drawingId] : [];
}
/** Ersetzt die gewählten Quell-Drawings durch das Join-Ergebnis (immutabel). */
function applyJoin(project: Project, ids: string[]): Project {
const idSet = new Set(ids);
const srcs = project.drawings2d.filter((d) => idSet.has(d.id));
if (srcs.length < 2) return project; // <2 → nichts zu verbinden
const joined: Drawing2D[] = joinDrawings(srcs);
const rest = project.drawings2d.filter((d) => !idSet.has(d.id));
return { ...project, drawings2d: [...rest, ...joined] };
}
interface JoinState extends CommandState {
phase: "done";
}
const done = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as JoinState,
{ draft: null, done: true },
];
export const joinCommand: Command = {
name: "join",
labelKey: "edit.join.label",
prompt: () => "cmd.join.prompt",
accepts: () => [],
options: () => [],
init: (): JoinState => ({ phase: "done", lastPoint: null }),
// Sofort auf der Auswahl ausführen (kein Pick nötig).
autoRun: (ctx: CommandContext): CommandResult => {
const ids = selectedDrawingIds(ctx.selection);
if (ids.length < 2) return { draft: null, done: true };
return { draft: null, done: true, commit: (p) => applyJoin(p, ids) };
},
onInput: (): [CommandState, CommandResult] => done(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => done(),
onCancel: (): [CommandState, CommandResult] => done(),
};

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