* Add a one-surface fill of closed 3D loops to geom
A closed loop of curves that does not lie in a plane has no exact surface;
the usual answer is one smooth face fitted over it. fill_loop does that:
- the loop's best-fit plane (Newell normal), with a frame chosen from the
loop itself so the result does not depend on where the loop starts, its
direction, or where the part sits
- the loop seen along that normal must be simple and must not fold or run
along the normal; otherwise it is refused ("not supported yet"), never
filled with wrong geometry
- one non-rational bicubic B-spline height field over the loop's box,
fitted by least squares to the loop's samples (The NURBS Book 9.4.1,
9.4.3, eq. 9.62/9.65) with a thin-plate smoothness term
lambda*A*int(h_uu^2 + 2 h_uv^2 + h_vv^2), A one grid cell's area, so the
fit is the same at any scale; only planes are reproduced exactly
- span counts per direction (long narrow loops fit), refined until the
loop is within tolerance, else refused with the deviation reached
bspline: bisected span search, basis functions on a stack array, one
clamping helper shared by curves and surfaces, one binomial helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
* Patch fills non-planar loops of edges with one smooth face (#250)
surface.patch with edges refused any loop that is not planar ("not
supported yet: patching a loop that is not planar"). It now fills it:
- planar loops still get one exact planar face, but planarity is certified
conservatively: lines by their ends, B-spline/NURBS edges by their control
points, intersection curves by their samples lying on a support plane
within tolerance; a loop that can't be certified is filled instead
- other loops get one face from geom's fill_loop, bounded by the body's own
chained edges, whole (no split): its history is one generated face whose
edges are the body's, and it stitches back into a closed solid
- intersection-curve edges are sampled by a seeded, guarded double
projection shared by the planarity test and the fill (plane_fit)
- the face tolerance is 10x the planarity tolerance (documented: later
operations take the face to contain its edges within it)
- edge picking runs inside the guard; the geom->truck conversion is shared
with the T-spline sheets (spline_conv); the Newell plane fit is shared with
STEP import
Tests: 3- to 8-sided, concave (dart, arrow, plus), flat-corner, two-edge and
imported rims fill as one face and stitch closed (volumes against an
independent integral); finite mesh normals, same result for any pick order
or placement; non-height-field loops and too-fine detail are refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
* Test the Patch dialog filling a non-planar loop of picked edges
The Patch dialog's only test used a sketch profile. Picking the four
curved rim edges of a box cut by a sideways cylinder now makes one
surface.patch command whose result is a single smooth face over the rim.
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>
254 lines
11 KiB
Rust
254 lines
11 KiB
Rust
//! T-spline sheets: a T-spline surface (`solidcraft_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 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};
|
||
|
||
/// 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 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> {
|
||
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();
|
||
mt::Face::try_new(vec![wire], crate::spline_conv::surface(n)?).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 = 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)?);
|
||
}
|
||
Ok(faces)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
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;
|
||
|
||
/// 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);
|
||
}
|
||
}
|
||
}
|
||
}
|