Skip to main content

rustial_engine/symbols/
cross_tile_index.rs

1//! Cross-tile symbol index for deduplicating labels at tile boundaries.
2//!
3//! Matching MapLibre's `CrossTileSymbolIndex`, this module assigns stable
4//! cross-tile IDs to symbol candidates so that the same conceptual label
5//! appearing at a tile edge is placed exactly once, regardless of how many
6//! tiles contain a copy of that feature.
7//!
8//! # Architecture
9//!
10//! The index is organised in three layers:
11//!
12//! | Type | Scope |
13//! |------|-------|
14//! | [`TileSymbolGrid`] | Per-tile spatial lookup: grid-snapped anchor positions grouped by symbol key. |
15//! | [`LayerCrossTileIndex`] | Per-style-layer zoom-stratified collection of tile grids, with parent/child matching. |
16//! | [`CrossTileSymbolIndex`] | Top-level facade keyed by style-layer id. |
17//!
18//! When a tile's symbol candidates are registered the index first attempts to
19//! match each candidate against existing entries at other zoom levels (parent
20//! tiles when zooming in, child tiles when zooming out). A match reuses the
21//! existing cross-tile ID so that fade state, collision results, and
22//! deduplication all refer to the same logical symbol. Candidates that find
23//! no match receive a fresh unique ID.
24//!
25//! Tile eviction removes all entries for that tile and releases the
26//! associated cross-tile IDs so they can be reclaimed.
27
28use rustial_math::TileId;
29use std::collections::{HashMap, HashSet};
30
31// ---------------------------------------------------------------------------
32// Constants
33// ---------------------------------------------------------------------------
34
35/// Tile-local coordinate extent (matches MapLibre's EXTENT = 8192).
36///
37/// Symbol anchor coordinates within a tile are expressed in `[0, EXTENT)`.
38/// The rounding factor below converts these to a coarser grid used for
39/// fuzzy spatial matching across zoom levels.
40const EXTENT: f64 = 8192.0;
41
42/// Grid down-sampling factor: `512 / EXTENT / 2`.
43///
44/// Anchor positions are multiplied by this factor before rounding so that
45/// nearby symbols snap to the same grid cell. At EXTENT = 8192 this gives
46/// a grid spacing of roughly 4 pixels in tile-local space.
47const ROUNDING_FACTOR: f64 = 512.0 / EXTENT / 2.0;
48
49// ---------------------------------------------------------------------------
50// Cross-tile ID generator
51// ---------------------------------------------------------------------------
52
53/// Monotonically increasing cross-tile ID allocator.
54///
55/// Each unique conceptual symbol receives a distinct ID that persists as long
56/// as the symbol remains indexed.  IDs are never reused; once evicted the
57/// counter simply moves forward.
58#[derive(Debug, Clone, Default)]
59struct CrossTileIdGenerator {
60    next_id: u64,
61}
62
63impl CrossTileIdGenerator {
64    /// Allocate a fresh cross-tile ID.
65    fn generate(&mut self) -> u64 {
66        self.next_id += 1;
67        self.next_id
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Symbol key
73// ---------------------------------------------------------------------------
74
75/// Composite key that identifies a conceptual symbol independent of tile.
76///
77/// Two symbols are considered "the same" if they share the same source layer,
78/// label text, and icon image. Feature IDs are intentionally excluded because
79/// the same real-world feature is often split across adjacent tiles with
80/// different local feature indices.
81#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82struct SymbolKey {
83    /// Source layer name (e.g. `"poi"`, `"place_label"`).
84    source_layer: String,
85    /// Label text content, or empty if icon-only.
86    text: String,
87    /// Icon image id, or empty if text-only.
88    icon: String,
89}
90
91// ---------------------------------------------------------------------------
92// Grid-snapped position
93// ---------------------------------------------------------------------------
94
95/// An anchor position rounded to the cross-tile matching grid.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97struct GridPosition {
98    x: i32,
99    y: i32,
100}
101
102// ---------------------------------------------------------------------------
103// Per-tile spatial index
104// ---------------------------------------------------------------------------
105
106/// Per-tile spatial index of symbol entries grouped by [`SymbolKey`].
107///
108/// When a tile is registered each symbol candidate is bucketed by its key.
109/// Within each bucket the grid-snapped anchor position and assigned
110/// cross-tile ID are stored so that later tiles can search for matches.
111#[derive(Debug, Clone)]
112struct TileSymbolGrid {
113    /// The tile this grid belongs to.
114    tile_id: TileId,
115    /// Unique instance identifier used to detect stale re-registrations.
116    instance_id: u64,
117    /// Symbol entries grouped by key. Each entry stores `(grid_pos, cross_tile_id)`.
118    entries: HashMap<SymbolKey, Vec<(GridPosition, u64)>>,
119}
120
121impl TileSymbolGrid {
122    /// Create a new empty grid for the given tile.
123    fn new(tile_id: TileId, instance_id: u64) -> Self {
124        Self {
125            tile_id,
126            instance_id,
127            entries: HashMap::new(),
128        }
129    }
130
131    /// Attempt to match an incoming candidate against this grid's entries.
132    ///
133    /// Returns the cross-tile ID of the first spatially matching entry that
134    /// has not already been claimed in `used_ids`, or `None` if no match.
135    ///
136    /// `scaled_pos` must already be converted to this grid's coordinate
137    /// system via [`scaled_coordinates`].
138    fn find_match(
139        &self,
140        key: &SymbolKey,
141        scaled_pos: GridPosition,
142        tolerance: i32,
143        used_ids: &HashSet<u64>,
144    ) -> Option<u64> {
145        let bucket = self.entries.get(key)?;
146        for &(pos, cross_tile_id) in bucket {
147            if (pos.x - scaled_pos.x).abs() <= tolerance
148                && (pos.y - scaled_pos.y).abs() <= tolerance
149                && !used_ids.contains(&cross_tile_id)
150            {
151                return Some(cross_tile_id);
152            }
153        }
154        None
155    }
156
157    /// Collect all cross-tile IDs stored in this grid.
158    fn all_cross_tile_ids(&self) -> Vec<u64> {
159        self.entries
160            .values()
161            .flat_map(|bucket| bucket.iter().map(|&(_, id)| id))
162            .collect()
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Coordinate scaling helpers
168// ---------------------------------------------------------------------------
169
170/// Convert a candidate's tile-local anchor into grid coordinates relative
171/// to `target_tile`, adjusting for zoom difference.
172///
173/// This mirrors MapLibre's `TileLayerIndex.getScaledCoordinates`: the
174/// candidate's world-space position (tile origin + anchor) is projected into
175/// `target_tile`'s local grid at the rounding resolution.
176fn scaled_coordinates(
177    anchor_x: f64,
178    anchor_y: f64,
179    candidate_tile: TileId,
180    target_tile: TileId,
181) -> GridPosition {
182    let z_diff = candidate_tile.zoom as i32 - target_tile.zoom as i32;
183    let scale = ROUNDING_FACTOR / 2.0_f64.powi(z_diff);
184
185    let x_world = (candidate_tile.x as f64 * EXTENT + anchor_x) * scale;
186    let y_world = (candidate_tile.y as f64 * EXTENT + anchor_y) * scale;
187
188    let x_offset = target_tile.x as f64 * EXTENT * ROUNDING_FACTOR;
189    let y_offset = target_tile.y as f64 * EXTENT * ROUNDING_FACTOR;
190
191    GridPosition {
192        x: (x_world - x_offset).floor() as i32,
193        y: (y_world - y_offset).floor() as i32,
194    }
195}
196
197/// Snap a tile-local anchor to the grid at native resolution (no zoom
198/// scaling). Used when inserting entries into the grid for their own tile.
199fn native_grid_position(anchor_x: f64, anchor_y: f64) -> GridPosition {
200    GridPosition {
201        x: (anchor_x * ROUNDING_FACTOR).floor() as i32,
202        y: (anchor_y * ROUNDING_FACTOR).floor() as i32,
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Per-layer cross-tile index
208// ---------------------------------------------------------------------------
209
210/// Per-style-layer cross-tile index stratified by zoom level.
211///
212/// Each zoom level maps tile keys to their [`TileSymbolGrid`]. When a new
213/// tile is added the index searches grids at other zoom levels for matching
214/// symbols, propagating cross-tile IDs across parent/child relationships.
215#[derive(Debug, Clone, Default)]
216struct LayerCrossTileIndex {
217    /// `zoom -> (tile_key -> grid)`.
218    grids: HashMap<u8, HashMap<u64, TileSymbolGrid>>,
219    /// `zoom -> set_of_cross_tile_ids` currently claimed at that zoom.
220    used_ids: HashMap<u8, HashSet<u64>>,
221}
222
223/// Compute a u64 tile key from a `TileId` for use as a hash-map key.
224/// Uses a simple bit-packing: `(z << 48) | (x << 24) | y`.
225fn tile_key(tile: &TileId) -> u64 {
226    ((tile.zoom as u64) << 48) | ((tile.x as u64) << 24) | (tile.y as u64)
227}
228
229/// Check whether `child` is a descendant of `parent` in the tile tree.
230fn is_child_of(child: &TileId, parent: &TileId) -> bool {
231    if child.zoom <= parent.zoom {
232        return false;
233    }
234    let dz = child.zoom - parent.zoom;
235    (child.x >> dz) == parent.x && (child.y >> dz) == parent.y
236}
237
238/// Scale `child` up to `target_zoom` (must be <= child.zoom) to get the
239/// ancestor tile coordinates at that zoom.
240fn scaled_to(tile: &TileId, target_zoom: u8) -> TileId {
241    if target_zoom >= tile.zoom {
242        return *tile;
243    }
244    let dz = tile.zoom - target_zoom;
245    TileId::new(target_zoom, tile.x >> dz, tile.y >> dz)
246}
247
248impl LayerCrossTileIndex {
249    /// Register a tile's symbol candidates, assigning cross-tile IDs.
250    ///
251    /// Returns `true` if any IDs were newly assigned (i.e. the symbol set
252    /// changed), which signals that placement should re-run.
253    fn add_tile(
254        &mut self,
255        tile_id: TileId,
256        instance_id: u64,
257        candidates: &mut [SymbolCandidateEntry],
258        id_gen: &mut CrossTileIdGenerator,
259    ) -> bool {
260        let tk = tile_key(&tile_id);
261
262        // If we already have this exact instance, skip.
263        if let Some(zoom_grids) = self.grids.get(&tile_id.zoom) {
264            if let Some(existing) = zoom_grids.get(&tk) {
265                if existing.instance_id == instance_id {
266                    return false;
267                }
268            }
269        }
270
271        // Remove previously claimed IDs for this tile slot (replacement).
272        if let Some(zoom_grids) = self.grids.get(&tile_id.zoom) {
273            if let Some(old_grid) = zoom_grids.get(&tk) {
274                let old_ids = old_grid.all_cross_tile_ids();
275                if let Some(used) = self.used_ids.get_mut(&tile_id.zoom) {
276                    for id in &old_ids {
277                        used.remove(id);
278                    }
279                }
280            }
281        }
282
283        // Clear candidate cross-tile IDs before matching.
284        for c in candidates.iter_mut() {
285            c.assigned_cross_tile_id = 0;
286        }
287
288        // Ensure used-ID set exists for this zoom.
289        let zoom_used = self.used_ids.entry(tile_id.zoom).or_default();
290
291        // Search other zoom levels for matches.
292        for (&z, zoom_grids) in &self.grids {
293            if z > tile_id.zoom {
294                // z is a child zoom: search child grids whose tile is a
295                // descendant of the incoming tile.
296                for grid in zoom_grids.values() {
297                    if is_child_of(&grid.tile_id, &tile_id) {
298                        Self::match_candidates(candidates, grid, &tile_id, zoom_used);
299                    }
300                }
301            } else {
302                // z is a parent (or same) zoom: scale the incoming tile up
303                // to that zoom and look for the parent grid.
304                let parent_coord = scaled_to(&tile_id, z);
305                let parent_key = tile_key(&parent_coord);
306                if let Some(parent_grid) = zoom_grids.get(&parent_key) {
307                    Self::match_candidates(candidates, parent_grid, &tile_id, zoom_used);
308                }
309            }
310        }
311
312        // Assign new IDs to any unmatched candidates.
313        for c in candidates.iter_mut() {
314            if c.assigned_cross_tile_id == 0 {
315                let id = id_gen.generate();
316                c.assigned_cross_tile_id = id;
317                zoom_used.insert(id);
318            }
319        }
320
321        // Build and store the tile grid.
322        let mut grid = TileSymbolGrid::new(tile_id, instance_id);
323        for c in candidates.iter() {
324            let key = c.key.clone();
325            let pos = native_grid_position(c.anchor_x, c.anchor_y);
326            grid.entries
327                .entry(key)
328                .or_default()
329                .push((pos, c.assigned_cross_tile_id));
330        }
331
332        self.grids.entry(tile_id.zoom).or_default().insert(tk, grid);
333
334        true
335    }
336
337    /// Match unassigned candidates against an existing tile grid.
338    fn match_candidates(
339        candidates: &mut [SymbolCandidateEntry],
340        grid: &TileSymbolGrid,
341        candidate_tile: &TileId,
342        used_ids: &mut HashSet<u64>,
343    ) {
344        // Tolerance: 1 grid unit when same zoom, widens for parent tiles.
345        let tolerance = if grid.tile_id.zoom < candidate_tile.zoom {
346            1
347        } else {
348            1i32 << (grid.tile_id.zoom as i32 - candidate_tile.zoom as i32).max(0)
349        };
350
351        for c in candidates.iter_mut() {
352            if c.assigned_cross_tile_id != 0 {
353                continue;
354            }
355            let scaled = scaled_coordinates(c.anchor_x, c.anchor_y, *candidate_tile, grid.tile_id);
356            if let Some(matched_id) = grid.find_match(&c.key, scaled, tolerance, used_ids) {
357                c.assigned_cross_tile_id = matched_id;
358                used_ids.insert(matched_id);
359            }
360        }
361    }
362
363    /// Remove grids for tiles whose instance IDs are not in `current_ids`.
364    ///
365    /// Returns `true` if any grids were removed.
366    fn remove_stale(&mut self, current_ids: &HashSet<u64>) -> bool {
367        let mut changed = false;
368        for (&z, zoom_grids) in self.grids.iter_mut() {
369            let before = zoom_grids.len();
370            let used = self.used_ids.entry(z).or_default();
371            zoom_grids.retain(|_tk, grid| {
372                if current_ids.contains(&grid.instance_id) {
373                    true
374                } else {
375                    // Release cross-tile IDs.
376                    for id in grid.all_cross_tile_ids() {
377                        used.remove(&id);
378                    }
379                    false
380                }
381            });
382            if zoom_grids.len() != before {
383                changed = true;
384            }
385        }
386        changed
387    }
388
389    /// Remove all grids associated with a specific tile.
390    fn remove_tile(&mut self, tile_id: &TileId) {
391        let tk = tile_key(tile_id);
392        if let Some(zoom_grids) = self.grids.get_mut(&tile_id.zoom) {
393            if let Some(grid) = zoom_grids.remove(&tk) {
394                if let Some(used) = self.used_ids.get_mut(&tile_id.zoom) {
395                    for id in grid.all_cross_tile_ids() {
396                        used.remove(&id);
397                    }
398                }
399            }
400        }
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Public: symbol candidate entry (lightweight input)
406// ---------------------------------------------------------------------------
407
408/// Lightweight input record fed into the cross-tile index.
409///
410/// Callers convert their full [`super::SymbolCandidate`] into this compact
411/// representation before passing it to [`CrossTileSymbolIndex::assign`].
412/// The index writes back the assigned cross-tile ID into
413/// [`assigned_cross_tile_id`](Self::assigned_cross_tile_id).
414#[derive(Debug, Clone)]
415pub struct SymbolCandidateEntry {
416    /// Composite symbol key (source layer + text + icon).
417    key: SymbolKey,
418    /// Tile-local anchor X in `[0, EXTENT)`.
419    anchor_x: f64,
420    /// Tile-local anchor Y in `[0, EXTENT)`.
421    anchor_y: f64,
422    /// Cross-tile ID written by the index. Zero means unassigned.
423    pub assigned_cross_tile_id: u64,
424}
425
426impl SymbolCandidateEntry {
427    /// Create a new candidate entry.
428    ///
429    /// `source_layer`, `text`, and `icon` form the deduplication key.
430    /// `anchor_x` and `anchor_y` are the symbol's position within the tile
431    /// in tile-local coordinates (range `[0, EXTENT)`, matching the MVT
432    /// geometry coordinate space).
433    pub fn new(source_layer: &str, text: &str, icon: &str, anchor_x: f64, anchor_y: f64) -> Self {
434        Self {
435            key: SymbolKey {
436                source_layer: source_layer.to_owned(),
437                text: text.to_owned(),
438                icon: icon.to_owned(),
439            },
440            anchor_x,
441            anchor_y,
442            assigned_cross_tile_id: 0,
443        }
444    }
445}
446
447// ---------------------------------------------------------------------------
448// Public: top-level cross-tile symbol index
449// ---------------------------------------------------------------------------
450
451/// Cross-tile symbol index that deduplicates labels across tile boundaries.
452///
453/// This is the top-level entry point, keyed by style-layer id. Each style
454/// layer has an independent [`LayerCrossTileIndex`] so that labels from
455/// different layers do not interfere with each other.
456///
457/// # Usage
458///
459/// ```text
460/// let mut index = CrossTileSymbolIndex::new();
461///
462/// // For each tile's symbol candidates:
463/// let changed = index.assign("poi-labels", tile_id, instance_id, &mut entries);
464///
465/// // After placement, prune tiles no longer visible:
466/// index.prune_stale("poi-labels", &active_instance_ids);
467///
468/// // When a tile is evicted from the cache:
469/// index.remove_tile(&tile_id);
470/// ```
471#[derive(Debug, Clone, Default)]
472pub struct CrossTileSymbolIndex {
473    /// Per-layer index, keyed by style-layer id.
474    layers: HashMap<String, LayerCrossTileIndex>,
475    /// Monotonic ID generator shared across all layers.
476    id_gen: CrossTileIdGenerator,
477    /// Global instance-ID counter for tile registrations.
478    next_instance_id: u64,
479}
480
481impl CrossTileSymbolIndex {
482    /// Create an empty cross-tile symbol index.
483    pub fn new() -> Self {
484        Self::default()
485    }
486
487    /// Allocate a unique instance ID for a tile registration.
488    ///
489    /// Each call to [`assign`](Self::assign) should use a fresh instance ID
490    /// so the index can detect when a tile's data has been replaced (e.g.
491    /// after a style change or data update).
492    pub fn next_instance_id(&mut self) -> u64 {
493        self.next_instance_id += 1;
494        self.next_instance_id
495    }
496
497    /// Register a tile's symbol candidates and assign cross-tile IDs.
498    ///
499    /// After this call, each entry in `candidates` will have a non-zero
500    /// [`assigned_cross_tile_id`](SymbolCandidateEntry::assigned_cross_tile_id)
501    /// that is stable across zoom transitions and tile reloads.
502    ///
503    /// Returns `true` if any IDs changed (caller should re-run placement).
504    pub fn assign(
505        &mut self,
506        layer_id: &str,
507        tile_id: TileId,
508        instance_id: u64,
509        candidates: &mut [SymbolCandidateEntry],
510    ) -> bool {
511        let layer_index = self.layers.entry(layer_id.to_owned()).or_default();
512        layer_index.add_tile(tile_id, instance_id, candidates, &mut self.id_gen)
513    }
514
515    /// Remove grids for tiles whose instance IDs are no longer active.
516    ///
517    /// Call this after each placement frame with the set of instance IDs
518    /// that are still in use. Returns `true` if any stale grids were removed.
519    pub fn prune_stale(&mut self, layer_id: &str, current_instance_ids: &HashSet<u64>) -> bool {
520        if let Some(layer_index) = self.layers.get_mut(layer_id) {
521            layer_index.remove_stale(current_instance_ids)
522        } else {
523            false
524        }
525    }
526
527    /// Remove all index entries for a specific tile across all layers.
528    ///
529    /// Call this when a tile is evicted from the tile cache to release the
530    /// associated cross-tile IDs.
531    pub fn remove_tile(&mut self, tile_id: &TileId) {
532        for layer_index in self.layers.values_mut() {
533            layer_index.remove_tile(tile_id);
534        }
535    }
536
537    /// Remove index state for layers that are no longer active.
538    ///
539    /// `active_layers` is the set of style-layer IDs currently in use.
540    /// Any layer not in this set has its index state dropped.
541    pub fn prune_layers(&mut self, active_layers: &HashSet<String>) {
542        self.layers.retain(|id, _| active_layers.contains(id));
543    }
544
545    /// Total number of indexed tiles across all layers and zoom levels.
546    pub fn tile_count(&self) -> usize {
547        self.layers
548            .values()
549            .flat_map(|layer| layer.grids.values())
550            .map(|zoom_grids| zoom_grids.len())
551            .sum()
552    }
553
554    /// Total number of indexed symbol entries across all tiles and layers.
555    pub fn entry_count(&self) -> usize {
556        self.layers
557            .values()
558            .flat_map(|layer| layer.grids.values())
559            .flat_map(|zoom_grids| zoom_grids.values())
560            .map(|grid| {
561                grid.entries
562                    .values()
563                    .map(|bucket| bucket.len())
564                    .sum::<usize>()
565            })
566            .sum()
567    }
568}
569
570// ---------------------------------------------------------------------------
571// Tests
572// ---------------------------------------------------------------------------
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    /// Helper: build a candidate entry at a position within a tile.
579    fn entry(source_layer: &str, text: &str, anchor_x: f64, anchor_y: f64) -> SymbolCandidateEntry {
580        SymbolCandidateEntry::new(source_layer, text, "", anchor_x, anchor_y)
581    }
582
583    #[test]
584    fn assigns_unique_ids_to_distinct_symbols() {
585        let mut index = CrossTileSymbolIndex::new();
586        let iid = index.next_instance_id();
587        let tile = TileId::new(10, 512, 512);
588        let mut candidates = vec![
589            entry("poi", "Restaurant", 100.0, 200.0),
590            entry("poi", "Cafe", 300.0, 400.0),
591        ];
592        index.assign("symbols", tile, iid, &mut candidates);
593
594        assert_ne!(candidates[0].assigned_cross_tile_id, 0);
595        assert_ne!(candidates[1].assigned_cross_tile_id, 0);
596        assert_ne!(
597            candidates[0].assigned_cross_tile_id,
598            candidates[1].assigned_cross_tile_id
599        );
600    }
601
602    #[test]
603    fn matches_same_symbol_across_parent_and_child_tiles() {
604        let mut index = CrossTileSymbolIndex::new();
605
606        // Register a symbol in a parent tile at zoom 10.
607        // Anchor (2048, 2048) is the centre of the top-left quadrant.
608        let parent = TileId::new(10, 512, 512);
609        let parent_iid = index.next_instance_id();
610        let mut parent_candidates = vec![entry("poi", "Museum", 2048.0, 2048.0)];
611        index.assign("symbols", parent, parent_iid, &mut parent_candidates);
612        let parent_id = parent_candidates[0].assigned_cross_tile_id;
613        assert_ne!(parent_id, 0);
614
615        // Register the same symbol in the top-left child tile at zoom 11.
616        // The parent's local (2048, 2048) maps to (4096, 4096) in the child
617        // because the child covers exactly the top-left quadrant of the parent
618        // and its local EXTENT is doubled relative to the parent's sub-region.
619        let child = TileId::new(11, 1024, 1024);
620        let child_iid = index.next_instance_id();
621        let mut child_candidates = vec![entry("poi", "Museum", 4096.0, 4096.0)];
622        index.assign("symbols", child, child_iid, &mut child_candidates);
623
624        // The child should inherit the parent's cross-tile ID.
625        assert_eq!(child_candidates[0].assigned_cross_tile_id, parent_id);
626    }
627
628    #[test]
629    fn does_not_match_different_text() {
630        let mut index = CrossTileSymbolIndex::new();
631
632        // Parent anchor at (2048, 2048) -- centre of top-left quadrant.
633        let parent = TileId::new(10, 512, 512);
634        let parent_iid = index.next_instance_id();
635        let mut parent_candidates = vec![entry("poi", "Museum", 2048.0, 2048.0)];
636        index.assign("symbols", parent, parent_iid, &mut parent_candidates);
637        let parent_id = parent_candidates[0].assigned_cross_tile_id;
638
639        // Same world position in the child but different label text.
640        let child = TileId::new(11, 1024, 1024);
641        let child_iid = index.next_instance_id();
642        let mut child_candidates = vec![entry("poi", "Library", 4096.0, 4096.0)];
643        index.assign("symbols", child, child_iid, &mut child_candidates);
644
645        // Different text should not reuse the parent ID.
646        assert_ne!(child_candidates[0].assigned_cross_tile_id, parent_id);
647    }
648
649    #[test]
650    fn eviction_removes_tile_entries() {
651        let mut index = CrossTileSymbolIndex::new();
652
653        let tile = TileId::new(10, 512, 512);
654        let iid = index.next_instance_id();
655        let mut candidates = vec![entry("poi", "Park", 1000.0, 2000.0)];
656        index.assign("symbols", tile, iid, &mut candidates);
657
658        assert_eq!(index.entry_count(), 1);
659        assert_eq!(index.tile_count(), 1);
660
661        index.remove_tile(&tile);
662
663        assert_eq!(index.entry_count(), 0);
664        assert_eq!(index.tile_count(), 0);
665    }
666
667    #[test]
668    fn prune_stale_removes_old_tiles() {
669        let mut index = CrossTileSymbolIndex::new();
670
671        let tile_a = TileId::new(10, 512, 512);
672        let iid_a = index.next_instance_id();
673        let mut a = vec![entry("poi", "A", 100.0, 100.0)];
674        index.assign("symbols", tile_a, iid_a, &mut a);
675
676        let tile_b = TileId::new(10, 513, 512);
677        let iid_b = index.next_instance_id();
678        let mut b = vec![entry("poi", "B", 100.0, 100.0)];
679        index.assign("symbols", tile_b, iid_b, &mut b);
680
681        assert_eq!(index.tile_count(), 2);
682
683        // Only keep tile_a.
684        let mut active = HashSet::new();
685        active.insert(iid_a);
686        let changed = index.prune_stale("symbols", &active);
687        assert!(changed);
688        assert_eq!(index.tile_count(), 1);
689    }
690
691    #[test]
692    fn same_instance_id_is_no_op() {
693        let mut index = CrossTileSymbolIndex::new();
694
695        let tile = TileId::new(10, 512, 512);
696        let iid = index.next_instance_id();
697        let mut candidates = vec![entry("poi", "Park", 1000.0, 2000.0)];
698        let changed1 = index.assign("symbols", tile, iid, &mut candidates);
699        assert!(changed1);
700
701        // Re-registering with the same instance ID should be a no-op.
702        let mut candidates2 = vec![entry("poi", "Park", 1000.0, 2000.0)];
703        let changed2 = index.assign("symbols", tile, iid, &mut candidates2);
704        assert!(!changed2);
705    }
706
707    #[test]
708    fn adjacent_tiles_at_same_zoom_do_not_cross_match() {
709        let mut index = CrossTileSymbolIndex::new();
710
711        // Two adjacent tiles at the same zoom with the same label text
712        // but different tile-local positions should get distinct IDs.
713        let tile_a = TileId::new(10, 512, 512);
714        let iid_a = index.next_instance_id();
715        let mut a = vec![entry("poi", "Church", 7000.0, 4096.0)];
716        index.assign("symbols", tile_a, iid_a, &mut a);
717
718        let tile_b = TileId::new(10, 513, 512);
719        let iid_b = index.next_instance_id();
720        let mut b = vec![entry("poi", "Church", 100.0, 4096.0)];
721        index.assign("symbols", tile_b, iid_b, &mut b);
722
723        // Same zoom tiles are indexed independently; they should only
724        // match if one is a parent/child of the other.
725        assert_ne!(a[0].assigned_cross_tile_id, b[0].assigned_cross_tile_id);
726    }
727
728    #[test]
729    fn child_registered_first_propagates_to_parent() {
730        let mut index = CrossTileSymbolIndex::new();
731
732        // Register a child tile first.
733        // Anchor (4096, 4096) = centre of child (11, 1024, 1024).
734        let child = TileId::new(11, 1024, 1024);
735        let child_iid = index.next_instance_id();
736        let mut child_candidates = vec![entry("poi", "Station", 4096.0, 4096.0)];
737        index.assign("symbols", child, child_iid, &mut child_candidates);
738        let child_id = child_candidates[0].assigned_cross_tile_id;
739
740        // Now register the parent with the world-equivalent anchor.
741        // Child local (4096, 4096) maps to parent local (2048, 2048).
742        let parent = TileId::new(10, 512, 512);
743        let parent_iid = index.next_instance_id();
744        let mut parent_candidates = vec![entry("poi", "Station", 2048.0, 2048.0)];
745        index.assign("symbols", parent, parent_iid, &mut parent_candidates);
746
747        assert_eq!(parent_candidates[0].assigned_cross_tile_id, child_id);
748    }
749
750    #[test]
751    fn prune_layers_removes_unused_layers() {
752        let mut index = CrossTileSymbolIndex::new();
753
754        let tile = TileId::new(10, 512, 512);
755        let iid = index.next_instance_id();
756        let mut a = vec![entry("poi", "A", 100.0, 100.0)];
757        index.assign("layer-a", tile, iid, &mut a);
758
759        let iid2 = index.next_instance_id();
760        let mut b = vec![entry("road", "B", 200.0, 200.0)];
761        index.assign("layer-b", tile, iid2, &mut b);
762
763        assert_eq!(index.tile_count(), 2);
764
765        let mut active = HashSet::new();
766        active.insert("layer-a".to_owned());
767        index.prune_layers(&active);
768
769        assert_eq!(index.tile_count(), 1);
770    }
771
772    #[test]
773    fn replacement_tile_reclaims_ids() {
774        let mut index = CrossTileSymbolIndex::new();
775
776        let tile = TileId::new(10, 512, 512);
777        let iid1 = index.next_instance_id();
778        let mut a = vec![entry("poi", "Old", 100.0, 100.0)];
779        index.assign("symbols", tile, iid1, &mut a);
780        let old_id = a[0].assigned_cross_tile_id;
781
782        // Replace with a new instance (e.g. tile data updated).
783        let iid2 = index.next_instance_id();
784        let mut b = vec![entry("poi", "New", 100.0, 100.0)];
785        index.assign("symbols", tile, iid2, &mut b);
786
787        // The old ID should have been released and a new one assigned.
788        assert_ne!(b[0].assigned_cross_tile_id, old_id);
789        assert_eq!(index.tile_count(), 1);
790    }
791
792    #[test]
793    fn multi_zoom_chain_propagates_ids() {
794        let mut index = CrossTileSymbolIndex::new();
795
796        // Three zoom levels: z9 -> z10 -> z11, all referencing the same
797        // world position.  Each child is the top-left quadrant of its
798        // parent, so the tile-local anchor doubles at each step:
799        //   z9  (256, 256) anchor (1024, 1024)
800        //   z10 (512, 512) anchor (2048, 2048)   -- 1024 * 2
801        //   z11 (1024,1024) anchor (4096, 4096)   -- 2048 * 2
802        let z9 = TileId::new(9, 256, 256);
803        let iid9 = index.next_instance_id();
804        let mut c9 = vec![entry("poi", "Bridge", 1024.0, 1024.0)];
805        index.assign("symbols", z9, iid9, &mut c9);
806        let id9 = c9[0].assigned_cross_tile_id;
807
808        // Register at zoom 10 (child of z9).
809        let z10 = TileId::new(10, 512, 512);
810        let iid10 = index.next_instance_id();
811        let mut c10 = vec![entry("poi", "Bridge", 2048.0, 2048.0)];
812        index.assign("symbols", z10, iid10, &mut c10);
813        assert_eq!(c10[0].assigned_cross_tile_id, id9);
814
815        // Register at zoom 11 (child of z10).
816        let z11 = TileId::new(11, 1024, 1024);
817        let iid11 = index.next_instance_id();
818        let mut c11 = vec![entry("poi", "Bridge", 4096.0, 4096.0)];
819        index.assign("symbols", z11, iid11, &mut c11);
820        assert_eq!(c11[0].assigned_cross_tile_id, id9);
821    }
822}