diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c1746a6225..6bf71333b9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -467,11 +467,13 @@ fn peg_front_round(from: f64) -> f64 { simpson(|y| spandrel(y) * (wall(y) - from), 0.0, 2.0, 20_000) } -/// The peg's front top edge rounded r2: SMLib's result holds the cylinder's wall as a rational -/// spline whose parameter range runs more than a turn round. As a cylinder over that range (past -/// 360°) SMLib refused it on the way back (SM_ERR_INVALID_INPUT), so the round seemed to build -/// and the body could not be measured or changed. It stays the spline: the body measures, and a -/// round of the three edges at the bar's other top corner builds on it. +/// The peg's front top edge rounded r2: SMLib's result holds the upper half of the cylinder's +/// wall as a rational spline whose parameter range runs more than a turn round, past the face's +/// trims (half a turn). Taken as a cylinder over the spline's range (past 360°) SMLib refused it +/// on the way back (SM_ERR_INVALID_INPUT), so the round seemed to build and the body could not +/// be measured or changed. It goes back as the cylinder over the face's own range: the body +/// measures, a round of the three edges at the bar's other top corner builds on it, and that +/// wall comes back from SMLib a cylinder of radius 8. #[test] fn rounds_on_a_bar_running_tangent_into_a_cylinder() { let mut s = Session::default(); @@ -491,6 +493,25 @@ fn rounds_on_a_bar_running_tangent_into_a_cylinder() { let bad = b.body.validity(); assert!(bad.is_empty(), "{}: {bad:?}", b.name); } + // The wall's upper half (x < 40, z in [10, 20], round (40, 8)): every face on the surface + // through (32, 8, 15) is a cylinder of radius 8. + let state = s.model.state(); + let rec = state.bodies[0].body.record(); + let p0 = solidcraft_geom::Vec3::new(32.0, 8.0, 15.0); + let on: Vec<&solidcraft_geom::brep::Surface> = rec + .faces + .iter() + .map(|f| &f.surface) + .filter(|s| { + let e = solidcraft_geom::brep::eval::SurfaceEval::new(s).ok(); + e.and_then(|e| e.project(p0, None, 1e-6).and_then(|uv| e.point(uv))).is_some_and(|q| q.dist(p0) < 1e-6) + }) + .collect(); + let kinds: Vec = on.iter().map(|s| format!("{s:?}").chars().take(48).collect()).collect(); + assert!( + !on.is_empty() && on.iter().all(|s| matches!(s, solidcraft_geom::brep::Surface::Cylinder { radius, .. } if (radius - 8.0).abs() < 1e-9)), + "{kinds:?}" + ); } /// After the peg's front top round, the edge where that round runs into the cylinder's wall: its diff --git a/crates/smlib/src/import/analytic.rs b/crates/smlib/src/import/analytic.rs index f4735e4ef3..ce7a61af27 100644 --- a/crates/smlib/src/import/analytic.rs +++ b/crates/smlib/src/import/analytic.rs @@ -22,6 +22,9 @@ use solidcraft_geom::brep::eval::{CurveEval, SurfaceEval}; use super::{R, Unsupported}; +/// Halvings one span's extent may take. +const MAX_EXTENT_PIECES: usize = 4096; + const NOT_ANALYTIC: Unsupported = Unsupported::NotYet("revolved surfaces other than planes, cylinders, cones, spheres and tori"); /// `brep` with every revolution and extruded circle replaced by its analytic surface (borrowed @@ -43,7 +46,12 @@ pub(super) fn analytic(brep: &b::Brep) -> R> { } } b::Surface::RationalSpline { u_knots, v_knots, homogeneous } => { - extruded_circle(u_knots, v_knots, homogeneous, f.domain, 1e-9 * size.max(1.0)) + let found = extruded_circle(u_knots, v_knots, homogeneous, trimmed(brep, f)?, 1e-9 * size.max(1.0)); + // A face's trims run at most one turn round a cylinder (SMLib refuses more). + if found.as_ref().is_some_and(|(_, range)| range[0][1] - range[0][0] > TAU * (1.0 + 1e-9)) { + return Err(Unsupported::Invalid("a cylinder's face whose trims run more than one turn round")); + } + found } _ => None, }; @@ -76,14 +84,16 @@ pub(super) fn analytic(brep: &b::Brep) -> R> { /// A rational spline that is a circular arc swept straight along the arc's axis, as a cylinder /// with the face's range on it: one direction linear (two rows of control points), the second /// row the first moved by one vector at right angles to the circle the first row runs on, the -/// weights alike. `None` where it is not, or where its range runs more than one turn round. -fn extruded_circle(u_knots: &[f64], v_knots: &[f64], net: &[Vec<[f64; 4]>], domain: [[f64; 2]; 2], tol: f64) -> Option<(b::Surface, [[f64; 2]; 2])> { +/// weights alike. `None` where it is not. The range is the face's own, its trims' box `trims` +/// ([`trimmed`]), not the spline's: SMLib's spline of a cylinder's wall may run on past the face, +/// more than a turn round (a blend's support surface extended past the face's ends). +fn extruded_circle(u_knots: &[f64], v_knots: &[f64], net: &[Vec<[f64; 4]>], trims: [[f64; 2]; 2], tol: f64) -> Option<(b::Surface, [[f64; 2]; 2])> { let linear = |k: &[f64]| k.len() == 4 && k[0] == k[1] && k[2] == k[3] && k[1] < k[2]; let (rows, arc_knots, arc_range, line_knots, line_range): ([Vec<[f64; 4]>; 2], &[f64], [f64; 2], &[f64], [f64; 2]) = if net.iter().all(|r| r.len() == 2) && linear(v_knots) && !linear(u_knots) { - ([net.iter().map(|r| r[0]).collect(), net.iter().map(|r| r[1]).collect()], u_knots, domain[0], v_knots, domain[1]) + ([net.iter().map(|r| r[0]).collect(), net.iter().map(|r| r[1]).collect()], u_knots, trims[0], v_knots, trims[1]) } else if net.len() == 2 && linear(u_knots) && !linear(v_knots) { - ([net.first()?.clone(), net.get(1)?.clone()], v_knots, domain[1], u_knots, domain[0]) + ([net.first()?.clone(), net.get(1)?.clone()], v_knots, trims[1], u_knots, trims[0]) } else { return None; }; @@ -117,18 +127,102 @@ fn extruded_circle(u_knots: &[f64], v_knots: &[f64], net: &[Vec<[f64; 4]>], doma prev = a; angle = a; } - // A cylinder's range is at most one turn (SMLib refuses more). A spline whose range runs - // further round (SMLib's own, its parameter range past the face's trims) has no such range: - // it stays the exact spline it is. - if angle.abs() > TAU * (1.0 + 1e-9) { - return None; - } let height = |s: f64| (s - line_knots[1]) / (line_knots[2] - line_knots[1]) * d.len(); let sorted = |a: f64, b: f64| if a <= b { [a, b] } else { [b, a] }; let frame = b::Frame { origin: arr(center), x_axis: arr(x), y_axis: arr(y) }; Some((b::Surface::Cylinder { frame, radius }, [sorted(0.0, angle), sorted(height(line_range[0]), height(line_range[1]))])) } +/// The box in its surface's parameters round face `f`'s loops: each use's pcurve over its edge's +/// interval, its least and greatest u and v ([`pcurve_box`]). The range the face takes of its +/// surface, which may run on past it. +fn trimmed(brep: &b::Brep, f: &b::Face) -> R<[[f64; 2]; 2]> { + let mut lo = [f64::INFINITY; 2]; + let mut hi = [f64::NEG_INFINITY; 2]; + for l in &f.loops { + let b::Loop::Edges { edges } = l else { return Err(Unsupported::Invalid("a vertex loop on a cylinder's face")) }; + for u in edges { + let interval = brep.edges.get(u.edge as usize).ok_or(Unsupported::Invalid("edge"))?.interval; + let [ru, rv] = pcurve_box(&u.pcurve, interval).ok_or(Unsupported::Invalid("a pcurve that does not evaluate"))?; + for (k, r) in [ru, rv].into_iter().enumerate() { + lo[k] = lo[k].min(r[0]); + hi[k] = hi[k].max(r[1]); + } + } + } + if !(lo.iter().chain(&hi).all(|x| x.is_finite()) && lo[0] <= hi[0] && lo[1] <= hi[1]) { + return Err(Unsupported::Invalid("a face without trims")); + } + Ok([[lo[0], hi[0]], [lo[1], hi[1]]]) +} + +/// A pcurve's least and greatest u and v over `interval`, to rounding: on each Bézier span, the +/// greatest value found at a span's points (its ends, then the ends of its halves) bounds the +/// greatest from below, and its control points' (weights positive) from above; halves whose +/// control points can't beat the best found are dropped, the others halved, until the two meet. +fn pcurve_box(c: &b::Curve2, interval: [f64; 2]) -> Option<[[f64; 2]; 2]> { + use solidcraft_geom::bspline::{HPoint, bezier_segment, segment}; + let (knots, ctrl): (&[f64], Vec) = match c { + b::Curve2::Spline { knots, points } => (knots, points.iter().map(|p| HPoint { x: p[0], y: p[1], z: 0.0, w: 1.0 }).collect()), + b::Curve2::RationalSpline { knots, homogeneous } => { + (knots, homogeneous.iter().map(|h| HPoint { x: h[0], y: h[1], z: 0.0, w: h[2] }).collect()) + } + }; + let p = knots.len().checked_sub(ctrl.len() + 1)?; + let [t0, t1] = interval; + let mut ends: Vec = vec![t0]; + ends.extend(knots.iter().copied().filter(|k| *k > t0 && *k < t1)); + ends.push(t1); + ends.dedup(); + let mut out = [[f64::INFINITY, f64::NEG_INFINITY]; 2]; + for w in ends.windows(2) { + let &[a, z] = w else { continue }; + let (_, span) = segment(p, knots, &ctrl, a, z).ok()?; + if span.len() != p + 1 { + return None; + } + for (k, r) in out.iter_mut().enumerate() { + let along = |h: &HPoint| if k == 0 { h.x / h.w } else { h.y / h.w }; + for sign in [1.0, -1.0] { + let value = |piece: &[HPoint]| -> Option<[f64; 2]> { + let (first, last) = (piece.first()?, piece.last()?); + let at = (sign * along(first)).max(sign * along(last)); + let hull = piece.iter().map(|h| sign * along(h)).fold(f64::NEG_INFINITY, f64::max); + Some([at, hull]) + }; + let [mut best, _] = value(&span)?; + let mut pieces = vec![span.clone()]; + let mut work = 0usize; + while let Some(piece) = pieces.pop() { + let [at, hull] = value(&piece)?; + best = best.max(at); + if hull <= best + 64.0 * f64::EPSILON * (1.0 + best.abs()) { + continue; + } + // Out of work (a pcurve running along its extreme to rounding), the piece's + // control points bound it: the box still holds the curve. + work += 1; + if work > MAX_EXTENT_PIECES { + best = hull; + continue; + } + pieces.push(bezier_segment(&piece, 0.0, 0.5).ok()?); + pieces.push(bezier_segment(&piece, 0.5, 1.0).ok()?); + } + if !best.is_finite() { + return None; + } + if sign > 0.0 { + r[1] = r[1].max(best); + } else { + r[0] = r[0].min(-best); + } + } + } + } + Some(out) +} + /// An analytic surface's frame turned by `angle` about its axis: u then counts from there. fn turn_frame(s: &mut b::Surface, angle: f64) { if let b::Surface::Cylinder { frame, .. } | b::Surface::Cone { frame, .. } | b::Surface::Sphere { frame, .. } | b::Surface::Torus { frame, .. } = diff --git a/tests/kernel-cases/fillets/KNOWN_FAILING b/tests/kernel-cases/fillets/KNOWN_FAILING index f842508bc9..e87e0fcef1 100644 --- a/tests/kernel-cases/fillets/KNOWN_FAILING +++ b/tests/kernel-cases/fillets/KNOWN_FAILING @@ -52,4 +52,4 @@ every-pocket-rim-edges-r5-as-deep-as-pocket a corner patch where two rim rounds report-fillet-concave-arc-bar-top-into-cylinder-solver-residues SMLib status 1001: SmFilletNx1Corner cannot make the topology at the arc's end (fillet error 3, corner type 8) report-fillet-corner-after-round-running-into-cylinder-solver-residues SMLib's check refuses the round: its corner's surface of revolution is close to singular at its boundary (SmSurface test 5) report-fillet-curvature-three-edges-at-bar-corner SMLib's check refuses the open setback sheet of curvature rounds: a 2D B-spline curve cusp (SmBSplineCurve test 13) -report-fillet-edge-tangent-at-one-end-into-cylinder SMLib's check refuses the round that fades out at the edge's tangent end: internal poles, coincident control points, cusps +report-fillet-edge-tangent-at-one-end-into-cylinder SMLib refuses the round as larger than the faces beside its edges allow (SMLIB_ERR_NO_ROOM): it fades to nothing at the edge's tangent end, on the wall SMLib now gets back as its cylinder