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:
@@ -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 {
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user