use crate::components::{
AnimationGraph, Behavior, Camera3D, InstancedProp, Model, PhysicsJoint, PhysicsJointKind, Prop,
VoxelChunk, VoxelWorld,
};
#[derive(Debug, Clone, Copy)]
pub(crate) enum RefKind {
MeshSource,
Material,
Scene,
BlockType,
SkinnedMesh,
Animation,
AudioClip,
Screen,
TriggerVolume,
AnyAsset,
}
pub(crate) enum CrossRef {
Resolve {
kind: RefKind,
target: String,
error: String,
},
Issue(String),
}
pub(crate) trait CrossReferenced {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef>;
}
pub(crate) fn state_clip_names(state: &serde_json::Value) -> Vec<String> {
let mut names = Vec::new();
let mut push = |v: Option<&serde_json::Value>| {
if let Some(clip) = v.and_then(|v| v.as_str())
&& !clip.is_empty()
{
names.push(clip.to_string());
}
};
push(state.get("clip"));
if let Some(blend) = state.get("blend") {
for point in blend
.get("points")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[])
{
push(point.get("clip"));
}
for row in blend
.get("rows")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[])
{
for cell in row.as_array().map(|a| a.as_slice()).unwrap_or(&[]) {
push(Some(cell));
}
}
}
names
}
impl CrossReferenced for AnimationGraph {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let mut refs = Vec::new();
match args.get("target").and_then(|v| v.as_str()).unwrap_or("") {
"" => refs.push(CrossRef::Issue(format!(
"AnimationGraph '{name}': `target` field is required (the SkinnedMesh to animate)"
))),
target => refs.push(CrossRef::Resolve {
kind: RefKind::SkinnedMesh,
target: target.to_string(),
error: format!("AnimationGraph '{name}': target SkinnedMesh '{target}' not found"),
}),
}
let states = args
.get("states")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
for (i, state) in states.iter().enumerate() {
let state_name = state.get("name").and_then(|v| v.as_str()).unwrap_or("");
let label = if state_name.is_empty() {
format!("state #{i}")
} else {
format!("state '{state_name}'")
};
let clips = state_clip_names(state);
if clips.is_empty() {
refs.push(CrossRef::Issue(format!(
"AnimationGraph '{name}': {label} names no Animation (set `clip`, or `blend` \
members)"
)));
}
for clip in clips {
refs.push(CrossRef::Resolve {
error: format!("AnimationGraph '{name}': {label} clip '{clip}' not found"),
kind: RefKind::Animation,
target: clip,
});
}
}
refs
}
}
impl CrossReferenced for Camera3D {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let Some(follow) = args
.get("controller")
.and_then(|c| c.get("follow"))
.filter(|f| !f.is_null())
else {
return Vec::new();
};
match follow.get("target").and_then(|v| v.as_str()).unwrap_or("") {
"" => vec![CrossRef::Issue(format!(
"Camera3D '{name}': `controller.follow.target` is required (the SkinnedMesh to follow)"
))],
target => vec![CrossRef::Resolve {
kind: RefKind::SkinnedMesh,
target: target.to_string(),
error: format!("Camera3D '{name}': follow target SkinnedMesh '{target}' not found"),
}],
}
}
}
impl CrossReferenced for Prop {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let arg = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or("");
if !arg("model").is_empty() {
return Vec::new();
}
let mesh_ref = arg("mesh");
if mesh_ref.is_empty() {
return Vec::new();
}
vec![CrossRef::Resolve {
kind: RefKind::MeshSource,
target: mesh_ref.to_string(),
error: format!(
"Prop '{}': mesh '{}' not found, add a Mesh, ProceduralMesh, or File (obj) asset with that name",
name, mesh_ref
),
}]
}
}
impl CrossReferenced for Model {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let mut refs = Vec::new();
if let Some(meshes) = args.get("meshes").and_then(|v| v.as_array()) {
for (i, sub) in meshes.iter().enumerate() {
let sub_mesh = sub.get("mesh").and_then(|v| v.as_str()).unwrap_or("");
if sub_mesh.is_empty() {
refs.push(CrossRef::Issue(format!(
"Model '{}': submesh[{}] is missing a 'mesh' field",
name, i
)));
} else {
refs.push(CrossRef::Resolve {
kind: RefKind::MeshSource,
target: sub_mesh.to_string(),
error: format!(
"Model '{}': submesh[{}] mesh '{}' not found, add a Mesh, ProceduralMesh, or File (obj) asset with that name",
name, i, sub_mesh
),
});
}
let sub_mat = sub.get("material").and_then(|v| v.as_str()).unwrap_or("");
if !sub_mat.is_empty() {
refs.push(CrossRef::Resolve {
kind: RefKind::Material,
target: sub_mat.to_string(),
error: format!(
"Model '{}': submesh[{}] material '{}' not found, add a Material asset with that name",
name, i, sub_mat
),
});
}
}
}
refs
}
}
impl CrossReferenced for InstancedProp {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let mesh_ref = args.get("mesh").and_then(|v| v.as_str()).unwrap_or("");
if mesh_ref.is_empty() {
return vec![CrossRef::Issue(format!(
"InstancedProp '{}': `mesh` field is required",
name
))];
}
vec![CrossRef::Resolve {
kind: RefKind::MeshSource,
target: mesh_ref.to_string(),
error: format!(
"InstancedProp '{}': mesh '{}' not found, add a Mesh, ProceduralMesh, VoxelChunk, or File (obj) asset with that name",
name, mesh_ref
),
}]
}
}
impl CrossReferenced for VoxelChunk {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let mut refs = Vec::new();
let palette = args
.get("palette")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
for (i, entry) in palette.iter().enumerate() {
let bt_name = entry.as_str().unwrap_or("");
if bt_name.is_empty() {
refs.push(CrossRef::Issue(format!(
"VoxelChunk '{}': palette[{}] is not a valid BlockType name",
name, i
)));
} else {
refs.push(CrossRef::Resolve {
kind: RefKind::BlockType,
target: bt_name.to_string(),
error: format!(
"VoxelChunk '{}': palette[{}] BlockType '{}' not found, add a BlockType asset with that name",
name, i, bt_name
),
});
}
}
refs
}
}
impl CrossReferenced for VoxelWorld {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let mut refs = Vec::new();
let palette = args
.get("palette")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
for (i, entry) in palette.iter().enumerate() {
let bt_name = entry.as_str().unwrap_or("");
if bt_name.is_empty() {
refs.push(CrossRef::Issue(format!(
"VoxelWorld '{}': palette[{}] is not a valid BlockType name",
name, i
)));
} else {
refs.push(CrossRef::Resolve {
kind: RefKind::BlockType,
target: bt_name.to_string(),
error: format!(
"VoxelWorld '{}': palette[{}] BlockType '{}' not found, add a BlockType asset with that name",
name, i, bt_name
),
});
}
}
refs
}
}
impl CrossReferenced for PhysicsJoint {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let arg_str = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or("");
let mut refs = Vec::new();
let kind = arg_str("kind");
if !kind.is_empty() && PhysicsJointKind::from_str_norm(kind).is_none() {
refs.push(CrossRef::Issue(format!(
"PhysicsJoint '{name}': unknown kind '{kind}' (expected one of fixed | revolute | spherical | prismatic)"
)));
}
if arg_str("body_a").is_empty() {
refs.push(CrossRef::Issue(format!(
"PhysicsJoint '{name}': `body_a` is required, name of a Prop with a collider"
)));
}
refs
}
}
impl CrossReferenced for Behavior {
fn cross_refs(name: &str, args: &serde_json::Value) -> Vec<CrossRef> {
let mut refs = Vec::new();
walk_behavior_nodes(args.get("do"), name, &mut refs);
if let Some(source) = args.get("on") {
if let Some(var) = source.get("variable")
&& var.as_str().unwrap_or("").is_empty()
{
refs.push(CrossRef::Issue(format!(
"Behavior '{name}': `variable` source requires a variable name"
)));
}
for verb in ["enter", "exit"] {
match source.get(verb) {
Some(serde_json::Value::String(target)) if !target.is_empty() => {
refs.push(CrossRef::Resolve {
kind: RefKind::TriggerVolume,
target: target.clone(),
error: format!(
"Behavior '{name}': `{verb}` volume '{target}' not found, \
add a TriggerVolume asset with that name"
),
});
}
Some(serde_json::Value::String(_)) | Some(serde_json::Value::Null) => {
refs.push(CrossRef::Issue(format!(
"Behavior '{name}': `{verb}` source requires a TriggerVolume name"
)));
}
_ => {}
}
}
match source.get("interact") {
Some(serde_json::Value::String(target)) if !target.is_empty() => {
refs.push(CrossRef::Resolve {
kind: RefKind::AnyAsset,
target: target.clone(),
error: format!("Behavior '{name}': `interact` target '{target}' not found"),
});
}
Some(serde_json::Value::String(_)) | Some(serde_json::Value::Null) => {
refs.push(CrossRef::Issue(format!(
"Behavior '{name}': `interact` source requires an entity name"
)));
}
_ => {}
}
}
refs
}
}
fn walk_behavior_nodes(value: Option<&serde_json::Value>, name: &str, refs: &mut Vec<CrossRef>) {
fn field(
node: &serde_json::Value,
verb: &str,
key: &str,
kind: RefKind,
name: &str,
refs: &mut Vec<CrossRef>,
) {
match node.get(key) {
Some(serde_json::Value::String(target)) if !target.is_empty() => {
refs.push(CrossRef::Resolve {
kind,
target: target.clone(),
error: format!("Behavior '{name}': {verb} {key} '{target}' not found"),
});
}
None | Some(serde_json::Value::String(_)) | Some(serde_json::Value::Null) => {
refs.push(CrossRef::Issue(format!(
"Behavior '{name}': `{verb}` node requires `{key}`"
)));
}
_ => {}
}
}
let Some(value) = value else { return };
match value {
serde_json::Value::Array(items) => {
for item in items {
walk_behavior_nodes(Some(item), name, refs);
}
}
serde_json::Value::Object(map) => {
for (key, body) in map {
if key == "named" {
match body {
serde_json::Value::String(target) if !target.is_empty() => {
refs.push(CrossRef::Resolve {
kind: RefKind::AnyAsset,
target: target.clone(),
error: format!(
"Behavior '{name}': `named` entity '{target}' not found"
),
});
}
serde_json::Value::String(_) | serde_json::Value::Null => {
refs.push(CrossRef::Issue(format!(
"Behavior '{name}': `named` requires an entity name"
)));
}
_ => {}
}
continue;
}
if body.is_object() {
match key.as_str() {
"spawn" => field(body, "spawn", "template", RefKind::AnyAsset, name, refs),
"sound" => field(body, "sound", "clip", RefKind::AudioClip, name, refs),
"scene" => field(body, "scene", "scene", RefKind::Scene, name, refs),
"screen" => field(body, "screen", "screen", RefKind::Screen, name, refs),
_ => {}
}
}
walk_behavior_nodes(Some(body), name, refs);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tally(refs: &[CrossRef]) -> (usize, usize) {
let mut resolves = 0;
let mut issues = 0;
for r in refs {
match r {
CrossRef::Resolve { .. } => resolves += 1,
CrossRef::Issue(_) => issues += 1,
}
}
(resolves, issues)
}
fn resolves_to(refs: &[CrossRef], kind: RefKind, target: &str) -> bool {
refs.iter().any(|r| match r {
CrossRef::Resolve {
kind: k, target: t, ..
} => std::mem::discriminant(k) == std::mem::discriminant(&kind) && t == target,
CrossRef::Issue(_) => false,
})
}
#[test]
fn voxel_world_and_chunk_cross_refs_palette() {
let refs = VoxelWorld::cross_refs("ow", &json!({"palette": ["", "grass"]}));
assert_eq!(tally(&refs), (1, 1));
assert!(resolves_to(&refs, RefKind::BlockType, "grass"));
let chunk = VoxelChunk::cross_refs("c", &json!({"palette": ["stone", ""]}));
assert_eq!(tally(&chunk), (1, 1));
assert!(resolves_to(&chunk, RefKind::BlockType, "stone"));
}
#[test]
fn prop_cross_refs_model_takes_precedence_over_mesh() {
let refs = Prop::cross_refs("p", &json!({"model": "m", "mesh": "mesh_skipped"}));
assert_eq!(tally(&refs), (0, 0));
let mesh_only = Prop::cross_refs("p", &json!({"mesh": "only_mesh"}));
assert!(resolves_to(&mesh_only, RefKind::MeshSource, "only_mesh"));
}
#[test]
fn model_cross_refs_submeshes_and_missing_field() {
let refs = Model::cross_refs(
"mdl",
&json!({"meshes": [{"mesh": "m0", "material": "mat0"}, {}]}),
);
assert_eq!(tally(&refs), (2, 1));
assert!(resolves_to(&refs, RefKind::MeshSource, "m0"));
assert!(resolves_to(&refs, RefKind::Material, "mat0"));
}
fn graph_json() -> serde_json::Value {
json!({
"target": "hero",
"parameters": [{"name": "speed", "default": 0.5}],
"initial": "idle",
"states": [
{"name": "idle", "clip": "hero_idle"},
{"name": "run", "clip": "hero_run", "rate": 1.5, "loop_override": false}
]
})
}
fn blend1d_graph_json() -> serde_json::Value {
json!({
"target": "hero",
"parameters": [{"name": "speed", "default": 0.0}],
"states": [
{"name": "locomotion", "blend": {"kind": "blend1d", "parameter": "speed",
"sync": true,
"points": [
{"value": 0.0, "clip": "idle"},
{"value": 1.6, "clip": "walk"},
{"value": 5.0, "clip": "run"}
]}}
]
})
}
fn blend2d_graph_json() -> serde_json::Value {
json!({
"target": "hero",
"parameters": [{"name": "speed"}, {"name": "strafe"}],
"states": [
{"name": "locomotion", "blend": {"kind": "blend2d",
"parameter_x": "speed", "parameter_y": "strafe",
"x_values": [0.0, 5.0], "y_values": [-1.0, 1.0],
"rows": [["run_l", "run_l"], ["run_r", "run_r"]]}}
]
})
}
#[test]
fn anim_graph_cross_refs_cover_target_and_clips() {
let refs = AnimationGraph::cross_refs("g", &graph_json());
assert_eq!(refs.len(), 3);
assert!(refs.iter().all(|r| matches!(r, CrossRef::Resolve { .. })));
}
#[test]
fn anim_graph_cross_refs_flag_missing_target_and_clip() {
let refs = AnimationGraph::cross_refs("g", &json!({"states":[{"name":"idle"}]}));
let issues: Vec<_> = refs
.iter()
.filter_map(|r| match r {
CrossRef::Issue(msg) => Some(msg.clone()),
_ => None,
})
.collect();
assert_eq!(issues.len(), 2);
assert!(issues[0].contains("target"));
assert!(issues[1].contains("clip"));
}
#[test]
fn anim_graph_cross_refs_cover_blend_members() {
let refs = AnimationGraph::cross_refs("g", &blend1d_graph_json());
assert_eq!(refs.len(), 4);
assert!(refs.iter().all(|r| matches!(r, CrossRef::Resolve { .. })));
let refs = AnimationGraph::cross_refs("g", &blend2d_graph_json());
assert_eq!(refs.len(), 5);
}
#[test]
fn state_clip_names_walks_clip_points_and_rows() {
let names = state_clip_names(&json!({"clip":"solo"}));
assert_eq!(names, vec!["solo"]);
let names = state_clip_names(&blend1d_graph_json()["states"][0]);
assert_eq!(names, vec!["idle", "walk", "run"]);
let names = state_clip_names(&blend2d_graph_json()["states"][0]);
assert_eq!(names, vec!["run_l", "run_l", "run_r", "run_r"]);
assert!(state_clip_names(&json!({})).is_empty());
}
}