Blacksite/crates/editor/src/scene/scene_io.rs

3118 lines
108 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::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,
/// When set, the next I/O request needs unsaved-changes confirmation.
pub pending_request: Option<SceneIoRequest>,
/// 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,
pending_request: None,
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 request = {
let mut io = world.resource_mut::<SceneIo>();
if let Some(pending) = io.pending_request.take() {
pending
} else {
match io.request.take() {
Some(req) if io.has_unsaved_tabs() && needs_dirty_confirm(&req) => {
io.pending_request = Some(req);
return;
}
Some(req) => req,
None => return,
}
}
};
if world.resource::<SceneIo>().has_unsaved_tabs() && needs_dirty_confirm(&request) {
match confirm_discard_or_save() {
DirtyConfirm::Save => {
let save_status = save_all_tabs(world);
if save_status.starts_with("Save failed") || save_status.ends_with("cancelled") {
world.resource_mut::<SceneIo>().set_status(save_status);
return;
}
}
DirtyConfirm::Discard => {}
DirtyConfirm::Cancel => {
world
.resource_mut::<SceneIo>()
.set_status("Operation cancelled");
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);
}
enum DirtyConfirm {
Save,
Discard,
Cancel,
}
fn needs_dirty_confirm(request: &SceneIoRequest) -> bool {
matches!(request, SceneIoRequest::SwitchProject)
}
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 confirm_discard_or_save() -> DirtyConfirm {
use rfd::{MessageButtons, MessageDialog, MessageLevel};
match MessageDialog::new()
.set_title("Unsaved Changes")
.set_description("The scene has unsaved changes. Save before continuing?")
.set_level(MessageLevel::Warning)
.set_buttons(MessageButtons::YesNoCancel)
.show()
{
rfd::MessageDialogResult::Yes => DirtyConfirm::Save,
rfd::MessageDialogResult::No => DirtyConfirm::Discard,
_ => DirtyConfirm::Cancel,
}
}
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 {
match confirm_close_scene() {
DirtyConfirm::Save => {
let status = save_active_or_prompt(world);
if status.starts_with("Save failed") || status.ends_with("cancelled") {
return status;
}
}
DirtyConfirm::Discard => {}
DirtyConfirm::Cancel => return "Close scene cancelled".to_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 confirm_close_scene() -> DirtyConfirm {
use rfd::{MessageButtons, MessageDialog, MessageLevel};
match MessageDialog::new()
.set_title("Close Scene")
.set_description("This scene tab has unsaved changes. Save before closing it?")
.set_level(MessageLevel::Warning)
.set_buttons(MessageButtons::YesNoCancel)
.show()
{
rfd::MessageDialogResult::Yes => DirtyConfirm::Save,
rfd::MessageDialogResult::No => DirtyConfirm::Discard,
_ => DirtyConfirm::Cancel,
}
}
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 Some(path) = rfd::FileDialog::new()
.set_directory("assets/prefabs")
.add_filter("Bevy prefab", &["scn.ron", "ron"])
.set_file_name("prefab.scn.ron")
.save_file()
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 Some(path) = rfd::FileDialog::new()
.set_directory("assets/levels")
.add_filter("Bevy scene", &["scn.ron", "ron"])
.set_file_name("editor_scene.scn.ron")
.save_file()
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 Some(path) = rfd::FileDialog::new()
.set_directory("assets/levels")
.add_filter("Bevy scene", &["scn.ron", "ron"])
.pick_file()
else {
return "Open cancelled".to_string();
};
open_path(world, path)
}
fn import_with_dialog(world: &mut World) -> String {
let Some(paths) = rfd::FileDialog::new()
.add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS)
.pick_files()
else {
return "Import cancelled".to_string();
};
match import_external_assets(&paths) {
Ok(count) => {
world.resource_mut::<EditorAssets>().refresh();
format!("Imported {count} asset(s)")
}
Err(err) => format!("Import failed: {err}"),
}
}
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 Some(path) = rfd::FileDialog::new()
.set_directory("assets/levels")
.add_filter("Bevy prefab", &["scn.ron", "ron"])
.set_file_name("selection.scn.ron")
.save_file()
else {
return "Export cancelled".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::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, &registry)
.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(&registry)
.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 Some(destination) = rfd::FileDialog::new()
.set_directory(directory)
.add_filter("Bevy scene", &["scn.ron", "ron"])
.set_file_name(file_name)
.save_file()
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: &registry,
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: &registry,
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();
}
}