Skip to main content

projective_grid/hex/
smoothness.rs

1//! Hex grid smoothness analysis: predict corner positions from neighbors.
2//!
3//! For each hex grid corner, the expected position can be predicted from its
4//! immediate neighbors along three axial directions via midpoint averaging.
5//! Corners that deviate significantly from the prediction are likely false detections.
6
7use crate::grid_index::GridIndex;
8use nalgebra::Point2;
9use std::collections::HashMap;
10
11/// Opposite-pair deltas for the three hex axes (axial coordinates).
12///
13/// Each entry is `((dq_neg, dr_neg), (dq_pos, dr_pos))`.
14const HEX_AXIS_PAIRS: [((i32, i32), (i32, i32)); 3] = [
15    // E/W axis
16    ((-1, 0), (1, 0)),
17    // NE/SW axis
18    ((1, -1), (-1, 1)),
19    // NW/SE axis
20    ((0, -1), (0, 1)),
21];
22
23/// Predict a hex grid corner's position from its neighbors along three axes.
24///
25/// Uses midpoint averaging on up to three opposite-direction pairs:
26/// - E/W: midpoint of `(q-1, r)` and `(q+1, r)`
27/// - NE/SW: midpoint of `(q+1, r-1)` and `(q-1, r+1)`
28/// - NW/SE: midpoint of `(q, r-1)` and `(q, r+1)`
29///
30/// Returns the average of available predictions, or `None` if no complete
31/// neighbor pair exists.
32pub fn hex_predict_grid_position(
33    grid: &HashMap<GridIndex, Point2<f32>>,
34    idx: GridIndex,
35) -> Option<Point2<f32>> {
36    let mut pred_sum = Point2::new(0.0f32, 0.0f32);
37    let mut pred_count = 0u32;
38
39    for &((dq_a, dr_a), (dq_b, dr_b)) in &HEX_AXIS_PAIRS {
40        let a = GridIndex {
41            i: idx.i + dq_a,
42            j: idx.j + dr_a,
43        };
44        let b = GridIndex {
45            i: idx.i + dq_b,
46            j: idx.j + dr_b,
47        };
48        if let (Some(&pa), Some(&pb)) = (grid.get(&a), grid.get(&b)) {
49            pred_sum.x += 0.5 * (pa.x + pb.x);
50            pred_sum.y += 0.5 * (pa.y + pb.y);
51            pred_count += 1;
52        }
53    }
54
55    if pred_count == 0 {
56        return None;
57    }
58
59    Some(Point2::new(
60        pred_sum.x / pred_count as f32,
61        pred_sum.y / pred_count as f32,
62    ))
63}
64
65/// Find hex grid corners whose position deviates from the neighbor-predicted
66/// position by more than `threshold` pixels.
67///
68/// Returns `(grid_index, predicted_position)` for each inconsistent corner.
69pub fn hex_find_inconsistent_corners(
70    grid: &HashMap<GridIndex, Point2<f32>>,
71    threshold: f32,
72) -> Vec<(GridIndex, Point2<f32>)> {
73    let threshold_sq = threshold * threshold;
74    let mut flagged = Vec::new();
75
76    for (&idx, &pos) in grid {
77        if let Some(predicted) = hex_predict_grid_position(grid, idx) {
78            let dx = pos.x - predicted.x;
79            let dy = pos.y - predicted.y;
80            if dx * dx + dy * dy > threshold_sq {
81                flagged.push((idx, predicted));
82            }
83        }
84    }
85
86    flagged
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn make_hex_grid(radius: i32, spacing: f32) -> HashMap<GridIndex, Point2<f32>> {
94        let sqrt3 = 3.0f32.sqrt();
95        let mut map = HashMap::new();
96        for q in -radius..=radius {
97            for r in -radius..=radius {
98                if (q + r).abs() > radius {
99                    continue;
100                }
101                let x = spacing * (q as f32 + r as f32 * 0.5);
102                let y = spacing * (r as f32 * sqrt3 / 2.0);
103                map.insert(GridIndex { i: q, j: r }, Point2::new(x, y));
104            }
105        }
106        map
107    }
108
109    #[test]
110    fn clean_hex_grid_has_no_inconsistencies() {
111        let grid = make_hex_grid(3, 60.0);
112        let flagged = hex_find_inconsistent_corners(&grid, 3.0);
113        assert!(flagged.is_empty());
114    }
115
116    #[test]
117    fn displaced_corner_is_flagged() {
118        let mut grid = make_hex_grid(2, 60.0);
119        let center = GridIndex { i: 0, j: 0 };
120        // Displace center by (9, 9) pixels
121        grid.insert(center, Point2::new(9.0, 9.0));
122
123        let flagged = hex_find_inconsistent_corners(&grid, 3.0);
124        assert_eq!(1, flagged.len());
125        assert_eq!(center, flagged[0].0);
126
127        // Predicted should be near (0, 0) — the ideal center
128        let pred = flagged[0].1;
129        assert!(pred.x.abs() < 0.01, "pred.x = {}", pred.x);
130        assert!(pred.y.abs() < 0.01, "pred.y = {}", pred.y);
131    }
132
133    #[test]
134    fn isolated_nodes_are_skipped() {
135        let mut grid = HashMap::new();
136        grid.insert(GridIndex { i: 0, j: 0 }, Point2::new(0.0, 0.0));
137        grid.insert(GridIndex { i: 10, j: 10 }, Point2::new(500.0, 500.0));
138
139        let flagged = hex_find_inconsistent_corners(&grid, 3.0);
140        assert!(flagged.is_empty());
141    }
142
143    #[test]
144    fn prediction_from_single_axis_pair() {
145        let spacing = 60.0;
146        let sqrt3 = 3.0f32.sqrt();
147        let mut grid = HashMap::new();
148        // Just three points along the E/W axis: q = -1, 0, 1 at r = 0
149        grid.insert(GridIndex { i: -1, j: 0 }, Point2::new(-spacing, 0.0));
150        grid.insert(GridIndex { i: 0, j: 0 }, Point2::new(0.0, 0.0));
151        grid.insert(GridIndex { i: 1, j: 0 }, Point2::new(spacing, 0.0));
152
153        let pred = hex_predict_grid_position(&grid, GridIndex { i: 0, j: 0 }).unwrap();
154        assert!((pred.x - 0.0).abs() < 0.01);
155        assert!((pred.y - 0.0).abs() < 0.01);
156
157        // Three points along the NW/SE axis: (0,-1), (0,0), (0,1)
158        let mut grid2 = HashMap::new();
159        grid2.insert(
160            GridIndex { i: 0, j: -1 },
161            Point2::new(-0.5 * spacing, -sqrt3 / 2.0 * spacing),
162        );
163        grid2.insert(GridIndex { i: 0, j: 0 }, Point2::new(0.0, 0.0));
164        grid2.insert(
165            GridIndex { i: 0, j: 1 },
166            Point2::new(0.5 * spacing, sqrt3 / 2.0 * spacing),
167        );
168
169        let pred2 = hex_predict_grid_position(&grid2, GridIndex { i: 0, j: 0 }).unwrap();
170        assert!((pred2.x - 0.0).abs() < 0.01);
171        assert!((pred2.y - 0.0).abs() < 0.01);
172    }
173
174    #[test]
175    fn perspective_distorted_hex_grid_passes() {
176        let spacing = 60.0;
177        let sqrt3 = 3.0f32.sqrt();
178        let mut grid = HashMap::new();
179        let radius: i32 = 3;
180        for q in -radius..=radius {
181            for r in -radius..=radius {
182                if (q + r).abs() > radius {
183                    continue;
184                }
185                let x = spacing * (q as f32 + r as f32 * 0.5);
186                let y = spacing * (r as f32 * sqrt3 / 2.0);
187                // Mild perspective: scale increases with y
188                let scale = 1.0 + 0.01 * y / spacing;
189                grid.insert(GridIndex { i: q, j: r }, Point2::new(x * scale, y * scale));
190            }
191        }
192
193        let flagged = hex_find_inconsistent_corners(&grid, 3.0);
194        assert!(flagged.is_empty());
195    }
196}