Skip to main content

alice_physics/
shape.rs

1//! Solid shapes with mass properties, and the bodies built from them.
2//!
3//! [`Shape`] names a solid by its dimensions and answers the three questions a
4//! rigid body needs of it: how much volume it has, where its centre of mass is,
5//! and what its inertia is **about that centre**. [`PhysicsWorld::add_shaped_body`](crate::solver::PhysicsWorld::add_shaped_body)
6//! turns a shape and a density into a body with the right mass, the right
7//! principal inertia and a collision radius that encloses the solid.
8//!
9//! # Why this exists
10//!
11//! [`RigidBody::new`](crate::solver::RigidBody::new) gives every body the inertia of a unit-radius sphere of its
12//! mass, whatever it is meant to be: a long box and a ball spin identically under
13//! the same torque. The closed forms for the common solids have lived in the
14//! shape modules ([`crate::box_collider`], [`crate::cylinder`], [`crate::cone`],
15//! [`crate::ellipsoid`], [`crate::wedge`], [`crate::torus`]) as methods nothing
16//! called; a shape is how they reach a body.
17//!
18//! # Frames
19//!
20//! A shaped body's position is its **centre of mass**, and its inertia is the
21//! tensor about that point in the body's local frame, whose axes are the shape's
22//! (`Y` is the axis of a cylinder, cone or torus; a wedge's apex is at `+Y`).
23//! A shape whose centroid is not at its geometric centre — the cone and the
24//! wedge — reports the offset in [`Shape::center_of_mass_offset`] so a caller
25//! that places geometry relative to the body can account for it.
26//!
27//! # Collision
28//!
29//! A shaped body is registered with the **bounding sphere about its centre of
30//! mass** as its collision radius: it encloses the whole solid. A pair in which
31//! at least one body carries a shape or a compound is decided by GJK/EPA on the
32//! collider itself, with a plain body's collision sphere entering as a sphere
33//! support; two plain bodies keep the exact sphere test. The broadphase box of a
34//! body with a collider is its closed-form world box, clipped to the cube of the
35//! collision sphere.
36//!
37//! GJK works on support points, so a solid that is not convex collides as its
38//! convex hull: a ball cannot enter the hole of a [`Torus`].
39//!
40//! Author: Moroya Sakamoto
41
42use crate::box_collider::OrientedBox;
43use crate::collider::{Support, AABB};
44use crate::cone::Cone;
45use crate::cylinder::Cylinder;
46use crate::ellipsoid::Ellipsoid;
47use crate::math::{Fix128, QuatFix, Vec3Fix};
48use crate::torus::Torus;
49use crate::wedge::Wedge;
50
51/// A solid, by its dimensions.
52///
53/// Dimensions are lengths and must be positive; [`Shape::validate`] says whether
54/// they are. The pose is not part of a shape: it is the body's.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum Shape {
57    /// A box of the given half-extents along `X`, `Y`, `Z`.
58    Box {
59        /// Half the side length along each local axis.
60        half_extents: Vec3Fix,
61    },
62    /// A solid cylinder along `Y`.
63    Cylinder {
64        /// Radius of the circular section.
65        radius: Fix128,
66        /// Half the length along `Y`.
67        half_height: Fix128,
68    },
69    /// A solid cone along `Y`, apex at `+half_height`, base at `−half_height`.
70    Cone {
71        /// Radius of the base.
72        radius: Fix128,
73        /// Half the height, from the geometric centre to the apex or the base.
74        half_height: Fix128,
75    },
76    /// A solid ellipsoid with the given semi-axes.
77    Ellipsoid {
78        /// Semi-axes along `X`, `Y`, `Z`.
79        radii: Vec3Fix,
80    },
81    /// A triangular prism: an isosceles triangle in the `XY` plane (base `width`
82    /// at `−height / 2`, apex at `+height / 2`) extruded `depth` along `Z`.
83    Wedge {
84        /// Base of the triangle, along `X`.
85        width: Fix128,
86        /// Height of the triangle, along `Y`.
87        height: Fix128,
88        /// Extrusion length, along `Z`.
89        depth: Fix128,
90    },
91    /// A solid torus around `Y`.
92    Torus {
93        /// Distance from the axis to the centre of the tube.
94        major_radius: Fix128,
95        /// Radius of the tube.
96        minor_radius: Fix128,
97    },
98}
99
100/// Why a shape could not become a body.
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum ShapeError {
103    /// The density is zero or negative: the body would have no mass.
104    NonPositiveDensity,
105    /// A dimension is zero or negative, or a torus's tube is at least as wide as
106    /// its ring: the solid is flat or does not exist.
107    DegenerateShape,
108    /// The mass or the inertia is too large for `Fix128`, whose arithmetic wraps:
109    /// a body built from the wrapped numbers would be silently wrong.
110    MassNotRepresentable,
111}
112
113/// Largest mass-times-length-squared the solve accepts, as an `f64`: a quarter of
114/// the `Fix128` integer range, so the inertia and its reciprocal stay well inside
115/// it.
116const MAGNITUDE_LIMIT: f64 = (1u64 << 61) as f64;
117
118impl Shape {
119    /// Whether every dimension is positive (and a torus has a hole).
120    pub fn validate(&self) -> Result<(), ShapeError> {
121        let positive = |x: Fix128| x > Fix128::ZERO;
122        let ok = match *self {
123            Self::Box { half_extents } => {
124                positive(half_extents.x) && positive(half_extents.y) && positive(half_extents.z)
125            }
126            Self::Cylinder {
127                radius,
128                half_height,
129            }
130            | Self::Cone {
131                radius,
132                half_height,
133            } => positive(radius) && positive(half_height),
134            Self::Ellipsoid { radii } => {
135                positive(radii.x) && positive(radii.y) && positive(radii.z)
136            }
137            Self::Wedge {
138                width,
139                height,
140                depth,
141            } => positive(width) && positive(height) && positive(depth),
142            Self::Torus {
143                major_radius,
144                minor_radius,
145            } => positive(major_radius) && positive(minor_radius) && minor_radius < major_radius,
146        };
147        if ok {
148            Ok(())
149        } else {
150            Err(ShapeError::DegenerateShape)
151        }
152    }
153
154    /// Volume.
155    ///
156    /// Meaningful for a shape that passes [`Self::validate`] and whose size is
157    /// representable; for an enormous one `Fix128` wraps, which is why
158    /// [`PhysicsWorld::add_shaped_body`](crate::solver::PhysicsWorld::add_shaped_body)(crate::solver::PhysicsWorld::add_shaped_body)
159    /// checks the magnitude first.
160    #[must_use]
161    pub fn volume(&self) -> Fix128 {
162        match *self {
163            Self::Box { half_extents } => {
164                OrientedBox::new(Vec3Fix::ZERO, half_extents, QuatFix::IDENTITY).volume()
165            }
166            Self::Cylinder {
167                radius,
168                half_height,
169            } => Cylinder::new(Vec3Fix::ZERO, half_height, radius).volume(),
170            Self::Cone {
171                radius,
172                half_height,
173            } => Cone::new(Vec3Fix::ZERO, radius, half_height).volume(),
174            Self::Ellipsoid { radii } => Ellipsoid::new(Vec3Fix::ZERO, radii).volume(),
175            Self::Wedge {
176                width,
177                height,
178                depth,
179            } => Wedge::new(Vec3Fix::ZERO, width, height, depth).volume(),
180            Self::Torus {
181                major_radius,
182                minor_radius,
183            } => Torus::new(Vec3Fix::ZERO, major_radius, minor_radius).volume(),
184        }
185    }
186
187    /// Offset of the centre of mass from the geometric centre, in the shape's
188    /// local frame: zero for the shapes symmetric about their centre, and along
189    /// `Y` for the cone (`−half_height / 2`) and the wedge (`−height / 6`).
190    #[must_use]
191    pub fn center_of_mass_offset(&self) -> Vec3Fix {
192        match *self {
193            Self::Cone {
194                radius,
195                half_height,
196            } => Cone::new(Vec3Fix::ZERO, radius, half_height).center_of_mass_offset(),
197            Self::Wedge {
198                width,
199                height,
200                depth,
201            } => Wedge::new(Vec3Fix::ZERO, width, height, depth).center_of_mass_offset(),
202            Self::Box { .. }
203            | Self::Cylinder { .. }
204            | Self::Ellipsoid { .. }
205            | Self::Torus { .. } => Vec3Fix::ZERO,
206        }
207    }
208
209    /// Diagonal of the inertia tensor about the centre of mass, for a given mass,
210    /// in the shape's local frame.
211    #[must_use]
212    pub fn inertia_diagonal(&self, mass: Fix128) -> Vec3Fix {
213        match *self {
214            Self::Box { half_extents } => {
215                OrientedBox::new(Vec3Fix::ZERO, half_extents, QuatFix::IDENTITY)
216                    .inertia_diagonal(mass)
217            }
218            Self::Cylinder {
219                radius,
220                half_height,
221            } => Cylinder::new(Vec3Fix::ZERO, half_height, radius).inertia_diagonal(mass),
222            Self::Cone {
223                radius,
224                half_height,
225            } => Cone::new(Vec3Fix::ZERO, radius, half_height).inertia_diagonal(mass),
226            Self::Ellipsoid { radii } => {
227                Ellipsoid::new(Vec3Fix::ZERO, radii).inertia_diagonal(mass)
228            }
229            Self::Wedge {
230                width,
231                height,
232                depth,
233            } => Wedge::new(Vec3Fix::ZERO, width, height, depth).inertia_diagonal(mass),
234            Self::Torus {
235                major_radius,
236                minor_radius,
237            } => Torus::new(Vec3Fix::ZERO, major_radius, minor_radius).inertia_diagonal(mass),
238        }
239    }
240
241    /// Radius of the smallest sphere about the **centre of mass** that encloses
242    /// the solid.
243    #[must_use]
244    pub fn bounding_radius(&self) -> Fix128 {
245        match *self {
246            Self::Box { half_extents } => half_extents.length(),
247            Self::Cylinder {
248                radius,
249                half_height,
250            } => (radius * radius + half_height * half_height).sqrt(),
251            Self::Cone {
252                radius,
253                half_height,
254            } => {
255                // The centroid is `half_height / 2` below the geometric centre: the
256                // apex is `3·half_height / 2` from it, a point of the base rim
257                // `√(r² + (half_height / 2)²)`.
258                let quarter = half_height.half();
259                let apex = half_height + quarter;
260                let rim = (radius * radius + quarter * quarter).sqrt();
261                if apex > rim {
262                    apex
263                } else {
264                    rim
265                }
266            }
267            Self::Ellipsoid { radii } => {
268                Ellipsoid::new(Vec3Fix::ZERO, radii).bounding_sphere_radius()
269            }
270            Self::Wedge {
271                width,
272                height,
273                depth,
274            } => {
275                // The centroid is `height / 6` below the geometric centre. The
276                // farthest points are a base corner (`h/3` below the centroid) and
277                // an apex vertex (`2h/3` above it), at the extreme depth.
278                let hd = depth.half();
279                let hw = width.half();
280                let third = height / Fix128::from_int(3);
281                let two_thirds = third + third;
282                let corner = (hw * hw + third * third + hd * hd).sqrt();
283                let apex = (two_thirds * two_thirds + hd * hd).sqrt();
284                if corner > apex {
285                    corner
286                } else {
287                    apex
288                }
289            }
290            Self::Torus {
291                major_radius,
292                minor_radius,
293            } => major_radius + minor_radius,
294        }
295    }
296
297    /// The largest length among the dimensions, as an `f64`: what the magnitude
298    /// check scales the mass by.
299    fn longest_f64(&self) -> f64 {
300        let f = Fix128::to_f64;
301        match *self {
302            Self::Box { half_extents } => crate::det_math::max64(
303                crate::det_math::max64(f(half_extents.x), f(half_extents.y)),
304                f(half_extents.z),
305            ),
306            Self::Cylinder {
307                radius,
308                half_height,
309            }
310            | Self::Cone {
311                radius,
312                half_height,
313            } => crate::det_math::max64(f(radius), f(half_height)),
314            Self::Ellipsoid { radii } => {
315                crate::det_math::max64(crate::det_math::max64(f(radii.x), f(radii.y)), f(radii.z))
316            }
317            Self::Wedge {
318                width,
319                height,
320                depth,
321            } => crate::det_math::max64(crate::det_math::max64(f(width), f(height)), f(depth)),
322            Self::Torus {
323                major_radius,
324                minor_radius,
325            } => f(major_radius) + f(minor_radius),
326        }
327    }
328
329    /// The mass of this shape at `density`, with the principal inertia about its
330    /// centre of mass; refuses what cannot be a body.
331    ///
332    /// # Errors
333    ///
334    /// [`ShapeError::NonPositiveDensity`], [`ShapeError::DegenerateShape`] (from
335    /// [`Self::validate`]), and [`ShapeError::MassNotRepresentable`] when the mass
336    /// times the square of the longest dimension — the scale of the inertia — does
337    /// not fit comfortably in `Fix128`, or the arithmetic came out non-positive
338    /// anyway.
339    pub fn mass_and_inertia(&self, density: Fix128) -> Result<(Fix128, Vec3Fix), ShapeError> {
340        if density <= Fix128::ZERO {
341            return Err(ShapeError::NonPositiveDensity);
342        }
343        self.validate()?;
344        let longest = self.longest_f64();
345        // Bound the *f64* estimate of the largest quantity the solve forms before
346        // forming it: after the fact a wrapped `Fix128` is indistinguishable from a
347        // small valid one.
348        let estimate = density.to_f64() * longest * longest * longest * 8.0 * longest * longest;
349        if !(estimate.is_finite() && estimate < MAGNITUDE_LIMIT) {
350            return Err(ShapeError::MassNotRepresentable);
351        }
352        let mass = density * self.volume();
353        let inertia = self.inertia_diagonal(mass);
354        let positive = |x: Fix128| x > Fix128::ZERO;
355        if positive(mass) && positive(inertia.x) && positive(inertia.y) && positive(inertia.z) {
356            Ok((mass, inertia))
357        } else {
358            Err(ShapeError::MassNotRepresentable)
359        }
360    }
361}
362
363/// A [`Shape`] placed in the world: its centre of mass at `position`, turned by
364/// `rotation`. It is the convex solid the narrow-phase works on, through
365/// [`Support`].
366#[derive(Clone, Copy, Debug, PartialEq, Eq)]
367pub struct PosedShape {
368    /// The solid.
369    pub shape: Shape,
370    /// Where its centre of mass is.
371    pub position: Vec3Fix,
372    /// Its orientation.
373    pub rotation: QuatFix,
374}
375
376impl PosedShape {
377    /// Where the solid's geometric centre is: the centre of mass moved back by the
378    /// shape's centre-of-mass offset, turned like the solid.
379    fn geometric_center(&self) -> Vec3Fix {
380        self.position
381            - self
382                .rotation
383                .unit_rotation()
384                .rotate_vec(self.shape.center_of_mass_offset())
385    }
386
387    /// The smallest world-axis box that contains the posed solid: each shape's own
388    /// closed-form box ([`OrientedBox::aabb`], [`Cylinder::aabb`], [`Cone::aabb`],
389    /// [`Ellipsoid::aabb`], [`Wedge::aabb`], [`Torus::aabb`]) about the geometric
390    /// centre, turned like the solid. It is never larger than the cube of
391    /// [`Shape::bounding_radius`] about the centre of mass, and is smaller for any
392    /// solid that is not round in every direction.
393    #[must_use]
394    pub(crate) fn world_aabb(&self) -> AABB {
395        let center = self.geometric_center();
396        let rotation = self.rotation.unit_rotation();
397        match self.shape {
398            Shape::Box { half_extents } => OrientedBox::new(center, half_extents, rotation).aabb(),
399            Shape::Cylinder {
400                radius,
401                half_height,
402            } => Cylinder::with_rotation(center, half_height, radius, rotation).aabb(),
403            Shape::Cone {
404                radius,
405                half_height,
406            } => Cone::with_rotation(center, radius, half_height, rotation).aabb(),
407            Shape::Ellipsoid { radii } => Ellipsoid::with_rotation(center, radii, rotation).aabb(),
408            Shape::Wedge {
409                width,
410                height,
411                depth,
412            } => Wedge::with_rotation(center, width, height, depth, rotation).aabb(),
413            Shape::Torus {
414                major_radius,
415                minor_radius,
416            } => Torus::with_rotation(center, major_radius, minor_radius, rotation).aabb(),
417        }
418    }
419}
420
421impl Support for PosedShape {
422    fn support(&self, direction: Vec3Fix) -> Vec3Fix {
423        let center = self.geometric_center();
424        let rotation = self.rotation.unit_rotation();
425        match self.shape {
426            Shape::Box { half_extents } => {
427                OrientedBox::new(center, half_extents, rotation).support(direction)
428            }
429            Shape::Cylinder {
430                radius,
431                half_height,
432            } => Cylinder::with_rotation(center, half_height, radius, rotation).support(direction),
433            Shape::Cone {
434                radius,
435                half_height,
436            } => Cone::with_rotation(center, radius, half_height, rotation).support(direction),
437            Shape::Ellipsoid { radii } => {
438                Ellipsoid::with_rotation(center, radii, rotation).support(direction)
439            }
440            Shape::Wedge {
441                width,
442                height,
443                depth,
444            } => Wedge::with_rotation(center, width, height, depth, rotation).support(direction),
445            Shape::Torus {
446                major_radius,
447                minor_radius,
448            } => Torus::with_rotation(center, major_radius, minor_radius, rotation)
449                .support(direction),
450        }
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    /// The geometric centre is the centre of mass moved back along the shape's
459    /// offset turned by the orientation; a quaternion of norm 2 must turn it the
460    /// same as the unit one, not stretch the offset by `|q|^2`.
461    #[test]
462    fn geometric_center_ignores_the_orientation_norm() {
463        let unit = QuatFix::from_axis_angle(
464            Vec3Fix::new(Fix128::ONE, Fix128::ONE, Fix128::ZERO).normalize(),
465            Fix128::from_ratio(7, 10),
466        );
467        let two = Fix128::from_int(2);
468        let doubled = QuatFix::new(unit.x * two, unit.y * two, unit.z * two, unit.w * two);
469        let shapes = [
470            Shape::Cone {
471                radius: Fix128::ONE,
472                half_height: Fix128::from_int(2),
473            },
474            Shape::Wedge {
475                width: Fix128::from_int(2),
476                height: Fix128::from_int(3),
477                depth: Fix128::ONE,
478            },
479        ];
480        for shape in shapes {
481            let posed = |rotation| PosedShape {
482                shape,
483                position: Vec3Fix::from_int(1, -2, 3),
484                rotation,
485            };
486            let gap = (posed(doubled).geometric_center() - posed(unit).geometric_center()).length();
487            assert!(
488                gap < Fix128::from_f64(1e-15),
489                "{shape:?}: centre moved by {:e} under a quaternion of norm 2",
490                gap.to_f64()
491            );
492            assert!(
493                !shape.center_of_mass_offset().length_squared().is_zero(),
494                "{shape:?} has no centre-of-mass offset, so the case measures nothing"
495            );
496        }
497    }
498}