diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index c7a9f09..8613836 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -145,7 +145,9 @@ pub struct Camera { pub far: f32, } -fn default_fov() -> f32 { +// `pub(crate)`, damit `web::set_view_preset` denselben Voreinstellungswert +// fuers formatfuellende Fit nutzen kann (kein zweiter FOV-Wert zum Pflegen). +pub(crate) fn default_fov() -> f32 { // 50 Grad, wie die three.js-PerspectiveCamera-Voreinstellung. 50.0_f32.to_radians() } diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index 13609ad..e2c87c3 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -11,11 +11,15 @@ // 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. +// KAMERA: zwei Wege, dieselbe `Camera`. `set_camera` nimmt eine FERTIGE Kamera +// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion +// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention), +// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt. +// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete +// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/ +// Top/Side werden dabei ECHT orthografisch (`preset_camera`), was eine reine +// TS-Orbit-Naeherung nicht leisten kann. Nach einem Praeset-Sprung bleibt die +// weitere Navigation frei ueber `set_camera` (kein Lock). // // MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch // zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts). @@ -25,8 +29,9 @@ use wasm_bindgen::prelude::*; use web_sys::HtmlCanvasElement; use crate::gpu::Renderer; +use crate::math::preset_camera; use crate::section::{cut_section, SectionPlane}; -use crate::types::{Camera, Projection, SlabInput, WallInput}; +use crate::types::{Camera, CameraPreset, Projection, SlabInput, WallInput}; /// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe /// `projectToWalls3d`) + Deckenplatten. `slabs` optional (leeres Modell ohne @@ -83,6 +88,54 @@ pub struct WebModelRenderer { config: wgpu::SurfaceConfiguration, renderer: Renderer, camera: Camera, + /// Achsparallele Bounding-Box (min, max) des zuletzt gesetzten Modells, + /// world-Meter (Y-up) — Grundlage fuer `set_view_preset` (formatfuellendes + /// Ziel/Distanz, mirror der TS-`fitTargetDist` in Wasm3DViewport.tsx). + /// `None` bei leerem Modell (kein Wall-/Slab-Vertex) oder vor `set_model`. + bounds: Option<([f32; 3], [f32; 3])>, +} + +/// Achsparallele Bounding-Box des Modells in Welt-Metern (Y-up), aus Waenden + +/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx): +/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante. +/// `None` bei leerem Modell. +fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [f32; 3])> { + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + let mut grow = |x: f32, y: f32, z: f32| { + if x < min[0] { + min[0] = x; + } + if x > max[0] { + max[0] = x; + } + if y < min[1] { + min[1] = y; + } + if y > max[1] { + max[1] = y; + } + if z < min[2] { + min[2] = z; + } + if z > max[2] { + max[2] = z; + } + }; + for w in walls { + grow(w.start[0], w.base_elevation, w.start[1]); + grow(w.end[0], w.base_elevation + w.height, w.end[1]); + } + for s in slabs { + for p in &s.outline { + grow(p[0], s.z_bottom, p[1]); + grow(p[0], s.z_top, p[1]); + } + } + if !min[0].is_finite() { + return None; + } + Some((min, max)) } #[wasm_bindgen] @@ -165,15 +218,18 @@ impl WebModelRenderer { config, renderer, camera: Camera::default(), + bounds: None, }) } /// 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`. + /// Merkt sich zusaetzlich die Bounding-Box (fuer `set_view_preset`). 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.bounds = model_bounds(&model.walls, &model.slabs); self.renderer .upload_model(&self.device, &model.walls, &model.slabs); Ok(()) @@ -216,6 +272,49 @@ impl WebModelRenderer { }; } + /// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht + /// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf + /// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend + /// (mirror der TS-`fitTargetDist`); Front/Top/Side werden orthografisch, + /// Iso/Perspective perspektivisch (`math::preset_camera`). Wirkt erst beim + /// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles + /// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx). + pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> { + let preset = match preset { + "front" => CameraPreset::Front, + "top" => CameraPreset::Top, + "side" => CameraPreset::Side, + "iso" => CameraPreset::Iso, + "perspective" => CameraPreset::Persp, + other => { + return Err(JsValue::from_str(&format!( + "unbekanntes Kamera-Praeset: {other}" + ))) + } + }; + let (target, dist) = match self.bounds { + Some((min, max)) => { + let center = [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + (min[2] + max[2]) * 0.5, + ]; + let (dx, dy, dz) = (max[0] - min[0], max[1] - min[1], max[2] - min[2]); + let radius = (dx * dx + dy * dy + dz * dz).sqrt() * 0.5; + let radius = if radius > 0.0 { radius } else { 1.0 }; + // Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld + // passt (+ Rand) — identische Formel wie `fitTargetDist`. + let fov_y = crate::types::default_fov(); + let dist = ((radius / (fov_y * 0.5).tan()) * 1.2).max(4.0); + (center, dist) + } + // Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start). + None => ([2.5_f32, 1.0, 2.0], 14.0_f32), + }; + self.camera = preset_camera(preset, target, dist); + Ok(()) + } + /// Darstellungsart umschalten: `true` = weisses Clay-Material (beleuchtet, /// ohne Bauteil-Farben — Vectorworks „white"), `false` = normale Material- /// farbe (shaded, Default). Wirkt erst beim naechsten `render`. diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index 5d04120..47d240f 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -116,7 +116,15 @@ const WASM_ENGINE_ACTIVE: boolean = (() => { * (Auswahl, Render-Modi, Editier-Griffe) wirken nur im three.js-Pfad. */ export function Viewport3D(props: Parameters[0]) { - if (WASM_ENGINE_ACTIVE) return ; + if (WASM_ENGINE_ACTIVE) { + return ( + + ); + } return ; } diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index 9c099a9..5343a18 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -5,26 +5,42 @@ // // INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs- // Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation + -// Kamera-Presets (Top/Bottom/Front/Back/Left/Right/Iso) + Darstellungsart -// (shaded/white) im Overlay unten (s. VIEW_PRESETS, wasm3dOverlay.css). -// Auswahl/Raycast, Griffe/Zeichnen sowie die übrigen Darstellungsarten der -// three.js-Sicht (texturiert/Drahtgitter/Kanten, HLR) folgen später — bis -// dahin gilt: LINKS ohne Funktion, RECHTS unterdrückt nur das Browser-Menü. +// Kamera-Presets (`view3d`, von der Oberleiste getrieben — s. u.) + Darstellungs- +// art (shaded/white, `renderMode`, ebenfalls Oberleiste). Auswahl/Raycast, +// Griffe/Zeichnen sowie die übrigen Darstellungsarten der three.js-Sicht +// (texturiert/Drahtgitter/Kanten, HLR) folgen später — bis dahin gilt: LINKS +// ohne Funktion, RECHTS unterdrückt nur das Browser-Menü. +// +// KAMERA-PRESETS: `view3d`/`renderMode` kommen als Props von der Oberleiste +// (TopBar → App.tsx → Viewport3D-Umschalter, dieselbe Quelle wie die three.js- +// Sicht). Front/Top/Side/Iso/Perspective rechnet NICHT mehr diese Datei (die +// alte VIEW_PRESETS-Tabelle war perspektivisch-only und damit für Front/Top/ +// Side falsch) — stattdessen `WebModelRenderer.set_view_preset` in Rust +// (`web.rs` → `math::preset_camera`): Front/Top/Side werden dort ECHT +// orthografisch, Iso/Perspective perspektivisch, exakt wie die native Fenster- +// Sicht. „Praeset = hinspringen, kein Lock" (wie `applyView3d` in der three.js- +// Sicht): manuelles Orbit/Pan/Zoom bleibt danach frei — es fährt mit der +// FREIEN perspektivischen Orbit-Kamera fort (Kugelkoordinaten, s. u.), deren +// Start-Blickwinkel VIEW3D_ORBIT so wählt, dass der erste Drag-Frame nahtlos +// an die Praeset-Blickrichtung anschliesst (Rust berechnet dieselbe Richtung +// für die exakte, evtl. orthografische Praeset-Kamera). // // 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, useState } from "react"; +import { useEffect, useRef } from "react"; import type { Project } from "../model/types"; import { projectToModel3d } from "../plan/toWalls3d"; +import type { RenderMode, View3d } from "../ui/TopBar"; import { t } from "../i18n"; import { useWasm3dRenderer } from "./useWasm3dRenderer"; import type { Camera3d } from "./useWasm3dRenderer"; -import "./wasm3dOverlay.css"; -/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°). */ +/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°), + * auch für die FREIE Orbit-Kamera hier (die Presets selbst nutzen den Rust- + * seitigen Default-FOV, s. `types::default_fov`, denselben Wert). */ 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; @@ -41,7 +57,9 @@ interface OrbitState { target: [number, number, number]; } -/** Fertige Kamera aus dem Orbit-Zustand (Konvention wie render3d::math::orbit_eye). */ +/** Fertige (freie, perspektivische) Kamera aus dem Orbit-Zustand (Konvention + * wie render3d::math::orbit_eye). Für die manuelle Navigation NACH einem + * Praeset-Sprung — die Presets selbst zeichnen über `set_view_preset`. */ function orbitCamera(o: OrbitState): Camera3d { const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch)); const r = o.dist * Math.cos(pitch); @@ -61,9 +79,10 @@ function orbitCamera(o: OrbitState): Camera3d { /** * Formatfüllendes Ziel + Distanz: Bounding-Box des geflachten Modells (Wände + - * Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen. Gemeinsame - * Grundlage für den Start-Orbit UND die Kamera-Presets (beide sollen das - * Modell formatfüllend zeigen, nur der Blickwinkel unterscheidet sich). + * Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen. Mirror der + * Rust-Bounding-Box in `web::model_bounds`/`set_view_preset` — beide Seiten + * müssen auf dieselbe Distanz/dasselbe Ziel kommen, damit der Orbit-Zustand + * (hier) nahtlos an die exakte Praeset-Kamera (dort) anschliesst. */ function fitTargetDist(project: Project): { target: [number, number, number]; @@ -94,7 +113,8 @@ function fitTargetDist(project: Project): { grow(p[0], s.zTop, p[1]); } } - // Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start). + // Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start und dem + // Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box). if (!Number.isFinite(minX)) { return { dist: 14, target: [2.5, 1, 2] }; } @@ -111,161 +131,117 @@ function fitTargetDist(project: Project): { } /** - * Formatfüllender Start-Orbit: Blick von schräg oben (wie das three.js- - * Perspektive-Preset). - */ -function fitOrbit(project: Project): OrbitState { - const { target, dist } = fitTargetDist(project); - return { yaw: Math.PI / 4, pitch: 0.5, dist, target }; -} - -/** - * Kanonische Blickwinkel-Presets (Vectorworks/DOSSIER-Stil, wie - * `applyView3d` in Viewport3D.tsx — hier als Kugelkoordinaten yaw/pitch statt - * eines Richtungsvektors, weil der Engine-Pfad die Kamera über den Orbit- - * Zustand treibt, s. o.). Herleitung (Y-up, world x = Plan-x, world z = - * Plan-y): - * front/back: Blick entlang ±Z (yaw = ±90°, pitch 0). - * left/right: Blick entlang ±X (yaw = 0°/180°, pitch 0). - * top/bottom: senkrecht von oben/unten; pitch ans Gimbal-Limit geklemmt - * (exakt 90° macht die Kamerabasis singulär — dieselbe Klemme wie beim - * freien Orbit, s. PITCH_LIMIT), mit demselben leichten Yaw-Versatz wie - * front/back (stabile, vorhersagbare „oben"-Achse am Bildschirm, analog - * zum three.js-Kniff `dir=(0,1,0.0001)`). - * iso: Ecklage von schräg oben (SW-Iso) — Richtung (1,1,1) normiert, exakt - * wie das three.js-Iso-Preset (Elevationswinkel arcsin(1/√3) ≈ 35.26°). + * Orbit-Blickwinkel (yaw/pitch), die DIESELBE Blickrichtung wie das jeweilige + * `view3d`-Praeset ergeben (hergeleitet aus `preset_camera` in + * render3d/src/math.rs — front/side/top/iso/perspective). Dient NICHT dem + * Zeichnen der Presets selbst (das macht `set_view_preset` in Rust, inkl. + * echter Orthografie für front/top/side) — nur als Startpunkt für den freien + * Orbit-Zustand, damit ein Drag direkt nach einem Praeset-Sprung nicht aus + * einem unpassenden Blickwinkel losspringt. */ const ISO_PITCH = Math.asin(1 / Math.sqrt(3)); -const VIEW_PRESETS: Record< - "top" | "bottom" | "front" | "back" | "left" | "right" | "iso", - { yaw: number; pitch: number } -> = { - top: { yaw: Math.PI / 2, pitch: PITCH_LIMIT }, - bottom: { yaw: -Math.PI / 2, pitch: -PITCH_LIMIT }, +const VIEW3D_ORBIT: Record = { front: { yaw: Math.PI / 2, pitch: 0 }, - back: { yaw: -Math.PI / 2, pitch: 0 }, - right: { yaw: 0, pitch: 0 }, - left: { yaw: Math.PI, pitch: 0 }, + side: { yaw: 0, pitch: 0 }, + top: { yaw: Math.PI / 2, pitch: PITCH_LIMIT }, iso: { yaw: Math.PI / 4, pitch: ISO_PITCH }, + perspective: { yaw: Math.PI / 4, pitch: 0.5 }, }; -type ViewPreset = keyof typeof VIEW_PRESETS; -/** Labels der Preset-Knöpfe: bestehende i18n-Schlüssel wiederverwendet, wo - * vorhanden (view3d.*, aus der three.js-Oberleiste); für die im engine-Pfad - * zusätzlichen Ansichten (bottom/back/left/right, dort nicht vorhanden) ein - * kurzer inline-deutscher Text — keine neuen i18n-Schlüssel (Projektregel). - * Als Funktion (nicht Modul-Konstante) aufgerufen, damit `t()` bei jedem - * Render die aktuelle Sprache liest (wie der bestehende Fallback-Text unten). */ -function viewPresetLabel(preset: ViewPreset): string { - switch (preset) { - case "top": - return t("view3d.top"); - case "bottom": - return "Unten"; - case "front": - return t("view3d.front"); - case "back": - return "Hinten"; - case "left": - return "Links"; - case "right": - return "Rechts"; - case "iso": - return t("view3d.iso"); - } -} -const VIEW_PRESET_ORDER: ViewPreset[] = [ - "top", - "bottom", - "front", - "back", - "left", - "right", - "iso", -]; - -export function Wasm3DViewport({ project }: { project: Project }) { +export function Wasm3DViewport({ + project, + view3d = "perspective", + renderMode = "shaded", +}: { + project: Project; + /** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ + view3d?: View3d; + /** Darstellungsart (Oberleiste): nur `shaded`/`white` wirken hier (die + * übrigen Modi der three.js-Sicht sind im Engine-Pfad noch nicht gebaut, + * s. Datei-Kommentar) — alles ausser `white` fällt auf shaded zurück. */ + renderMode?: RenderMode; +}) { const canvasRef = useRef(null); - const { render, updateModel, setWhiteMode, ready, failed } = useWasm3dRenderer( - canvasRef, - true, - ); + const { render, updateModel, setWhiteMode, applyViewPreset, 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. + // direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der + // freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar). const orbitRef = useRef(null); + // Redraw-Strategie des zuletzt aktiven Kamera-Modus (Praeset ODER freier + // Orbit) — Resize/Modell-Update ruft diese auf, ohne den Modus zu kennen. + const redrawRef = useRef<() => void>(() => {}); // Frisches Projekt für den (stabilen) Init-Effekt unten. const projectRef = useRef(project); projectRef.current = project; - // Aktiver Blickwinkel-Preset (nur fürs Knopf-Hervorheben) + Darstellungsart - // (Overlay-UI, s. u.). Bewusst React-State — anders als der Orbit ändern - // sich beide selten (Klick, nicht Mausbewegung), ein Re-Render ist unkritisch. - const [activePreset, setActivePreset] = useState(null); - const [renderMode, setRenderMode] = useState<"shaded" | "white">("shaded"); - - // 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). + // Modell bei Bereitschaft + jeder Projektänderung neu einspielen. Die + // Kamera bleibt dabei stehen (gleiches Verhalten wie der three.js-Kamera- + // Snapshot) — nur das `view3d`-Effekt unten springt aktiv auf ein Praeset. useEffect(() => { if (!ready) return; updateModel(project); - if (!orbitRef.current) orbitRef.current = fitOrbit(project); - render(orbitCamera(orbitRef.current)); - }, [ready, project, updateModel, render]); + redrawRef.current(); + }, [ready, project, updateModel]); + + // Blickwinkel-Praeset anwenden, wenn `view3d` wechselt (oder der Renderer + // bereit wird): springt über `set_view_preset` (Rust) auf die formatfüllende, + // ggf. orthografische Praeset-Kamera — „hinspringen, kein Lock", der + // Orbit-Zustand wird nur als Startpunkt für spätere manuelle Navigation + // nachgezogen (s. VIEW3D_ORBIT). + useEffect(() => { + if (!ready) return; + applyViewPreset(view3d); + const { yaw, pitch } = VIEW3D_ORBIT[view3d]; + const { target, dist } = fitTargetDist(projectRef.current); + orbitRef.current = { yaw, pitch, dist, target }; + redrawRef.current = () => render(null); + redrawRef.current(); + }, [ready, view3d, applyViewPreset, render]); // Darstellungsart (shaded/weiss) an den Renderer weiterreichen, sobald sie - // wechselt (Overlay-Klick) oder der Renderer bereit wird — dann neu zeichnen. + // wechselt oder der Renderer bereit wird — dann mit der aktuell aktiven + // Kamera (Praeset ODER Orbit) neu zeichnen. useEffect(() => { if (!ready) return; setWhiteMode(renderMode === "white"); - if (orbitRef.current) render(orbitCamera(orbitRef.current)); - }, [ready, renderMode, setWhiteMode, render]); - - // Blickwinkel-Preset anwenden: formatfüllend auf die aktuelle Modell- - // Bounding-Box (wie fitOrbit), aber mit dem Preset-Blickwinkel statt des - // Standard-Schrägblicks. „Preset = dorthin springen, kein Lock" — danach - // bleibt Orbit/Pan/Zoom frei (gleiche Philosophie wie Viewport3D.applyView3d). - const applyPreset = (name: ViewPreset) => { - if (!ready) return; - const { yaw, pitch } = VIEW_PRESETS[name]; - const { target, dist } = fitTargetDist(projectRef.current); - orbitRef.current = { yaw, pitch, dist, target }; - setActivePreset(name); - render(orbitCamera(orbitRef.current)); - }; + redrawRef.current(); + }, [ready, renderMode, setWhiteMode]); // 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)); - }); + const ro = new ResizeObserver(() => redrawRef.current()); ro.observe(canvas); return () => ro.disconnect(); - }, [ready, render]); + }, [ready]); - // Maus-Navigation (MITTE = Orbit, Shift+MITTE = Pan, RAD = Zoom). + // Maus-Navigation (MITTE = Orbit, Shift+MITTE = Pan, RAD = Zoom). Verlässt + // damit IMMER die Praeset-Kamera und wechselt auf die freie perspektivische + // Orbit-Kamera (Kontinuität über den in VIEW3D_ORBIT vorbereiteten Start- + // Blickwinkel) — gleiche Philosophie wie OrbitControls in der three.js-Sicht. useEffect(() => { const canvas = canvasRef.current; if (!ready || !canvas) return; + const useOrbitRedraw = () => { + redrawRef.current = () => { + if (orbitRef.current) render(orbitCamera(orbitRef.current)); + }; + }; + let drag: { mode: "orbit" | "pan"; x: number; y: number } | null = null; - const redraw = () => { - if (orbitRef.current) render(orbitCamera(orbitRef.current)); - }; + const redraw = () => redrawRef.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); - // Freies Navigieren verlässt den Preset-Blickwinkel — Knopf-Hervorhebung - // löschen (EINMAL je Geste, nicht je Pointermove: kein State-Update im - // Bewegungs-Hotpath, s. Kommentar oben zum Orbit-Ref). - setActivePreset((prev) => (prev === null ? prev : null)); + useOrbitRedraw(); }; const onPointerMove = (e: PointerEvent) => { @@ -319,9 +295,9 @@ export function Wasm3DViewport({ project }: { project: Project }) { const o = orbitRef.current; if (!o) return; e.preventDefault(); + useOrbitRedraw(); // 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))); - setActivePreset((prev) => (prev === null ? prev : null)); redraw(); }; @@ -347,63 +323,10 @@ export function Wasm3DViewport({ project }: { project: Project }) { return (
{/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die - Renderauflösung (DPR) gleicht der Hook je Frame ab. */} + Renderauflösung (DPR) gleicht der Hook je Frame ab. Presets + + Darstellungsart kommen jetzt von der Oberleiste (TopBar), kein + eigenes Overlay mehr hier. */} - {!failed && ( -
- {/* Kamera-Presets (Vectorworks/DOSSIER-Stil, s. VIEW_PRESETS oben) — - „dorthin springen", kein Lock: danach bleibt Orbit/Pan/Zoom frei. */} -
- {VIEW_PRESET_ORDER.map((key) => ( - - ))} -
- {/* Darstellungsart: shaded (Bauteil-/Materialfarbe, Default) vs. white - (einheitliches beleuchtetes Hellgrau, Clay-Look) — Rust-Uniform - `mode.x` (shaders.rs). Texturiert/Drahtgitter/Kanten der three.js- - Sicht sind hier (noch) nicht gebaut, s. Datei-Kommentar. */} -
- - -
-
- )} {failed && (
{t("viewport3d.engineUnavailable")} diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index a891df1..e26dc52 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -38,6 +38,7 @@ export interface Camera3d { interface WebModelRendererLike { set_model(json: string): void; set_render_mode_white(white: boolean): void; + set_view_preset(preset: string): void; set_camera( eyeX: number, eyeY: number, @@ -144,9 +145,33 @@ export function useWasm3dRenderer( } }, []); - /** Einen Frame mit der gegebenen Kamera zeichnen (Canvas-Größe + DPR abgleichen). */ + /** + * Kamera-Praeset anwenden (`front`/`top`/`side`/`iso`/`perspective`, wie + * `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz- + * Berechnung + Projektionsart (ortho fuer front/top/side) an Rust + * (`web::set_view_preset` -> `math::preset_camera`) — dieselbe Logik wie das + * native Fenster, statt einer zweiten Praeset-Tabelle in TS. Wirkt erst beim + * naechsten `render`. + */ + const applyViewPreset = useCallback((preset: string) => { + const r = rendererRef.current; + if (!r) return; + try { + r.set_view_preset(preset); + } catch (e) { + console.warn("render3d set_view_preset fehlgeschlagen:", e); + } + }, []); + + /** + * Einen Frame zeichnen (Canvas-Größe + DPR abgleichen). `cam` = null lässt + * die zuletzt in Rust gesetzte Kamera unverändert — Fall für `render(null)` + * direkt nach `applyViewPreset` (die Kamera kommt dann aus `preset_camera`, + * ein `set_camera` hier würde das freie Orbit-Ergebnis darüberschreiben und + * die orthografische Projektion der Presets zunichtemachen). + */ const render = useCallback( - (cam: Camera3d) => { + (cam: Camera3d | null) => { const r = rendererRef.current; const canvas = canvasRef.current; if (!r || !canvas) return; @@ -160,20 +185,22 @@ export function useWasm3dRenderer( } 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, - ); + if (cam) { + 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); @@ -182,5 +209,5 @@ export function useWasm3dRenderer( [canvasRef], ); - return { render, updateModel, setWhiteMode, ready, failed }; + return { render, updateModel, setWhiteMode, applyViewPreset, ready, failed }; } diff --git a/src/viewport/wasm3dOverlay.css b/src/viewport/wasm3dOverlay.css deleted file mode 100644 index fb07f38..0000000 --- a/src/viewport/wasm3dOverlay.css +++ /dev/null @@ -1,53 +0,0 @@ -/* Steuer-Overlay des engine-3D-Viewports (Wasm3DViewport): Kamera-Presets + - * Darstellungsart. Bewusst eine EIGENE Datei (nicht styles.css) — dieser - * Viewport-Pfad wird unabhängig von der three.js-Sicht weiterentwickelt. - * Nutzt dieselben CSS-Variablen wie die übrige App (in :root definiert), damit - * sich der Overlay optisch nahtlos einfügt (auch im Dunkelmodus). */ - -.wasm3d-overlay { - position: absolute; - top: 10px; - left: 10px; - z-index: 3; - display: flex; - flex-direction: column; - gap: 6px; - pointer-events: none; -} - -.wasm3d-overlay-group { - display: flex; - flex-wrap: wrap; - gap: 3px; - padding: 4px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); - pointer-events: auto; -} - -.wasm3d-overlay-btn { - min-width: 34px; - padding: 4px 8px; - font-size: 11px; - font-weight: 600; - line-height: 1.4; - color: var(--ink, #ddd); - background: transparent; - border: 1px solid var(--border); - border-radius: 5px; - cursor: pointer; -} - -.wasm3d-overlay-btn:hover { - background: var(--accent-dim); - border-color: var(--accent-border); - color: var(--accent); -} - -.wasm3d-overlay-btn.active { - background: var(--accent-dim); - border-color: var(--accent); - color: var(--accent); -}