Skip to main content

ogeom_geom/
traits.rs

1//! The adaptor traits: what every algorithm sees geometry through.
2//!
3//! Intersection, projection, extrema and tessellation are written against
4//! [`Curve3d`], [`Curve2d`] and [`Surface`], never against a concrete type.
5//! Nothing downstream needs to know whether it holds an analytic cylinder or a
6//! NURBS patch, which is what keeps one implementation of each algorithm rather
7//! than one per surface type.
8//!
9//! # Fast paths, opted into
10//!
11//! [`Curve3d::kind`] and [`Surface::kind`] let an algorithm *ask*. Plane/plane
12//! intersection is two lines of algebra and should never go through a marching
13//! intersector. A caller that wants that shortcut matches on the kind and takes
14//! it. The general path never has to know what it is looking at, so adding a
15//! surface type does not break existing algorithms. It only forgoes a
16//! shortcut until someone writes one.
17
18use ogeom_core::{OgeomResult, Tolerances};
19use ogeom_math::{Direction, Point, Point2, Transform, Vector, Vector2};
20
21/// How far past its ends a curve's domain still answers: the parametric
22/// confusion, or a few ulps of the ends themselves where those are large.
23/// A line's unbounded domain ends at a billion, where one ulp is a tenth of
24/// a micron, and a step to the end computed as `a + (b - a) * t` lands an
25/// ulp or so past it.
26pub(crate) fn domain_slack(a: f64, b: f64, tol: Tolerances) -> f64 {
27    tol.parametric()
28        .max(a.abs().max(b.abs()) * 4.0 * f64::EPSILON)
29}
30
31/// Whether a curve evaluated outside its domain says where it was asked
32/// from. Read once: the refusal is routine inside booleans, and reading the
33/// environment takes a lock.
34static DEBUG_DOMAIN: std::sync::LazyLock<bool> =
35    std::sync::LazyLock::new(|| std::env::var_os("OGEOM_DEBUG_DOMAIN").is_some());
36
37/// How smooth a curve or surface is.
38///
39/// Ordered from least to most smooth, so `>=` is a meaningful test.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub enum Continuity {
42    /// Positions agree. Tangents may not. A corner.
43    C0,
44    /// Tangent *directions* agree, but their magnitudes need not. Enough for a
45    /// visually smooth join, and the usual requirement for a wire.
46    G1,
47    /// First derivatives agree exactly.
48    C1,
49    /// Curvature agrees.
50    G2,
51    /// Second derivatives agree exactly.
52    C2,
53    /// Differentiable to any order: an analytic surface away from its
54    /// degeneracies.
55    CInfinity,
56}
57
58/// What kind of curve this is, for analytic fast paths.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum CurveKind {
62    /// A straight line.
63    Line,
64    /// A circle or circular arc.
65    Circle,
66    /// An ellipse or elliptical arc.
67    Ellipse,
68    /// One branch of a hyperbola.
69    Hyperbola,
70    /// A parabola.
71    Parabola,
72    /// A polynomial or rational Bézier.
73    Bezier,
74    /// A polynomial or rational B-spline.
75    BSpline,
76    /// A helix about an axis.
77    Helix,
78    /// A restriction of another curve to a sub-interval.
79    Trimmed,
80    /// A curve offset from another.
81    Offset,
82    /// A pcurve composed with the surface it is drawn on.
83    OnSurface,
84    /// An affine-plus-trigonometric chart curve.
85    Trig,
86}
87
88/// What kind of surface this is, for analytic fast paths.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum SurfaceKind {
92    /// A plane.
93    Plane,
94    /// A circular cylinder.
95    Cylinder,
96    /// A circular cone.
97    Cone,
98    /// A sphere.
99    Sphere,
100    /// A torus.
101    Torus,
102    /// A polynomial or rational Bézier patch.
103    Bezier,
104    /// A polynomial or rational B-spline patch.
105    BSpline,
106    /// A surface of revolution.
107    Revolution,
108    /// A surface swept by translating a curve.
109    Extrusion,
110    /// A restriction of another surface to a sub-rectangle.
111    Trimmed,
112    /// A surface offset from another.
113    Offset,
114}
115
116impl SurfaceKind {
117    /// Whether this surface is a quadric: a plane, cylinder, cone or sphere.
118    ///
119    /// Quadric pairs have closed-form intersections, which is worth a great
120    /// deal: it is the difference between an exact conic and a marched
121    /// approximation of one.
122    #[must_use]
123    pub const fn is_quadric(self) -> bool {
124        matches!(
125            self,
126            Self::Plane | Self::Cylinder | Self::Cone | Self::Sphere
127        )
128    }
129
130    /// Whether this surface has an exact analytic form, as opposed to being
131    /// defined by control points.
132    #[must_use]
133    pub const fn is_analytic(self) -> bool {
134        matches!(
135            self,
136            Self::Plane | Self::Cylinder | Self::Cone | Self::Sphere | Self::Torus
137        )
138    }
139}
140
141/// A parametric curve in space.
142///
143/// Implementors must guarantee:
144///
145/// - [`Curve3d::domain`] returns `(a, b)` with `a < b`, both finite;
146/// - [`Curve3d::point_at`] and the derivative methods agree: `d1_at` is the
147///   derivative of `point_at`, and so on;
148/// - a periodic curve's period is exactly its domain width.
149pub trait Curve3d {
150    /// The parameter interval over which the curve is defined.
151    fn domain(&self) -> (f64, f64);
152
153    /// The point at `u`.
154    ///
155    /// # Errors
156    ///
157    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `u` is outside the
158    /// domain and the curve is not periodic.
159    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point>;
160
161    /// The first derivative at `u`.
162    ///
163    /// # Errors
164    ///
165    /// As [`Curve3d::point_at`].
166    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector>;
167
168    /// Derivatives up to order `n`, with `result[0]` the point itself.
169    ///
170    /// # Errors
171    ///
172    /// As [`Curve3d::point_at`].
173    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>>;
174
175    /// What kind of curve this is.
176    fn kind(&self) -> CurveKind;
177
178    /// How smooth the curve is across its domain.
179    fn continuity(&self) -> Continuity;
180
181    /// Whether the curve's ends meet.
182    fn is_closed(&self, tol: Tolerances) -> bool;
183
184    /// Whether the curve continues past its domain by repeating.
185    ///
186    /// Distinct from being closed: a full circle is both, a closed B-spline
187    /// whose ends merely coincide is closed but not periodic, and evaluating
188    /// the latter outside its domain is an error.
189    fn is_periodic(&self) -> bool;
190
191    /// The unit tangent at `u`.
192    ///
193    /// # Errors
194    ///
195    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) at a cusp,
196    /// where the derivative vanishes.
197    fn tangent_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Direction> {
198        Direction::new(self.d1_at(u, tol)?, tol)
199    }
200
201    /// The curvature at `u`.
202    ///
203    /// # Errors
204    ///
205    /// As [`Curve3d::point_at`].
206    fn curvature_at(&self, u: f64, tol: Tolerances) -> OgeomResult<f64> {
207        let d = self.derivatives_at(u, 2, tol)?;
208        let speed = d[1].magnitude();
209        if speed == 0.0 {
210            return Ok(0.0);
211        }
212        Ok(d[1].cross(d[2]).magnitude() / (speed * speed * speed))
213    }
214
215    /// The start point.
216    ///
217    /// # Errors
218    ///
219    /// As [`Curve3d::point_at`].
220    fn start(&self, tol: Tolerances) -> OgeomResult<Point> {
221        self.point_at(self.domain().0, tol)
222    }
223
224    /// The end point.
225    ///
226    /// # Errors
227    ///
228    /// As [`Curve3d::point_at`].
229    fn end(&self, tol: Tolerances) -> OgeomResult<Point> {
230        self.point_at(self.domain().1, tol)
231    }
232
233    /// Bring `u` into the domain, wrapping if the curve is periodic.
234    ///
235    /// # Errors
236    ///
237    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `u` is outside the
238    /// domain of a non-periodic curve by more than `tol.parametric()`.
239    fn normalize_parameter(&self, u: f64, tol: Tolerances) -> OgeomResult<f64> {
240        let (a, b) = self.domain();
241        if self.is_periodic() && u.is_finite() {
242            if u >= a && u < b {
243                return Ok(u);
244            }
245            let period = b - a;
246            return Ok(a + (u - a).rem_euclid(period));
247        }
248        let slack = domain_slack(a, b, tol);
249        if !u.is_finite() || u < a - slack || u > b + slack {
250            if *DEBUG_DOMAIN {
251                eprintln!(
252                    "DOMAIN {u} outside [{a}, {b}]:\n{}",
253                    std::backtrace::Backtrace::force_capture()
254                );
255            }
256            return Err(ogeom_core::ogeom_err!(
257                Domain,
258                "parameter {u} outside curve domain [{a}, {b}]"
259            ));
260        }
261        Ok(u.clamp(a, b))
262    }
263}
264
265/// A parametric curve in the plane.
266///
267/// The same contract as [`Curve3d`], one dimension down. Kept as a separate
268/// trait rather than a generic parameter because pcurves (curves in a
269/// surface's parameter space) are used differently enough from spatial curves
270/// that conflating them invites mistakes.
271pub trait Curve2d {
272    /// The parameter interval over which the curve is defined.
273    fn domain(&self) -> (f64, f64);
274
275    /// The point at `u`.
276    ///
277    /// # Errors
278    ///
279    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `u` is outside the
280    /// domain and the curve is not periodic.
281    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2>;
282
283    /// The first derivative at `u`.
284    ///
285    /// # Errors
286    ///
287    /// As [`Curve2d::point_at`].
288    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2>;
289
290    /// Derivatives up to order `n`, with `result[0]` the point itself.
291    ///
292    /// # Errors
293    ///
294    /// As [`Curve2d::point_at`].
295    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>>;
296
297    /// What kind of curve this is.
298    fn kind(&self) -> CurveKind;
299
300    /// Whether the curve's ends meet.
301    fn is_closed(&self, tol: Tolerances) -> bool;
302
303    /// Whether the curve continues past its domain by repeating.
304    fn is_periodic(&self) -> bool;
305
306    /// The start point.
307    ///
308    /// # Errors
309    ///
310    /// As [`Curve2d::point_at`].
311    fn start(&self, tol: Tolerances) -> OgeomResult<Point2> {
312        self.point_at(self.domain().0, tol)
313    }
314
315    /// The end point.
316    ///
317    /// # Errors
318    ///
319    /// As [`Curve2d::point_at`].
320    fn end(&self, tol: Tolerances) -> OgeomResult<Point2> {
321        self.point_at(self.domain().1, tol)
322    }
323}
324
325/// A surface's point and its derivatives through second order, at one place.
326///
327/// What a foot-point solve needs in one go: see [`Surface::jet_at`].
328#[derive(Debug, Clone, Copy, PartialEq)]
329pub struct SurfaceJet {
330    /// The point at the parameters asked for.
331    pub point: Point,
332    /// The first derivative along `u`.
333    pub du: Vector,
334    /// The first derivative along `v`.
335    pub dv: Vector,
336    /// The second derivative along `u`.
337    pub d2u: Vector,
338    /// The mixed second derivative.
339    pub duv: Vector,
340    /// The second derivative along `v`.
341    pub d2v: Vector,
342}
343
344/// A surface's curvature at one place: the principal curvatures and the
345/// directions they are taken in, with the mean and Gaussian curvatures
346/// they combine to.
347///
348/// What curvature display, zebra analysis and a fillet's seat all ask.
349/// The principal directions are unit tangents in space, perpendicular to
350/// each other. At an umbilic (where every direction curves the same, as
351/// everywhere on a sphere or a plane) they are any perpendicular pair
352/// in the tangent plane, and [`SurfaceCurvature::is_umbilic`] says so.
353#[derive(Debug, Clone, Copy, PartialEq)]
354pub struct SurfaceCurvature {
355    /// The largest normal curvature.
356    pub max: f64,
357    /// The smallest normal curvature.
358    pub min: f64,
359    /// The tangent direction of the largest.
360    pub max_direction: Direction,
361    /// The tangent direction of the smallest.
362    pub min_direction: Direction,
363    /// The unit normal the curvatures are signed against: positive where
364    /// the surface curves towards it.
365    pub normal: Direction,
366}
367
368impl SurfaceCurvature {
369    /// The mean curvature, `(max + min) / 2`.
370    #[must_use]
371    pub fn mean(&self) -> f64 {
372        f64::midpoint(self.max, self.min)
373    }
374
375    /// The Gaussian curvature, `max · min`.
376    #[must_use]
377    pub fn gaussian(&self) -> f64 {
378        self.max * self.min
379    }
380
381    /// Whether the two principal curvatures are equal to within `tol`'s
382    /// angular resolution of their size, so no direction is principal
383    /// above another.
384    #[must_use]
385    pub fn is_umbilic(&self, tol: Tolerances) -> bool {
386        (self.max - self.min).abs() <= tol.angular() * self.max.abs().max(self.min.abs()).max(1.0)
387    }
388
389    /// The normal curvature along the tangent direction `along`, signed as
390    /// the principal ones are.
391    ///
392    /// Euler's formula: `max cos²θ + min sin²θ`, with `θ` the angle from the
393    /// largest's direction. The part of `along` that leaves the tangent plane
394    /// is dropped first. `None` if nothing is left of it, as for a vector
395    /// along the normal or a zero one.
396    #[must_use]
397    pub fn normal_curvature(&self, along: Vector) -> Option<f64> {
398        let c = along.dot(self.max_direction.vector());
399        let s = along.dot(self.min_direction.vector());
400        let size = c.mul_add(c, s * s);
401        // Relative to the vector's own length, so a short tangent is still
402        // a tangent and only one lying along the normal is refused.
403        let length = along.dot(along);
404        if length == 0.0 || !size.is_finite() || size <= 1e-24 * length {
405            return None;
406        }
407        Some(self.max.mul_add(c * c, self.min * s * s) / size)
408    }
409}
410
411/// A parametric surface.
412///
413/// Implementors must guarantee:
414///
415/// - both domain intervals are non-empty and finite;
416/// - the derivative methods agree with [`Surface::point_at`];
417/// - `du` and `dv` are the derivatives along the first and second parameters
418///   respectively, in that order, for every surface.
419pub trait Surface {
420    /// The `u` and `v` parameter intervals.
421    fn domain(&self) -> ((f64, f64), (f64, f64));
422
423    /// The point at `(u, v)`.
424    ///
425    /// # Errors
426    ///
427    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the parameters lie
428    /// outside the domain in a direction that is not periodic.
429    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point>;
430
431    /// The two first derivatives at `(u, v)`.
432    ///
433    /// # Errors
434    ///
435    /// As [`Surface::point_at`].
436    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)>;
437
438    /// The three second derivatives at `(u, v)`: `d2u`, `duv`, `d2v`.
439    ///
440    /// # Errors
441    ///
442    /// As [`Surface::point_at`].
443    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)>;
444
445    /// The point and first derivatives together: `(point, du, dv)`.
446    ///
447    /// For a caller that needs both at every sample (an integral over the
448    /// surface): a patch has the point in the same derivative table, and
449    /// asking for it separately sums the control grid twice. Agreement with
450    /// the separate accessors is to rounding, as for [`Surface::jet_at`].
451    ///
452    /// # Errors
453    ///
454    /// As [`Surface::point_at`].
455    fn point_d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Point, Vector, Vector)> {
456        let point = self.point_at(u, v, tol)?;
457        let (du, dv) = self.d1_at(u, v, tol)?;
458        Ok((point, du, dv))
459    }
460
461    /// The point and every derivative through second order, together.
462    ///
463    /// The default asks the three accessors above, which is right for a
464    /// surface carrying its answer in closed form: a plane or a cylinder costs
465    /// the same either way.
466    ///
467    /// It is not right for a tensor-product patch, where each accessor
468    /// re-locates the knot spans, rebuilds the basis functions and sums the
469    /// control grid again, three times, for numbers the order-two table
470    /// already holds. Anything walking a surface and asking for all six at a
471    /// point, as a foot-point solve does at every step, pays that over and
472    /// over. Such surfaces override this.
473    ///
474    /// **The contract is agreement to rounding, not to the bit.** A patch sums
475    /// its point by de Boor and its derivatives by basis functions, and those
476    /// reassociate differently. The combined answer may differ from the
477    /// separate accessors' in the last ulp. Callers needing one consistent
478    /// jet (every value from the same evaluation) should use this and not
479    /// mix it with the accessors at the same parameters.
480    ///
481    /// # Errors
482    ///
483    /// As [`Surface::point_at`].
484    fn jet_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<SurfaceJet> {
485        let point = self.point_at(u, v, tol)?;
486        let (du, dv) = self.d1_at(u, v, tol)?;
487        let (d2u, duv, d2v) = self.d2_at(u, v, tol)?;
488        Ok(SurfaceJet {
489            point,
490            du,
491            dv,
492            d2u,
493            duv,
494            d2v,
495        })
496    }
497
498    /// What kind of surface this is.
499    fn kind(&self) -> SurfaceKind;
500
501    /// How smooth the surface is across its domain.
502    fn continuity(&self) -> Continuity;
503
504    /// Whether the surface closes on itself along `u`.
505    fn is_closed_u(&self, tol: Tolerances) -> bool;
506
507    /// Whether the surface closes on itself along `v`.
508    fn is_closed_v(&self, tol: Tolerances) -> bool;
509
510    /// Whether `u` repeats past the domain.
511    fn is_periodic_u(&self) -> bool;
512
513    /// Whether `v` repeats past the domain.
514    fn is_periodic_v(&self) -> bool;
515
516    /// The unit normal at `(u, v)`, following `du x dv`.
517    ///
518    /// # Errors
519    ///
520    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) at a
521    /// degeneracy (a pole or an apex) where the tangents determine no normal.
522    fn normal_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Direction> {
523        let (du, dv) = self.d1_at(u, v, tol)?;
524        // Compared against the square of the larger tangent: at a pole one
525        // tangent vanishes, and a test relative to the *product* would find the
526        // cross product large by comparison and call the point healthy.
527        let scale = du.magnitude().max(dv.magnitude());
528        if du.cross(dv).magnitude() <= tol.angular() * scale * scale {
529            return Err(ogeom_core::ogeom_err!(
530                Construction,
531                "surface is degenerate at ({u}, {v}); no normal is determined"
532            ));
533        }
534        Direction::new(du.cross(dv), tol)
535    }
536
537    /// The principal curvatures and directions at `(u, v)`.
538    ///
539    /// From the two fundamental forms of the jet: the principal curvatures
540    /// are the eigenvalues of the shape operator `I⁻¹ II`, the directions
541    /// its eigenvectors carried into space. Signed against the surface's
542    /// own normal, [`Surface::normal_at`], so a sphere of radius `r` seen
543    /// from outside reads `-1/r` twice and a cylinder `-1/r` round and `0`
544    /// along.
545    ///
546    /// # Errors
547    ///
548    /// As [`Surface::normal_at`]: at a degenerate point there is no normal
549    /// to sign against.
550    fn curvature_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<SurfaceCurvature> {
551        let jet = self.jet_at(u, v, tol)?;
552        let normal = self.normal_at(u, v, tol)?;
553        let n = normal.vector();
554        let (e, f, g) = (jet.du.dot(jet.du), jet.du.dot(jet.dv), jet.dv.dot(jet.dv));
555        let (l, m, nn) = (jet.d2u.dot(n), jet.duv.dot(n), jet.d2v.dot(n));
556        let det = e * g - f * f;
557        let gaussian = (l * nn - m * m) / det;
558        let mean = (e * nn - 2.0 * f * m + g * l) / (2.0 * det);
559        let spread = (mean * mean - gaussian).max(0.0).sqrt();
560        let (max, min) = (mean + spread, mean - spread);
561        // A principal direction `a du + b dv` solves `(II - k I)(a, b) = 0`;
562        // either row of that matrix gives it, and the row with the larger
563        // entries is the one to trust. At an umbilic both rows vanish and
564        // any tangent will do: `du` and the normal turned against it.
565        let direction_for = |k: f64| -> OgeomResult<Vector> {
566            let row1 = (l - k * e, m - k * f);
567            let row2 = (m - k * f, nn - k * g);
568            let (a, b) = if row1.0.hypot(row1.1) >= row2.0.hypot(row2.1) {
569                (row1.1, -row1.0)
570            } else {
571                (row2.1, -row2.0)
572            };
573            let along = jet.du * a + jet.dv * b;
574            let scale = jet.du.magnitude().max(jet.dv.magnitude());
575            if along.magnitude() <= tol.angular() * scale * (a.abs() + b.abs()).max(1.0)
576                || a.abs() + b.abs() <= tol.angular() * (l.abs() + m.abs() + nn.abs()).max(1.0)
577            {
578                return Ok(jet.du);
579            }
580            Ok(along)
581        };
582        let max_direction = Direction::new(direction_for(max)?, tol)?;
583        let min_direction = if spread <= tol.angular() * max.abs().max(min.abs()).max(1.0) {
584            Direction::new(n.cross(max_direction.vector()), tol)?
585        } else {
586            Direction::new(direction_for(min)?, tol)?
587        };
588        Ok(SurfaceCurvature {
589            max,
590            min,
591            max_direction,
592            min_direction,
593            normal,
594        })
595    }
596
597    /// Whether the surface degenerates at `(u, v)`.
598    ///
599    /// # Errors
600    ///
601    /// As [`Surface::point_at`].
602    fn is_degenerate_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<bool> {
603        let (du, dv) = self.d1_at(u, v, tol)?;
604        let scale = du.magnitude().max(dv.magnitude());
605        Ok(du.cross(dv).magnitude() <= tol.angular() * scale * scale)
606    }
607
608    /// Bring `(u, v)` into the domain, wrapping in whichever directions are
609    /// periodic, or closed.
610    ///
611    /// A surface that merely closes on itself (a clamped B-spline tube
612    /// whose first and last control columns coincide) has the same points
613    /// at both ends of its domain exactly as a periodic one does, and a
614    /// parameter a whole period past the end names a point it has. A face
615    /// whose trim runs right round such a tube has a ring that straddles
616    /// the join whichever way it is slid, so somewhere it is asked past the
617    /// end, and a refusal there leaves the face unmeshable. Closure is
618    /// consulted only once a parameter is actually outside, so the common
619    /// case pays nothing for it.
620    ///
621    /// # Errors
622    ///
623    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if a parameter is outside
624    /// a direction's domain by more than `tol.parametric()` and that
625    /// direction neither repeats nor closes.
626    fn normalize_parameters(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(f64, f64)> {
627        // Every evaluation of every surface passes through here, so the
628        // in-range path is two comparisons, and everything rarer (wrapping
629        // a periodic direction, wrapping a closed one, refusing) lives out
630        // of line. An indirect call for closure on the common path costs a
631        // measurable share of a large mesh's time, for nothing in-range.
632        let ((ua, ub), (va, vb)) = self.domain();
633        let slack = tol.parametric();
634        let u = if u >= ua - slack && u <= ub + slack && !self.is_periodic_u() {
635            u.clamp(ua, ub)
636        } else if u >= ua && u < ub {
637            // Periodic and already in its first period: nothing to wrap.
638            u
639        } else {
640            self.parameter_outside(u, ua, ub, self.is_periodic_u(), true, tol)?
641        };
642        let v = if v >= va - slack && v <= vb + slack && !self.is_periodic_v() {
643            v.clamp(va, vb)
644        } else if v >= va && v < vb {
645            v
646        } else {
647            self.parameter_outside(v, va, vb, self.is_periodic_v(), false, tol)?
648        };
649        Ok((u, v))
650    }
651
652    /// A parameter outside its domain, or on a periodic direction: wrapped
653    /// where the direction repeats or closes on itself, refused otherwise.
654    ///
655    /// A surface that merely closes on itself has the same points at both
656    /// ends of its domain exactly as a periodic one does, so a parameter a
657    /// whole period past the end names a point it has and is wrapped to it,
658    /// for the reason given on `normalize_parameters`. Closure is consulted
659    /// only here, once a parameter is actually outside.
660    ///
661    /// # Errors
662    ///
663    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the parameter is
664    /// outside by more than `tol.parametric()` and the direction neither
665    /// repeats nor closes.
666    #[cold]
667    fn parameter_outside(
668        &self,
669        t: f64,
670        a: f64,
671        b: f64,
672        periodic: bool,
673        across: bool,
674        tol: Tolerances,
675    ) -> OgeomResult<f64> {
676        if periodic && t.is_finite() {
677            return Ok(a + (t - a).rem_euclid(b - a));
678        }
679        let closed = if across {
680            self.is_closed_u(tol)
681        } else {
682            self.is_closed_v(tol)
683        };
684        if t.is_finite() && b > a && closed {
685            return Ok(a + (t - a).rem_euclid(b - a));
686        }
687        Err(ogeom_core::ogeom_err!(
688            Domain,
689            "{} parameter {t} outside [{a}, {b}]",
690            if across { "u" } else { "v" }
691        ))
692    }
693}
694
695/// Geometry that can be moved by a similarity.
696///
697/// Separate from the evaluation traits because a *view* of geometry (a
698/// borrowed adaptor over someone else's data) can be evaluated but not moved.
699pub trait Transformable: Sized {
700    /// This geometry moved by `t`.
701    ///
702    /// # Errors
703    ///
704    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
705    /// transformed geometry would be degenerate.
706    fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self>;
707}
708
709/// Reversible geometry: the same point set, traversed the other way.
710pub trait Reversible: Sized {
711    /// This geometry with its parameter direction reversed.
712    ///
713    /// The domain is preserved, so a curve reversed still runs over the same
714    /// interval. Only the direction of travel changes. Preserving the domain
715    /// matters because trimming ranges elsewhere refer to it.
716    #[must_use]
717    fn reversed(&self) -> Self;
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn continuity_is_ordered_from_least_to_most_smooth() {
726        assert!(Continuity::C0 < Continuity::G1);
727        assert!(Continuity::G1 < Continuity::C1);
728        assert!(Continuity::C1 < Continuity::G2);
729        assert!(Continuity::G2 < Continuity::C2);
730        assert!(Continuity::C2 < Continuity::CInfinity);
731        // The ordering is what makes a requirement expressible as a comparison.
732        assert!(Continuity::CInfinity >= Continuity::C1);
733    }
734
735    #[test]
736    fn quadrics_and_analytics_are_classified_correctly() {
737        for k in [
738            SurfaceKind::Plane,
739            SurfaceKind::Cylinder,
740            SurfaceKind::Cone,
741            SurfaceKind::Sphere,
742        ] {
743            assert!(k.is_quadric(), "{k:?}");
744            assert!(k.is_analytic(), "{k:?}");
745        }
746        // A torus is analytic but quartic, not quadric, a distinction that
747        // matters, since quadric pairs have closed-form intersections and
748        // torus pairs do not.
749        assert!(!SurfaceKind::Torus.is_quadric());
750        assert!(SurfaceKind::Torus.is_analytic());
751
752        for k in [
753            SurfaceKind::BSpline,
754            SurfaceKind::Bezier,
755            SurfaceKind::Revolution,
756            SurfaceKind::Extrusion,
757            SurfaceKind::Trimmed,
758            SurfaceKind::Offset,
759        ] {
760            assert!(!k.is_quadric(), "{k:?}");
761            assert!(!k.is_analytic(), "{k:?}");
762        }
763    }
764
765    /// A line's unbounded domain ends at a billion, where an ulp is a tenth
766    /// of a micron: a parameter an ulp or two past the end, which a step
767    /// computed toward the end lands on, is the end. A short segment's end
768    /// keeps the parametric slack, and a micron past either is refused.
769    #[test]
770    #[allow(clippy::unwrap_used)]
771    fn a_domain_end_answers_an_ulp_past_it_at_any_magnitude() {
772        let tol = Tolerances::millimetres();
773        let axis = ogeom_math::Axis::new(Point::ORIGIN, Direction::X);
774        let line = crate::LineCurve::new(axis);
775        let (a, b) = line.domain();
776        for u in [b, b.next_up(), b.next_up().next_up(), a.next_down()] {
777            assert!(line.point_at(u, tol).is_ok(), "{u} on [{a}, {b}]");
778        }
779        assert!(line.point_at(b + 1e-3, tol).is_err());
780        let segment =
781            crate::LineCurve::segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0), tol).unwrap();
782        assert!(segment.point_at(1.0 + 1e-10, tol).is_ok());
783        assert!(segment.point_at(1.0 + 1e-6, tol).is_err());
784        let flat = crate::Line2d::over(
785            ogeom_math::Axis2::new(Point2::ORIGIN, ogeom_math::Direction2::X),
786            -crate::curve::LINE_EXTENT,
787            crate::curve::LINE_EXTENT,
788        )
789        .unwrap();
790        assert!(Curve2d::point_at(&flat, crate::curve::LINE_EXTENT.next_up(), tol).is_ok());
791        assert!(Curve2d::point_at(&flat, crate::curve::LINE_EXTENT + 1e-3, tol).is_err());
792    }
793}