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::grid_index::GridIndex;
9use crate::homography::{estimate_homography, Homography};
10use nalgebra::{Matrix2, Point2, Vector2};
11use std::collections::HashMap;
12
13/// Sqrt(3) / 2, the vertical spacing factor for pointy-top hex grids.
14const SQRT3_HALF: f64 = 0.866_025_403_784_438_6;
15
16#[non_exhaustive]
17#[derive(thiserror::Error, Debug)]
18pub enum HexMeshError {
19    #[error("not enough grid corners (need at least 3)")]
20    NotEnoughCorners,
21    #[error("no valid triangles found")]
22    NoValidTriangles,
23}
24
25/// A 2D affine transform: `dst = M * [src_x, src_y]^T + t`.
26#[derive(Clone, Copy, Debug)]
27pub struct AffineTransform2D {
28    /// 2x2 linear part.
29    pub linear: Matrix2<f64>,
30    /// Translation part.
31    pub translation: Vector2<f64>,
32}
33
34impl AffineTransform2D {
35    /// Compute the affine transform mapping `src` triangle to `dst` triangle.
36    ///
37    /// Returns `None` if the source triangle is degenerate (collinear points).
38    pub fn from_triangle_correspondence(
39        src: [Point2<f64>; 3],
40        dst: [Point2<f64>; 3],
41    ) -> Option<Self> {
42        // Solve: dst_i = M * src_i + t for i = 0, 1, 2
43        // Using src[0] as origin: M * (src_i - src_0) = (dst_i - dst_0) for i = 1, 2
44        let ds1 = src[1] - src[0];
45        let ds2 = src[2] - src[0];
46        let dd1 = dst[1] - dst[0];
47        let dd2 = dst[2] - dst[0];
48
49        // [ds1 | ds2] as column matrix, invert to get M
50        let src_mat = Matrix2::new(ds1.x, ds2.x, ds1.y, ds2.y);
51
52        let src_inv = src_mat.try_inverse()?;
53
54        // M = [dd1 | dd2] * src_inv
55        let dst_mat = Matrix2::new(dd1.x, dd2.x, dd1.y, dd2.y);
56        let linear = dst_mat * src_inv;
57
58        let t = dst[0] - linear * Vector2::new(src[0].x, src[0].y);
59        let translation = Vector2::new(t.x, t.y);
60
61        Some(Self {
62            linear,
63            translation,
64        })
65    }
66
67    /// Apply the transform to a 2D point.
68    pub fn apply(&self, p: Point2<f64>) -> Point2<f64> {
69        let v = self.linear * Vector2::new(p.x, p.y) + self.translation;
70        Point2::new(v.x, v.y)
71    }
72}
73
74#[derive(Clone, Debug)]
75struct TriangleCell {
76    /// Affine transform from rectified triangle to image triangle.
77    affine: AffineTransform2D,
78    /// Homography from rectified triangle to image triangle (4-point, with centroid).
79    homography: Homography,
80}
81
82/// Per-triangle homography mesh over a hex grid.
83///
84/// Each parallelogram cell in axial space `(q, r) → (q+1, r+1)` is split
85/// into two triangles:
86/// - **Lower**: `(q,r)`, `(q+1,r)`, `(q,r+1)` — when `frac_q + frac_r ≤ 1`
87/// - **Upper**: `(q+1,r)`, `(q,r+1)`, `(q+1,r+1)` — when `frac_q + frac_r > 1`
88#[derive(Clone, Debug)]
89pub struct HexGridHomographyMesh {
90    pub min_q: i32,
91    pub min_r: i32,
92    /// Number of parallelogram cells along q.
93    pub cells_q: usize,
94    /// Number of parallelogram cells along r.
95    pub cells_r: usize,
96    /// Rectified pixels per grid cell edge.
97    pub px_per_cell: f32,
98    /// Number of valid triangle cells.
99    pub valid_triangles: usize,
100    /// Rectified image dimensions.
101    pub rect_width: usize,
102    pub rect_height: usize,
103
104    // 2 triangles per parallelogram cell: [lower, upper] interleaved.
105    // Length = cells_q * cells_r * 2
106    cells: Vec<Option<TriangleCell>>,
107
108    // Rectified coordinate offset (subtracted from raw axial→rect mapping).
109    x_offset: f64,
110    y_offset: f64,
111}
112
113impl HexGridHomographyMesh {
114    /// Build per-triangle transforms from a hex grid corner map.
115    ///
116    /// - `corners`: map from axial grid index `(q=i, r=j)` to image position.
117    /// - `px_per_cell`: rectified pixels per grid cell edge.
118    pub fn from_corners(
119        corners: &HashMap<GridIndex, Point2<f32>>,
120        px_per_cell: f32,
121    ) -> Result<Self, HexMeshError> {
122        if corners.len() < 3 {
123            return Err(HexMeshError::NotEnoughCorners);
124        }
125
126        let (mut min_q, mut min_r) = (i32::MAX, i32::MAX);
127        let (mut max_q, mut max_r) = (i32::MIN, i32::MIN);
128        for g in corners.keys() {
129            min_q = min_q.min(g.i);
130            min_r = min_r.min(g.j);
131            max_q = max_q.max(g.i);
132            max_r = max_r.max(g.j);
133        }
134
135        if max_q - min_q < 1 || max_r - min_r < 1 {
136            return Err(HexMeshError::NoValidTriangles);
137        }
138
139        let cells_q = (max_q - min_q) as usize;
140        let cells_r = (max_r - min_r) as usize;
141        let s = px_per_cell as f64;
142
143        // Compute rectified bounding box
144        let mut x_min = f64::MAX;
145        let mut x_max = f64::MIN;
146        let mut y_min = f64::MAX;
147        let mut y_max = f64::MIN;
148
149        // Check all corner positions of the bounding parallelogram
150        for &q in &[min_q, max_q] {
151            for &r in &[min_r, max_r] {
152                let x = s * (q as f64 + r as f64 * 0.5);
153                let y = s * (r as f64 * SQRT3_HALF);
154                x_min = x_min.min(x);
155                x_max = x_max.max(x);
156                y_min = y_min.min(y);
157                y_max = y_max.max(y);
158            }
159        }
160
161        let rect_width = ((x_max - x_min).round().max(1.0)) as usize;
162        let rect_height = ((y_max - y_min).round().max(1.0)) as usize;
163
164        let axial_to_rect = |q: i32, r: i32| -> Point2<f64> {
165            Point2::new(
166                s * (q as f64 + r as f64 * 0.5) - x_min,
167                s * (r as f64 * SQRT3_HALF) - y_min,
168            )
169        };
170
171        let mut cells = vec![None; cells_q * cells_r * 2];
172        let mut valid_triangles = 0usize;
173
174        for cr in 0..cells_r {
175            for cq in 0..cells_q {
176                let q0 = min_q + cq as i32;
177                let r0 = min_r + cr as i32;
178
179                let g00 = GridIndex { i: q0, j: r0 };
180                let g10 = GridIndex { i: q0 + 1, j: r0 };
181                let g01 = GridIndex { i: q0, j: r0 + 1 };
182                let g11 = GridIndex {
183                    i: q0 + 1,
184                    j: r0 + 1,
185                };
186
187                let p00 = corners.get(&g00).copied();
188                let p10 = corners.get(&g10).copied();
189                let p01 = corners.get(&g01).copied();
190                let p11 = corners.get(&g11).copied();
191
192                let idx_base = (cr * cells_q + cq) * 2;
193
194                // Lower triangle: g00, g10, g01
195                if let (Some(ip00), Some(ip10), Some(ip01)) = (p00, p10, p01) {
196                    let rect_tri = [
197                        axial_to_rect(q0, r0),
198                        axial_to_rect(q0 + 1, r0),
199                        axial_to_rect(q0, r0 + 1),
200                    ];
201                    let img_tri = [
202                        Point2::new(ip00.x as f64, ip00.y as f64),
203                        Point2::new(ip10.x as f64, ip10.y as f64),
204                        Point2::new(ip01.x as f64, ip01.y as f64),
205                    ];
206
207                    if let Some(affine) =
208                        AffineTransform2D::from_triangle_correspondence(rect_tri, img_tri)
209                    {
210                        // 4-point homography: add centroid as 4th point
211                        let rect_c = centroid(&rect_tri);
212                        let img_c = affine.apply(rect_c);
213                        let rect_4: Vec<Point2<f32>> = rect_tri
214                            .iter()
215                            .chain(std::iter::once(&rect_c))
216                            .map(|p| Point2::new(p.x as f32, p.y as f32))
217                            .collect();
218                        let img_4: Vec<Point2<f32>> = img_tri
219                            .iter()
220                            .chain(std::iter::once(&img_c))
221                            .map(|p| Point2::new(p.x as f32, p.y as f32))
222                            .collect();
223
224                        if let Some(homography) = estimate_homography(&rect_4, &img_4) {
225                            cells[idx_base] = Some(TriangleCell { affine, homography });
226                            valid_triangles += 1;
227                        }
228                    }
229                }
230
231                // Upper triangle: g10, g01, g11
232                if let (Some(ip10), Some(ip01), Some(ip11)) = (p10, p01, p11) {
233                    let rect_tri = [
234                        axial_to_rect(q0 + 1, r0),
235                        axial_to_rect(q0, r0 + 1),
236                        axial_to_rect(q0 + 1, r0 + 1),
237                    ];
238                    let img_tri = [
239                        Point2::new(ip10.x as f64, ip10.y as f64),
240                        Point2::new(ip01.x as f64, ip01.y as f64),
241                        Point2::new(ip11.x as f64, ip11.y as f64),
242                    ];
243
244                    if let Some(affine) =
245                        AffineTransform2D::from_triangle_correspondence(rect_tri, img_tri)
246                    {
247                        let rect_c = centroid(&rect_tri);
248                        let img_c = affine.apply(rect_c);
249                        let rect_4: Vec<Point2<f32>> = rect_tri
250                            .iter()
251                            .chain(std::iter::once(&rect_c))
252                            .map(|p| Point2::new(p.x as f32, p.y as f32))
253                            .collect();
254                        let img_4: Vec<Point2<f32>> = img_tri
255                            .iter()
256                            .chain(std::iter::once(&img_c))
257                            .map(|p| Point2::new(p.x as f32, p.y as f32))
258                            .collect();
259
260                        if let Some(homography) = estimate_homography(&rect_4, &img_4) {
261                            cells[idx_base + 1] = Some(TriangleCell { affine, homography });
262                            valid_triangles += 1;
263                        }
264                    }
265                }
266            }
267        }
268
269        if valid_triangles == 0 {
270            return Err(HexMeshError::NoValidTriangles);
271        }
272
273        Ok(Self {
274            min_q,
275            min_r,
276            cells_q,
277            cells_r,
278            px_per_cell,
279            valid_triangles,
280            rect_width,
281            rect_height,
282            cells,
283            x_offset: x_min,
284            y_offset: y_min,
285        })
286    }
287
288    /// Map a point in **global rectified pixel coordinates** to image coordinates
289    /// using the per-triangle affine transform.
290    ///
291    /// Returns `None` if the point lies outside the mesh or the cell is invalid.
292    pub fn rect_to_img_affine(&self, p_rect: Point2<f32>) -> Option<Point2<f32>> {
293        let (cell, p64) = self.lookup_cell(p_rect)?;
294        let result = cell.affine.apply(p64);
295        Some(Point2::new(result.x as f32, result.y as f32))
296    }
297
298    /// Map a point in **global rectified pixel coordinates** to image coordinates
299    /// using the per-triangle homography.
300    ///
301    /// Returns `None` if the point lies outside the mesh or the cell is invalid.
302    pub fn rect_to_img(&self, p_rect: Point2<f32>) -> Option<Point2<f32>> {
303        let (cell, _) = self.lookup_cell(p_rect)?;
304        Some(cell.homography.apply(p_rect))
305    }
306
307    /// Look up the triangle cell for a rectified point.
308    fn lookup_cell(&self, p_rect: Point2<f32>) -> Option<(&TriangleCell, Point2<f64>)> {
309        let s = self.px_per_cell as f64;
310        if s <= 0.0 {
311            return None;
312        }
313
314        let p64 = Point2::new(p_rect.x as f64, p_rect.y as f64);
315
316        // Convert rectified pixel coords back to fractional axial coords
317        let r_frac = (p64.y + self.y_offset) / (s * SQRT3_HALF);
318        let q_frac = (p64.x + self.x_offset) / s - r_frac * 0.5;
319
320        // Determine parallelogram cell
321        let cq_f = q_frac - self.min_q as f64;
322        let cr_f = r_frac - self.min_r as f64;
323
324        let cq = cq_f.floor() as i32;
325        let cr = cr_f.floor() as i32;
326
327        if cq < 0 || cr < 0 || cq >= self.cells_q as i32 || cr >= self.cells_r as i32 {
328            return None;
329        }
330
331        // Determine lower vs upper triangle
332        let frac_q = cq_f - cq as f64;
333        let frac_r = cr_f - cr as f64;
334        let is_upper = frac_q + frac_r > 1.0;
335
336        let idx = (cr as usize * self.cells_q + cq as usize) * 2 + is_upper as usize;
337        let cell = self.cells.get(idx)?.as_ref()?;
338
339        Some((cell, p64))
340    }
341}
342
343fn centroid(tri: &[Point2<f64>; 3]) -> Point2<f64> {
344    Point2::new(
345        (tri[0].x + tri[1].x + tri[2].x) / 3.0,
346        (tri[0].y + tri[1].y + tri[2].y) / 3.0,
347    )
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn make_hex_corners(radius: i32, spacing: f32) -> HashMap<GridIndex, Point2<f32>> {
355        let sqrt3 = 3.0f32.sqrt();
356        let mut map = HashMap::new();
357        for q in -radius..=radius {
358            for r in -radius..=radius {
359                if (q + r).abs() > radius {
360                    continue;
361                }
362                let x = spacing * (q as f32 + r as f32 * 0.5);
363                let y = spacing * (r as f32 * sqrt3 / 2.0);
364                map.insert(GridIndex { i: q, j: r }, Point2::new(x, y));
365            }
366        }
367        map
368    }
369
370    #[test]
371    fn affine_from_triangle_identity() {
372        let tri = [
373            Point2::new(0.0, 0.0),
374            Point2::new(1.0, 0.0),
375            Point2::new(0.0, 1.0),
376        ];
377        let aff = AffineTransform2D::from_triangle_correspondence(tri, tri).unwrap();
378        let p = Point2::new(0.3, 0.4);
379        let result = aff.apply(p);
380        assert!((result.x - p.x).abs() < 1e-10);
381        assert!((result.y - p.y).abs() < 1e-10);
382    }
383
384    #[test]
385    fn affine_maps_vertices_correctly() {
386        let src = [
387            Point2::new(0.0, 0.0),
388            Point2::new(1.0, 0.0),
389            Point2::new(0.0, 1.0),
390        ];
391        let dst = [
392            Point2::new(10.0, 20.0),
393            Point2::new(30.0, 20.0),
394            Point2::new(10.0, 50.0),
395        ];
396        let aff = AffineTransform2D::from_triangle_correspondence(src, dst).unwrap();
397        for (s, d) in src.iter().zip(dst.iter()) {
398            let result = aff.apply(*s);
399            assert!((result.x - d.x).abs() < 1e-10);
400            assert!((result.y - d.y).abs() < 1e-10);
401        }
402    }
403
404    #[test]
405    fn degenerate_triangle_returns_none() {
406        let src = [
407            Point2::new(0.0, 0.0),
408            Point2::new(1.0, 0.0),
409            Point2::new(2.0, 0.0), // collinear
410        ];
411        let dst = src;
412        assert!(AffineTransform2D::from_triangle_correspondence(src, dst).is_none());
413    }
414
415    #[test]
416    fn mesh_from_regular_hex_grid() {
417        let corners = make_hex_corners(3, 60.0);
418        let mesh = HexGridHomographyMesh::from_corners(&corners, 60.0).unwrap();
419        assert!(mesh.valid_triangles > 0);
420        assert!(mesh.rect_width > 0);
421        assert!(mesh.rect_height > 0);
422    }
423
424    #[test]
425    fn round_trip_through_affine_mesh() {
426        let spacing = 60.0;
427        let corners = make_hex_corners(3, spacing);
428        let mesh = HexGridHomographyMesh::from_corners(&corners, spacing).unwrap();
429
430        // Test that known corner positions round-trip through the mesh
431        let s = spacing as f64;
432
433        // Verify that corners at known positions map back reasonably
434        for (g, &img_pos) in &corners {
435            let rx = (s * (g.i as f64 + g.j as f64 * 0.5) - mesh.x_offset) as f32;
436            let ry = (s * (g.j as f64 * SQRT3_HALF) - mesh.y_offset) as f32;
437            let rect_pt = Point2::new(rx, ry);
438
439            if let Some(recovered) = mesh.rect_to_img_affine(rect_pt) {
440                assert!(
441                    (recovered.x - img_pos.x).abs() < 1.0,
442                    "x mismatch at ({},{}): {} vs {}",
443                    g.i,
444                    g.j,
445                    recovered.x,
446                    img_pos.x,
447                );
448                assert!(
449                    (recovered.y - img_pos.y).abs() < 1.0,
450                    "y mismatch at ({},{}): {} vs {}",
451                    g.i,
452                    g.j,
453                    recovered.y,
454                    img_pos.y,
455                );
456            }
457            // Some boundary corners may not have a valid triangle cell — that's OK
458        }
459    }
460
461    #[test]
462    fn round_trip_through_homography_mesh() {
463        let spacing = 60.0;
464        let corners = make_hex_corners(3, spacing);
465        let mesh = HexGridHomographyMesh::from_corners(&corners, spacing).unwrap();
466
467        let s = spacing as f64;
468
469        for (g, &img_pos) in &corners {
470            let rx = (s * (g.i as f64 + g.j as f64 * 0.5) - mesh.x_offset) as f32;
471            let ry = (s * (g.j as f64 * SQRT3_HALF) - mesh.y_offset) as f32;
472            let rect_pt = Point2::new(rx, ry);
473
474            if let Some(recovered) = mesh.rect_to_img(rect_pt) {
475                assert!(
476                    (recovered.x - img_pos.x).abs() < 1.0,
477                    "homography x mismatch at ({},{}): {} vs {}",
478                    g.i,
479                    g.j,
480                    recovered.x,
481                    img_pos.x,
482                );
483                assert!(
484                    (recovered.y - img_pos.y).abs() < 1.0,
485                    "homography y mismatch at ({},{}): {} vs {}",
486                    g.i,
487                    g.j,
488                    recovered.y,
489                    img_pos.y,
490                );
491            }
492        }
493    }
494
495    #[test]
496    fn too_few_corners_errors() {
497        let mut corners = HashMap::new();
498        corners.insert(GridIndex { i: 0, j: 0 }, Point2::new(0.0, 0.0));
499        corners.insert(GridIndex { i: 1, j: 0 }, Point2::new(50.0, 0.0));
500
501        let result = HexGridHomographyMesh::from_corners(&corners, 50.0);
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn missing_corners_handled_gracefully() {
507        let mut corners = make_hex_corners(3, 60.0);
508        // Remove some corners
509        corners.remove(&GridIndex { i: 0, j: 0 });
510        corners.remove(&GridIndex { i: 1, j: 1 });
511
512        let mesh = HexGridHomographyMesh::from_corners(&corners, 60.0);
513        // Should still succeed (just with fewer valid triangles)
514        assert!(mesh.is_ok());
515        let mesh = mesh.unwrap();
516        assert!(mesh.valid_triangles > 0);
517    }
518}