use concinnity_cook::authoring::registry::RegisteredType;
use concinnity_cook::authoring::world::{entry_id, find_entry};
use concinnity_core::math::vec3::add;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TemplateSlot {
pub def_name: String,
pub def_index: usize,
pub entry_index: usize,
pub parent: Frame,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Frame {
pub pos: [f32; 3],
pub rot: [f32; 3],
pub scale: [f32; 3],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FieldMap {
Direct(&'static str),
Position,
Rotation,
Scale,
}
pub(crate) fn resolve(
entries: &[Value],
generated_by: &str,
asset_name: &str,
) -> Result<TemplateSlot, String> {
let instance = find_entry(entries, generated_by)
.map(|i| &entries[i])
.filter(|e| entry_type(e) == Some(RegisteredType::Prop))
.ok_or_else(|| format!("no Prop instance named '{generated_by}'"))?;
let args = instance.get("args").cloned().unwrap_or(Value::Null);
let prefab_ref = args
.get("prefab")
.and_then(|v| v.as_str())
.filter(|r| !r.is_empty())
.ok_or_else(|| format!("'{generated_by}' is not a prefab instance"))?;
let suffix = asset_name
.strip_prefix(&format!("{generated_by}_"))
.ok_or_else(|| format!("'{asset_name}' was not generated by '{generated_by}'"))?;
let mut matches: Vec<TemplateSlot> = Vec::new();
let frame = Frame {
pos: f32_arr3(&args, "position", [0.0; 3]),
rot: f32_arr3(&args, "rotation_deg", [0.0; 3]),
scale: f32_arr3(&args, "scale", [1.0; 3]),
};
walk(entries, prefab_ref, suffix, frame, &mut matches)?;
match matches.len() {
0 => Err(format!(
"prefab '{prefab_ref}' has no entry matching '{suffix}'"
)),
1 => Ok(matches.remove(0)),
_ => Err(format!(
"'{suffix}' matches more than one entry in prefab '{prefab_ref}'"
)),
}
}
fn walk(
entries: &[Value],
def_name: &str,
suffix: &str,
parent: Frame,
matches: &mut Vec<TemplateSlot>,
) -> Result<(), String> {
let def_index = entries
.iter()
.position(|e| entry_name(e) == def_name && entry_type(e) == Some(RegisteredType::Prefab))
.ok_or_else(|| {
format!(
"prefab '{def_name}' is a disk preset; materialize it as an authored Prefab first"
)
})?;
let props = entries[def_index]
.get("args")
.and_then(|a| a.get("props"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for (i, entry) in props.iter().enumerate() {
let kind = entry.get("kind").and_then(|v| v.as_str()).unwrap_or("prop");
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("obj");
if kind == "prefab" {
let Some(rest) = suffix.strip_prefix(&format!("{name}_")) else {
continue;
};
let nested = entry.get("prefab").and_then(|v| v.as_str()).unwrap_or("");
if nested.is_empty() {
continue;
}
let child = Frame {
pos: compose_position(f32_arr3(entry, "position", [0.0; 3]), parent),
rot: add(parent.rot, f32_arr3(entry, "rotation_deg", [0.0; 3])),
scale: mul3(parent.scale, f32_arr3(entry, "scale", [1.0; 3])),
};
walk(entries, nested, rest, child, matches)?;
} else if name == suffix {
matches.push(TemplateSlot {
def_name: def_name.to_string(),
def_index,
entry_index: i,
parent,
});
}
}
Ok(())
}
const DIRECT_PROP_KEYS: [&str; 8] = [
"model",
"mesh",
"material",
"texture",
"parent",
"interactable",
"pickup",
"collider",
];
pub(crate) fn map_field(asset_type: RegisteredType, root: &str) -> Result<FieldMap, String> {
let uncarried = || format!("'{root}' is not carried by the prefab entry");
match (asset_type, root) {
(RegisteredType::Prop | RegisteredType::PointLight, "position") => Ok(FieldMap::Position),
(RegisteredType::Prop, "rotation_deg") => Ok(FieldMap::Rotation),
(RegisteredType::Prop, "scale") => Ok(FieldMap::Scale),
(RegisteredType::Prop, _) => DIRECT_PROP_KEYS
.into_iter()
.find(|key| *key == root)
.map(FieldMap::Direct)
.ok_or_else(uncarried),
(RegisteredType::PointLight, "color") => Ok(FieldMap::Direct("light_color")),
(RegisteredType::PointLight, "intensity") => Ok(FieldMap::Direct("light_intensity")),
(RegisteredType::PointLight, "range") => Ok(FieldMap::Direct("light_range")),
_ => Err(uncarried()),
}
}
pub(crate) fn write_field(
entry: &mut Value,
slot: &TemplateSlot,
map: FieldMap,
covered: &str,
value: &Value,
) -> Result<(), String> {
match map {
FieldMap::Direct(key) => {
let sub = covered.split_once('.').map(|(_, rest)| rest);
let path = match sub {
Some(rest) => format!("{key}.{rest}"),
None => key.to_string(),
};
set_at_path(entry, &path, value.clone());
Ok(())
}
FieldMap::Position => {
let world = as_vec3(value).ok_or("position is not a 3-vector")?;
for (i, s) in slot.parent.scale.iter().enumerate() {
if *s == 0.0 {
return Err(format!(
"instance scale is zero on axis {i}; the local position is unrecoverable"
));
}
}
let delta = [
(world[0] - slot.parent.pos[0]) / slot.parent.scale[0],
(world[1] - slot.parent.pos[1]) / slot.parent.scale[1],
(world[2] - slot.parent.pos[2]) / slot.parent.scale[2],
];
let local = rotate_local_inv(delta, slot.parent.rot);
entry["position"] = json_vec3(local.map(round3));
Ok(())
}
FieldMap::Rotation => {
let world = as_vec3(value).ok_or("rotation is not a 3-vector")?;
let local = [
world[0] - slot.parent.rot[0],
world[1] - slot.parent.rot[1],
world[2] - slot.parent.rot[2],
];
entry["rotation_deg"] = json_vec3(local.map(round1));
Ok(())
}
FieldMap::Scale => {
let world = as_vec3(value).ok_or("scale is not a 3-vector")?;
for (i, s) in slot.parent.scale.iter().enumerate() {
if *s == 0.0 {
return Err(format!(
"instance scale is zero on axis {i}; the local scale is unrecoverable"
));
}
}
let local = [
world[0] / slot.parent.scale[0],
world[1] / slot.parent.scale[1],
world[2] / slot.parent.scale[2],
];
entry["scale"] = json_vec3(local.map(round3));
Ok(())
}
}
}
fn compose_position(local: [f32; 3], parent: Frame) -> [f32; 3] {
let r = rotate_local(local, parent.rot);
[
parent.pos[0] + parent.scale[0] * r[0],
parent.pos[1] + parent.scale[1] * r[1],
parent.pos[2] + parent.scale[2] * r[2],
]
}
fn rotate_local(pos: [f32; 3], rotation_deg: [f32; 3]) -> [f32; 3] {
let m = rotation_matrix(rotation_deg);
[
m[0][0] * pos[0] + m[0][1] * pos[1] + m[0][2] * pos[2],
m[1][0] * pos[0] + m[1][1] * pos[1] + m[1][2] * pos[2],
m[2][0] * pos[0] + m[2][1] * pos[1] + m[2][2] * pos[2],
]
}
fn rotate_local_inv(pos: [f32; 3], rotation_deg: [f32; 3]) -> [f32; 3] {
let m = rotation_matrix(rotation_deg);
[
m[0][0] * pos[0] + m[1][0] * pos[1] + m[2][0] * pos[2],
m[0][1] * pos[0] + m[1][1] * pos[1] + m[2][1] * pos[2],
m[0][2] * pos[0] + m[1][2] * pos[1] + m[2][2] * pos[2],
]
}
fn rotation_matrix(rotation_deg: [f32; 3]) -> [[f32; 3]; 3] {
let [pitch_deg, yaw_deg, roll_deg] = rotation_deg;
let (sp, cp) = (pitch_deg.to_radians().sin(), pitch_deg.to_radians().cos());
let (sy, cy) = (yaw_deg.to_radians().sin(), yaw_deg.to_radians().cos());
let (sr, cr) = (roll_deg.to_radians().sin(), roll_deg.to_radians().cos());
[
[cy * cr + sy * sp * sr, -cy * sr + sy * sp * cr, sy * cp],
[cp * sr, cp * cr, -sp],
[-sy * cr + cy * sp * sr, sy * sr + cy * sp * cr, cy * cp],
]
}
fn mul3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] * b[0], a[1] * b[1], a[2] * b[2]]
}
fn round3(v: f32) -> f32 {
(v * 1000.0).round() / 1000.0
}
fn round1(v: f32) -> f32 {
(v * 10.0).round() / 10.0
}
fn as_vec3(v: &Value) -> Option<[f32; 3]> {
let arr = v.as_array()?;
if arr.len() != 3 {
return None;
}
Some([
arr[0].as_f64()? as f32,
arr[1].as_f64()? as f32,
arr[2].as_f64()? as f32,
])
}
fn json_vec3(v: [f32; 3]) -> Value {
serde_json::json!([v[0], v[1], v[2]])
}
fn f32_arr3(v: &Value, key: &str, default: [f32; 3]) -> [f32; 3] {
v.get(key)
.and_then(|a| a.as_array())
.filter(|a| a.len() == 3)
.map(|a| {
[
a[0].as_f64().unwrap_or(default[0] as f64) as f32,
a[1].as_f64().unwrap_or(default[1] as f64) as f32,
a[2].as_f64().unwrap_or(default[2] as f64) as f32,
]
})
.unwrap_or(default)
}
fn set_at_path(v: &mut Value, path: &str, value: Value) {
match path.split_once('.') {
None => {
v[path] = value;
}
Some((head, rest)) => {
if !v.get(head).is_some_and(|c| c.is_object()) {
v[head] = serde_json::json!({});
}
set_at_path(&mut v[head], rest, value);
}
}
}
fn entry_type(v: &Value) -> Option<RegisteredType> {
v.get("type")
.and_then(Value::as_str)
.and_then(RegisteredType::parse)
}
fn entry_name(v: &Value) -> &str {
entry_id(v).unwrap_or("")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn table_world() -> Vec<Value> {
vec![
json!({"type":"Prefab","args":{"$id":"leaf","props":[
{"name":"cup","kind":"prop","mesh":"box","position":[1,0,0],"scale":[2,2,2]}]}}),
json!({"type":"Prefab","args":{"$id":"table","props":[
{"name":"top","kind":"prop","mesh":"box"},
{"name":"set","kind":"prefab","prefab":"leaf","position":[0,1,0]}]}}),
json!({"type":"Prop","args":{
"$id":"inst",
"prefab":"table","position":[10,0,0],"scale":[3,3,3]}}),
]
}
#[test]
fn resolve_finds_a_top_level_entry() {
let entries = table_world();
let slot = resolve(&entries, "inst", "inst_top").unwrap();
assert_eq!(slot.def_name, "table");
assert_eq!(slot.entry_index, 0);
assert_eq!(slot.parent.pos, [10.0, 0.0, 0.0]);
assert_eq!(slot.parent.scale, [3.0, 3.0, 3.0]);
}
#[test]
fn resolve_walks_nested_chains_composing_the_parent_frame() {
let entries = table_world();
let slot = resolve(&entries, "inst", "inst_set_cup").unwrap();
assert_eq!(slot.def_name, "leaf");
assert_eq!(slot.entry_index, 0);
assert_eq!(slot.parent.pos, [10.0, 3.0, 0.0]);
assert_eq!(slot.parent.scale, [3.0, 3.0, 3.0]);
}
#[test]
fn resolve_rejects_a_preset_backed_chain() {
let entries = vec![json!({"type":"Prop","args":{"$id":"inst","prefab":"ghost"}})];
let err = resolve(&entries, "inst", "inst_x").unwrap_err();
assert!(err.contains("preset"), "{err}");
}
#[test]
fn position_write_back_round_trips_the_expansion_composition() {
let entries = table_world();
let slot = resolve(&entries, "inst", "inst_set_cup").unwrap();
let mut entry = json!({"name":"cup","kind":"prop","mesh":"box"});
write_field(
&mut entry,
&slot,
FieldMap::Position,
"position",
&json!([13.0, 3.0, 0.0]),
)
.unwrap();
assert_eq!(entry["position"], json!([1.0, 0.0, 0.0]));
}
#[test]
fn position_write_back_inverts_rotation() {
let slot = TemplateSlot {
def_name: "p".into(),
def_index: 0,
entry_index: 0,
parent: Frame {
pos: [0.0; 3],
rot: [0.0, 90.0, 0.0],
scale: [1.0; 3],
},
};
let mut entry = json!({});
write_field(
&mut entry,
&slot,
FieldMap::Position,
"position",
&json!([0.0, 0.0, -1.0]),
)
.unwrap();
assert_eq!(entry["position"], json!([1.0, 0.0, 0.0]));
}
#[test]
fn zero_parent_scale_refuses_position_and_scale_write_back() {
let slot = TemplateSlot {
def_name: "p".into(),
def_index: 0,
entry_index: 0,
parent: Frame {
pos: [0.0; 3],
rot: [0.0; 3],
scale: [0.0, 1.0, 1.0],
},
};
let mut entry = json!({});
assert!(
write_field(
&mut entry,
&slot,
FieldMap::Position,
"position",
&json!([1, 2, 3])
)
.is_err()
);
assert!(
write_field(
&mut entry,
&slot,
FieldMap::Scale,
"scale",
&json!([1, 2, 3])
)
.is_err()
);
}
#[test]
fn rotation_and_scale_invert_componentwise() {
let slot = TemplateSlot {
def_name: "p".into(),
def_index: 0,
entry_index: 0,
parent: Frame {
pos: [0.0; 3],
rot: [0.0, 45.0, 0.0],
scale: [2.0, 2.0, 2.0],
},
};
let mut entry = json!({});
write_field(
&mut entry,
&slot,
FieldMap::Rotation,
"rotation_deg",
&json!([0.0, 90.0, 0.0]),
)
.unwrap();
assert_eq!(entry["rotation_deg"], json!([0.0, 45.0, 0.0]));
write_field(
&mut entry,
&slot,
FieldMap::Scale,
"scale",
&json!([6.0, 2.0, 4.0]),
)
.unwrap();
assert_eq!(entry["scale"], json!([3.0, 1.0, 2.0]));
}
#[test]
fn direct_fields_preserve_sub_paths_and_mapped_names() {
let slot = TemplateSlot {
def_name: "p".into(),
def_index: 0,
entry_index: 0,
parent: Frame {
pos: [0.0; 3],
rot: [0.0; 3],
scale: [1.0; 3],
},
};
let mut entry = json!({"collider": {"shape": "box", "radius": 1.0}});
write_field(
&mut entry,
&slot,
map_field(RegisteredType::Prop, "collider").unwrap(),
"collider.shape",
&json!("sphere"),
)
.unwrap();
assert_eq!(entry["collider"], json!({"shape": "sphere", "radius": 1.0}));
let mut light = json!({});
write_field(
&mut light,
&slot,
map_field(RegisteredType::PointLight, "intensity").unwrap(),
"intensity",
&json!(4.0),
)
.unwrap();
assert_eq!(light["light_intensity"], json!(4.0));
for key in DIRECT_PROP_KEYS {
assert_eq!(
map_field(RegisteredType::Prop, key),
Ok(FieldMap::Direct(key))
);
}
}
#[test]
fn map_field_rejects_uncarried_args() {
assert!(map_field(RegisteredType::Prop, "visible").is_err());
assert!(map_field(RegisteredType::PointLight, "rotation_deg").is_err());
assert!(map_field(RegisteredType::Sprite, "tint").is_err());
}
#[test]
fn map_field_maps_transforms_per_type() {
let prop = RegisteredType::Prop;
let light = RegisteredType::PointLight;
assert_eq!(map_field(prop, "position"), Ok(FieldMap::Position));
assert_eq!(map_field(prop, "rotation_deg"), Ok(FieldMap::Rotation));
assert_eq!(map_field(prop, "scale"), Ok(FieldMap::Scale));
assert_eq!(map_field(light, "position"), Ok(FieldMap::Position));
assert_eq!(
map_field(light, "color"),
Ok(FieldMap::Direct("light_color"))
);
assert_eq!(
map_field(light, "range"),
Ok(FieldMap::Direct("light_range"))
);
}
#[test]
fn map_field_carries_nothing_for_a_type_without_a_prefab_entry() {
for &ty in RegisteredType::all() {
if matches!(ty, RegisteredType::Prop | RegisteredType::PointLight) {
continue;
}
assert!(map_field(ty, "position").is_err(), "{}", ty.as_str());
}
}
}