From 5539de8fd4d283b4e20f32e15209530d5be03ddd Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 05:31:47 +0000 Subject: [PATCH] Booleans: a face crossed by nested intersection loops (both walls of a tube) gives each hole to the innermost piece holding it (vendored shapeops; tubes and tori cut across now work); revolved full circles start at an odd angle so their seams don't lie in common cut planes Oracle: 70/76. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ROADMAP.md | 2 +- crates/kernel/src/build.rs | 37 ++++++++++++++++++- crates/kernel/src/tests.rs | 23 ++++++++++++ .../src/transversal/divide_face/mod.rs | 11 +++++- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b7008d07..89e70226 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 | **60 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) | +| Fusion oracle | **70 / 76 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) | diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index bc50205d..998e449f 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -144,7 +144,42 @@ 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() }; + // Full circles start at an odd angle: their seam points then don't lie in the planes + // through the profile's centre that other bodies tend to cut along. + let reseam = |l: Loop2| -> Loop2 { + let c = match l.segs.first() { + Some(Seg2::Arc { center, radius, .. }) => Some((*center, *radius)), + _ => None, + }; + // (A circle touching the axis keeps its vertex there.) + let touches = c.is_some_and(|(center, radius)| (d.cross(center - axis_origin).abs() - radius).abs() < 1e-7); + match c { + Some((center, radius)) + if !touches + && l.segs.iter().all( + |s| matches!(s, Seg2::Arc { center: c2, radius: r2, .. } if c2.dist(center) < 1e-9 && (r2 - radius).abs() < 1e-9), + ) + && (l + .segs + .iter() + .map(|s| match s { + Seg2::Arc { sweep, .. } => *sweep, + _ => 0.0, + }) + .sum::() + .abs() + - std::f64::consts::TAU) + .abs() + < 1e-6 => + { + let ccw = l.signed_area() > 0.0; + let n = Loop2::circle_from(center, radius, 0.4142); + if ccw { n } else { n.reversed() } + } + _ => l, + } + }; + let r = &Region2 { outer: reseam(snap_loop(&r.outer)), holes: r.holes.iter().map(|h| reseam(snap_loop(h))).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() diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index a6d8686d..c696bbd6 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1037,3 +1037,26 @@ fn overlapping_holes_and_mirror_joins() { assert!(rel(u.volume, v1 * 5.0 / 3.0) < 2e-4, "{} (one: {v1})", u.volume); assert_eq!(u.merged.faces, 3, "{:?}", u.merged); } + +#[test] +fn torus_cut_through_its_middle_plane() { + // A torus whose tube centre lies in a box's top face: the box's top cuts the torus where + // the tube is vertical. (The revolve profile's seam points used to lie in that plane.) + let bx = box_solid(Vec3::new(-30.0, -30.0, -10.0), Vec3::new(30.0, 30.0, 0.0)).unwrap(); + let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap(); + let ring = Region2 { outer: Loop2::circle(Vec2::new(22.0, 0.0), 4.0), holes: vec![] }; + let torus = revolve(&xz, &[ring], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap(); + let c = boolean(&bx, &torus, BoolOp::Cut).unwrap().unwrap(); + // Half the torus volume (2π²Rr²) is removed. + let want = 36000.0 - PI * PI * 22.0 * 16.0; + let v = measure(&c).unwrap().volume; + assert!(rel(v, want) < 1e-4, "{v} vs {want}"); + // A revolved tube cut across: both walls cross the box's top face (nested loops on it). + let tube = + Region2 { outer: Loop2::polygon(&[Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, -30.0), Vec2::new(5.0, -30.0)]), holes: vec![] }; + let t = revolve(&xz, &[tube], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap(); + let bx = box_solid(Vec3::new(-40.0, -40.0, -40.0), Vec3::new(40.0, 40.0, 10.0)).unwrap(); + let i = boolean(&t, &bx, BoolOp::Intersect).unwrap().unwrap(); + let v = measure(&i).unwrap().volume; + assert!(rel(v, PI * 75.0 * 10.0) < 1e-4, "{v}"); +} diff --git a/vendor/truck-shapeops/src/transversal/divide_face/mod.rs b/vendor/truck-shapeops/src/transversal/divide_face/mod.rs index bf021083..965599ce 100644 --- a/vendor/truck-shapeops/src/transversal/divide_face/mod.rs +++ b/vendor/truck-shapeops/src/transversal/divide_face/mod.rs @@ -86,7 +86,16 @@ where } negative_wires.into_iter().try_for_each(|chunk| { let pt = chunk.poly.front(); - let op = crate::tr!(pre_faces.iter_mut().find(|face| face[0].poly.include(pt)), "loop not inside any face piece"); + // SolveCraft: the innermost piece that holds the loop (nested intersection loops, such + // as both walls of a tube crossing one face), never the loop's own twin. + let hole = -chunk.poly.area(); + let op = crate::tr!( + pre_faces + .iter_mut() + .filter(|face| face[0].poly.area() > hole * (1.0 + 1e-6) && face[0].poly.include(pt)) + .min_by(|x, y| x[0].poly.area().total_cmp(&y[0].poly.area())), + "loop not inside any face piece" + ); op.push(chunk); Some(()) })?;