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 <noreply@anthropic.com>
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

+3 -2
View File
@@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|---|---|
| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
| Command parity (in scope) | **139 / 541 (26%)** in-scope commands; SOLID + SKETCH **102 / 291 (35%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **52 / 66 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
| Fusion oracle | **53 / 66 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
@@ -64,7 +64,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
(extruded and box-like parts); other coincident configurations can still fail. Fully internal
voids are not supported. Spheres are built from six pole-free patches so they
combine reliably.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative).
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
+1 -1
View File
@@ -104,7 +104,7 @@ fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, Stri
let faces0: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
let ok = close(v, volume) && close(a, a0) && faces == faces0;
(ok, format!("re-imported volume {v:.3}, area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
}
Err(e) => (false, format!("import: {e}")),
}
+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}");
}