1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23use ogeom_math::{Aabb, Direction, Point, Point2, Vector};
24use ogeom_mesh::{Deflection, face_boundary, inside_boundary, triangulate};
25use ogeom_topo::{Model, NodeData, Shape, ShapeType};
26
27use crate::measure::project_on_surface;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Containment {
32 In,
34 On,
36 Out,
38}
39
40impl Containment {
41 #[must_use]
43 pub const fn is_inside_or_on(self) -> bool {
44 matches!(self, Self::In | Self::On)
45 }
46
47 #[must_use]
52 pub const fn inverted(self) -> Self {
53 match self {
54 Self::In => Self::Out,
55 Self::On => Self::On,
56 Self::Out => Self::In,
57 }
58 }
59}
60
61pub fn classify_on_face(
74 model: &Model,
75 face: &Shape,
76 point: Point,
77 deflection: Deflection,
78 tol: Tolerances,
79) -> OgeomResult<Containment> {
80 deflection.validate()?;
81 if model.kind_of(face)? != ShapeType::Face {
82 ogeom_bail!(Construction, "expected a face");
83 }
84 let Some(node) = model.node(face) else {
85 ogeom_bail!(Dangling, "face is not in this model");
86 };
87 let NodeData::Face(data) = node.data() else {
88 ogeom_bail!(Construction, "face node holds no face data");
89 };
90 let Some(surface) = model.geometry().surface(data.surface) else {
91 ogeom_bail!(Dangling, "face refers to a surface not in this model");
92 };
93
94 let placement = face.transform(model.datums())?;
97 let local = placement.inverse()?.apply(point);
98
99 let projection = project_on_surface(surface, local, 32, tol)?;
103 let reach = tol.confusion().max(data.tolerance.get());
104 if projection.distance > reach {
105 return Ok(Containment::Out);
106 }
107
108 let rings = face_boundary(model, face, deflection, tol)?;
109 let (u, v) = projection.parameters;
110 let at = fold_toward_rings(surface, &rings, Point2::new(u, v));
111
112 let band = parametric_band(surface, (u, v), reach + deflection.chord, tol);
120 if distance_to_rings(&rings, at) <= band {
121 return Ok(Containment::On);
122 }
123 Ok(if inside_boundary(&rings, at) {
124 Containment::In
125 } else {
126 Containment::Out
127 })
128}
129
130pub fn classify_in_solid(
143 model: &Model,
144 solid: &Shape,
145 point: Point,
146 deflection: Deflection,
147 tol: Tolerances,
148) -> OgeomResult<Containment> {
149 deflection.validate()?;
150 let mesh = triangulate(model, solid, deflection, tol)?;
151 if mesh.is_empty() || !mesh.is_closed() {
152 ogeom_bail!(
153 Construction,
154 "the boundary is not closed, so there is no inside to be in"
155 );
156 }
157
158 let triangles: Vec<[Point; 3]> = mesh
159 .triangles
160 .iter()
161 .map(|t| t.map(|i| mesh.positions[i as usize]))
162 .collect();
163
164 let reach = tol.confusion() + deflection.chord;
168 for t in &triangles {
169 if distance_to_triangle(point, *t) <= reach {
170 return Ok(Containment::On);
171 }
172 }
173
174 for direction in RAY_DIRECTIONS {
178 let ray = Direction::new(Vector::new(direction[0], direction[1], direction[2]), tol)?;
179 if let Some(crossings) = count_crossings(&triangles, point, ray, tol) {
180 return Ok(if crossings % 2 == 1 {
181 Containment::In
182 } else {
183 Containment::Out
184 });
185 }
186 }
187 ogeom_bail!(
188 NotDone,
189 "every ray tried met an edge or a vertex, where the crossing count is \
190 ambiguous"
191 )
192}
193
194pub fn classify_in_solid_exact(
219 model: &Model,
220 solid: &Shape,
221 point: Point,
222 tol: Tolerances,
223) -> OgeomResult<Containment> {
224 classify_in_solid_exact_banded(model, solid, point, tol.confusion() * 1e4, tol)
225}
226
227pub fn classify_in_solid_exact_banded(
240 model: &Model,
241 solid: &Shape,
242 point: Point,
243 ring_chord: f64,
244 tol: Tolerances,
245) -> OgeomResult<Containment> {
246 SolidBoundary::of(model, solid, ring_chord, tol)?.holds(model, point, tol)
247}
248
249#[derive(Debug)]
252struct PreparedFace {
253 face: Shape,
254 surface: ogeom_geom::SurfaceGeometry,
255 inverse: ogeom_math::Transform,
257 rings: Vec<Vec<Point2>>,
259 bound: Aabb,
262}
263
264#[derive(Debug)]
276pub struct SolidBoundary {
277 faces: Vec<PreparedFace>,
278 bound: ogeom_math::Aabb,
279 centre: Point,
280 diagonal: f64,
281 ring_chord: f64,
282}
283
284impl SolidBoundary {
285 pub fn of(model: &Model, solid: &Shape, ring_chord: f64, tol: Tolerances) -> OgeomResult<Self> {
291 Self::prepare(model, solid, ring_chord, tol)
292 }
293
294 fn prepare(
295 model: &Model,
296 solid: &Shape,
297 ring_chord: f64,
298 tol: Tolerances,
299 ) -> OgeomResult<Self> {
300 let kind = model.kind_of(solid)?;
301 if !matches!(kind, ShapeType::Solid | ShapeType::Shell) {
302 ogeom_bail!(Construction, "expected a solid or a shell, got {kind:?}");
303 }
304 let shells = if kind == ShapeType::Shell {
305 vec![solid.clone()]
306 } else {
307 ogeom_topo::explore_unique(model, solid, ShapeType::Shell)?
308 };
309 if shells.is_empty() {
310 ogeom_bail!(Construction, "the shape has no shell, so no boundary");
311 }
312 for shell in &shells {
313 if !crate::build::is_shell_closed(model, shell)? {
314 ogeom_bail!(
315 Construction,
316 "the boundary is not closed, so there is no inside to be in"
317 );
318 }
319 }
320
321 let bound = crate::measure::shape_bounds(model, solid, tol)?;
324 let (Some(centre), diagonal) = (bound.centre(), bound.diagonal()) else {
325 ogeom_bail!(Construction, "the boundary bounds nothing");
326 };
327
328 let ring_deflection = Deflection {
332 chord: ring_chord,
333 angular: 0.05,
334 ..Deflection::default()
335 };
336
337 let faces = ogeom_topo::explore_unique(model, solid, ShapeType::Face)?;
338 let prepared = ogeom_core::parallel::map_ordered(&faces, |_, face| {
343 ogeom_core::progress::checkpoint()?;
344 let Some(node) = model.node(face) else {
345 ogeom_bail!(Dangling, "face is not in this model");
346 };
347 let NodeData::Face(data) = node.data() else {
348 ogeom_bail!(Construction, "face node holds no face data");
349 };
350 let Some(surface) = model.geometry().surface(data.surface) else {
351 ogeom_bail!(Dangling, "face refers to a surface not in this model");
352 };
353 let inverse = face.transform(model.datums())?.inverse()?;
354 let rings = face_boundary(model, face, ring_deflection, tol)?;
355 let own = crate::measure::shape_bounds(model, face, tol)?;
356 let bound = own.expanded(
357 ring_chord + data.tolerance.get() + tol.confusion() * 1e2 + own.diagonal() * 0.02,
358 );
359 Ok(PreparedFace {
360 face: face.clone(),
361 surface: surface.clone(),
362 inverse,
363 rings,
364 bound,
365 })
366 })
367 .into_iter()
368 .collect::<OgeomResult<Vec<_>>>()?;
369 Ok(Self {
370 faces: prepared,
371 bound,
372 centre,
373 diagonal,
374 ring_chord,
375 })
376 }
377
378 pub fn holds(&self, model: &Model, point: Point, tol: Tolerances) -> OgeomResult<Containment> {
384 let ring_chord = self.ring_chord;
385 let ring_deflection = Deflection {
386 chord: ring_chord,
387 angular: 0.05,
388 ..Deflection::default()
389 };
390 let reach = tol.confusion();
391 if !self.bound.expanded(reach).contains(point) {
392 return Ok(Containment::Out);
393 }
394 let length = point.distance(self.centre) + self.diagonal + 1.0;
395
396 for prepared in &self.faces {
400 if !prepared.bound.contains(point) {
401 continue;
402 }
403 if classify_on_face(model, &prepared.face, point, ring_deflection, tol)?
404 != Containment::Out
405 {
406 return Ok(Containment::On);
407 }
408 }
409 'directions: for direction in RAY_DIRECTIONS {
410 let along = Vector::new(direction[0], direction[1], direction[2]);
411 let far = point + along * length;
412 let mut crossings = 0_usize;
413
414 for PreparedFace {
415 surface,
416 inverse,
417 rings,
418 bound,
419 ..
420 } in &self.faces
421 {
422 if !segment_meets(bound, point, far) {
423 continue;
424 }
425 let from = inverse.apply(point);
428 let to = inverse.apply(far);
429 let ray: ogeom_geom::Curve = ogeom_geom::LineCurve::segment(from, to, tol)?.into();
430 let found = ogeom_intersect::intersect_curve_surface(
431 &ray,
432 surface,
433 ogeom_intersect::CurveSurfaceOptions::default(),
434 tol,
435 )?;
436 if !found.lying.is_empty() {
437 continue 'directions;
440 }
441 for hit in &found.crossings {
442 if hit.on_curve <= tol.confusion() {
443 let (u, v) = hit.on_surface;
450 let at = fold_toward_rings(surface, rings, Point2::new(u, v));
451 let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
452 if distance_to_rings(rings, at) <= band || inside_boundary(rings, at) {
453 continue 'directions;
454 }
455 continue;
456 }
457 let (u, v) = hit.on_surface;
458 use ogeom_geom::Surface as _;
459 let Ok((du, dv)) = surface.d1_at(u, v, tol) else {
460 continue 'directions;
461 };
462 let normal = du.cross(dv);
463 if normal.magnitude() <= tol.confusion() {
464 continue 'directions;
466 }
467 let ray_direction = (to - from) / (to - from).magnitude();
468 if normal.dot(ray_direction).abs() <= GRAZING * normal.magnitude() {
469 continue 'directions;
472 }
473 let at = fold_toward_rings(surface, rings, Point2::new(u, v));
474 let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
475 if distance_to_rings(rings, at) <= band {
476 continue 'directions;
480 }
481 if inside_boundary(rings, at) {
482 crossings += 1;
483 }
484 }
485 }
486 return Ok(if crossings % 2 == 1 {
487 Containment::In
488 } else {
489 Containment::Out
490 });
491 }
492 ogeom_bail!(
493 NotDone,
494 "every ray tried met a tangency, a boundary, or a degenerate point, \
495 where the crossing count is ambiguous"
496 )
497 }
498}
499
500const GRAZING: f64 = 1e-6;
509
510const RAY_DIRECTIONS: [[f64; 3]; 6] = [
519 [0.577_35, 0.577_35, 0.577_35],
520 [-0.301_5, 0.904_5, 0.301_5],
521 [0.727_6, -0.485_1, 0.485_1],
522 [0.259_5, 0.259_5, -0.930_0],
523 [-0.816_5, -0.408_2, 0.408_2],
524 [0.132_5, -0.662_3, -0.737_5],
525];
526
527fn count_crossings(
530 triangles: &[[Point; 3]],
531 from: Point,
532 along: Direction,
533 tol: Tolerances,
534) -> Option<usize> {
535 let mut crossings = 0;
536 for t in triangles {
537 match ray_hits_triangle(from, along, *t, tol) {
538 Hit::Crosses => crossings += 1,
539 Hit::Misses => {}
540 Hit::Ambiguous => return None,
541 }
542 }
543 Some(crossings)
544}
545
546enum Hit {
548 Crosses,
550 Misses,
552 Ambiguous,
555}
556
557fn ray_hits_triangle(from: Point, along: Direction, t: [Point; 3], tol: Tolerances) -> Hit {
560 let direction = along.vector();
561 let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
562 let h = direction.cross(e2);
563 let determinant = e1.dot(h);
564
565 let scale = e1.magnitude() * e2.magnitude();
569 let flat = tol.confusion() * scale;
570 if determinant.abs() <= flat {
571 let normal = e1.cross(e2);
574 let reach = tol.confusion() * scale;
575 return if normal.dot(from - t[0]).abs() <= reach {
576 Hit::Ambiguous
577 } else {
578 Hit::Misses
579 };
580 }
581
582 let inverse = 1.0 / determinant;
583 let s = from - t[0];
584 let u = inverse * s.dot(h);
585 let q = s.cross(e1);
586 let v = inverse * direction.dot(q);
587 let w = 1.0 - u - v;
588
589 let edge = tol.confusion();
592 if [u, v, w].iter().any(|c| c.abs() <= edge) {
593 return if u >= -edge && v >= -edge && w >= -edge {
596 Hit::Ambiguous
597 } else {
598 Hit::Misses
599 };
600 }
601 if u < 0.0 || v < 0.0 || w < 0.0 {
602 return Hit::Misses;
603 }
604
605 let distance = inverse * e2.dot(q);
606 if distance <= tol.confusion() {
607 return Hit::Misses;
610 }
611 Hit::Crosses
612}
613
614fn distance_to_triangle(p: Point, t: [Point; 3]) -> f64 {
616 let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
621 let d = t[0] - p;
622 let (a, b, c) = (e1.dot(e1), e1.dot(e2), e2.dot(e2));
623 let (dd, e) = (e1.dot(d), e2.dot(d));
624 let determinant = b.mul_add(-b, a * c);
625
626 if determinant.abs() <= f64::MIN_POSITIVE {
627 return edge_distance(p, t);
629 }
630 let mut s = b.mul_add(e, -(c * dd)) / determinant;
631 let mut u = b.mul_add(dd, -(a * e)) / determinant;
632
633 if s >= 0.0 && u >= 0.0 && s + u <= 1.0 {
634 let closest = t[0] + e1 * s + e2 * u;
635 return p.distance(closest);
636 }
637 s = s.clamp(0.0, 1.0);
639 u = u.clamp(0.0, 1.0);
640 let _ = (s, u);
641 edge_distance(p, t)
642}
643
644fn edge_distance(p: Point, t: [Point; 3]) -> f64 {
646 let mut best = f64::INFINITY;
647 for i in 0..3 {
648 best = best.min(segment_distance(p, t[i], t[(i + 1) % 3]));
649 }
650 best
651}
652
653fn segment_distance(p: Point, a: Point, b: Point) -> f64 {
655 let d = b - a;
656 let squared = d.dot(d);
657 if squared <= f64::MIN_POSITIVE {
658 return p.distance(a);
659 }
660 let t = ((p - a).dot(d) / squared).clamp(0.0, 1.0);
661 p.distance(a + d * t)
662}
663
664pub(crate) fn distance_to_rings(rings: &[Vec<Point2>], p: Point2) -> f64 {
666 let mut best = f64::INFINITY;
667 for ring in rings {
668 for i in 0..ring.len() {
669 let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
670 best = best.min(segment_distance_2d(p, a, b));
671 }
672 }
673 best
674}
675
676fn segment_distance_2d(p: Point2, a: Point2, b: Point2) -> f64 {
678 let d = b - a;
679 let squared = d.dot(d);
680 if squared <= f64::MIN_POSITIVE {
681 return p.distance(a);
682 }
683 let t = ((p - a).dot(d) / squared).clamp(0.0, 1.0);
684 p.distance(a + d * t)
685}
686
687pub(crate) fn fold_toward_rings(
700 surface: &ogeom_geom::SurfaceGeometry,
701 rings: &[Vec<Point2>],
702 mut at: Point2,
703) -> Point2 {
704 use ogeom_geom::SurfaceGeometry as S;
705 let tau = core::f64::consts::TAU;
706 let (u_period, v_period) = match surface {
707 S::Cylinder(_) | S::Cone(_) => (Some(tau), None),
708 S::Sphere(_) => (Some(tau), None),
709 S::Torus(_) => (Some(tau), Some(tau)),
710 _ => (None, None),
711 };
712 let fold = |x: f64, lo: f64, hi: f64, period: f64| -> f64 {
713 let mut x = x;
714 while x < lo && x + period <= hi + period {
715 x += period;
716 if x >= lo {
717 break;
718 }
719 }
720 while x > hi && x - period >= lo - period {
721 x -= period;
722 if x <= hi {
723 break;
724 }
725 }
726 x
727 };
728 if let Some(period) = u_period {
729 let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
730 for ring in rings {
731 for q in ring {
732 lo = lo.min(q.x);
733 hi = hi.max(q.x);
734 }
735 }
736 if lo.is_finite() {
737 at.x = fold(at.x, lo, hi, period);
738 }
739 }
740 if let Some(period) = v_period {
741 let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
742 for ring in rings {
743 for q in ring {
744 lo = lo.min(q.y);
745 hi = hi.max(q.y);
746 }
747 }
748 if lo.is_finite() {
749 at.y = fold(at.y, lo, hi, period);
750 }
751 }
752 if !inside_boundary(rings, at) {
757 let shifts = [
758 u_period.map(|p| (p, 0.0)),
759 u_period.map(|p| (-p, 0.0)),
760 v_period.map(|p| (0.0, p)),
761 v_period.map(|p| (0.0, -p)),
762 ];
763 if let Some(inside) = shifts
764 .into_iter()
765 .flatten()
766 .map(|(du, dv)| Point2::new(at.x + du, at.y + dv))
767 .find(|q| inside_boundary(rings, *q))
768 {
769 return inside;
770 }
771 }
772 at
773}
774
775pub(crate) fn parametric_band(
776 surface: &ogeom_geom::SurfaceGeometry,
777 at: (f64, f64),
778 reach: f64,
779 tol: Tolerances,
780) -> f64 {
781 use ogeom_geom::Surface;
782 let Ok((du, dv)) = surface.d1_at(at.0, at.1, tol) else {
783 return reach;
784 };
785 let scale = du.magnitude().min(dv.magnitude());
786 if scale <= tol.confusion() {
787 return f64::INFINITY;
788 }
789 reach / scale
790}
791
792fn segment_meets(bound: &Aabb, a: Point, b: Point) -> bool {
794 let (Some(low), Some(high)) = (bound.low(), bound.high()) else {
795 return false;
796 };
797 let (mut enter, mut leave) = (0.0_f64, 1.0_f64);
798 for (from, to, lo, hi) in [
799 (a.x, b.x, low.x, high.x),
800 (a.y, b.y, low.y, high.y),
801 (a.z, b.z, low.z, high.z),
802 ] {
803 let d = to - from;
804 if d.abs() <= f64::EPSILON * (from.abs() + to.abs() + 1.0) {
805 if from < lo || from > hi {
806 return false;
807 }
808 continue;
809 }
810 let (t0, t1) = ((lo - from) / d, (hi - from) / d);
811 let (t0, t1) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
812 enter = enter.max(t0);
813 leave = leave.min(t1);
814 if enter > leave {
815 return false;
816 }
817 }
818 true
819}
820
821#[cfg(test)]
822#[allow(clippy::unwrap_used, clippy::expect_used)]
823mod tests {
824 use super::*;
825 use crate::make_box;
826 use ogeom_math::Frame;
827 use ogeom_topo::{ShapeType, explore_unique};
828
829 const T: Tolerances = Tolerances::millimetres();
830
831 fn fine() -> Deflection {
832 Deflection {
833 chord: 1e-3,
834 angular: 0.05,
835 ..Deflection::default()
836 }
837 }
838
839 #[test]
840 fn a_point_inside_a_box_is_inside_it() {
841 let mut model = Model::new();
842 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
843
844 for p in [
845 Point::new(1.0, 1.0, 1.0),
846 Point::new(0.1, 0.1, 0.1),
847 Point::new(1.9, 1.9, 1.9),
848 ] {
849 assert_eq!(
850 classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
851 Containment::In,
852 "{p:?} should be inside"
853 );
854 }
855 }
856
857 #[test]
858 fn a_point_outside_a_box_is_outside_it() {
859 let mut model = Model::new();
860 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
861
862 for p in [
863 Point::new(3.0, 1.0, 1.0),
864 Point::new(-1.0, 1.0, 1.0),
865 Point::new(1.0, 1.0, -0.5),
866 Point::new(-5.0, -5.0, -5.0),
867 ] {
868 assert_eq!(
869 classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
870 Containment::Out,
871 "{p:?} should be outside"
872 );
873 }
874 }
875
876 #[test]
877 fn a_point_on_a_boxs_face_is_on_it_rather_than_forced_to_a_side() {
878 let mut model = Model::new();
881 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
882
883 for p in [
884 Point::new(1.0, 1.0, 0.0), Point::new(0.0, 1.0, 1.0), Point::new(2.0, 2.0, 1.0), Point::ORIGIN, Point::new(2.0, 2.0, 2.0), ] {
890 assert_eq!(
891 classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
892 Containment::On,
893 "{p:?} should be on the boundary"
894 );
895 }
896 }
897
898 #[test]
899 fn a_ray_along_a_grid_of_edges_does_not_defeat_the_classifier() {
900 let mut model = Model::new();
905 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
906
907 assert_eq!(
910 classify_in_solid(&model, &built.shape, Point::new(1.0, 1.0, 1.0), fine(), T).unwrap(),
911 Containment::In
912 );
913 }
914
915 #[test]
916 fn an_open_shell_has_no_inside() {
917 let mut model = Model::new();
918 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
919 let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
920 assert!(classify_in_solid(&model, &face, Point::ORIGIN, fine(), T).is_err());
921 }
922
923 #[test]
924 fn a_point_on_a_face_is_inside_its_trimming_or_not() {
925 let mut model = Model::new();
926 let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
927 let bottom = explore_unique(&model, &built.shape, ShapeType::Face)
930 .unwrap()
931 .into_iter()
932 .find(|f| {
933 model
934 .provenance_of(f)
935 .and_then(ogeom_core::Provenance::role)
936 == Some(crate::primitive::roles::FACE_MIN_Z)
937 })
938 .expect("the box has a face at z = 0");
939
940 assert_eq!(
941 classify_on_face(&model, &bottom, Point::new(1.0, 1.5, 0.0), fine(), T).unwrap(),
942 Containment::In
943 );
944 assert_eq!(
945 classify_on_face(&model, &bottom, Point::new(5.0, 1.5, 0.0), fine(), T).unwrap(),
946 Containment::Out,
947 "on the surface's plane but outside the trimming"
948 );
949 assert_eq!(
950 classify_on_face(&model, &bottom, Point::new(1.0, 1.5, 1.0), fine(), T).unwrap(),
951 Containment::Out,
952 "off the surface entirely"
953 );
954 assert_eq!(
955 classify_on_face(&model, &bottom, Point::new(0.0, 1.5, 0.0), fine(), T).unwrap(),
956 Containment::On,
957 "on the trimming boundary"
958 );
959 }
960
961 #[test]
962 fn the_answers_invert_the_way_a_complement_does() {
963 assert_eq!(Containment::In.inverted(), Containment::Out);
964 assert_eq!(Containment::Out.inverted(), Containment::In);
965 assert_eq!(Containment::On.inverted(), Containment::On);
967
968 assert!(Containment::In.is_inside_or_on());
969 assert!(Containment::On.is_inside_or_on());
970 assert!(!Containment::Out.is_inside_or_on());
971 }
972
973 #[test]
974 fn a_translated_box_classifies_the_same_way_translated_points() {
975 let offset = Vector::new(10.0, -20.0, 30.0);
976 let mut model = Model::new();
977 let frame = Frame::new(Point::ORIGIN + offset, Direction::Z, Direction::X, T).unwrap();
978 let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
979
980 assert_eq!(
981 classify_in_solid(
982 &model,
983 &built.shape,
984 Point::new(1.0, 1.0, 1.0) + offset,
985 fine(),
986 T
987 )
988 .unwrap(),
989 Containment::In
990 );
991 assert_eq!(
992 classify_in_solid(&model, &built.shape, Point::new(1.0, 1.0, 1.0), fine(), T).unwrap(),
993 Containment::Out,
994 "the untranslated point is nowhere near the translated box"
995 );
996 }
997
998 #[test]
999 fn the_exact_classifier_agrees_with_the_tessellated_one_on_a_box() {
1000 let mut model = Model::new();
1001 let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
1002
1003 for (p, want) in [
1004 (Point::new(1.0, 1.0, 1.0), Containment::In),
1005 (Point::new(0.1, 0.1, 0.1), Containment::In),
1006 (Point::new(3.0, 1.0, 1.0), Containment::Out),
1007 (Point::new(1.0, 1.0, -0.5), Containment::Out),
1008 (Point::new(-50.0, -50.0, -50.0), Containment::Out),
1009 (Point::new(1.0, 1.0, 0.0), Containment::On),
1010 (Point::new(2.0, 2.0, 1.0), Containment::On),
1011 (Point::ORIGIN, Containment::On),
1012 ] {
1013 assert_eq!(
1014 classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
1015 want,
1016 "{p:?}"
1017 );
1018 }
1019 }
1020
1021 #[test]
1022 fn the_exact_classifier_resolves_what_the_deflection_band_cannot() {
1023 let mut model = Model::new();
1028 let built = crate::make_sphere(&mut model, Frame::WORLD, 2.0, T).unwrap();
1029
1030 let barely_in = Point::new(0.0, 0.0, 2.0 - 1e-5);
1031 let barely_out = Point::new(0.0, 0.0, 2.0 + 1e-5);
1032
1033 assert_eq!(
1034 classify_in_solid(&model, &built.shape, barely_in, fine(), T).unwrap(),
1035 Containment::On,
1036 "the mesh cannot tell a micron from the wall"
1037 );
1038 assert_eq!(
1039 classify_in_solid_exact(&model, &built.shape, barely_in, T).unwrap(),
1040 Containment::In
1041 );
1042 assert_eq!(
1043 classify_in_solid_exact(&model, &built.shape, barely_out, T).unwrap(),
1044 Containment::Out
1045 );
1046 assert_eq!(
1048 classify_in_solid_exact(&model, &built.shape, Point::new(0.0, 0.0, 2.0), T).unwrap(),
1049 Containment::On
1050 );
1051 }
1052
1053 #[test]
1054 fn the_exact_classifier_handles_a_cylinder_wall_and_caps() {
1055 let mut model = Model::new();
1056 let built = crate::make_cylinder(&mut model, Frame::WORLD, 1.5, 4.0, T).unwrap();
1057
1058 for (p, want) in [
1059 (Point::new(0.0, 0.0, 2.0), Containment::In),
1060 (Point::new(1.5 - 1e-5, 0.0, 2.0), Containment::In),
1061 (Point::new(1.5 + 1e-5, 0.0, 2.0), Containment::Out),
1062 (Point::new(0.3, 0.4, 4.0 - 1e-5), Containment::In),
1063 (Point::new(0.3, 0.4, 4.0 + 1e-5), Containment::Out),
1064 (Point::new(1.5, 0.0, 2.0), Containment::On),
1065 (Point::new(0.3, 0.4, 0.0), Containment::On),
1066 ] {
1067 assert_eq!(
1068 classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
1069 want,
1070 "{p:?}"
1071 );
1072 }
1073 }
1074
1075 #[test]
1076 fn the_exact_classifier_walks_the_general_path_through_a_torus() {
1077 let mut model = Model::new();
1081 let built = crate::make_torus(&mut model, Frame::WORLD, 3.0, 1.0, T).unwrap();
1082
1083 for (p, want) in [
1084 (Point::new(3.0, 0.0, 0.0), Containment::In),
1085 (Point::new(3.0, 0.0, 0.9), Containment::In),
1086 (Point::ORIGIN, Containment::Out),
1087 (Point::new(3.0, 0.0, 1.5), Containment::Out),
1088 (Point::new(5.0, 5.0, 0.0), Containment::Out),
1089 (Point::new(3.0, 0.0, 1.0), Containment::On),
1090 ] {
1091 assert_eq!(
1092 classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
1093 want,
1094 "{p:?}"
1095 );
1096 }
1097 }
1098
1099 #[test]
1100 fn the_exact_classifier_respects_a_placed_solid() {
1101 let offset = Vector::new(10.0, -20.0, 30.0);
1102 let mut model = Model::new();
1103 let frame = Frame::new(Point::ORIGIN + offset, Direction::Z, Direction::X, T).unwrap();
1104 let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
1105
1106 assert_eq!(
1107 classify_in_solid_exact(&model, &built.shape, Point::new(1.0, 1.0, 1.0) + offset, T)
1108 .unwrap(),
1109 Containment::In
1110 );
1111 assert_eq!(
1112 classify_in_solid_exact(&model, &built.shape, Point::new(1.0, 1.0, 1.0), T).unwrap(),
1113 Containment::Out
1114 );
1115 }
1116
1117 #[test]
1118 fn the_exact_classifier_refuses_an_open_boundary() {
1119 let mut model = Model::new();
1120 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1121 let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
1122 assert!(classify_in_solid_exact(&model, &face, Point::ORIGIN, T).is_err());
1123 }
1124
1125 #[test]
1126 fn distance_to_a_triangle_is_measured_from_the_nearest_part_of_it() {
1127 let t = [
1128 Point::ORIGIN,
1129 Point::new(1.0, 0.0, 0.0),
1130 Point::new(0.0, 1.0, 0.0),
1131 ];
1132 assert!((distance_to_triangle(Point::new(0.25, 0.25, 2.0), t) - 2.0).abs() < 1e-12);
1134 assert!((distance_to_triangle(Point::new(-3.0, 0.0, 0.0), t) - 3.0).abs() < 1e-12);
1136 assert!(distance_to_triangle(Point::new(0.25, 0.25, 0.0), t) < 1e-12);
1138 }
1139
1140 #[test]
1141 fn an_unusable_deflection_is_refused() {
1142 let mut model = Model::new();
1143 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1144 let bad = Deflection {
1145 chord: f64::NAN,
1146 ..Deflection::default()
1147 };
1148 assert!(classify_in_solid(&model, &built.shape, Point::ORIGIN, bad, T).is_err());
1149 }
1150}