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<UserEvent> 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.
This commit is contained in:
2026-07-02 20:10:38 +02:00
parent 19f99382f6
commit 128c04a5d0
4 changed files with 228 additions and 20 deletions
+37 -4
View File
@@ -14,17 +14,50 @@ async fn compute_joins(
Ok(geometry::compute_joins(input)) 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() let builder = tauri::Builder::default().setup(|_app| {
.setup(|_app| {
// Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen // Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt. // Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
#[cfg(any(feature = "native2d", feature = "native3d"))] #[cfg(any(feature = "native2d", feature = "native3d"))]
native::spawn(); native::spawn();
Ok(()) Ok(())
}) });
.invoke_handler(tauri::generate_handler![compute_joins])
#[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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+113 -11
View File
@@ -19,22 +19,70 @@
// mit `RecreationAttempt` paniken. // mit `RecreationAttempt` paniken.
// //
// Die Renderer selbst werden NICHT reimplementiert: `render2d::gpu::Renderer` + // Die Renderer selbst werden NICHT reimplementiert: `render2d::gpu::Renderer` +
// `render3d::gpu::Renderer` — exakt der Code der standalone-Spikes. Die echten // `render3d::gpu::Renderer` — exakt der Code der standalone-Spikes.
// Szenen werden aus `assets/native2d_scene.json` bzw. `assets/native3d_walls.json` //
// geladen (aus dem echten Modell erzeugt); fehlen sie, greift die Demo-Szene. // 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<UserEvent>` 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 // Hinter den Cargo-Features `native2d`/`native3d` — der normale Tauri-Build zieht
// weder winit noch wgpu und bleibt unveraendert. Beide Features sind // weder winit noch wgpu und bleibt unveraendert. Beide Features sind
// gleichzeitig aktivierbar (dann oeffnen sich beide Fenster). // gleichzeitig aktivierbar (dann oeffnen sich beide Fenster).
use std::sync::Arc; use std::sync::{Arc, OnceLock};
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize; use winit::dpi::LogicalSize;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; 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}; 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<WallInput>),
}
/// 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<EventLoopProxy<UserEvent>> = 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::<Scene>(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<render3d::types::WallInput>`).
#[cfg(feature = "native3d")]
pub fn push_walls(value: serde_json::Value) {
let Some(proxy) = PROXY.get() else { return };
match serde_json::from_value::<Vec<WallInput>>(value) {
Ok(walls) => {
let _ = proxy.send_event(UserEvent::Walls3d(walls));
}
Err(e) => eprintln!("push_native_walls: ungueltige Waende: {e}"),
}
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 2D // 2D
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -406,6 +454,14 @@ struct App {
drag2d: bool, drag2d: bool,
#[cfg(feature = "native2d")] #[cfg(feature = "native2d")]
cur2d: (f64, f64), 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<Scene>,
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
s3d: Option<GpuState3d>, s3d: Option<GpuState3d>,
@@ -417,6 +473,12 @@ struct App {
drag3d: bool, drag3d: bool,
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
cur3d: (f64, f64), 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<Vec<WallInput>>,
} }
impl App { impl App {
@@ -469,11 +531,13 @@ impl App {
let dy = (position.y - self.cur2d.1) as f32 / meet; let dy = (position.y - self.cur2d.1) as f32 / meet;
vb.x -= dx; vb.x -= dx;
vb.y -= dy; vb.y -= dy;
self.nav2d = true;
state.window.request_redraw(); state.window.request_redraw();
} }
self.cur2d = (position.x, position.y); self.cur2d = (position.x, position.y);
} }
WindowEvent::MouseWheel { delta, .. } => { WindowEvent::MouseWheel { delta, .. } => {
self.nav2d = true;
let step = match delta { let step = match delta {
MouseScrollDelta::LineDelta(_, y) => y, MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0, MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0,
@@ -526,11 +590,13 @@ impl App {
orbit.pitch += dy * 0.01; orbit.pitch += dy * 0.01;
let limit = std::f32::consts::FRAC_PI_2 - 0.01; let limit = std::f32::consts::FRAC_PI_2 - 0.01;
orbit.pitch = orbit.pitch.clamp(-limit, limit); orbit.pitch = orbit.pitch.clamp(-limit, limit);
self.nav3d = true;
state.window.request_redraw(); state.window.request_redraw();
} }
self.cur3d = (position.x, position.y); self.cur3d = (position.x, position.y);
} }
WindowEvent::MouseWheel { delta, .. } => { WindowEvent::MouseWheel { delta, .. } => {
self.nav3d = true;
let step = match delta { let step = match delta {
MouseScrollDelta::LineDelta(_, y) => y, MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0, MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0,
@@ -548,7 +614,7 @@ impl App {
} }
} }
impl ApplicationHandler for App { impl ApplicationHandler<UserEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) { fn resumed(&mut self, event_loop: &ActiveEventLoop) {
#[cfg(feature = "native2d")] #[cfg(feature = "native2d")]
if self.s2d.is_none() { if self.s2d.is_none() {
@@ -557,7 +623,8 @@ impl ApplicationHandler for App {
.with_inner_size(LogicalSize::new(1000.0, 760.0)); .with_inner_size(LogicalSize::new(1000.0, 760.0));
let window = Arc::new(event_loop.create_window(attrs).expect("2D-Fenster erstellen")); let window = Arc::new(event_loop.create_window(attrs).expect("2D-Fenster erstellen"));
self.id2d = Some(window.id()); 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)); self.view_box = Some(scene_view_box(&scene));
let state = GpuState2d::new(window, &scene); let state = GpuState2d::new(window, &scene);
state.window.request_redraw(); state.window.request_redraw();
@@ -571,7 +638,7 @@ impl ApplicationHandler for App {
.with_inner_size(LogicalSize::new(1000.0, 760.0)); .with_inner_size(LogicalSize::new(1000.0, 760.0));
let window = Arc::new(event_loop.create_window(attrs).expect("3D-Fenster erstellen")); let window = Arc::new(event_loop.create_window(attrs).expect("3D-Fenster erstellen"));
self.id3d = Some(window.id()); 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)); self.orbit = Some(Orbit::framed(&walls));
let state = GpuState3d::new(window, &walls); let state = GpuState3d::new(window, &walls);
state.window.request_redraw(); 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) { fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
#[cfg(feature = "native2d")] #[cfg(feature = "native2d")]
if self.id2d == Some(id) { 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 /// Baut die winit-Event-Loop so, dass sie auf DIESEM (Nicht-Haupt-)Thread laufen
/// darf. Auf Linux/Wayland via `EventLoopBuilderExtWayland::with_any_thread`, auf /// darf. Auf Linux/Wayland via `EventLoopBuilderExtWayland::with_any_thread`, auf
/// X11 das Pendant. Ohne diese Freigabe panict winit (Event-Loop nur Hauptthread). /// X11 das Pendant. Ohne diese Freigabe panict winit (Event-Loop nur Hauptthread).
fn build_event_loop() -> EventLoop<()> { fn build_event_loop() -> EventLoop<UserEvent> {
use winit::event_loop::EventLoopBuilder; let mut builder = EventLoop::<UserEvent>::with_user_event();
let mut builder = EventLoopBuilder::default();
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
{ {
@@ -619,6 +718,9 @@ fn build_event_loop() -> EventLoop<()> {
fn run_blocking() { fn run_blocking() {
let event_loop = build_event_loop(); let event_loop = build_event_loop();
event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait); 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 mut app = App::default();
let _ = event_loop.run_app(&mut app); let _ = event_loop.run_app(&mut app);
} }
+11
View File
@@ -37,6 +37,7 @@ import type {
} from "./model/types"; } from "./model/types";
import { generatePlan } from "./plan/generatePlan"; import { generatePlan } from "./plan/generatePlan";
import type { CategoryDisplayResolver } from "./plan/generatePlan"; import type { CategoryDisplayResolver } from "./plan/generatePlan";
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
import { parseDxf } from "./io/dxfParser"; import { parseDxf } from "./io/dxfParser";
import type { DxfImportResult } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser";
import { setDxfImportTrigger } from "./io/dxfImportHook"; import { setDxfImportTrigger } from "./io/dxfImportHook";
@@ -4068,6 +4069,16 @@ function LevelPlanView({
[project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono], [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. // Bei einer freien Zeichnung gibt es keine Schnitthöhe → eigene Legende/Titel.
const isDrawing = level.kind === "drawing"; const isDrawing = level.kind === "drawing";
const caption = isDrawing const caption = isDrawing
+62
View File
@@ -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<string, unknown>): Promise<void> {
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<typeof setTimeout> | undefined;
let wallsTimer: ReturnType<typeof setTimeout> | 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);
}