Compare commits
10 Commits
7b9f72e79f
...
0161b0231d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0161b0231d | |||
| 34317e53f4 | |||
| 27e41077b1 | |||
| a37b1c0cc9 | |||
| 4eb635cc89 | |||
| 4734c04ec0 | |||
| 2f971a54c0 | |||
| 8cfe6c2521 | |||
| 665cbce1f9 | |||
| 6eead6d493 |
+61
@@ -41,6 +41,67 @@ Code/Doku noch in der Git-Historie.
|
||||
vor dem Edit frisch lesen (der „modified since read"-Guard verhindert blindes
|
||||
Überschreiben).
|
||||
|
||||
## >>> ENGINE-NORDSTERN (User-Direktive 2026-07-02): die volle Engine-Vision <<<
|
||||
|
||||
Der User hat explizit beauftragt, die Engine-Vision KOMPLETT anzustreben ("wir brauchen
|
||||
exakte breiten usw. strebe bitte das komplett an!"). render2d/render3d sind nicht nur
|
||||
Viewport-Ersatz, sondern werden die EINZIGE Render-Wahrheit des CAD:
|
||||
|
||||
1. **Papier-mm-exakte Strichbreiten** überall — Bildschirm bei jedem Maßstab/Zoom = Druck.
|
||||
2. **Deterministische Plan-Ausgabe:** Druck/PDF mm-genau identisch zum Bildschirm, beides aus
|
||||
render2d (kein separater Druckpfad). Vektor-PDF-Backend = dieselbe Szene, anderes Target.
|
||||
3. **Headless-Rendering:** PNG/PDF-Export und Golden-Image-Tests ohne Fenster (die
|
||||
serde-only/GPU-Trennung in render2d existiert genau dafür; wgpu kann offscreen).
|
||||
4. **HLR/Schnitt-Pipelines** durch die Engine (render3d → Kantenextraktion → render2d-Szene).
|
||||
5. **Eine Wahrheit:** SVG-/WebGL2-Geometriepfade im Browser schrittweise durch die
|
||||
WASM/WebGPU-Engine ersetzen (`?engine=wasm`-Spike ist der Anfang); SVG bleibt nur
|
||||
Interaktions-/Overlay-Schicht. Bereits gefundene Duplikations-Bugs: Wandecken-Kerben,
|
||||
fehlende Strichelung im WebGL2-Pfad.
|
||||
|
||||
6. **WebKit-Unabhängigkeit** (explizit vom User betont): kein Render- oder Ausgabepfad darf
|
||||
von WebKitGTK abhängen. Brücke = Chromium-Shell (`npm run shell`), Endzustand = All-Native
|
||||
(ein Rust-Prozess, Engines + natives UI, kein Webview).
|
||||
|
||||
**RICHTUNGSENTSCHEIDUNG 2026-07-02: Electron statt Tauri, all-native aufgeschoben.**
|
||||
Abgewogen mit dem User: (a) „auf Tauri bleiben" = WebKitGTK, das WEDER WebGPU (kein WASM-
|
||||
Viewport) NOCH native-wgpu-Composite ins Fenster (Wayland tot) kann → Engine nur als separate
|
||||
Fenster möglich, kein integrierter Viewport. (b) „all-native jetzt" = kein Shortcut, sondern
|
||||
das GRÖSSERE Rewrite: ~46k Zeilen TS (UI ~18k + Modell-Gehirn ~5k + Interaktion ~23k) müssten
|
||||
nach Rust; die Rust-Engines sind erst ~10% der App. → **Gewählt: Electron/Chromium-Shell +
|
||||
WASM/WebGPU-Engine im Webview** (behält die funktionierende React-UI, verlässt WebKitGTK,
|
||||
integrierter Engine-Viewport möglich). Die Engine-Rust-Crates sind in einem späteren All-Native-
|
||||
Schritt 1:1 wiederverwendbar; nur die dünne WASM-Bindeschicht wäre dann Wegwerf. All-native
|
||||
bleibt legitimes Fernziel, ist aber KEIN Weg zurück zu Tauri (Tauri IST der Webview-Shell).
|
||||
|
||||
>>> ELECTRON-PROTOTYP: FERTIG (2026-07-02). <<<
|
||||
`electron` als devDep, `scripts/electron-main.cjs` (BrowserWindow, `Menu.setApplicationMenu(null)`
|
||||
+ `frame:false`/`autoHideMenuBar` für randloses Fenster wie chromium-shell.sh, WebGPU-Flags
|
||||
`enable-unsafe-webgpu`+`enable-features=Vulkan`), `scripts/electron-shell.sh` (Dev-Server-Guard
|
||||
wie chromium-shell.sh), npm-Script `npm run electron`. Verifiziert per Screenshot: App startet
|
||||
als eigenes randloses Fenster, lädt den echten Grundriss (nicht leer). WICHTIG:
|
||||
- **Datei muss `.cjs` sein** (`package.json` hat `"type":"module"`, Electron-Main läuft als CJS).
|
||||
- **`ELECTRON_RUN_AS_NODE=1`** kann aus dem umgebenden Prozess (z.B. VSCode-Electron-Host)
|
||||
vererbt sein — zwingt Electron in den Node-Modus (`require('electron')` liefert nur einen
|
||||
Pfad-String statt `{app,...}` → `app` ist undefined). Immer mit `env -u ELECTRON_RUN_AS_NODE`
|
||||
starten, falls das Fenster nicht öffnet / `app.commandLine` undefined ist.
|
||||
- **WebGPU/`?engine=wasm` funktioniert in Electron** — verifiziert per Screenshot (RENDERER-Anzeige
|
||||
zeigt "Engine" aktiv, Grundriss korrekt gezeichnet). Ein einzelner Fehlversuch vorher war ein
|
||||
GPU-Prozess-Init-Timing-Rennen (zu früh abgefragt), kein echter Bug — `app.getGPUFeatureStatus()`
|
||||
zeigt nach ~2s alles `enabled`/`enabled_on` inkl. `webgpu` und `vulkan` (AMD RX 7800 XT/RADV).
|
||||
KEINE Ozone-Platform-Flags nötig, `--ozone-platform=x11` NICHT verwenden (bricht die
|
||||
Fenstererstellung, Vulkan-Surface/GetGeometry-Fehler). `electron --version` meldet v24, weil
|
||||
`ELECTRON_RUN_AS_NODE=1` den Prozess in den Node-Modus zwingt und dann Nodes eigene Bundle-
|
||||
Version zeigt (kein echter Versions-Mismatch — `env -u ELECTRON_RUN_AS_NODE electron --version`
|
||||
meldet korrekt v43).
|
||||
Danach: render3d-WASM, Doppeltext-Fix.<<<
|
||||
Klarstellung an den User (er fragte, ob Webview = „billig"): NEIN — Figma (eigene C++/WASM-Engine
|
||||
im Web-Shell), Onshape, VS Code zeigen, dass „eigene Engine + Web-UI" Top-Tier-Architektur ist.
|
||||
CAD/BIM-Substanz = Modell + Engine + Normen (haben wir: parametrische Wände, SIA 416, DOSSIER-
|
||||
Ebenen, ein-Modell→alle-Sichten), NICHT der Fenster-Shell.
|
||||
|
||||
Praxisregel ab jetzt: Darstellungs-Features NICHT mehr mehrfach (SVG/WebGL2/nativ) bauen,
|
||||
sondern einmal in der Engine + dünne Anbindungen.
|
||||
|
||||
## >>> AUFGABE FÜR NEUE INSTANZ: 2D-Plan auf WebGL-GPU-Renderer (ZUERST) <<<
|
||||
|
||||
**Warum:** Tauri-Linux-Webview = **WebKitGTK**, das SVG/2D auf der **CPU (Cairo)** rastert.
|
||||
|
||||
Generated
+227
-1
@@ -27,11 +27,13 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.169.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"electron": "^43.0.0",
|
||||
"playwright": "^1.61.1",
|
||||
"puppeteer": "^25.2.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.9",
|
||||
"wasm-pack": "^0.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -325,6 +327,50 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz",
|
||||
"integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
|
||||
"integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^3.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^7.6.3",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"undici": "^7.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -750,6 +796,19 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1871,6 +1930,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
@@ -2206,6 +2275,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
|
||||
@@ -2355,6 +2434,25 @@
|
||||
"loglevel": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "43.0.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-43.0.0.tgz",
|
||||
"integrity": "sha512-PV60GsWU6qufhuOhw3n+Yix3WPDcqDtBqE8orbEQGQGHEkgp9o/JCPgb7L4vIL0r1HnfPdqSRtboOTqbDkcFDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-internal/extract-zip": "^1.0.1",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js",
|
||||
"install-electron": "install.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.380",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
||||
@@ -2369,6 +2467,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
@@ -2534,6 +2645,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
@@ -2986,6 +3104,29 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -3201,6 +3342,16 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer": {
|
||||
"version": "25.2.1",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.2.1.tgz",
|
||||
@@ -3543,6 +3694,19 @@
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
@@ -3577,6 +3741,33 @@
|
||||
"url": "https://github.com/fontello/svg2ttf?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.19",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
|
||||
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
@@ -3666,6 +3857,24 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -3968,6 +4177,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-pack": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-pack/-/wasm-pack-0.15.0.tgz",
|
||||
"integrity": "sha512-DdqtGWc3+iFx+7lL7QU5LBWs7qMnwQSWxF0htSfE15sNa3roVwHjAkTm2JXgueU2GGfSwNqbq2EzyC2b/biKDA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"tar": "^7.5.3"
|
||||
},
|
||||
"bin": {
|
||||
"wasm-pack": "run.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
|
||||
|
||||
+6
-2
@@ -13,7 +13,9 @@
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"dump:native": "node scripts/dump-native-scene.mjs",
|
||||
"shell": "scripts/chromium-shell.sh"
|
||||
"shell": "scripts/chromium-shell.sh",
|
||||
"electron": "scripts/electron-shell.sh",
|
||||
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
@@ -35,10 +37,12 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.169.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"electron": "^43.0.0",
|
||||
"playwright": "^1.61.1",
|
||||
"puppeteer": "^25.2.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.9",
|
||||
"wasm-pack": "^0.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +77,19 @@ trap cleanup EXIT
|
||||
|
||||
mkdir -p "${PROFILE_DIR}"
|
||||
|
||||
echo "chromium-shell: öffne ${URL} in ${BROWSER_BIN} (App-Modus) …"
|
||||
# WebGPU: Auf Linux liegt WebGPU in Chromium noch hinter Flags. Für den
|
||||
# render2d-WASM-Viewport (`?engine=wasm`, useWasmPlanRenderer) muss `navigator.gpu`
|
||||
# vorhanden sein — daher `--enable-unsafe-webgpu` (aktiviert die API auch ohne
|
||||
# volle Konformität) plus `--enable-features=Vulkan` (WebGPU nutzt unter Linux das
|
||||
# Vulkan-Backend). Ohne `?engine=wasm` sind die Flags folgenlos (der Standard-
|
||||
# WebGL2-Pfad läuft unverändert weiter).
|
||||
echo "chromium-shell: öffne ${URL} in ${BROWSER_BIN} (App-Modus, WebGPU aktiv) …"
|
||||
"${BROWSER_BIN}" \
|
||||
--app="${URL}" \
|
||||
--user-data-dir="${PROFILE_DIR}" \
|
||||
--window-size=1600,1000 \
|
||||
--enable-unsafe-webgpu \
|
||||
--enable-features=Vulkan \
|
||||
>/tmp/cad-chromium-shell-browser.log 2>&1
|
||||
|
||||
# Läuft weiter, bis der Browserprozess (das App-Fenster) beendet wird; dann
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// electron-main.js — Electron-Prototyp-Shell für die CAD-App.
|
||||
//
|
||||
// Ersetzt WebKitGTK (Tauri-Linux-Webview) durch Chromium, damit WebGPU
|
||||
// (render2d-WASM-Engine, ?engine=wasm) zuverlässig läuft. Kein Rust-Backend
|
||||
// nötig: compute_joins hat einen TS-Fallback (src/compute/index.ts).
|
||||
|
||||
const { app, BrowserWindow, Menu } = require("electron");
|
||||
|
||||
// Randloses App-Fenster wie chromium-shell.sh (--app=…): keine native
|
||||
// Menüleiste (File/Edit/View/Window), kein Fensterrahmen.
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
// Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux
|
||||
// hinter --enable-unsafe-webgpu, und WebGPU braucht das Vulkan-Backend.
|
||||
app.commandLine.appendSwitch("enable-unsafe-webgpu");
|
||||
app.commandLine.appendSwitch("enable-features", "Vulkan");
|
||||
|
||||
const DEV_URL = "http://localhost:5187";
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1600,
|
||||
height: 1000,
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL(DEV_URL);
|
||||
} else {
|
||||
win.loadFile(require("node:path").join(__dirname, "..", "dist", "index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# electron-shell.sh — startet die CAD-Oberfläche im Electron-Prototyp-Fenster.
|
||||
#
|
||||
# Wie chromium-shell.sh: stellt sicher, dass der Vite-Dev-Server läuft, startet
|
||||
# ihn sonst selbst und beendet ihn wieder, sobald das Electron-Fenster schliesst.
|
||||
|
||||
set -u
|
||||
|
||||
PORT=5187
|
||||
URL="http://localhost:${PORT}"
|
||||
|
||||
server_is_up() {
|
||||
curl --silent --fail --max-time 1 --output /dev/null "${URL}"
|
||||
}
|
||||
|
||||
DEV_SERVER_PID=""
|
||||
|
||||
if server_is_up; then
|
||||
echo "electron-shell: Dev-Server läuft bereits auf ${URL}."
|
||||
else
|
||||
echo "electron-shell: starte Vite-Dev-Server (Port ${PORT}) …"
|
||||
npm run dev >/tmp/cad-electron-shell-dev.log 2>&1 &
|
||||
DEV_SERVER_PID=$!
|
||||
|
||||
ATTEMPTS=0
|
||||
MAX_ATTEMPTS=60
|
||||
until server_is_up; do
|
||||
ATTEMPTS=$((ATTEMPTS + 1))
|
||||
if [ "${ATTEMPTS}" -ge "${MAX_ATTEMPTS}" ]; then
|
||||
echo "electron-shell: Dev-Server antwortet nach ${MAX_ATTEMPTS}s nicht, Abbruch." >&2
|
||||
kill "${DEV_SERVER_PID}" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "electron-shell: Dev-Server bereit."
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${DEV_SERVER_PID}" ]; then
|
||||
echo "electron-shell: beende selbst gestarteten Dev-Server (PID ${DEV_SERVER_PID}) …"
|
||||
kill "${DEV_SERVER_PID}" 2>/dev/null
|
||||
wait "${DEV_SERVER_PID}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "electron-shell: öffne Electron-Fenster …"
|
||||
npx electron "$(dirname "$0")/electron-main.cjs"
|
||||
Generated
+15
@@ -618,6 +618,16 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -3648,12 +3658,17 @@ name = "render2d"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"console_error_panic_hook",
|
||||
"env_logger",
|
||||
"glyphon",
|
||||
"js-sys",
|
||||
"naga",
|
||||
"pollster",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"wgpu",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,13 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Nativer wgpu-2D-Renderer fuer den CAD-Grundriss (Tessellierung + Pipelines)"
|
||||
|
||||
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul benoetigt. rlib:
|
||||
# damit die Crate weiterhin als Pfad-Abhaengigkeit (cad-tauri, Feature native2d)
|
||||
# und im Test-/Bin-Build (spike) nutzbar bleibt. Der cdylib-Artefakt-Build auf
|
||||
# nativen Zielen ist harmlos (leere Export-Oberflaeche ohne Feature "web").
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# Bewusst NICHT Teil des src-tauri-Workspaces: die Tessellierungs-Bibliothek soll
|
||||
# unabhaengig von Tauri baubar/testbar bleiben (headless, ohne Webview-Toolchain).
|
||||
# Ausschluss laeuft ueber `exclude = ["render2d"]` im uebergeordneten
|
||||
@@ -31,8 +38,25 @@ render = ["dep:wgpu", "dep:bytemuck", "dep:pollster", "dep:glyphon"]
|
||||
# Display-Session zur visuellen Verifikation.
|
||||
window = ["render", "dep:winit", "dep:env_logger"]
|
||||
|
||||
# Browser-Bindings (wasm32 + WebGPU): dieselbe GPU-Schicht (`render`) hinter einer
|
||||
# wasm-bindgen-Fassade (`web.rs`), die eine HTML-Canvas als wgpu-Surface bespielt.
|
||||
# Baut NUR fuer target wasm32-unknown-unknown sinnvoll (wgpu waehlt dort das
|
||||
# WebGPU-Backend). Die native Fensterschicht (`window`) bleibt davon unberuehrt.
|
||||
web = [
|
||||
"render",
|
||||
"dep:wasm-bindgen",
|
||||
"dep:wasm-bindgen-futures",
|
||||
"dep:web-sys",
|
||||
"dep:js-sys",
|
||||
"dep:console_error_panic_hook",
|
||||
"dep:serde_json",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# JSON-Szene (dieselbe Ableitung wie der native Push): im Web-Pfad zur Laufzeit
|
||||
# geparst; optional, nur mit Feature "web".
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
# --- GPU (nur mit Feature "render") ------------------------------------------
|
||||
# Standard-Features von wgpu (wgsl, webgpu, dx12, metal). Die Linux-Backends
|
||||
@@ -49,6 +73,20 @@ glyphon = { version = "0.6", optional = true }
|
||||
winit = { version = "0.30", optional = true }
|
||||
env_logger = { version = "0.11", optional = true }
|
||||
|
||||
# --- Browser/WASM (nur mit Feature "web") ------------------------------------
|
||||
# wasm-bindgen-Fassade + Canvas-Zugriff. Versionen bewusst breit ("0.2"/"0.3"),
|
||||
# damit wasm-pack die passende wasm-bindgen-CLI selbst zieht.
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
wasm-bindgen-futures = { version = "0.4", optional = true }
|
||||
js-sys = { version = "0.3", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
web-sys = { version = "0.3", optional = true, features = [
|
||||
"HtmlCanvasElement",
|
||||
"Window",
|
||||
"Document",
|
||||
"console",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
# naga validiert die WGSL-Quellen headless (Parser + Validator) im Test, ohne
|
||||
|
||||
Binary file not shown.
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
|
||||
use render2d::gpu::Renderer;
|
||||
use render2d::types::ViewBox;
|
||||
use render2d::{demo_scene, initial_view_box, meet_scale, PX_PER_M};
|
||||
use render2d::{demo_scene, initial_view_box};
|
||||
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
@@ -74,9 +74,7 @@ impl GpuState {
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let mut renderer = Renderer::new(&device, format);
|
||||
let view_box = initial_view_box();
|
||||
let px_per_m = PX_PER_M * meet_scale(view_box, config.width as f32, config.height as f32);
|
||||
renderer.upload_scene(&device, &demo_scene(), px_per_m);
|
||||
renderer.upload_scene(&device, &demo_scene());
|
||||
|
||||
Self {
|
||||
surface,
|
||||
|
||||
+233
-43
@@ -18,8 +18,8 @@ use bytemuck::{Pod, Zeroable};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::ortho::{compute_ortho_matrix, corrected_view_box, meet_scale, mm_to_device_px, Mat4};
|
||||
use crate::shaders::{FILL_WGSL, LINE_WGSL};
|
||||
use crate::tessellate::{compile_scene_scaled, to_screen, GpuGeometry, PX_PER_M};
|
||||
use crate::shaders::{ARC_WGSL, FILL_WGSL, LINE_WGSL};
|
||||
use crate::tessellate::{compile_scene, to_screen, ArcInstanceData, GpuGeometry, MAX_ARC_DASH};
|
||||
use crate::types::{Scene, Text, TextAlign, ViewBox};
|
||||
|
||||
/// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser).
|
||||
@@ -49,6 +49,49 @@ impl Default for Globals {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame-Uniform der analytischen Bogen-Pipeline (1:1 zu `ArcGlobals` in WGSL).
|
||||
/// EIN Block je Frame (fuer ALLE Boegen gleich) — anders als die per-Batch-
|
||||
/// `Globals` braucht er keinen dynamischen Offset. std140: mat4 + vec2 + 2 Skalare
|
||||
/// = 80 Byte (16-Byte-Vielfaches).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct ArcGlobals {
|
||||
view_proj: [f32; 16],
|
||||
viewport_px: [f32; 2],
|
||||
px_per_screen: f32,
|
||||
stroke_scale: f32,
|
||||
}
|
||||
|
||||
/// EINE Bogen-Instanz fuer die GPU (Vertex-Puffer, step_mode Instance), 1:1 zum
|
||||
/// Instanz-Layout in `ARC_WGSL`. Zoom-invariant: Bildschirm-Raum-Geometrie +
|
||||
/// Dash in Modell-Metern. 18 f32 = 72 Byte.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct ArcInstance {
|
||||
center: [f32; 2],
|
||||
/// (r_screen, a0, sweep, r_model)
|
||||
geom: [f32; 4],
|
||||
color: [f32; 4],
|
||||
/// (width_mm, dash_total, dash_count, _pad)
|
||||
wdash: [f32; 4],
|
||||
/// bis zu MAX_ARC_DASH An/Aus-Laengen (Modell-Meter).
|
||||
dash: [f32; 4],
|
||||
}
|
||||
|
||||
impl ArcInstance {
|
||||
fn from_data(a: &ArcInstanceData) -> Self {
|
||||
// MAX_ARC_DASH ist 4 (== vec4 im Shader); Compile-Time abgesichert.
|
||||
const _: () = assert!(MAX_ARC_DASH == 4);
|
||||
Self {
|
||||
center: a.center,
|
||||
geom: [a.r_screen, a.a0, a.sweep, a.r_model],
|
||||
color: a.color,
|
||||
wdash: [a.width_mm, a.dash_total, a.dash_count as f32, 0.0],
|
||||
dash: a.dash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rundet `size` auf das naechste Vielfache von `align` (>=1) auf.
|
||||
fn align_up(size: u64, align: u64) -> u64 {
|
||||
if align <= 1 {
|
||||
@@ -63,6 +106,10 @@ struct SceneBuffers {
|
||||
fill_ibo: Option<wgpu::Buffer>,
|
||||
line_vbo: Option<wgpu::Buffer>,
|
||||
line_ibo: Option<wgpu::Buffer>,
|
||||
/// Instanz-Puffer der analytischen Boegen (je Bogen EINE Instanz); None wenn leer.
|
||||
arc_vbo: Option<wgpu::Buffer>,
|
||||
/// Anzahl Bogen-Instanzen (Draw: 6 Vertices je Instanz).
|
||||
arc_count: u32,
|
||||
geo: GpuGeometry,
|
||||
/// Textzeilen der Szene (Modell-Anker + Papier-mm-Groesse). Werden nicht
|
||||
/// tesselliert, sondern pro Frame ueber den Glyphen-Atlas gesetzt (die
|
||||
@@ -83,8 +130,10 @@ struct TextState {
|
||||
|
||||
/// Schrift des Plans: dieselbe Familie wie der Browser-Text (`--font` in
|
||||
/// styles.css beginnt mit "Inter"); cosmic-text laedt die Systemfonts und
|
||||
/// faellt bei fehlender Familie auf sans-serif zurueck.
|
||||
const TEXT_FAMILY: &str = "Inter";
|
||||
/// faellt bei fehlender Familie auf sans-serif zurueck. Standardwert des
|
||||
/// `Renderer.text_family`-Feldes — im Web-Pfad (kein System-Font-Zugriff) wird
|
||||
/// er auf die Familie der eingebetteten Font-Bytes umgestellt (siehe `load_font`).
|
||||
const DEFAULT_TEXT_FAMILY: &str = "Inter";
|
||||
|
||||
/// Der native 2D-Renderer: haelt beide Pipelines, den (dynamischen) Uniform-
|
||||
/// Puffer und die aktuell hochgeladene Szene. Pan/Zoom aktualisiert nur die
|
||||
@@ -93,16 +142,16 @@ pub struct Renderer {
|
||||
fill_pipeline: wgpu::RenderPipeline,
|
||||
line_pipeline: wgpu::RenderPipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// Analytische Bogen-Pipeline (exakter Kreis-Shader, `ARC_WGSL`).
|
||||
arc_pipeline: wgpu::RenderPipeline,
|
||||
/// Frame-Uniform der Bogen-Pipeline (ein Block, kein dynamischer Offset).
|
||||
arc_uniform: wgpu::Buffer,
|
||||
arc_bind_group: wgpu::BindGroup,
|
||||
/// Ausgerichtete Groesse eines Globals-Blocks im dynamischen Uniform-Puffer.
|
||||
uniform_stride: u64,
|
||||
/// Aktuell allozierter Uniform-Puffer + zugehoerige Bind-Group (wachsen bei Bedarf).
|
||||
uniform: Option<UniformArena>,
|
||||
scene: Option<SceneBuffers>,
|
||||
/// Die zuletzt hochgeladene Szene (fuer Re-Tessellierung bei Zoomaenderung,
|
||||
/// siehe `maybe_retessellate` — nur relevant, wenn die Szene Boegen enthaelt).
|
||||
last_scene: Option<Scene>,
|
||||
/// Geraete-px je Modell-Meter, mit dem `last_scene` zuletzt tessellliert wurde.
|
||||
arc_px_per_m: f32,
|
||||
/// Farbformat der Ziel-Surface (auch Format der MSAA-Textur).
|
||||
format: wgpu::TextureFormat,
|
||||
/// Multisample-Farbtextur (4x), lazily an die Ziel-Groesse gebunden.
|
||||
@@ -111,6 +160,12 @@ pub struct Renderer {
|
||||
msaa_size: (u32, u32),
|
||||
/// Glyphen-Textpass (lazily beim ersten Frame mit Texten erstellt).
|
||||
text: Option<TextState>,
|
||||
/// Schriftfamilie fuer die Textzeilen (Default `DEFAULT_TEXT_FAMILY`). Wird im
|
||||
/// Web-Pfad auf die Familie der eingebetteten Font-Bytes umgestellt.
|
||||
text_family: String,
|
||||
/// Noch nicht in die FontSystem-DB geladene Font-Bytes (Web-Pfad: es gibt
|
||||
/// keine Systemfonts). Werden beim ersten `ensure_text` eingespielt.
|
||||
pending_font: Option<Vec<u8>>,
|
||||
/// Loeschfarbe (Zeichenblatt), Default hell wie im WebGL-Pfad (#f0f0f0).
|
||||
pub clear_color: wgpu::Color,
|
||||
/// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten.
|
||||
@@ -242,6 +297,91 @@ impl Renderer {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// ── Analytische Bogen-Pipeline (exakter Kreis-Shader) ─────────────────
|
||||
let arc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("arc.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(ARC_WGSL.into()),
|
||||
});
|
||||
// Eigenes Layout: EIN Uniform-Block je Frame (kein dynamischer Offset).
|
||||
let arc_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("arc.globals.layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(
|
||||
std::mem::size_of::<ArcGlobals>() as u64,
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let arc_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("arc.pipeline.layout"),
|
||||
bind_group_layouts: &[&arc_bind_group_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
// Instanz-Layout: [center vec2, geom vec4, color vec4, wdash vec4, dash vec4],
|
||||
// stride 18*4, step_mode Instance. Das Quad kommt aus @builtin(vertex_index).
|
||||
let arc_instance_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: 18 * 4,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &wgpu::vertex_attr_array![
|
||||
0 => Float32x2, 1 => Float32x4, 2 => Float32x4, 3 => Float32x4, 4 => Float32x4
|
||||
],
|
||||
};
|
||||
let arc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("arc.pipeline"),
|
||||
layout: Some(&arc_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &arc_module,
|
||||
entry_point: "vs_main",
|
||||
buffers: &[arc_instance_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &arc_module,
|
||||
entry_point: "fs_main",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: SAMPLE_COUNT,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
let arc_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("arc.globals.buffer"),
|
||||
size: std::mem::size_of::<ArcGlobals>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let arc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("arc.globals.bind"),
|
||||
layout: &arc_bind_group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: arc_uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
// Block-Stride = Globals auf die Dynamic-Offset-Ausrichtung des Geraets gepolstert.
|
||||
let min_align = device.limits().min_uniform_buffer_offset_alignment as u64;
|
||||
let uniform_stride = align_up(std::mem::size_of::<Globals>() as u64, min_align.max(1));
|
||||
@@ -250,15 +390,18 @@ impl Renderer {
|
||||
fill_pipeline,
|
||||
line_pipeline,
|
||||
bind_group_layout,
|
||||
arc_pipeline,
|
||||
arc_uniform,
|
||||
arc_bind_group,
|
||||
uniform_stride,
|
||||
uniform: None,
|
||||
scene: None,
|
||||
last_scene: None,
|
||||
arc_px_per_m: PX_PER_M,
|
||||
format: color_format,
|
||||
msaa_view: None,
|
||||
msaa_size: (0, 0),
|
||||
text: None,
|
||||
text_family: DEFAULT_TEXT_FAMILY.to_string(),
|
||||
pending_font: None,
|
||||
// #f0f0f0 (== --sheet). 0.941 entspricht dem WebGL-clearColor.
|
||||
clear_color: wgpu::Color {
|
||||
r: 0.941,
|
||||
@@ -270,12 +413,13 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellliert eine Szene und laedt die Puffer hoch. `px_per_m` sind die
|
||||
/// aktuellen Geraete-px je Modell-Meter (treibt nur die Bogen-Adaptivitaet,
|
||||
/// siehe `tessellate::tessellate_arc`); wird gemerkt, damit `render` bei
|
||||
/// Zoomaenderung automatisch neu tessellliert (`maybe_retessellate`).
|
||||
pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene, px_per_m: f32) {
|
||||
let geo = compile_scene_scaled(scene, px_per_m);
|
||||
/// Kompiliert eine Szene und laedt die GPU-Puffer hoch. Alles ist zoom-
|
||||
/// invariant — Papier-mm-Striche wie auch die analytischen Boegen werden erst
|
||||
/// im Shader auf Geraete-px abgebildet. Daher KEIN Zoom-Parameter und keine
|
||||
/// Neu-Tessellierung bei Zoomaenderung mehr (der frueher noetige Bogen-Re-Tess-
|
||||
/// Trigger entfaellt: der Kreis-Shader rendert bei jeder Skala exakt rund).
|
||||
pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene) {
|
||||
let geo = compile_scene(scene);
|
||||
|
||||
let mk_vbo = |data: &[f32], label: &str| -> Option<wgpu::Buffer> {
|
||||
if data.is_empty() {
|
||||
@@ -298,37 +442,37 @@ impl Renderer {
|
||||
}))
|
||||
};
|
||||
|
||||
// Bogen-Instanzen (je Bogen EINE) in einen Instanz-Vertexpuffer packen.
|
||||
let arc_instances: Vec<ArcInstance> =
|
||||
geo.arcs.iter().map(ArcInstance::from_data).collect();
|
||||
let arc_count = arc_instances.len() as u32;
|
||||
let arc_vbo = if arc_instances.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("arc.instances"),
|
||||
contents: bytemuck::cast_slice(&arc_instances),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
}))
|
||||
};
|
||||
|
||||
self.scene = Some(SceneBuffers {
|
||||
fill_vbo: mk_vbo(&geo.fill_pos, "fill.vbo"),
|
||||
fill_ibo: mk_ibo(&geo.fill_idx, "fill.ibo"),
|
||||
line_vbo: mk_vbo(&geo.line_verts, "line.vbo"),
|
||||
line_ibo: mk_ibo(&geo.line_idx, "line.ibo"),
|
||||
arc_vbo,
|
||||
arc_count,
|
||||
geo,
|
||||
texts: scene.texts.clone(),
|
||||
});
|
||||
self.last_scene = Some(scene.clone());
|
||||
self.arc_px_per_m = px_per_m;
|
||||
}
|
||||
|
||||
/// Tessellliert die zuletzt hochgeladene Szene neu, wenn sich der Zoom seit
|
||||
/// dem letzten Upload um mehr als den Faktor 1.3 veraendert hat UND die
|
||||
/// Szene ueberhaupt Boegen enthaelt (bei anderen Primitiven ist Zoom-
|
||||
/// invariant, kein Re-Tessellieren noetig). Haelt Boegen bei jeder
|
||||
/// Zoomstufe glatt, ohne jeden Frame neu zu tessellieren (Szenen sind
|
||||
/// klein, das Re-Tessellieren selbst ist billig — nur nicht JEDEN Frame).
|
||||
fn maybe_retessellate(&mut self, device: &wgpu::Device, view_box: ViewBox, viewport: (u32, u32)) {
|
||||
let Some(scene) = &self.last_scene else {
|
||||
return;
|
||||
};
|
||||
if scene.arcs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let px_per_m = PX_PER_M * meet_scale(view_box, viewport.0 as f32, viewport.1 as f32);
|
||||
let ratio = px_per_m / self.arc_px_per_m.max(1e-6);
|
||||
if ratio > 1.3 || ratio < 1.0 / 1.3 {
|
||||
let scene = scene.clone();
|
||||
self.upload_scene(device, &scene, px_per_m);
|
||||
}
|
||||
/// Reicht Font-Bytes (TrueType/OpenType) nach, die beim ersten `ensure_text`
|
||||
/// in die FontSystem-DB geladen werden. Noetig im Web-Pfad, wo cosmic-text
|
||||
/// KEINE Systemfonts findet — ohne das bliebe der Grundriss textlos.
|
||||
pub fn load_font(&mut self, bytes: Vec<u8>) {
|
||||
self.pending_font = Some(bytes);
|
||||
}
|
||||
|
||||
/// Erstellt den Glyphen-Textpass beim ersten Bedarf (FontSystem laedt die
|
||||
@@ -339,6 +483,20 @@ impl Renderer {
|
||||
if self.text.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut font_system = glyphon::FontSystem::new();
|
||||
// Nachgereichte Font-Bytes (Web-Pfad) in die DB laden und die tatsaechlich
|
||||
// registrierte Familie als Text-Familie uebernehmen (die Inter-Variable-
|
||||
// Datei meldet sich z.B. als "Inter Variable", nicht "Inter").
|
||||
if let Some(bytes) = self.pending_font.take() {
|
||||
let db = font_system.db_mut();
|
||||
let before: Vec<_> = db.faces().map(|f| f.id).collect();
|
||||
db.load_font_data(bytes);
|
||||
if let Some(face) = db.faces().find(|f| !before.contains(&f.id)) {
|
||||
if let Some((name, _)) = face.families.first() {
|
||||
self.text_family = name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let cache = glyphon::Cache::new(device);
|
||||
let viewport = glyphon::Viewport::new(device, &cache);
|
||||
let mut atlas = glyphon::TextAtlas::new(device, queue, &cache, self.format);
|
||||
@@ -353,7 +511,7 @@ impl Renderer {
|
||||
None,
|
||||
);
|
||||
self.text = Some(TextState {
|
||||
font_system: glyphon::FontSystem::new(),
|
||||
font_system,
|
||||
swash_cache: glyphon::SwashCache::new(),
|
||||
viewport,
|
||||
atlas,
|
||||
@@ -431,11 +589,25 @@ impl Renderer {
|
||||
view_box: ViewBox,
|
||||
viewport: (u32, u32),
|
||||
) {
|
||||
self.maybe_retessellate(device, view_box, viewport);
|
||||
|
||||
let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
|
||||
let proj: Mat4 = compute_ortho_matrix(view_box, vw, vh);
|
||||
let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);
|
||||
// Geraete-px je Bildschirm-Einheit (== meet-Skala) — der Bogen-Shader braucht
|
||||
// beides: px_per_screen fuer Radial-/Kappen-/Dash-AA, stroke_scale fuer die
|
||||
// Papier-mm-Breite (identisch zu den Linien).
|
||||
let px_per_screen = meet_scale(view_box, vw, vh);
|
||||
|
||||
// Frame-Uniform der Bogen-Pipeline schreiben (ein Block fuer ALLE Boegen).
|
||||
queue.write_buffer(
|
||||
&self.arc_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&ArcGlobals {
|
||||
view_proj: proj,
|
||||
viewport_px: [vw, vh],
|
||||
px_per_screen,
|
||||
stroke_scale: mm_px,
|
||||
}),
|
||||
);
|
||||
|
||||
// Alle Globals-Bloecke der Reihenfolge nach (erst Fuell-, dann Linien-Batches)
|
||||
// sammeln, den Puffer einmal schreiben, danach nur noch dynamisch binden.
|
||||
@@ -474,6 +646,9 @@ impl Renderer {
|
||||
.map_or(false, |s| !s.texts.is_empty());
|
||||
if has_texts || self.text.is_some() {
|
||||
self.ensure_text(device, queue);
|
||||
// Familie VOR dem mutablen Borrow von self.text kopieren (ensure_text
|
||||
// hat sie ggf. auf die geladene Font-Familie umgestellt).
|
||||
let family = self.text_family.clone();
|
||||
let ts = self.text.as_mut().expect("ensure_text");
|
||||
let cvb = corrected_view_box(view_box, vw, vh);
|
||||
let meet = meet_scale(view_box, vw, vh);
|
||||
@@ -488,7 +663,7 @@ impl Renderer {
|
||||
let mut placed: Vec<Placed> = Vec::new();
|
||||
if let Some(scene) = &self.scene {
|
||||
let attrs = glyphon::Attrs::new()
|
||||
.family(glyphon::Family::Name(TEXT_FAMILY));
|
||||
.family(glyphon::Family::Name(&family));
|
||||
for t in &scene.texts {
|
||||
let font_px = t.size_mm * mm_px;
|
||||
// Unlesbar kleine Glyphen ueberspringen (spart Shaping/Atlas).
|
||||
@@ -650,7 +825,22 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit
|
||||
// 3) Analytische Boegen: eigene Pipeline, EIN Frame-Uniform, je Bogen
|
||||
// EINE Instanz (6 Vertices, Quad aus vertex_index). Mathematisch
|
||||
// exakt rund per SDF — nach den Linien, damit der Schwenkbogen ueber
|
||||
// dem Tuerblatt liegt.
|
||||
if let Some(scene) = &self.scene {
|
||||
if let Some(arc_vbo) = &scene.arc_vbo {
|
||||
if scene.arc_count > 0 {
|
||||
pass.set_pipeline(&self.arc_pipeline);
|
||||
pass.set_bind_group(0, &self.arc_bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, arc_vbo.slice(..));
|
||||
pass.draw(0..6, 0..scene.arc_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit
|
||||
// identischem MultisampleState erstellt, siehe ensure_text).
|
||||
if let Some(ts) = &self.text {
|
||||
if let Err(e) = ts.renderer.render(&ts.atlas, &ts.viewport, &mut pass) {
|
||||
|
||||
@@ -21,9 +21,18 @@ pub mod types;
|
||||
#[cfg(feature = "render")]
|
||||
pub mod gpu;
|
||||
|
||||
// Browser-Bindings (wasm32 + WebGPU), nur mit Feature "web". Setzt "render" voraus
|
||||
// (dieselbe GPU-Schicht wie das native Fenster), fuegt aber KEINE winit-Abhaengigkeit
|
||||
// hinzu — die Canvas dient direkt als wgpu-Surface.
|
||||
#[cfg(feature = "web")]
|
||||
pub mod web;
|
||||
|
||||
pub use demo::{demo_scene, initial_view_box};
|
||||
pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
|
||||
pub use tessellate::{compile_scene, compile_scene_scaled, triangulate, GpuGeometry, PX_PER_M};
|
||||
pub use tessellate::{
|
||||
arc_screen_params, compile_scene, prepare_arc_dash, triangulate, ArcInstanceData, GpuGeometry,
|
||||
MAX_ARC_DASH, PX_PER_M,
|
||||
};
|
||||
pub use types::{Arc, FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox};
|
||||
|
||||
// --- Tests: Tessellierung (Muster wie glPlanCompile.test.ts) -----------------
|
||||
@@ -163,6 +172,7 @@ mod tests {
|
||||
for (name, src) in [
|
||||
("fill", super::shaders::FILL_WGSL),
|
||||
("line", super::shaders::LINE_WGSL),
|
||||
("arc", super::shaders::ARC_WGSL),
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("{name}: WGSL-Parse-Fehler: {e:?}"));
|
||||
|
||||
@@ -101,3 +101,164 @@ fn fs_main() -> @location(0) vec4<f32> {
|
||||
return globals.color;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Analytische BOGEN-Pipeline: rendert einen Kreisbogen mathematisch exakt (SDF im
|
||||
/// Fragment-Shader) statt als Segment-Kette — bei jedem Zoom ein "richtiger" Kreis,
|
||||
/// nie ein Vieleck.
|
||||
///
|
||||
/// Ein Bogen = EINE Instanz + EIN Quad (6 Vertices, aus `vertex_index` erzeugt).
|
||||
/// Das Quad ist die Bounding-Box des Bogens im BILDSCHIRM-Raum, im Vertex-Shader
|
||||
/// aus der aktuellen px-Skala aufgespannt (Radius + halbe Strichbreite + AA-Rand),
|
||||
/// sodass es bei jedem Zoom passt — OHNE Neu-Tessellierung.
|
||||
///
|
||||
/// Instanz-Layout (alles zoom-invariant, in Bildschirm-Raum bzw. Modell-Metern):
|
||||
/// @location(0) center : vec2 Mittelpunkt (Bildschirm-Raum)
|
||||
/// @location(1) geom : vec4 (r_screen, a0, sweep, r_model)
|
||||
/// @location(2) color : vec4 RGBA
|
||||
/// @location(3) wdash : vec4 (width_mm, dash_total[m], dash_count, _pad)
|
||||
/// @location(4) dash : vec4 bis zu MAX_ARC_DASH(=4) An/Aus-Laengen (Modell-m)
|
||||
///
|
||||
/// Frame-Uniform (`ArcGlobals`): view_proj, viewport_px, px_per_screen (== meet-
|
||||
/// Skala, Geraete-px je Bildschirm-Einheit) und stroke_scale (mm -> Geraete-px,
|
||||
/// dieselbe Formel wie die Linien, `ortho::mm_to_device_px`).
|
||||
///
|
||||
/// Fragment: `d = abs(length(p-center) - r)` gibt den Ring-Abstand; die Kante wird
|
||||
/// analytisch per `smoothstep` (~device-px) geglaettet (MSAA glaettet zusaetzlich).
|
||||
/// Der Winkel wird gegen [0, sweep] geklemmt (beide Sweep-Vorzeichen, sauberer
|
||||
/// Wrap) mit Butt-Cap an den Enden. Dash: Bogenlaenge s = theta_rel * r_model
|
||||
/// (Modell-Meter!) modulo Muster, weicher An/Aus-Uebergang.
|
||||
pub const ARC_WGSL: &str = r#"
|
||||
struct ArcGlobals {
|
||||
view_proj : mat4x4<f32>,
|
||||
viewport_px : vec2<f32>,
|
||||
px_per_screen : f32,
|
||||
stroke_scale : f32,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> g : ArcGlobals;
|
||||
|
||||
const PI : f32 = 3.14159265358979;
|
||||
// Bildschirm-Einheiten je Modell-Meter (== tessellate::PX_PER_M). Fest, weil die
|
||||
// Instanz-Geometrie bereits in Bildschirm-Raum vorliegt (to_screen skaliert *90).
|
||||
const PX_PER_M_2D : f32 = 90.0;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos : vec4<f32>,
|
||||
@location(0) frag : vec2<f32>,
|
||||
@location(1) @interpolate(flat) center : vec2<f32>,
|
||||
@location(2) @interpolate(flat) geom : vec4<f32>,
|
||||
@location(3) @interpolate(flat) color : vec4<f32>,
|
||||
@location(4) @interpolate(flat) wdash : vec4<f32>,
|
||||
@location(5) @interpolate(flat) dash : vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vidx : u32,
|
||||
@location(0) center : vec2<f32>,
|
||||
@location(1) geom : vec4<f32>,
|
||||
@location(2) color : vec4<f32>,
|
||||
@location(3) wdash : vec4<f32>,
|
||||
@location(4) dash : vec4<f32>,
|
||||
) -> VsOut {
|
||||
// Zwei Dreiecke, Ecken in {-1,+1}^2.
|
||||
var corners = array<vec2<f32>, 6>(
|
||||
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, -1.0), vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, 1.0), vec2<f32>(-1.0, 1.0),
|
||||
);
|
||||
let corner = corners[vidx];
|
||||
|
||||
let r_s = geom.x;
|
||||
// Echte Papierbreite (Geraete-px) -> zurueck in Bildschirm-Einheiten fuer die
|
||||
// Quad-Groesse; plus AA-Rand (~2 px). px_per_screen gegen 0 sichern.
|
||||
let pps = max(g.px_per_screen, 1e-6);
|
||||
let width_px = max(0.6, wdash.x * g.stroke_scale);
|
||||
let half_w_screen = 0.5 * width_px / pps;
|
||||
let aa_screen = 2.0 / pps;
|
||||
let ext = r_s + half_w_screen + aa_screen;
|
||||
|
||||
let p_screen = center + corner * ext;
|
||||
|
||||
var out : VsOut;
|
||||
out.pos = g.view_proj * vec4<f32>(p_screen, 0.0, 1.0);
|
||||
out.frag = p_screen;
|
||||
out.center = center;
|
||||
out.geom = geom;
|
||||
out.color = color;
|
||||
out.wdash = wdash;
|
||||
out.dash = dash;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
let center = in.center;
|
||||
let r_s = in.geom.x;
|
||||
let a0 = in.geom.y;
|
||||
let sweep = in.geom.z;
|
||||
let r_m = in.geom.w;
|
||||
let width_mm = in.wdash.x;
|
||||
let dash_total = in.wdash.y;
|
||||
let dash_count = i32(in.wdash.z + 0.5);
|
||||
let pps = max(g.px_per_screen, 1e-6);
|
||||
|
||||
let rel = in.frag - center;
|
||||
let dist = length(rel);
|
||||
|
||||
// 1) Radiale Kante (Strichbreite quer zum Bogen), analytisch antialiased.
|
||||
let d_ring_px = abs(dist - r_s) * pps;
|
||||
let half_w_px = 0.5 * max(0.6, width_mm * g.stroke_scale);
|
||||
let cov_radial = 1.0 - smoothstep(half_w_px - 0.6, half_w_px + 0.6, d_ring_px);
|
||||
|
||||
// 2) Winkel-Clamp auf [min(0,sweep), max(0,sweep)] mit Butt-Cap an den Enden.
|
||||
let theta = atan2(rel.y, rel.x);
|
||||
var da = theta - a0;
|
||||
da = da - 2.0 * PI * round(da / (2.0 * PI)); // Wrap nach (-pi, pi]
|
||||
let lo = min(0.0, sweep);
|
||||
let hi = max(0.0, sweep);
|
||||
var sd : f32;
|
||||
if (da < lo) {
|
||||
sd = lo - da;
|
||||
} else if (da > hi) {
|
||||
sd = da - hi;
|
||||
} else {
|
||||
sd = -min(da - lo, hi - da);
|
||||
}
|
||||
let cap_px = sd * r_s * pps; // signierter Abstand zur Kappe (Geraete-px)
|
||||
let cov_cap = 1.0 - smoothstep(-0.5, 0.5, cap_px);
|
||||
|
||||
// 3) Dash: Bogenlaenge ab a0 (Modell-Meter) modulo Muster, weicher Uebergang.
|
||||
var cov_dash = 1.0;
|
||||
if (dash_count > 0 && dash_total > 1e-9) {
|
||||
var progress = da;
|
||||
if (sweep < 0.0) { progress = -da; }
|
||||
progress = max(progress, 0.0);
|
||||
let s_model = progress * r_m;
|
||||
let m = s_model - dash_total * floor(s_model / dash_total);
|
||||
|
||||
var cyc = array<f32, 4>(in.dash.x, in.dash.y, in.dash.z, in.dash.w);
|
||||
var acc = 0.0;
|
||||
var cur_on = true;
|
||||
var edge = dash_total;
|
||||
for (var i = 0; i < 4; i = i + 1) {
|
||||
if (i >= dash_count) { break; }
|
||||
let seg = cyc[i];
|
||||
if (m >= acc && m < acc + seg) {
|
||||
cur_on = (i % 2) == 0; // gerade Segmente = "an"
|
||||
edge = min(m - acc, acc + seg - m); // Abstand zur naechsten Grenze
|
||||
}
|
||||
acc = acc + seg;
|
||||
}
|
||||
let px_per_m = PX_PER_M_2D * pps;
|
||||
let edge_px = edge * px_per_m;
|
||||
// Signierter Abstand: innen "an" positiv, innen "aus" negativ.
|
||||
let sdist = select(-edge_px, edge_px, cur_on);
|
||||
cov_dash = smoothstep(-0.5, 0.5, sdist);
|
||||
}
|
||||
|
||||
let a = in.color.a * cov_radial * cov_cap * cov_dash;
|
||||
if (a <= 0.002) {
|
||||
discard;
|
||||
}
|
||||
return vec4<f32>(in.color.rgb, a);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -27,44 +27,96 @@ pub fn to_screen(p: Point) -> Point {
|
||||
[p[0] * PX_PER_M, -p[1] * PX_PER_M]
|
||||
}
|
||||
|
||||
/// Mindest-Sehnenabweichung (Sagitta) in Geraete-px, unter der ein Kreisbogen als
|
||||
/// glatt gilt. Kleiner -> mehr Segmente bei gleichem Radius/Zoom.
|
||||
const ARC_SAGITTA_TOL_PX: f32 = 0.3;
|
||||
/// Hoechstzahl Dash-Muster-Eintraege je Bogen (An/Aus-Laengen), die der analytische
|
||||
/// Bogen-Shader modulo rechnet. Deckt die real vorkommenden Muster (max. EIN An/Aus-
|
||||
/// Paar, z.B. Tuerschwenk [0.06,0.04]) mit Reserve; laengere Muster werden gekappt.
|
||||
pub const MAX_ARC_DASH: usize = 4;
|
||||
|
||||
/// Kreisbogen (kuerzerer Sweep von `from` nach `to` um `center`, wie die alte
|
||||
/// JS-`tessellateArc`) zoomabhaengig in eine offene Punktfolge (Modell-Meter)
|
||||
/// zerlegen. Winkelschritt so klein, dass die Sehnen-Abweichung (Sagitta) bei der
|
||||
/// aktuellen Bildschirm-Skala `px_per_m` (Geraete-px je Modell-Meter) unter
|
||||
/// `ARC_SAGITTA_TOL_PX` bleibt: sagitta = r*(1-cos(dtheta/2)) <= tol
|
||||
/// => dtheta <= 2*acos(1 - tol/r_px), r_px = r*px_per_m. Segmentzahl auf 8..512
|
||||
/// geklemmt (nie zu grob, nie unnoetig fein). Erster/letzter Punkt werden EXAKT
|
||||
/// auf `from`/`to` gesetzt (kein Trig-Rundungsfehler an den Enden).
|
||||
pub(crate) fn tessellate_arc(center: Point, from: Point, to: Point, r: f32, px_per_m: f32) -> Vec<Point> {
|
||||
let a0 = (from[1] - center[1]).atan2(from[0] - center[0]);
|
||||
let a1_raw = (to[1] - center[1]).atan2(to[0] - center[0]);
|
||||
// Kuerzeren Bogen waehlen (die Szene liefert keine largeArc-Info mit) — 1:1
|
||||
// die Winkel-Normalisierung der alten JS-`tessellateArc`.
|
||||
let mut delta = a1_raw - a0;
|
||||
/// GPU-fertige Beschreibung EINES analytisch (im Fragment-Shader) gezeichneten
|
||||
/// Bogens — bereits in BILDSCHIRM-Raum-Parametern, damit ihn die GPU pro Frame
|
||||
/// mathematisch exakt rund rendert (SDF), ohne Segment-Tessellierung. Alle Felder
|
||||
/// sind zoom-INVARIANT (haengen nicht von der aktuellen Skala ab): `center`/
|
||||
/// `r_screen` in Bildschirm-Einheiten, Winkel im Bildschirm-Raum, `dash` in
|
||||
/// Modell-Metern. Die Strichbreite (`width_mm`) mappt der Shader pro Frame mit
|
||||
/// derselben Formel wie die Linien (`ortho::mm_to_device_px`) auf Geraete-px.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArcInstanceData {
|
||||
/// Mittelpunkt in Bildschirm-Raum-Einheiten (`to_screen(center)`).
|
||||
pub center: Point,
|
||||
/// Radius in Bildschirm-Raum-Einheiten (`r * PX_PER_M`).
|
||||
pub r_screen: f32,
|
||||
/// Radius in MODELL-Metern (fuer die Dash-Bogenlaenge).
|
||||
pub r_model: f32,
|
||||
/// Start-Winkel im Bildschirm-Raum (atan2 relativ `center`).
|
||||
pub a0: f32,
|
||||
/// Vorzeichenbehafteter Sweep (kuerzerer Bogen, |sweep| <= pi).
|
||||
pub sweep: f32,
|
||||
/// Strichfarbe (RGBA 0..1).
|
||||
pub color: Rgba,
|
||||
/// Strichbreite in echten Papier-Millimetern.
|
||||
pub width_mm: f32,
|
||||
/// Dash-Zyklus in MODELL-Metern (An/Aus-Laengen), ggf. verdoppelt bei ungerader
|
||||
/// Laenge (Standard-Dash-Semantik, wie `split_dash`). Nur `dash_count` gueltig.
|
||||
pub dash: [f32; MAX_ARC_DASH],
|
||||
/// Anzahl gueltiger `dash`-Eintraege; 0 = durchgezogen.
|
||||
pub dash_count: u32,
|
||||
/// Summe der gueltigen `dash`-Eintraege (Zykluslaenge, Modell-Meter).
|
||||
pub dash_total: f32,
|
||||
}
|
||||
|
||||
/// Bildschirm-Raum-Parameter eines Bogens: Mittelpunkt + Radius (Bildschirm-
|
||||
/// Einheiten) sowie Start- und Sweep-Winkel IM BILDSCHIRM-RAUM. Der Sweep ist der
|
||||
/// KUERZERE Bogen (|sweep| <= pi) — exakt die Winkel-Normalisierung der frueheren
|
||||
/// `tessellate_arc` (dort im Modell-Raum). Die Bildschirm-Y-Spiegelung (`to_screen`)
|
||||
/// negiert lediglich beide Winkel; der BETRAG von `sweep` bleibt identisch, sodass
|
||||
/// weiterhin derselbe (kuerzere) Bogen entsteht.
|
||||
pub fn arc_screen_params(center: Point, from: Point, to: Point, r: f32) -> (Point, f32, f32, f32) {
|
||||
let cs = to_screen(center);
|
||||
let fs = to_screen(from);
|
||||
let ts = to_screen(to);
|
||||
let a0 = (fs[1] - cs[1]).atan2(fs[0] - cs[0]);
|
||||
let a1 = (ts[1] - cs[1]).atan2(ts[0] - cs[0]);
|
||||
let mut delta = a1 - a0;
|
||||
while delta > std::f32::consts::PI {
|
||||
delta -= 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
while delta < -std::f32::consts::PI {
|
||||
delta += 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
|
||||
let r_px = (r * px_per_m).max(1e-6);
|
||||
let ratio = (1.0 - (ARC_SAGITTA_TOL_PX / r_px)).clamp(-1.0, 1.0);
|
||||
let max_dtheta = (2.0 * ratio.acos()).max(1e-4);
|
||||
let segs = ((delta.abs() / max_dtheta).ceil() as u32).clamp(8, 512);
|
||||
|
||||
let mut pts = Vec::with_capacity(segs as usize + 1);
|
||||
pts.push(from);
|
||||
for i in 1..segs {
|
||||
let t = a0 + delta * (i as f32) / (segs as f32);
|
||||
pts.push([center[0] + t.cos() * r, center[1] + t.sin() * r]);
|
||||
(cs, r * PX_PER_M, a0, delta)
|
||||
}
|
||||
pts.push(to);
|
||||
pts
|
||||
|
||||
/// Wandelt ein Papier-mm-Dash-Muster in den Modell-Meter-Zyklus, den der Bogen-
|
||||
/// Shader modulo rechnet — IDENTISCHE Semantik zu `split_dash` (`DASH_MM_TO_M`-
|
||||
/// Referenz, Verdopplung bei ungerader Musterlaenge). Auf `MAX_ARC_DASH` Eintraege
|
||||
/// begrenzt; laengere Muster (kommen real nicht vor) werden gekappt. Rueckgabe:
|
||||
/// (Zyklus, Anzahl, Gesamtlaenge); Anzahl 0 = durchgezogen.
|
||||
pub fn prepare_arc_dash(dash: Option<&[f32]>) -> ([f32; MAX_ARC_DASH], u32, f32) {
|
||||
let mut out = [0.0f32; MAX_ARC_DASH];
|
||||
let Some(d) = dash else {
|
||||
return (out, 0, 0.0);
|
||||
};
|
||||
let pat: Vec<f32> = d
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|v| v.max(0.0) * DASH_MM_TO_M)
|
||||
.filter(|v| *v > 0.0)
|
||||
.collect();
|
||||
if pat.is_empty() {
|
||||
return (out, 0, 0.0);
|
||||
}
|
||||
let cycle: Vec<f32> = if pat.len() % 2 == 0 {
|
||||
pat
|
||||
} else {
|
||||
[pat.as_slice(), pat.as_slice()].concat()
|
||||
};
|
||||
let n = cycle.len().min(MAX_ARC_DASH);
|
||||
let mut total = 0.0f32;
|
||||
for (i, v) in cycle.iter().take(n).enumerate() {
|
||||
out[i] = *v;
|
||||
total += *v;
|
||||
}
|
||||
(out, n as u32, total)
|
||||
}
|
||||
|
||||
/// Faktor Papier-mm -> Modell-Meter fuer ALLGEMEINE Linien-/Bogen-Striche, bei
|
||||
@@ -330,6 +382,9 @@ pub struct GpuGeometry {
|
||||
pub line_verts: Vec<f32>,
|
||||
pub line_idx: Vec<u32>,
|
||||
pub line_batches: Vec<LineBatch>,
|
||||
/// Analytisch gezeichnete Boegen (je EINE Instanz, kein Segment-Mesh mehr):
|
||||
/// die GPU rendert sie pro Frame exakt rund per SDF-Fragment-Shader.
|
||||
pub arcs: Vec<ArcInstanceData>,
|
||||
/// Modell-Bounds (Meter) fuer Debug/Einpassen; nicht render-kritisch.
|
||||
pub bounds: [f32; 4], // [min_x, min_y, max_x, max_y]
|
||||
}
|
||||
@@ -518,22 +573,13 @@ impl Bounds {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellliert eine ganze Szene zu GPU-Geometrie beim Standard-Massstab
|
||||
/// (`PX_PER_M`, kein Zoom beruecksichtigt) — Kompatibilitaets-Wrapper fuer
|
||||
/// bestehende Aufrufer (Tests, Demo, Fenster-Spike), die keinen aktuellen
|
||||
/// Geraete-px-je-Meter-Wert kennen. Der eigentliche (zoomabhaengige) Renderpfad
|
||||
/// nutzt `compile_scene_scaled` direkt (siehe `gpu::Renderer::upload_scene`).
|
||||
/// Kompiliert eine ganze Szene zu GPU-Geometrie: gefuellte Polygone +
|
||||
/// Papier-mm-Striche (tesselliert) + analytische Boegen (je EINE Instanz).
|
||||
/// Reihenfolge: Fuellungen -> Umrisse -> offene Polylinien -> freie Linien; die
|
||||
/// Boegen sammelt `geo.arcs` (eigene Pipeline, gezeichnet nach den Linien). ALLES
|
||||
/// ist zoom-INVARIANT: Papier-mm-Striche wie auch die Bogen-Kanten werden erst im
|
||||
/// Shader auf Geraete-px abgebildet — keine Neu-Tessellierung bei Zoom noetig.
|
||||
pub fn compile_scene(scene: &Scene) -> GpuGeometry {
|
||||
compile_scene_scaled(scene, PX_PER_M)
|
||||
}
|
||||
|
||||
/// Tessellliert eine ganze Szene zu GPU-Geometrie (gefuellte Polygone +
|
||||
/// Papier-mm-Striche). Reihenfolge: Fuellungen -> Umrisse -> offene Polylinien
|
||||
/// -> Boegen -> freie Linien (siehe `Scene`-Doc-Kommentar). `px_per_m` sind die
|
||||
/// aktuellen Geraete-px je Modell-Meter (aus der laufenden Zoom-Stufe) — treibt
|
||||
/// NUR die Bogen-Adaptivitaet (`tessellate_arc`); alles andere ist Zoom-invariant
|
||||
/// (Papier-mm-Striche werden erst im Shader auf Geraete-px abgebildet).
|
||||
pub fn compile_scene_scaled(scene: &Scene, px_per_m: f32) -> GpuGeometry {
|
||||
let mut geo = GpuGeometry::default();
|
||||
let mut bounds = Bounds::new();
|
||||
|
||||
@@ -556,15 +602,16 @@ pub fn compile_scene_scaled(scene: &Scene, px_per_m: f32) -> GpuGeometry {
|
||||
stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.dash.as_deref(), &mut bounds);
|
||||
}
|
||||
}
|
||||
// 4) Kreisboegen: zoomabhaengig in eine Punktfolge zerlegt (`tessellate_arc`),
|
||||
// dann wie eine offene Polylinie gestrichen (mit oder ohne Dash).
|
||||
for a in &scene.arcs {
|
||||
compile_arc(&mut geo, a, px_per_m, &mut bounds);
|
||||
}
|
||||
// 5) Freie Einzel-Linien (Tuerblaetter, Referenzlinien).
|
||||
// 4) Freie Einzel-Linien (Tuerblaetter, Referenzlinien).
|
||||
for l in &scene.lines {
|
||||
compile_line(&mut geo, l, &mut bounds);
|
||||
}
|
||||
// 5) Kreisboegen: NICHT mehr in Segmente zerlegt — je Bogen eine analytische
|
||||
// Instanz (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt
|
||||
// rund rendert (siehe `gpu::Renderer` Arc-Pipeline).
|
||||
for a in &scene.arcs {
|
||||
compile_arc(&mut geo, a, &mut bounds);
|
||||
}
|
||||
|
||||
geo.bounds = bounds.finish();
|
||||
geo
|
||||
@@ -589,12 +636,33 @@ fn compile_fill(geo: &mut GpuGeometry, poly: &FillPolygon, bounds: &mut Bounds)
|
||||
geo.add_fill_batch(count, poly.color);
|
||||
}
|
||||
|
||||
fn compile_arc(geo: &mut GpuGeometry, a: &Arc, px_per_m: f32, bounds: &mut Bounds) {
|
||||
if a.width_mm <= 0.0 {
|
||||
fn compile_arc(geo: &mut GpuGeometry, a: &Arc, bounds: &mut Bounds) {
|
||||
if a.width_mm <= 0.0 || a.r <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let pts = tessellate_arc(a.center, a.from, a.to, a.r, px_per_m);
|
||||
stroke_dashed_or_solid(geo, &pts, false, a.color, a.width_mm, a.dash.as_deref(), bounds);
|
||||
let (center, r_screen, a0, sweep) = arc_screen_params(a.center, a.from, a.to, a.r);
|
||||
// Degenerierter Bogen (from == to -> Sweep ~ 0): nichts zu zeichnen. Echte
|
||||
// Vollkreise kommen als geschlossenes Polygon (siehe generatePlan `circle`).
|
||||
if sweep.abs() < 1e-6 {
|
||||
return;
|
||||
}
|
||||
let (dash, dash_count, dash_total) = prepare_arc_dash(a.dash.as_deref());
|
||||
geo.arcs.push(ArcInstanceData {
|
||||
center,
|
||||
r_screen,
|
||||
r_model: a.r,
|
||||
a0,
|
||||
sweep,
|
||||
color: a.color,
|
||||
width_mm: a.width_mm,
|
||||
dash,
|
||||
dash_count,
|
||||
dash_total,
|
||||
});
|
||||
// Modell-Bounds: Bounding-Box des vollen Kreises (grosszuegig, aber sicher
|
||||
// fuer "Einpassen"; der sichtbare Bogen liegt stets darin).
|
||||
bounds.track([a.center[0] - a.r, a.center[1] - a.r]);
|
||||
bounds.track([a.center[0] + a.r, a.center[1] + a.r]);
|
||||
}
|
||||
|
||||
fn compile_line(geo: &mut GpuGeometry, l: &Line, bounds: &mut Bounds) {
|
||||
@@ -607,44 +675,80 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::types::{Arc, Outline};
|
||||
|
||||
// --- tessellate_arc: adaptive Bogen-Zerlegung ----------------------------
|
||||
// --- arc_screen_params: analytische Bogen-Parameter ----------------------
|
||||
|
||||
#[test]
|
||||
fn arc_endpunkte_exakt() {
|
||||
let center = [1.0, 1.0];
|
||||
let from = [2.0, 1.0]; // r=1, Winkel 0
|
||||
let to = [1.0, 2.0]; // Winkel 90 Grad
|
||||
let pts = tessellate_arc(center, from, to, 1.0, 100.0);
|
||||
assert_eq!(*pts.first().unwrap(), from, "erster Punkt exakt = from");
|
||||
assert_eq!(*pts.last().unwrap(), to, "letzter Punkt exakt = to");
|
||||
/// Modell-Raum-Sweep (kuerzerer Bogen) — Referenz fuer die Winkel-Paritaet
|
||||
/// (frueher die Winkellogik der `tessellate_arc`). Die Bildschirm-Y-Spiegelung
|
||||
/// aendert nur das Vorzeichen, nicht den Betrag.
|
||||
fn model_delta(center: Point, from: Point, to: Point) -> f32 {
|
||||
let a0 = (from[1] - center[1]).atan2(from[0] - center[0]);
|
||||
let a1 = (to[1] - center[1]).atan2(to[0] - center[0]);
|
||||
let mut d = a1 - a0;
|
||||
while d > std::f32::consts::PI {
|
||||
d -= 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
while d < -std::f32::consts::PI {
|
||||
d += 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_segmentzahl_waechst_mit_px_per_m() {
|
||||
fn arc_screen_params_endpunkte_treffen_exakt() {
|
||||
let center = [1.0, 1.0];
|
||||
let from = [2.0, 1.0]; // r=1, Modell-Winkel 0
|
||||
let to = [1.0, 2.0]; // Modell-Winkel 90 Grad
|
||||
let (cs, r_s, a0, sweep) = arc_screen_params(center, from, to, 1.0);
|
||||
// Punkt bei a0 == to_screen(from), Punkt bei a0+sweep == to_screen(to).
|
||||
let p0 = [cs[0] + a0.cos() * r_s, cs[1] + a0.sin() * r_s];
|
||||
let p1 = [cs[0] + (a0 + sweep).cos() * r_s, cs[1] + (a0 + sweep).sin() * r_s];
|
||||
let fs = to_screen(from);
|
||||
let ts = to_screen(to);
|
||||
assert!((p0[0] - fs[0]).abs() < 1e-3 && (p0[1] - fs[1]).abs() < 1e-3, "Start trifft from");
|
||||
assert!((p1[0] - ts[0]).abs() < 1e-3 && (p1[1] - ts[1]).abs() < 1e-3, "Ende trifft to");
|
||||
assert!((r_s - PX_PER_M).abs() < 1e-3, "r_screen = r * PX_PER_M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_screen_params_kuerzerer_bogen_paritaet() {
|
||||
let center = [0.0, 0.0];
|
||||
let from = [1.0, 0.0];
|
||||
let to = [0.0, 1.0]; // Viertelkreis, r=1
|
||||
let low = tessellate_arc(center, from, to, 1.0, 10.0);
|
||||
let high = tessellate_arc(center, from, to, 1.0, 1000.0);
|
||||
let segs_low = low.len() - 1;
|
||||
let segs_high = high.len() - 1;
|
||||
// Ziel bei Modell-Winkel 200 Grad -> naiv 200 Grad, kuerzer aber -160 Grad.
|
||||
let ang = 200.0_f32.to_radians();
|
||||
let to = [ang.cos(), ang.sin()];
|
||||
let (_, _, _, sweep) = arc_screen_params(center, from, to, 1.0);
|
||||
assert!(sweep.abs() <= std::f32::consts::PI + 1e-4, "kuerzerer Bogen (|sweep|<=pi)");
|
||||
// Betrag identisch zur Modell-Raum-Winkellogik (Y-Spiegelung nur Vorzeichen).
|
||||
assert!(
|
||||
segs_high > segs_low,
|
||||
"mehr Segmente bei hoeherem Zoom erwartet: {segs_low} vs {segs_high}"
|
||||
(sweep.abs() - model_delta(center, from, to).abs()).abs() < 1e-4,
|
||||
"Sweep-Betrag == Modell-Delta-Betrag (Paritaet zur alten tessellate_arc-Logik)"
|
||||
);
|
||||
}
|
||||
|
||||
// --- prepare_arc_dash: Papier-mm -> Modell-Meter-Zyklus -------------------
|
||||
|
||||
#[test]
|
||||
fn arc_segmentzahl_geklemmt_min_max() {
|
||||
let center = [0.0, 0.0];
|
||||
let from = [1.0, 0.0];
|
||||
let to = [0.0, 1.0];
|
||||
// Sehr grober Fall (winziger Radius/Zoom) -> trotzdem mindestens 8 Segmente.
|
||||
let coarse = tessellate_arc(center, from, to, 0.001, 1.0);
|
||||
assert!(coarse.len() - 1 >= 8, "min. 8 Segmente erwartet");
|
||||
// Extremer Zoom -> nie mehr als 512 Segmente.
|
||||
let fine = tessellate_arc(center, from, to, 1.0, 1.0e9);
|
||||
assert!(fine.len() - 1 <= 512, "max. 512 Segmente erwartet");
|
||||
fn prepare_arc_dash_tuerschwenk_mapping() {
|
||||
// Tuerschwenk-Muster [0.06, 0.04] mm -> * DASH_MM_TO_M(0.1) = [0.006, 0.004] m.
|
||||
let (cyc, n, total) = prepare_arc_dash(Some(&[0.06, 0.04]));
|
||||
assert_eq!(n, 2, "ein An/Aus-Paar");
|
||||
assert!((cyc[0] - 0.006).abs() < 1e-6 && (cyc[1] - 0.004).abs() < 1e-6);
|
||||
assert!((total - 0.010).abs() < 1e-6, "Zykluslaenge 0.01 m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_arc_dash_ungerade_wird_verdoppelt() {
|
||||
// Einzelwert -> Standard-Dash-Semantik: verdoppelt auf [x, x].
|
||||
let (cyc, n, total) = prepare_arc_dash(Some(&[10.0]));
|
||||
assert_eq!(n, 2, "ungerade Laenge wird verdoppelt");
|
||||
assert!((cyc[0] - 1.0).abs() < 1e-6 && (cyc[1] - 1.0).abs() < 1e-6);
|
||||
assert!((total - 2.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_arc_dash_leer_ist_durchgezogen() {
|
||||
assert_eq!(prepare_arc_dash(None).1, 0);
|
||||
assert_eq!(prepare_arc_dash(Some(&[])).1, 0);
|
||||
}
|
||||
|
||||
// --- split_dash: geometrische Strichmuster-Zerlegung ---------------------
|
||||
@@ -675,10 +779,10 @@ mod tests {
|
||||
assert_eq!(pieces[0], pts.to_vec());
|
||||
}
|
||||
|
||||
// --- compile_scene_scaled: End-zu-End-Verdrahtung -------------------------
|
||||
// --- compile_scene: End-zu-End-Verdrahtung --------------------------------
|
||||
|
||||
#[test]
|
||||
fn compile_scene_mit_bogen_erzeugt_liniengeometrie() {
|
||||
fn compile_scene_mit_bogen_erzeugt_arc_instanz() {
|
||||
let scene = Scene {
|
||||
arcs: vec![Arc {
|
||||
center: [0.0, 0.0],
|
||||
@@ -691,9 +795,15 @@ mod tests {
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let geo = compile_scene_scaled(&scene, PX_PER_M);
|
||||
assert!(!geo.line_verts.is_empty(), "Bogen sollte Linien-Vertices erzeugen");
|
||||
assert!(!geo.line_idx.is_empty(), "Bogen sollte Linien-Indizes erzeugen");
|
||||
let geo = compile_scene(&scene);
|
||||
// Analytisch: EINE Instanz, KEINE tessellierte Liniengeometrie.
|
||||
assert_eq!(geo.arcs.len(), 1, "genau eine Bogen-Instanz");
|
||||
assert!(geo.line_verts.is_empty(), "Bogen erzeugt keine Linien-Vertices mehr");
|
||||
let a = &geo.arcs[0];
|
||||
assert_eq!(a.dash_count, 0, "durchgezogen");
|
||||
assert!((a.r_screen - PX_PER_M).abs() < 1e-3);
|
||||
// Bounds decken den vollen Kreis (center +- r).
|
||||
assert!((geo.bounds[0] + 1.0).abs() < 1e-3 && (geo.bounds[2] - 1.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -717,8 +827,8 @@ mod tests {
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let geo_solid = compile_scene_scaled(&solid, PX_PER_M);
|
||||
let geo_dashed = compile_scene_scaled(&dashed, PX_PER_M);
|
||||
let geo_solid = compile_scene(&solid);
|
||||
let geo_dashed = compile_scene(&dashed);
|
||||
assert!(!geo_solid.line_idx.is_empty());
|
||||
assert!(!geo_dashed.line_idx.is_empty());
|
||||
assert_ne!(
|
||||
@@ -728,15 +838,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Gesamtlaenge eines offenen Punktzugs (Modell-Meter).
|
||||
fn polyline_len(pts: &[Point]) -> f32 {
|
||||
pts.windows(2)
|
||||
.map(|w| ((w[1][0] - w[0][0]).powi(2) + (w[1][1] - w[0][1]).powi(2)).sqrt())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gestrichelter_tuerschwenk_bogen_kein_panic_und_kuerzere_deckung() {
|
||||
fn gestrichelter_tuerschwenk_bogen_erzeugt_dash_instanz() {
|
||||
// Spiegelt den echten Anwendungsfall (Tuerschwenk-Boegen, dash 0.06/0.04mm).
|
||||
let center = [0.0, 0.0];
|
||||
let from = [1.0, 0.0];
|
||||
@@ -754,20 +857,14 @@ mod tests {
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
// Rundlauf durch die volle Pipeline: darf nicht paniken, muss Geometrie liefern.
|
||||
let geo = compile_scene_scaled(&scene, PX_PER_M);
|
||||
assert!(!geo.line_idx.is_empty(), "gestrichelter Bogen sollte trotzdem Geometrie erzeugen");
|
||||
|
||||
// Geometrische Invariante an den Bausteinen selbst: die Summe der "an"-
|
||||
// Stuecke deckt WENIGER Bogenlaenge ab als der volle (durchgezogene) Bogen.
|
||||
let arc_pts = tessellate_arc(center, from, to, 1.0, PX_PER_M);
|
||||
let full_len = polyline_len(&arc_pts);
|
||||
let pieces = split_dash(&arc_pts, &dash);
|
||||
assert!(pieces.len() > 1, "sollte in mehrere an-Stuecke gesplittet werden");
|
||||
let on_len: f32 = pieces.iter().map(|p| polyline_len(p)).sum();
|
||||
assert!(
|
||||
on_len < full_len,
|
||||
"gestrichelte Deckung ({on_len}) sollte kuerzer als der volle Bogen ({full_len}) sein"
|
||||
);
|
||||
// Rundlauf durch die volle Pipeline: eine analytische, GESTRICHELTE Instanz.
|
||||
let geo = compile_scene(&scene);
|
||||
assert_eq!(geo.arcs.len(), 1, "genau eine Bogen-Instanz");
|
||||
let a = &geo.arcs[0];
|
||||
assert_eq!(a.dash_count, 2, "ein An/Aus-Paar (dash-Zyklus)");
|
||||
assert!((a.dash_total - 0.010).abs() < 1e-6, "Zykluslaenge 0.01 m");
|
||||
// Sweep = Viertelkreis (Betrag pi/2), Radius exakt.
|
||||
assert!((a.sweep.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-4);
|
||||
assert!((a.r_screen - PX_PER_M).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +80,10 @@ pub struct Polyline {
|
||||
}
|
||||
|
||||
/// Ein Kreisbogen (kuerzerer Sweep von `from` nach `to` um `center`) in Modell-
|
||||
/// Metern — NICHT vortessellliert (anders als frueher, wo der Web-Renderer den
|
||||
/// Bogen einmalig in eine feste Facettenzahl zerlegte). Die Zerlegung passiert
|
||||
/// erst in `tessellate::tessellate_arc`, zoomabhaengig, damit der Bogen bei
|
||||
/// jeder Vergroesserung glatt bleibt statt sichtbare Facetten zu zeigen.
|
||||
/// Metern — MATHEMATISCH EXAKT gerendert: `compile_scene` verdichtet ihn nicht zu
|
||||
/// Segmenten, sondern zu EINER analytischen Instanz (`tessellate::ArcInstanceData`),
|
||||
/// die die GPU per SDF-Fragment-Shader (`shaders::ARC_WGSL`) bei JEDER Zoomstufe als
|
||||
/// echten Kreis zeichnet — kein Vieleck, keine Neu-Tessellierung.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Arc {
|
||||
/// Mittelpunkt in Modell-Metern.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Browser-Bindings (wasm32 + WebGPU): fuehrt die native GPU-Schicht (`gpu::Renderer`)
|
||||
// als WASM-Modul in die App-UI, mit einer HTML-Canvas als wgpu-Surface. Bewusst
|
||||
// duenn — die gesamte Zeichenlogik (Tessellierung, Pipelines, Text) ist mit dem
|
||||
// nativen Fenster geteilt; hier liegt nur die Canvas-/WebGPU-Anbindung + die
|
||||
// wasm-bindgen-Fassade.
|
||||
//
|
||||
// Ablauf im Frontend (siehe src/plan/useWasmPlanRenderer.ts):
|
||||
// const r = await WebPlanRenderer.new(canvas, undefined);
|
||||
// r.set_scene(JSON.stringify(planToRenderScene(plan)));
|
||||
// r.set_view_box(x, y, w, h); r.resize(px_w, px_h); r.render();
|
||||
//
|
||||
// Szenenformat = dieselbe serde-Struct wie der native Push (render2d::types::Scene),
|
||||
// als JSON-String hereingereicht. Pan/Zoom aendern nur die ViewBox (kein Re-Tess).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::HtmlCanvasElement;
|
||||
|
||||
use crate::gpu::Renderer;
|
||||
use crate::types::{Scene, ViewBox};
|
||||
|
||||
/// Eingebettete Plan-Schrift (Inter). cosmic-text findet auf wasm KEINE
|
||||
/// Systemfonts — ohne eingebettete Bytes bliebe der Grundriss textlos. Die
|
||||
/// Familie meldet sich als "Inter Variable"; `Renderer::load_font` uebernimmt
|
||||
/// den tatsaechlichen Namen automatisch.
|
||||
const INTER_TTF: &[u8] = include_bytes!("../assets/Inter.ttf");
|
||||
|
||||
/// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer`
|
||||
/// und die aktuelle ViewBox. Wird per `WebPlanRenderer.new(canvas)` aus JS erzeugt.
|
||||
#[wasm_bindgen]
|
||||
pub struct WebPlanRenderer {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
renderer: Renderer,
|
||||
view_box: ViewBox,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WebPlanRenderer {
|
||||
/// Erzeugt den Renderer asynchron (Adapter/Device via WebGPU). `font_bytes`
|
||||
/// optional — ohne Angabe wird die eingebettete Inter-Schrift genutzt.
|
||||
/// Aus JS: `await WebPlanRenderer.new(canvasEl, undefined)`.
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub async fn new(
|
||||
canvas: HtmlCanvasElement,
|
||||
font_bytes: Option<Vec<u8>>,
|
||||
) -> Result<WebPlanRenderer, JsValue> {
|
||||
// Panics als lesbare Konsolen-Fehler statt "unreachable" ausgeben.
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
let width = canvas.width().max(1);
|
||||
let height = canvas.height().max(1);
|
||||
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::BROWSER_WEBGPU,
|
||||
..Default::default()
|
||||
});
|
||||
let surface = instance
|
||||
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))
|
||||
.map_err(|e| JsValue::from_str(&format!("create_surface: {e:?}")))?;
|
||||
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter: false,
|
||||
compatible_surface: Some(&surface),
|
||||
})
|
||||
.await
|
||||
.ok_or_else(|| JsValue::from_str("kein WebGPU-Adapter (navigator.gpu?)"))?;
|
||||
|
||||
let (device, queue) = adapter
|
||||
.request_device(
|
||||
&wgpu::DeviceDescriptor {
|
||||
label: Some("render2d.web.device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::downlevel_webgl2_defaults()
|
||||
.using_resolution(adapter.limits()),
|
||||
memory_hints: wgpu::MemoryHints::Performance,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| JsValue::from_str(&format!("request_device: {e:?}")))?;
|
||||
|
||||
// WebGPU-Canvas liefert i.d.R. bgra8unorm (kein sRGB-Format fuer die
|
||||
// Surface). Wir nehmen das erste angebotene Format — MSAA-Textur nutzt
|
||||
// dasselbe (siehe Renderer::ensure_msaa).
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
let format = caps
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| !f.is_srgb())
|
||||
.unwrap_or(caps.formats[0]);
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let mut renderer = Renderer::new(&device, format);
|
||||
// Font einspielen: uebergebene Bytes ODER die eingebettete Inter-Schrift.
|
||||
renderer.load_font(font_bytes.unwrap_or_else(|| INTER_TTF.to_vec()));
|
||||
|
||||
web_sys::console::log_1(&JsValue::from_str(&format!(
|
||||
"render2d WebGPU-Viewport initialisiert ({}x{}, {:?})",
|
||||
width, height, format
|
||||
)));
|
||||
|
||||
Ok(WebPlanRenderer {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
renderer,
|
||||
// Platzhalter bis set_view_box; verhindert Division durch 0.
|
||||
view_box: ViewBox::new(0.0, 0.0, width as f32, height as f32),
|
||||
})
|
||||
}
|
||||
|
||||
/// Setzt die zu zeichnende Szene aus einem JSON-String (Format =
|
||||
/// render2d::types::Scene, identisch zum nativen Push). Re-tesselliert + laedt
|
||||
/// die GPU-Puffer; ruft NICHT selbst `render` — das macht der Aufrufer.
|
||||
pub fn set_scene(&mut self, json: &str) -> Result<(), JsValue> {
|
||||
let scene: Scene = serde_json::from_str(json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Szene parsen: {e}")))?;
|
||||
self.renderer.upload_scene(&self.device, &scene);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Aktuellen Ausschnitt (viewBox-Einheiten, Bildschirm-Raum) setzen. Aendert
|
||||
/// nur die Ortho-Matrix im naechsten `render` (kein Re-Tessellieren).
|
||||
pub fn set_view_box(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||||
self.view_box = ViewBox::new(x, y, w, h);
|
||||
}
|
||||
|
||||
/// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten setzen.
|
||||
pub fn set_paper_scale(&mut self, n: f32) {
|
||||
if n > 0.0 {
|
||||
self.renderer.paper_scale_n = n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
let (w, h) = (width.max(1), height.max(1));
|
||||
if self.config.width == w && self.config.height == h {
|
||||
return;
|
||||
}
|
||||
self.config.width = w;
|
||||
self.config.height = h;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
|
||||
/// Zeichnet EINEN Frame in die Surface-Textur.
|
||||
pub fn render(&mut self) {
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
Ok(f) => f,
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::warn_1(&JsValue::from_str(&format!(
|
||||
"render2d Surface-Fehler: {e:?}"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let view = frame
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
self.renderer.render(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&view,
|
||||
self.view_box,
|
||||
(self.config.width, self.config.height),
|
||||
);
|
||||
frame.present();
|
||||
}
|
||||
}
|
||||
@@ -124,8 +124,8 @@ impl GpuState2d {
|
||||
fn new(window: Arc<Window>, scene: &Scene, view_box: ViewBox) -> Self {
|
||||
let (surface, device, queue, config) = configure_surface(&window, "2d.device");
|
||||
let mut renderer = Renderer2d::new(&device, config.format);
|
||||
let px_per_m = PX_PER_M * meet_scale(view_box, config.width as f32, config.height as f32);
|
||||
renderer.upload_scene(&device, scene, px_per_m);
|
||||
let _ = view_box; // Szene-Upload ist zoom-invariant (Boegen analytisch im Shader).
|
||||
renderer.upload_scene(&device, scene);
|
||||
Self { surface, device, queue, config, renderer, window }
|
||||
}
|
||||
|
||||
@@ -696,9 +696,8 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
self.pending2d = Some(scene);
|
||||
return;
|
||||
};
|
||||
let vb = *self.view_box.get_or_insert_with(initial_view_box);
|
||||
let px_per_m = PX_PER_M * meet_scale(vb, state.config.width as f32, state.config.height as f32);
|
||||
state.renderer.upload_scene(&state.device, &scene, px_per_m);
|
||||
let _ = self.view_box.get_or_insert_with(initial_view_box);
|
||||
state.renderer.upload_scene(&state.device, &scene);
|
||||
if !self.nav2d {
|
||||
self.view_box = Some(scene_view_box(&scene));
|
||||
}
|
||||
|
||||
+26
-1
@@ -65,7 +65,7 @@ import { TextEditorDialog } from "./ui/TextEditorDialog";
|
||||
import { RoomStampEditor } from "./panels/RoomStampEditor";
|
||||
import { defaultRoomStamp } from "./model/roomStamp";
|
||||
import type { DetailLevel, RenderMode, View3d, ViewType } from "./ui/TopBar";
|
||||
import { StatusBar } from "./ui/StatusBar";
|
||||
import { StatusBar, type RendererMode } from "./ui/StatusBar";
|
||||
import { getTool } from "./tools/tools";
|
||||
import { applyAngleConstraint, computeSnap } from "./tools/snapping";
|
||||
import {
|
||||
@@ -2187,6 +2187,28 @@ export default function App() {
|
||||
// aktiv. Sonst sind Cursor/Zoom bedeutungslos (PlanView ist nicht montiert →
|
||||
// das Handle ist null, Aktionen sind No-Ops).
|
||||
const planActive = activeLevel.kind === "floor" && viewType === "grundriss";
|
||||
// Plan-Renderer-Umschalter (Statusleiste): reines Ableiten/Setzen des `engine`-
|
||||
// URL-Params (siehe PlanView.tsx `wantWasm`); die Renderer-Auswahl selbst bleibt
|
||||
// dort. Ein Wechsel lädt die Seite neu — kein Live-Remount der Renderer-Hooks.
|
||||
// Persistiert in localStorage, damit die Wahl einen Neustart ohne URL-Param
|
||||
// übersteht (z.B. die Electron-App startet immer auf der Basis-URL).
|
||||
const RENDERER_STORAGE_KEY = "cad.rendererMode";
|
||||
const rendererMode: RendererMode = (() => {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get("engine");
|
||||
if (fromUrl === "wasm") return "wasm";
|
||||
if (fromUrl === "webgl2") return "webgl2";
|
||||
return window.localStorage.getItem(RENDERER_STORAGE_KEY) === "wasm"
|
||||
? "wasm"
|
||||
: "webgl2";
|
||||
})();
|
||||
const webGpuAvailable = typeof navigator !== "undefined" && "gpu" in navigator;
|
||||
const setRendererMode = (mode: RendererMode) => {
|
||||
window.localStorage.setItem(RENDERER_STORAGE_KEY, mode);
|
||||
const url = new URL(window.location.href);
|
||||
if (mode === "wasm") url.searchParams.set("engine", "wasm");
|
||||
else url.searchParams.delete("engine");
|
||||
window.location.assign(url.toString());
|
||||
};
|
||||
// Übernimmt ein Zeichenwerkzeug ODER eine laufende Transformation die linke
|
||||
// Maustaste der Plan-Ansicht? Dann liegt der Pointer-Input beim Tool/Transform
|
||||
// (statt Auswahl/Marquee), und die Editier-Griffe sind ausgeblendet.
|
||||
@@ -2932,6 +2954,9 @@ export default function App() {
|
||||
zoomPercent={planActive ? zoomPercent : null}
|
||||
activeFloor={activeLevel.name}
|
||||
activeLayer={activeLayerName}
|
||||
rendererMode={rendererMode}
|
||||
webGpuAvailable={webGpuAvailable}
|
||||
onRendererModeChange={setRendererMode}
|
||||
/>
|
||||
|
||||
{/* Schwebendes Ressourcen-Fenster ÜBER allem (hoher z-index, abgedunkelter
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# wasm-pack-Ausgabe des render2d-Browser-Renderers (Feature "web"). Wird per
|
||||
# `npm run build:engine` erzeugt und NICHT eingecheckt (Build-Artefakt, groß, an
|
||||
# Toolchain gebunden). Der Quellcode liegt in src-tauri/render2d/src/web.rs.
|
||||
pkg/
|
||||
@@ -186,6 +186,10 @@ export const de = {
|
||||
"status.floor": "Geschoss",
|
||||
"status.layer": "Ebene",
|
||||
"status.none": "—",
|
||||
"status.renderer": "Renderer",
|
||||
"status.rendererWebgl": "WebGL2",
|
||||
"status.rendererWasm": "Engine",
|
||||
"status.rendererWasmUnavailable": "WebGPU nicht verfügbar",
|
||||
|
||||
// ── Navigator-Panels ─────────────────────────────────────────────────────
|
||||
"nav.drawingLevels": "Zeichnungsebenen",
|
||||
|
||||
@@ -185,6 +185,10 @@ export const en: Record<TranslationKey, string> = {
|
||||
"status.floor": "Floor",
|
||||
"status.layer": "Layer",
|
||||
"status.none": "—",
|
||||
"status.renderer": "Renderer",
|
||||
"status.rendererWebgl": "WebGL2",
|
||||
"status.rendererWasm": "Engine",
|
||||
"status.rendererWasmUnavailable": "WebGPU unavailable",
|
||||
|
||||
// Navigator panels.
|
||||
"nav.drawingLevels": "Drawing levels",
|
||||
|
||||
+39
-14
@@ -18,6 +18,7 @@ import { docToLines, type SvgLine } from "../text/renderHtml";
|
||||
import type { EdgeGrip, Vec2 } from "../model/types";
|
||||
import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../tools/types";
|
||||
import { useGlPlanRenderer } from "./useGlPlanRenderer";
|
||||
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
|
||||
|
||||
const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
|
||||
const PAD = 60; // Rand in viewBox-Einheiten
|
||||
@@ -410,19 +411,40 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
// WebGL2-GPU-Renderer ist der STANDARD-Pfad. Der SVG-Renderer bleibt als
|
||||
// automatischer Fallback (nur falls WebGL2/Shader nicht verfügbar) — es gibt
|
||||
// KEINE User-Umschaltung mehr. `?gl=0` erzwingt zu Debug-Zwecken reines SVG.
|
||||
const wantGpu =
|
||||
// Engine-Auswahl. STANDARD-Pfad: WebGL2 (useGlPlanRenderer); der SVG-Renderer
|
||||
// bleibt automatischer Fallback (nur falls WebGL2/Shader nicht verfügbar), `?gl=0`
|
||||
// erzwingt reines SVG. INTEGRATIONS-SPIKE: Mit `?engine=wasm` UND vorhandenem
|
||||
// WebGPU (navigator.gpu) übernimmt statt WebGL2 der native render2d-Renderer als
|
||||
// WASM/WebGPU-Viewport (useWasmPlanRenderer) — dieselbe Szene, dieselbe ViewBox,
|
||||
// dasselbe SVG-Overlay (Text/Griffe/Auswahl) darüber. Fehlt WebGPU trotz Flag:
|
||||
// stiller Fallback auf WebGL2 (+ Warnung).
|
||||
const engineParam =
|
||||
typeof window === "undefined"
|
||||
? null
|
||||
: new URLSearchParams(window.location.search).get("engine");
|
||||
const hasWebGpu = typeof navigator !== "undefined" && "gpu" in navigator;
|
||||
const wantWasm = engineParam === "wasm" && hasWebGpu;
|
||||
useEffect(() => {
|
||||
if (engineParam === "wasm" && !hasWebGpu) {
|
||||
console.warn("engine=wasm angefragt, aber navigator.gpu fehlt → WebGL2-Fallback");
|
||||
}
|
||||
}, [engineParam, hasWebGpu]);
|
||||
// WebGL2 nur, wenn NICHT der WASM-Pfad aktiv ist (und `?gl!=0`).
|
||||
const wantGl =
|
||||
!wantWasm &&
|
||||
(typeof window === "undefined"
|
||||
? true
|
||||
: new URLSearchParams(window.location.search).get("gl") !== "0";
|
||||
const { render: renderGl, updateGeometry, ready: glReady } = useGlPlanRenderer(
|
||||
canvasRef,
|
||||
wantGpu,
|
||||
);
|
||||
// GL WIRKLICH aktiv? Steuert das Umschalten SVG↔GL: bei false rendert der volle
|
||||
// SVG-Pfad (Fallback), bei true übernimmt die GL-Ebene + Text-Overlay.
|
||||
: new URLSearchParams(window.location.search).get("gl") !== "0");
|
||||
// Beide Hooks unbedingt aufrufen (Hook-Regeln); je nach Flag ist genau einer aktiv.
|
||||
const glHook = useGlPlanRenderer(canvasRef, wantGl);
|
||||
const wasmHook = useWasmPlanRenderer(canvasRef, wantWasm);
|
||||
const activeEngine = wantWasm ? wasmHook : glHook;
|
||||
const { render: renderGl, updateGeometry, ready: glReady } = activeEngine;
|
||||
// „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT
|
||||
// montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten).
|
||||
const wantGpu = wantGl || wantWasm;
|
||||
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
|
||||
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
|
||||
const useGpuRenderer = wantGpu && glReady;
|
||||
|
||||
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
|
||||
@@ -1551,8 +1573,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
plan.primitives.map((p, i) =>
|
||||
// Bei aktivem GL zeichnet die WebGL-Ebene Poché/Linien/Bögen; das SVG wird
|
||||
// zur reinen Text-Overlay-Ebene (scharfe Schrift, DOM-Hit-Test) → hier nur
|
||||
// noch Text-Primitive. Sonst: alles außer den gebündelten 2D-Zeichenlinien.
|
||||
(useGpuRenderer ? p.kind !== "text" : p.kind === "line" && p.drawingId) ? null : (
|
||||
// noch Text-Primitive. Bei aktiver WASM-Engine rastert render2d den
|
||||
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
|
||||
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
|
||||
// Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien.
|
||||
(useGpuRenderer ? wantWasm || p.kind !== "text" : p.kind === "line" && p.drawingId) ? null : (
|
||||
<PrimitiveShape
|
||||
key={i}
|
||||
p={p}
|
||||
@@ -1564,7 +1589,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- toScreen ist modulweit konstant
|
||||
[plan, hairline, paperScale, useGpuRenderer],
|
||||
[plan, hairline, paperScale, useGpuRenderer, wantWasm],
|
||||
);
|
||||
const drawingRunEls = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
// lines:[{a,b,color,widthMm,dash}], texts:[{pos,content,sizeMm,color,align}] }
|
||||
// mit Point = [x,y] (Meter) und Rgba = [r,g,b,a] (0..1).
|
||||
//
|
||||
// Bögen werden NICHT hier tessellliert (anders als früher): der Rust-Renderer
|
||||
// zerlegt sie zoomabhängig (`tessellate::tessellate_arc`), damit sie bei jeder
|
||||
// Vergrößerung glatt bleiben statt sichtbare Facetten zu zeigen. Strichmuster
|
||||
// Bögen werden NICHT hier tessellliert: der Rust-Renderer zeichnet sie
|
||||
// mathematisch exakt per SDF-Fragment-Shader (`shaders::ARC_WGSL`, eine
|
||||
// analytische Instanz je Bogen) — bei jeder Vergrößerung ein echter Kreis statt
|
||||
// eines Vielecks, ohne Neu-Tessellierung. Strichmuster
|
||||
// (`dash`, mm Papier) werden ebenfalls unverändert durchgereicht — das geometrische
|
||||
// Zerschneiden in Teilstücke (`applyDashRuns`-Algorithmus) passiert erst NACH der
|
||||
// Bogen-Tessellierung in Rust (`tessellate::split_dash`), damit die Phase über die
|
||||
@@ -328,9 +329,9 @@ export function planToRenderScene(plan: Plan): RScene {
|
||||
} else if (p.kind === "arc") {
|
||||
flushRun();
|
||||
const col = toRgba(DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1];
|
||||
// Bogen unvortessellliert an Rust übergeben (`RArc`) — die zoomabhängige
|
||||
// Zerlegung (glatte Rundung bei jeder Vergrößerung) und ein evtl. Strich-
|
||||
// muster übernimmt `tessellate::tessellate_arc`/`split_dash` drüben.
|
||||
// Bogen unvortessellliert an Rust übergeben (`RArc`) — die exakte runde
|
||||
// Darstellung (analytischer SDF-Shader, `ARC_WGSL`) und ein evtl. Strich-
|
||||
// muster übernimmt der native Renderer (`compile_scene`) drüben.
|
||||
arcs.push({
|
||||
center: [p.center.x, p.center.y],
|
||||
from: [p.from.x, p.from.y],
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* React-Hook für den nativen render2d-Renderer als WASM/WebGPU-Viewport.
|
||||
*
|
||||
* Spiegelt die Schnittstelle von {@link useGlPlanRenderer} ({ render,
|
||||
* updateGeometry, ready }), damit PlanView beide Engines ohne weitere Verzweigung
|
||||
* am selben Canvas + über dieselben Effekte treiben kann. Die eigentliche
|
||||
* Zeichenlogik lebt in Rust (src-tauri/render2d, Feature `web`); hier wird nur das
|
||||
* WASM-Modul geladen, der Renderer an das Canvas gebunden und die Szene/ViewBox
|
||||
* durchgereicht.
|
||||
*
|
||||
* Fällt bei fehlendem WebGPU oder Init-Fehler still auf `ready:false` zurück; der
|
||||
* Aufrufer (PlanView) behält dann den WebGL2-/SVG-Pfad.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Plan } from "./generatePlan";
|
||||
import { planToRenderScene } from "./toRenderScene";
|
||||
|
||||
interface ViewBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompatibilitäts-Shim: wgpu 22 sendet in `requestDevice` noch das Limit
|
||||
* `maxInterStageShaderComponents`, das AUS DER WebGPU-Spec ENTFERNT wurde. Neuere
|
||||
* Chromium-Builds lehnen ein nicht-`undefined`-Wert dafür mit `OperationError`
|
||||
* ab (→ Device-Erzeugung schlägt fehl). Wir entfernen den Schlüssel EINMAL global
|
||||
* aus dem Descriptor, bevor wgpu ihn stellt. Entfällt, sobald render2d auf ein
|
||||
* wgpu (+ glyphon) hochgezogen wird, das dieses Limit nicht mehr mitschickt.
|
||||
*/
|
||||
let requestDevicePatched = false;
|
||||
function patchRequestDeviceLimits(): void {
|
||||
if (requestDevicePatched) return;
|
||||
const AdapterCtor = (globalThis as any).GPUAdapter;
|
||||
if (!AdapterCtor?.prototype?.requestDevice) return;
|
||||
const orig = AdapterCtor.prototype.requestDevice;
|
||||
AdapterCtor.prototype.requestDevice = function (
|
||||
this: unknown,
|
||||
desc?: any,
|
||||
) {
|
||||
if (desc?.requiredLimits && "maxInterStageShaderComponents" in desc.requiredLimits) {
|
||||
const requiredLimits = { ...desc.requiredLimits };
|
||||
delete requiredLimits.maxInterStageShaderComponents;
|
||||
desc = { ...desc, requiredLimits };
|
||||
}
|
||||
return orig.call(this, desc);
|
||||
};
|
||||
requestDevicePatched = true;
|
||||
}
|
||||
|
||||
/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine`). */
|
||||
interface WebPlanRendererLike {
|
||||
set_scene(json: string): void;
|
||||
set_view_box(x: number, y: number, w: number, h: number): void;
|
||||
set_paper_scale(n: number): void;
|
||||
resize(w: number, h: number): void;
|
||||
render(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WASM-Modul EINMAL laden + initialisieren (memoisiert). Wichtig: `mod.default()`
|
||||
* darf nur einmal laufen — ein zweiter Init (z.B. durch React-StrictMode-Doppel-
|
||||
* mount) verwirft die Closures des ersten und wirft „closure invoked recursively
|
||||
* or after being dropped".
|
||||
*/
|
||||
let enginePromise: Promise<any> | null = null;
|
||||
function loadEngine(): Promise<any> {
|
||||
if (!enginePromise) {
|
||||
enginePromise = (async () => {
|
||||
const mod: any = await import("../engine/pkg/render2d.js");
|
||||
await mod.default(); // lädt render2d_bg.wasm
|
||||
return mod;
|
||||
})();
|
||||
}
|
||||
return enginePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN
|
||||
* WebGPU-Kontext tragen; ein zweiter `WebPlanRenderer.new` auf demselben Canvas
|
||||
* (StrictMode-Remount) würde kollidieren. Die Promise wird gecacht, damit der
|
||||
* Doppelmount denselben Renderer teilt.
|
||||
*/
|
||||
const rendererCache = new WeakMap<HTMLCanvasElement, Promise<WebPlanRendererLike>>();
|
||||
function getRenderer(canvas: HTMLCanvasElement): Promise<WebPlanRendererLike> {
|
||||
let p = rendererCache.get(canvas);
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (siehe oben)
|
||||
const mod = await loadEngine();
|
||||
// Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen.
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
return (await mod.WebPlanRenderer.new(canvas, undefined)) as WebPlanRendererLike;
|
||||
})();
|
||||
rendererCache.set(canvas, p);
|
||||
// Bei Fehlschlag den Cache leeren, damit ein späterer Versuch neu startet.
|
||||
p.catch(() => rendererCache.delete(canvas));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function useWasmPlanRenderer(
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const rendererRef = useRef<WebPlanRendererLike | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// WASM-Renderer an das Canvas binden (memoisiert, StrictMode-fest).
|
||||
useEffect(() => {
|
||||
if (!enabled || !canvasRef.current) {
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
const canvas = canvasRef.current;
|
||||
void (async () => {
|
||||
try {
|
||||
const renderer = await getRenderer(canvas);
|
||||
if (disposed) return;
|
||||
rendererRef.current = renderer;
|
||||
setReady(true);
|
||||
} catch (e) {
|
||||
console.warn("render2d WASM/WebGPU-Init fehlgeschlagen → Fallback", e);
|
||||
rendererRef.current = null;
|
||||
setReady(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
setReady(false);
|
||||
};
|
||||
}, [enabled, canvasRef]);
|
||||
|
||||
/** Szene beim Plan-Wechsel neu setzen (re-tesselliert in Rust, gecacht). */
|
||||
const updateGeometry = useCallback((plan: Plan) => {
|
||||
const r = rendererRef.current;
|
||||
if (!r || !plan) return;
|
||||
try {
|
||||
r.set_scene(JSON.stringify(planToRenderScene(plan)));
|
||||
} catch (e) {
|
||||
console.warn("render2d set_scene fehlgeschlagen:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). */
|
||||
const render = useCallback(
|
||||
(viewBox: ViewBox, paperScaleN = 100) => {
|
||||
const r = rendererRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!r || !canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.round(rect.width * dpr));
|
||||
const h = Math.max(1, Math.round(rect.height * dpr));
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
try {
|
||||
r.resize(w, h);
|
||||
r.set_paper_scale(paperScaleN);
|
||||
r.set_view_box(viewBox.x, viewBox.y, viewBox.w, viewBox.h);
|
||||
r.render();
|
||||
} catch (e) {
|
||||
console.warn("render2d render fehlgeschlagen:", e);
|
||||
}
|
||||
},
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return { render, updateGeometry, ready };
|
||||
}
|
||||
@@ -760,6 +760,49 @@ body {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* Renderer-Umschalter (WebGL2 / Engine) in der Statusleiste — kleine
|
||||
Segmentpille, an die 22px-Leiste angepasst (siehe .tb-seg für das größere
|
||||
Vorbild in der Oberleiste). */
|
||||
.sb-renderer-toggle {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--input);
|
||||
}
|
||||
.sb-renderer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s, color 0.14s;
|
||||
}
|
||||
.sb-renderer-btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.sb-renderer-btn:hover:not(:disabled) {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.sb-renderer-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.sb-renderer-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Command-Line (Rhino-artig) — über der Statusleiste ──────────────────────
|
||||
Dunkle, ruhige Leiste mit Prompt links, klickbaren Inline-Optionen und einer
|
||||
monospace-Texteingabe rechts; das Autocomplete-Dropdown öffnet nach oben. */
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
import type { Vec2 } from "../model/types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
/** Plan-Renderer-Wahl, gespiegelt aus dem `?engine`-URL-Param (siehe PlanView). */
|
||||
export type RendererMode = "webgl2" | "wasm";
|
||||
|
||||
export interface StatusBarProps {
|
||||
/** Kontextueller Werkzeug-Hinweis links (z. B. „Auswahl"). */
|
||||
hint: string;
|
||||
@@ -29,6 +32,15 @@ export interface StatusBarProps {
|
||||
activeFloor: string;
|
||||
/** Name der aktiven Ebene (Kategorie) oder „—". */
|
||||
activeLayer: string;
|
||||
/** Aktuell gewählter Plan-Renderer (aus dem `?engine`-URL-Param abgeleitet). */
|
||||
rendererMode: RendererMode;
|
||||
/** Ist `navigator.gpu` vorhanden? Steuert, ob „Engine" wählbar ist. */
|
||||
webGpuAvailable: boolean;
|
||||
/**
|
||||
* Renderer wechseln (setzt/entfernt den `engine`-URL-Param und lädt neu — kein
|
||||
* Live-Remount der Renderer-Hooks).
|
||||
*/
|
||||
onRendererModeChange: (mode: RendererMode) => void;
|
||||
}
|
||||
|
||||
/** Ein Wert-Feld im rechten Cluster: kleine Beschriftung + Wert. */
|
||||
@@ -57,6 +69,9 @@ export function StatusBar({
|
||||
zoomPercent,
|
||||
activeFloor,
|
||||
activeLayer,
|
||||
rendererMode,
|
||||
webGpuAvailable,
|
||||
onRendererModeChange,
|
||||
}: StatusBarProps) {
|
||||
const coords = cursor
|
||||
? `${fmt(cursor.x)}, ${fmt(cursor.y)}`
|
||||
@@ -69,6 +84,30 @@ export function StatusBar({
|
||||
|
||||
{/* Rechts: abgeleiteter Wert-Cluster. */}
|
||||
<div className="sb-cluster">
|
||||
<span className="sb-field">
|
||||
<span className="sb-key">{t("status.renderer")}</span>
|
||||
<span className="sb-renderer-toggle" role="group" aria-label={t("status.renderer")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`sb-renderer-btn${rendererMode === "webgl2" ? " active" : ""}`}
|
||||
aria-pressed={rendererMode === "webgl2"}
|
||||
onClick={() => onRendererModeChange("webgl2")}
|
||||
>
|
||||
{t("status.rendererWebgl")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`sb-renderer-btn${rendererMode === "wasm" ? " active" : ""}`}
|
||||
aria-pressed={rendererMode === "wasm"}
|
||||
disabled={!webGpuAvailable}
|
||||
title={webGpuAvailable ? undefined : t("status.rendererWasmUnavailable")}
|
||||
onClick={() => onRendererModeChange("wasm")}
|
||||
>
|
||||
{t("status.rendererWasm")}
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<Sep />
|
||||
<Field label={t("status.xy")} value={coords} />
|
||||
<Sep />
|
||||
<Field label={t("status.unit")} value={unit} />
|
||||
|
||||
Reference in New Issue
Block a user