brep: meridians, revolutions and whole turns exact on their forms

- A meridian of a sphere or a tube circle of a torus (a circle in a plane through the axis) has
  its exact pcurve on the label: u fixed at the arc's azimuth, v its angle from the outward
  radial, alpha + s t.
- A placed revolution has a NURBS form: its profile's control points turned about the axis on
  the quarter grid from angle zero, weights multiplied, then its matrix; the record takes forms on
  revolutions.
- `form::extent`: a face's parameter box from its boundary projected on its label (the collapsed
  parameter at a pole bounds nothing; a boundary that never goes round covers the turn), what its
  form is built over with no pcurve involved.
- A face that goes all the way round gets a form of exactly one turn, closed and so periodic.
- `along` projects inside the face's box (a spline extended past its knots, or a profile turned
  past the face, has other preimages), and takes a closed form within the record's tolerance, as
  the closed forms on analytic surfaces are.

Tests: fits are gone, so curves with no closed form on their face (a tilted circle on a sphere,
an ellipse on a cylinder, an intersection record, a curve weaving across a thread flank) are
asserted refused; the closed-spline fixtures are built as forms. geom 166/166.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnQawySUFx57iaJZq7z1Fm
This commit is contained in:
jyviko
2026-10-23 19:26:11 -04:00
parent 6cc0401fa7
commit 2883981a02
5 changed files with 304 additions and 123 deletions
+14 -6
View File
@@ -89,10 +89,11 @@ pub struct Face {
/// What the face is: its surface as the producer names it (a cylinder, a NURBS…).
pub surface: Surface,
/// The NURBS form the face is parameterised on, when it differs from `surface`'s own
/// parameters: a cylinder, cone, sphere or torus as SMLib holds it (every SMLib analytic
/// surface is a B-spline surface, its trims in that NURBS's parameters). When given, `domain`
/// and every pcurve are in this surface's (u, v), and `surface` only says what it is; it is a
/// `Spline` or `RationalSpline` and `surface` is a cylinder, cone, sphere or torus
/// parameters: a cylinder, cone, sphere, torus or placed revolution as SMLib holds it (every
/// SMLib analytic surface is a B-spline surface, its trims in that NURBS's parameters). When
/// given, `domain` and every pcurve are in this surface's (u, v), and `surface` only says what
/// it is; it is a `Spline` or `RationalSpline` and `surface` is a cylinder, cone, sphere, torus
/// or placed revolution
/// (docs/native-brep.md, "Surfaces").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nurbs: Option<Surface>,
@@ -600,8 +601,15 @@ impl Brep {
surface(n, 0, &mut budget)?;
require(matches!(n, Surface::Spline { .. } | Surface::RationalSpline { .. }), "NURBS form is not a NURBS")?;
require(
matches!(f.surface, Surface::Cylinder { .. } | Surface::Cone { .. } | Surface::Sphere { .. } | Surface::Torus { .. }),
"NURBS form on a surface that is not a cylinder, cone, sphere or torus",
matches!(
f.surface,
Surface::Cylinder { .. }
| Surface::Cone { .. }
| Surface::Sphere { .. }
| Surface::Torus { .. }
| Surface::PlacedRevolution { .. }
),
"NURBS form on a surface that is not a cylinder, cone, sphere, torus or revolution",
)?;
}
let [u, v] = surface_natural(f.parameters());
+148 -7
View File
@@ -61,6 +61,18 @@ fn quarters([a0, a1]: [f64; 2]) -> Option<(f64, usize)> {
(n <= 5).then_some((q0, n))
}
/// A face's quarters along an angle direction and its domain there: a whole turn is exactly
/// four quarters from the one it starts in (the form closes on itself, so it is periodic and the
/// face's seam uses lie a period apart), anything less the quarters it needs.
fn face_quarters(a: [f64; 2]) -> Option<((f64, usize), [f64; 2])> {
if a[1] - a[0] >= TAU * (1.0 - 1e-9) {
let q0 = quarter_of(a[0]);
return Some(((q0, 4), [q0, q0 + 4.0]));
}
let grid = quarters(a)?;
Some((grid, params(a, grid)))
}
/// The form parameters of an angle interval on the quarters `quarters` chose for it (its ends kept
/// on them: an end within rounding past a quarter's end stays in that quarter).
fn params([a0, a1]: [f64; 2], (q0, n): (f64, usize)) -> [f64; 2] {
@@ -128,9 +140,8 @@ fn conic(frame: &Frame, [rx, ry]: [f64; 2], t: [f64; 2]) -> Option<(Curve, [f64;
/// parameters: angles, and the height along the axis of a cylinder or cone) and the domain in the
/// form's parameters; `None` for another kind, or a domain of more than one turn.
pub fn surface(s: &Surface, [u, v]: [[f64; 2]; 2]) -> Option<(Surface, [[f64; 2]; 2])> {
let grid = quarters(u)?;
let (grid, fu) = face_quarters(u)?;
let (u_knots, ring) = arcs(grid.0, grid.1);
let fu = params(u, grid);
// The ring of arcs times a profile `(control, weight)` mapped to (radial, axial) about the
// frame: a rational tensor product, its weights the products.
let net = |f: &Frame, profile: &[([f64; 2], f64)], at: &dyn Fn([f64; 2]) -> (f64, f64)| -> Option<Vec<Vec<[f64; 4]>>> {
@@ -168,10 +179,45 @@ pub fn surface(s: &Surface, [u, v]: [[f64; 2]; 2]) -> Option<(Surface, [[f64; 2]
Some((Surface::RationalSpline { u_knots, v_knots, homogeneous }, [fu, params(v, vgrid)]))
}
Surface::Torus { frame, major_radius, minor_radius } => {
let vgrid = quarters(v)?;
let (vgrid, fv) = face_quarters(v)?;
let (v_knots, tube) = arcs(vgrid.0, vgrid.1);
let homogeneous = net(frame, &tube, &|[c, s]| (major_radius + minor_radius * c, minor_radius * s))?;
Some((Surface::RationalSpline { u_knots, v_knots, homogeneous }, [fu, params(v, vgrid)]))
Some((Surface::RationalSpline { u_knots, v_knots, homogeneous }, [fu, fv]))
}
Surface::PlacedRevolution { profile, origin, axis, matrix, forward } => {
// The profile's control points turned about the axis on the quarter grid from angle
// zero (the profile): (a + ib)(c + is) for each point's radial (a, b) in the frame and
// each arc control point (c, s), weights multiplied; then the matrix.
let (angles, along) = if *forward { (v, u) } else { (u, v) };
let (ring_grid, fa) = face_quarters(angles)?;
let (a_knots, arcs_r) = arcs(ring_grid.0, ring_grid.1);
let (p_knots, h) = profile_nurbs(profile, Some(along))?;
let fp = match profile.as_ref() {
Curve::Circle { .. } | Curve::Ellipse { .. } => curve(profile, along)?.1,
_ => along,
};
let (o, x, y, k) = revolution_frame(&h, *origin, *axis)?;
let net: Vec<Vec<[f64; 4]>> = arcs_r
.iter()
.map(|([c, s], wu)| {
h.iter()
.map(|hp| {
let d = Vec3::new(hp[0] / hp[3], hp[1] / hp[3], hp[2] / hp[3]) - o;
let (a, b, z) = (d.dot(x), d.dot(y), d.dot(k));
let q = o + k * z + x * (a * c - b * s) + y * (a * s + b * c);
hom(affine(matrix, q), wu * hp[3])
})
.collect()
})
.collect();
if *forward {
// u the profile, v the angle: the net turned over.
let n = net.first()?.len();
let t: Vec<Vec<[f64; 4]>> = (0..n).map(|j| net.iter().filter_map(|r| r.get(j).copied()).collect()).collect();
Some((Surface::RationalSpline { u_knots: p_knots, v_knots: a_knots, homogeneous: t }, [fp, fa]))
} else {
Some((Surface::RationalSpline { u_knots: a_knots, v_knots: p_knots, homogeneous: net }, [fa, fp]))
}
}
_ => None,
}
@@ -196,14 +242,60 @@ pub fn on_frame(c: &Curve, [t0, t1]: [f64; 2], frame: &Frame, tol: f64) -> Optio
Some((Curve::Circle { frame: relabel, radius: *radius }, [t0 + shift, t1 + shift]))
}
/// The frame of a cylinder, cone, sphere or torus (`None` for another surface).
pub fn frame_of(s: &Surface) -> Option<&Frame> {
/// The frame of a cylinder, cone, sphere or torus, or of a placed revolution (its axis, angle
/// zero at its profile: [`revolution_frame`]); `None` for another surface.
pub fn frame_of(s: &Surface) -> Option<Frame> {
match s {
Surface::Cylinder { frame, .. } | Surface::Cone { frame, .. } | Surface::Sphere { frame, .. } | Surface::Torus { frame, .. } => Some(frame),
Surface::Cylinder { frame, .. } | Surface::Cone { frame, .. } | Surface::Sphere { frame, .. } | Surface::Torus { frame, .. } => {
Some(frame.clone())
}
Surface::PlacedRevolution { profile, origin, axis, matrix, .. } => {
let (_, h) = profile_nurbs(profile, None)?;
let (o, x, y, _) = revolution_frame(&h, *origin, *axis)?;
let m = |p: Vec3| affine(matrix, p);
let (mo, mx, my) = (m(o), m(o + x) - m(o), m(o + y) - m(o));
Some(Frame { origin: [mo.x, mo.y, mo.z], x_axis: [mx.x, mx.y, mx.z], y_axis: [my.x, my.y, my.z] })
}
_ => None,
}
}
/// `matrix` (column-major, `matrix[column][row]`) applied to `p`.
fn affine(matrix: &[[f64; 4]; 4], p: Vec3) -> Vec3 {
let row = |r: usize| matrix[0][r] * p.x + matrix[1][r] * p.y + matrix[2][r] * p.z + matrix[3][r];
Vec3::new(row(0), row(1), row(2))
}
/// A revolution's profile as a NURBS: its knots and homogeneous control points (a B-spline or
/// NURBS as it is, a line or conic as its form over `range`; `None` without a range).
fn profile_nurbs(profile: &Curve, range: Option<[f64; 2]>) -> Option<(Vec<f64>, Vec<[f64; 4]>)> {
let formed;
let c = match profile {
Curve::Spline { .. } | Curve::RationalSpline { .. } => profile,
other => {
formed = curve(other, range?)?.0;
&formed
}
};
match c {
Curve::Spline { knots, points } => Some((knots.clone(), points.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect())),
Curve::RationalSpline { knots, homogeneous } => Some((knots.clone(), homogeneous.clone())),
_ => None,
}
}
/// A revolution's frame: the origin and unit axis, x the radial direction of the profile's
/// first control point off the axis (angle zero), y = axis × x.
fn revolution_frame(h: &[[f64; 4]], origin: [f64; 3], axis: [f64; 3]) -> Option<(Vec3, Vec3, Vec3, Vec3)> {
let (o, k) = (v3(origin), v3(axis).normalized()?);
let x = h.iter().find_map(|h| {
let d = Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]) - o;
let r = d - k * d.dot(k);
if r.len() > 1e-12 * (1.0 + d.len()) { r.normalized() } else { None }
})?;
Some((o, x, k.cross(x), k))
}
/// An edge's label and NURBS form: a circle about the axis of one of `frames` (the cylinder,
/// cone, sphere and torus faces it bounds) is labelled on that frame ([`on_frame`]), so its form
/// lies on that face's quarter grid; a circle or ellipse then has its form ([`curve`]). The label
@@ -221,3 +313,52 @@ pub fn edge<'a>(
};
Some((label, iv, form))
}
/// Points per edge along a loop taken for a face's extent.
const EXTENT_POINTS: usize = 32;
/// The parameter box of a face on `label` over its boundary: each loop's points (`loops`, in
/// order along each loop) projected onto the label, a periodic parameter unwrapped from one point
/// to the next. What the face's NURBS form ([`surface`]) is built over; no pcurve is involved.
pub fn extent(label: &Surface, loops: &[Vec<(&Curve, [f64; 2], bool)>]) -> Option<[[f64; 2]; 2]> {
let eval = super::eval::SurfaceEval::new(label).ok()?;
let period = eval.domain().period;
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for l in loops {
let mut prev: Option<[f64; 2]> = None;
for (c, [t0, t1], forward) in l {
let curve = super::eval::CurveEval::new(c).ok()?;
for k in 0..=EXTENT_POINTS {
let f = k as f64 / EXTENT_POINTS as f64;
let t = if *forward { t0 * (1.0 - f) + t1 * f } else { t1 * (1.0 - f) + t0 * f };
let mut q = eval.project(curve.point(t)?, prev, f64::INFINITY)?;
if let Some(p) = prev {
for d in 0..2 {
if let Some(Some(per)) = period.get(d) {
q[d] += ((p[d] - q[d]) / per).round() * per;
}
}
}
// At a pole the collapsed parameter is anything: it bounds nothing.
let pole = eval.pole(q);
for d in 0..2 {
if !pole.get(d).copied().unwrap_or(false) {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
}
}
if !pole.iter().any(|p| *p) {
prev = Some(q);
}
}
}
}
// At most one turn round a periodic direction; a boundary that never goes round it (a face
// bounded by its seam alone) covers the whole turn from there.
for d in 0..2 {
if let Some(Some(per)) = period.get(d) {
hi[d] = if hi[d] - lo[d] <= 1e-9 * per { lo[d] + per } else { hi[d].min(lo[d] + per) };
}
}
(lo[0] < hi[0] && lo[1] < hi[1]).then_some([[lo[0], hi[0]], [lo[1], hi[1]]])
}
+61 -9
View File
@@ -212,7 +212,7 @@ impl On {
}
fn pcurve(&self, record: &Curve, curve: &CurveEval, range: [f64; 2], tol: f64) -> Result<Curve2> {
if let Some(pc) = self.exact(record, curve, range)?
if let Some(pc) = self.exact(record, curve, range, tol)?
&& self.lifts(&pc, curve, range, 0.5 * tol)
{
return Ok(pc);
@@ -221,7 +221,7 @@ impl On {
}
/// The closed-form pcurve where there is one (unchecked).
fn exact(&self, record: &Curve, curve: &CurveEval, [t0, t1]: [f64; 2]) -> Result<Option<Curve2>> {
fn exact(&self, record: &Curve, curve: &CurveEval, [t0, t1]: [f64; 2], tol: f64) -> Result<Option<Curve2>> {
let segment = |a: [f64; 2], b: [f64; 2]| Curve2::Spline { knots: vec![t0, t0, t1, t1], points: vec![a, b] };
let at = |t: f64| curve.point(t).ok_or_else(|| Error::Failed(format!("the curve has no point at {t}")));
Ok(match (&self.surface, record) {
@@ -283,7 +283,7 @@ impl On {
let c = super::eval::Axes::new(frame);
let turn = c.z.dot(axes.z);
if (turn.abs() - 1.0).abs() > 1e-9 {
return Ok(None);
return Ok(self.meridian(axes, &c, *radius, curve, [t0, t1]));
}
let sign = turn.signum();
let u0 = c.x.dot(axes.y).atan2(c.x.dot(axes.x));
@@ -305,28 +305,69 @@ impl On {
let u = b.atan2(a);
Some(segment([u, height(t0)?], [u, height(t1)?]))
}
(SurfaceEval::Eval(..), _) => self.along(curve, [t0, t1]),
(SurfaceEval::Eval(..), _) => self.along(curve, [t0, t1], tol),
_ => None,
})
}
/// A meridian of a sphere or a tube circle of a torus (a circle in a plane through the axis,
/// centred on the sphere's centre or on the torus's ring): u fixed at the arc's azimuth, v its
/// angle from the outward radial toward the axis, `α + s·t` (`s` ±1 as the circle runs).
/// `None` for any other circle.
fn meridian(&self, axes: &super::eval::Axes, c: &super::eval::Axes, radius: f64, curve: &CurveEval, [t0, t1]: [f64; 2]) -> Option<Curve2> {
let ring = match &self.surface {
SurfaceEval::Sphere { radius: r, .. } => (0.0, *r),
SurfaceEval::Torus { major, minor, .. } => (*major, *minor),
_ => return None,
};
let size = 1.0 + ring.0 + ring.1;
if c.z.dot(axes.z).abs() > 1e-9 || (radius - ring.1).abs() > 1e-9 * size {
return None;
}
let tm = 0.5 * (t0 + t1);
let m = curve.point(tm)?;
let [mx, my, _] = axes.local(m);
let u0 = my.atan2(mx);
let radial = (axes.x * mx + axes.y * my).normalized()?;
// The circle's centre: the sphere's, or the torus's ring at that azimuth.
let centre = axes.o + radial * ring.0;
if c.o.dist(centre) > 1e-9 * size {
return None;
}
let s = c.z.dot(radial.cross(axes.z)).signum();
let d = m - centre;
let vm = d.dot(axes.z).atan2(d.dot(radial));
let alpha = vm - s * tm;
Some(Curve2::Spline { knots: vec![t0, t0, t1, t1], points: vec![[u0, alpha + s * t0], [u0, alpha + s * t1]] })
}
/// On a spline face (a NURBS form, or another spline), the edge's pcurve where it runs along
/// the face's parameters at its own parameter's pace: a straight line in (u, v), between the
/// ends' parameters, or with u (or v) the edge's parameter itself (a rim of a revolved form on
/// the same quarter grid, whose two ends are one point on the seam). A candidate is taken only
/// where it lifts onto the edge to within rounding, so it is the exact pcurve; none is fitted.
fn along(&self, curve: &CurveEval, [t0, t1]: [f64; 2]) -> Option<Curve2> {
/// where it lifts onto the edge within the record's tolerance, as the closed forms on analytic
/// surfaces must: the closed form the geometry has, never a fit.
fn along(&self, curve: &CurveEval, [t0, t1]: [f64; 2], tol: f64) -> Option<Curve2> {
let (p0, p1, pm) = (curve.point(t0)?, curve.point(t1)?, curve.point(0.5 * (t0 + t1))?);
let size = [p0, p1, pm].iter().flat_map(|p| [p.x.abs(), p.y.abs(), p.z.abs()]).fold(1.0, f64::max);
let exact = EXACT * size;
// Within the record's tolerance, as the closed forms on analytic surfaces are, and never
// looser than rounding.
let exact = (0.5 * tol).max(EXACT * size);
let line = |a: [f64; 2], b: [f64; 2]| Curve2::Spline { knots: vec![t0, t0, t1, t1], points: vec![a, b] };
let mut candidates = Vec::with_capacity(3);
if let (Some(a), Some(b)) = (self.project(p0, None, exact), self.project(p1, None, exact)) {
let ends = (self.project_in(p0, exact), self.project_in(p1, exact));
if let (Some(a), Some(b)) = ends {
candidates.push(line(a, b));
}
// One parameter fixed where the edge's middle is (an end on a seam or at an apex may
// project anywhere along it), the other from the ends.
if let (Some(a), Some(b), Some(m)) = (ends.0, ends.1, self.project_in(pm, exact)) {
candidates.push(line([m[0], a[1]], [m[0], b[1]]));
candidates.push(line([a[0], m[1]], [b[0], m[1]]));
}
// u or v the edge's own parameter, either way along and offset (forms on one frame's
// quarter grid, or on the same grid turned over), the other fixed.
if let Some([u, v]) = self.project(pm, None, exact) {
if let Some([u, v]) = self.project_in(pm, exact) {
let tm = 0.5 * (t0 + t1);
for sign in [1.0, -1.0] {
let (cu, cv) = (u - sign * tm, v - sign * tm);
@@ -351,6 +392,17 @@ impl On {
})
}
/// The parameters of `p` within `accept` inside the face's parameter box when it has one: a
/// spline extended past its knots, or a profile turned past where the face lies, has other
/// preimages outside it. The global search first, then from the box's centre.
fn project_in(&self, p: Vec3, accept: f64) -> Option<[f64; 2]> {
let Some(b) = self.face else { return self.project(p, None, accept) };
let pad = |d: usize| 1e-9 * (1.0 + (b[d][1] - b[d][0]).abs());
let inside = |q: [f64; 2]| (0..2).all(|d| q[d] >= b[d][0] - pad(d) && q[d] <= b[d][1] + pad(d));
let centre = [0.5 * (b[0][0] + b[0][1]), 0.5 * (b[1][0] + b[1][1])];
[None, Some(centre)].into_iter().filter_map(|near| self.project(p, near, accept)).find(|q| inside(*q))
}
/// The parameters of `p` within `accept`, from `near` (and unwrapped to it) when given.
fn project(&self, p: Vec3, near: Option<[f64; 2]>, accept: f64) -> Option<[f64; 2]> {
let mut q = self.surface.project_with(&self.starts, p, near, accept)?;
+58 -88
View File
@@ -128,10 +128,10 @@ fn rulings_are_v_lines() {
}
}
/// Curves with no closed form are fitted cubic B-splines within the tolerance: a tilted circle
/// on a sphere, a plane's ellipse on a cylinder, a circle off the axis on a plane.
/// Curves with no closed form on their face are refused, never fitted: a tilted circle on a
/// sphere, a plane's ellipse on a cylinder, a circle off the axis on a plane.
#[test]
fn other_curves_are_fitted_within_tolerance() {
fn curves_without_a_closed_form_are_refused() {
let (s, c) = 0.5f64.sin_cos();
let sphere = Surface::Sphere { frame: about_z([0.; 3], false), radius: 6. };
let great = Curve::Circle { frame: frame([0.; 3], [1., 0., 0.], [0., c, s]), radius: 6. };
@@ -143,22 +143,10 @@ fn other_curves_are_fitted_within_tolerance() {
let plane = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] };
let circle = Curve::Circle { frame: about_z([3., 1., 0.], false), radius: 2. };
for (surface, curve, interval) in [(&sphere, &great, [0., TAU]), (&cylinder, &ellipse, [0.2, 6.]), (&plane, &circle, [0., 3.])] {
for tol in [1e-3, 1e-6] {
let pc = &pcurves(surface, &one(curve, interval), tol).unwrap()[0][0];
let Curve2::Spline { knots, .. } = pc else { panic!("a B-spline") };
assert_eq!(knots.len() - pc_count(pc) - 1, 3, "cubic");
let e = lift_error(surface, curve, interval, pc);
assert!(e <= tol, "{curve:?} at {tol}: {e}");
}
let r = pcurves(surface, &one(curve, interval), 1e-6);
assert!(matches!(&r, Err(Error::Failed(m)) if m.contains("not supported yet: no exact pcurve")), "{curve:?}: {r:?}");
}
}
fn pc_count(pc: &Curve2) -> usize {
match pc {
Curve2::Spline { points, .. } => points.len(),
Curve2::RationalSpline { homogeneous, .. } => homogeneous.len(),
}
}
/// The ends of each use in loop order and the loop's signed area in (u, v).
fn walk(l: &[EdgeUse<'_>], pcs: &[Curve2]) -> (Vec<([f64; 2], [f64; 2])>, f64) {
let mut ring = Vec::new();
@@ -288,27 +276,30 @@ fn a_seam_from_pole_to_pole_encloses_the_whole_sphere() {
knots: vec![0., 0., 0., 0.5, 0.5, 1., 1., 1.],
homogeneous: vec![h(o - z + side, 1.), h(o - z + r, w), h(o + r, 1.), h(o + z + r, w), h(o + z - side, 1.)],
};
for (seam, iv) in [(&circle, [-FRAC_PI_2, FRAC_PI_2]), (&nurbs, [0., 1.])] {
// The circle on the sphere as it is; the NURBS meridian, on a parameter of its own,
// on the sphere's NURBS form (one turn is four quarters there, the latitude two).
let (form, _) = crate::brep::form::surface(&sphere, [[0., TAU], [-FRAC_PI_2, FRAC_PI_2]]).unwrap();
for (seam, iv, on, period, height) in [(&circle, [-FRAC_PI_2, FRAC_PI_2], &sphere, TAU, PI), (&nurbs, [0., 1.], &form, 4., 2.)] {
for order in [[true, false], [false, true]] {
let l: Vec<EdgeUse> = order
.iter()
.map(|&forward| EdgeUse { edge: 0, curve: seam, interval: iv, forward, tolerance: 0.0, given: None })
.collect();
let pcs = &pcurves(&sphere, std::slice::from_ref(&l), tol).unwrap()[0];
let pcs = &pcurves(on, std::slice::from_ref(&l), tol).unwrap()[0];
let what = format!("{sf:?} {turn} {off} {seam:?} {order:?}");
for pc in pcs {
assert!(lift_error(&sphere, seam, iv, pc) <= tol, "{what}");
assert!(lift_error(on, seam, iv, pc) <= tol, "{what}");
}
let mid = 0.5 * (iv[0] + iv[1]);
let (a, b) = (pcs[0].eval(mid).unwrap(), pcs[1].eval(mid).unwrap());
assert!(((a[0] - b[0]).abs() - TAU).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-12, "a period apart: {a:?} {b:?} {what}");
assert!(((a[0] - b[0]).abs() - period).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-12, "a period apart: {a:?} {b:?} {what}");
let (ends, area) = walk(&l, pcs);
for k in 0..2 {
assert!((ends[k].1[1] - ends[(k + 1) % 2].0[1]).abs() < 1e-6, "closed in v: {ends:?} {what}");
}
// The box less the corners `walk` cuts (it joins each use's 16th
// sample to the next use's start): about 15/16 of it.
assert!((area / (TAU * PI * 15. / 16.) - 1.).abs() < 1e-2, "counter-clockwise around the box: {area} {what}");
assert!((area / (period * height * 15. / 16.) - 1.).abs() < 1e-2, "counter-clockwise around the box: {area} {what}");
}
}
}
@@ -346,28 +337,6 @@ fn a_torus_bounded_by_its_seams_spans_both_periods() {
}
}
/// A surface of revolution about +z as a rational spline closed in u, as SMLib writes a fillet
/// ring round a hole's rim and STEP a NURBS cylinder: u the nine-point rational quadratic
/// circle on [0, 1] (first and last rows the same), v the profile's `[radius, height, weight]`
/// points on `v_knots`.
pub(crate) fn closed_ring(profile: &[[f64; 3]], v_knots: &[f64]) -> Surface {
let w = std::f64::consts::FRAC_1_SQRT_2;
let around = [[1., 0., 1.], [1., 1., w], [0., 1., 1.], [-1., 1., w], [-1., 0., 1.], [-1., -1., w], [0., -1., 1.], [1., -1., w], [1., 0., 1.]];
let homogeneous = around
.iter()
.map(|&[cx, cy, wu]| {
profile
.iter()
.map(|&[r, z, wv]| {
let wt = wu * wv;
[r * cx * wt, r * cy * wt, z * wt, wt]
})
.collect()
})
.collect();
Surface::RationalSpline { u_knots: vec![0., 0., 0., 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1., 1., 1.], v_knots: v_knots.to_vec(), homogeneous }
}
/// The pcurves of a loop on a spline closed in u (period 1): each lifts within `tol`, the
/// seam's two uses (`seam`: their positions) lie one period apart, the loop closes (in v alone
/// where it meets a pole) and turns counter-clockwise once around the whole (u, v) box, all in
@@ -380,7 +349,9 @@ fn closes_around_the_box(surface: &Surface, l: &[EdgeUse<'_>], seam: [usize; 2],
let iv = l[seam[0]].interval;
let mid = 0.5 * (iv[0] + iv[1]);
let (a, b) = (pcs[seam[0]].eval(mid).unwrap(), pcs[seam[1]].eval(mid).unwrap());
assert!(((a[0] - b[0]).abs() - 1.).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9, "{what}: seam uses a period apart: {a:?} {b:?}");
let dom = SurfaceEval::new(surface).unwrap().domain();
let period = dom.period[0].unwrap();
assert!(((a[0] - b[0]).abs() - period).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9, "{what}: seam uses a period apart: {a:?} {b:?}");
let (ends, area) = walk(l, pcs);
let n = l.len();
for k in 0..n {
@@ -390,6 +361,8 @@ fn closes_around_the_box(surface: &Surface, l: &[EdgeUse<'_>], seam: [usize; 2],
}
// At a pole `walk` cuts the corner (it joins each use's 16th sample to the next use's
// start, across the pole's line): a sliver of the box less.
let [u, v] = dom.range.map(|r| r.unwrap());
let area = area / ((u.1 - u.0) * (v.1 - v.0));
let least = if pole.is_some() { 0.9 } else { 1. - 1e-6 };
assert!(area >= least && area < 1. + 1e-6, "{what}: counter-clockwise around the box: {area}");
for pc in pcs {
@@ -397,7 +370,7 @@ fn closes_around_the_box(surface: &Surface, l: &[EdgeUse<'_>], seam: [usize; 2],
for k in 0..=32 {
let t = knots[0] + (knots[knots.len() - 1] - knots[0]) * k as f64 / 32.;
let q = pc.eval(t).unwrap();
assert!((-1e-9..=1. + 1e-9).contains(&q[0]), "{what}: base period: {q:?}");
assert!((u.0 - 1e-9..=u.1 + 1e-9).contains(&q[0]), "{what}: base period: {q:?}");
}
}
}
@@ -408,43 +381,45 @@ fn closes_around_the_box(surface: &Surface, l: &[EdgeUse<'_>], seam: [usize; 2],
/// into its pole. Each seam's uses come out a period apart, never both on u = 0.
#[test]
fn a_seam_on_a_spline_closed_in_u_gets_pcurves_a_period_apart() {
let w = std::f64::consts::FRAC_1_SQRT_2;
let full = [0., TAU];
let (big, small) = (5., 1.);
// The torus band: the tube's quarter from its outer equator (v = 0) to its top (v = 1).
let band = closed_ring(&[[big + small, 0., 1.], [big + small, small, w], [big, small, 1.]], &[0., 0., 0., 1., 1., 1.]);
let outer = Curve::Circle { frame: about_z([0.; 3], false), radius: big + small };
let top = Curve::Circle { frame: about_z([0., 0., small], false), radius: big };
let quarter = Curve::Circle { frame: frame([big, 0., 0.], [1., 0., 0.], [0., 0., 1.]), radius: small };
// Each surface and circle on its NURBS form (`form`), as SolidCraft's builders give them.
let on = |c: &Curve, iv: [f64; 2]| crate::brep::form::curve(c, iv).unwrap();
// The torus band: the tube's quarter from its outer equator to its top.
let torus = Surface::Torus { frame: about_z([0.; 3], false), major_radius: big, minor_radius: small };
let (band, _) = crate::brep::form::surface(&torus, [full, [0., FRAC_PI_2]]).unwrap();
let outer = on(&Curve::Circle { frame: about_z([0.; 3], false), radius: big + small }, full);
let top = on(&Curve::Circle { frame: about_z([0., 0., small], false), radius: big }, full);
let quarter = on(&Curve::Circle { frame: frame([big, 0., 0.], [1., 0., 0.], [0., 0., 1.]), radius: small }, [0., FRAC_PI_2]);
let l = vec![
EdgeUse { edge: 0, curve: &outer, interval: full, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &quarter, interval: [0., FRAC_PI_2], forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 2, curve: &top, interval: full, forward: false, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &quarter, interval: [0., FRAC_PI_2], forward: false, tolerance: 0.0, given: None },
EdgeUse { edge: 0, curve: &outer.0, interval: outer.1, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &quarter.0, interval: quarter.1, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 2, curve: &top.0, interval: top.1, forward: false, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &quarter.0, interval: quarter.1, forward: false, tolerance: 0.0, given: None },
];
for tol in [1e-4, 1e-7] {
closes_around_the_box(&band, &l, [1, 3], None, tol, &format!("torus band, tol {tol}"));
}
// The cylinder: radius 3 from z = 0 (v = 0) to z = 4 (v = 1), its seam a line.
let cylinder = closed_ring(&[[3., 0., 1.], [3., 4., 1.]], &[0., 0., 1., 1.]);
let (low, high) =
(Curve::Circle { frame: about_z([0.; 3], false), radius: 3. }, Curve::Circle { frame: about_z([0., 0., 4.], false), radius: 3. });
// The cylinder: radius 3 from z = 0 to z = 4, its seam a line.
let (cylinder, _) = crate::brep::form::surface(&Surface::Cylinder { frame: about_z([0.; 3], false), radius: 3. }, [full, [0., 4.]]).unwrap();
let low = on(&Curve::Circle { frame: about_z([0.; 3], false), radius: 3. }, full);
let high = on(&Curve::Circle { frame: about_z([0., 0., 4.], false), radius: 3. }, full);
let line = Curve::Line { start: [3., 0., 0.], end: [3., 0., 4.] };
let l = vec![
EdgeUse { edge: 0, curve: &low, interval: full, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 0, curve: &low.0, interval: low.1, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &line, interval: [0., 1.], forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 2, curve: &high, interval: full, forward: false, tolerance: 0.0, given: None },
EdgeUse { edge: 2, curve: &high.0, interval: high.1, forward: false, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &line, interval: [0., 1.], forward: false, tolerance: 0.0, given: None },
];
closes_around_the_box(&cylinder, &l, [1, 3], None, 1e-7, "cylinder");
// The dome: radius 6 from its equator (v = 0) to its pole (v = 1), the seam a meridian.
let dome = closed_ring(&[[6., 0., 1.], [6., 6., w], [0., 6., 1.]], &[0., 0., 0., 1., 1., 1.]);
let equator = Curve::Circle { frame: about_z([0.; 3], false), radius: 6. };
let meridian = Curve::Circle { frame: frame([0.; 3], [1., 0., 0.], [0., 0., 1.]), radius: 6. };
// The dome: radius 6 from its equator to its pole, the seam a meridian.
let (dome, _) = crate::brep::form::surface(&Surface::Sphere { frame: about_z([0.; 3], false), radius: 6. }, [full, [0., FRAC_PI_2]]).unwrap();
let equator = on(&Curve::Circle { frame: about_z([0.; 3], false), radius: 6. }, full);
let meridian = on(&Curve::Circle { frame: frame([0.; 3], [1., 0., 0.], [0., 0., 1.]), radius: 6. }, [0., FRAC_PI_2]);
let l = vec![
EdgeUse { edge: 0, curve: &equator, interval: full, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &meridian, interval: [0., FRAC_PI_2], forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &meridian, interval: [0., FRAC_PI_2], forward: false, tolerance: 0.0, given: None },
EdgeUse { edge: 0, curve: &equator.0, interval: equator.1, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &meridian.0, interval: meridian.1, forward: true, tolerance: 0.0, given: None },
EdgeUse { edge: 1, curve: &meridian.0, interval: meridian.1, forward: false, tolerance: 0.0, given: None },
];
closes_around_the_box(&dome, &l, [1, 2], Some(2), 1e-7, "dome");
// A spline whose ends merely come near each other stays open: no period, so the seam's
@@ -454,11 +429,11 @@ fn a_seam_on_a_spline_closed_in_u_gets_pcurves_a_period_apart() {
open[8][0][1] += 1e-3;
let open = Surface::RationalSpline { u_knots: u_knots.clone(), v_knots: v_knots.clone(), homogeneous: open };
assert_eq!(SurfaceEval::new(&open).unwrap().domain().period, [None, None]);
assert_eq!(SurfaceEval::new(&cylinder).unwrap().domain().period, [Some(1.), None]);
assert_eq!(SurfaceEval::new(&cylinder).unwrap().domain().period, [Some(4.), None]);
}
/// An intersection record evaluates to the point of both surfaces in the leader's normal
/// plane, and its pcurves on either surface fit.
/// plane; it has no exact pcurve on either.
#[test]
fn intersection_curves_lie_on_both_surfaces() {
// The cylinder x² + y² = 25 cut by the plane z = 20 + x tan 0.4: an ellipse.
@@ -482,9 +457,10 @@ fn intersection_curves_lie_on_both_surfaces() {
let p = eval.point(12. * i as f64 / 48.).unwrap();
assert!((p.x.hypot(p.y) - 5.).abs() < 1e-11 && (p.z - 20. - p.x * k).abs() < 1e-11, "{i}: {p:?}");
}
// It has no closed-form pcurve on either surface (an intersection record is a construction,
// not a NURBS): refused, never fitted.
for surface in [&cylinder, &plane] {
let pc = &pcurves(surface, &one(&curve, [0., 12.]), 1e-6).unwrap()[0][0];
assert!(lift_error(surface, &curve, [0., 12.], pc) <= 1e-6);
assert!(matches!(pcurves(surface, &one(&curve, [0., 12.]), 1e-6), Err(Error::Failed(m)) if m.contains("no exact pcurve")));
}
}
@@ -555,13 +531,11 @@ fn an_edge_along_the_rim_of_a_band_winding_close_to_itself_stays_on_its_turn() {
}
}
/// A curve that is smooth through a knot of full multiplicity (one smooth curve with the knot
/// inserted three times, as SMLib's intersection curves come) gets a pcurve with one tangent
/// there. Two pieces fitted apart meet at an angle in (u, v) (a tenth of a degree here, over the
/// degree SMLib's check allows on a real thread), sharp where u runs hundreds of times as fast as
/// v.
/// A curve weaving across a thread flank, smooth through a knot of full multiplicity (one smooth
/// curve with the knot inserted three times, as SMLib's intersection curves come), has no
/// closed-form pcurve on the flank: it is refused, never fitted (SMLib gives its own trims).
#[test]
fn a_smooth_curve_through_a_full_knot_gets_a_pcurve_with_a_tangent_there() {
fn a_curve_weaving_across_a_thread_flank_is_refused() {
let (surface, _) = thread_flank();
let s = SurfaceEval::new(&surface).unwrap();
// Along the flank as it winds, a third of a turn (u = t), weaving a little across it.
@@ -576,13 +550,8 @@ fn a_smooth_curve_through_a_full_knot_gets_a_pcurve_with_a_tangent_there() {
(knots, ctrl) = crate::bspline::insert_knot(3, &knots, &ctrl, k).unwrap();
}
let weave = Curve::Spline { knots, points: ctrl.iter().map(|h| h.project().unwrap()).map(|p| [p.x, p.y, p.z]).collect() };
let pc = &pcurves(&surface, &one(&weave, [a, b]), 1e-3).unwrap()[0][0];
assert!(lift_error(&surface, &weave, [a, b], pc) <= 1e-3);
let h = 1e-7;
let (l, m, r) = (pc.eval(k - h).unwrap(), pc.eval(k).unwrap(), pc.eval(k + h).unwrap());
let (dl, dr) = ([m[0] - l[0], m[1] - l[1]], [r[0] - m[0], r[1] - m[1]]);
let cos = (dl[0] * dr[0] + dl[1] * dr[1]) / (dl[0].hypot(dl[1]) * dr[0].hypot(dr[1]));
assert!(cos > 1. - 1e-6, "the pcurve turns by {} degrees at the knot", cos.min(1.).acos().to_degrees());
// A curve weaving across the flank has no closed-form pcurve there: refused, never fitted.
assert!(matches!(pcurves(&surface, &one(&weave, [a, b]), 1e-3), Err(Error::Failed(m)) if m.contains("no exact pcurve")));
}
/// Hostile records give errors, never a panic.
@@ -607,11 +576,12 @@ fn hostile_records_give_errors() {
// Too many uses, refused before any work.
let many = vec![vec![EdgeUse { edge: 0, curve: &line, interval: [0., 1.], forward: true, tolerance: 0.0, given: None }; MAX_USES + 1]];
assert!(matches!(pcurves(&plane, &many, 1e-6), Err(Error::TooLarge(_))));
// A polyline of many points on a curved surface: too many spans for one pcurve.
// A polyline of many points across a curved surface: it has no exact pcurve there, and is
// refused as such (nothing is fitted).
let cylinder = Surface::Cylinder { frame: about_z([0.; 3], false), radius: 1. };
let n = 5000;
let zigzag = Curve::Polyline { points: (0..n).map(|i| [(i as f64 * 0.01).cos(), (i as f64 * 0.01).sin(), (i % 2) as f64]).collect() };
assert!(matches!(pcurves(&cylinder, &one(&zigzag, [0., (n - 1) as f64]), 1e-6), Err(Error::TooLarge(_))));
assert!(matches!(pcurves(&cylinder, &one(&zigzag, [0., (n - 1) as f64]), 1e-6), Err(Error::Failed(m)) if m.contains("no exact pcurve")));
// A degree the evaluators do not take.
let high = Curve::Spline { knots: [vec![0.; 10], vec![1.; 10]].concat(), points: (0..10).map(|i| [i as f64, 0., 0.]).collect() };
assert!(matches!(pcurves(&cylinder, &one(&high, [0., 1.]), 1e-6), Err(Error::Failed(_))));
+23 -13
View File
@@ -38,7 +38,7 @@ fn fit_pcurves(b: &mut Brep) {
let e = &b.edges[u.edge as usize];
pcurve::EdgeUse {
edge: u.edge,
curve: &e.curve,
curve: e.parameters(),
interval: e.interval,
forward: u.forward,
tolerance: e.tolerance,
@@ -49,7 +49,7 @@ fn fit_pcurves(b: &mut Brep) {
Loop::Vertex { .. } => vec![],
})
.collect();
let fitted = pcurve::pcurves(&f.surface, &loops, 1e-7).unwrap();
let fitted = pcurve::pcurves_within(f.parameters(), &loops, 1e-7, Some(f.domain)).unwrap();
for (l, pcs) in f.loops.iter_mut().zip(fitted) {
if let Loop::Edges { edges } = l {
for (u, pc) in edges.iter_mut().zip(pcs) {
@@ -193,18 +193,19 @@ fn one_face(surface: Surface, domain: [[f64; 2]; 2], edges: Vec<(Curve, [f64; 2]
fit_pcurves(&mut b);
b
}
/// A ring of a torus as a rational spline closed in u (first and last control rows the same),
/// as SMLib writes the fillet round a hole's rim: bounded by its two circles and the tube's
/// quarter at u = 0, a seam used twice.
/// A ring of a torus on its NURBS form, closed in u (first and last control rows the same), as
/// SMLib holds the fillet round a hole's rim: bounded by its two circles and the tube's quarter
/// at u = 0, a seam used twice, each on its form.
fn closed_band() -> Brep {
let w = std::f64::consts::FRAC_1_SQRT_2;
let (big, small) = (5., 1.);
let surface = pcurve::tests::closed_ring(&[[big + small, 0., 1.], [big + small, small, w], [big, small, 1.]], &[0., 0., 0., 1., 1., 1.]);
let torus =
Surface::Torus { frame: Frame { origin: [0., 0., 0.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }, major_radius: big, minor_radius: small };
let (form, domain) = form::surface(&torus, [[0., TAU], [0., FRAC_PI_2]]).unwrap();
let about = |z: f64| Frame { origin: [0., 0., z], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let quarter = Curve::Circle { frame: Frame { origin: [big, 0., 0.], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }, radius: small };
one_face(
surface,
[[0., 1.], [0., 1.]],
let mut b = one_face(
torus,
domain,
vec![
(Curve::Circle { frame: about(0.), radius: big + small }, [0., TAU], [0, 0]),
(quarter, [0., FRAC_PI_2], [0, 1]),
@@ -212,7 +213,15 @@ fn closed_band() -> Brep {
],
&[[big + small, 0., 0.], [big, 0., small]],
&[(0, true), (1, true), (2, false), (1, false)],
)
);
b.faces[0].nurbs = Some(form);
for e in &mut b.edges {
if let Some((nurbs, interval)) = form::curve(&e.curve, e.interval) {
(e.nurbs, e.interval) = (Some(nurbs), interval);
}
}
fit_pcurves(&mut b);
b
}
/// A whole sphere bounded by one meridian from pole to pole, used twice: a closed shell.
fn ball() -> Brep {
@@ -549,10 +558,11 @@ fn geometric_defects_are_refused_by_record() {
(
"seam uses of a closed spline on one line",
Record::Use { face: 0, r#loop: 0, position: 1 },
Box::new(|p: &Problem| *p == Problem::Period { gap: 1., period: 1. }),
// On the ring's NURBS form one turn is four quarters.
Box::new(|p: &Problem| *p == Problem::Period { gap: 4., period: 4. }),
{
let mut b = closed_band();
shift(pcurve_of(&mut b, 0, 1), [-1., 0.]);
shift(pcurve_of(&mut b, 0, 1), [-4., 0.]);
b
},
),