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:
@@ -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<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>,
|
||||
msaa: Option<MsaaTarget>,
|
||||
/// 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::<Globals>() 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()));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +82,43 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
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);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -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));
|
||||
|
||||
+14
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -905,4 +905,6 @@ export const en: Record<TranslationKey, string> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
@@ -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>): 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>): 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 }),
|
||||
|
||||
@@ -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<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) {
|
||||
return (
|
||||
<Wasm3DViewport
|
||||
project={props.project}
|
||||
view3d={props.view3d}
|
||||
renderMode={props.renderMode}
|
||||
gridVisible={gridVisible}
|
||||
groundElevation={props.gridElevation}
|
||||
onToggleGrid={onToggleGrid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ThreeViewport3D {...props} />;
|
||||
return <ThreeViewport3D {...threeProps} />;
|
||||
}
|
||||
|
||||
function ThreeViewport3D({
|
||||
|
||||
+127
-17
@@ -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<HTMLCanvasElement | null>(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({
|
||||
<div className="viewport">
|
||||
{/* 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. */}
|
||||
<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 && (
|
||||
<div className="viewport-fallback">
|
||||
<strong>{t("viewport3d.engineUnavailable")}</strong>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user