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;