#![allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
use ogeom_core::Tolerances;
use ogeom_math::{Frame, Point};
use ogeom_topo::{Filter, ShapeType, explore};
const T: Tolerances = Tolerances::millimetres();
fn fine() -> ogeom_mesh::Deflection {
ogeom_mesh::Deflection {
chord: 1e-4,
..ogeom_mesh::Deflection::default()
}
}
fn volume(model: &ogeom_topo::Model, shape: &ogeom_topo::Shape) -> f64 {
ogeom_algo::volume_properties(model, shape, fine(), T)
.unwrap()
.mass
}
#[test]
fn a_box_offset_outward_is_the_bigger_box() {
let mut model = ogeom_topo::Model::new();
let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
let result = ogeom_offset::offset_shape(&mut model, &block.shape, 0.5, T).unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let measured = volume(&model, &result.shape);
assert!(
(measured - 27.0).abs() < 1e-9,
"outward box offset volume {measured} against 27"
);
let faces = explore(&model, &result.shape, Filter::OfType(ShapeType::Face)).unwrap();
assert_eq!(faces.len(), 6);
}
#[test]
fn a_box_offset_inward_is_the_smaller_box() {
let mut model = ogeom_topo::Model::new();
let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
let result = ogeom_offset::offset_shape(&mut model, &block.shape, -0.5, T).unwrap();
let measured = volume(&model, &result.shape);
assert!(
(measured - 1.0).abs() < 1e-9,
"inward box offset volume {measured} against 1"
);
}
#[test]
fn a_cylinder_offset_grows_radius_and_caps_alike() {
let mut model = ogeom_topo::Model::new();
let drum = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 1.0, 2.0, T).unwrap();
let result = ogeom_offset::offset_shape(&mut model, &drum.shape, 0.5, T).unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let expected = core::f64::consts::PI * 1.5 * 1.5 * 3.0;
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 5e-3,
"cylinder offset volume {measured} against {expected}"
);
}
#[test]
fn an_offset_that_swallows_the_box_is_refused() {
let mut model = ogeom_topo::Model::new();
let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
assert!(ogeom_offset::offset_shape(&mut model, &block.shape, -1.5, T).is_err());
}
fn face_at(
model: &ogeom_topo::Model,
solid: &ogeom_topo::Shape,
probe: Point,
) -> ogeom_topo::Shape {
explore(model, solid, Filter::OfType(ShapeType::Face))
.unwrap()
.into_iter()
.find(|f| {
ogeom_algo::classify_on_face(model, f, probe, fine(), T)
.map(|c| c == ogeom_algo::Containment::In)
.unwrap_or(false)
})
.expect("the solid has a face there")
}
#[test]
fn a_shelled_box_keeps_its_walls_and_opens_its_top() {
let mut model = ogeom_topo::Model::new();
let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
let top = face_at(&model, &block.shape, Point::new(1.0, 1.0, 2.0));
let t = 0.2;
let result =
ogeom_offset::make_thick_solid(&mut model, &block.shape, std::slice::from_ref(&top), t, T)
.unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let expected = 8.0 - 1.6 * 1.6 * 1.8;
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 1e-9,
"box shell volume {measured} against {expected}"
);
let faces = explore(&model, &result.shape, Filter::OfType(ShapeType::Face)).unwrap();
assert_eq!(faces.len(), 11);
assert!(result.history.is_deleted(&top));
let inside =
ogeom_algo::classify_in_solid_exact(&model, &result.shape, Point::new(1.0, 1.0, 1.0), T)
.unwrap();
assert_eq!(inside, ogeom_algo::Containment::Out);
let wall =
ogeom_algo::classify_in_solid_exact(&model, &result.shape, Point::new(0.1, 1.0, 1.0), T)
.unwrap();
assert_eq!(wall, ogeom_algo::Containment::In);
}
#[test]
fn a_shelled_cylinder_becomes_a_cup() {
let mut model = ogeom_topo::Model::new();
let drum = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 1.0, 2.0, T).unwrap();
let top = face_at(&model, &drum.shape, Point::new(0.0, 0.0, 2.0));
let t = 0.2;
let result =
ogeom_offset::make_thick_solid(&mut model, &drum.shape, std::slice::from_ref(&top), t, T)
.unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let pi = core::f64::consts::PI;
let expected = pi * 2.0 - pi * 0.8 * 0.8 * 1.8;
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 1e-3,
"cup volume {measured} against {expected}"
);
assert!(result.history.is_deleted(&top));
}
fn filleted_box(model: &mut ogeom_topo::Model) -> ogeom_topo::Shape {
let block = ogeom_algo::make_box(model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
let edge = explore(model, &block.shape, Filter::OfType(ShapeType::Edge))
.unwrap()
.into_iter()
.find(|e| {
ogeom_algo::edge_vertices(model, e)
.unwrap()
.is_some_and(|(a, b)| {
let p = |v: &ogeom_topo::Shape| {
model
.node(v)
.and_then(|n| n.data().as_vertex().map(|d| d.point))
.unwrap()
};
let (pa, pb) = (p(&a), p(&b));
(pa.x - 2.0).abs() < 1e-9
&& (pa.z - 2.0).abs() < 1e-9
&& (pb.x - 2.0).abs() < 1e-9
&& (pb.z - 2.0).abs() < 1e-9
})
})
.expect("the box has that edge");
ogeom_fillet::fillet_edge(model, &block.shape, &edge, 0.5, T)
.unwrap()
.shape
}
#[test]
fn a_filleted_box_offsets_with_its_blend() {
let pi = core::f64::consts::PI;
for (w, side, blend) in [(0.2, 2.4, 0.7), (-0.2, 1.6, 0.3)] {
let mut model = ogeom_topo::Model::new();
let part = filleted_box(&mut model);
let result = ogeom_offset::offset_shape(&mut model, &part, w, T).unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let expected = side * side * side - (1.0 - pi / 4.0) * blend * blend * side;
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 2e-3,
"offset {w}: volume {measured} against {expected}"
);
assert_eq!(
explore(&model, &result.shape, Filter::OfType(ShapeType::Face))
.unwrap()
.len(),
7
);
}
}
#[test]
fn a_filleted_box_shells_blend_and_all() {
let mut model = ogeom_topo::Model::new();
let part = filleted_box(&mut model);
let bottom = face_at(&model, &part, Point::new(1.0, 1.0, 0.0));
let t = 0.2;
let result =
ogeom_offset::make_thick_solid(&mut model, &part, std::slice::from_ref(&bottom), t, T)
.unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let pi = core::f64::consts::PI;
let outer = 8.0 - (1.0 - pi / 4.0) * 0.25 * 2.0;
let cavity = 1.6 * 1.6 * 1.8 - (1.0 - pi / 4.0) * 0.09 * 1.6;
let expected = outer - cavity;
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 2e-3,
"filleted shell volume {measured} against {expected}"
);
assert!(result.history.is_deleted(&bottom));
}
#[test]
fn a_cone_offsets_to_the_parallel_cone_apex_and_all() {
let mut model = ogeom_topo::Model::new();
let cone = ogeom_algo::make_cone(&mut model, Frame::WORLD, 3.0, 0.0, 4.0, T).unwrap();
let result = ogeom_offset::offset_shape(&mut model, &cone.shape, 0.5, T).unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let pi = core::f64::consts::PI;
let expected = pi / 3.0 * 16.0 * (16.0 / 3.0);
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 1e-2,
"offset cone volume {measured} against {expected}"
);
assert_eq!(
explore(&model, &result.shape, Filter::OfType(ShapeType::Face))
.unwrap()
.len(),
2
);
let apex = explore(&model, &result.shape, Filter::OfType(ShapeType::Vertex))
.unwrap()
.into_iter()
.any(|v| {
model
.node(&v)
.and_then(|n| n.data().as_vertex().map(|d| d.point))
.is_some_and(|p| (p.z - 29.0 / 6.0).abs() < 1e-6 && p.x.hypot(p.y) < 1e-6)
});
assert!(apex, "the apex rode the parallel cone");
}
#[test]
fn a_rim_filleted_cylinder_offsets_with_its_toroidal_band() {
let mut model = ogeom_topo::Model::new();
let (radius, height, blend, w) = (2.0, 4.0, 0.5, 0.3);
let drum = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
let rim = explore(&model, &drum.shape, Filter::OfType(ShapeType::Edge))
.unwrap()
.into_iter()
.find(|e| {
ogeom_algo::edge_vertices(&model, e)
.unwrap()
.is_some_and(|(a, _)| {
model
.node(&a)
.and_then(|n| n.data().as_vertex().map(|d| d.point))
.is_some_and(|p| {
(p.z - height).abs() < 1e-9 && (p.x.hypot(p.y) - radius).abs() < 1e-6
})
})
})
.expect("the drum has its top rim");
let part = ogeom_fillet::fillet_edge(&mut model, &drum.shape, &rim, blend, T)
.unwrap()
.shape;
let result = ogeom_offset::offset_shape(&mut model, &part, w, T).unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let pi = core::f64::consts::PI;
let (r, h, b) = (radius + w, height + 2.0 * w, blend + w);
let removed =
2.0 * pi * (r * b * b - b * b * b / 2.0 - (r - b) * pi * b * b / 4.0 - b * b * b / 3.0);
let expected = pi * r * r * h - removed;
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 5e-3,
"offset rim-filleted drum volume {measured} against {expected}"
);
assert_eq!(
explore(&model, &result.shape, Filter::OfType(ShapeType::Face))
.unwrap()
.len(),
4
);
}
#[test]
fn a_filleted_box_shells_open_at_its_blended_top() {
let mut model = ogeom_topo::Model::new();
let part = filleted_box(&mut model);
let top = face_at(&model, &part, Point::new(1.0, 0.5, 2.0));
let t = 0.2;
let result =
ogeom_offset::make_thick_solid(&mut model, &part, std::slice::from_ref(&top), t, T)
.unwrap();
let diagnosis = ogeom_algo::check(&model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let pi = core::f64::consts::PI;
let solid = 8.0 - (1.0 - pi / 4.0) * 0.25 * 2.0;
let cavity = 1.6 * 1.6 * 1.6 - (1.0 - pi / 4.0) * 0.09 * 1.6;
let punched = 1.6 * 1.3 * t;
let expected = solid - cavity - punched;
let measured = volume(&model, &result.shape);
assert!(
(measured - expected).abs() < 2e-3,
"tangent shell volume {measured} against {expected}"
);
let probes = [
(Point::new(0.1, 1.0, 1.0), ogeom_algo::Containment::In),
(Point::new(1.0, 1.0, 1.0), ogeom_algo::Containment::Out),
(Point::new(1.0, 1.0, 1.9), ogeom_algo::Containment::Out),
(Point::new(1.6, 1.0, 1.9), ogeom_algo::Containment::In),
(Point::new(1.9, 1.0, 1.0), ogeom_algo::Containment::In),
];
for (probe, want) in probes {
let got = ogeom_algo::classify_in_solid_exact(&model, &result.shape, probe, T).unwrap();
assert_eq!(got, want, "probe at {probe:?}");
}
assert!(result.history.is_deleted(&top));
}
#[test]
fn a_negative_thickness_builds_the_walls_outward() {
let mut model = ogeom_topo::Model::new();
let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (8.0, 6.0, 4.0), T).unwrap();
let top = explore(&model, &block.shape, Filter::OfType(ShapeType::Face))
.unwrap()
.into_iter()
.find(|f| {
let d = model.node(f).and_then(|n| n.data().as_face());
let Some(d) = d else { return false };
let Some(ogeom_geom::SurfaceGeometry::Plane(p)) = model.geometry().surface(d.surface)
else {
return false;
};
let placed = f.transform(model.datums()).unwrap();
let origin = placed.apply(p.plane().frame().origin());
let n = placed.apply_vector(p.plane().normal().vector());
(n.z - 1.0).abs() < 1e-9 && (origin.z - 4.0).abs() < 1e-9
})
.expect("the top face");
let shelled = ogeom_offset::make_thick_solid(
&mut model,
&block.shape,
std::slice::from_ref(&top),
-0.5,
T,
)
.unwrap();
let diagnosis = ogeom_algo::check(&model, &shelled.shape, T).unwrap();
assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
let expected = 9.0 * 7.0 * 4.5 - 8.0 * 6.0 * 4.0;
let measured = volume(&model, &shelled.shape);
assert!(
(measured - expected).abs() < 1e-6,
"outward shell volume {measured} against {expected}"
);
}
#[test]
fn a_thickness_joins_its_walls_by_intersection_or_arc() {
use ogeom_offset::{Join, make_thick_solid_with};
let top_of = |model: &ogeom_topo::Model, block: &ogeom_topo::Shape| {
explore(model, block, Filter::OfType(ShapeType::Face))
.unwrap()
.into_iter()
.find(|f| {
let b = ogeom_algo::shape_bounds(model, f, T).unwrap();
(b.low().unwrap().z - 10.0).abs() < 1e-6
})
.expect("the top face")
};
let pi = core::f64::consts::PI;
for (thickness, join, want) in [
(-1.0, Join::Arc, 1200.0 + 30.0 * pi + 2.0 / 3.0 * pi),
(-1.0, Join::Intersection, 22.0 * 22.0 * 11.0 - 4000.0),
(1.0, Join::Arc, 4000.0 - 18.0 * 18.0 * 9.0),
] {
let mut model = ogeom_topo::Model::new();
let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (20.0, 20.0, 10.0), T)
.unwrap()
.shape;
let top = top_of(&model, &block);
let shelled = make_thick_solid_with(
&mut model,
&block,
std::slice::from_ref(&top),
thickness,
join,
T,
)
.unwrap_or_else(|e| panic!("{thickness} {join:?}: {e}"));
let diagnosis = ogeom_algo::check(&model, &shelled.shape, T).unwrap();
assert!(
diagnosis.is_valid(),
"{thickness} {join:?}: {:?}",
diagnosis.problems
);
let measured = volume(&model, &shelled.shape);
assert!(
(measured - want).abs() < want * 1e-4,
"{thickness} {join:?}: {measured} against {want}"
);
}
}
#[test]
fn a_located_solid_is_edited_where_it_stands() {
let located = |model: &mut ogeom_topo::Model| {
let block = ogeom_algo::make_box(model, Frame::WORLD, (2.0, 2.0, 2.0), T)
.unwrap()
.shape;
model.placed(
&block,
ogeom_math::Transform::translation(ogeom_math::Vector::new(5.0, 0.0, 0.0)),
)
};
let holds = |model: &ogeom_topo::Model, built: &ogeom_algo::Built, want: f64| {
assert!(
ogeom_algo::check(model, &built.shape, T)
.unwrap()
.is_valid()
);
let got = volume(model, &built.shape);
assert!((got - want).abs() < 1e-9, "{got} against {want}");
let bounds = ogeom_algo::shape_bounds(model, &built.shape, T).unwrap();
assert!(bounds.low().unwrap().x > 4.0, "{bounds:?}");
};
let mut model = ogeom_topo::Model::new();
let block = located(&mut model);
let grown = ogeom_offset::offset_shape(&mut model, &block, 0.2, T).unwrap();
holds(&model, &grown, 2.4_f64.powi(3));
let mut model = ogeom_topo::Model::new();
let block = located(&mut model);
let top = face_at(&model, &block, Point::new(6.0, 1.0, 2.0));
let shell = ogeom_offset::make_thick_solid(&mut model, &block, &[top], 0.2, T).unwrap();
holds(&model, &shell, 8.0 - 1.6 * 1.6 * 1.8);
let mut model = ogeom_topo::Model::new();
let block = located(&mut model);
let top = face_at(&model, &block, Point::new(6.0, 1.0, 2.0));
let lift = ogeom_math::Transform::translation(ogeom_math::Vector::new(0.0, 0.0, 0.5));
let raised = ogeom_offset::move_faces(&mut model, &block, &[top], &lift, T).unwrap();
holds(&model, &raised, 10.0);
}
#[test]
fn an_offset_through_the_part_is_refused() {
for depth in [1.05, 1.2, 1.49] {
let mut model = ogeom_topo::Model::new();
let block = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
.unwrap()
.shape;
assert!(ogeom_offset::offset_shape(&mut model, &block, -depth, T).is_err());
let top = face_at(&model, &block, Point::new(1.0, 1.5, 4.0));
assert!(ogeom_offset::make_thick_solid(&mut model, &block, &[top], depth, T).is_err());
assert!(ogeom_offset::make_thick_solid(&mut model, &block, &[], depth, T).is_err());
}
}
fn rounded_cube(model: &mut ogeom_topo::Model) -> ogeom_topo::Shape {
let block = ogeom_algo::make_box(model, Frame::WORLD, (10.0, 10.0, 10.0), T)
.unwrap()
.shape;
let edges = ogeom_topo::explore_unique(model, &block, ShapeType::Edge).unwrap();
ogeom_fillet::fillet_edges(model, &block, &edges, 2.0, T)
.unwrap()
.shape
}
fn rounded_cube_volume(r: f64) -> f64 {
let (s, pi) = (6.0, core::f64::consts::PI);
s * s * s + 6.0 * s * s * r + 3.0 * pi * s * r * r + 4.0 / 3.0 * pi * r * r * r
}
fn offset_rounded_cube(model: &mut ogeom_topo::Model, part: &ogeom_topo::Shape, d: f64) {
let result =
ogeom_offset::offset_shape(model, part, d, T).unwrap_or_else(|e| panic!("offset {d}: {e}"));
let diagnosis = ogeom_algo::check(model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "offset {d}: {:?}", diagnosis.problems);
let expected = rounded_cube_volume(2.0 + d);
let measured = volume(model, &result.shape);
assert!(
(measured - expected).abs() < 1e-6 * expected,
"offset {d}: rounded cube volume {measured} against {expected}"
);
}
#[test]
fn a_rounded_cube_offsets_with_its_blend_arcs_a_quarter_turn() {
let mut model = ogeom_topo::Model::new();
let part = rounded_cube(&mut model);
for d in [-1.5, -1.0, -0.5, 0.2, 0.5, 1.0, 2.0] {
offset_rounded_cube(&mut model, &part, d);
}
}
fn shell_rounded_cube(model: &mut ogeom_topo::Model, part: &ogeom_topo::Shape, t: f64) {
let top = face_at(model, part, Point::new(5.0, 5.0, 10.0));
let result = ogeom_offset::make_thick_solid(model, part, std::slice::from_ref(&top), t, T)
.unwrap_or_else(|e| panic!("wall {t}: {e}"));
let diagnosis = ogeom_algo::check(model, &result.shape, T).unwrap();
assert!(diagnosis.is_valid(), "wall {t}: {:?}", diagnosis.problems);
let expected = (rounded_cube_volume(2.0) - rounded_cube_volume(2.0 - t)).abs() - 36.0 * t.abs();
let measured = volume(model, &result.shape);
assert!(
(measured - expected).abs() < 1e-6 * expected,
"wall {t}: shelled rounded cube volume {measured} against {expected}"
);
}
#[test]
fn a_rounded_cube_shells_open_at_its_top_either_way() {
let mut model = ogeom_topo::Model::new();
let part = rounded_cube(&mut model);
for t in [0.5, -0.5] {
shell_rounded_cube(&mut model, &part, t);
}
}
#[test]
#[ignore = "heavy"]
fn a_rounded_cube_shells_at_several_walls() {
let mut model = ogeom_topo::Model::new();
let part = rounded_cube(&mut model);
for t in [1.0, 1.5, -1.0, -2.0] {
shell_rounded_cube(&mut model, &part, t);
}
}