From 7ca6e9278bc12e2531f0ef5ec51b973f8c796942 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 03:50:00 +0000 Subject: [PATCH] Loop blends: sharp corners between straight edges meet in mitres (ellipse arcs), arcs of exactly the blend's radius become sphere octants (pole-free), and loops plus the other edges blend in order (every edge of an extruded part: sphere and torus corners) Oracle: 58/76. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ROADMAP.md | 8 +- crates/kernel/src/blend.rs | 20 +++- crates/kernel/src/loopblend.rs | 180 ++++++++++++++++++++++++++++----- crates/kernel/src/tests.rs | 43 ++++++++ 4 files changed, 223 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d722ec7a..0da0d0b5 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 | **56 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) | +| Fusion oracle | **58 / 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) | @@ -58,8 +58,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors, - plate outlines); every edge of a convex planar body (sphere corners); fillets inside patterns. - Edges between two curved faces and mixed corners are not supported yet. + plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), + 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 diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 5c097bdb..6fba195b 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -307,6 +307,17 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul } } // Whole smooth loops of planar faces are blended in one go, loop by loop. + // Loops and other edges together (every edge of an extruded part): the other edges + // first, which leaves the loops smooth, then the loops. + if let Some((groups, rest)) = loop_split(&cur, edges)? + && !rest.is_empty() + && rest.len() < edges.len() + { + let _ = groups; + let loop_pts: Vec = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect(); + cur = blend(&cur, &rest, r, shape, what)?; + return blend(&cur, &loop_pts, r, shape, what); + } if let Some(groups) = loop_groups(&cur, edges)? { for g in groups { let size = cur.size(); @@ -382,6 +393,11 @@ fn edge_ids(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result> /// When the edges make up whole boundary loops of planar faces: the edge points per loop. fn loop_groups(cur: &Body, pts: &[Vec3]) -> Result>>> { + Ok(loop_split(cur, pts)?.and_then(|(g, rest)| rest.is_empty().then_some(g))) +} + +/// Edges in whole loops of planar faces (points per loop), and the other edges' points. +fn loop_split(cur: &Body, pts: &[Vec3]) -> Result>, Vec)>> { let solid = cur.deep_copy(); let size = cur.size(); let tol = (size * 2e-3).max(1e-3); @@ -430,8 +446,8 @@ fn loop_groups(cur: &Body, pts: &[Vec3]) -> Result>>> { } } } - let all_in = ids.iter().all(|x| covered.contains(x)); - Ok((all_in && !groups.is_empty()).then_some(groups)) + let rest: Vec = per.iter().filter(|(_, id)| !covered.contains(id)).map(|(p, _)| *p).collect(); + Ok((!groups.is_empty()).then_some((groups, rest))) } /// Constant-radius fillet of the edges nearest to the given points. diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index e5a82b3d..1dc4e9d6 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -169,13 +169,19 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: let owner: Vec = split.iter().map(|x| x.0).collect(); let segs: Vec = split.iter().map(|x| x.1).collect(); let k = segs.len(); - // Smooth joints. + // Joints: smooth, or sharp between two lines (the blends then meet in a mitre). + let mut mitre = vec![false; k]; for i in 0..k { let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { continue }; let p = cur.start(); let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) }; if ta.dot(tb) < 1.0 - 1e-6 { - return Err(unsupported("blending a loop with sharp corners")); + if !(matches!(prev, Seg::Line { .. }) && matches!(cur, Seg::Line { .. })) || ta.dot(tb) < -1.0 + 1e-3 { + return Err(unsupported("blending a loop with sharp corners next to arcs")); + } + if let Some(x) = mitre.get_mut(i) { + *x = true; + } } } // Walls: the other face of each edge, perpendicular to the face; all on one side. @@ -226,26 +232,91 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: let sb = r / (phi / 2.0).tan(); Some((m, t, sb, r / (phi / 2.0).sin())) }; + // The straight seam between the walls at a sharp joint (direction away from the face). + let seam_dir = |i: usize, p: Vec3| -> Option { + let o = owner.get(i)?; + let v = edges.get(*o)?.front().clone(); + let loop_ids: Vec = edges.iter().map(|e| e.id()).collect(); + let mut dirs = Vec::new(); + for g in faces { + for e in g.edge_iter() { + if loop_ids.contains(&e.id()) || !(*e.front() == v || *e.back() == v) { + continue; + } + let other = if *e.front() == v { e.back().clone() } else { e.front().clone() }; + if let Some(d) = (from_p3(other.point()) - p).normalized() + && !dirs.iter().any(|x: &Vec3| x.dot(d) > 1.0 - 1e-9) + { + dirs.push(d); + } + } + } + match dirs[..] { + [d] => Some(d), + _ => None, + } + }; let mut pa = Vec::with_capacity(k); let mut pb = Vec::with_capacity(k); let mut centres = Vec::with_capacity(k); for i in 0..k { let p = segs.get(i).map(|s| s.start()).unwrap_or_default(); let (m, t, sb, dc) = frame(i, p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?; - pa.push(p + m * sb); - pb.push(p + t * sb); centres.push(p + (m + t).normalized().unwrap_or(m) * dc); + if !mitre.get(i).copied().unwrap_or(false) { + pa.push(p + m * sb); + pb.push(p + t * sb); + continue; + } + // Sharp joint: the face rails meet where the two offset lines cross; the wall rails + // meet on the seam. + let ip = (i + k - 1) % k; + let (m0, t0, sb0, _) = frame(ip, p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?; + let (Some(d0), Some(d1)) = (segs.get(ip).and_then(|x| x.tangent(p)), segs.get(i).and_then(|x| x.tangent(p))) else { + return Err(unsupported("degenerate loop edge")); + }; + let x = d0.cross(d1); + let lam = ((m * sb - m0 * sb0).cross(d1)).dot(x) / x.len2(); + pa.push(p + m0 * sb0 + d0 * lam); + let e = seam_dir(i, p).ok_or_else(|| unsupported("a sharp corner without one straight seam"))?; + let (h0, h1) = (e.dot(t0), e.dot(t)); + if h0 < 1e-6 || h1 < 1e-6 || (sb0 / h0 - sb / h1).abs() > tol * 10.0 { + return Err(unsupported("a sharp corner whose walls meet the blend differently")); + } + pb.push(p + e * (sb / h1)); } - // The offset arcs must not collapse. - for s in &segs { + // The offset arcs must not turn inside out; an arc of exactly the blend's radius shrinks + // to a point (a rounded corner rounded again: the blend there is a sphere). + let mut collapsed = vec![false; k]; + for (i, s) in segs.iter().enumerate() { if let Seg::Arc { a, c, .. } = *s { let toward = into(s, a).dot(c - a) > 0.0; - if toward && a.dist(c) <= r + tol { + if toward + && (a.dist(c) - r).abs() <= tol * 10.0 + && !mitre.get(i).copied().unwrap_or(true) + && !mitre.get((i + 1) % k).copied().unwrap_or(true) + { + if let Some(x) = collapsed.get_mut(i) { + *x = true; + } + } else if toward && a.dist(c) <= r + tol { return Err(unsupported("the blend is larger than a corner radius")); } } } - let va: Vec = pa.iter().map(|p| builder::vertex(p3(*p))).collect(); + if collapsed.iter().all(|x| *x) { + return Err(unsupported("the blend is larger than a corner radius")); + } + let mut va: Vec = pa.iter().map(|p| builder::vertex(p3(*p))).collect(); + // A collapsed piece starts and ends at one vertex. + for i in 0..k { + if collapsed.get(i).copied().unwrap_or(false) + && let Some(v) = va.get(i).cloned() + && let Some(slot) = va.get_mut((i + 1) % k) + { + *slot = v; + } + } let vb: Vec = pb.iter().map(|p| builder::vertex(p3(*p))).collect(); let centre = |i: usize| centres.get(i).copied().unwrap_or_default(); let profile_mid = |i: usize| { @@ -253,16 +324,50 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: c + (s.start() - c).normalized().unwrap_or(n) * r }; // Profiles at the joints (face side → wall side). - let profiles: Vec = (0..k) - .map(|i| { - let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { - return builder::line(&builder::vertex(p3(Vec3::ZERO)), &builder::vertex(p3(Vec3::X))); - }; - if round { builder::circle_arc(a, b, p3(profile_mid(i))) } else { builder::line(a, b) } - }) - .collect(); + let mut profiles: Vec = Vec::with_capacity(k); + for i in 0..k { + let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) }; + if !round { + profiles.push(builder::line(a, b)); + continue; + } + if !mitre.get(i).copied().unwrap_or(false) { + profiles.push(builder::circle_arc(a, b, p3(profile_mid(i)))); + continue; + } + // Mitre: the cross-section circle of this edge's blend where it passes the face-side + // point, slid along the edge onto the mitre plane (an ellipse arc, on both blends). + let p = segs.get(i).map(|x| x.start()).unwrap_or_default(); + let d1 = segs.get(i).and_then(|x| x.tangent(p)).ok_or_else(|| unsupported("tangent"))?; + let (pa_i, pb_i) = (from_p3(a.point()), from_p3(b.point())); + let lam = (pa_i - p).dot(d1); + let (_, t, sb, _) = frame(i, p).ok_or_else(|| unsupported("frame"))?; + let cc = centre(i) + d1 * lam; + let q = p + t * sb + d1 * lam; + let mid = cc + ((pa_i - cc) + (q - cc)).normalized().unwrap_or(t) * r; + let arc = builder::circle_arc(&builder::vertex(p3(pa_i)), &builder::vertex(p3(q)), p3(mid)); + let nb = (pa_i - p).cross(pb_i - p).normalized().ok_or_else(|| unsupported("mitre plane"))?; + let den = d1.dot(nb); + if den.abs() < 1e-9 { + return Err(unsupported("mitre plane along the edge")); + } + // x ↦ x − d1 ((x − p)·nb) / (d1·nb), column-major. + let col = |j: usize| { + let ej = [Vec3::X, Vec3::Y, Vec3::Z][j]; + ej - d1 * (ej.dot(nb) / den) + }; + let (c0, c1, c2) = (col(0), col(1), col(2)); + let tr = d1 * (p.dot(nb) / den); + let mat = mt::Matrix4::new(c0.x, c0.y, c0.z, 0.0, c1.x, c1.y, c1.z, 0.0, c2.x, c2.y, c2.z, 0.0, tr.x, tr.y, tr.z, 1.0); + let mut curve = arc.curve(); + mt::Transformed::transform_by(&mut curve, mat); + profiles.push(mt::Edge::new(a, b, curve)); + } let rail = |vs: &[mt::Vertex], i: usize, offset: &dyn Fn(Vec3, &Seg) -> Vec3| -> Option { let s = segs.get(i)?; + if std::ptr::eq(vs, va.as_slice()) && collapsed.get(i).copied().unwrap_or(false) { + return None; + } let (a, b) = (vs.get(i)?, vs.get((i + 1) % k)?); Some(match *s { Seg::Line { .. } => builder::line(a, b), @@ -278,8 +383,10 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: let i = piece_of(s); frame(i, p).map(|(_, t, sb, _)| p + t * sb).unwrap_or(p) }; - let face_rails: Vec = - (0..k).map(|i| rail(&va, i, &face_off)).collect::>().ok_or_else(|| KernelError::Failed("rail".into()))?; + let face_rails: Vec> = (0..k).map(|i| rail(&va, i, &face_off)).collect(); + if face_rails.iter().zip(&collapsed).any(|(r, c)| r.is_none() && !c) { + return Err(KernelError::Failed("rail".into())); + } let wall_rails: Vec = (0..k).map(|i| rail(&vb, i, &wall_off)).collect::>().ok_or_else(|| KernelError::Failed("rail".into()))?; // Seams between neighbouring walls at the joints get shorter. @@ -314,14 +421,14 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: } // Rebuild faces. let idx_of = |id: mt::EdgeID| loop_ids.iter().position(|x| *x == id); - let rebuild = |g: &mt::Face, rails: &[mt::Edge]| -> Result { + let rebuild = |g: &mt::Face, rails: &[Option]| -> Result { let mut wires = Vec::new(); for w in g.absolute_boundaries() { let mut es = Vec::new(); for e in w.edge_iter() { if let Some(i) = idx_of(e.id()) { // Rails run in loop order; an edge used the other way takes them reversed. - let mine: Vec<&mt::Edge> = owner.iter().zip(rails).filter(|(o, _)| **o == i).map(|(_, r)| r).collect(); + let mine: Vec<&mt::Edge> = owner.iter().zip(rails).filter(|(o, _)| **o == i).filter_map(|(_, r)| r.as_ref()).collect(); let same = edges.get(i).is_some_and(|le| le.orientation() == e.orientation()); if same { es.extend(mine.into_iter().cloned()); @@ -346,7 +453,8 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: if gi == fi { out.push(rebuild(g, &face_rails)?); } else if walls.contains(&gi) { - out.push(rebuild(g, &wall_rails)?); + let wr: Vec> = wall_rails.iter().cloned().map(Some).collect(); + out.push(rebuild(g, &wr)?); } else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) { out.push(rebuild(g, &[])?); } else { @@ -362,7 +470,30 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: continue; }; let swept: mt::Shell = match *s { - Seg::Line { a, b } => vec![builder::tsweep(pr, v3(b - a))].into(), + Seg::Line { a, b } => { + // The cross-section at the start, swept along the whole edge and beyond (the + // face may reach past the edge's ends at a mitre). + let d = (b - a).normalized().ok_or_else(|| unsupported("degenerate loop edge"))?; + let (_, t, sb, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?; + let m = into(s, a); + let ext = size; + let back = d * -ext; + let (fa, wa) = (builder::vertex(p3(a + m * sb + back)), builder::vertex(p3(a + t * sb + back))); + let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i) + back)) } else { builder::line(&fa, &wa) }; + vec![builder::tsweep(&base, v3(b - a + d * (2.0 * ext)))].into() + } + Seg::Arc { .. } if collapsed.get(i).copied().unwrap_or(false) => { + // A sphere about the profile's centre, its poles well away from the corner + // patch (a pole inside a face's corner confuses the meshing). + let c = centre(i); + let corners = [from_p3(pr.front().point()), from_p3(pr.back().point()), from_p3(pr_next.back().point())]; + let g = corners.iter().fold(Vec3::ZERO, |acc, q| acc + (*q - c).normalized().unwrap_or(Vec3::ZERO)); + let g = g.normalized().ok_or_else(|| unsupported("sphere corner"))?; + let w = g.any_perp(); + let (top, eq, bottom) = (builder::vertex(p3(c + w * r)), p3(c + g * r), builder::vertex(p3(c - w * r))); + let meridian = builder::circle_arc(&top, &bottom, eq); + builder::rsweep(&meridian, p3(c), v3(w), mt::Rad(std::f64::consts::TAU)) + } Seg::Arc { c, axis, angle, .. } => builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle)), }; let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?; @@ -375,7 +506,10 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: surf = mt::Invertible::inverse(&surf); } } - let w: mt::Wire = vec![fr.inverse(), pr.clone(), wr.clone(), pr_next.inverse()].into(); + let w: mt::Wire = match fr { + Some(fr) => vec![fr.inverse(), pr.clone(), wr.clone(), pr_next.inverse()].into(), + None => vec![pr.clone(), wr.clone(), pr_next.inverse()].into(), + }; let face = mt::Face::try_new(vec![w.clone()], surf.clone()).map_err(|e| KernelError::Failed(format!("blend face: {e}")))?; out.push(face); } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 076b561e..df0a9c77 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -958,3 +958,46 @@ fn draft_of_a_round_ended_pocket() { 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); } + +#[test] +fn rounded_corners_rounded_again_make_spheres() { + // Vertical edges first, then the top and bottom loops with the same radius: the corners + // become sphere octants, as if every edge were rounded at once. + let (a, b, c, r) = (40.0, 30.0, 20.0, 2.0); + let bx = box_solid(Vec3::ZERO, Vec3::new(a, b, c)).unwrap(); + let vert: Vec = [(0.0, 0.0), (a, 0.0), (a, b), (0.0, b)].iter().map(|(x, y)| Vec3::new(*x, *y, c / 2.0)).collect(); + let f = fillet(&bx, &vert, r).unwrap(); + let f = fillet(&f, &[Vec3::new(a / 2.0, 0.0, c)], r).unwrap(); + let f = fillet(&f, &[Vec3::new(a / 2.0, 0.0, 0.0)], r).unwrap(); + let (ai, bi, ci) = (a - 2.0 * r, b - 2.0 * r, c - 2.0 * r); + let area = 2.0 * (ai * bi + bi * ci + ai * ci) + PI * r * (ai + bi + ci) * 2.0 + 4.0 * PI * r * r; + let vol = ai * bi * ci + 2.0 * r * (ai * bi + bi * ci + ai * ci) + PI * r * r * (ai + bi + ci) + 4.0 / 3.0 * PI * r * r * r; + let m = measure(&f).unwrap(); + println!("area {} want {area}; volume {} want {vol}; faces {:?}", m.area, m.volume, m.merged); + assert!(rel(m.volume, vol) < 1e-4, "volume {} vs {vol}", m.volume); + assert!(rel(m.area, area) < 1e-4, "area {} vs {area}", m.area); +} + +#[test] +fn every_edge_of_an_l_block() { + // An L (60 × 40, legs 8 wide) 20 thick with all 18 edges rounded by 2: the inner vertical + // edge is concave (its corners become tori), the others convex (sphere corners). + let pts = [(0.0, 0.0), (60.0, 0.0), (60.0, 8.0), (8.0, 8.0), (8.0, 40.0), (0.0, 40.0)]; + let outer = Loop2::polygon(&pts.iter().map(|(x, y)| Vec2::new(*x, *y)).collect::>()); + let l = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap(); + let edges: Vec = l.edges(0.1).unwrap().iter().map(|e| e.mid).collect(); + let f = fillet(&l, &edges, 2.0).unwrap(); + let m = measure(&f).unwrap(); + // Area by pieces: walls (lengths less the roundings, 16 high), top and bottom (the L shrunk + // by 2 with a radius-4 inner corner), quarter cylinders along every straight edge, ten + // sphere octants, and two torus quarters (Pappus: arc π, centroid radius 4 − 4/π, angle π/2). + let walls = 16.0 * (56.0 + 4.0 + 48.0 + 28.0 + 4.0 + 36.0); + let caps = 2.0 * (56.0 * 4.0 + 4.0 * 32.0 + 16.0 - 4.0 * PI); + let vertical = 6.0 * PI * 16.0; + let horizontal = 2.0 * PI * (56.0 + 4.0 + 48.0 + 28.0 + 4.0 + 36.0); + let spheres = 10.0 * 2.0 * PI; + let tori = 2.0 * PI * (4.0 - 4.0 / PI) * PI / 2.0; + let area = walls + caps + vertical + horizontal + spheres + tori; + assert!(rel(m.area, area) < 1e-4, "area {} vs {area}", m.area); + assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (38, 74, 38)); +}