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