diff --git a/src-tauri/render3d/examples/section_svg.rs b/src-tauri/render3d/examples/section_svg.rs index 8c29636..b9f5dbf 100644 --- a/src-tauri/render3d/examples/section_svg.rs +++ b/src-tauri/render3d/examples/section_svg.rs @@ -32,6 +32,7 @@ fn l_wall_scene() -> (Vec, Vec) { height: 1.0, }], layers: None, + holes: vec![], }, WallInput { start: [0.0, 3.0], @@ -42,6 +43,7 @@ fn l_wall_scene() -> (Vec, Vec) { color, openings: vec![], layers: None, + holes: vec![], }, // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, // also groessere Tiefe von der Betrachter-Ebene aus), genau im @@ -57,6 +59,7 @@ fn l_wall_scene() -> (Vec, Vec) { color, openings: vec![], layers: None, + holes: vec![], }, ]; let slabs = vec![SlabInput { diff --git a/src-tauri/render3d/src/bin/spike3d.rs b/src-tauri/render3d/src/bin/spike3d.rs index 53f88d2..4b0aee7 100644 --- a/src-tauri/render3d/src/bin/spike3d.rs +++ b/src-tauri/render3d/src/bin/spike3d.rs @@ -38,6 +38,7 @@ fn demo_walls() -> Vec { color: grey, openings: vec![], layers: None, + holes: vec![], }; // Raum 6 x 4 m. vec![ diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 8abb13c..7bbab5d 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -54,7 +54,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, WallLayer, FLOATS_PER_VERTEX}; + use super::types::{Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX}; /// Bequemer Bau einer achsparallelen Wand entlang +X. fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput { @@ -67,6 +67,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], } } @@ -104,6 +105,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }, ]); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); @@ -182,6 +184,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }; let mut mesh = Mesh::default(); extrude_wall(&mut mesh, &w); @@ -203,6 +206,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }; let mesh = build_walls_mesh(&[w]); assert_eq!(mesh.vertex_count(), 0); @@ -306,6 +310,97 @@ mod tests { assert_eq!(mesh.indices.len(), INDICES_PER_BOX); } + // --- Echte Loecher in EINEM Wandkoerper (WallInput::holes) ----------------- + + #[test] + fn zwei_versetzte_ueberlappende_loecher_in_einem_koerper() { + // KERNFALL: EINE Wand (Laenge 4, Hoehe 2.5) mit ZWEI Loechern, deren + // Achsen-Intervalle sich UEBERLAPPEN, aber auf UNTERSCHIEDLICHER Hoehe + // liegen — Fenster A u=[1.0,2.0] z=[0.9,2.1], Fenster B u=[1.5,2.5] + // z=[0.3,0.7]. Das kann die Segment-Zerlegung nicht abbilden; als Loecher + // in EINEM Koerper muss es sauber zerfallen (keine Geometrie in den + // Loechern, Aussenmasse erhalten, beide Laibungen vollstaendig). + let mut w = wall_x(4.0, 0.2, 2.5); + w.holes = vec![ + Hole { from: 1.0, to: 2.0, z_bottom: 0.9, z_top: 2.1 }, + Hole { from: 1.5, to: 2.5, z_bottom: 0.3, z_top: 0.7 }, + ]; + let mesh = build_walls_mesh(&[w]); + + // Mehr als ein Vollkoerper (Loecher fuegen Teilrechtecke + Laibungen hinzu). + assert!(mesh.vertex_count() > VERTS_PER_BOX); + // Index-Integritaet. + let max_idx = *mesh.indices.iter().max().unwrap(); + assert!((max_idx as usize) < mesh.vertex_count()); + + // Aussenmasse unveraendert (die soliden Teilrechtecke decken den Rand ab). + let (min, max) = mesh.bounds(); + assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5, "u 0..4"); + assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5, "z 0..2.5"); + assert!((min[2] + 0.1).abs() < 1e-5 && (max[2] - 0.1).abs() < 1e-5, "Dicke +/-0.1"); + + // KEIN Vertex liegt (mit Sicherheitsabstand) im Inneren eines der Loecher. + let holes = [(1.0f32, 2.0f32, 0.9f32, 2.1f32), (1.5, 2.5, 0.3, 0.7)]; + let m = 0.05; + let n = mesh.vertex_count(); + for i in 0..n { + let (p, _) = vert(&mesh, i); + for (a0, a1, zb, zt) in holes { + let inside = + p[0] > a0 + m && p[0] < a1 - m && p[1] > zb + m && p[1] < zt - m; + assert!(!inside, "Vertex {i} bei {p:?} liegt im Loch [{a0},{a1}]x[{zb},{zt}]"); + } + } + + // Beide Loecher besitzen ihre vier inneren Laibungen: fuer jede Loch-Kante + // muss mindestens ein Vertex GENAU auf der Kante (in u bzw. z) UND im + // jeweils anderen Loch-Intervall liegen (die Laibungsflaeche). + let has_vert = |pred: &dyn Fn([f32; 3]) -> bool| (0..n).any(|i| pred(vert(&mesh, i).0)); + for (a0, a1, zb, zt) in holes { + // linke/rechte Laibung: Vertex bei u==a0 bzw. u==a1, z im Loch. + assert!( + has_vert(&|p| (p[0] - a0).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4), + "linke Laibung fehlt fuer Loch [{a0},{a1}]" + ); + assert!( + has_vert(&|p| (p[0] - a1).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4), + "rechte Laibung fehlt fuer Loch [{a0},{a1}]" + ); + // untere/obere Laibung: Vertex bei z==zb bzw. z==zt, u im Loch. + assert!( + has_vert(&|p| (p[1] - zb).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4), + "untere Laibung fehlt fuer Loch [{a0},{a1}]" + ); + assert!( + has_vert(&|p| (p[1] - zt).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4), + "obere Laibung fehlt fuer Loch [{a0},{a1}]" + ); + } + } + + #[test] + fn tuer_loch_bis_zum_boden_ohne_untere_laibung() { + // Tuer-Loch reicht bis zum Boden (z_bottom == base_elevation 0): die + // untere Kante liegt am Wandrand -> KEINE untere Laibung, stattdessen eine + // Luecke im Boden-Streifen. Unter der Kopfhoehe (z_top 2.1) darf im + // Tuerbereich keine Geometrie liegen. + let mut w = wall_x(4.0, 0.2, 2.5); + w.holes = vec![Hole { from: 1.0, to: 1.8, z_bottom: 0.0, z_top: 2.1 }]; + let mesh = build_walls_mesh(&[w]); + let n = mesh.vertex_count(); + let m = 0.05; + for i in 0..n { + let (p, _) = vert(&mesh, i); + let in_span = p[0] > 1.0 + m && p[0] < 1.8 - m; + if in_span { + assert!( + p[1] < m || p[1] > 2.1 - 1e-4, + "Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe im Loch" + ); + } + } + } + // --- Ecken-Verschneidung (Gehrung) ----------------------------------------- #[test] @@ -325,6 +420,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }; let b = WallInput { start: [0.0, 0.0], @@ -335,6 +431,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }; let mesh = build_walls_mesh(&[a, b]); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader"); @@ -394,6 +491,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }; let walls = vec![ mk([0.0, 0.0], [3.0, 0.0]), @@ -422,6 +520,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }; let walls = vec![ mk([0.0, 0.0], [3.0, 0.0], 0.0), @@ -510,6 +609,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: layers(), + holes: vec![], }; let mut b = a.clone(); b.end = [0.0, 3.0]; diff --git a/src-tauri/render3d/src/mesh.rs b/src-tauri/render3d/src/mesh.rs index e81cc7d..a424eb1 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -59,7 +59,8 @@ use std::collections::HashMap; use crate::openings::{split_range_by_voids, MIN_SPAN}; use crate::types::{ - Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, FLOATS_PER_VERTEX, + Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, + FLOATS_PER_VERTEX, }; /// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2 @@ -109,7 +110,16 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter let z0 = wall.base_elevation; let z1 = wall.base_elevation + wall.height; - let segments = wall_solid_segments(length, &wall.openings, z0, z1); + // Mit echten Loechern (`holes`) wird die Wand NICHT an Oeffnungen segmentiert, + // sondern als EIN voller Achsen-Abschnitt extrudiert; die Loecher stanzt + // `extrude_layer_segment_with_holes` in die Langseiten (holes/openings + // schliessen sich aus, siehe `types::WallInput::holes`). + let has_holes = !wall.holes.is_empty(); + let segments = if has_holes { + vec![(0.0, length, z0, z1)] + } else { + 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. @@ -139,10 +149,20 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter // 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, - ); + if has_holes { + // Voller Achsen-Abschnitt (a0=0, a1=length) mit echten Loechern + // in den Langseiten — pro Schicht-Band dieselben Loecher (quer + // gestapelt bilden ihre Laibungen die volle Loch-Tiefe). + extrude_layer_segment_with_holes( + mesh, wall, n, length, y0, y1, off_a, off_b, start_cut, end_cut, layer.color, + &wall.holes, + ); + } else { + 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; } @@ -284,6 +304,328 @@ fn extrude_layer_segment( push_quad(mesh, b3, b0, t0, t3, start_normal, color); } +/// Haengt ein Quad (vier Ecken als Ring, beliebige Reihenfolge) als zwei +/// orientierte Dreiecke an — Winding via `push_tri_oriented` an `want_normal` +/// ausgerichtet (robust gegen die Eck-Reihenfolge, anders als `push_quad`). +/// Genutzt fuer die Loch-Zerlegung (Langseiten-Teilrechtecke, Deckel-/Boden- +/// Streifen, Laibungen), wo die Ecken aus Gitter-Koordinaten kommen. +fn push_quad_oriented( + mesh: &mut Mesh, + a: [f32; 3], + b: [f32; 3], + c: [f32; 3], + d: [f32; 3], + want_normal: [f32; 3], + color: Rgb, +) { + push_tri_oriented(mesh, a, b, c, want_normal, color); + push_tri_oriented(mesh, a, c, d, want_normal, color); +} + +/// Zerlegt das Rechteck `[u_lo,u_hi] x [z_lo,z_hi]` MINUS der `holes` +/// (`(u0,u1,z0,z1)`) in achsparallele, paarweise disjunkte Teilrechtecke (die +/// SOLIDEN Bereiche). Verfahren: Koordinaten-Kompression — alle Loch-Kanten in u +/// und z bilden ein Gitter; jede Gitterzelle, deren Mittelpunkt in KEINEM Loch +/// liegt, ist ein solides Teilrechteck. Dadurch koennen sich Loecher in u +/// ueberlappen und auf unterschiedlicher Hoehe liegen (Kernfall zweier versetzt +/// uebereinanderliegender Fenster), ohne dass degenerierte oder ueberlappende +/// Teilrechtecke entstehen. +fn solid_subrects( + u_lo: f32, + u_hi: f32, + z_lo: f32, + z_hi: f32, + holes: &[(f32, f32, f32, f32)], +) -> Vec<(f32, f32, f32, f32)> { + let sort_dedup = |mut v: Vec| -> Vec { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v.dedup_by(|a, b| (*a - *b).abs() <= MIN_SPAN); + v + }; + let mut us = vec![u_lo, u_hi]; + let mut zs = vec![z_lo, z_hi]; + for (a0, a1, hb, ht) in holes { + us.push(*a0); + us.push(*a1); + zs.push(*hb); + zs.push(*ht); + } + let us = sort_dedup(us); + let zs = sort_dedup(zs); + let mut out = Vec::new(); + for i in 0..us.len().saturating_sub(1) { + let (ua, ub) = (us[i], us[i + 1]); + if ub - ua <= MIN_SPAN { + continue; + } + for j in 0..zs.len().saturating_sub(1) { + let (za, zb) = (zs[j], zs[j + 1]); + if zb - za <= MIN_SPAN { + continue; + } + let cu = (ua + ub) * 0.5; + let cz = (za + zb) * 0.5; + let in_hole = holes + .iter() + .any(|(a0, a1, hb, ht)| cu > *a0 && cu < *a1 && cz > *hb && cz < *ht); + if !in_hole { + out.push((ua, ub, za, zb)); + } + } + } + out +} + +/// Zieht die (ggf. ueberlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert +/// die verbleibenden Teilintervalle (sortiert, disjunkt). Fuer die Deckel-/ +/// Boden-/Kappen-Flaechen, in denen ein randstaendiges Loch eine Luecke reisst. +fn subtract_intervals(lo: f32, hi: f32, gaps: &[(f32, f32)]) -> Vec<(f32, f32)> { + let mut gs: Vec<(f32, f32)> = gaps + .iter() + .map(|(a, b)| (a.max(lo), b.min(hi))) + .filter(|(a, b)| b - a > MIN_SPAN) + .collect(); + gs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + let mut out = Vec::new(); + let mut cursor = lo; + for (a, b) in gs { + if a > cursor + MIN_SPAN { + out.push((cursor, a)); + } + cursor = cursor.max(b); + } + if hi > cursor + MIN_SPAN { + out.push((cursor, hi)); + } + out +} + +/// Extrudiert EIN Schicht-Band (volle Achse `0..length`, Hoehe `[y0,y1]`, +/// Dicken-Versatz `[off_a,off_b]`) mit ECHTEN rechteckigen LOECHERN (`holes`, +/// siehe `types::Hole`) statt der Oeffnungs-Segmentierung. Aufbau: +/// • Langseiten (+n bei `off_a`, −n bei `off_b`): das Flaechen-Rechteck MINUS +/// der Loecher, per `solid_subrects` in Teilrechtecke zerlegt (je 2 Dreiecke). +/// • Deckel (`y1`) / Boden (`y0`): voller Streifen minus der u-Intervalle der +/// Loecher, die die jeweilige Kante beruehren (z. B. eine Tuer am Boden). +/// • End-Kappen (`a=0`/`a=length`, gemitert): volle Hoehe minus der z-Intervalle +/// randstaendiger Loecher (Loecher liegen normalerweise im Inneren → volle +/// Kappe, unveraendert gegenueber der Gehrung). +/// • Laibungen je Loch: vier Quads (links/rechts/unten/oben) — aber nur fuer die +/// Seiten, die im Wand-INNEREN liegen (die randstaendige Seite hat stattdessen +/// die Luecke in Deckel/Boden/Kappe, keine Laibung). +/// Winding/Normalen konsistent ueber `push_quad_oriented`/`push_tri_oriented` +/// (Backface-Culling + Beleuchtung korrekt). +#[allow(clippy::too_many_arguments)] +fn extrude_layer_segment_with_holes( + mesh: &mut Mesh, + wall: &WallInput, + n: Point2, + length: f32, + y0: f32, + y1: f32, + off_a: f32, + off_b: f32, + start_cut: Option, + end_cut: Option, + color: Rgb, + holes: &[Hole], +) { + 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]; + + 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), + } + }; + // Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv`; NUR an den echten + // Wandenden (a≈0 / a≈length) gegen die Gehrungslinie verschnitten — innere + // Loch-Kanten bleiben rechtwinklig (unabhaengig von der Gehrung). + let g_at = |a: f32, offv: f32| -> Point2 { + let base = off(axis_point(wall, length, a), offv); + if a <= 1e-6 { + clip(base, start_cut) + } else if a >= length - 1e-6 { + clip(base, end_cut) + } else { + base + } + }; + let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] }; + + // Loecher auf [0,length] x [y0,y1] klemmen, entartete verwerfen. + let mut hs: Vec<(f32, f32, f32, f32)> = Vec::new(); + for h in holes { + let a0 = h.from.max(0.0); + let a1 = h.to.min(length); + let zb = h.z_bottom.max(y0); + let zt = h.z_top.min(y1); + if a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN { + hs.push((a0, a1, zb, zt)); + } + } + + // ── Langseiten (+n / −n): Rechteck-Gitter minus Loecher. ────────────────── + let np = [n[0], 0.0, n[1]]; + let nn = [-n[0], 0.0, -n[1]]; + for (ua, ub, za, zb) in solid_subrects(0.0, length, y0, y1, &hs) { + push_quad_oriented( + mesh, + w(g_at(ua, off_a), za), + w(g_at(ub, off_a), za), + w(g_at(ub, off_a), zb), + w(g_at(ua, off_a), zb), + np, + color, + ); + push_quad_oriented( + mesh, + w(g_at(ua, off_b), za), + w(g_at(ub, off_b), za), + w(g_at(ub, off_b), zb), + w(g_at(ua, off_b), zb), + nn, + color, + ); + } + + // ── Deckel (y1) und Boden (y0): voller Streifen minus randstaendiger Loecher. + let top_gaps: Vec<(f32, f32)> = hs + .iter() + .filter(|(_, _, _, zt)| *zt >= y1 - MIN_SPAN) + .map(|(a0, a1, _, _)| (*a0, *a1)) + .collect(); + for (ua, ub) in subtract_intervals(0.0, length, &top_gaps) { + push_quad_oriented( + mesh, + w(g_at(ua, off_a), y1), + w(g_at(ub, off_a), y1), + w(g_at(ub, off_b), y1), + w(g_at(ua, off_b), y1), + [0.0, 1.0, 0.0], + color, + ); + } + let bot_gaps: Vec<(f32, f32)> = hs + .iter() + .filter(|(_, _, zb, _)| *zb <= y0 + MIN_SPAN) + .map(|(a0, a1, _, _)| (*a0, *a1)) + .collect(); + for (ua, ub) in subtract_intervals(0.0, length, &bot_gaps) { + push_quad_oriented( + mesh, + w(g_at(ua, off_a), y0), + w(g_at(ub, off_a), y0), + w(g_at(ub, off_b), y0), + w(g_at(ua, off_b), y0), + [0.0, -1.0, 0.0], + color, + ); + } + + // ── End-Kappen (a=0 / a=length): volle Hoehe minus randstaendiger Loecher. ─ + // Aussen-Normale je Kappe geometrisch aus den vollen Kappen-Ecken (wie im + // rechtwinkligen/gehrten Fall in `extrude_layer_segment`). + let start_normal = quad_normal( + w(g_at(0.0, off_b), y0), + w(g_at(0.0, off_a), y0), + w(g_at(0.0, off_b), y1), + ); + let start_gaps: Vec<(f32, f32)> = hs + .iter() + .filter(|(a0, _, _, _)| *a0 <= MIN_SPAN) + .map(|(_, _, zb, zt)| (*zb, *zt)) + .collect(); + for (za, zb) in subtract_intervals(y0, y1, &start_gaps) { + push_quad_oriented( + mesh, + w(g_at(0.0, off_b), za), + w(g_at(0.0, off_a), za), + w(g_at(0.0, off_a), zb), + w(g_at(0.0, off_b), zb), + start_normal, + color, + ); + } + let end_normal = quad_normal( + w(g_at(length, off_a), y0), + w(g_at(length, off_b), y0), + w(g_at(length, off_a), y1), + ); + let end_gaps: Vec<(f32, f32)> = hs + .iter() + .filter(|(_, a1, _, _)| *a1 >= length - MIN_SPAN) + .map(|(_, _, zb, zt)| (*zb, *zt)) + .collect(); + for (za, zb) in subtract_intervals(y0, y1, &end_gaps) { + push_quad_oriented( + mesh, + w(g_at(length, off_a), za), + w(g_at(length, off_b), za), + w(g_at(length, off_b), zb), + w(g_at(length, off_a), zb), + end_normal, + color, + ); + } + + // ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ────────── + for &(a0, a1, zb, zt) in &hs { + // Linke Laibung (bei a0), Normale +u (in die Oeffnung). + if a0 > MIN_SPAN { + push_quad_oriented( + mesh, + w(g_at(a0, off_a), zb), + w(g_at(a0, off_b), zb), + w(g_at(a0, off_b), zt), + w(g_at(a0, off_a), zt), + [u[0], 0.0, u[1]], + color, + ); + } + // Rechte Laibung (bei a1), Normale −u. + if a1 < length - MIN_SPAN { + push_quad_oriented( + mesh, + w(g_at(a1, off_a), zb), + w(g_at(a1, off_b), zb), + w(g_at(a1, off_b), zt), + w(g_at(a1, off_a), zt), + [-u[0], 0.0, -u[1]], + color, + ); + } + // Untere Laibung (bei zb = Bruestungsoberkante), Normale +Y. + if zb > y0 + MIN_SPAN { + push_quad_oriented( + mesh, + w(g_at(a0, off_a), zb), + w(g_at(a1, off_a), zb), + w(g_at(a1, off_b), zb), + w(g_at(a0, off_b), zb), + [0.0, 1.0, 0.0], + color, + ); + } + // Obere Laibung (bei zt = Sturzunterkante), Normale −Y. + if zt < y1 - MIN_SPAN { + push_quad_oriented( + mesh, + w(g_at(a0, off_a), zt), + w(g_at(a1, off_a), zt), + w(g_at(a1, off_b), zt), + w(g_at(a0, off_b), zt), + [0.0, -1.0, 0.0], + 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 diff --git a/src-tauri/render3d/src/section.rs b/src-tauri/render3d/src/section.rs index d37fc32..f6a2b1a 100644 --- a/src-tauri/render3d/src/section.rs +++ b/src-tauri/render3d/src/section.rs @@ -1016,6 +1016,7 @@ mod tests { color, openings: vec![], layers: None, + holes: vec![], }, WallInput { start: [0.0, 3.0], @@ -1026,6 +1027,7 @@ mod tests { color, openings: vec![], layers: None, + holes: vec![], }, ]; let slabs = vec![SlabInput { @@ -1282,6 +1284,7 @@ mod tests { height: 1.0, }], layers: None, + holes: vec![], } } @@ -1367,6 +1370,7 @@ mod tests { color: [0.8, 0.8, 0.8], openings: vec![], layers: None, + holes: vec![], }; // 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 22f6dc8..1a6e5e6 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -66,6 +66,46 @@ pub struct WallInput { /// Verhalten unveraendert). #[serde(default)] pub layers: Option>, + /// Rechteckige LOECHER (Fenster/Tueren) als ECHTE Aussparungen in EINEM + /// Wandkoerper (siehe `Hole` + `mesh::extrude_wall*`). Default leer. + /// + /// Anders als `openings` zerlegt das die Wand NICHT in Pfeiler/Bruestung/ + /// Sturz-Teilquader, sondern stanzt die Loecher in die beiden Langseiten + /// (Rechteck-Gitter-Zerlegung + Laibungsflaechen). So bildet EIN Koerper + /// beliebig (auch versetzt uebereinander) angeordnete Oeffnungen ab — das + /// kann die Segment-Zerlegung nicht. Genutzt vom 3D-Viewer-Pfad der + /// TS-Ableitung (`toWalls3d`, `layeredWalls:true`); der Schnitt-Pfad + /// (`layeredWalls:false`) bleibt bei `openings`/Segmentierung. + /// + /// `holes` und `openings` schliessen sich im TS-Emitter gegenseitig aus; ist + /// `holes` gesetzt, ueberspringt `mesh.rs` die Oeffnungs-Segmentierung und + /// extrudiert den vollen Achsen-Abschnitt mit Loechern. + #[serde(default)] + pub holes: Vec, +} + +/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe +/// `WallInput::holes`. +/// +/// KONVENTION: `from`/`to` sind Achsen-Meter ab `WallInput::start` (0..Wandlaenge, +/// dieselbe Achse wie `Opening::from`/`to`); `z_bottom`/`z_top` sind ABSOLUTE +/// Weltmeter (NICHT relativ zu `base_elevation` — anders als `Opening::sill`). +/// Eine Tuer ist der Sonderfall `z_bottom == base_elevation` (Loch bis zur +/// Wand-Unterkante). Loecher liegen definitionsgemaess im Wand-INNEREN und +/// beruehren die gemiterten Enden nicht — die Gehrung (`compute_wall_miters`) +/// bleibt davon unberuehrt (siehe `mesh`-Moduldoc). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub struct Hole { + /// Start des Loch-Intervalls entlang der Wandachse (Meter ab `start`). + pub from: f32, + /// Ende des Loch-Intervalls entlang der Wandachse (Meter ab `start`). + pub to: f32, + /// Absolute Unterkante des Lochs (Weltmeter). + #[serde(default, rename = "zBottom")] + pub z_bottom: f32, + /// Absolute Oberkante des Lochs (Weltmeter). + #[serde(default, rename = "zTop")] + pub z_top: f32, } /// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`. diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index b8d9c5d..dcaa8db 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -63,12 +63,96 @@ describe("selectionHighlightLines", () => { expect(two.length).toBe(one.length * 2); }); - it("eine Wand MIT Öffnungen -> mehrere Teilquader (mehr als 12 Kanten)", () => { - // W1 trägt Tür D1 + Fenster O1 -> Pfeiler/Brüstung/Sturz-Segmente, also - // ein Vielfaches von 12 Kanten (mehr als ein einzelner Quader). - const lines = selectionHighlightLines(sampleProject, ["W1"], [], RGB); - expect(lines.length).toBeGreaterThan(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); - expect(lines.length % (12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX)).toBe(0); + it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => { + // NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird + // NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro + // Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher + // genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele + // Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches + // durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.) + const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB); + const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB); + expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); + expect(w1.length).toBe(w2.length); + }); +}); + +describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:true)", () => { + // Wand W1 (EG, Wandtyp "aw" = 4 Schichten) mit ZWEI Fenstern, deren Achsen- + // Intervalle sich ÜBERLAPPEN, aber auf UNTERSCHIEDLICHER Höhe liegen: + // Fenster A: u=[1.0,2.0] (position 1.0, width 1.0), z=[0.9,2.1] (sill 0.9, h 1.2) + // Fenster B: u=[1.5,2.5] (position 1.5, width 1.0), z=[0.3,0.7] (sill 0.3, h 0.4) + // Die alte Segment-Zerlegung kann diesen versetzt-überlappenden Fall nicht + // abbilden; als Löcher in EINEM Körper müssen beide sauber als `holes` + // erscheinen. W1: start (0,0)->(5,0), floor "eg" (baseElevation 0). + const twoOffsetWindows: Project = { + ...sampleProject, + doors: [], + openings: [ + { + id: "WA", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + position: 1.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + { + id: "WB", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + position: 1.5, + width: 1.0, + height: 0.4, + sillHeight: 0.3, + }, + ], + }; + + it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => { + const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1"); + // Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen. + expect(w1.length).toBe(4); + for (const body of w1) { + // Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper). + expect(body.holes).toBeDefined(); + expect(body.holes!.length).toBe(2); + const byFrom = [...body.holes!].sort((a, b) => a.from - b.from); + // Fenster A: u=[1,2], z=[0.9,2.1]. + expect(byFrom[0].from).toBeCloseTo(1.0, 6); + expect(byFrom[0].to).toBeCloseTo(2.0, 6); + expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6); + expect(byFrom[0].zTop).toBeCloseTo(2.1, 6); + // Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer. + expect(byFrom[1].from).toBeCloseTo(1.5, 6); + expect(byFrom[1].to).toBeCloseTo(2.5, 6); + expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6); + expect(byFrom[1].zTop).toBeCloseTo(0.7, 6); + } + }); + + it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => { + const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1"); + // Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr + // Boxen als die 4 Schicht-Bänder des 3D-Pfads) und setzt NIE `holes`. + expect(w1.length).toBeGreaterThan(4); + for (const seg of w1) expect(seg.holes).toBeUndefined(); + }); + + it("Tür = Loch bis zur Wand-UK (zBottom == baseElevation)", () => { + // Eine Tür (D1 auf W1) muss im 3D-Pfad als Loch bis zum Boden erscheinen: + // zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung. + const p: Project = { ...sampleProject, openings: [] }; + const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1"); + expect(w1.length).toBe(4); + const door = w1[0].holes?.find((h) => h.zBottom < 1e-6); + expect(door).toBeDefined(); + expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK. }); }); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 5549379..abcf51d 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -59,6 +59,24 @@ export interface RLayer { color: RRgb; } +/** + * Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper — matcht 1:1 + * render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab + * `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle); + * `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders + * als `Opening.sillHeight`). Eine Tür ist der Sonderfall + * `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die + * Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter- + * Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu + * zerlegen. + */ +export interface RHole { + from: number; + to: number; + zBottom: number; + zTop: number; +} + export interface RWall { start: RVec2; end: RVec2; @@ -79,6 +97,19 @@ export interface RWall { * serde verwirft unbekannte Felder). */ wallId: string; + /** + * Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe + * {@link RHole}). Nur im 3D-Viewer-Pfad ({@link projectToModel3d} mit + * `layeredWalls:true`) gesetzt: dort emittiert {@link emitWall} EINEN RWall pro + * Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der + * Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt + * übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der + * Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert + * unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/ + * leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes` + * und die Segment-Zerlegung schliessen sich damit gegenseitig aus. + */ + holes?: RHole[]; } /** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */ @@ -224,6 +255,13 @@ function resolveWallBands(project: Project, wall: Wall, layered = true): WallBan * mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0) * werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne * Nullquader. + * + * Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe + * {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe + * Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides + * unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur + * senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur + * im 3D-Pfad (voller Achsen-Quader) gesetzt. */ function pushSegment( out: RWall[], @@ -236,6 +274,7 @@ function pushSegment( normalOffset: number, color: RRgb, wallId: string, + holes?: RHole[], ): void { if (to - from <= EPS) return; if (zTop - zBottom <= EPS) return; @@ -269,6 +308,7 @@ function pushSegment( color, layers: [], wallId, + ...(holes && holes.length > 0 ? { holes } : {}), }); } @@ -422,11 +462,31 @@ function trimWallTopForCeilings( return Math.max(zBottom, top); } -/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */ +/** + * Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen). + * + * ZWEI PFADE je `layered`: + * • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die + * VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in + * die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in + * EINEM Körper (keine Segment-Nähte). + * • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/ + * Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die + * `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners` + * passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher + * werden hier NICHT emittiert. + */ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void { const bands = resolveWallBands(project, wall, layered); const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall); - const zTop = trimWallTopForCeilings(project, wall, zBottom, rawZTop); + // Decken-Trim (kappt die Wand an der Unterkante einer dominanteren Decke) NUR + // im 3D-Viewer-Pfad (layered=true): dort vermeidet das Kappen das Z-Fighting + // Wand/Decke am bündigen Anschluss. Der Schnitt-Pfad (layered=false) will die + // UNGEKAPPTE volle Wandhöhe, weil er seine EIGENE prioritätsbasierte Schicht- + // Dominanz (toSection.ts::subtractDominantBands) auf Rohgeometrie anwendet — + // ein vorab gekürzter Wandkopf würde dort Schichteinzug/Bodenaufbau am Wand- + // Decken-Anschluss verfälschen. + const zTop = layered ? trimWallTopForCeilings(project, wall, zBottom, rawZTop) : rawZTop; const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; @@ -461,8 +521,27 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v } cutouts.sort((a, b) => a.from - b.from); - // Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber - // in Lagen aufgeteilt). + // ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle + // Achse mit allen Öffnungen als echte Löcher. Die Segment-Zerlegung entfällt + // komplett — render3d stanzt die Löcher (Rechteck-Gitter + Laibungen). Türen + // sind Löcher bis zur Wand-UK (`oBottom == zBottom`), Fenster ab Brüstung. So + // bildet EIN Körper auch versetzt übereinanderliegende Fenster ab. + if (layered) { + const holes: RHole[] = cutouts.map((c) => ({ + from: c.from, + to: c.to, + zBottom: c.oBottom, + zTop: c.oTop, + })); + for (const b of bands) { + pushSegment(out, wall, 0, axisLen, zBottom, zTop, b.thickness, b.offset, b.color, wall.id, holes); + } + return; + } + + // ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz- + // Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners). + // Ohne Aussparungen: ein durchgehender Quader je Schicht. if (cutouts.length === 0) { emitSeg(0, axisLen, zBottom, zTop); return;