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 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()));
}
+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
// 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:?}"));
}
}
+40
View File
@@ -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);
}
"#;
+19
View File
@@ -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
View File
@@ -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}
+2
View File
@@ -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;
+2
View File
@@ -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",
};
+7
View File
@@ -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 }),
+16 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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,
};
}