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}