3213 lines
111 KiB
Rust
3213 lines
111 KiB
Rust
use std::collections::{HashSet, VecDeque};
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
use bevy::ecs::entity::EntityHashMap;
|
|
use bevy::ecs::system::SystemState;
|
|
use bevy::prelude::*;
|
|
use bevy::window::PrimaryWindow;
|
|
use bevy::world_serialization::serde::WorldDeserializer;
|
|
use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder, WorldFilter, WorldInstance};
|
|
use scene::{document::SceneDocument, strip_schema_version, validate_level_text};
|
|
use serde::de::DeserializeSeed;
|
|
use shared::{
|
|
infer_actor_kind, validate_actor, ActorId, ActorKind, ActorName, ActorValidationError,
|
|
AuthoringComponentStates, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember,
|
|
HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc, PrefabHydrationBlocked,
|
|
PrefabInstance, PrefabRef, SceneComposition,
|
|
};
|
|
|
|
#[cfg(test)]
|
|
use shared::{
|
|
AnimationControllerDesc, AudioSourceDesc, BrushDesc, ColliderDesc, SkinnedMeshRenderer,
|
|
};
|
|
|
|
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
|
|
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
|
|
use crate::native_dialog::NativeDialogBroker;
|
|
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
|
|
use crate::scene::recovery::{
|
|
default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
|
|
write_recovery_snapshot,
|
|
};
|
|
use crate::selection::SelectedEntity;
|
|
use crate::ui::hierarchy_ops::{
|
|
backfill_missing_editor_visibility, backfill_missing_sibling_indices,
|
|
};
|
|
use crate::ui::UiState;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SceneIoRequest {
|
|
New,
|
|
Open,
|
|
Save,
|
|
SaveAs,
|
|
ImportAssets,
|
|
ExportSelection,
|
|
SaveSelectionAsPrefab,
|
|
SwitchProject,
|
|
RestoreRecovery,
|
|
SaveRecoveryCopyAs,
|
|
DiscardRecovery,
|
|
OpenRecent(usize),
|
|
OpenPath(PathBuf),
|
|
SwitchTab(usize),
|
|
CloseTab(usize),
|
|
ReloadComposition,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SceneTab {
|
|
pub id: u64,
|
|
pub path: Option<PathBuf>,
|
|
pub dirty: bool,
|
|
snapshot: String,
|
|
recovery_snapshot: Option<PathBuf>,
|
|
disk_snapshot: Option<FileSnapshot>,
|
|
}
|
|
|
|
impl SceneTab {
|
|
pub fn label(&self) -> String {
|
|
self.path
|
|
.as_deref()
|
|
.and_then(Path::file_name)
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("Untitled")
|
|
.to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Resource, Debug)]
|
|
pub struct SceneIo {
|
|
pub active_path: Option<PathBuf>,
|
|
pub recent_paths: Vec<PathBuf>,
|
|
pub request: Option<SceneIoRequest>,
|
|
pub status: String,
|
|
pub dirty: bool,
|
|
/// Newest recovery snapshot that is newer than the active authored scene.
|
|
pub recovery_snapshot: Option<PathBuf>,
|
|
/// Bounded in-session audit trail for scene persistence and recovery operations.
|
|
pub events: VecDeque<SceneIoEvent>,
|
|
pub tabs: Vec<SceneTab>,
|
|
pub active_tab: usize,
|
|
change_revision: u64,
|
|
next_event_id: u64,
|
|
next_tab_id: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SceneIoEventSeverity {
|
|
Info,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SceneIoEvent {
|
|
pub id: u64,
|
|
pub severity: SceneIoEventSeverity,
|
|
pub message: String,
|
|
}
|
|
|
|
const MAX_SCENE_IO_EVENTS: usize = 32;
|
|
|
|
impl Default for SceneIo {
|
|
fn default() -> Self {
|
|
let active_path = None;
|
|
Self {
|
|
active_path: active_path.clone(),
|
|
recent_paths: Vec::new(),
|
|
request: None,
|
|
status: "Ready".to_string(),
|
|
dirty: false,
|
|
recovery_snapshot: None,
|
|
events: VecDeque::new(),
|
|
tabs: vec![SceneTab {
|
|
id: 1,
|
|
path: active_path,
|
|
dirty: false,
|
|
snapshot: String::new(),
|
|
recovery_snapshot: None,
|
|
disk_snapshot: None,
|
|
}],
|
|
active_tab: 0,
|
|
change_revision: 0,
|
|
next_event_id: 1,
|
|
next_tab_id: 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SceneIo {
|
|
pub fn mark_dirty(&mut self) {
|
|
self.dirty = true;
|
|
self.change_revision = self.change_revision.wrapping_add(1);
|
|
self.sync_active_tab_metadata();
|
|
}
|
|
|
|
pub fn mark_clean(&mut self) {
|
|
self.dirty = false;
|
|
self.change_revision = self.change_revision.wrapping_add(1);
|
|
self.sync_active_tab_metadata();
|
|
}
|
|
|
|
pub fn change_revision(&self) -> u64 {
|
|
self.change_revision
|
|
}
|
|
|
|
pub fn active_path_label(&self) -> String {
|
|
self.active_path
|
|
.as_ref()
|
|
.map(|path| path.display().to_string())
|
|
.unwrap_or_else(|| "Unsaved scene".to_string())
|
|
}
|
|
|
|
pub fn set_status(&mut self, status: impl Into<String>) {
|
|
let message = status.into();
|
|
let normalized = message.to_ascii_lowercase();
|
|
let severity = if normalized.contains("failed")
|
|
|| normalized.contains("error")
|
|
|| normalized.contains("could not")
|
|
{
|
|
SceneIoEventSeverity::Error
|
|
} else {
|
|
SceneIoEventSeverity::Info
|
|
};
|
|
self.status.clone_from(&message);
|
|
self.events.push_back(SceneIoEvent {
|
|
id: self.next_event_id,
|
|
severity,
|
|
message,
|
|
});
|
|
self.next_event_id = self.next_event_id.saturating_add(1);
|
|
while self.events.len() > MAX_SCENE_IO_EVENTS {
|
|
self.events.pop_front();
|
|
}
|
|
}
|
|
|
|
pub fn clear_events(&mut self) {
|
|
self.events.clear();
|
|
}
|
|
|
|
pub fn has_unsaved_tabs(&self) -> bool {
|
|
self.dirty || self.tabs.iter().any(|tab| tab.dirty)
|
|
}
|
|
|
|
fn sync_active_tab_metadata(&mut self) {
|
|
if let Some(tab) = self.tabs.get_mut(self.active_tab) {
|
|
tab.path.clone_from(&self.active_path);
|
|
tab.dirty = self.dirty;
|
|
tab.recovery_snapshot.clone_from(&self.recovery_snapshot);
|
|
}
|
|
}
|
|
|
|
fn allocate_tab_id(&mut self) -> u64 {
|
|
let id = self.next_tab_id;
|
|
self.next_tab_id = self.next_tab_id.saturating_add(1);
|
|
id
|
|
}
|
|
}
|
|
|
|
#[derive(Component)]
|
|
struct LoadedSceneRoot;
|
|
|
|
/// Runtime-only ownership marker for entities hydrated from composed subscenes.
|
|
#[derive(Component, Debug, Clone)]
|
|
pub struct ComposedSceneMember {
|
|
pub reference_id: String,
|
|
pub scene_id: String,
|
|
pub source_path: PathBuf,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
struct RecoveryClock {
|
|
elapsed_secs: f32,
|
|
}
|
|
|
|
pub struct SceneIoPlugin;
|
|
|
|
impl Plugin for SceneIoPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<SceneIo>()
|
|
.init_resource::<SceneComposition>()
|
|
.init_resource::<RecoveryClock>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
process_scene_io_requests,
|
|
tick_scene_recovery,
|
|
update_window_title,
|
|
)
|
|
.chain(),
|
|
)
|
|
.add_systems(PostStartup, load_startup_scene);
|
|
}
|
|
}
|
|
|
|
fn load_startup_scene(world: &mut World) {
|
|
let active_path = world.resource::<SceneIo>().active_path.clone();
|
|
let default_path = PathBuf::from(
|
|
world
|
|
.resource::<settings::ProjectSettings>()
|
|
.default_level
|
|
.clone(),
|
|
);
|
|
let path = active_path.unwrap_or(default_path);
|
|
|
|
match load_level(world, &path) {
|
|
Ok(disk_snapshot) => {
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.active_path = Some(path.clone());
|
|
let active_tab = io.active_tab;
|
|
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
|
|
io.mark_clean();
|
|
}
|
|
remember_path(world, path.clone());
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
refresh_recovery_notice(world, &path);
|
|
if let Err(error) = capture_active_tab(world) {
|
|
warn!("Failed to capture startup scene tab: {error}");
|
|
}
|
|
let recovery = world.resource::<SceneIo>().recovery_snapshot.clone();
|
|
let status = recovery.map_or_else(
|
|
|| format!("Loaded {}", path.display()),
|
|
|snapshot| {
|
|
format!(
|
|
"Loaded {}; newer recovery available: {}",
|
|
path.display(),
|
|
snapshot.display()
|
|
)
|
|
},
|
|
);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
}
|
|
Err(err) => {
|
|
world.resource_mut::<SceneIo>().set_status(format!(
|
|
"Startup scene load failed: {err}; using generated starter arena"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_scene_io_requests(world: &mut World) {
|
|
let Some(request) = world.resource_mut::<SceneIo>().request.take() else {
|
|
return;
|
|
};
|
|
|
|
if matches!(request, SceneIoRequest::SwitchProject)
|
|
&& world.resource::<SceneIo>().has_unsaved_tabs()
|
|
{
|
|
request_switch_project_confirmation(world);
|
|
return;
|
|
}
|
|
|
|
let status = match request {
|
|
SceneIoRequest::New => new_scene_tab(world),
|
|
SceneIoRequest::Save => save_active_or_prompt(world),
|
|
SceneIoRequest::SaveAs => save_with_dialog(world),
|
|
SceneIoRequest::Open => open_with_dialog(world),
|
|
SceneIoRequest::ImportAssets => import_with_dialog(world),
|
|
SceneIoRequest::ExportSelection => export_selection_with_dialog(world),
|
|
SceneIoRequest::SaveSelectionAsPrefab => save_selection_as_prefab(world),
|
|
SceneIoRequest::SwitchProject => switch_project(world),
|
|
SceneIoRequest::RestoreRecovery => restore_recovery(world),
|
|
SceneIoRequest::SaveRecoveryCopyAs => save_recovery_copy_with_dialog(world),
|
|
SceneIoRequest::DiscardRecovery => discard_recovery(world),
|
|
SceneIoRequest::OpenRecent(index) => open_recent(world, index),
|
|
SceneIoRequest::OpenPath(path) => open_path(world, path),
|
|
SceneIoRequest::SwitchTab(index) => switch_scene_tab(world, index),
|
|
SceneIoRequest::CloseTab(index) => close_scene_tab(world, index),
|
|
SceneIoRequest::ReloadComposition => reload_active_composition(world),
|
|
};
|
|
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
}
|
|
|
|
fn switch_project(world: &mut World) -> String {
|
|
match crate::launcher::spawn_project_launcher_process() {
|
|
Ok(()) => {
|
|
world.write_message(AppExit::Success);
|
|
"Opening Blacksite Project Browser".to_string()
|
|
}
|
|
Err(error) => format!("Switch project failed: {error}"),
|
|
}
|
|
}
|
|
|
|
fn request_switch_project_confirmation(world: &mut World) {
|
|
let result = world.resource::<NativeDialogBroker>().request(
|
|
|| {
|
|
rfd::MessageDialog::new()
|
|
.set_title("Unsaved Changes")
|
|
.set_description("The scene has unsaved changes. Save before continuing?")
|
|
.set_level(rfd::MessageLevel::Warning)
|
|
.set_buttons(rfd::MessageButtons::YesNoCancel)
|
|
.show()
|
|
},
|
|
|world, decision| match decision {
|
|
rfd::MessageDialogResult::Yes => {
|
|
let status = save_all_tabs(world);
|
|
if status == "Saved all modified scene tabs" {
|
|
let status = switch_project(world);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
} else {
|
|
world.resource_mut::<SceneIo>().set_status(format!(
|
|
"Project switch paused until every scene is saved: {status}"
|
|
));
|
|
}
|
|
}
|
|
rfd::MessageDialogResult::No => {
|
|
let status = switch_project(world);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
}
|
|
_ => world
|
|
.resource_mut::<SceneIo>()
|
|
.set_status("Project switch cancelled"),
|
|
},
|
|
);
|
|
world.resource_mut::<SceneIo>().set_status(match result {
|
|
Ok(()) => "Waiting for unsaved-scene confirmation".into(),
|
|
Err(error) => format!("Project switch unavailable: {error}"),
|
|
});
|
|
}
|
|
|
|
fn open_recent(world: &mut World, index: usize) -> String {
|
|
let path = world.resource::<SceneIo>().recent_paths.get(index).cloned();
|
|
let Some(path) = path else {
|
|
return "Recent scene not found".to_string();
|
|
};
|
|
open_path(world, path)
|
|
}
|
|
|
|
fn open_path(world: &mut World, path: PathBuf) -> String {
|
|
if let Some(index) = world
|
|
.resource::<SceneIo>()
|
|
.tabs
|
|
.iter()
|
|
.position(|tab| tab.path.as_ref() == Some(&path))
|
|
{
|
|
return switch_scene_tab(world, index);
|
|
}
|
|
if let Err(error) = capture_active_tab(world) {
|
|
return format!("Open failed: could not preserve active scene: {error}");
|
|
}
|
|
match load_level(world, &path) {
|
|
Ok(disk_snapshot) => {
|
|
let id = world.resource_mut::<SceneIo>().allocate_tab_id();
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.tabs.push(SceneTab {
|
|
id,
|
|
path: Some(path.clone()),
|
|
dirty: false,
|
|
snapshot: String::new(),
|
|
recovery_snapshot: None,
|
|
disk_snapshot: Some(disk_snapshot),
|
|
});
|
|
io.active_tab = io.tabs.len() - 1;
|
|
io.active_path = Some(path.clone());
|
|
io.recovery_snapshot = None;
|
|
io.mark_clean();
|
|
}
|
|
remember_path(world, path.clone());
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
refresh_recovery_notice(world, &path);
|
|
if let Err(error) = capture_active_tab(world) {
|
|
warn!("Failed to capture opened scene tab: {error}");
|
|
}
|
|
format!("Loading {}", path.display())
|
|
}
|
|
Err(err) => format!("Open failed: {err}"),
|
|
}
|
|
}
|
|
|
|
fn new_scene_tab(world: &mut World) -> String {
|
|
if let Err(error) = capture_active_tab(world) {
|
|
return format!("New scene failed: could not preserve active scene: {error}");
|
|
}
|
|
clear_scene_world(world);
|
|
world.insert_resource(SceneComposition {
|
|
scene_id: uuid::Uuid::new_v4().to_string(),
|
|
subscenes: Vec::new(),
|
|
});
|
|
let id = world.resource_mut::<SceneIo>().allocate_tab_id();
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.tabs.push(SceneTab {
|
|
id,
|
|
path: None,
|
|
dirty: false,
|
|
snapshot: String::new(),
|
|
recovery_snapshot: None,
|
|
disk_snapshot: None,
|
|
});
|
|
io.active_tab = io.tabs.len() - 1;
|
|
io.active_path = None;
|
|
io.recovery_snapshot = None;
|
|
io.mark_clean();
|
|
}
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
if let Err(error) = capture_active_tab(world) {
|
|
return format!("New scene failed: {error}");
|
|
}
|
|
"Created a new empty scene tab".to_string()
|
|
}
|
|
|
|
fn capture_active_tab(world: &mut World) -> Result<(), String> {
|
|
if world.resource::<SceneIo>().tabs.is_empty() {
|
|
return Ok(());
|
|
}
|
|
ensure_scene_composition_identity(world);
|
|
let text = serialize_active_scene(world)?;
|
|
let (active_tab, active_path, dirty, recovery_snapshot) = {
|
|
let io = world.resource::<SceneIo>();
|
|
(
|
|
io.active_tab,
|
|
io.active_path.clone(),
|
|
io.dirty,
|
|
io.recovery_snapshot.clone(),
|
|
)
|
|
};
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
let Some(tab) = io.tabs.get_mut(active_tab) else {
|
|
return Err(format!("active scene tab {active_tab} is missing"));
|
|
};
|
|
tab.path = active_path;
|
|
tab.dirty = dirty;
|
|
tab.snapshot = text;
|
|
tab.recovery_snapshot = recovery_snapshot;
|
|
Ok(())
|
|
}
|
|
|
|
fn switch_scene_tab(world: &mut World, index: usize) -> String {
|
|
let current = world.resource::<SceneIo>().active_tab;
|
|
if index == current {
|
|
return "Scene tab is already active".to_string();
|
|
}
|
|
if index >= world.resource::<SceneIo>().tabs.len() {
|
|
return "Scene tab not found".to_string();
|
|
}
|
|
if let Err(error) = capture_active_tab(world) {
|
|
return format!("Scene switch failed: could not preserve active scene: {error}");
|
|
}
|
|
let target = world.resource::<SceneIo>().tabs[index].clone();
|
|
if target.snapshot.is_empty() {
|
|
return "Scene switch failed: target tab has no recoverable snapshot".to_string();
|
|
}
|
|
if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) {
|
|
return format!("Scene switch failed: {error}");
|
|
}
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.active_tab = index;
|
|
io.active_path = target.path.clone();
|
|
io.dirty = target.dirty;
|
|
io.recovery_snapshot = target.recovery_snapshot.clone();
|
|
io.sync_active_tab_metadata();
|
|
}
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
format!("Activated scene tab {}", target.label())
|
|
}
|
|
|
|
fn close_scene_tab(world: &mut World, index: usize) -> String {
|
|
if index >= world.resource::<SceneIo>().tabs.len() {
|
|
return "Scene tab not found".to_string();
|
|
}
|
|
if index != world.resource::<SceneIo>().active_tab {
|
|
let status = switch_scene_tab(world, index);
|
|
if status.starts_with("Scene switch failed") {
|
|
return status;
|
|
}
|
|
}
|
|
|
|
if world.resource::<SceneIo>().dirty {
|
|
request_close_scene_confirmation(world);
|
|
return "Waiting for close-scene confirmation".to_string();
|
|
}
|
|
|
|
finish_close_scene_tab(world)
|
|
}
|
|
|
|
fn finish_close_scene_tab(world: &mut World) -> String {
|
|
if world.resource::<SceneIo>().tabs.len() == 1 {
|
|
clear_scene_world(world);
|
|
world.insert_resource(SceneComposition {
|
|
scene_id: uuid::Uuid::new_v4().to_string(),
|
|
subscenes: Vec::new(),
|
|
});
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
let id = io.allocate_tab_id();
|
|
io.tabs = vec![SceneTab {
|
|
id,
|
|
path: None,
|
|
dirty: false,
|
|
snapshot: String::new(),
|
|
recovery_snapshot: None,
|
|
disk_snapshot: None,
|
|
}];
|
|
io.active_tab = 0;
|
|
io.active_path = None;
|
|
io.recovery_snapshot = None;
|
|
io.mark_clean();
|
|
}
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
let _ = capture_active_tab(world);
|
|
return "Closed scene; created an empty scene tab".to_string();
|
|
}
|
|
|
|
let closing = world.resource::<SceneIo>().active_tab;
|
|
let target_index = if closing + 1 < world.resource::<SceneIo>().tabs.len() {
|
|
closing + 1
|
|
} else {
|
|
closing - 1
|
|
};
|
|
let target = world.resource::<SceneIo>().tabs[target_index].clone();
|
|
if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) {
|
|
return format!("Close scene failed: {error}");
|
|
}
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.tabs.remove(closing);
|
|
io.active_tab = if target_index > closing {
|
|
target_index - 1
|
|
} else {
|
|
target_index
|
|
};
|
|
io.active_path = target.path.clone();
|
|
io.dirty = target.dirty;
|
|
io.recovery_snapshot = target.recovery_snapshot;
|
|
io.sync_active_tab_metadata();
|
|
}
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
"Closed scene tab".to_string()
|
|
}
|
|
|
|
fn reload_active_composition(world: &mut World) -> String {
|
|
if let Err(error) = capture_active_tab(world) {
|
|
return format!("Composition reload failed: {error}");
|
|
}
|
|
let target = world.resource::<SceneIo>().tabs[world.resource::<SceneIo>().active_tab].clone();
|
|
if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) {
|
|
return format!("Composition reload failed: {error}");
|
|
}
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.active_path = target.path;
|
|
io.dirty = target.dirty;
|
|
io.recovery_snapshot = target.recovery_snapshot;
|
|
io.sync_active_tab_metadata();
|
|
}
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
"Reloaded active scene composition".to_string()
|
|
}
|
|
|
|
fn request_close_scene_confirmation(world: &mut World) {
|
|
let result = world.resource::<NativeDialogBroker>().request(
|
|
|| {
|
|
rfd::MessageDialog::new()
|
|
.set_title("Close Scene")
|
|
.set_description("This scene tab has unsaved changes. Save before closing it?")
|
|
.set_level(rfd::MessageLevel::Warning)
|
|
.set_buttons(rfd::MessageButtons::YesNoCancel)
|
|
.show()
|
|
},
|
|
|world, decision| {
|
|
let status = match decision {
|
|
rfd::MessageDialogResult::Yes => {
|
|
let save_status = save_active_or_prompt(world);
|
|
if save_status.starts_with("Saved ") {
|
|
finish_close_scene_tab(world)
|
|
} else {
|
|
format!("Close scene paused until it is saved: {save_status}")
|
|
}
|
|
}
|
|
rfd::MessageDialogResult::No => finish_close_scene_tab(world),
|
|
_ => "Close scene cancelled".to_string(),
|
|
};
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
},
|
|
);
|
|
if let Err(error) = result {
|
|
world
|
|
.resource_mut::<SceneIo>()
|
|
.set_status(format!("Close scene unavailable: {error}"));
|
|
}
|
|
}
|
|
|
|
fn save_all_tabs(world: &mut World) -> String {
|
|
let original_id = world.resource::<SceneIo>().tabs[world.resource::<SceneIo>().active_tab].id;
|
|
let dirty_ids: Vec<u64> = world
|
|
.resource::<SceneIo>()
|
|
.tabs
|
|
.iter()
|
|
.filter(|tab| tab.dirty)
|
|
.map(|tab| tab.id)
|
|
.collect();
|
|
for id in dirty_ids {
|
|
let Some(index) = world
|
|
.resource::<SceneIo>()
|
|
.tabs
|
|
.iter()
|
|
.position(|tab| tab.id == id)
|
|
else {
|
|
continue;
|
|
};
|
|
if index != world.resource::<SceneIo>().active_tab {
|
|
let status = switch_scene_tab(world, index);
|
|
if status.starts_with("Scene switch failed") {
|
|
return format!("Save failed: {status}");
|
|
}
|
|
}
|
|
let status = save_active_or_prompt(world);
|
|
if status.starts_with("Save failed") || status.ends_with("cancelled") {
|
|
return status;
|
|
}
|
|
}
|
|
if let Some(index) = world
|
|
.resource::<SceneIo>()
|
|
.tabs
|
|
.iter()
|
|
.position(|tab| tab.id == original_id)
|
|
{
|
|
if index != world.resource::<SceneIo>().active_tab {
|
|
let _ = switch_scene_tab(world, index);
|
|
}
|
|
}
|
|
"Saved all modified scene tabs".to_string()
|
|
}
|
|
|
|
fn save_selection_as_prefab(world: &mut World) -> String {
|
|
let selected_roots: Vec<Entity> = world
|
|
.get_resource::<UiState>()
|
|
.map(|ui| {
|
|
ui.selected_entities
|
|
.iter()
|
|
.filter(|entity| {
|
|
world
|
|
.get_entity(*entity)
|
|
.is_ok_and(|e| e.contains::<LevelObject>())
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let selection = expand_authored_selection(world, &selected_roots);
|
|
if selection.is_empty() {
|
|
return "Select level objects to save as prefab".to_string();
|
|
}
|
|
|
|
let request = world.resource::<NativeDialogBroker>().request(
|
|
|| {
|
|
rfd::FileDialog::new()
|
|
.set_directory("assets/prefabs")
|
|
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
|
.set_file_name("prefab.scn.ron")
|
|
.save_file()
|
|
},
|
|
move |world, path| {
|
|
let status = finish_save_selection_as_prefab(world, selection, path);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
},
|
|
);
|
|
match request {
|
|
Ok(()) => "Choose a destination for the prefab".to_string(),
|
|
Err(error) => format!("Save prefab unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
fn finish_save_selection_as_prefab(
|
|
world: &mut World,
|
|
selection: Vec<Entity>,
|
|
path: Option<PathBuf>,
|
|
) -> String {
|
|
let Some(path) = path else {
|
|
return "Save prefab cancelled".to_string();
|
|
};
|
|
let expected = match FileSnapshot::capture(&path) {
|
|
Ok(expected) => expected,
|
|
Err(error) => return format!("Save prefab failed: {error}"),
|
|
};
|
|
|
|
if ensure_unique_actor_ids(world, &selection) > 0 {
|
|
world.resource_mut::<SceneIo>().mark_dirty();
|
|
}
|
|
|
|
match save_prefab_entities(
|
|
world,
|
|
&path,
|
|
selection,
|
|
SceneWriteContext::Standalone {
|
|
expected,
|
|
description: "prefab copy",
|
|
},
|
|
) {
|
|
Ok(count) => {
|
|
world.resource_mut::<EditorAssets>().refresh();
|
|
format!(
|
|
"Saved prefab with {count} root entity(s) to {}",
|
|
path.display()
|
|
)
|
|
}
|
|
Err(err) => format!("Save prefab failed: {err}"),
|
|
}
|
|
}
|
|
|
|
fn ensure_unique_actor_ids(world: &mut World, entities: &[Entity]) -> usize {
|
|
let selected: HashSet<Entity> = entities.iter().copied().collect();
|
|
let mut occupied: HashSet<String> = world
|
|
.query_filtered::<(
|
|
Entity,
|
|
&ActorId,
|
|
Has<HydratedPrefabMember>,
|
|
Has<ComposedSceneMember>,
|
|
), With<LevelObject>>()
|
|
.iter(world)
|
|
.filter(|(entity, id, hydrated, composed)| {
|
|
!selected.contains(entity) && !id.0.trim().is_empty() && !hydrated && !composed
|
|
})
|
|
.map(|(_, id, _, _)| id.0.clone())
|
|
.collect();
|
|
let mut changed = 0;
|
|
for entity in entities {
|
|
let current = world
|
|
.get::<ActorId>(*entity)
|
|
.map(|id| id.0.trim().to_string())
|
|
.filter(|id| !id.is_empty());
|
|
if current
|
|
.as_ref()
|
|
.is_some_and(|actor_id| occupied.insert(actor_id.clone()))
|
|
{
|
|
continue;
|
|
}
|
|
let actor_id = loop {
|
|
let candidate = uuid::Uuid::new_v4().to_string();
|
|
if occupied.insert(candidate.clone()) {
|
|
break candidate;
|
|
}
|
|
};
|
|
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
|
|
entity_mut.insert(ActorId::new(actor_id));
|
|
changed += 1;
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
fn expand_authored_selection(world: &World, roots: &[Entity]) -> Vec<Entity> {
|
|
let mut selected = HashSet::new();
|
|
let mut stack = roots.to_vec();
|
|
while let Some(entity) = stack.pop() {
|
|
if !world
|
|
.get_entity(entity)
|
|
.is_ok_and(|entity_ref| entity_ref.contains::<LevelObject>())
|
|
|| world.get::<HydratedPrefabMember>(entity).is_some()
|
|
|| world.get::<ComposedSceneMember>(entity).is_some()
|
|
|| !selected.insert(entity)
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(children) = world.get::<Children>(entity) {
|
|
stack.extend(children.iter());
|
|
}
|
|
}
|
|
let mut selected: Vec<_> = selected.into_iter().collect();
|
|
selected.sort_by_key(|entity| entity.to_bits());
|
|
selected
|
|
}
|
|
|
|
fn save_prefab_entities(
|
|
world: &mut World,
|
|
path: &Path,
|
|
entities: Vec<Entity>,
|
|
write_context: SceneWriteContext,
|
|
) -> Result<usize, String> {
|
|
let count = entities.len();
|
|
let original_actor_ids: Vec<_> = entities
|
|
.iter()
|
|
.map(|entity| (*entity, world.get::<ActorId>(*entity).cloned()))
|
|
.collect();
|
|
let repaired = ensure_unique_actor_ids(world, &entities);
|
|
let result = (|| {
|
|
let text = serialize_standalone_entities(world, entities)?;
|
|
let project_root = PathBuf::from(
|
|
world
|
|
.resource::<crate::project_io::ProjectWorkspace>()
|
|
.root
|
|
.clone(),
|
|
);
|
|
scene::validate_prefab_graph_text(&text, path, &project_root)?;
|
|
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
|
|
Ok(count)
|
|
})();
|
|
if result.is_err() {
|
|
for (entity, actor_id) in original_actor_ids {
|
|
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
|
match actor_id {
|
|
Some(actor_id) => {
|
|
entity_mut.insert(actor_id);
|
|
}
|
|
None => {
|
|
entity_mut.remove::<ActorId>();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if repaired > 0 {
|
|
world.resource_mut::<SceneIo>().mark_dirty();
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Save helper for play-mode and other cross-module callers.
|
|
pub fn save_active_or_prompt_world(world: &mut World) -> String {
|
|
save_active_or_prompt(world)
|
|
}
|
|
|
|
fn save_active_or_prompt(world: &mut World) -> String {
|
|
let path = world.resource::<SceneIo>().active_path.clone();
|
|
match path {
|
|
Some(path) => match save_level(world, &path, SceneWriteContext::Active) {
|
|
Ok(count) => {
|
|
world.resource_mut::<SceneIo>().mark_clean();
|
|
remember_path(world, path.clone());
|
|
retire_scene_recovery(world, &path);
|
|
format!("Saved {count} level entities to {}", path.display())
|
|
}
|
|
Err(err) => format!("Save failed: {err}"),
|
|
},
|
|
None => save_with_dialog(world),
|
|
}
|
|
}
|
|
|
|
fn save_with_dialog(world: &mut World) -> String {
|
|
let request = world.resource::<NativeDialogBroker>().request(
|
|
|| {
|
|
rfd::FileDialog::new()
|
|
.set_directory("assets/levels")
|
|
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
|
.set_file_name("editor_scene.scn.ron")
|
|
.save_file()
|
|
},
|
|
|world, path| {
|
|
let status = finish_save_with_dialog(world, path);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
},
|
|
);
|
|
match request {
|
|
Ok(()) => "Choose a scene destination".to_string(),
|
|
Err(error) => format!("Save unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
fn finish_save_with_dialog(world: &mut World, path: Option<PathBuf>) -> String {
|
|
let Some(path) = path else {
|
|
return "Save cancelled".to_string();
|
|
};
|
|
let expected = match FileSnapshot::capture(&path) {
|
|
Ok(expected) => expected,
|
|
Err(error) => return format!("Save failed: {error}"),
|
|
};
|
|
|
|
match save_level(world, &path, SceneWriteContext::ActiveSaveAs { expected }) {
|
|
Ok(count) => {
|
|
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
|
|
world.resource_mut::<SceneIo>().mark_clean();
|
|
remember_path(world, path.clone());
|
|
retire_scene_recovery(world, &path);
|
|
format!("Saved {count} level entities to {}", path.display())
|
|
}
|
|
Err(err) => format!("Save failed: {err}"),
|
|
}
|
|
}
|
|
|
|
fn open_with_dialog(world: &mut World) -> String {
|
|
let request = world.resource::<NativeDialogBroker>().request(
|
|
|| {
|
|
rfd::FileDialog::new()
|
|
.set_directory("assets/levels")
|
|
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
|
.pick_file()
|
|
},
|
|
|world, path| {
|
|
let status = path.map_or_else(
|
|
|| "Open cancelled".to_string(),
|
|
|path| open_path(world, path),
|
|
);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
},
|
|
);
|
|
match request {
|
|
Ok(()) => "Choose a scene to open".to_string(),
|
|
Err(error) => format!("Open unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
fn import_with_dialog(world: &mut World) -> String {
|
|
let request = world.resource::<NativeDialogBroker>().request(
|
|
|| {
|
|
rfd::FileDialog::new()
|
|
.add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS)
|
|
.pick_files()
|
|
},
|
|
|world, paths| {
|
|
let status = match paths {
|
|
None => "Import cancelled".to_string(),
|
|
Some(paths) => match import_external_assets(&paths) {
|
|
Ok(count) => {
|
|
world.resource_mut::<EditorAssets>().refresh();
|
|
format!("Imported {count} asset(s)")
|
|
}
|
|
Err(err) => format!("Import failed: {err}"),
|
|
},
|
|
};
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
},
|
|
);
|
|
match request {
|
|
Ok(()) => "Choose assets to import".to_string(),
|
|
Err(error) => format!("Import unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
fn export_selection_with_dialog(world: &mut World) -> String {
|
|
let Some(entity) = world.resource::<SelectedEntity>().0 else {
|
|
return "Nothing selected to export".to_string();
|
|
};
|
|
if snapshot_entity(world, entity).is_none() {
|
|
return "Selected entity is not an authored level object".to_string();
|
|
}
|
|
|
|
let request = world.resource::<NativeDialogBroker>().request(
|
|
|| {
|
|
rfd::FileDialog::new()
|
|
.set_directory("assets/levels")
|
|
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
|
.set_file_name("selection.scn.ron")
|
|
.save_file()
|
|
},
|
|
move |world, path| {
|
|
let status = finish_export_selection(world, entity, path);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
},
|
|
);
|
|
match request {
|
|
Ok(()) => "Choose an export destination".to_string(),
|
|
Err(error) => format!("Export unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
fn finish_export_selection(world: &mut World, entity: Entity, path: Option<PathBuf>) -> String {
|
|
let Some(path) = path else {
|
|
return "Export cancelled".to_string();
|
|
};
|
|
if snapshot_entity(world, entity).is_none() {
|
|
return "Export failed: the initiating selection no longer exists".to_string();
|
|
}
|
|
let expected = match FileSnapshot::capture(&path) {
|
|
Ok(expected) => expected,
|
|
Err(error) => return format!("Export failed: {error}"),
|
|
};
|
|
|
|
match save_standalone_entities(
|
|
world,
|
|
&path,
|
|
vec![entity],
|
|
SceneWriteContext::Standalone {
|
|
expected,
|
|
description: "selection export",
|
|
},
|
|
) {
|
|
Ok(count) => format!("Exported {count} selected entity to {}", path.display()),
|
|
Err(err) => format!("Export failed: {err}"),
|
|
}
|
|
}
|
|
|
|
fn clear_loaded_scene_roots(world: &mut World) {
|
|
let mut query = world.query_filtered::<Entity, With<LoadedSceneRoot>>();
|
|
let entities: Vec<Entity> = query.iter(world).collect();
|
|
for entity in entities {
|
|
if let Ok(entity_mut) = world.get_entity_mut(entity) {
|
|
entity_mut.despawn();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn clear_scene_world(world: &mut World) {
|
|
clear_loaded_scene_roots(world);
|
|
clear_level_objects(world);
|
|
if let Some(mut hierarchy) =
|
|
world.get_resource_mut::<crate::ui::hierarchy_state::HierarchyPanelState>()
|
|
{
|
|
hierarchy.locked.clear();
|
|
}
|
|
if let Some(mut ui) = world.get_resource_mut::<UiState>() {
|
|
ui.selected_entities.clear();
|
|
}
|
|
if let Some(mut selected) = world.get_resource_mut::<SelectedEntity>() {
|
|
selected.0 = None;
|
|
}
|
|
}
|
|
|
|
fn save_level(
|
|
world: &mut World,
|
|
path: &Path,
|
|
write_context: SceneWriteContext,
|
|
) -> Result<usize, String> {
|
|
let entities = authored_scene_entities(world);
|
|
if is_prefab_document(path) {
|
|
save_prefab_entities(world, path, entities, write_context)
|
|
} else {
|
|
save_entities(world, path, entities, write_context)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn serialize_active_scene(world: &mut World) -> Result<String, String> {
|
|
let entities = authored_scene_entities(world);
|
|
if world
|
|
.resource::<SceneIo>()
|
|
.active_path
|
|
.as_deref()
|
|
.is_some_and(is_prefab_document)
|
|
{
|
|
serialize_standalone_entities(world, entities)
|
|
} else {
|
|
serialize_entities(world, entities)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn is_prefab_document(path: &Path) -> bool {
|
|
let normalized = path.to_string_lossy().replace('\\', "/");
|
|
normalized.starts_with("assets/prefabs/") || normalized.contains("/assets/prefabs/")
|
|
}
|
|
|
|
fn authored_scene_entities(world: &mut World) -> Vec<Entity> {
|
|
let mut query = world.query_filtered::<Entity, (
|
|
With<LevelObject>,
|
|
Without<ComposedSceneMember>,
|
|
Without<HydratedPrefabMember>,
|
|
)>();
|
|
query.iter(world).collect()
|
|
}
|
|
|
|
fn format_actor_validation(err: ActorValidationError) -> String {
|
|
match err {
|
|
ActorValidationError::MissingActorKind => {
|
|
"Save failed: level object missing ActorKind (re-open scene to migrate)".into()
|
|
}
|
|
ActorValidationError::MissingTransform => {
|
|
"Save failed: level object missing Transform".into()
|
|
}
|
|
ActorValidationError::BrushMissingDesc => {
|
|
"Save failed: Brush actor requires BrushDesc".into()
|
|
}
|
|
ActorValidationError::BrushHasPrimitive => {
|
|
"Save failed: Brush actor cannot have Primitive".into()
|
|
}
|
|
ActorValidationError::BrushHasStaticMeshRenderer => {
|
|
"Save failed: Brush actor cannot have StaticMeshRenderer".into()
|
|
}
|
|
ActorValidationError::BrushHasLight => {
|
|
"Save failed: Brush actor cannot have LightDesc".into()
|
|
}
|
|
ActorValidationError::BrushHasModelRef => {
|
|
"Save failed: Brush actor cannot have ModelRef".into()
|
|
}
|
|
ActorValidationError::InvalidBrushGeometry(message) => {
|
|
format!("Save failed: invalid brush geometry: {message}")
|
|
}
|
|
ActorValidationError::InvalidTerrain(message) => {
|
|
format!("Save failed: invalid terrain: {message}")
|
|
}
|
|
ActorValidationError::StaticMeshMissingPrimitive => {
|
|
"Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot"
|
|
.into()
|
|
}
|
|
ActorValidationError::StaticMeshHasLight => {
|
|
"Save failed: StaticMesh actor cannot have LightDesc".into()
|
|
}
|
|
ActorValidationError::StaticMeshHasModelRef => {
|
|
"Save failed: StaticMesh actor cannot have ModelRef".into()
|
|
}
|
|
ActorValidationError::ImportedModelMissingModelRef => {
|
|
"Save failed: ImportedModel actor requires ModelRef".into()
|
|
}
|
|
ActorValidationError::ImportedModelHasPrimitive => {
|
|
"Save failed: ImportedModel actor cannot have Primitive".into()
|
|
}
|
|
ActorValidationError::ImportedModelHasStaticMeshRenderer => {
|
|
"Save failed: ImportedModel actor cannot have StaticMeshRenderer".into()
|
|
}
|
|
ActorValidationError::SkinnedMeshMissingRenderer => {
|
|
"Save failed: SkinnedMesh actor requires SkinnedMeshRenderer".into()
|
|
}
|
|
ActorValidationError::SkinnedMeshInvalidRenderer => {
|
|
"Save failed: SkinnedMeshRenderer requires a model source path".into()
|
|
}
|
|
ActorValidationError::SkinnedMeshHasPrimitive => {
|
|
"Save failed: SkinnedMesh actor cannot have Primitive".into()
|
|
}
|
|
ActorValidationError::SkinnedMeshHasStaticMeshRenderer => {
|
|
"Save failed: SkinnedMesh actor cannot have StaticMeshRenderer".into()
|
|
}
|
|
ActorValidationError::SkinnedMeshHasModelRef => {
|
|
"Save failed: SkinnedMesh actor cannot have ModelRef".into()
|
|
}
|
|
ActorValidationError::SkinnedMeshRendererActorKindMismatch => {
|
|
"Save failed: SkinnedMeshRenderer requires ActorKind::SkinnedMesh".into()
|
|
}
|
|
ActorValidationError::ConflictingGeometrySources => {
|
|
"Save failed: actor has more than one primary geometry source component".into()
|
|
}
|
|
ActorValidationError::LightMissingLightDesc => {
|
|
"Save failed: Light actor requires LightDesc".into()
|
|
}
|
|
ActorValidationError::LightHasPrimitive => {
|
|
"Save failed: Light actor cannot have Primitive".into()
|
|
}
|
|
ActorValidationError::LightHasModelRef => {
|
|
"Save failed: Light actor cannot have ModelRef".into()
|
|
}
|
|
ActorValidationError::LightHasStaticMeshRenderer => {
|
|
"Save failed: Light actor cannot have StaticMeshRenderer".into()
|
|
}
|
|
ActorValidationError::AudioSourceMissingDesc => {
|
|
"Save failed: AudioSource actor requires AudioSourceDesc".into()
|
|
}
|
|
ActorValidationError::AudioSourceMissingClip => {
|
|
"Save failed: audio source requires an assigned clip".into()
|
|
}
|
|
ActorValidationError::AudioSourceInvalidClipReference => {
|
|
"Save failed: audio source clip reference is unresolved or not an audio clip".into()
|
|
}
|
|
ActorValidationError::AudioSourceInvalidGain => {
|
|
"Save failed: audio source gain is outside the supported dB range".into()
|
|
}
|
|
ActorValidationError::AudioSourceInvalidPitch => {
|
|
"Save failed: audio source pitch must be positive and finite".into()
|
|
}
|
|
ActorValidationError::AudioSourceInvalidSpatialBlend => {
|
|
"Save failed: audio source spatial blend must be between 0 and 1".into()
|
|
}
|
|
ActorValidationError::AudioSourceInvalidAttenuation => {
|
|
"Save failed: audio source attenuation distances or rolloff are invalid".into()
|
|
}
|
|
ActorValidationError::AudioSourceMissingBus => {
|
|
"Save failed: audio source requires a bus ID".into()
|
|
}
|
|
ActorValidationError::AudioListenerMissingDesc => {
|
|
"Save failed: AudioListener actor requires AudioListenerDesc".into()
|
|
}
|
|
ActorValidationError::AudioListenerInvalidEarGap => {
|
|
"Save failed: audio listener ear gap must be positive and finite".into()
|
|
}
|
|
ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer => {
|
|
"Save failed: animation controller requires SkinnedMeshRenderer on the same actor"
|
|
.into()
|
|
}
|
|
ActorValidationError::AnimationControllerMissingSkeleton => {
|
|
"Save failed: animation controller requires an assigned skeleton".into()
|
|
}
|
|
ActorValidationError::AnimationControllerInvalidSkeletonReference => {
|
|
"Save failed: animation controller skeleton reference is unresolved or invalid".into()
|
|
}
|
|
ActorValidationError::AnimationControllerEmptyStateId => {
|
|
"Save failed: animation controller state IDs cannot be empty".into()
|
|
}
|
|
ActorValidationError::AnimationControllerDuplicateStateId => {
|
|
"Save failed: animation controller state IDs must be unique".into()
|
|
}
|
|
ActorValidationError::AnimationControllerInvalidClipReference => {
|
|
"Save failed: animation states require resolved glTF/GLB clip references".into()
|
|
}
|
|
ActorValidationError::AnimationControllerInvalidStateSpeed => {
|
|
"Save failed: animation state speed must be finite and nonzero".into()
|
|
}
|
|
ActorValidationError::AnimationControllerInvalidStateRange => {
|
|
"Save failed: animation state playback range is invalid".into()
|
|
}
|
|
ActorValidationError::AnimationControllerInvalidCrossfade => {
|
|
"Save failed: animation controller crossfade must be finite and non-negative".into()
|
|
}
|
|
ActorValidationError::AnimationControllerMissingDefaultState => {
|
|
"Save failed: animation controller requires a default state".into()
|
|
}
|
|
ActorValidationError::AnimationControllerUnknownDefaultState => {
|
|
"Save failed: animation controller default state does not exist".into()
|
|
}
|
|
ActorValidationError::PostProcessVolumeMissingDesc => {
|
|
"Save failed: PostProcessVolume actor requires PostProcessVolumeDesc".into()
|
|
}
|
|
ActorValidationError::PostProcessVolumeInvalidHalfExtents => {
|
|
"Save failed: post-process volume half_extents must be positive".into()
|
|
}
|
|
ActorValidationError::PostProcessVolumeInvalidBlendDistance => {
|
|
"Save failed: post-process volume blend_distance must be non-negative".into()
|
|
}
|
|
ActorValidationError::PostProcessVolumeInvalidOverrideScalar => {
|
|
"Save failed: post-process volume override contains invalid scalar".into()
|
|
}
|
|
ActorValidationError::InvalidNavigation(message) => {
|
|
format!("Save failed: invalid navigation authoring: {message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn save_entities(
|
|
world: &mut World,
|
|
path: &Path,
|
|
entities: Vec<Entity>,
|
|
write_context: SceneWriteContext,
|
|
) -> Result<usize, String> {
|
|
let count = entities.len();
|
|
let text = serialize_entities(world, entities)?;
|
|
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
|
|
Ok(count)
|
|
}
|
|
|
|
fn save_standalone_entities(
|
|
world: &mut World,
|
|
path: &Path,
|
|
entities: Vec<Entity>,
|
|
write_context: SceneWriteContext,
|
|
) -> Result<usize, String> {
|
|
let count = entities.len();
|
|
let text = serialize_standalone_entities(world, entities)?;
|
|
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
|
|
Ok(count)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum SceneWriteContext {
|
|
Active,
|
|
ActiveSaveAs {
|
|
expected: FileSnapshot,
|
|
},
|
|
Standalone {
|
|
expected: FileSnapshot,
|
|
description: &'static str,
|
|
},
|
|
}
|
|
|
|
fn publish_scene_text(
|
|
world: &mut World,
|
|
path: &Path,
|
|
bytes: &[u8],
|
|
entity_count: usize,
|
|
context: SceneWriteContext,
|
|
) -> Result<(), String> {
|
|
match context {
|
|
SceneWriteContext::Active | SceneWriteContext::ActiveSaveAs { .. } => {
|
|
let (tab_id, expected) = match context {
|
|
SceneWriteContext::Active => active_scene_write_baseline(world, path)?,
|
|
SceneWriteContext::ActiveSaveAs { expected } => {
|
|
(active_scene_tab_id(world)?, expected)
|
|
}
|
|
SceneWriteContext::Standalone { .. } => unreachable!(),
|
|
};
|
|
let snapshot = publish_authored_file(
|
|
world,
|
|
path,
|
|
bytes,
|
|
&expected,
|
|
FileWriteIntent::Scene {
|
|
tab_id,
|
|
entity_count,
|
|
},
|
|
)?;
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
let Some(tab) = io.tabs.iter_mut().find(|tab| tab.id == tab_id) else {
|
|
return Err("saved scene tab no longer exists".into());
|
|
};
|
|
tab.disk_snapshot = Some(snapshot);
|
|
}
|
|
SceneWriteContext::Standalone {
|
|
expected,
|
|
description,
|
|
} => {
|
|
publish_authored_file(
|
|
world,
|
|
path,
|
|
bytes,
|
|
&expected,
|
|
FileWriteIntent::Standalone {
|
|
description: description.into(),
|
|
},
|
|
)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn active_scene_write_baseline(
|
|
world: &World,
|
|
destination: &Path,
|
|
) -> Result<(u64, FileSnapshot), String> {
|
|
let io = world.resource::<SceneIo>();
|
|
let tab = io
|
|
.tabs
|
|
.get(io.active_tab)
|
|
.ok_or_else(|| "active scene tab is missing".to_string())?;
|
|
if io.active_path.as_deref() != Some(destination) || tab.path.as_deref() != Some(destination) {
|
|
return Err(format!(
|
|
"{} is not the active scene destination; use Save As",
|
|
destination.display()
|
|
));
|
|
}
|
|
let expected = tab.disk_snapshot.clone().ok_or_else(|| {
|
|
format!(
|
|
"scene disk baseline is unavailable for {}; reload it or use Save As",
|
|
destination.display()
|
|
)
|
|
})?;
|
|
Ok((tab.id, expected))
|
|
}
|
|
|
|
fn active_scene_tab_id(world: &World) -> Result<u64, String> {
|
|
let io = world.resource::<SceneIo>();
|
|
io.tabs
|
|
.get(io.active_tab)
|
|
.map(|tab| tab.id)
|
|
.ok_or_else(|| "active scene tab is missing".to_string())
|
|
}
|
|
|
|
fn serialize_entities(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
|
|
serialize_entities_inner_with_resources(world, entities, true)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn serialize_entities_inner(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
|
|
serialize_entities_inner_with_resources(world, entities, true)
|
|
}
|
|
|
|
fn serialize_standalone_entities(
|
|
world: &mut World,
|
|
entities: Vec<Entity>,
|
|
) -> Result<String, String> {
|
|
serialize_entities_inner_with_resources(world, entities, false)
|
|
}
|
|
|
|
fn serialize_entities_inner_with_resources(
|
|
world: &mut World,
|
|
entities: Vec<Entity>,
|
|
include_scene_resources: bool,
|
|
) -> Result<String, String> {
|
|
for entity in &entities {
|
|
let entity_ref = world.entity(*entity);
|
|
if let Err(err) = validate_actor(entity_ref) {
|
|
return Err(format_actor_validation(err));
|
|
}
|
|
}
|
|
|
|
let entity_set: HashSet<Entity> = entities.iter().copied().collect();
|
|
let detached_parents: Vec<(Entity, Entity, Transform)> = if include_scene_resources {
|
|
Vec::new()
|
|
} else {
|
|
entities
|
|
.iter()
|
|
.filter_map(|entity| {
|
|
world
|
|
.get::<ChildOf>(*entity)
|
|
.map(ChildOf::parent)
|
|
.filter(|parent| !entity_set.contains(parent))
|
|
.and_then(|parent| {
|
|
computed_world_transform(world, *entity)
|
|
.map(|world_transform| (*entity, parent, world_transform))
|
|
})
|
|
})
|
|
.collect()
|
|
};
|
|
let original_local_transforms: Vec<(Entity, Transform)> = detached_parents
|
|
.iter()
|
|
.filter_map(|(entity, _, _)| {
|
|
world
|
|
.get::<Transform>(*entity)
|
|
.copied()
|
|
.map(|transform| (*entity, transform))
|
|
})
|
|
.collect();
|
|
for (entity, _, world_transform) in &detached_parents {
|
|
world
|
|
.entity_mut(*entity)
|
|
.remove::<ChildOf>()
|
|
.insert(*world_transform);
|
|
}
|
|
|
|
let result = (|| {
|
|
let ron = {
|
|
let registry = world.resource::<AppTypeRegistry>().read();
|
|
let mut component_filter = WorldFilter::deny_all()
|
|
.allow::<Name>()
|
|
.allow::<Transform>()
|
|
.allow::<ChildOf>()
|
|
.allow::<LevelObject>()
|
|
.allow::<ActorId>()
|
|
.allow::<ActorName>()
|
|
.allow::<HierarchySiblingIndex>()
|
|
.allow::<EditorVisibility>();
|
|
let component_registry = world
|
|
.get_resource::<crate::ui::component_registry::EditorComponentRegistry>()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
for descriptor in &component_registry.descriptors {
|
|
if let Some(registration) = registry.get_with_type_path(descriptor.type_name) {
|
|
component_filter = component_filter.allow_by_id(registration.type_id());
|
|
}
|
|
}
|
|
let builder = DynamicWorldBuilder::from_world(world, ®istry)
|
|
.with_component_filter(component_filter)
|
|
.extract_entities(entities.into_iter());
|
|
let scene = if include_scene_resources {
|
|
builder
|
|
.allow_resource::<SceneComposition>()
|
|
.extract_resources()
|
|
.remove_empty_entities()
|
|
.build()
|
|
} else {
|
|
builder.remove_empty_entities().build()
|
|
};
|
|
|
|
scene
|
|
.serialize(®istry)
|
|
.map_err(|err| format!("could not serialize scene: {err}"))?
|
|
};
|
|
|
|
let document = SceneDocument::from_ron_text(&ron)?;
|
|
document.to_ron_text()
|
|
})();
|
|
for (entity, transform) in original_local_transforms {
|
|
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
|
entity_mut.insert(transform);
|
|
}
|
|
}
|
|
for (entity, parent, _) in detached_parents {
|
|
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
|
entity_mut.insert(ChildOf(parent));
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn computed_world_transform(world: &World, entity: Entity) -> Option<Transform> {
|
|
let mut chain = Vec::new();
|
|
let mut current = entity;
|
|
let mut visited = HashSet::new();
|
|
while visited.insert(current) {
|
|
chain.push(*world.get::<Transform>(current)?);
|
|
let Some(parent) = world.get::<ChildOf>(current).map(ChildOf::parent) else {
|
|
break;
|
|
};
|
|
current = parent;
|
|
}
|
|
if world.get::<ChildOf>(current).is_some() {
|
|
return None;
|
|
}
|
|
let mut chain = chain.into_iter().rev();
|
|
let root = chain.next()?;
|
|
Some(
|
|
chain
|
|
.fold(GlobalTransform::from(root), |global, local| {
|
|
global.mul_transform(local)
|
|
})
|
|
.compute_transform(),
|
|
)
|
|
}
|
|
|
|
fn tick_scene_recovery(world: &mut World) {
|
|
let (enabled, interval_secs, max_generations) = {
|
|
let prefs = world.resource::<crate::project_io::UserPreferences>();
|
|
(
|
|
prefs.scene_autosave_enabled,
|
|
recovery_interval_secs(prefs),
|
|
prefs.scene_recovery_generations.max(1),
|
|
)
|
|
};
|
|
if !enabled {
|
|
return;
|
|
}
|
|
|
|
let dirty = world.resource::<SceneIo>().has_unsaved_tabs();
|
|
let delta = world.resource::<Time>().delta_secs();
|
|
let should_snapshot = {
|
|
let mut clock = world.resource_mut::<RecoveryClock>();
|
|
if !dirty {
|
|
clock.elapsed_secs = 0.0;
|
|
return;
|
|
}
|
|
clock.elapsed_secs += delta;
|
|
if clock.elapsed_secs < interval_secs {
|
|
false
|
|
} else {
|
|
clock.elapsed_secs = 0.0;
|
|
true
|
|
}
|
|
};
|
|
if !should_snapshot {
|
|
return;
|
|
}
|
|
|
|
let status = match save_dirty_tab_recoveries(world, max_generations) {
|
|
Ok(0) => "Recovery skipped: modified tabs are unsaved".to_string(),
|
|
Ok(1) => "Recovery snapshot saved for 1 scene tab".to_string(),
|
|
Ok(count) => format!("Recovery snapshots saved for {count} scene tabs"),
|
|
Err(error) => format!("Recovery snapshot failed: {error}"),
|
|
};
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
}
|
|
|
|
fn recovery_interval_secs(prefs: &crate::project_io::UserPreferences) -> f32 {
|
|
std::env::var("BLACKSITE_RECOVERY_INTERVAL_SECS")
|
|
.ok()
|
|
.and_then(|value| value.parse::<f32>().ok())
|
|
.filter(|value| value.is_finite() && *value >= 1.0)
|
|
.unwrap_or_else(|| prefs.scene_autosave_interval_secs.max(15) as f32)
|
|
}
|
|
|
|
fn save_dirty_tab_recoveries(world: &mut World, max_generations: usize) -> Result<usize, String> {
|
|
capture_active_tab(world)?;
|
|
let state_root =
|
|
default_state_root().ok_or_else(|| "HOME and XDG_STATE_HOME are unset".to_string())?;
|
|
let project_root = PathBuf::from(
|
|
world
|
|
.resource::<crate::project_io::ProjectWorkspace>()
|
|
.root
|
|
.clone(),
|
|
);
|
|
let candidates = dirty_saved_tab_snapshots(world.resource::<SceneIo>());
|
|
let mut saved = Vec::with_capacity(candidates.len());
|
|
for (index, path, snapshot) in candidates {
|
|
let recovery = write_recovery_snapshot(
|
|
&state_root,
|
|
&project_root,
|
|
&path,
|
|
snapshot.as_bytes(),
|
|
max_generations,
|
|
)?;
|
|
saved.push((index, recovery));
|
|
}
|
|
let count = saved.len();
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
for (index, recovery) in saved {
|
|
if let Some(tab) = io.tabs.get_mut(index) {
|
|
tab.recovery_snapshot = Some(recovery.clone());
|
|
}
|
|
if io.active_tab == index {
|
|
io.recovery_snapshot = Some(recovery);
|
|
}
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
fn dirty_saved_tab_snapshots(io: &SceneIo) -> Vec<(usize, PathBuf, String)> {
|
|
io.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, tab)| {
|
|
(tab.dirty && !tab.snapshot.is_empty())
|
|
.then(|| {
|
|
tab.path
|
|
.clone()
|
|
.map(|path| (index, path, tab.snapshot.clone()))
|
|
})
|
|
.flatten()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn refresh_recovery_notice(world: &mut World, scene_path: &Path) {
|
|
let recovery = default_state_root().and_then(|state_root| {
|
|
let project_root = PathBuf::from(
|
|
world
|
|
.resource::<crate::project_io::ProjectWorkspace>()
|
|
.root
|
|
.clone(),
|
|
);
|
|
latest_recovery_snapshot(&state_root, &project_root, scene_path)
|
|
});
|
|
world.resource_mut::<SceneIo>().recovery_snapshot = recovery;
|
|
}
|
|
|
|
fn retire_scene_recovery(world: &mut World, scene_path: &Path) {
|
|
let Some(state_root) = default_state_root() else {
|
|
return;
|
|
};
|
|
let project_root = PathBuf::from(
|
|
world
|
|
.resource::<crate::project_io::ProjectWorkspace>()
|
|
.root
|
|
.clone(),
|
|
);
|
|
if let Err(error) = discard_recovery_snapshots(&state_root, &project_root, scene_path) {
|
|
warn!("Failed to retire scene recovery snapshots: {error}");
|
|
}
|
|
world.resource_mut::<SceneIo>().recovery_snapshot = None;
|
|
}
|
|
|
|
fn restore_recovery(world: &mut World) -> String {
|
|
let (active_path, snapshot) = {
|
|
let io = world.resource::<SceneIo>();
|
|
(io.active_path.clone(), io.recovery_snapshot.clone())
|
|
};
|
|
let Some(snapshot) = snapshot else {
|
|
return "No scene recovery snapshot is available".to_string();
|
|
};
|
|
match load_level(world, &snapshot) {
|
|
Ok(_) => {
|
|
mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path);
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
format!(
|
|
"Restored recovery {}; save the scene to keep it or discard the snapshot",
|
|
snapshot.display()
|
|
)
|
|
}
|
|
Err(error) => format!("Recovery restore failed: {error}"),
|
|
}
|
|
}
|
|
|
|
fn save_recovery_copy_with_dialog(world: &mut World) -> String {
|
|
let (active_path, snapshot) = {
|
|
let io = world.resource::<SceneIo>();
|
|
(io.active_path.clone(), io.recovery_snapshot.clone())
|
|
};
|
|
let Some(snapshot) = snapshot else {
|
|
return "No scene recovery snapshot is available".to_string();
|
|
};
|
|
let directory = active_path
|
|
.as_deref()
|
|
.and_then(Path::parent)
|
|
.unwrap_or_else(|| Path::new("assets/levels"));
|
|
let file_name = active_path
|
|
.as_deref()
|
|
.and_then(Path::file_name)
|
|
.and_then(|name| name.to_str())
|
|
.and_then(|name| name.strip_suffix(".scn.ron"))
|
|
.map_or_else(
|
|
|| "recovered_scene.scn.ron".to_string(),
|
|
|stem| format!("{stem}.recovered.scn.ron"),
|
|
);
|
|
let directory = directory.to_path_buf();
|
|
let request = world.resource::<NativeDialogBroker>().request(
|
|
move || {
|
|
rfd::FileDialog::new()
|
|
.set_directory(directory)
|
|
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
|
.set_file_name(file_name)
|
|
.save_file()
|
|
},
|
|
move |world, destination| {
|
|
let status = finish_save_recovery_copy(world, snapshot, destination);
|
|
world.resource_mut::<SceneIo>().set_status(status);
|
|
},
|
|
);
|
|
match request {
|
|
Ok(()) => "Choose a destination for the recovery copy".to_string(),
|
|
Err(error) => format!("Save recovery copy unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
fn finish_save_recovery_copy(
|
|
world: &mut World,
|
|
snapshot: PathBuf,
|
|
destination: Option<PathBuf>,
|
|
) -> String {
|
|
let Some(destination) = destination else {
|
|
return "Save recovery copy cancelled".to_string();
|
|
};
|
|
let expected = match FileSnapshot::capture(&destination) {
|
|
Ok(expected) => expected,
|
|
Err(error) => return format!("Save recovery copy failed: {error}"),
|
|
};
|
|
|
|
match write_recovery_copy(world, &snapshot, &destination, &expected) {
|
|
Ok(()) => format!(
|
|
"Saved recovery copy {} from {}",
|
|
destination.display(),
|
|
snapshot.display()
|
|
),
|
|
Err(error) => format!("Save recovery copy failed: {error}"),
|
|
}
|
|
}
|
|
|
|
fn write_recovery_copy(
|
|
world: &mut World,
|
|
snapshot: &Path,
|
|
destination: &Path,
|
|
expected: &FileSnapshot,
|
|
) -> Result<(), String> {
|
|
let bytes = std::fs::read(snapshot)
|
|
.map_err(|error| format!("could not read {}: {error}", snapshot.display()))?;
|
|
publish_authored_file(
|
|
world,
|
|
destination,
|
|
&bytes,
|
|
expected,
|
|
FileWriteIntent::Standalone {
|
|
description: "recovery scene copy".into(),
|
|
},
|
|
)
|
|
.map(|_| ())
|
|
}
|
|
|
|
fn mark_recovery_restored(io: &mut SceneIo, active_path: Option<PathBuf>) {
|
|
io.active_path = active_path;
|
|
io.mark_dirty();
|
|
}
|
|
|
|
fn discard_recovery(world: &mut World) -> String {
|
|
let (scene_path, snapshot) = {
|
|
let io = world.resource::<SceneIo>();
|
|
(io.active_path.clone(), io.recovery_snapshot.clone())
|
|
};
|
|
let (Some(scene_path), Some(snapshot)) = (scene_path, snapshot) else {
|
|
return "No scene recovery snapshot is available".to_string();
|
|
};
|
|
let Some(state_root) = default_state_root() else {
|
|
return "Recovery discard failed: HOME and XDG_STATE_HOME are unset".to_string();
|
|
};
|
|
let project_root = PathBuf::from(
|
|
world
|
|
.resource::<crate::project_io::ProjectWorkspace>()
|
|
.root
|
|
.clone(),
|
|
);
|
|
match discard_recovery_snapshots(&state_root, &project_root, &scene_path) {
|
|
Ok(()) => {
|
|
world.resource_mut::<SceneIo>().recovery_snapshot = None;
|
|
format!(
|
|
"Discarded recovery generations including {}",
|
|
snapshot.display()
|
|
)
|
|
}
|
|
Err(error) => format!("Recovery discard failed: {error}"),
|
|
}
|
|
}
|
|
|
|
fn load_level(world: &mut World, path: &Path) -> Result<FileSnapshot, String> {
|
|
if !path.exists() {
|
|
return Err(format!("{} does not exist yet", path.display()));
|
|
}
|
|
|
|
let text = std::fs::read_to_string(path)
|
|
.map_err(|err| format!("could not read {}: {err}", path.display()))?;
|
|
let disk_snapshot = FileSnapshot::from_loaded_bytes(path, text.as_bytes());
|
|
load_level_text(world, Some(path), &text)?;
|
|
Ok(disk_snapshot)
|
|
}
|
|
|
|
pub(crate) fn reload_scene_after_file_conflict(
|
|
world: &mut World,
|
|
tab_id: u64,
|
|
path: &Path,
|
|
) -> Result<String, String> {
|
|
let active_matches = {
|
|
let io = world.resource::<SceneIo>();
|
|
io.tabs
|
|
.get(io.active_tab)
|
|
.is_some_and(|tab| tab.id == tab_id)
|
|
};
|
|
if !active_matches {
|
|
return Err("the scene conflict no longer belongs to the active tab".into());
|
|
}
|
|
|
|
let disk_snapshot = load_level(world, path)?;
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.active_path = Some(path.to_path_buf());
|
|
io.recovery_snapshot = None;
|
|
let active_tab = io.active_tab;
|
|
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
|
|
io.mark_clean();
|
|
}
|
|
world.resource_mut::<EditorHistory>().clear();
|
|
remember_path(world, path.to_path_buf());
|
|
refresh_recovery_notice(world, path);
|
|
capture_active_tab(world)?;
|
|
Ok(format!("Reloaded {} from disk", path.display()))
|
|
}
|
|
|
|
pub(crate) fn adopt_scene_conflict_save_as(
|
|
world: &mut World,
|
|
tab_id: u64,
|
|
path: PathBuf,
|
|
disk_snapshot: FileSnapshot,
|
|
entity_count: usize,
|
|
) -> Result<String, String> {
|
|
let active_matches = {
|
|
let io = world.resource::<SceneIo>();
|
|
io.tabs
|
|
.get(io.active_tab)
|
|
.is_some_and(|tab| tab.id == tab_id)
|
|
};
|
|
if !active_matches {
|
|
return Err("the scene conflict no longer belongs to the active tab".into());
|
|
}
|
|
|
|
{
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.active_path = Some(path.clone());
|
|
let active_tab = io.active_tab;
|
|
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
|
|
io.mark_clean();
|
|
}
|
|
remember_path(world, path.clone());
|
|
retire_scene_recovery(world, &path);
|
|
Ok(format!(
|
|
"Saved {entity_count} level entities to {}",
|
|
path.display()
|
|
))
|
|
}
|
|
|
|
fn load_level_text(world: &mut World, path: Option<&Path>, text: &str) -> Result<(), String> {
|
|
validate_level_text(text)?;
|
|
let document = SceneDocument::from_ron_text(text)?;
|
|
if let Some(composition) = document.composition.as_ref() {
|
|
scene::validate_scene_composition(composition)?;
|
|
}
|
|
let project_root = PathBuf::from(
|
|
world
|
|
.resource::<crate::project_io::ProjectWorkspace>()
|
|
.root
|
|
.clone(),
|
|
)
|
|
.canonicalize()
|
|
.map_err(|error| format!("could not resolve project root: {error}"))?;
|
|
let bevy_ron = strip_schema_version(document.normalized_ron())?;
|
|
let dynamic_scene = deserialize_dynamic_scene(world, &bevy_ron)?;
|
|
let composition = document
|
|
.composition
|
|
.unwrap_or_else(|| legacy_scene_composition(path));
|
|
preflight_subscenes(&composition, &project_root)?;
|
|
|
|
clear_scene_world(world);
|
|
world.remove_resource::<SceneComposition>();
|
|
|
|
dynamic_scene
|
|
.write_to_world(world, &mut EntityHashMap::default())
|
|
.map_err(|err| format!("could not spawn scene: {err}"))?;
|
|
if !world.contains_resource::<SceneComposition>() {
|
|
world.insert_resource(composition.clone());
|
|
}
|
|
|
|
let root_path = path.and_then(|path| path.canonicalize().ok());
|
|
let mut visiting = root_path.into_iter().collect::<Vec<_>>();
|
|
load_composed_subscenes(world, &composition, &project_root, &mut visiting, "", false)?;
|
|
upgrade_legacy_prefab_instances(world);
|
|
block_invalid_prefab_instances(world, &project_root);
|
|
|
|
backfill_missing_actor_kinds(world);
|
|
backfill_missing_sibling_indices(world);
|
|
backfill_missing_editor_visibility(world);
|
|
finalize_scene_load(world);
|
|
|
|
if world.contains_resource::<crate::scene_schema::SceneSchemaState>() {
|
|
world
|
|
.resource_mut::<crate::scene_schema::SceneSchemaState>()
|
|
.last_validated = scene::CURRENT_SCENE_SCHEMA_VERSION;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn upgrade_legacy_prefab_instances(world: &mut World) -> usize {
|
|
let legacy: Vec<(Entity, String)> = world
|
|
.query_filtered::<(Entity, &PrefabRef), Without<PrefabInstance>>()
|
|
.iter(world)
|
|
.map(|(entity, prefab)| (entity, prefab.path.clone()))
|
|
.collect();
|
|
for (entity, path) in &legacy {
|
|
world
|
|
.entity_mut(*entity)
|
|
.insert(PrefabInstance::new(format!("legacy:{path}"), path.clone()));
|
|
}
|
|
legacy.len()
|
|
}
|
|
|
|
fn block_invalid_prefab_instances(world: &mut World, project_root: &Path) -> usize {
|
|
let instances: Vec<(Entity, PrefabInstance)> = world
|
|
.query::<(Entity, &PrefabInstance)>()
|
|
.iter(world)
|
|
.map(|(entity, instance)| (entity, instance.clone()))
|
|
.collect();
|
|
let mut blocked = 0;
|
|
for (entity, instance) in instances {
|
|
match scene::validate_prefab_graph(&project_root.join(&instance.source_path), project_root)
|
|
{
|
|
Ok(()) => {
|
|
world.entity_mut(entity).remove::<PrefabHydrationBlocked>();
|
|
}
|
|
Err(reason) => {
|
|
blocked += 1;
|
|
world
|
|
.entity_mut(entity)
|
|
.insert(PrefabHydrationBlocked { reason })
|
|
.remove::<PrefabRef>()
|
|
.remove::<HydratedPrefabReady>()
|
|
.remove::<DynamicWorldRoot>()
|
|
.remove::<WorldInstance>();
|
|
}
|
|
}
|
|
}
|
|
blocked
|
|
}
|
|
|
|
fn deserialize_dynamic_scene(world: &World, bevy_ron: &str) -> Result<DynamicWorld, String> {
|
|
let registry = world.resource::<AppTypeRegistry>().read();
|
|
let mut asset_server = world.resource::<AssetServer>().clone();
|
|
let scene_deserializer = WorldDeserializer {
|
|
type_registry: ®istry,
|
|
load_from_path: &mut asset_server,
|
|
};
|
|
let mut deserializer =
|
|
ron::de::Deserializer::from_str(bevy_ron).map_err(|err| err.to_string())?;
|
|
scene_deserializer
|
|
.deserialize(&mut deserializer)
|
|
.map_err(|err| format!("could not deserialize scene: {err}"))
|
|
}
|
|
|
|
fn legacy_scene_composition(path: Option<&Path>) -> SceneComposition {
|
|
let scene_id = path.map_or_else(
|
|
|| uuid::Uuid::new_v4().to_string(),
|
|
|path| format!("legacy:{}", path.to_string_lossy().replace('\\', "/")),
|
|
);
|
|
SceneComposition {
|
|
scene_id,
|
|
subscenes: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn ensure_scene_composition_identity(world: &mut World) {
|
|
if !world.contains_resource::<SceneComposition>() {
|
|
let path = world.resource::<SceneIo>().active_path.clone();
|
|
world.insert_resource(legacy_scene_composition(path.as_deref()));
|
|
}
|
|
if world
|
|
.resource::<SceneComposition>()
|
|
.scene_id
|
|
.trim()
|
|
.is_empty()
|
|
{
|
|
world.resource_mut::<SceneComposition>().scene_id = uuid::Uuid::new_v4().to_string();
|
|
}
|
|
}
|
|
|
|
fn preflight_subscenes(composition: &SceneComposition, project_root: &Path) -> Result<(), String> {
|
|
scene::validate_scene_composition(composition)?;
|
|
for reference in &composition.subscenes {
|
|
let target = resolve_subscene_path(project_root, &reference.path)?;
|
|
scene::validate_composition_graph(&target, project_root)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_subscene_path(project_root: &Path, relative: &str) -> Result<PathBuf, String> {
|
|
let relative_path = Path::new(relative);
|
|
if relative_path.is_absolute()
|
|
|| relative_path.components().any(|component| {
|
|
matches!(
|
|
component,
|
|
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
|
)
|
|
})
|
|
{
|
|
return Err(format!(
|
|
"subscene path `{relative}` escapes the project root"
|
|
));
|
|
}
|
|
let target = project_root
|
|
.join(relative_path)
|
|
.canonicalize()
|
|
.map_err(|error| {
|
|
format!(
|
|
"missing subscene `{relative}` under {}: {error}",
|
|
project_root.display()
|
|
)
|
|
})?;
|
|
if !target.starts_with(project_root) {
|
|
return Err(format!(
|
|
"subscene path `{relative}` escapes the project root"
|
|
));
|
|
}
|
|
Ok(target)
|
|
}
|
|
|
|
fn load_composed_subscenes(
|
|
world: &mut World,
|
|
composition: &SceneComposition,
|
|
project_root: &Path,
|
|
visiting: &mut Vec<PathBuf>,
|
|
reference_prefix: &str,
|
|
inherited_lock: bool,
|
|
) -> Result<(), String> {
|
|
for reference in &composition.subscenes {
|
|
if !reference.visible {
|
|
continue;
|
|
}
|
|
let target = resolve_subscene_path(project_root, &reference.path)?;
|
|
if let Some(index) = visiting.iter().position(|path| path == &target) {
|
|
let mut cycle: Vec<String> = visiting[index..]
|
|
.iter()
|
|
.map(|path| {
|
|
path.strip_prefix(project_root)
|
|
.unwrap_or(path)
|
|
.display()
|
|
.to_string()
|
|
})
|
|
.collect();
|
|
cycle.push(reference.path.clone());
|
|
return Err(format!("cyclic subscene reference: {}", cycle.join(" -> ")));
|
|
}
|
|
|
|
let text = std::fs::read_to_string(&target)
|
|
.map_err(|error| format!("could not read {}: {error}", target.display()))?;
|
|
let document = SceneDocument::from_ron_text(&text)?;
|
|
let scene_id = document
|
|
.composition
|
|
.as_ref()
|
|
.map(|composition| composition.scene_id.clone())
|
|
.unwrap_or_else(|| legacy_scene_composition(Some(&target)).scene_id);
|
|
let bevy_ron = strip_schema_version(document.normalized_ron())?;
|
|
let mut dynamic_scene = deserialize_dynamic_scene(world, &bevy_ron)?;
|
|
dynamic_scene.resources.clear();
|
|
let mut entity_map = EntityHashMap::default();
|
|
dynamic_scene
|
|
.write_to_world(world, &mut entity_map)
|
|
.map_err(|error| format!("could not spawn subscene {}: {error}", target.display()))?;
|
|
|
|
let reference_id = if reference_prefix.is_empty() {
|
|
reference.id.clone()
|
|
} else {
|
|
format!("{reference_prefix}/{}", reference.id)
|
|
};
|
|
let locked = inherited_lock || reference.locked;
|
|
let loaded_entities: Vec<Entity> = entity_map.values().copied().collect();
|
|
for entity in &loaded_entities {
|
|
world.entity_mut(*entity).insert(ComposedSceneMember {
|
|
reference_id: reference_id.clone(),
|
|
scene_id: scene_id.clone(),
|
|
source_path: target.clone(),
|
|
});
|
|
}
|
|
if locked {
|
|
if let Some(mut hierarchy) =
|
|
world.get_resource_mut::<crate::ui::hierarchy_state::HierarchyPanelState>()
|
|
{
|
|
hierarchy.locked.extend(loaded_entities.iter().copied());
|
|
}
|
|
}
|
|
|
|
if let Some(child_composition) = document.composition {
|
|
visiting.push(target);
|
|
load_composed_subscenes(
|
|
world,
|
|
&child_composition,
|
|
project_root,
|
|
visiting,
|
|
&reference_id,
|
|
locked,
|
|
)?;
|
|
visiting.pop();
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn finalize_scene_load(world: &mut World) {
|
|
let rendering = world
|
|
.resource::<settings::ProjectSettings>()
|
|
.rendering
|
|
.clone();
|
|
world.insert_resource(GlobalAmbientLight {
|
|
color: Color::srgb(
|
|
rendering.ambient_color[0],
|
|
rendering.ambient_color[1],
|
|
rendering.ambient_color[2],
|
|
),
|
|
brightness: rendering.ambient_brightness,
|
|
..default()
|
|
});
|
|
|
|
let mut state: SystemState<(
|
|
Res<settings::ProjectSettings>,
|
|
Query<
|
|
(
|
|
&LightDesc,
|
|
Option<&AuthoringComponentStates>,
|
|
Option<&InspectorOrder>,
|
|
),
|
|
With<LevelObject>,
|
|
>,
|
|
Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
|
|
)> = SystemState::new(world);
|
|
|
|
{
|
|
let (settings, scene_suns, project_suns) = state
|
|
.get_mut(world)
|
|
.expect("finalize_scene_load system params should be valid");
|
|
game_hot::sync_project_sun_from_settings(settings, scene_suns, project_suns);
|
|
}
|
|
state.apply(world);
|
|
}
|
|
|
|
fn backfill_missing_actor_kinds(world: &mut World) {
|
|
let mut query = world.query_filtered::<Entity, With<LevelObject>>();
|
|
let entities: Vec<Entity> = query.iter(world).collect();
|
|
for entity in entities {
|
|
let Ok(entity_ref) = world.get_entity(entity) else {
|
|
continue;
|
|
};
|
|
let current = entity_ref.get::<ActorKind>().copied();
|
|
if let Some(kind) = infer_actor_kind(entity_ref).filter(|kind| current != Some(*kind)) {
|
|
world.entity_mut(entity).insert(kind);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remember_path(world: &mut World, path: PathBuf) {
|
|
let mut io = world.resource_mut::<SceneIo>();
|
|
io.recent_paths.retain(|recent| recent != &path);
|
|
io.recent_paths.insert(0, path.clone());
|
|
io.recent_paths.truncate(8);
|
|
|
|
if world.contains_resource::<crate::project_io::UserPreferences>() {
|
|
let path_label = path.display().to_string();
|
|
let mut prefs = world.resource_mut::<crate::project_io::UserPreferences>();
|
|
crate::project_io::push_recent(&mut prefs.recent_levels, path_label, 8);
|
|
if let Err(error) = crate::project_io::write_user_preferences(&prefs) {
|
|
warn!("Failed to save editor preferences: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_window_title(
|
|
io: Res<SceneIo>,
|
|
workspace: Res<crate::project_io::ProjectWorkspace>,
|
|
settings: Res<settings::ProjectSettings>,
|
|
settings_io: Res<settings::ProjectSettingsIo>,
|
|
mut window: Single<&mut Window, With<PrimaryWindow>>,
|
|
) {
|
|
if !io.is_changed()
|
|
&& !workspace.is_changed()
|
|
&& !settings.is_changed()
|
|
&& !settings_io.is_changed()
|
|
{
|
|
return;
|
|
}
|
|
|
|
window.title = crate::project_io::window_title(&io, &workspace, &settings);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::any::TypeId;
|
|
|
|
use bevy::asset::{AssetPath, LoadFromPath, UntypedHandle};
|
|
use bevy::world_serialization::{
|
|
DynamicWorldRoot, WorldInstanceSpawner, WorldSerializationPlugin,
|
|
};
|
|
|
|
struct NoAssetLoads;
|
|
|
|
#[derive(Component, Reflect, Default)]
|
|
#[reflect(Component, Default)]
|
|
struct RegisteredSceneExtension {
|
|
value: u32,
|
|
}
|
|
|
|
impl LoadFromPath for NoAssetLoads {
|
|
fn load_from_path_erased(
|
|
&mut self,
|
|
_type_id: TypeId,
|
|
_path: AssetPath<'static>,
|
|
) -> UntypedHandle {
|
|
panic!("hierarchy fixture does not contain asset handles")
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn registry_registered_extension_is_included_in_scene_persistence() {
|
|
let mut app = App::new();
|
|
app.register_type::<Name>()
|
|
.register_type::<Transform>()
|
|
.register_type::<LevelObject>()
|
|
.register_type::<ActorKind>()
|
|
.register_type::<SceneComposition>()
|
|
.register_type::<RegisteredSceneExtension>();
|
|
let mut component_registry =
|
|
crate::ui::component_registry::EditorComponentRegistry::default();
|
|
let mut descriptor = component_registry.descriptors[0].clone();
|
|
descriptor.id = "game.registered_scene_extension";
|
|
descriptor.type_name = std::any::type_name::<RegisteredSceneExtension>();
|
|
descriptor.display_name = "Registered Scene Extension";
|
|
descriptor.recommended = &[];
|
|
descriptor.conflicts_with = &[];
|
|
component_registry.register(descriptor).unwrap();
|
|
app.insert_resource(component_registry);
|
|
app.insert_resource(SceneComposition::default());
|
|
let entity = app
|
|
.world_mut()
|
|
.spawn((
|
|
Name::new("Extension"),
|
|
Transform::default(),
|
|
LevelObject,
|
|
ActorKind::Empty,
|
|
RegisteredSceneExtension { value: 73 },
|
|
))
|
|
.id();
|
|
|
|
let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();
|
|
|
|
assert!(text.contains(std::any::type_name::<RegisteredSceneExtension>()));
|
|
assert!(text.contains("value: 73") || text.contains("value:73"));
|
|
}
|
|
|
|
#[test]
|
|
fn restored_recovery_remains_available_until_save_or_discard() {
|
|
let authored = PathBuf::from("assets/levels/authored.scn.ron");
|
|
let snapshot = PathBuf::from("/state/recovery/snapshot.scn.ron");
|
|
let mut io = SceneIo {
|
|
active_path: None,
|
|
recovery_snapshot: Some(snapshot.clone()),
|
|
..default()
|
|
};
|
|
|
|
mark_recovery_restored(&mut io, Some(authored.clone()));
|
|
|
|
assert_eq!(io.active_path, Some(authored));
|
|
assert_eq!(io.recovery_snapshot, Some(snapshot));
|
|
assert!(io.dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn active_tab_tracks_path_and_dirty_state() {
|
|
let mut io = SceneIo::default();
|
|
assert_eq!(io.tabs.len(), 1);
|
|
assert!(!io.tabs[0].dirty);
|
|
let initial_revision = io.change_revision();
|
|
|
|
io.active_path = Some(PathBuf::from("assets/levels/arena.scn.ron"));
|
|
io.mark_dirty();
|
|
|
|
assert!(io.has_unsaved_tabs());
|
|
assert!(io.change_revision() > initial_revision);
|
|
assert!(io.tabs[0].dirty);
|
|
assert_eq!(
|
|
io.tabs[0].path.as_deref(),
|
|
Some(Path::new("assets/levels/arena.scn.ron"))
|
|
);
|
|
io.mark_clean();
|
|
assert!(!io.has_unsaved_tabs());
|
|
|
|
io.tabs.push(SceneTab {
|
|
id: 2,
|
|
path: Some(PathBuf::from("assets/levels/lighting.scn.ron")),
|
|
dirty: true,
|
|
snapshot: "dirty inactive tab".to_string(),
|
|
recovery_snapshot: None,
|
|
disk_snapshot: None,
|
|
});
|
|
assert!(
|
|
io.has_unsaved_tabs(),
|
|
"inactive modified tabs must participate in save/recovery prompts"
|
|
);
|
|
let candidates = dirty_saved_tab_snapshots(&io);
|
|
assert_eq!(candidates.len(), 1);
|
|
assert_eq!(candidates[0].0, 1);
|
|
assert_eq!(
|
|
candidates[0].1,
|
|
PathBuf::from("assets/levels/lighting.scn.ron")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_scene_write_refuses_an_external_revision() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"blacksite-scene-external-write-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let path = root.join("assets/levels/main.scn.ron");
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(&path, b"loaded").unwrap();
|
|
let disk_snapshot = FileSnapshot::capture(&path).unwrap();
|
|
let mut io = SceneIo {
|
|
active_path: Some(path.clone()),
|
|
..Default::default()
|
|
};
|
|
io.tabs[0].path = Some(path.clone());
|
|
io.tabs[0].disk_snapshot = Some(disk_snapshot);
|
|
let mut world = World::new();
|
|
world.insert_resource(io);
|
|
std::fs::write(&path, b"external").unwrap();
|
|
|
|
let result = publish_scene_text(&mut world, &path, b"editor", 1, SceneWriteContext::Active);
|
|
|
|
assert!(result.unwrap_err().contains("changed outside Blacksite"));
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"external");
|
|
assert!(world.resource::<SceneIo>().dirty);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn scene_save_as_refuses_a_file_created_after_destination_selection() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"blacksite-scene-save-as-race-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let path = root.join("assets/levels/new.scn.ron");
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
let expected = FileSnapshot::missing();
|
|
let mut world = World::new();
|
|
world.init_resource::<SceneIo>();
|
|
std::fs::write(&path, b"external").unwrap();
|
|
|
|
let result = publish_scene_text(
|
|
&mut world,
|
|
&path,
|
|
b"editor",
|
|
1,
|
|
SceneWriteContext::ActiveSaveAs { expected },
|
|
);
|
|
|
|
assert!(result.unwrap_err().contains("changed outside Blacksite"));
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"external");
|
|
assert!(world.resource::<SceneIo>().dirty);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn composition_edits_participate_in_undo_and_redo() {
|
|
let mut app = App::new();
|
|
app.init_resource::<SceneIo>()
|
|
.init_resource::<EditorHistory>()
|
|
.insert_resource(SceneComposition {
|
|
scene_id: "main".to_string(),
|
|
subscenes: Vec::new(),
|
|
});
|
|
let changed = SceneComposition {
|
|
scene_id: "main".to_string(),
|
|
subscenes: vec![shared::SubsceneReference {
|
|
id: "lighting".to_string(),
|
|
path: "assets/levels/lighting.scn.ron".to_string(),
|
|
visible: true,
|
|
locked: true,
|
|
}],
|
|
};
|
|
|
|
crate::history::set_scene_composition_with_history(app.world_mut(), changed.clone());
|
|
assert_eq!(app.world().resource::<EditorHistory>().undo_depth(), 1);
|
|
assert_eq!(app.world().resource::<SceneComposition>(), &changed);
|
|
|
|
crate::history::apply_command_undo(app.world_mut());
|
|
assert!(app
|
|
.world()
|
|
.resource::<SceneComposition>()
|
|
.subscenes
|
|
.is_empty());
|
|
crate::history::apply_command_redo(app.world_mut());
|
|
assert_eq!(app.world().resource::<SceneComposition>(), &changed);
|
|
}
|
|
|
|
#[test]
|
|
fn scene_finalize_defers_hydration_to_the_registered_update_pipeline() {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(AssetPlugin::default())
|
|
.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
|
|
.init_asset::<Mesh>()
|
|
.add_plugins(shared::SharedTypesPlugin);
|
|
app.world_mut()
|
|
.insert_resource(settings::ProjectSettings::default());
|
|
|
|
let actor = app
|
|
.world_mut()
|
|
.spawn((
|
|
LevelObject,
|
|
BrushDesc::default(),
|
|
ColliderDesc::static_cuboid(Vec3::ONE),
|
|
))
|
|
.id();
|
|
|
|
finalize_scene_load(app.world_mut());
|
|
assert!(
|
|
app.world().get::<Children>(actor).is_none(),
|
|
"scene finalization must not create collider children ahead of Update"
|
|
);
|
|
|
|
app.update();
|
|
let first_child = app
|
|
.world()
|
|
.get::<Children>(actor)
|
|
.and_then(|children| children.first().copied())
|
|
.expect("the registered hydration pipeline should create one brush child");
|
|
assert!(app
|
|
.world()
|
|
.get::<avian3d::prelude::ColliderConstructor>(first_child)
|
|
.is_some());
|
|
|
|
app.update();
|
|
assert_eq!(
|
|
app.world()
|
|
.get::<Children>(actor)
|
|
.and_then(|children| children.first().copied()),
|
|
Some(first_child),
|
|
"unchanged authoring data must not replace freshly hydrated collider children"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scene_serialization_persists_composition_without_composed_members() {
|
|
let mut app = App::new();
|
|
app.register_type::<Name>()
|
|
.register_type::<Transform>()
|
|
.register_type::<LevelObject>()
|
|
.register_type::<ActorKind>()
|
|
.register_type::<SceneComposition>()
|
|
.register_type::<shared::SubsceneReference>();
|
|
let world = app.world_mut();
|
|
world.insert_resource(SceneComposition {
|
|
scene_id: "main".to_string(),
|
|
subscenes: vec![shared::SubsceneReference {
|
|
id: "geometry".to_string(),
|
|
path: "assets/levels/geometry.scn.ron".to_string(),
|
|
visible: true,
|
|
locked: true,
|
|
}],
|
|
});
|
|
let owned = world
|
|
.spawn((
|
|
Name::new("Owned"),
|
|
Transform::default(),
|
|
LevelObject,
|
|
ActorKind::Empty,
|
|
))
|
|
.id();
|
|
world.spawn((
|
|
Name::new("Composed"),
|
|
Transform::default(),
|
|
LevelObject,
|
|
ActorKind::Empty,
|
|
ComposedSceneMember {
|
|
reference_id: "geometry".to_string(),
|
|
scene_id: "geometry-scene".to_string(),
|
|
source_path: PathBuf::from("assets/levels/geometry.scn.ron"),
|
|
},
|
|
));
|
|
|
|
let text = serialize_entities_inner(world, vec![owned]).unwrap();
|
|
let document = SceneDocument::from_ron_text(&text).unwrap();
|
|
|
|
assert_eq!(document.entities.len(), 1);
|
|
assert_eq!(document.entities[0].name.as_deref(), Some("Owned"));
|
|
assert_eq!(
|
|
document
|
|
.composition
|
|
.as_ref()
|
|
.map(|value| value.scene_id.as_str()),
|
|
Some("main")
|
|
);
|
|
assert_eq!(document.composition.unwrap().subscenes.len(), 1);
|
|
|
|
let standalone =
|
|
serialize_entities_inner_with_resources(world, vec![owned], false).unwrap();
|
|
let standalone = SceneDocument::from_ron_text(&standalone).unwrap();
|
|
assert!(
|
|
standalone.composition.is_none(),
|
|
"prefabs and selection exports must not carry level resources"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_authoring_round_trip_excludes_runtime_playback_components() {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(shared::SharedTypesPlugin);
|
|
app.world_mut().insert_resource(SceneComposition::default());
|
|
let source = AudioSourceDesc {
|
|
clip: Some(
|
|
shared::EditorAssetRef::new("audio-id", shared::AUDIO_CLIP_SUB_ASSET_ID, "Impact")
|
|
.with_source_path("assets/audio/impact.ogg"),
|
|
),
|
|
..Default::default()
|
|
};
|
|
let entity = app
|
|
.world_mut()
|
|
.spawn((
|
|
Name::new("Audio Source"),
|
|
Transform::IDENTITY,
|
|
LevelObject,
|
|
ActorId::new("audio-source"),
|
|
ActorKind::AudioSource,
|
|
source.clone(),
|
|
))
|
|
.id();
|
|
|
|
let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();
|
|
let document = SceneDocument::from_ron_text(&text).unwrap();
|
|
let component = document.entities[0]
|
|
.components
|
|
.iter()
|
|
.find(|component| component.type_name == shared::COMPONENT_AUDIO_SOURCE_DESC)
|
|
.expect("audio source descriptor persisted");
|
|
let decoded: AudioSourceDesc = ron::from_str(&component.ron).unwrap();
|
|
|
|
assert_eq!(decoded, source);
|
|
assert!(!text.contains("bevy_audio::"));
|
|
assert!(!text.contains("AuthoredAudioVoice"));
|
|
}
|
|
|
|
#[test]
|
|
fn animation_controller_round_trip_excludes_runtime_animation_components() {
|
|
use bevy::animation::prelude::{
|
|
AnimationGraphHandle, AnimationPlayer, AnimationTransitions,
|
|
};
|
|
use shared::{
|
|
animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationPlaybackRange,
|
|
AnimationStateDesc, EditorAssetRef,
|
|
};
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(shared::SharedTypesPlugin);
|
|
app.world_mut().insert_resource(SceneComposition::default());
|
|
let source_path = "assets/models/animated.glb";
|
|
let controller = AnimationControllerDesc {
|
|
skeleton: Some(
|
|
EditorAssetRef::new(
|
|
"animated-model-id",
|
|
animation_skeleton_sub_asset_id(0, "Rig"),
|
|
"Rig",
|
|
)
|
|
.with_source_path(source_path),
|
|
),
|
|
states: vec![AnimationStateDesc {
|
|
id: "locomotion.idle".into(),
|
|
label: "Idle".into(),
|
|
clip: EditorAssetRef::new(
|
|
"animated-model-id",
|
|
animation_clip_sub_asset_id(0, "Idle"),
|
|
"Idle",
|
|
)
|
|
.with_source_path(source_path),
|
|
looping: true,
|
|
speed: 1.0,
|
|
range: AnimationPlaybackRange {
|
|
start_seconds: 0.1,
|
|
end_seconds: Some(1.25),
|
|
},
|
|
}],
|
|
default_state: "locomotion.idle".into(),
|
|
default_crossfade_seconds: 0.15,
|
|
};
|
|
let entity = app
|
|
.world_mut()
|
|
.spawn((
|
|
Name::new("Animated Model"),
|
|
Transform::IDENTITY,
|
|
LevelObject,
|
|
ActorId::new("animated-model"),
|
|
ActorKind::SkinnedMesh,
|
|
SkinnedMeshRenderer::new(source_path),
|
|
controller.clone(),
|
|
AnimationPlayer::default(),
|
|
AnimationGraphHandle::default(),
|
|
AnimationTransitions::new(),
|
|
))
|
|
.id();
|
|
|
|
let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();
|
|
let document = SceneDocument::from_ron_text(&text).unwrap();
|
|
let component = document.entities[0]
|
|
.components
|
|
.iter()
|
|
.find(|component| component.type_name == shared::COMPONENT_ANIMATION_CONTROLLER_DESC)
|
|
.expect("animation controller descriptor persisted");
|
|
let decoded: AnimationControllerDesc = ron::from_str(&component.ron).unwrap();
|
|
|
|
assert_eq!(decoded, controller);
|
|
assert!(text.contains("shared::animation::SkinnedMeshRenderer"));
|
|
assert!(!text.contains("shared::components::ModelRef"));
|
|
assert!(!text.contains("AnimationPlayer"));
|
|
assert!(!text.contains("AnimationGraphHandle"));
|
|
assert!(!text.contains("AnimationTransitions"));
|
|
}
|
|
|
|
#[test]
|
|
fn linked_prefab_members_are_not_authored_scene_entities() {
|
|
let mut world = World::new();
|
|
let root = world.spawn((LevelObject, ActorKind::PrefabAnchor)).id();
|
|
let authored = world.spawn((LevelObject, ActorKind::Empty)).id();
|
|
let generated = world
|
|
.spawn((
|
|
LevelObject,
|
|
ActorKind::Empty,
|
|
HydratedPrefabMember {
|
|
instance_root: root,
|
|
},
|
|
))
|
|
.id();
|
|
let entities = authored_scene_entities(&mut world);
|
|
|
|
assert!(entities.contains(&root));
|
|
assert!(entities.contains(&authored));
|
|
assert!(!entities.contains(&generated));
|
|
}
|
|
|
|
#[test]
|
|
fn serializing_prefab_anchor_preserves_live_world_instance() {
|
|
let mut app = App::new();
|
|
app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin))
|
|
.register_type::<Name>()
|
|
.register_type::<Transform>()
|
|
.register_type::<LevelObject>()
|
|
.register_type::<ActorId>()
|
|
.register_type::<ActorKind>()
|
|
.register_type::<PrefabInstance>();
|
|
|
|
let mut source = World::new();
|
|
source.spawn((LevelObject, ActorId::new("source-child")));
|
|
let dynamic_world = DynamicWorld::from_world_with(
|
|
&source,
|
|
&app.world().resource::<AppTypeRegistry>().read(),
|
|
);
|
|
let handle = app
|
|
.world_mut()
|
|
.resource_mut::<Assets<DynamicWorld>>()
|
|
.add(dynamic_world);
|
|
let root = app
|
|
.world_mut()
|
|
.spawn((
|
|
Name::new("Linked Prefab"),
|
|
Transform::default(),
|
|
LevelObject,
|
|
ActorId::new("prefab-anchor"),
|
|
ActorKind::PrefabAnchor,
|
|
PrefabInstance::new("prefab", "assets/prefabs/test.scn.ron"),
|
|
DynamicWorldRoot(handle),
|
|
))
|
|
.id();
|
|
|
|
app.update();
|
|
|
|
let instance_id = **app
|
|
.world()
|
|
.get::<WorldInstance>(root)
|
|
.expect("prefab should be registered before save");
|
|
let spawned_before: Vec<_> = app
|
|
.world()
|
|
.resource::<WorldInstanceSpawner>()
|
|
.iter_instance_entities(instance_id)
|
|
.collect();
|
|
assert_eq!(spawned_before.len(), 1);
|
|
|
|
serialize_entities_inner_with_resources(app.world_mut(), vec![root], false).unwrap();
|
|
|
|
assert!(app.world().get::<DynamicWorldRoot>(root).is_some());
|
|
assert_eq!(
|
|
app.world().get::<WorldInstance>(root).map(|value| **value),
|
|
Some(instance_id)
|
|
);
|
|
let spawner = app.world().resource::<WorldInstanceSpawner>();
|
|
assert!(spawner.instance_is_ready(instance_id));
|
|
assert_eq!(
|
|
spawner
|
|
.iter_instance_entities(instance_id)
|
|
.collect::<Vec<_>>(),
|
|
spawned_before
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prefab_export_expands_local_children_and_nested_instances_only() {
|
|
let mut world = World::new();
|
|
let root = world.spawn((LevelObject, PrefabInstance::default())).id();
|
|
let local = world.spawn((LevelObject, ChildOf(root))).id();
|
|
let nested = world
|
|
.spawn((LevelObject, PrefabInstance::default(), ChildOf(local)))
|
|
.id();
|
|
let generated = world
|
|
.spawn((
|
|
LevelObject,
|
|
ChildOf(root),
|
|
HydratedPrefabMember {
|
|
instance_root: root,
|
|
},
|
|
))
|
|
.id();
|
|
let generated_descendant = world.spawn((LevelObject, ChildOf(generated))).id();
|
|
|
|
let expanded = expand_authored_selection(&world, &[root]);
|
|
|
|
assert!(expanded.contains(&root));
|
|
assert!(expanded.contains(&local));
|
|
assert!(expanded.contains(&nested));
|
|
assert!(!expanded.contains(&generated));
|
|
assert!(!expanded.contains(&generated_descendant));
|
|
}
|
|
|
|
#[test]
|
|
fn standalone_export_detaches_external_parent_and_restores_live_hierarchy() {
|
|
let mut app = App::new();
|
|
app.register_type::<Name>()
|
|
.register_type::<Transform>()
|
|
.register_type::<ChildOf>()
|
|
.register_type::<LevelObject>()
|
|
.register_type::<ActorId>()
|
|
.register_type::<ActorKind>();
|
|
let world = app.world_mut();
|
|
let parent = world
|
|
.spawn((
|
|
Name::new("Scene Parent"),
|
|
Transform::from_xyz(10.0, 0.0, 0.0),
|
|
LevelObject,
|
|
ActorId::new("parent"),
|
|
ActorKind::Empty,
|
|
))
|
|
.id();
|
|
let child = world
|
|
.spawn((
|
|
Name::new("Exported Root"),
|
|
Transform::from_xyz(1.0, 2.0, 3.0),
|
|
LevelObject,
|
|
ActorId::new("child"),
|
|
ActorKind::Empty,
|
|
ChildOf(parent),
|
|
))
|
|
.id();
|
|
|
|
let text = serialize_entities_inner_with_resources(world, vec![child], false).unwrap();
|
|
|
|
assert!(!text.contains("bevy_ecs::hierarchy::ChildOf"));
|
|
assert_eq!(world.get::<ChildOf>(child).unwrap().parent(), parent);
|
|
assert_eq!(
|
|
world.get::<Transform>(child).unwrap().translation,
|
|
Vec3::new(1.0, 2.0, 3.0),
|
|
"standalone export must restore the live local transform"
|
|
);
|
|
let document = SceneDocument::from_ron_text(&text).unwrap();
|
|
assert_eq!(document.entities.len(), 1);
|
|
assert_eq!(document.entities[0].actor_id.as_deref(), Some("child"));
|
|
let transform = document.entities[0]
|
|
.components
|
|
.iter()
|
|
.find(|component| component.type_name == scene::document::TRANSFORM_COMPONENT)
|
|
.and_then(|component| ron::from_str::<Transform>(&component.ron).ok())
|
|
.expect("standalone root should retain a serialized transform");
|
|
assert_eq!(transform.translation, Vec3::new(11.0, 2.0, 3.0));
|
|
}
|
|
|
|
#[test]
|
|
fn prefab_export_backfills_missing_and_duplicate_actor_ids() {
|
|
let mut world = World::new();
|
|
let existing = world.spawn((LevelObject, ActorId::new("occupied"))).id();
|
|
let duplicate = world.spawn((LevelObject, ActorId::new("occupied"))).id();
|
|
let missing = world.spawn(LevelObject).id();
|
|
|
|
assert_eq!(
|
|
ensure_unique_actor_ids(&mut world, &[duplicate, missing]),
|
|
2
|
|
);
|
|
|
|
let existing_id = world.get::<ActorId>(existing).unwrap().0.clone();
|
|
let duplicate_id = world.get::<ActorId>(duplicate).unwrap().0.clone();
|
|
let missing_id = world.get::<ActorId>(missing).unwrap().0.clone();
|
|
assert_eq!(existing_id, "occupied");
|
|
assert_ne!(duplicate_id, existing_id);
|
|
assert_ne!(missing_id, existing_id);
|
|
assert_ne!(missing_id, duplicate_id);
|
|
}
|
|
|
|
#[test]
|
|
fn scene_serialization_round_trip_preserves_parent_and_manual_sibling_order() {
|
|
let mut app = App::new();
|
|
app.register_type::<Name>()
|
|
.register_type::<Transform>()
|
|
.register_type::<ChildOf>()
|
|
.register_type::<LevelObject>()
|
|
.register_type::<ActorKind>()
|
|
.register_type::<HierarchySiblingIndex>();
|
|
let world = app.world_mut();
|
|
let parent = world
|
|
.spawn((
|
|
Name::new("Parent"),
|
|
Transform::default(),
|
|
LevelObject,
|
|
ActorKind::Empty,
|
|
HierarchySiblingIndex(0),
|
|
))
|
|
.id();
|
|
let second = world
|
|
.spawn((
|
|
Name::new("Second"),
|
|
Transform::default(),
|
|
LevelObject,
|
|
ActorKind::Empty,
|
|
HierarchySiblingIndex(1),
|
|
ChildOf(parent),
|
|
))
|
|
.id();
|
|
let first = world
|
|
.spawn((
|
|
Name::new("First"),
|
|
Transform::default(),
|
|
LevelObject,
|
|
ActorKind::Empty,
|
|
HierarchySiblingIndex(0),
|
|
ChildOf(parent),
|
|
))
|
|
.id();
|
|
|
|
let text = serialize_entities_inner(world, vec![parent, second, first]).unwrap();
|
|
world.despawn(first);
|
|
world.despawn(second);
|
|
world.despawn(parent);
|
|
|
|
let bevy_ron = strip_schema_version(
|
|
SceneDocument::from_ron_text(&text)
|
|
.unwrap()
|
|
.normalized_ron(),
|
|
)
|
|
.unwrap();
|
|
let dynamic_world: DynamicWorld = {
|
|
let registry = world.resource::<AppTypeRegistry>().read();
|
|
let mut no_assets = NoAssetLoads;
|
|
let mut deserializer = ron::de::Deserializer::from_str(&bevy_ron).unwrap();
|
|
WorldDeserializer {
|
|
type_registry: ®istry,
|
|
load_from_path: &mut no_assets,
|
|
}
|
|
.deserialize(&mut deserializer)
|
|
.unwrap()
|
|
};
|
|
dynamic_world
|
|
.write_to_world(world, &mut EntityHashMap::default())
|
|
.unwrap();
|
|
|
|
let mut query = world.query::<(Entity, &Name, &HierarchySiblingIndex, Option<&ChildOf>)>();
|
|
let rows: Vec<_> = query
|
|
.iter(world)
|
|
.map(|(entity, name, index, parent)| {
|
|
(
|
|
entity,
|
|
name.to_string(),
|
|
index.0,
|
|
parent.map(ChildOf::parent),
|
|
)
|
|
})
|
|
.collect();
|
|
let parent = rows
|
|
.iter()
|
|
.find(|(_, name, _, _)| name == "Parent")
|
|
.map(|(entity, _, _, _)| *entity)
|
|
.unwrap();
|
|
let first = rows.iter().find(|(_, name, _, _)| name == "First").unwrap();
|
|
let second = rows
|
|
.iter()
|
|
.find(|(_, name, _, _)| name == "Second")
|
|
.unwrap();
|
|
|
|
assert_eq!((first.2, first.3), (0, Some(parent)));
|
|
assert_eq!((second.2, second.3), (1, Some(parent)));
|
|
}
|
|
|
|
#[test]
|
|
fn composed_subscene_loads_with_stable_ownership_and_lock() {
|
|
let root =
|
|
std::env::temp_dir().join(format!("blacksite-composed-load-{}", uuid::Uuid::new_v4()));
|
|
let levels = root.join("assets/levels");
|
|
std::fs::create_dir_all(&levels).unwrap();
|
|
std::fs::write(root.join("assets/project.ron"), "()").unwrap();
|
|
let main_path = levels.join("main.scn.ron");
|
|
let child_path = levels.join("geometry.scn.ron");
|
|
std::fs::write(
|
|
&main_path,
|
|
r#"(schema_version: 2,
|
|
resources: {
|
|
"shared::components::SceneComposition": (
|
|
scene_id: "main-scene",
|
|
subscenes: [(
|
|
id: "geometry-ref",
|
|
path: "assets/levels/geometry.scn.ron",
|
|
visible: true,
|
|
locked: true,
|
|
)],
|
|
),
|
|
},
|
|
entities: {},
|
|
)"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
&child_path,
|
|
r#"(schema_version: 2,
|
|
resources: {
|
|
"shared::components::SceneComposition": (
|
|
scene_id: "geometry-scene",
|
|
subscenes: [],
|
|
),
|
|
},
|
|
entities: {
|
|
1: (components: {
|
|
"bevy_ecs::name::Name": "Composed Geometry",
|
|
"bevy_transform::components::transform::Transform": (
|
|
translation: (0.0, 0.0, 0.0),
|
|
rotation: (0.0, 0.0, 0.0, 1.0),
|
|
scale: (1.0, 1.0, 1.0),
|
|
),
|
|
"shared::components::LevelObject": (),
|
|
"shared::components::ActorKind": Empty,
|
|
}),
|
|
},
|
|
)"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(AssetPlugin::default())
|
|
.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
|
|
.init_asset::<Mesh>()
|
|
.add_plugins(shared::SharedTypesPlugin);
|
|
app.world_mut()
|
|
.insert_resource(settings::ProjectSettings::default());
|
|
app.world_mut()
|
|
.insert_resource(crate::project_io::ProjectWorkspace {
|
|
root: root.display().to_string(),
|
|
..Default::default()
|
|
});
|
|
app.world_mut()
|
|
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
|
|
|
|
load_level(app.world_mut(), &main_path).unwrap();
|
|
|
|
let world = app.world_mut();
|
|
assert_eq!(world.resource::<SceneComposition>().scene_id, "main-scene");
|
|
let mut query = world.query::<(Entity, &Name, &ComposedSceneMember)>();
|
|
let rows: Vec<_> = query
|
|
.iter(world)
|
|
.map(|(entity, name, member)| {
|
|
(
|
|
entity,
|
|
name.to_string(),
|
|
member.reference_id.clone(),
|
|
member.scene_id.clone(),
|
|
member.source_path.clone(),
|
|
)
|
|
})
|
|
.collect();
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].1, "Composed Geometry");
|
|
assert_eq!(rows[0].2, "geometry-ref");
|
|
assert_eq!(rows[0].3, "geometry-scene");
|
|
assert_eq!(rows[0].4, child_path.canonicalize().unwrap());
|
|
assert!(world
|
|
.resource::<crate::ui::hierarchy_state::HierarchyPanelState>()
|
|
.locked
|
|
.contains(&rows[0].0));
|
|
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn broken_prefab_source_loads_as_repairable_blocked_anchor() {
|
|
let root =
|
|
std::env::temp_dir().join(format!("blacksite-broken-prefab-{}", uuid::Uuid::new_v4()));
|
|
let levels = root.join("assets/levels");
|
|
std::fs::create_dir_all(&levels).unwrap();
|
|
std::fs::write(root.join("assets/project.ron"), "()").unwrap();
|
|
let level_path = levels.join("main.scn.ron");
|
|
std::fs::write(
|
|
&level_path,
|
|
r#"(schema_version: 2, resources: {}, entities: {
|
|
1: (components: {
|
|
"bevy_ecs::name::Name": "Broken Prefab",
|
|
"bevy_transform::components::transform::Transform": (
|
|
translation: (0.0, 0.0, 0.0),
|
|
rotation: (0.0, 0.0, 0.0, 1.0),
|
|
scale: (1.0, 1.0, 1.0),
|
|
),
|
|
"shared::components::LevelObject": (),
|
|
"shared::components::ActorId": ("broken-anchor"),
|
|
"shared::components::ActorKind": PrefabAnchor,
|
|
"shared::components::PrefabRef": (path: "assets/prefabs/missing.scn.ron"),
|
|
"shared::components::PrefabInstance": (
|
|
asset_id: "missing-asset",
|
|
source_path: "assets/prefabs/missing.scn.ron",
|
|
overrides_ron: None,
|
|
),
|
|
}),
|
|
2: (components: {
|
|
"bevy_ecs::name::Name": "Broken Legacy Prefab",
|
|
"bevy_transform::components::transform::Transform": (
|
|
translation: (0.0, 0.0, 0.0),
|
|
rotation: (0.0, 0.0, 0.0, 1.0),
|
|
scale: (1.0, 1.0, 1.0),
|
|
),
|
|
"shared::components::LevelObject": (),
|
|
"shared::components::ActorId": ("broken-legacy-anchor"),
|
|
"shared::components::ActorKind": PrefabAnchor,
|
|
"shared::components::PrefabRef": (path: "assets/prefabs/legacy-missing.scn.ron"),
|
|
}),
|
|
})"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(AssetPlugin::default())
|
|
.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
|
|
.init_asset::<Mesh>()
|
|
.add_plugins(shared::SharedTypesPlugin);
|
|
app.world_mut()
|
|
.insert_resource(settings::ProjectSettings::default());
|
|
app.world_mut()
|
|
.insert_resource(crate::project_io::ProjectWorkspace {
|
|
root: root.display().to_string(),
|
|
..Default::default()
|
|
});
|
|
app.world_mut()
|
|
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
|
|
|
|
load_level(app.world_mut(), &level_path).unwrap();
|
|
|
|
let world = app.world_mut();
|
|
let blocked: Vec<_> = world
|
|
.query::<(Entity, &PrefabHydrationBlocked)>()
|
|
.iter(world)
|
|
.map(|(entity, blocked)| (entity, blocked.reason.clone()))
|
|
.collect();
|
|
assert_eq!(blocked.len(), 2);
|
|
for (entity, reason) in blocked {
|
|
assert!(reason.contains("missing prefab source"));
|
|
assert!(world.get::<PrefabInstance>(entity).is_some());
|
|
assert!(world.get::<DynamicWorldRoot>(entity).is_none());
|
|
assert!(world.get::<HydratedPrefabReady>(entity).is_none());
|
|
}
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn saving_open_legacy_prefab_repairs_actor_ids() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"blacksite-legacy-prefab-save-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let prefabs = root.join("assets/prefabs");
|
|
std::fs::create_dir_all(&prefabs).unwrap();
|
|
std::fs::write(root.join("assets/project.ron"), "()").unwrap();
|
|
let prefab_path = prefabs.join("legacy.scn.ron");
|
|
std::fs::write(
|
|
&prefab_path,
|
|
r#"(schema_version: 2, resources: {}, entities: {
|
|
1: (components: {
|
|
"bevy_ecs::name::Name": "Missing ID",
|
|
"bevy_transform::components::transform::Transform": (
|
|
translation: (0.0, 0.0, 0.0),
|
|
rotation: (0.0, 0.0, 0.0, 1.0),
|
|
scale: (1.0, 1.0, 1.0),
|
|
),
|
|
"shared::components::LevelObject": (),
|
|
"shared::components::ActorKind": Empty,
|
|
}),
|
|
2: (components: {
|
|
"bevy_ecs::name::Name": "Duplicate A",
|
|
"bevy_transform::components::transform::Transform": (
|
|
translation: (1.0, 0.0, 0.0),
|
|
rotation: (0.0, 0.0, 0.0, 1.0),
|
|
scale: (1.0, 1.0, 1.0),
|
|
),
|
|
"shared::components::LevelObject": (),
|
|
"shared::components::ActorId": ("duplicate"),
|
|
"shared::components::ActorKind": Empty,
|
|
}),
|
|
3: (components: {
|
|
"bevy_ecs::name::Name": "Duplicate B",
|
|
"bevy_transform::components::transform::Transform": (
|
|
translation: (2.0, 0.0, 0.0),
|
|
rotation: (0.0, 0.0, 0.0, 1.0),
|
|
scale: (1.0, 1.0, 1.0),
|
|
),
|
|
"shared::components::LevelObject": (),
|
|
"shared::components::ActorId": ("duplicate"),
|
|
"shared::components::ActorKind": Empty,
|
|
}),
|
|
})"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(AssetPlugin::default())
|
|
.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
|
|
.init_asset::<Mesh>()
|
|
.add_plugins(shared::SharedTypesPlugin);
|
|
app.world_mut()
|
|
.insert_resource(settings::ProjectSettings::default());
|
|
app.world_mut()
|
|
.insert_resource(crate::project_io::ProjectWorkspace {
|
|
root: root.display().to_string(),
|
|
..Default::default()
|
|
});
|
|
app.world_mut()
|
|
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
|
|
app.world_mut().init_resource::<SceneIo>();
|
|
|
|
let expected = load_level(app.world_mut(), &prefab_path).unwrap();
|
|
save_level(
|
|
app.world_mut(),
|
|
&prefab_path,
|
|
SceneWriteContext::ActiveSaveAs { expected },
|
|
)
|
|
.unwrap();
|
|
|
|
scene::validate_prefab_graph(&prefab_path, &root).unwrap();
|
|
let document =
|
|
SceneDocument::from_ron_text(&std::fs::read_to_string(&prefab_path).unwrap()).unwrap();
|
|
let ids: HashSet<_> = document
|
|
.entities
|
|
.iter()
|
|
.map(|entity| entity.actor_id.clone().expect("ActorId must be repaired"))
|
|
.collect();
|
|
assert_eq!(ids.len(), 3);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn scene_io_event_log_is_bounded_and_classifies_failures() {
|
|
let mut io = SceneIo::default();
|
|
for index in 0..(MAX_SCENE_IO_EVENTS + 3) {
|
|
io.set_status(format!("Saved scene {index}"));
|
|
}
|
|
io.set_status("Recovery snapshot failed: disk full");
|
|
|
|
assert_eq!(io.events.len(), MAX_SCENE_IO_EVENTS);
|
|
assert_eq!(
|
|
io.events.back().map(|event| event.severity),
|
|
Some(SceneIoEventSeverity::Error)
|
|
);
|
|
assert_eq!(
|
|
io.events.front().map(|event| event.id),
|
|
Some(5),
|
|
"oldest entries should be evicted first"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_copy_preserves_snapshot_bytes_without_retiring_source() {
|
|
let root =
|
|
std::env::temp_dir().join(format!("blacksite-recovery-copy-{}", uuid::Uuid::new_v4()));
|
|
let snapshot = root.join("recovery.scn.ron");
|
|
let destination = root.join("kept.scn.ron");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(&snapshot, b"recovered scene bytes").unwrap();
|
|
let expected = FileSnapshot::missing();
|
|
let mut world = World::new();
|
|
world.init_resource::<SceneIo>();
|
|
|
|
write_recovery_copy(&mut world, &snapshot, &destination, &expected).unwrap();
|
|
|
|
assert_eq!(
|
|
std::fs::read(&destination).unwrap(),
|
|
b"recovered scene bytes"
|
|
);
|
|
assert!(snapshot.exists());
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
}
|