From 919bc67bfab4541baab29383ac31bf54b5355cb1 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 21:08:34 +0200 Subject: [PATCH] =?UTF-8?q?3D:=20Geschossdecken=20als=20extrudierte=20Poly?= =?UTF-8?q?gone=20+=20hemisph=C3=A4risches=20Licht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deckenplatten (SlabInput) werden im render3d aus dem Grundriss-Umriss per Ear-Clipping trianguliert und über die Deckendicke extrudiert (Deckel/Boden/ Mantel mit robust nach außen orientierten Normalen). Payload erweitert auf { walls, slabs } — blanke Wand-Arrays bleiben kompatibel. Beleuchtung auf hemisphärisches Ambient (Himmel/Boden) + Directional-Sonne umgestellt, dezente Kantenbetonung, hellerer Hintergrund (#f5f5f5). Beispiel-Geschossdecke im EG. --- src-tauri/render3d/src/gpu.rs | 52 ++++--- src-tauri/render3d/src/lib.rs | 107 ++++++++++++++- src-tauri/render3d/src/mesh.rs | 216 +++++++++++++++++++++++++++++- src-tauri/render3d/src/shaders.rs | 50 ++++--- src-tauri/render3d/src/types.rs | 26 ++++ src-tauri/src/native.rs | 96 ++++++++----- src/model/sampleProject.ts | 21 ++- src/plan/nativeSync.ts | 4 +- src/plan/toWalls3d.ts | 42 +++--- 9 files changed, 532 insertions(+), 82 deletions(-) diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index a8323de..27c81f3 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -13,9 +13,9 @@ use bytemuck::{Pod, Zeroable}; use wgpu::util::DeviceExt; use crate::math::{view_projection, Mat4}; -use crate::mesh::build_walls_mesh; +use crate::mesh::{build_model_mesh, build_walls_mesh}; use crate::shaders::MESH_WGSL; -use crate::types::{Camera, WallInput, FLOATS_PER_VERTEX}; +use crate::types::{Camera, Mesh, SlabInput, WallInput, FLOATS_PER_VERTEX}; /// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar). pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; @@ -27,18 +27,23 @@ pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; struct Globals { view_proj: [f32; 16], light_dir: [f32; 4], - ambient: [f32; 4], + sky_color: [f32; 4], + ground_color: [f32; 4], + sun_color: [f32; 4], } impl Default for Globals { fn default() -> Self { Self { view_proj: crate::math::identity(), - // Richtung ZUM Licht (world), normiert. Entspricht der three.js-Sonne - // bei (6,12,4): das Licht kommt aus dieser Richtung. + // Richtung ZUM Licht (world), normiert. Sonne oben-vorne (6,12,4). light_dir: normalize4([6.0, 12.0, 4.0]), - // Ambienter Sockel 0.6 (wie three.js-AmbientLight(0.6)). - ambient: [0.6, 0.6, 0.6, 1.0], + // Himmels-Ambient (von oben): helles, leicht kuehles Licht. + sky_color: [0.66, 0.68, 0.72, 1.0], + // Boden-Ambient (von unten): dunkler, warmer Ton (Bounce/Schatten). + ground_color: [0.30, 0.29, 0.27, 1.0], + // Directional-Sonne: warmweiss, moderat. + sun_color: [0.55, 0.53, 0.49, 1.0], } } } @@ -174,11 +179,11 @@ impl Renderer { mesh: None, depth: None, globals: Globals::default(), - // #e9e9e9 heller Hintergrund (wie die three.js-Modellsicht). + // #f5f5f5 heller Hintergrund (wie der 2D-Grundriss). clear_color: wgpu::Color { - r: 0.914, - g: 0.914, - b: 0.914, + r: 0.961, + g: 0.961, + b: 0.961, a: 1.0, }, } @@ -186,7 +191,16 @@ impl Renderer { /// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch. pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) { - let mesh = build_walls_mesh(walls); + self.upload_mesh(device, build_walls_mesh(walls)); + } + + /// Erzeugt das Mesh aus Waenden UND Deckenplatten und laedt die Puffer hoch. + pub fn upload_model(&mut self, device: &wgpu::Device, walls: &[WallInput], slabs: &[SlabInput]) { + self.upload_mesh(device, build_model_mesh(walls, slabs)); + } + + /// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer). + fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) { if mesh.indices.is_empty() { self.mesh = None; return; @@ -208,10 +222,18 @@ impl Renderer { }); } - /// Setzt die Lichtrichtung (Richtung ZUM Licht, world) und den ambienten Sockel. - pub fn set_light(&mut self, dir_to_light: [f32; 3], ambient: f32) { + /// Setzt die Richtung ZUM Directional-Light (Sonne, world). Das hemisphaerische + /// Ambient (Himmel/Boden) bleibt bei den Default-Farben. + pub fn set_light(&mut self, dir_to_light: [f32; 3]) { self.globals.light_dir = normalize4(dir_to_light); - self.globals.ambient = [ambient, ambient, ambient, 1.0]; + } + + /// Uebersteuert die hemisphaerischen Ambient-Farben (Himmel oben, Boden unten) + /// und die Sonnenfarbe. Fuer Feinabstimmung des Massenmodell-Looks. + pub fn set_ambient(&mut self, sky: [f32; 3], ground: [f32; 3], sun: [f32; 3]) { + self.globals.sky_color = [sky[0], sky[1], sky[2], 1.0]; + self.globals.ground_color = [ground[0], ground[1], ground[2], 1.0]; + self.globals.sun_color = [sun[0], sun[1], sun[2], 1.0]; } /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert. diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 49b33fd..fd5d8b6 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -26,9 +26,9 @@ pub use math::{ look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix, view_matrix, view_projection, Mat4, }; -pub use mesh::{build_walls_mesh, extrude_wall}; +pub use mesh::{build_model_mesh, build_walls_mesh, extrude_slab, extrude_wall, triangulate}; pub use types::{ - Camera, CameraPreset, Mesh, Point2, Projection, Rgb, WallInput, FLOATS_PER_VERTEX, + Camera, CameraPreset, Mesh, Point2, Projection, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX, }; // --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) ------------------- @@ -183,6 +183,109 @@ mod tests { assert!(mesh.indices.is_empty()); } + // --- Deckenplatten (extrudierte Polygone) --------------------------------- + + use super::mesh::{build_model_mesh, extrude_slab, triangulate}; + use super::types::SlabInput; + + /// Quadratischer Decken-Umriss (CCW) mit Kantenlaenge `s`, Ecke im Ursprung. + fn square_slab(s: f32, z_bottom: f32, z_top: f32) -> SlabInput { + SlabInput { + outline: vec![[0.0, 0.0], [s, 0.0], [s, s], [0.0, s]], + z_bottom, + z_top, + color: [0.86, 0.86, 0.88], + } + } + + #[test] + fn triangulate_quadrat_gibt_zwei_dreiecke() { + let tris = triangulate(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]); + assert_eq!(tris.len(), 6, "Quadrat -> 2 Dreiecke = 6 Indizes"); + } + + #[test] + fn triangulate_cw_umriss_ebenfalls_zwei_dreiecke() { + // CW-Umriss (negative Flaeche) muss genauso trianguliert werden. + let tris = triangulate(&[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]); + assert_eq!(tris.len(), 6); + } + + #[test] + fn slab_deckel_und_boden_und_mantel() { + // Quadratische Platte: Deckel (2 Tri) + Boden (2 Tri) + 4 Mantel-Quads + // (je 2 Tri) = 4 + 8 = 12 Dreiecke = 36 Vertices (je Tri eigene Vertices). + let mut mesh = Mesh::default(); + extrude_slab(&mut mesh, &square_slab(4.0, 2.4, 2.65)); + assert_eq!(mesh.triangle_count(), 12, "2+2 Kappen + 8 Mantel"); + assert_eq!(mesh.vertex_count(), 36); + // Z-Ausdehnung deckt die Deckendicke ab. + let (min, max) = mesh.bounds(); + assert!((min[1] - 2.4).abs() < 1e-5, "UK 2.4"); + assert!((max[1] - 2.65).abs() < 1e-5, "OK 2.65"); + } + + #[test] + fn slab_deckel_normale_zeigt_nach_oben_boden_nach_unten() { + let mut mesh = Mesh::default(); + extrude_slab(&mut mesh, &square_slab(4.0, 0.0, 0.25)); + // Fuer jedes Vertex mit y==0.25 (Deckel) muss die Normale +Y sein, fuer + // y==0.0 (Boden) −Y. Mantel-Vertices haben n.y ≈ 0. + let n = mesh.vertex_count(); + for i in 0..n { + let b = i * FLOATS_PER_VERTEX; + let py = mesh.verts[b + 1]; + let ny = mesh.verts[b + 4]; + if (py - 0.25).abs() < 1e-6 && (mesh.verts[b + 3]).abs() < 1e-6 && (mesh.verts[b + 5]).abs() < 1e-6 { + assert!((ny - 1.0).abs() < 1e-5, "Deckel-Normale +Y"); + } + } + } + + #[test] + fn slab_mantel_normalen_zeigen_nach_aussen() { + // Zentriertes Quadrat um den Ursprung: jede Mantel-Normale muss vom + // Zentrum weg zeigen. + let slab = SlabInput { + outline: vec![[-2.0, -2.0], [2.0, -2.0], [2.0, 2.0], [-2.0, 2.0]], + z_bottom: 0.0, + z_top: 0.3, + color: [0.8, 0.8, 0.8], + }; + let mut mesh = Mesh::default(); + extrude_slab(&mut mesh, &slab); + let center = [0.0f32, 0.15, 0.0]; + let n = mesh.vertex_count(); + for i in 0..n { + let b = i * FLOATS_PER_VERTEX; + let p = [mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]]; + let nor = [mesh.verts[b + 3], mesh.verts[b + 4], mesh.verts[b + 5]]; + let out = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]; + let d = out[0] * nor[0] + out[1] * nor[1] + out[2] * nor[2]; + assert!(d >= -1e-4, "Vertex {i}: Normale zeigt nach innen (dot={d})"); + } + } + + #[test] + fn build_model_mesh_haengt_slabs_an_waende() { + let wall = build_walls_mesh(&[wall_x(4.0, 0.2, 2.6)]); + let model = build_model_mesh(&[wall_x(4.0, 0.2, 2.6)], &[square_slab(4.0, 2.6, 2.85)]); + // Modell = Wand-Vertices + Slab-Vertices. + assert_eq!(model.vertex_count(), wall.vertex_count() + 36); + assert!(model.indices.len() > wall.indices.len()); + } + + #[test] + fn entarteter_slab_erzeugt_nichts() { + let mut mesh = Mesh::default(); + // <3 Ecken. + extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] }); + assert_eq!(mesh.vertex_count(), 0); + // Nullhoehe. + extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6)); + assert_eq!(mesh.vertex_count(), 0); + } + // --- Kamera / Matrizen ---------------------------------------------------- #[test] diff --git a/src-tauri/render3d/src/mesh.rs b/src-tauri/render3d/src/mesh.rs index 5cfc93b..9a0779a 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -15,7 +15,7 @@ // XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx: // "Modell (x,y,z) -> Three (x, z, y) (Z = Hoehe nach oben)". -use crate::types::{Mesh, Point2, Rgb, WallInput, FLOATS_PER_VERTEX}; +use crate::types::{Mesh, Point2, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX}; /// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2 /// Dreiecken = 12 Dreiecke, mit flachen Normalen also 24 Vertices (je Seite 4, @@ -134,3 +134,217 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh { } mesh } + +/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten +/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt +/// vorne (deterministische Zaehlung fuer die Wand-Tests). +pub fn build_model_mesh(walls: &[WallInput], slabs: &[SlabInput]) -> Mesh { + let mut mesh = build_walls_mesh(walls); + for s in slabs { + extrude_slab(&mut mesh, s); + } + mesh +} + +// ── Deckenplatten (extrudierte Polygone) ───────────────────────────────────── + +/// Signierte Flaeche eines Grundriss-Polygons (Shoelace) in Modell-Koordinaten. +fn signed_area(pts: &[Point2]) -> f32 { + let mut a = 0.0f32; + let n = pts.len(); + if n < 3 { + return 0.0; + } + let mut j = n - 1; + for i in 0..n { + a += pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1]; + j = i; + } + a * 0.5 +} + +/// Kreuzprodukt (b-a) x (c-a) im Grundriss. +#[inline] +fn cross2(a: Point2, b: Point2, c: Point2) -> f32 { + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) +} + +/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert). +fn point_in_tri(a: Point2, b: Point2, c: Point2, p: Point2) -> bool { + let d1 = cross2(a, b, p); + let d2 = cross2(b, c, p); + let d3 = cross2(c, a, p); + let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(has_neg && has_pos) +} + +/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Polygons. Robust fuer +/// konvexe UND konkave Ringe. O(n^2) — fuer Decken-Umrisse (wenige Ecken) voellig +/// ausreichend. Liefert Dreiecks-Indizes (0-basiert auf `pts`); leer bei <3 Ecken +/// oder Degeneration. 1:1-Port von `render2d::tessellate::triangulate`. +pub fn triangulate(pts: &[Point2]) -> Vec { + let n = pts.len(); + if n < 3 { + return Vec::new(); + } + // Ohr-Test unten nutzt cross>0 = konvex (setzt CCW voraus). CW-Polygone drehen. + let mut idx: Vec = (0..n).collect(); + if signed_area(pts) < 0.0 { + idx.reverse(); + } + + let mut tris: Vec = Vec::new(); + let mut guard = 0usize; + let max_guard = n * n + 16; + + while idx.len() > 3 && guard < max_guard { + guard += 1; + let mut clipped = false; + let m = idx.len(); + for i in 0..m { + let i_prev = idx[(i + m - 1) % m]; + let i_cur = idx[i]; + let i_next = idx[(i + 1) % m]; + let a = pts[i_prev]; + let b = pts[i_cur]; + let c = pts[i_next]; + if cross2(a, b, c) <= 0.0 { + continue; // konkav/kollinear -> kein Ohr + } + let mut contains = false; + for &vi in &idx { + if vi == i_prev || vi == i_cur || vi == i_next { + continue; + } + if point_in_tri(a, b, c, pts[vi]) { + contains = true; + break; + } + } + if contains { + continue; + } + tris.push(i_prev as u32); + tris.push(i_cur as u32); + tris.push(i_next as u32); + idx.remove(i); + clipped = true; + break; + } + if !clipped { + break; + } + } + if idx.len() == 3 { + tris.push(idx[0] as u32); + tris.push(idx[1] as u32); + tris.push(idx[2] as u32); + } + tris +} + +/// Haengt ein Dreieck (drei world-Ecken) mit fester Flaechen-Normale + Farbe an. +/// Die Reihenfolge wird so gedreht, dass die geometrische Normale mit `want_normal` +/// gleich orientiert ist (CCW von aussen -> korrektes Backface-Culling). +fn push_tri_oriented( + mesh: &mut Mesh, + a: [f32; 3], + b: [f32; 3], + c: [f32; 3], + want_normal: [f32; 3], + color: Rgb, +) { + // Geometrische Normale (b-a) x (c-a). + let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let gn = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + let dot = gn[0] * want_normal[0] + gn[1] * want_normal[1] + gn[2] * want_normal[2]; + let (v0, v1, v2) = if dot < 0.0 { (a, c, b) } else { (a, b, c) }; + let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32; + for p in [v0, v1, v2] { + mesh.verts.extend_from_slice(&[ + p[0], p[1], p[2], want_normal[0], want_normal[1], want_normal[2], color[0], color[1], + color[2], + ]); + } + mesh.indices.extend_from_slice(&[base, base + 1, base + 2]); +} + +/// Extrudiert EINE Deckenplatte (Slab) und haengt sie an `mesh` an: Deckel (+Y), +/// Boden (−Y) — beide aus der Polygon-Triangulierung — plus die Mantelflaechen +/// (ein Quad je Umriss-Kante). Normalen werden robust nach aussen orientiert +/// (Deckel +Y, Boden −Y, Mantel weg vom Umriss-Schwerpunkt), sodass Culling und +/// Shading unabhaengig von der Umlaufrichtung des Umrisses stimmen. +pub fn extrude_slab(mesh: &mut Mesh, slab: &SlabInput) { + let pts = &slab.outline; + let n = pts.len(); + if n < 3 { + return; + } + let y0 = slab.z_bottom.min(slab.z_top); + let y1 = slab.z_bottom.max(slab.z_top); + if (y1 - y0) < 1e-6 { + return; + } + let tris = triangulate(pts); + if tris.is_empty() { + return; + } + let color = slab.color; + // world-Position: model [x, y] -> (x, hoehe, y). + let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] }; + + // Deckel (+Y) und Boden (−Y) aus den Triangulierungs-Dreiecken. + for t in tris.chunks_exact(3) { + let a = pts[t[0] as usize]; + let b = pts[t[1] as usize]; + let c = pts[t[2] as usize]; + push_tri_oriented(mesh, w(a, y1), w(b, y1), w(c, y1), [0.0, 1.0, 0.0], color); + push_tri_oriented(mesh, w(a, y0), w(b, y0), w(c, y0), [0.0, -1.0, 0.0], color); + } + + // Umriss-Schwerpunkt (Grundriss) fuer die Aussenrichtung der Mantel-Normalen. + let mut cx = 0.0f32; + let mut cy = 0.0f32; + for p in pts { + cx += p[0]; + cy += p[1]; + } + cx /= n as f32; + cy /= n as f32; + + // Mantelflaechen: je Kante ein vertikales Quad (unten y0, oben y1). + for i in 0..n { + let a = pts[i]; + let b = pts[(i + 1) % n]; + let ex = b[0] - a[0]; + let ez = b[1] - a[1]; + let elen = (ex * ex + ez * ez).sqrt(); + if elen < 1e-9 { + continue; // entartete Kante + } + // Horizontale Kanten-Normale (senkrecht zur Kante), nach aussen orientiert. + let mx = (a[0] + b[0]) * 0.5; + let mz = (a[1] + b[1]) * 0.5; + let out = [mx - cx, mz - cy]; + let mut nx = -ez / elen; + let mut nz = ex / elen; + if nx * out[0] + nz * out[1] < 0.0 { + nx = -nx; + nz = -nz; + } + let normal = [nx, 0.0, nz]; + let ba = w(a, y0); + let bb = w(b, y0); + let tb = w(b, y1); + let ta = w(a, y1); + // Als zwei orientierte Dreiecke (Winding via push_tri_oriented gesichert). + push_tri_oriented(mesh, ba, bb, tb, normal, color); + push_tri_oriented(mesh, ba, tb, ta, normal, color); + } +} diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index 0367cbf..bfaa912 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -2,24 +2,32 @@ // ohne aktives GPU-Feature (headless) im Repo pruefbar bleibt (naga-Test, Muster: // render2d/shaders). // -// Beleuchtung: EIN Directional-Light (Sonne) + ambienter Grundterm — das Pendant -// zur three.js-Sicht (AmbientLight 0.6 + DirectionalLight 1.1 bei (6,12,4), siehe -// Viewport3D.tsx). PBR (Rauheit/Metallik/Texturen) folgt in spaeteren Milestones. +// Beleuchtung: HEMISPHAERISCHES Umgebungslicht (Himmel oben / Boden unten) plus +// EIN Directional-Light (Sonne). Das hemisphaerische Ambient toent jede Flaeche je +// nach ihrer Normalen-Neigung: Deckflaechen bekommen das helle Himmelslicht, nach +// unten weisende Flaechen das dunklere Bodenlicht, senkrechte Waende einen Misch- +// wert. Dadurch liest sich das Volumen sofort ab (Architektur-Massenmodell) — ohne +// teures Postprocessing. Zusaetzlich betont ein Facing-Term (n gegen die Blick- +// naeherung „nach oben") die Kanten sanft ab. // // Uniform-Layout (group(0) binding(0)): -// view_proj : mat4x4 world -> Clip (proj * view) -// light_dir : vec4 Richtung ZUM Licht (world, xyz; w ungenutzt) -// ambient : vec4 ambienter Grundfaktor (rgb; a ungenutzt) +// view_proj : mat4x4 world -> Clip (proj * view) +// light_dir : vec4 Richtung ZUM Licht (world, xyz; w = Sonnen-Staerke) +// 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) // // Vertex-Attribute: position (world), normal (world), color (Albedo). -/// Der einzige Shader (Vertex + Fragment). Diffuse Lambert-Beleuchtung mit einem -/// Directional-Light plus ambientem Sockel — GPU-Aequivalent des three.js-Setups. +/// Der einzige Shader (Vertex + Fragment). Hemisphaerisches Ambient (Himmel/Boden) +/// + Lambert-Directional — GPU-Aequivalent eines Architektur-Massenmodell-Lichts. pub const MESH_WGSL: &str = r#" struct Globals { - view_proj : mat4x4, - light_dir : vec4, - ambient : vec4, + view_proj : mat4x4, + light_dir : vec4, + sky_color : vec4, + ground_color : vec4, + sun_color : vec4, }; @group(0) @binding(0) var globals : Globals; @@ -48,10 +56,22 @@ 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); - // Lambert-Diffusanteil; Rueckseiten (n.l < 0) tragen nichts bei. - let diffuse = max(dot(n, l), 0.0); - // Ambienter Sockel (Albedo * ambient) + gerichteter Anteil (Albedo * diffuse). - let shaded = in.color * globals.ambient.rgb + in.color * diffuse; + + // Hemisphaerisches Ambient: Mischfaktor aus der Vertikal-Komponente der + // Normalen (n.y = +1 -> voll Himmel, n.y = -1 -> voll Boden). + let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t); + + // Gerichteter Lambert-Anteil (Sonne); Rueckseiten (n.l < 0) tragen nichts bei. + let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb; + + var shaded = in.color * (ambient + diffuse); + + // 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; + return vec4(shaded, 1.0); } "#; diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index cbd682b..7a0f170 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -50,6 +50,32 @@ fn default_wall_color() -> Rgb { [0.82, 0.80, 0.76] } +/// Eine geflachte Deckenplatte (Slab): ein GESCHLOSSENER Grundriss-Umriss (Polygon +/// in Modell-Metern, Schlusspunkt NICHT dupliziert) plus die absolute vertikale +/// Ausdehnung `z_bottom..z_top`. Aus diesen Feldern wird das Polygon trianguliert +/// (Ear-Clipping) und ueber die Dicke zu einer Platte extrudiert. +/// +/// KOORDINATEN wie WallInput: model `[x, y]` -> world `(x, elevation, y)`, d. h. der +/// Umriss liegt in der XZ-Ebene, die Extrusion laeuft entlang +Y. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlabInput { + /// Geschlossener Umriss im Grundriss (Modell-Meter), CW oder CCW zulaessig. + pub outline: Vec, + /// Unterkante (absolut, Meter). + #[serde(rename = "zBottom")] + pub z_bottom: f32, + /// Oberkante (absolut, Meter). + #[serde(rename = "zTop")] + pub z_top: f32, + /// Albedo-Farbe (RGB 0..1). Default heller Deckenton. + #[serde(default = "default_slab_color")] + pub color: Rgb, +} + +fn default_slab_color() -> Rgb { + [0.86, 0.86, 0.88] +} + /// Projektionsart der Kamera. Die three.js-Sicht schaltet zwischen perspektivisch /// (freies Orbit) und orthografisch (die achsparallelen Presets front/top/side um). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/src/native.rs b/src-tauri/src/native.rs index 54f9083..d25173d 100644 --- a/src-tauri/src/native.rs +++ b/src-tauri/src/native.rs @@ -51,7 +51,7 @@ enum UserEvent { #[cfg(feature = "native2d")] Scene2d(Scene), #[cfg(feature = "native3d")] - Walls3d(Vec), + Model3d(Model3d), } /// Proxy in die laufende Event-Loop; wird beim Start (vor `run`) gesetzt. @@ -71,15 +71,30 @@ pub fn push_scene(value: serde_json::Value) { } } -/// Webview-Push der 3D-Waende (JSON = `Vec`). +/// Webview-Push des 3D-Modells. Akzeptiert BEIDE Payload-Formen: +/// • ein Objekt `{ walls: [...], slabs: [...] }` (neu, mit Deckenplatten), +/// • ein blankes Array `[WallInput, …]` (alt / JSON-Snapshot) → nur Waende. +/// So bleiben aeltere Pushes/Assets kompatibel. #[cfg(feature = "native3d")] pub fn push_walls(value: serde_json::Value) { let Some(proxy) = PROXY.get() else { return }; - match serde_json::from_value::>(value) { - Ok(walls) => { - let _ = proxy.send_event(UserEvent::Walls3d(walls)); + match parse_model3d(value) { + Ok(model) => { + let _ = proxy.send_event(UserEvent::Model3d(model)); } - Err(e) => eprintln!("push_native_walls: ungueltige Waende: {e}"), + Err(e) => eprintln!("push_native_walls: ungueltiges Modell: {e}"), + } +} + +/// Parst eine 3D-Modell-Payload tolerant (Objekt mit walls/slabs ODER blankes +/// Wand-Array). +#[cfg(feature = "native3d")] +fn parse_model3d(value: serde_json::Value) -> Result { + if value.is_array() { + let walls = serde_json::from_value::>(value)?; + Ok(Model3d { walls, slabs: Vec::new() }) + } else { + serde_json::from_value::(value) } } @@ -213,7 +228,20 @@ use render3d::gpu::Renderer as Renderer3d; #[cfg(feature = "native3d")] use render3d::math::orbit_eye; #[cfg(feature = "native3d")] -use render3d::types::{Camera, Projection, WallInput}; +use render3d::types::{Camera, Projection, SlabInput, WallInput}; +#[cfg(feature = "native3d")] +use serde::Deserialize; + +/// Das an das 3D-Fenster gepushte Modell: Waende (mit Oeffnungs-Teilquadern) plus +/// Deckenplatten. `#[serde(default)]` haelt aeltere Payloads ohne `slabs` gueltig. +#[cfg(feature = "native3d")] +#[derive(Debug, Clone, Default, Deserialize)] +struct Model3d { + #[serde(default)] + walls: Vec, + #[serde(default)] + slabs: Vec, +} #[cfg(feature = "native3d")] struct GpuState3d { @@ -227,11 +255,11 @@ struct GpuState3d { #[cfg(feature = "native3d")] impl GpuState3d { - fn new(window: Arc, walls: &[WallInput]) -> Self { + fn new(window: Arc, model: &Model3d) -> Self { let (surface, device, queue, config) = configure_surface(&window, "3d.device"); let mut renderer = Renderer3d::new(&device, config.format); - renderer.upload_walls(&device, walls); - renderer.set_light([6.0, 12.0, 4.0], 0.6); + renderer.upload_model(&device, &model.walls, &model.slabs); + renderer.set_light([6.0, 12.0, 4.0]); Self { surface, device, queue, config, renderer, window } } @@ -272,18 +300,20 @@ impl GpuState3d { const WALLS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/native3d_walls.json"); #[cfg(feature = "native3d")] -fn load_walls() -> Vec { +fn load_model() -> Model3d { match std::fs::read_to_string(WALLS_PATH) { - Ok(text) => match serde_json::from_str::>(&text) { - Ok(walls) => walls, + Ok(text) => match serde_json::from_str::(&text) + .and_then(parse_model3d) + { + Ok(model) => model, Err(e) => { - eprintln!("native3d: Waende-Parse-Fehler ({WALLS_PATH}): {e} — nutze Demo-Waende"); - demo_walls() + eprintln!("native3d: Modell-Parse-Fehler ({WALLS_PATH}): {e} — nutze Demo-Waende"); + Model3d { walls: demo_walls(), slabs: Vec::new() } } }, Err(e) => { - eprintln!("native3d: Waende nicht ladbar ({WALLS_PATH}): {e} — nutze Demo-Waende"); - demo_walls() + eprintln!("native3d: Modell nicht ladbar ({WALLS_PATH}): {e} — nutze Demo-Waende"); + Model3d { walls: demo_walls(), slabs: Vec::new() } } } } @@ -314,8 +344,8 @@ fn demo_walls() -> Vec { /// Blickziel (world) + sinnvoller Start-Abstand, sodass alle Waende ins Bild /// passen. world: `x=model.x`, `z=model.y`, `y=Hoehe` (render3d-Konvention). #[cfg(feature = "native3d")] -fn frame_walls(walls: &[WallInput]) -> ([f32; 3], f32) { - if walls.is_empty() { +fn frame_model(model: &Model3d) -> ([f32; 3], f32) { + if model.walls.is_empty() && model.slabs.is_empty() { return ([3.0, 1.3, 2.0], 11.0); } let mut min = [f32::INFINITY; 3]; @@ -328,7 +358,7 @@ fn frame_walls(walls: &[WallInput]) -> ([f32; 3], f32) { max[1] = max[1].max(y); max[2] = max[2].max(z); }; - for w in walls { + for w in &model.walls { let base = w.base_elevation; let top = w.base_elevation + w.height; for p in [w.start, w.end] { @@ -336,6 +366,12 @@ fn frame_walls(walls: &[WallInput]) -> ([f32; 3], f32) { acc(p[0], top, p[1]); } } + for s in &model.slabs { + for p in &s.outline { + acc(p[0], s.z_bottom, p[1]); + acc(p[0], s.z_top, p[1]); + } + } if !(min[0].is_finite() && max[0] >= min[0]) { return ([3.0, 1.3, 2.0], 11.0); } @@ -365,8 +401,8 @@ struct Orbit { #[cfg(feature = "native3d")] impl Orbit { - fn framed(walls: &[WallInput]) -> Self { - let (target, dist) = frame_walls(walls); + fn framed(model: &Model3d) -> Self { + let (target, dist) = frame_model(model); Self { yaw: std::f32::consts::FRAC_PI_4, pitch: 0.5, dist, target } } @@ -478,7 +514,7 @@ struct App { nav3d: bool, /// Live-Push vor Fenster-Erstellung (siehe `pending2d`). #[cfg(feature = "native3d")] - pending3d: Option>, + pending3d: Option, } impl App { @@ -638,9 +674,9 @@ impl ApplicationHandler for App { .with_inner_size(LogicalSize::new(1000.0, 760.0)); let window = Arc::new(event_loop.create_window(attrs).expect("3D-Fenster erstellen")); self.id3d = Some(window.id()); - let walls = self.pending3d.take().unwrap_or_else(load_walls); - self.orbit = Some(Orbit::framed(&walls)); - let state = GpuState3d::new(window, &walls); + let model = self.pending3d.take().unwrap_or_else(load_model); + self.orbit = Some(Orbit::framed(&model)); + let state = GpuState3d::new(window, &model); state.window.request_redraw(); self.s3d = Some(state); } @@ -665,14 +701,14 @@ impl ApplicationHandler for App { state.window.request_redraw(); } #[cfg(feature = "native3d")] - UserEvent::Walls3d(walls) => { + UserEvent::Model3d(model) => { let Some(state) = self.s3d.as_mut() else { - self.pending3d = Some(walls); + self.pending3d = Some(model); return; }; - state.renderer.upload_walls(&state.device, &walls); + state.renderer.upload_model(&state.device, &model.walls, &model.slabs); if !self.nav3d { - self.orbit = Some(Orbit::framed(&walls)); + self.orbit = Some(Orbit::framed(&model)); } state.window.request_redraw(); } diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index c96b6d1..6b37153 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -200,8 +200,25 @@ export const sampleProject: Project = { sillHeight: 0.9, }, ], - // Decken (Slabs) — anfangs leer; werden mit dem Decken-Werkzeug gefüllt. - ceilings: [], + // Decken (Slabs) — Geschossdecke über dem EG (= Boden des OG). Umriss = EG- + // Grundriss (5 × 4 m). Kategorie „30 Decken", 25 cm dick, OK bündig mit dem + // EG-Wandkopf (baseElevation + floorHeight = 2.6 m). + ceilings: [ + { + id: "C1", + type: "ceiling", + floorId: "eg", + categoryCode: "30", + outline: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 5, y: 4 }, + { x: 0, y: 4 }, + ], + wallTypeId: "aw", + thickness: 0.25, + }, + ], // Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG // (totalRise = Geschosshöhe). Kategorie „40 Treppen". stairs: [ diff --git a/src/plan/nativeSync.ts b/src/plan/nativeSync.ts index bd48ca8..43f28f3 100644 --- a/src/plan/nativeSync.ts +++ b/src/plan/nativeSync.ts @@ -12,7 +12,7 @@ import type { Plan } from "./generatePlan"; import type { Project } from "../model/types"; import { planToRenderScene } from "./toRenderScene"; -import { projectToWalls3d } from "./toWalls3d"; +import { projectToModel3d } from "./toWalls3d"; /** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */ const DEBOUNCE_MS = 200; @@ -58,6 +58,6 @@ export function pushNativeWalls(project: Project): void { if (!isTauri()) return; clearTimeout(wallsTimer); wallsTimer = setTimeout(() => { - void invokeNative("push_native_walls", { walls: projectToWalls3d(project) }); + void invokeNative("push_native_walls", { walls: projectToModel3d(project) }); }, DEBOUNCE_MS); } diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 290f5a5..5858742 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -18,7 +18,7 @@ // Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und // zieht ihn über die Deckendicke hoch. -import type { Project, Wall, Opening } from "../model/types"; +import type { Project, Wall } from "../model/types"; import { getWallType, wallTypeThickness, openingsOfWall } from "../model/types"; import { wallVerticalExtent, ceilingVerticalExtent } from "../model/wall"; import { openingInterval, openingVerticalExtent } from "../geometry/opening"; @@ -104,33 +104,45 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void { const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; - // Öffnungen der Wand als [from..to]-Intervalle (auf die Achse geklemmt), - // nach Startlage sortiert. - const openings = openingsOfWall(project, wall.id); - const items: Array<{ from: number; to: number; op: Opening }> = []; - for (const op of openings) { + // Aussparungen der Wand als vereinheitlichte Cutouts sammeln: + // • Öffnungen (project.openings, Fenster mit Brüstung/Sturz), + // • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz). + // Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung. + const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = []; + for (const op of openingsOfWall(project, wall.id)) { const iv = openingInterval(wall, op); - if (iv) items.push({ from: iv.from, to: iv.to, op }); + if (!iv) continue; + const v = openingVerticalExtent(project, wall, op); + cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop }); } - items.sort((a, b) => a.from - b.from); + for (const d of project.doors ?? []) { + if (d.hostWallId !== wall.id) continue; + const from = Math.max(0, Math.min(d.position, axisLen)); + const to = Math.max(from, Math.min(d.position + d.width, axisLen)); + if (to - from < EPS) continue; + // Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe. + const oTop = Math.min(zTop, zBottom + d.height); + cutouts.push({ from, to, oBottom: zBottom, oTop }); + } + cutouts.sort((a, b) => a.from - b.from); - // Ohne Öffnungen: ein durchgehender Quader (wie bisher). - if (items.length === 0) { + // Ohne Aussparungen: ein durchgehender Quader (wie bisher). + if (cutouts.length === 0) { pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness); return; } - // Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Öffnungen, - // Brüstung/Sturz im Öffnungsbereich. + // Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen, + // Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor` + // zusammengefasst. let cursor = 0; - for (const { from, to, op } of items) { + for (const { from, to, oBottom, oTop } of cutouts) { const segFrom = Math.max(cursor, from); if (from > cursor) { - // Voller Wandpfeiler bis zur Öffnung. + // Voller Wandpfeiler bis zur Aussparung. pushSegment(out, wall, cursor, from, zBottom, zTop, thickness); } if (to > segFrom) { - const { zBottom: oBottom, zTop: oTop } = openingVerticalExtent(project, wall, op); // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom). if (oBottom > zBottom + EPS) { pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness);