diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index 83933d4..1d040ce 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -16,7 +16,9 @@ use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX}; 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::{GRID_WGSL, MESH_WGSL}; +use crate::section::SectionPlane; +use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX}; +use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_WGSL}; use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX}; /// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/ @@ -90,8 +92,13 @@ struct Globals { sky_color: [f32; 4], ground_color: [f32; 4], sun_color: [f32; 4], - /// x = Darstellungsart (0 = shaded/Material, 1 = weiss/Clay); siehe shaders.rs. + /// x = Darstellungsart (0 = shaded/Material, 1 = weiss/Clay, 2 = hidden); + /// y = Live-Schnitt aktiv (0/1). Siehe shaders.rs. mode: [f32; 4], + /// Live-Schnittebene: xyz = Ebenennormale (= Blickrichtung, normiert), + /// w = -dot(normal, point). Ein Fragment mit `dot(world, xyz) + w > 0` liegt + /// hinter der Ebene und wird (bei aktivem Schnitt, mode.y > 0.5) verworfen. + section_plane: [f32; 4], } impl Default for Globals { @@ -107,6 +114,8 @@ impl Default for Globals { // Directional-Sonne: warmweiss, moderat. sun_color: [0.55, 0.53, 0.49, 1.0], mode: [0.0, 0.0, 0.0, 0.0], + // Default: kein Schnitt (mode.y = 0). Neutrale Ebene. + section_plane: [0.0, 0.0, 1.0, 0.0], } } } @@ -131,6 +140,14 @@ struct LineBuffers { vertex_count: u32, } +/// GPU-seitiger Vertex-Buffer der Schnittflaechen-Kappen (Cut-Caps, TriangleList, +/// kein Indexpuffer). Format `[pos vec3, uv vec2]` (`CAP_FLOATS_PER_VERTEX`) — +/// die (u,v)-Ebenenkoordinaten treiben die prozedurale Schraffur (CAP_WGSL). +struct CapBuffers { + 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. @@ -150,6 +167,11 @@ pub struct Renderer { /// (`depth_compare: Always`, kein Depth-Write): die Auswahl-Highlight-Linien /// liegen immer sichtbar obenauf und scheinen auch hinter Waenden durch. highlight_pipeline: wgpu::RenderPipeline, + /// Schnittflaechen-Kappen (TriangleList, CAP_WGSL, eigenes Vertex-Layout + /// [pos vec3, uv vec2]). Teilt sich die `Globals`-Bind-Group; leichter + /// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene + /// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None). + cap_pipeline: wgpu::RenderPipeline, bind_group: wgpu::BindGroup, uniform: wgpu::Buffer, mesh: Option, @@ -165,6 +187,10 @@ pub struct Renderer { /// `set_highlight_lines` gesetzt/geloescht; im Render-Pass als ALLERLETZTES /// (ohne Tiefentest) gezeichnet. highlight: Option, + /// Schnittflaechen-Kappen (Cut-Caps) des Live-Schnitts. None = kein Schnitt + /// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht, + /// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight. + caps: Option, /// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false — /// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter. grid_visible: bool, @@ -372,6 +398,70 @@ impl Renderer { }, ); + // Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes + // schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe + // Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben. + let cap_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("cap.wgsl"), + source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()), + }); + let cap_vertex_layout = wgpu::VertexBufferLayout { + array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2], + }; + let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("cap.pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &cap_module, + entry_point: Some("vs_main"), + buffers: &[cap_vertex_layout], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &cap_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::TriangleList, + // Die Cap ist eine flache Flaeche und soll aus BEIDEN Richtungen + // sichtbar sein (das Winding der Rueckprojektion ist nicht + // kamerakonsistent garantiert) -> kein Backface-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(), + // Leicht ZUR Kamera ziehen (negativer Bias, Gegenteil des + // hidden_face_pipeline-Bias): die Caps liegen exakt auf der + // Schnittebene, an der die Wandflaechen weggeschnitten werden — + // der negative Bias vermeidet Z-Fighting mit an der Grenze + // gerade noch stehen gebliebenen Wandfragmenten. + bias: wgpu::DepthBiasState { + constant: -4, + slope_scale: -1.0, + clamp: 0.0, + }, + }), + multisample: wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("globals.buffer"), size: std::mem::size_of::() as u64, @@ -392,12 +482,14 @@ impl Renderer { hidden_face_pipeline, grid_pipeline, highlight_pipeline, + cap_pipeline, bind_group, uniform, mesh: None, edges: None, grid: None, highlight: None, + caps: None, grid_visible: false, style: RenderStyle::Shaded, depth: None, @@ -556,6 +648,61 @@ impl Renderer { }); } + /// Aktiviert/deaktiviert den Live-Schnitt und setzt die Schnittebene. `active` + /// steuert `mode.y` (der Mesh-/Grid-Shader kappt die Geometrie nur bei > 0.5); + /// `point`/`normal` (world-Meter, Normale = Blickrichtung) fuellen + /// `section_plane = [n, -dot(n, point)]`. Nur die Uniform-Werte — die Cap- + /// Geometrie setzt der Aufrufer separat via `set_cut_caps`. + pub fn set_section_plane(&mut self, active: bool, point: [f32; 3], normal: [f32; 3]) { + let n = crate::math::normalize(normal); + let w = -(n[0] * point[0] + n[1] * point[1] + n[2] * point[2]); + self.globals.section_plane = [n[0], n[1], n[2], w]; + self.globals.mode[1] = if active { 1.0 } else { 0.0 }; + } + + /// Setzt/loescht die Schnittflaechen-Kappen (Cut-Caps). `verts` = interleaved + /// `[px,py,pz, u,v, ...]` (world-Meter + Ebenenkoordinaten, TriangleList, + /// `CAP_FLOATS_PER_VERTEX`), wie `section_fill::build_cut_caps` liefert. Leeres + /// Slice loescht die Caps. Wirkt erst beim naechsten `render`. + pub fn set_cut_caps(&mut self, device: &wgpu::Device, verts: &[f32]) { + if verts.is_empty() { + self.caps = None; + return; + } + let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("caps.vbo"), + contents: bytemuck::cast_slice(verts), + usage: wgpu::BufferUsages::VERTEX, + }); + self.caps = Some(CapBuffers { + vbo, + vertex_count: (verts.len() / CAP_FLOATS_PER_VERTEX) as u32, + }); + } + + /// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten) + /// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und + /// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` + + /// `set_cut_caps`, damit `web.rs` nur EINEN Aufruf braucht. + pub fn update_section( + &mut self, + device: &wgpu::Device, + active: bool, + point: [f32; 3], + normal: [f32; 3], + walls: &[WallInput], + slabs: &[SlabInput], + ) { + self.set_section_plane(active, point, normal); + if active { + let plane = SectionPlane::new(point, normal); + let caps = build_cut_caps(&plane, walls, slabs); + self.set_cut_caps(device, &caps); + } else { + self.set_cut_caps(device, &[]); + } + } + /// 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); @@ -692,6 +839,17 @@ impl Renderer { } } + // 1b) Schnittflaechen-Kappen (Cut-Caps) NACH den Flaechen, VOR den + // Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde + // mit der Diagonalschraffur (CAP_WGSL). Werden selbst NICHT gekappt. + // Nur vorhanden, wenn der Live-Schnitt aktiv ist (set_cut_caps). + if let Some(c) = &self.caps { + pass.set_pipeline(&self.cap_pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, c.vbo.slice(..)); + pass.draw(0..c.vertex_count, 0..1); + } + // 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid). // Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten) // Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 8f85834..8abb13c 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -20,6 +20,7 @@ pub mod math; pub mod mesh; pub(crate) mod openings; pub mod section; +pub mod section_fill; pub mod shaders; pub mod types; @@ -818,5 +819,13 @@ mod tests { Validator::new(ValidationFlags::all(), Capabilities::all()) .validate(&grid_module) .unwrap_or_else(|e| panic!("grid: WGSL-Validierung fehlgeschlagen: {e:?}")); + + // Schnittflaechen-Kappen-Shader (Live-Schnitt-Schraffur) ebenso. + let cap_src = super::shaders::CAP_WGSL; + let cap_module = naga::front::wgsl::parse_str(cap_src) + .unwrap_or_else(|e| panic!("cap: WGSL-Parse-Fehler: {e:?}")); + Validator::new(ValidationFlags::all(), Capabilities::all()) + .validate(&cap_module) + .unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}")); } } diff --git a/src-tauri/render3d/src/section.rs b/src-tauri/render3d/src/section.rs index a0a063c..d37fc32 100644 --- a/src-tauri/render3d/src/section.rs +++ b/src-tauri/render3d/src/section.rs @@ -186,7 +186,9 @@ impl SectionPlane { /// u-Achse der Projektion: horizontal, senkrecht zur Blickrichtung (siehe /// Moduldoc — dieselbe Konvention wie `math::look_at`s `right`-Vektor). - fn u_axis(&self) -> [f32; 3] { + /// `pub(crate)`, damit `section_fill` die (u,v)->Welt-Ruecktransformation + /// mit EXAKT derselben Achsdefinition (inkl. Entartungs-Fallback) rechnet. + pub(crate) fn u_axis(&self) -> [f32; 3] { let up = [0.0, 1.0, 0.0]; let u = math::cross(self.normal, up); if math::length(u) > 1e-6 { diff --git a/src-tauri/render3d/src/section_fill.rs b/src-tauri/render3d/src/section_fill.rs new file mode 100644 index 0000000..12964ae --- /dev/null +++ b/src-tauri/render3d/src/section_fill.rs @@ -0,0 +1,82 @@ +//! Schnittflaechen-Geometrie fuer den Live-3D-Schnitt: wandelt die analytischen +//! Cut-Polygone aus `section::cut_section` (achsparallele (u,v)-Rechtecke, siehe +//! `section.rs`-Moduldoc) in kamerataugliche Dreiecks-Geometrie fuer die Cap- +//! Pipeline (`gpu::cap_pipeline`, Schraffur in `shaders::CAP_WGSL`). +//! +//! ## Ruecktransformation (u,v) -> Welt +//! +//! `SectionPlane::project(p) = [dot(p - point, u_axis), p.y]` (nur vertikale +//! Ebenen). Die Inverse fuer einen Punkt AUF der Ebene ist damit +//! +//! ```text +//! world = point + u * u_axis (horizontale Lage), world.y = v +//! ``` +//! +//! `u_axis` ist horizontal (y = 0), also traegt `u * u_axis` nichts zur Hoehe +//! bei — `world.y = v` gilt exakt, und der Punkt liegt auf der Ebene, weil +//! `u_axis` und die Welt-Hoehenachse [0,1,0] beide senkrecht auf der (horizon- +//! talen) Normale stehen. Wir nutzen `SectionPlane::u_axis()` (pub(crate)), +//! damit die Achsdefinition (inkl. Entartungs-Fallback) mit `section.rs` +//! identisch bleibt. +//! +//! ## Ausgabeformat +//! +//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32, +//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als +//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges, +//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im +//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken, +//! siehe `section.rs`) — Fan-Triangulierung genuegt. + +use crate::section::{cut_section, SectionPlane}; +use crate::types::{SlabInput, WallInput}; + +/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts +/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs). +pub const CAP_FLOATS_PER_VERTEX: usize = 5; + +/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale +/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die +/// Welt zurueck. Rueckgabe = interleaved Vertexpuffer (`CAP_FLOATS_PER_VERTEX`), +/// leer, wenn die Ebene das Modell nicht schneidet. +/// +/// WINDING: bewusst nicht garantiert kamerakonsistent — die Cap-Pipeline +/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die +/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt +/// nicht von der Normale ab. +pub fn build_cut_caps( + plane: &SectionPlane, + walls: &[WallInput], + slabs: &[SlabInput], +) -> Vec { + let out = cut_section(plane, walls, slabs); + let point = plane.point; + let u_axis = plane.u_axis(); + // (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v. + let to_world = |uv: [f32; 2]| -> [f32; 3] { + [ + point[0] + uv[0] * u_axis[0], + uv[1], + point[2] + uv[0] * u_axis[2], + ] + }; + + let mut verts: Vec = Vec::new(); + for poly in &out.cut_polygons { + let pts = &poly.pts; + if pts.len() < 3 { + continue; + } + let mut push_vertex = |uv: [f32; 2]| { + let w = to_world(uv); + verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]); + }; + // Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1). + for i in 1..pts.len() - 1 { + push_vertex(pts[0]); + push_vertex(pts[i]); + push_vertex(pts[i + 1]); + } + } + verts +} diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index 693a9ce..89a1c8c 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -31,12 +31,13 @@ /// + Lambert-Directional — GPU-Aequivalent eines Architektur-Massenmodell-Lichts. pub const MESH_WGSL: &str = r#" struct Globals { - view_proj : mat4x4, - light_dir : vec4, - sky_color : vec4, - ground_color : vec4, - sun_color : vec4, - mode : vec4, + view_proj : mat4x4, + light_dir : vec4, + sky_color : vec4, + ground_color : vec4, + sun_color : vec4, + mode : vec4, + section_plane : vec4, }; @group(0) @binding(0) var globals : Globals; @@ -50,6 +51,7 @@ struct VsOut { @builtin(position) clip_pos : vec4, @location(0) world_normal : vec3, @location(1) color : vec3, + @location(2) world_pos : vec3, }; @vertex @@ -58,11 +60,21 @@ fn vs_main(in : VsIn) -> VsOut { out.clip_pos = globals.view_proj * vec4(in.position, 1.0); out.world_normal = in.normal; out.color = in.color; + out.world_pos = in.position; return out; } @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { + // Live-Schnitt: mode.y > 0.5 aktiviert die Kappung. section_plane.xyz = + // Ebenennormale (= Blickrichtung), .w = -dot(n, point). depth > 0 heisst + // "hinter der Ebene" (in Blickrichtung) -> dieses Material wird weggeschnitten, + // die Schnittflaechen-Caps (cap_pipeline) fuellen die entstehende Wunde. + if (globals.mode.y > 0.5) { + if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) { + discard; + } + } let n = normalize(in.world_normal); let l = normalize(globals.light_dir.xyz); let style = globals.mode.x; @@ -120,12 +132,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { /// gemeinsamen Tiefenpuffer, sodass das Modell das Gitter korrekt verdeckt. pub const GRID_WGSL: &str = r#" struct Globals { - view_proj : mat4x4, - light_dir : vec4, - sky_color : vec4, - ground_color : vec4, - sun_color : vec4, - mode : vec4, + view_proj : mat4x4, + light_dir : vec4, + sky_color : vec4, + ground_color : vec4, + sun_color : vec4, + mode : vec4, + section_plane : vec4, }; @group(0) @binding(0) var globals : Globals; @@ -136,7 +149,8 @@ struct VsIn { struct VsOut { @builtin(position) clip_pos : vec4, - @location(0) color : vec3, + @location(0) color : vec3, + @location(1) world_pos : vec3, }; @vertex @@ -144,11 +158,79 @@ fn vs_main(in : VsIn) -> VsOut { var out : VsOut; out.clip_pos = globals.view_proj * vec4(in.position, 1.0); out.color = in.color; + out.world_pos = in.position; return out; } @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { + // Gleiche Kappung wie die Mesh-Pipeline: Grid-, Modell-Kanten- und + // Highlight-Linien (alle teilen diesen Shader) werden an der Schnittebene + // weggeschnitten, damit auf der abgeschnittenen Seite keine Kanten stehen + // bleiben (mode.y > 0.5 = aktiv). + if (globals.mode.y > 0.5) { + if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) { + discard; + } + } return vec4(in.color, 1.0); } "#; + +/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline). +/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2], +/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps +/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale +/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern — +/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention. +pub const CAP_WGSL: &str = r#" +struct Globals { + view_proj : mat4x4, + light_dir : vec4, + sky_color : vec4, + ground_color : vec4, + sun_color : vec4, + mode : vec4, + section_plane : vec4, +}; +@group(0) @binding(0) var globals : Globals; + +struct VsIn { + @location(0) position : vec3, + @location(1) uv : vec2, +}; + +struct VsOut { + @builtin(position) clip_pos : vec4, + @location(0) uv : vec2, +}; + +@vertex +fn vs_main(in : VsIn) -> VsOut { + var out : VsOut; + out.clip_pos = globals.view_proj * vec4(in.position, 1.0); + out.uv = in.uv; + return out; +} + +@fragment +fn fs_main(in : VsOut) -> @location(0) vec4 { + // 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen + // Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten + // von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm- + // groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel). + let spacing = 0.08; // Linienabstand ~8 cm + let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm) + let ink = vec3(0.06, 0.06, 0.06); + let paper = vec3(0.94, 0.94, 0.94); + + let s = (in.uv.x + in.uv.y) / spacing; + let frac = fract(s); + let dist = min(frac, 1.0 - frac); // 0 auf der Linie + let half_s = half / spacing; + let aa = max(fwidth(s), 1e-5); + let line = 1.0 - smoothstep(half_s, half_s + aa, dist); + let color = mix(paper, ink, line); + return vec4(color, 1.0); +} +"#; diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index f44dc67..a2559a0 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -98,6 +98,15 @@ pub struct WebModelRenderer { /// Ziel/Distanz, mirror der TS-`fitTargetDist` in Wasm3DViewport.tsx). /// `None` bei leerem Modell (kein Wall-/Slab-Vertex) oder vor `set_model`. bounds: Option<([f32; 3], [f32; 3])>, + /// Gecachte Wand-/Decken-Eingabe des zuletzt gesetzten Modells. `set_model` + /// verwirft sie nach dem GPU-Upload NICHT (anders als der Renderer selbst), + /// weil der Live-Schnitt (`set_section_plane`) daraus jederzeit die + /// Cut-Cap-Geometrie neu berechnen koennen muss (GPU-frei, `cut_section`). + walls: Vec, + slabs: Vec, + /// Aktueller Live-Schnitt-Zustand: aktiv? + Ebene (point/normal). Gemerkt, + /// damit ein `set_model` bei aktivem Schnitt die Caps automatisch neu baut. + section: Option<([f32; 3], [f32; 3])>, } /// Achsparallele Bounding-Box des Modells in Welt-Metern (Y-up), aus Waenden + @@ -224,6 +233,9 @@ impl WebModelRenderer { renderer, camera: Camera::default(), bounds: None, + walls: Vec::new(), + slabs: Vec::new(), + section: None, }) } @@ -237,6 +249,47 @@ impl WebModelRenderer { self.bounds = model_bounds(&model.walls, &model.slabs); self.renderer .upload_model(&self.device, &model.walls, &model.slabs, &model.meshes); + // Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer + // sie nur zu GPU-Puffern verarbeitet und verwirft). + self.walls = model.walls; + self.slabs = model.slabs; + // Ist der Schnitt aktiv, die Caps am neuen Modell neu aufbauen. + if let Some((point, normal)) = self.section { + self.renderer.update_section( + &self.device, + true, + point, + normal, + &self.walls, + &self.slabs, + ); + } + Ok(()) + } + + /// Live-Schnitt ein-/ausschalten und die Schnittebene setzen. `active=false` + /// hebt den Schnitt auf (Modell wieder komplett, keine Caps). `p*` = Punkt auf + /// der Ebene, `n*` = Ebenennormale = Blickrichtung (world-Meter, Y-up; wird + /// normalisiert). NUR VERTIKALE Ebenen sind sinnvoll (Normale ohne Y-Anteil — + /// Annahme von `section.rs`). Kappt Mesh/Kanten/Grid per Shader-`discard` und + /// baut aus den gecachten Waenden/Decken die Schnittflaechen-Schraffur (Caps). + /// Wirkt erst beim naechsten `render`. + #[allow(clippy::too_many_arguments)] + pub fn set_section_plane( + &mut self, + active: bool, + px: f32, + py: f32, + pz: f32, + nx: f32, + ny: f32, + nz: f32, + ) -> Result<(), JsValue> { + let point = [px, py, pz]; + let normal = [nx, ny, nz]; + self.section = if active { Some((point, normal)) } else { None }; + self.renderer + .update_section(&self.device, active, point, normal, &self.walls, &self.slabs); Ok(()) } diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index 2f7360b..887a8e4 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -42,7 +42,7 @@ // Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je // Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen. -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { Project } from "../model/types"; import { projectToModel3d, pickGeometry } from "../plan/toWalls3d"; import type { RenderMode, View3d } from "../ui/TopBar"; @@ -220,10 +220,16 @@ export function Wasm3DViewport({ setRenderStyle, applyViewPreset, setGroundGrid, + setSectionPlane, setHighlightLines, ready, failed, } = useWasm3dRenderer(canvasRef, true); + // Live-Schnitt-Zustand (lokaler Overlay-Umschalter, MVP): vertikale + // Schnittebene durch die Modellmitte, Blick +Y (Grundriss-Y = world Z, s. + // section.rs `looking_plus_y`: normal = [0,0,1]). Kein Store-/App-Prop — + // reine Viewport-Ansichts-Option analog zum Grid-Overlay. + const [sectionActive, setSectionActive] = useState(false); // 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). @@ -277,6 +283,18 @@ export function Wasm3DViewport({ redrawRef.current(); }, [ready, gridVisible, groundElevation, setGroundGrid]); + // Live-Schnitt: bei Bereitschaft, Umschalten (`sectionActive`) oder + // Modelländerung neu setzen. MVP-Ebene = vertikal durch die Modellmitte + // (`fitTargetDist`-Ziel), Blick nach +Y -> Normale [0,0,1] (Grundriss-Y = + // world Z, s. section.rs `looking_plus_y`). Bei aktivem Schnitt baut Rust die + // Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken. + useEffect(() => { + if (!ready) return; + const { target } = fitTargetDist(project); + setSectionPlane(sectionActive, target, [0, 0, 1]); + redrawRef.current(); + }, [ready, sectionActive, project, setSectionPlane]); + // Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien // (Auswahl gewechselt/aufgehoben) neu setzen + zeichnen. Die Engine hält das // Highlight, bis es erneut gesetzt wird — Kamerabewegungen (Orbit/Pan/Zoom) @@ -504,6 +522,37 @@ export function Wasm3DViewport({ Darstellungsart kommen von der Oberleiste (TopBar); das Grid-Overlay unten ist der einzige eigene Umschalter hier. */} + {/* Live-Schnitt-Umschalter (eigener Overlay-Button, MVP): vertikale + Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem + Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */} + {onToggleGrid && ( // Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport` // ist `position: relative` — s. styles.css), stört die Maus-Navigation diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index d31099f..48efa70 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -53,6 +53,20 @@ interface WebModelRendererLike { extent_m: number, spacing_m: number, ): void; + /** Live-Schnitt ein-/ausschalten (Rust-seitig, `web::set_section_plane`). + * `active=false` hebt den Schnitt auf. `p*` = Punkt auf der Ebene, `n*` = + * Ebenennormale = Blickrichtung (world-Meter, Y-up; nur vertikale Ebenen). + * Kappt das Modell per Shader-`discard` und fuellt die Schnittflaeche mit der + * Diagonalschraffur (Caps). Wirkt erst beim naechsten `render`. */ + set_section_plane( + active: boolean, + px: number, + py: number, + pz: number, + nx: number, + ny: number, + nz: number, + ): void; /** * Auswahl-Hervorhebung setzen: ein LineList-Vertex-Array (interleaved * `[px,py,pz, r,g,b, ...]`, world-Meter, je 2 Vertices = 1 Linie), das die @@ -187,6 +201,38 @@ export function useWasm3dRenderer( [], ); + /** + * Live-Schnitt setzen/aufheben (Overlay-Umschalter im Viewport, s. + * Wasm3DViewport.tsx). `active=false` zeigt das Modell wieder komplett. + * `point` = Punkt auf der Schnittebene, `normal` = Ebenennormale = + * Blickrichtung (world-Meter, Y-up; nur vertikale Ebenen sinnvoll). Wirkt + * erst beim nächsten `render`. + */ + const setSectionPlane = useCallback( + ( + active: boolean, + point: [number, number, number], + normal: [number, number, number], + ) => { + const r = rendererRef.current; + if (!r) return; + try { + r.set_section_plane( + active, + point[0], + point[1], + point[2], + normal[0], + normal[1], + normal[2], + ); + } catch (e) { + console.warn("render3d set_section_plane fehlgeschlagen:", e); + } + }, + [], + ); + /** * Auswahl-Hervorhebung setzen (Umriss-Linien des gewählten Bauteils, immer * obenauf) — reicht das fertige LineList-Vertex-Array 1:1 an @@ -274,6 +320,7 @@ export function useWasm3dRenderer( setRenderStyle, applyViewPreset, setGroundGrid, + setSectionPlane, setHighlightLines, ready, failed,