use axiolid_contracts::{CancellationToken, ExecutionOptions, GeomError, Operation};
use axiolid_core::{Frame3, Point2, Point3, Tolerance, Vec3};
use axiolid_dispatch::MeshPlaneSectionRegistry;
use axiolid_mesh::TriMesh;
use axiolid_mesh_section_contract::{
ChainEnd, CutRegion, CutRegionLimits, InPlaneSide, MeshPlaneSection, PlaneSection,
RegionLimits, SectionLimits, SectionRequest, SectionRole, REFUSED_CROSSING_CUT_RINGS,
REFUSED_IN_PLANE_FACES_ON_BOTH_SIDES, REFUSED_OVERLAPPING_IN_PLANE_FACES,
REFUSED_SADDLE_VERTEX,
};
use axiolid_reference::ScalarSection;
fn cuboid(x: Span, y: Span, z: Span) -> (Vec<Point3>, Vec<u32>) {
let positions = vec![
Point3::new(x.0, y.0, z.0),
Point3::new(x.1, y.0, z.0),
Point3::new(x.1, y.1, z.0),
Point3::new(x.0, y.1, z.0),
Point3::new(x.0, y.0, z.1),
Point3::new(x.1, y.0, z.1),
Point3::new(x.1, y.1, z.1),
Point3::new(x.0, y.1, z.1),
];
let indices = vec![
0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6,
3, 0, 4, 3, 4, 7,
];
(positions, indices)
}
type Span = (f64, f64);
fn shells(parts: &[(Span, Span, Span)]) -> TriMesh {
let mut positions = Vec::new();
let mut indices = Vec::new();
for &(x, y, z) in parts {
let (box_positions, box_indices) = cuboid(x, y, z);
let offset = u32::try_from(positions.len()).expect("small fixture");
positions.extend(box_positions);
indices.extend(box_indices.into_iter().map(|index| index + offset));
}
TriMesh::new(positions, indices)
}
fn cube() -> TriMesh {
shells(&[((0.0, 1.0), (0.0, 1.0), (0.0, 1.0))])
}
fn frame(origin: Point3, x: Vec3, y: Vec3) -> Frame3 {
Frame3 {
origin,
x,
y,
z: x.cross(y),
}
}
fn horizontal(z: f64) -> Frame3 {
frame(Point3::new(0.0, 0.0, z), Vec3::X, Vec3::Y)
}
fn request(frame: Frame3) -> SectionRequest {
SectionRequest::new(
frame,
SectionLimits::new(1_000, 1_000, 1_000, 100),
RegionLimits::new(100, 1_000, 1_000),
)
}
fn options() -> ExecutionOptions {
ExecutionOptions::new(Tolerance::METRE)
}
fn section(mesh: &TriMesh, frame: Frame3) -> Result<PlaneSection, GeomError> {
ScalarSection::new().section_request(mesh, &request(frame), &options())
}
fn ring(points: &[(f64, f64)]) -> Vec<Point2> {
points.iter().map(|&(x, y)| Point2::new(x, y)).collect()
}
#[test]
fn cube_top_in_the_plane_is_one_region_below() {
let result = section(&cube(), horizontal(1.0)).expect("section");
assert!(result.chains.is_empty());
assert_eq!(result.regions.len(), 1);
assert_eq!(result.regions[0].side, InPlaneSide::Below);
assert_eq!(
result.regions[0].outer,
ring(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)])
);
assert_eq!(result.evidence.in_plane_triangles, 2);
assert_eq!(result.evidence.touching_edges, 0);
assert_eq!(result.evidence.in_plane_pairs, 1);
}
#[test]
fn a_mirrored_frame_flips_the_side_and_the_ring_stays_counter_clockwise() {
let mirrored = frame(Point3::new(0.0, 0.0, 1.0), Vec3::X, -Vec3::Y);
assert_eq!(mirrored.z, -Vec3::Z);
let result = section(&cube(), mirrored).expect("section");
assert_eq!(result.regions.len(), 1);
assert_eq!(result.regions[0].side, InPlaneSide::Above);
assert_eq!(
result.regions[0].outer,
ring(&[(0.0, -1.0), (1.0, -1.0), (1.0, 0.0), (0.0, 0.0)])
);
}
#[test]
fn a_vertical_frame_drops_another_axis() {
let vertical = frame(Point3::new(0.0, 0.0, 0.0), Vec3::X, Vec3::Z);
assert_eq!(vertical.z, -Vec3::Y);
let result = section(&cube(), vertical).expect("section");
assert!(result.chains.is_empty());
assert_eq!(result.regions.len(), 1);
assert_eq!(result.regions[0].side, InPlaneSide::Below);
assert_eq!(
result.regions[0].outer,
ring(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)])
);
}
#[test]
fn a_transversal_plane_still_gives_the_closed_contour() {
let result = section(&cube(), horizontal(0.5)).expect("section");
assert!(result.regions.is_empty());
assert_eq!(result.chains.len(), 1);
assert!(result.chains[0].is_closed());
assert_eq!(
result.chains[0].points,
ring(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)])
);
assert_eq!(result.evidence.closed_chains, 1);
}
#[test]
fn a_mirrored_frame_still_gets_a_counter_clockwise_contour() {
let mirrored = frame(Point3::new(0.0, 0.0, 0.5), Vec3::X, -Vec3::Y);
let result = section(&cube(), mirrored).expect("section");
assert_eq!(result.chains.len(), 1);
assert_eq!(
result.chains[0].points,
ring(&[(0.0, -1.0), (1.0, -1.0), (1.0, 0.0), (0.0, 0.0)])
);
}
#[test]
fn a_plane_above_the_solid_gives_nothing() {
let result = section(&cube(), horizontal(2.0)).expect("section");
assert!(result.chains.is_empty() && result.regions.is_empty());
assert_eq!(result.evidence.in_plane_triangles, 0);
}
#[test]
fn overlapping_faces_of_one_side_are_refused_by_name() {
let mesh = shells(&[
((0.0, 2.0), (0.0, 1.0), (0.0, 1.0)),
((1.0, 3.0), (0.0, 1.0), (0.0, 1.0)),
]);
assert_unsupported(
section(&mesh, horizontal(1.0)),
REFUSED_OVERLAPPING_IN_PLANE_FACES,
);
}
#[test]
fn faces_of_opposite_sides_that_overlap_are_refused_by_name() {
let mesh = shells(&[
((0.0, 4.0), (0.0, 4.0), (0.0, 1.0)),
((1.0, 2.0), (1.0, 2.0), (1.0, 2.0)),
]);
assert_unsupported(
section(&mesh, horizontal(1.0)),
REFUSED_IN_PLANE_FACES_ON_BOTH_SIDES,
);
}
#[test]
fn faces_of_opposite_sides_that_only_touch_are_not_an_overlap() {
let mesh = shells(&[
((0.0, 1.0), (0.0, 1.0), (0.0, 1.0)),
((1.0, 2.0), (0.0, 1.0), (1.0, 2.0)),
]);
let result = section(&mesh, horizontal(1.0)).expect("touching is not overlapping");
assert_eq!(result.regions.len(), 2);
assert_eq!(result.regions[0].side, InPlaneSide::Below);
assert_eq!(result.regions[1].side, InPlaneSide::Above);
assert_eq!(
result.regions[1].outer,
ring(&[(1.0, 0.0), (2.0, 0.0), (2.0, 1.0), (1.0, 1.0)])
);
}
#[test]
fn a_saddle_vertex_is_refused_by_name() {
let positions = vec![
Point3::new(1.0, 0.0, 1.0),
Point3::new(0.0, 1.0, -1.0),
Point3::new(-1.0, 0.0, 1.0),
Point3::new(0.0, -1.0, -1.0),
Point3::new(0.0, 0.0, 0.0),
Point3::new(0.0, 0.0, -3.0),
];
let mut indices = Vec::new();
for k in 0..4_u32 {
let n = (k + 1) % 4;
indices.extend([4, k, n]);
indices.extend([5, n, k]);
}
let mesh = TriMesh::new(positions, indices);
assert_unsupported(section(&mesh, horizontal(0.0)), REFUSED_SADDLE_VERTEX);
}
#[test]
fn region_budgets_and_cancellation_are_enforced() {
let mesh = cube();
let limits = SectionLimits::new(1_000, 1_000, 1_000, 100);
for (region_limits, resource) in [
(
RegionLimits::new(0, 1_000, 1_000),
"section in-plane regions",
),
(
RegionLimits::new(10, 3, 1_000),
"section in-plane region vertices",
),
] {
let request = SectionRequest::new(horizontal(1.0), limits, region_limits);
assert_eq!(
ScalarSection::new().section_request(&mesh, &request, &options()),
Err(GeomError::BudgetExceeded { resource })
);
}
let overlapping = shells(&[
((0.0, 2.0), (0.0, 1.0), (0.0, 1.0)),
((1.0, 3.0), (0.0, 1.0), (0.0, 1.0)),
]);
let request = SectionRequest::new(horizontal(1.0), limits, RegionLimits::new(10, 100, 0));
assert_eq!(
ScalarSection::new().section_request(&overlapping, &request, &options()),
Err(GeomError::BudgetExceeded {
resource: "section in-plane pairs"
})
);
let token = CancellationToken::new();
token.cancel();
let cancelled = ExecutionOptions::new(Tolerance::METRE).with_cancellation(token);
assert_eq!(
ScalarSection::new().section_request(&mesh, &request_for_cube(), &cancelled),
Err(GeomError::Cancelled)
);
}
fn request_for_cube() -> SectionRequest {
request(horizontal(1.0))
}
#[test]
fn registration_runs_the_region_suite_for_a_provider_that_claims_it() {
#[derive(Debug)]
struct Wrong;
impl axiolid_contracts::Backend for Wrong {
fn descriptor(&self) -> axiolid_contracts::BackendDescriptor {
ScalarSection::new().descriptor()
}
}
impl MeshPlaneSection for Wrong {
fn section(
&self,
mesh: &TriMesh,
frame: Frame3,
limits: SectionLimits,
options: &ExecutionOptions,
) -> Result<axiolid_mesh_section_contract::SectionOutcome, GeomError> {
ScalarSection::new().section(mesh, frame, limits, options)
}
fn section_request(
&self,
mesh: &TriMesh,
request: &SectionRequest,
options: &ExecutionOptions,
) -> Result<PlaneSection, GeomError> {
let mut answer = ScalarSection::new().section_request(mesh, request, options)?;
answer.regions.clear();
Ok(answer)
}
}
#[derive(Debug)]
struct Legacy;
impl axiolid_contracts::Backend for Legacy {
fn descriptor(&self) -> axiolid_contracts::BackendDescriptor {
ScalarSection::new().descriptor()
}
}
impl MeshPlaneSection for Legacy {
fn section(
&self,
mesh: &TriMesh,
frame: Frame3,
limits: SectionLimits,
options: &ExecutionOptions,
) -> Result<axiolid_mesh_section_contract::SectionOutcome, GeomError> {
ScalarSection::new().section(mesh, frame, limits, options)
}
}
let mut registry = MeshPlaneSectionRegistry::new();
registry
.register_request_conformant(0, ScalarSection::new())
.expect("the reference provider is conformant");
registry
.register_request_conformant(0, Legacy)
.expect("a provider that does not claim the method passes");
let report = registry
.register_request_conformant(0, Wrong)
.expect_err("a claimed wrong answer is rejected");
assert!(report.claimed && !report.is_success());
}
#[test]
fn the_registry_accepts_the_reference_answer() {
let mut registry = MeshPlaneSectionRegistry::new();
registry.register(0, ScalarSection::new());
let result = registry
.section_request(&cube(), &request(horizontal(1.0)), &options())
.expect("registry validates the reference answer");
assert_eq!(result.regions.len(), 1);
}
#[test]
fn the_legacy_path_still_refuses_an_in_plane_face() {
let mut registry = MeshPlaneSectionRegistry::new();
registry.register(0, ScalarSection::new());
let error = registry
.section(
&cube(),
horizontal(1.0),
SectionLimits::new(100, 100, 100, 10),
&options(),
)
.expect_err("legacy refuses");
assert!(matches!(error, GeomError::Degenerate(_)), "{error:?}");
}
fn assert_unsupported(result: Result<PlaneSection, GeomError>, family: &'static str) {
match result {
Err(GeomError::UnsupportedInput {
operation: Operation::MeshPlaneSection,
input,
..
}) => assert_eq!(input, family),
other => panic!("expected a refusal naming {family:?}, got {other:?}"),
}
}
fn sheet(mesh: &TriMesh, frame: Frame3) -> Result<PlaneSection, GeomError> {
let request = request(frame).with_role(SectionRole::Sheet);
ScalarSection::new().section_request(mesh, &request, &options())
}
fn mesh_of(points: &[[f64; 3]], indices: &[u32]) -> TriMesh {
TriMesh::new(
points
.iter()
.map(|&[x, y, z]| Point3::new(x, y, z))
.collect(),
indices.to_vec(),
)
}
fn floor_and_wall() -> TriMesh {
mesh_of(
&[
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 2.0, 0.0],
[3.0, -1.0, 1.0],
[3.0, 1.0, -1.0],
],
&[0, 1, 2, 1, 3, 4],
)
}
#[test]
fn a_chain_ends_on_a_region_at_one_end_and_on_the_mesh_boundary_at_the_other() {
let found = sheet(&floor_and_wall(), horizontal(0.0)).expect("section");
assert_eq!(found.chains.len(), 1);
assert_eq!(found.chains[0].points, ring(&[(2.0, 0.0), (3.0, 0.0)]));
assert_eq!(
found.chains[0].ends,
Some([ChainEnd::InPlaneRegion, ChainEnd::MeshBoundary])
);
assert_eq!(found.regions.len(), 1);
assert_eq!(found.regions[0].side, InPlaneSide::Below);
assert_eq!(
found.regions[0].outer,
ring(&[(0.0, 0.0), (2.0, 0.0), (0.0, 2.0)])
);
let evidence = found.evidence;
assert_eq!(
(evidence.role, evidence.boundary_edges),
(SectionRole::Sheet, 6)
);
assert_eq!(
(evidence.in_plane_triangles, evidence.touching_edges),
(1, 0)
);
let mirrored = frame(Point3::new(0.0, 0.0, 0.0), -Vec3::X, Vec3::Y);
let found = sheet(&floor_and_wall(), mirrored).expect("section");
assert_eq!(found.chains[0].points, ring(&[(-3.0, 0.0), (-2.0, 0.0)]));
assert_eq!(
found.chains[0].ends,
Some([ChainEnd::MeshBoundary, ChainEnd::InPlaneRegion])
);
assert_eq!(found.regions[0].side, InPlaneSide::Above);
}
#[test]
fn a_solid_cannot_end_a_chain_on_the_mesh_boundary() {
let wall = mesh_of(
&[
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
],
&[0, 1, 2, 0, 2, 3],
);
let vertical = frame(Point3::new(1.0, 0.0, 0.0), Vec3::Y, Vec3::Z);
let found = sheet(&wall, vertical).expect("a sheet may end on the boundary");
assert_eq!(found.chains.len(), 1);
assert_eq!(
found.chains[0].ends,
Some([ChainEnd::MeshBoundary, ChainEnd::MeshBoundary])
);
let error = section(&wall, vertical).expect_err("a solid is closed");
assert!(
matches!(error, GeomError::BackendContractViolation { .. }),
"{error:?}"
);
}
#[test]
fn an_edge_in_the_plane_with_one_off_plane_triangle_only_touches() {
let wall = mesh_of(
&[
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
],
&[0, 1, 2, 0, 2, 3],
);
let found = sheet(&wall, horizontal(0.0)).expect("section");
assert!(found.chains.is_empty() && found.regions.is_empty());
assert_eq!(found.evidence.touching_edges, 1);
assert_eq!(found.evidence.boundary_edges, 4);
}
#[test]
fn a_closed_chain_of_a_sheet_is_counter_clockwise_whatever_its_facing() {
let ring_points = [
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[-1.0, 0.0, 0.0],
[0.0, -1.0, 0.0],
];
for outward in [true, false] {
let mut points = ring_points.to_vec();
points.push([0.0, 0.0, 1.0]);
let mut indices = Vec::new();
for k in 0..4_u32 {
let n = (k + 1) % 4;
indices.extend(if outward { [k, n, 4] } else { [n, k, 4] });
}
let found = sheet(&mesh_of(&points, &indices), horizontal(0.5)).expect("section");
assert_eq!(found.chains.len(), 1, "outward {outward}");
assert_eq!(found.chains[0].ends, None);
assert_eq!(
found.chains[0].points,
ring(&[(-0.5, 0.0), (0.0, -0.5), (0.5, 0.0), (0.0, 0.5)]),
"outward {outward}"
);
assert_eq!(found.evidence.boundary_edges, 4);
}
}
#[test]
fn a_sheet_folded_flat_onto_itself_is_refused_as_faces_of_both_sides() {
let folded = mesh_of(
&[
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.5, 1.0, 0.0],
],
&[0, 1, 2, 1, 0, 3],
);
assert_unsupported(
sheet(&folded, horizontal(0.0)),
REFUSED_IN_PLANE_FACES_ON_BOTH_SIDES,
);
}
#[test]
fn a_registry_refuses_a_sheet_with_inconsistent_winding() {
let mut registry = MeshPlaneSectionRegistry::new();
registry.register(0, ScalarSection::new());
let mesh = mesh_of(
&[
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[1.0, -1.0, 0.0],
],
&[0, 1, 2, 0, 1, 3],
);
let request = request(horizontal(1.0)).with_role(SectionRole::Sheet);
assert!(matches!(
registry.section_request(&mesh, &request, &options()),
Err(GeomError::NotManifold(_))
));
}
fn extruded(profile: &[(f64, f64)], triangles: &[[u32; 3]], depth: f64) -> TriMesh {
let count = u32::try_from(profile.len()).expect("small fixture");
let mut positions: Vec<Point3> = profile
.iter()
.map(|&(x, z)| Point3::new(x, 0.0, z))
.collect();
positions.extend(profile.iter().map(|&(x, z)| Point3::new(x, depth, z)));
let mut indices = Vec::new();
for &[a, b, c] in triangles {
indices.extend([a, b, c]);
indices.extend([a + count, c + count, b + count]);
}
for from in 0..count {
let to = (from + 1) % count;
indices.extend([from, from + count, to + count, from, to + count, to]);
}
TriMesh::new(positions, indices)
}
fn l_step() -> TriMesh {
extruded(
&[
(0.0, 0.0),
(2.0, 0.0),
(2.0, 1.0),
(1.0, 1.0),
(1.0, 2.0),
(0.0, 2.0),
],
&[[0, 1, 2], [0, 2, 3], [0, 3, 4], [0, 4, 5]],
1.0,
)
}
fn cut_request(frame: Frame3) -> SectionRequest {
request(frame).with_cut_regions(CutRegionLimits::new(100, 1_000, 1_000))
}
fn registry() -> MeshPlaneSectionRegistry {
let mut registry = MeshPlaneSectionRegistry::new();
registry.register(0, ScalarSection::new());
registry
}
fn is_refused_as_crossing(result: Result<PlaneSection, GeomError>) -> bool {
matches!(
result,
Err(GeomError::UnsupportedInput {
input: REFUSED_CROSSING_CUT_RINGS,
..
})
)
}
#[test]
fn the_cut_of_an_l_step_closes_along_the_region_edge() {
let mesh = l_step();
let plain = registry()
.section_request(&mesh, &request(horizontal(1.0)), &options())
.expect("plain request");
assert_eq!(plain.cut_regions, None);
assert_eq!(plain.regions.len(), 1);
assert_eq!(plain.regions[0].side, InPlaneSide::Below);
let cut = registry()
.section_request(&mesh, &cut_request(horizontal(1.0)), &options())
.expect("cut request");
assert_eq!(cut.chains, plain.chains);
assert_eq!(cut.regions, plain.regions);
assert_eq!(
cut.cut_regions,
Some(vec![CutRegion::new(
ring(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]),
vec![],
)])
);
assert_eq!(
(cut.evidence.cut_regions, cut.evidence.cut_region_vertices),
(1, 4)
);
}
#[test]
fn a_cut_inside_a_face_needs_no_region_edge() {
let section = registry()
.section_request(&cube(), &cut_request(horizontal(0.5)), &options())
.expect("cut request");
assert_eq!(
section.cut_regions,
Some(vec![CutRegion::new(
ring(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]),
vec![],
)])
);
}
#[test]
fn the_cube_top_in_the_plane_has_no_cut() {
let section = registry()
.section_request(&cube(), &cut_request(horizontal(1.0)), &options())
.expect("cut request");
assert_eq!(section.regions.len(), 1);
assert_eq!(section.cut_regions, Some(vec![]));
}
#[test]
fn crossing_shells_are_refused_for_the_cut_only() {
let plus = shells(&[
((0.0, 3.0), (1.0, 2.0), (0.0, 1.0)),
((1.0, 2.0), (0.0, 3.0), (0.0, 1.0)),
]);
registry()
.section_request(&plus, &request(horizontal(0.5)), &options())
.expect("the plain request does not look at the cut");
assert!(is_refused_as_crossing(registry().section_request(
&plus,
&cut_request(horizontal(0.5)),
&options()
)));
}
#[test]
fn overlapping_boxes_with_collinear_edges_are_refused() {
let overlapping = shells(&[
((0.0, 2.0), (0.0, 1.0), (0.0, 1.0)),
((1.0, 3.0), (0.0, 1.0), (0.0, 1.0)),
]);
assert!(is_refused_as_crossing(registry().section_request(
&overlapping,
&cut_request(horizontal(0.5)),
&options()
)));
}
#[test]
fn a_sheet_has_no_cut() {
let sheet = cut_request(horizontal(0.5)).with_role(SectionRole::Sheet);
let error = registry()
.section_request(&cube(), &sheet, &options())
.expect_err("a sheet has no inside");
assert!(matches!(error, GeomError::InvalidInput(_)), "{error:?}");
let error = ScalarSection::new()
.section_request(&cube(), &sheet, &options())
.expect_err("the provider refuses it too");
assert!(matches!(error, GeomError::InvalidInput(_)), "{error:?}");
}
#[test]
fn cut_region_budgets_are_enforced() {
let neighbours = shells(&[
((0.0, 1.0), (0.0, 1.0), (0.0, 1.0)),
((1.0, 2.0), (0.0, 1.0), (0.0, 1.0)),
]);
for (limits, resource) in [
(CutRegionLimits::new(1, 1_000, 1_000), "section cut regions"),
(
CutRegionLimits::new(10, 7, 1_000),
"section cut region vertices",
),
(CutRegionLimits::new(10, 1_000, 0), "section cut pairs"),
] {
let request = request(horizontal(0.5)).with_cut_regions(limits);
assert_eq!(
ScalarSection::new().section_request(&neighbours, &request, &options()),
Err(GeomError::BudgetExceeded { resource })
);
}
}