diff --git a/package-lock.json b/package-lock.json index eeb4490..253f4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,8 @@ "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": { @@ -750,6 +751,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", @@ -2206,6 +2220,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", @@ -2986,6 +3010,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", @@ -3577,6 +3624,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", @@ -3968,6 +4042,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", diff --git a/package.json b/package.json index e000200..eb7a454 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "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", + "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", @@ -39,6 +40,7 @@ "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" } } diff --git a/scripts/chromium-shell.sh b/scripts/chromium-shell.sh index aa24df1..384fb05 100755 --- a/scripts/chromium-shell.sh +++ b/scripts/chromium-shell.sh @@ -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 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0daaef7..82d7114 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", ] diff --git a/src-tauri/render2d/Cargo.toml b/src-tauri/render2d/Cargo.toml index 24ec84a..fe0a7a8 100644 --- a/src-tauri/render2d/Cargo.toml +++ b/src-tauri/render2d/Cargo.toml @@ -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 diff --git a/src-tauri/render2d/assets/Inter.ttf b/src-tauri/render2d/assets/Inter.ttf new file mode 100644 index 0000000..4ab79e0 Binary files /dev/null and b/src-tauri/render2d/assets/Inter.ttf differ diff --git a/src-tauri/render2d/src/gpu.rs b/src-tauri/render2d/src/gpu.rs index a067d93..ceed5c1 100644 --- a/src-tauri/render2d/src/gpu.rs +++ b/src-tauri/render2d/src/gpu.rs @@ -130,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 @@ -158,6 +160,12 @@ pub struct Renderer { msaa_size: (u32, u32), /// Glyphen-Textpass (lazily beim ersten Frame mit Texten erstellt). text: Option, + /// 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>, /// Loeschfarbe (Zeichenblatt), Default hell wie im WebGL-Pfad (#f0f0f0). pub clear_color: wgpu::Color, /// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten. @@ -392,6 +400,8 @@ impl Renderer { 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, @@ -458,6 +468,13 @@ impl Renderer { }); } + /// 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) { + self.pending_font = Some(bytes); + } + /// Erstellt den Glyphen-Textpass beim ersten Bedarf (FontSystem laedt die /// Systemfonts einmalig). Der TextRenderer MUSS mit demselben /// MultisampleState wie die Geometrie-Pipelines gebaut werden (MSAA 4x), @@ -466,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); @@ -480,7 +511,7 @@ impl Renderer { None, ); self.text = Some(TextState { - font_system: glyphon::FontSystem::new(), + font_system, swash_cache: glyphon::SwashCache::new(), viewport, atlas, @@ -615,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); @@ -629,7 +663,7 @@ impl Renderer { let mut placed: Vec = 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). diff --git a/src-tauri/render2d/src/lib.rs b/src-tauri/render2d/src/lib.rs index 1bb5b09..e78394b 100644 --- a/src-tauri/render2d/src/lib.rs +++ b/src-tauri/render2d/src/lib.rs @@ -21,6 +21,12 @@ 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::{ diff --git a/src-tauri/render2d/src/web.rs b/src-tauri/render2d/src/web.rs new file mode 100644 index 0000000..03e26db --- /dev/null +++ b/src-tauri/render2d/src/web.rs @@ -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>, + ) -> Result { + // 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(); + } +} diff --git a/src/engine/.gitignore b/src/engine/.gitignore new file mode 100644 index 0000000..530d91c --- /dev/null +++ b/src/engine/.gitignore @@ -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/ diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 1a5191a..32f3a30 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -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( const svgRef = useRef(null); const canvasRef = useRef(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- diff --git a/src/plan/useWasmPlanRenderer.ts b/src/plan/useWasmPlanRenderer.ts new file mode 100644 index 0000000..b9c4381 --- /dev/null +++ b/src/plan/useWasmPlanRenderer.ts @@ -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 | null = null; +function loadEngine(): Promise { + 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>(); +function getRenderer(canvas: HTMLCanvasElement): Promise { + 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, + enabled: boolean, +) { + const rendererRef = useRef(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 }; +}