feat: fit reduce lowers the radius only where it doesn't fit (#496)

Fillet fit: reduce lowers the radius only where it doesn't fit: picked edges are grouped where they can limit each other (sharing a face or vertex), and each group gets the requested radius where it fits, otherwise the largest that does. Refs #293.

Co-authored-by: Iraklis Kourtis <jyviko@gmail.com>
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 05:27:03 -04:00
1 parent 6d5df4b9b3
commit 0f0bb09858
9 files changed
+252 -35

No files matched your search

+7 -3
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: it is reduced to the largest that fits all of them, returned as `radius_applied` and noted in the feature's warning)"),
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (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)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; 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; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -807,11 +807,15 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
}
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
// The radius the fillet used, when `fit: reduce` reduced it.
// The radii the fillet used, when `fit: reduce` reduced it.
if let Some(id) = out.get("feature").and_then(Value::as_u64)
&& let Some(r) = s.model.result(id).and_then(|res| res.state.fitted_radius(id))
&& let Some(state) = s.model.result(id).map(|res| &res.state)
&& let Some(r) = state.fitted_radius(id)
{
out["radius_applied"] = serde_json::json!(r);
let each: Vec<Value> =
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
out["radii_applied"] = Value::Array(each);
}
Ok(out)
}
+27
View File
@@ -2909,6 +2909,33 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
}
#[test]
fn fillet_fit_reduce_keeps_the_radius_where_it_fits() {
// A 2 mm fin on a block: its top edge can't take 4, a corner of the block far from it can.
// Only the fin's edge is reduced; the reply gives each edge's radius.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 10, "corner": [19, 0, 20], "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
let edges = json!([[21, 15, 30], [40, 0, 10]]);
let out = run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 4, "fit": "reduce"}));
let fin = out["radius_applied"].as_f64().unwrap();
assert!(fin < 4.0, "{out}");
let each: Vec<(Vec<f64>, f64)> = out["radii_applied"]
.as_array()
.unwrap()
.iter()
.map(|x| (x["edge"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(), x["radius"].as_f64().unwrap()))
.collect();
assert_eq!(each, [(vec![21.0, 15.0, 30.0], fin), (vec![40.0, 0.0, 10.0], 4.0)], "{out}");
let removed = (1.0 - PI / 4.0) * (fin * fin * 30.0 + 16.0 * 20.0);
let want = 40.0 * 30.0 * 20.0 + 2.0 * 30.0 * 10.0 - removed;
assert!(rel(volume(&mut s), want) < 2e-4, "{} vs {want}", volume(&mut s));
let id = out["feature"].as_u64().unwrap();
let warning = s.model.result(id).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("on 1 of 2 edges"), "{warning}");
}
#[test]
fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
// #293: a 6 mm wide post on a plate, its four vertical edges at 3.5 reduced to 3, patterned