Skip to main content

yog_cli/design/
blocks3d.rs

1//! `draw2d_blocks` in the designer: blocks in 3D, as the game draws them in
2//! a GUI (an orthographic view, turned and tilted), each block a cube with
3//! its model's boxes (`elements`: a cable's core, a slab's half) with
4//! their textures — the faces you'd see, back to front, the top lit, the
5//! sides shaded like Minecraft's. A model without elements is a cube.
6//! A block state picks its blockstate's variant (turned by its `x`/`y`)
7//! or every multipart part whose `when` it meets; elements keep their own
8//! rotations.
9
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use serde_json::Value;
14
15use super::assets::{Image, Resources, decode_png, missing_image};
16use super::raster::Canvas;
17
18/// A cube's textures per face: up, down, north, south, west, east.
19pub type Faces = [Option<Arc<Image>>; 6];
20
21/// One box of a model, in sixteenths of a block: per face (up, down,
22/// north, south, west, east) its texture, already cut to the face's uv
23/// (`None`: the box has no such face), and the turns it's drawn with, in
24/// order (its element's, then its variant's).
25#[derive(Clone)]
26pub struct Cuboid {
27    pub from: [f32; 3],
28    pub to: [f32; 3],
29    pub faces: Faces,
30    pub turns: Vec<Turn>,
31}
32
33/// A turn of a box about `axis` (0 x, 1 y, 2 z) through `origin`
34/// (sixteenths of a block), `degrees` counter-clockwise looking down the
35/// axis; `rescale` stretches the turned box's other axes back to the block
36/// (an element's `rescale`).
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct Turn {
39    pub origin: [f32; 3],
40    pub axis: usize,
41    pub degrees: f32,
42    pub rescale: bool,
43}
44
45/// A block's centre, in sixteenths: what a variant turns about.
46const CENTRE: [f32; 3] = [8.0, 8.0, 8.0];
47/// A block's side, in the sixteenths models are written in.
48const BLOCK_UNITS: f32 = 16.0;
49
50impl Turn {
51    /// `p` (sixteenths) turned.
52    fn apply(&self, p: [f32; 3]) -> [f32; 3] {
53        let (s, c) = self.degrees.to_radians().sin_cos();
54        let (a, b) = match self.axis {
55            0 => (1, 2),
56            1 => (2, 0),
57            _ => (0, 1),
58        };
59        let d = [0, 1, 2].map(|i| p[i] - self.origin[i]);
60        let mut out = d;
61        out[a] = d[a] * c - d[b] * s;
62        out[b] = d[a] * s + d[b] * c;
63        if self.rescale && c.abs() > f32::EPSILON {
64            let k = 1.0 / c.abs();
65            out[a] *= k;
66            out[b] *= k;
67        }
68        [0, 1, 2].map(|i| out[i] + self.origin[i])
69    }
70
71    /// A direction turned (no origin, no rescale).
72    fn apply_dir(&self, v: [f32; 3]) -> [f32; 3] {
73        Turn { origin: [0.0; 3], rescale: false, ..*self }.apply(v)
74    }
75}
76
77/// A block as drawn: its model's boxes.
78pub type Shape = Vec<Cuboid>;
79
80/// A whole-block cube with `faces` (a missing one: the missing texture).
81pub fn cube(faces: Faces) -> Shape {
82    let faces = faces.map(|f| Some(f.unwrap_or_else(|| Arc::new(missing_image()))));
83    vec![Cuboid { from: [0.0; 3], to: [BLOCK_UNITS; 3], faces, turns: Vec::new() }]
84}
85
86const FACE_NAMES: [&str; 6] = ["up", "down", "north", "south", "west", "east"];
87
88/// The texture variables the game's block models draw each face with (in
89/// [`FACE_NAMES`] order), most particular first: a cube's `up`, a
90/// column's `end`, a `cube_all`'s `all`, a one-texture model's `texture`.
91pub const FACE_TEXTURES: [&[&str]; 6] = [
92    &["up", "top", "end", "all", "texture"],
93    &["down", "bottom", "end", "all", "texture"],
94    &["north", "front", "side", "all", "texture"],
95    &["south", "side", "all", "texture"],
96    &["west", "side", "all", "texture"],
97    &["east", "side", "all", "texture"],
98];
99
100/// What a face falls back to when its model has none of [`FACE_TEXTURES`].
101const PARTICLE: &str = "particle";
102
103/// The part of a face's texture its box shows when the model gives no `uv`.
104fn default_uv(slot: usize, f: [f32; 3], t: [f32; 3]) -> [f32; 4] {
105    match slot {
106        0 => [f[0], f[2], t[0], t[2]],
107        1 => [f[0], BLOCK_UNITS - t[2], t[0], BLOCK_UNITS - f[2]],
108        2 => [BLOCK_UNITS - t[0], BLOCK_UNITS - t[1], BLOCK_UNITS - f[0], BLOCK_UNITS - f[1]],
109        3 => [f[0], BLOCK_UNITS - t[1], t[0], BLOCK_UNITS - f[1]],
110        4 => [f[2], BLOCK_UNITS - t[1], t[2], BLOCK_UNITS - f[1]],
111        _ => [BLOCK_UNITS - t[2], BLOCK_UNITS - t[1], BLOCK_UNITS - f[2], BLOCK_UNITS - f[1]],
112    }
113}
114
115/// `img`'s part `uv` (sixteenths of it; reversed: mirrored).
116fn cut(img: &Image, [u0, v0, u1, v1]: [f32; 4]) -> Image {
117    let (sx, sy) = (img.width as f32 / BLOCK_UNITS, img.width.min(img.height) as f32 / BLOCK_UNITS);
118    let (x0, x1) = ((u0 * sx).round() as i32, (u1 * sx).round() as i32);
119    let (y0, y1) = ((v0 * sy).round() as i32, (v1 * sy).round() as i32);
120    let (w, h) = ((x1 - x0).unsigned_abs().max(1), (y1 - y0).unsigned_abs().max(1));
121    let mut rgba = Vec::with_capacity((w * h * 4) as usize);
122    for y in 0..h as i32 {
123        for x in 0..w as i32 {
124            let px = if x1 >= x0 { x0 + x } else { x0 - 1 - x };
125            let py = if y1 >= y0 { y0 + y } else { y0 - 1 - y };
126            let (px, py) = (px.clamp(0, img.width as i32 - 1) as u32, py.clamp(0, img.height as i32 - 1) as u32);
127            rgba.extend_from_slice(&img.pixel(px, py).to_rgba8());
128        }
129    }
130    Image { width: w, height: h, rgba }
131}
132
133/// A model's `elements` as boxes; `texture("#var" or "var")` gives a face's texture.
134pub fn shape_of(elements: &[Value], texture: &mut dyn FnMut(&str) -> Option<Arc<Image>>) -> Shape {
135    let triple = |v: Option<&Value>| -> Option<[f32; 3]> {
136        let a = v?.as_array()?;
137        Some([a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32, a.get(2)?.as_f64()? as f32])
138    };
139    elements
140        .iter()
141        .filter_map(|e| {
142            let (from, to) = (triple(e.get(yog_ids::model::FROM))?, triple(e.get(yog_ids::model::TO))?);
143            let (f, t) = ([0, 1, 2].map(|i| from[i].min(to[i])), [0, 1, 2].map(|i| from[i].max(to[i])));
144            let faces = e.get(yog_ids::model::FACES)?;
145            let faces: Faces = std::array::from_fn(|slot| {
146                let face = faces.get(FACE_NAMES[slot])?;
147                let img = face.get(yog_ids::model::TEXTURE).and_then(Value::as_str).and_then(&mut *texture).unwrap_or_else(|| Arc::new(missing_image()));
148                let uv = face
149                    .get(yog_ids::model::UV)
150                    .and_then(Value::as_array)
151                    .and_then(|a| Some([a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32, a.get(2)?.as_f64()? as f32, a.get(3)?.as_f64()? as f32]))
152                    .unwrap_or_else(|| default_uv(slot, f, t));
153                Some(Arc::new(cut(&img, uv)))
154            });
155            Some(Cuboid { from: f, to: t, faces, turns: element_turn(e).into_iter().collect() })
156        })
157        .collect()
158}
159
160/// An element's `rotation`: `{ origin, axis: "x"|"y"|"z", angle, rescale }`.
161fn element_turn(e: &Value) -> Option<Turn> {
162    let r = e.get(yog_ids::model::ROTATION)?;
163    let o = r.get(yog_ids::model::ORIGIN)?.as_array()?;
164    let origin = [o.first()?.as_f64()? as f32, o.get(1)?.as_f64()? as f32, o.get(2)?.as_f64()? as f32];
165    let axis = ["x", "y", "z"].iter().position(|a| r.get(yog_ids::model::AXIS).and_then(Value::as_str) == Some(*a))?;
166    let degrees = r.get(yog_ids::model::ANGLE)?.as_f64()? as f32;
167    Some(Turn { origin, axis, degrees, rescale: r.get(yog_ids::model::RESCALE).and_then(Value::as_bool).unwrap_or(false) })
168}
169
170/// The first `elements` up a model's parents.
171fn model_elements(res: &Resources, model: &str, depth: usize) -> Option<Vec<Value>> {
172    if depth > 8 {
173        return None;
174    }
175    let (ns, path) = yog_ids::split(model);
176    let path = path.strip_prefix("block/").map_or(format!("block/{path}"), |p| format!("block/{p}"));
177    let json: Value = serde_json::from_slice(&res.read(ns, &yog_ids::model_file(&path))?).ok()?;
178    if let Some(e) = json.get(yog_ids::model::ELEMENTS).and_then(Value::as_array) {
179        return Some(e.clone());
180    }
181    model_elements(res, json.get(yog_ids::model::PARENT)?.as_str()?, depth + 1)
182}
183
184/// The texture variables of a block model, its parents' included.
185fn model_textures(res: &Resources, model: &str, depth: usize) -> HashMap<String, String> {
186    let mut out = HashMap::new();
187    if depth > 8 {
188        return out;
189    }
190    let (ns, path) = yog_ids::split(model);
191    let path = path.strip_prefix("block/").map_or(format!("block/{path}"), |p| format!("block/{p}"));
192    let Some(json) = res.read(ns, &yog_ids::model_file(&path)).and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else {
193        return out;
194    };
195    if let Some(parent) = json.get(yog_ids::model::PARENT).and_then(Value::as_str) {
196        out = model_textures(res, parent, depth + 1);
197    }
198    if let Some(t) = json.get(yog_ids::model::TEXTURES).and_then(Value::as_object) {
199        for (k, v) in t {
200            if let Some(v) = v.as_str() {
201                out.insert(k.clone(), v.to_string());
202            }
203        }
204    }
205    out
206}
207
208/// A block state's id and its properties: `minecraft:furnace[facing=north]`.
209fn split_state(state: &str) -> (&str, HashMap<&str, &str>) {
210    let (id, props) = state.split_once('[').unwrap_or((state, ""));
211    let props = props.trim_end_matches(']').split(',').filter_map(|kv| kv.split_once('=')).map(|(k, v)| (k.trim(), v.trim())).collect();
212    (id, props)
213}
214
215/// Whether `props` meet a multipart `when` (every property one of its
216/// `a|b` values; `OR`/`AND`: any/all of a list). A property the state
217/// doesn't name meets nothing.
218fn meets(when: &Value, props: &HashMap<&str, &str>) -> bool {
219    let Some(map) = when.as_object() else { return true };
220    if let Some(any) = map.get(yog_ids::model::OR).and_then(Value::as_array) {
221        return any.iter().any(|w| meets(w, props));
222    }
223    if let Some(all) = map.get(yog_ids::model::AND).and_then(Value::as_array) {
224        return all.iter().all(|w| meets(w, props));
225    }
226    map.iter().all(|(k, v)| {
227        let want = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
228        props.get(k.as_str()).is_some_and(|have| want.split('|').any(|w| w == *have))
229    })
230}
231
232/// A blockstate's `apply`/variant: its model and its turns (`x` then `y`,
233/// about the block's centre; the game turns models clockwise looking down
234/// an axis).
235fn applied(v: &Value) -> Option<(String, Vec<Turn>)> {
236    let v = if v.is_array() { v.get(0)? } else { v };
237    let model = v.get(yog_ids::model::MODEL)?.as_str()?.to_string();
238    let mut turns = Vec::new();
239    for (axis, key) in [(0, "x"), (1, "y")] {
240        let deg = v.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32;
241        if deg != 0.0 {
242            turns.push(Turn { origin: CENTRE, axis, degrees: -deg, rescale: false });
243        }
244    }
245    Some((model, turns))
246}
247
248/// The models block state `state` shows: its variant (the one whose
249/// properties it has; the first otherwise), or each multipart part it meets.
250fn block_models(res: &Resources, state: &str) -> Vec<(String, Vec<Turn>)> {
251    let (id, props) = split_state(state);
252    let (ns, path) = yog_ids::split(id);
253    let Some(json) = res.read(ns, &yog_ids::blockstate_file(path)).and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else {
254        return Vec::new();
255    };
256    if let Some(variants) = json.get(yog_ids::model::VARIANTS).and_then(Value::as_object) {
257        let fits = |key: &str| key.split(',').filter(|kv| !kv.is_empty()).all(|kv| kv.split_once('=').is_some_and(|(k, v)| props.get(k) == Some(&v)));
258        let chosen = variants.iter().find(|(k, _)| fits(k)).or_else(|| variants.iter().next());
259        return chosen.and_then(|(_, v)| applied(v)).into_iter().collect();
260    }
261    json.get(yog_ids::model::MULTIPART)
262        .and_then(Value::as_array)
263        .into_iter()
264        .flatten()
265        .filter(|part| part.get(yog_ids::model::WHEN).is_none_or(|w| meets(w, &props)))
266        .filter_map(|part| applied(part.get(yog_ids::model::APPLY)?))
267        .collect()
268}
269
270/// Whether `id` is a block (the game's, or the mod's `.yblock`).
271pub fn is_block(res: &Resources, id: &str) -> bool {
272    let id = split_state(id).0;
273    let (ns, path) = yog_ids::split(id);
274    res.read(ns, &yog_ids::blockstate_file(path)).is_some()
275        || res.mod_root.as_deref().is_some_and(|root| crate::design::content::content_block_faces(root, id).is_some())
276}
277
278fn shape(res: &Resources, state: &str, cache: &mut HashMap<String, Shape>) -> Shape {
279    if let Some(s) = cache.get(state) {
280        return s.clone();
281    }
282    let id = split_state(state).0;
283    let s = mod_shape(res, id).unwrap_or_else(|| game_shape(res, state));
284    cache.insert(state.to_string(), s.clone());
285    s
286}
287
288/// The mod's own block: its `.yblock` model — its elements, else a cube of its textures.
289fn mod_shape(res: &Resources, id: &str) -> Option<Shape> {
290    let root = res.mod_root.as_deref()?;
291    if let Some((elements, textures)) = crate::design::content::content_block_model(root, id) {
292        let mut decoded: HashMap<String, Option<Arc<Image>>> = HashMap::new();
293        let mut texture = |var: &str| -> Option<Arc<Image>> {
294            let var = var.trim_start_matches('#');
295            decoded.entry(var.to_string()).or_insert_with(|| textures.get(var).and_then(|b| decode_png(b)).map(Arc::new)).clone()
296        };
297        let s = shape_of(&elements, &mut texture);
298        if !s.is_empty() {
299            return Some(s);
300        }
301    }
302    crate::design::content::content_block_faces(root, id).map(|faces| cube(faces.map(|b| b.and_then(|b| decode_png(&b)).map(Arc::new))))
303}
304
305/// The game's block state: every model its blockstate shows for it, each
306/// turned as the blockstate says.
307fn game_shape(res: &Resources, state: &str) -> Shape {
308    let mut models = block_models(res, state);
309    if models.is_empty() {
310        let (ns, path) = yog_ids::split(split_state(state).0);
311        models.push((format!("{ns}:block/{path}"), Vec::new()));
312    }
313    let mut out = Shape::new();
314    for (model, turns) in models {
315        for mut cuboid in model_shape(res, &model) {
316            cuboid.turns.extend(turns.iter().copied());
317            out.push(cuboid);
318        }
319    }
320    out
321}
322
323/// One block model's boxes: its elements, else a cube of its textures.
324fn model_shape(res: &Resources, model: &str) -> Shape {
325    let vars = model_textures(res, model, 0);
326    // `#var` → its value, following references.
327    let resolve = |key: &str| -> Option<String> {
328        let mut v = vars.get(key)?.clone();
329        for _ in 0..8 {
330            match v.strip_prefix('#') {
331                Some(r) => v = vars.get(r)?.clone(),
332                None => return Some(v),
333            }
334        }
335        None
336    };
337    let load = |tex: Option<String>| -> Option<Arc<Image>> {
338        let tex = tex?;
339        let (ns, path) = yog_ids::split(&tex);
340        decode_png(&res.read(ns, &yog_ids::texture_file(path))?).map(Arc::new)
341    };
342    if let Some(elements) = model_elements(res, model, 0) {
343        let mut texture = |var: &str| load(resolve(var.trim_start_matches('#')));
344        let s = shape_of(&elements, &mut texture);
345        if !s.is_empty() {
346            return s;
347        }
348    }
349    cube(FACE_TEXTURES.map(|keys| load(keys.iter().chain(&[PARTICLE]).find_map(|k| resolve(k)))))
350}
351
352/// `x y z state` lines → blocks.
353fn parse(list: &str) -> Vec<([i32; 3], String)> {
354    list.lines()
355        .filter_map(|l| {
356            let mut p = l.trim().splitn(4, ' ');
357            let at = [p.next()?.parse().ok()?, p.next()?.parse().ok()?, p.next()?.parse().ok()?];
358            Some((at, p.next()?.to_string()))
359        })
360        .collect()
361}
362
363pub fn draw(c: &mut Canvas, list: &str, rect: [f32; 4], yaw: f32, pitch: f32) {
364    let mut cache = HashMap::new();
365    draw_with(c, &parse(list), rect, yaw, pitch, |res, id| shape(res, id, &mut cache));
366}
367
368/// One cube with its own `faces` (a mob's head, say), as [`draw`] draws a block.
369pub fn draw_cube(c: &mut Canvas, faces: Faces, rect: [f32; 4], yaw: f32, pitch: f32) {
370    draw_with(c, &[([0, 0, 0], String::new())], rect, yaw, pitch, |_, _| cube(faces.clone()));
371}
372
373fn draw_with(
374    c: &mut Canvas,
375    blocks: &[([i32; 3], String)],
376    [x, y, w, h]: [f32; 4],
377    yaw: f32,
378    pitch: f32,
379    mut shape_of: impl FnMut(&Resources, &str) -> Shape,
380) {
381    if blocks.is_empty() || w <= 0.0 || h <= 0.0 {
382        return;
383    }
384    let mut lo = [i32::MAX; 3];
385    let mut hi = [i32::MIN; 3];
386    for (p, _) in blocks {
387        for i in 0..3 {
388            lo[i] = lo[i].min(p[i]);
389            hi[i] = hi[i].max(p[i]);
390        }
391    }
392    let size = (0..3).map(|i| hi[i] - lo[i]).max().unwrap_or(0) as f32 + 1.0;
393    // As the hosts: as big as fits (1.7 × the largest side).
394    let s = w.min(h) / (size * 1.7);
395    let centre = [(lo[0] + hi[0] + 1) as f32 / 2.0, (lo[1] + hi[1] + 1) as f32 / 2.0, (lo[2] + hi[2] + 1) as f32 / 2.0];
396    let (sy, cy) = yaw.to_radians().sin_cos();
397    let (sp, cp) = pitch.to_radians().sin_cos();
398    // World → view: about Y by yaw, then about X by pitch; x right, y up, z toward the viewer.
399    let view = |p: [f32; 3]| {
400        let (px, py, pz) = (p[0] - centre[0], p[1] - centre[1], p[2] - centre[2]);
401        let x1 = px * cy + pz * sy;
402        let z1 = -px * sy + pz * cy;
403        let y2 = py * cp - z1 * sp;
404        let z2 = py * sp + z1 * cp;
405        [x1, y2, z2]
406    };
407    let screen = |v: [f32; 3]| [x + w / 2.0 + v[0] * s, y + h / 2.0 - v[1] * s];
408    let occupied: HashSet<[i32; 3]> = blocks.iter().map(|(p, _)| *p).collect();
409
410    // Every face that can be seen: (depth, texture, corner, edge a, edge b, shade).
411    let mut quads = Vec::new();
412    // (normal, corner offset, a, b, slot, shade) per face of a unit cube.
413    const FACES: [([i32; 3], [f32; 3], [f32; 3], [f32; 3], usize, f32); 6] = [
414        ([0, 1, 0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0, 1.0),
415        ([0, -1, 0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], 1, 0.5),
416        ([0, 0, -1], [1.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], 2, 0.8),
417        ([0, 0, 1], [0.0, 1.0, 1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0], 3, 0.8),
418        ([-1, 0, 0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0], 4, 0.6),
419        ([1, 0, 0], [1.0, 1.0, 1.0], [0.0, 0.0, -1.0], [0.0, -1.0, 0.0], 5, 0.6),
420    ];
421    for (p, id) in blocks {
422        for cuboid in shape_of(c.resources(), id) {
423            let (f, t) = (cuboid.from, cuboid.to);
424            // A unit cube's corner → this box's (in sixteenths, turned), in the world.
425            let at = |u: [f32; 3]| {
426                let q = cuboid.turns.iter().fold([0, 1, 2].map(|i| f[i] + u[i] * (t[i] - f[i])), |q, turn| turn.apply(q));
427                [0, 1, 2].map(|i| p[i] as f32 + q[i] / BLOCK_UNITS)
428            };
429            for (n, corner, a, b, slot, shade) in FACES {
430                // On the block's side, against a neighbour: hidden (a turned box is never flush).
431                let on_side = cuboid.turns.is_empty()
432                    && (0..3).all(|i| n[i] == 0 || (n[i] > 0 && t[i] >= BLOCK_UNITS) || (n[i] < 0 && f[i] <= 0.0));
433                if on_side && occupied.contains(&[p[0] + n[0], p[1] + n[1], p[2] + n[2]]) {
434                    continue;
435                }
436                // Facing away from the viewer: unseen.
437                let normal = cuboid.turns.iter().fold(n.map(|v| v as f32), |v, turn| turn.apply_dir(v));
438                let nv = view([0, 1, 2].map(|i| centre[i] + normal[i]));
439                if nv[2] <= 0.0 {
440                    continue;
441                }
442                let Some(img) = cuboid.faces[slot].clone() else { continue };
443                let o = at(corner);
444                let oa = at([corner[0] + a[0], corner[1] + a[1], corner[2] + a[2]]);
445                let ob = at([corner[0] + b[0], corner[1] + b[1], corner[2] + b[2]]);
446                let (vo, va, vb) = (view(o), view(oa), view(ob));
447                let mid = view([0, 1, 2].map(|i| (oa[i] + ob[i]) / 2.0));
448                let (so, sa, sb) = (screen(vo), screen(va), screen(vb));
449                quads.push((mid[2], Some(img), so, [sa[0] - so[0], sa[1] - so[1]], [sb[0] - so[0], sb[1] - so[1]], shade));
450            }
451        }
452    }
453    // Back to front.
454    quads.sort_by(|a, b| a.0.total_cmp(&b.0));
455    let missing = Arc::new(missing_image());
456    for (_, img, o, a, b, shade) in quads {
457        let img = img.unwrap_or_else(|| missing.clone());
458        c.quad(&img, o, a, b, shade);
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn solid(w: u32, h: u32) -> Image {
467        Image { width: w, height: h, rgba: (0..w * h).flat_map(|i| [i as u8, 0, 0, 255]).collect() }
468    }
469
470    fn opaque(shape: Shape) -> usize {
471        let res = Resources { mod_root: None, vanilla: None };
472        let mut c = Canvas::new(32, 32, 1, &res);
473        draw_with(&mut c, &[([0, 0, 0], "x".into())], [0.0, 0.0, 32.0, 32.0], 225.0, 30.0, |_, _| shape.clone());
474        c.rgba.chunks_exact(4).filter(|p| p[3] > 0).count()
475    }
476
477    #[test]
478    fn a_models_boxes_are_drawn_their_size_with_their_uv() {
479        let img = Arc::new(solid(16, 16));
480        let elements: Vec<Value> = serde_json::from_str(
481            r##"[{ "from": [5, 5, 5], "to": [11, 11, 11], "faces": {
482                "up": { "uv": [5, 5, 11, 11], "texture": "#all" }, "north": { "texture": "#all" } } }]"##,
483        )
484        .unwrap();
485        let shape = shape_of(&elements, &mut |v: &str| (v == "#all").then(|| img.clone()));
486        assert_eq!(shape.len(), 1);
487        let up = shape[0].faces[0].as_ref().unwrap();
488        assert_eq!((up.width, up.height), (6, 6), "the uv's part");
489        assert_eq!(up.pixel(0, 0), img.pixel(5, 5));
490        let north = shape[0].faces[2].as_ref().unwrap();
491        assert_eq!((north.width, north.height), (6, 6), "no uv: the box's own part");
492        assert!(shape[0].faces[1].is_none(), "a face the model leaves out isn't drawn");
493
494        let small = opaque(shape);
495        let full = opaque(cube(std::array::from_fn(|_| Some(img.clone()))));
496        assert!(small > 0 && small * 4 < full, "a cable's core, not a whole block: {small} vs {full}");
497    }
498
499    #[test]
500    fn parses_block_lines() {
501        let b = parse("0 0 0 minecraft:stone\n1 2 -1 minecraft:furnace[facing=north]\nbad");
502        assert_eq!(b, [([0, 0, 0], "minecraft:stone".to_string()), ([1, 2, -1], "minecraft:furnace[facing=north]".to_string())]);
503    }
504
505    #[test]
506    fn a_state_meets_multipart_conditions() {
507        let (id, props) = split_state("minecraft:brewing_stand[has_bottle_0=true,has_bottle_1=false]");
508        assert_eq!(id, "minecraft:brewing_stand");
509        let when = |j: &str| serde_json::from_str::<Value>(j).unwrap();
510        assert!(meets(&when(r#"{ "has_bottle_0": "true" }"#), &props));
511        assert!(!meets(&when(r#"{ "has_bottle_1": "true" }"#), &props));
512        assert!(meets(&when(r#"{ "has_bottle_1": "true|false" }"#), &props));
513        assert!(meets(&when(r#"{ "OR": [{ "has_bottle_2": "true" }, { "has_bottle_0": "true" }] }"#), &props));
514        assert!(!meets(&when(r#"{ "has_bottle_2": "false" }"#), &props), "a property the state doesn't name");
515    }
516
517    #[test]
518    fn turns_turn_about_their_origin() {
519        let close = |a: [f32; 3], b: [f32; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-4);
520        // A quarter turn about y through the centre: east goes north.
521        let y = Turn { origin: CENTRE, axis: 1, degrees: 90.0, rescale: false };
522        assert!(close(y.apply([16.0, 8.0, 8.0]), [8.0, 8.0, 0.0]), "{:?}", y.apply([16.0, 8.0, 8.0]));
523        assert!(close(y.apply(CENTRE), CENTRE));
524        // Rescaled at 45°: the diagonal is stretched back to the block.
525        let r = Turn { origin: CENTRE, axis: 1, degrees: 45.0, rescale: true };
526        let d = r.apply([16.0, 8.0, 8.0]);
527        assert!(((d[0] - 8.0).powi(2) + (d[2] - 8.0).powi(2)).sqrt() > 8.0 * 1.4);
528    }
529}
530