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 crate::circular_stats as cs;
40use kiddo::{KdTree, SquaredEuclidean};
41use nalgebra::{Point2, Vector2};
42use std::collections::{HashMap, HashSet, VecDeque};
43
44/// Per-candidate decision from a [`GrowValidator`].
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum Admit {
47    /// Accept this candidate at the given grid cell.
48    Accept,
49    /// Reject this candidate; the generic code may move on to the
50    /// next nearest (if any).
51    Reject,
52}
53
54/// Information about an existing labelled neighbour, passed to the
55/// validator during candidate evaluation.
56#[derive(Clone, Copy, Debug)]
57pub struct LabelledNeighbour {
58    pub idx: usize,
59    pub at: (i32, i32),
60    pub position: Point2<f32>,
61}
62
63/// Pattern-specific validation hooks for [`bfs_grow`].
64///
65/// Implementations typically hold references to the caller's corner
66/// data (axes, labels, strengths) plus the pattern's tuning
67/// parameters, and use `idx` to look up the relevant per-corner
68/// record inside each callback.
69pub trait GrowValidator {
70    /// Is this corner index a possible candidate at all? Called
71    /// once per corner when the KD-tree is built.
72    fn is_eligible(&self, idx: usize) -> bool;
73
74    /// Optional pattern-required label at grid cell `(i, j)`.
75    /// Return `None` for no constraint.
76    fn required_label_at(&self, i: i32, j: i32) -> Option<u8>;
77
78    /// Return the label of the corner at `idx`. Must agree with
79    /// `required_label_at` at attachment time. Called during
80    /// candidate filtering.
81    fn label_of(&self, idx: usize) -> Option<u8>;
82
83    /// Accept or reject a candidate for attachment at grid cell
84    /// `at` given its geometric prediction and existing labelled
85    /// neighbours. Called per candidate in order of increasing
86    /// distance to `prediction`.
87    fn accept_candidate(
88        &self,
89        idx: usize,
90        at: (i32, i32),
91        prediction: Point2<f32>,
92        neighbours: &[LabelledNeighbour],
93    ) -> Admit;
94
95    /// Soft per-edge check: is the induced edge between the just-
96    /// attached candidate and one of its cardinal-labelled neighbours
97    /// admissible? At least one cardinal edge must pass for the
98    /// attachment to stick; otherwise the position is marked a hole
99    /// and the candidate is rolled back.
100    ///
101    /// Default: accept all edges (no soft check).
102    fn edge_ok(
103        &self,
104        _candidate_idx: usize,
105        _neighbour_idx: usize,
106        _at_candidate: (i32, i32),
107        _at_neighbour: (i32, i32),
108    ) -> bool {
109        true
110    }
111}
112
113/// Tolerances for [`bfs_grow`].
114#[non_exhaustive]
115#[derive(Clone, Copy, Debug)]
116pub struct GrowParams {
117    /// Candidate-search radius (fraction of `cell_size`) around each
118    /// prediction.
119    pub attach_search_rel: f32,
120    /// Ambiguity factor: if the second-nearest candidate is within
121    /// `factor × nearest_distance`, the attachment is skipped.
122    pub attach_ambiguity_factor: f32,
123}
124
125impl Default for GrowParams {
126    fn default() -> Self {
127        Self {
128            attach_search_rel: 0.35,
129            attach_ambiguity_factor: 1.5,
130        }
131    }
132}
133
134impl GrowParams {
135    pub fn new(attach_search_rel: f32, attach_ambiguity_factor: f32) -> Self {
136        Self {
137            attach_search_rel,
138            attach_ambiguity_factor,
139        }
140    }
141}
142
143/// Seed quad: corner indices at grid cells `(0, 0), (1, 0), (0, 1),
144/// (1, 1)` respectively.
145#[derive(Clone, Copy, Debug)]
146pub struct Seed {
147    pub a: usize,
148    pub b: usize,
149    pub c: usize,
150    pub d: usize,
151}
152
153/// Outcome of a grow pass.
154#[derive(Debug, Default)]
155pub struct GrowResult {
156    /// `(i, j) → corner_index` map of accepted labels. Rebased so the
157    /// bounding-box minimum is `(0, 0)`.
158    pub labelled: HashMap<(i32, i32), usize>,
159    /// Inverse map.
160    pub by_corner: HashMap<usize, (i32, i32)>,
161    /// Positions with ≥ 2 candidates inside the ambiguity window.
162    pub ambiguous: HashSet<(i32, i32)>,
163    /// Positions with no accepted candidate.
164    pub holes: HashSet<(i32, i32)>,
165    /// Grid vectors carried forward — overlays / boosters use them.
166    pub grid_u: Vector2<f32>,
167    pub grid_v: Vector2<f32>,
168}
169
170/// Grow a labelled `(i, j)` grid from a 2×2 seed using BFS over the
171/// lattice boundary.
172///
173/// `positions` must be indexed 1:1 with the caller's corner array;
174/// the validator uses the same indices.
175///
176/// Returns the labelled map rebased so the bounding-box minimum is
177/// `(0, 0)`. The caller is responsible for any per-corner state
178/// updates after the call (e.g., marking corners as "labelled" in a
179/// local stage enum).
180pub fn bfs_grow<V: GrowValidator>(
181    positions: &[Point2<f32>],
182    seed: Seed,
183    cell_size: f32,
184    params: &GrowParams,
185    validator: &V,
186) -> GrowResult {
187    let _ = cs::wrap_pi; // keeps `cs` in scope for future use
188
189    // Grid unit vectors inferred from the seed corners (pixel space).
190    let grid_u = {
191        let raw = positions[seed.b] - positions[seed.a];
192        let n = raw.norm().max(1e-6);
193        raw / n
194    };
195    let grid_v = {
196        let raw = positions[seed.c] - positions[seed.a];
197        let n = raw.norm().max(1e-6);
198        raw / n
199    };
200
201    // KD-tree over eligible corners.
202    let mut tree: KdTree<f32, 2> = KdTree::new();
203    let mut tree_slot_to_corner: Vec<usize> = Vec::new();
204    for (idx, pos) in positions.iter().enumerate() {
205        if validator.is_eligible(idx) {
206            tree.add(&[pos.x, pos.y], tree_slot_to_corner.len() as u64);
207            tree_slot_to_corner.push(idx);
208        }
209    }
210
211    let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
212    let mut by_corner: HashMap<usize, (i32, i32)> = HashMap::new();
213    let mut ambiguous: HashSet<(i32, i32)> = HashSet::new();
214    let mut holes: HashSet<(i32, i32)> = HashSet::new();
215
216    for (ij, idx) in [
217        ((0, 0), seed.a),
218        ((1, 0), seed.b),
219        ((0, 1), seed.c),
220        ((1, 1), seed.d),
221    ] {
222        labelled.insert(ij, idx);
223        by_corner.insert(idx, ij);
224    }
225
226    let mut boundary: VecDeque<(i32, i32)> = VecDeque::new();
227    let mut seen_boundary: HashSet<(i32, i32)> = HashSet::new();
228    for ij in labelled.keys().copied().collect::<Vec<_>>() {
229        enqueue_cardinal_neighbours(ij, &labelled, &mut boundary, &mut seen_boundary);
230    }
231
232    let search_r = params.attach_search_rel * cell_size;
233
234    while let Some(pos) = boundary.pop_front() {
235        if labelled.contains_key(&pos) {
236            continue;
237        }
238
239        let neighbours = collect_labelled_neighbours(pos, 1, &labelled, positions);
240        if neighbours.is_empty() {
241            holes.insert(pos);
242            continue;
243        }
244
245        let prediction = predict_from_neighbours(pos, &neighbours, grid_u, grid_v, cell_size);
246
247        let required_label = validator.required_label_at(pos.0, pos.1);
248        let candidates = collect_candidates(
249            &tree,
250            &tree_slot_to_corner,
251            prediction,
252            search_r,
253            validator,
254            required_label,
255            &by_corner,
256        );
257
258        let choice = choose_unambiguous(
259            &candidates,
260            params.attach_ambiguity_factor,
261            prediction,
262            positions,
263            validator,
264            pos,
265            &neighbours,
266        );
267        match choice {
268            CandidateChoice::None => {
269                holes.insert(pos);
270            }
271            CandidateChoice::Ambiguous => {
272                ambiguous.insert(pos);
273            }
274            CandidateChoice::Unique(c_idx) => {
275                if !any_cardinal_edge_ok(c_idx, pos, &labelled, validator) {
276                    holes.insert(pos);
277                    continue;
278                }
279                labelled.insert(pos, c_idx);
280                by_corner.insert(c_idx, pos);
281                enqueue_cardinal_neighbours(pos, &labelled, &mut boundary, &mut seen_boundary);
282            }
283        }
284    }
285
286    // Rebase so (min_i, min_j) = (0, 0).
287    let (min_i, min_j) = labelled
288        .keys()
289        .fold((i32::MAX, i32::MAX), |(a, b), &(i, j)| (a.min(i), b.min(j)));
290    if min_i != 0 || min_j != 0 {
291        let rebased: HashMap<(i32, i32), usize> = labelled
292            .into_iter()
293            .map(|((i, j), idx)| ((i - min_i, j - min_j), idx))
294            .collect();
295        let rebased_by_corner: HashMap<usize, (i32, i32)> =
296            rebased.iter().map(|(&ij, &idx)| (idx, ij)).collect();
297        labelled = rebased;
298        by_corner = rebased_by_corner;
299    }
300    let rebase_pos = |(i, j)| (i - min_i, j - min_j);
301    let ambiguous: HashSet<(i32, i32)> = ambiguous.into_iter().map(rebase_pos).collect();
302    let holes: HashSet<(i32, i32)> = holes.into_iter().map(rebase_pos).collect();
303
304    GrowResult {
305        labelled,
306        by_corner,
307        ambiguous,
308        holes,
309        grid_u,
310        grid_v,
311    }
312}
313
314fn enqueue_cardinal_neighbours(
315    pos: (i32, i32),
316    labelled: &HashMap<(i32, i32), usize>,
317    boundary: &mut VecDeque<(i32, i32)>,
318    seen: &mut HashSet<(i32, i32)>,
319) {
320    for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
321        let neigh = (pos.0 + di, pos.1 + dj);
322        if !labelled.contains_key(&neigh) && seen.insert(neigh) {
323            boundary.push_back(neigh);
324        }
325    }
326}
327
328fn collect_labelled_neighbours(
329    pos: (i32, i32),
330    window_half: i32,
331    labelled: &HashMap<(i32, i32), usize>,
332    positions: &[Point2<f32>],
333) -> Vec<LabelledNeighbour> {
334    let mut out = Vec::new();
335    for dj in -window_half..=window_half {
336        for di in -window_half..=window_half {
337            if di == 0 && dj == 0 {
338                continue;
339            }
340            let at = (pos.0 + di, pos.1 + dj);
341            if let Some(&idx) = labelled.get(&at) {
342                out.push(LabelledNeighbour {
343                    idx,
344                    at,
345                    position: positions[idx],
346                });
347            }
348        }
349    }
350    out
351}
352
353/// Average of per-neighbour axis-vector predictions:
354/// `pred_k = pos(N_k) + di·s·u + dj·s·v`, averaged equally.
355pub fn predict_from_neighbours(
356    target: (i32, i32),
357    neighbours: &[LabelledNeighbour],
358    u: Vector2<f32>,
359    v: Vector2<f32>,
360    cell_size: f32,
361) -> Point2<f32> {
362    debug_assert!(!neighbours.is_empty());
363    let mut sum_x = 0.0_f32;
364    let mut sum_y = 0.0_f32;
365    for n in neighbours {
366        let di = (target.0 - n.at.0) as f32;
367        let dj = (target.1 - n.at.1) as f32;
368        let off = u * (di * cell_size) + v * (dj * cell_size);
369        sum_x += n.position.x + off.x;
370        sum_y += n.position.y + off.y;
371    }
372    let denom = neighbours.len() as f32;
373    Point2::new(sum_x / denom, sum_y / denom)
374}
375
376fn collect_candidates<V: GrowValidator>(
377    tree: &KdTree<f32, 2>,
378    slot_to_corner: &[usize],
379    prediction: Point2<f32>,
380    search_r: f32,
381    validator: &V,
382    required_label: Option<u8>,
383    by_corner: &HashMap<usize, (i32, i32)>,
384) -> Vec<(usize, f32)> {
385    let r2 = search_r * search_r;
386    let mut out: Vec<(usize, f32)> = Vec::new();
387    for nn in tree
388        .within_unsorted::<SquaredEuclidean>(&[prediction.x, prediction.y], r2)
389        .into_iter()
390    {
391        let idx = slot_to_corner[nn.item as usize];
392        if by_corner.contains_key(&idx) {
393            continue;
394        }
395        if let Some(req) = required_label {
396            let Some(got) = validator.label_of(idx) else {
397                continue;
398            };
399            if got != req {
400                continue;
401            }
402        }
403        let d = nn.distance.sqrt();
404        out.push((idx, d));
405    }
406    out.sort_by(|a, b| a.1.total_cmp(&b.1));
407    out
408}
409
410enum CandidateChoice {
411    None,
412    Ambiguous,
413    Unique(usize),
414}
415
416fn choose_unambiguous<V: GrowValidator>(
417    candidates: &[(usize, f32)],
418    ambiguity_factor: f32,
419    prediction: Point2<f32>,
420    positions: &[Point2<f32>],
421    validator: &V,
422    at: (i32, i32),
423    neighbours: &[LabelledNeighbour],
424) -> CandidateChoice {
425    // Filter by validator in distance order; pick the first Accept.
426    // Ambiguity check uses raw geometric ranks (two geometrically-close
427    // candidates, regardless of validator opinion).
428    if candidates.is_empty() {
429        return CandidateChoice::None;
430    }
431    if candidates.len() >= 2 {
432        let (_, d0) = candidates[0];
433        let (_, d1) = candidates[1];
434        if d0 <= f32::EPSILON {
435            return CandidateChoice::Ambiguous;
436        }
437        if d1 / d0 < ambiguity_factor {
438            return CandidateChoice::Ambiguous;
439        }
440    }
441    for &(idx, _dist) in candidates {
442        let pos = positions[idx];
443        let _ = pos; // reserved for future per-candidate metric
444        match validator.accept_candidate(idx, at, prediction, neighbours) {
445            Admit::Accept => return CandidateChoice::Unique(idx),
446            Admit::Reject => continue,
447        }
448    }
449    CandidateChoice::None
450}
451
452fn any_cardinal_edge_ok<V: GrowValidator>(
453    c_idx: usize,
454    pos: (i32, i32),
455    labelled: &HashMap<(i32, i32), usize>,
456    validator: &V,
457) -> bool {
458    let mut found_any = false;
459    for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
460        let neigh = (pos.0 + di, pos.1 + dj);
461        if let Some(&n_idx) = labelled.get(&neigh) {
462            found_any = true;
463            if validator.edge_ok(c_idx, n_idx, pos, neigh) {
464                return true;
465            }
466        }
467    }
468    // No cardinal neighbours → defer (position reached via BFS from a
469    // labelled neighbour, so this is a safety net).
470    !found_any
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    /// Trivial validator: every corner eligible, no label constraint,
478    /// accept everything.
479    struct OpenValidator;
480
481    impl GrowValidator for OpenValidator {
482        fn is_eligible(&self, _idx: usize) -> bool {
483            true
484        }
485        fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
486            None
487        }
488        fn label_of(&self, _idx: usize) -> Option<u8> {
489            None
490        }
491        fn accept_candidate(
492            &self,
493            _idx: usize,
494            _at: (i32, i32),
495            _prediction: Point2<f32>,
496            _neighbours: &[LabelledNeighbour],
497        ) -> Admit {
498            Admit::Accept
499        }
500    }
501
502    #[test]
503    fn open_validator_grows_clean_grid() {
504        let s = 20.0_f32;
505        let rows = 6_i32;
506        let cols = 6_i32;
507        let mut positions = Vec::new();
508        let mut seed_idx = [0usize; 4];
509        for j in 0..rows {
510            for i in 0..cols {
511                let x = i as f32 * s + 50.0;
512                let y = j as f32 * s + 50.0;
513                let k = positions.len();
514                positions.push(Point2::new(x, y));
515                if (i, j) == (0, 0) {
516                    seed_idx[0] = k;
517                }
518                if (i, j) == (1, 0) {
519                    seed_idx[1] = k;
520                }
521                if (i, j) == (0, 1) {
522                    seed_idx[2] = k;
523                }
524                if (i, j) == (1, 1) {
525                    seed_idx[3] = k;
526                }
527            }
528        }
529
530        let seed = Seed {
531            a: seed_idx[0],
532            b: seed_idx[1],
533            c: seed_idx[2],
534            d: seed_idx[3],
535        };
536        let res = bfs_grow(&positions, seed, s, &GrowParams::default(), &OpenValidator);
537        assert_eq!(res.labelled.len(), (rows * cols) as usize);
538        // Origin rebased to (0, 0).
539        let (mi, mj) = res
540            .labelled
541            .keys()
542            .fold((i32::MAX, i32::MAX), |(a, b), &(i, j)| (a.min(i), b.min(j)));
543        assert_eq!((mi, mj), (0, 0));
544    }
545}