solidcraft-step: faces around a spline closed on itself get a seam

A face on a B-spline surface whose first and last control columns are the
same (closed around u), bounded by loops that go once around without a seam
edge, was refused ("a loop across a closed spline surface's seam"). It now
gets a seam as cylinders and cones do, along the surface's own boundary
column (u at the start of its knots, an exact iso-curve of the net), each
loop split where it crosses that column when it has no vertex on it; the
period is the knots' width. At a pole a loop may jump across u, where the
seam's two uses meet a period apart.

The step-conformance NURBS cylinder, dome and torus ring now read. With
SOLIDCRAFT_KERNEL=smlib all 17 parts import into SMLib with their exact
volumes and face counts. A spline closed in v only, or both ways, is still
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brennen Herbruck
2026-10-21 12:35:19 -04:00
committed by jyviko
parent b886ac36b9
commit a89c67adf9
4 changed files with 142 additions and 40 deletions
+3 -13
View File
@@ -1902,9 +1902,8 @@ fn corpus_truck_and_schema_4_counts() {
/// The step-conformance parts read into SMLib (`SOLIDCRAFT_KERNEL=smlib`): the STEP reader's
/// schema 4 record, SMLib's body of it, back as a kernel body. Each has its exact volume and
/// face count, and every face is traced to a face of the file. The NURBS cylinder, dome and
/// torus wind around a closed spline surface without a seam edge, which the reader refuses for
/// now, and stay refused on this path with that reason.
/// face count, and every face is traced to a face of the file (the NURBS cylinder, dome and
/// torus ring among them: their faces wind around a spline closed on itself, and get a seam).
#[cfg(feature = "smlib")]
#[test]
fn step_conformance_parts_read_into_smlib() {
@@ -1930,22 +1929,13 @@ fn step_conformance_parts_read_into_smlib() {
items.sort_unstable();
let (mut v, mut n) = (0.0, 0);
for item in items {
let made = parallel::smlib_part(&ex, item, units);
if name.ends_with("_nurbs") {
let msg = made.err().map(|f| f.msg).unwrap_or_default();
assert!(msg.contains("closed spline surface's seam"), "{name}: {msg}");
continue;
}
let (part, _) = made.unwrap_or_else(|f| panic!("{name}: {}", f.msg));
let (part, _) = parallel::smlib_part(&ex, item, units).unwrap_or_else(|f| panic!("{name}: {}", f.msg));
assert_eq!(part.face_origin.len(), part.body.face_count(), "{name}");
assert!(part.face_origin.iter().all(|f| ex.get(*f).is_some_and(|e| e.name() == "ADVANCED_FACE")), "{name}: {:?}", part.face_origin);
let m = measure(&part.body).unwrap_or_else(|e| panic!("{name}: {e}"));
v += m.volume;
n += m.faces;
}
if name.ends_with("_nurbs") {
continue;
}
assert!(rel(v, volume) < 1e-3, "{name}: volume {v} vs {volume}");
assert_eq!(n, faces, "{name}: faces");
}
+7
View File
@@ -963,6 +963,10 @@ impl<'b, 'a> Builder<'b, 'a> {
// does not wrap: a loop around a closed spline surface without a seam edge jumps across
// its domain.
let natural = surface.natural();
// At a pole (where the surface collapses to a point across a direction) a loop may jump
// in that direction: the seam's two uses meet there a period apart.
let poles = SurfaceEval::new(&surface).ok();
let at_pole = |uv: [f64; 2]| poles.as_ref().is_some_and(|e| e.pole(uv).iter().any(|p| *p));
for (l, pc) in edge_loops.iter().zip(&pcs) {
let mut ends = Vec::with_capacity(l.len());
for (&(_, forward), c) in l.iter().zip(pc) {
@@ -972,6 +976,9 @@ impl<'b, 'a> Builder<'b, 'a> {
}
for k in 0..ends.len() {
let (Some((start, _)), Some((_, end))) = (ends.get(k), ends.get(if k == 0 { ends.len() - 1 } else { k - 1 })) else { continue };
if at_pole(*start) || at_pole(*end) {
continue;
}
for d in 0..2 {
// Only a bounded direction has a seam to jump; half its width is no gap.
let Some(Natural::Range(a, b)) = natural.get(d) else { continue };
+130 -21
View File
@@ -5,7 +5,11 @@
//! a ruling line on a cylinder or cone, a meridian arc on a sphere, from a vertex of one loop to
//! the other loop (split there when it has no vertex at that angle) or to the pole the face
//! covers; on a surface of revolution, its profile turned to the vertex's angle. The loops merge into one: the first, the seam out, the second (or nothing at a pole),
//! the seam back. A torus face winding without a seam is refused for now.
//! the seam back. A face on a B-spline surface that closes on itself around u (its first and
//! last control columns the same) gets a seam the same way, along the surface's own boundary
//! column (u at the start of its knots), the loops split there where they have no vertex on it;
//! the period is the width of its knots. A torus face winding without a seam, and a spline
//! closed in v or both ways, are refused for now.
use std::f64::consts::{PI, TAU};
@@ -25,7 +29,12 @@ fn arr(v: Vec3) -> [f64; 3] {
}
fn wrap(x: f64) -> f64 {
x - (x / TAU).round() * TAU
wrap_by(x, TAU)
}
/// `x` wrapped into (−period/2, period/2].
fn wrap_by(x: f64, period: f64) -> f64 {
x - (x / period).round() * period
}
/// The axis frame of a surface of revolution: origin, unit axis, and the pole points at the
@@ -35,6 +44,8 @@ struct Axis {
z: Vec3,
kind: Kind,
poles: [Option<Vec3>; 2],
/// The period around (2π, or a closed spline's knot width).
period: f64,
}
#[derive(Clone, PartialEq)]
@@ -43,6 +54,12 @@ enum Kind {
Sphere(f64),
/// A placed revolution with u the angle: its profile.
Revolved(BCurve),
/// A B-spline surface closed on itself around u: its boundary column at u = `u0` (the start
/// of its knots), a curve in v, along which every seam runs.
Iso {
u0: f64,
column: BCurve,
},
}
fn axis(s: &BSurface) -> Option<Axis> {
@@ -53,17 +70,17 @@ fn axis(s: &BSurface) -> Option<Axis> {
match s {
BSurface::Cylinder { frame: f, .. } => {
let (origin, z) = frame(f);
Some(Axis { origin, z, kind: Kind::Ruled, poles: [None, None] })
Some(Axis { origin, z, kind: Kind::Ruled, poles: [None, None], period: TAU })
}
BSurface::Cone { frame: f, radius_at_origin, half_angle } => {
let (origin, z) = frame(f);
let apex = origin + z * (-radius_at_origin / half_angle.tan());
let poles = if *half_angle > 0.0 { [Some(apex), None] } else { [None, Some(apex)] };
Some(Axis { origin, z, kind: Kind::Ruled, poles })
Some(Axis { origin, z, kind: Kind::Ruled, poles, period: TAU })
}
BSurface::Sphere { frame: f, radius } => {
let (origin, z) = frame(f);
Some(Axis { origin, z, kind: Kind::Sphere(*radius), poles: [Some(origin - z * *radius), Some(origin + z * *radius)] })
Some(Axis { origin, z, kind: Kind::Sphere(*radius), poles: [Some(origin - z * *radius), Some(origin + z * *radius)], period: TAU })
}
BSurface::PlacedRevolution { profile, origin, axis, matrix, forward: false } if *matrix == IDENTITY => {
let (origin, z) = (Vec3::from(*origin), Vec3::from(*axis));
@@ -76,12 +93,34 @@ fn axis(s: &BSurface) -> Option<Axis> {
(d - z * d.dot(z)).len() <= 1e-9 * (1.0 + d.len())
})
};
Some(Axis { origin, z, kind: Kind::Revolved((**profile).clone()), poles: [pole(a), pole(b)] })
Some(Axis { origin, z, kind: Kind::Revolved((**profile).clone()), poles: [pole(a), pole(b)], period: TAU })
}
_ => None,
}
}
/// The seam frame of a B-spline surface closed around u only ([`Kind::Iso`]): its column at the
/// start of its u knots, and the points its v ends collapse to (a pole), if they do.
fn spline_axis(s: &BSurface, eval: &SurfaceEval) -> Option<Axis> {
let dom = eval.domain();
let (Some(period), None) = (dom.period[0], dom.period[1]) else { return None };
let ((u0, u1), (v0, v1)) = (dom.range[0]?, dom.range[1]?);
let column = match s {
BSurface::Spline { v_knots, points, .. } => BCurve::Spline { knots: v_knots.clone(), points: points.first()?.clone() },
BSurface::RationalSpline { v_knots, homogeneous, .. } => {
BCurve::RationalSpline { knots: v_knots.clone(), homogeneous: homogeneous.first()?.clone() }
}
_ => return None,
};
// A v end is a pole where the surface's points across u there are one point.
let pole = |v: f64| {
let p = eval.point([u0, v])?;
let across = (1..8).filter_map(|k| eval.point([u0 + (u1 - u0) * k as f64 / 8.0, v])).map(|q| q.dist(p)).fold(0.0, f64::max);
(across <= 1e-9 * (1.0 + p.len())).then_some(p)
};
Some(Axis { origin: Vec3::new(0.0, 0.0, 0.0), z: Vec3::new(0.0, 0.0, 1.0), kind: Kind::Iso { u0, column }, poles: [pole(v0), pole(v1)], period })
}
const IDENTITY: [[f64; 4]; 4] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
/// `p` turned by `angle` about the axis through `o` along unit `k` (right-handed).
@@ -121,16 +160,18 @@ impl Builder<'_, '_> {
for fi in 0..self.faces.len() {
let Some(face) = self.faces.get(fi) else { continue };
let surface = face.surface.clone();
let periodic = super::natural_periods(&surface);
if !periodic.iter().any(|p| *p) {
let natural = super::natural_periods(&surface);
let eval = SurfaceEval::new(&surface).map_err(|e| e.to_string())?;
// Periodic as the surface is, or as a spline closed on itself is (its knot width).
let periods = [0, 1].map(|d| if natural[d] { Some(TAU) } else { eval.domain().period[d] });
if periods.iter().all(Option::is_none) {
continue;
}
let eval = SurfaceEval::new(&surface).map_err(|e| e.to_string())?;
let mut up = Vec::new();
let mut down = Vec::new();
for (li, l) in face.loops.iter().enumerate() {
let LoopB::Edges(es) = l else { continue };
let w = self.winding(&eval, es, periodic)?;
let w = self.winding(&eval, es, periods)?;
if w[1] != 0 || w[0].abs() > 1 {
return Err(format!("face #{}: {}", face.step, unsupported("a loop winding around a surface more than once or in v")));
}
@@ -144,8 +185,8 @@ impl Builder<'_, '_> {
continue;
}
let step = face.step;
let Some(ax) = axis(&surface) else {
return Err(format!("face #{step}: {}", unsupported("a face winding around a torus without a seam edge")));
let Some(ax) = axis(&surface).or_else(|| spline_axis(&surface, &eval)) else {
return Err(format!("face #{step}: {}", unsupported("a face winding around a torus or a spline closed in v without a seam edge")));
};
match (up.as_slice(), down.as_slice()) {
// The face lies to the left of its loops in (u, v): above a loop running +u.
@@ -160,7 +201,7 @@ impl Builder<'_, '_> {
}
/// Turns (in u, in v) a loop makes around a periodic surface.
fn winding(&self, eval: &SurfaceEval, es: &[(u32, bool)], periodic: [bool; 2]) -> R<[i64; 2]> {
fn winding(&self, eval: &SurfaceEval, es: &[(u32, bool)], periods: [Option<f64>; 2]) -> R<[i64; 2]> {
let mut last: Option<[f64; 2]> = None;
let mut total = [0.0; 2];
for &(e, forward) in es {
@@ -174,15 +215,15 @@ impl Builder<'_, '_> {
let Some(q) = eval.project(p, last, f64::INFINITY) else { continue };
if let Some(l) = last {
for d in 0..2 {
if periodic[d] {
total[d] += wrap(q[d] - l[d]);
if let Some(p) = periods[d] {
total[d] += wrap_by(q[d] - l[d], p) / p;
}
}
}
last = Some(q);
}
}
Ok(total.map(|x| (x / TAU).round() as i64))
Ok(total.map(|x| x.round() as i64))
}
/// The vertex where use `k` of a loop starts.
@@ -221,6 +262,20 @@ impl Builder<'_, '_> {
}
(BCurve::Circle { frame: Frame { origin: arr(ax.origin), x_axis: arr(x), y_axis: arr(y) }, radius: *r }, [0.0, theta], true)
}
Kind::Iso { column, .. } => {
// Each vertex's v on the surface (a pole's from its end of the v range).
let Some((v0, v1)) = eval.domain().range[1] else { return Err("a closed spline with no v range".into()) };
let v_of = |p: Vec3| -> R<f64> {
Ok(match ax.poles {
[Some(q), _] if q.dist(p) <= self.tol => v0,
[_, Some(q)] if q.dist(p) <= self.tol => v1,
_ => eval.project(p, None, f64::INFINITY).ok_or("a seam vertex off its surface")?[1],
})
};
let (va, vb) = (v_of(pa)?, v_of(pb)?);
let along = va < vb;
(column.clone(), if along { [va, vb] } else { [vb, va] }, along)
}
Kind::Revolved(profile) => {
// a's angle; each vertex's profile parameter (a pole's from the profile's end).
let qa = eval.project(pa, None, f64::INFINITY).ok_or("a seam vertex off its surface")?;
@@ -277,6 +332,10 @@ impl Builder<'_, '_> {
}
}
let k = k.ok_or_else(|| format!("face #{step}: a loop around the axis on its pole"))?;
let k = match ax.kind {
Kind::Iso { u0, .. } => self.seam_start(fi, li, u0, ax, eval)?,
_ => k,
};
let ring = self.rotated(fi, li, k)?;
let a = self.start(*ring.first().ok_or("empty loop")?)?;
// The face's vertex loop at the pole, if it has one, else a new vertex there.
@@ -328,6 +387,16 @@ impl Builder<'_, '_> {
};
Ok(wrap(ua - ub).abs() * r <= s.tol)
};
if let Kind::Iso { u0, .. } = ax.kind {
// Both loops start on the spline's boundary column, split there where they don't.
let i = self.seam_start(fi, la, u0, ax, eval)?;
let j = self.seam_start(fi, lb, u0, ax, eval)?;
*splits += 2;
if *splits > MAX_SPLITS {
return Err(format!("more than {MAX_SPLITS} edges split for seams"));
}
return self.join_by_seam(fi, la, lb, i, j, ax, eval);
}
let mut pair = None;
'search: for (i, ua) in a_es.iter().enumerate().take(100) {
for (j, ub) in b_es.iter().enumerate().take(100) {
@@ -348,7 +417,7 @@ impl Builder<'_, '_> {
let target = angle(self, self.start(*a_es.first().ok_or("empty loop")?)?)?;
let mut found = None;
for &(e, _) in &b_es {
if let Some(t) = self.crossing(e, target, eval)? {
if let Some(t) = self.crossing(e, target, ax.period, eval)? {
found = Some((e, t));
break;
}
@@ -366,6 +435,13 @@ impl Builder<'_, '_> {
(0, j.ok_or("the split vertex is not on its loop")?)
}
};
self.join_by_seam(fi, la, lb, i, j, ax, eval)
}
/// Join loops `la` and `lb` of face `fi`, from their uses `i` and `j`, by a seam between
/// the vertices those uses start at: loop A, the seam out, loop B, the seam back.
#[allow(clippy::too_many_arguments)]
fn join_by_seam(&mut self, fi: usize, la: usize, lb: usize, i: usize, j: usize, ax: &Axis, eval: &SurfaceEval) -> R<()> {
let a_ring = self.rotated(fi, la, i)?;
let b_ring = self.rotated(fi, lb, j)?;
let va = self.start(*a_ring.first().ok_or("empty loop")?)?;
@@ -385,11 +461,13 @@ impl Builder<'_, '_> {
/// The parameter inside edge `e`'s interval where its curve reaches the angle `target`
/// about the axis, if it does.
fn crossing(&self, e: u32, target: f64, eval: &SurfaceEval) -> R<Option<f64>> {
fn crossing(&self, e: u32, target: f64, period: f64, eval: &SurfaceEval) -> R<Option<f64>> {
let edge = self.edges.get(e as usize).ok_or("edge reference")?;
let c = CurveEval::new(&edge.curve).map_err(|m| m.to_string())?;
let [lo, hi] = edge.interval;
let d = |t: f64| c.point(t).and_then(|p| eval.project(p, None, f64::INFINITY)).map(|q| wrap(q[0] - target));
let d = |t: f64| c.point(t).and_then(|p| eval.project(p, None, f64::INFINITY)).map(|q| wrap_by(q[0] - target, period));
// A jump across the half period is the wrap, not a crossing.
let near = period / TAU;
let n = 64;
let mut prev: Option<(f64, f64)> = None;
for k in 0..=n {
@@ -397,8 +475,8 @@ impl Builder<'_, '_> {
let Some(x) = d(t) else { continue };
if let Some((t0, x0)) = prev
&& x0.signum() != x.signum()
&& x0.abs() < 1.0
&& x.abs() < 1.0
&& x0.abs() < near
&& x.abs() < near
{
let (mut a, mut b, mut fa) = (t0, t, x0);
for _ in 0..100 {
@@ -420,6 +498,37 @@ impl Builder<'_, '_> {
Ok(None)
}
/// The use of loop `li` of face `fi` that starts on the surface's column at u = `u0`: a
/// vertex there within the tolerance, else one made by splitting the loop's edge that
/// crosses it.
fn seam_start(&mut self, fi: usize, li: usize, u0: f64, ax: &Axis, eval: &SurfaceEval) -> R<usize> {
let Some(LoopB::Edges(es)) = self.faces.get(fi).and_then(|f| f.loops.get(li)) else { return Err("loop reference".into()) };
let es = es.clone();
let on_seam = |s: &Self, v: u32| -> R<bool> {
let p = s.vpoint(v)?;
let Some(q) = eval.project(p, None, f64::INFINITY) else { return Ok(false) };
Ok(eval.point([u0, q[1]]).is_some_and(|c| c.dist(p) <= s.tol))
};
for (k, u) in es.iter().enumerate() {
if on_seam(self, self.start(*u)?)? {
return Ok(k);
}
}
for &(e, _) in &es {
if let Some(t) = self.crossing(e, u0, ax.period, eval)? {
let v = self.split_edge(e, t)?;
let Some(LoopB::Edges(now)) = self.faces.get(fi).and_then(|f| f.loops.get(li)) else { return Err("loop reference".into()) };
for (k, u) in now.iter().enumerate() {
if self.start(*u)? == v {
return Ok(k);
}
}
return Err("the split vertex is not on its loop".into());
}
}
Err(unsupported("a loop around a closed spline that never reaches its boundary column"))
}
/// Split edge `e` at `t` inside its interval: it keeps [min, t], a new edge takes
/// [t, max], and every loop using it uses both. Returns the new vertex.
fn split_edge(&mut self, e: u32, t: f64) -> R<u32> {
+2 -6
View File
@@ -823,8 +823,8 @@ fn hostile_topology_is_an_error_never_a_panic() {
}
/// The step-conformance parts (tests/step-conformance, CC0): every one reads, its faces facing
/// out of its material, except the NURBS cylinder, dome and torus whose faces wind around a
/// closed spline surface without a seam edge (refused for now).
/// out of its material; the NURBS cylinder, dome and torus ring, whose faces wind around a
/// spline closed on itself in u without a seam edge, get one along its boundary column.
#[test]
fn step_conformance_parts() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/step-conformance");
@@ -835,10 +835,6 @@ fn step_conformance_parts() {
for path in paths {
let name = path.file_stem().unwrap().to_string_lossy().into_owned();
let r = read_solids(&p21::parse(&std::fs::read_to_string(&path).unwrap()).unwrap());
if name.ends_with("_nurbs") {
assert!(r.solids.is_empty() && r.failures[0].message.contains("closed spline surface's seam"), "{name}: {:?}", r.failures);
continue;
}
assert!(r.failures.is_empty() && r.solids.len() == 1, "{name}: {:?}", r.failures);
let b = &r.solids[0].brep;
b.validate().unwrap();