Files
solidcraft-upstream/crates/geom/src/tspline/mesh.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

443 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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),
}