geom: a face's loops lie in one turn of its periodic surface

form::extent unwrapped each loop of a face from its own first point, so a
hole beside the seam (a cross bore through a rod at the rod's seam angle)
came out a period away from the outer loop. The box over both was more than a
turn, cut to one turn from the hole: the face's NURBS form ran over quarters
3..7 while its seam uses lay at 4 and 8, and SMLib found the re-imported
rod's edge ends outside its face domain (SmBrep #70-#72). Each loop after the
first now goes the whole periods along that keep the face's box smallest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gDVmStT5tCGKW9Fxgc5K
This commit is contained in:
jyviko
2026-10-25 19:51:02 -04:00
parent 3be6ad4330
commit 098fac037e
2 changed files with 53 additions and 3 deletions
+26 -3
View File
@@ -319,12 +319,18 @@ const EXTENT_POINTS: usize = 32;
/// The parameter box of a face on `label` over its boundary: each loop's points (`loops`, in
/// order along each loop) projected onto the label, a periodic parameter unwrapped from one point
/// to the next. What the face's NURBS form ([`surface`]) is built over; no pcurve is involved.
/// to the next, and each loop after the first a whole number of periods along where that keeps
/// the box smallest. What the face's NURBS form ([`surface`]) is built over; no pcurve is
/// involved.
pub fn extent(label: &Surface, loops: &[Vec<(&Curve, [f64; 2], bool)>]) -> Option<[[f64; 2]; 2]> {
let eval = super::eval::SurfaceEval::new(label).ok()?;
let period = eval.domain().period;
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for l in loops {
// A loop is unwrapped from its own first point, so it can come out whole periods away
// from the loops before it (a hole beside the seam projects to the seam's other side),
// and a box over both is more than a turn wide.
let (mut l_lo, mut l_hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
let mut prev: Option<[f64; 2]> = None;
for (c, [t0, t1], forward) in l {
let curve = super::eval::CurveEval::new(c).ok()?;
@@ -343,8 +349,8 @@ pub fn extent(label: &Surface, loops: &[Vec<(&Curve, [f64; 2], bool)>]) -> Optio
let pole = eval.pole(q);
for d in 0..2 {
if !pole.get(d).copied().unwrap_or(false) {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
l_lo[d] = l_lo[d].min(q[d]);
l_hi[d] = l_hi[d].max(q[d]);
}
}
if !pole.iter().any(|p| *p) {
@@ -352,6 +358,23 @@ pub fn extent(label: &Surface, loops: &[Vec<(&Curve, [f64; 2], bool)>]) -> Optio
}
}
}
for d in 0..2 {
if !(l_lo[d] <= l_hi[d]) {
continue;
}
let shift = match period.get(d) {
Some(Some(per)) if lo[d] <= hi[d] => {
// The whole periods around the ones that bring the loop's middle to the box's,
// the one giving the smallest box.
let k = ((lo[d] + hi[d] - l_lo[d] - l_hi[d]) / (2.0 * per)).round();
let span = |k: f64| hi[d].max(l_hi[d] + k * per) - lo[d].min(l_lo[d] + k * per);
[k, k - 1.0, k + 1.0].into_iter().min_by(|a, b| span(*a).total_cmp(&span(*b))).unwrap_or(k) * per
}
_ => 0.0,
};
lo[d] = lo[d].min(l_lo[d] + shift);
hi[d] = hi[d].max(l_hi[d] + shift);
}
}
// At most one turn round a periodic direction; a boundary that never goes round it (a face
// bounded by its seam alone) covers the whole turn from there.
+27
View File
@@ -802,3 +802,30 @@ fn analytic_records_are_unbounded_with_independent_frames() {
assert!(radius(f64::NAN).is_err());
assert!(surface(&Surface::Torus { frame: a_frame(), major_radius: 1., minor_radius: 2. }, 0, &mut Budget(0)).is_err());
}
/// A cylinder face round its axis with a hole beside its seam: the seam at 350 degrees (so its
/// loop projects from 350 degrees on), the hole at 15..25 degrees (which on its own projects a
/// turn below). The face's box is the outer loop's turn with the hole in it; the hole's own
/// placement made a box from 15 degrees that cut the turn short and left the seam outside it
/// (#4598: a bored rod read back with its bore's pcurve a period off its face).
#[test]
fn a_hole_beside_the_seam_lies_in_its_face_turn() {
let (r, h) = (20.0f64, 60.0);
let cylinder = Surface::Cylinder { frame: Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }, radius: r };
let s = -10f64.to_radians();
let at = |a: f64, z: f64| [r * a.cos(), r * a.sin(), z];
let ring = |z: f64| Curve::Circle { frame: Frame { origin: [0., 0., z], x_axis: [s.cos(), s.sin(), 0.], y_axis: [-s.sin(), s.cos(), 0.] }, radius: r };
let (bottom, top) = (ring(0.), ring(h));
let seam = Curve::Line { start: at(s, 0.), end: at(s, h) };
let (a0, a1) = (15f64.to_radians(), 25f64.to_radians());
let hole =
Curve::Spline { knots: vec![0., 0., 1., 2., 3., 4., 4.], points: vec![at(a0, 25.), at(a1, 25.), at(a1, 35.), at(a0, 35.), at(a0, 25.)] };
let outer = vec![(&bottom, [0., TAU], true), (&seam, [0., 1.], true), (&top, [0., TAU], false), (&seam, [0., 1.], false)];
let inner = vec![(&hole, [0., 4.], true)];
let [u, v] = form::extent(&cylinder, &[outer, inner]).expect("an extent");
assert!((u[1] - u[0] - TAU).abs() < 1e-9, "one turn: {u:?}");
assert!((u[0].rem_euclid(TAU) - s.rem_euclid(TAU)).abs() < 1e-9, "from the seam: {u:?}");
let hole_u = a0 + ((u[0] - a0) / TAU).ceil() * TAU;
assert!(hole_u > u[0] && hole_u + (a1 - a0) < u[1], "the hole in the turn: {u:?}");
assert!(v[0].abs() < 1e-9 && (v[1] - h).abs() < 1e-9, "{v:?}");
}