1use yog_sync::LazyLock;
23
24use yog_graphics::{Rect, Vec2};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ImageSource {
29 Item(String),
31 Block(String),
33 Sprite(String),
36 ModPath(String),
39 Texture(String),
41}
42
43const LEGACY_TEX_SIZE: f32 = 256.0;
45
46pub fn texture_id(ns: &str, path: &str) -> String {
51 use yog_ids::res::{PNG, SVG, TEXTURES};
52 let path = path.trim_start_matches(yog_ids::sep::PATH);
53 if path.ends_with(SVG) {
54 let path = path.strip_prefix(TEXTURES).unwrap_or(path);
55 return yog_ids::join(ns, &format!("{TEXTURES}{path}"));
56 }
57 if path.starts_with(TEXTURES) || path.ends_with(PNG) {
58 let png = if path.ends_with(PNG) { "" } else { PNG };
59 yog_ids::join(ns, &format!("{path}{png}"))
60 } else {
61 yog_ids::join(ns, &format!("{TEXTURES}{path}{PNG}"))
62 }
63}
64
65pub fn short_source(texture: &str) -> String {
68 let (ns, path) = yog_ids::split(texture.trim());
69 let path = path.strip_prefix(yog_ids::res::TEXTURES).unwrap_or(path);
70 yog_ids::join(ns, path.strip_suffix(yog_ids::res::PNG).unwrap_or(path))
71}
72
73pub use yog_ids::image_scheme as scheme;
75
76impl std::fmt::Display for ImageSource {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 ImageSource::Item(id) => write!(f, "{}:{id}", scheme::ITEM),
82 ImageSource::Block(id) => write!(f, "{}:{id}", scheme::BLOCK),
83 ImageSource::Sprite(name) => write!(f, "{}:{name}", scheme::SPRITE),
84 ImageSource::ModPath(path) => write!(f, "{}:{path}", scheme::MOD),
85 ImageSource::Texture(id) => f.write_str(id),
86 }
87 }
88}
89
90impl ImageSource {
91 pub fn parse(source: &str) -> Option<ImageSource> {
93 let s = source.trim();
94 let (scheme, rest) = s.split_once(yog_ids::sep::NAMESPACE)?;
95 if rest.is_empty() {
96 return None;
97 }
98 Some(match scheme {
99 scheme::ITEM => ImageSource::Item(yog_ids::qualify(rest.trim())),
100 scheme::BLOCK => ImageSource::Block(yog_ids::qualify(rest.trim())),
101 scheme::SPRITE => ImageSource::Sprite(rest.to_string()),
102 scheme::MOD => ImageSource::ModPath(rest.to_string()),
103 ns if !ns.is_empty() => ImageSource::Texture(texture_id(ns, rest)),
104 _ => return None,
105 })
106 }
107
108 pub fn with_mod_id(self, mod_id: &str) -> ImageSource {
110 match self {
111 ImageSource::ModPath(path) => ImageSource::Texture(texture_id(mod_id, &path)),
112 other => other,
113 }
114 }
115}
116
117pub fn suggest_alias(id: &str) -> Option<String> {
122 let (ns, path) = id.trim().split_once(yog_ids::sep::NAMESPACE)?;
123 let path = path.strip_prefix(yog_ids::res::TEXTURES)?;
124 let stem = path.strip_suffix(yog_ids::res::PNG).unwrap_or(path);
125 let short = |id: &str| -> String {
127 if ns == yog_ids::ns::MINECRAFT {
128 id.to_string()
129 } else {
130 yog_ids::join(ns, id)
131 }
132 };
133 if let Some(item) = stem.strip_prefix(yog_ids::res::ITEM_DIR) {
134 return Some(ImageSource::Item(short(item)).to_string());
135 }
136 if let Some(block) = stem.strip_prefix(yog_ids::res::BLOCK_DIR) {
137 return Some(ImageSource::Block(short(block)).to_string());
138 }
139 if ns == yog_ids::ns::MINECRAFT
140 && let Some(atlas_id) = stem.strip_prefix(yog_ids::res::GUI_SPRITES)
141 && let Some(sprite) = sprite_by_atlas(atlas_id)
142 {
143 return Some(ImageSource::Sprite(sprite.name.to_string()).to_string());
144 }
145 Some(yog_ids::join(ns, stem))
146}
147
148#[derive(Debug, Clone, PartialEq)]
150pub struct LegacyRegion {
151 pub texture: String,
153 pub region: Rect,
155 pub texture_size: Vec2,
157}
158
159#[derive(Debug, Clone, PartialEq)]
161pub struct Sprite {
162 pub name: String,
164 pub description: String,
166 pub size: Vec2,
168 pub atlas: Option<String>,
171 pub legacy: Option<LegacyRegion>,
173 pub own: Option<LegacyRegion>,
175 pub border: f32,
177}
178
179impl Sprite {
180 pub fn size(&self, atlas: bool) -> Vec2 {
182 match (atlas, &self.legacy) {
183 (false, Some(l)) => Vec2::new(l.region.w, l.region.h),
184 _ => self.size,
185 }
186 }
187
188 pub fn is_procedural(&self) -> bool {
190 self.atlas.is_none() && self.legacy.is_none() && self.own.is_none()
191 }
192
193 pub fn sources(&self) -> Vec<SpriteSource<'_>> {
197 let mut out = Vec::new();
198 if let Some(a) = &self.atlas {
199 out.push(SpriteSource::Atlas(yog_ids::qualify(a)));
200 }
201 out.extend(
202 self.legacy
203 .iter()
204 .chain(&self.own)
205 .map(SpriteSource::Region),
206 );
207 out
208 }
209}
210
211#[derive(Debug, Clone, PartialEq)]
213pub enum SpriteSource<'a> {
214 Atlas(String),
216 Region(&'a LegacyRegion),
218}
219
220const OWN: &[(&str, &[u8])] = &[
223 (
224 "textures/gui/panel.png",
225 include_bytes!("../res/assets/yog/textures/gui/panel.png"),
226 ),
227 (
228 "models/item/spawn_egg.json",
229 include_bytes!("../res/assets/yog/models/item/spawn_egg.json"),
230 ),
231 (
232 "textures/item/spawn_egg.png",
233 include_bytes!("../res/assets/yog/textures/item/spawn_egg.png"),
234 ),
235 (
236 "textures/item/spawn_egg_overlay.png",
237 include_bytes!("../res/assets/yog/textures/item/spawn_egg_overlay.png"),
238 ),
239];
240
241pub fn own_asset(path: &str) -> Option<&'static [u8]> {
243 OWN.iter().find(|(p, _)| *p == path).map(|(_, b)| *b)
244}
245
246pub fn sprite(name: &str) -> Option<&'static Sprite> {
248 SPRITES.iter().find(|s| s.name == name.trim())
249}
250
251pub fn sprite_by_atlas(atlas: &str) -> Option<&'static Sprite> {
253 SPRITES.iter().find(|s| s.atlas.as_deref() == Some(atlas))
254}
255
256pub fn atlas_texture(ns: &str, atlas: &str) -> String {
259 yog_ids::join(
260 ns,
261 &yog_ids::texture_file(&format!("{}{atlas}", yog_ids::res::GUI_SPRITES)),
262 )
263}
264
265#[derive(yog_data::Schema)]
267struct SpriteDef {
268 name: String,
269 description: String,
270 size: [f32; 2],
271 atlas: Option<String>,
272 legacy: Option<LegacyDef>,
273 own: Option<LegacyDef>,
274 #[data(default)]
275 border: f32,
276}
277
278#[derive(yog_data::Schema)]
279struct LegacyDef {
280 texture: String,
281 region: [f32; 4],
282 texture_size: Option<f32>,
284}
285
286fn region(l: LegacyDef) -> LegacyRegion {
287 let [u, v, w, h] = l.region;
288 let side = l.texture_size.unwrap_or(LEGACY_TEX_SIZE);
289 LegacyRegion {
290 texture: l.texture,
291 region: Rect::new(u, v, w, h),
292 texture_size: Vec2::new(side, side),
293 }
294}
295
296pub static SPRITES: LazyLock<Vec<Sprite>> = LazyLock::new(|| {
298 yog_data::built_in::<Vec<SpriteDef>>(
299 "yog-gfx's sprites",
300 include_str!("../assets/sprites.ydata"),
301 )
302 .into_iter()
303 .map(|d| Sprite {
304 name: d.name,
305 description: d.description,
306 size: Vec2::new(d.size[0], d.size[1]),
307 atlas: d.atlas,
308 legacy: d.legacy.map(region),
309 own: d.own.map(region),
310 border: d.border,
311 })
312 .collect()
313});
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
317#[repr(u8)]
318pub enum Stretch {
319 None = 0,
321 Fill = 1,
323 #[default]
325 Uniform = 2,
326 UniformToFill = 3,
328}
329
330impl Stretch {
331 pub const ALL: [(Stretch, &'static str); 4] = [
333 (Stretch::None, "None"),
334 (Stretch::Fill, "Fill"),
335 (Stretch::Uniform, "Uniform"),
336 (Stretch::UniformToFill, "UniformToFill"),
337 ];
338
339 pub fn name(self) -> &'static str {
341 Self::ALL
342 .iter()
343 .find(|(s, _)| *s == self)
344 .map_or("", |(_, n)| n)
345 }
346
347 pub fn parse(name: &str) -> Option<Stretch> {
349 let wanted = name.trim().replace('_', "");
350 Self::ALL
351 .iter()
352 .find(|(_, n)| n.eq_ignore_ascii_case(&wanted))
353 .map(|(s, _)| *s)
354 }
355}
356
357pub fn nine_slice(dest: [f32; 4], region: [f32; 4], border: f32) -> Vec<([f32; 4], [f32; 4])> {
362 let [x, y, w, h] = dest;
363 let [u, v, rw, rh] = region;
364 let b = border.min(rw / 2.0).min(rh / 2.0).min(w / 2.0).min(h / 2.0);
365 if b <= 0.0 || (w == rw && h == rh) {
366 return vec![(dest, region)];
367 }
368 let cols = [
370 (x, b, u, b),
371 (x + b, w - 2.0 * b, u + b, rw - 2.0 * b),
372 (x + w - b, b, u + rw - b, b),
373 ];
374 let rows = [
375 (y, b, v, b),
376 (y + b, h - 2.0 * b, v + b, rh - 2.0 * b),
377 (y + h - b, b, v + rh - b, b),
378 ];
379 let mut out = Vec::with_capacity(cols.len() * rows.len());
380 for (dy, dh, sv, sh) in rows {
381 for (dx, dw, su, sw) in cols {
382 out.push(([dx, dy, dw, dh], [su, sv, sw, sh]));
383 }
384 }
385 out
386}
387
388pub fn fit(stretch: Stretch, natural: Vec2, bx: Rect) -> (Rect, Rect) {
393 let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
394 if natural.x <= 0.0 || natural.y <= 0.0 || bx.w <= 0.0 || bx.h <= 0.0 {
395 return (Rect::new(bx.x, bx.y, 0.0, 0.0), whole);
396 }
397 let scale = match stretch {
398 Stretch::Fill => return (bx, whole),
399 Stretch::None => 1.0,
400 Stretch::Uniform => (bx.w / natural.x).min(bx.h / natural.y),
401 Stretch::UniformToFill => (bx.w / natural.x).max(bx.h / natural.y),
402 };
403 let d = natural * scale;
404 let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
406 if d <= size {
407 ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
408 } else {
409 let cut = (d - size) / 2.0 / d;
410 (pos, size, cut, 1.0 - 2.0 * cut)
411 }
412 };
413 let (dx, dw, u, uw) = axis(bx.x, bx.w, d.x);
414 let (dy, dh, v, vh) = axis(bx.y, bx.h, d.y);
415 (Rect::new(dx, dy, dw, dh), Rect::new(u, v, uw, vh))
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn the_sprite_table_reads() {
424 assert!(
425 yog_data::from_text::<Vec<SpriteDef>>(include_str!("../assets/sprites.ydata")).is_ok()
426 );
427 }
428
429 #[test]
430 fn parses_sources() {
431 assert_eq!(
432 ImageSource::parse("item:apple"),
433 Some(ImageSource::Item("minecraft:apple".into()))
434 );
435 assert_eq!(
436 ImageSource::parse("item:mymod:wand"),
437 Some(ImageSource::Item("mymod:wand".into()))
438 );
439 assert_eq!(
440 ImageSource::parse("block:stone"),
441 Some(ImageSource::Block("minecraft:stone".into()))
442 );
443 assert_eq!(
444 ImageSource::parse("sprite:button"),
445 Some(ImageSource::Sprite("button".into()))
446 );
447 assert_eq!(
448 ImageSource::parse("mymod:gui/logo"),
449 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
450 );
451 assert_eq!(
452 ImageSource::parse("minecraft:textures/item/apple.png"),
453 Some(ImageSource::Texture(
454 "minecraft:textures/item/apple.png".into()
455 ))
456 );
457 assert_eq!(
458 ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
459 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
460 );
461 assert_eq!(
462 ImageSource::parse("mymod:gui/logo.svg"),
463 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
464 );
465 assert_eq!(
466 ImageSource::parse("mymod:textures/gui/logo.svg"),
467 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
468 );
469 assert_eq!(ImageSource::parse(""), None);
470 assert_eq!(ImageSource::parse("apple"), None);
471 assert_eq!(ImageSource::parse("item:"), None);
472 }
473
474 #[test]
475 fn suggests_aliases_for_raw_paths() {
476 assert_eq!(
477 suggest_alias("minecraft:textures/item/apple.png").as_deref(),
478 Some("item:apple")
479 );
480 assert_eq!(
481 suggest_alias("mymod:textures/block/ore.png").as_deref(),
482 Some("block:mymod:ore")
483 );
484 assert_eq!(
485 suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
486 Some("minecraft:gui/widgets")
487 );
488 assert_eq!(
489 suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
490 Some("sprite:button")
491 );
492 assert_eq!(
493 suggest_alias("mymod:textures/gui/logo.png").as_deref(),
494 Some("mymod:gui/logo")
495 );
496 assert_eq!(suggest_alias("item:apple"), None);
497 }
498
499 #[test]
500 fn sprite_names_are_unique() {
501 for (i, s) in SPRITES.iter().enumerate() {
502 assert!(
503 SPRITES[i + 1..].iter().all(|o| o.name != s.name),
504 "{}",
505 s.name
506 );
507 }
508 assert!(sprite("slot").unwrap().is_procedural());
509 assert_eq!(
510 sprite("hotbar_selection").unwrap().size(false),
511 Vec2::new(24.0, 24.0)
512 );
513 assert_eq!(
514 sprite("hotbar_selection").unwrap().size(true),
515 Vec2::new(24.0, 23.0)
516 );
517 }
518
519 #[test]
520 fn fits_like_wpf() {
521 let icon = Vec2::new(16.0, 16.0);
522 let bx = Rect::new(0.0, 0.0, 156.0, 29.0);
523 let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
524 assert_eq!(
526 fit(Stretch::Uniform, icon, bx),
527 (Rect::new(63.0, 0.0, 29.0, 29.0), whole)
528 );
529 assert_eq!(
530 fit(Stretch::Fill, icon, Rect::new(1.0, 2.0, 156.0, 29.0)).0,
531 Rect::new(1.0, 2.0, 156.0, 29.0)
532 );
533 assert_eq!(
534 fit(Stretch::None, icon, bx).0,
535 Rect::new(70.0, 6.0, 16.0, 16.0)
536 );
537 let (d, s) = fit(Stretch::UniformToFill, icon, bx);
538 assert_eq!(d, bx);
539 assert!((s.y - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
540 let (d, s) = fit(
542 Stretch::None,
543 Vec2::new(40.0, 10.0),
544 Rect::new(0.0, 0.0, 20.0, 10.0),
545 );
546 assert_eq!(d, Rect::new(0.0, 0.0, 20.0, 10.0));
547 assert_eq!(s, Rect::new(0.25, 0.0, 0.5, 1.0));
548 }
549}