From 4f64a035ab72e2f7bb8401fd036bfea30bb57803 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:25:06 -0400 Subject: [PATCH] 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 --- crates/kernel/src/blend.rs | 101 ++++++++++++++++++++++++++++-- crates/kernel/src/blend_smlib.rs | 4 ++ crates/kernel/src/smooth_edges.rs | 3 +- crates/kernel/src/tangent_edge.rs | 52 ++++++++++++--- 4 files changed, 143 insertions(+), 17 deletions(-) diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index ac134b452f..d947e6bc25 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -684,9 +684,11 @@ pub fn tangent_chain(body: &Body, picks: &[Vec3]) -> Result> { 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 { @@ -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 { - 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> } 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. diff --git a/crates/kernel/src/blend_smlib.rs b/crates/kernel/src/blend_smlib.rs index 04e8438817..c49afdb9ab 100644 --- a/crates/kernel/src/blend_smlib.rs +++ b/crates/kernel/src/blend_smlib.rs @@ -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 = setbacks.iter().map(|(_, d)| *d).collect(); let tol = pick_tolerance(body); diff --git a/crates/kernel/src/smooth_edges.rs b/crates/kernel/src/smooth_edges.rs index 4dc4d84669..b5ab06441c 100644 --- a/crates/kernel/src/smooth_edges.rs +++ b/crates/kernel/src/smooth_edges.rs @@ -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 diff --git a/crates/kernel/src/tangent_edge.rs b/crates/kernel/src/tangent_edge.rs index 1d943ab396..4ca6afe67f 100644 --- a/crates/kernel/src/tangent_edge.rs +++ b/crates/kernel/src/tangent_edge.rs @@ -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, e: usize, max_angle: f64) -> Result { +/// 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, e: usize, mut visit: impl FnMut(Vec3, Vec3) -> bool) -> Result> { 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> { 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, 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, 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, e: usize, max_angle: f64) -> Result { + 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, e: usize) -> Result> { + 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)]