Skip to main content

fast_mesh_query/
plane.rs

1//! Cutting planes with a 2D frame.
2
3use crate::{QueryError, Result};
4
5/// A plane in mesh-local coordinates, with an orthonormal 2D frame `(u, v)`
6/// in it. The frame is right-handed: `normal = u × v`.
7///
8/// The signed distance of a point `p` is `n·p − n·origin`, in `f64`; points
9/// with a signed distance of zero or more are on the plane's `+` side. So a
10/// point exactly in the plane counts as `+`, and flipping the normal is not
11/// always the same cut: a mesh face lying in the plane is cut with one sign
12/// of the normal and not the other (see [`slice`](crate::slice())).
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct Plane {
15    origin: [f64; 3],
16    normal: [f64; 3],
17    u: [f64; 3],
18    v: [f64; 3],
19}
20
21/// How far from orthonormal the axes given to [`Plane::with_axes`] may be.
22const AXES_TOLERANCE: f64 = 1e-6;
23
24impl Plane {
25    /// The plane through `origin` with normal `normal`, which is normalised.
26    ///
27    /// The frame is the default one: `u` is the world axis least parallel to
28    /// the normal (the first such axis on ties), made orthogonal to it and
29    /// normalised, and `v = normal × u`. It changes only when the normal
30    /// changes which axis that is.
31    ///
32    /// # Errors
33    /// [`QueryError::InvalidInput`] when `origin` is not finite, or `normal`
34    /// is zero or not finite.
35    pub fn new(origin: [f64; 3], normal: [f64; 3]) -> Result<Plane> {
36        check_finite(origin, "plane origin must be finite")?;
37        check_finite(normal, "plane normal must be finite")?;
38        // Scale by the largest component first, so that huge normals do not
39        // overflow and tiny ones do not underflow.
40        let big = normal.iter().fold(0.0f64, |m, c| m.max(c.abs()));
41        if big == 0.0 {
42            return Err(QueryError::InvalidInput("plane normal must not be zero"));
43        }
44        let n = normalize(normal.map(|c| c / big));
45        let k = (0..3)
46            .min_by(|&i, &j| n[i].abs().total_cmp(&n[j].abs()))
47            .unwrap_or(0);
48        let mut axis = [0.0; 3];
49        axis[k] = 1.0;
50        let u = normalize(std::array::from_fn(|i| axis[i] - n[k] * n[i]));
51        let v = cross(n, u);
52        Ok(Plane {
53            origin,
54            normal: n,
55            u,
56            v,
57        })
58    }
59
60    /// The plane through `origin` spanned by the frame `(u, v)`, with normal
61    /// `u × v` (normalised). The axes are kept as given.
62    ///
63    /// # Errors
64    /// [`QueryError::InvalidInput`] when an argument is not finite, or the axes
65    /// are not orthonormal within `1e-6` (unit length and orthogonal).
66    pub fn with_axes(origin: [f64; 3], u: [f64; 3], v: [f64; 3]) -> Result<Plane> {
67        check_finite(origin, "plane origin must be finite")?;
68        check_finite(u, "plane axes must be finite")?;
69        check_finite(v, "plane axes must be finite")?;
70        let unit = |a: [f64; 3]| (dot(a, a).sqrt() - 1.0).abs() <= AXES_TOLERANCE;
71        if !(unit(u) && unit(v) && dot(u, v).abs() <= AXES_TOLERANCE) {
72            return Err(QueryError::InvalidInput("plane axes must be orthonormal"));
73        }
74        Ok(Plane {
75            origin,
76            normal: normalize(cross(u, v)),
77            u,
78            v,
79        })
80    }
81
82    /// The point the plane was given through, origin of the 2D frame.
83    pub fn origin(&self) -> [f64; 3] {
84        self.origin
85    }
86
87    /// The unit normal, pointing to the `+` side.
88    pub fn normal(&self) -> [f64; 3] {
89        self.normal
90    }
91
92    /// The first axis of the 2D frame.
93    pub fn u(&self) -> [f64; 3] {
94        self.u
95    }
96
97    /// The second axis of the 2D frame.
98    pub fn v(&self) -> [f64; 3] {
99        self.v
100    }
101
102    /// The signed distance `n·p − n·origin` of `p` from the plane: positive on
103    /// the side the normal points to.
104    pub fn signed_distance(&self, p: [f64; 3]) -> f64 {
105        dot(self.normal, p) - dot(self.normal, self.origin)
106    }
107
108    /// The 2D coordinates `((p − origin)·u, (p − origin)·v)` of `p`, which is
109    /// projected onto the plane along the normal.
110    pub fn to_uv(&self, p: [f64; 3]) -> [f64; 2] {
111        let d = sub(p, self.origin);
112        [dot(d, self.u), dot(d, self.v)]
113    }
114
115    /// The point of the plane with 2D coordinates `q`: `origin + q₀·u + q₁·v`.
116    pub fn from_uv(&self, q: [f64; 2]) -> [f64; 3] {
117        std::array::from_fn(|k| self.origin[k] + q[0] * self.u[k] + q[1] * self.v[k])
118    }
119}
120
121fn check_finite(a: [f64; 3], what: &'static str) -> Result<()> {
122    if a.iter().all(|c| c.is_finite()) {
123        Ok(())
124    } else {
125        Err(QueryError::InvalidInput(what))
126    }
127}
128
129fn normalize(a: [f64; 3]) -> [f64; 3] {
130    let len = dot(a, a).sqrt();
131    a.map(|c| c / len)
132}
133
134pub(crate) fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
135    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
136}
137
138pub(crate) fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
139    [
140        a[1] * b[2] - a[2] * b[1],
141        a[2] * b[0] - a[0] * b[2],
142        a[0] * b[1] - a[1] * b[0],
143    ]
144}
145
146fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
147    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::test_util::{Lcg, random_normal};
154
155    fn random_vec(rng: &mut Lcg, scale: f64) -> [f64; 3] {
156        std::array::from_fn(|_| (f64::from(rng.next()) * 2.0 - 1.0) * scale)
157    }
158
159    fn norm(a: [f64; 3]) -> f64 {
160        dot(a, a).sqrt()
161    }
162
163    #[test]
164    fn plane_new_normalises_and_rejects_zero() {
165        let p = Plane::new([0.0; 3], [0.0, 0.0, 2.0]).unwrap();
166        assert_eq!(p.normal(), [0.0, 0.0, 1.0]);
167        for bad in [[0.0; 3], [f64::NAN, 0.0, 1.0], [f64::INFINITY, 0.0, 0.0]] {
168            assert!(matches!(
169                Plane::new([0.0; 3], bad),
170                Err(QueryError::InvalidInput(_))
171            ));
172        }
173        assert!(matches!(
174            Plane::new([f64::NAN, 0.0, 0.0], [0.0, 0.0, 1.0]),
175            Err(QueryError::InvalidInput(_))
176        ));
177        // A huge but finite normal does not overflow to an invalid one.
178        let p = Plane::new([0.0; 3], [1e300, 1e300, 0.0]).unwrap();
179        assert!((norm(p.normal()) - 1.0).abs() < 1e-15);
180    }
181
182    #[test]
183    fn default_frame_is_orthonormal_right_handed() {
184        let mut rng = Lcg(0x91a7);
185        let mut normals: Vec<[f64; 3]> = (0..1000).map(|_| random_normal(&mut rng)).collect();
186        // The axes themselves and the ties between them.
187        normals.extend([
188            [1.0, 0.0, 0.0],
189            [0.0, -1.0, 0.0],
190            [0.0, 0.0, 1.0],
191            [1.0, 1.0, 1.0],
192        ]);
193        for n in normals {
194            let p = Plane::new([0.0; 3], n).unwrap();
195            let (n, u, v) = (p.normal(), p.u(), p.v());
196            assert!((norm(u) - 1.0).abs() < 1e-12, "{n:?}");
197            assert!((norm(v) - 1.0).abs() < 1e-12, "{n:?}");
198            assert!(dot(u, n).abs() < 1e-12, "{n:?}");
199            assert!(dot(v, n).abs() < 1e-12, "{n:?}");
200            assert!(dot(u, v).abs() < 1e-12, "{n:?}");
201            let w = cross(u, v);
202            assert!((0..3).all(|k| (w[k] - n[k]).abs() < 1e-12), "{n:?}");
203        }
204    }
205
206    #[test]
207    fn with_axes_rejects_non_orthonormal() {
208        let o = [1.0, 2.0, 3.0];
209        assert!(matches!(
210            Plane::with_axes(o, [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]),
211            Err(QueryError::InvalidInput(_))
212        ));
213        assert!(matches!(
214            Plane::with_axes(o, [2.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
215            Err(QueryError::InvalidInput(_))
216        ));
217        assert!(matches!(
218            Plane::with_axes(o, [f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0]),
219            Err(QueryError::InvalidInput(_))
220        ));
221        let p = Plane::with_axes(o, [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]).unwrap();
222        assert_eq!(p.normal(), [1.0, 0.0, 0.0]);
223        assert_eq!(
224            (p.origin(), p.u(), p.v()),
225            (o, [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
226        );
227        // Within 1e-6 of orthonormal is accepted.
228        assert!(Plane::with_axes(o, [1.0 + 5e-7, 0.0, 0.0], [5e-7, 1.0, 0.0]).is_ok());
229    }
230
231    #[test]
232    fn uv_round_trip() {
233        let mut rng = Lcg(0x3c0f);
234        for _ in 0..1000 {
235            let o = random_vec(&mut rng, 1.0);
236            let plane = Plane::new(o, random_normal(&mut rng)).unwrap();
237            let p = random_vec(&mut rng, 100.0);
238            let s = plane.signed_distance(p);
239            let n = plane.normal();
240            let projected: [f64; 3] = std::array::from_fn(|k| p[k] - s * n[k]);
241            let q = plane.from_uv(plane.to_uv(p));
242            let tol = 1e-12 * norm(p);
243            assert!((0..3).all(|k| (q[k] - projected[k]).abs() <= tol), "{p:?}");
244        }
245    }
246
247    #[test]
248    fn signed_distance_uses_the_side_rule() {
249        let plane = Plane::new([0.0, 0.0, 1.0], [0.0, 0.0, 3.0]).unwrap();
250        assert_eq!(plane.signed_distance([5.0, -2.0, 1.0]), 0.0);
251        assert_eq!(plane.signed_distance([0.0, 0.0, 3.5]), 2.5);
252        assert_eq!(plane.signed_distance([0.0, 0.0, 0.0]), -1.0);
253    }
254}