Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b9f72e79f | |||
| cd0f976b4f | |||
| 919bc67bfa | |||
| ec5cbf6fa0 | |||
| f1b802299f | |||
| 0337422ae6 | |||
| d39244989b | |||
| 01424c1e22 | |||
| 1811e82005 | |||
| 0a1aaedef8 | |||
| 483ad3f278 | |||
| d27622d581 | |||
| f315c80748 | |||
| bda256d3a4 | |||
| c93b168f5c | |||
| a971cd610b | |||
| 48bbcaed1e | |||
| 6d4b4e66c1 | |||
| 27bb5a42ec | |||
| 8fd8987b70 | |||
| 7b3b597abc | |||
| 4a08161e4b | |||
| 39236c65c7 | |||
| b9dc1838c7 |
@@ -15,7 +15,338 @@ Code/Doku noch in der Git-Historie.
|
|||||||
Wer hier weiterarbeitet, schreibt so, als wäre es Handarbeit des Teams.
|
Wer hier weiterarbeitet, schreibt so, als wäre es Handarbeit des Teams.
|
||||||
- Vor jedem Push kurz prüfen: `git log` und `git diff` frei von solchen Spuren.
|
- Vor jedem Push kurz prüfen: `git log` und `git diff` frei von solchen Spuren.
|
||||||
|
|
||||||
## >>> STAND 2026-06-30 (Fortsetzung, spät) — ZUERST lesen <<<
|
## >>> KOORDINATION & MEMORY (bei mehreren Instanzen/Agents) <<<
|
||||||
|
|
||||||
|
**Mehrere Hauptinstanzen gleichzeitig:**
|
||||||
|
- Git: nur **eine** Instanz committet/pusht auf `master`, ODER jede arbeitet auf
|
||||||
|
eigenem Branch und merged kontrolliert. `App.tsx`/`types.ts` ist der serielle
|
||||||
|
Flaschenhals — verschiedene Features ≠ dieselbe Datei.
|
||||||
|
- Dieser HANDOVER ist der **Koordinationskanal** zwischen Instanzen (liegt im
|
||||||
|
Repo, wird mitgepusht). Stand hier kurz festhalten, bevor du übergibst.
|
||||||
|
|
||||||
|
**Subagents:**
|
||||||
|
- Bekommen **kein** Memory automatisch — nur was im Prompt steht. Regeln (v. a.
|
||||||
|
die COMMIT-REGEL oben) explizit mitgeben, sonst kennt der Agent sie nicht.
|
||||||
|
- Agents **nicht** committen und **nicht** ins Memory schreiben lassen. Sie
|
||||||
|
liefern Diffs/Dateien/Ergebnisse zurück; die Hauptinstanz committet und pflegt
|
||||||
|
das Gedächtnis.
|
||||||
|
|
||||||
|
**Memory (`~/.claude/...`, außerhalb des Repos — leakt nie hierher):**
|
||||||
|
- In den Kontext geladen wird nur der schlanke **Index** (eine Zeile je Eintrag);
|
||||||
|
einzelne Fakten erscheinen nur bei Relevanz. Größe ist daher selten ein Problem.
|
||||||
|
- **Keine automatische Bereinigung.** Gepflegt wird beim Schreiben (Duplikate
|
||||||
|
aktualisieren statt anlegen, Überholtes löschen) oder auf Ansage.
|
||||||
|
- Nur **beständige** Fakten ablegen, ein Fakt pro Datei, Index-Zeile knapp. Bei
|
||||||
|
parallelen Schreibvorgängen ist der Index (MEMORY.md) die Contention-Stelle —
|
||||||
|
vor dem Edit frisch lesen (der „modified since read"-Guard verhindert blindes
|
||||||
|
Überschreiben).
|
||||||
|
|
||||||
|
## >>> AUFGABE FÜR NEUE INSTANZ: 2D-Plan auf WebGL-GPU-Renderer (ZUERST) <<<
|
||||||
|
|
||||||
|
**Warum:** Tauri-Linux-Webview = **WebKitGTK**, das SVG/2D auf der **CPU (Cairo)** rastert.
|
||||||
|
User-GPU (AMD RX 7800 XT) langweilt sich; bei **144 Hz** (~6,9 ms/Frame) ruckelt Pan/Zoom/
|
||||||
|
**Objekt-Bewegen** sichtbar. three.js/WebGL (3D) läuft GPU-beschleunigt → NUR der 2D-SVG-Pfad
|
||||||
|
ist CPU-gebunden. Entscheidung mit User: 2D-Plan **von SVG auf WebGL2 (GPU)** umbauen.
|
||||||
|
|
||||||
|
**Sofortlösung fürs Vorführen/Entwickeln — Chromium-App-Shell:** `npm run shell`
|
||||||
|
(`scripts/chromium-shell.sh`) startet bei Bedarf den Vite-Dev-Server (Port 5187, Polling per
|
||||||
|
`curl`) und öffnet ihn dann in einem randlosen Chromium-Fenster (`--app=…`, eigenes Profil unter
|
||||||
|
`~/.cache/cad-chromium-shell`), statt im WebKitGTK-Webview von `tauri:dev`. Chromium rendert
|
||||||
|
dieselbe Seite GPU-beschleunigt und spürbar flüssiger. Kein Ersatz für den nativen wgpu-Pfad
|
||||||
|
(`render2d`/`render3d`), aber ein schneller Weg zu einer ruckelfreien Oberfläche, solange der
|
||||||
|
Electron/CEF-Shell (siehe Notizen zu „Render path decision") noch aussteht. Läuft rein im
|
||||||
|
Browser ohne Tauri-Backend — `src/compute/index.ts` guardet den einzigen `invoke()`-Aufruf
|
||||||
|
bereits per dynamischem Import mit TS-Fallback.
|
||||||
|
|
||||||
|
**Architektur (mit User abgestimmt):**
|
||||||
|
- **Hybrid:** WebGL2-Canvas rendert die schwere Geometrie (Poché-Polygone, Linien, Schraffuren,
|
||||||
|
Kreise/Bögen als Segmente). **Dünne SVG-Ebene DARÜBER** behält Text (Raumstempel/Labels),
|
||||||
|
Griffe, Snap-Marker, Werkzeug-Vorschau (wenige Elemente → billig, DOM-Hit-Test + scharfer Text bleibt).
|
||||||
|
- **Raw WebGL2, KEINE neue npm-Dependency** (User will nicht „abhängiger" werden; nativ).
|
||||||
|
- **Geometrie pro Plan EINMAL tessellieren** (Cache), Pan/Zoom = nur Transform-Matrix-Uniform →
|
||||||
|
keine Re-Tessellation, GPU-buttrig. Objekt-Bewegen: Plan ändert sich → re-tessellieren (leichter als SVG-Reconcile+Cairo).
|
||||||
|
- **Linienbreite konstant in Screen-px** (wie heutiges `non-scaling-stroke`): im Vertex-Shader
|
||||||
|
expandieren (Position world → clip, dann Normalen-Offset in clip via Viewport-px). Miter an Ecken
|
||||||
|
später; Spike: Quads pro Segment.
|
||||||
|
- **SVG-Renderer als Fallback hinter einem Flag behalten**, bis GL-Parität erreicht → kein Risiko.
|
||||||
|
- Vorgehen: **erst Spike** (Linien+gefüllte Polygone + GPU-Pan/Zoom), Glätte bei 144 Hz messen
|
||||||
|
(dev :5187 läuft; `tauri:dev`-Fenster läuft), DANN Schraffur/Feinschliff/Text-Overlay/Hit-Test.
|
||||||
|
|
||||||
|
**Fixpunkte im Code:**
|
||||||
|
- `src/plan/PlanView.tsx` (~2500 Z.): `toScreen()` = Modell-m → viewBox-Einheiten, FIXER Ursprung,
|
||||||
|
`PX_PER_M=90`; `view={x,y,w,h}` treibt `<svg viewBox>`; Pan = setView, Zoom = setView(onWheel).
|
||||||
|
BEREITS optimiert: `primitiveEls`/`drawingRunEls` `useMemo` (Pan/Zoom 312→0 Re-Renders) +
|
||||||
|
rAF-Coalescing beim Objekt-Drag (`flushDragMove`/`enqueueDragMove`, 40→1 Update/Frame). Diese
|
||||||
|
bleiben nützlich; der GL-Renderer ersetzt die SVG-Primitiv-Ebene, NICHT die Interaktions-Logik/Griffe.
|
||||||
|
- `src/plan/generatePlan.ts`: `export type Primitive` (Z.133) — kinds: `polygon`{pts,fill,stroke,
|
||||||
|
strokeWidthMm,hatch,*Id}, `line`{a,b,weightMm?,strokeWidthMm?,dash?,color,cls,greyed,drawingId?},
|
||||||
|
`arc`{center,r,…}, `text`{at,doc,extraLines,basePt,color} (Text → SVG-Overlay, NICHT WebGL).
|
||||||
|
- Koordinaten/Breiten-Logik im SVG-Renderer: `PrimitiveShape` (~Z.2280) + `DrawingRunShape` — dort
|
||||||
|
steht, wie Haarlinie/mm/`paperScale`/`non-scaling-stroke` die Strichstärke bestimmen (für GL nachbilden).
|
||||||
|
- Hit-Test/Auswahl/Griffe laufen weiter über die bestehenden PlanView-Pfade (modellbasiert); GL ist nur Anzeige.
|
||||||
|
- **Vektor-PDF/-Export bleibt** (kommt aus `generatePlan`/DXF, nicht aus der Anzeigefläche) — NICHT anfassen.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b` 0, `npm run build` grün, Trace-Scan sauber (keine KI-Spuren, COMMIT-REGEL oben).
|
||||||
|
Kein Commit ohne Ansage. Verifizieren wie üblich per Puppeteer gegen :5187 (Render korrekt + Glätte messen).
|
||||||
|
|
||||||
|
**GESAMT-TODO-LISTE (Stand 2026-07-01 Nacht) — im Hinterkopf behalten:**
|
||||||
|
ERLEDIGT+verifiziert: U/I/O/P-Kopiermodi · Backlog#1 Transform→CommandLine · Backlog#4 3D-Zeichnen +
|
||||||
|
Engine-Confirm-Bugfix · Raumstempel#2 (Feld-Modell) · Plan-Perf (Memo + rAF-Coalesce) · Tauri-M1-PoC
|
||||||
|
(geometry-Crate 4/4 + Parität + `tauri:dev`-Boot). OFFEN: **(A) 2D-WebGL-Renderer [DIESE AUFGABE]** ·
|
||||||
|
(B) Tauri-M1 Rest: Compute-Boundary in Render-Pfad einhängen (generatePlan sync → precompute+cache) +
|
||||||
|
nächste Ops (kernel2d→DXF/DWG→detectRooms) · (C) three.js→wgpu (M2, nach 2D-WebGL) · Parametric Walls
|
||||||
|
Phase B (andere Instanz) · Wand-Referenzlinien-Option unter Bezugspunkt · Viewport/Editier-Welle
|
||||||
|
(Treppen-Griffe, Öffnung-entlang-Wand, 2D auf Zeichenebenen, runde Linienenden, schattiert-Politur) ·
|
||||||
|
Smart-Join/Split + nahtfreie Wandverschneidung · Text/Stempel-Annotation (Frame+Rotation) · Welle B
|
||||||
|
(3D-Schnitt+Stencil-Capping) · Welle C (HLR verdrahten) · Overrides/Ausschnitte/Multi-Page-Layouts.
|
||||||
|
|
||||||
|
## >>> STAND 2026-07-01 (Tauri-PoC — App-frei, gelandet) <<<
|
||||||
|
|
||||||
|
**Tauri v2 + Rust-Compute-Boundary — Proof-of-Concept steht (3 App-freie Agents).**
|
||||||
|
Alle Gates grün, nichts an `App.tsx`/`src/model/` angefasst (nur NEUE Dateien +
|
||||||
|
additive Config). Ref: `docs/design/tauri-migration-plan.md` / `tauri-architecture.md`.
|
||||||
|
|
||||||
|
- **`src-tauri/` (Rust, neu):** Cargo-**Workspace** aus zwei Crates:
|
||||||
|
- `src-tauri/geometry/` — **serde-only** Lib, Port von `computeJoins` (aus
|
||||||
|
`src/model/joins.ts`) inkl. Vektor-Helfer. Input flach: `WallInput{ id,start,end,
|
||||||
|
thickness,referenceOffset }`; Output `WallCuts{ wallId,startCut,endCut }` (Line=
|
||||||
|
point+dir). **`cargo test` grün: 4/4** (L-Ecke=geteilte Gehrung, freies Ende,
|
||||||
|
T-Stoss, kollinear → alle korrekt). Braucht KEIN webkit → hier testbar.
|
||||||
|
- `src-tauri/` App-Crate (`cad-tauri`) — Tauri-v2-Shell (`lib.rs` mit
|
||||||
|
`#[tauri::command] compute_joins` + `run()`, `main.rs`, `build.rs`,
|
||||||
|
`tauri.conf.json` devUrl **:5187**, `icons/icon.png`). **`cargo check` GRÜN** —
|
||||||
|
wider Erwarten NICHT von webkit blockiert: tauri v2 bindet `webkit2gtk-4.1`
|
||||||
|
(vorhanden); nur `-6.0` fehlte. `src-tauri/target` + `gen/` sind Build-Artefakte
|
||||||
|
(gitignored).
|
||||||
|
- **`src/compute/index.ts` (neu, Boundary):** einzige Stelle für schwere Ops.
|
||||||
|
`computeJoins(project,walls)` flacht ab → `invoke("compute_joins")` (geguardeter
|
||||||
|
dynamischer Import von `@tauri-apps/api/core`, Build bleibt grün OHNE das Paket)
|
||||||
|
→ bei null/Fehler `console.warn` + TS-Fallback (`joins.ts`). Zusätzlich
|
||||||
|
Durchreicher `detectRooms` (roomBoundary.ts) + `parseShapeFromDwg`(=`parseDwg`);
|
||||||
|
`computeKernel2D` bewusst ausgelassen (kein Einzel-Entry, ~30 Primitive).
|
||||||
|
- **`package.json`/`vite.config.ts`:** `@tauri-apps/cli`+`api` (v2) installiert,
|
||||||
|
Scripts `tauri`/`tauri:dev`/`tauri:build`; vite Port 5187 `strictPort`,
|
||||||
|
`clearScreen:false`, `envPrefix` — rein additiv.
|
||||||
|
|
||||||
|
**NÄCHSTE SCHRITTE (Tauri):** (1) **Echte TS↔Rust-Numerik-Parität** end-to-end prüfen
|
||||||
|
— erst sinnvoll, wenn die Boundary in einen Caller verdrahtet ist (aktuell bewusst
|
||||||
|
NICHT verdrahtet, weil das `generatePlan`/`App` async machen würde). (2) `computeJoins`
|
||||||
|
in den Wand-Render-Pfad einhängen (async-Welle). (3) `tauri:dev` end-to-end auf einer
|
||||||
|
Maschine MIT `webkit2gtk` (hier nur `cargo check`, kein Fenster-Boot getestet).
|
||||||
|
(4) Nächste Ops laut Plan: `kernel2d` → DXF/DWG → `detectRooms`.
|
||||||
|
|
||||||
|
## >>> STAND 2026-07-01 (Parametric Walls Phase A — ISOLIERT) <<<
|
||||||
|
|
||||||
|
**Neue Instanz (Opus) — Parallelisierung mit Agents (Sonnet):**
|
||||||
|
- **Branch:** `feature/parametric-walls` (isoliert, keine App.tsx-Änderungen)
|
||||||
|
- **Phase A FERTIG (alle Agents grün):**
|
||||||
|
- **Agent 1:** Types (`ParametricWall`, 5 `ParametricRule` Varianten) + Engine
|
||||||
|
(`resolveParametricWall()`, 5 rule handlers, helpers) in `src/model/parametricWalls.ts`
|
||||||
|
- **Agent 2:** 50 Unit-Tests (Vitest), alle grün; `npm test -- parametricWalls.test.ts`
|
||||||
|
- **Agent 3:** Design-Doku `docs/design/parametric-walls.md` + `docs/README.md` aktualisiert
|
||||||
|
- Verification: `tsc -b` ✓, `npm run build` ✓ (577 modules, 3.72s)
|
||||||
|
- **Phase B (UI-Integration) — für nächste Instanz mit Agents:**
|
||||||
|
- Agent 1: TopBar ParametricWall-Picker + UI-Komponente
|
||||||
|
- Agent 2: Command `pw` (`cmds/parametricWall.ts`)
|
||||||
|
- Agent 3: ResourceManager-Integration (ParametricWall CRUD)
|
||||||
|
- Dann merge → `master`, Screenshot-Verifikation
|
||||||
|
- **Koordination:** andere Instanz committet parallel (Text, Raum, HLR) auf `master`,
|
||||||
|
keine Konflikte (nur `types.ts`+`src/model/` erweitert, nicht `App.tsx`).
|
||||||
|
|
||||||
|
**Backlog #4 ERLEDIGT (Zeichnen im 3D-View — vorherige Instanz):** Werkzeug-Gate `toolsEnabled`
|
||||||
|
(`App.tsx` ~1877) von `viewType === "grundriss"` auf `activeLevel.kind ===
|
||||||
|
"floor"` erweitert → Werkzeuge sind jetzt AUCH in der Perspektive aktiv. Die
|
||||||
|
gesamte Zeichen-Pipeline war bereits verdrahtet (`Viewport3D.workplaneModel()`
|
||||||
|
raycastet den Cursor auf die Geschoss-OKFF; `onWorkplanePoint`/`onWorkplaneConfirm`
|
||||||
|
speisen dieselbe Engine wie der Grundriss). Ende-zu-Ende per Puppeteer verifiziert:
|
||||||
|
in iso-Ansicht Linie mit 2 Klicks gezeichnet → committet (`draw2d` 0→1, Prompt
|
||||||
|
zurück auf „Befehl:"); Picks liefern gültige Modell-Vec2 (raycast korrekt).
|
||||||
|
- **BUG-FIX unterwegs (Engine-Confirm, `src/commands/engine.ts` `confirm()`):**
|
||||||
|
Beim Testen von #4 fiel ein VORBESTEHENDER Bug auf (reproduziert IDENTISCH im
|
||||||
|
Grundriss, nicht durch #4 verursacht): mehrteilige Befehle (Wand/Polylinie)
|
||||||
|
liessen sich per Enter/Rechtsklick NIE abschliessen. Ursache: `confirm()` nahm
|
||||||
|
IMMER den Tab-Feld-Zweig (`fieldResultPoint`), sobald der Schritt Felder hatte —
|
||||||
|
auch OHNE gelocktes Feld → es wurde ein Cursor-Punkt gefüttert (bei Wand: Null-
|
||||||
|
Strecke, ignoriert) statt `onConfirm` (das den Zug committet) aufzurufen. Fix:
|
||||||
|
Feld-Zweig nur noch bei tatsächlich GELOCKTEM Feld (`Object.keys(this.locks).length
|
||||||
|
> 0`); ohne Lock fällt `confirm()` auf `onConfirm` durch. Verifiziert: Wand
|
||||||
|
schliesst jetzt per Rechtsklick im Grundriss (91→98) UND in 3D (99→106); Tab-Feld-
|
||||||
|
Fluss „Länge 4 tippen → Enter lockt → leeres Enter setzt Punkt" unverändert;
|
||||||
|
Linie-Auto-Commit (2 Klicks) unverändert. Gegatet: `tsc -b` 0, `build` grün, keine
|
||||||
|
Konsolenfehler. KEIN Commit.
|
||||||
|
|
||||||
|
- **Backlog #1 ERLEDIGT (Befehlsleiste-Transform vereinheitlicht):** Die laufende
|
||||||
|
Auswahl-Transformation (nur noch `rotate`/Taste `d` mit U/I/O/P; move/mirror
|
||||||
|
laufen längst über die Engine) speist jetzt die `CommandLine` statt der
|
||||||
|
schwebenden `TransformBar`. Dritter Zweig in der `<CommandLine>`-Ternäre parallel
|
||||||
|
zu `gripEdit` (`gripEdit ? … : activeTransform ? … : engineView…`): Prompt =
|
||||||
|
`transform.hint.${op}.${…}`, Inline-Optionen = U/I/O/P (`transformOptions()`,
|
||||||
|
aktiver Modus hervorgehoben via neuem `active?`-Feld an `CommandLineOption` +
|
||||||
|
`.cmdline-option.active` in styles.css, `×count` bei array/distribute), Live-/
|
||||||
|
lockbares **Winkel**-Feld beim Drehen bzw. **Distanz** beim Bewegen
|
||||||
|
(`transformFields()` aus neuem `transformDragInfoRef`), getippte Zahl committet
|
||||||
|
über `submitTransformValue()`. `d` fokussiert die CommandLine (wie `m`/`s`).
|
||||||
|
`src/ui/TransformBar.tsx` gelöscht; verwaiste `.transform-bar`/`.tf-*`-CSS +
|
||||||
|
`transform.bar`-Key harmlos stehen gelassen. Gegatet: `tsc -b` 0 Fehler, `build`
|
||||||
|
grün, Trace-Scan sauber, Puppeteer-Probe end-to-end (nach `d`: „Drehzentrum
|
||||||
|
klicken" + U/I/O/P; nach Zentrum+Bezug: „Zielwinkel klicken" + Live-Winkelfeld;
|
||||||
|
keine Konsolenfehler). KEIN Commit (Konvention der Vorsessions beibehalten).
|
||||||
|
|
||||||
|
## >>> STAND 2026-07-01 (Abend) — Raum + Topbar-Umbau + Backlog — ZUERST lesen <<<
|
||||||
|
|
||||||
|
Alle unten genannten Agenten GRÜN gelandet und gegatet (`tsc -b` 0 Fehler,
|
||||||
|
`npm run build` grün, Trace-Scan sauber, Boot-Probe ohne Konsolenfehler). Dev-
|
||||||
|
Server läuft/hält auf :5187. KEIN Commit gemacht (Commit-Regel oben beachten).
|
||||||
|
|
||||||
|
**Diese Session gelandet:**
|
||||||
|
- **Rich-Text-Kern (komplett):** `src/text/richText.ts` (Marks/RichTextDoc/TextRange,
|
||||||
|
toggleMark/applyMark/isMarkActive/DEFAULT_PRESETS/serialize…), `src/text/renderHtml.ts`
|
||||||
|
(docToHtml/docToLines/docToSvgText), `src/text/RichTextEditor.tsx` (contentEditable,
|
||||||
|
Toolbar B/I/U/S + Grösse/Farbe/Presets).
|
||||||
|
- **Raum-Slice (SIA-416), Agent gelandet:** `Room` in `model/types.ts` (id, floorId,
|
||||||
|
categoryCode "60", siaCategory HNF/NNF/VF/FF/KGF, boundary, stampAnchor?, stampDoc?);
|
||||||
|
`cmds/room.ts` (Modus inside=click-inside via `roomFromPointInside`, manual=Polylinie);
|
||||||
|
Plan: neues `text`-Primitive + Füllpolygon + Stempel via `docToLines` + Live-Fläche;
|
||||||
|
`panels/RoomBalancePanel.tsx` (SIA-Bilanz + CSV). **App-State für Stempel:**
|
||||||
|
`selectedRoomId`/`selectedRoom.stampDoc`, `projectSlice.setRoomStampDoc(roomId,doc)`,
|
||||||
|
`moveRoomStamp`, `setRoomStampAnchor`, `stampEditorRoomId`. Stempel-Editor = kleines
|
||||||
|
schwebendes Fenster (kein Footer).
|
||||||
|
- **DOSSIER-Referenz studiert → Spec `docs/design/topbar-dossier.md`** (verbindlich für
|
||||||
|
Topbar-Arbeit; Referenz-Klon lag unter `/tmp/DOSSIER`). Kernbefund: Text-Styling ist im
|
||||||
|
Original eine FEST in der Oberleiste sitzende Text-Gruppe (Stil/Font/Grösse + B/I/U +
|
||||||
|
L/C/R + „+Text"), Akzent-Glow bei Auswahl; Raumstempel-Typografie darüber; Text-INHALT
|
||||||
|
in eigenem kleinem Fenster; Zoom/Massstab als 2×2-Cluster (kombinierte Stat-Pille).
|
||||||
|
- **Topbar-Umbau (Agent gelandet), `src/ui/TopBar.tsx` + `styles.css` + `App.tsx` +
|
||||||
|
`ui/TextEditorDialog.tsx` (neu) + i18n:** Zoom-Doppelung entfernt; 2×2-Massstab/Zoom-
|
||||||
|
Cluster; einheitliche Segmentpillen (neue `Segment`-Komponente); **Text-Gruppe** in der
|
||||||
|
Topbar (`textTarget`-Prop = `null | {doc,range,apply}`, aus selektiertem Raum abgeleitet,
|
||||||
|
`apply`→`setRoomStampDoc`; ohne range wirkt Format auf ganzes Doc); Doppelklick öffnet
|
||||||
|
`TextEditorDialog`. Verwaiste alte CSS-Regeln (`.tb-zoom*`, `.stamp-editor*`) bewusst
|
||||||
|
stehen gelassen (harmlos). „+ Text"-Button verdrahtet, aber DEAKTIVIERT (noch kein
|
||||||
|
Text-Annotations-Werkzeug).
|
||||||
|
|
||||||
|
**Backlog / nächste Schritte (Priorität dieser Session, siehe auch Todo-Liste):**
|
||||||
|
1. **Befehlsleiste-Transform vereinheitlichen:** laufende Transformation (move/mirror/rotate)
|
||||||
|
soll die `CommandLine` speisen (Prompt „Basispunkt/Zielpunkt" + U/I/O/P als Inline-
|
||||||
|
Optionen) statt der separaten schwebenden `TransformBar`; freies Befehl-Tippen während der
|
||||||
|
Geste unterdrücken, Zahleneingabe (Distanz/Winkel) via `fields` bleibt. Muster: parallel
|
||||||
|
zum bestehenden `gripEdit`-Zweig bei `<CommandLine>` (~App.tsx:2793). U/I/O/P-Routing
|
||||||
|
(App.tsx ~1543–1548) 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` ~416–449).
|
||||||
|
6. **Smart-Join/Split + NAHT-FREIE Wandverschneidung** (Poché-Union an Stössen, keine sichtbare
|
||||||
|
innere Naht, Gehrung/T-Stoss). 7. **Text/Stempel-Annotation** (aktiviert „+Text";
|
||||||
|
Frame+Rotation). 8. **Welle B** (3D-Schnittebene + Stencil-Capping + Schnittschraffur).
|
||||||
|
9. **Welle C** (HLR verdrahten; `src/section/hlr.ts` bereit). 10. Overrides/Ausschnitte/
|
||||||
|
Multi-Page-Layouts.
|
||||||
|
|
||||||
|
**Kollisions-Regel bestätigt:** `App.tsx`, `types.ts`, `generatePlan.ts`, `Viewport3D.tsx`,
|
||||||
|
`registry.ts`, `PlanView.tsx`, i18n, `styles.css` sind Hotspots → nur EIN App.tsx-Agent pro
|
||||||
|
Welle; parallele Tracks nur auf genuin App-freien neuen Modulen.
|
||||||
|
|
||||||
|
## >>> STAND 2026-07-01 (Bug-Batch Runde 1) — ZUERST lesen <<<
|
||||||
|
|
||||||
|
Runde-1-Agenten alle GRÜN gelandet (`tsc -b` + build + Probe verifiziert):
|
||||||
|
- **3D-Editier-Kamera-Bug (kritisch, `src/viewport/Viewport3D.tsx`):** Ursache = schwerer
|
||||||
|
Szenen-Aufbau-Effekt mit `project`-Dependency → jede Griff-Mutation baute Szene neu auf und
|
||||||
|
`applyView3d` resettete die Kamera auf Preset (→ „springt in Top-View", Rechts/Links-Pan-Illusion).
|
||||||
|
Fix: `camStateRef`-Snapshot (Pos/Target/up/ortho-Zoom/aktive Kam) im Render-Loop fortgeschrieben,
|
||||||
|
bei Neuaufbau WIEDERHERGESTELLT statt `applyView3d`; `applyView3d` nur beim ersten Aufbau;
|
||||||
|
`view3d`-Effekt guarded via `gripDragRef`; Top-View `enableRotate=false`; 2D-Drawings bekommen
|
||||||
|
`move`-Griff (Anker=center/at) → in 3D verschiebbar. Kamera pos/target numerisch identisch vor/nach
|
||||||
|
Drag/Löschen bewiesen.
|
||||||
|
- **Print-Linienstärke invertiert → korrekt (`src/plan/PlanView.tsx`, `styles.css`):** Print zeichnet jetzt
|
||||||
|
echte Papier-mm in viewBox-Einheiten OHNE `non-scaling-stroke`: `strokeVb=(mm·N/1000)·PX_PER_M`,
|
||||||
|
N=`paperScale` stabil (nur bei `applyScale`/erstem Messen gesetzt). Rein=dicker, raus=dünner;
|
||||||
|
1:100 vs 1:10 = exakt ×10. Display/Haarlinie unverändert. **Ecken-Miter:** CSS `stroke-linejoin:miter`
|
||||||
|
+ `buildDrawingRuns` bündelt zusammenhängende `line`-Primitive gleicher drawingId zu einem
|
||||||
|
`polyline`/`polygon` (Ringschluss erkannt) → gehrte Ecken statt Butt-Cap-Stufe. Primitive-Modell
|
||||||
|
(und Print-Export) unverändert.
|
||||||
|
- **ambientCG-Live-Bibliothek (`src/materials/ambientcg.ts` neu, `ResourceManager.tsx`):** komplette CC0-Lib
|
||||||
|
live durchsuchbar (Suche/Kategorie/Auflösung 1K-4K), jszip-Entpackung → Blob-URLs, NormalGL bevorzugt.
|
||||||
|
**CORS:** Such-JSON + `/get` brauchen Proxy → Vite-Dev-Proxy `/ambientcg` in `vite.config.ts`
|
||||||
|
(`VITE_AMBIENTCG_PROXY` für Prod); optionale Prod-Route `openbureau-core/api/src/routes/ambientcg.js`
|
||||||
|
(lesend, gemountet VOR Auth-Gate). Thumbnails direkt (ACAO:*). Offline → Schnellauswahl-Fallback.
|
||||||
|
|
||||||
|
**Runde 2 FERTIG (2026-07-01, alle GRÜN + Probe-verifiziert):**
|
||||||
|
- **2a** [App/engine/editors/registry, neu `cmds/mirror.ts`+`cmds/join.ts`]: Snapping erster Punkt gefixt
|
||||||
|
(Modifier shift/ctrl wurden im Command-Pfad verschluckt → jetzt via `EngineMods` durch `move/pick`→`host.snap`
|
||||||
|
gefädelt); Segment-Move für offene Polylinien/Linien (`EdgeGrip.free`, ganzer 2D-Delta, Nachbar-Segmente
|
||||||
|
folgen über geteilte Vertices); Kürzel↔Befehl vereinheitlicht (`M`→engine.start("move"), `S`→"mirror",
|
||||||
|
`Ctrl+J`→"join", identisch zum Tippen; `Command.autoRun` für nicht-interaktive Befehle; `CommandSelection.drawingIds`
|
||||||
|
für Mehrfachauswahl); **Mirror-Befehl** (2-Punkt-Achse, Vorschau, `commitTransform mirror/copy`).
|
||||||
|
- **2b** [cmds/{polyline,rect,wall}]: Polylinie **offen/geschlossen** (Toggle `m`, Ring-Fill); Rechteck **2pt/3pt/Zentrum**
|
||||||
|
(3pt = gedreht → closed polyline); **Wandstärke-Tab-Feld** nur bei einschichtigen Wandtypen (baut on-commit
|
||||||
|
einen einschichtigen Wandtyp der Zieldicke, Original bleibt).
|
||||||
|
- **Swisstopo/OSM-Kontext-Importer** [io/{lv95,geoContext,swissTopo,osm}, ui/ContextImportDialog, SitePanel]:
|
||||||
|
„Standort importieren" — geocode (geo.admin SearchServer, LV95 direkt), swisstopo-Gebäude
|
||||||
|
(`MapServer/identify` layer `ch.swisstopo.vec25-gebaeude`), OSM-Overpass (Gebäude/Strassen/Wasser/Grün).
|
||||||
|
LV95↔WGS84 (Bern round-trip 0.38 m) + Origin-Shift. CORS: geo.admin direkt, Overpass via neuer read-only-Route
|
||||||
|
`openbureau-core/api/src/routes/geoproxy.js` (vor Auth-Gate, host-allowlist) wenn `VITE_GEO_PROXY` gesetzt.
|
||||||
|
|
||||||
|
**TIER-1 FORTSCHRITT (2026-07-01):**
|
||||||
|
- **DXF-Vektor-Export FERTIG** [src/export/{dxfWriter,exportDxf}, ui/ExportDxfDialog, TopBar+App-Button]:
|
||||||
|
hand-rollierter R2000/AC1015-ASCII-Writer, `$INSUNITS=6` (Meter), 23 Layer aus Kategorien (ACI+True-Color+lw),
|
||||||
|
LINE/LWPOLYLINE(closed)/CIRCLE/ARC/TEXT; Quelle = `generatePlan()` in Meter-Modellraum. 5 m-Wand→5.345 m Span
|
||||||
|
verifiziert. DWG NICHT geshippt (libredwg-web nur lesend — ehrlich weggelassen).
|
||||||
|
- **Decke (Ceiling) FERTIG** [geometry/ceiling, cmds/ceiling(alias d/decke), types.ts `Ceiling`, projectSlice/selectionSlice,
|
||||||
|
generatePlan `addCeilingPoche`, PlanView-Hittest, Viewport3D `addCeilingMesh`(Extrude+Material+Modi), ToolsPanel-Button+
|
||||||
|
Deckentyp-Picker, ObjectInfoPanel `CeilingSection`, host.ts, App-Grips/Selection]. Wand-Muster: floorId + categoryCode "30"
|
||||||
|
+ wallTypeId/Component + VerticalAnchor(OK=Geschoss-Top) + optionale Dickenüberschreibung; statt Achse/Band ein
|
||||||
|
closed `outline: Vec2[]`. Plan = Umriss-Polygon (Fill+Hatch + schwere Outline) VOR den Wänden gezeichnet. Grips Vertex+Edge+Body.
|
||||||
|
- ⚠️ MERKE: **App.tsx ist der Serialisierungs-Engpass** — pro Welle darf NUR EIN Agent App.tsx editieren (DXF+Decke
|
||||||
|
liefen versehentlich gleichzeitig darauf; Merge hat diesmal geklappt, aber nicht drauf verlassen).
|
||||||
|
|
||||||
|
- **Öffnung (Fenster/Tür) FERTIG** [geometry/opening, cmds/opening(alias f/fenster, t/tuer/tür, oe), types.ts `Opening`,
|
||||||
|
generatePlan(`wallGaps`), PlanView, Viewport3D(`addWallMeshes` segmentiert + Sturz/Brüstung, `addOpeningMeshes` Rahmen/Glas/Flügel),
|
||||||
|
slices, App, ToolsPanel(Fenster+Tür-Buttons), ObjectInfoPanel(editierbar)]. **Host-Sync:** Öffnung speichert KEINE
|
||||||
|
Weltkoordinaten — alles wird pro Render aus `wall.start/end` neu abgeleitet → Wand verschieben bewegt Öffnung automatisch.
|
||||||
|
Plan-Symbole je Detailgrad (Tür: Blatt→+Schwenkbogen→+Anschlag; Fenster: 1→+Rahmen→+2 Glaslinien).
|
||||||
|
- **SIA-416-Engine FERTIG** [geometry/{roomArea,roomBoundary} — pure, isoliert, 34/34 Tests]: Raumerkennung
|
||||||
|
(planar-arrangement+half-edge face-tracing, `detectRooms`/`roomFromPointInside`), Fläche/Umfang/Zentroid,
|
||||||
|
SIA-Hierarchie (GF→KGF+NGF; NGF→NF(HNF/NNF)/VF/FF), `balance()`, `roomsToCsv`. Bereit für den Raum-Slice.
|
||||||
|
|
||||||
|
- **Treppe FERTIG** [geometry/stair, cmds/stair(alias treppe/tp — `tr`=trim!), types.ts `Stair`, model/wall `stairVerticalExtent`,
|
||||||
|
generatePlan `addStairSymbol`(Schnittbruchlinie+Pfeil), Viewport3D `addStairMeshes`(Stufenprofil), slices/App/Panels/i18n].
|
||||||
|
gerade/L/Wendel (Wendel = vereinfachtes Keil-Modell), SIA-17/29, geschoss-übergreifend (totalRise=Geschosshöhe).
|
||||||
|
- **HLR-Spike FERTIG (Welle-C-Fundament)** [src/section/{hlr,occt,occt-wasm.d}.ts, docs/welle-c-hlr-spike/, package.json
|
||||||
|
+opencascade.js, vite.config aliases]. **API-Pfad = `HLRAppli_ReflectLines`** (NICHT HLRBRep_Algo — im Prebuilt nicht
|
||||||
|
konstruierbar). Lazy via dynamic import, Haupt-Bundle unberührt. 62 MB WASM → später Custom-Build auf 5-15 MB.
|
||||||
|
|
||||||
|
**SESSION-LIMIT-STOP (2026-07-01 ~11:00, reset 13:00 Europe/Zurich):** Raum- + Rich-Text-Agenten wurden vom
|
||||||
|
Session-Limit abgeschnitten. Repo ist trotzdem GRÜN & stabil (tsc+build grün, dev :5187 läuft). Genauer Stand:
|
||||||
|
- **Raum: NICHT begonnen** — kein `cmds/room.ts`, kein `Room`-Typ, keine Verdrahtung. Sauber neu starten. SIA-Engine
|
||||||
|
(`geometry/roomArea.ts`+`roomBoundary.ts`) liegt fertig & getestet bereit zum Import.
|
||||||
|
- **Rich-Text: FAST FERTIG** — `src/text/richText.ts` (Doc-Modell + Marks + plainText/applyMark/toggleMark/serialize/Presets)
|
||||||
|
FERTIG. `src/text/renderHtml.ts` (docToHtml für Editor + docToLines/docToSvgText für SVG-Plan, gemeinsame Mark→Stil-Map)
|
||||||
|
JETZT FERTIG & kompiliert (im Hauptthread nachgezogen, da Agent am Limit war). FEHLT nur noch: `src/text/RichTextEditor.tsx`
|
||||||
|
(WYSIWYG-Komponente mit Toolbar bold/italic/underline/strike/Grösse/Farbe + Presets, controlled: value/onChange).
|
||||||
|
- Zwei Helfer-Subagenten ("Map/Extract …wiring in App.tsx") liefen ebenfalls ins Limit; App.tsx ist laut tsc+build INTAKT.
|
||||||
|
|
||||||
|
**RESUME NACH RESET (Reihenfolge):** (1) Rich-Text-Kern fertig (renderHtml + Editor-Komponente) — App-frei, isoliert.
|
||||||
|
(2) Raum-Slice (nutzt SIA-Engine, click-inside, Stempel, Bilanz+CSV) — App-Slice. (3) Text/Stempel-Slice (nutzt Rich-Text-Kern).
|
||||||
|
(4) Smart-Join/Split. (5) Welle B (3D-Schnitt+Stencil-Capping) → Welle C (HLR verdrahten, `src/section/hlr.ts` bereit).
|
||||||
|
(6) Overrides-Engine, Ausschnitte, Multi-Page-Layouts. REGEL: nur EIN App.tsx-Agent pro Welle; Parallel-Track nur wenn App-frei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## >>> STAND 2026-06-30 (Fortsetzung, spät) <<<
|
||||||
|
|
||||||
Fortsetzungs-Lauf (UI + Import + Wand-Attribute + Selektion/Editieren). Alles
|
Fortsetzungs-Lauf (UI + Import + Wand-Attribute + Selektion/Editieren). Alles
|
||||||
unten ist `npx tsc -b` + `npm run build` GRÜN und per Screenshot/Probe verifiziert,
|
unten ist `npx tsc -b` + `npm run build` GRÜN und per Screenshot/Probe verifiziert,
|
||||||
@@ -23,6 +354,32 @@ außer „LÄUFT" markiert. Viel wurde an **Subagenten** delegiert (Dateien land
|
|||||||
auf der Platte, unabhängig von Benachrichtigungen).
|
auf der Platte, unabhängig von Benachrichtigungen).
|
||||||
|
|
||||||
**Fertig & verifiziert (diese Fortsetzung):**
|
**Fertig & verifiziert (diese Fortsetzung):**
|
||||||
|
- **Vektor-PDF-Export des Grundrisses (KERN-ZIEL — fertig & verifiziert):**
|
||||||
|
`src/export/planToPrintSvg.ts` (Druck-SVG aus `generatePlan`, mm-Mapping im Maßstab,
|
||||||
|
echte mm-Linienstärken/ISO-Stifte, Schraffuren als Vektorlinien) + `src/export/exportPdf.ts`
|
||||||
|
(`jspdf`+`svg2pdf.js` → echtes Vektor-PDF, A4/A3 ×Hoch/Quer, Schriftfeld). TopBar-Button
|
||||||
|
„PDF" + `src/ui/ExportPdfDialog.tsx`. Verifiziert: 522 Pfade, 0 Rasterbilder, maßstabsgenau
|
||||||
|
(5,345×4,345 m → 53,45×43,45 mm @1:100). `scripts/probe-pdf.mjs`.
|
||||||
|
- **2D-Boolean (Union/Differenz/Schnitt):** `src/editors/booleanOps.ts` (polygon-clipping),
|
||||||
|
Kontextmenü „Vereinigen/Subtrahieren/Schneiden" + Ctrl/Cmd+Shift+U/D/I; geschlossene
|
||||||
|
Drawing2D, Differenz=erstes Element minus Rest, Loch→Außenring gefüllt + Innenringe als
|
||||||
|
Umriss. Verifiziert (Union 8/Differenz 6/Schnitt 4 Ecken). `scripts/probe-boolean.mjs`.
|
||||||
|
- **3D-1 — ERSTELLEN in 3D (Meilenstein):** der 3D-Viewport ist Erstellungs-Fläche;
|
||||||
|
dieselben Befehle (line/wall/rect/polyline) erzeugen Elemente direkt in 3D. Raycast auf
|
||||||
|
Geschoss-Arbeitsebene (`THREE.Plane` y=gridElevation) → Modell-`Vec2` (model.x=P.x,
|
||||||
|
model.y=P.z, aus `addDrawing2DLines` abgeleitet) → dieselbe `CommandEngine`
|
||||||
|
(`move/pick/confirm`). Live-3D-Draft (Rubber-Band + Snap-Marker, eigene `draftGroup`),
|
||||||
|
OrbitControls bei aktivem Befehl umgeschaltet, `pxPerMeter`-Fallback 90. Viewport3D-Props
|
||||||
|
`commandActive`/`onWorkplanePoint`/`onWorkplaneConfirm`/`draft`. Verifiziert (Wand in 3D →
|
||||||
|
in 3D + Plan sichtbar). `scripts/probe-3d-create-*.png`.
|
||||||
|
- **3D-2 — EDITIEREN in 3D (Meilenstein):** 3D-Griffe (eigene `gripGroup`) bei genau 1
|
||||||
|
Auswahl + `!commandActive`. Wand: Endpunkt-Kugeln (Achse), Höhen-Griff (OK via
|
||||||
|
`updateWall top:{mode:"custom",z}`), Verschiebe-Griff; Drawing2D: Vertices. Drag raycastet
|
||||||
|
auf Arbeitsebene bzw. vertikale Ebene → DIESELBEN Store-Actions wie Plan-Griffe
|
||||||
|
(`moveGripOf`/`moveElementByOf`/`updateWall`) → Plan+3D konsistent. Laufender Drag in
|
||||||
|
`gripDragRef` (übersteht den Szenen-Neuaufbau pro Move). Verifiziert (Endpunkt + Höhe in 3D
|
||||||
|
editiert, im Plan deckungsgleich). `scripts/probe-3d-edit-*.png`. **Offen (→ Editier-Welle 3):
|
||||||
|
3D-Snap beim Drag + Griffe „immer oben" (Tiefentest).**
|
||||||
- **Zwei-Ton-Dark-Theme + KEIN Petrol-Grün mehr:** `src/styles.css` Dark-Tokens neutral
|
- **Zwei-Ton-Dark-Theme + KEIN Petrol-Grün mehr:** `src/styles.css` Dark-Tokens neutral
|
||||||
(`--bg #0e0e0e`, `--panel #1d1d1d`, `--accent #4d4d4d` …); Kontextmenü-Tokens
|
(`--bg #0e0e0e`, `--panel #1d1d1d`, `--accent #4d4d4d` …); Kontextmenü-Tokens
|
||||||
`--ctx-hover/-text` neutral; grüner Fokus-Ring entfernt.
|
`--ctx-hover/-text` neutral; grüner Fokus-Ring entfernt.
|
||||||
@@ -55,13 +412,54 @@ auf der Platte, unabhängig von Benachrichtigungen).
|
|||||||
**Multi-Delete**, **Multi-Highlight** (`.plan-sel-draw`), **Shift-Klick-Mehrfachauswahl**
|
**Multi-Delete**, **Multi-Highlight** (`.plan-sel-draw`), **Shift-Klick-Mehrfachauswahl**
|
||||||
(`PlanSelection.shift` → `onPlanSelect` toggelt).
|
(`PlanSelection.shift` → `onPlanSelect` toggelt).
|
||||||
|
|
||||||
**LÄUFT bei Übergabe (auf Resume verifizieren, NICHT doppelt bauen):**
|
- **Editier-Welle 2 — Split/Join/Segment-Löschen (FERTIG & verifiziert):** Ctrl+S Split
|
||||||
- **Editier-Welle 2 — Split/Join/Segment** (Agent): Ctrl+S Split (Rechteck→zwei
|
(Rechteck→zwei geschlossene; nur bis Schnittpunkt), Ctrl+J Join (koinzidente Enden),
|
||||||
geschlossene; nur bis Schnittpunkt), Ctrl+J Join, Alt+Klick=Segment löschen. Berührt
|
Alt+Klick=Segment löschen (Schere). `kernel2d.ts` (`splitPolylineAtParam`/
|
||||||
`App.tsx`, `PlanView.tsx`, `kernel2d.ts` (`splitPolylineAtParam`/`splitClosedByChord`/
|
`splitClosedByChord`/`removeSegment`/`splitAtIntersections`/`joinChains`; Tests 16–23,
|
||||||
`removeSegment`/`splitAtIntersections`/`joinChains`), `projectSlice.ts`, ggf.
|
25 ok), `src/editors/splitJoin.ts`, App-Keydown (Ctrl/Cmd+S/J, `preventDefault`) +
|
||||||
`src/editors/splitJoin.ts`. **Resume:** `git diff --stat`, `npx tsc -b`, `npm run build`,
|
`onSegmentCut`, PlanView Alt-Klick. Verifiziert (Rechteck→2 geschlossene, Join→1
|
||||||
`node scripts/probe-splitjoin.mjs`.
|
Polylinie, Alt→Segment weg). `scripts/probe-splitjoin.mjs`. Wände bleiben no-op.
|
||||||
|
- **Editier-Welle 3 + Eingabe-Vereinheitlichung + Snap-Feinschliff (FERTIG):** 3D-Drag-Snap
|
||||||
|
(gleiche `computeSnap`) · Griffe/Marker immer-oben · koinzidente Endpunkt-Griffe ziehen
|
||||||
|
gemeinsam (Viewport3D). **GUI-Werkzeug** (line/polyline/rect) → `engine.start` +
|
||||||
|
Befehlsfeld-Fokus; **Shift = H/V-Ortho** beim Griff-Drag (2D+3D, `applyAngleConstraint`
|
||||||
|
aus `tools/snapping`). OFFEN: „Wand" noch Legacy (Wand-als-Command); Editieren läuft noch
|
||||||
|
AD-HOC, nicht durch die Engine → Tab-Wert + Referenz-Tracking offen. Snap-Marker
|
||||||
|
vereinheitlicht: 2D Endpunkt=Kreis/Mittelpunkt=Strich, 3D-Snap=`#ffcc44`, Marker kleiner;
|
||||||
|
2D-Vorschaulinie grau-Volllinie.
|
||||||
|
- **DWG-Parser erweitert (FIX „keine Geometrie gefunden") + Trim (FERTIG):** `io/dwgParser.ts`
|
||||||
|
neu — ARC/CIRCLE/ELLIPSE/SPLINE + **INSERT/Block-Expansion** (rekursiv, aus
|
||||||
|
`db.tables.BLOCK_RECORD.entries`, volle 2D-Affin-Transform) + Diagnose-Histogramm
|
||||||
|
(`DxfImportResult.diagnostics`, in ImportDialog). Real-DWG 37 INSERT → 67k Konturen.
|
||||||
|
**Trim** (`commands/cmds/trim.ts`, Alias `tr`, `kernel2d.trimPolyline`) — Quick-Trim bis
|
||||||
|
Schnittpunkt. Beide verifiziert.
|
||||||
|
- **Material-Bibliothek (Daten, FERTIG):** 12 CC0-Materialien von ambientCG →
|
||||||
|
`public/assets/materials/<id>/{color,normal,roughness,displacement,ao}.jpg` + `manifest.json`
|
||||||
|
+ `src/materials/library.ts` (PBR).
|
||||||
|
- **Welle A — 3D-Materialien (FERTIG & verifiziert):** `Component.material` (`ComponentMaterial`:
|
||||||
|
PBR-Map-URLs + `sizeM` Kachelgröße), `src/materials/runtime.ts` (`MaterialRuntime` → gecachte
|
||||||
|
`MeshStandardMaterial`, Kacheln in Welt-Metern via Extrude-UVs), `renderMode` „textured"
|
||||||
|
(TopBar-Darstellung), Material-Picker (ambientCG-Grid) + Upload + Kachelgröße im
|
||||||
|
`ResourceManager` (Bauteile-Tab). Backstein mit Normal-Map-Tiefe verifiziert.
|
||||||
|
`scripts/probe-materials.mjs`. Schnittschraffur-Slot frei für Welle B.
|
||||||
|
- **Editier-Eingabe + Topbar-Politur (FERTIG):** Befehlszeile zeigt **Live-Länge/-Winkel**
|
||||||
|
beim Zeichnen (`Command.fieldValues`), **Cursor-HUD entfernt**; **Seiten-Verschieben
|
||||||
|
propagiert verbundene Enden** (2D+3D); **Tab beim Vertex-Editieren** setzt Wert
|
||||||
|
(`gripEdit`-Controller in App — noch ad-hoc, NICHT durch die Engine). Topbar: Rest
|
||||||
|
iconisiert (Referenzlinien/Linien-Modus/Ressourcen), Linien-Modus → Dropdown, Aktions-
|
||||||
|
Buttons als Dropdown-Pill-Familie, **Detailgrad+Massstab gestapelt + 1:N/Zoom% kompakt**
|
||||||
|
(`.tb-zoomstack`).
|
||||||
|
- **Wand-als-Command (FERTIG & verifiziert):** `commands/cmds/wall.ts` (chainend, Achs+Band-
|
||||||
|
Draft, Tab-Felder; Wandfelder aus `CommandContext.activeWallTypeId`/`level`) + Registry
|
||||||
|
(Aliase `w`/`wand`) + App-Kopplung (`TOOL_COMMAND.wall`, Legacy-Wand umgeleitet). Wände in
|
||||||
|
2D+3D. `scripts/probe-wall-cmd.mjs`.
|
||||||
|
|
||||||
|
**OFFENE WELLEN (nächste Schritte):** Referenzobjekt-Achs-Tracking + Editieren VOLL durch die
|
||||||
|
Engine (heute `gripEdit` ad-hoc); **Welle B** (3D-Clip/Schnittebene + Stencil-Capping +
|
||||||
|
Schnittschraffur — nutzt das Material-System aus Welle A); **Welle C** (3D→2D-Schnitt/
|
||||||
|
Perspektive via HLR, opencascade.js-Spike); **Swisstopo-Importer** (Ort+Radius → Gebäude+
|
||||||
|
Gelände, geo.admin STAC/Geocode, CORS prüfen); Bögen/Kreise-Trim; Wandstärke-Tab bei
|
||||||
|
einschichtigen Wänden.
|
||||||
|
|
||||||
**Offene Wellen (Todo-Spiegel — Todo-Liste lebt im Kontext):**
|
**Offene Wellen (Todo-Spiegel — Todo-Liste lebt im Kontext):**
|
||||||
1. Editier-Welle 3: **koinzidente Endpunkt-Griffe gemeinsam ziehen** + verbundene Enden
|
1. Editier-Welle 3: **koinzidente Endpunkt-Griffe gemeinsam ziehen** + verbundene Enden
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -48,6 +48,14 @@ Undo/Redo** eliminiert die Cache-Stale-Bugs strukturell. Ziel-Repo-Struktur mit
|
|||||||
**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering
|
**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering
|
||||||
über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt.
|
über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt.
|
||||||
|
|
||||||
|
### [design/parametric-walls.md](design/parametric-walls.md)
|
||||||
|
Regelbasierte Wandgenerierung als Alternative zum Direktzeichnen. Vier Regel-Varianten
|
||||||
|
(`GridRule`, `ModuleRule`, `ConditionalRule`, `PolylineRule`) erzeugen `Wall[]`-Arrays
|
||||||
|
über einen reinen Auflöser (`resolveParametricWall`). Deckungsbereich: Schweizer 3-m-
|
||||||
|
Wohnraster, bedingte Außen-/Innenwand-Dicken, 6-m-Jochbauweise. Phase A: Typsystem +
|
||||||
|
Resolver isoliert, kein UI. Phase B: Command + Formular-Editor. Phase C: Grid-Ressource
|
||||||
|
und IFC-Export.
|
||||||
|
|
||||||
### [design/elements.md](design/elements.md)
|
### [design/elements.md](design/elements.md)
|
||||||
Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste
|
Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste
|
||||||
Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone-
|
Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone-
|
||||||
|
|||||||
@@ -0,0 +1,672 @@
|
|||||||
|
# Parametrische Wände
|
||||||
|
|
||||||
|
Status: Implementiert (Phase A — Typ-System und Resolver in `src/model/`, kein UI).
|
||||||
|
Dieses Dokument spezifiziert die **Parametrischen Wände**: regelbasierte Definitionen,
|
||||||
|
die beim Auflösen eine Liste von `Wall`-Elementen erzeugen, anstatt sie einzeln vom
|
||||||
|
Nutzer zeichnen zu lassen.
|
||||||
|
|
||||||
|
Bezugsdokumente: [elements.md](elements.md) (Wand-/Türmodell),
|
||||||
|
[drawing-tools.md](drawing-tools.md) (Werkzeugsystem, Direktzeichnen),
|
||||||
|
[state-architecture.md](state-architecture.md) (Projekt-Store),
|
||||||
|
[resources-graphics.md](resources-graphics.md) (WallType/Component-Auflösung).
|
||||||
|
|
||||||
|
Implementierungsdateien:
|
||||||
|
- `src/model/types.ts` — `ParametricWall`, `ParametricRule` und alle Regel-Varianten.
|
||||||
|
- `src/model/parametricWalls.ts` — `resolveParametricWall()`, `applyRule()` und
|
||||||
|
Hilfsfunktionen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Überblick
|
||||||
|
|
||||||
|
Eine **parametrische Wand** (`ParametricWall`) ist kein festes `Wall`-Element, sondern
|
||||||
|
ein **Regelwerk**, das beim Auflösen (`resolveParametricWall`) eine Menge von `Wall[]`-
|
||||||
|
Elementen generiert. Die erzeugten Wände sind gewöhnliche `Wall`-Objekte; sie
|
||||||
|
unterscheiden sich lediglich in ihrer Herkunft. Das semantische Modell (`Project`)
|
||||||
|
bleibt die einzige Wahrheit — parametrische Wände sind eine Ressource in der
|
||||||
|
Ressourcen-Bibliothek, nicht eine separate Laufzeit-Geometrie-Schicht.
|
||||||
|
|
||||||
|
```
|
||||||
|
Project.parametricWalls: ParametricWall[]
|
||||||
|
│
|
||||||
|
│ resolveParametricWall(pw, floorId, context, defaultWallType)
|
||||||
|
▼
|
||||||
|
Wall[] ──→ normales Rendering über generatePlan / Viewport3D
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugte Wände können entweder **temporär** (zur Laufzeit, als Ergänzung zu
|
||||||
|
`project.walls` im Rendering-Pfad) oder **eingebacken** (als `Wall[]` fest in
|
||||||
|
`Project.walls` gespeichert) behandelt werden. Phase A legt nur den Auflöser fest;
|
||||||
|
die Auswahl liegt bei der aufrufenden Komponente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Motivation
|
||||||
|
|
||||||
|
### 1.1 Schnellere Modellierung von Regelgrundrissen
|
||||||
|
|
||||||
|
Schweizer Wohnbauten folgen häufig einem 3-m-Achsraster (SIA-Norm, Modul-/
|
||||||
|
Skelettbauweise). Zwanzig Wände eines Rasters von Hand zu zeichnen ist fehleranfällig
|
||||||
|
und verhindert spätere parametrische Änderungen (z. B. Geschossanzahl, Rasterweite,
|
||||||
|
Wandtyp).
|
||||||
|
|
||||||
|
Eine `GridRule` erzeugt dieses Muster aus wenigen Parametern (Achsabstand, Richtung,
|
||||||
|
Bereich) und lässt sich mit einer einzigen Zahl auf „4-m-Büroraster" umstellen.
|
||||||
|
|
||||||
|
### 1.2 Kongruenz mit FreeCAD BIM / IFC
|
||||||
|
|
||||||
|
FreeCAD BIM kennt **ParametricObjects**, die ihre Geometrie aus Regeln ableiten (z. B.
|
||||||
|
`ArchWall` mit `Length`, `Width`, `Height`). Obwohl das Datenformat hier kein IFC ist,
|
||||||
|
schafft ein ähnliches Abstraktionsniveau eine spätere Brücke: Beim IFC-Export können
|
||||||
|
parametrische Wände als `IfcWallStandardCase` mit konstanten Attributen exportiert
|
||||||
|
werden — kein Informationsverlust gegenüber manuell gezeichneten Wänden.
|
||||||
|
|
||||||
|
### 1.3 Bedingte Wandtypen ohne manuelle Klassifizierung
|
||||||
|
|
||||||
|
Außenwände sind dicker als Innenwände; Trennwände zwischen Einheiten erfordern
|
||||||
|
Schallschutz. Eine `ConditionalThicknessRule` (`condition: "exterior" → thickType`)
|
||||||
|
weist den richtigen Wandtyp automatisch aus der geometrischen Lage zu — ohne dass der
|
||||||
|
Nutzer jeden Wandabschnitt einzeln klassifizieren muss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architektur
|
||||||
|
|
||||||
|
### 2.1 Typen (`src/model/types.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Eine parametrische Wand-Regel — generiert automatisch Wall[]-Einträge für
|
||||||
|
* ein gegebenes Geschoss. Lebt in Project.parametricWalls[].
|
||||||
|
*/
|
||||||
|
export interface ParametricWall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* Geordnete Liste der anzuwendenden Regeln. Spätere Regeln können die
|
||||||
|
* Ausgabe früherer verfeinern (z. B. Dickenzuweisung nach Raster).
|
||||||
|
*/
|
||||||
|
rules: ParametricRule[];
|
||||||
|
/**
|
||||||
|
* Rückfall-Wandtyp, falls eine Regel keinen eigenen `wallTypeId` nennt.
|
||||||
|
*/
|
||||||
|
defaultWallTypeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diskriminierte Union aller Regel-Varianten. */
|
||||||
|
export type ParametricRule =
|
||||||
|
| GridRule
|
||||||
|
| ModuleRule
|
||||||
|
| ConditionalThicknessRule
|
||||||
|
| ReferenceLineRule
|
||||||
|
| SequenceRule;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Einbettung ins Projekt
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Project {
|
||||||
|
// … bestehende Felder …
|
||||||
|
/**
|
||||||
|
* Parametrische Wanddefinitionen (Ressourcen-Bibliothek). Optional, damit
|
||||||
|
* bestehende Projekte/Tests ohne `parametricWalls` gültig bleiben.
|
||||||
|
*/
|
||||||
|
parametricWalls?: ParametricWall[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Resolver-Kontext (`src/model/parametricWalls.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ParametricContext {
|
||||||
|
/** Das Ziel-Geschoss. */
|
||||||
|
floor: DrawingLevel;
|
||||||
|
/**
|
||||||
|
* Optionale Rasterachsen (Phase C: verlinkter Grid-Ressource). Fehlen sie,
|
||||||
|
* berechnet die Engine die Achsen aus GridRule.spacing.
|
||||||
|
*/
|
||||||
|
gridAxes?: { x: number[]; y: number[] };
|
||||||
|
/**
|
||||||
|
* Optionales Clipping-Polygon (Meter). Fehlt es, reicht das Raster über
|
||||||
|
* einen Standardbereich (0 … spacing × 10).
|
||||||
|
*/
|
||||||
|
boundaryGeometry?: { boundary: Vec2[] };
|
||||||
|
/**
|
||||||
|
* Bereits im Projekt vorhandene Wände des Geschosses. Werden von
|
||||||
|
* refinierenden Regeln (ConditionalThicknessRule, ReferenceLineRule) genutzt.
|
||||||
|
*/
|
||||||
|
existingWalls?: Wall[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Regel-Varianten
|
||||||
|
|
||||||
|
### 3.1 GridRule — Achsraster
|
||||||
|
|
||||||
|
Erzeugt parallele Wände auf einem gleichmäßigen Raster. Typischer Einsatz: Schweizer
|
||||||
|
Wohnbau-Achsraster (3 m), Büro-Konstruktionsraster (6 m), strukturelle Raster mit
|
||||||
|
fester Stützweite.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface GridRule {
|
||||||
|
type: "grid";
|
||||||
|
/**
|
||||||
|
* Optionaler Verweis auf eine Grid-Ressource (Phase C). Für Phase A wird
|
||||||
|
* stattdessen `spacing` genutzt.
|
||||||
|
*/
|
||||||
|
gridId?: string;
|
||||||
|
/** Rasterabstand in Metern (Default: 3.0). */
|
||||||
|
spacing?: number;
|
||||||
|
/**
|
||||||
|
* Achsrichtungen: „x" = nur Wände entlang der Y-Achse,
|
||||||
|
* „y" = nur Wände entlang der X-Achse, „both" = Vollraster.
|
||||||
|
*/
|
||||||
|
directions: "x" | "y" | "both";
|
||||||
|
/** Optionaler Verweis auf Clipping-Polygon. */
|
||||||
|
boundaryId?: string;
|
||||||
|
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
|
||||||
|
wallTypeId?: string;
|
||||||
|
/** Lage der Wandachse über die Dicke (Vectorworks-Stil). */
|
||||||
|
referenceLine?: WallReferenceLine;
|
||||||
|
/** Optionale Höhenübersteuerung in Metern; sonst Geschosshöhe. */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Geometrieausgabe (top-down Grundriss):**
|
||||||
|
|
||||||
|
```
|
||||||
|
directions: "x", spacing: 3.0, Bereich 0…12 m:
|
||||||
|
|
||||||
|
y
|
||||||
|
│
|
||||||
|
12 ──────────────────────────
|
||||||
|
│
|
||||||
|
9 ──────────────────────────
|
||||||
|
│
|
||||||
|
6 ──────────────────────────
|
||||||
|
│
|
||||||
|
3 ──────────────────────────
|
||||||
|
│
|
||||||
|
0 ──────────────────────────
|
||||||
|
│
|
||||||
|
└──────────────────────────► x
|
||||||
|
0 12
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Tragende Wände auf fester Stützweite (Wohnbau 3 m, Büro 6 m).
|
||||||
|
- Vollraster (`"both"`) für strukturelle Rastersysteme.
|
||||||
|
- In Kombination mit `ConditionalThicknessRule` zur automatischen Außen/Innen-Klassifizierung.
|
||||||
|
|
||||||
|
**Beispiel: Schweizer 3-m-Wohnraster**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pw: ParametricWall = {
|
||||||
|
id: "pw-eg-raster",
|
||||||
|
name: "EG Längswände 3m-Raster",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "grid",
|
||||||
|
spacing: 3.0,
|
||||||
|
directions: "x", // Wände in X-Richtung (y = 0, 3, 6, 9, 12)
|
||||||
|
wallTypeId: "wt-innen-15",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auflösung:
|
||||||
|
const walls = resolveParametricWall(pw, "floor-eg", {
|
||||||
|
floor: egFloor,
|
||||||
|
boundaryGeometry: { boundary: rectBoundary(0, 0, 12, 12) },
|
||||||
|
}, defaultWallType);
|
||||||
|
// → 5 Wände bei y = 0, 3, 6, 9, 12, je 12 m lang
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 ModuleRule — Bay-/Jochbauweise
|
||||||
|
|
||||||
|
Unterteilt eine Referenzspanne in gleiche Module und erzeugt Querwände an jedem
|
||||||
|
Teilungspunkt. Typisch für Bürogebäude (6-m-Joch) oder Reihenhäuser mit modularer
|
||||||
|
Erschließung.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ModuleRule {
|
||||||
|
type: "module";
|
||||||
|
/** Modulmaß in Metern (z. B. 6.0, 3.6). */
|
||||||
|
moduleSize: number;
|
||||||
|
/** Ausrichtung der Trennwände: „x" = Querwände senkrecht zu X, „y" = zu Y. */
|
||||||
|
direction: "x" | "y";
|
||||||
|
/**
|
||||||
|
* Optionaler Verweis auf eine Referenzwand, die die Spannweite definiert.
|
||||||
|
* Fehlt er, wird die Geschoss-Ausdehnung (Bounding-Box) genutzt.
|
||||||
|
*/
|
||||||
|
referenceWallId?: string;
|
||||||
|
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
|
||||||
|
wallTypeId?: string;
|
||||||
|
referenceLine?: WallReferenceLine;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Wenn sich Querwände aus einer Referenzspanne (Fassade, Achswand) ergeben.
|
||||||
|
- Vorzug vor `GridRule`, wenn nur in eine Richtung unterteilt wird und eine
|
||||||
|
Referenzwand die Spanne definiert.
|
||||||
|
|
||||||
|
**Beispiel: 6-m-Bay-Bürogebäude**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pw: ParametricWall = {
|
||||||
|
id: "pw-buero-joch",
|
||||||
|
name: "Büro 6m-Joch",
|
||||||
|
defaultWallTypeId: "wt-beton-20",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
moduleSize: 6.0,
|
||||||
|
direction: "x", // Querwände senkrecht zur X-Achse
|
||||||
|
// referenceWallId: "W-sudfassade" → Spanne aus der Südwand ableiten
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30 (bei 36-m-Fassade)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 ConditionalThicknessRule — Bedingte Wandtypen
|
||||||
|
|
||||||
|
Weist bereits erzeugten Wänden (aus vorherigen Regeln in der Sequenz) einen anderen
|
||||||
|
Wandtyp zu — abhängig von einer Bedingung. Gibt modifizierte **Kopien** zurück; die
|
||||||
|
Eingabe-Wände werden nicht mutiert.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ConditionalThicknessRule {
|
||||||
|
type: "conditional-thickness";
|
||||||
|
/**
|
||||||
|
* Bedingung für den Treffer:
|
||||||
|
* • „exterior" — Wand liegt am Außenrand (Bounding-Box des Kontexts).
|
||||||
|
* • „interior" — Wand liegt im Inneren.
|
||||||
|
* • „bearing" — tragende Wand (Heuristikum: Wand läuft ±10° zu X/Y-Achse).
|
||||||
|
* • beliebiger String — benutzerdefiniertes Tag (Phase C: Wall.tags[]).
|
||||||
|
*/
|
||||||
|
condition: "exterior" | "interior" | "bearing" | string;
|
||||||
|
/** Ziel-Wandtyp, der bei Treffer gesetzt wird. */
|
||||||
|
wallTypeId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Immer in Kombination mit `GridRule` oder `ModuleRule` (als zweite Regel in
|
||||||
|
`ParametricWall.rules`): Raster erzeugt, Dicke verfeinert.
|
||||||
|
- Wenn Außen- und Innenwände denselben geometrischen Ursprung haben, aber
|
||||||
|
verschiedene Aufbauten benötigen.
|
||||||
|
|
||||||
|
**Beispiel: Außen dick, Innen dünn**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pw: ParametricWall = {
|
||||||
|
id: "pw-eg-komplett",
|
||||||
|
name: "EG Vollraster mit Außenwand-Differenzierung",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "grid", spacing: 3.0, directions: "both",
|
||||||
|
wallTypeId: "wt-innen-15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "conditional-thickness",
|
||||||
|
condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36", // Außenwände erhalten dicken Aufbau
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 ReferenceLineRule — Wandachsen-Lage
|
||||||
|
|
||||||
|
Setzt `referenceLine` bei passenden Wänden einheitlich (Vectorworks-Stil: Achse
|
||||||
|
links/rechts/mittig). Gibt modifizierte Kopien zurück.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ReferenceLineRule {
|
||||||
|
type: "reference-line";
|
||||||
|
/** Neue Lage der Wandachse, die einheitlich gesetzt wird. */
|
||||||
|
referenceLine: WallReferenceLine; // "left" | "center" | "right"
|
||||||
|
/**
|
||||||
|
* Filterziel:
|
||||||
|
* • „all" — alle Wände im aktuellen Satz.
|
||||||
|
* • „exterior" — nur Außenwände.
|
||||||
|
* • beliebiger String — benutzerdefiniertes Tag (Phase C).
|
||||||
|
*/
|
||||||
|
target: "all" | "exterior" | string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Außenwände auf `"left"` setzen (Achse liegt auf der Fassadenfläche).
|
||||||
|
- Als abschließende Regel in einer `SequenceRule` nach Raster und Dickenzuweisung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 SequenceRule — Zusammenfassung von Unterregeln
|
||||||
|
|
||||||
|
Fasst mehrere Regeln als atomare Einheit zusammen. Jede Unterregel erhält die Ausgabe
|
||||||
|
der vorherigen als `existingWalls` — so können spätere Regeln frühere verfeinern.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface SequenceRule {
|
||||||
|
type: "sequence";
|
||||||
|
rules: ParametricRule[];
|
||||||
|
/**
|
||||||
|
* Wenn true: Abbruch nach der ersten Unterregel, die mindestens eine Wand
|
||||||
|
* generiert/verändert hat (Short-Circuit-Fallback).
|
||||||
|
*/
|
||||||
|
stopOnMatch?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Um eine zusammengehörige Kombination (Raster → Dicke → Referenzlinie) als
|
||||||
|
Untermodul wiederzuverwenden — z. B. in unterschiedlichen Geschossen mit leicht
|
||||||
|
abweichenden Parametern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resolver-API (`src/model/parametricWalls.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Löst ein ParametricWall-Regelwerk zu einem Wall[]-Array für ein gegebenes
|
||||||
|
* Geschoss auf.
|
||||||
|
*
|
||||||
|
* Ablauf:
|
||||||
|
* 1. Regelwerk sequenziell ausführen; jede Regel erhält die Ausgabe der
|
||||||
|
* vorherigen als existingWalls (ermöglicht Verfeinerung).
|
||||||
|
* 2. Duplikate (gleicher Start-/Endpunkt innerhalb tolerance) entfernen.
|
||||||
|
* 3. Bereinigte Wall[]-Liste zurückgeben.
|
||||||
|
*
|
||||||
|
* Die Ausgabe ist sofort bereit zur Einfügung in project.walls. Es werden
|
||||||
|
* keine Seiteneffekte erzeugt — kein Store, kein Dispatch, kein React.
|
||||||
|
*
|
||||||
|
* @param parametricWall Das Regelwerk.
|
||||||
|
* @param floorId ID des Ziel-Geschosses.
|
||||||
|
* @param context Kontext (Geschoss-Objekt, Grid-Achsen, Grenzen, …).
|
||||||
|
* @param defaultWallType Fallback-Wandtyp, wenn eine Regel keinen nennt.
|
||||||
|
* @param tolerance Näherungstoleranz für Duplikat-Erkennung (Meter, Default 0.01).
|
||||||
|
* @returns Wall[]-Array, bereit zur Einfügung.
|
||||||
|
*/
|
||||||
|
export function resolveParametricWall(
|
||||||
|
parametricWall: ParametricWall,
|
||||||
|
floorId: string,
|
||||||
|
context: ParametricContext,
|
||||||
|
defaultWallType: WallType,
|
||||||
|
tolerance?: number,
|
||||||
|
): Wall[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatcher: delegiert eine Regel an die passende Implementierung.
|
||||||
|
* Exportiert für Unit-Tests und erweiterbare Regeltypen.
|
||||||
|
*/
|
||||||
|
export function applyRule(rule: ParametricRule, ctx: RuleCtx): Wall[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt doppelte Wände: zwei Wände gelten als Duplikat, wenn Start- und
|
||||||
|
* Endpunkt jeweils innerhalb tolerance übereinstimmen (vorwärts und rückwärts).
|
||||||
|
*/
|
||||||
|
export function deduplicateWalls(walls: Wall[], tolerance?: number): Wall[];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Höhenauflösung
|
||||||
|
|
||||||
|
Die Wandhöhe (`Wall.height`) ergibt sich nach folgender Priorität:
|
||||||
|
|
||||||
|
1. `rule.height`, falls an der einzelnen Regel gesetzt.
|
||||||
|
2. `context.floor.floorHeight` des Zielgeschosses.
|
||||||
|
3. Fallback: 2.6 m (globaler Default, CONVENTIONS.md).
|
||||||
|
|
||||||
|
### 4.2 ID-Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
"pw-<floorId>-gx-<counter>" // GridRule, X-Achse
|
||||||
|
"pw-<floorId>-gy-<counter>" // GridRule, Y-Achse
|
||||||
|
"pw-<floorId>-mx-<counter>" // ModuleRule, X-Teilung
|
||||||
|
"pw-<floorId>-ct-<counter>" // ConditionalThicknessRule
|
||||||
|
"pw-<floorId>-rl-<counter>" // ReferenceLineRule
|
||||||
|
```
|
||||||
|
|
||||||
|
IDs sind sessionlokal (Zähler startet bei 0 je Modullade). Eingebrannte Wände
|
||||||
|
erhalten beim Commit neue stabile IDs über `uniqueId("W")` — konsistent mit dem
|
||||||
|
Wand-Werkzeug (vgl. [drawing-tools.md §8](drawing-tools.md#8-id-vergabe--immutabilität)).
|
||||||
|
|
||||||
|
### 4.3 Duplikat-Erkennung
|
||||||
|
|
||||||
|
`deduplicateWalls` vergleicht Start-/Endpunkte beider Wände (vorwärts: A→B == A→B,
|
||||||
|
und rückwärts: A→B == B→A) innerhalb einer Toleranz von 1 cm (0.01 m). Die **erste**
|
||||||
|
Instanz wird behalten; spätere Duplikate werden verworfen. Dies ist wichtig bei
|
||||||
|
Vollrastern (`"both"`), bei denen X- und Y-Wände exakt auf einem Rasterpunkt
|
||||||
|
zusammentreffen könnten.
|
||||||
|
|
||||||
|
### 4.4 Verhalten bei ungültigen Eingaben
|
||||||
|
|
||||||
|
| Situation | Verhalten |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `spacing <= 0` oder `moduleSize <= 0` | `[]` |
|
||||||
|
| `referenceWallId` nicht in `existingWalls` | Fallback auf Bounding-Box, kein Fehler |
|
||||||
|
| Unbekannter `condition`-String | `matchesCondition` gibt `false` zurück (kein Treffer) |
|
||||||
|
| Unbekannter `SequenceRule`-Untertyp | TypeScript exhaustiveness-Guard, `[]` |
|
||||||
|
| Segment mit `|end - start| < 1e-6` m | Kann durch deduplicateWalls entfernt werden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Integration ins Projekt
|
||||||
|
|
||||||
|
### 5.1 Ressourcen-Speicherung
|
||||||
|
|
||||||
|
`ParametricWall`-Einträge leben unter `Project.parametricWalls` (optionales Array).
|
||||||
|
Sie sind Teil des `.cad.json`-Dokuments und werden mit dem Rest des Projekts gespeichert.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// sampleProject.ts — Beispieleintrag
|
||||||
|
export const sampleProject: Project = {
|
||||||
|
// …
|
||||||
|
parametricWalls: [
|
||||||
|
{
|
||||||
|
id: "pw-eg-raster",
|
||||||
|
name: "EG Längswände 3m-Raster",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{ type: "grid", spacing: 3.0, directions: "x" },
|
||||||
|
{ type: "conditional-thickness", condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Rendering ohne UI (Phase A)
|
||||||
|
|
||||||
|
In Phase A werden parametrische Wände **nicht** automatisch gerendert. Der Auflöser
|
||||||
|
ist eine reine Funktion; Aufrufer müssen ihn explizit einbinden. Mögliche Verwendung
|
||||||
|
in `generatePlan` oder `Viewport3D`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// generatePlan.ts (Ergänzung, Phase A)
|
||||||
|
const defaultWallType = project.wallTypes[0];
|
||||||
|
const extraWalls = (project.parametricWalls ?? []).flatMap((pw) =>
|
||||||
|
resolveParametricWall(pw, activeLevelId, {
|
||||||
|
floor: activeFloor,
|
||||||
|
boundaryGeometry: projectBoundary,
|
||||||
|
}, defaultWallType)
|
||||||
|
);
|
||||||
|
const allWalls = [...project.walls, ...extraWalls];
|
||||||
|
// … allWalls statt project.walls in der Rendering-Pipeline verwenden
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Keine UI in Phase A
|
||||||
|
|
||||||
|
Kein Command, kein Panel, kein Formular. `ParametricWall`-Einträge werden in Phase A
|
||||||
|
ausschließlich **programmatisch** (Unit-Tests, `sampleProject`, direkte JSON-Bearbeitung
|
||||||
|
des Projekts) erstellt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ausblick: Folge-Phasen
|
||||||
|
|
||||||
|
### Phase B — UI und Command-Schnittstelle
|
||||||
|
|
||||||
|
- Neues Command (z. B. `PWWALL`) oder Ressourcen-Manager-Tab „Parametrische Wände"
|
||||||
|
mit Formular-Editor je Regeltyp.
|
||||||
|
- „Einbrennen" (Flatten): `ParametricWall` → feste `Wall[]` in `Project.walls`
|
||||||
|
einfügen und den `ParametricWall`-Eintrag entfernen (unidirektional, Undo über Store).
|
||||||
|
- Auswahl parametrischer Wände im Plan (als Gruppe); Grip-Editing der Raster-Parameter
|
||||||
|
und Spannweiten.
|
||||||
|
|
||||||
|
### Phase C — Grid-Ressource und Schnittpunkt-Clipping
|
||||||
|
|
||||||
|
- `GridResource`: ein projektweites, benanntes Koordinatenraster (LV95-Offset,
|
||||||
|
Rasterweite, Drehung), auf das mehrere `GridRule`-Instanzen via `gridId` verweisen.
|
||||||
|
- Präzises Clipping: erzeugte Wände werden am Gebäudeumriss getrimmt — exakte
|
||||||
|
`lineIntersect`-Berechnung statt Bounding-Box-Approximation.
|
||||||
|
- Benutzerdefinierte Tags (`Wall.tags[]`) für komplexe `ConditionalThicknessRule`-
|
||||||
|
Bedingungen jenseits von „exterior/interior/bearing".
|
||||||
|
- IFC-Export: `ParametricWall`-Gruppen → `IfcWallStandardCase` mit parametrischen
|
||||||
|
Attributen und `IfcRelDefinesByType`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Vollständige Anwendungsbeispiele
|
||||||
|
|
||||||
|
### 7.1 Schweizer Wohnbau: 3-m-Raster EG + 1.OG
|
||||||
|
|
||||||
|
Zwei-Geschoss-Wohnhaus, typisches CH-Wohnbauraster. Die Längswände beider Geschosse
|
||||||
|
entstehen aus zwei `ParametricWall`-Einträgen mit identischen Regeln, unterschieden
|
||||||
|
nur durch `floorId` beim Auflösen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Top-down (Grundriss):
|
||||||
|
|
||||||
|
y=12 ──────────────────────────── (W5)
|
||||||
|
y=9 ──────────────────────────── (W4)
|
||||||
|
y=6 ──────────────────────────── (W3)
|
||||||
|
y=3 ──────────────────────────── (W2)
|
||||||
|
y=0 ──────────────────────────── (W1)
|
||||||
|
↑
|
||||||
|
x=0 x=12
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const rasterRegel: ParametricWall = {
|
||||||
|
id: "pw-laengswand-raster",
|
||||||
|
name: "Längswände 3m-Raster",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{ type: "grid", spacing: 3.0, directions: "x" },
|
||||||
|
// Außenwände (y=0 und y=12) erhalten den dicken Aufbau:
|
||||||
|
{ type: "conditional-thickness", condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36" },
|
||||||
|
// Außenwände: Achse liegt auf der Fassadenfläche:
|
||||||
|
{ type: "reference-line", referenceLine: "left", target: "exterior" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// EG auflösen:
|
||||||
|
const wallsEG = resolveParametricWall(rasterRegel, "floor-eg",
|
||||||
|
{ floor: egFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
|
||||||
|
project.wallTypes[0]);
|
||||||
|
|
||||||
|
// 1.OG auflösen (gleiche Regel, anderes Geschoss):
|
||||||
|
const wallsOG = resolveParametricWall(rasterRegel, "floor-og1",
|
||||||
|
{ floor: ogFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
|
||||||
|
project.wallTypes[0]);
|
||||||
|
// Änderung spacing: 3.5 → beide Geschosse sofort konsistent.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Vollraster mit Außen/Innen-Differenzierung
|
||||||
|
|
||||||
|
Gebäudeumriss als Rechteck; die Randwände erhalten automatisch den dicken
|
||||||
|
Außenwand-Typ, alle anderen den dünnen Innenwand-Typ:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const vollraster: ParametricWall = {
|
||||||
|
id: "pw-eg-vollraster",
|
||||||
|
name: "EG Vollraster mit Differenzierung",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{ type: "grid", spacing: 3.0, directions: "both" },
|
||||||
|
{ type: "conditional-thickness", condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36" },
|
||||||
|
{ type: "conditional-thickness", condition: "interior",
|
||||||
|
wallTypeId: "wt-innen-15" },
|
||||||
|
{ type: "reference-line", referenceLine: "left", target: "exterior" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
─────┬─────┬─────┬─────
|
||||||
|
│ │ │ │ │
|
||||||
|
─────┼─────┼─────┼─────
|
||||||
|
│ │ │ │ │
|
||||||
|
─────┴─────┴─────┴─────
|
||||||
|
|
||||||
|
Rand-Segmente: wt-aussen-36 (dicker Aufbau)
|
||||||
|
Innen-Segmente: wt-innen-15 (dünner Aufbau)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Modulbauweise: 6-m-Joch, Bürogebäude
|
||||||
|
|
||||||
|
Längliches Bürogebäude, 36 m × 12 m, 6-m-Joch. Querwände entstehen automatisch;
|
||||||
|
Entwurfsänderung (z. B. auf 7.2-m-Joch) erfordert eine einzige Zahl:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const joch: ParametricWall = {
|
||||||
|
id: "pw-buero-joch",
|
||||||
|
name: "Büro 6m-Joch",
|
||||||
|
defaultWallTypeId: "wt-beton-20",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
moduleSize: 6.0,
|
||||||
|
direction: "x", // Querwände senkrecht zur X-Achse
|
||||||
|
// referenceWallId: "W-sudfassade" → Spanne aus Referenzwand
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30
|
||||||
|
// (bei Bounding-Box minX=0, maxX=36, Enden selbst ausgespart)
|
||||||
|
|
||||||
|
// Änderung auf 7.2-m-Joch: moduleSize: 7.2
|
||||||
|
// → 4 Trennwände bei x ≈ 7.2, 14.4, 21.6, 28.8 — automatisch neu berechnet.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Architektur-Garantien
|
||||||
|
|
||||||
|
- **Modell bleibt einzige Wahrheit.** `ParametricWall`-Definitionen sind Daten in
|
||||||
|
`Project.parametricWalls`; `resolveParametricWall` ist eine **reine Funktion** ohne
|
||||||
|
Side-Effects. Keine globale Laufzeit-Geometrie-Schicht.
|
||||||
|
- **Erzeugte Wände sind gewöhnliche `Wall`-Objekte.** Alle nachgelagerten Systeme
|
||||||
|
(`generatePlan`, `Viewport3D`, `computeJoins`) arbeiten unverändert; sie müssen
|
||||||
|
nicht zwischen „parametrisch erzeugten" und „direkt gezeichneten" Wänden
|
||||||
|
unterscheiden.
|
||||||
|
- **Fehlertoleranz statt Absturz.** Unbekannte Regeltypen liefern `[]`; der TypeScript-
|
||||||
|
exhaustiveness-Guard fängt fehlende `case`-Zweige zur Compilezeit. Unbekannte
|
||||||
|
Bedingungsstrings in `ConditionalThicknessRule` geben `false` (kein Treffer) statt
|
||||||
|
zu werfen.
|
||||||
|
- **Keine vorzeitige Generalisierung.** Phase A liefert fünf Regel-Varianten und
|
||||||
|
einen Auflöser. UI, Command-Schnittstelle und Grid-Ressource folgen in Phase B/C.
|
||||||
|
- **Immutabilität.** Verfeinerungsregeln (`ConditionalThicknessRule`,
|
||||||
|
`ReferenceLineRule`) geben modifizierte **Kopien** zurück; `existingWalls` werden
|
||||||
|
nie mutiert — konsistent mit der `setProject`-Konvention (CONVENTIONS.md).
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Architektur-Pivot: Tauri + Rust-Backend (2026-07-01)
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
**Alte Welt:** Browser-CAD (React/Vite + WebGL/three.js)
|
||||||
|
**Neue Welt:** Desktop Tauri-App (React/Vite Frontend + Rust-Backend + **wgpu 3D-Rendering**)
|
||||||
|
|
||||||
|
**Grund:** Komplexe Möbel mit vielen Polygonen (100k+) + mehrere parallele rechenintensive Ops → WebGL/three.js wird zum Bottleneck. **wgpu** (low-level GPU-API auf Vulkan/Metal/DX12) + Rust-Compute skaliert native.
|
||||||
|
|
||||||
|
**Scope:** Desktop-only Release (Milestone 1). WASM-Fallback für Browser später wenn gebraucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Migration Stack
|
||||||
|
|
||||||
|
### Frontend (React/Vite — Komponenten + State, unverändert)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
App.tsx ← Shell-Komponente
|
||||||
|
compute/index.ts ← Compute-Boundary (neu)
|
||||||
|
model/types.ts ← Semantisches Modell
|
||||||
|
commands/ ← Befehlssystem
|
||||||
|
panels/ ← UI-Panels
|
||||||
|
plan/PlanView.tsx ← 2D-SVG-Rendering
|
||||||
|
viewport/Viewport3D.tsx ← three.js 3D-Display
|
||||||
|
ui/ ← Topbar, Dialogs, etc.
|
||||||
|
state/ ← Redux-Slices (project, selection, view, layout)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rolle:** User-Input-Handling, 2D/3D-Darstellung (Display-Layer), State-Management.
|
||||||
|
|
||||||
|
### Backend (Rust/Tauri — neu)
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/
|
||||||
|
src/
|
||||||
|
main.rs ← Tauri window + invoke-handler registration
|
||||||
|
geometry.rs ← compute_joins(), kernel2d(), etc.
|
||||||
|
parsers/
|
||||||
|
dwg.rs ← DXF/DWG-Geometrie-Parsing
|
||||||
|
dxf.rs
|
||||||
|
sia/
|
||||||
|
room_detection.rs ← detectRooms() (SIA-416)
|
||||||
|
...
|
||||||
|
Cargo.toml ← Dependencies (serde, tauri, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rolle:** Rechenintensive Ops, Geometrie-Kernel, Parsing, SIA-Raumerkennung.
|
||||||
|
|
||||||
|
### IPC: Tauri invoke (async, serde)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend ruft Rust auf
|
||||||
|
const result = await invoke<JoinInfo[]>('compute_joins', { walls, joints });
|
||||||
|
|
||||||
|
// Rust bearbeitet + serialisiert Ergebnis
|
||||||
|
#[tauri::command]
|
||||||
|
async fn compute_joins(input: JoinInput) -> Result<JoinOutput, String> {
|
||||||
|
geometry::compute_joins(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compute-Boundary (Key Design)
|
||||||
|
|
||||||
|
**Neue Datei:** `src/compute/index.ts` — einziger Eingang für rechenintensive Ops.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Beispiel-Schnittstellen
|
||||||
|
export async function computeJoins(walls: Wall[], joints: Array<[number, number]>): Promise<JoinInfo[]> { … }
|
||||||
|
export async function computeKernel2D(op: 'offset'|'trim', geom: Polyline, …): Promise<Polyline[]> { … }
|
||||||
|
export async function detectRooms(…): Promise<Room[]> { … }
|
||||||
|
export async function parseShapeFromDwg(…): Promise<DwgGeometry> { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinter der Boundary:**
|
||||||
|
1. Versuche Tauri invoke zu Rust (`#[tauri::command]`)
|
||||||
|
2. Fallback auf lokale TS-Impl wenn Rust nicht verfügbar (während Migration)
|
||||||
|
|
||||||
|
**Effekt:** Aufrufort im Code bleibt stabil; Caller sieht nicht, ob Op schon in Rust ist oder noch TS.
|
||||||
|
|
||||||
|
**Migrationsfluss:**
|
||||||
|
```
|
||||||
|
1. TS-Impl existiert (z.B. src/model/joins.ts)
|
||||||
|
2. Neue Op in Compute-Boundary mit Invoke+Fallback
|
||||||
|
3. Parallel: Rust-Impl in src-tauri/src/geometry.rs
|
||||||
|
4. Tests: Rust-Output == TS-Output (Parität)
|
||||||
|
5. TS-Impl bleibt (Fallback, wird nicht entfernt bis Rust stable)
|
||||||
|
6. Eventuell: TS-Impl löschen wenn Rust bewährt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev-Workflow (post-Tauri)
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Vite dev-server
|
||||||
|
npm run dev # localhost:5173
|
||||||
|
|
||||||
|
# Terminal 2: Tauri dev
|
||||||
|
npm run tauri:dev # öffnet Tauri-Fenster, zeigt auf localhost:5173
|
||||||
|
# Rust hot-reload + TS hot-reload gleichzeitig
|
||||||
|
```
|
||||||
|
|
||||||
|
**Voraussetzungen:**
|
||||||
|
- Node.js + npm (wie heute)
|
||||||
|
- Rust + Cargo (neu)
|
||||||
|
- Tauri CLI: `npm install -D @tauri-apps/cli`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single command
|
||||||
|
npm run tauri:build
|
||||||
|
|
||||||
|
# Erzeugt:
|
||||||
|
# - Windows: src-tauri/target/release/cad.exe
|
||||||
|
# - macOS: src-tauri/target/release/bundle/macos/cad.app
|
||||||
|
# - Linux: src-tauri/target/release/bundle/deb/cad_*.deb (oder Flatpak)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rust-Unit-Tests
|
||||||
|
cargo test # in src-tauri/
|
||||||
|
|
||||||
|
# TS-Tests (unverändert)
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Integration-Test: App starten + Aktion prüfen
|
||||||
|
npm run tauri:dev # manuell testen oder Puppeteer-Probe erweitern
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPU-Strategy (für später)
|
||||||
|
|
||||||
|
**Milestone 1 (jetzt):** CPU-Ops in Rust (kernel2d, joins, parsing, SIA).
|
||||||
|
|
||||||
|
**Milestone 2 (später):** GPU-Compute via wgpu
|
||||||
|
- Tauri + wgpu Renderer (optional, nicht erforderlich)
|
||||||
|
- ODER drei.js bleibt, Rust handelt CPU-Ops, three.js handelt Display
|
||||||
|
- GPU-Heavy-Ops (z.B. große Boolean-Operationen) können in wgpu laufen, aber MVP braucht das nicht
|
||||||
|
|
||||||
|
**Aktueller Plan:** three.js bleibt für 3D-Display (skaliert ausreichend für Möbel-Geometrie mit Instancing + LOD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy: Ops nach Priorisierung
|
||||||
|
|
||||||
|
**Phase 1 (aktuell — Tauri-Shell + Proof-of-Concept):**
|
||||||
|
- [ ] `computeJoins` (Wand-Eckverbindungen) → Rust
|
||||||
|
- Gründe: klein, häufig, zeigt invoke-Flow
|
||||||
|
|
||||||
|
**Phase 2 (nächst):**
|
||||||
|
- [ ] `kernel2d` (Offset/Trim/Extend/Fillet) → Rust
|
||||||
|
- Gründe: Rechenlast ⭐⭐, Frequenz hoch
|
||||||
|
- [ ] DXF/DWG-Parser → Rust (Geometrie-Extraktion)
|
||||||
|
- Gründe: Rechenlast ⭐⭐, Frequenz mittel (Import-Dialog)
|
||||||
|
|
||||||
|
**Phase 3 (später):**
|
||||||
|
- [ ] `detectRooms` (SIA-416 Raumerkennung) → Rust
|
||||||
|
- Gründe: Rechenlast ⭐, async-freundlich
|
||||||
|
- [ ] `booleanOps` (Union/Differenz/Schnitt) → Rust
|
||||||
|
- Gründe: Rechenlast ⭐⭐, Frequenz gering (ad-hoc)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folgen für bestehenden Code
|
||||||
|
|
||||||
|
### Was ändert sich NICHT
|
||||||
|
|
||||||
|
- `src/model/types.ts` — semantisches Modell bleibt in TS (Frontend kennt es)
|
||||||
|
- `src/state/` — Redux-Store unverändert
|
||||||
|
- `src/ui/` — Komponenten unverändert
|
||||||
|
- `src/plan/PlanView.tsx` — SVG-Rendering unverändert
|
||||||
|
- `src/viewport/Viewport3D.tsx` — three.js-Rendering unverändert
|
||||||
|
- `src/commands/` — Befehlssystem unverändert
|
||||||
|
|
||||||
|
### Was ändert sich
|
||||||
|
|
||||||
|
- **Neue `src/compute/index.ts`** — alle rechenintensiven Ops laufen durch hier
|
||||||
|
- **Neue `src-tauri/`** — Rust-Backend
|
||||||
|
- **Vite-Config:** Tauri plugin hinzufügen
|
||||||
|
- **Package.json:** tauri scripts hinzufügen
|
||||||
|
- **Build-Prozess:** `npm run tauri:build` statt `npm run build`
|
||||||
|
|
||||||
|
### Was wird migriert (schrittweise)
|
||||||
|
|
||||||
|
- `src/model/joins.ts` → `src-tauri/src/geometry.rs` (Phase 1)
|
||||||
|
- `src/geometry/kernel2d.ts` → `src-tauri/src/geometry.rs` (Phase 2)
|
||||||
|
- `src/io/{dxfParser, dwgParser}.ts` → `src-tauri/src/parsers/` (Phase 2)
|
||||||
|
- `src/geometry/{roomArea, roomBoundary}.ts` → `src-tauri/src/sia/room_detection.rs` (Phase 3)
|
||||||
|
- `src/editors/booleanOps.ts` → `src-tauri/src/geometry.rs` (Phase 3)
|
||||||
|
|
||||||
|
**Wichtig:** TS-Versionen bleiben als Fallback (nicht gelöscht).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Distribution (später)
|
||||||
|
|
||||||
|
### Desktop Binaries (post-Tauri)
|
||||||
|
|
||||||
|
- **Windows:** `.exe` (standalone executable)
|
||||||
|
- **macOS:** `.app` bundle (code-signed)
|
||||||
|
- **Linux:** `.deb` package ODER **Flatpak** (preferred)
|
||||||
|
- Flatpak = moderne WebKitGTK6 immer dabei, unabhängig von Distro-Alter
|
||||||
|
|
||||||
|
### Browser (wenn gebraucht)
|
||||||
|
|
||||||
|
- **WASM-Fallback** für `src/compute/` Ops (Rust → WASM via wasm-bindgen)
|
||||||
|
- Later-phase feature, nicht Milestone 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische Details
|
||||||
|
|
||||||
|
### Serialisierung (TS ↔ Rust)
|
||||||
|
|
||||||
|
**serde + serde_json** für Geometrie-Typen:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Rust
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Vec2 { pub x: f64, pub y: f64 }
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Wall {
|
||||||
|
pub id: String,
|
||||||
|
pub start: Vec2,
|
||||||
|
pub end: Vec2,
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TS (type-safe invoke)
|
||||||
|
interface Vec2 { x: number; y: number }
|
||||||
|
interface Wall { id: string; start: Vec2; end: Vec2; /* … */ }
|
||||||
|
|
||||||
|
await invoke<JoinInfo[]>('compute_joins', { walls: Wall[] })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tauri Security (default)
|
||||||
|
|
||||||
|
- Invoke-Handler sind Rust-side validiert
|
||||||
|
- Whitelist-Makro (`#[tauri::command]`) registered nur explizit erlaubte Functions
|
||||||
|
- CORS/CSP Policy default secure
|
||||||
|
- Keine arbitrary-Script-Execution (native app)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abhängigkeiten (neu post-Tauri)
|
||||||
|
|
||||||
|
### Frontend (npm)
|
||||||
|
- Bestehende: react, vite, three.js, redux, etc.
|
||||||
|
- Neu: `@tauri-apps/api` (JS-SDK für invoke)
|
||||||
|
- Optional später: `@tauri-apps/cli` dev-dependency (bereits in package.json)
|
||||||
|
|
||||||
|
### Backend (Cargo)
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["webkit2gtk-6.0"] } # GTK4
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
# später: wgpu, delaunator, opencascade-sys, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### System
|
||||||
|
- Rust 1.70+
|
||||||
|
- GTK4 dev libraries (Linux only, auto-handled by Tauri)
|
||||||
|
- Xcode Command Line Tools (macOS, auto-checked)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps (Koordination)
|
||||||
|
|
||||||
|
**Aufgabe für nächste Phase:**
|
||||||
|
→ Siehe `docs/design/tauri-migration-plan.md` (Schritt-für-Schritt, vier parallele Agents)
|
||||||
|
|
||||||
|
**HANDOVER.md:** wird aktualisiert nach Tauri-Shell stabil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Läuft die App noch im Browser?**
|
||||||
|
A: Nein (Milestone 1). Desktop-only. WASM-Fallback für Browser später wenn gebraucht.
|
||||||
|
|
||||||
|
**Q: Was passiert mit dem existing TS-Code?**
|
||||||
|
A: Bleibt unverändert (außer neue Compute-Boundary). TS-Implementierungen = Fallback bis Rust stabil.
|
||||||
|
|
||||||
|
**Q: Muss ich Rust können um das Projekt zu verstehen?**
|
||||||
|
A: Nein. Frontend bleibt React/TS. Rust ist "blackbox" hinter invoke. Aber bei Rust-Bugs muss man rein.
|
||||||
|
|
||||||
|
**Q: Wann ist Tauri-Shell fertig?**
|
||||||
|
A: Nach den vier Agents (Schritt 1–4 in tauri-migration-plan.md), ~1–2 Wochen.
|
||||||
|
|
||||||
|
**Q: Kann ich lokal testen?**
|
||||||
|
A: Ja, `npm run tauri:dev` öffnet lokale App. Alles wie heute, nur Rust hinten dran.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
# WebGL2 GPU-Accelerated 2D Plan Renderer — Architecture
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
A WebGL2 canvas renderer for PlanView's heavy geometry (polygons, lines, hatches) with CPU fallback. Geometry tessellates once per plan and caches; pan/zoom only updates a transform-matrix uniform. Screen-space stroke width via vertex shader normal expansion. Thin SVG overlay handles text, grips, snap markers, tool preview.
|
||||||
|
|
||||||
|
**No npm dependencies** — raw WebGL2 + TypeScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State (SVG Bottleneck)
|
||||||
|
|
||||||
|
**PlanView.tsx** renders `Primitive[]` (polygon/line/arc/text) → SVG DOM:
|
||||||
|
- ~2500 LOC: pan/zoom via viewBox, toScreen() scaling (1 meter = 90 viewBox units)
|
||||||
|
- `Primitive` types (generatePlan.ts:133):
|
||||||
|
- **polygon**: `pts: Vec2[]`, fill/stroke/strokeWidthMm, hatch (solid/insulation/diagonal/crosshatch)
|
||||||
|
- **line**: a/b endpoints, className, weightMm, dash[], optional color
|
||||||
|
- **arc**: center, from/to points, r, className, weightMm, dash[]
|
||||||
|
- **text**: anchor, RichTextDoc, roomStamp metadata
|
||||||
|
- Bottleneck: **pan/zoom re-renders entire SVG DOM** → Cairo rasterizes geometry at 144 Hz
|
||||||
|
|
||||||
|
**Key constants:**
|
||||||
|
- `PX_PER_M = 90` (viewBox units per meter; Modell-Y up → SVG-Y down via negation)
|
||||||
|
- `PAD = 60` (margin in viewBox units)
|
||||||
|
- `mmToPx(mm) = (mm / 25.4) * dpi()` (stroke width: constant screen-px via non-scaling-stroke)
|
||||||
|
- `ZOOM_MAX/MIN = 50/0.2` (pan/zoom bounds)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture: PlanRenderer (WebGL2 + Fallback SVG)
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/plan/
|
||||||
|
├── PlanRenderer.ts (Main GPU/CPU dispatcher)
|
||||||
|
├── glPlan/
|
||||||
|
│ ├── glPlanCompile.ts (Tessellation & buffer upload)
|
||||||
|
│ ├── glPlanShaders.ts (Vertex/fragment sources + compilation)
|
||||||
|
│ ├── glPlanRender.ts (Draw loop: matrix uniform, state mgmt)
|
||||||
|
│ └── glPlanTypes.ts (TypeScript interfaces for GPU data)
|
||||||
|
└── PlanView.tsx (React wrapper, unchanged API)
|
||||||
|
```
|
||||||
|
|
||||||
|
### High-Level Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
PlanView.tsx
|
||||||
|
↓ [receives plan: Plan]
|
||||||
|
↓
|
||||||
|
PlanRenderer (new abstraction)
|
||||||
|
↓
|
||||||
|
├─→ GPU path [if WebGL2 available && flag=true]
|
||||||
|
│ ├─ glPlanCompile() → upload tessellated geometry to VRAM
|
||||||
|
│ ├─ glPlanRender() → draw with pan/zoom matrix uniform
|
||||||
|
│ └─ [fast pan/zoom via matrix only]
|
||||||
|
│
|
||||||
|
└─→ Fallback: SVG [if WebGL fails || flag=false]
|
||||||
|
└─ existing PlanView render path (toScreen + DOM)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPU Path: Tessellation & Shaders
|
||||||
|
|
||||||
|
### 1. Tessellation Strategy
|
||||||
|
|
||||||
|
#### **Polygon** → Fans + Ear Clipping
|
||||||
|
- **Input**: Primitive.polygon = { pts: Vec2[], fill, stroke, strokeWidthMm, hatch }
|
||||||
|
- **Output**: Indexed triangle mesh
|
||||||
|
- **Algorithm**: Earcut2D (existing JS library logic, inlined to avoid npm)
|
||||||
|
- Convert polygon pts to 2D float32 array in **world space** (meters)
|
||||||
|
- Earcut → triangle indices
|
||||||
|
- Store: `{ vertices: Float32Array, indices: Uint32Array, color: vec4, hasHatch: bool }`
|
||||||
|
- **Hatch rendering**: Bake hatch as texture or re-implement in fragment shader (MVP: solid fill only; hatch deferred)
|
||||||
|
|
||||||
|
#### **Line** → Quad Expansion (Screen-Space Width)
|
||||||
|
- **Input**: Primitive.line = { a, b, weightMm, cls, dash?, color }
|
||||||
|
- **Output**: Degenerate quad (2 triangles) with screen-space normal offset
|
||||||
|
- **Strategy**:
|
||||||
|
1. Vertex shader receives `{ pos: vec2, side: float }` (side = ±1 for left/right edge)
|
||||||
|
2. Transform pos to clip space via matrix uniform
|
||||||
|
3. Compute screen-space perpendicular via `dFdx/dFdy` or pre-compute normal in CPU
|
||||||
|
4. Expand by `(weightMm / 25.4) * dpi * (screenPixelsPerClipUnit)` in clip space
|
||||||
|
5. Fragment shader: solid color (no dash MVP; dashing deferred or CPU pre-tessellation)
|
||||||
|
|
||||||
|
#### **Arc** → Line Segments (Polyline → Quads)
|
||||||
|
- **Input**: Primitive.arc = { center, from, to, r, weightMm, cls, dash }
|
||||||
|
- **Output**: Tessellate arc to ~30 line segments (adaptive based on radius/zoom), expand each as quad
|
||||||
|
- Fallback: SVG arc for MVP
|
||||||
|
|
||||||
|
#### **Text, Grips, Snap-Markers, Tool-Preview**
|
||||||
|
- **Stays in SVG overlay** (thin, non-bottleneck)
|
||||||
|
- Render above WebGL canvas at z-order 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Shader Sources (GLSL 3.00 ES)
|
||||||
|
|
||||||
|
#### **Vertex Shader: Solid Fill (polygon)**
|
||||||
|
```glsl
|
||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform mat4 viewProjection; // pan/zoom as 2×3 affine (expand to mat4)
|
||||||
|
|
||||||
|
layout(location=0) in vec2 position; // world-space (meters)
|
||||||
|
layout(location=1) in vec4 color; // fill color
|
||||||
|
|
||||||
|
out VS_OUT {
|
||||||
|
flat vec4 vertexColor;
|
||||||
|
} vs_out;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
|
||||||
|
gl_Position = clipPos;
|
||||||
|
vs_out.vertexColor = color;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Vertex Shader: Screen-Space Stroked Line**
|
||||||
|
```glsl
|
||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform mat4 viewProjection; // world → clip space
|
||||||
|
uniform vec2 screenSize; // canvas (width, height) in pixels
|
||||||
|
uniform float strokeWidthMm; // millimeters
|
||||||
|
uniform float dpi; // 96 * devicePixelRatio
|
||||||
|
|
||||||
|
layout(location=0) in vec2 position; // world-space endpoint
|
||||||
|
layout(location=1) in float sideFlag; // ±1.0 (left/right edge)
|
||||||
|
layout(location=2) in vec4 lineColor; // stroke color
|
||||||
|
|
||||||
|
out VS_OUT {
|
||||||
|
flat vec4 vertexColor;
|
||||||
|
} vs_out;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Convert stroke width (mm) → screen pixels
|
||||||
|
float strokePx = (strokeWidthMm / 25.4) * dpi;
|
||||||
|
// Convert screen pixels → normalized device coords (NDC)
|
||||||
|
// NDC ∈ [-1,1]²; screen (0,screenSize) → NDC [-1,1]
|
||||||
|
float strokeNdc = (strokePx / screenSize.x) * 2.0;
|
||||||
|
|
||||||
|
// Expand in clip space (simple; assumes aspect ≈ 1)
|
||||||
|
vec4 expanded = clipPos + vec4(sideFlag * strokeNdc, 0.0, 0.0, 0.0);
|
||||||
|
|
||||||
|
gl_Position = expanded;
|
||||||
|
vs_out.vertexColor = lineColor;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Fragment Shader (both)**
|
||||||
|
```glsl
|
||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in VS_OUT {
|
||||||
|
flat vec4 vertexColor;
|
||||||
|
} fs_in;
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
fragColor = fs_in.vertexColor;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. GPU Data Structures (TypeScript)
|
||||||
|
|
||||||
|
**glPlanTypes.ts:**
|
||||||
|
```typescript
|
||||||
|
export interface GLGeometryBatch {
|
||||||
|
/** Vertex buffer: interleaved (x, y, [z if 3D], ...) in world space. */
|
||||||
|
vertexBuffer: WebGLBuffer;
|
||||||
|
vertexCount: number;
|
||||||
|
|
||||||
|
/** Index buffer (triangles for fill, degenerate quads for strokes). */
|
||||||
|
indexBuffer: WebGLBuffer;
|
||||||
|
indexCount: number;
|
||||||
|
|
||||||
|
/** Vertex Array Object (VAO) binds VBO + IBO. */
|
||||||
|
vao: WebGLVertexArrayObject;
|
||||||
|
|
||||||
|
/** Per-batch metadata. */
|
||||||
|
batches: Array<{
|
||||||
|
kind: "polygon" | "line" | "arc";
|
||||||
|
indexStart: number;
|
||||||
|
indexCount: number;
|
||||||
|
color: [r: number, g: number, b: number, a: number]; // RGBA [0,1]
|
||||||
|
hasHatch: boolean;
|
||||||
|
hatchPattern?: "solid" | "insulation" | "diagonal" | "crosshatch";
|
||||||
|
strokeWidthMm?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GLPlanRenderState {
|
||||||
|
// Pan/zoom transform: world (meters) → clip space
|
||||||
|
viewMatrix: Matrix3 | Matrix4; // 2×3 affine
|
||||||
|
projMatrix: Matrix4; // orthographic
|
||||||
|
|
||||||
|
// Viewport size & DPI for screen-space stroke width
|
||||||
|
screenWidth: number;
|
||||||
|
screenHeight: number;
|
||||||
|
dpi: number;
|
||||||
|
|
||||||
|
// Compiled shaders
|
||||||
|
solidFillProgram: WebGLProgram;
|
||||||
|
strokeProgram: WebGLProgram;
|
||||||
|
|
||||||
|
// Geometry cache (tessellated once per plan)
|
||||||
|
geometryBatch: GLGeometryBatch | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MVP API: PlanRenderer Class
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class PlanRenderer {
|
||||||
|
/**
|
||||||
|
* Create renderer with WebGL2 context + fallback config.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
options?: {
|
||||||
|
enableGpu?: boolean; // default: true
|
||||||
|
enableGpuFallback?: boolean; // SVG fallback if GL fails
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile and cache geometry from primitives.
|
||||||
|
* Call once per plan change.
|
||||||
|
*/
|
||||||
|
compilePlan(plan: Plan): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set pan/zoom transform matrix.
|
||||||
|
* Call on every view change (pan, zoom, fit).
|
||||||
|
*/
|
||||||
|
setViewMatrix(viewBox: { x, y, w, h }, canvasSize: { w, h }): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one frame: clear, draw batches, composite.
|
||||||
|
* Called from requestAnimationFrame loop.
|
||||||
|
*/
|
||||||
|
render(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release WebGL resources.
|
||||||
|
*/
|
||||||
|
dispose(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query GPU availability / fallback state.
|
||||||
|
*/
|
||||||
|
isGpuReady(): boolean;
|
||||||
|
isFallbackActive(): boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in PlanView
|
||||||
|
|
||||||
|
**Before** (SVG only):
|
||||||
|
```tsx
|
||||||
|
function PlanView({ plan, ... }) {
|
||||||
|
return (
|
||||||
|
<svg ref={svgRef}>
|
||||||
|
<defs>{hatches}</defs>
|
||||||
|
{plan.primitives.map((p, i) => <PrimitiveShape ... />)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (GPU + SVG fallback):
|
||||||
|
```tsx
|
||||||
|
function PlanView({ plan, ... }) {
|
||||||
|
const rendererRef = useRef<PlanRenderer | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
rendererRef.current = new PlanRenderer(canvas, { enableGpu: true });
|
||||||
|
rendererRef.current.compilePlan(plan);
|
||||||
|
}, [plan]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rendererRef.current?.setViewMatrix(view, { w: canvasWidth, h: canvasHeight });
|
||||||
|
}, [view, canvasWidth, canvasHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = () => {
|
||||||
|
rendererRef.current?.render();
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
{/* GPU canvas (or SVG fallback if GL unavailable) */}
|
||||||
|
<canvas ref={canvasRef} style={{ position: "absolute" }} />
|
||||||
|
{/* Thin SVG overlay: text, grips, snap-markers, tool preview */}
|
||||||
|
<svg ref={svgRef} style={{ position: "absolute", zIndex: 1 }}>
|
||||||
|
{/* text, grips, snaps only; geometry stays in WebGL */}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow: From Primitives → GPU
|
||||||
|
|
||||||
|
### 1. **Compile Phase** (glPlanCompile.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function compilePlan(gl: WebGL2RenderingContext, plan: Plan): GLGeometryBatch {
|
||||||
|
const batches: BatchInfo[] = [];
|
||||||
|
const vertices: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
let indexOffset = 0;
|
||||||
|
|
||||||
|
for (const prim of plan.primitives) {
|
||||||
|
if (prim.kind === "polygon") {
|
||||||
|
const { verts, inds } = tessellatePolygon(prim.pts);
|
||||||
|
const color = parseColor(prim.fill);
|
||||||
|
batches.push({
|
||||||
|
kind: "polygon",
|
||||||
|
indexStart: indexOffset,
|
||||||
|
indexCount: inds.length,
|
||||||
|
color,
|
||||||
|
hasHatch: prim.hatch.pattern !== "none",
|
||||||
|
hatchPattern: prim.hatch.pattern,
|
||||||
|
});
|
||||||
|
vertices.push(...verts);
|
||||||
|
indices.push(...inds.map((i) => i + indexOffset));
|
||||||
|
indexOffset += verts.length / 2;
|
||||||
|
} else if (prim.kind === "line") {
|
||||||
|
const { verts, inds } = tessellateLineQuad(prim.a, prim.b);
|
||||||
|
const color = parseColor(prim.color || "black");
|
||||||
|
batches.push({
|
||||||
|
kind: "line",
|
||||||
|
indexStart: indexOffset,
|
||||||
|
indexCount: inds.length,
|
||||||
|
color,
|
||||||
|
strokeWidthMm: prim.weightMm,
|
||||||
|
});
|
||||||
|
vertices.push(...verts);
|
||||||
|
indices.push(...inds.map((i) => i + indexOffset));
|
||||||
|
indexOffset += verts.length / 2;
|
||||||
|
}
|
||||||
|
// arc → polyline → quads (deferred for MVP)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vbo = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const ibo = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const vao = gl.createVertexArray()!;
|
||||||
|
gl.bindVertexArray(vao);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||||
|
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0); // position
|
||||||
|
gl.enableVertexAttribArray(0);
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
|
||||||
|
|
||||||
|
return { vertexBuffer: vbo, indexBuffer: ibo, vao, batches, vertexCount: vertices.length, indexCount: indices.length };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Render Phase** (glPlanRender.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function renderPlan(
|
||||||
|
gl: WebGL2RenderingContext,
|
||||||
|
state: GLPlanRenderState,
|
||||||
|
batch: GLGeometryBatch
|
||||||
|
): void {
|
||||||
|
gl.clearColor(1, 1, 1, 1); // white background
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.useProgram(state.solidFillProgram);
|
||||||
|
const mvpLoc = gl.getUniformLocation(state.solidFillProgram, "viewProjection");
|
||||||
|
const mvp = mat4.multiply(state.projMatrix, state.viewMatrix);
|
||||||
|
gl.uniformMatrix4fv(mvpLoc, false, mvp);
|
||||||
|
|
||||||
|
gl.bindVertexArray(batch.vao);
|
||||||
|
|
||||||
|
for (const b of batch.batches) {
|
||||||
|
const colorLoc = gl.getUniformLocation(state.solidFillProgram, "vertexColor");
|
||||||
|
gl.uniform4f(colorLoc, b.color[0], b.color[1], b.color[2], b.color[3]);
|
||||||
|
|
||||||
|
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.indexStart * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tessellation Details
|
||||||
|
|
||||||
|
### Earcut (Polygon Triangulation)
|
||||||
|
|
||||||
|
**Inlined earcut logic (no npm):**
|
||||||
|
```typescript
|
||||||
|
function tessellatePolygon(pts: Vec2[]): { verts: number[]; inds: number[] } {
|
||||||
|
// Convert Vec2[] → flat float array
|
||||||
|
const coords = pts.flatMap((p) => [p.x, p.y]);
|
||||||
|
|
||||||
|
// Earcut2D: robust polygon triangulation
|
||||||
|
// → Returns index array (triplets = triangles)
|
||||||
|
const triangles = earcut(coords);
|
||||||
|
|
||||||
|
// Vertex buffer: just positions (x, y) in world space (meters)
|
||||||
|
const verts = coords;
|
||||||
|
|
||||||
|
return { verts, inds: triangles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified earcut (full version ~200 LOC; reference libtess2 or earcut.js)
|
||||||
|
function earcut(data: number[], hole?: number[], dim?: number): number[] {
|
||||||
|
// ... iterative ear clipping, complexity O(n²) worst-case
|
||||||
|
// Returns Uint32Array of triangle indices
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Line Quad Expansion
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function tessellateLineQuad(
|
||||||
|
a: Vec2, b: Vec2,
|
||||||
|
widthMm: number = 0.5
|
||||||
|
): { verts: number[]; inds: number[] } {
|
||||||
|
// World-space endpoints; width (mm) will be expanded in vertex shader
|
||||||
|
|
||||||
|
// Create a degenerate quad: 2 triangles
|
||||||
|
// Vertices: [a_left, a_right, b_left, b_right]
|
||||||
|
// (normal expansion happens in VS)
|
||||||
|
|
||||||
|
const verts = [
|
||||||
|
a.x, a.y, 0.0, // vertex 0: a, left flag
|
||||||
|
a.x, a.y, 1.0, // vertex 1: a, right flag
|
||||||
|
b.x, b.y, 0.0, // vertex 2: b, left flag
|
||||||
|
b.x, b.y, 1.0, // vertex 3: b, right flag
|
||||||
|
];
|
||||||
|
|
||||||
|
// Two triangles: (0, 1, 2) and (1, 3, 2)
|
||||||
|
const inds = [0, 1, 2, 1, 3, 2];
|
||||||
|
|
||||||
|
return { verts, inds };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pan/Zoom Matrix Transform
|
||||||
|
|
||||||
|
### View Box → Clip Space
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildViewMatrix(
|
||||||
|
viewBox: { x, y, w, h },
|
||||||
|
canvasSize: { w, h }
|
||||||
|
): Matrix4 {
|
||||||
|
// 1. World space (meters, origin at model 0,0) → viewBox units (PX_PER_M=90)
|
||||||
|
const scale = PX_PER_M; // 1 meter → 90 viewBox units
|
||||||
|
|
||||||
|
// 2. ViewBox viewport: x,y,w,h in viewBox units → NDC [-1,+1]²
|
||||||
|
// Orthographic projection (no perspective).
|
||||||
|
const ortho = mat4.ortho(
|
||||||
|
viewBox.x,
|
||||||
|
viewBox.x + viewBox.w,
|
||||||
|
viewBox.y,
|
||||||
|
viewBox.y + viewBox.h,
|
||||||
|
-1, 1
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Scale from viewBox units → world (invert PX_PER_M)
|
||||||
|
const scaleMatrix = mat4.scale(mat4.identity(), [1/scale, 1/scale, 1]);
|
||||||
|
|
||||||
|
return mat4.multiply(ortho, scaleMatrix);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Whenever PlanView calls `setView(viewBox)` or `onWheel()` → call `setViewMatrix()` → GPU re-renders with new matrix uniform (no tessellation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallback Strategy: SVG Renderer Flag
|
||||||
|
|
||||||
|
**Global flag** in PlanView or app state:
|
||||||
|
```typescript
|
||||||
|
const [useGpuRenderer, setUseGpuRenderer] = useState(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Render path branching:**
|
||||||
|
```typescript
|
||||||
|
return useGpuRenderer && rendererRef.current?.isGpuReady()
|
||||||
|
? <canvas ref={canvasRef} />
|
||||||
|
: <svg ref={svgRef}>{/* existing SVG rendering */}</svg>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**When GL fails** (e.g., no WebGL2 support, Out-Of-Memory):
|
||||||
|
1. Renderer catches error in `compilePlan()`
|
||||||
|
2. Sets internal `fallbackActive = true`
|
||||||
|
3. Returns gracefully (app renders SVG path instead)
|
||||||
|
4. User sees same plan, slower but functional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order (MVP → Iteration)
|
||||||
|
|
||||||
|
### Phase 1: Core (Week 1)
|
||||||
|
1. **glPlanTypes.ts** — TypeScript interfaces for GPU state
|
||||||
|
2. **glPlanShaders.ts** — Compile vertex/fragment shaders, handle GL errors
|
||||||
|
3. **glPlanCompile.ts** — Tessellation (earcut inlined), buffer upload
|
||||||
|
4. **glPlanRender.ts** — Draw loop, matrix uniform, clear/present
|
||||||
|
5. **PlanRenderer.ts** — Main class, dispatcher (GPU vs SVG fallback)
|
||||||
|
6. **PlanView.tsx** — Wire renderer, canvas overlay, canvas lifecycle
|
||||||
|
|
||||||
|
### Phase 2: Hatches & Lines (Week 2)
|
||||||
|
- Improve line tessellation: proper screen-space width (dFdx/dFdy or pre-computed normals)
|
||||||
|
- Hatch patterns: texture-based or procedural fragment shader (diagonal/insulation)
|
||||||
|
- Arc tessellation: polyline → quads
|
||||||
|
|
||||||
|
### Phase 3: Polish (Week 3)
|
||||||
|
- Stroke dashing via geometry or fragment shader
|
||||||
|
- Greyed opacity blending
|
||||||
|
- Hit testing integration (point-in-triangle for GPU)
|
||||||
|
- Performance profiling, batch merging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Targets
|
||||||
|
|
||||||
|
| Operation | SVG (Current) | GPU (Target) | Notes |
|
||||||
|
|-----------|---------------|--------------|-------|
|
||||||
|
| **Tessellation** | — | 10–50 ms | Once per plan |
|
||||||
|
| **Pan/Zoom 60 Hz** | 16 ms (re-render SVG) | <1 ms (matrix uniform) | Matrix upload negligible |
|
||||||
|
| **Pan/Zoom 144 Hz** | 7 ms (bottleneck) | <0.5 ms | 28× speedup expected |
|
||||||
|
| **Geometry: 1000 polygons** | 50–100 ms SVG render | 1–5 ms GPU draw | CPU tessellation pipelined |
|
||||||
|
|
||||||
|
User: AMD RX 7800 XT → easily capable of 4K+ geometry at 144 Hz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Deferred Items (Post-MVP)
|
||||||
|
|
||||||
|
- **Hatches**: Solid fill only MVP; insulation/diagonal/crosshatch in Phase 2 via texture or procedural shader
|
||||||
|
- **Dashing**: Not in MVP (complex with screen-space strokes); either CPU pre-tessellation or fragment shader alpha-discard
|
||||||
|
- **Arcs**: Fallback to SVG for MVP; GPU polyline expansion in Phase 2
|
||||||
|
- **Text, Grips, Snaps**: Stay in SVG overlay indefinitely (no GPU benefit; text rendering nontrivial)
|
||||||
|
- **Hit Testing**: Keep in CPU/SVG for MVP; GPU pick-buffer deferred
|
||||||
|
- **Color/Opacity Blending**: Basic for MVP; advanced (multiply, screen, dodge) deferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Earcut.js**: https://github.com/mapbox/earcut — polygon triangulation (logic to inline)
|
||||||
|
- **three.js line expansion**: https://github.com/mrdoob/three.js/blob/master/src/renderers/webgl/WebGLGeometries.js
|
||||||
|
- **OpenGL Perspective Division**: https://en.wikibooks.org/wiki/OpenGL_Programming/Modern_OpenGL_Tutorial_Polygon_offset
|
||||||
|
- **Screen-Space Stroke Width**: https://forum.libcinder.org/topic/smooth-line-rendering-using-geometry-shaders
|
||||||
|
- PlanView source: `/home/karim/cad/src/plan/PlanView.tsx` (2500 LOC)
|
||||||
|
- Primitive types: `/home/karim/cad/src/plan/generatePlan.ts:133`
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Briefing — Nativer wgpu-3D-Renderer (M0 Design + M1 Spike)
|
||||||
|
|
||||||
|
> Schwester-Dokument zu `wgpu-2d-renderer-briefing.md`. Beschreibt den Weg vom
|
||||||
|
> jetzigen three.js-3D-View (WebGL im WebKitGTK-Webview) zu einer nativen
|
||||||
|
> wgpu-Engine (Rust). Dieses Dokument ist der ANFANG: M0 (Bestandsaufnahme +
|
||||||
|
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
|
||||||
|
|
||||||
|
## Commit-/Spuren-Regel
|
||||||
|
Wie im ganzen Repo: keine Hinweise auf KI-Werkzeuge — nicht im Code, in Kommentaren
|
||||||
|
oder in der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne
|
||||||
|
Ansage der Hauptinstanz.
|
||||||
|
|
||||||
|
## Warum
|
||||||
|
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
|
||||||
|
2D-Plan bremst dieser Compositor unter Linux (siehe `wgpu-2d-renderer-briefing.md`
|
||||||
|
und Memo `webkitgtk-bottleneck`). Die schwere 3D-Grafik soll daher **nativ mit wgpu**
|
||||||
|
gerendert werden (Rust), die Webview macht nur noch UI-Chrome. Das umgeht den
|
||||||
|
Webview-Compositor komplett und teilt sich die Toolchain mit dem 2D-Renderer
|
||||||
|
(`src-tauri/render2d/`, gleiche Feature-Stufung, gleiche Test-Muster).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bestandsaufnahme — was der three.js-View rendert
|
||||||
|
|
||||||
|
Referenz: `src/viewport/Viewport3D.tsx` (analysiert), plus die Geometrie-Grundlage
|
||||||
|
in `src/model/geometry.ts`, `src/model/wall.ts`, `src/model/joins.ts`,
|
||||||
|
`src/geometry/opening.ts`, `src/geometry/stair.ts`. Zeilenangaben beziehen sich auf
|
||||||
|
den Stand der Analyse.
|
||||||
|
|
||||||
|
### Koordinaten-Konvention (verbindlich)
|
||||||
|
Das Modell ist 2D in Metern (`x`, `y`) plus Hoehe `z`. Die 3D-Welt ist **Y-up**:
|
||||||
|
|
||||||
|
```
|
||||||
|
world.x = model.x
|
||||||
|
world.y = Hoehe (z)
|
||||||
|
world.z = model.y
|
||||||
|
```
|
||||||
|
|
||||||
|
D.h. **der Grundriss liegt in der XZ-Ebene, die Extrusion laeuft entlang +Y.**
|
||||||
|
Belegt u.a. in `Viewport3D.tsx`:
|
||||||
|
- Kommentar (~Z. 473): „Modell (x,y,z) → Three (x, z, y) (Z = Hoehe nach oben)".
|
||||||
|
- Kontext-Mesh (~Z. 1679–1684): `verts[i]=pos.x; verts[i+1]=pos.z (Hoehe); verts[i+2]=pos.y`.
|
||||||
|
- Wand-Griffe (~Z. 764): `new THREE.Vector3(wall.start.x, zBottom, wall.start.y)`.
|
||||||
|
- Workplane-Raycast (~Z. 627): `{ x: hit.x, y: hit.z }` (Three → Modell).
|
||||||
|
|
||||||
|
Diese Konvention ist in `render3d` 1:1 uebernommen (`types.rs`, `mesh.rs`).
|
||||||
|
|
||||||
|
### Waende (der Kern)
|
||||||
|
- Funktion `addWallMeshes()` / `addLayerPrism()` (~Z. 1939–2039, 2237–2271).
|
||||||
|
- **Mesh-Weg:** `THREE.ExtrudeGeometry` (~Z. 2248) ueber die 2D-Bandform, die
|
||||||
|
`clippedBand(p1, p2, offA, offB, startCut, endCut)` liefert (~Z. 2237; Funktion in
|
||||||
|
`src/model/geometry.ts:87`). Extrudiert wird um `depth = zTop - zBottom`.
|
||||||
|
- Die Bandform kommt aus Achse + Dicke: `wallBand`/`wallCorners`
|
||||||
|
(`geometry.ts:53`/`:70`) versetzen die Achse um `thickness/2` entlang der
|
||||||
|
**Links-Normale** `leftNormal(u) = (-u.y, u.x)` (`geometry.ts:17`), CCW-Umlauf.
|
||||||
|
- `ExtrudeGeometry` liegt in der XY-Ebene und waechst entlang +Z; three.js dreht das
|
||||||
|
Prisma daher um +90 Grad um X und setzt es auf `topY` (~Z. 2266–2271). In wgpu
|
||||||
|
extrudieren wir direkt in world (XZ-Grundriss, +Y-Hoehe) und sparen die Drehung.
|
||||||
|
- **Hoehe/Basis:** `wallVerticalExtent(project, wall)` (`wall.ts:56`) liefert
|
||||||
|
absolute `zBottom`/`zTop` (aus `wall.bottom`/`wall.top`-Ankern bzw. Geschoss-
|
||||||
|
`baseElevation + wall.height`).
|
||||||
|
- **Mehrschichtig:** je `wt.layers`-Schicht ein eigenes Prisma mit Dicken-Offset
|
||||||
|
(~Z. 1991–2015). M1 extrudiert vereinfacht EINE Schicht (Gesamtdicke).
|
||||||
|
- **Ecken/Gehrung:** `computeJoins()` (`joins.ts:45`) berechnet Schnittlinien
|
||||||
|
(`startCut`/`endCut`), die `clippedBand` an L-Ecken auf Gehrung zieht
|
||||||
|
(`miterLine`, `joins.ts:101`). M1 laesst das noch weg (stumpfe Enden).
|
||||||
|
|
||||||
|
### Oeffnungen (Fenster/Tueren)
|
||||||
|
- `addOpeningMeshes()` (~Z. 2056–2169) + Segmentierung in `addWallMeshes` (~Z.
|
||||||
|
1968–2035). **Kein CSG/Boolean:** die Wand wird entlang der Achse in Segmente
|
||||||
|
zerlegt (`openingInterval`, `geometry/opening.ts:31`), und je Oeffnung entstehen
|
||||||
|
bis zu drei Prismen: Wand DAVOR, **Bruestung** unter dem Fenster (`sillRel`),
|
||||||
|
**Sturz** ueber der Oeffnung (`headRel`). Rahmen/Fluegel als `BoxGeometry`;
|
||||||
|
Glas semitransparent, Tuerfluegel um `swingAngle` gedreht.
|
||||||
|
|
||||||
|
### Treppen
|
||||||
|
- `addStairMeshes()` (~Z. 2357–2409). `stairGeometry()` (`geometry/stair.ts`)
|
||||||
|
liefert Trittflaechen (Footprint + Steig-Hoehe) + optionalen Podest-Umriss; jede
|
||||||
|
Stufe als extrudierter Block (`ExtrudeGeometry`), Hoehe = `stairVerticalExtent`.
|
||||||
|
|
||||||
|
### Decken/Platten
|
||||||
|
- `addCeilingMesh()` (~Z. 2290–2347). `ceiling.outline` als `ExtrudeGeometry`, Tiefe
|
||||||
|
= Deckenstaerke, waechst nach unten von `zTop` (`ceilingVerticalExtent`, `wall.ts:80`).
|
||||||
|
|
||||||
|
### Raeume
|
||||||
|
- Nicht als eigenstaendige 3D-Koerper gerendert (2D-Grundriss-Repraesentation).
|
||||||
|
|
||||||
|
### Kontext/Gelaende
|
||||||
|
- `buildContext()` (~Z. 1651–1718). Terrain/importierte Meshes als rohe
|
||||||
|
`BufferGeometry` (Positions/Indices, Koordinaten-Swap wie oben); Hoehenlinien als
|
||||||
|
`LineSegments`. Dazu ein `GridHelper` (~Z. 421) auf OKFF-Hoehe.
|
||||||
|
|
||||||
|
### Materialien
|
||||||
|
- `MeshLambertMaterial` (Waende/Oeffnungen/Treppen, per Komponente eingefaerbt,
|
||||||
|
~Z. 2176–2206), `MeshStandardMaterial` (Weiss-/Textur-Modus + Terrain, PBR:
|
||||||
|
`roughness`/`metalness`/`aoMap`, ~Z. 445–491, 2001–2015), `MeshBasicMaterial`
|
||||||
|
(Hidden-Line-Flaechen + immer-oben-Marker), `LineBasicMaterial` (Kanten/2D-
|
||||||
|
Zeichnungen). Render-Modi: shaded / white / textured / wireframe / hidden-line.
|
||||||
|
|
||||||
|
### Beleuchtung
|
||||||
|
- `AmbientLight(0xffffff, 0.6)` (~Z. 416) + `DirectionalLight(0xffffff, 1.1)` bei
|
||||||
|
`(6, 12, 4)` (~Z. 417). **Keine Schatten** konfiguriert. Keine Hemisphere/Point-
|
||||||
|
Lights.
|
||||||
|
|
||||||
|
### Kamera + Presets
|
||||||
|
- Zwei Kameras: `PerspectiveCamera(fov, 1, 0.1, 1000)` (~Z. 367) und
|
||||||
|
`OrthographicCamera(-1,1,1,-1, 0.1, 5000)` (~Z. 374). `applyView3d()` (~Z.
|
||||||
|
1566–1633) setzt fuenf Presets:
|
||||||
|
- **front** — Richtung `(0,0,1)`, orthografisch.
|
||||||
|
- **side** — Richtung `(1,0,0)`, orthografisch.
|
||||||
|
- **top** — Richtung `(0,1,~0)`, orthografisch (Rotation gesperrt).
|
||||||
|
- **iso** — Richtung `(1,1,1)` normiert, orthografisch.
|
||||||
|
- **perspective** — Richtung `(0.62,0.5,0.7)` normiert, perspektivisch.
|
||||||
|
- Umschalten perspektiv/ortho ueber `active = perspective ? camera : orthoCamera`
|
||||||
|
(~Z. 1602); Ortho-Frustum aus den Modell-Bounds (`updateOrthoFrustum`, ~Z. 1522).
|
||||||
|
- **OrbitControls** (~Z. 391–413): Mitteltaste orbit, Shift+Mitte pan, Rad zoom
|
||||||
|
(linke/rechte Taste fuer Auswahl/Kontextmenue umgewidmet).
|
||||||
|
|
||||||
|
### Griffe / Gizmos
|
||||||
|
- Editier-Griffe (`SphereGeometry`, ~Z. 711–814): Endpunkt (orange), Hoehe (blau),
|
||||||
|
Verschieben (gruen); `depthTest:false` (immer sichtbar). Drag ueber Workplane-
|
||||||
|
Raycast. Fuer den nativen Renderer spaeter relevant (eigener Overlay-Pass).
|
||||||
|
|
||||||
|
### Schnittebene
|
||||||
|
- **Nicht implementiert:** keine `renderer.clippingPlanes` / `localClippingEnabled`.
|
||||||
|
Schnitte laufen aktuell 2D. Fuer wgpu ein eigenständiger spaeterer Milestone
|
||||||
|
(Clip-Distances im Shader oder Stencil-Capping).
|
||||||
|
|
||||||
|
### Tiefe / Culling
|
||||||
|
- Tiefentest three.js-Standard aktiv. Backface-Culling per Default (Ausnahme:
|
||||||
|
Terrain/Import `DoubleSide`). Diverse Overlays mit `depthTest:false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Port-Plan nach wgpu
|
||||||
|
|
||||||
|
### Datenfluss
|
||||||
|
Web-Modell → **geflachte Eingabe** (`WallInput`, spaeter Oeffnungen/Treppen/Decken)
|
||||||
|
→ `render3d`-Mesh-Erzeugung → GPU-Buffers → Draw. Analog zum 2D-Pfad (`Scene` →
|
||||||
|
Tessellierung → Buffers). Die Eingabe ist bewusst serde-only und GPU-frei, damit die
|
||||||
|
Mesh-Logik headless testbar bleibt.
|
||||||
|
|
||||||
|
### Mesh-Erzeugung (Waende extrudieren)
|
||||||
|
- Band aus Achse + Dicke ueber die Links-Normale (`(-u.y, u.x) * thickness/2`,
|
||||||
|
CCW), exakt wie `wallCorners`. Extrusion in world: XZ-Grundriss, +Y von
|
||||||
|
`base_elevation` bis `+height`.
|
||||||
|
- Ein Quader = 6 Seiten, je eigene Vertices mit Flaechen-Normale (flaches Shading,
|
||||||
|
korrektes Backface-Culling). 24 Vertices / 36 Indizes je Wand.
|
||||||
|
- Spaeter: mehrschichtige Waende (je Schicht ein Prisma), Gehrung
|
||||||
|
(`computeJoins`/`clippedBand`-Port), Oeffnungs-Segmentierung (Bruestung/Sturz).
|
||||||
|
|
||||||
|
### Kamera (View/Projektion, Presets)
|
||||||
|
- `look_at` (right-handed, Kamera blickt entlang -Z im View-Raum), `perspective`
|
||||||
|
und `orthographic` — beide auf **Clip-Z in [0,1]** (wgpu-Konvention, NICHT [-1,1]).
|
||||||
|
- Fuenf Presets (`preset_camera`): front/top/side orthografisch achsparallel,
|
||||||
|
iso/persp perspektivisch. `top` mit up=-Z, damit Modell-Y im Bild nach unten
|
||||||
|
zeigt (wie die 2D-Sicht).
|
||||||
|
- Orbit-Kamera aus Yaw/Pitch/Distanz (`orbit_eye`, Pitch geklemmt gegen Pol-Flip).
|
||||||
|
|
||||||
|
### Beleuchtung
|
||||||
|
- Zunaechst EIN Directional-Light (Richtung ZUM Licht) + ambienter Sockel im
|
||||||
|
Fragment-Shader (WGSL) — das GPU-Aequivalent zu `AmbientLight(0.6)` +
|
||||||
|
`DirectionalLight(1.1)@(6,12,4)`. Diffuses Lambert. PBR (Rauheit/Metallik/
|
||||||
|
Texturen/AO) spaeter.
|
||||||
|
|
||||||
|
### Tiefenpuffer + Culling
|
||||||
|
- `Depth32Float`-Attachment, `depth_compare = Less`, `depth_write = true`.
|
||||||
|
- `front_face = Ccw`, `cull_mode = Back` (die Extrusion liefert konsistent nach
|
||||||
|
aussen zeigende CCW-Flaechen).
|
||||||
|
|
||||||
|
### Matrix-Mathematik
|
||||||
|
- Handgerechnet (kein `glam`) in der serde-only Schicht — begruendet in `math.rs`:
|
||||||
|
die Standard-Schicht soll wie in render2d ohne Zusatz-Crates headless test-/baubar
|
||||||
|
bleiben; der Umfang (perspective/ortho/look_at + Orbit) ist klein und exakt
|
||||||
|
testbar. Ein spaeterer Wechsel zu `glam` (nur in der GPU-Schicht) bleibt moeglich,
|
||||||
|
ohne die Kamera-Tests anzufassen. Alles spalten-major, direkt als Uniform ladbar.
|
||||||
|
|
||||||
|
### Milestones M2..Mn
|
||||||
|
- **M2 — Mehrschichtige Waende + Gehrung:** Port von `computeJoins`/`miterLine` +
|
||||||
|
`clippedBand` → gehrte Bandformen je Schicht; Farben/Materialien je Komponente.
|
||||||
|
- **M3 — Oeffnungen:** Achsen-Segmentierung (Bruestung/Sturz) + Rahmen/Glas/Fluegel
|
||||||
|
als eigene Meshes; Tuerschwenk-Winkel.
|
||||||
|
- **M4 — Treppen + Decken:** Port von `stairGeometry`/`ceilingVerticalExtent`.
|
||||||
|
- **M5 — Kontext/Gelaende:** rohe Terrain-/Import-Meshes + Hoehenlinien + Grid.
|
||||||
|
- **M6 — Materialien (PBR):** `MeshStandardMaterial`-Aequivalent (roughness/
|
||||||
|
metalness/albedo/AO-Textur); Render-Modi shaded/white/textured/wireframe/hidden.
|
||||||
|
- **M7 — Schnittebene:** Clip-Distances im Shader oder Stencil-Capping (Feature, das
|
||||||
|
der three.js-View gar nicht hat — echter Mehrwert).
|
||||||
|
- **M8 — Griffe/Gizmos + Picking:** Overlay-Pass (immer-oben) + GPU-/Ray-Picking.
|
||||||
|
- **M9 — Tauri-Integration:** Surface unter der Webview (raw-window-handle,
|
||||||
|
Z-Order, Input-Routing) — siehe `wgpu-2d-renderer-briefing.md` M2 (gleiches
|
||||||
|
Integrations-Problem; einmal loesen, fuer 2D+3D nutzen). Kamera-Presets/Orbit-
|
||||||
|
Input aus der Webview an den Renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Was in `src-tauri/render3d/` steht (M1)
|
||||||
|
|
||||||
|
Neue, eigenstaendige Crate (eigener leerer `[workspace]`-Block, wie render2d), damit
|
||||||
|
`cargo test`/`build` unabhaengig vom Tauri-Workspace laufen. Feature-Stufung 1:1 wie
|
||||||
|
render2d:
|
||||||
|
|
||||||
|
- `Cargo.toml` — Features `default` (serde-only) / `render` (wgpu) / `window`
|
||||||
|
(winit-Spike). `[[bin]] spike3d` mit `required-features = ["window"]`.
|
||||||
|
- `src/types.rs` — serde-only Eingabe: `WallInput { start, end, thickness, height,
|
||||||
|
base_elevation, color }`, `Camera` (+ `Projection`), `CameraPreset`; Ausgabe
|
||||||
|
`Mesh` (interleaved `[pos.xyz, normal.xyz, color.rgb]` + Indizes) mit
|
||||||
|
`vertex_count`/`triangle_count`/`bounds`. Koordinaten-Konvention dokumentiert.
|
||||||
|
- `src/mesh.rs` — Wand-Extrusion: `extrude_wall`/`build_walls_mesh`. Band ueber
|
||||||
|
Links-Normale, Quader mit sechs eigenen Seiten, nach aussen zeigende Normalen.
|
||||||
|
- `src/math.rs` — `Mat4` (spalten-major), `perspective`/`orthographic` (Clip-Z
|
||||||
|
[0,1]), `look_at`, `view_projection`, `orbit_eye`, `preset_camera` (fuenf Presets).
|
||||||
|
- `src/shaders.rs` — WGSL (`MESH_WGSL`): View-Projektion-Uniform + Directional-
|
||||||
|
Light + ambienter Sockel im Fragment-Shader.
|
||||||
|
- `src/gpu.rs` (Feature `render`) — `Renderer`: eine Pipeline mit Tiefenpuffer,
|
||||||
|
View-Projektions-Uniform, Backface-Culling. `upload_walls` → GPU-Buffers,
|
||||||
|
`render(camera, viewport)`.
|
||||||
|
- `src/bin/spike3d.rs` (Feature `window`) — winit-Fenster mit Demo-Raum (5
|
||||||
|
extrudierte Waende) + **Orbit-Kamera** (linke Maustaste dreht Yaw/Pitch, Rad
|
||||||
|
zoomt Abstand). Matrix-getrieben, kein Re-Meshing beim Kamera-Wechsel.
|
||||||
|
- `src/lib.rs` — Modul-Deklarationen, Re-Exports, Tests.
|
||||||
|
|
||||||
|
### Tests (`cargo test`, default-Feature)
|
||||||
|
Muster wie render2d/`glPlanCompile.test.ts`:
|
||||||
|
- Quader-Zaehlung (eine Wand → 24 Vertices / 36 Indizes / 12 Dreiecke).
|
||||||
|
- Mehrere Waende addieren sich.
|
||||||
|
- Bounding-Box deckt Laenge/Dicke/Hoehe ab; `base_elevation` verschiebt in Y.
|
||||||
|
- Deckel-Normale = +Y; **alle Mantel-Normalen zeigen nach aussen** (Dot mit
|
||||||
|
„Vertex − Zentrum" ≥ 0 → Backface-Culling korrekt).
|
||||||
|
- Diagonale Wand; degenerierte Wand (Start==Ende) erzeugt nichts (kein Absturz).
|
||||||
|
- Kamera: `look_at` setzt Ziel auf view-z=-dist; Perspektive klemmt z in [0,1];
|
||||||
|
`orbit_eye` haelt den Abstand; Presets setzen die richtige Projektionsart.
|
||||||
|
- Mit `--features render`: WGSL headless via `naga` (Parser + Validator) validiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Build-/Test-Ergebnis
|
||||||
|
|
||||||
|
Alle Gates gruen (Toolchain: cargo 1.96, wgpu 22, winit 0.30):
|
||||||
|
- `cargo test` (default) — **12/12** gruen (Mesh + Kamera).
|
||||||
|
- `cargo test --features render` — **13/13** gruen (inkl. WGSL-naga-Validierung).
|
||||||
|
- `cargo build` (default), `--features render`, `--features window` — je gruen,
|
||||||
|
**keine Warnungen**.
|
||||||
|
- Trace-Scan sauber (keine KI-Spuren).
|
||||||
|
- Web-Gates unberuehrt (nur `src-tauri/` + `docs/` angefasst; `src/` nur gelesen).
|
||||||
|
|
||||||
|
**Visuelle Fenster-Verifikation** ist headless NICHT moeglich. Auf einer aktiven
|
||||||
|
Display-Session pruefbar mit:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run --features window --bin spike3d
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: ein Raum aus extrudierten Waenden mit diffuser Beleuchtung; linke
|
||||||
|
Maustaste dreht die Orbit-Kamera, das Rad zoomt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Naechste Schritte
|
||||||
|
1. **M2** starten: `computeJoins`/`clippedBand`-Port für gehrte, mehrschichtige
|
||||||
|
Waende (die Bandmath ist im Web bereits verifiziert — gleiche Tests portieren).
|
||||||
|
2. Oeffnungs-Segmentierung (M3) auf demselben Extrusions-Kern.
|
||||||
|
3. Die **Tauri-Integration (M9)** gemeinsam mit dem 2D-Renderer loesen (ein Surface-
|
||||||
|
Unterbau, ein Input-Routing) — das ist der eigentliche Engpass, nicht das
|
||||||
|
Rendering. Erst standalone spiken (dieser Stand), dann unter die Webview.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Welle C — HLR-Spike: Feasibility-Report
|
||||||
|
|
||||||
|
> 2D-Vektor-Schnitte/-Ansichten aus dem 3D-Modell via Hidden-Line-Removal (HLR)
|
||||||
|
> mit opencascade.js (OCCT-WASM). Isolierter De-Risking-Spike, NICHT in die App
|
||||||
|
> verdrahtet. Einheiten: Meter.
|
||||||
|
|
||||||
|
## Ergebnis (kurz)
|
||||||
|
|
||||||
|
**HLR funktioniert in diesem Browser-Projekt.** opencascade.js initialisiert im
|
||||||
|
Vite-Build, und der HLR-Lauf liefert korrekte, getrennte sichtbare/verdeckte
|
||||||
|
2D-Kanten. Verifiziert im echten Browser (Chromium via Vite-Dev-Server) an einer
|
||||||
|
L-Wand + Bodenplatte, Front-Ansicht:
|
||||||
|
|
||||||
|
| Messwert | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Sichtbare Kanten (sharp) | **9** |
|
||||||
|
| Verdeckte Kanten | **16** |
|
||||||
|
| Reine HLR-Rechenzeit | **~21 ms** |
|
||||||
|
| WASM-Größe (unkomprimiert) | **62.8 MB** (65 864 037 Bytes) |
|
||||||
|
| WASM-Größe (gzip) | **~19.6 MB** |
|
||||||
|
| WASM-Fetch (lokal, Dev) | ~113 ms |
|
||||||
|
| Modul-Init (Emscripten instanziieren) | ~450 ms (Browser) / ~680 ms (Node) |
|
||||||
|
|
||||||
|
Beweis-Artefakte: `hlr-elevation-proof.svg` / `.png` in diesem Ordner — sichtbare
|
||||||
|
Kanten durchgezogen, verdeckte gestrichelt. Die Zeichnung liest sich als korrekte
|
||||||
|
Hidden-Line-Ansicht (Silhouette + sichtbare Front-Kanten voll, verdeckte hinten
|
||||||
|
gestrichelt).
|
||||||
|
|
||||||
|
## Der genutzte API-Pfad (wichtig!)
|
||||||
|
|
||||||
|
Der geplante klassische Pfad (`HLRBRep_Algo`/`HLRBRep_PolyAlgo` +
|
||||||
|
`HLRBRep_HLRToShape`/`HLRBRep_PolyHLRToShape`) ist im **prebuilt Vollbuild
|
||||||
|
1.1.1 NICHT verfügbar**: diese Klassen sind nur als `Handle_…`-Smart-Pointer
|
||||||
|
gebunden, ohne konstruierbare Roh-Klasse und ohne den `HLRToShape`-Extraktor.
|
||||||
|
Ein direkter Aufbau darüber ist damit nicht möglich.
|
||||||
|
|
||||||
|
**Verwendeter, funktionierender Pfad:** `HLRAppli_ReflectLines` — der High-Level-
|
||||||
|
OCCT-Wrapper, der intern GENAU den exakten HLR-Algorithmus (`HLRBRep_Algo`)
|
||||||
|
fährt. Er ist als Klasse gebunden und liefert getrennt sichtbare/verdeckte
|
||||||
|
Kanten nach Kanten-Typ:
|
||||||
|
|
||||||
|
```
|
||||||
|
const rl = new oc.HLRAppli_ReflectLines(shape);
|
||||||
|
rl.SetAxes(dirX, dirY, dirZ, atX, atY, atZ, upX, upY, upZ); // Ortho-Projektion
|
||||||
|
rl.Perform();
|
||||||
|
const T = oc.HLRBRep_TypeOfResultingEdge;
|
||||||
|
// (typ, visible, in3d) → in3d=false liefert 2D-projizierte Kanten (Z≈0)
|
||||||
|
const visSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false);
|
||||||
|
const visOutline = rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false);
|
||||||
|
const hidSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Kanten-Extraktion: `TopExp_Explorer_2(compound, TopAbs_EDGE, TopAbs_SHAPE)` →
|
||||||
|
`TopoDS.Edge_1(current)` → `new BRepAdaptor_Curve_2(edge)` →
|
||||||
|
`FirstParameter/LastParameter/Value(u)` (`gp_Pnt` mit `.X() .Y() .Z()`).
|
||||||
|
|
||||||
|
**embind-Überladungen sind versioniert** (`_1`, `_2`, …) und die Nummerierung im
|
||||||
|
Vollbuild folgt NICHT der Argumentzahl. Empirisch verifiziert:
|
||||||
|
`BRepPrimAPI_MakeBox_1(dx,dy,dz)`, `BRepPrimAPI_MakeBox_3(gp_Pnt, gp_Pnt)`,
|
||||||
|
`BRepAlgoAPI_Fuse_3(a, b)`, `gp_Pnt_3(x,y,z)`. Bei einem Versionswechsel des
|
||||||
|
Pakets müssen diese Suffixe neu geprüft werden (Fehlermeldung nennt die
|
||||||
|
erwartete Parameterzahl).
|
||||||
|
|
||||||
|
## Vite-/package.json-Änderungen (genau)
|
||||||
|
|
||||||
|
- `package.json`: Dependency `opencascade.js@^1.1.1` (Vollbuild).
|
||||||
|
- `vite.config.ts` (additiv, analog zum bestehenden LibreDWG-Muster):
|
||||||
|
- Zwei Aliase → virtuelle Module auf die echten Paket-Dateien:
|
||||||
|
`virtual:occt-glue` → `dist/opencascade.wasm.js`,
|
||||||
|
`virtual:occt-wasm-url` → `dist/opencascade.wasm.wasm?url`.
|
||||||
|
- `optimizeDeps.exclude: ["opencascade.js"]` (esbuild kommt mit dem
|
||||||
|
UMD-Wrapper + Node-Shims der Glue nicht klar → Vor-Bündeln ausschließen).
|
||||||
|
- `src/section/occt-wasm.d.ts`: Ambient-Stubs für die zwei virtuellen Module.
|
||||||
|
- Geladen wird per **dynamischem Import** in `src/section/occt.ts` → die 62-MB-
|
||||||
|
WASM landet NICHT im Haupt-Bundle, sondern als separater Lazy-Chunk + Asset.
|
||||||
|
|
||||||
|
Verifiziert: `vite build` der App bleibt grün und unverändert groß (kein
|
||||||
|
OCCT-Chunk, da der Spike nicht verdrahtet ist). Ein Lib-Build, der `hlr.ts`
|
||||||
|
referenziert, splittet OCCT sauber in einen eigenen Glue-Chunk + WASM-Asset ab.
|
||||||
|
`tsc` ist für die neuen Dateien grün. (`npm run build` schlägt aktuell in
|
||||||
|
`tsc -b` fehl — ausschließlich wegen fehlender i18n-Keys in den parallel
|
||||||
|
bearbeiteten Dateien `commands/cmds/stair.ts` / `state/*`, NICHT wegen dieses
|
||||||
|
Spikes.)
|
||||||
|
|
||||||
|
## Dateien dieses Spikes
|
||||||
|
|
||||||
|
- `src/section/hlr.ts` — Kernmodul: Box-Specs → Fuse → HLR → 2D-Polylinien
|
||||||
|
(`hlrFromBoxes`, `hlrShape`, `VIEWS`, `hlrToSvg`).
|
||||||
|
- `src/section/occt.ts` — Lazy-Loader + schmale Typ-Fassade + Lade-Metriken.
|
||||||
|
- `src/section/occt-wasm.d.ts` — Ambient-Deklarationen der virtuellen Module.
|
||||||
|
- `docs/welle-c-hlr-spike/` — dieser Report + Proof-SVG/-PNG.
|
||||||
|
|
||||||
|
## Bewertung für Welle C
|
||||||
|
|
||||||
|
**Viabel.** Der exakte HLR liefert saubere, normgerechte Vektor-Kanten mit
|
||||||
|
korrekter Sichtbarkeit — genau das, was Schnitt/Ansicht brauchen und was reines
|
||||||
|
three.js-Kanten-Projizieren nicht robust liefert (dort fehlt echte
|
||||||
|
Flächen-Verdeckung). Die 2D-Polylinien passen direkt in die bestehende
|
||||||
|
SVG-Plan-Pipeline (`Pt2`/`Vec2`-kompatibel).
|
||||||
|
|
||||||
|
**Der Kostenpunkt ist die WASM-Größe (62.8 MB / ~19.6 MB gzip).** Für einen
|
||||||
|
Spike akzeptabel; für Produktion zu groß, um sie eager zu laden.
|
||||||
|
|
||||||
|
### Empfohlener Integrations- + Größenreduktions-Plan
|
||||||
|
|
||||||
|
1. **Lazy laden** (bereits so gebaut): OCCT erst bei erster Schnitt-/Ansichts-
|
||||||
|
Erzeugung dynamisch importieren; Ladezustand in der UI anzeigen. Der
|
||||||
|
Grundriss läuft weiter ohne OCCT (parametrisch, wie bisher).
|
||||||
|
2. **Web-Worker**: HLR im Worker fahren, damit der UI-Thread frei bleibt (die
|
||||||
|
WASM ist groß, aber der HLR-Lauf selbst ist mit ~20 ms günstig).
|
||||||
|
3. **Größe reduzieren — lohnt sich klar**: ein **Custom-Build** von
|
||||||
|
opencascade.js (das Paket unterstützt `make.py`/Docker-Build mit einer
|
||||||
|
Symbol-Whitelist). Für Welle C reicht ein minimaler Satz:
|
||||||
|
`BRepPrimAPI_*` (bzw. der spätere Solid-Erzeuger), `BRepAlgoAPI_Fuse/Common`,
|
||||||
|
`HLRAppli_ReflectLines` + `HLRBRep_TypeOfResultingEdge`, `TopExp_Explorer`,
|
||||||
|
`TopoDS`, `BRepAdaptor_Curve`, `gp_*`. Erfahrungswerte solcher Minimal-Builds
|
||||||
|
liegen bei **~5–15 MB WASM** statt 62 MB — Aufwand: ein reproduzierbarer
|
||||||
|
Docker-Build in CI, der die `.wasm`/`.js` als Projekt-Asset eincheckt.
|
||||||
|
Alternativ die **beta 2.0** prüfen (moderneres Build-System, evtl. bereits
|
||||||
|
schlankere Module + die klassischen HLR-Klassen konstruierbar).
|
||||||
|
4. **Fallback, falls die Größe untragbar bleibt**: three.js-basierte
|
||||||
|
Kanten-Extraktion (`EdgesGeometry`) + eigene Sichtbarkeit via Depth-Peeling /
|
||||||
|
GPU-Occlusion. Deutlich mehr Eigenaufwand, weniger robust bei Verschneidungen
|
||||||
|
— daher nur zweite Wahl; OCCT-HLR bleibt der empfohlene Weg.
|
||||||
|
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 |
@@ -0,0 +1,90 @@
|
|||||||
|
# M2 — nativer wgpu-2D-Renderer im Tauri-Fenster: Ansatzwahl
|
||||||
|
|
||||||
|
> Ziel: den bereits fluessig laufenden standalone `render2d`-Renderer aus dem
|
||||||
|
> ECHTEN Tauri-Prozess heraus mit nativer GPU-Performance anzeigen — nicht in der
|
||||||
|
> WebKitGTK-Webview (Perf-Flaschenhals), nicht in einem Chromium-Workaround.
|
||||||
|
> Maschine: Linux, Wayland, AMD.
|
||||||
|
|
||||||
|
## Gewaehlter Ansatz: **B — separates natives Fenster (winit) im Tauri-Prozess**
|
||||||
|
|
||||||
|
Der Tauri-Hauptthread haelt weiterhin die GTK-Hauptschleife + das Webview-Fenster
|
||||||
|
(HTML-Chrome). Aus dem Tauri-`setup`-Hook startet der Prozess auf einem
|
||||||
|
Hintergrund-Thread ein **eigenes natives winit-Fenster** mit eigener wgpu-Surface,
|
||||||
|
das die `render2d`-Demo-Szene (konkaves L + Raum + Papier-mm-Linien) rendert,
|
||||||
|
pan-/zoombar. Renderer/Szene werden NICHT reimplementiert — es ist exakt der Code
|
||||||
|
des verifizierten standalone `spike`-Bins (`render2d::gpu::Renderer` +
|
||||||
|
`render2d::demo_scene`).
|
||||||
|
|
||||||
|
### Warum B (und was gegen A/C spricht)
|
||||||
|
|
||||||
|
- **Kein geteilter Wayland-Surface → keine Contention/Flicker.** winit spricht auf
|
||||||
|
Linux DIREKT Wayland/X11 (Crates `wayland-client`/`x11rb`), es benutzt **kein
|
||||||
|
GTK**. Das native Fenster bekommt eine eigene, von WebKitGTK voellig getrennte
|
||||||
|
Wayland-Surface. Genau das umgeht das dokumentierte Problem aus der Vorrecherche
|
||||||
|
(Roh-wgpu-Surface UNTER der Webview im selben Fenster → Flicker/Schwarzbild auf
|
||||||
|
Wayland).
|
||||||
|
- **Zwei Fenster-Stacks, aber KEIN Event-Loop-Konflikt.** tao (Tauri) fuehrt eine
|
||||||
|
GTK-Hauptschleife auf dem Hauptthread; winit fuehrt eine eigene Schleife.
|
||||||
|
winit 0.30 erlaubt die Event-Loop explizit auf einem Nicht-Haupt-Thread via
|
||||||
|
`EventLoopBuilderExtWayland::with_any_thread(true)` (X11-Pendant analog). Der
|
||||||
|
native Renderer laeuft daher auf `std::thread` „cad-native2d", der Tauri-/GTK-
|
||||||
|
Hauptthread bleibt frei. Da winit nicht an GTK gebunden ist, kollidieren die
|
||||||
|
beiden Schleifen nicht (getrennte Stacks, getrennte fds).
|
||||||
|
|
||||||
|
- **Ansatz A (wgpu-Surface im SELBEN Tauri-Fenster, Unter-/Overlay):** verworfen.
|
||||||
|
taos `raw_window_handle_rwh_06()` liefert auf Wayland den `WaylandWindowHandle`
|
||||||
|
der GTK-`ApplicationWindow` — also DIESELBE Surface, in die WebKitGTK
|
||||||
|
komponiert. Eine zweite wgpu-Surface darauf ist genau die Contention, die die
|
||||||
|
Vorrecherche als flackernd/schwarz auf diesem Setup markiert hat. Hoechstes
|
||||||
|
Wayland-Risiko, fuer den Spike ungeeignet.
|
||||||
|
- **Ansatz C (wgpu als Haupt-Surface, HTML nur duennes Overlay):** verworfen fuer
|
||||||
|
den Spike. Sauberste Perf-Story, aber groesster Umbau: die ganze App muesste auf
|
||||||
|
eine winit/tao-getriebene Haupt-Schleife umziehen und die Tauri-Webview zur
|
||||||
|
Nebenrolle degradiert werden. Kein „minimaler realer Spike" mehr. Bleibt die
|
||||||
|
Ziel-Endarchitektur-Option, aber nach B.
|
||||||
|
|
||||||
|
## Crates / Versionen (verifiziert aus der Lock-Datei)
|
||||||
|
|
||||||
|
| Rolle | Crate | Version |
|
||||||
|
|---|---|---|
|
||||||
|
| Tauri | `tauri` / `tauri-runtime-wry` | 2.11.5 / 2.11.4 |
|
||||||
|
| Fenster (Webview-Seite) | `tao` | 0.35.3 |
|
||||||
|
| Webview | `wry` | 0.55.1 |
|
||||||
|
| Natives Renderfenster | `winit` | 0.30.13 |
|
||||||
|
| GPU | `wgpu` / `wgpu-hal` | 22.1.0 / 22.0.0 |
|
||||||
|
| Handle-Bruecke | `raw-window-handle` | **0.6.2 (identisch fuer tao UND wgpu/winit)** |
|
||||||
|
| Renderer | `render2d` (Pfad) | 0.1.0, Feature `window` |
|
||||||
|
|
||||||
|
Der entscheidende Kompatibilitaets-Glueckstreffer: tao 0.35 und wgpu 22/winit 0.30
|
||||||
|
teilen sich `raw-window-handle 0.6.2` — keine rwh-Versionsbruecke noetig. (Fuer
|
||||||
|
Ansatz B wird der tao-Handle gar nicht gebraucht, weil das native Fenster eine
|
||||||
|
eigene winit-Surface hat; die Versions-Parität ist aber die Voraussetzung fuer ein
|
||||||
|
spaeteres A/Compositing, falls je gewuenscht.)
|
||||||
|
|
||||||
|
## Wayland-Caveats (fuer den Start auf dieser Maschine)
|
||||||
|
|
||||||
|
- **`WEBKIT_DISABLE_DMABUF_RENDERER=1`** bleibt fuer die Webview-Sichtbarkeit auf
|
||||||
|
diesem Wayland/AMD-Setup noetig (Vorrecherche). Betrifft die WebKitGTK-Seite,
|
||||||
|
nicht das wgpu-Fenster.
|
||||||
|
- Das native winit-Fenster laeuft ueber Vulkan; GLES/EGL-Fallback wurde standalone
|
||||||
|
ebenfalls funktionierend gesehen. `WGPU_BACKEND=gl` erzwingt bei Vulkan-Zicken
|
||||||
|
den GL-Pfad.
|
||||||
|
- `with_any_thread(true)` ist zwingend — ohne die Freigabe panict winit, weil es
|
||||||
|
die Event-Loop sonst nur auf dem Hauptthread zulaesst (den haelt hier GTK).
|
||||||
|
|
||||||
|
## Bau-Isolierung
|
||||||
|
|
||||||
|
Alles hinter Cargo-Feature **`native2d`** in `cad-tauri` (opt-in). Der normale
|
||||||
|
Build (`cargo build`, `npm run tauri:dev`) zieht weder winit noch wgpu und bleibt
|
||||||
|
unveraendert. `render2d` bleibt ohne Tauri-/GTK-Abhaengigkeiten headless baubar
|
||||||
|
und testbar.
|
||||||
|
|
||||||
|
## Naechster Schritt Richtung Vollbild-App (2D-Viewport + HTML-Chrome)
|
||||||
|
|
||||||
|
1. Szene aus dem echten Modell speisen: `Plan.primitives` (TS) → serde-`Scene` →
|
||||||
|
ueber einen Tauri-`emit`/Command an den native2d-Thread (statt `demo_scene`).
|
||||||
|
2. Pan/Zoom-State zwischen Webview-Chrome und native2d-Fenster synchronisieren
|
||||||
|
(Tauri-Events beidseitig).
|
||||||
|
3. Fenster-Kopplung: das native Fenster als Kind/als angedockten Bereich neben der
|
||||||
|
Webview positionieren (Layout), spaeter ggf. Umstieg auf Ansatz C, wenn ein
|
||||||
|
einziges Fenster gefordert ist.
|
||||||
@@ -6,17 +6,31 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build",
|
||||||
|
"dump:native": "node scripts/dump-native-scene.mjs",
|
||||||
|
"shell": "scripts/chromium-shell.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"delaunator": "^5.1.0",
|
"delaunator": "^5.1.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"opencascade.js": "^1.1.1",
|
||||||
|
"polygon-clipping": "^0.15.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"svg2pdf.js": "^2.7.0",
|
||||||
"three": "^0.169.0"
|
"three": "^0.169.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/three": "^0.169.0",
|
"@types/three": "^0.169.0",
|
||||||
@@ -24,6 +38,7 @@
|
|||||||
"playwright": "^1.61.1",
|
"playwright": "^1.61.1",
|
||||||
"puppeteer": "^25.2.1",
|
"puppeteer": "^25.2.1",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 883 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 834 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 752 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 588 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 1023 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 445 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# chromium-shell.sh — startet die CAD-Oberfläche in einem randlosen
|
||||||
|
# Chromium-App-Fenster statt im Tauri-Webview.
|
||||||
|
#
|
||||||
|
# Hintergrund: Tauris Linux-Webview ist WebKitGTK, das SVG/2D auf der CPU
|
||||||
|
# (Cairo) rastert und bei hoher Bildwiederholrate sichtbar ruckelt. Chromium
|
||||||
|
# rendert dieselbe Seite butterweich (GPU-beschleunigt). Bis ein nativer
|
||||||
|
# wgpu-Renderer oder ein Electron/CEF-Shell fertig ist, ist dieses Script der
|
||||||
|
# schnellste Weg zu einer flüssigen Oberfläche für Vorführungen/Entwicklung.
|
||||||
|
#
|
||||||
|
# Ablauf:
|
||||||
|
# 1. Prüft, ob der Vite-Dev-Server auf dem konfigurierten Port bereits
|
||||||
|
# lauscht; falls nicht, startet es ihn selbst und wartet per Polling.
|
||||||
|
# 2. Öffnet den Dev-Server im App-Modus (kein Tab/URL-Leiste) im ersten
|
||||||
|
# gefundenen Chromium-basierten Browser.
|
||||||
|
# 3. Hat es den Dev-Server selbst gestartet, beendet es ihn wieder, sobald
|
||||||
|
# das Browserfenster geschlossen wird.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PORT=5187
|
||||||
|
URL="http://localhost:${PORT}"
|
||||||
|
PROFILE_DIR="${HOME}/.cache/cad-chromium-shell"
|
||||||
|
|
||||||
|
# ── Browser-Suche (Reihenfolge wie vorgegeben) ────────────────────────────────
|
||||||
|
BROWSER_BIN=""
|
||||||
|
for candidate in chromium chromium-browser google-chrome-stable google-chrome brave vivaldi; do
|
||||||
|
if command -v "${candidate}" >/dev/null 2>&1; then
|
||||||
|
BROWSER_BIN="${candidate}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "${BROWSER_BIN}" ]; then
|
||||||
|
echo "chromium-shell: kein Chromium-basierter Browser gefunden." >&2
|
||||||
|
echo "Gesucht wurden: chromium, chromium-browser, google-chrome-stable, google-chrome, brave, vivaldi." >&2
|
||||||
|
echo "Bitte einen dieser Browser installieren, um die flüssige App-Shell zu nutzen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Dev-Server sicherstellen ───────────────────────────────────────────────────
|
||||||
|
DEV_SERVER_PID=""
|
||||||
|
|
||||||
|
server_is_up() {
|
||||||
|
curl --silent --fail --max-time 1 --output /dev/null "${URL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if server_is_up; then
|
||||||
|
echo "chromium-shell: Dev-Server läuft bereits auf ${URL}."
|
||||||
|
else
|
||||||
|
echo "chromium-shell: starte Vite-Dev-Server (Port ${PORT}) …"
|
||||||
|
npm run dev >/tmp/cad-chromium-shell-dev.log 2>&1 &
|
||||||
|
DEV_SERVER_PID=$!
|
||||||
|
|
||||||
|
ATTEMPTS=0
|
||||||
|
MAX_ATTEMPTS=60
|
||||||
|
until server_is_up; do
|
||||||
|
ATTEMPTS=$((ATTEMPTS + 1))
|
||||||
|
if [ "${ATTEMPTS}" -ge "${MAX_ATTEMPTS}" ]; then
|
||||||
|
echo "chromium-shell: Dev-Server antwortet nach ${MAX_ATTEMPTS}s nicht, Abbruch." >&2
|
||||||
|
kill "${DEV_SERVER_PID}" 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "chromium-shell: Dev-Server bereit."
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "${DEV_SERVER_PID}" ]; then
|
||||||
|
echo "chromium-shell: beende selbst gestarteten Dev-Server (PID ${DEV_SERVER_PID}) …"
|
||||||
|
kill "${DEV_SERVER_PID}" 2>/dev/null
|
||||||
|
wait "${DEV_SERVER_PID}" 2>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "${PROFILE_DIR}"
|
||||||
|
|
||||||
|
echo "chromium-shell: öffne ${URL} in ${BROWSER_BIN} (App-Modus) …"
|
||||||
|
"${BROWSER_BIN}" \
|
||||||
|
--app="${URL}" \
|
||||||
|
--user-data-dir="${PROFILE_DIR}" \
|
||||||
|
--window-size=1600,1000 \
|
||||||
|
>/tmp/cad-chromium-shell-browser.log 2>&1
|
||||||
|
|
||||||
|
# Läuft weiter, bis der Browserprozess (das App-Fenster) beendet wird; dann
|
||||||
|
# räumt der trap oben den selbst gestarteten Dev-Server auf.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Bundlet scripts/dumpNativeScene.ts mit esbuild für Node und führt es aus.
|
||||||
|
// So läuft die echte TS-Modellpipeline (generatePlan) headless, ohne tsx.
|
||||||
|
import esbuild from "esbuild";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
const out = resolve(tmpdir(), `dump-native-scene.${process.pid}.mjs`);
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ["scripts/dumpNativeScene.ts"],
|
||||||
|
bundle: true,
|
||||||
|
platform: "node",
|
||||||
|
format: "esm",
|
||||||
|
target: "node18",
|
||||||
|
outfile: out,
|
||||||
|
logLevel: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
await import(pathToFileURL(out).href);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Erzeugt aus dem echten Modell (sampleProject → generatePlan) die JSON-Dateien,
|
||||||
|
// die die nativen wgpu-Fenster (render2d/render3d) beim Start laden:
|
||||||
|
// src-tauri/assets/native2d_scene.json (RScene: fills/outlines/lines/texts)
|
||||||
|
// src-tauri/assets/native3d_walls.json (RWall[])
|
||||||
|
// Wird via scripts/dump-native-scene.mjs (esbuild-Bundle) unter Node ausgeführt.
|
||||||
|
|
||||||
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { sampleProject } from "../src/model/sampleProject";
|
||||||
|
import { generatePlan } from "../src/plan/generatePlan";
|
||||||
|
import { planToRenderScene } from "../src/plan/toRenderScene";
|
||||||
|
import { projectToWalls3d } from "../src/plan/toWalls3d";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const outDir = resolve(root, "src-tauri", "assets");
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
// 2D: Erdgeschoss-Grundriss, alle sichtbaren Kategorien, Detailgrad „mittel".
|
||||||
|
const floorId = "eg";
|
||||||
|
const visibleCodes = new Set(sampleProject.layers.map((l) => l.code));
|
||||||
|
const plan = generatePlan(sampleProject, floorId, visibleCodes);
|
||||||
|
const scene = planToRenderScene(plan);
|
||||||
|
|
||||||
|
// 3D: alle Wände aller Geschosse, korrekt gestapelt.
|
||||||
|
const walls = projectToWalls3d(sampleProject);
|
||||||
|
|
||||||
|
const scenePath = resolve(outDir, "native2d_scene.json");
|
||||||
|
const wallsPath = resolve(outDir, "native3d_walls.json");
|
||||||
|
writeFileSync(scenePath, JSON.stringify(scene));
|
||||||
|
writeFileSync(wallsPath, JSON.stringify(walls));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`native2d_scene.json: ${scene.fills.length} fills, ${scene.outlines.length} outlines, ${scene.lines.length} lines, ${scene.texts.length} texts`,
|
||||||
|
);
|
||||||
|
console.log(`native3d_walls.json: ${walls.length} walls`);
|
||||||
|
console.log(`→ ${outDir}`);
|
||||||
@@ -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();
|
||||||
@@ -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");
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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");
|
||||||
@@ -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();
|
||||||
@@ -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");
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
// Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick).
|
// Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick).
|
||||||
// Liest das Modell über den TEMP-Hook window.__drawings2d (id + geom).
|
// Liest das Modell über einen TEMP-Hook window.__drawings2d (id + geom). Dieser
|
||||||
|
// Hook wurde nur für die Verifikation in App.tsx eingehängt und danach wieder
|
||||||
|
// ENTFERNT — zum erneuten Lauf muss er kurzzeitig reaktiviert werden:
|
||||||
|
// useEffect(() => {
|
||||||
|
// (window).__drawings2d = project.drawings2d.map(d => ({id:d.id, geom:d.geom}));
|
||||||
|
// }, [project.drawings2d]);
|
||||||
|
// Verifizierte Ergebnisse (siehe probe-splitjoin-*.png):
|
||||||
|
// Split Rechteck+Querlinie → 2 geschlossene Polylinien + Linie
|
||||||
|
// Join 2 Linien (gem. Endpunkt) → 1 Polylinie (3 Punkte)
|
||||||
|
// Alt-Klick auf Rechteck-Kante → offene Polylinie (4 Punkte, Segment weg)
|
||||||
import puppeteer from "puppeteer";
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
const b = await puppeteer.launch({
|
const b = await puppeteer.launch({
|
||||||
@@ -75,8 +84,8 @@ await endCommand();
|
|||||||
await sleep(150);
|
await sleep(150);
|
||||||
|
|
||||||
const before = await drawings();
|
const before = await drawings();
|
||||||
// Rechteck wählen: auf eine Rechteck-Kante klicken (untere Kante y=0, x=1).
|
// Rechteck wählen: auf die linke Kante klicken (x=0, y=2) — eindeutig auf dem Rand.
|
||||||
let [cx, cy] = await modelToClient(1, 0);
|
let [cx, cy] = await modelToClient(0, 2);
|
||||||
await p.mouse.click(cx, cy);
|
await p.mouse.click(cx, cy);
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
await ctrlPress("s");
|
await ctrlPress("s");
|
||||||
@@ -91,20 +100,20 @@ await p.reload({ waitUntil: "networkidle0" });
|
|||||||
await sleep(800);
|
await sleep(800);
|
||||||
await focusCmd();
|
await focusCmd();
|
||||||
await typeLine("line");
|
await typeLine("line");
|
||||||
await typeLine("-3,-3");
|
await typeLine("1,1");
|
||||||
await typeLine("0,-3");
|
await typeLine("4,1");
|
||||||
await focusCmd();
|
await focusCmd();
|
||||||
await typeLine("line");
|
await typeLine("line");
|
||||||
await typeLine("0,-3");
|
await typeLine("4,1");
|
||||||
await typeLine("0,0");
|
await typeLine("4,4");
|
||||||
await endCommand();
|
await endCommand();
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
const beforeJoin = await drawings();
|
const beforeJoin = await drawings();
|
||||||
// Beide wählen: erste klicken, dann Shift+zweite.
|
// Beide wählen: erste klicken (Mitte 2.5,1), dann Shift+zweite (Mitte 4,2.5).
|
||||||
let [ax, ay] = await modelToClient(-1.5, -3);
|
let [ax, ay] = await modelToClient(2.5, 1);
|
||||||
await p.mouse.click(ax, ay);
|
await p.mouse.click(ax, ay);
|
||||||
await sleep(100);
|
await sleep(120);
|
||||||
let [bx2, by2] = await modelToClient(0, -1.5);
|
let [bx2, by2] = await modelToClient(4, 2.5);
|
||||||
await p.keyboard.down("Shift");
|
await p.keyboard.down("Shift");
|
||||||
await p.mouse.click(bx2, by2);
|
await p.mouse.click(bx2, by2);
|
||||||
await p.keyboard.up("Shift");
|
await p.keyboard.up("Shift");
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
/gen/schemas
|
||||||
|
# Build-Artefakte aller (Sub-)Crates, unabhängig von der Verschachtelung.
|
||||||
|
target/
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [".", "geometry"]
|
||||||
|
# render2d/render3d sind eigenstaendige Crates (jeweils eigener [workspace]), damit
|
||||||
|
# sie headless ohne Tauri-Toolchain baubar/testbar bleiben. Sie liegen zwar im
|
||||||
|
# src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace — hier ausschliessen,
|
||||||
|
# damit `cad-tauri` sie dennoch per Pfad als (optionale) Abhaengigkeit nutzen kann.
|
||||||
|
exclude = ["render2d", "render3d"]
|
||||||
|
|
||||||
|
[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"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Standard: reine Tauri-App (Webview + invoke). KEIN wgpu/winit.
|
||||||
|
default = []
|
||||||
|
# M2-Spike: nativer wgpu-2D-Viewport im Tauri-Prozess (eigenes winit-Fenster).
|
||||||
|
# Zieht render2d(+window) sowie winit/wgpu/pollster. Bewusst OPT-IN, damit der
|
||||||
|
# normale Build unveraendert schlank bleibt.
|
||||||
|
native2d = ["dep:render2d", "dep:winit", "dep:wgpu", "dep:pollster"]
|
||||||
|
# M2-Spike: nativer wgpu-3D-Viewport im Tauri-Prozess (eigenes winit-Fenster).
|
||||||
|
# Zieht render3d(+window) sowie winit/wgpu/pollster. Kann gemeinsam mit `native2d`
|
||||||
|
# aktiviert werden (dann zwei native Fenster). Ebenfalls OPT-IN.
|
||||||
|
native3d = ["dep:render3d", "dep:winit", "dep:wgpu", "dep:pollster"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
geometry = { path = "geometry" }
|
||||||
|
|
||||||
|
# --- M2-Spike (nur mit Feature "native2d") -----------------------------------
|
||||||
|
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
|
||||||
|
# sie hier per Pfad ein und aktivieren ihr GPU-Fenster-Feature "window".
|
||||||
|
render2d = { path = "render2d", features = ["window"], optional = true }
|
||||||
|
# render3d analog: eigenstaendige Crate (via Parent-`exclude` aus dem Workspace
|
||||||
|
# genommen, ohne eigenen [workspace]-Block); per Pfad mit GPU-Fenster-Feature
|
||||||
|
# "window" eingebunden.
|
||||||
|
render3d = { path = "render3d", features = ["window"], optional = true }
|
||||||
|
# Versionen an render2d gekoppelt (identische raw-window-handle 0.6-Kette).
|
||||||
|
winit = { version = "0.30", optional = true }
|
||||||
|
wgpu = { version = "22", optional = true }
|
||||||
|
pollster = { version = "0.3", optional = true }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 103 B |