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}
42
43/// Side of the square 1.20.1 GUI textures most legacy regions live in.
44const LEGACY_TEX_SIZE: f32 = 256.0;
45
46/// `<ns>` + `<path>` → the texture resource id: `gui/logo` →
47/// `<ns>:textures/gui/logo.png`, `gui/logo.svg` →
48/// `<ns>:textures/gui/logo.svg` (an SVG image); paths already under
49/// `textures/` (or ending in `.png`) are kept as they are.
50pub 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
65/// A texture named either way — `ns:textures/x.png` or `ns:x` (`x` alone is
66/// the game's) — as the short source `ns:x`.
67pub 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
73/// The schemes an image source starts with (`item:apple`).
74pub use yog_ids::image_scheme as scheme;
75
76/// The source as written: `item:minecraft:apple`, `sprite:button`; a
77/// texture as its resource id.
78impl 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    /// Parses a source string; `None` for an empty or malformed one.
92    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    /// Resolves `mod:<path>` against `mod_id` (others are unchanged).
109    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
117/// `<ns>:textures/<path>.png` → the Yog source a `.yui` file or a mod should
118/// use instead: `item:` for item textures, `block:` for block textures, a
119/// `sprite:` with the same image, or the short `<ns>:<path>` form. `None`
120/// when `id` isn't a raw texture id.
121pub 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    // `minecraft:` is the default namespace of every alias.
126    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/// A region of a texture on Minecraft 1.20.1 (before the GUI sprite atlas).
149#[derive(Debug, Clone, PartialEq)]
150pub struct LegacyRegion {
151    /// Texture resource id.
152    pub texture: String,
153    /// The region, in the texture's pixels.
154    pub region: Rect,
155    /// The whole texture's size.
156    pub texture_size: Vec2,
157}
158
159/// One entry of Yog's GUI sprite table ([`SPRITES`]).
160#[derive(Debug, Clone, PartialEq)]
161pub struct Sprite {
162    /// The stable Yog name (`sprite:<name>`).
163    pub name: String,
164    /// What it is, for editors' pickers.
165    pub description: String,
166    /// Natural size in GUI pixels (1.20.2+; 1.20.1 uses the legacy region's).
167    pub size: Vec2,
168    /// GUI-atlas sprite id on Minecraft 1.20.2+ (`minecraft:<id>`, i.e.
169    /// `textures/gui/sprites/<id>.png`).
170    pub atlas: Option<String>,
171    /// Where it lives on 1.20.1.
172    pub legacy: Option<LegacyRegion>,
173    /// Yog's own picture of it (`yog:textures/gui/…`), for a version that has neither.
174    pub own: Option<LegacyRegion>,
175    /// Nine-slice border in pixels when stretched (0 = scale the whole image).
176    pub border: f32,
177}
178
179impl Sprite {
180    /// Natural size on a host with (`true`) or without the GUI sprite atlas.
181    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    /// Drawn by Yog itself (no texture on any version) — the inventory slot.
189    pub fn is_procedural(&self) -> bool {
190        self.atlas.is_none() && self.legacy.is_none() && self.own.is_none()
191    }
192
193    /// Where it may come from, in the order a host tries them: the GUI
194    /// atlas's sprite (`minecraft:<id>`), the game's texture region, Yog's
195    /// own picture. The host draws it from the first its game has.
196    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/// One place a sprite may come from (see [`Sprite::sources`]).
212#[derive(Debug, Clone, PartialEq)]
213pub enum SpriteSource<'a> {
214    /// A GUI-atlas sprite, by full id (`minecraft:widget/button`).
215    Atlas(String),
216    /// A region of a texture.
217    Region(&'a LegacyRegion),
218}
219
220/// Yog's own assets (`yog:textures/gui/…`, `yog:models/item/…`), shipped in this crate's `res/`
221/// (a resource pack's layout: the hosts take the folder as resources).
222const 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
241/// The bytes of Yog's own asset `path` (`textures/gui/panel.png`, `models/item/spawn_egg.json`), if it has one.
242pub fn own_asset(path: &str) -> Option<&'static [u8]> {
243    OWN.iter().find(|(p, _)| *p == path).map(|(_, b)| *b)
244}
245
246/// Looks up a [`SPRITES`] entry by its Yog name.
247pub fn sprite(name: &str) -> Option<&'static Sprite> {
248    SPRITES.iter().find(|s| s.name == name.trim())
249}
250
251/// Looks up a [`SPRITES`] entry by its 1.20.2+ atlas id (`widget/button`).
252pub fn sprite_by_atlas(atlas: &str) -> Option<&'static Sprite> {
253    SPRITES.iter().find(|s| s.atlas.as_deref() == Some(atlas))
254}
255
256/// The texture of GUI-atlas sprite `atlas` of namespace `ns`:
257/// `minecraft:textures/gui/sprites/<atlas>.png`.
258pub 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/// A sprite as `assets/sprites.ydata` writes it.
266#[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    /// The texture's side, when it isn't [`LEGACY_TEX_SIZE`].
283    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
296/// Yog's GUI sprite names (`assets/sprites.ydata`).
297pub 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/// How an image fills the box it's given — WPF's `Stretch`.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
317#[repr(u8)]
318pub enum Stretch {
319    /// Natural size, centered; cut to the box when larger.
320    None = 0,
321    /// The whole box (aspect ratio not kept).
322    Fill = 1,
323    /// As large as fits, aspect ratio kept, centered.
324    #[default]
325    Uniform = 2,
326    /// Covers the box, aspect ratio kept, centered; the overflow is cut.
327    UniformToFill = 3,
328}
329
330impl Stretch {
331    /// Every stretch, with its name in `.yui` (`stretch="Fill"`).
332    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    /// Its name in `.yui`.
340    pub fn name(self) -> &'static str {
341        Self::ALL
342            .iter()
343            .find(|(s, _)| *s == self)
344            .map_or("", |(_, n)| n)
345    }
346
347    /// A stretch by its name, case and `_` ignored (`fill`, `uniform_to_fill`).
348    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
357/// A picture's `region` (`[u, v, width, height]` in its pixels) stretched into
358/// `dest` (`[x, y, w, h]`) with its `border` kept as it is: the pieces to
359/// draw, each (destination, region) — nine, or the whole when it isn't
360/// stretched or has no border.
361pub 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    // Columns / rows: (where, how long, from, how long there).
369    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
388/// Where an image of size `natural` goes in the box `bx` under `stretch`:
389/// `(dest, src)` — the destination rect and the part of the image shown, as
390/// a rect in fractions of the image (less than the whole for
391/// `None`/`UniformToFill` images bigger than the box).
392pub 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    // Per axis: centered when it fits, else cut evenly on both sides.
405    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        // 16×16 into 156×29.
525        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        // None, bigger than the box: cut to it.
541        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}