use super::*;
use crate::{
fillet_edges, make_box_brep, make_cone_brep, make_cylinder_brep, make_sphere_brep,
make_torus_brep, read_binary_stl, tessellate_brep_watertight, write_binary_stl,
};
fn segment_solid_mesh(mesh: &crate::Mesh) -> MeshSegmentation {
segment_mesh_faces(&mesh.positions, &mesh.indices, &SegmentOptions::default())
.expect("segmentation must succeed")
}
fn kinds(seg: &MeshSegmentation) -> Vec<&'static str> {
seg.regions.iter().map(|r| r.carrier.kind()).collect()
}
fn count_kind(seg: &MeshSegmentation, kind: &str) -> usize {
seg.regions
.iter()
.filter(|r| r.carrier.kind() == kind)
.count()
}
#[test]
fn rejects_malformed_input() {
let options = SegmentOptions::default();
assert!(segment_mesh_faces(&[0.0; 8], &[], &options).is_err());
assert!(segment_mesh_faces(
&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
&[0, 1, 7],
&options
)
.is_err());
assert!(segment_mesh_faces(&[], &[], &options).is_err());
}
#[test]
fn box_segments_into_six_oriented_planes() {
let solid = make_box_brep(Vec3::default(), 2.0, 3.0, 4.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let seg = segment_solid_mesh(&mesh);
assert_eq!(seg.regions.len(), 6, "kinds: {:?}", kinds(&seg));
let expected = [
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(-1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, -1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(0.0, 0.0, -1.0),
];
let mut matched = [false; 6];
for region in &seg.regions {
let RegionCarrier::Plane { normal, .. } = ®ion.carrier else {
panic!("box region {} is {:?}", region.id, region.carrier.kind());
};
assert!(region.max_deviation < 1e-9);
let slot = expected
.iter()
.position(|e| e.dot(*normal) > 0.999_999)
.unwrap_or_else(|| panic!("unexpected plane normal {normal:?}"));
assert!(!matched[slot], "duplicate outward normal {normal:?}");
matched[slot] = true;
}
assert!(matched.iter().all(|m| *m));
assert!(seg
.triangle_region_ids
.iter()
.all(|&id| id != UNASSIGNED_REGION));
}
#[test]
fn cylinder_segments_into_two_caps_and_wall() {
let solid =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let seg = segment_solid_mesh(&mesh);
assert_eq!(seg.regions.len(), 3, "kinds: {:?}", kinds(&seg));
assert_eq!(count_kind(&seg, "plane"), 2);
assert_eq!(count_kind(&seg, "cylinder"), 1);
let wall = seg
.regions
.iter()
.find(|r| matches!(r.carrier, RegionCarrier::Cylinder { .. }))
.unwrap();
let RegionCarrier::Cylinder {
axis_point,
axis_dir,
radius,
sense,
} = wall.carrier.clone()
else {
unreachable!()
};
assert!((radius - 2.0).abs() < 1e-3, "radius {radius}");
assert!(
axis_dir.cross(Vec3::new(0.0, 0.0, 1.0)).length() < 1e-3,
"axis {axis_dir:?}"
);
assert!(
(axis_point.x.powi(2) + axis_point.y.powi(2)).sqrt() < 1e-3,
"axis point {axis_point:?}"
);
assert_eq!(sense, 1, "outward wall normals");
let mut cap_dirs: Vec<f64> = seg
.regions
.iter()
.filter_map(|r| match &r.carrier {
RegionCarrier::Plane { normal, .. } => Some(normal.z),
_ => None,
})
.collect();
cap_dirs.sort_by(f64::total_cmp);
assert!(cap_dirs[0] < -0.999_999 && cap_dirs[1] > 0.999_999);
}
#[test]
fn sphere_segments_into_single_spherical_region() {
let center = Vec3::new(1.0, -2.0, 0.5);
let solid = make_sphere_brep(center, 3.0, Vec3::new(0.0, 1.0, 0.0)).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let seg = segment_solid_mesh(&mesh);
assert_eq!(seg.regions.len(), 1, "kinds: {:?}", kinds(&seg));
let RegionCarrier::Sphere {
center: fitted,
radius,
sense,
} = seg.regions[0].carrier.clone()
else {
panic!("sphere region is {:?}", seg.regions[0].carrier.kind());
};
assert!(fitted.sub(center).length() < 1e-3, "center {fitted:?}");
assert!((radius - 3.0).abs() < 1e-3, "radius {radius}");
assert_eq!(sense, 1);
}
#[test]
fn cone_segments_into_frustum_wall_and_caps() {
let solid =
make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 1.5, 4.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let seg = segment_solid_mesh(&mesh);
assert_eq!(seg.regions.len(), 3, "kinds: {:?}", kinds(&seg));
assert_eq!(count_kind(&seg, "plane"), 2);
assert_eq!(count_kind(&seg, "cone"), 1);
let wall = seg
.regions
.iter()
.find(|r| matches!(r.carrier, RegionCarrier::Cone { .. }))
.unwrap();
let RegionCarrier::Cone {
apex,
axis_dir,
half_angle_rad,
sense,
} = wall.carrier.clone()
else {
unreachable!()
};
assert!(
apex.sub(Vec3::new(0.0, 0.0, 8.0)).length() < 1e-3,
"apex {apex:?}"
);
assert!(
axis_dir.z < -0.999_999,
"axis from apex into region: {axis_dir:?}"
);
let expected = (3.0_f64 / 8.0).atan();
assert!(
(half_angle_rad - expected).abs() < 1e-4,
"half angle {half_angle_rad} vs {expected}"
);
assert_eq!(sense, 1);
}
#[test]
fn torus_segments_into_single_toroidal_region() {
let center = Vec3::new(1.0, -2.0, 0.5);
let solid = make_torus_brep(center, Vec3::new(0.0, 0.0, 1.0), 5.0, 1.5).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-2).unwrap();
let seg = segment_solid_mesh(&mesh);
assert_eq!(seg.regions.len(), 1, "kinds: {:?}", kinds(&seg));
let RegionCarrier::Torus {
center: fitted,
axis_dir,
major_radius,
minor_radius,
sense,
} = seg.regions[0].carrier.clone()
else {
panic!("torus region is {:?}", seg.regions[0].carrier.kind());
};
assert!(fitted.sub(center).length() < 1e-3, "center {fitted:?}");
assert!((major_radius - 5.0).abs() < 1e-3, "major {major_radius}");
assert!((minor_radius - 1.5).abs() < 1e-3, "minor {minor_radius}");
assert!(
axis_dir.cross(Vec3::new(0.0, 0.0, 1.0)).length() < 1e-3,
"axis {axis_dir:?}"
);
assert_eq!(sense, 1);
}
#[test]
fn filleted_box_yields_six_planes_and_blend_cylinder() {
let solid = make_box_brep(Vec3::default(), 10.0, 10.0, 10.0).unwrap();
let filleted = fillet_edges(&solid, &[Vec3::new(10.0, 10.0, 5.0)], None, 1.0, false, Some("F"))
.expect("fillet must succeed");
let mesh = tessellate_brep_watertight(&filleted, 1e-3).unwrap();
let seg = segment_solid_mesh(&mesh);
assert_eq!(count_kind(&seg, "plane"), 6, "kinds: {:?}", kinds(&seg));
assert_eq!(count_kind(&seg, "cylinder"), 1, "kinds: {:?}", kinds(&seg));
assert_eq!(count_kind(&seg, "freeform"), 0, "kinds: {:?}", kinds(&seg));
let blend = seg
.regions
.iter()
.find(|r| matches!(r.carrier, RegionCarrier::Cylinder { .. }))
.unwrap();
let RegionCarrier::Cylinder {
axis_point,
axis_dir,
radius,
..
} = blend.carrier.clone()
else {
unreachable!()
};
assert!((radius - 1.0).abs() < 1e-3, "blend radius {radius}");
assert!(
axis_dir.cross(Vec3::new(0.0, 0.0, 1.0)).length() < 1e-3,
"blend axis {axis_dir:?}"
);
assert!(
(axis_point.x - 9.0).abs() < 1e-3 && (axis_point.y - 9.0).abs() < 1e-3,
"blend axis point {axis_point:?}"
);
}
#[test]
fn stl_round_trip_soup_segments_like_indexed_mesh() {
let solid =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let bytes = write_binary_stl(&mesh, "cylinder").unwrap();
let parsed = read_binary_stl(&bytes).unwrap();
let seg = segment_mesh_faces(&parsed.positions, &[], &SegmentOptions::default())
.expect("soup segmentation must succeed");
assert_eq!(seg.regions.len(), 3, "kinds: {:?}", kinds(&seg));
assert_eq!(count_kind(&seg, "plane"), 2);
assert_eq!(count_kind(&seg, "cylinder"), 1);
let wall = seg
.regions
.iter()
.find(|r| matches!(r.carrier, RegionCarrier::Cylinder { .. }))
.unwrap();
let RegionCarrier::Cylinder { radius, .. } = wall.carrier.clone() else {
unreachable!()
};
assert!((radius - 2.0).abs() < 1e-3, "radius {radius}");
}
#[test]
fn options_control_granularity_and_fitting() {
let solid =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let strict = SegmentOptions {
deflection_angle_deg: 1.0,
..SegmentOptions::default()
};
let seg = segment_mesh_faces(&mesh.positions, &mesh.indices, &strict).unwrap();
assert!(seg.regions.len() > 3, "regions {}", seg.regions.len());
let gated = SegmentOptions {
min_region_triangles: 1_000_000,
..SegmentOptions::default()
};
let seg = segment_mesh_faces(&mesh.positions, &mesh.indices, &gated).unwrap();
assert!(seg
.regions
.iter()
.all(|r| matches!(r.carrier, RegionCarrier::Freeform)));
}
#[test]
fn segmentation_serializes_round_trip() {
let solid = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let seg = segment_solid_mesh(&mesh);
let json = serde_json::to_string(&seg).expect("serialize");
let parsed: MeshSegmentation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.regions.len(), seg.regions.len());
assert_eq!(parsed.triangle_region_ids, seg.triangle_region_ids);
}
use crate::solid_mass_properties;
fn mesh_volume(mesh: &crate::Mesh) -> f64 {
let point = |index: usize| {
let i = mesh.indices[index] as usize;
Vec3::new(
mesh.positions[3 * i],
mesh.positions[3 * i + 1],
mesh.positions[3 * i + 2],
)
};
let mut volume = 0.0;
for triangle in 0..mesh.indices.len() / 3 {
let a = point(3 * triangle);
let b = point(3 * triangle + 1);
let c = point(3 * triangle + 2);
volume += a.dot(b.cross(c)) / 6.0;
}
volume
}
fn is_plane_face(face: &crate::topology::FaceRecord) -> bool {
face.surface.degree_u == 1
&& face.surface.degree_v == 1
&& face.surface.control_points.len() == 2
&& face.surface.control_points[0].len() == 2
}
#[test]
fn rebuilds_box_mesh_as_six_exact_planar_faces() {
let solid = make_box_brep(Vec3::default(), 2.0, 3.0, 4.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let rebuilt =
mesh_regions_to_brep(&mesh.positions, &mesh.indices, &SegmentOptions::default())
.expect("box rebuild must succeed");
assert!(rebuilt.validate().is_empty(), "{:?}", rebuilt.validate());
assert_eq!(rebuilt.shells.len(), 1);
assert_eq!(rebuilt.shells[0].faces.len(), 6, "one face per box side");
assert!(rebuilt.shells[0].faces.iter().all(is_plane_face));
assert_eq!(rebuilt.vertices.len(), 8, "corners survive as vertices");
assert_eq!(rebuilt.edges.len(), 12, "collinear chains merged per edge");
assert_eq!(rebuilt.genus, 0);
let volume = solid_mass_properties(&rebuilt).unwrap().volume;
assert!((volume - 24.0).abs() < 1e-9, "volume {volume}");
}
#[test]
fn rebuilds_cylinder_mesh_with_exact_wall_beating_chordal_volume() {
let radius = 2.0;
let height = 5.0;
let solid =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), radius, height).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let rebuilt =
mesh_regions_to_brep(&mesh.positions, &mesh.indices, &SegmentOptions::default())
.expect("cylinder rebuild must succeed");
assert!(rebuilt.validate().is_empty(), "{:?}", rebuilt.validate());
assert_eq!(rebuilt.shells[0].faces.len(), 3, "two caps + one wall");
assert_eq!(
rebuilt.shells[0]
.faces
.iter()
.filter(|face| is_plane_face(face))
.count(),
2
);
assert_eq!(rebuilt.vertices.len(), 2, "one seam vertex per ring");
assert_eq!(rebuilt.edges.len(), 3, "two circles + seam");
let exact = std::f64::consts::PI * radius * radius * height;
let volume = solid_mass_properties(&rebuilt).unwrap().volume;
let chordal = mesh_volume(&mesh);
assert!(
(volume - exact).abs() < 1e-6,
"rebuilt volume {volume} vs exact {exact}"
);
assert!(
(volume - exact).abs() < (chordal - exact).abs(),
"rebuilt error {:.3e} must beat chordal error {:.3e}",
(volume - exact).abs(),
(chordal - exact).abs()
);
}
#[test]
fn rebuilds_filleted_box_with_exact_cylindrical_blend() {
let solid = make_box_brep(Vec3::default(), 10.0, 10.0, 10.0).unwrap();
let filleted = fillet_edges(&solid, &[Vec3::new(10.0, 10.0, 5.0)], None, 1.0, false, Some("F"))
.expect("fillet must succeed");
let mesh = tessellate_brep_watertight(&filleted, 1e-3).unwrap();
let rebuilt =
mesh_regions_to_brep(&mesh.positions, &mesh.indices, &SegmentOptions::default())
.expect("filleted box rebuild must succeed");
assert!(rebuilt.validate().is_empty(), "{:?}", rebuilt.validate());
assert_eq!(rebuilt.shells[0].faces.len(), 7, "six planes + one blend");
assert_eq!(
rebuilt.shells[0]
.faces
.iter()
.filter(|face| is_plane_face(face))
.count(),
6
);
assert_eq!(rebuilt.genus, 0);
let exact = 1000.0 - (1.0 - std::f64::consts::PI / 4.0) * 10.0;
let volume = solid_mass_properties(&rebuilt).unwrap().volume;
let chordal = mesh_volume(&mesh);
assert!(
(volume - exact).abs() < 1e-6,
"rebuilt volume {volume} vs exact {exact}"
);
assert!(
(volume - exact).abs() < (chordal - exact).abs(),
"rebuilt error {:.3e} must beat chordal error {:.3e}",
(volume - exact).abs(),
(chordal - exact).abs()
);
}
#[test]
fn refuses_boundary_less_sphere_region_honestly() {
let solid = make_sphere_brep(Vec3::default(), 1.5, Vec3::new(0.0, 0.0, 1.0)).unwrap();
let mesh = tessellate_brep_watertight(&solid, 5e-3).unwrap();
let error =
mesh_regions_to_brep(&mesh.positions, &mesh.indices, &SegmentOptions::default())
.expect_err("sphere region has no v1 face topology");
assert!(error.contains("sphere"), "unexpected error: {error}");
}
#[test]
fn all_freeform_mesh_falls_back_to_faceted_import() {
let solid = make_box_brep(Vec3::default(), 2.0, 3.0, 4.0).unwrap();
let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
let options = SegmentOptions {
min_region_triangles: 1_000_000,
..SegmentOptions::default()
};
let rebuilt = mesh_regions_to_brep(&mesh.positions, &mesh.indices, &options)
.expect("all-freeform fallback must import");
assert!(rebuilt.validate().is_empty(), "{:?}", rebuilt.validate());
assert!(
rebuilt.shells[0].faces.len() > 6,
"fallback keeps triangle-per-face granularity"
);
let volume = solid_mass_properties(&rebuilt).unwrap().volume;
assert!((volume - 24.0).abs() < 1e-9, "volume {volume}");
}