Skip to main content

projective_grid/topological/
classify.rs

1//! Axis-driven grid-edge classification plus local triangle diagonal
2//! inference (replaces SBF09's image-color sampling test).
3//!
4//! For a Delaunay half-edge from corner `a` to corner `b`, the edge angle
5//! `θ = atan2(b - a)` is compared to each corner's two axes (modulo π,
6//! since axes are undirected). If both endpoints see the edge within
7//! `axis_align_tol_rad` of one informative axis, the edge is a **Grid**
8//! edge.
9//!
10//! Diagonals are not classified by a fixed `axis ± π/4` angle. Under a
11//! projective warp, a projected cell diagonal is induced by the local
12//! projected grid-step vectors, not by the angle bisector in image space.
13//! After the Grid/Spurious pass, each Delaunay triangle is inspected: if
14//! exactly two of its edges are Grid edges and those two edges meet at a
15//! vertex using different local axis slots, the remaining edge is
16//! promoted to **Diagonal** for that triangle.
17//!
18//! The pre-filter in [`super::build_usable_mask`] guarantees both endpoints
19//! of every classified edge have at least one informative axis; `Spurious`
20//! here only flags genuine geometric misalignment, not uncertainty
21//! rejection.
22
23use nalgebra::Point2;
24
25use super::axis::AxisCache;
26use super::delaunay::Triangulation;
27
28/// Per-edge classification result.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub(super) enum EdgeClass {
31    /// Both endpoints see the edge as aligned with one of their axes.
32    Grid,
33    /// The edge crosses a lattice cell from one corner to the
34    /// opposite corner — promoted by the per-triangle inference pass.
35    Diagonal,
36    /// Neither endpoint accepts the edge as a grid-axis match.
37    Spurious,
38}
39
40/// Per-corner result of matching the half-edge against the two axes.
41#[derive(Clone, Copy, Debug)]
42struct GridAxisMatch {
43    slot: usize,
44    distance_rad: f32,
45}
46
47/// Joint per-half-edge result of matching against both endpoints' axes.
48#[derive(Clone, Copy, Debug)]
49struct GridEdgeMatch {
50    start_slot: usize,
51    end_slot: usize,
52}
53
54/// Smallest unsigned angle between two undirected directions, in `[0, π/2]`.
55///
56/// Both `theta` and `alpha` are interpreted modulo π (axes are
57/// undirected). The result is the geodesic distance on the half-circle.
58#[inline]
59fn axis_diff(theta: f32, alpha: f32) -> f32 {
60    let pi = std::f32::consts::PI;
61    let half_pi = pi / 2.0;
62    let mut d = (theta - alpha) % pi;
63    if d < 0.0 {
64        d += pi;
65    }
66    if d > half_pi {
67        d = pi - d;
68    }
69    d
70}
71
72/// Nearest informative grid axis to `theta` at this corner.
73fn nearest_axis_at_corner(theta: f32, cache: &AxisCache) -> Option<GridAxisMatch> {
74    let mut best: Option<GridAxisMatch> = None;
75    for slot in 0..2 {
76        if !cache.informative[slot] {
77            continue;
78        }
79        let angle = cache.angle_rad[slot];
80        let d = axis_diff(theta, angle);
81        if !d.is_finite() {
82            continue;
83        }
84        match best {
85            None => {
86                best = Some(GridAxisMatch {
87                    slot,
88                    distance_rad: d,
89                });
90            }
91            Some(m) if d < m.distance_rad => {
92                best = Some(GridAxisMatch {
93                    slot,
94                    distance_rad: d,
95                });
96            }
97            _ => {}
98        }
99    }
100    best
101}
102
103fn grid_match_at_corner(
104    theta: f32,
105    cache: &AxisCache,
106    align_tol_rad: f32,
107) -> Option<GridAxisMatch> {
108    let best = nearest_axis_at_corner(theta, cache)?;
109    (best.distance_rad < align_tol_rad).then_some(best)
110}
111
112fn edge_vertices(triangulation: &Triangulation, edge: usize) -> (usize, usize) {
113    (
114        triangulation.triangles[edge],
115        triangulation.triangles[Triangulation::next_edge(edge)],
116    )
117}
118
119fn grid_axis_slot_at_vertex(
120    triangulation: &Triangulation,
121    grid_matches: &[Option<GridEdgeMatch>],
122    edge: usize,
123    vertex: usize,
124) -> Option<usize> {
125    let grid = grid_matches[edge]?;
126    let (start, end) = edge_vertices(triangulation, edge);
127    if vertex == start {
128        Some(grid.start_slot)
129    } else if vertex == end {
130        Some(grid.end_slot)
131    } else {
132        None
133    }
134}
135
136fn shared_vertex_of_edges(
137    triangulation: &Triangulation,
138    edge_a: usize,
139    edge_b: usize,
140) -> Option<usize> {
141    let (a0, a1) = edge_vertices(triangulation, edge_a);
142    let (b0, b1) = edge_vertices(triangulation, edge_b);
143    if a0 == b0 || a0 == b1 {
144        Some(a0)
145    } else if a1 == b0 || a1 == b1 {
146        Some(a1)
147    } else {
148        None
149    }
150}
151
152fn infer_triangle_diagonal(
153    triangulation: &Triangulation,
154    grid_matches: &[Option<GridEdgeMatch>],
155    kinds: &[EdgeClass],
156    triangle: usize,
157) -> Option<usize> {
158    let base = 3 * triangle;
159    let mut grid_edges = [usize::MAX; 2];
160    let mut grid_count = 0usize;
161    let mut non_grid_edge: Option<usize> = None;
162
163    for k in 0..3 {
164        let edge = base + k;
165        match kinds[edge] {
166            EdgeClass::Grid => {
167                if grid_count >= grid_edges.len() {
168                    return None;
169                }
170                grid_edges[grid_count] = edge;
171                grid_count += 1;
172            }
173            EdgeClass::Spurious => {
174                if non_grid_edge.is_some() {
175                    return None;
176                }
177                non_grid_edge = Some(k);
178            }
179            EdgeClass::Diagonal => return None,
180        }
181    }
182    if grid_count != 2 {
183        return None;
184    }
185
186    let shared = shared_vertex_of_edges(triangulation, grid_edges[0], grid_edges[1])?;
187    let slot0 = grid_axis_slot_at_vertex(triangulation, grid_matches, grid_edges[0], shared)?;
188    let slot1 = grid_axis_slot_at_vertex(triangulation, grid_matches, grid_edges[1], shared)?;
189    (slot0 != slot1).then_some(non_grid_edge?)
190}
191
192fn promote_triangle_diagonals_from_grid_edges(
193    triangulation: &Triangulation,
194    grid_matches: &[Option<GridEdgeMatch>],
195    kinds: &mut [EdgeClass],
196) {
197    for triangle in 0..triangulation.num_tri() {
198        if let Some(k) = infer_triangle_diagonal(triangulation, grid_matches, kinds, triangle) {
199            kinds[3 * triangle + k] = EdgeClass::Diagonal;
200        }
201    }
202}
203
204/// Classify every directed half-edge in the triangulation.
205///
206/// `axes_cache[global_idx]` carries the precomputed per-axis informative
207/// flag for the feature. The pre-filter in [`super::build_usable_mask`]
208/// guarantees at least one informative axis at every endpoint of every
209/// triangulated edge.
210#[cfg_attr(
211    feature = "tracing",
212    tracing::instrument(
213        level = "debug",
214        skip_all,
215        fields(num_edges = triangulation.triangles.len()),
216    )
217)]
218pub(super) fn classify_all_edges(
219    positions: &[Point2<f32>],
220    axes_cache: &[AxisCache],
221    triangulation: &Triangulation,
222    align_tol_rad: f32,
223) -> Vec<EdgeClass> {
224    let n = triangulation.triangles.len();
225    let mut kinds = vec![EdgeClass::Spurious; n];
226    let mut grid_matches = vec![None; n];
227    for (e, kind) in kinds.iter_mut().enumerate().take(n) {
228        let a = triangulation.triangles[e];
229        let b = triangulation.triangles[Triangulation::next_edge(e)];
230        let pa = positions[a];
231        let pb = positions[b];
232        let theta = (pb.y - pa.y).atan2(pb.x - pa.x);
233        let at_a = grid_match_at_corner(theta, &axes_cache[a], align_tol_rad);
234        let at_b = grid_match_at_corner(theta, &axes_cache[b], align_tol_rad);
235        if let (Some(a_match), Some(b_match)) = (at_a, at_b) {
236            grid_matches[e] = Some(GridEdgeMatch {
237                start_slot: a_match.slot,
238                end_slot: b_match.slot,
239            });
240            *kind = EdgeClass::Grid;
241        }
242    }
243    promote_triangle_diagonals_from_grid_edges(triangulation, &grid_matches, &mut kinds);
244    kinds
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn cache(angle0: f32, angle1: f32) -> AxisCache {
252        AxisCache {
253            angle_rad: [angle0, angle1],
254            informative: [true, true],
255        }
256    }
257
258    #[test]
259    fn axis_diff_is_symmetric_modulo_pi() {
260        let pi = std::f32::consts::PI;
261        let frac_pi_4 = pi / 4.0;
262        let eps = 1e-5_f32;
263        assert!(axis_diff(0.0, pi).abs() < eps);
264        let one_tenth = 0.1_f32;
265        assert!((axis_diff(one_tenth, 0.0) - one_tenth).abs() < eps);
266        assert!((axis_diff(pi - one_tenth, 0.0) - one_tenth).abs() < eps);
267        assert!((axis_diff(frac_pi_4, 0.0) - frac_pi_4).abs() < eps);
268    }
269
270    #[test]
271    fn axis_aligned_edge_is_grid() {
272        let frac_pi_2 = std::f32::consts::FRAC_PI_2;
273        let tol = 15.0_f32.to_radians();
274        let cache = cache(0.0, frac_pi_2);
275        let horizontal = grid_match_at_corner(0.0, &cache, tol).unwrap();
276        assert_eq!(horizontal.slot, 0);
277        assert!(horizontal.distance_rad.abs() < 1e-5);
278        let vertical = grid_match_at_corner(frac_pi_2, &cache, tol).unwrap();
279        assert_eq!(vertical.slot, 1);
280        assert!(vertical.distance_rad.abs() < 1e-5);
281    }
282
283    #[test]
284    fn diagonal_angle_is_not_grid() {
285        let frac_pi_2 = std::f32::consts::FRAC_PI_2;
286        let frac_pi_4 = std::f32::consts::FRAC_PI_4;
287        let tol = 15.0_f32.to_radians();
288        let cache = cache(0.0, frac_pi_2);
289        assert!(grid_match_at_corner(frac_pi_4, &cache, tol).is_none());
290    }
291
292    #[test]
293    fn unaligned_edge_is_spurious() {
294        let frac_pi_2 = std::f32::consts::FRAC_PI_2;
295        let tol = 15.0_f32.to_radians();
296        let cache = cache(0.0, frac_pi_2);
297        let twenty_two = 22.0_f32.to_radians();
298        assert!(grid_match_at_corner(twenty_two, &cache, tol).is_none());
299    }
300}