render3d: Öffnungen im 3D-Vollkörper (mesh.rs) — geteilte Zerlegung mit section.rs

- 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.
This commit is contained in:
2026-07-03 18:47:40 +02:00
parent e3f460c9f8
commit d2503c1191
4 changed files with 283 additions and 48 deletions
+69
View File
@@ -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
}