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}