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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 03:59:23 +00:00
1 parent 457ed7fed7
commit 7ca6e9278b
4 files changed
+223 -28

No files matched your search

+5 -3
View File
@@ -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
+18 -2
View File
@@ -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<Vec3> = 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<Vec<mt::EdgeID>>
/// When the edges make up whole boundary loops of planar faces: the edge points per loop.
fn loop_groups(cur: &Body, pts: &[Vec3]) -> Result<Option<Vec<Vec<Vec3>>>> {
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<Option<(Vec<Vec<Vec3>>, Vec<Vec3>)>> {
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<Option<Vec<Vec<Vec3>>>> {
}
}
}
let all_in = ids.iter().all(|x| covered.contains(x));
Ok((all_in && !groups.is_empty()).then_some(groups))
let rest: Vec<Vec3> = 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.
+157 -23
View File
@@ -169,13 +169,19 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
let owner: Vec<usize> = split.iter().map(|x| x.0).collect();
let segs: Vec<Seg> = 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<Vec3> {
let o = owner.get(i)?;
let v = edges.get(*o)?.front().clone();
let loop_ids: Vec<mt::EdgeID> = 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<mt::Vertex> = 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<mt::Vertex> = 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<mt::Vertex> = 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<mt::Edge> = (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<mt::Edge> = 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<mt::Edge> {
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<mt::Edge> =
(0..k).map(|i| rail(&va, i, &face_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?;
let face_rails: Vec<Option<mt::Edge>> = (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<mt::Edge> =
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().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<mt::Face> {
let rebuild = |g: &mt::Face, rails: &[Option<mt::Edge>]| -> Result<mt::Face> {
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<Option<mt::Edge>> = 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);
}
+43
View File
@@ -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<Vec3> = [(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::<Vec<_>>());
let l = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
let edges: Vec<Vec3> = 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));
}