kernel, doc: an overflowing round goes first; an edge it removes is gone, and the fillet says so

SMLib (smlib-batch-4-rulings 0e83cca, SMLIB_ABI_VERSION 5) rounds the edges whose rounds overflow
their faces first and then what is left of the others; smlib_blend_dropping says which picks'
edges the overflow removed whole. The smlib crate binds it (Brep::blend_dropping), the kernel
returns those picks (fillet_removing), and a fillet notes them as its warning: "the edge at
(0, 0, 10) was removed by the larger round, so it is not rounded" (a 50 x 50 x 20 block, R21 on
its bottom front edge with the front left upright: the block is R21's alone, in closed form).

Ruling d8: [21, 5, 5] on a 20 cube builds as R21 alone (6107.529370); the test is no longer
ignored. R25 with R2 at a corner (blend_corner) builds the same way, R25's alone in closed form,
where it was refused. The rig's 1080 notched block outline at R8 builds now and comes off the
known-failing list.

Needs SMLIB_REV at that SMLib once it is pushed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 14:30:31 -04:00
committed by jyviko
parent 6f0bf0dea5
commit 3772a6e972
10 changed files with 155 additions and 22 deletions
+23 -6
View File
@@ -392,23 +392,37 @@ pub struct SolvedSketch {
/// 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. With
/// the exact fit a radius too large is refused, naming the largest that fits ([`with_largest_fit`]).
/// Also the picks whose edges a round overflowing its faces removed whole ([`kernel::fillet_removing`]).
fn fillet_fitted(
body: &kernel::Body,
edges: &[Vec3],
r: f64,
fit: crate::FilletFit,
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>), kernel::KernelError> {
match kernel::fillet(body, edges, r) {
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>, Vec<Vec3>), kernel::KernelError> {
match kernel::fillet_removing(body, edges, r) {
Ok((b, gone)) => Ok((b, None, gone)),
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))),
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used), Vec::new())),
None => Err(kernel::KernelError::TooLarge(why)),
},
Err(kernel::KernelError::TooLarge(_)) if fit == crate::FilletFit::Taper => kernel::fillet_tapered(body, edges, r).map(|(b, _)| (b, None)),
other => other.map(|b| (b, None)),
Err(kernel::KernelError::TooLarge(_)) if fit == crate::FilletFit::Taper => {
kernel::fillet_tapered(body, edges, r).map(|(b, _)| (b, None, Vec::new()))
}
Err(e) => Err(e),
}
}
/// The warning of a fillet some of whose edges a larger round, overflowing its faces, removed
/// whole: they are gone, so they are not rounded.
fn removed_note(gone: &[Vec3]) -> String {
let at = |p: &Vec3| format!("({}, {}, {})", show_mm(p.x), show_mm(p.y), show_mm(p.z));
match gone {
[one] => format!("the edge at {} was removed by the larger round, so it is not rounded", at(one)),
_ => format!("the edges at {} were removed by the larger round, so they are not rounded", gone.iter().map(at).collect::<Vec<_>>().join(", ")),
}
}
@@ -3718,10 +3732,13 @@ fn blend_body(
(b, input)
}
FeatureKind::Fillet { fit, .. } => {
let (b, u) = fillet_fitted(&mb.body, edges, r, *fit)?;
let (b, u, gone) = fillet_fitted(&mb.body, edges, r, *fit)?;
if let Some(u) = &u {
append_warning(warning, reduced_note(u, r));
}
if !gone.is_empty() {
append_warning(warning, removed_note(&gone));
}
used = u;
(b, input)
}
+20
View File
@@ -4158,6 +4158,26 @@ fn a_design_saved_before_format_14_with_fewer_body_names_opens_and_names_every_b
let _ = std::fs::remove_dir_all(&dir);
}
/// R21 on the bottom front edge of a 50 x 50 x 20 block overflows the 20 mm front face (its circle
/// about (y, z) = (21, 21) reaches the top only at y = 21 - sqrt(440)) and goes first; the front
/// left upright, picked with it, lies wholly in what it removes (every (0, 0, z) is outside that
/// circle), so it is gone: not rounded, and the feature says so. The block is R21's alone: the
/// section loses 20 (21 - sqrt(440)) + int_0^sqrt(440) (21 - sqrt(441 - u^2)) du, along 50.
#[test]
fn fillet_edge_removed_by_a_larger_overflowing_round_is_noted() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 50, "height": 20}));
let out = run(&mut s, "solid.fillet", json!({"edges": [[25, 0, 0], [0, 0, 10]], "radius": 21, "tangent_chain": false, "name": "Round"}));
let w = 440f64.sqrt();
let f = 0.5 * (w * (441.0 - w * w).sqrt() + 441.0 * (w / 21.0).asin());
let lost = 20.0 * (21.0 - w) + 21.0 * w - f;
let want = 50.0 * 50.0 * 20.0 - 50.0 * lost;
assert!(rel(volume(&mut s), want) < 1e-9, "{} 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_eq!(warning, "the edge at (0, 0, 10) was removed by the larger round, so it is not rounded");
}
#[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
+10
View File
@@ -214,6 +214,16 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
crate::blend_smlib::fillet(body, edges, radius).and_then(|b| closed(body, b))
}
/// [`fillet`], with the picks whose edges are gone: where some of the rounds overflow their
/// faces, those are built first, and a picked edge one of them removes whole is not rounded (the
/// others round what is left of their edges).
pub fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Vec3>)> {
let _span = solidcraft_profile::span("kernel", "fillet");
body.require_brep("fillet")?;
let (b, gone) = crate::blend_smlib::fillet_removing(body, edges, radius)?;
Ok((closed(body, b)?, gone))
}
/// [`fillet`] with corner setbacks: each `(at, distance)` sets back the corner at the vertex
/// nearest `at`, a vertex where every edge is rounded, so its patch starts `distance` along
/// each of its edges from the vertex (one distance for all of a corner's edges: SMLib keeps one
+17 -2
View File
@@ -46,6 +46,13 @@ pub(crate) struct Later {
seams: Vec<usize>,
}
impl Later {
/// How many of `edges`, the last ones, are the new edges.
pub(crate) fn new_count(&self) -> usize {
self.new.len()
}
}
/// The history of `body` over the body `levels` rounds back ([`Rounds::before`] taken that many
/// times), composed; `None` when a step has no record or history, or for no levels.
fn down(body: &Body, levels: usize) -> Option<History> {
@@ -537,9 +544,17 @@ mod tests {
#[test]
fn corners_refuse_what_they_cannot_build() {
let (bx, a, b, _) = setup();
// Larger than the faces, or not a size: errors, no panic. Large but fitting is fine.
// Not a size: errors, no panic. Large but fitting is fine.
assert!(fillet_radii(&bx, &[(a, 15.0), (b, 2.0)]).is_ok_and(|r| r.unmeshed_faces() == 0));
assert!(fillet_radii(&bx, &[(a, 25.0), (b, 2.0)]).is_err());
// R25 on a overflows both its faces and goes first: its circle about (x, z) = (-5, -5)
// takes all of the top, and with it b, which is gone. What is left is R25's alone: the
// square [0, 20]² inside that circle, with t = x + 5, ∫₅^√600 √(625 − t²) dt − 5 (√600 − 5),
// along 20.
let g = |t: f64| 0.5 * (t * (625.0 - t * t).sqrt() + 625.0 * (t / 25.0).asin());
let w = 600f64.sqrt();
let want = 20.0 * (g(w) - g(5.0) - 5.0 * (w - 5.0));
let r = fillet_radii(&bx, &[(a, 25.0), (b, 2.0)]).unwrap();
assert!((vol(&r) - want).abs() < 1e-9 * want, "{} vs {want}", vol(&r));
assert!(fillet_radii(&bx, &[(a, f64::NAN)]).is_err());
assert!(fillet_radii(&bx, &[(a, 0.0)]).is_err());
assert!(fillet_radii(&bx, &[]).is_err());
+28 -8
View File
@@ -29,6 +29,12 @@ pub(crate) fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
imp::blend(body, "fillet", Kind::Round, edges, &vec![radius; edges.len()])
}
/// [`fillet`], with the picks whose edges a larger, overflowing round removed whole.
pub(crate) fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Vec3>)> {
let (b, dropped) = imp::blend_dropping(body, "fillet", Kind::Round, edges, &vec![radius; edges.len()])?;
Ok((b, edges.iter().zip(dropped).filter(|(_, d)| *d).map(|(p, _)| *p).collect()))
}
/// Each edge rounded at its own radius ([`crate::fillet_each`]).
pub(crate) fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
if radii.len() != edges.len() {
@@ -200,6 +206,12 @@ mod imp {
}
pub(super) fn blend(body: &Body, what: &str, kind: Kind, edges: &[Vec3], sizes: &[f64]) -> Result<Body> {
blend_dropping(body, what, kind, edges, sizes).map(|(b, _)| b)
}
/// [`blend`], with whether each of `edges` picked an edge a larger round removed whole: a
/// round overflowing its faces is built first, and an edge it takes away is gone.
pub(super) fn blend_dropping(body: &Body, what: &str, kind: Kind, edges: &[Vec3], sizes: &[f64]) -> Result<(Body, Vec<bool>)> {
body.require_brep(what)?;
sizes_ok(what, sizes)?;
let picked = resolve(body, edges)?;
@@ -210,23 +222,29 @@ mod imp {
let tol = pick_tolerance(body);
let Kind::Round = kind else {
let kind = blend_kind(kind);
return run(body, what, &picked.edges, |b| b.blend(kind, &picked.points, sizes, tol))
return run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(kind, &picked.points, sizes, tol))
.map(|(out, dropped, _)| (out, dropped))
.map_err(|e| crate::blend::with_continuation(e, next));
};
// Constant-radius rounds: the body keeps what they were made on, and a later round at a
// corner where they meet blends them again with it (crate::blend_corner).
let out = match crate::blend_corner::later_corners(body, &picked.edges, sizes)? {
Some((later, new)) => reblend(body, what, &later).map(|b| b.with_rounds(body, new)),
None => run(body, what, &picked.edges, |b| b.blend(Blend::Round, &picked.points, sizes, tol))
.map(|b| b.with_rounds(body, picked.edges.iter().copied().zip(sizes.iter().copied()).collect())),
Some((later, new)) => reblend(body, what, &later).map(|(b, dropped_new)| {
// A picked seam has no round of its own: only the new edges can be removed.
let dropped = picked.edges.iter().map(|e| new.iter().position(|(n, _)| n == e).is_some_and(|j| dropped_new.get(j).copied().unwrap_or(false))).collect();
(b.with_rounds(body, new), dropped)
}),
None => run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(Blend::Round, &picked.points, sizes, tol))
.map(|(out, dropped, _)| (out.with_rounds(body, picked.edges.iter().copied().zip(sizes.iter().copied()).collect()), dropped)),
};
out.map_err(|e| crate::blend::with_continuation(e, next))
}
/// The blend `later` makes of its base, every round from the earliest meeting the new edges'
/// corners on and the new edges in one, with its history over `body`, the body the later
/// round was given ([`crate::blend_corner::rebase`]).
fn reblend(body: &Body, what: &str, later: &crate::blend_corner::Later) -> Result<Body> {
/// round was given ([`crate::blend_corner::rebase`]), and whether each new edge (in
/// `later`'s order) was removed whole by a larger, overflowing round.
fn reblend(body: &Body, what: &str, later: &crate::blend_corner::Later) -> Result<(Body, Vec<bool>)> {
let base = &later.base;
let mesh = base.mesh(&crate::MeshTolerance::chord((base.size() * 2e-3).max(1e-3)))?;
let mut indices = Vec::with_capacity(later.edges.len());
@@ -241,9 +259,11 @@ mod imp {
}
sizes_ok(what, &radii)?;
let tol = pick_tolerance(base);
let out = run(base, what, &indices, |b| b.blend(Blend::Round, &points, &radii, tol))?;
let (out, dropped, _) = run_with(base, what, &indices, &[], |b| b.blend_dropping(Blend::Round, &points, &radii, tol))?;
// The new edges are the last of `later.edges`.
let dropped_new = dropped.get(dropped.len().saturating_sub(later.new_count())..).map(<[bool]>::to_vec).unwrap_or_default();
let history = out.history().and_then(|h| crate::blend_corner::rebase(h, later, body));
Ok(out.with_checked_history(history, &record::counts(&[body])).inherit_regularization_protection(body))
Ok((out.with_checked_history(history, &record::counts(&[body])).inherit_regularization_protection(body), dropped_new))
}
/// Rounds with corner setbacks: each corner point picks the body's nearest vertex (within
+1 -1
View File
@@ -157,7 +157,7 @@ pub mod watch;
pub use blend::{
ChamferSide, blend_edges, chamfer, chamfer_max, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_g2_with_g2_corners,
fillet_least_setback, fillet_max, fillet_max_each, fillet_setback, tangent_chain,
fillet_least_setback, fillet_max, fillet_max_each, fillet_removing, 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};
+2 -3
View File
@@ -394,14 +394,13 @@ fn a_round_wider_than_its_faces_overflows_onto_its_tangent_sides() {
}
/// A corner's rounds with a radius per edge, one wider than its faces: R21 with R5 on the other
/// two edges at the corner. The R21 takes both faces beside its edge, and with them the R5
/// edges, which lie on them: the body is R21's alone
/// two edges at the corner. The R21 overflows, so it goes first; it takes both faces beside its
/// edge, and with them the R5 edges, which lie on them and are gone: the body is R21's alone
/// ([`a_round_wider_than_its_faces_overflows_onto_its_tangent_sides`]). (The same corner at one
/// radius as wide as the faces, and its fit, are the rig's
/// fillets/477-fillet-cube-corner-rounds-as-wide-as-faces-* cases; no command takes a radius per
/// edge.)
#[test]
#[ignore = "fails until SMLib blends the corner whole: status 1001, fillet error 4 (edge type 14), Unable to Trim Rail Curve"]
fn a_corner_round_wider_than_its_faces_overflows_and_takes_the_corners_other_edges() {
let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let corner = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)];
+37 -1
View File
@@ -1,5 +1,5 @@
//! The operations block of `capi/include/smlib.h` (`smlib_boolean`, `smlib_boolean_union_all`, `smlib_boolean2d`, `smlib_fillet`,
//! `smlib_chamfer`, `smlib_blend`, `smlib_blend_setback`, `smlib_blend_setback_open`, `smlib_fillet_variable`, `smlib_chamfer_sides`, `smlib_shell`, `smlib_offset_faces`,
//! `smlib_chamfer`, `smlib_blend`, `smlib_blend_dropping`, `smlib_blend_setback`, `smlib_blend_setback_open`, `smlib_fillet_variable`, `smlib_chamfer_sides`, `smlib_shell`, `smlib_offset_faces`,
//! `smlib_thicken`, `smlib_draft`, `smlib_merge_faces`, `smlib_brep_solid_count`, `smlib_imprint`; the fork's `capi/src/smlib_ops.cpp`), declared by
//! hand. Part of the FFI boundary, like its parent: called only through
//! [`crate::ops`].
@@ -95,6 +95,17 @@ unsafe extern "C" {
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_blend_dropping(
brep: *const RawBrep,
kind: i32,
points: *const f64,
sizes: *const f64,
n_points: i64,
tolerance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
out_dropped: *mut i64,
) -> i32;
fn smlib_blend_setback(
brep: *const RawBrep,
kind: i32,
@@ -467,6 +478,31 @@ pub fn chamfer_sides(_: &Handle, _: &[[f64; 3]], _: f64, _: f64, _: f64, _: bool
Err((NOT_BUILT, None))
}
/// `smlib_blend_dropping`: `smlib_blend`, with whether each point picked an edge a larger,
/// overflowing round removed whole (and so not rounded).
#[cfg(feature = "native")]
pub fn blend_dropping(kind: i32, b: &Handle, points: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Handle, Vec<bool>), PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
if sizes.len() != points.len() {
return Err((ERR_ARG, None));
}
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
let mut dropped = vec![0i64; points.len()];
let out =
new_brep(|out| unsafe { smlib_blend_dropping(b.raw(), kind, flat, sizes.as_ptr(), n, tolerance, out, &mut failed, dropped.as_mut_ptr()) })
.map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})?;
Ok((out, dropped.iter().map(|d| *d != 0).collect()))
}
#[cfg(not(feature = "native"))]
pub fn blend_dropping(_: i32, _: &Handle, _: &[[f64; 3]], _: &[f64], _: f64) -> Result<(Handle, Vec<bool>), PickError> {
Err((NOT_BUILT, None))
}
#[cfg(not(feature = "native"))]
pub fn blend(_: i32, _: &Handle, _: &[[f64; 3]], _: &[f64], _: f64) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
+17
View File
@@ -191,6 +191,23 @@ impl Brep {
ffi::ops::blend(kind, &self.0, edges, sizes, tolerance).map(Brep).map_err(pick_error)
}
/// [`Brep::blend`], with whether each point picked an edge that a larger round, overflowing
/// its faces, removed whole: that edge is gone and not rounded (SMLib rounds the edges whose
/// blends overflow first, then what is left of the others).
pub fn blend_dropping(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Brep, Vec<bool>)> {
init()?;
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
return Err(Error::Argument);
}
let kind = match kind {
Blend::Round => ffi::ops::BLEND_ROUND,
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
Blend::Chord => ffi::ops::BLEND_CHORD,
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
};
ffi::ops::blend_dropping(kind, &self.0, edges, sizes, tolerance).map(|(b, d)| (Brep(b), d)).map_err(pick_error)
}
/// [`Brep::blend`] with corner setbacks: each of `corners` picks the vertex nearest it
/// (within `tolerance`), a corner where every edge is blended, and that corner's patch starts
/// `setbacks[j]` along each of its edges from the vertex (one distance per corner, as SMLib
-1
View File
@@ -8,7 +8,6 @@
1080-chamfer-sloped-basin-1-mm-rim-3-past-the-rim SMLib refuses the chamfer past the rim as too large (SM_FILERR_NO_ROOM, CloseRemovalRegion): its sheet's boundary along the end wall's top edge line, past the rim, lies on the surfaces of two faces, where it should build cut by the front face
1080-fillet-equal-slope-basin-1-mm-rim-loop-0-5 SMLib's check refuses the round: SmSurfOfRevolution Test #7, polar curve intervals do not match the surface's domain
1080-fillet-equal-slope-basin-1-mm-rim-loop-4 not supported yet: a convex round past its face is not yet built by subtraction
1080-fillet-notched-block-top-outline-r8 not supported yet: a convex round past its face is not yet built by subtraction (largest that fits named 7 mm)
1080-fillet-sloped-basin-0-5-mm-rim-r4 not supported yet: a convex round past its face is not yet built by subtraction (largest that fits named 1.185 mm)
1080-fillet-sloped-basin-0-5-mm-rim-r4-4-edges not supported yet: a convex round past its face is not yet built by subtraction
1080-fillet-sloped-basin-1-mm-rim-r0-5-4-edges SMLib's check refuses the round: SmSurfOfRevolution Test #7, polar curve intervals do not match the surface's domain