From 5a10ea5404dfdb5eee426d37da498699142d731d Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 18:47:40 +0200 Subject: [PATCH] =?UTF-8?q?render3d:=20=C3=96ffnungen=20im=203D-Vollk?= =?UTF-8?q?=C3=B6rper=20(mesh.rs)=20=E2=80=94=20geteilte=20Zerlegung=20mit?= =?UTF-8?q?=20section.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openings.rs (neu): split_range_by_voids() — die bisher nur in section.rs liegende Pfeiler/Brüstung/Sturz-Zerlegung, herausgezogen als koordinaten- neutraler Helfer. section.rs und mesh.rs teilen jetzt EIN Öffnungsmodell. - section.rs: wall_cut_rectangles delegiert den Sortier-/Rechteck-Teil an openings::split_range_by_voids (plane-spezifisches Clipping bleibt lokal). - mesh.rs: extrude_wall emittiert nicht mehr einen Vollblock je Wand, sondern Teilkörper (Pfeiler in voller Höhe + Brüstung/Sturz je Öffnung) → echtes Loch; Tür (sill==0) lässt die Brüstung weg und reicht bis zum Boden. Ohne Öffnung exakt EIN Block, byte-identische Geometrie zu vorher (Alt-Tests unverändert grün). Gates: cargo test 32/32 (29+3 neue), wasm32 --features web check grün, nativ grün. --- src-tauri/render3d/src/lib.rs | 100 ++++++++++++++++++++++- src-tauri/render3d/src/mesh.rs | 125 ++++++++++++++++++++++++----- src-tauri/render3d/src/openings.rs | 69 ++++++++++++++++ src-tauri/render3d/src/section.rs | 37 +++------ 4 files changed, 283 insertions(+), 48 deletions(-) create mode 100644 src-tauri/render3d/src/openings.rs diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 2868f45..56a57d9 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -16,6 +16,7 @@ pub mod math; pub mod mesh; +pub(crate) mod openings; pub mod section; pub mod shaders; pub mod types; @@ -46,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, WallInput, FLOATS_PER_VERTEX}; + use super::types::{Mesh, Opening, WallInput, FLOATS_PER_VERTEX}; /// Bequemer Bau einer achsparallelen Wand entlang +X. fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput { @@ -197,6 +198,103 @@ mod tests { assert!(mesh.indices.is_empty()); } + // --- Oeffnungen (Fenster/Tueren) in der Vollkoerper-Extrusion ------------- + + #[test] + fn fenster_erzeugt_pfeiler_bruestung_sturz_und_sichtbare_luecke() { + // Wand entlang +X, Laenge 4, ein Fenster bei x in [1.5,2.5], Bruestung + // 0.9, Kopfhoehe 2.1 (< Wandhoehe 2.5) -> vier Teil-Quader: Pfeiler + // 0..1.5 (voll), Bruestung 1.5..2.5 (0..0.9), Sturz 1.5..2.5 (2.1..2.5), + // Pfeiler 2.5..4 (voll). Kein Vollkoerper mehr ueber die Oeffnung. + let mut w = wall_x(4.0, 0.2, 2.5); + w.openings = vec![Opening { + from: 1.5, + to: 2.5, + sill: 0.9, + height: 1.2, + }]; + let mesh = build_walls_mesh(&[w]); + assert_eq!( + mesh.vertex_count(), + 4 * VERTS_PER_BOX, + "vier Teil-Quader (2 Pfeiler + Bruestung + Sturz)" + ); + assert_eq!(mesh.indices.len(), 4 * INDICES_PER_BOX); + let max_idx = *mesh.indices.iter().max().unwrap(); + assert!((max_idx as usize) < mesh.vertex_count()); + + // Aussenmasse bleiben unveraendert (Pfeiler+Bruestung+Sturz decken + // zusammen wieder die volle Bounding-Box ab). + let (min, max) = mesh.bounds(); + assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5); + assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5); + + // Sichtbare Luecke: kein Vertex liegt (mit Sicherheitsabstand) im + // Oeffnungs-Loch selbst (x in (1.5,2.5) UND y in (0.9,2.1)) — dort darf + // keine Mesh-Flaeche mehr existieren. + let margin = 0.05; + let n = mesh.vertex_count(); + for i in 0..n { + let (p, _) = vert(&mesh, i); + let inside_hole = p[0] > 1.5 + margin + && p[0] < 2.5 - margin + && p[1] > 0.9 + margin + && p[1] < 2.1 - margin; + assert!(!inside_hole, "Vertex {i} bei {p:?} liegt im Fensterloch"); + } + } + + #[test] + fn tuer_erreicht_den_boden_keine_bruestung() { + // Tuer (sill == 0.0) reicht bis zum Boden -> keine Bruestung, nur zwei + // volle Pfeiler + ein Sturz-Quader ueber der Tuer (drei Teil-Quader). + let mut w = wall_x(4.0, 0.2, 2.5); + w.openings = vec![Opening { + from: 1.0, + to: 1.8, + sill: 0.0, + height: 2.1, + }]; + let mesh = build_walls_mesh(&[w]); + assert_eq!( + mesh.vertex_count(), + 3 * VERTS_PER_BOX, + "zwei Pfeiler + ein Sturz, keine Bruestung" + ); + assert_eq!(mesh.indices.len(), 3 * INDICES_PER_BOX); + + // Im Tuerbereich (x in (1.0,1.8)) darf unterhalb der Kopfhoehe (2.1) + // keine Geometrie liegen -> jeder Vertex dort liegt auf/ueber dem Sturz. + let margin = 0.05; + let n = mesh.vertex_count(); + for i in 0..n { + let (p, _) = vert(&mesh, i); + let in_door_span = p[0] > 1.0 + margin && p[0] < 1.8 - margin; + if in_door_span { + assert!( + p[1] > 2.1 - 1e-4, + "Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe" + ); + } + } + } + + #[test] + fn oeffnung_ausserhalb_der_wandlaenge_erzeugt_vollkoerper() { + // Oeffnung komplett ausserhalb [0,length] -> Regressionsfall: ein + // einzelner Vollkoerper-Quader wie ohne Oeffnungen. + let mut w = wall_x(4.0, 0.2, 2.5); + w.openings = vec![Opening { + from: 5.0, + to: 6.0, + sill: 0.9, + height: 1.2, + }]; + let mesh = build_walls_mesh(&[w]); + assert_eq!(mesh.vertex_count(), VERTS_PER_BOX); + assert_eq!(mesh.indices.len(), INDICES_PER_BOX); + } + // --- 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 9a0779a..6900ed1 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -9,13 +9,24 @@ // `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`) und Oeffnungen kommen in spaeteren Milestones. +// (`computeJoins`/`clippedBand`) sind ein spaeterer Milestone. +// +// OEFFNUNGEN (Tueren/Fenster, `WallInput::openings`): eine Wand mit Oeffnungen +// wird NICHT als ein Vollkoerper-Quader extrudiert, sondern in mehrere +// Teil-Quader zerlegt — volle Hoehe zwischen Oeffnungen (Pfeiler), Bruestung +// unterhalb und Sturz oberhalb jeder Oeffnung (`wall_solid_segments`). Das ist +// exakt dieselbe Bruestungs-/Sturz-Zerlegung wie `section::wall_cut_rectangles` +// 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. // // 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 crate::types::{Mesh, Point2, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX}; +use crate::openings::{split_range_by_voids, MIN_SPAN}; +use crate::types::{Mesh, Opening, Point2, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX}; /// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2 /// Dreiecken = 12 Dreiecke, mit flachen Normalen also 24 Vertices (je Seite 4, @@ -35,30 +46,101 @@ fn left_normal(start: Point2, end: Point2) -> Point2 { [-dy / len, dx / len] } -/// Extrudiert EINE Wand zu einem Quader-Mesh und haengt sie an `mesh` an. -/// -/// 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. +/// 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). pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) { let n = left_normal(wall.start, wall.end); // Degenerierte Wand (Start == Ende) ueberspringen. if n == [0.0, 0.0] { return; } + let dx = wall.end[0] - wall.start[0]; + let dy = wall.end[1] - wall.start[1]; + let length = (dx * dx + dy * dy).sqrt(); + + 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); + } +} + +/// Zerlegt eine Wandachse (`0..length`, volle Hoehe `[z0,z1]`) an ihren +/// `openings` in Voll-/Bruestungs-/Sturz-Rechtecke `(achse0, achse1, y0, y1)` — +/// direkt in Wandachsen-Metern, keine Schnittebenen-Projektion noetig (anders +/// als `section::wall_cut_rectangles`, das dieselbe Zerlegung fuer eine +/// Cut-Ebenen-u-Achse braucht). Ohne Oeffnungen (oder wenn alle ausserhalb +/// `[0,length]` liegen) liefert das das eine Vollstueck `[0,length]x[z0,z1]` +/// (Regressionsfall, identisch zur bisherigen Vollkoerper-Extrusion). +fn wall_solid_segments( + length: f32, + openings: &[Opening], + z0: f32, + z1: f32, +) -> Vec<(f32, f32, f32, f32)> { + if openings.is_empty() { + return vec![(0.0, length, z0, z1)]; + } + let mut voids: Vec<(f32, f32, &Opening)> = Vec::new(); + for op in openings { + let from = op.from.max(0.0); + let to = op.to.min(length); + if to - from > MIN_SPAN { + voids.push((from, to, op)); + } + } + split_range_by_voids(0.0, length, z0, z1, voids) +} + +/// Punkt entlang der Wandachse bei Achsen-Position `a` (Meter ab `wall.start`), +/// linear zwischen `wall.start` und `wall.end` interpoliert. +fn axis_point(wall: &WallInput, length: f32, a: f32) -> Point2 { + if length < 1e-9 { + return wall.start; + } + let t = a / length; + [ + wall.start[0] + (wall.end[0] - wall.start[0]) * t, + wall.start[1] + (wall.end[1] - wall.start[1]) * t, + ] +} + +/// Extrudiert EIN Achsen-Teilstueck `[a0,a1]` (Hoehe `[y0,y1]`) 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. +/// +/// 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( + mesh: &mut Mesh, + wall: &WallInput, + n: Point2, + length: f32, + a0: f32, + a1: f32, + y0: f32, + y1: f32, +) { let h = wall.thickness * 0.5; - // Vier Band-Ecken im Grundriss (CCW, wie `wallCorners(start,end,thickness)`): - // p0 = start + n*h, p1 = end + n*h, p2 = end - n*h, p3 = start - n*h. + // 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 g0 = off(wall.start, 1.0); - let g1 = off(wall.end, 1.0); - let g2 = off(wall.end, -1.0); - let g3 = off(wall.start, -1.0); + 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 y0 = wall.base_elevation; - let y1 = wall.base_elevation + wall.height; let color = wall.color; // world-Position aus Grundriss-XZ + Hoehe: (x, y, z=grundriss.y). @@ -83,10 +165,13 @@ pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) { // 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 Start (g3->g0) und Ende (g1->g2) liegen an den Achsen-Enden. + // 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 (fuer die End-Kappen-Normalen). + // 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); @@ -97,9 +182,9 @@ pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) { 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 (Achsen-Ende, Normale +u): b1, b2, t2, t1. + // End-Kappe bei a1, Normale +u: b1, b2, t2, t1. push_quad(mesh, b1, b2, t2, t1, u, color); - // Start-Kappe (Achsen-Anfang, Normale -u): b3, b0, t0, t3. + // End-Kappe bei a0, Normale -u: b3, b0, t0, t3. push_quad(mesh, b3, b0, t0, t3, neg_u, color); } diff --git a/src-tauri/render3d/src/openings.rs b/src-tauri/render3d/src/openings.rs new file mode 100644 index 0000000..a5e03fc --- /dev/null +++ b/src-tauri/render3d/src/openings.rs @@ -0,0 +1,69 @@ +//! Gemeinsame Rechteck-Zerlegung fuer Wand-Oeffnungen (Fenster/Tueren). +//! +//! Kern-Idee: ein Achsen-Intervall voller Hoehe (`[z0, z1]`) wird an den darin +//! liegenden Oeffnungen in Voll-/Bruestungs-/Sturz-Rechtecke gesplittet — +//! Bruestung `[z0, sill]` unterhalb, Sturz `[sill+height, z1]` oberhalb der +//! Oeffnung, dazwischen eine Luecke (das Loch). Diese Zerlegung ist +//! koordinatenraum-neutral: sie braucht nur ein 1D-Achsen-Intervall plus die +//! Oeffnungen als bereits geclippte `(v_lo, v_hi, &Opening)`-Teilstuecke in +//! DEMSELBEN Koordinatenraum wie `lo`/`hi`. +//! +//! Zwei Aufrufer, zwei Koordinatenraeume: +//! - `section::wall_cut_rectangles`: Cut-Ebene — die Achsen-Koordinate ist die +//! Projektions-u-Achse der Schnittebene (`axis_map` rechnet Wandachsen-Meter +//! dorthin um). +//! - `mesh::wall_solid_segments`: 3D-Solid-Extrusion — die Achsen-Koordinate +//! ist direkt die Wandachse in Metern (0..Wandlaenge), keine Projektion +//! noetig. +//! +//! Beide nutzen `split_range_by_voids` fuer die eigentliche Bruestungs-/ +//! Sturz-Logik, damit Schnitt-Ansicht und 3D-Solid-Mesh garantiert dasselbe +//! Oeffnungsmodell (siehe `types::Opening`) auswerten statt zweier +//! divergierender Implementierungen. + +use crate::types::Opening; + +/// Mindestlaenge eines Oeffnungs-/Rest-Teilstuecks, unterhalb derer es als +/// vernachlaessigbar (Rand-/Rundungsfall) ignoriert wird. +pub(crate) const MIN_SPAN: f32 = 1e-4; + +/// Zerlegt EIN Achsen-Intervall `[lo, hi]` (volle Hoehe `[z0, z1]`) an den in +/// `voids` uebergebenen, bereits auf `[lo,hi]` geclippten Oeffnungs- +/// Teilstuecken (`(v_lo, v_hi, opening)`, Reihenfolge beliebig) in Rechtecke +/// `(u0, u1, v0, v1)`: volle Hoehe ausserhalb von Oeffnungen, Bruestung +/// darunter, Sturz darueber (siehe `Opening::sill`/`Opening::height`). Ohne +/// Oeffnungen (leeres `voids`) liefert es das eine Vollrechteck `[lo,hi]x[z0,z1]` +/// (Regressionsfall). +pub(crate) fn split_range_by_voids( + lo: f32, + hi: f32, + z0: f32, + z1: f32, + mut voids: Vec<(f32, f32, &Opening)>, +) -> Vec<(f32, f32, f32, f32)> { + if voids.is_empty() { + return vec![(lo, hi, z0, z1)]; + } + voids.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + let mut rects = Vec::new(); + let mut cursor = lo; + for (v_lo, v_hi, op) in &voids { + if *v_lo > cursor + MIN_SPAN { + rects.push((cursor, *v_lo, z0, z1)); // Pfeiler zwischen Oeffnungen + } + let sill_abs = z0 + op.sill.max(0.0); + let head_abs = (sill_abs + op.height).min(z1); + if sill_abs - z0 > MIN_SPAN { + rects.push((*v_lo, *v_hi, z0, sill_abs)); // Bruestung + } + if z1 - head_abs > MIN_SPAN { + rects.push((*v_lo, *v_hi, head_abs, z1)); // Sturz + } + cursor = cursor.max(*v_hi); + } + if hi > cursor + MIN_SPAN { + rects.push((cursor, hi, z0, z1)); // Rest-Pfeiler nach der letzten Oeffnung + } + rects +} diff --git a/src-tauri/render3d/src/section.rs b/src-tauri/render3d/src/section.rs index dd08899..65abd99 100644 --- a/src-tauri/render3d/src/section.rs +++ b/src-tauri/render3d/src/section.rs @@ -107,11 +107,11 @@ //! 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. -//! - Oeffnungen werden hier (Cut + Projektion) ausgewertet, aber NICHT von der -//! Vollkoerper-Extrusion in `mesh.rs` — die 3D-Solid-Mesh-Erzeugung extrudiert -//! weiterhin die volle Wandflaeche (siehe `mesh.rs`-Moduldoc). Cut-Ansicht und -//! 3D-Solid-Ansicht sind bis zu diesem Nachtrag also bewusst inkonsistent; -//! das Nachziehen von `mesh.rs` ist eine bekannte, separate Luecke. +//! - 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:: +//! split_range_by_voids`), damit Cut-Ansicht und 3D-Solid-Ansicht +//! konsistent bleiben. //! - Oeffnungs-Durchblick nutzt dieselbe achsparallele Bounding-Box-Naeherung //! wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche //! gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe @@ -508,28 +508,11 @@ fn wall_cut_rectangles( if voids.is_empty() { return vec![(u_lo, u_hi, prism.z0, prism.z1)]; } - voids.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - - let mut rects = Vec::new(); - let mut cursor = u_lo; - for (v_lo, v_hi, op) in &voids { - if *v_lo > cursor + MIN_SPAN { - rects.push((cursor, *v_lo, prism.z0, prism.z1)); - } - let sill_abs = prism.z0 + op.sill.max(0.0); - let head_abs = (sill_abs + op.height).min(prism.z1); - if sill_abs - prism.z0 > MIN_SPAN { - rects.push((*v_lo, *v_hi, prism.z0, sill_abs)); // Bruestung - } - if prism.z1 - head_abs > MIN_SPAN { - rects.push((*v_lo, *v_hi, head_abs, prism.z1)); // Sturz - } - cursor = cursor.max(*v_hi); - } - if u_hi > cursor + MIN_SPAN { - rects.push((cursor, u_hi, prism.z0, prism.z1)); - } - rects + // Bruestungs-/Sturz-Zerlegung ist ab hier koordinatenraum-neutral (nur noch + // u-Intervall + Hoehe + geclippte Voids) -> gemeinsame Basis mit + // `mesh::wall_solid_segments` (siehe `crate::openings`-Moduldoc), damit + // Cut-Ansicht und 3D-Solid-Mesh garantiert dasselbe Oeffnungsmodell nutzen. + crate::openings::split_range_by_voids(u_lo, u_hi, prism.z0, prism.z1, voids) } // ─────────────────────────────────────────────────────────────────────────────