Skip to main content

hpr_design/
mass.rs

1//! Rigid-body mass properties: mass, center of mass and the full inertia tensor, and how bodies
2//! combine, move and turn.
3//!
4//! **Frame.** Positions and tensors are in body axes (`docs/physics/frames.md`): `z` along the
5//! axis of symmetry, positive toward the nose; `x` the design's zero radial direction;
6//! `y = z × x`. A component's own frame has these axes with its origin on the axis at the
7//! component's forward reference plane (its forward end, or a nose cone's tip), so points of the
8//! component have `z ≤ 0`. The design places a component by translating and rolling it.
9//!
10//! **Inertia tensor** about a point `p` (positive products-of-inertia convention):
11//!
12//! ```text
13//! I_p = ∫ (|r|² E − r rᵀ) dm,     r = position − p
14//! ```
15//!
16//! so `I_xx = ∫(y² + z²) dm` and `I_xy = −∫ x y dm`. [`MassProperties`] stores it about the center
17//! of mass. The standard results used here (parallel-axis theorem, rotation of a tensor) are in any
18//! dynamics text, for example J. L. Meriam and L. G. Kraige, *Engineering Mechanics: Dynamics*,
19//! appendix B:
20//!
21//! ```text
22//! parallel axis:  I_p = I_cg + m (|d|² E − d dᵀ),   d = cg − p
23//! rotation R:     cg' = R cg,  I' = R I Rᵀ
24//! combination:    m = Σ m_k,  cg = Σ m_k cg_k / m,  I = Σ [I_k + m_k (|d_k|² E − d_k d_kᵀ)],  d_k = cg_k − cg
25//! ```
26//!
27//! See `docs/physics/mass.md`.
28
29use hpr_core::{DMat3, DQuat, DVec3};
30use hpr_motor::MassElement;
31use serde::{Deserialize, Serialize};
32
33use crate::error::DesignError;
34
35/// Mass, center of mass and inertia tensor about the center of mass, in body axes.
36#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37pub struct MassProperties {
38    /// Mass, kg.
39    pub mass_kg: f64,
40    /// Center of mass in body axes, m from the frame origin.
41    pub cg_m: DVec3,
42    /// Inertia tensor about the center of mass, in body axes, kg·m².
43    pub inertia_kg_m2: DMat3,
44}
45
46impl Default for MassProperties {
47    fn default() -> Self {
48        Self::ZERO
49    }
50}
51
52/// `|d|² E − d dᵀ`: the parallel-axis term for unit mass at offset `d`.
53fn offset_tensor(d: DVec3) -> DMat3 {
54    DMat3::from_diagonal(DVec3::splat(d.length_squared())) - outer(d, d)
55}
56
57/// The outer product `a bᵀ`.
58fn outer(a: DVec3, b: DVec3) -> DMat3 {
59    DMat3::from_cols(a * b.x, a * b.y, a * b.z)
60}
61
62impl MassProperties {
63    /// No mass, at the origin.
64    pub const ZERO: Self = Self {
65        mass_kg: 0.0,
66        cg_m: DVec3::ZERO,
67        inertia_kg_m2: DMat3::ZERO,
68    };
69
70    /// A point mass at `position_m`.
71    pub fn point(mass_kg: f64, position_m: DVec3) -> Self {
72        Self {
73            mass_kg,
74            cg_m: position_m,
75            inertia_kg_m2: DMat3::ZERO,
76        }
77    }
78
79    /// A body symmetric about a line parallel to `z` through `cg_m`, with moment of inertia
80    /// `axial` about that line and `transverse` about any perpendicular line through the center:
81    /// the tensor `diag(I_t, I_t, I_a)`.
82    pub fn axisymmetric(
83        mass_kg: f64,
84        cg_m: DVec3,
85        axial_kg_m2: f64,
86        transverse_kg_m2: f64,
87    ) -> Self {
88        Self {
89            mass_kg,
90            cg_m,
91            inertia_kg_m2: DMat3::from_diagonal(DVec3::new(
92                transverse_kg_m2,
93                transverse_kg_m2,
94                axial_kg_m2,
95            )),
96        }
97    }
98
99    /// A motor-crate mass element on the body axis. The element's axial coordinate runs toward the
100    /// nose like `z`, so a nozzle exit at body `z = nozzle_z_m` puts the element's center at
101    /// `nozzle_z_m + element.cg_m`.
102    pub fn from_motor_element(element: &MassElement, nozzle_z_m: f64) -> Self {
103        Self::axisymmetric(
104            element.mass_kg,
105            DVec3::new(0.0, 0.0, nozzle_z_m + element.cg_m),
106            element.axial_inertia_kg_m2,
107            element.transverse_inertia_kg_m2,
108        )
109    }
110
111    /// The inertia tensor about `point_m` instead of the center of mass (parallel-axis theorem).
112    pub fn inertia_about(&self, point_m: DVec3) -> DMat3 {
113        self.inertia_kg_m2 + offset_tensor(self.cg_m - point_m) * self.mass_kg
114    }
115
116    /// The same body moved by `offset_m`.
117    #[must_use]
118    pub fn translated(&self, offset_m: DVec3) -> Self {
119        Self {
120            cg_m: self.cg_m + offset_m,
121            ..*self
122        }
123    }
124
125    /// This body with one of its parts, `part`, moved by `offset_m` inside it: a ballast weight
126    /// slid along the airframe, say. The mass is unchanged; with `M` the whole's mass, `m` the
127    /// part's, `c` its center before the move and `δ` the offset,
128    ///
129    /// ```text
130    /// cg' = cg + m δ / M
131    /// I'  = I_{cg'} + m [(|c + δ − cg'|² E − (c + δ − cg')(c + δ − cg')ᵀ) − (|c − cg'|² E − (c − cg')(c − cg')ᵀ)]
132    /// ```
133    ///
134    /// where `I_{cg'}` is this body's inertia about the new center (parallel axis): the part's
135    /// point contribution about the new center is taken out where it was and put back where it is.
136    /// Its own inertia about its center moves with it unchanged, so it cancels. `part` must be a
137    /// part of this body; nothing checks that it is.
138    #[must_use]
139    pub fn with_part_moved(&self, part: &MassProperties, offset_m: DVec3) -> Self {
140        let cg = self.cg_m + offset_m * (part.mass_kg / self.mass_kg);
141        let before = offset_tensor(part.cg_m - cg);
142        let after = offset_tensor(part.cg_m + offset_m - cg);
143        Self {
144            mass_kg: self.mass_kg,
145            cg_m: cg,
146            inertia_kg_m2: self.inertia_about(cg) + (after - before) * part.mass_kg,
147        }
148    }
149
150    /// This body with one of its parts, `part`, taken out: a ballast weight or a payload released
151    /// in flight. With `M` the whole's mass and `cg` its center, `m` the part's mass, `c` its
152    /// center and `I_p` its own inertia about `c`,
153    ///
154    /// ```text
155    /// M'  = M − m
156    /// cg' = (M cg − m c) / M'
157    /// I'  = I_{cg'} − (I_p + m (|c − cg'|² E − (c − cg')(c − cg')ᵀ))
158    /// ```
159    ///
160    /// where `I_{cg'}` is this body's inertia about the new center (parallel axis), and the
161    /// bracket is the part's about it: [`Self::combine`] run backwards. `part` must be a part of
162    /// this body, lighter than it; nothing checks that it is.
163    #[must_use]
164    pub fn without_part(&self, part: &MassProperties) -> Self {
165        let mass_kg = self.mass_kg - part.mass_kg;
166        let cg = (self.cg_m * self.mass_kg - part.cg_m * part.mass_kg) / mass_kg;
167        Self {
168            mass_kg,
169            cg_m: cg,
170            inertia_kg_m2: self.inertia_about(cg) - part.inertia_about(cg),
171        }
172    }
173
174    /// The same body turned by `rotation` about the frame origin: `cg' = R cg`, `I' = R I Rᵀ`.
175    #[must_use]
176    pub fn rotated(&self, rotation: DQuat) -> Self {
177        let r = DMat3::from_quat(rotation);
178        Self {
179            mass_kg: self.mass_kg,
180            cg_m: r * self.cg_m,
181            inertia_kg_m2: r * self.inertia_kg_m2 * r.transpose(),
182        }
183    }
184
185    /// The same body rolled by `angle_rad` about the body `z` axis (right-handed: `x` toward `y`).
186    #[must_use]
187    pub fn rolled(&self, angle_rad: f64) -> Self {
188        self.rotated(DQuat::from_rotation_z(angle_rad))
189    }
190
191    /// The same body with its mass scaled by `factor` and its shape unchanged: the inertia scales
192    /// with the mass.
193    #[must_use]
194    pub fn scaled(&self, factor: f64) -> Self {
195        Self {
196            mass_kg: self.mass_kg * factor,
197            cg_m: self.cg_m,
198            inertia_kg_m2: self.inertia_kg_m2 * factor,
199        }
200    }
201
202    /// Copies of `body`, one moved by each `[x, y]` of `offsets_m` across the axis, combined into
203    /// one rigid body: a cluster's tubes, or what each of them holds. One copy that is not moved
204    /// is `body` itself, bit for bit; no offsets at all is no body (zero mass).
205    #[must_use]
206    pub fn copied(body: Self, offsets_m: &[[f64; 2]]) -> Self {
207        if let [[x, y]] = offsets_m
208            && *x == 0.0
209            && *y == 0.0
210        {
211            return body;
212        }
213        let copies: Vec<Self> = offsets_m
214            .iter()
215            .map(|&[x, y]| body.translated(DVec3::new(x, y, 0.0)))
216            .collect();
217        Self::combine(&copies)
218    }
219
220    /// Copies of `body`, one at each of `places`, combined into one rigid body: a cluster's tubes
221    /// or a pod set's pods, or what each of them holds. Each copy is `body` rolled by its place's
222    /// angle about the body's axis, then moved across it by its offset, so each carries its own
223    /// parallel-axis term. One place that neither moves nor turns is `body` itself, bit for bit;
224    /// no places at all is no body (zero mass).
225    #[must_use]
226    pub fn placed(body: Self, places: &[Placement]) -> Self {
227        if let [only] = places
228            && *only == Placement::HERE
229        {
230            return body;
231        }
232        let copies: Vec<Self> = places
233            .iter()
234            .map(|place| {
235                let turned = if place.roll_rad == 0.0 {
236                    body
237                } else {
238                    body.rolled(place.roll_rad)
239                };
240                let [x, y] = place.offset_m;
241                turned.translated(DVec3::new(x, y, 0.0))
242            })
243            .collect();
244        Self::combine(&copies)
245    }
246
247    /// The bodies combined into one rigid body.
248    ///
249    /// With zero total mass the center is the plain average of the parts' centers (the origin with
250    /// no parts) and the tensors are summed, so massless placeholders stay finite.
251    pub fn combine<'a>(parts: impl IntoIterator<Item = &'a MassProperties> + Clone) -> Self {
252        let mut mass = 0.0;
253        let mut moment = DVec3::ZERO;
254        let mut position_sum = DVec3::ZERO;
255        let mut count = 0.0;
256        for part in parts.clone() {
257            mass += part.mass_kg;
258            moment += part.cg_m * part.mass_kg;
259            position_sum += part.cg_m;
260            count += 1.0;
261        }
262        let cg = if mass.is_nan() {
263            DVec3::NAN
264        } else if mass > 0.0 {
265            moment / mass
266        } else if count > 0.0 {
267            position_sum / count
268        } else {
269            DVec3::ZERO
270        };
271        let inertia = parts
272            .into_iter()
273            .fold(DMat3::ZERO, |sum, part| sum + part.inertia_about(cg));
274        Self {
275            mass_kg: mass,
276            cg_m: cg,
277            inertia_kg_m2: inertia,
278        }
279    }
280
281    /// The principal moments of inertia about the center of mass, ascending: the eigenvalues of
282    /// the tensor.
283    pub fn principal_moments_kg_m2(&self) -> [f64; 3] {
284        symmetric_eigenvalues(self.inertia_kg_m2)
285    }
286
287    /// Checks that this could be a real body: finite, non-negative mass; finite center; a
288    /// symmetric tensor (to 1e-9 of its largest entry) whose principal moments are non-negative
289    /// and obey the triangle inequality `I_1 + I_2 ≥ I_3` (to the same tolerance), and that is zero
290    /// when the mass is.
291    ///
292    /// # Errors
293    ///
294    /// [`DesignError::Domain`] for a bad mass or center, [`DesignError::UnphysicalInertia`] for a
295    /// bad tensor.
296    pub fn validate(&self) -> Result<(), DesignError> {
297        if !self.mass_kg.is_finite() || self.mass_kg < 0.0 {
298            return Err(DesignError::Domain {
299                what: "mass",
300                value: self.mass_kg,
301            });
302        }
303        if !self.cg_m.is_finite() {
304            return Err(DesignError::Domain {
305                what: "center of mass",
306                value: self.cg_m.max_element().max(-self.cg_m.min_element()),
307            });
308        }
309        let i = self.inertia_kg_m2;
310        let entries = i.to_cols_array();
311        if entries.iter().any(|v| !v.is_finite()) {
312            return Err(DesignError::UnphysicalInertia(
313                "the tensor has a non-finite entry".to_owned(),
314            ));
315        }
316        let scale = entries.iter().fold(0.0f64, |m, v| m.max(v.abs()));
317        if self.mass_kg == 0.0 && scale > 0.0 {
318            return Err(DesignError::UnphysicalInertia(format!(
319                "a body with no mass has no inertia, but this one has {scale:e} kg·m²"
320            )));
321        }
322        let tolerance = 1e-9 * scale;
323        let asymmetry = (i - i.transpose())
324            .to_cols_array()
325            .iter()
326            .fold(0.0f64, |m, v| m.max(v.abs()));
327        if asymmetry > tolerance {
328            return Err(DesignError::UnphysicalInertia(format!(
329                "the tensor is not symmetric (largest difference {asymmetry:e} kg·m²)"
330            )));
331        }
332        let [a, b, c] = self.principal_moments_kg_m2();
333        if a < -tolerance {
334            return Err(DesignError::UnphysicalInertia(format!(
335                "a principal moment is negative ({a:e} kg·m²)"
336            )));
337        }
338        if a + b < c - tolerance {
339            return Err(DesignError::UnphysicalInertia(format!(
340                "the principal moments {a:e}, {b:e}, {c:e} kg·m² break I1 + I2 ≥ I3"
341            )));
342        }
343        Ok(())
344    }
345}
346
347/// Eigenvalues of a symmetric 3×3 matrix, ascending, by the cyclic Jacobi method (G. H. Golub and
348/// C. F. Van Loan, *Matrix Computations*, 4th ed., 2013, §8.5.2–8.5.3), which is accurate to about
349/// machine precision relative to the matrix norm even for repeated eigenvalues.
350fn symmetric_eigenvalues(m: DMat3) -> [f64; 3] {
351    let mut a = [
352        [m.x_axis.x, m.y_axis.x, m.z_axis.x],
353        [m.x_axis.y, m.y_axis.y, m.z_axis.y],
354        [m.x_axis.z, m.y_axis.z, m.z_axis.z],
355    ];
356    // Symmetrize, so a slightly asymmetric input gives the eigenvalues of its symmetric part.
357    for (p, q) in [(0, 1), (0, 2), (1, 2)] {
358        let mean = 0.5 * (a[p][q] + a[q][p]);
359        a[p][q] = mean;
360        a[q][p] = mean;
361    }
362    // Quadratic convergence: a handful of sweeps reaches machine precision.
363    for _ in 0..32 {
364        let off = a[0][1].abs() + a[0][2].abs() + a[1][2].abs();
365        if off == 0.0 {
366            break;
367        }
368        for (p, q) in [(0, 1), (0, 2), (1, 2)] {
369            if a[p][q] == 0.0 {
370                continue;
371            }
372            // The symmetric Schur decomposition of the (p, q) block (Golub and Van Loan,
373            // algorithm 8.5.1).
374            let tau = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
375            let t = tau.signum() / (tau.abs() + (1.0 + tau * tau).sqrt());
376            let t = if tau == 0.0 { 1.0 } else { t };
377            let c = 1.0 / (1.0 + t * t).sqrt();
378            let s = t * c;
379            for row in &mut a {
380                let (kp, kq) = (row[p], row[q]);
381                row[p] = c * kp - s * kq;
382                row[q] = s * kp + c * kq;
383            }
384            #[expect(
385                clippy::needless_range_loop,
386                reason = "rows p and q of the same matrix change together"
387            )]
388            for k in 0..3 {
389                let (pk, qk) = (a[p][k], a[q][k]);
390                a[p][k] = c * pk - s * qk;
391                a[q][k] = s * pk + c * qk;
392            }
393        }
394    }
395    let mut eig = [a[0][0], a[1][1], a[2][2]];
396    eig.sort_by(f64::total_cmp);
397    eig
398}
399
400/// Where one copy of a repeated part sits: the part as it is written, turned by `roll_rad` about
401/// the body's axis (from `x_B` toward `y_B`), then moved across the axis by `offset_m`, in body
402/// axes. A point `p` of the part goes to `R(roll) p + offset`.
403///
404/// A cluster's tubes only move (`roll_rad = 0`). A pod set's pod `k` also turns, by its angle
405/// `φ_k`, so that what a pod holds keeps its place relative to the airframe, as the fins of a fin
406/// set do ([ADR-089][adr-089]).
407///
408/// [adr-089]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-089-a-pod-is-a-stack-of-body-components-repeated-around-the-axis-2026-09-27
409#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct Placement {
412    /// How far the copy moves across the axis, `[x, y]` in body axes, m.
413    pub offset_m: [f64; 2],
414    /// How far the copy turns about the body's axis, from `x_B` toward `y_B`, rad.
415    #[serde(default)]
416    pub roll_rad: f64,
417}
418
419impl Placement {
420    /// The part where it is written: no move, no turn.
421    pub const HERE: Self = Self {
422        offset_m: [0.0, 0.0],
423        roll_rad: 0.0,
424    };
425
426    /// A copy moved by `offset_m` without turning, as a cluster's tube is.
427    pub fn moved(offset_m: [f64; 2]) -> Self {
428        Self {
429            offset_m,
430            roll_rad: 0.0,
431        }
432    }
433
434    /// Where the point `[x, y]` of the part (body axes, m) goes: `R(roll) [x, y] + offset`.
435    pub fn point(&self, [x, y]: [f64; 2]) -> [f64; 2] {
436        let [ox, oy] = self.offset_m;
437        if self.roll_rad == 0.0 {
438            return [x + ox, y + oy];
439        }
440        let (sin, cos) = self.roll_rad.sin_cos();
441        [cos * x - sin * y + ox, sin * x + cos * y + oy]
442    }
443
444    /// The place of a copy that `inner` places inside a part that `self` places: `self` after
445    /// `inner`. The turns add, and `inner`'s offset turns with `self`.
446    #[must_use]
447    pub fn after(&self, inner: &Self) -> Self {
448        Self {
449            offset_m: self.point(inner.offset_m),
450            roll_rad: self.roll_rad + inner.roll_rad,
451        }
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use std::f64::consts::PI;
458
459    use super::*;
460
461    /// A place inside a place: the turns add and the inner offset turns with the outer place, so
462    /// placing a point by the composite is placing it by the inner place, then the outer one; and a
463    /// body placed by it is the body placed twice.
464    #[test]
465    fn placements_compose_as_turn_then_move() {
466        use std::f64::consts::FRAC_PI_2;
467        let outer = Placement {
468            offset_m: [1.0, 0.0],
469            roll_rad: FRAC_PI_2,
470        };
471        let inner = Placement::moved([0.1, 0.0]);
472        let both = outer.after(&inner);
473        assert!((both.offset_m[0] - 1.0).abs() < 1e-15, "{both:?}");
474        assert!((both.offset_m[1] - 0.1).abs() < 1e-15, "{both:?}");
475        assert_eq!(both.roll_rad, FRAC_PI_2);
476        let p = [0.02, -0.03];
477        let [a, b] = both.point(p);
478        let [c, d] = outer.point(inner.point(p));
479        assert!((a - c).abs() < 1e-15 && (b - d).abs() < 1e-15);
480        let body = MassProperties::axisymmetric(0.5, DVec3::new(0.01, 0.0, -0.2), 1e-4, 3e-3);
481        let once = MassProperties::placed(body, &[both]);
482        let twice = MassProperties::placed(MassProperties::placed(body, &[inner]), &[outer]);
483        assert!((once.cg_m - twice.cg_m).length() < 1e-15);
484        let gap = once.inertia_kg_m2 - twice.inertia_kg_m2;
485        for col in [gap.x_axis, gap.y_axis, gap.z_axis] {
486            assert!(col.abs().max_element() < 1e-15, "{gap:?}");
487        }
488        assert_eq!(MassProperties::placed(body, &[Placement::HERE]), body);
489    }
490
491    fn assert_mat_close(a: DMat3, b: DMat3, tol: f64) {
492        let diff = (a - b)
493            .to_cols_array()
494            .iter()
495            .fold(0.0f64, |m, v| m.max(v.abs()));
496        assert!(diff <= tol, "{a:?}\nvs\n{b:?}\n(difference {diff:e})");
497    }
498
499    /// A solid box with sides `sx, sy, sz` centered at `center`: `I = m/12 diag(sy²+sz², ...)`.
500    fn box_body(mass: f64, sides: DVec3, center: DVec3) -> MassProperties {
501        let s2 = sides * sides;
502        MassProperties {
503            mass_kg: mass,
504            cg_m: center,
505            inertia_kg_m2: DMat3::from_diagonal(
506                DVec3::new(s2.y + s2.z, s2.x + s2.z, s2.x + s2.y) * (mass / 12.0),
507            ),
508        }
509    }
510
511    #[test]
512    fn two_boxes_side_by_side_are_one_box() {
513        // Two 1×1×1 boxes of 3 kg at x = ±0.5 make a 2×1×1 box of 6 kg at the origin.
514        let left = box_body(3.0, DVec3::ONE, DVec3::new(-0.5, 0.0, 0.0));
515        let right = box_body(3.0, DVec3::ONE, DVec3::new(0.5, 0.0, 0.0));
516        let joined = MassProperties::combine([&left, &right]);
517        let whole = box_body(6.0, DVec3::new(2.0, 1.0, 1.0), DVec3::ZERO);
518        assert_eq!(joined.mass_kg, 6.0);
519        assert!(joined.cg_m.length() < 1e-15);
520        assert_mat_close(joined.inertia_kg_m2, whole.inertia_kg_m2, 1e-15);
521    }
522
523    #[test]
524    fn point_masses_give_products_of_inertia_by_hand() {
525        // 2 kg at (1, 2, 0) and 2 kg at (−1, −2, 0): center at the origin, and by hand
526        // I_xx = Σ m(y²+z²) = 16, I_yy = Σ m(x²+z²) = 4, I_zz = Σ m(x²+y²) = 20,
527        // I_xy = −Σ m x y = −8, I_xz = I_yz = 0.
528        let a = MassProperties::point(2.0, DVec3::new(1.0, 2.0, 0.0));
529        let b = MassProperties::point(2.0, DVec3::new(-1.0, -2.0, 0.0));
530        let c = MassProperties::combine([&a, &b]);
531        let expected = DMat3::from_cols(
532            DVec3::new(16.0, -8.0, 0.0),
533            DVec3::new(-8.0, 4.0, 0.0),
534            DVec3::new(0.0, 0.0, 20.0),
535        );
536        assert_mat_close(c.inertia_kg_m2, expected, 1e-15);
537        let principal = c.principal_moments_kg_m2();
538        for (got, want) in principal.iter().zip([0.0, 20.0, 20.0]) {
539            assert!((got - want).abs() < 1e-14, "{principal:?}");
540        }
541        c.validate().unwrap();
542        // About a point off the center: add m (|d|² E − d dᵀ) with d = (0, 0, 1) and m = 4.
543        let about = c.inertia_about(DVec3::new(0.0, 0.0, -1.0));
544        assert_mat_close(
545            about - c.inertia_kg_m2,
546            DMat3::from_diagonal(DVec3::new(4.0, 4.0, 0.0)),
547            1e-15,
548        );
549    }
550
551    #[test]
552    fn a_part_moved_inside_a_body_gives_the_hand_computed_whole() {
553        // A 4 kg unit cube at the origin (2/3 kg·m² about each axis) holding a 1 kg point at
554        // (0.1, 0, 0), which slides by (0, 0, −0.5). By hand, with M = 5: cg' = (0.02, 0, −0.1),
555        // so the cube sits at d = (−0.02, 0, 0.1) from it and the point at (0.08, 0, −0.4), and
556        // I_xx = 2/3 + 4·0.01 + 0.16, I_yy = 2/3 + 4·0.0104 + 0.1664, I_zz = 2/3 + 4·0.0004 + 0.0064,
557        // I_xz = −(4·(−0.02)·0.1 + 0.08·(−0.4)) = 0.04.
558        let cube = box_body(4.0, DVec3::ONE, DVec3::ZERO);
559        let part = MassProperties::point(1.0, DVec3::new(0.1, 0.0, 0.0));
560        let whole = MassProperties::combine([&cube, &part]);
561        let moved = whole.with_part_moved(&part, DVec3::new(0.0, 0.0, -0.5));
562        assert_eq!(moved.mass_kg, 5.0);
563        assert!((moved.cg_m - DVec3::new(0.02, 0.0, -0.1)).length() < 1e-16);
564        let third = 2.0 / 3.0;
565        let expected = DMat3::from_cols(
566            DVec3::new(third + 0.2, 0.0, 0.04),
567            DVec3::new(0.0, third + 0.208, 0.0),
568            DVec3::new(0.04, 0.0, third + 0.008),
569        );
570        assert_mat_close(moved.inertia_kg_m2, expected, 1e-15);
571        // The same as building the whole again with the part where it now is, and moving it back
572        // gives the whole it started as.
573        let rebuilt =
574            MassProperties::combine([&cube, &part.translated(DVec3::new(0.0, 0.0, -0.5))]);
575        assert!((moved.cg_m - rebuilt.cg_m).length() < 1e-16);
576        assert_mat_close(moved.inertia_kg_m2, rebuilt.inertia_kg_m2, 1e-15);
577        let back = moved.with_part_moved(
578            &part.translated(DVec3::new(0.0, 0.0, -0.5)),
579            DVec3::new(0.0, 0.0, 0.5),
580        );
581        assert!((back.cg_m - whole.cg_m).length() < 1e-16);
582        assert_mat_close(back.inertia_kg_m2, whole.inertia_kg_m2, 1e-15);
583    }
584
585    #[test]
586    fn a_part_taken_out_of_a_body_leaves_the_hand_computed_rest() {
587        // A 4 kg unit cube at the origin (2/3 kg·m² about each axis) holding a 1 kg box
588        // 0.1 × 0.2 × 0.3 m at (0.1, 0, −0.5). Taking the box out must leave the cube as it was.
589        let cube = box_body(4.0, DVec3::ONE, DVec3::ZERO);
590        let part = box_body(1.0, DVec3::new(0.1, 0.2, 0.3), DVec3::new(0.1, 0.0, -0.5));
591        let whole = MassProperties::combine([&cube, &part]);
592        assert!((whole.cg_m - DVec3::new(0.02, 0.0, -0.1)).length() < 1e-16);
593        let rest = whole.without_part(&part);
594        assert!((rest.mass_kg - 4.0).abs() < 1e-15);
595        assert!(rest.cg_m.length() < 1e-16, "{:?}", rest.cg_m);
596        assert_mat_close(
597            rest.inertia_kg_m2,
598            DMat3::from_diagonal(DVec3::splat(2.0 / 3.0)),
599            1e-15,
600        );
601        // Putting it back gives the whole again.
602        let again = MassProperties::combine([&rest, &part]);
603        assert!((again.cg_m - whole.cg_m).length() < 1e-16);
604        assert_mat_close(again.inertia_kg_m2, whole.inertia_kg_m2, 1e-15);
605    }
606
607    #[test]
608    fn rotation_and_roll_move_the_tensor_with_the_body() {
609        let body = box_body(1.0, DVec3::new(0.2, 0.4, 1.0), DVec3::new(0.1, 0.0, -0.5));
610        // A quarter roll swaps x and y: the center moves to (0, 0.1, −0.5) and I_xx ↔ I_yy.
611        let rolled = body.rolled(std::f64::consts::FRAC_PI_2);
612        assert!((rolled.cg_m - DVec3::new(0.0, 0.1, -0.5)).length() < 1e-16);
613        let d = body.inertia_kg_m2;
614        assert_mat_close(
615            rolled.inertia_kg_m2,
616            DMat3::from_diagonal(DVec3::new(d.y_axis.y, d.x_axis.x, d.z_axis.z)),
617            1e-16,
618        );
619        // A 30° roll of a body with I_xx ≠ I_yy gives I_xy = −(I_yy − I_xx)... by the rotation
620        // formula: I'_xy = (I_xx − I_yy) sin θ cos θ.
621        let theta = 30f64.to_radians();
622        let turned = body.rolled(theta);
623        let expected_xy = (d.x_axis.x - d.y_axis.y) * theta.sin() * theta.cos();
624        assert!((turned.inertia_kg_m2.y_axis.x - expected_xy).abs() < 1e-16);
625        // Rotation keeps the principal moments.
626        let a = body.principal_moments_kg_m2();
627        let q = DQuat::from_euler(glam::EulerRot::XYZ, 0.3, -1.1, 2.0);
628        let b = body.rotated(q).principal_moments_kg_m2();
629        for k in 0..3 {
630            assert!((a[k] - b[k]).abs() < 1e-15, "{a:?} vs {b:?}");
631        }
632    }
633
634    #[test]
635    fn motor_elements_land_on_the_axis_and_scaling_keeps_the_shape() {
636        let element = MassElement::hollow_cylinder(1.2, 0.3, 0.027, 0.01, 0.4);
637        let placed = MassProperties::from_motor_element(&element, -1.5);
638        assert_eq!(placed.cg_m, DVec3::new(0.0, 0.0, -1.2));
639        assert_eq!(placed.inertia_kg_m2.z_axis.z, element.axial_inertia_kg_m2);
640        assert_eq!(
641            placed.inertia_kg_m2.x_axis.x,
642            element.transverse_inertia_kg_m2
643        );
644        let half = placed.scaled(0.5);
645        assert_eq!(half.mass_kg, 0.6);
646        assert_eq!(
647            half.inertia_kg_m2.z_axis.z,
648            0.5 * element.axial_inertia_kg_m2
649        );
650    }
651
652    fn close(got: f64, want: f64, rel: f64, what: &str) {
653        let err = ((got - want) / want).abs();
654        assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
655    }
656
657    /// Loft lesson L44: Loft's inertia was pitch only, used the rod formula `mL²/12` without the
658    /// radial term, ignored fin span, and gave rings and masses zero.
659    #[test]
660    fn thin_tube_inertia_includes_radial_term() {
661        use crate::fins::{FinCrossSection, FinPlanform, FinSet};
662        use crate::material::Material;
663        use crate::parts::{BodyTube, CenteringRing, MassComponent, Packing};
664        let tube = BodyTube {
665            length_m: 0.6,
666            outer_radius_m: 0.0508,
667            thickness_m: 0.0015,
668            material: Material::bulk("fiberglass", 1990.0),
669        };
670        let g = tube.mass_properties().unwrap();
671        let (big, small) = (0.0508f64, 0.0493f64);
672        let radii = big * big + small * small;
673        let rod = g.mass_kg * 0.36 / 12.0;
674        close(
675            g.inertia_kg_m2.x_axis.x,
676            g.mass_kg * (radii / 4.0 + 0.36 / 12.0),
677            1e-14,
678            "pitch",
679        );
680        close(
681            g.inertia_kg_m2.y_axis.y,
682            g.inertia_kg_m2.x_axis.x,
683            1e-15,
684            "yaw",
685        );
686        close(
687            g.inertia_kg_m2.z_axis.z,
688            g.mass_kg * radii / 2.0,
689            1e-14,
690            "roll",
691        );
692        // For this 4" tube the radial term is 2% of the rod value; it grows as tubes get stubbier.
693        assert!(g.inertia_kg_m2.x_axis.x > 1.02 * rod);
694
695        // Fins: the span sets the roll inertia and part of pitch.
696        let fins = FinSet {
697            count: 3,
698            planform: FinPlanform::Trapezoidal {
699                root_chord_m: 0.2,
700                tip_chord_m: 0.1,
701                span_m: 0.12,
702                sweep_m: 0.1,
703            },
704            thickness_m: 0.003,
705            cross_section: FinCrossSection::Square,
706            tab: None,
707            fillet: None,
708            cant_rad: 0.0,
709            base_angle_rad: 0.0,
710            material: Material::bulk("G10", 1800.0),
711        };
712        let f = fins.mass_properties(0.0508).unwrap();
713        // Every fin element is at least R_b from the axis.
714        assert!(f.inertia_kg_m2.z_axis.z > f.mass_kg * 0.0508 * 0.0508);
715        assert!(f.inertia_kg_m2.x_axis.x > 0.5 * f.inertia_kg_m2.z_axis.z);
716
717        // Rings and packed masses have inertia of their own.
718        let ring = CenteringRing {
719            length_m: 0.006,
720            outer_radius_m: 0.0493,
721            inner_radius_m: 0.0275,
722            material: Material::bulk("plywood", 630.0),
723        };
724        let r = ring.mass_properties().unwrap();
725        close(
726            r.inertia_kg_m2.z_axis.z,
727            r.mass_kg * (0.0493f64.powi(2) + 0.0275f64.powi(2)) / 2.0,
728            1e-14,
729            "ring roll",
730        );
731        let bay = MassComponent {
732            mass_kg: 0.4,
733            packing: Packing {
734                length_m: 0.15,
735                radius_m: 0.045,
736                radial_offset_m: 0.0,
737                angle_rad: 0.0,
738            },
739        };
740        let b = bay.mass_properties().unwrap();
741        close(
742            b.inertia_kg_m2.x_axis.x,
743            0.4 * (3.0 * 0.045f64.powi(2) + 0.0225) / 12.0,
744            1e-14,
745            "bay",
746        );
747    }
748
749    /// Loft lesson L45: Loft put a hollow transition's CG at the solid centroid and hard-coded a
750    /// freeform fin's CG at 0.42 of the root chord.
751    #[test]
752    fn hollow_transition_and_freeform_fin_cg_are_exact_centroids() {
753        use crate::fins::{FinCrossSection, FinPlanform, FinSet};
754        use crate::material::Material;
755        use crate::parts::Transition;
756        use crate::shapes::NoseShape;
757        use crate::solids::Wall;
758        // A conical shoulderless transition from R1 to R2 with a wall t normal to the surface. Cut
759        // square, the inner surface would be the outer line moved in by t √(1 + k²),
760        // k = (R2 − R1)/L, giving a wall of area π t w (2R1 − t w + 2kx) at x, w = √(1 + k²).
761        let (r1, r2, l, t) = (0.0381f64, 0.0508f64, 0.1f64, 0.002f64);
762        let k = (r2 - r1) / l;
763        let w = (1.0 + k * k).sqrt();
764        let square_volume = PI * t * w * ((2.0 * r1 - t * w) * l + k * l * l);
765        let square_moment =
766            PI * t * w * ((2.0 * r1 - t * w) * l * l / 2.0 + 2.0 * k * l.powi(3) / 3.0);
767        // At the fore end the surface meets the end plane at an obtuse angle inside the wall, so
768        // the wall is the square cut less the sliver outside the fore rim's circle. In polar
769        // coordinates (ρ, ψ) about the rim, with φ = atan k, the sliver is 0 ≤ ψ ≤ φ,
770        // t < ρ ≤ t / cos(φ − ψ), at x = ρ sin ψ and r = R1 − ρ cos ψ; its volume and first moment
771        // are 2π ∬ r ρ dρ dψ and 2π ∬ x r ρ dρ dψ, done in ρ by hand and in ψ by quadrature.
772        let phi = k.atan();
773        let tol = hpr_core::quadrature::Tolerance::default();
774        let (sliver_volume, sliver_moment) = {
775            let span = |psi: f64| (t, t / (phi - psi).cos());
776            let volume = hpr_core::quadrature::integrate_scalar(
777                |psi| {
778                    let (a, b) = span(psi);
779                    r1 * (b * b - a * a) / 2.0 - psi.cos() * (b.powi(3) - a.powi(3)) / 3.0
780                },
781                0.0,
782                phi,
783                tol,
784            )
785            .unwrap();
786            let moment = hpr_core::quadrature::integrate_scalar(
787                |psi| {
788                    let (a, b) = span(psi);
789                    r1 * psi.sin() * (b.powi(3) - a.powi(3)) / 3.0
790                        - psi.sin() * psi.cos() * (b.powi(4) - a.powi(4)) / 4.0
791                },
792                0.0,
793                phi,
794                tol,
795            )
796            .unwrap();
797            (2.0 * PI * volume, 2.0 * PI * moment)
798        };
799        let volume = square_volume - sliver_volume;
800        let moment = square_moment - sliver_moment;
801        // The sliver's section is ∬ ρ dρ dψ = (t²/2) ∫ (sec²(φ − ψ) − 1) dψ = t² (tan φ − φ)/2.
802        let sliver_area = hpr_core::quadrature::integrate_scalar(
803            |psi| 0.5 * t * t * ((phi - psi).cos().powi(-2) - 1.0),
804            0.0,
805            phi,
806            tol,
807        )
808        .unwrap();
809        close(
810            sliver_area,
811            t * t * (phi.tan() - phi) / 2.0,
812            1e-10,
813            "sliver section",
814        );
815        let transition = Transition {
816            shape: NoseShape::Conical {},
817            clipped: false,
818            length_m: l,
819            fore_radius_m: r1,
820            aft_radius_m: r2,
821            wall: Wall::Shell { thickness_m: t },
822            fore_shoulder: None,
823            aft_shoulder: None,
824            material: Material::bulk("PLA", 1240.0),
825        };
826        let g = transition.mass_properties().unwrap();
827        close(g.mass_kg, 1240.0 * volume, 1e-10, "transition mass");
828        close(-g.cg_m.z, moment / volume, 1e-10, "transition center");
829        // The solid frustum's centroid is further aft; the shell's is not the same point.
830        let solid =
831            l * (r1 * r1 + 2.0 * r1 * r2 + 3.0 * r2 * r2) / (4.0 * (r1 * r1 + r1 * r2 + r2 * r2));
832        assert!((moment / volume - solid).abs() > 1e-3);
833
834        // A non-convex (M-shaped) freeform fin against the polygon centroid (shoelace formulas).
835        let points = vec![
836            [0.0, 0.0],
837            [0.04, 0.1],
838            [0.07, 0.05],
839            [0.1, 0.1],
840            [0.14, 0.0],
841        ];
842        let (mut a2, mut cx, mut cy) = (0.0, 0.0, 0.0);
843        for i in 0..points.len() {
844            let [x0, y0] = points[i];
845            let [x1, y1] = points[(i + 1) % points.len()];
846            let cross = x0 * y1 - x1 * y0;
847            a2 += cross;
848            cx += (x0 + x1) * cross;
849            cy += (y0 + y1) * cross;
850        }
851        let (cx, cy) = (cx / (3.0 * a2), cy / (3.0 * a2));
852        let fins = FinSet {
853            count: 1,
854            planform: FinPlanform::Freeform {
855                points_m: points,
856                root_m: Vec::new(),
857            },
858            thickness_m: 0.003,
859            cross_section: FinCrossSection::Square,
860            tab: None,
861            fillet: None,
862            cant_rad: 0.0,
863            base_angle_rad: 0.0,
864            material: Material::bulk("plywood", 630.0),
865        };
866        let fin = fins.single_fin(0.04).unwrap();
867        close(
868            fin.mass_kg,
869            630.0 * 0.003 * 0.5 * a2.abs(),
870            1e-12,
871            "fin mass",
872        );
873        close(-fin.cg_m.z, cx, 1e-12, "fin centroid along the chord");
874        close(fin.cg_m.x, 0.04 + cy, 1e-12, "fin centroid across the span");
875        assert!((cx - 0.42 * 0.14).abs() > 1e-2);
876    }
877
878    /// Loft lesson L46: Loft never read fin tabs (100 to 120 g lost on two designs) and weighed
879    /// rail buttons at 0 kg.
880    #[test]
881    fn fin_tab_and_rail_button_mass_counted() {
882        use crate::fins::{FinCrossSection, FinPlanform, FinSet, FinTab};
883        use crate::material::Material;
884        use crate::parts::RailButton;
885        let (h, len, t, rho, rb) = (0.02f64, 0.15f64, 0.003f64, 1800.0f64, 0.0508f64);
886        let mut fins = FinSet {
887            count: 4,
888            planform: FinPlanform::Trapezoidal {
889                root_chord_m: 0.2,
890                tip_chord_m: 0.08,
891                span_m: 0.1,
892                sweep_m: 0.12,
893            },
894            thickness_m: t,
895            cross_section: FinCrossSection::Square,
896            tab: None,
897            fillet: None,
898            cant_rad: 0.0,
899            base_angle_rad: 0.0,
900            material: Material::bulk("G10", rho),
901        };
902        let bare = fins.single_fin(rb).unwrap();
903        fins.tab = Some(FinTab {
904            height_m: h,
905            length_m: len,
906            offset_m: 0.025,
907        });
908        let tabbed = fins.single_fin(rb).unwrap();
909        let tab_mass = rho * h * len * t;
910        close(tabbed.mass_kg - bare.mass_kg, tab_mass, 1e-12, "tab mass");
911        // The tab's center is at radius R_b − h/2 and 0.025 + len/2 aft of the root leading edge.
912        let expected_r = (bare.mass_kg * bare.cg_m.x + tab_mass * (rb - h / 2.0)) / tabbed.mass_kg;
913        close(
914            tabbed.cg_m.x,
915            expected_r,
916            1e-12,
917            "tab moves the center inward",
918        );
919        let set = fins.mass_properties(rb).unwrap();
920        close(set.mass_kg, 4.0 * tabbed.mass_kg, 1e-14, "four tabbed fins");
921
922        // A 1010-size button: 11.1 mm flange and base, 6.3 mm waist, 8.6 mm tall.
923        let button = RailButton {
924            outer_diameter_m: 0.0111,
925            inner_diameter_m: 0.0063,
926            height_m: 0.0086,
927            base_height_m: 0.0025,
928            flange_height_m: 0.0022,
929            screw_height_m: 0.0,
930            angle_rad: 0.0,
931            count: 2,
932            spacing_m: 0.5,
933            material: Material::bulk("acetal", 1420.0),
934        };
935        let g = button.mass_properties(rb).unwrap();
936        let one = 1420.0
937            * PI
938            * (0.0111f64.powi(2) / 4.0 * (0.0025 + 0.0022) + 0.0063f64.powi(2) / 4.0 * 0.0039);
939        close(g.mass_kg, 2.0 * one, 1e-12, "two rail buttons");
940        assert!(g.mass_kg > 1e-3);
941    }
942
943    /// A random physical body: a box of random sides and mass, turned and moved at random.
944    fn arbitrary_body() -> impl proptest::strategy::Strategy<Value = MassProperties> {
945        use proptest::prelude::*;
946        (
947            0.01..10.0f64,
948            prop::array::uniform3(0.01..2.0f64),
949            prop::array::uniform3(-3.0..3.0f64),
950            prop::array::uniform3(-3.2..3.2f64),
951        )
952            .prop_map(|(mass, sides, center, angles)| {
953                box_body(mass, DVec3::from(sides), DVec3::ZERO)
954                    .rotated(DQuat::from_euler(
955                        glam::EulerRot::ZYX,
956                        angles[0],
957                        angles[1],
958                        angles[2],
959                    ))
960                    .translated(DVec3::from(center))
961            })
962    }
963
964    proptest::proptest! {
965        #[test]
966        fn combining_is_associative_and_order_free(
967            a in arbitrary_body(),
968            b in arbitrary_body(),
969            c in arbitrary_body(),
970        ) {
971            let all = MassProperties::combine([&a, &b, &c]);
972            let nested = MassProperties::combine([&MassProperties::combine([&c, &a]), &b]);
973            let scale = all.inertia_kg_m2.to_cols_array().iter().fold(0.0f64, |m, v| m.max(v.abs()));
974            proptest::prop_assert!((all.mass_kg - nested.mass_kg).abs() <= 1e-13 * all.mass_kg);
975            proptest::prop_assert!((all.cg_m - nested.cg_m).length() <= 1e-12);
976            let diff = (all.inertia_kg_m2 - nested.inertia_kg_m2)
977                .to_cols_array()
978                .iter()
979                .fold(0.0f64, |m, v| m.max(v.abs()));
980            proptest::prop_assert!(diff <= 1e-11 * scale);
981            proptest::prop_assert!(all.validate().is_ok());
982        }
983
984        #[test]
985        fn turning_a_body_keeps_its_principal_moments(
986            body in arbitrary_body(),
987            angles in proptest::array::uniform3(-3.2..3.2f64),
988        ) {
989            let q = DQuat::from_euler(glam::EulerRot::XYZ, angles[0], angles[1], angles[2]);
990            let turned = body.rotated(q);
991            let (a, b) = (body.principal_moments_kg_m2(), turned.principal_moments_kg_m2());
992            for k in 0..3 {
993                proptest::prop_assert!((a[k] - b[k]).abs() <= 1e-12 * a[2]);
994            }
995            proptest::prop_assert!(turned.validate().is_ok());
996            // The inertia about any point is at least the inertia about the center of mass.
997            let about = body.inertia_about(DVec3::new(0.3, -0.2, 1.0));
998            let delta = MassProperties { inertia_kg_m2: about - body.inertia_kg_m2, ..body };
999            let extra = delta.principal_moments_kg_m2();
1000            // `delta` is m(|d|² 1 − d dᵀ), with an exact zero eigenvalue along d: its rounding is
1001            // relative to the tensor's own size, which can dwarf the body's moments (#20).
1002            let size = delta
1003                .inertia_kg_m2
1004                .to_cols_array()
1005                .iter()
1006                .fold(a[2], |m, v| m.max(v.abs()));
1007            proptest::prop_assert!(extra[0] >= -1e-12 * size);
1008        }
1009    }
1010
1011    #[test]
1012    fn massless_parts_and_unphysical_tensors() {
1013        let none: [&MassProperties; 0] = [];
1014        assert_eq!(MassProperties::combine(none), MassProperties::ZERO);
1015        let a = MassProperties::point(0.0, DVec3::new(0.0, 0.0, -1.0));
1016        let b = MassProperties::point(0.0, DVec3::new(0.0, 0.0, -3.0));
1017        assert_eq!(
1018            MassProperties::combine([&a, &b]).cg_m,
1019            DVec3::new(0.0, 0.0, -2.0)
1020        );
1021        let bad = MassProperties::axisymmetric(1.0, DVec3::ZERO, 1.0, 0.1);
1022        assert!(matches!(
1023            bad.validate(),
1024            Err(DesignError::UnphysicalInertia(_))
1025        ));
1026        let negative = MassProperties::axisymmetric(1.0, DVec3::ZERO, -1.0, 1.0);
1027        assert!(negative.validate().is_err());
1028        assert!(MassProperties::point(-1.0, DVec3::ZERO).validate().is_err());
1029        // A thin rod along z has I_zz = 0 and I_xx = I_yy: on the triangle-inequality boundary.
1030        MassProperties::axisymmetric(1.0, DVec3::ZERO, 0.0, 0.5)
1031            .validate()
1032            .unwrap();
1033        // No mass, no inertia; massless placeholders stay valid.
1034        assert!(matches!(
1035            MassProperties::axisymmetric(0.0, DVec3::ZERO, 1.0, 5.0).validate(),
1036            Err(DesignError::UnphysicalInertia(_))
1037        ));
1038        MassProperties::combine([&a, &b]).validate().unwrap();
1039    }
1040}