1use 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
33pub 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
44pub 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
54pub 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
67pub 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
77fn 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
93pub 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
118fn 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
139fn atlas_image(id: &str) -> Option<Arc<Image>> {
141 let (ns, path) = yog_ids::split(id);
142 texture(&atlas_texture(ns, path))
143}
144
145fn 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
153fn item_texture(id: &str, block: bool) -> Option<Arc<Image>> {
156 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
175fn 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
190fn 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
211use yog_book::renderer::{BLOCK_PITCH as SLOT_PITCH, SLOT_YAW};
213const HEAD_MARGIN: f32 = 0.1;
215
216pub 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
231fn tint(c: u32) -> [f32; 4] {
233 Color(c).to_f32()
234}
235
236fn 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
255fn 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
282fn 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
292fn 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 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 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 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
348const SVG_OVERSAMPLE: f32 = 4.0;
350
351fn 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
371fn 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
383pub 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
395const ENTITY_EGG_MAX: f32 = 2.0 * yog_ui::ICON_SIZE;
397
398pub 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
408pub 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#[derive(Debug, Clone, serde::Serialize)]
461pub struct Candidate {
462 pub source: String,
464 pub group: String,
466 pub detail: String,
467}
468
469pub 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#[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
519const MIN_PREVIEW: u32 = 8;
521const MAX_PREVIEW: u32 = 256;
522const 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 #[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 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}