Skip to main content

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