M1: hole feature (simple, drilled, counterbore, countersink), cone classification and apex counting

Oracle: 19/29 parts match Fusion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 19:22:27 +00:00
1 parent 42e0362f5b
commit 431b87c2b9
12 files changed
+396 -41

No files matched your search

+41 -6
View File
@@ -338,24 +338,59 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
let shift = plane.to_world(axis_origin);
let local = Plane { origin: plane.origin - shift, ..*plane };
let plane = &local;
// Radial lines at the ends become planar caps (no degenerate disc centres).
let radial = |sg: &Seg2| matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
let mut mid: &[Seg2] = &chain;
if mid.first().is_some_and(|f| radial(f) && on_axis(f.start())) {
mid = mid.get(1..).unwrap_or(&[]);
}
if mid.last().is_some_and(|l| radial(l) && on_axis(l.end())) {
mid = mid.get(..mid.len().saturating_sub(1)).unwrap_or(&[]);
}
if mid.is_empty() {
return Ok(None);
}
let apex_start = mid.first().is_some_and(|f| on_axis(f.start()));
let apex_end = mid.last().is_some_and(|l| on_axis(l.end()));
guard("revolve", || {
let verts: Vec<mt::Vertex> = chain
let verts: Vec<mt::Vertex> = mid
.iter()
.map(|s| builder::vertex(p3(plane.to_world(s.start()))))
.chain(chain.last().map(|s| builder::vertex(p3(plane.to_world(s.end())))))
.chain(mid.last().map(|s| builder::vertex(p3(plane.to_world(s.end())))))
.collect();
let mut edges: Vec<mt::Edge> = Vec::new();
for (i, s) in chain.iter().enumerate() {
for (i, s) in mid.iter().enumerate() {
let (Some(a), Some(b)) = (verts.get(i), verts.get(i + 1)) else { continue };
edges.push(match *s {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(s.mid()))),
});
}
let w: mt::Wire = edges.into();
let mut w: mt::Wire = edges.into();
let axis = plane.dir_to_world(d);
let shell = builder::cone(&w, v3(axis), mt::Rad(std::f64::consts::TAU));
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("revolve: {e}")))?;
let mut shell = if apex_start || apex_end {
if !apex_start {
// builder::cone wants the apex first.
w = w.inverse();
}
builder::cone(&w, v3(axis), mt::Rad(std::f64::consts::TAU))
} else {
builder::rsweep(&w, p3(plane.to_world(axis_origin)), v3(axis), mt::Rad(std::f64::consts::TAU))
};
let sides = shell.len();
for b in shell.extract_boundaries() {
let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?;
shell.push(cap);
}
let solid = Solid::try_new(vec![shell.clone()]).or_else(|_| {
// Caps may come out facing inwards: flip them.
let mut s2 = shell.clone();
for f in s2.face_iter_mut().skip(sides) {
f.invert();
}
Solid::try_new(vec![s2])
});
let solid = solid.map_err(|e| KernelError::Failed(format!("revolve: {e}")))?;
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
Body::new(solid).map(Some)
})
+49
View File
@@ -330,3 +330,52 @@ fn debug_sphere() {
println!("{name} and {:?}", boolean(&b, &sp, BoolOp::Intersect).map(|o| o.map(|x| measure(&x).unwrap().volume)));
}
}
#[test]
#[ignore]
fn debug_countersink() {
let b = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 15.0)).unwrap();
for (name, pts) in [
("csink", vec![Vec2::new(0.0, 0.6), Vec2::new(6.6, 0.6), Vec2::new(3.0, -3.0), Vec2::new(3.0, -31.0), Vec2::new(0.0, -31.0)]),
("csink-thru-top", vec![Vec2::new(0.0, 0.6), Vec2::new(6.6, 0.6), Vec2::new(3.0, -3.0), Vec2::new(3.0, -16.3), Vec2::new(0.0, -16.3)]),
(
"cbore",
vec![Vec2::new(0.0, 0.6), Vec2::new(7.0, 0.6), Vec2::new(7.0, -5.0), Vec2::new(4.0, -5.0), Vec2::new(4.0, -31.0), Vec2::new(0.0, -31.0)],
),
] {
for (pn, pl) in [
("xz@top", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::X, Vec3::Z).unwrap()),
("yz@top", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::Y, Vec3::Z).unwrap()),
("diag", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::new(0.6, 0.8, 0.0), Vec3::Z).unwrap()),
] {
let tool = revolve(&pl, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)
.unwrap()
.pop()
.unwrap();
println!(
"{name} {pn}: tool {} cut {:?}",
measure(&tool).unwrap().volume,
boolean(&b, &tool, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume))
);
}
}
}
#[test]
#[ignore]
fn debug_cbore_seq() {
let b = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 15.0)).unwrap();
let pl = Plane::from_normal(Vec3::new(15.0, 20.0, 15.0), Vec3::Z).unwrap();
for (s1, s2, r2top) in [(0.0, 0.0, 0.6), (0.613, 1.226, 0.6), (0.3, 0.9, 2.37)] {
let t1 = extrude(&pl, &[Region2 { outer: Loop2::circle_from(Vec2::ZERO, 4.0, s1), holes: vec![] }], -31.0, 0.6).unwrap().pop().unwrap();
let c1 = boolean(&b, &t1, BoolOp::Cut).unwrap().unwrap();
let t2 = extrude(&pl, &[Region2 { outer: Loop2::circle_from(Vec2::ZERO, 7.0, s2), holes: vec![] }], -5.0, r2top).unwrap().pop().unwrap();
println!(
"seams {s1} {s2}: first {} second {:?}",
measure(&c1).unwrap().volume,
boolean(&c1, &t2, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume))
);
let c2 = boolean(&b, &t2, BoolOp::Cut).unwrap().unwrap();
println!(" swapped: {:?}", boolean(&c2, &t1, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume)));
}
}
+65 -1
View File
@@ -26,6 +26,7 @@ enum Surf {
Plane { n: Vec3, d: f64 },
Cylinder { axis: Vec3, p: Vec3, r: f64 },
Sphere { c: Vec3, r: f64 },
Cone { axis: Vec3, apex: Vec3, cos: f64 },
Other(usize),
}
@@ -35,6 +36,7 @@ impl Surf {
Surf::Plane { .. } => "plane",
Surf::Cylinder { .. } => "cylinder",
Surf::Sphere { .. } => "sphere",
Surf::Cone { .. } => "cone",
Surf::Other(_) => "other",
}
}
@@ -46,6 +48,9 @@ impl Surf {
axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol
}
(Surf::Sphere { c, r }, Surf::Sphere { c: c2, r: r2 }) => c.dist(c2) < tol && (r - r2).abs() < tol,
(Surf::Cone { axis, apex, cos }, Surf::Cone { axis: a2, apex: p2, cos: c2 }) => {
axis.dot(a2) > 1.0 - 1e-5 && (cos - c2).abs() < 1e-4 && apex.dist(p2) < tol * 10.0
}
_ => false,
}
}
@@ -105,6 +110,31 @@ fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf {
return Surf::Cylinder { axis, p: u * cu + v * cv, r };
}
}
// Cone: normals at a constant angle to one axis; the apex lies on every tangent plane.
if let Some((axis, cos)) = cone_signature(&pts.iter().copied().zip(nrm.iter().copied()).collect::<Vec<_>>())
&& cos.abs() > 1e-3
&& cos.abs() < 1.0 - 1e-3
{
// Least squares: n_i · v = n_i · p_i.
let mut m = [[0.0f64; 3]; 3];
let mut r = [0.0f64; 3];
for (p, n) in pts.iter().zip(nrm) {
let nv = [n.x, n.y, n.z];
let k = n.dot(*p);
for i in 0..3 {
for j in 0..3 {
m[i][j] += nv[i] * nv[j];
}
r[i] += nv[i] * k;
}
}
if let Some(v) = solve3(m, r) {
let apex = Vec3::new(v[0], v[1], v[2]);
if pts.iter().zip(nrm).all(|(p, n)| (*p - apex).dot(*n).abs() < tol.max(1e-6) * 10.0) {
return Surf::Cone { axis, apex, cos };
}
}
}
// Sphere: points equidistant from the point where normals meet.
let c_est = {
let mut acc = Vec3::ZERO;
@@ -184,6 +214,27 @@ fn same_surface(surfs: &[Surf], verts: &[Vec<(Vec3, Vec3)>], edge: Option<&Vec<V
}
}
/// Solve a 3×3 system (Cramer's rule).
fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> {
let det = |a: [[f64; 3]; 3]| {
a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])
};
let d = det(m);
if d.abs() < 1e-12 {
return None;
}
let mut out = [0.0; 3];
for (k, o) in out.iter_mut().enumerate() {
let mut mk = m;
for (row, rv) in mk.iter_mut().zip(r) {
row[k] = rv;
}
*o = det(mk) / d;
}
Some(out)
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
@@ -365,5 +416,18 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
let real_vertices = deg.keys().filter(|v| !merge_vertex.contains(v)).count();
let closed_loops = edge_groups.values().filter(|r| !**r).count();
let _ = vpos;
Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len(), vertices: real_vertices + closed_loops, face_types })
// A cone that reaches its apex has a degenerate edge and a vertex there (as other kernels
// count them).
let mut apexes = 0;
for (root, f) in &groups {
if let Some(Surf::Cone { apex, .. }) = surfs.get(*f) {
let reaches = (0..nf)
.filter(|g| find(&mut fp.clone(), *g) == *root)
.any(|g| verts.get(g).is_some_and(|v| v.iter().any(|(p, _)| p.dist(*apex) < tol * 10.0)));
if reaches {
apexes += 1;
}
}
}
Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len() + apexes, vertices: real_vertices + closed_loops + apexes, face_types })
}