Skip to main content

ogeom_geom/
surface.rs

1//! Concrete surfaces.
2//!
3//! The five analytic surfaces plus a NURBS patch, a surface of revolution, a
4//! surface of extrusion, and a trimmed restriction of any of them. All reachable
5//! through [`SurfaceGeometry`], an enum for the same reasons curves are one; see
6//! [`crate::curve`].
7//!
8//! # Keeping analytic surfaces analytic
9//!
10//! A cylinder could be written as a NURBS patch, and some kernels do exactly
11//! that. Keeping it a cylinder is worth the extra types: intersection can take a
12//! closed-form path, measurement can report a radius rather than a fit, files
13//! stay small, and a fillet knows it is filleting a cylinder. The cost is that
14//! every algorithm must handle a handful of cases, which the `kind` method
15//! makes an explicit choice rather than a hidden one.
16
17use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
18use ogeom_math::{
19    Axis, Cone, ControlGrid, Cylinder, Direction, KnotVector, Plane, Point, Sphere, Torus,
20    Transform, Vector, Weighted, bspline, elementary,
21};
22
23use crate::curve::{BSplineCurve, Curve};
24use crate::traits::{Continuity, Curve3d, Surface, SurfaceKind, Transformable};
25
26/// How far an unbounded surface's default domain reaches.
27///
28/// A plane and a cylinder are unbounded, but every interface here works on a
29/// finite rectangle. Far past any real model, and well short of where `f64`
30/// spacing turns coarse.
31pub const SURFACE_EXTENT: f64 = 1.0e9;
32
33const TAU: f64 = core::f64::consts::TAU;
34
35/// A surface.
36#[derive(Debug, Clone, PartialEq)]
37pub enum SurfaceGeometry {
38    /// A plane.
39    Plane(PlaneSurface),
40    /// A circular cylinder.
41    Cylinder(CylinderSurface),
42    /// A circular cone.
43    Cone(ConeSurface),
44    /// A sphere.
45    Sphere(SphereSurface),
46    /// A torus.
47    Torus(TorusSurface),
48    /// A polynomial or rational B-spline patch.
49    BSpline(BSplineSurface),
50    /// A curve revolved about an axis.
51    Revolution(Box<RevolutionSurface>),
52    /// A curve swept along a direction.
53    Extrusion(Box<ExtrusionSurface>),
54    /// Another surface restricted to a sub-rectangle.
55    Trimmed(Box<TrimmedSurface>),
56    /// A surface at a constant signed distance along another's normal.
57    Offset(Box<OffsetSurface>),
58}
59
60/// A plane, parameterized by its frame's `x` and `y` axes.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct PlaneSurface {
63    plane: Plane,
64    domain: ((f64, f64), (f64, f64)),
65}
66
67/// A cylinder, parameterized by `(angle, height)`.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct CylinderSurface {
70    cylinder: Cylinder,
71    height: (f64, f64),
72}
73
74/// A cone, parameterized by `(angle, height)`.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct ConeSurface {
77    cone: Cone,
78    height: (f64, f64),
79}
80
81/// A sphere, parameterized by `(longitude, latitude)`.
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub struct SphereSurface {
84    sphere: Sphere,
85}
86
87/// A torus, parameterized by `(angle about the axis, angle around the tube)`.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct TorusSurface {
90    torus: Torus,
91}
92
93/// A tensor-product B-spline patch, polynomial or rational.
94#[derive(Debug, Clone, PartialEq)]
95pub struct BSplineSurface {
96    u_knots: KnotVector,
97    v_knots: KnotVector,
98    grid: ControlGrid<Weighted<Point>>,
99    rational: bool,
100    /// Whether the net's first and last columns, and rows, coincide:
101    /// settled once here, because a parameter past the end of a closed
102    /// direction is wrapped rather than refused, and asking the net at
103    /// every such evaluation walked a control column millions of times
104    /// over one face.
105    closed: (bool, bool),
106}
107
108/// A curve revolved about an axis.
109///
110/// `u` is the angle of revolution; `v` is the generating curve's own parameter.
111#[derive(Debug, Clone, PartialEq)]
112pub struct RevolutionSurface {
113    curve: Curve,
114    axis: Axis,
115    angle: (f64, f64),
116}
117
118/// A curve swept along a direction.
119///
120/// `u` is the generating curve's parameter; `v` is the distance swept.
121#[derive(Debug, Clone, PartialEq)]
122pub struct ExtrusionSurface {
123    curve: Curve,
124    direction: Direction,
125    extent: (f64, f64),
126}
127
128/// Another surface restricted to a sub-rectangle of its domain.
129#[derive(Debug, Clone, PartialEq)]
130pub struct TrimmedSurface {
131    basis: SurfaceGeometry,
132    domain: ((f64, f64), (f64, f64)),
133}
134
135/// A surface displaced a constant signed distance along its basis's normal,
136/// sharing the basis's parameterization.
137///
138/// Point and first derivatives are exact; the normal's derivative is the
139/// projection formula over the basis's second derivatives. The *second*
140/// derivative would need the basis's third, which the vocabulary does not
141/// carry, so `d2_at` refuses by name rather than differencing quietly. For
142/// an analytic basis the offset is itself analytic and the direct type is
143/// the better spelling; this type exists for the free-form bases that have
144/// no such spelling.
145#[derive(Debug, Clone, PartialEq)]
146pub struct OffsetSurface {
147    basis: SurfaceGeometry,
148    distance: f64,
149}
150
151/// Reject an empty or non-finite parameter range.
152fn check_range(name: &str, lo: f64, hi: f64) -> OgeomResult<()> {
153    if !lo.is_finite() || !hi.is_finite() || hi <= lo {
154        ogeom_bail!(
155            Construction,
156            "{name} range [{lo}, {hi}] is empty or non-finite"
157        );
158    }
159    Ok(())
160}
161
162impl PlaneSurface {
163    /// A plane spanning [`SURFACE_EXTENT`] in both directions.
164    #[must_use]
165    pub const fn new(plane: Plane) -> Self {
166        Self {
167            plane,
168            domain: (
169                (-SURFACE_EXTENT, SURFACE_EXTENT),
170                (-SURFACE_EXTENT, SURFACE_EXTENT),
171            ),
172        }
173    }
174
175    /// A plane over an explicit rectangle of its own coordinates.
176    ///
177    /// # Errors
178    ///
179    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either
180    /// range is empty.
181    pub fn over(plane: Plane, u: (f64, f64), v: (f64, f64)) -> OgeomResult<Self> {
182        check_range("u", u.0, u.1)?;
183        check_range("v", v.0, v.1)?;
184        Ok(Self {
185            plane,
186            domain: (u, v),
187        })
188    }
189
190    /// The underlying plane.
191    #[must_use]
192    pub const fn plane(&self) -> Plane {
193        self.plane
194    }
195}
196
197impl CylinderSurface {
198    /// A cylinder over a height range.
199    ///
200    /// # Errors
201    ///
202    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the range
203    /// is empty.
204    pub fn new(cylinder: Cylinder, height: (f64, f64)) -> OgeomResult<Self> {
205        check_range("height", height.0, height.1)?;
206        Ok(Self { cylinder, height })
207    }
208
209    /// The underlying cylinder.
210    #[must_use]
211    pub const fn cylinder(&self) -> Cylinder {
212        self.cylinder
213    }
214}
215
216impl ConeSurface {
217    /// A cone over a height range.
218    ///
219    /// # Errors
220    ///
221    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the range
222    /// is empty.
223    pub fn new(cone: Cone, height: (f64, f64)) -> OgeomResult<Self> {
224        check_range("height", height.0, height.1)?;
225        Ok(Self { cone, height })
226    }
227
228    /// The underlying cone.
229    #[must_use]
230    pub const fn cone(&self) -> Cone {
231        self.cone
232    }
233
234    /// The height at which the radius vanishes: the apex.
235    #[must_use]
236    pub fn apex_height(&self) -> f64 {
237        -self.cone.reference_radius() / self.cone.half_angle().tan()
238    }
239}
240
241impl SphereSurface {
242    /// A whole sphere.
243    #[must_use]
244    pub const fn new(sphere: Sphere) -> Self {
245        Self { sphere }
246    }
247
248    /// The underlying sphere.
249    #[must_use]
250    pub const fn sphere(&self) -> Sphere {
251        self.sphere
252    }
253}
254
255impl TorusSurface {
256    /// A whole torus.
257    #[must_use]
258    pub const fn new(torus: Torus) -> Self {
259        Self { torus }
260    }
261
262    /// The underlying torus.
263    #[must_use]
264    pub const fn torus(&self) -> Torus {
265        self.torus
266    }
267}
268
269impl BSplineSurface {
270    /// A polynomial patch.
271    ///
272    /// # Errors
273    ///
274    /// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) on a shape mismatch.
275    pub fn new(
276        u_knots: KnotVector,
277        v_knots: KnotVector,
278        grid: &ControlGrid<Point>,
279        tol: Tolerances,
280    ) -> OgeomResult<Self> {
281        let weighted = ControlGrid::new(
282            grid.points()
283                .iter()
284                .map(|p| Weighted::new(*p, 1.0, tol))
285                .collect::<OgeomResult<Vec<_>>>()?,
286            grid.u_count(),
287            grid.v_count(),
288        )?;
289        Self::rational(u_knots, v_knots, weighted)
290    }
291
292    /// A rational patch.
293    ///
294    /// # Errors
295    ///
296    /// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) on a shape mismatch.
297    pub fn rational(
298        u_knots: KnotVector,
299        v_knots: KnotVector,
300        grid: ControlGrid<Weighted<Point>>,
301    ) -> OgeomResult<Self> {
302        if grid.u_count() != u_knots.control_point_count()
303            || grid.v_count() != v_knots.control_point_count()
304        {
305            ogeom_bail!(
306                Dimension,
307                "knot vectors describe a {}x{} grid, got {}x{}",
308                u_knots.control_point_count(),
309                v_knots.control_point_count(),
310                grid.u_count(),
311                grid.v_count()
312            );
313        }
314        let first = grid.points()[0].weight;
315        let rational = grid
316            .points()
317            .iter()
318            .any(|w| (w.weight - first).abs() > 1e-12 * first.abs());
319        let closed = (
320            Self::net_closed_u(&grid, Tolerances::millimetres()),
321            Self::net_closed_v(&grid, Tolerances::millimetres()),
322        );
323        Ok(Self {
324            u_knots,
325            v_knots,
326            grid,
327            rational,
328            closed,
329        })
330    }
331
332    /// Whether the net's first and last columns coincide, within `tol`.
333    fn net_closed_u(grid: &ControlGrid<Weighted<Point>>, tol: Tolerances) -> bool {
334        let last = grid.u_count() - 1;
335        (0..grid.v_count()).all(|j| match (grid.get(0, j), grid.get(last, j)) {
336            (Some(a), Some(b)) => a.point().is_equal(b.point(), tol),
337            _ => false,
338        })
339    }
340
341    /// Whether the net's first and last rows coincide, within `tol`.
342    fn net_closed_v(grid: &ControlGrid<Weighted<Point>>, tol: Tolerances) -> bool {
343        let last = grid.v_count() - 1;
344        (0..grid.u_count()).all(|i| match (grid.get(i, 0), grid.get(i, last)) {
345            (Some(a), Some(b)) => a.point().is_equal(b.point(), tol),
346            _ => false,
347        })
348    }
349
350    /// This patch continued past one of its four sides by about `length`
351    /// in space: every column of the control net continued along `u` (or
352    /// every row along `v`), as [`BSplineCurve::extended`] continues a
353    /// curve, over one shared span so the net stays a grid. The span is the
354    /// length over the mean speed along that side, so the continuation
355    /// reaches the length where the side runs at its mean speed and less
356    /// where the surface stretches faster. The original patch keeps its
357    /// parameters; the domain grows at the side continued.
358    ///
359    /// # Errors
360    ///
361    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
362    /// side stands still, or the length is not positive; as
363    /// [`ogeom_math::bspline::extend`].
364    pub fn extended(
365        &self,
366        along_u: bool,
367        at_end: bool,
368        length: f64,
369        continuity: usize,
370        tol: Tolerances,
371    ) -> OgeomResult<Self> {
372        if !along_u {
373            let turned = Self::rational(
374                self.v_knots.clone(),
375                self.u_knots.clone(),
376                self.grid.transposed(),
377            )?;
378            let longer = turned.extended(true, at_end, length, continuity, tol)?;
379            return Self::rational(
380                longer.v_knots.clone(),
381                longer.u_knots.clone(),
382                longer.grid.transposed(),
383            );
384        }
385        if !(length > 0.0 && length.is_finite()) {
386            ogeom_bail!(
387                Construction,
388                "an extension needs a positive length; got {length}"
389            );
390        }
391        use crate::traits::Surface as _;
392        let ((ua, ub), (va, vb)) = self.domain();
393        let u = if at_end { ub } else { ua };
394        let mut speed = 0.0;
395        const STATIONS: usize = 9;
396        for k in 0..STATIONS {
397            #[allow(clippy::cast_precision_loss)]
398            let v = va + (vb - va) * (k as f64 + 0.5) / STATIONS as f64;
399            speed += self.d1_at(u, v, tol)?.0.magnitude();
400        }
401        #[allow(clippy::cast_precision_loss)]
402        let speed = speed / STATIONS as f64;
403        if speed <= tol.confusion() {
404            ogeom_bail!(
405                Construction,
406                "the surface stands still along that side; there is no \
407                 direction to continue in"
408            );
409        }
410        let span = length / speed;
411        let (nu, nv) = (self.grid.u_count(), self.grid.v_count());
412        let mut columns: Vec<Vec<Weighted<Point>>> = Vec::with_capacity(nv);
413        let mut knots = None;
414        for j in 0..nv {
415            let column: Vec<Weighted<Point>> = (0..nu)
416                .map(|i| self.grid.get(i, j).unwrap_or_else(|| self.grid.points()[0]))
417                .collect();
418            let (longer_knots, longer) =
419                ogeom_math::bspline::extend(&self.u_knots, &column, at_end, span, continuity, tol)?;
420            knots.get_or_insert(longer_knots);
421            columns.push(longer);
422        }
423        let Some(u_knots) = knots else {
424            ogeom_bail!(Construction, "a patch with no columns cannot be continued");
425        };
426        let longer_nu = columns[0].len();
427        let mut points = Vec::with_capacity(longer_nu * nv);
428        for i in 0..longer_nu {
429            for column in &columns {
430                points.push(column[i]);
431            }
432        }
433        Self::rational(
434            u_knots,
435            self.v_knots.clone(),
436            ControlGrid::new(points, longer_nu, nv)?,
437        )
438    }
439
440    /// The `u = at` iso-curve: a B-spline over the `v` knots whose controls
441    /// are the control columns blended by the `u` basis at `at`, weights
442    /// and all: exactly the curve the surface traces up that column.
443    ///
444    /// # Errors
445    ///
446    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `at` is
447    /// outside the `u` domain.
448    pub fn iso_u_curve(&self, at: f64, tol: Tolerances) -> OgeomResult<BSplineCurve> {
449        use ogeom_math::Blend as _;
450        let span = self.u_knots.span(at, tol)?;
451        let basis = self.u_knots.basis(span, at);
452        let p = self.u_knots.degree();
453        let (k, l) = (self.grid.u_count(), self.grid.v_count());
454        let mut control: Vec<Weighted<Point>> = Vec::with_capacity(l);
455        for j in 0..l {
456            let mut acc = Weighted::<Point>::zero();
457            for (b, i) in basis.iter().zip(span - p..=span) {
458                if let Some(w) = self.grid.get(i.min(k - 1), j) {
459                    acc = acc.add(w.scale(*b));
460                }
461            }
462            control.push(acc);
463        }
464        BSplineCurve::rational(self.v_knots.clone(), control)
465    }
466
467    /// The `v = at` iso-curve, as [`BSplineSurface::iso_u_curve`] the other
468    /// way round.
469    ///
470    /// # Errors
471    ///
472    /// As [`BSplineSurface::iso_u_curve`].
473    pub fn iso_v_curve(&self, at: f64, tol: Tolerances) -> OgeomResult<BSplineCurve> {
474        use ogeom_math::Blend as _;
475        let span = self.v_knots.span(at, tol)?;
476        let basis = self.v_knots.basis(span, at);
477        let q = self.v_knots.degree();
478        let (k, l) = (self.grid.u_count(), self.grid.v_count());
479        let mut control: Vec<Weighted<Point>> = Vec::with_capacity(k);
480        for i in 0..k {
481            let mut acc = Weighted::<Point>::zero();
482            for (b, j) in basis.iter().zip(span - q..=span) {
483                if let Some(w) = self.grid.get(i, j.min(l - 1)) {
484                    acc = acc.add(w.scale(*b));
485                }
486            }
487            control.push(acc);
488        }
489        BSplineCurve::rational(self.u_knots.clone(), control)
490    }
491
492    /// The patch of this surface over `u` by `v`, exactly and keeping its
493    /// parameters: the patch at `(u, v)` is this surface at `(u, v)`.
494    ///
495    /// # Errors
496    ///
497    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if either range
498    /// is empty or leaves the domain.
499    pub fn segment(&self, u: (f64, f64), v: (f64, f64), tol: Tolerances) -> OgeomResult<Self> {
500        let (nu, nv) = (self.grid.u_count(), self.grid.v_count());
501        let points = self.grid.points();
502        // Each row cut along `v`, then each column of the result along `u`.
503        let v_piece = |row: Vec<Weighted<Point>>| -> OgeomResult<BSplineCurve> {
504            BSplineCurve::rational(self.v_knots.clone(), row)?.segment(v, tol)
505        };
506        let mut rows = Vec::with_capacity(nu);
507        let mut v_knots = self.v_knots.clone();
508        for i in 0..nu {
509            let piece = v_piece(points[i * nv..(i + 1) * nv].to_vec())?;
510            v_knots = piece.knots().clone();
511            rows.push(piece.control_points().to_vec());
512        }
513        let mv = v_knots.control_point_count();
514        let mut columns = Vec::with_capacity(mv);
515        let mut u_knots = self.u_knots.clone();
516        for j in 0..mv {
517            let column: Vec<Weighted<Point>> = rows.iter().map(|r| r[j]).collect();
518            let piece = BSplineCurve::rational(self.u_knots.clone(), column)?.segment(u, tol)?;
519            u_knots = piece.knots().clone();
520            columns.push(piece.control_points().to_vec());
521        }
522        let mu = u_knots.control_point_count();
523        let net: Vec<Weighted<Point>> = (0..mu)
524            .flat_map(|i| columns.iter().map(move |c| c[i]))
525            .collect();
526        Self::rational(u_knots, v_knots, ControlGrid::new(net, mu, mv)?)
527    }
528
529    /// The `u` knot vector.
530    #[must_use]
531    pub const fn u_knots(&self) -> &KnotVector {
532        &self.u_knots
533    }
534
535    /// The `v` knot vector.
536    #[must_use]
537    pub const fn v_knots(&self) -> &KnotVector {
538        &self.v_knots
539    }
540
541    /// The control grid.
542    #[must_use]
543    pub const fn grid(&self) -> &ControlGrid<Weighted<Point>> {
544        &self.grid
545    }
546
547    /// Whether the weights differ, so the patch is genuinely rational.
548    #[must_use]
549    pub const fn is_rational(&self) -> bool {
550        self.rational
551    }
552}
553
554impl RevolutionSurface {
555    /// Revolve `curve` about `axis` through `angle` radians.
556    ///
557    /// # Errors
558    ///
559    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the angle
560    /// is not finite and positive, or exceeds a full turn.
561    pub fn new(curve: Curve, axis: Axis, angle: f64) -> OgeomResult<Self> {
562        if !angle.is_finite() || angle <= 0.0 || angle > TAU + 1e-12 {
563            ogeom_bail!(
564                Construction,
565                "revolution angle {angle} must be in (0, 2*pi]"
566            );
567        }
568        Ok(Self {
569            curve,
570            axis,
571            angle: (0.0, angle.min(TAU)),
572        })
573    }
574
575    /// The generating curve.
576    #[must_use]
577    pub const fn curve(&self) -> &Curve {
578        &self.curve
579    }
580
581    /// The axis of revolution.
582    #[must_use]
583    pub const fn axis(&self) -> Axis {
584        self.axis
585    }
586}
587
588impl ExtrusionSurface {
589    /// Sweep `curve` along `direction` over `[0, distance]`.
590    ///
591    /// # Errors
592    ///
593    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `distance`
594    /// is not finite and positive.
595    pub fn new(curve: Curve, direction: Direction, distance: f64) -> OgeomResult<Self> {
596        if !distance.is_finite() || distance <= 0.0 {
597            ogeom_bail!(
598                Construction,
599                "extrusion distance {distance} must be positive"
600            );
601        }
602        Ok(Self {
603            curve,
604            direction,
605            extent: (0.0, distance),
606        })
607    }
608
609    /// Sweep `curve` along `direction` over an explicit window of the
610    /// sweep parameter: a file's extrusion, unbounded either way, is
611    /// given the window its faces reach.
612    ///
613    /// # Errors
614    ///
615    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
616    /// the window is not finite and increasing.
617    pub fn over(curve: Curve, direction: Direction, extent: (f64, f64)) -> OgeomResult<Self> {
618        if !extent.0.is_finite() || !extent.1.is_finite() || extent.1 <= extent.0 {
619            ogeom_bail!(
620                Construction,
621                "extrusion window {extent:?} must be finite and increasing"
622            );
623        }
624        Ok(Self {
625            curve,
626            direction,
627            extent,
628        })
629    }
630
631    /// The generating curve.
632    #[must_use]
633    pub const fn curve(&self) -> &Curve {
634        &self.curve
635    }
636
637    /// The sweep direction.
638    #[must_use]
639    pub const fn direction(&self) -> Direction {
640        self.direction
641    }
642}
643
644impl TrimmedSurface {
645    /// Restrict `basis` to a sub-rectangle.
646    ///
647    /// # Errors
648    ///
649    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if either range is empty
650    /// or leaves the basis surface's domain in a non-periodic direction.
651    pub fn new(
652        basis: SurfaceGeometry,
653        u: (f64, f64),
654        v: (f64, f64),
655        tol: Tolerances,
656    ) -> OgeomResult<Self> {
657        check_range("u", u.0, u.1)?;
658        check_range("v", v.0, v.1)?;
659        let ((ua, ub), (va, vb)) = basis.domain();
660        let eps = tol.parametric();
661        if !basis.is_periodic_u() && (u.0 < ua - eps || u.1 > ub + eps) {
662            ogeom_bail!(Domain, "u range [{}, {}] leaves [{ua}, {ub}]", u.0, u.1);
663        }
664        if !basis.is_periodic_v() && (v.0 < va - eps || v.1 > vb + eps) {
665            ogeom_bail!(Domain, "v range [{}, {}] leaves [{va}, {vb}]", v.0, v.1);
666        }
667        Ok(Self {
668            basis,
669            domain: (u, v),
670        })
671    }
672
673    /// The surface being trimmed.
674    #[must_use]
675    pub const fn basis(&self) -> &SurfaceGeometry {
676        &self.basis
677    }
678}
679
680impl OffsetSurface {
681    /// Offset `basis` by a signed `distance` along its own normal.
682    ///
683    /// # Errors
684    ///
685    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
686    /// distance is not finite and non-zero.
687    pub fn new(basis: SurfaceGeometry, distance: f64) -> OgeomResult<Self> {
688        if !distance.is_finite() || distance == 0.0 {
689            ogeom_bail!(
690                Construction,
691                "an offset of {distance} is not a displacement"
692            );
693        }
694        Ok(Self { basis, distance })
695    }
696
697    /// The surface being offset.
698    #[must_use]
699    pub const fn basis(&self) -> &SurfaceGeometry {
700        &self.basis
701    }
702
703    /// The signed displacement along the basis normal.
704    #[must_use]
705    pub const fn distance(&self) -> f64 {
706        self.distance
707    }
708
709    /// The offset as the analytic surface it is, where the basis is one: a
710    /// plane moved along its normal, a drum, ball or ring's tube grown or
711    /// shrunk, a cone's reference radius changed by `d / cos α` over the
712    /// same half-angle. Which way the offset grows is read off the basis's
713    /// own normal at a point, not assumed from its parameterization. `None`
714    /// for any other basis, or for an offset that would turn the surface
715    /// inside out (a radius through zero).
716    ///
717    /// # Errors
718    ///
719    /// As the basis's evaluation, or as the analytic constructors.
720    pub fn analytic(&self, tol: Tolerances) -> OgeomResult<Option<SurfaceGeometry>> {
721        let ((u0, u1), (v0, v1)) = self.basis.domain();
722        let clamp = |lo: f64, hi: f64| {
723            if lo.is_finite() && hi.is_finite() {
724                f64::midpoint(lo, hi)
725            } else {
726                0.0
727            }
728        };
729        let (u, v) = (clamp(u0, u1), clamp(v0, v1));
730        let at = self.basis.point_at(u, v, tol)?;
731        let normal = self.basis.normal_at(u, v, tol)?.vector();
732        let d = self.distance;
733        // Whether the basis normal points away from the axis or centre it
734        // is measured from: the offset then grows the radius.
735        let outward = |from_axis: Vector| normal.dot(from_axis) > 0.0;
736        Ok(match &self.basis {
737            SurfaceGeometry::Plane(p) => {
738                let plane = p.plane();
739                let frame = plane.frame();
740                let moved =
741                    ogeom_math::Frame::new(frame.origin() + normal * d, frame.z(), frame.x(), tol)?;
742                Some(PlaneSurface::over(ogeom_math::Plane::new(moved), (u0, u1), (v0, v1))?.into())
743            }
744            SurfaceGeometry::Cylinder(c) => {
745                let cylinder = c.cylinder();
746                let frame = cylinder.frame();
747                let radial = at - frame.origin();
748                let radial = radial - frame.z().vector() * radial.dot(frame.z().vector());
749                let radius = cylinder.radius() + if outward(radial) { d } else { -d };
750                if radius <= tol.confusion() {
751                    return Ok(None);
752                }
753                Some(
754                    CylinderSurface::new(ogeom_math::Cylinder::new(frame, radius, tol)?, (v0, v1))?
755                        .into(),
756                )
757            }
758            SurfaceGeometry::Cone(c) => {
759                let cone = c.cone();
760                let frame = cone.frame();
761                let radial = at - frame.origin();
762                let radial = radial - frame.z().vector() * radial.dot(frame.z().vector());
763                let grow = if outward(radial) { d } else { -d };
764                let radius = cone.reference_radius() + grow / cone.half_angle().cos();
765                if radius <= tol.confusion() {
766                    return Ok(None);
767                }
768                Some(
769                    ConeSurface::new(
770                        ogeom_math::Cone::new(frame, radius, cone.half_angle(), tol)?,
771                        (v0, v1),
772                    )?
773                    .into(),
774                )
775            }
776            SurfaceGeometry::Sphere(s) => {
777                let sphere = s.sphere();
778                let grow = if outward(at - sphere.frame().origin()) {
779                    d
780                } else {
781                    -d
782                };
783                let radius = sphere.radius() + grow;
784                if radius <= tol.confusion() {
785                    return Ok(None);
786                }
787                Some(
788                    SphereSurface::new(ogeom_math::Sphere::new(sphere.frame(), radius, tol)?)
789                        .into(),
790                )
791            }
792            SurfaceGeometry::Torus(t) => {
793                let torus = t.torus();
794                let frame = torus.frame();
795                // From the tube's own centre circle.
796                let flat = at - frame.origin();
797                let flat = flat - frame.z().vector() * flat.dot(frame.z().vector());
798                let ring = frame.origin() + flat * (torus.major_radius() / flat.magnitude());
799                let grow = if outward(at - ring) { d } else { -d };
800                let minor = torus.minor_radius() + grow;
801                if minor <= tol.confusion() {
802                    return Ok(None);
803                }
804                Some(
805                    TorusSurface::new(ogeom_math::Torus::new(
806                        frame,
807                        torus.major_radius(),
808                        minor,
809                        tol,
810                    )?)
811                    .into(),
812                )
813            }
814            _ => None,
815        })
816    }
817}
818
819impl Surface for OffsetSurface {
820    fn domain(&self) -> ((f64, f64), (f64, f64)) {
821        self.basis.domain()
822    }
823
824    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
825        let base = self.basis.point_at(u, v, tol)?;
826        let normal = self.basis.normal_at(u, v, tol)?;
827        Ok(base + normal.vector() * self.distance)
828    }
829
830    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
831        // Differentiate S + d·c/|c| with c = Su x Sv: the unit normal's
832        // derivative is the tangential projection of c's derivative, scaled
833        // by the magnitude, exact from the basis's first and second
834        // derivatives.
835        let (su, sv) = self.basis.d1_at(u, v, tol)?;
836        let (suu, suv, svv) = self.basis.d2_at(u, v, tol)?;
837        let c = su.cross(sv);
838        let m = c.magnitude();
839        let scale = su.magnitude().max(sv.magnitude());
840        if m <= tol.angular() * scale * scale {
841            ogeom_bail!(
842                Construction,
843                "the basis is degenerate at ({u}, {v}); the offset has no tangent plane there"
844            );
845        }
846        let n = c / m;
847        let cu = suu.cross(sv) + su.cross(suv);
848        let cv = suv.cross(sv) + su.cross(svv);
849        let nu = (cu - n * n.dot(cu)) / m;
850        let nv = (cv - n * n.dot(cv)) / m;
851        Ok((su + nu * self.distance, sv + nv * self.distance))
852    }
853
854    fn d2_at(&self, _u: f64, _v: f64, _tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
855        ogeom_bail!(
856            Construction,
857            "an offset surface's second derivative needs its basis's third, which the              vocabulary does not carry; offset the basis analytically or fit at a stated              tolerance instead"
858        )
859    }
860
861    fn kind(&self) -> SurfaceKind {
862        SurfaceKind::Offset
863    }
864
865    fn continuity(&self) -> Continuity {
866        // Offsetting spends one order of smoothness.
867        match self.basis.continuity() {
868            Continuity::CInfinity => Continuity::CInfinity,
869            Continuity::C2 | Continuity::G2 => Continuity::C1,
870            Continuity::C1 | Continuity::G1 | Continuity::C0 => Continuity::C0,
871        }
872    }
873
874    fn is_closed_u(&self, tol: Tolerances) -> bool {
875        self.basis.is_closed_u(tol)
876    }
877
878    fn is_closed_v(&self, tol: Tolerances) -> bool {
879        self.basis.is_closed_v(tol)
880    }
881
882    fn is_periodic_u(&self) -> bool {
883        self.basis.is_periodic_u()
884    }
885
886    fn is_periodic_v(&self) -> bool {
887        self.basis.is_periodic_v()
888    }
889}
890
891fn finite_parameters(u: f64, v: f64) -> OgeomResult<()> {
892    if u.is_finite() && v.is_finite() {
893        Ok(())
894    } else {
895        ogeom_bail!(Domain, "parameters ({u}, {v}) are not finite")
896    }
897}
898
899impl Surface for PlaneSurface {
900    fn domain(&self) -> ((f64, f64), (f64, f64)) {
901        self.domain
902    }
903
904    // A plane's window says where its face was built, not where the plane
905    // ends: it is evaluated anywhere, so a face merged across two windows
906    // (or trimmed a hair past one) is read where it lies.
907    fn point_at(&self, u: f64, v: f64, _tol: Tolerances) -> OgeomResult<Point> {
908        finite_parameters(u, v)?;
909        Ok(elementary::plane_at(&self.plane, u, v).point)
910    }
911
912    fn d1_at(&self, u: f64, v: f64, _tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
913        finite_parameters(u, v)?;
914        let f = self.plane.frame();
915        Ok((f.x().vector(), f.y().vector()))
916    }
917
918    fn d2_at(&self, u: f64, v: f64, _tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
919        finite_parameters(u, v)?;
920        Ok((Vector::ZERO, Vector::ZERO, Vector::ZERO))
921    }
922
923    fn kind(&self) -> SurfaceKind {
924        SurfaceKind::Plane
925    }
926
927    fn continuity(&self) -> Continuity {
928        Continuity::CInfinity
929    }
930
931    fn is_closed_u(&self, _tol: Tolerances) -> bool {
932        false
933    }
934
935    fn is_closed_v(&self, _tol: Tolerances) -> bool {
936        false
937    }
938
939    fn is_periodic_u(&self) -> bool {
940        false
941    }
942
943    fn is_periodic_v(&self) -> bool {
944        false
945    }
946}
947
948impl Surface for CylinderSurface {
949    fn domain(&self) -> ((f64, f64), (f64, f64)) {
950        ((0.0, TAU), self.height)
951    }
952
953    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
954        let (u, v) = self.normalize_parameters(u, v, tol)?;
955        Ok(elementary::cylinder_at(&self.cylinder, u, v).point)
956    }
957
958    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
959        let (u, v) = self.normalize_parameters(u, v, tol)?;
960        let p = elementary::cylinder_at(&self.cylinder, u, v);
961        Ok((p.du, p.dv))
962    }
963
964    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
965        let (u, _) = self.normalize_parameters(u, v, tol)?;
966        let f = self.cylinder.frame();
967        let r = self.cylinder.radius();
968        let (sin, cos) = u.sin_cos();
969        // Second derivative in u points back at the axis; the surface is ruled
970        // along v, so everything involving v vanishes.
971        Ok((
972            f.x() * (-r * cos) + f.y() * (-r * sin),
973            Vector::ZERO,
974            Vector::ZERO,
975        ))
976    }
977
978    fn kind(&self) -> SurfaceKind {
979        SurfaceKind::Cylinder
980    }
981
982    fn continuity(&self) -> Continuity {
983        Continuity::CInfinity
984    }
985
986    fn is_closed_u(&self, _tol: Tolerances) -> bool {
987        true
988    }
989
990    fn is_closed_v(&self, _tol: Tolerances) -> bool {
991        false
992    }
993
994    fn is_periodic_u(&self) -> bool {
995        true
996    }
997
998    fn is_periodic_v(&self) -> bool {
999        false
1000    }
1001}
1002
1003impl Surface for ConeSurface {
1004    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1005        ((0.0, TAU), self.height)
1006    }
1007
1008    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1009        let (u, v) = self.normalize_parameters(u, v, tol)?;
1010        Ok(elementary::cone_at(&self.cone, u, v).point)
1011    }
1012
1013    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1014        let (u, v) = self.normalize_parameters(u, v, tol)?;
1015        let p = elementary::cone_at(&self.cone, u, v);
1016        Ok((p.du, p.dv))
1017    }
1018
1019    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1020        let (u, v) = self.normalize_parameters(u, v, tol)?;
1021        let f = self.cone.frame();
1022        let r = self.cone.radius_at(v);
1023        let slope = self.cone.half_angle().tan();
1024        let (sin, cos) = u.sin_cos();
1025        Ok((
1026            // Radial, scaled by the radius at this height.
1027            f.x() * (-r * cos) + f.y() * (-r * sin),
1028            // The radius grows linearly along v, so the mixed partial is the
1029            // u-tangent's rate of growth.
1030            f.x() * (-slope * sin) + f.y() * (slope * cos),
1031            // Straight along the ruling.
1032            Vector::ZERO,
1033        ))
1034    }
1035
1036    fn kind(&self) -> SurfaceKind {
1037        SurfaceKind::Cone
1038    }
1039
1040    fn continuity(&self) -> Continuity {
1041        Continuity::CInfinity
1042    }
1043
1044    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1045        true
1046    }
1047
1048    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1049        false
1050    }
1051
1052    fn is_periodic_u(&self) -> bool {
1053        true
1054    }
1055
1056    fn is_periodic_v(&self) -> bool {
1057        false
1058    }
1059}
1060
1061impl Surface for SphereSurface {
1062    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1063        let half = core::f64::consts::FRAC_PI_2;
1064        ((0.0, TAU), (-half, half))
1065    }
1066
1067    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1068        let (u, v) = self.normalize_parameters(u, v, tol)?;
1069        Ok(elementary::sphere_at(&self.sphere, u, v).point)
1070    }
1071
1072    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1073        let (u, v) = self.normalize_parameters(u, v, tol)?;
1074        let p = elementary::sphere_at(&self.sphere, u, v);
1075        Ok((p.du, p.dv))
1076    }
1077
1078    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1079        let (u, v) = self.normalize_parameters(u, v, tol)?;
1080        let f = self.sphere.frame();
1081        let r = self.sphere.radius();
1082        let (sin_u, cos_u) = u.sin_cos();
1083        let (sin_v, cos_v) = v.sin_cos();
1084        let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
1085        let ring = r * cos_v;
1086        Ok((
1087            x * (-ring * cos_u) + y * (-ring * sin_u),
1088            x * (r * sin_v * sin_u) + y * (-r * sin_v * cos_u),
1089            x * (-ring * cos_u) + y * (-ring * sin_u) + z * (-r * sin_v),
1090        ))
1091    }
1092
1093    fn kind(&self) -> SurfaceKind {
1094        SurfaceKind::Sphere
1095    }
1096
1097    fn continuity(&self) -> Continuity {
1098        Continuity::CInfinity
1099    }
1100
1101    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1102        true
1103    }
1104
1105    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1106        false
1107    }
1108
1109    fn is_periodic_u(&self) -> bool {
1110        true
1111    }
1112
1113    fn is_periodic_v(&self) -> bool {
1114        // Latitude runs pole to pole and stops. Treating it as periodic would
1115        // let a parameter past the pole wrap round to the far side, which is a
1116        // different point.
1117        false
1118    }
1119}
1120
1121impl Surface for TorusSurface {
1122    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1123        ((0.0, TAU), (0.0, TAU))
1124    }
1125
1126    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1127        let (u, v) = self.normalize_parameters(u, v, tol)?;
1128        Ok(elementary::torus_at(&self.torus, u, v).point)
1129    }
1130
1131    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1132        let (u, v) = self.normalize_parameters(u, v, tol)?;
1133        let p = elementary::torus_at(&self.torus, u, v);
1134        Ok((p.du, p.dv))
1135    }
1136
1137    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1138        let (u, v) = self.normalize_parameters(u, v, tol)?;
1139        let f = self.torus.frame();
1140        let (major, minor) = (self.torus.major_radius(), self.torus.minor_radius());
1141        let (sin_u, cos_u) = u.sin_cos();
1142        let (sin_v, cos_v) = v.sin_cos();
1143        let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
1144        let out = x * cos_u + y * sin_u;
1145        let side = x * -sin_u + y * cos_u;
1146        let radius = minor.mul_add(cos_v, major);
1147        Ok((
1148            out * -radius,
1149            side * (-minor * sin_v),
1150            out * (-minor * cos_v) + z * (-minor * sin_v),
1151        ))
1152    }
1153
1154    fn kind(&self) -> SurfaceKind {
1155        SurfaceKind::Torus
1156    }
1157
1158    fn continuity(&self) -> Continuity {
1159        Continuity::CInfinity
1160    }
1161
1162    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1163        true
1164    }
1165
1166    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1167        true
1168    }
1169
1170    fn is_periodic_u(&self) -> bool {
1171        true
1172    }
1173
1174    fn is_periodic_v(&self) -> bool {
1175        true
1176    }
1177}
1178
1179impl Surface for BSplineSurface {
1180    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1181        (self.u_knots.domain(), self.v_knots.domain())
1182    }
1183
1184    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1185        let (u, v) = self.normalize_parameters(u, v, tol)?;
1186        if self.rational {
1187            bspline::evaluate_rational_surface(&self.u_knots, &self.v_knots, &self.grid, u, v, tol)
1188        } else {
1189            Ok(
1190                bspline::evaluate_surface(&self.u_knots, &self.v_knots, &self.grid, u, v, tol)?
1191                    .point(),
1192            )
1193        }
1194    }
1195
1196    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1197        let (u, v) = self.normalize_parameters(u, v, tol)?;
1198        let d = bspline::rational_surface_derivatives(
1199            &self.u_knots,
1200            &self.v_knots,
1201            &self.grid,
1202            u,
1203            v,
1204            1,
1205            tol,
1206        )?;
1207        Ok((d[1][0].to_vector(), d[0][1].to_vector()))
1208    }
1209
1210    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1211        let (u, v) = self.normalize_parameters(u, v, tol)?;
1212        let d = bspline::rational_surface_derivatives(
1213            &self.u_knots,
1214            &self.v_knots,
1215            &self.grid,
1216            u,
1217            v,
1218            2,
1219            tol,
1220        )?;
1221        Ok((
1222            d[2][0].to_vector(),
1223            d[1][1].to_vector(),
1224            d[0][2].to_vector(),
1225        ))
1226    }
1227
1228    fn jet_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<crate::SurfaceJet> {
1229        // One order-two table answers for all six. Through the separate
1230        // accessors this patch locates its spans, builds its basis functions
1231        // and sums its control grid three times for the same numbers.
1232        let (u, v) = self.normalize_parameters(u, v, tol)?;
1233        let d = if self.rational {
1234            bspline::rational_surface_derivatives(
1235                &self.u_knots,
1236                &self.v_knots,
1237                &self.grid,
1238                u,
1239                v,
1240                2,
1241                tol,
1242            )?
1243        } else {
1244            bspline::surface_derivatives(&self.u_knots, &self.v_knots, &self.grid, u, v, 2, tol)?
1245                .into_iter()
1246                .map(|row| row.into_iter().map(|w| w.scaled).collect())
1247                .collect()
1248        };
1249        Ok(crate::SurfaceJet {
1250            point: Point::ORIGIN + d[0][0].to_vector(),
1251            du: d[1][0].to_vector(),
1252            dv: d[0][1].to_vector(),
1253            d2u: d[2][0].to_vector(),
1254            duv: d[1][1].to_vector(),
1255            d2v: d[0][2].to_vector(),
1256        })
1257    }
1258
1259    fn kind(&self) -> SurfaceKind {
1260        SurfaceKind::BSpline
1261    }
1262
1263    fn continuity(&self) -> Continuity {
1264        // The worse of the two directions governs the patch.
1265        let worst = |k: &KnotVector| {
1266            let (a, b) = k.domain();
1267            k.distinct()
1268                .into_iter()
1269                .filter(|(x, _)| *x > a && *x < b)
1270                .map(|(_, m)| m)
1271                .max()
1272                .map_or(Continuity::CInfinity, |m| {
1273                    match k.degree().saturating_sub(m) {
1274                        0 => Continuity::C0,
1275                        1 => Continuity::C1,
1276                        _ => Continuity::C2,
1277                    }
1278                })
1279        };
1280        worst(&self.u_knots).min(worst(&self.v_knots))
1281    }
1282
1283    fn is_closed_u(&self, tol: Tolerances) -> bool {
1284        // Settled at construction; the walk is repeated only where the net
1285        // was not closed then and a coarser tolerance might say otherwise.
1286        self.closed.0 || Self::net_closed_u(&self.grid, tol)
1287    }
1288
1289    fn is_closed_v(&self, tol: Tolerances) -> bool {
1290        self.closed.1 || Self::net_closed_v(&self.grid, tol)
1291    }
1292
1293    fn is_periodic_u(&self) -> bool {
1294        false
1295    }
1296
1297    fn is_periodic_v(&self) -> bool {
1298        false
1299    }
1300}
1301
1302impl Surface for RevolutionSurface {
1303    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1304        (self.angle, self.curve.domain())
1305    }
1306
1307    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1308        let (u, v) = self.normalize_parameters(u, v, tol)?;
1309        let p = self.curve.point_at(v, tol)?;
1310        Ok(Transform::rotation(self.axis, u).apply(p))
1311    }
1312
1313    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1314        let (u, v) = self.normalize_parameters(u, v, tol)?;
1315        let rotate = Transform::rotation(self.axis, u);
1316        let p = rotate.apply(self.curve.point_at(v, tol)?);
1317        // Rotating about an axis moves a point along a circle centred on the
1318        // axis, so the u-tangent is the axis direction crossed with the radius.
1319        let radius = p - self.axis.project(p);
1320        Ok((
1321            self.axis.direction.cross_with(radius),
1322            rotate.apply_vector(self.curve.d1_at(v, tol)?),
1323        ))
1324    }
1325
1326    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1327        let (u, v) = self.normalize_parameters(u, v, tol)?;
1328        let rotate = Transform::rotation(self.axis, u);
1329        let p = rotate.apply(self.curve.point_at(v, tol)?);
1330        let radius = p - self.axis.project(p);
1331        let d = self.curve.derivatives_at(v, 2, tol)?;
1332        let curve_d1 = rotate.apply_vector(d[1]);
1333        // Circular motion: the second derivative in u points back at the axis.
1334        let d2u = -radius;
1335        // The mixed partial is the u-derivative of the rotated curve tangent,
1336        // which is the same circular relation applied to that tangent.
1337        let duv = self.axis.direction.cross_with(curve_d1);
1338        Ok((d2u, duv, rotate.apply_vector(d[2])))
1339    }
1340
1341    fn kind(&self) -> SurfaceKind {
1342        SurfaceKind::Revolution
1343    }
1344
1345    fn continuity(&self) -> Continuity {
1346        // Rotation is smooth, so the generating curve governs.
1347        self.curve.continuity()
1348    }
1349
1350    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1351        (self.angle.1 - self.angle.0 - TAU).abs() <= 1e-12
1352    }
1353
1354    fn is_closed_v(&self, tol: Tolerances) -> bool {
1355        self.curve.is_closed(tol)
1356    }
1357
1358    fn is_periodic_u(&self) -> bool {
1359        (self.angle.1 - self.angle.0 - TAU).abs() <= 1e-12
1360    }
1361
1362    fn is_periodic_v(&self) -> bool {
1363        self.curve.is_periodic()
1364    }
1365}
1366
1367impl Surface for ExtrusionSurface {
1368    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1369        (self.curve.domain(), self.extent)
1370    }
1371
1372    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1373        let (u, v) = self.normalize_parameters(u, v, tol)?;
1374        Ok(self.curve.point_at(u, tol)? + self.direction * v)
1375    }
1376
1377    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1378        let (u, _) = self.normalize_parameters(u, v, tol)?;
1379        Ok((self.curve.d1_at(u, tol)?, self.direction.vector()))
1380    }
1381
1382    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1383        let (u, _) = self.normalize_parameters(u, v, tol)?;
1384        // Ruled along v, so only the curve's own curvature survives.
1385        Ok((
1386            self.curve.derivatives_at(u, 2, tol)?[2],
1387            Vector::ZERO,
1388            Vector::ZERO,
1389        ))
1390    }
1391
1392    fn kind(&self) -> SurfaceKind {
1393        SurfaceKind::Extrusion
1394    }
1395
1396    fn continuity(&self) -> Continuity {
1397        self.curve.continuity()
1398    }
1399
1400    fn is_closed_u(&self, tol: Tolerances) -> bool {
1401        self.curve.is_closed(tol)
1402    }
1403
1404    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1405        false
1406    }
1407
1408    fn is_periodic_u(&self) -> bool {
1409        self.curve.is_periodic()
1410    }
1411
1412    fn is_periodic_v(&self) -> bool {
1413        false
1414    }
1415}
1416
1417impl Surface for TrimmedSurface {
1418    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1419        self.domain
1420    }
1421
1422    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1423        let (u, v) = self.normalize_parameters(u, v, tol)?;
1424        self.basis.point_at(u, v, tol)
1425    }
1426
1427    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1428        let (u, v) = self.normalize_parameters(u, v, tol)?;
1429        self.basis.d1_at(u, v, tol)
1430    }
1431
1432    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1433        let (u, v) = self.normalize_parameters(u, v, tol)?;
1434        self.basis.d2_at(u, v, tol)
1435    }
1436
1437    fn kind(&self) -> SurfaceKind {
1438        SurfaceKind::Trimmed
1439    }
1440
1441    fn continuity(&self) -> Continuity {
1442        self.basis.continuity()
1443    }
1444
1445    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1446        // A trim narrower than the basis cannot close, whatever the basis does.
1447        false
1448    }
1449
1450    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1451        false
1452    }
1453
1454    fn is_periodic_u(&self) -> bool {
1455        false
1456    }
1457
1458    fn is_periodic_v(&self) -> bool {
1459        false
1460    }
1461}
1462
1463/// Dispatch a method across every surface variant.
1464macro_rules! dispatch {
1465    ($self:ident, $s:ident => $body:expr) => {
1466        match $self {
1467            Self::Plane($s) => $body,
1468            Self::Cylinder($s) => $body,
1469            Self::Cone($s) => $body,
1470            Self::Sphere($s) => $body,
1471            Self::Torus($s) => $body,
1472            Self::BSpline($s) => $body,
1473            Self::Revolution($s) => $body,
1474            Self::Extrusion($s) => $body,
1475            Self::Trimmed($s) => $body,
1476            Self::Offset($s) => $body,
1477        }
1478    };
1479}
1480
1481impl Surface for SurfaceGeometry {
1482    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1483        dispatch!(self, s => s.domain())
1484    }
1485
1486    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1487        dispatch!(self, s => s.point_at(u, v, tol))
1488    }
1489
1490    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1491        dispatch!(self, s => s.d1_at(u, v, tol))
1492    }
1493
1494    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1495        dispatch!(self, s => s.d2_at(u, v, tol))
1496    }
1497
1498    // Dispatched like the rest. Without it the enum answers with the trait's
1499    // default, which asks the three accessors, so the variant that overrides
1500    // `jet_at` to avoid exactly that would never be reached through a
1501    // `SurfaceGeometry`, which is how every caller holds a surface.
1502    fn jet_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<crate::SurfaceJet> {
1503        dispatch!(self, s => s.jet_at(u, v, tol))
1504    }
1505
1506    fn kind(&self) -> SurfaceKind {
1507        dispatch!(self, s => s.kind())
1508    }
1509
1510    fn continuity(&self) -> Continuity {
1511        dispatch!(self, s => s.continuity())
1512    }
1513
1514    fn is_closed_u(&self, tol: Tolerances) -> bool {
1515        dispatch!(self, s => s.is_closed_u(tol))
1516    }
1517
1518    fn is_closed_v(&self, tol: Tolerances) -> bool {
1519        dispatch!(self, s => s.is_closed_v(tol))
1520    }
1521
1522    fn is_periodic_u(&self) -> bool {
1523        dispatch!(self, s => s.is_periodic_u())
1524    }
1525
1526    fn is_periodic_v(&self) -> bool {
1527        dispatch!(self, s => s.is_periodic_v())
1528    }
1529}
1530
1531impl Transformable for SurfaceGeometry {
1532    fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
1533        let scale = t.scale_factor().abs();
1534        Ok(match self {
1535            Self::Plane(s) => Self::Plane(PlaneSurface {
1536                plane: s.plane.transformed(t, tol)?,
1537                // The parameters are lengths along the frame axes, so a scaling
1538                // rescales the extent with them.
1539                domain: (
1540                    (s.domain.0.0 * scale, s.domain.0.1 * scale),
1541                    (s.domain.1.0 * scale, s.domain.1.1 * scale),
1542                ),
1543            }),
1544            Self::Cylinder(s) => Self::Cylinder(CylinderSurface {
1545                cylinder: s.cylinder.transformed(t, tol)?,
1546                height: (s.height.0 * scale, s.height.1 * scale),
1547            }),
1548            Self::Offset(s) => Self::Offset(Box::new(OffsetSurface {
1549                basis: s.basis.transformed(t, tol)?,
1550                distance: s.distance * scale,
1551            })),
1552            Self::Cone(s) => Self::Cone(ConeSurface {
1553                cone: s.cone.transformed(t, tol)?,
1554                height: (s.height.0 * scale, s.height.1 * scale),
1555            }),
1556            Self::Sphere(s) => Self::Sphere(SphereSurface {
1557                sphere: s.sphere.transformed(t, tol)?,
1558            }),
1559            Self::Torus(s) => Self::Torus(TorusSurface {
1560                torus: s.torus.transformed(t, tol)?,
1561            }),
1562            Self::BSpline(s) => {
1563                let grid = ControlGrid::new(
1564                    s.grid
1565                        .points()
1566                        .iter()
1567                        .map(|w| Weighted::new(t.apply(w.point()), w.weight, tol))
1568                        .collect::<OgeomResult<Vec<_>>>()?,
1569                    s.grid.u_count(),
1570                    s.grid.v_count(),
1571                )?;
1572                Self::BSpline(BSplineSurface { grid, ..s.clone() })
1573            }
1574            Self::Revolution(s) => Self::Revolution(Box::new(RevolutionSurface {
1575                curve: s.curve.transformed(t, tol)?,
1576                axis: Axis::new(
1577                    t.apply(s.axis.location),
1578                    t.apply_direction(s.axis.direction, tol)?,
1579                ),
1580                angle: s.angle,
1581            })),
1582            Self::Extrusion(s) => Self::Extrusion(Box::new(ExtrusionSurface {
1583                curve: s.curve.transformed(t, tol)?,
1584                direction: t.apply_direction(s.direction, tol)?,
1585                extent: (s.extent.0 * scale, s.extent.1 * scale),
1586            })),
1587            Self::Trimmed(s) => {
1588                let basis = s.basis.transformed(t, tol)?;
1589                // The trim range lives in the basis surface's parameters, and
1590                // those rescale exactly when the basis domain does.
1591                let ((oa, _), (ob, _)) = s.basis.domain();
1592                let ((na, _), (nb, _)) = basis.domain();
1593                let ur = if oa == 0.0 { 1.0 } else { na / oa };
1594                let vr = if ob == 0.0 { 1.0 } else { nb / ob };
1595                Self::Trimmed(Box::new(TrimmedSurface {
1596                    basis,
1597                    domain: (
1598                        (s.domain.0.0 * ur, s.domain.0.1 * ur),
1599                        (s.domain.1.0 * vr, s.domain.1.1 * vr),
1600                    ),
1601                }))
1602            }
1603        })
1604    }
1605}
1606
1607impl From<PlaneSurface> for SurfaceGeometry {
1608    fn from(s: PlaneSurface) -> Self {
1609        Self::Plane(s)
1610    }
1611}
1612impl From<CylinderSurface> for SurfaceGeometry {
1613    fn from(s: CylinderSurface) -> Self {
1614        Self::Cylinder(s)
1615    }
1616}
1617impl From<ConeSurface> for SurfaceGeometry {
1618    fn from(s: ConeSurface) -> Self {
1619        Self::Cone(s)
1620    }
1621}
1622impl From<SphereSurface> for SurfaceGeometry {
1623    fn from(s: SphereSurface) -> Self {
1624        Self::Sphere(s)
1625    }
1626}
1627impl From<TorusSurface> for SurfaceGeometry {
1628    fn from(s: TorusSurface) -> Self {
1629        Self::Torus(s)
1630    }
1631}
1632impl From<BSplineSurface> for SurfaceGeometry {
1633    fn from(s: BSplineSurface) -> Self {
1634        Self::BSpline(s)
1635    }
1636}
1637impl From<RevolutionSurface> for SurfaceGeometry {
1638    fn from(s: RevolutionSurface) -> Self {
1639        Self::Revolution(Box::new(s))
1640    }
1641}
1642impl From<ExtrusionSurface> for SurfaceGeometry {
1643    fn from(s: ExtrusionSurface) -> Self {
1644        Self::Extrusion(Box::new(s))
1645    }
1646}
1647impl From<TrimmedSurface> for SurfaceGeometry {
1648    fn from(s: TrimmedSurface) -> Self {
1649        Self::Trimmed(Box::new(s))
1650    }
1651}
1652
1653#[cfg(test)]
1654#[allow(clippy::unwrap_used)]
1655mod tests {
1656    /// A surface that closes on itself evaluates past its join as a
1657    /// periodic one would.
1658    ///
1659    /// A clamped tube whose first and last control columns coincide has
1660    /// the same points at both ends of `u` and repeats nowhere. A ring that
1661    /// runs right round it spans exactly a period, so wherever it is slid
1662    /// some of it lies past the end, and refusing there stopped three real
1663    /// bodies from meshing. A parameter a period past the end names a point
1664    /// the surface has, and is answered with it.
1665    #[test]
1666    fn a_closed_surface_wraps_a_parameter_past_its_join() {
1667        use crate::Surface as _;
1668        use ogeom_math::{ControlGrid, KnotVector, Point};
1669        let ring = [
1670            Point::new(1.0, 0.0, 0.0),
1671            Point::new(0.0, 1.0, 0.0),
1672            Point::new(-1.0, 0.0, 0.0),
1673            Point::new(0.0, -1.0, 0.0),
1674            Point::new(1.0, 0.0, 0.0),
1675        ];
1676        let mut control = Vec::new();
1677        for p in &ring {
1678            for z in [0.0, 5.0] {
1679                control.push(Point::new(p.x, p.y, z));
1680            }
1681        }
1682        let tube = BSplineSurface::new(
1683            KnotVector::new(vec![0.0, 0.0, 0.25, 0.5, 0.75, 1.0, 1.0], 1).unwrap(),
1684            KnotVector::clamped_uniform(1, 2).unwrap(),
1685            &ControlGrid::new(control, 5, 2).unwrap(),
1686            T,
1687        )
1688        .unwrap();
1689        assert!(
1690            tube.is_closed_u(T) && !tube.is_periodic_u(),
1691            "closed, not periodic"
1692        );
1693        let inside = tube.point_at(0.3, 0.5, T).unwrap();
1694        let past = tube.point_at(1.3, 0.5, T).unwrap();
1695        let before = tube.point_at(-0.7, 0.5, T).unwrap();
1696        assert!(
1697            inside.is_equal(past, T),
1698            "a period past the end: {inside:?} vs {past:?}"
1699        );
1700        assert!(
1701            inside.is_equal(before, T),
1702            "a period before the start: {inside:?} vs {before:?}"
1703        );
1704        // Along `v` the tube is open, and past its end is still past it.
1705        assert!(
1706            tube.point_at(0.3, 1.5, T).is_err(),
1707            "an open direction still refuses"
1708        );
1709    }
1710
1711    use super::*;
1712    use crate::curve::{CircleCurve, LineCurve};
1713    use approx::assert_relative_eq;
1714    use ogeom_math::{Circle, Frame};
1715
1716    const T: Tolerances = Tolerances::millimetres();
1717
1718    #[test]
1719    fn a_jet_agrees_with_the_separate_accessors() {
1720        // `jet_at` answers in one evaluation what three accessors answer in
1721        // three. Its contract is agreement to rounding, not to the bit: a
1722        // patch sums its point by de Boor and its derivatives by basis
1723        // functions, and those reassociate differently. Held to a relative
1724        // ulp-scale bound, which a real disagreement would blow through by
1725        // orders of magnitude.
1726        let close = |a: Vector, b: Vector, what: &str| {
1727            let scale = a.magnitude().max(b.magnitude()).max(1.0);
1728            assert!(
1729                (a - b).magnitude() <= scale * 1e-12,
1730                "{what}: {a:?} against {b:?}"
1731            );
1732        };
1733        let surfaces: Vec<SurfaceGeometry> = vec![
1734            SurfaceGeometry::Plane(PlaneSurface::new(ogeom_math::Plane::XY)),
1735            SurfaceGeometry::Sphere(SphereSurface::new(
1736                ogeom_math::Sphere::new(ogeom_math::Frame::WORLD, 3.0, T).unwrap(),
1737            )),
1738            bspline_patch(),
1739        ];
1740        for surface in &surfaces {
1741            let ((ua, ub), (va, vb)) = surface.domain();
1742            for i in 1..5 {
1743                for j in 1..5 {
1744                    let u = ua + (ub - ua) * f64::from(i) / 5.0;
1745                    let v = va + (vb - va) * f64::from(j) / 5.0;
1746                    let jet = surface.jet_at(u, v, T).unwrap();
1747                    let point = surface.point_at(u, v, T).unwrap();
1748                    close(jet.point - Point::ORIGIN, point - Point::ORIGIN, "point");
1749                    let (du, dv) = surface.d1_at(u, v, T).unwrap();
1750                    close(jet.du, du, "du");
1751                    close(jet.dv, dv, "dv");
1752                    let (d2u, duv, d2v) = surface.d2_at(u, v, T).unwrap();
1753                    close(jet.d2u, d2u, "d2u");
1754                    close(jet.duv, duv, "duv");
1755                    close(jet.d2v, d2v, "d2v");
1756                }
1757            }
1758        }
1759    }
1760
1761    /// A bicubic patch with a bulge, so its derivatives are not degenerate.
1762    fn bspline_patch() -> SurfaceGeometry {
1763        let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], 3).unwrap();
1764        let mut points = Vec::new();
1765        for i in 0..4 {
1766            for j in 0..4 {
1767                let z = if i == 1 && j == 2 { 2.0 } else { 0.0 };
1768                points.push(Point::new(f64::from(i), f64::from(j), z));
1769            }
1770        }
1771        let grid = ControlGrid::new(points, 4, 4).unwrap();
1772        SurfaceGeometry::BSpline(BSplineSurface::new(knots.clone(), knots, &grid, T).unwrap())
1773    }
1774
1775    #[test]
1776    fn an_offset_cylinder_is_the_larger_cylinder() {
1777        use ogeom_math::Cylinder;
1778        let basis = SurfaceGeometry::Cylinder(
1779            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 5.0)).unwrap(),
1780        );
1781        let offset = OffsetSurface::new(basis, 1.5).unwrap();
1782        // The cylinder's outward normal is radial, so every offset point
1783        // stands at the grown radius, at the same height.
1784        for (u, v) in [(0.0, 0.0), (1.0, 2.0), (3.5, 4.5)] {
1785            let p = offset.point_at(u, v, T).unwrap();
1786            let radial = (p.x * p.x + p.y * p.y).sqrt();
1787            assert_relative_eq!(radial, 3.5, epsilon = 1e-12);
1788        }
1789        // Exact first derivatives against differencing the exact points.
1790        let h = 1e-6;
1791        let (du, dv) = offset.d1_at(1.0, 2.0, T).unwrap();
1792        let fdu = (offset.point_at(1.0 + h, 2.0, T).unwrap()
1793            - offset.point_at(1.0 - h, 2.0, T).unwrap())
1794            / (2.0 * h);
1795        let fdv = (offset.point_at(1.0, 2.0 + h, T).unwrap()
1796            - offset.point_at(1.0, 2.0 - h, T).unwrap())
1797            / (2.0 * h);
1798        assert_relative_eq!((du - fdu).magnitude(), 0.0, epsilon = 1e-5);
1799        assert_relative_eq!((dv - fdv).magnitude(), 0.0, epsilon = 1e-5);
1800        // The second derivative refuses by name.
1801        assert!(offset.d2_at(1.0, 2.0, T).is_err());
1802    }
1803
1804    fn tilted() -> Frame {
1805        Frame::new(
1806            Point::new(1.0, -2.0, 3.0),
1807            Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
1808            Direction::X,
1809            T,
1810        )
1811        .unwrap()
1812    }
1813
1814    /// A patch continued past a side keeps its own run and continues it:
1815    /// a rational cylinder patch continued along its axis and round its
1816    /// circle stays on the cylinder both ways, and the fitted patch keeps
1817    /// every point it had.
1818    #[test]
1819    fn a_patch_extended_stays_itself_and_continues() {
1820        use crate::Surface as _;
1821        let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 5.0, T).unwrap();
1822        let wall =
1823            SurfaceGeometry::Cylinder(crate::CylinderSurface::new(cylinder, (0.0, 10.0)).unwrap())
1824                .to_bspline(T)
1825                .unwrap();
1826        let ((ua, ub), (va, vb)) = wall.domain();
1827        for (along_u, at_end) in [(true, true), (true, false), (false, true), (false, false)] {
1828            let longer = wall.extended(along_u, at_end, 3.0, 2, T).unwrap();
1829            let ((la, lb), (ma, mb)) = longer.domain();
1830            assert!(
1831                (la <= ua && lb >= ub && ma <= va && mb >= vb)
1832                    && ((la < ua) || (lb > ub) || (ma < va) || (mb > vb)),
1833                "the domain grows: {:?}",
1834                longer.domain()
1835            );
1836            for i in 0..=6 {
1837                for j in 0..=6 {
1838                    let u = la + (lb - la) * f64::from(i) / 6.0;
1839                    let v = ma + (mb - ma) * f64::from(j) / 6.0;
1840                    let p = longer.point_at(u, v, T).unwrap();
1841                    let radial = (p.x * p.x + p.y * p.y).sqrt();
1842                    assert!(
1843                        (radial - 5.0).abs() < 1e-9,
1844                        "off the cylinder at ({u},{v}): {p:?}"
1845                    );
1846                    if (ua..=ub).contains(&u) && (va..=vb).contains(&v) {
1847                        let was = wall.point_at(u, v, T).unwrap();
1848                        assert!(was.distance(p) < 1e-9, "the wall itself at ({u},{v})");
1849                    }
1850                }
1851            }
1852            if !along_u {
1853                let reach = if at_end { mb } else { ma };
1854                let p = longer.point_at(ua, reach, T).unwrap();
1855                assert!(
1856                    (p.z - if at_end { 13.0 } else { -3.0 }).abs() < 1e-9,
1857                    "the axial continuation reaches the length asked: {p:?}"
1858                );
1859            }
1860        }
1861        let fitted = patch();
1862        let ((ua, ub), (va, vb)) = fitted.domain();
1863        let longer = fitted.extended(true, true, 1.0, 2, T).unwrap();
1864        for i in 0..=5 {
1865            for j in 0..=5 {
1866                let u = ua + (ub - ua) * f64::from(i) / 5.0;
1867                let v = va + (vb - va) * f64::from(j) / 5.0;
1868                let (was, now) = (
1869                    fitted.point_at(u, v, T).unwrap(),
1870                    longer.point_at(u, v, T).unwrap(),
1871                );
1872                assert!(was.distance(now) < 1e-9, "the patch itself at ({u},{v})");
1873            }
1874        }
1875    }
1876
1877    /// An iso-curve lifted off the control net is the surface's own trace
1878    /// up that column, point for point.
1879    #[test]
1880    fn an_iso_curve_traces_the_surface_exactly() {
1881        use crate::Curve3d as _;
1882        let surface = patch();
1883        let ((ua, ub), (va, vb)) = surface.domain();
1884        for frac in [0.1, 0.5, 0.83] {
1885            let u = ua + (ub - ua) * frac;
1886            let column = surface.iso_u_curve(u, T).unwrap();
1887            let v = va + (vb - va) * frac;
1888            let row = surface.iso_v_curve(v, T).unwrap();
1889            for k in 0..=10 {
1890                let f = f64::from(k) / 10.0;
1891                let vv = va + (vb - va) * f;
1892                let on = surface.point_at(u, vv, T).unwrap();
1893                let along = column.point_at(vv, T).unwrap();
1894                assert!(
1895                    on.distance(along) < 1e-9,
1896                    "column at u {u}: {on:?} vs {along:?}"
1897                );
1898                let uu = ua + (ub - ua) * f;
1899                let on = surface.point_at(uu, v, T).unwrap();
1900                let along = row.point_at(uu, T).unwrap();
1901                assert!(
1902                    on.distance(along) < 1e-9,
1903                    "row at v {v}: {on:?} vs {along:?}"
1904                );
1905            }
1906        }
1907    }
1908
1909    fn patch() -> BSplineSurface {
1910        let (nu, nv) = (5, 4);
1911        let mut points = Vec::with_capacity(nu * nv);
1912        for i in 0..nu {
1913            for j in 0..nv {
1914                #[allow(clippy::cast_precision_loss)]
1915                let (x, y) = (i as f64, j as f64);
1916                points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos()));
1917            }
1918        }
1919        BSplineSurface::new(
1920            KnotVector::clamped_uniform(3, nu).unwrap(),
1921            KnotVector::clamped_uniform(2, nv).unwrap(),
1922            &ControlGrid::new(points, nu, nv).unwrap(),
1923            T,
1924        )
1925        .unwrap()
1926    }
1927
1928    fn every_surface() -> Vec<SurfaceGeometry> {
1929        let circle: Curve = CircleCurve::new(
1930            Circle::new(
1931                Frame::new(Point::new(5.0, 0.0, 0.0), Direction::Y, Direction::X, T).unwrap(),
1932                1.0,
1933                T,
1934            )
1935            .unwrap(),
1936        )
1937        .into();
1938        let line: Curve =
1939            LineCurve::segment(Point::new(2.0, 0.0, 0.0), Point::new(2.0, 0.0, 4.0), T)
1940                .unwrap()
1941                .into();
1942        vec![
1943            PlaneSurface::over(Plane::new(tilted()), (-5.0, 5.0), (-3.0, 3.0))
1944                .unwrap()
1945                .into(),
1946            CylinderSurface::new(Cylinder::new(tilted(), 2.0, T).unwrap(), (-4.0, 4.0))
1947                .unwrap()
1948                .into(),
1949            ConeSurface::new(Cone::new(tilted(), 3.0, 0.6, T).unwrap(), (-1.0, 5.0))
1950                .unwrap()
1951                .into(),
1952            SphereSurface::new(Sphere::new(tilted(), 4.0, T).unwrap()).into(),
1953            TorusSurface::new(Torus::new(tilted(), 5.0, 2.0, T).unwrap()).into(),
1954            patch().into(),
1955            RevolutionSurface::new(circle, Axis::Z, TAU).unwrap().into(),
1956            ExtrusionSurface::new(line, Direction::X, 3.0)
1957                .unwrap()
1958                .into(),
1959            TrimmedSurface::new(patch().into(), (0.2, 0.8), (0.3, 0.7), T)
1960                .unwrap()
1961                .into(),
1962        ]
1963    }
1964
1965    /// Interior sample parameters, avoiding the domain edges *and* the tidy
1966    /// fractions where a spline's interior knots sit.
1967    ///
1968    /// At a knot a spline's second derivative is one-sided, while a central
1969    /// difference straddles two different polynomial pieces, so a comparison
1970    /// there measures the discontinuity rather than the derivative. The offset
1971    /// keeps samples clear of knots at halves, thirds and quarters.
1972    fn interior(s: &SurfaceGeometry, n: usize) -> Vec<(f64, f64)> {
1973        const OFF: f64 = 0.0413;
1974        let ((ua, ub), (va, vb)) = s.domain();
1975        let mut out = Vec::new();
1976        for i in 1..n {
1977            for j in 1..n {
1978                #[allow(clippy::cast_precision_loss)]
1979                let (tu, tv) = (i as f64 / n as f64 + OFF, j as f64 / n as f64 + OFF);
1980                out.push((ua + (ub - ua) * tu, va + (vb - va) * tv));
1981            }
1982        }
1983        out
1984    }
1985
1986    #[test]
1987    fn every_surfaces_first_partials_agree_with_finite_differences() {
1988        let h = 1e-6;
1989        for s in every_surface() {
1990            for (u, v) in interior(&s, 5) {
1991                let (du, dv) = s.d1_at(u, v, T).unwrap();
1992                let nu = (s.point_at(u + h, v, T).unwrap() - s.point_at(u - h, v, T).unwrap())
1993                    * (1.0 / (2.0 * h));
1994                let nv = (s.point_at(u, v + h, T).unwrap() - s.point_at(u, v - h, T).unwrap())
1995                    * (1.0 / (2.0 * h));
1996                assert!(
1997                    (du - nu).magnitude() <= 1e-5 * nu.magnitude().max(1.0),
1998                    "{:?} du at ({u}, {v}): {du:?} vs {nu:?}",
1999                    s.kind()
2000                );
2001                assert!(
2002                    (dv - nv).magnitude() <= 1e-5 * nv.magnitude().max(1.0),
2003                    "{:?} dv at ({u}, {v}): {dv:?} vs {nv:?}",
2004                    s.kind()
2005                );
2006            }
2007        }
2008    }
2009
2010    #[test]
2011    fn every_surfaces_second_partials_agree_with_finite_differences() {
2012        let h = 1e-5;
2013        for s in every_surface() {
2014            for (u, v) in interior(&s, 4) {
2015                let (d2u, duv, d2v) = s.d2_at(u, v, T).unwrap();
2016
2017                let nuu = (s.point_at(u + h, v, T).unwrap().to_vector()
2018                    - s.point_at(u, v, T).unwrap().to_vector() * 2.0
2019                    + s.point_at(u - h, v, T).unwrap().to_vector())
2020                    * (1.0 / (h * h));
2021                let nvv = (s.point_at(u, v + h, T).unwrap().to_vector()
2022                    - s.point_at(u, v, T).unwrap().to_vector() * 2.0
2023                    + s.point_at(u, v - h, T).unwrap().to_vector())
2024                    * (1.0 / (h * h));
2025                let nuv = (s.point_at(u + h, v + h, T).unwrap()
2026                    - s.point_at(u + h, v - h, T).unwrap()
2027                    - (s.point_at(u - h, v + h, T).unwrap()
2028                        - s.point_at(u - h, v - h, T).unwrap()))
2029                    * (1.0 / (4.0 * h * h));
2030
2031                for (analytic, numeric, name) in
2032                    [(d2u, nuu, "d2u"), (duv, nuv, "duv"), (d2v, nvv, "d2v")]
2033                {
2034                    assert!(
2035                        (analytic - numeric).magnitude() <= 1e-3 * numeric.magnitude().max(1.0),
2036                        "{:?} {name} at ({u}, {v}): {analytic:?} vs {numeric:?}",
2037                        s.kind()
2038                    );
2039                }
2040            }
2041        }
2042    }
2043
2044    #[test]
2045    fn out_of_domain_parameters_are_refused_where_the_surface_is_not_periodic() {
2046        for s in every_surface() {
2047            let ((ua, ub), (va, vb)) = s.domain();
2048            let inside = ((ua + ub) / 2.0, (va + vb) / 2.0);
2049            if s.kind() == SurfaceKind::Plane {
2050                // A plane's window is where its face was built; the plane
2051                // goes on past it.
2052                let p = s.point_at(ub + 1.0, inside.1, T).unwrap();
2053                let q = s.point_at(ub, inside.1, T).unwrap();
2054                assert!((p.distance(q) - 1.0).abs() < 1e-12);
2055                continue;
2056            }
2057            if s.is_periodic_u() {
2058                assert!(s.point_at(ub + 1.0, inside.1, T).is_ok(), "{:?}", s.kind());
2059            } else {
2060                assert!(s.point_at(ub + 1.0, inside.1, T).is_err(), "{:?}", s.kind());
2061            }
2062            if s.is_periodic_v() {
2063                assert!(s.point_at(inside.0, vb + 1.0, T).is_ok(), "{:?}", s.kind());
2064            } else {
2065                assert!(s.point_at(inside.0, vb + 1.0, T).is_err(), "{:?}", s.kind());
2066            }
2067        }
2068    }
2069
2070    #[test]
2071    fn periodic_parameters_wrap_to_the_same_point() {
2072        for s in every_surface() {
2073            let ((ua, ub), (va, vb)) = s.domain();
2074            let (u, v) = ((ua + ub) * 0.4, (va + vb) * 0.4);
2075            let base = s.point_at(u, v, T).unwrap();
2076            if s.is_periodic_u() {
2077                let wrapped = s.point_at(u + (ub - ua), v, T).unwrap();
2078                assert!(base.is_equal(wrapped, T), "{:?} u wrap", s.kind());
2079            }
2080            if s.is_periodic_v() {
2081                let wrapped = s.point_at(u, v + (vb - va), T).unwrap();
2082                assert!(base.is_equal(wrapped, T), "{:?} v wrap", s.kind());
2083            }
2084        }
2085    }
2086
2087    #[test]
2088    fn analytic_surfaces_contain_their_own_points() {
2089        // Cross-check against the independent distance functions in ogeom-math.
2090        let cyl = Cylinder::new(tilted(), 2.0, T).unwrap();
2091        let cone = Cone::new(tilted(), 3.0, 0.6, T).unwrap();
2092        let sph = Sphere::new(tilted(), 4.0, T).unwrap();
2093        let tor = Torus::new(tilted(), 5.0, 2.0, T).unwrap();
2094        let plane = Plane::new(tilted());
2095
2096        let cyl_s = CylinderSurface::new(cyl, (-4.0, 4.0)).unwrap();
2097        let cone_s = ConeSurface::new(cone, (-1.0, 5.0)).unwrap();
2098        let sph_s = SphereSurface::new(sph);
2099        let tor_s = TorusSurface::new(tor);
2100        let plane_s = PlaneSurface::over(plane, (-5.0, 5.0), (-3.0, 3.0)).unwrap();
2101
2102        for i in 1..6 {
2103            for j in 1..6 {
2104                let (tu, tv) = (f64::from(i) / 6.0, f64::from(j) / 6.0);
2105                assert!(
2106                    plane.contains(
2107                        plane_s
2108                            .point_at(-5.0 + 10.0 * tu, -3.0 + 6.0 * tv, T)
2109                            .unwrap(),
2110                        T
2111                    )
2112                );
2113                assert!(cyl.contains(cyl_s.point_at(TAU * tu, -4.0 + 8.0 * tv, T).unwrap(), T));
2114                assert!(cone.contains(cone_s.point_at(TAU * tu, -1.0 + 6.0 * tv, T).unwrap(), T));
2115                let half = core::f64::consts::FRAC_PI_2;
2116                assert!(
2117                    sph.contains(
2118                        sph_s
2119                            .point_at(TAU * tu, -half + core::f64::consts::PI * tv, T)
2120                            .unwrap(),
2121                        T
2122                    )
2123                );
2124                assert!(tor.contains(tor_s.point_at(TAU * tu, TAU * tv, T).unwrap(), T));
2125            }
2126        }
2127    }
2128
2129    #[test]
2130    fn normals_of_analytic_surfaces_match_their_own_definitions() {
2131        let sph = Sphere::new(tilted(), 4.0, T).unwrap();
2132        let s = SphereSurface::new(sph);
2133        for i in 1..6 {
2134            for j in 1..6 {
2135                let u = TAU * f64::from(i) / 6.0;
2136                let v = -1.2 + 2.4 * f64::from(j) / 6.0;
2137                let p = s.point_at(u, v, T).unwrap();
2138                assert!(
2139                    s.normal_at(u, v, T)
2140                        .unwrap()
2141                        .is_equal(sph.normal_at(p, T).unwrap(), T)
2142                );
2143            }
2144        }
2145    }
2146
2147    #[test]
2148    fn a_sphere_degenerates_at_its_poles_and_says_so() {
2149        let s = SphereSurface::new(Sphere::new(tilted(), 4.0, T).unwrap());
2150        let half = core::f64::consts::FRAC_PI_2;
2151        for pole in [-half, half] {
2152            assert!(s.is_degenerate_at(1.0, pole, T).unwrap());
2153            assert!(s.normal_at(1.0, pole, T).is_err());
2154        }
2155        assert!(!s.is_degenerate_at(1.0, 0.0, T).unwrap());
2156        assert!(s.normal_at(1.0, 0.0, T).is_ok());
2157    }
2158
2159    #[test]
2160    fn a_cone_degenerates_at_its_apex() {
2161        let cone = Cone::new(tilted(), 3.0, 0.6, T).unwrap();
2162        let apex_height = -3.0 / 0.6_f64.tan();
2163        let s = ConeSurface::new(cone, (apex_height, 5.0)).unwrap();
2164        assert!(s.is_degenerate_at(1.0, apex_height, T).unwrap());
2165        assert!(s.normal_at(1.0, apex_height, T).is_err());
2166        assert!(
2167            s.point_at(1.0, apex_height, T)
2168                .unwrap()
2169                .is_equal(cone.apex(), T)
2170        );
2171    }
2172
2173    #[test]
2174    fn revolving_a_circle_about_an_offset_axis_gives_a_torus() {
2175        // The strongest cross-check available here: two independent
2176        // constructions of the same surface must agree pointwise.
2177        let major = 5.0;
2178        let minor = 1.0;
2179        // The generator's normal is -Y, not +Y. A frame's second axis is
2180        // `z x x`, so a normal of +Y with an x of +X gives a y of -Z and winds
2181        // the circle backwards relative to the torus's v parameter. Getting
2182        // this wrong produces a surface that is the right shape and the wrong
2183        // parameterization, which is exactly the sort of mistake that survives
2184        // a visual check.
2185        let generator: Curve = CircleCurve::new(
2186            Circle::new(
2187                Frame::new(Point::new(major, 0.0, 0.0), -Direction::Y, Direction::X, T).unwrap(),
2188                minor,
2189                T,
2190            )
2191            .unwrap(),
2192        )
2193        .into();
2194        let revolved = RevolutionSurface::new(generator, Axis::Z, TAU).unwrap();
2195        let torus = TorusSurface::new(Torus::new(Frame::WORLD, major, minor, T).unwrap());
2196
2197        for i in 0..12 {
2198            for j in 0..12 {
2199                let u = TAU * f64::from(i) / 12.0;
2200                let v = TAU * f64::from(j) / 12.0;
2201                let a = revolved.point_at(u, v, T).unwrap();
2202                let b = torus.point_at(u, v, T).unwrap();
2203                assert!(a.is_equal(b, T), "at ({u}, {v}): {a:?} vs {b:?}");
2204            }
2205        }
2206    }
2207
2208    #[test]
2209    fn extruding_a_line_gives_a_plane_and_a_circle_gives_a_cylinder() {
2210        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(0.0, 4.0, 0.0), T)
2211            .unwrap()
2212            .into();
2213        let sheet = ExtrusionSurface::new(line, Direction::Z, 3.0).unwrap();
2214        let plane = Plane::new(Frame::WORLD);
2215        for i in 0..=4 {
2216            for j in 0..=4 {
2217                let p = sheet
2218                    .point_at(4.0 * f64::from(i) / 4.0, 3.0 * f64::from(j) / 4.0, T)
2219                    .unwrap();
2220                assert!(plane.distance_to(p) < 1e-12 || p.x.abs() < 1e-12);
2221            }
2222        }
2223
2224        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2225        let tube = ExtrusionSurface::new(circle, Direction::Z, 5.0).unwrap();
2226        let cylinder = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
2227        for i in 0..8 {
2228            for j in 0..=4 {
2229                let p = tube
2230                    .point_at(TAU * f64::from(i) / 8.0, 5.0 * f64::from(j) / 4.0, T)
2231                    .unwrap();
2232                assert!(cylinder.contains(p, T));
2233            }
2234        }
2235    }
2236
2237    #[test]
2238    fn degenerate_constructions_are_refused() {
2239        assert!(PlaneSurface::over(Plane::new(tilted()), (1.0, 1.0), (0.0, 1.0)).is_err());
2240        assert!(
2241            CylinderSurface::new(Cylinder::new(tilted(), 1.0, T).unwrap(), (2.0, 1.0)).is_err()
2242        );
2243        let circle: Curve = CircleCurve::new(Circle::new(tilted(), 1.0, T).unwrap()).into();
2244        assert!(RevolutionSurface::new(circle.clone(), Axis::Z, 0.0).is_err());
2245        assert!(RevolutionSurface::new(circle.clone(), Axis::Z, 7.0).is_err());
2246        assert!(RevolutionSurface::new(circle.clone(), Axis::Z, TAU).is_ok());
2247        assert!(ExtrusionSurface::new(circle.clone(), Direction::Z, 0.0).is_err());
2248        assert!(ExtrusionSurface::new(circle, Direction::Z, f64::NAN).is_err());
2249    }
2250
2251    #[test]
2252    fn trimming_is_bounds_checked_and_agrees_with_its_basis() {
2253        let basis: SurfaceGeometry = patch().into();
2254        assert!(TrimmedSurface::new(basis.clone(), (0.2, 0.8), (0.3, 0.7), T).is_ok());
2255        assert!(TrimmedSurface::new(basis.clone(), (0.8, 0.2), (0.3, 0.7), T).is_err());
2256        assert!(TrimmedSurface::new(basis.clone(), (-0.5, 0.8), (0.3, 0.7), T).is_err());
2257
2258        let trimmed = TrimmedSurface::new(basis.clone(), (0.2, 0.8), (0.3, 0.7), T).unwrap();
2259        assert_eq!(trimmed.domain(), ((0.2, 0.8), (0.3, 0.7)));
2260        for i in 0..=4 {
2261            for j in 0..=4 {
2262                let u = 0.2 + 0.6 * f64::from(i) / 4.0;
2263                let v = 0.3 + 0.4 * f64::from(j) / 4.0;
2264                assert!(
2265                    trimmed
2266                        .point_at(u, v, T)
2267                        .unwrap()
2268                        .is_equal(basis.point_at(u, v, T).unwrap(), T)
2269                );
2270            }
2271        }
2272        assert!(trimmed.point_at(0.1, 0.5, T).is_err());
2273    }
2274
2275    #[test]
2276    fn transforms_move_surfaces_and_preserve_their_kind() {
2277        let t =
2278            Transform::rotation(Axis::X, 0.7) * Transform::translation(Vector::new(1.0, 2.0, 3.0));
2279        for s in every_surface() {
2280            let moved = s.transformed(&t, T).unwrap();
2281            assert_eq!(moved.kind(), s.kind());
2282            for (u, v) in interior(&s, 4) {
2283                let expected = t.apply(s.point_at(u, v, T).unwrap());
2284                assert!(
2285                    moved.point_at(u, v, T).unwrap().is_equal(expected, T),
2286                    "{:?} at ({u}, {v})",
2287                    s.kind()
2288                );
2289            }
2290        }
2291    }
2292
2293    #[test]
2294    fn a_scaling_rescales_length_valued_parameters() {
2295        // A cylinder's v is a height, so it must rescale; its u is an angle and
2296        // must not.
2297        let s: SurfaceGeometry =
2298            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 4.0))
2299                .unwrap()
2300                .into();
2301        let scaled = s
2302            .transformed(&Transform::scaling(Point::ORIGIN, 3.0, T).unwrap(), T)
2303            .unwrap();
2304        assert_eq!(scaled.domain(), ((0.0, TAU), (0.0, 12.0)));
2305        assert!(
2306            scaled
2307                .point_at(0.0, 12.0, T)
2308                .unwrap()
2309                .is_equal(Point::new(6.0, 0.0, 12.0), T)
2310        );
2311    }
2312
2313    #[test]
2314    fn a_rational_patch_is_recognized_and_a_uniformly_weighted_one_is_not() {
2315        let g = patch();
2316        assert!(!g.is_rational());
2317
2318        let w = core::f64::consts::FRAC_1_SQRT_2;
2319        let points: Vec<_> = [
2320            (Point::new(1.0, 0.0, 0.0), 1.0),
2321            (Point::new(1.0, 1.0, 0.0), w),
2322            (Point::new(0.0, 1.0, 0.0), 1.0),
2323            (Point::new(1.0, 0.0, 1.0), 1.0),
2324            (Point::new(1.0, 1.0, 1.0), w),
2325            (Point::new(0.0, 1.0, 1.0), 1.0),
2326        ]
2327        .iter()
2328        .map(|(p, w)| Weighted::new(*p, *w, T).unwrap())
2329        .collect();
2330        let arc = BSplineSurface::rational(
2331            KnotVector::clamped_uniform(1, 2).unwrap(),
2332            KnotVector::clamped_uniform(2, 3).unwrap(),
2333            ControlGrid::new(points, 2, 3).unwrap(),
2334        )
2335        .unwrap();
2336        assert!(arc.is_rational());
2337        // Each isoparametric line is an exact unit arc in the xy plane.
2338        for i in 0..=4 {
2339            for j in 0..=8 {
2340                let p = arc
2341                    .point_at(f64::from(i) / 4.0, f64::from(j) / 8.0, T)
2342                    .unwrap();
2343                assert_relative_eq!(p.x.hypot(p.y), 1.0, epsilon = 1e-14);
2344            }
2345        }
2346    }
2347
2348    #[test]
2349    fn closure_and_periodicity_are_reported_correctly() {
2350        let s = every_surface();
2351        let expect = [
2352            (SurfaceKind::Plane, false, false, false, false),
2353            (SurfaceKind::Cylinder, true, false, true, false),
2354            (SurfaceKind::Cone, true, false, true, false),
2355            (SurfaceKind::Sphere, true, false, true, false),
2356            (SurfaceKind::Torus, true, true, true, true),
2357            (SurfaceKind::BSpline, false, false, false, false),
2358            (SurfaceKind::Revolution, true, true, true, true),
2359            (SurfaceKind::Extrusion, false, false, false, false),
2360            (SurfaceKind::Trimmed, false, false, false, false),
2361        ];
2362        for (surface, (kind, cu, cv, pu, pv)) in s.iter().zip(expect) {
2363            assert_eq!(surface.kind(), kind);
2364            assert_eq!(surface.is_closed_u(T), cu, "{kind:?} closed u");
2365            assert_eq!(surface.is_closed_v(T), cv, "{kind:?} closed v");
2366            assert_eq!(surface.is_periodic_u(), pu, "{kind:?} periodic u");
2367            assert_eq!(surface.is_periodic_v(), pv, "{kind:?} periodic v");
2368        }
2369    }
2370
2371    #[test]
2372    fn a_sphere_is_not_periodic_in_latitude() {
2373        // Wrapping past a pole would land on the far side of the sphere, which
2374        // is a different point, so latitude stops rather than repeating.
2375        let s = SphereSurface::new(Sphere::new(Frame::WORLD, 1.0, T).unwrap());
2376        assert!(!s.is_periodic_v());
2377        assert!(s.point_at(0.0, core::f64::consts::PI, T).is_err());
2378    }
2379}