Measure: split triangles that cut through curved faces onto the surface (trimmed faces after booleans were measured up to 15% off; 61's keyway volume now matches); oracle step check shows the original volume

Oracle: 53/66.

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-08 02:48:17 +00:00
1 parent 063d55bdb3
commit 8c88fb8d2d
5 files changed
+164 -7

No files matched your search

+122
View File
@@ -56,6 +56,102 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
.collect()
}
/// A face's triangles (corner positions and normals, in the mesh's winding) with every edge
/// whose middle sags more than `tol` from the surface split there, found through the mesh's
/// surface parameters. A split depends on the edge alone, so neighbours stay conforming.
/// `None` when nothing needs splitting or the parameters don't match the surface.
#[allow(clippy::type_complexity)]
fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> {
use mt::{ParametricSurface, ParametricSurface3D};
let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals());
if uvs.is_empty() {
return None;
}
type V = (Vec3, (f64, f64), Vec3);
let mut tris: Vec<[V; 3]> = Vec::new();
for tri in pm.faces().triangle_iter() {
let mut t = [(Vec3::ZERO, (0.0, 0.0), Vec3::Z); 3];
for (slot, v) in t.iter_mut().zip(tri.iter()) {
let p = pos.get(v.pos)?;
let uv = uvs.get(v.uv?)?;
let n = v.nor.and_then(|i| nor.get(i)).map(|n| Vec3::new(n.x, n.y, n.z)).unwrap_or(Vec3::Z);
*slot = (Vec3::new(p.x, p.y, p.z), (uv.x, uv.y), n);
}
tris.push(t);
}
// The parameters must be the surface's own.
let check = tris.iter().take(8).flat_map(|t| t.iter()).all(|(p, (u, v), _)| from_p3(surf.subs(*u, *v)).dist(*p) < tol * 4.0 + 1e-9);
if !check || tris.len() > 200_000 {
return None;
}
// Edges on the face's boundary are shared with the next face: never split them.
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
let mut changed = false;
for _ in 0..4 {
let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new();
for [a, b, c] in &tris {
for e in [ek(a, b), ek(b, c), ek(c, a)] {
*uses.entry(e).or_default() += 1;
}
}
let at = |a: &V, b: &V| -> Option<V> {
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
return None;
}
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
let singular = |x: &V| {
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300
};
if singular(a) || singular(b) {
return None;
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
};
let mut next = Vec::with_capacity(tris.len());
let mut any = false;
for t in &tris {
let [a, b, c] = t;
let m = [at(a, b), at(b, c), at(c, a)];
any |= m.iter().any(Option::is_some);
match m {
[None, None, None] => next.push(*t),
[Some(x), Some(y), Some(z)] => {
next.extend([[*a, x, z], [x, *b, y], [z, y, *c], [x, y, z]]);
}
[Some(x), None, None] => next.extend([[*a, x, *c], [x, *b, *c]]),
[None, Some(y), None] => next.extend([[*a, *b, y], [*a, y, *c]]),
[None, None, Some(z)] => next.extend([[*a, *b, z], [z, *b, *c]]),
[Some(x), Some(y), None] => next.extend([[*a, x, *c], [x, *b, y], [x, y, *c]]),
[None, Some(y), Some(z)] => next.extend([[*a, *b, z], [z, *b, y], [z, y, *c]]),
[Some(x), None, Some(z)] => next.extend([[*a, x, z], [x, *b, *c], [x, *c, z]]),
}
}
tris = next;
changed |= any;
if !any || tris.len() > 400_000 {
break;
}
}
changed.then(|| tris.into_iter().map(|t| t.map(|(p, _, n)| (p, n))).collect())
}
/// Revolved surfaces are periodic in their angle; a face whose angles start at 0 is found at 0
/// or 2π, which confuses the boolean's loop projection. Each revolved face's surface is turned
/// (same geometry) so the face sits around angle π, well away from the wrap.
@@ -217,6 +313,12 @@ impl Body {
/// Triangulate with chord tolerance `tol` (mm). Triangles carry their face index.
pub fn tessellate(&self, tol: f64) -> Result<Mesh> {
self.tessellate_with(tol, false)
}
/// Like [`Body::tessellate`]; with `refine`, triangles that cut through curved faces are
/// split until within `tol` of the surface (for measuring).
pub fn tessellate_with(&self, tol: f64, refine: bool) -> Result<Mesh> {
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
let tol = tol.max(self.size() * 1e-6);
if let Some(m) = &self.mesh {
@@ -225,10 +327,30 @@ impl Body {
guard("tessellate", || {
let meshed = mesh_shells(&self.solid, tol);
let mut out = Mesh::default();
let solid_faces: Vec<&mt::Face> = self.solid.face_iter().collect();
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
let Some(pm) = face.surface() else { continue };
let flip = !face.orientation();
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
// Curved faces: split triangles that cut through the surface.
if refine
&& let Some(sf) = solid_faces.get(fi)
&& !matches!(sf.surface(), mt::Surface::Plane(_))
&& let Some(tris) = refined(&pm, &sf.surface(), tol)
{
let fid = u32::try_from(fi).unwrap_or(u32::MAX);
for t in tris {
let mut idx = [0u32; 3];
for (slot, (p, n)) in idx.iter_mut().zip(t) {
out.positions.push(p);
out.normals.push(if flip { -n } else { n });
*slot = u32::try_from(out.positions.len() - 1).unwrap_or(0);
}
out.triangles.push(if flip { [idx[0], idx[2], idx[1]] } else { idx });
out.tri_face.push(fid);
}
continue;
}
let pos = pm.positions();
let nor = pm.normals();
// Expand to per-corner vertices so normals stay per face (crisp edges).
+4 -4
View File
@@ -79,13 +79,13 @@ pub fn measure(b: &Body) -> Result<BodyMeasure> {
});
}
let size = b.size();
let coarse = b.tessellate((size * 1e-3).max(1e-3))?;
let coarse = b.tessellate_with((size * 1e-3).max(1e-3), true)?;
// Chord error relative to a radius sets the volume error: keep it near 3e-4 of the
// smallest radius (thin tubes, small fillets).
let tight = min_edge_radius(&coarse).map(|r| r * 3e-4).unwrap_or(f64::INFINITY);
let fine_tol = (size * 5e-5).min(tight).max(size * 5e-6).max(1e-4);
let fine = b.tessellate(fine_tol)?;
let medium = b.tessellate((fine_tol * 5.0).max(2e-4))?;
let fine = b.tessellate_with(fine_tol, true)?;
let medium = b.tessellate_with((fine_tol * 5.0).max(2e-4), true)?;
let nf = b.face_count();
let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf));
let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9;
@@ -114,6 +114,6 @@ pub fn measure(b: &Body) -> Result<BodyMeasure> {
edges: b.edge_count(),
vertices: b.vertex_count(),
shells: b.shell_count(),
merged: merged_topology(b, &coarse)?,
merged: merged_topology(b, &b.tessellate((size * 1e-3).max(1e-3))?)?,
})
}
+34
View File
@@ -875,3 +875,37 @@ fn circle_loop_fillets_and_chamfers() {
let removed = 0.5 * 2.0 * PI * (10.0 - 1.0 / 3.0);
assert!((v1 - v2 - removed).abs() < 0.02 * removed, "{} {}", v1 - v2, removed);
}
#[test]
fn trimmed_revolved_faces_measure_exactly() {
use solvecraft_geom::Seg2;
use std::f64::consts::FRAC_PI_2;
// A slot with concave ends sketched on the plane x = 12.5, cut 4 deep into a revolved shaft
// of radius 12.5 (the cut faces' triangulation spans wide angles unless refined).
let segs = vec![
Seg2::Arc { center: Vec2::new(-45.0, 0.0), radius: 4.0, start: FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(-45.0, -4.0), b: Vec2::new(-75.0, -4.0) },
Seg2::Arc { center: Vec2::new(-75.0, 0.0), radius: 4.0, start: -FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(-75.0, 4.0), b: Vec2::new(-45.0, 4.0) },
];
let pl = Plane::new(Vec3::new(12.5, 0.0, 0.0), Vec3::new(0.0, 0.0, -1.0), Vec3::Y).unwrap();
let tool = extrude(&pl, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], -4.0, 1.0).unwrap().pop().unwrap();
let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, -30.0), (12.5, -30.0), (12.5, -90.0), (10.0, -90.0), (10.0, -120.0), (0.0, -120.0)];
let segs = (0..8).map(|i| Seg2::Line { a: Vec2::new(pts[i].0, pts[i].1), b: Vec2::new(pts[(i + 1) % 8].0, pts[(i + 1) % 8].1) }).collect();
let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap();
let shaft = revolve(&xz, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap();
let c = boolean(&shaft, &tool, BoolOp::Cut).unwrap().unwrap();
// Removed: ∫∫ over the slot of (√(12.5² − y²) − 8.5), by a fine midpoint rule.
let (n, mut want) = (800, 0.0);
for i in 0..n {
let z = 30.0 + 60.0 * (i as f64 + 0.5) / n as f64;
for j in 0..n {
let y = -4.0 + 8.0 * (j as f64 + 0.5) / n as f64;
if (45.0..=75.0).contains(&z) && (z - 45.0).powi(2) + y * y >= 16.0 && (z - 75.0).powi(2) + y * y >= 16.0 {
want += ((156.25 - y * y).sqrt() - 8.5) * (60.0 / n as f64) * (8.0 / n as f64);
}
}
}
let got = measure(&shaft).unwrap().volume - measure(&c).unwrap().volume;
assert!((got - want).abs() < 0.01 * want, "removed {got}, want {want}");
}