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
22
23use std::sync::LazyLock;
24
25use yog_graphics::{Rect, Vec2};
26
27/// A parsed image source — see the [module docs](self).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ImageSource {
30    /// `item:<id>` — a namespaced item id.
31    Item(String),
32    /// `block:<id>` — a namespaced block id (drawn as its item).
33    Block(String),
34    /// `sprite:<name>` — a [`SPRITES`] name, or a raw GUI-atlas sprite id
35    /// when it has a namespace (`sprite:minecraft:widget/button`, 1.20.2+).
36    Sprite(String),
37    /// `mod:<path>` — the calling mod's own texture; needs the mod id
38    /// (see [`ImageSource::with_mod_id`]).
39    ModPath(String),
40    /// A texture resource id, `<ns>:textures/<path>.png`.
41    Texture(String),
42}
43
44/// Side of the square 1.20.1 GUI textures most legacy regions live in.
45const LEGACY_TEX_SIZE: f32 = 256.0;
46
47/// `<ns>` + `<path>` → the texture resource id: `gui/logo` →
48/// `<ns>:textures/gui/logo.png`, `gui/logo.svg` →
49/// `<ns>:textures/gui/logo.svg` (an SVG image); paths already under
50/// `textures/` (or ending in `.png`) are kept as they are.
51pub fn texture_id(ns: &str, path: &str) -> String {
52    use yog_ids::res::{PNG, SVG, TEXTURES};
53    let path = path.trim_start_matches(yog_ids::sep::PATH);
54    if path.ends_with(SVG) {
55        let path = path.strip_prefix(TEXTURES).unwrap_or(path);
56        return yog_ids::join(ns, &format!("{TEXTURES}{path}"));
57    }
58    if path.starts_with(TEXTURES) || path.ends_with(PNG) {
59        let png = if path.ends_with(PNG) { "" } else { PNG };
60        yog_ids::join(ns, &format!("{path}{png}"))
61    } else {
62        yog_ids::join(ns, &format!("{TEXTURES}{path}{PNG}"))
63    }
64}
65
66/// A texture named either way — `ns:textures/x.png` or `ns:x` (`x` alone is
67/// the game's) — as the short source `ns:x`.
68pub fn short_source(texture: &str) -> String {
69    let (ns, path) = yog_ids::split(texture.trim());
70    let path = path.strip_prefix(yog_ids::res::TEXTURES).unwrap_or(path);
71    yog_ids::join(ns, path.strip_suffix(yog_ids::res::PNG).unwrap_or(path))
72}
73
74/// The schemes an image source starts with (`item:apple`).
75pub use yog_ids::image_scheme as scheme;
76
77/// The source as written: `item:minecraft:apple`, `sprite:button`; a
78/// texture as its resource id.
79impl std::fmt::Display for ImageSource {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            ImageSource::Item(id) => write!(f, "{}:{id}", scheme::ITEM),
83            ImageSource::Block(id) => write!(f, "{}:{id}", scheme::BLOCK),
84            ImageSource::Sprite(name) => write!(f, "{}:{name}", scheme::SPRITE),
85            ImageSource::ModPath(path) => write!(f, "{}:{path}", scheme::MOD),
86            ImageSource::Texture(id) => f.write_str(id),
87        }
88    }
89}
90
91impl ImageSource {
92    /// Parses a source string; `None` for an empty or malformed one.
93    pub fn parse(source: &str) -> Option<ImageSource> {
94        let s = source.trim();
95        let (scheme, rest) = s.split_once(yog_ids::sep::NAMESPACE)?;
96        if rest.is_empty() {
97            return None;
98        }
99        Some(match scheme {
100            scheme::ITEM => ImageSource::Item(yog_ids::qualify(rest.trim())),
101            scheme::BLOCK => ImageSource::Block(yog_ids::qualify(rest.trim())),
102            scheme::SPRITE => ImageSource::Sprite(rest.to_string()),
103            scheme::MOD => ImageSource::ModPath(rest.to_string()),
104            ns if !ns.is_empty() => ImageSource::Texture(texture_id(ns, rest)),
105            _ => return None,
106        })
107    }
108
109    /// Resolves `mod:<path>` against `mod_id` (others are unchanged).
110    pub fn with_mod_id(self, mod_id: &str) -> ImageSource {
111        match self {
112            ImageSource::ModPath(path) => ImageSource::Texture(texture_id(mod_id, &path)),
113            other => other,
114        }
115    }
116}
117
118/// `<ns>:textures/<path>.png` → the Yog source a `.yui` file or a mod should
119/// use instead: `item:` for item textures, `block:` for block textures, a
120/// `sprite:` with the same image, or the short `<ns>:<path>` form. `None`
121/// when `id` isn't a raw texture id.
122pub fn suggest_alias(id: &str) -> Option<String> {
123    let (ns, path) = id.trim().split_once(yog_ids::sep::NAMESPACE)?;
124    let path = path.strip_prefix(yog_ids::res::TEXTURES)?;
125    let stem = path.strip_suffix(yog_ids::res::PNG).unwrap_or(path);
126    // `minecraft:` is the default namespace of every alias.
127    let short = |id: &str| -> String {
128        if ns == yog_ids::ns::MINECRAFT {
129            id.to_string()
130        } else {
131            yog_ids::join(ns, id)
132        }
133    };
134    if let Some(item) = stem.strip_prefix(yog_ids::res::ITEM_DIR) {
135        return Some(ImageSource::Item(short(item)).to_string());
136    }
137    if let Some(block) = stem.strip_prefix(yog_ids::res::BLOCK_DIR) {
138        return Some(ImageSource::Block(short(block)).to_string());
139    }
140    if ns == yog_ids::ns::MINECRAFT
141        && let Some(atlas_id) = stem.strip_prefix(yog_ids::res::GUI_SPRITES)
142        && let Some(sprite) = sprite_by_atlas(atlas_id)
143    {
144        return Some(ImageSource::Sprite(sprite.name.to_string()).to_string());
145    }
146    Some(yog_ids::join(ns, stem))
147}
148
149/// A region of a texture on Minecraft 1.20.1 (before the GUI sprite atlas).
150#[derive(Debug, Clone, PartialEq)]
151pub struct LegacyRegion {
152    /// Texture resource id.
153    pub texture: String,
154    /// The region, in the texture's pixels.
155    pub region: Rect,
156    /// The whole texture's size.
157    pub texture_size: Vec2,
158}
159
160/// One entry of Yog's GUI sprite table ([`SPRITES`]).
161#[derive(Debug, Clone, PartialEq)]
162pub struct Sprite {
163    /// The stable Yog name (`sprite:<name>`).
164    pub name: String,
165    /// What it is, for editors' pickers.
166    pub description: String,
167    /// Natural size in GUI pixels (1.20.2+; 1.20.1 uses the legacy region's).
168    pub size: Vec2,
169    /// GUI-atlas sprite id on Minecraft 1.20.2+ (`minecraft:<id>`, i.e.
170    /// `textures/gui/sprites/<id>.png`).
171    pub atlas: Option<String>,
172    /// Where it lives on 1.20.1.
173    pub legacy: Option<LegacyRegion>,
174    /// Yog's own picture of it (`yog:textures/gui/…`), for a version that has neither.
175    pub own: Option<LegacyRegion>,
176    /// Nine-slice border in pixels when stretched (0 = scale the whole image).
177    pub border: f32,
178}
179
180impl Sprite {
181    /// Natural size on a host with (`true`) or without the GUI sprite atlas.
182    pub fn size(&self, atlas: bool) -> Vec2 {
183        match (atlas, &self.legacy) {
184            (false, Some(l)) => Vec2::new(l.region.w, l.region.h),
185            _ => self.size,
186        }
187    }
188
189    /// Drawn by Yog itself (no texture on any version) — the inventory slot.
190    pub fn is_procedural(&self) -> bool {
191        self.atlas.is_none() && self.legacy.is_none() && self.own.is_none()
192    }
193
194    /// Where it may come from, in the order a host tries them: the GUI
195    /// atlas's sprite (`minecraft:<id>`), the game's texture region, Yog's
196    /// own picture. The host draws it from the first its game has.
197    pub fn sources(&self) -> Vec<SpriteSource<'_>> {
198        let mut out = Vec::new();
199        if let Some(a) = &self.atlas {
200            out.push(SpriteSource::Atlas(yog_ids::qualify(a)));
201        }
202        out.extend(self.legacy.iter().chain(&self.own).map(SpriteSource::Region));
203        out
204    }
205}
206
207/// One place a sprite may come from (see [`Sprite::sources`]).
208#[derive(Debug, Clone, PartialEq)]
209pub enum SpriteSource<'a> {
210    /// A GUI-atlas sprite, by full id (`minecraft:widget/button`).
211    Atlas(String),
212    /// A region of a texture.
213    Region(&'a LegacyRegion),
214}
215
216/// Yog's own assets (`yog:textures/gui/…`, `yog:models/item/…`), shipped in this crate's `res/`
217/// (a resource pack's layout: the hosts take the folder as resources).
218const OWN: &[(&str, &[u8])] = &[
219    ("textures/gui/panel.png", include_bytes!("../res/assets/yog/textures/gui/panel.png")),
220    ("models/item/spawn_egg.json", include_bytes!("../res/assets/yog/models/item/spawn_egg.json")),
221    ("textures/item/spawn_egg.png", include_bytes!("../res/assets/yog/textures/item/spawn_egg.png")),
222    ("textures/item/spawn_egg_overlay.png", include_bytes!("../res/assets/yog/textures/item/spawn_egg_overlay.png")),
223];
224
225/// The bytes of Yog's own asset `path` (`textures/gui/panel.png`, `models/item/spawn_egg.json`), if it has one.
226pub fn own_asset(path: &str) -> Option<&'static [u8]> {
227    OWN.iter().find(|(p, _)| *p == path).map(|(_, b)| *b)
228}
229
230/// Looks up a [`SPRITES`] entry by its Yog name.
231pub fn sprite(name: &str) -> Option<&'static Sprite> {
232    SPRITES.iter().find(|s| s.name == name.trim())
233}
234
235/// Looks up a [`SPRITES`] entry by its 1.20.2+ atlas id (`widget/button`).
236pub fn sprite_by_atlas(atlas: &str) -> Option<&'static Sprite> {
237    SPRITES.iter().find(|s| s.atlas.as_deref() == Some(atlas))
238}
239
240/// The texture of GUI-atlas sprite `atlas` of namespace `ns`:
241/// `minecraft:textures/gui/sprites/<atlas>.png`.
242pub fn atlas_texture(ns: &str, atlas: &str) -> String {
243    yog_ids::join(ns, &yog_ids::texture_file(&format!("{}{atlas}", yog_ids::res::GUI_SPRITES)))
244}
245
246/// A sprite as `assets/sprites.ydata` writes it.
247#[derive(yog_data::Schema)]
248struct SpriteDef {
249    name: String,
250    description: String,
251    size: [f32; 2],
252    atlas: Option<String>,
253    legacy: Option<LegacyDef>,
254    own: Option<LegacyDef>,
255    #[data(default)]
256    border: f32,
257}
258
259#[derive(yog_data::Schema)]
260struct LegacyDef {
261    texture: String,
262    region: [f32; 4],
263    /// The texture's side, when it isn't [`LEGACY_TEX_SIZE`].
264    texture_size: Option<f32>,
265}
266
267fn region(l: LegacyDef) -> LegacyRegion {
268    let [u, v, w, h] = l.region;
269    let side = l.texture_size.unwrap_or(LEGACY_TEX_SIZE);
270    LegacyRegion { texture: l.texture, region: Rect::new(u, v, w, h), texture_size: Vec2::new(side, side) }
271}
272
273/// Yog's GUI sprite names (`assets/sprites.ydata`).
274pub static SPRITES: LazyLock<Vec<Sprite>> = LazyLock::new(|| {
275    yog_data::from_text::<Vec<SpriteDef>>(include_str!("../assets/sprites.ydata"))
276        .unwrap_or_else(|e| panic!("yog-gfx's sprites: {e}"))
277        .value
278        .into_iter()
279        .map(|d| Sprite {
280            name: d.name,
281            description: d.description,
282            size: Vec2::new(d.size[0], d.size[1]),
283            atlas: d.atlas,
284            legacy: d.legacy.map(region),
285            own: d.own.map(region),
286            border: d.border,
287        })
288        .collect()
289});
290
291/// How an image fills the box it's given — WPF's `Stretch`.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
293#[repr(u8)]
294pub enum Stretch {
295    /// Natural size, centered; cut to the box when larger.
296    None = 0,
297    /// The whole box (aspect ratio not kept).
298    Fill = 1,
299    /// As large as fits, aspect ratio kept, centered.
300    #[default]
301    Uniform = 2,
302    /// Covers the box, aspect ratio kept, centered; the overflow is cut.
303    UniformToFill = 3,
304}
305
306/// Where an image of size `natural` goes in the box `bx` under `stretch`:
307/// `(dest, src)` — the destination rect and the part of the image shown, as
308/// a rect in fractions of the image (less than the whole for
309/// `None`/`UniformToFill` images bigger than the box).
310pub fn fit(stretch: Stretch, natural: Vec2, bx: Rect) -> (Rect, Rect) {
311    let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
312    if natural.x <= 0.0 || natural.y <= 0.0 || bx.w <= 0.0 || bx.h <= 0.0 {
313        return (Rect::new(bx.x, bx.y, 0.0, 0.0), whole);
314    }
315    let scale = match stretch {
316        Stretch::Fill => return (bx, whole),
317        Stretch::None => 1.0,
318        Stretch::Uniform => (bx.w / natural.x).min(bx.h / natural.y),
319        Stretch::UniformToFill => (bx.w / natural.x).max(bx.h / natural.y),
320    };
321    let d = natural * scale;
322    // Per axis: centered when it fits, else cut evenly on both sides.
323    let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
324        if d <= size {
325            ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
326        } else {
327            let cut = (d - size) / 2.0 / d;
328            (pos, size, cut, 1.0 - 2.0 * cut)
329        }
330    };
331    let (dx, dw, u, uw) = axis(bx.x, bx.w, d.x);
332    let (dy, dh, v, vh) = axis(bx.y, bx.h, d.y);
333    (Rect::new(dx, dy, dw, dh), Rect::new(u, v, uw, vh))
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn parses_sources() {
342        assert_eq!(
343            ImageSource::parse("item:apple"),
344            Some(ImageSource::Item("minecraft:apple".into()))
345        );
346        assert_eq!(
347            ImageSource::parse("item:mymod:wand"),
348            Some(ImageSource::Item("mymod:wand".into()))
349        );
350        assert_eq!(
351            ImageSource::parse("block:stone"),
352            Some(ImageSource::Block("minecraft:stone".into()))
353        );
354        assert_eq!(
355            ImageSource::parse("sprite:button"),
356            Some(ImageSource::Sprite("button".into()))
357        );
358        assert_eq!(
359            ImageSource::parse("mymod:gui/logo"),
360            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
361        );
362        assert_eq!(
363            ImageSource::parse("minecraft:textures/item/apple.png"),
364            Some(ImageSource::Texture(
365                "minecraft:textures/item/apple.png".into()
366            ))
367        );
368        assert_eq!(
369            ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
370            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
371        );
372        assert_eq!(
373            ImageSource::parse("mymod:gui/logo.svg"),
374            Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
375        );
376        assert_eq!(
377            ImageSource::parse("mymod:textures/gui/logo.svg"),
378            Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
379        );
380        assert_eq!(ImageSource::parse(""), None);
381        assert_eq!(ImageSource::parse("apple"), None);
382        assert_eq!(ImageSource::parse("item:"), None);
383    }
384
385    #[test]
386    fn suggests_aliases_for_raw_paths() {
387        assert_eq!(
388            suggest_alias("minecraft:textures/item/apple.png").as_deref(),
389            Some("item:apple")
390        );
391        assert_eq!(
392            suggest_alias("mymod:textures/block/ore.png").as_deref(),
393            Some("block:mymod:ore")
394        );
395        assert_eq!(
396            suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
397            Some("minecraft:gui/widgets")
398        );
399        assert_eq!(
400            suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
401            Some("sprite:button")
402        );
403        assert_eq!(
404            suggest_alias("mymod:textures/gui/logo.png").as_deref(),
405            Some("mymod:gui/logo")
406        );
407        assert_eq!(suggest_alias("item:apple"), None);
408    }
409
410    #[test]
411    fn sprite_names_are_unique() {
412        for (i, s) in SPRITES.iter().enumerate() {
413            assert!(
414                SPRITES[i + 1..].iter().all(|o| o.name != s.name),
415                "{}",
416                s.name
417            );
418        }
419        assert!(sprite("slot").unwrap().is_procedural());
420        assert_eq!(
421            sprite("hotbar_selection").unwrap().size(false),
422            Vec2::new(24.0, 24.0)
423        );
424        assert_eq!(sprite("hotbar_selection").unwrap().size(true), Vec2::new(24.0, 23.0));
425    }
426
427    #[test]
428    fn fits_like_wpf() {
429        let icon = Vec2::new(16.0, 16.0);
430        let bx = Rect::new(0.0, 0.0, 156.0, 29.0);
431        let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
432        // 16×16 into 156×29.
433        assert_eq!(fit(Stretch::Uniform, icon, bx), (Rect::new(63.0, 0.0, 29.0, 29.0), whole));
434        assert_eq!(fit(Stretch::Fill, icon, Rect::new(1.0, 2.0, 156.0, 29.0)).0, Rect::new(1.0, 2.0, 156.0, 29.0));
435        assert_eq!(fit(Stretch::None, icon, bx).0, Rect::new(70.0, 6.0, 16.0, 16.0));
436        let (d, s) = fit(Stretch::UniformToFill, icon, bx);
437        assert_eq!(d, bx);
438        assert!((s.y - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
439        // None, bigger than the box: cut to it.
440        let (d, s) = fit(Stretch::None, Vec2::new(40.0, 10.0), Rect::new(0.0, 0.0, 20.0, 10.0));
441        assert_eq!(d, Rect::new(0.0, 0.0, 20.0, 10.0));
442        assert_eq!(s, Rect::new(0.25, 0.0, 0.5, 1.0));
443    }
444}