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}