Skip to main content

yog_cli/content/
import.rs

1//! `yog import` — turns models made elsewhere into `content/` files:
2//!
3//! - `yog import resourcepack <zip|dir> [--model <id>] [--all]` — a
4//!   resource pack's (or a mod's `assets/`) block and item models: parents
5//!   resolved (a vanilla template stays a template, other parents are
6//!   flattened from the pack or the Minecraft client jar), textures copied
7//!   into `resources/`, the blockstate's rotations kept;
8//! - `yog import bbmodel <file.bbmodel> [--id <name>] [--item]` — a
9//!   Blockbench project in the Java block/item format, textures included.
10
11use super::json::{J, V, to_pretty};
12use super::{TEMPLATES, template, valid_namespace, valid_path};
13use serde_json::{Map, Value};
14use std::collections::BTreeMap;
15use std::io::Read as _;
16use std::path::{Path, PathBuf};
17
18use crate::yres::RESOURCES_DIR;
19use yog_data::Schema;
20use yog_ids::Format;
21
22const USAGE: &str = "usage:\n  yog import resourcepack <pack.zip|dir> [--model <ns:block/name|ns:item/name>] [--all] [--force]\n  yog import bbmodel <file.bbmodel> [--id <name>] [--item] [--force]";
23
24/// `yog import ...` (args after `import`).
25pub fn import_command(args: &[String]) -> Result<(), String> {
26    let flag = |name: &str| args.iter().any(|a| a == name);
27    let value = |name: &str| {
28        args.iter()
29            .position(|a| a == name)
30            .and_then(|i| args.get(i + 1))
31            .map(String::as_str)
32    };
33    let positional: Vec<&str> = {
34        let mut out = Vec::new();
35        let mut skip = false;
36        for a in args.iter().skip(1) {
37            if skip {
38                skip = false;
39            } else if a == "--model" || a == "--id" {
40                skip = true;
41            } else if !a.starts_with("--") {
42                out.push(a.as_str());
43            }
44        }
45        out
46    };
47    let root = std::env::current_dir().map_err(|e| e.to_string())?;
48    let mod_id = read_mod_id(&root)?;
49    let mut target = Target {
50        root: root.clone(),
51        ns: valid_namespace(&mod_id).then_some(mod_id),
52        force: flag("--force"),
53        written: Vec::new(),
54        warnings: Vec::new(),
55    };
56    match (args.first().map(String::as_str), positional.first()) {
57        (Some("resourcepack"), Some(src)) => {
58            let pack = Pack::open(&root.join(src))?;
59            import_pack(&mut target, &pack, value("--model"), flag("--all"))?;
60        }
61        (Some("bbmodel"), Some(file)) => {
62            import_bbmodel(&mut target, &root.join(file), value("--id"), flag("--item"))?;
63        }
64        _ => return Err(USAGE.into()),
65    }
66    for w in &target.warnings {
67        eprintln!("warning: {w}");
68    }
69    for f in &target.written {
70        eprintln!("==> wrote {f}");
71    }
72    if target.written.is_empty() {
73        return Err("nothing imported".into());
74    }
75    eprintln!(
76        "    `yog content check` validates them; register with `yog_api::content!();` in src/lib.rs"
77    );
78    Ok(())
79}
80
81fn read_mod_id(root: &Path) -> Result<String, String> {
82    let text = std::fs::read_to_string(root.join(yog_ids::file::MANIFEST)).map_err(|_| {
83        "no yog.toml in the current directory — run `yog import` in a mod".to_string()
84    })?;
85    let mut in_mod = false;
86    for line in text.lines() {
87        let t = line.trim();
88        if t.starts_with('[') {
89            in_mod = t == "[mod]";
90        } else if in_mod
91            && let Some(v) = t
92                .strip_prefix("id")
93                .map(str::trim_start)
94                .and_then(|r| r.strip_prefix('='))
95        {
96            return Ok(v.trim().trim_matches('"').to_string());
97        }
98    }
99    Err("yog.toml has no [mod] id".into())
100}
101
102/// Where imports go.
103struct Target {
104    root: PathBuf,
105    /// The mod's namespace, when its id is one.
106    ns: Option<String>,
107    force: bool,
108    written: Vec<String>,
109    warnings: Vec<String>,
110}
111
112impl Target {
113    /// The `"id"` to write for `name` from namespace `from_ns`: none when
114    /// the mod's namespace applies.
115    fn id_for(&self, from_ns: &str, name: &str) -> Option<String> {
116        self.ns.is_none().then(|| format!("{from_ns}:{name}"))
117    }
118
119    /// The namespace textures bundled for an entry of `from_ns` land in.
120    fn texture_ns(&self, from_ns: &str) -> String {
121        self.ns.clone().unwrap_or_else(|| from_ns.to_string())
122    }
123
124    fn write_file(&mut self, rel: &str, bytes: &[u8]) -> Result<(), String> {
125        let path = self.root.join(rel);
126        if path.exists() && !self.force {
127            if std::fs::read(&path).ok().as_deref() == Some(bytes) {
128                return Ok(());
129            }
130            return Err(format!("{rel} already exists (--force overwrites)"));
131        }
132        if let Some(dir) = path.parent() {
133            std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
134        }
135        std::fs::write(&path, bytes).map_err(|e| format!("{rel}: {e}"))?;
136        self.written.push(rel.to_string());
137        Ok(())
138    }
139
140    /// Puts a texture's picture into its `.yres` set (as bytes, named by
141    /// its path: `block.ruby` in `BlockImages.yres`) and writes its `<path>.ytexture`
142    /// (its atlas by its folder, the animation an `.mcmeta` gave) → its
143    /// value in a content file (`@texture:<ns>:<path>`).
144    fn put_texture(
145        &mut self,
146        ns: &str,
147        path: &str,
148        png: &[u8],
149        mcmeta: Option<&[u8]>,
150    ) -> Result<String, String> {
151        let name = path.replace(yog_ids::sep::PATH, ".");
152        let set = yog_formats::texture::picture_set(path);
153        let set_rel = format!("{RESOURCES_DIR}/{}", Format::Res.file(&set));
154        let set_file = self.root.join(&set_rel);
155        let mut entries = match std::fs::read_to_string(&set_file) {
156            Ok(text) => crate::yres::parse(&text).map_err(|e| format!("{set_rel}: {e}"))?,
157            Err(_) => Vec::new(),
158        };
159        let picture = crate::yres::Value::Bytes(png.to_vec());
160        match entries.iter_mut().find(|e| e.name == name) {
161            Some(e) if e.value == picture => {}
162            // A picture already there and different: keep it, warn.
163            Some(_) if !self.force => self.warnings.push(format!("{set_rel}: {name} exists and differs — kept it (--force overwrites)")),
164            Some(e) => e.value = picture,
165            None => entries.push(crate::yres::Entry::picture(&name, png.to_vec())),
166        }
167        if let Some(dir) = set_file.parent() {
168            std::fs::create_dir_all(dir).map_err(|e| format!("{set_rel}: {e}"))?;
169        }
170        std::fs::write(&set_file, crate::yres::write(&entries)).map_err(|e| format!("{set_rel}: {e}"))?;
171        if !self.written.contains(&set_rel) {
172            self.written.push(set_rel);
173        }
174        let rel = format!("{RESOURCES_DIR}/{}", Format::Texture.file(path));
175        if !self.root.join(&rel).is_file() || self.force {
176            let texture = yog_formats::texture::TextureFile {
177                image: yog_data::Res { set, key: name },
178                atlas: yog_registry::Atlas::of_path(path).map(|a| a.id.clone()),
179                animation: mcmeta.and_then(|m| serde_json::from_slice(m).ok()).and_then(|m| yog_formats::texture::Animation::from_mcmeta(&m)),
180                blur: false,
181                clamp: false,
182            };
183            self.write_file(&rel, yog_data::text::to_string_pretty(&texture.write()).as_bytes())?;
184        }
185        Ok(format!("{}{}", yog_ids::reference::TEXTURE, yog_ids::join(ns, path)))
186    }
187}
188
189// ── Resource packs ───────────────────────────────────────────────────────────
190
191/// A zip or a directory with `assets/`.
192enum Pack {
193    Dir(PathBuf),
194    Zip(BTreeMap<String, Vec<u8>>),
195}
196
197impl Pack {
198    fn open(path: &Path) -> Result<Pack, String> {
199        if path.is_dir() {
200            if !path.join("assets").is_dir() {
201                return Err(format!("{} has no assets/ folder", path.display()));
202            }
203            return Ok(Pack::Dir(path.to_path_buf()));
204        }
205        let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
206        let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", path.display()))?;
207        let mut files = BTreeMap::new();
208        // Packs sometimes wrap everything in one folder.
209        let mut prefix: Option<String> = None;
210        for i in 0..zip.len() {
211            let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
212            if entry.is_dir() {
213                continue;
214            }
215            let name = entry.name().map_err(|e| e.to_string())?.replace('\\', "/");
216            if prefix.is_none()
217                && let Some(at) = name.find("assets/")
218            {
219                prefix = Some(name[..at].to_string());
220            }
221            if !name.ends_with(".json") && !name.ends_with(".png") && !name.ends_with(".mcmeta") {
222                continue;
223            }
224            let mut bytes = Vec::new();
225            entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
226            files.insert(name, bytes);
227        }
228        let prefix = prefix.unwrap_or_default();
229        Ok(Pack::Zip(
230            files
231                .into_iter()
232                .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|k| (k.to_string(), v)))
233                .collect(),
234        ))
235    }
236
237    fn read(&self, rel: &str) -> Option<Vec<u8>> {
238        match self {
239            Pack::Dir(d) => std::fs::read(d.join(rel)).ok(),
240            Pack::Zip(files) => files.get(rel).cloned(),
241        }
242    }
243
244    /// Paths under `assets/` matching `assets/<ns>/<dir>/<name>.json` → (ns, name).
245    fn list(&self, dir: &str) -> Vec<(String, String)> {
246        let mut out = Vec::new();
247        let mut add = |rel: &str| {
248            let rest = rel.strip_prefix("assets/")?;
249            let (ns, rest) = rest.split_once('/')?;
250            let name = rest
251                .strip_prefix(dir)?
252                .strip_prefix('/')?
253                .strip_suffix(".json")?;
254            out.push((ns.to_string(), name.to_string()));
255            Some(())
256        };
257        match self {
258            Pack::Dir(d) => {
259                let mut stack = vec![d.join("assets")];
260                while let Some(p) = stack.pop() {
261                    for e in std::fs::read_dir(&p).into_iter().flatten().flatten() {
262                        let path = e.path();
263                        if path.is_dir() {
264                            stack.push(path);
265                        } else if let Ok(rel) = path.strip_prefix(d) {
266                            add(&rel.to_string_lossy().replace('\\', "/"));
267                        }
268                    }
269                }
270            }
271            Pack::Zip(files) => {
272                for k in files.keys() {
273                    add(k);
274                }
275            }
276        }
277        out.sort();
278        out
279    }
280}
281
282/// The newest Minecraft client jar on this machine (vanilla models for
283/// flattening parents).
284fn client_jar(root: &Path) -> Option<PathBuf> {
285    if let Ok(p) = std::env::var("YOG_MC_CLIENT_JAR") {
286        return Some(PathBuf::from(p)).filter(|p| p.is_file());
287    }
288    let home = std::env::var_os("HOME")
289        .or_else(|| std::env::var_os("USERPROFILE"))
290        .map(PathBuf::from);
291    let mut dirs: Vec<(PathBuf, &str)> = Vec::new();
292    if let Some(h) = &home {
293        let caches = h.join(".gradle/caches");
294        dirs.push((caches.join("fabric-loom"), "minecraft-client.jar"));
295        dirs.push((
296            caches.join("forge_gradle/minecraft_repo/versions"),
297            "client.jar",
298        ));
299        dirs.push((caches.join("minecraft/versions"), "client.jar"));
300        dirs.push((h.join(".minecraft/versions"), ""));
301    }
302    for loader in ["neoforge", "fabric", "forge"] {
303        dirs.push((
304            root.join("../Yog-Mod-Loader")
305                .join(loader)
306                .join(".gradle/caches/minecraft/versions"),
307            "client.jar",
308        ));
309    }
310    let key = |v: &str| -> Vec<u32> {
311        v.split(['.', '-'])
312            .map(|p| p.parse().unwrap_or(0))
313            .collect()
314    };
315    let mut best: Option<(Vec<u32>, PathBuf)> = None;
316    for (dir, file) in dirs {
317        for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
318            let v = e.file_name().to_string_lossy().into_owned();
319            if !v.starts_with(|c: char| c.is_ascii_digit()) {
320                continue;
321            }
322            let jar = if file.is_empty() {
323                e.path().join(format!("{v}.jar"))
324            } else {
325                e.path().join(file)
326            };
327            if jar.is_file() && best.as_ref().is_none_or(|(k, _)| key(&v) > *k) {
328                best = Some((key(&v), jar));
329            }
330        }
331    }
332    best.map(|(_, p)| p)
333}
334
335/// Reads models: the pack first, then the client jar.
336struct Models<'a> {
337    pack: &'a Pack,
338    jar: Option<zip::ZipArchive<std::fs::File>>,
339    jar_tried: bool,
340    root: PathBuf,
341}
342
343impl Models<'_> {
344    fn model(&mut self, id: &str) -> Option<(Map<String, Value>, bool)> {
345        let (ns, path) = yog_ids::split(id);
346        let rel = format!("assets/{ns}/models/{path}.json");
347        if let Some(b) = self.pack.read(&rel) {
348            return serde_json::from_slice(&b).ok().map(|m| (m, true));
349        }
350        if !self.jar_tried {
351            self.jar_tried = true;
352            self.jar = client_jar(&self.root)
353                .and_then(|p| std::fs::File::open(p).ok())
354                .and_then(|f| zip::ZipArchive::new(f).ok());
355        }
356        let jar = self.jar.as_mut()?;
357        let mut entry = jar.by_name(&rel).ok()?;
358        let mut bytes = Vec::new();
359        entry.read_to_end(&mut bytes).ok()?;
360        serde_json::from_slice(&bytes).ok().map(|m| (m, false))
361    }
362}
363
364
365/// The template a vanilla model id is.
366fn template_of(id: &str) -> Option<&'static super::Template> {
367    let id = yog_ids::qualify(id);
368    TEMPLATES.iter().find(|t| t.parent == id)
369}
370
371/// A model of the pack → a content model object (and `render_type`).
372fn convert_model(
373    t: &mut Target,
374    models: &mut Models,
375    pack: &Pack,
376    id: &str,
377    item: bool,
378) -> Result<(J, Option<String>), String> {
379    let mut textures: Vec<(String, String)> = Vec::new();
380    let mut elements: Option<Vec<Value>> = None;
381    let mut display: Map<String, Value> = Map::new();
382    let mut ao: Option<bool> = None;
383    let mut gui_light: Option<String> = None;
384    let mut render_type: Option<String> = None;
385    let mut parent: Option<String> = None;
386    let mut current = yog_ids::qualify(id);
387    let mut from_ns = yog_ids::split(&current).0.to_string();
388    for depth in 0.. {
389        if depth > 16 {
390            return Err(format!("{id}: parent chain too long"));
391        }
392        let Some((m, _)) = models.model(&current) else {
393            if depth == 0 {
394                return Err(format!("{id}: no such model in the pack"));
395            }
396            t.warnings.push(format!(
397                "{id}: parent {current} not found (no Minecraft client jar?) — kept as the parent"
398            ));
399            parent = Some(current.clone());
400            break;
401        };
402        if depth == 0 {
403            from_ns = yog_ids::split(&current).0.to_string();
404        }
405        if let Some(Value::Object(tx)) = m.get("textures") {
406            for (k, v) in tx {
407                if !textures.iter().any(|(s, _)| s == k)
408                    && let Some(s) = v.as_str()
409                {
410                    textures.push((k.clone(), s.to_string()));
411                }
412            }
413        }
414        if elements.is_none()
415            && let Some(Value::Array(e)) = m.get("elements")
416        {
417            elements = Some(e.clone());
418        }
419        if let Some(Value::Object(d)) = m.get("display") {
420            for (k, v) in d {
421                display.entry(k.clone()).or_insert_with(|| v.clone());
422            }
423        }
424        if ao.is_none() {
425            ao = m.get("ambientocclusion").and_then(Value::as_bool);
426        }
427        if gui_light.is_none() {
428            gui_light = m.get("gui_light").and_then(Value::as_str).map(String::from);
429        }
430        if render_type.is_none() {
431            render_type = m
432                .get("render_type")
433                .and_then(Value::as_str)
434                .map(|s| s.trim_start_matches("minecraft:").to_string());
435        }
436        let Some(p) = m.get("parent").and_then(Value::as_str) else {
437            break;
438        };
439        if p.starts_with("builtin/") || p.starts_with("minecraft:builtin/") {
440            if p.ends_with("generated") {
441                parent = Some("generated".into());
442                break;
443            }
444            return Err(format!("{id}: built-in model {p} can't be imported"));
445        }
446        if elements.is_none()
447            && let Some(tpl) = template_of(p)
448            && (!tpl.sprite || item)
449        {
450            parent = Some(tpl.name.to_string());
451            break;
452        }
453        current = yog_ids::qualify(p);
454    }
455    // A sprite parent reached through the jar: `item/generated` itself.
456    if elements.is_none() && parent.is_none() {
457        parent = Some(if item {
458            "generated".into()
459        } else {
460            "cube_all".into()
461        });
462    }
463
464    let mut out = J::obj::<&str>([]);
465    if let Some(p) = &parent {
466        out.set("parent", J::string(p.clone()));
467    }
468    // Textures that are references to other slots stay; ids get copied
469    // when the pack has them.
470    let mut tex = J::obj::<&str>([]);
471    let tex_ns = t.texture_ns(&from_ns);
472    for (slot, v) in &textures {
473        let value = if v.starts_with('#') {
474            v.clone()
475        } else {
476            let (ns, path) = yog_ids::split(v);
477            let rel = format!("assets/{ns}/textures/{path}.png");
478            match pack.read(&rel) {
479                Some(png) if valid_path(path) => {
480                    let meta = pack.read(&format!("{rel}.mcmeta"));
481                    if ns != tex_ns {
482                        t.warnings.push(format!(
483                            "{id}: texture {ns}:{path} is bundled as {tex_ns}:{path}"
484                        ));
485                    }
486                    t.put_texture(&tex_ns, path, &png, meta.as_deref())?
487                }
488                _ => format!("{}{}", yog_ids::reference::TEXTURE, yog_ids::qualify(v)),
489            }
490        };
491        tex.set(slot, J::string(value));
492    }
493    // Templates already define `particle` — drop a duplicate of a slot.
494    if let Some(tpl) = parent.as_deref().and_then(template)
495        && tpl.has_particle
496        && let V::Obj(m) = &mut tex.v
497    {
498        m.retain(|(k, _)| k != "particle");
499    }
500    if !tex.members().is_empty() {
501        out.set("textures", tex);
502    }
503    if let Some(els) = &elements {
504        let converted: Vec<J> = els
505            .iter()
506            .enumerate()
507            .map(|(i, e)| element_from_mc(t, id, i, e))
508            .collect();
509        out.set("elements", J::of(V::Arr(converted)));
510    }
511    if !display.is_empty() {
512        out.set("display", J::from_value(&Value::Object(display)));
513    }
514    if let Some(ao) = ao {
515        out.set("ambient_occlusion", J::of(V::Bool(ao)));
516    }
517    if let Some(g) = gui_light {
518        out.set("gui_light", J::string(g));
519    }
520    Ok((out, render_type))
521}
522
523/// The nearest allowed element angle (-45..45 by 22.5).
524fn snap_angle(t: &mut Target, what: &str, a: f64) -> f64 {
525    let snapped = ((a / 22.5).round() * 22.5).clamp(-45.0, 45.0);
526    if (snapped - a).abs() > 1e-6 {
527        t.warnings.push(format!(
528            "{what}: rotation {a}° → {snapped}° (Minecraft allows -45..45 by 22.5)"
529        ));
530    }
531    snapped
532}
533
534fn clamp_coord(t: &mut Target, what: &str, v: f64) -> f64 {
535    let c = v.clamp(-16.0, 32.0);
536    if c != v {
537        t.warnings
538            .push(format!("{what}: {v} is outside -16..32, clamped"));
539    }
540    (c * 10000.0).round() / 10000.0
541}
542
543/// A Minecraft model element → a content element (same shape, `tint`).
544fn element_from_mc(t: &mut Target, id: &str, i: usize, e: &Value) -> J {
545    let what = format!("{id} element {i}");
546    let coords = |t: &mut Target, v: Option<&Value>| -> Vec<f64> {
547        let a: Vec<f64> = v
548            .and_then(Value::as_array)
549            .map(|a| a.iter().filter_map(Value::as_f64).collect())
550            .unwrap_or_default();
551        a.into_iter().map(|n| clamp_coord(t, &what, n)).collect()
552    };
553    let mut out = J::obj::<&str>([]);
554    if let Some(n) = e.get("name").and_then(Value::as_str) {
555        out.set("name", J::string(n));
556    }
557    out.set("from", J::nums(&coords(t, e.get("from"))));
558    out.set("to", J::nums(&coords(t, e.get("to"))));
559    if let Some(r) = e.get("rotation") {
560        let angle = r.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
561        let angle = snap_angle(t, &what, angle);
562        if angle != 0.0 {
563            let mut rot = J::obj([(
564                "axis",
565                J::string(r.get("axis").and_then(Value::as_str).unwrap_or("y")),
566            )]);
567            rot.set("angle", J::number(angle));
568            rot.set("origin", J::nums(&coords(t, r.get("origin"))));
569            if r.get("rescale").and_then(Value::as_bool) == Some(true) {
570                rot.set("rescale", J::of(V::Bool(true)));
571            }
572            out.set("rotation", rot);
573        }
574    }
575    if e.get("shade").and_then(Value::as_bool) == Some(false) {
576        out.set("shade", J::of(V::Bool(false)));
577    }
578    let mut faces = J::obj::<&str>([]);
579    if let Some(Value::Object(f)) = e.get("faces") {
580        for dir in super::DIRECTIONS {
581            let Some(face) = f.get(dir) else { continue };
582            let mut fo = J::obj([(
583                "texture",
584                J::string(
585                    face.get("texture")
586                        .and_then(Value::as_str)
587                        .unwrap_or("#missing"),
588                ),
589            )]);
590            if let Some(uv) = face.get("uv").and_then(Value::as_array) {
591                let uv: Vec<f64> = uv
592                    .iter()
593                    .filter_map(Value::as_f64)
594                    .map(|n| (n.clamp(0.0, 16.0) * 10000.0).round() / 10000.0)
595                    .collect();
596                fo.set("uv", J::nums(&uv));
597            }
598            if let Some(r) = face
599                .get("rotation")
600                .and_then(Value::as_f64)
601                .filter(|r| *r != 0.0)
602            {
603                fo.set("rotation", J::number(r));
604            }
605            if let Some(c) = face.get("cullface").and_then(Value::as_str) {
606                fo.set(
607                    "cullface",
608                    J::string(if c == "bottom" { "down" } else { c }),
609                );
610            }
611            if let Some(n) = face.get("tintindex").and_then(Value::as_f64) {
612                fo.set("tint", J::number(n));
613            }
614            faces.set(dir, fo);
615        }
616    }
617    out.set("faces", faces);
618    out
619}
620
621/// The blockstate's rotations (`"variants": {"": [...]}`) — or none.
622fn state_rotations(t: &mut Target, id: &str, bs: &Value) -> (Option<String>, Option<J>) {
623    let one = |v: &Value| -> (Option<String>, J) {
624        let mut r = J::obj::<&str>([]);
625        for k in ["x", "y"] {
626            if let Some(n) = v.get(k).and_then(Value::as_f64).filter(|n| *n != 0.0) {
627                r.set(k, J::number(n));
628            }
629        }
630        if let Some(w) = v.get("weight").and_then(Value::as_f64) {
631            r.set("weight", J::number(w));
632        }
633        if v.get("uvlock").and_then(Value::as_bool) == Some(true) {
634            r.set("uvlock", J::of(V::Bool(true)));
635        }
636        (v.get("model").and_then(Value::as_str).map(String::from), r)
637    };
638    let variant = if let Some(Value::Object(vs)) = bs.get("variants") {
639        if vs.len() > 1 {
640            t.warnings.push(format!(
641                "{id}: the blockstate has {} variants (block state properties) — only the first is imported; content files don't do properties yet",
642                vs.len()
643            ));
644        }
645        vs.values().next().cloned()
646    } else if let Some(Value::Array(parts)) = bs.get("multipart") {
647        t.warnings.push(format!(
648            "{id}: multipart blockstate — only the first part is imported"
649        ));
650        parts.first().and_then(|p| p.get("apply")).cloned()
651    } else {
652        None
653    };
654    match variant {
655        Some(Value::Array(list)) => {
656            let mut model = None;
657            let mut rots = Vec::new();
658            for v in &list {
659                let (m, r) = one(v);
660                model = model.or(m);
661                rots.push(r);
662            }
663            (model, Some(J::of(V::Arr(rots))))
664        }
665        Some(v) => {
666            let (m, r) = one(&v);
667            let rots = (!r.members().is_empty()).then(|| J::of(V::Arr(vec![r])));
668            (m, rots)
669        }
670        None => (None, None),
671    }
672}
673
674/// The pack's English name for a key, if it has a plain `en_us.json`.
675fn lang_name(pack: &Pack, ns: &str, key: &str) -> Option<String> {
676    let bytes = pack.read(&format!("assets/{ns}/lang/en_us.json"))?;
677    let m: Map<String, Value> = serde_json::from_slice(&bytes).ok()?;
678    m.get(key).and_then(Value::as_str).map(String::from)
679}
680
681fn header(t: &Target, ns: &str, name: &str, pack: &Pack, kind: &str) -> J {
682    let mut j = J::obj::<&str>([]);
683    if let Some(id) = t.id_for(ns, name) {
684        j.set("id", J::string(id));
685    }
686    if let Some(n) = lang_name(pack, ns, &format!("{kind}.{ns}.{}", name.replace('/', "."))) {
687        j.set("name", J::string(n));
688    }
689    j
690}
691
692fn import_block(
693    t: &mut Target,
694    models: &mut Models,
695    pack: &Pack,
696    ns: &str,
697    name: &str,
698) -> Result<(), String> {
699    let bs: Option<Value> = pack
700        .read(&format!("assets/{ns}/blockstates/{name}.json"))
701        .and_then(|b| serde_json::from_slice(&b).ok());
702    let (model_id, rotations) = match &bs {
703        Some(bs) => state_rotations(t, &format!("{ns}:{name}"), bs),
704        None => (None, None),
705    };
706    let model_id = model_id.unwrap_or_else(|| format!("{ns}:block/{name}"));
707    let (model, render) = convert_model(t, models, pack, &model_id, false)?;
708    let mut j = header(t, ns, name, pack, "block");
709    if let Some(r) = render.filter(|r| super::RENDER_LAYERS.contains(&r.as_str()) && r != "solid") {
710        j.set("block", J::obj([("render_layer", J::string(r))]));
711    }
712    j.set("model", model);
713    // Its own item model, unless it's just the block's.
714    if let Some((im, _)) = models.model(&format!("{ns}:item/{name}")) {
715        let plain = im.len() == 1
716            && im.get("parent").and_then(Value::as_str).map(yog_ids::qualify) == Some(yog_ids::qualify(&model_id));
717        if !plain {
718            let (item_model, _) =
719                convert_model(t, models, pack, &format!("{ns}:item/{name}"), true)?;
720            j.set("item_model", item_model);
721        }
722    }
723    if let Some(r) = rotations {
724        j.set("states", J::obj([("rotations", r)]));
725    }
726    let leaf = name.rsplit('/').next().unwrap_or(name);
727    t.write_file(
728        &format!("content/blocks/{leaf}.yblock"),
729        to_pretty(&j).as_bytes(),
730    )
731}
732
733fn import_item(
734    t: &mut Target,
735    models: &mut Models,
736    pack: &Pack,
737    ns: &str,
738    name: &str,
739) -> Result<(), String> {
740    let (model, _) = convert_model(t, models, pack, &format!("{ns}:item/{name}"), true)?;
741    let mut j = header(t, ns, name, pack, "item");
742    j.set("model", model);
743    let leaf = name.rsplit('/').next().unwrap_or(name);
744    t.write_file(
745        &format!("content/items/{leaf}.yitem"),
746        to_pretty(&j).as_bytes(),
747    )
748}
749
750fn import_pack(t: &mut Target, pack: &Pack, model: Option<&str>, all: bool) -> Result<(), String> {
751    let mut models = Models {
752        pack,
753        jar: None,
754        jar_tried: false,
755        root: t.root.clone(),
756    };
757    let blockstates = pack.list("blockstates");
758    let block_models = pack.list("models/block");
759    let item_models = pack.list("models/item");
760    if let Some(m) = model {
761        let (ns, path) = yog_ids::split(m);
762        let ns = if m.contains(':') {
763            ns.to_string()
764        } else {
765            t.ns.clone().unwrap_or_else(|| "minecraft".into())
766        };
767        let path = if m.contains(':') { path } else { m };
768        if let Some(name) = path.strip_prefix("block/") {
769            return import_block(t, &mut models, pack, &ns, name);
770        }
771        if let Some(name) = path.strip_prefix("item/") {
772            return import_item(t, &mut models, pack, &ns, name);
773        }
774        if blockstates.iter().any(|(n, p)| *n == ns && p == path)
775            || block_models.iter().any(|(n, p)| *n == ns && p == path)
776        {
777            return import_block(t, &mut models, pack, &ns, path);
778        }
779        if item_models.iter().any(|(n, p)| *n == ns && p == path) {
780            return import_item(t, &mut models, pack, &ns, path);
781        }
782        return Err(format!("no block or item {ns}:{path} in the pack"));
783    }
784    if !all {
785        let mut list = String::new();
786        for (ns, p) in blockstates.iter().chain(&block_models) {
787            list.push_str(&format!("\n  {ns}:block/{p}"));
788        }
789        for (ns, p) in &item_models {
790            list.push_str(&format!("\n  {ns}:item/{p}"));
791        }
792        return Err(format!(
793            "pick one with --model <id>, or --all for every block (blockstate) and item. The pack has:{}",
794            if list.is_empty() {
795                "\n  (no models)".to_string()
796            } else {
797                list
798            }
799        ));
800    }
801    let mut failed = Vec::new();
802    for (ns, name) in &blockstates {
803        if let Err(e) = import_block(t, &mut models, pack, ns, name) {
804            failed.push(e);
805        }
806    }
807    for (ns, name) in &item_models {
808        // A block's item model came with the block.
809        if blockstates.iter().any(|(n, p)| n == ns && p == name) {
810            continue;
811        }
812        if let Err(e) = import_item(t, &mut models, pack, ns, name) {
813            failed.push(e);
814        }
815    }
816    t.warnings.extend(failed);
817    Ok(())
818}
819
820// ── Blockbench ───────────────────────────────────────────────────────────────
821
822/// Standard base64 (data URLs of Blockbench textures).
823fn base64_decode(s: &str) -> Option<Vec<u8>> {
824    let mut out = Vec::with_capacity(s.len() * 3 / 4);
825    let (mut acc, mut bits) = (0u32, 0u32);
826    for c in s.bytes() {
827        let v = match c {
828            b'A'..=b'Z' => c - b'A',
829            b'a'..=b'z' => c - b'a' + 26,
830            b'0'..=b'9' => c - b'0' + 52,
831            b'+' | b'-' => 62,
832            b'/' | b'_' => 63,
833            b'=' | b'\n' | b'\r' | b' ' => continue,
834            _ => return None,
835        } as u32;
836        acc = (acc << 6) | v;
837        bits += 6;
838        if bits >= 8 {
839            bits -= 8;
840            out.push((acc >> bits) as u8);
841            acc &= (1 << bits) - 1;
842        }
843    }
844    Some(out)
845}
846
847/// `My Texture (1).png` → `my_texture_1`.
848fn sanitize(name: &str) -> String {
849    let stem = name.strip_suffix(".png").unwrap_or(name);
850    let mut s = String::new();
851    for c in stem.chars() {
852        if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
853            s.push(c.to_ascii_lowercase());
854        } else if !s.ends_with('_') {
855            s.push('_');
856        }
857    }
858    let s = s.trim_matches('_').to_string();
859    if s.is_empty() { "texture".into() } else { s }
860}
861
862fn import_bbmodel(t: &mut Target, file: &Path, id: Option<&str>, item: bool) -> Result<(), String> {
863    let text = std::fs::read_to_string(file).map_err(|e| format!("{}: {e}", file.display()))?;
864    let bb: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", file.display()))?;
865    let format = bb
866        .pointer("/meta/model_format")
867        .and_then(Value::as_str)
868        .unwrap_or("");
869    if !matches!(format, "java_block" | "free" | "") {
870        return Err(format!(
871            "{}: a {format:?} Blockbench model — only the \"Java Block/Item\" format maps to Minecraft models (File > Convert Project)",
872            file.display()
873        ));
874    }
875    if format == "free" {
876        t.warnings
877            .push("a generic Blockbench model: rotations get snapped to Minecraft's".into());
878    }
879    let name = match id {
880        Some(i) => i.to_string(),
881        None => {
882            let n = bb
883                .get("name")
884                .and_then(Value::as_str)
885                .filter(|s| !s.is_empty())
886                .map(String::from);
887            sanitize(&n.unwrap_or_else(|| {
888                file.file_stem()
889                    .unwrap_or_default()
890                    .to_string_lossy()
891                    .into_owned()
892            }))
893        }
894    };
895    let (id_ns, name) = match name.split_once(':') {
896        Some((n, p)) => (Some(n.to_string()), p.to_string()),
897        None => (None, name),
898    };
899    if !valid_path(&name) {
900        return Err(format!("`{name}` isn't a valid id — pass --id <name>"));
901    }
902    let ns = id_ns
903        .clone()
904        .or_else(|| t.ns.clone())
905        .ok_or("the mod id isn't a valid namespace — pass --id <namespace>:<name>")?;
906    let res_w = bb
907        .pointer("/resolution/width")
908        .and_then(Value::as_f64)
909        .unwrap_or(16.0);
910    let res_h = bb
911        .pointer("/resolution/height")
912        .and_then(Value::as_f64)
913        .unwrap_or(16.0);
914    let folder = if item { "item" } else { "block" };
915
916    // Textures: slot = index ("0", "1", ...), like Blockbench's export.
917    let mut tex = J::obj::<&str>([]);
918    let mut uv_scale: Vec<(f64, f64)> = Vec::new();
919    let mut particle: Option<String> = None;
920    let textures = bb
921        .get("textures")
922        .and_then(Value::as_array)
923        .cloned()
924        .unwrap_or_default();
925    for (i, tx) in textures.iter().enumerate() {
926        let slot = i.to_string();
927        let tex_name = sanitize(tx.get("name").and_then(Value::as_str).unwrap_or("texture"));
928        let uw = tx.get("uv_width").and_then(Value::as_f64).unwrap_or(res_w);
929        let uh = tx.get("uv_height").and_then(Value::as_f64).unwrap_or(res_h);
930        uv_scale.push((16.0 / uw, 16.0 / uh));
931        let source = tx.get("source").and_then(Value::as_str).unwrap_or("");
932        let value = if let Some(data) = source.strip_prefix("data:image/png;base64,") {
933            let png = base64_decode(data).ok_or("a texture's data isn't base64")?;
934            let tex_folder = tx
935                .get("folder")
936                .and_then(Value::as_str)
937                .filter(|f| valid_path(f))
938                .unwrap_or(folder);
939            let tns = t.texture_ns(&ns);
940            t.put_texture(&tns, &format!("{tex_folder}/{tex_name}"), &png, None)?
941        } else {
942            let tns = tx
943                .get("namespace")
944                .and_then(Value::as_str)
945                .filter(|s| !s.is_empty())
946                .unwrap_or("minecraft");
947            let tfolder = tx
948                .get("folder")
949                .and_then(Value::as_str)
950                .filter(|s| !s.is_empty())
951                .unwrap_or(folder);
952            t.warnings.push(format!("texture {tex_name} isn't embedded in the .bbmodel — referenced as {tns}:{tfolder}/{tex_name}"));
953            format!("{}{tns}:{tfolder}/{tex_name}", yog_ids::reference::TEXTURE)
954        };
955        if tx.get("particle").and_then(Value::as_bool) == Some(true) {
956            particle = Some(value.clone());
957        }
958        tex.set(&slot, J::string(value));
959    }
960    if let Some(p) = particle {
961        tex.set("particle", J::string(p));
962    }
963    let uuid_slot: BTreeMap<String, usize> = textures
964        .iter()
965        .enumerate()
966        .filter_map(|(i, tx)| {
967            tx.get("uuid")
968                .and_then(Value::as_str)
969                .map(|u| (u.to_string(), i))
970        })
971        .collect();
972
973    let mut elements = Vec::new();
974    for (i, e) in bb
975        .get("elements")
976        .and_then(Value::as_array)
977        .cloned()
978        .unwrap_or_default()
979        .iter()
980        .enumerate()
981    {
982        let what = format!(
983            "element {i} ({})",
984            e.get("name").and_then(Value::as_str).unwrap_or("?")
985        );
986        if e.get("type")
987            .and_then(Value::as_str)
988            .is_some_and(|ty| ty != "cube")
989        {
990            t.warnings.push(format!("{what}: not a cube — skipped"));
991            continue;
992        }
993        if e.get("visibility").and_then(Value::as_bool) == Some(false) {
994            t.warnings
995                .push(format!("{what}: hidden in Blockbench — imported anyway"));
996        }
997        let vec3 = |t: &mut Target, k: &str| -> Vec<f64> {
998            let a: Vec<f64> = e
999                .get(k)
1000                .and_then(Value::as_array)
1001                .map(|a| a.iter().filter_map(Value::as_f64).collect())
1002                .unwrap_or_default();
1003            a.into_iter().map(|n| clamp_coord(t, &what, n)).collect()
1004        };
1005        let mut el = J::obj::<&str>([]);
1006        if let Some(n) = e.get("name").and_then(Value::as_str) {
1007            el.set("name", J::string(n));
1008        }
1009        // Blockbench's inflate grows the box both ways.
1010        let inflate = e.get("inflate").and_then(Value::as_f64).unwrap_or(0.0);
1011        let mut from = vec3(t, "from");
1012        let mut to = vec3(t, "to");
1013        if inflate != 0.0 {
1014            for v in &mut from {
1015                *v -= inflate;
1016            }
1017            for v in &mut to {
1018                *v += inflate;
1019            }
1020        }
1021        el.set("from", J::nums(&from));
1022        el.set("to", J::nums(&to));
1023        let rot: Vec<f64> = e
1024            .get("rotation")
1025            .and_then(Value::as_array)
1026            .map(|a| a.iter().filter_map(Value::as_f64).collect())
1027            .unwrap_or_default();
1028        let nonzero: Vec<(usize, f64)> = rot
1029            .iter()
1030            .copied()
1031            .enumerate()
1032            .filter(|(_, a)| *a != 0.0)
1033            .collect();
1034        if nonzero.len() > 1 {
1035            t.warnings.push(format!("{what}: rotated on more than one axis — only the first kept (Minecraft rotates elements on one axis)"));
1036        }
1037        if let Some((axis, angle)) = nonzero.first().copied() {
1038            let angle = snap_angle(t, &what, angle);
1039            if angle != 0.0 {
1040                let mut r = J::obj([("axis", J::string(["x", "y", "z"][axis.min(2)]))]);
1041                r.set("angle", J::number(angle));
1042                r.set("origin", J::nums(&vec3(t, "origin")));
1043                if e.get("rescale").and_then(Value::as_bool) == Some(true) {
1044                    r.set("rescale", J::of(V::Bool(true)));
1045                }
1046                el.set("rotation", r);
1047            }
1048        }
1049        if e.get("shade").and_then(Value::as_bool) == Some(false) {
1050            el.set("shade", J::of(V::Bool(false)));
1051        }
1052        let mut faces = J::obj::<&str>([]);
1053        for dir in super::DIRECTIONS {
1054            let Some(f) = e.pointer(&format!("/faces/{dir}")) else {
1055                continue;
1056            };
1057            let index = match f.get("texture") {
1058                Some(Value::Number(n)) => n.as_u64().map(|n| n as usize),
1059                Some(Value::String(u)) => uuid_slot.get(u).copied(),
1060                _ => None,
1061            };
1062            let Some(index) = index.filter(|i| *i < textures.len()) else {
1063                continue; // No texture: Blockbench doesn't export the face.
1064            };
1065            let (su, sv) = uv_scale[index];
1066            let mut fo = J::obj([("texture", J::string(format!("#{index}")))]);
1067            if let Some(uv) = f.get("uv").and_then(Value::as_array) {
1068                let uv: Vec<f64> = uv
1069                    .iter()
1070                    .filter_map(Value::as_f64)
1071                    .enumerate()
1072                    .map(|(k, n)| {
1073                        ((n * if k % 2 == 0 { su } else { sv }).clamp(0.0, 16.0) * 10000.0).round()
1074                            / 10000.0
1075                    })
1076                    .collect();
1077                fo.set("uv", J::nums(&uv));
1078            }
1079            if let Some(r) = f
1080                .get("rotation")
1081                .and_then(Value::as_f64)
1082                .filter(|r| *r != 0.0)
1083            {
1084                fo.set("rotation", J::number(r));
1085            }
1086            if let Some(c) = f
1087                .get("cullface")
1088                .and_then(Value::as_str)
1089                .filter(|c| !c.is_empty())
1090            {
1091                fo.set("cullface", J::string(c));
1092            }
1093            if let Some(n) = f.get("tint").and_then(Value::as_f64).filter(|n| *n >= 0.0) {
1094                fo.set("tint", J::number(n));
1095            }
1096            faces.set(dir, fo);
1097        }
1098        el.set("faces", faces);
1099        elements.push(el);
1100    }
1101
1102    let mut model = J::obj::<&str>([]);
1103    let parent = bb
1104        .get("parent")
1105        .and_then(Value::as_str)
1106        .filter(|p| !p.is_empty());
1107    if let Some(p) = parent {
1108        match template_of(p) {
1109            Some(tpl) if elements.is_empty() => model.set("parent", J::string(tpl.name)),
1110            _ if elements.is_empty() => model.set("parent", J::string(yog_ids::qualify(p))),
1111            _ => {}
1112        }
1113    } else if elements.is_empty() {
1114        return Err("the model has no cubes".into());
1115    }
1116    if !tex.members().is_empty() {
1117        model.set("textures", tex);
1118    }
1119    if !elements.is_empty() {
1120        model.set("elements", J::of(V::Arr(elements)));
1121    }
1122    if let Some(Value::Object(d)) = bb.get("display") {
1123        let mut display = J::obj::<&str>([]);
1124        for (slot, v) in d {
1125            if !super::DISPLAY_SLOTS.contains(&slot.as_str()) {
1126                continue;
1127            }
1128            let mut o = J::obj::<&str>([]);
1129            for k in ["rotation", "translation", "scale"] {
1130                if let Some(a) = v.get(k).and_then(Value::as_array) {
1131                    let a: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
1132                    let default = if k == "scale" { [1.0; 3] } else { [0.0; 3] };
1133                    if a.len() == 3 && a[..] != default[..] {
1134                        o.set(k, J::nums(&a));
1135                    }
1136                }
1137            }
1138            if !o.members().is_empty() {
1139                display.set(slot, o);
1140            }
1141        }
1142        if !display.members().is_empty() {
1143            model.set("display", display);
1144        }
1145    }
1146    if bb.get("ambientocclusion").and_then(Value::as_bool) == Some(false) {
1147        model.set("ambient_occlusion", J::of(V::Bool(false)));
1148    }
1149    if bb.get("front_gui_light").and_then(Value::as_bool) == Some(true) {
1150        model.set("gui_light", J::string("front"));
1151    }
1152
1153    let mut j = J::obj::<&str>([]);
1154    if t.ns.is_none() || id_ns.is_some() {
1155        j.set("id", J::string(format!("{ns}:{name}")));
1156    }
1157    j.set("model", model);
1158    let leaf = name.rsplit('/').next().unwrap_or(&name).to_string();
1159    let rel = if item {
1160        format!("content/items/{leaf}.yitem")
1161    } else {
1162        format!("content/blocks/{leaf}.yblock")
1163    };
1164    t.write_file(&rel, to_pretty(&j).as_bytes())
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170
1171    fn temp(name: &str) -> PathBuf {
1172        let d = std::env::temp_dir().join(format!("yog-import-test-{}-{name}", std::process::id()));
1173        let _ = std::fs::remove_dir_all(&d);
1174        std::fs::create_dir_all(&d).unwrap();
1175        d
1176    }
1177
1178    fn target(root: &Path) -> Target {
1179        Target {
1180            root: root.to_path_buf(),
1181            ns: Some("mymod".into()),
1182            force: false,
1183            written: Vec::new(),
1184            warnings: Vec::new(),
1185        }
1186    }
1187
1188    #[test]
1189    fn decodes_base64() {
1190        assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello");
1191        assert_eq!(base64_decode("aGk").unwrap(), b"hi");
1192    }
1193
1194    /// A 1×1 PNG.
1195    fn picture() -> Vec<u8> {
1196        yog_media::image::encode_png(&yog_media::image::Image { width: 1, height: 1, rgba: vec![255, 0, 0, 255] }).unwrap()
1197    }
1198
1199    #[test]
1200    fn imports_a_pack_block_and_compiles_it() {
1201        let pack_dir = temp("pack");
1202        let w = |rel: &str, s: &[u8]| {
1203            let p = pack_dir.join(rel);
1204            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1205            std::fs::write(p, s).unwrap();
1206        };
1207        w(
1208            "assets/foo/blockstates/lamp.json",
1209            br#"{"variants":{"":[{"model":"foo:block/lamp"},{"model":"foo:block/lamp","y":90}]}}"#,
1210        );
1211        w("assets/foo/models/block/lamp_base.json", br##"{"textures":{"0":"foo:block/lamp","particle":"#0"},"render_type":"cutout",
1212            "elements":[{"from":[4,0,4],"to":[12,8,12],"rotation":{"angle":20,"axis":"y","origin":[8,8,8]},
1213            "faces":{"north":{"uv":[0,0,8,8],"texture":"#0","cullface":"north"}}}]}"##);
1214        w(
1215            "assets/foo/models/block/lamp.json",
1216            br#"{"parent":"foo:block/lamp_base"}"#,
1217        );
1218        w(
1219            "assets/foo/models/item/lamp.json",
1220            br#"{"parent":"foo:block/lamp"}"#,
1221        );
1222        w("assets/foo/textures/block/lamp.png", &picture());
1223        w(
1224            "assets/foo/lang/en_us.json",
1225            br#"{"block.foo.lamp":"Lamp"}"#,
1226        );
1227        let root = temp("mod");
1228        let mut t = target(&root);
1229        let pack = Pack::open(&pack_dir).unwrap();
1230        import_pack(&mut t, &pack, Some("foo:block/lamp"), false).unwrap();
1231        assert!(
1232            t.warnings.iter().any(|w| w.contains("20° → 22.5°")),
1233            "{:?}",
1234            t.warnings
1235        );
1236        let text = std::fs::read_to_string(root.join("content/blocks/lamp.yblock")).unwrap();
1237        assert!(text.contains("\"name\": \"Lamp\""), "{text}");
1238        assert!(text.contains("\"render_layer\": \"cutout\""), "{text}");
1239        assert!(text.contains("\"0\": \"@texture:mymod:block/lamp\""), "{text}");
1240        assert!(root.join("resources/block/lamp.ytexture").is_file());
1241        assert!(!text.contains("item_model"), "{text}");
1242        let images = crate::yres::parse(&std::fs::read_to_string(root.join("resources/BlockImages.yres")).unwrap()).unwrap();
1243        assert!(images.iter().any(|e| e.name == "block.lamp" && e.value == crate::yres::Value::Bytes(picture())));
1244        let refs = crate::yres::compile(&root, "mymod").unwrap().refs;
1245        let c = super::super::compile(&root, "mymod", &refs).unwrap();
1246        let bs = c
1247            .outputs
1248            .iter()
1249            .find(|(p, _)| p == "assets/mymod/blockstates/lamp.json")
1250            .unwrap();
1251        let bs: Value = serde_json::from_slice(&bs.1).unwrap();
1252        assert_eq!(bs["variants"][""][1]["y"], serde_json::json!(90));
1253        let _ = std::fs::remove_dir_all(pack_dir);
1254        let _ = std::fs::remove_dir_all(root);
1255    }
1256
1257    #[test]
1258    fn imports_a_bbmodel() {
1259        let root = temp("bb");
1260        let bb = root.join("thing.bbmodel");
1261        std::fs::write(
1262            &bb,
1263            r#"{"meta":{"model_format":"java_block"},"name":"Thing","resolution":{"width":32,"height":32},
1264                "textures":[{"name":"thing.png","uuid":"u1","source":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="}],
1265                "elements":[{"name":"cube","from":[0,0,0],"to":[16,8,16],"origin":[8,8,8],"rotation":[0,45,0],
1266                  "faces":{"north":{"uv":[0,0,32,16],"texture":0},"up":{"uv":[0,0,32,32],"texture":null}}}]}"#,
1267        )
1268        .unwrap();
1269        let mut t = target(&root);
1270        import_bbmodel(&mut t, &bb, None, false).unwrap();
1271        let text = std::fs::read_to_string(root.join("content/blocks/thing.yblock")).unwrap();
1272        assert!(text.contains("\"uv\": [0, 0, 16, 8]"), "{text}");
1273        assert!(!text.contains("\"up\""), "{text}");
1274        let images = crate::yres::parse(&std::fs::read_to_string(root.join("resources/BlockImages.yres")).unwrap()).unwrap();
1275        assert!(images.iter().any(|e| e.name == "block.thing"));
1276        let refs = crate::yres::compile(&root, "mymod").unwrap().refs;
1277        super::super::compile(&root, "mymod", &refs).unwrap();
1278        let _ = std::fs::remove_dir_all(root);
1279    }
1280}