Skip to main content

axiolid_mesh_compile/
contact.rs

1//! Bodies of a union's result that lie within the tolerance (#356, #357).
2//!
3//! The mesh boolean has no tolerance (ADR 0047): it merges faces only where
4//! they are exactly coplanar. Two operands that meet within the linear
5//! tolerance, but not exactly, can therefore come back as two bodies, closed
6//! and consistently wound, which the closure check labels a solid. The exact
7//! compiler reads the same operands as one body (ADR 0080), so the two
8//! compilers would disagree about a union the caller declared in contact.
9//!
10//! [`unmerged`] finds that outcome on the result: the first pair of index
11//! components whose surfaces lie within the tolerance of each other. The
12//! caller refuses the union by name instead of returning it. Reading the
13//! result needs no point-in-mesh containment between the operands, and it
14//! also sees a union that leaves a cavity open to the outside by less than
15//! the tolerance.
16//!
17//! The cost is nothing for the common case, a result of one component.
18//! Otherwise pairs of components whose boxes, grown by the tolerance, meet
19//! are measured with [`axiolid_inspect::surface_gap`].
20//!
21//! What it does not see: one body whose faces cross with sub-rounding cracks
22//! where contact faces met under different placements. That is not this
23//! guard's: a union whose operands have faces that nearly share a plane
24//! ([`axiolid_inspect::near_coplanar`]) is decided by the exact compiler
25//! ([`crate::referee`], #391, ADR 0098), and the guard also decides whether
26//! the referee's result is accepted.
27
28use axiolid_contracts::GeomError;
29use axiolid_core::{Aabb, Point3, Scalar};
30use axiolid_mesh::{decompose, TriMesh};
31
32/// The start of the `GeomError::Degenerate` message a union is refused with
33/// when its result holds two bodies within the linear tolerance of each other
34/// (#356, #357): the operands meet within the tolerance, and the mesh boolean
35/// left them apart.
36pub const UNMERGED_UNION_CONTACT: &str =
37    "union of operands that meet within the linear tolerance, left as separate bodies";
38
39/// Two bodies of a union's result within `eps` of each other.
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub(crate) struct Unmerged {
42    /// The distance between their surfaces.
43    pub distance: Scalar,
44    /// The first body's bounding box, as minimum and maximum corners.
45    pub first: (Point3, Point3),
46    /// The second body's bounding box.
47    pub second: (Point3, Point3),
48}
49
50/// The first pair of index components of `mesh` (in
51/// [`axiolid_mesh::decompose`] order, pairs `i < j` lexicographic) whose
52/// surfaces lie within `eps`; `None` for fewer than two components or none
53/// that close.
54pub(crate) fn unmerged(mesh: &TriMesh, eps: Scalar) -> Option<Unmerged> {
55    let parts = decompose(mesh);
56    if parts.len() < 2 {
57        return None;
58    }
59    let boxes: Vec<Aabb> = parts.iter().map(bounds).collect();
60    for i in 0..parts.len() {
61        for j in i + 1..parts.len() {
62            if !close(&boxes[i], &boxes[j], eps) {
63                continue;
64            }
65            if let Some(distance) = axiolid_inspect::surface_gap(&parts[i], &parts[j], eps) {
66                return Some(Unmerged {
67                    distance,
68                    first: (boxes[i].min, boxes[i].max),
69                    second: (boxes[j].min, boxes[j].max),
70                });
71            }
72        }
73    }
74    None
75}
76
77fn bounds(mesh: &TriMesh) -> Aabb {
78    let mut points = mesh.positions.iter();
79    let mut bounds = Aabb::from_point(points.next().copied().unwrap_or(Point3::ZERO));
80    for point in points {
81        bounds.extend(*point);
82    }
83    bounds
84}
85
86/// Whether the boxes, grown by `eps`, meet.
87fn close(a: &Aabb, b: &Aabb, eps: Scalar) -> bool {
88    a.min.x - eps <= b.max.x
89        && b.min.x - eps <= a.max.x
90        && a.min.y - eps <= b.max.y
91        && b.min.y - eps <= a.max.y
92        && a.min.z - eps <= b.max.z
93        && b.min.z - eps <= a.max.z
94}
95
96/// The refusal of a union that left `found` apart, within `eps`.
97pub(crate) fn refusal(found: &Unmerged, eps: Scalar) -> GeomError {
98    GeomError::Degenerate(format!(
99        "{UNMERGED_UNION_CONTACT}: two bodies {:e} apart, within the linear tolerance {eps:e} \
100         (boxes {:?} and {:?})",
101        found.distance, found.first, found.second
102    ))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn cube(lo: Point3, hi: Point3) -> TriMesh {
110        let p = |x: bool, y: bool, z: bool| {
111            Point3::new(
112                if x { hi.x } else { lo.x },
113                if y { hi.y } else { lo.y },
114                if z { hi.z } else { lo.z },
115            )
116        };
117        let positions = vec![
118            p(false, false, false),
119            p(true, false, false),
120            p(true, true, false),
121            p(false, true, false),
122            p(false, false, true),
123            p(true, false, true),
124            p(true, true, true),
125            p(false, true, true),
126        ];
127        let indices = vec![
128            0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7,
129            6, 3, 0, 4, 3, 4, 7,
130        ];
131        TriMesh::new(positions, indices)
132    }
133
134    fn unit(x: f64, y: f64, z: f64) -> TriMesh {
135        cube(Point3::new(x, y, z), Point3::new(x + 1.0, y + 1.0, z + 1.0))
136    }
137
138    /// Two meshes as one, indices shifted, as a union result holds two bodies.
139    fn together(a: &TriMesh, b: &TriMesh) -> TriMesh {
140        let mut positions = a.positions.clone();
141        let offset = positions.len() as u32;
142        positions.extend(b.positions.iter().copied());
143        let mut indices = a.indices.clone();
144        indices.extend(b.indices.iter().map(|i| i + offset));
145        TriMesh::new(positions, indices)
146    }
147
148    const EPS: f64 = 1e-6;
149
150    #[test]
151    fn one_body_is_never_unmerged() {
152        assert_eq!(unmerged(&unit(0.0, 0.0, 0.0), EPS), None);
153    }
154
155    #[test]
156    fn two_cubes_half_a_tolerance_apart_are_unmerged() {
157        let mesh = together(&unit(0.0, 0.0, 0.0), &unit(1.0 + 0.5e-6, 0.0, 0.0));
158        let found = unmerged(&mesh, EPS).expect("within the tolerance");
159        assert!(
160            (found.distance - 0.5e-6).abs() < 1e-15,
161            "{}",
162            found.distance
163        );
164        assert_eq!(found.first.1.x, 1.0);
165        assert_eq!(found.second.0.x, 1.0 + 0.5e-6);
166    }
167
168    #[test]
169    fn two_cubes_two_tolerances_apart_are_not() {
170        let mesh = together(&unit(0.0, 0.0, 0.0), &unit(1.0 + 2e-6, 0.0, 0.0));
171        assert_eq!(unmerged(&mesh, EPS), None);
172    }
173
174    #[test]
175    fn cubes_touching_along_an_edge_are_unmerged() {
176        let mesh = together(&unit(0.0, 0.0, 0.0), &unit(1.0, 1.0, 0.0));
177        let found = unmerged(&mesh, EPS).expect("touching");
178        assert_eq!(found.distance, 0.0);
179    }
180
181    #[test]
182    fn a_body_in_a_cavity_further_than_the_tolerance_is_not() {
183        let outer = cube(Point3::ZERO, Point3::new(10.0, 10.0, 10.0));
184        let mut void = cube(Point3::new(2.0, 2.0, 2.0), Point3::new(8.0, 8.0, 8.0));
185        void.indices
186            .as_chunks_mut::<3>()
187            .0
188            .iter_mut()
189            .for_each(|triangle| triangle.swap(1, 2));
190        let island = cube(Point3::new(3.0, 3.0, 3.0), Point3::new(4.0, 4.0, 4.0));
191        let mesh = together(&together(&outer, &void), &island);
192        assert_eq!(unmerged(&mesh, EPS), None);
193    }
194
195    #[test]
196    fn zero_tolerance_finds_only_touching() {
197        let touching = together(&unit(0.0, 0.0, 0.0), &unit(1.0, 0.0, 0.0));
198        assert!(unmerged(&touching, 0.0).is_some());
199        let apart = together(&unit(0.0, 0.0, 0.0), &unit(1.0 + 1e-9, 0.0, 0.0));
200        assert_eq!(unmerged(&apart, 0.0), None);
201    }
202}