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