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//! Keeping these as exact analytic descriptions rather than converting them to
8//! NURBS lets intersection take analytic shortcuts, lets measurement report a
9//! radius rather than a fitted approximation of one, and keeps files small.
10//!
11//! Evaluation and derivatives are in [`crate::elementary`]; this module holds
12//! the descriptions and the queries that follow directly from them.
13
14use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15
16use crate::{Axis, Direction, Frame, Point, Transform, Vector};
17
18/// Reject a size parameter that cannot describe a real shape.
19fn check_positive(name: &str, value: f64, tol: Tolerances) -> OgeomResult<()> {
20    if !value.is_finite() || value <= tol.confusion() {
21        ogeom_bail!(Construction, "{name} {value} must be finite and positive");
22    }
23    Ok(())
24}
25
26/// An unbounded plane.
27///
28/// The frame's `z` is the normal; `x` and `y` span the surface and fix its
29/// parameterization.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct Plane {
32    frame: Frame,
33}
34
35/// An unbounded circular cylinder, with the frame's `z` as its axis.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct Cylinder {
38    frame: Frame,
39    radius: f64,
40}
41
42/// An unbounded circular cone.
43///
44/// The frame's `z` is the axis and its origin sits on the reference circle, of
45/// radius [`Cone::reference_radius`]. The radius grows in `+z` for a positive
46/// half angle. The apex is the point where it reaches zero.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct Cone {
49    frame: Frame,
50    reference_radius: f64,
51    half_angle: f64,
52}
53
54/// A sphere.
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct Sphere {
57    frame: Frame,
58    radius: f64,
59}
60
61/// A torus, with the frame's `z` as its axis of revolution.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct Torus {
64    frame: Frame,
65    major_radius: f64,
66    minor_radius: f64,
67}
68
69/// How a torus's minor radius compares with its major radius.
70///
71/// The three cases are genuinely different surfaces, and an algorithm that
72/// assumes the first will produce nonsense on the others: a spindle torus
73/// self-intersects, and a horn torus is tangent to itself at the poles.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum TorusKind {
76    /// `minor < major`: a ring, with a hole.
77    Ring,
78    /// `minor == major`: the hole closes to a single point at each pole.
79    Horn,
80    /// `minor > major`: the surface passes through itself.
81    Spindle,
82}
83
84impl Plane {
85    /// The `xy` plane.
86    pub const XY: Self = Self {
87        frame: Frame::WORLD,
88    };
89
90    /// The plane of `frame`, with `frame`'s `z` as its normal.
91    #[must_use]
92    pub const fn new(frame: Frame) -> Self {
93        Self { frame }
94    }
95
96    /// The plane through `origin` with the given `normal`, parameterized
97    /// arbitrarily but deterministically.
98    #[must_use]
99    pub fn through(origin: Point, normal: Direction) -> Self {
100        Self {
101            frame: Frame::about(origin, normal),
102        }
103    }
104
105    /// The plane through three points, with the normal following the right-hand
106    /// rule around `a`, `b`, `c`.
107    ///
108    /// # Errors
109    ///
110    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the points
111    /// are collinear.
112    pub fn through_points(a: Point, b: Point, c: Point, tol: Tolerances) -> OgeomResult<Self> {
113        let normal = Direction::from_cross(b - a, c - a, tol)?;
114        Ok(Self::through(a, normal))
115    }
116
117    /// The frame positioning this plane.
118    #[must_use]
119    pub const fn frame(&self) -> Frame {
120        self.frame
121    }
122
123    /// A point on the plane.
124    #[must_use]
125    pub const fn origin(&self) -> Point {
126        self.frame.origin()
127    }
128
129    /// The normal.
130    #[must_use]
131    pub const fn normal(&self) -> Direction {
132        self.frame.z()
133    }
134
135    /// The signed distance from `p`, positive on the side the normal points to.
136    #[must_use]
137    pub fn signed_distance_to(&self, p: Point) -> f64 {
138        self.frame.signed_distance_to_plane(p)
139    }
140
141    /// The distance from `p`.
142    #[must_use]
143    pub fn distance_to(&self, p: Point) -> f64 {
144        self.signed_distance_to(p).abs()
145    }
146
147    /// The closest point on the plane to `p`.
148    #[must_use]
149    pub fn project(&self, p: Point) -> Point {
150        p - self.normal() * self.signed_distance_to(p)
151    }
152
153    /// Whether `p` lies on the plane within `tol.confusion()`.
154    #[must_use]
155    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
156        self.distance_to(p) <= tol.confusion()
157    }
158
159    /// This plane with its normal reversed.
160    #[must_use]
161    pub const fn reversed(&self) -> Self {
162        Self {
163            frame: self.frame.with_z_reversed(),
164        }
165    }
166
167    /// This plane moved by `t`.
168    ///
169    /// # Errors
170    ///
171    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
172    /// transformed frame is degenerate.
173    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
174        Ok(Self::new(t.apply_frame(&self.frame, tol)?))
175    }
176}
177
178impl Cylinder {
179    /// A cylinder of `radius` about `frame`'s `z` axis.
180    ///
181    /// # Errors
182    ///
183    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `radius` is
184    /// not finite and positive.
185    pub fn new(frame: Frame, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
186        check_positive("cylinder radius", radius, tol)?;
187        Ok(Self { frame, radius })
188    }
189
190    /// A cylinder about an axis, parameterized arbitrarily but
191    /// deterministically.
192    ///
193    /// # Errors
194    ///
195    /// As [`Cylinder::new`].
196    pub fn about(axis: Axis, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
197        Self::new(Frame::about(axis.location, axis.direction), radius, tol)
198    }
199
200    /// The frame positioning this cylinder.
201    #[must_use]
202    pub const fn frame(&self) -> Frame {
203        self.frame
204    }
205
206    /// The axis of revolution.
207    #[must_use]
208    pub const fn axis(&self) -> Axis {
209        self.frame.axis()
210    }
211
212    /// The radius.
213    #[must_use]
214    pub const fn radius(&self) -> f64 {
215        self.radius
216    }
217
218    /// The signed distance from `p`, negative inside.
219    #[must_use]
220    pub fn signed_distance_to(&self, p: Point) -> f64 {
221        self.axis().distance_to(p) - self.radius
222    }
223
224    /// The distance from `p` to the surface.
225    #[must_use]
226    pub fn distance_to(&self, p: Point) -> f64 {
227        self.signed_distance_to(p).abs()
228    }
229
230    /// Whether `p` lies on the surface within `tol.confusion()`.
231    #[must_use]
232    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
233        self.distance_to(p) <= tol.confusion()
234    }
235
236    /// The outward unit normal at the point of the surface nearest `p`.
237    ///
238    /// # Errors
239    ///
240    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` lies on
241    /// the axis, where the nearest point (and so the normal) is not unique.
242    pub fn normal_at(&self, p: Point, tol: Tolerances) -> OgeomResult<Direction> {
243        let axis = self.axis();
244        Direction::new(p - axis.project(p), tol)
245    }
246
247    /// The area of a section of this cylinder `height` long.
248    #[must_use]
249    pub fn lateral_area(&self, height: f64) -> f64 {
250        core::f64::consts::TAU * self.radius * height.abs()
251    }
252
253    /// The volume enclosed by a section `height` long.
254    #[must_use]
255    pub fn volume(&self, height: f64) -> f64 {
256        core::f64::consts::PI * self.radius * self.radius * height.abs()
257    }
258
259    /// This cylinder moved by `t`.
260    ///
261    /// # Errors
262    ///
263    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
264    /// transformed frame is degenerate.
265    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
266        Self::new(
267            t.apply_frame(&self.frame, tol)?,
268            self.radius * t.scale_factor().abs(),
269            tol,
270        )
271    }
272}
273
274impl Cone {
275    /// A cone with the given reference radius and half angle.
276    ///
277    /// The reference circle lies in `frame`'s `xy` plane. A positive half angle
278    /// widens the cone in `+z`, a negative one narrows it there.
279    ///
280    /// # Errors
281    ///
282    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
283    /// `reference_radius` is negative or non-finite, or if `half_angle`'s size
284    /// is not strictly between `0` and `pi/2`. At zero the cone is a cylinder and at
285    /// `pi/2` it is a plane; both are different surfaces with their own types,
286    /// and admitting them here would produce a cone whose apex is at infinity.
287    pub fn new(
288        frame: Frame,
289        reference_radius: f64,
290        half_angle: f64,
291        tol: Tolerances,
292    ) -> OgeomResult<Self> {
293        if !reference_radius.is_finite() || reference_radius < 0.0 {
294            ogeom_bail!(
295                Construction,
296                "cone reference radius {reference_radius} must be finite and non-negative"
297            );
298        }
299        if !half_angle.is_finite()
300            || half_angle.abs() <= tol.angular()
301            || half_angle.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
302        {
303            ogeom_bail!(
304                Construction,
305                "cone half angle {half_angle} must lie strictly between 0 and pi/2"
306            );
307        }
308        Ok(Self {
309            frame,
310            reference_radius,
311            half_angle,
312        })
313    }
314
315    /// The frame positioning this cone.
316    #[must_use]
317    pub const fn frame(&self) -> Frame {
318        self.frame
319    }
320
321    /// The axis of revolution.
322    #[must_use]
323    pub const fn axis(&self) -> Axis {
324        self.frame.axis()
325    }
326
327    /// The radius of the circle in the frame's `xy` plane.
328    #[must_use]
329    pub const fn reference_radius(&self) -> f64 {
330        self.reference_radius
331    }
332
333    /// The half angle at the apex, its size in `(0, pi/2)`: negative where
334    /// the cone narrows in `+z`.
335    #[must_use]
336    pub const fn half_angle(&self) -> f64 {
337        self.half_angle
338    }
339
340    /// The apex.
341    #[must_use]
342    pub fn apex(&self) -> Point {
343        // The radius shrinks at `tan(half_angle)` per unit along the axis, so
344        // the apex is that many units back from the reference circle.
345        self.frame.origin() - self.frame.z() * (self.reference_radius / self.half_angle.tan())
346    }
347
348    /// The radius at signed distance `z` along the axis from the frame origin.
349    #[must_use]
350    pub fn radius_at(&self, z: f64) -> f64 {
351        self.half_angle.tan().mul_add(z, self.reference_radius)
352    }
353
354    /// The distance from `p` to the surface, both nappes of it.
355    ///
356    /// A double cone extends both sides of its apex. This measures to the
357    /// surface as a whole, which is what a surface query means.
358    #[must_use]
359    pub fn distance_to(&self, p: Point) -> f64 {
360        let local = self.frame.to_local(p);
361        let radial = local.xy().to_vector().magnitude();
362        let apex_z = -self.reference_radius / self.half_angle.tan();
363        // A cone is a *double* cone: the quadric has two nappes meeting at the
364        // apex, and the surface type built on this parameterizes both, so its
365        // height range may cross the apex. Measuring one nappe and clamping
366        // everything past the apex to the apex would report a point *on* the
367        // second nappe as off the surface.
368        //
369        // In the (radial, axial) half-plane each nappe is a ray from the apex.
370        // The distance is the nearer of the two, each clamped to its own ray
371        // so a point in the wedge beyond the apex measures to the apex. The
372        // rays lean out from the axis whichever way the cone widens: a
373        // negative half angle (a cone narrowing up its axis) only swaps
374        // which of them is the chart's own nappe.
375        let (sin, cos) = self.half_angle.sin_cos();
376        let sin = sin.abs();
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 not
413        // 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 a_cone_narrowing_up_its_axis_measures_to_both_nappes() {
799        let f = Frame::WORLD;
800        // A negative half angle narrows the cone up its axis, and points of
801        // it, on either nappe, stand on it.
802        let narrowing = Cone::new(f, 2.0, -0.5, T).unwrap();
803        for z in [-3.0, 0.0, 1.5, 6.0] {
804            let r = narrowing.radius_at(z);
805            let p = f.to_world(Point::new(r.abs() * 0.6, r.abs() * 0.8, z));
806            assert!(
807                narrowing.distance_to(p) < 1e-12,
808                "{z}: {}",
809                narrowing.distance_to(p)
810            );
811        }
812        let beside = f.to_world(Point::new(2.5, 0.0, 0.0));
813        assert!((narrowing.distance_to(beside) - 0.5 * 0.5_f64.cos()).abs() < 1e-12);
814    }
815
816    #[test]
817    fn cone_degenerate_angles_are_refused() {
818        let f = Frame::WORLD;
819        assert!(
820            Cone::new(f, 1.0, 0.0, T).is_err(),
821            "zero angle is a cylinder"
822        );
823        assert!(
824            Cone::new(f, 1.0, core::f64::consts::FRAC_PI_2, T).is_err(),
825            "a right angle is a plane"
826        );
827        assert!(Cone::new(f, 1.0, f64::NAN, T).is_err());
828        assert!(Cone::new(f, -1.0, 0.5, T).is_err());
829        // A zero reference radius is fine: the frame origin is then the apex.
830        assert!(Cone::new(f, 0.0, 0.5, T).is_ok());
831        assert!(Cone::new(f, 1.0, 0.5, T).is_ok());
832    }
833
834    #[test]
835    fn cone_apex_and_radius_profile() {
836        // Half angle of 45 degrees: radius grows one unit per unit of height.
837        let quarter = core::f64::consts::FRAC_PI_4;
838        let c = Cone::new(Frame::WORLD, 3.0, quarter, T).unwrap();
839        assert!(c.apex().is_equal(Point::new(0.0, 0.0, -3.0), T));
840        assert_relative_eq!(c.radius_at(0.0), 3.0, epsilon = 1e-12);
841        assert_relative_eq!(c.radius_at(2.0), 5.0, epsilon = 1e-12);
842        assert_relative_eq!(c.radius_at(-3.0), 0.0, epsilon = 1e-12);
843    }
844
845    #[test]
846    fn cone_distance_is_zero_on_the_surface() {
847        let quarter = core::f64::consts::FRAC_PI_4;
848        let c = Cone::new(Frame::WORLD, 3.0, quarter, T).unwrap();
849        for z in [-3.0_f64, -1.0, 0.0, 2.0, 10.0] {
850            let r = c.radius_at(z);
851            for angle in [0.0_f64, 1.0, 2.5] {
852                let p = Point::new(r * angle.cos(), r * angle.sin(), z);
853                assert!(c.contains(p, T), "z = {z}, distance = {}", c.distance_to(p));
854            }
855        }
856    }
857
858    #[test]
859    fn cone_distance_off_the_surface_is_perpendicular() {
860        let quarter = core::f64::consts::FRAC_PI_4;
861        let c = Cone::new(Frame::WORLD, 0.0, quarter, T).unwrap();
862        // Apex at the origin, opening along +z at 45 degrees. The point (1,0,0)
863        // sits at perpendicular distance sin(45) from the surface line.
864        assert_relative_eq!(
865            c.distance_to(Point::new(1.0, 0.0, 0.0)),
866            core::f64::consts::FRAC_1_SQRT_2,
867            epsilon = 1e-12
868        );
869        // A cone is a double cone: behind the apex is the second nappe, and a
870        // point on the axis there measures perpendicular to it, not to the
871        // apex.
872        assert_relative_eq!(
873            c.distance_to(Point::new(0.0, 0.0, -5.0)),
874            5.0 * core::f64::consts::FRAC_1_SQRT_2,
875            epsilon = 1e-12
876        );
877        // A point *on* the second nappe is on the cone.
878        assert_relative_eq!(
879            c.distance_to(Point::new(2.0, 0.0, -2.0)),
880            0.0,
881            epsilon = 1e-12
882        );
883    }
884
885    #[test]
886    fn sphere_queries() {
887        let s = Sphere::centred(Point::new(1.0, 2.0, 3.0), 5.0, T).unwrap();
888        assert_relative_eq!(s.signed_distance_to(Point::new(1.0, 2.0, 3.0)), -5.0);
889        assert_relative_eq!(s.signed_distance_to(Point::new(6.0, 2.0, 3.0)), 0.0);
890        assert_relative_eq!(s.signed_distance_to(Point::new(11.0, 2.0, 3.0)), 5.0);
891        assert!(s.encloses(s.centre(), T));
892        assert!(!s.encloses(Point::new(6.0, 2.0, 3.0), T));
893        assert!(s.contains(Point::new(6.0, 2.0, 3.0), T));
894        assert!(
895            s.normal_at(Point::new(6.0, 2.0, 3.0), T)
896                .unwrap()
897                .is_equal(Direction::X, T)
898        );
899        assert!(s.normal_at(s.centre(), T).is_err());
900    }
901
902    #[test]
903    fn sphere_measurements() {
904        let s = Sphere::centred(Point::ORIGIN, 3.0, T).unwrap();
905        assert_relative_eq!(s.area(), 4.0 * core::f64::consts::PI * 9.0);
906        assert_relative_eq!(s.volume(), 4.0 / 3.0 * core::f64::consts::PI * 27.0);
907    }
908
909    #[test]
910    fn torus_kinds_are_distinguished() {
911        let f = Frame::WORLD;
912        assert_eq!(Torus::new(f, 5.0, 1.0, T).unwrap().kind(T), TorusKind::Ring);
913        assert_eq!(Torus::new(f, 5.0, 5.0, T).unwrap().kind(T), TorusKind::Horn);
914        assert_eq!(
915            Torus::new(f, 5.0, 8.0, T).unwrap().kind(T),
916            TorusKind::Spindle
917        );
918        assert!(Torus::new(f, 5.0, 8.0, T).unwrap().self_intersects(T));
919        assert!(!Torus::new(f, 5.0, 1.0, T).unwrap().self_intersects(T));
920        // A spindle torus is a real surface and must be constructible.
921        assert!(Torus::new(f, 1.0, 2.0, T).is_ok());
922        assert!(Torus::new(f, 0.0, 1.0, T).is_err());
923    }
924
925    #[test]
926    fn spindle_torus_distance_accounts_for_the_folded_branch() {
927        // Minor radius exceeds major: the generating circle crosses the axis,
928        // so part of the surface comes from the far side of it. A point there
929        // is on the surface, and the unfolded formula alone would not say so.
930        let t = Torus::new(Frame::WORLD, 1.0, 3.0, T).unwrap();
931        assert_eq!(t.kind(T), TorusKind::Spindle);
932
933        // Parametric point with `major + minor*cos(v) < 0`, which lands on the
934        // folded branch.
935        let v = core::f64::consts::PI;
936        let radial = 1.0 + 3.0 * v.cos(); // = -2
937        let p = Point::new(radial.abs(), 0.0, 3.0 * v.sin());
938        assert!(t.contains(p, T), "distance was {}", t.distance_to(p));
939
940        // The ring case is unaffected: near branch still wins everywhere.
941        let ring = Torus::new(Frame::WORLD, 5.0, 2.0, T).unwrap();
942        for p in [
943            Point::new(7.0, 0.0, 0.0),
944            Point::new(3.0, 0.0, 0.0),
945            Point::new(5.0, 0.0, 2.0),
946        ] {
947            assert_relative_eq!(ring.distance_to(p), 0.0, epsilon = 1e-12);
948        }
949    }
950
951    #[test]
952    fn torus_distance_and_measurements() {
953        let t = Torus::new(Frame::WORLD, 5.0, 2.0, T).unwrap();
954        // Outer equator, inner equator, and the top of the tube.
955        assert!(t.contains(Point::new(7.0, 0.0, 0.0), T));
956        assert!(t.contains(Point::new(3.0, 0.0, 0.0), T));
957        assert!(t.contains(Point::new(5.0, 0.0, 2.0), T));
958        // The centre of the tube is one tube radius inside.
959        assert_relative_eq!(t.signed_distance_to(Point::new(5.0, 0.0, 0.0)), -2.0);
960        // The centre of the hole is far outside the surface.
961        assert_relative_eq!(t.signed_distance_to(Point::ORIGIN), 3.0);
962
963        let pi2 = core::f64::consts::PI * core::f64::consts::PI;
964        assert_relative_eq!(t.area(), 4.0 * pi2 * 10.0);
965        assert_relative_eq!(t.volume(), 2.0 * pi2 * 5.0 * 4.0);
966    }
967
968    #[test]
969    fn transforms_scale_sizes_and_preserve_shape() {
970        let scale = Transform::scaling(Point::ORIGIN, 3.0, T).unwrap();
971
972        let c = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
973        assert_relative_eq!(
974            c.transformed(&scale, T).unwrap().radius(),
975            6.0,
976            epsilon = 1e-12
977        );
978
979        let s = Sphere::centred(Point::new(1.0, 0.0, 0.0), 2.0, T).unwrap();
980        let moved = s.transformed(&scale, T).unwrap();
981        assert_relative_eq!(moved.radius(), 6.0, epsilon = 1e-12);
982        assert!(moved.centre().is_equal(Point::new(3.0, 0.0, 0.0), T));
983
984        let t = Torus::new(Frame::WORLD, 5.0, 2.0, T).unwrap();
985        let scaled = t.transformed(&scale, T).unwrap();
986        assert_relative_eq!(scaled.major_radius(), 15.0, epsilon = 1e-12);
987        assert_relative_eq!(scaled.minor_radius(), 6.0, epsilon = 1e-12);
988
989        // A similarity leaves a cone's half angle alone: the reason transforms
990        // are restricted to similarities in the first place.
991        let cone = Cone::new(Frame::WORLD, 3.0, 0.5, T).unwrap();
992        let big = cone.transformed(&scale, T).unwrap();
993        assert_relative_eq!(big.half_angle(), 0.5);
994        assert_relative_eq!(big.reference_radius(), 9.0, epsilon = 1e-12);
995    }
996
997    #[test]
998    fn transformed_surfaces_still_contain_their_transformed_points() {
999        let t =
1000            Transform::rotation(Axis::X, 0.7) * Transform::translation(Vector::new(1.0, 2.0, 3.0));
1001        let s = Sphere::centred(Point::ORIGIN, 4.0, T).unwrap();
1002        let moved = s.transformed(&t, T).unwrap();
1003        let on_surface = Point::new(4.0, 0.0, 0.0);
1004        assert!(s.contains(on_surface, T));
1005        assert!(moved.contains(t.apply(on_surface), T));
1006    }
1007}
1008
1009#[cfg(test)]
1010#[allow(clippy::unwrap_used, clippy::expect_used)]
1011mod spindle_tests {
1012    use super::*;
1013    use approx::assert_relative_eq;
1014
1015    const T: Tolerances = Tolerances::millimetres();
1016
1017    /// The spindle's signed distance names the apple: negative through the
1018    /// whole enclosed solid, zero on either sheet, positive beyond, and a
1019    /// ring torus keeps its classical values exactly.
1020    #[test]
1021    fn a_spindle_signs_its_apple_and_a_ring_is_unchanged() {
1022        let f = Frame::WORLD;
1023        let spindle = Torus::new(f, 2.0, 5.0, T).unwrap();
1024        // The axis point: inside, three units from the folded sheet.
1025        assert_relative_eq!(
1026            spindle.signed_distance_to(Point::new(0.0, 0.0, 0.0)),
1027            -3.0,
1028            epsilon = 1e-12
1029        );
1030        // Where the lemon's sheet crosses the axis: on the surface.
1031        let cusp_z = (5.0_f64 * 5.0 - 2.0 * 2.0).sqrt();
1032        assert_relative_eq!(
1033            spindle.signed_distance_to(Point::new(0.0, 0.0, cusp_z)),
1034            0.0,
1035            epsilon = 1e-12
1036        );
1037        // The outer equator, and beyond it.
1038        assert_relative_eq!(
1039            spindle.signed_distance_to(Point::new(7.0, 0.0, 0.0)),
1040            0.0,
1041            epsilon = 1e-12
1042        );
1043        assert_relative_eq!(
1044            spindle.signed_distance_to(Point::new(10.0, 0.0, 0.0)),
1045            3.0,
1046            epsilon = 1e-12
1047        );
1048        // Signed magnitude everywhere agrees with the unsigned distance.
1049        for p in [
1050            Point::new(0.0, 0.0, 2.0),
1051            Point::new(1.0, 1.0, 3.0),
1052            Point::new(4.0, -2.0, 1.0),
1053        ] {
1054            assert_relative_eq!(
1055                spindle.signed_distance_to(p).abs(),
1056                spindle.distance_to(p),
1057                epsilon = 1e-12
1058            );
1059        }
1060
1061        let ring = Torus::new(f, 5.0, 1.0, T).unwrap();
1062        for p in [
1063            Point::new(5.0, 0.0, 0.0),
1064            Point::new(6.5, 0.0, 0.0),
1065            Point::new(0.0, 4.2, 0.3),
1066            Point::new(0.0, 0.0, 0.0),
1067        ] {
1068            let local_radial = p.x.hypot(p.y) - 5.0;
1069            let classical = local_radial.hypot(p.z) - 1.0;
1070            assert_relative_eq!(ring.signed_distance_to(p), classical, epsilon = 1e-12);
1071        }
1072    }
1073}