Blends: tangent propagation off blends the picked edges alone; a refusal says how to change it

The Fillet and Chamfer features already blend each picked edge's tangent chain
by default (tangent_chain, true unless saved false), as mainstream CAD's
tangent propagation does; the kernel blends exactly the edges it is given.
#8678 refused, before SMLib, every edge that runs on tangentially into one left
out, which also refused blends SMLib builds (a round ending on the round its
edge runs into: blend_corner's a_round_next_to_a_round_of_another_radius, the
same feature with propagation off). Now:
- propagation off: only the picked edges are blended; where SMLib cannot end
  the blend at such a continuation, its refusal names the edge it runs into
  and that propagation (tangent_chain: true) blends the chain as one;
- propagation on, where the chain cannot be built (an r3 round carried on
  round an r2 round's end arc), the refusal says the chain was blended and
  that with propagation off only the picked edges are.
A smooth edge is still refused before SMLib. No default changes: the feature's
default was propagation on already.

The kernel tests that stand for a Fillet feature (curvecorner's sequential
rounds, issue 2422) take each pick's tangent chain, as the feature does by
default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 00:50:13 -04:00
committed by Irakli Kourtis
parent 229739a704
commit 0ec97c5708
5 changed files with 107 additions and 29 deletions
+61 -1
View File
@@ -3552,6 +3552,19 @@ fn off_the_ends(mb: &ModelBody, edge: usize, p: Vec3) -> Vec3 {
/// The edges a tapered fillet tapered (a point on each, with its taper).
type Tapered = Vec<(Vec3, kernel::Taper)>;
/// A blend's refusal when the feature blended the picked edges' tangent chains too (tangent
/// propagation, the default), saying so and how to blend the picked edges alone.
fn with_propagation_hint(e: DocError) -> DocError {
const HINT: &str = " (tangent propagation blended the picked edges' tangent chain with them; with it off, tangent_chain: false, only the picked edges are blended)";
match e {
DocError::Kernel(kernel::KernelError::TooLarge(m)) => DocError::Kernel(kernel::KernelError::TooLarge(m + HINT)),
DocError::Kernel(kernel::KernelError::Invalid(m)) => DocError::Kernel(kernel::KernelError::Invalid(m + HINT)),
DocError::Kernel(kernel::KernelError::Failed(m)) => DocError::Kernel(kernel::KernelError::Failed(m + HINT)),
DocError::Invalid(m) => DocError::Invalid(m + HINT),
e => e,
}
}
/// Body `mb` with blend `f` applied at `edges` (radius or distance `r`), the radii a fitted fillet
/// used (when it reduced any) and the edges it tapered.
fn blend_body(
@@ -6793,12 +6806,15 @@ fn eval_feature(
let Some(mb) = st.bodies.get(*ti) else { return Err(DocError::Invalid("body".into())) };
let mut w = None;
let edges = blend_edges_on(mb, f, edges, idx, &mut w)?;
let picked = edges.len();
let edges = if chain { kernel::tangent_chain(&mb.body, &edges)? } else { edges };
if let Some(w) = w {
notes.push(w);
}
let mut note = None;
let (nb, used, tapered) = blend_body(vals, f, mb, &edges, r, &mut note)?;
let propagated = edges.len() > picked;
let (nb, used, tapered) =
blend_body(vals, f, mb, &edges, r, &mut note).map_err(|e| if propagated { with_propagation_hint(e) } else { e })?;
if let Some(n) = note {
notes.push(n);
}
@@ -7850,6 +7866,50 @@ mod chain_tests {
}
assert_eq!(tangent_face_chain(b, &[Vec3::new(20.0, 15.0, 20.0); 2]), pts);
}
fn fillet(at: Vec3, radius: &str, tangent_chain: bool) -> FeatureKind {
FeatureKind::Fillet {
edges: vec![crate::Position(at)],
radius: radius.into(),
body: None,
style: crate::FilletStyle::Constant,
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
tangent_chain,
}
}
/// A 20 box's top edge along y rounded r2, then the top front edge (along x) rounded r3: that
/// edge runs on tangentially into the round's end arc, so by default (tangent propagation)
/// the arc is rounded with it, which an r3 round on an r2 arc cannot be: refused, saying that
/// propagation blended the chain and how to blend the edge alone. With propagation off the
/// edge is rounded alone, ending on the r2 round.
#[test]
fn a_propagated_chain_that_cannot_build_says_how_to_blend_the_edge_alone() {
let build = |chain: bool| {
let mut doc = Document::new("T");
let solid = FeatureKind::Box {
corner: crate::point_expr(Vec3::ZERO),
length: "20".into(),
width: "20".into(),
height: "20".into(),
operation: Operation::NewBody,
};
doc.add_feature(solid, None).unwrap();
doc.add_feature(fillet(Vec3::new(20.0, 10.0, 20.0), "2", true), None).unwrap();
let last = doc.add_feature(fillet(Vec3::new(10.0, 0.0, 20.0), "3", chain), None).unwrap();
let mut m = Model::new();
m.evaluate(&doc);
(m.result(last).and_then(|r| r.error.clone()), m.state().bodies.first().map(|b| b.body.face_count()))
};
let (on, _) = build(true);
let on = on.expect("the propagated chain is refused");
assert!(on.contains("with it off, tangent_chain: false, only the picked edges are blended"), "{on}");
let (off, faces) = build(false);
assert_eq!(off, None);
assert_eq!(faces, Some(8));
}
}
#[cfg(test)]
+24 -14
View File
@@ -728,12 +728,12 @@ fn chain_of(rec: &Faces, seeds: &[usize], hidden: &[bool]) -> Vec<usize> {
chain
}
/// Refuses blending exactly `edges` (the record's edges `picks` took, in order) where SMLib has
/// no blend to build: an edge whose faces meet tangentially (a smooth edge, nothing to round),
/// and an edge that runs on tangentially into an edge left out (SMLib rounds a tangent chain
/// as one sequence; it has no end for a blend at a vertex where its edge continues smoothly,
/// and fails building the blend's face there). [`tangent_chain`] gives the whole chain.
pub(crate) fn refuse_unblendable(body: &Body, picks: &[Vec3], edges: &[usize], hidden: &[bool]) -> Result<()> {
/// Before blending exactly `edges` (the record's edges `picks` took, in order): refuses an edge
/// whose faces meet tangentially (a smooth edge, nothing to round), and names the first edge a
/// picked one runs on into tangentially without being blended with it, if any (tangent
/// propagation off). SMLib blends the picked edges alone where it can end a blend there (a round
/// ending on the round it runs into); where it can't, [`with_continuation`] says why.
pub(crate) fn unfinished_chain(body: &Body, picks: &[Vec3], edges: &[usize], hidden: &[bool]) -> Result<Option<Vec3>> {
let rec = Faces::of(body)?;
let at = |i: usize| picks.get(i).map(|p| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)).unwrap_or_default();
for (i, &e) in edges.iter().enumerate() {
@@ -747,15 +747,25 @@ pub(crate) fn refuse_unblendable(body: &Body, picks: &[Vec3], edges: &[usize], h
seeds.push(e);
}
}
let chain = chain_of(&rec, &seeds, hidden);
if let Some(&next) = chain.get(seeds.len()) {
let p = edge_middle(&rec, next).map(|p| format!(" ({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)).unwrap_or_default();
return Err(KernelError::Invalid(format!(
"a picked edge continues tangentially into the edge at{p}, which is not blended with it: a blend cannot end where its \
edge runs on smoothly, so blend its tangent chain too (tangent_chain: true)"
)));
Ok(chain_of(&rec, &seeds, hidden).get(seeds.len()).and_then(|&next| edge_middle(&rec, next)))
}
/// A blend's failure where a picked edge continues tangentially into `next`, an edge left out
/// ([`unfinished_chain`]): SMLib rounds a tangent chain as one sequence and could not end the
/// blend there, so the reason and the way out are named.
pub(crate) fn with_continuation(e: KernelError, next: Option<Vec3>) -> KernelError {
let Some(p) = next else { return e };
let why = format!(
" (a picked edge continues tangentially into the edge at ({:.3}, {:.3}, {:.3}), which is not blended with it, and the blend \
cannot end there: with tangent propagation on, tangent_chain: true, its tangent chain is blended as one)",
p.x, p.y, p.z
);
match e {
KernelError::Invalid(m) => KernelError::Invalid(m + &why),
KernelError::Failed(m) => KernelError::Failed(m + &why),
KernelError::TooLarge(m) => KernelError::TooLarge(m + &why),
e => e,
}
Ok(())
}
#[cfg(test)]
+5 -3
View File
@@ -177,13 +177,15 @@ mod tests {
// using the reported picks, with and without the open cavity visible in the screenshot.
let picks = [Vec3::new(-5.0, 87.2, 12.5), Vec3::new(-4.283446394180081, 86.48344639418008, -1.5001085324947512)];
for base in [b.clone(), crate::shell(&b, &[Vec3::new(15.0, 47.2, 25.0)], 1.0).unwrap()] {
// Each fillet with its tangent chain, as the Fillet feature asks for it by default
// (tangent propagation).
let mut rounded = base;
for edge in bottom {
rounded = fillet(&rounded, &[edge], 2.0).unwrap();
rounded = fillet(&rounded, &crate::tangent_chain(&rounded, &[edge]).unwrap(), 2.0).unwrap();
}
let reference = fillet(&rounded, &picks[..1], 2.0).unwrap();
let reference = fillet(&rounded, &crate::tangent_chain(&rounded, &picks[..1]).unwrap(), 2.0).unwrap();
for selection in [picks, [picks[1], picks[0]]] {
let result = fillet(&rounded, &selection, 2.0).unwrap();
let result = fillet(&rounded, &crate::tangent_chain(&rounded, &selection).unwrap(), 2.0).unwrap();
assert_eq!(result.face_count(), reference.face_count());
assert_eq!(result.unmeshed_faces(), 0);
assert!(result.validity().is_empty(), "{:?}", result.validity());
+11 -7
View File
@@ -191,7 +191,7 @@ mod imp {
body.require_brep(what)?;
sizes_ok(what, sizes)?;
let picked = resolve(body, edges)?;
crate::blend::refuse_unblendable(body, edges, &picked.edges, &picked.hidden)?;
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
let kind = match kind {
Kind::Round => Blend::Round,
Kind::Curvature => Blend::Curvature,
@@ -199,7 +199,7 @@ mod imp {
Kind::Chamfer => Blend::Chamfer,
};
let tol = pick_tolerance(body);
run(body, what, &picked.edges, |b| b.blend(kind, &picked.points, sizes, tol))
run(body, what, &picked.edges, |b| b.blend(kind, &picked.points, sizes, tol)).map_err(|e| crate::blend::with_continuation(e, next))
}
/// Rounds with corner setbacks: each corner point picks the body's nearest vertex (within
@@ -215,13 +215,14 @@ mod imp {
return Err(KernelError::Invalid(format!("{what}: each corner is a finite point with a positive, finite setback")));
}
let picked = resolve(body, edges)?;
crate::blend::refuse_unblendable(body, edges, &picked.edges, &picked.hidden)?;
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
let corners = corner_vertices(body, setbacks.iter().map(|(p, _)| *p))?;
let distances: Vec<f64> = setbacks.iter().map(|(_, d)| *d).collect();
let tol = pick_tolerance(body);
let at: Vec<Vec3> = setbacks.iter().map(|(p, _)| *p).collect();
run_with(body, what, &picked.edges, &at, |b| b.blend_setback(Blend::Round, &picked.points, sizes, &corners, &distances, tol).map(|r| (r, ())))
.map(|(out, (), _)| out)
.map_err(|e| crate::blend::with_continuation(e, next))
}
/// [`blend_setback`] with the set-back corners left open (`super::OpenCorners`).
@@ -236,13 +237,14 @@ mod imp {
return Err(KernelError::Invalid(format!("{what}: each corner is a finite point with a positive, finite setback")));
}
let picked = resolve(body, edges)?;
crate::blend::refuse_unblendable(body, edges, &picked.edges, &picked.hidden)?;
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
let corners = corner_vertices(body, setbacks.iter().map(|(p, _)| *p))?;
let distances: Vec<f64> = setbacks.iter().map(|(_, d)| *d).collect();
let at: Vec<Vec3> = setbacks.iter().map(|(p, _)| *p).collect();
let tol = pick_tolerance(body);
let (sheet, holes, order) =
run_with(body, what, &picked.edges, &at, |b| b.blend_setback_open(Blend::Round, &picked.points, sizes, &corners, &distances, tol))?;
run_with(body, what, &picked.edges, &at, |b| b.blend_setback_open(Blend::Round, &picked.points, sizes, &corners, &distances, tol))
.map_err(|e| crate::blend::with_continuation(e, next))?;
// SMLib's indices in the sheet's own orders.
let place = |list: &[usize], i: usize| if list.is_empty() { Some(i) } else { list.get(i).copied() };
let renumber = |hole: &solidcraft_smlib::Hole| -> Result<Vec<(usize, bool)>> {
@@ -284,7 +286,7 @@ mod imp {
body.require_brep("chamfer")?;
sizes_ok("chamfer", &vec![d1; edges.len()])?;
let picked = resolve(body, edges)?;
crate::blend::refuse_unblendable(body, edges, &picked.edges, &picked.hidden)?;
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
let second = match side {
crate::ChamferSide::Distance(d) => ChamferSecond::Distance(d),
crate::ChamferSide::Angle(a) => ChamferSecond::Angle(a),
@@ -292,6 +294,7 @@ mod imp {
let up = if flip { [0.0, 0.0, -1.0] } else { [0.0, 0.0, 1.0] };
let tol = pick_tolerance(body);
run(body, "chamfer", &picked.edges, |b| b.chamfer_sides(&picked.points, tol, d1, second, up))
.map_err(|e| crate::blend::with_continuation(e, next))
}
pub(super) fn variable(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result<Body> {
@@ -300,12 +303,13 @@ mod imp {
return Err(KernelError::Invalid("the start point must be finite".into()));
}
let picked = resolve(body, &[edge_at])?;
crate::blend::refuse_unblendable(body, &[edge_at], &picked.edges, &picked.hidden)?;
let next = crate::blend::unfinished_chain(body, &[edge_at], &picked.edges, &picked.hidden)?;
let [at] = picked.points[..] else { return Err(KernelError::Invalid("one edge".into())) };
let tol = pick_tolerance(body);
run(body, "variable fillet", &picked.edges, |b| {
b.fillet_variable(at, tol, [start.x, start.y, start.z], spline.degree, &spline.knots, &spline.radii)
})
.map_err(|e| crate::blend::with_continuation(e, next))
}
/// `op` on `body` in SMLib, tagged as operand 0, checked, decoded, with its history.
+6 -4
View File
@@ -252,13 +252,15 @@ fn the_arc_rounded_after_the_back_edge_matches_both_together() {
}
/// The edge at `first` rounded (2), then the one at `then` as a fillet of its own, against both
/// rounded together.
/// rounded together; each with its tangent chain, as the Fillet feature rounds by default (tangent
/// propagation).
fn one_then_the_other(first: Vec3, then: Vec3) {
let body = arc_part();
let step = crate::fillet(&body, &[first], 2.0).unwrap();
let after = crate::fillet(&step, &[then], 2.0).unwrap();
let chained = |b: &Body, pts: &[Vec3]| crate::tangent_chain(b, pts).unwrap();
let step = crate::fillet(&body, &chained(&body, &[first]), 2.0).unwrap();
let after = crate::fillet(&step, &chained(&step, &[then]), 2.0).unwrap();
let v = volume(&after);
let together = volume(&crate::fillet(&body, &[first, then], 2.0).unwrap());
let together = volume(&crate::fillet(&body, &chained(&body, &[first, then]), 2.0).unwrap());
// Within the display meshes' accuracy (two different B-reps, meshed apart).
assert!((v - together).abs() < 5e-3, "{first:?} then {then:?}: {v} vs together {together}");
}