use glam::DVec3;
const BLANK_HALF_EXTENT: f64 = 64.0;
const EPS_FEAS: f64 = 1e-5;
const EPS_FACE: f64 = 2e-5;
const MIN_TRIPLE_DET: f64 = 1e-6;
const VERTEX_DEDUP: f64 = 1e-6;
const GIRDLE_NY: f64 = 1e-6;
#[derive(Debug, Clone, Copy)]
pub struct SolidMetrics {
pub volume: f64,
pub width_axis: f64,
pub length_axis: f64,
pub width_caliper: f64,
pub length_caliper: f64,
pub total_height: f64,
pub crown_height: Option<f64>,
pub pavilion_depth: Option<f64>,
pub girdle_thickness: Option<f64>,
pub vertex_count: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ExternalProportions {
pub vol_w3: Option<f64>,
pub lw: Option<f64>,
pub cw: Option<f64>,
pub pw: Option<f64>,
pub hw: Option<f64>,
}
impl ExternalProportions {
#[must_use]
pub fn combined_deviation(&self, metrics: &SolidMetrics) -> Option<f64> {
let w = metrics.width_axis;
if w < 1e-9 {
return None;
}
let mut sum = 0.0_f64;
let mut count = 0_usize;
let mut add = |target: Option<f64>, measured: Option<f64>| {
if let (Some(t), Some(v)) = (target, measured)
&& t > 1e-9
{
sum += (v - t).abs() / t;
count += 1;
}
};
add(self.vol_w3, Some(metrics.volume / (w * w * w)));
add(self.lw, Some(metrics.length_axis / w));
add(self.cw, metrics.crown_height.map(|c| c / w));
add(self.pw, metrics.pavilion_depth.map(|p| p / w));
add(self.hw, Some(metrics.total_height / w));
if count == 0 {
None
} else {
Some(sum / count as f64)
}
}
}
#[derive(Debug, Clone)]
pub enum SolidStatus {
Closed(SolidMesh),
Unbounded { escaping: Vec<usize> },
Degenerate {
vertex_count: usize,
volume: Option<f64>,
},
}
#[derive(Debug, Clone, Default)]
pub struct SolidMesh {
pub positions: Vec<DVec3>,
pub normals: Vec<DVec3>,
pub facet_id: Vec<usize>,
pub indices: Vec<u32>,
pub rings: Vec<(usize, Vec<DVec3>)>,
}
struct SolidVertex {
v: DVec3,
}
#[derive(Default)]
struct VertexAccumulator {
verts: Vec<SolidVertex>,
by_x: Vec<usize>,
}
impl VertexAccumulator {
fn insert_if_new(&mut self, v: DVec3) {
let verts = &self.verts;
let lo = self
.by_x
.partition_point(|&i| verts[i].v.x < v.x - VERTEX_DEDUP);
let hi = self
.by_x
.partition_point(|&i| verts[i].v.x <= v.x + VERTEX_DEDUP);
for &i in &self.by_x[lo..hi] {
if (self.verts[i].v - v).abs().max_element() < VERTEX_DEDUP {
return;
}
}
let idx = self.verts.len();
self.verts.push(SolidVertex { v });
let pos = self.by_x.partition_point(|&i| self.verts[i].v.x < v.x);
self.by_x.insert(pos, idx);
}
}
#[must_use]
pub fn measure_solid(planes: &[(DVec3, f64)]) -> Option<SolidMetrics> {
let planes = dedup_planes(planes);
let verts = feasible_vertices(&planes)?;
if verts.len() < 4 {
return None;
}
let volume: f64 = planes
.iter()
.map(|&(n, m)| m * face_area(n, m, &verts) / 3.0)
.sum();
if !(volume.is_finite() && volume > 0.0) {
return None;
}
let y_max = verts
.iter()
.map(|s| s.v.y)
.fold(f64::NEG_INFINITY, f64::max);
let y_min = verts.iter().map(|s| s.v.y).fold(f64::INFINITY, f64::min);
let mut girdle_top = f64::NEG_INFINITY;
let mut girdle_bottom = f64::INFINITY;
for &(n, m) in planes.iter().filter(|(n, _)| n.y.abs() <= GIRDLE_NY) {
for s in &verts {
if (n.dot(s.v) - m).abs() <= EPS_FACE {
girdle_top = girdle_top.max(s.v.y);
girdle_bottom = girdle_bottom.min(s.v.y);
}
}
}
let has_girdle = girdle_top >= girdle_bottom;
let x_max = verts
.iter()
.map(|s| s.v.x)
.fold(f64::NEG_INFINITY, f64::max);
let x_min = verts.iter().map(|s| s.v.x).fold(f64::INFINITY, f64::min);
let z_max = verts
.iter()
.map(|s| s.v.z)
.fold(f64::NEG_INFINITY, f64::max);
let z_min = verts.iter().map(|s| s.v.z).fold(f64::INFINITY, f64::min);
let (dx, dz) = (x_max - x_min, z_max - z_min);
let (width_axis, length_axis) = if dx <= dz { (dx, dz) } else { (dz, dx) };
let outline: Vec<(f64, f64)> = verts.iter().map(|s| (s.v.x, s.v.z)).collect();
let (width_caliper, length_caliper) =
caliper_extents(&outline).unwrap_or((width_axis, length_axis));
Some(SolidMetrics {
volume,
width_axis,
length_axis,
width_caliper,
length_caliper,
total_height: y_max - y_min,
crown_height: has_girdle.then_some(y_max - girdle_top),
pavilion_depth: has_girdle.then_some(girdle_bottom - y_min),
girdle_thickness: has_girdle.then_some(girdle_top - girdle_bottom),
vertex_count: verts.len(),
})
}
fn dedup_planes(planes: &[(DVec3, f64)]) -> Vec<(DVec3, f64)> {
let mut out: Vec<(DVec3, f64)> = Vec::with_capacity(planes.len());
for &(n, m) in planes {
let dup = out
.iter()
.any(|&(n2, m2)| n.dot(n2) > 1.0 - 1e-12 && (m - m2).abs() < 1e-9);
if !dup {
out.push((n, m));
}
}
out
}
fn flush_solid_batch(
batch: &crate::simd::TripleBatch,
soa: &crate::simd::PlanesSoA64,
acc: &mut VertexAccumulator,
) -> Option<()> {
let sol = crate::simd::solve_triple_batch(batch);
for lane in 0..batch.len {
if sol.det[lane].abs() < MIN_TRIPLE_DET {
continue;
}
let v = DVec3::new(sol.vx[lane], sol.vy[lane], sol.vz[lane]);
if v.abs().max_element() > BLANK_HALF_EXTENT + 1.0 {
continue;
}
if crate::simd::any_violation(soa, v, EPS_FEAS) {
continue;
}
if v.abs().max_element() > BLANK_HALF_EXTENT - 1.0 {
return None;
}
acc.insert_if_new(v);
}
Some(())
}
fn feasible_vertices(planes: &[(DVec3, f64)]) -> Option<Vec<SolidVertex>> {
let mut all: Vec<(DVec3, f64)> = planes.to_vec();
for n in [
DVec3::X,
DVec3::NEG_X,
DVec3::Y,
DVec3::NEG_Y,
DVec3::Z,
DVec3::NEG_Z,
] {
all.push((n, BLANK_HALF_EXTENT));
}
let mut soa = crate::simd::PlanesSoA64::with_capacity(all.len());
for &(n, m) in &all {
soa.push(n, m, 0);
}
let p = all.len();
let mut acc = VertexAccumulator::default();
let mut batch = crate::simd::TripleBatch::default();
for a in 0..p {
for b in (a + 1)..p {
for c in (b + 1)..p {
let (pa, pb, pc) = (all[a], all[b], all[c]);
if batch.push((pa.0, pa.1), (pb.0, pb.1), (pc.0, pc.1)) {
flush_solid_batch(&batch, &soa, &mut acc)?;
batch = crate::simd::TripleBatch::default();
}
}
}
}
if batch.len > 0 {
flush_solid_batch(&batch, &soa, &mut acc)?;
}
Some(acc.verts)
}
fn dedup_origin_indices(original: &[(DVec3, f64)], deduped: &[(DVec3, f64)]) -> Vec<usize> {
let same = |a: (DVec3, f64), b: (DVec3, f64)| {
a.0.x.to_bits() == b.0.x.to_bits()
&& a.0.y.to_bits() == b.0.y.to_bits()
&& a.0.z.to_bits() == b.0.z.to_bits()
&& a.1.to_bits() == b.1.to_bits()
};
let mut out = Vec::with_capacity(deduped.len());
let mut cursor = 0usize;
for &d in deduped {
while cursor < original.len() && !same(original[cursor], d) {
cursor += 1;
}
out.push(cursor.min(original.len().saturating_sub(1)));
cursor += 1;
}
out
}
fn escaping_plane_indices(planes: &[(DVec3, f64)]) -> Vec<usize> {
let real_count = planes.len();
let mut all: Vec<(DVec3, f64)> = planes.to_vec();
for n in [
DVec3::X,
DVec3::NEG_X,
DVec3::Y,
DVec3::NEG_Y,
DVec3::Z,
DVec3::NEG_Z,
] {
all.push((n, BLANK_HALF_EXTENT));
}
let p = all.len();
let mut escaping: Vec<usize> = Vec::new();
for a in 0..p {
for b in (a + 1)..p {
for c in (b + 1)..p {
let (na, ma) = all[a];
let (nb, mb) = all[b];
let (nc, mc) = all[c];
let mat = glam::DMat3::from_cols(na, nb, nc).transpose();
let det = mat.determinant();
if det.abs() < MIN_TRIPLE_DET {
continue;
}
let v = mat.inverse() * DVec3::new(ma, mb, mc);
if v.abs().max_element() > BLANK_HALF_EXTENT + 1.0 {
continue;
}
if all.iter().any(|&(n, m)| n.dot(v) - m > EPS_FEAS) {
continue;
}
if v.abs().max_element() > BLANK_HALF_EXTENT - 1.0 {
for idx in [a, b, c] {
if idx < real_count && !escaping.contains(&idx) {
escaping.push(idx);
}
}
}
}
}
}
escaping.sort_unstable();
escaping
}
#[must_use]
pub fn build_solid_mesh(planes: &[(DVec3, f64)]) -> SolidStatus {
let deduped = dedup_planes(planes);
let origin = dedup_origin_indices(planes, &deduped);
let Some(verts) = feasible_vertices(&deduped) else {
let mut escaping: Vec<usize> = escaping_plane_indices(&deduped)
.into_iter()
.map(|i| origin[i])
.collect();
escaping.sort_unstable();
escaping.dedup();
return SolidStatus::Unbounded { escaping };
};
if verts.len() < 4 {
return SolidStatus::Degenerate {
vertex_count: verts.len(),
volume: None,
};
}
let volume: f64 = deduped
.iter()
.map(|&(n, m)| m * face_area(n, m, &verts) / 3.0)
.sum();
if !(volume.is_finite() && volume > 0.0) {
return SolidStatus::Degenerate {
vertex_count: verts.len(),
volume: volume.is_finite().then_some(volume),
};
}
let mut mesh = SolidMesh::default();
for (i, &(normal, offset)) in deduped.iter().enumerate() {
let Some((ring, centroid)) = face_ring(normal, offset, &verts) else {
continue;
};
let facet_idx = origin[i];
let base = mesh.positions.len() as u32;
mesh.positions.push(centroid);
mesh.normals.push(normal);
mesh.facet_id.push(facet_idx);
for &v in &ring {
mesh.positions.push(v);
mesh.normals.push(normal);
mesh.facet_id.push(facet_idx);
}
let k = ring.len() as u32;
for e in 0..k {
let a = base + 1 + e;
let b = base + 1 + (e + 1) % k;
mesh.indices.push(base);
mesh.indices.push(a);
mesh.indices.push(b);
}
mesh.rings.push((facet_idx, ring));
}
SolidStatus::Closed(mesh)
}
fn face_ring(normal: DVec3, offset: f64, verts: &[SolidVertex]) -> Option<(Vec<DVec3>, DVec3)> {
let on_face: Vec<DVec3> = verts
.iter()
.map(|s| s.v)
.filter(|v| (normal.dot(*v) - offset).abs() <= EPS_FACE)
.collect();
if on_face.len() < 3 {
return None;
}
let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() {
DVec3::X
} else if normal.y.abs() <= normal.z.abs() {
DVec3::Y
} else {
DVec3::Z
};
let basis_u = (seed - normal * normal.dot(seed)).normalize();
let basis_v = normal.cross(basis_u);
let centroid = on_face.iter().copied().sum::<DVec3>() / on_face.len() as f64;
let mut angled: Vec<(f64, DVec3)> = on_face
.into_iter()
.map(|vert| {
let d = vert - centroid;
(basis_v.dot(d).atan2(basis_u.dot(d)), vert)
})
.collect();
angled.sort_by(|x, y| x.0.total_cmp(&y.0));
Some((angled.into_iter().map(|(_, v)| v).collect(), centroid))
}
fn face_area(normal: DVec3, offset: f64, verts: &[SolidVertex]) -> f64 {
let Some((ring, centroid)) = face_ring(normal, offset, verts) else {
return 0.0;
};
let mut cross_sum = DVec3::ZERO;
for i in 0..ring.len() {
let a = ring[i] - centroid;
let b = ring[(i + 1) % ring.len()] - centroid;
cross_sum += a.cross(b);
}
0.5 * normal.dot(cross_sum).abs()
}
fn caliper_extents(points: &[(f64, f64)]) -> Option<(f64, f64)> {
let hull = convex_hull_2d(points);
if hull.len() < 3 {
return None;
}
let mut best: Option<(f64, f64)> = None;
for i in 0..hull.len() {
let (px, pz) = hull[i];
let (qx, qz) = hull[(i + 1) % hull.len()];
let (ex, ez) = (qx - px, qz - pz);
let len = ex.hypot(ez);
if len < 1e-12 {
continue;
}
let (dx, dz) = (ex / len, ez / len);
let mut along = (f64::INFINITY, f64::NEG_INFINITY);
let mut across = (f64::INFINITY, f64::NEG_INFINITY);
for &(x, z) in &hull {
let a = x.mul_add(dx, z * dz);
let c = x.mul_add(-dz, z * dx);
along = (along.0.min(a), along.1.max(a));
across = (across.0.min(c), across.1.max(c));
}
let width = across.1 - across.0;
let length = along.1 - along.0;
if best.is_none_or(|(bw, _)| width < bw) {
best = Some((width, length));
}
}
best.map(|(w, l)| if w <= l { (w, l) } else { (l, w) })
}
fn convex_hull_2d(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut pts: Vec<(f64, f64)> = points.to_vec();
pts.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12);
if pts.len() < 3 {
return pts;
}
let cross = |o: (f64, f64), a: (f64, f64), b: (f64, f64)| -> f64 {
(a.0 - o.0).mul_add(b.1 - o.1, -((a.1 - o.1) * (b.0 - o.0)))
};
let mut lower: Vec<(f64, f64)> = Vec::new();
for &p in &pts {
while lower.len() >= 2 && cross(lower[lower.len() - 2], lower[lower.len() - 1], p) <= 0.0 {
lower.pop();
}
lower.push(p);
}
let mut upper: Vec<(f64, f64)> = Vec::new();
for &p in pts.iter().rev() {
while upper.len() >= 2 && cross(upper[upper.len() - 2], upper[upper.len() - 1], p) <= 0.0 {
upper.pop();
}
upper.push(p);
}
lower.pop();
upper.pop();
lower.extend(upper);
lower
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_a_plain_box() {
let planes = vec![
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Y, 0.6),
(DVec3::NEG_Y, 0.6),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
];
let m = measure_solid(&planes).expect("box must measure");
assert!((m.volume - 4.8).abs() < 1e-9, "volume {}", m.volume);
assert!((m.width_axis - 2.0).abs() < 1e-9);
assert!((m.length_axis - 2.0).abs() < 1e-9);
assert!((m.width_caliper - 2.0).abs() < 1e-9);
assert!((m.total_height - 1.2).abs() < 1e-9);
assert_eq!(m.vertex_count, 8);
assert!((m.crown_height.expect("girdle present")).abs() < 1e-9);
assert!((m.pavilion_depth.expect("girdle present")).abs() < 1e-9);
assert!((m.girdle_thickness.expect("girdle present") - 1.2).abs() < 1e-9);
}
#[test]
fn measures_crown_height_against_the_girdle_band() {
let s = std::f64::consts::FRAC_1_SQRT_2;
let planes = vec![
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
(DVec3::NEG_Y, 0.5),
(DVec3::new(s, s, 0.0), s),
(DVec3::new(-s, s, 0.0), s),
(DVec3::new(0.0, s, s), s),
(DVec3::new(0.0, s, -s), s),
];
let m = measure_solid(&planes).expect("roofed block must measure");
assert!(
(m.volume - (2.0 + 4.0 / 3.0)).abs() < 1e-9,
"volume {}",
m.volume
);
assert!((m.total_height - 1.5).abs() < 1e-9);
assert!((m.crown_height.expect("girdle present") - 1.0).abs() < 1e-9);
assert!((m.pavilion_depth.expect("girdle present")).abs() < 1e-9);
assert!((m.girdle_thickness.expect("girdle present") - 0.5).abs() < 1e-9);
assert!((m.width_axis - 2.0).abs() < 1e-9);
}
#[test]
fn unbounded_solid_reports_none() {
let planes = vec![
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Y, 0.6),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
];
assert!(measure_solid(&planes).is_none());
}
#[test]
fn duplicate_planes_do_not_double_count() {
let planes = vec![
(DVec3::X, 1.0),
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Y, 0.6),
(DVec3::NEG_Y, 0.6),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
];
let m = measure_solid(&planes).expect("box must measure");
assert!((m.volume - 4.8).abs() < 1e-9, "volume {}", m.volume);
}
#[test]
fn caliper_width_beats_axis_width_on_a_rotated_outline() {
let s = std::f64::consts::FRAC_1_SQRT_2;
let planes = vec![
(DVec3::new(s, 0.0, s), 1.0),
(DVec3::new(-s, 0.0, s), 1.0),
(DVec3::new(s, 0.0, -s), 1.0),
(DVec3::new(-s, 0.0, -s), 1.0),
(DVec3::Y, 0.5),
(DVec3::NEG_Y, 0.5),
];
let m = measure_solid(&planes).expect("rotated box must measure");
let diag = 2.0 * std::f64::consts::SQRT_2;
assert!((m.width_axis - diag).abs() < 1e-9, "axis {}", m.width_axis);
assert!(
(m.width_caliper - 2.0).abs() < 1e-9,
"caliper {}",
m.width_caliper
);
assert!((m.volume - 4.0).abs() < 1e-9, "volume {}", m.volume);
}
#[test]
fn measurement_is_deterministic() {
let s = std::f64::consts::FRAC_1_SQRT_2;
let planes = vec![
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
(DVec3::NEG_Y, 0.5),
(DVec3::new(s, s, 0.0), s),
(DVec3::new(-s, s, 0.0), s),
(DVec3::new(0.0, s, s), s),
(DVec3::new(0.0, s, -s), s),
];
let a = measure_solid(&planes).expect("must measure");
let b = measure_solid(&planes).expect("must measure");
assert_eq!(a.volume.to_bits(), b.volume.to_bits());
assert_eq!(a.width_caliper.to_bits(), b.width_caliper.to_bits());
assert_eq!(a.total_height.to_bits(), b.total_height.to_bits());
}
fn flush_solid_batch_linear_reference(
batch: &crate::simd::TripleBatch,
soa: &crate::simd::PlanesSoA64,
verts: &mut Vec<SolidVertex>,
) -> Option<()> {
let sol = crate::simd::solve_triple_batch(batch);
for lane in 0..batch.len {
if sol.det[lane].abs() < MIN_TRIPLE_DET {
continue;
}
let v = DVec3::new(sol.vx[lane], sol.vy[lane], sol.vz[lane]);
if v.abs().max_element() > BLANK_HALF_EXTENT + 1.0 {
continue;
}
if crate::simd::any_violation(soa, v, EPS_FEAS) {
continue;
}
if v.abs().max_element() > BLANK_HALF_EXTENT - 1.0 {
return None;
}
let dup = verts
.iter()
.any(|s| (s.v - v).abs().max_element() < VERTEX_DEDUP);
if !dup {
verts.push(SolidVertex { v });
}
}
Some(())
}
fn feasible_vertices_linear_reference(planes: &[(DVec3, f64)]) -> Option<Vec<DVec3>> {
let mut all: Vec<(DVec3, f64)> = planes.to_vec();
for n in [
DVec3::X,
DVec3::NEG_X,
DVec3::Y,
DVec3::NEG_Y,
DVec3::Z,
DVec3::NEG_Z,
] {
all.push((n, BLANK_HALF_EXTENT));
}
let mut soa = crate::simd::PlanesSoA64::with_capacity(all.len());
for &(n, m) in &all {
soa.push(n, m, 0);
}
let p = all.len();
let mut verts: Vec<SolidVertex> = Vec::new();
let mut batch = crate::simd::TripleBatch::default();
for a in 0..p {
for b in (a + 1)..p {
for c in (b + 1)..p {
let (pa, pb, pc) = (all[a], all[b], all[c]);
if batch.push((pa.0, pa.1), (pb.0, pb.1), (pc.0, pc.1)) {
flush_solid_batch_linear_reference(&batch, &soa, &mut verts)?;
batch = crate::simd::TripleBatch::default();
}
}
}
}
if batch.len > 0 {
flush_solid_batch_linear_reference(&batch, &soa, &mut verts)?;
}
Some(verts.into_iter().map(|s| s.v).collect())
}
fn assert_dedup_matches_reference(planes: &[(DVec3, f64)], label: &str) {
let deduped = dedup_planes(planes);
let fast =
feasible_vertices(&deduped).map(|v| v.into_iter().map(|s| s.v).collect::<Vec<_>>());
let reference = feasible_vertices_linear_reference(&deduped);
match (fast, reference) {
(None, None) => {}
(Some(f), Some(r)) => {
assert_eq!(
f.len(),
r.len(),
"{label}: vertex count differs (indexed {} vs linear-scan reference {})",
f.len(),
r.len()
);
for (i, (fv, rv)) in f.iter().zip(r.iter()).enumerate() {
assert_eq!(
(fv.x.to_bits(), fv.y.to_bits(), fv.z.to_bits()),
(rv.x.to_bits(), rv.y.to_bits(), rv.z.to_bits()),
"{label}: vertex {i} differs (indexed {fv:?} vs reference {rv:?})"
);
}
}
(f, r) => panic!(
"{label}: indexed and reference dedup disagree on boundedness (indexed {:?}, reference {:?})",
f.is_some(),
r.is_some()
),
}
}
#[test]
fn dedup_matches_linear_scan_reference_on_module_fixtures() {
let s = std::f64::consts::FRAC_1_SQRT_2;
assert_dedup_matches_reference(
&[
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Y, 0.6),
(DVec3::NEG_Y, 0.6),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
],
"plain box",
);
assert_dedup_matches_reference(
&[
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
(DVec3::NEG_Y, 0.5),
(DVec3::new(s, s, 0.0), s),
(DVec3::new(-s, s, 0.0), s),
(DVec3::new(0.0, s, s), s),
(DVec3::new(0.0, s, -s), s),
],
"hip-roofed block",
);
assert_dedup_matches_reference(
&[
(DVec3::new(s, 0.0, s), 1.0),
(DVec3::new(-s, 0.0, s), 1.0),
(DVec3::new(s, 0.0, -s), 1.0),
(DVec3::new(-s, 0.0, -s), 1.0),
(DVec3::Y, 0.5),
(DVec3::NEG_Y, 0.5),
],
"rotated square girdle",
);
}
fn planes_from_asc_schedule(text: &str) -> Vec<(DVec3, f64)> {
let schedule = indicatrix_formats::asc::parse_asc(text).expect("fixture schedule parses");
let mut tiers = crate::geometry::meet_solver::meet_tier_inputs_from_asc(&schedule);
for j in [0usize, 1, 2] {
if let Some(t) = schedule.tiers.get(j) {
tiers[j].constraint =
crate::geometry::meet_solver::MeetConstraint::ScaleReference(t.mast);
}
}
let normals =
crate::geometry::meet_solver::tier_instance_normals(schedule.gear_teeth_abs(), &tiers);
let solved =
crate::geometry::meet_solver::solve_meet_points(schedule.gear_teeth_abs(), &tiers);
normals
.iter()
.zip(solved.iter().map(|s| s.mast))
.flat_map(|(ns, m)| ns.iter().map(move |&n| (n, m)))
.collect()
}
#[test]
fn dedup_matches_linear_scan_reference_on_real_schedules() {
assert_dedup_matches_reference(
&planes_from_asc_schedule(
"GemCad 5.0\n\
g 96 0.0\n\
y 6 y\n\
I 1.72\n\
H Bench design\n\
a -41.000000 0.64991234 92 n 1 84 76 68 60 52 44 36 28 20 12 4\n\
a -90.000000 1.07325092 92 n 2 84 76 68 60 52 44 36 28 20 12 4\n\
a 29.730000 0.65249790 4 n A 12 20 28 36 44 52 60 68 76 84 92\n\
a 25.000000 0.59508784 96 n B 16 32 48 64 80\n\
a 10.000000 0.48799664 96 n C 16 32 48 64 80\n\
a 0.000000 0.44000000 n T\n",
),
"real schedule: Train A (96-tooth round)",
);
assert_dedup_matches_reference(
&planes_from_asc_schedule(
"GemCad 5.0\ng 96 0.0\ny 8 y\nI 1.54\nH Train B\n\
a -43.000000 0.70000000 96 n P1 12 24 36 48 60 72 84\n\
a -41.000000 0.68000000 6 n P2 18 30 42 54 66 78 90\n\
a -90.000000 1.00000000 96 n G 12 24 36 48 60 72 84\n\
a -90.000000 1.00000000 6 n G2 18 30 42 54 66 78 90\n\
a 42.000000 0.72000000 96 n C1 12 24 36 48 60 72 84\n\
a 27.000000 0.62000000 6 n C2 18 30 42 54 66 78 90\n\
a 0.000000 0.40000000 n T\n",
),
"real schedule: Train B (mixed 96/6-tooth)",
);
assert_dedup_matches_reference(
&planes_from_asc_schedule(
"GemCad 5.0\ng 96 0.0\ny 4 y\nI 1.62\nH Train C\n\
a -45.000000 0.75000000 96 n 1 24 48 72\n\
a -40.000000 0.70000000 12 n 2 36 60 84\n\
a -90.000000 1.05000000 96 n G 24 48 72\n\
a -90.000000 1.05000000 12 n G2 36 60 84\n\
a 35.000000 0.70000000 96 n 3 24 48 72\n\
a 20.000000 0.58000000 12 n 4 36 60 84\n\
a 0.000000 0.42000000 n T\n",
),
"real schedule: Train C (4-fold)",
);
}
fn mesh_divergence_volume(mesh: &SolidMesh) -> f64 {
let mut acc = 0.0f64;
for tri in mesh.indices.as_chunks::<3>().0 {
let v0 = mesh.positions[tri[0] as usize];
let v1 = mesh.positions[tri[1] as usize];
let v2 = mesh.positions[tri[2] as usize];
acc += v0.dot(v1.cross(v2));
}
acc / 6.0
}
fn assert_watertight(mesh: &SolidMesh, label: &str) {
type PosKey = (u64, u64, u64);
let key = |i: u32| -> PosKey {
let p = mesh.positions[i as usize];
(p.x.to_bits(), p.y.to_bits(), p.z.to_bits())
};
let mut edges: Vec<(PosKey, PosKey)> = Vec::new();
for tri in mesh.indices.as_chunks::<3>().0 {
for &(a, b) in &[(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
let (ka, kb) = (key(a), key(b));
edges.push(if ka <= kb { (ka, kb) } else { (kb, ka) });
}
}
for e in &edges {
let count = edges.iter().filter(|other| *other == e).count();
assert_eq!(
count, 2,
"{label}: edge {e:?} is shared by {count} triangles, not 2 (not watertight)"
);
}
}
fn verts_for(planes: &[(DVec3, f64)]) -> Vec<SolidVertex> {
feasible_vertices(&dedup_planes(planes)).expect("fixture must be bounded")
}
fn assert_mesh_matches_measure_solid(planes: &[(DVec3, f64)], label: &str) {
let status = build_solid_mesh(planes);
let SolidStatus::Closed(mesh) = status else {
panic!("{label}: expected a closed mesh, got {status:?}");
};
assert_watertight(&mesh, label);
let verts = verts_for(planes);
for &(facet_idx, ref ring) in &mesh.rings {
let (normal, offset) = planes[facet_idx];
let want_area = face_area(normal, offset, &verts);
let centroid = ring.iter().copied().sum::<DVec3>() / ring.len() as f64;
let mut got_area = 0.0f64;
for i in 0..ring.len() {
let a = ring[i] - centroid;
let b = ring[(i + 1) % ring.len()] - centroid;
got_area = 0.5f64.mul_add(a.cross(b).length(), got_area);
}
assert!(
(got_area - want_area).abs() < 1e-9,
"{label}: facet {facet_idx} triangle-area sum {got_area} != face_area {want_area}"
);
}
let want_volume = measure_solid(planes).expect("must measure").volume;
let got_volume = mesh_divergence_volume(&mesh);
assert!(
(got_volume - want_volume).abs() < 1e-6,
"{label}: mesh divergence volume {got_volume} != measure_solid volume {want_volume}"
);
}
#[test]
fn build_solid_mesh_matches_measure_solid_on_a_plain_box() {
assert_mesh_matches_measure_solid(
&[
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Y, 0.6),
(DVec3::NEG_Y, 0.6),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
],
"plain box",
);
}
#[test]
fn build_solid_mesh_matches_measure_solid_on_a_hip_roofed_block() {
let s = std::f64::consts::FRAC_1_SQRT_2;
assert_mesh_matches_measure_solid(
&[
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
(DVec3::NEG_Y, 0.5),
(DVec3::new(s, s, 0.0), s),
(DVec3::new(-s, s, 0.0), s),
(DVec3::new(0.0, s, s), s),
(DVec3::new(0.0, s, -s), s),
],
"hip-roofed block",
);
}
#[test]
fn build_solid_mesh_matches_measure_solid_on_a_real_schedule() {
assert_mesh_matches_measure_solid(
&planes_from_asc_schedule(
"GemCad 5.0\n\
g 96 0.0\n\
y 6 y\n\
I 1.72\n\
H Bench design\n\
a -41.000000 0.64991234 92 n 1 84 76 68 60 52 44 36 28 20 12 4\n\
a -90.000000 1.07325092 92 n 2 84 76 68 60 52 44 36 28 20 12 4\n\
a 29.730000 0.65249790 4 n A 12 20 28 36 44 52 60 68 76 84 92\n\
a 25.000000 0.59508784 96 n B 16 32 48 64 80\n\
a 10.000000 0.48799664 96 n C 16 32 48 64 80\n\
a 0.000000 0.44000000 n T\n",
),
"real schedule: Bench design",
);
}
#[test]
fn build_solid_mesh_is_deterministic() {
let s = std::f64::consts::FRAC_1_SQRT_2;
let planes = vec![
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
(DVec3::NEG_Y, 0.5),
(DVec3::new(s, s, 0.0), s),
(DVec3::new(-s, s, 0.0), s),
(DVec3::new(0.0, s, s), s),
(DVec3::new(0.0, s, -s), s),
];
let (SolidStatus::Closed(a), SolidStatus::Closed(b)) =
(build_solid_mesh(&planes), build_solid_mesh(&planes))
else {
panic!("fixture must close");
};
assert_eq!(a.facet_id, b.facet_id);
assert_eq!(a.indices, b.indices);
assert_eq!(a.positions.len(), b.positions.len());
for (pa, pb) in a.positions.iter().zip(&b.positions) {
assert_eq!(
(pa.x.to_bits(), pa.y.to_bits(), pa.z.to_bits()),
(pb.x.to_bits(), pb.y.to_bits(), pb.z.to_bits())
);
}
for (na, nb) in a.normals.iter().zip(&b.normals) {
assert_eq!(
(na.x.to_bits(), na.y.to_bits(), na.z.to_bits()),
(nb.x.to_bits(), nb.y.to_bits(), nb.z.to_bits())
);
}
}
#[test]
fn build_solid_mesh_reports_unbounded_with_escaping_planes() {
let planes = vec![
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Y, 0.6),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
];
match build_solid_mesh(&planes) {
SolidStatus::Unbounded { escaping } => {
assert!(!escaping.is_empty(), "expected at least one escaping plane");
assert!(escaping.iter().all(|&i| i < planes.len()));
let mut sorted = escaping.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
escaping, sorted,
"escaping indices must be sorted and deduped"
);
}
other => panic!("expected Unbounded, got {other:?}"),
}
}
#[test]
fn build_solid_mesh_reports_degenerate_for_too_few_vertices() {
let planes = vec![
(DVec3::X, 0.0),
(DVec3::NEG_X, 0.0),
(DVec3::Y, 0.0),
(DVec3::NEG_Y, 0.0),
(DVec3::Z, 0.0),
(DVec3::NEG_Z, 0.0),
];
match build_solid_mesh(&planes) {
SolidStatus::Degenerate {
vertex_count,
volume,
} => {
assert!(vertex_count < 4, "vertex_count {vertex_count}");
assert!(volume.is_none() || volume == Some(0.0));
}
other => panic!("expected Degenerate, got {other:?}"),
}
}
#[test]
fn build_solid_mesh_reports_degenerate_for_zero_volume() {
let planes = vec![
(DVec3::X, 1.0),
(DVec3::NEG_X, 1.0),
(DVec3::Y, 0.0),
(DVec3::NEG_Y, 0.0),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
];
match build_solid_mesh(&planes) {
SolidStatus::Degenerate {
vertex_count,
volume,
} => {
assert_eq!(vertex_count, 4);
assert_eq!(volume, Some(0.0));
}
other => panic!("expected Degenerate, got {other:?}"),
}
}
#[test]
fn build_solid_mesh_dedups_planes_and_maps_indices_to_the_original_list() {
let planes = vec![
(DVec3::X, 1.0),
(DVec3::X, 1.0), (DVec3::NEG_X, 1.0),
(DVec3::Y, 0.6),
(DVec3::NEG_Y, 0.6),
(DVec3::Z, 1.0),
(DVec3::NEG_Z, 1.0),
];
let SolidStatus::Closed(mesh) = build_solid_mesh(&planes) else {
panic!("fixture must close");
};
assert_watertight(&mesh, "duplicate-plane box");
let x_owners: Vec<usize> = mesh
.rings
.iter()
.map(|&(idx, _)| idx)
.filter(|&idx| idx == 0 || idx == 1)
.collect();
assert_eq!(
x_owners.len(),
1,
"expected exactly one +X facet owner among indices [0, 1], got {x_owners:?}"
);
assert!(mesh.facet_id.iter().all(|&f| f < planes.len()));
let want_volume = measure_solid(&planes).expect("must measure").volume;
let got_volume = mesh_divergence_volume(&mesh);
assert!((got_volume - want_volume).abs() < 1e-9);
}
}