diff --git a/src-tauri/render3d/src/edges.rs b/src-tauri/render3d/src/edges.rs new file mode 100644 index 0000000..0a1ee64 --- /dev/null +++ b/src-tauri/render3d/src/edges.rs @@ -0,0 +1,250 @@ +// Modell-Kanten (Feature-Edges) fuer die Draufsicht-Stile "wireframe" und +// "hidden" des 3D-Renderers. +// +// Leitet aus einem fertigen Dreiecks-`Mesh` (types::Mesh, interleaved +// [pos, normal, color]) eine schlanke Kantenliste ab und liefert sie im +// GLEICHEN Vertex-Format wie das Bodengitter (grid.rs): interleaved +// [px,py,pz, r,g,b], je 2 Vertices = 1 Liniensegment (LineList). Dadurch teilt +// sich die Kanten-Zeichnung Shader (GRID_WGSL) und Vertex-Layout mit dem Grid; +// die GPU-Schicht (gpu.rs) laedt die Vertices einmalig bei `set_model` in einen +// Linien-Vertex-Buffer und cached sie (nicht je Frame). +// +// FEATURE-EDGE-STATT-ALLE-DREIECKSKANTEN: Wuerde man jede Dreieckskante zeichnen, +// erschienen auch die Triangulierungs-Diagonalen jeder rechteckigen Flaeche +// (jede Wand-/Deckenseite ist aus 2 Dreiecken) — ein unruhiges Gitter. Statt +// dessen dedupliziert diese Schicht geteilte Kanten UND unterdrueckt Kanten +// zwischen zwei koplanaren Dreiecken (Flaechen-Innendiagonalen): gezeichnet +// werden nur Silhouetten-/Randkanten (Kante gehoert nur einem Dreieck) und +// Knickkanten ("Crease": die beiden angrenzenden Dreiecke stehen deutlich +// zueinander gewinkelt). Ergebnis ist ein sauberer Architektur-Drahtgitter-/ +// Hidden-Line-Look statt einer Dreiecks-Suppe. +// +// GPU-frei (nur `Vec`), damit headless per `cargo test` pruefbar (Muster: +// grid.rs / mesh.rs / section.rs). + +use std::collections::HashMap; + +use crate::types::{Mesh, FLOATS_PER_VERTEX}; + +/// f32 je Kanten-Vertex im interleaved Puffer: 3 Position (world) + 3 Farbe. +/// Bewusst identisch zu `grid::GRID_FLOATS_PER_VERTEX`, damit dieselbe +/// LineList-Pipeline/derselbe Shader (GRID_WGSL) beide zeichnen kann. +pub const EDGE_FLOATS_PER_VERTEX: usize = 6; + +/// Farbe der Modell-Kanten (RGB 0..1). Dezentes, dunkles Anthrazit — deutlich +/// genug fuer den Hidden-Line-Look, ohne hart schwarz zu wirken. +pub const EDGE_COLOR: [f32; 3] = [0.16, 0.16, 0.18]; + +/// Quantisierungsfaktor fuer den Kanten-Schluessel: Positionen werden auf 0.1 mm +/// (1e4 pro Meter) gerundet, damit geometrisch zusammenfallende Endpunkte aus +/// verschiedenen (pro Flaeche duplizierten) Vertices denselben Schluessel teilen. +const QUANT: f32 = 1.0e4; + +/// Kosinus des Crease-Winkels: liegen die Normalen zweier angrenzender Dreiecke +/// enger beieinander (dot > COS), gelten sie als koplanar → die geteilte Kante +/// wird NICHT gezeichnet (Flaechen-Innendiagonale). Darunter = Knickkante → +/// zeichnen. cos(15°) ≈ 0.966: Ebenen-Diagonalen (dot≈1) fallen weg, echte +/// Bauteil-Kanten (≥ ~90°) bleiben. +const CREASE_COS: f32 = 0.966; + +/// Ganzzahliger Positions-Schluessel (quantisiert) fuer die Kanten-Deduplizierung. +type Key = [i64; 3]; + +fn key_of(p: [f32; 3]) -> Key { + [ + (p[0] * QUANT).round() as i64, + (p[1] * QUANT).round() as i64, + (p[2] * QUANT).round() as i64, + ] +} + +/// Sammlung je Kante: die (erste gesehene) Position beider Endpunkte plus die +/// Normalen aller angrenzenden Dreiecke — daraus entscheidet sich Rand/Knick. +struct EdgeRec { + p0: [f32; 3], + p1: [f32; 3], + normals: Vec<[f32; 3]>, +} + +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn normalize(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if l <= 1e-12 { + [0.0, 0.0, 0.0] + } else { + [v[0] / l, v[1] / l, v[2] / l] + } +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc). +/// +/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment. +/// Leerer Vektor bei leerem/degeneriertem Mesh. +pub fn build_mesh_edges(mesh: &Mesh) -> Vec { + if mesh.indices.len() < 3 { + return Vec::new(); + } + let pos = |i: u32| -> [f32; 3] { + let b = i as usize * FLOATS_PER_VERTEX; + [mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]] + }; + + // Kanten-Schluessel (kanonisch sortiertes Endpunkt-Paar) -> Datensatz. + let mut edges: HashMap<(Key, Key), EdgeRec> = HashMap::new(); + + for tri in mesh.indices.chunks_exact(3) { + let (a, b, c) = (pos(tri[0]), pos(tri[1]), pos(tri[2])); + // Geometrische Flaechennormale (robuster als die pro-Vertex-Normalen). + let n = normalize(cross(sub(b, a), sub(c, a))); + if n == [0.0, 0.0, 0.0] { + continue; // entartetes Dreieck ueberspringen + } + for (u, v) in [(a, b), (b, c), (c, a)] { + let (ku, kv) = (key_of(u), key_of(v)); + if ku == kv { + continue; // Null-Laengen-Kante + } + // Kanonische Reihenfolge, damit u->v und v->u denselben Schluessel haben. + let (key, p0, p1) = if ku <= kv { + ((ku, kv), u, v) + } else { + ((kv, ku), v, u) + }; + edges + .entry(key) + .or_insert_with(|| EdgeRec { + p0, + p1, + normals: Vec::new(), + }) + .normals + .push(n); + } + } + + let mut verts: Vec = Vec::new(); + let mut push = |p: [f32; 3]| { + verts.push(p[0]); + verts.push(p[1]); + verts.push(p[2]); + verts.push(EDGE_COLOR[0]); + verts.push(EDGE_COLOR[1]); + verts.push(EDGE_COLOR[2]); + }; + + for rec in edges.values() { + if should_draw(&rec.normals) { + push(rec.p0); + push(rec.p1); + } + } + verts +} + +/// Entscheidet, ob eine Kante gezeichnet wird: Randkante (nur ein Dreieck) oder +/// Knickkante (zwei angrenzende Dreiecke stehen ueber dem Crease-Winkel zuein- +/// ander). Koplanare geteilte Kanten (Flaechendiagonalen) werden unterdrueckt. +fn should_draw(normals: &[[f32; 3]]) -> bool { + match normals.len() { + 0 => false, + 1 => true, // Silhouette/Rand: gehoert nur einem Dreieck + _ => { + // Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt. + for i in 0..normals.len() { + for j in (i + 1)..normals.len() { + if dot(normals[i], normals[j]) < CREASE_COS { + return true; + } + } + } + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Hilfsbau: Mesh aus (pos, dummy-normal, dummy-color) + Indizes. + fn mesh(positions: &[[f32; 3]], indices: &[u32]) -> Mesh { + let mut verts = Vec::new(); + for p in positions { + verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]); + } + Mesh { + verts, + indices: indices.to_vec(), + } + } + + fn segment_count(v: &[f32]) -> usize { + v.len() / EDGE_FLOATS_PER_VERTEX / 2 + } + + #[test] + fn single_triangle_has_three_boundary_edges() { + let m = mesh( + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + &[0, 1, 2], + ); + let e = build_mesh_edges(&m); + assert_eq!(segment_count(&e), 3); + } + + #[test] + fn coplanar_quad_drops_shared_diagonal() { + // Ebenes Quad aus 2 Dreiecken in der XZ-Ebene: geteilte Diagonale ist + // koplanar -> weg; es bleiben die 4 Umriss-Kanten. + let m = mesh( + &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 0.0, 1.0], + [0.0, 0.0, 1.0], + ], + &[0, 1, 2, 0, 2, 3], + ); + let e = build_mesh_edges(&m); + assert_eq!(segment_count(&e), 4); + } + + #[test] + fn folded_quad_keeps_crease() { + // Zwei Dreiecke im 90°-Knick teilen die Kante (0,0,0)-(1,0,0): + // Normalen stehen senkrecht -> Knickkante bleibt erhalten. + let m = mesh( + &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 0.0, 1.0], // erstes Dreieck in XZ (Normale +Y) + [1.0, 1.0, 0.0], // zweites Dreieck kippt nach oben (Normale +Z) + ], + &[0, 1, 2, 0, 3, 1], + ); + let e = build_mesh_edges(&m); + // geteilte Knickkante + je 2 Randkanten pro Dreieck = 5 Segmente. + assert_eq!(segment_count(&e), 5); + } + + #[test] + fn empty_mesh_yields_no_edges() { + let m = Mesh::default(); + assert!(build_mesh_edges(&m).is_empty()); + } +} diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index e73e4ff..64909e6 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -12,12 +12,66 @@ use bytemuck::{Pod, Zeroable}; use wgpu::util::DeviceExt; +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::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX}; +/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/ +/// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und +/// welchen `mode.x`-Wert der Mesh-Shader bekommt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderStyle { + /// Beleuchtete Bauteilfarben (Default). + Shaded, + /// Clay-Look: einheitlich helles Material, beleuchtet. + White, + /// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake). + Textured, + /// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen. + Wireframe, + /// Hidden-Line: flach-weisse Flaechen (fuellen den Depth-Buffer) + dunkle + /// Kanten obenauf, tiefengetestet -> nur sichtbare Kanten erscheinen. + Hidden, +} + +impl RenderStyle { + /// Parst den API-String; unbekannte Werte fallen auf `Shaded` zurueck. + pub fn from_str_or_shaded(s: &str) -> Self { + match s { + "white" => RenderStyle::White, + "textured" => RenderStyle::Textured, + "wireframe" => RenderStyle::Wireframe, + "hidden" => RenderStyle::Hidden, + _ => RenderStyle::Shaded, + } + } + + /// `mode.x`-Wert fuer den Mesh-Shader (siehe shaders.rs): 0 = Material/shaded + /// (auch textured), 1 = weiss/Clay, 2 = flach-weisse Hidden-Line-Flaechen. + fn mesh_mode(self) -> f32 { + match self { + RenderStyle::White => 1.0, + RenderStyle::Hidden => 2.0, + // Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht + // gezeichnet) -> Material. + _ => 0.0, + } + } + + /// Werden in diesem Stil gefuellte Flaechen gezeichnet? + fn draws_faces(self) -> bool { + !matches!(self, RenderStyle::Wireframe) + } + + /// Werden in diesem Stil Modell-Kanten gezeichnet? + fn draws_edges(self) -> bool { + matches!(self, RenderStyle::Wireframe | RenderStyle::Hidden) + } +} + /// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar). pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; @@ -69,8 +123,10 @@ struct MeshBuffers { index_count: u32, } -/// GPU-seitiger Vertex-Buffer des Bodengitters (LineList, kein Indexpuffer). -struct GridBuffers { +/// GPU-seitiger Vertex-Buffer einer LineList (kein Indexpuffer). Format +/// [pos vec3, color vec3] — genutzt fuer das Bodengitter UND die Modell-Kanten +/// (identisches Layout/Shader, siehe grid.rs / edges.rs). +struct LineBuffers { vbo: wgpu::Buffer, vertex_count: u32, } @@ -80,19 +136,32 @@ struct GridBuffers { /// Ziel-Groesse (neu) angelegt. pub struct Renderer { pipeline: wgpu::RenderPipeline, - /// Eigene, schlanke Linien-Pipeline (LineList, unlit) fuer das Bodengitter. + /// Wie `pipeline`, aber mit positivem Depth-Bias (Flaechen leicht nach hinten + /// geschoben). Nur fuer den Hidden-Line-Stil: so gewinnen die danach an ihrer + /// wahren Tiefe gezeichneten Kanten den Less-Test und liegen sauber obenauf, + /// waehrend verdeckte Kanten weiterhin weggeschnitten werden (kein Z-Fighting). + hidden_face_pipeline: wgpu::RenderPipeline, + /// Eigene, schlanke Linien-Pipeline (LineList, unlit) fuer das Bodengitter + /// UND die Modell-Kanten (wireframe/hidden — identisches Layout/Shader). /// 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, + /// Modell-Kanten (Feature-Edges, edges.rs) als LineList. Einmalig bei + /// `set_model`/`upload_*` erzeugt und gecacht (nicht je Frame). Gezeichnet in + /// den Stilen wireframe/hidden. + edges: Option, /// Bodengitter-Vertices (None = nicht erzeugt). Wird nur gezeichnet, wenn /// zusaetzlich `grid_visible` gesetzt ist. - grid: Option, + grid: Option, /// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false — /// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter. grid_visible: bool, + /// Aktuelle Darstellungsart (Default `Shaded`). Einzige Quelle der Wahrheit; + /// `set_white_mode` bildet auf `Shaded`/`White` ab. + style: RenderStyle, depth: Option, msaa: Option, /// Farbformat des Render-Ziels (Surface), fuer die MSAA-Zwischentextur. @@ -155,47 +224,64 @@ impl Renderer { attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3], }; - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("mesh.pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_main"), - buffers: &[vertex_layout], - compilation_options: Default::default(), + // Mesh-Pipeline-Fabrik: identische Beschreibung fuer die Standard-Flaechen + // (kein Bias) und die Hidden-Line-Flaechen (positiver Depth-Bias, damit die + // spaeter gezeichneten Kanten obenauf liegen). Nur `bias` unterscheidet sich. + let make_mesh_pipeline = |label: &str, bias: wgpu::DepthBiasState| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + buffers: &[vertex_layout.clone()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &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, + // Aussen zeigende Flaechen sind CCW (mesh.rs) -> Rueckseiten cullen. + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + ..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, + }), + multisample: wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }; + let pipeline = make_mesh_pipeline("mesh.pipeline", wgpu::DepthBiasState::default()); + // Hidden-Line-Flaechen leicht nach hinten schieben (Depth-Bias auf + // Dreiecke wirkt zuverlaessig — anders als bei Linien), damit die Kanten + // an ihrer wahren Tiefe den Less-Test gewinnen. + let hidden_face_pipeline = make_mesh_pipeline( + "mesh.hidden.pipeline", + wgpu::DepthBiasState { + constant: 4, + slope_scale: 1.0, + clamp: 0.0, }, - fragment: Some(wgpu::FragmentState { - module: &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, - // Aussen zeigende Flaechen sind CCW (mesh.rs) -> Rueckseiten cullen. - front_face: wgpu::FrontFace::Ccw, - cull_mode: Some(wgpu::Face::Back), - ..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, - }); + ); // Bodengitter-Pipeline: dieselbe Bind-Group (Globals/View-Projektion) und // dasselbe Depth-/MSAA-/Farbziel wie die Mesh-Pipeline, aber LineList- @@ -268,12 +354,15 @@ impl Renderer { Self { pipeline, + hidden_face_pipeline, grid_pipeline, bind_group, uniform, mesh: None, + edges: None, grid: None, grid_visible: false, + style: RenderStyle::Shaded, depth: None, msaa: None, color_format, @@ -305,12 +394,32 @@ impl Renderer { self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes)); } - /// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer). + /// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und + /// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer + /// die Stile wireframe/hidden — einmalig hier, nicht je Frame. fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) { if mesh.indices.is_empty() { self.mesh = None; + self.edges = None; return; } + + // Kanten aus der Mesh-Geometrie ableiten und (falls vorhanden) hochladen. + let edge_verts = build_mesh_edges(&mesh); + self.edges = if edge_verts.is_empty() { + None + } else { + let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("edges.vbo"), + contents: bytemuck::cast_slice(&edge_verts), + usage: wgpu::BufferUsages::VERTEX, + }); + Some(LineBuffers { + vbo, + vertex_count: (edge_verts.len() / EDGE_FLOATS_PER_VERTEX) as u32, + }) + }; + let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mesh.vbo"), contents: bytemuck::cast_slice(&mesh.verts), @@ -342,10 +451,21 @@ impl Renderer { self.globals.sun_color = [sun[0], sun[1], sun[2], 1.0]; } - /// Darstellungsart umschalten: `true` = weisses Clay-Material (Beleuchtung - /// bleibt aktiv, siehe shaders.rs), `false` = normale Bauteil-/Vertexfarbe. + /// Darstellungsart setzen (shaded/white/textured/wireframe/hidden). Einzige + /// Quelle der Wahrheit; `render` leitet daraus Pipeline-Wahl und `mode.x` ab. + pub fn set_render_style(&mut self, style: RenderStyle) { + self.style = style; + } + + /// Rueckwaerts-kompatibler Schalter: `true` bildet auf `White`, `false` auf + /// `Shaded` ab (kein separater Zustand mehr — vermeidet Widerspruch zum + /// `style`-Feld). Neue Aufrufer nutzen `set_render_style`. pub fn set_white_mode(&mut self, white: bool) { - self.globals.mode[0] = if white { 1.0 } else { 0.0 }; + self.style = if white { + RenderStyle::White + } else { + RenderStyle::Shaded + }; } /// Setzt/aktualisiert das Referenz-Bodengitter (grid.rs). Erzeugt den Linien- @@ -372,7 +492,7 @@ impl Renderer { contents: bytemuck::cast_slice(&verts), usage: wgpu::BufferUsages::VERTEX, }); - self.grid = Some(GridBuffers { + self.grid = Some(LineBuffers { vbo, vertex_count: (verts.len() / GRID_FLOATS_PER_VERTEX) as u32, }); @@ -453,6 +573,9 @@ impl Renderer { let aspect = if h > 0 { w as f32 / h as f32 } else { 1.0 }; let vp: Mat4 = view_projection(camera, aspect); self.globals.view_proj = vp; + // `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/ + // Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal. + self.globals.mode[0] = self.style.mesh_mode(); queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals)); self.ensure_depth(device, w, h); @@ -493,16 +616,40 @@ impl Renderer { multiview_mask: None, }); - if let Some(m) = &self.mesh { - pass.set_pipeline(&self.pipeline); - pass.set_bind_group(0, &self.bind_group, &[]); - pass.set_vertex_buffer(0, m.vbo.slice(..)); - pass.set_index_buffer(m.ibo.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..m.index_count, 0, 0..1); + // 1) Gefuellte Flaechen — in allen Stilen ausser "wireframe". Im + // Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit + // die folgenden Kanten sauber obenauf liegen. + if self.style.draws_faces() { + if let Some(m) = &self.mesh { + let face_pipeline = if self.style == RenderStyle::Hidden { + &self.hidden_face_pipeline + } else { + &self.pipeline + }; + pass.set_pipeline(face_pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, m.vbo.slice(..)); + 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. + // 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 + // Flaechen -> alle Kanten sichtbar. + if self.style.draws_edges() { + if let Some(e) = &self.edges { + pass.set_pipeline(&self.grid_pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, e.vbo.slice(..)); + pass.draw(0..e.vertex_count, 0..1); + } + } + + // 3) Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer -> + // das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur + // wenn sichtbar + vorhanden. if self.grid_visible { if let Some(g) = &self.grid { pass.set_pipeline(&self.grid_pipeline); diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 46f26d8..8f85834 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -14,6 +14,7 @@ // Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die // GPU-freien Schichten und ist damit unabhaengig von einer Display-Session. +pub mod edges; pub mod grid; pub mod math; pub mod mesh; diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index 9ecd714..693a9ce 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -16,9 +16,16 @@ // sky_color : vec4 Himmels-Ambient (rgb; von oben) // ground_color : vec4 Boden-Ambient (rgb; von unten) // sun_color : vec4 Farbe/Staerke des Directional-Lights (rgb) -// mode : vec4 x = Darstellungsart (0 = shaded/Material, 1 = weiss/Clay) +// mode : vec4 x = Darstellungsart (0 = shaded/Material, +// 1 = weiss/Clay, 2 = hidden-Line-Flaechen/flach-weiss) // // Vertex-Attribute: position (world), normal (world), color (Albedo). +// +// FILL-LIGHT: Zusaetzlich zum Haupt-Directional (Sonne) wirkt ein schwaches +// GEGEN-Fuelllicht aus der entgegengesetzten Azimut-Richtung (leicht von oben), +// abgeleitet aus der Sonnenrichtung — es hebt die vom Hauptlicht abgewandten +// Flaechen an, damit sie nicht in reinem Schwarz absaufen. Dezent (≈ 0.28 der +// Sonne, leicht kuehl), physikalisch als Himmels-/Bounce-Fuellung plausibel. /// Der einzige Shader (Vertex + Fragment). Hemisphaerisches Ambient (Himmel/Boden) /// + Lambert-Directional — GPU-Aequivalent eines Architektur-Massenmodell-Lichts. @@ -58,6 +65,7 @@ fn vs_main(in : VsIn) -> VsOut { fn fs_main(in : VsOut) -> @location(0) vec4 { let n = normalize(in.world_normal); let l = normalize(globals.light_dir.xyz); + let style = globals.mode.x; // Hemisphaerisches Ambient: Mischfaktor aus der Vertikal-Komponente der // Normalen (n.y = +1 -> voll Himmel, n.y = -1 -> voll Boden). @@ -67,18 +75,40 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { // Gerichteter Lambert-Anteil (Sonne); Rueckseiten (n.l < 0) tragen nichts bei. let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb; - // Weiss-Modus (Clay-Look): Material-/Vertexfarbe durch ein einheitliches - // Hellgrau ersetzen, Beleuchtung (Ambient + Directional) bleibt aktiv — so - // liest sich die Volumetrie ohne Bauteil-Farben ab (Vectorworks „white"). - let albedo = mix(in.color, vec3(0.82, 0.82, 0.80), globals.mode.x); + // Schwaches Gegen-Fuelllicht: aus entgegengesetztem Azimut, leicht von oben, + // aus der Sonnenrichtung abgeleitet — hebt abgewandte Flaechen an (kein + // reines Schwarz). Dezent (0.28) und leicht kuehl getoent. + let fill_l = normalize(vec3(-l.x, abs(l.y) * 0.35 + 0.15, -l.z)); + let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb + * vec3(0.24, 0.27, 0.30); - var shaded = albedo * (ambient + diffuse); + // Weiss-Modus (Clay, style==1) bzw. Hidden-Flaechen (style==2): Material-/ + // Vertexfarbe durch ein einheitliches Hell ersetzen. Clay = warmes Hellgrau, + // Hidden = nahezu Weiss (damit die dunklen Kanten obenauf klar lesbar sind). + let clay = vec3(0.82, 0.82, 0.80); + let paper = vec3(0.97, 0.97, 0.97); + var albedo = in.color; + if (style > 1.5) { + albedo = paper; + } else if (style > 0.5) { + albedo = clay; + } + + var shaded = albedo * (ambient + diffuse + fill); // Sanfte Kantenbetonung: senkrechte Flaechen (n.y nahe 0) minimal abdunkeln, // damit sich Waende vom hellen Deckel/Boden abheben (ohne Postprocessing). let edge = 0.90 + 0.10 * abs(n.y); shaded = shaded * edge; + // Hidden-Line-Flaechen: sehr flach halten (fast unlit, nahezu weiss), damit + // die dunklen Modell-Kanten die Form tragen und die Flaechen nur den Depth- + // Buffer fuellen (verdeckte Kanten wegschneiden). Leichte Hemi-Toenung bleibt, + // damit die Silhouette auch ohne Kante nicht voellig verschwindet. + if (style > 1.5) { + shaded = albedo * (0.88 + 0.12 * hemi_t); + } + return vec4(shaded, 1.0); } "#; diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index 0e04657..dc7ca74 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -320,9 +320,29 @@ impl WebModelRenderer { Ok(()) } + /// Darstellungsart der Oberleiste setzen. Gueltige Werte: + /// - `"shaded"` — beleuchtete Bauteilfarben (Default). + /// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet + /// (identisch zum bisherigen `set_render_mode_white(true)`). + /// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"` + /// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht. + /// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen. + /// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth- + /// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare). + /// Unbekannte Werte fallen auf `"shaded"` zurueck. Wirkt erst beim naechsten + /// `render`. Loest `set_render_mode_white` ab (das bleibt kompatibel). + pub fn set_render_style(&mut self, style: &str) -> Result<(), JsValue> { + self.renderer + .set_render_style(crate::gpu::RenderStyle::from_str_or_shaded(style)); + Ok(()) + } + /// Darstellungsart umschalten: `true` = weisses Clay-Material (beleuchtet, /// ohne Bauteil-Farben — Vectorworks „white"), `false` = normale Material- - /// farbe (shaded, Default). Wirkt erst beim naechsten `render`. + /// farbe (shaded, Default). Wirkt erst beim naechsten `render`. RUECKWAERTS- + /// KOMPATIBEL — bildet intern auf denselben `render_style`-Zustand ab wie + /// `set_render_style` (true→„white", false→„shaded"), sodass es keine zwei + /// widersprechenden Zustaende gibt. pub fn set_render_mode_white(&mut self, white: bool) { self.renderer.set_white_mode(white); } diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index e39ab8d..5689bf3 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -6,12 +6,13 @@ // 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) + 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). +// art (volle `renderMode`-Union der Oberleiste — shaded/white/textured/ +// wireframe/hidden — via `set_render_style` in Rust; "textured" faellt dort +// vorerst auf "shaded" zurueck) + ein-/ausblendbares Boden-Referenzraster +// (`gridVisible`/`groundElevation`, Overlay-Umschalter im Viewport selbst). +// Auswahl/Raycast sowie Griffe/Zeichnen der three.js-Sicht 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- @@ -170,9 +171,8 @@ export function Wasm3DViewport({ project: Project; /** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ view3d?: View3d; - /** Darstellungsart (Oberleiste): nur `shaded`/`white` wirken hier (die - * ü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. */ + /** Darstellungsart (Oberleiste) — alle fünf Werte wirken hier, s. + * Datei-Kommentar. */ renderMode?: RenderMode; /** Boden-Referenzraster sichtbar? Umgeschaltet über den Overlay-Button unten * rechts im Viewport (`onToggleGrid`) — Single Source of Truth ist der @@ -188,7 +188,7 @@ export function Wasm3DViewport({ const { render, updateModel, - setWhiteMode, + setRenderStyle, applyViewPreset, setGroundGrid, ready, @@ -229,14 +229,14 @@ export function Wasm3DViewport({ redrawRef.current(); }, [ready, view3d, applyViewPreset, render]); - // Darstellungsart (shaded/weiss) an den Renderer weiterreichen, sobald sie - // wechselt oder der Renderer bereit wird — dann mit der aktuell aktiven - // Kamera (Praeset ODER Orbit) neu zeichnen. + // Darstellungsart (volle RenderMode-Union) an den Renderer weiterreichen, + // sobald sie wechselt oder der Renderer bereit wird — dann mit der aktuell + // aktiven Kamera (Praeset ODER Orbit) neu zeichnen. useEffect(() => { if (!ready) return; - setWhiteMode(renderMode === "white"); + setRenderStyle(renderMode); redrawRef.current(); - }, [ready, renderMode, setWhiteMode]); + }, [ready, renderMode, setRenderStyle]); // Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/ // Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (40 m / diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index 09614be..5cd1dff 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -37,7 +37,10 @@ export interface Camera3d { /** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine3d`). */ interface WebModelRendererLike { set_model(json: string): void; - set_render_mode_white(white: boolean): void; + /** Darstellungsart setzen — `style` ∈ "shaded"|"white"|"wireframe"|"hidden"| + * "textured" (deckt die volle `RenderMode`-Union der Oberleiste ab; + * "textured" faellt engine-seitig vorerst auf "shaded" zurueck). */ + set_render_style(style: string): void; set_view_preset(preset: string): void; /** Boden-Referenzraster in der Welt-XZ-Ebene ein-/ausblenden (Rust-seitig, * `web::set_ground_grid`). `visible=false` zeichnet kein Raster; sonst ein @@ -142,17 +145,19 @@ export function useWasm3dRenderer( }, []); /** - * Darstellungsart umschalten: `true` = weisses Clay-Material (beleuchtet, - * ohne Bauteil-Farben), `false` = normale Material-/Vertexfarbe (shaded, - * Default). Wirkt erst beim nächsten `render`. + * Darstellungsart umschalten — reicht die volle `RenderMode`-Union der + * Oberleiste ("shaded"/"white"/"textured"/"wireframe"/"hidden") an Rust + * durch (`web::set_render_style`); die Engine entscheidet je Stil, wie + * gezeichnet wird ("textured" faellt dort vorerst auf "shaded" zurueck). + * Wirkt erst beim nächsten `render`. */ - const setWhiteMode = useCallback((white: boolean) => { + const setRenderStyle = useCallback((style: string) => { const r = rendererRef.current; if (!r) return; try { - r.set_render_mode_white(white); + r.set_render_style(style); } catch (e) { - console.warn("render3d set_render_mode_white fehlgeschlagen:", e); + console.warn("render3d set_render_style fehlgeschlagen:", e); } }, []); @@ -242,7 +247,7 @@ export function useWasm3dRenderer( return { render, updateModel, - setWhiteMode, + setRenderStyle, applyViewPreset, setGroundGrid, ready,