//! 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, } pub fn prefab_source_revision(project_root: &Path, source_path: &str) -> Result { scene::prefab_graph_revision(&project_root.join(source_path), project_root) } fn current_source_revision(world: &World, instance: &PrefabInstance) -> Option { let root = world .get_resource::() .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), Broken(String), InvalidOverrides(String), } fn prefab_health( world: &mut World, entity: Entity, instance: &PrefabInstance, overrides: &PrefabOverrides, ) -> PrefabHealth { if let Some(health) = world .get_resource::() .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::() { 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::() .map(|workspace| PathBuf::from(&workspace.root)) else { return PrefabHealth::Broken("Project workspace is unavailable".to_string()); }; if let Some(blocked) = world.get::(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::(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 = 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 != ¤t => PrefabHealth::ChangedBase, _ => PrefabHealth::Ready, } } fn prefab_legacy_target_keys(project_root: &Path, instance: &PrefabInstance) -> HashSet { 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 { 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::() => { apply_transform_property(&source.ron, property).is_err() } Ok(Some(source)) if property.component_type == std::any::type_name::() => { 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, ¤t.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::(), ) .ok() .flatten() .is_none_or(|current| { !component_values_equal( std::any::type_name::(), ¤t.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 { 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::() }) }) .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, 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::() }) }) .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, 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::() }) }) .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, overrides: &PrefabOverrides, target: &PrefabActorPath, component_type: &str, ) -> Result, 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::() { apply_transform_property(&source.ron, property)? } else if component_type == std::any::type_name::() { 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, overrides: &PrefabOverrides, target: &PrefabActorPath, component_type: &str, ) -> Result, String> { if component_type == std::any::type_name::() { 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::(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::(entity).cloned() else { return; }; let Some(project_root) = world .get_resource::() .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::(), ) .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::(entity).cloned() else { return; }; let Some(project_root) = world .get_resource::() .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::(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::(entity).cloned() else { return; }; let Some(project_root) = world .get_resource::() .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::(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::() .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::().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::().status = format!("Prefab relink failed: {error}"); return; } let old = capture_prefab_edit_state(world, entity); let Some(mut instance) = world.get::(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::() .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::().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::() .insert((instance, PrefabRef::new(source_path))); record_prefab_edit_state(world, entity, old); world.resource_mut::().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 { decode_prefab_overrides(instance) } fn read_overrides_for_edit( world: &mut World, instance: &PrefabInstance, ) -> Option { match try_read_overrides(instance) { Ok(overrides) => Some(overrides), Err(error) => { world.resource_mut::().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, source_path: impl Into, ) -> 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::(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::().status = "Cannot store prefab visibility: target has no stable prefab path".into(); return; }; if instance_root != entity { return; } let Some(instance) = world.get::(instance_root).cloned() else { return; }; let component_type = std::any::type_name::().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::() .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::(member)?.0.clone(); let mut instance_root = world.get::(member)?.instance_root; let mut instance_chain = Vec::new(); let mut seen = HashSet::new(); while seen.insert(instance_root) { let Some(owner) = world.get::(instance_root) else { break; }; instance_chain.insert(0, world.get::(instance_root)?.0.clone()); instance_root = owner.instance_root; } world.get::(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( 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::(instance_root).cloned() else { return; }; let Some(current) = world.get::(member).cloned() else { return; }; let component_type = std::any::type_name::().to_string(); let current_ron = match ron::to_string(¤t) { Ok(value) => value, Err(error) => { world.resource_mut::().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::().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(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::(instance_root).cloned() else { return; }; let Some(current) = world.get::(member).cloned() else { return; }; let Ok(current_component_ron) = ron::to_string(¤t) else { return; }; let component_type = std::any::type_name::().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::() .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::(); record_prefab_edit_state(world, instance_root, old); } pub fn set_prefab_component_override(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::(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::().to_string(); let base_component_ron = world .get::(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::(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::(instance_root).cloned() else { return; }; let Some(project_root) = world .get_resource::() .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::().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::().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::().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::().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::().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::().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::() { 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::(), 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::() { apply_transform_property(&source.ron, property).map(|_| ()) } else if property.component_type == std::any::type_name::() { 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::() { 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::(), 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::().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::() { apply_transform_property(&source.ron, property)? } else if property.component_type == std::any::type_name::() { 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 { 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 { 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, ¤t.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::() { return ron::from_str::(left).ok() == ron::from_str::(right).ok(); } if component_type == std::any::type_name::() { return ron::from_str::(left).ok() == ron::from_str::(right).ok(); } ron::from_str::(left).ok() == ron::from_str::(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_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::(), )? .ok_or_else(|| { format!( "conflict: source transform for `{}` was removed", actor_path_key(target) ) })?; if !component_values_equal(std::any::type_name::(), ¤t.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, ) { 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::(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::() .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::()) }) .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::(), 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::(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, ) -> bool { reparent_prefab_source_children(world, &[member], new_parent) } pub fn reparent_prefab_source_children( world: &mut World, members: &[Entity], new_parent: Option, ) -> 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::().status = "Prefab reparent blocked: hierarchy cycle".into(); return false; } let new_transform = world .get::(member) .map( |global| match world.get::(runtime_parent) { Some(parent_global) => global.reparented_to(parent_global), None => global.compute_transform(), }, ) .or_else(|| world.get::(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::(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::() .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::(*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::(), ) .ok() .flatten() .map(|component| component.ron) }) .or_else(|| { world .get::(*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::(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::(entity).cloned() else { return; }; let Some(mut overrides) = read_overrides_for_edit(world, &instance) else { return; }; if let Some(project_root) = world .get_resource::() .map(|workspace| PathBuf::from(&workspace.root)) { let _ = promote_legacy_visibility_overrides(&project_root, &instance, &mut overrides); } let component_type = std::any::type_name::(); 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::(&ron).ok()) { world.entity_mut(member).insert(value); } else { world.entity_mut(member).remove::(); } } 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::(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::(child).map(|id| id.0.as_str()) else { continue; }; let legacy_name = world.get::(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 { let candidates: Vec = world .query_filtered::>() .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, mut health_cache: Option>, ) { if let Some(cache) = health_cache.as_deref_mut() { cache.entries.remove(&ready.entity); } } pub fn guard_prefab_reimports( mut events: MessageReader>, mut failures: MessageReader>, roots: Query<(Entity, &DynamicWorldRoot, &PrefabInstance)>, workspace: Res, 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::(); } 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::(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::() .remove::() .remove::(); } if let Some(mut cache) = world.get_resource_mut::() { cache.entries.remove(&entity); } } fn retry_prefab_source(world: &mut World, entity: Entity) { let Some(instance) = world.get::(entity).cloned() else { return; }; let Some(project_root) = world .get_resource::() .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::().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::().insert(( instance.clone(), PrefabRef::new(instance.source_path.clone()), )); } world.resource_mut::().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::(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::(); let material_type = std::any::type_name::(); 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::(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::(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::(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::(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::(); 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::(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::(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::(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::().request = Some(crate::scene_io::SceneIoRequest::SaveSelectionAsPrefab); } if ui .add_enabled( world.get::(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::(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 { let candidates: Vec = world .query_filtered::>() .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 { 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::(), 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::(), 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::(), ron::to_string(&Transform::default()).unwrap(), std::any::type_name::(), ron::to_string(&child_transform).unwrap(), ) } fn prefab_test_world(project_root: &Path) -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); 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::(); 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::().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::(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::()), 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::()) .unwrap() .ron, ) .unwrap(); assert_eq!(material.roughness, 0.2); assert!( read_overrides(world.get::(instance_root).unwrap()) .properties .is_empty() ); assert_eq!( world .resource::() .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::(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::() .undo_depth(); crate::history::apply_command_undo(&mut world); assert_eq!( world .resource::() .undo_depth(), undo_depth ); assert!(world .resource::() .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::()), 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::()) .unwrap() .ron, ) .unwrap(); assert_eq!(material.roughness, 0.17); let applied = read_overrides(world.get::(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::(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::(&mut world, member); apply_prefab_member_scope_to_source( &mut world, member, Some(std::any::type_name::()), 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::())); assert!( read_overrides(world.get::(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::(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::(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::(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::(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::(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::()) .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::(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::(); world.init_resource::(); 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::(first).map(ChildOf::parent), Some(parent) ); assert_eq!( world.get::(second).map(ChildOf::parent), Some(parent) ); assert_eq!( read_overrides(world.get::(root).unwrap()) .structural .len(), 2 ); assert_eq!( world .resource::() .undo_depth(), 1 ); let invalid = world.spawn_empty().id(); let before = world.get::(first).map(ChildOf::parent); assert!(!reparent_prefab_source_children( &mut world, &[first, invalid], None, )); assert_eq!(world.get::(first).map(ChildOf::parent), before); assert_eq!( world .resource::() .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::().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::(), ron::to_string(&Transform::default()).unwrap(), std::any::type_name::(), 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::(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::()), 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::(outer).unwrap(), &PrefabActorPath { instance_chain: vec!["nested".to_string()], actor_id: "mesh".to_string(), }, std::any::type_name::(), ) .unwrap() .unwrap(); assert_eq!( ron::from_str::(&effective.ron) .unwrap() .roughness, 0.1 ); assert!(read_overrides(world.get::(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::(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::()), Some("roughness"), ); assert_eq!( std::fs::read_to_string(&source_path).unwrap(), changed_source ); assert_eq!( read_overrides(world.get::(instance_root).unwrap()) .properties .len(), 1 ); assert!(world .resource::() .status .contains("conflict")); assert_eq!( world .resource::() .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::(); world.init_resource::(); 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::(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::() ); assert_eq!( world .resource::() .undo_depth(), 1 ); crate::history::apply_command_undo(&mut world); assert!(read_overrides(world.get::(root).unwrap()) .components .is_empty()); crate::history::apply_command_redo(&mut world); assert_eq!( read_overrides(world.get::(root).unwrap()).components[0].target, PrefabActorPath::direct("stable-child") ); world.entity_mut(child).insert(Name::new("Renamed Child")); assert_eq!( read_overrides(world.get::(root).unwrap()).components[0].target, PrefabActorPath::direct("stable-child") ); revert_child_visibility_overrides(&mut world, root); assert_eq!( world .resource::() .undo_depth(), 2 ); assert!(world.get::(child).is_none()); assert!(read_overrides(world.get::(root).unwrap()) .components .is_empty()); crate::history::apply_command_undo(&mut world); assert_eq!( read_overrides(world.get::(root).unwrap()).components[0].target, PrefabActorPath::direct("stable-child") ); crate::history::apply_command_redo(&mut world); assert!(read_overrides(world.get::(root).unwrap()) .components .is_empty()); } #[test] fn nested_visibility_overrides_distinguish_repeated_leaf_actor_ids() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); 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::(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::(root).unwrap()) .components .len(), 1 ); crate::history::apply_command_redo(&mut world); assert_eq!( read_overrides(world.get::(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::() .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::().read(), ); let handle = app .world_mut() .resource_mut::>() .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::(); let mut source = World::new(); source.spawn(ActorId::new("source-child")); let dynamic_world = DynamicWorld::from_world_with( &source, &app.world().resource::().read(), ); let handle = app .world_mut() .resource_mut::>() .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::(root) .expect("initial prefab should be registered"); let first_members: Vec<_> = app .world() .resource::() .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::(root).is_none()); assert!(app.world().get::(root).is_none()); assert!(app .world() .get::(root) .is_some()); assert_eq!( app.world_mut() .query::<&HydratedPrefabMember>() .iter(app.world()) .count(), 0 ); assert!(!app .world() .resource::() .instance_is_ready(first_instance)); app.world_mut() .entity_mut(root) .remove::() .insert(DynamicWorldRoot(handle)); app.update(); let repaired_instance = **app .world() .get::(root) .expect("repaired prefab should register a fresh instance"); let repaired_members: Vec<_> = app .world() .resource::() .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::() .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::>() .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::>>() .write(AssetEvent::Removed { id: handle.id() }); app.update(); assert!(app.world().get_entity(generated).is_err()); assert!(app .world() .get::(root) .is_some()); assert!(app.world().get::(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::(root) .is_some_and(|blocked| blocked.reason == "nested source invalid")); assert!(world.get::(root).is_none()); assert!(world.get_entity(nested).is_err()); assert!(world.get_entity(nested_child).is_err()); } }