Skip to main content

rustial_engine/terrain/
backfill.rs

1// ---------------------------------------------------------------------------
2//! # DEM edge backfilling -- eliminate elevation seams at tile boundaries
3//!
4//! ## Problem
5//!
6//! When adjacent DEM tiles are decoded independently, hillshade and
7//! terrain normal kernels read one sample beyond the tile edge and fall
8//! back to clamping, producing an incorrect (flat) gradient that appears
9//! as a visible seam.
10//!
11//! ## Solution (hybrid in-place patching -- MapLibre-style)
12//!
13//! MapLibre GL JS solves this with `DEMData.backfillBorder`: each DEM
14//! tile is allocated with a 1-pixel border at decode time
15//! (`stride = dim + 2`).  The border is initially clamped to the
16//! nearest interior sample.  When a neighbour loads,
17//! `backfillBorder(dx, dy)` copies the neighbour's edge directly into
18//! the border -- zero allocation, one row/column copy per event.
19//!
20//! Rustial follows the same strategy:
21//!
22//! 1. **On decode** -- [`expand_with_clamped_border`] wraps the raw grid
23//!    in a 1-sample border filled by clamping.  The result is stored in
24//!    the terrain manager's cache.  One allocation per tile, at load
25//!    time only.
26//!
27//! 2. **On neighbour arrival** -- [`patch_border_edge`] overwrites the
28//!    affected border strip in-place.  Cost: one `memcpy` of a single
29//!    row or column (typically 256 x 4 bytes = 1 KB).  No heap
30//!    allocation.
31//!
32//! ## Border-sample layout
33//!
34//! After expansion a grid with interior dimensions `W x H` is stored as
35//! `(W + 2) x (H + 2)`:
36//!
37//! ```text
38//!    NW | ---- north ---- | NE
39//!   ----+------------------+----
40//!   w   |   interior WxH   |  e
41//!   e   |                   |  a
42//!   s   |                   |  s
43//!   t   |                   |  t
44//!   ----+------------------+----
45//!    SW | ---- south ---- | SE
46//! ```
47//!
48//! Interior dimensions are always `(stored_width - 2, stored_height - 2)`.
49//!
50//! ## Integration point
51//!
52//! The terrain manager calls these functions automatically:
53//! - `expand_with_clamped_border` when inserting poll results into the
54//!   cache.
55//! - `patch_border_edge` for each cached tile whose neighbour just
56//!   loaded (or whose own data just loaded, triggering neighbour
57//!   patching of its own borders).
58// ---------------------------------------------------------------------------
59
60use rustial_math::{ElevationGrid, TileId};
61use std::collections::HashMap;
62
63// ---------------------------------------------------------------------------
64// Public API
65// ---------------------------------------------------------------------------
66
67/// Interior (source) dimensions of an expanded grid.
68///
69/// The cache stores grids at `(W+2) x (H+2)`.  This helper returns the
70/// original `(W, H)` before the border was added.
71#[inline]
72pub fn interior_dims(grid: &ElevationGrid) -> (u32, u32) {
73    (grid.width.saturating_sub(2), grid.height.saturating_sub(2))
74}
75
76/// Wrap a raw elevation grid in a 1-sample border, clamped to the
77/// nearest interior sample.
78///
79/// The returned grid has dimensions `(W+2) x (H+2)` and the same
80/// `tile` field as the input.  The border serves as a scratch area
81/// that [`patch_border_edge`] can overwrite in-place when neighbour
82/// data becomes available.
83///
84/// This function allocates exactly once (`(W+2)*(H+2` f32 samples).
85/// It should be called **once** per tile, immediately after the
86/// elevation source delivers decoded data.
87pub fn expand_with_clamped_border(src: &ElevationGrid) -> ElevationGrid {
88    let w = src.width;
89    let h = src.height;
90    let new_w = w + 2;
91    let new_h = h + 2;
92    let mut data = vec![0.0f32; (new_w * new_h) as usize];
93
94    // Copy interior at offset (1, 1).
95    for row in 0..h {
96        let src_start = (row * w) as usize;
97        let dst_start = ((row + 1) * new_w + 1) as usize;
98        data[dst_start..dst_start + w as usize]
99            .copy_from_slice(&src.data[src_start..src_start + w as usize]);
100    }
101
102    // Clamp north border (row 0, cols 1..=w) to interior row 0.
103    for col in 0..w {
104        data[(col + 1) as usize] = src.data[col as usize];
105    }
106    // Clamp south border (row h+1, cols 1..=w) to interior last row.
107    for col in 0..w {
108        data[((h + 1) * new_w + col + 1) as usize] = src.data[((h - 1) * w + col) as usize];
109    }
110    // Clamp west border (col 0, rows 1..=h) to interior col 0.
111    for row in 0..h {
112        data[((row + 1) * new_w) as usize] = src.data[(row * w) as usize];
113    }
114    // Clamp east border (col w+1, rows 1..=h) to interior last col.
115    for row in 0..h {
116        data[((row + 1) * new_w + w + 1) as usize] = src.data[(row * w + w - 1) as usize];
117    }
118
119    // Clamp four corners.
120    data[0] = src.data[0]; // NW
121    data[(w + 1) as usize] = src.data[(w - 1) as usize]; // NE
122    data[((h + 1) * new_w) as usize] = src.data[((h - 1) * w) as usize]; // SW
123    data[((h + 1) * new_w + w + 1) as usize] = src.data[((h - 1) * w + w - 1) as usize]; // SE
124
125    // min/max stay the same -- the border is a subset of interior values.
126    ElevationGrid {
127        width: new_w,
128        height: new_h,
129        min_elev: src.min_elev,
130        max_elev: src.max_elev,
131        data,
132        tile: src.tile,
133    }
134}
135
136/// Overwrite one border strip of `target` with edge data from
137/// `neighbor`, in place.
138///
139/// `dx` and `dy` describe the relative position of the **neighbour**
140/// with respect to the target tile:
141///
142/// | (dx, dy) | meaning           | border written |
143/// |----------|-------------------|----------------|
144/// | ( 0, -1) | neighbour is north | top row        |
145/// | ( 0,  1) | neighbour is south | bottom row     |
146/// | (-1,  0) | neighbour is west  | left column    |
147/// | ( 1,  0) | neighbour is east  | right column   |
148/// | (-1, -1) | neighbour is NW    | NW corner      |
149/// | ( 1, -1) | neighbour is NE    | NE corner      |
150/// | (-1,  1) | neighbour is SW    | SW corner      |
151/// | ( 1,  1) | neighbour is SE    | SE corner      |
152///
153/// Both grids must be in expanded form (`(W+2) x (H+2)`) and have the
154/// same interior dimensions.  If they differ, the call is a no-op.
155///
156/// After patching, `target.min_elev` / `max_elev` are updated if the
157/// newly written samples extend the range.
158pub fn patch_border_edge(target: &mut ElevationGrid, neighbor: &ElevationGrid, dx: i32, dy: i32) {
159    let (tw, th) = interior_dims(target);
160    let (nw, nh) = interior_dims(neighbor);
161    if tw != nw || th != nh || tw == 0 || th == 0 {
162        return;
163    }
164
165    let stride = target.width; // == tw + 2
166
167    // Clamp border values to a generous window around the target's
168    // interior elevation range.  This preserves seamless transitions
169    // between land tiles while preventing extreme ocean depths or
170    // corrupt neighbour data from producing displacement spikes in the
171    // GPU shader's bilinear sampling.
172    let interior_range = target.max_elev - target.min_elev;
173    let margin = (interior_range * 0.5).max(200.0);
174    let clamp_lo = target.min_elev - margin;
175    let clamp_hi = target.max_elev + margin;
176
177    let mut write = |idx: usize, val: f32| {
178        target.data[idx] = val.clamp(clamp_lo, clamp_hi);
179    };
180
181    match (dx, dy) {
182        // Cardinal neighbours -- copy a full row or column.
183        (0, -1) => {
184            // Neighbour is north: write target's top border row.
185            // Source: neighbour's last interior row (row `nh` in expanded coords).
186            let src_row = nh;
187            for col in 0..tw {
188                let src_idx = (src_row * stride + col + 1) as usize;
189                let dst_idx = (col + 1) as usize; // row 0
190                write(dst_idx, neighbor.data[src_idx]);
191            }
192        }
193        (0, 1) => {
194            // Neighbour is south: write target's bottom border row.
195            // Source: neighbour's first interior row (row 1 in expanded).
196            let src_row = 1u32;
197            let dst_row = th + 1;
198            for col in 0..tw {
199                let src_idx = (src_row * stride + col + 1) as usize;
200                let dst_idx = (dst_row * stride + col + 1) as usize;
201                write(dst_idx, neighbor.data[src_idx]);
202            }
203        }
204        (-1, 0) => {
205            // Neighbour is west: write target's left border column.
206            // Source: neighbour's last interior column (col `nw` in expanded).
207            let src_col = nw;
208            for row in 0..th {
209                let src_idx = ((row + 1) * stride + src_col) as usize;
210                let dst_idx = ((row + 1) * stride) as usize; // col 0
211                write(dst_idx, neighbor.data[src_idx]);
212            }
213        }
214        (1, 0) => {
215            // Neighbour is east: write target's right border column.
216            // Source: neighbour's first interior column (col 1 in expanded).
217            let src_col = 1u32;
218            let dst_col = tw + 1;
219            for row in 0..th {
220                let src_idx = ((row + 1) * stride + src_col) as usize;
221                let dst_idx = ((row + 1) * stride + dst_col) as usize;
222                write(dst_idx, neighbor.data[src_idx]);
223            }
224        }
225
226        // Diagonal neighbours -- single corner sample.
227        (-1, -1) => {
228            // NW corner: source = neighbour's SE interior corner.
229            let src_idx = (nh * stride + nw) as usize;
230            write(0, neighbor.data[src_idx]);
231        }
232        (1, -1) => {
233            // NE corner: source = neighbour's SW interior corner.
234            let src_idx = (nh * stride + 1) as usize;
235            write((tw + 1) as usize, neighbor.data[src_idx]);
236        }
237        (-1, 1) => {
238            // SW corner: source = neighbour's NE interior corner.
239            let src_idx = (stride + nw) as usize;
240            write(((th + 1) * stride) as usize, neighbor.data[src_idx]);
241        }
242        (1, 1) => {
243            // SE corner: source = neighbour's NW interior corner.
244            let src_idx = (stride + 1) as usize;
245            write(
246                ((th + 1) * stride + tw + 1) as usize,
247                neighbor.data[src_idx],
248            );
249        }
250
251        _ => {} // Unsupported offset -- silently ignore.
252    }
253}
254
255/// The eight (dx, dy) neighbour offsets -- four cardinal, four diagonal.
256pub const NEIGHBOR_OFFSETS: [(i32, i32); 8] = [
257    (0, -1),  // north
258    (0, 1),   // south
259    (-1, 0),  // west
260    (1, 0),   // east
261    (-1, -1), // NW
262    (1, -1),  // NE
263    (-1, 1),  // SW
264    (1, 1),   // SE
265];
266
267/// Per-tile record of which neighbour borders have been backfilled.
268///
269/// Each flag corresponds to one of the eight [`NEIGHBOR_OFFSETS`].
270/// When a flag is `true`, the border strip for that direction contains
271/// real neighbour data rather than clamped self-data.
272#[derive(Debug, Clone, Default)]
273pub struct BackfillState {
274    /// Indexed by the same order as [`NEIGHBOR_OFFSETS`]:
275    /// `[N, S, W, E, NW, NE, SW, SE]`.
276    pub filled: [bool; 8],
277}
278
279impl BackfillState {
280    /// Reset all flags to `false` (all borders need re-patching).
281    pub fn reset(&mut self) {
282        self.filled = [false; 8];
283    }
284
285    /// Mark the direction at `offset_index` as backfilled.
286    #[inline]
287    pub fn mark(&mut self, offset_index: usize) {
288        if offset_index < 8 {
289            self.filled[offset_index] = true;
290        }
291    }
292
293    /// Check whether the direction at `offset_index` has been filled.
294    #[inline]
295    pub fn is_filled(&self, offset_index: usize) -> bool {
296        offset_index < 8 && self.filled[offset_index]
297    }
298}
299
300/// Run incremental backfill patching on all tiles affected by a set of
301/// newly loaded tiles.
302///
303/// For each tile in `changed`, its own borders are patched from any
304/// cached neighbours, and each cached neighbour's border facing the
305/// changed tile is also patched.
306///
307/// `backfill_states` tracks which borders have already been filled so
308/// that redundant copies are skipped.
309///
310/// Returns the set of tiles whose cached grid was actually modified
311/// (useful for generation bumping / hillshade invalidation).
312pub fn patch_changed_tiles(
313    cache: &mut HashMap<TileId, ElevationGrid>,
314    backfill_states: &mut HashMap<TileId, BackfillState>,
315    changed: &std::collections::HashSet<TileId>,
316) -> std::collections::HashSet<TileId> {
317    let mut modified = std::collections::HashSet::new();
318
319    // Collect the list of (target, neighbor, offset_index, dx, dy)
320    // pairs that need patching.  We must collect first because we
321    // cannot borrow `cache` mutably while iterating neighbour lookups.
322    struct PatchOp {
323        target: TileId,
324        neighbor: TileId,
325        offset_index: usize,
326        dx: i32,
327        dy: i32,
328    }
329
330    let mut ops: Vec<PatchOp> = Vec::new();
331
332    // Reset backfill states for changed tiles so all borders are
333    // re-patched with fresh data.
334    for &tile in changed {
335        backfill_states.entry(tile).or_default().reset();
336    }
337
338    for &tile in changed {
339        // 1) Patch this tile's borders from its cached neighbours.
340        for (idx, &(dx, dy)) in NEIGHBOR_OFFSETS.iter().enumerate() {
341            if let Some(nb) = tile.neighbor(dx, dy) {
342                if cache.contains_key(&nb) {
343                    ops.push(PatchOp {
344                        target: tile,
345                        neighbor: nb,
346                        offset_index: idx,
347                        dx,
348                        dy,
349                    });
350                }
351            }
352        }
353
354        // 2) Patch each cached neighbour's border that faces this tile.
355        for &(dx, dy) in NEIGHBOR_OFFSETS.iter() {
356            if let Some(nb) = tile.neighbor(dx, dy) {
357                if cache.contains_key(&nb) {
358                    // The reverse direction: if this tile is the east
359                    // neighbour of `nb`, then `nb`'s east border should
360                    // contain data from this tile (offset (-dx, -dy)).
361                    let rev_idx = NEIGHBOR_OFFSETS
362                        .iter()
363                        .position(|&(rdx, rdy)| rdx == -dx && rdy == -dy);
364                    if let Some(ri) = rev_idx {
365                        let nb_state = backfill_states.entry(nb).or_default();
366                        if !nb_state.is_filled(ri) {
367                            ops.push(PatchOp {
368                                target: nb,
369                                neighbor: tile,
370                                offset_index: ri,
371                                dx: -dx,
372                                dy: -dy,
373                            });
374                        }
375                    }
376                }
377            }
378        }
379    }
380
381    // Apply all patch operations.
382    // We need split borrows: read from `neighbor`, write to `target`.
383    // Since target != neighbor for all valid operations (a tile is never
384    // its own neighbour), we clone the neighbour grid to avoid aliasing.
385    // For 256x256 grids the clone is ~260KB -- acceptable since this
386    // only happens when new tiles load (not every frame).
387    for op in &ops {
388        if op.target == op.neighbor {
389            continue;
390        }
391
392        let neighbor_grid = cache.get(&op.neighbor).cloned();
393        if let Some(ref nb_grid) = neighbor_grid {
394            if let Some(target_grid) = cache.get_mut(&op.target) {
395                patch_border_edge(target_grid, nb_grid, op.dx, op.dy);
396                backfill_states
397                    .entry(op.target)
398                    .or_default()
399                    .mark(op.offset_index);
400                modified.insert(op.target);
401            }
402        }
403    }
404
405    modified
406}
407
408// ---------------------------------------------------------------------------
409// Tests
410// ---------------------------------------------------------------------------
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    /// Helper: create a simple NxN grid filled with a constant value.
417    fn const_grid(tile: TileId, size: u32, value: f32) -> ElevationGrid {
418        ElevationGrid::from_data(tile, size, size, vec![value; (size * size) as usize])
419            .expect("valid grid")
420    }
421
422    /// Helper: create a grid with sequentially increasing sample values.
423    fn sequential_grid(tile: TileId, size: u32, base_val: f32) -> ElevationGrid {
424        let data: Vec<f32> = (0..size * size).map(|i| base_val + i as f32).collect();
425        ElevationGrid::from_data(tile, size, size, data).expect("valid grid")
426    }
427
428    // -- expand_with_clamped_border ----------------------------------------
429
430    #[test]
431    fn expand_produces_correct_dimensions() {
432        let tile = TileId::new(3, 4, 4);
433        let src = const_grid(tile, 4, 100.0);
434        let expanded = expand_with_clamped_border(&src);
435        assert_eq!(expanded.width, 6);
436        assert_eq!(expanded.height, 6);
437        assert_eq!(interior_dims(&expanded), (4, 4));
438    }
439
440    #[test]
441    fn expand_interior_unchanged() {
442        let tile = TileId::new(3, 4, 4);
443        let src = sequential_grid(tile, 4, 0.0);
444        let expanded = expand_with_clamped_border(&src);
445
446        // Interior at (row+1, col+1) in expanded must match original.
447        for row in 0..4u32 {
448            for col in 0..4u32 {
449                let orig = src.data[(row * 4 + col) as usize];
450                let exp = expanded.data[((row + 1) * 6 + (col + 1)) as usize];
451                assert!(
452                    (orig - exp).abs() < 1e-6,
453                    "interior mismatch at ({col},{row}): {orig} vs {exp}"
454                );
455            }
456        }
457    }
458
459    #[test]
460    fn expand_borders_clamp_to_nearest_interior() {
461        let tile = TileId::new(3, 4, 4);
462        let src = sequential_grid(tile, 4, 0.0);
463        let expanded = expand_with_clamped_border(&src);
464        let s = 6u32; // expanded stride
465
466        // North border (row 0) should equal interior row 0.
467        for col in 0..4u32 {
468            assert!(
469                (expanded.data[(col + 1) as usize] - src.data[col as usize]).abs() < 1e-6,
470                "north border mismatch at col {col}"
471            );
472        }
473        // South border (row 5) should equal interior row 3.
474        for col in 0..4u32 {
475            assert!(
476                (expanded.data[(5 * s + col + 1) as usize] - src.data[(3 * 4 + col) as usize])
477                    .abs()
478                    < 1e-6,
479                "south border mismatch at col {col}"
480            );
481        }
482        // West border (col 0) should equal interior col 0.
483        for row in 0..4u32 {
484            assert!(
485                (expanded.data[((row + 1) * s) as usize] - src.data[(row * 4) as usize]).abs()
486                    < 1e-6,
487                "west border mismatch at row {row}"
488            );
489        }
490        // East border (col 5) should equal interior col 3.
491        for row in 0..4u32 {
492            assert!(
493                (expanded.data[((row + 1) * s + 5) as usize] - src.data[(row * 4 + 3) as usize])
494                    .abs()
495                    < 1e-6,
496                "east border mismatch at row {row}"
497            );
498        }
499        // NW corner = interior (0,0).
500        assert!((expanded.data[0] - src.data[0]).abs() < 1e-6);
501        // SE corner = interior (3,3).
502        assert!(
503            (expanded.data[(5 * s + 5) as usize] - src.data[(3 * 4 + 3) as usize]).abs() < 1e-6
504        );
505    }
506
507    // -- patch_border_edge -------------------------------------------------
508
509    #[test]
510    fn patch_north_border() {
511        let tile = TileId::new(3, 4, 4);
512        let mut target = expand_with_clamped_border(&const_grid(tile, 4, 100.0));
513
514        let north_tile = TileId::new(3, 4, 3);
515        let neighbor = expand_with_clamped_border(&const_grid(north_tile, 4, 200.0));
516
517        patch_border_edge(&mut target, &neighbor, 0, -1);
518
519        let s = 6u32;
520        // North border (row 0, cols 1..=4) should now be 200.
521        for col in 1..=4u32 {
522            let val = target.data[col as usize];
523            assert!(
524                (val - 200.0).abs() < 1e-6,
525                "north border at col {col}: expected 200, got {val}"
526            );
527        }
528        // South border should still be clamped to 100.
529        for col in 1..=4u32 {
530            let val = target.data[(5 * s + col) as usize];
531            assert!(
532                (val - 100.0).abs() < 1e-6,
533                "south border should still be 100 at col {col}, got {val}"
534            );
535        }
536    }
537
538    #[test]
539    fn patch_east_border() {
540        let tile = TileId::new(3, 4, 4);
541        let mut target = expand_with_clamped_border(&const_grid(tile, 4, 100.0));
542
543        let east_tile = TileId::new(3, 5, 4);
544        let neighbor = expand_with_clamped_border(&const_grid(east_tile, 4, 300.0));
545
546        patch_border_edge(&mut target, &neighbor, 1, 0);
547
548        let s = 6u32;
549        // East border (col 5, rows 1..=4) should now be 300.
550        for row in 1..=4u32 {
551            let val = target.data[(row * s + 5) as usize];
552            assert!(
553                (val - 300.0).abs() < 1e-6,
554                "east border at row {row}: expected 300, got {val}"
555            );
556        }
557    }
558
559    #[test]
560    fn patch_nw_corner() {
561        let tile = TileId::new(3, 4, 4);
562        let mut target = expand_with_clamped_border(&const_grid(tile, 4, 100.0));
563
564        let nw_tile = TileId::new(3, 3, 3);
565        let mut nw_src = const_grid(nw_tile, 4, 500.0);
566        // Set bottom-right interior sample to a unique value.
567        nw_src.data[(3 * 4 + 3) as usize] = 999.0;
568        let neighbor = expand_with_clamped_border(&nw_src);
569
570        patch_border_edge(&mut target, &neighbor, -1, -1);
571
572        // Target interior is all 100.0 → range=0, margin=200 → clamp=[−100, 300].
573        // The neighbour's NW sample (999.0) is clamped to 300.
574        assert!(
575            (target.data[0] - 300.0).abs() < 1e-6,
576            "NW corner should be clamped to 300.0, got {}",
577            target.data[0]
578        );
579    }
580
581    #[test]
582    fn patch_mismatched_dims_is_noop() {
583        let tile = TileId::new(3, 4, 4);
584        let mut target = expand_with_clamped_border(&const_grid(tile, 4, 100.0));
585
586        // Neighbour with different interior dims (8x8 instead of 4x4).
587        let nb_tile = TileId::new(3, 5, 4);
588        let neighbor = expand_with_clamped_border(&const_grid(nb_tile, 8, 999.0));
589
590        patch_border_edge(&mut target, &neighbor, 1, 0);
591
592        // East border should still be clamped to 100 (no-op).
593        let s = 6u32;
594        for row in 1..=4u32 {
595            let val = target.data[(row * s + 5) as usize];
596            assert!(
597                (val - 100.0).abs() < 1e-6,
598                "east border should still be 100 at row {row}, got {val}"
599            );
600        }
601    }
602
603    // -- patch_changed_tiles integration -----------------------------------
604
605    #[test]
606    fn patch_changed_two_horizontal_neighbours() {
607        let left = TileId::new(3, 3, 4);
608        let right = TileId::new(3, 4, 4);
609
610        let mut cache = HashMap::new();
611        cache.insert(
612            left,
613            expand_with_clamped_border(&const_grid(left, 4, 100.0)),
614        );
615        cache.insert(
616            right,
617            expand_with_clamped_border(&const_grid(right, 4, 200.0)),
618        );
619
620        let mut states = HashMap::new();
621        let changed: std::collections::HashSet<TileId> = [left, right].iter().copied().collect();
622
623        let modified = patch_changed_tiles(&mut cache, &mut states, &changed);
624
625        // Both tiles should have been modified.
626        assert!(modified.contains(&left));
627        assert!(modified.contains(&right));
628
629        let s = 6u32;
630        // Left tile's east border should be 200.
631        let left_grid = cache.get(&left).unwrap();
632        for row in 1..=4u32 {
633            let val = left_grid.data[(row * s + 5) as usize];
634            assert!(
635                (val - 200.0).abs() < 1e-6,
636                "left east border at row {row}: expected 200, got {val}"
637            );
638        }
639
640        // Right tile's west border should be 100.
641        let right_grid = cache.get(&right).unwrap();
642        for row in 1..=4u32 {
643            let val = right_grid.data[(row * s) as usize];
644            assert!(
645                (val - 100.0).abs() < 1e-6,
646                "right west border at row {row}: expected 100, got {val}"
647            );
648        }
649    }
650
651    #[test]
652    fn patch_changed_vertical_neighbours() {
653        let upper = TileId::new(3, 4, 3);
654        let lower = TileId::new(3, 4, 4);
655
656        let mut cache = HashMap::new();
657        cache.insert(
658            upper,
659            expand_with_clamped_border(&const_grid(upper, 4, 50.0)),
660        );
661        cache.insert(
662            lower,
663            expand_with_clamped_border(&const_grid(lower, 4, 150.0)),
664        );
665
666        let mut states = HashMap::new();
667        let changed: std::collections::HashSet<TileId> = [upper, lower].iter().copied().collect();
668
669        patch_changed_tiles(&mut cache, &mut states, &changed);
670
671        let s = 6u32;
672        // Upper's south border (row 5) should be 150.
673        let upper_grid = cache.get(&upper).unwrap();
674        for col in 1..=4u32 {
675            let val = upper_grid.data[(5 * s + col) as usize];
676            assert!(
677                (val - 150.0).abs() < 1e-6,
678                "upper south border at col {col}: expected 150, got {val}"
679            );
680        }
681        // Lower's north border (row 0) should be 50.
682        let lower_grid = cache.get(&lower).unwrap();
683        for col in 1..=4u32 {
684            let val = lower_grid.data[col as usize];
685            assert!(
686                (val - 50.0).abs() < 1e-6,
687                "lower north border at col {col}: expected 50, got {val}"
688            );
689        }
690    }
691
692    #[test]
693    fn backfill_state_tracks_filled_directions() {
694        let mut state = BackfillState::default();
695        assert!(!state.is_filled(0));
696        state.mark(0);
697        assert!(state.is_filled(0));
698        state.reset();
699        assert!(!state.is_filled(0));
700    }
701
702    #[test]
703    fn incremental_patch_skips_already_filled() {
704        // Simulate: left tile loaded first, then right tile loaded.
705        let left = TileId::new(3, 3, 4);
706        let right = TileId::new(3, 4, 4);
707
708        let mut cache = HashMap::new();
709        let mut states = HashMap::new();
710
711        // Step 1: left tile arrives.
712        cache.insert(
713            left,
714            expand_with_clamped_border(&const_grid(left, 4, 100.0)),
715        );
716        let changed1: std::collections::HashSet<TileId> = [left].iter().copied().collect();
717        patch_changed_tiles(&mut cache, &mut states, &changed1);
718
719        // Left's east border should still be clamped (no right neighbour yet).
720        let s = 6u32;
721        let left_grid = cache.get(&left).unwrap();
722        for row in 1..=4u32 {
723            let val = left_grid.data[(row * s + 5) as usize];
724            assert!(
725                (val - 100.0).abs() < 1e-6,
726                "left east should be clamped 100 before right arrives, got {val}"
727            );
728        }
729
730        // Step 2: right tile arrives.
731        cache.insert(
732            right,
733            expand_with_clamped_border(&const_grid(right, 4, 200.0)),
734        );
735        let changed2: std::collections::HashSet<TileId> = [right].iter().copied().collect();
736        patch_changed_tiles(&mut cache, &mut states, &changed2);
737
738        // Left's east border should now be 200 (patched from right).
739        let left_grid = cache.get(&left).unwrap();
740        for row in 1..=4u32 {
741            let val = left_grid.data[(row * s + 5) as usize];
742            assert!(
743                (val - 200.0).abs() < 1e-6,
744                "left east border should be 200 after right arrives, got {val}"
745            );
746        }
747
748        // Right's west border should be 100 (patched from left).
749        let right_grid = cache.get(&right).unwrap();
750        for row in 1..=4u32 {
751            let val = right_grid.data[(row * s) as usize];
752            assert!(
753                (val - 100.0).abs() < 1e-6,
754                "right west border should be 100, got {val}"
755            );
756        }
757    }
758
759    // -- TileId::neighbor --------------------------------------------------
760
761    #[test]
762    fn neighbor_east() {
763        let tile = TileId::new(3, 4, 4);
764        let east = tile.neighbor(1, 0).expect("east");
765        assert_eq!(east, TileId::new(3, 5, 4));
766    }
767
768    #[test]
769    fn neighbor_west_wrap() {
770        let tile = TileId::new(3, 0, 4);
771        let west = tile.neighbor(-1, 0).expect("west wrap");
772        assert_eq!(west, TileId::new(3, 7, 4));
773    }
774
775    #[test]
776    fn neighbor_east_wrap() {
777        let tile = TileId::new(3, 7, 4);
778        let east = tile.neighbor(1, 0).expect("east wrap");
779        assert_eq!(east, TileId::new(3, 0, 4));
780    }
781
782    #[test]
783    fn neighbor_north_pole() {
784        let tile = TileId::new(3, 4, 0);
785        assert!(tile.neighbor(0, -1).is_none());
786    }
787
788    #[test]
789    fn neighbor_south_pole() {
790        let tile = TileId::new(3, 4, 7);
791        assert!(tile.neighbor(0, 1).is_none());
792    }
793}