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    /// Nine-slice border in pixels when stretched (0 = scale the whole image).
175    pub border: f32,
176}
177
178impl Sprite {
179    /// Natural size on a host with (`true`) or without the GUI sprite atlas.
180    pub fn size(&self, atlas: bool) -> Vec2 {
181        match (atlas, &self.legacy) {
182            (false, Some(l)) => Vec2::new(l.region.w, l.region.h),
183            _ => self.size,
184        }
185    }
186
187    /// Drawn by Yog itself (no texture on any version) — the inventory slot.
188    pub fn is_procedural(&self) -> bool {
189        self.atlas.is_none() && self.legacy.is_none()
190    }
191}
192
193/// Looks up a [`SPRITES`] entry by its Yog name.
194pub fn sprite(name: &str) -> Option<&'static Sprite> {
195    SPRITES.iter().find(|s| s.name == name.trim())
196}
197
198/// Looks up a [`SPRITES`] entry by its 1.20.2+ atlas id (`widget/button`).
199pub fn sprite_by_atlas(atlas: &str) -> Option<&'static Sprite> {
200    SPRITES.iter().find(|s| s.atlas.as_deref() == Some(atlas))
201}
202
203/// The texture of GUI-atlas sprite `atlas` of namespace `ns`:
204/// `minecraft:textures/gui/sprites/<atlas>.png`.
205pub fn atlas_texture(ns: &str, atlas: &str) -> String {
206    yog_ids::join(ns, &yog_ids::texture_file(&format!("{}{atlas}", yog_ids::res::GUI_SPRITES)))
207}
208
209/// A sprite as `assets/sprites.ydata` writes it.
210#[derive(yog_data::Schema)]
211struct SpriteDef {
212    name: String,
213    description: String,
214    size: [f32; 2],
215    atlas: Option<String>,
216    legacy: Option<LegacyDef>,
217    #[data(default)]
218    border: f32,
219}
220
221#[derive(yog_data::Schema)]
222struct LegacyDef {
223    texture: String,
224    region: [f32; 4],
225    /// The texture's side, when it isn't [`LEGACY_TEX_SIZE`].
226    texture_size: Option<f32>,
227}
228
229/// Yog's GUI sprite names (`assets/sprites.ydata`).
230pub static SPRITES: LazyLock<Vec<Sprite>> = LazyLock::new(|| {
231    yog_data::from_text::<Vec<SpriteDef>>(include_str!("../assets/sprites.ydata"))
232        .unwrap_or_else(|e| panic!("yog-gfx's sprites: {e}"))
233        .value
234        .into_iter()
235        .map(|d| Sprite {
236            name: d.name,
237            description: d.description,
238            size: Vec2::new(d.size[0], d.size[1]),
239            atlas: d.atlas,
240            legacy: d.legacy.map(|l| {
241                let [u, v, w, h] = l.region;
242                let side = l.texture_size.unwrap_or(LEGACY_TEX_SIZE);
243                LegacyRegion { texture: l.texture, region: Rect::new(u, v, w, h), texture_size: Vec2::new(side, side) }
244            }),
245            border: d.border,
246        })
247        .collect()
248});
249
250/// How an image fills the box it's given — WPF's `Stretch`.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
252#[repr(u8)]
253pub enum Stretch {
254    /// Natural size, centered; cut to the box when larger.
255    None = 0,
256    /// The whole box (aspect ratio not kept).
257    Fill = 1,
258    /// As large as fits, aspect ratio kept, centered.
259    #[default]
260    Uniform = 2,
261    /// Covers the box, aspect ratio kept, centered; the overflow is cut.
262    UniformToFill = 3,
263}
264
265/// Where an image of size `natural` goes in the box `bx` under `stretch`:
266/// `(dest, src)` — the destination rect and the part of the image shown, as
267/// a rect in fractions of the image (less than the whole for
268/// `None`/`UniformToFill` images bigger than the box).
269pub fn fit(stretch: Stretch, natural: Vec2, bx: Rect) -> (Rect, Rect) {
270    let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
271    if natural.x <= 0.0 || natural.y <= 0.0 || bx.w <= 0.0 || bx.h <= 0.0 {
272        return (Rect::new(bx.x, bx.y, 0.0, 0.0), whole);
273    }
274    let scale = match stretch {
275        Stretch::Fill => return (bx, whole),
276        Stretch::None => 1.0,
277        Stretch::Uniform => (bx.w / natural.x).min(bx.h / natural.y),
278        Stretch::UniformToFill => (bx.w / natural.x).max(bx.h / natural.y),
279    };
280    let d = natural * scale;
281    // Per axis: centered when it fits, else cut evenly on both sides.
282    let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
283        if d <= size {
284            ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
285        } else {
286            let cut = (d - size) / 2.0 / d;
287            (pos, size, cut, 1.0 - 2.0 * cut)
288        }
289    };
290    let (dx, dw, u, uw) = axis(bx.x, bx.w, d.x);
291    let (dy, dh, v, vh) = axis(bx.y, bx.h, d.y);
292    (Rect::new(dx, dy, dw, dh), Rect::new(u, v, uw, vh))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn parses_sources() {
301        assert_eq!(
302            ImageSource::parse("item:apple"),
303            Some(ImageSource::Item("minecraft:apple".into()))
304        );
305        assert_eq!(
306            ImageSource::parse("item:mymod:wand"),
307            Some(ImageSource::Item("mymod:wand".into()))
308        );
309        assert_eq!(
310            ImageSource::parse("block:stone"),
311            Some(ImageSource::Block("minecraft:stone".into()))
312        );
313        assert_eq!(
314            ImageSource::parse("sprite:button"),
315            Some(ImageSource::Sprite("button".into()))
316        );
317        assert_eq!(
318            ImageSource::parse("mymod:gui/logo"),
319            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
320        );
321        assert_eq!(
322            ImageSource::parse("minecraft:textures/item/apple.png"),
323            Some(ImageSource::Texture(
324                "minecraft:textures/item/apple.png".into()
325            ))
326        );
327        assert_eq!(
328            ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
329            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
330        );
331        assert_eq!(
332            ImageSource::parse("mymod:gui/logo.svg"),
333            Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
334        );
335        assert_eq!(
336            ImageSource::parse("mymod:textures/gui/logo.svg"),
337            Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
338        );
339        assert_eq!(ImageSource::parse(""), None);
340        assert_eq!(ImageSource::parse("apple"), None);
341        assert_eq!(ImageSource::parse("item:"), None);
342    }
343
344    #[test]
345    fn suggests_aliases_for_raw_paths() {
346        assert_eq!(
347            suggest_alias("minecraft:textures/item/apple.png").as_deref(),
348            Some("item:apple")
349        );
350        assert_eq!(
351            suggest_alias("mymod:textures/block/ore.png").as_deref(),
352            Some("block:mymod:ore")
353        );
354        assert_eq!(
355            suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
356            Some("minecraft:gui/widgets")
357        );
358        assert_eq!(
359            suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
360            Some("sprite:button")
361        );
362        assert_eq!(
363            suggest_alias("mymod:textures/gui/logo.png").as_deref(),
364            Some("mymod:gui/logo")
365        );
366        assert_eq!(suggest_alias("item:apple"), None);
367    }
368
369    #[test]
370    fn sprite_names_are_unique() {
371        for (i, s) in SPRITES.iter().enumerate() {
372            assert!(
373                SPRITES[i + 1..].iter().all(|o| o.name != s.name),
374                "{}",
375                s.name
376            );
377        }
378        assert!(sprite("slot").unwrap().is_procedural());
379        assert_eq!(
380            sprite("hotbar_selection").unwrap().size(false),
381            Vec2::new(24.0, 24.0)
382        );
383        assert_eq!(sprite("hotbar_selection").unwrap().size(true), Vec2::new(24.0, 23.0));
384    }
385
386    #[test]
387    fn fits_like_wpf() {
388        let icon = Vec2::new(16.0, 16.0);
389        let bx = Rect::new(0.0, 0.0, 156.0, 29.0);
390        let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
391        // 16×16 into 156×29.
392        assert_eq!(fit(Stretch::Uniform, icon, bx), (Rect::new(63.0, 0.0, 29.0, 29.0), whole));
393        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));
394        assert_eq!(fit(Stretch::None, icon, bx).0, Rect::new(70.0, 6.0, 16.0, 16.0));
395        let (d, s) = fit(Stretch::UniformToFill, icon, bx);
396        assert_eq!(d, bx);
397        assert!((s.y - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
398        // None, bigger than the box: cut to it.
399        let (d, s) = fit(Stretch::None, Vec2::new(40.0, 10.0), Rect::new(0.0, 0.0, 20.0, 10.0));
400        assert_eq!(d, Rect::new(0.0, 0.0, 20.0, 10.0));
401        assert_eq!(s, Rect::new(0.25, 0.0, 0.5, 1.0));
402    }
403}