From 935e6465fa43f0902b719c7c8b5a001235415e03 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 00:30:36 +0000 Subject: [PATCH] Blends: whole smooth loops picked by one edge (tangent chain), full circles, walls at any angle, several loops per feature; flat non-plane faces healed into planes; fillets and chamfers repeat in feature patterns Oracle: 49/66. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ROADMAP.md | 8 +- crates/doc/src/eval.rs | 18 ++++ crates/kernel/src/blend.rs | 153 +++++++++++++++++++++++++-------- crates/kernel/src/heal.rs | 72 ++++++++++++++++ crates/kernel/src/loopblend.rs | 127 ++++++++++++++++++++++----- crates/kernel/src/tests.rs | 19 ++++ 6 files changed, 337 insertions(+), 60 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 69d0b682..4b96cf1e 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 | **38 / 66 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) | +| Fusion oracle | **49 / 66 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) | @@ -54,8 +54,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. ## Known limitations - Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular - planar end faces; whole smooth loops of a planar face (pocket floors, plate outlines); every edge - of a convex planar body (sphere corners). Other corner configurations are not supported yet. + 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. - 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/doc/src/eval.rs b/crates/doc/src/eval.rs index 3b61c9da..003b2aa5 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1188,6 +1188,24 @@ fn replay(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut .find(|x| &x.name == name && !matches!(x.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. })) .or_else(|| doc.find_feature(name)) .ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?; + // A fillet or chamfer repeats at the copied edges. + if let FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } = &src.kind { + let r = val(vals, radius, Kind::Length)?; + for m in mats { + let moved: Vec = edges.iter().map(|p| crate::apply_point(m, *p)).collect(); + let ti = blend_target(st, &None, &moved)?; + let Some(mb) = st.bodies.get(ti).cloned() else { continue }; + let nb = if matches!(src.kind, FeatureKind::Fillet { .. }) { + kernel::fillet(&mb.body, &moved, r)? + } else { + kernel::chamfer(&mb.body, &moved, r)? + }; + if let Some(slot) = st.bodies.get_mut(ti) { + *slot = ModelBody::new(mb.name, nb, mb.feature); + } + } + continue; + } let tools = feature_tools(vals, src, st)?; let (op, targets) = feature_op(src); let mut copies = Vec::new(); diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 423c900c..d0e5e6e6 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -306,25 +306,18 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul } } } - // A whole smooth loop of a planar face is blended in one go. - if edges.len() >= 2 { - let size = cur.size(); - let tol = (size * 2e-3).max(1e-3); - let solid = cur.deep_copy(); - let all = Body::unique_edges(&solid); - let mut ids = Vec::new(); - for p in edges { - if let Some((idx, dist)) = cur.nearest_edge(*p, tol)? - && dist <= size * 0.05 + 1e-3 - && let Some(e) = all.get(idx) - && !ids.contains(&e.id()) - { - ids.push(e.id()); + // Whole smooth loops of planar faces are blended in one go, loop by loop. + if let Some(groups) = loop_groups(&cur, edges)? { + for g in groups { + let size = cur.size(); + let solid = cur.deep_copy(); + let ids = edge_ids(&cur, &solid, &g)?; + match crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) { + Some(res) => cur = Body::new(guard(what, || res)?)?, + None => return Err(unsupported("the edges are not a whole loop of a planar face")), } } - if let Some(r2) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) { - return Body::new(guard(what, || r2)?); - } + return Ok(cur); } for p in edges { let size = cur.size(); @@ -341,6 +334,106 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul Ok(cur) } +fn edge_mid(e: &mt::Edge) -> Option { + use mt::{BoundedCurve, ParametricCurve}; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + Some(from_p3(c.subs((t0 + t1) * 0.5))) +} + +/// Do the loop's edges meet tangentially everywhere? +fn smooth_loop(w: &mt::Wire) -> bool { + use mt::{BoundedCurve, ParametricCurve}; + let es: Vec = w.edge_iter().cloned().collect(); + let n = es.len(); + let dir = |e: &mt::Edge, at_end: bool| -> Option { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let t = if at_end { t1 } else { t0 }; + let d = c.der(t); + Vec3::new(d.x, d.y, d.z).normalized() + }; + (0..n).all(|i| match (es.get(i), es.get((i + 1) % n)) { + (Some(a), Some(b)) => match (dir(a, true), dir(b, false)) { + (Some(x), Some(y)) => x.dot(y) > 1.0 - 1e-6, + _ => false, + }, + _ => false, + }) +} + +/// The B-rep edges nearest the points (each once). +fn edge_ids(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result> { + let size = cur.size(); + let tol = (size * 2e-3).max(1e-3); + let all = Body::unique_edges(solid); + let mut ids = Vec::new(); + for p in pts { + if let Some((idx, dist)) = cur.nearest_edge(*p, tol)? + && dist <= size * 0.05 + 1e-3 + && let Some(e) = all.get(idx) + && !ids.contains(&e.id()) + { + ids.push(e.id()); + } + } + Ok(ids) +} + +/// When the edges make up whole boundary loops of planar faces: the edge points per loop. +fn loop_groups(cur: &Body, pts: &[Vec3]) -> Result>>> { + let solid = cur.deep_copy(); + let size = cur.size(); + let tol = (size * 2e-3).max(1e-3); + let all = Body::unique_edges(&solid); + // Each point's edge. A point on a vertex is as near to the edges meeting there (a circle + // and a cylinder's seam line): prefer one on a planar face's boundary. + let polys = cur.edges(tol)?; + let planar_edge: std::collections::HashSet = solid + .face_iter() + .filter(|f| matches!(f.oriented_surface(), mt::Surface::Plane(_))) + .flat_map(|f| f.edge_iter().map(|e| e.id()).collect::>()) + .collect(); + let mut per: Vec<(Vec3, mt::EdgeID)> = Vec::new(); + for p in pts { + let dists: Vec<(usize, f64)> = + polys.iter().map(|e| (e.index, e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min))).collect(); + let best = dists.iter().map(|x| x.1).fold(f64::INFINITY, f64::min); + if best > size * 0.05 + 1e-3 { + return Ok(None); + } + let near: Vec<&mt::Edge> = dists.iter().filter(|x| x.1 <= best + tol).filter_map(|x| all.get(x.0)).collect(); + let Some(e) = near.iter().find(|e| planar_edge.contains(&e.id())).or(near.first()) else { return Ok(None) }; + per.push((*p, e.id())); + } + let ids: Vec = per.iter().map(|x| x.1).collect(); + let mut covered: Vec = Vec::new(); + let mut groups = Vec::new(); + for f in solid.face_iter() { + if !matches!(f.oriented_surface(), mt::Surface::Plane(_)) { + continue; + } + for w in f.boundaries() { + let wids: Vec = w.edge_iter().map(|e| e.id()).collect(); + if wids.iter().any(|x| covered.contains(x)) { + continue; + } + // The whole loop is selected, or one of its edges and the loop is smooth (tangent + // chain, which also covers a circle that a boolean left in pieces). + let all_sel = wids.iter().all(|x| ids.contains(x)); + let some_sel = wids.iter().any(|x| ids.contains(x)); + if all_sel || (some_sel && smooth_loop(&w)) { + covered.extend(wids.iter().copied()); + // One point per loop edge, on it. + let pts: Vec = w.edge_iter().filter_map(|e| edge_mid(&e)).collect(); + groups.push(pts); + } + } + } + let all_in = ids.iter().all(|x| covered.contains(x)); + Ok((all_in && !groups.is_empty()).then_some(groups)) +} + /// Constant-radius fillet of the edges nearest to the given points. pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { body.require_brep("fillet")?; @@ -383,25 +476,17 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { } // Coplanar neighbours as one face (as after a join), so corners look as they should. let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?; - // A whole smooth loop of a planar face is blended in one go. - if edges.len() >= 2 { - let size = cur.size(); - let tol = (size * 2e-3).max(1e-3); - let solid = cur.deep_copy(); - let all = Body::unique_edges(&solid); - let mut ids = Vec::new(); - for p in edges { - if let Some((idx, dist)) = cur.nearest_edge(*p, tol)? - && dist <= size * 0.05 + 1e-3 - && let Some(e) = all.get(idx) - && !ids.contains(&e.id()) - { - ids.push(e.id()); + if let Some(groups) = loop_groups(&cur, edges)? { + for g in groups { + let size = cur.size(); + let solid = cur.deep_copy(); + let ids = edge_ids(&cur, &solid, &g)?; + match crate::loopblend::loop_blend(&solid, &ids, size, distance, false) { + Some(res) => cur = Body::new(guard("chamfer", || res)?)?, + None => return Err(unsupported("the edges are not a whole loop of a planar face")), } } - if let Some(r2) = crate::loopblend::loop_blend(&solid, &ids, size, distance, false) { - return Body::new(guard("chamfer", || r2)?); - } + return Ok(cur); } for p in edges { cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) { diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index b40e7fcf..02e053ca 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -308,6 +308,74 @@ fn exact_edges(shell: &mt::Shell, tol: f64) -> Option { Some(faces.into()) } +/// Faces whose surface is flat but not a plane (a revolved line square to the axis, a swept +/// line) get a real plane, so they merge with their coplanar neighbours and blend like planes. +fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let mut changed = false; + let mut faces = Vec::new(); + for f in shell.face_iter() { + let surf = f.surface(); + if matches!(surf, mt::Surface::Plane(_)) { + faces.push(f.clone()); + continue; + } + // Parameter box of the boundary, then a grid of surface points in it. + let mut uv: Vec<(f64, f64)> = Vec::new(); + let mut pts: Vec = Vec::new(); + for w in f.absolute_boundaries() { + for e in w.edge_iter() { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + for k in 0..6 { + let p = c.subs(t0 + (t1 - t0) * k as f64 / 6.0); + pts.push(from_p3(p)); + if let Some(x) = surf.search_nearest_parameter(p, uv.last().copied(), 50) { + uv.push(x); + } + } + } + } + if uv.len() < 3 || pts.len() < 3 { + faces.push(f.clone()); + continue; + } + let (u0, u1) = uv.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, x| (a.0.min(x.0), a.1.max(x.0))); + let (v0, v1) = uv.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, x| (a.0.min(x.1), a.1.max(x.1))); + for i in 0..=4 { + for j in 0..=4 { + pts.push(from_p3(surf.subs(u0 + (u1 - u0) * i as f64 / 4.0, v0 + (v1 - v0) * j as f64 / 4.0))); + } + } + // The plane through the points (normal from the surface). + let (um, vm) = ((u0 + u1) / 2.0, (v0 + v1) / 2.0); + let nn = surf.normal(um, vm); + let Some(n) = Vec3::new(nn.x, nn.y, nn.z).normalized() else { + faces.push(f.clone()); + continue; + }; + let o = from_p3(surf.subs(um, vm)); + if pts.iter().any(|p| (*p - o).dot(n).abs() > tol) { + faces.push(f.clone()); + continue; + } + let u = n.any_perp(); + let w = n.cross(u); + let plane = mt::Plane::new(crate::body::p3(o), crate::body::p3(o + u), crate::body::p3(o + w)); + match mt::Face::try_new(f.absolute_boundaries().clone(), mt::Surface::Plane(plane)) { + Ok(mut nf) => { + if !f.orientation() { + nf.invert(); + } + faces.push(nf); + changed = true; + } + Err(_) => faces.push(f.clone()), + } + } + changed.then(|| faces.into()) +} + /// Heal a solid; returns the input unchanged when nothing applies or healing fails. pub fn heal(solid: Solid, size: f64) -> Solid { let tol = (size * 1e-7).max(1e-9); @@ -315,6 +383,10 @@ pub fn heal(solid: Solid, size: f64) -> Solid { let mut changed = false; for sh in solid.boundaries() { let mut cur = sh.clone(); + if let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) { + cur = m; + changed = true; + } if let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7)) { cur = m; changed = true; diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index 29478a04..e5a82b3d 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -55,11 +55,14 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option { let c = e.oriented_curve(); let (t0, t1) = c.range_tuple(); let pts: Vec = (0..=16).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0))).collect(); - if pts.iter().all(|p| p.dist_to_segment(a, b) < tol) { + let closed = a.dist(b) < tol; + if !closed && pts.iter().all(|p| p.dist_to_segment(a, b) < tol) { return Some(Seg::Line { a, b }); } let m = *pts.get(8)?; - let (u, v) = (b - a, m - a); + // A closed edge (a full circle): fit through points a third of the way round. + let (b_fit, m_fit) = if closed { (*pts.get(5)?, *pts.get(11)?) } else { (b, m) }; + let (u, v) = (b_fit - a, m_fit - a); let w = u.cross(v); let d = 2.0 * w.len2(); if d < 1e-300 { @@ -81,9 +84,37 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option { if ra.cross(rb).dot(axis) < 0.0 { angle = std::f64::consts::TAU - angle; } + if closed { + angle = std::f64::consts::TAU; + } Some(Seg::Arc { a, c: centre, axis, angle, mid: m }) } +/// `p` turned about the line (c, axis) by `ang`. +fn turn(p: Vec3, c: Vec3, axis: Vec3, ang: f64) -> Vec3 { + let k = axis.normalized().unwrap_or(Vec3::Z); + let v = p - c; + let (s, co) = ang.sin_cos(); + c + v * co + k.cross(v) * s + k * (k.dot(v) * (1.0 - co)) +} + +/// Split full circles in two halves (an edge needs two ends). +fn pieces(segs: &[Seg]) -> Vec<(usize, Seg)> { + let mut out = Vec::new(); + for (i, s) in segs.iter().enumerate() { + match *s { + Seg::Arc { a, c, axis, angle, .. } if angle > std::f64::consts::TAU - 1e-9 => { + let h = std::f64::consts::PI; + let a2 = turn(a, c, axis, h); + out.push((i, Seg::Arc { a, c, axis, angle: h, mid: turn(a, c, axis, h / 2.0) })); + out.push((i, Seg::Arc { a: a2, c, axis, angle: h, mid: turn(a, c, axis, 1.5 * h) })); + } + _ => out.push((i, *s)), + } + } + out +} + fn plane_normal(f: &mt::Face) -> Option { match f.oriented_surface() { mt::Surface::Plane(p) => { @@ -128,12 +159,16 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?; let edges: Vec = wire.edge_iter().cloned().collect(); - let k = edges.len(); - let segs: Vec = edges + let edge_segs: Vec = edges .iter() .map(|e| seg_of(e, (size * 1e-5).max(1e-6))) .collect::>() .ok_or_else(|| unsupported("loop edges must be lines or arcs"))?; + // Pieces: the loop's edges, full circles in halves; each knows its edge. + let split = pieces(&edge_segs); + 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. for i in 0..k { let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { continue }; @@ -146,7 +181,8 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: // Walls: the other face of each edge, perpendicular to the face; all on one side. let mut walls: Vec = Vec::with_capacity(k); let mut side = 0.0; - for (e, s) in edges.iter().zip(&segs) { + for (oi, s) in owner.iter().zip(&segs) { + let e = edges.get(*oi).ok_or_else(|| KernelError::Failed("edge".into()))?; let wf = faces .iter() .position(|g| !std::ptr::eq(*g, *f) && g.edge_iter().any(|x| x.id() == e.id())) @@ -157,8 +193,8 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: }; let g = faces.get(wf).ok_or_else(|| KernelError::Failed("wall".into()))?; let gn = face_normal(g, mid).ok_or_else(|| unsupported("wall normal"))?; - if gn.dot(n).abs() > 1e-6 { - return Err(unsupported("the walls must stand perpendicular to the face")); + if gn.dot(n).abs() > 1.0 - 1e-6 { + return Err(unsupported("a wall tangent to the face")); } // Into the face from the loop: left of travel. let m = n.cross(s.tangent(mid).ok_or_else(|| unsupported("tangent"))?); @@ -169,11 +205,37 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: side = sd; walls.push(wf); } - // Offsets: into the face by r; along the wall by r (up a concave wall, down a convex one). + // Cross-section at a loop point: into the face (m) and up the wall (t, away from the edge, + // up a concave wall and down a convex one). The ball of radius r touches both at the + // setback s = r / tan(φ/2), φ the angle between m and t; its centre is on the bisector. let into = |s: &Seg, p: Vec3| -> Vec3 { s.tangent(p).map(|t| n.cross(t)).unwrap_or(Vec3::ZERO) }; - let lift = n * (r * side); - let pa: Vec = (0..k).map(|i| segs.get(i).map(|s| s.start() + into(s, s.start()) * r).unwrap_or_default()).collect(); - let pb: Vec = (0..k).map(|i| segs.get(i).map(|s| s.start() + lift).unwrap_or_default()).collect(); + let up = |i: usize, p: Vec3| -> Option { + let s = segs.get(i)?; + let g = faces.get(*walls.get(i)?)?; + let gn = face_normal(g, p)?; + let t = s.tangent(p)?.cross(gn).normalized()?; + Some(if t.dot(n * side) < 0.0 { -t } else { t }) + }; + let frame = |i: usize, p: Vec3| -> Option<(Vec3, Vec3, f64, f64)> { + let s = segs.get(i)?; + let (m, t) = (into(s, p), up(i, p)?); + let phi = m.dot(t).clamp(-1.0, 1.0).acos(); + if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) { + return None; + } + let sb = r / (phi / 2.0).tan(); + Some((m, t, sb, r / (phi / 2.0).sin())) + }; + 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); + } // The offset arcs must not collapse. for s in &segs { if let Seg::Arc { a, c, .. } = *s { @@ -185,7 +247,7 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: } let va: Vec = pa.iter().map(|p| builder::vertex(p3(*p))).collect(); let vb: Vec = pb.iter().map(|p| builder::vertex(p3(*p))).collect(); - let centre = |i: usize| pa.get(i).copied().unwrap_or_default() + lift; + let centre = |i: usize| centres.get(i).copied().unwrap_or_default(); let profile_mid = |i: usize| { let (Some(s), c) = (segs.get(i), centre(i)) else { return Vec3::ZERO }; c + (s.start() - c).normalized().unwrap_or(n) * r @@ -207,17 +269,28 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: Seg::Arc { mid, .. } => builder::circle_arc(a, b, p3(offset(mid, s))), }) }; - let face_off = |p: Vec3, s: &Seg| p + into(s, p) * r; - let wall_off = |p: Vec3, _: &Seg| p + lift; + let piece_of = |s: &Seg| segs.iter().position(|x| std::ptr::eq(x, s)).unwrap_or(0); + let face_off = |p: Vec3, s: &Seg| { + let i = piece_of(s); + frame(i, p).map(|(m, _, sb, _)| p + m * sb).unwrap_or(p) + }; + let wall_off = |p: Vec3, s: &Seg| { + 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 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. - let loop_vertex: Vec = edges.iter().map(|e| e.front().clone()).collect(); + // Model vertices at the joints (the second half of a circle starts at none). + let joint_vertex: Vec> = (0..k) + .map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None }) + .collect(); let mut seam_subst: HashMap = HashMap::new(); let loop_ids: Vec = edges.iter().map(|e| e.id()).collect(); - for (i, v) in loop_vertex.iter().enumerate() { + for (i, v) in joint_vertex.iter().enumerate() { + let Some(v) = v else { continue }; for g in faces { for e in g.edge_iter() { if loop_ids.contains(&e.id()) || seam_subst.contains_key(&e.id()) || !(e.front() == v || e.back() == v) { @@ -228,8 +301,9 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: if !matches!(e.curve(), mt::Curve::Line(_)) { return Err(unsupported("the wall seams must be straight")); } - let along = (from_p3(other.point()) - from_p3(v.point())).dot(n) * side; - if along <= r + tol { + let along = (from_p3(other.point()) - from_p3(v.point())).dot(pb.get(i).copied().unwrap_or_default() - from_p3(v.point())); + let reach = (pb.get(i).copied().unwrap_or_default() - from_p3(v.point())).len2(); + if along <= reach + tol { return Err(unsupported("the blend is taller than the walls")); } let nb = vb.get(i).ok_or_else(|| KernelError::Failed("joint".into()))?; @@ -245,11 +319,18 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: for w in g.absolute_boundaries() { let mut es = Vec::new(); for e in w.edge_iter() { - let ne = if let Some(i) = idx_of(e.id()) { - let rl = rails.get(i).ok_or_else(|| KernelError::Failed("rail".into()))?; - // Rails run in loop order (joint i → i+1). - if e.front() == loop_vertex.get(i).ok_or_else(|| KernelError::Failed("joint".into()))? { rl.clone() } else { rl.inverse() } - } else if let Some(s) = seam_subst.get(&e.id()) { + 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 same = edges.get(i).is_some_and(|le| le.orientation() == e.orientation()); + if same { + es.extend(mine.into_iter().cloned()); + } else { + es.extend(mine.into_iter().rev().map(|r| r.inverse())); + } + continue; + } + let ne = if let Some(s) = seam_subst.get(&e.id()) { if e.front() == e.absolute_front() { s.clone() } else { s.inverse() } } else { e.clone() diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index b1abe9e9..642dda2a 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -856,3 +856,22 @@ fn debug_revolved_tube_cut() { println!("{name}: faces {} cut {r:?}", tool.face_count()); } } + +#[test] +fn circle_loop_fillets_and_chamfers() { + // A boss on a plate: concave fillet where it meets the plate, convex at its top. + let plate = box_solid(Vec3::new(-30.0, -30.0, 0.0), Vec3::new(30.0, 30.0, 8.0)).unwrap(); + let boss = cylinder(Vec3::new(0.0, 0.0, 8.0), Vec3::Z, 10.0, 15.0).unwrap(); + let b = boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap(); + let v0 = measure(&b).unwrap().volume; + let f = fillet(&b, &[Vec3::new(-10.0, 0.0, 8.0)], 3.0).unwrap(); + let v1 = measure(&f).unwrap().volume; + // Concave: the profile area r²(1 − π/4) swept round a circle of radius ≈ 10 + r/3. + let added = 9.0 * (1.0 - PI / 4.0) * 2.0 * PI * (10.0 + 3.0 * (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI)); + assert!((v1 - v0 - added).abs() < 0.02 * added, "{} {}", v1 - v0, added); + let c = chamfer(&f, &[Vec3::new(-10.0, 0.0, 23.0)], 1.0).unwrap(); + let v2 = measure(&c).unwrap().volume; + // Convex chamfer: a 1×1 triangle round a circle of radius ≈ 10 − 1/3. + let removed = 0.5 * 2.0 * PI * (10.0 - 1.0 / 3.0); + assert!((v1 - v2 - removed).abs() < 0.02 * removed, "{} {}", v1 - v2, removed); +}