Skip to main content

ogeom_math/
quadric.rs

1//! Elementary surfaces: plane, cylinder, cone, sphere, torus.
2//!
3//! Each is described by a [`Frame`] and its size parameters. As with the conics
4//! the frame is not decoration: it fixes the parameterization, and therefore
5//! fixes where a cylinder's seam falls and which way its normal points.
6//!
7//! These five plus the plane cover the overwhelming majority of real mechanical
8//! geometry. Keeping them as exact analytic descriptions rather than converting
9//! everything to NURBS is what lets intersection take analytic shortcuts, lets
10//! measurement report a radius rather than a fitted approximation of one, and
11//! keeps files small.
12//!
13//! Evaluation and derivatives are in [`crate::elementary`]; this module holds
14//! the descriptions and the queries that follow directly from them.
15
16use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
17
18use crate::{Axis, Direction, Frame, Point, Transform, Vector};
19
20/// Reject a size parameter that cannot describe a real shape.
21fn check_positive(name: &str, value: f64, tol: Tolerances) -> OgeomResult<()> {
22    if !value.is_finite() || value <= tol.confusion() {
23        ogeom_bail!(Construction, "{name} {value} must be finite and positive");
24    }
25    Ok(())
26}
27
28/// An unbounded plane.
29///
30/// The frame's `z` is the normal; `x` and `y` span the surface and fix its
31/// parameterization.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Plane {
34    frame: Frame,
35}
36
37/// An unbounded circular cylinder, with the frame's `z` as its axis.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct Cylinder {
40    frame: Frame,
41    radius: f64,
42}
43
44/// An unbounded circular cone.
45///
46/// The frame's `z` is the axis and its origin sits on the reference circle, of
47/// radius [`Cone::reference_radius`]. The radius grows in `+z` for a positive
48/// half angle. The apex is the point where it reaches zero.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Cone {
51    frame: Frame,
52    reference_radius: f64,
53    half_angle: f64,
54}
55
56/// A sphere.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct Sphere {
59    frame: Frame,
60    radius: f64,
61}
62
63/// A torus, with the frame's `z` as its axis of revolution.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct Torus {
66    frame: Frame,
67    major_radius: f64,
68    minor_radius: f64,
69}
70
71/// How a torus's minor radius compares with its major radius.
72///
73/// The three cases are genuinely different surfaces, and an algorithm that
74/// assumes the first will produce nonsense on the others: a spindle torus
75/// self-intersects, and a horn torus is tangent to itself at the poles.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum TorusKind {
78    /// `minor < major`: a ring, with a hole.
79    Ring,
80    /// `minor == major`: the hole closes to a single point at each pole.
81    Horn,
82    /// `minor > major`: the surface passes through itself.
83    Spindle,
84}
85
86impl Plane {
87    /// The `xy` plane.
88    pub const XY: Self = Self {
89        frame: Frame::WORLD,
90    };
91
92    /// The plane of `frame`, with `frame`'s `z` as its normal.
93    #[must_use]
94    pub const fn new(frame: Frame) -> Self {
95        Self { frame }
96    }
97
98    /// The plane through `origin` with the given `normal`, parameterized
99    /// arbitrarily but deterministically.
100    #[must_use]
101    pub fn through(origin: Point, normal: Direction) -> Self {
102        Self {
103            frame: Frame::about(origin, normal),
104        }
105    }
106
107    /// The plane through three points, with the normal following the right-hand
108    /// rule around `a`, `b`, `c`.
109    ///
110    /// # Errors
111    ///
112    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the points
113    /// are collinear.
114    pub fn through_points(a: Point, b: Point, c: Point, tol: Tolerances) -> OgeomResult<Self> {
115        let normal = Direction::from_cross(b - a, c - a, tol)?;
116        Ok(Self::through(a, normal))
117    }
118
119    /// The frame positioning this plane.
120    #[must_use]
121    pub const fn frame(&self) -> Frame {
122        self.frame
123    }
124
125    /// A point on the plane.
126    #[must_use]
127    pub const fn origin(&self) -> Point {
128        self.frame.origin()
129    }
130
131    /// The normal.
132    #[must_use]
133    pub const fn normal(&self) -> Direction {
134        self.frame.z()
135    }
136
137    /// The signed distance from `p`, positive on the side the normal points to.
138    #[must_use]
139    pub fn signed_distance_to(&self, p: Point) -> f64 {
140        self.frame.signed_distance_to_plane(p)
141    }
142
143    /// The distance from `p`.
144    #[must_use]
145    pub fn distance_to(&self, p: Point) -> f64 {
146        self.signed_distance_to(p).abs()
147    }
148
149    /// The closest point on the plane to `p`.
150    #[must_use]
151    pub fn project(&self, p: Point) -> Point {
152        p - self.normal() * self.signed_distance_to(p)
153    }
154
155    /// Whether `p` lies on the plane within `tol.confusion()`.
156    #[must_use]
157    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
158        self.distance_to(p) <= tol.confusion()
159    }
160
161    /// This plane with its normal reversed.
162    #[must_use]
163    pub const fn reversed(&self) -> Self {
164        Self {
165            frame: self.frame.with_z_reversed(),
166        }
167    }
168
169    /// This plane moved by `t`.
170    ///
171    /// # Errors
172    ///
173    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
174    /// transformed frame is degenerate.
175    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
176        Ok(Self::new(t.apply_frame(&self.frame, tol)?))
177    }
178}
179
180impl Cylinder {
181    /// A cylinder of `radius` about `frame`'s `z` axis.
182    ///
183    /// # Errors
184    ///
185    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `radius` is
186    /// not finite and positive.
187    pub fn new(frame: Frame, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
188        check_positive("cylinder radius", radius, tol)?;
189        Ok(Self { frame, radius })
190    }
191
192    /// A cylinder about an axis, parameterized arbitrarily but
193    /// deterministically.
194    ///
195    /// # Errors
196    ///
197    /// As [`Cylinder::new`].
198    pub fn about(axis: Axis, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
199        Self::new(Frame::about(axis.location, axis.direction), radius, tol)
200    }
201
202    /// The frame positioning this cylinder.
203    #[must_use]
204    pub const fn frame(&self) -> Frame {
205        self.frame
206    }
207
208    /// The axis of revolution.
209    #[must_use]
210    pub const fn axis(&self) -> Axis {
211        self.frame.axis()
212    }
213
214    /// The radius.
215    #[must_use]
216    pub const fn radius(&self) -> f64 {
217        self.radius
218    }
219
220    /// The signed distance from `p`, negative inside.
221    #[must_use]
222    pub fn signed_distance_to(&self, p: Point) -> f64 {
223        self.axis().distance_to(p) - self.radius
224    }
225
226    /// The distance from `p` to the surface.
227    #[must_use]
228    pub fn distance_to(&self, p: Point) -> f64 {
229        self.signed_distance_to(p).abs()
230    }
231
232    /// Whether `p` lies on the surface within `tol.confusion()`.
233    #[must_use]
234    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
235        self.distance_to(p) <= tol.confusion()
236    }
237
238    /// The outward unit normal at the point of the surface nearest `p`.
239    ///
240    /// # Errors
241    ///
242    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` lies on
243    /// the axis, where the nearest point (and so the normal) is not unique.
244    pub fn normal_at(&self, p: Point, tol: Tolerances) -> OgeomResult<Direction> {
245        let axis = self.axis();
246        Direction::new(p - axis.project(p), tol)
247    }
248
249    /// The area of a section of this cylinder `height` long.
250    #[must_use]
251    pub fn lateral_area(&self, height: f64) -> f64 {
252        core::f64::consts::TAU * self.radius * height.abs()
253    }
254
255    /// The volume enclosed by a section `height` long.
256    #[must_use]
257    pub fn volume(&self, height: f64) -> f64 {
258        core::f64::consts::PI * self.radius * self.radius * height.abs()
259    }
260
261    /// This cylinder moved by `t`.
262    ///
263    /// # Errors
264    ///
265    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
266    /// transformed frame is degenerate.
267    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
268        Self::new(
269            t.apply_frame(&self.frame, tol)?,
270            self.radius * t.scale_factor().abs(),
271            tol,
272        )
273    }
274}
275
276impl Cone {
277    /// A cone with the given reference radius and half angle.
278    ///
279    /// The reference circle lies in `frame`'s `xy` plane. A positive half angle
280    /// widens the cone in `+z`.
281    ///
282    /// # Errors
283    ///
284    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
285    /// `reference_radius` is negative or non-finite, or if `half_angle` is not
286    /// strictly between `0` and `pi/2`. At zero the cone is a cylinder and at
287    /// `pi/2` it is a plane; both are different surfaces with their own types,
288    /// and admitting them here would produce a cone whose apex is at infinity.
289    pub fn new(
290        frame: Frame,
291        reference_radius: f64,
292        half_angle: f64,
293        tol: Tolerances,
294    ) -> OgeomResult<Self> {
295        if !reference_radius.is_finite() || reference_radius < 0.0 {
296            ogeom_bail!(
297                Construction,
298                "cone reference radius {reference_radius} must be finite and non-negative"
299            );
300        }
301        if !half_angle.is_finite()
302            || half_angle.abs() <= tol.angular()
303            || half_angle.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
304        {
305            ogeom_bail!(
306                Construction,
307                "cone half angle {half_angle} must lie strictly between 0 and pi/2"
308            );
309        }
310        Ok(Self {
311            frame,
312            reference_radius,
313            half_angle,
314        })
315    }
316
317    /// The frame positioning this cone.
318    #[must_use]
319    pub const fn frame(&self) -> Frame {
320        self.frame
321    }
322
323    /// The axis of revolution.
324    #[must_use]
325    pub const fn axis(&self) -> Axis {
326        self.frame.axis()
327    }
328
329    /// The radius of the circle in the frame's `xy` plane.
330    #[must_use]
331    pub const fn reference_radius(&self) -> f64 {
332        self.reference_radius
333    }
334
335    /// The half angle at the apex, in `(0, pi/2)`.
336    #[must_use]
337    pub const fn half_angle(&self) -> f64 {
338        self.half_angle
339    }
340
341    /// The apex.
342    #[must_use]
343    pub fn apex(&self) -> Point {
344        // The radius shrinks at `tan(half_angle)` per unit along the axis, so
345        // the apex is that many units back from the reference circle.
346        self.frame.origin() - self.frame.z() * (self.reference_radius / self.half_angle.tan())
347    }
348
349    /// The radius at signed distance `z` along the axis from the frame origin.
350    #[must_use]
351    pub fn radius_at(&self, z: f64) -> f64 {
352        self.half_angle.tan().mul_add(z, self.reference_radius)
353    }
354
355    /// The distance from `p` to the surface, ignoring the far nappe.
356    ///
357    /// A double cone extends both sides of its apex; this measures to the
358    /// surface as a whole, which is what a surface query means.
359    #[must_use]
360    pub fn distance_to(&self, p: Point) -> f64 {
361        let local = self.frame.to_local(p);
362        let radial = local.xy().to_vector().magnitude();
363        let apex_z = -self.reference_radius / self.half_angle.tan();
364        // A cone is a *double* cone: the quadric has two nappes meeting at the
365        // apex, and the surface type built on this parameterizes both; its
366        // height range may cross the apex, exactly as the conventional
367        // kernel's conical surface does. An earlier version measured one nappe
368        // and clamped everything past the apex to the apex, which reported a
369        // point *on* the second nappe as almost a unit away, and it was the
370        // intersection benchmark that caught it, by flagging a correctly
371        // traced curve as off the surface.
372        //
373        // In the (radial, axial) half-plane each nappe is a ray from the apex;
374        // the distance is the nearer of the two, each clamped to its own ray
375        // so a point in the wedge beyond the apex measures to the apex.
376        let (sin, cos) = self.half_angle.sin_cos();
377        let height = local.z - apex_z;
378        let apex_distance = radial.hypot(height);
379        let nappe = |along: f64, across: f64| {
380            if along <= 0.0 {
381                apex_distance
382            } else {
383                across.abs()
384            }
385        };
386        let up = nappe(
387            height.mul_add(cos, radial * sin),
388            height.mul_add(sin, -(radial * cos)),
389        );
390        let down = nappe(
391            height.mul_add(-cos, radial * sin),
392            height.mul_add(sin, radial * cos),
393        );
394        up.min(down)
395    }
396
397    /// Whether `p` lies on the surface within `tol.confusion()`.
398    #[must_use]
399    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
400        self.distance_to(p) <= tol.confusion()
401    }
402
403    /// This cone moved by `t`.
404    ///
405    /// # Errors
406    ///
407    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
408    /// transformed frame is degenerate.
409    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
410        // A similarity scales lengths uniformly, so the half angle survives it
411        // unchanged. That is exactly why the transform type is restricted to
412        // similarities: a non-uniform scale would leave a surface that is no
413        // longer a circular cone at all.
414        Self::new(
415            t.apply_frame(&self.frame, tol)?,
416            self.reference_radius * t.scale_factor().abs(),
417            self.half_angle,
418            tol,
419        )
420    }
421}
422
423impl Sphere {
424    /// A sphere of `radius` centred on `frame`'s origin.
425    ///
426    /// # Errors
427    ///
428    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `radius` is
429    /// not finite and positive.
430    pub fn new(frame: Frame, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
431        check_positive("sphere radius", radius, tol)?;
432        Ok(Self { frame, radius })
433    }
434
435    /// A sphere from a centre and a radius, parameterized arbitrarily but
436    /// deterministically.
437    ///
438    /// # Errors
439    ///
440    /// As [`Sphere::new`].
441    pub fn centred(centre: Point, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
442        Self::new(Frame::about(centre, Direction::Z), radius, tol)
443    }
444
445    /// The frame positioning this sphere.
446    #[must_use]
447    pub const fn frame(&self) -> Frame {
448        self.frame
449    }
450
451    /// The centre.
452    #[must_use]
453    pub const fn centre(&self) -> Point {
454        self.frame.origin()
455    }
456
457    /// The radius.
458    #[must_use]
459    pub const fn radius(&self) -> f64 {
460        self.radius
461    }
462
463    /// The signed distance from `p`, negative inside.
464    #[must_use]
465    pub fn signed_distance_to(&self, p: Point) -> f64 {
466        self.centre().distance(p) - self.radius
467    }
468
469    /// The distance from `p` to the surface.
470    #[must_use]
471    pub fn distance_to(&self, p: Point) -> f64 {
472        self.signed_distance_to(p).abs()
473    }
474
475    /// Whether `p` lies on the surface within `tol.confusion()`.
476    #[must_use]
477    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
478        self.distance_to(p) <= tol.confusion()
479    }
480
481    /// Whether `p` lies strictly inside.
482    #[must_use]
483    pub fn encloses(&self, p: Point, tol: Tolerances) -> bool {
484        self.signed_distance_to(p) < -tol.confusion()
485    }
486
487    /// The outward unit normal at the point nearest `p`.
488    ///
489    /// # Errors
490    ///
491    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` is the
492    /// centre, where no nearest point is unique.
493    pub fn normal_at(&self, p: Point, tol: Tolerances) -> OgeomResult<Direction> {
494        Direction::new(p - self.centre(), tol)
495    }
496
497    /// The surface area.
498    #[must_use]
499    pub fn area(&self) -> f64 {
500        4.0 * core::f64::consts::PI * self.radius * self.radius
501    }
502
503    /// The volume enclosed.
504    #[must_use]
505    pub fn volume(&self) -> f64 {
506        4.0 / 3.0 * core::f64::consts::PI * self.radius.powi(3)
507    }
508
509    /// This sphere moved by `t`.
510    ///
511    /// # Errors
512    ///
513    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
514    /// transformed frame is degenerate.
515    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
516        Self::new(
517            t.apply_frame(&self.frame, tol)?,
518            self.radius * t.scale_factor().abs(),
519            tol,
520        )
521    }
522}
523
524impl Torus {
525    /// A torus about `frame`'s `z` axis.
526    ///
527    /// `major_radius` is the distance from the axis to the centre of the tube;
528    /// `minor_radius` is the tube's own radius.
529    ///
530    /// # Errors
531    ///
532    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either
533    /// radius is not finite and positive. A minor radius exceeding the major is
534    /// *allowed*: that is a spindle torus, a real self-intersecting surface, and
535    /// [`Torus::kind`] reports which case this is.
536    pub fn new(
537        frame: Frame,
538        major_radius: f64,
539        minor_radius: f64,
540        tol: Tolerances,
541    ) -> OgeomResult<Self> {
542        check_positive("major radius", major_radius, tol)?;
543        check_positive("minor radius", minor_radius, tol)?;
544        Ok(Self {
545            frame,
546            major_radius,
547            minor_radius,
548        })
549    }
550
551    /// The frame positioning this torus.
552    #[must_use]
553    pub const fn frame(&self) -> Frame {
554        self.frame
555    }
556
557    /// The centre.
558    #[must_use]
559    pub const fn centre(&self) -> Point {
560        self.frame.origin()
561    }
562
563    /// The axis of revolution.
564    #[must_use]
565    pub const fn axis(&self) -> Axis {
566        self.frame.axis()
567    }
568
569    /// The distance from the axis to the centre of the tube.
570    #[must_use]
571    pub const fn major_radius(&self) -> f64 {
572        self.major_radius
573    }
574
575    /// The radius of the tube.
576    #[must_use]
577    pub const fn minor_radius(&self) -> f64 {
578        self.minor_radius
579    }
580
581    /// Which of the three topological cases this torus falls into.
582    #[must_use]
583    pub fn kind(&self, tol: Tolerances) -> TorusKind {
584        let difference = self.major_radius - self.minor_radius;
585        if difference.abs() <= tol.confusion() {
586            TorusKind::Horn
587        } else if difference > 0.0 {
588            TorusKind::Ring
589        } else {
590            TorusKind::Spindle
591        }
592    }
593
594    /// Whether the surface passes through itself.
595    #[must_use]
596    pub fn self_intersects(&self, tol: Tolerances) -> bool {
597        self.kind(tol) == TorusKind::Spindle
598    }
599
600    /// The signed distance from `p`: negative inside, magnitude the
601    /// distance to the nearest sheet.
602    ///
603    /// For a [`TorusKind::Ring`] or [`TorusKind::Horn`] torus, inside is
604    /// the tube. A [`TorusKind::Spindle`] torus passes through itself and
605    /// bounds two nested regions, and "inside" cannot name both; here it
606    /// names the *outer* one (the apple, the solid of revolution the
607    /// outer sheet bounds, which contains the lemon) because that is the
608    /// region the torus-as-a-solid occupies. The magnitude is measured to
609    /// whichever sheet is nearer, the folded inner sheet included, so a
610    /// point on either sheet reads zero.
611    #[must_use]
612    pub fn signed_distance_to(&self, p: Point) -> f64 {
613        let local = self.frame.to_local(p);
614        let radial = local.xy().to_vector().magnitude();
615        // The generating circle, and its fold about the axis. For a ring
616        // torus the folded branch is never nearer and this reduces to the
617        // classical tube distance.
618        let unfolded = (radial - self.major_radius).hypot(local.z) - self.minor_radius;
619        let folded = (radial + self.major_radius).hypot(local.z) - self.minor_radius;
620        let magnitude = unfolded.abs().min(folded.abs());
621        if unfolded < 0.0 {
622            -magnitude
623        } else {
624            magnitude
625        }
626    }
627
628    /// The distance from `p` to the surface.
629    ///
630    /// Correct for all three kinds. In the half-plane at a fixed azimuth, the
631    /// surface's profile is the generating circle *folded* about the axis: when
632    /// the minor radius exceeds the major, part of that circle lies on the far
633    /// side of the axis and sweeps to the near side. Measuring only to the
634    /// unfolded branch (which is what the signed form does) then reports a
635    /// point on the surface as being some distance off it.
636    #[must_use]
637    pub fn distance_to(&self, p: Point) -> f64 {
638        let local = self.frame.to_local(p);
639        let radial = local.xy().to_vector().magnitude();
640        let near = (radial - self.major_radius).hypot(local.z) - self.minor_radius;
641        let folded = (radial + self.major_radius).hypot(local.z) - self.minor_radius;
642        near.abs().min(folded.abs())
643    }
644
645    /// Whether `p` lies on the surface within `tol.confusion()`.
646    #[must_use]
647    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
648        self.distance_to(p) <= tol.confusion()
649    }
650
651    /// The surface area, for a ring torus.
652    ///
653    /// Meaningless for a spindle torus, whose surface overlaps itself.
654    #[must_use]
655    pub fn area(&self) -> f64 {
656        4.0 * core::f64::consts::PI * core::f64::consts::PI * self.major_radius * self.minor_radius
657    }
658
659    /// The volume enclosed, for a ring torus.
660    #[must_use]
661    pub fn volume(&self) -> f64 {
662        2.0 * core::f64::consts::PI
663            * core::f64::consts::PI
664            * self.major_radius
665            * self.minor_radius
666            * self.minor_radius
667    }
668
669    /// This torus moved by `t`.
670    ///
671    /// # Errors
672    ///
673    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
674    /// transformed frame is degenerate.
675    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
676        let s = t.scale_factor().abs();
677        Self::new(
678            t.apply_frame(&self.frame, tol)?,
679            self.major_radius * s,
680            self.minor_radius * s,
681            tol,
682        )
683    }
684}
685
686/// The unit normal to a plane through `origin` containing `a` and `b`.
687///
688/// # Errors
689///
690/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `a` and `b` are
691/// collinear.
692pub fn plane_normal(a: Vector, b: Vector, tol: Tolerances) -> OgeomResult<Direction> {
693    Direction::from_cross(a, b, tol)
694}
695
696#[cfg(test)]
697#[allow(clippy::unwrap_used)]
698mod tests {
699    use super::*;
700    use approx::assert_relative_eq;
701
702    const T: Tolerances = Tolerances::millimetres();
703
704    #[test]
705    fn plane_distance_is_signed_by_the_normal() {
706        let p = Plane::XY;
707        assert_relative_eq!(p.signed_distance_to(Point::new(1.0, 2.0, 3.0)), 3.0);
708        assert_relative_eq!(p.signed_distance_to(Point::new(1.0, 2.0, -3.0)), -3.0);
709        assert_relative_eq!(p.distance_to(Point::new(0.0, 0.0, -3.0)), 3.0);
710        assert!(p.contains(Point::new(9.0, -9.0, 0.0), T));
711        assert!(
712            p.project(Point::new(1.0, 2.0, 3.0))
713                .is_equal(Point::new(1.0, 2.0, 0.0), T)
714        );
715    }
716
717    #[test]
718    fn reversing_a_plane_flips_the_sign_but_not_the_surface() {
719        let p = Plane::XY;
720        let r = p.reversed();
721        let q = Point::new(0.0, 0.0, 5.0);
722        assert_relative_eq!(r.signed_distance_to(q), -p.signed_distance_to(q));
723        assert_relative_eq!(r.distance_to(q), p.distance_to(q));
724        assert!(r.contains(Point::new(1.0, 1.0, 0.0), T));
725    }
726
727    #[test]
728    fn plane_through_three_points_follows_the_right_hand_rule() {
729        let p = Plane::through_points(
730            Point::ORIGIN,
731            Point::new(1.0, 0.0, 0.0),
732            Point::new(0.0, 1.0, 0.0),
733            T,
734        )
735        .unwrap();
736        assert!(p.normal().is_equal(Direction::Z, T));
737        // Reversing the winding reverses the normal.
738        let q = Plane::through_points(
739            Point::ORIGIN,
740            Point::new(0.0, 1.0, 0.0),
741            Point::new(1.0, 0.0, 0.0),
742            T,
743        )
744        .unwrap();
745        assert!(q.normal().is_equal(-Direction::Z, T));
746        assert!(
747            Plane::through_points(
748                Point::ORIGIN,
749                Point::new(1.0, 1.0, 1.0),
750                Point::new(2.0, 2.0, 2.0),
751                T
752            )
753            .is_err()
754        );
755    }
756
757    #[test]
758    fn plane_through_tiny_triangles_still_works() {
759        // Same trap as the circumcircle: the cross product scales as the square
760        // of the triangle size.
761        let s = 1e-6;
762        let p = Plane::through_points(
763            Point::ORIGIN,
764            Point::new(s, 0.0, 0.0),
765            Point::new(0.0, s, 0.0),
766            T,
767        )
768        .unwrap();
769        assert!(p.normal().is_equal(Direction::Z, T));
770    }
771
772    #[test]
773    fn cylinder_distance_and_normal() {
774        let c = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
775        assert_relative_eq!(c.signed_distance_to(Point::new(3.0, 0.0, 100.0)), 1.0);
776        assert_relative_eq!(c.signed_distance_to(Point::new(1.0, 0.0, -50.0)), -1.0);
777        assert_relative_eq!(c.signed_distance_to(Point::ORIGIN), -2.0);
778        assert!(c.contains(Point::new(0.0, 2.0, 7.0), T));
779        assert!(
780            c.normal_at(Point::new(3.0, 0.0, 5.0), T)
781                .unwrap()
782                .is_equal(Direction::X, T)
783        );
784        // A point on the axis has no unique normal.
785        assert!(c.normal_at(Point::new(0.0, 0.0, 5.0), T).is_err());
786    }
787
788    #[test]
789    fn cylinder_measurements() {
790        let c = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
791        assert_relative_eq!(c.lateral_area(5.0), core::f64::consts::TAU * 10.0);
792        assert_relative_eq!(c.volume(5.0), core::f64::consts::PI * 20.0);
793        // Height is a magnitude; a negative one is the same section.
794        assert_relative_eq!(c.volume(-5.0), c.volume(5.0));
795    }
796
797    #[test]
798    fn cone_degenerate_angles_are_refused() {
799        let f = Frame::WORLD;
800        assert!(
801            Cone::new(f, 1.0, 0.0, T).is_err(),
802            "zero angle is a cylinder"
803        );
804        assert!(
805            Cone::new(f, 1.0, core::f64::consts::FRAC_PI_2, T).is_err(),
806            "a right angle is a plane"
807        );
808        assert!(Cone::new(f, 1.0, f64::NAN, T).is_err());
809        assert!(Cone::new(f, -1.0, 0.5, T).is_err());
810        // A zero reference radius is fine: the frame origin is then the apex.
811        assert!(Cone::new(f, 0.0, 0.5, T).is_ok());
812        assert!(Cone::new(f, 1.0, 0.5, T).is_ok());
813    }
814
815    #[test]
816    fn cone_apex_and_radius_profile() {
817        // Half angle of 45 degrees: radius grows one unit per unit of height.
818        let quarter = core::f64::consts::FRAC_PI_4;
819        let c = Cone::new(Frame::WORLD, 3.0, quarter, T).unwrap();
820        assert!(c.apex().is_equal(Point::new(0.0, 0.0, -3.0), T));
821        assert_relative_eq!(c.radius_at(0.0), 3.0, epsilon = 1e-12);
822        assert_relative_eq!(c.radius_at(2.0), 5.0, epsilon = 1e-12);
823        assert_relative_eq!(c.radius_at(-3.0), 0.0, epsilon = 1e-12);
824    }
825
826    #[test]
827    fn cone_distance_is_zero_on_the_surface() {
828        let quarter = core::f64::consts::FRAC_PI_4;
829        let c = Cone::new(Frame::WORLD, 3.0, quarter, T).unwrap();
830        for z in [-3.0_f64, -1.0, 0.0, 2.0, 10.0] {
831            let r = c.radius_at(z);
832            for angle in [0.0_f64, 1.0, 2.5] {
833                let p = Point::new(r * angle.cos(), r * angle.sin(), z);
834                assert!(c.contains(p, T), "z = {z}, distance = {}", c.distance_to(p));
835            }
836        }
837    }
838
839    #[test]
840    fn cone_distance_off_the_surface_is_perpendicular() {
841        let quarter = core::f64::consts::FRAC_PI_4;
842        let c = Cone::new(Frame::WORLD, 0.0, quarter, T).unwrap();
843        // Apex at the origin, opening along +z at 45 degrees. The point (1,0,0)
844        // sits at perpendicular distance sin(45) from the surface line.
845        assert_relative_eq!(
846            c.distance_to(Point::new(1.0, 0.0, 0.0)),
847            core::f64::consts::FRAC_1_SQRT_2,
848            epsilon = 1e-12
849        );
850        // A cone is a double cone: behind the apex is the second nappe, and a
851        // point on the axis there measures perpendicular to it, not to the
852        // apex. The earlier claim here (apex distance, 5.0) encoded a
853        // single-nappe convention that disagreed with the surface type built
854        // on this, and the intersection benchmark caught the disagreement by
855        // flagging a correctly traced second-nappe curve as off the surface.
856        assert_relative_eq!(
857            c.distance_to(Point::new(0.0, 0.0, -5.0)),
858            5.0 * core::f64::consts::FRAC_1_SQRT_2,
859            epsilon = 1e-12
860        );
861        // A point *on* the second nappe is on the cone.
862        assert_relative_eq!(
863            c.distance_to(Point::new(2.0, 0.0, -2.0)),
864            0.0,
865            epsilon = 1e-12
866        );
867    }
868
869    #[test]
870    fn sphere_queries() {
871        let s = Sphere::centred(Point::new(1.0, 2.0, 3.0), 5.0, T).unwrap();
872        assert_relative_eq!(s.signed_distance_to(Point::new(1.0, 2.0, 3.0)), -5.0);
873        assert_relative_eq!(s.signed_distance_to(Point::new(6.0, 2.0, 3.0)), 0.0);
874        assert_relative_eq!(s.signed_distance_to(Point::new(11.0, 2.0, 3.0)), 5.0);
875        assert!(s.encloses(s.centre(), T));
876        assert!(!s.encloses(Point::new(6.0, 2.0, 3.0), T));
877        assert!(s.contains(Point::new(6.0, 2.0, 3.0), T));
878        assert!(
879            s.normal_at(Point::new(6.0, 2.0, 3.0), T)
880                .unwrap()
881                .is_equal(Direction::X, T)
882        );
883        assert!(s.normal_at(s.centre(), T).is_err());
884    }
885
886    #[test]
887    fn sphere_measurements() {
888        let s = Sphere::centred(Point::ORIGIN, 3.0, T).unwrap();
889        assert_relative_eq!(s.area(), 4.0 * core::f64::consts::PI * 9.0);
890        assert_relative_eq!(s.volume(), 4.0 / 3.0 * core::f64::consts::PI * 27.0);
891    }
892
893    #[test]
894    fn torus_kinds_are_distinguished() {
895        let f = Frame::WORLD;
896        assert_eq!(Torus::new(f, 5.0, 1.0, T).unwrap().kind(T), TorusKind::Ring);
897        assert_eq!(Torus::new(f, 5.0, 5.0, T).unwrap().kind(T), TorusKind::Horn);
898        assert_eq!(
899            Torus::new(f, 5.0, 8.0, T).unwrap().kind(T),
900            TorusKind::Spindle
901        );
902        assert!(Torus::new(f, 5.0, 8.0, T).unwrap().self_intersects(T));
903        assert!(!Torus::new(f, 5.0, 1.0, T).unwrap().self_intersects(T));
904        // A spindle torus is a real surface and must be constructible.
905        assert!(Torus::new(f, 1.0, 2.0, T).is_ok());
906        assert!(Torus::new(f, 0.0, 1.0, T).is_err());
907    }
908
909    #[test]
910    fn spindle_torus_distance_accounts_for_the_folded_branch() {
911        // Minor radius exceeds major: the generating circle crosses the axis,
912        // so part of the surface comes from the far side of it. A point there
913        // is on the surface, and the unfolded formula alone would not say so.
914        let t = Torus::new(Frame::WORLD, 1.0, 3.0, T).unwrap();
915        assert_eq!(t.kind(T), TorusKind::Spindle);
916
917        // Parametric point with `major + minor*cos(v) < 0`, which lands on the
918        // folded branch.
919        let v = core::f64::consts::PI;
920        let radial = 1.0 + 3.0 * v.cos(); // = -2
921        let p = Point::new(radial.abs(), 0.0, 3.0 * v.sin());
922        assert!(t.contains(p, T), "distance was {}", t.distance_to(p));
923
924        // The ring case is unaffected: near branch still wins everywhere.
925        let ring = Torus::new(Frame::WORLD, 5.0, 2.0, T).unwrap();
926        for p in [
927            Point::new(7.0, 0.0, 0.0),
928            Point::new(3.0, 0.0, 0.0),
929            Point::new(5.0, 0.0, 2.0),
930        ] {
931            assert_relative_eq!(ring.distance_to(p), 0.0, epsilon = 1e-12);
932        }
933    }
934
935    #[test]
936    fn torus_distance_and_measurements() {
937        let t = Torus::new(Frame::WORLD, 5.0, 2.0, T).unwrap();
938        // Outer equator, inner equator, and the top of the tube.
939        assert!(t.contains(Point::new(7.0, 0.0, 0.0), T));
940        assert!(t.contains(Point::new(3.0, 0.0, 0.0), T));
941        assert!(t.contains(Point::new(5.0, 0.0, 2.0), T));
942        // The centre of the tube is one tube radius inside.
943        assert_relative_eq!(t.signed_distance_to(Point::new(5.0, 0.0, 0.0)), -2.0);
944        // The centre of the hole is far outside the surface.
945        assert_relative_eq!(t.signed_distance_to(Point::ORIGIN), 3.0);
946
947        let pi2 = core::f64::consts::PI * core::f64::consts::PI;
948        assert_relative_eq!(t.area(), 4.0 * pi2 * 10.0);
949        assert_relative_eq!(t.volume(), 2.0 * pi2 * 5.0 * 4.0);
950    }
951
952    #[test]
953    fn transforms_scale_sizes_and_preserve_shape() {
954        let scale = Transform::scaling(Point::ORIGIN, 3.0, T).unwrap();
955
956        let c = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
957        assert_relative_eq!(
958            c.transformed(&scale, T).unwrap().radius(),
959            6.0,
960            epsilon = 1e-12
961        );
962
963        let s = Sphere::centred(Point::new(1.0, 0.0, 0.0), 2.0, T).unwrap();
964        let moved = s.transformed(&scale, T).unwrap();
965        assert_relative_eq!(moved.radius(), 6.0, epsilon = 1e-12);
966        assert!(moved.centre().is_equal(Point::new(3.0, 0.0, 0.0), T));
967
968        let t = Torus::new(Frame::WORLD, 5.0, 2.0, T).unwrap();
969        let scaled = t.transformed(&scale, T).unwrap();
970        assert_relative_eq!(scaled.major_radius(), 15.0, epsilon = 1e-12);
971        assert_relative_eq!(scaled.minor_radius(), 6.0, epsilon = 1e-12);
972
973        // A similarity leaves a cone's half angle alone: the reason transforms
974        // are restricted to similarities in the first place.
975        let cone = Cone::new(Frame::WORLD, 3.0, 0.5, T).unwrap();
976        let big = cone.transformed(&scale, T).unwrap();
977        assert_relative_eq!(big.half_angle(), 0.5);
978        assert_relative_eq!(big.reference_radius(), 9.0, epsilon = 1e-12);
979    }
980
981    #[test]
982    fn transformed_surfaces_still_contain_their_transformed_points() {
983        let t =
984            Transform::rotation(Axis::X, 0.7) * Transform::translation(Vector::new(1.0, 2.0, 3.0));
985        let s = Sphere::centred(Point::ORIGIN, 4.0, T).unwrap();
986        let moved = s.transformed(&t, T).unwrap();
987        let on_surface = Point::new(4.0, 0.0, 0.0);
988        assert!(s.contains(on_surface, T));
989        assert!(moved.contains(t.apply(on_surface), T));
990    }
991}
992
993#[cfg(test)]
994#[allow(clippy::unwrap_used, clippy::expect_used)]
995mod spindle_tests {
996    use super::*;
997    use approx::assert_relative_eq;
998
999    const T: Tolerances = Tolerances::millimetres();
1000
1001    /// The spindle's signed distance names the apple: negative through the
1002    /// whole enclosed solid, zero on either sheet, positive beyond, and a
1003    /// ring torus keeps its classical values exactly.
1004    #[test]
1005    fn a_spindle_signs_its_apple_and_a_ring_is_unchanged() {
1006        let f = Frame::WORLD;
1007        let spindle = Torus::new(f, 2.0, 5.0, T).unwrap();
1008        // The axis point: inside, three units from the folded sheet.
1009        assert_relative_eq!(
1010            spindle.signed_distance_to(Point::new(0.0, 0.0, 0.0)),
1011            -3.0,
1012            epsilon = 1e-12
1013        );
1014        // Where the lemon's sheet crosses the axis: on the surface.
1015        let cusp_z = (5.0_f64 * 5.0 - 2.0 * 2.0).sqrt();
1016        assert_relative_eq!(
1017            spindle.signed_distance_to(Point::new(0.0, 0.0, cusp_z)),
1018            0.0,
1019            epsilon = 1e-12
1020        );
1021        // The outer equator, and beyond it.
1022        assert_relative_eq!(
1023            spindle.signed_distance_to(Point::new(7.0, 0.0, 0.0)),
1024            0.0,
1025            epsilon = 1e-12
1026        );
1027        assert_relative_eq!(
1028            spindle.signed_distance_to(Point::new(10.0, 0.0, 0.0)),
1029            3.0,
1030            epsilon = 1e-12
1031        );
1032        // Signed magnitude everywhere agrees with the unsigned distance.
1033        for p in [
1034            Point::new(0.0, 0.0, 2.0),
1035            Point::new(1.0, 1.0, 3.0),
1036            Point::new(4.0, -2.0, 1.0),
1037        ] {
1038            assert_relative_eq!(
1039                spindle.signed_distance_to(p).abs(),
1040                spindle.distance_to(p),
1041                epsilon = 1e-12
1042            );
1043        }
1044
1045        let ring = Torus::new(f, 5.0, 1.0, T).unwrap();
1046        for p in [
1047            Point::new(5.0, 0.0, 0.0),
1048            Point::new(6.5, 0.0, 0.0),
1049            Point::new(0.0, 4.2, 0.3),
1050            Point::new(0.0, 0.0, 0.0),
1051        ] {
1052            let local_radial = p.x.hypot(p.y) - 5.0;
1053            let classical = local_radial.hypot(p.z) - 1.0;
1054            assert_relative_eq!(ring.signed_distance_to(p), classical, epsilon = 1e-12);
1055        }
1056    }
1057}