Kernel: pole-free six-patch spheres; vendored truck-shapeops with robust meshing and nearest-parameter loop projection (sphere and box booleans work); least-squares sphere classification

Oracle: 25/29 (14 and 15 now match Fusion).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:30:18 +00:00
1 parent 7ef3426cb3
commit 043a542e29
34 files changed
+6228 -43

No files matched your search

+6 -7
View File
@@ -37,17 +37,16 @@ pub struct FaceInfo {
type MeshedShell = truck_topology::Shell<mt::Point3, PolylineCurve<mt::Point3>, Option<PolygonMesh>>;
/// Triangulate each shell (not the solid: truck's solid constructor panics on the open or
/// non-manifold shells an import can produce). Faces whose boundary is not exactly on the
/// surface (imported geometry within the file's tolerance) fail the exact parameter search;
/// such shells are meshed again projecting onto the surface.
/// non-manifold shells an import can produce). Boundaries that lie on their surface only within
/// tolerance (imports, boolean results) are projected onto it.
fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
solid
.boundaries()
.iter()
.map(|sh| {
let m = sh.triangulation(tol);
if m.face_iter().any(|f| f.surface().is_none()) { sh.robust_triangulation(tol) } else { m }
})
// The robust search tries the exact one first, so exact geometry costs nothing extra; a
// face can also come back as an empty mesh rather than none (boolean results with
// approximate intersection curves), so always use it.
.map(|sh| sh.robust_triangulation(tol))
.collect()
}
+100 -13
View File
@@ -140,6 +140,14 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
.collect(),
};
let r = &Region2 { outer: snap_loop(&r.outer), holes: r.holes.iter().map(snap_loop).collect() };
// A half disc on the axis revolved fully is a sphere: build it without poles.
if angle.abs() >= std::f64::consts::TAU - 1e-9
&& r.holes.is_empty()
&& let Some((c, rad)) = half_disc(&r.outer, axis_origin, d)
{
out.push(sphere_patches(plane.to_world(c), rad)?);
continue;
}
// A full revolution of a profile with one edge on the axis: sweep the rest of the loop
// as a cone-like shell (no zero-area faces, which booleans can't handle).
if angle.abs() >= std::f64::consts::TAU - 1e-9
@@ -191,19 +199,7 @@ pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body
/// Sphere: revolve a half disc.
pub fn sphere(center: Vec3, radius: f64) -> Result<Body> {
let radius = finite(radius, "radius")?;
if radius <= 1e-6 {
return Err(KernelError::Invalid("radius must be positive".into()));
}
let plane = Plane { origin: center, ..Plane::XZ };
let half = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Line { a: Vec2::new(0.0, radius), b: Vec2::new(0.0, -radius) },
],
};
let mut v = revolve(&plane, &[Region2 { outer: half, holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
v.pop().ok_or_else(|| KernelError::Failed("sphere".into()))
sphere_patches(center, radius)
}
/// Torus about the Z axis through `center`.
@@ -313,6 +309,23 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
}
/// Revolve 360° a loop that has exactly one line segment on the axis, without degenerate faces.
/// A loop made of a half circle and its diameter on the axis: (centre, radius).
fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-7 * (1.0 + p.len());
let mut arcs = Vec::new();
for s in &lp.segs {
match *s {
Seg2::Line { a, b } if on_axis(a) && on_axis(b) => {}
Seg2::Arc { center, radius, sweep, .. } if on_axis(center) => arcs.push((center, radius, sweep)),
_ => return None,
}
}
let (c, r, _) = *arcs.first()?;
let total: f64 = arcs.iter().map(|a| a.2.abs()).sum();
let same = arcs.iter().all(|(c2, r2, _)| c2.dist(c) < 1e-7 * (1.0 + r) && (r2 - r).abs() < 1e-7 * (1.0 + r));
(same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r))
}
fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
@@ -654,3 +667,77 @@ fn uniform_wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
}
Ok(edges.into())
}
/// A sphere as six patches (the cube's faces projected onto it) on revolved surfaces whose poles
/// and seams lie outside each patch. No face has a singular point, which the boolean needs; the
/// patches are one analytic sphere, so measured topology counts it as one face.
pub fn sphere_patches(center: Vec3, radius: f64) -> Result<Body> {
sphere_patches_rotated(center, radius, [Vec3::X, Vec3::Y, Vec3::Z])
}
/// [`sphere_patches`] with the patch layout turned to the frame `rot` (orthonormal columns).
pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Result<Body> {
let radius = finite(radius, "radius")?;
let turn = |v: Vec3| rot[0] * v.x + rot[1] * v.y + rot[2] * v.z;
if radius <= 1e-6 || !center.is_finite() {
return Err(KernelError::Invalid("radius must be positive".into()));
}
let k = radius / 3f64.sqrt();
let corner = |sx: f64, sy: f64, sz: f64| center + turn(Vec3::new(sx * k, sy * k, sz * k));
// Cube faces: outward direction, rotation axis for the patch's surface (perpendicular to it),
// and the four corner sign triples counter-clockwise seen from outside.
let faces: [(Vec3, Vec3, [[f64; 3]; 4]); 6] = [
(Vec3::X, Vec3::Z, [[1.0, -1.0, -1.0], [1.0, 1.0, -1.0], [1.0, 1.0, 1.0], [1.0, -1.0, 1.0]]),
(-Vec3::X, Vec3::Z, [[-1.0, 1.0, -1.0], [-1.0, -1.0, -1.0], [-1.0, -1.0, 1.0], [-1.0, 1.0, 1.0]]),
(Vec3::Y, Vec3::Z, [[1.0, 1.0, -1.0], [-1.0, 1.0, -1.0], [-1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]),
(-Vec3::Y, Vec3::Z, [[-1.0, -1.0, -1.0], [1.0, -1.0, -1.0], [1.0, -1.0, 1.0], [-1.0, -1.0, 1.0]]),
(Vec3::Z, Vec3::X, [[-1.0, -1.0, 1.0], [1.0, -1.0, 1.0], [1.0, 1.0, 1.0], [-1.0, 1.0, 1.0]]),
(-Vec3::Z, Vec3::X, [[-1.0, 1.0, -1.0], [1.0, 1.0, -1.0], [1.0, -1.0, -1.0], [-1.0, -1.0, -1.0]]),
];
guard("sphere", || {
let key = |s: [f64; 3]| ((s[0] > 0.0) as usize) | (((s[1] > 0.0) as usize) << 1) | (((s[2] > 0.0) as usize) << 2);
let mut verts: std::collections::HashMap<usize, mt::Vertex> = std::collections::HashMap::new();
let mut vert = |s: [f64; 3]| verts.entry(key(s)).or_insert_with(|| builder::vertex(p3(corner(s[0], s[1], s[2])))).clone();
let mut edges: std::collections::HashMap<(usize, usize), mt::Edge> = std::collections::HashMap::new();
let mut out: Vec<mt::Face> = Vec::new();
for (dir, axis, cs) in faces {
let (dir, axis) = (turn(dir), turn(axis));
let mut wire: Vec<mt::Edge> = Vec::new();
for i in 0..4 {
let (a, b) = (cs[i], cs[(i + 1) % 4]);
let (ka, kb) = (key(a), key(b));
let e = match edges.get(&(ka.min(kb), ka.max(kb))) {
Some(e) => e.clone(),
None => {
let (lo, hi) = if ka < kb { (a, b) } else { (b, a) };
let (va, vb) = (vert(lo), vert(hi));
let mid = (corner(lo[0], lo[1], lo[2]) + corner(hi[0], hi[1], hi[2])) * 0.5 - center;
let transit = center + mid.normalized().unwrap_or(dir) * radius;
let e = builder::circle_arc(&va, &vb, p3(transit));
edges.insert((ka.min(kb), ka.max(kb)), e.clone());
e
}
};
wire.push(if ka < kb { e } else { e.inverse() });
}
// The meridian runs pole to pole through the side away from the patch.
let (np, sp, back) = (center + axis * radius, center - axis * radius, center - dir * radius);
let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back));
let mut surface =
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(center), v3(axis))));
// Outward normal at the patch centre.
let probe = p3(center + dir * radius);
use mt::{ParametricSurface3D, SearchParameter};
if let Some((u, v)) = surface.search_parameter(probe, mt::SPHint2D::None, 100) {
let n = surface.normal(u, v);
if mt::InnerSpace::dot(n, v3(dir)) < 0.0 {
mt::Invertible::invert(&mut surface);
}
}
out.push(mt::Face::new(vec![wire.into()], surface));
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
Body::new(solid)
})
}
+143
View File
@@ -246,6 +246,20 @@ fn planar_booleans_with_coincident_faces() {
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
}
#[test]
fn sphere_booleans() {
let s = sphere(Vec3::ZERO, 15.0).unwrap();
let m = measure(&s).unwrap();
assert!(rel(m.volume, 4.0 / 3.0 * PI * 3375.0) < 1e-3, "{}", m.volume);
assert_eq!((m.merged.faces, m.merged.edges), (1, 0));
// A ball cut out of the top face of a slab (oracle 14).
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
let c = boolean(&b, &s, BoolOp::Cut).unwrap().unwrap();
let m = measure(&c).unwrap();
assert!(rel(m.volume, 50.0 * 50.0 * 20.0 - 2.0 / 3.0 * PI * 3375.0) < 1e-3, "{}", m.volume);
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (7, 13, 9));
}
#[test]
#[ignore]
fn debug_convergence() {
@@ -456,3 +470,132 @@ fn debug_cbore_seq() {
println!(" swapped: {:?}", boolean(&c2, &t1, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume)));
}
}
#[test]
#[ignore]
fn debug_truck_sphere() {
use truck_modeling::{self as mt, builder};
let mk = |c: Vec3, r: f64, axis: Vec3| -> Body {
let ax = axis.normalized().unwrap();
let side = ax.any_perp();
let v0 = builder::vertex(crate::body::p3(c + ax * r));
let arc = builder::rsweep(&v0, crate::body::p3(c), crate::body::v3(side), mt::Rad(PI));
let shell = builder::rsweep(&arc, crate::body::p3(c), crate::body::v3(ax), mt::Rad(2.0 * PI));
Body::new(mt::Solid::new(vec![shell])).unwrap()
};
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
for (name, axis) in [("z", Vec3::Z), ("x", Vec3::X), ("diag", Vec3::new(0.3, 0.5, 0.8))] {
let sp = mk(Vec3::ZERO, 15.0, axis);
println!("{name}: faces {} vol {}", sp.face_count(), measure(&sp).unwrap().volume);
for op in [BoolOp::Cut, BoolOp::Intersect, BoolOp::Union] {
let r = guard("t", || {
let (sa, mut sb) = (b.deep_copy(), sp.deep_copy());
let res = match op {
BoolOp::Cut => {
sb.not();
truck_shapeops::and(&sa, &sb, 0.01)
}
BoolOp::Intersect => truck_shapeops::and(&sa, &sb, 0.01),
BoolOp::Union => truck_shapeops::or(&sa, &sb, 0.01),
};
Ok(res.map(|s| s.face_iter().count()))
});
println!(" {op:?}: {r:?}");
}
}
}
#[test]
#[ignore]
fn debug_sphere_patches() {
let sp = crate::build::sphere_patches(Vec3::ZERO, 15.0).unwrap();
let m = measure(&sp).unwrap();
println!("vol {} want {} area {} merged {:?}", m.volume, 4.0 / 3.0 * PI * 3375.0, m.area, m.merged);
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
for op in [BoolOp::Cut, BoolOp::Intersect, BoolOp::Union] {
let r = boolean(&b, &sp, op).map(|o| {
o.map(|x| {
let m = measure(&x).unwrap();
(m.volume, m.merged)
})
});
println!("{op:?}: {r:?}");
}
let c = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
let a = 0.3f64;
let (ca, sa) = (a.cos(), a.sin());
let b2 = 0.5f64;
let (cb, sb) = (b2.cos(), b2.sin());
// Rz(a) * Rx(b)
let rot = [Vec3::new(ca, sa, 0.0), Vec3::new(-sa * cb, ca * cb, sb), Vec3::new(sa * sb, -ca * sb, cb)];
for (name, s2) in [
("plain", crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap()),
("turned", crate::build::sphere_patches_rotated(Vec3::ZERO, 25.0, rot).unwrap()),
] {
println!("{name} vol {}", measure(&s2).unwrap().volume);
println!(
"{name} cube and sphere: {:?}",
boolean(&c, &s2, BoolOp::Intersect).map(|o| o.map(|x| {
let m = measure(&x).unwrap();
(m.volume, m.merged)
}))
);
}
}
#[test]
#[ignore]
fn debug_sphere_cases() {
let s = crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap();
let cases = [
("one plane", Vec3::new(-100.0, -100.0, -100.0), Vec3::new(100.0, 100.0, 20.0)),
("two planes", Vec3::new(-100.0, -100.0, -20.0), Vec3::new(100.0, 100.0, 20.0)),
("three", Vec3::new(-100.0, -100.0, -20.0), Vec3::new(20.0, 100.0, 20.0)),
("four", Vec3::new(-100.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)),
("five", Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 100.0)),
("cube", Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)),
("cube big sphere-ish", Vec3::new(-24.0, -24.0, -24.0), Vec3::new(24.0, 24.0, 24.0)),
("cube off", Vec3::new(-20.3, -19.7, -20.1), Vec3::new(19.9, 20.2, 19.8)),
];
for (name, lo, hi) in cases {
let b = box_solid(lo, hi).unwrap();
for op in [BoolOp::Intersect, BoolOp::Cut] {
let r = boolean(&b, &s, op).map(|o| o.map(|x| measure(&x).unwrap().volume));
println!("{name} {op:?}: {:?}", r.map_err(|e| e.to_string().chars().take(60).collect::<String>()));
}
}
}
#[test]
#[ignore]
fn debug_sphere_rotations() {
let b = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
for k in 0..8 {
let (a, c) = (0.37 * k as f64 + 0.1, 0.61 * k as f64 + 0.2);
let (ca, sa, cc, sc) = (a.cos(), a.sin(), c.cos(), c.sin());
let rot = [Vec3::new(ca, sa, 0.0), Vec3::new(-sa * cc, ca * cc, sc), Vec3::new(sa * sc, -ca * sc, cc)];
let s = crate::build::sphere_patches_rotated(Vec3::ZERO, 25.0, rot).unwrap();
let r = guard("t", || {
let res = truck_shapeops::and(&b.deep_copy(), &s.deep_copy(), 0.01);
Ok(res.map(|x| x.face_iter().count()))
});
println!("rot {k}: {r:?}");
}
}
#[test]
#[ignore]
fn debug_cube_sphere_measure() {
let b = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
let s = crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap();
let r = guard("t", || Ok(truck_shapeops::and(&b.deep_copy(), &s.deep_copy(), 0.05))).unwrap().unwrap();
let body = Body::new(r).unwrap();
for tol in [0.5, 0.1, 0.02] {
let m = body.tessellate(tol).unwrap();
let mm = m.measure();
println!("tol {tol}: vol {} area {} tris {}", mm.volume, mm.area, m.triangles.len());
}
for f in body.faces(0.05).unwrap() {
println!("face {} area {:.2} c {:?} planar {:?}", f.index, f.area, f.centroid, f.plane_normal.is_some());
}
}
+45 -17
View File
@@ -135,23 +135,10 @@ fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf {
}
}
}
// Sphere: points equidistant from the point where normals meet.
let c_est = {
let mut acc = Vec3::ZERO;
let mut k = 0.0;
for (p, n) in pts.iter().zip(nrm) {
// Assume radius from the first pair.
let r0 = (p0 - *p).len() / ((n0 - *n).len().max(1e-12));
if r0.is_finite() && r0 > 0.0 && (n0 - *n).len() > 0.1 {
acc += *p - *n * r0;
k += 1.0;
}
}
(k > 0.0).then(|| acc / k)
};
if let Some(c) = c_est {
let r = pts.iter().map(|p| p.dist(c)).sum::<f64>() / pts.len().max(1) as f64;
if pts.iter().all(|p| (p.dist(c) - r).abs() < tol.max(r * 2e-3)) {
// Sphere: algebraic least-squares fit |p|² = 2c·p + k, then every point at the same distance.
if let Some((c, r)) = fit_sphere(pts) {
let spread = pts.iter().map(|p| p.dist(p0)).fold(0.0, f64::max);
if spread > tol && pts.iter().all(|p| (p.dist(c) - r).abs() < tol.max(r * 2e-3)) && r < spread * 1e4 {
return Surf::Sphere { c, r };
}
}
@@ -278,6 +265,47 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
.collect()
}
/// Least-squares sphere through points: centre and radius.
fn fit_sphere(pts: &[Vec3]) -> Option<(Vec3, f64)> {
if pts.len() < 4 {
return None;
}
// Centre the data for conditioning.
let m = pts.iter().fold(Vec3::ZERO, |a, p| a + *p) / pts.len() as f64;
let mut a = [[0.0f64; 5]; 4];
for p in pts {
let q = *p - m;
let row = [2.0 * q.x, 2.0 * q.y, 2.0 * q.z, 1.0];
let rhs = q.dot(q);
for i in 0..4 {
for j in 0..4 {
a[i][j] += row[i] * row[j];
}
a[i][4] += row[i] * rhs;
}
}
for col in 0..4 {
let piv = (col..4).max_by(|x, y| a[*x][col].abs().total_cmp(&a[*y][col].abs()))?;
if a[piv][col].abs() < 1e-18 {
return None;
}
a.swap(col, piv);
for r in 0..4 {
if r != col {
let f = a[r][col] / a[col][col];
let prow = a[col];
for (x, v) in a[r].iter_mut().zip(prow) {
*x -= f * v;
}
}
}
}
let c = Vec3::new(a[0][4] / a[0][0], a[1][4] / a[1][1], a[2][4] / a[2][2]);
let k = a[3][4] / a[3][3];
let r2 = k + c.dot(c);
(r2 > 0.0 && r2.is_finite()).then(|| (c + m, r2.sqrt()))
}
fn classify_faces(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];