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: Rect, region: Rect, border: f32) -> Vec<(Rect, Rect)> {
362 let Rect { x, y, w, h } = dest;
363 let Rect {
364 x: u,
365 y: v,
366 w: rw,
367 h: rh,
368 } = region;
369 let b = border.min(rw / 2.0).min(rh / 2.0).min(w / 2.0).min(h / 2.0);
370 if b <= 0.0 || (w == rw && h == rh) {
371 return vec![(dest, region)];
372 }
373 let cols = [
375 (x, b, u, b),
376 (x + b, w - 2.0 * b, u + b, rw - 2.0 * b),
377 (x + w - b, b, u + rw - b, b),
378 ];
379 let rows = [
380 (y, b, v, b),
381 (y + b, h - 2.0 * b, v + b, rh - 2.0 * b),
382 (y + h - b, b, v + rh - b, b),
383 ];
384 let mut out = Vec::with_capacity(cols.len() * rows.len());
385 for (dy, dh, sv, sh) in rows {
386 for (dx, dw, su, sw) in cols {
387 out.push((Rect::new(dx, dy, dw, dh), Rect::new(su, sv, sw, sh)));
388 }
389 }
390 out
391}
392
393pub fn fit(stretch: Stretch, natural: Vec2, bx: Rect) -> (Rect, Rect) {
398 let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
399 if natural.x <= 0.0 || natural.y <= 0.0 || bx.w <= 0.0 || bx.h <= 0.0 {
400 return (Rect::new(bx.x, bx.y, 0.0, 0.0), whole);
401 }
402 let scale = match stretch {
403 Stretch::Fill => return (bx, whole),
404 Stretch::None => 1.0,
405 Stretch::Uniform => (bx.w / natural.x).min(bx.h / natural.y),
406 Stretch::UniformToFill => (bx.w / natural.x).max(bx.h / natural.y),
407 };
408 let d = natural * scale;
409 let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
411 if d <= size {
412 ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
413 } else {
414 let cut = (d - size) / 2.0 / d;
415 (pos, size, cut, 1.0 - 2.0 * cut)
416 }
417 };
418 let (dx, dw, u, uw) = axis(bx.x, bx.w, d.x);
419 let (dy, dh, v, vh) = axis(bx.y, bx.h, d.y);
420 (Rect::new(dx, dy, dw, dh), Rect::new(u, v, uw, vh))
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn the_sprite_table_reads() {
429 assert!(
430 yog_data::from_text::<Vec<SpriteDef>>(include_str!("../assets/sprites.ydata")).is_ok()
431 );
432 }
433
434 #[test]
435 fn parses_sources() {
436 assert_eq!(
437 ImageSource::parse("item:apple"),
438 Some(ImageSource::Item("minecraft:apple".into()))
439 );
440 assert_eq!(
441 ImageSource::parse("item:mymod:wand"),
442 Some(ImageSource::Item("mymod:wand".into()))
443 );
444 assert_eq!(
445 ImageSource::parse("block:stone"),
446 Some(ImageSource::Block("minecraft:stone".into()))
447 );
448 assert_eq!(
449 ImageSource::parse("sprite:button"),
450 Some(ImageSource::Sprite("button".into()))
451 );
452 assert_eq!(
453 ImageSource::parse("mymod:gui/logo"),
454 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
455 );
456 assert_eq!(
457 ImageSource::parse("minecraft:textures/item/apple.png"),
458 Some(ImageSource::Texture(
459 "minecraft:textures/item/apple.png".into()
460 ))
461 );
462 assert_eq!(
463 ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
464 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
465 );
466 assert_eq!(
467 ImageSource::parse("mymod:gui/logo.svg"),
468 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
469 );
470 assert_eq!(
471 ImageSource::parse("mymod:textures/gui/logo.svg"),
472 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
473 );
474 assert_eq!(ImageSource::parse(""), None);
475 assert_eq!(ImageSource::parse("apple"), None);
476 assert_eq!(ImageSource::parse("item:"), None);
477 }
478
479 #[test]
480 fn suggests_aliases_for_raw_paths() {
481 assert_eq!(
482 suggest_alias("minecraft:textures/item/apple.png").as_deref(),
483 Some("item:apple")
484 );
485 assert_eq!(
486 suggest_alias("mymod:textures/block/ore.png").as_deref(),
487 Some("block:mymod:ore")
488 );
489 assert_eq!(
490 suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
491 Some("minecraft:gui/widgets")
492 );
493 assert_eq!(
494 suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
495 Some("sprite:button")
496 );
497 assert_eq!(
498 suggest_alias("mymod:textures/gui/logo.png").as_deref(),
499 Some("mymod:gui/logo")
500 );
501 assert_eq!(suggest_alias("item:apple"), None);
502 }
503
504 #[test]
505 fn sprite_names_are_unique() {
506 for (i, s) in SPRITES.iter().enumerate() {
507 assert!(
508 SPRITES[i + 1..].iter().all(|o| o.name != s.name),
509 "{}",
510 s.name
511 );
512 }
513 assert!(sprite("slot").unwrap().is_procedural());
514 assert_eq!(
515 sprite("hotbar_selection").unwrap().size(false),
516 Vec2::new(24.0, 24.0)
517 );
518 assert_eq!(
519 sprite("hotbar_selection").unwrap().size(true),
520 Vec2::new(24.0, 23.0)
521 );
522 }
523
524 #[test]
525 fn fits_like_wpf() {
526 let icon = Vec2::new(16.0, 16.0);
527 let bx = Rect::new(0.0, 0.0, 156.0, 29.0);
528 let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
529 assert_eq!(
531 fit(Stretch::Uniform, icon, bx),
532 (Rect::new(63.0, 0.0, 29.0, 29.0), whole)
533 );
534 assert_eq!(
535 fit(Stretch::Fill, icon, Rect::new(1.0, 2.0, 156.0, 29.0)).0,
536 Rect::new(1.0, 2.0, 156.0, 29.0)
537 );
538 assert_eq!(
539 fit(Stretch::None, icon, bx).0,
540 Rect::new(70.0, 6.0, 16.0, 16.0)
541 );
542 let (d, s) = fit(Stretch::UniformToFill, icon, bx);
543 assert_eq!(d, bx);
544 assert!((s.y - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
545 let (d, s) = fit(
547 Stretch::None,
548 Vec2::new(40.0, 10.0),
549 Rect::new(0.0, 0.0, 20.0, 10.0),
550 );
551 assert_eq!(d, Rect::new(0.0, 0.0, 20.0, 10.0));
552 assert_eq!(s, Rect::new(0.25, 0.0, 0.5, 1.0));
553 }
554}