use super::*;
use super::geom::*;
use crate::Mesh;
fn aabb(
min: (f64, f64, f64),
max: (f64, f64, f64),
) -> (Point3<f64>, Point3<f64>, Option<Vector3<f64>>) {
(
Point3::new(min.0, min.1, min.2),
Point3::new(max.0, max.1, max.2),
None,
)
}
#[test]
fn spatial_clusters_separate_window_row_split_per_body() {
let boxes: Vec<_> = (0..4)
.map(|i| {
let x0 = i as f64 * 2.0;
aabb((x0, 0.0, 0.0), (x0 + 1.0, 0.3, 1.9))
})
.collect();
assert_eq!(spatial_cluster_count(&boxes), 4);
}
#[test]
fn spatial_clusters_touching_wall_leaf_halves_stay_merged() {
let inner = aabb((0.0, 0.0, 0.0), (1.0, 0.485, 0.9));
let outer = aabb((0.0, 0.485, 0.0), (1.0, 0.9, 0.9));
assert_eq!(spatial_cluster_count(&[inner, outer]), 1);
}
#[test]
fn spatial_clusters_single_or_empty() {
assert_eq!(spatial_cluster_count(&[]), 0);
assert_eq!(
spatial_cluster_count(&[aabb((0.0, 0.0, 0.0), (1.0, 1.0, 1.0))]),
1
);
}
fn make_framed_box_mesh(
origin: Point3<f64>,
depth_axis: Vector3<f64>,
cross_a: Vector3<f64>,
cross_b: Vector3<f64>,
depth: (f64, f64),
a: (f64, f64),
b: (f64, f64),
) -> Mesh {
let point =
|d: f64, av: f64, bv: f64| origin + depth_axis * d + cross_a * av + cross_b * bv;
let corners = [
point(depth.0, a.0, b.0),
point(depth.1, a.0, b.0),
point(depth.1, a.1, b.0),
point(depth.0, a.1, b.0),
point(depth.0, a.0, b.1),
point(depth.1, a.0, b.1),
point(depth.1, a.1, b.1),
point(depth.0, a.1, b.1),
];
let mut m = Mesh::with_capacity(24, 36);
let faces: [[usize; 4]; 6] = [
[0, 3, 2, 1],
[4, 5, 6, 7],
[0, 1, 5, 4],
[2, 3, 7, 6],
[0, 4, 7, 3],
[1, 2, 6, 5],
];
for idx in &faces {
let edge1 = corners[idx[1]] - corners[idx[0]];
let edge2 = corners[idx[2]] - corners[idx[0]];
let normal = edge1
.cross(&edge2)
.try_normalize(1e-10)
.unwrap_or(Vector3::new(0.0, 0.0, 1.0));
let b = m.vertex_count() as u32;
m.add_vertex(corners[idx[0]], normal);
m.add_vertex(corners[idx[1]], normal);
m.add_vertex(corners[idx[2]], normal);
m.add_vertex(corners[idx[3]], normal);
m.add_triangle(b, b + 1, b + 2);
m.add_triangle(b, b + 2, b + 3);
}
m
}
fn make_l_shape_prism_mesh() -> Mesh {
let z0 = 0.0;
let z1 = 1.0;
let footprint = [
(0.0_f64, 0.0_f64),
(4.0, 0.0),
(4.0, 2.0),
(2.0, 2.0),
(2.0, 4.0),
(0.0, 4.0),
];
let mut m = Mesh::new();
let n = footprint.len();
for i in 0..n {
let (x0, y0) = footprint[i];
let (x1, y1) = footprint[(i + 1) % n];
let edge = Vector3::new(x1 - x0, y1 - y0, 0.0);
let z_up = Vector3::new(0.0, 0.0, 1.0);
let normal = edge
.cross(&z_up)
.try_normalize(1e-10)
.unwrap_or(Vector3::new(1.0, 0.0, 0.0));
let p0 = Point3::new(x0, y0, z0);
let p1 = Point3::new(x1, y1, z0);
let p2 = Point3::new(x1, y1, z1);
let p3 = Point3::new(x0, y0, z1);
let b = m.vertex_count() as u32;
m.add_vertex(p0, normal);
m.add_vertex(p1, normal);
m.add_vertex(p2, normal);
m.add_vertex(p3, normal);
m.add_triangle(b, b + 1, b + 2);
m.add_triangle(b, b + 2, b + 3);
}
let bottom_n = Vector3::new(0.0, 0.0, -1.0);
let top_n = Vector3::new(0.0, 0.0, 1.0);
let bottom_base = m.vertex_count() as u32;
for &(x, y) in &footprint {
m.add_vertex(Point3::new(x, y, z0), bottom_n);
}
let top_base = m.vertex_count() as u32;
for &(x, y) in &footprint {
m.add_vertex(Point3::new(x, y, z1), top_n);
}
for i in 1..(n as u32 - 1) {
m.add_triangle(bottom_base, bottom_base + i + 1, bottom_base + i);
m.add_triangle(top_base, top_base + i, top_base + i + 1);
}
m
}
#[test]
fn test_rectangular_box_detector_accepts_clean_box() {
let opening = make_framed_box_mesh(
Point3::new(0.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 0.0, 1.0),
(-0.15, 0.15),
(-1.0, 1.0),
(0.0, 2.0),
);
assert!(is_rectangular_box_mesh(&opening));
}
#[test]
fn test_rectangular_box_detector_rejects_l_shape() {
let opening = make_l_shape_prism_mesh();
assert!(
!is_rectangular_box_mesh(&opening),
"rectilinear non-box footprints must fall through to NonRectangular CSG"
);
}
#[test]
fn test_rectangular_box_detector_rejects_trapezoid_extrusion() {
let mut positions: Vec<f32> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let push_v = |positions: &mut Vec<f32>, x: f32, y: f32, z: f32| {
positions.extend_from_slice(&[x, y, z]);
};
push_v(&mut positions, -0.3, 0.0, 0.0); push_v(&mut positions, 0.3, 0.0, 0.0); push_v(&mut positions, 0.5, 0.0, 2.0); push_v(&mut positions, -0.5, 0.0, 2.0); push_v(&mut positions, -0.3, 0.6, 0.0); push_v(&mut positions, 0.3, 0.6, 0.0); push_v(&mut positions, 0.5, 0.6, 2.0); push_v(&mut positions, -0.5, 0.6, 2.0); indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
indices.extend_from_slice(&[5, 4, 7, 5, 7, 6]);
indices.extend_from_slice(&[4, 5, 1, 4, 1, 0]);
indices.extend_from_slice(&[3, 2, 6, 3, 6, 7]);
indices.extend_from_slice(&[1, 5, 6, 1, 6, 2]);
indices.extend_from_slice(&[4, 0, 3, 4, 3, 7]);
let mut mesh = Mesh::new();
mesh.positions = positions;
mesh.indices = indices;
assert!(
!is_rectangular_box_mesh(&mesh),
"trapezoid extrusion must be rejected — its slanted-side axis is \
not perpendicular to the top/bottom axis, so the AABB cutter would \
over-cut the host"
);
}
#[test]
fn test_rectangular_box_detector_accepts_rotated_box() {
let opening = make_framed_box_mesh(
Point3::new(0.0, 0.0, 0.0),
Vector3::new(0.7071067811865476, 0.7071067811865476, 0.0),
Vector3::new(-0.7071067811865476, 0.7071067811865476, 0.0),
Vector3::new(0.0, 0.0, 1.0),
(-0.15, 0.15),
(-1.0, 1.0),
(0.0, 2.0),
);
assert!(
is_rectangular_box_mesh(&opening),
"axis-rotated boxes must still be detected — rotation alone does \
not make them non-rectangular"
);
}
#[test]
fn test_infers_sloped_brep_opening_frame() {
let depth_axis = Vector3::new(0.0, -0.5, 0.8660254037844386);
let cross_a = Vector3::new(1.0, 0.0, 0.0);
let cross_b = depth_axis.cross(&cross_a).normalize();
let opening = make_framed_box_mesh(
Point3::new(10.0, 20.0, 5.0),
depth_axis,
cross_a,
cross_b,
(-0.2, 0.2),
(-0.8, 0.8),
(-0.4, 0.4),
);
let frame = infer_opening_frame(&opening, None).unwrap();
assert!(
frame.depth.dot(&depth_axis).abs() > 0.99,
"shortest inferred axis should be the sloped roof-window depth"
);
assert!(
frame.cross_a.dot(&cross_a).abs() > 0.99 || frame.cross_b.dot(&cross_a).abs() > 0.99,
"inferred frame should preserve the opening roll axis"
);
assert!(
!frame.is_axis_aligned(),
"sloped BRep opening should use the diagonal frame path"
);
}
#[test]
fn test_perpendicular_corner_openings_do_not_merge() {
let window = OpeningType::Rectangular(
Point3::new(1.5, -1.2, 0.9),
Point3::new(2.7, 1.2, 2.1),
None,
);
let door = OpeningType::Rectangular(
Point3::new(-2.4, 0.55, 0.0),
Point3::new(2.4, 2.95, 2.2),
None,
);
let merged = GeometryRouter::merge_rectangular_openings(&[window, door]);
assert_eq!(
merged.len(),
2,
"perpendicular corner openings (overlap-all-axes, coincide-none) must not merge"
);
}
#[test]
fn test_deep_box_opening_penetration_axis_is_transversal() {
let dir = infer_box_penetration_dir(
&Point3::new(1.5, -1.2, 0.9),
&Point3::new(2.7, 1.2, 2.1),
&Point3::new(0.0, 0.0, 0.0),
&Point3::new(8.4, 6.2, 3.93),
);
assert!(dir.y.abs() > 0.99, "deep cutter penetrates along y, got {dir:?}");
}
#[test]
fn test_flush_box_opening_falls_back_to_thinnest_axis() {
let dir = infer_box_penetration_dir(
&Point3::new(1.5, 0.0, 0.9),
&Point3::new(2.7, 0.4, 2.1),
&Point3::new(0.0, 0.0, 0.0),
&Point3::new(8.4, 6.2, 3.93),
);
assert!(dir.y.abs() > 0.99, "flush cutter -> thinnest (y), got {dir:?}");
}
#[test]
fn test_aligned_stacked_openings_still_merge() {
let lower = OpeningType::Rectangular(
Point3::new(1.0, 0.0, 0.0),
Point3::new(2.0, 0.4, 1.0),
None,
);
let upper = OpeningType::Rectangular(
Point3::new(1.0, 0.0, 1.0),
Point3::new(2.0, 0.4, 2.0),
None,
);
let merged = GeometryRouter::merge_rectangular_openings(&[lower, upper]);
assert_eq!(merged.len(), 1, "aligned stacked openings should still merge");
match &merged[0] {
OpeningType::Rectangular(mn, mx, _) => {
assert!(mn.z.abs() < 1e-9 && (mx.z - 2.0).abs() < 1e-9, "merged Z span");
}
_ => panic!("expected a merged Rectangular opening"),
}
}
#[test]
fn test_extend_opening_pads_past_wall_on_exact_match() {
let router = crate::router::GeometryRouter::new();
let wall_min = Point3::new(0.0, 0.0, 0.0);
let wall_max = Point3::new(10.0, 0.2, 3.0);
let open_min = Point3::new(4.0, 0.0, 1.0);
let open_max = Point3::new(6.0, 0.2, 2.5);
let dir = Vector3::new(0.0, 1.0, 0.0);
let (new_min, new_max) =
router.extend_opening_along_direction(open_min, open_max, wall_min, wall_max, dir);
assert!(
new_min.y < wall_min.y,
"extended opening min Y {} must be strictly below wall min Y {}",
new_min.y,
wall_min.y,
);
assert!(
new_max.y > wall_max.y,
"extended opening max Y {} must be strictly above wall max Y {}",
new_max.y,
wall_max.y,
);
let back_pad = wall_min.y - new_min.y;
let fwd_pad = new_max.y - wall_max.y;
assert!(back_pad > 0.0 && back_pad < 1e-3);
assert!(fwd_pad > 0.0 && fwd_pad < 1e-3);
assert_eq!(new_min.x, open_min.x);
assert_eq!(new_max.x, open_max.x);
assert_eq!(new_min.z, open_min.z);
assert_eq!(new_max.z, open_max.z);
}
#[test]
fn test_extend_opening_skipped_when_opening_pokes_past_wall() {
let router = crate::router::GeometryRouter::new();
let wall_min = Point3::new(7.9, 0.0, 0.0);
let wall_max = Point3::new(8.1, 3.0, 3.0);
let open_min = Point3::new(8.0, 0.5, 1.0);
let open_max = Point3::new(8.2, 1.5, 2.0);
let dir = Vector3::new(1.0, 0.0, 0.0);
let (new_min, new_max) =
router.extend_opening_along_direction(open_min, open_max, wall_min, wall_max, dir);
assert_eq!(new_min, open_min, "X-poke-out: extension must not change min");
assert_eq!(new_max, open_max, "X-poke-out: extension must not change max");
let wall_min = Point3::new(5.9, 0.0, 0.0);
let wall_max = Point3::new(6.1, 3.0, 3.0);
let open_min = Point3::new(5.8, 0.5, 1.0);
let open_max = Point3::new(6.0, 1.5, 2.0);
let dir = Vector3::new(-1.0, 0.0, 0.0);
let (new_min, new_max) =
router.extend_opening_along_direction(open_min, open_max, wall_min, wall_max, dir);
assert_eq!(new_min, open_min, "-X-poke-out: extension must not change min");
assert_eq!(new_max, open_max, "-X-poke-out: extension must not change max");
}
#[test]
fn param_cut_is_deterministic_watertight_and_local_framed() {
let router = GeometryRouter::new();
let yaw = 37.0_f64.to_radians();
let (c, s) = (yaw.cos(), yaw.sin());
let cross_a = Vector3::new(c, s, 0.0); let depth = Vector3::new(-s, c, 0.0); let cross_b = depth.cross(&cross_a); let center = Point3::new(3.0, 2.0, 5.0);
let host_world = make_framed_box_mesh(
center, depth, cross_a, cross_b, (-0.15, 0.15), (-2.0, 2.0), (-1.5, 1.5),
);
let host = RectParam {
r: Matrix3::from_columns(&[cross_a, cross_b, depth]),
center,
half: [2.0, 1.5, 0.15],
};
let opening = RectParam {
r: host.r,
center,
half: [0.5, 0.5, 0.25],
};
let ctx = VoidContext {
openings: Vec::new(),
merged_openings: Vec::new(),
param: Some(ParamRectCut {
host,
openings: vec![opening],
}),
bool2d: None,
};
let out1 = router
.try_param_rect_cut(&host_world, &ctx)
.expect("parametric cut must fire on a clean rotated wall");
let out2 = router
.try_param_rect_cut(&host_world, &ctx)
.expect("parametric cut must fire on a clean rotated wall");
assert_eq!(out1.positions, out2.positions, "positions must be deterministic");
assert_eq!(out1.indices, out2.indices, "indices must be deterministic");
assert_eq!(out1.normals, out2.normals, "normals must be deterministic");
assert_eq!(out1.origin, out2.origin, "origin must be deterministic");
assert_eq!(out1.origin, [center.x, center.y, center.z]);
let max_abs = out1
.positions
.iter()
.fold(0.0f32, |m, &v| m.max(v.abs()));
assert!(max_abs < 10.0, "local-frame positions must stay small (got {max_abs})");
assert!(param_cut_watertight(&out1), "cut must be watertight");
assert!(out1.triangle_count() > 12, "the opening must add faces");
}