use crate::kernel::mesh_bridge::union_many;
use crate::csg::ClippingProcessor;
use crate::Mesh;
use nalgebra::{Point3, Rotation3, Unit, Vector3};
use std::collections::HashMap;
const SG: f64 = 1.0 / 65536.0;
fn boxed(min: [f64; 3], size: [f64; 3], rot: Option<(Vector3<f64>, f64, [f64; 3])>) -> Mesh {
let mx = [min[0] + size[0], min[1] + size[1], min[2] + size[2]];
let c = |i: usize| -> [f64; 2] { [min[i], mx[i]] };
let mut corners: Vec<Point3<f64>> = [
(0, 0, 0),
(1, 0, 0),
(1, 1, 0),
(0, 1, 0),
(0, 0, 1),
(1, 0, 1),
(1, 1, 1),
(0, 1, 1),
]
.iter()
.map(|&(i, j, k)| Point3::new(c(0)[i], c(1)[j], c(2)[k]))
.collect();
if let Some((axis, angle, about)) = rot {
let r = Rotation3::from_axis_angle(&Unit::new_normalize(axis), angle);
let o = Point3::new(about[0], about[1], about[2]);
for p in corners.iter_mut() {
*p = o + r * (*p - o);
}
}
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],
];
let mut m = Mesh::with_capacity(24, 36);
for f in &faces {
let e1 = corners[f[1]] - corners[f[0]];
let e2 = corners[f[2]] - corners[f[0]];
let n = e1.cross(&e2).try_normalize(1e-12).unwrap_or(Vector3::z());
let b = m.vertex_count() as u32;
for &i in f {
m.add_vertex(corners[i], n);
}
m.add_triangle(b, b + 1, b + 2);
m.add_triangle(b, b + 2, b + 3);
}
m
}
fn open_edges(m: &Mesh) -> Result<usize, String> {
if m.is_empty() {
return Err("union produced nothing".to_string());
}
let w = m.welded_by_position(1e-4);
let mut edges: HashMap<(u32, u32), (u32, u32)> = HashMap::new();
for t in w.indices.chunks_exact(3) {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
if a == b {
return Err(format!("degenerate edge: triangle repeats welded vertex {a}"));
}
let e = edges.entry((a.min(b), a.max(b))).or_insert((0, 0));
if a < b {
e.0 += 1;
} else {
e.1 += 1;
}
}
}
Ok(edges.values().filter(|&&(f, r)| f != 1 || r != 1).count())
}
#[test]
fn sweep_self_check_open_edges_detects_known_bad_meshes() {
let closed = boxed([0.0, 0.0, 0.0], [1.0, 1.0, 1.0], None);
assert_eq!(open_edges(&closed), Ok(0), "a plain closed box must be clean");
let mut doubled = boxed([0.0, 0.0, 0.0], [1.0, 1.0, 1.0], None);
let tris = doubled.indices.clone();
for tri in tris.chunks_exact(3) {
doubled.add_triangle(tri[0], tri[1], tri[2]);
}
assert_ne!(
open_edges(&doubled),
Ok(0),
"a duplicated shell must be rejected (signed cancellation alone would pass it)"
);
let mut open = boxed([0.0, 0.0, 0.0], [1.0, 1.0, 1.0], None);
open.indices.truncate(open.indices.len() - 3);
assert_ne!(
open_edges(&open),
Ok(0),
"an open mesh (one triangle removed) must be rejected"
);
}
fn three_boxes_at(bx: f64, by: f64, dz: f64) -> [Mesh; 3] {
let a = boxed([0.0, 0.0, 0.0], [1.0, 1.0, 1.0], None);
let b = boxed(
[bx, by, dz],
[1.0, 1.0, 1.0],
Some((
Vector3::z(),
30.0f64.to_radians(),
[bx + 0.5, by + 0.5, 0.5 + dz],
)),
);
let c = boxed(
[-bx, by, dz],
[1.0, 1.0, 1.0],
Some((
Vector3::z(),
-20.0f64.to_radians(),
[-bx + 0.5, by + 0.5, 0.5 + dz],
)),
);
[a, b, c]
}
const CORNER_OFFSETS: [f64; 7] = [0.1, 0.2, 0.35, 0.4, 0.5, 0.65, 0.8];
const DZ_VALUES: [f64; 3] = [0.0, SG, -SG];
const DZ_LABELS: [&str; 3] = ["flush", "+1snap", "-1snap"];
const ORDERINGS: [[usize; 3]; 6] = [
[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0], ];
const ORDERING_LABELS: [&str; 6] = ["ABC", "ACB", "BAC", "BCA", "CAB", "CBA"];
struct Verdict {
bx_idx: usize,
by_idx: usize,
dz_idx: usize,
order_idx: usize,
torn: bool,
detail: String,
}
fn run_sweep() -> Vec<Verdict> {
let mut out = Vec::with_capacity(882);
for (bx_idx, &bx) in CORNER_OFFSETS.iter().enumerate() {
for (by_idx, &by) in CORNER_OFFSETS.iter().enumerate() {
for (dz_idx, &dz) in DZ_VALUES.iter().enumerate() {
let boxes = three_boxes_at(bx, by, dz);
for (order_idx, order) in ORDERINGS.iter().enumerate() {
let refs: [&Mesh; 3] =
[&boxes[order[0]], &boxes[order[1]], &boxes[order[2]]];
let raw = union_many(&refs);
let out_mesh = ClippingProcessor::consolidate_coplanar(raw);
let (torn, detail) = match open_edges(&out_mesh) {
Ok(0) => (false, String::new()),
Ok(n) => (true, format!("{n} unmatched directed edge(s)")),
Err(e) => (true, e),
};
out.push(Verdict {
bx_idx,
by_idx,
dz_idx,
order_idx,
torn,
detail,
});
}
}
}
}
out
}
const KNOWN_TORN_CEILING: usize = 136;
#[test]
fn union_many_nary_sweep_regression_gate() {
let verdicts = run_sweep();
assert_eq!(verdicts.len(), 882, "sweep must enumerate exactly 882 configurations");
let torn: Vec<&Verdict> = verdicts.iter().filter(|v| v.torn).collect();
let mut by_dz = [0usize; 3];
for v in &torn {
by_dz[v.dz_idx] += 1;
}
let mut by_order = [0usize; 6];
for v in &torn {
by_order[v.order_idx] += 1;
}
let mut report = format!(
"issue #3913 union_many N-ary sweep: {} / {} configurations torn\n\
by dz: {}={:<4} {}={:<4} {}={}\n\
by order: {}={:<4} {}={:<4} {}={:<4} {}={:<4} {}={:<4} {}={}\n",
torn.len(),
verdicts.len(),
DZ_LABELS[0], by_dz[0], DZ_LABELS[1], by_dz[1], DZ_LABELS[2], by_dz[2],
ORDERING_LABELS[0], by_order[0], ORDERING_LABELS[1], by_order[1],
ORDERING_LABELS[2], by_order[2], ORDERING_LABELS[3], by_order[3],
ORDERING_LABELS[4], by_order[4], ORDERING_LABELS[5], by_order[5],
);
for v in &torn {
report.push_str(&format!(
" torn: bx={:.2} by={:.2} dz={} order={} :: {}\n",
CORNER_OFFSETS[v.bx_idx],
CORNER_OFFSETS[v.by_idx],
DZ_LABELS[v.dz_idx],
ORDERING_LABELS[v.order_idx],
v.detail,
));
}
println!("{report}");
assert!(
torn.len() <= KNOWN_TORN_CEILING,
"{report}\n{} of 882 configurations tore, exceeding the recorded ceiling of {} \
(issue #3913) — this is a REGRESSION, not the known residual. Do not raise \
KNOWN_TORN_CEILING to force a pass; find what changed.",
torn.len(),
KNOWN_TORN_CEILING,
);
}