projective_grid/shared/merge.rs
1//! Local-geometry-only component merge.
2//!
3//! The topological pipeline can leave multiple disconnected grid
4//! components when a board is partially occluded, when a line of
5//! corners drops below the strength threshold, or when topological
6//! filtering removes a noisy quad in the middle of the board. This
7//! module attempts to reunite components in label space.
8//!
9//! The merge is lattice-parameterized: [`merge_components_local`] uses the
10//! square symmetry group (D4) for byte-compatibility with the square facades;
11//! [`merge_components_local_for`] takes a [`LatticeKind`] and uses its symmetry
12//! group (D6 for hex — a hex relabelling has 12 automorphisms).
13//!
14//! # Acceptance criterion
15//!
16//! Local geometry only — never a global homography fit. Strong radial
17//! distortion can break a single global homography across the whole
18//! board, so we score component pairs purely from agreement between
19//! corners that should coincide after a candidate alignment:
20//!
21//! - **Per-component cell size** (median nearest-neighbour distance
22//! along the component's `i` and `j` axes) must agree within
23//! `cell_size_ratio_tol`.
24//! - **Per-corner positions** of overlapping labels must agree within
25//! `position_tol_rel * mean_cell_size` pixels.
26//! - **Overlap count** must reach `min_overlap`.
27//!
28//! Component reorientation uses the symmetry group of the lattice (the eight
29//! elements of D4 for square, the twelve of D6 for hex). The translation is
30//! fixed by an anchor-pair correspondence; we try every anchor pair from each
31//! component to find the best alignment.
32//!
33//! # Out-of-scope (v1)
34//!
35//! Disjoint label sets with no overlap. Such pairs are common when an
36//! entire row of corners is missing. The current implementation rejects
37//! them; extend by adding a "predict-next-corner" check that compares
38//! one component's predicted boundary position to the other's actual
39//! boundary corner.
40
41use std::collections::HashMap;
42
43use kiddo::{KdTree, SquaredEuclidean};
44use nalgebra::Point2;
45use serde::{Deserialize, Serialize};
46
47use crate::lattice::{Coord, GridTransform, LatticeKind, D4_TRANSFORMS};
48
49// Preserve the historical square-facade tie-break order while deriving every
50// transform from the canonical lattice table.
51const GRID_TRANSFORMS_D4: [GridTransform; 8] = [
52 D4_TRANSFORMS[0],
53 D4_TRANSFORMS[3],
54 D4_TRANSFORMS[2],
55 D4_TRANSFORMS[1],
56 D4_TRANSFORMS[4],
57 D4_TRANSFORMS[5],
58 D4_TRANSFORMS[6],
59 D4_TRANSFORMS[7],
60];
61
62/// Tuning knobs for [`merge_components_local`].
63#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct LocalMergeParams {
66 /// Position tolerance for accepting two corners as the same physical
67 /// point, expressed as a fraction of the mean per-component cell
68 /// size in pixels. Default: `0.20`.
69 pub position_tol_rel: f32,
70 /// Cell-size agreement tolerance: `|s_p - s_q| / max(s_p, s_q)` must
71 /// be ≤ this value to even attempt a merge. Default: `0.20`.
72 pub cell_size_ratio_tol: f32,
73 /// Minimum number of overlapping labels (after candidate alignment)
74 /// for a merge to be accepted. Default: `2`.
75 pub min_overlap: usize,
76 /// Upper bound on returned components after merging. Default: `4`.
77 pub max_components: usize,
78}
79
80impl Default for LocalMergeParams {
81 fn default() -> Self {
82 Self {
83 position_tol_rel: 0.20,
84 cell_size_ratio_tol: 0.20,
85 min_overlap: 2,
86 max_components: 4,
87 }
88 }
89}
90
91/// Slim view over one component's data for merging.
92#[derive(Clone, Copy, Debug)]
93pub struct ComponentInput<'a> {
94 /// `(i, j) → corner_idx` (indices into `positions`).
95 pub labelled: &'a HashMap<(i32, i32), usize>,
96 /// Corner positions in image pixels, indexed by the values of `labelled`.
97 pub positions: &'a [Point2<f32>],
98}
99
100/// Output of [`merge_components_local`].
101#[derive(Clone, Debug, Default)]
102pub struct ComponentMergeResult {
103 /// One labelling per surviving component. Each is rebased to start
104 /// at `(0, 0)`. Corners in the input may appear in multiple
105 /// components if alignment was ambiguous.
106 pub components: Vec<HashMap<(i32, i32), usize>>,
107 /// Counters describing how many components were merged.
108 pub diagnostics: ComponentMergeStats,
109}
110
111/// Diagnostics for a single merge call.
112#[derive(Clone, Copy, Debug, Default)]
113#[non_exhaustive]
114pub struct ComponentMergeStats {
115 /// Number of components supplied to the merge.
116 pub components_in: usize,
117 /// Number of components remaining after merging.
118 pub components_out: usize,
119 /// Number of pairwise merges that passed the geometry gate.
120 pub merges_accepted: usize,
121}
122
123fn euclidean(p: Point2<f32>, q: Point2<f32>) -> f32 {
124 ((p.x - q.x).powi(2) + (p.y - q.y).powi(2)).sqrt()
125}
126
127/// Median nearest-neighbour cell size along grid axes (i and j directions).
128/// Falls back to 0.0 if the component has fewer than two corners.
129fn estimate_cell_size(c: &ComponentInput<'_>) -> f32 {
130 let mut dists: Vec<f32> = Vec::new();
131 for (&(i, j), &idx) in c.labelled.iter() {
132 let p = c.positions[idx];
133 if let Some(&right) = c.labelled.get(&(i + 1, j)) {
134 dists.push(euclidean(p, c.positions[right]));
135 }
136 if let Some(&down) = c.labelled.get(&(i, j + 1)) {
137 dists.push(euclidean(p, c.positions[down]));
138 }
139 }
140 if dists.is_empty() {
141 return 0.0;
142 }
143 dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
144 dists[dists.len() / 2]
145}
146
147/// Apply D4 transform to label coordinates.
148#[inline]
149fn apply_transform(t: GridTransform, ij: (i32, i32)) -> (i32, i32) {
150 let v = t.apply(Coord::new(ij.0, ij.1));
151 (v.u, v.v)
152}
153
154/// For a candidate `(transform, delta)`, score the alignment by full
155/// label-space overlap.
156///
157/// Counts every `c_p` label whose `transform · ij_p + delta` exists as a
158/// key in `c_q.labelled` (regardless of pixel distance), and tracks the
159/// worst pixel-position disagreement among those overlapping label
160/// pairs. The histogram-based candidate enumeration in
161/// [`find_best_alignment`] only sees pairs already within `pos_tol`, so
162/// without this re-scoring an alignment whose label-space overlap
163/// includes one or more pairs *outside* `pos_tol` would silently merge.
164/// That would corrupt downstream calibration. Use this re-scoring as
165/// the precision gate before accepting a candidate.
166fn score_alignment(
167 c_p: &ComponentInput<'_>,
168 c_q: &ComponentInput<'_>,
169 t: GridTransform,
170 delta: (i32, i32),
171) -> (usize, f32) {
172 let mut overlap = 0usize;
173 let mut max_err = 0.0f32;
174 for (&ij_p, &idx_p) in c_p.labelled.iter() {
175 let ij_t = apply_transform(t, ij_p);
176 let ij_q = (ij_t.0 + delta.0, ij_t.1 + delta.1);
177 if let Some(&idx_q) = c_q.labelled.get(&ij_q) {
178 let err = euclidean(c_p.positions[idx_p], c_q.positions[idx_q]);
179 overlap += 1;
180 if err > max_err {
181 max_err = err;
182 }
183 }
184 }
185 (overlap, max_err)
186}
187
188/// Find the best (transform, offset) for merging `c_p` into `c_q`'s frame.
189///
190/// Two-pass strategy:
191///
192/// 1. **Hough enumeration.** Index `c_q`'s positions in a KD-tree, then
193/// for each label in `c_p` find every `c_q` label whose pixel
194/// position is within `pos_tol` and vote each match into a histogram
195/// bin keyed by the candidate `(transform, label-delta)`. This
196/// surfaces a small set of candidate alignments in `O(P log Q)`,
197/// replacing the previous `O(P² Q)` anchor enumeration.
198/// 2. **Full-overlap re-scoring.** Each surviving candidate is
199/// re-scored by [`score_alignment`] over the *full* label-space
200/// overlap (every `c_p` label whose `transform · ij_p + delta` is a
201/// key in `c_q.labelled`, regardless of pixel distance). The
202/// candidate is accepted only when the re-scored overlap meets
203/// `min_overlap` AND the re-scored `max_err` is within `pos_tol`.
204/// This is the precision gate: a histogram bin can pass with
205/// `min_overlap` position-close inliers even when other label-space
206/// overlaps under the same alignment sit far above tolerance, and
207/// accepting such an alignment would corrupt downstream calibration.
208/// Re-scoring catches that case.
209///
210/// The accepted candidate set is then ranked by
211/// `(overlap_full desc, max_err_full asc, transform_index asc,
212/// delta asc)` — a strict total order that matches the original
213/// algorithm's tiebreaker (which preferred identity by D4 iteration
214/// order).
215fn find_best_alignment(
216 c_p: &ComponentInput<'_>,
217 c_q: &ComponentInput<'_>,
218 cell_size: f32,
219 params: &LocalMergeParams,
220 transforms: &[GridTransform],
221) -> Option<(GridTransform, (i32, i32), usize)> {
222 let pos_tol = params.position_tol_rel * cell_size.max(1.0);
223 let pos_tol_sq = pos_tol * pos_tol;
224
225 // KD-tree over c_q label positions. The slot index maps back to
226 // q_entries[slot] = (ij_q, idx_q).
227 let q_entries: Vec<((i32, i32), usize)> = c_q.labelled.iter().map(|(k, v)| (*k, *v)).collect();
228 if q_entries.is_empty() {
229 return None;
230 }
231 let mut tree: KdTree<f32, 2> = KdTree::new();
232 for (slot, (_, idx)) in q_entries.iter().enumerate() {
233 let pos = c_q.positions[*idx];
234 tree.add(&[pos.x, pos.y], slot as u64);
235 }
236
237 // Pass 1: Hough enumeration. The bin counts position-close votes
238 // only — that's a *lower bound* on the full label-space overlap.
239 let mut hist: HashMap<(u8, i32, i32), usize> = HashMap::new();
240 for (&ij_p, &idx_p) in c_p.labelled.iter() {
241 let pos_p = c_p.positions[idx_p];
242 for nn in tree
243 .within_unsorted::<SquaredEuclidean>(&[pos_p.x, pos_p.y], pos_tol_sq)
244 .into_iter()
245 {
246 let slot = nn.item as usize;
247 let (ij_q, _idx_q) = q_entries[slot];
248 for (t_idx, t) in transforms.iter().enumerate() {
249 let tij_p = apply_transform(*t, ij_p);
250 let key = (t_idx as u8, ij_q.0 - tij_p.0, ij_q.1 - tij_p.1);
251 *hist.entry(key).or_insert(0usize) += 1;
252 }
253 }
254 }
255
256 // Pass 2: re-score each candidate over the full label-space
257 // overlap. A bin survives only when every `c_p` label that maps
258 // (under this t/δ) to a key in `c_q.labelled` is within `pos_tol`
259 // — see `score_alignment` for the precision contract.
260 //
261 // Tiebreaker: prefer higher overlap, then lower max_err, then
262 // smaller transform index (identity = 0, so identity wins ties),
263 // then lexicographic delta — matching the original algorithm's
264 // iteration order on highly symmetric synthetic test grids.
265 let mut best: Option<(u8, (i32, i32), usize, f32)> = None;
266 for (&(t_idx, di, dj), &kdtree_overlap) in &hist {
267 if kdtree_overlap < params.min_overlap {
268 // Histogram is a lower bound on the full overlap, but only
269 // for pairs already within `pos_tol`. A bin that fails the
270 // KD-tree-overlap floor cannot reach `min_overlap`
271 // position-close pairs and is rejected outright; we don't
272 // even bother re-scoring.
273 continue;
274 }
275 let t = transforms[t_idx as usize];
276 let delta = (di, dj);
277 let (overlap_full, max_err_full) = score_alignment(c_p, c_q, t, delta);
278 if overlap_full < params.min_overlap || max_err_full > pos_tol {
279 continue;
280 }
281 let take = match &best {
282 None => true,
283 Some((best_t_idx, best_delta, best_overlap, best_err)) => {
284 if overlap_full != *best_overlap {
285 overlap_full > *best_overlap
286 } else if (max_err_full - *best_err).abs() > f32::EPSILON {
287 max_err_full < *best_err
288 } else if t_idx != *best_t_idx {
289 t_idx < *best_t_idx
290 } else {
291 (di, dj) < *best_delta
292 }
293 }
294 };
295 if take {
296 best = Some((t_idx, (di, dj), overlap_full, max_err_full));
297 }
298 }
299 best.map(|(t_idx, d, n, _)| (transforms[t_idx as usize], d, n))
300}
301
302fn rebase(labelled: &mut HashMap<(i32, i32), usize>) {
303 if labelled.is_empty() {
304 return;
305 }
306 let min_i = labelled.keys().map(|(i, _)| *i).min().unwrap();
307 let min_j = labelled.keys().map(|(_, j)| *j).min().unwrap();
308 if min_i == 0 && min_j == 0 {
309 return;
310 }
311 let rebased: HashMap<(i32, i32), usize> = labelled
312 .drain()
313 .map(|((i, j), v)| ((i - min_i, j - min_j), v))
314 .collect();
315 *labelled = rebased;
316}
317
318/// Greedy local merge.
319///
320/// Strategy: estimate each component's cell size, then for every pair
321/// `(p, q)` (largest-first by labelled count), search for an
322/// alignment that satisfies the cell-size, overlap, and position
323/// tolerances. On success, rewrite `p`'s labels into `q`'s frame and
324/// merge into `q`. Repeat until no further merges are possible or the
325/// `max_components` cap is reached.
326#[cfg_attr(
327 feature = "tracing",
328 tracing::instrument(
329 level = "info",
330 skip_all,
331 fields(num_components = inputs.len()),
332 )
333)]
334pub fn merge_components_local(
335 inputs: &[ComponentInput<'_>],
336 params: &LocalMergeParams,
337) -> ComponentMergeResult {
338 // Default to the square symmetry group, preserving the historical
339 // byte-identical behaviour for the square callers (topological + seed-and-
340 // grow facades). The lattice-parameterized variant is
341 // [`merge_components_local_for`].
342 merge_components_local_with_transforms(inputs, params, &GRID_TRANSFORMS_D4)
343}
344
345/// Lattice-parameterized [`merge_components_local`]: reunite components under
346/// the symmetry group of `lattice` (D4 for square, D6 for hex). The hex path
347/// uses this so the 12 D6 relabellings of a hex component are all candidate
348/// alignments.
349pub fn merge_components_local_for(
350 inputs: &[ComponentInput<'_>],
351 params: &LocalMergeParams,
352 lattice: LatticeKind,
353) -> ComponentMergeResult {
354 merge_components_local_with_transforms(inputs, params, lattice.symmetry_transforms())
355}
356
357fn merge_components_local_with_transforms(
358 inputs: &[ComponentInput<'_>],
359 params: &LocalMergeParams,
360 transforms: &[GridTransform],
361) -> ComponentMergeResult {
362 let mut stats = ComponentMergeStats {
363 components_in: inputs.len(),
364 ..Default::default()
365 };
366 if inputs.is_empty() {
367 return ComponentMergeResult {
368 components: Vec::new(),
369 diagnostics: stats,
370 };
371 }
372
373 // Working copies.
374 let mut working: Vec<HashMap<(i32, i32), usize>> =
375 inputs.iter().map(|c| c.labelled.clone()).collect();
376 let positions_per: Vec<&[Point2<f32>]> = inputs.iter().map(|c| c.positions).collect();
377 let mut cell_sizes: Vec<f32> = inputs.iter().map(estimate_cell_size).collect();
378
379 let mut alive: Vec<bool> = vec![true; inputs.len()];
380 let mut changed = true;
381 while changed {
382 changed = false;
383 // Order alive components by size descending; bigger anchors are
384 // more reliable.
385 let mut order: Vec<usize> = (0..inputs.len()).filter(|i| alive[*i]).collect();
386 order.sort_by(|a, b| working[*b].len().cmp(&working[*a].len()));
387
388 'outer: for &i in &order {
389 for &j in &order {
390 if i == j || !alive[i] || !alive[j] {
391 continue;
392 }
393 // Cell-size sanity gate.
394 let s_i = cell_sizes[i].max(1e-3);
395 let s_j = cell_sizes[j].max(1e-3);
396 let ratio = (s_i - s_j).abs() / s_i.max(s_j);
397 if ratio > params.cell_size_ratio_tol {
398 continue;
399 }
400 let cell_size = 0.5 * (s_i + s_j);
401 let c_p = ComponentInput {
402 labelled: &working[i],
403 positions: positions_per[i],
404 };
405 let c_q = ComponentInput {
406 labelled: &working[j],
407 positions: positions_per[j],
408 };
409 let Some((t, delta, _overlap)) =
410 find_best_alignment(&c_p, &c_q, cell_size, params, transforms)
411 else {
412 continue;
413 };
414 // Merge i into j (the larger component is j by ordering).
415 // For each label in i, transform to j's frame, insert if
416 // not already present (keeping j's value on conflict).
417 //
418 // `i` is killed immediately below (`alive[i] = false`) and its
419 // map is never read again — the final collection filters dead
420 // components — so move it out with `mem::take` instead of
421 // cloning. Byte-exact: i's keys are unique within its own map,
422 // and `or_insert` keeps j's value on any i↔j collision
423 // regardless of iteration order, so the merged result is
424 // independent of the order i's pairs are drained.
425 for (ij, idx_i) in std::mem::take(&mut working[i]) {
426 let tij = apply_transform(t, ij);
427 let key = (tij.0 + delta.0, tij.1 + delta.1);
428 working[j].entry(key).or_insert(idx_i);
429 }
430 alive[i] = false;
431 cell_sizes[j] = 0.5 * (cell_sizes[i] + cell_sizes[j]);
432 stats.merges_accepted += 1;
433 changed = true;
434 continue 'outer;
435 }
436 }
437 }
438
439 let mut out: Vec<HashMap<(i32, i32), usize>> = working
440 .into_iter()
441 .zip(alive.iter().copied())
442 .filter_map(|(m, a)| if a { Some(m) } else { None })
443 .collect();
444 // Sort by size desc, cap, rebase.
445 out.sort_by_key(|m| std::cmp::Reverse(m.len()));
446 out.truncate(params.max_components);
447 for m in &mut out {
448 rebase(m);
449 }
450 stats.components_out = out.len();
451 ComponentMergeResult {
452 components: out,
453 diagnostics: stats,
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 type Labels = HashMap<(i32, i32), usize>;
462 type Positions = Vec<Point2<f32>>;
463
464 fn component_5x5() -> (Labels, Positions) {
465 let mut labelled = HashMap::new();
466 let mut positions = Vec::new();
467 for j in 0..5 {
468 for i in 0..5 {
469 let idx = positions.len();
470 labelled.insert((i, j), idx);
471 positions.push(Point2::new(i as f32 * 10.0, j as f32 * 10.0));
472 }
473 }
474 (labelled, positions)
475 }
476
477 #[test]
478 fn identical_components_merge_into_one() {
479 let (l1, p1) = component_5x5();
480 let (l2, p2) = component_5x5();
481 let inputs = vec![
482 ComponentInput {
483 labelled: &l1,
484 positions: &p1,
485 },
486 ComponentInput {
487 labelled: &l2,
488 positions: &p2,
489 },
490 ];
491 let res = merge_components_local(&inputs, &LocalMergeParams::default());
492 assert_eq!(res.components.len(), 1);
493 assert_eq!(res.components[0].len(), 25);
494 assert_eq!(res.diagnostics.merges_accepted, 1);
495 }
496
497 #[test]
498 fn shifted_components_with_overlap_merge() {
499 // C1: labels (0..3, 0..5) at world (0..2, 0..4) * step
500 // C2: labels (0..3, 0..5) at world (3..5, 0..4) * step
501 // Overlap if we offset C2 by (2, 0): C1 cell (2, j) coincides with C2 cell (0, j) world-wise.
502 let step = 10.0;
503 let mut l1 = HashMap::new();
504 let mut p1 = Vec::new();
505 for j in 0..5 {
506 for i in 0..3 {
507 let idx = p1.len();
508 l1.insert((i, j), idx);
509 p1.push(Point2::new(i as f32 * step, j as f32 * step));
510 }
511 }
512 let mut l2 = HashMap::new();
513 let mut p2 = Vec::new();
514 for j in 0..5 {
515 for i in 0..3 {
516 let idx = p2.len();
517 l2.insert((i, j), idx);
518 p2.push(Point2::new((i as f32 + 2.0) * step, j as f32 * step));
519 }
520 }
521 let inputs = vec![
522 ComponentInput {
523 labelled: &l1,
524 positions: &p1,
525 },
526 ComponentInput {
527 labelled: &l2,
528 positions: &p2,
529 },
530 ];
531 let res = merge_components_local(&inputs, &LocalMergeParams::default());
532 assert_eq!(res.components.len(), 1);
533 // Combined unique labels: (0..5, 0..5) = 25.
534 assert_eq!(res.components[0].len(), 25);
535 }
536
537 #[test]
538 fn cell_size_mismatch_blocks_merge() {
539 let (l1, p1) = component_5x5();
540 // Same labels but positions stretched 2x — cell size differs by 2x.
541 let mut l2 = HashMap::new();
542 let mut p2 = Vec::new();
543 for j in 0..5 {
544 for i in 0..5 {
545 let idx = p2.len();
546 l2.insert((i, j), idx);
547 p2.push(Point2::new(i as f32 * 20.0, j as f32 * 20.0));
548 }
549 }
550 let inputs = vec![
551 ComponentInput {
552 labelled: &l1,
553 positions: &p1,
554 },
555 ComponentInput {
556 labelled: &l2,
557 positions: &p2,
558 },
559 ];
560 let res = merge_components_local(&inputs, &LocalMergeParams::default());
561 assert_eq!(res.components.len(), 2);
562 assert_eq!(res.diagnostics.merges_accepted, 0);
563 }
564
565 /// Regression for the precision contract: a histogram bin can pass
566 /// `min_overlap` on position-close votes alone while another
567 /// label-aligned pair under the same `(transform, delta)` sits far
568 /// outside `pos_tol`. Without the full-overlap re-score, the merge
569 /// would proceed and corrupt the grid labelling.
570 ///
571 /// Setup: two 2×2 components share three corners exactly, but one
572 /// corner has drifted ~5× the cell size in `c_q`. The histogram
573 /// counts three position-close votes for `(identity, (0, 0))` —
574 /// enough to clear `min_overlap = 2`. The full label-space
575 /// overlap is four with `max_err ≈ 56 px`, which the precision
576 /// gate must reject.
577 #[test]
578 fn drifted_overlapping_corner_blocks_merge() {
579 let cell = 10.0_f32;
580 // C1: 4 labels on the unit cell, exact positions.
581 let mut l1: Labels = HashMap::new();
582 let mut p1: Positions = Vec::new();
583 for j in 0..2 {
584 for i in 0..2 {
585 let idx = p1.len();
586 l1.insert((i, j), idx);
587 p1.push(Point2::new(i as f32 * cell, j as f32 * cell));
588 }
589 }
590 // C2: same labels, but the (1, 1) corner is drifted to (50, 50)
591 // — far outside `pos_tol = 0.20 × cell = 2.0` from c_p's (10, 10).
592 let mut l2: Labels = HashMap::new();
593 let mut p2: Positions = Vec::new();
594 for j in 0..2 {
595 for i in 0..2 {
596 let idx = p2.len();
597 l2.insert((i, j), idx);
598 let pos = if (i, j) == (1, 1) {
599 Point2::new(50.0, 50.0)
600 } else {
601 Point2::new(i as f32 * cell, j as f32 * cell)
602 };
603 p2.push(pos);
604 }
605 }
606 let inputs = vec![
607 ComponentInput {
608 labelled: &l1,
609 positions: &p1,
610 },
611 ComponentInput {
612 labelled: &l2,
613 positions: &p2,
614 },
615 ];
616 let res = merge_components_local(&inputs, &LocalMergeParams::default());
617 assert_eq!(
618 res.components.len(),
619 2,
620 "drifted corner should block the merge entirely"
621 );
622 assert_eq!(res.diagnostics.merges_accepted, 0);
623 }
624
625 // --- Hex (D6) merge -------------------------------------------------
626
627 fn hex_model(q: i32, r: i32) -> Point2<f32> {
628 let sqrt3_2 = 3.0_f32.sqrt() * 0.5;
629 Point2::new(q as f32 + 0.5 * r as f32, sqrt3_2 * r as f32)
630 }
631
632 /// Build a hex axial patch (radius `radius`) at pixel `scale`, with an axial
633 /// relabelling applied by `relabel` (a D6 element index 0..12) so the merge
634 /// must undo the automorphism. Positions are in model pixels regardless of
635 /// the relabelling (the physical points are the same).
636 fn hex_component(radius: i32, scale: f32, relabel: usize) -> (Labels, Positions) {
637 let t = crate::lattice::D6_TRANSFORMS[relabel];
638 let mut labelled = HashMap::new();
639 let mut positions = Vec::new();
640 for q in -radius..=radius {
641 for r in (-radius).max(-q - radius)..=radius.min(-q + radius) {
642 let idx = positions.len();
643 let m = hex_model(q, r);
644 positions.push(Point2::new(m.x * scale, m.y * scale));
645 let c = t.apply(Coord::new(q, r));
646 labelled.insert((c.u, c.v), idx);
647 }
648 }
649 (labelled, positions)
650 }
651
652 #[test]
653 fn hex_identical_components_merge_under_d6() {
654 // Two copies of the same hex patch, the second relabelled by a
655 // non-identity D6 element. The D6-aware merge must reunite them into
656 // one component (the D4-only merge would not find the alignment).
657 let (l1, p1) = hex_component(2, 14.0, 0);
658 let (l2, p2) = hex_component(2, 14.0, 4); // 120° rotation
659 let inputs = vec![
660 ComponentInput {
661 labelled: &l1,
662 positions: &p1,
663 },
664 ComponentInput {
665 labelled: &l2,
666 positions: &p2,
667 },
668 ];
669 let res =
670 merge_components_local_for(&inputs, &LocalMergeParams::default(), LatticeKind::Hex);
671 assert_eq!(
672 res.components.len(),
673 1,
674 "D6 merge should reunite the relabelled hex copies"
675 );
676 assert_eq!(res.components[0].len(), l1.len());
677 assert_eq!(res.diagnostics.merges_accepted, 1);
678 }
679
680 #[test]
681 fn hex_relabelled_copy_merges_for_every_d6_element() {
682 // For every D6 automorphism, a relabelled copy must still merge — the
683 // 12-element symmetry group is fully exercised.
684 for relabel in 0..crate::lattice::D6_TRANSFORMS.len() {
685 let (l1, p1) = hex_component(2, 16.0, 0);
686 let (l2, p2) = hex_component(2, 16.0, relabel);
687 let inputs = vec![
688 ComponentInput {
689 labelled: &l1,
690 positions: &p1,
691 },
692 ComponentInput {
693 labelled: &l2,
694 positions: &p2,
695 },
696 ];
697 let res =
698 merge_components_local_for(&inputs, &LocalMergeParams::default(), LatticeKind::Hex);
699 assert_eq!(
700 res.components.len(),
701 1,
702 "D6 element {relabel} failed to merge"
703 );
704 }
705 }
706}