Skip to main content

projective_grid/topological/
classify.rs

1//! Axis-driven edge classification (replaces the paper's color test).
2//!
3//! For a Delaunay half-edge from corner `a` to corner `b`, the edge angle
4//! `θ = atan2(b - a)` is compared to each corner's two axes (modulo π,
5//! since axes are undirected). The minimum angular distance to either
6//! axis at each endpoint determines the edge's classification at that
7//! endpoint:
8//!
9//! - within `axis_align_tol_rad` of an axis → **Grid** (the edge runs
10//!   along a chessboard cell side at this corner);
11//! - within `diagonal_angle_tol_rad` of `axis ± π/4` → **Diagonal** (the
12//!   edge crosses a chessboard cell at this corner);
13//! - otherwise → **Spurious** (background or unaligned noise).
14//!
15//! The whole-edge classification is the conjunction of the per-endpoint
16//! classifications: an edge is `Grid` iff both endpoints see it as
17//! `Grid`, `Diagonal` iff both see it as `Diagonal`, otherwise
18//! `Spurious`. This is the axis-only analogue of the paper's "shared
19//! edge of a same-color triangle pair is the diagonal of a cell".
20
21use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
22
23use nalgebra::Point2;
24
25use super::delaunay::Triangulation;
26use super::{AxisHint, TopologicalParams};
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum EdgeKind {
31    /// Edge runs along a grid line (cell edge in the chessboard pattern).
32    Grid,
33    /// Edge crosses a cell as its diagonal.
34    Diagonal,
35    /// Edge is unaligned with any grid direction (background, noise,
36    /// occlusion).
37    Spurious,
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41enum EdgeAt {
42    Grid,
43    Diagonal,
44    Spurious,
45}
46
47/// Smallest unsigned angle between two undirected directions, in `[0, π/2]`.
48///
49/// Both `theta` and `alpha` are interpreted modulo π (axes are
50/// undirected). The result is the geodesic distance on the half-circle.
51#[inline]
52fn axis_diff(theta: f32, alpha: f32) -> f32 {
53    let mut d = (theta - alpha).rem_euclid(PI);
54    if d > FRAC_PI_2 {
55        d = PI - d;
56    }
57    d
58}
59
60fn classify_at_corner(theta: f32, axes: &[AxisHint; 2], params: &TopologicalParams) -> EdgeAt {
61    // Pick the smaller axis-distance over the two axes; this is well-defined
62    // even when one axis has sigma = π, because we only use angles, and the
63    // pre-filter already excludes corners where both axes are unusable.
64    let mut min_d = f32::INFINITY;
65    for a in axes.iter() {
66        if a.sigma >= params.max_axis_sigma_rad {
67            continue;
68        }
69        let d = axis_diff(theta, a.angle);
70        if d < min_d {
71            min_d = d;
72        }
73    }
74    if !min_d.is_finite() {
75        return EdgeAt::Spurious;
76    }
77    if min_d < params.axis_align_tol_rad {
78        return EdgeAt::Grid;
79    }
80    let dia = (min_d - FRAC_PI_4).abs();
81    if dia < params.diagonal_angle_tol_rad {
82        return EdgeAt::Diagonal;
83    }
84    EdgeAt::Spurious
85}
86
87/// Classify every directed half-edge in the triangulation.
88///
89/// Length matches `triangulation.triangles.len()`.
90#[cfg_attr(
91    feature = "tracing",
92    tracing::instrument(
93        level = "debug",
94        skip_all,
95        fields(num_edges = triangulation.triangles.len()),
96    )
97)]
98pub(crate) fn classify_all_edges(
99    positions: &[Point2<f32>],
100    axes: &[[AxisHint; 2]],
101    usable: &[bool],
102    triangulation: &Triangulation,
103    params: &TopologicalParams,
104) -> Vec<EdgeKind> {
105    let n = triangulation.triangles.len();
106    let mut kinds = vec![EdgeKind::Spurious; n];
107    for (e, kind) in kinds.iter_mut().enumerate().take(n) {
108        let a = triangulation.triangles[e];
109        let b = triangulation.triangles[Triangulation::next_edge(e)];
110        if !usable[a] || !usable[b] {
111            // Corner without axis info — drop the edge.
112            continue;
113        }
114        let pa = positions[a];
115        let pb = positions[b];
116        let theta = (pb.y - pa.y).atan2(pb.x - pa.x);
117        let at_a = classify_at_corner(theta, &axes[a], params);
118        let at_b = classify_at_corner(theta, &axes[b], params);
119        *kind = match (at_a, at_b) {
120            (EdgeAt::Grid, EdgeAt::Grid) => EdgeKind::Grid,
121            (EdgeAt::Diagonal, EdgeAt::Diagonal) => EdgeKind::Diagonal,
122            _ => EdgeKind::Spurious,
123        };
124    }
125    kinds
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn axes(angle0: f32, angle1: f32) -> [AxisHint; 2] {
133        [
134            AxisHint {
135                angle: angle0,
136                sigma: 0.05,
137            },
138            AxisHint {
139                angle: angle1,
140                sigma: 0.05,
141            },
142        ]
143    }
144
145    #[test]
146    fn axis_diff_is_symmetric_modulo_pi() {
147        assert!((axis_diff(0.0, PI) - 0.0).abs() < 1e-6);
148        assert!((axis_diff(0.1, 0.0) - 0.1).abs() < 1e-6);
149        assert!((axis_diff(PI - 0.1, 0.0) - 0.1).abs() < 1e-6);
150        assert!((axis_diff(FRAC_PI_4, 0.0) - FRAC_PI_4).abs() < 1e-6);
151    }
152
153    #[test]
154    fn axis_aligned_edge_is_grid() {
155        let p = TopologicalParams::default();
156        let a = axes(0.0, FRAC_PI_2);
157        // Edge angle = 0 → aligned with first axis at (almost) zero distance.
158        assert_eq!(classify_at_corner(0.0, &a, &p), EdgeAt::Grid);
159        // Edge angle = π/2 → aligned with second axis.
160        assert_eq!(classify_at_corner(FRAC_PI_2, &a, &p), EdgeAt::Grid);
161    }
162
163    #[test]
164    fn diagonal_edge_is_diagonal() {
165        let p = TopologicalParams::default();
166        let a = axes(0.0, FRAC_PI_2);
167        assert_eq!(classify_at_corner(FRAC_PI_4, &a, &p), EdgeAt::Diagonal);
168        assert_eq!(classify_at_corner(-FRAC_PI_4, &a, &p), EdgeAt::Diagonal);
169    }
170
171    #[test]
172    fn unaligned_edge_is_spurious() {
173        let p = TopologicalParams::default();
174        let a = axes(0.0, FRAC_PI_2);
175        // 22° from horizontal axis: outside the 15° grid tolerance and
176        // far from the 45° diagonal (|22-45| = 23° > 15° tol).
177        assert_eq!(
178            classify_at_corner(22.0_f32.to_radians(), &a, &p),
179            EdgeAt::Spurious
180        );
181    }
182}