329 lines
12 KiB
Rust
329 lines
12 KiB
Rust
//! Deterministically bake navigation artifacts from authored scene components.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use scene::navigation::{
|
|
bake_navigation, navigation_artifact_content_hash, resolve_navigation_bake_jobs,
|
|
write_navigation_artifact, NavigationBakeArtifact, NavigationBakeJob,
|
|
};
|
|
use walkdir::WalkDir;
|
|
|
|
fn main() {
|
|
if let Err(error) = run() {
|
|
eprintln!("bake-navigation: {error}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn run() -> Result<(), String> {
|
|
let mut project_root = PathBuf::from(".");
|
|
let mut requested_scene = None;
|
|
let mut check = false;
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(argument) = args.next() {
|
|
match argument.as_str() {
|
|
"--project" => {
|
|
project_root = PathBuf::from(
|
|
args.next()
|
|
.ok_or_else(|| "--project requires a path".to_string())?,
|
|
);
|
|
}
|
|
"--scene" => {
|
|
requested_scene =
|
|
Some(PathBuf::from(args.next().ok_or_else(|| {
|
|
"--scene requires a project-relative path".to_string()
|
|
})?));
|
|
}
|
|
"--check" => check = true,
|
|
_ => return Err(format!("unknown argument `{argument}`")),
|
|
}
|
|
}
|
|
|
|
bake_project(project_root, requested_scene, check)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct BakeRequest {
|
|
source_path: PathBuf,
|
|
output: PathBuf,
|
|
job: NavigationBakeJob,
|
|
}
|
|
|
|
fn bake_project(
|
|
project_root: PathBuf,
|
|
requested_scene: Option<PathBuf>,
|
|
check: bool,
|
|
) -> Result<(), String> {
|
|
let project_root = project_root
|
|
.canonicalize()
|
|
.map_err(|error| format!("could not resolve project root: {error}"))?;
|
|
let scenes = match requested_scene {
|
|
Some(scene) => vec![safe_project_path(&project_root, &scene)?],
|
|
None => discover_scenes(&project_root)?,
|
|
};
|
|
|
|
let mut requests = Vec::new();
|
|
for scene_path in scenes {
|
|
let jobs = resolve_navigation_bake_jobs(&project_root, &scene_path, None)
|
|
.map_err(|error| format!("{}: {error}", scene_path.display()))?;
|
|
for job in jobs {
|
|
let output = safe_artifact_path(&project_root, &job.artifact_path)?;
|
|
requests.push(BakeRequest {
|
|
source_path: scene_path.clone(),
|
|
output,
|
|
job,
|
|
});
|
|
}
|
|
}
|
|
|
|
reject_duplicate_artifact_ownership(&requests)?;
|
|
let artifact_count = requests.len();
|
|
for request in requests {
|
|
if check {
|
|
let fresh = bake_navigation(&request.job.input)
|
|
.map_err(|error| owner_error(&request, &format!("rebake failed: {error}")))?;
|
|
let existing = scene::navigation::read_navigation_artifact(&request.output)
|
|
.map_err(|error| owner_error(&request, &error))?;
|
|
verify_checked_artifact(&request, &existing, &fresh)?;
|
|
println!("current {}", request.output.display());
|
|
} else {
|
|
let artifact = bake_navigation(&request.job.input)
|
|
.map_err(|error| owner_error(&request, &format!("bake failed: {error}")))?;
|
|
write_navigation_artifact(&request.output, &artifact)
|
|
.map_err(|error| owner_error(&request, &error))?;
|
|
println!("baked {}", request.output.display());
|
|
}
|
|
}
|
|
println!("bake-navigation: {artifact_count} artifacts");
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_checked_artifact(
|
|
request: &BakeRequest,
|
|
existing: &NavigationBakeArtifact,
|
|
fresh: &NavigationBakeArtifact,
|
|
) -> Result<(), String> {
|
|
if existing.is_stale_for(&request.job.input) {
|
|
return Err(owner_error(
|
|
request,
|
|
"stored artifact fingerprint is stale; rerun `cargo bake-navigation --project .`",
|
|
));
|
|
}
|
|
let existing_hash =
|
|
navigation_artifact_content_hash(existing).map_err(|error| owner_error(request, &error))?;
|
|
let fresh_hash =
|
|
navigation_artifact_content_hash(fresh).map_err(|error| owner_error(request, &error))?;
|
|
if existing_hash != fresh_hash {
|
|
return Err(owner_error(
|
|
request,
|
|
&format!(
|
|
"stored artifact content differs from a deterministic fresh bake (stored {existing_hash}, fresh {fresh_hash}); rerun `cargo bake-navigation --project .`"
|
|
),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn owner_error(request: &BakeRequest, message: &str) -> String {
|
|
format!(
|
|
"{}: Navigation Bounds actor `{}` in {}: {message}",
|
|
request.output.display(),
|
|
request.job.input.bounds_actor_id,
|
|
request.source_path.display()
|
|
)
|
|
}
|
|
|
|
fn reject_duplicate_artifact_ownership(requests: &[BakeRequest]) -> Result<(), String> {
|
|
let mut owners = BTreeMap::<&Path, Vec<&BakeRequest>>::new();
|
|
for request in requests {
|
|
owners.entry(&request.output).or_default().push(request);
|
|
}
|
|
let Some((output, duplicate_owners)) = owners.into_iter().find(|(_, owners)| owners.len() > 1)
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
let owners = duplicate_owners
|
|
.iter()
|
|
.map(|request| {
|
|
format!(
|
|
"actor `{}` in {}",
|
|
request.job.input.bounds_actor_id,
|
|
request.source_path.display()
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
Err(format!(
|
|
"navigation artifact `{}` has duplicate owners: {owners}. Give each Navigation Bounds actor a unique artifact path under {} and rerun the bake",
|
|
output.display(),
|
|
shared::NAVIGATION_GENERATED_ARTIFACT_DIRECTORY
|
|
))
|
|
}
|
|
|
|
fn discover_scenes(project_root: &Path) -> Result<Vec<PathBuf>, String> {
|
|
let levels = project_root.join("assets/levels");
|
|
if !levels.is_dir() {
|
|
return Err(format!("missing levels directory {}", levels.display()));
|
|
}
|
|
let mut scenes: Vec<PathBuf> = WalkDir::new(&levels)
|
|
.follow_links(false)
|
|
.into_iter()
|
|
.filter_map(Result::ok)
|
|
.filter(|entry| entry.file_type().is_file())
|
|
.map(|entry| entry.into_path())
|
|
.filter(|path| {
|
|
path.to_string_lossy().ends_with(".scn.ron")
|
|
&& path
|
|
.strip_prefix(project_root)
|
|
.is_ok_and(scene::is_runtime_package_asset)
|
|
})
|
|
.collect();
|
|
scenes.sort();
|
|
Ok(scenes)
|
|
}
|
|
|
|
fn safe_artifact_path(project_root: &Path, authored: &str) -> Result<PathBuf, String> {
|
|
let normalized = shared::navigation_generated_artifact_path(authored)
|
|
.map_err(|error| format!("navigation artifact `{authored}` is invalid: {error}"))?;
|
|
safe_project_path(project_root, Path::new(&normalized))
|
|
}
|
|
|
|
fn safe_project_path(project_root: &Path, relative: &Path) -> Result<PathBuf, String> {
|
|
if relative.is_absolute()
|
|
|| relative
|
|
.components()
|
|
.any(|component| matches!(component, std::path::Component::ParentDir))
|
|
{
|
|
return Err(format!(
|
|
"project-relative path `{}` is unsafe",
|
|
relative.display()
|
|
));
|
|
}
|
|
Ok(project_root.join(relative))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);
|
|
|
|
fn fixture_root() -> PathBuf {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"blacksite-navigation-bake-{}-{}",
|
|
std::process::id(),
|
|
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
std::fs::create_dir_all(root.join("assets/levels")).unwrap();
|
|
root
|
|
}
|
|
|
|
fn navigation_scene(actor_id: &str, artifact_path: &str) -> String {
|
|
let mut bounds = shared::NavigationBounds::for_actor(actor_id);
|
|
bounds.artifact_path = artifact_path.to_string();
|
|
let bounds = ron::to_string(&bounds).unwrap();
|
|
format!(
|
|
"(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":({actor_id:?}),\"shared::navigation::NavigationBounds\":{bounds},}})}})"
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_ownership_is_rejected_before_any_artifact_write() {
|
|
let root = fixture_root();
|
|
let artifact_path = "assets/navigation/generated/shared.nav.ron";
|
|
std::fs::write(
|
|
root.join("assets/levels/first.scn.ron"),
|
|
navigation_scene("first-bounds", artifact_path),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
root.join("assets/levels/second.scn.ron"),
|
|
navigation_scene("second-bounds", artifact_path),
|
|
)
|
|
.unwrap();
|
|
|
|
let error = bake_project(root.clone(), None, false).unwrap_err();
|
|
assert!(error.contains("duplicate owners"), "{error}");
|
|
assert!(error.contains("first-bounds"), "{error}");
|
|
assert!(error.contains("second-bounds"), "{error}");
|
|
assert!(!root.join(artifact_path).exists());
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_output_must_use_generated_navigation_directory() {
|
|
let root = Path::new("/project");
|
|
assert!(safe_artifact_path(root, "assets/navigation/manual.nav.ron").is_err());
|
|
assert_eq!(
|
|
safe_artifact_path(root, "assets\\navigation\\generated\\bounds.nav.ron").unwrap(),
|
|
root.join("assets/navigation/generated/bounds.nav.ron")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scene_discovery_ignores_transient_hidden_snapshots() {
|
|
let root = fixture_root();
|
|
std::fs::write(
|
|
root.join("assets/levels/main.scn.ron"),
|
|
"(schema_version:4,resources:{},entities:{})",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
root.join("assets/levels/.pie_session.scn.ron"),
|
|
"(resources:{},entities:{})",
|
|
)
|
|
.unwrap();
|
|
let scenes = discover_scenes(&root).unwrap();
|
|
assert_eq!(scenes, [root.join("assets/levels/main.scn.ron")]);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn check_reports_owner_when_content_differs_despite_current_fingerprint() {
|
|
let input = scene::navigation::NavigationBakeInput {
|
|
source_scene: "assets/levels/main.scn.ron".into(),
|
|
bounds_actor_id: "main-bounds".into(),
|
|
center: [0.0, 0.0, 0.0],
|
|
half_extents: [5.0, 2.0, 5.0],
|
|
agent: shared::NavigationAgentProfile {
|
|
min_region_size: 1,
|
|
merge_region_size: 2,
|
|
..Default::default()
|
|
},
|
|
geometry: vec![scene::navigation::NavigationGeometryInput {
|
|
actor_id: "floor".into(),
|
|
triangles: vec![
|
|
[[-5.0, 0.0, -5.0], [5.0, 0.0, 5.0], [5.0, 0.0, -5.0]],
|
|
[[-5.0, 0.0, -5.0], [-5.0, 0.0, 5.0], [5.0, 0.0, 5.0]],
|
|
],
|
|
}],
|
|
obstacles: Vec::new(),
|
|
areas: Vec::new(),
|
|
links: Vec::new(),
|
|
samples: Vec::new(),
|
|
};
|
|
let fresh = bake_navigation(&input).unwrap();
|
|
let mut stored = fresh.clone();
|
|
stored.bounds_actor_id = "unexpected-owner".into();
|
|
let request = BakeRequest {
|
|
source_path: PathBuf::from("/project/assets/levels/main.scn.ron"),
|
|
output: PathBuf::from("/project/assets/navigation/generated/main-bounds.nav.ron"),
|
|
job: NavigationBakeJob {
|
|
artifact_path: "assets/navigation/generated/main-bounds.nav.ron".into(),
|
|
input,
|
|
},
|
|
};
|
|
|
|
let error = verify_checked_artifact(&request, &stored, &fresh).unwrap_err();
|
|
assert!(error.contains("main-bounds"), "{error}");
|
|
assert!(error.contains("assets/levels/main.scn.ron"), "{error}");
|
|
assert!(
|
|
error.contains("differs from a deterministic fresh bake"),
|
|
"{error}"
|
|
);
|
|
}
|
|
}
|