From 1f6da09b2b1c0e9453b3a11291f69a25bcf95004 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 21:45:01 +0200 Subject: [PATCH] render3d: Wandverschneidung an Ecken + sichtbare Schichten mesh.rs berechnet die Ecken-Verschneidung jetzt selbst (portiert aus dem Web-Kern computeJoins/clippedBand): Wandenden werden per Rundungs-Key gruppiert, ueber Hoehen-Ueberlappung geclustert (gestapelte Geschosse mit gleichem Grundriss verschneiden sich NICHT), und Zweier-Cluster ueber die gemeinsame Gehrungslinie geschnitten. T-/X-Stoesse und freie Enden bleiben stumpf, wie im Web-Kern. Endkappen-Normalen geometrisch aus dem Kreuzprodukt. WallInput bekommt optional layers (WallLayer{thickness,color}, serde-default, rueckwaertskompatibel): jede Schicht wird als eigenes Band ueber dieselbe gehrte Grundflaeche extrudiert und materialgefaerbt. toWalls3d.ts loest die WallType-Schichten je Wand auf (hexToRgb) und reicht sie durch. 39/39 Rust-Tests (7 neue: Gehrung, T-Stoss, freies Ende, Schichten, Kombi). --- src-tauri/render3d/examples/section_svg.rs | 3 + src-tauri/render3d/src/bin/spike3d.rs | 1 + src-tauri/render3d/src/lib.rs | 228 +++++++++++- src-tauri/render3d/src/mesh.rs | 409 ++++++++++++++++++--- src-tauri/render3d/src/section.rs | 23 +- src-tauri/render3d/src/types.rs | 36 +- src/plan/toWalls3d.ts | 54 ++- 7 files changed, 681 insertions(+), 73 deletions(-) diff --git a/src-tauri/render3d/examples/section_svg.rs b/src-tauri/render3d/examples/section_svg.rs index de621d8..8c29636 100644 --- a/src-tauri/render3d/examples/section_svg.rs +++ b/src-tauri/render3d/examples/section_svg.rs @@ -31,6 +31,7 @@ fn l_wall_scene() -> (Vec, Vec) { sill: 0.8, height: 1.0, }], + layers: None, }, WallInput { start: [0.0, 3.0], @@ -40,6 +41,7 @@ fn l_wall_scene() -> (Vec, Vec) { base_elevation: 0.0, color, openings: vec![], + layers: None, }, // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, // also groessere Tiefe von der Betrachter-Ebene aus), genau im @@ -54,6 +56,7 @@ fn l_wall_scene() -> (Vec, Vec) { base_elevation: 0.0, color, openings: vec![], + layers: None, }, ]; let slabs = vec![SlabInput { diff --git a/src-tauri/render3d/src/bin/spike3d.rs b/src-tauri/render3d/src/bin/spike3d.rs index 9a3441e..53f88d2 100644 --- a/src-tauri/render3d/src/bin/spike3d.rs +++ b/src-tauri/render3d/src/bin/spike3d.rs @@ -37,6 +37,7 @@ fn demo_walls() -> Vec { base_elevation: 0.0, color: grey, openings: vec![], + layers: None, }; // Raum 6 x 4 m. vec![ diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 56a57d9..4ad71b1 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -47,7 +47,7 @@ pub use types::{ #[cfg(test)] mod tests { use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX}; - use super::types::{Mesh, Opening, WallInput, FLOATS_PER_VERTEX}; + use super::types::{Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX}; /// Bequemer Bau einer achsparallelen Wand entlang +X. fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput { @@ -59,6 +59,7 @@ mod tests { base_elevation: 0.0, color: [0.8, 0.8, 0.8], openings: vec![], + layers: None, } } @@ -95,6 +96,7 @@ mod tests { base_elevation: 0.0, color: [0.8, 0.8, 0.8], openings: vec![], + layers: None, }, ]); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); @@ -172,6 +174,7 @@ mod tests { base_elevation: 0.0, color: [0.8, 0.8, 0.8], openings: vec![], + layers: None, }; let mut mesh = Mesh::default(); extrude_wall(&mut mesh, &w); @@ -192,6 +195,7 @@ mod tests { base_elevation: 0.0, color: [0.8, 0.8, 0.8], openings: vec![], + layers: None, }; let mesh = build_walls_mesh(&[w]); assert_eq!(mesh.vertex_count(), 0); @@ -295,6 +299,228 @@ mod tests { assert_eq!(mesh.indices.len(), INDICES_PER_BOX); } + // --- Ecken-Verschneidung (Gehrung) ----------------------------------------- + + #[test] + fn rechtwinklige_ecke_wird_gehrt_kein_ueberstand_kein_spalt() { + // Zwei Waende entlang +X bzw. +Y, die sich exakt im Ursprung treffen + // (L-Ecke, Dicke 0.4 -> Halbdicke 0.2). Ohne Gehrung ueberlappen sich die + // Baender im Eckbereich (jede Wand ragt um die Halbdicke der jeweils + // anderen ueber den Knoten hinaus); MIT Gehrung muessen alle Vertices IM + // Eckbereich exakt auf der 45-Grad-Gehrungslinie |x| == |z| liegen — + // weder darueber hinaus (Ueberstand) noch darunter (Spalt). + let a = WallInput { + start: [0.0, 0.0], + end: [3.0, 0.0], + thickness: 0.4, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + }; + let b = WallInput { + start: [0.0, 0.0], + end: [0.0, 3.0], + thickness: 0.4, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + }; + let mesh = build_walls_mesh(&[a, b]); + assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader"); + + // Jeder Vertex nahe dem Knoten (innerhalb der doppelten Halbdicke) muss + // auf der Gehrungslinie liegen: world.x und world.z (== Grundriss-y) + // betragsgleich (45-Grad-Winkelhalbierende der beiden Achsen). + let near_corner_margin = 0.5; // deutlich groesser als die Halbdicke 0.2 + let n = mesh.vertex_count(); + let mut checked_any = false; + for i in 0..n { + let (p, _) = vert(&mesh, i); + if p[0].abs() < near_corner_margin && p[2].abs() < near_corner_margin { + checked_any = true; + assert!( + (p[0].abs() - p[2].abs()).abs() < 1e-4, + "Eck-Vertex {i} bei {p:?} liegt nicht auf der 45-Grad-Gehrungslinie" + ); + } + } + assert!(checked_any, "Test hat keinen Eck-Vertex gefunden (Setup falsch)"); + + // Kein Vertex ragt ueber die Gehrungslinie hinaus: fuer Punkte mit x>0 + // UND z>0 (im "inneren" Quadranten der Ecke) muss min(x,z) <= Halbdicke + // gelten -- sonst wuerde die Wand ueber die andere hinausragen. + for i in 0..n { + let (p, _) = vert(&mesh, i); + if p[0] > 1e-4 && p[2] > 1e-4 && p[0] < near_corner_margin && p[2] < near_corner_margin { + assert!( + p[0].min(p[2]) <= 0.2 + 1e-4, + "Vertex {i} bei {p:?} ragt ueber die Gehrung hinaus" + ); + } + } + } + + #[test] + fn freies_wandende_bleibt_rechtwinklig() { + // Eine einzelne Wand ohne Nachbarn: keine Gehrung, Enden bleiben exakt + // senkrecht zur Achse -> identisch zum Einzel-Wand-Regressionsfall. + let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]); + let (min, max) = mesh.bounds(); + assert!((min[0]).abs() < 1e-5 && (max[0] - 3.0).abs() < 1e-5); + } + + #[test] + fn t_stoss_bleibt_rechtwinklig() { + // Drei Wandenden treffen sich im selben Knoten (T-Stoss) -> gemaess + // Moduldoc/`joins.ts` bewusst NICHT gehrt (mehrdeutig), alle drei bleiben + // rechtwinklig. Regressions-Absicherung: keine Panik, stabile Zaehlung. + let mk = |s: [f32; 2], e: [f32; 2]| WallInput { + start: s, + end: e, + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + }; + let walls = vec![ + mk([0.0, 0.0], [3.0, 0.0]), + mk([0.0, 0.0], [0.0, 3.0]), + mk([0.0, 0.0], [-3.0, 0.0]), + ]; + let mesh = build_walls_mesh(&walls); + assert_eq!(mesh.vertex_count(), 3 * VERTS_PER_BOX); + } + + #[test] + fn zwei_geschosse_mit_gleichem_grundriss_verschneiden_sich_nicht_ueber_geschosse() { + // EG (Hoehe 0..2.5) und OG (Basis 2.5, Hoehe 2.5..5.0) mit identischem + // L-Grundriss: die Hoehenbereiche BERUEHREN sich nur (bei 2.5), ueber- + // lappen aber nicht -> jedes Geschoss wird NUR gegen sein eigenes + // Pendant gehrt, nicht ueber die Geschossgrenze hinweg. Absicherung: + // beide Geschosse zusammen liefern exakt dieselbe Gehrung wie ein + // einzelnes Geschoss fuer sich (Vertex-Zaehlung + Eck-Vertices je + // Geschoss weiterhin auf der 45-Grad-Linie relativ zu SEINER Basis). + let mk = |s: [f32; 2], e: [f32; 2], base: f32| WallInput { + start: s, + end: e, + thickness: 0.4, + height: 2.5, + base_elevation: base, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + }; + let walls = vec![ + mk([0.0, 0.0], [3.0, 0.0], 0.0), + mk([0.0, 0.0], [0.0, 3.0], 0.0), + mk([0.0, 0.0], [3.0, 0.0], 2.5), + mk([0.0, 0.0], [0.0, 3.0], 2.5), + ]; + let mesh = build_walls_mesh(&walls); + assert_eq!(mesh.vertex_count(), 4 * VERTS_PER_BOX); + let n = mesh.vertex_count(); + for i in 0..n { + let (p, _) = vert(&mesh, i); + if p[0].abs() < 0.5 && p[2].abs() < 0.5 { + assert!( + (p[0].abs() - p[2].abs()).abs() < 1e-4, + "Eck-Vertex {i} bei {p:?} (Geschoss-Basis {}) nicht auf der Gehrung", + p[1] + ); + } + } + } + + // --- Schichten (WallInput::layers) ----------------------------------------- + + #[test] + fn ohne_layers_entsteht_weiterhin_ein_vollkoerper() { + // Regressionsfall: `layers: None` extrudiert exakt wie zuvor. + let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]); + assert_eq!(mesh.vertex_count(), VERTS_PER_BOX); + } + + #[test] + fn layer_wand_liefert_ein_prisma_je_schicht() { + // Drei Schichten (Putz/Daemmung/Mauerwerk) -> drei eigenstaendige + // Quader, je VERTS_PER_BOX Vertices, ohne Oeffnungen. + let mut w = wall_x(3.0, 0.3, 2.5); + w.layers = Some(vec![ + WallLayer { thickness: 0.02, color: [1.0, 1.0, 1.0] }, + WallLayer { thickness: 0.18, color: [0.9, 0.85, 0.7] }, + WallLayer { thickness: 0.10, color: [0.6, 0.3, 0.2] }, + ]); + let mesh = build_walls_mesh(&[w]); + assert_eq!(mesh.vertex_count(), 3 * VERTS_PER_BOX); + assert_eq!(mesh.indices.len(), 3 * INDICES_PER_BOX); + + // Die Schichten decken zusammen exakt die Gesamtdicke (0.3, Z in + // -0.15..0.15) lueckenlos und ueberlappungsfrei ab. + let (min, max) = mesh.bounds(); + assert!((min[2] + 0.15).abs() < 1e-4, "min z == -0.15"); + assert!((max[2] - 0.15).abs() < 1e-4, "max z == 0.15"); + + // Jede Schicht-Farbe taucht in den Vertex-Farben auf (Layer wurden + // tatsaechlich mit ihrer eigenen Farbe extrudiert, nicht der Wandfarbe). + let n = mesh.vertex_count(); + let has_color = |target: [f32; 3]| { + (0..n).any(|i| { + let b = i * FLOATS_PER_VERTEX; + let c = [mesh.verts[b + 6], mesh.verts[b + 7], mesh.verts[b + 8]]; + (c[0] - target[0]).abs() < 1e-6 + && (c[1] - target[1]).abs() < 1e-6 + && (c[2] - target[2]).abs() < 1e-6 + }) + }; + assert!(has_color([1.0, 1.0, 1.0]), "Putz-Farbe fehlt"); + assert!(has_color([0.9, 0.85, 0.7]), "Daemmungs-Farbe fehlt"); + assert!(has_color([0.6, 0.3, 0.2]), "Mauerwerk-Farbe fehlt"); + } + + #[test] + fn layer_ecke_wird_ebenfalls_gehrt() { + // Zwei mehrschichtige Waende an einer Ecke: JEDE Schicht nutzt dasselbe + // gehrte Fussabdruck-Band -> auch hier landen alle Eck-Vertices exakt + // auf der 45-Grad-Gehrungslinie (analog zum Vollkoerper-Test oben). + let layers = || { + Some(vec![ + WallLayer { thickness: 0.15, color: [0.9, 0.85, 0.7] }, + WallLayer { thickness: 0.15, color: [0.6, 0.3, 0.2] }, + ]) + }; + let a = WallInput { + start: [0.0, 0.0], + end: [3.0, 0.0], + thickness: 0.3, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: layers(), + }; + let mut b = a.clone(); + b.end = [0.0, 3.0]; + + let mesh = build_walls_mesh(&[a, b]); + let n = mesh.vertex_count(); + assert_eq!(n, 4 * VERTS_PER_BOX, "2 Waende x 2 Schichten"); + for i in 0..n { + let (p, _) = vert(&mesh, i); + if p[0].abs() < 0.4 && p[2].abs() < 0.4 { + assert!( + (p[0].abs() - p[2].abs()).abs() < 1e-4, + "Layer-Eck-Vertex {i} bei {p:?} nicht auf der Gehrung" + ); + } + } + } + // --- Deckenplatten (extrudierte Polygone) --------------------------------- use super::mesh::{build_model_mesh, extrude_slab, triangulate}; diff --git a/src-tauri/render3d/src/mesh.rs b/src-tauri/render3d/src/mesh.rs index 6900ed1..de0fba2 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -8,8 +8,32 @@ // Diese 2D-Band-Ecken werden nach world abgebildet (XZ-Ebene) und entlang +Y von // `base_elevation` bis `base_elevation + height` extrudiert — analog zur // three.js-`ExtrudeGeometry`, die den 2D-`clippedBand` hochzieht (Viewport3D.tsx -// `addLayerPrism`). M1 nimmt die stumpfe (rechtwinklige) Band-Form; Gehrungen -// (`computeJoins`/`clippedBand`) sind ein spaeterer Milestone. +// `addLayerPrism`). +// +// ECKEN-VERSCHNEIDUNG (Gehrung): `compute_wall_miters` berechnet — analog zum +// Web-Pendant `src/model/joins.ts::computeJoins` + `geometry.ts::clippedBand` — +// fuer jede Wand die Gehrungslinien an ihren ECHTEN Achsenenden, rein aus der +// Wandkonnektivitaet (Endpunkte, die sich bei genau ZWEI, nicht-parallelen +// Waenden treffen). Anders als der Web-Pfad braucht das HIER keine eigenen +// Eingabefelder: die Berechnung laeuft direkt auf `WallInput` (Start/Ende/ +// Dicke), robust auch gegenueber der Teilquader-Zerlegung an Oeffnungen (siehe +// unten) — zwei Teilstuecke DERSELBEN Wand haben stets dieselbe (parallele) +// Achsrichtung, ihre "Gehrung" waere kollinear und wird automatisch verworfen +// (siehe `miter_line`). Mehrgeschossige Modelle mit deckungsgleichem Grundriss +// werden durch einen Hoehen-Ueberlappungstest (`cluster_by_height_overlap`) +// sauber getrennt (kein Verschneiden ueber Geschosse hinweg). T-/X-Stoesse +// (mehr als zwei Enden an einem Knoten) bleiben — wie im Web-Pendant — +// rechtwinklig. Das Schnitt-Modul (`section.rs`) bleibt UNVERAENDERT bei der +// achsparallelen Bounding-Box-Naeherung (siehe dessen Moduldoc). +// +// SCHICHTEN (`WallInput::layers`): mehrschichtige Waende (Putz/Daemmung/ +// Mauerwerk/...) werden nicht als ein Vollkoerper, sondern als je EIN Prisma +// pro Schicht extrudiert, quer zur Gesamtdicke gestapelt (gleiche Idee wie +// Viewport3D.tsx `addWallMeshes`: `off` laeuft ueber die Schichtdicken). +// Jede Schicht nutzt DASSELBE gehrte Fussabdruck-Band wie Gap 1 (nur mit ihrem +// eigenen Dicken-Versatz), sodass auch mehrschichtige Waende an Ecken saeuberlich +// stossen. Fehlt `layers` (None/leer), wird ein synthetisches Einzel-Layer aus +// `thickness`/`color` gebildet — bitgenau dasselbe Ergebnis wie zuvor. // // OEFFNUNGEN (Tueren/Fenster, `WallInput::openings`): eine Wand mit Oeffnungen // wird NICHT als ein Vollkoerper-Quader extrudiert, sondern in mehrere @@ -19,14 +43,19 @@ // fuer die Schnitt-Cut-Polygone (gemeinsame Basis: `crate::openings:: // split_range_by_voids`), nur direkt in Wandachsen-Metern statt in der // Projektions-u-Achse einer Schnittebene — damit zeigt die 3D-Vollansicht -// (`?engine=wasm`) dieselbe Oeffnung wie der Schnitt. +// (`?engine=wasm`) dieselbe Oeffnung wie der Schnitt. Die Gehrung (siehe oben) +// greift dabei NUR an Teilstuecken, die tatsaechlich an ein echtes Wandende +// grenzen (Achsposition 0 bzw. Wandlaenge) — innere Teilstuecke (Laibungen an +// Oeffnungen) bleiben immer rechtwinklig. // // KOORDINATEN: model [x, y] -> world (x, elevation, y). Also Grundriss in der // XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx: // "Modell (x,y,z) -> Three (x, z, y) (Z = Hoehe nach oben)". +use std::collections::HashMap; + use crate::openings::{split_range_by_voids, MIN_SPAN}; -use crate::types::{Mesh, Opening, Point2, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX}; +use crate::types::{Mesh, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, 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, @@ -46,13 +75,24 @@ fn left_normal(start: Point2, end: Point2) -> Point2 { [-dy / len, dx / len] } -/// Extrudiert EINE Wand zu einem oder mehreren Quader-Meshes und haengt sie an -/// `mesh` an. Ohne Oeffnungen (Regressionsfall) entsteht wie zuvor GENAU ein -/// Quader (`VERTS_PER_BOX`/`INDICES_PER_BOX`); mit Oeffnungen wird die Wand -/// zuerst in Achsen-Teilstuecke (Pfeiler/Bruestung/Sturz) zerlegt und jedes -/// Teilstueck einzeln als Quader extrudiert (siehe `wall_solid_segments` und -/// die Oeffnungen-Erklaerung im Moduldoc oben). +/// Extrudiert EINE Wand (ohne Kenntnis von Nachbarn, also stets rechtwinklige +/// Enden) zu einem oder mehreren Quader-Meshes. Fuer Szenen mit mehreren +/// Waenden bitte `build_walls_mesh`/`build_model_mesh` nutzen — die berechnen +/// zuerst die Eck-Gehrung ueber alle Waende (siehe `compute_wall_miters`) und +/// wenden sie hier mit an. Diese Funktion bleibt der einfache Einzel-Wand- +/// Einstieg (u. a. fuer bestehende Tests) und entspricht exakt dem Aufruf mit +/// leeren Gehrungen. pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) { + extrude_wall_with_miters(mesh, wall, WallMiters::default()); +} + +/// Wie `extrude_wall`, aber mit den (ggf. leeren) Gehrungslinien an den +/// echten Wandenden. Ohne Oeffnungen und ohne `layers` entsteht wie zuvor +/// GENAU ein Quader je Wand (`VERTS_PER_BOX`/`INDICES_PER_BOX`); mit +/// Oeffnungen wird die Wand zuerst in Achsen-Teilstuecke (Pfeiler/Bruestung/ +/// Sturz) zerlegt, mit `layers` zusaetzlich in Schicht-Baender quer zur +/// Dicke — siehe Moduldoc. +fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiters) { let n = left_normal(wall.start, wall.end); // Degenerierte Wand (Start == Ende) ueberspringen. if n == [0.0, 0.0] { @@ -64,8 +104,42 @@ pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) { let z0 = wall.base_elevation; let z1 = wall.base_elevation + wall.height; - for (a0, a1, y0, y1) in wall_solid_segments(length, &wall.openings, z0, z1) { - extrude_wall_segment(mesh, wall, n, length, a0, a1, y0, y1); + let segments = wall_solid_segments(length, &wall.openings, z0, z1); + + // Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus + // der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten. + let synthetic; + let layers: &[WallLayer] = match &wall.layers { + Some(ls) if !ls.is_empty() => ls.as_slice(), + _ => { + synthetic = [WallLayer { + thickness: wall.thickness, + color: wall.color, + }]; + &synthetic + } + }; + + // Schichten von der +n-Seite (offA = +Gesamtdicke/2) zur -n-Seite stapeln, + // damit offA stets > offB gilt — exakt die Konvention, unter der die + // bisherige Ecken-/Flankennormalen-Herleitung (siehe `extrude_layer_segment`) + // gilt. Fuer das synthetische Einzel-Layer ergibt sich offA=+h, offB=-h wie + // zuvor. + let total: f32 = layers.iter().map(|l| l.thickness).sum(); + let mut off_a = total * 0.5; + for layer in layers { + let off_b = off_a - layer.thickness; + for &(a0, a1, y0, y1) in &segments { + // Gehrung nur an Teilstuecken, die tatsaechlich an ein echtes + // Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig). + let start_cut = if a0 <= 1e-6 { miters.start } else { None }; + let end_cut = if a1 >= length - 1e-6 { miters.end } else { None }; + extrude_layer_segment( + mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut, + layer.color, + ); + } + off_a = off_b; } } @@ -109,16 +183,28 @@ fn axis_point(wall: &WallInput, length: f32, a: f32) -> Point2 { ] } -/// Extrudiert EIN Achsen-Teilstueck `[a0,a1]` (Hoehe `[y0,y1]`) einer Wand zu +/// Extrudiert EIN Schicht-Achsen-Teilstueck (Achse `[a0,a1]`, Hoehe `[y0,y1]`, +/// Dicken-Versatz `[off_a,off_b]` gegen die Achse entlang `n`) einer Wand zu /// einem Quader-Mesh und haengt ihn an `mesh` an. Fuer `a0=0, a1=length, -/// y0=base_elevation, y1=base_elevation+height` (keine Oeffnungen) ist das -/// exakt die bisherige Vollkoerper-Extrusion. +/// y0=base_elevation, y1=base_elevation+height, off_a=+thickness/2, +/// off_b=-thickness/2` und ohne Gehrung (`start_cut`/`end_cut` beide `None`) +/// ist das exakt die bisherige Vollkoerper-Extrusion (Regressionsfall). +/// +/// GEHRUNG: ist `start_cut`/`end_cut` gesetzt, werden die jeweiligen Band-Ecken +/// NICHT rechtwinklig bei `a0`/`a1` gekappt, sondern mit der Gehrungslinie +/// verschnitten (analog `clippedBand` im Web) — siehe `compute_wall_miters`. +/// Die beiden End-Kappenflaechen bleiben dabei planar (siehe Beweis im +/// Moduldoc von `compute_wall_miters`), ihre Normale wird deshalb geometrisch +/// aus den tatsaechlichen Kantenvektoren hergeleitet (`quad_normal`) statt aus +/// der festen Achsrichtung — das liefert im rechtwinkligen Fall exakt dieselbe +/// Normale wie zuvor UND bleibt bei einer schraegen Gehrung korrekt. /// /// Aufbau: die vier Grundriss-Ecken des Bandes (CCW) bilden das Deckel-Rechteck. /// Boden = gleiche XZ auf `y0`, Deckel = auf `y1`. Jede der sechs Seiten bekommt /// eigene Vertices mit ihrer (nach aussen zeigenden) Flaechen-Normale — daher /// flaches Shading und korrektes Backface-Culling. -fn extrude_wall_segment( +#[allow(clippy::too_many_arguments)] +fn extrude_layer_segment( mesh: &mut Mesh, wall: &WallInput, n: Point2, @@ -127,21 +213,35 @@ fn extrude_wall_segment( a1: f32, y0: f32, y1: f32, + off_a: f32, + off_b: f32, + start_cut: Option, + end_cut: Option, + color: Rgb, ) { - let h = wall.thickness * 0.5; - - // Vier Band-Ecken im Grundriss (CCW, wie `wallCorners(start,end,thickness)`), - // aber auf Basis der Achsen-Punkte des Teilstuecks statt Wandanfang/-ende: - // p0 = p(a0) + n*h, p1 = p(a1) + n*h, p2 = p(a1) - n*h, p3 = p(a0) - n*h. - let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s * h, p[1] + n[1] * s * h] }; let p0 = axis_point(wall, length, a0); let p1 = axis_point(wall, length, a1); - let g0 = off(p0, 1.0); - let g1 = off(p1, 1.0); - let g2 = off(p1, -1.0); - let g3 = off(p0, -1.0); - let color = wall.color; + // Achsenrichtung der GESAMTEN Wand (fuer Gehrungs-Schnitt und Kappen) — + // konstant ueber alle Teilstuecke, unabhaengig von `a0`/`a1`. + let ux = wall.end[0] - wall.start[0]; + let uz = wall.end[1] - wall.start[1]; + let ul = (ux * ux + uz * uz).sqrt().max(1e-9); + let u: Point2 = [ux / ul, uz / ul]; + + // Band-Eckpunkte, ggf. an der Gehrungslinie verschnitten statt rechtwinklig + // bei a0/a1 gekappt (siehe Moduldoc oben / `clippedBand` im Web). + let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s, p[1] + n[1] * s] }; + let clip = |base: Point2, cut: Option| -> Point2 { + match cut { + None => base, + Some(c) => line_intersect_2(base, u, c.point, c.dir).unwrap_or(base), + } + }; + let g0 = clip(off(p0, off_a), start_cut); + let g1 = clip(off(p1, off_a), end_cut); + let g2 = clip(off(p1, off_b), end_cut); + let g3 = clip(off(p0, off_b), start_cut); // world-Position aus Grundriss-XZ + Hoehe: (x, y, z=grundriss.y). let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] }; @@ -162,30 +262,230 @@ fn extrude_wall_segment( // Boden unten (-Y): b0,b3,b2,b1 (von unten gesehen CCW). push_quad(mesh, b0, b3, b2, b1, [0.0, -1.0, 0.0], color); - // Vier Mantelflaechen. Aussen-Normalen aus dem Grundriss: - // Kante g0->g1 liegt auf der +n-Seite -> Normale +n. - // Kante g2->g3 liegt auf der -n-Seite -> Normale -n. - // Kappen an a0 (g3->g0) und a1 (g1->g2) liegen an den Achsen-Enden des - // Teilstuecks (bei einem inneren Teilstueck also am Rand der Oeffnung - // -> Leibungsflaeche/Reveal statt Wandende). - let np = [n[0], 0.0, n[1]]; // +n-Seite (world) - let nn = [-n[0], 0.0, -n[1]]; // -n-Seite - // Achsenrichtung der GESAMTEN Wand (fuer die End-Kappen-Normalen) — konstant - // ueber alle Teilstuecke, unabhaengig von `a0`/`a1`. - let ux = wall.end[0] - wall.start[0]; - let uz = wall.end[1] - wall.start[1]; - let ul = (ux * ux + uz * uz).sqrt().max(1e-9); - let u = [ux / ul, 0.0, uz / ul]; - let neg_u = [-u[0], 0.0, -u[2]]; - - // +n-Wand: aussen betrachtet CCW = b0, b1, t1, t0. + // Zwei Mantelflaechen (+n-/−n-Seite): bleiben bei Gehrung EXAKT planar mit + // fester Normale, weil sich die Ecken nur entlang `u` verschieben (siehe + // Moduldoc-Beweis) — der `off_a`/`off_b`-Abstand zur Achse bleibt konstant. + let np = [n[0], 0.0, n[1]]; + let nn = [-n[0], 0.0, -n[1]]; push_quad(mesh, b0, b1, t1, t0, np, color); - // -n-Wand: aussen betrachtet CCW = b2, b3, t3, t2. push_quad(mesh, b2, b3, t3, t2, nn, color); - // End-Kappe bei a1, Normale +u: b1, b2, t2, t1. - push_quad(mesh, b1, b2, t2, t1, u, color); - // End-Kappe bei a0, Normale -u: b3, b0, t0, t3. - push_quad(mesh, b3, b0, t0, t3, neg_u, color); + + // End-Kappen bei a1/a0: Normale geometrisch hergeleitet (siehe Moduldoc) — + // liefert im rechtwinkligen Fall exakt +u/-u, bei Gehrung die tatsaechliche + // (schraege) Kappen-Normale. + let end_normal = quad_normal(b1, b2, t1); + let start_normal = quad_normal(b3, b0, t3); + push_quad(mesh, b1, b2, t2, t1, end_normal, color); + push_quad(mesh, b3, b0, t0, t3, start_normal, color); +} + +/// Aussen-Normale eines planaren, CCW-von-aussen georderten Quads `(a,b,_,d)` +/// (nur die drei ersten Ecken werden gebraucht: `d` ist die vierte, ueber `a` +/// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe +/// Moduldoc von `compute_wall_miters`. Degenerierte (nulllange) Flaechen liefern +/// den Nullvektor statt NaN (Winkel spielt dort keine Rolle, siehe Aufrufer). +fn quad_normal(a: [f32; 3], b: [f32; 3], d: [f32; 3]) -> [f32; 3] { + let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let ad = [d[0] - a[0], d[1] - a[1], d[2] - a[2]]; + let c = [ + ab[1] * ad[2] - ab[2] * ad[1], + ab[2] * ad[0] - ab[0] * ad[2], + ab[0] * ad[1] - ab[1] * ad[0], + ]; + let len = (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt().max(1e-9); + [c[0] / len, c[1] / len, c[2] / len] +} + +// ───────────────────────────────────────────────────────────────────────────── +// Ecken-Verschneidung (Gehrung): Wandkonnektivitaet -> Gehrungslinien je Wandende +// ───────────────────────────────────────────────────────────────────────────── + +/// Eine Gehrungslinie (Punkt + Richtung, Modell-Meter) an einem Wandende. +#[derive(Debug, Clone, Copy)] +struct MiterLine { + point: Point2, + dir: Point2, +} + +/// Die (ggf. fehlenden) Gehrungslinien EINER Wand an Start/Ende. +#[derive(Debug, Clone, Copy, Default)] +struct WallMiters { + start: Option, + end: Option, +} + +/// Schnittpunkt der Geraden `a + t*da` mit `b + s*db` (2D). `None` bei +/// (nahezu) parallelen Richtungen — 2D-Pendant zu `math::cross`/`lineIntersect` +/// im Web (`src/model/geometry.ts`). +fn line_intersect_2(a: Point2, da: Point2, b: Point2, db: Point2) -> Option { + let denom = da[0] * db[1] - da[1] * db[0]; + if denom.abs() < 1e-9 { + return None; + } + let bx = b[0] - a[0]; + let by = b[1] - a[1]; + let t = (bx * db[1] - by * db[0]) / denom; + Some([a[0] + t * da[0], a[1] + t * da[1]]) +} + +/// Rundungsschluessel eines 2D-Punkts (0.1 mm), zur robusten Gruppierung von +/// Wandenden am selben Knoten — 1:1 das Muster von `joins.ts::roundKey`. +fn round_key(p: Point2) -> (i64, i64) { + ( + (p[0] * 1e4).round() as i64, + (p[1] * 1e4).round() as i64, + ) +} + +/// Ein Wandende, wie es fuer die Knoten-Gruppierung gebraucht wird. +struct EndRef { + wall: usize, + is_start: bool, + point: Point2, + /// Normalisierte Achsenrichtung Start->Ende der GESAMTEN Wand (konstant, + /// unabhaengig davon, ob es sich um das Start- oder Endende handelt). + dir: Point2, + z0: f32, + z1: f32, +} + +/// Gruppiert `members` (Indizes in `ends`) nach echt ueberlappendem +/// Hoehenbereich (Union-Find) — trennt uebereinanderliegende Geschosse mit +/// deckungsgleichem Grundriss (deren Hoehenbereiche sich hoechstens BERUEHREN, +/// nicht ueberlappen) von echten Knoten INNERHALB eines Geschosses (deren +/// Hoehenbereiche sich ueberschneiden, auch wenn Oeffnungen ein Teilstueck auf +/// Bruestung/Sturz reduzieren). +fn cluster_by_height_overlap(ends: &[EndRef], members: &[usize]) -> Vec> { + let n = members.len(); + let mut parent: Vec = (0..n).collect(); + fn find(parent: &mut [usize], x: usize) -> usize { + if parent[x] != x { + let root = find(parent, parent[x]); + parent[x] = root; + } + parent[x] + } + const OVERLAP_EPS: f32 = 1e-4; + for i in 0..n { + for j in (i + 1)..n { + let a = &ends[members[i]]; + let b = &ends[members[j]]; + let lo = a.z0.max(b.z0); + let hi = a.z1.min(b.z1); + if hi - lo > OVERLAP_EPS { + let ri = find(&mut parent, i); + let rj = find(&mut parent, j); + if ri != rj { + parent[ri] = rj; + } + } + } + } + let mut clusters: HashMap> = HashMap::new(); + for i in 0..n { + let r = find(&mut parent, i); + clusters.entry(r).or_default().push(members[i]); + } + clusters.into_values().collect() +} + +/// Gemeinsame Gehrungslinie zweier Wandenden `a`, `b`, die sich im selben +/// Knoten treffen — 1:1-Port von `joins.ts::miterLine` (Referenzlinie "center", +/// da `WallInput` keine `referenceLine`-Information mitfuehrt): `a`s linke +/// Flaeche wird mit der naeher gelegenen Flaeche von `b` verschnitten, `a`s +/// rechte mit der jeweils anderen. Liefert `None` bei (nahezu) kollinearen +/// Richtungen — das deckt insbesondere zwei Teilstuecke DERSELBEN Wand ab +/// (identische Richtung, siehe Moduldoc). +fn miter_line(walls: &[WallInput], a: &EndRef, b: &EndRef) -> Option { + let j = a.point; + let t_a = walls[a.wall].thickness; + let t_b = walls[b.wall].thickness; + let n_a: Point2 = [-a.dir[1], a.dir[0]]; + let n_b: Point2 = [-b.dir[1], b.dir[0]]; + + let add_scaled = |p: Point2, v: Point2, s: f32| -> Point2 { [p[0] + v[0] * s, p[1] + v[1] * s] }; + let p_la = add_scaled(j, n_a, t_a * 0.5); + let p_ra = add_scaled(j, n_a, -t_a * 0.5); + let p_lb = add_scaled(j, n_b, t_b * 0.5); + let p_rb = add_scaled(j, n_b, -t_b * 0.5); + + let dist2 = |p: Point2, q: Point2| { + let dx = p[0] - q[0]; + let dy = p[1] - q[1]; + dx * dx + dy * dy + }; + let lb_closer = dist2(p_la, p_lb) <= dist2(p_la, p_rb); + let b_for_left = if lb_closer { p_lb } else { p_rb }; + let b_for_right = if lb_closer { p_rb } else { p_lb }; + + let c1 = line_intersect_2(p_la, a.dir, b_for_left, b.dir)?; + let c2 = line_intersect_2(p_ra, a.dir, b_for_right, b.dir)?; + let dir = [c2[0] - c1[0], c2[1] - c1[1]]; + if (dir[0] * dir[0] + dir[1] * dir[1]).sqrt() < 1e-9 { + return None; // kollinear -> kein Schnitt + } + Some(MiterLine { point: c1, dir }) +} + +fn apply_cut(out: &mut [WallMiters], e: &EndRef, cut: MiterLine) { + if e.is_start { + out[e.wall].start = Some(cut); + } else { + out[e.wall].end = Some(cut); + } +} + +/// Berechnet fuer JEDE Wand in `walls` die Gehrungslinien an ihren echten +/// Achsenenden — reine Funktion der Wandkonnektivitaet (Endpunkte + Dicke + +/// Hoehenbereich), siehe Moduldoc "Ecken-Verschneidung". Nur Knoten, an denen +/// sich (innerhalb desselben Hoehenbereichs) GENAU zwei, nicht-kollineare +/// Wandenden treffen, werden gehrt; freie Enden, T-/X-Stoesse und mehrdeutige +/// Knoten (z. B. eine Oeffnung direkt an einer echten Ecke, die das Ende in +/// mehrere Hoehen-Teilstuecke zerlegt) bleiben konservativ rechtwinklig. +fn compute_wall_miters(walls: &[WallInput]) -> Vec { + let mut out = vec![WallMiters::default(); walls.len()]; + + let mut ends: Vec = Vec::with_capacity(walls.len() * 2); + for (i, w) in walls.iter().enumerate() { + let n = left_normal(w.start, w.end); + if n == [0.0, 0.0] { + continue; // entartete Wand, siehe extrude_wall + } + // Achsenrichtung aus der Links-Normale zurueckgedreht (90 Grad): + // n = (-dir.y, dir.x) => dir = (n.y, -n.x). Siehe section.rs::wall_prism. + let dir: Point2 = [n[1], -n[0]]; + let z0 = w.base_elevation; + let z1 = w.base_elevation + w.height; + ends.push(EndRef { wall: i, is_start: true, point: w.start, dir, z0, z1 }); + ends.push(EndRef { wall: i, is_start: false, point: w.end, dir, z0, z1 }); + } + + let mut groups: HashMap<(i64, i64), Vec> = HashMap::new(); + for (idx, e) in ends.iter().enumerate() { + groups.entry(round_key(e.point)).or_default().push(idx); + } + + for members in groups.into_values() { + if members.len() < 2 { + continue; // freies Ende + } + for cluster in cluster_by_height_overlap(&ends, &members) { + if cluster.len() != 2 { + continue; // T-/X-Stoss oder mehrdeutig -> rechtwinklig + } + let (ia, ib) = (cluster[0], cluster[1]); + let (a, b) = (&ends[ia], &ends[ib]); + if a.wall == b.wall { + continue; // eine Wand kann sich nicht selbst verschneiden + } + let Some(cut) = miter_line(walls, a, b) else { + continue; + }; + apply_cut(&mut out, a, cut); + apply_cut(&mut out, b, cut); + } + } + out } /// Haengt ein Quad (vier Ecken in CCW von aussen) als zwei Dreiecke an, mit fester @@ -210,12 +510,15 @@ fn push_quad( } /// Baut ein einzelnes Mesh aus mehreren Waenden (alle in EINEN Puffer). Die -/// Reihenfolge der Vertices ist deterministisch (je Wand ein Quader), damit die -/// Tests stabile Zaehlungen erwarten koennen. +/// Reihenfolge der Vertices ist deterministisch (je Wand ein Quader, bzw. bei +/// `layers`/Oeffnungen mehrere), damit die Tests stabile Zaehlungen erwarten +/// koennen. Berechnet zuerst die Eck-Gehrung ueber ALLE Waende (siehe +/// `compute_wall_miters`) und extrudiert dann jede Wand mit ihren Gehrungen. pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh { let mut mesh = Mesh::default(); - for w in walls { - extrude_wall(&mut mesh, w); + let miters = compute_wall_miters(walls); + for (w, m) in walls.iter().zip(miters.iter()) { + extrude_wall_with_miters(&mut mesh, w, *m); } mesh } diff --git a/src-tauri/render3d/src/section.rs b/src-tauri/render3d/src/section.rs index 65abd99..0ee9cc6 100644 --- a/src-tauri/render3d/src/section.rs +++ b/src-tauri/render3d/src/section.rs @@ -101,12 +101,19 @@ //! (Rechteck-Baender) und Decken (i.d.R. konvexe Rechtecke) ist das exakt; //! fuer stark diagonale oder konkave Grundrisse kann es zu Ueberverdeckung //! fuehren. -//! - Keine Wandknoten-Verschneidung (T-/X-Stoesse): Waende werden wie in -//! `mesh.rs` als eigenstaendige, stumpf abgeschlossene Quader behandelt, die -//! sich an Ecken UEBERLAPPEN (kein Miter-Join). Das kann in Ecknaehe dazu -//! fuehren, dass eine Wand als "in eine andere eingebettet" verdeckt erscheint -//! (siehe Test `eck_ueberlappung_verdeckt_kante_am_wandknoten` unten) — real, -//! aber ein Artefakt der fehlenden Verschneidungslogik, kein Bug dieses Moduls. +//! - Keine Wandknoten-Verschneidung (T-/X-Stoesse): DIESES Modul behandelt +//! Waende weiterhin als eigenstaendige, stumpf abgeschlossene Quader, die +//! sich an Ecken UEBERLAPPEN (kein Miter-Join) — sowohl fuer die Cut-Polygone +//! als auch fuer die Verdeckungs-Bounding-Box. `mesh.rs` (die Vollkoerper- +//! Extrusion fuer die 3D-Ansicht) berechnet die Eck-Gehrung inzwischen SELBST +//! (`mesh::compute_wall_miters`, aus der Wandkonnektivitaet); dieses Modul +//! zieht davon bewusst KEINEN Nutzen, weil seine Verdeckungs-Bounding-Box +//! ohnehin nur die achsparallele Grundflaeche kennt (siehe oben) — eine +//! gehrte, nicht-achsparallele Kante wuerde dort keinen Praezisionsgewinn +//! bringen. Das kann in Ecknaehe dazu fuehren, dass eine Wand als "in eine +//! andere eingebettet" verdeckt erscheint (siehe Test +//! `eck_ueberlappung_verdeckt_kante_am_wandknoten` unten) — real, aber ein +//! Artefakt der fehlenden Verschneidungslogik DIESES Moduls, kein Bug. //! - Oeffnungen werden hier (Cut + Projektion) UND von der Vollkoerper- //! Extrusion in `mesh.rs` ausgewertet (`mesh::wall_solid_segments`) — beide //! nutzen dieselbe Bruestungs-/Sturz-Zerlegung (`crate::openings:: @@ -984,6 +991,7 @@ mod tests { base_elevation: 0.0, color, openings: vec![], + layers: None, }, WallInput { start: [0.0, 3.0], @@ -993,6 +1001,7 @@ mod tests { base_elevation: 0.0, color, openings: vec![], + layers: None, }, ]; let slabs = vec![SlabInput { @@ -1208,6 +1217,7 @@ mod tests { sill: 1.0, height: 1.0, }], + layers: None, } } @@ -1292,6 +1302,7 @@ mod tests { base_elevation: 0.0, color: [0.8, 0.8, 0.8], openings: vec![], + layers: None, }; // Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter. let plane = SectionPlane::looking_plus_y(-2.0); diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index 8613836..ed98d15 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -25,13 +25,17 @@ pub type Rgb = [f32; 3]; /// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`). /// /// Vereinfachung des Web-`Wall` (siehe src/model/wall.ts): Ecken-Verschneidung -/// (`computeJoins`/`wallCorners`) kommt in einem spaeteren Milestone; M1 extrudiert -/// jede Wand als eigenstaendigen, an den Enden stumpf abgeschlossenen Quader. +/// (Gehrung an echten Wandenden, analog `computeJoins`/`wallCorners` im Web) +/// wird von `mesh.rs` SELBSTAENDIG aus der Wandkonnektivitaet berechnet (siehe +/// `mesh::compute_wall_miters`) — keine eigenen Eingabefelder noetig, robust +/// auch gegenueber der hier bereits in Teilquader zerlegten Darstellung (siehe +/// `openings` unten). Das Schnitt-Modul (`section.rs`) nutzt weiterhin die +/// (unveraenderte) achsparallele Bounding-Box-Naeherung ohne Gehrung — siehe +/// dessen Moduldoc "Bekannte Vereinfachungen". /// `openings` (Tueren/Fenster, siehe `Opening`) werden vom Schnitt-Modul -/// (`section.rs`) ausgewertet; die Vollkoerper-Extrusion (`mesh.rs`) kennt sie -/// (noch) NICHT und extrudiert weiterhin die volle Wandflaeche — siehe -/// `mesh.rs`-Moduldoc und `docs/design/engine-section-pipeline.md` fuer diese -/// bekannte Diskrepanz. +/// (`section.rs`) UND von der Vollkoerper-Extrusion (`mesh.rs`) ausgewertet +/// (siehe `mesh::wall_solid_segments`) — beide nutzen dieselbe Bruestungs-/ +/// Sturz-Zerlegung (`crate::openings`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WallInput { /// Achsen-Startpunkt im Grundriss (Modell-Meter). @@ -39,6 +43,9 @@ pub struct WallInput { /// Achsen-Endpunkt im Grundriss (Modell-Meter). pub end: Point2, /// Wanddicke in Metern (das Band wird um `thickness/2` je Seite versetzt). + /// Bleibt auch bei gesetztem `layers` die GESAMT-Dicke (Summe der + /// Schichtdicken) — `section.rs`/`web::model_bounds` rechnen weiterhin + /// allein mit diesem Feld, kennen `layers` nicht. pub thickness: f32, /// Wandhoehe in Metern (Extrusion entlang +Y). pub height: f32, @@ -46,11 +53,28 @@ pub struct WallInput { #[serde(default, rename = "baseElevation")] pub base_elevation: f32, /// Albedo-Farbe der Wand (RGB 0..1). Default helles Grau wie die three.js-Sicht. + /// Nur wirksam, wenn `layers` fehlt/leer ist (Vollkoerper-Fallback). #[serde(default = "default_wall_color")] pub color: Rgb, /// Oeffnungen (Tueren/Fenster) entlang der Wandachse. Default leer (volle Wand). #[serde(default)] pub openings: Vec, + /// Optionaler Schichtaufbau (z. B. Putz/Daemmung/Mauerwerk), quer zur + /// Gesamtdicke gestapelt (siehe `mesh::extrude_wall`). Fehlt dieses Feld + /// (`None`) oder ist es leer, extrudiert `mesh.rs` weiterhin EINEN + /// Vollkoerper aus `thickness`/`color` (Rueckwaertskompatibilitaet, M1- + /// Verhalten unveraendert). + #[serde(default)] + pub layers: Option>, +} + +/// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct WallLayer { + /// Schichtdicke in Metern. + pub thickness: f32, + /// Albedo-Farbe dieser Schicht (RGB 0..1). + pub color: Rgb, } /// Eine Oeffnung (Tuer/Fenster) in einer Wand: Intervall entlang der Wandachse diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 5858742..b10fa20 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -17,15 +17,29 @@ // DECKEN (Slabs): geschossgebundene Flächenbauteile werden als extrudierte // Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und // zieht ihn über die Deckendicke hoch. +// +// SCHICHTEN (Putz/Dämmung/Mauerwerk/…): zusätzlich zur Gesamtdicke wird der +// aufgelöste Schichtaufbau des WallType mitgegeben (`layers`) — je Schicht +// Dicke + 3D-Farbe des zugehörigen Bauteil-Materials (Component). render3d +// extrudiert daraus je Schicht ein eigenes Band quer zur Wanddicke (siehe +// render3d::mesh); die Eck-Gehrung (Wandknoten-Verschneidung) berechnet +// render3d selbständig aus der Wandkonnektivität, braucht also keine +// zusätzlichen Felder von hier. import type { Project, Wall } from "../model/types"; -import { getWallType, wallTypeThickness, openingsOfWall } from "../model/types"; +import { getComponent, getWallType, wallTypeThickness, openingsOfWall } from "../model/types"; import { wallVerticalExtent, ceilingVerticalExtent } from "../model/wall"; import { openingInterval, openingVerticalExtent } from "../geometry/opening"; export type RVec2 = [number, number]; export type RRgb = [number, number, number]; +/** Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component). */ +export interface RLayer { + thickness: number; + color: RRgb; +} + export interface RWall { start: RVec2; end: RVec2; @@ -33,6 +47,8 @@ export interface RWall { height: number; baseElevation: number; color: RRgb; + /** Aufgelöster Schichtaufbau (leer = kein WallType auflösbar, siehe emitWall). */ + layers: RLayer[]; } /** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */ @@ -57,6 +73,18 @@ const SLAB_RGB: RRgb = [0.86, 0.86, 0.88]; /** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */ const EPS = 1e-4; +/** + * Wandelt eine Hex-Farbe ("#rrggbb", Component-Farbe im Plan) in float-RGB + * (0..1, render3d-Konvention) um. Ungültiges/fehlendes Format fällt auf den + * neutralen Wand-Grundton zurück (nie ein hartes Fehlbild). + */ +function hexToRgb(hex: string): RRgb { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex); + if (!m) return WALL_RGB; + const v = parseInt(m[1], 16); + return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255]; +} + /** * Hängt einen Wand-Teilquader an: das Achsenstück [from..to] (Meter ab * Wand-Startpunkt) über den Höhenbereich [zBottom..zTop]. Entartete Stücke @@ -71,6 +99,7 @@ function pushSegment( zBottom: number, zTop: number, thickness: number, + layers: RLayer[], ): void { if (to - from <= EPS) return; if (zTop - zBottom <= EPS) return; @@ -89,16 +118,27 @@ function pushSegment( height: zTop - zBottom, baseElevation: zBottom, color: WALL_RGB, + layers, }); } /** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */ function emitWall(out: RWall[], project: Project, wall: Wall): void { let thickness = 0.2; + // Aufgelöster Schichtaufbau (Dicke + 3D-Farbe je Schicht des WallType) — + // leer, wenn kein WallType auflösbar ist (render3d extrudiert dann wie + // bisher einen Vollkörper aus `thickness`/`color`, siehe RWall-Moduldoc). + let layers: RLayer[] = []; try { - thickness = wallTypeThickness(getWallType(project, wall)); + const wt = getWallType(project, wall); + thickness = wallTypeThickness(wt); + layers = wt.layers.map((l) => ({ + thickness: l.thickness, + color: hexToRgb(getComponent(project, l.componentId).color), + })); } catch { thickness = 0.2; + layers = []; } const { zBottom, zTop } = wallVerticalExtent(project, wall); const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); @@ -128,7 +168,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void { // Ohne Aussparungen: ein durchgehender Quader (wie bisher). if (cutouts.length === 0) { - pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness); + pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness, layers); return; } @@ -140,23 +180,23 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void { const segFrom = Math.max(cursor, from); if (from > cursor) { // Voller Wandpfeiler bis zur Aussparung. - pushSegment(out, wall, cursor, from, zBottom, zTop, thickness); + pushSegment(out, wall, cursor, from, zBottom, zTop, thickness, layers); } if (to > segFrom) { // 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); + pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness, layers); } // Sturz über der Öffnung (bis zum Wandkopf). if (oTop < zTop - EPS) { - pushSegment(out, wall, segFrom, to, oTop, zTop, thickness); + pushSegment(out, wall, segFrom, to, oTop, zTop, thickness, layers); } } cursor = Math.max(cursor, to); } // Restlicher Wandpfeiler bis zum Achsenende. if (cursor < axisLen) { - pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness); + pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness, layers); } }