use super::*;
use crate::topology::{FaceRecord, ShellRecord};
use crate::{
interpolate_curve, make_arc, make_box_brep, make_cone_brep, make_cylinder_brep, make_line,
make_revolution, make_sphere_brep, make_torus_brep, NurbsSurface, Vec4,
};
fn assert_close(actual: f64, expected: f64, label: &str) {
assert!(
(actual - expected).abs() < 1e-9,
"{label}: expected {expected}, got {actual}"
);
}
fn assert_vec3(actual: Vec3, expected: Vec3, label: &str) {
assert_close(actual.x, expected.x, &format!("{label}.x"));
assert_close(actual.y, expected.y, &format!("{label}.y"));
assert_close(actual.z, expected.z, &format!("{label}.z"));
}
fn assert_parallel(direction: Vec3, axis: Vec3, label: &str) {
assert_close(direction.dot(axis).abs(), 1.0, label);
}
fn face_ids(solid: &BrepSolid) -> Vec<u64> {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.map(|face| face.id)
.collect()
}
fn face_only_solid(surface: NurbsSurface) -> BrepSolid {
BrepSolid {
id: 1,
vertices: vec![],
edges: vec![],
shells: vec![ShellRecord {
id: 1,
faces: vec![FaceRecord {
id: 1,
surface,
same_sense: true,
loops: vec![],
name: None,
}],
}],
genus: 0,
}
}
fn edge_only_solid(curve: NurbsCurve, t0: f64, t1: f64, degenerate: bool) -> BrepSolid {
BrepSolid {
id: 1,
vertices: vec![],
edges: vec![crate::topology::EdgeRecord {
id: 1,
curve,
t0,
t1,
start_vertex_id: 0,
end_vertex_id: 0,
degenerate,
name: Some("E".into()),
}],
shells: vec![],
genus: 0,
}
}
#[test]
fn box_faces_resolve_to_six_outward_planes() {
let solid = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 2.0, 3.0, 4.0).unwrap();
let center = Vec3::new(1.0, 1.5, 2.0);
let mut seen = [0usize; 6];
for id in face_ids(&solid) {
let SelectionGeometry::Plane { origin, normal } =
resolve_face_selection(&solid, id).unwrap()
else {
panic!("box face {id} did not resolve to a plane");
};
assert_close(normal.length(), 1.0, "unit normal");
assert!(
origin.sub(center).dot(normal) > 0.0,
"face {id} normal points inward"
);
let axes = [
(Vec3::new(1.0, 0.0, 0.0), 2.0_f64),
(Vec3::new(0.0, 1.0, 0.0), 3.0),
(Vec3::new(0.0, 0.0, 1.0), 4.0),
];
let slot = axes
.iter()
.position(|(axis, _)| normal.dot(*axis).abs() > 1.0 - 1e-9)
.expect("axis-aligned normal");
let (axis, size) = axes[slot];
let positive = normal.dot(axis) > 0.0;
seen[slot * 2 + positive as usize] += 1;
let expected = center.add(axis.scale((size / 2.0) * if positive { 1.0 } else { -1.0 }));
assert_vec3(origin, expected, "face origin");
}
assert_eq!(seen, [1; 6], "one face per box side");
}
#[test]
fn box_face_resolves_by_name() {
let mut solid = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0).unwrap();
solid.shells[0].faces[0].name = Some("lid".into());
let by_id = resolve_face_selection(&solid, solid.shells[0].faces[0].id).unwrap();
let by_name = resolve_named_selection(&solid, "lid").unwrap();
assert_vec3(
by_name.representative_point(),
by_id.representative_point(),
"name/id parity",
);
let missing = resolve_named_selection(&solid, "nope").unwrap_err();
assert!(matches!(missing, ResolveError::NotFound { .. }));
assert_eq!(missing.status(), "invalid-selection");
}
#[test]
fn cylinder_side_face_resolves_to_axis_with_radius() {
let base = Vec3::new(1.0, 2.0, 3.0);
let axis = Vec3::new(0.0, 0.0, 1.0);
let solid = make_cylinder_brep(base, axis, 2.0, 5.0).unwrap();
let mut side = None;
let mut caps = 0usize;
for id in face_ids(&solid) {
match resolve_face_selection(&solid, id).unwrap() {
SelectionGeometry::Axis {
origin,
direction,
radius,
} => {
assert_parallel(direction, axis, "cylinder axis direction");
assert_close(
origin.sub(base).cross(axis).length(),
0.0,
"axis origin off the construction axis",
);
assert_close(radius.expect("cylinder radius"), 2.0, "cylinder radius");
side = Some(id);
}
SelectionGeometry::Plane { normal, .. } => {
assert_parallel(normal, axis, "cap normal");
caps += 1;
}
other => panic!("unexpected cylinder face resolution: {other:?}"),
}
}
assert!(side.is_some(), "no side face resolved to an axis");
assert_eq!(caps, 2, "two planar caps");
}
#[test]
fn cylinder_cap_edges_resolve_to_circles_and_seam_to_line() {
let base = Vec3::new(1.0, 2.0, 3.0);
let axis = Vec3::new(0.0, 0.0, 1.0);
let solid = make_cylinder_brep(base, axis, 2.0, 5.0).unwrap();
let mut circles = Vec::new();
let mut lines = 0usize;
for edge in &solid.edges {
match resolve_edge_selection(&solid, edge.id).unwrap() {
SelectionGeometry::Circle {
center,
axis: circle_axis,
radius,
} => {
assert_parallel(circle_axis, axis, "cap circle axis");
assert_close(radius, 2.0, "cap circle radius");
circles.push(center);
}
SelectionGeometry::Line { origin, direction } => {
assert_parallel(direction, axis, "seam direction");
assert_close(
origin.sub(base).dot(axis),
2.5,
"seam midpoint at half height",
);
lines += 1;
}
other => panic!("unexpected cylinder edge resolution: {other:?}"),
}
}
assert_eq!(lines, 1, "one seam line");
circles.sort_by(|a, b| a.z.total_cmp(&b.z));
assert_eq!(circles.len(), 2, "two cap circles");
assert_vec3(circles[0], base, "bottom cap center");
assert_vec3(circles[1], base.add(axis.scale(5.0)), "top cap center");
}
#[test]
fn cone_side_face_resolves_to_axis_without_radius() {
let base = Vec3::new(0.0, 0.0, 0.0);
let axis = Vec3::new(0.0, 0.0, 1.0);
let solid = make_cone_brep(base, axis, 3.0, 1.0, 4.0).unwrap();
let cone_axes: Vec<_> = face_ids(&solid)
.into_iter()
.filter_map(|id| match resolve_face_selection(&solid, id).unwrap() {
SelectionGeometry::Axis {
origin,
direction,
radius,
} => Some((origin, direction, radius)),
_ => None,
})
.collect();
let [(origin, direction, radius)] = cone_axes[..] else {
panic!("expected exactly one axis-bearing cone face, got {cone_axes:?}");
};
assert_parallel(direction, axis, "cone axis direction");
assert_close(origin.sub(base).cross(axis).length(), 0.0, "origin on axis");
assert_eq!(radius, None, "cone must not report a radius");
}
#[test]
fn sphere_face_resolves_to_center_and_radius() {
let center = Vec3::new(3.0, -1.0, 2.0);
let solid = make_sphere_brep(center, 1.5, Vec3::new(0.0, 0.0, 1.0)).unwrap();
let ids = face_ids(&solid);
let SelectionGeometry::Sphere {
center: resolved,
radius,
} = resolve_face_selection(&solid, ids[0]).unwrap()
else {
panic!("sphere face did not resolve to a sphere");
};
assert_vec3(resolved, center, "sphere center");
assert_close(radius, 1.5, "sphere radius");
}
#[test]
fn torus_face_resolves_to_axis_without_radius() {
let center = Vec3::new(0.0, 1.0, -2.0);
let axis = Vec3::new(0.0, 0.0, 1.0);
let solid = make_torus_brep(center, axis, 4.0, 1.0).unwrap();
let ids = face_ids(&solid);
let SelectionGeometry::Axis {
origin,
direction,
radius,
} = resolve_face_selection(&solid, ids[0]).unwrap()
else {
panic!("torus face did not resolve to an axis");
};
assert_parallel(direction, axis, "torus axis direction");
assert_close(origin.sub(center).cross(axis).length(), 0.0, "origin on axis");
assert_eq!(radius, None, "torus must not report a cylinder radius");
}
#[test]
fn partial_cylinder_revolution_resolves_with_radius() {
let generatrix = make_line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 3.0)).unwrap();
let surface = make_revolution(
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
&generatrix,
std::f64::consts::PI,
)
.unwrap();
let solid = face_only_solid(surface);
let SelectionGeometry::Axis {
direction, radius, ..
} = resolve_face_selection(&solid, 1).unwrap()
else {
panic!("half-cylinder did not resolve to an axis");
};
assert_parallel(direction, Vec3::new(0.0, 0.0, 1.0), "half-cylinder axis");
assert_close(radius.expect("half-cylinder radius"), 2.0, "half-cylinder radius");
}
#[test]
fn hyperboloid_revolution_resolves_without_radius() {
let generatrix = make_line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 2.0, 3.0)).unwrap();
let surface = make_revolution(
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
&generatrix,
std::f64::consts::PI,
)
.unwrap();
let solid = face_only_solid(surface);
let SelectionGeometry::Axis { radius, .. } = resolve_face_selection(&solid, 1).unwrap() else {
panic!("hyperboloid did not resolve to an axis");
};
assert_eq!(radius, None, "hyperboloid must not report a radius");
}
#[test]
fn freeform_face_reports_unsupported() {
let mut rows = Vec::new();
for i in 0..4 {
let mut row = Vec::new();
for j in 0..4 {
let bump = if (1..=2).contains(&i) && (1..=2).contains(&j) {
1.0
} else {
0.0
};
row.push(Vec4::from_point(
Vec3::new(i as f64, j as f64, bump),
1.0,
));
}
rows.push(row);
}
let knots = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let surface = NurbsSurface::new(3, 3, knots.clone(), knots, rows).unwrap();
let solid = face_only_solid(surface);
let error = resolve_face_selection(&solid, 1).unwrap_err();
assert!(matches!(error, ResolveError::Unsupported { .. }), "{error:?}");
assert_eq!(error.status(), "unsupported-selection");
}
#[test]
fn unknown_face_id_reports_not_found() {
let solid = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 1.0, 1.0).unwrap();
let error = resolve_face_selection(&solid, 9999).unwrap_err();
assert!(matches!(error, ResolveError::NotFound { .. }));
assert_eq!(error.status(), "invalid-selection");
}
#[test]
fn box_edges_resolve_to_carrier_lines_through_their_vertices() {
let solid = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 2.0, 3.0, 4.0).unwrap();
assert_eq!(solid.edges.len(), 12);
for edge in &solid.edges {
let SelectionGeometry::Line { origin, direction } =
resolve_edge_selection(&solid, edge.id).unwrap()
else {
panic!("box edge {} did not resolve to a line", edge.id);
};
assert_close(direction.length(), 1.0, "unit line direction");
let endpoints: Vec<Vec3> = [edge.start_vertex_id, edge.end_vertex_id]
.iter()
.map(|id| {
solid
.vertices
.iter()
.find(|vertex| vertex.id == *id)
.unwrap()
.point
})
.collect();
for point in &endpoints {
assert_close(
point.sub(origin).cross(direction).length(),
0.0,
"vertex off the carrier line",
);
}
assert_vec3(
origin,
endpoints[0].add(endpoints[1]).scale(0.5),
"line origin at chord midpoint",
);
}
}
#[test]
fn arc_edge_resolves_to_circle() {
let curve = make_arc(
Vec3::new(1.0, 1.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
2.0,
0.0,
std::f64::consts::FRAC_PI_2,
)
.unwrap();
let [t0, t1] = curve.domain().unwrap();
let solid = edge_only_solid(curve, t0, t1, false);
let SelectionGeometry::Circle {
center,
axis,
radius,
} = resolve_edge_selection(&solid, 1).unwrap()
else {
panic!("arc edge did not resolve to a circle");
};
assert_vec3(center, Vec3::new(1.0, 1.0, 0.0), "arc center");
assert_close(radius, 2.0, "arc radius");
assert_vec3(axis, Vec3::new(0.0, 0.0, 1.0), "arc axis");
}
#[test]
fn spline_edge_reports_unsupported() {
let points = [
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.4, 0.0),
Vec3::new(2.0, -0.3, 0.2),
Vec3::new(3.0, 0.0, 0.0),
];
let parameters = [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0];
let curve = interpolate_curve(&points, 3, ¶meters).unwrap();
let [t0, t1] = curve.domain().unwrap();
let solid = edge_only_solid(curve, t0, t1, false);
let error = resolve_edge_selection(&solid, 1).unwrap_err();
assert!(matches!(error, ResolveError::Unsupported { .. }), "{error:?}");
assert_eq!(error.status(), "unsupported-selection");
}
#[test]
fn degenerate_edge_reports_unsupported() {
let point = Vec3::new(1.0, 1.0, 1.0);
let curve = make_line(point, point.add(Vec3::new(0.0, 0.0, 1e-15))).unwrap();
let solid = edge_only_solid(curve, 0.0, 1.0, true);
let error = resolve_edge_selection(&solid, 1).unwrap_err();
assert!(matches!(error, ResolveError::Unsupported { .. }));
assert_eq!(error.status(), "unsupported-selection");
}
#[test]
fn vertex_resolves_to_exact_point_nearest_wins() {
let solid = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 2.0, 3.0, 4.0).unwrap();
let SelectionGeometry::Point { position } =
resolve_vertex_selection(&solid, Vec3::new(2.0 + 1e-8, 3.0 - 1e-8, 4.0)).unwrap()
else {
panic!("vertex did not resolve to a point");
};
assert_vec3(position, Vec3::new(2.0, 3.0, 4.0), "snapped corner");
let SelectionGeometry::Point { position } =
resolve_vertex_selection(&solid, Vec3::new(0.9e-6, 0.0, 0.0)).unwrap()
else {
panic!("vertex did not resolve to a point");
};
assert_vec3(position, Vec3::new(0.0, 0.0, 0.0), "nearest corner");
let error = resolve_vertex_selection(&solid, Vec3::new(10.0, 10.0, 10.0)).unwrap_err();
assert!(matches!(error, ResolveError::NotFound { .. }));
assert_eq!(error.status(), "invalid-selection");
}
#[test]
fn component_point_is_aggregate_bbox_center() {
let a = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0).unwrap();
let b = make_box_brep(Vec3::new(4.0, 0.0, 0.0), 2.0, 2.0, 2.0).unwrap();
let SelectionGeometry::Point { position } = resolve_component_point(&[&a, &b]).unwrap() else {
panic!("component did not resolve to a point");
};
assert_vec3(position, Vec3::new(3.0, 1.0, 1.0), "two-solid bbox center");
let SelectionGeometry::Point { position } = resolve_component_point(&[&a]).unwrap() else {
panic!("component did not resolve to a point");
};
assert_vec3(position, Vec3::new(1.0, 1.0, 1.0), "one-solid bbox center");
assert!(resolve_component_point(&[]).is_err(), "empty solid set");
}
#[test]
fn component_namespace_parsing() {
let (chain, local) = split_component_namespace("ACOMP2:Extrude1|Extrude1_top[0]");
assert_eq!(chain, vec!["ACOMP2"]);
assert_eq!(local, "Extrude1|Extrude1_top[0]");
let (chain, local) = split_component_namespace("ACOMP3:ACOMP1:S1:G20");
assert_eq!(chain, vec!["ACOMP3", "ACOMP1"]);
assert_eq!(local, "S1:G20");
let (chain, local) = split_component_namespace("ACOMP2");
assert!(chain.is_empty());
assert_eq!(local, "ACOMP2");
assert!(is_component_reference(local));
let (chain, local) = split_component_namespace("ACOMP3:ACOMP1");
assert_eq!(chain, vec!["ACOMP3"]);
assert_eq!(local, "ACOMP1");
assert!(is_component_reference(local));
let (chain, local) = split_component_namespace("S1:G20");
assert!(chain.is_empty());
assert_eq!(local, "S1:G20");
assert!(is_component_reference("ACOMP12"));
assert!(!is_component_reference("ACOMP"));
assert!(!is_component_reference("ACOMP1x"));
assert!(!is_component_reference("TALN3"));
}
#[test]
fn transform_localizes_frames() {
let world_to_local = AffineTransform::new([
0.0, 1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 5.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
])
.unwrap();
let plane = SelectionGeometry::Plane {
origin: Vec3::new(1.0, 2.0, 3.0),
normal: Vec3::new(1.0, 0.0, 0.0),
};
let SelectionGeometry::Plane { origin, normal } =
plane.transformed(&world_to_local).unwrap()
else {
panic!("plane transformed into a different kind");
};
assert_vec3(origin, Vec3::new(2.0, 4.0, 3.0), "localized plane origin");
assert_vec3(normal, Vec3::new(0.0, -1.0, 0.0), "localized plane normal");
let circle = SelectionGeometry::Circle {
center: Vec3::new(0.0, 0.0, 0.0),
axis: Vec3::new(0.0, 0.0, 1.0),
radius: 2.0,
};
let SelectionGeometry::Circle { radius, .. } = circle.transformed(&world_to_local).unwrap()
else {
panic!("circle transformed into a different kind");
};
assert_close(radius, 2.0, "rigid transform keeps radius");
let double = AffineTransform::new([
2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 1.0,
])
.unwrap();
let SelectionGeometry::Circle { radius, .. } = circle.transformed(&double).unwrap() else {
panic!("circle transformed into a different kind");
};
assert_close(radius, 4.0, "uniform scale scales radius");
let squash = AffineTransform::new([
1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
])
.unwrap();
let error = circle.transformed(&squash).unwrap_err();
assert!(matches!(error, ResolveError::Unsupported { .. }));
assert_eq!(error.status(), "unsupported-selection");
}
#[test]
fn mate_marshaling_helpers() {
let plane = SelectionGeometry::Plane {
origin: Vec3::new(1.0, 2.0, 3.0),
normal: Vec3::new(0.0, 0.0, 1.0),
};
let mate_plane = plane.mate_plane().unwrap();
assert_eq!(mate_plane.origin, [1.0, 2.0, 3.0]);
assert_eq!(mate_plane.normal, [0.0, 0.0, 1.0]);
assert!(plane.mate_axis().is_none());
let circle = SelectionGeometry::Circle {
center: Vec3::new(4.0, 5.0, 6.0),
axis: Vec3::new(0.0, 1.0, 0.0),
radius: 2.0,
};
let mate_axis = circle.mate_axis().unwrap();
assert_eq!(mate_axis.origin, [4.0, 5.0, 6.0]);
assert_eq!(mate_axis.direction, [0.0, 1.0, 0.0]);
assert!(circle.mate_plane().is_none());
let line = SelectionGeometry::Line {
origin: Vec3::new(7.0, 8.0, 9.0),
direction: Vec3::new(1.0, 0.0, 0.0),
};
assert!(line.mate_axis().is_some());
let sphere = SelectionGeometry::Sphere {
center: Vec3::new(1.0, 1.0, 1.0),
radius: 3.0,
};
assert!(sphere.mate_axis().is_none());
assert!(sphere.mate_plane().is_none());
assert_vec3(
sphere.representative_point(),
Vec3::new(1.0, 1.0, 1.0),
"sphere anchor",
);
}