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}