3D: Geschossdecken als extrudierte Polygone + hemisphärisches Licht

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.
This commit is contained in:
2026-07-02 21:08:34 +02:00
parent 0189eed5ac
commit 31ac91b2a7
9 changed files with 532 additions and 82 deletions
+37 -15
View File
@@ -13,9 +13,9 @@ use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::math::{view_projection, Mat4}; 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::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). /// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar).
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
@@ -27,18 +27,23 @@ pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
struct Globals { struct Globals {
view_proj: [f32; 16], view_proj: [f32; 16],
light_dir: [f32; 4], light_dir: [f32; 4],
ambient: [f32; 4], sky_color: [f32; 4],
ground_color: [f32; 4],
sun_color: [f32; 4],
} }
impl Default for Globals { impl Default for Globals {
fn default() -> Self { fn default() -> Self {
Self { Self {
view_proj: crate::math::identity(), view_proj: crate::math::identity(),
// Richtung ZUM Licht (world), normiert. Entspricht der three.js-Sonne // Richtung ZUM Licht (world), normiert. Sonne oben-vorne (6,12,4).
// bei (6,12,4): das Licht kommt aus dieser Richtung.
light_dir: normalize4([6.0, 12.0, 4.0]), light_dir: normalize4([6.0, 12.0, 4.0]),
// Ambienter Sockel 0.6 (wie three.js-AmbientLight(0.6)). // Himmels-Ambient (von oben): helles, leicht kuehles Licht.
ambient: [0.6, 0.6, 0.6, 1.0], 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, mesh: None,
depth: None, depth: None,
globals: Globals::default(), globals: Globals::default(),
// #e9e9e9 heller Hintergrund (wie die three.js-Modellsicht). // #f5f5f5 heller Hintergrund (wie der 2D-Grundriss).
clear_color: wgpu::Color { clear_color: wgpu::Color {
r: 0.914, r: 0.961,
g: 0.914, g: 0.961,
b: 0.914, b: 0.961,
a: 1.0, a: 1.0,
}, },
} }
@@ -186,7 +191,16 @@ impl Renderer {
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch. /// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) { 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() { if mesh.indices.is_empty() {
self.mesh = None; self.mesh = None;
return; return;
@@ -208,10 +222,18 @@ impl Renderer {
}); });
} }
/// Setzt die Lichtrichtung (Richtung ZUM Licht, world) und den ambienten Sockel. /// Setzt die Richtung ZUM Directional-Light (Sonne, world). Das hemisphaerische
pub fn set_light(&mut self, dir_to_light: [f32; 3], ambient: f32) { /// 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.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. /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
+105 -2
View File
@@ -26,9 +26,9 @@ pub use math::{
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix, look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
view_matrix, view_projection, Mat4, 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::{ 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) ------------------- // --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
@@ -183,6 +183,109 @@ mod tests {
assert!(mesh.indices.is_empty()); 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 ---------------------------------------------------- // --- Kamera / Matrizen ----------------------------------------------------
#[test] #[test]
+215 -1
View File
@@ -15,7 +15,7 @@
// XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx: // XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx:
// "Modell (x,y,z) -> Three (x, z, y) (Z = Hoehe nach oben)". // "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 /// 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, /// 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 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<u32> {
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<usize> = (0..n).collect();
if signed_area(pts) < 0.0 {
idx.reverse();
}
let mut tris: Vec<u32> = 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);
}
}
+32 -12
View File
@@ -2,24 +2,32 @@
// ohne aktives GPU-Feature (headless) im Repo pruefbar bleibt (naga-Test, Muster: // ohne aktives GPU-Feature (headless) im Repo pruefbar bleibt (naga-Test, Muster:
// render2d/shaders). // render2d/shaders).
// //
// Beleuchtung: EIN Directional-Light (Sonne) + ambienter Grundterm — das Pendant // Beleuchtung: HEMISPHAERISCHES Umgebungslicht (Himmel oben / Boden unten) plus
// zur three.js-Sicht (AmbientLight 0.6 + DirectionalLight 1.1 bei (6,12,4), siehe // EIN Directional-Light (Sonne). Das hemisphaerische Ambient toent jede Flaeche je
// Viewport3D.tsx). PBR (Rauheit/Metallik/Texturen) folgt in spaeteren Milestones. // 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)): // Uniform-Layout (group(0) binding(0)):
// view_proj : mat4x4<f32> world -> Clip (proj * view) // view_proj : mat4x4<f32> world -> Clip (proj * view)
// light_dir : vec4<f32> Richtung ZUM Licht (world, xyz; w ungenutzt) // light_dir : vec4<f32> Richtung ZUM Licht (world, xyz; w = Sonnen-Staerke)
// ambient : vec4<f32> ambienter Grundfaktor (rgb; a ungenutzt) // sky_color : vec4<f32> Himmels-Ambient (rgb; von oben)
// ground_color : vec4<f32> Boden-Ambient (rgb; von unten)
// sun_color : vec4<f32> Farbe/Staerke des Directional-Lights (rgb)
// //
// Vertex-Attribute: position (world), normal (world), color (Albedo). // Vertex-Attribute: position (world), normal (world), color (Albedo).
/// Der einzige Shader (Vertex + Fragment). Diffuse Lambert-Beleuchtung mit einem /// Der einzige Shader (Vertex + Fragment). Hemisphaerisches Ambient (Himmel/Boden)
/// Directional-Light plus ambientem Sockel — GPU-Aequivalent des three.js-Setups. /// + Lambert-Directional — GPU-Aequivalent eines Architektur-Massenmodell-Lichts.
pub const MESH_WGSL: &str = r#" pub const MESH_WGSL: &str = r#"
struct Globals { struct Globals {
view_proj : mat4x4<f32>, view_proj : mat4x4<f32>,
light_dir : vec4<f32>, light_dir : vec4<f32>,
ambient : vec4<f32>, sky_color : vec4<f32>,
ground_color : vec4<f32>,
sun_color : vec4<f32>,
}; };
@group(0) @binding(0) var<uniform> globals : Globals; @group(0) @binding(0) var<uniform> globals : Globals;
@@ -48,10 +56,22 @@ fn vs_main(in : VsIn) -> VsOut {
fn fs_main(in : VsOut) -> @location(0) vec4<f32> { fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
let n = normalize(in.world_normal); let n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz); let l = normalize(globals.light_dir.xyz);
// Lambert-Diffusanteil; Rueckseiten (n.l < 0) tragen nichts bei.
let diffuse = max(dot(n, l), 0.0); // Hemisphaerisches Ambient: Mischfaktor aus der Vertikal-Komponente der
// Ambienter Sockel (Albedo * ambient) + gerichteter Anteil (Albedo * diffuse). // Normalen (n.y = +1 -> voll Himmel, n.y = -1 -> voll Boden).
let shaded = in.color * globals.ambient.rgb + in.color * diffuse; 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<f32>(shaded, 1.0); return vec4<f32>(shaded, 1.0);
} }
"#; "#;
+26
View File
@@ -50,6 +50,32 @@ fn default_wall_color() -> Rgb {
[0.82, 0.80, 0.76] [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<Point2>,
/// 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 /// Projektionsart der Kamera. Die three.js-Sicht schaltet zwischen perspektivisch
/// (freies Orbit) und orthografisch (die achsparallelen Presets front/top/side um). /// (freies Orbit) und orthografisch (die achsparallelen Presets front/top/side um).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+66 -30
View File
@@ -51,7 +51,7 @@ enum UserEvent {
#[cfg(feature = "native2d")] #[cfg(feature = "native2d")]
Scene2d(Scene), Scene2d(Scene),
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
Walls3d(Vec<WallInput>), Model3d(Model3d),
} }
/// Proxy in die laufende Event-Loop; wird beim Start (vor `run`) gesetzt. /// 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<render3d::types::WallInput>`). /// 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")] #[cfg(feature = "native3d")]
pub fn push_walls(value: serde_json::Value) { pub fn push_walls(value: serde_json::Value) {
let Some(proxy) = PROXY.get() else { return }; let Some(proxy) = PROXY.get() else { return };
match serde_json::from_value::<Vec<WallInput>>(value) { match parse_model3d(value) {
Ok(walls) => { Ok(model) => {
let _ = proxy.send_event(UserEvent::Walls3d(walls)); 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<Model3d, serde_json::Error> {
if value.is_array() {
let walls = serde_json::from_value::<Vec<WallInput>>(value)?;
Ok(Model3d { walls, slabs: Vec::new() })
} else {
serde_json::from_value::<Model3d>(value)
} }
} }
@@ -213,7 +228,20 @@ use render3d::gpu::Renderer as Renderer3d;
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
use render3d::math::orbit_eye; use render3d::math::orbit_eye;
#[cfg(feature = "native3d")] #[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<WallInput>,
#[serde(default)]
slabs: Vec<SlabInput>,
}
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
struct GpuState3d { struct GpuState3d {
@@ -227,11 +255,11 @@ struct GpuState3d {
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
impl GpuState3d { impl GpuState3d {
fn new(window: Arc<Window>, walls: &[WallInput]) -> Self { fn new(window: Arc<Window>, model: &Model3d) -> Self {
let (surface, device, queue, config) = configure_surface(&window, "3d.device"); let (surface, device, queue, config) = configure_surface(&window, "3d.device");
let mut renderer = Renderer3d::new(&device, config.format); let mut renderer = Renderer3d::new(&device, config.format);
renderer.upload_walls(&device, walls); renderer.upload_model(&device, &model.walls, &model.slabs);
renderer.set_light([6.0, 12.0, 4.0], 0.6); renderer.set_light([6.0, 12.0, 4.0]);
Self { surface, device, queue, config, renderer, window } 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"); const WALLS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/native3d_walls.json");
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
fn load_walls() -> Vec<WallInput> { fn load_model() -> Model3d {
match std::fs::read_to_string(WALLS_PATH) { match std::fs::read_to_string(WALLS_PATH) {
Ok(text) => match serde_json::from_str::<Vec<WallInput>>(&text) { Ok(text) => match serde_json::from_str::<serde_json::Value>(&text)
Ok(walls) => walls, .and_then(parse_model3d)
{
Ok(model) => model,
Err(e) => { Err(e) => {
eprintln!("native3d: Waende-Parse-Fehler ({WALLS_PATH}): {e} — nutze Demo-Waende"); eprintln!("native3d: Modell-Parse-Fehler ({WALLS_PATH}): {e} — nutze Demo-Waende");
demo_walls() Model3d { walls: demo_walls(), slabs: Vec::new() }
} }
}, },
Err(e) => { Err(e) => {
eprintln!("native3d: Waende nicht ladbar ({WALLS_PATH}): {e} — nutze Demo-Waende"); eprintln!("native3d: Modell nicht ladbar ({WALLS_PATH}): {e} — nutze Demo-Waende");
demo_walls() Model3d { walls: demo_walls(), slabs: Vec::new() }
} }
} }
} }
@@ -314,8 +344,8 @@ fn demo_walls() -> Vec<WallInput> {
/// Blickziel (world) + sinnvoller Start-Abstand, sodass alle Waende ins Bild /// Blickziel (world) + sinnvoller Start-Abstand, sodass alle Waende ins Bild
/// passen. world: `x=model.x`, `z=model.y`, `y=Hoehe` (render3d-Konvention). /// passen. world: `x=model.x`, `z=model.y`, `y=Hoehe` (render3d-Konvention).
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
fn frame_walls(walls: &[WallInput]) -> ([f32; 3], f32) { fn frame_model(model: &Model3d) -> ([f32; 3], f32) {
if walls.is_empty() { if model.walls.is_empty() && model.slabs.is_empty() {
return ([3.0, 1.3, 2.0], 11.0); return ([3.0, 1.3, 2.0], 11.0);
} }
let mut min = [f32::INFINITY; 3]; 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[1] = max[1].max(y);
max[2] = max[2].max(z); max[2] = max[2].max(z);
}; };
for w in walls { for w in &model.walls {
let base = w.base_elevation; let base = w.base_elevation;
let top = w.base_elevation + w.height; let top = w.base_elevation + w.height;
for p in [w.start, w.end] { 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]); 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]) { if !(min[0].is_finite() && max[0] >= min[0]) {
return ([3.0, 1.3, 2.0], 11.0); return ([3.0, 1.3, 2.0], 11.0);
} }
@@ -365,8 +401,8 @@ struct Orbit {
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
impl Orbit { impl Orbit {
fn framed(walls: &[WallInput]) -> Self { fn framed(model: &Model3d) -> Self {
let (target, dist) = frame_walls(walls); let (target, dist) = frame_model(model);
Self { yaw: std::f32::consts::FRAC_PI_4, pitch: 0.5, dist, target } Self { yaw: std::f32::consts::FRAC_PI_4, pitch: 0.5, dist, target }
} }
@@ -478,7 +514,7 @@ struct App {
nav3d: bool, nav3d: bool,
/// Live-Push vor Fenster-Erstellung (siehe `pending2d`). /// Live-Push vor Fenster-Erstellung (siehe `pending2d`).
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
pending3d: Option<Vec<WallInput>>, pending3d: Option<Model3d>,
} }
impl App { impl App {
@@ -638,9 +674,9 @@ impl ApplicationHandler<UserEvent> for App {
.with_inner_size(LogicalSize::new(1000.0, 760.0)); .with_inner_size(LogicalSize::new(1000.0, 760.0));
let window = Arc::new(event_loop.create_window(attrs).expect("3D-Fenster erstellen")); let window = Arc::new(event_loop.create_window(attrs).expect("3D-Fenster erstellen"));
self.id3d = Some(window.id()); self.id3d = Some(window.id());
let walls = self.pending3d.take().unwrap_or_else(load_walls); let model = self.pending3d.take().unwrap_or_else(load_model);
self.orbit = Some(Orbit::framed(&walls)); self.orbit = Some(Orbit::framed(&model));
let state = GpuState3d::new(window, &walls); let state = GpuState3d::new(window, &model);
state.window.request_redraw(); state.window.request_redraw();
self.s3d = Some(state); self.s3d = Some(state);
} }
@@ -665,14 +701,14 @@ impl ApplicationHandler<UserEvent> for App {
state.window.request_redraw(); state.window.request_redraw();
} }
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
UserEvent::Walls3d(walls) => { UserEvent::Model3d(model) => {
let Some(state) = self.s3d.as_mut() else { let Some(state) = self.s3d.as_mut() else {
self.pending3d = Some(walls); self.pending3d = Some(model);
return; return;
}; };
state.renderer.upload_walls(&state.device, &walls); state.renderer.upload_model(&state.device, &model.walls, &model.slabs);
if !self.nav3d { if !self.nav3d {
self.orbit = Some(Orbit::framed(&walls)); self.orbit = Some(Orbit::framed(&model));
} }
state.window.request_redraw(); state.window.request_redraw();
} }
+19 -2
View File
@@ -200,8 +200,25 @@ export const sampleProject: Project = {
sillHeight: 0.9, sillHeight: 0.9,
}, },
], ],
// Decken (Slabs) — anfangs leer; werden mit dem Decken-Werkzeug gefüllt. // Decken (Slabs) — Geschossdecke über dem EG (= Boden des OG). Umriss = EG-
ceilings: [], // 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 // Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
// (totalRise = Geschosshöhe). Kategorie „40 Treppen". // (totalRise = Geschosshöhe). Kategorie „40 Treppen".
stairs: [ stairs: [
+2 -2
View File
@@ -12,7 +12,7 @@
import type { Plan } from "./generatePlan"; import type { Plan } from "./generatePlan";
import type { Project } from "../model/types"; import type { Project } from "../model/types";
import { planToRenderScene } from "./toRenderScene"; import { planToRenderScene } from "./toRenderScene";
import { projectToWalls3d } from "./toWalls3d"; import { projectToModel3d } from "./toWalls3d";
/** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */ /** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */
const DEBOUNCE_MS = 200; const DEBOUNCE_MS = 200;
@@ -58,6 +58,6 @@ export function pushNativeWalls(project: Project): void {
if (!isTauri()) return; if (!isTauri()) return;
clearTimeout(wallsTimer); clearTimeout(wallsTimer);
wallsTimer = setTimeout(() => { wallsTimer = setTimeout(() => {
void invokeNative("push_native_walls", { walls: projectToWalls3d(project) }); void invokeNative("push_native_walls", { walls: projectToModel3d(project) });
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
} }
+27 -15
View File
@@ -18,7 +18,7 @@
// Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und // Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und
// zieht ihn über die Deckendicke hoch. // 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 { getWallType, wallTypeThickness, openingsOfWall } from "../model/types";
import { wallVerticalExtent, ceilingVerticalExtent } from "../model/wall"; import { wallVerticalExtent, ceilingVerticalExtent } from "../model/wall";
import { openingInterval, openingVerticalExtent } from "../geometry/opening"; 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); const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
// Öffnungen der Wand als [from..to]-Intervalle (auf die Achse geklemmt), // Aussparungen der Wand als vereinheitlichte Cutouts sammeln:
// nach Startlage sortiert. // • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
const openings = openingsOfWall(project, wall.id); // • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
const items: Array<{ from: number; to: number; op: Opening }> = []; // Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
for (const op of openings) { const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = [];
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op); 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). // Ohne Aussparungen: ein durchgehender Quader (wie bisher).
if (items.length === 0) { if (cutouts.length === 0) {
pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness); pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness);
return; return;
} }
// Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Öffnungen, // Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen,
// Brüstung/Sturz im Öffnungsbereich. // Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor`
// zusammengefasst.
let cursor = 0; let cursor = 0;
for (const { from, to, op } of items) { for (const { from, to, oBottom, oTop } of cutouts) {
const segFrom = Math.max(cursor, from); const segFrom = Math.max(cursor, from);
if (from > cursor) { if (from > cursor) {
// Voller Wandpfeiler bis zur Öffnung. // Voller Wandpfeiler bis zur Aussparung.
pushSegment(out, wall, cursor, from, zBottom, zTop, thickness); pushSegment(out, wall, cursor, from, zBottom, zTop, thickness);
} }
if (to > segFrom) { 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). // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
if (oBottom > zBottom + EPS) { if (oBottom > zBottom + EPS) {
pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness); pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness);