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
@@ -328,6 +328,9 @@ pub enum FeatureKind {
|
||||
/// radius varying along each edge.
|
||||
#[serde(default, skip_serializing_if = "FilletStyle::is_constant")]
|
||||
style: FilletStyle,
|
||||
/// What to do when the radius is too large for the geometry around the edges.
|
||||
#[serde(default, skip_serializing_if = "FilletFit::is_exact")]
|
||||
fit: FilletFit,
|
||||
},
|
||||
Chamfer {
|
||||
edges: Vec<Vec3>,
|
||||
@@ -916,6 +919,22 @@ pub enum FilletStyle {
|
||||
},
|
||||
}
|
||||
|
||||
/// A fillet radius too large for the geometry around its edges: refused (`Exact`, the default),
|
||||
/// or reduced to the largest that works for all of them (`Reduce`), with a warning.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FilletFit {
|
||||
#[default]
|
||||
Exact,
|
||||
Reduce,
|
||||
}
|
||||
|
||||
impl FilletFit {
|
||||
pub fn is_exact(&self) -> bool {
|
||||
*self == FilletFit::Exact
|
||||
}
|
||||
}
|
||||
|
||||
impl FilletStyle {
|
||||
pub fn is_constant(&self) -> bool {
|
||||
*self == FilletStyle::Constant
|
||||
|
||||
+63
-2
@@ -256,6 +256,42 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<solv
|
||||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, k, p, (s1 - s0) / p, 48)?)
|
||||
}
|
||||
|
||||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||||
/// large for the geometry around them is reduced to the largest that works for all of them, which
|
||||
/// is returned.
|
||||
fn fillet_fitted(
|
||||
body: &kernel::Body,
|
||||
edges: &[Vec3],
|
||||
r: f64,
|
||||
fit: crate::FilletFit,
|
||||
) -> std::result::Result<(kernel::Body, Option<f64>), kernel::KernelError> {
|
||||
match kernel::fillet(body, edges, r) {
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max(body, edges, r) {
|
||||
Some((used, b)) => Ok((b, Some(used))),
|
||||
None => Err(kernel::KernelError::TooLarge(why)),
|
||||
},
|
||||
other => other.map(|b| (b, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A length in mm, without trailing zeros.
|
||||
fn show_mm(v: f64) -> String {
|
||||
let s = format!("{v:.6}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
/// The radius fillet `feature` used when it reduced a radius too large for its edges.
|
||||
pub fn fitted_radius(&self, feature: u64) -> Option<f64> {
|
||||
self.fitted.get(&feature).copied()
|
||||
}
|
||||
|
||||
/// Every reduced fillet radius (by feature id).
|
||||
pub fn fitted_radii(&self) -> impl Iterator<Item = (u64, f64)> + '_ {
|
||||
self.fitted.iter().map(|(k, v)| (*k, *v))
|
||||
}
|
||||
}
|
||||
|
||||
/// Model after some prefix of the timeline.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ModelState {
|
||||
@@ -275,6 +311,9 @@ pub struct ModelState {
|
||||
/// a pattern of the feature repeats these, not the points picked when it was made, which a
|
||||
/// parameter change may have moved off the edges.
|
||||
blend_edges: BTreeMap<u64, Vec<Vec3>>,
|
||||
/// The radius a fillet used when it reduced a radius too large for its edges (by feature id,
|
||||
/// see `FilletFit::Reduce`).
|
||||
fitted: BTreeMap<u64, f64>,
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
@@ -300,6 +339,10 @@ impl ModelState {
|
||||
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
|
||||
}
|
||||
}
|
||||
for (feature, r) in &self.fitted {
|
||||
feature.hash(&mut h);
|
||||
r.to_bits().hash(&mut h);
|
||||
}
|
||||
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||||
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||||
h.finish()
|
||||
@@ -2780,6 +2823,7 @@ fn replay(
|
||||
let Some(mb) = st.bodies.get(ti).cloned() else { continue };
|
||||
let nb = match &src.kind {
|
||||
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
|
||||
FeatureKind::Fillet { fit, .. } => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
|
||||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||||
};
|
||||
let nb = Traced::step(nb, &[&mb.traced()]);
|
||||
@@ -3518,7 +3562,18 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
None => return Err(DocError::Invalid("no edges to fillet".into())),
|
||||
}
|
||||
}
|
||||
FeatureKind::Fillet { .. } => (kernel::fillet(&mb.body, &edges, r)?, input),
|
||||
FeatureKind::Fillet { fit, .. } => {
|
||||
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
|
||||
if let Some(used) = used {
|
||||
st.fitted.insert(f.id, used);
|
||||
let note = format!("the radius is reduced to {} mm, the largest that fits these edges", show_mm(used));
|
||||
*warning = Some(match warning.take() {
|
||||
Some(w) => format!("{w}; {note}"),
|
||||
None => note,
|
||||
});
|
||||
}
|
||||
(b, input)
|
||||
}
|
||||
FeatureKind::Chamfer { distance2, angle, flip, .. } => (chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?, input),
|
||||
_ => return Err(DocError::Invalid("blend".into())),
|
||||
};
|
||||
@@ -3986,7 +4041,13 @@ mod chain_tests {
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Fillet { edges: vec![Vec3::new(20.0, 0.0, 20.0)], radius: "4".into(), body: None, style: crate::FilletStyle::Constant },
|
||||
FeatureKind::Fillet {
|
||||
edges: vec![Vec3::new(20.0, 0.0, 20.0)],
|
||||
radius: "4".into(),
|
||||
body: None,
|
||||
style: crate::FilletStyle::Constant,
|
||||
fit: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
+22
-4
@@ -104,8 +104,17 @@ fn set_back_reports_restored_features() {
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None, style: Default::default() }, None)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Fillet {
|
||||
edges: vec![Vec3::new(0.0, 0.0, 10.0)],
|
||||
radius: "3".into(),
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
fit: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap();
|
||||
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
@@ -163,8 +172,17 @@ fn errors_are_reported_not_fatal() {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Fillet {
|
||||
edges: vec![Vec3::new(500.0, 0.0, 0.0)],
|
||||
radius: "1".into(),
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
fit: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::ZERO),
|
||||
|
||||
@@ -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)"),
|
||||
.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)"),
|
||||
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)
|
||||
@@ -785,7 +785,19 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style })
|
||||
let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("exact") => solvecraft_doc::FilletFit::Exact,
|
||||
Some("reduce") => solvecraft_doc::FilletFit::Reduce,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact or reduce)"))),
|
||||
};
|
||||
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.
|
||||
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))
|
||||
{
|
||||
out["radius_applied"] = serde_json::json!(r);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -2790,3 +2790,31 @@ fn a_design_saved_before_format_14_keeps_generic_names_past_body_name() {
|
||||
assert_eq!(names, ["Ribs", "Body1", "Body2"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
|
||||
// #293: 3.5 is too large for the 6 mm end faces of a 14 x 6 x 10 box; reduce gives 3 (a full
|
||||
// round), says so, and refits when the box changes.
|
||||
let mut s = Session::default();
|
||||
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());
|
||||
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;
|
||||
assert!(rel(volume(&mut s), full) < 2e-4, "{} vs {full}", 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("reduced to 3 mm"), "{warning}");
|
||||
// Wider: the requested 3.5 fits, nothing is reduced.
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "8 mm"}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert_eq!(s.model.result(id).and_then(|r| r.state.fitted_radius(id)), None);
|
||||
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_none());
|
||||
// Saved only when set.
|
||||
let json = serde_json::to_string(&s.doc.features).unwrap();
|
||||
assert_eq!(json.matches("\"fit\":\"reduce\"").count(), 1, "{json}");
|
||||
// An unknown mode is refused.
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper"})).is_err());
|
||||
}
|
||||
@@ -575,7 +575,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 5.0)], radius: "4".into(), body: None, style: Default::default() },
|
||||
FeatureKind::Fillet {
|
||||
edges: vec![Vec3::new(0.0, 0.0, 5.0)],
|
||||
radius: "4".into(),
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
fit: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1577,6 +1577,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
);
|
||||
enter |= field(ui, radius);
|
||||
ui.end_row();
|
||||
// A constant radius too large for the edges: refused, or reduced to the
|
||||
// largest that fits (the preview shows what was used).
|
||||
if !*chamfer && rtype == 0 {
|
||||
row_label(ui, "Radius Fit");
|
||||
let fit = extra_choice(ui, &mut d.extra, "fi_fit", "fit", &[("Exact", "exact"), ("Reduce to Fit", "reduce")]);
|
||||
ui.end_row();
|
||||
if fit == 1
|
||||
&& let Some(r) = app.preview.applied
|
||||
{
|
||||
row_label(ui, "Applied Radius");
|
||||
ui.label(RichText::new(crate::prefs::show_mm(app, r, 1)).color(t.warning));
|
||||
ui.end_row();
|
||||
}
|
||||
} else if !*chamfer {
|
||||
d.extra.remove("fit");
|
||||
}
|
||||
if *chamfer && *ctype == 1 {
|
||||
row_label(ui, "Distance 2");
|
||||
enter |= field(ui, distance2);
|
||||
@@ -3303,6 +3319,10 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
*flip = *f;
|
||||
}
|
||||
}
|
||||
// A fillet that reduces a radius too large for its edges.
|
||||
if let FeatureKind::Fillet { fit: solvecraft_engine::doc::FilletFit::Reduce, .. } = &f.kind {
|
||||
d.extra.insert("fit".into(), json!("reduce"));
|
||||
}
|
||||
// A chord-length or variable fillet.
|
||||
if let FeatureKind::Fillet { style, .. } = &f.kind {
|
||||
match style {
|
||||
|
||||
@@ -512,6 +512,39 @@ fn chamfer_types_send_their_values() {
|
||||
assert!(matches!(&e.kind, Kind::Fillet { chamfer: true, ctype: 2, angle, flip: true, .. } if angle == "30 deg"), "{:?}", e.kind);
|
||||
}
|
||||
|
||||
/// Radius Fit: the fillet reduces a radius too large for its edges, the preview shows the radius
|
||||
/// it used, and editing the feature keeps the choice (#293).
|
||||
#[test]
|
||||
fn fillet_radius_fit_reduces_and_shows_the_applied_radius() {
|
||||
let mut app = app_with(serde_json::json!([
|
||||
{"command": "solid.box", "params": {"length": 14, "width": 6, "height": 10, "corner": [14, 0, 0]}}
|
||||
]));
|
||||
let edges: Vec<Sel> = [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)]
|
||||
.into_iter()
|
||||
.map(|p| edge(&app, p))
|
||||
.collect();
|
||||
let mut d = start(&mut app, edges, "solid.fillet");
|
||||
if let Kind::Fillet { radius, .. } = &mut d.kind {
|
||||
*radius = "3.5 mm".into();
|
||||
}
|
||||
// Exact (the default) sends no fit and the preview refuses the radius.
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert!(p.get("fit").is_none(), "{p}");
|
||||
assert!(app.session.preview(&[(id.clone(), p)]).is_err());
|
||||
// Reduce to Fit: the preview builds it, at the radius that fits.
|
||||
d.extra.insert("fit".into(), json!("reduce"));
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!(p["fit"], json!("reduce"));
|
||||
let job = crate::preview::compute(&app.session, &[(id.clone(), p.clone())], crate::preview::Colors::from_tokens(&crate::theme::Tokens::dark()))
|
||||
.unwrap();
|
||||
assert_eq!(job.applied, Some(3.0));
|
||||
// Applied and edited again, the fillet keeps its fit.
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
let fillet = app.session.doc.features.last().unwrap().id;
|
||||
let e = crate::dialogs::for_feature(&app, fillet, None).unwrap();
|
||||
assert_eq!(e.extra.get("fit"), Some(&json!("reduce")));
|
||||
}
|
||||
|
||||
/// Apply a dialog's commands to the session; the new feature's id.
|
||||
fn apply(app: &mut SolveApp, d: &Dialog) -> u64 {
|
||||
for (id, p) in apply_commands(app, d).unwrap() {
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct Built {
|
||||
pub error: Option<String>,
|
||||
/// Extent of what the preview shows (the view's depth range must cover it).
|
||||
pub bounds: Aabb3,
|
||||
/// The radius a fillet used when it reduced one too large for its edges (`fit: reduce`).
|
||||
pub applied: Option<f64>,
|
||||
}
|
||||
|
||||
type Job = Result<Built, String>;
|
||||
@@ -66,6 +68,8 @@ pub struct PreviewState {
|
||||
/// Time the last preview took (ms), for `ui.inspect`.
|
||||
pub ms: f64,
|
||||
started: f64,
|
||||
/// The radius the previewed fillet used when it reduced the one asked for.
|
||||
pub applied: Option<f64>,
|
||||
}
|
||||
|
||||
impl PreviewState {
|
||||
@@ -73,6 +77,7 @@ impl PreviewState {
|
||||
self.running = None;
|
||||
self.shown_key = None;
|
||||
self.error = None;
|
||||
self.applied = None;
|
||||
self.busy = false;
|
||||
if self.active || !self.replaced.is_empty() {
|
||||
self.active = false;
|
||||
@@ -98,9 +103,13 @@ impl PreviewState {
|
||||
self.bounds = b.bounds;
|
||||
self.active = true;
|
||||
self.error = b.error;
|
||||
self.applied = b.applied;
|
||||
self.set_scene(b.scene);
|
||||
}
|
||||
Err(e) => self.error = Some(e),
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
self.applied = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +229,7 @@ pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
let p = real.map_err(|e| e.to_string())?;
|
||||
let mut b = guard(&|| build(&p.before, &p.after, colors))?;
|
||||
b.bounds = state_bounds(&p.after);
|
||||
b.applied = p.after.fitted_radii().find(|(id, _)| p.before.fitted_radius(*id).is_none()).map(|(_, r)| r);
|
||||
Ok(b)
|
||||
}
|
||||
}
|
||||
@@ -256,7 +266,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
|
||||
}
|
||||
}
|
||||
}
|
||||
Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY }
|
||||
Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None }
|
||||
}
|
||||
|
||||
/// The same commands making a new body instead of joining, cutting or intersecting, and the
|
||||
@@ -357,7 +367,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
|
||||
replaced.push(b.name.clone());
|
||||
}
|
||||
}
|
||||
Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY }
|
||||
Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY, applied: None }
|
||||
}
|
||||
|
||||
/// Is face `f` of `m` new surface, i.e. not on the old mesh? Decided by a vote of points on it
|
||||
|
||||
Reference in new issue
Block a user