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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: bherbruck <[email protected]>
This commit is contained in:
14 files changed
+316
-23
No files matched your search
@@ -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.
|
||||
|
||||
@@ -691,7 +691,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
let (ta, oa) = a.as_ref().map(at).unwrap_or((0.0, 0.0));
|
||||
let (tb, ob) = b.as_ref().map(at).unwrap_or((1.0, 0.0));
|
||||
if oa > size * 1e-5 || ob > size * 1e-5 || ta < -1e-9 || tb > 1.0 + 1e-9 || tb - ta < 1e-6 {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
return Err(crate::blend::too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
let a = a.unwrap_or_else(|| seam.front().clone());
|
||||
let b = b.unwrap_or_else(|| seam.back().clone());
|
||||
|
||||
@@ -66,7 +66,7 @@ mod thread_tests;
|
||||
mod topo;
|
||||
mod tspline;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_max};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
@@ -105,6 +105,10 @@ pub enum KernelError {
|
||||
Invalid(String),
|
||||
#[error("the operation failed: {0}")]
|
||||
Failed(String),
|
||||
/// A blend or other size too large for the geometry around it (a smaller size may work);
|
||||
/// shown like any feature that isn't supported yet.
|
||||
#[error("the operation failed: not supported yet: {0}")]
|
||||
TooLarge(String),
|
||||
/// The third-party kernel panicked; the model is unchanged.
|
||||
#[error("kernel internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
@@ -356,7 +356,7 @@ fn build(
|
||||
}
|
||||
let p = joint_point(i).ok_or_else(|| unsupported("degenerate loop joint"))?;
|
||||
if !crate::blend::extension_fits(f, p, *q, size * 1e-7) {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
return Err(crate::blend::too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
// The offset arcs must not turn inside out; an arc of exactly the blend's radius shrinks
|
||||
@@ -371,12 +371,12 @@ fn build(
|
||||
*x = true;
|
||||
}
|
||||
} else if toward && a.dist(c) <= r + tol {
|
||||
return Err(unsupported("the blend is larger than a corner radius"));
|
||||
return Err(crate::blend::too_large("the blend is larger than a corner radius"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if collapsed.iter().all(|x| *x) {
|
||||
return Err(unsupported("the blend is larger than a corner radius"));
|
||||
return Err(crate::blend::too_large("the blend is larger than a corner radius"));
|
||||
}
|
||||
let mut va: Vec<mt::Vertex> = pa.iter().map(|p| builder::vertex(p3(*p))).collect();
|
||||
// A collapsed piece starts and ends at one vertex.
|
||||
@@ -546,7 +546,11 @@ fn build(
|
||||
let along = (from_p3(other.point()) - from_p3(v.point())).dot(target - from_p3(v.point()));
|
||||
let reach = (target - from_p3(v.point())).len2();
|
||||
if along <= reach + tol {
|
||||
return Err(unsupported(if face_side { "the blend is longer than the next edge" } else { "the blend is taller than the walls" }));
|
||||
return Err(crate::blend::too_large(if face_side {
|
||||
"the blend is longer than the next edge"
|
||||
} else {
|
||||
"the blend is taller than the walls"
|
||||
}));
|
||||
}
|
||||
let nb = if face_side { va.get(i) } else { vb.get(i) }.ok_or_else(|| KernelError::Failed("joint".into()))?;
|
||||
let slot = seam_ends.entry(e.id()).or_insert_with(|| (e.absolute_clone(), None, None));
|
||||
@@ -562,7 +566,7 @@ fn build(
|
||||
let a = a.unwrap_or_else(|| e.absolute_front().clone());
|
||||
let b = b.unwrap_or_else(|| e.absolute_back().clone());
|
||||
if from_p3(a.point()).dist(from_p3(b.point())) < tol * 10.0 {
|
||||
return Err(unsupported("the blend is longer than the next edge"));
|
||||
return Err(crate::blend::too_large("the blend is longer than the next edge"));
|
||||
}
|
||||
seam_subst.insert(id, builder::line(&a, &b));
|
||||
}
|
||||
|
||||
@@ -323,6 +323,39 @@ fn fillet_consuming_a_side_face_makes_a_full_round() {
|
||||
assert_eq!(measure(&b).unwrap().volume, 840.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_max_finds_the_largest_radius_that_works() {
|
||||
let too_large = |b: &Body, edges: &[Vec3], r: f64| matches!(fillet(b, edges, r), Err(KernelError::TooLarge(_)));
|
||||
// Two rounds on a 6 mm face: up to 3 (a full round), although after the first round at
|
||||
// 3.5 the next edge alone would take only 2.5.
|
||||
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
|
||||
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
|
||||
assert!(too_large(&b, &mids, 3.5));
|
||||
let (r, body) = fillet_max(&b, &mids, 3.5).unwrap();
|
||||
assert_eq!(r, 3.0);
|
||||
assert!(rel(measure(&body).unwrap().volume, 840.0 - (4.0 - PI) * 90.0) < 2e-4);
|
||||
// A 2 mm disc's top edge: up to 2 (the wall used up).
|
||||
let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap();
|
||||
let top = [Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)];
|
||||
assert!(too_large(&disc, &top, 2.5));
|
||||
assert_eq!(fillet_max(&disc, &top, 2.5).map(|x| x.0), Some(2.0));
|
||||
// Walls of different heights: at the shortest one's height only that wall would be used up,
|
||||
// which is refused, so the largest radius is just below it (to the search's 3 digits).
|
||||
let notched = boolean(
|
||||
&box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(),
|
||||
&box_solid(Vec3::new(35.0, -1.0, -1.0), Vec3::new(41.0, 5.0, 3.0)).unwrap(),
|
||||
BoolOp::Cut,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let outline = [Vec3::new(20.0, 0.0, 10.0), Vec3::new(40.0, 15.0, 10.0), Vec3::new(20.0, 30.0, 10.0), Vec3::new(0.0, 15.0, 10.0)];
|
||||
assert!(too_large(¬ched, &outline, 8.0));
|
||||
let (r, _) = fillet_max(¬ched, &outline, 8.0).unwrap();
|
||||
assert!((r - 6.999).abs() < 1e-9, "{r}");
|
||||
// No edge there: no radius.
|
||||
assert!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_extrudes() {
|
||||
// 40 x 40 square, 30 up, 10° outward: a frustum.
|
||||
|
||||
Reference in new issue
Block a user