1use hpr_core::{DMat3, DQuat, DVec3};
30use hpr_motor::MassElement;
31use serde::{Deserialize, Serialize};
32
33use crate::error::DesignError;
34
35#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37pub struct MassProperties {
38 pub mass_kg: f64,
40 pub cg_m: DVec3,
42 pub inertia_kg_m2: DMat3,
44}
45
46impl Default for MassProperties {
47 fn default() -> Self {
48 Self::ZERO
49 }
50}
51
52fn offset_tensor(d: DVec3) -> DMat3 {
54 DMat3::from_diagonal(DVec3::splat(d.length_squared())) - outer(d, d)
55}
56
57fn outer(a: DVec3, b: DVec3) -> DMat3 {
59 DMat3::from_cols(a * b.x, a * b.y, a * b.z)
60}
61
62impl MassProperties {
63 pub const ZERO: Self = Self {
65 mass_kg: 0.0,
66 cg_m: DVec3::ZERO,
67 inertia_kg_m2: DMat3::ZERO,
68 };
69
70 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 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 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 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 #[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 #[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 #[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 #[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 #[must_use]
187 pub fn rolled(&self, angle_rad: f64) -> Self {
188 self.rotated(DQuat::from_rotation_z(angle_rad))
189 }
190
191 #[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 #[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 #[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 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 pub fn principal_moments_kg_m2(&self) -> [f64; 3] {
284 symmetric_eigenvalues(self.inertia_kg_m2)
285 }
286
287 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
347fn 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct Placement {
412 pub offset_m: [f64; 2],
414 #[serde(default)]
416 pub roll_rad: f64,
417}
418
419impl Placement {
420 pub const HERE: Self = Self {
422 offset_m: [0.0, 0.0],
423 roll_rad: 0.0,
424 };
425
426 pub fn moved(offset_m: [f64; 2]) -> Self {
428 Self {
429 offset_m,
430 roll_rad: 0.0,
431 }
432 }
433
434 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 #[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 assert!(g.inertia_kg_m2.x_axis.x > 1.02 * rod);
694
695 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 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 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 #[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 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 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 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 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 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 #[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 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 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 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 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 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 MassProperties::axisymmetric(1.0, DVec3::ZERO, 0.0, 0.5)
1031 .validate()
1032 .unwrap();
1033 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}