diff --git a/ROADMAP.md b/ROADMAP.md
index fcbd9834..d722ec7a 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 | **55 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
+| Fusion oracle | **56 / 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) |
@@ -75,7 +75,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- Control-point splines of degree 4 and up, and free-form curves split by crossings, enter
profiles as polylines; 3D sketch curves are reference geometry (not sweep paths yet).
- Shell and offset faces work on bodies of planes and cylinders (convex or not: filleted boxes,
- bosses), draft on convex planar bodies; loft is ruled.
+ bosses); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; loft is ruled.
## Kernel
diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs
index c6794ffb..1fd9f939 100644
--- a/crates/kernel/src/offset.rs
+++ b/crates/kernel/src/offset.rs
@@ -334,3 +334,198 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
Body::new(solid)
})
}
+
+/// Draft walls parallel to `pull` (planes, and cylinders about axes along `pull`) by `angle`
+/// about their line on the neutral plane: at height h along `pull` a wall moves along its
+/// outward normal by −h·tan(angle), so planes tilt and cylinders become cones. The other
+/// faces must be caps square to `pull`; the topology is kept.
+pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom::Plane, pull: Vec3, angle: f64) -> Result
{
+ let faces: Vec = b.solid.face_iter().cloned().collect();
+ let size = b.size();
+ let tol = (size * 1e-7).max(1e-9);
+ let nn = neutral.normal();
+ let den = pull.dot(nn);
+ if den.abs() < 1e-9 {
+ return Err(fail("the neutral plane is parallel to the pull direction"));
+ }
+ let height = |p: Vec3| (p - neutral.origin).dot(nn) / den;
+ let tan = angle.tan();
+ let mut old = Vec::with_capacity(faces.len());
+ for (i, f) in faces.iter().enumerate() {
+ let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("draft: only bodies with planar and cylindrical faces"))?;
+ let wall = match sf {
+ Surf::Plane { n, .. } => n.dot(pull).abs() < 1e-9,
+ Surf::Cylinder { a, .. } => a.dot(pull).abs() > 1.0 - 1e-9,
+ };
+ let cap = matches!(sf, Surf::Plane { n, .. } if n.dot(pull).abs() > 1.0 - 1e-9);
+ if chosen.contains(&i) && !wall {
+ return Err(fail("draft: a chosen face isn't parallel to the pull direction"));
+ }
+ if !chosen.contains(&i) && !cap && !wall {
+ return Err(fail("draft: faces next to the drafted walls must be square to the pull direction"));
+ }
+ old.push(sf);
+ }
+ // Shifts and moved surfaces at a height.
+ let at = |ids: &[usize], h: f64| -> Option<(Vec, Vec, Vec)> {
+ let mut o = Vec::new();
+ let mut s = Vec::new();
+ let mut n = Vec::new();
+ for i in ids {
+ let sf = *old.get(*i)?;
+ let sh = if chosen.contains(i) { -h * tan } else { 0.0 };
+ o.push(sf);
+ s.push(sh);
+ n.push(sf.moved(sh)?);
+ }
+ Some((o, s, n))
+ };
+ let mut vfaces: HashMap> = HashMap::new();
+ let mut efaces: HashMap> = HashMap::new();
+ for (i, f) in faces.iter().enumerate() {
+ for v in f.vertex_iter() {
+ let e = vfaces.entry(v.id()).or_default();
+ if !e.contains(&i) {
+ e.push(i);
+ }
+ }
+ for ed in f.edge_iter() {
+ let e = efaces.entry(ed.id()).or_default();
+ if !e.contains(&i) {
+ e.push(i);
+ }
+ }
+ }
+ let mut newpos: HashMap = HashMap::new();
+ for v in b.solid.vertex_iter() {
+ let p = from_p3(v.point());
+ let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
+ let (o, s, n) = at(fs, height(p)).ok_or_else(|| fail("draft: a wall shrinks to nothing"))?;
+ let q = move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("draft: a corner whose faces don't meet the same way after the draft"))?;
+ newpos.insert(v.id(), q);
+ }
+ // New surfaces for the drafted walls: planes through their hinge line, cylinders → cones.
+ let mut surfaces: Vec