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
+99 -1
View File
@@ -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};