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 ([`crate::topological`] `quads.rs`).
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/// Optional known grid dimensions.
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
86#[non_exhaustive]
87pub struct GridDimensions {
88    /// Number of cells or feature positions along the first lattice axis.
89    pub width: usize,
90    /// Number of cells or feature positions along the second lattice axis.
91    pub height: usize,
92}
93
94impl GridDimensions {
95    /// Construct known grid dimensions.
96    pub const fn new(width: usize, height: usize) -> Self {
97        Self { width, height }
98    }
99}
100
101/// Supported lattice families.
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
103#[non_exhaustive]
104pub enum LatticeKind {
105    /// Orthogonal square lattice.
106    Square,
107    /// Axial-coordinate hexagonal lattice.
108    Hex,
109}
110
111impl LatticeKind {
112    /// Map an integer lattice coordinate into the model plane.
113    ///
114    /// Square coordinates map to `(u, v)`. Hex axial coordinates map to
115    /// `(q + 0.5*r, sqrt(3)/2*r)`, using unit nearest-neighbour spacing in the
116    /// model plane.
117    ///
118    /// This dispatches to the [`Lattice::model_point`] of the family impl, so
119    /// callers holding only a [`LatticeKind`] (e.g. `shared::fit`,
120    /// [`crate::check`]) need not name the concrete family type.
121    pub fn model_point(self, coord: Coord) -> Point2<f32> {
122        match self {
123            Self::Square => Square.model_point(coord),
124            Self::Hex => Hex.model_point(coord),
125        }
126    }
127
128    /// Cardinal neighbour offsets for this family (4 for square, 6 for hex).
129    pub fn neighbour_offsets(self) -> &'static [Coord] {
130        match self {
131            Self::Square => Square.neighbour_offsets(),
132            Self::Hex => Hex.neighbour_offsets(),
133        }
134    }
135
136    /// The coordinate symmetry group for this family (D4 for square,
137    /// D6 for hex).
138    pub fn symmetry_transforms(self) -> &'static [GridTransform] {
139        match self {
140            Self::Square => Square.symmetry_transforms(),
141            Self::Hex => Hex.symmetry_transforms(),
142        }
143    }
144
145    /// Number of distinct axis families: 2 for square (`±u`, `±v`), 3 for hex
146    /// (the three axial directions). This is the `k` the topological
147    /// classifier matches each edge against.
148    pub fn axis_family_count(self) -> usize {
149        match self {
150            Self::Square => Square.axis_family_count(),
151            Self::Hex => Hex.axis_family_count(),
152        }
153    }
154
155    /// Unit model-plane directions of the `k` primitive axis families.
156    ///
157    /// Returns one direction per family (`axis_family_count()` of them), each a
158    /// unit vector in the model plane. For square these are `(1,0)` and `(0,1)`;
159    /// for hex they are the three axial step directions folded into the upper
160    /// half-plane. The topological pipeline uses these as the canonical
161    /// orientation targets when synthesizing per-corner axes.
162    pub fn model_axis_directions(self) -> &'static [Vector2<f32>] {
163        match self {
164            Self::Square => Square.model_axis_directions(),
165            Self::Hex => Hex.model_axis_directions(),
166        }
167    }
168
169    /// How Delaunay triangles map to lattice cells for this family.
170    pub fn cell_topology(self) -> CellTopology {
171        match self {
172            Self::Square => Square.cell_topology(),
173            Self::Hex => Hex.cell_topology(),
174        }
175    }
176}
177
178/// Crate-private sealing for [`Lattice`].
179///
180/// External crates can *name* and *use* [`Lattice`] (it appears in the public
181/// API of the shared back-half) but cannot *implement* it. This lets the
182/// trait grow new required methods in later phases — the hex-detection axes,
183/// the cell-type discriminant, etc. (see `docs/DESIGN.md` "Extending to hex")
184/// — without those additions being a breaking change for downstream impls,
185/// because the only impls are the two zero-sized markers in this crate.
186mod private {
187    /// Sealed-trait marker. Implemented only for the in-crate lattice markers.
188    pub trait Sealed {}
189
190    impl Sealed for super::Square {}
191    impl Sealed for super::Hex {}
192}
193
194/// Per-family lattice geometry.
195///
196/// A [`Lattice`] impl supplies the geometry a recovery pipeline needs without
197/// hard-coding the family: how a coordinate maps into the model plane, the
198/// cardinal neighbour offsets used to walk the graph, and the coordinate
199/// symmetry group used by component merge. The shared back-half and (in the
200/// hex roadmap) the strategy skeletons are written against this trait so a new
201/// family is added by implementing the trait, not by copying machinery.
202///
203/// Implementations are zero-sized markers ([`Square`], [`Hex`]); the
204/// [`LatticeKind`] enum is the runtime selector that dispatches to them.
205///
206/// # Sealed
207///
208/// This trait is **sealed**: it has a crate-private supertrait
209/// (`private::Sealed`) so only the two in-crate markers can implement it.
210/// The seal is deliberate — extending hex detection adds new required methods
211/// (axis-family count, model-plane axis directions, cell-type discriminant).
212/// Because no external crate can implement `Lattice`, those additions are
213/// non-breaking. External callers depend on `Lattice` only as a
214/// bound / through [`LatticeKind`] dispatch, never as an impl target.
215pub trait Lattice: Copy + private::Sealed {
216    /// The [`LatticeKind`] this impl corresponds to.
217    const KIND: LatticeKind;
218
219    /// Map an integer lattice coordinate into the model plane (unit
220    /// nearest-neighbour spacing).
221    fn model_point(self, coord: Coord) -> Point2<f32>;
222
223    /// Cardinal neighbour offsets used to walk between adjacent lattice
224    /// coordinates.
225    fn neighbour_offsets(self) -> &'static [Coord];
226
227    /// The coordinate symmetry group (dihedral) for this family.
228    fn symmetry_transforms(self) -> &'static [GridTransform];
229
230    /// Number of distinct axis families (`k`): 2 for square, 3 for hex.
231    fn axis_family_count(self) -> usize;
232
233    /// Unit model-plane directions of the `k` primitive axis families.
234    fn model_axis_directions(self) -> &'static [Vector2<f32>];
235
236    /// How Delaunay triangles map to lattice cells for this family.
237    fn cell_topology(self) -> CellTopology;
238}
239
240/// A lattice-coordinate symmetry transform: `out = matrix * coord + offset`.
241#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
242#[non_exhaustive]
243pub struct GridTransform {
244    /// Lattice family this transform belongs to.
245    pub source_kind: LatticeKind,
246    /// Row-major 2x2 integer linear part.
247    pub matrix: [[i32; 2]; 2],
248    /// Integer offset applied after the linear part.
249    pub offset: [i32; 2],
250}
251
252impl GridTransform {
253    /// Construct a lattice transform from raw components.
254    pub const fn new(source_kind: LatticeKind, matrix: [[i32; 2]; 2], offset: [i32; 2]) -> Self {
255        Self {
256            source_kind,
257            matrix,
258            offset,
259        }
260    }
261
262    /// Apply this transform to a coordinate.
263    pub fn apply(self, coord: Coord) -> Coord {
264        Coord {
265            u: self.matrix[0][0] * coord.u + self.matrix[0][1] * coord.v + self.offset[0],
266            v: self.matrix[1][0] * coord.u + self.matrix[1][1] * coord.v + self.offset[1],
267        }
268    }
269
270    /// Determinant of the linear part.
271    pub const fn determinant(self) -> i32 {
272        self.matrix[0][0] * self.matrix[1][1] - self.matrix[0][1] * self.matrix[1][0]
273    }
274}
275
276/// Four cardinal neighbour offsets on a square grid.
277pub const SQUARE_CARDINAL_OFFSETS: [Coord; 4] = square::SQUARE_CARDINAL_OFFSETS;
278
279/// Six axial neighbour offsets on a hex grid.
280pub const HEX_AXIAL_OFFSETS: [Coord; 6] = hex::HEX_AXIAL_OFFSETS;
281
282/// Dihedral group D4 acting on square lattice coordinates.
283pub const D4_TRANSFORMS: [GridTransform; 8] = square::D4_TRANSFORMS;
284
285/// Dihedral group D6 acting on hex axial coordinates.
286pub const D6_TRANSFORMS: [GridTransform; 12] = hex::D6_TRANSFORMS;
287
288#[cfg(test)]
289mod tests {
290    use std::collections::HashSet;
291
292    use super::*;
293
294    #[test]
295    fn square_model_mapping_is_cartesian() {
296        let p = LatticeKind::Square.model_point(Coord::new(2, -3));
297        assert_eq!(p, Point2::new(2.0, -3.0));
298    }
299
300    #[test]
301    fn hex_model_mapping_is_axial() {
302        let p = LatticeKind::Hex.model_point(Coord::new(1, 2));
303        assert!((p.x - 2.0).abs() < 1e-6);
304        assert!((p.y - 3.0_f32.sqrt()).abs() < 1e-6);
305    }
306
307    #[test]
308    fn kind_dispatch_matches_trait_impls() {
309        let c = Coord::new(3, -1);
310        assert_eq!(LatticeKind::Square.model_point(c), Square.model_point(c));
311        assert_eq!(LatticeKind::Hex.model_point(c), Hex.model_point(c));
312        assert_eq!(
313            LatticeKind::Square.neighbour_offsets(),
314            Square.neighbour_offsets()
315        );
316        assert_eq!(
317            LatticeKind::Square.symmetry_transforms().len(),
318            D4_TRANSFORMS.len()
319        );
320        assert_eq!(
321            LatticeKind::Hex.symmetry_transforms().len(),
322            D6_TRANSFORMS.len()
323        );
324    }
325
326    #[test]
327    fn d4_table_is_complete() {
328        let set: HashSet<_> = D4_TRANSFORMS.iter().map(|t| t.matrix).collect();
329        assert_eq!(set.len(), 8);
330        assert!(D4_TRANSFORMS
331            .iter()
332            .all(|t| t.source_kind == LatticeKind::Square && t.determinant().abs() == 1));
333    }
334
335    #[test]
336    fn axis_family_counts() {
337        assert_eq!(LatticeKind::Square.axis_family_count(), 2);
338        assert_eq!(LatticeKind::Hex.axis_family_count(), 3);
339    }
340
341    #[test]
342    fn cell_topology_by_family() {
343        assert_eq!(
344            LatticeKind::Square.cell_topology(),
345            CellTopology::TrianglePairToQuad
346        );
347        assert_eq!(
348            LatticeKind::Hex.cell_topology(),
349            CellTopology::TriangleIsCell
350        );
351    }
352
353    #[test]
354    fn model_axis_directions_are_unit_and_match_offsets() {
355        // Square: the two axis directions are the +u/+v unit vectors.
356        let sq = LatticeKind::Square.model_axis_directions();
357        assert_eq!(sq.len(), 2);
358        for v in sq {
359            assert!((v.norm() - 1.0).abs() < 1e-6);
360        }
361        // Hex: three unit directions at 0°, 60°, 120° (mod π).
362        let hx = LatticeKind::Hex.model_axis_directions();
363        assert_eq!(hx.len(), 3);
364        for v in hx {
365            assert!((v.norm() - 1.0).abs() < 1e-6);
366        }
367        // The first hex axis direction must equal the folded model direction
368        // of the (1,0) axial offset.
369        let d_q = LatticeKind::Hex.model_point(Coord::new(1, 0))
370            - LatticeKind::Hex.model_point(Coord::new(0, 0));
371        let ang_offset = d_q.y.atan2(d_q.x);
372        let ang_dir = hx[0].y.atan2(hx[0].x);
373        let diff = (ang_offset - ang_dir).abs() % std::f32::consts::PI;
374        assert!(diff < 1e-5 || (std::f32::consts::PI - diff) < 1e-5);
375    }
376
377    #[test]
378    fn d6_table_is_complete() {
379        let set: HashSet<_> = D6_TRANSFORMS.iter().map(|t| t.matrix).collect();
380        assert_eq!(set.len(), 12);
381        assert!(D6_TRANSFORMS
382            .iter()
383            .all(|t| t.source_kind == LatticeKind::Hex && t.determinant().abs() == 1));
384    }
385}