Skip to main content

rustial_engine/symbols/
mod.rs

1//! Symbol/cartography foundations: asset dependencies and placement.
2//!
3//! This module provides a small engine-owned symbol system inspired by the
4//! higher-level responsibilities in MapLibre's `symbol/placement.ts` and image
5//! / glyph management. It is intentionally foundational rather than complete:
6//! the engine can now derive text/icon dependencies, perform basic collision
7//! detection, de-duplicate nearby repeated labels, and preserve per-symbol fade
8//! state across frames.
9//!
10//! ## Sub-modules
11//!
12//! | Module | Purpose |
13//! |--------|---------|
14//! | [`cross_tile_index`] | Cross-tile symbol deduplication index (MapLibre `CrossTileSymbolIndex` equivalent). |
15
16pub mod cross_tile_index;
17#[cfg(feature = "text-shaping")]
18pub mod text_shaper;
19
20use crate::camera_projection::CameraProjection;
21use rustial_math::{GeoCoord, TileId, WorldBounds};
22use std::collections::{BTreeSet, HashMap, HashSet};
23
24/// A requested glyph in a font stack.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct GlyphKey {
27    /// Font stack name used for the glyph.
28    pub font_stack: String,
29    /// Unicode scalar requested from the font stack.
30    pub codepoint: char,
31}
32
33/// Symbol anchor position relative to the anchor point.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub enum SymbolAnchor {
36    /// Centered on the anchor.
37    Center,
38    /// Above the anchor.
39    Top,
40    /// Below the anchor.
41    Bottom,
42    /// Left of the anchor.
43    Left,
44    /// Right of the anchor.
45    Right,
46    /// Above-left of the anchor.
47    TopLeft,
48    /// Above-right of the anchor.
49    TopRight,
50    /// Below-left of the anchor.
51    BottomLeft,
52    /// Below-right of the anchor.
53    BottomRight,
54}
55
56impl SymbolAnchor {
57    fn offset_signs(self) -> [f64; 2] {
58        match self {
59            SymbolAnchor::Center => [0.0, 0.0],
60            SymbolAnchor::Top => [0.0, 1.0],
61            SymbolAnchor::Bottom => [0.0, -1.0],
62            SymbolAnchor::Left => [-1.0, 0.0],
63            SymbolAnchor::Right => [1.0, 0.0],
64            SymbolAnchor::TopLeft => [-1.0, 1.0],
65            SymbolAnchor::TopRight => [1.0, 1.0],
66            SymbolAnchor::BottomLeft => [-1.0, -1.0],
67            SymbolAnchor::BottomRight => [1.0, -1.0],
68        }
69    }
70}
71
72/// Text writing mode for symbol labels.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
74pub enum SymbolWritingMode {
75    /// Left-to-right horizontal text flow.
76    Horizontal,
77    /// Top-to-bottom vertical text flow.
78    Vertical,
79}
80
81impl Default for SymbolWritingMode {
82    fn default() -> Self {
83        Self::Horizontal
84    }
85}
86
87/// Horizontal text justification for symbol labels.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
89pub enum SymbolTextJustify {
90    /// Match justification to the effective anchor direction.
91    Auto,
92    /// Align text toward the left side of the label box.
93    Left,
94    /// Center text within the label box.
95    Center,
96    /// Align text toward the right side of the label box.
97    Right,
98}
99
100impl Default for SymbolTextJustify {
101    fn default() -> Self {
102        Self::Auto
103    }
104}
105
106/// Text transformation applied before shaping and measurement.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
108pub enum SymbolTextTransform {
109    /// Keep source text unchanged.
110    None,
111    /// Convert text to uppercase.
112    Uppercase,
113    /// Convert text to lowercase.
114    Lowercase,
115}
116
117impl Default for SymbolTextTransform {
118    fn default() -> Self {
119        Self::None
120    }
121}
122
123/// Icon sizing mode relative to the label text box.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub enum SymbolIconTextFit {
126    /// Keep the icon at its nominal size.
127    None,
128    /// Stretch the icon to match text width.
129    Width,
130    /// Stretch the icon to match text height.
131    Height,
132    /// Stretch the icon to match both text width and height.
133    Both,
134}
135
136impl Default for SymbolIconTextFit {
137    fn default() -> Self {
138        Self::None
139    }
140}
141
142/// Placement mode for symbol features.
143///
144/// Mapbox and MapLibre distinguish between point-placed symbols and symbols
145/// that are anchored along line geometry. The engine still uses a simplified
146/// line-placement model, but keeping the mode explicit in the shared symbol
147/// types lets style parsing, candidate generation, and future parity work all
148/// agree on the intended placement behavior.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
150pub enum SymbolPlacement {
151    /// Place symbols at point geometry anchors.
152    Point,
153    /// Place symbols along line geometry.
154    Line,
155}
156
157impl Default for SymbolPlacement {
158    fn default() -> Self {
159        Self::Point
160    }
161}
162
163/// Rasterized glyph bitmap.
164#[derive(Debug, Clone, PartialEq)]
165pub struct GlyphRaster {
166    /// Bitmap width in pixels.
167    pub width: u16,
168    /// Bitmap height in pixels.
169    pub height: u16,
170    /// Horizontal advance in pixels.
171    pub advance_x: f32,
172    /// Left-side bearing in pixels.
173    pub bearing_x: i16,
174    /// Top bearing in pixels.
175    pub bearing_y: i16,
176    /// Alpha bitmap, row-major.
177    pub alpha: Vec<u8>,
178}
179
180impl GlyphRaster {
181    /// Create a new glyph raster.
182    pub fn new(
183        width: u16,
184        height: u16,
185        advance_x: f32,
186        bearing_x: i16,
187        bearing_y: i16,
188        alpha: Vec<u8>,
189    ) -> Self {
190        Self {
191            width,
192            height,
193            advance_x,
194            bearing_x,
195            bearing_y,
196            alpha,
197        }
198    }
199}
200
201/// Glyph atlas entry metadata.
202#[derive(Debug, Clone, PartialEq)]
203pub struct GlyphAtlasEntry {
204    /// Requested glyph key.
205    pub key: GlyphKey,
206    /// Atlas origin in pixels.
207    pub origin: [u16; 2],
208    /// Raster width/height in pixels.
209    pub size: [u16; 2],
210    /// Horizontal advance in pixels.
211    pub advance_x: f32,
212    /// Left-side bearing in pixels.
213    pub bearing_x: i16,
214    /// Top bearing in pixels.
215    pub bearing_y: i16,
216}
217
218/// Glyph raster source used to populate a glyph atlas.
219pub trait GlyphProvider: Send + Sync {
220    /// Load or rasterize a glyph bitmap for the requested font/codepoint.
221    fn load_glyph(&self, font_stack: &str, codepoint: char) -> Option<GlyphRaster>;
222
223    /// Pixel size of 1 em as rasterized by this provider.
224    ///
225    /// The symbol batch builder divides `symbol.size_px` by this value to
226    /// compute the scale factor that maps glyph atlas pixels to world units.
227    /// Default is 24.0 (matching the MapLibre `ONE_EM` convention).
228    fn render_em_pixels(&self) -> f32 {
229        24.0
230    }
231}
232
233/// A tiny built-in procedural glyph source for engine-side symbol plumbing.
234#[derive(Debug, Clone, Default)]
235pub struct ProceduralGlyphProvider;
236
237impl ProceduralGlyphProvider {
238    /// Create a procedural glyph provider.
239    pub fn new() -> Self {
240        Self
241    }
242}
243
244impl GlyphProvider for ProceduralGlyphProvider {
245    fn load_glyph(&self, _font_stack: &str, codepoint: char) -> Option<GlyphRaster> {
246        let width = 8u16;
247        let height = 12u16;
248        let mut alpha = vec![0u8; width as usize * height as usize];
249        let seed = codepoint as u32;
250        for y in 0..height as usize {
251            for x in 0..width as usize {
252                let border = x == 0 || y == 0 || x + 1 == width as usize || y + 1 == height as usize;
253                let bits = ((seed >> ((x + y) % 8)) & 1) != 0;
254                let horizontal = y == 3 || y == 6 || y == 9;
255                let vertical = x == 2 || x == 5;
256                alpha[y * width as usize + x] = if border || (bits && (horizontal || vertical)) {
257                    255
258                } else {
259                    0
260                };
261            }
262        }
263        Some(GlyphRaster::new(width, height, width as f32 + 1.0, 0, height as i16, alpha))
264    }
265
266    fn render_em_pixels(&self) -> f32 {
267        12.0
268    }
269}
270
271/// Number of pixels of SDF padding added around each glyph in the atlas.
272///
273/// The signed distance field extends this many pixels beyond the glyph
274/// outline, enabling smooth anti-aliasing and configurable halo width.
275/// Matches the MapLibre `GLYPH_PBF_BORDER` value of 3.
276const SDF_BUFFER: u16 = 3;
277
278/// Requested glyph set for symbol text rendering.
279#[derive(Debug, Clone)]
280pub struct GlyphAtlas {
281    requested: BTreeSet<GlyphKey>,
282    entries: HashMap<GlyphKey, GlyphAtlasEntry>,
283    alpha: Vec<u8>,
284    dimensions: [u16; 2],
285    render_em_px: f32,
286}
287
288impl Default for GlyphAtlas {
289    fn default() -> Self {
290        Self {
291            requested: BTreeSet::new(),
292            entries: HashMap::new(),
293            alpha: Vec::new(),
294            dimensions: [0, 0],
295            render_em_px: 24.0,
296        }
297    }
298}
299
300impl GlyphAtlas {
301    /// Create an empty glyph atlas request set.
302    pub fn new() -> Self {
303        Self::default()
304    }
305
306    /// Request all glyphs needed to render a text string.
307    pub fn request_text(&mut self, font_stack: &str, text: &str) {
308        for codepoint in text.chars() {
309            self.requested.insert(GlyphKey {
310                font_stack: font_stack.to_owned(),
311                codepoint,
312            });
313        }
314    }
315
316    /// Iterate requested glyphs.
317    pub fn requested(&self) -> impl Iterator<Item = &GlyphKey> {
318        self.requested.iter()
319    }
320
321    /// Number of unique requested glyphs.
322    pub fn len(&self) -> usize {
323        self.requested.len()
324    }
325
326    /// Whether there are no requested glyphs.
327    pub fn is_empty(&self) -> bool {
328        self.requested.is_empty()
329    }
330
331    /// Loaded atlas entries.
332    pub fn entries(&self) -> impl Iterator<Item = &GlyphAtlasEntry> {
333        self.entries.values()
334    }
335
336    /// Look up a glyph entry by font stack and codepoint.
337    pub fn get(&self, font_stack: &str, codepoint: char) -> Option<&GlyphAtlasEntry> {
338        self.entries.get(&GlyphKey {
339            font_stack: font_stack.to_owned(),
340            codepoint,
341        })
342    }
343
344    /// Packed alpha bitmap for the atlas.
345    pub fn alpha(&self) -> &[u8] {
346        &self.alpha
347    }
348
349    /// Atlas dimensions in pixels.
350    pub fn dimensions(&self) -> [u16; 2] {
351        self.dimensions
352    }
353
354    /// Pixel size of 1 em as reported by the provider used for the last
355    /// [`load_requested`](Self::load_requested) call.
356    pub fn render_em_px(&self) -> f32 {
357        self.render_em_px
358    }
359
360    /// Load all requested glyphs into a packed atlas using a glyph provider.
361    ///
362    /// Each glyph is rasterized by the provider, then converted to a signed
363    /// distance field (SDF) with [`SDF_BUFFER`] pixels of padding.  The
364    /// resulting atlas can be used directly with an SDF text shader.
365    pub fn load_requested(&mut self, provider: &dyn GlyphProvider) {
366        self.entries.clear();
367        self.alpha.clear();
368        self.dimensions = [0, 0];
369        self.render_em_px = provider.render_em_pixels();
370
371        let mut rasters: Vec<(GlyphKey, GlyphRaster)> = Vec::new();
372        for key in &self.requested {
373            if let Some(raster) = provider.load_glyph(&key.font_stack, key.codepoint) {
374                rasters.push((key.clone(), raster));
375            }
376        }
377        if rasters.is_empty() {
378            return;
379        }
380
381        // Apply SDF distance transform with padding to each glyph.
382        let buf = SDF_BUFFER;
383        let rasters: Vec<(GlyphKey, GlyphRaster)> = rasters
384            .into_iter()
385            .map(|(key, raster)| {
386                if raster.width == 0 || raster.height == 0 {
387                    return (key, raster); // Whitespace: keep as-is.
388                }
389                let sdf_alpha = binary_to_sdf(
390                    &raster.alpha,
391                    raster.width as usize,
392                    raster.height as usize,
393                    buf as usize,
394                );
395                (
396                    key,
397                    GlyphRaster::new(
398                        raster.width + buf * 2,
399                        raster.height + buf * 2,
400                        raster.advance_x,
401                        raster.bearing_x - buf as i16,
402                        raster.bearing_y + buf as i16,
403                        sdf_alpha,
404                    ),
405                )
406            })
407            .collect();
408
409        let padding = 1u16;
410        let atlas_width = rasters
411            .iter()
412            .map(|(_, raster)| raster.width + padding)
413            .sum::<u16>()
414            .max(1);
415        let atlas_height = rasters
416            .iter()
417            .map(|(_, raster)| raster.height)
418            .max()
419            .unwrap_or(0)
420            + padding * 2;
421
422        self.dimensions = [atlas_width, atlas_height.max(1)];
423        self.alpha = vec![0; atlas_width as usize * self.dimensions[1] as usize];
424
425        let mut cursor_x = padding;
426        for (key, raster) in rasters {
427            let atlas_key = key.clone();
428            let origin = [cursor_x, padding];
429            blit_alpha(
430                &mut self.alpha,
431                self.dimensions[0] as usize,
432                origin,
433                raster.width as usize,
434                raster.height as usize,
435                &raster.alpha,
436            );
437            self.entries.insert(
438                atlas_key,
439                GlyphAtlasEntry {
440                    key,
441                    origin,
442                    size: [raster.width, raster.height],
443                    advance_x: raster.advance_x,
444                    bearing_x: raster.bearing_x,
445                    bearing_y: raster.bearing_y,
446                },
447            );
448            cursor_x += raster.width + padding;
449        }
450    }
451}
452
453/// A sprite or standalone image dependency.
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct SpriteImage {
456    /// Stable image id.
457    pub id: String,
458    /// Pixel origin in a sprite sheet.
459    pub origin: [u32; 2],
460    /// Image pixel size.
461    pub pixel_size: [u32; 2],
462}
463
464impl SpriteImage {
465    /// Create a new image descriptor.
466    pub fn new(id: impl Into<String>, pixel_size: [u32; 2]) -> Self {
467        Self {
468            id: id.into(),
469            origin: [0, 0],
470            pixel_size,
471        }
472    }
473
474    /// Set the sprite-sheet origin for this image.
475    pub fn with_origin(mut self, origin: [u32; 2]) -> Self {
476        self.origin = origin;
477        self
478    }
479}
480
481/// Registered sprite-sheet metadata.
482#[derive(Debug, Clone, Default)]
483pub struct SpriteSheet {
484    images: HashMap<String, SpriteImage>,
485}
486
487impl SpriteSheet {
488    /// Create an empty sprite sheet registry.
489    pub fn new() -> Self {
490        Self::default()
491    }
492
493    /// Register or replace a sprite image.
494    pub fn register(&mut self, image: SpriteImage) {
495        self.images.insert(image.id.clone(), image);
496    }
497
498    /// Look up a sprite image.
499    pub fn get(&self, id: &str) -> Option<&SpriteImage> {
500        self.images.get(id)
501    }
502
503    /// Iterate registered sprite images.
504    pub fn iter(&self) -> impl Iterator<Item = &SpriteImage> {
505        self.images.values()
506    }
507
508    /// Parse a MapLibre-style sprite index JSON document.
509    #[cfg(feature = "style-json")]
510    pub fn from_index_json(json: &str) -> Result<Self, SpriteSheetParseError> {
511        use serde::Deserialize;
512
513        #[derive(Deserialize)]
514        struct SpriteIndexEntry {
515            x: u32,
516            y: u32,
517            width: u32,
518            height: u32,
519        }
520
521        let index: HashMap<String, SpriteIndexEntry> = serde_json::from_str(json)
522            .map_err(SpriteSheetParseError::Json)?;
523        let mut sheet = SpriteSheet::new();
524        for (id, entry) in index {
525            sheet.register(
526                SpriteImage::new(id, [entry.width, entry.height]).with_origin([entry.x, entry.y]),
527            );
528        }
529        Ok(sheet)
530    }
531}
532
533/// Errors while parsing a sprite-sheet index.
534#[cfg(feature = "style-json")]
535#[derive(Debug)]
536pub enum SpriteSheetParseError {
537    /// JSON parse failure.
538    Json(serde_json::Error),
539}
540
541#[cfg(feature = "style-json")]
542impl std::fmt::Display for SpriteSheetParseError {
543    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544        match self {
545            SpriteSheetParseError::Json(err) => write!(f, "sprite sheet parse error: {err}"),
546        }
547    }
548}
549
550#[cfg(feature = "style-json")]
551impl std::error::Error for SpriteSheetParseError {}
552
553/// Tracks symbol image dependencies.
554#[derive(Debug, Clone, Default)]
555pub struct ImageManager {
556    sprites: SpriteSheet,
557    images: HashMap<String, SpriteImage>,
558    referenced: BTreeSet<String>,
559}
560
561impl ImageManager {
562    /// Create an empty image manager.
563    pub fn new() -> Self {
564        Self::default()
565    }
566
567    /// Register a sprite-sheet image.
568    pub fn register_sprite(&mut self, image: SpriteImage) {
569        self.sprites.register(image);
570    }
571
572    /// Register a standalone image.
573    pub fn register_image(&mut self, image: SpriteImage) {
574        self.images.insert(image.id.clone(), image);
575    }
576
577    /// Load a sprite-sheet index JSON document.
578    #[cfg(feature = "style-json")]
579    pub fn load_sprite_sheet_index_json(&mut self, json: &str) -> Result<(), SpriteSheetParseError> {
580        self.sprites = SpriteSheet::from_index_json(json)?;
581        Ok(())
582    }
583
584    /// Mark an image id as required by the current symbol set.
585    pub fn request(&mut self, id: &str) {
586        self.referenced.insert(id.to_owned());
587    }
588
589    /// Check whether an image is known either as a sprite or standalone image.
590    pub fn contains(&self, id: &str) -> bool {
591        self.images.contains_key(id) || self.sprites.get(id).is_some()
592    }
593
594    /// Iterate referenced image ids.
595    pub fn referenced(&self) -> impl Iterator<Item = &str> {
596        self.referenced.iter().map(String::as_str)
597    }
598
599    fn clear_requests(&mut self) {
600        self.referenced.clear();
601    }
602}
603
604/// Asset dependencies for a single placed symbol.
605#[derive(Debug, Clone, Default, PartialEq, Eq)]
606pub struct SymbolAssetDependencies {
607    /// Glyphs needed for text rendering.
608    pub glyphs: BTreeSet<GlyphKey>,
609    /// Referenced image ids needed for icon rendering.
610    pub images: BTreeSet<String>,
611}
612
613/// Runtime registry of glyph and image dependencies derived from placed symbols.
614#[derive(Debug, Clone, Default)]
615pub struct SymbolAssetRegistry {
616    glyphs: GlyphAtlas,
617    images: ImageManager,
618}
619
620impl SymbolAssetRegistry {
621    /// Create an empty symbol asset registry.
622    pub fn new() -> Self {
623        Self::default()
624    }
625
626    /// Immutable access to requested glyphs.
627    pub fn glyphs(&self) -> &GlyphAtlas {
628        &self.glyphs
629    }
630
631    /// Immutable access to tracked images.
632    pub fn images(&self) -> &ImageManager {
633        &self.images
634    }
635
636    /// Mutable access to tracked images for registration.
637    pub fn images_mut(&mut self) -> &mut ImageManager {
638        &mut self.images
639    }
640
641    /// Rebuild dependency state from placed symbols.
642    pub fn rebuild_from_symbols(&mut self, symbols: &[PlacedSymbol]) {
643        self.glyphs = GlyphAtlas::default();
644        self.images.clear_requests();
645        for symbol in symbols.iter().filter(|symbol| symbol.visible) {
646            if let Some(text) = &symbol.text {
647                self.glyphs.request_text(&symbol.font_stack, text);
648            }
649            if let Some(icon) = &symbol.icon_image {
650                self.images.request(icon);
651            }
652        }
653    }
654}
655
656/// Candidate symbol generated from a symbol layer before placement.
657#[derive(Debug, Clone)]
658pub struct SymbolCandidate {
659    /// Stable per-feature candidate id used for fade persistence.
660    pub id: String,
661    /// Originating style/runtime layer id, when known.
662    pub layer_id: Option<String>,
663    /// Originating style source id, when known.
664    pub source_id: Option<String>,
665    /// Originating style source-layer id, when known.
666    pub source_layer: Option<String>,
667    /// Tile that supplied the symbol feature, when known.
668    pub source_tile: Option<TileId>,
669    /// Stable feature id for feature-state and queries.
670    pub feature_id: String,
671    /// Source-local feature index.
672    pub feature_index: usize,
673    /// Stable group id for candidate variants of the same conceptual symbol.
674    ///
675    /// Most symbols only generate one candidate, so this matches the unique
676    /// per-feature candidate id. When optional text/icon fallback variants are
677    /// emitted, they all share this group id so placement can choose the first
678    /// variant that fits without treating the fallbacks as unrelated symbols.
679    pub placement_group_id: String,
680    /// Whether this symbol is point-placed or line-placed.
681    pub placement: SymbolPlacement,
682    /// Geographic anchor point for the symbol.
683    pub anchor: GeoCoord,
684    /// Optional label text.
685    pub text: Option<String>,
686    /// Optional icon image id.
687    pub icon_image: Option<String>,
688    /// Font stack to use for text glyph requests.
689    pub font_stack: String,
690    /// Stable cross-tile / cross-frame placement identity.
691    pub cross_tile_id: String,
692    /// Clockwise screen-space rotation in radians for line-following labels.
693    ///
694    /// Point-placed symbols keep this at `0`. Line-placed symbols use the
695    /// local path direction at the chosen anchor so placeholder rendering can
696    /// start following the line before full glyph shaping along paths exists.
697    pub rotation_rad: f32,
698    /// Nominal symbol size in pixels.
699    pub size_px: f32,
700    /// Additional collision padding in pixels.
701    pub padding_px: f32,
702    /// Whether overlap should be allowed.
703    pub allow_overlap: bool,
704    /// Whether this symbol should avoid blocking later symbols.
705    pub ignore_placement: bool,
706    /// Optional placement ordering key.
707    ///
708    /// Lower keys are placed first. `None` means the candidate keeps source
709    /// order relative to other unsorted symbols.
710    pub sort_key: Option<f32>,
711    /// Optional radial offset measured in symbol-size units.
712    pub radial_offset: Option<f32>,
713    /// Explicit per-anchor offsets for variable anchor placement.
714    pub variable_anchor_offsets: Option<Vec<(SymbolAnchor, [f32; 2])>>,
715    /// Maximum point-label width before wrapping.
716    pub text_max_width: Option<f32>,
717    /// Preferred wrapped text line height.
718    pub text_line_height: Option<f32>,
719    /// Extra spacing between adjacent glyphs.
720    pub text_letter_spacing: Option<f32>,
721    /// Icon sizing mode relative to the text box.
722    pub icon_text_fit: SymbolIconTextFit,
723    /// Padding applied when fitting the icon around text.
724    pub icon_text_fit_padding: [f32; 4],
725    /// Preferred anchor candidates in priority order.
726    pub anchors: Vec<SymbolAnchor>,
727    /// Writing mode used to estimate collision and layout dimensions.
728    pub writing_mode: SymbolWritingMode,
729    /// Pixel-space text/icon offset.
730    pub offset_px: [f32; 2],
731    /// Primary fill colour used for placeholder rendering.
732    pub fill_color: [f32; 4],
733    /// Halo colour used for placeholder rendering.
734    pub halo_color: [f32; 4],
735}
736
737impl SymbolCandidate {
738    /// Build asset dependencies for this candidate.
739    pub fn dependencies(&self) -> SymbolAssetDependencies {
740        let mut deps = SymbolAssetDependencies::default();
741        if let Some(text) = &self.text {
742            for codepoint in text.chars() {
743                deps.glyphs.insert(GlyphKey {
744                    font_stack: self.font_stack.clone(),
745                    codepoint,
746                });
747            }
748        }
749        if let Some(icon) = &self.icon_image {
750            deps.images.insert(icon.clone());
751        }
752        deps
753    }
754
755    fn dedupe_key(&self, meters_per_pixel: f64) -> String {
756        if !self.cross_tile_id.is_empty() {
757            return self.cross_tile_id.clone();
758        }
759        cross_tile_id_for_symbol(self.text.as_deref(), self.icon_image.as_deref(), &self.anchor, meters_per_pixel)
760    }
761}
762
763/// A simple collision box in world-space meters.
764#[derive(Debug, Clone, PartialEq)]
765pub struct SymbolCollisionBox {
766    /// Minimum X/Y corner.
767    pub min: [f64; 2],
768    /// Maximum X/Y corner.
769    pub max: [f64; 2],
770}
771
772impl SymbolCollisionBox {
773    /// Whether two collision boxes overlap.
774    pub fn intersects(&self, other: &Self) -> bool {
775        !(self.max[0] <= other.min[0]
776            || self.min[0] >= other.max[0]
777            || self.max[1] <= other.min[1]
778            || self.min[1] >= other.max[1])
779    }
780}
781
782/// A pre-laid-out glyph quad for GPU rendering.
783///
784/// Positions are in pixels relative to the symbol anchor.  The batch builder
785/// uses these directly instead of re-computing layout from the raw text string.
786#[derive(Debug, Clone, PartialEq)]
787pub struct GlyphQuad {
788    /// Unicode codepoint (for atlas lookup).
789    pub codepoint: char,
790    /// Horizontal offset from the symbol anchor in pixels.
791    pub x: f32,
792    /// Vertical offset from the symbol anchor in pixels.
793    pub y: f32,
794}
795
796/// A placed symbol after collision resolution.
797#[derive(Debug, Clone)]
798pub struct PlacedSymbol {
799    /// Stable symbol id.
800    pub id: String,
801    /// Originating style/runtime layer id, when known.
802    pub layer_id: Option<String>,
803    /// Originating style source id, when known.
804    pub source_id: Option<String>,
805    /// Originating style source-layer id, when known.
806    pub source_layer: Option<String>,
807    /// Tile that supplied the symbol feature, when known.
808    pub source_tile: Option<TileId>,
809    /// Stable feature id for feature-state and queries.
810    pub feature_id: String,
811    /// Source-local feature index.
812    pub feature_index: usize,
813    /// Whether this symbol is point-placed or line-placed.
814    pub placement: SymbolPlacement,
815    /// Geographic anchor point.
816    pub anchor: GeoCoord,
817    /// World-space anchor point.
818    pub world_anchor: [f64; 3],
819    /// Optional text content.
820    pub text: Option<String>,
821    /// Optional icon image id.
822    pub icon_image: Option<String>,
823    /// Font stack for text rendering.
824    pub font_stack: String,
825    /// Stable cross-tile identity.
826    pub cross_tile_id: String,
827    /// Clockwise screen-space rotation in radians for rendering.
828    pub rotation_rad: f32,
829    /// Collision box used during placement.
830    pub collision_box: SymbolCollisionBox,
831    /// Selected anchor used for this placement.
832    pub anchor_mode: SymbolAnchor,
833    /// Writing mode used for this placement.
834    pub writing_mode: SymbolWritingMode,
835    /// Pixel-space offset applied during placement.
836    pub offset_px: [f32; 2],
837    /// Optional radial offset measured in symbol-size units.
838    pub radial_offset: Option<f32>,
839    /// Maximum point-label width before wrapping.
840    pub text_max_width: Option<f32>,
841    /// Preferred wrapped text line height.
842    pub text_line_height: Option<f32>,
843    /// Extra spacing between adjacent glyphs.
844    pub text_letter_spacing: Option<f32>,
845    /// Icon sizing mode relative to the text box.
846    pub icon_text_fit: SymbolIconTextFit,
847    /// Padding applied when fitting the icon around text.
848    pub icon_text_fit_padding: [f32; 4],
849    /// Nominal symbol size in pixels.
850    pub size_px: f32,
851    /// Placeholder fill colour used by current renderers.
852    pub fill_color: [f32; 4],
853    /// Placeholder halo colour used by current renderers.
854    pub halo_color: [f32; 4],
855    /// Fade opacity in `[0, 1]`.
856    pub opacity: f32,
857    /// Whether the symbol survived collision placement this frame.
858    pub visible: bool,
859    /// Pre-computed glyph positions relative to the symbol anchor (pixels).
860    ///
861    /// Populated by [`layout_symbol_glyphs`].  When non-empty the renderer
862    /// uses these quads directly; when empty it falls back to naive
863    /// character-by-character layout.
864    pub glyph_quads: Vec<GlyphQuad>,
865}
866
867impl PlacedSymbol {
868    /// Asset dependencies required by this placed symbol.
869    pub fn dependencies(&self) -> SymbolAssetDependencies {
870        SymbolCandidate {
871            id: self.id.clone(),
872            layer_id: self.layer_id.clone(),
873            source_id: self.source_id.clone(),
874            source_layer: self.source_layer.clone(),
875            source_tile: self.source_tile,
876            feature_id: self.feature_id.clone(),
877            feature_index: self.feature_index,
878            placement_group_id: self.id.clone(),
879            placement: self.placement,
880            anchor: self.anchor,
881            text: self.text.clone(),
882            icon_image: self.icon_image.clone(),
883            font_stack: self.font_stack.clone(),
884            cross_tile_id: self.cross_tile_id.clone(),
885            rotation_rad: self.rotation_rad,
886            size_px: 0.0,
887            padding_px: 0.0,
888            allow_overlap: true,
889            ignore_placement: false,
890            sort_key: None,
891            radial_offset: self.radial_offset,
892            variable_anchor_offsets: None,
893            text_max_width: self.text_max_width,
894            text_line_height: self.text_line_height,
895            text_letter_spacing: self.text_letter_spacing,
896            icon_text_fit: self.icon_text_fit,
897            icon_text_fit_padding: self.icon_text_fit_padding,
898            anchors: vec![self.anchor_mode],
899            writing_mode: self.writing_mode,
900            offset_px: self.offset_px,
901            fill_color: self.fill_color,
902            halo_color: self.halo_color,
903        }
904        .dependencies()
905    }
906}
907
908/// Placement tuning parameters.
909#[derive(Debug, Clone)]
910pub struct SymbolPlacementConfig {
911    /// Fade-in rate in opacity units per second.
912    pub fade_in_per_second: f32,
913    /// Fade-out rate in opacity units per second.
914    pub fade_out_per_second: f32,
915    /// Extra world-space margin multiplier used for viewport culling.
916    pub viewport_padding_factor: f64,
917}
918
919impl Default for SymbolPlacementConfig {
920    fn default() -> Self {
921        Self {
922            fade_in_per_second: 6.0,
923            fade_out_per_second: 8.0,
924            viewport_padding_factor: 2.0,
925        }
926    }
927}
928
929/// Stateful placement engine with collision handling and fade persistence.
930#[derive(Debug, Clone, Default)]
931pub struct SymbolPlacementEngine {
932    /// Placement tuning parameters.
933    pub config: SymbolPlacementConfig,
934    previous_opacity: HashMap<String, f32>,
935    previous_anchor: HashMap<String, SymbolAnchor>,
936    /// Cross-tile symbol index for deduplicating labels across tile boundaries.
937    pub cross_tile_index: cross_tile_index::CrossTileSymbolIndex,
938}
939
940impl SymbolPlacementEngine {
941    /// Create a placement engine with default configuration.
942    pub fn new() -> Self {
943        Self::default()
944    }
945
946    /// Remove all cross-tile index entries for a specific tile.
947    ///
948    /// Call this when a tile is evicted from the tile cache so that the
949    /// index releases the associated cross-tile IDs.
950    pub fn remove_tile(&mut self, tile_id: &TileId) {
951        self.cross_tile_index.remove_tile(tile_id);
952    }
953
954    /// Place candidates for the current frame.
955    pub fn place_candidates(
956        &mut self,
957        candidates: &[SymbolCandidate],
958        projection: CameraProjection,
959        meters_per_pixel: f64,
960        dt_seconds: f64,
961        viewport_bounds: Option<&WorldBounds>,
962    ) -> Vec<PlacedSymbol> {
963        let dt = dt_seconds.max(0.0) as f32;
964        let grouped_candidates = Self::group_symbol_candidates(candidates, meters_per_pixel);
965        let mut result = Vec::new();
966        let mut accepted_boxes = Vec::new();
967        let mut seen_ids = HashSet::new();
968        let mut dedupe = HashSet::new();
969
970        for (placement_id, variants) in grouped_candidates {
971            let Some(primary_candidate) = variants.first() else {
972                continue;
973            };
974            seen_ids.insert(placement_id.clone());
975            if !dedupe.insert(placement_id.clone()) {
976                continue;
977            }
978
979            let anchors = ordered_candidate_anchors(
980                primary_candidate,
981                self.previous_anchor.get(&placement_id).copied(),
982            );
983            let mut chosen = None;
984            for candidate in &variants {
985                if candidate.text.is_none() && candidate.icon_image.is_none() {
986                    continue;
987                }
988                for anchor in anchors.iter().copied() {
989                    let collision_boxes =
990                        candidate_collision_boxes(candidate, projection, anchor, meters_per_pixel);
991                    if collision_boxes.is_empty()
992                        || !collision_boxes.iter().any(|bbox| {
993                            viewport_contains_box(
994                                viewport_bounds,
995                                bbox,
996                                self.config.viewport_padding_factor,
997                            )
998                        })
999                    {
1000                        continue;
1001                    }
1002                    let collides = !candidate.allow_overlap
1003                        && accepted_boxes
1004                            .iter()
1005                            .any(|other| collision_boxes.iter().any(|bbox| bbox.intersects(other)));
1006                    if !collides {
1007                        chosen = Some((candidate, anchor, collision_boxes));
1008                        break;
1009                    }
1010                }
1011                if chosen.is_some() {
1012                    break;
1013                }
1014            }
1015
1016            let prev = self.previous_opacity.get(&placement_id).copied().unwrap_or(0.0);
1017            let opacity = if chosen.is_none() {
1018                (prev - self.config.fade_out_per_second * dt).max(0.0)
1019            } else {
1020                (prev + self.config.fade_in_per_second * dt).clamp(0.0, 1.0)
1021            };
1022
1023            self.previous_opacity.insert(placement_id.clone(), opacity);
1024            if opacity <= 0.0 {
1025                continue;
1026            }
1027
1028            let (selected_candidate, anchor_mode, collision_box, visible) = if let Some((selected_candidate, anchor_mode, collision_boxes)) = chosen {
1029                if !selected_candidate.ignore_placement {
1030                    accepted_boxes.extend(collision_boxes.iter().cloned());
1031                }
1032                self.previous_anchor.insert(placement_id.clone(), anchor_mode);
1033                (
1034                    selected_candidate,
1035                    anchor_mode,
1036                    merge_collision_boxes(&collision_boxes),
1037                    true,
1038                )
1039            } else {
1040                let fallback_anchor = self
1041                    .previous_anchor
1042                    .get(&placement_id)
1043                    .copied()
1044                    .unwrap_or_else(|| {
1045                        primary_candidate
1046                            .anchors
1047                            .first()
1048                            .copied()
1049                            .unwrap_or(SymbolAnchor::Center)
1050                    });
1051                let collision_boxes = candidate_collision_boxes(
1052                    primary_candidate,
1053                    projection,
1054                    fallback_anchor,
1055                    meters_per_pixel,
1056                );
1057                (
1058                    primary_candidate,
1059                    fallback_anchor,
1060                    merge_collision_boxes(&collision_boxes),
1061                    false,
1062                )
1063            };
1064
1065            let world = projection.project(&selected_candidate.anchor);
1066            result.push(PlacedSymbol {
1067                id: selected_candidate.id.clone(),
1068                layer_id: selected_candidate.layer_id.clone(),
1069                source_id: selected_candidate.source_id.clone(),
1070                source_layer: selected_candidate.source_layer.clone(),
1071                source_tile: selected_candidate.source_tile,
1072                feature_id: selected_candidate.feature_id.clone(),
1073                feature_index: selected_candidate.feature_index,
1074                placement: selected_candidate.placement,
1075                anchor: selected_candidate.anchor,
1076                world_anchor: [world.position.x, world.position.y, world.position.z],
1077                text: selected_candidate.text.clone(),
1078                icon_image: selected_candidate.icon_image.clone(),
1079                font_stack: selected_candidate.font_stack.clone(),
1080                cross_tile_id: placement_id.clone(),
1081                rotation_rad: selected_candidate.rotation_rad,
1082                collision_box,
1083                anchor_mode,
1084                writing_mode: selected_candidate.writing_mode,
1085                offset_px: resolve_symbol_offset(
1086                    anchor_mode,
1087                    selected_candidate.offset_px,
1088                    selected_candidate.radial_offset,
1089                    selected_candidate.variable_anchor_offsets.as_deref(),
1090                    selected_candidate.size_px,
1091                ),
1092                radial_offset: None,
1093                text_max_width: selected_candidate.text_max_width,
1094                text_line_height: selected_candidate.text_line_height,
1095                text_letter_spacing: selected_candidate.text_letter_spacing,
1096                icon_text_fit: selected_candidate.icon_text_fit,
1097                icon_text_fit_padding: selected_candidate.icon_text_fit_padding,
1098                size_px: selected_candidate.size_px,
1099                fill_color: selected_candidate.fill_color,
1100                halo_color: selected_candidate.halo_color,
1101                opacity,
1102                visible,
1103                glyph_quads: Vec::new(),
1104            });
1105        }
1106
1107        self.previous_opacity
1108            .retain(|id, opacity| seen_ids.contains(id) && *opacity > 0.0);
1109        self.previous_anchor.retain(|id, _| seen_ids.contains(id));
1110        result
1111    }
1112
1113/// Group candidate variants by conceptual symbol before placement.
1114///
1115/// Optional text/icon semantics can emit several fallback candidates for one
1116/// underlying symbol. Grouping them here lets placement try the preferred
1117/// variant first and then fall back to a text-only or icon-only variant if the
1118/// full pair does not fit, while still keeping fade state keyed by the shared
1119/// cross-tile placement identity.
1120fn group_symbol_candidates<'a>(
1121    candidates: &'a [SymbolCandidate],
1122    meters_per_pixel: f64,
1123) -> Vec<(String, Vec<&'a SymbolCandidate>)> {
1124    let mut grouped: Vec<(String, Vec<&'a SymbolCandidate>)> = Vec::new();
1125    let mut group_indexes: HashMap<String, usize> = HashMap::new();
1126
1127    for candidate in candidates {
1128        let placement_id = candidate.dedupe_key(meters_per_pixel);
1129        let group_key = format!("{}|{}", placement_id, candidate.placement_group_id);
1130        if let Some(index) = group_indexes.get(&group_key).copied() {
1131            grouped[index].1.push(candidate);
1132        } else {
1133            group_indexes.insert(group_key, grouped.len());
1134            grouped.push((placement_id, vec![candidate]));
1135        }
1136    }
1137
1138        // Mapbox and MapLibre use `symbol-sort-key` to make placement order
1139        // explicit. Sorting at the grouped-symbol level keeps optional fallback
1140        // variants together while still honoring the requested inter-symbol
1141        // priority. When no sort keys are present, retain the original source
1142        // order to preserve the engine's existing behavior.
1143        if grouped
1144            .iter()
1145            .any(|(_, variants)| variants.first().and_then(|candidate| candidate.sort_key).is_some())
1146        {
1147            grouped.sort_by(|(_, a_variants), (_, b_variants)| {
1148                let a_key = a_variants.first().and_then(|candidate| candidate.sort_key).unwrap_or(0.0);
1149                let b_key = b_variants.first().and_then(|candidate| candidate.sort_key).unwrap_or(0.0);
1150                a_key
1151                    .partial_cmp(&b_key)
1152                    .unwrap_or(std::cmp::Ordering::Equal)
1153            });
1154        }
1155
1156    grouped
1157}
1158}
1159
1160/// Build a placeholder vector mesh from placed symbols.
1161pub fn symbol_mesh_from_placed_symbols(
1162    symbols: &[PlacedSymbol],
1163    projection: CameraProjection,
1164    meters_per_pixel: f64,
1165) -> crate::layers::VectorMeshData {
1166    let mut mesh = crate::layers::VectorMeshData::default();
1167    for symbol in symbols.iter().filter(|symbol| symbol.visible && symbol.opacity > 0.0) {
1168        let [half_w, half_h] = symbol_half_extents(
1169            symbol.size_px,
1170            symbol.text.as_deref(),
1171            symbol.icon_image.as_deref(),
1172            symbol.writing_mode,
1173            symbol.placement,
1174            symbol.text_max_width,
1175            symbol.text_line_height,
1176            symbol.text_letter_spacing,
1177            symbol.icon_text_fit,
1178            symbol.icon_text_fit_padding,
1179            symbol.offset_px,
1180            1.0,
1181        );
1182        append_symbol_quad(&mut mesh, symbol, projection, half_w * meters_per_pixel * 1.35, half_h * meters_per_pixel * 1.35, symbol.halo_color, symbol.opacity, meters_per_pixel);
1183        append_symbol_quad(&mut mesh, symbol, projection, half_w * meters_per_pixel, half_h * meters_per_pixel, symbol.fill_color, symbol.opacity, meters_per_pixel);
1184    }
1185    mesh
1186}
1187
1188// ---------------------------------------------------------------------------
1189// Symbol glyph layout
1190// ---------------------------------------------------------------------------
1191
1192/// Convert a [`SymbolAnchor`] to horizontal/vertical alignment factors.
1193///
1194/// Returns `(h_align, v_align)` where `0.0 = left/top`, `0.5 = center`,
1195/// `1.0 = right/bottom`.
1196fn anchor_align(anchor: SymbolAnchor) -> (f32, f32) {
1197    let h = match anchor {
1198        SymbolAnchor::Left | SymbolAnchor::TopLeft | SymbolAnchor::BottomLeft => 0.0,
1199        SymbolAnchor::Right | SymbolAnchor::TopRight | SymbolAnchor::BottomRight => 1.0,
1200        _ => 0.5,
1201    };
1202    let v = match anchor {
1203        SymbolAnchor::Top | SymbolAnchor::TopLeft | SymbolAnchor::TopRight => 0.0,
1204        SymbolAnchor::Bottom | SymbolAnchor::BottomLeft | SymbolAnchor::BottomRight => 1.0,
1205        _ => 0.5,
1206    };
1207    (h, v)
1208}
1209
1210/// Compute per-glyph positions from the glyph atlas and store them in each
1211/// `PlacedSymbol::glyph_quads`.
1212///
1213/// This is the basic (non-shaping) layout path: glyphs are positioned
1214/// left-to-right using atlas advance/bearing metrics.  Point labels are
1215/// wrapped at `text_max_width` (in em-units) and the resulting block is
1216/// aligned according to `anchor_mode`.
1217///
1218/// When the `text-shaping` feature is enabled, call
1219/// [`layout_symbol_glyphs_shaped`] instead for proper kerning, ligatures,
1220/// BiDi reorder, and line breaking.
1221pub fn layout_symbol_glyphs(symbols: &mut [PlacedSymbol], atlas: &GlyphAtlas) {
1222    let em = atlas.render_em_px();
1223    for symbol in symbols.iter_mut() {
1224        symbol.glyph_quads.clear();
1225        if !symbol.visible || symbol.opacity <= 0.0 {
1226            continue;
1227        }
1228        let text = match &symbol.text {
1229            Some(t) if !t.is_empty() => t.clone(),
1230            _ => continue,
1231        };
1232
1233        let scale = symbol.size_px / em.max(1.0);
1234        let letter_spacing = symbol.text_letter_spacing.unwrap_or(0.0) * symbol.size_px;
1235        let line_height = symbol.text_line_height.unwrap_or(1.2) * symbol.size_px;
1236
1237        // Break text into lines based on text_max_width (for point labels).
1238        let max_line_width_px = if symbol.placement == SymbolPlacement::Point {
1239            symbol.text_max_width.map(|w| w * symbol.size_px)
1240        } else {
1241            None
1242        };
1243
1244        let lines = break_text_simple(&text, atlas, &symbol.font_stack, scale, letter_spacing, max_line_width_px);
1245        if lines.is_empty() {
1246            continue;
1247        }
1248
1249        // Compute per-line widths and total height.
1250        let line_widths: Vec<f32> = lines.iter().map(|glyphs| {
1251            glyphs.last().map(|(_, x, adv)| x + adv).unwrap_or(0.0)
1252        }).collect();
1253        let max_width = line_widths.iter().cloned().fold(0.0f32, f32::max);
1254        let total_height = lines.len() as f32 * line_height;
1255
1256        // Anchor alignment.
1257        let (h_align, v_align) = anchor_align(symbol.anchor_mode);
1258
1259        let mut quads = Vec::new();
1260        for (line_idx, (glyphs, line_w)) in lines.iter().zip(line_widths.iter()).enumerate() {
1261            // Justify: center each line relative to the widest line.
1262            let line_shift_x = (max_width - line_w) * 0.5;
1263            // Anchor-based horizontal offset.
1264            let anchor_x = -max_width * h_align;
1265            // Anchor-based vertical offset.
1266            let anchor_y = -total_height * v_align + line_idx as f32 * line_height;
1267
1268            for &(codepoint, glyph_x, _advance) in glyphs {
1269                quads.push(GlyphQuad {
1270                    codepoint,
1271                    x: anchor_x + line_shift_x + glyph_x,
1272                    y: anchor_y,
1273                });
1274            }
1275        }
1276
1277        symbol.glyph_quads = quads;
1278    }
1279}
1280
1281/// Break text into lines and compute per-glyph x positions using atlas
1282/// metrics.  Returns lines of `(codepoint, x_pixel, advance_pixel)` tuples.
1283fn break_text_simple(
1284    text: &str,
1285    atlas: &GlyphAtlas,
1286    font_stack: &str,
1287    scale: f32,
1288    letter_spacing: f32,
1289    max_width: Option<f32>,
1290) -> Vec<Vec<(char, f32, f32)>> {
1291    let chars: Vec<char> = text.chars().collect();
1292    if chars.is_empty() {
1293        return Vec::new();
1294    }
1295
1296    // First pass: compute advance for each character.
1297    let mut advances: Vec<f32> = Vec::with_capacity(chars.len());
1298    for &ch in &chars {
1299        let adv = atlas.get(font_stack, ch)
1300            .map(|e| e.advance_x * scale)
1301            .unwrap_or(0.0);
1302        advances.push(adv);
1303    }
1304
1305    // Break into words at spaces.
1306    let mut lines: Vec<Vec<(char, f32, f32)>> = Vec::new();
1307    let mut current_line: Vec<(char, f32, f32)> = Vec::new();
1308    let mut cursor_x: f32 = 0.0;
1309
1310    for (i, &ch) in chars.iter().enumerate() {
1311        let adv = advances[i];
1312
1313        // Forced newline.
1314        if ch == '\n' {
1315            lines.push(std::mem::take(&mut current_line));
1316            cursor_x = 0.0;
1317            continue;
1318        }
1319
1320        // Check if we should wrap before this word.
1321        if let Some(max_w) = max_width {
1322            if ch == ' ' && cursor_x > 0.0 {
1323                // Check if the next word would exceed max_width.
1324                let next_word_end = next_word_width(&chars, &advances, i + 1, letter_spacing);
1325                if cursor_x + adv + letter_spacing + next_word_end > max_w && !current_line.is_empty() {
1326                    lines.push(std::mem::take(&mut current_line));
1327                    cursor_x = 0.0;
1328                    continue; // Drop the space at line start.
1329                }
1330            }
1331        }
1332
1333        if !current_line.is_empty() {
1334            cursor_x += letter_spacing;
1335        }
1336        current_line.push((ch, cursor_x, adv));
1337        cursor_x += adv;
1338    }
1339    if !current_line.is_empty() {
1340        lines.push(current_line);
1341    }
1342
1343    lines
1344}
1345
1346/// Measure the width of the next word (until the next space or end of text).
1347fn next_word_width(chars: &[char], advances: &[f32], start: usize, letter_spacing: f32) -> f32 {
1348    let mut w: f32 = 0.0;
1349    let mut first = true;
1350    for i in start..chars.len() {
1351        if chars[i] == ' ' || chars[i] == '\n' {
1352            break;
1353        }
1354        if !first {
1355            w += letter_spacing;
1356        }
1357        w += advances[i];
1358        first = false;
1359    }
1360    w
1361}
1362
1363/// Lay out symbol glyphs using the text shaping engine.
1364///
1365/// Uses [`shape_text`](text_shaper::shape_text) for correct kerning,
1366/// ligatures, BiDi reordering, and line wrapping.  The shaped glyph
1367/// coordinates (in layout units) are scaled to pixel coordinates using
1368/// `symbol.size_px / ONE_EM`.
1369#[cfg(feature = "text-shaping")]
1370pub fn layout_symbol_glyphs_shaped(
1371    symbols: &mut [PlacedSymbol],
1372    registry: &mut text_shaper::FontRegistry,
1373) {
1374    use text_shaper::{ShapeTextOptions, TextAnchor, TextJustify, shape_text, ONE_EM};
1375
1376    for symbol in symbols.iter_mut() {
1377        symbol.glyph_quads.clear();
1378        if !symbol.visible || symbol.opacity <= 0.0 {
1379            continue;
1380        }
1381        let text = match &symbol.text {
1382            Some(t) if !t.is_empty() => t.as_str(),
1383            _ => continue,
1384        };
1385
1386        let anchor = match symbol.anchor_mode {
1387            SymbolAnchor::Center => TextAnchor::Center,
1388            SymbolAnchor::Top => TextAnchor::Top,
1389            SymbolAnchor::Bottom => TextAnchor::Bottom,
1390            SymbolAnchor::Left => TextAnchor::Left,
1391            SymbolAnchor::Right => TextAnchor::Right,
1392            SymbolAnchor::TopLeft => TextAnchor::TopLeft,
1393            SymbolAnchor::TopRight => TextAnchor::TopRight,
1394            SymbolAnchor::BottomLeft => TextAnchor::BottomLeft,
1395            SymbolAnchor::BottomRight => TextAnchor::BottomRight,
1396        };
1397
1398        let options = ShapeTextOptions {
1399            font_stack: symbol.font_stack.clone(),
1400            max_width: if symbol.placement == SymbolPlacement::Point {
1401                symbol.text_max_width.or(Some(10.0))
1402            } else {
1403                None
1404            },
1405            line_height: symbol.text_line_height.unwrap_or(1.2),
1406            letter_spacing: symbol.text_letter_spacing.unwrap_or(0.0),
1407            justify: TextJustify::Center,
1408            anchor,
1409            writing_mode: symbol.writing_mode,
1410            text_transform: SymbolTextTransform::None,
1411        };
1412
1413        let shaped = match shape_text(text, registry, &options) {
1414            Some(s) => s,
1415            None => continue,
1416        };
1417
1418        let px_per_layout = symbol.size_px / ONE_EM;
1419        let mut quads = Vec::with_capacity(shaped.glyph_count());
1420        for line in &shaped.lines {
1421            for glyph in &line.glyphs {
1422                quads.push(GlyphQuad {
1423                    codepoint: glyph.codepoint,
1424                    x: glyph.x * px_per_layout,
1425                    y: glyph.y * px_per_layout,
1426                });
1427            }
1428        }
1429
1430        symbol.glyph_quads = quads;
1431    }
1432}
1433
1434fn candidate_collision_boxes(
1435    candidate: &SymbolCandidate,
1436    projection: CameraProjection,
1437    anchor_mode: SymbolAnchor,
1438    meters_per_pixel: f64,
1439) -> Vec<SymbolCollisionBox> {
1440    let world = projection.project(&candidate.anchor);
1441    let effective_offset = resolve_symbol_offset(
1442        anchor_mode,
1443        candidate.offset_px,
1444        candidate.radial_offset,
1445        candidate.variable_anchor_offsets.as_deref(),
1446        candidate.size_px,
1447    );
1448    let [half_w_px, half_h_px] = symbol_half_extents(
1449        candidate.size_px,
1450        candidate.text.as_deref(),
1451        candidate.icon_image.as_deref(),
1452        candidate.writing_mode,
1453        candidate.placement,
1454        candidate.text_max_width,
1455        candidate.text_line_height,
1456        candidate.text_letter_spacing,
1457        candidate.icon_text_fit,
1458        candidate.icon_text_fit_padding,
1459        effective_offset,
1460        candidate.padding_px.max(0.0) as f64,
1461    );
1462    let half_w = half_w_px * meters_per_pixel;
1463    let half_h = half_h_px * meters_per_pixel;
1464    let signs = anchor_mode.offset_signs();
1465    let center_x = world.position.x + effective_offset[0] as f64 * meters_per_pixel + signs[0] * half_w * 2.0;
1466    let center_y = world.position.y + effective_offset[1] as f64 * meters_per_pixel + signs[1] * half_h * 2.0;
1467
1468    segmented_collision_boxes(candidate, center_x, center_y, half_w, half_h)
1469}
1470
1471/// Build collision boxes for a symbol candidate.
1472///
1473/// Point labels still collide as a single box. Line labels use several smaller
1474/// boxes along the local label axis so collision testing better matches the
1475/// rendered label footprint. This is still simpler than Mapbox or MapLibre's
1476/// full line collision geometry, but it reduces false positives from one large
1477/// enclosing box without changing the rest of the placement contract.
1478fn segmented_collision_boxes(
1479    candidate: &SymbolCandidate,
1480    center_x: f64,
1481    center_y: f64,
1482    half_w: f64,
1483    half_h: f64,
1484) -> Vec<SymbolCollisionBox> {
1485    if candidate.placement != SymbolPlacement::Line {
1486        return vec![rotated_collision_box(
1487            center_x,
1488            center_y,
1489            half_w,
1490            half_h,
1491            candidate.rotation_rad,
1492        )];
1493    }
1494
1495    let full_width = half_w * 2.0;
1496    let full_height = half_h * 2.0;
1497    let segment_count = ((full_width / full_height.max(1.0)).ceil() as usize).clamp(1, 4);
1498    let segment_half_w = half_w / segment_count as f64;
1499    let sin_theta = candidate.rotation_rad.sin() as f64;
1500    let cos_theta = candidate.rotation_rad.cos() as f64;
1501    let mut boxes = Vec::with_capacity(segment_count);
1502
1503    for index in 0..segment_count {
1504        let local_center_x = -half_w + segment_half_w + (index as f64 * segment_half_w * 2.0);
1505        let rotated_center_x = local_center_x * cos_theta;
1506        let rotated_center_y = local_center_x * sin_theta;
1507        boxes.push(rotated_collision_box(
1508            center_x + rotated_center_x,
1509            center_y + rotated_center_y,
1510            segment_half_w,
1511            half_h,
1512            candidate.rotation_rad,
1513        ));
1514    }
1515
1516    boxes
1517}
1518
1519/// Build an axis-aligned collision box that encloses a rotated symbol quad.
1520///
1521/// Mapbox and MapLibre eventually work with richer line-label collision
1522/// geometry than a single box. The engine still uses a single collision box,
1523/// but by enclosing the rotated quad instead of the unrotated one we keep the
1524/// collision approximation aligned with the current rendered orientation.
1525fn rotated_collision_box(
1526    center_x: f64,
1527    center_y: f64,
1528    half_w: f64,
1529    half_h: f64,
1530    rotation_rad: f32,
1531) -> SymbolCollisionBox {
1532    let sin_theta = rotation_rad.sin() as f64;
1533    let cos_theta = rotation_rad.cos() as f64;
1534    let mut min_x = f64::INFINITY;
1535    let mut min_y = f64::INFINITY;
1536    let mut max_x = f64::NEG_INFINITY;
1537    let mut max_y = f64::NEG_INFINITY;
1538
1539    for [local_x, local_y] in [
1540        [-half_w, -half_h],
1541        [half_w, -half_h],
1542        [half_w, half_h],
1543        [-half_w, half_h],
1544    ] {
1545        let rotated_x = local_x * cos_theta - local_y * sin_theta;
1546        let rotated_y = local_x * sin_theta + local_y * cos_theta;
1547        let x = center_x + rotated_x;
1548        let y = center_y + rotated_y;
1549        min_x = min_x.min(x);
1550        min_y = min_y.min(y);
1551        max_x = max_x.max(x);
1552        max_y = max_y.max(y);
1553    }
1554
1555    SymbolCollisionBox {
1556        min: [min_x, min_y],
1557        max: [max_x, max_y],
1558    }
1559}
1560
1561fn merge_collision_boxes(boxes: &[SymbolCollisionBox]) -> SymbolCollisionBox {
1562    let mut min_x = f64::INFINITY;
1563    let mut min_y = f64::INFINITY;
1564    let mut max_x = f64::NEG_INFINITY;
1565    let mut max_y = f64::NEG_INFINITY;
1566
1567    for bbox in boxes {
1568        min_x = min_x.min(bbox.min[0]);
1569        min_y = min_y.min(bbox.min[1]);
1570        max_x = max_x.max(bbox.max[0]);
1571        max_y = max_y.max(bbox.max[1]);
1572    }
1573
1574    if boxes.is_empty() {
1575        SymbolCollisionBox {
1576            min: [0.0, 0.0],
1577            max: [0.0, 0.0],
1578        }
1579    } else {
1580        SymbolCollisionBox {
1581            min: [min_x, min_y],
1582            max: [max_x, max_y],
1583        }
1584    }
1585}
1586
1587fn ordered_candidate_anchors(candidate: &SymbolCandidate, previous: Option<SymbolAnchor>) -> Vec<SymbolAnchor> {
1588    let mut anchors = candidate.anchors.clone();
1589    if anchors.is_empty() {
1590        anchors.push(SymbolAnchor::Center);
1591    }
1592    if let Some(previous) = previous {
1593        if let Some(index) = anchors.iter().position(|anchor| *anchor == previous) {
1594            anchors.swap(0, index);
1595        }
1596    }
1597    anchors
1598}
1599
1600fn viewport_contains_box(
1601    viewport_bounds: Option<&WorldBounds>,
1602    bbox: &SymbolCollisionBox,
1603    padding_factor: f64,
1604) -> bool {
1605    let Some(bounds) = viewport_bounds else {
1606        return true;
1607    };
1608    let width = (bbox.max[0] - bbox.min[0]).abs() * padding_factor;
1609    let height = (bbox.max[1] - bbox.min[1]).abs() * padding_factor;
1610    !(bbox.max[0] < bounds.min.position.x - width
1611        || bbox.min[0] > bounds.max.position.x + width
1612        || bbox.max[1] < bounds.min.position.y - height
1613        || bbox.min[1] > bounds.max.position.y + height)
1614}
1615
1616fn symbol_half_extents(
1617    size_px: f32,
1618    text: Option<&str>,
1619    icon_image: Option<&str>,
1620    writing_mode: SymbolWritingMode,
1621    placement: SymbolPlacement,
1622    text_max_width: Option<f32>,
1623    text_line_height: Option<f32>,
1624    text_letter_spacing: Option<f32>,
1625    icon_text_fit: SymbolIconTextFit,
1626    icon_text_fit_padding: [f32; 4],
1627    offset_px: [f32; 2],
1628    padding_px: f64,
1629) -> [f64; 2] {
1630    let size_px = size_px.max(1.0) as f64;
1631    let (text_width_px, text_height_px) = estimate_text_box(
1632        text,
1633        size_px,
1634        placement,
1635        text_max_width,
1636        text_line_height,
1637        text_letter_spacing,
1638    );
1639    let icon_size_px = if icon_image.is_some() { size_px * 1.2 } else { 0.0 };
1640    let (width_px, height_px) = match writing_mode {
1641        SymbolWritingMode::Horizontal => fitted_symbol_box(
1642            text_width_px.max(size_px),
1643            text_height_px.max(size_px),
1644            icon_image.is_some(),
1645            icon_size_px,
1646            icon_text_fit,
1647            icon_text_fit_padding,
1648            padding_px,
1649        ),
1650        SymbolWritingMode::Vertical => fitted_symbol_box(
1651            text_height_px.max(size_px),
1652            text_width_px.max(size_px),
1653            icon_image.is_some(),
1654            icon_size_px,
1655            icon_text_fit,
1656            icon_text_fit_padding,
1657            padding_px,
1658        ),
1659    };
1660    [
1661        (width_px + offset_px[0].abs() as f64) * 0.5,
1662        (height_px + offset_px[1].abs() as f64) * 0.5,
1663    ]
1664}
1665
1666/// Estimate the placeholder text box used by the current simplified symbol renderer.
1667///
1668/// Mapbox and MapLibre perform full shaping and line breaking. The engine still
1669/// uses a lightweight character-based approximation, but honoring
1670/// `text-max-width` for point labels makes wrapped labels occupy a footprint
1671/// much closer to the eventual shaped result than always treating text as a
1672/// single line. `text-line-height` feeds the wrapped height estimate so the
1673/// style can influence multi-line placeholder spacing as well, and
1674/// `text-letter-spacing` expands the estimated line width so collisions react
1675/// to basic glyph spacing changes before full shaping exists.
1676fn estimate_text_box(
1677    text: Option<&str>,
1678    size_px: f64,
1679    placement: SymbolPlacement,
1680    text_max_width: Option<f32>,
1681    text_line_height: Option<f32>,
1682    text_letter_spacing: Option<f32>,
1683) -> (f64, f64) {
1684    let Some(text) = text else {
1685        return (0.0, 0.0);
1686    };
1687
1688    let glyph_count = text.chars().count() as f64;
1689    let letter_spacing_px = text_letter_spacing.unwrap_or(0.0).max(0.0) as f64 * size_px;
1690    let total_width_px = glyph_count * size_px * 0.6 + (glyph_count - 1.0).max(0.0) * letter_spacing_px;
1691    let line_height_px = size_px * text_line_height.unwrap_or(1.2).max(0.1) as f64;
1692
1693    if placement != SymbolPlacement::Point {
1694        return (total_width_px, line_height_px);
1695    }
1696
1697    let Some(max_width_em) = text_max_width else {
1698        return (total_width_px, line_height_px);
1699    };
1700    let max_width_px = (max_width_em.max(1.0) as f64) * size_px;
1701    if total_width_px <= max_width_px {
1702        return (total_width_px, line_height_px);
1703    }
1704
1705    let line_count = (total_width_px / max_width_px).ceil().max(1.0);
1706    (max_width_px, line_height_px * line_count)
1707}
1708
1709/// Combine placeholder text and icon extents, including simplified icon-text-fit support.
1710///
1711/// Mapbox and MapLibre can stretch icons around the shaped text box when
1712/// `icon-text-fit` is enabled. The engine approximates that by resizing the
1713/// placeholder icon box around the estimated text box plus fit padding instead
1714/// of treating the icon as a separate side-by-side glyph.
1715fn fitted_symbol_box(
1716    text_width_px: f64,
1717    text_height_px: f64,
1718    has_icon: bool,
1719    icon_size_px: f64,
1720    icon_text_fit: SymbolIconTextFit,
1721    icon_text_fit_padding: [f32; 4],
1722    padding_px: f64,
1723) -> (f64, f64) {
1724    if !has_icon {
1725        return (text_width_px + padding_px * 2.0, text_height_px + padding_px * 2.0);
1726    }
1727
1728    let icon_base_width = icon_size_px;
1729    let icon_base_height = icon_size_px;
1730    if icon_text_fit == SymbolIconTextFit::None || text_width_px <= 0.0 || text_height_px <= 0.0 {
1731        return (
1732            text_width_px + icon_base_width + padding_px * 2.0,
1733            text_height_px.max(icon_base_height) + padding_px * 2.0,
1734        );
1735    }
1736
1737    let fit_width = match icon_text_fit {
1738        SymbolIconTextFit::None | SymbolIconTextFit::Height => icon_base_width,
1739        SymbolIconTextFit::Width | SymbolIconTextFit::Both => {
1740            text_width_px + icon_text_fit_padding[1] as f64 + icon_text_fit_padding[3] as f64
1741        }
1742    };
1743    let fit_height = match icon_text_fit {
1744        SymbolIconTextFit::None | SymbolIconTextFit::Width => icon_base_height,
1745        SymbolIconTextFit::Height | SymbolIconTextFit::Both => {
1746            text_height_px + icon_text_fit_padding[0] as f64 + icon_text_fit_padding[2] as f64
1747        }
1748    };
1749
1750    (
1751        fit_width.max(text_width_px) + padding_px * 2.0,
1752        fit_height.max(text_height_px) + padding_px * 2.0,
1753    )
1754}
1755
1756/// Resolve the effective offset for a symbol after anchor selection.
1757///
1758/// The engine treats `text-radial-offset` as an anchor-direction displacement
1759/// expressed in symbol-size units. When it is present it overrides the fixed
1760/// `text-offset` vector, matching the style-spec precedence while still using a
1761/// simple geometry model that does not depend on full glyph metrics. When no
1762/// radial offset is present, the fixed offset is resolved relative to the
1763/// chosen anchor so anchored labels move in the same general direction as the
1764/// corresponding Mapbox and MapLibre placement rules.
1765fn resolve_symbol_offset(
1766    anchor_mode: SymbolAnchor,
1767    offset_px: [f32; 2],
1768    radial_offset: Option<f32>,
1769    variable_anchor_offsets: Option<&[(SymbolAnchor, [f32; 2])]>,
1770    size_px: f32,
1771) -> [f32; 2] {
1772    if let Some(anchor_offsets) = variable_anchor_offsets {
1773        if let Some((_, offset)) = anchor_offsets.iter().find(|(anchor, _)| *anchor == anchor_mode) {
1774            return [offset[0] * size_px.max(1.0), offset[1] * size_px.max(1.0)];
1775        }
1776    }
1777    let Some(radial_offset) = radial_offset else {
1778        return resolve_anchor_relative_text_offset(anchor_mode, offset_px);
1779    };
1780
1781    let radial_px = radial_offset.max(0.0) * size_px.max(1.0);
1782    let diagonal_px = radial_px / std::f32::consts::SQRT_2;
1783    match anchor_mode {
1784        SymbolAnchor::Center => [0.0, 0.0],
1785        SymbolAnchor::Top => [0.0, radial_px],
1786        SymbolAnchor::Bottom => [0.0, -radial_px],
1787        SymbolAnchor::Left => [radial_px, 0.0],
1788        SymbolAnchor::Right => [-radial_px, 0.0],
1789        SymbolAnchor::TopLeft => [diagonal_px, diagonal_px],
1790        SymbolAnchor::TopRight => [-diagonal_px, diagonal_px],
1791        SymbolAnchor::BottomLeft => [diagonal_px, -diagonal_px],
1792        SymbolAnchor::BottomRight => [-diagonal_px, -diagonal_px],
1793    }
1794}
1795
1796/// Resolve a fixed text offset relative to the selected anchor.
1797///
1798/// Mapbox and MapLibre interpret `text-offset` relative to anchor direction
1799/// rather than as a raw free-form XY translation for non-centered anchors. The
1800/// engine keeps a simplified baseline-free version of that rule here: centered
1801/// labels retain the raw offset vector, while anchored labels use the absolute
1802/// offset magnitudes projected into the anchor's readable directions.
1803fn resolve_anchor_relative_text_offset(
1804    anchor_mode: SymbolAnchor,
1805    offset_px: [f32; 2],
1806) -> [f32; 2] {
1807    let offset_x = offset_px[0].abs();
1808    let offset_y = offset_px[1].abs();
1809
1810    match anchor_mode {
1811        SymbolAnchor::Center => offset_px,
1812        SymbolAnchor::Top => [0.0, offset_y],
1813        SymbolAnchor::Bottom => [0.0, -offset_y],
1814        SymbolAnchor::Left => [offset_x, 0.0],
1815        SymbolAnchor::Right => [-offset_x, 0.0],
1816        SymbolAnchor::TopLeft => [offset_x, offset_y],
1817        SymbolAnchor::TopRight => [-offset_x, offset_y],
1818        SymbolAnchor::BottomLeft => [offset_x, -offset_y],
1819        SymbolAnchor::BottomRight => [-offset_x, -offset_y],
1820    }
1821}
1822
1823fn append_symbol_quad(
1824    mesh: &mut crate::layers::VectorMeshData,
1825    symbol: &PlacedSymbol,
1826    projection: CameraProjection,
1827    half_w: f64,
1828    half_h: f64,
1829    mut color: [f32; 4],
1830    opacity: f32,
1831    meters_per_pixel: f64,
1832) {
1833    color[3] *= opacity.clamp(0.0, 1.0);
1834    let scale = projection.scale_factor(&symbol.anchor).max(1e-6);
1835    let offset_scale = 1.0 / scale;
1836    let signs = symbol.anchor_mode.offset_signs();
1837    let center_x = symbol.world_anchor[0]
1838        + symbol.offset_px[0] as f64 * meters_per_pixel * offset_scale
1839        + signs[0] * half_w * 2.0;
1840    let center_y = symbol.world_anchor[1]
1841        + symbol.offset_px[1] as f64 * meters_per_pixel * offset_scale
1842        + signs[1] * half_h * 2.0;
1843    let z = symbol.world_anchor[2];
1844    let sin_theta = symbol.rotation_rad.sin() as f64;
1845    let cos_theta = symbol.rotation_rad.cos() as f64;
1846    let base = mesh.positions.len() as u32;
1847    for [local_x, local_y] in [
1848        [-half_w, -half_h],
1849        [half_w, -half_h],
1850        [half_w, half_h],
1851        [-half_w, half_h],
1852    ] {
1853        let rotated_x = local_x * cos_theta - local_y * sin_theta;
1854        let rotated_y = local_x * sin_theta + local_y * cos_theta;
1855        mesh.positions.push([center_x + rotated_x, center_y + rotated_y, z]);
1856    }
1857    for _ in 0..4 {
1858        mesh.colors.push(color);
1859    }
1860    mesh.indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1861}
1862
1863fn cross_tile_id_for_symbol(
1864    text: Option<&str>,
1865    icon: Option<&str>,
1866    anchor: &GeoCoord,
1867    meters_per_pixel: f64,
1868) -> String {
1869    let world = CameraProjection::WebMercator.project(anchor);
1870    let bucket = (meters_per_pixel * 32.0).max(1.0);
1871    let x = (world.position.x / bucket).round() as i64;
1872    let y = (world.position.y / bucket).round() as i64;
1873    format!("{}|{}|{}|{}", text.unwrap_or(""), icon.unwrap_or(""), x, y)
1874}
1875
1876// ---------------------------------------------------------------------------
1877// SDF distance transform (Felzenszwalb & Huttenlocher 2012)
1878// ---------------------------------------------------------------------------
1879
1880/// Convert a binary alpha bitmap into an SDF bitmap with padding.
1881///
1882/// The output bitmap is `(width + 2*buffer)` × `(height + 2*buffer)` pixels.
1883/// Values encode the signed distance to the glyph edge: ~128 (0.5) = edge,
1884/// >128 = inside, <128 = outside.
1885fn binary_to_sdf(
1886    alpha: &[u8],
1887    width: usize,
1888    height: usize,
1889    buffer: usize,
1890) -> Vec<u8> {
1891    if width == 0 || height == 0 {
1892        return Vec::new();
1893    }
1894    let new_w = width + 2 * buffer;
1895    let new_h = height + 2 * buffer;
1896    let len = new_w * new_h;
1897    let big = (new_w * new_w + new_h * new_h) as f32;
1898
1899    // Outer grid: seeds are "inside" pixels (distance 0 to nearest inside);
1900    // non-seeds (outside / padding) start at `big`.
1901    let mut outer = vec![big; len];
1902    // Inner grid: seeds are "outside" pixels (distance 0 to nearest outside);
1903    // non-seeds (inside) start at `big`.  Padding is outside → 0.
1904    let mut inner = vec![0.0f32; len];
1905
1906    for y in 0..height {
1907        for x in 0..width {
1908            let dst = (y + buffer) * new_w + (x + buffer);
1909            if alpha[y * width + x] > 127 {
1910                outer[dst] = 0.0;
1911                inner[dst] = big;
1912            }
1913        }
1914    }
1915
1916    edt_2d(&mut outer, new_w, new_h);
1917    edt_2d(&mut inner, new_w, new_h);
1918
1919    let buf_f = buffer as f32;
1920    let mut sdf = vec![0u8; len];
1921    for i in 0..len {
1922        let dist = outer[i].sqrt() - inner[i].sqrt();
1923        let normalized = 0.5 - dist / (2.0 * buf_f);
1924        sdf[i] = (normalized.clamp(0.0, 1.0) * 255.0) as u8;
1925    }
1926    sdf
1927}
1928
1929/// 2D squared-Euclidean distance transform (separable row + column passes).
1930fn edt_2d(grid: &mut [f32], width: usize, height: usize) {
1931    for y in 0..height {
1932        let offset = y * width;
1933        edt_1d(&mut grid[offset..offset + width]);
1934    }
1935    let mut col = vec![0.0f32; height];
1936    for x in 0..width {
1937        for y in 0..height {
1938            col[y] = grid[y * width + x];
1939        }
1940        edt_1d(&mut col);
1941        for y in 0..height {
1942            grid[y * width + x] = col[y];
1943        }
1944    }
1945}
1946
1947/// 1D squared-Euclidean distance transform (Felzenszwalb & Huttenlocher).
1948///
1949/// Overwrites `f` in-place: seeds have `f[i] == 0.0`, non-seeds start at a
1950/// large value.  On output `f[i]` is the **squared** distance to the nearest
1951/// seed.
1952fn edt_1d(f: &mut [f32]) {
1953    let n = f.len();
1954    if n <= 1 {
1955        return;
1956    }
1957    let mut v = vec![0usize; n]; // Locations of parabolas in the lower envelope.
1958    let mut z = vec![0.0f32; n + 1]; // Range boundaries.
1959    let mut d = vec![0.0f32; n]; // Output.
1960
1961    z[0] = f32::NEG_INFINITY;
1962    z[1] = f32::INFINITY;
1963    let mut k: usize = 0;
1964
1965    for q in 1..n {
1966        let q2 = (q * q) as f32;
1967        loop {
1968            let vk = v[k];
1969            let vk2 = (vk * vk) as f32;
1970            let s = ((f[q] + q2) - (f[vk] + vk2)) / (2.0 * (q - vk) as f32);
1971            if s > z[k] {
1972                k += 1;
1973                v[k] = q;
1974                z[k] = s;
1975                z[k + 1] = f32::INFINITY;
1976                break;
1977            }
1978            // Safe: z[0] = −∞ guarantees s > z[0] for any finite s.
1979            k -= 1;
1980        }
1981    }
1982
1983    k = 0;
1984    for q in 0..n {
1985        while z[k + 1] < q as f32 {
1986            k += 1;
1987        }
1988        let dq = q as f32 - v[k] as f32;
1989        d[q] = dq * dq + f[v[k]];
1990    }
1991    f.copy_from_slice(&d);
1992}
1993
1994fn blit_alpha(
1995    atlas: &mut [u8],
1996    atlas_width: usize,
1997    origin: [u16; 2],
1998    width: usize,
1999    height: usize,
2000    src: &[u8],
2001) {
2002    for row in 0..height {
2003        let dst_start = (origin[1] as usize + row) * atlas_width + origin[0] as usize;
2004        let src_start = row * width;
2005        atlas[dst_start..dst_start + width].copy_from_slice(&src[src_start..src_start + width]);
2006    }
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011    use super::*;
2012    use crate::camera_projection::CameraProjection;
2013    use rustial_math::GeoCoord;
2014
2015    fn candidate(id: &str, lon: f64, text: &str) -> SymbolCandidate {
2016        SymbolCandidate {
2017            id: id.into(),
2018            layer_id: Some("symbols".into()),
2019            source_id: Some("source".into()),
2020            source_layer: Some("poi".into()),
2021            source_tile: None,
2022            feature_id: id.into(),
2023            feature_index: 0,
2024            placement_group_id: id.into(),
2025            placement: SymbolPlacement::Point,
2026            anchor: GeoCoord::from_lat_lon(0.0, lon),
2027            text: Some(text.into()),
2028            icon_image: None,
2029            font_stack: "Test Sans".into(),
2030            cross_tile_id: id.into(),
2031            rotation_rad: 0.0,
2032            size_px: 16.0,
2033            padding_px: 2.0,
2034            allow_overlap: false,
2035            ignore_placement: false,
2036            sort_key: None,
2037            radial_offset: None,
2038            variable_anchor_offsets: None,
2039            text_max_width: None,
2040            text_line_height: None,
2041            text_letter_spacing: None,
2042            icon_text_fit: SymbolIconTextFit::None,
2043            icon_text_fit_padding: [0.0, 0.0, 0.0, 0.0],
2044            anchors: vec![SymbolAnchor::Center],
2045            writing_mode: SymbolWritingMode::Horizontal,
2046            offset_px: [0.0, 0.0],
2047            fill_color: [1.0, 1.0, 1.0, 1.0],
2048            halo_color: [0.0, 0.0, 0.0, 1.0],
2049        }
2050    }
2051
2052    #[test]
2053    fn glyph_atlas_tracks_unique_glyphs() {
2054        let mut atlas = GlyphAtlas::new();
2055        atlas.request_text("Test Sans", "aba");
2056        assert_eq!(atlas.len(), 2);
2057        atlas.load_requested(&ProceduralGlyphProvider::new());
2058        assert_eq!(atlas.entries().count(), 2);
2059        assert!(atlas.dimensions()[0] > 0);
2060    }
2061
2062    #[test]
2063    fn image_manager_tracks_referenced_ids() {
2064        let mut images = ImageManager::new();
2065        images.register_image(SpriteImage::new("marker", [32, 32]));
2066        images.request("marker");
2067        assert!(images.contains("marker"));
2068        assert_eq!(images.referenced().collect::<Vec<_>>(), vec!["marker"]);
2069    }
2070
2071    #[test]
2072    fn placement_filters_colliding_symbols() {
2073        let mut engine = SymbolPlacementEngine::new();
2074        let placed = engine.place_candidates(&[candidate("a", 0.0, "Alpha"), candidate("b", 0.00001, "Beta")], CameraProjection::WebMercator, 2.0, 1.0 / 60.0, None);
2075        assert_eq!(placed.iter().filter(|symbol| symbol.visible).count(), 1);
2076    }
2077
2078    #[test]
2079    fn placement_dedupes_nearby_repeated_labels() {
2080        let mut engine = SymbolPlacementEngine::new();
2081        let placed = engine.place_candidates(&[candidate("a", 0.0, "Same"), candidate("b", 0.0, "Same")], CameraProjection::WebMercator, 2.0, 1.0 / 60.0, None);
2082        assert_eq!(placed.len(), 1);
2083    }
2084
2085    #[test]
2086    fn placement_tries_alternate_anchors() {
2087        let mut a = candidate("a", 0.0, "Alpha");
2088        let mut b = candidate("b", 0.0, "Beta");
2089        a.anchors = vec![SymbolAnchor::Center];
2090        b.anchors = vec![SymbolAnchor::Center, SymbolAnchor::Top];
2091
2092        let mut engine = SymbolPlacementEngine::new();
2093        let placed = engine.place_candidates(&[a, b], CameraProjection::WebMercator, 2.0, 1.0, None);
2094        assert_eq!(placed.iter().filter(|symbol| symbol.visible).count(), 2);
2095        assert_eq!(placed[1].anchor_mode, SymbolAnchor::Top);
2096    }
2097
2098    #[test]
2099    fn placement_preserves_previous_anchor_by_cross_tile_id() {
2100        let mut first = candidate("a", 0.0, "Alpha");
2101        first.cross_tile_id = "shared".into();
2102        first.anchors = vec![SymbolAnchor::Center, SymbolAnchor::Top];
2103
2104        let mut blocker = candidate("blocker", 0.0, "Block");
2105        blocker.anchors = vec![SymbolAnchor::Center];
2106
2107        let mut engine = SymbolPlacementEngine::new();
2108        let placed = engine.place_candidates(&[blocker.clone(), first.clone()], CameraProjection::WebMercator, 2.0, 1.0, None);
2109        assert_eq!(placed[1].anchor_mode, SymbolAnchor::Top);
2110
2111        let mut second = candidate("b", 0.0, "Alpha");
2112        second.cross_tile_id = "shared".into();
2113        second.anchors = vec![SymbolAnchor::Center, SymbolAnchor::Top];
2114        let placed = engine.place_candidates(&[blocker, second], CameraProjection::WebMercator, 2.0, 1.0 / 60.0, None);
2115        assert_eq!(placed[1].anchor_mode, SymbolAnchor::Top);
2116    }
2117
2118    #[test]
2119    fn placement_honors_symbol_sort_key_order() {
2120        let mut low = candidate("low", 0.0, "Low");
2121        low.sort_key = Some(0.0);
2122
2123        let mut high = candidate("high", 0.0, "High");
2124        high.sort_key = Some(10.0);
2125
2126        let mut engine = SymbolPlacementEngine::new();
2127        let placed = engine.place_candidates(
2128            &[high, low],
2129            CameraProjection::WebMercator,
2130            2.0,
2131            1.0,
2132            None,
2133        );
2134
2135        assert_eq!(placed.iter().filter(|symbol| symbol.visible).count(), 1);
2136        assert_eq!(placed.iter().find(|symbol| symbol.visible).map(|symbol| symbol.id.as_str()), Some("low"));
2137    }
2138
2139    #[test]
2140    fn fixed_offset_is_resolved_relative_to_anchor_direction() {
2141        assert_eq!(
2142            resolve_symbol_offset(SymbolAnchor::TopRight, [3.0, 4.0], None, None, 10.0),
2143            [-3.0, 4.0]
2144        );
2145        assert_eq!(
2146            resolve_symbol_offset(SymbolAnchor::BottomLeft, [3.0, 4.0], None, None, 10.0),
2147            [3.0, -4.0]
2148        );
2149    }
2150
2151    #[test]
2152    fn centered_fixed_offset_preserves_raw_vector() {
2153        assert_eq!(
2154            resolve_symbol_offset(SymbolAnchor::Center, [3.0, -4.0], None, None, 10.0),
2155            [3.0, -4.0]
2156        );
2157    }
2158
2159    #[test]
2160    fn radial_offset_overrides_fixed_offset_for_anchor_direction() {
2161        let resolved = resolve_symbol_offset(
2162            SymbolAnchor::TopRight,
2163            [99.0, 99.0],
2164            Some(2.0),
2165            None,
2166            10.0,
2167        );
2168
2169        assert!(resolved[0] < 0.0);
2170        assert!(resolved[1] > 0.0);
2171        assert!(resolved[0].abs() < 99.0);
2172        assert!(resolved[1].abs() < 99.0);
2173    }
2174
2175    #[test]
2176    fn variable_anchor_offset_overrides_radial_and_fixed_offsets() {
2177        let resolved = resolve_symbol_offset(
2178            SymbolAnchor::Top,
2179            [99.0, 99.0],
2180            Some(5.0),
2181            Some(&[(SymbolAnchor::Top, [1.0, 2.0])]),
2182            10.0,
2183        );
2184
2185        assert_eq!(resolved, [10.0, 20.0]);
2186    }
2187
2188    #[test]
2189    fn point_text_max_width_wraps_placeholder_text_box() {
2190        let single_line = estimate_text_box(
2191            Some("abcdefghij"),
2192            10.0,
2193            SymbolPlacement::Point,
2194            None,
2195            None,
2196            None,
2197        );
2198        let wrapped = estimate_text_box(
2199            Some("abcdefghij"),
2200            10.0,
2201            SymbolPlacement::Point,
2202            Some(3.0),
2203            None,
2204            None,
2205        );
2206
2207        assert!(wrapped.0 < single_line.0);
2208        assert!(wrapped.1 > single_line.1);
2209    }
2210
2211    #[test]
2212    fn line_text_max_width_does_not_wrap_placeholder_text_box() {
2213        let unbounded = estimate_text_box(
2214            Some("abcdefghij"),
2215            10.0,
2216            SymbolPlacement::Line,
2217            None,
2218            None,
2219            None,
2220        );
2221        let bounded = estimate_text_box(
2222            Some("abcdefghij"),
2223            10.0,
2224            SymbolPlacement::Line,
2225            Some(3.0),
2226            None,
2227            None,
2228        );
2229
2230        assert_eq!(bounded, unbounded);
2231    }
2232
2233    #[test]
2234    fn wrapped_text_box_uses_text_line_height() {
2235        let compact = estimate_text_box(
2236            Some("abcdefghij"),
2237            10.0,
2238            SymbolPlacement::Point,
2239            Some(3.0),
2240            Some(1.0),
2241            None,
2242        );
2243        let spacious = estimate_text_box(
2244            Some("abcdefghij"),
2245            10.0,
2246            SymbolPlacement::Point,
2247            Some(3.0),
2248            Some(2.0),
2249            None,
2250        );
2251
2252        assert_eq!(compact.0, spacious.0);
2253        assert!(spacious.1 > compact.1);
2254    }
2255
2256    #[test]
2257    fn text_letter_spacing_expands_placeholder_text_box_width() {
2258        let compact = estimate_text_box(
2259            Some("abcde"),
2260            10.0,
2261            SymbolPlacement::Point,
2262            None,
2263            None,
2264            None,
2265        );
2266        let spaced = estimate_text_box(
2267            Some("abcde"),
2268            10.0,
2269            SymbolPlacement::Point,
2270            None,
2271            None,
2272            Some(0.25),
2273        );
2274
2275        assert!(spaced.0 > compact.0);
2276        assert_eq!(spaced.1, compact.1);
2277    }
2278
2279    #[test]
2280    fn icon_text_fit_both_wraps_icon_around_text_box() {
2281        let fitted = symbol_half_extents(
2282            10.0,
2283            Some("abcdefghij"),
2284            Some("marker"),
2285            SymbolWritingMode::Horizontal,
2286            SymbolPlacement::Point,
2287            Some(3.0),
2288            Some(1.5),
2289            Some(0.25),
2290            SymbolIconTextFit::Both,
2291            [1.0, 2.0, 3.0, 4.0],
2292            [0.0, 0.0],
2293            0.0,
2294        );
2295        let unfitted = symbol_half_extents(
2296            10.0,
2297            Some("abcdefghij"),
2298            Some("marker"),
2299            SymbolWritingMode::Horizontal,
2300            SymbolPlacement::Point,
2301            Some(3.0),
2302            Some(1.5),
2303            Some(0.25),
2304            SymbolIconTextFit::None,
2305            [0.0, 0.0, 0.0, 0.0],
2306            [0.0, 0.0],
2307            0.0,
2308        );
2309
2310        assert!(fitted[0] < unfitted[0]);
2311        assert!(fitted[1] > unfitted[1]);
2312    }
2313
2314    #[test]
2315    fn icon_text_fit_width_only_keeps_base_height() {
2316        let fitted = fitted_symbol_box(
2317            40.0,
2318            18.0,
2319            true,
2320            12.0,
2321            SymbolIconTextFit::Width,
2322            [2.0, 3.0, 4.0, 5.0],
2323            0.0,
2324        );
2325
2326        assert_eq!(fitted.1, 18.0);
2327        assert_eq!(fitted.0, 48.0);
2328    }
2329
2330    #[test]
2331    fn placement_ignored_symbol_does_not_block_later_symbol() {
2332        let mut first = candidate("first", 0.0, "Alpha");
2333        first.ignore_placement = true;
2334
2335        let second = candidate("second", 0.0, "Beta");
2336
2337        let mut engine = SymbolPlacementEngine::new();
2338        let placed = engine.place_candidates(
2339            &[first, second],
2340            CameraProjection::WebMercator,
2341            2.0,
2342            1.0,
2343            None,
2344        );
2345
2346        assert_eq!(placed.iter().filter(|symbol| symbol.visible).count(), 2);
2347    }
2348
2349    #[test]
2350    fn asset_registry_rebuilds_from_visible_symbols() {
2351        let mut registry = SymbolAssetRegistry::new();
2352        let mut engine = SymbolPlacementEngine::new();
2353        let placed = engine.place_candidates(
2354            &[SymbolCandidate {
2355                id: "icon".into(),
2356                layer_id: Some("symbols".into()),
2357                source_id: Some("source".into()),
2358                source_layer: Some("poi".into()),
2359                source_tile: None,
2360                feature_id: "icon".into(),
2361                feature_index: 0,
2362                placement_group_id: "icon".into(),
2363                placement: SymbolPlacement::Point,
2364                anchor: GeoCoord::from_lat_lon(0.0, 0.0),
2365                text: Some("Hi".into()),
2366                icon_image: Some("marker".into()),
2367                font_stack: "Test Sans".into(),
2368                cross_tile_id: "icon".into(),
2369                rotation_rad: 0.0,
2370                size_px: 14.0,
2371                padding_px: 0.0,
2372                allow_overlap: true,
2373                ignore_placement: false,
2374                sort_key: None,
2375                radial_offset: None,
2376                variable_anchor_offsets: None,
2377                text_max_width: None,
2378                text_line_height: None,
2379                text_letter_spacing: None,
2380                icon_text_fit: SymbolIconTextFit::None,
2381                icon_text_fit_padding: [0.0, 0.0, 0.0, 0.0],
2382                anchors: vec![SymbolAnchor::Center],
2383                writing_mode: SymbolWritingMode::Horizontal,
2384                offset_px: [0.0, 0.0],
2385                fill_color: [1.0, 1.0, 1.0, 1.0],
2386                halo_color: [0.0, 0.0, 0.0, 1.0],
2387             }],
2388             CameraProjection::WebMercator,
2389              1.0,
2390              0.5,
2391             None,
2392         );
2393         registry.rebuild_from_symbols(&placed);
2394         assert!(registry.glyphs().len() >= 2);
2395         assert_eq!(registry.images().referenced().collect::<Vec<_>>(), vec!["marker"]);
2396     }
2397
2398    #[test]
2399    fn placed_symbols_generate_render_mesh() {
2400        let mut engine = SymbolPlacementEngine::new();
2401        let placed = engine.place_candidates(&[candidate("a", 0.0, "Alpha")], CameraProjection::WebMercator, 1.0, 1.0, None);
2402        let mesh = symbol_mesh_from_placed_symbols(&placed, CameraProjection::WebMercator, 1.0);
2403        assert_eq!(mesh.vertex_count(), 8);
2404        assert_eq!(mesh.index_count(), 12);
2405    }
2406
2407    #[test]
2408    fn rotated_collision_box_expands_for_diagonal_symbols() {
2409        let bbox = rotated_collision_box(0.0, 0.0, 10.0, 2.0, std::f32::consts::FRAC_PI_4);
2410        // A 20x4 box rotated 45 deg produces an AABB of ~17x17.  The height
2411        // should expand well beyond the unrotated 4.0 (2 * half_h), and
2412        // both AABB axes should be wider than the minimum dimension.
2413        let width = bbox.max[0] - bbox.min[0];
2414        let height = bbox.max[1] - bbox.min[1];
2415        assert!(width > 4.0, "rotated AABB width {width} should exceed the unrotated height");
2416        assert!(height > 4.0, "rotated AABB height {height} should exceed the unrotated height");
2417        // Verify the AABB is roughly square for a 45-degree rotation.
2418        assert!((width - height).abs() < 1.0, "45 deg rotation should produce a near-square AABB");
2419    }
2420
2421    #[test]
2422    fn line_candidates_use_multiple_collision_boxes() {
2423        let mut line = candidate("line", 0.0, "Long river label");
2424        line.placement = SymbolPlacement::Line;
2425        line.rotation_rad = std::f32::consts::FRAC_PI_4;
2426
2427        let boxes = candidate_collision_boxes(&line, CameraProjection::WebMercator, SymbolAnchor::Center, 2.0);
2428        assert!(boxes.len() > 1);
2429    }
2430
2431    #[test]
2432    fn rotated_line_candidates_can_coexist_when_segment_boxes_no_longer_overlap() {
2433        let mut a = candidate("a", 0.0, "Alpha");
2434        a.placement = SymbolPlacement::Line;
2435        a.rotation_rad = std::f32::consts::FRAC_PI_2;
2436
2437        let mut b = candidate("b", 0.0005, "Beta");
2438        b.placement = SymbolPlacement::Line;
2439        b.rotation_rad = std::f32::consts::FRAC_PI_2;
2440
2441        let mut engine = SymbolPlacementEngine::new();
2442        let placed = engine.place_candidates(
2443            &[a, b],
2444            CameraProjection::WebMercator,
2445            2.0,
2446            1.0,
2447            None,
2448        );
2449        assert_eq!(placed.iter().filter(|symbol| symbol.visible).count(), 2);
2450    }
2451
2452    #[test]
2453    fn equirectangular_symbol_anchor_differs_from_mercator() {
2454        let mut engine = SymbolPlacementEngine::new();
2455        let mut merc_candidate = candidate("a", 10.0, "Alpha");
2456        merc_candidate.anchor = GeoCoord::from_lat_lon(45.0, 10.0);
2457        let mut eq_candidate = candidate("b", 10.0, "Alpha");
2458        eq_candidate.anchor = GeoCoord::from_lat_lon(45.0, 10.0);
2459        let merc = engine.place_candidates(&[merc_candidate], CameraProjection::WebMercator, 1.0, 1.0, None);
2460        let eq = engine.place_candidates(&[eq_candidate], CameraProjection::Equirectangular, 1.0, 1.0, None);
2461        assert_eq!(merc.len(), 1);
2462        assert_eq!(eq.len(), 1);
2463        assert!((merc[0].world_anchor[1] - eq[0].world_anchor[1]).abs() > 1.0);
2464    }
2465
2466    // -- SDF distance transform tests -------------------------------------
2467
2468    #[test]
2469    fn sdf_single_pixel_inside_produces_centered_gradient() {
2470        // 1x1 fully-inside bitmap: the center of the padded SDF should be
2471        // bright (inside), edges should taper toward 128 (the edge value).
2472        let alpha = vec![255u8];
2473        let sdf = binary_to_sdf(&alpha, 1, 1, 3);
2474        let w = 1 + 2 * 3; // 7
2475        assert_eq!(sdf.len(), w * w);
2476        // Center pixel (3, 3): should be above 128 (inside).
2477        let center = sdf[3 * w + 3];
2478        assert!(center > 140, "center SDF value {center} should be inside (>140)");
2479        // Corner pixel (0, 0): should be well below 128 (outside).
2480        let corner = sdf[0];
2481        assert!(corner < 100, "corner SDF value {corner} should be outside (<100)");
2482    }
2483
2484    #[test]
2485    fn sdf_empty_bitmap_is_all_outside() {
2486        // All-zero bitmap: everywhere is "outside" → values below 128.
2487        let alpha = vec![0u8; 4];
2488        let sdf = binary_to_sdf(&alpha, 2, 2, 2);
2489        for &v in &sdf {
2490            assert!(v <= 128, "SDF value {v} should be ≤128 for all-outside");
2491        }
2492    }
2493
2494    #[test]
2495    fn sdf_full_bitmap_is_all_inside() {
2496        // All-255 bitmap: everywhere is "inside" → values above 128.
2497        let alpha = vec![255u8; 4];
2498        let sdf = binary_to_sdf(&alpha, 2, 2, 2);
2499        let w = 2 + 2 * 2;
2500        // Interior of the original bitmap region should be solidly inside.
2501        let interior = sdf[(2) * w + (2)]; // top-left of the original region
2502        assert!(interior > 128, "SDF interior {interior} should be >128");
2503    }
2504
2505    #[test]
2506    fn sdf_adds_padding_to_dimensions() {
2507        let alpha = vec![255u8; 6]; // 3x2
2508        let sdf = binary_to_sdf(&alpha, 3, 2, 3);
2509        assert_eq!(sdf.len(), (3 + 6) * (2 + 6)); // 9 * 8 = 72
2510    }
2511
2512    #[test]
2513    fn sdf_zero_size_returns_empty() {
2514        assert!(binary_to_sdf(&[], 0, 0, 3).is_empty());
2515    }
2516
2517    #[test]
2518    fn glyph_atlas_sdf_entries_include_padding() {
2519        // When the atlas loads glyphs with SDF, each entry should be wider
2520        // than the raw procedural bitmap (8 + 2*3 = 14 wide).
2521        let mut atlas = GlyphAtlas::new();
2522        atlas.request_text("Test Sans", "A");
2523        atlas.load_requested(&ProceduralGlyphProvider::new());
2524        let entry = atlas.get("Test Sans", 'A').expect("glyph A");
2525        // Procedural glyphs are 8x12; with SDF_BUFFER=3 padding → 14x18.
2526        assert_eq!(entry.size[0], 8 + 2 * SDF_BUFFER);
2527        assert_eq!(entry.size[1], 12 + 2 * SDF_BUFFER);
2528    }
2529
2530    #[test]
2531    fn glyph_atlas_sdf_alpha_contains_gradient_values() {
2532        // SDF atlas alpha should contain intermediate values (not just 0/255).
2533        let mut atlas = GlyphAtlas::new();
2534        atlas.request_text("Test Sans", "X");
2535        atlas.load_requested(&ProceduralGlyphProvider::new());
2536        let has_intermediate = atlas.alpha().iter().any(|&v| v > 10 && v < 245);
2537        assert!(has_intermediate, "SDF atlas should contain gradient values between 0 and 255");
2538    }
2539
2540    #[test]
2541    fn glyph_atlas_render_em_px_from_procedural() {
2542        let mut atlas = GlyphAtlas::new();
2543        atlas.request_text("Test Sans", "A");
2544        atlas.load_requested(&ProceduralGlyphProvider::new());
2545        assert_eq!(atlas.render_em_px(), 12.0);
2546    }
2547
2548    #[test]
2549    fn edt_1d_seeds_remain_zero() {
2550        let mut f = vec![0.0, 1e10, 1e10, 0.0, 1e10];
2551        edt_1d(&mut f);
2552        assert_eq!(f[0], 0.0);
2553        assert_eq!(f[3], 0.0);
2554        assert!(f[1] > 0.0);
2555        assert!(f[2] > 0.0);
2556    }
2557
2558    #[test]
2559    fn edt_1d_distances_increase_from_seed() {
2560        let mut f = vec![0.0, 1e10, 1e10, 1e10, 1e10];
2561        edt_1d(&mut f);
2562        // Squared distances: 0, 1, 4, 9, 16
2563        assert!((f[0] - 0.0).abs() < 0.01);
2564        assert!((f[1] - 1.0).abs() < 0.01);
2565        assert!((f[2] - 4.0).abs() < 0.01);
2566        assert!((f[3] - 9.0).abs() < 0.01);
2567        assert!((f[4] - 16.0).abs() < 0.01);
2568    }
2569}