Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
451 lines
16 KiB
Rust
451 lines
16 KiB
Rust
//! Explicit, transactional project content upgrade support.
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use serde::Serialize;
|
|
use shared::{
|
|
AuthoringComponentStates, ComponentInstanceId, InspectorOrder, MaterialAsset,
|
|
MaterialInstanceAsset, MaterialRef, RendererMaterialSlot, ShaderSchemaAsset,
|
|
StaticMeshRenderer, COMPONENT_STATIC_MESH_RENDERER,
|
|
};
|
|
|
|
use crate::document::{SceneComponentBlob, SceneDocument};
|
|
|
|
const INSPECTOR_ORDER_COMPONENT: &str = "shared::components::InspectorOrder";
|
|
const AUTHORING_COMPONENT_STATES_COMPONENT: &str = "shared::components::AuthoringComponentStates";
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ProjectUpgradeChange {
|
|
pub path: String,
|
|
pub kind: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ProjectUpgradeReport {
|
|
pub applied: bool,
|
|
pub backup_path: Option<String>,
|
|
pub changes: Vec<ProjectUpgradeChange>,
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
struct PendingWrite {
|
|
path: PathBuf,
|
|
contents: String,
|
|
kind: &'static str,
|
|
}
|
|
|
|
pub fn upgrade_project(root: &Path, apply: bool) -> Result<ProjectUpgradeReport, String> {
|
|
let assets = root.join("assets");
|
|
let mut files = Vec::new();
|
|
collect_files(&assets, &mut files)?;
|
|
files.sort();
|
|
|
|
let mut pending = Vec::new();
|
|
let mut warnings = Vec::new();
|
|
for path in files {
|
|
if path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| name.starts_with('.'))
|
|
{
|
|
continue;
|
|
}
|
|
let relative = path.strip_prefix(root).unwrap_or(&path);
|
|
let normalized = relative.to_string_lossy().replace('\\', "/");
|
|
let Some(original) = fs::read_to_string(&path).ok() else {
|
|
continue;
|
|
};
|
|
let upgraded = if normalized.ends_with(".scn.ron") || normalized.ends_with(".prefab.ron") {
|
|
match canonical_scene_document(&original) {
|
|
Ok(value) => Some((value, "scene-schema")),
|
|
Err(error) => {
|
|
warnings.push(format!("{normalized}: {error}"));
|
|
None
|
|
}
|
|
}
|
|
} else if normalized.contains("/materials/") && normalized.ends_with(".ron") {
|
|
canonical_material_document(&path, &original)
|
|
} else if normalized.contains("/shaders/") && normalized.ends_with(".shader.ron") {
|
|
canonical_shader_document(&original)
|
|
} else {
|
|
None
|
|
};
|
|
let Some((contents, kind)) = upgraded else {
|
|
continue;
|
|
};
|
|
if normalize_text(&contents) != normalize_text(&original) {
|
|
pending.push(PendingWrite {
|
|
path,
|
|
contents,
|
|
kind,
|
|
});
|
|
}
|
|
}
|
|
|
|
let changes = pending
|
|
.iter()
|
|
.map(|write| ProjectUpgradeChange {
|
|
path: write
|
|
.path
|
|
.strip_prefix(root)
|
|
.unwrap_or(&write.path)
|
|
.to_string_lossy()
|
|
.replace('\\', "/"),
|
|
kind: write.kind.to_string(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !apply || pending.is_empty() {
|
|
return Ok(ProjectUpgradeReport {
|
|
applied: false,
|
|
backup_path: None,
|
|
changes,
|
|
warnings,
|
|
});
|
|
}
|
|
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(|error| error.to_string())?
|
|
.as_secs();
|
|
let backup_root = root
|
|
.join(".blacksite")
|
|
.join("backups")
|
|
.join(format!("material-component-v4-{timestamp}"));
|
|
for write in &pending {
|
|
let relative = write
|
|
.path
|
|
.strip_prefix(root)
|
|
.map_err(|error| error.to_string())?;
|
|
let backup = backup_root.join(relative);
|
|
if let Some(parent) = backup.parent() {
|
|
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
|
}
|
|
fs::copy(&write.path, &backup).map_err(|error| {
|
|
format!(
|
|
"could not back up {} to {}: {error}",
|
|
write.path.display(),
|
|
backup.display()
|
|
)
|
|
})?;
|
|
}
|
|
|
|
let mut staged = Vec::new();
|
|
for (index, write) in pending.iter().enumerate() {
|
|
let temporary = write
|
|
.path
|
|
.with_extension(format!("blacksite-upgrade-{index}.tmp"));
|
|
fs::write(&temporary, &write.contents)
|
|
.map_err(|error| format!("could not stage {}: {error}", write.path.display()))?;
|
|
staged.push((temporary, write.path.clone()));
|
|
}
|
|
for (temporary, destination) in &staged {
|
|
if let Err(error) = fs::rename(temporary, destination) {
|
|
for change in &changes {
|
|
let backup = backup_root.join(&change.path);
|
|
let destination = root.join(&change.path);
|
|
let _ = fs::copy(backup, destination);
|
|
}
|
|
return Err(format!(
|
|
"project upgrade failed while replacing {} and was rolled back: {error}",
|
|
destination.display()
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(ProjectUpgradeReport {
|
|
applied: true,
|
|
backup_path: Some(
|
|
backup_root
|
|
.strip_prefix(root)
|
|
.unwrap_or(&backup_root)
|
|
.to_string_lossy()
|
|
.replace('\\', "/"),
|
|
),
|
|
changes,
|
|
warnings,
|
|
})
|
|
}
|
|
|
|
fn canonical_scene_document(text: &str) -> Result<String, String> {
|
|
let mut document = SceneDocument::from_ron_text(text)?;
|
|
for entity in &mut document.entities {
|
|
let present_types = entity
|
|
.components
|
|
.iter()
|
|
.filter(|component| shared::authoring_component_id(&component.type_name).is_some())
|
|
.map(|component| component.type_name.clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
if let Some(component) = entity
|
|
.components
|
|
.iter_mut()
|
|
.find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
|
|
{
|
|
let mut renderer: StaticMeshRenderer =
|
|
ron::from_str(&component.ron).map_err(|error| {
|
|
format!("could not upgrade StaticMeshRenderer component: {error}")
|
|
})?;
|
|
normalize_static_renderer(&mut renderer);
|
|
*component =
|
|
SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?;
|
|
}
|
|
|
|
let states_index = entity
|
|
.components
|
|
.iter()
|
|
.position(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT);
|
|
let mut states = states_index
|
|
.and_then(|index| {
|
|
ron::from_str::<AuthoringComponentStates>(&entity.components[index].ron).ok()
|
|
})
|
|
.unwrap_or_default();
|
|
normalize_component_states(&mut states);
|
|
|
|
let mut migrated_legacy_state = false;
|
|
if let Some(order_index) = entity
|
|
.components
|
|
.iter()
|
|
.position(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
|
|
{
|
|
let mut order: InspectorOrder = ron::from_str(&entity.components[order_index].ron)
|
|
.map_err(|error| format!("could not upgrade InspectorOrder component: {error}"))?;
|
|
for legacy in std::mem::take(&mut order.component_states) {
|
|
let key = if legacy.component_id.trim().is_empty() {
|
|
legacy.type_name
|
|
} else {
|
|
legacy.component_id
|
|
};
|
|
if !key.trim().is_empty() {
|
|
states.set_component_active(key, legacy.active);
|
|
migrated_legacy_state = true;
|
|
}
|
|
}
|
|
let present = present_types.iter().map(String::as_str).collect::<Vec<_>>();
|
|
order.ensure_component_order(&present);
|
|
entity.components[order_index] =
|
|
SceneComponentBlob::from_serializable(INSPECTOR_ORDER_COMPONENT, &order)?;
|
|
}
|
|
if states_index.is_some() || migrated_legacy_state {
|
|
let component = SceneComponentBlob::from_serializable(
|
|
AUTHORING_COMPONENT_STATES_COMPONENT,
|
|
&states,
|
|
)?;
|
|
if let Some(index) = states_index {
|
|
entity.components[index] = component;
|
|
} else {
|
|
entity.components.push(component);
|
|
}
|
|
}
|
|
}
|
|
document.to_ron_text()
|
|
}
|
|
|
|
fn normalize_static_renderer(renderer: &mut StaticMeshRenderer) {
|
|
for (index, part) in renderer.slots.iter_mut().enumerate() {
|
|
if part.id.is_empty() {
|
|
let id = if part.mesh.sub_asset_id.trim().is_empty() {
|
|
format!("draw:legacy:{index}")
|
|
} else {
|
|
part.mesh.sub_asset_id.clone()
|
|
};
|
|
part.id = ComponentInstanceId::new(id);
|
|
}
|
|
if part.material_slot_id.is_empty() {
|
|
part.material_slot_id = ComponentInstanceId::new(format!("slot:{}", part.id.0));
|
|
}
|
|
let legacy_material = part.material.take().map(MaterialRef::new);
|
|
if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) {
|
|
if slot.source_material.is_none() {
|
|
slot.source_material = legacy_material;
|
|
}
|
|
if slot.name.trim().is_empty() {
|
|
slot.name = part.name.clone();
|
|
}
|
|
} else {
|
|
renderer.materials.slots.push(RendererMaterialSlot {
|
|
id: part.material_slot_id.clone(),
|
|
name: part.name.clone(),
|
|
source_material: legacy_material,
|
|
material: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_component_states(states: &mut AuthoringComponentStates) {
|
|
let old = std::mem::take(&mut states.states);
|
|
for state in old {
|
|
let key = if state.component_id.trim().is_empty() {
|
|
state.type_name
|
|
} else {
|
|
state.component_id
|
|
};
|
|
if !key.trim().is_empty() {
|
|
states.set_component_active(key, state.active);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn canonical_material_document(path: &Path, text: &str) -> Option<(String, &'static str)> {
|
|
if let Ok(asset) = ron::from_str::<MaterialAsset>(text) {
|
|
return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
|
|
.ok()
|
|
.map(|text| (text, "material-schema"));
|
|
}
|
|
if let Ok(instance) = ron::from_str::<MaterialInstanceAsset>(text) {
|
|
return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
|
|
.ok()
|
|
.map(|text| (text, "material-instance-schema"));
|
|
}
|
|
let _ = path;
|
|
None
|
|
}
|
|
|
|
fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> {
|
|
let schema = ron::from_str::<ShaderSchemaAsset>(text).ok()?;
|
|
ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default())
|
|
.ok()
|
|
.map(|text| (text, "surface-shader-schema"))
|
|
}
|
|
|
|
fn collect_files(path: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
|
let entry = entry.map_err(|error| error.to_string())?;
|
|
let path = entry.path();
|
|
if entry
|
|
.file_type()
|
|
.map_err(|error| error.to_string())?
|
|
.is_dir()
|
|
{
|
|
collect_files(&path, output)?;
|
|
} else {
|
|
output.push(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_text(text: &str) -> String {
|
|
text.trim().replace("\r\n", "\n")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use shared::{
|
|
EditorAssetRef, InspectorComponentState, MeshRenderSlot,
|
|
AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
|
|
};
|
|
|
|
#[test]
|
|
fn dry_run_does_not_write_and_apply_creates_backup() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"blacksite-upgrader-{}",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let level = root.join("assets/levels/main.scn.ron");
|
|
fs::create_dir_all(level.parent().unwrap()).unwrap();
|
|
fs::write(&level, "(schema_version: 3, resources: {}, entities: {})").unwrap();
|
|
|
|
let dry_run = upgrade_project(&root, false).unwrap();
|
|
assert!(!dry_run.applied);
|
|
assert_eq!(dry_run.changes.len(), 1);
|
|
assert!(fs::read_to_string(&level)
|
|
.unwrap()
|
|
.contains("schema_version: 3"));
|
|
|
|
let applied = upgrade_project(&root, true).unwrap();
|
|
assert!(applied.applied);
|
|
assert!(fs::read_to_string(&level)
|
|
.unwrap()
|
|
.contains("schema_version: 4"));
|
|
assert!(root.join(applied.backup_path.unwrap()).exists());
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_upgrade_materializes_slots_and_separates_active_state() {
|
|
let renderer = StaticMeshRenderer {
|
|
slots: vec![MeshRenderSlot {
|
|
id: ComponentInstanceId::new("draw:body"),
|
|
name: "Body".into(),
|
|
mesh: EditorAssetRef::new("model", "mesh:body", "Body"),
|
|
material: Some(EditorAssetRef::new(
|
|
"model",
|
|
"material:body",
|
|
"Body Material",
|
|
)),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let order = InspectorOrder {
|
|
component_type_names: vec![COMPONENT_STATIC_MESH_RENDERER.into()],
|
|
component_states: vec![InspectorComponentState {
|
|
type_name: COMPONENT_STATIC_MESH_RENDERER.into(),
|
|
active: false,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let text = format!(
|
|
"(schema_version: 3, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_STATIC_MESH_RENDERER}\": {}, \"{INSPECTOR_ORDER_COMPONENT}\": {}}})}})",
|
|
ron::to_string(&renderer).unwrap(),
|
|
ron::to_string(&order).unwrap(),
|
|
);
|
|
|
|
let upgraded = canonical_scene_document(&text).unwrap();
|
|
let document = SceneDocument::from_ron_text(&upgraded).unwrap();
|
|
let components = &document.entities[0].components;
|
|
let renderer: StaticMeshRenderer = ron::from_str(
|
|
&components
|
|
.iter()
|
|
.find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
|
|
.unwrap()
|
|
.ron,
|
|
)
|
|
.unwrap();
|
|
assert!(renderer.slots[0].material.is_none());
|
|
assert_eq!(renderer.materials.slots.len(), 1);
|
|
assert_eq!(
|
|
renderer.materials.slots[0]
|
|
.source_material
|
|
.as_ref()
|
|
.unwrap()
|
|
.0
|
|
.sub_asset_id,
|
|
"material:body"
|
|
);
|
|
let order: InspectorOrder = ron::from_str(
|
|
&components
|
|
.iter()
|
|
.find(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
|
|
.unwrap()
|
|
.ron,
|
|
)
|
|
.unwrap();
|
|
assert!(order.component_states.is_empty());
|
|
assert_eq!(
|
|
order.component_ids,
|
|
vec![AUTHORING_COMPONENT_STATIC_MESH_RENDERER]
|
|
);
|
|
let states: AuthoringComponentStates = ron::from_str(
|
|
&components
|
|
.iter()
|
|
.find(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT)
|
|
.unwrap()
|
|
.ron,
|
|
)
|
|
.unwrap();
|
|
assert!(!states.is_component_active(COMPONENT_STATIC_MESH_RENDERER));
|
|
}
|
|
}
|