Skip to main content

geop_core_topology/validation/
manifold_check.rs

1use super::{ValidationParameters, validate};
2use geop_core_math::{geop_error::GeopError, plain, scalars::Scalar, vector::Vector3};
3
4use crate::{
5    Model, Sense,
6    contains::{
7        rng::Rng,
8        shell::{PointClassification, ShellBoundary, cast_ray},
9    },
10};
11
12/// Every edge of a solid's shell must be shared by an even number of
13/// coedges, as many traversing it `Forward` as `Reversed`: two where two
14/// faces meet, four (or more) where the solid touches itself along the
15/// edge — two blocks sharing only an edge, a boss tangent to a wall along a
16/// line — each face on one side of it paired with one on the other. An odd
17/// count is a free edge, a hole in the shell; unbalanced senses mean faces
18/// treating the edge as running the same direction, a torn seam rather
19/// than a shared one. Containment counts crossings of rays that graze no
20/// edge (see `contains::shell`), so an edge touched by more faces costs it
21/// nothing.
22///
23/// The exceptions bound no volume: a sheet's border, where an edge only one
24/// of its faces uses is simply where it ends, and a wire's edges, which no
25/// face uses.
26fn check_every_edge_has_paired_coedges(errors: &mut Vec<GeopError>, model: &Model) {
27    let wire_edges: std::collections::HashSet<crate::EdgeId> = model
28        .wires
29        .values()
30        .flat_map(|w| w.edges.iter().copied())
31        .collect();
32    for &edge_id in model.edges.keys().filter(|e| !wire_edges.contains(e)) {
33        let coedge_ids = model.coedges_of_edge(edge_id);
34        let n = coedge_ids.len();
35        let on_sheet = |coedge: &crate::CoedgeId| {
36            model
37                .body_of_face(model.coedges[coedge].face)
38                .is_ok_and(|body| matches!(body, crate::Body::Sheet(_)))
39        };
40        if n == 1 && on_sheet(&coedge_ids[0]) {
41            continue;
42        }
43        if n == 0 || !n.is_multiple_of(2) {
44            errors.push(GeopError::new(format!(
45                "edge {} has {} coedge(s) (expected an even number, two where two faces meet)",
46                edge_id.0, n
47            )));
48            continue;
49        }
50        let forward = coedge_ids
51            .iter()
52            .filter(|&id| model.coedges[id].sense == Sense::Forward)
53            .count();
54        if 2 * forward != n {
55            errors.push(GeopError::new(format!(
56                "edge {}'s {n} coedges {coedge_ids:?} run it Forward {forward} time(s) (expected as many Forward as Reversed)",
57                edge_id.0
58            )));
59        }
60    }
61}
62
63/// For every shell, samples `params.manifold_ray_sample_count` random points
64/// (drawn from a box around the shell, so both interior and exterior points
65/// come up) and, for each, casts `params.manifold_ray_sample_count` random
66/// ray directions (see `contains::shell::cast_ray`, the same per-direction
67/// logic `shell_contains` itself retries on a degenerate hit) — a degenerate
68/// direction is simply skipped (it gives no classification to compare).
69///
70/// A point near the shell's boundary can occasionally get one ray-cast
71/// classification that disagrees with the rest, purely from numerical
72/// fragility of the intersection search that close in — not a real topology
73/// defect. So a single dissenting vote is tolerated; only genuine majority
74/// disagreement (at least 2 votes on each side) is reported.
75fn check_ray_direction_consistency(
76    params: &ValidationParameters,
77    errors: &mut Vec<GeopError>,
78    model: &Model,
79) {
80    // Only a solid's shells enclose anything to be inside of.
81    for (&shell_id, _) in model.shells.iter().filter(|(_, s)| s.solid.is_some()) {
82        let boundary = ShellBoundary::of(model, shell_id);
83        // The box around the shell's vertices, as plain `f64`s: it only
84        // places random sample points, so exactness does not matter here.
85        let points = (boundary.vertices.iter()).map(|v| model.vertices[v].point.to_f64_array());
86        let Some((lo, hi)) = plain::bounds(points) else {
87            continue;
88        };
89        // Expand the box by half its size each way, so roughly-exterior
90        // points come up too, not just interior ones.
91        let margin: Vec<f64> = (0..3)
92            .map(|axis| (hi[axis] - lo[axis]).max(1.0) * 0.5)
93            .collect();
94
95        let mut rng = Rng::new(params.manifold_seed ^ shell_id.0);
96
97        for point_sample in 0..params.manifold_ray_sample_count {
98            let point = Vector3::from_array([
99                Scalar::from_f64(rng.next_range(lo[0] - margin[0], hi[0] + margin[0])),
100                Scalar::from_f64(rng.next_range(lo[1] - margin[1], hi[1] + margin[1])),
101                Scalar::from_f64(rng.next_range(lo[2] - margin[2], hi[2] + margin[2])),
102            ]);
103
104            let Ok(ray_length) = boundary.ray_length(&point) else {
105                continue;
106            };
107
108            let mut classifications = Vec::new();
109            for dir_sample in 0..params.manifold_ray_sample_count {
110                let direction = rng.next_direction3();
111                let seed = params.manifold_seed
112                    ^ shell_id.0
113                    ^ (point_sample as u64).wrapping_mul(0x9E3779B97F4A7C15)
114                    ^ (dir_sample as u64).wrapping_mul(0x2545_F491_4F6C_DD1D);
115                match cast_ray(
116                    &boundary,
117                    point,
118                    direction,
119                    ray_length,
120                    None,
121                    params.max_nodes,
122                    params.min_subdivision_size,
123                    seed,
124                ) {
125                    Ok(Ok(ray)) => classifications.push(ray.classification()),
126                    // Degenerate direction (grazed a vertex/edge, or an
127                    // ambiguous trim hit) — no claim to compare, skip.
128                    Ok(Err(_)) => {}
129                    Err(e) => errors.push(e.with_context(format!(
130                        "shell {}, point {:?}, direction sample {}",
131                        shell_id.0,
132                        (point[0].to_f64(), point[1].to_f64(), point[2].to_f64()),
133                        dir_sample
134                    ))),
135                }
136            }
137
138            if classifications.len() >= 2 {
139                let inside_count = classifications
140                    .iter()
141                    .filter(|&&c| c == PointClassification::Inside)
142                    .count();
143                let outside_count = classifications.len() - inside_count;
144                let minority = inside_count.min(outside_count);
145                // A single dissenting vote is tolerated as numerical noise
146                // near the boundary; only a genuine split (2+ votes on both
147                // sides) is reported.
148                if minority >= 2 {
149                    errors.push(GeopError::new(format!(
150                        "shell {}, point {:?}: ray directions disagree on inside/outside ({:?})",
151                        shell_id.0,
152                        (point[0].to_f64(), point[1].to_f64(), point[2].to_f64()),
153                        classifications
154                    )));
155                }
156            }
157        }
158    }
159}
160
161/// [`validate`] plus two extra whole-model sanity checks that go beyond
162/// per-entity consistency: every edge is shared by coedges paired up, as
163/// many each way (see [`check_every_edge_has_paired_coedges`]), and — the check that actually exercises that strategy — every
164/// shell classifies a batch of random points identically no matter which
165/// (non-degenerate) random ray direction was used to test them.
166pub fn validate_manifold(
167    params: &ValidationParameters,
168    model: &Model,
169) -> Result<(), Vec<GeopError>> {
170    let mut errors = match validate(params, model) {
171        Ok(()) => Vec::new(),
172        Err(e) => e,
173    };
174
175    check_every_edge_has_paired_coedges(&mut errors, model);
176    check_ray_direction_consistency(params, &mut errors, model);
177
178    if errors.is_empty() {
179        Ok(())
180    } else {
181        Err(errors)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::validate_manifold;
188    use crate::test_fixtures::test_cube_solid;
189    use crate::{CoedgeGeometry, Model, validation::ValidationParameters};
190
191    #[test]
192    fn welded_cube_passes() {
193        let mut model = Model::new();
194        test_cube_solid(&mut model);
195        validate_manifold(&ValidationParameters::default(), &model).unwrap();
196    }
197
198    #[test]
199    fn missing_edge_coedge_fails() {
200        let mut model = Model::new();
201        test_cube_solid(&mut model);
202        // Detach one coedge from its edge onto a bogus new one, leaving the
203        // original edge with only 1 coedge — a free edge.
204        let coedge_id = *model.coedges.keys().next().unwrap();
205        let orphan_edge = {
206            let template = model.edges[&model.coedges[&coedge_id].edge().unwrap()].clone();
207            model.insert_edge(template)
208        };
209        model.coedges.get_mut(&coedge_id).unwrap().geometry = CoedgeGeometry::Edge(orphan_edge);
210
211        let errors = validate_manifold(&ValidationParameters::default(), &model).unwrap_err();
212        assert!(
213            errors
214                .iter()
215                .any(|e| format!("{e:?}").contains("expected an even number"))
216        );
217    }
218}