Nordstern-3D: Fenster/Tueren als echte Boolean-Loecher in EINEM Wandkoerper

Bisher zerlegte emitWall eine Wand um jede Oeffnung in Pfeiler/Bruestung/Sturz-
Teilquader -> sichtbare Segment-Naehte im 3D, Oeffnung war semantisch kein Loch,
und versetzt uebereinanderliegende Fenster liessen sich gar nicht abbilden. Neu:
im 3D-Pfad (layered=true) EIN RWall pro Schicht-Band ueber die volle Achse mit
allen Oeffnungen als rechteckige holes; render3d stanzt sie per achsparalleler
Rechteck-Gitter-Zerlegung der Langseiten aus und setzt 4 Laibungsquads je Loch.
holes und openings schliessen sich im Emitter gegenseitig aus. Tuer = Loch bis
zum Wand-zBottom. Schnitt-Pfad (layered=false) segmentiert unveraendert weiter
(wallSegmentOwners bleibt synchron, toSection.ts unangetastet).

Zusatz: trimWallTopForCeilings (Deckentrim, e4b8df6) gilt jetzt NUR im 3D-Pfad
(layered=true, Z-Fighting-Vermeidung) und wird im Schnitt-Pfad uebersprungen -
der Schnitt braucht die volle Wandhoehe fuer seine schichtweise Prioritaets-
Subtraktion (Kontrolle von Schichteinzug/Bodenaufbau am Wand-Decken-Anschluss).

RWall.holes / WallInput.holes additiv (#[serde(default)]). Pflicht-Testfaelle:
zwei ueberlappende, hoehenversetzte Fenster in einem Koerper; Tuer-Loch bis Boden
ohne untere Laibung. cargo test 56 gruen, vitest 230 (+3), build:engine3d + tsc
sauber.
This commit is contained in:
2026-07-04 22:17:21 +02:00
parent 87a30b7061
commit 05cfade483
8 changed files with 670 additions and 17 deletions
@@ -32,6 +32,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
height: 1.0, height: 1.0,
}], }],
layers: None, layers: None,
holes: vec![],
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -42,6 +43,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im // also groessere Tiefe von der Betrachter-Ebene aus), genau im
@@ -57,6 +59,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
]; ];
let slabs = vec![SlabInput { let slabs = vec![SlabInput {
+1
View File
@@ -38,6 +38,7 @@ fn demo_walls() -> Vec<WallInput> {
color: grey, color: grey,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
// Raum 6 x 4 m. // Raum 6 x 4 m.
vec![ vec![
+101 -1
View File
@@ -54,7 +54,7 @@ pub use types::{
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX}; 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. /// Bequemer Bau einer achsparallelen Wand entlang +X.
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput { fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
@@ -67,6 +67,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
} }
} }
@@ -104,6 +105,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
]); ]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -182,6 +184,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w); extrude_wall(&mut mesh, &w);
@@ -203,6 +206,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let mesh = build_walls_mesh(&[w]); let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0); assert_eq!(mesh.vertex_count(), 0);
@@ -306,6 +310,97 @@ mod tests {
assert_eq!(mesh.indices.len(), INDICES_PER_BOX); 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) ----------------------------------------- // --- Ecken-Verschneidung (Gehrung) -----------------------------------------
#[test] #[test]
@@ -325,6 +420,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let b = WallInput { let b = WallInput {
start: [0.0, 0.0], start: [0.0, 0.0],
@@ -335,6 +431,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let mesh = build_walls_mesh(&[a, b]); let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader"); 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], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let walls = vec![ let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]), mk([0.0, 0.0], [3.0, 0.0]),
@@ -422,6 +520,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
let walls = vec![ let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0), mk([0.0, 0.0], [3.0, 0.0], 0.0),
@@ -510,6 +609,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: layers(), layers: layers(),
holes: vec![],
}; };
let mut b = a.clone(); let mut b = a.clone();
b.end = [0.0, 3.0]; b.end = [0.0, 3.0];
+344 -2
View File
@@ -59,7 +59,8 @@ use std::collections::HashMap;
use crate::openings::{split_range_by_voids, MIN_SPAN}; use crate::openings::{split_range_by_voids, MIN_SPAN};
use crate::types::{ 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 /// 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 z0 = wall.base_elevation;
let z1 = wall.base_elevation + wall.height; 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 // Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus
// der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten. // der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten.
@@ -139,11 +149,21 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
// Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig). // Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig).
let start_cut = if a0 <= 1e-6 { miters.start } else { None }; let start_cut = if a0 <= 1e-6 { miters.start } else { None };
let end_cut = if a1 >= length - 1e-6 { miters.end } else { None }; let end_cut = if a1 >= length - 1e-6 { miters.end } else { None };
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( extrude_layer_segment(
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut, mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
layer.color, layer.color,
); );
} }
}
off_a = off_b; off_a = off_b;
} }
} }
@@ -284,6 +304,328 @@ fn extrude_layer_segment(
push_quad(mesh, b3, b0, t0, t3, start_normal, color); 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<f32>| -> Vec<f32> {
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<MiterLine>,
end_cut: Option<MiterLine>,
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<MiterLine>| -> 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)` /// 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` /// (nur die drei ersten Ecken werden gebraucht: `d` ist die vierte, ueber `a`
/// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe /// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe
+4
View File
@@ -1016,6 +1016,7 @@ mod tests {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -1026,6 +1027,7 @@ mod tests {
color, color,
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}, },
]; ];
let slabs = vec![SlabInput { let slabs = vec![SlabInput {
@@ -1282,6 +1284,7 @@ mod tests {
height: 1.0, height: 1.0,
}], }],
layers: None, layers: None,
holes: vec![],
} }
} }
@@ -1367,6 +1370,7 @@ mod tests {
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![],
}; };
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter. // Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0); let plane = SectionPlane::looking_plus_y(-2.0);
+40
View File
@@ -66,6 +66,46 @@ pub struct WallInput {
/// Verhalten unveraendert). /// Verhalten unveraendert).
#[serde(default)] #[serde(default)]
pub layers: Option<Vec<WallLayer>>, pub layers: Option<Vec<WallLayer>>,
/// 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<Hole>,
}
/// 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`. /// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`.
+90 -6
View File
@@ -63,12 +63,96 @@ describe("selectionHighlightLines", () => {
expect(two.length).toBe(one.length * 2); expect(two.length).toBe(one.length * 2);
}); });
it("eine Wand MIT Öffnungen -> mehrere Teilquader (mehr als 12 Kanten)", () => { it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => {
// W1 trägt Tür D1 + Fenster O1 -> Pfeiler/Brüstung/Sturz-Segmente, also // NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
// ein Vielfaches von 12 Kanten (mehr als ein einzelner Quader). // NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
const lines = selectionHighlightLines(sampleProject, ["W1"], [], RGB); // Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
expect(lines.length).toBeGreaterThan(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); // genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
expect(lines.length % (12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX)).toBe(0); // 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.
}); });
}); });
+83 -4
View File
@@ -59,6 +59,24 @@ export interface RLayer {
color: RRgb; 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 { export interface RWall {
start: RVec2; start: RVec2;
end: RVec2; end: RVec2;
@@ -79,6 +97,19 @@ export interface RWall {
* serde verwirft unbekannte Felder). * serde verwirft unbekannte Felder).
*/ */
wallId: string; 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. */ /** 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) * mit Schicht-`thickness`/`color`. Entartete Stücke (Länge 0 oder Höhe 0)
* werden übersprungen so überleben Öffnungen dicht an den Wandenden ohne * werden übersprungen so überleben Öffnungen dicht an den Wandenden ohne
* Nullquader. * 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( function pushSegment(
out: RWall[], out: RWall[],
@@ -236,6 +274,7 @@ function pushSegment(
normalOffset: number, normalOffset: number,
color: RRgb, color: RRgb,
wallId: string, wallId: string,
holes?: RHole[],
): void { ): void {
if (to - from <= EPS) return; if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return; if (zTop - zBottom <= EPS) return;
@@ -269,6 +308,7 @@ function pushSegment(
color, color,
layers: [], layers: [],
wallId, wallId,
...(holes && holes.length > 0 ? { holes } : {}),
}); });
} }
@@ -422,11 +462,31 @@ function trimWallTopForCeilings(
return Math.max(zBottom, top); 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 { function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void {
const bands = resolveWallBands(project, wall, layered); const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall); 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); const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; 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); cutouts.sort((a, b) => a.from - b.from);
// Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber // ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
// in Lagen aufgeteilt). // 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) { if (cutouts.length === 0) {
emitSeg(0, axisLen, zBottom, zTop); emitSeg(0, axisLen, zBottom, zTop);
return; return;