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