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}