use std::collections::{HashMap, HashSet};
use std::path::Path;
use super::expand::{ExpandReport, asset_name, type_norm};
use crate::import::{ImportOptions, entries_from_scene, sanitize_name};
pub(crate) fn expand_scene_imports(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
assets_dir: Option<&Path>,
) -> Result<(), String> {
if !assets.iter().any(|v| type_norm(v) == "sceneimport") {
return Ok(());
}
let world_has_camera = assets
.iter()
.any(|v| matches!(type_norm(v).as_str(), "camera3d" | "camerashot"));
let mut camera_emitted = false;
let authored: HashMap<String, String> = assets
.iter()
.filter(|v| type_norm(v) != "sceneimport")
.map(|v| (asset_name(v), type_of(v)))
.filter(|(n, _)| !n.is_empty())
.collect();
let mut taken: HashSet<String> = HashSet::new();
let mut result: Vec<serde_json::Value> = Vec::new();
let mut merges: Vec<(String, serde_json::Value)> = Vec::new();
for value in assets.drain(..) {
if type_norm(&value) != "sceneimport" {
result.push(value);
continue;
}
let import_name = asset_name(&value);
let args = value
.get("args")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let source = args
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if source.is_empty() {
return Err(format!("SceneImport '{}': missing `source`", import_name));
}
let want_camera = args
.get("emit_camera")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let opts = ImportOptions {
name_prefix: sanitize_name(&import_name),
texture_max_size: args
.get("texture_max_size")
.and_then(|v| v.as_u64())
.unwrap_or(512) as u32,
emissive_map_strength: args
.get("emissive_map_strength")
.and_then(|v| v.as_f64())
.unwrap_or(3.0) as f32,
emit_camera: want_camera && !world_has_camera && !camera_emitted,
};
let entries = expand_one(&source, &opts, assets_dir)
.map_err(|e| format!("SceneImport '{}': {}", import_name, e))?;
for entry in entries {
if !resolve_entry(&entry, &authored, &mut taken, &import_name, report)? {
let name = asset_name(&entry);
let args = entry.get("args").cloned().unwrap_or(serde_json::json!({}));
merges.push((name, args));
continue;
}
if type_norm(&entry) == "camera3d" {
camera_emitted = true;
}
result.push(entry);
}
}
for (name, template_args) in &merges {
super::shadow::merge_into_authored(&mut result, name, template_args);
}
*assets = result;
Ok(())
}
fn resolve_entry(
entry: &serde_json::Value,
authored: &HashMap<String, String>,
taken: &mut HashSet<String>,
import_name: &str,
report: &mut ExpandReport,
) -> Result<bool, String> {
let name = asset_name(entry);
if !name.is_empty() && !taken.insert(name.clone()) {
return Err(format!(
"SceneImport '{}': generated asset name '{}' collides with an asset generated by \
another import; rename one of the imports",
import_name, name
));
}
if let Some(authored_type) = authored.get(&name) {
if norm(authored_type) != type_norm(entry) {
return Err(format!(
"SceneImport '{}': generated asset '{}' ({}) collides with your {} asset of \
the same name; rename that asset or the import",
import_name,
name,
type_of(entry),
authored_type,
));
}
let args = entry.get("args").cloned().unwrap_or(serde_json::json!({}));
report.record_shadowed(&name, authored_type, import_name, args);
return Ok(false);
}
if !name.is_empty() {
report.record_generated(&name, &type_of(entry), import_name);
}
Ok(true)
}
fn type_of(v: &serde_json::Value) -> String {
v.get("type")
.and_then(|t| t.as_str())
.unwrap_or("?")
.to_string()
}
fn norm(type_str: &str) -> String {
type_str.to_lowercase().replace('_', "")
}
fn expand_one(
source: &str,
opts: &ImportOptions,
assets_dir: Option<&Path>,
) -> std::io::Result<Vec<serde_json::Value>> {
let key_args = serde_json::json!({
"prefix": opts.name_prefix,
"texture_max_size": opts.texture_max_size,
"emissive_map_strength": opts.emissive_map_strength,
"emit_camera": opts.emit_camera,
});
let key = crate::cache::expand_key(source, &key_args, assets_dir);
if let Some(bytes) = crate::cache::load(&key)
&& let Ok(entries) = serde_json::from_slice::<Vec<serde_json::Value>>(&bytes)
{
return Ok(entries);
}
let entries = entries_from_scene(source, opts, assets_dir)?;
if let Ok(bytes) = serde_json::to_vec(&entries) {
crate::cache::store(&key, &bytes);
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passes_through_without_imports() {
let mut assets = vec![serde_json::json!({"name":"x","type":"Logger","args":{}})];
let mut report = ExpandReport::default();
expand_scene_imports(&mut assets, &mut report, None).unwrap();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0]["type"], "Logger");
}
#[test]
fn missing_source_is_an_error() {
let mut assets = vec![serde_json::json!({
"name": "scene", "type": "SceneImport", "args": {}
})];
let mut report = ExpandReport::default();
let err = expand_scene_imports(&mut assets, &mut report, None).unwrap_err();
assert!(err.contains("missing `source`"));
}
#[test]
fn an_import_without_args_is_a_missing_source() {
let mut assets = vec![serde_json::json!({"name":"scene","type":"SceneImport"})];
let mut report = ExpandReport::default();
let err = expand_scene_imports(&mut assets, &mut report, None).unwrap_err();
assert!(
err.contains("SceneImport 'scene': missing `source`"),
"{err}"
);
}
#[test]
fn unsupported_source_format_is_an_error() {
let mut assets = vec![serde_json::json!({
"name": "scene", "type": "SceneImport", "args": {"source": "thing.txt"}
})];
let mut report = ExpandReport::default();
let err = expand_scene_imports(&mut assets, &mut report, None).unwrap_err();
assert!(err.contains("scene"));
assert!(err.contains(".txt"));
}
fn triangle_gltf(dir: &std::path::Path) -> String {
let mut json = crate::glb::test_fixtures::static_triangle_json();
json["buffers"][0]["uri"] = "geo.bin".into();
std::fs::write(
dir.join("geo.bin"),
crate::glb::test_fixtures::static_triangle_bin(),
)
.unwrap();
let path = dir.join("tri.gltf");
std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap();
path.to_string_lossy().into_owned()
}
fn scene_import(name: &str, source: &str, extra: serde_json::Value) -> serde_json::Value {
let mut args = serde_json::json!({"source": source});
for (k, v) in extra.as_object().cloned().unwrap_or_default() {
args[k] = v;
}
serde_json::json!({"name": name, "type": "SceneImport", "args": args})
}
#[test]
fn an_import_expands_in_place_and_records_what_it_generated() {
let dir = tempfile::tempdir().unwrap();
let source = triangle_gltf(dir.path());
let mut assets = vec![
serde_json::json!({"name":"gfx","type":"GraphicsConfig","args":{}}),
scene_import("bistro", &source, serde_json::json!({})),
];
let mut report = ExpandReport::default();
expand_scene_imports(&mut assets, &mut report, None).unwrap();
assert_eq!(assets[0]["name"], "gfx");
assert!(!assets.iter().any(|v| type_norm(v) == "sceneimport"));
let names: Vec<String> = assets.iter().skip(1).map(asset_name).collect();
assert!(
names.iter().all(|n| n.starts_with("bistro_")),
"unprefixed entries: {names:?}"
);
assert!(names.contains(&"bistro_mat_default".to_string()));
assert!(names.contains(&"bistro_prim_0".to_string()));
assert!(names.contains(&"bistro_model_0".to_string()));
let recorded: Vec<&str> = report
.generated
.iter()
.map(|g| g.name.as_str())
.collect::<Vec<_>>();
assert_eq!(recorded.len(), names.len());
assert!(report.generated.iter().all(|g| g.generated_by == "bistro"));
}
#[test]
fn the_framed_camera_yields_to_the_world_and_to_earlier_imports() {
let dir = tempfile::tempdir().unwrap();
let source = triangle_gltf(dir.path());
let cameras = |assets: &[serde_json::Value]| {
assets.iter().filter(|v| type_norm(v) == "camera3d").count()
};
let mut alone = vec![scene_import("a", &source, serde_json::json!({}))];
expand_scene_imports(&mut alone, &mut ExpandReport::default(), None).unwrap();
assert_eq!(cameras(&alone), 1);
let mut pair = vec![
scene_import("a", &source, serde_json::json!({})),
scene_import("b", &source, serde_json::json!({})),
];
expand_scene_imports(&mut pair, &mut ExpandReport::default(), None).unwrap();
assert_eq!(cameras(&pair), 1);
let mut authored = vec![
serde_json::json!({"name":"cam","type":"CameraShot","args":{}}),
scene_import("a", &source, serde_json::json!({})),
];
expand_scene_imports(&mut authored, &mut ExpandReport::default(), None).unwrap();
assert_eq!(cameras(&authored), 0);
let mut declined = vec![scene_import(
"a",
&source,
serde_json::json!({"emit_camera": false}),
)];
expand_scene_imports(&mut declined, &mut ExpandReport::default(), None).unwrap();
assert_eq!(cameras(&declined), 0);
}
#[test]
fn import_options_reach_the_generated_textures() {
let dir = tempfile::tempdir().unwrap();
let mut json = crate::glb::test_fixtures::static_triangle_json();
json["buffers"][0]["uri"] = "geo.bin".into();
json["images"] = serde_json::json!([{"uri": "albedo.png"}]);
std::fs::write(
dir.path().join("geo.bin"),
crate::glb::test_fixtures::static_triangle_bin(),
)
.unwrap();
let source = dir.path().join("tex.gltf");
std::fs::write(&source, serde_json::to_vec(&json).unwrap()).unwrap();
let mut assets = vec![scene_import(
"bistro",
&source.to_string_lossy(),
serde_json::json!({"texture_max_size": 128, "emissive_map_strength": 5.0}),
)];
let mut report = ExpandReport::default();
expand_scene_imports(&mut assets, &mut report, None).unwrap();
let tex = assets
.iter()
.find(|v| type_norm(v) == "texture")
.expect("a Texture entry per glTF image");
assert_eq!(asset_name(tex), "bistro_tex_0");
assert_eq!(tex["args"]["max_size"], 128);
assert_eq!(tex["args"]["image_index"], 0);
}
#[test]
fn a_type_clash_during_expansion_aborts_the_import() {
let dir = tempfile::tempdir().unwrap();
let source = triangle_gltf(dir.path());
let mut assets = vec![
serde_json::json!({"name":"bistro_mat_default","type":"Sprite","args":{}}),
scene_import("bistro", &source, serde_json::json!({})),
];
let mut report = ExpandReport::default();
let err = expand_scene_imports(&mut assets, &mut report, None).unwrap_err();
assert!(err.contains("bistro_mat_default"), "{err}");
assert!(err.contains("Sprite"), "{err}");
}
#[test]
fn a_nameless_entry_is_emitted_without_being_recorded() {
let entry = serde_json::json!({"type": "Material"});
let mut report = ExpandReport::default();
let mut taken = HashSet::new();
assert!(resolve_entry(&entry, &authored_map(&[]), &mut taken, "b", &mut report).unwrap());
assert!(report.generated.is_empty());
assert!(taken.is_empty());
}
#[test]
fn an_authored_copy_replaces_the_generated_entry_in_the_world() {
let dir = tempfile::tempdir().unwrap();
let source = triangle_gltf(dir.path());
let mut assets = vec![
serde_json::json!({
"name":"bistro_mat_default","type":"Material","args":{"roughness":0.1}
}),
scene_import("bistro", &source, serde_json::json!({})),
];
let mut report = ExpandReport::default();
expand_scene_imports(&mut assets, &mut report, None).unwrap();
let mats: Vec<&serde_json::Value> = assets
.iter()
.filter(|v| asset_name(v) == "bistro_mat_default")
.collect();
assert_eq!(mats.len(), 1);
assert_eq!(mats[0]["args"]["roughness"], 0.1);
assert_eq!(report.shadowed.len(), 1);
assert_eq!(report.shadowed[0].name, "bistro_mat_default");
}
fn authored_map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(n, t)| (n.to_string(), t.to_string()))
.collect()
}
fn resolve(
entry: &serde_json::Value,
authored: &HashMap<String, String>,
report: &mut ExpandReport,
) -> Result<bool, String> {
let mut taken = HashSet::new();
resolve_entry(entry, authored, &mut taken, "bistro", report)
}
#[test]
fn a_generated_entry_is_emitted_and_recorded() {
let entry = serde_json::json!({"name": "bistro_mat_wood", "type": "Material"});
let mut report = ExpandReport::default();
assert!(resolve(&entry, &authored_map(&[]), &mut report).unwrap());
assert!(report.shadowed.is_empty());
assert_eq!(report.generated.len(), 1);
assert_eq!(report.generated[0].name, "bistro_mat_wood");
assert_eq!(report.generated[0].asset_type, "Material");
assert_eq!(report.generated[0].generated_by, "bistro");
}
#[test]
fn an_authored_copy_shadows_the_generated_entry() {
let entry = serde_json::json!({"name": "bistro_mat_wood", "type": "Material"});
let authored = authored_map(&[("bistro_mat_wood", "Material")]);
let mut report = ExpandReport::default();
assert!(!resolve(&entry, &authored, &mut report).unwrap());
assert!(report.generated.is_empty());
assert_eq!(report.shadowed.len(), 1);
assert_eq!(report.shadowed[0].name, "bistro_mat_wood");
assert_eq!(report.shadowed[0].asset_type, "Material");
assert_eq!(report.shadowed[0].generated_by, "bistro");
}
#[test]
fn shadowing_matches_types_through_normalization() {
let entry = serde_json::json!({"name": "bistro_cam", "type": "Camera3D"});
let authored = authored_map(&[("bistro_cam", "camera_3d")]);
let mut report = ExpandReport::default();
assert!(!resolve(&entry, &authored, &mut report).unwrap());
assert_eq!(report.shadowed.len(), 1);
}
#[test]
fn a_same_name_different_type_asset_is_an_error() {
let entry = serde_json::json!({"name": "bistro_mat_wood", "type": "Material"});
let authored = authored_map(&[("bistro_mat_wood", "Sprite")]);
let mut report = ExpandReport::default();
let err = resolve(&entry, &authored, &mut report).unwrap_err();
assert!(err.contains("bistro_mat_wood"), "{err}");
assert!(err.contains("Material"), "{err}");
assert!(err.contains("Sprite"), "{err}");
assert!(err.contains("bistro"), "{err}");
assert!(report.shadowed.is_empty());
assert!(report.generated.is_empty());
}
#[test]
fn two_imports_generating_the_same_name_is_an_error() {
let entry = serde_json::json!({"name": "shared_mat", "type": "Material"});
let authored = authored_map(&[]);
let mut report = ExpandReport::default();
let mut taken = HashSet::new();
assert!(resolve_entry(&entry, &authored, &mut taken, "first", &mut report).unwrap());
let err = resolve_entry(&entry, &authored, &mut taken, "second", &mut report).unwrap_err();
assert!(err.contains("another import"), "{err}");
assert!(err.contains("shared_mat"), "{err}");
}
#[test]
fn type_of_reads_the_declared_type_and_norm_matches_type_norm() {
let v = serde_json::json!({"name": "m", "type": "Material"});
assert_eq!(type_of(&v), "Material");
assert_eq!(norm(&type_of(&v)), type_norm(&v));
assert_eq!(type_of(&serde_json::json!({"name": "m"})), "?");
let c = serde_json::json!({"type": "Camera_3D"});
assert_eq!(norm(&type_of(&c)), type_norm(&c));
}
}