use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde_json::{Value, json};
use yog_formats::texture::TextureFile;
use yog_ids::Format;
use yog_media::image::Image;
use super::Output;
use crate::yres::{RESOURCES_DIR, Refs};
#[derive(Default)]
pub struct Textures {
pub ids: BTreeSet<String>,
pub outputs: Vec<Output>,
pub packed: BTreeSet<String>,
}
const ATLAS_DIR: &str = "atlas";
const SINGLE: &str = "single";
const SPRITE: &str = "sprite";
const UNSTITCH: &str = "unstitch";
pub fn compile(root: &Path, mod_id: &str, refs: &Refs, errors: &mut Vec<String>) -> Textures {
let resources = root.join(RESOURCES_DIR);
let mut out = Textures::default();
let mut packed: BTreeMap<String, Vec<(String, Image)>> = BTreeMap::new();
let mut singles: BTreeMap<String, Vec<Value>> = BTreeMap::new();
for file in crate::yui::find_files(&resources, Format::Texture.ext()) {
let shown = super::display(root, &file);
let rel = file.strip_prefix(&resources).unwrap_or(&file).to_string_lossy().replace('\\', yog_ids::sep::PATH);
let Some(id) = yog_formats::texture::id_of(mod_id, &rel) else { continue };
let texture = match std::fs::read_to_string(&file).map_err(|e| e.to_string()).and_then(|t| yog_data::from_text::<TextureFile>(&t).map_err(|e| e.to_string())) {
Ok(r) => r.value,
Err(e) => {
errors.push(format!("{shown}: {e}"));
continue;
}
};
let reference = format!("{}.{}", texture.image.set, texture.image.key);
let Some(picture) = refs.pictures.get(&reference) else {
errors.push(format!("{shown}: image `@{reference}`: no such image in {RESOURCES_DIR}/*.{}", Format::Res.ext()));
continue;
};
if let Some(atlas) = &texture.atlas
&& yog_registry::Atlas::find(atlas).is_none()
{
let known: Vec<&str> = yog_registry::Atlas::game().iter().map(|a| a.id.as_str()).collect();
errors.push(format!("{shown}: atlas `{atlas}`: one of {}", known.join(", ")));
continue;
}
let atlas = texture.atlas.as_deref().map(yog_ids::qualify);
let sprite = {
let (ns, path) = yog_ids::split(&id);
let path = atlas.as_deref().and_then(yog_registry::Atlas::find).map_or(path, |a| a.sprite(path));
yog_ids::join(ns, path)
};
match (&atlas, &texture.animation) {
(Some(atlas), None) if !texture.blur && !texture.clamp => match yog_media::image::decode(&picture.png) {
Ok(img) => {
packed.entry(atlas.clone()).or_default().push((sprite.clone(), img));
out.packed.insert(picture.output.clone());
}
Err(e) => errors.push(format!("{shown}: {e}")),
},
_ => {
let (ns, path) = yog_ids::split(&id);
let target = format!("{}/{ns}/{}", yog_ids::pack::ASSETS, yog_ids::texture_file(path));
if let Some(meta) = texture.game_mcmeta() {
out.outputs.push((format!("{target}{}", yog_ids::res::MCMETA), super::pretty(&meta)));
}
out.outputs.push((target, picture.png.clone()));
if let Some(atlas) = atlas {
let mut single = json!({ "type": SINGLE, "resource": id });
if sprite != id {
single[SPRITE] = json!(sprite);
}
singles.entry(atlas).or_default().push(single);
}
}
}
out.ids.insert(id);
}
for atlas in packed.keys().chain(singles.keys()).cloned().collect::<BTreeSet<_>>() {
let mut sources = singles.remove(&atlas).unwrap_or_default();
if let Some(textures) = packed.remove(&atlas) {
let (sheet, regions) = pack(&textures);
let picture_id = yog_ids::join(mod_id, &format!("{ATLAS_DIR}{}{}", yog_ids::sep::PATH, yog_ids::split(&atlas).1));
match yog_media::image::encode_png(&sheet) {
Ok(png) => out.outputs.push((format!("{}/{mod_id}/{}", yog_ids::pack::ASSETS, yog_ids::texture_file(yog_ids::split(&picture_id).1)), png)),
Err(e) => errors.push(format!("atlas {atlas}: {e}")),
}
sources.insert(0, json!({
"type": UNSTITCH,
"resource": picture_id,
"divisor_x": sheet.width,
"divisor_y": sheet.height,
"regions": regions.iter().map(|(id, x, y, w, h)| json!({ "sprite": id, "x": x, "y": y, "width": w, "height": h })).collect::<Vec<_>>(),
}));
}
let (ns, path) = yog_ids::split(&atlas);
out.outputs.push((format!("{}/{ns}/{}{path}{}", yog_ids::pack::ASSETS, yog_ids::res::ATLASES, yog_ids::res::JSON), super::pretty(&json!({ "sources": sources }))));
}
out
}
fn pack(textures: &[(String, Image)]) -> (Image, Vec<(String, u32, u32, u32, u32)>) {
let mut order: Vec<&(String, Image)> = textures.iter().collect();
order.sort_by(|a, b| b.1.height.cmp(&a.1.height).then_with(|| a.0.cmp(&b.0)));
let area: u64 = textures.iter().map(|(_, i)| u64::from(i.width) * u64::from(i.height)).sum();
let widest = textures.iter().map(|(_, i)| i.width).max().unwrap_or(1);
let width = (area as f64).sqrt().ceil().max(f64::from(widest)) as u32;
let width = width.next_power_of_two();
let (mut x, mut y, mut row_h) = (0, 0, 0);
let mut regions = Vec::new();
for (id, img) in &order {
if x + img.width > width {
(x, y, row_h) = (0, y + row_h, 0);
}
regions.push((id.clone(), x, y, img.width, img.height));
x += img.width;
row_h = row_h.max(img.height);
}
let height = (y + row_h).max(1).next_power_of_two();
let mut sheet = Image::new(width, height);
for ((_, img), (_, rx, ry, _, _)) in order.iter().zip(®ions) {
for row in 0..img.height {
let from = (row * img.width * 4) as usize;
let to = (((ry + row) * width + rx) * 4) as usize;
sheet.rgba[to..to + (img.width * 4) as usize].copy_from_slice(&img.rgba[from..from + (img.width * 4) as usize]);
}
}
(sheet, regions)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::yres::Picture;
fn picture(w: u32, h: u32) -> Picture {
Picture { png: yog_media::image::encode_png(&Image { width: w, height: h, rgba: vec![255; (w * h * 4) as usize] }).unwrap(), output: String::new() }
}
#[test]
fn an_atlas_is_one_picture_and_an_animation_its_own() {
let dir = std::env::temp_dir().join(format!("yog-ytexture-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("resources/block")).unwrap();
std::fs::write(dir.join("resources/block/ruby.ytexture"), r#"{ image: "@Images.block.ruby", atlas: "minecraft:blocks" }"#).unwrap();
std::fs::write(dir.join("resources/block/opal.ytexture"), r#"{ image: "@Images.block.opal", atlas: "minecraft:blocks" }"#).unwrap();
std::fs::write(dir.join("resources/block/glow.ytexture"), r#"{ image: "@Images.block.glow", atlas: "minecraft:blocks", animation: { frame_time: 3 } }"#).unwrap();
let mut refs = Refs::default();
for (name, h) in [("block.ruby", 16), ("block.opal", 16), ("block.glow", 32)] {
refs.pictures.insert(format!("Images.{name}"), picture(16, h));
}
let mut errors = Vec::new();
let t = compile(&dir, "mymod", &refs, &mut errors);
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(t.ids.len(), 3);
let names: BTreeSet<&str> = t.outputs.iter().map(|(p, _)| p.as_str()).collect();
assert!(names.contains("assets/mymod/textures/atlas/blocks.png"), "{names:?}");
assert!(names.contains("assets/mymod/textures/block/glow.png.mcmeta"), "{names:?}");
let atlas: Value = serde_json::from_slice(&t.outputs.iter().find(|(p, _)| p == "assets/minecraft/atlases/blocks.json").unwrap().1).unwrap();
assert_eq!(atlas["sources"][0]["type"], UNSTITCH);
assert_eq!(atlas["sources"][0]["regions"].as_array().unwrap().len(), 2);
assert_eq!(atlas["sources"][1], json!({ "type": SINGLE, "resource": "mymod:block/glow" }));
std::fs::create_dir_all(dir.join("resources/mob_effect")).unwrap();
std::fs::write(dir.join("resources/mob_effect/clarity.ytexture"), r#"{ image: "@Images.mob_effect.clarity", atlas: "minecraft:mob_effects" }"#).unwrap();
refs.pictures.insert("Images.mob_effect.clarity".into(), picture(18, 18));
let t = compile(&dir, "mymod", &refs, &mut errors);
let effects: Value = serde_json::from_slice(&t.outputs.iter().find(|(p, _)| p == "assets/minecraft/atlases/mob_effects.json").unwrap().1).unwrap();
assert_eq!(effects["sources"][0]["regions"][0]["sprite"], "mymod:clarity");
let _ = std::fs::remove_dir_all(&dir);
}
}