kernel: Body::classify_point decides inside, outside or on the boundary exactly

SMLib's point classification at the body's own tolerance, on the exact faces and edges, in
place of a mesh at a chord of 2e-3 of the body's size (about 0.4 mm on a 200 mm part). A point
within the tolerance of a face, edge or vertex is OnBoundary, never decided by a ray. A mesh
body's triangles are its exact shape: on them within the record's tolerance, else by ray
crossings. The kernel's own probes move to it; Body::contains goes once its last callers have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKQ2rEZvcXNQnhE3Sgs
This commit is contained in:
jyviko
2026-10-30 13:39:25 -04:00
committed by jyviko
parent 56d39a5764
commit 7b3e66cbae
8 changed files with 111 additions and 14 deletions
+6 -6
View File
@@ -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);
}
}
}
+44
View File
@@ -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<PointClass> {
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() }
+49
View File
@@ -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);
}
+1 -1
View File
@@ -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]
+2 -1
View File
@@ -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};
+2 -2
View File
@@ -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();
+3 -3
View File
@@ -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
+4 -1
View File
@@ -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());