Nordstern-3D: Boden-Referenzraster + bessere Kamera-Steuerung

- Boden-Grid-Fläche auf y = baseElevation des aktiven Geschosses,
  ein-/ausschaltbar (Overlay-Button im Viewport, State in viewSlice
  grid3dVisible). Eigene LineList-Pipeline in der Engine (grid.rs,
  GRID_WGSL, geteilte View-Projection/Depth mit der Mesh-Pipeline),
  neue set_ground_grid(visible, elevation, extent, spacing)-Methode.
  Minor-Linien dezent, jede 5. als Major betont.
- Kamera-Steuerung überarbeitet (vorher nur Mitte=Orbit, Pan auf
  Shift+Mitte versteckt): Links=Orbit, Mitte/Rechts=Pan (bewegen),
  Rad=Zoom zum Cursor. Laptop-freundlich, konsistent mit der
  2D-Plan-Navigation (dort Mitte=Pan).
This commit is contained in:
2026-07-04 05:54:16 +02:00
parent d0b94a75ec
commit d4065c5a41
12 changed files with 567 additions and 21 deletions
+116 -1
View File
@@ -12,9 +12,10 @@
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
use crate::math::{view_projection, Mat4}; use crate::math::{view_projection, Mat4};
use crate::mesh::{build_scene_mesh, build_walls_mesh}; 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}; use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX};
/// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar). /// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar).
@@ -68,14 +69,30 @@ struct MeshBuffers {
index_count: u32, 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 + /// Der native 3D-Renderer: haelt die Pipeline, das Uniform (View-Projektion +
/// Licht) und das aktuell hochgeladene Mesh. Ein Tiefenpuffer wird passend zur /// Licht) und das aktuell hochgeladene Mesh. Ein Tiefenpuffer wird passend zur
/// Ziel-Groesse (neu) angelegt. /// Ziel-Groesse (neu) angelegt.
pub struct Renderer { pub struct Renderer {
pipeline: wgpu::RenderPipeline, 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, bind_group: wgpu::BindGroup,
uniform: wgpu::Buffer, uniform: wgpu::Buffer,
mesh: Option<MeshBuffers>, mesh: Option<MeshBuffers>,
/// Bodengitter-Vertices (None = nicht erzeugt). Wird nur gezeichnet, wenn
/// zusaetzlich `grid_visible` gesetzt ist.
grid: Option<GridBuffers>,
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
grid_visible: bool,
depth: Option<DepthTarget>, depth: Option<DepthTarget>,
msaa: Option<MsaaTarget>, msaa: Option<MsaaTarget>,
/// Farbformat des Render-Ziels (Surface), fuer die MSAA-Zwischentextur. /// Farbformat des Render-Ziels (Surface), fuer die MSAA-Zwischentextur.
@@ -180,6 +197,60 @@ impl Renderer {
cache: None, 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 { let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("globals.buffer"), label: Some("globals.buffer"),
size: std::mem::size_of::<Globals>() as u64, size: std::mem::size_of::<Globals>() as u64,
@@ -197,9 +268,12 @@ impl Renderer {
Self { Self {
pipeline, pipeline,
grid_pipeline,
bind_group, bind_group,
uniform, uniform,
mesh: None, mesh: None,
grid: None,
grid_visible: false,
depth: None, depth: None,
msaa: None, msaa: None,
color_format, color_format,
@@ -274,6 +348,36 @@ impl Renderer {
self.globals.mode[0] = if white { 1.0 } else { 0.0 }; 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. /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { 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); 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.set_index_buffer(m.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..m.index_count, 0, 0..1); 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())); queue.submit(std::iter::once(encoder.finish()));
} }
+176
View File
@@ -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<f32>`), 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<f32> {
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<f32> = 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);
}
}
+9
View File
@@ -14,6 +14,7 @@
// Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die // Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die
// GPU-freien Schichten und ist damit unabhaengig von einer Display-Session. // GPU-freien Schichten und ist damit unabhaengig von einer Display-Session.
pub mod grid;
pub mod math; pub mod math;
pub mod mesh; pub mod mesh;
pub(crate) mod openings; pub(crate) mod openings;
@@ -808,5 +809,13 @@ mod tests {
validator validator
.validate(&module) .validate(&module)
.unwrap_or_else(|e| panic!("mesh: WGSL-Validierung fehlgeschlagen: {e:?}")); .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:?}"));
} }
} }
+40
View File
@@ -82,3 +82,43 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
return vec4<f32>(shaded, 1.0); return vec4<f32>(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<f32>,
light_dir : vec4<f32>,
sky_color : vec4<f32>,
ground_color : vec4<f32>,
sun_color : vec4<f32>,
mode : vec4<f32>,
};
@group(0) @binding(0) var<uniform> globals : Globals;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) color : vec3<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) color : vec3<f32>,
};
@vertex
fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
"#;
+19
View File
@@ -327,6 +327,25 @@ impl WebModelRenderer {
self.renderer.set_white_mode(white); 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). /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
pub fn resize(&mut self, width: u32, height: u32) { pub fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1)); let (w, h) = (width.max(1), height.max(1));
+14
View File
@@ -269,6 +269,10 @@ export default function App() {
const setView3d = useStore((s) => s.setView3d); const setView3d = useStore((s) => s.setView3d);
const fov = useStore((s) => s.fov); const fov = useStore((s) => s.fov);
const setFov = useStore((s) => s.setFov); 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 planColorMode = useStore((s) => s.planColorMode);
const setPlanColorMode = useStore((s) => s.setPlanColorMode); const setPlanColorMode = useStore((s) => s.setPlanColorMode);
const referenceLines = useStore((s) => s.referenceLines); const referenceLines = useStore((s) => s.referenceLines);
@@ -3104,6 +3108,8 @@ export default function App() {
renderMode={renderMode} renderMode={renderMode}
view3d={view3d} view3d={view3d}
fov={fov} fov={fov}
grid3dVisible={grid3dVisible}
onToggleGrid3d={() => setGrid3dVisible(!grid3dVisible)}
mono={planColorMode === "mono"} mono={planColorMode === "mono"}
drawings={drawings} drawings={drawings}
onSelectLevel={setActiveLevelId} onSelectLevel={setActiveLevelId}
@@ -4065,6 +4071,8 @@ function Content({
renderMode, renderMode,
view3d, view3d,
fov, fov,
grid3dVisible,
onToggleGrid3d,
mono, mono,
drawings, drawings,
onSelectLevel, onSelectLevel,
@@ -4126,6 +4134,10 @@ function Content({
view3d: View3d; view3d: View3d;
/** Sichtwinkel der 3D-Perspektivkamera in Grad. */ /** Sichtwinkel der 3D-Perspektivkamera in Grad. */
fov: number; 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. */ /** Grundriss in Schwarz-Weiss (mono) statt mit Kategorie-/Element-Farben. */
mono: boolean; mono: boolean;
/** Dokument-Tabs der Kopfzeile — ein Tab je Geschoss-Grundriss. */ /** Dokument-Tabs der Kopfzeile — ein Tab je Geschoss-Grundriss. */
@@ -4310,6 +4322,8 @@ function Content({
view3d={view3d} view3d={view3d}
fov={fov} fov={fov}
gridElevation={level.baseElevation ?? 0} gridElevation={level.baseElevation ?? 0}
gridVisible={grid3dVisible}
onToggleGrid={onToggleGrid3d}
onSelectWall={onViewportSelectWall} onSelectWall={onViewportSelectWall}
onSelectCeiling={onViewportSelectCeiling} onSelectCeiling={onViewportSelectCeiling}
onSelectOpening={onViewportSelectOpening} onSelectOpening={onViewportSelectOpening}
+2
View File
@@ -914,6 +914,8 @@ export const de = {
"viewport3d.engineUnavailable": "3D-Engine nicht verfügbar", "viewport3d.engineUnavailable": "3D-Engine nicht verfügbar",
"viewport3d.engineUnavailableHint": "viewport3d.engineUnavailableHint":
"WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.", "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; } as const;
export type TranslationKey = keyof typeof de; export type TranslationKey = keyof typeof de;
+2
View File
@@ -905,4 +905,6 @@ export const en: Record<TranslationKey, string> = {
"viewport3d.engineUnavailable": "3D engine unavailable", "viewport3d.engineUnavailable": "3D engine unavailable",
"viewport3d.engineUnavailableHint": "viewport3d.engineUnavailableHint":
"WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.", "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",
}; };
+7
View File
@@ -33,6 +33,10 @@ export interface ViewSlice {
view3d: View3d; view3d: View3d;
// Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad. // Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad.
fov: number; 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), // Farb-Modus des Grundrisses: "color" = Kategorie-/Element-Farben (heute),
// "mono" = reiner Schwarz-Weiss-Plan (Tinte, keine Farb-/Schraffurfarben). // "mono" = reiner Schwarz-Weiss-Plan (Tinte, keine Farb-/Schraffurfarben).
planColorMode: "color" | "mono"; planColorMode: "color" | "mono";
@@ -60,6 +64,7 @@ export interface ViewSlice {
setRenderMode: (m: RenderMode) => void; setRenderMode: (m: RenderMode) => void;
setView3d: (v: View3d) => void; setView3d: (v: View3d) => void;
setFov: (n: number) => void; setFov: (n: number) => void;
setGrid3dVisible: (b: boolean) => void;
setPlanColorMode: (m: "color" | "mono") => void; setPlanColorMode: (m: "color" | "mono") => void;
setReferenceLines: (b: boolean) => void; setReferenceLines: (b: boolean) => void;
setLineMode: (m: "display" | "print") => void; setLineMode: (m: "display" | "print") => void;
@@ -83,6 +88,7 @@ export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
renderMode: "shaded", renderMode: "shaded",
view3d: "perspective", view3d: "perspective",
fov: 50, fov: 50,
grid3dVisible: true,
planColorMode: "color", planColorMode: "color",
referenceLines: false, referenceLines: false,
lineMode: "display", lineMode: "display",
@@ -107,6 +113,7 @@ export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
setRenderMode: (m) => set({ renderMode: m }), setRenderMode: (m) => set({ renderMode: m }),
setView3d: (v) => set({ view3d: v }), setView3d: (v) => set({ view3d: v }),
setFov: (n) => set({ fov: n }), setFov: (n) => set({ fov: n }),
setGrid3dVisible: (b) => set({ grid3dVisible: b }),
setPlanColorMode: (m) => set({ planColorMode: m }), setPlanColorMode: (m) => set({ planColorMode: m }),
setReferenceLines: (b) => set({ referenceLines: b }), setReferenceLines: (b) => set({ referenceLines: b }),
setLineMode: (m) => set({ lineMode: m }), setLineMode: (m) => set({ lineMode: m }),
+16 -2
View File
@@ -115,17 +115,31 @@ const WASM_ENGINE_ACTIVE: boolean = (() => {
* nutzt vorerst nur das Projekt (Massenmodell + Orbit); alle weiteren Props * nutzt vorerst nur das Projekt (Massenmodell + Orbit); alle weiteren Props
* (Auswahl, Render-Modi, Editier-Griffe) wirken nur im three.js-Pfad. * (Auswahl, Render-Modi, Editier-Griffe) wirken nur im three.js-Pfad.
*/ */
export function Viewport3D(props: Parameters<typeof ThreeViewport3D>[0]) { export function Viewport3D(
props: Parameters<typeof ThreeViewport3D>[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) { if (WASM_ENGINE_ACTIVE) {
return ( return (
<Wasm3DViewport <Wasm3DViewport
project={props.project} project={props.project}
view3d={props.view3d} view3d={props.view3d}
renderMode={props.renderMode} renderMode={props.renderMode}
gridVisible={gridVisible}
groundElevation={props.gridElevation}
onToggleGrid={onToggleGrid}
/> />
); );
} }
return <ThreeViewport3D {...props} />; return <ThreeViewport3D {...threeProps} />;
} }
function ThreeViewport3D({ function ThreeViewport3D({
+127 -17
View File
@@ -6,10 +6,12 @@
// INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs- // INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs-
// Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation + // Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation +
// Kamera-Presets (`view3d`, von der Oberleiste getrieben — s. u.) + Darstellungs- // Kamera-Presets (`view3d`, von der Oberleiste getrieben — s. u.) + Darstellungs-
// art (shaded/white, `renderMode`, ebenfalls Oberleiste). Auswahl/Raycast, // art (shaded/white, `renderMode`, ebenfalls Oberleiste) + ein-/ausblendbares
// Griffe/Zeichnen sowie die übrigen Darstellungsarten der three.js-Sicht // Boden-Referenzraster (`gridVisible`/`groundElevation`, Overlay-Umschalter im
// (texturiert/Drahtgitter/Kanten, HLR) folgen später — bis dahin gilt: LINKS // Viewport selbst). Auswahl/Raycast, Griffe/Zeichnen sowie die übrigen
// ohne Funktion, RECHTS unterdrückt nur das Browser-Menü. // 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 // KAMERA-PRESETS: `view3d`/`renderMode` kommen als Props von der Oberleiste
// (TopBar → App.tsx → Viewport3D-Umschalter, dieselbe Quelle wie die three.js- // (TopBar → App.tsx → Viewport3D-Umschalter, dieselbe Quelle wie die three.js-
@@ -25,8 +27,17 @@
// an die Praeset-Blickrichtung anschliesst (Rust berechnet dieselbe Richtung // an die Praeset-Blickrichtung anschliesst (Rust berechnet dieselbe Richtung
// für die exakte, evtl. orthografische Praeset-Kamera). // für die exakte, evtl. orthografische Praeset-Kamera).
// //
// MAUS-SCHEMA wie die three.js-Sicht (docs/design/context-menu.md): // MAUS-SCHEMA (bewusst ANDERS als die three.js-Sicht — Laptop-freundlich, ohne
// MITTE ziehen = Orbit, Shift+MITTE = Pan, RAD = Zoom. // 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 // Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je
// Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen. // Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen.
@@ -152,6 +163,9 @@ export function Wasm3DViewport({
project, project,
view3d = "perspective", view3d = "perspective",
renderMode = "shaded", renderMode = "shaded",
gridVisible = true,
groundElevation = 0,
onToggleGrid,
}: { }: {
project: Project; project: Project;
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ /** 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, * ü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. */ * s. Datei-Kommentar) alles ausser `white` fällt auf shaded zurück. */
renderMode?: RenderMode; 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<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const { render, updateModel, setWhiteMode, applyViewPreset, ready, failed } = const {
useWasm3dRenderer(canvasRef, true); render,
updateModel,
setWhiteMode,
applyViewPreset,
setGroundGrid,
ready,
failed,
} = useWasm3dRenderer(canvasRef, true);
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen // Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
// direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der // direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der
// freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar). // freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar).
@@ -208,6 +238,15 @@ export function Wasm3DViewport({
redrawRef.current(); redrawRef.current();
}, [ready, renderMode, setWhiteMode]); }, [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 // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()). // zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
useEffect(() => { useEffect(() => {
@@ -218,10 +257,12 @@ export function Wasm3DViewport({
return () => ro.disconnect(); return () => ro.disconnect();
}, [ready]); }, [ready]);
// Maus-Navigation (MITTE = Orbit, Shift+MITTE = Pan, RAD = Zoom). Verlässt // Maus-Navigation (LINKS = Orbit, MITTE/RECHTS = Pan, RAD = Zoom, s.
// damit IMMER die Praeset-Kamera und wechselt auf die freie perspektivische // Datei-Kommentar MAUS-SCHEMA). Verlässt damit IMMER die Praeset-Kamera und
// Orbit-Kamera (Kontinuität über den in VIEW3D_ORBIT vorbereiteten Start- // wechselt auf die freie perspektivische Orbit-Kamera (Kontinuität über den
// Blickwinkel) — gleiche Philosophie wie OrbitControls in der three.js-Sicht. // 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(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!ready || !canvas) return; if (!ready || !canvas) return;
@@ -237,9 +278,11 @@ export function Wasm3DViewport({
const redraw = () => redrawRef.current(); const redraw = () => redrawRef.current();
const onPointerDown = (e: PointerEvent) => { 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(); 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); canvas.setPointerCapture(e.pointerId);
useOrbitRedraw(); useOrbitRedraw();
}; };
@@ -296,12 +339,46 @@ export function Wasm3DViewport({
if (!o) return; if (!o) return;
e.preventDefault(); e.preventDefault();
useOrbitRedraw(); useOrbitRedraw();
const prevDist = o.dist;
// Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt). // 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))); 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(); 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(); const onContextMenu = (e: MouseEvent) => e.preventDefault();
canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointerdown", onPointerDown);
@@ -324,9 +401,42 @@ export function Wasm3DViewport({
<div className="viewport"> <div className="viewport">
{/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die {/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die
Renderauflösung (DPR) gleicht der Hook je Frame ab. Presets + Renderauflösung (DPR) gleicht der Hook je Frame ab. Presets +
Darstellungsart kommen jetzt von der Oberleiste (TopBar), kein Darstellungsart kommen von der Oberleiste (TopBar); das Grid-Overlay
eigenes Overlay mehr hier. */} unten ist der einzige eigene Umschalter hier. */}
<canvas ref={canvasRef} data-engine="render3d" /> <canvas ref={canvasRef} data-engine="render3d" />
{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.
<button
type="button"
onClick={onToggleGrid}
title={t(gridVisible ? "viewport3d.grid.hide" : "viewport3d.grid.show")}
aria-pressed={gridVisible}
style={{
position: "absolute",
right: 10,
bottom: 10,
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
borderRadius: 7,
border: "1px solid rgba(255,255,255,0.14)",
background: gridVisible ? "rgba(255,255,255,0.14)" : "rgba(20,20,24,0.65)",
color: gridVisible ? "#f2f2f2" : "#9a9a9a",
cursor: "pointer",
backdropFilter: "blur(4px)",
}}
>
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 18 }}>
{gridVisible ? "grid_on" : "grid_off"}
</span>
</button>
)}
{failed && ( {failed && (
<div className="viewport-fallback"> <div className="viewport-fallback">
<strong>{t("viewport3d.engineUnavailable")}</strong> <strong>{t("viewport3d.engineUnavailable")}</strong>
+39 -1
View File
@@ -39,6 +39,17 @@ interface WebModelRendererLike {
set_model(json: string): void; set_model(json: string): void;
set_render_mode_white(white: boolean): void; set_render_mode_white(white: boolean): void;
set_view_preset(preset: string): 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( set_camera(
eyeX: number, eyeX: number,
eyeY: 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 * Kamera-Praeset anwenden (`front`/`top`/`side`/`iso`/`perspective`, wie
* `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz- * `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz-
@@ -209,5 +239,13 @@ export function useWasm3dRenderer(
[canvasRef], [canvasRef],
); );
return { render, updateModel, setWhiteMode, applyViewPreset, ready, failed }; return {
render,
updateModel,
setWhiteMode,
applyViewPreset,
setGroundGrid,
ready,
failed,
};
} }