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