solidcore-step: a seam round the axis for a torus face winding round its tube

A torus face bounded by two circles round its tube (an elbow's wall between its two ends) was
refused: "a loop winding around a surface more than once or in v". Its loops wind in v, so it
gets a seam the other way about: a circle about the axis at a vertex's angle round the tube,
running from the loop that winds up the tube towards −u (the face lies to the left of its loops)
to the other. band_seam and crossing take the parameter the loops wind in. In STEP Tools'
jack_in_the_box 14 bodies that were open shells close (34 -> 48 of 56).

Test: a quarter elbow closes with a quarter-circle seam about its axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brennen Herbruck
2026-11-09 06:54:34 -05:00
parent 96a757aad2
commit 2a96a046ca
2 changed files with 99 additions and 7 deletions
+53 -7
View File
@@ -80,6 +80,9 @@ enum Kind {
major: f64,
minor: f64,
},
/// A torus face winding round its tube (a band between two circles round the tube, an elbow's
/// wall): its seam a circle about the axis, at a vertex's angle round the tube.
TorusTube,
/// 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
@@ -321,9 +324,15 @@ impl Builder<'_, '_> {
let found = axis(&surface).or_else(|| spline_axis(&surface, &eval));
let mut up = Vec::new();
let mut down = Vec::new();
// On a torus a face may wind round its tube instead (its loops turn in v, not u).
let mut tube = false;
for (li, l) in face.loops.iter().enumerate() {
let LoopB::Edges(es) = l else { continue };
let w = self.winding(&eval, es, periods)?;
let mut w = self.winding(&eval, es, periods)?;
if matches!(surface, BSurface::Torus { .. }) && w[0] == 0 && w[1].abs() == 1 {
tube = true;
w = [w[1], 0];
}
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")));
}
@@ -343,6 +352,7 @@ impl Builder<'_, '_> {
continue;
}
let step = face.step;
let found = if tube { found.map(|ax| Axis { kind: Kind::TorusTube, ..ax }) } else { found };
let Some(ax) = found else {
return Err(format!("face #{step}: {}", unsupported("a face winding around a torus or a spline closed in v without a seam edge")));
};
@@ -521,6 +531,25 @@ impl Builder<'_, '_> {
}
(BCurve::Circle { frame: Frame { origin: arr(centre), x_axis: arr(x), y_axis: arr(ax.z) }, radius: *minor }, [ta, ta + up], true)
}
Kind::TorusTube => {
// The circle about the axis through `a`: angle 0 at `a`, growing with the
// torus's u. The face lies to the left of its loops in (u, v), so from the loop
// running round the tube up (+v) it lies towards −u: the seam runs from `a` back
// round the axis to `b`, the edge from `b` to `a`.
let da = pa - ax.origin;
let h = da.dot(ax.z);
let radial = da - ax.z * h;
let r = radial.len();
let x = radial.normalized().ok_or("a torus seam on its axis")?;
let y = ax.z.cross(x);
let db = pb - ax.origin;
let back = (-(db.dot(y).atan2(db.dot(x)))).rem_euclid(TAU);
if !(back > 1e-9) {
return Err("a seam of no length".into());
}
let centre = ax.origin + ax.z * h;
(BCurve::Circle { frame: Frame { origin: arr(centre), x_axis: arr(x), y_axis: arr(y) }, radius: r }, [-back, 0.0], false)
}
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()) };
@@ -869,9 +898,11 @@ impl Builder<'_, '_> {
/// Join loops `la` (running +u) and `lb` (running −u) of face `fi` by a seam.
fn band_seam(&mut self, fi: usize, la: usize, lb: usize, ax: &Axis, eval: &SurfaceEval, splits: &mut usize) -> R<()> {
// The parameter the loops wind in: u, or v round a torus's tube.
let d = usize::from(ax.kind == Kind::TorusTube);
let angle = |s: &Self, v: u32| -> R<f64> {
let p = s.vpoint(v)?;
eval.project(p, None, f64::INFINITY).map(|q| q[0]).ok_or_else(|| "a vertex off its surface".to_string())
eval.project(p, None, f64::INFINITY).and_then(|q| q.get(d).copied()).ok_or_else(|| "a vertex off its surface".to_string())
};
let (Some(LoopB::Edges(a_es)), Some(LoopB::Edges(b_es))) =
(self.faces.get(fi).and_then(|f| f.loops.get(la)), self.faces.get(fi).and_then(|f| f.loops.get(lb)))
@@ -882,9 +913,16 @@ impl Builder<'_, '_> {
// Angles agree within the tolerance at the radius they are at.
let close = |s: &Self, va: u32, vb: u32| -> R<bool> {
let (ua, ub) = (angle(s, va)?, angle(s, vb)?);
let r = {
let p = s.vpoint(va)? - ax.origin;
(p - ax.z * p.dot(ax.z)).len()
let r = match &ax.kind {
// Round the tube the angle is the tube's, at the tube's radius at most.
Kind::TorusTube => match &s.faces.get(fi).map(|f| &f.surface) {
Some(BSurface::Torus { minor_radius, .. }) => *minor_radius,
_ => 0.0,
},
_ => {
let p = s.vpoint(va)? - ax.origin;
(p - ax.z * p.dot(ax.z)).len()
}
};
Ok(wrap(ua - ub).abs() * r <= s.tol)
};
@@ -918,7 +956,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, ax.period, eval)? {
if let Some(t) = self.crossing_in(e, d, target, ax.period, eval)? {
found = Some((e, t));
break;
}
@@ -963,10 +1001,18 @@ 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, period: f64, eval: &SurfaceEval) -> R<Option<f64>> {
self.crossing_in(e, 0, target, period, eval)
}
/// [`Self::crossing`] of the surface's parameter `d` (0: u, 1: v).
fn crossing_in(&self, e: u32, d: usize, 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_by(q[0] - target, period));
let at = d;
let d = |t: f64| {
c.point(t).and_then(|p| eval.project(p, None, f64::INFINITY)).and_then(|q| q.get(at).copied()).map(|x| wrap_by(x - target, period))
};
// A jump across the half period is the wrap, not a crossing.
let near = period / TAU;
let n = 64;
+46
View File
@@ -576,6 +576,52 @@ fn a_half_torus_between_its_rims_gets_a_seam_round_the_tube() {
});
}
/// A quarter elbow: a torus face winding round its tube between two tube circles (at u = 0 and
/// u = π/2), closed by the two disks they bound. The tube face gets a seam round the axis at a
/// vertex's angle round the tube, as a band round the axis gets one round the tube.
#[test]
fn a_quarter_elbow_gets_a_seam_round_the_axis() {
let mut w = W::default();
// The tube circle at u = 0 (in the plane y = 0, about (20, 0, 0)) and at u = π/2 (x = 0).
let ring = |w: &mut W, c: V, n: V, x: V| {
let v = w.vertex([c[0] + 5.0 * x[0], c[1] + 5.0 * x[1], c[2] + 5.0 * x[2]]);
let k = w.circle(c, n, x, 5.0);
w.add(format!("EDGE_CURVE('',#{v},#{v},#{k},.T.)"))
};
// Each circle runs with the torus's v about its normal −(the tube's direction there).
let start = ring(&mut w, [20.0, 0.0, 0.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0]);
let end = ring(&mut w, [0.0, 20.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
let a = w.axis([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let torus = w.add(format!("TOROIDAL_SURFACE('',#{a},20.,5.)"));
// The face lies to the left of its loops in (u, v): from u = 0 it runs −v, from π/2 +v.
let (x, y) = (w.oe(start, false), w.oe(end, true));
let (bx, by) = (w.bound(&[x], true, true), w.bound(&[y], false, true));
let tube = w.face(&[bx, by], torus, true);
// The end disks, facing out of the elbow: −y at its start, −x at its end.
let p0 = w.plane([20.0, 0.0, 0.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0]);
let x = w.oe(start, true);
let bx = w.bound(&[x], true, true);
let d0 = w.face(&[bx], p0, true);
let p1 = w.plane([0.0, 20.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
let x = w.oe(end, false);
let bx = w.bound(&[x], true, true);
let d1 = w.face(&[bx], p1, true);
let shell = closed_shell(&mut w, &[tube, d0, d1]);
let solid = w.add(format!("MANIFOLD_SOLID_BREP('elbow',#{shell})"));
let r = read_solids(&w.file(&[solid]));
assert!(r.failures.is_empty(), "{:?}", r.failures);
let s = r.solids.into_iter().next().unwrap();
solidcore_ops::record_check::validate(&s.brep).unwrap();
solidcore_ops::record_check::agreement(&s.brep).unwrap();
assert!(s.notes.iter().any(|n| n.contains("1 seam edges added")), "{:?}", s.notes);
// The seam: a circle about the axis (centred on it), a quarter turn long.
let seam = s.brep.edges.iter().find(
|e| matches!(&e.curve, solidcore_record::brep::Curve::Circle { frame, .. } if frame.origin[0].abs() < 1e-9 && frame.origin[1].abs() < 1e-9),
);
let [t0, t1] = solidcore_ops::label::edge_curve_interval(seam.expect("a seam about the axis")).unwrap();
assert!((t1 - t0 - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "{t0} {t1}");
}
/// A ball whose one face is bounded by two vertex loops, at its poles: the whole sphere, closed
/// by a meridian seam from pole to pole.
#[test]