T-spline core library and sheet bodies (#250, step 1) (#337)

* Add B-spline basis, knot insertion and NURBS surfaces to geom

Freeform faces need local refinement and gap-free joins between patches
with different knot vectors, which tensor B-spline grids cannot give;
T-splines can, and the first user is an N-sided Boundary Fill of
non-planar loops. This lays the univariate groundwork they rest on, in
the L0 geometry crate so it stays independent of the B-rep kernel:

- Cox-de Boor basis of one function from its local knot vector, on
  half-open spans with a left-limit side for the domain end, with
  derivatives (NURBS Book eq. 2.5, 2.9)
- knot insertion into a single basis function (Sederberg et al. 2004
  section 3) and Bezier extraction of one function by repeated insertion
- Boehm knot insertion for curves (NURBS Book A5.1), de Casteljau
  evaluation and sub-segments
- a rational tensor NURBS surface with evaluation, knot insertion and
  clamping, and a rational Bezier patch type

All input is checked (finite, monotone knots, positive weights, size
caps) and returns SplineError; nothing indexes unchecked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Add T-spline surfaces: T-mesh, evaluation, AS check, refinement, extraction

Tensor B-spline faces refine only by whole rows and columns and cannot
join patches with different knot vectors without gaps. T-splines allow
T-junctions in the control net, which fixes both; the first user is an
N-sided Boundary Fill. This adds the maths only (no command, no change
in behaviour):

- T-mesh in index space with knot values per line, validated (outer
  rectangle, rectangular faces, no empty lines, size caps); local knot
  vectors read off rays from each vertex, rays leaving the mesh repeat
  the boundary (Sederberg et al. 2003 section 3)
- rational T-spline evaluation and first derivatives for odd bi-degree,
  with a complete (p-1)/2-line frame and the domain inside it
- T-junction extensions and the analysis-suitability check (Scott et al.
  2012 section 2)
- local refinement that keeps the surface exactly: requested knot lines
  are cut in, then every original blending function is carried as a
  record and split (violation 1) or the mesh gains edges (violations 2
  and 3) until each record is a blending function of the refined mesh
  (Sederberg et al. 2004 section 3); analysis-suitable input stays
  analysis-suitable by carrying conflicting T-junctions across faces
- NURBS <-> T-spline conversion for meshes without T-junctions, and
  Bezier extraction over the rectangles no blending-function knot line
  crosses

Measured: regular T-spline vs NURBS < 1e-12, round trip exact, partition
of unity with T-junctions < 1e-14, refinement deviation <= 1.4e-14 over
40 random refinements, Bezier patches vs surface < 1e-14.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Convert T-splines to sewn NURBS sheet bodies in the kernel

A T-spline is only useful to the modeller once it is a B-rep face set.
tspline_sheet builds an open shell: a mesh without T-junctions becomes
one clamped NURBS face; otherwise each Bezier element is a face, and
sides are split at the T-points where neighbours' corners land, so all
faces share vertices and edges and the sheet is one oriented shell
(V - E + F = 1). Every truck call runs under guard. No command yet;
Boundary Fill will be the first caller.

Tests: one face for a regular surface, sewn Bezier faces with
T-junctions, tessellated points on the surface to ~1e-9 mm, and hostile
control nets (coincident, 1e300, needle-thin) never panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Iraklis KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 13:46:22 -04:00
1 parent 8e87a37511
commit a04ef4ca59
12 files changed
+2566 -3

No files matched your search

Generated
+1
View File
@@ -3351,6 +3351,7 @@ version = "0.4.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+812
View File
@@ -0,0 +1,812 @@
//! B-spline machinery shared by NURBS and T-spline surfaces: homogeneous points, the
//! univariate basis (Cox–de Boor on half-open intervals, with derivatives), Boehm knot
//! insertion, knot insertion into a single basis function, Bézier extraction, and a tensor
//! NURBS surface.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2 (basis definition, eq. 2.5),
//! §2.3 (derivatives, eq. 2.9), A2.1 (span search), §5.2 (knot insertion, eq. 5.10 / A5.1),
//! §1.4 (de Casteljau); Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline
//! simplification and local refinement", §3 (knot insertion into one blending function).
//!
//! Input is hostile: every constructor checks finiteness and sizes and returns
//! [`SplineError`] instead of panicking; no function here indexes with an unchecked number.
use std::ops::{Add, AddAssign, Mul, Sub};
use crate::Vec3;
/// Highest polynomial degree accepted in either direction.
pub const MAX_SPLINE_DEGREE: usize = 7;
/// Most knots accepted in one knot vector.
pub const MAX_KNOTS: usize = 1 << 14;
/// Most control points accepted in one tensor surface.
pub const MAX_SURFACE_POINTS: usize = 1 << 20;
/// Why a spline could not be built or used.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SplineError {
#[error("invalid spline: {0}")]
Invalid(String),
#[error("not supported yet: {0}")]
Unsupported(String),
#[error("spline too large: {0}")]
TooLarge(String),
}
pub type SplineResult<T> = Result<T, SplineError>;
pub(crate) fn invalid<T>(msg: impl Into<String>) -> SplineResult<T> {
Err(SplineError::Invalid(msg.into()))
}
/// A homogeneous control point `(x·w, y·w, z·w, w)`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct HPoint {
pub x: f64,
pub y: f64,
pub z: f64,
pub w: f64,
}
impl HPoint {
pub const ZERO: HPoint = HPoint { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
/// The point `p` with weight `w`.
pub fn weighted(p: Vec3, w: f64) -> HPoint {
HPoint { x: p.x * w, y: p.y * w, z: p.z * w, w }
}
/// The Cartesian point, if the weight is usable.
pub fn project(self) -> Option<Vec3> {
let p = Vec3::new(self.x / self.w, self.y / self.w, self.z / self.w);
(self.w.abs() > f64::MIN_POSITIVE && p.is_finite()).then_some(p)
}
/// The weighted coordinates `(x·w, y·w, z·w)`.
pub fn xyz(self) -> Vec3 {
Vec3::new(self.x, self.y, self.z)
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite() && self.w.is_finite()
}
}
impl Add for HPoint {
type Output = HPoint;
fn add(self, o: HPoint) -> HPoint {
HPoint { x: self.x + o.x, y: self.y + o.y, z: self.z + o.z, w: self.w + o.w }
}
}
impl Sub for HPoint {
type Output = HPoint;
fn sub(self, o: HPoint) -> HPoint {
HPoint { x: self.x - o.x, y: self.y - o.y, z: self.z - o.z, w: self.w - o.w }
}
}
impl Mul<f64> for HPoint {
type Output = HPoint;
fn mul(self, k: f64) -> HPoint {
HPoint { x: self.x * k, y: self.y * k, z: self.z * k, w: self.w * k }
}
}
impl AddAssign for HPoint {
fn add_assign(&mut self, o: HPoint) {
*self = *self + o;
}
}
/// A parameter direction: along s (u, the first) or along t (v, the second).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Dir {
S,
T,
}
impl Dir {
/// `s` for [`Dir::S`], `t` for [`Dir::T`].
pub fn pick<X>(self, s: X, t: X) -> X {
match self {
Dir::S => s,
Dir::T => t,
}
}
}
/// Homogeneous control points from finite points and positive weights, one each.
pub fn weighted_points(points: &[Vec3], weights: &[f64]) -> SplineResult<Vec<HPoint>> {
if points.len() != weights.len() {
return invalid("one weight per control point");
}
if points.iter().any(|p| !p.is_finite()) {
return invalid("a control point is not finite");
}
if weights.iter().any(|w| !(w.is_finite() && *w > 0.0)) {
return invalid("weights must be positive numbers");
}
Ok(points.iter().zip(weights).map(|(p, w)| HPoint::weighted(*p, *w)).collect())
}
/// Which one-sided limit a basis evaluation takes at a knot. Basis functions live on half-open
/// spans `[uᵢ, uᵢ₊₁)` ([`Side::Right`]); at the right end of a domain the left limit on
/// `(uᵢ, uᵢ₊₁]` ([`Side::Left`]) keeps the closing span.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Right,
Left,
}
/// Check a knot vector: finite, non-decreasing, not too long.
pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
if knots.len() > MAX_KNOTS {
return Err(SplineError::TooLarge(format!("{} knots (at most {MAX_KNOTS})", knots.len())));
}
if knots.iter().any(|k| !k.is_finite()) {
return invalid("a knot is not a finite number");
}
if knots.windows(2).any(|w| matches!(w, [a, b] if b < a)) {
return invalid("knots must not decrease");
}
Ok(())
}
/// One B-spline basis function of degree `p = knots.len() − 2` given by its local knot vector
/// `[ξ₀ … ξₚ₊₁]`, and its derivatives up to order `n`: element `k` is the `k`-th derivative at
/// `u`. Cox–de Boor recursion (NURBS Book eq. 2.5) with the 0/0 = 0 convention, derivatives by
/// eq. 2.9 applied repeatedly (`dᵏNᵢ,ₚ = p·(dᵏ⁻¹Nᵢ,ₚ₋₁/(uᵢ₊ₚ−uᵢ) − dᵏ⁻¹Nᵢ₊₁,ₚ₋₁/(uᵢ₊ₚ₊₁−uᵢ₊₁))`).
/// A knot vector shorter than two knots gives zeros.
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
let mut out = vec![0.0; n + 1];
let Some(p) = knots.len().checked_sub(2) else { return out };
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
return out;
}
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
let ratio = |num: f64, den: f64| if den > 0.0 { num / den } else { 0.0 };
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
const W: usize = MAX_SPLINE_DEGREE + 1;
let levels = n.min(p);
let mut table = [[0.0f64; W]; W];
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
let (a, b) = (kn(j), kn(j + 1));
let inside = match side {
Side::Right => a <= u && u < b,
Side::Left => a < u && u <= b,
};
*slot = if inside { 1.0 } else { 0.0 };
}
for q in 1..=p {
for k in (0..=levels).rev() {
for j in 0..=p - q {
let (d1, d2) = (kn(j + q) - kn(j), kn(j + q + 1) - kn(j + 1));
let at = |k: usize, j: usize| table.get(k).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let v = if k == 0 {
ratio(u - kn(j), d1) * at(0, j) + ratio(kn(j + q + 1) - u, d2) * at(0, j + 1)
} else {
q as f64 * (ratio(at(k - 1, j), d1) - ratio(at(k - 1, j + 1), d2))
};
if let Some(slot) = table.get_mut(k).and_then(|r| r.get_mut(j)) {
*slot = v;
}
}
}
}
for (k, o) in out.iter_mut().enumerate().take(levels + 1) {
*o = table.get(k).map_or(0.0, |r| r[0]);
}
out
}
/// The coefficients `(c₁, c₂)` of knot insertion into one basis function: inserting `k`
/// (with `ξ₀ ≤ k ≤ ξₚ₊₁`) into `N[ξ₀…ξₚ₊₁]` gives `c₁·N[first p+2 merged knots] +
/// c₂·N[last p+2 merged knots]` (Sederberg et al. 2004, §3; the single-function case of the
/// NURBS Book eq. 5.10). `None` when `k` is outside the support or the knots are too few.
pub fn split_basis(knots: &[f64], k: f64) -> Option<(f64, f64)> {
let p = knots.len().checked_sub(2)?;
let (x0, x1, xp, xl) = (*knots.first()?, *knots.get(1)?, *knots.get(p)?, *knots.get(p + 1)?);
if !(k >= x0 && k <= xl) {
return None;
}
let c1 = if k >= xp { 1.0 } else { (k - x0) / (xp - x0) };
let c2 = if k <= x1 { 1.0 } else { (xl - k) / (xl - x1) };
Some((c1, c2))
}
/// The two knot vectors of a split basis function (see [`split_basis`]): `k` merged into
/// `knots` in order, then the first and the last `knots.len()` entries.
pub fn split_knots<T: Copy + PartialOrd>(knots: &[T], k: T) -> [Vec<T>; 2] {
let at = knots.iter().position(|&x| x > k).unwrap_or(knots.len());
let mut merged = knots.to_vec();
merged.insert(at, k);
let n = knots.len();
[merged.get(..n).map(<[T]>::to_vec).unwrap_or_default(), merged.get(1..).map(<[T]>::to_vec).unwrap_or_default()]
}
/// The Bernstein coefficients of one basis function `N[knots]` on `[a, b]`: `N(u) =
/// Σ cᵢ Bᵢ,ₚ((u−a)/(b−a))` there. `[a, b]` must not contain a knot in its interior (the
/// function is one polynomial on it). Found by inserting `a` and `b` into the function until
/// every surviving piece is a Bernstein polynomial `N[a…a, b…b]` (Sederberg et al. 2004, §3;
/// NURBS Book §5.4, decomposition into Bézier segments).
pub fn basis_bezier(knots: &[f64], a: f64, b: f64) -> SplineResult<Vec<f64>> {
let Some(p) = knots.len().checked_sub(2) else { return invalid("a basis function needs at least two knots") };
if p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (at most {MAX_SPLINE_DEGREE})")));
}
check_knots(knots)?;
if !(a.is_finite() && b.is_finite() && a < b) {
return invalid("a Bézier interval must be finite and non-empty");
}
if knots.iter().any(|&x| x > a && x < b) {
return invalid("a knot lies inside the Bézier interval");
}
let mut out = vec![0.0; p + 1];
let mut work = vec![(knots.to_vec(), 1.0)];
let mut steps = 0usize;
while let Some((kn, c)) = work.pop() {
steps += 1;
if steps > 1 << 12 {
return Err(SplineError::TooLarge("Bézier extraction did not converge".into()));
}
let (Some(&lo), Some(&hi)) = (kn.first(), kn.last()) else { continue };
if hi <= a || lo >= b || c == 0.0 {
continue;
}
let insert = if kn.iter().any(|&x| x < a) {
Some(a)
} else if kn.iter().any(|&x| x > b) {
Some(b)
} else {
None
};
match insert {
Some(k) => {
let (c1, c2) = split_basis(&kn, k).ok_or_else(|| SplineError::Invalid("knot insertion outside the support".into()))?;
let [first, second] = split_knots(&kn, k);
work.push((first, c * c1));
work.push((second, c * c2));
}
None => {
// Knots a×(p+1−i), b×(i+1): the Bernstein polynomial Bᵢ,ₚ.
let nb = kn.iter().filter(|&&x| x == b).count();
if let Some(slot) = nb.checked_sub(1).and_then(|i| out.get_mut(i)) {
*slot += c;
}
}
}
}
Ok(out)
}
/// The span index `k` with `knots[k] ≤ u < knots[k+1]` among the valid spans `p ≤ k < n`
/// (`n` control points), the last span closed at the domain end (NURBS Book A2.1).
pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
if n <= p || knots.len() != n + p + 1 || !u.is_finite() {
return None;
}
let (lo, hi) = (*knots.get(p)?, *knots.get(n)?);
if u < lo || u > hi {
return None;
}
// The last non-empty span in [p, n−1] that starts at or before u.
let mut k = p;
for i in p..n {
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
&& a <= u
&& a < b
{
k = i;
}
}
Some(k)
}
/// Insert the knot `u` once into a B-spline with degree `p`, knots and homogeneous control
/// points (Boehm; NURBS Book eq. 5.10 / A5.1). `u` must lie in the domain `[knots[p],
/// knots[n]]`; the curve is unchanged.
pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
let n = ctrl.len();
if n <= p || knots.len() != n + p + 1 {
return invalid("knot and control point counts do not match the degree");
}
if knots.len() >= MAX_KNOTS {
return Err(SplineError::TooLarge("too many knots".into()));
}
check_knots(knots)?;
let k = find_span(knots, p, n, u).ok_or_else(|| SplineError::Invalid(format!("knot {u} is outside the domain")))?;
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
let pt = |i: usize| ctrl.get(i).copied().unwrap_or(HPoint::ZERO);
let mut q = Vec::with_capacity(n + 1);
for i in 0..=n {
q.push(if i + p <= k {
pt(i)
} else if i > k {
pt(i - 1)
} else {
let den = kn(i + p) - kn(i);
let alpha = if den > 0.0 { (u - kn(i)) / den } else { 1.0 };
pt(i) * alpha + pt(i - 1) * (1.0 - alpha)
});
}
let mut nk = knots.to_vec();
nk.insert(k + 1, u);
Ok((nk, q))
}
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
if i > p {
return 0.0;
}
let mut c = 1.0;
for m in 0..i {
c = c * (p - m) as f64 / (m + 1) as f64;
}
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
}
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
pub fn bezier_point(ctrl: &[HPoint], x: f64) -> HPoint {
subdivide(ctrl, x).0.last().copied().unwrap_or(HPoint::ZERO)
}
/// The control points of the piece of a Bézier curve over `[a, b] ⊂ [0, 1]` (two de Casteljau
/// subdivisions).
pub fn bezier_segment(ctrl: &[HPoint], a: f64, b: f64) -> SplineResult<Vec<HPoint>> {
if !(a.is_finite() && b.is_finite() && 0.0 <= a && a < b && b <= 1.0) {
return invalid("a Bézier segment must satisfy 0 ≤ a < b ≤ 1");
}
// Left part [0, b], then its right part from a/b.
let left = subdivide(ctrl, b).0;
Ok(subdivide(&left, a / b).1)
}
/// Split a Bézier curve at `x` (de Casteljau): control points of `[0, x]` and `[x, 1]`.
fn subdivide(ctrl: &[HPoint], x: f64) -> (Vec<HPoint>, Vec<HPoint>) {
let mut left = Vec::with_capacity(ctrl.len());
let mut right = Vec::with_capacity(ctrl.len());
let mut pts = ctrl.to_vec();
while let (Some(&f), Some(&l)) = (pts.first(), pts.last()) {
left.push(f);
right.push(l);
pts = pts.windows(2).map(|w| if let [a, b] = w { *a * (1.0 - x) + *b * x } else { HPoint::ZERO }).collect();
}
right.reverse();
(left, right)
}
/// A rational tensor Bézier patch over the parameter rectangle `s × t`: control point `(i, j)`
/// (i along s, j along t) at `ctrl[j·(degree[0]+1) + i]`.
#[derive(Clone, Debug, PartialEq)]
pub struct BezierPatch {
pub s: [f64; 2],
pub t: [f64; 2],
pub degree: [usize; 2],
pub ctrl: Vec<HPoint>,
}
impl BezierPatch {
/// Control point `(i, j)`.
pub fn point(&self, i: usize, j: usize) -> HPoint {
self.ctrl.get(j * (self.degree[0] + 1) + i).copied().unwrap_or(HPoint::ZERO)
}
/// Homogeneous point at global parameters `(s, t)`.
pub fn eval_h(&self, s: f64, t: f64) -> HPoint {
let x = (s - self.s[0]) / (self.s[1] - self.s[0]);
let y = (t - self.t[0]) / (self.t[1] - self.t[0]);
let mut acc = HPoint::ZERO;
for j in 0..=self.degree[1] {
let bj = bernstein(self.degree[1], j, y);
for i in 0..=self.degree[0] {
acc += self.point(i, j) * (bernstein(self.degree[0], i, x) * bj);
}
}
acc
}
/// Point at global parameters `(s, t)`.
pub fn eval(&self, s: f64, t: f64) -> Option<Vec3> {
self.eval_h(s, t).project()
}
/// The control points of one side as a Bézier curve: `0` t = t₀ (s rising), `1` s = s₁
/// (t rising), `2` t = t₁ (s rising), `3` s = s₀ (t rising).
pub fn side(&self, side: usize) -> Vec<HPoint> {
let [ps, pt] = self.degree;
match side {
0 => (0..=ps).map(|i| self.point(i, 0)).collect(),
1 => (0..=pt).map(|j| self.point(ps, j)).collect(),
2 => (0..=ps).map(|i| self.point(i, pt)).collect(),
_ => (0..=pt).map(|j| self.point(0, j)).collect(),
}
}
}
/// A rational tensor-product B-spline (NURBS) surface. Control point `(i, j)` (i along u, j
/// along v) is `ctrl[j·count_u + i]`; the domain is `[knots_u[p], knots_u[n]] × …`.
#[derive(Clone, Debug, PartialEq)]
pub struct NurbsSurface {
degree: [usize; 2],
knots: [Vec<f64>; 2],
count: [usize; 2],
ctrl: Vec<HPoint>,
}
impl NurbsSurface {
/// A surface from Cartesian points and positive weights (one per point, u fastest).
pub fn new(degree: [usize; 2], knots: [Vec<f64>; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<NurbsSurface> {
Self::from_homogeneous(degree, knots, weighted_points(points, weights)?)
}
/// A surface from homogeneous control points (u fastest).
pub fn from_homogeneous(degree: [usize; 2], knots: [Vec<f64>; 2], ctrl: Vec<HPoint>) -> SplineResult<NurbsSurface> {
let mut count = [0; 2];
for d in 0..2 {
let (p, kn) = (degree[d], &knots[d]);
if p == 0 || p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
}
check_knots(kn)?;
let n = kn.len().checked_sub(p + 1).filter(|&n| n > p).ok_or_else(|| SplineError::Invalid("too few knots for the degree".into()))?;
if !(kn.get(p) < kn.get(n)) {
return invalid("the surface's domain is empty");
}
count[d] = n;
}
if count[0].checked_mul(count[1]).is_none_or(|c| c > MAX_SURFACE_POINTS) {
return Err(SplineError::TooLarge(format!("more than {MAX_SURFACE_POINTS} control points")));
}
if ctrl.len() != count[0] * count[1] {
return invalid(format!("{} control points, expected {}", ctrl.len(), count[0] * count[1]));
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
return invalid("control points must be finite with positive weights");
}
Ok(NurbsSurface { degree, knots, count, ctrl })
}
pub fn degree(&self) -> [usize; 2] {
self.degree
}
pub fn knots(&self) -> &[Vec<f64>; 2] {
&self.knots
}
/// Control point counts along u and v.
pub fn count(&self) -> [usize; 2] {
self.count
}
pub fn homogeneous(&self) -> &[HPoint] {
&self.ctrl
}
/// Control point `(i, j)`.
pub fn point(&self, i: usize, j: usize) -> HPoint {
self.ctrl.get(j * self.count[0] + i).copied().unwrap_or(HPoint::ZERO)
}
/// The parameter domain `[[u₀, u₁], [v₀, v₁]]`.
pub fn domain(&self) -> [[f64; 2]; 2] {
let d = |k: usize| {
let (p, n, kn) = (self.degree[k], self.count[k], &self.knots[k]);
[kn.get(p).copied().unwrap_or(0.0), kn.get(n).copied().unwrap_or(0.0)]
};
[d(0), d(1)]
}
/// The point at `(u, v)`, which must lie in the domain.
pub fn eval(&self, u: f64, v: f64) -> SplineResult<Vec3> {
let dom = self.domain();
let mut spans = [0; 2];
let mut basis: [Vec<f64>; 2] = [Vec::new(), Vec::new()];
for (d, x) in [u, v].into_iter().enumerate() {
let (p, n, kn) = (self.degree[d], self.count[d], &self.knots[d]);
let span = find_span(kn, p, n, x).ok_or_else(|| SplineError::Invalid(format!("({u}, {v}) is outside the domain")))?;
let side = if x >= dom[d][1] { Side::Left } else { Side::Right };
spans[d] = span;
basis[d] = (span - p..=span).map(|i| kn.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, x, side, 0)[0])).collect();
}
let mut acc = HPoint::ZERO;
for (b, nj) in basis[1].iter().enumerate() {
for (a, ni) in basis[0].iter().enumerate() {
acc += self.point(spans[0] - self.degree[0] + a, spans[1] - self.degree[1] + b) * (ni * nj);
}
}
acc.project().ok_or_else(|| SplineError::Invalid("the surface's weights vanish there".into()))
}
/// The same surface with the knot `x` inserted once in direction `dir` ([`Dir::S`] = u).
pub fn insert_knot(&self, dir: Dir, x: f64) -> SplineResult<NurbsSurface> {
let d = dir.pick(0, 1);
let (p, kn) = (self.degree[d], &self.knots[d]);
let [nu, nv] = self.count;
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
let at = |line: usize, k: usize| if d == 0 { self.point(k, line) } else { self.point(line, k) };
let mut new_knots = Vec::new();
let mut rows: Vec<Vec<HPoint>> = Vec::with_capacity(lines);
for line in 0..lines {
let row: Vec<HPoint> = (0..len).map(|k| at(line, k)).collect();
let (nk, nr) = insert_knot(p, kn, &row, x)?;
new_knots = nk;
rows.push(nr);
}
let mut knots = self.knots.clone();
knots[d] = new_knots;
let new_len = len + 1;
let mut ctrl = Vec::with_capacity(lines * new_len);
if d == 0 {
for row in &rows {
ctrl.extend_from_slice(row);
}
} else {
for j in 0..new_len {
for row in &rows {
ctrl.push(row.get(j).copied().unwrap_or(HPoint::ZERO));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
/// each end, so the boundary curves are given by the boundary rows of control points):
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
/// does not depend on its first knot, so that knot can be moved to the domain end).
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
let mut s = self.clone();
for dir in [Dir::S, Dir::T] {
let d = dir.pick(0, 1);
let p = s.degree[d];
for end in 0..2 {
let a = s.domain()[d][end];
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
s = s.insert_knot(dir, a)?;
}
s = s.trim_end(d, end, a)?;
}
}
Ok(s)
}
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
/// at the start (`end == 0`) or the end of direction `d`.
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
let p = self.degree[d];
let kn = &self.knots[d];
let n = self.count[d];
let (first, last) = match end {
0 => {
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
(last_a.saturating_sub(p), n)
}
_ => {
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i ≥ first_a vanish on (…, a].
(0, first_a.min(n))
}
};
if last <= first + p {
return invalid("clamp: too few control points remain");
}
let mut knots = self.knots.clone();
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
match end {
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
}
knots[d] = nk;
let [nu, nv] = self.count;
let mut ctrl = Vec::new();
for j in 0..nv {
for i in 0..nu {
let k = if d == 0 { i } else { j };
if k >= first && k < last {
ctrl.push(self.point(i, j));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A small deterministic generator for test data.
pub(crate) struct Lcg(pub(crate) u64);
impl Lcg {
pub(crate) fn unit(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
}
const KNOTS: [f64; 12] = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 1.0, 1.0, 1.0, 1.0];
fn samples() -> Vec<f64> {
(0..=200).map(|k| k as f64 / 200.0).collect()
}
#[test]
fn basis_partition_of_unity_non_negative_compact_support() {
let p = 3;
let n = KNOTS.len() - p - 1;
for u in samples() {
let side = if u >= 1.0 { Side::Left } else { Side::Right };
let mut sum = 0.0;
for i in 0..n {
let local = &KNOTS[i..i + p + 2];
let b = local_basis(local, u, side, 0)[0];
assert!(b >= -1e-15, "N{i}({u}) = {b}");
if u < local[0] || u > local[p + 1] {
assert_eq!(b, 0.0, "N{i} is not zero outside its support at {u}");
}
sum += b;
}
assert!((sum - 1.0).abs() < 1e-14, "Σ N({u}) = {sum}");
}
}
#[test]
fn basis_derivatives_match_finite_differences() {
let p = 3;
let h = 1e-6;
let mut worst = 0.0f64;
for i in 0..KNOTS.len() - p - 1 {
let local = &KNOTS[i..i + p + 2];
for u in samples() {
if KNOTS.iter().any(|k| (k - u).abs() < 2.0 * h) {
continue;
}
let d = local_basis(local, u, Side::Right, 2);
let f = |x: f64| local_basis(local, x, Side::Right, 0)[0];
let fd1 = (f(u + h) - f(u - h)) / (2.0 * h);
let df = |x: f64| local_basis(local, x, Side::Right, 1)[1];
let fd2 = (df(u + h) - df(u - h)) / (2.0 * h);
worst = worst.max((d[1] - fd1).abs()).max((d[2] - fd2).abs() / 100.0);
}
}
assert!(worst < 1e-5, "derivative error {worst}");
}
#[test]
fn single_function_knot_insertion_is_exact() {
let mut rng = Lcg(7);
for _ in 0..200 {
let mut kn: Vec<f64> = (0..5).map(|_| (rng.unit() * 8.0).round() / 8.0).collect();
kn.sort_by(f64::total_cmp);
if kn[0] == kn[4] {
continue;
}
let k = kn[0] + (kn[4] - kn[0]) * (rng.unit() * 8.0).round() / 8.0;
let (c1, c2) = split_basis(&kn, k).unwrap();
let mut merged = kn.clone();
let at = merged.iter().position(|&x| x > k).unwrap_or(5);
merged.insert(at, k);
for u in samples() {
let u = kn[0] - 0.1 + u * (kn[4] - kn[0] + 0.2);
let f = |k: &[f64]| local_basis(k, u, Side::Right, 0)[0];
let lhs = f(&kn);
let rhs = c1 * f(&merged[..5]) + c2 * f(&merged[1..]);
assert!((lhs - rhs).abs() < 1e-14, "{kn:?} insert {k} at {u}: {lhs} vs {rhs}");
}
}
}
#[test]
fn basis_bezier_coefficients_reproduce_the_function() {
let p = 3;
let spans: Vec<(f64, f64)> = KNOTS.windows(2).filter(|w| w[0] < w[1]).map(|w| (w[0], w[1])).collect();
for i in 0..KNOTS.len() - p - 1 {
let local = &KNOTS[i..i + p + 2];
for &(a, b) in &spans {
let c = basis_bezier(local, a, b).unwrap();
for k in 0..=20 {
let x = k as f64 / 20.0;
let u = a + (b - a) * x;
let side = if x >= 1.0 { Side::Left } else { Side::Right };
let want = local_basis(local, u, side, 0)[0];
let got: f64 = c.iter().enumerate().map(|(j, cj)| cj * bernstein(p, j, x)).sum();
assert!((want - got).abs() < 1e-14, "N{i} on [{a}, {b}] at {u}: {want} vs {got}");
}
}
}
assert!(basis_bezier(&KNOTS[..5], 0.0, 0.5).is_err(), "a knot inside the interval is refused");
assert!(basis_bezier(&KNOTS[..5], f64::NAN, 0.5).is_err());
}
fn curve(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> Vec3 {
let n = ctrl.len();
let side = if u >= knots[n] { Side::Left } else { Side::Right };
let mut acc = HPoint::ZERO;
for (i, c) in ctrl.iter().enumerate() {
acc += *c * local_basis(&knots[i..i + p + 2], u, side, 0)[0];
}
acc.project().unwrap()
}
#[test]
fn boehm_insertion_keeps_the_curve() {
let p = 3;
let mut rng = Lcg(3);
let ctrl: Vec<HPoint> = (0..8).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0, 0.5 + rng.unit())).collect();
let (mut kn, mut pts) = (KNOTS.to_vec(), ctrl.clone());
for u in [0.0, 0.1, 0.3, 0.3, 0.77, 1.0, 1.0] {
(kn, pts) = insert_knot(p, &kn, &pts, u).unwrap();
}
let mut worst = 0.0f64;
for u in samples() {
worst = worst.max(curve(p, &KNOTS, &ctrl, u).dist(curve(p, &kn, &pts, u)));
}
assert!(worst < 1e-13, "deviation {worst}");
assert!(insert_knot(p, &KNOTS, &ctrl, 1.5).is_err());
assert!(insert_knot(p, &KNOTS, &ctrl, f64::NAN).is_err());
}
pub(crate) fn random_surface(seed: u64, ku: Vec<f64>, kv: Vec<f64>, degree: [usize; 2]) -> NurbsSurface {
let mut rng = Lcg(seed);
let n = (ku.len() - degree[0] - 1) * (kv.len() - degree[1] - 1);
let pts: Vec<Vec3> = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
let w: Vec<f64> = (0..n).map(|_| 0.5 + rng.unit()).collect();
NurbsSurface::new(degree, [ku, kv], &pts, &w).unwrap()
}
fn max_deviation(a: &NurbsSurface, b: &NurbsSurface) -> f64 {
let [du, dv] = a.domain();
let mut worst = 0.0f64;
for i in 0..=30 {
for j in 0..=30 {
let u = du[0] + (du[1] - du[0]) * i as f64 / 30.0;
let v = dv[0] + (dv[1] - dv[0]) * j as f64 / 30.0;
worst = worst.max(a.eval(u, v).unwrap().dist(b.eval(u, v).unwrap()));
}
}
worst
}
#[test]
fn surface_knot_insertion_and_clamping_keep_the_surface() {
let uniform: Vec<f64> = (0..10).map(|k| k as f64).collect();
let s = random_surface(11, uniform.clone(), (0..9).map(|k| k as f64 * 0.5).collect(), [3, 3]);
let r = s.insert_knot(Dir::S, 4.5).unwrap().insert_knot(Dir::T, 2.25).unwrap();
assert!(max_deviation(&s, &r) < 1e-12);
let c = s.clamped().unwrap();
assert_eq!(c.domain(), s.domain());
assert_eq!(&c.knots()[0][..4], &[3.0; 4]);
assert_eq!(&c.knots()[1][c.knots()[1].len() - 4..], &[2.5; 4]);
let dev = max_deviation(&s, &c);
assert!(dev < 1e-12, "clamping moved the surface by {dev}");
// A clamped surface interpolates its corner control points.
let corner = c.eval(3.0, 1.5).unwrap();
assert!(corner.dist(c.point(0, 0).project().unwrap()) < 1e-12);
}
#[test]
fn bezier_segments_lie_on_the_curve() {
let mut rng = Lcg(5);
let ctrl: Vec<HPoint> = (0..4).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), 0.0), 0.5 + rng.unit())).collect();
let seg = bezier_segment(&ctrl, 0.2, 0.7).unwrap();
for k in 0..=10 {
let x = k as f64 / 10.0;
let want = bezier_point(&ctrl, 0.2 + 0.5 * x).project().unwrap();
assert!(bezier_point(&seg, x).project().unwrap().dist(want) < 1e-14);
}
assert!(bezier_segment(&ctrl, 0.7, 0.2).is_err());
}
#[test]
fn hostile_spline_input_is_refused() {
let pts = [Vec3::ZERO; 16];
let w = [1.0; 16];
let k = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &w).is_ok());
assert!(NurbsSurface::new([3, 3], [vec![0.0, f64::NAN, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &[0.0; 16]).is_err());
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts[..15], &w[..15]).is_err());
assert!(NurbsSurface::new([0, 3], [k.clone(), k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([9, 3], [k.clone(), k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([3, 3], [vec![1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
assert!(check_knots(&vec![0.0; MAX_KNOTS + 1]).is_err());
let s = NurbsSurface::new([3, 3], [k.clone(), k], &pts, &w).unwrap();
assert!(s.eval(2.0, 0.5).is_err());
assert!(s.eval(f64::NAN, 0.5).is_err());
assert_eq!(local_basis(&[], 0.5, Side::Right, 1), vec![0.0, 0.0]);
}
}
+6 -2
View File
@@ -1,14 +1,18 @@
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes and
//! triangle meshes with their measures (volume, area, centroid). Units are millimetres.
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
//! millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod bspline;
mod mesh;
mod plane;
mod profile;
pub mod tspline;
mod vec;
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
+148
View File
@@ -0,0 +1,148 @@
//! Conversions: a tensor NURBS surface as a T-spline and back (a T-mesh without T-junctions is
//! a tensor B-spline; 2003 §3 and §5), and Bézier extraction — the surface as rational Bézier
//! patches over the rectangles where every blending function is one polynomial.
use super::mesh::{Grid, TMesh};
use super::{Blend, TSpline, frame};
use crate::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, SplineResult, basis_bezier, invalid};
/// Most unit-edge marks and coefficient products in one Bézier extraction.
const MAX_EXTRACT_WORK: usize = 1 << 26;
impl TSpline {
/// A tensor NURBS surface of odd bi-degree as a T-spline on the full grid: column `i` of the
/// mesh is knot `i + (p+1)/2` of the knot vector, so the blending function at a vertex is
/// the B-spline basis function of that control point (rays that leave the mesh repeat the
/// boundary line, 2003 §3). Knot vectors whose first and last `(p+1)/2 + 1` knots are not
/// equal are clamped first (same surface on the same domain).
pub fn from_nurbs(n: &NurbsSurface) -> SplineResult<TSpline> {
let degree = n.degree();
if degree.iter().any(|p| p % 2 == 0) {
return Err(SplineError::Unsupported("a NURBS surface of even degree as a T-spline".into()));
}
let ends_ok = |k: &[f64], p: usize| {
let h = p.div_ceil(2);
let same = |w: Option<&[f64]>| w.is_some_and(|w| w.windows(2).all(|x| matches!(x, [a, b] if a == b)));
same(k.get(..=h)) && same(k.len().checked_sub(h + 1).and_then(|s| k.get(s..)))
};
let n = if (0..2).all(|d| ends_ok(&n.knots()[d], degree[d])) { n.clone() } else { n.clamped()? };
let lines = |d: usize| -> Vec<f64> {
let h = degree[d].div_ceil(2);
let k = &n.knots()[d];
k.get(h..k.len().saturating_sub(h)).map(<[f64]>::to_vec).unwrap_or_default()
};
let mesh = TMesh::tensor(lines(0), lines(1))?;
TSpline::from_homogeneous(mesh, degree, n.homogeneous().to_vec())
}
/// The T-spline as a tensor NURBS surface, when its mesh has no T-junctions.
pub fn to_nurbs(&self) -> SplineResult<NurbsSurface> {
if !self.mesh.is_regular() {
return Err(SplineError::Unsupported("a T-mesh with T-junctions as one tensor surface".into()));
}
let pad = |k: &[f64], p: usize| -> Vec<f64> {
let h = p.div_ceil(2);
let (first, last) = (k.first().copied().unwrap_or(0.0), k.last().copied().unwrap_or(0.0));
std::iter::repeat_n(first, h).chain(k.iter().copied()).chain(std::iter::repeat_n(last, h)).collect()
};
let g = &self.mesh.grid;
NurbsSurface::from_homogeneous(self.degree, [pad(&g.s, self.degree[0]), pad(&g.t, self.degree[1])], self.ctrl.clone())
}
/// The surface as rational Bézier patches over rectangles of the domain, inside each of
/// which every blending function is one polynomial: the knot lines of all blending
/// functions (each function's s-knots across its t-support and t-knots across its
/// s-support) are marked on the index grid and the domain's cells are gathered into
/// rectangles that no marked line crosses. Each function's Bernstein coefficients on a
/// rectangle come from knot insertion ([`basis_bezier`]). Patches tile the domain and meet
/// at T-points where their corners land on a neighbour's side.
pub fn bezier_elements(&self) -> SplineResult<Vec<BezierPatch>> {
let g = &self.mesh.grid;
let marks = knot_lines(g, &self.blends)?;
let [cs, ct] = frame(self.degree);
let (ns, nt) = (g.ns(), g.nt());
let (ci_end, cj_end) = (ns - 1 - cs, nt - 1 - ct);
let mut taken = vec![false; ns * nt];
let is_taken = |taken: &[bool], i: usize, j: usize| taken.get(j * ns + i).copied().unwrap_or(true);
let mut rects = Vec::new();
for cj in ct..cj_end {
for ci in cs..ci_end {
if is_taken(&taken, ci, cj) {
continue;
}
let mut i1 = ci + 1;
while i1 < ci_end && !is_taken(&taken, i1, cj) && !marks.v(i1, cj) {
i1 += 1;
}
let mut j1 = cj + 1;
while j1 < cj_end && (ci..i1).all(|x| !is_taken(&taken, x, j1) && !marks.h(x, j1)) && (ci + 1..i1).all(|x| !marks.v(x, j1)) {
j1 += 1;
}
for y in cj..j1 {
for x in ci..i1 {
if let Some(c) = taken.get_mut(y * ns + x) {
*c = true;
}
}
}
rects.push((ci, i1, cj, j1));
}
}
let val = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
let [ps, pt] = self.degree;
let mut work = 0usize;
let mut out = Vec::new();
for (i0, i1, j0, j1) in rects {
let (s0, s1, t0, t1) = (val(&g.s, i0), val(&g.s, i1), val(&g.t, j0), val(&g.t, j1));
if !(s0 < s1 && t0 < t1) {
continue;
}
let mut ctrl = vec![HPoint::ZERO; (ps + 1) * (pt + 1)];
for (b, c) in self.blends.iter().zip(&self.ctrl) {
let [[bs0, bs1], [bt0, bt1]] = b.support();
if !(s1.min(bs1) > s0.max(bs0) && t1.min(bt1) > t0.max(bt0)) {
continue;
}
work += ctrl.len();
if work > MAX_EXTRACT_WORK {
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
}
let cs = basis_bezier(&b.sv, s0, s1)?;
let ct = basis_bezier(&b.tv, t0, t1)?;
for (j, bj) in ct.iter().enumerate() {
for (i, bi) in cs.iter().enumerate() {
if let Some(slot) = ctrl.get_mut(j * (ps + 1) + i) {
*slot += *c * (bi * bj);
}
}
}
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
return invalid("a Bézier patch has a non-positive weight");
}
out.push(BezierPatch { s: [s0, s1], t: [t0, t1], degree: self.degree, ctrl });
}
Ok(out)
}
}
/// The unit edges of the index grid that carry a knot line of some blending function: each
/// function's s-knots across its t-support and its t-knots across its s-support.
fn knot_lines(g: &Grid, blends: &[Blend]) -> SplineResult<Grid> {
let mut m = Grid::empty(g.s.clone(), g.t.clone())?;
let mut work = 0usize;
for b in blends {
let (Some(&s0), Some(&s1), Some(&t0), Some(&t1)) = (b.s.first(), b.s.last(), b.t.first(), b.t.last()) else { continue };
work += (b.s.len() * (t1 - t0)) + (b.t.len() * (s1 - s0));
if work > MAX_EXTRACT_WORK {
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
}
for &x in &b.s {
(t0..t1).for_each(|y| m.set_v(x, y));
}
for &y in &b.t {
(s0..s1).for_each(|x| m.set_h(x, y));
}
}
Ok(m)
}
+442
View File
@@ -0,0 +1,442 @@
//! The T-mesh in index space (Sederberg, Zheng, Bakenov, Nasri 2003, §3; Scott, Li,
//! Sederberg, Hughes 2012, §2): a rectangle of index lines — columns `0..ns` carrying the
//! non-decreasing knot values `s[i]`, rows `0..nt` carrying `t[j]` — and the unit edges of
//! that grid that are present. Equal knot values on neighbouring lines give zero-length knot
//! intervals (repeated knots), which is how clamped boundaries and multiple knots are written.
//!
//! A valid T-mesh has its outer rectangle, rectangular faces (every grid point is empty, an
//! edge passing straight through, a T-junction, a crossing, or one of the four outer
//! corners), and at least one edge on every index line. A *vertex* is a point on both a column
//! edge and a row edge; every vertex carries a control point.
use crate::bspline::{Dir, SplineError, SplineResult, check_knots, invalid};
/// Most index lines in either direction.
pub const MAX_INDEX_LINES: usize = 1 << 12;
/// Most grid points (`ns · nt`).
pub const MAX_GRID_POINTS: usize = 1 << 20;
/// A run of edges on one index line, given to [`TMesh::from_segments`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Segment {
/// Edges on column `i` (constant s) from row `j0` to row `j1`.
Column { i: usize, j0: usize, j1: usize },
/// Edges on row `j` (constant t) from column `i0` to column `i1`.
Row { j: usize, i0: usize, i1: usize },
}
/// A rectangle in index space, `[i0, i1] × [j0, j1]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IndexRect {
pub i0: usize,
pub i1: usize,
pub j0: usize,
pub j1: usize,
}
/// The index lines and the unit edges present on them; no validity implied.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Grid {
pub(crate) s: Vec<f64>,
pub(crate) t: Vec<f64>,
/// Row edge `(i, j)–(i+1, j)` at `j·(ns−1) + i`.
h: Vec<bool>,
/// Column edge `(i, j)–(i, j+1)` at `i·(nt−1) + j`.
v: Vec<bool>,
}
impl Grid {
/// An empty grid (no edges) after checking sizes and knots.
pub(crate) fn empty(s: Vec<f64>, t: Vec<f64>) -> SplineResult<Grid> {
for k in [&s, &t] {
if k.len() < 2 {
return invalid("a T-mesh needs at least two index lines each way");
}
if k.len() > MAX_INDEX_LINES {
return Err(SplineError::TooLarge(format!("{} index lines (at most {MAX_INDEX_LINES})", k.len())));
}
check_knots(k)?;
}
if s.len() * t.len() > MAX_GRID_POINTS {
return Err(SplineError::TooLarge(format!("{} grid points (at most {MAX_GRID_POINTS})", s.len() * t.len())));
}
let (ns, nt) = (s.len(), t.len());
Ok(Grid { h: vec![false; (ns - 1) * nt], v: vec![false; ns * (nt - 1)], s, t })
}
pub(crate) fn ns(&self) -> usize {
self.s.len()
}
pub(crate) fn nt(&self) -> usize {
self.t.len()
}
/// Row edge from `(i, j)` to `(i+1, j)`.
pub(crate) fn h(&self, i: usize, j: usize) -> bool {
i + 1 < self.ns() && self.h.get(j * (self.ns() - 1) + i).copied().unwrap_or(false)
}
/// Column edge from `(i, j)` to `(i, j+1)`.
pub(crate) fn v(&self, i: usize, j: usize) -> bool {
j + 1 < self.nt() && self.v.get(i * (self.nt() - 1) + j).copied().unwrap_or(false)
}
pub(crate) fn set_h(&mut self, i: usize, j: usize) {
let ns = self.ns();
if i + 1 < ns
&& let Some(e) = self.h.get_mut(j * (ns - 1) + i)
{
*e = true;
}
}
pub(crate) fn set_v(&mut self, i: usize, j: usize) {
let nt = self.nt();
if j + 1 < nt
&& let Some(e) = self.v.get_mut(i * (nt - 1) + j)
{
*e = true;
}
}
/// The point `(i, j)` lies on a column edge (passing through or ending there).
pub(crate) fn on_column(&self, i: usize, j: usize) -> bool {
(j > 0 && self.v(i, j - 1)) || self.v(i, j)
}
/// The point `(i, j)` lies on a row edge.
pub(crate) fn on_row(&self, i: usize, j: usize) -> bool {
(i > 0 && self.h(i - 1, j)) || self.h(i, j)
}
/// The first `count` index lines a ray from `(i, j)` crosses (2003 §3: the knots of a
/// blending function are read off rays cast from its anchor). Along s it crosses columns
/// carrying an edge at row `j`; along t, rows carrying an edge at column `i`. Nearest
/// first; a ray that leaves the mesh repeats the boundary line (clamped ends).
pub(crate) fn ray(&self, i: usize, j: usize, dir: Dir, forward: bool, count: usize) -> Vec<usize> {
let (start, last) = dir.pick((i, self.ns() - 1), (j, self.nt() - 1));
let hit = |x: usize| match dir {
Dir::S => self.on_column(x, j),
Dir::T => self.on_row(i, x),
};
let mut out = Vec::with_capacity(count);
let mut x = start;
while out.len() < count {
let next = if forward { (x < last).then(|| x + 1) } else { x.checked_sub(1) };
match next {
Some(n) => {
x = n;
if hit(x) {
out.push(x);
}
}
None => out.push(x),
}
}
out
}
/// The local knot vector (as line indices) of a degree-`p` blending function anchored at
/// `(i, j)` in direction `dir`: `(p+1)/2` crossings each side of the anchor's own line
/// (odd degree; 2003 §3).
pub(crate) fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
let h = p.div_ceil(2);
let own = dir.pick(i, j);
let mut k: Vec<usize> = self.ray(i, j, dir, false, h);
k.reverse();
k.push(own);
k.extend(self.ray(i, j, dir, true, h));
k
}
/// The face containing the unit cell whose lower-left corner is `(ci, cj)`.
pub(crate) fn face_of_cell(&self, ci: usize, cj: usize) -> IndexRect {
let i0 = (0..=ci).rev().find(|&x| self.v(x, cj)).unwrap_or(0);
let i1 = (ci + 1..self.ns()).find(|&x| self.v(x, cj)).unwrap_or(self.ns() - 1);
let j0 = (0..=cj).rev().find(|&y| self.h(ci, y)).unwrap_or(0);
let j1 = (cj + 1..self.nt()).find(|&y| self.h(ci, y)).unwrap_or(self.nt() - 1);
IndexRect { i0, i1, j0, j1 }
}
/// Split a face with a full-height column edge at `x` so that the point `(x, y)` lies on
/// a column edge (the face above the point, or below it on the top row).
pub(crate) fn split_column_at(&mut self, x: usize, y: usize) {
let cj = if y + 1 < self.nt() { y } else { y.saturating_sub(1) };
let f = self.face_of_cell(x.min(self.ns() - 2), cj);
for j in f.j0..f.j1 {
self.set_v(x, j);
}
}
/// Split a face with a full-width row edge at `y` so that `(x, y)` lies on a row edge (the
/// face to the right of the point, or to its left on the last column).
pub(crate) fn split_row_at(&mut self, x: usize, y: usize) {
let ci = if x + 1 < self.ns() { x } else { x.saturating_sub(1) };
let f = self.face_of_cell(ci, y.min(self.nt() - 2));
for i in f.i0..f.i1 {
self.set_h(i, y);
}
}
/// Insert a new column at index `at` (`0 < at < ns`) with knot value `value`; row edges
/// crossing it are split in two, the new column has no edges.
pub(crate) fn insert_column(&mut self, at: usize, value: f64) -> SplineResult<()> {
let (ns, nt) = (self.ns(), self.nt());
if at == 0 || at >= ns || ns + 1 > MAX_INDEX_LINES || (ns + 1) * nt > MAX_GRID_POINTS {
return Err(SplineError::TooLarge("refinement needs too many index lines".into()));
}
let mut h = Vec::with_capacity(ns * nt);
for j in 0..nt {
for i in 0..ns {
// New row edge i runs (i, i+1) in the new numbering.
let old = if i + 1 < at {
self.h(i, j)
} else if i + 1 == at || i == at {
self.h(at - 1, j)
} else {
self.h(i - 1, j)
};
h.push(old);
}
}
let mut v = Vec::with_capacity((ns + 1) * (nt - 1));
for i in 0..=ns {
for j in 0..nt - 1 {
v.push(match i.cmp(&at) {
std::cmp::Ordering::Less => self.v(i, j),
std::cmp::Ordering::Equal => false,
std::cmp::Ordering::Greater => self.v(i - 1, j),
});
}
}
self.s.insert(at, value);
self.h = h;
self.v = v;
Ok(())
}
/// The grid transposed (s ↔ t), so row operations can reuse column code.
pub(crate) fn transposed(&self) -> Grid {
let (ns, nt) = (self.ns(), self.nt());
let mut g = Grid { s: self.t.clone(), t: self.s.clone(), h: vec![false; (nt - 1) * ns], v: vec![false; nt * (ns - 1)] };
for i in 0..ns {
for j in 0..nt {
if self.h(i, j) {
g.set_v(j, i);
}
if self.v(i, j) {
g.set_h(j, i);
}
}
}
g
}
}
/// A validated T-mesh.
#[derive(Clone, Debug, PartialEq)]
pub struct TMesh {
pub(crate) grid: Grid,
vertices: Vec<(usize, usize)>,
/// Vertex number of grid point `(i, j)` at `j·ns + i`, `u32::MAX` where there is none.
vid: Vec<u32>,
}
impl TMesh {
/// The full tensor-product mesh on the given knot lines.
pub fn tensor(s: Vec<f64>, t: Vec<f64>) -> SplineResult<TMesh> {
let (ns, nt) = (s.len(), t.len());
let segs: Vec<Segment> = (0..ns)
.map(|i| Segment::Column { i, j0: 0, j1: nt.saturating_sub(1) })
.chain((0..nt).map(|j| Segment::Row { j, i0: 0, i1: ns.saturating_sub(1) }))
.collect();
TMesh::from_segments(s, t, &segs)
}
/// A T-mesh from knot lines and runs of edges (the outer rectangle must be among them).
pub fn from_segments(s: Vec<f64>, t: Vec<f64>, segments: &[Segment]) -> SplineResult<TMesh> {
let mut g = Grid::empty(s, t)?;
if segments.len() > MAX_GRID_POINTS {
return Err(SplineError::TooLarge("too many segments".into()));
}
let (ns, nt) = (g.ns(), g.nt());
for seg in segments {
match *seg {
Segment::Column { i, j0, j1 } => {
if i >= ns || j0 >= j1 || j1 >= nt {
return invalid(format!("column segment {i}: rows {j0}..{j1} are not inside the mesh"));
}
(j0..j1).for_each(|j| g.set_v(i, j));
}
Segment::Row { j, i0, i1 } => {
if j >= nt || i0 >= i1 || i1 >= ns {
return invalid(format!("row segment {j}: columns {i0}..{i1} are not inside the mesh"));
}
(i0..i1).for_each(|i| g.set_h(i, j));
}
}
}
TMesh::from_grid(g)
}
/// Validate a grid as a T-mesh.
pub(crate) fn from_grid(g: Grid) -> SplineResult<TMesh> {
let (ns, nt) = (g.ns(), g.nt());
if (0..ns - 1).any(|i| !g.h(i, 0) || !g.h(i, nt - 1)) || (0..nt - 1).any(|j| !g.v(0, j) || !g.v(ns - 1, j)) {
return invalid("the T-mesh's outer rectangle is not closed");
}
if let Some(i) = (0..ns).find(|&i| !(0..nt - 1).any(|j| g.v(i, j))) {
return invalid(format!("column {i} carries no edge"));
}
if let Some(j) = (0..nt).find(|&j| !(0..ns - 1).any(|i| g.h(i, j))) {
return invalid(format!("row {j} carries no edge"));
}
let mut vertices = Vec::new();
let mut vid = vec![u32::MAX; ns * nt];
for j in 0..nt {
for i in 0..ns {
let l = i > 0 && g.h(i - 1, j);
let r = g.h(i, j);
let d = j > 0 && g.v(i, j - 1);
let u = g.v(i, j);
let n = [l, r, d, u].iter().filter(|&&e| e).count();
let corner = (i == 0 || i == ns - 1) && (j == 0 || j == nt - 1);
match n {
1 => return invalid(format!("an edge ends without meeting another at ({i}, {j})")),
2 if (l != r) && !corner => return invalid(format!("a face is not rectangular at ({i}, {j})")),
_ => {}
}
if (l || r) && (d || u) {
if let Some(slot) = vid.get_mut(j * ns + i) {
*slot = vertices.len() as u32;
}
vertices.push((i, j));
}
}
}
Ok(TMesh { grid: g, vertices, vid })
}
/// Knot values of the columns.
pub fn s_knots(&self) -> &[f64] {
&self.grid.s
}
/// Knot values of the rows.
pub fn t_knots(&self) -> &[f64] {
&self.grid.t
}
/// The vertices `(column, row)`, row by row (column fastest). Control points follow this
/// order.
pub fn vertices(&self) -> &[(usize, usize)] {
&self.vertices
}
/// Vertex number of grid point `(i, j)`.
pub fn vertex_at(&self, i: usize, j: usize) -> Option<usize> {
if i >= self.grid.ns() {
return None;
}
self.vid.get(j * self.grid.ns() + i).filter(|&&k| k != u32::MAX).map(|&k| k as usize)
}
/// The parameter point `(s, t)` of vertex `k`.
pub fn vertex_param(&self, k: usize) -> Option<(f64, f64)> {
let &(i, j) = self.vertices.get(k)?;
Some((*self.grid.s.get(i)?, *self.grid.t.get(j)?))
}
/// Is the edge from `(i, j)` to `(i+1, j)` present?
pub fn row_edge(&self, i: usize, j: usize) -> bool {
self.grid.h(i, j)
}
/// Is the edge from `(i, j)` to `(i, j+1)` present?
pub fn column_edge(&self, i: usize, j: usize) -> bool {
self.grid.v(i, j)
}
/// Every unit edge present: a tensor-product mesh without T-junctions.
pub fn is_regular(&self) -> bool {
(0..self.grid.nt()).all(|j| (0..self.grid.ns() - 1).all(|i| self.grid.h(i, j)))
&& (0..self.grid.ns()).all(|i| (0..self.grid.nt() - 1).all(|j| self.grid.v(i, j)))
}
/// The faces, by their lower-left corner.
pub fn faces(&self) -> Vec<IndexRect> {
let g = &self.grid;
let mut out = Vec::new();
for cj in 0..g.nt() - 1 {
for ci in 0..g.ns() - 1 {
if g.v(ci, cj) && g.h(ci, cj) {
out.push(g.face_of_cell(ci, cj));
}
}
}
out
}
/// The local knot vector (line indices) of a degree-`p` blending function anchored at
/// vertex `(i, j)` in direction `dir` (2003 §3).
pub fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
self.grid.local_knots(i, j, dir, p)
}
/// The T-junction extensions for bi-degree `degree` (Scott, Li, Sederberg, Hughes 2012,
/// §2, after Li, Zheng, Sederberg, Hughes, Scott 2012): from each interior T-junction a face
/// extension runs in the direction of the missing edge across `⌈p/2⌉` perpendicular lines
/// carrying an edge there, and an edge extension the other way across `⌊p/2⌋` of them.
/// `p` is the degree along the extension.
pub fn extensions(&self, degree: [usize; 2]) -> Vec<Extension> {
let g = &self.grid;
let (ns, nt) = (g.ns(), g.nt());
let mut out = Vec::new();
for &(i, j) in &self.vertices {
if i == 0 || j == 0 || i == ns - 1 || j == nt - 1 {
continue;
}
let (l, r, d, u) = (g.h(i - 1, j), g.h(i, j), g.v(i, j - 1), g.v(i, j));
if [l, r, d, u].iter().filter(|&&e| e).count() != 3 {
continue;
}
let (dir, missing_forward) = if !l || !r { (Dir::S, !r) } else { (Dir::T, !u) };
let p = dir.pick(degree[0], degree[1]);
let reach = |forward: bool, count: usize| -> usize {
if count == 0 {
return dir.pick(i, j);
}
g.ray(i, j, dir, forward, count).last().copied().unwrap_or(0)
};
let face = reach(missing_forward, p.div_ceil(2));
let edge = reach(!missing_forward, p / 2);
let range = if face < edge { [face, edge] } else { [edge, face] };
let at = dir.pick(j, i);
out.push(Extension { dir, at, range, from: (i, j) });
}
out
}
/// Analysis-suitable for bi-degree `degree`: no extension along s meets an extension along
/// t (Scott et al. 2012, §2). Analysis-suitable T-splines have linearly independent
/// blending functions that form a partition of unity.
pub fn is_analysis_suitable(&self, degree: [usize; 2]) -> bool {
self.conflict(degree).is_none()
}
/// The first extension along s that meets an extension along t.
pub(crate) fn conflict(&self, degree: [usize; 2]) -> Option<Extension> {
let ns = self.grid.ns();
let ext = self.extensions(degree);
let mut covered: Vec<Option<usize>> = vec![None; ns * self.grid.nt()];
for (k, e) in ext.iter().enumerate().filter(|(_, e)| e.dir == Dir::S) {
for x in e.range[0]..=e.range[1] {
if let Some(c) = covered.get_mut(e.at * ns + x) {
*c = Some(k);
}
}
}
ext.iter()
.filter(|e| e.dir == Dir::T)
.find_map(|e| (e.range[0]..=e.range[1]).find_map(|y| covered.get(y * ns + e.at).copied().flatten()))
.and_then(|k| ext.get(k).copied())
}
}
/// A T-junction extension: a closed segment along `dir` on index line `at` (the row for
/// [`Dir::S`], the column for [`Dir::T`]) spanning the lines `range`, from the T-junction
/// `from`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Extension {
pub dir: Dir,
pub at: usize,
pub range: [usize; 2],
pub from: (usize, usize),
}
+219
View File
@@ -0,0 +1,219 @@
//! T-spline surfaces: control points on a T-mesh, which allows T-junctions, so a surface can
//! be refined locally and patches with different knot vectors join without gaps.
//!
//! References: Sederberg, Zheng, Bakenov, Nasri 2003, "T-splines and T-NURCCs" (ACM ToG
//! 22(3)) — §3 the T-spline equation and the local knot vectors read off the T-mesh by rays;
//! Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline simplification and local
//! refinement" (ACM ToG 23(3)) — §3 local refinement by knot insertion into blending
//! functions; Scott, Li, Sederberg, Hughes 2012, "Local refinement of analysis-suitable
//! T-splines" (CMAME 213–216) — §2 index-space T-meshes and T-junction extensions; Piegl &
//! Tiller, *The NURBS Book* — the univariate basis.
//!
//! Scope: odd degree in each direction (control points at vertices, as in the 2003 paper;
//! even degrees put anchors on faces and are refused), rectangular index domains.
//!
//! The surface is `S(s, t) = Σ wₖ Pₖ Bₖ(s, t) / Σ wₖ Bₖ(s, t)` with `Bₖ(s, t) = N[sₖ](s) ·
//! N[tₖ](t)`, the univariate B-splines of the vertex's local knot vectors (2003, eq. 2–3).
//! Near the boundary the outermost `(p−1)/2` index lines each way form a frame that must be
//! complete lines; the domain is the rectangle inside it.
mod extract;
mod mesh;
mod refine;
pub use crate::bspline::Dir;
pub use mesh::{Extension, IndexRect, MAX_GRID_POINTS, MAX_INDEX_LINES, Segment, TMesh};
pub use refine::Cut;
use crate::Vec3;
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, invalid, local_basis, weighted_points};
/// A blending function: its anchor's local knot vectors as line indices and as values, and
/// its support.
#[derive(Clone, Debug, PartialEq)]
struct Blend {
s: Vec<usize>,
t: Vec<usize>,
sv: Vec<f64>,
tv: Vec<f64>,
}
impl Blend {
fn support(&self) -> [[f64; 2]; 2] {
let ends = |v: &[f64]| [v.first().copied().unwrap_or(0.0), v.last().copied().unwrap_or(0.0)];
[ends(&self.sv), ends(&self.tv)]
}
}
/// The number of frame lines outside the domain on each side, `(p−1)/2` per direction.
fn frame(degree: [usize; 2]) -> [usize; 2] {
degree.map(|p| p.saturating_sub(1) / 2)
}
/// A T-spline surface of odd bi-degree.
#[derive(Clone, Debug, PartialEq)]
pub struct TSpline {
mesh: TMesh,
degree: [usize; 2],
ctrl: Vec<HPoint>,
blends: Vec<Blend>,
}
impl TSpline {
/// A T-spline from a mesh, a bi-degree and one point and positive weight per vertex (in
/// [`TMesh::vertices`] order).
pub fn new(mesh: TMesh, degree: [usize; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<TSpline> {
TSpline::from_homogeneous(mesh, degree, weighted_points(points, weights)?)
}
/// A T-spline from homogeneous control points (`(x·w, y·w, z·w, w)`, `w > 0`).
pub fn from_homogeneous(mesh: TMesh, degree: [usize; 2], ctrl: Vec<HPoint>) -> SplineResult<TSpline> {
for p in degree {
if p == 0 || p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
}
if p % 2 == 0 {
return Err(SplineError::Unsupported(format!("even degree {p} (T-splines here have odd degrees)")));
}
}
let g = &mesh.grid;
let (ns, nt) = (g.ns(), g.nt());
let [cs, ct] = frame(degree);
if ns < 2 * cs + 2 || nt < 2 * ct + 2 {
return invalid("too few index lines for the degree");
}
let full_col = |i: usize| (0..nt - 1).all(|j| g.v(i, j));
let full_row = |j: usize| (0..ns - 1).all(|i| g.h(i, j));
if (0..=cs).any(|k| !full_col(k) || !full_col(ns - 1 - k)) || (0..=ct).any(|k| !full_row(k) || !full_row(nt - 1 - k)) {
return invalid("the outermost (p−1)/2 index lines must be complete");
}
if !(g.s.get(cs) < g.s.get(ns - 1 - cs)) || !(g.t.get(ct) < g.t.get(nt - 1 - ct)) {
return invalid("the T-spline's domain is empty");
}
if ctrl.len() != mesh.vertices().len() {
return invalid(format!("{} control points for {} vertices", ctrl.len(), mesh.vertices().len()));
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0) || h.project().is_none()) {
return invalid("control points must be finite with positive weights");
}
let mut blends = Vec::with_capacity(ctrl.len());
for &(i, j) in mesh.vertices() {
let s = g.local_knots(i, j, Dir::S, degree[0]);
let t = g.local_knots(i, j, Dir::T, degree[1]);
let sv: Vec<f64> = s.iter().map(|&x| g.s.get(x).copied().unwrap_or(0.0)).collect();
let tv: Vec<f64> = t.iter().map(|&y| g.t.get(y).copied().unwrap_or(0.0)).collect();
let b = Blend { s, t, sv, tv };
let [[s0, s1], [t0, t1]] = b.support();
if !(s0 < s1 && t0 < t1) {
return invalid(format!("repeated knots leave the blending function at ({i}, {j}) empty"));
}
blends.push(b);
}
Ok(TSpline { mesh, degree, ctrl, blends })
}
pub fn mesh(&self) -> &TMesh {
&self.mesh
}
pub fn degree(&self) -> [usize; 2] {
self.degree
}
/// Homogeneous control points, one per vertex.
pub fn homogeneous(&self) -> &[HPoint] {
&self.ctrl
}
/// Cartesian control points, one per vertex.
pub fn points(&self) -> Vec<Vec3> {
self.ctrl.iter().map(|h| h.project().unwrap_or(Vec3::ZERO)).collect()
}
/// Weights, one per vertex.
pub fn weights(&self) -> Vec<f64> {
self.ctrl.iter().map(|h| h.w).collect()
}
/// The local knot vectors (values) of vertex `k`'s blending function.
pub fn local_knots(&self, k: usize) -> Option<(&[f64], &[f64])> {
self.blends.get(k).map(|b| (b.sv.as_slice(), b.tv.as_slice()))
}
/// The parameter domain `[[s₀, s₁], [t₀, t₁]]`.
pub fn domain(&self) -> [[f64; 2]; 2] {
let g = &self.mesh.grid;
let [cs, ct] = frame(self.degree);
let at = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
[[at(&g.s, cs), at(&g.s, g.ns() - 1 - cs)], [at(&g.t, ct), at(&g.t, g.nt() - 1 - ct)]]
}
/// Analysis-suitable (no s-extension meets a t-extension; Scott et al. 2012, §2).
pub fn is_analysis_suitable(&self) -> bool {
self.mesh.is_analysis_suitable(self.degree)
}
/// Which side of the knots to evaluate on, after checking `(s, t)` lies in the domain.
fn sides(&self, s: f64, t: f64) -> SplineResult<[Side; 2]> {
let [ds, dt] = self.domain();
if !(s >= ds[0] && s <= ds[1] && t >= dt[0] && t <= dt[1]) {
return invalid(format!("({s}, {t}) is outside the T-spline's domain"));
}
let side = |x: f64, hi: f64| if x >= hi { Side::Left } else { Side::Right };
Ok([side(s, ds[1]), side(t, dt[1])])
}
/// Blending function `k` at `(s, t)`, with its first derivatives `[B, ∂B/∂s, ∂B/∂t]`.
fn blend_at(&self, b: &Blend, s: f64, t: f64, sides: [Side; 2], n: usize) -> [f64; 3] {
let [[s0, s1], [t0, t1]] = b.support();
if s < s0 || s > s1 || t < t0 || t > t1 {
return [0.0; 3];
}
let ns = local_basis(&b.sv, s, sides[0], n);
let nt = local_basis(&b.tv, t, sides[1], n);
let (n0, n1) = (ns.first().copied().unwrap_or(0.0), ns.get(1).copied().unwrap_or(0.0));
let (m0, m1) = (nt.first().copied().unwrap_or(0.0), nt.get(1).copied().unwrap_or(0.0));
[n0 * m0, n1 * m0, n0 * m1]
}
/// The value of vertex `k`'s blending function `Bₖ(s, t)` (no weight).
pub fn blending(&self, k: usize, s: f64, t: f64) -> SplineResult<f64> {
let sides = self.sides(s, t)?;
let b = self.blends.get(k).ok_or_else(|| SplineError::Invalid(format!("no vertex {k}")))?;
Ok(self.blend_at(b, s, t, sides, 0)[0])
}
/// Homogeneous sums `[A, ∂A/∂s, ∂A/∂t]` at `(s, t)`.
fn sums(&self, s: f64, t: f64, n: usize) -> SplineResult<[HPoint; 3]> {
let sides = self.sides(s, t)?;
let mut acc = [HPoint::ZERO; 3];
for (b, c) in self.blends.iter().zip(&self.ctrl) {
let v = self.blend_at(b, s, t, sides, n);
for (a, x) in acc.iter_mut().zip(v) {
if x != 0.0 {
*a += *c * x;
}
}
}
Ok(acc)
}
/// The point at `(s, t)`.
pub fn eval(&self, s: f64, t: f64) -> SplineResult<Vec3> {
let [a, _, _] = self.sums(s, t, 0)?;
a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))
}
/// The point and the first partial derivatives `[S, ∂S/∂s, ∂S/∂t]` at `(s, t)` (quotient
/// rule on the homogeneous sums).
pub fn derivs(&self, s: f64, t: f64) -> SplineResult<[Vec3; 3]> {
let [a, a_s, a_t] = self.sums(s, t, 1)?;
let p = a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))?;
let d = |h: HPoint| (h.xyz() - p * h.w) / a.w;
Ok([p, d(a_s), d(a_t)])
}
/// The unit normal `∂S/∂s × ∂S/∂t` at `(s, t)`, if the surface is regular there.
pub fn normal(&self, s: f64, t: f64) -> SplineResult<Option<Vec3>> {
let [_, ds, dt] = self.derivs(s, t)?;
Ok(ds.cross(dt).normalized())
}
}
#[cfg(test)]
mod tests;
+317
View File
@@ -0,0 +1,317 @@
//! Local refinement that keeps the surface exactly (Sederberg, Cardon, Finnigan, North,
//! Zheng, Lyche 2004, §3).
//!
//! The requested knot lines are cut into the T-mesh first. Then every blending function of
//! the original surface is carried as a record — its local knot vectors (as index lines) and
//! its homogeneous coefficient — and the records are made to agree with the refined mesh:
//!
//! * **Violation 1** — the mesh's ray from a record's anchor crosses a line inside the
//! record's support that the record lacks: insert that knot into the record, which splits
//! it into two records (knot insertion into one blending function, 2004 §3).
//! * **Violation 2** — the record has a knot where the ray crosses nothing: split the face
//! there so the mesh dictates that knot.
//! * **Violation 3** — a record's anchor is not a vertex: split a face so it becomes one.
//!
//! Records with the same knot vectors merge. When nothing is violated, each record is exactly
//! the blending function of the vertex at its anchor, so summing the coefficients per vertex
//! gives control points of the refined T-spline, and the surface is unchanged. All knots are
//! handled as line indices, so every comparison is exact; no new index line appears after
//! the cuts, so the work is bounded by the grid.
use std::collections::BTreeMap;
use super::TSpline;
use super::mesh::{Grid, TMesh};
use crate::bspline::{Dir, HPoint, SplineError, SplineResult, invalid, split_basis, split_knots};
/// Most cuts in one refinement.
const MAX_CUTS: usize = 1 << 12;
/// Most record checks and mesh changes in one refinement.
const MAX_REFINE_STEPS: usize = 1 << 22;
/// A knot line to cut into a T-spline.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Cut {
/// The line `s = s` from `t = t0` to `t = t1`: every face it crosses there is split.
S { s: f64, t0: f64, t1: f64 },
/// The line `t = t` from `s = s0` to `s = s1`.
T { t: f64, s0: f64, s1: f64 },
}
impl Cut {
/// The direction the cut line is constant in: [`Dir::S`] for a line `s = …`.
fn dir(&self) -> Dir {
match self {
Cut::S { .. } => Dir::S,
Cut::T { .. } => Dir::T,
}
}
}
/// A record's knot vectors (s, t) as line indices.
type Key = (Vec<usize>, Vec<usize>);
enum Fix {
/// Violation 1: insert line `k` into the record along the direction.
Split(Dir, usize),
/// Violations 2 and 3: the point (column, row) must lie on a column edge.
Column(usize, usize),
/// … on a row edge.
Row(usize, usize),
}
impl TSpline {
/// The same surface on a refined T-mesh with the given knot lines cut in (2004 §3; see the
/// module notes). The cut lines must lie strictly inside the domain; a cut at the value
/// of an existing knot line extends that line. Further faces may be split to keep the
/// surface exact and, when this T-spline is analysis-suitable, to keep the result
/// analysis-suitable (T-junctions are carried across faces until no s-extension meets a
/// t-extension; Scott et al. 2012).
pub fn refine(&self, cuts: &[Cut]) -> SplineResult<TSpline> {
if cuts.len() > MAX_CUTS {
return Err(SplineError::TooLarge(format!("{} cuts (at most {MAX_CUTS})", cuts.len())));
}
let mut g = self.mesh.grid.clone();
let mut smap: Vec<usize> = (0..g.ns()).collect();
let mut tmap: Vec<usize> = (0..g.nt()).collect();
let [ds, dt] = self.domain();
// Row cuts run as column cuts on the transposed grid; consecutive cuts along one
// direction share a transposition.
for run in cuts.chunk_by(|a, b| a.dir() == b.dir()) {
let along_t = run.first().is_some_and(|c| c.dir() == Dir::T);
if along_t {
g = g.transposed();
}
for cut in run {
match *cut {
Cut::S { s, t0, t1 } => cut_column(&mut g, &mut smap, s, [t0, t1], ds)?,
Cut::T { t, s0, s1 } => cut_column(&mut g, &mut tmap, t, [s0, s1], dt)?,
}
}
if along_t {
g = g.transposed();
}
}
let remap = |idx: &[usize], map: &[usize]| -> Vec<usize> { idx.iter().map(|&x| map.get(x).copied().unwrap_or(x)).collect() };
let mut rec: BTreeMap<Key, HPoint> = BTreeMap::new();
for (b, c) in self.blends.iter().zip(&self.ctrl) {
*rec.entry((remap(&b.s, &smap), remap(&b.t, &tmap))).or_insert(HPoint::ZERO) += *c;
}
// Keep analysis-suitability (Scott et al. 2012): while an s-extension meets a
// t-extension, carry the s-extension's T-junction across its face, then make the
// records agree with the mesh again. Every round adds edges, so this ends.
let keep_suitable = self.is_analysis_suitable();
let mesh = loop {
close(&mut g, &mut rec, self.degree)?;
let mesh = TMesh::from_grid(g.clone())?;
match mesh.conflict(self.degree).filter(|_| keep_suitable) {
Some(e) => extend_t_junction(&mut g, e.from),
None => break mesh,
}
};
let mut ctrl = vec![HPoint::ZERO; mesh.vertices().len()];
let mut seen = vec![false; ctrl.len()];
for ((s, t), c) in &rec {
let (ci, cj) = (centre(s), centre(t));
let k = mesh.vertex_at(ci, cj).ok_or_else(|| SplineError::Invalid("refinement left a blending function without a vertex".into()))?;
if let (Some(slot), Some(f)) = (ctrl.get_mut(k), seen.get_mut(k)) {
*slot += *c;
*f = true;
}
}
if let Some(k) = seen.iter().position(|&f| !f) {
let (i, j) = mesh.vertices().get(k).copied().unwrap_or_default();
return Err(SplineError::Unsupported(format!(
"this refinement leaves the control point at ({i}, {j}) without a blending function (the refined T-mesh is not analysis-suitable)"
)));
}
let out = TSpline::from_homogeneous(mesh, self.degree, ctrl)?;
// The records are the refined blending functions, one per vertex.
for (s, t) in rec.keys() {
let k = out.mesh.vertex_at(centre(s), centre(t));
if k.and_then(|k| out.blends.get(k)).is_none_or(|b| &b.s != s || &b.t != t) {
return Err(SplineError::Invalid("refinement did not converge to the refined mesh's blending functions".into()));
}
}
Ok(out)
}
}
/// Carry a T-junction's missing edge across the face beyond it.
fn extend_t_junction(g: &mut Grid, (i, j): (usize, usize)) {
let (l, r, d, u) = (i > 0 && g.h(i - 1, j), g.h(i, j), j > 0 && g.v(i, j - 1), g.v(i, j));
if !r {
g.split_row_at(i, j);
} else if !l {
g.split_row_at(i.saturating_sub(1), j);
} else if !u {
g.split_column_at(i, j);
} else if !d {
g.split_column_at(i, j.saturating_sub(1));
}
}
/// The anchor line of a local knot vector.
fn centre(k: &[usize]) -> usize {
k.get(k.len() / 2).copied().unwrap_or(0)
}
/// Cut the column line `value` into every face it crosses between `range[0]` and `range[1]`,
/// adding the column if needed.
fn cut_column(g: &mut Grid, map: &mut [usize], value: f64, range: [f64; 2], dom: [f64; 2]) -> SplineResult<()> {
let [a, b] = range;
if !(value.is_finite() && a.is_finite() && b.is_finite()) {
return invalid("a cut needs finite numbers");
}
if !(a < b) {
return invalid("a cut needs a non-empty range");
}
if !(value > dom[0] && value < dom[1]) {
return invalid(format!("the cut at {value} is not inside the domain"));
}
let same: Vec<usize> = (0..g.ns()).filter(|&x| g.s.get(x) == Some(&value)).collect();
let (x, new) = match same.as_slice() {
[] => {
let at = g.s.iter().position(|&k| k > value).unwrap_or(g.ns() - 1);
g.insert_column(at, value)?;
map.iter_mut().filter(|m| **m >= at).for_each(|m| *m += 1);
(at, true)
}
[x] => (*x, false),
_ => return Err(SplineError::Unsupported(format!("cutting along the repeated knot {value}"))),
};
let mut added = false;
for y in 0..g.nt() - 1 {
if g.v(x, y) {
continue;
}
let f = g.face_of_cell(x, y);
let (fa, fb) = (g.t.get(f.j0).copied().unwrap_or(0.0), g.t.get(f.j1).copied().unwrap_or(0.0));
// Faces overlapping the range, and faces of zero height inside it (so a cut from a
// clamped domain end runs on through the zero-height frame to the boundary).
if fb.min(b) - fa.max(a) > 0.0 || (fa == fb && fa >= a && fb <= b) {
(f.j0..f.j1).for_each(|j| g.set_v(x, j));
added = true;
}
}
if new && !added {
return invalid(format!("the cut at {value} crosses no face"));
}
Ok(())
}
/// Resolve the violations until every record is a blending function of the mesh.
fn close(g: &mut Grid, rec: &mut BTreeMap<Key, HPoint>, degree: [usize; 2]) -> SplineResult<()> {
let mut steps = 0usize;
let mut tick = |n: usize| -> SplineResult<()> {
steps += n;
if steps > MAX_REFINE_STEPS { Err(SplineError::TooLarge("the refinement needs too much work".into())) } else { Ok(()) }
};
loop {
// Violation 1 first (knot insertion into records), until none is left.
loop {
let keys: Vec<Key> = rec.keys().cloned().collect();
tick(keys.len())?;
let mut changed = false;
for key in keys {
if let Some(Fix::Split(dir, k)) = check(g, &key, degree)
&& let Some(c) = rec.remove(&key)
{
for (piece, f) in split(g, &key, dir, k)? {
if f != 0.0 {
*rec.entry(piece).or_insert(HPoint::ZERO) += c * f;
}
}
changed = true;
}
}
if !changed {
break;
}
}
// Then one mesh change for the first record that needs one.
tick(rec.len())?;
let fix = rec.keys().find_map(|k| match check(g, k, degree) {
Some(Fix::Split(..)) | None => None,
other => other,
});
match fix {
Some(Fix::Column(x, y)) => g.split_column_at(x, y),
Some(Fix::Row(x, y)) => g.split_row_at(x, y),
_ => return Ok(()),
}
}
}
/// The first violation of a record against the mesh, if any.
fn check(g: &Grid, key: &Key, degree: [usize; 2]) -> Option<Fix> {
let (s, t) = key;
let (ci, cj) = (centre(s), centre(t));
let ms = g.local_knots(ci, cj, Dir::S, degree[0]);
match compare(s, &ms) {
Some(Ok(k)) => return Some(Fix::Split(Dir::S, k)),
Some(Err(x)) => return Some(Fix::Column(x, cj)),
None => {}
}
let mt = g.local_knots(ci, cj, Dir::T, degree[1]);
match compare(t, &mt) {
Some(Ok(k)) => return Some(Fix::Split(Dir::T, k)),
Some(Err(y)) => return Some(Fix::Row(ci, y)),
None => {}
}
if !g.on_column(ci, cj) {
return Some(Fix::Column(ci, cj));
}
if !g.on_row(ci, cj) {
return Some(Fix::Row(ci, cj));
}
None
}
/// Compare a record's knots with the mesh's, outward from the anchor: `Ok(k)` the mesh
/// crosses line `k` first (insert it into the record), `Err(x)` the record has line `x` where
/// the mesh crosses nothing (the mesh must gain it).
fn compare(rec: &[usize], mesh: &[usize]) -> Option<Result<usize, usize>> {
let mid = rec.len() / 2;
for k in mid + 1..rec.len() {
let (r, m) = (rec.get(k)?, mesh.get(k)?);
if m < r {
return Some(Ok(*m));
}
if m > r {
return Some(Err(*r));
}
}
for k in (0..mid).rev() {
let (r, m) = (rec.get(k)?, mesh.get(k)?);
if m > r {
return Some(Ok(*m));
}
if m < r {
return Some(Err(*r));
}
}
None
}
/// Insert line `k` into a record along `dir`: the two pieces and their factors (2004 §3).
/// Pieces whose knots all have one value are the zero function and are dropped.
fn split(g: &Grid, key: &Key, dir: Dir, k: usize) -> SplineResult<Vec<(Key, f64)>> {
let (idx, vals) = dir.pick((&key.0, &g.s), (&key.1, &g.t));
let value = |x: usize| vals.get(x).copied().unwrap_or(0.0);
let values: Vec<f64> = idx.iter().map(|&x| value(x)).collect();
let (c1, c2) = split_basis(&values, value(k)).ok_or_else(|| SplineError::Invalid("refinement inserted a knot outside a support".into()))?;
let mut out = Vec::with_capacity(2);
for (piece, c) in split_knots(idx, k).into_iter().zip([c1, c2]) {
let (Some(&lo), Some(&hi)) = (piece.first(), piece.last()) else { continue };
if value(lo) == value(hi) {
continue;
}
let key = match dir {
Dir::S => (piece, key.1.clone()),
Dir::T => (key.0.clone(), piece),
};
out.push((key, c));
}
Ok(out)
}
+350
View File
@@ -0,0 +1,350 @@
use super::*;
use crate::bspline::tests::{Lcg, random_surface};
use crate::bspline::{BezierPatch, NurbsSurface, SplineError};
fn uniform(n: usize) -> Vec<f64> {
(0..n).map(|k| k as f64).collect()
}
fn col(i: usize, j0: usize, j1: usize) -> Segment {
Segment::Column { i, j0, j1 }
}
fn row(j: usize, i0: usize, i1: usize) -> Segment {
Segment::Row { j, i0, i1 }
}
/// 7 × 7 lines, row 3 only from column 0 to column 3: one T-junction at (3, 3) whose
/// extension runs along s.
fn t_mesh() -> TMesh {
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
segs.extend([0, 1, 2, 4, 5, 6].map(|j| row(j, 0, 6)));
segs.push(row(3, 0, 3));
TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap()
}
fn random_points(seed: u64, n: usize) -> (Vec<Vec3>, Vec<f64>) {
let mut rng = Lcg(seed);
let pts = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
let w = (0..n).map(|_| 0.5 + rng.unit()).collect();
(pts, w)
}
fn t_spline(seed: u64) -> TSpline {
let mesh = t_mesh();
let (pts, w) = random_points(seed, mesh.vertices().len());
TSpline::new(mesh, [3, 3], &pts, &w).unwrap()
}
/// A clamped bicubic NURBS with uneven interior knots, as a T-spline.
fn tensor_spline(seed: u64) -> (NurbsSurface, TSpline) {
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
let n = random_surface(seed, ku, kv, [3, 3]);
let ts = TSpline::from_nurbs(&n).unwrap();
(n, ts)
}
fn grid_params(dom: [[f64; 2]; 2], k: usize) -> Vec<(f64, f64)> {
let mut out = Vec::new();
for i in 0..=k {
for j in 0..=k {
let s = dom[0][0] + (dom[0][1] - dom[0][0]) * i as f64 / k as f64;
let t = dom[1][0] + (dom[1][1] - dom[1][0]) * j as f64 / k as f64;
out.push((s, t));
}
}
out
}
fn max_deviation(a: &TSpline, b: &TSpline) -> f64 {
assert_eq!(a.domain(), b.domain());
grid_params(a.domain(), 60).into_iter().map(|(s, t)| a.eval(s, t).unwrap().dist(b.eval(s, t).unwrap())).fold(0.0, f64::max)
}
#[test]
fn tmesh_validation_refuses_malformed_meshes() {
let frame = |extra: &[Segment]| -> Vec<Segment> {
let mut s = vec![col(0, 0, 4), col(4, 0, 4), row(0, 0, 4), row(4, 0, 4)];
s.extend_from_slice(extra);
s
};
let full: Vec<Segment> = (1..4).flat_map(|k| [col(k, 0, 4), row(k, 0, 4)]).collect();
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&full)).is_ok());
// A dangling edge, an L-shaped corner, an open boundary, an empty line.
let mut dangling = full.clone();
dangling.push(col(2, 0, 4));
dangling.retain(|s| *s != row(2, 0, 4));
dangling.push(row(2, 0, 1));
dangling.push(row(2, 3, 4));
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&dangling)).is_ok(), "T-junctions on both sides are fine");
let dangling_edge = frame(&[col(1, 0, 4), col(3, 0, 4), row(1, 0, 4), row(2, 0, 4), row(3, 0, 1), col(2, 0, 3)]);
assert!(TMesh::from_segments(uniform(5), uniform(5), &dangling_edge).is_err(), "edge ending inside a face");
let mut l_shape = frame(&[col(1, 0, 4), col(2, 0, 4), col(3, 0, 4), row(1, 0, 4), row(3, 0, 4)]);
l_shape.push(row(2, 0, 2));
l_shape.retain(|s| *s != col(2, 0, 4));
l_shape.extend([col(2, 0, 2), col(2, 3, 4)]);
assert!(TMesh::from_segments(uniform(5), uniform(5), &l_shape).is_err(), "L-shaped face corner");
assert!(TMesh::from_segments(uniform(5), uniform(5), &full).is_err(), "no outer rectangle");
let mut empty = frame(&full);
empty.retain(|s| *s != col(2, 0, 4));
assert!(TMesh::from_segments(uniform(5), uniform(5), &empty).is_err(), "a column without edges");
assert!(TMesh::from_segments(vec![0.0, 1.0, f64::NAN, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
assert!(TMesh::from_segments(vec![0.0, 2.0, 1.0, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(9, 0, 4)]).is_err());
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(1, 3, 3)]).is_err());
assert!(matches!(TMesh::tensor(uniform(MAX_INDEX_LINES + 1), uniform(2)), Err(SplineError::TooLarge(_))));
assert!(matches!(TMesh::tensor(uniform(2048), uniform(2048)), Err(SplineError::TooLarge(_))));
assert!(TMesh::tensor(uniform(1), uniform(3)).is_err());
}
#[test]
fn local_knot_vectors_are_read_off_rays() {
let ts = t_spline(1);
let m = ts.mesh();
assert!(m.vertex_at(4, 3).is_none(), "(4, 3) lies on a column edge only");
assert!(m.vertex_at(3, 3).is_some(), "the T-junction is a vertex");
let knots = |i, j| {
let k = m.vertex_at(i, j).unwrap();
let (s, t) = ts.local_knots(k).unwrap();
(s.to_vec(), t.to_vec())
};
assert_eq!(knots(2, 3), (vec![0.0, 1.0, 2.0, 3.0, 4.0], vec![1.0, 2.0, 3.0, 4.0, 5.0]));
assert_eq!(knots(3, 4).1, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
// The ray down column 4 passes (4, 3), which carries no row edge.
assert_eq!(knots(4, 4).1, vec![1.0, 2.0, 4.0, 5.0, 6.0]);
assert_eq!(knots(4, 2).1, vec![0.0, 1.0, 2.0, 4.0, 5.0]);
// Rays leaving the mesh repeat the boundary line.
assert_eq!(knots(0, 0), (vec![0.0, 0.0, 0.0, 1.0, 2.0], vec![0.0, 0.0, 0.0, 1.0, 2.0]));
assert_eq!(knots(6, 5).1, vec![2.0, 4.0, 5.0, 6.0, 6.0]);
}
#[test]
fn regular_tspline_is_the_tensor_nurbs_surface() {
for (seed, degree, ku, kv) in [
(2, [3, 3], vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0]),
(3, [3, 1], vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.4, 1.0, 1.0]),
(4, [5, 3], vec![0.0; 6].into_iter().chain([0.3, 0.7]).chain([1.0; 6]).collect(), vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]),
] {
let n = random_surface(seed, ku, kv, degree);
let ts = TSpline::from_nurbs(&n).unwrap();
assert!(ts.mesh().is_regular() && ts.is_analysis_suitable());
assert_eq!(ts.domain(), n.domain());
let worst = grid_params(n.domain(), 40).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
assert!(worst < 1e-12, "degree {degree:?}: T-spline differs from NURBS by {worst}");
assert_eq!(ts.to_nurbs().unwrap(), n, "NURBS → T-spline → NURBS is exact");
}
// An unclamped NURBS is clamped on the way in (same surface).
let n = random_surface(5, uniform(10), uniform(9), [3, 3]);
let ts = TSpline::from_nurbs(&n).unwrap();
let worst = grid_params(n.domain(), 30).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
assert!(worst < 1e-12, "unclamped: {worst}");
assert!(TSpline::from_nurbs(&random_surface(6, uniform(9), uniform(9), [2, 3])).is_err(), "even degree");
}
#[test]
fn blending_functions_sum_to_one_with_t_junctions() {
let ts = t_spline(1);
assert!(ts.is_analysis_suitable());
let n = ts.mesh().vertices().len();
let mut worst = 0.0f64;
for (s, t) in grid_params(ts.domain(), 64) {
let sum: f64 = (0..n).map(|k| ts.blending(k, s, t).unwrap()).sum();
worst = worst.max((sum - 1.0).abs());
}
assert!(worst < 1e-14, "partition of unity off by {worst}");
}
#[test]
fn derivatives_match_finite_differences() {
let ts = t_spline(9);
let h = 1e-6;
let mut worst = 0.0f64;
for (s, t) in grid_params([[1.05, 4.95], [1.05, 4.95]], 17) {
let [_, ds, dt] = ts.derivs(s, t).unwrap();
let fs = (ts.eval(s + h, t).unwrap() - ts.eval(s - h, t).unwrap()) / (2.0 * h);
let ft = (ts.eval(s, t + h).unwrap() - ts.eval(s, t - h).unwrap()) / (2.0 * h);
worst = worst.max((ds - fs).len()).max((dt - ft).len());
}
assert!(worst < 1e-6, "derivative error {worst}");
assert!(ts.normal(3.0, 3.0).unwrap().is_some());
}
#[test]
fn analysis_suitability_follows_t_junction_extensions() {
assert!(tensor_spline(1).1.is_analysis_suitable());
let ext = t_mesh().extensions([3, 3]);
assert_eq!(ext, vec![Extension { dir: Dir::S, at: 3, range: [2, 5], from: (3, 3) }]);
assert!(t_mesh().is_analysis_suitable([3, 3]));
// Row 4 stops at column 4 (extension along s over columns 3..7 on row 4: column 5 has no
// edge there) and column 5 stops at row 3 (extension along t over rows 2..6 on column 5):
// they cross at (5, 4).
let mut segs: Vec<Segment> = [0, 1, 2, 3, 4, 6, 7, 8].iter().map(|&i| col(i, 0, 8)).collect();
segs.push(col(5, 0, 3));
segs.extend([0, 1, 2, 3, 5, 6, 7, 8].map(|j| row(j, 0, 8)));
segs.push(row(4, 0, 4));
let m = TMesh::from_segments(uniform(9), uniform(9), &segs).unwrap();
let ext = m.extensions([3, 3]);
assert!(ext.contains(&Extension { dir: Dir::S, at: 4, range: [3, 7], from: (4, 4) }));
assert!(ext.contains(&Extension { dir: Dir::T, at: 5, range: [2, 6], from: (5, 3) }));
assert!(!m.is_analysis_suitable([3, 3]));
// Refining it does not repair it (only suitable T-splines are kept suitable), but stays exact.
let (pts, w) = random_points(4, m.vertices().len());
let ts = TSpline::new(m, [3, 3], &pts, &w).unwrap();
let r = ts.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 3.0 }]).unwrap();
assert!(max_deviation(&ts, &r) < 1e-12);
}
#[test]
fn a_cut_across_the_domain_is_boehm_knot_insertion() {
let (n, ts) = tensor_spline(4);
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.0, t1: 1.0 }, Cut::T { t: 0.75, s0: 0.0, s1: 1.0 }]).unwrap();
assert!(r.mesh().is_regular());
let want = n.insert_knot(Dir::S, 0.33).unwrap().insert_knot(Dir::T, 0.75).unwrap();
let got = r.to_nurbs().unwrap();
assert_eq!(got.knots(), want.knots());
let worst = got.homogeneous().iter().zip(want.homogeneous()).map(|(a, b)| (*a - *b).xyz().len() + (a.w - b.w).abs()).fold(0.0, f64::max);
assert!(worst < 1e-12, "control points differ by {worst}");
}
#[test]
fn local_refinement_keeps_the_surface() {
let (_, ts) = tensor_spline(7);
let before = ts.mesh().vertices().len();
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }]).unwrap();
let dev = max_deviation(&ts, &r);
eprintln!("local cut: {} → {} vertices, AS {}, max deviation {dev:e}", before, r.mesh().vertices().len(), r.is_analysis_suitable());
assert!(dev < 1e-12, "deviation {dev}");
assert!(r.mesh().vertices().len() <= before + 4, "a one-face cut stays local");
assert!(r.is_analysis_suitable());
// Refining again near the new T-junctions, and across them.
let r2 = r.refine(&[Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }, Cut::S { s: 0.39, t0: 0.3, t1: 0.45 }]).unwrap();
let dev2 = max_deviation(&ts, &r2);
eprintln!("second refinement: {} vertices, max deviation {dev2:e}", r2.mesh().vertices().len());
assert!(dev2 < 1e-12, "deviation {dev2}");
assert!(r2.is_analysis_suitable(), "refinement keeps analysis-suitability");
let n2 = r2.mesh().vertices().len();
let pu = grid_params(r2.domain(), 50)
.into_iter()
.map(|(s, t)| ((0..n2).map(|k| r2.blending(k, s, t).unwrap()).sum::<f64>() - 1.0).abs())
.fold(0.0, f64::max);
assert!(pu < 1e-14, "partition of unity after refinement off by {pu}");
// A rational T-spline with T-junctions.
let t = t_spline(3);
let r3 = t.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 4.0 }, Cut::T { t: 3.5, s0: 3.0, s1: 5.0 }]).unwrap();
let dev3 = max_deviation(&t, &r3);
eprintln!("rational T-spline: {} → {} vertices, max deviation {dev3:e}", t.mesh().vertices().len(), r3.mesh().vertices().len());
assert!(dev3 < 1e-12, "deviation {dev3}");
}
#[test]
fn random_local_refinements_keep_the_surface() {
let mut rng = Lcg(42);
let mut worst = 0.0f64;
let mut vertices = 0;
for round in 0..40 {
let (_, ts) = tensor_spline(round);
let mut cur = ts.clone();
for _ in 0..1 + (rng.unit() * 3.0) as usize {
let v = 0.05 + 0.9 * (rng.unit() * 40.0).round() / 40.0;
let a = (rng.unit() * 8.0).round() / 10.0;
let b = (a + 0.1 + (rng.unit() * 5.0).round() / 10.0).min(1.0);
let cut = if rng.unit() < 0.5 { Cut::S { s: v, t0: a, t1: b } } else { Cut::T { t: v, s0: a, s1: b } };
cur = cur.refine(&[cut]).unwrap_or_else(|e| panic!("round {round}: {cut:?}: {e}"));
}
assert!(cur.is_analysis_suitable(), "round {round}: refinement keeps analysis-suitability");
vertices += cur.mesh().vertices().len() - ts.mesh().vertices().len();
worst = worst.max(max_deviation(&ts, &cur));
}
eprintln!("random refinements: max deviation {worst:e}, {vertices} vertices added over 40 rounds");
assert!(worst < 1e-12, "deviation {worst}");
}
fn check_patches(ts: &TSpline, patches: &[BezierPatch]) -> f64 {
let [ds, dt] = ts.domain();
let area: f64 = patches.iter().map(|p| (p.s[1] - p.s[0]) * (p.t[1] - p.t[0])).sum();
assert!((area - (ds[1] - ds[0]) * (dt[1] - dt[0])).abs() < 1e-12, "the patches tile the domain");
let mut worst = 0.0f64;
for p in patches {
for (s, t) in grid_params([p.s, p.t], 6) {
worst = worst.max(p.eval(s, t).unwrap().dist(ts.eval(s, t).unwrap()));
}
}
worst
}
#[test]
fn bezier_extraction_reproduces_the_surface() {
let ts = t_spline(5);
let patches = ts.bezier_elements().unwrap();
let dev = check_patches(&ts, &patches);
eprintln!("Bézier extraction, T-junction mesh: {} patches, max deviation {dev:e}", patches.len());
assert!(dev < 1e-12);
let (n, tensor) = tensor_spline(8);
let patches = tensor.bezier_elements().unwrap();
assert_eq!(patches.len(), 5 * 3, "one patch per pair of knot spans");
assert!(check_patches(&tensor, &patches) < 1e-12);
let _ = n;
let refined = tensor.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
let patches = refined.bezier_elements().unwrap();
let dev = check_patches(&refined, &patches);
eprintln!("Bézier extraction, refined: {} patches, max deviation {dev:e}", patches.len());
assert!(dev < 1e-12);
}
#[test]
fn hostile_tspline_input_is_refused() {
let mesh = t_mesh();
let n = mesh.vertices().len();
let (pts, w) = random_points(1, n);
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &w).is_ok());
let mut bad = pts.clone();
bad[3] = Vec3::new(f64::NAN, 0.0, 0.0);
assert!(TSpline::new(mesh.clone(), [3, 3], &bad, &w).is_err());
let mut bw = w.clone();
bw[0] = 0.0;
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
bw[0] = f64::INFINITY;
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
assert!(TSpline::new(mesh.clone(), [3, 3], &pts[1..], &w[1..]).is_err());
assert!(matches!(TSpline::new(mesh.clone(), [2, 3], &pts, &w), Err(SplineError::Unsupported(_))));
assert!(TSpline::new(mesh.clone(), [0, 3], &pts, &w).is_err());
assert!(TSpline::new(mesh.clone(), [9, 3], &pts, &w).is_err());
// Degree 5 needs the three outermost lines each way complete; row 3 is not, so a mesh
// whose row 2 stops short is refused.
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
segs.extend([0, 1, 3, 4, 5, 6].map(|j| row(j, 0, 6)));
segs.push(row(2, 0, 3));
let short_frame = TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap();
let (p6, w6) = random_points(3, short_frame.vertices().len());
assert!(TSpline::new(short_frame.clone(), [3, 3], &p6, &w6).is_ok());
assert!(TSpline::new(short_frame, [5, 5], &p6, &w6).is_err());
// A row of knots repeated so often that a blending function is empty.
let m = TMesh::tensor(vec![0.0, 0.0, 0.0, 1.0, 2.0], uniform(5)).unwrap();
let (p, w5) = random_points(2, 25);
assert!(TSpline::new(m, [3, 3], &p, &w5).is_err());
// An empty domain.
let m = TMesh::tensor(vec![0.0, 1.0, 1.0, 2.0], uniform(4)).unwrap();
assert!(TSpline::new(m, [3, 3], &p[..16], &w5[..16]).is_err());
let ts = TSpline::new(mesh, [3, 3], &pts, &w).unwrap();
for (s, t) in [(f64::NAN, 2.0), (2.0, f64::INFINITY), (0.5, 2.0), (2.0, 5.5)] {
assert!(ts.eval(s, t).is_err() && ts.derivs(s, t).is_err() && ts.blending(0, s, t).is_err());
}
assert!(ts.blending(n + 5, 2.0, 2.0).is_err());
for cut in [
Cut::S { s: f64::NAN, t0: 1.0, t1: 2.0 },
Cut::S { s: 2.5, t0: 2.0, t1: 1.0 },
Cut::S { s: 2.5, t0: 2.0, t1: f64::INFINITY },
Cut::S { s: 0.5, t0: 1.0, t1: 2.0 },
Cut::T { t: 5.0, s0: 1.0, s1: 2.0 },
Cut::T { t: 9.0, s0: 1.0, s1: 2.0 },
Cut::S { s: 2.5, t0: 8.0, t1: 9.0 },
] {
assert!(ts.refine(&[cut]).is_err(), "{cut:?}");
}
assert!(matches!(ts.refine(&vec![Cut::S { s: 2.5, t0: 1.0, t1: 2.0 }; 5000]), Err(SplineError::TooLarge(_))));
// Refining the same line twice is a no-op the second time.
let a = ts.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
let b = a.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
assert_eq!(a, b);
}
+2
View File
@@ -64,6 +64,7 @@ mod sweep_path;
#[cfg(test)]
mod thread_tests;
mod topo;
mod tspline;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use blend_corner::fillet_radii;
@@ -96,6 +97,7 @@ pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicke
pub use sweep_path::sweep_path;
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
+1 -1
View File
@@ -22,7 +22,7 @@ impl Body {
}
/// A body of faces as they are (no orientation fix: a surface has no inside).
fn surface_body(faces: Vec<mt::Face>) -> Body {
pub(crate) fn surface_body(faces: Vec<mt::Face>) -> Body {
surface_of(mt::Shell::from(faces))
}
+267
View File
@@ -0,0 +1,267 @@
//! T-spline sheets: a T-spline surface (`solvecraft_geom::tspline`) as a surface body of NURBS
//! faces. A T-mesh without T-junctions becomes one clamped tensor face; otherwise each Bézier
//! element is a face, and the faces share their edges and vertices — a side that meets two
//! neighbours is split at the T-point between them — so the sheet is one sewn open shell.
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use solvecraft_geom::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, bezier_segment};
use solvecraft_geom::tspline::TSpline;
use truck_modeling as mt;
use crate::body::Body;
use crate::{KernelError, Result, guard};
/// A T-spline as a surface body (an open shell of NURBS faces whose normals follow
/// `∂S/∂s × ∂S/∂t`).
pub fn tspline_sheet(ts: &TSpline) -> Result<Body> {
let faces = if ts.mesh().is_regular() {
let n = ts.to_nurbs().and_then(|n| n.clamped()).map_err(spline_err)?;
guard("t-spline sheet", || tensor_face(&n).map(|f| vec![f]))?
} else {
let patches = ts.bezier_elements().map_err(spline_err)?;
guard("t-spline sheet", || patch_faces(&patches))?
};
Ok(crate::surfaces::surface_body(faces))
}
fn spline_err(e: SplineError) -> KernelError {
KernelError::Invalid(format!("t-spline: {e}"))
}
fn fail(m: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("t-spline sheet: {m}"))
}
fn h4(h: HPoint) -> mt::Vector4 {
mt::Vector4::new(h.x, h.y, h.z, h.w)
}
fn vertex(h: HPoint) -> Result<mt::Vertex> {
let p = h.project().ok_or_else(|| fail("a corner has no position"))?;
Ok(mt::builder::vertex(crate::body::p3(p)))
}
fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
}
fn surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
}
/// One face from a clamped tensor surface, bounded by its four boundary curves.
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
let [nu, nv] = n.count();
let [ku, kv] = n.knots();
let (ku, kv) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
let corners = [n.point(0, 0), n.point(nu - 1, 0), n.point(nu - 1, nv - 1), n.point(0, nv - 1)];
let v: Vec<mt::Vertex> = corners.into_iter().map(vertex).collect::<Result<_>>()?;
let row = |j: usize| -> Vec<HPoint> { (0..nu).map(|i| n.point(i, j)).collect() };
let column = |i: usize| -> Vec<HPoint> { (0..nv).map(|j| n.point(i, j)).collect() };
let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) };
let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail);
let bottom = edge(v0, v1, nurbs_curve(ku.clone(), &row(0))?)?;
let right = edge(v1, v2, nurbs_curve(kv.clone(), &column(nu - 1))?)?;
let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?;
let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?;
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(n.point(i, j))).collect()).collect();
mt::Face::try_new(vec![wire], surface((ku, kv), rows)?).map_err(fail)
}
/// A parameter point, exact (knot values are shared, so equal points have equal bits).
type Key = (u64, u64);
fn key(s: f64, t: f64) -> Key {
((s + 0.0).to_bits(), (t + 0.0).to_bits())
}
/// One face per Bézier patch, sharing vertices and edges; sides are split where a
/// neighbour's corner lands on them.
fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
// Corner parameters on each line of constant t (their s values) and of constant s.
let mut on_t: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
let mut on_s: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
for p in patches {
for s in p.s {
for t in p.t {
let (ks, kt) = key(s, t);
on_t.entry(kt).or_default().insert(ks);
on_s.entry(ks).or_default().insert(kt);
}
}
}
let mut verts: HashMap<Key, mt::Vertex> = HashMap::new();
let mut edges: HashMap<(Key, Key), mt::Edge> = HashMap::new();
let mut faces = Vec::with_capacity(patches.len());
for p in patches {
let mut wire: Vec<mt::Edge> = Vec::new();
for side in 0..4 {
// Side 0: t = t₀, 1: s = s₁, 2: t = t₁, 3: s = s₀; each runs with its parameter
// rising, sides 2 and 3 are walked backwards.
let (along_s, fixed, [lo, hi]) = match side {
0 => (true, p.t[0], p.s),
1 => (false, p.s[1], p.t),
2 => (true, p.t[1], p.s),
_ => (false, p.s[0], p.t),
};
let line = if along_s { on_t.get(&key(0.0, fixed).1) } else { on_s.get(&key(fixed, 0.0).0) };
let mut breaks: Vec<f64> = line.into_iter().flatten().map(|&b| f64::from_bits(b)).filter(|&x| x > lo && x < hi).collect();
breaks.insert(0, lo);
breaks.push(hi);
let ctrl = p.side(side);
let at = |x: f64| if along_s { key(x, fixed) } else { key(fixed, x) };
let mut pieces = Vec::with_capacity(breaks.len());
for w in breaks.windows(2) {
let &[x0, x1] = w else { continue };
let (ka, kb) = (at(x0), at(x1));
let e = if let Some(e) = edges.get(&(ka, kb)) {
e.clone()
} else if let Some(e) = edges.get(&(kb, ka)) {
e.inverse()
} else {
let seg = bezier_segment(&ctrl, (x0 - lo) / (hi - lo), (x1 - lo) / (hi - lo)).map_err(spline_err)?;
let (Some(&first), Some(&last)) = (seg.first(), seg.last()) else { return Err(fail("an empty side")) };
let mut corner = |k: Key, h: HPoint| -> Result<mt::Vertex> {
Ok(match verts.entry(k) {
Entry::Occupied(e) => e.get().clone(),
Entry::Vacant(e) => e.insert(vertex(h)?).clone(),
})
};
let (va, vb) = (corner(ka, first)?, corner(kb, last)?);
let curve = nurbs_curve(mt::KnotVec::bezier_knot(seg.len() - 1), &seg)?;
let e = mt::Edge::try_new(&va, &vb, curve).map_err(fail)?;
edges.insert((ka, kb), e.clone());
e
};
pieces.push(e);
}
if side >= 2 {
pieces.reverse();
pieces.iter_mut().for_each(|e| *e = e.inverse());
}
wire.extend(pieces);
}
let [ps, pt] = p.degree;
let rows: Vec<Vec<mt::Vector4>> = (0..=ps).map(|i| (0..=pt).map(|j| h4(p.point(i, j))).collect()).collect();
let surf = surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
faces.push(mt::Face::try_new(vec![wire.into()], surf).map_err(fail)?);
}
Ok(faces)
}
#[cfg(test)]
mod tests {
use solvecraft_geom::Vec3;
use solvecraft_geom::bspline::NurbsSurface;
use solvecraft_geom::tspline::{Cut, TSpline};
use truck_modeling as mt;
use super::tspline_sheet;
use crate::fuzz::Rng;
/// A rational bicubic with uneven knots over a 10 × 8 mm area, bulging up to ~3 mm.
fn surface(seed: u64) -> TSpline {
let mut rng = Rng::new(seed);
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
let (nu, nv) = (ku.len() - 4, kv.len() - 4);
let mut pts = Vec::new();
for j in 0..nv {
for i in 0..nu {
pts.push(Vec3::new(10.0 * i as f64 / (nu - 1) as f64, 8.0 * j as f64 / (nv - 1) as f64, 3.0 * rng.unit()));
}
}
let w: Vec<f64> = (0..pts.len()).map(|_| 0.7 + 0.6 * rng.unit()).collect();
TSpline::from_nurbs(&NurbsSurface::new([3, 3], [ku, kv], &pts, &w).unwrap()).unwrap()
}
/// Distance from `p` to the T-spline: nearest of a sample grid, then Gauss–Newton.
fn distance(ts: &TSpline, p: Vec3) -> f64 {
let [ds, dt] = ts.domain();
let n = 24;
let mut best = (f64::MAX, 0.0, 0.0);
for i in 0..=n {
for j in 0..=n {
let (s, t) = (ds[0] + (ds[1] - ds[0]) * i as f64 / n as f64, dt[0] + (dt[1] - dt[0]) * j as f64 / n as f64);
let d = ts.eval(s, t).unwrap().dist(p);
if d < best.0 {
best = (d, s, t);
}
}
}
let (_, mut s, mut t) = best;
for _ in 0..60 {
let [q, a, b] = ts.derivs(s, t).unwrap();
let r = q - p;
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
let det = aa * bb - ab * ab;
if det.abs() < 1e-30 {
break;
}
let (ga, gb) = (a.dot(r), b.dot(r));
s = (s - (bb * ga - ab * gb) / det).clamp(ds[0], ds[1]);
t = (t - (aa * gb - ab * ga) / det).clamp(dt[0], dt[1]);
}
ts.eval(s, t).unwrap().dist(p)
}
fn check_sheet(ts: &TSpline, faces: usize) {
let body = tspline_sheet(ts).unwrap();
assert!(body.is_surface());
assert_eq!(body.shell_count(), 1);
assert_eq!(body.face_count(), faces);
let shell = &body.solid.boundaries()[0];
assert_eq!(shell.shell_condition(), mt::ShellCondition::Oriented, "neighbours share edges, run opposite ways");
let euler = body.vertex_count() as i64 - body.edge_count() as i64 + body.face_count() as i64;
assert_eq!(euler, 1, "one sewn sheet (a disc)");
let mesh = body.tessellate(0.01).unwrap();
assert!(!mesh.triangles.is_empty());
// Mesh points lie on the surface (not merely within the tessellation tolerance).
let step = (mesh.positions.len() / 300).max(1);
let worst = mesh.positions.iter().step_by(step).map(|p| distance(ts, *p)).fold(0.0, f64::max);
eprintln!("t-spline sheet: {faces} faces, {} mesh points, farthest checked {worst:e} mm from the surface", mesh.positions.len());
assert!(worst < 1e-6, "a mesh point is {worst} mm off the T-spline");
}
#[test]
fn regular_tspline_becomes_one_nurbs_face() {
check_sheet(&surface(1), 1);
}
#[test]
fn tspline_with_t_junctions_becomes_sewn_bezier_faces() {
let ts = surface(2).refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
assert!(!ts.mesh().is_regular());
let n = ts.bezier_elements().unwrap().len();
check_sheet(&ts, n);
}
#[test]
fn hostile_tsplines_never_panic() {
let base = surface(3);
let n = base.mesh().vertices().len();
let mesh = base.mesh().clone();
// Every control point at one place, enormous coordinates, a needle-thin patch.
let cases: Vec<Vec<Vec3>> = vec![
vec![Vec3::new(1.0, 2.0, 3.0); n],
(0..n).map(|k| Vec3::new(1e300, -1e300, k as f64 * 1e299)).collect(),
(0..n).map(|k| Vec3::new(k as f64 * 1e-12, 0.0, 0.0)).collect(),
];
for pts in cases {
let Ok(ts) = TSpline::new(mesh.clone(), [3, 3], &pts, &vec![1.0; n]) else { continue };
if let Ok(body) = tspline_sheet(&ts) {
let _ = body.tessellate(0.01);
}
if let Ok(r) = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }])
&& let Ok(body) = tspline_sheet(&r)
{
let _ = body.tessellate(0.01);
}
}
}
}