kernel: T-spline sheets as record faces; NURBS faces on the record

surfaces.rs gains the record pieces NURBS faces are built from: a NURBS surface
and curve as record geometry, a pcurve along a parameter line, a whole tensor face
bounded by its net's boundary rows and columns (exact for a clamped surface), and
all_new for a face new to an operation. tspline_sheet makes a regular T-spline
that tensor face and an irregular one its Bézier elements, each side split at the
T-points, each edge an exact Bézier piece of its side with a straight pcurve on
every patch it bounds, shared between neighbours. Tests ported to the record.

(not built: base in progress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 01:03:36 -04:00
parent 23bfae0107
commit 9b5e0a8b42
2 changed files with 163 additions and 56 deletions
+77
View File
@@ -19,6 +19,7 @@ use std::collections::BTreeSet;
use solidcraft_geom::brep::eval::CurveEval;
use solidcraft_geom::brep::{self as b, Brep, Curve2};
use solidcraft_geom::bspline::{HPoint, NurbsSurface};
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
use crate::body::Body;
@@ -354,6 +355,15 @@ pub(crate) fn surface_body(r: &Brep) -> Result<Body> {
record_body(r).map(|(body, _)| body.without_history())
}
/// `body` (made by an operation on `operands`) with the history that every face and edge is new to
/// it.
pub(crate) fn all_new(body: Body, operands: &[&Body]) -> Result<Body> {
let r = crate::native::brep_record(&body)?;
let faces = vec![record::generated(Vec::new()); r.faces.len()];
let h = record_history(&r, operands.len(), faces, |_, _, _| Vec::new());
body.with_history_counts(h, &record::counts(operands))
}
/// The faces `picked` of `body` (its record `r`; each with whether to turn it over) as their own
/// body, each the body's face (Kept), every edge the body's.
fn taken(body: &Body, r: &Brep, picked: &[(usize, bool)]) -> Result<Body> {
@@ -364,6 +374,73 @@ fn taken(body: &Body, r: &Brep, picked: &[(usize, bool)]) -> Result<Body> {
with_history(result, same, h, &record::counts(&[body]))
}
// ---- NURBS faces --------------------------------------------------------------------------
/// A NURBS surface as a record surface (a plain B-spline where every weight is 1).
pub(crate) fn spline_surface(n: &NurbsSurface) -> b::Surface {
let [nu, nv] = n.count();
let [ku, kv] = n.knots().clone();
if n.homogeneous().iter().all(|h| h.w == 1.0) {
let points = (0..nu).map(|i| (0..nv).map(|j| a3(n.point(i, j).xyz())).collect()).collect();
return b::Surface::Spline { u_knots: ku, v_knots: kv, points };
}
let homogeneous = (0..nu).map(|i| (0..nv).map(|j| n.point(i, j)).map(|h| [h.x, h.y, h.z, h.w]).collect()).collect();
b::Surface::RationalSpline { u_knots: ku, v_knots: kv, homogeneous }
}
/// The NURBS curve of homogeneous control points `ctrl` on `knots` as a record curve, and its
/// parameter interval.
pub(crate) fn spline_curve(ctrl: &[HPoint], knots: Vec<f64>) -> Result<(b::Curve, [f64; 2])> {
let degree = knots.len().checked_sub(ctrl.len() + 1).filter(|d| *d >= 1).ok_or_else(|| invalid("a spline's knots"))?;
let interval = [*knots.get(degree).ok_or_else(|| invalid("knots"))?, *knots.get(ctrl.len()).ok_or_else(|| invalid("knots"))?];
let curve = if ctrl.iter().all(|h| h.w == 1.0) {
b::Curve::Spline { knots, points: ctrl.iter().map(|h| a3(h.xyz())).collect() }
} else {
b::Curve::RationalSpline { knots, homogeneous: ctrl.iter().map(|h| [h.x, h.y, h.z, h.w]).collect() }
};
Ok((curve, interval))
}
/// The pcurve of an edge along a straight line of its face's parameters: from `a` at the
/// interval's start to `z` at its end.
pub(crate) fn uv_line(a: [f64; 2], z: [f64; 2], [t0, t1]: [f64; 2]) -> Curve2 {
Curve2::Spline { knots: vec![t0, t0, t1, t1], points: vec![a, z] }
}
/// One face: the whole of the NURBS surface `n`, bounded by its four boundary curves (each the
/// boundary row or column of its net on its knots, exact for a clamped surface), as a surface
/// body with no history.
pub(crate) fn tensor_sheet(n: &NurbsSurface) -> Result<Body> {
let [nu, nv] = n.count();
if nu < 2 || nv < 2 {
return Err(invalid("a face needs at least 2 x 2 control points"));
}
let [ku, kv] = n.knots().clone();
let [[u0, u1], [v0, v1]] = n.domain();
let corner = |i: usize, j: usize| n.point(i, j).project().ok_or_else(|| failed("a corner of the surface has no position"));
let corners = [corner(0, 0)?, corner(nu - 1, 0)?, corner(nu - 1, nv - 1)?, corner(0, nv - 1)?];
let mut parts = Parts { tolerance: tolerance_of(corners.into_iter()), ..Parts::default() };
parts.vertices = corners.iter().map(|p| b::Vertex { point: a3(*p), tolerance: parts.tolerance }).collect();
let row = |j: usize| (0..nu).map(|i| n.point(i, j)).collect::<Vec<_>>();
let column = |i: usize| (0..nv).map(|j| n.point(i, j)).collect::<Vec<_>>();
// Bottom (v0) and top (v1) run along u, left (u0) and right (u1) along v; the loop goes bottom,
// right, top backwards, left backwards: counter-clockwise in (u, v).
let sides = [
(row(0), ku.clone(), [0, 1], [[u0, v0], [u1, v0]], true),
(column(nu - 1), kv.clone(), [1, 2], [[u1, v0], [u1, v1]], true),
(row(nv - 1), ku, [3, 2], [[u0, v1], [u1, v1]], false),
(column(0), kv, [0, 3], [[u0, v0], [u0, v1]], false),
];
let mut uses = Vec::with_capacity(4);
for (i, (ctrl, knots, [a, z], [pa, pz], forward)) in sides.into_iter().enumerate() {
let (curve, interval) = spline_curve(&ctrl, knots)?;
parts.edges.push(b::Edge { curve, interval, vertices: [index(a)?, index(z)?], tolerance: parts.tolerance, uses: Vec::new() });
uses.push(b::OrientedEdge { edge: index(i)?, forward, pcurve: uv_line(pa, pz, interval) });
}
parts.faces.push((b::Face { surface: spline_surface(n), domain: n.domain(), loops: vec![b::Loop::Edges { edges: uses }] }, true));
surface_body(&parts.canonical()?.brep)
}
// ---- Patches ----------------------------------------------------------------------------
/// A planar surface filling a sketch region. Its history has no operands: the face is new.
+86 -56
View File
@@ -2,29 +2,29 @@
//! 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.
//!
//! Every edge is an exact piece of its patch's side (a Bézier segment of the side's control
//! points) and its pcurve on each face it bounds a straight line in that patch's parameters.
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use solidcraft_geom::brep as b;
use solidcraft_geom::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, bezier_segment};
use solidcraft_geom::tspline::TSpline;
use truck_modeling as mt;
use crate::body::Body;
use crate::spline_conv::{h4, nurbs_curve, nurbs_surface};
use crate::{KernelError, Result, guard};
use crate::surfaces::{Parts, spline_curve, spline_surface, surface_body, uv_line};
use crate::{KernelError, Result};
/// 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() {
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))
return crate::surfaces::tensor_sheet(&n);
}
let patches = ts.bezier_elements().map_err(spline_err)?;
surface_body(&patch_faces(&patches)?.canonical()?.brep)
}
fn spline_err(e: SplineError) -> KernelError {
@@ -35,14 +35,8 @@ fn fail(m: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("t-spline sheet: {m}"))
}
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)))
}
/// One face from a clamped tensor surface, bounded by its four boundary curves.
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
crate::spline_conv::tensor_face(n)
fn index(i: usize) -> Result<u32> {
u32::try_from(i).map_err(|_| KernelError::TooLarge("t-spline sheet: too many patches".into()))
}
/// A parameter point, exact (knot values are shared, so equal points have equal bits).
@@ -52,9 +46,13 @@ 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>> {
fn st(k: Key) -> [f64; 2] {
[f64::from_bits(k.0), f64::from_bits(k.1)]
}
/// 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<Parts> {
// 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();
@@ -67,14 +65,21 @@ fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
}
}
}
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());
let mut parts = Parts::default();
let mut verts: HashMap<Key, u32> = HashMap::new();
// Each edge by its ends' parameters, in the direction it was made.
let mut edges: HashMap<(Key, Key), u32> = HashMap::new();
let mut corners: Vec<[f64; 3]> = Vec::new();
for p in patches {
let mut wire: Vec<mt::Edge> = Vec::new();
// The patch's own (u, v) on [0, 1]² of a global (s, t).
let local = |k: Key| {
let [s, t] = st(k);
[(s - p.s[0]) / (p.s[1] - p.s[0]), (t - p.t[0]) / (p.t[1] - p.t[0])]
};
let mut uses: Vec<b::OrientedEdge> = 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.
// Side 0: t = t0, 1: s = s1, 2: t = t1, 3: s = s0; 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),
@@ -91,39 +96,62 @@ fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
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
// The edge as made (a to b with its parameter rising), and whether this side runs
// along it.
let (edge, made) = match (edges.get(&(ka, kb)), edges.get(&(kb, ka))) {
(Some(e), _) => (*e, (ka, kb)),
(None, Some(e)) => (*e, (kb, ka)),
(None, None) => {
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<u32> {
if let Some(v) = verts.get(&k) {
return Ok(*v);
}
let q = h.project().ok_or_else(|| fail("a corner has no position"))?;
let v = index(corners.len())?;
corners.push([q.x, q.y, q.z]);
verts.insert(k, v);
Ok(v)
};
let vertices = [corner(ka, first)?, corner(kb, last)?];
let degree = seg.len().saturating_sub(1);
let knots = std::iter::repeat_n(0.0, degree + 1).chain(std::iter::repeat_n(1.0, degree + 1)).collect();
let (curve, interval) = spline_curve(&seg, knots)?;
let e = index(parts.edges.len())?;
parts.edges.push(b::Edge { curve, interval, vertices, tolerance: 0.0, uses: Vec::new() });
edges.insert((ka, kb), e);
(e, (ka, kb))
}
};
pieces.push(e);
let pcurve = uv_line(local(made.0), local(made.1), [0.0, 1.0]);
pieces.push(b::OrientedEdge { edge, forward: made == (ka, kb), pcurve });
}
if side >= 2 {
pieces.reverse();
pieces.iter_mut().for_each(|e| *e = e.inverse());
pieces.iter_mut().for_each(|u| u.forward = !u.forward);
}
wire.extend(pieces);
uses.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 = nurbs_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)?);
let knots = |d: usize| std::iter::repeat_n(0.0, d + 1).chain(std::iter::repeat_n(1.0, d + 1)).collect::<Vec<_>>();
let ctrl: Vec<HPoint> = (0..=pt).flat_map(|j| (0..=ps).map(move |i| (i, j))).map(|(i, j)| p.point(i, j)).collect();
let surface = NurbsSurface::from_homogeneous([ps, pt], [knots(ps), knots(pt)], ctrl).map_err(spline_err)?;
parts.faces.push((
b::Face { surface: spline_surface(&surface), domain: [[0.0, 1.0], [0.0, 1.0]], loops: vec![b::Loop::Edges { edges: uses }] },
true,
));
}
Ok(faces)
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
for c in &corners {
bounds.add(solidcraft_geom::Vec3::new(c[0], c[1], c[2]));
}
parts.tolerance = (bounds.diagonal() * 1e-6).max(1e-5);
parts.vertices = corners.into_iter().map(|point| b::Vertex { point, tolerance: parts.tolerance }).collect();
for e in &mut parts.edges {
e.tolerance = parts.tolerance;
}
Ok(parts)
}
#[cfg(test)]
@@ -131,7 +159,6 @@ mod tests {
use solidcraft_geom::Vec3;
use solidcraft_geom::bspline::NurbsSurface;
use solidcraft_geom::tspline::{Cut, TSpline};
use truck_modeling as mt;
use super::tspline_sheet;
use crate::fuzz::Rng;
@@ -187,8 +214,11 @@ mod tests {
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 record = crate::native::brep_record(&body).unwrap();
assert!(
record.edges.iter().all(|e| e.uses.len() == 1 || (e.uses.len() == 2 && e.uses[0].upper != e.uses[1].upper)),
"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.mesh(&crate::MeshTolerance::chord(0.01)).unwrap();