From 882ef0e9c6138b0a179781e6daa2c3fcaa7e2828 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Mon, 12 Oct 2026 14:35:13 -0400 Subject: [PATCH] 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 --- crates/engine/src/cmd/face_tests.rs | 27 ++++++++++++++++++++++++++ crates/kernel/src/topo.rs | 30 ++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/face_tests.rs b/crates/engine/src/cmd/face_tests.rs index f1651255..1de4ff2f 100644 --- a/crates/engine/src/cmd/face_tests.rs +++ b/crates/engine/src/cmd/face_tests.rs @@ -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}"); +} diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 680fe914..58e99f4f 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -239,7 +239,7 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec { 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 { +fn classify_faces(b: &Body, mesh: &Mesh, nf: usize, tol: f64) -> Vec { let mut pts: Vec> = vec![Vec::new(); nf]; let mut nrm: Vec> = 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 { 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 { 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 { } 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;