Skip to main content

yog_gfx/
image.rs

1//! Yog image sources — the names every Yog API that shows a picture takes
2//! instead of raw Minecraft resource paths, so mods don't depend on the game
3//! version, the loader or where Mojang keeps a texture this release.
4//!
5//! | Source | Draws |
6//! |---|---|
7//! | `item:<id>` | an item's icon through the item renderer (3D block items, modded items), e.g. `item:apple`, `item:mymod:wand` |
8//! | `block:<id>` | a block's item form, e.g. `block:crafting_table` |
9//! | `sprite:<name>` | a vanilla GUI sprite by Yog's stable name (see [`SPRITES`]), e.g. `sprite:button`, `sprite:icon/heart` |
10//! | `mod:<path>` | the mod's own image `assets/<mod id>/textures/<path>.png` (`.yui` files; `yog build` fills in the mod id) |
11//! | `<ns>:<path>` | `assets/<ns>/textures/<path>.png` — another mod's (or your own) image, e.g. `mymod:gui/logo` |
12//! | `<ns>:<path>.svg` | `assets/<ns>/textures/<path>.svg` — an SVG image, sharp at any size |
13//! | `<ns>:textures/<path>.png` | a raw resource id — what `mod:`/`<ns>:<path>` resolve to; `.yui` files reject it in favour of the alias ([`suggest_alias`]) |
14//!
15//! Ids without a namespace are `minecraft:`. The host resolves a source when
16//! it draws it ([`Draw2D::image`](crate::draw2d::Draw2D::image)) — sprites
17//! map to the GUI atlas on Minecraft 1.20.2+ and to `textures/gui/*.png`
18//! regions on 1.20.1 — so the same mod binary draws right on every version.
19//! This module is the shared definition (the runtime and Yog-IDLE's designer
20//! resolve with it); mods normally only pass strings.
21
22use yog_sync::LazyLock;
23
24use yog_graphics::{Rect, Vec2};
25
26/// A parsed image source — see the [module docs](self).
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ImageSource {
29    /// `item:<id>` — a namespaced item id.
30    Item(String),
31    /// `block:<id>` — a namespaced block id (drawn as its item).
32    Block(String),
33    /// `sprite:<name>` — a [`SPRITES`] name, or a raw GUI-atlas sprite id
34    /// when it has a namespace (`sprite:minecraft:widget/button`, 1.20.2+).
35    Sprite(String),
36    /// `mod:<path>` — the calling mod's own texture; needs the mod id
37    /// (see [`ImageSource::with_mod_id`]).
38    ModPath(String),
39    /// A texture resource id, `<ns>:textures/<path>.png`.
40    Texture(String),
41    /// `video:<id>` — a mod's video (`mymod:intro`), playing while it's
42    /// drawn: from its start when it's first drawn, let go when it isn't.
43    Video(String),
44}
45
46/// Side of the square 1.20.1 GUI textures most legacy regions live in.
47const LEGACY_TEX_SIZE: f32 = 256.0;
48
49/// `<ns>` + `<path>` → the texture resource id: `gui/logo` →
50/// `<ns>:textures/gui/logo.png`, `gui/logo.svg` →
51/// `<ns>:textures/gui/logo.svg` (an SVG image); paths already under
52/// `textures/` (or ending in `.png`) are kept as they are.
53pub fn texture_id(ns: &str, path: &str) -> String {
54    use yog_ids::res::{PNG, SVG, TEXTURES};
55    let path = path.trim_start_matches(yog_ids::sep::PATH);
56    if path.ends_with(SVG) {
57        let path = path.strip_prefix(TEXTURES).unwrap_or(path);
58        return yog_ids::join(ns, &format!("{TEXTURES}{path}"));
59    }
60    if path.starts_with(TEXTURES) || path.ends_with(PNG) {
61        let png = if path.ends_with(PNG) { "" } else { PNG };
62        yog_ids::join(ns, &format!("{path}{png}"))
63    } else {
64        yog_ids::join(ns, &format!("{TEXTURES}{path}{PNG}"))
65    }
66}
67
68/// A texture named either way — `ns:textures/x.png` or `ns:x` (`x` alone is
69/// the game's) — as the short source `ns:x`.
70pub fn short_source(texture: &str) -> String {
71    let (ns, path) = yog_ids::split(texture.trim());
72    let path = path.strip_prefix(yog_ids::res::TEXTURES).unwrap_or(path);
73    yog_ids::join(ns, path.strip_suffix(yog_ids::res::PNG).unwrap_or(path))
74}
75
76/// The schemes an image source starts with (`item:apple`).
77pub use yog_ids::image_scheme as scheme;
78
79/// The source as written: `item:minecraft:apple`, `sprite:button`; a
80/// texture as its resource id.
81impl std::fmt::Display for ImageSource {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            ImageSource::Item(id) => write!(f, "{}:{id}", scheme::ITEM),
85            ImageSource::Block(id) => write!(f, "{}:{id}", scheme::BLOCK),
86            ImageSource::Sprite(name) => write!(f, "{}:{name}", scheme::SPRITE),
87            ImageSource::ModPath(path) => write!(f, "{}:{path}", scheme::MOD),
88            ImageSource::Texture(id) => f.write_str(id),
89            ImageSource::Video(id) => write!(f, "{}:{id}", scheme::VIDEO),
90        }
91    }
92}
93
94impl ImageSource {
95    /// Parses a source string; `None` for an empty or malformed one.
96    pub fn parse(source: &str) -> Option<ImageSource> {
97        let s = source.trim();
98        let (scheme, rest) = s.split_once(yog_ids::sep::NAMESPACE)?;
99        if rest.is_empty() {
100            return None;
101        }
102        Some(match scheme {
103            scheme::ITEM => ImageSource::Item(yog_ids::qualify(rest.trim())),
104            scheme::BLOCK => ImageSource::Block(yog_ids::qualify(rest.trim())),
105            scheme::SPRITE => ImageSource::Sprite(rest.to_string()),
106            scheme::MOD => ImageSource::ModPath(rest.to_string()),
107            scheme::VIDEO => ImageSource::Video(rest.trim().to_string()),
108            ns if !ns.is_empty() => ImageSource::Texture(texture_id(ns, rest)),
109            _ => return None,
110        })
111    }
112
113    /// Resolves `mod:<path>` against `mod_id` (others are unchanged).
114    pub fn with_mod_id(self, mod_id: &str) -> ImageSource {
115        match self {
116            ImageSource::ModPath(path) => ImageSource::Texture(texture_id(mod_id, &path)),
117            other => other,
118        }
119    }
120}
121
122/// `<ns>:textures/<path>.png` → the Yog source a `.yui` file or a mod should
123/// use instead: `item:` for item textures, `block:` for block textures, a
124/// `sprite:` with the same image, or the short `<ns>:<path>` form. `None`
125/// when `id` isn't a raw texture id.
126pub fn suggest_alias(id: &str) -> Option<String> {
127    let (ns, path) = id.trim().split_once(yog_ids::sep::NAMESPACE)?;
128    let path = path.strip_prefix(yog_ids::res::TEXTURES)?;
129    let stem = path.strip_suffix(yog_ids::res::PNG).unwrap_or(path);
130    // `minecraft:` is the default namespace of every alias.
131    let short = |id: &str| -> String {
132        if ns == yog_ids::ns::MINECRAFT {
133            id.to_string()
134        } else {
135            yog_ids::join(ns, id)
136        }
137    };
138    if let Some(item) = stem.strip_prefix(yog_ids::res::ITEM_DIR) {
139        return Some(ImageSource::Item(short(item)).to_string());
140    }
141    if let Some(block) = stem.strip_prefix(yog_ids::res::BLOCK_DIR) {
142        return Some(ImageSource::Block(short(block)).to_string());
143    }
144    if ns == yog_ids::ns::MINECRAFT
145        && let Some(atlas_id) = stem.strip_prefix(yog_ids::res::GUI_SPRITES)
146        && let Some(sprite) = sprite_by_atlas(atlas_id)
147    {
148        return Some(ImageSource::Sprite(sprite.name.to_string()).to_string());
149    }
150    Some(yog_ids::join(ns, stem))
151}
152
153/// A region of a texture on Minecraft 1.20.1 (before the GUI sprite atlas).
154#[derive(Debug, Clone, PartialEq)]
155pub struct LegacyRegion {
156    /// Texture resource id.
157    pub texture: String,
158    /// The region, in the texture's pixels.
159    pub region: Rect,
160    /// The whole texture's size.
161    pub texture_size: Vec2,
162}
163
164/// One entry of Yog's GUI sprite table ([`SPRITES`]).
165#[derive(Debug, Clone, PartialEq)]
166pub struct Sprite {
167    /// The stable Yog name (`sprite:<name>`).
168    pub name: String,
169    /// What it is, for editors' pickers.
170    pub description: String,
171    /// Natural size in GUI pixels (1.20.2+; 1.20.1 uses the legacy region's).
172    pub size: Vec2,
173    /// GUI-atlas sprite id on Minecraft 1.20.2+ (`minecraft:<id>`, i.e.
174    /// `textures/gui/sprites/<id>.png`).
175    pub atlas: Option<String>,
176    /// Where it lives on 1.20.1.
177    pub legacy: Option<LegacyRegion>,
178    /// Yog's own picture of it (`yog:textures/gui/…`), for a version that has neither.
179    pub own: Option<LegacyRegion>,
180    /// Nine-slice border in pixels when stretched (0 = scale the whole image).
181    pub border: f32,
182}
183
184impl Sprite {
185    /// Natural size on a host with (`true`) or without the GUI sprite atlas.
186    pub fn size(&self, atlas: bool) -> Vec2 {
187        match (atlas, &self.legacy) {
188            (false, Some(l)) => Vec2::new(l.region.w, l.region.h),
189            _ => self.size,
190        }
191    }
192
193    /// Drawn by Yog itself (no texture on any version) — the inventory slot.
194    pub fn is_procedural(&self) -> bool {
195        self.atlas.is_none() && self.legacy.is_none() && self.own.is_none()
196    }
197
198    /// Where it may come from, in the order a host tries them: the GUI
199    /// atlas's sprite (`minecraft:<id>`), the game's texture region, Yog's
200    /// own picture. The host draws it from the first its game has.
201    pub fn sources(&self) -> Vec<SpriteSource<'_>> {
202        let mut out = Vec::new();
203        if let Some(a) = &self.atlas {
204            out.push(SpriteSource::Atlas(yog_ids::qualify(a)));
205        }
206        out.extend(
207            self.legacy
208                .iter()
209                .chain(&self.own)
210                .map(SpriteSource::Region),
211        );
212        out
213    }
214}
215
216/// One place a sprite may come from (see [`Sprite::sources`]).
217#[derive(Debug, Clone, PartialEq)]
218pub enum SpriteSource<'a> {
219    /// A GUI-atlas sprite, by full id (`minecraft:widget/button`).
220    Atlas(String),
221    /// A region of a texture.
222    Region(&'a LegacyRegion),
223}
224
225/// Yog's own assets (`yog:textures/gui/…`, `yog:models/item/…`), shipped in this crate's `res/`
226/// (a resource pack's layout: the hosts take the folder as resources).
227const OWN: &[(&str, &[u8])] = &[
228    (
229        "textures/gui/panel.png",
230        include_bytes!("../res/assets/yog/textures/gui/panel.png"),
231    ),
232    (
233        "models/item/spawn_egg.json",
234        include_bytes!("../res/assets/yog/models/item/spawn_egg.json"),
235    ),
236    (
237        "textures/item/spawn_egg.png",
238        include_bytes!("../res/assets/yog/textures/item/spawn_egg.png"),
239    ),
240    (
241        "textures/item/spawn_egg_overlay.png",
242        include_bytes!("../res/assets/yog/textures/item/spawn_egg_overlay.png"),
243    ),
244];
245
246/// The bytes of Yog's own asset `path` (`textures/gui/panel.png`, `models/item/spawn_egg.json`), if it has one.
247pub fn own_asset(path: &str) -> Option<&'static [u8]> {
248    OWN.iter().find(|(p, _)| *p == path).map(|(_, b)| *b)
249}
250
251/// Looks up a [`SPRITES`] entry by its Yog name.
252pub fn sprite(name: &str) -> Option<&'static Sprite> {
253    SPRITES.iter().find(|s| s.name == name.trim())
254}
255
256/// Looks up a [`SPRITES`] entry by its 1.20.2+ atlas id (`widget/button`).
257pub fn sprite_by_atlas(atlas: &str) -> Option<&'static Sprite> {
258    SPRITES.iter().find(|s| s.atlas.as_deref() == Some(atlas))
259}
260
261/// The texture of GUI-atlas sprite `atlas` of namespace `ns`:
262/// `minecraft:textures/gui/sprites/<atlas>.png`.
263pub fn atlas_texture(ns: &str, atlas: &str) -> String {
264    yog_ids::join(
265        ns,
266        &yog_ids::texture_file(&format!("{}{atlas}", yog_ids::res::GUI_SPRITES)),
267    )
268}
269
270/// A sprite as `assets/sprites.ydata` writes it.
271#[derive(yog_data::Schema)]
272struct SpriteDef {
273    name: String,
274    description: String,
275    size: [f32; 2],
276    atlas: Option<String>,
277    legacy: Option<LegacyDef>,
278    own: Option<LegacyDef>,
279    #[data(default)]
280    border: f32,
281}
282
283#[derive(yog_data::Schema)]
284struct LegacyDef {
285    texture: String,
286    region: [f32; 4],
287    /// The texture's side, when it isn't [`LEGACY_TEX_SIZE`].
288    texture_size: Option<f32>,
289}
290
291fn region(l: LegacyDef) -> LegacyRegion {
292    let [u, v, w, h] = l.region;
293    let side = l.texture_size.unwrap_or(LEGACY_TEX_SIZE);
294    LegacyRegion {
295        texture: l.texture,
296        region: Rect::new(u, v, w, h),
297        texture_size: Vec2::new(side, side),
298    }
299}
300
301/// Yog's GUI sprite names (`assets/sprites.ydata`).
302pub static SPRITES: LazyLock<Vec<Sprite>> = LazyLock::new(|| {
303    yog_data::built_in::<Vec<SpriteDef>>(
304        "yog-gfx's sprites",
305        include_str!("../assets/sprites.ydata"),
306    )
307    .into_iter()
308    .map(|d| Sprite {
309        name: d.name,
310        description: d.description,
311        size: Vec2::new(d.size[0], d.size[1]),
312        atlas: d.atlas,
313        legacy: d.legacy.map(region),
314        own: d.own.map(region),
315        border: d.border,
316    })
317    .collect()
318});
319
320/// How an image fills the box it's given — WPF's `Stretch`.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
322#[repr(u8)]
323pub enum Stretch {
324    /// Natural size, centered; cut to the box when larger.
325    None = 0,
326    /// The whole box (aspect ratio not kept).
327    Fill = 1,
328    /// As large as fits, aspect ratio kept, centered.
329    #[default]
330    Uniform = 2,
331    /// Covers the box, aspect ratio kept, centered; the overflow is cut.
332    UniformToFill = 3,
333}
334
335impl Stretch {
336    /// Every stretch, with its name in `.yui` (`stretch="Fill"`).
337    pub const ALL: [(Stretch, &'static str); 4] = [
338        (Stretch::None, "None"),
339        (Stretch::Fill, "Fill"),
340        (Stretch::Uniform, "Uniform"),
341        (Stretch::UniformToFill, "UniformToFill"),
342    ];
343
344    /// Its name in `.yui`.
345    pub fn name(self) -> &'static str {
346        Self::ALL
347            .iter()
348            .find(|(s, _)| *s == self)
349            .map_or("", |(_, n)| n)
350    }
351
352    /// A stretch by its name, case and `_` ignored (`fill`, `uniform_to_fill`).
353    pub fn parse(name: &str) -> Option<Stretch> {
354        let wanted = name.trim().replace('_', "");
355        Self::ALL
356            .iter()
357            .find(|(_, n)| n.eq_ignore_ascii_case(&wanted))
358            .map(|(s, _)| *s)
359    }
360}
361
362/// A picture's `region` (`[u, v, width, height]` in its pixels) stretched into
363/// `dest` (`[x, y, w, h]`) with its `border` kept as it is: the pieces to
364/// draw, each (destination, region) — nine, or the whole when it isn't
365/// stretched or has no border.
366pub fn nine_slice(dest: Rect, region: Rect, border: f32) -> Vec<(Rect, Rect)> {
367    let Rect { x, y, w, h } = dest;
368    let Rect {
369        x: u,
370        y: v,
371        w: rw,
372        h: rh,
373    } = region;
374    let b = border.min(rw / 2.0).min(rh / 2.0).min(w / 2.0).min(h / 2.0);
375    if b <= 0.0 || (w == rw && h == rh) {
376        return vec![(dest, region)];
377    }
378    // Columns / rows: (where, how long, from, how long there).
379    let cols = [
380        (x, b, u, b),
381        (x + b, w - 2.0 * b, u + b, rw - 2.0 * b),
382        (x + w - b, b, u + rw - b, b),
383    ];
384    let rows = [
385        (y, b, v, b),
386        (y + b, h - 2.0 * b, v + b, rh - 2.0 * b),
387        (y + h - b, b, v + rh - b, b),
388    ];
389    let mut out = Vec::with_capacity(cols.len() * rows.len());
390    for (dy, dh, sv, sh) in rows {
391        for (dx, dw, su, sw) in cols {
392            out.push((Rect::new(dx, dy, dw, dh), Rect::new(su, sv, sw, sh)));
393        }
394    }
395    out
396}
397
398/// Where an image of size `natural` goes in the box `bx` under `stretch`:
399/// `(dest, src)` — the destination rect and the part of the image shown, as
400/// a rect in fractions of the image (less than the whole for
401/// `None`/`UniformToFill` images bigger than the box).
402pub fn fit(stretch: Stretch, natural: Vec2, bx: Rect) -> (Rect, Rect) {
403    let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
404    if natural.x <= 0.0 || natural.y <= 0.0 || bx.w <= 0.0 || bx.h <= 0.0 {
405        return (Rect::new(bx.x, bx.y, 0.0, 0.0), whole);
406    }
407    let scale = match stretch {
408        Stretch::Fill => return (bx, whole),
409        Stretch::None => 1.0,
410        Stretch::Uniform => (bx.w / natural.x).min(bx.h / natural.y),
411        Stretch::UniformToFill => (bx.w / natural.x).max(bx.h / natural.y),
412    };
413    let d = natural * scale;
414    // Per axis: centered when it fits, else cut evenly on both sides.
415    let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
416        if d <= size {
417            ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
418        } else {
419            let cut = (d - size) / 2.0 / d;
420            (pos, size, cut, 1.0 - 2.0 * cut)
421        }
422    };
423    let (dx, dw, u, uw) = axis(bx.x, bx.w, d.x);
424    let (dy, dh, v, vh) = axis(bx.y, bx.h, d.y);
425    (Rect::new(dx, dy, dw, dh), Rect::new(u, v, uw, vh))
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn the_sprite_table_reads() {
434        assert!(
435            yog_data::from_text::<Vec<SpriteDef>>(include_str!("../assets/sprites.ydata")).is_ok()
436        );
437    }
438
439    #[test]
440    fn parses_sources() {
441        assert_eq!(
442            ImageSource::parse("item:apple"),
443            Some(ImageSource::Item("minecraft:apple".into()))
444        );
445        assert_eq!(
446            ImageSource::parse("item:mymod:wand"),
447            Some(ImageSource::Item("mymod:wand".into()))
448        );
449        assert_eq!(
450            ImageSource::parse("block:stone"),
451            Some(ImageSource::Block("minecraft:stone".into()))
452        );
453        assert_eq!(
454            ImageSource::parse("sprite:button"),
455            Some(ImageSource::Sprite("button".into()))
456        );
457        assert_eq!(
458            ImageSource::parse("mymod:gui/logo"),
459            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
460        );
461        assert_eq!(
462            ImageSource::parse("minecraft:textures/item/apple.png"),
463            Some(ImageSource::Texture(
464                "minecraft:textures/item/apple.png".into()
465            ))
466        );
467        assert_eq!(
468            ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
469            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
470        );
471        assert_eq!(
472            ImageSource::parse("mymod:gui/logo.svg"),
473            Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
474        );
475        assert_eq!(
476            ImageSource::parse("mymod:textures/gui/logo.svg"),
477            Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
478        );
479        assert_eq!(ImageSource::parse(""), None);
480        assert_eq!(ImageSource::parse("apple"), None);
481        assert_eq!(ImageSource::parse("item:"), None);
482    }
483
484    #[test]
485    fn suggests_aliases_for_raw_paths() {
486        assert_eq!(
487            suggest_alias("minecraft:textures/item/apple.png").as_deref(),
488            Some("item:apple")
489        );
490        assert_eq!(
491            suggest_alias("mymod:textures/block/ore.png").as_deref(),
492            Some("block:mymod:ore")
493        );
494        assert_eq!(
495            suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
496            Some("minecraft:gui/widgets")
497        );
498        assert_eq!(
499            suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
500            Some("sprite:button")
501        );
502        assert_eq!(
503            suggest_alias("mymod:textures/gui/logo.png").as_deref(),
504            Some("mymod:gui/logo")
505        );
506        assert_eq!(suggest_alias("item:apple"), None);
507    }
508
509    #[test]
510    fn sprite_names_are_unique() {
511        for (i, s) in SPRITES.iter().enumerate() {
512            assert!(
513                SPRITES[i + 1..].iter().all(|o| o.name != s.name),
514                "{}",
515                s.name
516            );
517        }
518        assert!(sprite("slot").unwrap().is_procedural());
519        assert_eq!(
520            sprite("hotbar_selection").unwrap().size(false),
521            Vec2::new(24.0, 24.0)
522        );
523        assert_eq!(
524            sprite("hotbar_selection").unwrap().size(true),
525            Vec2::new(24.0, 23.0)
526        );
527    }
528
529    #[test]
530    fn fits_like_wpf() {
531        let icon = Vec2::new(16.0, 16.0);
532        let bx = Rect::new(0.0, 0.0, 156.0, 29.0);
533        let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
534        // 16×16 into 156×29.
535        assert_eq!(
536            fit(Stretch::Uniform, icon, bx),
537            (Rect::new(63.0, 0.0, 29.0, 29.0), whole)
538        );
539        assert_eq!(
540            fit(Stretch::Fill, icon, Rect::new(1.0, 2.0, 156.0, 29.0)).0,
541            Rect::new(1.0, 2.0, 156.0, 29.0)
542        );
543        assert_eq!(
544            fit(Stretch::None, icon, bx).0,
545            Rect::new(70.0, 6.0, 16.0, 16.0)
546        );
547        let (d, s) = fit(Stretch::UniformToFill, icon, bx);
548        assert_eq!(d, bx);
549        assert!((s.y - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
550        // None, bigger than the box: cut to it.
551        let (d, s) = fit(
552            Stretch::None,
553            Vec2::new(40.0, 10.0),
554            Rect::new(0.0, 0.0, 20.0, 10.0),
555        );
556        assert_eq!(d, Rect::new(0.0, 0.0, 20.0, 10.0));
557        assert_eq!(s, Rect::new(0.25, 0.0, 0.5, 1.0));
558    }
559}