martin_core/resources/sprites/
mod.rs

1//! Sprite processing and serving for map tile rendering.
2//!
3//! Generates spritesheets from SVG files with support for high-DPI (@2x) and
4//! SDF (Signed Distance Field) sprites for dynamic styling.
5//!
6//! # Usage
7//!
8//! ```rust,no_run
9//! # async fn foo() {
10//! use martin_core::sprites::SpriteSources;
11//! use std::path::PathBuf;
12//!
13//! let mut sources = SpriteSources::default();
14//! sources.add_source("icons".to_string(), PathBuf::from("/path/to/svg/directory"));
15//! let spritesheet = sources.get_sprites("icons@2x", false).await.unwrap();
16//! # }
17//! ```
18
19use std::collections::HashMap;
20use std::fmt::Debug;
21use std::path::PathBuf;
22
23use dashmap::{DashMap, Entry};
24use futures::future::try_join_all;
25use log::{info, warn};
26use serde::{Deserialize, Serialize};
27pub use spreet::Spritesheet;
28use spreet::resvg::usvg::{Options, Tree};
29use spreet::{Sprite, SpritesheetBuilder, get_svg_input_paths, sprite_name};
30use tokio::io::AsyncReadExt;
31
32use self::SpriteError::{SpriteInstError, SpriteParsingError, SpriteProcessingError};
33
34mod error;
35pub use error::SpriteError;
36
37mod cache;
38pub use cache::{NO_SPRITE_CACHE, OptSpriteCache, SpriteCache};
39
40/// Sprite source metadata.
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
42pub struct CatalogSpriteEntry {
43    /// Available sprite image names.
44    pub images: Vec<String>,
45}
46
47/// Catalog mapping sprite names to metadata (e.g., "icons" -> [`CatalogSpriteEntry`]).
48pub type SpriteCatalog = HashMap<String, CatalogSpriteEntry>;
49
50/// Thread-safe sprite source manager for serving sprites as `.png` or `.json`.
51#[derive(Debug, Clone, Default)]
52pub struct SpriteSources(DashMap<String, SpriteSource>);
53
54impl SpriteSources {
55    /// Returns a catalog of all sprite sources.
56    pub fn get_catalog(&self) -> Result<SpriteCatalog, SpriteError> {
57        // TODO: all sprite generation should be pre-cached
58        let mut entries = SpriteCatalog::new();
59        for source in &self.0 {
60            let paths = get_svg_input_paths(&source.path, true)
61                .map_err(|e| SpriteProcessingError(e, source.path.clone()))?;
62            let mut images = Vec::with_capacity(paths.len());
63            for path in paths {
64                images.push(
65                    sprite_name(&path, &source.path)
66                        .map_err(|e| SpriteProcessingError(e, source.path.clone()))?,
67                );
68            }
69            images.sort();
70            entries.insert(source.key().clone(), CatalogSpriteEntry { images });
71        }
72        Ok(entries)
73    }
74
75    /// Adds a sprite source directory containing SVG files.
76    /// Files are ignored - only directories accepted. Duplicates ignored with warning.
77    pub fn add_source(&mut self, id: String, path: PathBuf) {
78        let disp_path = path.display();
79        if path.is_file() {
80            warn!("Ignoring non-directory sprite source {id} from {disp_path}");
81        } else {
82            match self.0.entry(id) {
83                Entry::Occupied(v) => {
84                    warn!(
85                        "Ignoring duplicate sprite source {} from {disp_path} because it was already configured for {}",
86                        v.key(),
87                        v.get().path.display()
88                    );
89                }
90                Entry::Vacant(v) => {
91                    info!("Configured sprite source {} from {disp_path}", v.key());
92                    v.insert(SpriteSource { path });
93                }
94            }
95        }
96    }
97
98    /// Generates a spritesheet from comma-separated sprite source IDs.
99    ///
100    /// Append "@2x" for high-DPI sprites.
101    /// Set `as_sdf` for SDF sprites.
102    pub async fn get_sprites(&self, ids: &str, as_sdf: bool) -> Result<Spritesheet, SpriteError> {
103        let (ids, dpi) = if let Some(ids) = ids.strip_suffix("@2x") {
104            (ids, 2)
105        } else {
106            (ids, 1)
107        };
108
109        let sprite_ids = ids
110            .split(',')
111            .map(|id| self.get(id))
112            .collect::<Result<Vec<_>, SpriteError>>()?;
113
114        get_spritesheet(sprite_ids.iter(), dpi, as_sdf).await
115    }
116
117    fn get(&self, id: &str) -> Result<SpriteSource, SpriteError> {
118        match self.0.get(id) {
119            Some(v) => Ok(v.clone()),
120            None => Err(SpriteError::SpriteNotFound(id.to_string())),
121        }
122    }
123}
124
125/// Sprite source directory.
126#[derive(Clone, Debug)]
127pub struct SpriteSource {
128    path: PathBuf,
129}
130
131/// Parses SVG file into sprite.
132async fn parse_sprite(
133    name: String,
134    path: PathBuf,
135    pixel_ratio: u8,
136    as_sdf: bool,
137) -> Result<(String, Sprite), SpriteError> {
138    let on_err = |e| SpriteError::IoError(e, path.clone());
139
140    let mut file = tokio::fs::File::open(&path).await.map_err(on_err)?;
141
142    let mut buffer = Vec::new();
143    file.read_to_end(&mut buffer).await.map_err(on_err)?;
144
145    let tree = Tree::from_data(&buffer, &Options::default())
146        .map_err(|e| SpriteParsingError(e, path.clone()))?;
147
148    let sprite = if as_sdf {
149        Sprite::new_sdf(tree, pixel_ratio)
150    } else {
151        Sprite::new(tree, pixel_ratio)
152    };
153    let sprite = sprite.ok_or_else(|| SpriteInstError(path.clone()))?;
154
155    Ok((name, sprite))
156}
157
158/// Generates spritesheet from sprite sources.
159pub async fn get_spritesheet(
160    sources: impl Iterator<Item = &SpriteSource>,
161    pixel_ratio: u8,
162    as_sdf: bool,
163) -> Result<Spritesheet, SpriteError> {
164    // Asynchronously load all SVG files from the given sources
165    let mut futures = Vec::new();
166    for source in sources {
167        let paths = get_svg_input_paths(&source.path, true)
168            .map_err(|e| SpriteProcessingError(e, source.path.clone()))?;
169        for path in paths {
170            let name = sprite_name(&path, &source.path)
171                .map_err(|e| SpriteProcessingError(e, source.path.clone()))?;
172            futures.push(parse_sprite(name, path, pixel_ratio, as_sdf));
173        }
174    }
175    let sprites = try_join_all(futures).await?;
176    let mut builder = SpritesheetBuilder::new();
177    if as_sdf {
178        builder.make_sdf();
179    }
180    builder.sprites(sprites.into_iter().collect());
181
182    // TODO: decide if this is needed and/or configurable
183    // builder.make_unique();
184
185    builder
186        .generate()
187        .ok_or(SpriteError::UnableToGenerateSpritesheet)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[tokio::test]
195    async fn test_sprites() {
196        let mut sprites = SpriteSources::default();
197        sprites.add_source(
198            "src1".to_string(),
199            PathBuf::from("../tests/fixtures/sprites/src1"),
200        );
201        sprites.add_source(
202            "src2".to_string(),
203            PathBuf::from("../tests/fixtures/sprites/src2"),
204        );
205
206        assert_eq!(sprites.0.len(), 2);
207
208        for generate_sdf in [true, false] {
209            let paths = sprites
210                .0
211                .iter()
212                .map(|v| v.value().clone())
213                .collect::<Vec<_>>();
214            test_src(paths.iter(), 1, "all_1", generate_sdf).await;
215            test_src(paths.iter(), 2, "all_2", generate_sdf).await;
216
217            let src1_path = sprites.get("src1").into_iter().collect::<Vec<_>>();
218            test_src(src1_path.iter(), 1, "src1_1", generate_sdf).await;
219            test_src(src1_path.iter(), 2, "src1_2", generate_sdf).await;
220
221            let src2_path = sprites.get("src2").into_iter().collect::<Vec<_>>();
222            test_src(src2_path.iter(), 1, "src2_1", generate_sdf).await;
223            test_src(src2_path.iter(), 2, "src2_2", generate_sdf).await;
224        }
225    }
226
227    async fn test_src(
228        sources: impl Iterator<Item = &SpriteSource>,
229        pixel_ratio: u8,
230        filename: &str,
231        generate_sdf: bool,
232    ) {
233        let sprites = get_spritesheet(sources, pixel_ratio, generate_sdf)
234            .await
235            .unwrap();
236        let filename = if generate_sdf {
237            format!("{filename}_sdf")
238        } else {
239            filename.to_string()
240        };
241        insta::assert_json_snapshot!(format!("{filename}.json"), sprites.get_index());
242        let png = sprites.encode_png().unwrap();
243        insta::assert_binary_snapshot!(&format!("{filename}.png"), png);
244    }
245}