From 01424c1e22ffc9c12f2d86c25523868f8885af34 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 20:10:38 +0200 Subject: [PATCH] Natives 2D/3D live: Webview pusht Szene/Waende per Tauri-Command Die nativen wgpu-Fenster spiegeln jetzt das LIVE-Modell statt des eingefrorenen JSON-Snapshots: die Webview schiebt bei jeder Aenderung (debounced 200 ms) den sichtbaren Plan (planToRenderScene) bzw. die Projekt-Waende (projectToWalls3d) per push_native_scene/push_native_walls an einen EventLoopProxy der winit-Loop. Ansicht wird nur neu eingepasst, solange im Fenster noch nicht gepannt/gezoomt/orbitiert wurde; die JSON-Assets bleiben Start-Fallback. Ohne Tauri: No-op. --- src-tauri/src/lib.rs | 51 ++++++++++++++--- src-tauri/src/native.rs | 124 ++++++++++++++++++++++++++++++++++++---- src/App.tsx | 11 ++++ src/plan/nativeSync.ts | 62 ++++++++++++++++++++ 4 files changed, 228 insertions(+), 20 deletions(-) create mode 100644 src/plan/nativeSync.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed83c3f..daf0c61 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,17 +14,50 @@ async fn compute_joins( Ok(geometry::compute_joins(input)) } +// Live-Spiegelung: die Webview schiebt bei jeder Modellaenderung (debounced) +// die aktuelle 2D-Szene bzw. die 3D-Waende hierher; native.rs stellt sie ueber +// einen EventLoopProxy in die winit-Loop zu. Die Payload kommt als rohes JSON +// (`serde_json::Value`) an und wird erst in native.rs in die render2d-/render3d- +// Typen deserialisiert — so existieren beide Commands unter EINEM cfg, egal +// welche Feature-Kombination gebaut wird. +#[cfg(any(feature = "native2d", feature = "native3d"))] +#[tauri::command] +fn push_native_scene(scene: serde_json::Value) { + #[cfg(feature = "native2d")] + native::push_scene(scene); + #[cfg(not(feature = "native2d"))] + let _ = scene; +} + +#[cfg(any(feature = "native2d", feature = "native3d"))] +#[tauri::command] +fn push_native_walls(walls: serde_json::Value) { + #[cfg(feature = "native3d")] + native::push_walls(walls); + #[cfg(not(feature = "native3d"))] + let _ = walls; +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - .setup(|_app| { - // Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen - // Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt. - #[cfg(any(feature = "native2d", feature = "native3d"))] - native::spawn(); - Ok(()) - }) - .invoke_handler(tauri::generate_handler![compute_joins]) + let builder = tauri::Builder::default().setup(|_app| { + // Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen + // Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt. + #[cfg(any(feature = "native2d", feature = "native3d"))] + native::spawn(); + Ok(()) + }); + + #[cfg(any(feature = "native2d", feature = "native3d"))] + let builder = builder.invoke_handler(tauri::generate_handler![ + compute_joins, + push_native_scene, + push_native_walls + ]); + #[cfg(not(any(feature = "native2d", feature = "native3d")))] + let builder = builder.invoke_handler(tauri::generate_handler![compute_joins]); + + builder .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/native.rs b/src-tauri/src/native.rs index 79abbaa..54f9083 100644 --- a/src-tauri/src/native.rs +++ b/src-tauri/src/native.rs @@ -19,22 +19,70 @@ // mit `RecreationAttempt` paniken. // // Die Renderer selbst werden NICHT reimplementiert: `render2d::gpu::Renderer` + -// `render3d::gpu::Renderer` — exakt der Code der standalone-Spikes. Die echten -// Szenen werden aus `assets/native2d_scene.json` bzw. `assets/native3d_walls.json` -// geladen (aus dem echten Modell erzeugt); fehlen sie, greift die Demo-Szene. +// `render3d::gpu::Renderer` — exakt der Code der standalone-Spikes. +// +// LIVE-Spiegelung: Die Webview schiebt bei jeder Modellaenderung (debounced) +// die aktuelle 2D-Szene bzw. die 3D-Waende per Tauri-Command +// (`push_native_scene`/`push_native_walls`, siehe lib.rs) hierher; ein +// `EventLoopProxy` stellt sie in die winit-Loop zu. Die JSON-Assets +// `assets/native2d_scene.json`/`assets/native3d_walls.json` bleiben nur noch +// Start-Fallback (bis der erste Push ankommt); fehlen sie, greift die Demo-Szene. // // Hinter den Cargo-Features `native2d`/`native3d` — der normale Tauri-Build zieht // weder winit noch wgpu und bleibt unveraendert. Beide Features sind // gleichzeitig aktivierbar (dann oeffnen sich beide Fenster). -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use winit::application::ApplicationHandler; use winit::dpi::LogicalSize; use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; -use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}; use winit::window::{Window, WindowId}; +// ───────────────────────────────────────────────────────────────────────────── +// Live-Push aus der Webview +// ───────────────────────────────────────────────────────────────────────────── + +/// Ereignisse, die die Webview (via Tauri-Command) in die native Event-Loop +/// schiebt: die frisch erzeugte 2D-Szene bzw. die geflachten 3D-Waende. So +/// spiegeln die nativen Fenster das LIVE-Modell statt des JSON-Snapshots. +enum UserEvent { + #[cfg(feature = "native2d")] + Scene2d(Scene), + #[cfg(feature = "native3d")] + Walls3d(Vec), +} + +/// Proxy in die laufende Event-Loop; wird beim Start (vor `run`) gesetzt. +/// Solange die Loop noch nicht steht, verpuffen Pushes bewusst still. +static PROXY: OnceLock> = OnceLock::new(); + +/// Webview-Push der 2D-Szene (JSON = `render2d::types::Scene`). Parse-Fehler +/// werden geloggt, ein fehlender Proxy still ignoriert (Loop startet noch). +#[cfg(feature = "native2d")] +pub fn push_scene(value: serde_json::Value) { + let Some(proxy) = PROXY.get() else { return }; + match serde_json::from_value::(value) { + Ok(scene) => { + let _ = proxy.send_event(UserEvent::Scene2d(scene)); + } + Err(e) => eprintln!("push_native_scene: ungueltige Szene: {e}"), + } +} + +/// Webview-Push der 3D-Waende (JSON = `Vec`). +#[cfg(feature = "native3d")] +pub fn push_walls(value: serde_json::Value) { + let Some(proxy) = PROXY.get() else { return }; + match serde_json::from_value::>(value) { + Ok(walls) => { + let _ = proxy.send_event(UserEvent::Walls3d(walls)); + } + Err(e) => eprintln!("push_native_walls: ungueltige Waende: {e}"), + } +} + // ───────────────────────────────────────────────────────────────────────────── // 2D // ───────────────────────────────────────────────────────────────────────────── @@ -406,6 +454,14 @@ struct App { drag2d: bool, #[cfg(feature = "native2d")] cur2d: (f64, f64), + /// Hat der Nutzer im 2D-Fenster schon gepannt/gezoomt? Solange NICHT, wird + /// die Ansicht bei jedem Live-Push neu eingepasst; danach bleibt sie stehen. + #[cfg(feature = "native2d")] + nav2d: bool, + /// Live-Push, der ankam, bevor das 2D-Fenster stand (wird in `resumed` statt + /// des JSON-Snapshots angewandt). + #[cfg(feature = "native2d")] + pending2d: Option, #[cfg(feature = "native3d")] s3d: Option, @@ -417,6 +473,12 @@ struct App { drag3d: bool, #[cfg(feature = "native3d")] cur3d: (f64, f64), + /// Hat der Nutzer im 3D-Fenster schon orbitiert/gezoomt? Analog `nav2d`. + #[cfg(feature = "native3d")] + nav3d: bool, + /// Live-Push vor Fenster-Erstellung (siehe `pending2d`). + #[cfg(feature = "native3d")] + pending3d: Option>, } impl App { @@ -469,11 +531,13 @@ impl App { let dy = (position.y - self.cur2d.1) as f32 / meet; vb.x -= dx; vb.y -= dy; + self.nav2d = true; state.window.request_redraw(); } self.cur2d = (position.x, position.y); } WindowEvent::MouseWheel { delta, .. } => { + self.nav2d = true; let step = match delta { MouseScrollDelta::LineDelta(_, y) => y, MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0, @@ -526,11 +590,13 @@ impl App { orbit.pitch += dy * 0.01; let limit = std::f32::consts::FRAC_PI_2 - 0.01; orbit.pitch = orbit.pitch.clamp(-limit, limit); + self.nav3d = true; state.window.request_redraw(); } self.cur3d = (position.x, position.y); } WindowEvent::MouseWheel { delta, .. } => { + self.nav3d = true; let step = match delta { MouseScrollDelta::LineDelta(_, y) => y, MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0, @@ -548,7 +614,7 @@ impl App { } } -impl ApplicationHandler for App { +impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { #[cfg(feature = "native2d")] if self.s2d.is_none() { @@ -557,7 +623,8 @@ impl ApplicationHandler for App { .with_inner_size(LogicalSize::new(1000.0, 760.0)); let window = Arc::new(event_loop.create_window(attrs).expect("2D-Fenster erstellen")); self.id2d = Some(window.id()); - let scene = load_scene(); + // Kam schon ein Live-Push an, gewinnt der; sonst der JSON-Snapshot. + let scene = self.pending2d.take().unwrap_or_else(load_scene); self.view_box = Some(scene_view_box(&scene)); let state = GpuState2d::new(window, &scene); state.window.request_redraw(); @@ -571,7 +638,7 @@ impl ApplicationHandler for App { .with_inner_size(LogicalSize::new(1000.0, 760.0)); let window = Arc::new(event_loop.create_window(attrs).expect("3D-Fenster erstellen")); self.id3d = Some(window.id()); - let walls = load_walls(); + let walls = self.pending3d.take().unwrap_or_else(load_walls); self.orbit = Some(Orbit::framed(&walls)); let state = GpuState3d::new(window, &walls); state.window.request_redraw(); @@ -579,6 +646,39 @@ impl ApplicationHandler for App { } } + /// Live-Push aus der Webview: Szene/Waende ersetzen, GPU-Buffer neu bauen, + /// neu zeichnen. Die Ansicht wird nur neu eingepasst, solange der Nutzer im + /// jeweiligen Fenster noch nicht selbst navigiert hat (kein Zuruckspringen + /// von Pan/Zoom/Orbit bei jeder Modellaenderung). + fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) { + match event { + #[cfg(feature = "native2d")] + UserEvent::Scene2d(scene) => { + let Some(state) = self.s2d.as_mut() else { + self.pending2d = Some(scene); + return; + }; + state.renderer.upload_scene(&state.device, &scene); + if !self.nav2d { + self.view_box = Some(scene_view_box(&scene)); + } + state.window.request_redraw(); + } + #[cfg(feature = "native3d")] + UserEvent::Walls3d(walls) => { + let Some(state) = self.s3d.as_mut() else { + self.pending3d = Some(walls); + return; + }; + state.renderer.upload_walls(&state.device, &walls); + if !self.nav3d { + self.orbit = Some(Orbit::framed(&walls)); + } + state.window.request_redraw(); + } + } + } + fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) { #[cfg(feature = "native2d")] if self.id2d == Some(id) { @@ -597,9 +697,8 @@ impl ApplicationHandler for App { /// Baut die winit-Event-Loop so, dass sie auf DIESEM (Nicht-Haupt-)Thread laufen /// darf. Auf Linux/Wayland via `EventLoopBuilderExtWayland::with_any_thread`, auf /// X11 das Pendant. Ohne diese Freigabe panict winit (Event-Loop nur Hauptthread). -fn build_event_loop() -> EventLoop<()> { - use winit::event_loop::EventLoopBuilder; - let mut builder = EventLoopBuilder::default(); +fn build_event_loop() -> EventLoop { + let mut builder = EventLoop::::with_user_event(); #[cfg(all(unix, not(target_os = "macos")))] { @@ -619,6 +718,9 @@ fn build_event_loop() -> EventLoop<()> { fn run_blocking() { let event_loop = build_event_loop(); event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait); + // Proxy VOR dem Lauf veroeffentlichen, damit die Tauri-Commands + // (`push_native_scene`/`push_native_walls`) sofort zustellen koennen. + let _ = PROXY.set(event_loop.create_proxy()); let mut app = App::default(); let _ = event_loop.run_app(&mut app); } diff --git a/src/App.tsx b/src/App.tsx index b6cc502..659cd89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import type { } from "./model/types"; import { generatePlan } from "./plan/generatePlan"; import type { CategoryDisplayResolver } from "./plan/generatePlan"; +import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync"; import { parseDxf } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser"; import { setDxfImportTrigger } from "./io/dxfImportHook"; @@ -4068,6 +4069,16 @@ function LevelPlanView({ [project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono], ); + // Live-Spiegelung in die nativen wgpu-Fenster (No-op ohne Tauri, debounced): + // EXAKT der sichtbare Plan (gleiches Geschoss, gleicher Detailgrad) fürs + // 2D-Fenster, die Projekt-Wände fürs 3D-Fenster. + useEffect(() => { + pushNativeScene(plan); + }, [plan]); + useEffect(() => { + pushNativeWalls(project); + }, [project]); + // Bei einer freien Zeichnung gibt es keine Schnitthöhe → eigene Legende/Titel. const isDrawing = level.kind === "drawing"; const caption = isDrawing diff --git a/src/plan/nativeSync.ts b/src/plan/nativeSync.ts new file mode 100644 index 0000000..3279d6a --- /dev/null +++ b/src/plan/nativeSync.ts @@ -0,0 +1,62 @@ +// Live-Spiegelung in die nativen wgpu-Fenster (src-tauri/src/native.rs): schiebt +// die AKTUELL sichtbare 2D-Szene bzw. die geflachten 3D-Wände debounced per +// Tauri-Command an die native Event-Loop. Es wird EXAKT dieselbe Ableitung +// benutzt wie für die Browser-Ansicht (planToRenderScene auf dem sichtbaren +// Plan, projectToWalls3d aufs Projekt) — 1:1-Parität inkl. Türschwenk und +// Fenster-Symbolen (Text bleibt bewusst dem Browser-Overlay überlassen). +// +// Im reinen Browser (kein Tauri) sind beide Funktionen No-ops: erkannt wird +// Tauri an `window.__TAURI_INTERNALS__`; Fehler beim Invoke werden still +// geschluckt (console.debug), damit die App nie daran scheitert. + +import type { Plan } from "./generatePlan"; +import type { Project } from "../model/types"; +import { planToRenderScene } from "./toRenderScene"; +import { projectToWalls3d } from "./toWalls3d"; + +/** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */ +const DEBOUNCE_MS = 200; + +/** Läuft die App in einer Tauri-Webview? (Sonst: reiner Browser, kein Invoke.) */ +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +/** Geguardeter Invoke (Muster wie src/compute): nie werfen, Fehler nur debuggen. */ +async function invokeNative(cmd: string, args: Record): Promise { + try { + // @ts-ignore optionales Paket + const mod = await import(/* @vite-ignore */ "@tauri-apps/api/core").catch(() => null); + if (!mod || typeof (mod as any).invoke !== "function") return; + await (mod as any).invoke(cmd, args); + } catch (e) { + console.debug(`nativeSync: ${cmd} fehlgeschlagen`, e); + } +} + +let sceneTimer: ReturnType | undefined; +let wallsTimer: ReturnType | undefined; + +/** + * Debounced: den sichtbaren Plan (dasselbe Objekt wie in der PlanView) als + * render2d-Szene ans native 2D-Fenster schieben. No-op ohne Tauri. + */ +export function pushNativeScene(plan: Plan): void { + if (!isTauri()) return; + clearTimeout(sceneTimer); + sceneTimer = setTimeout(() => { + void invokeNative("push_native_scene", { scene: planToRenderScene(plan) }); + }, DEBOUNCE_MS); +} + +/** + * Debounced: die Wände des Projekts (alle Geschosse, gestapelt) als + * render3d-WallInputs ans native 3D-Fenster schieben. No-op ohne Tauri. + */ +export function pushNativeWalls(project: Project): void { + if (!isTauri()) return; + clearTimeout(wallsTimer); + wallsTimer = setTimeout(() => { + void invokeNative("push_native_walls", { walls: projectToWalls3d(project) }); + }, DEBOUNCE_MS); +}