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:
1 parent
063d55bdb3
commit
8c88fb8d2d
5 files changed
+164
-7
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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))?)?,
|
||||
})
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user