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:
1 parent
cde3af7a3c
commit
935e6465fa
6 files changed
+337
-60
No files matched your search
+5
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user