Skip to main content

projective_grid/square/
fill.rs

1//! Post-grow fill pass: interior holes + line extrapolation.
2//!
3//! [`fill_grid_holes`] is the pattern-agnostic "booster" pass that
4//! runs **after** the precision core (seed + grow + validate) has
5//! converged. It enumerates `(i, j)` cells inside the labelled
6//! bounding box that are still empty, plus `±1` cells immediately
7//! outside each row / column boundary, and tries to attach a
8//! candidate at each one using the same per-cell ladder as
9//! [`crate::square::grow::bfs_grow`].
10//!
11//! # Precision contract
12//!
13//! Fill attachments must obey the same invariants as BFS-grow
14//! attachments. The validator's existing gates (`is_eligible`,
15//! `required_label_at`, `label_of`, `accept_candidate`) are reused
16//! verbatim; the edge check delegates to [`GrowValidator::fill_edge_ok`]
17//! which, by default, forwards to [`GrowValidator::edge_ok`].
18//!
19//! # Pattern extension points
20//!
21//! Two optional methods on [`GrowValidator`] let pattern crates
22//! customise the fill pass without touching this module:
23//!
24//! - [`GrowValidator::eligible_for_fill`] — widen the admissible
25//!   candidate set during the booster pass (e.g. admit near-cluster
26//!   corners that the precision core dropped by a hair).
27//! - [`GrowValidator::fill_edge_ok`] — replace the scalar-`cell_size`
28//!   edge-length check with a labelled-set-aware (e.g. directional
29//!   median) variant. Useful when a component is strongly anisotropic
30//!   before final recovery has filled the boundary.
31//!
32//! Patterns whose precision core is strict enough that the booster
33//! pass should not relax anything need not override either method —
34//! the defaults forward to `is_eligible` and `edge_ok`.
35//!
36//! # Iteration
37//!
38//! The pass iterates until a full scan attaches zero new corners,
39//! capped at `max_iters`. Each iteration rebuilds the KD-tree over
40//! `eligible_for_fill` candidates because the previous iteration's
41//! attachments shrink the eligible set.
42
43use crate::square::grow::{
44    collect_labelled_neighbours, predict_from_neighbours, Admit, FillEdgeCtx, GrowResult,
45    GrowValidator,
46};
47use kiddo::{KdTree, SquaredEuclidean};
48use nalgebra::Point2;
49
50/// Tunables for [`fill_grid_holes`].
51#[non_exhaustive]
52#[derive(Clone, Copy, Debug)]
53pub struct FillParams {
54    /// Candidate-search radius (fraction of `cell_size`) around each
55    /// per-cell prediction.
56    pub attach_search_rel: f32,
57    /// Ambiguity factor: if the second-nearest candidate is within
58    /// `factor × nearest_distance`, the cell is skipped.
59    pub attach_ambiguity_factor: f32,
60    /// Maximum number of full scans. Defaults to 1 — most boards
61    /// converge in one pass, and the precision contract is identical
62    /// per iteration, so additional iterations only help when an
63    /// attached corner enables further attachments in the same
64    /// cell's 3×3 window.
65    pub max_iters: usize,
66}
67
68impl Default for FillParams {
69    fn default() -> Self {
70        Self {
71            attach_search_rel: 0.35,
72            attach_ambiguity_factor: 1.5,
73            max_iters: 1,
74        }
75    }
76}
77
78impl FillParams {
79    /// Construct fill parameters from the search radius (relative to
80    /// `cell_size`), the ambiguity factor, and the maximum scan count.
81    pub fn new(attach_search_rel: f32, attach_ambiguity_factor: f32, max_iters: usize) -> Self {
82        Self {
83            attach_search_rel,
84            attach_ambiguity_factor,
85            max_iters,
86        }
87    }
88}
89
90/// Counters returned by [`fill_grid_holes`].
91#[non_exhaustive]
92#[derive(Clone, Debug, Default)]
93pub struct FillStats {
94    /// Total number of corners attached across all iterations.
95    pub added: usize,
96    /// Number of full scans performed.
97    pub iterations: usize,
98    /// Corner indices of all attached corners (parallel to
99    /// [`attached_cells`][FillStats::attached_cells]). Callers use
100    /// this to update per-corner state without re-scanning
101    /// `grow.labelled`.
102    pub attached_indices: Vec<usize>,
103    /// Grid cells at which each attached corner was placed.
104    pub attached_cells: Vec<(i32, i32)>,
105}
106
107/// Run the unified fill pass: interior gap fill + line extrapolation.
108///
109/// Mutates `grow.labelled` and `grow.by_corner` in place. The caller
110/// is responsible for downstream per-corner state updates (e.g.
111/// marking newly-attached corners as "Labeled" in a local stage enum)
112/// — `fill_grid_holes` records the attached indices in
113/// [`FillStats::attached_indices`] so the caller can sweep them
114/// without re-scanning the labelled map.
115///
116/// Returns a [`FillStats`] summary.
117pub fn fill_grid_holes<V: GrowValidator>(
118    positions: &[Point2<f32>],
119    grow: &mut GrowResult,
120    cell_size: f32,
121    params: &FillParams,
122    validator: &V,
123) -> FillStats {
124    let mut stats = FillStats::default();
125    if grow.labelled.is_empty() {
126        return stats;
127    }
128
129    for _iter in 0..params.max_iters.max(1) {
130        stats.iterations += 1;
131        let (tree, slot_to_corner) = build_fill_tree(positions, grow, validator);
132        let ctx = FillCtx {
133            positions,
134            cell_size,
135            params,
136            tree: &tree,
137            slot_to_corner: &slot_to_corner,
138            validator,
139        };
140
141        let cells = enumerate_fill_cells(grow);
142        let mut added_this_iter = 0usize;
143        for cell in cells {
144            if grow.labelled.contains_key(&cell) {
145                continue;
146            }
147            if let Some(attached_idx) = try_fill_cell(cell, grow, &ctx) {
148                stats.attached_indices.push(attached_idx);
149                stats.attached_cells.push(cell);
150                added_this_iter += 1;
151            }
152        }
153        stats.added += added_this_iter;
154        if added_this_iter == 0 {
155            break;
156        }
157    }
158
159    stats
160}
161
162fn build_fill_tree<V: GrowValidator>(
163    positions: &[Point2<f32>],
164    grow: &GrowResult,
165    validator: &V,
166) -> (KdTree<f32, 2>, Vec<usize>) {
167    let mut tree: KdTree<f32, 2> = KdTree::new();
168    let mut slot_to_corner: Vec<usize> = Vec::new();
169    for (idx, pos) in positions.iter().enumerate() {
170        if validator.eligible_for_fill(idx) && !grow.by_corner.contains_key(&idx) {
171            tree.add(&[pos.x, pos.y], slot_to_corner.len() as u64);
172            slot_to_corner.push(idx);
173        }
174    }
175    (tree, slot_to_corner)
176}
177
178/// Cells to try: every unlabelled cell inside the labelled bbox plus
179/// `±1` rows / columns just outside.
180fn enumerate_fill_cells(grow: &GrowResult) -> Vec<(i32, i32)> {
181    use std::collections::HashSet;
182    let mut out: HashSet<(i32, i32)> = HashSet::new();
183    let (mut min_i, mut max_i, mut min_j, mut max_j) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
184    for &(i, j) in grow.labelled.keys() {
185        min_i = min_i.min(i);
186        max_i = max_i.max(i);
187        min_j = min_j.min(j);
188        max_j = max_j.max(j);
189    }
190
191    // Interior gap fill: every unlabelled cell inside the bbox.
192    for j in min_j..=max_j {
193        for i in min_i..=max_i {
194            if !grow.labelled.contains_key(&(i, j)) {
195                out.insert((i, j));
196            }
197        }
198    }
199
200    // Line extrapolation: ±1 beyond the bbox ends, at every row
201    // and column that has any labelled member.
202    for j in min_j..=max_j {
203        out.insert((min_i - 1, j));
204        out.insert((max_i + 1, j));
205    }
206    for i in min_i..=max_i {
207        out.insert((i, min_j - 1));
208        out.insert((i, max_j + 1));
209    }
210
211    out.into_iter().collect()
212}
213
214/// Per-iteration context for the fill pass.
215///
216/// Bundles the references that every per-cell call would otherwise
217/// have to re-thread. Each iteration of [`fill_grid_holes`] builds one
218/// of these once and reuses it for all candidate cells.
219struct FillCtx<'a, V: GrowValidator> {
220    positions: &'a [Point2<f32>],
221    cell_size: f32,
222    params: &'a FillParams,
223    tree: &'a KdTree<f32, 2>,
224    slot_to_corner: &'a [usize],
225    validator: &'a V,
226}
227
228fn try_fill_cell<V: GrowValidator>(
229    cell: (i32, i32),
230    grow: &mut GrowResult,
231    ctx: &FillCtx<'_, V>,
232) -> Option<usize> {
233    let positions = ctx.positions;
234    let cell_size = ctx.cell_size;
235    let params = ctx.params;
236    let tree = ctx.tree;
237    let slot_to_corner = ctx.slot_to_corner;
238    let validator = ctx.validator;
239    // Collect 8-connected labelled neighbours of `cell`. For interior
240    // gaps this will usually be 4+; for line extensions it will be 1
241    // (the last corner of the line) up to 3 (with diagonals from
242    // adjacent labelled rows).
243    let neighbours = collect_labelled_neighbours(cell, 1, &grow.labelled, positions);
244    if neighbours.is_empty() {
245        return None;
246    }
247
248    // Adaptive prediction: each labelled neighbour contributes a
249    // finite-difference local-step from its own labelled peers when
250    // available, falling back to the global `(u, v) × cell_size` step
251    // otherwise.
252    let pred = predict_from_neighbours(
253        cell,
254        &neighbours,
255        grow.axis_i,
256        grow.axis_j,
257        cell_size,
258        &grow.labelled,
259        positions,
260    );
261
262    // Required label at this cell (parity for a chessboard, or `None`
263    // for patterns with no parity gate).
264    let required_label = validator.required_label_at(cell.0, cell.1);
265
266    // Candidate search.
267    let search_r = params.attach_search_rel * cell_size;
268    let r2 = search_r * search_r;
269    let mut hits: Vec<(usize, f32)> = Vec::new();
270    for nn in tree
271        .within_unsorted::<SquaredEuclidean>(&[pred.x, pred.y], r2)
272        .into_iter()
273    {
274        let slot = nn.item as usize;
275        let idx = slot_to_corner[slot];
276        if grow.by_corner.contains_key(&idx) {
277            continue;
278        }
279        if let Some(req) = required_label {
280            if validator.label_of(idx) != Some(req) {
281                continue;
282            }
283        }
284        if matches!(
285            validator.accept_candidate(idx, cell, pred, &neighbours),
286            Admit::Reject
287        ) {
288            continue;
289        }
290        hits.push((idx, nn.distance.sqrt()));
291    }
292    hits.sort_by(|a, b| a.1.total_cmp(&b.1));
293
294    let candidate_idx = match hits.len() {
295        0 => return None,
296        1 => hits[0].0,
297        _ => {
298            let d0 = hits[0].1.max(f32::EPSILON);
299            let d1 = hits[1].1;
300            if d1 / d0 < params.attach_ambiguity_factor {
301                return None;
302            }
303            hits[0].0
304        }
305    };
306
307    // Edge-invariant check via the validator's fill-aware variant.
308    // At least one cardinal labelled neighbour must accept the
309    // candidate; the chessboard validator can swap the scalar
310    // `cell_size` for a directional median here when its component
311    // is strongly anisotropic.
312    if !any_cardinal_fill_edge_ok(
313        candidate_idx,
314        cell,
315        positions,
316        &grow.labelled,
317        validator,
318        cell_size,
319    ) {
320        return None;
321    }
322
323    // Attach.
324    grow.labelled.insert(cell, candidate_idx);
325    grow.by_corner.insert(candidate_idx, cell);
326    Some(candidate_idx)
327}
328
329fn any_cardinal_fill_edge_ok<V: GrowValidator>(
330    c_idx: usize,
331    pos: (i32, i32),
332    positions: &[Point2<f32>],
333    labelled: &std::collections::HashMap<(i32, i32), usize>,
334    validator: &V,
335    cell_size: f32,
336) -> bool {
337    let mut found_any = false;
338    for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
339        let neigh = (pos.0 + di, pos.1 + dj);
340        if let Some(&n_idx) = labelled.get(&neigh) {
341            found_any = true;
342            let ctx = FillEdgeCtx {
343                candidate_idx: c_idx,
344                neighbour_idx: n_idx,
345                at_candidate: pos,
346                at_neighbour: neigh,
347                labelled,
348                positions,
349                cell_size,
350            };
351            if validator.fill_edge_ok(ctx) {
352                return true;
353            }
354        }
355    }
356    !found_any
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::square::grow::{Admit, LabelledNeighbour};
363    use std::collections::HashMap;
364
365    /// Trivial validator: every corner eligible, no label constraint,
366    /// accept everything, accept all edges.
367    struct OpenValidator;
368
369    impl GrowValidator for OpenValidator {
370        fn is_eligible(&self, _idx: usize) -> bool {
371            true
372        }
373        fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
374            None
375        }
376        fn label_of(&self, _idx: usize) -> Option<u8> {
377            None
378        }
379        fn accept_candidate(
380            &self,
381            _idx: usize,
382            _at: (i32, i32),
383            _prediction: Point2<f32>,
384            _neighbours: &[LabelledNeighbour],
385        ) -> Admit {
386            Admit::Accept
387        }
388    }
389
390    #[test]
391    fn fill_pass_attaches_interior_hole() {
392        // 3×3 grid; (1, 1) un-labelled, surrounded by 8 neighbours.
393        let s = 20.0_f32;
394        let mut positions: Vec<Point2<f32>> = Vec::new();
395        for j in 0..3 {
396            for i in 0..3 {
397                positions.push(Point2::new(50.0 + i as f32 * s, 50.0 + j as f32 * s));
398            }
399        }
400        let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
401        let mut by_corner: HashMap<usize, (i32, i32)> = HashMap::new();
402        for j in 0..3 {
403            for i in 0..3 {
404                if (i, j) == (1, 1) {
405                    continue;
406                }
407                let idx = (j * 3 + i) as usize;
408                labelled.insert((i, j), idx);
409                by_corner.insert(idx, (i, j));
410            }
411        }
412        let mut grow = GrowResult {
413            labelled,
414            by_corner,
415            ambiguous: Default::default(),
416            holes: Default::default(),
417            axis_i: nalgebra::Vector2::new(1.0, 0.0),
418            axis_j: nalgebra::Vector2::new(0.0, 1.0),
419            parity_shift_i: 0,
420            parity_shift_j: 0,
421        };
422        let stats = fill_grid_holes(
423            &positions,
424            &mut grow,
425            s,
426            &FillParams::default(),
427            &OpenValidator,
428        );
429        assert_eq!(stats.added, 1);
430        assert_eq!(grow.labelled.get(&(1, 1)), Some(&4));
431        assert_eq!(stats.attached_indices, vec![4]);
432        assert_eq!(stats.attached_cells, vec![(1, 1)]);
433    }
434}