1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
40use ogeom_geom::Transformable as _;
41use ogeom_math::{Direction, Matrix3, Point, Vector};
42use ogeom_mesh::{Deflection, discretize};
43use ogeom_topo::{EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, explore, explore_unique};
44
45#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct MassProperties {
48 pub mass: f64,
54 pub centre: Point,
57 pub inertia: Matrix3,
63 pub deflection: f64,
69}
70
71impl MassProperties {
72 #[must_use]
74 pub const fn none(deflection: f64) -> Self {
75 Self {
76 mass: 0.0,
77 centre: Point::ORIGIN,
78 inertia: Matrix3::ZERO,
79 deflection,
80 }
81 }
82
83 #[must_use]
85 pub fn inertia_about(&self, point: Point) -> Matrix3 {
86 let d = self.centre - point;
87 add(self.inertia, displacement_term(self.mass, d))
90 }
91
92 #[must_use]
98 pub fn radius_of_gyration(&self, axis: Direction) -> Option<f64> {
99 if self.mass <= 0.0 {
100 return None;
101 }
102 let v = axis.vector();
103 let i = quadratic_form(self.inertia, v);
104 Some((i / self.mass).max(0.0).sqrt())
105 }
106
107 pub fn principal_axes(&self, tol: Tolerances) -> OgeomResult<[(f64, Direction); 3]> {
119 let m = nalgebra::Matrix3::from_row_slice(&[
120 self.inertia.rows[0][0],
121 self.inertia.rows[0][1],
122 self.inertia.rows[0][2],
123 self.inertia.rows[1][0],
124 self.inertia.rows[1][1],
125 self.inertia.rows[1][2],
126 self.inertia.rows[2][0],
127 self.inertia.rows[2][1],
128 self.inertia.rows[2][2],
129 ]);
130 if !m.iter().all(|x| x.is_finite()) {
131 ogeom_bail!(NotDone, "the inertia tensor is not finite");
132 }
133 let eigen = nalgebra::SymmetricEigen::new(m);
136
137 let mut out: Vec<(f64, Direction)> = Vec::with_capacity(3);
138 for i in 0..3 {
139 let column = eigen.eigenvectors.column(i);
140 let axis = Direction::new(Vector::new(column[0], column[1], column[2]), tol)?;
141 out.push((eigen.eigenvalues[i], axis));
142 }
143 out.sort_by(|a, b| a.0.total_cmp(&b.0));
144 Ok([out[0], out[1], out[2]])
145 }
146}
147
148pub fn linear_properties(
158 model: &Model,
159 shape: &Shape,
160 deflection: Deflection,
161 tol: Tolerances,
162) -> OgeomResult<MassProperties> {
163 deflection.validate()?;
164 let mut acc = Accumulator::new();
165
166 for edge in explore_unique(model, shape, ShapeType::Edge)? {
167 let Some(node) = model.node(&edge) else {
168 ogeom_bail!(Dangling, "edge is not in this model");
169 };
170 let NodeData::Edge(data) = node.data() else {
171 ogeom_bail!(Construction, "edge node holds no edge data");
172 };
173 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
174 continue;
175 };
176 let Some(geometry) = model.geometry().curve(*curve) else {
177 ogeom_bail!(Dangling, "curve is not in this model");
178 };
179 let placement = edge.transform(model.datums())?;
180 let line = discretize(geometry, *range, deflection, tol)?;
181 for w in line.points.windows(2) {
182 let (a, b) = (placement.apply(w[0]), placement.apply(w[1]));
183 acc.add(&[a, b], a.distance(b));
184 }
185 }
186 Ok(acc.finish(deflection.chord))
187}
188
189pub fn surface_properties(
195 model: &Model,
196 shape: &Shape,
197 deflection: Deflection,
198 tol: Tolerances,
199) -> OgeomResult<MassProperties> {
200 deflection.validate()?;
201 if let Some(exact) = exact_surface_properties(model, shape, tol)? {
202 return Ok(exact);
203 }
204 let mut acc = Accumulator::new();
205
206 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
207 let mesh = ogeom_mesh::triangulate_face(model, &face, deflection, tol)?;
208 for triangle in &mesh.triangles {
209 let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
210 let area = (b - a).cross(c - a).magnitude() * 0.5;
213 acc.add(&[a, b, c], area);
214 }
215 }
216 Ok(acc.finish(deflection.chord))
217}
218
219pub fn volume_properties(
230 model: &Model,
231 shape: &Shape,
232 deflection: Deflection,
233 tol: Tolerances,
234) -> OgeomResult<MassProperties> {
235 deflection.validate()?;
236 if let Some(exact) = exact_volume_properties(model, shape, tol)? {
237 return Ok(exact);
238 }
239 let mut mesh = ogeom_mesh::triangulate(model, shape, deflection, tol)?;
240 if mesh.is_empty() {
241 return Ok(MassProperties::none(deflection.chord));
242 }
243 if !mesh.is_closed() {
244 let shells = explore_unique(model, shape, ShapeType::Shell)?;
251 let mut closed = !shells.is_empty();
252 for shell in &shells {
253 closed &= crate::build::is_shell_closed(model, shell)?;
254 }
255 if !closed {
256 ogeom_bail!(
257 Construction,
258 "the boundary is not closed, so it encloses no volume to measure"
259 );
260 }
261 let chords = ogeom_mesh::edge_chords_for(model, shape, deflection, tol)?;
262 mesh = ogeom_topo::Triangulation::new();
263 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
264 mesh.append(&ogeom_mesh::triangulate_face_with(
265 model, &face, deflection, &chords, tol,
266 )?);
267 }
268 }
269
270 let apex = mesh.positions[0];
275 let mut acc = Accumulator::new();
276 for triangle in &mesh.triangles {
277 let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
278 let volume = (a - apex).dot((b - apex).cross(c - apex)) / 6.0;
282 acc.add(&[apex, a, b, c], volume);
283 }
284
285 if acc.mass < 0.0 {
286 ogeom_bail!(
287 Construction,
288 "the boundary is wound inward, so the volume came out negative"
289 );
290 }
291 Ok(acc.finish(deflection.chord))
292}
293
294enum ExactFace {
299 ChartRectangle {
301 surface: ogeom_geom::SurfaceGeometry,
302 rect: (f64, f64, f64, f64),
303 sign: f64,
304 share: f64,
305 },
306 Disc {
308 centre: Point,
309 e1: Vector,
310 e2: Vector,
311 normal: Vector,
312 radius: f64,
313 sign: f64,
314 share: f64,
315 },
316 Chart(Box<crate::mass_chart::ChartFace>),
318}
319
320impl ExactFace {
321 const fn share(&self) -> f64 {
326 match self {
327 Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share,
328 Self::Chart(_) => 1.0,
329 }
330 }
331
332 fn chart_area(&self) -> f64 {
337 match self {
338 Self::Disc { radius, .. } => core::f64::consts::PI * radius * radius,
339 Self::ChartRectangle { rect, .. } => (rect.1 - rect.0) * (rect.3 - rect.2),
340 Self::Chart(_) => 0.0,
341 }
342 }
343
344 fn take_away(&mut self) {
345 match self {
346 Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share = -1.0,
347 Self::Chart(_) => {}
348 }
349 }
350}
351
352fn exact_volume_properties(
362 model: &Model,
363 shape: &Shape,
364 tol: Tolerances,
365) -> OgeomResult<Option<MassProperties>> {
366 let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
367 if faces.is_empty() {
368 return Ok(None);
369 }
370 let mut exact = Vec::with_capacity(faces.len());
371 for face in &faces {
372 match integrable_face(model, face, tol)? {
373 Some(found) => exact.extend(found),
374 None => {
375 if std::env::var_os("OGEOM_DEBUG_MASS").is_some() {
376 eprintln!(
377 "MASS face {} is not exactly integrable",
378 face.node().index()
379 );
380 }
381 return Ok(None);
382 }
383 }
384 }
385 if !flags_agree(model, shape, tol).unwrap_or(false) {
389 return Ok(None);
390 }
391 let shells = explore_unique(model, shape, ShapeType::Shell)?;
395 if shells.is_empty() {
396 return Ok(None);
397 }
398 for shell in shells {
399 if !crate::build::is_shell_closed(model, &shell)? {
400 ogeom_bail!(
401 Construction,
402 "the boundary is not closed, so it encloses no volume to measure"
403 );
404 }
405 }
406
407 let reference = reference_point(&exact, tol)?;
408 let mut mass = 0.0;
409 let mut first = Vector::ZERO;
410 let mut second = Matrix3::ZERO;
411 for face in &exact {
412 let settled = integrate_face(face, reference, tol, &mut |p, n_da, share| {
413 let n_da = n_da * share;
414 let q = p - reference;
415 mass += q.dot(n_da) / 3.0;
416 first += Vector::new(
417 q.x * q.x * n_da.x / 2.0,
418 q.y * q.y * n_da.y / 2.0,
419 q.z * q.z * n_da.z / 2.0,
420 );
421 let d = [q.x, q.y, q.z];
422 let nd = [n_da.x, n_da.y, n_da.z];
423 for i in 0..3 {
424 second.rows[i][i] += d[i] * d[i] * d[i] * nd[i] / 3.0;
426 for j in 0..3 {
428 if i != j {
429 second.rows[i][j] += d[i] * d[i] * d[j] * nd[i] / 2.0;
430 }
431 }
432 }
433 })?;
434 if !settled {
435 return Ok(None);
436 }
437 }
438 for i in 0..3 {
441 for j in (i + 1)..3 {
442 let mean = f64::midpoint(second.rows[i][j], second.rows[j][i]);
443 second.rows[i][j] = mean;
444 second.rows[j][i] = mean;
445 }
446 }
447 if mass < 0.0 {
448 ogeom_bail!(
449 Construction,
450 "the boundary is wound inward, so the volume came out negative"
451 );
452 }
453 let acc = Accumulator {
454 reference: Some(reference),
455 mass,
456 first,
457 second,
458 };
459 Ok(Some(acc.finish(0.0)))
460}
461
462fn exact_surface_properties(
464 model: &Model,
465 shape: &Shape,
466 tol: Tolerances,
467) -> OgeomResult<Option<MassProperties>> {
468 let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
469 if faces.is_empty() {
470 return Ok(None);
471 }
472 let mut exact = Vec::with_capacity(faces.len());
473 for face in &faces {
474 match integrable_face(model, face, tol)? {
475 Some(found) => exact.extend(found),
476 None => return Ok(None),
477 }
478 }
479 let reference = reference_point(&exact, tol)?;
480 let mut mass = 0.0;
481 let mut first = Vector::ZERO;
482 let mut second = Matrix3::ZERO;
483 for face in &exact {
484 let settled = integrate_face(face, reference, tol, &mut |p, n_da, share| {
485 let da = n_da.magnitude() * share;
486 let q = p - reference;
487 mass += da;
488 first += q * da;
489 for (i, qi) in [q.x, q.y, q.z].iter().enumerate() {
490 for (j, qj) in [q.x, q.y, q.z].iter().enumerate() {
491 second.rows[i][j] += qi * qj * da;
492 }
493 }
494 })?;
495 if !settled {
496 return Ok(None);
497 }
498 }
499 let acc = Accumulator {
500 reference: Some(reference),
501 mass,
502 first,
503 second,
504 };
505 Ok(Some(acc.finish(0.0)))
506}
507
508fn reference_point(faces: &[ExactFace], tol: Tolerances) -> OgeomResult<Point> {
510 use ogeom_geom::Surface as _;
511 match &faces[0] {
512 ExactFace::ChartRectangle { surface, rect, .. } => surface.point_at(rect.0, rect.2, tol),
513 ExactFace::Disc { centre, .. } => Ok(*centre),
514 ExactFace::Chart(chart) => chart.anchor(tol),
515 }
516}
517
518fn integrate_face(
524 face: &ExactFace,
525 _reference: Point,
526 tol: Tolerances,
527 contribute: &mut dyn FnMut(Point, Vector, f64),
528) -> OgeomResult<bool> {
529 let share = face.share();
530 use ogeom_geom::Surface as _;
531 const QUARTER: f64 = core::f64::consts::FRAC_PI_2;
532 match face {
533 ExactFace::Chart(chart) => Ok(chart.integrate(_reference, tol, contribute)),
534 ExactFace::ChartRectangle {
535 surface,
536 rect,
537 sign,
538 ..
539 } => {
540 let (u0, u1, v0, v1) = *rect;
541 let breaks = |lo: f64, hi: f64, knots: Option<&ogeom_math::KnotVector>| {
544 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
545 let panels = (((hi - lo) / QUARTER).ceil() as usize).max(1);
546 #[allow(clippy::cast_precision_loss)]
547 let mut out: Vec<f64> = (0..=panels)
548 .map(|i| lo + (hi - lo) * i as f64 / panels as f64)
549 .collect();
550 if let Some(knots) = knots {
551 out.extend(
552 knots
553 .distinct()
554 .into_iter()
555 .map(|(k, _)| k)
556 .filter(|k| *k > lo && *k < hi),
557 );
558 out.sort_by(f64::total_cmp);
559 out.dedup_by(|a, b| (*a - *b).abs() <= 1e-14);
560 }
561 out
562 };
563 fn curve_knots(curve: &ogeom_geom::Curve) -> Option<&ogeom_math::KnotVector> {
566 match curve {
567 ogeom_geom::Curve::BSpline(b) => Some(b.knots()),
568 ogeom_geom::Curve::Trimmed(t) => curve_knots(t.basis()),
569 _ => None,
570 }
571 }
572 let (u_knots, v_knots) = match surface {
573 ogeom_geom::SurfaceGeometry::BSpline(b) => (Some(b.u_knots()), Some(b.v_knots())),
574 ogeom_geom::SurfaceGeometry::Extrusion(e) => (curve_knots(e.curve()), None),
575 ogeom_geom::SurfaceGeometry::Revolution(r) => (None, curve_knots(r.curve())),
576 _ => (None, None),
577 };
578 let (u_breaks, v_breaks) = (breaks(u0, u1, u_knots), breaks(v0, v1, v_knots));
579 let mut failure = None;
580 for uw in u_breaks.windows(2) {
581 let (ua, ub) = (uw[0], uw[1]);
582 for vw in v_breaks.windows(2) {
583 let (va, vb) = (vw[0], vw[1]);
584 gauss2(ua, ub, va, vb, &mut |u, v, weight| {
588 if failure.is_some() {
589 return;
590 }
591 let sample = (|| -> OgeomResult<()> {
592 let p = surface.point_at(u, v, tol)?;
593 let (du, dv) = surface.d1_at(u, v, tol)?;
594 contribute(p, du.cross(dv) * (sign * weight), share);
595 Ok(())
596 })();
597 if let Err(e) = sample {
598 failure = Some(e);
599 }
600 });
601 }
602 }
603 match failure {
604 Some(e) => Err(e),
605 None => Ok(true),
606 }
607 }
608 ExactFace::Disc {
609 centre,
610 e1,
611 e2,
612 normal,
613 radius,
614 sign,
615 ..
616 } => {
617 let failure: Option<ogeom_core::OgeomError> = None;
618 let turns = 4;
619 for k in 0..turns {
620 #[allow(clippy::cast_precision_loss)]
621 let (ta, tb) = (
622 core::f64::consts::TAU * k as f64 / turns as f64,
623 core::f64::consts::TAU * (k + 1) as f64 / turns as f64,
624 );
625 gauss2(0.0, *radius, ta, tb, &mut |rho, theta, weight| {
626 if failure.is_some() {
627 return;
628 }
629 let p = *centre + (*e1 * theta.cos() + *e2 * theta.sin()) * rho;
630 contribute(p, *normal * (sign * rho * weight), share);
631 });
632 }
633 match failure {
634 Some(e) => Err(e),
635 None => Ok(true),
636 }
637 }
638 }
639}
640
641fn gauss2(a: f64, b: f64, c: f64, d: f64, f: &mut dyn FnMut(f64, f64, f64)) {
644 let mut us: Vec<(f64, f64)> = Vec::with_capacity(10);
648 ogeom_math::gauss_legendre(
649 |u| {
650 us.push((u, 0.0));
651 1.0
652 },
653 a,
654 b,
655 );
656 for (i, entry) in us.iter_mut().enumerate() {
660 let mut k = 0;
661 let w = ogeom_math::gauss_legendre(
662 |_| {
663 let value = if k == i { 1.0 } else { 0.0 };
664 k += 1;
665 value
666 },
667 a,
668 b,
669 );
670 entry.1 = w;
671 }
672 let mut vs: Vec<(f64, f64)> = Vec::with_capacity(10);
673 ogeom_math::gauss_legendre(
674 |v| {
675 vs.push((v, 0.0));
676 1.0
677 },
678 c,
679 d,
680 );
681 for (j, entry) in vs.iter_mut().enumerate() {
682 let mut k = 0;
683 let w = ogeom_math::gauss_legendre(
684 |_| {
685 let value = if k == j { 1.0 } else { 0.0 };
686 k += 1;
687 value
688 },
689 c,
690 d,
691 );
692 entry.1 = w;
693 }
694 for &(u, wu) in &us {
695 for &(v, wv) in &vs {
696 f(u, v, wu * wv);
697 }
698 }
699}
700
701fn integrable_face(
705 model: &Model,
706 face: &Shape,
707 tol: Tolerances,
708) -> OgeomResult<Option<Vec<ExactFace>>> {
709 if let Some(found) = exact_face(model, face, tol)? {
710 return Ok(Some(found));
711 }
712 Ok(crate::mass_chart::chart_face(model, face, tol)
713 .map(|chart| vec![ExactFace::Chart(Box::new(chart))]))
714}
715
716fn exact_face(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Vec<ExactFace>>> {
726 let Some(node) = model.node(face) else {
727 return Ok(None);
728 };
729 let NodeData::Face(data) = node.data() else {
730 return Ok(None);
731 };
732 let Some(surface) = model.geometry().surface(data.surface) else {
733 return Ok(None);
734 };
735 let analytic = matches!(
736 surface,
737 ogeom_geom::SurfaceGeometry::Plane(_)
738 | ogeom_geom::SurfaceGeometry::Cylinder(_)
739 | ogeom_geom::SurfaceGeometry::Cone(_)
740 | ogeom_geom::SurfaceGeometry::Sphere(_)
741 | ogeom_geom::SurfaceGeometry::Torus(_)
742 | ogeom_geom::SurfaceGeometry::BSpline(_)
746 | ogeom_geom::SurfaceGeometry::Extrusion(_)
747 | ogeom_geom::SurfaceGeometry::Revolution(_)
748 );
749 if !analytic {
750 return Ok(None);
751 }
752 let placement = face.transform(model.datums())?;
753 if !matches!(
757 placement.kind(),
758 ogeom_math::TransformKind::Identity
759 | ogeom_math::TransformKind::Translation
760 | ogeom_math::TransformKind::Rotation
761 ) {
762 return Ok(None);
763 }
764 let placed = surface.clone().transformed(&placement, tol)?;
765 let sign = if face.orientation() == ogeom_topo::Orientation::Reversed {
766 -1.0
767 } else {
768 1.0
769 };
770
771 let wires = model.ordered_children_of(face)?;
772 let mut regions = Vec::with_capacity(wires.len());
780 for wire in &wires {
781 let Some(region) = exact_wire(model, data, &placed, wire, sign, 1.0, tol)? else {
782 return Ok(None);
783 };
784 regions.push(region);
785 }
786 let Some(outer) = (0..regions.len()).max_by(|a, b| {
787 regions[*a]
788 .chart_area()
789 .total_cmp(®ions[*b].chart_area())
790 }) else {
791 return Ok(None);
792 };
793 for (index, region) in regions.iter_mut().enumerate() {
794 if index != outer {
795 region.take_away();
796 }
797 }
798 Ok(Some(regions))
799}
800
801fn flags_agree(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<bool> {
824 use ogeom_geom::Curve2d as _;
825 use ogeom_geom::Surface as _;
826 let placed_at = ogeom_topo::Location::default();
827 let mut walks: std::collections::HashMap<
828 ogeom_topo::TShapeId,
829 Vec<(bool, ogeom_topo::TShapeId, bool)>,
830 > = std::collections::HashMap::new();
831 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
832 if face.location() != &placed_at {
833 return Ok(true);
834 }
835 let Some(data) = model.node(&face).and_then(|n| n.data().as_face()).cloned() else {
836 return Ok(true);
837 };
838 let Some(surface) = model.geometry().surface(data.surface) else {
839 return Ok(true);
840 };
841 let placed = surface
842 .clone()
843 .transformed(&face.transform(model.datums())?, tol)?;
844 let flag = if face.orientation() == ogeom_topo::Orientation::Reversed {
845 -1.0
846 } else {
847 1.0
848 };
849 if let Some(stations) = crate::mass_chart::material_sides(model, &face, tol) {
852 for (edge, at, toward) in stations {
853 let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
854 let raw = du.cross(dv);
855 let inward = du * toward.x + dv * toward.y;
856 if raw.magnitude() <= tol.angular() || inward.magnitude() <= tol.angular() {
857 return Ok(true);
858 }
859 let out = raw / raw.magnitude() * flag;
860 let walk = out.cross(inward / inward.magnitude());
861 let station = placed.point_at(at.x, at.y, tol)?;
862 let Some(along) = edge_heading(model, &edge, station, tol)? else {
863 return Ok(true);
864 };
865 walks.entry(edge.node()).or_default().push((
866 walk.dot(along) > 0.0,
867 face.node(),
868 true,
869 ));
870 }
871 continue;
872 }
873 let wires = model.ordered_children_of(&face)?;
878 let mut middles: Vec<(ogeom_math::Point2, f64)> = Vec::with_capacity(wires.len());
879 let mut stations: Vec<Vec<(Shape, ogeom_math::Point2)>> = Vec::with_capacity(wires.len());
880 let mut uses: std::collections::HashMap<ogeom_topo::TShapeId, usize> =
883 std::collections::HashMap::new();
884 for wire in &wires {
885 for edge in model.ordered_children_of(wire)? {
886 *uses.entry(edge.node()).or_default() += 1;
887 }
888 }
889 for wire in &wires {
890 let mut here = Vec::new();
891 let mut sum = ogeom_math::Vector2::new(0.0, 0.0);
892 let (mut lo, mut hi) = (
893 ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY),
894 ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
895 );
896 let mut once: Vec<(Shape, [ogeom_topo::PCurveId; 2], (f64, f64))> = Vec::new();
900 let mut ends: Vec<ogeom_math::Point2> = Vec::new();
901 for edge in model.ordered_children_of(wire)? {
902 if edge.location() != &placed_at {
903 return Ok(true);
904 }
905 let Some(repr) = model
906 .node(&edge)
907 .and_then(|n| n.data().as_edge())
908 .and_then(|d| d.pcurve_for(data.surface, edge.location()))
909 else {
910 return Ok(true);
911 };
912 let sides: Vec<(ogeom_topo::PCurveId, (f64, f64))> = match repr {
914 EdgeRepr::PCurve { curve, range, .. } => vec![(*curve, *range)],
915 EdgeRepr::Seam {
916 forward,
917 reversed,
918 range,
919 ..
920 } if uses.get(&edge.node()) == Some(&1) => {
921 once.push((edge.clone(), [*forward, *reversed], *range));
922 continue;
923 }
924 EdgeRepr::Seam {
925 forward,
926 reversed,
927 range,
928 ..
929 } => vec![(*forward, *range), (*reversed, *range)],
930 _ => return Ok(true),
931 };
932 for (id, range) in sides {
933 let Some(pcurve) = model.geometry().pcurve(id) else {
934 return Ok(true);
935 };
936 ends.push(pcurve.point_at(range.0, tol)?);
937 ends.push(pcurve.point_at(range.1, tol)?);
938 const STATIONS: usize = 4;
942 for step in 1..=STATIONS {
943 #[allow(clippy::cast_precision_loss)]
944 let t =
945 range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
946 let at = pcurve.point_at(t, tol)?;
947 sum += at.to_vector();
948 lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
949 hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
950 here.push((edge.clone(), at));
951 }
952 }
953 }
954 if here.is_empty() && once.is_empty() {
955 return Ok(true);
956 }
957 for (edge, sides, range) in once {
959 let mut best: Option<(f64, ogeom_topo::PCurveId)> = None;
960 for id in sides {
961 let Some(pcurve) = model.geometry().pcurve(id) else {
962 return Ok(true);
963 };
964 let mut d = f64::INFINITY;
965 for t in [range.0, range.1] {
966 let at = pcurve.point_at(t, tol)?;
967 for end in &ends {
968 d = d.min(at.distance(*end));
969 }
970 }
971 if best.is_none_or(|(held, _)| d < held) {
972 best = Some((d, id));
973 }
974 }
975 let Some((_, id)) = best else {
976 return Ok(true);
977 };
978 let Some(pcurve) = model.geometry().pcurve(id) else {
979 return Ok(true);
980 };
981 const STATIONS: usize = 4;
982 for step in 1..=STATIONS {
983 #[allow(clippy::cast_precision_loss)]
984 let t = range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
985 let at = pcurve.point_at(t, tol)?;
986 sum += at.to_vector();
987 lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
988 hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
989 here.push((edge.clone(), at));
990 }
991 }
992 #[allow(clippy::cast_precision_loss)]
993 let middle = ogeom_math::Point2::ORIGIN + sum / here.len() as f64;
994 middles.push((middle, (hi.x - lo.x) * (hi.y - lo.y)));
995 stations.push(here);
996 }
997 let Some(outer) = (0..middles.len()).max_by(|a, b| middles[*a].1.total_cmp(&middles[*b].1))
998 else {
999 return Ok(true);
1000 };
1001 for (index, here) in stations.into_iter().enumerate() {
1002 let (middle, _) = middles[index];
1003 for (edge, at) in here {
1004 let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
1005 let raw = du.cross(dv);
1006 if raw.magnitude() <= tol.angular() {
1007 return Ok(true);
1008 }
1009 let out = raw / raw.magnitude() * flag;
1010 let toward = middle - at;
1014 let toward = if index == outer { toward } else { -toward };
1015 let inward = du * toward.x + dv * toward.y;
1016 if inward.magnitude() <= tol.angular() {
1017 return Ok(true);
1018 }
1019 let walk = out.cross(inward / inward.magnitude());
1020 let station = placed.point_at(at.x, at.y, tol)?;
1026 let Some(along) = edge_heading(model, &edge, station, tol)? else {
1027 return Ok(true);
1028 };
1029 walks.entry(edge.node()).or_default().push((
1030 walk.dot(along) > 0.0,
1031 face.node(),
1032 index == outer,
1033 ));
1034 }
1035 }
1036 }
1037 if std::env::var_os("OGEOM_DEBUG_MASS").is_some() {
1038 eprintln!("MASS flags_agree walked {} edges", walks.len());
1039 }
1040 for (edge, uses) in &walks {
1041 if uses.iter().all(|(_, owner, _)| *owner == uses[0].1) {
1046 continue;
1047 }
1048 let ahead = uses.iter().filter(|(ahead, ..)| *ahead).count();
1049 if ahead * 2 != uses.len() {
1050 if std::env::var_os("OGEOM_DEBUG_MASS").is_some() {
1051 eprintln!("MASS edge {} is walked {uses:?}", edge.index());
1052 }
1053 return Ok(false);
1054 }
1055 }
1056 Ok(true)
1057}
1058
1059fn edge_heading(
1063 model: &Model,
1064 edge: &Shape,
1065 at: Point,
1066 tol: Tolerances,
1067) -> OgeomResult<Option<Vector>> {
1068 use ogeom_geom::Curve3d as _;
1069 let Some((curve, range)) = model
1070 .node(edge)
1071 .and_then(|n| n.data().as_edge())
1072 .and_then(|d| match d.curve3d()? {
1073 EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1074 _ => None,
1075 })
1076 .and_then(|(id, range)| Some((model.geometry().curve(id)?.clone(), range)))
1077 else {
1078 return Ok(None);
1079 };
1080 let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
1081 let gap = |t: f64| -> OgeomResult<f64> { Ok(curve.point_at(t, tol)?.distance(at)) };
1082 const SAMPLES: u32 = 32;
1083 let step = (range.1 - range.0) / f64::from(SAMPLES);
1084 let mut best = (range.0, gap(range.0)?);
1085 for k in 1..=SAMPLES {
1086 let t = range.0 + step * f64::from(k);
1087 let d = gap(t)?;
1088 if d < best.1 {
1089 best = (t, d);
1090 }
1091 }
1092 let (mut a, mut b) = (
1093 (best.0 - step.abs()).max(range.0.min(range.1)),
1094 (best.0 + step.abs()).min(range.0.max(range.1)),
1095 );
1096 let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
1097 for _ in 0..60 {
1098 let (c, d) = (b - (b - a) * ratio, a + (b - a) * ratio);
1099 if gap(c)? < gap(d)? {
1100 b = d;
1101 } else {
1102 a = c;
1103 }
1104 }
1105 let along = curve.d1_at(f64::midpoint(a, b), tol)?;
1106 Ok((along.magnitude() > tol.angular()).then(|| along / along.magnitude()))
1107}
1108
1109fn exact_wire(
1111 model: &Model,
1112 data: &ogeom_topo::FaceData,
1113 placed: &ogeom_geom::SurfaceGeometry,
1114 wire: &Shape,
1115 sign: f64,
1116 share: f64,
1117 tol: Tolerances,
1118) -> OgeomResult<Option<ExactFace>> {
1119 use ogeom_geom::Surface as _;
1120 let mut segments: Vec<(ogeom_math::Point2, ogeom_math::Point2)> = Vec::new();
1122 let mut columns: Vec<f64> = Vec::new();
1124 let mut rows: Vec<f64> = Vec::new();
1125 let mut circle: Option<(ogeom_geom::Circle2d, f64)> = None;
1126 let mut arc_ends: Vec<ogeom_math::Point2> = Vec::new();
1129 let mut pieces = 0_usize;
1130 let mut seams_seen: Vec<ogeom_topo::TShapeId> = Vec::new();
1132 for edge in model.ordered_children_of(wire)? {
1133 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1134 return Ok(None);
1135 };
1136 let Some(repr) = edge_data.pcurve_for(data.surface, edge.location()) else {
1137 return Ok(None);
1138 };
1139 pieces += 1;
1140 match repr {
1141 EdgeRepr::PCurve { curve, range, .. } => {
1142 let Some(pcurve) = model.geometry().pcurve(*curve) else {
1143 return Ok(None);
1144 };
1145 match pcurve {
1146 ogeom_geom::PlanarCurve::Line(_) => {
1147 use ogeom_geom::Curve2d as _;
1148 let a = pcurve.point_at(range.0, tol)?;
1149 let b = pcurve.point_at(range.1, tol)?;
1150 segments.push((a, b));
1151 }
1152 ogeom_geom::PlanarCurve::BSpline(spline)
1156 if along_one_chart_line(spline.control_points()) =>
1157 {
1158 use ogeom_geom::Curve2d as _;
1159 let a = pcurve.point_at(range.0, tol)?;
1160 let b = pcurve.point_at(range.1, tol)?;
1161 segments.push((a, b));
1162 }
1163 ogeom_geom::PlanarCurve::Circle(arc) => {
1164 use ogeom_geom::Curve2d as _;
1165 let span = (range.1 - range.0).abs();
1174 arc_ends.push(pcurve.point_at(range.0, tol)?);
1175 arc_ends.push(pcurve.point_at(range.1, tol)?);
1176 match &mut circle {
1177 None => circle = Some((*arc, span)),
1178 Some((held, total)) => {
1179 let (a, b) = (held.circle(), arc.circle());
1180 if a.centre().distance(b.centre()) > tol.confusion()
1181 || (a.radius() - b.radius()).abs() > tol.confusion()
1182 {
1183 return Ok(None);
1184 }
1185 *total += span;
1186 }
1187 }
1188 }
1189 _ => return Ok(None),
1190 }
1191 }
1192 EdgeRepr::Seam {
1193 forward,
1194 reversed,
1195 range,
1196 ..
1197 } => {
1198 use ogeom_geom::Curve2d as _;
1199 if seams_seen.contains(&edge.node()) {
1200 pieces -= 1;
1201 continue;
1202 }
1203 seams_seen.push(edge.node());
1204 for id in [forward, reversed] {
1211 let Some(pcurve) = model.geometry().pcurve(*id) else {
1212 return Ok(None);
1213 };
1214 let ogeom_geom::PlanarCurve::Line(_) = pcurve else {
1215 return Ok(None);
1216 };
1217 let (lo, hi) = pcurve.domain();
1218 let at = pcurve.point_at(range.0.clamp(lo, hi), tol)?;
1219 let far = pcurve.point_at(range.1.clamp(lo, hi), tol)?;
1220 if (at.x - far.x).abs() <= (at.y - far.y).abs() {
1221 columns.push(at.x);
1222 } else {
1223 rows.push(at.y);
1224 }
1225 }
1226 }
1227 _ => return Ok(None),
1228 }
1229 }
1230
1231 if let Some((arc, span)) = circle {
1232 let _ = pieces;
1235 if !segments.is_empty()
1236 || !columns.is_empty()
1237 || !rows.is_empty()
1238 || (span - core::f64::consts::TAU).abs() > tol.parametric().max(1e-9)
1239 {
1240 return Ok(None);
1241 }
1242 let reach = tol.confusion() * 10.0;
1249 for (index, at) in arc_ends.iter().enumerate() {
1250 let met = arc_ends
1251 .iter()
1252 .enumerate()
1253 .filter(|(other, q)| *other != index && q.distance(*at) <= reach)
1254 .count();
1255 if met != 1 {
1256 return Ok(None);
1257 }
1258 }
1259 let ogeom_geom::SurfaceGeometry::Plane(plane) = placed else {
1260 return Ok(None);
1261 };
1262 let frame = plane.plane().frame();
1263 let centre2 = arc.circle().centre();
1264 let centre = placed.point_at(centre2.x, centre2.y, tol)?;
1265 let radius = arc.circle().radius();
1266 let normal = frame.z().vector();
1267 return Ok(Some(ExactFace::Disc {
1268 centre,
1269 e1: frame.x().vector(),
1270 e2: frame.y().vector(),
1271 normal,
1272 radius,
1273 sign,
1274 share,
1275 }));
1276 }
1277
1278 if segments.is_empty() && columns.is_empty() && rows.is_empty() {
1282 return Ok(None);
1283 }
1284 let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
1285 let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
1286 for (a, b) in &segments {
1287 for p in [a, b] {
1288 u0 = u0.min(p.x);
1289 u1 = u1.max(p.x);
1290 v0 = v0.min(p.y);
1291 v1 = v1.max(p.y);
1292 }
1293 }
1294 for u in &columns {
1295 u0 = u0.min(*u);
1296 u1 = u1.max(*u);
1297 }
1298 for v in &rows {
1299 v0 = v0.min(*v);
1300 v1 = v1.max(*v);
1301 }
1302 if !(u0.is_finite() && u1.is_finite() && v0.is_finite() && v1.is_finite()) {
1303 return Ok(None);
1304 }
1305 if u1 - u0 <= tol.confusion() || v1 - v0 <= tol.confusion() {
1306 return Ok(None);
1307 }
1308 let eps = tol.confusion().max(1e-9 * (u1 - u0).max(v1 - v0));
1309 let on_side =
1310 |value: f64, lo: f64, hi: f64| (value - lo).abs() <= eps || (value - hi).abs() <= eps;
1311 let mut perimeter = 0.0;
1312 for (a, b) in &segments {
1313 let horizontal = (a.y - b.y).abs() <= eps;
1314 let vertical = (a.x - b.x).abs() <= eps;
1315 if !(horizontal ^ vertical) {
1316 return Ok(None);
1317 }
1318 if horizontal && !on_side(a.y, v0, v1) {
1319 return Ok(None);
1320 }
1321 if vertical && !on_side(a.x, u0, u1) {
1322 return Ok(None);
1323 }
1324 perimeter += a.distance(*b);
1325 }
1326 #[allow(clippy::cast_precision_loss)]
1327 for (values, lo, hi, span) in [(&columns, u0, u1, v1 - v0), (&rows, v0, v1, u1 - u0)] {
1328 for value in values {
1329 if !on_side(*value, lo, hi) {
1330 return Ok(None);
1331 }
1332 perimeter += span;
1333 }
1334 }
1335 let expected = 2.0 * ((u1 - u0) + (v1 - v0));
1336 if (perimeter - expected).abs() > 1e-6 * expected {
1337 return Ok(None);
1338 }
1339 Ok(Some(ExactFace::ChartRectangle {
1340 surface: placed.clone(),
1341 rect: (u0, u1, v0, v1),
1342 sign,
1343 share,
1344 }))
1345}
1346
1347struct Accumulator {
1360 reference: Option<Point>,
1363 mass: f64,
1364 first: Vector,
1367 second: Matrix3,
1369}
1370
1371impl Accumulator {
1372 const fn new() -> Self {
1373 Self {
1374 reference: None,
1375 mass: 0.0,
1376 first: Vector::ZERO,
1377 second: Matrix3::ZERO,
1378 }
1379 }
1380
1381 fn add(&mut self, points: &[Point], measure: f64) {
1384 if points.is_empty() || measure == 0.0 || !measure.is_finite() {
1385 return;
1386 }
1387 let n = points.len();
1388 #[allow(clippy::cast_precision_loss)]
1389 let count = n as f64;
1390 let reference = *self.reference.get_or_insert(points[0]);
1391 let local: Vec<Vector> = points.iter().map(|p| *p - reference).collect();
1392 let sum: Vector = local.iter().fold(Vector::ZERO, |a, v| a + *v);
1393
1394 self.mass += measure;
1395 self.first += sum * (measure / count);
1396
1397 let scale = measure / (count * (count + 1.0));
1400 let mut term = outer(sum, sum);
1401 for v in &local {
1402 term = add(term, outer(*v, *v));
1403 }
1404 self.second = add(self.second, scale_matrix(term, scale));
1405 }
1406
1407 fn finish(self, deflection: f64) -> MassProperties {
1409 if self.mass.abs() <= f64::MIN_POSITIVE {
1410 return MassProperties::none(deflection);
1411 }
1412 let offset = self.first / self.mass;
1413 let centre = self.reference.unwrap_or(Point::ORIGIN) + offset;
1414
1415 let trace = self.second.rows[0][0] + self.second.rows[1][1] + self.second.rows[2][2];
1417 let about_reference = add(
1418 scale_matrix(Matrix3::IDENTITY, trace),
1419 scale_matrix(self.second, -1.0),
1420 );
1421 let inertia = add(
1423 about_reference,
1424 scale_matrix(displacement_term(self.mass, offset), -1.0),
1425 );
1426
1427 MassProperties {
1428 mass: self.mass.abs(),
1429 centre,
1430 inertia,
1431 deflection,
1432 }
1433 }
1434}
1435
1436fn displacement_term(mass: f64, d: Vector) -> Matrix3 {
1438 let squared = d.dot(d);
1439 add(
1440 scale_matrix(Matrix3::IDENTITY, mass * squared),
1441 scale_matrix(outer(d, d), -mass),
1442 )
1443}
1444
1445fn outer(a: Vector, b: Vector) -> Matrix3 {
1447 Matrix3::new([
1448 [a.x * b.x, a.x * b.y, a.x * b.z],
1449 [a.y * b.x, a.y * b.y, a.y * b.z],
1450 [a.z * b.x, a.z * b.y, a.z * b.z],
1451 ])
1452}
1453
1454fn add(a: Matrix3, b: Matrix3) -> Matrix3 {
1456 let mut rows = a.rows;
1457 for (row, other) in rows.iter_mut().zip(b.rows) {
1458 for (value, addend) in row.iter_mut().zip(other) {
1459 *value += addend;
1460 }
1461 }
1462 Matrix3::new(rows)
1463}
1464
1465fn scale_matrix(m: Matrix3, s: f64) -> Matrix3 {
1467 let mut rows = m.rows;
1468 for row in &mut rows {
1469 for value in row {
1470 *value *= s;
1471 }
1472 }
1473 Matrix3::new(rows)
1474}
1475
1476fn quadratic_form(m: Matrix3, v: Vector) -> f64 {
1478 let c = [v.x, v.y, v.z];
1479 let mut sum = 0.0;
1480 for (i, ci) in c.iter().enumerate() {
1481 for (j, cj) in c.iter().enumerate() {
1482 sum += ci * m.rows[i][j] * cj;
1483 }
1484 }
1485 sum
1486}
1487
1488fn along_one_chart_line(control: &[ogeom_math::Weighted<ogeom_math::Point2>]) -> bool {
1491 let points: Vec<ogeom_math::Point2> = control.iter().map(|w| w.point()).collect();
1492 let Some(first) = points.first() else {
1493 return false;
1494 };
1495 let extent = points
1496 .iter()
1497 .map(|p| p.distance(*first))
1498 .fold(0.0_f64, f64::max);
1499 let eps = 1e-9 * extent.max(1.0);
1500 points.iter().all(|p| (p.x - first.x).abs() <= eps)
1501 || points.iter().all(|p| (p.y - first.y).abs() <= eps)
1502}
1503
1504#[cfg(test)]
1505#[allow(clippy::unwrap_used)]
1506mod tests {
1507 use super::*;
1508 use crate::make_box;
1509 use approx::assert_relative_eq;
1510 use ogeom_math::Frame;
1511
1512 const T: Tolerances = Tolerances::millimetres();
1513
1514 fn fine() -> Deflection {
1515 Deflection {
1516 chord: 1e-3,
1517 angular: 0.05,
1518 ..Deflection::default()
1519 }
1520 }
1521
1522 #[test]
1523 fn analytic_primitives_measure_exactly_on_their_own_surfaces() {
1524 let mut model = Model::new();
1527 let pi = core::f64::consts::PI;
1528
1529 let cylinder = crate::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
1530 let props = volume_properties(&model, &cylinder.shape, fine(), T).unwrap();
1531 assert_eq!(props.deflection, 0.0, "the exact path was taken");
1532 assert_relative_eq!(props.mass, pi * 4.0 * 5.0, epsilon = 1e-10);
1533 assert!(props.centre.is_equal(Point::new(0.0, 0.0, 2.5), T));
1534 let m = pi * 4.0 * 5.0;
1536 assert_relative_eq!(props.inertia.rows[2][2], m * 4.0 / 2.0, epsilon = 1e-8);
1537
1538 let sphere = crate::make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap();
1539 let props = volume_properties(&model, &sphere.shape, fine(), T).unwrap();
1540 assert_eq!(props.deflection, 0.0);
1541 assert_relative_eq!(props.mass, 4.0 / 3.0 * pi * 27.0, epsilon = 1e-10);
1542 let m = 4.0 / 3.0 * pi * 27.0;
1544 assert_relative_eq!(props.inertia.rows[0][0], 0.4 * m * 9.0, epsilon = 1e-8);
1545
1546 let torus = crate::make_torus(&mut model, Frame::WORLD, 5.0, 1.5, T).unwrap();
1547 let props = volume_properties(&model, &torus.shape, fine(), T).unwrap();
1548 assert_eq!(props.deflection, 0.0);
1549 assert_relative_eq!(props.mass, 2.0 * pi * pi * 5.0 * 1.5 * 1.5, epsilon = 1e-10);
1550
1551 let cone = crate::make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T).unwrap();
1552 let props = volume_properties(&model, &cone.shape, fine(), T).unwrap();
1553 assert_eq!(props.deflection, 0.0);
1554 assert_relative_eq!(
1556 props.mass,
1557 pi * 4.0 * (9.0 + 3.0 + 1.0) / 3.0,
1558 epsilon = 1e-10
1559 );
1560
1561 let props = surface_properties(&model, &sphere.shape, fine(), T).unwrap();
1563 assert_eq!(props.deflection, 0.0);
1564 assert_relative_eq!(props.mass, 4.0 * pi * 9.0, epsilon = 1e-10);
1565 }
1566
1567 #[test]
1568 fn a_box_has_the_volume_centre_and_inertia_a_box_has() {
1569 let (dx, dy, dz) = (2.0, 3.0, 4.0);
1573 let mut model = Model::new();
1574 let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
1575
1576 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1577 assert_relative_eq!(props.mass, dx * dy * dz, epsilon = 1e-9);
1578 assert!(
1579 props
1580 .centre
1581 .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T),
1582 "the centre of a box is its middle, got {:?}",
1583 props.centre
1584 );
1585
1586 let m = dx * dy * dz;
1588 assert_relative_eq!(
1589 props.inertia.rows[0][0],
1590 m * dz.mul_add(dz, dy * dy) / 12.0,
1591 epsilon = 1e-9
1592 );
1593 assert_relative_eq!(
1594 props.inertia.rows[1][1],
1595 m * dz.mul_add(dz, dx * dx) / 12.0,
1596 epsilon = 1e-9
1597 );
1598 assert_relative_eq!(
1599 props.inertia.rows[2][2],
1600 m * dy.mul_add(dy, dx * dx) / 12.0,
1601 epsilon = 1e-9
1602 );
1603 for (i, j) in [(0, 1), (0, 2), (1, 2)] {
1605 assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-9);
1606 assert_relative_eq!(props.inertia.rows[j][i], 0.0, epsilon = 1e-9);
1607 }
1608 }
1609
1610 #[test]
1611 fn a_box_has_the_area_and_edge_length_a_box_has() {
1612 let (dx, dy, dz) = (2.0, 3.0, 4.0);
1613 let mut model = Model::new();
1614 let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
1615
1616 let area = surface_properties(&model, &built.shape, fine(), T).unwrap();
1617 assert_relative_eq!(
1618 area.mass,
1619 2.0 * dz.mul_add(dx, dx.mul_add(dy, dy * dz)),
1620 epsilon = 1e-9
1621 );
1622 assert!(
1623 area.centre
1624 .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
1625 );
1626
1627 let length = linear_properties(&model, &built.shape, fine(), T).unwrap();
1630 assert_relative_eq!(length.mass, 4.0 * (dx + dy + dz), epsilon = 1e-9);
1631 assert!(
1632 length
1633 .centre
1634 .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
1635 );
1636 }
1637
1638 #[test]
1639 fn the_answer_does_not_depend_on_where_the_shape_sits() {
1640 let mut model = Model::new();
1644 let here = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1645 let far = Frame::new(
1646 Point::new(100.0, -50.0, 25.0),
1647 Direction::Z,
1648 Direction::X,
1649 T,
1650 )
1651 .unwrap();
1652 let there = make_box(&mut model, far, (1.0, 2.0, 3.0), T).unwrap();
1653
1654 let a = volume_properties(&model, &here.shape, fine(), T).unwrap();
1655 let b = volume_properties(&model, &there.shape, fine(), T).unwrap();
1656
1657 assert_relative_eq!(a.mass, b.mass, epsilon = 1e-9);
1658 assert!(
1659 b.centre
1660 .is_equal(a.centre + Vector::new(100.0, -50.0, 25.0), T)
1661 );
1662 for i in 0..3 {
1663 for j in 0..3 {
1664 assert_relative_eq!(a.inertia.rows[i][j], b.inertia.rows[i][j], epsilon = 1e-6);
1665 }
1666 }
1667 }
1668
1669 #[test]
1670 fn a_part_a_long_way_from_the_origin_keeps_its_precision() {
1671 let mut model = Model::new();
1676 let far = Frame::new(
1677 Point::new(1.0e6, -2.0e6, 5.0e5),
1678 Direction::Z,
1679 Direction::X,
1680 T,
1681 )
1682 .unwrap();
1683 let built = make_box(&mut model, far, (2.0, 3.0, 4.0), T).unwrap();
1684 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1685
1686 assert_relative_eq!(props.mass, 24.0, epsilon = 1e-6);
1687 assert_relative_eq!(
1688 props.inertia.rows[0][0],
1689 24.0 * 4.0_f64.mul_add(4.0, 3.0 * 3.0) / 12.0,
1690 epsilon = 1e-6
1691 );
1692 for (i, j) in [(0, 1), (0, 2), (1, 2)] {
1693 assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-6);
1694 }
1695 }
1696
1697 #[test]
1698 fn moving_the_inertia_off_the_centre_agrees_with_the_parallel_axis_theorem() {
1699 let mut model = Model::new();
1700 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
1701 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1702
1703 let m = 8.0;
1705 let corner = props.inertia_about(Point::ORIGIN);
1706 assert_relative_eq!(
1707 corner.rows[0][0],
1708 2.0_f64.mul_add(2.0, 2.0 * 2.0).mul_add(m / 12.0, m * 2.0),
1709 epsilon = 1e-9
1710 );
1711 assert!(corner.rows[0][0] > props.inertia.rows[0][0]);
1713 }
1714
1715 #[test]
1716 fn a_cubes_principal_moments_are_all_the_same() {
1717 let mut model = Model::new();
1721 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
1722 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1723
1724 let axes = props.principal_axes(T).unwrap();
1725 let expected = 8.0 * 2.0_f64.mul_add(2.0, 2.0 * 2.0) / 12.0;
1726 for (moment, _) in &axes {
1727 assert_relative_eq!(*moment, expected, epsilon = 1e-6);
1728 }
1729 for (i, j) in [(0, 1), (0, 2), (1, 2)] {
1730 assert_relative_eq!(
1731 axes[i].1.vector().dot(axes[j].1.vector()),
1732 0.0,
1733 epsilon = 1e-9
1734 );
1735 }
1736 }
1737
1738 #[test]
1739 fn a_long_box_spins_most_easily_about_its_length() {
1740 let mut model = Model::new();
1741 let built = make_box(&mut model, Frame::WORLD, (10.0, 1.0, 1.0), T).unwrap();
1742 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1743
1744 let axes = props.principal_axes(T).unwrap();
1745 assert!(axes[0].1.vector().x.abs() > 0.99, "got {:?}", axes[0].1);
1747 assert!(axes[0].0 < axes[1].0 && axes[1].0 <= axes[2].0);
1748
1749 let along = props.radius_of_gyration(Direction::X).unwrap();
1750 let across = props.radius_of_gyration(Direction::Y).unwrap();
1751 assert!(along < across, "{along} should be less than {across}");
1752 }
1753
1754 #[test]
1755 fn a_sphere_converges_on_the_volume_a_sphere_has() {
1756 use crate::build::make_natural_face;
1761 use ogeom_geom::SphereSurface;
1762 use ogeom_math::Sphere;
1763
1764 let radius = 5.0_f64;
1765 let exact = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3);
1766 let mut previous = 0.0;
1767
1768 for chord in [0.5_f64, 0.1, 0.02] {
1769 let mut model = Model::new();
1770 let surface = SphereSurface::new(Sphere::new(Frame::WORLD, radius, T).unwrap());
1771 let face = make_natural_face(&mut model, surface.into()).unwrap().shape;
1772 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
1773 .unwrap()
1774 .shape;
1775
1776 let deflection = Deflection {
1777 chord,
1778 ..Deflection::default()
1779 };
1780 let props = volume_properties(&model, &shell, deflection, T).unwrap();
1781 assert_relative_eq!(props.deflection, chord);
1782 assert!(props.mass < exact, "an inscribed volume cannot exceed it");
1783 assert!(
1784 props.mass > previous,
1785 "tightening the chord lost volume: {} after {previous}",
1786 props.mass
1787 );
1788 assert!(
1789 props
1790 .centre
1791 .is_equal(Point::ORIGIN, Tolerances::with_scale(1e4).unwrap()),
1792 "a sphere's centre is its centre, got {:?}",
1793 props.centre
1794 );
1795 previous = props.mass;
1796 }
1797 assert!(
1798 previous > exact * 0.99,
1799 "{previous} should be within a percent of {exact}"
1800 );
1801 }
1802
1803 #[test]
1804 fn an_open_shell_is_refused_rather_than_measured() {
1805 let mut model = Model::new();
1808 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1809 let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
1810
1811 assert!(volume_properties(&model, &face, fine(), T).is_err());
1812 let area = surface_properties(&model, &face, fine(), T).unwrap();
1814 assert_relative_eq!(area.mass, 1.0, epsilon = 1e-9);
1815 }
1816
1817 #[test]
1818 fn an_inward_shell_is_refused_rather_than_reported_as_negative() {
1819 let mut model = Model::new();
1820 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1821 assert!(volume_properties(&model, &built.shape.reversed(), fine(), T).is_err());
1822 }
1823
1824 #[test]
1825 fn a_shape_with_nothing_to_measure_says_so_rather_than_dividing_by_zero() {
1826 let mut model = Model::new();
1827 let vertex = model.add_point(Point::ORIGIN);
1828
1829 for props in [
1830 volume_properties(&model, &vertex, fine(), T).unwrap(),
1831 surface_properties(&model, &vertex, fine(), T).unwrap(),
1832 linear_properties(&model, &vertex, fine(), T).unwrap(),
1833 ] {
1834 assert_relative_eq!(props.mass, 0.0);
1835 assert!(props.centre.is_equal(Point::ORIGIN, T));
1836 assert!(props.radius_of_gyration(Direction::Z).is_none());
1837 }
1838 }
1839
1840 #[test]
1841 fn an_unusable_deflection_is_refused() {
1842 let mut model = Model::new();
1843 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1844 let bad = Deflection {
1845 chord: -1.0,
1846 ..Deflection::default()
1847 };
1848 assert!(volume_properties(&model, &built.shape, bad, T).is_err());
1849 assert!(surface_properties(&model, &built.shape, bad, T).is_err());
1850 assert!(linear_properties(&model, &built.shape, bad, T).is_err());
1851 }
1852}