1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
42use ogeom_geom::Transformable as _;
43use ogeom_math::{Direction, Matrix3, Point, Vector};
44use ogeom_mesh::{Deflection, discretize};
45use ogeom_topo::{EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, explore, explore_unique};
46
47#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct MassProperties {
50 pub mass: f64,
56 pub centre: Point,
59 pub inertia: Matrix3,
65 pub deflection: f64,
71}
72
73impl MassProperties {
74 #[must_use]
76 pub const fn none(deflection: f64) -> Self {
77 Self {
78 mass: 0.0,
79 centre: Point::ORIGIN,
80 inertia: Matrix3::ZERO,
81 deflection,
82 }
83 }
84
85 #[must_use]
87 pub fn inertia_about(&self, point: Point) -> Matrix3 {
88 let d = self.centre - point;
89 add(self.inertia, displacement_term(self.mass, d))
92 }
93
94 #[must_use]
100 pub fn radius_of_gyration(&self, axis: Direction) -> Option<f64> {
101 if self.mass <= 0.0 {
102 return None;
103 }
104 let v = axis.vector();
105 let i = quadratic_form(self.inertia, v);
106 Some((i / self.mass).max(0.0).sqrt())
107 }
108
109 pub fn principal_axes(&self, tol: Tolerances) -> OgeomResult<[(f64, Direction); 3]> {
121 let m = nalgebra::Matrix3::from_row_slice(&[
122 self.inertia.rows[0][0],
123 self.inertia.rows[0][1],
124 self.inertia.rows[0][2],
125 self.inertia.rows[1][0],
126 self.inertia.rows[1][1],
127 self.inertia.rows[1][2],
128 self.inertia.rows[2][0],
129 self.inertia.rows[2][1],
130 self.inertia.rows[2][2],
131 ]);
132 if !m.iter().all(|x| x.is_finite()) {
133 ogeom_bail!(NotDone, "the inertia tensor is not finite");
134 }
135 let eigen = nalgebra::SymmetricEigen::new(m);
138
139 let mut out: Vec<(f64, Direction)> = Vec::with_capacity(3);
140 for i in 0..3 {
141 let column = eigen.eigenvectors.column(i);
142 let axis = Direction::new(Vector::new(column[0], column[1], column[2]), tol)?;
143 out.push((eigen.eigenvalues[i], axis));
144 }
145 out.sort_by(|a, b| a.0.total_cmp(&b.0));
146 Ok([out[0], out[1], out[2]])
147 }
148}
149
150pub fn linear_properties(
160 model: &Model,
161 shape: &Shape,
162 deflection: Deflection,
163 tol: Tolerances,
164) -> OgeomResult<MassProperties> {
165 deflection.validate()?;
166 let mut acc = Accumulator::new();
167
168 for edge in explore_unique(model, shape, ShapeType::Edge)? {
169 let Some(node) = model.node(&edge) else {
170 ogeom_bail!(Dangling, "edge is not in this model");
171 };
172 let NodeData::Edge(data) = node.data() else {
173 ogeom_bail!(Construction, "edge node holds no edge data");
174 };
175 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
176 continue;
177 };
178 let Some(geometry) = model.geometry().curve(*curve) else {
179 ogeom_bail!(Dangling, "curve is not in this model");
180 };
181 let placement = edge.transform(model.datums())?;
182 let line = discretize(geometry, *range, deflection, tol)?;
183 for w in line.points.windows(2) {
184 let (a, b) = (placement.apply(w[0]), placement.apply(w[1]));
185 acc.add(&[a, b], a.distance(b));
186 }
187 }
188 Ok(acc.finish(deflection.chord))
189}
190
191pub fn surface_properties(
197 model: &Model,
198 shape: &Shape,
199 deflection: Deflection,
200 tol: Tolerances,
201) -> OgeomResult<MassProperties> {
202 deflection.validate()?;
203 if let Some(exact) = exact_surface_properties(model, shape, tol)? {
204 return Ok(exact);
205 }
206 let mut acc = Accumulator::new();
207
208 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
209 let mesh = ogeom_mesh::triangulate_face(model, &face, deflection, tol)?;
210 for triangle in &mesh.triangles {
211 let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
212 let area = (b - a).cross(c - a).magnitude() * 0.5;
215 acc.add(&[a, b, c], area);
216 }
217 }
218 Ok(acc.finish(deflection.chord))
219}
220
221pub fn volume_properties(
239 model: &Model,
240 shape: &Shape,
241 deflection: Deflection,
242 tol: Tolerances,
243) -> OgeomResult<MassProperties> {
244 volume_with(model, shape, deflection, Turning::Probed, None, tol)
245}
246
247pub(crate) fn volume_as_flagged(
257 model: &Model,
258 shape: &Shape,
259 deflection: Deflection,
260 kept: Option<&VolumeKept>,
261 tol: Tolerances,
262) -> OgeomResult<MassProperties> {
263 volume_with(model, shape, deflection, Turning::WholeShells, kept, tol)
264}
265
266#[derive(Default)]
273pub(crate) struct VolumeKept {
274 meshes: ogeom_mesh::FaceMeshCache,
275 regions: std::sync::Mutex<ogeom_core::FastMap<Vec<u8>, Option<Vec<ExactFace>>>>,
278 integrals: std::sync::Mutex<ogeom_core::FastMap<Vec<u8>, Option<Moments>>>,
282}
283
284impl VolumeKept {
285 pub(crate) const fn meshes(&self) -> &ogeom_mesh::FaceMeshCache {
287 &self.meshes
288 }
289
290 fn regions(&self, key: &[u8]) -> Option<Option<Vec<ExactFace>>> {
291 self.regions
292 .lock()
293 .unwrap_or_else(std::sync::PoisonError::into_inner)
294 .get(key)
295 .cloned()
296 }
297
298 fn keep_regions(&self, key: Vec<u8>, found: Option<Vec<ExactFace>>) {
299 self.regions
300 .lock()
301 .unwrap_or_else(std::sync::PoisonError::into_inner)
302 .insert(key, found);
303 }
304
305 fn integral(&self, key: &[u8]) -> Option<Option<Moments>> {
306 self.integrals
307 .lock()
308 .unwrap_or_else(std::sync::PoisonError::into_inner)
309 .get(key)
310 .cloned()
311 }
312
313 fn keep_integral(&self, key: Vec<u8>, found: Option<Moments>) {
314 self.integrals
315 .lock()
316 .unwrap_or_else(std::sync::PoisonError::into_inner)
317 .insert(key, found);
318 }
319}
320
321#[derive(Clone, Copy, PartialEq, Eq)]
324enum Turning {
325 Probed,
327 WholeShells,
331}
332
333fn volume_with(
334 model: &Model,
335 shape: &Shape,
336 deflection: Deflection,
337 turning: Turning,
338 kept: Option<&VolumeKept>,
339 tol: Tolerances,
340) -> OgeomResult<MassProperties> {
341 deflection.validate()?;
342 if let Some(exact) = exact_volume_properties(model, shape, turning, kept, deflection, tol)? {
343 return Ok(exact);
344 }
345 if let Some(probed) = probed_mesh_volume(model, shape, deflection, tol)? {
346 return Ok(probed);
347 }
348 let (mut mesh, _, faces) = ogeom_mesh::triangulate_with_face_meshes(
349 model,
350 shape,
351 deflection,
352 kept.map(VolumeKept::meshes),
353 tol,
354 )?;
355 if mesh.is_empty() {
356 return Ok(MassProperties::none(deflection.chord));
357 }
358 if !mesh.is_closed() {
359 let shells = explore_unique(model, shape, ShapeType::Shell)?;
366 let mut closed = !shells.is_empty();
367 for shell in &shells {
368 closed &= crate::build::is_shell_closed(model, shell)?;
369 }
370 if !closed {
371 ogeom_bail!(
372 Construction,
373 "the boundary is not closed, so it encloses no volume to measure"
374 );
375 }
376 mesh = ogeom_topo::Triangulation::new();
377 for (_, face_mesh) in faces {
378 mesh.append(&face_mesh?);
379 }
380 }
381
382 let apex = mesh.positions[0];
387 let mut acc = Accumulator::new();
388 for triangle in &mesh.triangles {
389 let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
390 let volume = (a - apex).dot((b - apex).cross(c - apex)) / 6.0;
394 acc.add(&[apex, a, b, c], volume);
395 }
396
397 if acc.mass < 0.0 {
398 ogeom_bail!(
399 Construction,
400 "the boundary is wound inward, so the volume came out negative"
401 );
402 }
403 Ok(acc.finish(deflection.chord))
404}
405
406fn probed_mesh_volume(
417 model: &Model,
418 shape: &Shape,
419 deflection: Deflection,
420 tol: Tolerances,
421) -> OgeomResult<Option<MassProperties>> {
422 let solids = explore(model, shape, Filter::OfType(ShapeType::Solid))?;
423 let mut several = false;
424 for solid in &solids {
425 several |= explore(model, solid, Filter::OfType(ShapeType::Shell))?.len() > 1;
426 }
427 if !several {
428 return Ok(None);
429 }
430 for shell in explore_unique(model, shape, ShapeType::Shell)? {
431 if !crate::build::is_shell_closed(model, &shell)? {
432 ogeom_bail!(
433 Construction,
434 "the boundary is not closed, so it encloses no volume to measure"
435 );
436 }
437 }
438 let mut faces: Vec<(Shape, Option<usize>)> = Vec::new();
440 let mut seen: ogeom_core::FastSet<Occurrence> = ogeom_core::FastSet::default();
441 for (at, solid) in solids.iter().enumerate() {
442 for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
443 seen.insert((face.node(), face.location().clone()));
444 faces.push((face, Some(at)));
445 }
446 }
447 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
448 if !seen.contains(&(face.node(), face.location().clone())) {
449 faces.push((face, None));
450 }
451 }
452 let mut boundaries: Vec<Option<crate::SolidBoundary>> = Vec::with_capacity(solids.len());
453 for solid in &solids {
454 boundaries.push(or_mesh(
455 crate::check::probe_boundary(model, solid, tol).map(Some),
456 None,
457 )?);
458 }
459 let chords = ogeom_mesh::edge_chords_for(model, shape, deflection, tol)?;
460 let meshes = ogeom_core::parallel::map_ordered(&faces, |_, (face, at)| {
461 let mesh = ogeom_mesh::triangulate_face_with(model, face, deflection, &chords, tol)?;
462 let boundary = at.and_then(|at| boundaries[at].as_ref());
463 let inward = match boundary {
464 Some(boundary) => {
465 or_mesh(crate::check::faces_inward(model, face, boundary, tol), None)?
466 }
467 None => None,
468 };
469 Ok((mesh, inward == Some(true)))
470 });
471 let mut acc = Accumulator::new();
472 let mut apex: Option<Point> = None;
473 for one in meshes {
474 let (mesh, turned) = one?;
475 let Some(&first) = mesh.positions.first() else {
476 continue;
477 };
478 let apex = *apex.get_or_insert(first);
480 let sign = if turned { -1.0 } else { 1.0 };
481 for triangle in &mesh.triangles {
482 let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
483 let volume = (a - apex).dot((b - apex).cross(c - apex)) / 6.0;
484 acc.add(&[apex, a, b, c], volume * sign);
485 }
486 }
487 if apex.is_none() {
488 return Ok(Some(MassProperties::none(deflection.chord)));
489 }
490 if acc.mass < 0.0 {
491 ogeom_bail!(
492 Construction,
493 "the boundary is wound inward, so the volume came out negative"
494 );
495 }
496 Ok(Some(acc.finish(deflection.chord)))
497}
498
499#[derive(Clone)]
507enum ExactFace {
508 ChartRectangle {
510 surface: ogeom_geom::SurfaceGeometry,
511 rect: (f64, f64, f64, f64),
512 sign: f64,
513 share: f64,
514 split: crate::mass_chart::Split,
515 },
516 Disc {
518 centre: Point,
519 e1: Vector,
520 e2: Vector,
521 normal: Vector,
522 radius: f64,
523 sign: f64,
524 share: f64,
525 split: crate::mass_chart::Split,
526 },
527 Chart(Box<crate::mass_chart::ChartFace>),
529}
530
531impl ExactFace {
532 const fn share(&self) -> f64 {
537 match self {
538 Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share,
539 Self::Chart(_) => 1.0,
540 }
541 }
542
543 fn chart_area(&self) -> f64 {
548 match self {
549 Self::Disc { radius, .. } => core::f64::consts::PI * radius * radius,
550 Self::ChartRectangle { rect, .. } => (rect.1 - rect.0) * (rect.3 - rect.2),
551 Self::Chart(_) => 0.0,
552 }
553 }
554
555 fn take_away(&mut self) {
556 match self {
557 Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share = -1.0,
558 Self::Chart(_) => {}
559 }
560 }
561}
562
563fn exact_volume_properties(
573 model: &Model,
574 shape: &Shape,
575 turning: Turning,
576 kept: Option<&VolumeKept>,
577 deflection: Deflection,
578 tol: Tolerances,
579) -> OgeomResult<Option<MassProperties>> {
580 let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
581 if faces.is_empty() {
582 return Ok(None);
583 }
584 let mut exact = Vec::with_capacity(faces.len());
585 let mut region_of: Vec<usize> = Vec::with_capacity(faces.len());
587 let mut unspoken: Vec<usize> = Vec::new();
589 let contents: Vec<Option<Vec<u8>>> = match kept {
591 Some(_) => faces
592 .iter()
593 .map(|face| ogeom_topo::face_content(model, face))
594 .collect(),
595 None => vec![None; faces.len()],
596 };
597 let prepared = ogeom_core::parallel::map_ordered(&faces, |at, face| {
601 if model.children_of(face)?.is_empty() {
602 return Ok(None);
603 }
604 let key = contents[at].as_ref().map(|content| {
605 let mut key = content.clone();
606 key.extend_from_slice(format!("{tol:?}").as_bytes());
607 key
608 });
609 if let (Some(kept), Some(key)) = (kept, &key)
610 && let Some(found) = kept.regions(key)
611 {
612 return Ok(found);
613 }
614 let found = integrable_face(model, face, tol);
615 if let (Some(kept), Some(key), Ok(found)) = (kept, key, &found) {
616 kept.keep_regions(key, found.clone());
617 }
618 found
619 });
620 for (at, (face, found)) in faces.iter().zip(prepared).enumerate() {
621 match or_mesh(found, None)? {
625 Some(found) => {
626 region_of.extend(std::iter::repeat_n(at, found.len()));
627 exact.extend(found);
628 }
629 None => {
630 if *DEBUG_MASS {
631 eprintln!(
632 "MASS face {} is not exactly integrable",
633 face.node().index()
634 );
635 }
636 unspoken.push(at);
637 }
638 }
639 }
640 if exact.is_empty() {
643 return Ok(None);
644 }
645 let Some(flags) = or_mesh(flags_agree(model, shape, tol).map(Some), None)? else {
649 return Ok(None);
650 };
651 if !flags.agree {
652 return Ok(None);
653 }
654 let shells = explore_unique(model, shape, ShapeType::Shell)?;
658 if shells.is_empty() {
659 return Ok(None);
660 }
661 for shell in shells {
662 if !crate::build::is_shell_closed(model, &shell)? {
663 ogeom_bail!(
664 Construction,
665 "the boundary is not closed, so it encloses no volume to measure"
666 );
667 }
668 }
669 let mut turned: ogeom_core::FastSet<Occurrence> = ogeom_core::FastSet::default();
676 if !flags.sets.is_empty() {
677 if turning == Turning::WholeShells
678 && flags.sets.iter().flatten().any(|(_, against)| *against)
679 {
680 return Ok(None);
681 }
682 let Some(inward) = sets_facing_in(model, shape, &flags.sets, tol)? else {
683 return Ok(None);
684 };
685 let shells = match turning {
686 Turning::Probed => Vec::new(),
687 Turning::WholeShells => shells_of_several(model, shape)?,
688 };
689 for (set, inward) in flags.sets.iter().zip(inward) {
690 let facing_in: ogeom_core::FastSet<Occurrence> = set
691 .iter()
692 .filter(|(_, against)| *against != inward)
693 .map(|(f, _)| (f.node(), f.location().clone()))
694 .collect();
695 if turning == Turning::WholeShells
696 && !facing_in.is_empty()
697 && !shells.contains(&facing_in)
698 {
699 return Ok(None);
700 }
701 turned.extend(facing_in);
702 }
703 }
704 if unspoken
709 .iter()
710 .any(|&at| turned.contains(&(faces[at].node(), faces[at].location().clone())))
711 {
712 return Ok(None);
713 }
714 let sign: Vec<f64> = region_of
715 .iter()
716 .map(|&at| {
717 let face = &faces[at];
718 if turned.contains(&(face.node(), face.location().clone())) {
719 -1.0
720 } else {
721 1.0
722 }
723 })
724 .collect();
725
726 let reference = reference_point(&exact, tol)?;
727 let keys: Vec<Option<Vec<u8>>> = match kept {
730 Some(_) => {
731 let mut place = 0_u64;
732 region_of
733 .iter()
734 .enumerate()
735 .map(|(i, &at)| {
736 place = if i > 0 && region_of[i - 1] == at {
737 place + 1
738 } else {
739 0
740 };
741 contents[at].as_ref().map(|content| {
742 let mut key = content.clone();
743 key.extend_from_slice(&place.to_le_bytes());
744 for x in [reference.x, reference.y, reference.z] {
745 key.extend_from_slice(&x.to_bits().to_le_bytes());
746 }
747 key
748 })
749 })
750 .collect()
751 }
752 None => vec![None; exact.len()],
753 };
754 let held = |i: usize| {
755 let key = keys[i].as_ref()?;
756 kept?.integral(key)
757 };
758 let summed = ogeom_core::parallel::map_ordered(&exact, |i, face| {
761 if let Some(found) = held(i) {
762 return Ok(found);
763 }
764 let found = integrate_face(
765 face,
766 crate::mass_chart::Measure::Volume,
767 reference,
768 tol,
769 Moments::zero,
770 |sums, p, n_da, share| {
771 let n_da = n_da * share;
772 let q = p - reference;
773 sums.mass += q.dot(n_da) / 3.0;
774 sums.first += Vector::new(
775 q.x * q.x * n_da.x / 2.0,
776 q.y * q.y * n_da.y / 2.0,
777 q.z * q.z * n_da.z / 2.0,
778 );
779 let d = [q.x, q.y, q.z];
780 let nd = [n_da.x, n_da.y, n_da.z];
781 for i in 0..3 {
782 sums.second.rows[i][i] += d[i] * d[i] * d[i] * nd[i] / 3.0;
784 for j in 0..3 {
786 if i != j {
787 sums.second.rows[i][j] += d[i] * d[i] * d[j] * nd[i] / 2.0;
788 }
789 }
790 }
791 },
792 Moments::add,
793 );
794 if let (Ok(found), Some(kept), Some(key)) = (&found, kept, &keys[i]) {
795 kept.keep_integral(key.clone(), found.clone());
796 }
797 found
798 });
799 let mut settled: Vec<Option<Moments>> = Vec::with_capacity(exact.len());
804 for found in summed {
805 settled.push(or_mesh(found, None)?);
806 }
807 let mut meshed: Vec<usize> = (0..exact.len())
808 .filter(|&i| settled[i].is_none())
809 .map(|i| region_of[i])
810 .chain(unspoken)
811 .collect();
812 meshed.sort_unstable();
813 meshed.dedup();
814 let mut total = Moments::zero();
815 for (i, found) in settled.iter().enumerate() {
816 if let Some(sums) = found
817 && !meshed.contains(®ion_of[i])
818 {
819 total.add_signed(sums, sign[i]);
820 }
821 }
822 for &at in &meshed {
823 let finer = Deflection {
827 chord: deflection.chord * MESHED_FACE_CHORD,
828 ..deflection
829 };
830 let Some(mesh) = or_mesh(
836 ogeom_mesh::triangulate_face(model, &faces[at], finer, tol).map(Some),
837 None,
838 )?
839 else {
840 return Ok(None);
841 };
842 let mut acc = Accumulator::new();
843 for triangle in &mesh.triangles {
844 let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
845 let volume = (a - reference).dot((b - reference).cross(c - reference)) / 6.0;
846 acc.add(&[reference, a, b, c], volume);
847 }
848 rim_slivers(model, &faces[at], &mesh, reference, &mut acc, tol)?;
849 let face = &faces[at];
850 let face_sign = if turned.contains(&(face.node(), face.location().clone())) {
851 -1.0
852 } else {
853 1.0
854 };
855 total.add_signed(
856 &Moments {
857 mass: acc.mass,
858 first: acc.first,
859 second: acc.second,
860 },
861 face_sign,
862 );
863 }
864 let Moments {
865 mass,
866 first,
867 mut second,
868 } = total;
869 for i in 0..3 {
872 for j in (i + 1)..3 {
873 let mean = f64::midpoint(second.rows[i][j], second.rows[j][i]);
874 second.rows[i][j] = mean;
875 second.rows[j][i] = mean;
876 }
877 }
878 if mass < 0.0 {
879 ogeom_bail!(
880 Construction,
881 "the boundary is wound inward, so the volume came out negative"
882 );
883 }
884 let acc = Accumulator {
885 reference: Some(reference),
886 mass,
887 first,
888 second,
889 };
890 Ok(Some(acc.finish(if meshed.is_empty() {
891 0.0
892 } else {
893 deflection.chord
894 })))
895}
896
897fn exact_surface_properties(
899 model: &Model,
900 shape: &Shape,
901 tol: Tolerances,
902) -> OgeomResult<Option<MassProperties>> {
903 let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
904 if faces.is_empty() {
905 return Ok(None);
906 }
907 let mut exact = Vec::with_capacity(faces.len());
908 let prepared =
909 ogeom_core::parallel::map_ordered(&faces, |_, face| integrable_face(model, face, tol));
910 for found in prepared {
911 match or_mesh(found, None)? {
912 Some(found) => exact.extend(found),
913 None => return Ok(None),
914 }
915 }
916 let reference = reference_point(&exact, tol)?;
917 let mut total = Moments::zero();
918 for face in &exact {
919 let found = integrate_face(
920 face,
921 crate::mass_chart::Measure::Area,
922 reference,
923 tol,
924 Moments::zero,
925 |sums, p, n_da, share| {
926 let da = n_da.magnitude() * share;
927 let q = p - reference;
928 sums.mass += da;
929 sums.first += q * da;
930 for (i, qi) in [q.x, q.y, q.z].iter().enumerate() {
931 for (j, qj) in [q.x, q.y, q.z].iter().enumerate() {
932 sums.second.rows[i][j] += qi * qj * da;
933 }
934 }
935 },
936 Moments::add,
937 );
938 match or_mesh(found, None)? {
939 Some(sums) => total.add(&sums),
940 None => return Ok(None),
941 }
942 }
943 let Moments {
944 mass,
945 first,
946 second,
947 } = total;
948 let acc = Accumulator {
949 reference: Some(reference),
950 mass,
951 first,
952 second,
953 };
954 Ok(Some(acc.finish(0.0)))
955}
956
957#[derive(Clone)]
960struct Moments {
961 mass: f64,
962 first: Vector,
963 second: Matrix3,
964}
965
966impl Moments {
967 const fn zero() -> Self {
968 Self {
969 mass: 0.0,
970 first: Vector::ZERO,
971 second: Matrix3::ZERO,
972 }
973 }
974
975 fn add(&mut self, other: &Self) {
976 self.add_signed(other, 1.0);
977 }
978
979 fn add_signed(&mut self, other: &Self, sign: f64) {
981 self.mass += other.mass * sign;
982 self.first += other.first * sign;
983 self.second = add(self.second, scale_matrix(other.second, sign));
984 }
985}
986
987fn or_mesh<T>(result: OgeomResult<T>, fallback: T) -> OgeomResult<T> {
992 match result {
993 Ok(value) => Ok(value),
994 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
995 Err(e)
996 }
997 Err(_) => Ok(fallback),
998 }
999}
1000
1001static DEBUG_MASS: std::sync::LazyLock<bool> =
1003 std::sync::LazyLock::new(|| std::env::var_os("OGEOM_DEBUG_MASS").is_some());
1004
1005fn rim_slivers(
1014 model: &Model,
1015 face: &Shape,
1016 mesh: &ogeom_topo::Triangulation,
1017 reference: Point,
1018 acc: &mut Accumulator,
1019 tol: Tolerances,
1020) -> OgeomResult<()> {
1021 let bits = |p: Point| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
1022 let mut sides: ogeom_core::FastSet<([u64; 3], [u64; 3])> = ogeom_core::FastSet::default();
1023 for t in &mesh.triangles {
1024 for k in 0..3 {
1025 let (a, b) = (
1026 mesh.positions[t[k] as usize],
1027 mesh.positions[t[(k + 1) % 3] as usize],
1028 );
1029 sides.insert((bits(a), bits(b)));
1030 }
1031 }
1032 let mut curves = Vec::new();
1033 for edge in explore_unique(model, face, ShapeType::Edge)? {
1034 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1035 continue;
1036 };
1037 if data.degenerate {
1038 continue;
1039 }
1040 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
1041 continue;
1042 };
1043 let Some(geometry) = model.geometry().curve(*curve) else {
1044 continue;
1045 };
1046 let placement = edge.transform(model.datums())?;
1047 let near = (data.tolerance.get() * 2.0).max(tol.confusion() * 10.0);
1050 curves.push((geometry.clone().transformed(&placement, tol)?, near));
1051 }
1052 let mut corners = Vec::new();
1053 for vertex in explore_unique(model, face, ShapeType::Vertex)? {
1054 if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
1055 corners.push(vertex.transform(model.datums())?.apply(data.point));
1056 }
1057 }
1058 for t in &mesh.triangles {
1059 for k in 0..3 {
1060 let (a, b) = (
1061 mesh.positions[t[k] as usize],
1062 mesh.positions[t[(k + 1) % 3] as usize],
1063 );
1064 if a.distance(b) <= tol.confusion() || sides.contains(&(bits(b), bits(a))) {
1067 continue;
1068 }
1069 let Some(arc) = curves
1072 .iter()
1073 .filter_map(|(curve, near)| arc_of(curve, a, b, *near, tol))
1074 .min_by(|x, y| x.0.total_cmp(&y.0))
1075 .map(|(_, arc)| arc)
1076 .or_else(|| round_a_corner(&curves, &corners, a, b, tol))
1077 else {
1078 continue;
1079 };
1080 for pair in arc.windows(2) {
1081 let (p, q) = (pair[0], pair[1]);
1082 let volume = (a - reference).dot((p - reference).cross(q - reference)) / 6.0;
1083 acc.add(&[reference, a, p, q], volume);
1084 }
1085 }
1086 }
1087 Ok(())
1088}
1089
1090fn arc_of(
1096 curve: &ogeom_geom::Curve,
1097 a: Point,
1098 b: Point,
1099 near: f64,
1100 tol: Tolerances,
1101) -> Option<(f64, Vec<Point>)> {
1102 use ogeom_geom::Curve3d as _;
1103 let at = |p: Point| {
1104 crate::project_on_curve(curve, p, 64, tol)
1105 .ok()
1106 .filter(|found| found.distance <= near)
1107 .map(|found| found.parameter)
1108 };
1109 let (ta, tb) = (at(a)?, at(b)?);
1110 let mut tb = tb;
1114 let closed = curve.is_closed(tol);
1115 let (lo, hi) = curve.domain();
1116 let period = hi - lo;
1117 let point_at = |t: f64| {
1118 let t = if closed && period > 0.0 && !curve.is_periodic() {
1119 lo + (t - lo).rem_euclid(period)
1120 } else {
1121 t
1122 };
1123 curve.point_at(t, tol)
1124 };
1125 if closed {
1126 let middle = a.midpoint(b);
1127 let other = if tb > ta { tb - period } else { tb + period };
1128 let off =
1129 |t: f64| point_at(f64::midpoint(ta, t)).map_or(f64::INFINITY, |p| p.distance(middle));
1130 if off(other) < off(tb) {
1131 tb = other;
1132 }
1133 }
1134 let sag = point_at(f64::midpoint(ta, tb))
1135 .ok()?
1136 .distance(a.midpoint(b));
1137 if sag > a.distance(b) {
1138 return None;
1139 }
1140 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1142 let pieces = ((sag / tol.confusion()).sqrt().ceil() as usize).clamp(1, MAX_RIM_PIECES);
1143 let mut points = Vec::with_capacity(pieces + 1);
1144 points.push(a);
1145 for k in 1..pieces {
1146 #[allow(clippy::cast_precision_loss)]
1147 let f = k as f64 / pieces as f64;
1148 points.push(point_at(ta + (tb - ta) * f).ok()?);
1149 }
1150 points.push(b);
1151 Some((sag, points))
1152}
1153
1154fn round_a_corner(
1158 curves: &[(ogeom_geom::Curve, f64)],
1159 corners: &[Point],
1160 a: Point,
1161 b: Point,
1162 tol: Tolerances,
1163) -> Option<Vec<Point>> {
1164 for (first, near_first) in curves {
1165 for (second, near_second) in curves {
1166 for &corner in corners {
1167 let Some((_, to)) = arc_of(first, a, corner, *near_first, tol) else {
1168 continue;
1169 };
1170 let Some((_, on)) = arc_of(second, corner, b, *near_second, tol) else {
1171 continue;
1172 };
1173 return Some(to.into_iter().chain(on.into_iter().skip(1)).collect());
1174 }
1175 }
1176 }
1177 None
1178}
1179
1180const MESHED_FACE_CHORD: f64 = 0.1;
1183
1184const MAX_RIM_PIECES: usize = 4096;
1186
1187fn reference_point(faces: &[ExactFace], tol: Tolerances) -> OgeomResult<Point> {
1189 use ogeom_geom::Surface as _;
1190 match &faces[0] {
1191 ExactFace::ChartRectangle {
1192 surface,
1193 rect,
1194 split,
1195 ..
1196 } => Ok(split.point(surface.point_at(rect.0, rect.2, tol)?)),
1197 ExactFace::Disc { centre, split, .. } => Ok(split.point(*centre)),
1198 ExactFace::Chart(chart) => chart.anchor(tol),
1199 }
1200}
1201
1202fn integrate_face<A: Clone>(
1209 face: &ExactFace,
1210 measure: crate::mass_chart::Measure,
1211 reference: Point,
1212 tol: Tolerances,
1213 fresh: impl Fn() -> A,
1214 contribute: impl Fn(&mut A, Point, Vector, f64),
1215 merge: impl Fn(&mut A, &A),
1216) -> OgeomResult<Option<A>> {
1217 let share = face.share();
1218 use ogeom_geom::Surface as _;
1219 const QUARTER: f64 = core::f64::consts::FRAC_PI_2;
1220 let mut sums = fresh();
1221 match face {
1222 ExactFace::Chart(chart) => {
1223 Ok(chart.integrate(measure, reference, tol, fresh, contribute, merge))
1224 }
1225 ExactFace::ChartRectangle {
1226 surface,
1227 rect,
1228 sign,
1229 split,
1230 ..
1231 } => {
1232 let (u0, u1, v0, v1) = *rect;
1233 let (angular_u, angular_v) = match surface {
1239 ogeom_geom::SurfaceGeometry::Plane(_) => (false, false),
1240 ogeom_geom::SurfaceGeometry::Cylinder(_)
1241 | ogeom_geom::SurfaceGeometry::Cone(_)
1242 | ogeom_geom::SurfaceGeometry::Extrusion(_) => (true, false),
1243 _ => (true, true),
1244 };
1245 let breaks =
1246 |lo: f64, hi: f64, angular: bool, knots: Option<&ogeom_math::KnotVector>| {
1247 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1248 let panels = if angular {
1249 ((hi - lo) / QUARTER).ceil().clamp(1.0, 64.0) as usize
1250 } else {
1251 1
1252 };
1253 #[allow(clippy::cast_precision_loss)]
1254 let mut out: Vec<f64> = (0..=panels)
1255 .map(|i| lo + (hi - lo) * i as f64 / panels as f64)
1256 .collect();
1257 if let Some(knots) = knots {
1258 out.extend(
1259 knots
1260 .distinct()
1261 .into_iter()
1262 .map(|(k, _)| k)
1263 .filter(|k| *k > lo && *k < hi),
1264 );
1265 out.sort_by(f64::total_cmp);
1266 out.dedup_by(|a, b| (*a - *b).abs() <= 1e-14);
1267 }
1268 out
1269 };
1270 fn curve_knots(curve: &ogeom_geom::Curve) -> Option<&ogeom_math::KnotVector> {
1273 match curve {
1274 ogeom_geom::Curve::BSpline(b) => Some(b.knots()),
1275 ogeom_geom::Curve::Trimmed(t) => curve_knots(t.basis()),
1276 _ => None,
1277 }
1278 }
1279 let (u_knots, v_knots) = match surface {
1280 ogeom_geom::SurfaceGeometry::BSpline(b) => (Some(b.u_knots()), Some(b.v_knots())),
1281 ogeom_geom::SurfaceGeometry::Extrusion(e) => (curve_knots(e.curve()), None),
1282 ogeom_geom::SurfaceGeometry::Revolution(r) => (None, curve_knots(r.curve())),
1283 _ => (None, None),
1284 };
1285 let (u_breaks, v_breaks) = (
1286 breaks(u0, u1, angular_u, u_knots),
1287 breaks(v0, v1, angular_v, v_knots),
1288 );
1289 let mut failure = None;
1290 for uw in u_breaks.windows(2) {
1291 let (ua, ub) = (uw[0], uw[1]);
1292 for vw in v_breaks.windows(2) {
1293 let (va, vb) = (vw[0], vw[1]);
1294 gauss2(ua, ub, va, vb, &mut |u, v, weight| {
1298 if failure.is_some() {
1299 return;
1300 }
1301 let sample = (|| -> OgeomResult<()> {
1302 let (p, du, dv) = surface.point_d1_at(u, v, tol)?;
1303 let n_da = du.cross(dv) * (sign * weight);
1304 contribute(&mut sums, split.point(p), split.area(n_da), share);
1305 Ok(())
1306 })();
1307 if let Err(e) = sample {
1308 failure = Some(e);
1309 }
1310 });
1311 }
1312 }
1313 match failure {
1314 Some(e) => Err(e),
1315 None => Ok(Some(sums)),
1316 }
1317 }
1318 ExactFace::Disc {
1319 centre,
1320 e1,
1321 e2,
1322 normal,
1323 radius,
1324 sign,
1325 split,
1326 ..
1327 } => {
1328 let failure: Option<ogeom_core::OgeomError> = None;
1329 let turns = 4;
1330 for k in 0..turns {
1331 #[allow(clippy::cast_precision_loss)]
1332 let (ta, tb) = (
1333 core::f64::consts::TAU * k as f64 / turns as f64,
1334 core::f64::consts::TAU * (k + 1) as f64 / turns as f64,
1335 );
1336 gauss2(0.0, *radius, ta, tb, &mut |rho, theta, weight| {
1337 if failure.is_some() {
1338 return;
1339 }
1340 let p = *centre + (*e1 * theta.cos() + *e2 * theta.sin()) * rho;
1341 let n_da = *normal * (sign * rho * weight);
1342 contribute(&mut sums, split.point(p), split.area(n_da), share);
1343 });
1344 }
1345 match failure {
1346 Some(e) => Err(e),
1347 None => Ok(Some(sums)),
1348 }
1349 }
1350 }
1351}
1352
1353fn gauss2(a: f64, b: f64, c: f64, d: f64, f: &mut dyn FnMut(f64, f64, f64)) {
1356 let us = ogeom_math::gauss_legendre_rule(a, b);
1357 let vs = ogeom_math::gauss_legendre_rule(c, d);
1358 for &(u, wu) in &us {
1359 for &(v, wv) in &vs {
1360 f(u, v, wu * wv);
1361 }
1362 }
1363}
1364
1365fn integrable_face(
1369 model: &Model,
1370 face: &Shape,
1371 tol: Tolerances,
1372) -> OgeomResult<Option<Vec<ExactFace>>> {
1373 if !crate::mass_chart::runs_wide_of_an_edge(model, face, tol)?
1377 && let Some(found) = exact_face(model, face, tol)?
1378 {
1379 return Ok(Some(found));
1380 }
1381 Ok(crate::mass_chart::chart_face(model, face, tol)
1382 .map(|chart| vec![ExactFace::Chart(Box::new(chart))]))
1383}
1384
1385fn exact_face(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Vec<ExactFace>>> {
1394 let Some(node) = model.node(face) else {
1395 return Ok(None);
1396 };
1397 let NodeData::Face(data) = node.data() else {
1398 return Ok(None);
1399 };
1400 let Some(surface) = model.geometry().surface(data.surface) else {
1401 return Ok(None);
1402 };
1403 let analytic = matches!(
1404 surface,
1405 ogeom_geom::SurfaceGeometry::Plane(_)
1406 | ogeom_geom::SurfaceGeometry::Cylinder(_)
1407 | ogeom_geom::SurfaceGeometry::Cone(_)
1408 | ogeom_geom::SurfaceGeometry::Sphere(_)
1409 | ogeom_geom::SurfaceGeometry::Torus(_)
1410 | ogeom_geom::SurfaceGeometry::BSpline(_)
1414 | ogeom_geom::SurfaceGeometry::Extrusion(_)
1415 | ogeom_geom::SurfaceGeometry::Revolution(_)
1416 );
1417 if !analytic {
1418 return Ok(None);
1419 }
1420 let Some(split) = crate::mass_chart::Split::of(&face.transform(model.datums())?) else {
1425 return Ok(None);
1426 };
1427 let placed = surface.clone().transformed(split.rigid(), tol)?;
1428 let sign = split.handedness()
1429 * if face.orientation() == ogeom_topo::Orientation::Reversed {
1430 -1.0
1431 } else {
1432 1.0
1433 };
1434
1435 let wires = model.ordered_children_of(face)?;
1436 if wires.is_empty() {
1440 use ogeom_geom::Surface as _;
1441 let ((u0, u1), (v0, v1)) = surface.domain();
1442 if ![u0, u1, v0, v1].iter().all(|x| x.is_finite()) {
1443 return Ok(None);
1444 }
1445 return Ok(Some(vec![ExactFace::ChartRectangle {
1446 surface: placed,
1447 rect: (u0, u1, v0, v1),
1448 sign,
1449 share: 1.0,
1450 split,
1451 }]));
1452 }
1453 let mut regions = Vec::with_capacity(wires.len());
1461 for wire in &wires {
1462 let Some(region) = exact_wire(model, data, &placed, split, wire, sign, 1.0, tol)? else {
1463 return Ok(None);
1464 };
1465 regions.push(region);
1466 }
1467 let Some(outer) = (0..regions.len()).max_by(|a, b| {
1468 regions[*a]
1469 .chart_area()
1470 .total_cmp(®ions[*b].chart_area())
1471 }) else {
1472 return Ok(None);
1473 };
1474 for (index, region) in regions.iter_mut().enumerate() {
1475 if index != outer {
1476 region.take_away();
1477 }
1478 }
1479 Ok(Some(regions))
1480}
1481
1482type Occurrence = (ogeom_topo::TShapeId, ogeom_topo::Location);
1484
1485type PlacedCurves = ogeom_core::FastMap<Occurrence, (ogeom_geom::Curve, (f64, f64))>;
1488
1489pub(crate) fn flags_agree(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Flags> {
1526 let unplaced = shape.located(ogeom_topo::Location::default());
1529 let faces = explore(model, &unplaced, Filter::OfType(ShapeType::Face))?;
1530 let mut walks: ogeom_core::FastMap<Occurrence, Vec<(bool, usize, bool)>> =
1531 ogeom_core::FastMap::default();
1532 let walked = ogeom_core::parallel::map_ordered(&faces, |_, face| {
1535 let mut curves: PlacedCurves = ogeom_core::FastMap::default();
1536 face_walks(model, face, &mut curves, tol)
1537 });
1538 for (index, (face, found)) in faces.iter().zip(walked).enumerate() {
1539 match found? {
1540 Some(found) => {
1541 for (edge, ahead, outer) in found {
1542 walks.entry(edge).or_default().push((ahead, index, outer));
1543 }
1544 }
1545 None => {
1546 if *DEBUG_MASS {
1547 eprintln!("MASS face {:?} cannot be walked", face.node());
1548 }
1549 }
1550 }
1551 }
1552 if *DEBUG_MASS {
1553 eprintln!("MASS flags_agree walked {} edges", walks.len());
1554 }
1555 let mut parent: Vec<(usize, bool)> = (0..faces.len()).map(|at| (at, false)).collect();
1558 for ((edge, _), uses) in &walks {
1559 if uses.iter().all(|(_, owner, _)| *owner == uses[0].1) {
1564 continue;
1565 }
1566 let mut owners: Vec<(usize, bool)> = Vec::with_capacity(2);
1570 let mut uniform = true;
1571 for &(ahead, owner, _) in uses {
1572 match owners.iter().find(|(held, _)| *held == owner) {
1573 Some(&(_, way)) => uniform &= way == ahead,
1574 None => owners.push((owner, ahead)),
1575 }
1576 }
1577 let tie: Vec<(usize, bool)> = if uniform && owners.len() == 2 {
1578 vec![owners[0], (owners[1].0, owners[0].1 == owners[1].1)]
1579 } else {
1580 let ahead = uses.iter().filter(|(ahead, ..)| *ahead).count();
1583 if ahead * 2 != uses.len() {
1584 if *DEBUG_MASS {
1585 eprintln!("MASS edge {} is walked {uses:?}", edge.index());
1586 }
1587 return Ok(Flags {
1588 agree: false,
1589 sets: Vec::new(),
1590 });
1591 }
1592 uses.iter().map(|&(_, owner, _)| (owner, false)).collect()
1593 };
1594 let (first, first_turned) = set_of(&mut parent, tie[0].0);
1595 for &(owner, against) in &tie[1..] {
1596 let (other, other_turned) = set_of(&mut parent, owner);
1597 let turned = first_turned ^ other_turned ^ against;
1599 if other == first {
1600 if turned {
1601 if *DEBUG_MASS {
1602 eprintln!(
1603 "MASS edge {} closes a loop of faces turned oddly",
1604 edge.index()
1605 );
1606 }
1607 return Ok(Flags {
1608 agree: false,
1609 sets: Vec::new(),
1610 });
1611 }
1612 continue;
1613 }
1614 parent[other] = (first, turned);
1615 }
1616 }
1617 let mut sets: Vec<Vec<(Shape, bool)>> = Vec::new();
1621 let mut set_at: ogeom_core::FastMap<usize, (usize, bool)> = ogeom_core::FastMap::default();
1622 let mut mixed = false;
1623 for (index, face) in faces.iter().enumerate() {
1624 let (root, turned) = set_of(&mut parent, index);
1625 let (at, first_turned) = *set_at.entry(root).or_insert_with(|| {
1626 sets.push(Vec::new());
1627 (sets.len() - 1, turned)
1628 });
1629 let against = turned != first_turned;
1630 mixed |= against;
1631 sets[at].push((face.moved(shape.location()), against));
1632 }
1633 if sets.len() < 2 && !mixed {
1634 sets.clear();
1635 }
1636 Ok(Flags { agree: true, sets })
1637}
1638
1639fn set_of(parent: &mut [(usize, bool)], at: usize) -> (usize, bool) {
1642 let mut path = Vec::new();
1643 let mut here = at;
1644 while parent[here].0 != here {
1645 path.push(here);
1646 here = parent[here].0;
1647 }
1648 let root = here;
1649 for &face in path.iter().rev() {
1651 let (up, turned) = parent[face];
1652 let up_turned = if up == root { false } else { parent[up].1 };
1653 parent[face] = (root, turned ^ up_turned);
1654 }
1655 (root, if at == root { false } else { parent[at].1 })
1656}
1657
1658pub(crate) struct Flags {
1660 pub agree: bool,
1664 pub sets: Vec<Vec<(Shape, bool)>>,
1669}
1670
1671type EdgeWalk = (Occurrence, bool, bool);
1675
1676fn face_walks(
1679 model: &Model,
1680 face: &Shape,
1681 curves: &mut PlacedCurves,
1682 tol: Tolerances,
1683) -> OgeomResult<Option<Vec<EdgeWalk>>> {
1684 use ogeom_geom::Curve2d as _;
1685 use ogeom_geom::Surface as _;
1686 let placed_at = ogeom_topo::Location::default();
1687 let mut walks = Vec::new();
1688 let Some(data) = model.node(face).and_then(|n| n.data().as_face()).cloned() else {
1689 return Ok(None);
1690 };
1691 let Some(surface) = model.geometry().surface(data.surface) else {
1692 return Ok(None);
1693 };
1694 let placement = face.transform(model.datums())?;
1695 let placed = surface.clone().transformed(&placement, tol)?;
1696 let flag = if face.orientation() == ogeom_topo::Orientation::Reversed {
1699 -1.0
1700 } else {
1701 1.0
1702 } * if placement.preserves_handedness() {
1703 1.0
1704 } else {
1705 -1.0
1706 };
1707 if let Some(stations) = crate::mass_chart::material_sides(model, face, tol) {
1710 for (edge, at, toward) in stations {
1711 let at = crate::mass_chart::into_domain(&placed, at);
1712 let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
1713 let raw = du.cross(dv);
1714 let inward = du * toward.x + dv * toward.y;
1715 if raw.magnitude() <= tol.angular() || inward.magnitude() <= tol.angular() {
1716 return Ok(None);
1717 }
1718 let out = raw / raw.magnitude() * flag;
1719 let walk = out.cross(inward / inward.magnitude());
1720 let station = placed.point_at(at.x, at.y, tol)?;
1721 let Some(along) = edge_heading(model, &edge, station, curves, tol)? else {
1722 return Ok(None);
1723 };
1724 walks.push((
1725 (edge.node(), edge.location().clone()),
1726 walk.dot(along) > 0.0,
1727 true,
1728 ));
1729 }
1730 return Ok(Some(walks));
1731 }
1732 if face.location() != &placed_at {
1734 return Ok(None);
1735 }
1736 let wires = model.ordered_children_of(face)?;
1745 let mut middles: Vec<(ogeom_math::Point2, f64)> = Vec::with_capacity(wires.len());
1746 let mut stations: Vec<Vec<(Shape, ogeom_math::Point2)>> = Vec::with_capacity(wires.len());
1747 let mut uses: ogeom_core::FastMap<ogeom_topo::TShapeId, usize> = ogeom_core::FastMap::default();
1750 for wire in &wires {
1751 for edge in model.ordered_children_of(wire)? {
1752 *uses.entry(edge.node()).or_default() += 1;
1753 }
1754 }
1755 for wire in &wires {
1756 let mut here = Vec::new();
1757 let mut sum = ogeom_math::Vector2::new(0.0, 0.0);
1758 let (mut lo, mut hi) = (
1759 ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY),
1760 ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
1761 );
1762 let mut once: Vec<(Shape, [ogeom_topo::PCurveId; 2], (f64, f64))> = Vec::new();
1766 let mut ends: Vec<ogeom_math::Point2> = Vec::new();
1767 for edge in model.ordered_children_of(wire)? {
1768 if edge.location() != &placed_at {
1769 return Ok(None);
1770 }
1771 let Some(repr) = model
1772 .node(&edge)
1773 .and_then(|n| n.data().as_edge())
1774 .and_then(|d| d.pcurve_for(data.surface, edge.location()))
1775 else {
1776 return Ok(None);
1777 };
1778 let sides: Vec<(ogeom_topo::PCurveId, (f64, f64))> = match repr {
1780 EdgeRepr::PCurve { curve, range, .. } => vec![(*curve, *range)],
1781 EdgeRepr::Seam {
1782 forward,
1783 reversed,
1784 range,
1785 ..
1786 } if uses.get(&edge.node()) == Some(&1) => {
1787 once.push((edge.clone(), [*forward, *reversed], *range));
1788 continue;
1789 }
1790 EdgeRepr::Seam {
1791 forward,
1792 reversed,
1793 range,
1794 ..
1795 } => vec![(*forward, *range), (*reversed, *range)],
1796 _ => return Ok(None),
1797 };
1798 for (id, range) in sides {
1799 let Some(pcurve) = model.geometry().pcurve(id) else {
1800 return Ok(None);
1801 };
1802 ends.push(pcurve.point_at(range.0, tol)?);
1803 ends.push(pcurve.point_at(range.1, tol)?);
1804 const STATIONS: usize = 4;
1808 for step in 1..=STATIONS {
1809 #[allow(clippy::cast_precision_loss)]
1810 let t = range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
1811 let at = pcurve.point_at(t, tol)?;
1812 sum += at.to_vector();
1813 lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
1814 hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
1815 here.push((edge.clone(), at));
1816 }
1817 }
1818 }
1819 if here.is_empty() && once.is_empty() {
1820 return Ok(None);
1821 }
1822 for (edge, sides, range) in once {
1824 let mut best: Option<(f64, ogeom_topo::PCurveId)> = None;
1825 for id in sides {
1826 let Some(pcurve) = model.geometry().pcurve(id) else {
1827 return Ok(None);
1828 };
1829 let mut d = f64::INFINITY;
1830 for t in [range.0, range.1] {
1831 let at = pcurve.point_at(t, tol)?;
1832 for end in &ends {
1833 d = d.min(at.distance(*end));
1834 }
1835 }
1836 if best.is_none_or(|(held, _)| d < held) {
1837 best = Some((d, id));
1838 }
1839 }
1840 let Some((_, id)) = best else {
1841 return Ok(None);
1842 };
1843 let Some(pcurve) = model.geometry().pcurve(id) else {
1844 return Ok(None);
1845 };
1846 const STATIONS: usize = 4;
1847 for step in 1..=STATIONS {
1848 #[allow(clippy::cast_precision_loss)]
1849 let t = range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
1850 let at = pcurve.point_at(t, tol)?;
1851 sum += at.to_vector();
1852 lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
1853 hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
1854 here.push((edge.clone(), at));
1855 }
1856 }
1857 #[allow(clippy::cast_precision_loss)]
1858 let middle = ogeom_math::Point2::ORIGIN + sum / here.len() as f64;
1859 middles.push((middle, (hi.x - lo.x) * (hi.y - lo.y)));
1860 stations.push(here);
1861 }
1862 let Some(outer) = (0..middles.len()).max_by(|a, b| middles[*a].1.total_cmp(&middles[*b].1))
1863 else {
1864 return Ok(None);
1865 };
1866 for (index, here) in stations.into_iter().enumerate() {
1867 let (middle, _) = middles[index];
1868 for (edge, at) in here {
1869 let at = crate::mass_chart::into_domain(&placed, at);
1870 let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
1871 let raw = du.cross(dv);
1872 if raw.magnitude() <= tol.angular() {
1873 return Ok(None);
1874 }
1875 let out = raw / raw.magnitude() * flag;
1876 let toward = middle - at;
1880 let toward = if index == outer { toward } else { -toward };
1881 let inward = du * toward.x + dv * toward.y;
1882 if inward.magnitude() <= tol.angular() {
1883 return Ok(None);
1884 }
1885 let walk = out.cross(inward / inward.magnitude());
1886 let station = placed.point_at(at.x, at.y, tol)?;
1892 let Some(along) = edge_heading(model, &edge, station, curves, tol)? else {
1893 return Ok(None);
1894 };
1895 walks.push((
1896 (edge.node(), edge.location().clone()),
1897 walk.dot(along) > 0.0,
1898 index == outer,
1899 ));
1900 }
1901 }
1902 Ok(Some(walks))
1903}
1904
1905fn sets_facing_in(
1917 model: &Model,
1918 shape: &Shape,
1919 sets: &[Vec<(Shape, bool)>],
1920 tol: Tolerances,
1921) -> OgeomResult<Option<Vec<bool>>> {
1922 let solids = explore(model, shape, Filter::OfType(ShapeType::Solid))?;
1923 let mut owner: ogeom_core::FastMap<Occurrence, usize> = ogeom_core::FastMap::default();
1925 let solids = if solids.is_empty() {
1926 vec![shape.clone()]
1927 } else {
1928 solids
1929 };
1930 for (at, solid) in solids.iter().enumerate() {
1931 for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
1932 owner
1933 .entry((face.node(), face.location().clone()))
1934 .or_insert(at);
1935 }
1936 }
1937 let mut boundaries: Vec<Option<crate::SolidBoundary>> = solids.iter().map(|_| None).collect();
1938 type Asking<'a> = std::collections::VecDeque<(usize, &'a Shape, bool)>;
1941 let mut pending: Vec<(usize, Asking)> = Vec::with_capacity(sets.len());
1942 for (set, faces_of) in sets.iter().enumerate() {
1943 let mut faces = std::collections::VecDeque::with_capacity(faces_of.len());
1944 for (face, against) in faces_of {
1945 let Some(&at) = owner.get(&(face.node(), face.location().clone())) else {
1946 return Ok(None);
1947 };
1948 faces.push_back((at, face, *against));
1949 }
1950 pending.push((set, faces));
1951 }
1952 let mut inward = vec![false; sets.len()];
1953 let mut take = 1;
1954 loop {
1955 let mut asked: Vec<(usize, usize, &Shape, bool)> = Vec::new();
1956 for (set, faces) in &mut pending {
1957 for _ in 0..take {
1958 let Some((at, face, against)) = faces.pop_front() else {
1959 break;
1960 };
1961 asked.push((*set, at, face, against));
1962 }
1963 }
1964 for &(_, at, ..) in &asked {
1965 if boundaries[at].is_none() {
1966 let Some(boundary) = or_mesh(
1967 crate::check::probe_boundary(model, &solids[at], tol).map(Some),
1968 None,
1969 )?
1970 else {
1971 return Ok(None);
1972 };
1973 boundaries[at] = Some(boundary);
1974 }
1975 }
1976 let facing = ogeom_core::parallel::map_ordered(&asked, |_, &(_, at, face, _)| {
1977 let Some(boundary) = boundaries[at].as_ref() else {
1978 return Ok(None);
1979 };
1980 crate::check::faces_inward(model, face, boundary, tol)
1981 });
1982 let mut settled = vec![false; sets.len()];
1983 for (&(set, _, _, against), facing) in asked.iter().zip(facing) {
1984 let facing = or_mesh(facing, None)?;
1985 if *DEBUG_MASS {
1986 eprintln!("MASS set {set} probed {facing:?}");
1987 }
1988 if let Some(facing) = facing {
1989 inward[set] = facing != against;
1992 settled[set] = true;
1993 }
1994 }
1995 for (set, faces) in &pending {
1997 if !settled[*set] && faces.is_empty() {
1998 if *DEBUG_MASS {
1999 eprintln!("MASS set {set}: no face settles it");
2000 }
2001 return Ok(None);
2002 }
2003 }
2004 pending.retain(|(set, _)| !settled[*set]);
2005 if pending.is_empty() {
2006 return Ok(Some(inward));
2007 }
2008 take *= 2;
2009 }
2010}
2011
2012fn shells_of_several(
2015 model: &Model,
2016 shape: &Shape,
2017) -> OgeomResult<Vec<ogeom_core::FastSet<Occurrence>>> {
2018 let mut out = Vec::new();
2019 for solid in explore(model, shape, Filter::OfType(ShapeType::Solid))? {
2020 let shells = explore(model, &solid, Filter::OfType(ShapeType::Shell))?;
2021 if shells.len() < 2 {
2022 continue;
2023 }
2024 for shell in shells {
2025 out.push(
2026 explore(model, &shell, Filter::OfType(ShapeType::Face))?
2027 .iter()
2028 .map(|f| (f.node(), f.location().clone()))
2029 .collect(),
2030 );
2031 }
2032 }
2033 Ok(out)
2034}
2035
2036fn edge_heading(
2040 model: &Model,
2041 edge: &Shape,
2042 at: Point,
2043 curves: &mut PlacedCurves,
2044 tol: Tolerances,
2045) -> OgeomResult<Option<Vector>> {
2046 use ogeom_geom::Curve3d as _;
2047 let key = (edge.node(), edge.location().clone());
2048 if !curves.contains_key(&key) {
2049 let Some((curve, range)) = model
2050 .node(edge)
2051 .and_then(|n| n.data().as_edge())
2052 .and_then(|d| match d.curve3d()? {
2053 EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
2054 _ => None,
2055 })
2056 .and_then(|(id, range)| Some((model.geometry().curve(id)?.clone(), range)))
2057 else {
2058 return Ok(None);
2059 };
2060 let placed = curve.transformed(&edge.transform(model.datums())?, tol)?;
2061 curves.insert(key.clone(), (placed, range));
2062 }
2063 let Some((curve, range)) = curves.get(&key) else {
2064 return Ok(None);
2065 };
2066 let range = *range;
2067 let gap = |t: f64| -> OgeomResult<f64> { Ok(curve.point_at(t, tol)?.distance(at)) };
2068 const SAMPLES: u32 = 32;
2069 let step = (range.1 - range.0) / f64::from(SAMPLES);
2070 let mut best = (range.0, gap(range.0)?);
2071 for k in 1..=SAMPLES {
2072 let t = range.0 + step * f64::from(k);
2073 let d = gap(t)?;
2074 if d < best.1 {
2075 best = (t, d);
2076 }
2077 }
2078 let (mut a, mut b) = (
2079 (best.0 - step.abs()).max(range.0.min(range.1)),
2080 (best.0 + step.abs()).min(range.0.max(range.1)),
2081 );
2082 let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
2084 let (mut c, mut d) = (b - (b - a) * ratio, a + (b - a) * ratio);
2085 let (mut fc, mut fd) = (gap(c)?, gap(d)?);
2086 for _ in 0..60 {
2087 if fc < fd {
2088 b = d;
2089 (d, fd) = (c, fc);
2090 c = b - (b - a) * ratio;
2091 fc = gap(c)?;
2092 } else {
2093 a = c;
2094 (c, fc) = (d, fd);
2095 d = a + (b - a) * ratio;
2096 fd = gap(d)?;
2097 }
2098 }
2099 let along = curve.d1_at(f64::midpoint(a, b), tol)?;
2100 Ok((along.magnitude() > tol.angular()).then(|| along / along.magnitude()))
2101}
2102
2103#[allow(clippy::too_many_arguments)]
2105fn exact_wire(
2106 model: &Model,
2107 data: &ogeom_topo::FaceData,
2108 placed: &ogeom_geom::SurfaceGeometry,
2109 split: crate::mass_chart::Split,
2110 wire: &Shape,
2111 sign: f64,
2112 share: f64,
2113 tol: Tolerances,
2114) -> OgeomResult<Option<ExactFace>> {
2115 use ogeom_geom::Surface as _;
2116 let mut segments: Vec<(ogeom_math::Point2, ogeom_math::Point2)> = Vec::new();
2118 let mut columns: Vec<f64> = Vec::new();
2120 let mut rows: Vec<f64> = Vec::new();
2121 let mut circle: Option<(ogeom_geom::Circle2d, f64)> = None;
2122 let mut arc_ends: Vec<ogeom_math::Point2> = Vec::new();
2125 let mut pieces = 0_usize;
2126 let mut seams_seen: Vec<ogeom_topo::TShapeId> = Vec::new();
2128 for edge in model.ordered_children_of(wire)? {
2129 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2130 return Ok(None);
2131 };
2132 let Some(repr) = edge_data.pcurve_for(data.surface, edge.location()) else {
2133 return Ok(None);
2134 };
2135 pieces += 1;
2136 match repr {
2137 EdgeRepr::PCurve { curve, range, .. } => {
2138 let Some(pcurve) = model.geometry().pcurve(*curve) else {
2139 return Ok(None);
2140 };
2141 match pcurve {
2142 ogeom_geom::PlanarCurve::Line(_) => {
2143 use ogeom_geom::Curve2d as _;
2144 let a = pcurve.point_at(range.0, tol)?;
2145 let b = pcurve.point_at(range.1, tol)?;
2146 segments.push((a, b));
2147 }
2148 ogeom_geom::PlanarCurve::BSpline(spline)
2152 if along_one_chart_line(spline.control_points()) =>
2153 {
2154 use ogeom_geom::Curve2d as _;
2155 let a = pcurve.point_at(range.0, tol)?;
2156 let b = pcurve.point_at(range.1, tol)?;
2157 segments.push((a, b));
2158 }
2159 ogeom_geom::PlanarCurve::Circle(arc) => {
2160 use ogeom_geom::Curve2d as _;
2161 let span = (range.1 - range.0).abs();
2170 arc_ends.push(pcurve.point_at(range.0, tol)?);
2171 arc_ends.push(pcurve.point_at(range.1, tol)?);
2172 match &mut circle {
2173 None => circle = Some((*arc, span)),
2174 Some((held, total)) => {
2175 let (a, b) = (held.circle(), arc.circle());
2176 if a.centre().distance(b.centre()) > tol.confusion()
2177 || (a.radius() - b.radius()).abs() > tol.confusion()
2178 {
2179 return Ok(None);
2180 }
2181 *total += span;
2182 }
2183 }
2184 }
2185 _ => return Ok(None),
2186 }
2187 }
2188 EdgeRepr::Seam {
2189 forward,
2190 reversed,
2191 range,
2192 ..
2193 } => {
2194 use ogeom_geom::Curve2d as _;
2195 if seams_seen.contains(&edge.node()) {
2196 pieces -= 1;
2197 continue;
2198 }
2199 seams_seen.push(edge.node());
2200 for id in [forward, reversed] {
2207 let Some(pcurve) = model.geometry().pcurve(*id) else {
2208 return Ok(None);
2209 };
2210 let straight = match pcurve {
2214 ogeom_geom::PlanarCurve::Line(_) => true,
2215 ogeom_geom::PlanarCurve::BSpline(spline) => {
2216 along_one_chart_line(spline.control_points())
2217 }
2218 _ => false,
2219 };
2220 if !straight {
2221 return Ok(None);
2222 }
2223 let (lo, hi) = pcurve.domain();
2224 let at = pcurve.point_at(range.0.clamp(lo, hi), tol)?;
2225 let far = pcurve.point_at(range.1.clamp(lo, hi), tol)?;
2226 if (at.x - far.x).abs() <= (at.y - far.y).abs() {
2227 columns.push(at.x);
2228 } else {
2229 rows.push(at.y);
2230 }
2231 }
2232 }
2233 _ => return Ok(None),
2234 }
2235 }
2236
2237 if let Some((arc, span)) = circle {
2238 let _ = pieces;
2241 if !segments.is_empty()
2242 || !columns.is_empty()
2243 || !rows.is_empty()
2244 || (span - core::f64::consts::TAU).abs() > tol.parametric().max(1e-9)
2245 {
2246 return Ok(None);
2247 }
2248 let reach = tol.confusion() * 10.0;
2255 for (index, at) in arc_ends.iter().enumerate() {
2256 let met = arc_ends
2257 .iter()
2258 .enumerate()
2259 .filter(|(other, q)| *other != index && q.distance(*at) <= reach)
2260 .count();
2261 if met != 1 {
2262 return Ok(None);
2263 }
2264 }
2265 let ogeom_geom::SurfaceGeometry::Plane(plane) = placed else {
2266 return Ok(None);
2267 };
2268 let frame = plane.plane().frame();
2269 let centre2 = arc.circle().centre();
2270 let centre = placed.point_at(centre2.x, centre2.y, tol)?;
2271 let radius = arc.circle().radius();
2272 let normal = frame.x().vector().cross(frame.y().vector());
2276 return Ok(Some(ExactFace::Disc {
2277 centre,
2278 e1: frame.x().vector(),
2279 e2: frame.y().vector(),
2280 normal,
2281 radius,
2282 sign,
2283 share,
2284 split,
2285 }));
2286 }
2287
2288 if segments.is_empty() && columns.is_empty() && rows.is_empty() {
2292 return Ok(None);
2293 }
2294 let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
2295 let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
2296 for (a, b) in &segments {
2297 for p in [a, b] {
2298 u0 = u0.min(p.x);
2299 u1 = u1.max(p.x);
2300 v0 = v0.min(p.y);
2301 v1 = v1.max(p.y);
2302 }
2303 }
2304 for u in &columns {
2305 u0 = u0.min(*u);
2306 u1 = u1.max(*u);
2307 }
2308 for v in &rows {
2309 v0 = v0.min(*v);
2310 v1 = v1.max(*v);
2311 }
2312 if !(u0.is_finite() && u1.is_finite() && v0.is_finite() && v1.is_finite()) {
2313 return Ok(None);
2314 }
2315 if u1 - u0 <= tol.confusion() || v1 - v0 <= tol.confusion() {
2316 return Ok(None);
2317 }
2318 let eps = tol.confusion().max(1e-9 * (u1 - u0).max(v1 - v0));
2319 let on_side =
2320 |value: f64, lo: f64, hi: f64| (value - lo).abs() <= eps || (value - hi).abs() <= eps;
2321 let mut perimeter = 0.0;
2322 for (a, b) in &segments {
2323 let horizontal = (a.y - b.y).abs() <= eps;
2324 let vertical = (a.x - b.x).abs() <= eps;
2325 if !(horizontal ^ vertical) {
2326 return Ok(None);
2327 }
2328 if horizontal && !on_side(a.y, v0, v1) {
2329 return Ok(None);
2330 }
2331 if vertical && !on_side(a.x, u0, u1) {
2332 return Ok(None);
2333 }
2334 perimeter += a.distance(*b);
2335 }
2336 for values in [&mut columns, &mut rows] {
2339 values.sort_by(f64::total_cmp);
2340 values.dedup_by(|a, b| (*a - *b).abs() <= eps);
2341 }
2342 #[allow(clippy::cast_precision_loss)]
2343 for (values, lo, hi, span) in [(&columns, u0, u1, v1 - v0), (&rows, v0, v1, u1 - u0)] {
2344 for value in values {
2345 if !on_side(*value, lo, hi) {
2346 return Ok(None);
2347 }
2348 perimeter += span;
2349 }
2350 }
2351 let expected = 2.0 * ((u1 - u0) + (v1 - v0));
2352 if (perimeter - expected).abs() > 1e-6 * expected {
2353 return Ok(None);
2354 }
2355 Ok(Some(ExactFace::ChartRectangle {
2356 surface: placed.clone(),
2357 rect: (u0, u1, v0, v1),
2358 sign,
2359 share,
2360 split,
2361 }))
2362}
2363
2364struct Accumulator {
2377 reference: Option<Point>,
2380 mass: f64,
2381 first: Vector,
2384 second: Matrix3,
2386}
2387
2388impl Accumulator {
2389 const fn new() -> Self {
2390 Self {
2391 reference: None,
2392 mass: 0.0,
2393 first: Vector::ZERO,
2394 second: Matrix3::ZERO,
2395 }
2396 }
2397
2398 fn add(&mut self, points: &[Point], measure: f64) {
2401 if points.is_empty() || measure == 0.0 || !measure.is_finite() {
2402 return;
2403 }
2404 let n = points.len();
2405 #[allow(clippy::cast_precision_loss)]
2406 let count = n as f64;
2407 let reference = *self.reference.get_or_insert(points[0]);
2408 let local: smallvec::SmallVec<[Vector; 4]> =
2411 points.iter().map(|p| *p - reference).collect();
2412 let sum: Vector = local.iter().fold(Vector::ZERO, |a, v| a + *v);
2413
2414 self.mass += measure;
2415 self.first += sum * (measure / count);
2416
2417 let scale = measure / (count * (count + 1.0));
2420 let mut term = outer(sum, sum);
2421 for v in &local {
2422 term = add(term, outer(*v, *v));
2423 }
2424 self.second = add(self.second, scale_matrix(term, scale));
2425 }
2426
2427 fn finish(self, deflection: f64) -> MassProperties {
2429 if self.mass.abs() <= f64::MIN_POSITIVE {
2430 return MassProperties::none(deflection);
2431 }
2432 let offset = self.first / self.mass;
2433 let centre = self.reference.unwrap_or(Point::ORIGIN) + offset;
2434
2435 let trace = self.second.rows[0][0] + self.second.rows[1][1] + self.second.rows[2][2];
2437 let about_reference = add(
2438 scale_matrix(Matrix3::IDENTITY, trace),
2439 scale_matrix(self.second, -1.0),
2440 );
2441 let inertia = add(
2443 about_reference,
2444 scale_matrix(displacement_term(self.mass, offset), -1.0),
2445 );
2446
2447 MassProperties {
2448 mass: self.mass.abs(),
2449 centre,
2450 inertia,
2451 deflection,
2452 }
2453 }
2454}
2455
2456fn displacement_term(mass: f64, d: Vector) -> Matrix3 {
2458 let squared = d.dot(d);
2459 add(
2460 scale_matrix(Matrix3::IDENTITY, mass * squared),
2461 scale_matrix(outer(d, d), -mass),
2462 )
2463}
2464
2465fn outer(a: Vector, b: Vector) -> Matrix3 {
2467 Matrix3::new([
2468 [a.x * b.x, a.x * b.y, a.x * b.z],
2469 [a.y * b.x, a.y * b.y, a.y * b.z],
2470 [a.z * b.x, a.z * b.y, a.z * b.z],
2471 ])
2472}
2473
2474fn add(a: Matrix3, b: Matrix3) -> Matrix3 {
2476 let mut rows = a.rows;
2477 for (row, other) in rows.iter_mut().zip(b.rows) {
2478 for (value, addend) in row.iter_mut().zip(other) {
2479 *value += addend;
2480 }
2481 }
2482 Matrix3::new(rows)
2483}
2484
2485fn scale_matrix(m: Matrix3, s: f64) -> Matrix3 {
2487 let mut rows = m.rows;
2488 for row in &mut rows {
2489 for value in row {
2490 *value *= s;
2491 }
2492 }
2493 Matrix3::new(rows)
2494}
2495
2496fn quadratic_form(m: Matrix3, v: Vector) -> f64 {
2498 let c = [v.x, v.y, v.z];
2499 let mut sum = 0.0;
2500 for (i, ci) in c.iter().enumerate() {
2501 for (j, cj) in c.iter().enumerate() {
2502 sum += ci * m.rows[i][j] * cj;
2503 }
2504 }
2505 sum
2506}
2507
2508fn along_one_chart_line(control: &[ogeom_math::Weighted<ogeom_math::Point2>]) -> bool {
2511 let points: Vec<ogeom_math::Point2> = control.iter().map(|w| w.point()).collect();
2512 let Some(first) = points.first() else {
2513 return false;
2514 };
2515 let extent = points
2516 .iter()
2517 .map(|p| p.distance(*first))
2518 .fold(0.0_f64, f64::max);
2519 let eps = 1e-9 * extent.max(1.0);
2520 points.iter().all(|p| (p.x - first.x).abs() <= eps)
2521 || points.iter().all(|p| (p.y - first.y).abs() <= eps)
2522}
2523
2524#[cfg(test)]
2525#[allow(clippy::unwrap_used)]
2526mod tests {
2527 use super::*;
2528 use crate::make_box;
2529 use approx::assert_relative_eq;
2530 use ogeom_math::{Frame, Transform};
2531
2532 const T: Tolerances = Tolerances::millimetres();
2533
2534 fn fine() -> Deflection {
2535 Deflection {
2536 chord: 1e-3,
2537 angular: 0.05,
2538 ..Deflection::default()
2539 }
2540 }
2541
2542 #[test]
2543 fn analytic_primitives_measure_exactly_on_their_own_surfaces() {
2544 let mut model = Model::new();
2547 let pi = core::f64::consts::PI;
2548
2549 let cylinder = crate::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
2550 let props = volume_properties(&model, &cylinder.shape, fine(), T).unwrap();
2551 assert_eq!(props.deflection, 0.0, "the exact path was taken");
2552 assert_relative_eq!(props.mass, pi * 4.0 * 5.0, epsilon = 1e-10);
2553 assert!(props.centre.is_equal(Point::new(0.0, 0.0, 2.5), T));
2554 let m = pi * 4.0 * 5.0;
2556 assert_relative_eq!(props.inertia.rows[2][2], m * 4.0 / 2.0, epsilon = 1e-8);
2557
2558 let sphere = crate::make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap();
2559 let props = volume_properties(&model, &sphere.shape, fine(), T).unwrap();
2560 assert_eq!(props.deflection, 0.0);
2561 assert_relative_eq!(props.mass, 4.0 / 3.0 * pi * 27.0, epsilon = 1e-10);
2562 let m = 4.0 / 3.0 * pi * 27.0;
2564 assert_relative_eq!(props.inertia.rows[0][0], 0.4 * m * 9.0, epsilon = 1e-8);
2565
2566 let torus = crate::make_torus(&mut model, Frame::WORLD, 5.0, 1.5, T).unwrap();
2567 let props = volume_properties(&model, &torus.shape, fine(), T).unwrap();
2568 assert_eq!(props.deflection, 0.0);
2569 assert_relative_eq!(props.mass, 2.0 * pi * pi * 5.0 * 1.5 * 1.5, epsilon = 1e-10);
2570
2571 let cone = crate::make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T).unwrap();
2572 let props = volume_properties(&model, &cone.shape, fine(), T).unwrap();
2573 assert_eq!(props.deflection, 0.0);
2574 assert_relative_eq!(
2576 props.mass,
2577 pi * 4.0 * (9.0 + 3.0 + 1.0) / 3.0,
2578 epsilon = 1e-10
2579 );
2580
2581 let props = surface_properties(&model, &sphere.shape, fine(), T).unwrap();
2583 assert_eq!(props.deflection, 0.0);
2584 assert_relative_eq!(props.mass, 4.0 * pi * 9.0, epsilon = 1e-10);
2585 }
2586
2587 #[test]
2588 fn a_box_has_the_volume_centre_and_inertia_a_box_has() {
2589 let (dx, dy, dz) = (2.0, 3.0, 4.0);
2593 let mut model = Model::new();
2594 let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
2595
2596 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2597 assert_relative_eq!(props.mass, dx * dy * dz, epsilon = 1e-9);
2598 assert!(
2599 props
2600 .centre
2601 .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T),
2602 "the centre of a box is its middle, got {:?}",
2603 props.centre
2604 );
2605
2606 let m = dx * dy * dz;
2608 assert_relative_eq!(
2609 props.inertia.rows[0][0],
2610 m * dz.mul_add(dz, dy * dy) / 12.0,
2611 epsilon = 1e-9
2612 );
2613 assert_relative_eq!(
2614 props.inertia.rows[1][1],
2615 m * dz.mul_add(dz, dx * dx) / 12.0,
2616 epsilon = 1e-9
2617 );
2618 assert_relative_eq!(
2619 props.inertia.rows[2][2],
2620 m * dy.mul_add(dy, dx * dx) / 12.0,
2621 epsilon = 1e-9
2622 );
2623 for (i, j) in [(0, 1), (0, 2), (1, 2)] {
2625 assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-9);
2626 assert_relative_eq!(props.inertia.rows[j][i], 0.0, epsilon = 1e-9);
2627 }
2628 }
2629
2630 #[test]
2631 fn a_box_has_the_area_and_edge_length_a_box_has() {
2632 let (dx, dy, dz) = (2.0, 3.0, 4.0);
2633 let mut model = Model::new();
2634 let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
2635
2636 let area = surface_properties(&model, &built.shape, fine(), T).unwrap();
2637 assert_relative_eq!(
2638 area.mass,
2639 2.0 * dz.mul_add(dx, dx.mul_add(dy, dy * dz)),
2640 epsilon = 1e-9
2641 );
2642 assert!(
2643 area.centre
2644 .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
2645 );
2646
2647 let length = linear_properties(&model, &built.shape, fine(), T).unwrap();
2650 assert_relative_eq!(length.mass, 4.0 * (dx + dy + dz), epsilon = 1e-9);
2651 assert!(
2652 length
2653 .centre
2654 .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
2655 );
2656 }
2657
2658 #[test]
2659 fn the_answer_does_not_depend_on_where_the_shape_sits() {
2660 let mut model = Model::new();
2664 let here = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
2665 let far = Frame::new(
2666 Point::new(100.0, -50.0, 25.0),
2667 Direction::Z,
2668 Direction::X,
2669 T,
2670 )
2671 .unwrap();
2672 let there = make_box(&mut model, far, (1.0, 2.0, 3.0), T).unwrap();
2673
2674 let a = volume_properties(&model, &here.shape, fine(), T).unwrap();
2675 let b = volume_properties(&model, &there.shape, fine(), T).unwrap();
2676
2677 assert_relative_eq!(a.mass, b.mass, epsilon = 1e-9);
2678 assert!(
2679 b.centre
2680 .is_equal(a.centre + Vector::new(100.0, -50.0, 25.0), T)
2681 );
2682 for i in 0..3 {
2683 for j in 0..3 {
2684 assert_relative_eq!(a.inertia.rows[i][j], b.inertia.rows[i][j], epsilon = 1e-6);
2685 }
2686 }
2687 }
2688
2689 #[test]
2690 fn a_scaling_placement_scales_the_exact_measures_by_their_powers() {
2691 let mut model = Model::new();
2697 let pi = core::f64::consts::PI;
2698 let ball = crate::make_sphere(&mut model, Frame::WORLD, 10.0, T)
2699 .unwrap()
2700 .shape;
2701 let block = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
2702 .unwrap()
2703 .shape;
2704 let drum = crate::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
2705 .unwrap()
2706 .shape;
2707 let shapes = [
2709 (
2710 &ball,
2711 4.0 / 3.0 * pi * 1000.0,
2712 4.0 * pi * 100.0,
2713 Point::ORIGIN,
2714 ),
2715 (&block, 6.0, 22.0, Point::new(0.5, 1.0, 1.5)),
2716 (&drum, 20.0 * pi, 28.0 * pi, Point::new(0.0, 0.0, 2.5)),
2717 ];
2718 let half = Transform::scaling(Point::ORIGIN, 0.5, T).unwrap();
2719 let double = Transform::scaling(Point::new(1.0, -2.0, 3.0), 2.0, T).unwrap();
2720 let turned = Transform::translation(Vector::new(7.0, -3.0, 11.0))
2721 * Transform::rotation(ogeom_math::Axis::X, 0.7)
2722 * half;
2723 let mirrored = Transform::plane_mirror(Point::new(0.0, 4.0, 0.0), Direction::Y) * double;
2724 let inverted = Transform::scaling(Point::new(2.0, 0.0, 0.0), -0.5, T).unwrap();
2725
2726 for (shape, volume, area, centre) in shapes {
2727 let unplaced = [
2728 volume_properties(&model, shape, fine(), T).unwrap(),
2729 surface_properties(&model, shape, fine(), T).unwrap(),
2730 ];
2731 for placement in [half, double, turned, mirrored, inverted] {
2732 let placed = crate::place::transformed(&mut model, shape, placement)
2733 .unwrap()
2734 .shape;
2735 let s = placement.scale_factor().abs();
2736 let l = placement.linear();
2737 let measured = [
2738 volume_properties(&model, &placed, fine(), T).unwrap(),
2739 surface_properties(&model, &placed, fine(), T).unwrap(),
2740 ];
2741 for ((found, before), (power, exact)) in
2742 measured.iter().zip(&unplaced).zip([(3, volume), (2, area)])
2743 {
2744 assert_eq!(found.deflection, 0.0, "the exact path was taken");
2745 assert_relative_eq!(found.mass, exact * s.powi(power), max_relative = 1e-11);
2746 let at = placement.apply(centre);
2747 assert!(
2748 found.centre.distance(at) <= 1e-10 * (1.0 + at.to_vector().magnitude()),
2749 "centre {:?} against {at:?}",
2750 found.centre
2751 );
2752 let expected = l * before.inertia * l.transposed() * s.powi(power + 2);
2753 let size = (0..3).map(|i| expected.rows[i][i]).fold(0.0, f64::max);
2754 for i in 0..3 {
2755 for j in 0..3 {
2756 assert!(
2757 (found.inertia.rows[i][j] - expected.rows[i][j]).abs()
2758 <= 1e-10 * size,
2759 "inertia [{i}][{j}] {} against {}",
2760 found.inertia.rows[i][j],
2761 expected.rows[i][j]
2762 );
2763 }
2764 }
2765 }
2766 }
2767 }
2768 }
2769
2770 #[test]
2771 fn a_part_a_long_way_from_the_origin_keeps_its_precision() {
2772 let mut model = Model::new();
2777 let far = Frame::new(
2778 Point::new(1.0e6, -2.0e6, 5.0e5),
2779 Direction::Z,
2780 Direction::X,
2781 T,
2782 )
2783 .unwrap();
2784 let built = make_box(&mut model, far, (2.0, 3.0, 4.0), T).unwrap();
2785 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2786
2787 assert_relative_eq!(props.mass, 24.0, epsilon = 1e-6);
2788 assert_relative_eq!(
2789 props.inertia.rows[0][0],
2790 24.0 * 4.0_f64.mul_add(4.0, 3.0 * 3.0) / 12.0,
2791 epsilon = 1e-6
2792 );
2793 for (i, j) in [(0, 1), (0, 2), (1, 2)] {
2794 assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-6);
2795 }
2796 }
2797
2798 #[test]
2799 fn moving_the_inertia_off_the_centre_agrees_with_the_parallel_axis_theorem() {
2800 let mut model = Model::new();
2801 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2802 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2803
2804 let m = 8.0;
2806 let corner = props.inertia_about(Point::ORIGIN);
2807 assert_relative_eq!(
2808 corner.rows[0][0],
2809 2.0_f64.mul_add(2.0, 2.0 * 2.0).mul_add(m / 12.0, m * 2.0),
2810 epsilon = 1e-9
2811 );
2812 assert!(corner.rows[0][0] > props.inertia.rows[0][0]);
2814 }
2815
2816 #[test]
2817 fn a_cubes_principal_moments_are_all_the_same() {
2818 let mut model = Model::new();
2822 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2823 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2824
2825 let axes = props.principal_axes(T).unwrap();
2826 let expected = 8.0 * 2.0_f64.mul_add(2.0, 2.0 * 2.0) / 12.0;
2827 for (moment, _) in &axes {
2828 assert_relative_eq!(*moment, expected, epsilon = 1e-6);
2829 }
2830 for (i, j) in [(0, 1), (0, 2), (1, 2)] {
2831 assert_relative_eq!(
2832 axes[i].1.vector().dot(axes[j].1.vector()),
2833 0.0,
2834 epsilon = 1e-9
2835 );
2836 }
2837 }
2838
2839 #[test]
2840 fn a_long_box_spins_most_easily_about_its_length() {
2841 let mut model = Model::new();
2842 let built = make_box(&mut model, Frame::WORLD, (10.0, 1.0, 1.0), T).unwrap();
2843 let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2844
2845 let axes = props.principal_axes(T).unwrap();
2846 assert!(axes[0].1.vector().x.abs() > 0.99, "got {:?}", axes[0].1);
2848 assert!(axes[0].0 < axes[1].0 && axes[1].0 <= axes[2].0);
2849
2850 let along = props.radius_of_gyration(Direction::X).unwrap();
2851 let across = props.radius_of_gyration(Direction::Y).unwrap();
2852 assert!(along < across, "{along} should be less than {across}");
2853 }
2854
2855 #[test]
2856 fn a_sphere_converges_on_the_volume_a_sphere_has() {
2857 use crate::build::make_natural_face;
2862 use ogeom_geom::SphereSurface;
2863 use ogeom_math::Sphere;
2864
2865 let radius = 5.0_f64;
2866 let exact = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3);
2867 let mut previous = 0.0;
2868
2869 for chord in [0.5_f64, 0.1, 0.02] {
2870 let mut model = Model::new();
2871 let surface = SphereSurface::new(Sphere::new(Frame::WORLD, radius, T).unwrap());
2872 let face = make_natural_face(&mut model, surface.into()).unwrap().shape;
2873 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
2874 .unwrap()
2875 .shape;
2876
2877 let deflection = Deflection {
2878 chord,
2879 ..Deflection::default()
2880 };
2881 let props = volume_properties(&model, &shell, deflection, T).unwrap();
2882 assert_relative_eq!(props.deflection, chord);
2883 assert!(props.mass < exact, "an inscribed volume cannot exceed it");
2884 assert!(
2885 props.mass > previous,
2886 "tightening the chord lost volume: {} after {previous}",
2887 props.mass
2888 );
2889 assert!(
2893 props.centre.distance(Point::ORIGIN) < chord * 0.01,
2894 "a sphere's centre is its centre, got {:?}",
2895 props.centre
2896 );
2897 previous = props.mass;
2898 }
2899 assert!(
2900 previous > exact * 0.99,
2901 "{previous} should be within a percent of {exact}"
2902 );
2903 }
2904
2905 #[test]
2906 fn an_open_shell_is_refused_rather_than_measured() {
2907 let mut model = Model::new();
2910 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2911 let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
2912
2913 assert!(volume_properties(&model, &face, fine(), T).is_err());
2914 let area = surface_properties(&model, &face, fine(), T).unwrap();
2916 assert_relative_eq!(area.mass, 1.0, epsilon = 1e-9);
2917 }
2918
2919 #[test]
2920 fn an_inward_shell_is_refused_rather_than_reported_as_negative() {
2921 let mut model = Model::new();
2922 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2923 assert!(volume_properties(&model, &built.shape.reversed(), fine(), T).is_err());
2924 }
2925
2926 #[test]
2927 fn a_shape_with_nothing_to_measure_says_so_rather_than_dividing_by_zero() {
2928 let mut model = Model::new();
2929 let vertex = model.add_point(Point::ORIGIN);
2930
2931 for props in [
2932 volume_properties(&model, &vertex, fine(), T).unwrap(),
2933 surface_properties(&model, &vertex, fine(), T).unwrap(),
2934 linear_properties(&model, &vertex, fine(), T).unwrap(),
2935 ] {
2936 assert_relative_eq!(props.mass, 0.0);
2937 assert!(props.centre.is_equal(Point::ORIGIN, T));
2938 assert!(props.radius_of_gyration(Direction::Z).is_none());
2939 }
2940 }
2941
2942 #[test]
2943 fn an_unusable_deflection_is_refused() {
2944 let mut model = Model::new();
2945 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2946 let bad = Deflection {
2947 chord: -1.0,
2948 ..Deflection::default()
2949 };
2950 assert!(volume_properties(&model, &built.shape, bad, T).is_err());
2951 assert!(surface_properties(&model, &built.shape, bad, T).is_err());
2952 assert!(linear_properties(&model, &built.shape, bad, T).is_err());
2953 }
2954}