Skip to main content

projective_grid/
validators.rs

1//! Ready-to-use [`NeighborValidator`] and [`HexNeighborValidator`] implementations
2//! for common grid detection scenarios.
3//!
4//! | Validator | Grid | PointData | Use case |
5//! |-----------|------|-----------|----------|
6//! | [`XJunctionValidator`] | Square | `F` (orientation mod π) | ChESS corners, chessboard X-junctions |
7//! | [`SpatialSquareValidator`] | Square | `()` | Unoriented features on a square lattice |
8//! | [`SpatialHexValidator`] | Hex | `()` | Unoriented features on a hex lattice (ringgrid, etc.) |
9
10use crate::direction::NeighborDirection;
11use crate::float_helpers::{lit, rem_euclid, to_degrees};
12use crate::graph::{NeighborCandidate, NeighborValidator};
13use crate::hex::direction::HexDirection;
14use crate::hex::graph::HexNeighborValidator;
15use crate::Float;
16use nalgebra::Vector2;
17
18// ---------------------------------------------------------------------------
19// Geometry helpers
20// ---------------------------------------------------------------------------
21
22/// Absolute angle difference in `[0, π]`.
23fn angle_diff_abs<F: Float>(a: F, b: F) -> F {
24    let two_pi: F = lit::<F>(2.0) * F::pi();
25    let mut diff = rem_euclid(b - a, two_pi);
26    if diff >= F::pi() {
27        diff -= two_pi;
28    }
29    diff.abs()
30}
31
32/// Check whether two undirected axes (angles mod π) are approximately orthogonal.
33fn is_orthogonal<F: Float>(a: F, b: F, tolerance: F) -> bool {
34    let diff = angle_diff_abs(a, b);
35    (F::frac_pi_2() - diff).abs() <= tolerance.abs()
36}
37
38/// Angle between an undirected axis `axis_angle` (mod π) and a directed vector
39/// `vec_angle`. Returns a value in `[0, π/2]`.
40fn axis_vec_diff<F: Float>(axis_angle: F, vec_angle: F) -> F {
41    let two_pi: F = lit::<F>(2.0) * F::pi();
42    let mut diff = rem_euclid(vec_angle - axis_angle, two_pi);
43    if diff >= F::pi() {
44        diff -= two_pi;
45    }
46    let diff_abs = diff.abs();
47    diff_abs.min(F::pi() - diff_abs)
48}
49
50/// Classify an offset vector into one of 4 cardinal directions by quadrant.
51fn direction_quadrant<F: Float>(offset: &Vector2<F>) -> NeighborDirection {
52    if offset.x.abs() > offset.y.abs() {
53        if offset.x >= F::zero() {
54            NeighborDirection::Right
55        } else {
56            NeighborDirection::Left
57        }
58    } else if offset.y >= F::zero() {
59        NeighborDirection::Down
60    } else {
61        NeighborDirection::Up
62    }
63}
64
65/// Classify an offset vector into one of 6 hex directions by sextant (60° sectors).
66fn direction_sextant<F: Float>(offset: &Vector2<F>) -> HexDirection {
67    let deg = to_degrees(offset.y.atan2(offset.x));
68    if deg >= lit(-30.0) && deg < lit(30.0) {
69        HexDirection::East
70    } else if deg >= lit(30.0) && deg < lit(90.0) {
71        HexDirection::SouthEast
72    } else if deg >= lit(90.0) && deg < lit(150.0) {
73        HexDirection::SouthWest
74    } else if deg < lit(-150.0) || deg >= lit(150.0) {
75        HexDirection::West
76    } else if deg >= lit(-150.0) && deg < lit(-90.0) {
77        HexDirection::NorthWest
78    } else {
79        HexDirection::NorthEast
80    }
81}
82
83// ---------------------------------------------------------------------------
84// XJunctionValidator
85// ---------------------------------------------------------------------------
86
87/// Validator for **square grids** of X-junction corners with known orientation.
88///
89/// Designed for ChESS-like features where each corner has an orientation angle
90/// (mod π) and adjacent corners on a chessboard pattern have orthogonal
91/// orientations. The edge between two neighbors is at ~45° to both orientations.
92///
93/// # PointData
94///
95/// `F` — the corner's orientation angle in radians (mod π, undirected axis).
96///
97/// # Example
98///
99/// ```
100/// use projective_grid::{GridGraph, GridGraphParams, NeighborCandidate};
101/// use projective_grid::validators::XJunctionValidator;
102/// use nalgebra::Point2;
103/// use std::f32::consts::FRAC_PI_4;
104///
105/// let positions = vec![
106///     Point2::new(0.0f32, 0.0), Point2::new(10.0, 0.0),
107///     Point2::new(0.0, 10.0),   Point2::new(10.0, 10.0),
108/// ];
109/// // Alternating orientations (π/4 and 3π/4) like a chessboard
110/// let orientations = vec![FRAC_PI_4, 3.0 * FRAC_PI_4, 3.0 * FRAC_PI_4, FRAC_PI_4];
111///
112/// let validator = XJunctionValidator {
113///     min_spacing: 5.0,
114///     max_spacing: 15.0,
115///     tolerance_rad: 15.0f32.to_radians(),
116/// };
117/// let graph = GridGraph::build(
118///     &positions, &orientations, &validator, &GridGraphParams::default(),
119/// );
120/// ```
121pub struct XJunctionValidator<F: Float = f32> {
122    /// Minimum acceptable neighbor distance (pixels).
123    pub min_spacing: F,
124    /// Maximum acceptable neighbor distance (pixels).
125    pub max_spacing: F,
126    /// Angular tolerance (radians) for orthogonality and 45° edge alignment checks.
127    pub tolerance_rad: F,
128}
129
130impl<F: Float> NeighborValidator<F> for XJunctionValidator<F> {
131    type PointData = F;
132
133    fn validate(
134        &self,
135        _source_index: usize,
136        source_data: &F,
137        candidate: &NeighborCandidate<F>,
138        candidate_data: &F,
139    ) -> Option<(NeighborDirection, F)> {
140        // 1. Orientations must be approximately orthogonal.
141        if !is_orthogonal(*source_data, *candidate_data, self.tolerance_rad) {
142            return None;
143        }
144
145        // 2. Distance within range.
146        if candidate.distance < self.min_spacing || candidate.distance > self.max_spacing {
147            return None;
148        }
149
150        // 3. Edge direction should be at ~45° to both corner orientations.
151        let edge_angle = candidate.offset.y.atan2(candidate.offset.x);
152        let expected = F::frac_pi_4();
153
154        let score_src = (axis_vec_diff(*source_data, edge_angle) - expected).abs();
155        let score_cand = (axis_vec_diff(*candidate_data, edge_angle) - expected).abs();
156
157        if score_src > self.tolerance_rad || score_cand > self.tolerance_rad {
158            return None;
159        }
160
161        // 4. Direction by image-space quadrant.
162        let direction = direction_quadrant(&candidate.offset);
163
164        // 5. Score: angular deviations + orientation orthogonality residual.
165        let score_ortho = (F::frac_pi_2() - angle_diff_abs(*source_data, *candidate_data)).abs();
166        let score = score_src + score_cand + score_ortho;
167
168        Some((direction, score))
169    }
170}
171
172// ---------------------------------------------------------------------------
173// SpatialSquareValidator
174// ---------------------------------------------------------------------------
175
176/// Validator for **square grids** with no orientation information.
177///
178/// Classifies neighbors by image-space quadrant and filters by distance.
179/// Suitable for any approximately regular square lattice of detected features.
180///
181/// # PointData
182///
183/// `()` — no per-point data needed.
184pub struct SpatialSquareValidator<F: Float = f32> {
185    /// Minimum acceptable neighbor distance (pixels).
186    pub min_spacing: F,
187    /// Maximum acceptable neighbor distance (pixels).
188    pub max_spacing: F,
189}
190
191impl<F: Float> NeighborValidator<F> for SpatialSquareValidator<F> {
192    type PointData = ();
193
194    fn validate(
195        &self,
196        _source_index: usize,
197        _source_data: &(),
198        candidate: &NeighborCandidate<F>,
199        _candidate_data: &(),
200    ) -> Option<(NeighborDirection, F)> {
201        if candidate.distance < self.min_spacing || candidate.distance > self.max_spacing {
202            return None;
203        }
204
205        let direction = direction_quadrant(&candidate.offset);
206        Some((direction, candidate.distance))
207    }
208}
209
210// ---------------------------------------------------------------------------
211// SpatialHexValidator
212// ---------------------------------------------------------------------------
213
214/// Validator for **hex grids** with no orientation information.
215///
216/// Classifies neighbors into 6 sextant directions and filters by distance.
217/// Suitable for hex lattices of circular markers (ringgrid), blob detections,
218/// or any approximately regular hex arrangement.
219///
220/// # PointData
221///
222/// `()` — no per-point data needed.
223pub struct SpatialHexValidator<F: Float = f32> {
224    /// Minimum acceptable neighbor distance (pixels).
225    pub min_spacing: F,
226    /// Maximum acceptable neighbor distance (pixels).
227    pub max_spacing: F,
228}
229
230impl<F: Float> HexNeighborValidator<F> for SpatialHexValidator<F> {
231    type PointData = ();
232
233    fn validate(
234        &self,
235        _source_index: usize,
236        _source_data: &(),
237        candidate: &NeighborCandidate<F>,
238        _candidate_data: &(),
239    ) -> Option<(HexDirection, F)> {
240        if candidate.distance < self.min_spacing || candidate.distance > self.max_spacing {
241            return None;
242        }
243
244        let direction = direction_sextant(&candidate.offset);
245        Some((direction, candidate.distance))
246    }
247}
248
249// ===========================================================================
250// Tests
251// ===========================================================================
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::graph::GridGraphParams;
257    use crate::hex::graph::HexGridGraph;
258    use crate::hex::traverse::{hex_assign_grid_coordinates, hex_connected_components};
259    use crate::traverse::{assign_grid_coordinates, connected_components};
260    use crate::{GridGraph, NodeNeighbor};
261    use nalgebra::Point2;
262    use std::collections::HashMap;
263    use std::f32::consts::FRAC_PI_4;
264
265    fn neighbor_map(neighbors: &[NodeNeighbor]) -> HashMap<NeighborDirection, &NodeNeighbor> {
266        neighbors.iter().map(|n| (n.direction, n)).collect()
267    }
268
269    // -----------------------------------------------------------------------
270    // XJunctionValidator tests
271    // -----------------------------------------------------------------------
272
273    fn make_chess_grid(rows: usize, cols: usize, spacing: f32) -> (Vec<Point2<f32>>, Vec<f32>) {
274        let mut positions = Vec::new();
275        let mut orientations = Vec::new();
276        for j in 0..rows {
277            for i in 0..cols {
278                positions.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
279                orientations.push(if (i + j) % 2 == 0 {
280                    FRAC_PI_4
281                } else {
282                    3.0 * FRAC_PI_4
283                });
284            }
285        }
286        (positions, orientations)
287    }
288
289    #[test]
290    fn xjunction_regular_grid_center_has_four_neighbors() {
291        let spacing = 10.0;
292        let (positions, orientations) = make_chess_grid(3, 3, spacing);
293
294        let validator = XJunctionValidator {
295            min_spacing: 5.0,
296            max_spacing: 15.0,
297            tolerance_rad: 15.0f32.to_radians(),
298        };
299        let graph = GridGraph::build(
300            &positions,
301            &orientations,
302            &validator,
303            &GridGraphParams::default(),
304        );
305
306        let idx = |i: usize, j: usize| j * 3 + i;
307        let center = neighbor_map(&graph.neighbors[idx(1, 1)]);
308        assert_eq!(4, center.len());
309        assert_eq!(idx(0, 1), center[&NeighborDirection::Left].index);
310        assert_eq!(idx(2, 1), center[&NeighborDirection::Right].index);
311        assert_eq!(idx(1, 0), center[&NeighborDirection::Up].index);
312        assert_eq!(idx(1, 2), center[&NeighborDirection::Down].index);
313    }
314
315    #[test]
316    fn xjunction_rejects_parallel_orientations() {
317        let spacing = 10.0;
318        let positions = vec![Point2::new(0.0, 0.0), Point2::new(spacing, 0.0)];
319        // Same orientation — should be rejected
320        let orientations = vec![FRAC_PI_4, FRAC_PI_4];
321
322        let validator = XJunctionValidator {
323            min_spacing: 5.0,
324            max_spacing: 15.0,
325            tolerance_rad: 15.0f32.to_radians(),
326        };
327        let graph = GridGraph::build(
328            &positions,
329            &orientations,
330            &validator,
331            &GridGraphParams {
332                k_neighbors: 2,
333                ..Default::default()
334            },
335        );
336
337        assert!(graph.neighbors[0].is_empty());
338        assert!(graph.neighbors[1].is_empty());
339    }
340
341    #[test]
342    fn xjunction_rejects_out_of_range_distance() {
343        let spacing = 30.0;
344        let positions = vec![Point2::new(0.0, 0.0), Point2::new(spacing, 0.0)];
345        let orientations = vec![FRAC_PI_4, 3.0 * FRAC_PI_4];
346
347        let validator = XJunctionValidator {
348            min_spacing: 5.0,
349            max_spacing: 15.0, // 30 > 15, rejected
350            tolerance_rad: 15.0f32.to_radians(),
351        };
352        let graph = GridGraph::build(
353            &positions,
354            &orientations,
355            &validator,
356            &GridGraphParams::default(),
357        );
358
359        assert!(graph.neighbors[0].is_empty());
360        assert!(graph.neighbors[1].is_empty());
361    }
362
363    #[test]
364    fn xjunction_rotated_grid_forms_single_component() {
365        let spacing = 20.0;
366        let angle = 40.0f32.to_radians();
367        let ax = Vector2::new(angle.cos(), angle.sin());
368        let ay = Vector2::new(-angle.sin(), angle.cos());
369        let cols = 4usize;
370        let rows = 4usize;
371
372        let diag0 = angle + FRAC_PI_4;
373        let diag1 = angle + 3.0 * FRAC_PI_4;
374
375        let mut positions = Vec::new();
376        let mut orientations = Vec::new();
377        for j in 0..rows {
378            for i in 0..cols {
379                let pos = ax * (i as f32 * spacing) + ay * (j as f32 * spacing);
380                positions.push(Point2::new(pos.x + 100.0, pos.y + 100.0));
381                orientations.push(if (i + j) % 2 == 0 { diag0 } else { diag1 });
382            }
383        }
384
385        let validator = XJunctionValidator {
386            min_spacing: spacing * 0.5,
387            max_spacing: spacing * 1.5,
388            tolerance_rad: 20.0f32.to_radians(),
389        };
390        let graph = GridGraph::build(
391            &positions,
392            &orientations,
393            &validator,
394            &GridGraphParams {
395                k_neighbors: 8,
396                ..Default::default()
397            },
398        );
399
400        let components = connected_components(&graph);
401        assert_eq!(1, components.len());
402        assert_eq!(cols * rows, components[0].len());
403
404        let coords = assign_grid_coordinates(&graph, &components[0]);
405        let coord_set: std::collections::HashSet<(i32, i32)> =
406            coords.iter().map(|&(_, g)| (g.i, g.j)).collect();
407        assert_eq!(cols * rows, coord_set.len());
408    }
409
410    #[test]
411    fn xjunction_direction_symmetry() {
412        let spacing = 20.0;
413        let (positions, orientations) = make_chess_grid(3, 3, spacing);
414
415        let validator = XJunctionValidator {
416            min_spacing: spacing * 0.5,
417            max_spacing: spacing * 1.5,
418            tolerance_rad: 15.0f32.to_radians(),
419        };
420        let graph = GridGraph::build(
421            &positions,
422            &orientations,
423            &validator,
424            &GridGraphParams::default(),
425        );
426
427        for (a, neighbors) in graph.neighbors.iter().enumerate() {
428            for n in neighbors {
429                let b = n.index;
430                let back = graph.neighbors[b].iter().find(|nn| nn.index == a);
431                assert!(
432                    back.is_some(),
433                    "Edge {a}->{b} exists but reverse {b}->{a} does not"
434                );
435                assert_eq!(n.direction.opposite(), back.unwrap().direction,);
436            }
437        }
438    }
439
440    // -----------------------------------------------------------------------
441    // SpatialSquareValidator tests
442    // -----------------------------------------------------------------------
443
444    #[test]
445    fn spatial_square_regular_grid_center_has_four() {
446        let spacing = 10.0;
447        let mut positions = Vec::new();
448        for j in 0..3 {
449            for i in 0..3 {
450                positions.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
451            }
452        }
453        let data = vec![(); positions.len()];
454
455        let validator = SpatialSquareValidator {
456            min_spacing: 5.0,
457            max_spacing: 15.0,
458        };
459        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
460
461        let idx = |i: usize, j: usize| j * 3 + i;
462        let center = neighbor_map(&graph.neighbors[idx(1, 1)]);
463        assert_eq!(4, center.len());
464        assert_eq!(idx(0, 1), center[&NeighborDirection::Left].index);
465        assert_eq!(idx(2, 1), center[&NeighborDirection::Right].index);
466        assert_eq!(idx(1, 0), center[&NeighborDirection::Up].index);
467        assert_eq!(idx(1, 2), center[&NeighborDirection::Down].index);
468    }
469
470    #[test]
471    fn spatial_square_rejects_out_of_range() {
472        let positions = vec![
473            Point2::new(0.0f32, 0.0),
474            Point2::new(3.0, 0.0),  // too close
475            Point2::new(50.0, 0.0), // too far
476        ];
477        let data = vec![(); 3];
478
479        let validator = SpatialSquareValidator {
480            min_spacing: 5.0,
481            max_spacing: 15.0,
482        };
483        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
484
485        assert!(graph.neighbors[0].is_empty());
486    }
487
488    #[test]
489    fn spatial_square_score_prefers_closest() {
490        let positions = vec![
491            Point2::new(0.0f32, 0.0),
492            Point2::new(8.0, 0.0),  // closer
493            Point2::new(12.0, 0.0), // farther, same quadrant
494        ];
495        let data = vec![(); 3];
496
497        let validator = SpatialSquareValidator {
498            min_spacing: 5.0,
499            max_spacing: 15.0,
500        };
501        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
502
503        let right = graph.neighbors[0]
504            .iter()
505            .find(|n| n.direction == NeighborDirection::Right)
506            .unwrap();
507        assert_eq!(1, right.index); // closer one wins
508    }
509
510    #[test]
511    fn spatial_square_diagonal_grid_works() {
512        // Grid rotated 45° — quadrant classification still assigns consistent directions
513        let spacing = 10.0;
514        let angle = 45.0f32.to_radians();
515        let ax = Vector2::new(angle.cos(), angle.sin());
516        let ay = Vector2::new(-angle.sin(), angle.cos());
517
518        let mut positions = Vec::new();
519        for j in 0..3 {
520            for i in 0..3 {
521                let pos = ax * (i as f32 * spacing) + ay * (j as f32 * spacing);
522                positions.push(Point2::new(pos.x + 50.0, pos.y + 50.0));
523            }
524        }
525        let data = vec![(); positions.len()];
526
527        let validator = SpatialSquareValidator {
528            min_spacing: spacing * 0.5,
529            max_spacing: spacing * 1.5,
530        };
531        let graph = GridGraph::build(&positions, &data, &validator, &GridGraphParams::default());
532
533        let components = connected_components(&graph);
534        assert_eq!(1, components.len());
535        assert_eq!(9, components[0].len());
536    }
537
538    // -----------------------------------------------------------------------
539    // SpatialHexValidator tests
540    // -----------------------------------------------------------------------
541
542    fn hex_lattice(radius: i32, spacing: f32) -> Vec<Point2<f32>> {
543        let sqrt3 = 3.0f32.sqrt();
544        let mut points = Vec::new();
545        for q in -radius..=radius {
546            for r in -radius..=radius {
547                if (q + r).abs() > radius {
548                    continue;
549                }
550                let x = spacing * (q as f32 + r as f32 * 0.5);
551                let y = spacing * (r as f32 * sqrt3 / 2.0);
552                points.push(Point2::new(x, y));
553            }
554        }
555        points
556    }
557
558    #[test]
559    fn spatial_hex_center_has_six_neighbors() {
560        let spacing = 50.0;
561        let points = hex_lattice(2, spacing);
562        let data = vec![(); points.len()];
563
564        let validator = SpatialHexValidator {
565            min_spacing: spacing * 0.5,
566            max_spacing: spacing * 1.5,
567        };
568        let graph = HexGridGraph::build(
569            &points,
570            &data,
571            &validator,
572            &GridGraphParams {
573                k_neighbors: 12,
574                ..Default::default()
575            },
576        );
577
578        let center = points
579            .iter()
580            .position(|p| p.x.abs() < 0.01 && p.y.abs() < 0.01)
581            .unwrap();
582        assert_eq!(6, graph.neighbors[center].len());
583    }
584
585    #[test]
586    fn spatial_hex_edge_nodes_have_three() {
587        let spacing = 50.0;
588        let points = hex_lattice(1, spacing);
589        let data = vec![(); points.len()];
590
591        let validator = SpatialHexValidator {
592            min_spacing: spacing * 0.5,
593            max_spacing: spacing * 1.5,
594        };
595        let graph = HexGridGraph::build(
596            &points,
597            &data,
598            &validator,
599            &GridGraphParams {
600                k_neighbors: 12,
601                ..Default::default()
602            },
603        );
604
605        for (i, p) in points.iter().enumerate() {
606            if p.x.abs() < 0.01 && p.y.abs() < 0.01 {
607                assert_eq!(6, graph.neighbors[i].len());
608            } else {
609                assert_eq!(3, graph.neighbors[i].len());
610            }
611        }
612    }
613
614    #[test]
615    fn spatial_hex_rejects_out_of_range() {
616        let points = vec![
617            Point2::new(0.0f32, 0.0),
618            Point2::new(3.0, 0.0), // too close
619        ];
620        let data = vec![(); 2];
621
622        let validator = SpatialHexValidator {
623            min_spacing: 10.0,
624            max_spacing: 50.0,
625        };
626        let graph = HexGridGraph::build(&points, &data, &validator, &GridGraphParams::default());
627
628        assert!(graph.neighbors[0].is_empty());
629    }
630
631    #[test]
632    fn spatial_hex_single_component_and_correct_coordinates() {
633        let spacing = 50.0;
634        let points = hex_lattice(2, spacing);
635        let data = vec![(); points.len()];
636
637        let validator = SpatialHexValidator {
638            min_spacing: spacing * 0.5,
639            max_spacing: spacing * 1.5,
640        };
641        let graph = HexGridGraph::build(
642            &points,
643            &data,
644            &validator,
645            &GridGraphParams {
646                k_neighbors: 12,
647                ..Default::default()
648            },
649        );
650
651        let components = hex_connected_components(&graph);
652        assert_eq!(1, components.len());
653        assert_eq!(points.len(), components[0].len());
654
655        let coords = hex_assign_grid_coordinates(&graph, &components[0]);
656        assert_eq!(points.len(), coords.len());
657
658        // All coordinates should be unique
659        let coord_set: std::collections::HashSet<(i32, i32)> =
660            coords.iter().map(|&(_, g)| (g.i, g.j)).collect();
661        assert_eq!(points.len(), coord_set.len());
662    }
663}