Skip to main content

projective_grid/hex/
mesh.rs

1//! Per-triangle homography mesh for hex grid rectification.
2//!
3//! Given a map of hex grid corners (axial coordinates) to image positions,
4//! builds one affine transform and one homography per triangle cell.
5//! The hex lattice is decomposed into parallelogram cells, each split into
6//! two triangles.
7
8use crate::affine::AffineTransform2D;
9use crate::float_helpers::lit;
10use crate::homography::{estimate_homography, Homography};
11use crate::Float;
12use crate::GridCoords;
13use nalgebra::Point2;
14use std::collections::HashMap;
15
16fn sqrt3_half<F: Float>() -> F {
17    lit::<F>(3.0).sqrt() / lit::<F>(2.0)
18}
19
20/// Reason a [`HexGridHomographyMesh`] could not be built.
21#[non_exhaustive]
22#[derive(thiserror::Error, Debug)]
23pub enum HexGridMeshError {
24    /// Fewer than 3 grid corners were supplied — no triangle can be formed.
25    #[error("not enough grid corners (need at least 3)")]
26    NotEnoughCorners,
27    /// No parallelogram cell had all corners present, so no triangle
28    /// transform could be fitted.
29    #[error("no valid triangles found")]
30    NoValidTriangles,
31}
32
33#[derive(Clone, Debug)]
34struct TriangleCell<F: Float> {
35    affine: AffineTransform2D<F>,
36    homography: Homography<F>,
37}
38
39/// Per-triangle homography mesh over a hex grid.
40///
41/// Each parallelogram cell in axial space `(q, r) → (q+1, r+1)` is split
42/// into two triangles:
43/// - **Lower**: `(q,r)`, `(q+1,r)`, `(q,r+1)` — when `frac_q + frac_r ≤ 1`
44/// - **Upper**: `(q+1,r)`, `(q,r+1)`, `(q+1,r+1)` — when `frac_q + frac_r > 1`
45#[derive(Clone, Debug)]
46pub struct HexGridHomographyMesh<F: Float = f32> {
47    /// Minimum axial `q` of the corner set — the mesh's grid origin.
48    pub min_q: i32,
49    /// Minimum axial `r` of the corner set — the mesh's grid origin.
50    pub min_r: i32,
51    /// Number of parallelogram cells along q.
52    pub cells_q: usize,
53    /// Number of parallelogram cells along r.
54    pub cells_r: usize,
55    /// Rectified pixels per grid cell edge.
56    pub px_per_cell: F,
57    /// Number of valid triangle cells.
58    pub valid_triangles: usize,
59    /// Rectified image width in pixels.
60    pub rect_width: usize,
61    /// Rectified image height in pixels.
62    pub rect_height: usize,
63
64    cells: Vec<Option<TriangleCell<F>>>,
65
66    x_offset: F,
67    y_offset: F,
68}
69
70impl<F: Float> HexGridHomographyMesh<F> {
71    /// Build per-triangle transforms from a hex grid corner map.
72    ///
73    /// - `corners`: map from axial grid index `(q=i, r=j)` to image position.
74    /// - `px_per_cell`: rectified pixels per grid cell edge.
75    pub fn from_corners(
76        corners: &HashMap<GridCoords, Point2<F>>,
77        px_per_cell: F,
78    ) -> Result<Self, HexGridMeshError> {
79        if corners.len() < 3 {
80            return Err(HexGridMeshError::NotEnoughCorners);
81        }
82
83        let (mut min_q, mut min_r) = (i32::MAX, i32::MAX);
84        let (mut max_q, mut max_r) = (i32::MIN, i32::MIN);
85        for g in corners.keys() {
86            min_q = min_q.min(g.i);
87            min_r = min_r.min(g.j);
88            max_q = max_q.max(g.i);
89            max_r = max_r.max(g.j);
90        }
91
92        if max_q - min_q < 1 || max_r - min_r < 1 {
93            return Err(HexGridMeshError::NoValidTriangles);
94        }
95
96        let cells_q = (max_q - min_q) as usize;
97        let cells_r = (max_r - min_r) as usize;
98        let s = px_per_cell;
99        let s3h: F = sqrt3_half();
100        let half: F = lit(0.5);
101
102        // Compute rectified bounding box
103        let mut x_min = F::max_value().unwrap_or_else(|| lit(1e30));
104        let mut x_max = -x_min;
105        let mut y_min = x_min;
106        let mut y_max = -y_min;
107
108        for &q_i in &[min_q, max_q] {
109            for &r_j in &[min_r, max_r] {
110                let q: F = lit(q_i as f64);
111                let r: F = lit(r_j as f64);
112                let x = s * (q + r * half);
113                let y = s * (r * s3h);
114                x_min = if x < x_min { x } else { x_min };
115                x_max = if x > x_max { x } else { x_max };
116                y_min = if y < y_min { y } else { y_min };
117                y_max = if y > y_max { y } else { y_max };
118            }
119        }
120
121        let rect_width = nalgebra::try_convert::<F, f64>((x_max - x_min).round().max(F::one()))
122            .unwrap_or(1.0) as usize;
123        let rect_height = nalgebra::try_convert::<F, f64>((y_max - y_min).round().max(F::one()))
124            .unwrap_or(1.0) as usize;
125
126        let axial_to_rect = |qi: i32, rj: i32| -> Point2<F> {
127            let q: F = lit(qi as f64);
128            let r: F = lit(rj as f64);
129            Point2::new(s * (q + r * half) - x_min, s * (r * s3h) - y_min)
130        };
131
132        let mut cells = vec![None; cells_q * cells_r * 2];
133        let mut valid_triangles = 0usize;
134
135        for cr in 0..cells_r {
136            for cq in 0..cells_q {
137                let q0 = min_q + cq as i32;
138                let r0 = min_r + cr as i32;
139
140                let g00 = GridCoords { i: q0, j: r0 };
141                let g10 = GridCoords { i: q0 + 1, j: r0 };
142                let g01 = GridCoords { i: q0, j: r0 + 1 };
143                let g11 = GridCoords {
144                    i: q0 + 1,
145                    j: r0 + 1,
146                };
147
148                let p00 = corners.get(&g00).copied();
149                let p10 = corners.get(&g10).copied();
150                let p01 = corners.get(&g01).copied();
151                let p11 = corners.get(&g11).copied();
152
153                let idx_base = (cr * cells_q + cq) * 2;
154
155                // Lower triangle: g00, g10, g01
156                if let (Some(ip00), Some(ip10), Some(ip01)) = (p00, p10, p01) {
157                    let rect_tri = [
158                        axial_to_rect(q0, r0),
159                        axial_to_rect(q0 + 1, r0),
160                        axial_to_rect(q0, r0 + 1),
161                    ];
162                    let img_tri = [ip00, ip10, ip01];
163
164                    if let Some(affine) =
165                        AffineTransform2D::from_triangle_correspondence(rect_tri, img_tri)
166                    {
167                        let rect_c = centroid(&rect_tri);
168                        let img_c = affine.apply(rect_c);
169                        let rect_4: Vec<Point2<F>> = rect_tri
170                            .iter()
171                            .chain(std::iter::once(&rect_c))
172                            .copied()
173                            .collect();
174                        let img_4: Vec<Point2<F>> = img_tri
175                            .iter()
176                            .chain(std::iter::once(&img_c))
177                            .copied()
178                            .collect();
179
180                        if let Some(homography) = estimate_homography(&rect_4, &img_4) {
181                            cells[idx_base] = Some(TriangleCell { affine, homography });
182                            valid_triangles += 1;
183                        }
184                    }
185                }
186
187                // Upper triangle: g10, g01, g11
188                if let (Some(ip10), Some(ip01), Some(ip11)) = (p10, p01, p11) {
189                    let rect_tri = [
190                        axial_to_rect(q0 + 1, r0),
191                        axial_to_rect(q0, r0 + 1),
192                        axial_to_rect(q0 + 1, r0 + 1),
193                    ];
194                    let img_tri = [ip10, ip01, ip11];
195
196                    if let Some(affine) =
197                        AffineTransform2D::from_triangle_correspondence(rect_tri, img_tri)
198                    {
199                        let rect_c = centroid(&rect_tri);
200                        let img_c = affine.apply(rect_c);
201                        let rect_4: Vec<Point2<F>> = rect_tri
202                            .iter()
203                            .chain(std::iter::once(&rect_c))
204                            .copied()
205                            .collect();
206                        let img_4: Vec<Point2<F>> = img_tri
207                            .iter()
208                            .chain(std::iter::once(&img_c))
209                            .copied()
210                            .collect();
211
212                        if let Some(homography) = estimate_homography(&rect_4, &img_4) {
213                            cells[idx_base + 1] = Some(TriangleCell { affine, homography });
214                            valid_triangles += 1;
215                        }
216                    }
217                }
218            }
219        }
220
221        if valid_triangles == 0 {
222            return Err(HexGridMeshError::NoValidTriangles);
223        }
224
225        Ok(Self {
226            min_q,
227            min_r,
228            cells_q,
229            cells_r,
230            px_per_cell,
231            valid_triangles,
232            rect_width,
233            rect_height,
234            cells,
235            x_offset: x_min,
236            y_offset: y_min,
237        })
238    }
239
240    /// Map a point in **global rectified pixel coordinates** to image coordinates
241    /// using the per-triangle affine transform.
242    ///
243    /// Returns `None` if the point lies outside the mesh or the cell is invalid.
244    pub fn rect_to_img_affine(&self, p_rect: Point2<F>) -> Option<Point2<F>> {
245        let cell = self.lookup_cell(p_rect)?;
246        Some(cell.affine.apply(p_rect))
247    }
248
249    /// Map a point in **global rectified pixel coordinates** to image coordinates
250    /// using the per-triangle homography.
251    ///
252    /// Returns `None` if the point lies outside the mesh or the cell is invalid.
253    pub fn rect_to_img(&self, p_rect: Point2<F>) -> Option<Point2<F>> {
254        let cell = self.lookup_cell(p_rect)?;
255        Some(cell.homography.apply(p_rect))
256    }
257
258    /// Look up the triangle cell for a rectified point.
259    fn lookup_cell(&self, p_rect: Point2<F>) -> Option<&TriangleCell<F>> {
260        let s = self.px_per_cell;
261        if s <= F::zero() {
262            return None;
263        }
264
265        let s3h: F = sqrt3_half();
266        let half: F = lit(0.5);
267
268        // Convert rectified pixel coords back to fractional axial coords
269        let r_frac = (p_rect.y + self.y_offset) / (s * s3h);
270        let q_frac = (p_rect.x + self.x_offset) / s - r_frac * half;
271
272        // Determine parallelogram cell
273        let cq_f = q_frac - lit(self.min_q as f64);
274        let cr_f = r_frac - lit(self.min_r as f64);
275
276        let cq = nalgebra::try_convert::<F, f64>(cq_f.floor()).unwrap_or(0.0) as i32;
277        let cr = nalgebra::try_convert::<F, f64>(cr_f.floor()).unwrap_or(0.0) as i32;
278
279        if cq < 0 || cr < 0 || cq >= self.cells_q as i32 || cr >= self.cells_r as i32 {
280            return None;
281        }
282
283        // Determine lower vs upper triangle
284        let frac_q = cq_f - lit(cq as f64);
285        let frac_r = cr_f - lit(cr as f64);
286        let is_upper = frac_q + frac_r > F::one();
287
288        let idx = (cr as usize * self.cells_q + cq as usize) * 2 + is_upper as usize;
289        self.cells.get(idx)?.as_ref()
290    }
291}
292
293fn centroid<F: Float>(tri: &[Point2<F>; 3]) -> Point2<F> {
294    let third: F = lit(1.0 / 3.0);
295    Point2::new(
296        (tri[0].x + tri[1].x + tri[2].x) * third,
297        (tri[0].y + tri[1].y + tri[2].y) * third,
298    )
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn make_hex_corners(radius: i32, spacing: f32) -> HashMap<GridCoords, Point2<f32>> {
306        let sqrt3 = 3.0f32.sqrt();
307        let mut map = HashMap::new();
308        for q in -radius..=radius {
309            for r in -radius..=radius {
310                if (q + r).abs() > radius {
311                    continue;
312                }
313                let x = spacing * (q as f32 + r as f32 * 0.5);
314                let y = spacing * (r as f32 * sqrt3 / 2.0);
315                map.insert(GridCoords { i: q, j: r }, Point2::new(x, y));
316            }
317        }
318        map
319    }
320
321    #[test]
322    fn affine_from_triangle_identity() {
323        let tri: [Point2<f32>; 3] = [
324            Point2::new(0.0, 0.0),
325            Point2::new(1.0, 0.0),
326            Point2::new(0.0, 1.0),
327        ];
328        let aff = AffineTransform2D::from_triangle_correspondence(tri, tri).unwrap();
329        let p = Point2::new(0.3f32, 0.4);
330        let result = aff.apply(p);
331        assert!((result.x - p.x).abs() < 1e-6);
332        assert!((result.y - p.y).abs() < 1e-6);
333    }
334
335    #[test]
336    fn affine_maps_vertices_correctly() {
337        let src: [Point2<f32>; 3] = [
338            Point2::new(0.0, 0.0),
339            Point2::new(1.0, 0.0),
340            Point2::new(0.0, 1.0),
341        ];
342        let dst: [Point2<f32>; 3] = [
343            Point2::new(10.0, 20.0),
344            Point2::new(30.0, 20.0),
345            Point2::new(10.0, 50.0),
346        ];
347        let aff = AffineTransform2D::from_triangle_correspondence(src, dst).unwrap();
348        for (s, d) in src.iter().zip(dst.iter()) {
349            let result = aff.apply(*s);
350            assert!((result.x - d.x).abs() < 1e-4);
351            assert!((result.y - d.y).abs() < 1e-4);
352        }
353    }
354
355    #[test]
356    fn degenerate_triangle_returns_none() {
357        let src: [Point2<f32>; 3] = [
358            Point2::new(0.0, 0.0),
359            Point2::new(1.0, 0.0),
360            Point2::new(2.0, 0.0), // collinear
361        ];
362        let dst = src;
363        assert!(AffineTransform2D::from_triangle_correspondence(src, dst).is_none());
364    }
365
366    #[test]
367    fn mesh_from_regular_hex_grid() {
368        let corners = make_hex_corners(3, 60.0);
369        let mesh = HexGridHomographyMesh::from_corners(&corners, 60.0).unwrap();
370        assert!(mesh.valid_triangles > 0);
371        assert!(mesh.rect_width > 0);
372        assert!(mesh.rect_height > 0);
373    }
374
375    #[test]
376    fn round_trip_through_affine_mesh() {
377        let spacing = 60.0;
378        let corners = make_hex_corners(3, spacing);
379        let mesh = HexGridHomographyMesh::from_corners(&corners, spacing).unwrap();
380
381        let s3h = 3.0f32.sqrt() / 2.0;
382
383        for (g, &img_pos) in &corners {
384            let rx = spacing * (g.i as f32 + g.j as f32 * 0.5) - mesh.x_offset;
385            let ry = spacing * (g.j as f32 * s3h) - mesh.y_offset;
386            let rect_pt = Point2::new(rx, ry);
387
388            if let Some(recovered) = mesh.rect_to_img_affine(rect_pt) {
389                assert!(
390                    (recovered.x - img_pos.x).abs() < 1.0,
391                    "x mismatch at ({},{}): {} vs {}",
392                    g.i,
393                    g.j,
394                    recovered.x,
395                    img_pos.x,
396                );
397                assert!(
398                    (recovered.y - img_pos.y).abs() < 1.0,
399                    "y mismatch at ({},{}): {} vs {}",
400                    g.i,
401                    g.j,
402                    recovered.y,
403                    img_pos.y,
404                );
405            }
406        }
407    }
408
409    #[test]
410    fn round_trip_through_homography_mesh() {
411        let spacing = 60.0;
412        let corners = make_hex_corners(3, spacing);
413        let mesh = HexGridHomographyMesh::from_corners(&corners, spacing).unwrap();
414
415        let s3h = 3.0f32.sqrt() / 2.0;
416
417        for (g, &img_pos) in &corners {
418            let rx = spacing * (g.i as f32 + g.j as f32 * 0.5) - mesh.x_offset;
419            let ry = spacing * (g.j as f32 * s3h) - mesh.y_offset;
420            let rect_pt = Point2::new(rx, ry);
421
422            if let Some(recovered) = mesh.rect_to_img(rect_pt) {
423                assert!(
424                    (recovered.x - img_pos.x).abs() < 1.0,
425                    "homography x mismatch at ({},{}): {} vs {}",
426                    g.i,
427                    g.j,
428                    recovered.x,
429                    img_pos.x,
430                );
431                assert!(
432                    (recovered.y - img_pos.y).abs() < 1.0,
433                    "homography y mismatch at ({},{}): {} vs {}",
434                    g.i,
435                    g.j,
436                    recovered.y,
437                    img_pos.y,
438                );
439            }
440        }
441    }
442
443    #[test]
444    fn too_few_corners_errors() {
445        let mut corners = HashMap::new();
446        corners.insert(GridCoords { i: 0, j: 0 }, Point2::new(0.0f32, 0.0));
447        corners.insert(GridCoords { i: 1, j: 0 }, Point2::new(50.0, 0.0));
448
449        let result = HexGridHomographyMesh::from_corners(&corners, 50.0);
450        assert!(result.is_err());
451    }
452
453    #[test]
454    fn missing_corners_handled_gracefully() {
455        let mut corners = make_hex_corners(3, 60.0);
456        corners.remove(&GridCoords { i: 0, j: 0 });
457        corners.remove(&GridCoords { i: 1, j: 1 });
458
459        let mesh = HexGridHomographyMesh::from_corners(&corners, 60.0);
460        assert!(mesh.is_ok());
461        let mesh = mesh.unwrap();
462        assert!(mesh.valid_triangles > 0);
463    }
464}