axiolid_arrangement/validate.rs
1// SPDX-License-Identifier: MPL-2.0
2
3//! Structural audit of the arrangement.
4//!
5//! Every edit is supposed to preserve these invariants. Having them as a
6//! checkable report rather than scattered `debug_assert!`s means a test can
7//! prove an edit left the structure sound, and a caller integrating a new
8//! edit can find out where it went wrong instead of getting a hang.
9
10use crate::id::{FaceId, HalfEdgeId};
11use crate::Arrangement;
12
13/// What an audit found.
14///
15/// Counts rather than a bare bool: "the structure is broken" is not
16/// actionable, "three half-edges have a twin that does not point back" is.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub struct ArrangementHealth {
19 /// Half-edges whose twin does not name them back.
20 pub broken_twins: usize,
21 /// Half-edges where `next`'s `prev` is not the half-edge itself.
22 pub broken_links: usize,
23 /// Half-edges whose `next` lies on a different face.
24 pub face_mismatches: usize,
25 /// Boundary walks that did not close within the arena bound.
26 pub unclosed_faces: usize,
27 /// Bounded faces whose signed area is not strictly positive.
28 pub inverted_faces: usize,
29}
30
31impl ArrangementHealth {
32 /// Whether every invariant holds.
33 #[must_use]
34 pub fn is_sound(&self) -> bool {
35 *self == Self::default()
36 }
37}
38
39impl Arrangement {
40 /// Check every structural invariant.
41 #[must_use]
42 pub fn audit(&self) -> ArrangementHealth {
43 let mut health = ArrangementHealth::default();
44
45 for (index, he) in self.halfedges.iter().enumerate() {
46 let id = HalfEdgeId::from_index(index);
47 if self.halfedges[he.twin.index()].twin != id {
48 health.broken_twins += 1;
49 }
50 if self.halfedges[he.next.index()].prev != id {
51 health.broken_links += 1;
52 }
53 if self.halfedges[he.next.index()].face != he.face {
54 health.face_mismatches += 1;
55 }
56 }
57
58 for index in 0..self.faces.len() {
59 let face = FaceId::from_index(index);
60 let Some(start) = self.faces[index].boundary else {
61 continue;
62 };
63 // Walk the boundary and confirm it returns to the start.
64 let mut steps = 0usize;
65 let mut current = self.halfedges[start.index()].next;
66 while current != start {
67 steps += 1;
68 if steps > self.halfedges.len() {
69 health.unclosed_faces += 1;
70 break;
71 }
72 current = self.halfedges[current.index()].next;
73 }
74 if face != FaceId::OUTER && self.face_area(face) <= 0.0 {
75 health.inverted_faces += 1;
76 }
77 }
78
79 health
80 }
81}