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. Keeping it a cylinder is worth
11//! the extra types: intersection can take a closed-form path, measurement can
12//! report a radius rather than a fit, files stay small, and a fillet knows it
13//! is filleting a cylinder. The cost is that every algorithm must handle a
14//! handful of cases, which the `kind` method makes an explicit choice rather
15//! 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, CircleCurve, 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 walks a control column per evaluation,
104    /// millions of times 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    /// Revolve `curve` about `axis` over the angles `range`, which may
576    /// start anywhere: the point at angle `u` is the curve turned by `u`, as
577    /// for [`RevolutionSurface::new`], so a wider range sweeps the same
578    /// surface further either way, its parameters kept.
579    ///
580    /// # Errors
581    ///
582    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the range
583    /// is not finite, empty, or spans more than a full turn.
584    pub fn over(curve: Curve, axis: Axis, range: (f64, f64)) -> OgeomResult<Self> {
585        let (a0, a1) = range;
586        let sweep = a1 - a0;
587        if !a0.is_finite() || !a1.is_finite() || sweep <= 0.0 || sweep > TAU + 1e-12 {
588            ogeom_bail!(
589                Construction,
590                "revolution range [{a0}, {a1}] must sweep (0, 2*pi]"
591            );
592        }
593        Ok(Self {
594            curve,
595            axis,
596            angle: (a0, a0 + sweep.min(TAU)),
597        })
598    }
599
600    /// The generating curve.
601    #[must_use]
602    pub const fn curve(&self) -> &Curve {
603        &self.curve
604    }
605
606    /// The axis of revolution.
607    #[must_use]
608    pub const fn axis(&self) -> Axis {
609        self.axis
610    }
611}
612
613impl ExtrusionSurface {
614    /// Sweep `curve` along `direction` over `[0, distance]`.
615    ///
616    /// # Errors
617    ///
618    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `distance`
619    /// is not finite and positive.
620    pub fn new(curve: Curve, direction: Direction, distance: f64) -> OgeomResult<Self> {
621        if !distance.is_finite() || distance <= 0.0 {
622            ogeom_bail!(
623                Construction,
624                "extrusion distance {distance} must be positive"
625            );
626        }
627        Ok(Self {
628            curve,
629            direction,
630            extent: (0.0, distance),
631        })
632    }
633
634    /// Sweep `curve` along `direction` over an explicit window of the
635    /// sweep parameter: a file's extrusion, unbounded either way, is
636    /// given the window its faces reach.
637    ///
638    /// # Errors
639    ///
640    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
641    /// the window is not finite and increasing.
642    pub fn over(curve: Curve, direction: Direction, extent: (f64, f64)) -> OgeomResult<Self> {
643        if !extent.0.is_finite() || !extent.1.is_finite() || extent.1 <= extent.0 {
644            ogeom_bail!(
645                Construction,
646                "extrusion window {extent:?} must be finite and increasing"
647            );
648        }
649        Ok(Self {
650            curve,
651            direction,
652            extent,
653        })
654    }
655
656    /// The generating curve.
657    #[must_use]
658    pub const fn curve(&self) -> &Curve {
659        &self.curve
660    }
661
662    /// The sweep direction.
663    #[must_use]
664    pub const fn direction(&self) -> Direction {
665        self.direction
666    }
667}
668
669impl TrimmedSurface {
670    /// Restrict `basis` to a sub-rectangle.
671    ///
672    /// # Errors
673    ///
674    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if either range is empty
675    /// or leaves the basis surface's domain in a non-periodic direction.
676    pub fn new(
677        basis: SurfaceGeometry,
678        u: (f64, f64),
679        v: (f64, f64),
680        tol: Tolerances,
681    ) -> OgeomResult<Self> {
682        check_range("u", u.0, u.1)?;
683        check_range("v", v.0, v.1)?;
684        let ((ua, ub), (va, vb)) = basis.domain();
685        let eps = tol.parametric();
686        if !basis.is_periodic_u() && (u.0 < ua - eps || u.1 > ub + eps) {
687            ogeom_bail!(Domain, "u range [{}, {}] leaves [{ua}, {ub}]", u.0, u.1);
688        }
689        if !basis.is_periodic_v() && (v.0 < va - eps || v.1 > vb + eps) {
690            ogeom_bail!(Domain, "v range [{}, {}] leaves [{va}, {vb}]", v.0, v.1);
691        }
692        Ok(Self {
693            basis,
694            domain: (u, v),
695        })
696    }
697
698    /// The surface being trimmed.
699    #[must_use]
700    pub const fn basis(&self) -> &SurfaceGeometry {
701        &self.basis
702    }
703}
704
705impl OffsetSurface {
706    /// Offset `basis` by a signed `distance` along its own normal.
707    ///
708    /// # Errors
709    ///
710    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
711    /// distance is not finite and non-zero.
712    pub fn new(basis: SurfaceGeometry, distance: f64) -> OgeomResult<Self> {
713        if !distance.is_finite() || distance == 0.0 {
714            ogeom_bail!(
715                Construction,
716                "an offset of {distance} is not a displacement"
717            );
718        }
719        Ok(Self { basis, distance })
720    }
721
722    /// The surface being offset.
723    #[must_use]
724    pub const fn basis(&self) -> &SurfaceGeometry {
725        &self.basis
726    }
727
728    /// The signed displacement along the basis normal.
729    #[must_use]
730    pub const fn distance(&self) -> f64 {
731        self.distance
732    }
733
734    /// The offset as the analytic surface it is, where the basis is one: a
735    /// plane moved along its normal, a drum, ball or ring's tube grown or
736    /// shrunk, a cone's reference radius changed by `d / cos α` over the
737    /// same half-angle, a revolved circle in a plane through its axis
738    /// revolved again with its radius grown or shrunk. Which way the offset
739    /// grows is read off the basis's
740    /// own normal at a point, not assumed from its parameterization. `None`
741    /// for any other basis, or for an offset that would turn the surface
742    /// inside out (a radius through zero).
743    ///
744    /// # Errors
745    ///
746    /// As the basis's evaluation, or as the analytic constructors.
747    pub fn analytic(&self, tol: Tolerances) -> OgeomResult<Option<SurfaceGeometry>> {
748        let ((u0, u1), (v0, v1)) = self.basis.domain();
749        let clamp = |lo: f64, hi: f64| {
750            if lo.is_finite() && hi.is_finite() {
751                f64::midpoint(lo, hi)
752            } else {
753                0.0
754            }
755        };
756        let (u, v) = (clamp(u0, u1), clamp(v0, v1));
757        let at = self.basis.point_at(u, v, tol)?;
758        let normal = self.basis.normal_at(u, v, tol)?.vector();
759        let d = self.distance;
760        // Whether the basis normal points away from the axis or centre it
761        // is measured from: the offset then grows the radius.
762        let outward = |from_axis: Vector| normal.dot(from_axis) > 0.0;
763        Ok(match &self.basis {
764            SurfaceGeometry::Plane(p) => {
765                let plane = p.plane();
766                let frame = plane.frame();
767                // The plane's own frame moved, of whichever hand it is: a
768                // mirrored plane's normal and `v` stay as they were.
769                let moved = ogeom_math::Frame::from_axes(
770                    frame.origin() + normal * d,
771                    frame.x(),
772                    frame.y(),
773                    frame.z(),
774                    tol,
775                )?;
776                Some(PlaneSurface::over(ogeom_math::Plane::new(moved), (u0, u1), (v0, v1))?.into())
777            }
778            SurfaceGeometry::Cylinder(c) => {
779                let cylinder = c.cylinder();
780                let frame = cylinder.frame();
781                let radial = at - frame.origin();
782                let radial = radial - frame.z().vector() * radial.dot(frame.z().vector());
783                let radius = cylinder.radius() + if outward(radial) { d } else { -d };
784                if radius <= tol.confusion() {
785                    return Ok(None);
786                }
787                Some(
788                    CylinderSurface::new(ogeom_math::Cylinder::new(frame, radius, tol)?, (v0, v1))?
789                        .into(),
790                )
791            }
792            SurfaceGeometry::Cone(c) => {
793                let cone = c.cone();
794                let frame = cone.frame();
795                let radial = at - frame.origin();
796                let radial = radial - frame.z().vector() * radial.dot(frame.z().vector());
797                let grow = if outward(radial) { d } else { -d };
798                // A point of the offset stands the offset's axial part higher
799                // than its basis point: the frame rises by it, so each
800                // `(u, v)` names the offset of the point it names on the
801                // basis, and the radius there is the basis's grown by the
802                // offset's radial part.
803                let rise = normal.dot(frame.z().vector()) * d;
804                let radius = cone.reference_radius() + grow * cone.half_angle().cos();
805                if radius <= tol.confusion() {
806                    return Ok(None);
807                }
808                let raised = ogeom_math::Frame::from_axes(
809                    frame.origin() + frame.z().vector() * rise,
810                    frame.x(),
811                    frame.y(),
812                    frame.z(),
813                    tol,
814                )?;
815                Some(
816                    ConeSurface::new(
817                        ogeom_math::Cone::new(raised, radius, cone.half_angle(), tol)?,
818                        (v0, v1),
819                    )?
820                    .into(),
821                )
822            }
823            SurfaceGeometry::Sphere(s) => {
824                let sphere = s.sphere();
825                let grow = if outward(at - sphere.frame().origin()) {
826                    d
827                } else {
828                    -d
829                };
830                let radius = sphere.radius() + grow;
831                if radius <= tol.confusion() {
832                    return Ok(None);
833                }
834                Some(
835                    SphereSurface::new(ogeom_math::Sphere::new(sphere.frame(), radius, tol)?)
836                        .into(),
837                )
838            }
839            SurfaceGeometry::Torus(t) => {
840                let torus = t.torus();
841                let frame = torus.frame();
842                // From the tube's own centre circle.
843                let flat = at - frame.origin();
844                let flat = flat - frame.z().vector() * flat.dot(frame.z().vector());
845                let ring = frame.origin() + flat * (torus.major_radius() / flat.magnitude());
846                let grow = if outward(at - ring) { d } else { -d };
847                let minor = torus.minor_radius() + grow;
848                if minor <= tol.confusion() {
849                    return Ok(None);
850                }
851                Some(
852                    TorusSurface::new(ogeom_math::Torus::new(
853                        frame,
854                        torus.major_radius(),
855                        minor,
856                        tol,
857                    )?)
858                    .into(),
859                )
860            }
861            SurfaceGeometry::Revolution(r) => self.revolved_circle_offset(r, tol)?,
862            _ => None,
863        })
864    }
865
866    /// The offset of a revolution whose profile is a circle in a plane
867    /// through the axis: each meridian's normal is its circle's radius, so
868    /// the offset is the concentric circle revolved over the same angle,
869    /// and each `(u, v)` names the offset of the point it names on the
870    /// basis. A circle centred on the axis makes a sphere, any other a
871    /// stretch of a torus. `None` for any other profile, or where the
872    /// radius would pass through zero.
873    fn revolved_circle_offset(
874        &self,
875        r: &RevolutionSurface,
876        tol: Tolerances,
877    ) -> OgeomResult<Option<SurfaceGeometry>> {
878        let Curve::Circle(profile) = r.curve() else {
879            return Ok(None);
880        };
881        // Running forward, the same point at every parameter.
882        let circle = profile.forward(tol)?.circle();
883        let frame = circle.frame();
884        let axis = r.axis();
885        let normal = frame.z().vector();
886        // The profile's plane holds the axis: its normal is square to the
887        // axis and the axis's point lies in it.
888        if normal.dot(axis.direction.vector()).abs() > tol.angular()
889            || (axis.location - frame.origin()).dot(normal).abs() > tol.confusion()
890        {
891            return Ok(None);
892        }
893        // Which way the basis normal points against the circle's radius,
894        // read where the meridian stands clear of the axis.
895        let ((u0, u1), (v0, v1)) = r.domain();
896        let u = f64::midpoint(u0, u1);
897        let mut grow = None;
898        for k in 1..8 {
899            let v = v0 + (v1 - v0) * f64::from(k) / 8.0;
900            let Ok(n) = r.normal_at(u, v, tol) else {
901                continue;
902            };
903            let at = r.point_at(u, v, tol)?;
904            let centre = Transform::rotation(axis, u).apply(frame.origin());
905            let along = n.vector().dot(at - centre);
906            if along.abs() > 0.5 * circle.radius() {
907                grow = Some(if along > 0.0 { 1.0 } else { -1.0 });
908                break;
909            }
910        }
911        let Some(grow) = grow else {
912            return Ok(None);
913        };
914        let radius = circle.radius() + grow * self.distance;
915        if radius <= tol.confusion() {
916            return Ok(None);
917        }
918        let curve = CircleCurve::new(ogeom_math::Circle::new(frame, radius, tol)?);
919        let angle = r.domain().0;
920        Ok(Some(
921            RevolutionSurface::over(curve.into(), axis, angle)?.into(),
922        ))
923    }
924}
925
926impl Surface for OffsetSurface {
927    fn domain(&self) -> ((f64, f64), (f64, f64)) {
928        self.basis.domain()
929    }
930
931    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
932        let base = self.basis.point_at(u, v, tol)?;
933        let normal = self.basis.normal_at(u, v, tol)?;
934        Ok(base + normal.vector() * self.distance)
935    }
936
937    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
938        // Differentiate S + d·c/|c| with c = Su x Sv: the unit normal's
939        // derivative is the tangential projection of c's derivative, scaled
940        // by the magnitude, exact from the basis's first and second
941        // derivatives.
942        let (su, sv) = self.basis.d1_at(u, v, tol)?;
943        let (suu, suv, svv) = self.basis.d2_at(u, v, tol)?;
944        let c = su.cross(sv);
945        let m = c.magnitude();
946        let scale = su.magnitude().max(sv.magnitude());
947        if m <= tol.angular() * scale * scale {
948            ogeom_bail!(
949                Construction,
950                "the basis is degenerate at ({u}, {v}); the offset has no tangent plane there"
951            );
952        }
953        let n = c / m;
954        let cu = suu.cross(sv) + su.cross(suv);
955        let cv = suv.cross(sv) + su.cross(svv);
956        let nu = (cu - n * n.dot(cu)) / m;
957        let nv = (cv - n * n.dot(cv)) / m;
958        Ok((su + nu * self.distance, sv + nv * self.distance))
959    }
960
961    fn d2_at(&self, _u: f64, _v: f64, _tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
962        ogeom_bail!(
963            Construction,
964            "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"
965        )
966    }
967
968    fn kind(&self) -> SurfaceKind {
969        SurfaceKind::Offset
970    }
971
972    fn continuity(&self) -> Continuity {
973        // Offsetting spends one order of smoothness.
974        match self.basis.continuity() {
975            Continuity::CInfinity => Continuity::CInfinity,
976            Continuity::C2 | Continuity::G2 => Continuity::C1,
977            Continuity::C1 | Continuity::G1 | Continuity::C0 => Continuity::C0,
978        }
979    }
980
981    fn is_closed_u(&self, tol: Tolerances) -> bool {
982        self.basis.is_closed_u(tol)
983    }
984
985    fn is_closed_v(&self, tol: Tolerances) -> bool {
986        self.basis.is_closed_v(tol)
987    }
988
989    fn is_periodic_u(&self) -> bool {
990        self.basis.is_periodic_u()
991    }
992
993    fn is_periodic_v(&self) -> bool {
994        self.basis.is_periodic_v()
995    }
996}
997
998fn finite_parameters(u: f64, v: f64) -> OgeomResult<()> {
999    if u.is_finite() && v.is_finite() {
1000        Ok(())
1001    } else {
1002        ogeom_bail!(Domain, "parameters ({u}, {v}) are not finite")
1003    }
1004}
1005
1006impl Surface for PlaneSurface {
1007    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1008        self.domain
1009    }
1010
1011    // A plane's window says where its face was built, not where the plane
1012    // ends: it is evaluated anywhere, so a face merged across two windows
1013    // (or trimmed a hair past one) is read where it lies.
1014    fn point_at(&self, u: f64, v: f64, _tol: Tolerances) -> OgeomResult<Point> {
1015        finite_parameters(u, v)?;
1016        Ok(elementary::plane_at(&self.plane, u, v).point)
1017    }
1018
1019    fn d1_at(&self, u: f64, v: f64, _tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1020        finite_parameters(u, v)?;
1021        let f = self.plane.frame();
1022        Ok((f.x().vector(), f.y().vector()))
1023    }
1024
1025    fn d2_at(&self, u: f64, v: f64, _tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1026        finite_parameters(u, v)?;
1027        Ok((Vector::ZERO, Vector::ZERO, Vector::ZERO))
1028    }
1029
1030    fn kind(&self) -> SurfaceKind {
1031        SurfaceKind::Plane
1032    }
1033
1034    fn continuity(&self) -> Continuity {
1035        Continuity::CInfinity
1036    }
1037
1038    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1039        false
1040    }
1041
1042    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1043        false
1044    }
1045
1046    fn is_periodic_u(&self) -> bool {
1047        false
1048    }
1049
1050    fn is_periodic_v(&self) -> bool {
1051        false
1052    }
1053}
1054
1055impl Surface for CylinderSurface {
1056    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1057        ((0.0, TAU), self.height)
1058    }
1059
1060    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1061        let (u, v) = self.normalize_parameters(u, v, tol)?;
1062        Ok(elementary::cylinder_at(&self.cylinder, u, v).point)
1063    }
1064
1065    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1066        let (u, v) = self.normalize_parameters(u, v, tol)?;
1067        let p = elementary::cylinder_at(&self.cylinder, u, v);
1068        Ok((p.du, p.dv))
1069    }
1070
1071    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1072        let (u, _) = self.normalize_parameters(u, v, tol)?;
1073        let f = self.cylinder.frame();
1074        let r = self.cylinder.radius();
1075        let (sin, cos) = u.sin_cos();
1076        // Second derivative in u points back at the axis. The surface is ruled
1077        // along v, so everything involving v vanishes.
1078        Ok((
1079            f.x() * (-r * cos) + f.y() * (-r * sin),
1080            Vector::ZERO,
1081            Vector::ZERO,
1082        ))
1083    }
1084
1085    fn kind(&self) -> SurfaceKind {
1086        SurfaceKind::Cylinder
1087    }
1088
1089    fn continuity(&self) -> Continuity {
1090        Continuity::CInfinity
1091    }
1092
1093    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1094        true
1095    }
1096
1097    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1098        false
1099    }
1100
1101    fn is_periodic_u(&self) -> bool {
1102        true
1103    }
1104
1105    fn is_periodic_v(&self) -> bool {
1106        false
1107    }
1108}
1109
1110impl Surface for ConeSurface {
1111    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1112        ((0.0, TAU), self.height)
1113    }
1114
1115    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1116        let (u, v) = self.normalize_parameters(u, v, tol)?;
1117        Ok(elementary::cone_at(&self.cone, u, v).point)
1118    }
1119
1120    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1121        let (u, v) = self.normalize_parameters(u, v, tol)?;
1122        let p = elementary::cone_at(&self.cone, u, v);
1123        Ok((p.du, p.dv))
1124    }
1125
1126    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1127        let (u, v) = self.normalize_parameters(u, v, tol)?;
1128        let f = self.cone.frame();
1129        let r = self.cone.radius_at(v);
1130        let slope = self.cone.half_angle().tan();
1131        let (sin, cos) = u.sin_cos();
1132        Ok((
1133            // Radial, scaled by the radius at this height.
1134            f.x() * (-r * cos) + f.y() * (-r * sin),
1135            // The radius grows linearly along v, so the mixed partial is the
1136            // u-tangent's rate of growth.
1137            f.x() * (-slope * sin) + f.y() * (slope * cos),
1138            // Straight along the ruling.
1139            Vector::ZERO,
1140        ))
1141    }
1142
1143    fn kind(&self) -> SurfaceKind {
1144        SurfaceKind::Cone
1145    }
1146
1147    fn continuity(&self) -> Continuity {
1148        Continuity::CInfinity
1149    }
1150
1151    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1152        true
1153    }
1154
1155    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1156        false
1157    }
1158
1159    fn is_periodic_u(&self) -> bool {
1160        true
1161    }
1162
1163    fn is_periodic_v(&self) -> bool {
1164        false
1165    }
1166}
1167
1168impl Surface for SphereSurface {
1169    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1170        let half = core::f64::consts::FRAC_PI_2;
1171        ((0.0, TAU), (-half, half))
1172    }
1173
1174    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1175        let (u, v) = self.normalize_parameters(u, v, tol)?;
1176        Ok(elementary::sphere_at(&self.sphere, u, v).point)
1177    }
1178
1179    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1180        let (u, v) = self.normalize_parameters(u, v, tol)?;
1181        let p = elementary::sphere_at(&self.sphere, u, v);
1182        Ok((p.du, p.dv))
1183    }
1184
1185    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1186        let (u, v) = self.normalize_parameters(u, v, tol)?;
1187        let f = self.sphere.frame();
1188        let r = self.sphere.radius();
1189        let (sin_u, cos_u) = u.sin_cos();
1190        let (sin_v, cos_v) = v.sin_cos();
1191        let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
1192        let ring = r * cos_v;
1193        Ok((
1194            x * (-ring * cos_u) + y * (-ring * sin_u),
1195            x * (r * sin_v * sin_u) + y * (-r * sin_v * cos_u),
1196            x * (-ring * cos_u) + y * (-ring * sin_u) + z * (-r * sin_v),
1197        ))
1198    }
1199
1200    fn kind(&self) -> SurfaceKind {
1201        SurfaceKind::Sphere
1202    }
1203
1204    fn continuity(&self) -> Continuity {
1205        Continuity::CInfinity
1206    }
1207
1208    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1209        true
1210    }
1211
1212    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1213        false
1214    }
1215
1216    fn is_periodic_u(&self) -> bool {
1217        true
1218    }
1219
1220    fn is_periodic_v(&self) -> bool {
1221        // Latitude runs pole to pole and stops. Treating it as periodic would
1222        // let a parameter past the pole wrap round to the far side, which is a
1223        // different point.
1224        false
1225    }
1226}
1227
1228impl Surface for TorusSurface {
1229    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1230        ((0.0, TAU), (0.0, TAU))
1231    }
1232
1233    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1234        let (u, v) = self.normalize_parameters(u, v, tol)?;
1235        Ok(elementary::torus_at(&self.torus, u, v).point)
1236    }
1237
1238    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1239        let (u, v) = self.normalize_parameters(u, v, tol)?;
1240        let p = elementary::torus_at(&self.torus, u, v);
1241        Ok((p.du, p.dv))
1242    }
1243
1244    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1245        let (u, v) = self.normalize_parameters(u, v, tol)?;
1246        let f = self.torus.frame();
1247        let (major, minor) = (self.torus.major_radius(), self.torus.minor_radius());
1248        let (sin_u, cos_u) = u.sin_cos();
1249        let (sin_v, cos_v) = v.sin_cos();
1250        let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
1251        let out = x * cos_u + y * sin_u;
1252        let side = x * -sin_u + y * cos_u;
1253        let radius = minor.mul_add(cos_v, major);
1254        Ok((
1255            out * -radius,
1256            side * (-minor * sin_v),
1257            out * (-minor * cos_v) + z * (-minor * sin_v),
1258        ))
1259    }
1260
1261    fn kind(&self) -> SurfaceKind {
1262        SurfaceKind::Torus
1263    }
1264
1265    fn continuity(&self) -> Continuity {
1266        Continuity::CInfinity
1267    }
1268
1269    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1270        true
1271    }
1272
1273    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1274        true
1275    }
1276
1277    fn is_periodic_u(&self) -> bool {
1278        true
1279    }
1280
1281    fn is_periodic_v(&self) -> bool {
1282        true
1283    }
1284}
1285
1286impl Surface for BSplineSurface {
1287    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1288        (self.u_knots.domain(), self.v_knots.domain())
1289    }
1290
1291    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1292        let (u, v) = self.normalize_parameters(u, v, tol)?;
1293        if self.rational {
1294            bspline::evaluate_rational_surface(&self.u_knots, &self.v_knots, &self.grid, u, v, tol)
1295        } else {
1296            Ok(
1297                bspline::evaluate_surface(&self.u_knots, &self.v_knots, &self.grid, u, v, tol)?
1298                    .point(),
1299            )
1300        }
1301    }
1302
1303    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1304        let (u, v) = self.normalize_parameters(u, v, tol)?;
1305        // A polynomial patch has no weights to divide through: the plain
1306        // table, without the quotient rule's second table and its loops.
1307        // Every normal of every spline face comes through here.
1308        if !self.rational {
1309            let d = bspline::surface_derivatives(
1310                &self.u_knots,
1311                &self.v_knots,
1312                &self.grid,
1313                u,
1314                v,
1315                1,
1316                tol,
1317            )?;
1318            return Ok((d[1][0].scaled.to_vector(), d[0][1].scaled.to_vector()));
1319        }
1320        let d = bspline::rational_surface_derivatives(
1321            &self.u_knots,
1322            &self.v_knots,
1323            &self.grid,
1324            u,
1325            v,
1326            1,
1327            tol,
1328        )?;
1329        Ok((d[1][0].to_vector(), d[0][1].to_vector()))
1330    }
1331
1332    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1333        let (u, v) = self.normalize_parameters(u, v, tol)?;
1334        if !self.rational {
1335            let d = bspline::surface_derivatives(
1336                &self.u_knots,
1337                &self.v_knots,
1338                &self.grid,
1339                u,
1340                v,
1341                2,
1342                tol,
1343            )?;
1344            return Ok((
1345                d[2][0].scaled.to_vector(),
1346                d[1][1].scaled.to_vector(),
1347                d[0][2].scaled.to_vector(),
1348            ));
1349        }
1350        let d = bspline::rational_surface_derivatives(
1351            &self.u_knots,
1352            &self.v_knots,
1353            &self.grid,
1354            u,
1355            v,
1356            2,
1357            tol,
1358        )?;
1359        Ok((
1360            d[2][0].to_vector(),
1361            d[1][1].to_vector(),
1362            d[0][2].to_vector(),
1363        ))
1364    }
1365
1366    fn point_d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Point, Vector, Vector)> {
1367        let (u, v) = self.normalize_parameters(u, v, tol)?;
1368        if self.rational {
1369            let d = bspline::rational_surface_derivatives(
1370                &self.u_knots,
1371                &self.v_knots,
1372                &self.grid,
1373                u,
1374                v,
1375                1,
1376                tol,
1377            )?;
1378            return Ok((
1379                Point::ORIGIN + d[0][0].to_vector(),
1380                d[1][0].to_vector(),
1381                d[0][1].to_vector(),
1382            ));
1383        }
1384        let d =
1385            bspline::surface_derivatives(&self.u_knots, &self.v_knots, &self.grid, u, v, 1, tol)?;
1386        Ok((
1387            Point::ORIGIN + d[0][0].scaled.to_vector(),
1388            d[1][0].scaled.to_vector(),
1389            d[0][1].scaled.to_vector(),
1390        ))
1391    }
1392
1393    fn jet_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<crate::SurfaceJet> {
1394        // One order-two table answers for all six. Through the separate
1395        // accessors this patch locates its spans, builds its basis functions
1396        // and sums its control grid three times for the same numbers.
1397        let (u, v) = self.normalize_parameters(u, v, tol)?;
1398        let d = if self.rational {
1399            bspline::rational_surface_derivatives(
1400                &self.u_knots,
1401                &self.v_knots,
1402                &self.grid,
1403                u,
1404                v,
1405                2,
1406                tol,
1407            )?
1408        } else {
1409            bspline::surface_derivatives(&self.u_knots, &self.v_knots, &self.grid, u, v, 2, tol)?
1410                .into_iter()
1411                .map(|row| row.into_iter().map(|w| w.scaled).collect())
1412                .collect()
1413        };
1414        Ok(crate::SurfaceJet {
1415            point: Point::ORIGIN + d[0][0].to_vector(),
1416            du: d[1][0].to_vector(),
1417            dv: d[0][1].to_vector(),
1418            d2u: d[2][0].to_vector(),
1419            duv: d[1][1].to_vector(),
1420            d2v: d[0][2].to_vector(),
1421        })
1422    }
1423
1424    fn kind(&self) -> SurfaceKind {
1425        SurfaceKind::BSpline
1426    }
1427
1428    fn continuity(&self) -> Continuity {
1429        // The worse of the two directions governs the patch.
1430        let worst = |k: &KnotVector| {
1431            let (a, b) = k.domain();
1432            k.distinct()
1433                .into_iter()
1434                .filter(|(x, _)| *x > a && *x < b)
1435                .map(|(_, m)| m)
1436                .max()
1437                .map_or(Continuity::CInfinity, |m| {
1438                    match k.degree().saturating_sub(m) {
1439                        0 => Continuity::C0,
1440                        1 => Continuity::C1,
1441                        _ => Continuity::C2,
1442                    }
1443                })
1444        };
1445        worst(&self.u_knots).min(worst(&self.v_knots))
1446    }
1447
1448    fn is_closed_u(&self, tol: Tolerances) -> bool {
1449        // Settled at construction. The walk is repeated only where the net
1450        // was not closed then and a coarser tolerance might say otherwise.
1451        self.closed.0 || Self::net_closed_u(&self.grid, tol)
1452    }
1453
1454    fn is_closed_v(&self, tol: Tolerances) -> bool {
1455        self.closed.1 || Self::net_closed_v(&self.grid, tol)
1456    }
1457
1458    fn is_periodic_u(&self) -> bool {
1459        false
1460    }
1461
1462    fn is_periodic_v(&self) -> bool {
1463        false
1464    }
1465}
1466
1467impl Surface for RevolutionSurface {
1468    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1469        (self.angle, self.curve.domain())
1470    }
1471
1472    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1473        let (u, v) = self.normalize_parameters(u, v, tol)?;
1474        let p = self.curve.point_at(v, tol)?;
1475        Ok(Transform::rotation(self.axis, u).apply(p))
1476    }
1477
1478    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1479        let (u, v) = self.normalize_parameters(u, v, tol)?;
1480        let rotate = Transform::rotation(self.axis, u);
1481        let p = rotate.apply(self.curve.point_at(v, tol)?);
1482        // Rotating about an axis moves a point along a circle centred on the
1483        // axis, so the u-tangent is the axis direction crossed with the radius.
1484        let radius = p - self.axis.project(p);
1485        Ok((
1486            self.axis.direction.cross_with(radius),
1487            rotate.apply_vector(self.curve.d1_at(v, tol)?),
1488        ))
1489    }
1490
1491    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1492        let (u, v) = self.normalize_parameters(u, v, tol)?;
1493        let rotate = Transform::rotation(self.axis, u);
1494        let p = rotate.apply(self.curve.point_at(v, tol)?);
1495        let radius = p - self.axis.project(p);
1496        let d = self.curve.derivatives_at(v, 2, tol)?;
1497        let curve_d1 = rotate.apply_vector(d[1]);
1498        // Circular motion: the second derivative in u points back at the axis.
1499        let d2u = -radius;
1500        // The mixed partial is the u-derivative of the rotated curve tangent,
1501        // which is the same circular relation applied to that tangent.
1502        let duv = self.axis.direction.cross_with(curve_d1);
1503        Ok((d2u, duv, rotate.apply_vector(d[2])))
1504    }
1505
1506    fn kind(&self) -> SurfaceKind {
1507        SurfaceKind::Revolution
1508    }
1509
1510    fn continuity(&self) -> Continuity {
1511        // Rotation is smooth, so the generating curve governs.
1512        self.curve.continuity()
1513    }
1514
1515    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1516        (self.angle.1 - self.angle.0 - TAU).abs() <= 1e-12
1517    }
1518
1519    fn is_closed_v(&self, tol: Tolerances) -> bool {
1520        self.curve.is_closed(tol)
1521    }
1522
1523    fn is_periodic_u(&self) -> bool {
1524        (self.angle.1 - self.angle.0 - TAU).abs() <= 1e-12
1525    }
1526
1527    fn is_periodic_v(&self) -> bool {
1528        self.curve.is_periodic()
1529    }
1530}
1531
1532impl Surface for ExtrusionSurface {
1533    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1534        (self.curve.domain(), self.extent)
1535    }
1536
1537    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1538        let (u, v) = self.normalize_parameters(u, v, tol)?;
1539        Ok(self.curve.point_at(u, tol)? + self.direction * v)
1540    }
1541
1542    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1543        let (u, _) = self.normalize_parameters(u, v, tol)?;
1544        Ok((self.curve.d1_at(u, tol)?, self.direction.vector()))
1545    }
1546
1547    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1548        let (u, _) = self.normalize_parameters(u, v, tol)?;
1549        // Ruled along v, so only the curve's own curvature survives.
1550        Ok((
1551            self.curve.derivatives_at(u, 2, tol)?[2],
1552            Vector::ZERO,
1553            Vector::ZERO,
1554        ))
1555    }
1556
1557    fn kind(&self) -> SurfaceKind {
1558        SurfaceKind::Extrusion
1559    }
1560
1561    fn continuity(&self) -> Continuity {
1562        self.curve.continuity()
1563    }
1564
1565    fn is_closed_u(&self, tol: Tolerances) -> bool {
1566        self.curve.is_closed(tol)
1567    }
1568
1569    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1570        false
1571    }
1572
1573    fn is_periodic_u(&self) -> bool {
1574        self.curve.is_periodic()
1575    }
1576
1577    fn is_periodic_v(&self) -> bool {
1578        false
1579    }
1580}
1581
1582impl Surface for TrimmedSurface {
1583    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1584        self.domain
1585    }
1586
1587    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1588        let (u, v) = self.normalize_parameters(u, v, tol)?;
1589        self.basis.point_at(u, v, tol)
1590    }
1591
1592    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1593        let (u, v) = self.normalize_parameters(u, v, tol)?;
1594        self.basis.d1_at(u, v, tol)
1595    }
1596
1597    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1598        let (u, v) = self.normalize_parameters(u, v, tol)?;
1599        self.basis.d2_at(u, v, tol)
1600    }
1601
1602    fn kind(&self) -> SurfaceKind {
1603        SurfaceKind::Trimmed
1604    }
1605
1606    fn continuity(&self) -> Continuity {
1607        self.basis.continuity()
1608    }
1609
1610    fn is_closed_u(&self, _tol: Tolerances) -> bool {
1611        // A trim narrower than the basis cannot close, whatever the basis does.
1612        false
1613    }
1614
1615    fn is_closed_v(&self, _tol: Tolerances) -> bool {
1616        false
1617    }
1618
1619    fn is_periodic_u(&self) -> bool {
1620        false
1621    }
1622
1623    fn is_periodic_v(&self) -> bool {
1624        false
1625    }
1626}
1627
1628/// Dispatch a method across every surface variant.
1629macro_rules! dispatch {
1630    ($self:ident, $s:ident => $body:expr) => {
1631        match $self {
1632            Self::Plane($s) => $body,
1633            Self::Cylinder($s) => $body,
1634            Self::Cone($s) => $body,
1635            Self::Sphere($s) => $body,
1636            Self::Torus($s) => $body,
1637            Self::BSpline($s) => $body,
1638            Self::Revolution($s) => $body,
1639            Self::Extrusion($s) => $body,
1640            Self::Trimmed($s) => $body,
1641            Self::Offset($s) => $body,
1642        }
1643    };
1644}
1645
1646impl Surface for SurfaceGeometry {
1647    fn domain(&self) -> ((f64, f64), (f64, f64)) {
1648        dispatch!(self, s => s.domain())
1649    }
1650
1651    fn point_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<Point> {
1652        dispatch!(self, s => s.point_at(u, v, tol))
1653    }
1654
1655    fn d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
1656        dispatch!(self, s => s.d1_at(u, v, tol))
1657    }
1658
1659    fn d2_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector, Vector)> {
1660        dispatch!(self, s => s.d2_at(u, v, tol))
1661    }
1662
1663    // Dispatched like the rest. Without it the enum answers with the trait's
1664    // default, which asks the three accessors, so the variant that overrides
1665    // `jet_at` to avoid exactly that would never be reached through a
1666    // `SurfaceGeometry`, which is how every caller holds a surface.
1667    fn point_d1_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<(Point, Vector, Vector)> {
1668        dispatch!(self, s => s.point_d1_at(u, v, tol))
1669    }
1670
1671    fn jet_at(&self, u: f64, v: f64, tol: Tolerances) -> OgeomResult<crate::SurfaceJet> {
1672        dispatch!(self, s => s.jet_at(u, v, tol))
1673    }
1674
1675    fn kind(&self) -> SurfaceKind {
1676        dispatch!(self, s => s.kind())
1677    }
1678
1679    fn continuity(&self) -> Continuity {
1680        dispatch!(self, s => s.continuity())
1681    }
1682
1683    fn is_closed_u(&self, tol: Tolerances) -> bool {
1684        dispatch!(self, s => s.is_closed_u(tol))
1685    }
1686
1687    fn is_closed_v(&self, tol: Tolerances) -> bool {
1688        dispatch!(self, s => s.is_closed_v(tol))
1689    }
1690
1691    fn is_periodic_u(&self) -> bool {
1692        dispatch!(self, s => s.is_periodic_u())
1693    }
1694
1695    fn is_periodic_v(&self) -> bool {
1696        dispatch!(self, s => s.is_periodic_v())
1697    }
1698}
1699
1700impl Transformable for SurfaceGeometry {
1701    fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
1702        if t.kind() == ogeom_math::TransformKind::Identity {
1703            return Ok(self.clone());
1704        }
1705        let scale = t.scale_factor().abs();
1706        Ok(match self {
1707            Self::Plane(s) => Self::Plane(PlaneSurface {
1708                plane: s.plane.transformed(t, tol)?,
1709                // The parameters are lengths along the frame axes, so a scaling
1710                // rescales the extent with them.
1711                domain: (
1712                    (s.domain.0.0 * scale, s.domain.0.1 * scale),
1713                    (s.domain.1.0 * scale, s.domain.1.1 * scale),
1714                ),
1715            }),
1716            Self::Cylinder(s) => Self::Cylinder(CylinderSurface {
1717                cylinder: s.cylinder.transformed(t, tol)?,
1718                height: (s.height.0 * scale, s.height.1 * scale),
1719            }),
1720            // The basis's normal is the cross of its two tangents, which a
1721            // reflection turns to the other side: the offset goes with it.
1722            Self::Offset(s) => Self::Offset(Box::new(OffsetSurface {
1723                basis: s.basis.transformed(t, tol)?,
1724                distance: s.distance * scale * if t.preserves_handedness() { 1.0 } else { -1.0 },
1725            })),
1726            Self::Cone(s) => Self::Cone(ConeSurface {
1727                cone: s.cone.transformed(t, tol)?,
1728                height: (s.height.0 * scale, s.height.1 * scale),
1729            }),
1730            Self::Sphere(s) => Self::Sphere(SphereSurface {
1731                sphere: s.sphere.transformed(t, tol)?,
1732            }),
1733            Self::Torus(s) => Self::Torus(TorusSurface {
1734                torus: s.torus.transformed(t, tol)?,
1735            }),
1736            Self::BSpline(s) => {
1737                let grid = ControlGrid::new(
1738                    s.grid
1739                        .points()
1740                        .iter()
1741                        .map(|w| Weighted::new(t.apply(w.point()), w.weight, tol))
1742                        .collect::<OgeomResult<Vec<_>>>()?,
1743                    s.grid.u_count(),
1744                    s.grid.v_count(),
1745                )?;
1746                Self::BSpline(BSplineSurface {
1747                    u_knots: s.u_knots.clone(),
1748                    v_knots: s.v_knots.clone(),
1749                    grid,
1750                    rational: s.rational,
1751                    closed: s.closed,
1752                })
1753            }
1754            Self::Revolution(s) => {
1755                // A reflection turns a rotation the other way round its
1756                // image axis (`M·R(a, u) = R(-M·a, u)·M`), so under one the
1757                // axis is carried reversed and every `(u, v)` still names
1758                // the image of the point it named.
1759                let direction = t.apply_direction(s.axis.direction, tol)?;
1760                let direction = if t.preserves_handedness() {
1761                    direction
1762                } else {
1763                    direction.reversed()
1764                };
1765                Self::Revolution(Box::new(RevolutionSurface {
1766                    curve: s.curve.transformed(t, tol)?,
1767                    axis: Axis::new(t.apply(s.axis.location), direction),
1768                    angle: s.angle,
1769                }))
1770            }
1771            Self::Extrusion(s) => Self::Extrusion(Box::new(ExtrusionSurface {
1772                curve: s.curve.transformed(t, tol)?,
1773                direction: t.apply_direction(s.direction, tol)?,
1774                extent: (s.extent.0 * scale, s.extent.1 * scale),
1775            })),
1776            Self::Trimmed(s) => {
1777                let basis = s.basis.transformed(t, tol)?;
1778                // The trim range lives in the basis surface's parameters, and
1779                // moves with them: carried affinely from the basis's old
1780                // domain to its new one, so a domain starting at zero
1781                // rescales as surely as any other.
1782                let (old_u, old_v) = s.basis.domain();
1783                let (new_u, new_v) = basis.domain();
1784                let carry = |x: f64, from: (f64, f64), to: (f64, f64)| carried(x, from, to);
1785                Self::Trimmed(Box::new(TrimmedSurface {
1786                    basis,
1787                    domain: (
1788                        (
1789                            carry(s.domain.0.0, old_u, new_u),
1790                            carry(s.domain.0.1, old_u, new_u),
1791                        ),
1792                        (
1793                            carry(s.domain.1.0, old_v, new_v),
1794                            carry(s.domain.1.1, old_v, new_v),
1795                        ),
1796                    ),
1797                }))
1798            }
1799        })
1800    }
1801}
1802
1803impl From<PlaneSurface> for SurfaceGeometry {
1804    fn from(s: PlaneSurface) -> Self {
1805        Self::Plane(s)
1806    }
1807}
1808impl From<CylinderSurface> for SurfaceGeometry {
1809    fn from(s: CylinderSurface) -> Self {
1810        Self::Cylinder(s)
1811    }
1812}
1813impl From<ConeSurface> for SurfaceGeometry {
1814    fn from(s: ConeSurface) -> Self {
1815        Self::Cone(s)
1816    }
1817}
1818impl From<SphereSurface> for SurfaceGeometry {
1819    fn from(s: SphereSurface) -> Self {
1820        Self::Sphere(s)
1821    }
1822}
1823impl From<TorusSurface> for SurfaceGeometry {
1824    fn from(s: TorusSurface) -> Self {
1825        Self::Torus(s)
1826    }
1827}
1828impl From<BSplineSurface> for SurfaceGeometry {
1829    fn from(s: BSplineSurface) -> Self {
1830        Self::BSpline(s)
1831    }
1832}
1833impl From<RevolutionSurface> for SurfaceGeometry {
1834    fn from(s: RevolutionSurface) -> Self {
1835        Self::Revolution(Box::new(s))
1836    }
1837}
1838impl From<ExtrusionSurface> for SurfaceGeometry {
1839    fn from(s: ExtrusionSurface) -> Self {
1840        Self::Extrusion(Box::new(s))
1841    }
1842}
1843impl From<TrimmedSurface> for SurfaceGeometry {
1844    fn from(s: TrimmedSurface) -> Self {
1845        Self::Trimmed(Box::new(s))
1846    }
1847}
1848
1849/// A parameter carried from one domain to another by the affine map
1850/// between them: where a transform rescales a parameter, a trim inside the
1851/// old domain lands at the same place inside the new. Unchanged where the
1852/// old domain has no width to map from.
1853#[must_use]
1854pub fn carried(x: f64, from: (f64, f64), to: (f64, f64)) -> f64 {
1855    let span = from.1 - from.0;
1856    if span == 0.0 || !span.is_finite() || !(to.1 - to.0).is_finite() {
1857        return x;
1858    }
1859    if from == to {
1860        return x;
1861    }
1862    let ratio = (to.1 - to.0) / span;
1863    // A plain rescaling about zero, as a length parameter under a scaling
1864    // transform: the product, exactly, rather than an affine map that
1865    // loses digits to cancellation across a wide domain.
1866    let about_zero = |a: f64, b: f64| (a * ratio - b).abs() <= f64::EPSILON * 4.0 * b.abs();
1867    if about_zero(from.0, to.0) && about_zero(from.1, to.1) {
1868        return x * ratio;
1869    }
1870    to.0 + (x - from.0) * ratio
1871}
1872
1873#[cfg(test)]
1874#[allow(clippy::unwrap_used)]
1875mod tests {
1876    /// The closed form an offset of a plane, cylinder, cone or sphere
1877    /// stands for names, at every `(u, v)`, the point the offset names
1878    /// there, and turns the same way: a mirrored plane's included, and a
1879    /// cone offset either way.
1880    #[test]
1881    fn an_offset_s_closed_form_is_the_offset_point_for_point() {
1882        use crate::Surface as _;
1883        use ogeom_math::{Cone, Cylinder, Direction, Plane, Point, Sphere};
1884        let tilted = Frame::new(
1885            Point::new(1.0, -2.0, 3.0),
1886            Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
1887            Direction::from_coords(3.0, 0.0, -1.0, T).unwrap(),
1888            T,
1889        )
1890        .unwrap();
1891        let bases: Vec<SurfaceGeometry> = vec![
1892            PlaneSurface::new(Plane::new(tilted.mirrored())).into(),
1893            CylinderSurface::new(Cylinder::new(tilted, 5.0, T).unwrap(), (0.0, 10.0))
1894                .unwrap()
1895                .into(),
1896            ConeSurface::new(Cone::new(tilted, 5.0, 0.4, T).unwrap(), (0.0, 10.0))
1897                .unwrap()
1898                .into(),
1899            SphereSurface::new(Sphere::new(tilted, 5.0, T).unwrap()).into(),
1900        ];
1901        for basis in bases {
1902            for d in [1.5, -1.5] {
1903                let offset = OffsetSurface::new(basis.clone(), d).unwrap();
1904                let closed = offset.analytic(T).unwrap().unwrap();
1905                for (u, v) in [(0.3, 1.0), (2.0, 4.0), (4.5, 8.0)] {
1906                    let v = if matches!(basis, SurfaceGeometry::Sphere(_)) {
1907                        v / 10.0
1908                    } else {
1909                        v
1910                    };
1911                    let want = offset.point_at(u, v, T).unwrap();
1912                    let got = closed.point_at(u, v, T).unwrap();
1913                    assert!(
1914                        got.distance(want) < 1e-9,
1915                        "{basis:?} by {d} at ({u}, {v}): {got:?} against {want:?}"
1916                    );
1917                    let (a, b) = (
1918                        offset.normal_at(u, v, T).unwrap(),
1919                        closed.normal_at(u, v, T).unwrap(),
1920                    );
1921                    assert!(
1922                        a.vector().dot(b.vector()) > 0.999_999,
1923                        "{basis:?} by {d}: normals {a:?} against {b:?}"
1924                    );
1925                }
1926            }
1927        }
1928    }
1929
1930    /// An offset sphere carried by a mirror, a point mirror or a negative
1931    /// scale names, at every `(u, v)`, the image of the point it named.
1932    #[test]
1933    fn a_mirrored_offset_surface_is_the_image_of_the_original() {
1934        use crate::Surface as _;
1935        use ogeom_math::{Direction, Point, Sphere, Transform};
1936        let sphere: SurfaceGeometry =
1937            SphereSurface::new(Sphere::new(Frame::WORLD, 20.0, T).unwrap()).into();
1938        let offset = SurfaceGeometry::Offset(Box::new(OffsetSurface::new(sphere, 3.0).unwrap()));
1939        for t in [
1940            Transform::plane_mirror(Point::ORIGIN, Direction::X),
1941            Transform::point_mirror(Point::new(1.0, 2.0, 3.0)),
1942            Transform::scaling(Point::ORIGIN, -2.0, T).unwrap(),
1943        ] {
1944            let moved = offset.transformed(&t, T).unwrap();
1945            for (u, v) in [(0.0, 0.0), (0.5, 0.3), (2.0, -1.0), (4.0, 1.2)] {
1946                let want = t.apply(offset.point_at(u, v, T).unwrap());
1947                let got = moved.point_at(u, v, T).unwrap();
1948                assert!(
1949                    got.distance(want) < 1e-9,
1950                    "{t:?} at ({u}, {v}): {got:?} against {want:?}"
1951                );
1952            }
1953        }
1954    }
1955
1956    /// A mirrored or point-mirrored surface of revolution names, at every
1957    /// `(u, v)`, the image of the point the original named there.
1958    #[test]
1959    fn a_mirrored_revolution_is_the_image_of_the_original() {
1960        use crate::Surface as _;
1961        use crate::curve::LineCurve;
1962        use ogeom_math::{Direction, Point, Transform};
1963        let profile: Curve =
1964            LineCurve::segment(Point::new(10.0, 0.0, 0.0), Point::new(20.0, 0.0, 5.0), T)
1965                .unwrap()
1966                .into();
1967        let original: SurfaceGeometry = RevolutionSurface::new(profile, Axis::Z, 1.5)
1968            .unwrap()
1969            .into();
1970        for t in [
1971            Transform::plane_mirror(Point::ORIGIN, Direction::X),
1972            Transform::point_mirror(Point::new(1.0, 2.0, 3.0)),
1973            Transform::rotation(ogeom_math::Axis::new(Point::ORIGIN, Direction::Y), 0.7),
1974        ] {
1975            let moved = original.transformed(&t, T).unwrap();
1976            for (u, v) in [(0.0, 0.0), (0.5, 3.0), (1.2, 7.5), (1.5, 11.0)] {
1977                let want = t.apply(original.point_at(u, v, T).unwrap());
1978                let got = moved.point_at(u, v, T).unwrap();
1979                assert!(
1980                    got.distance(want) < 1e-9,
1981                    "{t:?} at ({u}, {v}): {got:?} against {want:?}"
1982                );
1983            }
1984        }
1985    }
1986
1987    /// A surface that closes on itself evaluates past its join as a
1988    /// periodic one would.
1989    ///
1990    /// A clamped tube whose first and last control columns coincide has
1991    /// the same points at both ends of `u` and repeats nowhere. A ring that
1992    /// runs right round it spans exactly a period, so wherever it is slid
1993    /// some of it lies past the end, and a refusal there leaves the face
1994    /// unmeshable. A parameter a period past the end names a point the
1995    /// surface has, and is answered with it.
1996    #[test]
1997    fn a_closed_surface_wraps_a_parameter_past_its_join() {
1998        use crate::Surface as _;
1999        use ogeom_math::{ControlGrid, KnotVector, Point};
2000        let ring = [
2001            Point::new(1.0, 0.0, 0.0),
2002            Point::new(0.0, 1.0, 0.0),
2003            Point::new(-1.0, 0.0, 0.0),
2004            Point::new(0.0, -1.0, 0.0),
2005            Point::new(1.0, 0.0, 0.0),
2006        ];
2007        let mut control = Vec::new();
2008        for p in &ring {
2009            for z in [0.0, 5.0] {
2010                control.push(Point::new(p.x, p.y, z));
2011            }
2012        }
2013        let tube = BSplineSurface::new(
2014            KnotVector::new(vec![0.0, 0.0, 0.25, 0.5, 0.75, 1.0, 1.0], 1).unwrap(),
2015            KnotVector::clamped_uniform(1, 2).unwrap(),
2016            &ControlGrid::new(control, 5, 2).unwrap(),
2017            T,
2018        )
2019        .unwrap();
2020        assert!(
2021            tube.is_closed_u(T) && !tube.is_periodic_u(),
2022            "closed, not periodic"
2023        );
2024        let inside = tube.point_at(0.3, 0.5, T).unwrap();
2025        let past = tube.point_at(1.3, 0.5, T).unwrap();
2026        let before = tube.point_at(-0.7, 0.5, T).unwrap();
2027        assert!(
2028            inside.is_equal(past, T),
2029            "a period past the end: {inside:?} vs {past:?}"
2030        );
2031        assert!(
2032            inside.is_equal(before, T),
2033            "a period before the start: {inside:?} vs {before:?}"
2034        );
2035        // Along `v` the tube is open, and past its end is still past it.
2036        assert!(
2037            tube.point_at(0.3, 1.5, T).is_err(),
2038            "an open direction still refuses"
2039        );
2040    }
2041
2042    use super::*;
2043    use crate::curve::{CircleCurve, LineCurve};
2044    use approx::assert_relative_eq;
2045    use ogeom_math::{Circle, Frame};
2046
2047    const T: Tolerances = Tolerances::millimetres();
2048
2049    #[test]
2050    fn a_jet_agrees_with_the_separate_accessors() {
2051        // `jet_at` answers in one evaluation what three accessors answer in
2052        // three. Its contract is agreement to rounding, not to the bit: a
2053        // patch sums its point by de Boor and its derivatives by basis
2054        // functions, and those reassociate differently. Held to a relative
2055        // ulp-scale bound, which a real disagreement would blow through by
2056        // orders of magnitude.
2057        let close = |a: Vector, b: Vector, what: &str| {
2058            let scale = a.magnitude().max(b.magnitude()).max(1.0);
2059            assert!(
2060                (a - b).magnitude() <= scale * 1e-12,
2061                "{what}: {a:?} against {b:?}"
2062            );
2063        };
2064        let surfaces: Vec<SurfaceGeometry> = vec![
2065            SurfaceGeometry::Plane(PlaneSurface::new(ogeom_math::Plane::XY)),
2066            SurfaceGeometry::Sphere(SphereSurface::new(
2067                ogeom_math::Sphere::new(ogeom_math::Frame::WORLD, 3.0, T).unwrap(),
2068            )),
2069            bspline_patch(),
2070        ];
2071        for surface in &surfaces {
2072            let ((ua, ub), (va, vb)) = surface.domain();
2073            for i in 1..5 {
2074                for j in 1..5 {
2075                    let u = ua + (ub - ua) * f64::from(i) / 5.0;
2076                    let v = va + (vb - va) * f64::from(j) / 5.0;
2077                    let jet = surface.jet_at(u, v, T).unwrap();
2078                    let point = surface.point_at(u, v, T).unwrap();
2079                    close(jet.point - Point::ORIGIN, point - Point::ORIGIN, "point");
2080                    let (du, dv) = surface.d1_at(u, v, T).unwrap();
2081                    close(jet.du, du, "du");
2082                    close(jet.dv, dv, "dv");
2083                    let (d2u, duv, d2v) = surface.d2_at(u, v, T).unwrap();
2084                    close(jet.d2u, d2u, "d2u");
2085                    close(jet.duv, duv, "duv");
2086                    close(jet.d2v, d2v, "d2v");
2087                }
2088            }
2089        }
2090    }
2091
2092    /// A bicubic patch with a bulge, so its derivatives are not degenerate.
2093    fn bspline_patch() -> SurfaceGeometry {
2094        let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], 3).unwrap();
2095        let mut points = Vec::new();
2096        for i in 0..4 {
2097            for j in 0..4 {
2098                let z = if i == 1 && j == 2 { 2.0 } else { 0.0 };
2099                points.push(Point::new(f64::from(i), f64::from(j), z));
2100            }
2101        }
2102        let grid = ControlGrid::new(points, 4, 4).unwrap();
2103        SurfaceGeometry::BSpline(BSplineSurface::new(knots.clone(), knots, &grid, T).unwrap())
2104    }
2105
2106    #[test]
2107    fn an_offset_cylinder_is_the_larger_cylinder() {
2108        use ogeom_math::Cylinder;
2109        let basis = SurfaceGeometry::Cylinder(
2110            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 5.0)).unwrap(),
2111        );
2112        let offset = OffsetSurface::new(basis, 1.5).unwrap();
2113        // The cylinder's outward normal is radial, so every offset point
2114        // stands at the grown radius, at the same height.
2115        for (u, v) in [(0.0, 0.0), (1.0, 2.0), (3.5, 4.5)] {
2116            let p = offset.point_at(u, v, T).unwrap();
2117            let radial = (p.x * p.x + p.y * p.y).sqrt();
2118            assert_relative_eq!(radial, 3.5, epsilon = 1e-12);
2119        }
2120        // Exact first derivatives against differencing the exact points.
2121        let h = 1e-6;
2122        let (du, dv) = offset.d1_at(1.0, 2.0, T).unwrap();
2123        let fdu = (offset.point_at(1.0 + h, 2.0, T).unwrap()
2124            - offset.point_at(1.0 - h, 2.0, T).unwrap())
2125            / (2.0 * h);
2126        let fdv = (offset.point_at(1.0, 2.0 + h, T).unwrap()
2127            - offset.point_at(1.0, 2.0 - h, T).unwrap())
2128            / (2.0 * h);
2129        assert_relative_eq!((du - fdu).magnitude(), 0.0, epsilon = 1e-5);
2130        assert_relative_eq!((dv - fdv).magnitude(), 0.0, epsilon = 1e-5);
2131        // The second derivative refuses by name.
2132        assert!(offset.d2_at(1.0, 2.0, T).is_err());
2133    }
2134
2135    fn tilted() -> Frame {
2136        Frame::new(
2137            Point::new(1.0, -2.0, 3.0),
2138            Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
2139            Direction::X,
2140            T,
2141        )
2142        .unwrap()
2143    }
2144
2145    /// A patch continued past a side keeps its own run and continues it:
2146    /// a rational cylinder patch continued along its axis and round its
2147    /// circle stays on the cylinder both ways, and the fitted patch keeps
2148    /// every point it had.
2149    #[test]
2150    fn a_patch_extended_stays_itself_and_continues() {
2151        use crate::Surface as _;
2152        let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 5.0, T).unwrap();
2153        let wall =
2154            SurfaceGeometry::Cylinder(crate::CylinderSurface::new(cylinder, (0.0, 10.0)).unwrap())
2155                .to_bspline(T)
2156                .unwrap();
2157        let ((ua, ub), (va, vb)) = wall.domain();
2158        for (along_u, at_end) in [(true, true), (true, false), (false, true), (false, false)] {
2159            let longer = wall.extended(along_u, at_end, 3.0, 2, T).unwrap();
2160            let ((la, lb), (ma, mb)) = longer.domain();
2161            assert!(
2162                (la <= ua && lb >= ub && ma <= va && mb >= vb)
2163                    && ((la < ua) || (lb > ub) || (ma < va) || (mb > vb)),
2164                "the domain grows: {:?}",
2165                longer.domain()
2166            );
2167            for i in 0..=6 {
2168                for j in 0..=6 {
2169                    let u = la + (lb - la) * f64::from(i) / 6.0;
2170                    let v = ma + (mb - ma) * f64::from(j) / 6.0;
2171                    let p = longer.point_at(u, v, T).unwrap();
2172                    let radial = (p.x * p.x + p.y * p.y).sqrt();
2173                    assert!(
2174                        (radial - 5.0).abs() < 1e-9,
2175                        "off the cylinder at ({u},{v}): {p:?}"
2176                    );
2177                    if (ua..=ub).contains(&u) && (va..=vb).contains(&v) {
2178                        let was = wall.point_at(u, v, T).unwrap();
2179                        assert!(was.distance(p) < 1e-9, "the wall itself at ({u},{v})");
2180                    }
2181                }
2182            }
2183            if !along_u {
2184                let reach = if at_end { mb } else { ma };
2185                let p = longer.point_at(ua, reach, T).unwrap();
2186                assert!(
2187                    (p.z - if at_end { 13.0 } else { -3.0 }).abs() < 1e-9,
2188                    "the axial continuation reaches the length asked: {p:?}"
2189                );
2190            }
2191        }
2192        let fitted = patch();
2193        let ((ua, ub), (va, vb)) = fitted.domain();
2194        let longer = fitted.extended(true, true, 1.0, 2, T).unwrap();
2195        for i in 0..=5 {
2196            for j in 0..=5 {
2197                let u = ua + (ub - ua) * f64::from(i) / 5.0;
2198                let v = va + (vb - va) * f64::from(j) / 5.0;
2199                let (was, now) = (
2200                    fitted.point_at(u, v, T).unwrap(),
2201                    longer.point_at(u, v, T).unwrap(),
2202                );
2203                assert!(was.distance(now) < 1e-9, "the patch itself at ({u},{v})");
2204            }
2205        }
2206    }
2207
2208    /// An iso-curve lifted off the control net is the surface's own trace
2209    /// up that column, point for point.
2210    #[test]
2211    fn an_iso_curve_traces_the_surface_exactly() {
2212        use crate::Curve3d as _;
2213        let surface = patch();
2214        let ((ua, ub), (va, vb)) = surface.domain();
2215        for frac in [0.1, 0.5, 0.83] {
2216            let u = ua + (ub - ua) * frac;
2217            let column = surface.iso_u_curve(u, T).unwrap();
2218            let v = va + (vb - va) * frac;
2219            let row = surface.iso_v_curve(v, T).unwrap();
2220            for k in 0..=10 {
2221                let f = f64::from(k) / 10.0;
2222                let vv = va + (vb - va) * f;
2223                let on = surface.point_at(u, vv, T).unwrap();
2224                let along = column.point_at(vv, T).unwrap();
2225                assert!(
2226                    on.distance(along) < 1e-9,
2227                    "column at u {u}: {on:?} vs {along:?}"
2228                );
2229                let uu = ua + (ub - ua) * f;
2230                let on = surface.point_at(uu, v, T).unwrap();
2231                let along = row.point_at(uu, T).unwrap();
2232                assert!(
2233                    on.distance(along) < 1e-9,
2234                    "row at v {v}: {on:?} vs {along:?}"
2235                );
2236            }
2237        }
2238    }
2239
2240    fn patch() -> BSplineSurface {
2241        let (nu, nv) = (5, 4);
2242        let mut points = Vec::with_capacity(nu * nv);
2243        for i in 0..nu {
2244            for j in 0..nv {
2245                #[allow(clippy::cast_precision_loss)]
2246                let (x, y) = (i as f64, j as f64);
2247                points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos()));
2248            }
2249        }
2250        BSplineSurface::new(
2251            KnotVector::clamped_uniform(3, nu).unwrap(),
2252            KnotVector::clamped_uniform(2, nv).unwrap(),
2253            &ControlGrid::new(points, nu, nv).unwrap(),
2254            T,
2255        )
2256        .unwrap()
2257    }
2258
2259    fn every_surface() -> Vec<SurfaceGeometry> {
2260        let circle: Curve = CircleCurve::new(
2261            Circle::new(
2262                Frame::new(Point::new(5.0, 0.0, 0.0), Direction::Y, Direction::X, T).unwrap(),
2263                1.0,
2264                T,
2265            )
2266            .unwrap(),
2267        )
2268        .into();
2269        let line: Curve =
2270            LineCurve::segment(Point::new(2.0, 0.0, 0.0), Point::new(2.0, 0.0, 4.0), T)
2271                .unwrap()
2272                .into();
2273        vec![
2274            PlaneSurface::over(Plane::new(tilted()), (-5.0, 5.0), (-3.0, 3.0))
2275                .unwrap()
2276                .into(),
2277            CylinderSurface::new(Cylinder::new(tilted(), 2.0, T).unwrap(), (-4.0, 4.0))
2278                .unwrap()
2279                .into(),
2280            ConeSurface::new(Cone::new(tilted(), 3.0, 0.6, T).unwrap(), (-1.0, 5.0))
2281                .unwrap()
2282                .into(),
2283            SphereSurface::new(Sphere::new(tilted(), 4.0, T).unwrap()).into(),
2284            TorusSurface::new(Torus::new(tilted(), 5.0, 2.0, T).unwrap()).into(),
2285            patch().into(),
2286            RevolutionSurface::new(circle, Axis::Z, TAU).unwrap().into(),
2287            ExtrusionSurface::new(line, Direction::X, 3.0)
2288                .unwrap()
2289                .into(),
2290            TrimmedSurface::new(patch().into(), (0.2, 0.8), (0.3, 0.7), T)
2291                .unwrap()
2292                .into(),
2293        ]
2294    }
2295
2296    /// Interior sample parameters, avoiding the domain edges *and* the tidy
2297    /// fractions where a spline's interior knots sit.
2298    ///
2299    /// At a knot a spline's second derivative is one-sided, while a central
2300    /// difference straddles two different polynomial pieces, so a comparison
2301    /// there measures the discontinuity rather than the derivative. The offset
2302    /// keeps samples clear of knots at halves, thirds and quarters.
2303    fn interior(s: &SurfaceGeometry, n: usize) -> Vec<(f64, f64)> {
2304        const OFF: f64 = 0.0413;
2305        let ((ua, ub), (va, vb)) = s.domain();
2306        let mut out = Vec::new();
2307        for i in 1..n {
2308            for j in 1..n {
2309                #[allow(clippy::cast_precision_loss)]
2310                let (tu, tv) = (i as f64 / n as f64 + OFF, j as f64 / n as f64 + OFF);
2311                out.push((ua + (ub - ua) * tu, va + (vb - va) * tv));
2312            }
2313        }
2314        out
2315    }
2316
2317    #[test]
2318    fn every_surfaces_first_partials_agree_with_finite_differences() {
2319        let h = 1e-6;
2320        for s in every_surface() {
2321            for (u, v) in interior(&s, 5) {
2322                let (du, dv) = s.d1_at(u, v, T).unwrap();
2323                let nu = (s.point_at(u + h, v, T).unwrap() - s.point_at(u - h, v, T).unwrap())
2324                    * (1.0 / (2.0 * h));
2325                let nv = (s.point_at(u, v + h, T).unwrap() - s.point_at(u, v - h, T).unwrap())
2326                    * (1.0 / (2.0 * h));
2327                assert!(
2328                    (du - nu).magnitude() <= 1e-5 * nu.magnitude().max(1.0),
2329                    "{:?} du at ({u}, {v}): {du:?} vs {nu:?}",
2330                    s.kind()
2331                );
2332                assert!(
2333                    (dv - nv).magnitude() <= 1e-5 * nv.magnitude().max(1.0),
2334                    "{:?} dv at ({u}, {v}): {dv:?} vs {nv:?}",
2335                    s.kind()
2336                );
2337            }
2338        }
2339    }
2340
2341    #[test]
2342    fn every_surfaces_second_partials_agree_with_finite_differences() {
2343        let h = 1e-5;
2344        for s in every_surface() {
2345            for (u, v) in interior(&s, 4) {
2346                let (d2u, duv, d2v) = s.d2_at(u, v, T).unwrap();
2347
2348                let nuu = (s.point_at(u + h, v, T).unwrap().to_vector()
2349                    - s.point_at(u, v, T).unwrap().to_vector() * 2.0
2350                    + s.point_at(u - h, v, T).unwrap().to_vector())
2351                    * (1.0 / (h * h));
2352                let nvv = (s.point_at(u, v + h, T).unwrap().to_vector()
2353                    - s.point_at(u, v, T).unwrap().to_vector() * 2.0
2354                    + s.point_at(u, v - h, T).unwrap().to_vector())
2355                    * (1.0 / (h * h));
2356                let nuv = (s.point_at(u + h, v + h, T).unwrap()
2357                    - s.point_at(u + h, v - h, T).unwrap()
2358                    - (s.point_at(u - h, v + h, T).unwrap()
2359                        - s.point_at(u - h, v - h, T).unwrap()))
2360                    * (1.0 / (4.0 * h * h));
2361
2362                for (analytic, numeric, name) in
2363                    [(d2u, nuu, "d2u"), (duv, nuv, "duv"), (d2v, nvv, "d2v")]
2364                {
2365                    assert!(
2366                        (analytic - numeric).magnitude() <= 1e-3 * numeric.magnitude().max(1.0),
2367                        "{:?} {name} at ({u}, {v}): {analytic:?} vs {numeric:?}",
2368                        s.kind()
2369                    );
2370                }
2371            }
2372        }
2373    }
2374
2375    #[test]
2376    fn out_of_domain_parameters_are_refused_where_the_surface_is_not_periodic() {
2377        for s in every_surface() {
2378            let ((ua, ub), (va, vb)) = s.domain();
2379            let inside = ((ua + ub) / 2.0, (va + vb) / 2.0);
2380            if s.kind() == SurfaceKind::Plane {
2381                // A plane's window is where its face was built. The plane
2382                // goes on past it.
2383                let p = s.point_at(ub + 1.0, inside.1, T).unwrap();
2384                let q = s.point_at(ub, inside.1, T).unwrap();
2385                assert!((p.distance(q) - 1.0).abs() < 1e-12);
2386                continue;
2387            }
2388            if s.is_periodic_u() {
2389                assert!(s.point_at(ub + 1.0, inside.1, T).is_ok(), "{:?}", s.kind());
2390            } else {
2391                assert!(s.point_at(ub + 1.0, inside.1, T).is_err(), "{:?}", s.kind());
2392            }
2393            if s.is_periodic_v() {
2394                assert!(s.point_at(inside.0, vb + 1.0, T).is_ok(), "{:?}", s.kind());
2395            } else {
2396                assert!(s.point_at(inside.0, vb + 1.0, T).is_err(), "{:?}", s.kind());
2397            }
2398        }
2399    }
2400
2401    #[test]
2402    fn periodic_parameters_wrap_to_the_same_point() {
2403        for s in every_surface() {
2404            let ((ua, ub), (va, vb)) = s.domain();
2405            let (u, v) = ((ua + ub) * 0.4, (va + vb) * 0.4);
2406            let base = s.point_at(u, v, T).unwrap();
2407            if s.is_periodic_u() {
2408                let wrapped = s.point_at(u + (ub - ua), v, T).unwrap();
2409                assert!(base.is_equal(wrapped, T), "{:?} u wrap", s.kind());
2410            }
2411            if s.is_periodic_v() {
2412                let wrapped = s.point_at(u, v + (vb - va), T).unwrap();
2413                assert!(base.is_equal(wrapped, T), "{:?} v wrap", s.kind());
2414            }
2415        }
2416    }
2417
2418    #[test]
2419    fn analytic_surfaces_contain_their_own_points() {
2420        // Cross-check against the independent distance functions in ogeom-math.
2421        let cyl = Cylinder::new(tilted(), 2.0, T).unwrap();
2422        let cone = Cone::new(tilted(), 3.0, 0.6, T).unwrap();
2423        let sph = Sphere::new(tilted(), 4.0, T).unwrap();
2424        let tor = Torus::new(tilted(), 5.0, 2.0, T).unwrap();
2425        let plane = Plane::new(tilted());
2426
2427        let cyl_s = CylinderSurface::new(cyl, (-4.0, 4.0)).unwrap();
2428        let cone_s = ConeSurface::new(cone, (-1.0, 5.0)).unwrap();
2429        let sph_s = SphereSurface::new(sph);
2430        let tor_s = TorusSurface::new(tor);
2431        let plane_s = PlaneSurface::over(plane, (-5.0, 5.0), (-3.0, 3.0)).unwrap();
2432
2433        for i in 1..6 {
2434            for j in 1..6 {
2435                let (tu, tv) = (f64::from(i) / 6.0, f64::from(j) / 6.0);
2436                assert!(
2437                    plane.contains(
2438                        plane_s
2439                            .point_at(-5.0 + 10.0 * tu, -3.0 + 6.0 * tv, T)
2440                            .unwrap(),
2441                        T
2442                    )
2443                );
2444                assert!(cyl.contains(cyl_s.point_at(TAU * tu, -4.0 + 8.0 * tv, T).unwrap(), T));
2445                assert!(cone.contains(cone_s.point_at(TAU * tu, -1.0 + 6.0 * tv, T).unwrap(), T));
2446                let half = core::f64::consts::FRAC_PI_2;
2447                assert!(
2448                    sph.contains(
2449                        sph_s
2450                            .point_at(TAU * tu, -half + core::f64::consts::PI * tv, T)
2451                            .unwrap(),
2452                        T
2453                    )
2454                );
2455                assert!(tor.contains(tor_s.point_at(TAU * tu, TAU * tv, T).unwrap(), T));
2456            }
2457        }
2458    }
2459
2460    #[test]
2461    fn normals_of_analytic_surfaces_match_their_own_definitions() {
2462        let sph = Sphere::new(tilted(), 4.0, T).unwrap();
2463        let s = SphereSurface::new(sph);
2464        for i in 1..6 {
2465            for j in 1..6 {
2466                let u = TAU * f64::from(i) / 6.0;
2467                let v = -1.2 + 2.4 * f64::from(j) / 6.0;
2468                let p = s.point_at(u, v, T).unwrap();
2469                assert!(
2470                    s.normal_at(u, v, T)
2471                        .unwrap()
2472                        .is_equal(sph.normal_at(p, T).unwrap(), T)
2473                );
2474            }
2475        }
2476    }
2477
2478    #[test]
2479    fn a_sphere_degenerates_at_its_poles_and_says_so() {
2480        let s = SphereSurface::new(Sphere::new(tilted(), 4.0, T).unwrap());
2481        let half = core::f64::consts::FRAC_PI_2;
2482        for pole in [-half, half] {
2483            assert!(s.is_degenerate_at(1.0, pole, T).unwrap());
2484            assert!(s.normal_at(1.0, pole, T).is_err());
2485        }
2486        assert!(!s.is_degenerate_at(1.0, 0.0, T).unwrap());
2487        assert!(s.normal_at(1.0, 0.0, T).is_ok());
2488    }
2489
2490    #[test]
2491    fn a_cone_degenerates_at_its_apex() {
2492        let cone = Cone::new(tilted(), 3.0, 0.6, T).unwrap();
2493        let apex_height = -3.0 / 0.6_f64.tan();
2494        let s = ConeSurface::new(cone, (apex_height, 5.0)).unwrap();
2495        assert!(s.is_degenerate_at(1.0, apex_height, T).unwrap());
2496        assert!(s.normal_at(1.0, apex_height, T).is_err());
2497        assert!(
2498            s.point_at(1.0, apex_height, T)
2499                .unwrap()
2500                .is_equal(cone.apex(), T)
2501        );
2502    }
2503
2504    #[test]
2505    fn revolving_a_circle_about_an_offset_axis_gives_a_torus() {
2506        // The strongest cross-check available here: two independent
2507        // constructions of the same surface must agree pointwise.
2508        let major = 5.0;
2509        let minor = 1.0;
2510        // The generator's normal is -Y, not +Y. A frame's second axis is
2511        // `z x x`, so a normal of +Y with an x of +X gives a y of -Z and winds
2512        // the circle backwards relative to the torus's v parameter. Getting
2513        // this wrong produces a surface that is the right shape and the wrong
2514        // parameterization, which is exactly the sort of mistake that survives
2515        // a visual check.
2516        let generator: Curve = CircleCurve::new(
2517            Circle::new(
2518                Frame::new(Point::new(major, 0.0, 0.0), -Direction::Y, Direction::X, T).unwrap(),
2519                minor,
2520                T,
2521            )
2522            .unwrap(),
2523        )
2524        .into();
2525        let revolved = RevolutionSurface::new(generator, Axis::Z, TAU).unwrap();
2526        let torus = TorusSurface::new(Torus::new(Frame::WORLD, major, minor, T).unwrap());
2527
2528        for i in 0..12 {
2529            for j in 0..12 {
2530                let u = TAU * f64::from(i) / 12.0;
2531                let v = TAU * f64::from(j) / 12.0;
2532                let a = revolved.point_at(u, v, T).unwrap();
2533                let b = torus.point_at(u, v, T).unwrap();
2534                assert!(a.is_equal(b, T), "at ({u}, {v}): {a:?} vs {b:?}");
2535            }
2536        }
2537    }
2538
2539    #[test]
2540    fn extruding_a_line_gives_a_plane_and_a_circle_gives_a_cylinder() {
2541        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(0.0, 4.0, 0.0), T)
2542            .unwrap()
2543            .into();
2544        let sheet = ExtrusionSurface::new(line, Direction::Z, 3.0).unwrap();
2545        let plane = Plane::new(Frame::WORLD);
2546        for i in 0..=4 {
2547            for j in 0..=4 {
2548                let p = sheet
2549                    .point_at(4.0 * f64::from(i) / 4.0, 3.0 * f64::from(j) / 4.0, T)
2550                    .unwrap();
2551                assert!(plane.distance_to(p) < 1e-12 || p.x.abs() < 1e-12);
2552            }
2553        }
2554
2555        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2556        let tube = ExtrusionSurface::new(circle, Direction::Z, 5.0).unwrap();
2557        let cylinder = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
2558        for i in 0..8 {
2559            for j in 0..=4 {
2560                let p = tube
2561                    .point_at(TAU * f64::from(i) / 8.0, 5.0 * f64::from(j) / 4.0, T)
2562                    .unwrap();
2563                assert!(cylinder.contains(p, T));
2564            }
2565        }
2566    }
2567
2568    #[test]
2569    fn degenerate_constructions_are_refused() {
2570        assert!(PlaneSurface::over(Plane::new(tilted()), (1.0, 1.0), (0.0, 1.0)).is_err());
2571        assert!(
2572            CylinderSurface::new(Cylinder::new(tilted(), 1.0, T).unwrap(), (2.0, 1.0)).is_err()
2573        );
2574        let circle: Curve = CircleCurve::new(Circle::new(tilted(), 1.0, T).unwrap()).into();
2575        assert!(RevolutionSurface::new(circle.clone(), Axis::Z, 0.0).is_err());
2576        assert!(RevolutionSurface::new(circle.clone(), Axis::Z, 7.0).is_err());
2577        assert!(RevolutionSurface::new(circle.clone(), Axis::Z, TAU).is_ok());
2578        assert!(ExtrusionSurface::new(circle.clone(), Direction::Z, 0.0).is_err());
2579        assert!(ExtrusionSurface::new(circle, Direction::Z, f64::NAN).is_err());
2580    }
2581
2582    #[test]
2583    fn trimming_is_bounds_checked_and_agrees_with_its_basis() {
2584        let basis: SurfaceGeometry = patch().into();
2585        assert!(TrimmedSurface::new(basis.clone(), (0.2, 0.8), (0.3, 0.7), T).is_ok());
2586        assert!(TrimmedSurface::new(basis.clone(), (0.8, 0.2), (0.3, 0.7), T).is_err());
2587        assert!(TrimmedSurface::new(basis.clone(), (-0.5, 0.8), (0.3, 0.7), T).is_err());
2588
2589        let trimmed = TrimmedSurface::new(basis.clone(), (0.2, 0.8), (0.3, 0.7), T).unwrap();
2590        assert_eq!(trimmed.domain(), ((0.2, 0.8), (0.3, 0.7)));
2591        for i in 0..=4 {
2592            for j in 0..=4 {
2593                let u = 0.2 + 0.6 * f64::from(i) / 4.0;
2594                let v = 0.3 + 0.4 * f64::from(j) / 4.0;
2595                assert!(
2596                    trimmed
2597                        .point_at(u, v, T)
2598                        .unwrap()
2599                        .is_equal(basis.point_at(u, v, T).unwrap(), T)
2600                );
2601            }
2602        }
2603        assert!(trimmed.point_at(0.1, 0.5, T).is_err());
2604    }
2605
2606    #[test]
2607    fn transforms_move_surfaces_and_preserve_their_kind() {
2608        let t =
2609            Transform::rotation(Axis::X, 0.7) * Transform::translation(Vector::new(1.0, 2.0, 3.0));
2610        for s in every_surface() {
2611            let moved = s.transformed(&t, T).unwrap();
2612            assert_eq!(moved.kind(), s.kind());
2613            for (u, v) in interior(&s, 4) {
2614                let expected = t.apply(s.point_at(u, v, T).unwrap());
2615                assert!(
2616                    moved.point_at(u, v, T).unwrap().is_equal(expected, T),
2617                    "{:?} at ({u}, {v})",
2618                    s.kind()
2619                );
2620            }
2621        }
2622    }
2623
2624    #[test]
2625    fn a_scaling_rescales_length_valued_parameters() {
2626        // A cylinder's v is a height, so it must rescale. Its u is an angle and
2627        // must not.
2628        let s: SurfaceGeometry =
2629            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 4.0))
2630                .unwrap()
2631                .into();
2632        let scaled = s
2633            .transformed(&Transform::scaling(Point::ORIGIN, 3.0, T).unwrap(), T)
2634            .unwrap();
2635        assert_eq!(scaled.domain(), ((0.0, TAU), (0.0, 12.0)));
2636        assert!(
2637            scaled
2638                .point_at(0.0, 12.0, T)
2639                .unwrap()
2640                .is_equal(Point::new(6.0, 0.0, 12.0), T)
2641        );
2642    }
2643
2644    #[test]
2645    fn a_rational_patch_is_recognized_and_a_uniformly_weighted_one_is_not() {
2646        let g = patch();
2647        assert!(!g.is_rational());
2648
2649        let w = core::f64::consts::FRAC_1_SQRT_2;
2650        let points: Vec<_> = [
2651            (Point::new(1.0, 0.0, 0.0), 1.0),
2652            (Point::new(1.0, 1.0, 0.0), w),
2653            (Point::new(0.0, 1.0, 0.0), 1.0),
2654            (Point::new(1.0, 0.0, 1.0), 1.0),
2655            (Point::new(1.0, 1.0, 1.0), w),
2656            (Point::new(0.0, 1.0, 1.0), 1.0),
2657        ]
2658        .iter()
2659        .map(|(p, w)| Weighted::new(*p, *w, T).unwrap())
2660        .collect();
2661        let arc = BSplineSurface::rational(
2662            KnotVector::clamped_uniform(1, 2).unwrap(),
2663            KnotVector::clamped_uniform(2, 3).unwrap(),
2664            ControlGrid::new(points, 2, 3).unwrap(),
2665        )
2666        .unwrap();
2667        assert!(arc.is_rational());
2668        // Each isoparametric line is an exact unit arc in the xy plane.
2669        for i in 0..=4 {
2670            for j in 0..=8 {
2671                let p = arc
2672                    .point_at(f64::from(i) / 4.0, f64::from(j) / 8.0, T)
2673                    .unwrap();
2674                assert_relative_eq!(p.x.hypot(p.y), 1.0, epsilon = 1e-14);
2675            }
2676        }
2677    }
2678
2679    #[test]
2680    fn closure_and_periodicity_are_reported_correctly() {
2681        let s = every_surface();
2682        let expect = [
2683            (SurfaceKind::Plane, false, false, false, false),
2684            (SurfaceKind::Cylinder, true, false, true, false),
2685            (SurfaceKind::Cone, true, false, true, false),
2686            (SurfaceKind::Sphere, true, false, true, false),
2687            (SurfaceKind::Torus, true, true, true, true),
2688            (SurfaceKind::BSpline, false, false, false, false),
2689            (SurfaceKind::Revolution, true, true, true, true),
2690            (SurfaceKind::Extrusion, false, false, false, false),
2691            (SurfaceKind::Trimmed, false, false, false, false),
2692        ];
2693        for (surface, (kind, cu, cv, pu, pv)) in s.iter().zip(expect) {
2694            assert_eq!(surface.kind(), kind);
2695            assert_eq!(surface.is_closed_u(T), cu, "{kind:?} closed u");
2696            assert_eq!(surface.is_closed_v(T), cv, "{kind:?} closed v");
2697            assert_eq!(surface.is_periodic_u(), pu, "{kind:?} periodic u");
2698            assert_eq!(surface.is_periodic_v(), pv, "{kind:?} periodic v");
2699        }
2700    }
2701
2702    #[test]
2703    fn a_sphere_is_not_periodic_in_latitude() {
2704        // Wrapping past a pole would land on the far side of the sphere, which
2705        // is a different point, so latitude stops rather than repeating.
2706        let s = SphereSurface::new(Sphere::new(Frame::WORLD, 1.0, T).unwrap());
2707        assert!(!s.is_periodic_v());
2708        assert!(s.point_at(0.0, core::f64::consts::PI, T).is_err());
2709    }
2710}