Skip to main content

projective_grid/square/
grow.rs

1//! Generic BFS-style growth from a 2×2 seed over a square lattice.
2//!
3//! The growth algorithm — BFS queue, KD-tree candidate search, per-
4//! neighbour prediction averaging, ambiguity filtering — is pure
5//! geometry and works for any square-grid pattern. Pattern-specific
6//! invariants (parity rules, axis clustering, marker constraints)
7//! plug in via the [`GrowValidator`] trait.
8//!
9//! # Design
10//!
11//! The generic function manages:
12//! - The labelled `(i, j) → corner_index` map.
13//! - The BFS boundary queue and "seen" set.
14//! - A KD-tree over eligible candidate positions.
15//! - Per-neighbour prediction averaging (grid vectors `u`, `v`).
16//! - Ambiguity resolution (nearest vs second-nearest ratio).
17//! - Final rebase so the bounding-box minimum is `(0, 0)`.
18//!
19//! The validator is asked four questions:
20//! - **`is_eligible(idx)`** — can this corner index be considered as
21//!   a candidate at all? (typically: pre-filtered / in a cluster / not
22//!   blacklisted)
23//! - **`required_label_at(i, j)`** — what pattern label is required at
24//!   this grid cell? Opaque `u8`; the validator picks the scheme.
25//!   `None` means "no label constraint".
26//! - **`accept_candidate(idx, at, prediction, neighbours)`** — once
27//!   the generic search has found a candidate passing geometric
28//!   checks, is it pattern-legal?
29//! - **`edge_ok(candidate_idx, neighbour_idx, at_cand, at_neigh)`** —
30//!   soft per-edge check at attachment time.
31//!
32//! # Non-goals
33//!
34//! This function does **not** do post-growth validation (line
35//! collinearity / local-H residuals). See
36//! [`crate::square::validate`](mod@crate::square::validate) for
37//! that.
38
39use kiddo::{KdTree, SquaredEuclidean};
40use nalgebra::{Point2, Vector2};
41use std::collections::{HashMap, HashSet, VecDeque};
42
43pub use crate::square::seed::Seed;
44
45/// Per-candidate decision from a [`GrowValidator`].
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Admit {
48    /// Accept this candidate at the given grid cell.
49    Accept,
50    /// Reject this candidate; the generic code may move on to the
51    /// next nearest (if any).
52    Reject,
53}
54
55/// Information about an existing labelled neighbour, passed to the
56/// validator during candidate evaluation.
57#[derive(Clone, Copy, Debug)]
58pub struct LabelledNeighbour {
59    /// Index of the neighbour corner in the caller's position array.
60    pub idx: usize,
61    /// The neighbour's `(i, j)` grid cell.
62    pub at: (i32, i32),
63    /// The neighbour's position in image pixels.
64    pub position: Point2<f32>,
65}
66
67/// Pattern-specific validation hooks for [`bfs_grow`].
68///
69/// Implementations typically hold references to the caller's corner
70/// data (axes, labels, strengths) plus the pattern's tuning
71/// parameters, and use `idx` to look up the relevant per-corner
72/// record inside each callback.
73pub trait GrowValidator {
74    /// Is this corner index a possible candidate at all? Called
75    /// once per corner when the KD-tree is built.
76    fn is_eligible(&self, idx: usize) -> bool;
77
78    /// Optional pattern-required label at grid cell `(i, j)`.
79    /// Return `None` for no constraint.
80    fn required_label_at(&self, i: i32, j: i32) -> Option<u8>;
81
82    /// Return the label of the corner at `idx`. Must agree with
83    /// `required_label_at` at attachment time. Called during
84    /// candidate filtering.
85    fn label_of(&self, idx: usize) -> Option<u8>;
86
87    /// Accept or reject a candidate for attachment at grid cell
88    /// `at` given its geometric prediction and existing labelled
89    /// neighbours. Called per candidate in order of increasing
90    /// distance to `prediction`.
91    fn accept_candidate(
92        &self,
93        idx: usize,
94        at: (i32, i32),
95        prediction: Point2<f32>,
96        neighbours: &[LabelledNeighbour],
97    ) -> Admit;
98
99    /// Soft per-edge check: is the induced edge between the just-
100    /// attached candidate and one of its cardinal-labelled neighbours
101    /// admissible? At least one cardinal edge must pass for the
102    /// attachment to stick; otherwise the position is marked a hole
103    /// and the candidate is rolled back.
104    ///
105    /// Default: accept all edges (no soft check).
106    fn edge_ok(
107        &self,
108        _candidate_idx: usize,
109        _neighbour_idx: usize,
110        _at_candidate: (i32, i32),
111        _at_neighbour: (i32, i32),
112    ) -> bool {
113        true
114    }
115
116    /// Optional widened eligibility used by the fill-pass booster.
117    ///
118    /// Defaults to [`Self::is_eligible`]; patterns whose precision
119    /// core admits only `Clustered` corners but want to admit a few
120    /// near-cluster corners during the booster pass override this to
121    /// expand the admissible set. The fill pass calls this when
122    /// building its KD-tree; the regular grow / boundary-extension
123    /// passes ignore it.
124    fn eligible_for_fill(&self, idx: usize) -> bool {
125        self.is_eligible(idx)
126    }
127
128    /// Optional fill-pass edge check that has access to the full
129    /// labelled set and the position table via [`FillEdgeCtx`].
130    ///
131    /// The default delegates to [`Self::edge_ok`], ignoring the extra
132    /// context. Pattern implementations that need a directional edge
133    /// metric (e.g., a strongly anisotropic component where the
134    /// horizontal pitch is much larger than the vertical pitch and a
135    /// scalar `cell_size` rejects legitimate vertical extrapolations)
136    /// override this to consult the labelled set when computing the
137    /// expected edge length.
138    ///
139    /// Only invoked by [`crate::square::fill::fill_grid_holes`]; the
140    /// regular grow and boundary-extension passes call [`Self::edge_ok`]
141    /// directly.
142    fn fill_edge_ok(&self, ctx: FillEdgeCtx<'_>) -> bool {
143        self.edge_ok(
144            ctx.candidate_idx,
145            ctx.neighbour_idx,
146            ctx.at_candidate,
147            ctx.at_neighbour,
148        )
149    }
150}
151
152/// Context passed to [`GrowValidator::fill_edge_ok`].
153///
154/// Bundles every piece of state the validator needs to make a
155/// labelled-set-aware edge decision: the candidate + cardinal
156/// neighbour indices, their `(i, j)` cells, the full labelled map,
157/// the corner position array, and the scalar fallback cell size.
158#[non_exhaustive]
159#[derive(Clone, Copy)]
160pub struct FillEdgeCtx<'a> {
161    /// Index of the candidate corner being evaluated.
162    pub candidate_idx: usize,
163    /// Index of the already-labelled cardinal neighbour.
164    pub neighbour_idx: usize,
165    /// The candidate's prospective `(i, j)` cell.
166    pub at_candidate: (i32, i32),
167    /// The cardinal neighbour's `(i, j)` cell.
168    pub at_neighbour: (i32, i32),
169    /// The full `(i, j) → corner_idx` labelled map at this point in the grow.
170    pub labelled: &'a HashMap<(i32, i32), usize>,
171    /// Corner positions in image pixels, indexed by the values of `labelled`.
172    pub positions: &'a [Point2<f32>],
173    /// Scalar fallback cell size in pixels, used when no local estimate exists.
174    pub cell_size: f32,
175}
176
177/// Tolerances for [`bfs_grow`].
178#[non_exhaustive]
179#[derive(Clone, Copy, Debug)]
180pub struct GrowParams {
181    /// Candidate-search radius (fraction of `cell_size`) around each
182    /// prediction. Applies when the target is being **interpolated**
183    /// between labelled neighbours on opposite sides.
184    pub attach_search_rel: f32,
185    /// Ambiguity factor: if the second-nearest candidate is within
186    /// `factor × nearest_distance`, the attachment is skipped.
187    pub attach_ambiguity_factor: f32,
188    /// Multiplier on `attach_search_rel` when the target is being
189    /// **extrapolated** outward from the labelled set (every labelled
190    /// neighbour sits on the same side of the target along at least one
191    /// axis). Defaults to 2.0 — opens the search up enough to absorb
192    /// the perspective-foreshortening overshoot at the image edge while
193    /// still rejecting marker-internal corners which sit several cell-
194    /// widths away.
195    pub boundary_search_factor: f32,
196}
197
198impl Default for GrowParams {
199    fn default() -> Self {
200        Self {
201            attach_search_rel: 0.35,
202            attach_ambiguity_factor: 1.5,
203            boundary_search_factor: 2.0,
204        }
205    }
206}
207
208impl GrowParams {
209    /// Construct grow parameters from the interpolation search radius and
210    /// ambiguity factor; `boundary_search_factor` keeps its default.
211    pub fn new(attach_search_rel: f32, attach_ambiguity_factor: f32) -> Self {
212        Self {
213            attach_search_rel,
214            attach_ambiguity_factor,
215            ..Self::default()
216        }
217    }
218}
219
220/// Outcome of a grow pass.
221#[derive(Debug, Default)]
222pub struct GrowResult {
223    /// `(i, j) → corner_index` map of accepted labels. Rebased so the
224    /// bounding-box minimum is `(0, 0)`.
225    pub labelled: HashMap<(i32, i32), usize>,
226    /// Inverse map.
227    pub by_corner: HashMap<usize, (i32, i32)>,
228    /// Positions with ≥ 2 candidates inside the ambiguity window.
229    pub ambiguous: HashSet<(i32, i32)>,
230    /// Positions with no accepted candidate.
231    pub holes: HashSet<(i32, i32)>,
232    /// Grid `i`-axis vector (pixels per cell) carried forward — overlays
233    /// and boosters use it.
234    pub axis_i: Vector2<f32>,
235    /// Grid `j`-axis vector (pixels per cell) carried forward — overlays
236    /// and boosters use it.
237    pub axis_j: Vector2<f32>,
238    /// Parity shift applied during the post-BFS rebase, modulo 2.
239    ///
240    /// BFS walks in pre-rebase coords where the seed's `(0, 0)` cell
241    /// has its caller-defined parity (e.g., `Canonical` for the
242    /// chessboard convention). After BFS finishes, the labelled map
243    /// is rebased so the bounding-box minimum is `(0, 0)`. If the
244    /// rebase shift `(min_i, min_j)` has odd Manhattan parity (i.e.
245    /// `(min_i + min_j) % 2 == 1`), the rebase flips the parity at
246    /// every cell — what was at an even-parity cell is now at an
247    /// odd-parity cell post-rebase.
248    ///
249    /// For chessboard-style consumers that derive a "required label at
250    /// `(i, j)`" from `(i + j) % 2`, this means the post-rebase
251    /// `required_label_at(i, j)` gives the WRONG answer on every
252    /// cell when `parity_shift == 1`. Such consumers must instead
253    /// query `required_label_at(i + parity_shift_i, j + parity_shift_j)`
254    /// to recover the pre-rebase parity at the post-rebase cell.
255    ///
256    /// `parity_shift_i + parity_shift_j` is always equivalent to
257    /// `parity_shift` mod 2; this struct exposes both individual
258    /// shifts so consumers that depend on the absolute (i, j) parity
259    /// (rare) can adjust without re-deriving them.
260    ///
261    /// For non-chessboard consumers (no parity invariant), this field
262    /// can be ignored.
263    pub parity_shift_i: i32,
264    /// See [`Self::parity_shift_i`].
265    pub parity_shift_j: i32,
266}
267
268/// Grow a labelled `(i, j)` grid from a 2×2 seed using BFS over the
269/// lattice boundary.
270///
271/// `positions` must be indexed 1:1 with the caller's corner array;
272/// the validator uses the same indices.
273///
274/// Returns the labelled map rebased so the bounding-box minimum is
275/// `(0, 0)`. The caller is responsible for any per-corner state
276/// updates after the call (e.g., marking corners as "labelled" in a
277/// local stage enum).
278#[cfg_attr(
279    feature = "tracing",
280    tracing::instrument(
281        level = "info",
282        skip_all,
283        fields(num_corners = positions.len(), cell_size = cell_size),
284    )
285)]
286pub fn bfs_grow<V: GrowValidator>(
287    positions: &[Point2<f32>],
288    seed: Seed,
289    cell_size: f32,
290    params: &GrowParams,
291    validator: &V,
292) -> GrowResult {
293    // Grid unit vectors inferred from the seed corners (pixel space).
294    let axis_i = {
295        let raw = positions[seed.b] - positions[seed.a];
296        let n = raw.norm().max(1e-6);
297        raw / n
298    };
299    let axis_j = {
300        let raw = positions[seed.c] - positions[seed.a];
301        let n = raw.norm().max(1e-6);
302        raw / n
303    };
304
305    // KD-tree over eligible corners.
306    let mut tree: KdTree<f32, 2> = KdTree::new();
307    let mut tree_slot_to_corner: Vec<usize> = Vec::new();
308    for (idx, pos) in positions.iter().enumerate() {
309        if validator.is_eligible(idx) {
310            tree.add(&[pos.x, pos.y], tree_slot_to_corner.len() as u64);
311            tree_slot_to_corner.push(idx);
312        }
313    }
314
315    let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
316    let mut by_corner: HashMap<usize, (i32, i32)> = HashMap::new();
317    let mut ambiguous: HashSet<(i32, i32)> = HashSet::new();
318    let mut holes: HashSet<(i32, i32)> = HashSet::new();
319
320    for (ij, idx) in [
321        ((0, 0), seed.a),
322        ((1, 0), seed.b),
323        ((0, 1), seed.c),
324        ((1, 1), seed.d),
325    ] {
326        labelled.insert(ij, idx);
327        by_corner.insert(idx, ij);
328    }
329
330    let mut boundary: VecDeque<(i32, i32)> = VecDeque::new();
331    let mut seen_boundary: HashSet<(i32, i32)> = HashSet::new();
332    for ij in labelled.keys().copied().collect::<Vec<_>>() {
333        enqueue_cardinal_neighbours(ij, &labelled, &mut boundary, &mut seen_boundary);
334    }
335
336    while let Some(pos) = boundary.pop_front() {
337        if labelled.contains_key(&pos) {
338            continue;
339        }
340        let ctx = BoundaryCtx {
341            positions,
342            labelled: &labelled,
343            by_corner: &by_corner,
344            tree: &tree,
345            tree_slot_to_corner: &tree_slot_to_corner,
346            axis_i,
347            axis_j,
348            cell_size,
349            params,
350            validator,
351        };
352        let (decision, _neighbours) = process_boundary_cell(pos, &ctx);
353        match decision {
354            BoundaryDecision::Hole | BoundaryDecision::EdgeRejected => {
355                holes.insert(pos);
356            }
357            BoundaryDecision::Ambiguous => {
358                ambiguous.insert(pos);
359            }
360            BoundaryDecision::Attach(c_idx) => {
361                labelled.insert(pos, c_idx);
362                by_corner.insert(c_idx, pos);
363                enqueue_cardinal_neighbours(pos, &labelled, &mut boundary, &mut seen_boundary);
364            }
365        }
366    }
367
368    // Rebase so (min_i, min_j) = (0, 0).
369    let (min_i, min_j) = labelled
370        .keys()
371        .fold((i32::MAX, i32::MAX), |(a, b), &(i, j)| (a.min(i), b.min(j)));
372    if min_i != 0 || min_j != 0 {
373        let rebased: HashMap<(i32, i32), usize> = labelled
374            .into_iter()
375            .map(|((i, j), idx)| ((i - min_i, j - min_j), idx))
376            .collect();
377        let rebased_by_corner: HashMap<usize, (i32, i32)> =
378            rebased.iter().map(|(&ij, &idx)| (idx, ij)).collect();
379        labelled = rebased;
380        by_corner = rebased_by_corner;
381    }
382    let rebase_pos = |(i, j)| (i - min_i, j - min_j);
383    let ambiguous: HashSet<(i32, i32)> = ambiguous.into_iter().map(rebase_pos).collect();
384    let holes: HashSet<(i32, i32)> = holes.into_iter().map(rebase_pos).collect();
385
386    // Parity shifts: when the rebase shifts a coord by an odd amount,
387    // the post-rebase parity at any cell flips relative to pre-rebase.
388    // Chessboard consumers that derive a "required label at (i, j)"
389    // from `(i + j) % 2` must add these shifts back to recover the
390    // pre-rebase parity. Stored mod 2 for clarity; downstream may
391    // also use the simpler combined `(parity_shift_i + parity_shift_j)
392    // % 2` for chessboard parity since the convention only depends on
393    // `(i + j) % 2`.
394    let parity_shift_i = min_i.rem_euclid(2);
395    let parity_shift_j = min_j.rem_euclid(2);
396
397    GrowResult {
398        labelled,
399        by_corner,
400        ambiguous,
401        holes,
402        axis_i,
403        axis_j,
404        parity_shift_i,
405        parity_shift_j,
406    }
407}
408
409pub(super) fn enqueue_cardinal_neighbours(
410    pos: (i32, i32),
411    labelled: &HashMap<(i32, i32), usize>,
412    boundary: &mut VecDeque<(i32, i32)>,
413    seen: &mut HashSet<(i32, i32)>,
414) {
415    for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
416        let neigh = (pos.0 + di, pos.1 + dj);
417        if !labelled.contains_key(&neigh) && seen.insert(neigh) {
418            boundary.push_back(neigh);
419        }
420    }
421}
422
423pub(crate) fn collect_labelled_neighbours(
424    pos: (i32, i32),
425    window_half: i32,
426    labelled: &HashMap<(i32, i32), usize>,
427    positions: &[Point2<f32>],
428) -> Vec<LabelledNeighbour> {
429    let mut out = Vec::new();
430    for dj in -window_half..=window_half {
431        for di in -window_half..=window_half {
432            if di == 0 && dj == 0 {
433                continue;
434            }
435            let at = (pos.0 + di, pos.1 + dj);
436            if let Some(&idx) = labelled.get(&at) {
437                out.push(LabelledNeighbour {
438                    idx,
439                    at,
440                    position: positions[idx],
441                });
442            }
443        }
444    }
445    out
446}
447
448/// Distance-weighted average of per-neighbour axis-vector predictions.
449///
450/// Use this function for in-the-loop BFS attachment where arbitrary
451/// labelled neighbours are available. For post-grow outlier detection
452/// using cardinal midpoint averaging, see
453/// [`crate::square::smoothness::square_predict_grid_position`].
454///
455/// For each labelled neighbour `N_k` at `(i_k, j_k)`, the prediction is
456/// `pred_k = pos(N_k) + (Δi · i_step_k) + (Δj · j_step_k)` where
457/// `Δi = target.i − i_k`, `Δj = target.j − j_k`, and `i_step_k` /
458/// `j_step_k` are the **local** grid-step vectors observed at `N_k`:
459///
460/// - If `(i_k+1, j_k)` and `(i_k−1, j_k)` are both labelled, the i-step is
461///   the central difference `(pos(i_k+1, j_k) − pos(i_k−1, j_k)) / 2`.
462/// - Otherwise, a one-sided difference from whichever neighbour is
463///   labelled.
464/// - Otherwise, fall back to the global `cell_size · u`. Same for j.
465///
466/// This linearises the grid **at every neighbour individually** instead of
467/// trusting the seed's global `(u, v, cell_size)` — critical under strong
468/// perspective foreshortening, where the cell pitch on the far edge of
469/// the labelled set is materially different from the seed's mean. With
470/// the global-only model, BFS predictions on the foreshortened side
471/// overshoot the next true corner by more than the search radius and
472/// growth terminates prematurely.
473///
474/// Predictions are averaged with weights `1 / (Δi² + Δj²)` so cardinal
475/// neighbours (grid distance 1) carry weight 1.0 while diagonal
476/// neighbours (grid distance √2) carry weight 0.5 — variance addition
477/// per grid step.
478///
479/// A neighbour at the target cell itself (`Δi = Δj = 0`) would yield an
480/// infinite weight; in practice [`bfs_grow`] never enqueues such a
481/// neighbour (they're already labelled), but for robustness we treat
482/// `Δi = Δj = 0` as weight 1.0 to avoid `NaN`.
483pub fn predict_from_neighbours(
484    target: (i32, i32),
485    neighbours: &[LabelledNeighbour],
486    u: Vector2<f32>,
487    v: Vector2<f32>,
488    cell_size: f32,
489    labelled: &HashMap<(i32, i32), usize>,
490    positions: &[Point2<f32>],
491) -> Point2<f32> {
492    debug_assert!(!neighbours.is_empty());
493    let global_i_step = u * cell_size;
494    let global_j_step = v * cell_size;
495
496    let mut sum_x = 0.0_f32;
497    let mut sum_y = 0.0_f32;
498    let mut sum_w = 0.0_f32;
499    for n in neighbours {
500        let di = (target.0 - n.at.0) as f32;
501        let dj = (target.1 - n.at.1) as f32;
502        let d2 = di * di + dj * dj;
503        let w = if d2 > 0.0 { 1.0 / d2 } else { 1.0 };
504
505        let i_step = local_step_at(n.at, (1, 0), labelled, positions).unwrap_or(global_i_step);
506        let j_step = local_step_at(n.at, (0, 1), labelled, positions).unwrap_or(global_j_step);
507
508        let off = i_step * di + j_step * dj;
509        sum_x += w * (n.position.x + off.x);
510        sum_y += w * (n.position.y + off.y);
511        sum_w += w;
512    }
513    Point2::new(sum_x / sum_w, sum_y / sum_w)
514}
515
516/// True when every labelled neighbour sits on the same side of `target`
517/// along at least one of the two grid axes — i.e., the target is being
518/// extrapolated outward from the labelled set rather than interpolated
519/// between two opposing sides.
520///
521/// This is the geometric signal that the search prediction is less
522/// reliable: extrapolation accumulates foreshortening error linearly,
523/// while interpolation has neighbours on both sides bracketing the
524/// truth.
525pub(super) fn is_extrapolating(target: (i32, i32), neighbours: &[LabelledNeighbour]) -> bool {
526    let mut has_neg_di = false;
527    let mut has_pos_di = false;
528    let mut has_neg_dj = false;
529    let mut has_pos_dj = false;
530    for n in neighbours {
531        let di = target.0 - n.at.0;
532        let dj = target.1 - n.at.1;
533        if di > 0 {
534            has_neg_di = true; // neighbour is on the −i side of target
535        } else if di < 0 {
536            has_pos_di = true;
537        }
538        if dj > 0 {
539            has_neg_dj = true;
540        } else if dj < 0 {
541            has_pos_dj = true;
542        }
543    }
544    !(has_neg_di && has_pos_di && has_neg_dj && has_pos_dj)
545}
546
547/// Estimate the local grid-step vector at labelled cell `at` along
548/// direction `step = (di, dj)` using a finite-difference of labelled
549/// neighbours. Returns `None` when neither the forward nor backward
550/// neighbour is labelled.
551pub(super) fn local_step_at(
552    at: (i32, i32),
553    step: (i32, i32),
554    labelled: &HashMap<(i32, i32), usize>,
555    positions: &[Point2<f32>],
556) -> Option<Vector2<f32>> {
557    let here = labelled.get(&at).map(|&i| positions[i])?;
558    let fwd = (at.0 + step.0, at.1 + step.1);
559    let bwd = (at.0 - step.0, at.1 - step.1);
560    let fwd_pos = labelled.get(&fwd).map(|&i| positions[i]);
561    let bwd_pos = labelled.get(&bwd).map(|&i| positions[i]);
562    match (fwd_pos, bwd_pos) {
563        (Some(f), Some(b)) => {
564            let v = (f - b) * 0.5;
565            Some(v)
566        }
567        (Some(f), None) => Some(f - here),
568        (None, Some(b)) => Some(here - b),
569        (None, None) => None,
570    }
571}
572
573pub(super) fn collect_candidates<V: GrowValidator>(
574    tree: &KdTree<f32, 2>,
575    slot_to_corner: &[usize],
576    prediction: Point2<f32>,
577    search_r: f32,
578    validator: &V,
579    required_label: Option<u8>,
580    by_corner: &HashMap<usize, (i32, i32)>,
581) -> Vec<(usize, f32)> {
582    let r2 = search_r * search_r;
583    let mut out: Vec<(usize, f32)> = Vec::new();
584    for nn in tree
585        .within_unsorted::<SquaredEuclidean>(&[prediction.x, prediction.y], r2)
586        .into_iter()
587    {
588        let idx = slot_to_corner[nn.item as usize];
589        if by_corner.contains_key(&idx) {
590            continue;
591        }
592        if let Some(req) = required_label {
593            let Some(got) = validator.label_of(idx) else {
594                continue;
595            };
596            if got != req {
597                continue;
598            }
599        }
600        let d = nn.distance.sqrt();
601        out.push((idx, d));
602    }
603    out.sort_by(|a, b| a.1.total_cmp(&b.1));
604    out
605}
606
607pub(super) enum CandidateChoice {
608    None,
609    Ambiguous,
610    Unique(usize),
611}
612
613pub(super) fn choose_unambiguous<V: GrowValidator>(
614    candidates: &[(usize, f32)],
615    ambiguity_factor: f32,
616    prediction: Point2<f32>,
617    positions: &[Point2<f32>],
618    validator: &V,
619    at: (i32, i32),
620    neighbours: &[LabelledNeighbour],
621) -> CandidateChoice {
622    // Filter by validator in distance order; pick the first Accept.
623    // Ambiguity check uses raw geometric ranks (two geometrically-close
624    // candidates, regardless of validator opinion).
625    if candidates.is_empty() {
626        return CandidateChoice::None;
627    }
628    if candidates.len() >= 2 {
629        let (_, d0) = candidates[0];
630        let (_, d1) = candidates[1];
631        if d0 <= f32::EPSILON {
632            return CandidateChoice::Ambiguous;
633        }
634        if d1 / d0 < ambiguity_factor {
635            return CandidateChoice::Ambiguous;
636        }
637    }
638    for &(idx, _dist) in candidates {
639        let pos = positions[idx];
640        let _ = pos; // reserved for future per-candidate metric
641        match validator.accept_candidate(idx, at, prediction, neighbours) {
642            Admit::Accept => return CandidateChoice::Unique(idx),
643            Admit::Reject => continue,
644        }
645    }
646    CandidateChoice::None
647}
648
649pub(super) fn any_cardinal_edge_ok<V: GrowValidator>(
650    c_idx: usize,
651    pos: (i32, i32),
652    labelled: &HashMap<(i32, i32), usize>,
653    validator: &V,
654) -> bool {
655    let mut found_any = false;
656    for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
657        let neigh = (pos.0 + di, pos.1 + dj);
658        if let Some(&n_idx) = labelled.get(&neigh) {
659            found_any = true;
660            if validator.edge_ok(c_idx, n_idx, pos, neigh) {
661                return true;
662            }
663        }
664    }
665    // No cardinal neighbours → defer (position reached via BFS from a
666    // labelled neighbour, so this is a safety net).
667    !found_any
668}
669
670/// Outcome of processing one boundary cell.
671pub(super) enum BoundaryDecision {
672    /// No eligible candidates in the search radius.
673    Hole,
674    /// Multiple near-equidistant candidates — cannot pick unambiguously.
675    Ambiguous,
676    /// The edge check blocked the unique candidate.
677    EdgeRejected,
678    /// Unique candidate accepted; caller should attach this corner index.
679    Attach(usize),
680}
681
682/// Shared context for one boundary-cell decision.
683///
684/// Bundles the references that all boundary-cell helpers thread
685/// through — positions / labelled state / KD-tree over eligible
686/// candidates / growth geometry / validator. Carrying them in one
687/// struct keeps [`process_boundary_cell`]'s signature compact and
688/// avoids re-stating the same nine arguments at every call site.
689pub(super) struct BoundaryCtx<'a, V: GrowValidator> {
690    pub positions: &'a [Point2<f32>],
691    pub labelled: &'a HashMap<(i32, i32), usize>,
692    pub by_corner: &'a HashMap<usize, (i32, i32)>,
693    pub tree: &'a KdTree<f32, 2>,
694    pub tree_slot_to_corner: &'a [usize],
695    pub axis_i: Vector2<f32>,
696    pub axis_j: Vector2<f32>,
697    pub cell_size: f32,
698    pub params: &'a GrowParams,
699    pub validator: &'a V,
700}
701
702/// Process one cell from the BFS boundary queue.
703///
704/// Collects labelled neighbours, predicts the target pixel position,
705/// searches candidates, resolves ambiguity, and checks `edge_ok`.
706/// Returns a [`BoundaryDecision`] that the caller applies to the mutable
707/// state. Keeping the decision logic in one place makes `bfs_grow` and
708/// `extend_from_labelled` share the same filter pipeline without
709/// duplicating code.
710pub(super) fn process_boundary_cell<V: GrowValidator>(
711    pos: (i32, i32),
712    ctx: &BoundaryCtx<'_, V>,
713) -> (BoundaryDecision, Vec<LabelledNeighbour>) {
714    let neighbours = collect_labelled_neighbours(pos, 1, ctx.labelled, ctx.positions);
715    if neighbours.is_empty() {
716        return (BoundaryDecision::Hole, neighbours);
717    }
718
719    let prediction = predict_from_neighbours(
720        pos,
721        &neighbours,
722        ctx.axis_i,
723        ctx.axis_j,
724        ctx.cell_size,
725        ctx.labelled,
726        ctx.positions,
727    );
728
729    let search_r = ctx.params.attach_search_rel * ctx.cell_size;
730    let extrapolating = is_extrapolating(pos, &neighbours);
731    let local_search_r = if extrapolating {
732        search_r * ctx.params.boundary_search_factor
733    } else {
734        search_r
735    };
736
737    let required_label = ctx.validator.required_label_at(pos.0, pos.1);
738    let candidates = collect_candidates(
739        ctx.tree,
740        ctx.tree_slot_to_corner,
741        prediction,
742        local_search_r,
743        ctx.validator,
744        required_label,
745        ctx.by_corner,
746    );
747
748    let choice = choose_unambiguous(
749        &candidates,
750        ctx.params.attach_ambiguity_factor,
751        prediction,
752        ctx.positions,
753        ctx.validator,
754        pos,
755        &neighbours,
756    );
757
758    let decision = match choice {
759        CandidateChoice::None => BoundaryDecision::Hole,
760        CandidateChoice::Ambiguous => BoundaryDecision::Ambiguous,
761        CandidateChoice::Unique(c_idx) => {
762            if !any_cardinal_edge_ok(c_idx, pos, ctx.labelled, ctx.validator) {
763                BoundaryDecision::EdgeRejected
764            } else {
765                BoundaryDecision::Attach(c_idx)
766            }
767        }
768    };
769    (decision, neighbours)
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    /// Trivial validator: every corner eligible, no label constraint,
777    /// accept everything.
778    struct OpenValidator;
779
780    impl GrowValidator for OpenValidator {
781        fn is_eligible(&self, _idx: usize) -> bool {
782            true
783        }
784        fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
785            None
786        }
787        fn label_of(&self, _idx: usize) -> Option<u8> {
788            None
789        }
790        fn accept_candidate(
791            &self,
792            _idx: usize,
793            _at: (i32, i32),
794            _prediction: Point2<f32>,
795            _neighbours: &[LabelledNeighbour],
796        ) -> Admit {
797            Admit::Accept
798        }
799    }
800
801    #[test]
802    fn predict_weights_diagonal_less_than_cardinal() {
803        // Demonstrate the 1/(Δi² + Δj²) weighting on **isolated** labelled
804        // neighbours — placed far enough apart in (i, j) that the local-step
805        // lookup returns `None` for both, exercising the global (u, v,
806        // cell_size) fallback path.
807        //
808        // target = (5, 5)
809        //   - cardinal at (5, 4), pos = (50, 40)
810        //   - diagonal at (3, 3), pos = (30, 30 + 4)  (4 px y-bias)
811        //
812        // Both neighbours' adjacent (i, j) cells are unlabelled, so each
813        // falls back to the global step `cell_size · u`, `cell_size · v`.
814        // Cardinal prediction at target: (50, 40) + (0, 10) = (50, 50).
815        // Diagonal prediction at target: (30, 34) + (20, 20) = (50, 54).
816        //
817        // Weights: cardinal Δd²=1 → w=1.0; diagonal Δd²=8 → w=0.125.
818        // Weighted y: (50 + 0.125·54) / 1.125 ≈ 50.444 px.
819        // Equal-weight average would be (50 + 54)/2 = 52, so the
820        // diagonal's bias has been suppressed by the d² down-weighting.
821        let s = 10.0_f32;
822        let u = Vector2::new(1.0, 0.0);
823        let v = Vector2::new(0.0, 1.0);
824        let target = (5, 5);
825        let cardinal = LabelledNeighbour {
826            idx: 0,
827            at: (5, 4),
828            position: Point2::new(50.0, 40.0),
829        };
830        let diagonal = LabelledNeighbour {
831            idx: 1,
832            at: (3, 3),
833            position: Point2::new(30.0, 34.0),
834        };
835        let positions = vec![cardinal.position, diagonal.position];
836        let mut labelled = HashMap::new();
837        labelled.insert(cardinal.at, 0usize);
838        labelled.insert(diagonal.at, 1usize);
839        let pred = predict_from_neighbours(
840            target,
841            &[cardinal, diagonal],
842            u,
843            v,
844            s,
845            &labelled,
846            &positions,
847        );
848        let expected_y = (50.0 + 0.125 * 54.0) / 1.125;
849        assert!(
850            (pred.x - 50.0).abs() < 1e-4,
851            "predicted x {} should equal 50",
852            pred.x
853        );
854        assert!(
855            (pred.y - expected_y).abs() < 1e-4,
856            "predicted y {} should equal {} (1/d² weighted)",
857            pred.y,
858            expected_y
859        );
860        let equal_weight_y = (50.0 + 54.0) * 0.5;
861        assert!(
862            (pred.y - 50.0) < (equal_weight_y - 50.0),
863            "weighted bias {} should be smaller than equal-weight bias {}",
864            pred.y - 50.0,
865            equal_weight_y - 50.0,
866        );
867    }
868
869    #[test]
870    fn predict_with_only_cardinal_recovers_exact_offset() {
871        let s = 12.0_f32;
872        let u = Vector2::new(1.0, 0.0);
873        let v = Vector2::new(0.0, 1.0);
874        let target = (2, 2);
875        let neighbour = LabelledNeighbour {
876            idx: 0,
877            at: (1, 2),
878            position: Point2::new(s, 2.0 * s),
879        };
880        let positions = vec![neighbour.position];
881        let mut labelled = HashMap::new();
882        labelled.insert(neighbour.at, 0usize);
883        let pred = predict_from_neighbours(target, &[neighbour], u, v, s, &labelled, &positions);
884        assert!((pred.x - 2.0 * s).abs() < 1e-4);
885        assert!((pred.y - 2.0 * s).abs() < 1e-4);
886    }
887
888    #[test]
889    fn predict_uses_local_step_when_neighbour_has_own_neighbours() {
890        // Foreshortened-grid scenario:
891        //   labelled (i, j) | image position
892        //   ---------------- | --------------
893        //   (3, 0)            | (300, 0)   ← neighbour we extrapolate from
894        //   (4, 0)            | (310, 0)   ← +1 step at (3,0) is only +10 px
895        //   (5, 0)            | (320, 0)
896        //
897        // The seed's global cell_size is 50 px (a far-region estimate). The
898        // global model would predict target (2, 0) at (300 - 50, 0) = (250, 0),
899        // missing the actual location at (290, 0) by 40 px.
900        //
901        // The local-step model uses the central-difference at (3, 0):
902        //   i_step = (pos(4, 0) − pos(2, 0)) / 2  but (2, 0) is unlabelled
903        //   so it falls back to one-sided: pos(3, 0) − pos(4, 0) = (−10, 0)
904        //   wait — that's BACKWARD. Let me redo: forward (4, 0) is labelled,
905        //   so i_step ← pos(4, 0) − pos(3, 0) = (+10, 0). For target (2, 0),
906        //   prediction = pos(3, 0) + (2 − 3) · (+10, 0) = (290, 0). ✓
907        let u = Vector2::new(1.0, 0.0);
908        let v = Vector2::new(0.0, 1.0);
909        let global_cell_size = 50.0_f32;
910        let neighbour = LabelledNeighbour {
911            idx: 0,
912            at: (3, 0),
913            position: Point2::new(300.0, 0.0),
914        };
915        let mut positions = vec![neighbour.position];
916        let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
917        labelled.insert((3, 0), 0);
918        positions.push(Point2::new(310.0, 0.0));
919        labelled.insert((4, 0), 1);
920        positions.push(Point2::new(320.0, 0.0));
921        labelled.insert((5, 0), 2);
922
923        let pred = predict_from_neighbours(
924            (2, 0),
925            &[neighbour],
926            u,
927            v,
928            global_cell_size,
929            &labelled,
930            &positions,
931        );
932        // Adaptive prediction lands on the foreshortened position, not the
933        // 50-px global step.
934        assert!(
935            (pred.x - 290.0).abs() < 1e-3,
936            "expected adaptive prediction at x=290, got {}",
937            pred.x
938        );
939        assert!((pred.y - 0.0).abs() < 1e-3);
940    }
941
942    #[test]
943    fn predict_falls_back_to_global_when_no_local_steps() {
944        // Single isolated neighbour with no labelled +i / +j peers — the
945        // local-step lookup returns None for both directions and the global
946        // (u, v, cell_size) fallback produces the same answer as the
947        // pre-refactor implementation.
948        let u = Vector2::new(1.0, 0.0);
949        let v = Vector2::new(0.0, 1.0);
950        let s = 25.0_f32;
951        let neighbour = LabelledNeighbour {
952            idx: 0,
953            at: (4, 4),
954            position: Point2::new(100.0, 100.0),
955        };
956        let positions = vec![neighbour.position];
957        let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
958        labelled.insert((4, 4), 0);
959        let pred = predict_from_neighbours((5, 4), &[neighbour], u, v, s, &labelled, &positions);
960        assert!((pred.x - (100.0 + s)).abs() < 1e-3);
961        assert!((pred.y - 100.0).abs() < 1e-3);
962    }
963
964    #[test]
965    fn open_validator_grows_clean_grid() {
966        let s = 20.0_f32;
967        let rows = 6_i32;
968        let cols = 6_i32;
969        let mut positions = Vec::new();
970        let mut seed_idx = [0usize; 4];
971        for j in 0..rows {
972            for i in 0..cols {
973                let x = i as f32 * s + 50.0;
974                let y = j as f32 * s + 50.0;
975                let k = positions.len();
976                positions.push(Point2::new(x, y));
977                if (i, j) == (0, 0) {
978                    seed_idx[0] = k;
979                }
980                if (i, j) == (1, 0) {
981                    seed_idx[1] = k;
982                }
983                if (i, j) == (0, 1) {
984                    seed_idx[2] = k;
985                }
986                if (i, j) == (1, 1) {
987                    seed_idx[3] = k;
988                }
989            }
990        }
991
992        let seed = Seed {
993            a: seed_idx[0],
994            b: seed_idx[1],
995            c: seed_idx[2],
996            d: seed_idx[3],
997        };
998        let res = bfs_grow(&positions, seed, s, &GrowParams::default(), &OpenValidator);
999        assert_eq!(res.labelled.len(), (rows * cols) as usize);
1000        // Origin rebased to (0, 0).
1001        let (mi, mj) = res
1002            .labelled
1003            .keys()
1004            .fold((i32::MAX, i32::MAX), |(a, b), &(i, j)| (a.min(i), b.min(j)));
1005        assert_eq!((mi, mj), (0, 0));
1006    }
1007}