1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use serde::Deserialize;
15
16pub const SIDECAR_EXT: &str = "atlas.json";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct AtlasFrame {
22 pub x: u32,
23 pub y: u32,
24 pub w: u32,
25 pub h: u32,
26}
27
28impl AtlasFrame {
29 pub fn uv(&self, sheet: (u32, u32)) -> [f32; 4] {
32 let (sw, sh) = (sheet.0 as f32, sheet.1 as f32);
33 [
34 self.x as f32 / sw,
35 self.y as f32 / sh,
36 (self.x + self.w) as f32 / sw,
37 (self.y + self.h) as f32 / sh,
38 ]
39 }
40
41 fn fits(&self, sheet: (u32, u32)) -> bool {
42 self.w > 0
43 && self.h > 0
44 && self.x.saturating_add(self.w) <= sheet.0
45 && self.y.saturating_add(self.h) <= sheet.1
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Atlas {
52 pub texture: String,
53 pub size: (u32, u32),
54 pub frames: BTreeMap<String, AtlasFrame>,
55}
56
57impl Atlas {
58 pub fn frame(&self, name: &str) -> Result<AtlasFrame> {
60 self.frames.get(name).copied().ok_or_else(|| {
61 anyhow::anyhow!(
62 "atlas {:?} has no frame {name:?} (available: {})",
63 self.texture,
64 if self.frames.is_empty() {
65 "none".to_string()
66 } else {
67 self.names().join(", ")
68 }
69 )
70 })
71 }
72
73 pub fn names(&self) -> Vec<&str> {
74 self.frames.keys().map(String::as_str).collect()
75 }
76}
77
78#[derive(Debug, Deserialize)]
79struct RawAtlas {
80 texture: String,
81 size: [u32; 2],
82 frames: BTreeMap<String, RawFrame>,
83}
84
85#[derive(Debug, Deserialize)]
86struct RawFrame {
87 x: u32,
88 y: u32,
89 w: u32,
90 h: u32,
91}
92
93impl From<RawFrame> for AtlasFrame {
94 fn from(raw: RawFrame) -> Self {
95 AtlasFrame {
96 x: raw.x,
97 y: raw.y,
98 w: raw.w,
99 h: raw.h,
100 }
101 }
102}
103
104pub fn parse_atlas(text: &str) -> Result<Atlas> {
107 let raw: RawAtlas =
108 serde_json::from_str(text).context("atlas is not valid JSON (texture/size/frames)")?;
109 if raw.texture.trim().is_empty() {
110 anyhow::bail!("atlas has an empty `texture`");
111 }
112 if raw.size[0] == 0 || raw.size[1] == 0 {
113 anyhow::bail!("atlas size {:?} must be positive", raw.size);
114 }
115 if raw.frames.is_empty() {
116 anyhow::bail!("atlas {:?} declares no frames", raw.texture);
117 }
118 let sheet = (raw.size[0], raw.size[1]);
119 let mut frames = BTreeMap::new();
120 for (name, frame) in raw.frames {
121 let frame = AtlasFrame::from(frame);
122 if !frame.fits(sheet) {
123 anyhow::bail!(
124 "atlas frame {name:?} ({},{} {}x{}) does not fit the {sheet:?} sheet",
125 frame.x,
126 frame.y,
127 frame.w,
128 frame.h
129 );
130 }
131 frames.insert(name, frame);
132 }
133 Ok(Atlas {
134 texture: raw.texture,
135 size: sheet,
136 frames,
137 })
138}
139
140pub fn load_atlas(path: &Path) -> Result<Atlas> {
141 let text = std::fs::read_to_string(path)
142 .with_context(|| format!("cannot read atlas {}", path.display()))?;
143 parse_atlas(&text).with_context(|| format!("cannot parse atlas {}", path.display()))
144}
145
146pub fn sidecar_for(sheet: &Path) -> PathBuf {
148 let stem = sheet
149 .file_stem()
150 .and_then(|s| s.to_str())
151 .filter(|s| !s.is_empty())
152 .unwrap_or("sheet");
153 sheet
154 .parent()
155 .unwrap_or_else(|| Path::new(""))
156 .join(format!("{stem}.{SIDECAR_EXT}"))
157}
158
159pub fn sheet_for_sidecar(sidecar: &Path) -> PathBuf {
161 let name = sidecar
162 .file_name()
163 .and_then(|s| s.to_str())
164 .unwrap_or_default();
165 let stem = name
166 .strip_suffix(&format!(".{SIDECAR_EXT}"))
167 .unwrap_or(name);
168 sidecar
169 .parent()
170 .unwrap_or_else(|| Path::new(""))
171 .join(format!("{stem}.png"))
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 const SHEET: &str = r#"{
179 "texture": "sheet.png",
180 "size": [64, 32],
181 "frames": {
182 "player": { "x": 0, "y": 0, "w": 32, "h": 32 },
183 "enemy": { "x": 32, "y": 0, "w": 32, "h": 32 },
184 "coin": { "x": 0, "y": 16, "w": 16, "h": 16 }
185 }
186 }"#;
187
188 #[test]
189 fn parses_named_frames() {
190 let atlas = parse_atlas(SHEET).unwrap();
191 assert_eq!(atlas.texture, "sheet.png");
192 assert_eq!(atlas.size, (64, 32));
193 assert_eq!(atlas.frames.len(), 3);
194 assert_eq!(
195 atlas.frame("player").unwrap(),
196 AtlasFrame {
197 x: 0,
198 y: 0,
199 w: 32,
200 h: 32
201 }
202 );
203 assert_eq!(
204 atlas.frame("enemy").unwrap(),
205 AtlasFrame {
206 x: 32,
207 y: 0,
208 w: 32,
209 h: 32
210 }
211 );
212 assert_eq!(atlas.names(), vec!["coin", "enemy", "player"]);
213 }
214
215 #[test]
216 fn missing_frame_names_the_alternatives() {
217 let atlas = parse_atlas(SHEET).unwrap();
218 let err = atlas.frame("ghost").unwrap_err().to_string();
219 assert!(err.contains("no frame \"ghost\""), "got: {err}");
220 assert!(err.contains("player"), "got: {err}");
221 }
222
223 #[test]
224 fn uv_math_maps_pixels_to_normalized_rects() {
225 let atlas = parse_atlas(SHEET).unwrap();
226 let player = atlas.frame("player").unwrap().uv(atlas.size);
227 assert_eq!(player, [0.0, 0.0, 0.5, 1.0]);
228 let enemy = atlas.frame("enemy").unwrap().uv(atlas.size);
229 assert_eq!(enemy, [0.5, 0.0, 1.0, 1.0]);
230 let coin = atlas.frame("coin").unwrap().uv(atlas.size);
231 assert_eq!(coin, [0.0, 0.5, 0.25, 1.0]);
232 }
233
234 #[test]
235 fn bad_atlases_fail_loudly() {
236 assert!(parse_atlas("not json").is_err());
237 assert!(parse_atlas(r#"{"texture":"","size":[1,1],"frames":{}}"#).is_err());
238 assert!(parse_atlas(r#"{"texture":"a.png","size":[0,8],"frames":{}}"#).is_err());
239 assert!(parse_atlas(r#"{"texture":"a.png","size":[8,8],"frames":{}}"#).is_err());
240 let out_of_bounds = r#"{"texture":"a.png","size":[8,8],
241 "frames":{"big":{"x":4,"y":4,"w":8,"h":8}}}"#;
242 let err = parse_atlas(out_of_bounds).unwrap_err().to_string();
243 assert!(err.contains("does not fit"), "got: {err}");
244 }
245
246 #[test]
247 fn sidecar_and_sheet_paths_round_trip() {
248 let sheet = Path::new("/game/assets/sheet.png");
249 let sidecar = sidecar_for(sheet);
250 assert_eq!(sidecar, PathBuf::from("/game/assets/sheet.atlas.json"));
251 assert_eq!(sheet_for_sidecar(&sidecar), sheet);
252 }
253}