1use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use anyhow::{bail, Context, Result};
13use pite_render::atlas::{self, Atlas};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SpriteSource {
18 Empty,
20 Texture(String),
21 Atlas {
22 atlas: String,
23 frame: String,
24 },
25}
26
27pub 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#[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
64pub 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
106pub fn sheet_path(root: &Path, atlas_ref: &str) -> PathBuf {
108 atlas::sheet_for_sidecar(&atlas_path(root, atlas_ref))
109}
110
111pub 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}