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}