brep: a spline's closedness is worked out once, not on every evaluation

EvalSurface::wrap asked uv::closed whether a spline closes on itself every
time it evaluated a point outside the knot interval, and that test walks the
whole control net (its extent, then the first and last rows). A tessellation
of a closed spline unwraps its parameters across the seam, so about half its
evaluations are outside: on a coil's helical tube (thousands of control
points) the test was 80% of the tessellation's time, 30 s of the coil
command on SMLib (sweeps/3832, sweeps/793).

EvalSurface::Nurbs now carries `closed`, set by EvalSurface::nurbs from the
same test; wrap and uv::domain read it. An affine map of the surface keeps it
(closedness does not change under one). The answers are the same as before. The constructor's callers in
crates/kernel/src/tess (size.rs, surface.rs) change with it.

Not built: base in progress (rebased onto kernel-smlib/no-truck 41adb372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-21 23:28:12 -04:00
parent 4b8edef069
commit 6e17d26dd9
4 changed files with 50 additions and 78 deletions
+1 -1
View File
@@ -234,7 +234,7 @@ fn nurbs(u_knots: &[f64], v_knots: &[f64], rows: &[Vec<[f64; 4]>]) -> Result<Eva
ctrl.push(*row.get(j).ok_or_else(|| Error::Invalid("ragged surface".into()))?);
}
}
Ok(EvalSurface::Nurbs { degree: [pu, pv], knots: [u_knots.to_vec(), v_knots.to_vec()], count: [nu, nv], ctrl })
Ok(EvalSurface::nurbs([pu, pv], [u_knots.to_vec(), v_knots.to_vec()], [nu, nv], ctrl))
}
/// A curve record ready to evaluate.
+27 -35
View File
@@ -38,10 +38,10 @@ pub struct EvalCurve {
pub enum EvalSurface {
/// `origin + u·du + v·dv`.
Plane { origin: Vec3, du: Vec3, dv: Vec3 },
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`.
/// `closed[d]`: the surface joins itself along direction `d` (a B-spline tube's way round).
/// Such a direction is periodic: a parameter outside the knot range is moved by whole
/// ranges back into it, so a face whose boundary runs across the join still evaluates.
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`. `closed`:
/// whether it closes on itself along each direction ([`uv::closed`]), worked out once by
/// [`EvalSurface::nurbs`] (every evaluation outside the knot interval asks, and the test
/// walks the whole control net).
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]>, closed: [bool; 2] },
/// `profile(u)` turned by the angle `v` (right-handed) about the axis through `origin`
/// along the unit `axis`, then mapped by the affine `transform` (row-major 3×4); with
@@ -136,6 +136,8 @@ impl TessPlan {
EvalSurface::Plane { origin, du, dv } => EvalSurface::Plane { origin: point(*origin), du: vector(*du), dv: vector(*dv) },
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => EvalSurface::Nurbs {
degree: *degree,
// An affine map keeps a surface closed or open.
closed: *closed,
knots: knots.clone(),
count: *count,
// Homogeneous: the translation scales with the weight.
@@ -232,6 +234,12 @@ impl EvalCurve {
}
impl EvalSurface {
/// The rational tensor B-spline [`EvalSurface::Nurbs`], with whether it closes on itself.
pub fn nurbs(degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]>) -> EvalSurface {
let closed = uv::closed(degree, &knots, count, &ctrl);
EvalSurface::Nurbs { degree, knots, count, ctrl, closed }
}
/// Point and unit normal (`S_u × S_v`, oriented as the surface) at `(u, v)`. A degenerate
/// point (a cone's apex, a sphere's pole) takes its normal from a step inside; anything
/// that still fails gives the origin and +Z rather than a NaN.
@@ -278,16 +286,13 @@ impl EvalSurface {
}
return out;
}
let EvalSurface::Nurbs { degree, knots, count, ctrl, .. } = self else { return uv };
let EvalSurface::Nurbs { degree, knots, count, closed, .. } = self else { return uv };
let range = |d: usize| Some((*knots.get(d)?.get(*degree.get(d)?)?, *knots.get(d)?.get(*count.get(d)?)?));
let outside: Vec<usize> = (0..2).filter(|&d| range(d).is_some_and(|(a, b)| !(a..=b).contains(&uv[d]))).collect();
if outside.is_empty() {
return uv;
}
let shut = uv::closed(*degree, knots, *count, ctrl);
let mut out = uv;
for d in outside {
if let (true, Some(r)) = (shut[d], range(d)) {
for d in 0..2 {
if let (true, Some(r)) = (closed[d], range(d))
&& !(r.0..=r.1).contains(&uv[d])
{
out[d] = uv::wrap_into(uv[d], r);
}
}
@@ -456,13 +461,12 @@ mod tests {
let m = [[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [5.0, -2.0, 7.0, 1.0]];
let apply = |p: Vec3| Vec3::new(c * p.x - s * p.y + 5.0, s * p.x + c * p.y - 2.0, p.z + 7.0);
let w = std::f64::consts::FRAC_1_SQRT_2;
let nurbs = EvalSurface::Nurbs {
degree: [2, 1],
knots: [vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [3, 2],
ctrl: vec![[1.0, 0.0, 0.0, 1.0], [w, w, 0.0, w], [0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 2.0, 1.0], [w, w, 2.0 * w, w], [0.0, 1.0, 2.0, 1.0]],
closed: [false, false],
};
let nurbs = EvalSurface::nurbs(
[2, 1],
[vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
[3, 2],
vec![[1.0, 0.0, 0.0, 1.0], [w, w, 0.0, w], [0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 2.0, 1.0], [w, w, 2.0 * w, w], [0.0, 1.0, 2.0, 1.0]],
);
let revolved = EvalSurface::Revolved {
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(3.0, 0.0, 1.0)),
origin: Vec3::ZERO,
@@ -583,13 +587,7 @@ mod tests {
fn bilinear_nurbs_patch_and_derivatives() {
// Degree (1, 1) over [0, 2] × [0, 1] with corners lifted: a hyperbolic paraboloid.
let ctrl = vec![[0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [2.0, 1.0, 1.0, 1.0]];
let s = EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl,
closed: [false, false],
};
let s = EvalSurface::nurbs([1, 1], [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]], [2, 2], ctrl);
let Some((p, du, dv)) = s.derivs(1.0, 0.5) else { panic!("evaluates") };
assert!(close(p, Vec3::new(1.0, 0.5, 0.25), 1e-12), "{p:?}");
assert!(close(du, Vec3::new(1.0, 0.0, 0.25), 1e-12), "{du:?}");
@@ -611,7 +609,7 @@ mod tests {
}
let Ok(reference) = crate::bspline::NurbsSurface::new([3, 2], knots.clone(), &pts, &wts) else { panic!("valid surface") };
let ctrl = reference.homogeneous().iter().map(|h| [h.x, h.y, h.z, h.w]).collect();
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl, closed: [false, false] };
let s = EvalSurface::nurbs([3, 2], knots, [nu, nv], ctrl);
for i in 0..=8 {
for j in 0..=8 {
let (u, v) = (i as f64 / 8.0, j as f64 / 4.0);
@@ -635,14 +633,8 @@ mod tests {
#[test]
fn hostile_surfaces_do_not_panic() {
let bad = [
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![], closed: [false, false] },
EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl: vec![[0.0; 4]; 4],
closed: [false, false],
},
EvalSurface::nurbs([9, 1], [vec![0.0; 3], vec![0.0; 3]], [1, 1], vec![]),
EvalSurface::nurbs([1, 1], [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]], [2, 2], vec![[0.0; 4]; 4]),
EvalSurface::Revolved {
profile: EvalCurve { degree: 0, knots: vec![], ctrl: vec![] },
origin: Vec3::ZERO,
+4 -4
View File
@@ -25,10 +25,9 @@ pub fn domain(s: &EvalSurface) -> Domain {
let tau = std::f64::consts::TAU;
match s {
EvalSurface::Plane { .. } => Domain { range: [None, None], period: [None, None] },
EvalSurface::Nurbs { degree, knots, count, ctrl } => {
EvalSurface::Nurbs { degree, knots, count, closed: shut, .. } => {
let r = |d: usize| Some((*knots[d].get(degree[d])?, *knots[d].get(count[d])?));
let range = [r(0), r(1)];
let shut = closed(*degree, knots, *count, ctrl);
let period = [0, 1].map(|d| range[d].filter(|_| shut[d]).map(|(a, b)| b - a).filter(|p| *p > 0.0));
Domain { range, period }
}
@@ -363,15 +362,16 @@ mod tests {
ctrl.push([c[0] * c[3], c[1] * c[3], (c[2] + z) * c[3], c[3]]);
}
}
EvalSurface::Nurbs { degree: [2, 1], knots: [u_knots, vec![0.0, 0.0, 1.0, 1.0]], count: [6, 2], ctrl }
EvalSurface::nurbs([2, 1], [u_knots, vec![0.0, 0.0, 1.0, 1.0]], [6, 2], ctrl)
}
#[test]
fn a_spline_closed_on_itself_is_periodic_over_its_knot_interval() {
let clamped = || vec![0.0, 0.0, 0.0, 0.25, 0.5, 0.75, 1.0, 1.0, 1.0];
let s = ring([0.0; 4], clamped());
let EvalSurface::Nurbs { degree, knots, count, ctrl } = &s else { panic!("a spline") };
let EvalSurface::Nurbs { degree, knots, count, ctrl, closed: kept } = &s else { panic!("a spline") };
assert_eq!(closed(*degree, knots, *count, ctrl), [true, false]);
assert_eq!(*kept, [true, false], "the constructor keeps what the test finds");
assert_eq!(domain(&s).period, [Some(1.0), None]);
// A period away is the same point; inside the interval nothing moves.
for (u, inside) in [(1.25, 0.25), (-0.25, 0.75), (3.6, 0.6)] {
+18 -38
View File
@@ -61,35 +61,14 @@ impl Turn {
pub(crate) struct Rules {
pub tol: f64,
pub max_len: f64,
/// The body's extent (see [`extent`]); 0 when it has none. Tolerances that measure the
/// body's own geometry rather than the mesh are fractions of it.
pub extent: f64,
pub turn: Option<Turn>,
/// How far the body's boundaries may lie off their surfaces (mm), as the file the body was
/// read from declares; `None` for a body this kernel built (see
/// [`crate::body::Body::boundary_tol`]). It raises the planner's bound (a hundredth of the
/// extent) where it is larger, never lowers it.
pub boundary_tol: Option<f64>,
}
impl Rules {
/// The same rules with a chord tolerance `k` times finer.
pub(crate) fn finer(self, k: f64) -> Rules {
Rules { tol: self.tol / k, ..self }
}
/// Rules for a body of extent `extent` (see [`extent`]). `boundary_tol`: the body's declared
/// off-surface budget (mm), `None` for a body this kernel built.
pub(crate) fn new(tol: f64, extent: f64, turn: Option<Turn>, boundary_tol: Option<f64>) -> Rules {
let sound = extent.is_finite() && extent > 0.0;
let max_len = if sound { extent * MAX_LEN_FRACTION } else { f64::INFINITY };
Rules {
tol,
max_len: max_len.max(tol),
extent: if sound { extent } else { 0.0 },
turn,
boundary_tol: boundary_tol.filter(|t| t.is_finite() && *t > 0.0),
}
/// Rules for a body of extent `extent` (see [`extent`]).
pub(crate) fn new(tol: f64, extent: f64, turn: Option<Turn>) -> Rules {
let max_len = if extent.is_finite() && extent > 0.0 { extent * MAX_LEN_FRACTION } else { f64::INFINITY };
Rules { tol, max_len: max_len.max(tol), turn }
}
/// The side length for a curvature `k` (1/mm): the chord sags at most the tolerance across
@@ -199,36 +178,37 @@ mod tests {
swap: false,
};
// A 20 mm body: sides from 1 mm (the floor, a quarter of the longest) to 4 mm.
let rules = Rules::new(0.01, 20.0, THIRTY, None);
let rules = Rules::new(0.01, 20.0, THIRTY);
let (h, speed) = face_size(&s, &super::super::uv::domain(&s), &rules);
// r = 3: 30° of turn is 1.57 mm; the 0.01 mm sag chord is √(8·3·0.01) = 0.49 mm, times the
// aspect, unless that is under the floor.
assert!((h - (ASPECT * (8.0 * 3.0 * 0.01f64).sqrt()).max(20.0 * MAX_LEN_FRACTION * MIN_LEN_FRACTION)).abs() < 0.01, "{h}");
assert!((speed[0] - 40.0).abs() < 1e-6 && (speed[1] - 3.0).abs() < 1e-6, "{speed:?}");
// Coarser: the turn limit governs.
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, THIRTY, None));
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, THIRTY));
assert!((h - (ASPECT * 3.0 * std::f64::consts::PI / 6.0).max(100.0 * MAX_LEN_FRACTION * MIN_LEN_FRACTION)).abs() < 0.01, "{h}");
// Without a facet angle the sag alone: √(8·3·0.5) = 3.46 mm, four times along.
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, None, None));
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, None));
assert!((h - ASPECT * (8.0 * 3.0 * 0.5f64).sqrt()).abs() < 0.01, "{h}");
// A turn that sags below its floor does not divide.
let floored = Some(Turn { angle: std::f64::consts::PI / 6.0, floor: 1.0 });
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, floored, None));
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, floored));
assert!((h - ASPECT * (8.0 * 3.0 * 0.5f64).sqrt()).abs() < 0.01, "{h}");
}
#[test]
fn a_flat_spline_is_planar_and_a_bent_one_is_not() {
let patch = |z: f64| EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl: vec![[0.0, 0.0, 0.0, 1.0], [4.0, 0.0, 0.0, 1.0], [0.0, 3.0, 0.0, 1.0], [4.0, 3.0, z, 1.0]],
closed: [false, false],
let patch = |z: f64| {
EvalSurface::nurbs(
[1, 1],
[vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
[2, 2],
vec![[0.0, 0.0, 0.0, 1.0], [4.0, 0.0, 0.0, 1.0], [0.0, 3.0, 0.0, 1.0], [4.0, 3.0, z, 1.0]],
)
};
assert!(planar(&patch(0.0)));
assert!(!planar(&patch(0.5)));
let rules = Rules::new(0.01, 100.0, THIRTY, None);
let rules = Rules::new(0.01, 100.0, THIRTY);
let (h, speed) = face_size(&patch(0.0), &super::super::uv::domain(&patch(0.0)), &rules);
assert_eq!(h, rules.max_len);
assert!((speed[0] - 4.0).abs() < 1e-9 && (speed[1] - 3.0).abs() < 1e-9, "{speed:?}");
@@ -245,7 +225,7 @@ mod tests {
transform: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]],
swap: false,
};
let rules = Rules::new(0.1, 100.0, THIRTY, None);
let rules = Rules::new(0.1, 100.0, THIRTY);
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &rules);
assert!((h - rules.max_len * MIN_LEN_FRACTION).abs() < 1e-9, "{h}");
}
@@ -253,7 +233,7 @@ mod tests {
#[test]
fn a_plane_takes_the_longest_side() {
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
let rules = Rules::new(0.01, 100.0, THIRTY, None);
let rules = Rules::new(0.01, 100.0, THIRTY);
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, 20.0);
}
}