brep: splines closed on themselves are periodic for pcurves and tessellation

A spline surface whose first and last control rows coincide in a direction
(points and weights, knots clamped there) closes on itself, but the display
evaluator's domain gave NURBS no period. The pcurve routine therefore never
moved a seam's second use by a period: SMLib's fillet round a hole's rim (a
rational spline closed in u) got both seam pcurves on u = 0, its outer loop
enclosed no (u, v) area, the decoded face tessellated to nothing and the body
measured 6839.73 mm3 against SMLib's 6989.00.

tess::uv::closed detects the closure from the record, within 1e-9 of the
control net's extent; the domain then has period = the active knot interval's
width, and the evaluator wraps a parameter outside that interval back inside
it, so pcurves, projection, sizing and the display plan all treat the direction
as periodic. The record stays as it is. The display plan hands both evaluators
wrapped parameters (the GPU does not wrap).

Tests: a rational torus band, a NURBS cylinder and a NURBS dome with a pole,
each bounded by a seam used twice, get seam pcurves one period apart; a record
of the band passes check_geometry and its seam uses on one line are refused;
SMLib's hole-rim fillet decodes with every face meshed and SMLib's volume
within 1e-3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 02:52:36 -04:00
parent 734883bdb5
commit 27e996c44d
8 changed files with 350 additions and 22 deletions
+5 -4
View File
@@ -13,9 +13,10 @@
//!
//! Periodic parameters are unwrapped along each curve, and each loop's uses are moved by whole
//! periods so every use starts where the one before it ends: a seam edge used twice gets two
//! pcurves a period apart. At a pole the parameter along the collapsed direction is undefined;
//! there it is taken from the nearest sample where it is defined, so a pcurve runs straight into
//! the pole, and where two uses meet at a pole the loop runs along the pole's line.
//! pcurves a period apart. A spline closed on itself in a direction is periodic there, one knot
//! interval per period ([`uv`]). At a pole the parameter along the collapsed direction is
//! undefined; there it is taken from the nearest sample where it is defined, so a pcurve runs
//! straight into the pole, and where two uses meet at a pole the loop runs along the pole's line.
use super::eval::{CurveEval, SurfaceEval};
use super::{Budget, Curve, Curve2, Surface, curve, curve_natural, interval, surface};
use crate::Vec3;
@@ -646,4 +647,4 @@ fn move_by(c: &mut Curve2, d: usize, by: f64) {
#[cfg(test)]
#[path = "pcurve_tests.rs"]
mod tests;
pub(super) mod tests;
+111
View File
@@ -321,6 +321,117 @@ fn a_torus_bounded_by_its_seams_spans_both_periods() {
}
}
/// A surface of revolution about +z as a rational spline closed in u, as SMLib writes a fillet
/// ring round a hole's rim and STEP a NURBS cylinder: u the nine-point rational quadratic
/// circle on [0, 1] (first and last rows the same), v the profile's `[radius, height, weight]`
/// points on `v_knots`.
pub(crate) fn closed_ring(profile: &[[f64; 3]], v_knots: &[f64]) -> Surface {
let w = std::f64::consts::FRAC_1_SQRT_2;
let around = [[1., 0., 1.], [1., 1., w], [0., 1., 1.], [-1., 1., w], [-1., 0., 1.], [-1., -1., w], [0., -1., 1.], [1., -1., w], [1., 0., 1.]];
let homogeneous = around
.iter()
.map(|&[cx, cy, wu]| {
profile
.iter()
.map(|&[r, z, wv]| {
let wt = wu * wv;
[r * cx * wt, r * cy * wt, z * wt, wt]
})
.collect()
})
.collect();
Surface::RationalSpline { u_knots: vec![0., 0., 0., 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1., 1., 1.], v_knots: v_knots.to_vec(), homogeneous }
}
/// The pcurves of a loop on a spline closed in u (period 1): each lifts within `tol`, the
/// seam's two uses (`seam`: their positions) lie one period apart, the loop closes (in v alone
/// where it meets a pole) and turns counter-clockwise once around the whole (u, v) box, all in
/// the base period.
fn closes_around_the_box(surface: &Surface, l: &[EdgeUse<'_>], seam: [usize; 2], pole: Option<usize>, tol: f64, what: &str) {
let pcs = &pcurves(surface, &[l.to_vec()], tol).unwrap_or_else(|e| panic!("{what}: {e}"))[0];
for (u, pc) in l.iter().zip(pcs) {
assert!(lift_error(surface, u.curve, u.interval, pc) <= tol, "{what}: lifts");
}
let iv = l[seam[0]].interval;
let mid = 0.5 * (iv[0] + iv[1]);
let (a, b) = (pcs[seam[0]].eval(mid).unwrap(), pcs[seam[1]].eval(mid).unwrap());
assert!(((a[0] - b[0]).abs() - 1.).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9, "{what}: seam uses a period apart: {a:?} {b:?}");
let (ends, area) = walk(l, pcs);
let n = l.len();
for k in 0..n {
let (end, start) = (ends[k].1, ends[(k + 1) % n].0);
let free_u = pole == Some((k + 1) % n);
assert!((free_u || (end[0] - start[0]).abs() < 1e-9) && (end[1] - start[1]).abs() < 1e-9, "{what}: closes at {k}: {end:?} {start:?}");
}
// At a pole `walk` cuts the corner (it joins each use's 16th sample to the next use's
// start, across the pole's line): a sliver of the box less.
let least = if pole.is_some() { 0.9 } else { 1. - 1e-6 };
assert!(area >= least && area < 1. + 1e-6, "{what}: counter-clockwise around the box: {area}");
for pc in pcs {
let (Curve2::Spline { knots, .. } | Curve2::RationalSpline { knots, .. }) = pc;
for k in 0..=32 {
let t = knots[0] + (knots[knots.len() - 1] - knots[0]) * k as f64 / 32.;
let q = pc.eval(t).unwrap();
assert!((-1e-9..=1. + 1e-9).contains(&q[0]), "{what}: base period: {q:?}");
}
}
}
/// Splines closed on themselves in u (their first and last control rows the same) are periodic
/// there, one knot interval per period: a ring of a torus bounded by two circles and a seam used
/// twice (SMLib's fillet round a hole's rim), a NURBS cylinder, and a NURBS dome whose seam runs
/// into its pole. Each seam's uses come out a period apart, never both on u = 0.
#[test]
fn a_seam_on_a_spline_closed_in_u_gets_pcurves_a_period_apart() {
let w = std::f64::consts::FRAC_1_SQRT_2;
let full = [0., TAU];
let (big, small) = (5., 1.);
// The torus band: the tube's quarter from its outer equator (v = 0) to its top (v = 1).
let band = closed_ring(&[[big + small, 0., 1.], [big + small, small, w], [big, small, 1.]], &[0., 0., 0., 1., 1., 1.]);
let outer = Curve::Circle { frame: about_z([0.; 3], false), radius: big + small };
let top = Curve::Circle { frame: about_z([0., 0., small], false), radius: big };
let quarter = Curve::Circle { frame: frame([big, 0., 0.], [1., 0., 0.], [0., 0., 1.]), radius: small };
let l = vec![
EdgeUse { edge: 0, curve: &outer, interval: full, forward: true },
EdgeUse { edge: 1, curve: &quarter, interval: [0., FRAC_PI_2], forward: true },
EdgeUse { edge: 2, curve: &top, interval: full, forward: false },
EdgeUse { edge: 1, curve: &quarter, interval: [0., FRAC_PI_2], forward: false },
];
for tol in [1e-4, 1e-7] {
closes_around_the_box(&band, &l, [1, 3], None, tol, &format!("torus band, tol {tol}"));
}
// The cylinder: radius 3 from z = 0 (v = 0) to z = 4 (v = 1), its seam a line.
let cylinder = closed_ring(&[[3., 0., 1.], [3., 4., 1.]], &[0., 0., 1., 1.]);
let (low, high) =
(Curve::Circle { frame: about_z([0.; 3], false), radius: 3. }, Curve::Circle { frame: about_z([0., 0., 4.], false), radius: 3. });
let line = Curve::Line { start: [3., 0., 0.], end: [3., 0., 4.] };
let l = vec![
EdgeUse { edge: 0, curve: &low, interval: full, forward: true },
EdgeUse { edge: 1, curve: &line, interval: [0., 1.], forward: true },
EdgeUse { edge: 2, curve: &high, interval: full, forward: false },
EdgeUse { edge: 1, curve: &line, interval: [0., 1.], forward: false },
];
closes_around_the_box(&cylinder, &l, [1, 3], None, 1e-7, "cylinder");
// The dome: radius 6 from its equator (v = 0) to its pole (v = 1), the seam a meridian.
let dome = closed_ring(&[[6., 0., 1.], [6., 6., w], [0., 6., 1.]], &[0., 0., 0., 1., 1., 1.]);
let equator = Curve::Circle { frame: about_z([0.; 3], false), radius: 6. };
let meridian = Curve::Circle { frame: frame([0.; 3], [1., 0., 0.], [0., 0., 1.]), radius: 6. };
let l = vec![
EdgeUse { edge: 0, curve: &equator, interval: full, forward: true },
EdgeUse { edge: 1, curve: &meridian, interval: [0., FRAC_PI_2], forward: true },
EdgeUse { edge: 1, curve: &meridian, interval: [0., FRAC_PI_2], forward: false },
];
closes_around_the_box(&dome, &l, [1, 2], Some(2), 1e-7, "dome");
// A spline whose ends merely come near each other stays open: no period, so the seam's
// second use cannot be moved and the loop does not close around the box.
let Surface::RationalSpline { u_knots, v_knots, homogeneous } = &cylinder else { panic!("a rational spline") };
let mut open = homogeneous.clone();
open[8][0][1] += 1e-3;
let open = Surface::RationalSpline { u_knots: u_knots.clone(), v_knots: v_knots.clone(), homogeneous: open };
assert_eq!(SurfaceEval::new(&open).unwrap().domain().period, [None, None]);
assert_eq!(SurfaceEval::new(&cylinder).unwrap().domain().period, [Some(1.), None]);
}
/// An intersection record evaluates to the point of both surfaces in the leader's normal
/// plane, and its pcurves on either surface fit.
#[test]
+59 -1
View File
@@ -160,6 +160,47 @@ fn tube() -> Brep {
fit_pcurves(&mut b);
b
}
/// One face on `surface` bounded by one loop of `uses` (edge index, forward), each edge's
/// pcurves fitted, as an open sheet.
fn one_face(surface: Surface, domain: [[f64; 2]; 2], edges: Vec<(Curve, [f64; 2], [u32; 2])>, vertices: &[[f64; 3]], uses: &[(u32, bool)]) -> Brep {
let mut b = Brep {
version: VERSION,
tolerance: 1e-7,
regions: vec![infinite(vec![0])],
shells: vec![Shell::Faces { faces: vec![OrientedFace { face: 0, forward: true }], wires: vec![] }],
faces: vec![Face {
surface,
domain,
loops: vec![Loop::Edges { edges: uses.iter().map(|&(edge, forward)| OrientedEdge { edge, forward, pcurve: segment(0., 1.) }).collect() }],
}],
edges: edges.into_iter().map(|(curve, interval, vertices)| Edge { curve, interval, vertices, tolerance: 1e-7, uses: vec![] }).collect(),
vertices: vertices.iter().map(|&point| Vertex { point, tolerance: 1e-7 }).collect(),
};
list_uses(&mut b);
fit_pcurves(&mut b);
b
}
/// A ring of a torus as a rational spline closed in u (first and last control rows the same),
/// as SMLib writes the fillet round a hole's rim: bounded by its two circles and the tube's
/// quarter at u = 0, a seam used twice.
fn closed_band() -> Brep {
let w = std::f64::consts::FRAC_1_SQRT_2;
let (big, small) = (5., 1.);
let surface = pcurve::tests::closed_ring(&[[big + small, 0., 1.], [big + small, small, w], [big, small, 1.]], &[0., 0., 0., 1., 1., 1.]);
let about = |z: f64| Frame { origin: [0., 0., z], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let quarter = Curve::Circle { frame: Frame { origin: [big, 0., 0.], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }, radius: small };
one_face(
surface,
[[0., 1.], [0., 1.]],
vec![
(Curve::Circle { frame: about(0.), radius: big + small }, [0., TAU], [0, 0]),
(quarter, [0., FRAC_PI_2], [0, 1]),
(Curve::Circle { frame: about(small), radius: big }, [0., TAU], [1, 1]),
],
&[[big + small, 0., 0.], [big, 0., small]],
&[(0, true), (1, true), (2, false), (1, false)],
)
}
/// The sheet with a vertex loop inside it, a wire shell and a vertex shell beside it.
fn sheet_with_lower_forms() -> Brep {
let mut b = sheet();
@@ -182,7 +223,14 @@ fn sheet_with_lower_forms() -> Brep {
#[test]
fn every_form_validates_and_round_trips_without_a_kernel() {
for (name, body) in [("sheet", sheet()), ("closed", tetra()), ("void", hollow()), ("seam", tube()), ("lower forms", sheet_with_lower_forms())] {
for (name, body) in [
("sheet", sheet()),
("closed", tetra()),
("void", hollow()),
("seam", tube()),
("seam on a closed spline", closed_band()),
("lower forms", sheet_with_lower_forms()),
] {
body.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
body.check_geometry().unwrap_or_else(|e| panic!("{name}: {e}"));
let bytes = serde_json::to_vec(&body).unwrap();
@@ -470,6 +518,16 @@ fn geometric_defects_are_refused_by_record() {
b.vertices[6].point[2] = 0.01;
b
}),
(
"seam uses of a closed spline on one line",
Record::Use { face: 0, r#loop: 0, position: 1 },
Box::new(|p: &Problem| *p == Problem::Period { gap: 1., period: 1. }),
{
let mut b = closed_band();
shift(pcurve_of(&mut b, 0, 1), [-1., 0.]);
b
},
),
];
for (name, record, problem, body) in cases {
body.validate().unwrap_or_else(|e| panic!("{name}: structurally valid: {e}"));
+25 -14
View File
@@ -237,26 +237,37 @@ impl EvalSurface {
}
}
/// Point and first partial derivatives.
/// `uv` with each parameter along which a spline closes on itself moved by whole periods
/// into the spline's active knot interval (unchanged inside it, and for any other surface):
/// the parameters both evaluators take for a point of a closed spline found a period away.
pub fn wrap(&self, uv: [f64; 2]) -> [f64; 2] {
let EvalSurface::Nurbs { degree, knots, count, ctrl, .. } = 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)) {
out[d] = uv::wrap_into(uv[d], r);
}
}
out
}
/// Point and first partial derivatives (a closed spline's parameters wrapped into its
/// active interval first, [`EvalSurface::wrap`]).
pub fn derivs(&self, u: f64, v: f64) -> Option<(Vec3, Vec3, Vec3)> {
if !(u.is_finite() && v.is_finite()) {
return None;
}
match self {
EvalSurface::Plane { origin, du, dv } => Some((*origin + *du * u + *dv * v, *du, *dv)),
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
// The knot range of direction `d`: the parameters the surface is defined over.
let range = |d: usize| -> Option<(f64, f64)> {
let k = knots.get(d)?;
Some((*k.get(*degree.get(d)?)?, *k.get(*count.get(d)?)?))
};
// A direction the surface joins itself along repeats, so a parameter outside the
// range is moved by whole ranges back into it. Any other direction is left alone.
let wrap = |d: usize, t: f64| match range(d) {
Some((a, b)) if closed.get(d).copied().unwrap_or(false) && b > a && !(a..=b).contains(&t) => a + (t - a).rem_euclid(b - a),
_ => t,
};
nurbs_derivs(*degree, knots, *count, ctrl, wrap(0, u), wrap(1, v))
EvalSurface::Nurbs { degree, knots, count, ctrl, .. } => {
let [u, v] = self.wrap([u, v]);
nurbs_derivs(*degree, knots, *count, ctrl, u, v)
}
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
let (s, a) = if *swap { (v, u) } else { (u, v) };
+110 -2
View File
@@ -1,6 +1,12 @@
//! Parameters of points on an [`EvalSurface`], on the evaluator's own surface form: a
//! surface's parameter box and periods, the parameters of a point on it (closed form on a plane,
//! damped Gauss–Newton elsewhere), unwrapping across a period, and its poles.
//!
//! A spline that closes on itself along a direction (its first and last control rows coincide,
//! weights included, and its knots are clamped there: a NURBS cylinder, a fillet ring round a
//! hole's rim) is periodic along it, one active knot interval per period: the record stays as it
//! is, and parameters outside the interval are evaluated a whole number of periods back inside
//! it ([`EvalSurface::derivs`]).
use super::EvalSurface;
use crate::Vec3;
@@ -18,9 +24,12 @@ 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, .. } => {
EvalSurface::Nurbs { degree, knots, count, ctrl } => {
let r = |d: usize| Some((*knots[d].get(degree[d])?, *knots[d].get(count[d])?));
Domain { range: [r(0), r(1)], period: [None, None] }
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 }
}
EvalSurface::Revolved { profile, swap, .. } => {
let r = (|| Some((*profile.knots.get(profile.degree)?, *profile.knots.get(profile.ctrl.len())?)))();
@@ -31,6 +40,59 @@ pub fn domain(s: &EvalSurface) -> Domain {
}
}
/// Relative closeness of a spline's first and last control rows that makes it closed: control
/// points within this fraction of the control net's extent, weights within this fraction of
/// their size. Rows written as one (a producer's exact copy) pass; a surface whose ends merely
/// come near each other does not.
const CLOSED: f64 = 1e-9;
/// Whether the tensor spline closes on itself along each direction: its knots clamped at both
/// ends of that direction (so its boundary rows are its edges) and its first and last control
/// rows the same points with the same weights. Control point `(i, j)` is `ctrl[j·count[0] + i]`.
pub fn closed(degree: [usize; 2], knots: &[Vec<f64>; 2], count: [usize; 2], ctrl: &[[f64; 4]]) -> [bool; 2] {
let [nu, nv] = count;
if nu.checked_mul(nv) != Some(ctrl.len()) || nu < 2 || nv < 2 {
return [false; 2];
}
let cart = |c: &[f64; 4]| (c[3] != 0.0).then(|| Vec3::new(c[0] / c[3], c[1] / c[3], c[2] / c[3]));
let (mut lo, mut hi) =
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
for c in ctrl {
let Some(p) = cart(c) else { return [false; 2] };
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
let extent = hi.dist(lo);
if !(extent.is_finite() && extent > 0.0) {
return [false; 2];
}
let same = |a: Option<&[f64; 4]>, b: Option<&[f64; 4]>| match (a, b) {
(Some(a), Some(b)) => {
matches!((cart(a), cart(b)), (Some(p), Some(q)) if p.dist(q) <= CLOSED * extent)
&& (a[3] - b[3]).abs() <= CLOSED * a[3].abs().max(b[3].abs())
}
_ => false,
};
let clamped = |d: usize| {
let (p, n, k) = (degree[d], count[d], &knots[d]);
let ends = |r: std::ops::RangeInclusive<usize>| k.get(r).is_some_and(|s| s.windows(2).all(|w| w.first() == w.get(1)));
k.len() == n + p + 1 && ends(0..=p) && ends(n..=n + p)
};
let u = clamped(0) && (0..nv).all(|j| same(ctrl.get(j * nu), ctrl.get(j * nu + nu - 1)));
let v = clamped(1) && (0..nu).all(|i| same(ctrl.get(i), ctrl.get((nv - 1) * nu + i)));
[u, v]
}
/// `x` moved by whole periods `b − a` into `[a, b]` when it lies outside.
pub(crate) fn wrap_into(x: f64, (a, b): (f64, f64)) -> f64 {
let period = b - a;
if (a..=b).contains(&x) || !(period > 0.0) {
return x;
}
let w = x - ((x - a) / period).floor() * period;
w.clamp(a, b)
}
impl Domain {
pub fn clamp(&self, uv: [f64; 2]) -> [f64; 2] {
let mut out = uv;
@@ -175,6 +237,52 @@ mod tests {
assert!(project(&s, &dom, Vec3::new(5.0, 0.0, 1.0), None, 1e-3).is_none());
}
/// A quadratic spline round a square in u (first and last columns the same), straight
/// up in v, `last` moving its last u column.
fn ring(last: [f64; 4], u_knots: Vec<f64>) -> EvalSurface {
let around =
[[2.0, 0.0, 0.0, 1.0], [2.0, 2.0, 0.0, 1.0], [-2.0, 2.0, 0.0, 1.0], [-2.0, -2.0, 0.0, 1.0], [2.0, -2.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0]];
let mut ctrl = Vec::new();
for z in [0.0, 3.0] {
for (i, c) in around.iter().enumerate() {
let c = if i == around.len() - 1 { [c[0] + last[0], c[1] + last[1], c[2] + last[2], c[3] + last[3]] } else { *c };
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 }
}
#[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") };
assert_eq!(closed(*degree, knots, *count, ctrl), [true, false]);
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)] {
let (Some(a), Some(b)) = (s.derivs(u, 0.4), s.derivs(inside, 0.4)) else { panic!("evaluates") };
assert!(a.0.dist(b.0) < 1e-12 && a.1.dist(b.1) < 1e-12, "{u}: {a:?} {b:?}");
}
assert_eq!(s.wrap([1.0, 0.5]), [1.0, 0.5]);
assert_eq!(s.wrap([1.25, 2.0]), [0.25, 2.0]);
// Projection finds a point near the seam from either side.
let p = s.derivs(0.02, 0.5).map(|d| d.0).unwrap_or(Vec3::ZERO);
let dom = domain(&s);
let Some(q) = project(&s, &dom, p, Some([0.98, 0.5]), 1e-9) else { panic!("projects") };
assert!((unwrap(q[0], 0.98, dom.period[0]) - 1.02).abs() < 1e-9, "{q:?}");
// Ends that only come near each other, a weight that differs, or unclamped knots: open,
// and evaluated past the interval as before (the end span continued).
for (what, open) in [
("ends apart", ring([0.0, 1e-6, 0.0, 0.0], clamped())),
("weights differ", ring([0.0, 0.0, 0.0, 1e-6], clamped())),
("unclamped", ring([0.0; 4], vec![-0.5, -0.25, 0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5])),
] {
assert_eq!(domain(&open).period, [None, None], "{what}");
assert_eq!(open.wrap([1.25, 0.5]), [1.25, 0.5], "{what}");
}
}
#[test]
fn unwraps_across_the_period() {
let tau = std::f64::consts::TAU;
+30
View File
@@ -251,3 +251,33 @@ fn smlib_primitives_decode_with_their_volume_and_orientation() {
);
}
}
/// SMLib's fillet round a hole's rim is a rational spline closed on itself in u, bounded by its
/// seam used twice: the seam's pcurves lie a period apart, so the face meshes and the decoded
/// body has SMLib's volume (both seam uses on u = 0 left the face empty, 149 mm³ short).
#[cfg(feature = "smlib")]
#[test]
fn smlib_hole_rim_fillet_decodes_with_its_volume() {
use solidcraft_smlib::{BooleanOp, Brep as S};
let block = S::make_box([0.; 3], [20.; 3]).unwrap();
let pin = S::make_cylinder([10., 10., -1.], 4., 22.).unwrap();
let holed = block.boolean(&pin, BooleanOp::Subtract).unwrap().unwrap();
let rim = holed.fillet(&[[10., 14., 20.]], 0.1, 1.).unwrap();
let record = rim.to_native().unwrap();
assert!(
record
.faces
.iter()
.any(|f| matches!(&f.surface, b::Surface::RationalSpline { homogeneous, .. } if homogeneous.first() == homogeneous.last())),
"the fillet is a spline closed in u"
);
record.check_geometry().unwrap();
let body = crate::native::body_from_smlib(&rim).unwrap();
let mesh = body.tessellate(0.02).unwrap();
for f in 0..body.face_count() {
assert!(mesh.tri_face.iter().any(|&t| t as usize == f), "face {f} meshes");
}
let smlib = rim.mass_properties(solidcraft_smlib::MEASURE_ACCURACY).unwrap().volume;
let ours = crate::measure(&body).unwrap().volume;
assert!((ours - smlib).abs() < 1e-3 * smlib, "decoded volume {ours}, SMLib's {smlib}");
}
+4
View File
@@ -737,6 +737,10 @@ fn plan_face(
}
let mut all = uvs;
all.extend(extra);
// Both evaluators take a closed spline's parameters inside its active interval.
for q in &mut all {
*q = s.wrap(*q);
}
Ok(FacePlan { face: u32::try_from(fi).unwrap_or(u32::MAX), surface: None, uv: all, fixed, normals: Vec::new(), flip: false, triangles })
}
+6 -1
View File
@@ -158,7 +158,12 @@ max, whatever the oriented edge's direction. Its interval must equal the edge's
within 10⁻⁹ of the interval's magnitude (at least 1), room for a linear remap's
rounding. A seam edge has two uses on one face, each with its own pcurve, a
period apart. On a periodic surface a loop's pcurves are unwrapped, so each one
starts where the one before it ends.
starts where the one before it ends. A spline surface that closes on itself in a
direction (knots clamped there, first and last control rows the same points with
the same weights, within 10⁻⁹ of the control net's extent) is periodic in it for
pcurves and tessellation, one active knot interval per period: a seam on it has
its two uses an interval apart. The record is unchanged; a parameter outside the
interval is evaluated a whole number of periods back inside it.
Pcurves are SolidCraft's own, never a kernel's internal parameter space:
`brep::pcurve::pcurves` computes them from a face's surface record and each