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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 00:30:38 +00:00
1 parent cde3af7a3c
commit 935e6465fa
6 files changed
+337 -60

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 | **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
+18
View File
@@ -1188,6 +1188,24 @@ fn replay(doc: &Document, vals: &BTreeMap<String, Value>, 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<Vec3> = 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();
+119 -34
View File
@@ -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<Vec3> {
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<mt::Edge> = w.edge_iter().cloned().collect();
let n = es.len();
let dir = |e: &mt::Edge, at_end: bool| -> Option<Vec3> {
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<Vec<mt::EdgeID>> {
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<Option<Vec<Vec<Vec3>>>> {
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<mt::EdgeID> = solid
.face_iter()
.filter(|f| matches!(f.oriented_surface(), mt::Surface::Plane(_)))
.flat_map(|f| f.edge_iter().map(|e| e.id()).collect::<Vec<_>>())
.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<mt::EdgeID> = per.iter().map(|x| x.1).collect();
let mut covered: Vec<mt::EdgeID> = 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<mt::EdgeID> = 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<Vec3> = 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> {
body.require_brep("fillet")?;
@@ -383,25 +476,17 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
}
// 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)) {
+72
View File
@@ -308,6 +308,74 @@ fn exact_edges(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
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<mt::Shell> {
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<Vec3> = 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;
+104 -23
View File
@@ -55,11 +55,14 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
let pts: Vec<Vec3> = (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<Seg> {
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<Vec3> {
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<mt::Edge> = wire.edge_iter().cloned().collect();
let k = edges.len();
let segs: Vec<Seg> = edges
let edge_segs: Vec<Seg> = edges
.iter()
.map(|e| seg_of(e, (size * 1e-5).max(1e-6)))
.collect::<Option<_>>()
.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<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.
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<usize> = 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<Vec3> = (0..k).map(|i| segs.get(i).map(|s| s.start() + into(s, s.start()) * r).unwrap_or_default()).collect();
let pb: Vec<Vec3> = (0..k).map(|i| segs.get(i).map(|s| s.start() + lift).unwrap_or_default()).collect();
let up = |i: usize, p: Vec3| -> Option<Vec3> {
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<mt::Vertex> = pa.iter().map(|p| builder::vertex(p3(*p))).collect();
let vb: Vec<mt::Vertex> = 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<mt::Edge> =
(0..k).map(|i| rail(&va, i, &face_off)).collect::<Option<_>>().ok_or_else(|| 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.
let loop_vertex: Vec<mt::Vertex> = 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<Option<mt::Vertex>> = (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<mt::EdgeID, mt::Edge> = HashMap::new();
let loop_ids: Vec<mt::EdgeID> = 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()
+19
View File
@@ -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);
}