Blacksite/crates/editor/src/assets/prefab_overrides.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

4335 lines
159 KiB
Rust

//! Prefab instance overrides — apply/revert per field group (v2).
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use bevy::prelude::*;
use bevy::world_serialization::{
DynamicWorld, DynamicWorldRoot, WorldInstance, WorldInstanceReady,
};
use bevy_egui::egui;
use egui_phosphor_icons::icons;
use scene::document::{SceneComponentBlob, SceneDocument, SceneEntityId, ScenePatch};
use shared::{
decode_prefab_overrides, encode_prefab_overrides, ActorId, EditorVisibility,
HydratedPrefabMember, HydratedPrefabReady, MaterialDesc, PrefabActorPath,
PrefabComponentOverride, PrefabInstance, PrefabOverrides, PrefabPropertyOverride, PrefabRef,
PrefabStructuralOverride,
};
use crate::asset_db::{ensure_asset_record, AssetRegistry};
use crate::history::{
capture_prefab_edit_state, record_prefab_edit_state, record_prefab_source_apply,
};
use crate::ui::inspector::{
apply_component_card_response, component_card, component_card_context, property_row,
ComponentCardOptions,
};
use crate::ui::theme::{ERROR, SUCCESS, TEXT_MUTED, WARNING};
#[derive(Resource, Default)]
pub struct PrefabOverrideClipboard {
pub property_path: String,
pub value_ron: String,
}
#[derive(Resource, Default)]
pub struct PrefabHealthCache {
entries: HashMap<Entity, (Instant, PrefabHealth)>,
}
pub fn prefab_source_revision(project_root: &Path, source_path: &str) -> Result<String, String> {
scene::prefab_graph_revision(&project_root.join(source_path), project_root)
}
fn current_source_revision(world: &World, instance: &PrefabInstance) -> Option<String> {
let root = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))?;
prefab_source_revision(&root, &instance.source_path).ok()
}
fn track_current_source_revision(
world: &World,
instance: &PrefabInstance,
overrides: &mut PrefabOverrides,
) {
overrides.source_revision = current_source_revision(world, instance);
}
#[derive(Debug, Clone)]
enum PrefabHealth {
Ready,
Loading,
Untracked,
ChangedBase,
Conflict(Vec<String>),
Broken(String),
InvalidOverrides(String),
}
fn prefab_health(
world: &mut World,
entity: Entity,
instance: &PrefabInstance,
overrides: &PrefabOverrides,
) -> PrefabHealth {
if let Some(health) = world
.get_resource::<PrefabHealthCache>()
.and_then(|cache| cache.entries.get(&entity))
.filter(|(checked, _)| checked.elapsed() < Duration::from_millis(500))
.map(|(_, health)| health.clone())
{
return health;
}
let health = compute_prefab_health(world, entity, instance, overrides);
if let Some(mut cache) = world.get_resource_mut::<PrefabHealthCache>() {
cache
.entries
.insert(entity, (Instant::now(), health.clone()));
}
health
}
fn compute_prefab_health(
world: &World,
entity: Entity,
instance: &PrefabInstance,
overrides: &PrefabOverrides,
) -> PrefabHealth {
let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
else {
return PrefabHealth::Broken("Project workspace is unavailable".to_string());
};
if let Some(blocked) = world.get::<shared::PrefabHydrationBlocked>(entity) {
return PrefabHealth::Broken(blocked.reason.clone());
}
if let Err(error) =
scene::validate_prefab_graph(&project_root.join(&instance.source_path), &project_root)
{
return PrefabHealth::Broken(error);
}
if world.get::<HydratedPrefabReady>(entity).is_none() {
return PrefabHealth::Loading;
}
let current = prefab_source_revision(&project_root, &instance.source_path).ok();
let known_keys = prefab_legacy_target_keys(&project_root, instance);
let mut stale: Vec<String> = overrides
.child_visibility
.keys()
.filter(|key| !known_keys.contains(*key))
.cloned()
.collect();
stale.extend(prefab_semantic_conflicts(
&project_root,
instance,
overrides,
));
stale.sort();
stale.dedup();
if !stale.is_empty() {
return PrefabHealth::Conflict(stale);
}
match (&overrides.source_revision, current) {
(None, _) => PrefabHealth::Untracked,
(Some(recorded), Some(current)) if recorded != &current => PrefabHealth::ChangedBase,
_ => PrefabHealth::Ready,
}
}
fn prefab_legacy_target_keys(project_root: &Path, instance: &PrefabInstance) -> HashSet<String> {
let direct = PrefabActorPath::direct("");
source_document_for_target(project_root, instance, &direct)
.map(|document| {
document
.entities
.into_iter()
.flat_map(|entity| [entity.actor_id, entity.name])
.flatten()
.collect()
})
.unwrap_or_default()
}
fn prefab_semantic_conflicts(
project_root: &Path,
instance: &PrefabInstance,
overrides: &PrefabOverrides,
) -> Vec<String> {
let mut conflicts = Vec::new();
for property in &overrides.properties {
let source = source_component_for_target(
project_root,
instance,
&property.target,
&property.component_type,
)
.and_then(|source| {
apply_layer_prerequisites(
source,
overrides,
&property.target,
&property.component_type,
)
});
let conflict = match source {
Ok(Some(source)) if property.component_type == std::any::type_name::<Transform>() => {
apply_transform_property(&source.ron, property).is_err()
}
Ok(Some(source))
if property.component_type == std::any::type_name::<MaterialDesc>() =>
{
apply_material_property(&source.ron, property).is_err()
}
Ok(Some(_)) => false,
_ => true,
};
if conflict {
conflicts.push(property_conflict_key(property));
}
}
for component in &overrides.components {
let current = source_component_for_target(
project_root,
instance,
&component.target,
&component.component_type,
)
.ok()
.flatten();
let conflict = match (&component.base_component_ron, current) {
(None, Some(_)) | (Some(_), None) => true,
(Some(base), Some(current)) => {
!component_values_equal(&component.component_type, &current.ron, base)
}
(None, None) => false,
};
if conflict {
conflicts.push(component_conflict_key(component));
}
}
for structural in &overrides.structural {
let target = structural_target(structural);
let document = source_document_for_target(project_root, instance, target);
let source_entity = document.as_ref().ok().and_then(|document| {
document.find_entity(&SceneEntityId::ActorId(target.actor_id.clone()))
});
let conflict = match structural {
PrefabStructuralOverride::RemoveChild {
base_subtree_revision,
..
} => {
source_entity.is_none()
|| match prefab_source_revision(project_root, &instance.source_path) {
Ok(current) => current != *base_subtree_revision,
Err(_) => true,
}
}
PrefabStructuralOverride::ReparentChild {
base_parent,
new_parent,
base_transform_ron,
..
} => {
let parent_changed = match source_parent_for_target(project_root, instance, target)
{
Ok(current) => current != *base_parent,
Err(_) => true,
};
let transform_changed = base_transform_ron.as_ref().is_some_and(|base| {
source_component_for_target(
project_root,
instance,
target,
std::any::type_name::<Transform>(),
)
.ok()
.flatten()
.is_none_or(|current| {
!component_values_equal(
std::any::type_name::<Transform>(),
&current.ron,
base,
)
})
});
let destination_missing = new_parent.as_ref().is_some_and(|parent| {
parent.instance_chain != target.instance_chain
|| source_parent_for_target(project_root, instance, parent).is_err()
});
parent_changed || transform_changed || destination_missing
}
PrefabStructuralOverride::AttachLocalChild { .. } => false,
};
if conflict {
conflicts.push(structural_conflict_key(structural));
}
}
conflicts
}
fn actor_path_key(target: &PrefabActorPath) -> String {
if target.instance_chain.is_empty() {
target.actor_id.clone()
} else {
format!("{}/{}", target.instance_chain.join("/"), target.actor_id)
}
}
fn property_conflict_key(property: &PrefabPropertyOverride) -> String {
format!(
"{}.{}.{}",
actor_path_key(&property.target),
property.component_type,
property.property_path
)
}
fn component_conflict_key(component: &PrefabComponentOverride) -> String {
format!(
"{}.{}",
actor_path_key(&component.target),
component.component_type
)
}
fn structural_conflict_key(structural: &PrefabStructuralOverride) -> String {
format!(
"{}.structure",
actor_path_key(structural_target(structural))
)
}
fn source_document_for_target(
project_root: &Path,
instance: &PrefabInstance,
target: &PrefabActorPath,
) -> Result<SceneDocument, String> {
let mut source_path = instance.source_path.clone();
for anchor_actor_id in &target.instance_chain {
let document = SceneDocument::from_ron_text(
&std::fs::read_to_string(project_root.join(&source_path))
.map_err(|error| error.to_string())?,
)?;
let component = document
.find_entity(&SceneEntityId::ActorId(anchor_actor_id.clone()))
.and_then(|entity| {
entity.components.iter().find(|component| {
component.type_name == std::any::type_name::<PrefabInstance>()
})
})
.ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?;
let nested: PrefabInstance =
ron::from_str(&component.ron).map_err(|error| error.to_string())?;
source_path = nested.source_path;
}
SceneDocument::from_ron_text(
&std::fs::read_to_string(project_root.join(source_path))
.map_err(|error| error.to_string())?,
)
}
fn source_component_for_target(
project_root: &Path,
instance: &PrefabInstance,
target: &PrefabActorPath,
component_type: &str,
) -> Result<Option<SceneComponentBlob>, String> {
let mut source_path = instance.source_path.clone();
let mut remaining_target = target.clone();
let mut layers = Vec::new();
for anchor_actor_id in &target.instance_chain {
let document = SceneDocument::from_ron_text(
&std::fs::read_to_string(project_root.join(&source_path))
.map_err(|error| error.to_string())?,
)?;
let nested_component = document
.find_entity(&SceneEntityId::ActorId(anchor_actor_id.clone()))
.and_then(|entity| {
entity.components.iter().find(|component| {
component.type_name == std::any::type_name::<PrefabInstance>()
})
})
.ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?;
let nested: PrefabInstance =
ron::from_str(&nested_component.ron).map_err(|error| error.to_string())?;
remaining_target.instance_chain.remove(0);
layers.push((decode_prefab_overrides(&nested)?, remaining_target.clone()));
source_path = nested.source_path;
}
let document = SceneDocument::from_ron_text(
&std::fs::read_to_string(project_root.join(source_path))
.map_err(|error| error.to_string())?,
)?;
let mut current = document
.find_entity(&SceneEntityId::ActorId(target.actor_id.clone()))
.and_then(|entity| {
entity
.components
.iter()
.find(|component| component.type_name == component_type)
})
.cloned();
for (overrides, layer_target) in layers.into_iter().rev() {
current = apply_layer_to_component(current, &overrides, &layer_target, component_type)?;
}
Ok(current)
}
fn source_parent_for_target(
project_root: &Path,
instance: &PrefabInstance,
target: &PrefabActorPath,
) -> Result<Option<PrefabActorPath>, String> {
let mut source_path = instance.source_path.clone();
let mut remaining_target = target.clone();
let mut layers = Vec::new();
for anchor_actor_id in &target.instance_chain {
let document = SceneDocument::from_ron_text(
&std::fs::read_to_string(project_root.join(&source_path))
.map_err(|error| error.to_string())?,
)?;
let component = document
.find_entity(&SceneEntityId::ActorId(anchor_actor_id.clone()))
.and_then(|entity| {
entity.components.iter().find(|component| {
component.type_name == std::any::type_name::<PrefabInstance>()
})
})
.ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?;
let nested: PrefabInstance =
ron::from_str(&component.ron).map_err(|error| error.to_string())?;
remaining_target.instance_chain.remove(0);
layers.push((decode_prefab_overrides(&nested)?, remaining_target.clone()));
source_path = nested.source_path;
}
let document = SceneDocument::from_ron_text(
&std::fs::read_to_string(project_root.join(source_path))
.map_err(|error| error.to_string())?,
)?;
let mut parent_actor_id = document
.find_entity(&SceneEntityId::ActorId(target.actor_id.clone()))
.ok_or_else(|| format!("source actor `{}` is missing", actor_path_key(target)))?
.parent_actor_id
.clone();
for (overrides, layer_target) in layers.into_iter().rev() {
for structural in &overrides.structural {
match structural {
PrefabStructuralOverride::RemoveChild { target, .. } if target == &layer_target => {
return Err(format!(
"source actor `{}` was removed by a nested layer",
actor_path_key(target)
));
}
PrefabStructuralOverride::ReparentChild {
target,
base_parent,
new_parent,
..
} if target == &layer_target => {
let expected = base_parent.as_ref().map(|parent| parent.actor_id.as_str());
if parent_actor_id.as_deref() != expected {
return Err(format!(
"nested parent override for `{}` conflicts with its source",
actor_path_key(target)
));
}
parent_actor_id = new_parent.as_ref().map(|parent| parent.actor_id.clone());
}
_ => {}
}
}
}
Ok(parent_actor_id.map(|actor_id| PrefabActorPath {
instance_chain: target.instance_chain.clone(),
actor_id,
}))
}
fn apply_layer_to_component(
current: Option<SceneComponentBlob>,
overrides: &PrefabOverrides,
target: &PrefabActorPath,
component_type: &str,
) -> Result<Option<SceneComponentBlob>, String> {
let mut current = apply_layer_prerequisites(current, overrides, target, component_type)?;
for property in &overrides.properties {
if property.target != *target || property.component_type != component_type {
continue;
}
let source = current.as_ref().ok_or_else(|| {
format!(
"nested property `{}` target is missing component `{component_type}`",
property.property_path
)
})?;
let ron = if component_type == std::any::type_name::<Transform>() {
apply_transform_property(&source.ron, property)?
} else if component_type == std::any::type_name::<MaterialDesc>() {
apply_material_property(&source.ron, property)?
} else {
return Err(format!(
"nested property resolution does not support component `{component_type}`"
));
};
current = Some(SceneComponentBlob::from_ron(component_type, ron)?);
}
if overrides.structural.iter().any(|structural| {
matches!(structural, PrefabStructuralOverride::RemoveChild { target: candidate, .. } if candidate == target)
}) {
return Ok(None);
}
Ok(current)
}
fn apply_layer_prerequisites(
mut current: Option<SceneComponentBlob>,
overrides: &PrefabOverrides,
target: &PrefabActorPath,
component_type: &str,
) -> Result<Option<SceneComponentBlob>, String> {
if component_type == std::any::type_name::<Transform>() {
for structural in &overrides.structural {
if let PrefabStructuralOverride::ReparentChild {
target: candidate,
base_transform_ron,
local_transform_ron,
..
} = structural
{
if candidate != target {
continue;
}
if let Some(base) = base_transform_ron {
let current_ron = current.as_ref().map(|component| component.ron.as_str());
if current_ron
.is_none_or(|value| !component_values_equal(component_type, value, base))
{
return Err(format!(
"nested reparent transform for `{}` conflicts with its source",
actor_path_key(target)
));
}
}
if let Some(value) = local_transform_ron {
current = Some(SceneComponentBlob::from_ron(component_type, value.clone())?);
}
}
}
}
for component in &overrides.components {
if component.target != *target || component.component_type != component_type {
continue;
}
let current_ron = current.as_ref().map(|value| value.ron.as_str());
let base_matches = match (&component.base_component_ron, current_ron) {
(None, None) => true,
(Some(base), Some(current)) => component_values_equal(component_type, current, base),
_ => false,
};
if !base_matches {
return Err(format!(
"nested component `{component_type}` for `{}` conflicts with its source",
actor_path_key(target)
));
}
current = component
.value_component_ron
.as_ref()
.map(|value| SceneComponentBlob::from_ron(component_type, value.clone()))
.transpose()?;
}
Ok(current)
}
fn source_target_exists(
project_root: &Path,
instance: &PrefabInstance,
target: &PrefabActorPath,
) -> bool {
source_document_for_target(project_root, instance, target)
.ok()
.and_then(|document| {
document
.find_entity(&SceneEntityId::ActorId(target.actor_id.clone()))
.map(|_| ())
})
.is_some()
}
fn accept_current_source(world: &mut World, entity: Entity) {
let old = capture_prefab_edit_state(world, entity);
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, entity, &overrides);
record_prefab_edit_state(world, entity, old);
}
fn keep_prefab_overrides(world: &mut World, entity: Entity) {
let old = capture_prefab_edit_state(world, entity);
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
else {
return;
};
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
let prerequisite_layer = overrides.clone();
for property in &mut overrides.properties {
if let Ok(Some(source)) = source_component_for_target(
&project_root,
&instance,
&property.target,
&property.component_type,
)
.and_then(|source| {
apply_layer_prerequisites(
source,
&prerequisite_layer,
&property.target,
&property.component_type,
)
}) {
property.base_component_ron = source.ron;
}
}
for component in &mut overrides.components {
component.base_component_ron = source_component_for_target(
&project_root,
&instance,
&component.target,
&component.component_type,
)
.ok()
.flatten()
.map(|source| source.ron);
}
for structural in &mut overrides.structural {
match structural {
PrefabStructuralOverride::RemoveChild {
base_subtree_revision,
..
} => {
*base_subtree_revision =
prefab_source_revision(&project_root, &instance.source_path)
.unwrap_or_default();
}
PrefabStructuralOverride::ReparentChild {
target,
base_parent,
base_transform_ron,
..
} => {
if let Ok(parent) = source_parent_for_target(&project_root, &instance, target) {
*base_parent = parent;
}
*base_transform_ron = source_component_for_target(
&project_root,
&instance,
target,
std::any::type_name::<Transform>(),
)
.ok()
.flatten()
.map(|component| component.ron);
}
PrefabStructuralOverride::AttachLocalChild { .. } => {}
}
}
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, entity, &overrides);
record_prefab_edit_state(world, entity, old);
}
fn take_source_for_conflicts(world: &mut World, entity: Entity) {
let old = capture_prefab_edit_state(world, entity);
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
else {
return;
};
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
let conflicts: HashSet<_> = prefab_semantic_conflicts(&project_root, &instance, &overrides)
.into_iter()
.collect();
overrides
.properties
.retain(|property| !conflicts.contains(&property_conflict_key(property)));
overrides
.components
.retain(|component| !conflicts.contains(&component_conflict_key(component)));
overrides
.structural
.retain(|structural| !conflicts.contains(&structural_conflict_key(structural)));
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, entity, &overrides);
record_prefab_edit_state(world, entity, old);
}
pub fn revert_prefab_instance_overrides(world: &mut World, entity: Entity) {
let old = capture_prefab_edit_state(world, entity);
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let mut overrides = PrefabOverrides::default();
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, entity, &overrides);
record_prefab_edit_state(world, entity, old);
}
fn remove_stale_visibility_overrides(world: &mut World, entity: Entity) {
let old = capture_prefab_edit_state(world, entity);
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
else {
return;
};
let known_keys = prefab_legacy_target_keys(&project_root, &instance);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
overrides
.child_visibility
.retain(|key, _| known_keys.contains(key));
overrides
.properties
.retain(|property| source_target_exists(&project_root, &instance, &property.target));
overrides
.components
.retain(|component| source_target_exists(&project_root, &instance, &component.target));
overrides.structural.retain(|structural| match structural {
PrefabStructuralOverride::RemoveChild { target, .. }
| PrefabStructuralOverride::ReparentChild { target, .. } => {
source_target_exists(&project_root, &instance, target)
}
PrefabStructuralOverride::AttachLocalChild { parent, .. } => {
source_target_exists(&project_root, &instance, parent)
}
});
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, entity, &overrides);
record_prefab_edit_state(world, entity, old);
}
fn discard_invalid_overrides(world: &mut World, entity: Entity) {
let old = capture_prefab_edit_state(world, entity);
let Some(mut instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
instance.overrides_ron = None;
world.entity_mut(entity).insert(instance);
record_prefab_edit_state(world, entity, old);
}
fn relink_prefab_source(world: &mut World, entity: Entity) {
let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
else {
return;
};
let Some(path) = rfd::FileDialog::new()
.set_directory(project_root.join("assets/prefabs"))
.add_filter("Bevy prefab", &["scn.ron", "ron"])
.pick_file()
else {
return;
};
let Ok(path) = path.canonicalize() else {
return;
};
let Ok(root) = project_root.canonicalize() else {
return;
};
let Ok(relative) = path.strip_prefix(&root) else {
world.resource_mut::<crate::scene_io::SceneIo>().status =
"Prefab source must remain inside the project".into();
return;
};
let source_path = relative.to_string_lossy().replace('\\', "/");
if let Err(error) = scene::validate_prefab_graph(&path, &root) {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Prefab relink failed: {error}");
return;
}
let old = capture_prefab_edit_state(world, entity);
let Some(mut instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
instance.source_path = source_path.clone();
let label = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("Prefab")
.to_string();
let registration = world
.get_resource_mut::<AssetRegistry>()
.ok_or_else(|| "asset registry is unavailable".to_string())
.and_then(|mut registry| {
ensure_asset_record(&mut registry, source_path.clone(), label, "Prefab")
});
let record = match registration {
Ok(record) => record,
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Prefab relink failed: could not register source: {error}");
return;
}
};
instance.asset_id = record.id.as_string();
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
track_current_source_revision(world, &instance, &mut overrides);
instance.overrides_ron = encode_prefab_overrides(&overrides).ok().flatten();
world
.entity_mut(entity)
.remove::<shared::PrefabHydrationBlocked>()
.insert((instance, PrefabRef::new(source_path)));
record_prefab_edit_state(world, entity, old);
world.resource_mut::<crate::scene_io::SceneIo>().status = "Prefab source relinked".into();
}
pub fn read_overrides(instance: &PrefabInstance) -> PrefabOverrides {
try_read_overrides(instance).unwrap_or_default()
}
pub fn try_read_overrides(instance: &PrefabInstance) -> Result<PrefabOverrides, String> {
decode_prefab_overrides(instance)
}
fn read_overrides_for_edit(
world: &mut World,
instance: &PrefabInstance,
) -> Option<PrefabOverrides> {
match try_read_overrides(instance) {
Ok(overrides) => Some(overrides),
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Prefab edit blocked: {error}. Use Discard invalid data to recover.");
None
}
}
}
pub fn new_tracked_prefab_instance(
world: &World,
asset_id: impl Into<String>,
source_path: impl Into<String>,
) -> PrefabInstance {
let mut instance = PrefabInstance::new(asset_id, source_path);
let mut overrides = PrefabOverrides::default();
track_current_source_revision(world, &instance, &mut overrides);
if overrides != PrefabOverrides::default() {
instance.overrides_ron = encode_prefab_overrides(&overrides).ok().flatten();
}
instance
}
pub fn write_overrides(world: &mut World, entity: Entity, overrides: &PrefabOverrides) {
let Some(mut instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
instance.overrides_ron = encode_prefab_overrides(overrides).unwrap_or_default();
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(instance);
}
}
pub fn set_child_visibility_override(
world: &mut World,
entity: Entity,
child: Entity,
visible: bool,
) {
let Some((instance_root, target)) = prefab_member_override_target(world, child) else {
world.resource_mut::<crate::scene_io::SceneIo>().status =
"Cannot store prefab visibility: target has no stable prefab path".into();
return;
};
if instance_root != entity {
return;
}
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return;
};
let component_type = std::any::type_name::<EditorVisibility>().to_string();
let value = EditorVisibility { visible };
let value_component_ron = match ron::to_string(&value) {
Ok(value) => value,
Err(_) => return,
};
let source_base = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
.and_then(|project_root| {
source_component_for_target(&project_root, &instance, &target, &component_type)
.ok()
.flatten()
.map(|component| component.ron)
});
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
overrides.child_visibility.remove(&target.actor_id);
let base_component_ron = overrides
.components
.iter()
.find(|candidate| candidate.target == target && candidate.component_type == component_type)
.and_then(|candidate| candidate.base_component_ron.clone())
.or(source_base);
overrides.components.retain(|candidate| {
!(candidate.target == target && candidate.component_type == component_type)
});
overrides.components.push(PrefabComponentOverride {
target,
component_type,
base_component_ron,
value_component_ron: Some(value_component_ron),
});
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
world.entity_mut(child).insert(value);
record_prefab_edit_state(world, instance_root, old);
}
pub fn prefab_member_override_target(
world: &World,
member: Entity,
) -> Option<(Entity, PrefabActorPath)> {
let actor_id = world.get::<ActorId>(member)?.0.clone();
let mut instance_root = world.get::<HydratedPrefabMember>(member)?.instance_root;
let mut instance_chain = Vec::new();
let mut seen = HashSet::new();
while seen.insert(instance_root) {
let Some(owner) = world.get::<HydratedPrefabMember>(instance_root) else {
break;
};
instance_chain.insert(0, world.get::<ActorId>(instance_root)?.0.clone());
instance_root = owner.instance_root;
}
world.get::<PrefabInstance>(instance_root)?;
Some((
instance_root,
PrefabActorPath {
instance_chain,
actor_id,
},
))
}
pub fn set_prefab_material_property_override(
world: &mut World,
member: Entity,
property_path: &str,
value: MaterialDesc,
) {
record_prefab_property_override(world, member, property_path, value);
}
pub fn set_prefab_transform_property_override(
world: &mut World,
member: Entity,
property_path: &str,
value: Transform,
) {
record_prefab_property_override(world, member, property_path, value);
}
fn record_prefab_property_override<T>(
world: &mut World,
member: Entity,
property_path: &str,
value: T,
) where
T: Component + Clone + serde::Serialize,
{
let Some((instance_root, target)) = prefab_member_override_target(world, member) else {
return;
};
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return;
};
let Some(current) = world.get::<T>(member).cloned() else {
return;
};
let component_type = std::any::type_name::<T>().to_string();
let current_ron = match ron::to_string(&current) {
Ok(value) => value,
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Could not capture prefab property: {error}");
return;
}
};
let value_ron = match ron::to_string(&value) {
Ok(value) => value,
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Could not encode prefab property: {error}");
return;
}
};
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
let base_component_ron = overrides
.properties
.iter()
.find(|candidate| candidate.target == target && candidate.component_type == component_type)
.map(|candidate| candidate.base_component_ron.clone())
.unwrap_or(current_ron);
overrides.properties.retain(|candidate| {
!(candidate.target == target
&& candidate.component_type == component_type
&& candidate.property_path == property_path)
});
overrides.properties.push(PrefabPropertyOverride {
target,
component_type,
property_path: property_path.to_string(),
base_component_ron,
value_component_ron: value_ron,
});
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
world.entity_mut(member).insert(value);
record_prefab_edit_state(world, instance_root, old);
}
pub fn remove_prefab_component_override<T>(world: &mut World, member: Entity)
where
T: Component + Clone + serde::Serialize,
{
let Some((instance_root, target)) = prefab_member_override_target(world, member) else {
return;
};
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return;
};
let Some(current) = world.get::<T>(member).cloned() else {
return;
};
let Ok(current_component_ron) = ron::to_string(&current) else {
return;
};
let component_type = std::any::type_name::<T>().to_string();
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
let existing_component = overrides
.components
.iter()
.find(|candidate| candidate.target == target && candidate.component_type == component_type);
let cancels_local_add = existing_component.is_some_and(|component| {
component.base_component_ron.is_none() && component.value_component_ron.is_some()
});
let base_component_ron = existing_component
.and_then(|component| component.base_component_ron.clone())
.or_else(|| {
overrides
.properties
.iter()
.find(|candidate| {
candidate.target == target && candidate.component_type == component_type
})
.map(|property| property.base_component_ron.clone())
})
.or_else(|| {
world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
.and_then(|project_root| {
source_component_for_target(&project_root, &instance, &target, &component_type)
.ok()
.flatten()
.map(|component| component.ron)
})
})
.or(Some(current_component_ron));
overrides.properties.retain(|candidate| {
!(candidate.target == target && candidate.component_type == component_type)
});
overrides.components.retain(|candidate| {
!(candidate.target == target && candidate.component_type == component_type)
});
if !cancels_local_add {
overrides.components.push(PrefabComponentOverride {
target,
component_type,
base_component_ron,
value_component_ron: None,
});
}
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
world.entity_mut(member).remove::<T>();
record_prefab_edit_state(world, instance_root, old);
}
pub fn set_prefab_component_override<T>(world: &mut World, member: Entity, value: T)
where
T: Component + Clone + serde::Serialize,
{
let Some((instance_root, target)) = prefab_member_override_target(world, member) else {
return;
};
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return;
};
let value_component_ron = match ron::to_string(&value) {
Ok(value) => value,
Err(_) => return,
};
let component_type = std::any::type_name::<T>().to_string();
let base_component_ron = world
.get::<T>(member)
.and_then(|current| ron::to_string(current).ok());
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
let base_component_ron = overrides
.components
.iter()
.find(|candidate| candidate.target == target && candidate.component_type == component_type)
.and_then(|candidate| candidate.base_component_ron.clone())
.or(base_component_ron);
overrides.components.retain(|candidate| {
!(candidate.target == target && candidate.component_type == component_type)
});
overrides.components.push(PrefabComponentOverride {
target,
component_type,
base_component_ron,
value_component_ron: Some(value_component_ron),
});
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
world.entity_mut(member).insert(value);
record_prefab_edit_state(world, instance_root, old);
}
pub fn revert_prefab_override_scope(
world: &mut World,
member: Entity,
component_type: Option<&str>,
property_path: Option<&str>,
) {
let Some((instance_root, target)) = prefab_member_override_target(world, member) else {
return;
};
revert_prefab_target_scope(world, instance_root, &target, component_type, property_path);
}
fn revert_prefab_target_scope(
world: &mut World,
instance_root: Entity,
target: &PrefabActorPath,
component_type: Option<&str>,
property_path: Option<&str>,
) {
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return;
};
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
overrides.properties.retain(|candidate| {
candidate.target != *target
|| component_type.is_some_and(|value| candidate.component_type != value)
|| property_path.is_some_and(|value| candidate.property_path != value)
});
if property_path.is_none() {
overrides.components.retain(|candidate| {
candidate.target != *target
|| component_type.is_some_and(|value| candidate.component_type != value)
});
}
if component_type.is_none() && property_path.is_none() {
overrides
.structural
.retain(|candidate| structural_target(candidate) != target);
}
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
record_prefab_edit_state(world, instance_root, old);
}
pub fn revert_prefab_actor_path(
world: &mut World,
instance_root: Entity,
target: &PrefabActorPath,
) {
revert_prefab_target_scope(world, instance_root, target, None, None);
}
pub fn apply_prefab_member_scope_to_source(
world: &mut World,
member: Entity,
component_type: Option<&str>,
property_path: Option<&str>,
) {
let Some((instance_root, target)) = prefab_member_override_target(world, member) else {
return;
};
apply_prefab_scope_to_source(
world,
instance_root,
Some(&target),
component_type,
property_path,
);
}
pub fn apply_prefab_instance_overrides_to_source(world: &mut World, instance_root: Entity) {
apply_prefab_scope_to_source(world, instance_root, None, None, None);
}
pub fn apply_prefab_actor_path_to_source(
world: &mut World,
instance_root: Entity,
target: &PrefabActorPath,
) {
apply_prefab_scope_to_source(world, instance_root, Some(target), None, None);
}
fn apply_prefab_scope_to_source(
world: &mut World,
instance_root: Entity,
target_filter: Option<&PrefabActorPath>,
component_type: Option<&str>,
property_path: Option<&str>,
) {
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return;
};
let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
else {
return;
};
let source_path = project_root.join(&instance.source_path);
let before_source = match std::fs::read_to_string(&source_path) {
Ok(source) => source,
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Apply to source failed: {error}");
return;
}
};
let mut document = match SceneDocument::from_ron_text(&before_source) {
Ok(document) => document,
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Apply to source failed: {error}");
return;
}
};
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
if let Err(error) =
promote_legacy_visibility_overrides(&project_root, &instance, &mut overrides)
{
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Apply to source failed: {error}");
return;
}
let selected_properties: Vec<_> = overrides
.properties
.iter()
.filter(|candidate| {
target_filter.is_none_or(|target| candidate.target == *target)
&& component_type.is_none_or(|value| candidate.component_type == value)
&& property_path.is_none_or(|value| candidate.property_path == value)
})
.cloned()
.collect();
let selected_components: Vec<_> = overrides
.components
.iter()
.filter(|candidate| {
target_filter.is_none_or(|target| candidate.target == *target)
&& component_type.is_none_or(|value| candidate.component_type == value)
&& (property_path.is_none()
|| (candidate.base_component_ron.is_none()
&& candidate.value_component_ron.is_some()))
})
.cloned()
.collect();
let selected_structural: Vec<_> = if component_type.is_none() && property_path.is_none() {
overrides
.structural
.iter()
.filter(|candidate| {
target_filter.is_none_or(|target| structural_target(candidate) == target)
})
.cloned()
.collect()
} else {
Vec::new()
};
if selected_properties.is_empty()
&& selected_components.is_empty()
&& selected_structural.is_empty()
{
world.resource_mut::<crate::scene_io::SceneIo>().status =
"No prefab overrides in this scope".into();
return;
}
let apply_result = (|| {
for structural in &selected_structural {
validate_structural_apply_base(&project_root, &instance, structural)?;
}
for component in &selected_components {
validate_component_apply_base(
&project_root,
&instance,
component,
&selected_structural,
)?;
}
for property in &selected_properties {
validate_property_apply_base(
&project_root,
&instance,
property,
&selected_components,
&selected_structural,
)?;
}
for structural in &selected_structural {
if matches!(structural, PrefabStructuralOverride::ReparentChild { .. }) {
apply_or_transfer_structural(&mut document, structural, &project_root)?;
}
}
for component in &selected_components {
if component.target.instance_chain.is_empty() {
apply_component_to_document(&mut document, component)?;
} else {
transfer_component_to_nested_layer(
&mut document,
component.clone(),
&project_root,
)?;
}
}
for property in &selected_properties {
if property.target.instance_chain.is_empty() {
apply_property_to_document(&mut document, property)?;
} else {
transfer_property_to_nested_layer(&mut document, property.clone(), &project_root)?;
}
}
for structural in &selected_structural {
if !matches!(structural, PrefabStructuralOverride::ReparentChild { .. }) {
apply_or_transfer_structural(&mut document, structural, &project_root)?;
}
}
let after_source = document.to_ron_text()?;
scene::validate_prefab_graph_text(&after_source, &source_path, &project_root)?;
crate::scene::recovery::atomic_write(&source_path, after_source.as_bytes())?;
Ok::<_, String>(after_source)
})();
let after_source = match apply_result {
Ok(source) => source,
Err(error) => {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Apply to source failed: {error}");
return;
}
};
overrides
.properties
.retain(|candidate| !selected_properties.contains(candidate));
overrides
.components
.retain(|candidate| !selected_components.contains(candidate));
overrides
.structural
.retain(|candidate| !selected_structural.contains(candidate));
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
record_prefab_source_apply(
world,
instance_root,
source_path,
before_source,
after_source,
old,
);
world.resource_mut::<crate::scene_io::SceneIo>().status =
"Prefab overrides applied to source".into();
}
fn apply_or_transfer_structural(
document: &mut SceneDocument,
structural: &PrefabStructuralOverride,
project_root: &Path,
) -> Result<(), String> {
if structural_target(structural).instance_chain.is_empty() {
apply_structural_to_document(document, structural)
} else {
transfer_structural_to_nested_layer(document, structural.clone(), project_root)
}
}
fn validate_property_apply_base(
project_root: &Path,
instance: &PrefabInstance,
property: &PrefabPropertyOverride,
selected_components: &[PrefabComponentOverride],
selected_structural: &[PrefabStructuralOverride],
) -> Result<(), String> {
let mut current = source_component_for_target(
project_root,
instance,
&property.target,
&property.component_type,
)?;
if property.component_type == std::any::type_name::<Transform>() {
if let Some(local_transform_ron) =
selected_structural
.iter()
.find_map(|structural| match structural {
PrefabStructuralOverride::ReparentChild {
target,
local_transform_ron,
..
} if target == &property.target => local_transform_ron.clone(),
_ => None,
})
{
current = Some(SceneComponentBlob::from_ron(
std::any::type_name::<Transform>(),
local_transform_ron,
)?);
}
}
for component in selected_components.iter().filter(|component| {
component.target == property.target && component.component_type == property.component_type
}) {
let current_ron = current.as_ref().map(|value| value.ron.as_str());
let base_matches = match (&component.base_component_ron, current_ron) {
(None, None) => true,
(Some(base), Some(current)) => {
component_values_equal(&component.component_type, current, base)
}
_ => false,
};
if !base_matches {
return Err(format!(
"conflict: source component `{}` changed",
component.component_type
));
}
current = component
.value_component_ron
.as_ref()
.map(|value| {
SceneComponentBlob::from_ron(component.component_type.clone(), value.clone())
})
.transpose()?;
}
let source = current.ok_or_else(|| {
format!(
"source actor `{}` is missing component `{}`",
actor_path_key(&property.target),
property.component_type
)
})?;
if property.component_type == std::any::type_name::<Transform>() {
apply_transform_property(&source.ron, property).map(|_| ())
} else if property.component_type == std::any::type_name::<MaterialDesc>() {
apply_material_property(&source.ron, property).map(|_| ())
} else {
Err(format!(
"property apply does not support component `{}`",
property.component_type
))
}
}
fn validate_component_apply_base(
project_root: &Path,
instance: &PrefabInstance,
component: &PrefabComponentOverride,
selected_structural: &[PrefabStructuralOverride],
) -> Result<(), String> {
let mut current = source_component_for_target(
project_root,
instance,
&component.target,
&component.component_type,
)?;
if component.component_type == std::any::type_name::<Transform>() {
if let Some(local_transform_ron) =
selected_structural
.iter()
.find_map(|structural| match structural {
PrefabStructuralOverride::ReparentChild {
target,
local_transform_ron,
..
} if target == &component.target => local_transform_ron.clone(),
_ => None,
})
{
current = Some(SceneComponentBlob::from_ron(
std::any::type_name::<Transform>(),
local_transform_ron,
)?);
}
}
let current_ron = current.as_ref().map(|value| value.ron.as_str());
let base_matches = match (&component.base_component_ron, current_ron) {
(None, None) => true,
(Some(base), Some(current)) => {
component_values_equal(&component.component_type, current, base)
}
_ => false,
};
if base_matches {
Ok(())
} else {
Err(format!(
"conflict: source component `{}` changed",
component.component_type
))
}
}
fn structural_target(structural: &PrefabStructuralOverride) -> &PrefabActorPath {
match structural {
PrefabStructuralOverride::RemoveChild { target, .. }
| PrefabStructuralOverride::ReparentChild { target, .. } => target,
PrefabStructuralOverride::AttachLocalChild { parent, .. } => parent,
}
}
fn promote_legacy_visibility_overrides(
project_root: &Path,
instance: &PrefabInstance,
overrides: &mut PrefabOverrides,
) -> Result<(), String> {
if overrides.child_visibility.is_empty() {
return Ok(());
}
let direct = PrefabActorPath::direct("");
let document = source_document_for_target(project_root, instance, &direct)?;
let component_type = std::any::type_name::<EditorVisibility>().to_string();
let legacy = std::mem::take(&mut overrides.child_visibility);
for (key, visible) in legacy {
let source = document
.entities
.iter()
.find(|entity| {
entity.actor_id.as_deref() == Some(key.as_str())
|| entity.name.as_deref() == Some(key.as_str())
})
.ok_or_else(|| format!("legacy visibility target `{key}` no longer exists"))?;
let actor_id = source
.actor_id
.clone()
.ok_or_else(|| format!("legacy visibility target `{key}` has no ActorId"))?;
let target = PrefabActorPath::direct(actor_id);
let base_component_ron = source
.components
.iter()
.find(|component| component.type_name == component_type)
.map(|component| component.ron.clone());
overrides.components.retain(|candidate| {
!(candidate.target == target && candidate.component_type == component_type)
});
overrides.components.push(PrefabComponentOverride {
target,
component_type: component_type.clone(),
base_component_ron,
value_component_ron: Some(
ron::to_string(&EditorVisibility { visible }).map_err(|error| error.to_string())?,
),
});
}
Ok(())
}
fn apply_property_to_document(
document: &mut SceneDocument,
property: &PrefabPropertyOverride,
) -> Result<(), String> {
let target = SceneEntityId::ActorId(property.target.actor_id.clone());
let source = document
.find_entity(&target)
.and_then(|entity| {
entity
.components
.iter()
.find(|component| component.type_name == property.component_type)
})
.cloned()
.ok_or_else(|| {
format!(
"source actor `{}` is missing component `{}`",
property.target.actor_id, property.component_type
)
})?;
let ron = if property.component_type == std::any::type_name::<Transform>() {
apply_transform_property(&source.ron, property)?
} else if property.component_type == std::any::type_name::<MaterialDesc>() {
apply_material_property(&source.ron, property)?
} else {
return Err(format!(
"property apply does not support component `{}`",
property.component_type
));
};
document.apply_patch(ScenePatch::SetComponent {
target,
component: SceneComponentBlob::from_ron(property.component_type.clone(), ron)?,
})
}
fn apply_transform_property(
source_ron: &str,
property: &PrefabPropertyOverride,
) -> Result<String, String> {
let mut source: Transform = ron::from_str(source_ron).map_err(|error| error.to_string())?;
let base: Transform =
ron::from_str(&property.base_component_ron).map_err(|error| error.to_string())?;
let value: Transform =
ron::from_str(&property.value_component_ron).map_err(|error| error.to_string())?;
let conflict = match property.property_path.as_str() {
"translation" => {
let conflict = source.translation != base.translation;
source.translation = value.translation;
conflict
}
"rotation" => {
let conflict = source.rotation != base.rotation;
source.rotation = value.rotation;
conflict
}
"scale" => {
let conflict = source.scale != base.scale;
source.scale = value.scale;
conflict
}
path => return Err(format!("unsupported Transform property `{path}`")),
};
if conflict {
return Err(format!(
"conflict: source Transform.{} changed since the override was created",
property.property_path
));
}
ron::to_string(&source).map_err(|error| error.to_string())
}
fn apply_material_property(
source_ron: &str,
property: &PrefabPropertyOverride,
) -> Result<String, String> {
let mut source: MaterialDesc = ron::from_str(source_ron).map_err(|error| error.to_string())?;
let base: MaterialDesc =
ron::from_str(&property.base_component_ron).map_err(|error| error.to_string())?;
let value: MaterialDesc =
ron::from_str(&property.value_component_ron).map_err(|error| error.to_string())?;
let conflict = match property.property_path.as_str() {
"base_color" => {
let conflict = source.base_color != base.base_color;
source.base_color = value.base_color;
conflict
}
"metallic" => {
let conflict = source.metallic != base.metallic;
source.metallic = value.metallic;
conflict
}
"roughness" => {
let conflict = source.roughness != base.roughness;
source.roughness = value.roughness;
conflict
}
path => return Err(format!("unsupported MaterialDesc property `{path}`")),
};
if conflict {
return Err(format!(
"conflict: source MaterialDesc.{} changed since the override was created",
property.property_path
));
}
ron::to_string(&source).map_err(|error| error.to_string())
}
fn apply_component_to_document(
document: &mut SceneDocument,
component: &PrefabComponentOverride,
) -> Result<(), String> {
let target = SceneEntityId::ActorId(component.target.actor_id.clone());
let current = document.find_entity(&target).and_then(|entity| {
entity
.components
.iter()
.find(|candidate| candidate.type_name == component.component_type)
});
match (&component.base_component_ron, current) {
(None, Some(_)) => {
return Err(format!(
"conflict: source actor `{}` gained component `{}`",
component.target.actor_id, component.component_type
));
}
(Some(base), Some(current))
if !component_values_equal(&component.component_type, &current.ron, base) =>
{
return Err(format!(
"conflict: source component `{}` changed",
component.component_type
));
}
(Some(_), None) => {
return Err(format!(
"conflict: source component `{}` was removed",
component.component_type
));
}
_ => {}
}
match &component.value_component_ron {
Some(value) => document.apply_patch(ScenePatch::SetComponent {
target,
component: SceneComponentBlob::from_ron(
component.component_type.clone(),
value.clone(),
)?,
}),
None => document.apply_patch(ScenePatch::RemoveComponent {
target,
component_type: component.component_type.clone(),
}),
}
}
fn component_values_equal(component_type: &str, left: &str, right: &str) -> bool {
if component_type == std::any::type_name::<Transform>() {
return ron::from_str::<Transform>(left).ok() == ron::from_str::<Transform>(right).ok();
}
if component_type == std::any::type_name::<MaterialDesc>() {
return ron::from_str::<MaterialDesc>(left).ok()
== ron::from_str::<MaterialDesc>(right).ok();
}
ron::from_str::<ron::Value>(left).ok() == ron::from_str::<ron::Value>(right).ok()
}
fn apply_structural_to_document(
document: &mut SceneDocument,
structural: &PrefabStructuralOverride,
) -> Result<(), String> {
match structural {
PrefabStructuralOverride::RemoveChild { target, .. } => {
document.apply_patch(ScenePatch::DeleteEntity {
target: SceneEntityId::ActorId(target.actor_id.clone()),
})
}
PrefabStructuralOverride::ReparentChild {
target,
new_parent,
local_transform_ron,
..
} => {
if new_parent
.as_ref()
.is_some_and(|parent| !parent.instance_chain.is_empty())
{
return Err("cannot apply a cross-layer parent to this source".to_string());
}
if document_reparent_would_cycle(document, target, new_parent.as_ref()) {
return Err(format!(
"reparenting `{}` would create a hierarchy cycle",
actor_path_key(target)
));
}
document.apply_patch(ScenePatch::Reparent {
target: SceneEntityId::ActorId(target.actor_id.clone()),
parent_actor_id: new_parent.as_ref().map(|parent| parent.actor_id.clone()),
})?;
if let Some(transform_ron) = local_transform_ron {
document.apply_patch(ScenePatch::SetComponent {
target: SceneEntityId::ActorId(target.actor_id.clone()),
component: SceneComponentBlob::from_ron(
std::any::type_name::<Transform>(),
transform_ron.clone(),
)?,
})?;
}
Ok(())
}
PrefabStructuralOverride::AttachLocalChild { .. } => {
Err("local child attachment must be converted before source apply".to_string())
}
}
}
fn document_reparent_would_cycle(
document: &SceneDocument,
target: &PrefabActorPath,
new_parent: Option<&PrefabActorPath>,
) -> bool {
let Some(new_parent) = new_parent else {
return false;
};
let mut current = Some(new_parent.actor_id.as_str());
let mut seen = HashSet::new();
while let Some(actor_id) = current {
if actor_id == target.actor_id || !seen.insert(actor_id.to_string()) {
return true;
}
current = document
.find_entity(&SceneEntityId::ActorId(actor_id.to_string()))
.and_then(|entity| entity.parent_actor_id.as_deref());
}
false
}
fn validate_structural_apply_base(
project_root: &Path,
instance: &PrefabInstance,
structural: &PrefabStructuralOverride,
) -> Result<(), String> {
let target = structural_target(structural);
let document = source_document_for_target(project_root, instance, target)?;
let _source = document
.find_entity(&SceneEntityId::ActorId(target.actor_id.clone()))
.ok_or_else(|| {
format!(
"conflict: source actor `{}` no longer exists",
actor_path_key(target)
)
})?;
match structural {
PrefabStructuralOverride::RemoveChild {
base_subtree_revision,
..
} => {
let current = prefab_source_revision(project_root, &instance.source_path)?;
if current != *base_subtree_revision {
return Err(format!(
"conflict: source subtree `{}` changed since removal was authored",
actor_path_key(target)
));
}
}
PrefabStructuralOverride::ReparentChild {
base_parent,
new_parent,
base_transform_ron,
..
} => {
let current_parent = source_parent_for_target(project_root, instance, target)?;
if current_parent != *base_parent {
return Err(format!(
"conflict: source parent for `{}` changed",
actor_path_key(target)
));
}
if let Some(base) = base_transform_ron {
let current = source_component_for_target(
project_root,
instance,
target,
std::any::type_name::<Transform>(),
)?
.ok_or_else(|| {
format!(
"conflict: source transform for `{}` was removed",
actor_path_key(target)
)
})?;
if !component_values_equal(std::any::type_name::<Transform>(), &current.ron, base) {
return Err(format!(
"conflict: source transform for `{}` changed",
actor_path_key(target)
));
}
}
if let Some(parent) = new_parent {
if parent.instance_chain != target.instance_chain
|| source_parent_for_target(project_root, instance, parent).is_err()
{
return Err(format!(
"conflict: destination parent `{}` is unavailable",
actor_path_key(parent)
));
}
}
}
PrefabStructuralOverride::AttachLocalChild { .. } => {}
}
Ok(())
}
fn transfer_property_to_nested_layer(
document: &mut SceneDocument,
mut property: PrefabPropertyOverride,
project_root: &Path,
) -> Result<(), String> {
let anchor = property.target.instance_chain.remove(0);
update_nested_instance(document, &anchor, project_root, move |overrides, _| {
merge_property_override(overrides, property);
})
}
fn transfer_component_to_nested_layer(
document: &mut SceneDocument,
mut component: PrefabComponentOverride,
project_root: &Path,
) -> Result<(), String> {
let anchor = component.target.instance_chain.remove(0);
update_nested_instance(document, &anchor, project_root, move |overrides, _| {
merge_component_override(overrides, component);
})
}
fn transfer_structural_to_nested_layer(
document: &mut SceneDocument,
mut structural: PrefabStructuralOverride,
project_root: &Path,
) -> Result<(), String> {
let anchor = structural_target(&structural)
.instance_chain
.first()
.cloned()
.ok_or_else(|| "nested structural override has no instance path".to_string())?;
trim_structural_instance_prefix(&mut structural, &anchor)?;
update_nested_instance(
document,
&anchor,
project_root,
move |overrides, nested_instance| {
let base_revision =
prefab_source_revision(project_root, &nested_instance.source_path).ok();
merge_structural_override(overrides, structural, base_revision);
},
)
}
fn merge_property_override(overrides: &mut PrefabOverrides, mut property: PrefabPropertyOverride) {
if let Some(existing) = overrides.properties.iter().find(|candidate| {
candidate.target == property.target
&& candidate.component_type == property.component_type
&& candidate.property_path == property.property_path
}) {
property.base_component_ron = existing.base_component_ron.clone();
}
overrides.properties.retain(|candidate| {
!(candidate.target == property.target
&& candidate.component_type == property.component_type
&& candidate.property_path == property.property_path)
});
overrides.properties.push(property);
}
fn merge_component_override(
overrides: &mut PrefabOverrides,
mut component: PrefabComponentOverride,
) {
if let Some(existing) = overrides.components.iter().find(|candidate| {
candidate.target == component.target && candidate.component_type == component.component_type
}) {
component.base_component_ron = existing.base_component_ron.clone();
}
overrides.components.retain(|candidate| {
!(candidate.target == component.target
&& candidate.component_type == component.component_type)
});
overrides.components.push(component);
}
fn merge_structural_override(
overrides: &mut PrefabOverrides,
mut structural: PrefabStructuralOverride,
base_revision: Option<String>,
) {
let target = structural_target(&structural).clone();
let existing = overrides
.structural
.iter()
.find(|candidate| {
std::mem::discriminant(*candidate) == std::mem::discriminant(&structural)
&& structural_target(candidate) == &target
})
.cloned();
match (&mut structural, existing) {
(
PrefabStructuralOverride::RemoveChild {
base_subtree_revision,
..
},
Some(PrefabStructuralOverride::RemoveChild {
base_subtree_revision: existing,
..
}),
) => *base_subtree_revision = existing,
(
PrefabStructuralOverride::ReparentChild {
base_parent,
base_transform_ron,
..
},
Some(PrefabStructuralOverride::ReparentChild {
base_parent: existing_parent,
base_transform_ron: existing_transform,
..
}),
) => {
*base_parent = existing_parent;
*base_transform_ron = existing_transform;
}
(
PrefabStructuralOverride::RemoveChild {
base_subtree_revision,
..
},
None,
) => {
if let Some(revision) = base_revision {
*base_subtree_revision = revision;
}
}
_ => {}
}
overrides.structural.retain(|candidate| {
std::mem::discriminant(candidate) != std::mem::discriminant(&structural)
|| structural_target(candidate) != &target
});
overrides.structural.push(structural);
}
pub fn fold_nested_overrides_for_unpack(
world: &mut World,
instance_root: Entity,
) -> Result<(), String> {
let instance = world
.get::<PrefabInstance>(instance_root)
.cloned()
.ok_or_else(|| "prefab instance is missing".to_string())?;
let outer = try_read_overrides(&instance)?;
let nested_entities: Vec<(Entity, String, PrefabInstance)> = world
.query::<(Entity, &HydratedPrefabMember, &ActorId, &PrefabInstance)>()
.iter(world)
.filter(|(_, owner, _, _)| owner.instance_root == instance_root)
.map(|(entity, _, actor_id, nested)| (entity, actor_id.0.clone(), nested.clone()))
.collect();
let project_root = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root));
let mut updates = Vec::new();
for (entity, anchor_actor_id, mut nested_instance) in nested_entities {
let mut nested_overrides = try_read_overrides(&nested_instance)?;
let mut changed = false;
for mut property in outer
.properties
.iter()
.filter(|property| property.target.instance_chain.first() == Some(&anchor_actor_id))
.cloned()
{
property.target.instance_chain.remove(0);
merge_property_override(&mut nested_overrides, property);
changed = true;
}
for mut component in outer
.components
.iter()
.filter(|component| component.target.instance_chain.first() == Some(&anchor_actor_id))
.cloned()
{
component.target.instance_chain.remove(0);
merge_component_override(&mut nested_overrides, component);
changed = true;
}
for mut structural in outer
.structural
.iter()
.filter(|structural| {
structural_target(structural).instance_chain.first() == Some(&anchor_actor_id)
})
.cloned()
{
trim_structural_instance_prefix(&mut structural, &anchor_actor_id)?;
let base_revision = project_root
.as_ref()
.and_then(|root| prefab_source_revision(root, &nested_instance.source_path).ok());
merge_structural_override(&mut nested_overrides, structural, base_revision);
changed = true;
}
if !changed {
continue;
}
if let Some(root) = project_root.as_ref() {
nested_overrides.source_revision =
prefab_source_revision(root, &nested_instance.source_path).ok();
}
nested_instance.overrides_ron = encode_prefab_overrides(&nested_overrides)?;
updates.push((entity, nested_instance));
}
for (entity, nested_instance) in updates {
world.entity_mut(entity).insert(nested_instance);
}
Ok(())
}
fn trim_structural_instance_prefix(
structural: &mut PrefabStructuralOverride,
prefix: &str,
) -> Result<(), String> {
let trim = |path: &mut PrefabActorPath| -> Result<(), String> {
if path.instance_chain.first().map(String::as_str) != Some(prefix) {
return Err("structural override crosses an instance layer".to_string());
}
path.instance_chain.remove(0);
Ok(())
};
match structural {
PrefabStructuralOverride::RemoveChild { target, .. } => trim(target),
PrefabStructuralOverride::ReparentChild {
target,
base_parent,
new_parent,
..
} => {
trim(target)?;
if let Some(parent) = base_parent {
trim(parent)?;
}
if let Some(parent) = new_parent {
trim(parent)?;
}
Ok(())
}
PrefabStructuralOverride::AttachLocalChild { parent, .. } => trim(parent),
}
}
fn update_nested_instance(
document: &mut SceneDocument,
anchor_actor_id: &str,
project_root: &Path,
update: impl FnOnce(&mut PrefabOverrides, &PrefabInstance),
) -> Result<(), String> {
let target = SceneEntityId::ActorId(anchor_actor_id.to_string());
let component = document
.find_entity(&target)
.and_then(|entity| {
entity
.components
.iter()
.find(|component| component.type_name == std::any::type_name::<PrefabInstance>())
})
.cloned()
.ok_or_else(|| format!("nested prefab anchor `{anchor_actor_id}` is missing"))?;
let mut instance: PrefabInstance =
ron::from_str(&component.ron).map_err(|error| error.to_string())?;
let mut overrides = decode_prefab_overrides(&instance)?;
update(&mut overrides, &instance);
overrides.source_revision = prefab_source_revision(project_root, &instance.source_path).ok();
instance.overrides_ron = encode_prefab_overrides(&overrides)?;
document.apply_patch(ScenePatch::SetComponent {
target,
component: SceneComponentBlob::from_ron(
std::any::type_name::<PrefabInstance>(),
ron::to_string(&instance).map_err(|error| error.to_string())?,
)?,
})
}
pub fn remove_prefab_source_child(world: &mut World, member: Entity) -> bool {
let Some((instance_root, target)) = prefab_member_override_target(world, member) else {
return false;
};
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return false;
};
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return false;
};
overrides
.properties
.retain(|candidate| candidate.target != target);
overrides
.components
.retain(|candidate| candidate.target != target);
overrides
.structural
.retain(|candidate| structural_target(candidate) != &target);
let base_subtree_revision = current_source_revision(world, &instance).unwrap_or_default();
overrides
.structural
.push(PrefabStructuralOverride::RemoveChild {
target,
base_subtree_revision,
});
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
if let Ok(entity_mut) = world.get_entity_mut(member) {
entity_mut.despawn();
}
record_prefab_edit_state(world, instance_root, old);
true
}
pub fn reparent_prefab_source_child(
world: &mut World,
member: Entity,
new_parent: Option<Entity>,
) -> bool {
reparent_prefab_source_children(world, &[member], new_parent)
}
pub fn reparent_prefab_source_children(
world: &mut World,
members: &[Entity],
new_parent: Option<Entity>,
) -> bool {
let Some(first) = members.first().copied() else {
return false;
};
let Some((instance_root, first_target)) = prefab_member_override_target(world, first) else {
return false;
};
let new_parent_target = match new_parent {
Some(parent) => {
let Some((parent_root, parent_target)) = prefab_member_override_target(world, parent)
else {
return false;
};
if parent_root != instance_root
|| parent_target.instance_chain != first_target.instance_chain
{
return false;
}
Some(parent_target)
}
None => None,
};
let runtime_parent = new_parent.unwrap_or(instance_root);
let mut plans = Vec::with_capacity(members.len());
for member in members.iter().copied() {
let Some((owner, target)) = prefab_member_override_target(world, member) else {
return false;
};
if owner != instance_root || target.instance_chain != first_target.instance_chain {
return false;
}
if hierarchy_would_cycle(world, member, runtime_parent) {
world.resource_mut::<crate::scene_io::SceneIo>().status =
"Prefab reparent blocked: hierarchy cycle".into();
return false;
}
let new_transform = world
.get::<GlobalTransform>(member)
.map(
|global| match world.get::<GlobalTransform>(runtime_parent) {
Some(parent_global) => global.reparented_to(parent_global),
None => global.compute_transform(),
},
)
.or_else(|| world.get::<Transform>(member).copied());
let local_transform_ron = new_transform
.as_ref()
.and_then(|transform| ron::to_string(transform).ok());
plans.push((member, target, new_transform, local_transform_ron));
}
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return false;
};
let old = capture_prefab_edit_state(world, instance_root);
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return false;
};
let project_root = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root));
for (member, target, _, local_transform_ron) in &plans {
let existing_base = overrides
.structural
.iter()
.find_map(|candidate| match candidate {
PrefabStructuralOverride::ReparentChild {
target: existing,
base_parent,
base_transform_ron,
..
} if existing == target => Some((base_parent.clone(), base_transform_ron.clone())),
_ => None,
});
let (base_parent, base_transform_ron) = existing_base.unwrap_or_else(|| {
let source_parent = project_root
.as_ref()
.and_then(|root| source_parent_for_target(root, &instance, target).ok());
let base_parent = source_parent.unwrap_or_else(|| {
world
.get::<ChildOf>(*member)
.map(ChildOf::parent)
.and_then(|parent| prefab_member_override_target(world, parent))
.filter(|(root, path)| {
*root == instance_root && path.instance_chain == target.instance_chain
})
.map(|(_, path)| path)
});
let base_transform = project_root
.as_ref()
.and_then(|root| {
source_component_for_target(
root,
&instance,
target,
std::any::type_name::<Transform>(),
)
.ok()
.flatten()
.map(|component| component.ron)
})
.or_else(|| {
world
.get::<Transform>(*member)
.and_then(|transform| ron::to_string(transform).ok())
});
(base_parent, base_transform)
});
overrides.structural.retain(|candidate| {
!matches!(candidate, PrefabStructuralOverride::ReparentChild { target: existing, .. } if existing == target)
});
if new_parent_target != base_parent {
overrides
.structural
.push(PrefabStructuralOverride::ReparentChild {
target: target.clone(),
base_parent,
new_parent: new_parent_target.clone(),
base_transform_ron,
local_transform_ron: local_transform_ron.clone(),
});
}
}
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, instance_root, &overrides);
for (member, _, new_transform, _) in plans {
if let Ok(mut entity_mut) = world.get_entity_mut(member) {
entity_mut.insert(ChildOf(runtime_parent));
if let Some(transform) = new_transform {
entity_mut.insert(transform);
}
}
}
record_prefab_edit_state(world, instance_root, old);
true
}
fn hierarchy_would_cycle(world: &World, entity: Entity, parent: Entity) -> bool {
let mut current = Some(parent);
let mut seen = HashSet::new();
while let Some(candidate) = current {
if candidate == entity || !seen.insert(candidate) {
return true;
}
current = world.get::<ChildOf>(candidate).map(ChildOf::parent);
}
false
}
pub fn revert_child_visibility_overrides(world: &mut World, entity: Entity) {
let old = capture_prefab_edit_state(world, entity);
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let Some(mut overrides) = read_overrides_for_edit(world, &instance) else {
return;
};
if let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
{
let _ = promote_legacy_visibility_overrides(&project_root, &instance, &mut overrides);
}
let component_type = std::any::type_name::<EditorVisibility>();
let restorations: Vec<_> = overrides
.components
.iter()
.filter(|component| component.component_type == component_type)
.map(|component| {
(
component.target.clone(),
component.base_component_ron.clone(),
)
})
.collect();
for (target, base) in restorations {
let Some(member) = prefab_member_for_target(world, entity, &target) else {
continue;
};
if let Some(value) = base.and_then(|ron| ron::from_str::<EditorVisibility>(&ron).ok()) {
world.entity_mut(member).insert(value);
} else {
world.entity_mut(member).remove::<EditorVisibility>();
}
}
overrides.child_visibility.clear();
overrides
.components
.retain(|component| component.component_type != component_type);
track_current_source_revision(world, &instance, &mut overrides);
write_overrides(world, entity, &overrides);
record_prefab_edit_state(world, entity, old);
}
pub fn apply_stored_overrides(world: &mut World, entity: Entity) {
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let Ok(overrides) = try_read_overrides(&instance) else {
return;
};
for child in prefab_instance_members(world, entity) {
let is_direct = prefab_member_override_target(world, child)
.is_some_and(|(root, target)| root == entity && target.instance_chain.is_empty());
if !is_direct {
continue;
}
let Some(actor_id) = world.get::<ActorId>(child).map(|id| id.0.as_str()) else {
continue;
};
let legacy_name = world.get::<Name>(child).map(|name| name.as_str());
if let Some(&visible) = overrides
.child_visibility
.get(actor_id)
.or_else(|| legacy_name.and_then(|name| overrides.child_visibility.get(name)))
{
if let Ok(mut entity_mut) = world.get_entity_mut(child) {
entity_mut.insert(EditorVisibility { visible });
}
}
}
}
fn prefab_member_for_target(
world: &mut World,
instance_root: Entity,
target: &PrefabActorPath,
) -> Option<Entity> {
let candidates: Vec<Entity> = world
.query_filtered::<Entity, With<HydratedPrefabMember>>()
.iter(world)
.collect();
candidates.into_iter().find(|candidate| {
prefab_member_override_target(world, *candidate).is_some_and(|(root, candidate_target)| {
root == instance_root && candidate_target == *target
})
})
}
pub fn invalidate_prefab_health_on_ready(
ready: On<WorldInstanceReady>,
mut health_cache: Option<ResMut<PrefabHealthCache>>,
) {
if let Some(cache) = health_cache.as_deref_mut() {
cache.entries.remove(&ready.entity);
}
}
pub fn guard_prefab_reimports(
mut events: MessageReader<AssetEvent<DynamicWorld>>,
mut failures: MessageReader<bevy::asset::AssetLoadFailedEvent<DynamicWorld>>,
roots: Query<(Entity, &DynamicWorldRoot, &PrefabInstance)>,
workspace: Res<crate::project_io::ProjectWorkspace>,
mut commands: Commands,
) {
let mut changed: std::collections::HashSet<_> = events
.read()
.filter_map(|event| match event {
AssetEvent::Modified { id }
| AssetEvent::Removed { id }
| AssetEvent::LoadedWithDependencies { id } => Some(*id),
_ => None,
})
.collect();
let failed: HashMap<_, _> = failures
.read()
.map(|failure| {
(
failure.id,
format!("could not load {}: {}", failure.path, failure.error),
)
})
.collect();
changed.extend(failed.keys().copied());
if changed.is_empty() {
return;
}
let project_root = Path::new(&workspace.root);
for (entity, dynamic_root, instance) in &roots {
if !changed.contains(&dynamic_root.id()) {
continue;
}
if let Some(reason) = failed.get(&dynamic_root.id()) {
let reason = reason.clone();
commands.queue(move |world: &mut World| {
block_prefab_hydration(world, entity, reason);
});
continue;
}
match scene::validate_prefab_graph(&project_root.join(&instance.source_path), project_root)
{
Ok(()) => {
commands
.entity(entity)
.remove::<shared::PrefabHydrationBlocked>();
}
Err(reason) => {
commands.queue(move |world: &mut World| {
block_prefab_hydration(world, entity, reason);
});
}
}
}
}
fn block_prefab_hydration(world: &mut World, entity: Entity, reason: String) {
let mut entity = entity;
let mut seen = HashSet::new();
while seen.insert(entity) {
let Some(owner) = world
.get::<HydratedPrefabMember>(entity)
.map(|member| member.instance_root)
else {
break;
};
entity = owner;
}
// Nested instances are owned by generated members of their parent instance. Walk that
// ownership graph so an invalid reload cannot leave an unregistered nested tree behind.
let ownership: Vec<(Entity, Entity)> = world
.query::<(Entity, &HydratedPrefabMember)>()
.iter(world)
.map(|(member, owner)| (member, owner.instance_root))
.collect();
let mut owned_roots = HashSet::from([entity]);
let mut changed = true;
while changed {
changed = false;
for (member, owner) in &ownership {
if owned_roots.contains(owner) && owned_roots.insert(*member) {
changed = true;
}
}
}
owned_roots.remove(&entity);
for member in owned_roots {
if let Ok(entity_mut) = world.get_entity_mut(member) {
entity_mut.despawn();
}
}
if let Ok(mut root) = world.get_entity_mut(entity) {
// Remove the asset root while WorldInstance is still present so Bevy unregisters the
// instance, then remove the stale ID before a repaired source is hydrated.
root.insert(shared::PrefabHydrationBlocked { reason })
.remove::<HydratedPrefabReady>()
.remove::<DynamicWorldRoot>()
.remove::<WorldInstance>();
}
if let Some(mut cache) = world.get_resource_mut::<PrefabHealthCache>() {
cache.entries.remove(&entity);
}
}
fn retry_prefab_source(world: &mut World, entity: Entity) {
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let Some(project_root) = world
.get_resource::<crate::project_io::ProjectWorkspace>()
.map(|workspace| PathBuf::from(&workspace.root))
else {
return;
};
if let Err(error) =
scene::validate_prefab_graph(&project_root.join(&instance.source_path), &project_root)
{
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Prefab retry failed: {error}");
return;
}
block_prefab_hydration(world, entity, "restarting prefab hydration".to_string());
if let Ok(mut root) = world.get_entity_mut(entity) {
root.remove::<shared::PrefabHydrationBlocked>().insert((
instance.clone(),
PrefabRef::new(instance.source_path.clone()),
));
}
world.resource_mut::<crate::scene_io::SceneIo>().status =
"Prefab source queued for reload".into();
}
pub fn prefab_member_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some((instance_root, target)) = prefab_member_override_target(world, entity) else {
ui.label(egui::RichText::new("Prefab target unavailable").color(ERROR));
return;
};
let Some(instance) = world.get::<PrefabInstance>(instance_root).cloned() else {
return;
};
let overrides = match try_read_overrides(&instance) {
Ok(overrides) => overrides,
Err(error) => {
ui.label(egui::RichText::new(error).color(ERROR));
ui.label("Repair the prefab instance from its root inspector before editing members.");
return;
}
};
let transform_type = std::any::type_name::<Transform>();
let material_type = std::any::type_name::<MaterialDesc>();
let property_active = |component_type: &str, property_path: &str| {
overrides.properties.iter().any(|candidate| {
candidate.target == target
&& candidate.component_type == component_type
&& candidate.property_path == property_path
})
};
let component_active = |component_type: &str| {
overrides.components.iter().any(|candidate| {
candidate.target == target && candidate.component_type == component_type
})
};
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new("Override layer")
.strong()
.color(WARNING),
);
ui.label(
egui::RichText::new(format!("{} / {}", instance.source_path, target.actor_id))
.color(TEXT_MUTED),
);
});
ui.add_space(4.0);
if let Some(mut transform) = world.get::<Transform>(entity).copied() {
ui.collapsing("Transform", |ui| {
let old_translation = transform.translation;
ui.horizontal_wrapped(|ui| {
ui.label("Position");
ui.add(egui::DragValue::new(&mut transform.translation.x).speed(0.1));
ui.add(egui::DragValue::new(&mut transform.translation.y).speed(0.1));
ui.add(egui::DragValue::new(&mut transform.translation.z).speed(0.1));
if property_active(transform_type, "translation")
&& ui.small_button("Revert").clicked()
{
revert_prefab_override_scope(
world,
entity,
Some(transform_type),
Some("translation"),
);
}
if property_active(transform_type, "translation")
&& ui.small_button("Apply to source").clicked()
{
apply_prefab_member_scope_to_source(
world,
entity,
Some(transform_type),
Some("translation"),
);
}
});
if transform.translation != old_translation {
set_prefab_transform_property_override(world, entity, "translation", transform);
}
let old_rotation = transform.rotation;
let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ);
let mut degrees = Vec3::new(pitch.to_degrees(), yaw.to_degrees(), roll.to_degrees());
ui.horizontal_wrapped(|ui| {
ui.label("Rotation");
ui.add(egui::DragValue::new(&mut degrees.x).speed(0.5));
ui.add(egui::DragValue::new(&mut degrees.y).speed(0.5));
ui.add(egui::DragValue::new(&mut degrees.z).speed(0.5));
if property_active(transform_type, "rotation")
&& ui.small_button("Revert").clicked()
{
revert_prefab_override_scope(
world,
entity,
Some(transform_type),
Some("rotation"),
);
}
if property_active(transform_type, "rotation")
&& ui.small_button("Apply to source").clicked()
{
apply_prefab_member_scope_to_source(
world,
entity,
Some(transform_type),
Some("rotation"),
);
}
});
transform.rotation = Quat::from_euler(
EulerRot::YXZ,
degrees.y.to_radians(),
degrees.x.to_radians(),
degrees.z.to_radians(),
);
if transform.rotation != old_rotation {
set_prefab_transform_property_override(world, entity, "rotation", transform);
}
let old_scale = transform.scale;
ui.horizontal_wrapped(|ui| {
ui.label("Scale");
ui.add(egui::DragValue::new(&mut transform.scale.x).speed(0.05));
ui.add(egui::DragValue::new(&mut transform.scale.y).speed(0.05));
ui.add(egui::DragValue::new(&mut transform.scale.z).speed(0.05));
if property_active(transform_type, "scale") && ui.small_button("Revert").clicked() {
revert_prefab_override_scope(
world,
entity,
Some(transform_type),
Some("scale"),
);
}
if property_active(transform_type, "scale")
&& ui.small_button("Apply to source").clicked()
{
apply_prefab_member_scope_to_source(
world,
entity,
Some(transform_type),
Some("scale"),
);
}
});
if transform.scale != old_scale {
set_prefab_transform_property_override(world, entity, "scale", transform);
}
});
}
ui.collapsing("Material", |ui| {
if let Some(mut material) = world.get::<MaterialDesc>(entity).cloned() {
let mut color = [
material.base_color.r,
material.base_color.g,
material.base_color.b,
material.base_color.a,
];
ui.horizontal_wrapped(|ui| {
ui.label("Base color");
if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() {
material.base_color.r = color[0];
material.base_color.g = color[1];
material.base_color.b = color[2];
material.base_color.a = color[3];
set_prefab_material_property_override(
world,
entity,
"base_color",
material.clone(),
);
}
if property_active(material_type, "base_color")
&& ui.small_button("Revert").clicked()
{
revert_prefab_override_scope(
world,
entity,
Some(material_type),
Some("base_color"),
);
}
if property_active(material_type, "base_color")
&& ui.small_button("Apply to source").clicked()
{
apply_prefab_member_scope_to_source(
world,
entity,
Some(material_type),
Some("base_color"),
);
}
});
let old_metallic = material.metallic;
ui.horizontal_wrapped(|ui| {
ui.label("Metallic");
ui.add(egui::Slider::new(&mut material.metallic, 0.0..=1.0));
if property_active(material_type, "metallic") && ui.small_button("Revert").clicked()
{
revert_prefab_override_scope(
world,
entity,
Some(material_type),
Some("metallic"),
);
}
if property_active(material_type, "metallic")
&& ui.small_button("Apply to source").clicked()
{
apply_prefab_member_scope_to_source(
world,
entity,
Some(material_type),
Some("metallic"),
);
}
});
if material.metallic != old_metallic {
set_prefab_material_property_override(world, entity, "metallic", material.clone());
}
let old_roughness = material.roughness;
ui.horizontal_wrapped(|ui| {
ui.label("Roughness");
ui.add(egui::Slider::new(&mut material.roughness, 0.0..=1.0));
if property_active(material_type, "roughness")
&& ui.small_button("Revert").clicked()
{
revert_prefab_override_scope(
world,
entity,
Some(material_type),
Some("roughness"),
);
}
if property_active(material_type, "roughness")
&& ui.small_button("Apply to source").clicked()
{
apply_prefab_member_scope_to_source(
world,
entity,
Some(material_type),
Some("roughness"),
);
}
});
if material.roughness != old_roughness {
set_prefab_material_property_override(world, entity, "roughness", material);
}
if ui.small_button("Remove component").clicked() {
remove_prefab_component_override::<MaterialDesc>(world, entity);
}
if overrides.properties.iter().any(|candidate| {
candidate.target == target && candidate.component_type == material_type
}) || component_active(material_type)
{
ui.horizontal_wrapped(|ui| {
if ui.small_button("Apply component to source").clicked() {
apply_prefab_member_scope_to_source(
world,
entity,
Some(material_type),
None,
);
}
if ui.small_button("Revert component overrides").clicked() {
revert_prefab_override_scope(world, entity, Some(material_type), None);
}
});
}
} else if component_active(material_type) {
ui.horizontal_wrapped(|ui| {
if ui.button("Apply removal to source").clicked() {
apply_prefab_member_scope_to_source(world, entity, Some(material_type), None);
}
if ui.button("Restore source component").clicked() {
revert_prefab_override_scope(world, entity, Some(material_type), None);
}
});
} else if ui.button("Add material component").clicked() {
set_prefab_component_override(world, entity, MaterialDesc::default());
}
});
ui.separator();
ui.horizontal_wrapped(|ui| {
if ui.button("Apply actor to source").clicked() {
apply_prefab_member_scope_to_source(world, entity, None, None);
}
if ui.button("Revert actor overrides").clicked() {
revert_prefab_override_scope(world, entity, None, None);
}
if ui
.add(egui::Button::new("Remove source actor"))
.on_hover_text("Creates an undoable structural override")
.clicked()
{
remove_prefab_source_child(world, entity);
}
});
}
pub fn prefab_instance_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
return;
};
let parsed_overrides = try_read_overrides(&instance);
let overrides_valid = parsed_overrides.is_ok();
let overrides = parsed_overrides.clone().unwrap_or_default();
let health = parsed_overrides.map_or_else(PrefabHealth::InvalidOverrides, |_| {
prefab_health(world, entity, &instance, &overrides)
});
let mut options = ComponentCardOptions::fixed(
shared::COMPONENT_PREFAB_INSTANCE,
"Prefab Instance",
icons::PACKAGE,
);
options.resettable = false;
let card = component_card_context(world, entity, options);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Asset ID", |ui| {
ui.add(egui::Label::new(instance.asset_id.as_str()).truncate());
});
property_row(ui, "Source", |ui| {
ui.add(egui::Label::new(instance.source_path.as_str()).truncate());
});
property_row(ui, "Status", |ui| {
let (label, color, detail) = match &health {
PrefabHealth::Ready => ("Ready", SUCCESS, None),
PrefabHealth::Loading => (
"Loading source",
TEXT_MUTED,
Some("Override targets will be available when the prefab finishes loading"),
),
PrefabHealth::Untracked => (
"Base not tracked",
WARNING,
Some("Record the current source revision before production use"),
),
PrefabHealth::ChangedBase => (
"Base changed",
WARNING,
Some("The source file changed after these overrides were recorded"),
),
PrefabHealth::Conflict(stale) => (
"Override conflict",
ERROR,
Some(if stale.len() == 1 {
"One override conflicts with the current source value, hierarchy, or target"
} else {
"Multiple overrides conflict with current source values, hierarchy, or targets"
}),
),
PrefabHealth::Broken(error) => ("Broken source", ERROR, Some(error.as_str())),
PrefabHealth::InvalidOverrides(error) => {
("Invalid overrides", ERROR, Some(error.as_str()))
}
};
let response = ui.label(egui::RichText::new(label).strong().color(color));
if let Some(detail) = detail {
response.on_hover_text(detail);
}
});
ui.horizontal_wrapped(|ui| match &health {
PrefabHealth::Ready => {
if ui.small_button("Reload source").clicked() {
world.entity_mut(entity).insert((
instance.clone(),
PrefabRef::new(instance.source_path.clone()),
));
}
if ui.small_button("Relink...").clicked() {
relink_prefab_source(world, entity);
}
}
PrefabHealth::Untracked | PrefabHealth::ChangedBase => {
if ui.small_button("Accept current base").clicked() {
accept_current_source(world, entity);
}
if ui.small_button("Relink...").clicked() {
relink_prefab_source(world, entity);
}
}
PrefabHealth::Conflict(stale) => {
ui.label(
egui::RichText::new(format!("{} conflicts", stale.len())).color(TEXT_MUTED),
);
if ui.small_button("Keep overrides").clicked() {
keep_prefab_overrides(world, entity);
}
if ui.small_button("Take source").clicked() {
take_source_for_conflicts(world, entity);
}
if ui.small_button("Remove missing targets").clicked() {
remove_stale_visibility_overrides(world, entity);
}
if ui.small_button("Relink...").clicked() {
relink_prefab_source(world, entity);
}
}
PrefabHealth::Broken(_) => {
if ui.small_button("Retry source").clicked() {
retry_prefab_source(world, entity);
}
if ui.small_button("Relink source...").clicked() {
relink_prefab_source(world, entity);
}
}
PrefabHealth::Loading => {
ui.spinner();
}
PrefabHealth::InvalidOverrides(_) => {
if ui.small_button("Discard invalid data").clicked() {
discard_invalid_overrides(world, entity);
}
}
});
ui.add_enabled_ui(overrides_valid, |ui| {
let operation_count = overrides.properties.len()
+ overrides.components.len()
+ overrides.structural.len()
+ overrides.child_visibility.len();
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(format!("{operation_count} override operations"))
.color(TEXT_MUTED),
);
if ui
.add_enabled(
operation_count > 0,
egui::Button::new("Apply all to source"),
)
.clicked()
{
apply_prefab_instance_overrides_to_source(world, entity);
}
if ui
.add_enabled(operation_count > 0, egui::Button::new("Revert all"))
.clicked()
{
revert_prefab_instance_overrides(world, entity);
}
});
if !overrides.structural.is_empty() {
let mut structural_action = None;
ui.collapsing("Structural overrides", |ui| {
for structural in &overrides.structural {
let target = structural_target(structural).clone();
let operation = match structural {
PrefabStructuralOverride::RemoveChild { .. } => "Removed",
PrefabStructuralOverride::ReparentChild { .. } => "Reparented",
PrefabStructuralOverride::AttachLocalChild { .. } => "Attached",
};
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(format!(
"{operation} {}",
actor_path_key(&target)
))
.color(TEXT_MUTED),
);
if ui.small_button("Apply actor").clicked() {
structural_action = Some((true, target.clone()));
}
if ui.small_button("Revert actor").clicked() {
structural_action = Some((false, target));
}
});
}
});
if let Some((apply, target)) = structural_action {
if apply {
apply_prefab_actor_path_to_source(world, entity, &target);
} else {
revert_prefab_actor_path(world, entity, &target);
}
}
}
ui.collapsing("Visibility overrides", |ui| {
let visibility_type = std::any::type_name::<EditorVisibility>();
let active = !overrides.child_visibility.is_empty()
|| overrides
.components
.iter()
.any(|component| component.component_type == visibility_type);
if active {
ui.label(egui::RichText::new("Overrides use stable nested actor paths").weak());
}
for child in prefab_instance_members(world, entity) {
let Some((_, target)) = prefab_member_override_target(world, child) else {
continue;
};
let name = world
.get::<Name>(child)
.map(|n| n.to_string())
.unwrap_or_else(|| format!("{child:?}"));
let mut visible = overrides
.components
.iter()
.find(|component| {
component.target == target
&& component.component_type == visibility_type
})
.and_then(|component| component.value_component_ron.as_deref())
.and_then(|ron| ron::from_str::<EditorVisibility>(ron).ok())
.map(|visibility| visibility.visible)
.or_else(|| {
target
.instance_chain
.is_empty()
.then(|| overrides.child_visibility.get(&target.actor_id).copied())
.flatten()
})
.unwrap_or_else(|| {
world
.get::<EditorVisibility>(child)
.map(|visibility| visibility.visible)
.unwrap_or(true)
});
ui.horizontal_wrapped(|ui| {
ui.add(
egui::Label::new(format!("{name} ({})", actor_path_key(&target)))
.truncate(),
);
if ui.checkbox(&mut visible, "Visible").changed() {
set_child_visibility_override(world, entity, child, visible);
}
});
}
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(active, egui::Button::new("Revert all"))
.clicked()
{
revert_child_visibility_overrides(world, entity);
}
});
});
});
ui.horizontal_wrapped(|ui| {
if ui.button("Create Variant...").clicked() {
world.resource_mut::<crate::scene_io::SceneIo>().request =
Some(crate::scene_io::SceneIoRequest::SaveSelectionAsPrefab);
}
if ui
.add_enabled(
world.get::<HydratedPrefabReady>(entity).is_some(),
egui::Button::new("Unpack Layer"),
)
.on_disabled_hover_text("Prefab source has not finished loading")
.on_hover_text("Make this prefab layer local while preserving nested prefab links")
.clicked()
{
crate::history::unpack_prefab_instance(world, entity);
}
if ui
.add_enabled(
world.get::<HydratedPrefabReady>(entity).is_some(),
egui::Button::new("Convert to Local"),
)
.on_disabled_hover_text("Prefab source has not finished loading")
.on_hover_text(
"Recursively replace this instance and all nested links with local actors",
)
.clicked()
{
crate::history::convert_prefab_completely_to_local(world, entity);
}
});
});
apply_component_card_response(world, entity, card_response);
}
fn prefab_instance_members(world: &mut World, root: Entity) -> Vec<Entity> {
let candidates: Vec<Entity> = world
.query_filtered::<Entity, With<HydratedPrefabMember>>()
.iter(world)
.collect();
let mut members: Vec<_> = candidates
.into_iter()
.filter_map(|candidate| {
prefab_member_override_target(world, candidate)
.filter(|(owner, _)| *owner == root)
.map(|(_, target)| (actor_path_key(&target), candidate))
})
.collect();
members.sort_by(|(left, _), (right, _)| left.cmp(right));
members
.into_iter()
.map(|(_, candidate)| candidate)
.collect()
}
pub fn list_prefab_dependencies(path: &str) -> Vec<String> {
vec![path.to_string()]
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::asset::AssetPlugin;
use bevy::prelude::DynamicWorldRoot;
use bevy::world_serialization::{DynamicWorld, WorldInstanceSpawner, WorldSerializationPlugin};
use shared::LevelObject;
fn prefab_source(actor_id: &str, material: Option<&MaterialDesc>) -> String {
let material = material.map(|material| {
format!(
",\"{}\":{}",
std::any::type_name::<MaterialDesc>(),
ron::to_string(material).unwrap()
)
});
format!(
"(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"{actor_id}\"),\"shared::components::ActorKind\":Empty,\"{}\":{}{} }})}})",
std::any::type_name::<Transform>(),
ron::to_string(&Transform::default()).unwrap(),
material.unwrap_or_default(),
)
}
fn hierarchy_prefab_source(child_transform: Transform) -> String {
format!(
"(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"parent\"),\"shared::components::ActorKind\":Empty,\"{}\":{}}}),2:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"child\"),\"shared::components::ActorKind\":Empty,\"{}\":{},\"bevy_ecs::hierarchy::ChildOf\":(1)}})}})",
std::any::type_name::<Transform>(),
ron::to_string(&Transform::default()).unwrap(),
std::any::type_name::<Transform>(),
ron::to_string(&child_transform).unwrap(),
)
}
fn prefab_test_world(project_root: &Path) -> World {
let mut world = World::new();
world.init_resource::<crate::scene_io::SceneIo>();
world.init_resource::<crate::history::EditorHistory>();
world.insert_resource(crate::project_io::ProjectWorkspace {
root: project_root.display().to_string(),
..Default::default()
});
world
}
#[test]
fn empty_overrides_roundtrip() {
let instance = PrefabInstance::new("id", "assets/prefabs/test.scn.ron");
assert_eq!(read_overrides(&instance), PrefabOverrides::default());
}
#[test]
fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() {
let mut world = World::new();
world.init_resource::<crate::scene_io::SceneIo>();
let material = MaterialDesc::default();
let mut outer = PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron");
outer.overrides_ron = encode_prefab_overrides(&PrefabOverrides {
properties: ["first", "second"]
.into_iter()
.map(|anchor| PrefabPropertyOverride {
target: PrefabActorPath {
instance_chain: vec![anchor.to_string()],
actor_id: "mesh".to_string(),
},
component_type: std::any::type_name::<MaterialDesc>().to_string(),
property_path: "roughness".to_string(),
base_component_ron: ron::to_string(&material).unwrap(),
value_component_ron: ron::to_string(&material).unwrap(),
})
.collect(),
..default()
})
.unwrap();
let root = world.spawn(outer).id();
let first = world
.spawn((
ActorId::new("first"),
PrefabInstance::new("first", "assets/prefabs/first.scn.ron"),
HydratedPrefabMember {
instance_root: root,
},
))
.id();
let mut invalid = PrefabInstance::new("second", "assets/prefabs/second.scn.ron");
invalid.overrides_ron = Some("invalid".to_string());
world.spawn((
ActorId::new("second"),
invalid,
HydratedPrefabMember {
instance_root: root,
},
));
assert!(fold_nested_overrides_for_unpack(&mut world, root).is_err());
assert!(world
.get::<PrefabInstance>(first)
.unwrap()
.overrides_ron
.is_none());
}
#[test]
fn property_apply_to_source_is_transactional_and_history_guarded() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-source-apply-{}",
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 source_path = prefabs.join("base.scn.ron");
let base_material = MaterialDesc::default();
let before_source = prefab_source("mesh", Some(&base_material));
std::fs::write(&source_path, &before_source).unwrap();
let mut world = prefab_test_world(&root);
let instance_root = world
.spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron"))
.id();
let member = world
.spawn((
ActorId::new("mesh"),
base_material.clone(),
HydratedPrefabMember { instance_root },
ChildOf(instance_root),
))
.id();
let mut overridden = base_material.clone();
overridden.roughness = 0.2;
set_prefab_material_property_override(&mut world, member, "roughness", overridden);
apply_prefab_member_scope_to_source(
&mut world,
member,
Some(std::any::type_name::<MaterialDesc>()),
Some("roughness"),
);
let applied_source = std::fs::read_to_string(&source_path).unwrap();
let document = SceneDocument::from_ron_text(&applied_source).unwrap();
let material: MaterialDesc = ron::from_str(
&document.entities[0]
.components
.iter()
.find(|component| component.type_name == std::any::type_name::<MaterialDesc>())
.unwrap()
.ron,
)
.unwrap();
assert_eq!(material.roughness, 0.2);
assert!(
read_overrides(world.get::<PrefabInstance>(instance_root).unwrap())
.properties
.is_empty()
);
assert_eq!(
world
.resource::<crate::history::EditorHistory>()
.undo_depth(),
2
);
crate::history::apply_command_undo(&mut world);
assert_eq!(
std::fs::read_to_string(&source_path).unwrap(),
before_source
);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(instance_root).unwrap())
.properties
.len(),
1
);
crate::history::apply_command_redo(&mut world);
assert_eq!(
std::fs::read_to_string(&source_path).unwrap(),
applied_source
);
std::fs::write(&source_path, format!("{applied_source}\n")).unwrap();
let undo_depth = world
.resource::<crate::history::EditorHistory>()
.undo_depth();
crate::history::apply_command_undo(&mut world);
assert_eq!(
world
.resource::<crate::history::EditorHistory>()
.undo_depth(),
undo_depth
);
assert!(world
.resource::<crate::history::EditorHistory>()
.status
.contains("changed outside"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn added_component_field_apply_commits_prerequisite_and_round_trips() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-component-apply-{}",
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 source_path = prefabs.join("base.scn.ron");
let before_source = prefab_source("mesh", None);
std::fs::write(&source_path, &before_source).unwrap();
let mut world = prefab_test_world(&root);
let instance_root = world
.spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron"))
.id();
let member = world
.spawn((
ActorId::new("mesh"),
HydratedPrefabMember { instance_root },
ChildOf(instance_root),
))
.id();
let base = MaterialDesc::default();
set_prefab_component_override(&mut world, member, base.clone());
let mut edited = base;
edited.roughness = 0.17;
set_prefab_material_property_override(&mut world, member, "roughness", edited);
apply_prefab_member_scope_to_source(
&mut world,
member,
Some(std::any::type_name::<MaterialDesc>()),
Some("roughness"),
);
let after_source = std::fs::read_to_string(&source_path).unwrap();
let document = SceneDocument::from_ron_text(&after_source).unwrap();
let material: MaterialDesc = ron::from_str(
&document
.find_entity(&SceneEntityId::ActorId("mesh".into()))
.unwrap()
.components
.iter()
.find(|component| component.type_name == std::any::type_name::<MaterialDesc>())
.unwrap()
.ron,
)
.unwrap();
assert_eq!(material.roughness, 0.17);
let applied = read_overrides(world.get::<PrefabInstance>(instance_root).unwrap());
assert!(applied.components.is_empty());
assert!(applied.properties.is_empty());
crate::history::apply_command_undo(&mut world);
assert_eq!(
std::fs::read_to_string(&source_path).unwrap(),
before_source
);
let restored = read_overrides(world.get::<PrefabInstance>(instance_root).unwrap());
assert_eq!(restored.components.len(), 1);
assert_eq!(restored.properties.len(), 1);
crate::history::apply_command_redo(&mut world);
assert_eq!(std::fs::read_to_string(&source_path).unwrap(), after_source);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn removed_component_apply_and_undo_preserve_source_bytes() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-component-remove-{}",
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 source_path = prefabs.join("base.scn.ron");
let material = MaterialDesc::default();
let before_source = prefab_source("mesh", Some(&material));
std::fs::write(&source_path, &before_source).unwrap();
let mut world = prefab_test_world(&root);
let instance_root = world
.spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron"))
.id();
let member = world
.spawn((
ActorId::new("mesh"),
material,
HydratedPrefabMember { instance_root },
ChildOf(instance_root),
))
.id();
remove_prefab_component_override::<MaterialDesc>(&mut world, member);
apply_prefab_member_scope_to_source(
&mut world,
member,
Some(std::any::type_name::<MaterialDesc>()),
None,
);
let after_source = std::fs::read_to_string(&source_path).unwrap();
let document = SceneDocument::from_ron_text(&after_source).unwrap();
assert!(document.entities[0]
.components
.iter()
.all(|component| component.type_name != std::any::type_name::<MaterialDesc>()));
assert!(
read_overrides(world.get::<PrefabInstance>(instance_root).unwrap())
.components
.is_empty()
);
crate::history::apply_command_undo(&mut world);
assert_eq!(
std::fs::read_to_string(&source_path).unwrap(),
before_source
);
let restored = read_overrides(world.get::<PrefabInstance>(instance_root).unwrap());
assert_eq!(restored.components.len(), 1);
assert!(restored.components[0].value_component_ron.is_none());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn structural_remove_apply_is_exact_byte_undoable() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-structural-apply-{}",
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 source_path = prefabs.join("hierarchy.scn.ron");
let before_source = hierarchy_prefab_source(Transform::from_xyz(1.0, 2.0, 3.0));
std::fs::write(&source_path, &before_source).unwrap();
let mut world = prefab_test_world(&root);
let instance = PrefabInstance::new("hierarchy", "assets/prefabs/hierarchy.scn.ron");
let instance_root = world.spawn(instance.clone()).id();
let revision = prefab_source_revision(&root, &instance.source_path).unwrap();
write_overrides(
&mut world,
instance_root,
&PrefabOverrides {
source_revision: Some(revision.clone()),
structural: vec![PrefabStructuralOverride::RemoveChild {
target: PrefabActorPath::direct("child"),
base_subtree_revision: revision,
}],
..default()
},
);
apply_prefab_instance_overrides_to_source(&mut world, instance_root);
let after_source = std::fs::read_to_string(&source_path).unwrap();
let document = SceneDocument::from_ron_text(&after_source).unwrap();
assert!(document
.find_entity(&SceneEntityId::ActorId("child".into()))
.is_none());
assert!(
read_overrides(world.get::<PrefabInstance>(instance_root).unwrap())
.structural
.is_empty()
);
crate::history::apply_command_undo(&mut world);
assert_eq!(
std::fs::read_to_string(&source_path).unwrap(),
before_source
);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(instance_root).unwrap())
.structural
.len(),
1
);
crate::history::apply_command_redo(&mut world);
assert_eq!(std::fs::read_to_string(&source_path).unwrap(), after_source);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn reparent_then_transform_edit_applies_in_layer_order_and_undoes() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-reparent-apply-{}",
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 source_path = prefabs.join("hierarchy.scn.ron");
let before_source = hierarchy_prefab_source(Transform::from_xyz(1.0, 0.0, 0.0));
std::fs::write(&source_path, &before_source).unwrap();
let mut world = prefab_test_world(&root);
let instance_root = world
.spawn(PrefabInstance::new(
"hierarchy",
"assets/prefabs/hierarchy.scn.ron",
))
.id();
let parent = world
.spawn((
ActorId::new("parent"),
Transform::default(),
GlobalTransform::from_xyz(10.0, 0.0, 0.0),
HydratedPrefabMember { instance_root },
ChildOf(instance_root),
))
.id();
let child = world
.spawn((
ActorId::new("child"),
Transform::from_xyz(1.0, 0.0, 0.0),
GlobalTransform::from_xyz(11.0, 0.0, 0.0),
HydratedPrefabMember { instance_root },
ChildOf(parent),
))
.id();
assert!(reparent_prefab_source_child(&mut world, child, None));
assert_eq!(world.get::<Transform>(child).unwrap().translation.x, 11.0);
set_prefab_transform_property_override(
&mut world,
child,
"translation",
Transform::from_xyz(12.0, 0.0, 0.0),
);
let instance = world.get::<PrefabInstance>(instance_root).unwrap().clone();
let overrides = read_overrides(&instance);
assert!(prefab_semantic_conflicts(&root, &instance, &overrides).is_empty());
apply_prefab_actor_path_to_source(
&mut world,
instance_root,
&PrefabActorPath::direct("child"),
);
let after_source = std::fs::read_to_string(&source_path).unwrap();
let document = SceneDocument::from_ron_text(&after_source).unwrap();
let child_source = document
.find_entity(&SceneEntityId::ActorId("child".into()))
.unwrap();
assert!(child_source.parent_actor_id.is_none());
let transform: Transform = ron::from_str(
&child_source
.components
.iter()
.find(|component| component.type_name == std::any::type_name::<Transform>())
.unwrap()
.ron,
)
.unwrap();
assert_eq!(transform.translation.x, 12.0);
crate::history::apply_command_undo(&mut world);
assert_eq!(
std::fs::read_to_string(&source_path).unwrap(),
before_source
);
let restored = read_overrides(world.get::<PrefabInstance>(instance_root).unwrap());
assert_eq!(restored.structural.len(), 1);
assert_eq!(restored.properties.len(), 1);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn multi_member_reparent_is_atomic_and_records_one_history_entry() {
let mut world = World::new();
world.init_resource::<crate::scene_io::SceneIo>();
world.init_resource::<crate::history::EditorHistory>();
let root = world
.spawn(PrefabInstance::new("prefab", "assets/prefabs/test.scn.ron"))
.id();
let parent = world
.spawn((
ActorId::new("parent"),
GlobalTransform::from_xyz(10.0, 0.0, 0.0),
HydratedPrefabMember {
instance_root: root,
},
ChildOf(root),
))
.id();
let first = world
.spawn((
ActorId::new("first"),
Transform::default(),
GlobalTransform::from_xyz(1.0, 0.0, 0.0),
HydratedPrefabMember {
instance_root: root,
},
ChildOf(root),
))
.id();
let second = world
.spawn((
ActorId::new("second"),
Transform::default(),
GlobalTransform::from_xyz(2.0, 0.0, 0.0),
HydratedPrefabMember {
instance_root: root,
},
ChildOf(root),
))
.id();
assert!(reparent_prefab_source_children(
&mut world,
&[first, second],
Some(parent),
));
assert_eq!(
world.get::<ChildOf>(first).map(ChildOf::parent),
Some(parent)
);
assert_eq!(
world.get::<ChildOf>(second).map(ChildOf::parent),
Some(parent)
);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(root).unwrap())
.structural
.len(),
2
);
assert_eq!(
world
.resource::<crate::history::EditorHistory>()
.undo_depth(),
1
);
let invalid = world.spawn_empty().id();
let before = world.get::<ChildOf>(first).map(ChildOf::parent);
assert!(!reparent_prefab_source_children(
&mut world,
&[first, invalid],
None,
));
assert_eq!(world.get::<ChildOf>(first).map(ChildOf::parent), before);
assert_eq!(
world
.resource::<crate::history::EditorHistory>()
.undo_depth(),
1
);
}
#[test]
fn nested_apply_updates_immediate_source_layer() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-nested-apply-{}",
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 base_path = prefabs.join("base.scn.ron");
let variant_path = prefabs.join("variant.scn.ron");
let base_material = MaterialDesc::default();
let base_source = prefab_source("mesh", Some(&base_material));
std::fs::write(&base_path, &base_source).unwrap();
let mut variant_material = base_material.clone();
variant_material.roughness = 0.2;
let mut nested_instance = PrefabInstance::new("base", "assets/prefabs/base.scn.ron");
nested_instance.overrides_ron = encode_prefab_overrides(&PrefabOverrides {
properties: vec![PrefabPropertyOverride {
target: PrefabActorPath::direct("mesh"),
component_type: std::any::type_name::<MaterialDesc>().to_string(),
property_path: "roughness".to_string(),
base_component_ron: ron::to_string(&base_material).unwrap(),
value_component_ron: ron::to_string(&variant_material).unwrap(),
}],
..default()
})
.unwrap();
let variant_source = format!(
"(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::LevelObject\":(),\"shared::components::ActorId\":(\"nested\"),\"shared::components::ActorKind\":PrefabAnchor,\"{}\":{},\"{}\":{} }})}})",
std::any::type_name::<Transform>(),
ron::to_string(&Transform::default()).unwrap(),
std::any::type_name::<PrefabInstance>(),
ron::to_string(&nested_instance).unwrap(),
);
std::fs::write(&variant_path, &variant_source).unwrap();
let mut world = prefab_test_world(&root);
let outer = world
.spawn(PrefabInstance::new(
"variant",
"assets/prefabs/variant.scn.ron",
))
.id();
let nested = world
.spawn((
ActorId::new("nested"),
nested_instance.clone(),
HydratedPrefabMember {
instance_root: outer,
},
ChildOf(outer),
))
.id();
let member = world
.spawn((
ActorId::new("mesh"),
variant_material.clone(),
HydratedPrefabMember {
instance_root: nested,
},
ChildOf(nested),
))
.id();
let mut overridden = variant_material;
overridden.roughness = 0.1;
set_prefab_material_property_override(&mut world, member, "roughness", overridden);
let outer_instance = world.get::<PrefabInstance>(outer).unwrap().clone();
let outer_overrides = read_overrides(&outer_instance);
assert!(prefab_semantic_conflicts(&root, &outer_instance, &outer_overrides).is_empty());
apply_prefab_member_scope_to_source(
&mut world,
member,
Some(std::any::type_name::<MaterialDesc>()),
Some("roughness"),
);
assert_eq!(std::fs::read_to_string(&base_path).unwrap(), base_source);
let document =
SceneDocument::from_ron_text(&std::fs::read_to_string(&variant_path).unwrap()).unwrap();
let nested_overrides = decode_prefab_overrides(&document.prefab_instances[0]).unwrap();
assert_eq!(nested_overrides.properties.len(), 1);
assert!(nested_overrides.properties[0]
.target
.instance_chain
.is_empty());
assert_eq!(nested_overrides.properties[0].target.actor_id, "mesh");
let preserved_base: MaterialDesc =
ron::from_str(&nested_overrides.properties[0].base_component_ron).unwrap();
let committed_value: MaterialDesc =
ron::from_str(&nested_overrides.properties[0].value_component_ron).unwrap();
assert_eq!(preserved_base.roughness, base_material.roughness);
assert_eq!(committed_value.roughness, 0.1);
let effective = source_component_for_target(
&root,
world.get::<PrefabInstance>(outer).unwrap(),
&PrefabActorPath {
instance_chain: vec!["nested".to_string()],
actor_id: "mesh".to_string(),
},
std::any::type_name::<MaterialDesc>(),
)
.unwrap()
.unwrap();
assert_eq!(
ron::from_str::<MaterialDesc>(&effective.ron)
.unwrap()
.roughness,
0.1
);
assert!(read_overrides(world.get::<PrefabInstance>(outer).unwrap())
.properties
.is_empty());
crate::history::apply_command_undo(&mut world);
assert_eq!(
std::fs::read_to_string(&variant_path).unwrap(),
variant_source
);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(outer).unwrap())
.properties
.len(),
1
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn source_field_conflict_keeps_override_recoverable() {
let root = std::env::temp_dir().join(format!(
"blacksite-prefab-source-conflict-{}",
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 source_path = prefabs.join("base.scn.ron");
let base = MaterialDesc::default();
std::fs::write(&source_path, prefab_source("mesh", Some(&base))).unwrap();
let mut world = prefab_test_world(&root);
let instance_root = world
.spawn(PrefabInstance::new("base", "assets/prefabs/base.scn.ron"))
.id();
let member = world
.spawn((
ActorId::new("mesh"),
base.clone(),
HydratedPrefabMember { instance_root },
ChildOf(instance_root),
))
.id();
let mut overridden = base.clone();
overridden.roughness = 0.1;
set_prefab_material_property_override(&mut world, member, "roughness", overridden);
let mut externally_changed = base;
externally_changed.roughness = 0.4;
let changed_source = prefab_source("mesh", Some(&externally_changed));
std::fs::write(&source_path, &changed_source).unwrap();
apply_prefab_member_scope_to_source(
&mut world,
member,
Some(std::any::type_name::<MaterialDesc>()),
Some("roughness"),
);
assert_eq!(
std::fs::read_to_string(&source_path).unwrap(),
changed_source
);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(instance_root).unwrap())
.properties
.len(),
1
);
assert!(world
.resource::<crate::scene_io::SceneIo>()
.status
.contains("conflict"));
assert_eq!(
world
.resource::<crate::history::EditorHistory>()
.undo_depth(),
1
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn visibility_component_override_survives_rename_by_actor_path() {
let mut world = World::new();
world.init_resource::<crate::scene_io::SceneIo>();
world.init_resource::<crate::history::EditorHistory>();
let root = world
.spawn((
LevelObject,
PrefabInstance::new("prefab-id", "assets/prefabs/test.scn.ron"),
))
.id();
let child = world
.spawn((
LevelObject,
ActorId::new("stable-child"),
Name::new("Old Name"),
EditorVisibility { visible: false },
ChildOf(root),
HydratedPrefabMember {
instance_root: root,
},
))
.id();
set_child_visibility_override(&mut world, root, child, false);
let overrides = read_overrides(world.get::<PrefabInstance>(root).unwrap());
assert!(overrides.child_visibility.is_empty());
assert_eq!(overrides.components.len(), 1);
assert_eq!(
overrides.components[0].target,
PrefabActorPath::direct("stable-child")
);
assert_eq!(
overrides.components[0].component_type,
std::any::type_name::<EditorVisibility>()
);
assert_eq!(
world
.resource::<crate::history::EditorHistory>()
.undo_depth(),
1
);
crate::history::apply_command_undo(&mut world);
assert!(read_overrides(world.get::<PrefabInstance>(root).unwrap())
.components
.is_empty());
crate::history::apply_command_redo(&mut world);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(root).unwrap()).components[0].target,
PrefabActorPath::direct("stable-child")
);
world.entity_mut(child).insert(Name::new("Renamed Child"));
assert_eq!(
read_overrides(world.get::<PrefabInstance>(root).unwrap()).components[0].target,
PrefabActorPath::direct("stable-child")
);
revert_child_visibility_overrides(&mut world, root);
assert_eq!(
world
.resource::<crate::history::EditorHistory>()
.undo_depth(),
2
);
assert!(world.get::<EditorVisibility>(child).is_none());
assert!(read_overrides(world.get::<PrefabInstance>(root).unwrap())
.components
.is_empty());
crate::history::apply_command_undo(&mut world);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(root).unwrap()).components[0].target,
PrefabActorPath::direct("stable-child")
);
crate::history::apply_command_redo(&mut world);
assert!(read_overrides(world.get::<PrefabInstance>(root).unwrap())
.components
.is_empty());
}
#[test]
fn nested_visibility_overrides_distinguish_repeated_leaf_actor_ids() {
let mut world = World::new();
world.init_resource::<crate::scene_io::SceneIo>();
world.init_resource::<crate::history::EditorHistory>();
let root = world
.spawn(PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"))
.id();
let nested_a = world
.spawn((
ActorId::new("nested-a"),
PrefabInstance::new("leaf", "assets/prefabs/leaf.scn.ron"),
HydratedPrefabMember {
instance_root: root,
},
ChildOf(root),
))
.id();
let nested_b = world
.spawn((
ActorId::new("nested-b"),
PrefabInstance::new("leaf", "assets/prefabs/leaf.scn.ron"),
HydratedPrefabMember {
instance_root: root,
},
ChildOf(root),
))
.id();
let leaf_a = world
.spawn((
ActorId::new("shared-leaf"),
EditorVisibility::default(),
HydratedPrefabMember {
instance_root: nested_a,
},
ChildOf(nested_a),
))
.id();
let leaf_b = world
.spawn((
ActorId::new("shared-leaf"),
EditorVisibility::default(),
HydratedPrefabMember {
instance_root: nested_b,
},
ChildOf(nested_b),
))
.id();
set_child_visibility_override(&mut world, root, leaf_a, false);
set_child_visibility_override(&mut world, root, leaf_b, false);
let overrides = read_overrides(world.get::<PrefabInstance>(root).unwrap());
let targets: HashSet<_> = overrides
.components
.iter()
.map(|component| component.target.clone())
.collect();
assert_eq!(targets.len(), 2);
assert!(targets.contains(&PrefabActorPath {
instance_chain: vec!["nested-a".to_string()],
actor_id: "shared-leaf".to_string(),
}));
assert!(targets.contains(&PrefabActorPath {
instance_chain: vec!["nested-b".to_string()],
actor_id: "shared-leaf".to_string(),
}));
crate::history::apply_command_undo(&mut world);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(root).unwrap())
.components
.len(),
1
);
crate::history::apply_command_redo(&mut world);
assert_eq!(
read_overrides(world.get::<PrefabInstance>(root).unwrap())
.components
.len(),
2
);
}
#[test]
fn ready_instance_reapplies_child_visibility_by_actor_id() {
let mut app = App::new();
app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin))
.register_type::<ActorId>()
.add_observer(shared::apply_prefab_overrides_on_ready);
let mut source = World::new();
source.spawn(ActorId::new("stable-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((
PrefabInstance::new("prefab-id", "assets/prefabs/test.scn.ron"),
DynamicWorldRoot(handle),
))
.id();
write_overrides(
app.world_mut(),
root,
&PrefabOverrides {
child_visibility: HashMap::from([("stable-child".to_string(), false)]),
..default()
},
);
app.update();
let world = app.world_mut();
let (_, visibility) = world
.query::<(&ActorId, &EditorVisibility)>()
.single(world)
.expect("ready observer should apply the stable child override");
assert!(!visibility.visible);
}
#[test]
fn invalid_reimport_cleanup_allows_single_clean_retry() {
let mut app = App::new();
app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin))
.register_type::<ActorId>();
let mut source = World::new();
source.spawn(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((
PrefabInstance::new("prefab-id", "assets/prefabs/test.scn.ron"),
DynamicWorldRoot(handle.clone()),
))
.id();
app.update();
let first_instance = **app
.world()
.get::<WorldInstance>(root)
.expect("initial prefab should be registered");
let first_members: Vec<_> = app
.world()
.resource::<WorldInstanceSpawner>()
.iter_instance_entities(first_instance)
.collect();
assert_eq!(first_members.len(), 1);
for member in first_members {
app.world_mut()
.entity_mut(member)
.insert(HydratedPrefabMember {
instance_root: root,
});
}
block_prefab_hydration(app.world_mut(), root, "invalid source".to_string());
assert!(app.world().get::<DynamicWorldRoot>(root).is_none());
assert!(app.world().get::<WorldInstance>(root).is_none());
assert!(app
.world()
.get::<shared::PrefabHydrationBlocked>(root)
.is_some());
assert_eq!(
app.world_mut()
.query::<&HydratedPrefabMember>()
.iter(app.world())
.count(),
0
);
assert!(!app
.world()
.resource::<WorldInstanceSpawner>()
.instance_is_ready(first_instance));
app.world_mut()
.entity_mut(root)
.remove::<shared::PrefabHydrationBlocked>()
.insert(DynamicWorldRoot(handle));
app.update();
let repaired_instance = **app
.world()
.get::<WorldInstance>(root)
.expect("repaired prefab should register a fresh instance");
let repaired_members: Vec<_> = app
.world()
.resource::<WorldInstanceSpawner>()
.iter_instance_entities(repaired_instance)
.collect();
assert_ne!(repaired_instance, first_instance);
assert_eq!(repaired_members.len(), 1);
assert_eq!(
app.world_mut()
.query::<&ActorId>()
.iter(app.world())
.count(),
1,
"retry must not duplicate generated source state"
);
}
#[test]
fn removed_asset_event_blocks_prefab_and_cleans_generated_state() {
let project_root = std::env::temp_dir().join(format!(
"blacksite-prefab-removed-event-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(project_root.join("assets/prefabs")).unwrap();
std::fs::write(project_root.join("assets/project.ron"), "()").unwrap();
let mut app = App::new();
app.add_plugins((AssetPlugin::default(), WorldSerializationPlugin))
.init_resource::<crate::scene_io::SceneIo>()
.insert_resource(crate::project_io::ProjectWorkspace {
root: project_root.display().to_string(),
..Default::default()
})
.add_systems(Update, guard_prefab_reimports);
let handle = app
.world_mut()
.resource_mut::<Assets<DynamicWorld>>()
.add(DynamicWorld::default());
let root = app
.world_mut()
.spawn((
DynamicWorldRoot(handle.clone()),
PrefabInstance::new("missing", "assets/prefabs/missing.scn.ron"),
))
.id();
let generated = app
.world_mut()
.spawn(HydratedPrefabMember {
instance_root: root,
})
.id();
app.world_mut()
.resource_mut::<Messages<AssetEvent<DynamicWorld>>>()
.write(AssetEvent::Removed { id: handle.id() });
app.update();
assert!(app.world().get_entity(generated).is_err());
assert!(app
.world()
.get::<shared::PrefabHydrationBlocked>(root)
.is_some());
assert!(app.world().get::<DynamicWorldRoot>(root).is_none());
std::fs::remove_dir_all(project_root).unwrap();
}
#[test]
fn nested_invalid_reimport_blocks_top_authored_owner() {
let mut world = World::new();
let root = world
.spawn((
PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"),
DynamicWorldRoot::default(),
))
.id();
let nested = world
.spawn((
PrefabInstance::new("nested", "assets/prefabs/nested.scn.ron"),
HydratedPrefabMember {
instance_root: root,
},
))
.id();
let nested_child = world
.spawn(HydratedPrefabMember {
instance_root: nested,
})
.id();
block_prefab_hydration(&mut world, nested, "nested source invalid".to_string());
assert!(world
.get::<shared::PrefabHydrationBlocked>(root)
.is_some_and(|blocked| blocked.reason == "nested source invalid"));
assert!(world.get::<DynamicWorldRoot>(root).is_none());
assert!(world.get_entity(nested).is_err());
assert!(world.get_entity(nested_child).is_err());
}
}