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