axiolid_core/
primitives3.rs

1//! Bounded three-dimensional primitives.
2//!
3//! These are plain data with public fields, matching the rest of [`crate`]:
4//! adapters may construct degenerate or dirty values, and algorithms validate
5//! against the operation's own tolerance rather than a global epsilon hidden
6//! here. Nothing in this module refuses input.
7//!
8//! There is deliberately no read-only/mutable type pair. Languages without
9//! ownership express that distinction by shipping `Triangle3` beside a
10//! `MTriangle3`; Rust expresses it as `&T` versus `&mut T` on one type, checked
11//! by the compiler. Mirroring every type would double the surface and prove
12//! nothing the borrow checker does not already prove.
13
14use crate::{Point3, Scalar, Vec3};
15
16/// A three-dimensional triangle.
17///
18/// Corner order defines the winding, and therefore the sign of [`normal`]. A
19/// degenerate triangle -- collinear or coincident corners -- is representable
20/// on purpose: whether that is an error depends on the caller's tolerance, so
21/// the decision belongs to the algorithm consuming it.
22///
23/// [`normal`]: Triangle3::normal
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct Triangle3 {
26    /// First corner.
27    pub a: Point3,
28    /// Second corner.
29    pub b: Point3,
30    /// Third corner.
31    pub c: Point3,
32}
33
34impl Triangle3 {
35    /// Construct a triangle without validating it.
36    pub const fn new(a: Point3, b: Point3, c: Point3) -> Self {
37        Self { a, b, c }
38    }
39
40    /// Unnormalized right-handed normal.
41    ///
42    /// Left unnormalized because its magnitude is twice the triangle area, so
43    /// callers that need area get it without a second cross product, and
44    /// callers that need a direction normalize explicitly against their own
45    /// tolerance. A degenerate triangle yields a zero vector rather than a
46    /// NaN-bearing unit vector.
47    pub fn normal(&self) -> Vec3 {
48        (self.b - self.a).cross(self.c - self.a)
49    }
50
51    /// Triangle area, always non-negative.
52    pub fn area(&self) -> Scalar {
53        self.normal().length() * 0.5
54    }
55
56    /// Centroid of the three corners.
57    pub fn centroid(&self) -> Point3 {
58        (self.a + self.b + self.c) / 3.0
59    }
60}
61
62/// A planar rectangle in three-dimensional space.
63///
64/// Stored as a corner and two edge vectors rather than four corners, so the
65/// parallelogram property holds by construction and cannot drift as corners
66/// are edited independently. Whether `x` and `y` are perpendicular is not
67/// enforced: an importer may produce a sheared quad, and refusing it here
68/// would lose data the caller may still want to inspect or repair.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct Rectangle3 {
71    /// Corner the edge vectors start from.
72    pub origin: Point3,
73    /// First edge vector, from `origin`.
74    pub x: Vec3,
75    /// Second edge vector, from `origin`.
76    pub y: Vec3,
77}
78
79impl Rectangle3 {
80    /// Construct from a corner and two edge vectors.
81    pub const fn new(origin: Point3, x: Vec3, y: Vec3) -> Self {
82        Self { origin, x, y }
83    }
84
85    /// The four corners, counter-clockwise about [`normal`].
86    ///
87    /// [`normal`]: Rectangle3::normal
88    pub fn corners(&self) -> [Point3; 4] {
89        [
90            self.origin,
91            self.origin + self.x,
92            self.origin + self.x + self.y,
93            self.origin + self.y,
94        ]
95    }
96
97    /// Unnormalized normal of the plane the rectangle lies in.
98    pub fn normal(&self) -> Vec3 {
99        self.x.cross(self.y)
100    }
101
102    /// Area of the rectangle, which is the parallelogram area when sheared.
103    pub fn area(&self) -> Scalar {
104        self.normal().length()
105    }
106
107    /// Centre point.
108    pub fn center(&self) -> Point3 {
109        self.origin + (self.x + self.y) * 0.5
110    }
111}
112
113/// An oriented box: the generalization of a rectangle to three dimensions.
114///
115/// Distinct from [`Aabb`], which is axis-aligned and exists for broad-phase
116/// rejection. This one carries its own orientation, so it can bound a rotated
117/// object tightly where an axis-aligned box would not.
118///
119/// The three edge vectors are not required to be mutually perpendicular, for
120/// the same reason [`Rectangle3`] does not require it: a dirty import stays
121/// representable and inspectable.
122///
123/// [`Aabb`]: crate::Aabb
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct Box3 {
126    /// Corner the edge vectors start from.
127    pub origin: Point3,
128    /// First edge vector, from `origin`.
129    pub x: Vec3,
130    /// Second edge vector, from `origin`.
131    pub y: Vec3,
132    /// Third edge vector, from `origin`.
133    pub z: Vec3,
134}
135
136impl Box3 {
137    /// Construct from a corner and three edge vectors.
138    pub const fn new(origin: Point3, x: Vec3, y: Vec3, z: Vec3) -> Self {
139        Self { origin, x, y, z }
140    }
141
142    /// The eight corners.
143    ///
144    /// Ordered so bit 0 selects `x`, bit 1 selects `y`, and bit 2 selects `z`:
145    /// index 0 is `origin` and index 7 is the far corner. That ordering lets a
146    /// caller index a corner by axis mask instead of memorising a winding.
147    pub fn corners(&self) -> [Point3; 8] {
148        let mut out = [self.origin; 8];
149        for (index, corner) in out.iter_mut().enumerate() {
150            let mut point = self.origin;
151            if index & 1 != 0 {
152                point += self.x;
153            }
154            if index & 2 != 0 {
155                point += self.y;
156            }
157            if index & 4 != 0 {
158                point += self.z;
159            }
160            *corner = point;
161        }
162        out
163    }
164
165    /// Signed volume. Negative when the edge vectors are left-handed.
166    ///
167    /// Signed rather than absolute so a caller can detect an inverted frame,
168    /// which is a common symptom of a mirrored or badly converted import.
169    pub fn signed_volume(&self) -> Scalar {
170        self.x.cross(self.y).dot(self.z)
171    }
172
173    /// Centre point.
174    pub fn center(&self) -> Point3 {
175        self.origin + (self.x + self.y + self.z) * 0.5
176    }
177}
178
179/// A simple polygon in three-dimensional space, without holes.
180///
181/// The vertices are assumed coplanar and non-self-intersecting, and neither is
182/// checked here: both are tolerance-dependent judgements. The closing edge is
183/// implicit, so the last vertex joins the first and repeating it creates a
184/// zero-length edge rather than a closed ring.
185#[derive(Debug, Clone, PartialEq)]
186pub struct Polygon3 {
187    /// Boundary vertices in order. The closing edge is implicit.
188    pub vertices: Vec<Point3>,
189}
190
191impl Polygon3 {
192    /// Construct from boundary vertices in order.
193    pub const fn new(vertices: Vec<Point3>) -> Self {
194        Self { vertices }
195    }
196
197    /// Number of boundary vertices, which equals the number of edges.
198    pub fn len(&self) -> usize {
199        self.vertices.len()
200    }
201
202    /// Whether the polygon has no vertices.
203    pub fn is_empty(&self) -> bool {
204        self.vertices.is_empty()
205    }
206
207    /// Twice the vector area, summed by the shoelace formula in three
208    /// dimensions.
209    ///
210    /// The direction is the polygon's normal and the magnitude is twice its
211    /// area, so this is the 3D analogue of the signed 2D shoelace sum. It is
212    /// exact for a planar polygon and degrades gracefully for a slightly
213    /// non-planar one, which is what imported data usually is.
214    ///
215    /// Fewer than three vertices enclose nothing and yield a zero vector.
216    pub fn vector_area2(&self) -> Vec3 {
217        if self.vertices.len() < 3 {
218            return Vec3::ZERO;
219        }
220        // Summed about the first vertex rather than the origin: the origin can
221        // be arbitrarily far from the data in a georeferenced model, and the
222        // cancellation that follows costs precision for no benefit.
223        let base = self.vertices[0];
224        let mut total = Vec3::ZERO;
225        for pair in self.vertices[1..].windows(2) {
226            total += (pair[0] - base).cross(pair[1] - base);
227        }
228        total
229    }
230
231    /// Unnormalized normal implied by the vertex winding.
232    pub fn normal(&self) -> Vec3 {
233        self.vector_area2()
234    }
235
236    /// Polygon area, always non-negative.
237    pub fn area(&self) -> Scalar {
238        self.vector_area2().length() * 0.5
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn triangle_normal_follows_winding_and_area_is_half_its_length() {
248        let triangle = Triangle3::new(
249            Point3::ZERO,
250            Point3::new(2.0, 0.0, 0.0),
251            Point3::new(0.0, 3.0, 0.0),
252        );
253        assert_eq!(triangle.normal(), Vec3::new(0.0, 0.0, 6.0));
254        assert_eq!(triangle.area(), 3.0);
255
256        // Reversing the winding flips the normal but not the area.
257        let reversed = Triangle3::new(triangle.a, triangle.c, triangle.b);
258        assert_eq!(reversed.normal(), -triangle.normal());
259        assert_eq!(reversed.area(), triangle.area());
260    }
261
262    #[test]
263    fn degenerate_triangle_has_zero_normal_rather_than_nan() {
264        let collinear = Triangle3::new(
265            Point3::ZERO,
266            Point3::new(1.0, 1.0, 1.0),
267            Point3::new(2.0, 2.0, 2.0),
268        );
269        assert_eq!(collinear.normal(), Vec3::ZERO);
270        assert_eq!(collinear.area(), 0.0);
271    }
272
273    #[test]
274    fn rectangle_corners_close_the_loop_and_area_matches_the_edges() {
275        let rectangle = Rectangle3::new(
276            Point3::new(1.0, 0.0, 0.0),
277            Vec3::new(2.0, 0.0, 0.0),
278            Vec3::new(0.0, 4.0, 0.0),
279        );
280        let corners = rectangle.corners();
281        assert_eq!(corners[0], rectangle.origin);
282        // Opposite corners share a midpoint when the quad really is planar.
283        assert_eq!((corners[0] + corners[2]) * 0.5, rectangle.center());
284        assert_eq!((corners[1] + corners[3]) * 0.5, rectangle.center());
285        assert_eq!(rectangle.area(), 8.0);
286    }
287
288    #[test]
289    fn box_corner_index_selects_edges_by_bit() {
290        let unit = Box3::new(Point3::ZERO, Vec3::X, Vec3::Y, Vec3::Z);
291        let corners = unit.corners();
292        assert_eq!(corners[0], Point3::ZERO);
293        assert_eq!(corners[1], Vec3::X);
294        assert_eq!(corners[2], Vec3::Y);
295        assert_eq!(corners[4], Vec3::Z);
296        assert_eq!(corners[7], Vec3::ONE);
297        assert_eq!(unit.center(), Vec3::splat(0.5));
298    }
299
300    #[test]
301    fn box_volume_is_signed_so_a_mirrored_frame_is_detectable() {
302        let right_handed = Box3::new(Point3::ZERO, Vec3::X, Vec3::Y, Vec3::Z);
303        assert_eq!(right_handed.signed_volume(), 1.0);
304
305        let mirrored = Box3::new(Point3::ZERO, Vec3::Y, Vec3::X, Vec3::Z);
306        assert_eq!(mirrored.signed_volume(), -1.0);
307    }
308
309    #[test]
310    fn polygon_area_is_winding_independent_and_normal_is_not() {
311        let square = Polygon3::new(vec![
312            Point3::ZERO,
313            Point3::new(2.0, 0.0, 0.0),
314            Point3::new(2.0, 2.0, 0.0),
315            Point3::new(0.0, 2.0, 0.0),
316        ]);
317        assert_eq!(square.area(), 4.0);
318        assert_eq!(square.normal(), Vec3::new(0.0, 0.0, 8.0));
319
320        let mut reversed = square.vertices.clone();
321        reversed.reverse();
322        let reversed = Polygon3::new(reversed);
323        assert_eq!(reversed.area(), square.area());
324        assert_eq!(reversed.normal(), -square.normal());
325    }
326
327    #[test]
328    fn polygon_with_fewer_than_three_vertices_encloses_nothing() {
329        assert_eq!(Polygon3::new(Vec::new()).vector_area2(), Vec3::ZERO);
330        assert_eq!(
331            Polygon3::new(vec![Point3::ZERO, Vec3::X]).vector_area2(),
332            Vec3::ZERO
333        );
334    }
335
336    #[test]
337    fn polygon_area_is_translation_invariant_far_from_the_origin() {
338        // Georeferenced models sit millions of units from the origin, which is
339        // where a shoelace sum about the origin loses precision.
340        let offset = Vec3::splat(6_000_000.0);
341        let local = Polygon3::new(vec![
342            Point3::ZERO,
343            Point3::new(1.0, 0.0, 0.0),
344            Point3::new(1.0, 1.0, 0.0),
345            Point3::new(0.0, 1.0, 0.0),
346        ]);
347        let far = Polygon3::new(local.vertices.iter().map(|v| *v + offset).collect());
348        assert_eq!(far.area(), local.area());
349    }
350}