feat: fillet fit option reduces a radius too large for its edges (#388)

* feat(kernel): typed TooLarge refusal and fillet_max search

Blend refusals caused only by the size (larger than the neighbouring faces,
taller than the walls, longer than the next edge, larger than a corner
radius) now return KernelError::TooLarge, displayed as before. fillet_max
finds the largest radius below a request at which the fillet of the edges
works, to three significant digits, by building candidates; it is an explicit
call (up to 16 fillets), not run on every refusal.

A corner edge that doesn't run square to the edge is now reported as such
instead of as a blend larger than the neighbouring faces.

Refs #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat: fillet fit option reduces a radius too large for its edges

solid.fillet takes fit: exact (the default, as before: a radius too large
for the geometry around the edges is refused) or reduce: the radius is
reduced to the largest that works for all the picked edges (kernel
fillet_max, three significant digits, every candidate built). The feature
stores the requested radius, so it refits on every evaluation; when it
reduces, the feature's warning says so and the command returns
radius_applied. Pattern copies of the fillet use the same rule. The file
format writes fit only when it is reduce.

The Fillet dialog gets a Radius Fit row (Exact / Reduce to Fit, constant
radius) and, when the preview reduced the radius, an Applied Radius line;
editing the feature keeps the choice.

One radius for all edges for now; per-edge radii and taper follow.

Refs #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 16:21:17 -04:00
1 parent 3c94f59d43
commit c62879b00d
14 files changed
+316 -23

No files matched your search

+49 -4
View File
@@ -27,6 +27,11 @@ pub(crate) fn unsupported(msg: &str) -> KernelError {
KernelError::Failed(format!("not supported yet: {msg}"))
}
/// A blend too large for the faces around it.
pub(crate) fn too_large(msg: &str) -> KernelError {
KernelError::TooLarge(msg.into())
}
pub(crate) fn vtx(v: &mt::Vertex) -> Vec3 {
from_p3(v.point())
}
@@ -286,7 +291,7 @@ fn blend_geometry(
// A rail ending before the old corner extends the neighbouring rim edge away from its
// other vertex: valid only within the trimmed side face.
if along >= len - tol || (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol))) {
return Err(unsupported("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces"));
}
}
// Across two end faces: where the blend's cylinder crosses the edge between them, on the
@@ -332,7 +337,7 @@ fn blend_geometry(
return Err(unsupported("both ends touch the same face"));
}
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces"));
}
// A side face whose corner edges are consumed at both ends is used up: its remaining edge,
// parallel to the old edge at the setback, becomes the rail and the face is removed.
@@ -616,7 +621,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
.collect();
guard(what, || crate::curveblend::curve_blend(&solid, &ids, size, r, shape == Shape::Round))
.map(|o| (o, if on_chain.is_empty() { vec![j] } else { on_chain }))
.map_err(|e2| KernelError::Failed(format!("{e2} ({e})")))
.map_err(|e2| match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
})
}
None => Err(e),
},
@@ -893,7 +901,9 @@ fn loop_split(cur: &Body, pts: &[Vec3]) -> Result<Option<(Vec<Vec<Vec3>>, Vec<Ve
Ok((!groups.is_empty()).then_some((groups, rest)))
}
/// Constant-radius fillet of the edges nearest to the given points.
/// Constant-radius fillet of the edges nearest to the given points. A radius too large for the
/// faces around the edges is refused with [`KernelError::TooLarge`] ([`fillet_max`] finds the
/// largest that works).
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
body.require_brep("fillet")?;
// Corners the local blends refuse (an earlier round at an edge's end): radius per edge.
@@ -903,6 +913,41 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
})
}
/// The step of the radius search: three significant digits of the requested radius.
fn radius_step(radius: f64) -> f64 {
10f64.powf(radius.log10().floor() - 3.0)
}
/// The largest radius below `radius` (to three significant digits) at which the fillet of
/// these edges works, with its result; `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.
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Option<(f64, Body)> {
if !(radius.is_finite() && radius > 1e-6) {
return None;
}
let q = radius_step(radius);
// Radii n·q for n in [0, hi): hi fails (it is the request, or above it), lo works.
let mut hi = (radius / 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.
for _ in 0..16 {
if hi - lo <= 1 {
break;
}
let mid = lo + (hi - lo) / 2;
match fillet(body, edges, mid as f64 * q) {
Ok(b) => {
lo = mid;
best = Some(b);
}
Err(_) => hi = mid,
}
}
best.map(|b| (lo as f64 * q, b))
}
/// Equal-distance chamfer of the edges nearest to the given points. Each edge is cut with a
/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in
/// a mitre); the local operation is the fallback.