kernel: one smooth angle, and a round too small to change the part is refused

The blend's tangent angle is the shaded view's half degree (smooth_edges::ANGLE), one value
for smooth. A constant round lies r (sec(d/2) - 1) inside the crease it replaces (about
r d^2/8); below the edge's tolerance it changes nothing and is refused before SMLib (which
answered SM_ERR_INVALID_INPUT), naming the smallest radius that rounds,
tolerance / (sec(d/2) - 1). d is the least turn along the edge
(tangent_edge::least_angle_along, at tangent_all_along's stations).

Ruling d3 (2026-10-30). The block's tolerance is 2e-5 mm, not 0.1 um: at 1 deg the smallest
radius is 0.526 mm, so R2 and R20 reach SMLib, which still calls an edge within 2 deg smooth
(SmEdge::GetBlendEdgeuse); that test is ignored until SMLib uses 0.5 deg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 12:25:06 -04:00
committed by jyviko
parent 04955683f8
commit 4f64a035ab
4 changed files with 143 additions and 17 deletions
+95 -6
View File
@@ -684,9 +684,11 @@ pub fn tangent_chain(body: &Body, picks: &[Vec3]) -> Result<Vec<Vec3>> {
Ok(out)
}
/// Edges meeting at an angle below this continue one another; faces meeting at an angle below
/// it meet tangentially.
const TANGENT_DEGREES: f64 = 0.5;
/// Edges meeting at an angle (radians) below this continue one another; faces meeting at an
/// angle below it meet tangentially, a smooth edge with nothing to round. One value for smooth
/// everywhere: the shaded view's ([`crate::smooth_edges::ANGLE`], half a degree), so an edge
/// the view draws can be rounded and one it hides is smooth.
const TANGENT: f64 = crate::smooth_edges::ANGLE;
/// The point halfway along edge `e`.
fn edge_middle(rec: &Faces, e: usize) -> Option<Vec3> {
@@ -699,7 +701,7 @@ fn normal_reach(rec: &Faces) -> f64 {
rec.rec.tolerance.max(1e-7) * 10.0
}
/// Whether edge `e`'s two uses meet tangentially (within [`TANGENT_DEGREES`]) all along it: a
/// Whether edge `e`'s two uses meet tangentially (within [`TANGENT`]) all along it: a
/// smooth edge, with no corner to blend (a seam a face meets itself across is one). An edge
/// whose faces are tangent at one end only (a round running into a wall it is tangent to where
/// the wall meets its face) is a crease everywhere else, which is not smooth; nor is a cusp, its
@@ -707,7 +709,7 @@ fn normal_reach(rec: &Faces) -> f64 {
/// edge. Decided exactly from the faces' surfaces and the uses' pcurves
/// ([`crate::tangent_edge`]).
fn smooth_join(rec: &Faces, e: usize) -> Result<bool> {
crate::tangent_edge::tangent_all_along(&rec.rec, |f| rec.side(f), e, TANGENT_DEGREES.to_radians())
crate::tangent_edge::tangent_all_along(&rec.rec, |f| rec.side(f), e, TANGENT)
}
/// `seeds` (edges in the record's order, each once), then every other edge that continues one
@@ -735,7 +737,7 @@ fn chain_of(rec: &Faces, seeds: &[usize], hidden: &[bool]) -> Result<Vec<usize>>
}
if a == v { rec.tangent(e, false) } else { rec.tangent(e, true).map(|t| t * -1.0) }
};
let cos = TANGENT_DEGREES.to_radians().cos();
let cos = TANGENT.cos();
let accept = normal_reach(rec);
// Whether edge `e` is convex at its end `v` (its faces meet at less than 180° inside the
// body): the direction into its second face, square to it, points against its first face's
@@ -798,6 +800,33 @@ pub(crate) fn unfinished_chain(body: &Body, picks: &[Vec3], edges: &[usize], hid
Ok(chain_of(&rec, &seeds, hidden)?.get(seeds.len()).and_then(|&next| edge_middle(&rec, next)))
}
/// Before rounding exactly `edges` (the record's edges `picks` took, in order), edge i at radius
/// `radii[i]`: refuses a round too small to change the part. Across a crease whose faces' normals
/// turn by δ, a round of radius r lies r·(sec(δ/2) − 1) (≈ r·δ²/8) inside the sharp edge at its
/// middle, the farthest it moves the part; below the edge's tolerance the round is the edge
/// itself to the model's accuracy (SMLib refuses to build it). δ is the least turn along the edge
/// ([`crate::tangent_edge::least_angle_along`]); the message names the smallest radius that
/// rounds it, tolerance / (sec(δ/2) − 1), rounded up to the micrometre.
pub(crate) fn rounds_too_small(body: &Body, picks: &[Vec3], edges: &[usize], radii: &[f64]) -> Result<()> {
let rec = Faces::of(body)?;
for (i, (&e, &r)) in edges.iter().zip(radii).enumerate() {
let Some(edge) = rec.rec.edges.get(e) else { continue };
let tolerance = edge.tolerance.max(rec.rec.tolerance);
let Some(turn) = crate::tangent_edge::least_angle_along(&rec.rec, |f| rec.side(f), e)? else { continue };
let reach = 1.0 / (0.5 * turn).cos() - 1.0;
if !(turn > TANGENT && reach.is_finite() && reach > 0.0) || r * reach >= tolerance {
continue;
}
let at = picks.get(i).map(|p| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)).unwrap_or_default();
let smallest = (tolerance / reach * 1e3).ceil() / 1e3;
return Err(KernelError::Invalid(format!(
"a round of radius {r} mm on the edge at {at} is too small: its faces turn only {:.2}° across the edge, so the round stays within the model's tolerance ({tolerance} mm) of it and changes nothing; the smallest radius that rounds this edge is {smallest:.3} mm",
turn.to_degrees()
)));
}
Ok(())
}
/// A blend's failure where a picked edge continues tangentially into `next`, an edge left out
/// ([`unfinished_chain`]): SMLib rounds a tangent chain as one sequence and could not end the
/// blend there, so the reason and the way out are named.
@@ -1216,6 +1245,66 @@ mod tests {
}
}
/// A block 40 wide and 20 deep (z 0…20) whose top rises from y = 20 at its sides to a
/// crease along z at x = 20, each half sloping `slope` degrees, so the faces turn by twice
/// that across the crease; and a point on the crease.
fn roof(slope: f64) -> (Body, Vec3) {
let h = 20.0 * slope.to_radians().tan();
let outline = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 20.0), Vec2::new(20.0, 20.0 + h), Vec2::new(0.0, 20.0)];
let region = Region2 { outer: Loop2::polygon(&outline), holes: vec![] };
let body = crate::build::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().remove(0);
(body, Vec3::new(20.0, 20.0 + h, 10.0))
}
/// A round lies r·(sec(δ/2) − 1) inside the crease it replaces (δ the faces' turn): below the
/// edge's tolerance it changes nothing and is refused as too small, naming the smallest
/// radius that rounds. The block's edges are held to 2e-5 mm (SMLib's intersection tolerance
/// for a part this size, twice its zone tolerance of 1e-5). At 1° an R0.4 round would lie
/// 0.4 · (sec 0.5° − 1) = 0.015 µm in, under it: the smallest is 2e-5 / (sec 0.5° − 1) =
/// 0.52523 mm, named rounded up. At 3° the R2 round is 0.685 µm in and builds: one face more,
/// the top that much lower, removing 20 · r²(tan(δ/2) − δ/2) = 4.786e-4 mm³.
#[test]
fn a_round_too_small_to_change_the_part_is_refused_naming_the_smallest_that_rounds() {
let (gentle, crease) = roof(0.5);
let rec = Faces::of(&gentle).unwrap().rec;
assert!(rec.tolerance == 2e-5 && rec.edges.iter().all(|e| e.tolerance == 2e-5), "{}", rec.tolerance);
let turn = 1f64.to_radians();
let smallest = 2e-5 / (1.0 / (0.5 * turn).cos() - 1.0);
assert!((smallest - 0.52523).abs() < 1e-5, "{smallest}");
let e = fillet(&gentle, &[crease], 0.4).unwrap_err().to_string();
assert!(
e.contains("is too small") && e.contains("turn only 1.00°") && e.contains("the smallest radius that rounds this edge is 0.526 mm"),
"{e}"
);
let (steeper, crease) = roof(1.5);
let (r, turn) = (2.0, 3f64.to_radians());
let rounded = fillet(&steeper, &[crease], r).unwrap();
assert!(rounded.validity().is_empty(), "{:?}", rounded.validity());
assert_eq!(rounded.face_count(), steeper.face_count() + 1);
let (before, after) = (crate::measure(&steeper).unwrap(), crate::measure(&rounded).unwrap());
let sag = r * (1.0 / (0.5 * turn).cos() - 1.0);
assert!(((before.bbox.max.y - after.bbox.max.y) - sag).abs() < 1e-9, "{} vs {sag}", before.bbox.max.y - after.bbox.max.y);
let removed = 20.0 * r * r * ((0.5 * turn).tan() - 0.5 * turn);
assert!(((before.volume - after.volume) - removed).abs() < 1e-6, "{} vs {removed}", before.volume - after.volume);
}
/// At 1° the crease rounds from 0.526 mm: R2 lies 0.076 µm in and R20 0.76 µm, and each
/// builds, removing 20 · r²(tan(δ/2) − δ/2).
#[test]
#[ignore = "fails until SMLib rounds creases from 0.5° (SmEdge::GetBlendEdgeuse calls an edge within 2° of tangent smooth: SM_ERR_INVALID_INPUT)"]
fn a_one_degree_crease_rounds_from_the_smallest_radius_named() {
let (gentle, crease) = roof(0.5);
let turn = 1f64.to_radians();
for r in [2.0, 20.0] {
let rounded = fillet(&gentle, &[crease], r).unwrap();
assert!(rounded.validity().is_empty(), "r={r}: {:?}", rounded.validity());
let removed = 20.0 * r * r * ((0.5 * turn).tan() - 0.5 * turn);
let (before, after) = (crate::measure(&gentle).unwrap().volume, crate::measure(&rounded).unwrap().volume);
assert!(((before - after) - removed).abs() < 1e-6, "r={r}: {} vs {removed}", before - after);
}
}
/// A pin standing in a socket, 3.5 mm from two of its walls, in a large block. The cutters
/// for its tip reach several times the chamfer's size past each edge (more on a larger part),
/// into the walls, and their union took bites out of them: 7.2 mm³ instead of the tip's.
+4
View File
@@ -204,6 +204,9 @@ mod imp {
sizes_ok(what, sizes)?;
let picked = resolve(body, edges)?;
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
if matches!(kind, Kind::Round) {
crate::blend::rounds_too_small(body, edges, &picked.edges, sizes)?;
}
let tol = pick_tolerance(body);
let Kind::Round = kind else {
let kind = blend_kind(kind);
@@ -257,6 +260,7 @@ mod imp {
}
let picked = resolve(body, edges)?;
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
crate::blend::rounds_too_small(body, edges, &picked.edges, sizes)?;
let corners = corner_vertices(body, setbacks.iter().map(|(p, _)| *p))?;
let distances: Vec<f64> = setbacks.iter().map(|(_, d)| *d).collect();
let tol = pick_tolerance(body);
+2 -1
View File
@@ -33,7 +33,8 @@ const STATIONS: usize = 8;
/// 1–2% step the eye tells apart on a smooth gradient. So a join this close to tangent shows no
/// line in the shading. Exact tangency (fillets, analytic seams) reads 1e-12 or so, far inside
/// it; real creases a model means are degrees. Tangency alone does not hide an edge ([`BEND`]).
const ANGLE: f64 = 0.5 * std::f64::consts::PI / 180.0;
/// It is SolidCraft's one angle for smooth: a blend finds nothing to round below it too.
pub(crate) const ANGLE: f64 = 0.5 * std::f64::consts::PI / 180.0;
/// Largest difference of the two faces' normal curvatures across an edge, relative to the
/// larger one (or to 1 / the edge's length where both are flatter), that still counts as one
/// smooth surface (G2): [`ANGLE`], the same visibility criterion as for the normals. Continued
+42 -10
View File
@@ -265,12 +265,15 @@ fn bernstein_roots(c: &[f64], lo: f64, hi: f64, depth: u32, work: &mut usize, ou
bernstein_roots(&r, mid, hi, depth - 1, work, out);
}
/// Whether edge `e`'s two uses' faces meet with outward normals within `max_angle` (radians) of
/// each other all along it, `sign(f)` turning face `f`'s natural normal outward. An edge with
/// other than two uses has no such pair: false. See the module documentation.
pub(crate) fn tangent_all_along(rec: &Brep, sign: impl Fn(usize) -> Option<f64>, e: usize, max_angle: f64) -> Result<bool> {
/// Calls `visit` with the outward unit normals of edge `e`'s two uses' faces at one point of the
/// edge after another, `sign(f)` turning face `f`'s natural normal outward: on each piece of the
/// edge where both normals are polynomials, at the `D + 1` Chebyshev points the module
/// documentation describes, skipping a point where either is undefined (a pole). `visit` returns
/// whether to go on. `Ok(None)` for an edge with other than two uses; else whether some point had
/// both normals.
fn normal_pairs(rec: &Brep, sign: impl Fn(usize) -> Option<f64>, e: usize, mut visit: impl FnMut(Vec3, Vec3) -> bool) -> Result<Option<bool>> {
let edge = rec.edges.get(e).ok_or_else(|| failed(format!("edge {e}")))?;
let [ua, ub] = edge.uses.as_slice() else { return Ok(false) };
let [ua, ub] = edge.uses.as_slice() else { return Ok(None) };
let use_of = |u: &solidcraft_geom::brep::EdgeUseRef| -> Result<Use<'_>> {
let face = rec.faces.get(u.face as usize).ok_or_else(|| failed(format!("face {}", u.face)))?;
let oriented = match face.loops.get(u.r#loop as usize) {
@@ -297,7 +300,6 @@ pub(crate) fn tangent_all_along(rec: &Brep, sign: impl Fn(usize) -> Option<f64>,
if breaks.len().saturating_mul(nodes) > MAX_POINTS {
return Err(failed(format!("deciding tangency along edge {e}: {} pieces of degree {degree}", breaks.len())));
}
let cos = max_angle.cos();
let mut defined = false;
for w in breaks.windows(2) {
let &[t0, t1] = w else { continue };
@@ -308,17 +310,47 @@ pub(crate) fn tangent_all_along(rec: &Brep, sign: impl Fn(usize) -> Option<f64>,
for j in 0..nodes {
let t = m + h * ((2 * j + 1) as f64 * std::f64::consts::PI / (2 * nodes) as f64).cos();
if let (Some(na), Some(nb)) = (a.normal(t)?, b.normal(t)?) {
if !(na.dot(nb) >= cos) {
return Ok(false);
}
defined = true;
if !visit(na, nb) {
return Ok(Some(true));
}
}
}
}
Ok(Some(defined))
}
/// Whether edge `e`'s two uses' faces meet with outward normals within `max_angle` (radians) of
/// each other all along it, `sign(f)` turning face `f`'s natural normal outward. An edge with
/// other than two uses has no such pair: false. See the module documentation.
pub(crate) fn tangent_all_along(rec: &Brep, sign: impl Fn(usize) -> Option<f64>, e: usize, max_angle: f64) -> Result<bool> {
let cos = max_angle.cos();
let mut tangent = true;
let Some(defined) = normal_pairs(rec, sign, e, |na, nb| {
tangent = na.dot(nb) >= cos;
tangent
})?
else {
return Ok(false);
};
if !defined {
return Err(failed(format!("edge {e}: its faces' normals are undefined all along it")));
}
Ok(true)
Ok(tangent)
}
/// The least angle (radians, 0 to π) between the outward normals of edge `e`'s two uses' faces
/// at the points [`normal_pairs`] visits: the edge's least angle where it is the same all along
/// the edge (two planes, a plane and a cylinder along a generator), at most a little above it
/// elsewhere. `None` for an edge with other than two uses, or whose normals are undefined
/// everywhere.
pub(crate) fn least_angle_along(rec: &Brep, sign: impl Fn(usize) -> Option<f64>, e: usize) -> Result<Option<f64>> {
let mut least = f64::INFINITY;
let defined = normal_pairs(rec, sign, e, |na, nb| {
least = least.min(na.cross(nb).len().atan2(na.dot(nb)));
true
})?;
Ok(defined.filter(|d| *d).map(|_| least))
}
#[cfg(test)]