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}