Skip to main content

projective_grid/shared/
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** a component-assembly + validate pass has converged.
5//! It enumerates `(i, j)` cells inside the labelled bounding box that
6//! are still empty, plus `±1` cells immediately outside each row /
7//! column boundary, and tries to attach a candidate at each one using
8//! the same per-cell ladder as the [`crate::shared::grow`] candidate
9//! search.
10//!
11//! # Precision contract
12//!
13//! Fill attachments must obey the same invariants as BFS-grow
14//! attachments. The policy's existing gates (`is_eligible`,
15//! `required_label_at`, `label_of`, `accept_candidate`) are reused
16//! verbatim; the edge check delegates to [`SquareAttachPolicy::fill_edge_ok`]
17//! which, by default, forwards to [`SquareAttachPolicy::edge_ok`].
18//!
19//! # Pattern extension points
20//!
21//! Two optional methods on [`SquareAttachPolicy`] let pattern crates
22//! customise the fill pass without touching this module:
23//!
24//! - [`SquareAttachPolicy::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//! - [`SquareAttachPolicy::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::shared::grow::{
44    collect_labelled_neighbours, predict_from_neighbours, Admit, FillEdgeCtx, GrowResult,
45    SquareAttachPolicy,
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: SquareAttachPolicy>(
118    positions: &[Point2<f32>],
119    grow: &mut GrowResult,
120    cell_size: f32,
121    params: &FillParams,
122    policy: &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, policy);
132        let ctx = FillCtx {
133            positions,
134            cell_size,
135            params,
136            tree: &tree,
137            slot_to_corner: &slot_to_corner,
138            policy,
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: SquareAttachPolicy>(
163    positions: &[Point2<f32>],
164    grow: &GrowResult,
165    policy: &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 policy.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    // Determinism: the fill pass attaches corners greedily in scan
212    // order, and two adjacent candidate cells can compete for the same
213    // boundary corner (whichever cell is visited first claims it). A
214    // `HashSet` iteration order is randomized per process, so returning
215    // the raw set order makes the labelled boundary extent vary run to
216    // run for identical input. Sort by `(i, j)` to pin the scan order;
217    // this is byte-stable across runs and does not change the outcome
218    // for inputs whose attachments are uncontested (the common case for
219    // the topological recovery caller).
220    let mut cells: Vec<(i32, i32)> = out.into_iter().collect();
221    cells.sort_unstable();
222    cells
223}
224
225/// Per-iteration context for the fill pass.
226///
227/// Bundles the references that every per-cell call would otherwise
228/// have to re-thread. Each iteration of [`fill_grid_holes`] builds one
229/// of these once and reuses it for all candidate cells.
230struct FillCtx<'a, V: SquareAttachPolicy> {
231    positions: &'a [Point2<f32>],
232    cell_size: f32,
233    params: &'a FillParams,
234    tree: &'a KdTree<f32, 2>,
235    slot_to_corner: &'a [usize],
236    policy: &'a V,
237}
238
239fn try_fill_cell<V: SquareAttachPolicy>(
240    cell: (i32, i32),
241    grow: &mut GrowResult,
242    ctx: &FillCtx<'_, V>,
243) -> Option<usize> {
244    let positions = ctx.positions;
245    let cell_size = ctx.cell_size;
246    let params = ctx.params;
247    let tree = ctx.tree;
248    let slot_to_corner = ctx.slot_to_corner;
249    let policy = ctx.policy;
250    // Collect 8-connected labelled neighbours of `cell`. For interior
251    // gaps this will usually be 4+; for line extensions it will be 1
252    // (the last corner of the line) up to 3 (with diagonals from
253    // adjacent labelled rows).
254    let neighbours = collect_labelled_neighbours(cell, 1, &grow.labelled, positions);
255    if neighbours.is_empty() {
256        return None;
257    }
258
259    // Adaptive prediction: each labelled neighbour contributes a
260    // finite-difference local-step from its own labelled peers when
261    // available, falling back to the global `(u, v) × cell_size` step
262    // otherwise.
263    let pred = predict_from_neighbours(
264        cell,
265        &neighbours,
266        grow.axis_i,
267        grow.axis_j,
268        cell_size,
269        &grow.labelled,
270        positions,
271    );
272
273    // Optional caller-defined label required at this cell.
274    let required_label = policy.required_label_at(cell.0, cell.1);
275
276    // Candidate search.
277    let search_r = params.attach_search_rel * cell_size;
278    let r2 = search_r * search_r;
279    let mut hits: Vec<(usize, f32)> = Vec::new();
280    for nn in tree
281        .within_unsorted::<SquaredEuclidean>(&[pred.x, pred.y], r2)
282        .into_iter()
283    {
284        let slot = nn.item as usize;
285        let idx = slot_to_corner[slot];
286        if grow.by_corner.contains_key(&idx) {
287            continue;
288        }
289        if let Some(req) = required_label {
290            if policy.label_of(idx) != Some(req) {
291                continue;
292            }
293        }
294        if matches!(
295            policy.accept_candidate(idx, cell, pred, &neighbours),
296            Admit::Reject
297        ) {
298            continue;
299        }
300        hits.push((idx, nn.distance.sqrt()));
301    }
302    hits.sort_by(|a, b| a.1.total_cmp(&b.1));
303
304    let candidate_idx = match hits.len() {
305        0 => return None,
306        1 => hits[0].0,
307        _ => {
308            let d0 = hits[0].1.max(f32::EPSILON);
309            let d1 = hits[1].1;
310            if d1 / d0 < params.attach_ambiguity_factor {
311                return None;
312            }
313            hits[0].0
314        }
315    };
316
317    // Edge-invariant check via the policy's fill-aware variant.
318    // At least one cardinal labelled neighbour must accept the
319    // candidate; a policy can swap the scalar
320    // `cell_size` for a directional median here when its component
321    // is strongly anisotropic.
322    if !any_cardinal_fill_edge_ok(
323        candidate_idx,
324        cell,
325        positions,
326        &grow.labelled,
327        policy,
328        cell_size,
329    ) {
330        return None;
331    }
332
333    // Attach.
334    grow.labelled.insert(cell, candidate_idx);
335    grow.by_corner.insert(candidate_idx, cell);
336    Some(candidate_idx)
337}
338
339fn any_cardinal_fill_edge_ok<V: SquareAttachPolicy>(
340    c_idx: usize,
341    pos: (i32, i32),
342    positions: &[Point2<f32>],
343    labelled: &std::collections::HashMap<(i32, i32), usize>,
344    policy: &V,
345    cell_size: f32,
346) -> bool {
347    let mut found_any = false;
348    for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
349        let neigh = (pos.0 + di, pos.1 + dj);
350        if let Some(&n_idx) = labelled.get(&neigh) {
351            found_any = true;
352            let ctx = FillEdgeCtx {
353                candidate_idx: c_idx,
354                neighbour_idx: n_idx,
355                at_candidate: pos,
356                at_neighbour: neigh,
357                labelled,
358                positions,
359                cell_size,
360            };
361            if policy.fill_edge_ok(ctx) {
362                return true;
363            }
364        }
365    }
366    !found_any
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::shared::grow::{Admit, LabelledNeighbour};
373    use std::collections::HashMap;
374
375    /// Trivial policy: every corner eligible, no label constraint,
376    /// accept everything, accept all edges.
377    struct OpenValidator;
378
379    impl SquareAttachPolicy for OpenValidator {
380        fn is_eligible(&self, _idx: usize) -> bool {
381            true
382        }
383        fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
384            None
385        }
386        fn label_of(&self, _idx: usize) -> Option<u8> {
387            None
388        }
389        fn accept_candidate(
390            &self,
391            _idx: usize,
392            _at: (i32, i32),
393            _prediction: Point2<f32>,
394            _neighbours: &[LabelledNeighbour],
395        ) -> Admit {
396            Admit::Accept
397        }
398    }
399
400    #[test]
401    fn fill_pass_attaches_interior_hole() {
402        // 3×3 grid; (1, 1) un-labelled, surrounded by 8 neighbours.
403        let s = 20.0_f32;
404        let mut positions: Vec<Point2<f32>> = Vec::new();
405        for j in 0..3 {
406            for i in 0..3 {
407                positions.push(Point2::new(50.0 + i as f32 * s, 50.0 + j as f32 * s));
408            }
409        }
410        let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
411        let mut by_corner: HashMap<usize, (i32, i32)> = HashMap::new();
412        for j in 0..3 {
413            for i in 0..3 {
414                if (i, j) == (1, 1) {
415                    continue;
416                }
417                let idx = (j * 3 + i) as usize;
418                labelled.insert((i, j), idx);
419                by_corner.insert(idx, (i, j));
420            }
421        }
422        let mut grow = GrowResult {
423            labelled,
424            by_corner,
425            ambiguous: Default::default(),
426            holes: Default::default(),
427            axis_i: nalgebra::Vector2::new(1.0, 0.0),
428            axis_j: nalgebra::Vector2::new(0.0, 1.0),
429            rebase_i_mod2: 0,
430            rebase_j_mod2: 0,
431        };
432        let stats = fill_grid_holes(
433            &positions,
434            &mut grow,
435            s,
436            &FillParams::default(),
437            &OpenValidator,
438        );
439        assert_eq!(stats.added, 1);
440        assert_eq!(grow.labelled.get(&(1, 1)), Some(&4));
441        assert_eq!(stats.attached_indices, vec![4]);
442        assert_eq!(stats.attached_cells, vec![(1, 1)]);
443    }
444}