mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 12:01:10 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user