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> = vec![None; faces.len()]; + for (i, sf) in old.iter().enumerate() { + if !chosen.contains(&i) { + continue; + } + let surface = match *sf { + Surf::Plane { n, d } => { + let n2 = (n * angle.cos() + pull * angle.sin()).normalized().ok_or_else(|| fail("draft"))?; + // A point of the old plane on the neutral plane: the hinge. + let o = neutral.intersect_ray(n * d, pull).ok_or_else(|| fail("draft: hinge"))?; + let u = n2.cross(pull).normalized().ok_or_else(|| fail("draft"))?; + let w = n2.cross(u); + mt::Surface::Plane(mt::Plane::new(p3(o), p3(o + u), p3(o + w))) + } + Surf::Cylinder { o, a, r, convex } => { + // Generator: the wall at two heights along one radial direction. + let u = a.any_perp(); + // Over the face's heights (and a little beyond), short of the apex. + let hs: Vec = faces.get(i).map(|f| f.vertex_iter().map(|v| height(from_p3(v.point()))).collect()).unwrap_or_default(); + let (lo, hi) = hs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, h| (m.0.min(*h), m.1.max(*h))); + if !(lo.is_finite() && hi.is_finite()) { + return Err(fail("draft: a wall without corners")); + } + let pad = (hi - lo).max(size * 1e-3) * 0.25; + let (mut h0, mut h1) = (lo - pad, hi + pad); + // Keep the radius positive along the generator. + let r_at = |h: f64| if convex { r - h * tan } else { r + h * tan }; + for h in [&mut h0, &mut h1] { + if r_at(*h) <= r * 1e-3 { + *h = if (*h - lo).abs() < (*h - hi).abs() { lo } else { hi }; + } + } + let base = |h: f64| { + let rr = if convex { r - h * tan } else { r + h * tan }; + o + pull * (h - height(o)) + u * rr + }; + let (pa, pb) = (base(h0), base(h1)); + let line = mt::Curve::Line(mt::Line(p3(pa), p3(pb))); + let rc = mt::RevolutedCurve::by_revolution(line, p3(o), mt::Vector3::new(a.x, a.y, a.z)); + mt::Surface::RevolutedCurve(mt::Processor::new(rc)) + } + }; + if let Some(slot) = surfaces.get_mut(i) { + *slot = Some(surface); + } + } + guard("draft", || { + let verts: HashMap = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect(); + let mut edges: HashMap = HashMap::new(); + for e in b.solid.edge_iter() { + if edges.contains_key(&e.id()) { + continue; + } + let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) }; + let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("draft: edges must be lines or arcs"))?; + let ne = match kind { + None => builder::line(a, c), + Some(m) => { + let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?; + let (o, s, n) = at(fs, height(m)).ok_or_else(|| fail("draft"))?; + let m2 = move_point(m, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("draft: an arc that can't follow its faces"))?; + builder::circle_arc(a, c, p3(m2)) + } + }; + edges.insert(e.id(), ne); + } + let mut out = Vec::new(); + for (i, f) in faces.iter().enumerate() { + let wires: Vec = f + .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 nf = match surfaces.get(i).cloned().flatten() { + Some(mut surface) => { + // Outward like the old face (checked at a boundary point). + let probe = f.boundaries().first().and_then(|w| w.vertex_iter().next()).map(|v| from_p3(v.point())); + let want = probe.and_then(|p| old.get(i).and_then(|s| s.normal(p))); + if let (Some(p), Some(want)) = (probe, want) { + use mt::{ParametricSurface3D, SearchNearestParameter}; + if let Some((u, v)) = surface.search_nearest_parameter(p3(p), None, 100) { + let nn = surface.normal(u, v); + if Vec3::new(nn.x, nn.y, nn.z).dot(want) < 0.0 { + surface = mt::Invertible::inverse(&surface); + } + } + } + mt::Face::try_new(wires, surface).map_err(|e| fail(&e.to_string()))? + } + None => { + // Unchanged surface: rebuild on the oriented boundary. + let mut nf = mt::Face::try_new( + 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(), + f.surface(), + ) + .map_err(|e| fail(&e.to_string()))?; + if !f.orientation() { + nf.invert(); + } + nf + } + }; + out.push(nf); + } + let shell: mt::Shell = out.into(); + let solid = Solid::try_new(vec![shell]).map_err(|e| fail(&e.to_string()))?; + Body::new(solid) + }) +} diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 861ef1cd..1a0f0bd1 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -161,7 +161,10 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) return Err(KernelError::Invalid("draft angle must be between −90° and 90°".into())); } let pull = pull.normalized().ok_or_else(|| KernelError::Invalid("pull direction".into()))?; - let hs = planar_convex(b)?; + let hs = match planar_convex(b) { + Ok(h) => h, + Err(_) => return draft_walls(b, faces, neutral, pull, angle), + }; let size = b.size(); let mut out = Vec::new(); let mut moved = 0; @@ -187,6 +190,31 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) convex_polyhedron(&out) } +/// Draft of a non-convex body: walls (planes, cylinders) next to caps square to `pull`. +fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result { + let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?; + let size = healed.size(); + let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; + let mut chosen: Vec = Vec::new(); + for p in at { + let near = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize))) + .min_by(|a, c| a.0.total_cmp(&c.0)); + match near { + Some((d, f)) if d < size * 1e-3 + 1e-6 => { + if !chosen.contains(&f) { + chosen.push(f); + } + } + _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), + } + } + crate::offset::draft_walls(&healed, &chosen, neutral, pull, angle) +} + /// Round every edge of a convex body with planar faces by `r`: faces shrink, each edge becomes a /// cylinder and each corner a sphere patch (the body is the inner polyhedron grown by a ball). pub fn round_all_edges(b: &Body, r: f64) -> Result { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 025f6999..076b561e 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -932,3 +932,29 @@ fn shell_with_cylindrical_faces() { let v = measure(&s).unwrap().volume; assert!(rel(v, outer - cavity) < 1e-4, "{v} vs {}", outer - cavity); } + +#[test] +fn draft_of_a_round_ended_pocket() { + use solvecraft_geom::Seg2; + use std::f64::consts::FRAC_PI_2; + let block = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 25.0)).unwrap(); + let segs = vec![ + Seg2::Line { a: Vec2::new(25.0, 20.0), b: Vec2::new(55.0, 20.0) }, + Seg2::Arc { center: Vec2::new(55.0, 30.0), radius: 10.0, start: -FRAC_PI_2, sweep: PI }, + Seg2::Line { a: Vec2::new(55.0, 40.0), b: Vec2::new(25.0, 40.0) }, + Seg2::Arc { center: Vec2::new(25.0, 30.0), radius: 10.0, start: FRAC_PI_2, sweep: PI }, + ]; + let tool = extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 15.0, 30.0).unwrap().pop().unwrap(); + let b = boolean(&block, &tool, BoolOp::Cut).unwrap().unwrap(); + let walls = [Vec3::new(15.0, 30.0, 20.0), Vec3::new(40.0, 20.0, 20.0), Vec3::new(65.0, 30.0, 20.0), Vec3::new(40.0, 40.0, 20.0)]; + let a = 5f64.to_radians(); + let d = draft(&b, &walls, &Plane::XY.offset(25.0), Vec3::Z, a).unwrap(); + // The pocket narrows going down: at depth t its walls stand t·tan(a) further in. + let (n, mut pocket) = (2000, 0.0); + for k in 0..n { + let e = (k as f64 + 0.5) / n as f64 * 10.0 * a.tan(); + pocket += (30.0 * (20.0 - 2.0 * e) + PI * (10.0 - e).powi(2)) * 10.0 / n as f64; + } + let v = measure(&d).unwrap().volume; + assert!(rel(v, 80.0 * 60.0 * 25.0 - pocket) < 1e-4, "{v} vs {}", 80.0 * 60.0 * 25.0 - pocket); +}