Skip to main content

pite_assets/
atlas.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Sprite-source rules (`texture` vs `atlas` + `frame`) and project-wide
3//! atlas reference checks for `pite check`.
4//!
5//! Frame tables are parsed by `pite-render` (it owns UVs and batching); this
6//! crate reuses that parser so a file can never be valid for the renderer and
7//! invalid for the checker.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use anyhow::{bail, Context, Result};
13use pite_render::atlas::{self, Atlas};
14
15/// Where a `Sprite2D` gets its pixels.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SpriteSource {
18    /// No texture at all: the node draws nothing (today's behavior).
19    Empty,
20    Texture(String),
21    Atlas {
22        atlas: String,
23        frame: String,
24    },
25}
26
27/// `texture` and `atlas` are mutually exclusive, and `frame` is meaningless
28/// without an atlas. Every violation is an error, never a silent preference.
29pub fn resolve_sprite_source(
30    texture: Option<&str>,
31    atlas_ref: Option<&str>,
32    frame: Option<&str>,
33) -> Result<SpriteSource> {
34    match (texture, atlas_ref, frame) {
35        (Some(_), Some(_), _) => bail!("`texture` and `atlas` are mutually exclusive"),
36        (None, Some(_), None) => bail!("`atlas` requires a `frame`"),
37        (Some(_), None, Some(_)) => bail!("`frame` requires an `atlas`"),
38        (None, None, Some(_)) => bail!("`frame` requires an `atlas`"),
39        (Some(texture), None, None) => Ok(SpriteSource::Texture(texture.to_string())),
40        (None, Some(atlas_ref), Some(frame)) => Ok(SpriteSource::Atlas {
41            atlas: atlas_ref.to_string(),
42            frame: frame.to_string(),
43        }),
44        (None, None, None) => Ok(SpriteSource::Empty),
45    }
46}
47
48/// One scene node drawing a named frame.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AtlasUse {
51    pub scene: String,
52    pub node: String,
53    pub atlas: String,
54    pub frame: String,
55}
56
57fn atlas_path(root: &Path, atlas_ref: &str) -> PathBuf {
58    match atlas_ref.strip_prefix("res://") {
59        Some(rel) => root.join(rel),
60        None => root.join(atlas_ref),
61    }
62}
63
64/// Check every atlas reference in the project: unknown frames are errors
65/// (naming the alternatives), frames nothing points at are warnings.
66/// Output is sorted so repeated runs report identically.
67pub fn atlas_report(root: &Path, uses: &[AtlasUse]) -> (Vec<String>, Vec<String>) {
68    let mut errors = Vec::new();
69    let mut warnings = Vec::new();
70    let mut by_atlas: BTreeMap<&str, Vec<&AtlasUse>> = BTreeMap::new();
71    for use_ in uses {
72        by_atlas.entry(use_.atlas.as_str()).or_default().push(use_);
73    }
74    for (atlas_ref, uses) in by_atlas {
75        let path = atlas_path(root, atlas_ref);
76        let loaded = atlas::load_atlas(&path);
77        let mut referenced: Vec<&str> = Vec::new();
78        for use_ in &uses {
79            match &loaded {
80                Ok(atlas) => match atlas.frame(&use_.frame) {
81                    Ok(_) => referenced.push(use_.frame.as_str()),
82                    Err(e) => {
83                        errors.push(format!("scene {}: node {:?} {e}", use_.scene, use_.node))
84                    }
85                },
86                Err(e) => errors.push(format!("scene {}: node {:?} {e:#}", use_.scene, use_.node)),
87            }
88        }
89        if let Ok(atlas) = &loaded {
90            for name in atlas.names() {
91                if !referenced.contains(&name) {
92                    warnings.push(format!(
93                        "atlas {atlas_ref:?}: frame {name:?} is never referenced"
94                    ));
95                }
96            }
97        }
98    }
99    errors.sort();
100    errors.dedup();
101    warnings.sort();
102    warnings.dedup();
103    (errors, warnings)
104}
105
106/// The sheet an atlas sidecar describes, for callers that resolve paths.
107pub fn sheet_path(root: &Path, atlas_ref: &str) -> PathBuf {
108    atlas::sheet_for_sidecar(&atlas_path(root, atlas_ref))
109}
110
111/// Load and validate one atlas sidecar, naming the file on failure.
112pub fn load_sidecar(root: &Path, atlas_ref: &str) -> Result<Atlas> {
113    let path = atlas_path(root, atlas_ref);
114    let text = std::fs::read_to_string(&path)
115        .with_context(|| format!("cannot read atlas {}", path.display()))?;
116    atlas::parse_atlas(&text).with_context(|| format!("cannot parse atlas {}", path.display()))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::sync::atomic::{AtomicU64, Ordering};
123
124    static COUNTER: AtomicU64 = AtomicU64::new(0);
125
126    fn project(tag: &str) -> PathBuf {
127        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
128        let dir = std::env::temp_dir().join(format!("pite-atlas-{tag}-{}-{n}", std::process::id()));
129        let _ = std::fs::remove_dir_all(&dir);
130        std::fs::create_dir_all(dir.join("assets")).unwrap();
131        std::fs::write(
132            dir.join("assets").join("sheet.atlas.json"),
133            r#"{"texture":"sheet.png","size":[64,32],
134                "frames":{"player":{"x":0,"y":0,"w":32,"h":32},
135                          "enemy":{"x":32,"y":0,"w":32,"h":32}}}"#,
136        )
137        .unwrap();
138        dir
139    }
140
141    #[test]
142    fn texture_and_atlas_are_mutually_exclusive() {
143        let err = resolve_sprite_source(
144            Some("res://a.png"),
145            Some("res://sheet.atlas.json"),
146            Some("player"),
147        )
148        .unwrap_err()
149        .to_string();
150        assert!(err.contains("mutually exclusive"), "got: {err}");
151    }
152
153    #[test]
154    fn frame_requires_an_atlas_and_atlas_requires_a_frame() {
155        let err = resolve_sprite_source(None, Some("res://sheet.atlas.json"), None)
156            .unwrap_err()
157            .to_string();
158        assert!(err.contains("requires a `frame`"), "got: {err}");
159        let err = resolve_sprite_source(None, None, Some("player"))
160            .unwrap_err()
161            .to_string();
162        assert!(err.contains("requires an `atlas`"), "got: {err}");
163    }
164
165    #[test]
166    fn plain_texture_still_resolves() {
167        assert_eq!(
168            resolve_sprite_source(Some("res://a.png"), None, None).unwrap(),
169            SpriteSource::Texture("res://a.png".to_string())
170        );
171        assert_eq!(
172            resolve_sprite_source(None, None, None).unwrap(),
173            SpriteSource::Empty
174        );
175        assert_eq!(
176            resolve_sprite_source(None, Some("res://sheet.atlas.json"), Some("player")).unwrap(),
177            SpriteSource::Atlas {
178                atlas: "res://sheet.atlas.json".to_string(),
179                frame: "player".to_string()
180            }
181        );
182    }
183
184    #[test]
185    fn report_flags_unknown_frames_and_unreferenced_ones() {
186        let dir = project("report");
187        let uses = vec![
188            AtlasUse {
189                scene: "res://scenes/main.ptscn".to_string(),
190                node: "player".to_string(),
191                atlas: "res://assets/sheet.atlas.json".to_string(),
192                frame: "player".to_string(),
193            },
194            AtlasUse {
195                scene: "res://scenes/main.ptscn".to_string(),
196                node: "ghost".to_string(),
197                atlas: "res://assets/sheet.atlas.json".to_string(),
198                frame: "ghost".to_string(),
199            },
200        ];
201        let (errors, warnings) = atlas_report(&dir, &uses);
202        assert_eq!(errors.len(), 1, "got: {errors:?}");
203        assert!(errors[0].contains("no frame \"ghost\""), "got: {errors:?}");
204        assert!(
205            errors[0].contains("player"),
206            "should list alternatives: {errors:?}"
207        );
208        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
209        assert!(warnings[0].contains("\"enemy\""), "got: {warnings:?}");
210        std::fs::remove_dir_all(&dir).ok();
211    }
212
213    #[test]
214    fn missing_sidecar_is_an_error_not_a_skip() {
215        let dir = project("missing");
216        let uses = vec![AtlasUse {
217            scene: "res://scenes/main.ptscn".to_string(),
218            node: "player".to_string(),
219            atlas: "res://assets/nope.atlas.json".to_string(),
220            frame: "player".to_string(),
221        }];
222        let (errors, warnings) = atlas_report(&dir, &uses);
223        assert_eq!(errors.len(), 1, "got: {errors:?}");
224        assert!(errors[0].contains("cannot read atlas"), "got: {errors:?}");
225        assert!(warnings.is_empty(), "got: {warnings:?}");
226        std::fs::remove_dir_all(&dir).ok();
227    }
228
229    #[test]
230    fn sheet_path_follows_the_sidecar() {
231        let dir = project("sheet");
232        assert_eq!(
233            sheet_path(&dir, "res://assets/sheet.atlas.json"),
234            dir.join("assets").join("sheet.png")
235        );
236        std::fs::remove_dir_all(&dir).ok();
237    }
238}