diff --git a/ROADMAP.md b/ROADMAP.md index be76f3a3..c62e6ee4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, diff --git a/apps/solvecraft-cli/src/oracle.rs b/apps/solvecraft-cli/src/oracle.rs index 7ebf37d9..47a40e97 100644 --- a/apps/solvecraft-cli/src/oracle.rs +++ b/apps/solvecraft-cli/src/oracle.rs @@ -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}")), } diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index f320e4b5..8782a442 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -56,6 +56,102 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec { .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> { + 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 { + 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 { + 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 { 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). diff --git a/crates/kernel/src/measure.rs b/crates/kernel/src/measure.rs index 9b74d840..7c4b7380 100644 --- a/crates/kernel/src/measure.rs +++ b/crates/kernel/src/measure.rs @@ -79,13 +79,13 @@ pub fn measure(b: &Body) -> Result { }); } 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 { 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))?)?, }) } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 642dda2a..040577b0 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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}"); +}