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