Skip to main content

yog_cli/design/
images.rs

1//! `<Image>` in the designer: Yog image sources (`item:apple`,
2//! `sprite:button`, `mod:gui/logo`, `mymod:gui/logo` — see
3//! `yog_gfx::image`) resolved against the local game files and the mod's
4//! `assets/`, the way the Yog runtime resolves them in game: yog-ui's
5//! `Image` widget lays out and fits the picture, then calls `draw2d_image`
6//! / `image_size`, which the recording table (`gfx`) answers from here.
7//! Also the Properties window's `source` picker ([`candidates`],
8//! [`preview`]).
9
10use std::cell::RefCell;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use yog_gfx::{Color, Rect, Vec2};
15use yog_gfx::image::{
16    ImageSource, LegacyRegion, SPRITES, Sprite, SpriteSource, Stretch, atlas_texture, fit, sprite as find_sprite,
17};
18use yog_ui::widget::{self, Widget};
19
20use super::assets::{Image, Resources};
21use super::markup::{YuiNode, color};
22use super::raster::Canvas;
23
24struct Doc {
25    res: Resources,
26    mod_id: Option<String>,
27}
28
29thread_local! {
30    static DOC: RefCell<Option<Doc>> = const { RefCell::new(None) };
31}
32
33/// `[mod] id` from `<root>/yog.toml`.
34/// The `.yui` being laid out and drawn: its mod's resources (images, fonts)
35/// and id (for `mod:` sources). Set before every layout.
36pub fn set_document(mod_root: Option<&Path>) {
37    let doc = Doc {
38        res: Resources::for_mod(mod_root),
39        mod_id: mod_root.and_then(crate::design::project::mod_id),
40    };
41    DOC.with(|d| *d.borrow_mut() = Some(doc));
42}
43
44/// The current document's resources (vanilla only without one).
45pub fn resources() -> Resources {
46    DOC.with(|d| d.borrow().as_ref().map(|d| d.res.clone()))
47        .unwrap_or_else(|| Resources::for_mod(None))
48}
49
50fn with_doc<R>(f: impl FnOnce(Option<&Doc>) -> R) -> R {
51    DOC.with(|d| f(d.borrow().as_ref()))
52}
53
54/// `mod:<path>` → `<mod id>:<path>` (what `yog build` writes into the
55/// generated code); other sources unchanged.
56pub fn resolve_mod(source: &str) -> String {
57    let source = source.trim();
58    match source.strip_prefix("mod:") {
59        Some(path) => match with_doc(|d| d.and_then(|d| d.mod_id.clone())) {
60            Some(id) => format!("{id}:{path}"),
61            None => source.to_string(),
62        },
63        None => source.to_string(),
64    }
65}
66
67/// `stretch` attribute → [`Stretch`] (case-insensitive, like `yog build`).
68pub fn stretch(v: &str) -> Stretch {
69    match v.trim().to_ascii_lowercase().as_str() {
70        "none" => Stretch::None,
71        "fill" => Stretch::Fill,
72        "uniformtofill" | "uniform_to_fill" => Stretch::UniformToFill,
73        _ => Stretch::Uniform,
74    }
75}
76
77/// `uv="u v w h"`.
78fn uv(v: &str) -> Option<[f32; 4]> {
79    let p: Vec<f32> = v
80        .split([' ', ','])
81        .filter(|s| !s.is_empty())
82        .map(|s| s.parse().ok())
83        .collect::<Option<_>>()?;
84    <[f32; 4]>::try_from(p)
85        .ok()
86        .filter(|r| r[2] > 0.0 && r[3] > 0.0)
87}
88
89fn rect([x, y, w, h]: [f32; 4]) -> Rect {
90    Rect::new(x, y, w, h)
91}
92
93/// The widget for an `<Image>` — what the generated code builds: `source`,
94/// `stretch`, `uv`, `tint`.
95pub fn widget(node: &YuiNode) -> Widget {
96    let a = |k: &str| node.attr(k);
97    let source = a(yog_ids::yui_attr::SOURCE).unwrap_or_default();
98    let mut w = widget::image(resolve_mod(source));
99    if let Some(s) = a(yog_ids::yui_attr::STRETCH) {
100        w = w.stretch(stretch(s));
101    }
102    if let Some(r) = a(yog_ids::yui_attr::UV).and_then(uv) {
103        w = w.region(rect(r));
104    }
105    if let Some(t) = a(yog_ids::yui_attr::TINT).and_then(color) {
106        w = w.tint(Color(t));
107    }
108    w
109}
110
111fn texture(id: &str) -> Option<Arc<Image>> {
112    if let Some(sheet) = book_sheet(id) {
113        return Some(sheet);
114    }
115    with_doc(|d| d.and_then(|d| d.res.texture(id)))
116}
117
118/// The book's sprite sheets — shipped in the loader's jars, embedded in
119/// yog-book (see `yog_book::BOOK_SHEET`).
120fn book_sheet(id: &str) -> Option<Arc<Image>> {
121    static SHEETS: std::sync::OnceLock<[Option<Arc<Image>>; 2]> = std::sync::OnceLock::new();
122    let [book, crafting] = SHEETS.get_or_init(|| {
123        [
124            super::assets::decode_png(yog_book::BOOK_PNG).map(Arc::new),
125            super::assets::decode_png(yog_book::CRAFTING_PNG).map(Arc::new),
126        ]
127    });
128    let texture = |sheet: &str| match ImageSource::parse(sheet) {
129        Some(ImageSource::Texture(t)) => t,
130        _ => String::new(),
131    };
132    match id {
133        _ if id == texture(yog_book::BOOK_SHEET) => book.clone(),
134        _ if id == texture(yog_book::CRAFTING_SHEET) => crafting.clone(),
135        _ => None,
136    }
137}
138
139/// A GUI-atlas sprite's picture (`minecraft:widget/button` → its png).
140fn atlas_image(id: &str) -> Option<Arc<Image>> {
141    let (ns, path) = yog_ids::split(id);
142    texture(&atlas_texture(ns, path))
143}
144
145/// The source of `sp` the game files (or Yog) have — the first, as the host picks it.
146fn resolve(sp: &Sprite) -> Option<SpriteSource<'_>> {
147    sp.sources().into_iter().find(|s| match s {
148        SpriteSource::Atlas(id) => atlas_image(id).is_some(),
149        SpriteSource::Region(r) => texture(&r.texture).is_some(),
150    })
151}
152
153/// The texture an item/block icon is drawn with (the designer doesn't run
154/// the 3D item renderer: block items show a face of the block).
155fn item_texture(id: &str, block: bool) -> Option<Arc<Image>> {
156    // Its item model's layers, when it has some.
157    if !block
158        && let Some(super::item_models::Look::Flat(img)) = with_doc(|d| d.and_then(|d| super::item_models::look(&d.res, id)))
159    {
160        return Some(img);
161    }
162    let (ns, name) = yog_ids::split(id);
163    let item = format!("{ns}:textures/item/{name}.png");
164    let blocks =
165        ["", "_front", "_side", "_top"].map(|s| format!("{ns}:textures/block/{name}{s}.png"));
166    let mut order: Vec<&String> = blocks.iter().collect();
167    if block {
168        order.push(&item);
169    } else {
170        order.insert(0, &item);
171    }
172    order.into_iter().find_map(|t| texture(t)).or_else(|| content_icon(id))
173}
174
175/// A mod item's icon from its `content/` file (cached per id).
176fn content_icon(id: &str) -> Option<Arc<Image>> {
177    type Cache = std::sync::Mutex<std::collections::HashMap<(PathBuf, String), Option<Arc<Image>>>>;
178    static CACHE: std::sync::OnceLock<Cache> = std::sync::OnceLock::new();
179    let root = with_doc(|d| d.and_then(|d| d.res.mod_root.clone()))?;
180    let key = (root.clone(), id.to_string());
181    let cache = CACHE.get_or_init(Default::default);
182    if let Some(hit) = cache.lock().ok()?.get(&key) {
183        return hit.clone();
184    }
185    let img = crate::design::content::content_icon(&root, id).and_then(|b| super::assets::decode_png(&b)).map(Arc::new);
186    cache.lock().ok()?.insert(key, img.clone());
187    img
188}
189
190/// A block item as the game shows it in a slot (the block in 3D, turned
191/// 225°, tilted 30°), or a head (its mob's, a little smaller). `false`:
192/// a flat item. As in the game, the item's model decides: one drawn from
193/// texture layers is flat even when there's a block of the same id (a
194/// brewing stand, a door, a cauldron).
195fn item_3d(c: &mut Canvas, id: &str, [x, y, w, h]: [f32; 4]) -> bool {
196    match super::item_models::look(c.resources(), id) {
197        Some(super::item_models::Look::Flat(_)) => false,
198        Some(super::item_models::Look::Head(faces)) => {
199            let m = w.min(h) * HEAD_MARGIN;
200            super::blocks3d::draw_cube(c, faces, [x + m, y + m, w - 2.0 * m, h - 2.0 * m], SLOT_YAW, SLOT_PITCH);
201            true
202        }
203        None if super::blocks3d::is_block(c.resources(), id) => {
204            super::blocks3d::draw(c, &format!("0 0 0 {id}"), [x, y, w, h], SLOT_YAW, SLOT_PITCH);
205            true
206        }
207        None => false,
208    }
209}
210
211/// How a block is turned and tilted in a slot (the game's GUI display; the book's).
212use yog_book::renderer::{BLOCK_PITCH as SLOT_PITCH, SLOT_YAW};
213/// How much smaller than the slot a head is, on each side.
214const HEAD_MARGIN: f32 = 0.1;
215
216/// Natural size of `source` in GUI pixels (`image_size`); `None` when it
217/// can't be resolved.
218pub fn source_size(source: &str) -> Option<(f32, f32)> {
219    match ImageSource::parse(source)? {
220        ImageSource::Item(_) | ImageSource::Block(_) => Some((yog_ui::ICON_SIZE, yog_ui::ICON_SIZE)),
221        ImageSource::Sprite(name) => find_sprite(&name).map(|s| match resolve(s) {
222            Some(SpriteSource::Region(r)) => (r.region.w, r.region.h),
223            _ => (s.size.x, s.size.y),
224        }),
225        ImageSource::Texture(id) if id.ends_with(".svg") => svg_document(&id).and_then(|d| svg_size(&d)),
226        ImageSource::Texture(id) => texture(&id).map(|i| (i.width as f32, i.height as f32)),
227        ImageSource::ModPath(_) => None,
228    }
229}
230
231/// `0xAARRGGBB` → RGBA multipliers.
232fn tint(c: u32) -> [f32; 4] {
233    Color(c).to_f32()
234}
235
236/// `img` pixels `src` (`u v w h`) into GUI `dest` (`x y w h`).
237fn blit_px(c: &mut Canvas, img: &Image, dest: [f32; 4], src: [f32; 4], t: u32) {
238    let [x, y, w, h] = dest;
239    let [u, v, uw, vh] = src;
240    if w <= 0.0 || h <= 0.0 || uw <= 0.0 || vh <= 0.0 {
241        return;
242    }
243    let (iw, ih) = (img.width as f32, img.height as f32);
244    c.blit(
245        img,
246        x,
247        y,
248        w,
249        h,
250        [u / iw, v / ih, (u + uw) / iw, (v + vh) / ih],
251        tint(t),
252    );
253}
254
255/// A texture region, nine-sliced by `border` when stretched (the runtime's
256/// legacy-sprite drawing).
257fn nine_slice(c: &mut Canvas, img: &Image, region: [f32; 4], border: f32, dest: [f32; 4], t: u32) {
258    let [x, y, w, h] = dest;
259    let [u, v, rw, rh] = region;
260    let b = border.min(rw / 2.0).min(rh / 2.0).min(w / 2.0).min(h / 2.0);
261    if b <= 0.0 || (w == rw && h == rh) {
262        blit_px(c, img, dest, region, t);
263        return;
264    }
265    let cols = [
266        (x, b, u, b),
267        (x + b, w - 2.0 * b, u + b, rw - 2.0 * b),
268        (x + w - b, b, u + rw - b, b),
269    ];
270    let rows = [
271        (y, b, v, b),
272        (y + b, h - 2.0 * b, v + b, rh - 2.0 * b),
273        (y + h - b, b, v + rh - b, b),
274    ];
275    for (dy, dh, sv, sh) in rows {
276        for (dx, dw, su, sw) in cols {
277            blit_px(c, img, [dx, dy, dw, dh], [su, sv, sw, sh], t);
278        }
279    }
280}
281
282/// The inventory slot (drawn by Yog on every version).
283fn slot(c: &mut Canvas, [x, y, w, h]: [f32; 4]) {
284    let (face, shadow, light) = (yog_ui::INV_SLOT_FACE.0, yog_ui::INV_SLOT_SHADOW.0, yog_ui::INV_SLOT_LIGHT.0);
285    c.fill(x, y, x + w, y + h, face, face);
286    c.fill(x, y, x + w, y + 1.0, shadow, shadow);
287    c.fill(x, y, x + 1.0, y + h, shadow, shadow);
288    c.fill(x, y + h - 1.0, x + w, y + h, light, light);
289    c.fill(x + w - 1.0, y, x + w, y + h, light, light);
290}
291
292/// Sprite `sp` into `dest`: nine-sliced by its border, or — `part` a region
293/// of it in its own pixels (`w`, `h` > 0) — that part, scaled.
294fn sprite(c: &mut Canvas, sp: &Sprite, dest: [f32; 4], part: [f32; 4], t: u32) {
295    let whole = part[2] <= 0.0 || part[3] <= 0.0;
296    // `src` (a region of the image whose sprite is `natural` pixels wide) drawn whole or in part.
297    let mut put = |img: &Image, src: [f32; 4], natural: f32, border: f32| {
298        if whole {
299            nine_slice(c, img, src, border, dest, t);
300        } else {
301            let k = src[2] / natural;
302            blit_px(c, img, dest, [src[0] + part[0] * k, src[1] + part[1] * k, part[2] * k, part[3] * k], t);
303        }
304    };
305    if sp.is_procedural() {
306        slot(c, dest);
307        return;
308    }
309    let source = resolve(sp);
310    if let Some(SpriteSource::Atlas(id)) = &source
311        && let Some(img) = atlas_image(id)
312    {
313        // Animated sprites stack their frames vertically: the first one.
314        let size = sp.size;
315        let frame_h = if img.height > img.width && size.y <= size.x {
316            img.width as f32 * size.y / size.x
317        } else {
318            img.height as f32
319        };
320        put(&img, [0.0, 0.0, img.width as f32, frame_h], size.x, sp.border);
321        return;
322    }
323    if let Some(SpriteSource::Region(LegacyRegion {
324        texture: tex,
325        region,
326        texture_size,
327    })) = source
328        && let Some(img) = texture(tex)
329    {
330        // Resource packs may ship the sheet at a higher resolution.
331        let k = img.width as f32 / texture_size.x;
332        put(&img, [region.x * k, region.y * k, region.w * k, region.h * k], region.w, sp.border * k);
333        return;
334    }
335    missing(c, dest);
336}
337
338fn missing(c: &mut Canvas, dest: [f32; 4]) {
339    blit_px(
340        c,
341        &super::assets::missing_image(),
342        dest,
343        [0.0, 0.0, yog_ui::ICON_SIZE, yog_ui::ICON_SIZE],
344        Color::WHITE.0,
345    );
346}
347
348/// Physical pixels an SVG is rasterized at per GUI pixel.
349const SVG_OVERSAMPLE: f32 = 4.0;
350
351/// An SVG document as straight-alpha RGBA, `w`×`h` — as the runtime
352/// rasterizes it in game.
353fn svg_image(data: &str, w: u32, h: u32) -> Option<Image> {
354    let tree = resvg::usvg::Tree::from_str(data, &resvg::usvg::Options::default()).ok()?;
355    let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)?;
356    let size = tree.size();
357    let t = resvg::tiny_skia::Transform::from_scale(w as f32 / size.width(), h as f32 / size.height());
358    resvg::render(&tree, t, &mut pixmap.as_mut());
359    let mut rgba = pixmap.take();
360    for p in rgba.as_chunks_mut::<4>().0 {
361        let a = p[3] as u32;
362        if a > 0 && a < 255 {
363            for c in &mut p[..3] {
364                *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
365            }
366        }
367    }
368    Some(Image { width: w, height: h, rgba })
369}
370
371/// The document of an `.svg` texture id (`ns:textures/x.svg`) of the mod.
372fn svg_document(id: &str) -> Option<String> {
373    let (ns, path) = yog_ids::split(id);
374    let bytes = with_doc(|d| d.and_then(|d| d.res.read(ns, path)))?;
375    String::from_utf8(bytes).ok()
376}
377
378fn svg_size(data: &str) -> Option<(f32, f32)> {
379    let tree = resvg::usvg::Tree::from_str(data, &resvg::usvg::Options::default()).ok()?;
380    Some((tree.size().width(), tree.size().height()))
381}
382
383/// `draw2d_svg`: `data` into `dest`.
384pub fn draw_svg(c: &mut Canvas, data: &str, dest: [f32; 4], t: u32) {
385    if dest[2] <= 0.0 || dest[3] <= 0.0 {
386        return;
387    }
388    let side = |v: f32| ((v * SVG_OVERSAMPLE).ceil() as u32).clamp(1, 2048);
389    match svg_image(data, side(dest[2]), side(dest[3])) {
390        Some(img) => blit_px(c, &img, dest, [0.0, 0.0, img.width as f32, img.height as f32], t),
391        None => missing(c, dest),
392    }
393}
394
395/// An entity's spawn egg is drawn at most this big (two icons).
396const ENTITY_EGG_MAX: f32 = 2.0 * yog_ui::ICON_SIZE;
397
398/// `draw2d_entity`: the designer has no entity models — the entity's spawn
399/// egg, in the middle of its box.
400pub fn draw_entity(c: &mut Canvas, entity: &str, [x, y, w, h]: [f32; 4]) {
401    let (ns, path) = yog_ids::split(entity);
402    let side = w.min(h).min(ENTITY_EGG_MAX);
403    let dest = [x + (w - side) / 2.0, y + (h - side) / 2.0, side, side];
404    let egg = yog_ids::join(ns, &format!("{path}{}", yog_ids::res::SPAWN_EGG_SUFFIX));
405    draw(c, &yog_ids::join(yog_ids::image_scheme::ITEM, &egg), dest, [0.0; 4], Color::WHITE.0);
406}
407
408/// `draw2d_image`: `source` into `dest` (`x y w h`, already fitted by
409/// yog-ui); `region` `u v w h` in image pixels (`w <= 0`: whole image).
410pub fn draw(c: &mut Canvas, source: &str, dest: [f32; 4], region: [f32; 4], t: u32) {
411    if dest[2] <= 0.0 || dest[3] <= 0.0 {
412        return;
413    }
414    if let Some(ImageSource::Texture(id)) = ImageSource::parse(source)
415        && id.ends_with(".svg")
416    {
417        match svg_document(&id) {
418            Some(doc) => draw_svg(c, &doc, dest, t),
419            None => missing(c, dest),
420        }
421        return;
422    }
423    let first_frame = |img: &Image| [0.0, 0.0, img.width as f32, img.width.min(img.height) as f32];
424    match ImageSource::parse(source) {
425        Some(ImageSource::Item(id)) if item_3d(c, &id, dest) => {}
426        Some(ImageSource::Item(id)) => match item_texture(&id, false) {
427            Some(img) => blit_px(c, &img, dest, first_frame(&img), Color::WHITE.0),
428            None => missing(c, dest),
429        },
430        Some(ImageSource::Block(id)) => match item_texture(&id, true) {
431            Some(img) => blit_px(c, &img, dest, first_frame(&img), Color::WHITE.0),
432            None => missing(c, dest),
433        },
434        Some(ImageSource::Sprite(name)) => match find_sprite(&name) {
435            Some(sp) => sprite(c, sp, dest, region, t),
436            None => match name
437                .split_once(':')
438                .and_then(|(ns, p)| texture(&atlas_texture(ns, p)))
439            {
440                Some(img) => blit_px(c, &img, dest, [0.0, 0.0, img.width as f32, img.height as f32], t),
441                None => missing(c, dest),
442            },
443        },
444        Some(ImageSource::Texture(id)) => match texture(&id) {
445            Some(img) => {
446                let src = if region[2] > 0.0 && region[3] > 0.0 {
447                    region
448                } else {
449                    [0.0, 0.0, img.width as f32, img.height as f32]
450                };
451                blit_px(c, &img, dest, src, t);
452            }
453            None => missing(c, dest),
454        },
455        Some(ImageSource::ModPath(_)) | None => missing(c, dest),
456    }
457}
458
459/// An alias the Properties picker offers.
460#[derive(Debug, Clone, serde::Serialize)]
461pub struct Candidate {
462    /// The source to write (`item:apple`, `sprite:button`, `mod:gui/logo`).
463    pub source: String,
464    /// `item`, `block`, `sprite` or `mod`.
465    pub group: String,
466    pub detail: String,
467}
468
469/// Everything the `source` picker offers for the `.yui` at `path`: the
470/// mod's own textures, the sprite table, then the game's items and blocks.
471pub fn candidates(path: &Path) -> Vec<Candidate> {
472    let root = super::mod_root(path);
473    let mut out = Vec::new();
474    if let Some(root) = &root {
475        out.extend(crate::media::pictures(root).into_iter().map(|p| Candidate {
476            source: ImageSource::ModPath(p.path()).to_string(),
477            group: "mod".into(),
478            detail: format!("@{}.{}", p.set, p.name),
479        }));
480    }
481    out.extend(SPRITES.iter().map(|s| Candidate {
482        source: format!("sprite:{}", s.name),
483        group: "sprite".into(),
484        detail: format!("{} ({}×{})", s.description, s.size.x, s.size.y),
485    }));
486    let res = Resources::for_mod(root.as_deref());
487    if let Some(v) = &res.vanilla {
488        let (items, blocks) = v.texture_names();
489        out.extend(items.into_iter().map(|n| Candidate {
490            source: yog_ids::join(yog_ids::image_scheme::ITEM, &n),
491            group: yog_ids::image_scheme::ITEM.into(),
492            detail: String::new(),
493        }));
494        out.extend(blocks.into_iter().map(|n| Candidate {
495            source: yog_ids::join(yog_ids::image_scheme::BLOCK, &n),
496            group: yog_ids::image_scheme::BLOCK.into(),
497            detail: String::new(),
498        }));
499    }
500    out
501}
502
503/// A preview of an image — the one way the IDE draws any image reference:
504/// its PNG data URL (`size` px, nearest-neighbour; `None` if it can't be
505/// drawn) and, for a raw resource path (`minecraft:textures/item/apple.png`),
506/// the alias to write instead (`item:apple`) — which is also what's drawn.
507#[derive(serde::Serialize)]
508pub struct Preview {
509    pub url: Option<String>,
510    pub alias: Option<String>,
511}
512
513pub fn preview(path: &Path, source: &str, size: u32) -> Preview {
514    let alias = yog_gfx::image::suggest_alias(source);
515    let url = draw_preview(path, alias.as_deref().unwrap_or(source), size);
516    Preview { url, alias }
517}
518
519/// The smallest and largest square preview.
520const MIN_PREVIEW: u32 = 8;
521const MAX_PREVIEW: u32 = 256;
522/// An item icon's side, the size of a source that doesn't say its own.
523const ICON_SIDE: f32 = 16.0;
524
525fn draw_preview(path: &Path, source: &str, size: u32) -> Option<String> {
526    use base64::Engine as _;
527    let root = super::mod_root(path);
528    set_document(root.as_deref());
529    let res = Resources::for_mod(root.as_deref());
530    let source = resolve_mod(source);
531    let (nw, nh) = source_size(&source).unwrap_or((ICON_SIDE, ICON_SIDE));
532    let size = size.clamp(MIN_PREVIEW, MAX_PREVIEW);
533    let (dest, _) = fit(Stretch::Uniform, Vec2::new(nw, nh), Rect::new(0.0, 0.0, size as f32, size as f32));
534    let mut c = Canvas::new(size, size, 1, &res);
535    let [x, y, w, h] = [dest.x, dest.y, dest.w, dest.h];
536    draw(&mut c, &source, [x, y, w, h], [0.0; 4], Color::WHITE.0);
537    let png = c.png().ok()?;
538    Some(format!(
539        "data:image/png;base64,{}",
540        base64::engine::general_purpose::STANDARD.encode(png)
541    ))
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548
549    /// `YOG_IMAGES_PNG=out.png cargo test --lib designer::images -- --ignored`:
550    /// every kind of source drawn with the local game files, for eyeballing.
551    #[test]
552    #[ignore]
553    fn sample_png() {
554        use base64::Engine as _;
555        let Ok(out) = std::env::var("YOG_IMAGES_PNG") else {
556            return;
557        };
558        let n = |uid: &str, attrs: &[(&str, &str)]| YuiNode {
559            uid: uid.into(),
560            kind: "Image".into(),
561            attrs: attrs
562                .iter()
563                .map(|(k, v)| (k.to_string(), v.to_string()))
564                .collect(),
565            children: vec![],
566        };
567        let mut root = n(
568            "s",
569            &[("width", "200"), ("height", "120"), ("bg", "#C0101010")],
570        );
571        root.kind = "Screen".into();
572        root.children = vec![
573            // w/h larger than the texture.
574            n(
575                "a",
576                &[
577                    ("x", "4"),
578                    ("y", "4"),
579                    ("w", "156"),
580                    ("h", "29"),
581                    ("source", "minecraft:item/apple"),
582                    ("bg", "#40FFFFFF"),
583                ],
584            ),
585            n(
586                "b",
587                &[
588                    ("x", "4"),
589                    ("y", "36"),
590                    ("w", "32"),
591                    ("h", "32"),
592                    ("source", "item:apple"),
593                ],
594            ),
595            n(
596                "c",
597                &[
598                    ("x", "40"),
599                    ("y", "36"),
600                    ("w", "32"),
601                    ("h", "32"),
602                    ("source", "block:crafting_table"),
603                ],
604            ),
605            n(
606                "d",
607                &[
608                    ("x", "76"),
609                    ("y", "36"),
610                    ("w", "100"),
611                    ("h", "20"),
612                    ("source", "sprite:button"),
613                    ("stretch", "Fill"),
614                ],
615            ),
616            n(
617                "e",
618                &[("x", "76"), ("y", "60"), ("source", "sprite:icon/heart")],
619            ),
620            n("f", &[("x", "90"), ("y", "60"), ("source", "sprite:slot")]),
621            n(
622                "g",
623                &[
624                    ("x", "4"),
625                    ("y", "72"),
626                    ("w", "64"),
627                    ("h", "40"),
628                    ("source", "item:diamond"),
629                    ("stretch", "Fill"),
630                    ("tint", "#FF80FF80"),
631                ],
632            ),
633        ];
634        let args = super::super::RenderArgs {
635            path: "/nowhere/src/x.yui".into(),
636            root,
637            screen_w: 200,
638            screen_h: 120,
639            scale: 4,
640            preview: false,
641        };
642        let r = super::super::render(&args).unwrap();
643        let b64 = r.image.trim_start_matches("data:image/png;base64,");
644        std::fs::write(
645            out,
646            base64::engine::general_purpose::STANDARD
647                .decode(b64)
648                .unwrap(),
649        )
650        .unwrap();
651        eprintln!("assets: {:?}", r.assets);
652    }
653}