Fillet and chamfer: a refusal as too large names the largest size that fits

When the kernel's room pre-check went (bcbb99005), a fillet or chamfer refused as
too large stopped saying what would fit: only SMLib's NO_ROOM words were left. The
rig expects the refusal to name it (fillets/4782-fillet-two-end-verticals-past-a-full-round:
"the largest radius that fits is 7.5 mm", fillet_max for the exact fit).

A constant fillet with the exact fit, and an equal-distance chamfer, refused as too
large now add "; the largest radius (distance) that fits is X mm", found by the same
bisection fillet_max runs (up to 16 blends, three significant digits), now shared with
a new kernel::chamfer_max. Where the search itself fails (a candidate failing for
another reason), the refusal is left as it was. The reduce and taper fits and the
kernel's own searches call the kernel blends directly and do not pay for it.

Tests: the 14 x 6 x 10 post's R3.5 names 3 mm; a 4 mm rib's crossing chamfers and
rounds at 3 name 2 mm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 22:27:36 -04:00
committed by Irakli Kourtis
parent 44b7793642
commit d945cf53a0
6 changed files with 68 additions and 12 deletions
+28 -2
View File
@@ -390,7 +390,8 @@ pub struct SolvedSketch {
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
/// for each group of edges sharing a face; the radius per edge is returned. With
/// [`FilletFit::Taper`](crate::FilletFit::Taper) it tapers along each edge it doesn't fit.
/// [`FilletFit::Taper`](crate::FilletFit::Taper) it tapers along each edge it doesn't fit. With
/// the exact fit a radius too large is refused, naming the largest that fits ([`with_largest_fit`]).
fn fillet_fitted(
body: &kernel::Body,
edges: &[Vec3],
@@ -398,6 +399,9 @@ fn fillet_fitted(
fit: crate::FilletFit,
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>), kernel::KernelError> {
match kernel::fillet(body, edges, r) {
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Exact => {
Err(with_largest_fit(why, "radius", || kernel::fillet_max(body, edges, r)))
}
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r)? {
// Built at the radius asked for after all (the groups apart): nothing reduced.
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
@@ -408,6 +412,25 @@ fn fillet_fitted(
}
}
/// A blend's refusal as too large (`why`) with the largest `size` (radius, distance) that fits,
/// as `search` finds it by building candidates ([`kernel::fillet_max`]): the refusal as it is
/// when none fits or the search itself fails (a candidate failing for another reason says
/// nothing about the size).
fn with_largest_fit(
why: String,
size: &str,
search: impl FnOnce() -> std::result::Result<Option<(f64, kernel::Body)>, kernel::KernelError>,
) -> kernel::KernelError {
match search() {
Ok(Some((largest, _))) => kernel::KernelError::TooLarge(format!("{why}; the largest {size} that fits is {} mm", show_mm(largest))),
Ok(None) => kernel::KernelError::TooLarge(why),
Err(e) => {
log::debug!("the largest {size} that fits: {e}");
kernel::KernelError::TooLarge(why)
}
}
}
/// The warning of a fillet whose radius `asked` was reduced to `used` (per edge).
fn reduced_note(used: &[f64], asked: f64) -> String {
let mut values: Vec<f64> = used.iter().copied().filter(|r| *r < asked).collect();
@@ -6298,7 +6321,10 @@ fn chamfer_with(
};
Ok(match side {
Some(s) => kernel::chamfer_sides(b, edges, d, s, flip)?,
None => kernel::chamfer(b, edges, d)?,
None => match kernel::chamfer(b, edges, d) {
Err(kernel::KernelError::TooLarge(why)) => return Err(with_largest_fit(why, "distance", || kernel::chamfer_max(b, edges, d)).into()),
other => other?,
},
})
}
+1 -1
View File
@@ -49,7 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
+3 -1
View File
@@ -3983,7 +3983,9 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
run(&mut s, "parameters.add", json!({"name": "w", "expression": "6 mm"}));
run(&mut s, "solid.box", json!({"length": 14, "width": "w", "height": 10, "corner": [14, 0, 0]}));
let mids = json!([[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]]);
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 3.5})).is_err());
// The exact fit refuses it, naming the largest radius that fits.
let e = s.execute("solid.fillet", &json!({"edges": mids, "radius": 3.5})).unwrap_err().to_string();
assert!(e.contains("the largest radius that fits is 3 mm"), "{e}");
let out = run(&mut s, "solid.fillet", json!({"edges": mids, "radius": 3.5, "fit": "reduce", "name": "Round"}));
assert_eq!(out["radius_applied"].as_f64(), Some(3.0), "{out}");
let full = 840.0 - (4.0 - PI) * 9.0 * 10.0;
+14
View File
@@ -44,3 +44,17 @@ fn chamfer_that_fits_still_builds() {
assert!((volume(&mut s) - want).abs() < 1e-6, "{d} mm: {} (want {want})", volume(&mut s));
}
}
/// A rib 4 mm wide: blends of its two top edges meet in its top's middle at 2 mm and past that
/// would cross each other. Refused as too large, naming the largest size that fits.
#[test]
fn blends_crossing_each_other_are_refused_naming_the_largest_that_fits() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 4, "height": 20}));
let edges = json!([[20, 0, 20], [20, 4, 20]]);
let e = s.execute("solid.chamfer", &json!({"edges": edges, "distance": 3})).unwrap_err().to_string();
assert!(e.contains("the largest distance that fits is 2 mm"), "{e}");
let e = s.execute("solid.fillet", &json!({"edges": edges, "radius": 3})).unwrap_err().to_string();
assert!(e.contains("the largest radius that fits is 2 mm"), "{e}");
assert!((volume(&mut s) - 3200.0).abs() < 1e-6, "the part stays as it was");
}
+20 -6
View File
@@ -455,17 +455,31 @@ fn radius_step(radius: f64) -> f64 {
/// The largest radius below `radius` (to three significant digits) at which the fillet of
/// these edges works, with its result; `Ok(None)` when none does. Each candidate is a real fillet
/// of the edges, so the radius returned is one that was built; the search runs up to 16 of
/// them, so it is for an explicit request, not every refusal. Only a refusal as too large
/// them (a refusal of a fillet feature's exact fit runs it to name the largest that fits, and
/// so does `fit: reduce`). Only a refusal as too large
/// ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
/// search's error, since a smaller radius would not be the largest that fits.
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(f64, Body)>> {
let _span = solidcraft_profile::span("kernel", "fillet_max");
if !(radius.is_finite() && radius > 1e-6) {
largest_that_fits(radius, |r| fillet(body, edges, r))
}
/// [`fillet_max`] for an equal-distance chamfer of these edges: the largest distance below
/// `distance` (to three significant digits) at which it works, with its result.
pub fn chamfer_max(body: &Body, edges: &[Vec3], distance: f64) -> Result<Option<(f64, Body)>> {
let _span = solidcraft_profile::span("kernel", "chamfer_max");
largest_that_fits(distance, |d| chamfer(body, edges, d))
}
/// The largest size n·q below `size` (q: three significant digits of it) at which `blend` works,
/// by bisection over at most 16 blends; only [`KernelError::TooLarge`] narrows the search.
fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<Option<(f64, Body)>> {
if !(size.is_finite() && size > 1e-6) {
return Ok(None);
}
let q = radius_step(radius);
// Radii n·q for n in [0, hi): hi is too large (it is the request, or above it), lo works.
let mut hi = (radius / q).ceil() as u64;
let q = radius_step(size);
// Sizes n·q for n in [0, hi): hi is too large (it is the request, or above it), lo works.
let mut hi = (size / q).ceil() as u64;
let mut lo = 0u64;
let mut best: Option<Body> = None;
// log2(10⁴) steps reach the step size; the cap keeps a slow body from stalling.
@@ -474,7 +488,7 @@ pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(f6
break;
}
let mid = lo + (hi - lo) / 2;
match fillet(body, edges, mid as f64 * q) {
match blend(mid as f64 * q) {
Ok(b) => {
lo = mid;
best = Some(b);
+2 -2
View File
@@ -155,8 +155,8 @@ mod validity;
pub mod watch;
pub use blend::{
ChamferSide, blend_edges, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_least_setback, fillet_max,
fillet_max_each, fillet_setback, tangent_chain,
ChamferSide, blend_edges, chamfer, chamfer_max, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_least_setback,
fillet_max, fillet_max_each, fillet_setback, tangent_chain,
};
pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};