1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
17
18use crate::{Axis, Direction, Frame, Point, Transform, Vector};
19
20fn 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#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Plane {
34 frame: Frame,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct Cylinder {
40 frame: Frame,
41 radius: f64,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Cone {
51 frame: Frame,
52 reference_radius: f64,
53 half_angle: f64,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct Sphere {
59 frame: Frame,
60 radius: f64,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct Torus {
66 frame: Frame,
67 major_radius: f64,
68 minor_radius: f64,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum TorusKind {
78 Ring,
80 Horn,
82 Spindle,
84}
85
86impl Plane {
87 pub const XY: Self = Self {
89 frame: Frame::WORLD,
90 };
91
92 #[must_use]
94 pub const fn new(frame: Frame) -> Self {
95 Self { frame }
96 }
97
98 #[must_use]
101 pub fn through(origin: Point, normal: Direction) -> Self {
102 Self {
103 frame: Frame::about(origin, normal),
104 }
105 }
106
107 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 #[must_use]
121 pub const fn frame(&self) -> Frame {
122 self.frame
123 }
124
125 #[must_use]
127 pub const fn origin(&self) -> Point {
128 self.frame.origin()
129 }
130
131 #[must_use]
133 pub const fn normal(&self) -> Direction {
134 self.frame.z()
135 }
136
137 #[must_use]
139 pub fn signed_distance_to(&self, p: Point) -> f64 {
140 self.frame.signed_distance_to_plane(p)
141 }
142
143 #[must_use]
145 pub fn distance_to(&self, p: Point) -> f64 {
146 self.signed_distance_to(p).abs()
147 }
148
149 #[must_use]
151 pub fn project(&self, p: Point) -> Point {
152 p - self.normal() * self.signed_distance_to(p)
153 }
154
155 #[must_use]
157 pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
158 self.distance_to(p) <= tol.confusion()
159 }
160
161 #[must_use]
163 pub const fn reversed(&self) -> Self {
164 Self {
165 frame: self.frame.with_z_reversed(),
166 }
167 }
168
169 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 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 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 #[must_use]
204 pub const fn frame(&self) -> Frame {
205 self.frame
206 }
207
208 #[must_use]
210 pub const fn axis(&self) -> Axis {
211 self.frame.axis()
212 }
213
214 #[must_use]
216 pub const fn radius(&self) -> f64 {
217 self.radius
218 }
219
220 #[must_use]
222 pub fn signed_distance_to(&self, p: Point) -> f64 {
223 self.axis().distance_to(p) - self.radius
224 }
225
226 #[must_use]
228 pub fn distance_to(&self, p: Point) -> f64 {
229 self.signed_distance_to(p).abs()
230 }
231
232 #[must_use]
234 pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
235 self.distance_to(p) <= tol.confusion()
236 }
237
238 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 #[must_use]
251 pub fn lateral_area(&self, height: f64) -> f64 {
252 core::f64::consts::TAU * self.radius * height.abs()
253 }
254
255 #[must_use]
257 pub fn volume(&self, height: f64) -> f64 {
258 core::f64::consts::PI * self.radius * self.radius * height.abs()
259 }
260
261 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 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 #[must_use]
319 pub const fn frame(&self) -> Frame {
320 self.frame
321 }
322
323 #[must_use]
325 pub const fn axis(&self) -> Axis {
326 self.frame.axis()
327 }
328
329 #[must_use]
331 pub const fn reference_radius(&self) -> f64 {
332 self.reference_radius
333 }
334
335 #[must_use]
337 pub const fn half_angle(&self) -> f64 {
338 self.half_angle
339 }
340
341 #[must_use]
343 pub fn apex(&self) -> Point {
344 self.frame.origin() - self.frame.z() * (self.reference_radius / self.half_angle.tan())
347 }
348
349 #[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 #[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 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 #[must_use]
399 pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
400 self.distance_to(p) <= tol.confusion()
401 }
402
403 pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
410 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 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 pub fn centred(centre: Point, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
442 Self::new(Frame::about(centre, Direction::Z), radius, tol)
443 }
444
445 #[must_use]
447 pub const fn frame(&self) -> Frame {
448 self.frame
449 }
450
451 #[must_use]
453 pub const fn centre(&self) -> Point {
454 self.frame.origin()
455 }
456
457 #[must_use]
459 pub const fn radius(&self) -> f64 {
460 self.radius
461 }
462
463 #[must_use]
465 pub fn signed_distance_to(&self, p: Point) -> f64 {
466 self.centre().distance(p) - self.radius
467 }
468
469 #[must_use]
471 pub fn distance_to(&self, p: Point) -> f64 {
472 self.signed_distance_to(p).abs()
473 }
474
475 #[must_use]
477 pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
478 self.distance_to(p) <= tol.confusion()
479 }
480
481 #[must_use]
483 pub fn encloses(&self, p: Point, tol: Tolerances) -> bool {
484 self.signed_distance_to(p) < -tol.confusion()
485 }
486
487 pub fn normal_at(&self, p: Point, tol: Tolerances) -> OgeomResult<Direction> {
494 Direction::new(p - self.centre(), tol)
495 }
496
497 #[must_use]
499 pub fn area(&self) -> f64 {
500 4.0 * core::f64::consts::PI * self.radius * self.radius
501 }
502
503 #[must_use]
505 pub fn volume(&self) -> f64 {
506 4.0 / 3.0 * core::f64::consts::PI * self.radius.powi(3)
507 }
508
509 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 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 #[must_use]
553 pub const fn frame(&self) -> Frame {
554 self.frame
555 }
556
557 #[must_use]
559 pub const fn centre(&self) -> Point {
560 self.frame.origin()
561 }
562
563 #[must_use]
565 pub const fn axis(&self) -> Axis {
566 self.frame.axis()
567 }
568
569 #[must_use]
571 pub const fn major_radius(&self) -> f64 {
572 self.major_radius
573 }
574
575 #[must_use]
577 pub const fn minor_radius(&self) -> f64 {
578 self.minor_radius
579 }
580
581 #[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 #[must_use]
596 pub fn self_intersects(&self, tol: Tolerances) -> bool {
597 self.kind(tol) == TorusKind::Spindle
598 }
599
600 #[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 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 #[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 #[must_use]
647 pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
648 self.distance_to(p) <= tol.confusion()
649 }
650
651 #[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 #[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 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
686pub 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 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 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 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 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 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 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 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 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 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 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 let t = Torus::new(Frame::WORLD, 1.0, 3.0, T).unwrap();
915 assert_eq!(t.kind(T), TorusKind::Spindle);
916
917 let v = core::f64::consts::PI;
920 let radial = 1.0 + 3.0 * v.cos(); 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 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 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 assert_relative_eq!(t.signed_distance_to(Point::new(5.0, 0.0, 0.0)), -2.0);
944 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 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 #[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 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 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 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 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}