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}