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 } => f(half_extents.x)
303                .max(f(half_extents.y))
304                .max(f(half_extents.z)),
305            Self::Cylinder {
306                radius,
307                half_height,
308            }
309            | Self::Cone {
310                radius,
311                half_height,
312            } => f(radius).max(f(half_height)),
313            Self::Ellipsoid { radii } => f(radii.x).max(f(radii.y)).max(f(radii.z)),
314            Self::Wedge {
315                width,
316                height,
317                depth,
318            } => f(width).max(f(height)).max(f(depth)),
319            Self::Torus {
320                major_radius,
321                minor_radius,
322            } => f(major_radius) + f(minor_radius),
323        }
324    }
325
326    /// The mass of this shape at `density`, with the principal inertia about its
327    /// centre of mass; refuses what cannot be a body.
328    ///
329    /// # Errors
330    ///
331    /// [`ShapeError::NonPositiveDensity`], [`ShapeError::DegenerateShape`] (from
332    /// [`Self::validate`]), and [`ShapeError::MassNotRepresentable`] when the mass
333    /// times the square of the longest dimension — the scale of the inertia — does
334    /// not fit comfortably in `Fix128`, or the arithmetic came out non-positive
335    /// anyway.
336    pub fn mass_and_inertia(&self, density: Fix128) -> Result<(Fix128, Vec3Fix), ShapeError> {
337        if density <= Fix128::ZERO {
338            return Err(ShapeError::NonPositiveDensity);
339        }
340        self.validate()?;
341        let longest = self.longest_f64();
342        // Bound the *f64* estimate of the largest quantity the solve forms before
343        // forming it: after the fact a wrapped `Fix128` is indistinguishable from a
344        // small valid one.
345        let estimate = density.to_f64() * longest * longest * longest * 8.0 * longest * longest;
346        if !(estimate.is_finite() && estimate < MAGNITUDE_LIMIT) {
347            return Err(ShapeError::MassNotRepresentable);
348        }
349        let mass = density * self.volume();
350        let inertia = self.inertia_diagonal(mass);
351        let positive = |x: Fix128| x > Fix128::ZERO;
352        if positive(mass) && positive(inertia.x) && positive(inertia.y) && positive(inertia.z) {
353            Ok((mass, inertia))
354        } else {
355            Err(ShapeError::MassNotRepresentable)
356        }
357    }
358}
359
360/// A [`Shape`] placed in the world: its centre of mass at `position`, turned by
361/// `rotation`. It is the convex solid the narrow-phase works on, through
362/// [`Support`].
363#[derive(Clone, Copy, Debug, PartialEq, Eq)]
364pub struct PosedShape {
365    /// The solid.
366    pub shape: Shape,
367    /// Where its centre of mass is.
368    pub position: Vec3Fix,
369    /// Its orientation.
370    pub rotation: QuatFix,
371}
372
373impl PosedShape {
374    /// Where the solid's geometric centre is: the centre of mass moved back by the
375    /// shape's centre-of-mass offset, turned like the solid.
376    fn geometric_center(&self) -> Vec3Fix {
377        self.position
378            - self
379                .rotation
380                .unit_rotation()
381                .rotate_vec(self.shape.center_of_mass_offset())
382    }
383
384    /// The smallest world-axis box that contains the posed solid: each shape's own
385    /// closed-form box ([`OrientedBox::aabb`], [`Cylinder::aabb`], [`Cone::aabb`],
386    /// [`Ellipsoid::aabb`], [`Wedge::aabb`], [`Torus::aabb`]) about the geometric
387    /// centre, turned like the solid. It is never larger than the cube of
388    /// [`Shape::bounding_radius`] about the centre of mass, and is smaller for any
389    /// solid that is not round in every direction.
390    #[must_use]
391    pub(crate) fn world_aabb(&self) -> AABB {
392        let center = self.geometric_center();
393        let rotation = self.rotation.unit_rotation();
394        match self.shape {
395            Shape::Box { half_extents } => OrientedBox::new(center, half_extents, rotation).aabb(),
396            Shape::Cylinder {
397                radius,
398                half_height,
399            } => Cylinder::with_rotation(center, half_height, radius, rotation).aabb(),
400            Shape::Cone {
401                radius,
402                half_height,
403            } => Cone::with_rotation(center, radius, half_height, rotation).aabb(),
404            Shape::Ellipsoid { radii } => Ellipsoid::with_rotation(center, radii, rotation).aabb(),
405            Shape::Wedge {
406                width,
407                height,
408                depth,
409            } => Wedge::with_rotation(center, width, height, depth, rotation).aabb(),
410            Shape::Torus {
411                major_radius,
412                minor_radius,
413            } => Torus::with_rotation(center, major_radius, minor_radius, rotation).aabb(),
414        }
415    }
416}
417
418impl Support for PosedShape {
419    fn support(&self, direction: Vec3Fix) -> Vec3Fix {
420        let center = self.geometric_center();
421        let rotation = self.rotation.unit_rotation();
422        match self.shape {
423            Shape::Box { half_extents } => {
424                OrientedBox::new(center, half_extents, rotation).support(direction)
425            }
426            Shape::Cylinder {
427                radius,
428                half_height,
429            } => Cylinder::with_rotation(center, half_height, radius, rotation).support(direction),
430            Shape::Cone {
431                radius,
432                half_height,
433            } => Cone::with_rotation(center, radius, half_height, rotation).support(direction),
434            Shape::Ellipsoid { radii } => {
435                Ellipsoid::with_rotation(center, radii, rotation).support(direction)
436            }
437            Shape::Wedge {
438                width,
439                height,
440                depth,
441            } => Wedge::with_rotation(center, width, height, depth, rotation).support(direction),
442            Shape::Torus {
443                major_radius,
444                minor_radius,
445            } => Torus::with_rotation(center, major_radius, minor_radius, rotation)
446                .support(direction),
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    /// The geometric centre is the centre of mass moved back along the shape's
456    /// offset turned by the orientation; a quaternion of norm 2 must turn it the
457    /// same as the unit one, not stretch the offset by `|q|^2`.
458    #[test]
459    fn geometric_center_ignores_the_orientation_norm() {
460        let unit = QuatFix::from_axis_angle(
461            Vec3Fix::new(Fix128::ONE, Fix128::ONE, Fix128::ZERO).normalize(),
462            Fix128::from_ratio(7, 10),
463        );
464        let two = Fix128::from_int(2);
465        let doubled = QuatFix::new(unit.x * two, unit.y * two, unit.z * two, unit.w * two);
466        let shapes = [
467            Shape::Cone {
468                radius: Fix128::ONE,
469                half_height: Fix128::from_int(2),
470            },
471            Shape::Wedge {
472                width: Fix128::from_int(2),
473                height: Fix128::from_int(3),
474                depth: Fix128::ONE,
475            },
476        ];
477        for shape in shapes {
478            let posed = |rotation| PosedShape {
479                shape,
480                position: Vec3Fix::from_int(1, -2, 3),
481                rotation,
482            };
483            let gap = (posed(doubled).geometric_center() - posed(unit).geometric_center()).length();
484            assert!(
485                gap < Fix128::from_f64(1e-15),
486                "{shape:?}: centre moved by {:e} under a quaternion of norm 2",
487                gap.to_f64()
488            );
489            assert!(
490                !shape.center_of_mass_offset().length_squared().is_zero(),
491                "{shape:?} has no centre-of-mass offset, so the case measures nothing"
492            );
493        }
494    }
495}