From 0e484746de07c7b257ec97aee68fea8484250e1a Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 07:41:37 +0200 Subject: [PATCH] render3d im Browser: WASM/WebGPU-3D-Viewport hinter ?engine=wasm Feature web (wasm-bindgen) + cdylib analog render2d; WebModelRenderer mit Canvas-Surface, set_model (walls/slabs wie der native Push) und set_camera. Projektion liefert bereits [0,1]-Clip-Z, math.rs unveraendert. wgpu-22-requestDevice-Shim in src/engine/requestDeviceShim.ts geteilt. Neuer Hook useWasm3dRenderer + Wasm3DViewport (Orbit/Pan/Zoom wie three.js- Sicht); Viewport3D dispatcht per ?engine=wasm bzw. localStorage, three.js bleibt Default. Build-Script build:engine3d (wasm-pack, src/engine/pkg3d). Verifiziert headful per scripts/probe-engine3d.mjs (37 % Geometrie-Pixel); headless praesentiert Chromium keine WebGPU-Frames (auch bei render2d). --- package.json | 3 +- scripts/probe-engine3d.mjs | 129 ++++++++++++++++ src-tauri/Cargo.lock | 5 + src-tauri/render3d/Cargo.toml | 39 +++++ src-tauri/render3d/src/lib.rs | 6 + src-tauri/render3d/src/web.rs | 220 +++++++++++++++++++++++++++ src/engine/.gitignore | 4 + src/engine/requestDeviceShim.ts | 30 ++++ src/i18n/de.ts | 5 + src/i18n/en.ts | 5 + src/viewport/Viewport3D.tsx | 37 ++++- src/viewport/Wasm3DViewport.tsx | 244 ++++++++++++++++++++++++++++++ src/viewport/useWasm3dRenderer.ts | 189 +++++++++++++++++++++++ 13 files changed, 914 insertions(+), 2 deletions(-) create mode 100644 scripts/probe-engine3d.mjs create mode 100644 src-tauri/render3d/src/web.rs create mode 100644 src/engine/requestDeviceShim.ts create mode 100644 src/viewport/Wasm3DViewport.tsx create mode 100644 src/viewport/useWasm3dRenderer.ts diff --git a/package.json b/package.json index fa91e6a..964373f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dump:native": "node scripts/dump-native-scene.mjs", "shell": "scripts/chromium-shell.sh", "electron": "scripts/electron-shell.sh", - "build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web" + "build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web", + "build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web" }, "dependencies": { "@mlightcad/libredwg-web": "^0.7.7", diff --git a/scripts/probe-engine3d.mjs b/scripts/probe-engine3d.mjs new file mode 100644 index 0000000..d691157 --- /dev/null +++ b/scripts/probe-engine3d.mjs @@ -0,0 +1,129 @@ +// Probe: belegt den nativen render3d-Renderer als WASM/WebGPU-Viewport in der +// 3D-Ansicht (`?engine=wasm`, siehe src/viewport/Wasm3DViewport.tsx). +// +// Ablauf: App mit ?engine=wasm laden, in die Perspektive wechseln (Ansichts- +// Icon-Grid der Oberleiste), auf die Engine-Initialisierung warten, Screenshot +// scripts/probe-engine3d.png schiessen und dann PRÜFEN, dass das Engine-Canvas +// (canvas[data-engine="render3d"]) vorhanden ist UND Geometrie zeigt. +// +// PIXEL-SAMPLING über den Screenshot (nicht drawImage): ein WebGPU-Canvas +// liefert nach dem Present über drawImage/getImageData nur transparente Pixel +// (der Frame ist bereits verbraucht). Der CDP-Screenshot enthält dagegen die +// komposittierte Ausgabe — er wird als Data-URL zurück in die Seite geladen und +// im Rechteck des Engine-Canvas gegen die Löschfarbe (#f5f5f5) ausgezählt. +// +// WebGPU liegt in Chromium/Linux hinter Flags (siehe scripts/chromium-shell.sh): +// --enable-unsafe-webgpu --enable-features=Vulkan. HEADLESS initialisiert die +// API zwar (Adapter/Device ok, Init-Logs erscheinen), PRÄSENTIERT aber keine +// Frames — das gilt genauso für den etablierten render2d-Viewport (gegen- +// geprüft). Verlässlich verifiziert daher nur headful: +// HEADFUL=1 [CHROMIUM_BIN=/usr/bin/chromium] node scripts/probe-engine3d.mjs + +import puppeteer from "puppeteer"; + +const BASE = process.env.PROBE_URL || "http://localhost:5187/"; +const URL = BASE.replace(/\/?$/, "/") + "?engine=wasm"; +const HEADFUL = !!process.env.HEADFUL; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +const browser = await puppeteer.launch({ + headless: HEADFUL ? false : "new", + // Headful: das System-Chromium nutzen (Puppeteers gebündeltes Chrome kommt + // mit der Wayland/niri-Session hier nicht hoch). + executablePath: HEADFUL ? process.env.CHROMIUM_BIN || "/usr/bin/chromium" : undefined, + args: ["--no-sandbox", "--enable-unsafe-webgpu", "--enable-features=Vulkan"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +await page.goto(URL, { waitUntil: "networkidle0", timeout: 25000 }); +const hasGpu = await page.evaluate(() => "gpu" in navigator); + +// In die Perspektive wechseln: die 3D-Blicke im Ansichts-Icon-Grid tragen ihr +// Label als aria-label (TopBar.tsx). +const clicked = await page.evaluate(() => { + const btn = document.querySelector('button.view-icon[aria-label="Perspektive"]'); + if (!btn) return false; + btn.click(); + return true; +}); + +// Engine-Init (WASM-Load + Adapter/Device) + erster Frame brauchen einen Moment. +await wait(3500); + +// Position/Größe des Engine-Canvas im Viewport (CSS-Pixel, dsf=1). +const rect = await page.evaluate(() => { + const c = document.querySelector('canvas[data-engine="render3d"]'); + if (!c) return null; + const r = c.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; +}); + +await page.screenshot({ path: "scripts/probe-engine3d.png" }); + +let verdict = { present: !!rect }; +if (rect && rect.w > 4 && rect.h > 4) { + // Screenshot zurück in die Seite laden und im Canvas-Rechteck auszählen. + const b64 = await page.screenshot({ encoding: "base64" }); + verdict = await page.evaluate( + async (b64Png, r) => { + const img = new Image(); + img.src = "data:image/png;base64," + b64Png; + await img.decode(); + const s = document.createElement("canvas"); + s.width = img.width; + s.height = img.height; + const ctx = s.getContext("2d"); + ctx.drawImage(img, 0, 0); + const d = ctx.getImageData( + Math.round(r.x), + Math.round(r.y), + Math.round(r.w), + Math.round(r.h), + ).data; + let nonBackground = 0; + const total = d.length / 4; + for (let i = 0; i < d.length; i += 4) { + if ( + Math.abs(d[i] - 245) > 12 || + Math.abs(d[i + 1] - 245) > 12 || + Math.abs(d[i + 2] - 245) > 12 + ) { + nonBackground++; + } + } + return { present: true, rect: r, total, nonBackground }; + }, + b64, + rect, + ); +} + +console.log(`URL: ${URL}`); +console.log("navigator.gpu:", hasGpu, "| Perspektive-Button:", clicked); +console.log(JSON.stringify(verdict, null, 2)); +const relevant = logs.filter((l) => /render3d|render2d|webgpu|wasm|error|warn/i.test(l)); +console.log("Logs:", relevant.length ? "\n" + relevant.join("\n") : "(keine)"); + +await browser.close(); + +// Erfolg: Canvas vorhanden und deutlich mehr als nur die Löschfarbe sichtbar +// (>3 % der Pixel abseits von #f5f5f5 — das Gebäude deckt real ~40 % ab; das +// Ansichts-Badge allein läge unter 1 %). +const ok = verdict.present && (verdict.nonBackground ?? 0) > (verdict.total ?? 1) * 0.03; +if (ok) { + console.log( + `\nOK: Engine-Canvas zeigt Geometrie (${verdict.nonBackground}/${verdict.total} Pixel abseits der Löschfarbe). Screenshot: scripts/probe-engine3d.png`, + ); +} else { + console.error( + `\nFEHLSCHLAG: Engine-Canvas ${verdict.present ? "ohne sichtbare Geometrie" : "fehlt"} (navigator.gpu=${hasGpu}). ` + + "Headless präsentiert keine WebGPU-Frames — mit HEADFUL=1 erneut ausführen.", + ); + process.exit(1); +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 82d7114..c6802c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3678,11 +3678,16 @@ name = "render3d" version = "0.1.0" dependencies = [ "bytemuck", + "console_error_panic_hook", "env_logger", + "js-sys", "naga", "pollster", "serde", "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "wgpu", "winit", ] diff --git a/src-tauri/render3d/Cargo.toml b/src-tauri/render3d/Cargo.toml index 9d3262f..b9e0b7e 100644 --- a/src-tauri/render3d/Cargo.toml +++ b/src-tauri/render3d/Cargo.toml @@ -15,6 +15,13 @@ description = "Nativer wgpu-3D-Renderer fuer die CAD-Modellsicht (Wand-Extrusion # Workspace-Root INNERHALB des src-tauri-Baums Cargo mit "multiple workspace # roots" abbrechen. Der Parent-`exclude` haelt die Crate trotzdem eigenstaendig. +# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul benoetigt. rlib: +# damit die Crate weiterhin als Pfad-Abhaengigkeit (cad-tauri, Feature native3d) +# und im Test-/Bin-Build (spike3d) nutzbar bleibt. Der cdylib-Artefakt-Build auf +# nativen Zielen ist harmlos (leere Export-Oberflaeche ohne Feature "web"). +[lib] +crate-type = ["cdylib", "rlib"] + [features] # Standard: nur Mesh-Erzeugung (Wand-Extrusion) + Kamera/Matrizen + WGSL-Quellen. # Diese Schicht ist serde-only, hat keine GPU-Abhaengigkeit und ist headless @@ -30,8 +37,26 @@ render = ["dep:wgpu", "dep:bytemuck", "dep:pollster"] # Wayland/X11 + eine 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. +# Muster: render2d/Cargo.toml (Feature "web"). +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-Modell (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 @@ -45,6 +70,20 @@ pollster = { version = "0.3", 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/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index fd5d8b6..dc9e10e 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -22,6 +22,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. Muster: render2d/src/web.rs. +#[cfg(feature = "web")] +pub mod web; + pub use math::{ look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix, view_matrix, view_projection, Mat4, diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs new file mode 100644 index 0000000..bc5fcff --- /dev/null +++ b/src-tauri/render3d/src/web.rs @@ -0,0 +1,220 @@ +// 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 (Wand-/Decken-Extrusion, Pipeline, Licht) ist +// mit dem nativen Fenster-Spike (bin/spike3d) geteilt; hier liegt nur die Canvas-/ +// WebGPU-Anbindung + die wasm-bindgen-Fassade. Muster: render2d/src/web.rs. +// +// Ablauf im Frontend (siehe src/viewport/useWasm3dRenderer.ts): +// const r = await WebModelRenderer.new(canvas); +// r.set_model(JSON.stringify(projectToModel3d(project))); // bei Modelländerung +// r.set_camera(eye.x, eye.y, eye.z, target.x, target.y, target.z, +// up.x, up.y, up.z, perspective, fovY, orthoHalfHeight); +// r.resize(px_w, px_h); r.render(); // pro Frame +// +// KAMERA: die Fassade nimmt eine FERTIGE Kamera (eye/target/up + Projektionsart) +// entgegen — Orbit/Pan/Zoom-Interaktion (Yaw/Pitch/Distanz, Praesets) rechnet die +// TS-Seite (dieselbe Konvention wie `math::orbit_eye`/`preset_camera`), damit die +// Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgen kann, ohne +// eine zweite Praeset-Tabelle in Rust pflegen zu muessen. +// +// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch +// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts). + +use serde::Deserialize; +use wasm_bindgen::prelude::*; +use web_sys::HtmlCanvasElement; + +use crate::gpu::Renderer; +use crate::types::{Camera, Projection, SlabInput, WallInput}; + +/// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe +/// `projectToWalls3d`) + Deckenplatten. `slabs` optional (leeres Modell ohne +/// Decken bleibt gueltig). +#[derive(Deserialize)] +struct ModelInput { + walls: Vec, + #[serde(default)] + slabs: Vec, +} + +/// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer` +/// und die aktuelle Kamera. Wird per `WebModelRenderer.new(canvas)` aus JS erzeugt. +#[wasm_bindgen] +pub struct WebModelRenderer { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, + renderer: Renderer, + camera: Camera, +} + +#[wasm_bindgen] +impl WebModelRenderer { + /// Erzeugt den Renderer asynchron (Adapter/Device via WebGPU). + /// Aus JS: `await WebModelRenderer.new(canvasEl)`. + #[allow(clippy::new_ret_no_self)] + pub async fn new(canvas: HtmlCanvasElement) -> 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("render3d.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) — Format vom Kontext abfragen statt hart zu kodieren (dieselbe + // Wahl wie render2d: erstes NICHT-sRGB-Format, sonst das erste angebotene). + 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 renderer = Renderer::new(&device, format); + + web_sys::console::log_1(&JsValue::from_str(&format!( + "render3d WebGPU-Viewport initialisiert ({}x{}, {:?})", + width, height, format + ))); + + Ok(WebModelRenderer { + surface, + device, + queue, + config, + renderer, + camera: Camera::default(), + }) + } + + /// Setzt/aktualisiert das darzustellende Modell aus einem JSON-String + /// (Format = `{ walls: WallInput[], slabs?: SlabInput[] }`, identisch zum + /// nativen Push). Laedt die GPU-Puffer neu hoch; ruft NICHT selbst `render`. + pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> { + let model: ModelInput = serde_json::from_str(json) + .map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?; + self.renderer + .upload_model(&self.device, &model.walls, &model.slabs); + Ok(()) + } + + /// Setzt die Kamera fuer den naechsten `render`-Aufruf. `eye`/`target`/`up` + /// in world-Metern (Y-up, wie `types::Camera`). `perspective=true` waehlt eine + /// perspektivische Projektion (`fov_y` in Radiant), sonst orthografisch + /// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet + /// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp) + /// — siehe `useWasm3dRenderer`/`Wasm3DViewport`. + #[allow(clippy::too_many_arguments)] + pub fn set_camera( + &mut self, + eye_x: f32, + eye_y: f32, + eye_z: f32, + target_x: f32, + target_y: f32, + target_z: f32, + up_x: f32, + up_y: f32, + up_z: f32, + perspective: bool, + fov_y: f32, + ortho_half_height: f32, + ) { + self.camera = Camera { + eye: [eye_x, eye_y, eye_z], + target: [target_x, target_y, target_z], + up: [up_x, up_y, up_z], + projection: if perspective { + Projection::Perspective + } else { + Projection::Orthographic + }, + fov_y, + ortho_half_height, + ..Camera::default() + }; + } + + /// 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!( + "render3d Surface-Fehler: {e:?}" + ))); + return; + } + }; + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + self.renderer.render( + &self.device, + &self.queue, + &view, + &self.camera, + (self.config.width, self.config.height), + ); + frame.present(); + } +} diff --git a/src/engine/.gitignore b/src/engine/.gitignore index 530d91c..9077bc1 100644 --- a/src/engine/.gitignore +++ b/src/engine/.gitignore @@ -2,3 +2,7 @@ # `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/ + +# Dito für den render3d-Browser-Renderer (`npm run build:engine3d`); Quellcode +# in src-tauri/render3d/src/web.rs. +pkg3d/ diff --git a/src/engine/requestDeviceShim.ts b/src/engine/requestDeviceShim.ts new file mode 100644 index 0000000..3aa64b1 --- /dev/null +++ b/src/engine/requestDeviceShim.ts @@ -0,0 +1,30 @@ +/** + * Kompatibilitäts-Shim für die WASM-Engines (render2d/render3d, beide wgpu 22): + * wgpu 22 sendet in `requestDevice` noch das Limit `maxInterStageShaderComponents`, + * das AUS DER WebGPU-Spec ENTFERNT wurde. Neuere Chromium-Builds lehnen einen + * 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 die Engines auf ein wgpu (+ glyphon) + * hochgezogen werden, das dieses Limit nicht mehr mitschickt. + */ + +let requestDevicePatched = false; + +export 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; +} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index b3a5f61..fc35e49 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -777,6 +777,11 @@ export const de = { "rt.color": "Farbe", "rt.preset": "Vorlage", "rt.preset.none": "— Vorlage —", + + // ── 3D-Viewport (native Engine, WASM/WebGPU) ──────────────────────────────── + "viewport3d.engineUnavailable": "3D-Engine nicht verfügbar", + "viewport3d.engineUnavailableHint": + "WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.", } as const; export type TranslationKey = keyof typeof de; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e51a52a..97a653d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -768,4 +768,9 @@ export const en: Record = { "rt.color": "Color", "rt.preset": "Preset", "rt.preset.none": "— Preset —", + + // ── 3D viewport (native engine, WASM/WebGPU) ──────────────────────────────── + "viewport3d.engineUnavailable": "3D engine unavailable", + "viewport3d.engineUnavailableHint": + "WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.", }; diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index f25a37a..4003558 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -44,6 +44,7 @@ import { wallVerticalExtent, } from "../model/wall"; import { MaterialRuntime } from "../materials/runtime"; +import { Wasm3DViewport } from "./Wasm3DViewport"; import type { RenderMode, View3d } from "../ui/TopBar"; import type { ToolDraft } from "../tools/types"; import { applyAngleConstraint } from "../tools/snapping"; @@ -85,7 +86,41 @@ export interface ViewportContextInfo { wallId: string | null; } -export function Viewport3D({ +/** + * Engine-Umschalter (Gegenstück zu PlanView `wantWasm` / App `rendererMode`): + * `?engine=wasm` in der URL gewinnt; ohne Param zählt die in der Statusleiste + * persistierte Wahl (localStorage `cad.rendererMode`, siehe App.tsx). Zusätzlich + * muss WebGPU vorhanden sein — fehlt es trotz Flag, bleibt die three.js-Sicht + * (stiller Fallback + Warnung, wie in PlanView). Einmal je Seitenladung + * ausgewertet: ein Wechsel läuft ohnehin über einen Reload (setRendererMode). + */ +const WASM_ENGINE_ACTIVE: boolean = (() => { + if (typeof window === "undefined" || typeof navigator === "undefined") return false; + const fromUrl = new URLSearchParams(window.location.search).get("engine"); + const wanted = fromUrl + ? fromUrl === "wasm" + : window.localStorage.getItem("cad.rendererMode") === "wasm"; + if (!wanted) return false; + if (!("gpu" in navigator)) { + console.warn("engine=wasm angefragt, aber navigator.gpu fehlt → three.js-Fallback (3D)"); + return false; + } + return true; +})(); + +/** + * Öffentliche 3D-Sicht: wählt zwischen der nativen Engine (render3d als + * WASM/WebGPU, Wasm3DViewport) und der three.js-Szene (ThreeViewport3D, + * unveränderter Standardpfad). Der Engine-Pfad ist ein Integrations-Spike und + * nutzt vorerst nur das Projekt (Massenmodell + Orbit); alle weiteren Props + * (Auswahl, Render-Modi, Editier-Griffe) wirken nur im three.js-Pfad. + */ +export function Viewport3D(props: Parameters[0]) { + if (WASM_ENGINE_ACTIVE) return ; + return ; +} + +function ThreeViewport3D({ project, visibleCodes, floorDisplay = SHOW_ALL, diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx new file mode 100644 index 0000000..9403ce2 --- /dev/null +++ b/src/viewport/Wasm3DViewport.tsx @@ -0,0 +1,244 @@ +// 3D-Viewport auf der nativen Engine: render3d (Rust/wgpu) als WASM/WebGPU- +// Canvas. Aktiv hinter demselben Engine-Umschalter wie die 2D-Plan-Ansicht +// (`?engine=wasm` bzw. Statusleisten-Wahl, siehe Viewport3D.tsx); die three.js- +// Sicht bleibt der unveränderte Standardpfad. +// +// INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs- +// Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation. +// Auswahl/Raycast, Render-Modi, Kamera-Presets und Editier-Griffe der three.js- +// Sicht folgen später — bis dahin gilt: LINKS ohne Funktion, RECHTS unterdrückt +// nur das Browser-Menü. +// +// MAUS-SCHEMA wie die three.js-Sicht (docs/design/context-menu.md): +// MITTE ziehen = Orbit, Shift+MITTE = Pan, RAD = Zoom. +// Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je +// Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen. + +import { useEffect, useRef } from "react"; +import type { Project } from "../model/types"; +import { projectToModel3d } from "../plan/toWalls3d"; +import { t } from "../i18n"; +import { useWasm3dRenderer } from "./useWasm3dRenderer"; +import type { Camera3d } from "./useWasm3dRenderer"; + +/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°). */ +const FOV_Y = (50 * Math.PI) / 180; +/** Pitch-Klemme gegen den Gimbal-Flip (identisch zu render3d::math::orbit_eye). */ +const PITCH_LIMIT = Math.PI / 2 - 0.01; + +/** Orbit-Zustand der Kamera: Kugelkoordinaten um ein Blickziel (world, Y-up). */ +interface OrbitState { + /** Azimut um die Y-Achse (Radiant). */ + yaw: number; + /** Erhebung über die XZ-Ebene (Radiant, geklemmt). */ + pitch: number; + /** Abstand vom Ziel (Meter). */ + dist: number; + /** Blickziel (world). */ + target: [number, number, number]; +} + +/** Fertige Kamera aus dem Orbit-Zustand (Konvention wie render3d::math::orbit_eye). */ +function orbitCamera(o: OrbitState): Camera3d { + const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch)); + const r = o.dist * Math.cos(pitch); + return { + eye: [ + o.target[0] + r * Math.cos(o.yaw), + o.target[1] + o.dist * Math.sin(pitch), + o.target[2] + r * Math.sin(o.yaw), + ], + target: [o.target[0], o.target[1], o.target[2]], + up: [0, 1, 0], + perspective: true, + fovY: FOV_Y, + orthoHalfHeight: o.dist * 0.5, + }; +} + +/** + * Formatfüllender Start-Orbit: Bounding-Box des geflachten Modells (Wände + + * Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen — Blick von + * schräg oben (wie das Persp-Preset), Distanz aus Radius + Sichtwinkel. + */ +function fitOrbit(project: Project): OrbitState { + const model = projectToModel3d(project); + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + const grow = (x: number, y: number, z: number) => { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + }; + for (const w of model.walls) { + grow(w.start[0], w.baseElevation, w.start[1]); + grow(w.end[0], w.baseElevation + w.height, w.end[1]); + } + for (const s of model.slabs) { + for (const p of s.outline) { + grow(p[0], s.zBottom, p[1]); + grow(p[0], s.zTop, p[1]); + } + } + // Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start). + if (!Number.isFinite(minX)) { + return { yaw: Math.PI / 4, pitch: 0.5, dist: 14, target: [2.5, 1, 2] }; + } + const target: [number, number, number] = [ + (minX + maxX) / 2, + (minY + maxY) / 2, + (minZ + maxZ) / 2, + ]; + const radius = + Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 || 1; + // Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld passt (+ Rand). + const dist = Math.max(4, (radius / Math.tan(FOV_Y / 2)) * 1.2); + return { yaw: Math.PI / 4, pitch: 0.5, dist, target }; +} + +export function Wasm3DViewport({ project }: { project: Project }) { + const canvasRef = useRef(null); + const { render, updateModel, ready, failed } = useWasm3dRenderer(canvasRef, true); + // Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen + // direkt neu, ohne die Komponente je Frame zu re-rendern. + const orbitRef = useRef(null); + // Frisches Projekt für den (stabilen) Init-Effekt unten. + const projectRef = useRef(project); + projectRef.current = project; + + // Modell bei Bereitschaft + jeder Projektänderung neu einspielen. Der Orbit + // wird nur beim ERSTEN Mal eingepasst — danach bleibt die Kamera beim + // Editieren stehen (gleiches Verhalten wie der three.js-Kamera-Snapshot). + useEffect(() => { + if (!ready) return; + updateModel(project); + if (!orbitRef.current) orbitRef.current = fitOrbit(project); + render(orbitCamera(orbitRef.current)); + }, [ready, project, updateModel, render]); + + // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu + // zeichnen (die Surface-Anpassung übernimmt der Hook im render()). + useEffect(() => { + const canvas = canvasRef.current; + if (!ready || !canvas) return; + const ro = new ResizeObserver(() => { + if (orbitRef.current) render(orbitCamera(orbitRef.current)); + }); + ro.observe(canvas); + return () => ro.disconnect(); + }, [ready, render]); + + // Maus-Navigation (MITTE = Orbit, Shift+MITTE = Pan, RAD = Zoom). + useEffect(() => { + const canvas = canvasRef.current; + if (!ready || !canvas) return; + + let drag: { mode: "orbit" | "pan"; x: number; y: number } | null = null; + + const redraw = () => { + if (orbitRef.current) render(orbitCamera(orbitRef.current)); + }; + + const onPointerDown = (e: PointerEvent) => { + if (e.button !== 1) return; // nur MITTE navigiert (links = Auswahl, s. o.) + e.preventDefault(); + drag = { mode: e.shiftKey ? "pan" : "orbit", x: e.clientX, y: e.clientY }; + canvas.setPointerCapture(e.pointerId); + }; + + const onPointerMove = (e: PointerEvent) => { + const o = orbitRef.current; + if (!drag || !o) return; + const dx = e.clientX - drag.x; + const dy = e.clientY - drag.y; + drag.x = e.clientX; + drag.y = e.clientY; + const h = canvas.clientHeight || 1; + if (drag.mode === "orbit") { + // Winkel je Pixel wie OrbitControls: voller Umlauf über die Viewport-Höhe. + o.yaw += (2 * Math.PI * dx) / h; + o.pitch = Math.max( + -PITCH_LIMIT, + Math.min(PITCH_LIMIT, o.pitch + (2 * Math.PI * dy) / h), + ); + } else { + // Pan in der Bildebene: Welt-Meter je Pixel aus Distanz + Sichtwinkel; + // Ziel entgegen der Mausrichtung verschieben (Szene folgt dem Cursor). + const worldPerPx = (2 * o.dist * Math.tan(FOV_Y / 2)) / h; + const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch)); + // Kamera-Basis aus dem Orbit: f = Blickrichtung, s = rechts, u = Kamera-oben. + const f = [ + -Math.cos(pitch) * Math.cos(o.yaw), + -Math.sin(pitch), + -Math.cos(pitch) * Math.sin(o.yaw), + ]; + const s = [Math.sin(o.yaw), 0, -Math.cos(o.yaw)]; + const u = [ + s[1] * f[2] - s[2] * f[1], + s[2] * f[0] - s[0] * f[2], + s[0] * f[1] - s[1] * f[0], + ]; + o.target[0] += (-s[0] * dx + u[0] * dy) * worldPerPx; + o.target[1] += (-s[1] * dx + u[1] * dy) * worldPerPx; + o.target[2] += (-s[2] * dx + u[2] * dy) * worldPerPx; + } + redraw(); + }; + + const endDrag = (e: PointerEvent) => { + if (!drag) return; + drag = null; + if (canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId); + } + }; + + const onWheel = (e: WheelEvent) => { + const o = orbitRef.current; + if (!o) return; + e.preventDefault(); + // Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt). + o.dist = Math.max(0.2, Math.min(2000, o.dist * Math.exp(e.deltaY * 0.0013))); + redraw(); + }; + + // Browser-Kontextmenü unterdrücken (RECHTS ist fürs App-Menü reserviert). + const onContextMenu = (e: MouseEvent) => e.preventDefault(); + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", endDrag); + canvas.addEventListener("pointercancel", endDrag); + canvas.addEventListener("wheel", onWheel, { passive: false }); + canvas.addEventListener("contextmenu", onContextMenu); + return () => { + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", endDrag); + canvas.removeEventListener("pointercancel", endDrag); + canvas.removeEventListener("wheel", onWheel); + canvas.removeEventListener("contextmenu", onContextMenu); + }; + }, [ready, render]); + + return ( +
+ {/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die + Renderauflösung (DPR) gleicht der Hook je Frame ab. */} + + {failed && ( +
+ {t("viewport3d.engineUnavailable")} + {t("viewport3d.engineUnavailableHint")} +
+ )} +
+ ); +} diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts new file mode 100644 index 0000000..982382b --- /dev/null +++ b/src/viewport/useWasm3dRenderer.ts @@ -0,0 +1,189 @@ +/** + * React-Hook für den nativen render3d-Renderer als WASM/WebGPU-Viewport. + * + * Gegenstück zu {@link useWasmPlanRenderer} (2D): lädt das WASM-Modul aus + * src/engine/pkg3d (`npm run build:engine3d`), bindet den Renderer an ein + * Canvas und reicht Modell + Kamera durch. Die eigentliche Zeichenlogik lebt + * in Rust (src-tauri/render3d, Feature `web`) — dieselbe Extrusion/Pipeline + * wie das native wgpu-Fenster. + * + * Modell-Sync: `updateModel(project)` flacht das Projekt über + * {@link projectToModel3d} (Wände mit Öffnungs-Teilquadern + Deckenplatten) + * und schiebt es als JSON an `set_model` — exakt dasselbe Format wie der + * native Tauri-Push (nativeSync.ts). + * + * Fällt bei fehlendem WebGPU oder Init-Fehler auf `ready:false` + `failed:true` + * zurück; der Aufrufer (Wasm3DViewport) zeigt dann den Hinweis-Fallback. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Project } from "../model/types"; +import { projectToModel3d } from "../plan/toWalls3d"; +// wgpu-22-Limit-Shim (maxInterStageShaderComponents), geteilt mit useWasmPlanRenderer. +import { patchRequestDeviceLimits } from "../engine/requestDeviceShim"; + +/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */ +export interface Camera3d { + eye: [number, number, number]; + target: [number, number, number]; + up: [number, number, number]; + /** true = perspektivisch (fovY), false = orthografisch (orthoHalfHeight). */ + perspective: boolean; + /** Vertikaler Öffnungswinkel in RADIANT (nur perspektivisch). */ + fovY: number; + /** Halbe Sichthöhe in Metern (nur orthografisch). */ + orthoHalfHeight: number; +} + +/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine3d`). */ +interface WebModelRendererLike { + set_model(json: string): void; + set_camera( + eyeX: number, + eyeY: number, + eyeZ: number, + targetX: number, + targetY: number, + targetZ: number, + upX: number, + upY: number, + upZ: number, + perspective: boolean, + fovY: number, + orthoHalfHeight: number, + ): void; + resize(w: number, h: number): void; + render(): void; +} + +/** + * WASM-Modul EINMAL laden + initialisieren (memoisiert). `mod.default()` darf + * nur einmal laufen — ein zweiter Init (React-StrictMode-Doppelmount) verwirft + * die Closures des ersten („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/pkg3d/render3d.js"); + await mod.default(); // lädt render3d_bg.wasm + return mod; + })(); + } + return enginePromise; +} + +/** + * Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN + * WebGPU-Kontext tragen; ein zweiter `WebModelRenderer.new` auf demselben + * Canvas (StrictMode-Remount) würde kollidieren — die gecachte Promise lässt + * den Doppelmount denselben Renderer teilen. + */ +const rendererCache = new WeakMap>(); +function getRenderer(canvas: HTMLCanvasElement): Promise { + let p = rendererCache.get(canvas); + if (!p) { + p = (async () => { + patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (src/engine/requestDeviceShim) + 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.WebModelRenderer.new(canvas)) as WebModelRendererLike; + })(); + 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 useWasm3dRenderer( + canvasRef: React.RefObject, + enabled: boolean, +) { + const rendererRef = useRef(null); + const [ready, setReady] = useState(false); + const [failed, setFailed] = 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); + setFailed(false); + } catch (e) { + console.warn("render3d WASM/WebGPU-Init fehlgeschlagen", e); + rendererRef.current = null; + setReady(false); + if (!disposed) setFailed(true); + } + })(); + return () => { + disposed = true; + setReady(false); + }; + }, [enabled, canvasRef]); + + /** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). */ + const updateModel = useCallback((project: Project) => { + const r = rendererRef.current; + if (!r || !project) return; + try { + r.set_model(JSON.stringify(projectToModel3d(project))); + } catch (e) { + console.warn("render3d set_model fehlgeschlagen:", e); + } + }, []); + + /** Einen Frame mit der gegebenen Kamera zeichnen (Canvas-Größe + DPR abgleichen). */ + const render = useCallback( + (cam: Camera3d) => { + 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_camera( + cam.eye[0], + cam.eye[1], + cam.eye[2], + cam.target[0], + cam.target[1], + cam.target[2], + cam.up[0], + cam.up[1], + cam.up[2], + cam.perspective, + cam.fovY, + cam.orthoHalfHeight, + ); + r.render(); + } catch (e) { + console.warn("render3d render fehlgeschlagen:", e); + } + }, + [canvasRef], + ); + + return { render, updateModel, ready, failed }; +}