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(
299                            candidates,
300                            grid,
301                            &tile_id,
302                            zoom_used,
303                        );
304                    }
305                }
306            } else {
307                // z is a parent (or same) zoom: scale the incoming tile up
308                // to that zoom and look for the parent grid.
309                let parent_coord = scaled_to(&tile_id, z);
310                let parent_key = tile_key(&parent_coord);
311                if let Some(parent_grid) = zoom_grids.get(&parent_key) {
312                    Self::match_candidates(
313                        candidates,
314                        parent_grid,
315                        &tile_id,
316                        zoom_used,
317                    );
318                }
319            }
320        }
321
322        // Assign new IDs to any unmatched candidates.
323        for c in candidates.iter_mut() {
324            if c.assigned_cross_tile_id == 0 {
325                let id = id_gen.generate();
326                c.assigned_cross_tile_id = id;
327                zoom_used.insert(id);
328            }
329        }
330
331        // Build and store the tile grid.
332        let mut grid = TileSymbolGrid::new(tile_id, instance_id);
333        for c in candidates.iter() {
334            let key = c.key.clone();
335            let pos = native_grid_position(c.anchor_x, c.anchor_y);
336            grid.entries
337                .entry(key)
338                .or_default()
339                .push((pos, c.assigned_cross_tile_id));
340        }
341
342        self.grids
343            .entry(tile_id.zoom)
344            .or_default()
345            .insert(tk, grid);
346
347        true
348    }
349
350    /// Match unassigned candidates against an existing tile grid.
351    fn match_candidates(
352        candidates: &mut [SymbolCandidateEntry],
353        grid: &TileSymbolGrid,
354        candidate_tile: &TileId,
355        used_ids: &mut HashSet<u64>,
356    ) {
357        // Tolerance: 1 grid unit when same zoom, widens for parent tiles.
358        let tolerance = if grid.tile_id.zoom < candidate_tile.zoom {
359            1
360        } else {
361            1i32 << (grid.tile_id.zoom as i32 - candidate_tile.zoom as i32).max(0)
362        };
363
364        for c in candidates.iter_mut() {
365            if c.assigned_cross_tile_id != 0 {
366                continue;
367            }
368            let scaled = scaled_coordinates(
369                c.anchor_x,
370                c.anchor_y,
371                *candidate_tile,
372                grid.tile_id,
373            );
374            if let Some(matched_id) =
375                grid.find_match(&c.key, scaled, tolerance, used_ids)
376            {
377                c.assigned_cross_tile_id = matched_id;
378                used_ids.insert(matched_id);
379            }
380        }
381    }
382
383    /// Remove grids for tiles whose instance IDs are not in `current_ids`.
384    ///
385    /// Returns `true` if any grids were removed.
386    fn remove_stale(&mut self, current_ids: &HashSet<u64>) -> bool {
387        let mut changed = false;
388        for (&z, zoom_grids) in self.grids.iter_mut() {
389            let before = zoom_grids.len();
390            let used = self.used_ids.entry(z).or_default();
391            zoom_grids.retain(|_tk, grid| {
392                if current_ids.contains(&grid.instance_id) {
393                    true
394                } else {
395                    // Release cross-tile IDs.
396                    for id in grid.all_cross_tile_ids() {
397                        used.remove(&id);
398                    }
399                    false
400                }
401            });
402            if zoom_grids.len() != before {
403                changed = true;
404            }
405        }
406        changed
407    }
408
409    /// Remove all grids associated with a specific tile.
410    fn remove_tile(&mut self, tile_id: &TileId) {
411        let tk = tile_key(tile_id);
412        if let Some(zoom_grids) = self.grids.get_mut(&tile_id.zoom) {
413            if let Some(grid) = zoom_grids.remove(&tk) {
414                if let Some(used) = self.used_ids.get_mut(&tile_id.zoom) {
415                    for id in grid.all_cross_tile_ids() {
416                        used.remove(&id);
417                    }
418                }
419            }
420        }
421    }
422}
423
424// ---------------------------------------------------------------------------
425// Public: symbol candidate entry (lightweight input)
426// ---------------------------------------------------------------------------
427
428/// Lightweight input record fed into the cross-tile index.
429///
430/// Callers convert their full [`super::SymbolCandidate`] into this compact
431/// representation before passing it to [`CrossTileSymbolIndex::assign`].
432/// The index writes back the assigned cross-tile ID into
433/// [`assigned_cross_tile_id`](Self::assigned_cross_tile_id).
434#[derive(Debug, Clone)]
435pub struct SymbolCandidateEntry {
436    /// Composite symbol key (source layer + text + icon).
437    key: SymbolKey,
438    /// Tile-local anchor X in `[0, EXTENT)`.
439    anchor_x: f64,
440    /// Tile-local anchor Y in `[0, EXTENT)`.
441    anchor_y: f64,
442    /// Cross-tile ID written by the index. Zero means unassigned.
443    pub assigned_cross_tile_id: u64,
444}
445
446impl SymbolCandidateEntry {
447    /// Create a new candidate entry.
448    ///
449    /// `source_layer`, `text`, and `icon` form the deduplication key.
450    /// `anchor_x` and `anchor_y` are the symbol's position within the tile
451    /// in tile-local coordinates (range `[0, EXTENT)`, matching the MVT
452    /// geometry coordinate space).
453    pub fn new(
454        source_layer: &str,
455        text: &str,
456        icon: &str,
457        anchor_x: f64,
458        anchor_y: f64,
459    ) -> Self {
460        Self {
461            key: SymbolKey {
462                source_layer: source_layer.to_owned(),
463                text: text.to_owned(),
464                icon: icon.to_owned(),
465            },
466            anchor_x,
467            anchor_y,
468            assigned_cross_tile_id: 0,
469        }
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Public: top-level cross-tile symbol index
475// ---------------------------------------------------------------------------
476
477/// Cross-tile symbol index that deduplicates labels across tile boundaries.
478///
479/// This is the top-level entry point, keyed by style-layer id. Each style
480/// layer has an independent [`LayerCrossTileIndex`] so that labels from
481/// different layers do not interfere with each other.
482///
483/// # Usage
484///
485/// ```text
486/// let mut index = CrossTileSymbolIndex::new();
487///
488/// // For each tile's symbol candidates:
489/// let changed = index.assign("poi-labels", tile_id, instance_id, &mut entries);
490///
491/// // After placement, prune tiles no longer visible:
492/// index.prune_stale("poi-labels", &active_instance_ids);
493///
494/// // When a tile is evicted from the cache:
495/// index.remove_tile(&tile_id);
496/// ```
497#[derive(Debug, Clone, Default)]
498pub struct CrossTileSymbolIndex {
499    /// Per-layer index, keyed by style-layer id.
500    layers: HashMap<String, LayerCrossTileIndex>,
501    /// Monotonic ID generator shared across all layers.
502    id_gen: CrossTileIdGenerator,
503    /// Global instance-ID counter for tile registrations.
504    next_instance_id: u64,
505}
506
507impl CrossTileSymbolIndex {
508    /// Create an empty cross-tile symbol index.
509    pub fn new() -> Self {
510        Self::default()
511    }
512
513    /// Allocate a unique instance ID for a tile registration.
514    ///
515    /// Each call to [`assign`](Self::assign) should use a fresh instance ID
516    /// so the index can detect when a tile's data has been replaced (e.g.
517    /// after a style change or data update).
518    pub fn next_instance_id(&mut self) -> u64 {
519        self.next_instance_id += 1;
520        self.next_instance_id
521    }
522
523    /// Register a tile's symbol candidates and assign cross-tile IDs.
524    ///
525    /// After this call, each entry in `candidates` will have a non-zero
526    /// [`assigned_cross_tile_id`](SymbolCandidateEntry::assigned_cross_tile_id)
527    /// that is stable across zoom transitions and tile reloads.
528    ///
529    /// Returns `true` if any IDs changed (caller should re-run placement).
530    pub fn assign(
531        &mut self,
532        layer_id: &str,
533        tile_id: TileId,
534        instance_id: u64,
535        candidates: &mut [SymbolCandidateEntry],
536    ) -> bool {
537        let layer_index = self
538            .layers
539            .entry(layer_id.to_owned())
540            .or_default();
541        layer_index.add_tile(tile_id, instance_id, candidates, &mut self.id_gen)
542    }
543
544    /// Remove grids for tiles whose instance IDs are no longer active.
545    ///
546    /// Call this after each placement frame with the set of instance IDs
547    /// that are still in use. Returns `true` if any stale grids were removed.
548    pub fn prune_stale(
549        &mut self,
550        layer_id: &str,
551        current_instance_ids: &HashSet<u64>,
552    ) -> bool {
553        if let Some(layer_index) = self.layers.get_mut(layer_id) {
554            layer_index.remove_stale(current_instance_ids)
555        } else {
556            false
557        }
558    }
559
560    /// Remove all index entries for a specific tile across all layers.
561    ///
562    /// Call this when a tile is evicted from the tile cache to release the
563    /// associated cross-tile IDs.
564    pub fn remove_tile(&mut self, tile_id: &TileId) {
565        for layer_index in self.layers.values_mut() {
566            layer_index.remove_tile(tile_id);
567        }
568    }
569
570    /// Remove index state for layers that are no longer active.
571    ///
572    /// `active_layers` is the set of style-layer IDs currently in use.
573    /// Any layer not in this set has its index state dropped.
574    pub fn prune_layers(&mut self, active_layers: &HashSet<String>) {
575        self.layers.retain(|id, _| active_layers.contains(id));
576    }
577
578    /// Total number of indexed tiles across all layers and zoom levels.
579    pub fn tile_count(&self) -> usize {
580        self.layers
581            .values()
582            .flat_map(|layer| layer.grids.values())
583            .map(|zoom_grids| zoom_grids.len())
584            .sum()
585    }
586
587    /// Total number of indexed symbol entries across all tiles and layers.
588    pub fn entry_count(&self) -> usize {
589        self.layers
590            .values()
591            .flat_map(|layer| layer.grids.values())
592            .flat_map(|zoom_grids| zoom_grids.values())
593            .map(|grid| {
594                grid.entries.values().map(|bucket| bucket.len()).sum::<usize>()
595            })
596            .sum()
597    }
598}
599
600// ---------------------------------------------------------------------------
601// Tests
602// ---------------------------------------------------------------------------
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    /// Helper: build a candidate entry at a position within a tile.
609    fn entry(source_layer: &str, text: &str, anchor_x: f64, anchor_y: f64) -> SymbolCandidateEntry {
610        SymbolCandidateEntry::new(source_layer, text, "", anchor_x, anchor_y)
611    }
612
613    #[test]
614    fn assigns_unique_ids_to_distinct_symbols() {
615        let mut index = CrossTileSymbolIndex::new();
616        let iid = index.next_instance_id();
617        let tile = TileId::new(10, 512, 512);
618        let mut candidates = vec![
619            entry("poi", "Restaurant", 100.0, 200.0),
620            entry("poi", "Cafe", 300.0, 400.0),
621        ];
622        index.assign("symbols", tile, iid, &mut candidates);
623
624        assert_ne!(candidates[0].assigned_cross_tile_id, 0);
625        assert_ne!(candidates[1].assigned_cross_tile_id, 0);
626        assert_ne!(
627            candidates[0].assigned_cross_tile_id,
628            candidates[1].assigned_cross_tile_id
629        );
630    }
631
632    #[test]
633    fn matches_same_symbol_across_parent_and_child_tiles() {
634        let mut index = CrossTileSymbolIndex::new();
635
636        // Register a symbol in a parent tile at zoom 10.
637        // Anchor (2048, 2048) is the centre of the top-left quadrant.
638        let parent = TileId::new(10, 512, 512);
639        let parent_iid = index.next_instance_id();
640        let mut parent_candidates = vec![entry("poi", "Museum", 2048.0, 2048.0)];
641        index.assign("symbols", parent, parent_iid, &mut parent_candidates);
642        let parent_id = parent_candidates[0].assigned_cross_tile_id;
643        assert_ne!(parent_id, 0);
644
645        // Register the same symbol in the top-left child tile at zoom 11.
646        // The parent's local (2048, 2048) maps to (4096, 4096) in the child
647        // because the child covers exactly the top-left quadrant of the parent
648        // and its local EXTENT is doubled relative to the parent's sub-region.
649        let child = TileId::new(11, 1024, 1024);
650        let child_iid = index.next_instance_id();
651        let mut child_candidates = vec![entry("poi", "Museum", 4096.0, 4096.0)];
652        index.assign("symbols", child, child_iid, &mut child_candidates);
653
654        // The child should inherit the parent's cross-tile ID.
655        assert_eq!(child_candidates[0].assigned_cross_tile_id, parent_id);
656    }
657
658    #[test]
659    fn does_not_match_different_text() {
660        let mut index = CrossTileSymbolIndex::new();
661
662        // Parent anchor at (2048, 2048) -- centre of top-left quadrant.
663        let parent = TileId::new(10, 512, 512);
664        let parent_iid = index.next_instance_id();
665        let mut parent_candidates = vec![entry("poi", "Museum", 2048.0, 2048.0)];
666        index.assign("symbols", parent, parent_iid, &mut parent_candidates);
667        let parent_id = parent_candidates[0].assigned_cross_tile_id;
668
669        // Same world position in the child but different label text.
670        let child = TileId::new(11, 1024, 1024);
671        let child_iid = index.next_instance_id();
672        let mut child_candidates = vec![entry("poi", "Library", 4096.0, 4096.0)];
673        index.assign("symbols", child, child_iid, &mut child_candidates);
674
675        // Different text should not reuse the parent ID.
676        assert_ne!(child_candidates[0].assigned_cross_tile_id, parent_id);
677    }
678
679    #[test]
680    fn eviction_removes_tile_entries() {
681        let mut index = CrossTileSymbolIndex::new();
682
683        let tile = TileId::new(10, 512, 512);
684        let iid = index.next_instance_id();
685        let mut candidates = vec![entry("poi", "Park", 1000.0, 2000.0)];
686        index.assign("symbols", tile, iid, &mut candidates);
687
688        assert_eq!(index.entry_count(), 1);
689        assert_eq!(index.tile_count(), 1);
690
691        index.remove_tile(&tile);
692
693        assert_eq!(index.entry_count(), 0);
694        assert_eq!(index.tile_count(), 0);
695    }
696
697    #[test]
698    fn prune_stale_removes_old_tiles() {
699        let mut index = CrossTileSymbolIndex::new();
700
701        let tile_a = TileId::new(10, 512, 512);
702        let iid_a = index.next_instance_id();
703        let mut a = vec![entry("poi", "A", 100.0, 100.0)];
704        index.assign("symbols", tile_a, iid_a, &mut a);
705
706        let tile_b = TileId::new(10, 513, 512);
707        let iid_b = index.next_instance_id();
708        let mut b = vec![entry("poi", "B", 100.0, 100.0)];
709        index.assign("symbols", tile_b, iid_b, &mut b);
710
711        assert_eq!(index.tile_count(), 2);
712
713        // Only keep tile_a.
714        let mut active = HashSet::new();
715        active.insert(iid_a);
716        let changed = index.prune_stale("symbols", &active);
717        assert!(changed);
718        assert_eq!(index.tile_count(), 1);
719    }
720
721    #[test]
722    fn same_instance_id_is_no_op() {
723        let mut index = CrossTileSymbolIndex::new();
724
725        let tile = TileId::new(10, 512, 512);
726        let iid = index.next_instance_id();
727        let mut candidates = vec![entry("poi", "Park", 1000.0, 2000.0)];
728        let changed1 = index.assign("symbols", tile, iid, &mut candidates);
729        assert!(changed1);
730
731        // Re-registering with the same instance ID should be a no-op.
732        let mut candidates2 = vec![entry("poi", "Park", 1000.0, 2000.0)];
733        let changed2 = index.assign("symbols", tile, iid, &mut candidates2);
734        assert!(!changed2);
735    }
736
737    #[test]
738    fn adjacent_tiles_at_same_zoom_do_not_cross_match() {
739        let mut index = CrossTileSymbolIndex::new();
740
741        // Two adjacent tiles at the same zoom with the same label text
742        // but different tile-local positions should get distinct IDs.
743        let tile_a = TileId::new(10, 512, 512);
744        let iid_a = index.next_instance_id();
745        let mut a = vec![entry("poi", "Church", 7000.0, 4096.0)];
746        index.assign("symbols", tile_a, iid_a, &mut a);
747
748        let tile_b = TileId::new(10, 513, 512);
749        let iid_b = index.next_instance_id();
750        let mut b = vec![entry("poi", "Church", 100.0, 4096.0)];
751        index.assign("symbols", tile_b, iid_b, &mut b);
752
753        // Same zoom tiles are indexed independently; they should only
754        // match if one is a parent/child of the other.
755        assert_ne!(
756            a[0].assigned_cross_tile_id,
757            b[0].assigned_cross_tile_id
758        );
759    }
760
761    #[test]
762    fn child_registered_first_propagates_to_parent() {
763        let mut index = CrossTileSymbolIndex::new();
764
765        // Register a child tile first.
766        // Anchor (4096, 4096) = centre of child (11, 1024, 1024).
767        let child = TileId::new(11, 1024, 1024);
768        let child_iid = index.next_instance_id();
769        let mut child_candidates = vec![entry("poi", "Station", 4096.0, 4096.0)];
770        index.assign("symbols", child, child_iid, &mut child_candidates);
771        let child_id = child_candidates[0].assigned_cross_tile_id;
772
773        // Now register the parent with the world-equivalent anchor.
774        // Child local (4096, 4096) maps to parent local (2048, 2048).
775        let parent = TileId::new(10, 512, 512);
776        let parent_iid = index.next_instance_id();
777        let mut parent_candidates = vec![entry("poi", "Station", 2048.0, 2048.0)];
778        index.assign("symbols", parent, parent_iid, &mut parent_candidates);
779
780        assert_eq!(parent_candidates[0].assigned_cross_tile_id, child_id);
781    }
782
783    #[test]
784    fn prune_layers_removes_unused_layers() {
785        let mut index = CrossTileSymbolIndex::new();
786
787        let tile = TileId::new(10, 512, 512);
788        let iid = index.next_instance_id();
789        let mut a = vec![entry("poi", "A", 100.0, 100.0)];
790        index.assign("layer-a", tile, iid, &mut a);
791
792        let iid2 = index.next_instance_id();
793        let mut b = vec![entry("road", "B", 200.0, 200.0)];
794        index.assign("layer-b", tile, iid2, &mut b);
795
796        assert_eq!(index.tile_count(), 2);
797
798        let mut active = HashSet::new();
799        active.insert("layer-a".to_owned());
800        index.prune_layers(&active);
801
802        assert_eq!(index.tile_count(), 1);
803    }
804
805    #[test]
806    fn replacement_tile_reclaims_ids() {
807        let mut index = CrossTileSymbolIndex::new();
808
809        let tile = TileId::new(10, 512, 512);
810        let iid1 = index.next_instance_id();
811        let mut a = vec![entry("poi", "Old", 100.0, 100.0)];
812        index.assign("symbols", tile, iid1, &mut a);
813        let old_id = a[0].assigned_cross_tile_id;
814
815        // Replace with a new instance (e.g. tile data updated).
816        let iid2 = index.next_instance_id();
817        let mut b = vec![entry("poi", "New", 100.0, 100.0)];
818        index.assign("symbols", tile, iid2, &mut b);
819
820        // The old ID should have been released and a new one assigned.
821        assert_ne!(b[0].assigned_cross_tile_id, old_id);
822        assert_eq!(index.tile_count(), 1);
823    }
824
825    #[test]
826    fn multi_zoom_chain_propagates_ids() {
827        let mut index = CrossTileSymbolIndex::new();
828
829        // Three zoom levels: z9 -> z10 -> z11, all referencing the same
830        // world position.  Each child is the top-left quadrant of its
831        // parent, so the tile-local anchor doubles at each step:
832        //   z9  (256, 256) anchor (1024, 1024)
833        //   z10 (512, 512) anchor (2048, 2048)   -- 1024 * 2
834        //   z11 (1024,1024) anchor (4096, 4096)   -- 2048 * 2
835        let z9 = TileId::new(9, 256, 256);
836        let iid9 = index.next_instance_id();
837        let mut c9 = vec![entry("poi", "Bridge", 1024.0, 1024.0)];
838        index.assign("symbols", z9, iid9, &mut c9);
839        let id9 = c9[0].assigned_cross_tile_id;
840
841        // Register at zoom 10 (child of z9).
842        let z10 = TileId::new(10, 512, 512);
843        let iid10 = index.next_instance_id();
844        let mut c10 = vec![entry("poi", "Bridge", 2048.0, 2048.0)];
845        index.assign("symbols", z10, iid10, &mut c10);
846        assert_eq!(c10[0].assigned_cross_tile_id, id9);
847
848        // Register at zoom 11 (child of z10).
849        let z11 = TileId::new(11, 1024, 1024);
850        let iid11 = index.next_instance_id();
851        let mut c11 = vec![entry("poi", "Bridge", 4096.0, 4096.0)];
852        index.assign("symbols", z11, iid11, &mut c11);
853        assert_eq!(c11[0].assigned_cross_tile_id, id9);
854    }
855}