solidcraft-step: surfaces of revolution and extrusion, offset surfaces, hyperbola and parabola edges

SURFACE_OF_REVOLUTION reads as a placed revolution (u the angle, v the profile's parameter,
STEP's normal); SURFACE_OF_LINEAR_EXTRUSION as an exact spline surface, the profile as a
(rational) B-spline times degree 1 along the vector; a circle extruded along its axis or
revolved in a plane through the axis as the cylinder or ring torus it is. Line, conic and
open-conic profiles are bounded by the face (a closed conic's seam outside it, a revolved
profile stopped at the axis). OFFSET_SURFACE of the analytic kinds reads as the moved
surface, checked at each level. Hyperbola and parabola edges read as exact rational
quadratic pieces and a Bezier on STEP's parameter. Faces winding around a revolution get
seams along its profile. Every mapping is checked point by point and normal by normal
against STEP's own surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brennen Herbruck
2026-10-21 02:10:00 -04:00
committed by jyviko
parent 8f31c088f4
commit d41ae85125
4 changed files with 1084 additions and 71 deletions
+74 -45
View File
@@ -44,12 +44,13 @@ use solidcraft_geom::brep::{
Surface as BSurface, Vertex,
};
use crate::geom::{BSplineCurve, BSplineSurface, Curve, P3, Placement, Surface, dot, scale, sub};
use crate::geom::{BSplineCurve, BSplineSurface, Curve, P3, Placement, dot, scale, sub};
use crate::p21::{Entity, Exchange, Param};
use crate::read::{R, Reader};
use crate::units;
mod seams;
mod swept;
/// Tolerance (mm) when the file states no uncertainty.
pub const DEFAULT_TOLERANCE: f64 = 1e-3;
@@ -553,8 +554,7 @@ impl<'b, 'a> Builder<'b, 'a> {
if self.faces.len() >= MAX_FACES {
return Err(format!("more than {MAX_FACES} faces"));
}
let surface = self.reader.surface(rid(p.get(2), "face geometry")?).map_err(|m| format!("face #{id}: {m}"))?;
let surface = map_surface(&surface).map_err(|m| format!("face #{id}: {m}"))?;
let step_surface = self.reader.surface(rid(p.get(2), "face geometry")?).map_err(|m| format!("face #{id}: {m}"))?;
let same_sense = flag(p.get(3), "face same_sense")?;
let bounds = refs(p.get(1), "face bounds")?;
if bounds.len() > MAX_BOUNDS {
@@ -564,10 +564,7 @@ impl<'b, 'a> Builder<'b, 'a> {
let mut outer = None;
let mut edges = 0;
for b in bounds {
let (mut l, is_outer) = self.bound(b, &mut edges).map_err(|m| format!("face #{id}: {m}"))?;
if !same_sense && let LoopB::Edges(uses) = &mut l {
reverse(uses);
}
let (l, is_outer) = self.bound(b, &mut edges).map_err(|m| format!("face #{id}: {m}"))?;
if is_outer && outer.is_none() {
outer = Some(loops.len());
}
@@ -576,9 +573,53 @@ impl<'b, 'a> Builder<'b, 'a> {
if !loops.iter().any(|l| matches!(l, LoopB::Edges(_))) {
return Err(format!("face #{id}: {}", unsupported("a face without an edge loop")));
}
let extent = self.extent(&loops)?;
let (surface, reversed) = swept::map_surface(&step_surface, &extent).map_err(|m| format!("face #{id}: {m}"))?;
// Loops relative to the record's natural normal: the face's normal is STEP's surface
// normal when same_sense, and the record's is STEP's unless `reversed`.
let sense = same_sense != reversed;
if !sense {
for l in &mut loops {
if let LoopB::Edges(uses) = l {
reverse(uses);
}
}
}
let i = index(self.faces.len())?;
self.faces.push(FaceB { step: id, surface, loops, outer, forward: same_sense });
Ok((i, same_sense))
self.faces.push(FaceB { step: id, surface, loops, outer, forward: sense });
Ok((i, sense))
}
/// Points along a face's loops (each edge at nine points, and its vertex loops), and along
/// the edges its loops use twice (its seams).
fn extent(&self, loops: &[LoopB]) -> R<swept::Extent> {
let mut ext = swept::Extent::default();
let mut count: HashMap<u32, usize> = HashMap::new();
for l in loops {
match l {
LoopB::Edges(es) => {
for (e, _) in es {
*count.entry(*e).or_default() += 1;
}
}
LoopB::Vertex(v) => ext.points.push(v3(self.point(*v)?)),
}
}
let mut edges: Vec<u32> = count.keys().copied().collect();
edges.sort_unstable();
for e in edges {
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;
for k in 0..=8 {
let p = c.point(lo + (hi - lo) * f64::from(k) / 8.0).ok_or("edge point")?;
ext.points.push(p);
if count.get(&e).is_some_and(|n| *n > 1) {
ext.seam.push(p);
}
}
}
Ok(ext)
}
fn shell(&mut self, id: u64) -> R<Vec<(u32, bool)>> {
@@ -1119,40 +1160,6 @@ fn map_bspline_surface(b: &BSplineSurface) -> R<BSurface> {
})
}
/// A face's surface as a schema surface.
fn map_surface(s: &Surface) -> R<BSurface> {
map_surface_in(s, 0)
}
fn map_surface_in(s: &Surface, depth: usize) -> R<BSurface> {
if depth > MAX_NESTING {
return Err("surfaces nested too deeply".into());
}
Ok(match s {
Surface::Plane { position } => BSurface::Plane { origin: position.origin, u_axis: position.ref_direction, v_axis: position.y() },
Surface::Cylinder { position, radius } => BSurface::Cylinder { frame: frame(position), radius: *radius },
Surface::Cone { position, radius, semi_angle } => {
if !(*radius >= 0.0) || !(semi_angle.abs() > 1e-12 && semi_angle.abs() < std::f64::consts::FRAC_PI_2) {
return Err(unsupported(format!("a cone of radius {radius} and semi-angle {semi_angle}")));
}
BSurface::Cone { frame: frame(position), radius_at_origin: *radius, half_angle: *semi_angle }
}
Surface::Sphere { position, radius } => BSurface::Sphere { frame: frame(position), radius: *radius },
Surface::Torus { position, major_radius, minor_radius } => {
if !(major_radius > minor_radius) {
return Err(unsupported("a spindle or horn torus"));
}
BSurface::Torus { frame: frame(position), major_radius: *major_radius, minor_radius: *minor_radius }
}
Surface::DegenerateTorus { .. } => return Err(unsupported("DEGENERATE_TOROIDAL_SURFACE")),
Surface::BSpline(b) => map_bspline_surface(b)?,
Surface::RectangularTrimmed { basis, .. } => map_surface_in(basis, depth + 1)?,
Surface::Revolution { .. } => return Err(unsupported("SURFACE_OF_REVOLUTION")),
Surface::Extrusion { .. } => return Err(unsupported("SURFACE_OF_LINEAR_EXTRUSION")),
Surface::Offset { .. } => return Err(unsupported("OFFSET_SURFACE")),
})
}
/// An edge on its schema curve.
#[derive(Debug)]
struct Mapped {
@@ -1213,13 +1220,35 @@ fn map_edge(curve: &Curve, ps: P3, pe: P3, same_sense: bool, closed: bool, near:
}
open_or_closed(BCurve::Polyline { points: points.clone() }, ps, pe, same_sense, closed, tol)
}
Curve::Hyperbola { .. } => Err(unsupported("HYPERBOLA")),
Curve::Parabola { .. } => Err(unsupported("PARABOLA")),
Curve::Hyperbola { position, semi_axis, semi_imag_axis } => {
open_conic(curve, ps, pe, same_sense, closed, |lo, hi| swept::hyperbola_spline(position, *semi_axis, *semi_imag_axis, lo, hi))
}
Curve::Parabola { position, focal_dist } => {
open_conic(curve, ps, pe, same_sense, closed, |lo, hi| swept::parabola_spline(position, *focal_dist, lo, hi))
}
Curve::OffsetCurve3d { .. } => Err(unsupported("OFFSET_CURVE_3D")),
Curve::Trimmed { .. } | Curve::SurfaceCurve { .. } => Err(unsupported("nested trimmed or surface curves")),
}
}
/// A hyperbola or parabola: the vertices' parameters in closed form, the edge running the way
/// they go, as an exact spline over just that interval.
fn open_conic(curve: &Curve, ps: P3, pe: P3, same_sense: bool, closed: bool, spline: impl Fn(f64, f64) -> BCurve) -> R<Mapped> {
if closed {
return Err("a closed edge on an open conic".into());
}
let (ts, te) = swept::open_conic_params(curve, ps, pe)?;
if !((te - ts).abs() > 1e-12 * (1.0 + ts.abs().max(te.abs()))) {
return Err("a degenerate edge on an open conic".into());
}
if (ts - te).abs() > 64.0 {
return Err(unsupported("a hyperbola or parabola edge that long"));
}
let with_step = ts < te;
let (lo, hi) = if with_step { (ts, te) } else { (te, ts) };
Ok(Mapped { curve: spline(lo, hi), interval: [lo, hi], with_step, sense_mismatch: with_step != same_sense })
}
/// A circle or ellipse: the vertices' angles, the arc between them in the edge's sense (a full
/// turn when they coincide).
fn conic(curve: BCurve, position: &Placement, radii: [f64; 2], ps: P3, pe: P3, same_sense: bool, near: bool) -> R<Mapped> {
+88 -24
View File
@@ -4,7 +4,7 @@
//! apex); schema 4 keeps every loop closed in the surface's (u, v), so such a face gets a seam:
//! 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. The loops merge into one: the first, the seam out, the second (or nothing at a pole),
//! 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.
use std::f64::consts::{PI, TAU};
@@ -37,10 +37,12 @@ struct Axis {
poles: [Option<Vec3>; 2],
}
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, PartialEq)]
enum Kind {
Ruled,
Sphere(f64),
/// A placed revolution with u the angle: its profile.
Revolved(BCurve),
}
fn axis(s: &BSurface) -> Option<Axis> {
@@ -63,10 +65,53 @@ fn axis(s: &BSurface) -> Option<Axis> {
let (origin, z) = frame(f);
Some(Axis { origin, z, kind: Kind::Sphere(*radius), poles: [Some(origin - z * *radius), Some(origin + z * *radius)] })
}
BSurface::PlacedRevolution { profile, origin, axis, matrix, forward: false } if *matrix == IDENTITY => {
let (origin, z) = (Vec3::from(*origin), Vec3::from(*axis));
let c = CurveEval::new(profile).ok()?;
let solidcraft_geom::brep::Natural::Range(a, b) = profile.natural() else { return None };
// A profile end on the axis is a pole.
let pole = |t: f64| {
c.point(t).filter(|p| {
let d = *p - origin;
(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)] })
}
_ => None,
}
}
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).
fn turned(p: Vec3, o: Vec3, k: Vec3, angle: f64) -> Vec3 {
let (s, c) = angle.sin_cos();
let x = p - o;
o + x * c + k.cross(x) * s + k * (k.dot(x) * (1.0 - c))
}
/// A revolution's profile turned by `angle`: the meridian there, with the profile's parameter.
fn meridian(profile: &BCurve, o: Vec3, k: Vec3, angle: f64) -> R<BCurve> {
let turn = |h: &[f64; 4]| {
let w = h[3];
let p = turned(Vec3::new(h[0] / w, h[1] / w, h[2] / w), o, k, angle);
[p.x * w, p.y * w, p.z * w, w]
};
Ok(match profile {
BCurve::Spline { knots, points } => {
BCurve::Spline { knots: knots.clone(), points: points.iter().map(|p| arr(turned(Vec3::from(*p), o, k, angle))).collect() }
}
BCurve::RationalSpline { knots, homogeneous } => {
BCurve::RationalSpline { knots: knots.clone(), homogeneous: homogeneous.iter().map(turn).collect() }
}
BCurve::Line { start, end } => {
BCurve::Line { start: arr(turned(Vec3::from(*start), o, k, angle)), end: arr(turned(Vec3::from(*end), o, k, angle)) }
}
_ => return Err(unsupported("a revolution of this profile")),
})
}
impl Builder<'_, '_> {
/// Give every face that winds around its surface without a seam one. Returns how many
/// seams were added.
@@ -104,8 +149,8 @@ impl Builder<'_, '_> {
};
match (up.as_slice(), down.as_slice()) {
// The face lies to the left of its loops in (u, v): above a loop running +u.
([l], []) => self.pole_seam(fi, *l, &ax, ax.poles[1])?,
([], [l]) => self.pole_seam(fi, *l, &ax, ax.poles[0])?,
([l], []) => self.pole_seam(fi, *l, &ax, ax.poles[1], &eval)?,
([], [l]) => self.pole_seam(fi, *l, &ax, ax.poles[0], &eval)?,
([a], [b]) => self.band_seam(fi, *a, *b, &ax, &eval, &mut splits)?,
_ => return Err(format!("face #{step}: {}", unsupported("a face with several loops around its axis"))),
}
@@ -156,12 +201,16 @@ impl Builder<'_, '_> {
Ok(es.iter().skip(k).chain(es.iter().take(k)).copied().collect())
}
/// A seam edge from vertex `a` to vertex `b` on the surface: a ruling line, or a meridian
/// arc of a sphere.
fn seam_edge(&mut self, a: u32, b: u32, ax: &Axis, eval: Option<&SurfaceEval>) -> R<u32> {
/// A seam edge from vertex `a` to vertex `b` on the surface: a ruling line, a meridian arc
/// of a sphere, or a revolution's profile turned to their angle. Returns the edge and
/// whether it runs from `a` to `b`.
fn seam_edge(&mut self, a: u32, b: u32, ax: &Axis, eval: &SurfaceEval, check: bool) -> R<(u32, bool)> {
let (pa, pb) = (self.vpoint(a)?, self.vpoint(b)?);
let (curve, interval) = match ax.kind {
Kind::Ruled => (BCurve::Line { start: arr(pa), end: arr(pb) }, [0.0, 1.0]),
if pa.dist(pb) <= self.tol {
return Err("a seam of no length".into());
}
let (curve, interval, along) = match &ax.kind {
Kind::Ruled => (BCurve::Line { start: arr(pa), end: arr(pb) }, [0.0, 1.0], true),
Kind::Sphere(r) => {
let x = (pa - ax.origin).normalized().ok_or("a seam from the sphere's centre")?;
let to = pb - ax.origin;
@@ -170,37 +219,52 @@ impl Builder<'_, '_> {
if !(theta > 0.0 && theta <= PI) {
return Err("a degenerate seam".into());
}
(BCurve::Circle { frame: Frame { origin: arr(ax.origin), x_axis: arr(x), y_axis: arr(y) }, radius: r }, [0.0, theta])
(BCurve::Circle { frame: Frame { origin: arr(ax.origin), x_axis: arr(x), y_axis: arr(y) }, radius: *r }, [0.0, theta], true)
}
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")?;
let qb = eval.project(pb, Some(qa), f64::INFINITY).ok_or("a seam vertex off its surface")?;
let solidcraft_geom::brep::Natural::Range(lo, hi) = profile.natural() else { return Err("a periodic profile".into()) };
let vb = match ax.poles {
[Some(p), _] if p.dist(pb) <= self.tol => lo,
[_, Some(p)] if p.dist(pb) <= self.tol => hi,
_ => qb[1],
};
let curve = meridian(profile, ax.origin, ax.z, qa[0])?;
let along = qa[1] < vb;
(curve, if along { [qa[1], vb] } else { [vb, qa[1]] }, along)
}
};
if pa.dist(pb) <= self.tol {
return Err("a seam of no length".into());
if !(interval[0] < interval[1]) {
return Err("a degenerate seam".into());
}
// On the surface: its middle within the tolerance (a ruling, a meridian).
let c = CurveEval::new(&curve).map_err(|m| m.to_string())?;
if let Some(eval) = eval {
if check {
let m = c.point(0.5 * (interval[0] + interval[1])).ok_or("seam point")?;
let off = eval.project(m, None, f64::INFINITY).and_then(|q| eval.point(q)).map_or(f64::INFINITY, |s| s.dist(m));
if !(off <= self.tol) {
return Err(unsupported("a seam off its surface: loops with no vertices at one angle"));
}
}
for (t, v) in interval.into_iter().zip([a, b]) {
let vertices = if along { [a, b] } else { [b, a] };
for (t, v) in interval.into_iter().zip(vertices) {
let gap = c.point(t).ok_or("seam point")?.dist(self.vpoint(v)?);
if let Some(x) = self.vertices.get_mut(v as usize) {
x.tolerance = x.tolerance.max(1.5 * gap);
}
}
let i = index(self.edges.len())?;
self.edges.push(EdgeB { curve, interval, vertices: [a, b], tolerance: self.tol });
Ok(i)
self.edges.push(EdgeB { curve, interval, vertices, tolerance: self.tol });
Ok((i, along))
}
/// Join loop `li` of face `fi` to the pole the face covers: seam out and back.
fn pole_seam(&mut self, fi: usize, li: usize, ax: &Axis, pole: Option<Vec3>) -> R<()> {
fn pole_seam(&mut self, fi: usize, li: usize, ax: &Axis, pole: Option<Vec3>, eval: &SurfaceEval) -> R<()> {
let step = self.faces.get(fi).map_or(0, |f| f.step);
let Some(p) = pole else {
return Err(format!("face #{step}: a face around an open cylinder or cone with one bound"));
return Err(format!("face #{step}: a face around an open surface of revolution with one bound"));
};
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();
@@ -229,10 +293,10 @@ impl Builder<'_, '_> {
v
}
};
let seam = self.seam_edge(a, b, ax, None)?;
let (seam, along) = self.seam_edge(a, b, ax, eval, false)?;
let mut merged = ring;
merged.push((seam, true));
merged.push((seam, false));
merged.push((seam, along));
merged.push((seam, !along));
let face = self.faces.get_mut(fi).ok_or("face reference")?;
if let Some(l) = face.loops.get_mut(li) {
*l = LoopB::Edges(merged);
@@ -306,11 +370,11 @@ impl Builder<'_, '_> {
let b_ring = self.rotated(fi, lb, j)?;
let va = self.start(*a_ring.first().ok_or("empty loop")?)?;
let vb = self.start(*b_ring.first().ok_or("empty loop")?)?;
let seam = self.seam_edge(va, vb, ax, Some(eval))?;
let (seam, along) = self.seam_edge(va, vb, ax, eval, true)?;
let mut merged = a_ring;
merged.push((seam, true));
merged.push((seam, along));
merged.extend(b_ring);
merged.push((seam, false));
merged.push((seam, !along));
let face = self.faces.get_mut(fi).ok_or("face reference")?;
if let Some(l) = face.loops.get_mut(la) {
*l = LoopB::Edges(merged);
+583
View File
@@ -0,0 +1,583 @@
//! Swept, offset and open-conic geometry as exact schema 4 kinds. STEP's SURFACE_OF_REVOLUTION
//! becomes a `placed_revolution` (u the angle, v the profile's parameter, as STEP has them);
//! SURFACE_OF_LINEAR_EXTRUSION becomes a spline surface, the profile as an exact (rational)
//! B-spline times degree 1 along the extrusion vector (a circle extruded along its axis, and a
//! circle revolved in a plane through the axis, become the cylinder and ring torus they are, so
//! whole turns keep a periodic parameter); OFFSET_SURFACE of an analytic surface
//! becomes that surface moved along its normal; HYPERBOLA and PARABOLA become exact rational
//! quadratic and quadratic splines.
//!
//! Profiles are bounded where the face needs them: a line, a conic or an open conic over the
//! parameters of the face's boundary points (with a margin), a closed conic starting at the
//! face's seam or in the widest gap between them, so its seam lies outside the face. A
//! revolved profile stops where it meets the axis (beyond it the surface would fold over). The
//! surfaces' parameters may differ from STEP's (an extrusion's v, a conic's rational
//! parameter); pcurves and domains come from the records, never from STEP's parameters.
use std::f64::consts::{FRAC_PI_2, TAU};
use solidcraft_geom::Vec3;
use solidcraft_geom::brep::{Curve as BCurve, Surface as BSurface};
use super::{MAX_NESTING, R, frame, map_bspline_curve, unsupported, v3};
use crate::geom::{Axis1, Curve, P3, Placement, Surface};
/// Margin around a face's parameters on a profile, as a fraction of their span.
const MARGIN: f64 = 0.1;
/// Most sweep of one rational quadratic conic piece.
const CONIC_PIECE: f64 = FRAC_PI_2;
/// Most parameter step of one rational quadratic hyperbola piece (its weight `cosh(step / 2)`
/// stays near 1).
const HYPERBOLA_STEP: f64 = 1.0;
/// Most hyperbola pieces.
const MAX_PIECES: usize = 64;
/// Samples when looking for where a revolved profile meets its axis.
const AXIS_SAMPLES: usize = 128;
/// Points of a face's boundary (its edges sampled) and of its seam edges (used twice).
#[derive(Default)]
pub(super) struct Extent {
pub points: Vec<Vec3>,
pub seam: Vec<Vec3>,
}
fn p3(v: Vec3) -> P3 {
[v.x, v.y, v.z]
}
/// The ellipse of `radii` in `p` from angle `t0` over `sweep` > 0 as rational quadratic pieces
/// whose knots are the pieces' end angles.
pub(super) fn conic_spline(p: &Placement, radii: [f64; 2], t0: f64, sweep: f64) -> BCurve {
let n = ((sweep / CONIC_PIECE).ceil() as usize).clamp(1, 8);
let dt = sweep / n as f64;
let w = (0.5 * dt).cos();
let at = |c: f64, s: f64| p.at(radii[0] * c, radii[1] * s, 0.0);
let mut knots = vec![t0; 3];
let first = at(t0.cos(), t0.sin());
let mut homogeneous = vec![[first[0], first[1], first[2], 1.0]];
for i in 0..n {
let ta = t0 + dt * i as f64;
let (tm, tb) = (ta + 0.5 * dt, ta + dt);
// The meeting point of the end tangents, weighted cos of the half sweep.
let m = at(tm.cos() / w, tm.sin() / w);
homogeneous.push([m[0] * w, m[1] * w, m[2] * w, w]);
let b = at(tb.cos(), tb.sin());
homogeneous.push([b[0], b[1], b[2], 1.0]);
let k = if i + 1 == n { t0 + sweep } else { tb };
knots.extend([k, k]);
}
knots.push(t0 + sweep);
BCurve::RationalSpline { knots, homogeneous }
}
/// The hyperbola branch `a cosh t X + b sinh t Y` from `t0` to `t1` (t0 < t1) as rational
/// quadratic pieces whose knots are the pieces' end parameters.
pub(super) fn hyperbola_spline(p: &Placement, a: f64, b: f64, t0: f64, t1: f64) -> BCurve {
let n = (((t1 - t0) / HYPERBOLA_STEP).ceil() as usize).clamp(1, MAX_PIECES);
let dt = (t1 - t0) / n as f64;
let w = (0.5 * dt).cosh();
let at = |c: f64, s: f64| p.at(a * c, b * s, 0.0);
let mut knots = vec![t0; 3];
let first = at(t0.cosh(), t0.sinh());
let mut homogeneous = vec![[first[0], first[1], first[2], 1.0]];
for i in 0..n {
let ta = t0 + dt * i as f64;
let (tm, tb) = (ta + 0.5 * dt, ta + dt);
let m = at(tm.cosh() / w, tm.sinh() / w);
homogeneous.push([m[0] * w, m[1] * w, m[2] * w, w]);
let e = at(tb.cosh(), tb.sinh());
homogeneous.push([e[0], e[1], e[2], 1.0]);
let k = if i + 1 == n { t1 } else { tb };
knots.extend([k, k]);
}
knots.push(t1);
BCurve::RationalSpline { knots, homogeneous }
}
/// The parabola `f t² X + 2 f t Y` from `t0` to `t1` (t0 < t1): one quadratic Bézier on knots
/// `t0, t1`, so its parameter is STEP's t exactly.
pub(super) fn parabola_spline(p: &Placement, f: f64, t0: f64, t1: f64) -> BCurve {
let at = |t: f64| p.at(f * t * t, 2.0 * f * t, 0.0);
// The end tangents meet at f·(t0·t1, t0 + t1).
let mid = p.at(f * t0 * t1, f * (t0 + t1), 0.0);
BCurve::Spline { knots: vec![t0, t0, t0, t1, t1, t1], points: vec![at(t0), mid, at(t1)] }
}
/// The STEP line from `t0` to `t1` as a degree-1 spline on knots `t0, t1`: STEP's parameter.
fn line_spline(origin: P3, direction: P3, magnitude: f64, t0: f64, t1: f64) -> BCurve {
let at = |t: f64| [0, 1, 2].map(|i| origin[i] + direction[i] * magnitude * t);
BCurve::Spline { knots: vec![t0, t0, t1, t1], points: vec![at(t0), at(t1)] }
}
/// A polyline as a degree-1 spline: segment i on [i, i + 1], as STEP has it.
fn polyline_spline(points: &[P3]) -> BCurve {
let n = points.len();
let mut knots = vec![0.0];
knots.extend((0..n).map(|i| i as f64));
knots.push(n.saturating_sub(1) as f64);
BCurve::Spline { knots, points: points.to_vec() }
}
/// A curve under its SURFACE_CURVE and TRIMMED_CURVE wrappers.
fn basis(c: &Curve) -> R<&Curve> {
let mut c = c;
for _ in 0..=MAX_NESTING {
match c {
Curve::SurfaceCurve { curve_3d, .. } => c = curve_3d,
Curve::Trimmed { basis, .. } => c = basis,
_ => return Ok(c),
}
}
Err("profile curves nested too deeply".into())
}
/// The conic angle of a point in the conic's plane.
fn conic_angle(p: &Placement, radii: [f64; 2], q: Vec3) -> f64 {
let d = q - v3(p.origin);
(d.dot(v3(p.y())) / radii[1]).atan2(d.dot(v3(p.ref_direction)) / radii[0])
}
/// The STEP parameter of `q` (a point on, or projected into the plane of, a planar curve):
/// angle, hyperbola or parabola parameter, or line parameter.
fn planar_param(c: &Curve, q: Vec3) -> Option<f64> {
let local = |p: &Placement| {
let d = q - v3(p.origin);
(d.dot(v3(p.ref_direction)), d.dot(v3(p.y())))
};
let t = match c {
Curve::Line { origin, direction, magnitude } => (q - v3(*origin)).dot(v3(*direction)) / magnitude,
Curve::Circle { position, radius } => conic_angle(position, [*radius; 2], q),
Curve::Ellipse { position, semi_axis_1, semi_axis_2 } => conic_angle(position, [*semi_axis_1, *semi_axis_2], q),
Curve::Hyperbola { position, semi_imag_axis, .. } => (local(position).1 / semi_imag_axis).asinh(),
Curve::Parabola { position, focal_dist } => local(position).1 / (2.0 * focal_dist),
_ => return None,
};
t.is_finite().then_some(t)
}
/// [min, max] of the parameters, or `None` without any.
fn range(ts: &[f64]) -> Option<(f64, f64)> {
let (lo, hi) = ts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), t| (a.min(*t), b.max(*t)));
(lo.is_finite() && hi.is_finite()).then_some((lo, hi))
}
/// Middle-free description of the widest gap between angles: (where it starts, its width).
fn widest_gap(angles: &[f64]) -> Option<(f64, f64)> {
let mut a: Vec<f64> = angles.iter().map(|x| x.rem_euclid(TAU)).filter(|x| x.is_finite()).collect();
a.sort_by(f64::total_cmp);
let (first, last) = (*a.first()?, *a.last()?);
let mut best = (last, first + TAU - last);
for w in a.windows(2) {
if let [x, y] = w
&& y - x > best.1
{
best = (*x, y - x);
}
}
Some(best)
}
/// The angle all the given angles share (within 1e-6 rad), if there are any.
fn common_angle(angles: &[f64]) -> Option<f64> {
let first = *angles.first()?;
let off = |a: f64| ((a - first) + 0.5 * TAU).rem_euclid(TAU) - 0.5 * TAU;
angles.iter().all(|a| off(*a).abs() < 1e-6).then_some(first)
}
/// A profile's parameter range: where the face is (`inner`) and the bounded range with its
/// margin (`outer`), or a whole closed conic from a start angle.
enum Span {
Open { inner: (f64, f64), outer: (f64, f64) },
Turn { start: f64 },
}
/// The range a profile is built over, from the parameters of the face's points on it and of its
/// seam points; `closed` for a circle or ellipse.
fn span(ts: &[f64], seam: &[f64], closed: bool) -> R<Span> {
if closed {
if let Some(s) = common_angle(seam) {
return Ok(Span::Turn { start: s });
}
let (gap_start, gap) = widest_gap(ts).ok_or("a face without boundary points")?;
let covered = TAU - gap;
if gap < 1e-3 {
return Ok(Span::Turn { start: gap_start + gap });
}
let start = gap_start + gap;
let m = (MARGIN * covered + 1e-3).min(0.25 * gap);
return Ok(Span::Open { inner: (start, start + covered), outer: (start - m, start + covered + m) });
}
let (lo, hi) = range(ts).ok_or("a face without boundary points")?;
let m = MARGIN * (hi - lo) + 1e-3 * (1.0 + lo.abs().max(hi.abs()));
Ok(Span::Open { inner: (lo, hi), outer: (lo - m, hi + m) })
}
/// The profile over its span as an exact spline (B-spline and polyline profiles whole).
fn bounded(c: &Curve, s: Span) -> R<BCurve> {
let (lo, hi) = match s {
Span::Open { outer, .. } => outer,
Span::Turn { start } => (start, start + TAU),
};
if !(lo < hi && lo.is_finite() && hi.is_finite()) {
return Err("a profile without extent".into());
}
Ok(match c {
Curve::Line { origin, direction, magnitude } => line_spline(*origin, *direction, *magnitude, lo, hi),
Curve::Circle { position, radius } => conic_spline(position, [*radius; 2], lo, hi - lo),
Curve::Ellipse { position, semi_axis_1, semi_axis_2 } => conic_spline(position, [*semi_axis_1, *semi_axis_2], lo, hi - lo),
Curve::Hyperbola { position, semi_axis, semi_imag_axis } => {
if hi - lo > HYPERBOLA_STEP * MAX_PIECES as f64 {
return Err(unsupported("a hyperbola profile that long"));
}
hyperbola_spline(position, *semi_axis, *semi_imag_axis, lo, hi)
}
Curve::Parabola { position, focal_dist } => parabola_spline(position, *focal_dist, lo, hi),
_ => return Err(unsupported("this profile curve")),
})
}
/// A profile that needs no bounds (a B-spline, a polyline), if it is one.
fn whole(c: &Curve) -> R<Option<BCurve>> {
Ok(match c {
Curve::BSpline(b) => Some(map_bspline_curve(b)?),
Curve::Polyline(points) => {
if points.len() < 2 || points.len() > super::MAX_SPLINE_POINTS || points.windows(2).any(|w| w.first() == w.get(1)) {
return Err(unsupported(format!("a polyline profile of {} points", points.len())));
}
Some(polyline_spline(points))
}
Curve::Line { .. } | Curve::Circle { .. } | Curve::Ellipse { .. } | Curve::Hyperbola { .. } | Curve::Parabola { .. } => None,
Curve::OffsetCurve3d { .. } => return Err(unsupported("an OFFSET_CURVE_3D profile")),
Curve::Trimmed { .. } | Curve::SurfaceCurve { .. } => return Err(unsupported("nested profile curves")),
})
}
/// The middle parameter of a TRIMMED_CURVE trimmed by parameters (on a closed conic, the
/// middle of the arc its sense runs over).
fn trimmed_middle(c: &Curve) -> Option<f64> {
let Curve::Trimmed { basis, trim_1, trim_2, sense, .. } = c else { return None };
let param = |t: &[crate::geom::Trim]| {
t.iter().find_map(|x| match x {
crate::geom::Trim::Parameter(p) => Some(*p),
crate::geom::Trim::Point(_) => None,
})
};
let (a, mut b) = (param(trim_1)?, param(trim_2)?);
if is_closed(basis) {
b = if *sense { a + (b - a).rem_euclid(TAU) } else { a - (a - b).rem_euclid(TAU) };
}
let m = 0.5 * (a + b);
m.is_finite().then_some(m)
}
fn is_closed(c: &Curve) -> bool {
matches!(c, Curve::Circle { .. } | Curve::Ellipse { .. })
}
/// SURFACE_OF_REVOLUTION: the profile turned about the axis, u the angle and v the profile's
/// parameter (`forward: false`), so the natural normal is ∂S/∂angle × ∂S/∂profile, STEP's.
fn revolution(profile: &Curve, axis: &Axis1, ext: &Extent) -> R<BSurface> {
let c = basis(profile)?;
let (o, k) = (v3(axis.origin), v3(axis.direction));
let k = k.normalized().ok_or("a revolution about no direction")?;
let curve = match whole(c)? {
Some(curve) => curve,
None => {
// The meridian half-plane the profile lies in: the radial direction of its point
// farthest from the axis among a few.
let radial = |q: Vec3| {
let d = q - o;
d - k * d.dot(k)
};
let probe = |t: f64| c.point(t).map(v3);
let mut e = None::<Vec3>;
let mut best = 0.0;
// A trimmed profile's own middle says which side of the axis it is on (a profile
// across the axis covers the surface twice, once each way round).
let probes = match trimmed_middle(profile) {
Some(t) => vec![t],
None => vec![-1.0, 0.0, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0],
};
for t in probes {
if let Some(r) = probe(t).map(radial)
&& r.len() > best
{
best = r.len();
e = r.normalized();
}
}
let e = e.ok_or_else(|| unsupported("a profile on its axis"))?;
// Each boundary point turned into the meridian plane, then its profile parameter:
// on whichever side of the axis the profile has a point where it lands.
let meridian = |q: Vec3| ((q - o).dot(k), radial(q).len());
let param = |q: Vec3| {
let (h, r) = meridian(q);
[e, -e]
.into_iter()
.filter_map(|side| {
let at = o + k * h + side * r;
let t = planar_param(c, at)?;
Some((t, probe(t)?.dist(at)))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(t, _)| t)
};
let ts: Vec<f64> = ext.points.iter().filter_map(|q| param(*q)).collect();
let seam: Vec<f64> = ext.seam.iter().filter_map(|q| param(*q)).collect();
// The profile's side of the axis where the face is.
let mid = ts.get(ts.len() / 2).copied().ok_or("a face without boundary points")?;
let e = probe(mid).map(radial).and_then(|r| r.normalized()).unwrap_or(e);
let s = span(&ts, &seam, is_closed(c))?;
// Stop where the profile crosses the axis outside the face.
let side = |t: f64| probe(t).map(|q| (q - o).dot(e));
let s = match s {
Span::Open { inner, outer } => {
let lo = crossing(&side, inner.0, outer.0).unwrap_or(outer.0);
let hi = crossing(&side, inner.1, outer.1).unwrap_or(outer.1);
Span::Open { inner, outer: (lo, hi) }
}
turn => turn,
};
bounded(c, s)?
}
};
let identity = [[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]];
Ok(BSurface::PlacedRevolution { profile: Box::new(curve), origin: p3(o), axis: p3(k), matrix: identity, forward: false })
}
/// Going from `from` (where `side` is positive or zero) towards `to`, the first parameter
/// where `side` reaches zero, if it does.
fn crossing(side: &dyn Fn(f64) -> Option<f64>, from: f64, to: f64) -> Option<f64> {
let s0 = side(from)?;
let mut prev = (from, s0);
for i in 1..=AXIS_SAMPLES {
let t = from + (to - from) * i as f64 / AXIS_SAMPLES as f64;
let s = side(t)?;
if s.signum() != prev.1.signum() || s == 0.0 {
let (mut a, mut b, fa) = (prev.0, t, prev.1);
for _ in 0..100 {
let m = 0.5 * (a + b);
let fm = side(m)?;
if fm.signum() == fa.signum() && fm != 0.0 {
a = m;
} else {
b = m;
}
}
return Some(b);
}
prev = (t, s);
}
None
}
/// SURFACE_OF_LINEAR_EXTRUSION: the profile as an exact spline along u, degree 1 along the
/// vector in v, over the face's reach along it.
fn extrusion(profile: &Curve, direction: P3, magnitude: f64, ext: &Extent) -> R<BSurface> {
let c = basis(profile)?;
let d = v3(direction) * magnitude;
let dd = d.len2();
if !(dd > 0.0 && dd.is_finite()) {
return Err("an extrusion of no length".into());
}
let curve = match whole(c)? {
Some(curve) => curve,
None => {
// Each boundary point slid along the vector onto the profile.
let onto = |q: Vec3| -> Option<f64> {
match c {
Curve::Line { origin, direction: e, magnitude: m } => {
// q − origin = s·m·e + v·d, least squares in (s, v).
let (e, r) = (v3(*e) * *m, q - v3(*origin));
let (a, b, cc) = (e.len2(), e.dot(d), dd);
let det = a * cc - b * b;
(det.abs() > 1e-12 * a * cc).then(|| (r.dot(e) * cc - r.dot(d) * b) / det)
}
Curve::Circle { position, .. }
| Curve::Ellipse { position, .. }
| Curve::Hyperbola { position, .. }
| Curve::Parabola { position, .. } => {
let n = v3(position.axis);
let dn = d.dot(n);
if !(dn.abs() > 1e-9 * d.len()) {
return None;
}
planar_param(c, q - d * ((q - v3(position.origin)).dot(n) / dn))
}
_ => None,
}
};
let ts: Vec<f64> = ext.points.iter().filter_map(|q| onto(*q)).collect();
if ts.len() < ext.points.len() {
return Err(unsupported("an extrusion along its profile's plane"));
}
let seam: Vec<f64> = ext.seam.iter().filter_map(|q| onto(*q)).collect();
bounded(c, span(&ts, &seam, is_closed(c))?)?
}
};
let (knots, homogeneous, rational) = match curve {
BCurve::Spline { knots, points } => (knots, points.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect::<Vec<_>>(), false),
BCurve::RationalSpline { knots, homogeneous } => (knots, homogeneous, true),
_ => return Err("an extrusion profile".into()),
};
// v: the face's points' reach along the vector, less the profile's (its control points
// bound it), with a margin.
let along = |p: Vec3| p.dot(d) / dd;
let (plo, phi) = range(&ext.points.iter().map(|p| along(*p)).collect::<Vec<_>>()).ok_or("a face without boundary points")?;
let cs: Vec<f64> = homogeneous.iter().filter(|h| h[3] > 0.0).map(|h| along(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]))).collect();
let (clo, chi) = range(&cs).ok_or("an extrusion profile")?;
let (lo, hi) = (plo - chi, phi - clo);
let m = MARGIN * (hi - lo) + 1e-3 / dd.sqrt();
let (lo, hi) = (lo - m, hi + m);
let shift = |h: &[f64; 4], s: f64| [h[0] + d.x * s * h[3], h[1] + d.y * s * h[3], h[2] + d.z * s * h[3], h[3]];
let rows: Vec<Vec<[f64; 4]>> = homogeneous.iter().map(|h| vec![shift(h, lo), shift(h, hi)]).collect();
let v_knots = vec![lo, lo, hi, hi];
Ok(if rational {
BSurface::RationalSpline { u_knots: knots, v_knots, homogeneous: rows }
} else {
BSurface::Spline { u_knots: knots, v_knots, points: rows.into_iter().map(|r| r.into_iter().map(|h| [h[0], h[1], h[2]]).collect()).collect() }
})
}
/// An analytic surface moved `d` along its natural normal (every analytic kind's natural
/// normal points out of its axis, or along z × … for a plane): checked at each level.
fn offset(s: BSurface, d: f64) -> R<BSurface> {
let bad = |what: &str| Err(format!("an offset {what}"));
Ok(match s {
BSurface::Plane { origin, u_axis, v_axis } => {
let n = v3(u_axis).cross(v3(v_axis)).normalized().ok_or("a degenerate plane")?;
BSurface::Plane { origin: p3(v3(origin) + n * d), u_axis, v_axis }
}
BSurface::Cylinder { frame, radius } => {
if !(radius + d > 1e-9) {
return bad("cylinder that collapses or turns inside out");
}
BSurface::Cylinder { frame, radius: radius + d }
}
BSurface::Sphere { frame, radius } => {
if !(radius + d > 1e-9) {
return bad("sphere that collapses or turns inside out");
}
BSurface::Sphere { frame, radius: radius + d }
}
BSurface::Cone { frame, radius_at_origin, half_angle } => {
// The normal is (cos a, −sin a) in (radial, axial): the reference radius grows by
// d / cos a, the angle stays.
let r = radius_at_origin + d / half_angle.cos();
if !(r >= 0.0 && r.is_finite()) {
return bad("cone whose reference radius turns negative");
}
BSurface::Cone { frame, radius_at_origin: r, half_angle }
}
BSurface::Torus { frame, major_radius, minor_radius } => {
let r = minor_radius + d;
if !(r > 1e-9 && r < major_radius) {
return bad("torus that collapses or meets its axis");
}
BSurface::Torus { frame, major_radius, minor_radius: r }
}
_ => return Err(unsupported("an OFFSET_SURFACE of a spline or swept surface")),
})
}
/// A face's surface as a schema surface, and whether the record's natural normal is opposite
/// STEP's surface normal (an extruded circle or revolved circle read as a cylinder or torus,
/// whose natural normals point away from their axes); `ext` bounds swept surfaces' profiles.
pub(super) fn map_surface(s: &Surface, ext: &Extent) -> R<(BSurface, bool)> {
let mut reversed = false;
let s = map_surface_in(s, ext, 0, &mut reversed)?;
Ok((s, reversed))
}
/// An extruded circle along its axis is a cylinder; a circle in a plane through the axis,
/// revolved, a ring torus. `None` for anything else.
fn canonical(s: &Surface, reversed: &mut bool) -> Option<BSurface> {
let c = match s {
Surface::Extrusion { profile, .. } | Surface::Revolution { profile, .. } => basis(profile).ok()?,
_ => return None,
};
let Curve::Circle { position, radius } = c else { return None };
let n = v3(position.axis);
match s {
Surface::Extrusion { direction, .. } => {
let d = v3(*direction);
if d.cross(n).len() > 1e-12 {
return None;
}
// STEP's normal is the profile's tangent × the vector: out of the axis when the
// vector runs along the circle's axis.
*reversed = d.dot(n) < 0.0;
Some(BSurface::Cylinder { frame: frame(position), radius: *radius })
}
Surface::Revolution { axis, .. } => {
let (o, k) = (v3(axis.origin), v3(axis.direction).normalized()?);
let centre = v3(position.origin);
let scale = 1.0 + (centre - o).len() + radius;
if n.dot(k).abs() > 1e-12 || (centre - o).dot(n).abs() > 1e-12 * scale {
return None;
}
let foot = o + k * (centre - o).dot(k);
let big = (centre - foot).len();
if !(big > *radius * (1.0 + 1e-9)) {
return None;
}
let x = (centre - foot).normalized()?;
// STEP's normal at the circle's angle 0: ∂angle × ∂profile; the torus's points out
// of the tube.
let (cx, cy) = (v3(position.ref_direction), v3(position.y()));
let at = centre + cx * *radius;
let step_normal = k.cross(at - o).cross(cy * *radius);
*reversed = step_normal.dot(cx) < 0.0;
Some(BSurface::Torus {
frame: solidcraft_geom::brep::Frame { origin: p3(foot), x_axis: p3(x), y_axis: p3(k.cross(x)) },
major_radius: big,
minor_radius: *radius,
})
}
_ => None,
}
}
fn map_surface_in(s: &Surface, ext: &Extent, depth: usize, reversed: &mut bool) -> R<BSurface> {
if depth > MAX_NESTING {
return Err("surfaces nested too deeply".into());
}
if let Some(c) = canonical(s, reversed) {
return Ok(c);
}
Ok(match s {
Surface::Plane { position } => BSurface::Plane { origin: position.origin, u_axis: position.ref_direction, v_axis: position.y() },
Surface::Cylinder { position, radius } => BSurface::Cylinder { frame: frame(position), radius: *radius },
Surface::Cone { position, radius, semi_angle } => {
if !(*radius >= 0.0) || !(semi_angle.abs() > 1e-12 && semi_angle.abs() < FRAC_PI_2) {
return Err(unsupported(format!("a cone of radius {radius} and semi-angle {semi_angle}")));
}
BSurface::Cone { frame: frame(position), radius_at_origin: *radius, half_angle: *semi_angle }
}
Surface::Sphere { position, radius } => BSurface::Sphere { frame: frame(position), radius: *radius },
Surface::Torus { position, major_radius, minor_radius } => {
if !(major_radius > minor_radius) {
return Err(unsupported("a spindle or horn torus"));
}
BSurface::Torus { frame: frame(position), major_radius: *major_radius, minor_radius: *minor_radius }
}
Surface::DegenerateTorus { .. } => return Err(unsupported("DEGENERATE_TOROIDAL_SURFACE")),
Surface::BSpline(b) => super::map_bspline_surface(b)?,
Surface::RectangularTrimmed { basis, .. } => map_surface_in(basis, ext, depth + 1, reversed)?,
Surface::Revolution { profile, axis } => revolution(profile, axis, ext)?,
Surface::Extrusion { profile, direction, magnitude } => extrusion(profile, *direction, *magnitude, ext)?,
Surface::Offset { basis, distance, .. } => {
// Along STEP's normal: the record's, or its opposite.
let b = map_surface_in(basis, ext, depth + 1, reversed)?;
offset(b, if *reversed { -*distance } else { *distance })?
}
})
}
/// The STEP parameters of `ps` and `pe` on a hyperbola or parabola.
pub(super) fn open_conic_params(c: &Curve, ps: P3, pe: P3) -> R<(f64, f64)> {
match (planar_param(c, v3(ps)), planar_param(c, v3(pe))) {
(Some(a), Some(b)) => Ok((a, b)),
_ => Err("no parameter on the conic".into()),
}
}
+339 -2
View File
@@ -687,9 +687,39 @@ fn vertex_parameters_on_lines_conics_and_splines() {
let p = [4.0 * t.cos(), t.sin(), 0.0];
let m = map_edge(&ellipse, [4.0, 0.0, 0.0], p, true, false, false, tol).unwrap();
assert!(m.interval[0].abs() < 1e-15 && (m.interval[1] - t).abs() < 1e-14, "{:?}", m.interval);
// A hyperbola edge: rational quadratic pieces on the STEP parameters of its ends, through
// the hyperbola's points; a parabola: one Bézier whose parameter is STEP's.
let (a, b) = (3.0, 2.0);
let hyperbola = Curve::Hyperbola { position, semi_axis: a, semi_imag_axis: b };
let at = |t: f64| [a * t.cosh(), b * t.sinh(), 0.0];
let m = map_edge(&hyperbola, at(1.5), at(-2.5), true, false, false, tol).unwrap();
assert_eq!(m.interval, [-2.5, 1.5]);
assert!(!m.with_step && m.sense_mismatch);
let c = CurveEval::new(&m.curve).unwrap();
for k in 0..=40 {
let p = c.point(-2.5 + 4.0 * f64::from(k) / 40.0).unwrap();
assert!(((p.x / a).powi(2) - (p.y / b).powi(2) - 1.0).abs() < 1e-12 && p.x > 0.0, "{p:?}");
}
for t in [-2.5, -0.5, 0.5, 1.5] {
assert!(c.point(t).unwrap().dist(v3(at(t))) < 1e-12, "{t}");
}
let parabola = Curve::Parabola { position, focal_dist: 0.5 };
let at = |t: f64| [0.5 * t * t, t, 0.0];
let m = map_edge(&parabola, at(-1.0), at(3.0), true, false, false, tol).unwrap();
assert_eq!(m.interval, [-1.0, 3.0]);
let c = CurveEval::new(&m.curve).unwrap();
for k in 0..=8 {
let t = -1.0 + 0.5 * f64::from(k);
assert!(c.point(t).unwrap().dist(v3(at(t))) < 1e-12, "{t}");
}
// Unsupported curves.
let hyperbola = Curve::Hyperbola { position, semi_axis: 1.0, semi_imag_axis: 1.0 };
assert!(map_edge(&hyperbola, s, e, true, false, false, tol).unwrap_err().contains("not supported yet: HYPERBOLA"));
let offset = Curve::OffsetCurve3d {
basis: std::sync::Arc::new(line),
distance: 1.0,
self_intersect: crate::geom::Logical::False,
ref_direction: [0.0, 0.0, 1.0],
};
assert!(map_edge(&offset, s, e, true, false, false, tol).unwrap_err().contains("not supported yet: OFFSET_CURVE_3D"));
}
use std::f64::consts::{FRAC_PI_2, PI};
@@ -865,3 +895,310 @@ fn corpus_solids_read_into_schema_4() {
}
}
}
/// One STEP surface (entity #10 of `data`, after the common placements) sampled over a box of
/// its own parameters: the mapped record passes through every sample, and its natural normal is
/// STEP's (or its opposite where the mapping says so).
#[track_caller]
fn same_surface(data: &str, us: (f64, f64), vs: (f64, f64)) -> BSurface {
const BASE: &str = "#1=CARTESIAN_POINT('',(0.,0.,0.));#2=DIRECTION('',(0.,0.,1.));#3=DIRECTION('',(1.,0.,0.));\
#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);#5=DIRECTION('',(0.,1.,0.));#6=AXIS1_PLACEMENT('',#1,#2);";
let ex = parse(&format!("{BASE}{data}\n"));
let s = crate::read::Reader::new(&ex, crate::read::Units::default()).surface(10).unwrap();
let n = 8;
let grid: Vec<(f64, f64)> = (0..=n)
.flat_map(|i| (0..=n).map(move |j| (us.0 + (us.1 - us.0) * i as f64 / n as f64, vs.0 + (vs.1 - vs.0) * j as f64 / n as f64)))
.collect();
let ext = swept::Extent { points: grid.iter().map(|&(u, v)| v3(s.point(u, v).unwrap())).collect(), seam: Vec::new() };
let (record, reversed) = swept::map_surface(&s, &ext).unwrap();
record.natural();
let eval = SurfaceEval::new(&record).unwrap();
for &(u, v) in &grid {
let p = v3(s.point(u, v).unwrap());
let q = eval.project(p, None, f64::INFINITY).unwrap_or_else(|| panic!("{data}: no parameters at ({u}, {v})"));
let (at, su, sv) = eval.derivs(q[0], q[1]).unwrap();
assert!(at.dist(p) < 1e-8, "{data}: ({u}, {v}) {p:?} is {} off", at.dist(p));
let mine = su.cross(sv).normalized().unwrap();
let theirs = v3(s.normal(u, v).unwrap());
let agree = mine.dot(theirs) * if reversed { -1.0 } else { 1.0 };
assert!(agree > 0.999, "{data}: ({u}, {v}) normal {mine:?} vs {theirs:?} (reversed {reversed})");
}
record
}
#[test]
fn swept_and_offset_surfaces_are_the_files_own() {
// Revolutions: a slanted line (a cone's frustum), a ball's half circle, an ellipse off the
// axis, a B-spline profile; a circle in a plane through the axis is the ring torus it is.
let line = "#7=CARTESIAN_POINT('',(5.,0.,0.));#8=DIRECTION('',(-2.,0.,4.));#9=VECTOR('',#8,4.47213595499958);#11=LINE('',#7,#9);";
let r = same_surface(&format!("{line}#10=SURFACE_OF_REVOLUTION('',#11,#6);"), (0.0, 6.0), (0.0, 1.0));
assert!(matches!(r, BSurface::PlacedRevolution { forward: false, .. }));
// A circle across the axis covers the ball twice, once each way round: its trims say which
// half the surface is.
for (half, vs) in [
("(PARAMETER_VALUE(0.)),(PARAMETER_VALUE(3.14159265358979))", (0.2, 3.0)),
("(PARAMETER_VALUE(3.14159265358979)),(PARAMETER_VALUE(0.))", (3.3, 6.0)),
] {
let ball = format!(
"#7=AXIS2_PLACEMENT_3D('',#1,#5,#2);#12=CIRCLE('',#7,5.);#11=TRIMMED_CURVE('',#12,{half},.T.,.PARAMETER.);#10=SURFACE_OF_REVOLUTION('',#11,#6);"
);
assert!(matches!(same_surface(&ball, (0.0, 6.0), vs), BSurface::PlacedRevolution { .. }));
}
let tilted =
"#7=CARTESIAN_POINT('',(10.,0.,1.));#8=AXIS2_PLACEMENT_3D('',#7,#5,#3);#11=ELLIPSE('',#8,3.,2.);#10=SURFACE_OF_REVOLUTION('',#11,#6);";
same_surface(tilted, (-1.0, 2.0), (-1.0, 1.5));
let spline = "#7=CARTESIAN_POINT('',(4.,0.,0.));#8=CARTESIAN_POINT('',(6.,0.,2.));#9=CARTESIAN_POINT('',(5.,0.,5.));\
#11=B_SPLINE_CURVE_WITH_KNOTS('',2,(#7,#8,#9),.UNSPECIFIED.,.F.,.F.,(3,3),(0.,1.),.UNSPECIFIED.);#10=SURFACE_OF_REVOLUTION('',#11,#6);";
same_surface(spline, (0.0, 6.0), (0.05, 0.95));
for axis in ["#5", "#12"] {
let torus = format!(
"#12=DIRECTION('',(0.,-1.,0.));#7=CARTESIAN_POINT('',(10.,0.,0.));#8=AXIS2_PLACEMENT_3D('',#7,{axis},#3);#11=CIRCLE('',#8,2.);#10=SURFACE_OF_REVOLUTION('',#11,#6);"
);
assert!(matches!(same_surface(&torus, (0.0, 6.0), (0.0, 6.0)), BSurface::Torus { .. }));
}
// Extrusions: an ellipse askew, a line (a plane strip), a B-spline, a parabola, a hyperbola;
// a circle along its axis either way is a cylinder.
let askew = "#7=ELLIPSE('',#4,9.,4.);#8=DIRECTION('',(0.5,0.25,1.));#9=VECTOR('',#8,2.);#10=SURFACE_OF_LINEAR_EXTRUSION('',#7,#9);";
assert!(matches!(same_surface(askew, (-1.0, 1.0), (0.0, 5.0)), BSurface::RationalSpline { .. }));
let strip = "#7=VECTOR('',#3,2.);#8=LINE('',#1,#7);#9=VECTOR('',#5,1.);#10=SURFACE_OF_LINEAR_EXTRUSION('',#8,#9);";
assert!(matches!(same_surface(strip, (-1.0, 3.0), (0.0, 5.0)), BSurface::Spline { .. }));
let spline = "#7=CARTESIAN_POINT('',(4.,0.,0.));#8=CARTESIAN_POINT('',(6.,1.,0.));#9=CARTESIAN_POINT('',(5.,3.,0.));\
#11=B_SPLINE_CURVE_WITH_KNOTS('',2,(#7,#8,#9),.UNSPECIFIED.,.F.,.F.,(3,3),(0.,1.),.UNSPECIFIED.);#12=VECTOR('',#2,3.);#10=SURFACE_OF_LINEAR_EXTRUSION('',#11,#12);";
same_surface(spline, (0.05, 0.95), (-1.0, 1.0));
let parabola = "#7=PARABOLA('',#4,0.5);#8=VECTOR('',#2,1.);#10=SURFACE_OF_LINEAR_EXTRUSION('',#7,#8);";
same_surface(parabola, (-2.0, 3.0), (0.0, 4.0));
let hyperbola = "#7=HYPERBOLA('',#4,3.,2.);#8=VECTOR('',#2,1.);#10=SURFACE_OF_LINEAR_EXTRUSION('',#7,#8);";
same_surface(hyperbola, (-2.0, 1.5), (0.0, 4.0));
for (dir, reversed) in [("#2", false), ("#7", true)] {
let tube = format!("#7=DIRECTION('',(0.,0.,-1.));#8=CIRCLE('',#4,5.);#9=VECTOR('',{dir},3.);#10=SURFACE_OF_LINEAR_EXTRUSION('',#8,#9);");
assert!(matches!(same_surface(&tube, (0.0, 6.0), (0.0, 2.0)), BSurface::Cylinder { radius: 5.0, .. }));
// Offset along STEP's normal: outward one way, inward the other.
let off = tube.replace("#10=SURFACE", "#11=SURFACE").replace("#10=", "#11=") + "#10=OFFSET_SURFACE('',#11,1.,.F.);";
let want = if reversed { 4.0 } else { 6.0 };
assert!(matches!(same_surface(&off, (0.0, 6.0), (0.0, 2.0)), BSurface::Cylinder { radius, .. } if (radius - want).abs() < 1e-12), "{dir}");
}
// Offsets of each analytic kind, nested ones too.
for (basis, d) in [
("PLANE('',#4)", 2.0),
("CYLINDRICAL_SURFACE('',#4,5.)", -2.0),
("CONICAL_SURFACE('',#4,5.,0.3)", 1.5),
("CONICAL_SURFACE('',#4,5.,-0.3)", -1.0),
("SPHERICAL_SURFACE('',#4,5.)", 1.0),
("TOROIDAL_SURFACE('',#4,10.,2.)", 1.0),
] {
let one = format!("#11={basis};#10=OFFSET_SURFACE('',#11,{},.F.);", real(d));
same_surface(&one, (0.1, 2.0), (0.1, 1.2));
let two = format!("#12={basis};#11=OFFSET_SURFACE('',#12,{},.F.);#10=OFFSET_SURFACE('',#11,0.5,.U.);", real(d));
same_surface(&two, (0.1, 2.0), (0.1, 1.2));
}
// Refused: offsets that collapse or of other kinds, an extrusion in its profile's plane.
let ext = swept::Extent { points: vec![Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 1.0)], seam: Vec::new() };
for (data, why) in [
("#11=CYLINDRICAL_SURFACE('',#4,5.);#10=OFFSET_SURFACE('',#11,-5.,.F.);", "collapses"),
("#11=TOROIDAL_SURFACE('',#4,10.,2.);#10=OFFSET_SURFACE('',#11,9.,.F.);", "meets its axis"),
("#11=CYLINDRICAL_SURFACE('',#4,5.);#12=OFFSET_SURFACE('',#11,-6.,.F.);#10=OFFSET_SURFACE('',#12,6.,.F.);", "collapses"),
("#7=ELLIPSE('',#4,9.,4.);#8=VECTOR('',#3,1.);#10=SURFACE_OF_LINEAR_EXTRUSION('',#7,#8);", "profile's plane"),
(
"#7=ELLIPSE('',#4,9.,4.);#8=VECTOR('',#2,1.);#11=SURFACE_OF_LINEAR_EXTRUSION('',#7,#8);#10=OFFSET_SURFACE('',#11,1.,.F.);",
"not supported yet",
),
] {
let exch = parse(&format!(
"#1=CARTESIAN_POINT('',(0.,0.,0.));#2=DIRECTION('',(0.,0.,1.));#3=DIRECTION('',(1.,0.,0.));#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);{data}\n"
));
let s = crate::read::Reader::new(&exch, crate::read::Units::default()).surface(10).unwrap();
let e = swept::map_surface(&s, &ext).unwrap_err();
assert!(e.contains(why), "{data}: {e}");
}
}
/// A frustum (radius 5 at z = 0 to radius 3 at z = 4) whose side is a SURFACE_OF_REVOLUTION of
/// its slanted profile line, with the profile as a seam edge or without one, about +z or −z
/// (STEP's normal ∂angle × ∂profile then points in, and the face says same_sense .F.).
fn frustum(seam: bool, down: bool) -> p21::Exchange {
let mut w = W::default();
let (pb, pt) = ([5.0, 0.0, 0.0], [3.0, 0.0, 4.0]);
let (vb, vt) = (w.vertex(pb), w.vertex(pt));
let kb = w.circle([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], 5.0);
let kt = w.circle([0.0, 0.0, 4.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], 3.0);
let cb = w.add(format!("EDGE_CURVE('',#{vb},#{vb},#{kb},.T.)"));
let ct = w.add(format!("EDGE_CURVE('',#{vt},#{vt},#{kt},.T.)"));
let (o, d) = (w.pt(pb), w.dir([-2.0, 0.0, 4.0]));
let vec = w.add(format!("VECTOR('',#{d},{})", real(20f64.sqrt())));
let profile = w.add(format!("LINE('',#{o},#{vec})"));
let origin = w.pt([0.0; 3]);
let z = w.dir([0.0, 0.0, if down { -1.0 } else { 1.0 }]);
let axis = w.add(format!("AXIS1_PLACEMENT('',#{origin},#{z})"));
let surface = w.add(format!("SURFACE_OF_REVOLUTION('',#{profile},#{axis})"));
let bounds = if seam {
let s = w.add(format!("EDGE_CURVE('',#{vb},#{vt},#{profile},.T.)"));
let ring = [(cb, true), (s, true), (ct, false), (s, false)];
let oes: Vec<u64> = ring.iter().map(|&(e, o)| w.oe(e, o)).collect();
vec![w.bound(&oes, true, true)]
} else {
let (x, y) = (w.oe(cb, true), w.oe(ct, false));
vec![w.bound(&[x], true, true), w.bound(&[y], false, true)]
};
let side = w.face(&bounds, surface, !down);
let x = w.oe(cb, false);
let bb = w.bound(&[x], true, true);
let plane = w.plane([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let bottom = w.face(&[bb], plane, false);
let x = w.oe(ct, true);
let tb = w.bound(&[x], true, true);
let plane = w.plane([0.0, 0.0, 4.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let top = w.face(&[tb], plane, true);
let shell = closed_shell(&mut w, &[side, bottom, top]);
let solid = w.add(format!("MANIFOLD_SOLID_BREP('frustum',#{shell})"));
w.file(&[solid])
}
#[test]
fn surfaces_of_revolution_read_as_placed_revolutions() {
for seam in [true, false] {
for down in [false, true] {
let s = one_solid(&frustum(seam, down));
assert_eq!(counts(&s.brep), [2, 1, 3, 3, 2], "seam {seam} down {down}");
assert!(s.brep.faces.iter().any(|f| matches!(f.surface, BSurface::PlacedRevolution { forward: false, .. })));
assert_eq!(s.notes.iter().any(|n| n.contains("seam edges added")), !seam, "{:?}", s.notes);
faces_away(&s.brep, 0, |p| [0.0, 0.0, p[2].clamp(1.0, 3.0)]);
}
}
// A ball: a half circle (trimmed from the north pole to the south through +x) revolved,
// bounded by its seam meridian alone. STEP's normal there points in: same_sense .F.
let mut w = W::default();
let (vn, vs) = (w.vertex([0.0, 0.0, 5.0]), w.vertex([0.0, 0.0, -5.0]));
let k = w.circle([0.0; 3], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], 5.0);
let half = w.add(format!("TRIMMED_CURVE('',#{k},(PARAMETER_VALUE(0.)),(PARAMETER_VALUE({})),.T.,.PARAMETER.)", real(PI)));
let e = w.add(format!("EDGE_CURVE('',#{vn},#{vs},#{k},.T.)"));
let (o1, o2) = (w.oe(e, true), w.oe(e, false));
let bound = w.bound(&[o1, o2], true, true);
let (origin, z) = (w.pt([0.0; 3]), w.dir([0.0, 0.0, 1.0]));
let axis = w.add(format!("AXIS1_PLACEMENT('',#{origin},#{z})"));
let surface = w.add(format!("SURFACE_OF_REVOLUTION('',#{half},#{axis})"));
let face = w.face(&[bound], surface, false);
let shell = closed_shell(&mut w, &[face]);
let solid = w.add(format!("MANIFOLD_SOLID_BREP('ball',#{shell})"));
let s = one_solid(&w.file(&[solid]));
assert_eq!(counts(&s.brep), [2, 1, 1, 1, 2]);
faces_away(&s.brep, 0, |_| [0.0; 3]);
}
#[test]
fn surfaces_of_extrusion_read_as_splines_or_cylinders() {
// A half elliptic prism: the ellipse (6 by 3) from −y round +x to +y, extruded 5 up, cut
// by the plane x = 0.
let mut w = W::default();
let p = |y: f64, z: f64| [0.0, y, z];
let v: Vec<(u64, V)> = [p(-3.0, 0.0), p(3.0, 0.0), p(-3.0, 5.0), p(3.0, 5.0)].iter().map(|q| (w.vertex(*q), *q)).collect();
let ellipse = |w: &mut W, z: f64| {
let a = w.axis([0.0, 0.0, z], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
w.add(format!("ELLIPSE('',#{a},6.,3.)"))
};
let (eb, et) = (ellipse(&mut w, 0.0), ellipse(&mut w, 5.0));
let arc_b = w.add(format!("EDGE_CURVE('',#{},#{},#{eb},.T.)", v[0].0, v[1].0));
let arc_t = w.add(format!("EDGE_CURVE('',#{},#{},#{et},.T.)", v[2].0, v[3].0));
let up_m = w.line_edge(v[0], v[2], true, 1.0);
let up_p = w.line_edge(v[1], v[3], true, 1.0);
let low = w.line_edge(v[1], v[0], true, 1.0);
let high = w.line_edge(v[2], v[3], true, 1.0);
let z = w.dir([0.0, 0.0, 1.0]);
let vec = w.add(format!("VECTOR('',#{z},2.)"));
let surface = w.add(format!("SURFACE_OF_LINEAR_EXTRUSION('',#{eb},#{vec})"));
let face = |w: &mut W, ring: &[(u64, bool)], surface: u64, sense: bool| {
let oes: Vec<u64> = ring.iter().map(|&(e, o)| w.oe(e, o)).collect();
let b = w.bound(&oes, true, true);
w.face(&[b], surface, sense)
};
let curved = face(&mut w, &[(arc_b, true), (up_p, true), (arc_t, false), (up_m, false)], surface, true);
let flat_plane = w.plane([0.0; 3], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
let flat = face(&mut w, &[(low, true), (up_m, true), (high, true), (up_p, false)], flat_plane, true);
let bottom_plane = w.plane([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let bottom = face(&mut w, &[(arc_b, false), (low, false)], bottom_plane, false);
let top_plane = w.plane([0.0, 0.0, 5.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let top = face(&mut w, &[(arc_t, true), (high, false)], top_plane, true);
let shell = closed_shell(&mut w, &[curved, flat, bottom, top]);
let solid = w.add(format!("MANIFOLD_SOLID_BREP('half',#{shell})"));
let s = one_solid(&w.file(&[solid]));
assert_eq!(counts(&s.brep), [2, 1, 4, 6, 4]);
assert!(s.brep.faces.iter().any(|f| matches!(f.surface, BSurface::RationalSpline { .. })));
faces_away(&s.brep, 0, |q| [q[0].clamp(0.5, 2.0), q[1].clamp(-1.0, 1.0), q[2].clamp(1.0, 4.0)]);
// A cylinder whose side is its bottom circle extruded up (or down, STEP's normal then
// pointing in), or a narrower cylinder offset out by 1.
for variant in ["up", "down", "offset"] {
let mut w = W::default();
let c = cylinder(&mut w, [1.0, 2.0], 5.0, [0.0, 10.0]);
let side_face = side(&mut w, &c, true);
let bottom_bound = {
let ob = w.oe(c.circles[0], false);
w.bound(&[ob], true, true)
};
let bottom_plane = w.plane([1.0, 2.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let bottom = w.face(&[bottom_bound], bottom_plane, false);
let top_bound = {
let ot = w.oe(c.circles[1], true);
w.bound(&[ot], true, true)
};
let top_plane = w.plane([1.0, 2.0, 10.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let top = w.face(&[top_bound], top_plane, true);
let k = w.circle([1.0, 2.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], 5.0);
let d = w.dir([0.0, 0.0, if variant == "down" { -1.0 } else { 1.0 }]);
let vec = w.add(format!("VECTOR('',#{d},1.)"));
let a = w.axis([1.0, 2.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let narrow = w.add(format!("CYLINDRICAL_SURFACE('',#{a},4.)"));
let shell = closed_shell(&mut w, &[bottom, side_face, top]);
let solid = w.add(format!("MANIFOLD_SOLID_BREP('',#{shell})"));
let old = format!("#{}=CYLINDRICAL_SURFACE", c.surface);
let text = w.s.lines().map(|l| {
if l.starts_with(&old) {
match variant {
"offset" => format!("#{}=OFFSET_SURFACE('',#{narrow},1.,.F.);", c.surface),
_ => format!("#{}=SURFACE_OF_LINEAR_EXTRUSION('',#{k},#{vec});", c.surface),
}
} else if variant == "down" && l.starts_with(&format!("#{side_face}=")) {
l.replace(",.T.);", ",.F.);")
} else {
l.to_string()
}
});
w.s = text.collect::<Vec<_>>().join("\n") + "\n";
assert!(!w.s.contains(&old) && w.s.contains(if variant == "offset" { "OFFSET_SURFACE" } else { "LINEAR_EXTRUSION" }));
assert_eq!(w.s.contains(&format!("#{side_face}=ADVANCED_FACE('',(#{}),#{},.F.)", side_face - 1, c.surface)), variant == "down");
let s = one_solid(&w.file(&[solid]));
assert_eq!(counts(&s.brep), [2, 1, 3, 3, 2], "{variant}");
assert!(s.brep.faces.iter().any(|f| matches!(f.surface, BSurface::Cylinder { radius, .. } if (radius - 5.0).abs() < 1e-12)), "{variant}");
faces_away(&s.brep, 0, |q| [1.0, 2.0, q[2].clamp(1.0, 9.0)]);
}
}
#[test]
fn parabola_and_hyperbola_edges_bound_faces() {
for kind in ["PARABOLA('',#{a},0.5)", "HYPERBOLA('',#{a},1.,1.)"] {
let mut w = W::default();
let at = |t: f64| if kind.starts_with("PARABOLA") { [0.5 * t * t, t, 0.0] } else { [t.cosh(), t.sinh(), 0.0] };
let (p1, p2) = (at(-1.5), at(1.5));
let (v1, v2) = (w.vertex(p1), w.vertex(p2));
let a = w.axis([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let conic = w.add(kind.replace("{a}", &a.to_string()));
let e1 = w.add(format!("EDGE_CURVE('',#{v1},#{v2},#{conic},.T.)"));
let e2 = w.line_edge((v2, p2), (v1, p1), true, 1.0);
let (o1, o2) = (w.oe(e1, true), w.oe(e2, true));
let bound = w.bound(&[o1, o2], true, true);
let plane = w.plane([0.0; 3], [0.0, 0.0, -1.0], [1.0, 0.0, 0.0]);
let face = w.face(&[bound], plane, true);
let shell = w.add(format!("OPEN_SHELL('',(#{face}))"));
let model = w.add(format!("SHELL_BASED_SURFACE_MODEL('',(#{shell}))"));
let s = one_solid(&w.file(&[model]));
assert_eq!(counts(&s.brep), [1, 1, 1, 2, 2], "{kind}");
let e = s.brep.edges.iter().find(|e| !matches!(e.curve, BCurve::Line { .. })).unwrap();
assert_eq!(e.interval, [-1.5, 1.5]);
let c = CurveEval::new(&e.curve).unwrap();
for k in 0..=12 {
let p = c.point(-1.5 + 0.25 * f64::from(k)).unwrap();
let on = if kind.starts_with("PARABOLA") { p.y * p.y - 2.0 * p.x } else { p.x * p.x - p.y * p.y - 1.0 };
assert!(on.abs() < 1e-12, "{kind}: {p:?}");
}
}
}