Files
OneCAD/crates/geom/src/tspline/refine.rs
T
Iraklis KourtisandClaude Opus 5.5 a04ef4ca59 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:46:22 -04:00

318 lines
13 KiB
Rust

//! 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)
}