use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::stages::Facing;
pub const RIG_VERSION: u32 = 1;
pub const RIGS_DIR: &str = "rigs";
pub const RIG_FILE: &str = "rig.json";
pub const MAX_TICKS_PER_FRAME: u32 = 20;
pub const MIN_TICKS_PER_FRAME: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rig {
pub rig_version: u32,
pub parts: Vec<RigPart>,
pub clips: BTreeMap<String, Clip>,
pub provenance: RigProvenance,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RigPart {
pub id: String,
pub kind: PartKind,
pub block: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rest: Option<Transform>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PartKind {
Block,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Clip {
pub ticks_per_frame: u32,
#[serde(rename = "loop")]
pub looping: bool,
pub frames: Vec<Vec<Transform>>,
}
impl Clip {
pub fn length_ticks(&self) -> u32 {
1 + (self.frames.len().saturating_sub(1) as u32) * self.ticks_per_frame
}
pub fn landing_ticks(&self) -> u32 {
self.length_ticks() + self.ticks_per_frame
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transform {
pub translation: [f64; 3],
pub left_rotation: [f64; 4],
pub scale: [f64; 3],
pub right_rotation: [f64; 4],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RigProvenance {
pub generator: String,
pub source: String,
pub spdx: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RigIssue {
pub field: String,
pub message: String,
}
pub fn parse(raw: &str) -> Result<Rig, String> {
serde_json::from_str::<Rig>(raw).map_err(|e| e.to_string())
}
pub fn is_clip_name(name: &str) -> bool {
crate::ids::is_kebab(name)
}
pub fn check(rig: &Rig) -> Vec<RigIssue> {
let mut out = Vec::new();
let mut push = |field: String, message: String| out.push(RigIssue { field, message });
if rig.rig_version != RIG_VERSION {
push(
"/rig_version".into(),
format!(
"`rig_version` is {}; this engine reads version {RIG_VERSION}",
rig.rig_version
),
);
}
if rig.parts.is_empty() {
push(
"/parts".into(),
"the rig declares no part, so the assembly would show nothing".into(),
);
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, p) in rig.parts.iter().enumerate() {
if p.id.is_empty() {
push(format!("/parts/{i}/id"), "a part's `id` is empty".into());
} else if !seen.insert(p.id.as_str()) {
push(
format!("/parts/{i}/id"),
format!("part id `{}` is declared twice", p.id),
);
}
if let Err(e) = crate::blocks::BlockRegistry::v1_21_11().validate_state_string(&p.block) {
push(
format!("/parts/{i}/block"),
format!(
"part `{}` shows `{}`, which is not a block state of Minecraft Java \
1.21.11 ({e:?})",
p.id, p.block
),
);
}
if let Some(t) = &p.rest {
for m in transform_issues(t) {
push(format!("/parts/{i}/rest"), m);
}
}
}
if rig.clips.is_empty() {
push(
"/clips".into(),
"the rig declares no clip, so the assembly has nothing to play and no pose to \
stand in"
.into(),
);
}
for (name, clip) in &rig.clips {
let at = format!("/clips/{name}");
if !is_clip_name(name) {
push(
at.clone(),
format!(
"clip name `{name}` is not lowercase kebab-case (`[a-z0-9]` segments joined by `-`)"
),
);
}
if !(MIN_TICKS_PER_FRAME..=MAX_TICKS_PER_FRAME).contains(&clip.ticks_per_frame) {
push(
format!("{at}/ticks_per_frame"),
format!(
"clip `{name}` declares `ticks_per_frame` {}; the cadence is \
{MIN_TICKS_PER_FRAME}..={MAX_TICKS_PER_FRAME} ticks per frame",
clip.ticks_per_frame
),
);
}
if clip.frames.is_empty() {
push(
format!("{at}/frames"),
format!("clip `{name}` has no frame"),
);
}
for (f, frame) in clip.frames.iter().enumerate() {
if frame.len() != rig.parts.len() {
push(
format!("{at}/frames/{f}"),
format!(
"clip `{name}` frame {f} carries {} transform(s) and the rig has {} \
part(s); a frame is one transform per part",
frame.len(),
rig.parts.len()
),
);
}
for (p, t) in frame.iter().enumerate() {
for m in transform_issues(t) {
push(format!("{at}/frames/{f}/{p}"), m);
}
}
}
}
out
}
fn transform_issues(t: &Transform) -> Vec<String> {
let mut out = Vec::new();
let all = t
.translation
.iter()
.chain(t.left_rotation.iter())
.chain(t.scale.iter())
.chain(t.right_rotation.iter());
if all.clone().any(|v| !v.is_finite()) {
out.push("a transform carries a value that is not a finite number".to_string());
return out;
}
for (axis, v) in ["x", "y", "z"].iter().zip(t.scale) {
if v == 0.0 {
out.push(format!(
"`scale` is 0 on {axis}: the part collapses to a plane and shows nothing"
));
}
}
for (name, q) in [
("left_rotation", t.left_rotation),
("right_rotation", t.right_rotation),
] {
if q.iter().map(|v| v * v).sum::<f64>() < 1e-12 {
out.push(format!("`{name}` is a quaternion of zero length"));
}
}
out
}
impl Rig {
pub fn clip_names(&self) -> Vec<&str> {
self.clips.keys().map(String::as_str).collect()
}
pub fn clip_index(&self, name: &str) -> Option<usize> {
self.clips.keys().position(|k| k == name)
}
pub fn rest_pose(&self) -> Option<Vec<Transform>> {
let first = self.clips.values().next().and_then(|c| c.frames.first());
self.parts
.iter()
.enumerate()
.map(|(i, p)| {
p.rest
.clone()
.or_else(|| first.and_then(|f| f.get(i).cloned()))
})
.collect()
}
pub fn frame_count(&self) -> usize {
self.clips.values().map(|c| c.frames.len()).sum()
}
}
impl RigPart {
pub fn block_state_snbt(&self) -> String {
let (name, props) = crate::blocks::parse_state(&self.block);
let name = if name.contains(':') {
name.to_string()
} else {
format!("minecraft:{name}")
};
if props.is_empty() {
format!("{{Name:\"{name}\"}}")
} else {
let p: Vec<String> = props.iter().map(|(k, v)| format!("{k}:\"{v}\"")).collect();
format!("{{Name:\"{name}\",Properties:{{{}}}}}", p.join(","))
}
}
}
pub fn facing_angle(f: Facing) -> f64 {
match f {
Facing::South => 0.0,
Facing::East => std::f64::consts::FRAC_PI_2,
Facing::North => std::f64::consts::PI,
Facing::West => -std::f64::consts::FRAC_PI_2,
}
}
fn qmul(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
let [ax, ay, az, aw] = a;
let [bx, by, bz, bw] = b;
[
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
]
}
fn qrot(q: [f64; 4], v: [f64; 3]) -> [f64; 3] {
let n = q.iter().map(|c| c * c).sum::<f64>().sqrt();
let q = [q[0] / n, q[1] / n, q[2] / n, q[3] / n];
let p = qmul(
qmul(q, [v[0], v[1], v[2], 0.0]),
[-q[0], -q[1], -q[2], q[3]],
);
[p[0], p[1], p[2]]
}
fn tidy(v: f64) -> f64 {
let r = v.round();
let v = if (v - r).abs() < 1e-9 { r } else { v };
if v == 0.0 { 0.0 } else { v }
}
impl Transform {
pub fn faced(&self, facing: Facing) -> Transform {
self.turned(facing_angle(facing))
}
pub fn turned(&self, a: f64) -> Transform {
if a == 0.0 {
return self.clone();
}
let yaw = [0.0, (a / 2.0).sin(), 0.0, (a / 2.0).cos()];
let t = qrot(yaw, self.translation);
let l = qmul(yaw, self.left_rotation);
Transform {
translation: t.map(tidy),
left_rotation: l.map(tidy),
scale: self.scale,
right_rotation: self.right_rotation,
}
}
fn corners(&self) -> [[f64; 3]; 8] {
let mut out = [[0.0; 3]; 8];
for (k, c) in out.iter_mut().enumerate() {
let unit = [(k & 1) as f64, ((k >> 1) & 1) as f64, ((k >> 2) & 1) as f64];
let r = qrot(self.right_rotation, unit);
let s = [
r[0] * self.scale[0],
r[1] * self.scale[1],
r[2] * self.scale[2],
];
let l = qrot(self.left_rotation, s);
*c = [
l[0] + self.translation[0],
l[1] + self.translation[1],
l[2] + self.translation[2],
];
}
out
}
pub fn cells(&self) -> BTreeSet<[i32; 3]> {
let origin = [0.5, 0.0, 0.5];
let corners = self
.corners()
.map(|c| [c[0] + origin[0], c[1] + origin[1], c[2] + origin[2]]);
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for c in corners {
for i in 0..3 {
lo[i] = lo[i].min(c[i]);
hi[i] = hi[i].max(c[i]);
}
}
let span = |i: usize| -> (i32, i32) {
let from = (lo[i] + CELL_EPS).floor() as i32;
let to = (hi[i] - CELL_EPS).ceil() as i32 - 1;
(from, to.max(from))
};
let (x0, x1) = span(0);
let (y0, y1) = span(1);
let (z0, z1) = span(2);
let axes = separating_axes(&corners);
let mut out = BTreeSet::new();
for x in x0..=x1 {
for y in y0..=y1 {
for z in z0..=z1 {
let lo = [f64::from(x), f64::from(y), f64::from(z)];
if box_meets(&corners, &axes, lo, [lo[0] + 1.0, lo[1] + 1.0, lo[2] + 1.0]) {
out.insert([x, y, z]);
}
}
}
}
out
}
pub fn meets_box(&self, lo: [f64; 3], hi: [f64; 3]) -> bool {
let corners = self.corners().map(|c| [c[0] + 0.5, c[1], c[2] + 0.5]);
box_meets(&corners, &separating_axes(&corners), lo, hi)
}
}
const CELL_EPS: f64 = 1e-9;
fn separating_axes(c: &[[f64; 3]; 8]) -> Vec<[f64; 3]> {
let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let edges = [sub(c[1], c[0]), sub(c[2], c[0]), sub(c[4], c[0])];
let world = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let mut axes: Vec<[f64; 3]> = world.to_vec();
let mut push = |v: [f64; 3]| {
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if n > 1e-12 {
axes.push([v[0] / n, v[1] / n, v[2] / n]);
}
};
for e in edges {
push(e);
}
for a in world {
for e in edges {
push([
a[1] * e[2] - a[2] * e[1],
a[2] * e[0] - a[0] * e[2],
a[0] * e[1] - a[1] * e[0],
]);
}
}
axes
}
fn box_meets(c: &[[f64; 3]; 8], axes: &[[f64; 3]], lo: [f64; 3], hi: [f64; 3]) -> bool {
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
axes.iter().all(|a| {
let (mut p0, mut p1) = (f64::INFINITY, f64::NEG_INFINITY);
for v in c {
let d = dot(*v, *a);
p0 = p0.min(d);
p1 = p1.max(d);
}
let (mut q0, mut q1) = (f64::INFINITY, f64::NEG_INFINITY);
for k in 0..8 {
let pick = |i: usize, bit: usize| if (k >> bit) & 1 == 0 { lo[i] } else { hi[i] };
let v = [pick(0, 0), pick(1, 1), pick(2, 2)];
let d = dot(v, *a);
q0 = q0.min(d);
q1 = q1.max(d);
}
p1 > q0 + CELL_EPS && q1 > p0 + CELL_EPS
})
}
pub fn frame_footprint(frame: &[Transform], facing: Facing) -> BTreeSet<[i32; 3]> {
frame_footprint_turned(frame, facing_angle(facing))
}
pub fn frame_footprint_turned(frame: &[Transform], a: f64) -> BTreeSet<[i32; 3]> {
let mut out = BTreeSet::new();
for t in frame {
out.extend(t.turned(a).cells());
}
out
}
pub fn clip_footprint(clip: &Clip, facing: Facing) -> BTreeSet<[i32; 3]> {
let mut out = BTreeSet::new();
for f in &clip.frames {
out.extend(frame_footprint(f, facing));
}
out
}
pub fn last_frame_footprint(clip: &Clip, facing: Facing) -> BTreeSet<[i32; 3]> {
clip.frames
.last()
.map(|f| frame_footprint(f, facing))
.unwrap_or_default()
}
pub fn cells_line(cells: &BTreeSet<[i32; 3]>) -> String {
cells
.iter()
.map(|c| format!("[{}, {}, {}]", c[0], c[1], c[2]))
.collect::<Vec<_>>()
.join(" ")
}
pub fn describe(id: &str, rig: &Rig, facing: Facing) -> String {
let mut out = String::new();
out.push_str(&format!(
"rig {id}: {} part(s), {} clip(s), {} frame(s) in all; facing {}; provenance: {} ({}, {})\n",
rig.parts.len(),
rig.clips.len(),
rig.frame_count(),
facing.token(),
rig.provenance.generator,
rig.provenance.source,
rig.provenance.spdx,
));
for (name, clip) in &rig.clips {
let last = last_frame_footprint(clip, facing);
out.push_str(&format!(
"clip {name}: {} frame(s) every {} tick(s), {} tick(s) from switch to last frame \
applied, {} until it is drawn whole (a blow from it lands then), {}\n",
clip.frames.len(),
clip.ticks_per_frame,
clip.length_ticks(),
clip.landing_ticks(),
if clip.looping {
"loops"
} else {
"holds its last frame"
},
));
out.push_str(&format!(
" last-frame footprint, {} cell(s) relative to the mark: {}\n",
last.len(),
cells_line(&last)
));
}
out
}
#[derive(Clone, Copy, Debug)]
pub enum RigLookup<'a> {
Unknown,
Missing,
Malformed(&'a str),
Found(&'a Rig),
}
#[cfg(test)]
mod tests {
use super::*;
fn unit() -> Transform {
Transform {
translation: [0.0, 0.0, 0.0],
left_rotation: [0.0, 0.0, 0.0, 1.0],
scale: [1.0, 1.0, 1.0],
right_rotation: [0.0, 0.0, 0.0, 1.0],
}
}
fn rig(frames: Vec<Vec<Transform>>, tpf: u32) -> Rig {
let mut clips = BTreeMap::new();
clips.insert(
"idle".to_string(),
Clip {
ticks_per_frame: tpf,
looping: true,
frames,
},
);
Rig {
rig_version: RIG_VERSION,
parts: vec![RigPart {
id: "a".into(),
kind: PartKind::Block,
block: "minecraft:stone".into(),
rest: None,
}],
clips,
provenance: RigProvenance {
generator: "test".into(),
source: "original".into(),
spdx: "GPL-3.0-or-later".into(),
},
}
}
#[test]
fn the_unit_cube_at_the_origin_meets_four_cells() {
let cells = unit().cells();
assert_eq!(
cells,
[[0, 0, 0], [0, 0, 1], [1, 0, 0], [1, 0, 1]]
.into_iter()
.collect()
);
}
#[test]
fn a_centred_cube_is_the_mark_cell() {
let mut t = unit();
t.translation = [-0.5, 0.0, -0.5];
assert_eq!(t.cells(), [[0, 0, 0]].into_iter().collect());
}
#[test]
fn a_diagonal_part_meets_the_cells_along_it_not_its_hull() {
let a = std::f64::consts::FRAC_PI_4;
let t = Transform {
translation: [0.0, 0.0, 0.0],
left_rotation: [0.0, (a / 2.0).sin(), 0.0, (a / 2.0).cos()],
scale: [0.2, 1.0, 4.24],
right_rotation: [0.0, 0.0, 0.0, 1.0],
};
let cells = t.cells();
assert!(
cells.contains(&[0, 0, 0]) && cells.contains(&[2, 0, 2]),
"{cells:?}"
);
assert!(
!cells.contains(&[0, 0, 3]) && !cells.contains(&[3, 0, 0]),
"{cells:?}"
);
assert!(cells.len() < 16, "{} cells: {cells:?}", cells.len());
}
#[test]
fn rotation_and_scale_move_the_cell_set() {
let s = std::f64::consts::FRAC_1_SQRT_2;
let t = Transform {
translation: [-0.5, 0.0, -0.5],
left_rotation: [0.0, 0.0, s, s],
scale: [1.0, 3.0, 1.0],
right_rotation: [0.0, 0.0, 0.0, 1.0],
};
assert_eq!(
t.cells(),
[[-3, 0, 0], [-2, 0, 0], [-1, 0, 0]].into_iter().collect()
);
}
#[test]
fn facing_turns_the_footprint_about_the_mark() {
let mut t = unit();
t.translation = [-0.5, 0.0, 1.5];
assert_eq!(
frame_footprint(std::slice::from_ref(&t), Facing::South),
[[0, 0, 2]].into_iter().collect()
);
assert_eq!(
frame_footprint(std::slice::from_ref(&t), Facing::North),
[[0, 0, -2]].into_iter().collect()
);
assert_eq!(
frame_footprint(std::slice::from_ref(&t), Facing::East),
[[2, 0, 0]].into_iter().collect()
);
assert_eq!(
frame_footprint(std::slice::from_ref(&t), Facing::West),
[[-2, 0, 0]].into_iter().collect()
);
}
#[test]
fn a_well_formed_rig_has_no_issue() {
assert!(check(&rig(vec![vec![unit()]], 5)).is_empty());
}
#[test]
fn each_structural_defect_names_its_field() {
let short = rig(vec![vec![]], 5);
assert!(
check(&short)
.iter()
.any(|i| i.field == "/clips/idle/frames/0")
);
for tpf in [0, 21] {
let r = rig(vec![vec![unit()]], tpf);
assert!(
check(&r)
.iter()
.any(|i| i.field == "/clips/idle/ticks_per_frame"),
"{tpf}"
);
}
let mut z = unit();
z.scale = [1.0, 0.0, 1.0];
assert!(
check(&rig(vec![vec![z]], 5))
.iter()
.any(|i| i.field == "/clips/idle/frames/0/0" && i.message.contains("`scale` is 0"))
);
let mut bad = rig(vec![vec![unit()]], 5);
bad.parts[0].block = "minecraft:no_such_block".into();
assert!(check(&bad).iter().any(|i| i.field == "/parts/0/block"));
}
#[test]
fn block_state_snbt_carries_properties() {
let mut r = rig(vec![vec![unit()]], 5);
assert_eq!(r.parts[0].block_state_snbt(), "{Name:\"minecraft:stone\"}");
r.parts[0].block = "minecraft:oak_log[axis=x]".into();
assert_eq!(
r.parts[0].block_state_snbt(),
"{Name:\"minecraft:oak_log\",Properties:{axis:\"x\"}}"
);
}
#[test]
fn describe_is_deterministic() {
let r = rig(vec![vec![unit()], vec![unit()]], 5);
assert_eq!(
describe("rig/x", &r, Facing::South),
describe("rig/x", &r, Facing::South)
);
assert!(describe("rig/x", &r, Facing::South).contains("6 tick(s)"));
}
}