Face counts: classify sliver faces from their exact surface (#308) (#357)

Since 4e595de a partial-depth cut that ends inside a cylinder runs through the
exact stacked-section path, which leaves 0.3 mm wide strips of the cylinder wall
beside the cut. Their tessellation is one triangle wide, too few distinct
samples for the cylinder fit, so the merged topology reported them as a sphere
and a freeform face. Where the mesh fit finds no plane or cylinder, use the
face's exact surface (plane, cylinder or sphere) instead.

Fixes #308


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-12 14:35:13 -04:00
1 parent d15869ab85
commit 882ef0e9c6
2 files changed
+52 -5

No files matched your search

+27
View File
@@ -185,3 +185,30 @@ fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence()
assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0))));
}
}
/// #308: a blind cut ending inside a cylinder leaves thin strips of the cylinder wall beside
/// the cut; they must count as cylinder, not as a sphere or a freeform face.
#[test]
fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
let mut s = Session::default();
let side = json!({"origin":[0,0,0],"x_dir":[0,0,-1],"y_dir":[0,1,0]});
s.execute("solid.box", &json!({"length":40,"width":30,"height":3,"body_name":"Hinge"})).unwrap();
for (name, radius, operation, extent) in
[("Knuckle", 4, "join", json!({"distance":40})), ("Pin", 2, "cut", json!({"distance":100,"direction":"symmetric"}))]
{
s.execute("sketch.create", &json!({"name":name,"plane":side})).unwrap();
s.execute("sketch.circle.center", &json!({"center":[-4,33],"radius":radius})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
let mut p = json!({"sketch":name,"operation":operation,"body_names":["Hinge"]});
p.as_object_mut().unwrap().extend(extent.as_object().unwrap().clone());
s.execute("solid.extrude", &p).unwrap();
}
s.execute("sketch.create", &json!({"name":"Gap","plane":"XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0":[12,29],"p1":[28,40]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"sketch":"Gap","operation":"cut","distance":3.3,"body_names":["Hinge"]})).unwrap();
let m = s.execute("inspect.measure", &json!({})).unwrap();
let body = &m["bodies"][0];
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
assert_eq!(body["faces"], 14, "{m}");
}
+25 -5
View File
@@ -239,7 +239,7 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
let solid = &*b.solid;
let nf = solid.face_iter().count();
let tol = (b.size() * 1e-4).max(1e-6);
let surfs = classify_faces(mesh, nf, tol);
let surfs = classify_faces(b, mesh, nf, tol);
let verts = face_vertices(mesh, nf);
// Faces cut from one surface (an imported face split where it wraps around a closed
// surface) carry the very same surface: the edge between them is a seam.
@@ -312,7 +312,7 @@ fn fit_sphere(pts: &[Vec3]) -> Option<(Vec3, f64)> {
(r2 > 0.0 && r2.is_finite()).then(|| (c + m, r2.sqrt()))
}
fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
fn classify_faces(b: &Body, mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
@@ -351,7 +351,27 @@ fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
nv.retain(|_| it.next().copied().unwrap_or(true));
}
}
(0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i)).collect()
let fitted = (0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i));
// A sliver (a strip one triangle wide) has too few distinct samples to fit: its points
// can fit a sphere or nothing. Where the fit finds no plane or cylinder, take the face's
// exact surface when that is a plane, cylinder or sphere.
let exact_tol = (b.size() * 1e-7).max(1e-9) * 100.0;
let faces: Vec<_> = b.solid.face_iter().collect();
fitted
.enumerate()
.map(|(i, fit)| match (fit, faces.get(i)) {
(Surf::Sphere { .. } | Surf::Other(_), Some(face)) => match crate::offset::surf_of(face, exact_tol) {
Some(crate::offset::Surf::Plane { n, d }) => Surf::Plane { n, d },
Some(crate::offset::Surf::Cylinder { o, a, r, .. }) => {
let axis = if a.x + a.y * 1e-3 + a.z * 1e-6 < 0.0 { -a } else { a };
Surf::Cylinder { axis, p: o, r }
}
Some(crate::offset::Surf::Sphere { c, r, .. }) => Surf::Sphere { c, r },
_ => fit,
},
_ => fit,
})
.collect()
}
/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`.
@@ -373,7 +393,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
let solid = &*b.solid;
let nf = solid.face_iter().count();
let tol = (b.size() * 1e-4).max(1e-6);
let surfs = classify_faces(mesh, nf, tol);
let surfs = classify_faces(b, mesh, nf, tol);
let verts = face_vertices(mesh, nf);
// Zero-area faces (a revolved profile edge lying on the axis) don't count.
let mut area = vec![0.0f64; nf];
@@ -604,7 +624,7 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option<CylinderFace> {
}
let nf = b.face_count();
let tol = (size * 1e-4).max(1e-6);
let surfs = classify_faces(&mesh, nf, tol);
let surfs = classify_faces(b, &mesh, nf, tol);
let Surf::Cylinder { axis, p: ap, r } = *surfs.get(face as usize)? else { return None };
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;