use std::path::{Path, PathBuf};
use path_slash::PathExt as _;
use jackdaw_bsn::{
BsnField, BsnPatch, BsnStructData, BsnStructFields, BsnTupleStructData, BsnValue, SceneBsnAst,
};
use crate::components::{ISA_TYPE, PREFAB_ENTITY_ID_TYPE, PREFAB_TYPE};
use crate::resolve::{read_isa_deleted, read_isa_source, set_whole_component};
const TRANSFORM_TYPE: &str = "bevy_transform::components::transform::Transform";
const VISIBILITY_INHERITED_TYPE: &str = "bevy_camera::visibility::Visibility::Inherited";
pub fn source_path(source: &Path, assets_root: &Path, document_dir: &Path) -> PathBuf {
if source.is_absolute() {
return normalize(source);
}
let under_assets = normalize(&assets_root.join(source));
if document_dir.as_os_str().is_empty() || document_dir == assets_root {
return under_assets;
}
if is_a_held_document(&under_assets) {
return under_assets;
}
let beside_document = normalize(&document_dir.join(source));
match is_a_held_document(&beside_document) {
true => beside_document,
false => under_assets,
}
}
fn is_a_held_document(path: &Path) -> bool {
path.exists() || jackdaw_bsn::existing_form(path).is_some()
}
pub fn normalize_path(path: &Path) -> PathBuf {
normalize(path)
}
fn normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn relativize_isa_sources(ast: &mut SceneBsnAst, dir: &Path) {
rewrite_absolute_sources(ast, dir, false);
}
pub fn relativize_isa_sources_under(ast: &mut SceneBsnAst, assets_root: &Path) -> Vec<String> {
rewrite_absolute_sources(ast, assets_root, true)
}
fn rewrite_absolute_sources(ast: &mut SceneBsnAst, root: &Path, within: bool) -> Vec<String> {
let mut strays = Vec::new();
if !root.is_absolute() {
return strays;
}
for node in ast.entities_with_component(ISA_TYPE) {
let Some(source) = read_isa_source(ast, node) else {
continue;
};
if !source.is_absolute() {
continue;
}
let Some(relative) = pathdiff::diff_paths(&source, root)
.filter(|relative| !within || !relative.starts_with(".."))
else {
strays.push(source.to_slash_lossy().into_owned());
continue;
};
let deleted = read_isa_deleted(ast, node);
let spelling = relative.to_slash_lossy();
set_whole_component(ast, node, ISA_TYPE, isa_value(&spelling, &deleted));
}
strays
}
pub fn absolutize_isa_sources(ast: &mut SceneBsnAst, assets_root: &Path, document_dir: &Path) {
for node in ast.entities_with_component(ISA_TYPE) {
let Some(source) = read_isa_source(ast, node) else {
continue;
};
let full = source_path(&source, assets_root, document_dir);
if full == source {
continue;
}
let deleted = read_isa_deleted(ast, node);
set_whole_component(
ast,
node,
ISA_TYPE,
isa_value(&full.to_slash_lossy(), &deleted),
);
}
}
pub fn isa_value(source: &str, deleted: &[u32]) -> BsnValue {
BsnValue::Struct(BsnStructData {
type_path: ISA_TYPE.to_string(),
fields: BsnStructFields(vec![
BsnField {
name: "source".to_string(),
value: BsnValue::String(source.to_string()),
},
BsnField {
name: "deleted".to_string(),
value: BsnValue::List(
deleted
.iter()
.map(|&d| BsnValue::Int(i128::from(d)))
.collect(),
),
},
]),
})
}
pub fn normalize_as_prefab_source(ast: &mut SceneBsnAst, display_name: &str) {
let roots = ast.roots.clone();
if roots.is_empty()
|| roots
.iter()
.any(|&root| ast.find_patch_by_type_path(root, PREFAB_TYPE).is_some())
{
return;
}
if roots.len() == 1 {
let root = roots[0];
set_whole_component(
ast,
root,
PREFAB_TYPE,
BsnValue::Type(PREFAB_TYPE.to_string()),
);
set_whole_component(ast, root, PREFAB_ENTITY_ID_TYPE, peid_value(0));
for (i, desc) in ast.descendants_of(root).into_iter().enumerate() {
set_whole_component(ast, desc, PREFAB_ENTITY_ID_TYPE, peid_value((i + 1) as u32));
}
} else {
let synth = ast.create_entity_node(synthetic_root_patches(display_name));
let mut next_id: u32 = 1;
for root in roots {
ast.move_to_parent(root, None, Some(synth));
set_whole_component(ast, root, PREFAB_ENTITY_ID_TYPE, peid_value(next_id));
next_id += 1;
for desc in ast.descendants_of(root) {
set_whole_component(ast, desc, PREFAB_ENTITY_ID_TYPE, peid_value(next_id));
next_id += 1;
}
}
ast.add_to_roots(synth);
}
}
pub fn read_prefab_document(
path: &Path,
assets_root: &Path,
) -> Result<SceneBsnAst, std::io::Error> {
let text = jackdaw_bsn::read_document_text(path)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()))?;
let mut ast = jackdaw_bsn::parse_bsn_text(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
let display_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("scene");
normalize_as_prefab_source(&mut ast, display_name);
absolutize_isa_sources(
&mut ast,
assets_root,
path.parent().unwrap_or(Path::new("")),
);
Ok(ast)
}
pub fn peid_value(id: u32) -> BsnValue {
BsnValue::TupleStruct(BsnTupleStructData {
type_path: PREFAB_ENTITY_ID_TYPE.to_string(),
values: vec![BsnValue::Int(i128::from(id))],
})
}
pub fn synthetic_root_patches(display_name: &str) -> Vec<BsnPatch> {
vec![
BsnPatch::Type(PREFAB_TYPE.to_string()),
BsnPatch::TupleStruct(BsnTupleStructData {
type_path: PREFAB_ENTITY_ID_TYPE.to_string(),
values: vec![BsnValue::Int(0)],
}),
BsnPatch::Name(display_name.to_string()),
BsnPatch::Struct(BsnStructData {
type_path: TRANSFORM_TYPE.to_string(),
fields: BsnStructFields(vec![
field("translation", vec3_value(0.0, 0.0, 0.0)),
field("rotation", quat_value(0.0, 0.0, 0.0, 1.0)),
field("scale", vec3_value(1.0, 1.0, 1.0)),
]),
}),
BsnPatch::Type(VISIBILITY_INHERITED_TYPE.to_string()),
]
}
fn field(name: &str, value: BsnValue) -> BsnField {
BsnField {
name: name.to_string(),
value,
}
}
fn vec3_value(x: f32, y: f32, z: f32) -> BsnValue {
BsnValue::Struct(BsnStructData {
type_path: "glam::Vec3".to_string(),
fields: BsnStructFields(vec![
field("x", BsnValue::Float(f64::from(x))),
field("y", BsnValue::Float(f64::from(y))),
field("z", BsnValue::Float(f64::from(z))),
]),
})
}
fn quat_value(x: f32, y: f32, z: f32, w: f32) -> BsnValue {
BsnValue::Struct(BsnStructData {
type_path: "glam::Quat".to_string(),
fields: BsnStructFields(vec![
field("x", BsnValue::Float(f64::from(x))),
field("y", BsnValue::Float(f64::from(y))),
field("z", BsnValue::Float(f64::from(z))),
field("w", BsnValue::Float(f64::from(w))),
]),
})
}
#[cfg(test)]
mod tests {
use super::*;
use jackdaw_bsn::get_bsn_field;
fn scene_with_source(source: &str) -> SceneBsnAst {
let mut ast = SceneBsnAst::default();
let BsnValue::Struct(data) = isa_value(source, &[]) else {
unreachable!()
};
let node = ast.create_entity_node(vec![BsnPatch::Struct(data)]);
ast.add_to_roots(node);
ast
}
fn source_of(ast: &SceneBsnAst) -> String {
match get_bsn_field(ast, ast.roots[0], ISA_TYPE, "source") {
Some(BsnValue::String(s)) => s,
other => panic!("expected a source string, got {other:?}"),
}
}
#[test]
fn a_relative_source_resolves_against_the_assets_folder() {
let assets = std::path::absolute("/game/assets").expect("cwd");
assert_eq!(
source_path(Path::new("props/crate.bsn"), &assets, &assets),
std::path::absolute("/game/assets/props/crate.bsn").expect("cwd")
);
}
#[test]
fn a_source_spelled_beside_its_own_document_still_opens() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("base")).expect("the base folder");
std::fs::create_dir_all(dir.path().join("zones")).expect("the zone folder");
let base = dir.path().join("base/panel.bsn");
std::fs::write(&base, "#Panel\n").expect("the base file");
assert_eq!(
source_path(
Path::new("../base/panel.bsn"),
dir.path(),
&dir.path().join("zones"),
),
normalize(&base),
"a document written before the assets folder was the namespace still reads",
);
}
#[test]
fn a_source_the_assets_folder_holds_is_read_there_rather_than_beside_the_document() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("props")).expect("the assets folder");
std::fs::create_dir_all(dir.path().join("zones/props")).expect("the zone folder");
std::fs::write(dir.path().join("props/crate.bsn"), "#Crate\n").expect("the asset");
std::fs::write(dir.path().join("zones/props/crate.bsn"), "#Other\n").expect("the twin");
assert_eq!(
source_path(
Path::new("props/crate.bsn"),
dir.path(),
&dir.path().join("zones"),
),
normalize(&dir.path().join("props/crate.bsn")),
"the one namespace wins wherever else a file of that name sits",
);
}
#[test]
fn an_absolute_source_is_taken_as_written() {
let source = std::path::absolute("/elsewhere/crate.bsn").expect("cwd");
assert_eq!(
source_path(
&source,
&std::path::absolute("/game/assets").expect("cwd"),
Path::new(""),
),
source
);
}
#[test]
fn saving_spells_a_source_under_the_assets_folder() {
let source = std::path::absolute("/game/assets/props/crate.bsn")
.expect("cwd")
.to_slash_lossy()
.into_owned();
let mut ast = scene_with_source(&source);
let strays = relativize_isa_sources_under(
&mut ast,
&std::path::absolute("/game/assets").expect("cwd"),
);
assert!(strays.is_empty());
assert_eq!(source_of(&ast), "props/crate.bsn");
}
#[test]
fn a_source_the_assets_folder_does_not_hold_is_reported_rather_than_spelled() {
let source = std::path::absolute("/elsewhere/crate.bsn")
.expect("cwd")
.to_slash_lossy()
.into_owned();
let mut ast = scene_with_source(&source);
let strays = relativize_isa_sources_under(
&mut ast,
&std::path::absolute("/game/assets").expect("cwd"),
);
assert_eq!(strays, vec![source.clone()]);
assert_eq!(source_of(&ast), source, "and it is left as it was written");
}
#[test]
fn a_document_with_no_directory_keeps_the_source_it_had() {
let source = std::path::absolute("/game/assets/props/crate.bsn")
.expect("cwd")
.to_slash_lossy()
.into_owned();
let mut ast = scene_with_source(&source);
relativize_isa_sources(&mut ast, Path::new("."));
assert_eq!(source_of(&ast), source);
}
#[test]
fn a_source_that_is_already_relative_is_left_alone() {
let mut ast = scene_with_source("props/crate.bsn");
relativize_isa_sources_under(&mut ast, &std::path::absolute("/game/assets").expect("cwd"));
assert_eq!(source_of(&ast), "props/crate.bsn");
}
#[test]
fn an_absolute_source_is_folded_so_a_caller_can_compare_it() {
let source = std::path::absolute("/game/assets/../../etc/passwd.bsn")
.expect("cwd")
.to_slash_lossy()
.into_owned();
let mut ast = scene_with_source(&source);
let assets = std::path::absolute("/game/assets").expect("cwd");
absolutize_isa_sources(&mut ast, &assets, &assets);
assert_eq!(
PathBuf::from(source_of(&ast)),
std::path::absolute("/etc/passwd.bsn").expect("cwd")
);
}
#[test]
fn loading_names_the_file_a_relative_source_meant() {
let mut ast = scene_with_source("props/crate.bsn");
let assets = std::path::absolute("/game/assets").expect("cwd");
absolutize_isa_sources(&mut ast, &assets, &assets);
assert_eq!(
PathBuf::from(source_of(&ast)),
std::path::absolute("/game/assets/props/crate.bsn").expect("cwd")
);
}
#[test]
fn a_saved_source_survives_the_round_trip_it_was_written_for() {
let assets = std::path::absolute("/game/assets").expect("cwd");
let source = std::path::absolute("/game/assets/props/crate.bsn")
.expect("cwd")
.to_slash_lossy()
.into_owned();
let mut ast = scene_with_source(&source);
relativize_isa_sources_under(&mut ast, &assets);
absolutize_isa_sources(&mut ast, &assets, &assets);
relativize_isa_sources_under(&mut ast, &assets);
assert_eq!(
source_of(&ast),
"props/crate.bsn",
"a second save writes what the first one did"
);
}
}