diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index 9d682d1..e73e4ff 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -12,9 +12,10 @@ use bytemuck::{Pod, Zeroable}; use wgpu::util::DeviceExt; +use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX}; use crate::math::{view_projection, Mat4}; use crate::mesh::{build_scene_mesh, build_walls_mesh}; -use crate::shaders::MESH_WGSL; +use crate::shaders::{GRID_WGSL, MESH_WGSL}; use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX}; /// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar). @@ -68,14 +69,30 @@ struct MeshBuffers { index_count: u32, } +/// GPU-seitiger Vertex-Buffer des Bodengitters (LineList, kein Indexpuffer). +struct GridBuffers { + vbo: wgpu::Buffer, + vertex_count: u32, +} + /// Der native 3D-Renderer: haelt die Pipeline, das Uniform (View-Projektion + /// Licht) und das aktuell hochgeladene Mesh. Ein Tiefenpuffer wird passend zur /// Ziel-Groesse (neu) angelegt. pub struct Renderer { pipeline: wgpu::RenderPipeline, + /// Eigene, schlanke Linien-Pipeline (LineList, unlit) fuer das Bodengitter. + /// Teilt sich das `Globals`-Uniform/Bind-Group (nur View-Projektion) mit der + /// Mesh-Pipeline und rendert in denselben Pass/Tiefenpuffer. + grid_pipeline: wgpu::RenderPipeline, bind_group: wgpu::BindGroup, uniform: wgpu::Buffer, mesh: Option, + /// Bodengitter-Vertices (None = nicht erzeugt). Wird nur gezeichnet, wenn + /// zusaetzlich `grid_visible` gesetzt ist. + grid: Option, + /// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false — + /// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter. + grid_visible: bool, depth: Option, msaa: Option, /// Farbformat des Render-Ziels (Surface), fuer die MSAA-Zwischentextur. @@ -180,6 +197,60 @@ impl Renderer { cache: None, }); + // Bodengitter-Pipeline: dieselbe Bind-Group (Globals/View-Projektion) und + // dasselbe Depth-/MSAA-/Farbziel wie die Mesh-Pipeline, aber LineList- + // Topologie, ein eigener unlit-Shader (konstante Vertexfarbe) und ein + // schlankeres Vertex-Layout [pos vec3, color vec3]. + let grid_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("grid.wgsl"), + source: wgpu::ShaderSource::Wgsl(GRID_WGSL.into()), + }); + let grid_vertex_layout = wgpu::VertexBufferLayout { + array_stride: (GRID_FLOATS_PER_VERTEX * 4) as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3], + }; + let grid_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("grid.pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &grid_module, + entry_point: Some("vs_main"), + buffers: &[grid_vertex_layout], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &grid_module, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + // Linien haben keine Vorder-/Rueckseite -> kein Culling. + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("globals.buffer"), size: std::mem::size_of::() as u64, @@ -197,9 +268,12 @@ impl Renderer { Self { pipeline, + grid_pipeline, bind_group, uniform, mesh: None, + grid: None, + grid_visible: false, depth: None, msaa: None, color_format, @@ -274,6 +348,36 @@ impl Renderer { self.globals.mode[0] = if white { 1.0 } else { 0.0 }; } + /// Setzt/aktualisiert das Referenz-Bodengitter (grid.rs). Erzeugt den Linien- + /// Vertex-Buffer aus den Parametern neu und merkt sich die Sichtbarkeit; der + /// naechste `render` zeichnet das Gitter (bzw. nichts bei `visible == false`). + /// Ohne Aufruf bleibt das Gitter unsichtbar (Default), damit bestehende + /// Aufrufer unveraendert bleiben. + pub fn set_ground_grid( + &mut self, + device: &wgpu::Device, + visible: bool, + elevation: f32, + extent: f32, + spacing: f32, + ) { + self.grid_visible = visible; + let verts = build_ground_grid(elevation, extent, spacing); + if verts.is_empty() { + self.grid = None; + return; + } + let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("grid.vbo"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + self.grid = Some(GridBuffers { + vbo, + vertex_count: (verts.len() / GRID_FLOATS_PER_VERTEX) as u32, + }); + } + /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert. fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h); @@ -396,6 +500,17 @@ impl Renderer { pass.set_index_buffer(m.ibo.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..m.index_count, 0, 0..1); } + + // Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer -> + // das Modell verdeckt das Gitter korrekt). Nur wenn sichtbar + vorhanden. + if self.grid_visible { + if let Some(g) = &self.grid { + pass.set_pipeline(&self.grid_pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, g.vbo.slice(..)); + pass.draw(0..g.vertex_count, 0..1); + } + } } queue.submit(std::iter::once(encoder.finish())); } diff --git a/src-tauri/render3d/src/grid.rs b/src-tauri/render3d/src/grid.rs new file mode 100644 index 0000000..17a053b --- /dev/null +++ b/src-tauri/render3d/src/grid.rs @@ -0,0 +1,176 @@ +// Referenz-Bodengitter (Ground-Grid) fuer den 3D-Renderer. +// +// Erzeugt CPU-seitig die Linien-Vertices eines quadratischen Referenzgitters in +// der Welt-XZ-Ebene (Grundriss-Ebene), zentriert um den Welt-Ursprung +// (x=0, z=0), auf konstanter Hoehe `y = elevation`. Damit hat der 3D-Viewport +// einen dezenten Boden + raeumliche Orientierung. +// +// KOORDINATEN (wie types.rs): Welt-Y ist die Hoehe, XZ die Grundriss-Ebene. Das +// Gitter liegt also flach in XZ auf konstantem y = elevation. +// +// Diese Schicht ist GPU-frei (nur `Vec`), damit sie headless per `cargo +// test` pruefbar bleibt (Muster: mesh.rs/section.rs). Die GPU-Schicht (gpu.rs) +// laedt die erzeugten Vertices in einen Linien-Vertex-Buffer und zeichnet sie +// ueber eine eigene `LineList`-Pipeline (shaders::GRID_WGSL). + +/// f32 je Gitter-Vertex im interleaved Puffer: 3 Position (world) + 3 Farbe (rgb). +pub const GRID_FLOATS_PER_VERTEX: usize = 6; + +/// Farbe einer normalen ("Minor") Gitterlinie (RGB 0..1). Gedecktes Neutralgrau, +/// das sich vom hellen Hintergrund (#f5f5f5) und vom Modell (~0.8er Grau) abhebt, +/// ohne zu dominieren. +pub const MINOR_COLOR: [f32; 3] = [0.72, 0.72, 0.74]; + +/// Farbe einer "Major"-Linie (jede 5. Linie, inkl. Mittelachse): etwas kraeftiger +/// (dunkler) als Minor, damit die Rasterweite ablesbar bleibt. Auf dem hellen +/// Hintergrund liest sich "dunkler = betonter" am besten (CAD-uebliche Optik). +pub const MAJOR_COLOR: [f32; 3] = [0.55, 0.55, 0.58]; + +/// Jede n-te Linie (vom Zentrum aus gezaehlt) wird als Major-Linie eingefaerbt. +const MAJOR_EVERY: i32 = 5; + +/// Anzahl der Gitterlinien PRO Richtung fuer gegebene Ausdehnung/Weite. +/// +/// Linien sitzen bei `i * spacing` fuer `i` in `-steps..=steps`, wobei +/// `steps = floor((extent/2) / spacing)`. Ergebnis ist damit immer ungerade +/// (die Mittelachse `i = 0` ist stets dabei). 0, falls Parameter unbrauchbar. +pub fn line_count(extent: f32, spacing: f32) -> usize { + if !(extent > 0.0) || !(spacing > 0.0) { + return 0; + } + let steps = ((extent * 0.5) / spacing).floor() as i32; + if steps < 0 { + return 0; + } + (2 * steps + 1) as usize +} + +/// Baut die Linien-Vertices (Positions + Farbe, interleaved) fuer das Bodengitter. +/// +/// - Quadratisches Raster in der Welt-XZ-Ebene, zentriert um (0,0), auf `y = elevation`. +/// - `extent` = Gesamt-Kantenlaenge (Meter); tatsaechlich genutzte Halb-Ausdehnung +/// ist `steps * spacing` (auf ein ganzes Vielfaches der Weite gerundet), damit +/// das Gitter ein sauberes Quadrat bildet, dessen Linien sich an den Ecken treffen. +/// - `spacing` = Linienabstand (Meter). +/// - Jede `MAJOR_EVERY`-te Linie (inkl. Mittelachse) bekommt `MAJOR_COLOR`, sonst `MINOR_COLOR`. +/// +/// Ausgabe: `LineList`-Vertices (je 2 aufeinanderfolgende Vertices = 1 Segment), +/// interleaved `[px,py,pz, r,g,b, ...]`. Leerer Vektor bei unbrauchbaren Parametern. +pub fn build_ground_grid(elevation: f32, extent: f32, spacing: f32) -> Vec { + let n = line_count(extent, spacing); + if n == 0 { + return Vec::new(); + } + let steps = (n as i32 - 1) / 2; + let half = steps as f32 * spacing; + + // 2 Richtungen * n Linien * 2 Endpunkte * 6 f32. + let mut verts: Vec = Vec::with_capacity(2 * n * 2 * GRID_FLOATS_PER_VERTEX); + + let mut push = |x: f32, z: f32, c: [f32; 3]| { + verts.push(x); + verts.push(elevation); + verts.push(z); + verts.push(c[0]); + verts.push(c[1]); + verts.push(c[2]); + }; + + for i in -steps..=steps { + let c = if i % MAJOR_EVERY == 0 { + MAJOR_COLOR + } else { + MINOR_COLOR + }; + let p = i as f32 * spacing; + // Linie parallel zur X-Achse (konstantes z = p): von -half bis +half in x. + push(-half, p, c); + push(half, p, c); + // Linie parallel zur Z-Achse (konstantes x = p): von -half bis +half in z. + push(p, -half, c); + push(p, half, c); + } + + verts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_count_inclusive_and_odd() { + // extent 20, spacing 1 -> steps 10 -> 21 Linien je Richtung. + assert_eq!(line_count(20.0, 1.0), 21); + // extent 10, spacing 2 -> steps 2 (half=5, 5/2=2.5 floor 2) -> 5 Linien. + assert_eq!(line_count(10.0, 2.0), 5); + // Mittelachse immer dabei -> ungerade. + assert_eq!(line_count(3.0, 1.0) % 2, 1); + } + + #[test] + fn degenerate_params_yield_empty() { + assert_eq!(line_count(0.0, 1.0), 0); + assert_eq!(line_count(20.0, 0.0), 0); + assert_eq!(line_count(-5.0, 1.0), 0); + assert!(build_ground_grid(0.0, 0.0, 1.0).is_empty()); + assert!(build_ground_grid(0.0, 20.0, 0.0).is_empty()); + } + + #[test] + fn vertex_count_matches_lines() { + let extent = 20.0; + let spacing = 1.0; + let n = line_count(extent, spacing); // 21 + let verts = build_ground_grid(0.0, extent, spacing); + // 2 Richtungen * n Linien * 2 Endpunkte * 6 f32. + assert_eq!(verts.len(), 2 * n * 2 * GRID_FLOATS_PER_VERTEX); + // = 84 Vertices. + assert_eq!(verts.len() / GRID_FLOATS_PER_VERTEX, 4 * n); + } + + #[test] + fn all_vertices_lie_on_elevation_plane_and_within_extent() { + let elevation = 2.7; + let extent = 20.0; + let spacing = 1.0; + let half = 10.0; // steps=10 * spacing=1 + let verts = build_ground_grid(elevation, extent, spacing); + for v in verts.chunks(GRID_FLOATS_PER_VERTEX) { + let (x, y, z) = (v[0], v[1], v[2]); + assert_eq!(y, elevation, "alle Vertices auf y = elevation"); + assert!(x.abs() <= half + 1e-6); + assert!(z.abs() <= half + 1e-6); + } + } + + #[test] + fn first_line_endpoints_and_center_is_major() { + let extent = 20.0; + let spacing = 1.0; + let half = 10.0; + let verts = build_ground_grid(0.0, extent, spacing); + // Erste Linie: i = -steps = -10 (kein Vielfaches von 5? -10 % 5 == 0 -> Major). + // X-parallele Linie bei z = -10, von x=-half..+half. + assert_eq!(&verts[0..3], &[-half, 0.0, -10.0]); + assert_eq!(&verts[3..6], &MAJOR_COLOR); // -10 ist Vielfaches von 5 -> Major + assert_eq!(&verts[6..9], &[half, 0.0, -10.0]); + + // Mittelachse (i = 0) muss als Major vorkommen: suche Vertex bei z=0 X-Linie. + let center_is_major = verts + .chunks(GRID_FLOATS_PER_VERTEX) + .any(|v| v[2] == 0.0 && v[3..6] == MAJOR_COLOR); + assert!(center_is_major); + } + + #[test] + fn extent_snaps_to_spacing_multiple() { + // extent 21, spacing 2 -> steps = floor(10.5/2) = 5 -> half = 10 (nicht 10.5). + let verts = build_ground_grid(0.0, 21.0, 2.0); + let max_abs = verts + .chunks(GRID_FLOATS_PER_VERTEX) + .flat_map(|v| [v[0].abs(), v[2].abs()]) + .fold(0.0_f32, f32::max); + assert_eq!(max_abs, 10.0); + } +} diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index a5039d9..46f26d8 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -14,6 +14,7 @@ // Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die // GPU-freien Schichten und ist damit unabhaengig von einer Display-Session. +pub mod grid; pub mod math; pub mod mesh; pub(crate) mod openings; @@ -808,5 +809,13 @@ mod tests { validator .validate(&module) .unwrap_or_else(|e| panic!("mesh: WGSL-Validierung fehlgeschlagen: {e:?}")); + + // Bodengitter-Shader ebenso headless validieren. + let grid_src = super::shaders::GRID_WGSL; + let grid_module = naga::front::wgsl::parse_str(grid_src) + .unwrap_or_else(|e| panic!("grid: WGSL-Parse-Fehler: {e:?}")); + Validator::new(ValidationFlags::all(), Capabilities::all()) + .validate(&grid_module) + .unwrap_or_else(|e| panic!("grid: WGSL-Validierung fehlgeschlagen: {e:?}")); } } diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index 32706d0..9ecd714 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -82,3 +82,43 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { return vec4(shaded, 1.0); } "#; + +/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline: +/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion +/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline. +/// Vertex-Attribute: position (world), color (rgb). Depth-Test laeuft ueber den +/// gemeinsamen Tiefenpuffer, sodass das Modell das Gitter korrekt verdeckt. +pub const GRID_WGSL: &str = r#" +struct Globals { + view_proj : mat4x4, + light_dir : vec4, + sky_color : vec4, + ground_color : vec4, + sun_color : vec4, + mode : vec4, +}; +@group(0) @binding(0) var globals : Globals; + +struct VsIn { + @location(0) position : vec3, + @location(1) color : vec3, +}; + +struct VsOut { + @builtin(position) clip_pos : vec4, + @location(0) color : vec3, +}; + +@vertex +fn vs_main(in : VsIn) -> VsOut { + var out : VsOut; + out.clip_pos = globals.view_proj * vec4(in.position, 1.0); + out.color = in.color; + return out; +} + +@fragment +fn fs_main(in : VsOut) -> @location(0) vec4 { + return vec4(in.color, 1.0); +} +"#; diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index d72262c..0e04657 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -327,6 +327,25 @@ impl WebModelRenderer { self.renderer.set_white_mode(white); } + /// Schaltet das Referenz-Bodengitter (grid.rs) ein/aus und setzt seine + /// Parameter. `elevation_m` = Hoehe der XZ-Gitterebene (world-Meter, i. d. R. + /// die z=0-Ebene des aktiven Geschosses); `extent_m` = quadratische Kantenlaenge + /// (auf ein Vielfaches von `spacing_m` gerundet); `spacing_m` = Linienabstand. + /// Das Gitter ist zentriert um den Welt-Ursprung (x=0, z=0). Wirkt erst beim + /// naechsten `render`. Default ohne Aufruf: unsichtbar (bestehende Aufrufer + /// bleiben unveraendert). Jede 5. Linie (inkl. Mittelachse) ist eine Major-Linie. + pub fn set_ground_grid( + &mut self, + visible: bool, + elevation_m: f32, + extent_m: f32, + spacing_m: f32, + ) -> Result<(), JsValue> { + self.renderer + .set_ground_grid(&self.device, visible, elevation_m, extent_m, spacing_m); + Ok(()) + } + /// 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)); diff --git a/src/App.tsx b/src/App.tsx index f9343fe..945b2a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -269,6 +269,10 @@ export default function App() { const setView3d = useStore((s) => s.setView3d); const fov = useStore((s) => s.fov); const setFov = useStore((s) => s.setFov); + // Boden-Referenzraster im 3D-Viewport (wirkt nur im WASM/Nordstern-Pfad, + // s. Wasm3DViewport.tsx) — Umschalter sitzt als Overlay-Button im Viewport. + const grid3dVisible = useStore((s) => s.grid3dVisible); + const setGrid3dVisible = useStore((s) => s.setGrid3dVisible); const planColorMode = useStore((s) => s.planColorMode); const setPlanColorMode = useStore((s) => s.setPlanColorMode); const referenceLines = useStore((s) => s.referenceLines); @@ -3104,6 +3108,8 @@ export default function App() { renderMode={renderMode} view3d={view3d} fov={fov} + grid3dVisible={grid3dVisible} + onToggleGrid3d={() => setGrid3dVisible(!grid3dVisible)} mono={planColorMode === "mono"} drawings={drawings} onSelectLevel={setActiveLevelId} @@ -4065,6 +4071,8 @@ function Content({ renderMode, view3d, fov, + grid3dVisible, + onToggleGrid3d, mono, drawings, onSelectLevel, @@ -4126,6 +4134,10 @@ function Content({ view3d: View3d; /** Sichtwinkel der 3D-Perspektivkamera in Grad. */ fov: number; + /** Boden-Referenzraster im 3D-Viewport sichtbar (wirkt nur im WASM-Pfad). */ + grid3dVisible: boolean; + /** Klick auf den Grid-Umschalter-Button im 3D-Viewport (WASM-Pfad). */ + onToggleGrid3d: () => void; /** Grundriss in Schwarz-Weiss (mono) statt mit Kategorie-/Element-Farben. */ mono: boolean; /** Dokument-Tabs der Kopfzeile — ein Tab je Geschoss-Grundriss. */ @@ -4310,6 +4322,8 @@ function Content({ view3d={view3d} fov={fov} gridElevation={level.baseElevation ?? 0} + gridVisible={grid3dVisible} + onToggleGrid={onToggleGrid3d} onSelectWall={onViewportSelectWall} onSelectCeiling={onViewportSelectCeiling} onSelectOpening={onViewportSelectOpening} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 7b5e002..3b2a038 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -914,6 +914,8 @@ export const de = { "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.", + "viewport3d.grid.show": "Bodenraster einblenden", + "viewport3d.grid.hide": "Bodenraster ausblenden", } as const; export type TranslationKey = keyof typeof de; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 62df35d..496b2f6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -905,4 +905,6 @@ export const en: Record = { "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.", + "viewport3d.grid.show": "Show ground grid", + "viewport3d.grid.hide": "Hide ground grid", }; diff --git a/src/state/viewSlice.ts b/src/state/viewSlice.ts index a85936f..353a4d9 100644 --- a/src/state/viewSlice.ts +++ b/src/state/viewSlice.ts @@ -33,6 +33,10 @@ export interface ViewSlice { view3d: View3d; // Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad. fov: number; + // Boden-Referenzraster im 3D-Viewport ein-/ausgeblendet (wirkt aktuell nur + // im Nordstern/WASM-Pfad — die three.js-Sicht zeigt ihr Raster unveraendert + // immer; s. Wasm3DViewport.tsx). + grid3dVisible: boolean; // Farb-Modus des Grundrisses: "color" = Kategorie-/Element-Farben (heute), // "mono" = reiner Schwarz-Weiss-Plan (Tinte, keine Farb-/Schraffurfarben). planColorMode: "color" | "mono"; @@ -60,6 +64,7 @@ export interface ViewSlice { setRenderMode: (m: RenderMode) => void; setView3d: (v: View3d) => void; setFov: (n: number) => void; + setGrid3dVisible: (b: boolean) => void; setPlanColorMode: (m: "color" | "mono") => void; setReferenceLines: (b: boolean) => void; setLineMode: (m: "display" | "print") => void; @@ -83,6 +88,7 @@ export function createViewSlice(api: StoreApi): ViewSlice { renderMode: "shaded", view3d: "perspective", fov: 50, + grid3dVisible: true, planColorMode: "color", referenceLines: false, lineMode: "display", @@ -107,6 +113,7 @@ export function createViewSlice(api: StoreApi): ViewSlice { setRenderMode: (m) => set({ renderMode: m }), setView3d: (v) => set({ view3d: v }), setFov: (n) => set({ fov: n }), + setGrid3dVisible: (b) => set({ grid3dVisible: b }), setPlanColorMode: (m) => set({ planColorMode: m }), setReferenceLines: (b) => set({ referenceLines: b }), setLineMode: (m) => set({ lineMode: m }), diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index 47d240f..0907c6c 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -115,17 +115,31 @@ const WASM_ENGINE_ACTIVE: boolean = (() => { * 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[0]) { +export function Viewport3D( + props: Parameters[0] & { + /** Boden-Referenzraster ein-/ausblenden — wirkt NUR im WASM-Pfad (der + * Overlay-Umschalter sitzt im Wasm3DViewport selbst); die three.js-Sicht + * zeigt ihr Raster unveraendert immer (kein Toggle dort). */ + gridVisible?: boolean; + /** Klick auf den Grid-Umschalter-Button im WASM-Viewport — Single Source + * of Truth bleibt der Aufrufer (App/viewSlice `grid3dVisible`). */ + onToggleGrid?: () => void; + }, +) { + const { gridVisible, onToggleGrid, ...threeProps } = props; if (WASM_ENGINE_ACTIVE) { return ( ); } - return ; + return ; } function ThreeViewport3D({ diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index 5343a18..e39ab8d 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -6,10 +6,12 @@ // INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs- // Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation + // 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ü. +// art (shaded/white, `renderMode`, ebenfalls Oberleiste) + ein-/ausblendbares +// Boden-Referenzraster (`gridVisible`/`groundElevation`, Overlay-Umschalter im +// Viewport selbst). Auswahl/Raycast, Griffe/Zeichnen sowie die übrigen +// Darstellungsarten der three.js-Sicht (texturiert/Drahtgitter/Kanten, HLR) +// folgen später — bis dahin gilt: RECHTS unterdrückt weiterhin nur das +// Browser-Menü (kein Kontextmenü im Engine-Pfad). // // KAMERA-PRESETS: `view3d`/`renderMode` kommen als Props von der Oberleiste // (TopBar → App.tsx → Viewport3D-Umschalter, dieselbe Quelle wie die three.js- @@ -25,8 +27,17 @@ // 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. +// MAUS-SCHEMA (bewusst ANDERS als die three.js-Sicht — Laptop-freundlich, ohne +// Modifier-Taste zu verlangen; im Engine-Pfad gibt es noch keine Auswahl/kein +// Kontextmenü, LINKS/RECHTS sind also frei für Navigation): +// LINKS ziehen = Orbit (drehen) — entdeckbarste Geste, da frei. +// MITTE ziehen = Pan (bewegen) — konsistent mit der 2D-Grundriss-Ansicht +// (dort ist MITTE ebenfalls Pan). +// RECHTS ziehen = Pan (bewegen), zweiter leicht erreichbarer Weg; das +// Browser-Kontextmenü bleibt unterdrückt (RECHTS-KLICK ohne +// Bewegung löst ohnehin kein Menü aus, es gibt hier keins). +// RAD = Zoom, Ziel leicht Richtung Cursor-Strahl verschoben +// (Näherungs-„Zoom-to-Cursor", s. onWheel). // Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je // Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen. @@ -152,6 +163,9 @@ export function Wasm3DViewport({ project, view3d = "perspective", renderMode = "shaded", + gridVisible = true, + groundElevation = 0, + onToggleGrid, }: { project: Project; /** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ @@ -160,10 +174,26 @@ export function Wasm3DViewport({ * ü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; + /** Boden-Referenzraster sichtbar? Umgeschaltet über den Overlay-Button unten + * rechts im Viewport (`onToggleGrid`) — Single Source of Truth ist der + * Aufrufer (App/viewSlice `grid3dVisible`), diese Komponente spiegelt nur. */ + gridVisible?: boolean; + /** Hoehe (world-Y, Meter) des Bodenrasters = OKFF/`baseElevation` des + * aktiven Geschosses (vom Aufrufer berechnet, s. App.tsx). */ + groundElevation?: number; + /** Klick auf den Grid-Umschalter-Button; ohne Angabe kein Button. */ + onToggleGrid?: () => void; }) { const canvasRef = useRef(null); - const { render, updateModel, setWhiteMode, applyViewPreset, ready, failed } = - useWasm3dRenderer(canvasRef, true); + const { + render, + updateModel, + setWhiteMode, + applyViewPreset, + setGroundGrid, + 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. Dient NUR der // freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar). @@ -208,6 +238,15 @@ export function Wasm3DViewport({ redrawRef.current(); }, [ready, renderMode, setWhiteMode]); + // Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/ + // Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (40 m / + // 1 m) — ausreichend fuer die ueblichen Grundriss-Groessen dieses Spikes. + useEffect(() => { + if (!ready) return; + setGroundGrid(gridVisible, groundElevation, 40, 1); + redrawRef.current(); + }, [ready, gridVisible, groundElevation, setGroundGrid]); + // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu // zeichnen (die Surface-Anpassung übernimmt der Hook im render()). useEffect(() => { @@ -218,10 +257,12 @@ export function Wasm3DViewport({ return () => ro.disconnect(); }, [ready]); - // 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. + // Maus-Navigation (LINKS = Orbit, MITTE/RECHTS = Pan, RAD = Zoom, s. + // Datei-Kommentar MAUS-SCHEMA). 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, nur mit laptop-freundlicher + // Tastenbelegung ohne Modifier (s. o.). useEffect(() => { const canvas = canvasRef.current; if (!ready || !canvas) return; @@ -237,9 +278,11 @@ export function Wasm3DViewport({ const redraw = () => redrawRef.current(); const onPointerDown = (e: PointerEvent) => { - if (e.button !== 1) return; // nur MITTE navigiert (links = Auswahl, s. o.) + // LINKS (0) = Orbit, MITTE (1)/RECHTS (2) = Pan (s. MAUS-SCHEMA oben). + const mode = e.button === 0 ? "orbit" : e.button === 1 || e.button === 2 ? "pan" : null; + if (!mode) return; e.preventDefault(); - drag = { mode: e.shiftKey ? "pan" : "orbit", x: e.clientX, y: e.clientY }; + drag = { mode, x: e.clientX, y: e.clientY }; canvas.setPointerCapture(e.pointerId); useOrbitRedraw(); }; @@ -296,12 +339,46 @@ export function Wasm3DViewport({ if (!o) return; e.preventDefault(); useOrbitRedraw(); + const prevDist = o.dist; // 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))); + // Zoom-to-Cursor-Näherung (kein Tiefenwert unter dem Cursor verfügbar): + // das Blickziel anteilig der Distanzänderung in Richtung des Cursor- + // Strahls IN DER BILDEBENE verschieben (Bildschirm-Basis s/u aus dem + // Orbit-Winkel, wie beim Pan). Bei Cursor in Bildmitte (ndcX=ndcY=0) + // bleibt es ein reines Distanz-Dolly (Verschiebung = 0); je weiter der + // Cursor vom Zentrum, desto mehr wandert das Ziel darauf zu — spürbar + // besser bedienbar als ein reines Mittendolly, ohne echte Tiefenprojektion. + const rect = canvas.getBoundingClientRect(); + const ndcX = rect.width > 0 ? ((e.clientX - rect.left) / rect.width) * 2 - 1 : 0; + const ndcY = rect.height > 0 ? 1 - ((e.clientY - rect.top) / rect.height) * 2 : 0; + const distDelta = prevDist - o.dist; // > 0 beim Reinzoomen + if (distDelta !== 0 && (ndcX !== 0 || ndcY !== 0)) { + const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch)); + 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], + ]; + // Bildhalbhöhe je Distanz-Einheit (Sichtwinkel) × halbe Distanzänderung + // (gedämpft — voller Betrag würde den Cursor-Punkt exakt zentrieren, + // was sich bei wiederholtem Scrollen zu ruckartig anfühlt). + const shift = distDelta * Math.tan(FOV_Y / 2) * 0.5; + o.target[0] += (s[0] * ndcX + u[0] * ndcY) * shift; + o.target[1] += (s[1] * ndcX + u[1] * ndcY) * shift; + o.target[2] += (s[2] * ndcX + u[2] * ndcY) * shift; + } redraw(); }; - // Browser-Kontextmenü unterdrücken (RECHTS ist fürs App-Menü reserviert). + // Browser-Kontextmenü unterdrücken (RECHTS navigiert jetzt selbst, s. o.; + // ein eigenes Kontextmenü gibt es im Engine-Pfad noch nicht). const onContextMenu = (e: MouseEvent) => e.preventDefault(); canvas.addEventListener("pointerdown", onPointerDown); @@ -324,9 +401,42 @@ export function Wasm3DViewport({
{/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die Renderauflösung (DPR) gleicht der Hook je Frame ab. Presets + - Darstellungsart kommen jetzt von der Oberleiste (TopBar), kein - eigenes Overlay mehr hier. */} + Darstellungsart kommen von der Oberleiste (TopBar); das Grid-Overlay + unten ist der einzige eigene Umschalter hier. */} + {onToggleGrid && ( + // Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport` + // ist `position: relative` — s. styles.css), stört die Maus-Navigation + // auf dem Canvas nicht. Keine eigene CSS-Klasse (styles.css ist nicht in + // dieser Datei-Lane) — Inline-Style im dunklen App-Look. + + )} {failed && (
{t("viewport3d.engineUnavailable")} diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index e26dc52..09614be 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -39,6 +39,17 @@ interface WebModelRendererLike { set_model(json: string): void; set_render_mode_white(white: boolean): void; set_view_preset(preset: string): void; + /** Boden-Referenzraster in der Welt-XZ-Ebene ein-/ausblenden (Rust-seitig, + * `web::set_ground_grid`). `visible=false` zeichnet kein Raster; sonst ein + * Quadrat der Kantenlaenge `extent_m` (zentriert um den Ursprung, XZ) auf + * Hoehe `elevation_m` (world-Y) mit Linienabstand `spacing_m`. Wirkt erst + * beim naechsten `render`. */ + set_ground_grid( + visible: boolean, + elevation_m: number, + extent_m: number, + spacing_m: number, + ): void; set_camera( eyeX: number, eyeY: number, @@ -145,6 +156,25 @@ export function useWasm3dRenderer( } }, []); + /** + * Boden-Referenzraster ein-/ausblenden (Overlay-Umschalter im Viewport, + * s. Wasm3DViewport.tsx). `elevationM` = OKFF des aktiven Geschosses + * (`baseElevation`), `extentM`/`spacingM` = Kantenlaenge/Linienabstand des + * Quadrats in Metern. Wirkt erst beim naechsten `render`. + */ + const setGroundGrid = useCallback( + (visible: boolean, elevationM: number, extentM: number, spacingM: number) => { + const r = rendererRef.current; + if (!r) return; + try { + r.set_ground_grid(visible, elevationM, extentM, spacingM); + } catch (e) { + console.warn("render3d set_ground_grid fehlgeschlagen:", e); + } + }, + [], + ); + /** * Kamera-Praeset anwenden (`front`/`top`/`side`/`iso`/`perspective`, wie * `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz- @@ -209,5 +239,13 @@ export function useWasm3dRenderer( [canvasRef], ); - return { render, updateModel, setWhiteMode, applyViewPreset, ready, failed }; + return { + render, + updateModel, + setWhiteMode, + applyViewPreset, + setGroundGrid, + ready, + failed, + }; }