Skip to main content

delvewright_dsl/
rig.rs

1//! **The rig: the parts and clips of an assembly** (spec-0082 §3.1).
2//!
3//! An assembly (`assemblies[]`, stage 5) is a thing built of display entities
4//! that moves through authored clips. Its keyframes are thousands of numbers —
5//! a procedural derivation, never a creative judgement — so they do not live in
6//! campaign JSON. They live in a **rig file** beside the prefab library,
7//! `<library>/rigs/<name>/rig.json`, written by a deterministic generator the
8//! way a tileset generator writes `.nbt`, and referenced from the campaign as
9//! `rig/<name>` exactly as a piece is referenced as `prefab/<name>`.
10//!
11//! This module is the one reader of that file and the one statement of what it
12//! means:
13//!
14//! * [`Rig`] is the document, parsed with unknown keys refused.
15//! * [`check`] is every structural rule (`DW0935`): part count, block ids
16//!   against the pinned block registry (the `DW0193` rule), every clip
17//!   non-empty with one transform per part per frame, a cadence in
18//!   `1..=20`, a finite transform whose scale is not zero on any axis.
19//! * [`frame_footprint`] / [`clip_footprint`] are **the** footprint arithmetic
20//!   (§5.2): the cells a frame's parts occupy, as the compiler computes them
21//!   from the transforms. `delvec rig describe` prints from it and the strike
22//!   check judges from it — one symbol, so the number a creator is handed is
23//!   the number the engine refuses against.
24//! * [`Facing`]-rotation of a transform ([`Transform::faced`]) is applied here,
25//!   once, for the emitter and the footprint alike.
26//!
27//! Determinism (ADR-0006): clips are a `BTreeMap`, footprints are `BTreeSet`s,
28//! and nothing here reads a clock or a hash order.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use serde::{Deserialize, Serialize};
33
34use crate::Facing;
35
36/// The only rig document version this engine reads.
37pub const RIG_VERSION: u32 = 1;
38
39/// The directory under a prefab library that holds rigs.
40pub const RIGS_DIR: &str = "rigs";
41
42/// The file inside `rigs/<name>/` that is the rig document.
43pub const RIG_FILE: &str = "rig.json";
44
45/// The slowest keyframe cadence a clip may declare, in ticks per frame.
46///
47/// A frame held a whole second is no longer one movement the client
48/// interpolates; it is a pose. (Authored, spec-0082 §3.1.)
49pub const MAX_TICKS_PER_FRAME: u32 = 20;
50
51/// The fastest: one keyframe every tick.
52pub const MIN_TICKS_PER_FRAME: u32 = 1;
53
54/// One rig document.
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Rig {
58    /// The document version; [`RIG_VERSION`].
59    pub rig_version: u32,
60    /// One display entity each, in emission order.
61    pub parts: Vec<RigPart>,
62    /// Named clips. A frame is one transform per part, in [`Self::parts`]
63    /// order, in the rig's own frame: origin at the assembly's mark (the cell's
64    /// centre at the mark's floor plane), `+z` the rig's front.
65    pub clips: BTreeMap<String, Clip>,
66    /// Where the rig came from (ADR-0013's record, as a piece's metadata
67    /// carries it).
68    pub provenance: RigProvenance,
69}
70
71/// One part: one display entity.
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct RigPart {
75    /// The part's name, unique within the rig. Read by people and diagnostics.
76    pub id: String,
77    /// What kind of display the part is.
78    pub kind: PartKind,
79    /// The block state a `block` part shows (`minecraft:sculk`,
80    /// `minecraft:oak_log[axis=x]`), checked against the pinned block registry.
81    pub block: String,
82    /// The part's rest pose, the transform it takes when no clip plays. Absent:
83    /// the first frame of the rig's first clip.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub rest: Option<Transform>,
86}
87
88/// What kind of display entity a part is. `block` today; `item` is the next
89/// kind and is not written (spec-0082 §3.1).
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum PartKind {
93    /// A `minecraft:block_display` showing [`RigPart::block`].
94    Block,
95}
96
97/// One clip.
98#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct Clip {
101    /// The keyframe cadence, and the `interpolation_duration` the compiler
102    /// writes on every part (`1..=20`).
103    pub ticks_per_frame: u32,
104    /// Whether the clip starts over after its last frame. A clip that does not
105    /// loop holds its last frame.
106    #[serde(rename = "loop")]
107    pub looping: bool,
108    /// The keyframes: one transform per part per frame.
109    pub frames: Vec<Vec<Transform>>,
110}
111
112impl Clip {
113    /// How long the clip runs in ticks, from the tick its first frame is
114    /// applied to the tick its last frame is applied: `(frames - 1) ×
115    /// ticks_per_frame`, plus the one tick between a switch and its first frame.
116    /// The number `delvec rig describe` prints and a `sequence` after the clip
117    /// is timed by.
118    pub fn length_ticks(&self) -> u32 {
119        1 + (self.frames.len().saturating_sub(1) as u32) * self.ticks_per_frame
120    }
121
122    /// How long from the switch until a client has drawn the clip's last
123    /// frame whole: [`Self::length_ticks`] plus one cadence, because each
124    /// keyframe is drawn over `ticks_per_frame` ticks after it is applied. A
125    /// strike's blow lands on this tick, so the limb the player sees is the
126    /// last frame the strike check judges (spec-0082 §5.4).
127    pub fn landing_ticks(&self) -> u32 {
128        self.length_ticks() + self.ticks_per_frame
129    }
130}
131
132/// A display entity's transformation (`Display` entity data, Minecraft Wiki):
133/// the rendered model is `translation · left_rotation · scale · right_rotation`
134/// applied to the unit cube.
135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct Transform {
138    /// `[x, y, z]` translation, in blocks.
139    pub translation: [f64; 3],
140    /// `[x, y, z, w]` quaternion applied after scale.
141    pub left_rotation: [f64; 4],
142    /// `[x, y, z]` scale.
143    pub scale: [f64; 3],
144    /// `[x, y, z, w]` quaternion applied before scale.
145    pub right_rotation: [f64; 4],
146}
147
148/// The rig's provenance record.
149#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
150#[serde(deny_unknown_fields)]
151pub struct RigProvenance {
152    /// The program that wrote the rig (`prefabs/rig-generator`), with its
153    /// revision where it states one.
154    pub generator: String,
155    /// `original`, `cc0`, … — the source class ADR-0013 admits.
156    pub source: String,
157    /// The SPDX licence the rig is published under.
158    pub spdx: String,
159}
160
161/// One structural refusal of a rig: the field it names and what is wrong.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct RigIssue {
164    /// A JSON pointer into the rig document.
165    pub field: String,
166    /// What is wrong, in a sentence.
167    pub message: String,
168}
169
170/// Parse a rig document, refusing unknown keys.
171pub fn parse(raw: &str) -> Result<Rig, String> {
172    serde_json::from_str::<Rig>(raw).map_err(|e| e.to_string())
173}
174
175/// Whether a clip name is one the engine can address: lowercase kebab, the
176/// shape every id segment in the DSL takes.
177pub fn is_clip_name(name: &str) -> bool {
178    crate::ids::is_kebab(name)
179}
180
181/// **Every structural rule a rig obeys** (`DW0935`, spec-0082 §5.1), each
182/// refusal naming its field. Empty for a rig the engine can emit.
183pub fn check(rig: &Rig) -> Vec<RigIssue> {
184    let mut out = Vec::new();
185    let mut push = |field: String, message: String| out.push(RigIssue { field, message });
186    if rig.rig_version != RIG_VERSION {
187        push(
188            "/rig_version".into(),
189            format!(
190                "`rig_version` is {}; this engine reads version {RIG_VERSION}",
191                rig.rig_version
192            ),
193        );
194    }
195    if rig.parts.is_empty() {
196        push(
197            "/parts".into(),
198            "the rig declares no part, so the assembly would show nothing".into(),
199        );
200    }
201    let mut seen: BTreeSet<&str> = BTreeSet::new();
202    for (i, p) in rig.parts.iter().enumerate() {
203        if p.id.is_empty() {
204            push(format!("/parts/{i}/id"), "a part's `id` is empty".into());
205        } else if !seen.insert(p.id.as_str()) {
206            push(
207                format!("/parts/{i}/id"),
208                format!("part id `{}` is declared twice", p.id),
209            );
210        }
211        if let Err(e) = crate::blocks::BlockRegistry::v1_21_11().validate_state_string(&p.block) {
212            push(
213                format!("/parts/{i}/block"),
214                format!(
215                    "part `{}` shows `{}`, which is not a block state of Minecraft Java \
216                     1.21.11 ({e:?})",
217                    p.id, p.block
218                ),
219            );
220        }
221        if let Some(t) = &p.rest {
222            for m in transform_issues(t) {
223                push(format!("/parts/{i}/rest"), m);
224            }
225        }
226    }
227    if rig.clips.is_empty() {
228        push(
229            "/clips".into(),
230            "the rig declares no clip, so the assembly has nothing to play and no pose to \
231             stand in"
232                .into(),
233        );
234    }
235    for (name, clip) in &rig.clips {
236        let at = format!("/clips/{name}");
237        if !is_clip_name(name) {
238            push(
239                at.clone(),
240                format!(
241                    "clip name `{name}` is not lowercase kebab-case (`[a-z0-9]` segments joined by `-`)"
242                ),
243            );
244        }
245        if !(MIN_TICKS_PER_FRAME..=MAX_TICKS_PER_FRAME).contains(&clip.ticks_per_frame) {
246            push(
247                format!("{at}/ticks_per_frame"),
248                format!(
249                    "clip `{name}` declares `ticks_per_frame` {}; the cadence is \
250                     {MIN_TICKS_PER_FRAME}..={MAX_TICKS_PER_FRAME} ticks per frame",
251                    clip.ticks_per_frame
252                ),
253            );
254        }
255        if clip.frames.is_empty() {
256            push(
257                format!("{at}/frames"),
258                format!("clip `{name}` has no frame"),
259            );
260        }
261        for (f, frame) in clip.frames.iter().enumerate() {
262            if frame.len() != rig.parts.len() {
263                push(
264                    format!("{at}/frames/{f}"),
265                    format!(
266                        "clip `{name}` frame {f} carries {} transform(s) and the rig has {} \
267                         part(s); a frame is one transform per part",
268                        frame.len(),
269                        rig.parts.len()
270                    ),
271                );
272            }
273            for (p, t) in frame.iter().enumerate() {
274                for m in transform_issues(t) {
275                    push(format!("{at}/frames/{f}/{p}"), m);
276                }
277            }
278        }
279    }
280    out
281}
282
283/// What is wrong with one transform: a non-finite number, a scale of zero on
284/// an axis (the part would vanish into a plane), or a quaternion of zero
285/// length (no rotation at all, not the identity).
286fn transform_issues(t: &Transform) -> Vec<String> {
287    let mut out = Vec::new();
288    let all = t
289        .translation
290        .iter()
291        .chain(t.left_rotation.iter())
292        .chain(t.scale.iter())
293        .chain(t.right_rotation.iter());
294    if all.clone().any(|v| !v.is_finite()) {
295        out.push("a transform carries a value that is not a finite number".to_string());
296        return out;
297    }
298    for (axis, v) in ["x", "y", "z"].iter().zip(t.scale) {
299        if v == 0.0 {
300            out.push(format!(
301                "`scale` is 0 on {axis}: the part collapses to a plane and shows nothing"
302            ));
303        }
304    }
305    for (name, q) in [
306        ("left_rotation", t.left_rotation),
307        ("right_rotation", t.right_rotation),
308    ] {
309        if q.iter().map(|v| v * v).sum::<f64>() < 1e-12 {
310            out.push(format!("`{name}` is a quaternion of zero length"));
311        }
312    }
313    out
314}
315
316impl Rig {
317    /// The clip names, in the order the compiler numbers them.
318    pub fn clip_names(&self) -> Vec<&str> {
319        self.clips.keys().map(String::as_str).collect()
320    }
321
322    /// The compiler's index for a clip: its position in name order.
323    pub fn clip_index(&self, name: &str) -> Option<usize> {
324        self.clips.keys().position(|k| k == name)
325    }
326
327    /// The rest pose: each part's `rest`, else the first frame of the first
328    /// clip. `None` only for a rig [`check`] refuses.
329    pub fn rest_pose(&self) -> Option<Vec<Transform>> {
330        let first = self.clips.values().next().and_then(|c| c.frames.first());
331        self.parts
332            .iter()
333            .enumerate()
334            .map(|(i, p)| {
335                p.rest
336                    .clone()
337                    .or_else(|| first.and_then(|f| f.get(i).cloned()))
338            })
339            .collect()
340    }
341
342    /// Total frames across every clip — the count of frame functions the
343    /// compiler emits for this rig.
344    pub fn frame_count(&self) -> usize {
345        self.clips.values().map(|c| c.frames.len()).sum()
346    }
347}
348
349impl RigPart {
350    /// The part's block state as `block_display` NBT: `{Name:"…"}` or
351    /// `{Name:"…",Properties:{k:"v",…}}`.
352    pub fn block_state_snbt(&self) -> String {
353        let (name, props) = crate::blocks::parse_state(&self.block);
354        let name = if name.contains(':') {
355            name.to_string()
356        } else {
357            format!("minecraft:{name}")
358        };
359        if props.is_empty() {
360            format!("{{Name:\"{name}\"}}")
361        } else {
362            let p: Vec<String> = props.iter().map(|(k, v)| format!("{k}:\"{v}\"")).collect();
363            format!("{{Name:\"{name}\",Properties:{{{}}}}}", p.join(","))
364        }
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Facing and the transform arithmetic
370// ---------------------------------------------------------------------------
371
372/// The yaw a facing turns the rig's `+z` front to, as the angle of a rotation
373/// about `+y` (right-handed: `+z` turns toward `+x`). `south` is the identity.
374pub fn facing_angle(f: Facing) -> f64 {
375    match f {
376        Facing::South => 0.0,
377        Facing::East => std::f64::consts::FRAC_PI_2,
378        Facing::North => std::f64::consts::PI,
379        Facing::West => -std::f64::consts::FRAC_PI_2,
380    }
381}
382
383/// Hamilton product `a · b` of `[x, y, z, w]` quaternions.
384fn qmul(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
385    let [ax, ay, az, aw] = a;
386    let [bx, by, bz, bw] = b;
387    [
388        aw * bx + ax * bw + ay * bz - az * by,
389        aw * by - ax * bz + ay * bw + az * bx,
390        aw * bz + ax * by - ay * bx + az * bw,
391        aw * bw - ax * bx - ay * by - az * bz,
392    ]
393}
394
395/// Rotate `v` by the unit quaternion `q`.
396fn qrot(q: [f64; 4], v: [f64; 3]) -> [f64; 3] {
397    let n = q.iter().map(|c| c * c).sum::<f64>().sqrt();
398    let q = [q[0] / n, q[1] / n, q[2] / n, q[3] / n];
399    let p = qmul(
400        qmul(q, [v[0], v[1], v[2], 0.0]),
401        [-q[0], -q[1], -q[2], q[3]],
402    );
403    [p[0], p[1], p[2]]
404}
405
406/// Snap a value within a billionth of an integer to it, and `-0.0` to `0.0`,
407/// so a quarter turn of an exact number stays exact and two machines format it
408/// identically.
409fn tidy(v: f64) -> f64 {
410    let r = v.round();
411    let v = if (v - r).abs() < 1e-9 { r } else { v };
412    if v == 0.0 { 0.0 } else { v }
413}
414
415impl Transform {
416    /// This transform turned by `facing` about the vertical axis through the
417    /// mark: the translation rotated, the yaw composed before the left
418    /// rotation. The model is `Ryaw · T · L · S · R`, which is
419    /// `T(Ryaw·t) · (Ryaw·L) · S · R` — so the emitted entity stands at yaw 0
420    /// and no client fact about how a display's own yaw composes with its
421    /// transformation is relied on (spec-0082 §3.2).
422    pub fn faced(&self, facing: Facing) -> Transform {
423        self.turned(facing_angle(facing))
424    }
425
426    /// This transform turned by `a` radians about the vertical axis through the
427    /// mark (right-handed about `+y`: `+z` turns toward `+x`) — what
428    /// [`Self::faced`] does for a quarter turn, for any angle. An aimed
429    /// assembly's facings are turns of this kind (spec-0082 §5.7).
430    pub fn turned(&self, a: f64) -> Transform {
431        if a == 0.0 {
432            return self.clone();
433        }
434        let yaw = [0.0, (a / 2.0).sin(), 0.0, (a / 2.0).cos()];
435        let t = qrot(yaw, self.translation);
436        let l = qmul(yaw, self.left_rotation);
437        Transform {
438            translation: t.map(tidy),
439            left_rotation: l.map(tidy),
440            scale: self.scale,
441            right_rotation: self.right_rotation,
442        }
443    }
444
445    /// The model's eight corners, relative to the entity's position.
446    fn corners(&self) -> [[f64; 3]; 8] {
447        let mut out = [[0.0; 3]; 8];
448        for (k, c) in out.iter_mut().enumerate() {
449            let unit = [(k & 1) as f64, ((k >> 1) & 1) as f64, ((k >> 2) & 1) as f64];
450            let r = qrot(self.right_rotation, unit);
451            let s = [
452                r[0] * self.scale[0],
453                r[1] * self.scale[1],
454                r[2] * self.scale[2],
455            ];
456            let l = qrot(self.left_rotation, s);
457            *c = [
458                l[0] + self.translation[0],
459                l[1] + self.translation[1],
460                l[2] + self.translation[2],
461            ];
462        }
463        out
464    }
465
466    /// The cells this part's box meets, relative to the mark's cell: every
467    /// cell the transformed unit cube overlaps with positive volume, judged
468    /// exactly (a separating-axis test of the oriented box against the cell),
469    /// not by the box's axis-aligned hull — a part laid on a diagonal meets the
470    /// cells along it, never the empty corners of its hull. The entity stands
471    /// at the mark cell's centre (`x + 0.5`, `z + 0.5`) on its floor (`y`).
472    pub fn cells(&self) -> BTreeSet<[i32; 3]> {
473        // The entity's own position inside the mark cell.
474        let origin = [0.5, 0.0, 0.5];
475        let corners = self
476            .corners()
477            .map(|c| [c[0] + origin[0], c[1] + origin[1], c[2] + origin[2]]);
478        let mut lo = [f64::INFINITY; 3];
479        let mut hi = [f64::NEG_INFINITY; 3];
480        for c in corners {
481            for i in 0..3 {
482                lo[i] = lo[i].min(c[i]);
483                hi[i] = hi[i].max(c[i]);
484            }
485        }
486        let span = |i: usize| -> (i32, i32) {
487            let from = (lo[i] + CELL_EPS).floor() as i32;
488            let to = (hi[i] - CELL_EPS).ceil() as i32 - 1;
489            (from, to.max(from))
490        };
491        let (x0, x1) = span(0);
492        let (y0, y1) = span(1);
493        let (z0, z1) = span(2);
494        let axes = separating_axes(&corners);
495        let mut out = BTreeSet::new();
496        for x in x0..=x1 {
497            for y in y0..=y1 {
498                for z in z0..=z1 {
499                    let lo = [f64::from(x), f64::from(y), f64::from(z)];
500                    if box_meets(&corners, &axes, lo, [lo[0] + 1.0, lo[1] + 1.0, lo[2] + 1.0]) {
501                        out.insert([x, y, z]);
502                    }
503                }
504            }
505        }
506        out
507    }
508
509    /// Whether this part's box overlaps the axis-aligned box `lo..hi` with
510    /// positive volume, judged exactly. Coordinates are relative to the mark's
511    /// cell, which spans `[0, 1]` on every axis; the entity stands at its
512    /// centre on its floor, as for [`Self::cells`].
513    pub fn meets_box(&self, lo: [f64; 3], hi: [f64; 3]) -> bool {
514        let corners = self.corners().map(|c| [c[0] + 0.5, c[1], c[2] + 0.5]);
515        box_meets(&corners, &separating_axes(&corners), lo, hi)
516    }
517}
518
519/// How far a box must reach into a cell to meet it: a billionth of a block,
520/// so a face lying on a cell boundary meets neither side, and two machines whose
521/// sines differ in the last bit decide every cell the same way.
522const CELL_EPS: f64 = 1e-9;
523
524/// The candidate separating axes of a parallelepiped against an axis-aligned
525/// cell: the three world axes, its three edge directions and their nine cross
526/// products.
527fn separating_axes(c: &[[f64; 3]; 8]) -> Vec<[f64; 3]> {
528    let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
529    let edges = [sub(c[1], c[0]), sub(c[2], c[0]), sub(c[4], c[0])];
530    let world = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
531    let mut axes: Vec<[f64; 3]> = world.to_vec();
532    let mut push = |v: [f64; 3]| {
533        let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
534        if n > 1e-12 {
535            axes.push([v[0] / n, v[1] / n, v[2] / n]);
536        }
537    };
538    for e in edges {
539        push(e);
540    }
541    for a in world {
542        for e in edges {
543            push([
544                a[1] * e[2] - a[2] * e[1],
545                a[2] * e[0] - a[0] * e[2],
546                a[0] * e[1] - a[1] * e[0],
547            ]);
548        }
549    }
550    axes
551}
552
553/// Whether the parallelepiped `c` overlaps the axis-aligned box `lo..hi`
554/// with positive volume: no candidate axis separates them, an overlap thinner
555/// than [`CELL_EPS`] counting as none.
556fn box_meets(c: &[[f64; 3]; 8], axes: &[[f64; 3]], lo: [f64; 3], hi: [f64; 3]) -> bool {
557    let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
558    axes.iter().all(|a| {
559        let (mut p0, mut p1) = (f64::INFINITY, f64::NEG_INFINITY);
560        for v in c {
561            let d = dot(*v, *a);
562            p0 = p0.min(d);
563            p1 = p1.max(d);
564        }
565        let (mut q0, mut q1) = (f64::INFINITY, f64::NEG_INFINITY);
566        for k in 0..8 {
567            let pick = |i: usize, bit: usize| if (k >> bit) & 1 == 0 { lo[i] } else { hi[i] };
568            let v = [pick(0, 0), pick(1, 1), pick(2, 2)];
569            let d = dot(v, *a);
570            q0 = q0.min(d);
571            q1 = q1.max(d);
572        }
573        p1 > q0 + CELL_EPS && q1 > p0 + CELL_EPS
574    })
575}
576
577/// **The frame footprint** (spec-0082 §5.2): the union over parts of the cells
578/// each part's box meets, relative to the mark's cell, after `facing`.
579///
580/// The one footprint function. `delvec rig describe` prints it and the strike
581/// check (`DW0938`) judges by it, so the region a creator declares from the
582/// printed cells is the region the engine measures.
583pub fn frame_footprint(frame: &[Transform], facing: Facing) -> BTreeSet<[i32; 3]> {
584    frame_footprint_turned(frame, facing_angle(facing))
585}
586
587/// The frame footprint with the rig turned `a` radians about the mark's
588/// vertical axis ([`Transform::turned`]) — the one footprint function;
589/// [`frame_footprint`] is it at a quarter turn.
590pub fn frame_footprint_turned(frame: &[Transform], a: f64) -> BTreeSet<[i32; 3]> {
591    let mut out = BTreeSet::new();
592    for t in frame {
593        out.extend(t.turned(a).cells());
594    }
595    out
596}
597
598/// The clip footprint: the union over frames of [`frame_footprint`].
599pub fn clip_footprint(clip: &Clip, facing: Facing) -> BTreeSet<[i32; 3]> {
600    let mut out = BTreeSet::new();
601    for f in &clip.frames {
602        out.extend(frame_footprint(f, facing));
603    }
604    out
605}
606
607/// The footprint of a clip's **last** frame — the pose a non-looping clip
608/// holds, and for a strike clip the pose the blow lands in.
609pub fn last_frame_footprint(clip: &Clip, facing: Facing) -> BTreeSet<[i32; 3]> {
610    clip.frames
611        .last()
612        .map(|f| frame_footprint(f, facing))
613        .unwrap_or_default()
614}
615
616/// A footprint as one line of cells, `[x, y, z]` relative to the mark, in
617/// cell order — what `delvec rig describe` prints and a refusal quotes.
618pub fn cells_line(cells: &BTreeSet<[i32; 3]>) -> String {
619    cells
620        .iter()
621        .map(|c| format!("[{}, {}, {}]", c[0], c[1], c[2]))
622        .collect::<Vec<_>>()
623        .join(" ")
624}
625
626/// **What `delvec rig describe` prints** (spec-0082 §3.1): the part count,
627/// every clip with its length in ticks, and per clip the footprint of its last
628/// frame relative to the mark at `facing`. Deterministic: two runs over one
629/// rig are byte-identical.
630pub fn describe(id: &str, rig: &Rig, facing: Facing) -> String {
631    let mut out = String::new();
632    out.push_str(&format!(
633        "rig {id}: {} part(s), {} clip(s), {} frame(s) in all; facing {}; provenance: {} ({}, {})\n",
634        rig.parts.len(),
635        rig.clips.len(),
636        rig.frame_count(),
637        facing.token(),
638        rig.provenance.generator,
639        rig.provenance.source,
640        rig.provenance.spdx,
641    ));
642    for (name, clip) in &rig.clips {
643        let last = last_frame_footprint(clip, facing);
644        out.push_str(&format!(
645            "clip {name}: {} frame(s) every {} tick(s), {} tick(s) from switch to last frame \
646             applied, {} until it is drawn whole (a blow from it lands then), {}\n",
647            clip.frames.len(),
648            clip.ticks_per_frame,
649            clip.length_ticks(),
650            clip.landing_ticks(),
651            if clip.looping {
652                "loops"
653            } else {
654                "holds its last frame"
655            },
656        ));
657        out.push_str(&format!(
658            "  last-frame footprint, {} cell(s) relative to the mark: {}\n",
659            last.len(),
660            cells_line(&last)
661        ));
662    }
663    out
664}
665
666/// What a library knows about one rig id.
667#[derive(Clone, Copy, Debug)]
668pub enum RigLookup<'a> {
669    /// The registry asked is not the whole library and cannot vouch either way
670    /// (a test double, the DSL's vendored registry). Nothing is refused on its
671    /// word.
672    Unknown,
673    /// The library holds no `rigs/<name>/rig.json`.
674    Missing,
675    /// The file is there and does not parse; the parse error.
676    Malformed(&'a str),
677    /// The rig.
678    Found(&'a Rig),
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn unit() -> Transform {
686        Transform {
687            translation: [0.0, 0.0, 0.0],
688            left_rotation: [0.0, 0.0, 0.0, 1.0],
689            scale: [1.0, 1.0, 1.0],
690            right_rotation: [0.0, 0.0, 0.0, 1.0],
691        }
692    }
693
694    fn rig(frames: Vec<Vec<Transform>>, tpf: u32) -> Rig {
695        let mut clips = BTreeMap::new();
696        clips.insert(
697            "idle".to_string(),
698            Clip {
699                ticks_per_frame: tpf,
700                looping: true,
701                frames,
702            },
703        );
704        Rig {
705            rig_version: RIG_VERSION,
706            parts: vec![RigPart {
707                id: "a".into(),
708                kind: PartKind::Block,
709                block: "minecraft:stone".into(),
710                rest: None,
711            }],
712            clips,
713            provenance: RigProvenance {
714                generator: "test".into(),
715                source: "original".into(),
716                spdx: "GPL-3.0-or-later".into(),
717            },
718        }
719    }
720
721    /// The unit cube placed at the entity's position (the mark cell's centre)
722    /// spans half of the mark cell and half of the cells beside it on x and z.
723    #[test]
724    fn the_unit_cube_at_the_origin_meets_four_cells() {
725        let cells = unit().cells();
726        assert_eq!(
727            cells,
728            [[0, 0, 0], [0, 0, 1], [1, 0, 0], [1, 0, 1]]
729                .into_iter()
730                .collect()
731        );
732    }
733
734    /// Translated back by half a block on x and z, the unit cube is exactly
735    /// the mark cell.
736    #[test]
737    fn a_centred_cube_is_the_mark_cell() {
738        let mut t = unit();
739        t.translation = [-0.5, 0.0, -0.5];
740        assert_eq!(t.cells(), [[0, 0, 0]].into_iter().collect());
741    }
742
743    /// Scaled to three blocks tall and turned a quarter about z, a centred
744    /// column lies along -x: its cells are the three cells west of the mark
745    /// at floor height (the rotation lays it down, the scale lengthens it).
746    #[test]
747    fn a_diagonal_part_meets_the_cells_along_it_not_its_hull() {
748        // A bar 0.2 thick and 4.24 long, turned 45 degrees about y, laid from
749        // the mark cell's centre toward +x +z: its hull spans a 4 x 4 square of
750        // columns; the bar itself crosses only the cells along the diagonal and
751        // the ones its edge clips beside them.
752        let a = std::f64::consts::FRAC_PI_4;
753        let t = Transform {
754            translation: [0.0, 0.0, 0.0],
755            left_rotation: [0.0, (a / 2.0).sin(), 0.0, (a / 2.0).cos()],
756            scale: [0.2, 1.0, 4.24],
757            right_rotation: [0.0, 0.0, 0.0, 1.0],
758        };
759        let cells = t.cells();
760        assert!(
761            cells.contains(&[0, 0, 0]) && cells.contains(&[2, 0, 2]),
762            "{cells:?}"
763        );
764        assert!(
765            !cells.contains(&[0, 0, 3]) && !cells.contains(&[3, 0, 0]),
766            "{cells:?}"
767        );
768        assert!(cells.len() < 16, "{} cells: {cells:?}", cells.len());
769    }
770
771    #[test]
772    fn rotation_and_scale_move_the_cell_set() {
773        let s = std::f64::consts::FRAC_1_SQRT_2;
774        let t = Transform {
775            translation: [-0.5, 0.0, -0.5],
776            // +90° about z: +y turns toward -x.
777            left_rotation: [0.0, 0.0, s, s],
778            scale: [1.0, 3.0, 1.0],
779            right_rotation: [0.0, 0.0, 0.0, 1.0],
780        };
781        assert_eq!(
782            t.cells(),
783            [[-3, 0, 0], [-2, 0, 0], [-1, 0, 0]].into_iter().collect()
784        );
785    }
786
787    /// Facing north turns a part standing two cells in front of the mark
788    /// (+z) to two cells behind it (-z); east turns it to +x.
789    #[test]
790    fn facing_turns_the_footprint_about_the_mark() {
791        let mut t = unit();
792        t.translation = [-0.5, 0.0, 1.5];
793        assert_eq!(
794            frame_footprint(std::slice::from_ref(&t), Facing::South),
795            [[0, 0, 2]].into_iter().collect()
796        );
797        assert_eq!(
798            frame_footprint(std::slice::from_ref(&t), Facing::North),
799            [[0, 0, -2]].into_iter().collect()
800        );
801        assert_eq!(
802            frame_footprint(std::slice::from_ref(&t), Facing::East),
803            [[2, 0, 0]].into_iter().collect()
804        );
805        assert_eq!(
806            frame_footprint(std::slice::from_ref(&t), Facing::West),
807            [[-2, 0, 0]].into_iter().collect()
808        );
809    }
810
811    #[test]
812    fn a_well_formed_rig_has_no_issue() {
813        assert!(check(&rig(vec![vec![unit()]], 5)).is_empty());
814    }
815
816    #[test]
817    fn each_structural_defect_names_its_field() {
818        let short = rig(vec![vec![]], 5);
819        assert!(
820            check(&short)
821                .iter()
822                .any(|i| i.field == "/clips/idle/frames/0")
823        );
824        for tpf in [0, 21] {
825            let r = rig(vec![vec![unit()]], tpf);
826            assert!(
827                check(&r)
828                    .iter()
829                    .any(|i| i.field == "/clips/idle/ticks_per_frame"),
830                "{tpf}"
831            );
832        }
833        let mut z = unit();
834        z.scale = [1.0, 0.0, 1.0];
835        assert!(
836            check(&rig(vec![vec![z]], 5))
837                .iter()
838                .any(|i| i.field == "/clips/idle/frames/0/0" && i.message.contains("`scale` is 0"))
839        );
840        let mut bad = rig(vec![vec![unit()]], 5);
841        bad.parts[0].block = "minecraft:no_such_block".into();
842        assert!(check(&bad).iter().any(|i| i.field == "/parts/0/block"));
843    }
844
845    #[test]
846    fn block_state_snbt_carries_properties() {
847        let mut r = rig(vec![vec![unit()]], 5);
848        assert_eq!(r.parts[0].block_state_snbt(), "{Name:\"minecraft:stone\"}");
849        r.parts[0].block = "minecraft:oak_log[axis=x]".into();
850        assert_eq!(
851            r.parts[0].block_state_snbt(),
852            "{Name:\"minecraft:oak_log\",Properties:{axis:\"x\"}}"
853        );
854    }
855
856    #[test]
857    fn describe_is_deterministic() {
858        let r = rig(vec![vec![unit()], vec![unit()]], 5);
859        assert_eq!(
860            describe("rig/x", &r, Facing::South),
861            describe("rig/x", &r, Facing::South)
862        );
863        assert!(describe("rig/x", &r, Facing::South).contains("6 tick(s)"));
864    }
865}