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}