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).
This commit is contained in:
+2
-1
@@ -15,7 +15,8 @@
|
|||||||
"dump:native": "node scripts/dump-native-scene.mjs",
|
"dump:native": "node scripts/dump-native-scene.mjs",
|
||||||
"shell": "scripts/chromium-shell.sh",
|
"shell": "scripts/chromium-shell.sh",
|
||||||
"electron": "scripts/electron-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": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+5
@@ -3678,11 +3678,16 @@ name = "render3d"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"console_error_panic_hook",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"js-sys",
|
||||||
"naga",
|
"naga",
|
||||||
"pollster",
|
"pollster",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
# Workspace-Root INNERHALB des src-tauri-Baums Cargo mit "multiple workspace
|
||||||
# roots" abbrechen. Der Parent-`exclude` haelt die Crate trotzdem eigenstaendig.
|
# 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]
|
[features]
|
||||||
# Standard: nur Mesh-Erzeugung (Wand-Extrusion) + Kamera/Matrizen + WGSL-Quellen.
|
# Standard: nur Mesh-Erzeugung (Wand-Extrusion) + Kamera/Matrizen + WGSL-Quellen.
|
||||||
# Diese Schicht ist serde-only, hat keine GPU-Abhaengigkeit und ist headless
|
# 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.
|
# Wayland/X11 + eine Display-Session zur visuellen Verifikation.
|
||||||
window = ["render", "dep:winit", "dep:env_logger"]
|
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]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
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") ------------------------------------------
|
# --- GPU (nur mit Feature "render") ------------------------------------------
|
||||||
# Standard-Features von wgpu (wgsl, webgpu, dx12, metal). Die Linux-Backends
|
# 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 }
|
winit = { version = "0.30", optional = true }
|
||||||
env_logger = { version = "0.11", 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]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
# naga validiert die WGSL-Quellen headless (Parser + Validator) im Test, ohne
|
# naga validiert die WGSL-Quellen headless (Parser + Validator) im Test, ohne
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ pub mod types;
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub mod gpu;
|
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::{
|
pub use math::{
|
||||||
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
|
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
|
||||||
view_matrix, view_projection, Mat4,
|
view_matrix, view_projection, Mat4,
|
||||||
|
|||||||
@@ -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<WallInput>,
|
||||||
|
#[serde(default)]
|
||||||
|
slabs: Vec<SlabInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<WebModelRenderer, 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("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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
# `npm run build:engine` erzeugt und NICHT eingecheckt (Build-Artefakt, groß, an
|
# `npm run build:engine` erzeugt und NICHT eingecheckt (Build-Artefakt, groß, an
|
||||||
# Toolchain gebunden). Der Quellcode liegt in src-tauri/render2d/src/web.rs.
|
# Toolchain gebunden). Der Quellcode liegt in src-tauri/render2d/src/web.rs.
|
||||||
pkg/
|
pkg/
|
||||||
|
|
||||||
|
# Dito für den render3d-Browser-Renderer (`npm run build:engine3d`); Quellcode
|
||||||
|
# in src-tauri/render3d/src/web.rs.
|
||||||
|
pkg3d/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -777,6 +777,11 @@ export const de = {
|
|||||||
"rt.color": "Farbe",
|
"rt.color": "Farbe",
|
||||||
"rt.preset": "Vorlage",
|
"rt.preset": "Vorlage",
|
||||||
"rt.preset.none": "— 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;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof de;
|
export type TranslationKey = keyof typeof de;
|
||||||
|
|||||||
@@ -768,4 +768,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"rt.color": "Color",
|
"rt.color": "Color",
|
||||||
"rt.preset": "Preset",
|
"rt.preset": "Preset",
|
||||||
"rt.preset.none": "— 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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
wallVerticalExtent,
|
wallVerticalExtent,
|
||||||
} from "../model/wall";
|
} from "../model/wall";
|
||||||
import { MaterialRuntime } from "../materials/runtime";
|
import { MaterialRuntime } from "../materials/runtime";
|
||||||
|
import { Wasm3DViewport } from "./Wasm3DViewport";
|
||||||
import type { RenderMode, View3d } from "../ui/TopBar";
|
import type { RenderMode, View3d } from "../ui/TopBar";
|
||||||
import type { ToolDraft } from "../tools/types";
|
import type { ToolDraft } from "../tools/types";
|
||||||
import { applyAngleConstraint } from "../tools/snapping";
|
import { applyAngleConstraint } from "../tools/snapping";
|
||||||
@@ -85,7 +86,41 @@ export interface ViewportContextInfo {
|
|||||||
wallId: string | null;
|
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<typeof ThreeViewport3D>[0]) {
|
||||||
|
if (WASM_ENGINE_ACTIVE) return <Wasm3DViewport project={props.project} />;
|
||||||
|
return <ThreeViewport3D {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreeViewport3D({
|
||||||
project,
|
project,
|
||||||
visibleCodes,
|
visibleCodes,
|
||||||
floorDisplay = SHOW_ALL,
|
floorDisplay = SHOW_ALL,
|
||||||
|
|||||||
@@ -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<HTMLCanvasElement | null>(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<OrbitState | null>(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 (
|
||||||
|
<div className="viewport">
|
||||||
|
{/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die
|
||||||
|
Renderauflösung (DPR) gleicht der Hook je Frame ab. */}
|
||||||
|
<canvas ref={canvasRef} data-engine="render3d" />
|
||||||
|
{failed && (
|
||||||
|
<div className="viewport-fallback">
|
||||||
|
<strong>{t("viewport3d.engineUnavailable")}</strong>
|
||||||
|
<span>{t("viewport3d.engineUnavailableHint")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<any> | null = null;
|
||||||
|
function loadEngine(): Promise<any> {
|
||||||
|
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<HTMLCanvasElement, Promise<WebModelRendererLike>>();
|
||||||
|
function getRenderer(canvas: HTMLCanvasElement): Promise<WebModelRendererLike> {
|
||||||
|
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<HTMLCanvasElement | null>,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const rendererRef = useRef<WebModelRendererLike | null>(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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user