Skip to main content

projective_grid/lattice/
mod.rs

1//! Lattice-family axis: the parameter that the strategies and the shared
2//! back-half are written against, rather than a copy per family.
3//!
4//! This module hosts the family-agnostic coordinate types ([`Coord`],
5//! [`GridDimensions`], [`GridTransform`]), the [`LatticeKind`] selector, and
6//! the [`Lattice`] trait that captures the per-family geometry a recovery
7//! pipeline needs: how a lattice coordinate maps into the model plane, the
8//! cardinal neighbour offsets, and the coordinate symmetry group.
9//!
10//! Today only [`Square`] is implemented; [`Hex`] is a
11//! roadmap stub (see `docs/DESIGN.md` "Extending to hex"). Both the strategies
12//! and `shared::fit` reach the geometry through [`LatticeKind`] /
13//! [`Lattice::model_point`], so adding hex detection is a fill-in-the-trait
14//! task rather than a new folder tree.
15
16use nalgebra::{Point2, Vector2};
17
18pub mod hex;
19pub mod predict;
20pub mod square;
21
22pub use hex::Hex;
23pub use predict::{predict_grid_position, PredictedPosition};
24pub use square::Square;
25
26/// How the topological pipeline turns Delaunay triangles into lattice cells.
27///
28/// The axis-driven topological grid finder triangulates the feature cloud and
29/// then has to recover the lattice cells from the triangle mesh. The recovery
30/// shape differs by family:
31///
32/// * On a **square** lattice a unit cell is a quad, which the Delaunay
33///   triangulation splits into two triangles sharing the cell **diagonal**.
34///   The pipeline classifies that diagonal, then merges the triangle pair back
35///   into one quad in the internal topological pipeline.
36/// * On a **hex** point lattice (one feature per lattice node) the Delaunay
37///   triangles **are** the unit cells — three mutually-adjacent nodes form an
38///   equilateral-ish triangle, and there is no diagonal class. The triangle-pair
39///   merge is bypassed entirely; each kept triangle is walked directly.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum CellTopology {
43    /// Merge diagonal-sharing triangle pairs into quads (square lattice).
44    TrianglePairToQuad,
45    /// Each Delaunay triangle is itself a unit cell (hex point lattice).
46    TriangleIsCell,
47}
48
49/// Integer coordinate on a lattice.
50///
51/// For square grids this is `(u, v) = (i, j)`. For hex grids this is axial
52/// `(u, v) = (q, r)`.
53///
54/// This is the canonical integer grid-coordinate type for the whole
55/// calibration-target workspace; it serializes as a `{ "u", "v" }` object.
56#[derive(
57    Clone,
58    Copy,
59    Debug,
60    Default,
61    PartialEq,
62    Eq,
63    Hash,
64    PartialOrd,
65    Ord,
66    serde::Serialize,
67    serde::Deserialize,
68)]
69#[non_exhaustive]
70pub struct Coord {
71    /// First lattice coordinate: square `i`, or hex axial `q`.
72    pub u: i32,
73    /// Second lattice coordinate: square `j`, or hex axial `r`.
74    pub v: i32,
75}
76
77impl Coord {
78    /// Construct a coordinate from two integer components.
79    pub const fn new(u: i32, v: i32) -> Self {
80        Self { u, v }
81    }
82}
83
84/// Known maximum grid extent, counted in observable feature positions.
85///
86/// For a square chessboard this is the number of corner intersections, not
87/// the number of black/white cells. A partially visible detection may span
88/// fewer positions, but a returned coordinate span never exceeds these
89/// bounds after canonical orientation is chosen.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
91#[non_exhaustive]
92pub struct GridDimensions {
93    /// Maximum number of feature positions along the first lattice axis.
94    pub width: usize,
95    /// Maximum number of feature positions along the second lattice axis.
96    pub height: usize,
97}
98
99impl GridDimensions {
100    /// Construct maximum feature-position dimensions.
101    pub const fn new(width: usize, height: usize) -> Self {
102        Self { width, height }
103    }
104}
105
106/// Supported lattice families.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
108#[non_exhaustive]
109#[serde(rename_all = "snake_case")]
110pub enum LatticeKind {
111    /// Orthogonal square lattice.
112    Square,
113    /// Axial-coordinate hexagonal lattice.
114    Hex,
115}
116
117impl LatticeKind {
118    /// Map an integer lattice coordinate into the model plane.
119    ///
120    /// Square coordinates map to `(u, v)`. Hex axial coordinates map to
121    /// `(q + 0.5*r, sqrt(3)/2*r)`, using unit nearest-neighbour spacing in the
122    /// model plane.
123    ///
124    /// This dispatches to the [`Lattice::model_point`] of the family impl, so
125    /// callers holding only a [`LatticeKind`] need not name the concrete family
126    /// type.
127    pub fn model_point(self, coord: Coord) -> Point2<f32> {
128        match self {
129            Self::Square => Square.model_point(coord),
130            Self::Hex => Hex.model_point(coord),
131        }
132    }
133
134    /// Cardinal neighbour offsets for this family (4 for square, 6 for hex).
135    pub fn neighbour_offsets(self) -> &'static [Coord] {
136        match self {
137            Self::Square => Square.neighbour_offsets(),
138            Self::Hex => Hex.neighbour_offsets(),
139        }
140    }
141
142    /// The coordinate symmetry group for this family (D4 for square,
143    /// D6 for hex).
144    pub fn symmetry_transforms(self) -> &'static [GridTransform] {
145        match self {
146            Self::Square => Square.symmetry_transforms(),
147            Self::Hex => Hex.symmetry_transforms(),
148        }
149    }
150
151    /// Number of distinct axis families: 2 for square (`±u`, `±v`), 3 for hex
152    /// (the three axial directions). This is the `k` the topological
153    /// classifier matches each edge against.
154    pub fn axis_family_count(self) -> usize {
155        match self {
156            Self::Square => Square.axis_family_count(),
157            Self::Hex => Hex.axis_family_count(),
158        }
159    }
160
161    /// Unit model-plane directions of the `k` primitive axis families.
162    ///
163    /// Returns one direction per family (`axis_family_count()` of them), each a
164    /// unit vector in the model plane. For square these are `(1,0)` and `(0,1)`;
165    /// for hex they are the three axial step directions folded into the upper
166    /// half-plane. The topological pipeline uses these as the canonical
167    /// orientation targets when synthesizing per-corner axes.
168    pub fn model_axis_directions(self) -> &'static [Vector2<f32>] {
169        match self {
170            Self::Square => Square.model_axis_directions(),
171            Self::Hex => Hex.model_axis_directions(),
172        }
173    }
174
175    /// How Delaunay triangles map to lattice cells for this family.
176    pub fn cell_topology(self) -> CellTopology {
177        match self {
178            Self::Square => Square.cell_topology(),
179            Self::Hex => Hex.cell_topology(),
180        }
181    }
182}
183
184/// Crate-private sealing for [`Lattice`].
185///
186/// External crates can *name* and *use* [`Lattice`] (it appears in the public
187/// API of the shared back-half) but cannot *implement* it. This lets the
188/// trait grow new required methods in later phases — the hex-detection axes,
189/// the cell-type discriminant, etc. (see `docs/DESIGN.md` "Extending to hex")
190/// — without those additions being a breaking change for downstream impls,
191/// because the only impls are the two zero-sized markers in this crate.
192mod private {
193    /// Sealed-trait marker. Implemented only for the in-crate lattice markers.
194    pub trait Sealed {}
195
196    impl Sealed for super::Square {}
197    impl Sealed for super::Hex {}
198}
199
200/// Per-family lattice geometry.
201///
202/// A [`Lattice`] impl supplies the geometry a recovery pipeline needs without
203/// hard-coding the family: how a coordinate maps into the model plane, the
204/// cardinal neighbour offsets used to walk the graph, and the coordinate
205/// symmetry group used by component merge. The shared back-half and (in the
206/// hex roadmap) the strategy skeletons are written against this trait so a new
207/// family is added by implementing the trait, not by copying machinery.
208///
209/// Implementations are zero-sized markers ([`Square`], [`Hex`]); the
210/// [`LatticeKind`] enum is the runtime selector that dispatches to them.
211///
212/// # Sealed
213///
214/// This trait is **sealed**: it has a crate-private supertrait
215/// (`private::Sealed`) so only the two in-crate markers can implement it.
216/// The seal is deliberate — extending hex detection adds new required methods
217/// (axis-family count, model-plane axis directions, cell-type discriminant).
218/// Because no external crate can implement `Lattice`, those additions are
219/// non-breaking. External callers depend on `Lattice` only as a
220/// bound / through [`LatticeKind`] dispatch, never as an impl target.
221pub trait Lattice: Copy + private::Sealed {
222    /// The [`LatticeKind`] this impl corresponds to.
223    const KIND: LatticeKind;
224
225    /// Map an integer lattice coordinate into the model plane (unit
226    /// nearest-neighbour spacing).
227    fn model_point(self, coord: Coord) -> Point2<f32>;
228
229    /// Cardinal neighbour offsets used to walk between adjacent lattice
230    /// coordinates.
231    fn neighbour_offsets(self) -> &'static [Coord];
232
233    /// The coordinate symmetry group (dihedral) for this family.
234    fn symmetry_transforms(self) -> &'static [GridTransform];
235
236    /// Number of distinct axis families (`k`): 2 for square, 3 for hex.
237    fn axis_family_count(self) -> usize;
238
239    /// Unit model-plane directions of the `k` primitive axis families.
240    fn model_axis_directions(self) -> &'static [Vector2<f32>];
241
242    /// How Delaunay triangles map to lattice cells for this family.
243    fn cell_topology(self) -> CellTopology;
244}
245
246/// An affine integer lattice-coordinate transform:
247/// `destination = matrix * source + translation`.
248///
249/// The source and destination coordinates belong to the same [`LatticeKind`].
250/// Pure D4/D6 symmetry transforms have zero translation; target-detector
251/// alignments use the same type with a board-frame translation. This type
252/// contains no image-space or pixel-space coordinates.
253#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
254#[non_exhaustive]
255pub struct GridTransform {
256    lattice: LatticeKind,
257    matrix: [[i32; 2]; 2],
258    translation: [i32; 2],
259}
260
261impl GridTransform {
262    /// The identity transform for a square lattice.
263    ///
264    /// Prefer [`Self::identity`] when the lattice family is dynamic. This
265    /// constant exists for the workspace's square-target result defaults.
266    pub const IDENTITY: Self = Self::identity(LatticeKind::Square);
267
268    /// Construct a lattice transform from its family, row-major linear part,
269    /// and destination-frame translation.
270    pub const fn new(lattice: LatticeKind, matrix: [[i32; 2]; 2], translation: [i32; 2]) -> Self {
271        Self {
272            lattice,
273            matrix,
274            translation,
275        }
276    }
277
278    /// Construct the identity transform for `lattice`.
279    pub const fn identity(lattice: LatticeKind) -> Self {
280        Self::new(lattice, [[1, 0], [0, 1]], [0, 0])
281    }
282
283    /// Lattice family of both the source and destination coordinates.
284    pub const fn lattice(self) -> LatticeKind {
285        self.lattice
286    }
287
288    /// Row-major 2x2 integer linear part.
289    pub const fn matrix(self) -> [[i32; 2]; 2] {
290        self.matrix
291    }
292
293    /// Destination-frame translation applied after the linear part.
294    pub const fn translation(self) -> [i32; 2] {
295        self.translation
296    }
297
298    /// Return this transform with a replacement destination-frame translation.
299    pub const fn with_translation(self, translation: [i32; 2]) -> Self {
300        Self {
301            translation,
302            ..self
303        }
304    }
305
306    /// Apply this transform to a coordinate.
307    pub fn apply(self, coord: Coord) -> Coord {
308        Coord {
309            u: self.matrix[0][0] * coord.u + self.matrix[0][1] * coord.v + self.translation[0],
310            v: self.matrix[1][0] * coord.u + self.matrix[1][1] * coord.v + self.translation[1],
311        }
312    }
313
314    /// Determinant of the linear part.
315    pub const fn determinant(self) -> i32 {
316        self.matrix[0][0] * self.matrix[1][1] - self.matrix[0][1] * self.matrix[1][0]
317    }
318
319    /// Invert this transform when its linear part is unimodular
320    /// (`determinant == ±1`).
321    ///
322    /// Returns `None` for a non-bijective integer transform. The inverse maps
323    /// destination-frame coordinates back into the original source frame.
324    pub fn inverse(self) -> Option<Self> {
325        let det = self.determinant();
326        if det != 1 && det != -1 {
327            return None;
328        }
329        let matrix = [
330            [self.matrix[1][1] / det, -self.matrix[0][1] / det],
331            [-self.matrix[1][0] / det, self.matrix[0][0] / det],
332        ];
333        let translation = [
334            -(matrix[0][0] * self.translation[0] + matrix[0][1] * self.translation[1]),
335            -(matrix[1][0] * self.translation[0] + matrix[1][1] * self.translation[1]),
336        ];
337        Some(Self::new(self.lattice, matrix, translation))
338    }
339}
340
341/// Four cardinal neighbour offsets on a square grid.
342pub const SQUARE_CARDINAL_OFFSETS: [Coord; 4] = square::SQUARE_CARDINAL_OFFSETS;
343
344/// Six axial neighbour offsets on a hex grid.
345pub const HEX_AXIAL_OFFSETS: [Coord; 6] = hex::HEX_AXIAL_OFFSETS;
346
347/// Dihedral group D4 acting on square lattice coordinates.
348pub const D4_TRANSFORMS: [GridTransform; 8] = square::D4_TRANSFORMS;
349
350/// Dihedral group D6 acting on hex axial coordinates.
351pub const D6_TRANSFORMS: [GridTransform; 12] = hex::D6_TRANSFORMS;
352
353#[cfg(test)]
354mod tests {
355    use std::collections::HashSet;
356
357    use super::*;
358
359    #[test]
360    fn square_model_mapping_is_cartesian() {
361        let p = LatticeKind::Square.model_point(Coord::new(2, -3));
362        assert_eq!(p, Point2::new(2.0, -3.0));
363    }
364
365    #[test]
366    fn hex_model_mapping_is_axial() {
367        let p = LatticeKind::Hex.model_point(Coord::new(1, 2));
368        assert!((p.x - 2.0).abs() < 1e-6);
369        assert!((p.y - 3.0_f32.sqrt()).abs() < 1e-6);
370    }
371
372    #[test]
373    fn kind_dispatch_matches_trait_impls() {
374        let c = Coord::new(3, -1);
375        assert_eq!(LatticeKind::Square.model_point(c), Square.model_point(c));
376        assert_eq!(LatticeKind::Hex.model_point(c), Hex.model_point(c));
377        assert_eq!(
378            LatticeKind::Square.neighbour_offsets(),
379            Square.neighbour_offsets()
380        );
381        assert_eq!(
382            LatticeKind::Square.symmetry_transforms().len(),
383            D4_TRANSFORMS.len()
384        );
385        assert_eq!(
386            LatticeKind::Hex.symmetry_transforms().len(),
387            D6_TRANSFORMS.len()
388        );
389    }
390
391    #[test]
392    fn d4_table_is_complete() {
393        let set: HashSet<_> = D4_TRANSFORMS.iter().map(|t| t.matrix()).collect();
394        assert_eq!(set.len(), 8);
395        assert!(D4_TRANSFORMS
396            .iter()
397            .all(|t| t.lattice() == LatticeKind::Square && t.determinant().abs() == 1));
398    }
399
400    #[test]
401    fn affine_inverse_round_trips_coordinates() {
402        let transform = D4_TRANSFORMS[3].with_translation([7, -11]);
403        let inverse = transform.inverse().expect("D4 transform is unimodular");
404        let source = Coord::new(-5, 13);
405        assert_eq!(inverse.apply(transform.apply(source)), source);
406        assert_eq!(transform.apply(inverse.apply(source)), source);
407        assert_eq!(inverse.lattice(), LatticeKind::Square);
408    }
409
410    #[test]
411    fn non_unimodular_transform_has_no_integer_inverse() {
412        let transform = GridTransform::new(LatticeKind::Square, [[2, 0], [0, 1]], [0, 0]);
413        assert_eq!(transform.inverse(), None);
414    }
415
416    #[test]
417    fn affine_transform_has_one_canonical_serde_shape() {
418        let transform = D4_TRANSFORMS[1].with_translation([3, -4]);
419        let json = serde_json::to_value(transform).expect("serialize transform");
420        assert_eq!(
421            json,
422            serde_json::json!({
423                "lattice": "square",
424                "matrix": [[0, -1], [1, 0]],
425                "translation": [3, -4]
426            })
427        );
428        assert_eq!(
429            serde_json::from_value::<GridTransform>(json).expect("deserialize transform"),
430            transform
431        );
432    }
433
434    #[test]
435    fn axis_family_counts() {
436        assert_eq!(LatticeKind::Square.axis_family_count(), 2);
437        assert_eq!(LatticeKind::Hex.axis_family_count(), 3);
438    }
439
440    #[test]
441    fn cell_topology_by_family() {
442        assert_eq!(
443            LatticeKind::Square.cell_topology(),
444            CellTopology::TrianglePairToQuad
445        );
446        assert_eq!(
447            LatticeKind::Hex.cell_topology(),
448            CellTopology::TriangleIsCell
449        );
450    }
451
452    #[test]
453    fn model_axis_directions_are_unit_and_match_offsets() {
454        // Square: the two axis directions are the +u/+v unit vectors.
455        let sq = LatticeKind::Square.model_axis_directions();
456        assert_eq!(sq.len(), 2);
457        for v in sq {
458            assert!((v.norm() - 1.0).abs() < 1e-6);
459        }
460        // Hex: three unit directions at 0°, 60°, 120° (mod π).
461        let hx = LatticeKind::Hex.model_axis_directions();
462        assert_eq!(hx.len(), 3);
463        for v in hx {
464            assert!((v.norm() - 1.0).abs() < 1e-6);
465        }
466        // The first hex axis direction must equal the folded model direction
467        // of the (1,0) axial offset.
468        let d_q = LatticeKind::Hex.model_point(Coord::new(1, 0))
469            - LatticeKind::Hex.model_point(Coord::new(0, 0));
470        let ang_offset = d_q.y.atan2(d_q.x);
471        let ang_dir = hx[0].y.atan2(hx[0].x);
472        let diff = (ang_offset - ang_dir).abs() % std::f32::consts::PI;
473        assert!(diff < 1e-5 || (std::f32::consts::PI - diff) < 1e-5);
474    }
475
476    #[test]
477    fn d6_table_is_complete() {
478        let set: HashSet<_> = D6_TRANSFORMS.iter().map(|t| t.matrix()).collect();
479        assert_eq!(set.len(), 12);
480        assert!(D6_TRANSFORMS
481            .iter()
482            .all(|t| t.lattice() == LatticeKind::Hex && t.determinant().abs() == 1));
483    }
484}