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 = entries
.iter()
.find(|e| entry_name(e) == generated_by && type_norm(e) == "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 && type_norm(e) == "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(())
}
pub(crate) fn map_field(asset_type: &str, root: &str) -> Result<FieldMap, String> {
let ty = asset_type.to_lowercase().replace('_', "");
match (ty.as_str(), root) {
("prop" | "pointlight", "position") => Ok(FieldMap::Position),
("prop", "rotation_deg") => Ok(FieldMap::Rotation),
("prop", "scale") => Ok(FieldMap::Scale),
(
"prop",
"model" | "mesh" | "material" | "texture" | "parent" | "interactable" | "pickup"
| "collider",
) => Ok(FieldMap::Direct(direct_key(root))),
("pointlight", "color") => Ok(FieldMap::Direct("light_color")),
("pointlight", "intensity") => Ok(FieldMap::Direct("light_intensity")),
("pointlight", "range") => Ok(FieldMap::Direct("light_range")),
_ => Err(format!("'{root}' is not carried by the prefab entry")),
}
}
fn direct_key(root: &str) -> &'static str {
for key in [
"model",
"mesh",
"material",
"texture",
"parent",
"interactable",
"pickup",
"collider",
] {
if key == root {
return key;
}
}
unreachable!("unmapped direct key '{root}'")
}
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 type_norm(v: &Value) -> String {
v.get("type")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_lowercase()
.replace('_', "")
}
fn entry_name(v: &Value) -> &str {
v.get("name").and_then(|n| n.as_str()).unwrap_or("")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn table_world() -> Vec<Value> {
vec![
json!({"name":"leaf","type":"Prefab","args":{"props":[
{"name":"cup","kind":"prop","mesh":"box","position":[1,0,0],"scale":[2,2,2]}]}}),
json!({"name":"table","type":"Prefab","args":{"props":[
{"name":"top","kind":"prop","mesh":"box"},
{"name":"set","kind":"prefab","prefab":"leaf","position":[0,1,0]}]}}),
json!({"name":"inst","type":"Prop","args":{
"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!({"name":"inst","type":"Prop","args":{"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("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("PointLight", "intensity").unwrap(),
"intensity",
&json!(4.0),
)
.unwrap();
assert_eq!(light["light_intensity"], json!(4.0));
}
#[test]
fn map_field_rejects_uncarried_args() {
assert!(map_field("Prop", "visible").is_err());
assert!(map_field("Sprite", "tint").is_err());
}
}