use crate::ecs::EditorWorld;
use crate::systems::retained_ui::UiHandles;
use nightshade::ecs::ui::components::DragPayload;
use nightshade::ecs::world::PREFAB_SOURCE;
use nightshade::prelude::*;
use std::collections::HashMap;
use std::path::PathBuf;
const PREFAB_BROWSER_RECT: Rect = Rect {
min: Vec2::new(200.0, 200.0),
max: Vec2::new(640.0, 620.0),
};
const MAX_PREFAB_ROWS: usize = 64;
const DRAG_PREVIEW_HOVER_HEIGHT: f32 = 0.4;
#[derive(Clone, Default)]
pub struct PrefabRow {
pub prefab_name: String,
pub library_path: Option<PathBuf>,
pub thumbnail_path: Option<PathBuf>,
pub thumbnail: Entity,
pub label: Entity,
pub select_button: Entity,
pub spawn_button: Entity,
pub refresh_button: Entity,
}
#[derive(Default, Clone)]
pub struct PrefabBrowserHandles {
pub panel: Entity,
pub status_label: Entity,
pub focus_label: Entity,
pub list_rows: Vec<PrefabRow>,
pub scan_button: Entity,
pub library: Vec<PathBuf>,
pub last_find_name: Option<String>,
pub find_cycle_index: usize,
pub drag_state: Option<PrefabDragState>,
}
#[derive(Clone)]
pub struct PrefabDragState {
pub prefab_name: String,
pub library_path: Option<PathBuf>,
pub source_entity: Entity,
pub preview_root: Option<Entity>,
pub ghost_material_swaps: Vec<GhostMaterialSwap>,
}
#[derive(Clone)]
pub struct GhostMaterialSwap {
pub entity: Entity,
pub original_name: String,
}
pub fn build(tree: &mut UiTreeBuilder) -> PrefabBrowserHandles {
let panel = tree.add_floating_panel("prefab_browser", "Prefabs", PREFAB_BROWSER_RECT);
ui_set_visible(tree.world_mut(), panel, false);
let content = super::panel_content(tree, panel);
let mut status_label = Entity::default();
let mut focus_label = Entity::default();
let mut scan_button = Entity::default();
let mut list_rows: Vec<PrefabRow> = Vec::with_capacity(MAX_PREFAB_ROWS);
tree.in_parent(content, |tree| {
let theme = tree
.world_mut()
.resources
.retained_ui
.theme_state
.active_theme();
let font = theme.font_size;
let dim = vec4(0.7, 0.7, 0.75, 1.0);
scan_button = tree
.add_node()
.size(100.pct(), (24.0).px())
.with_text("Scan directory for *.nsprefab...", font * 0.85)
.text_center()
.color_raw::<UiBase>(vec4(0.6, 0.85, 1.0, 1.0))
.color_raw::<UiHover>(vec4(1.0, 1.0, 1.0, 1.0))
.with_interaction()
.with_cursor_icon(winit::window::CursorIcon::Pointer)
.entity();
status_label = tree
.add_node()
.size(100.pct(), (18.0).px())
.with_text("0 prefabs", font * 0.85)
.text_left()
.color_raw::<UiBase>(dim)
.entity();
focus_label = tree
.add_node()
.size(100.pct(), (18.0).px())
.with_text("Click an entry to select its first instance.", font * 0.8)
.text_left()
.color_raw::<UiBase>(vec4(0.85, 0.85, 0.6, 1.0))
.entity();
let wrapper = tree.add_node().fill_width().flex_grow(1.0).entity();
let scroll = tree.in_parent(wrapper, |tree| tree.add_scroll_area_fill(4.0, 4.0));
let list_root = widget::<UiScrollAreaData>(tree.world_mut(), scroll)
.map(|d| d.content_entity)
.unwrap_or(scroll);
tree.in_parent(list_root, |tree| {
for row_index in 0..MAX_PREFAB_ROWS {
let row = tree
.add_node()
.size(100.pct(), (24.0).px())
.flow_with_alignment(
FlowDirection::Horizontal,
0.0,
6.0,
FlowAlignment::Start,
FlowAlignment::Center,
)
.with_visible(false)
.entity();
let mut label = Entity::default();
let mut select_button = Entity::default();
let mut spawn_button = Entity::default();
let mut refresh_button = Entity::default();
let mut thumbnail = Entity::default();
tree.in_parent(row, |tree| {
thumbnail = tree
.add_node()
.flow_child(Ab(vec2(22.0, 22.0)))
.color_raw::<UiBase>(vec4(0.18, 0.18, 0.2, 1.0))
.with_drag_source(DragPayload::Index(row_index))
.with_cursor_icon(winit::window::CursorIcon::Grab)
.entity();
label = tree
.add_node()
.flow_child(Ab(vec2(0.0, 20.0)))
.flex_grow(1.0)
.with_text("", font * 0.85)
.text_left()
.color_raw::<UiBase>(vec4(0.9, 0.9, 0.9, 1.0))
.entity();
spawn_button = tree
.add_node()
.flow_child(Ab(vec2(70.0, 22.0)))
.with_text("Spawn", font * 0.8)
.text_center()
.color_raw::<UiBase>(vec4(0.7, 1.0, 0.6, 1.0))
.color_raw::<UiHover>(vec4(1.0, 1.0, 1.0, 1.0))
.with_interaction()
.with_cursor_icon(winit::window::CursorIcon::Pointer)
.entity();
refresh_button = tree
.add_node()
.flow_child(Ab(vec2(70.0, 22.0)))
.with_text("Refresh", font * 0.8)
.text_center()
.color_raw::<UiBase>(vec4(1.0, 0.85, 0.55, 1.0))
.color_raw::<UiHover>(vec4(1.0, 1.0, 1.0, 1.0))
.with_interaction()
.with_cursor_icon(winit::window::CursorIcon::Pointer)
.entity();
select_button = tree
.add_node()
.flow_child(Ab(vec2(100.0, 22.0)))
.with_text("Find instance", font * 0.8)
.text_center()
.color_raw::<UiBase>(vec4(0.65, 0.8, 1.0, 1.0))
.color_raw::<UiHover>(vec4(1.0, 1.0, 1.0, 1.0))
.with_interaction()
.with_cursor_icon(winit::window::CursorIcon::Pointer)
.entity();
});
list_rows.push(PrefabRow {
prefab_name: String::new(),
library_path: None,
thumbnail_path: None,
thumbnail,
label,
select_button,
spawn_button,
refresh_button,
});
}
});
});
PrefabBrowserHandles {
panel,
status_label,
focus_label,
list_rows,
scan_button,
library: Vec::new(),
last_find_name: None,
find_cycle_index: 0,
drag_state: None,
}
}
pub fn poll(editor_world: &mut EditorWorld, world: &mut World, handles: &UiHandles) {
let select_buttons: Vec<(Entity, String)> = handles
.prefab_browser
.list_rows
.iter()
.map(|row| (row.select_button, row.prefab_name.clone()))
.collect();
let spawn_buttons: Vec<(Entity, String, Option<PathBuf>)> = handles
.prefab_browser
.list_rows
.iter()
.map(|row| {
(
row.spawn_button,
row.prefab_name.clone(),
row.library_path.clone(),
)
})
.collect();
let refresh_buttons: Vec<(Entity, String)> = handles
.prefab_browser
.list_rows
.iter()
.map(|row| (row.refresh_button, row.prefab_name.clone()))
.collect();
let scan_button = handles.prefab_browser.scan_button;
let row_info: Vec<(Entity, String, Option<PathBuf>)> = handles
.prefab_browser
.list_rows
.iter()
.map(|row| {
(
row.thumbnail,
row.prefab_name.clone(),
row.library_path.clone(),
)
})
.collect();
let mut select_request: Option<String> = None;
let mut spawn_request: Option<(String, Option<PathBuf>)> = None;
let mut refresh_request: Option<String> = None;
let mut scan_request = false;
let mut drag_start_request: Option<(Entity, String, Option<PathBuf>)> = None;
let mut drag_release_request: Option<Entity> = None;
for event in ui_events(world) {
match event {
UiEvent::ButtonClicked(entity) => {
if *entity == scan_button {
scan_request = true;
continue;
}
for (button, prefab_name) in &select_buttons {
if *entity == *button && !prefab_name.is_empty() {
select_request = Some(prefab_name.clone());
break;
}
}
for (button, prefab_name, path) in &spawn_buttons {
if *entity == *button {
spawn_request = Some((prefab_name.clone(), path.clone()));
break;
}
}
for (button, prefab_name) in &refresh_buttons {
if *entity == *button && !prefab_name.is_empty() {
refresh_request = Some(prefab_name.clone());
break;
}
}
}
UiEvent::DragStarted {
source,
payload: DragPayload::Index(index),
} => {
if let Some((thumbnail, prefab_name, library_path)) = row_info.get(*index)
&& *thumbnail == *source
&& !prefab_name.is_empty()
{
drag_start_request =
Some((*thumbnail, prefab_name.clone(), library_path.clone()));
}
}
UiEvent::DragCancelled { source } | UiEvent::DragDropped { source, .. } => {
drag_release_request = Some(*source);
}
_ => {}
}
}
if let Some(source) = drag_release_request {
finalize_drag(editor_world, world, source);
}
if let Some((source_entity, prefab_name, library_path)) = drag_start_request {
begin_drag(editor_world, source_entity, prefab_name, library_path);
}
if scan_request {
scan_prefab_directory(editor_world);
return;
}
if let Some(prefab_name) = refresh_request {
refresh_instances(editor_world, world, &prefab_name);
return;
}
if let Some((prefab_name, library_path)) = spawn_request {
if let Some(path) = library_path {
crate::systems::project_io::import_prefab_from_path(editor_world, world, &path);
} else if !prefab_name.is_empty() {
spawn_cached_prefab(editor_world, world, &prefab_name);
}
return;
}
let Some(prefab_name) = select_request else {
return;
};
let matches: Vec<Entity> = world
.core
.query_entities(PREFAB_SOURCE)
.filter(|entity| {
world
.core
.get_prefab_source(*entity)
.map(|prefab| prefab.prefab_name == prefab_name)
.unwrap_or(false)
})
.collect();
let browser = &mut editor_world.resources.ui_handles.prefab_browser;
let next_index = if browser.last_find_name.as_deref() == Some(prefab_name.as_str()) {
browser.find_cycle_index.saturating_add(1)
} else {
0
};
browser.last_find_name = Some(prefab_name.clone());
if matches.is_empty() {
browser.find_cycle_index = 0;
let label = browser.focus_label;
ui_set_text(world, label, &format!("No live instance of {prefab_name}"));
return;
}
let cycled = next_index % matches.len();
browser.find_cycle_index = cycled;
let entity = matches[cycled];
let label = browser.focus_label;
editor_world
.resources
.ui_interaction
.actions
.push(crate::systems::retained_ui::Action::SelectEntity(entity));
ui_set_text(
world,
label,
&format!(
"Selected instance {} of {} (of {})",
cycled + 1,
prefab_name,
matches.len()
),
);
}
#[cfg(not(target_arch = "wasm32"))]
fn scan_prefab_directory(editor_world: &mut EditorWorld) {
let Some(directory) = nightshade::prelude::rfd::FileDialog::new()
.set_title("Pick prefab directory")
.pick_folder()
else {
return;
};
let mut found: Vec<PathBuf> = Vec::new();
walk_for_prefabs(&directory, &mut found);
found.sort();
editor_world.resources.ui_handles.prefab_browser.library = found;
}
#[cfg(target_arch = "wasm32")]
fn scan_prefab_directory(_editor_world: &mut EditorWorld) {}
#[cfg(not(target_arch = "wasm32"))]
fn walk_for_prefabs(directory: &std::path::Path, found: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_for_prefabs(&path, found);
} else if path.extension().and_then(|s| s.to_str()) == Some("nsprefab") {
found.push(path);
}
}
}
fn spawn_cached_prefab(editor_world: &mut EditorWorld, world: &mut World, prefab_name: &str) {
let cached = world
.resources
.assets
.prefab_cache
.prefabs
.get(prefab_name)
.cloned();
let Some(cached) = cached else {
let label = editor_world.resources.ui_handles.prefab_browser.focus_label;
ui_set_text(
world,
label,
&format!("Prefab '{prefab_name}' not in cache"),
);
return;
};
let spawn_position = world
.resources
.active_camera
.and_then(|camera| world.core.get_global_transform(camera))
.map(|transform| {
let forward = transform.forward_vector();
transform.translation() + forward * 4.0
})
.unwrap_or_else(|| Vec3::new(0.0, 0.0, 0.0));
let entity = nightshade::ecs::prefab::spawn_prefab_with_skins(
world,
&cached.prefab,
&cached.animations,
&cached.skins,
spawn_position,
);
editor_world
.resources
.ui_interaction
.actions
.push(crate::systems::retained_ui::Action::SelectEntity(entity));
let label = editor_world.resources.ui_handles.prefab_browser.focus_label;
ui_set_text(
world,
label,
&format!("Spawned new instance of {prefab_name}"),
);
}
fn resolve_prefab_scene(
prefab_source: &nightshade::ecs::prefab::components::PrefabSource,
imported_scenes: &HashMap<nightshade::ecs::scene::AssetUuid, nightshade::ecs::scene::Scene>,
) -> Option<nightshade::ecs::scene::Scene> {
if let Some(uuid) = prefab_source.source_uuid
&& let Some(scene) = imported_scenes.get(&uuid)
{
return Some(scene.clone());
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(path) = prefab_source.source_path.as_deref() {
let path = std::path::Path::new(path);
if path.exists()
&& let Ok(bytes) = std::fs::read(path)
&& let Ok(scene) =
nightshade::ecs::scene::commands::load_scene_binary_from_bytes(&bytes)
{
return Some(scene);
}
}
}
None
}
fn ensure_prefab_cached(
editor_world: &mut EditorWorld,
prefab_source: &nightshade::ecs::prefab::components::PrefabSource,
) -> Option<nightshade::ecs::scene::Scene> {
let scene = resolve_prefab_scene(
prefab_source,
&editor_world.resources.prefab_instance_links.imported_scenes,
)?;
if let Some(uuid) = scene.header.prefab_id {
editor_world
.resources
.prefab_instance_links
.imported_scenes
.entry(uuid)
.or_insert_with(|| scene.clone());
}
Some(scene)
}
fn resolvable_prefab(
prefab_name: &str,
world: &World,
imported_scenes: &HashMap<nightshade::ecs::scene::AssetUuid, nightshade::ecs::scene::Scene>,
) -> bool {
world
.core
.query_entities(PREFAB_SOURCE)
.filter_map(|entity| world.core.get_prefab_source(entity))
.any(|source| {
source.prefab_name == prefab_name
&& (resolve_prefab_scene(source, imported_scenes).is_some()
|| source_path_exists(source))
})
}
pub fn source_reachable(
editor_world: &EditorWorld,
source: &nightshade::ecs::prefab::components::PrefabSource,
) -> bool {
if let Some(uuid) = source.source_uuid
&& editor_world
.resources
.prefab_instance_links
.imported_scenes
.contains_key(&uuid)
{
return true;
}
source_path_exists(source)
}
fn source_path_exists(source: &nightshade::ecs::prefab::components::PrefabSource) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
source
.source_path
.as_deref()
.map(|path| std::path::Path::new(path).exists())
.unwrap_or(false)
}
#[cfg(target_arch = "wasm32")]
{
let _ = source;
false
}
}
fn refresh_instances(editor_world: &mut EditorWorld, world: &mut World, prefab_name: &str) {
let matches: Vec<Entity> = world
.core
.query_entities(PREFAB_SOURCE)
.filter(|entity| {
world
.core
.get_prefab_source(*entity)
.map(|source| source.prefab_name == prefab_name)
.unwrap_or(false)
})
.collect();
let label = editor_world.resources.ui_handles.prefab_browser.focus_label;
if matches.is_empty() {
ui_set_text(
world,
label,
&format!("No live instance of {prefab_name} to refresh"),
);
return;
}
let mut steps: Vec<crate::undo::UndoableOperation> = Vec::new();
let mut refreshed_count = 0usize;
let mut last_failure: Option<String> = None;
for entity in matches {
let Some(source) = world.core.get_prefab_source(entity).cloned() else {
continue;
};
let Some(prefab_scene) = ensure_prefab_cached(editor_world, &source) else {
last_failure = Some(format!(
"{} has no cached source and source file is unreachable",
source.prefab_name
));
continue;
};
match refresh_single_instance(editor_world, world, entity, &source, &prefab_scene) {
Some((captured_old, captured_new)) => {
steps.push(crate::undo::UndoableOperation::EntityDeleted {
captured: Box::new(captured_old),
});
steps.push(crate::undo::UndoableOperation::EntityCreated {
captured: Box::new(captured_new),
});
refreshed_count += 1;
}
None => {
last_failure = Some(format!(
"Refresh produced no root entity for {}",
source.prefab_name
));
}
}
}
if !steps.is_empty() {
editor_world.resources.undo.push(
crate::undo::UndoableOperation::Batch { steps },
format!("Refresh {prefab_name}"),
);
editor_world.resources.project.mark_modified();
}
let message = match (refreshed_count, last_failure) {
(0, Some(reason)) => format!("Refresh {prefab_name} failed: {reason}"),
(count, Some(reason)) => format!("Refreshed {count} {prefab_name} (warnings: {reason})"),
(count, None) => format!("Refreshed {count} instance(s) of {prefab_name}"),
};
ui_set_text(world, label, &message);
}
pub fn refresh_selected_instance(
editor_world: &mut EditorWorld,
world: &mut World,
selected: Entity,
) {
let Some(source) = world.core.get_prefab_source(selected).cloned() else {
return;
};
let Some(prefab_scene) = ensure_prefab_cached(editor_world, &source) else {
tracing::warn!(
"Cannot refresh '{}' — no cached source and source file unreachable.",
source.prefab_name
);
return;
};
let Some((captured_old, captured_new)) =
refresh_single_instance(editor_world, world, selected, &source, &prefab_scene)
else {
return;
};
let prefab_name = source.prefab_name.clone();
editor_world.resources.undo.push(
crate::undo::UndoableOperation::Batch {
steps: vec![
crate::undo::UndoableOperation::EntityDeleted {
captured: Box::new(captured_old),
},
crate::undo::UndoableOperation::EntityCreated {
captured: Box::new(captured_new),
},
],
},
format!("Refresh {prefab_name}"),
);
editor_world.resources.project.mark_modified();
}
pub fn break_instance_link(editor_world: &mut EditorWorld, world: &mut World, entity: Entity) {
if world.core.get_prefab_source(entity).is_none() {
return;
}
let Some(uuid) = editor_world.resources.editor_scene.uuid_for(entity) else {
return;
};
let snapshot_old = crate::undo::ComponentSnapshot::capture(
world,
entity,
crate::undo::SnapshotKind::PrefabSource,
);
world
.core
.remove_components(entity, nightshade::ecs::world::PREFAB_SOURCE);
crate::scene_writeback::sync_entity(
&mut editor_world.resources.project,
&editor_world.resources.editor_scene,
world,
entity,
);
editor_world
.resources
.prefab_instance_links
.entity_to_source
.remove(&entity);
editor_world.resources.project.mark_modified();
if let (Some(old), Some(new)) = (
snapshot_old,
crate::undo::ComponentSnapshot::capture(
world,
entity,
crate::undo::SnapshotKind::PrefabSource,
),
) {
editor_world.resources.undo.push(
crate::undo::UndoableOperation::ComponentChanged {
uuid,
old: Box::new(old),
new: Box::new(new),
},
"Break prefab link",
);
}
}
fn refresh_single_instance(
editor_world: &mut EditorWorld,
world: &mut World,
live_root: Entity,
source: &nightshade::ecs::prefab::components::PrefabSource,
prefab_scene: &nightshade::ecs::scene::Scene,
) -> Option<(crate::undo::CapturedSubtree, crate::undo::CapturedSubtree)> {
let captured_old =
crate::undo::capture_subtree(world, &mut editor_world.resources.editor_scene, live_root);
let old_transform = world
.core
.get_local_transform(live_root)
.copied()
.unwrap_or_default();
let old_name = world.core.get_name(live_root).cloned();
let old_parent = world.core.get_parent(live_root).and_then(|parent| parent.0);
let preserved_root_uuid = editor_world.resources.editor_scene.uuid_for(live_root);
let overrides = collect_overrides(editor_world, world, live_root, prefab_scene);
let descendants = nightshade::ecs::transform::queries::query_descendants(world, live_root);
let added_entities: std::collections::HashSet<Entity> =
overrides.added.iter().map(|a| a.entity).collect();
let source_linked_descendants: Vec<Entity> = descendants
.into_iter()
.filter(|entity| !added_entities.contains(entity))
.collect();
for descendant in &source_linked_descendants {
editor_world
.resources
.prefab_instance_links
.entity_to_source
.remove(descendant);
crate::scene_writeback::remove_entity(
&mut editor_world.resources.project,
&mut editor_world.resources.editor_scene,
*descendant,
);
}
editor_world
.resources
.prefab_instance_links
.entity_to_source
.remove(&live_root);
crate::scene_writeback::remove_entity(
&mut editor_world.resources.project,
&mut editor_world.resources.editor_scene,
live_root,
);
let mut despawn_list = vec![live_root];
despawn_list.extend(source_linked_descendants);
nightshade::ecs::world::commands::despawn_entities_with_cache_cleanup(world, &despawn_list);
world.resources.transform_state.children_cache_valid = false;
let spawn_result =
nightshade::ecs::scene::commands::spawn_prefab_scene(world, prefab_scene, old_parent)
.ok()?;
let new_root = *spawn_result.root_entities.first()?;
if let Some(transform) = world.core.get_local_transform_mut(new_root) {
*transform = old_transform;
}
if let Some(name) = old_name {
world.core.set_name(new_root, name);
}
mark_local_transform_dirty(world, new_root);
let prefab_source_component = nightshade::ecs::prefab::components::PrefabSource {
prefab_name: source.prefab_name.clone(),
source_path: source.source_path.clone(),
source_uuid: prefab_scene.header.prefab_id.or(source.source_uuid),
};
world
.core
.add_components(new_root, nightshade::ecs::world::PREFAB_SOURCE);
world
.core
.set_prefab_source(new_root, prefab_source_component);
apply_overrides(
world,
new_root,
&spawn_result.entity_to_source_uuid,
&overrides,
prefab_scene,
);
let source_uuid_to_new_entity: HashMap<nightshade::ecs::scene::AssetUuid, Entity> =
spawn_result
.entity_to_source_uuid
.iter()
.map(|(entity, source_uuid)| (*source_uuid, *entity))
.collect();
reattach_added_entities(world, &overrides, new_root, &source_uuid_to_new_entity);
for (&entity, &source_uuid) in &spawn_result.entity_to_source_uuid {
if overrides.removed.contains(&source_uuid) {
continue;
}
editor_world
.resources
.prefab_instance_links
.entity_to_source
.insert(entity, source_uuid);
}
register_subtree_preserving_root_uuid(editor_world, world, new_root, preserved_root_uuid);
let captured_new =
crate::undo::capture_subtree(world, &mut editor_world.resources.editor_scene, new_root);
Some((captured_old, captured_new))
}
fn register_subtree_preserving_root_uuid(
editor_world: &mut EditorWorld,
world: &mut World,
root: Entity,
preserved_root_uuid: Option<nightshade::ecs::scene::AssetUuid>,
) {
let root_uuid = preserved_root_uuid.unwrap_or_else(nightshade::ecs::scene::AssetUuid::random);
editor_world.resources.editor_scene.insert(root_uuid, root);
crate::scene_writeback::sync_entity(
&mut editor_world.resources.project,
&editor_world.resources.editor_scene,
world,
root,
);
let descendants = nightshade::ecs::transform::queries::query_descendants(world, root);
for descendant in descendants {
if editor_world
.resources
.editor_scene
.is_scaffolding(descendant)
{
continue;
}
if editor_world
.resources
.editor_scene
.uuid_for(descendant)
.is_none()
{
let descendant_uuid = nightshade::ecs::scene::AssetUuid::random();
editor_world
.resources
.editor_scene
.insert(descendant_uuid, descendant);
}
crate::scene_writeback::sync_entity(
&mut editor_world.resources.project,
&editor_world.resources.editor_scene,
world,
descendant,
);
}
}
struct Overrides {
live: HashMap<nightshade::ecs::scene::AssetUuid, nightshade::ecs::scene::SceneEntity>,
added: Vec<AddedEntity>,
removed: std::collections::HashSet<nightshade::ecs::scene::AssetUuid>,
}
struct AddedEntity {
parent_source_uuid: Option<nightshade::ecs::scene::AssetUuid>,
entity: Entity,
}
fn collect_overrides(
editor_world: &EditorWorld,
world: &World,
live_root: Entity,
prefab_scene: &nightshade::ecs::scene::Scene,
) -> Overrides {
let mut live: HashMap<nightshade::ecs::scene::AssetUuid, nightshade::ecs::scene::SceneEntity> =
HashMap::new();
let mut added: Vec<AddedEntity> = Vec::new();
let mut entities = vec![live_root];
entities.extend(nightshade::ecs::transform::queries::query_descendants(
world, live_root,
));
let mut present: std::collections::HashSet<nightshade::ecs::scene::AssetUuid> =
std::collections::HashSet::new();
for entity in &entities {
if let Some(&source_uuid) = editor_world
.resources
.prefab_instance_links
.entity_to_source
.get(entity)
{
present.insert(source_uuid);
let scene_entity =
nightshade::ecs::scene::entity_to_scene_entity(world, *entity, source_uuid, None);
live.insert(source_uuid, scene_entity);
} else {
let parent_source_uuid = world
.core
.get_parent(*entity)
.and_then(|parent| parent.0)
.and_then(|parent_entity| {
editor_world
.resources
.prefab_instance_links
.entity_to_source
.get(&parent_entity)
.copied()
});
added.push(AddedEntity {
parent_source_uuid,
entity: *entity,
});
}
}
let mut removed = std::collections::HashSet::new();
for source_entity in &prefab_scene.entities {
if !present.contains(&source_entity.uuid) {
removed.insert(source_entity.uuid);
}
}
Overrides {
live,
added,
removed,
}
}
fn apply_overrides(
world: &mut World,
new_root: Entity,
entity_to_source_uuid: &HashMap<Entity, nightshade::ecs::scene::AssetUuid>,
overrides: &Overrides,
prefab_scene: &nightshade::ecs::scene::Scene,
) {
let mut despawn_due_to_removal: Vec<Entity> = Vec::new();
for (&entity, &source_uuid) in entity_to_source_uuid {
if overrides.removed.contains(&source_uuid) {
despawn_due_to_removal.push(entity);
continue;
}
let Some(live) = overrides.live.get(&source_uuid) else {
continue;
};
let Some(source) = prefab_scene.find_entity(source_uuid) else {
continue;
};
let is_root = entity == new_root;
apply_entity_diff(world, entity, live, source, is_root);
}
for entity in despawn_due_to_removal {
despawn_recursive_immediate(world, entity);
}
}
fn reattach_added_entities(
world: &mut World,
overrides: &Overrides,
new_root: Entity,
source_uuid_to_new_entity: &HashMap<nightshade::ecs::scene::AssetUuid, Entity>,
) {
for added in &overrides.added {
let new_parent = match added.parent_source_uuid {
Some(uuid) => source_uuid_to_new_entity
.get(&uuid)
.copied()
.unwrap_or(new_root),
None => new_root,
};
world
.core
.add_components(added.entity, nightshade::ecs::world::PARENT);
world
.core
.set_parent(added.entity, Parent(Some(new_parent)));
mark_local_transform_dirty(world, added.entity);
}
}
fn apply_entity_diff(
world: &mut World,
entity: Entity,
live: &nightshade::ecs::scene::SceneEntity,
source: &nightshade::ecs::scene::SceneEntity,
is_root: bool,
) {
if !is_root && !transforms_equal(&live.transform, &source.transform) {
if let Some(slot) = world.core.get_local_transform_mut(entity) {
*slot = live.transform;
}
mark_local_transform_dirty(world, entity);
}
if !is_root && live.name != source.name {
match &live.name {
Some(name) => {
world
.core
.add_components(entity, nightshade::ecs::world::NAME);
world.core.set_name(entity, Name(name.clone()));
}
None => {
world
.core
.remove_components(entity, nightshade::ecs::world::NAME);
}
}
}
if live.components.visible != source.components.visible {
world
.core
.add_components(entity, nightshade::ecs::world::VISIBILITY);
world.core.set_visibility(
entity,
Visibility {
visible: live.components.visible,
},
);
}
if live.components.casts_shadow != source.components.casts_shadow {
if live.components.casts_shadow {
world
.core
.add_components(entity, nightshade::ecs::world::CASTS_SHADOW);
world.core.set_casts_shadow(entity, CastsShadow);
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::CASTS_SHADOW);
}
}
apply_material_diff(
world,
entity,
&live.components.mesh,
&source.components.mesh,
);
apply_instanced_material_diff(
world,
entity,
&live.components.instanced_mesh,
&source.components.instanced_mesh,
);
apply_light_diff(
world,
entity,
&live.components.light,
&source.components.light,
);
apply_camera_diff(
world,
entity,
&live.components.camera,
&source.components.camera,
);
apply_audio_diff(
world,
entity,
&live.components.audio,
&source.components.audio,
);
apply_particle_diff(
world,
entity,
&live.components.particle_emitter,
&source.components.particle_emitter,
);
apply_decal_diff(
world,
entity,
&live.components.decal,
&source.components.decal,
);
apply_grass_region_diff(
world,
entity,
&live.components.grass_region,
&source.components.grass_region,
);
apply_grass_interactor_diff(
world,
entity,
&live.components.grass_interactor,
&source.components.grass_interactor,
);
apply_render_layer_diff(
world,
entity,
&live.components.render_layer,
&source.components.render_layer,
);
apply_navmesh_agent_diff(
world,
entity,
&live.components.navmesh_agent,
&source.components.navmesh_agent,
);
apply_animation_player_diff(
world,
entity,
&live.components.animation_player,
&source.components.animation_player,
);
apply_text_diff(
world,
entity,
&live.components.text,
&live.components.text_content,
&source.components.text,
&source.components.text_content,
);
apply_tags_diff(
world,
entity,
&live.components.tags,
&source.components.tags,
);
}
fn apply_material_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::scene::SceneMesh>,
source: &Option<nightshade::ecs::scene::SceneMesh>,
) {
let live_material = live.as_ref().and_then(|mesh| mesh.material.clone());
let source_material = source.as_ref().and_then(|mesh| mesh.material.clone());
if scene_value_eq(live_material.as_ref(), source_material.as_ref()) {
return;
}
let Some(material_ref) = world.core.get_material_ref(entity).cloned() else {
return;
};
if let Some(scene_material) = live_material {
let material = scene_material.to_material();
nightshade::ecs::material::resources::material_registry_insert(
&mut world.resources.assets.material_registry,
material_ref.name.clone(),
material,
);
world.resources.mesh_render_state.request_full_rebuild();
}
}
fn apply_instanced_material_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::scene::SceneInstancedMesh>,
source: &Option<nightshade::ecs::scene::SceneInstancedMesh>,
) {
let live_material = live.as_ref().and_then(|mesh| mesh.material.clone());
let source_material = source.as_ref().and_then(|mesh| mesh.material.clone());
if scene_value_eq(live_material.as_ref(), source_material.as_ref()) {
return;
}
let Some(material_ref) = world.core.get_material_ref(entity).cloned() else {
return;
};
if let Some(scene_material) = live_material {
let material = scene_material.to_material();
nightshade::ecs::material::resources::material_registry_insert(
&mut world.resources.assets.material_registry,
material_ref.name.clone(),
material,
);
world.resources.mesh_render_state.request_full_rebuild();
}
}
fn apply_light_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::scene::SceneLight>,
source: &Option<nightshade::ecs::scene::SceneLight>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(scene_light) = live {
world
.core
.add_components(entity, nightshade::ecs::world::LIGHT);
world.core.set_light(entity, scene_light.to_light());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::LIGHT);
}
}
fn apply_camera_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::scene::SceneCamera>,
source: &Option<nightshade::ecs::scene::SceneCamera>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(scene_camera) = live {
world
.core
.add_components(entity, nightshade::ecs::world::CAMERA);
world.core.set_camera(entity, scene_camera.to_camera());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::CAMERA);
}
}
fn apply_audio_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::scene::SceneAudioSource>,
source: &Option<nightshade::ecs::scene::SceneAudioSource>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(audio) = live {
let audio_ref = audio
.audio_name
.clone()
.or_else(|| audio.audio_uuid.map(|uuid| uuid.to_string()));
let mut reverb_zones = audio.reverb_zones.clone();
if reverb_zones.is_empty() && audio.reverb {
reverb_zones.push(("default".to_string(), 0.0));
}
world
.core
.add_components(entity, nightshade::ecs::world::AUDIO_SOURCE);
world.core.set_audio_source(
entity,
nightshade::ecs::audio::components::AudioSource {
audio_ref,
volume: audio.volume,
looping: audio.looping,
playing: audio.playing,
spatial: audio.spatial,
bus: audio.bus,
min_distance: audio.min_distance,
max_distance: audio.max_distance,
reverb_zones,
random_clips: audio.random_clips.clone(),
random_pick: audio.random_pick,
playback_rate: 1.0,
},
);
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::AUDIO_SOURCE);
}
}
fn apply_particle_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::scene::SceneParticleEmitter>,
source: &Option<nightshade::ecs::scene::SceneParticleEmitter>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(emitter) = live {
world
.core
.add_components(entity, nightshade::ecs::world::PARTICLE_EMITTER);
world
.core
.set_particle_emitter(entity, emitter.to_particle_emitter());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::PARTICLE_EMITTER);
}
}
fn apply_decal_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::decal::components::Decal>,
source: &Option<nightshade::ecs::decal::components::Decal>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(decal) = live {
world
.core
.add_components(entity, nightshade::ecs::world::DECAL);
world.core.set_decal(entity, decal.clone());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::DECAL);
}
}
fn apply_grass_region_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::grass::components::GrassRegion>,
source: &Option<nightshade::ecs::grass::components::GrassRegion>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(region) = live {
world
.core
.add_components(entity, nightshade::ecs::world::GRASS_REGION);
world.core.set_grass_region(entity, region.clone());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::GRASS_REGION);
}
}
fn apply_grass_interactor_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::grass::components::GrassInteractor>,
source: &Option<nightshade::ecs::grass::components::GrassInteractor>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(interactor) = live {
world
.core
.add_components(entity, nightshade::ecs::world::GRASS_INTERACTOR);
world.core.set_grass_interactor(entity, interactor.clone());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::GRASS_INTERACTOR);
}
}
fn apply_render_layer_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::primitives::RenderLayer>,
source: &Option<nightshade::ecs::primitives::RenderLayer>,
) {
if live == source {
return;
}
if let Some(layer) = live {
world
.core
.add_components(entity, nightshade::ecs::world::RENDER_LAYER);
world.core.set_render_layer(entity, *layer);
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::RENDER_LAYER);
}
}
fn apply_navmesh_agent_diff(
world: &mut World,
entity: Entity,
live: &Option<NavMeshAgent>,
source: &Option<NavMeshAgent>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(agent) = live {
world
.core
.add_components(entity, nightshade::ecs::world::NAVMESH_AGENT);
world.core.set_navmesh_agent(entity, agent.clone());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::NAVMESH_AGENT);
}
}
fn apply_animation_player_diff(
world: &mut World,
entity: Entity,
live: &Option<nightshade::ecs::scene::SceneAnimationPlayer>,
source: &Option<nightshade::ecs::scene::SceneAnimationPlayer>,
) {
if scene_value_eq(live.as_ref(), source.as_ref()) {
return;
}
if let Some(player) = live {
world
.core
.add_components(entity, nightshade::ecs::world::ANIMATION_PLAYER);
world
.core
.set_animation_player(entity, player.to_animation_player());
} else {
world
.core
.remove_components(entity, nightshade::ecs::world::ANIMATION_PLAYER);
}
}
fn apply_text_diff(
world: &mut World,
entity: Entity,
live_text: &Option<nightshade::ecs::text::components::Text>,
live_content: &Option<String>,
source_text: &Option<nightshade::ecs::text::components::Text>,
source_content: &Option<String>,
) {
let text_changed = scene_value_eq(live_text.as_ref(), source_text.as_ref());
let content_changed = live_content == source_content;
if text_changed && content_changed {
return;
}
match live_text {
Some(text) => {
let mut text = text.clone();
if let Some(content) = live_content.as_deref() {
text.text_index = world.resources.text.cache.add_text(content);
}
text.dirty = true;
world
.core
.add_components(entity, nightshade::ecs::world::TEXT);
world.core.set_text(entity, text);
}
None => {
world
.core
.remove_components(entity, nightshade::ecs::world::TEXT);
}
}
}
fn apply_tags_diff(world: &mut World, entity: Entity, live: &[String], source: &[String]) {
if live == source {
return;
}
if live.is_empty() {
world.resources.entities.tags.remove(&entity);
} else {
world.resources.entities.tags.insert(entity, live.to_vec());
}
}
fn transforms_equal(a: &LocalTransform, b: &LocalTransform) -> bool {
const EPSILON: f32 = 1e-5;
let translations_eq = (a.translation - b.translation).magnitude() < EPSILON;
let scales_eq = (a.scale - b.scale).magnitude() < EPSILON;
let rotations_eq = (a.rotation.coords - b.rotation.coords).magnitude() < EPSILON
|| (a.rotation.coords + b.rotation.coords).magnitude() < EPSILON;
translations_eq && scales_eq && rotations_eq
}
fn scene_value_eq<T: serde::Serialize>(a: Option<&T>, b: Option<&T>) -> bool {
match (a, b) {
(None, None) => true,
(Some(left), Some(right)) => {
bincode::serialize(left).ok() == bincode::serialize(right).ok()
}
_ => false,
}
}
const PREVIEW_GHOST_ALPHA: f32 = 0.45;
fn apply_preview_ghost(world: &mut World, root: Entity) -> Vec<GhostMaterialSwap> {
let entities: Vec<Entity> = std::iter::once(root)
.chain(nightshade::ecs::transform::queries::query_descendants(
world, root,
))
.collect();
let mut swaps: Vec<GhostMaterialSwap> = Vec::new();
for entity in entities {
let Some(material_ref) = world.core.get_material_ref(entity).cloned() else {
continue;
};
let Some(material) = nightshade::ecs::generational_registry::registry_entry_by_name(
&world.resources.assets.material_registry.registry,
&material_ref.name,
)
.cloned() else {
continue;
};
let mut ghost = material;
ghost.base_color[3] *= PREVIEW_GHOST_ALPHA;
ghost.alpha_mode = nightshade::ecs::material::components::AlphaMode::Blend;
let ghost_name = format!("PrefabPreviewGhost_{}", entity.id);
nightshade::ecs::material::resources::material_registry_insert(
&mut world.resources.assets.material_registry,
ghost_name.clone(),
ghost,
);
world.core.set_material_ref(
entity,
nightshade::ecs::world::components::MaterialRef::new(ghost_name),
);
swaps.push(GhostMaterialSwap {
entity,
original_name: material_ref.name.clone(),
});
}
world.resources.mesh_render_state.request_full_rebuild();
swaps
}
fn revert_preview_ghost(world: &mut World, swaps: &[GhostMaterialSwap]) {
if swaps.is_empty() {
return;
}
for swap in swaps {
world.core.set_material_ref(
swap.entity,
nightshade::ecs::world::components::MaterialRef::new(swap.original_name.clone()),
);
}
world.resources.mesh_render_state.request_full_rebuild();
}
fn begin_drag(
editor_world: &mut EditorWorld,
source_entity: Entity,
prefab_name: String,
library_path: Option<PathBuf>,
) {
let browser = &mut editor_world.resources.ui_handles.prefab_browser;
if browser
.drag_state
.as_ref()
.is_some_and(|state| state.source_entity == source_entity)
{
return;
}
browser.drag_state = Some(PrefabDragState {
prefab_name,
library_path,
source_entity,
preview_root: None,
ghost_material_swaps: Vec::new(),
});
}
fn finalize_drag(editor_world: &mut EditorWorld, world: &mut World, source: Entity) {
let drag = match editor_world
.resources
.ui_handles
.prefab_browser
.drag_state
.take()
{
Some(state) if state.source_entity == source => state,
Some(state) => {
editor_world.resources.ui_handles.prefab_browser.drag_state = Some(state);
return;
}
None => return,
};
let Some(preview_root) = drag.preview_root else {
return;
};
let in_viewport = cursor_in_viewport(world);
let label = editor_world.resources.ui_handles.prefab_browser.focus_label;
if in_viewport {
revert_preview_ghost(world, &drag.ghost_material_swaps);
if let Some(transform) = world.core.get_local_transform_mut(preview_root) {
transform.translation.y -= DRAG_PREVIEW_HOVER_HEIGHT;
}
mark_local_transform_dirty(world, preview_root);
crate::scene_writeback::sync_entity(
&mut editor_world.resources.project,
&editor_world.resources.editor_scene,
world,
preview_root,
);
editor_world.resources.ui_interaction.actions.push(
crate::systems::retained_ui::Action::SelectEntity(preview_root),
);
ui_set_text(
world,
label,
&format!("Dropped instance of {}", drag.prefab_name),
);
push_create_undo(editor_world, world, preview_root, drag.prefab_name.clone());
} else {
despawn_preview(editor_world, world, preview_root);
ui_set_text(
world,
label,
&format!("Cancelled drop of {}", drag.prefab_name),
);
}
}
fn push_create_undo(
editor_world: &mut EditorWorld,
world: &mut World,
root: Entity,
prefab_name: String,
) {
let captured =
crate::undo::capture_subtree(world, &mut editor_world.resources.editor_scene, root);
editor_world.resources.undo.push(
crate::undo::UndoableOperation::EntityCreated {
captured: Box::new(captured),
},
format!("Drag spawn {prefab_name}"),
);
}
fn despawn_preview(editor_world: &mut EditorWorld, world: &mut World, root: Entity) {
let descendants = nightshade::ecs::transform::queries::query_descendants(world, root);
crate::scene_writeback::remove_entity(
&mut editor_world.resources.project,
&mut editor_world.resources.editor_scene,
root,
);
for descendant in descendants {
crate::scene_writeback::remove_entity(
&mut editor_world.resources.project,
&mut editor_world.resources.editor_scene,
descendant,
);
}
despawn_recursive_immediate(world, root);
}
fn update_drag_preview(editor_world: &mut EditorWorld, world: &mut World) {
if world.resources.retained_ui.drag.active.is_none() {
return;
}
if editor_world
.resources
.ui_handles
.prefab_browser
.drag_state
.is_none()
{
return;
}
let Some(hit) = cursor_world_hit(world) else {
return;
};
let mut state = editor_world
.resources
.ui_handles
.prefab_browser
.drag_state
.take()
.expect("drag_state is_some checked above");
let time_seconds = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;
let bob = (time_seconds * std::f32::consts::TAU * 1.5).sin() * 0.05;
let hover = hit + Vec3::new(0.0, DRAG_PREVIEW_HOVER_HEIGHT + bob, 0.0);
if let Some(preview) = state.preview_root {
if let Some(transform) = world.core.get_local_transform_mut(preview) {
transform.translation = hover;
mark_local_transform_dirty(world, preview);
}
} else if let Some(spawned) = spawn_preview(editor_world, world, &state, hover) {
state.preview_root = Some(spawned);
state.ghost_material_swaps = apply_preview_ghost(world, spawned);
}
editor_world.resources.ui_handles.prefab_browser.drag_state = Some(state);
}
fn spawn_preview(
editor_world: &mut EditorWorld,
world: &mut World,
state: &PrefabDragState,
position: Vec3,
) -> Option<Entity> {
if let Some(path) = state.library_path.as_deref() {
return spawn_preview_from_library(editor_world, world, path, position);
}
spawn_preview_from_cache(editor_world, world, &state.prefab_name, position)
}
fn spawn_preview_from_cache(
editor_world: &mut EditorWorld,
world: &mut World,
prefab_name: &str,
position: Vec3,
) -> Option<Entity> {
let cached = world
.resources
.assets
.prefab_cache
.prefabs
.get(prefab_name)
.cloned()?;
let entity = nightshade::ecs::prefab::spawn_prefab_with_skins(
world,
&cached.prefab,
&cached.animations,
&cached.skins,
position,
);
crate::scene_writeback::register_subtree(
&mut editor_world.resources.project,
&mut editor_world.resources.editor_scene,
world,
entity,
);
editor_world.resources.loading.model_entities.push(entity);
Some(entity)
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn_preview_from_library(
editor_world: &mut EditorWorld,
world: &mut World,
path: &std::path::Path,
position: Vec3,
) -> Option<Entity> {
let entity_count_before = world.core.get_all_entities().len();
crate::systems::project_io::import_prefab_from_path(editor_world, world, path);
let new_entities: Vec<Entity> = world
.core
.get_all_entities()
.into_iter()
.skip(entity_count_before)
.collect();
let candidate = editor_world
.resources
.loading
.pending_fit_roots
.last()
.copied()
.or_else(|| new_entities.into_iter().next())?;
if let Some(transform) = world.core.get_local_transform_mut(candidate) {
transform.translation = position;
}
mark_local_transform_dirty(world, candidate);
editor_world.resources.loading.pending_fit_roots.clear();
editor_world.resources.loading.pending_fit_frames = 0;
Some(candidate)
}
#[cfg(target_arch = "wasm32")]
fn spawn_preview_from_library(
_editor_world: &mut EditorWorld,
_world: &mut World,
_path: &std::path::Path,
_position: Vec3,
) -> Option<Entity> {
None
}
fn cursor_in_viewport(world: &World) -> bool {
let mouse = nightshade::ecs::input::access::mouse_for_active(world);
let Some(rect) = world.resources.window.active_viewport_rect else {
return false;
};
rect.width > 0.0 && rect.height > 0.0 && rect.contains(mouse.position)
}
fn cursor_world_hit(world: &World) -> Option<Vec3> {
let camera_entity = world.resources.active_camera?;
let mouse = nightshade::ecs::input::access::mouse_for_active(world);
let viewport = world
.resources
.window
.camera_tile_rects
.get(&camera_entity)
.copied()
.or(world.resources.window.active_viewport_rect)?;
if viewport.width <= 0.0 || viewport.height <= 0.0 {
return None;
}
if !viewport.contains(mouse.position) {
return None;
}
let camera = world.core.get_camera(camera_entity).copied()?;
let camera_transform = world.core.get_global_transform(camera_entity)?;
let camera_position = camera_transform.translation();
let view_matrix = camera_transform
.0
.try_inverse()
.unwrap_or_else(Mat4::identity);
let aspect = viewport.width / viewport.height;
let view_projection = camera.projection.matrix_with_aspect(aspect) * view_matrix;
let inverse_view_projection = view_projection.try_inverse().unwrap_or_else(Mat4::identity);
let ndc_x = ((mouse.position.x - viewport.x) / viewport.width) * 2.0 - 1.0;
let ndc_y = 1.0 - ((mouse.position.y - viewport.y) / viewport.height) * 2.0;
let near_clip = inverse_view_projection * nalgebra_glm::Vec4::new(ndc_x, ndc_y, 1.0, 1.0);
if near_clip.w.abs() < 1e-6 {
return None;
}
let near_world = Vec3::new(near_clip.x, near_clip.y, near_clip.z) / near_clip.w;
let direction = (near_world - camera_position).normalize();
if let Some(hit) = nightshade::ecs::physics::resources::physics_world_cast_ray(
&world.resources.physics,
camera_position,
direction,
10_000.0,
None,
) {
return Some(hit.point);
}
let denominator = direction.y;
if denominator.abs() < 1e-4 {
return None;
}
let parameter = -camera_position.y / denominator;
if parameter <= 0.0 {
return None;
}
Some(camera_position + direction * parameter)
}
pub fn update(editor_world: &mut EditorWorld, world: &mut World) {
let panel = editor_world.resources.ui_handles.prefab_browser.panel;
if !world
.ui
.get_ui_layout_node(panel)
.map(|node| node.visible)
.unwrap_or(false)
{
return;
}
let mut counts: HashMap<String, u32> = HashMap::new();
let mut total_instances: u32 = 0;
for entity in world.core.query_entities(PREFAB_SOURCE) {
let Some(prefab) = world.core.get_prefab_source(entity) else {
continue;
};
*counts.entry(prefab.prefab_name.clone()).or_insert(0) += 1;
total_instances += 1;
}
let cached_names: Vec<String> = nightshade::ecs::prefab::resources::prefab_cache_names(
&world.resources.assets.prefab_cache,
)
.cloned()
.collect();
for name in cached_names {
counts.entry(name).or_insert(0);
}
let mut sorted: Vec<(String, u32)> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let status = editor_world
.resources
.ui_handles
.prefab_browser
.status_label;
let cached_count = sorted
.iter()
.filter(|(name, _)| {
world
.resources
.assets
.prefab_cache
.prefabs
.contains_key(name)
})
.count();
ui_set_text(
world,
status,
&format!(
"{} unique prefabs ({} cached), {} live instances",
sorted.len(),
cached_count,
total_instances
),
);
let library_entries: Vec<(String, PathBuf, Option<PathBuf>)> = editor_world
.resources
.ui_handles
.prefab_browser
.library
.iter()
.map(|path| {
let name = path
.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_default();
let thumbnail = thumbnail_path_for(path);
(name, path.clone(), thumbnail)
})
.collect();
queue_prefab_thumbnails(editor_world, &library_entries);
let row_count = editor_world
.resources
.ui_handles
.prefab_browser
.list_rows
.len();
for index in 0..row_count {
let mut owned_rows =
std::mem::take(&mut editor_world.resources.ui_handles.prefab_browser.list_rows);
if let Some(row) = owned_rows.get_mut(index) {
if index < sorted.len() {
let (name, count) = &sorted[index];
let cached = world
.resources
.assets
.prefab_cache
.prefabs
.contains_key(name);
let resolvable = resolvable_prefab(
name,
world,
&editor_world.resources.prefab_instance_links.imported_scenes,
);
let badge = if cached { "[cached]" } else { "" };
row.prefab_name = name.clone();
row.library_path = None;
row.thumbnail_path = None;
ui_set_text(world, row.label, &format!("{name} {badge} x{count}"));
ui_set_visible(world, row.label, true);
ui_set_visible(world, row.select_button, *count > 0);
ui_set_visible(world, row.spawn_button, cached);
ui_set_visible(world, row.refresh_button, *count > 0 && resolvable);
ui_set_visible(world, row.thumbnail, true);
} else {
let library_index = index - sorted.len();
if let Some((name, path, thumbnail)) = library_entries.get(library_index) {
row.prefab_name = name.clone();
row.library_path = Some(path.clone());
row.thumbnail_path = thumbnail.clone();
ui_set_text(world, row.label, &format!("{name} [library]"));
ui_set_visible(world, row.label, true);
ui_set_visible(world, row.select_button, false);
ui_set_visible(world, row.spawn_button, true);
ui_set_visible(world, row.refresh_button, false);
ui_set_visible(world, row.thumbnail, true);
} else {
row.prefab_name.clear();
row.library_path = None;
row.thumbnail_path = None;
ui_set_visible(world, row.label, false);
ui_set_visible(world, row.select_button, false);
ui_set_visible(world, row.spawn_button, false);
ui_set_visible(world, row.refresh_button, false);
ui_set_visible(world, row.thumbnail, false);
}
}
}
editor_world.resources.ui_handles.prefab_browser.list_rows = owned_rows;
}
patch_thumbnails(editor_world, world);
update_drag_preview(editor_world, world);
}
fn thumbnail_path_for(prefab_path: &std::path::Path) -> Option<PathBuf> {
let candidate = prefab_path.with_extension("thumb.png");
if candidate.exists() {
Some(candidate)
} else {
None
}
}
fn thumbnail_slot_key(prefab_name: &str) -> String {
format!("prefab_thumb_{prefab_name}")
}
fn queue_prefab_thumbnails(
editor_world: &mut EditorWorld,
entries: &[(String, PathBuf, Option<PathBuf>)],
) {
let Ok(mut queue) = editor_world.resources.browsers.thumbnail_queue.lock() else {
return;
};
for (name, _, thumbnail_path) in entries {
let Some(path) = thumbnail_path else { continue };
let slot_key = thumbnail_slot_key(name);
if editor_world
.resources
.browsers
.thumbnail_slots
.contains_key(&slot_key)
{
continue;
}
queue.queue_texture(slot_key, path.to_string_lossy().to_string());
}
}
fn patch_thumbnails(editor_world: &mut EditorWorld, world: &mut World) {
let entries: Vec<(Entity, Option<crate::ecs::ThumbnailSlot>)> = editor_world
.resources
.ui_handles
.prefab_browser
.list_rows
.iter()
.map(|row| {
let slot = if row.prefab_name.is_empty() {
None
} else {
editor_world
.resources
.browsers
.thumbnail_slots
.get(&thumbnail_slot_key(&row.prefab_name))
.copied()
};
(row.thumbnail, slot)
})
.collect();
let mut dirty = false;
for (thumbnail_entity, slot) in entries {
match slot {
Some(thumb) => {
if patch_thumbnail_image(world, thumbnail_entity, thumb) {
dirty = true;
}
}
None => {
if clear_thumbnail_image(world, thumbnail_entity) {
dirty = true;
}
}
}
}
if dirty {
ui_mark_render_dirty(world);
}
}
fn patch_thumbnail_image(
world: &mut World,
image_entity: Entity,
thumb: crate::ecs::ThumbnailSlot,
) -> bool {
let already_matches = matches!(
world.ui.get_ui_node_content(image_entity),
Some(UiNodeContent::Image { texture_index, uv_min, uv_max })
if *texture_index == thumb.layer && *uv_min == thumb.uv_min && *uv_max == thumb.uv_max
);
if already_matches {
return false;
}
if let Some(content) = world.ui.get_ui_node_content_mut(image_entity) {
*content = UiNodeContent::Image {
texture_index: thumb.layer,
uv_min: thumb.uv_min,
uv_max: thumb.uv_max,
};
}
ui_mark_layout_dirty(world);
true
}
fn clear_thumbnail_image(world: &mut World, image_entity: Entity) -> bool {
let already_blank = matches!(
world.ui.get_ui_node_content(image_entity),
Some(UiNodeContent::None) | None
);
if already_blank {
return false;
}
if let Some(content) = world.ui.get_ui_node_content_mut(image_entity) {
*content = UiNodeContent::None;
}
ui_mark_layout_dirty(world);
true
}