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:
+42
-9
@@ -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");
|
||||
}
|
||||
|
||||
+113
-11
@@ -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<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
|
||||
// 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<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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -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<Scene>,
|
||||
|
||||
#[cfg(feature = "native3d")]
|
||||
s3d: Option<GpuState3d>,
|
||||
@@ -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<Vec<WallInput>>,
|
||||
}
|
||||
|
||||
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<UserEvent> 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<UserEvent> {
|
||||
let mut builder = EventLoop::<UserEvent>::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);
|
||||
}
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user