diff --git a/ROADMAP.md b/ROADMAP.md
index 0da0d0b5..a2a1514a 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 | **58 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
+| Fusion oracle | **60 / 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) |
@@ -62,10 +62,11 @@ headline counts in-scope tabs only and lists the deferred ones separately.
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
convex planar body or of an extruded part; fillets inside patterns. Edges between two curved
faces are not supported yet.
-- Coincident planar faces are pushed apart exactly when their neighbours stand perpendicular
- (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.
+- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
+ sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
+ (a part and its mirror image) are stitched; coincident planar faces are pushed apart when
+ their neighbours are planes or cylinders. Other coincident curved configurations can still
+ fail. Fully internal voids are not supported. Spheres are built from six pole-free patches.
- 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
diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs
index 661d6cff..1ecf2499 100644
--- a/crates/kernel/src/coplanar.rs
+++ b/crates/kernel/src/coplanar.rs
@@ -43,6 +43,30 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> {
/// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane.
fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result
{
+ match push_faces_exact(b, n, d, delta) {
+ Ok(r) => Ok(r),
+ // Faces of planes and cylinders: every face follows (any neighbours).
+ Err(e) => {
+ let size = b.size();
+ let tol = (size * 1e-7).max(1e-9) * 10.0;
+ let planes: Vec = b
+ .solid
+ .face_iter()
+ .map(|f| match f.oriented_surface() {
+ mt::Surface::Plane(p) => {
+ let m = p.normal();
+ let m = Vec3::new(m.x, m.y, m.z);
+ m.dot(n) > 1.0 - 1e-9 && (n.dot(from_p3(p.origin())) - d).abs() < tol
+ }
+ _ => false,
+ })
+ .collect();
+ crate::offset::offset_body(b, |i, _| if planes.get(i).copied().unwrap_or(false) { delta } else { 0.0 }).map_err(|_| e)
+ }
+ }
+}
+
+fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result {
let size = b.size();
let tol = (size * 1e-7).max(1e-9);
let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}"));
@@ -258,3 +282,105 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option Option> {
+ use mt::{BoundedCurve, ParametricCurve};
+ let size = a.size().max(b.size());
+ let tol = (size * 1e-7).max(1e-9) * 10.0;
+ let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
+ let mid = |e: &mt::Edge| {
+ let c = e.curve();
+ let (t0, t1) = c.range_tuple();
+ from_p3(c.subs((t0 + t1) * 0.5))
+ };
+ // Matching faces: same plane, opposite normals, the same edges.
+ let (pa, pb) = (planes(a), planes(b));
+ let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
+ let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
+ for (ia, x) in &pa {
+ for (ib, y) in &pb {
+ if x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
+ continue;
+ }
+ let (Some(fa), Some(fb)) = (fa_list.get(*ia), fb_list.get(*ib)) else { continue };
+ let ea: Vec = fa.edge_iter().collect();
+ let eb: Vec = fb.edge_iter().collect();
+ if ea.len() != eb.len() {
+ continue;
+ }
+ // B edge → the matching A edge.
+ let mut map: HashMap = HashMap::new();
+ let mut vmap: HashMap = HashMap::new();
+ let mut ok = true;
+ for e in &eb {
+ let ends = (key(from_p3(e.absolute_front().point())), key(from_p3(e.absolute_back().point())));
+ let m = key(mid(e));
+ let hit = ea.iter().find(|f| {
+ let fe = (key(from_p3(f.absolute_front().point())), key(from_p3(f.absolute_back().point())));
+ (fe == ends || fe == (ends.1, ends.0)) && key(mid(f)) == m
+ });
+ let Some(f) = hit else {
+ ok = false;
+ break;
+ };
+ let same = key(from_p3(f.absolute_front().point())) == ends.0;
+ map.insert(e.id(), if same { f.absolute_clone() } else { f.absolute_clone().inverse() });
+ let (fa_v, fb_v) = if same {
+ (f.absolute_front().clone(), f.absolute_back().clone())
+ } else {
+ (f.absolute_back().clone(), f.absolute_front().clone())
+ };
+ vmap.insert(e.absolute_front().id(), fa_v);
+ vmap.insert(e.absolute_back().id(), fb_v);
+ }
+ if !ok {
+ continue;
+ }
+ return Some(guard("glue", || {
+ let fail = |m: &str| KernelError::Failed(format!("joining touching faces: {m}"));
+ // B's other edges, rebuilt on A's vertices where they meet the seam.
+ let mut edges: HashMap = map.clone();
+ for e in b.solid.edge_iter() {
+ if edges.contains_key(&e.id()) {
+ continue;
+ }
+ let (f0, f1) = (e.absolute_front(), e.absolute_back());
+ if !vmap.contains_key(&f0.id()) && !vmap.contains_key(&f1.id()) {
+ edges.insert(e.id(), e.absolute_clone());
+ continue;
+ }
+ let v0 = vmap.get(&f0.id()).cloned().unwrap_or_else(|| f0.clone());
+ let v1 = vmap.get(&f1.id()).cloned().unwrap_or_else(|| f1.clone());
+ edges.insert(e.id(), mt::Edge::new(&v0, &v1, e.curve()));
+ }
+ let mut faces: Vec = fa_list.iter().enumerate().filter(|(i, _)| i != ia).map(|(_, f)| (*f).clone()).collect();
+ for (i, f) in fb_list.iter().enumerate() {
+ if i == *ib {
+ continue;
+ }
+ let wires: Vec = f
+ .absolute_boundaries()
+ .iter()
+ .map(|w| {
+ w.edge_iter()
+ .filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
+ .collect::>()
+ .into()
+ })
+ .collect();
+ let mut nf = mt::Face::try_new(wires, f.surface()).map_err(|e| fail(&e.to_string()))?;
+ if !f.orientation() {
+ nf.invert();
+ }
+ faces.push(nf);
+ }
+ let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
+ Body::new(crate::heal::heal(solid, size))
+ }));
+ }
+ }
+ None
+}
diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs
index bae5292a..32e6c5f3 100644
--- a/crates/kernel/src/lib.rs
+++ b/crates/kernel/src/lib.rs
@@ -26,6 +26,7 @@ mod offset;
mod ops;
mod polybool;
mod polyhedron;
+mod prism;
mod sew;
mod step;
mod step_in;
diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs
index 48f9e829..97684525 100644
--- a/crates/kernel/src/ops.rs
+++ b/crates/kernel/src/ops.rs
@@ -152,6 +152,35 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result