Skip to main content

projective_grid/square/
validators.rs

1//! Ready-to-use [`NeighborValidator`] implementations for square grids.
2//!
3//! | Validator | PointData | Use case |
4//! |-----------|-----------|----------|
5//! | [`XJunctionValidator`] | `F` (orientation mod π) | ChESS corners, chessboard X-junctions |
6//! | [`SpatialSquareValidator`] | `()` | Unoriented features on a square lattice |
7
8use crate::float_helpers::{lit, rem_euclid};
9use crate::graph::{NeighborCandidate, NeighborValidator};
10use crate::Float;
11use crate::NeighborDirection;
12use nalgebra::Vector2;
13
14// ---------------------------------------------------------------------------
15// Geometry helpers (private to the square validators)
16// ---------------------------------------------------------------------------
17
18/// Absolute angle difference in `[0, π]`.
19fn angle_diff_abs<F: Float>(a: F, b: F) -> F {
20    let two_pi: F = lit::<F>(2.0) * F::pi();
21    let mut diff = rem_euclid(b - a, two_pi);
22    if diff >= F::pi() {
23        diff -= two_pi;
24    }
25    diff.abs()
26}
27
28/// Check whether two undirected axes (angles mod π) are approximately orthogonal.
29fn is_orthogonal<F: Float>(a: F, b: F, tolerance: F) -> bool {
30    let diff = angle_diff_abs(a, b);
31    (F::frac_pi_2() - diff).abs() <= tolerance.abs()
32}
33
34/// Angle between an undirected axis `axis_angle` (mod π) and a directed vector
35/// `vec_angle`. Returns a value in `[0, π/2]`.
36fn axis_vec_diff<F: Float>(axis_angle: F, vec_angle: F) -> F {
37    let two_pi: F = lit::<F>(2.0) * F::pi();
38    let mut diff = rem_euclid(vec_angle - axis_angle, two_pi);
39    if diff >= F::pi() {
40        diff -= two_pi;
41    }
42    let diff_abs = diff.abs();
43    diff_abs.min(F::pi() - diff_abs)
44}
45
46/// Classify an offset vector into one of 4 cardinal directions by quadrant.
47fn direction_quadrant<F: Float>(offset: &Vector2<F>) -> NeighborDirection {
48    if offset.x.abs() > offset.y.abs() {
49        if offset.x >= F::zero() {
50            NeighborDirection::Right
51        } else {
52            NeighborDirection::Left
53        }
54    } else if offset.y >= F::zero() {
55        NeighborDirection::Down
56    } else {
57        NeighborDirection::Up
58    }
59}
60
61// ---------------------------------------------------------------------------
62// XJunctionValidator
63// ---------------------------------------------------------------------------
64
65/// Validator for **square grids** of X-junction corners with known orientation.
66///
67/// Designed for ChESS-like features where each corner has an orientation angle
68/// (mod π) and adjacent corners on a chessboard pattern have orthogonal
69/// orientations. The edge between two neighbors is at ~45° to both orientations.
70///
71/// # PointData
72///
73/// `F` — the corner's orientation angle in radians (mod π, undirected axis).
74///
75/// # Example
76///
77/// ```
78/// use projective_grid::{GridGraph, GridGraphParams, NeighborCandidate};
79/// use projective_grid::square::validators::XJunctionValidator;
80/// use nalgebra::Point2;
81/// use std::f32::consts::FRAC_PI_4;
82///
83/// let positions = vec![
84///     Point2::new(0.0f32, 0.0), Point2::new(10.0, 0.0),
85///     Point2::new(0.0, 10.0),   Point2::new(10.0, 10.0),
86/// ];
87/// // Alternating orientations (π/4 and 3π/4) like a chessboard
88/// let orientations = vec![FRAC_PI_4, 3.0 * FRAC_PI_4, 3.0 * FRAC_PI_4, FRAC_PI_4];
89///
90/// let validator = XJunctionValidator {
91///     min_spacing: 5.0,
92///     max_spacing: 15.0,
93///     tolerance_rad: 15.0f32.to_radians(),
94/// };
95/// let graph = GridGraph::build(
96///     &positions, &orientations, &validator, &GridGraphParams::default(),
97/// );
98/// ```
99pub struct XJunctionValidator<F: Float = f32> {
100    /// Minimum acceptable neighbor distance (pixels).
101    pub min_spacing: F,
102    /// Maximum acceptable neighbor distance (pixels).
103    pub max_spacing: F,
104    /// Angular tolerance (radians) for orthogonality and 45° edge alignment checks.
105    pub tolerance_rad: F,
106}
107
108impl<F: Float> NeighborValidator<F> for XJunctionValidator<F> {
109    type PointData = F;
110
111    fn validate(
112        &self,
113        _source_index: usize,
114        source_data: &F,
115        candidate: &NeighborCandidate<F>,
116        candidate_data: &F,
117    ) -> Option<(NeighborDirection, F)> {
118        // 1. Orientations must be approximately orthogonal.
119        if !is_orthogonal(*source_data, *candidate_data, self.tolerance_rad) {
120            return None;
121        }
122
123        // 2. Distance within range.
124        if candidate.distance < self.min_spacing || candidate.distance > self.max_spacing {
125            return None;
126        }
127
128        // 3. Edge direction should be at ~45° to both corner orientations.
129        let edge_angle = candidate.offset.y.atan2(candidate.offset.x);
130        let expected = F::frac_pi_4();
131
132        let score_src = (axis_vec_diff(*source_data, edge_angle) - expected).abs();
133        let score_cand = (axis_vec_diff(*candidate_data, edge_angle) - expected).abs();
134
135        if score_src > self.tolerance_rad || score_cand > self.tolerance_rad {
136            return None;
137        }
138
139        // 4. Direction by image-space quadrant.
140        let direction = direction_quadrant(&candidate.offset);
141
142        // 5. Score: angular deviations + orientation orthogonality residual.
143        let score_ortho = (F::frac_pi_2() - angle_diff_abs(*source_data, *candidate_data)).abs();
144        let score = score_src + score_cand + score_ortho;
145
146        Some((direction, score))
147    }
148}
149
150// ---------------------------------------------------------------------------
151// SpatialSquareValidator
152// ---------------------------------------------------------------------------
153
154/// Validator for **square grids** with no orientation information.
155///
156/// Classifies neighbors by image-space quadrant and filters by distance.
157/// Suitable for any approximately regular square lattice of detected features.
158///
159/// # PointData
160///
161/// `()` — no per-point data needed.
162pub struct SpatialSquareValidator<F: Float = f32> {
163    /// Minimum acceptable neighbor distance (pixels).
164    pub min_spacing: F,
165    /// Maximum acceptable neighbor distance (pixels).
166    pub max_spacing: F,
167}
168
169impl<F: Float> NeighborValidator<F> for SpatialSquareValidator<F> {
170    type PointData = ();
171
172    fn validate(
173        &self,
174        _source_index: usize,
175        _source_data: &(),
176        candidate: &NeighborCandidate<F>,
177        _candidate_data: &(),
178    ) -> Option<(NeighborDirection, F)> {
179        if candidate.distance < self.min_spacing || candidate.distance > self.max_spacing {
180            return None;
181        }
182
183        let direction = direction_quadrant(&candidate.offset);
184        Some((direction, candidate.distance))
185    }
186}
187
188// ===========================================================================
189// Tests
190// ===========================================================================
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::graph::GridGraphParams;
196    use crate::traverse::{assign_grid_coordinates, connected_components};
197    use crate::{GridGraph, NodeNeighbor};
198    use nalgebra::Point2;
199    use std::collections::HashMap;
200    use std::f32::consts::FRAC_PI_4;
201
202    fn neighbor_map(neighbors: &[NodeNeighbor]) -> HashMap<NeighborDirection, &NodeNeighbor> {
203        neighbors.iter().map(|n| (n.direction, n)).collect()
204    }
205
206    // -----------------------------------------------------------------------
207    // XJunctionValidator tests
208    // -----------------------------------------------------------------------
209
210    fn make_chess_grid(rows: usize, cols: usize, spacing: f32) -> (Vec<Point2<f32>>, Vec<f32>) {
211        let mut positions = Vec::new();
212        let mut orientations = Vec::new();
213        for j in 0..rows {
214            for i in 0..cols {
215                positions.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
216                orientations.push(if (i + j) % 2 == 0 {
217                    FRAC_PI_4
218                } else {
219                    3.0 * FRAC_PI_4
220                });
221            }
222        }
223        (positions, orientations)
224    }
225
226    #[test]
227    fn xjunction_regular_grid_center_has_four_neighbors() {
228        let spacing = 10.0;
229        let (positions, orientations) = make_chess_grid(3, 3, spacing);
230
231        let validator = XJunctionValidator {
232            min_spacing: 5.0,
233            max_spacing: 15.0,
234            tolerance_rad: 15.0f32.to_radians(),
235        };
236        let graph = GridGraph::build(
237            &positions,
238            &orientations,
239            &validator,
240            &GridGraphParams::default(),
241        );
242
243        let idx = |i: usize, j: usize| j * 3 + i;
244        let center = neighbor_map(&graph.neighbors[idx(1, 1)]);
245        assert_eq!(4, center.len());
246        assert_eq!(idx(0, 1), center[&NeighborDirection::Left].index);
247        assert_eq!(idx(2, 1), center[&NeighborDirection::Right].index);
248        assert_eq!(idx(1, 0), center[&NeighborDirection::Up].index);
249        assert_eq!(idx(1, 2), center[&NeighborDirection::Down].index);
250    }
251
252    #[test]
253    fn xjunction_rejects_parallel_orientations() {
254        let spacing = 10.0;
255        let positions = vec![Point2::new(0.0, 0.0), Point2::new(spacing, 0.0)];
256        // Same orientation — should be rejected
257        let orientations = vec![FRAC_PI_4, FRAC_PI_4];
258
259        let validator = XJunctionValidator {
260            min_spacing: 5.0,
261            max_spacing: 15.0,
262            tolerance_rad: 15.0f32.to_radians(),
263        };
264        let graph = GridGraph::build(
265            &positions,
266            &orientations,
267            &validator,
268            &GridGraphParams {
269                k_neighbors: 2,
270                ..Default::default()
271            },
272        );
273
274        assert!(graph.neighbors[0].is_empty());
275        assert!(graph.neighbors[1].is_empty());
276    }
277
278    #[test]
279    fn xjunction_rejects_out_of_range_distance() {
280        let spacing = 30.0;
281        let positions = vec![Point2::new(0.0, 0.0), Point2::new(spacing, 0.0)];
282        let orientations = vec![FRAC_PI_4, 3.0 * FRAC_PI_4];
283
284        let validator = XJunctionValidator {
285            min_spacing: 5.0,
286            max_spacing: 15.0, // 30 > 15, rejected
287            tolerance_rad: 15.0f32.to_radians(),
288        };
289        let graph = GridGraph::build(
290            &positions,
291            &orientations,
292            &validator,
293            &GridGraphParams::default(),
294        );
295
296        assert!(graph.neighbors[0].is_empty());
297        assert!(graph.neighbors[1].is_empty());
298    }
299
300    #[test]
301    fn xjunction_rotated_grid_forms_single_component() {
302        let spacing = 20.0;
303        let angle = 40.0f32.to_radians();
304        let ax = Vector2::new(angle.cos(), angle.sin());
305        let ay = Vector2::new(-angle.sin(), angle.cos());
306        let cols = 4usize;
307        let rows = 4usize;
308
309        let diag0 = angle + FRAC_PI_4;
310        let diag1 = angle + 3.0 * FRAC_PI_4;
311
312        let mut positions = Vec::new();
313        let mut orientations = Vec::new();
314        for j in 0..rows {
315            for i in 0..cols {
316                let pos = ax * (i as f32 * spacing) + ay * (j as f32 * spacing);
317                positions.push(Point2::new(pos.x + 100.0, pos.y + 100.0));
318                orientations.push(if (i + j) % 2 == 0 { diag0 } else { diag1 });
319            }
320        }
321
322        let validator = XJunctionValidator {
323            min_spacing: spacing * 0.5,
324            max_spacing: spacing * 1.5,
325            tolerance_rad: 20.0f32.to_radians(),
326        };
327        let graph = GridGraph::build(
328            &positions,
329            &orientations,
330            &validator,
331            &GridGraphParams {
332                k_neighbors: 8,
333                ..Default::default()
334            },
335        );
336
337        let components = connected_components(&graph);
338        assert_eq!(1, components.len());
339        assert_eq!(cols * rows, components[0].len());
340
341        let coords = assign_grid_coordinates(&graph, &components[0]);
342        let coord_set: std::collections::HashSet<(i32, i32)> =
343            coords.iter().map(|&(_, g)| (g.i, g.j)).collect();
344        assert_eq!(cols * rows, coord_set.len());
345    }
346
347    #[test]
348    fn xjunction_direction_symmetry() {
349        let spacing = 20.0;
350        let (positions, orientations) = make_chess_grid(3, 3, spacing);
351
352        let validator = XJunctionValidator {
353            min_spacing: spacing * 0.5,
354            max_spacing: spacing * 1.5,
355            tolerance_rad: 15.0f32.to_radians(),
356        };
357        let graph = GridGraph::build(
358            &positions,
359            &orientations,
360            &validator,
361            &GridGraphParams::default(),
362        );
363
364        for (a, neighbors) in graph.neighbors.iter().enumerate() {
365            for n in neighbors {
366                let b = n.index;
367                let back = graph.neighbors[b].iter().find(|nn| nn.index == a);
368                assert!(
369                    back.is_some(),
370                    "Edge {a}->{b} exists but reverse {b}->{a} does not"
371                );
372                assert_eq!(n.direction.opposite(), back.unwrap().direction,);
373            }
374        }
375    }
376
377    // -----------------------------------------------------------------------
378    // SpatialSquareValidator tests
379    // -----------------------------------------------------------------------
380
381    #[test]
382    fn spatial_square_regular_grid_center_has_four() {
383        let spacing = 10.0;
384        let mut positions = Vec::new();
385        for j in 0..3 {
386            for i in 0..3 {
387                positions.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
388            }
389        }
390        let data = vec![(); positions.len()];
391
392        let validator = SpatialSquareValidator {
393            min_spacing: 5.0,
394            max_spacing: 15.0,
395        };
396        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
397
398        let idx = |i: usize, j: usize| j * 3 + i;
399        let center = neighbor_map(&graph.neighbors[idx(1, 1)]);
400        assert_eq!(4, center.len());
401        assert_eq!(idx(0, 1), center[&NeighborDirection::Left].index);
402        assert_eq!(idx(2, 1), center[&NeighborDirection::Right].index);
403        assert_eq!(idx(1, 0), center[&NeighborDirection::Up].index);
404        assert_eq!(idx(1, 2), center[&NeighborDirection::Down].index);
405    }
406
407    #[test]
408    fn spatial_square_rejects_out_of_range() {
409        let positions = vec![
410            Point2::new(0.0f32, 0.0),
411            Point2::new(3.0, 0.0),  // too close
412            Point2::new(50.0, 0.0), // too far
413        ];
414        let data = vec![(); 3];
415
416        let validator = SpatialSquareValidator {
417            min_spacing: 5.0,
418            max_spacing: 15.0,
419        };
420        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
421
422        assert!(graph.neighbors[0].is_empty());
423    }
424
425    #[test]
426    fn spatial_square_score_prefers_closest() {
427        let positions = vec![
428            Point2::new(0.0f32, 0.0),
429            Point2::new(8.0, 0.0),  // closer
430            Point2::new(12.0, 0.0), // farther, same quadrant
431        ];
432        let data = vec![(); 3];
433
434        let validator = SpatialSquareValidator {
435            min_spacing: 5.0,
436            max_spacing: 15.0,
437        };
438        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
439
440        let right = graph.neighbors[0]
441            .iter()
442            .find(|n| n.direction == NeighborDirection::Right)
443            .unwrap();
444        assert_eq!(1, right.index); // closer one wins
445    }
446
447    #[test]
448    fn spatial_square_diagonal_grid_works() {
449        // Grid rotated 45° — quadrant classification still assigns consistent directions
450        let spacing = 10.0;
451        let angle = 45.0f32.to_radians();
452        let ax = Vector2::new(angle.cos(), angle.sin());
453        let ay = Vector2::new(-angle.sin(), angle.cos());
454
455        let mut positions = Vec::new();
456        for j in 0..3 {
457            for i in 0..3 {
458                let pos = ax * (i as f32 * spacing) + ay * (j as f32 * spacing);
459                positions.push(Point2::new(pos.x + 50.0, pos.y + 50.0));
460            }
461        }
462        let data = vec![(); positions.len()];
463
464        let validator = SpatialSquareValidator {
465            min_spacing: spacing * 0.5,
466            max_spacing: spacing * 1.5,
467        };
468        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
469
470        let components = connected_components(&graph);
471        assert_eq!(1, components.len());
472        assert_eq!(9, components[0].len());
473    }
474}