diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs index de1da03493..e1d7081ae8 100644 --- a/crates/kernel/src/blend_corner.rs +++ b/crates/kernel/src/blend_corner.rs @@ -404,8 +404,8 @@ mod tests { assert_eq!(result.unmeshed_faces(), 0); assert!(result.validity().is_empty(), "{:?}", result.validity()); assert!((vol(&result) - vol(&reference)).abs() < 0.05); - assert!(!result.contains(Vec3::new(24.9, 0.1, 5.0))); - assert!(result.contains(Vec3::new(24.0, 1.0, 5.0))); + assert_eq!(result.classify_point(Vec3::new(24.9, 0.1, 5.0)).unwrap(), crate::PointClass::Outside); + assert_eq!(result.classify_point(Vec3::new(24.0, 1.0, 5.0)).unwrap(), crate::PointClass::Inside); } } @@ -424,8 +424,8 @@ mod tests { assert!(result.validity().is_empty(), "{:?}", result.validity()); assert!((vol(&result) - vol(&reference)).abs() < 0.05, "radius {radius}"); // The old bottom radius is still 2, away from the newly rounded corner. - assert!(!result.contains(Vec3::new(12.5, 0.5, 0.5))); - assert!(result.contains(Vec3::new(12.5, 1.0, 1.0))); + assert_eq!(result.classify_point(Vec3::new(12.5, 0.5, 0.5)).unwrap(), crate::PointClass::Outside); + assert_eq!(result.classify_point(Vec3::new(12.5, 1.0, 1.0)).unwrap(), crate::PointClass::Inside); } // A tangent boundary of an earlier round is not a sharp edge, and cannot be // reinterpreted as the sharp edge exposed when that round is taken off. @@ -470,8 +470,8 @@ mod tests { let result = fillet(&rounded, &crate::tangent_chain(&rounded, selection).unwrap(), 2.0).unwrap(); assert!(result.validity().is_empty(), "{:?}", result.validity()); assert!((vol(&result) - want).abs() < 1e-6 * want, "{selection:?}: {} vs {want}", vol(&result)); - assert!(!result.contains(Vec3::new(-4.9, 87.1, 10.0))); - assert!(result.contains(Vec3::new(-4.1, 86.3, 10.0))); + assert_eq!(result.classify_point(Vec3::new(-4.9, 87.1, 10.0)).unwrap(), crate::PointClass::Outside); + assert_eq!(result.classify_point(Vec3::new(-4.1, 86.3, 10.0)).unwrap(), crate::PointClass::Inside); } } } diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 49b46bb0fa..44c42974ce 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -422,6 +422,17 @@ pub struct Refusal { pub why: String, } +/// Where a point lies against a body ([`Body::classify_point`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PointClass { + /// In the body's material. + Inside, + /// Outside the material, or in a cavity. + Outside, + /// Within the body's tolerance of a face, an edge or a vertex. + OnBoundary, +} + /// The kind of surface a face lies on, as a person modelling would name it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SurfaceKind { @@ -1348,6 +1359,39 @@ impl Body { self.mesh(&MeshTolerance::chord(self.size() * 2e-3)).map(|m| m.contains(p)).unwrap_or(false) } + /// Where `p` lies against the body, decided exactly on its faces' surfaces and edges' curves + /// (SMLib's point classification at the body's own tolerance, no mesh): on the boundary when + /// within that tolerance of a face, an edge or a vertex, else inside its material or outside + /// (a cavity is outside). A mesh body's triangles are its exact shape: on its boundary + /// within the record's tolerance of a triangle, else inside when rays from the point cross + /// it an odd number of times. + pub fn classify_point(&self, p: Vec3) -> Result { + if !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) { + return Err(KernelError::Invalid(format!("point classification: the point {p:?} is not finite"))); + } + if let Some(m) = &self.mesh { + let mesh = m.to_mesh(); + let near = mesh + .triangles + .iter() + .filter_map(|t| mesh.tri(t)) + .any(|[a, b, c]| solidcraft_geom::compare::closest_on_triangle(p, a, b, c).dist(p) <= self.record.tolerance); + return Ok(if near { + PointClass::OnBoundary + } else if mesh.contains(p) { + PointClass::Inside + } else { + PointClass::Outside + }); + } + let class = self.smlib()?.classify_point([p.x, p.y, p.z]).map_err(|e| KernelError::Failed(format!("SMLib point classification: {e}")))?; + Ok(match class { + solidcraft_smlib::PointClass::Inside => PointClass::Inside, + solidcraft_smlib::PointClass::Outside => PointClass::Outside, + solidcraft_smlib::PointClass::OnBoundary => PointClass::OnBoundary, + }) + } + /// B-rep faces (a mesh body is one face; [`crate::measure`] counts its triangles). pub fn face_count(&self) -> usize { if self.mesh.is_some() { 1 } else { self.record.faces.len() } diff --git a/crates/kernel/src/body_lump_tests.rs b/crates/kernel/src/body_lump_tests.rs index b8ac628bc0..95db09a81f 100644 --- a/crates/kernel/src/body_lump_tests.rs +++ b/crates/kernel/src/body_lump_tests.rs @@ -259,3 +259,52 @@ fn a_long_thin_cylinder_comes_out_outward() { assert!((volume - want).abs() < want * 0.02, "{lo} {seam}: {volume} vs {want}"); } } + +/// Exact point classification: a box's inside, outside, faces, edges and corners; a cylinder's +/// curved wall within its tolerance (on it), 5 µm inside it (inside: the old mesh test, at a +/// chord of 2e-3 of the size, missed this band) and just beyond; a cavity is outside. +#[test] +fn points_classify_exactly_against_the_body() { + use crate::{PointClass::*, cylinder}; + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + for (p, want) in [ + (Vec3::new(5.0, 10.0, 15.0), Inside), + (Vec3::new(5.0, 10.0, 31.0), Outside), + (Vec3::new(5.0, 10.0, 30.0 + 1e-4), Outside), + (Vec3::new(5.0, 10.0, 30.0 - 1e-4), Inside), + (Vec3::new(5.0, 10.0, 30.0), OnBoundary), + (Vec3::new(10.0, 10.0, 30.0), OnBoundary), + (Vec3::new(10.0, 20.0, 30.0), OnBoundary), + (Vec3::ZERO, OnBoundary), + ] { + assert_eq!(b.classify_point(p).unwrap(), want, "{p:?}"); + } + assert!(b.classify_point(Vec3::new(f64::NAN, 0.0, 0.0)).is_err()); + + let c = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 10.0).unwrap(); + let tol = c.record().tolerance; + let at = |r: f64| Vec3::new(r * std::f64::consts::FRAC_1_SQRT_2, r * std::f64::consts::FRAC_1_SQRT_2, 5.0); + assert_eq!(c.classify_point(at(5.0 + 0.5 * tol)).unwrap(), OnBoundary); + assert_eq!(c.classify_point(at(5.0 - 0.5 * tol)).unwrap(), OnBoundary); + assert_eq!(c.classify_point(at(5.0 - 5e-3)).unwrap(), Inside); + assert_eq!(c.classify_point(at(5.0 + 1e-4)).unwrap(), Outside); + assert_eq!(c.classify_point(Vec3::new(0.0, 0.0, 10.0)).unwrap(), OnBoundary); + + let hollow = boolean(&cube(50.0), &cube(40.0), BoolOp::Cut).unwrap().unwrap(); + assert_eq!(hollow.classify_point(Vec3::ZERO).unwrap(), Outside, "the cavity"); + assert_eq!(hollow.classify_point(Vec3::X * 45.0).unwrap(), Inside); + assert_eq!(hollow.classify_point(Vec3::X * 40.0).unwrap(), OnBoundary, "the cavity's wall"); +} + +/// A mesh body's triangles are its shape: on them within the record's tolerance, else inside or +/// outside by the rays' crossings. +#[test] +fn points_classify_against_a_mesh_body() { + use crate::PointClass::*; + let p = [Vec3::ZERO, Vec3::X * 10.0, Vec3::Y * 10.0, Vec3::Z * 10.0]; + let m = crate::mesh_body(&p, &[[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]).unwrap(); + assert_eq!(m.classify_point(Vec3::new(1.0, 1.0, 1.0)).unwrap(), Inside); + assert_eq!(m.classify_point(Vec3::new(5.0, 5.0, 5.0)).unwrap(), Outside); + assert_eq!(m.classify_point(Vec3::new(1.0, 1.0, 0.0)).unwrap(), OnBoundary); + assert_eq!(m.classify_point(Vec3::X * 10.0).unwrap(), OnBoundary); +} diff --git a/crates/kernel/src/delete_face.rs b/crates/kernel/src/delete_face.rs index 5a8461f4b3..d4655d0475 100644 --- a/crates/kernel/src/delete_face.rs +++ b/crates/kernel/src/delete_face.rs @@ -358,7 +358,7 @@ mod tests { assert_eq!(restored.face_count(), 6); assert_eq!(restored.unmeshed_faces(), 0); assert!((volume(&restored) - 6250.0).abs() < 1e-6); - assert!(restored.contains(Vec3::new(0.1, 0.1, 0.1))); + assert_eq!(restored.classify_point(Vec3::new(0.1, 0.1, 0.1)).unwrap(), crate::PointClass::Inside); } #[test] diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 7821808a1e..6c457444ce 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -162,7 +162,8 @@ pub use blend::{ pub use blend_corner::fillet_radii; pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper}; pub use body::{ - Body, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint, Refusal, SurfaceKind, + Body, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint, PointClass, Refusal, + SurfaceKind, }; pub use boundary::{Boundary, boundary_patch}; pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface}; diff --git a/crates/kernel/src/sphere_lens_tests.rs b/crates/kernel/src/sphere_lens_tests.rs index b44a7f1aab..d12e7a3422 100644 --- a/crates/kernel/src/sphere_lens_tests.rs +++ b/crates/kernel/src/sphere_lens_tests.rs @@ -48,8 +48,8 @@ fn projected_spherical_outline_does_not_block_a_lens_cut() { let measured = crate::measure(&result).unwrap(); assert!((measured.volume - 8810.08973246).abs() < 2., "{measured:?}"); assert!(measured.bbox.max.z < 20.01, "no thin lid may remain above the cut: {measured:?}"); - assert!(result.contains(Vec3::new(0., -10., 2.))); - assert!(!result.contains(Vec3::new(0., -10., 15.))); + assert_eq!(result.classify_point(Vec3::new(0., -10., 2.)).unwrap(), crate::PointClass::Inside); + assert_eq!(result.classify_point(Vec3::new(0., -10., 15.)).unwrap(), crate::PointClass::Outside); assert_eq!(measured.merged.face_types.get("cylinder"), Some(&1)); let history = result.history().expect("the cut has a face history"); history.check(&result, &[&target, &cutter]).unwrap(); diff --git a/crates/kernel/tests/chamfer_curved_holder.rs b/crates/kernel/tests/chamfer_curved_holder.rs index 96196bb205..e4fb8e2274 100644 --- a/crates/kernel/tests/chamfer_curved_holder.rs +++ b/crates/kernel/tests/chamfer_curved_holder.rs @@ -73,9 +73,9 @@ fn chamfer_the_top_lip_at_a_tangent_wall_junction() { let expected = expected_removed(2.0); eprintln!("removed {removed:.8} mm³, expected {expected:.8} mm³"); assert!((removed - expected).abs() < 0.03, "removed {removed}, expected {expected}"); - assert!(!made.contains(Vec3::new(10.0, 9.5, 59.5)), "the old corner was removed"); - assert!(made.contains(Vec3::new(10.0, 7.0, 59.5)), "material behind the bevel remains"); - assert!(made.contains(Vec3::new(10.0, 10.0, 55.0)), "material below the bevel remains"); + assert_eq!(made.classify_point(Vec3::new(10.0, 9.5, 59.5)).unwrap(), solidcraft_kernel::PointClass::Outside, "the old corner was removed"); + assert_eq!(made.classify_point(Vec3::new(10.0, 7.0, 59.5)).unwrap(), solidcraft_kernel::PointClass::Inside, "material behind the bevel remains"); + assert_eq!(made.classify_point(Vec3::new(10.0, 10.0, 55.0)).unwrap(), solidcraft_kernel::PointClass::Inside, "material below the bevel remains"); // A closed mesh's signed volume is independent of the origin. let volume_about = |origin: Vec3| { after diff --git a/crates/kernel/tests/shell_barrel.rs b/crates/kernel/tests/shell_barrel.rs index fc7422ae94..d424c71c8a 100644 --- a/crates/kernel/tests/shell_barrel.rs +++ b/crates/kernel/tests/shell_barrel.rs @@ -54,7 +54,10 @@ fn shell_of_a_revolved_barrel() { assert!((volume - full).abs() < full * 1e-3, "{volume} vs {full}"); for turn in [0.0, 1.0, 2.5, 4.0] { let (c, s) = (f64::cos(turn), f64::sin(turn)); - assert!(barrel.contains(Vec3::new(13.8 * c, 13.8 * s, HALF_HEIGHT)) && !barrel.contains(Vec3::new(14.2 * c, 14.2 * s, HALF_HEIGHT))); + assert!( + barrel.classify_point(Vec3::new(13.8 * c, 13.8 * s, HALF_HEIGHT)).unwrap() == solidcraft_kernel::PointClass::Inside + && barrel.classify_point(Vec3::new(14.2 * c, 14.2 * s, HALF_HEIGHT)).unwrap() == solidcraft_kernel::PointClass::Outside + ); } let result = shell(&barrel, &[Vec3::new(0.0, 0.0, 2.0 * HALF_HEIGHT)], 2.0).unwrap(); assert!(result.validity().is_empty(), "{:?}", result.validity());