feat(editor): unify renderer materials and content browser shell
Some checks failed
CI / Format, lint, test, build (push) Has been cancelled

This commit is contained in:
Rbanh 2026-07-18 14:05:26 -04:00
parent 53dc1e44d8
commit 93e23ba194
14 changed files with 1296 additions and 526 deletions

View File

@ -49,20 +49,19 @@ use crate::assets::{
}; };
use crate::ui::asset_card::{draw_asset_card, draw_asset_status_marker, AssetCardStatus}; use crate::ui::asset_card::{draw_asset_card, draw_asset_status_marker, AssetCardStatus};
use crate::ui::document_status::authored_document_status_ui; use crate::ui::document_status::authored_document_status_ui;
use crate::ui::helpers::asset_label;
use crate::ui::theme::{ use crate::ui::theme::{
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, WARNING, WIDGET_BG, panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED,
WARNING, WIDGET_BG,
}; };
use crate::ui::widgets::{icon_button_small, tool_button};
const FOOTER_HEIGHT: f32 = 64.0; const FOOTER_HEIGHT: f32 = editor_ui::content_browser::STATUS_BAR_HEIGHT;
const TOOLBAR_HEIGHT: f32 = 40.0; const TOOLBAR_HEIGHT: f32 = editor_ui::content_browser::TOOLBAR_HEIGHT;
const DETAILS_MIN_PANEL_WIDTH: f32 = 760.0; const DETAILS_MIN_PANEL_WIDTH: f32 = 980.0;
const DETAILS_MIN_WIDTH: f32 = 200.0; const DETAILS_MIN_WIDTH: f32 = 200.0;
const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0; const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0;
const TREE_MIN_PANEL_WIDTH: f32 = 540.0; const TREE_MIN_PANEL_WIDTH: f32 = editor_ui::content_browser::COMPACT_BREAKPOINT;
const TREE_MIN_WIDTH: f32 = 150.0; const TREE_MIN_WIDTH: f32 = 180.0;
const TREE_MAX_WIDTH: f32 = 240.0; const TREE_MAX_WIDTH: f32 = 216.0;
const MIN_CONTENT_WIDTH: f32 = 160.0; const MIN_CONTENT_WIDTH: f32 = 160.0;
const COMPACT_LIST_WIDTH: f32 = 560.0; const COMPACT_LIST_WIDTH: f32 = 560.0;
const PHOSPHOR: &str = "phosphor-regular"; const PHOSPHOR: &str = "phosphor-regular";
@ -209,9 +208,16 @@ pub fn asset_browser_ui(
asset_toolbar(world, ui); asset_toolbar(world, ui);
}, },
); );
ui.separator(); let (
current_folder,
let (current_folder, selected, folders, assets, cache_snapshot, state_snapshot) = { selected,
folders,
assets,
cache_snapshot,
state_snapshot,
browser_status,
selection_count,
) = {
let assets = world.resource::<EditorAssets>(); let assets = world.resource::<EditorAssets>();
let cache = world.resource::<AssetThumbnailCache>(); let cache = world.resource::<AssetThumbnailCache>();
let state = world.resource::<AssetBrowserUiState>(); let state = world.resource::<AssetBrowserUiState>();
@ -241,6 +247,8 @@ pub fn asset_browser_ui(
asset_rows, asset_rows,
cache.snapshot(), cache.snapshot(),
state_snapshot(state), state_snapshot(state),
assets.status.clone(),
assets.selections.len(),
) )
}; };
@ -250,7 +258,7 @@ pub fn asset_browser_ui(
let show_details = let show_details =
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH; state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH;
let tree_width = if show_tree { let tree_width = if show_tree {
(available_width * 0.22).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH) (available_width * 0.18).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH)
} else { } else {
0.0 0.0
}; };
@ -275,7 +283,12 @@ pub fn asset_browser_ui(
egui::vec2(tree_width, body_height), egui::vec2(tree_width, body_height),
egui::Layout::top_down(egui::Align::Min), egui::Layout::top_down(egui::Align::Min),
|ui| { |ui| {
ui.label(panel_heading("Project")); ui.label(
egui::RichText::new("SOURCES")
.small()
.strong()
.color(TEXT_MUTED),
);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("asset_folder_tree") .id_salt("asset_folder_tree")
.max_height(ui.available_height()) .max_height(ui.available_height())
@ -396,19 +409,32 @@ pub fn asset_browser_ui(
}, },
); );
ui.separator();
exact_region( exact_region(
ui, ui,
egui::vec2(available_width, FOOTER_HEIGHT), egui::vec2(available_width, FOOTER_HEIGHT),
egui::Layout::top_down(egui::Align::Min), egui::Layout::top_down(egui::Align::Min),
|ui| { |ui| {
egui::Frame::new() let direct_assets = assets
.fill(ELEVATED_BG.linear_multiply(0.5)) .iter()
.inner_margin(egui::Margin::symmetric(8, 6)) .filter(|row| row.asset.folder_path == current_folder)
.show(ui, |ui| { .count();
ui.set_max_height(FOOTER_HEIGHT - 12.0); let direct_folders = folders
selection_footer(world, ui, selected_entities); .iter()
}); .filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str()))
.count();
let summary = if selection_count > 0 {
format!("{selection_count} selected")
} else {
format!("{direct_folders} folders · {direct_assets} assets")
};
editor_ui::content_browser::content_browser_status_bar(
ui,
editor_ui::content_browser::ContentBrowserStatusViewModel {
summary: &summary,
location: &current_folder,
status: &browser_status,
},
);
}, },
); );
}, },

View File

@ -228,17 +228,37 @@ pub(crate) fn top_level_asset_details_panel(
} }
pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) { pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) {
ui.horizontal_wrapped(|ui| { let thumbnail = world
ui.label( .get_resource::<AssetThumbnailCache>()
egui::RichText::new(kind_icon(&asset.kind).as_str()) .and_then(|cache| cache.snapshot().texture_for(asset));
.font(egui::FontId::new( ui.horizontal_top(|ui| {
30.0, let (preview, _) = ui.allocate_exact_size(egui::vec2(96.0, 96.0), egui::Sense::hover());
egui::FontFamily::Name(PHOSPHOR.into()), ui.painter().rect(
)) preview,
.color(ACCENT), 5.0,
WIDGET_BG,
egui::Stroke::new(1.0_f32, BORDER),
egui::StrokeKind::Inside,
); );
if let Some(texture) = thumbnail {
ui.painter().image(
texture,
preview.shrink(4.0),
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
} else {
ui.painter().text(
preview.center(),
egui::Align2::CENTER_CENTER,
kind_icon(&asset.kind).as_str(),
egui::FontId::new(30.0, egui::FontFamily::Name(PHOSPHOR.into())),
TEXT_DIM,
);
}
ui.vertical(|ui| { ui.vertical(|ui| {
ui.strong(asset.label.as_str()); ui.add(egui::Label::new(egui::RichText::new(&asset.label).strong()).truncate())
.on_hover_text(&asset.label);
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM)); ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
if let (Some(state), Some(path)) = ( if let (Some(state), Some(path)) = (
world.get_resource::<CollaborationState>(), world.get_resource::<CollaborationState>(),
@ -256,6 +276,8 @@ pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &Edi
ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong()); ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong());
} }
} }
ui.add_space(4.0);
ui.small(egui::RichText::new("Asset identity and import settings").color(TEXT_MUTED));
}); });
}); });
} }

View File

@ -7,11 +7,15 @@ pub(super) fn content_header(
folders: &[FolderSnapshot], folders: &[FolderSnapshot],
assets: &[AssetRow], assets: &[AssetRow],
) { ) {
ui.horizontal_wrapped(|ui| { let folder_label = if current_folder == BUILTINS_FOLDER {
ui.label(panel_heading("Assets")); "Built-ins"
ui.separator(); } else {
breadcrumb(world, ui, current_folder); Path::new(current_folder)
}); .file_name()
.and_then(|name| name.to_str())
.unwrap_or(current_folder)
};
ui.label(panel_heading(folder_label));
let direct_assets = assets let direct_assets = assets
.iter() .iter()
.filter(|row| row.asset.folder_path == current_folder) .filter(|row| row.asset.folder_path == current_folder)
@ -20,9 +24,14 @@ pub(super) fn content_header(
.iter() .iter()
.filter(|folder| folder.parent.as_deref() == Some(current_folder)) .filter(|folder| folder.parent.as_deref() == Some(current_folder))
.count(); .count();
let sort = world.resource::<AssetBrowserUiState>().sort;
ui.small( ui.small(
egui::RichText::new(format!("{direct_folders} folders, {direct_assets} assets")) egui::RichText::new(format!(
.color(TEXT_DIM), "{} items · sorted by {}",
direct_folders + direct_assets,
sort_label(sort)
))
.color(TEXT_DIM),
); );
} }
@ -67,176 +76,6 @@ pub(super) fn prefetch_asset_row_thumbnails(world: &mut World, rows: &[AssetRow]
}); });
} }
pub(super) fn breadcrumb(world: &mut World, ui: &mut egui::Ui, current_folder: &str) {
let mut parts = Vec::new();
let mut cursor = Some(current_folder.to_string());
while let Some(path) = cursor {
let label = if path == BUILTINS_FOLDER {
"Built-ins".to_string()
} else {
Path::new(&path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&path)
.to_string()
};
cursor = path.rfind('/').map(|index| path[..index].to_string());
parts.push((path, label));
}
parts.reverse();
for (index, (path, label)) in parts.iter().enumerate() {
if index > 0 {
ui.label(egui::RichText::new("/").color(TEXT_DIM));
}
if ui.link(label).clicked() {
navigate_content_folder(world, path.clone());
}
}
}
pub(super) fn selection_footer(
world: &mut World,
ui: &mut egui::Ui,
selected_entities: &SelectedEntities,
) {
let selection = world.resource::<EditorAssets>().selected.clone();
let selection_count = world.resource::<EditorAssets>().selections.len();
if selection_count > 1 {
let has_authored_content = !selected_content_paths(world).is_empty();
ui.label(format!("{selection_count} items selected"));
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(has_authored_content, egui::Button::new("Cut"))
.clicked()
{
set_content_clipboard(world, true);
}
if ui
.add_enabled(has_authored_content, egui::Button::new("Copy"))
.clicked()
{
set_content_clipboard(world, false);
}
if ui
.add_enabled(has_authored_content, egui::Button::new("Duplicate"))
.clicked()
{
duplicate_selection(world);
}
if ui
.add_enabled(has_authored_content, egui::Button::new("Move To Trash"))
.clicked()
{
request_delete_for_selection(world);
}
});
return;
}
let selected_embedded = selection
.as_ref()
.and_then(|selection| embedded_asset_for_selection(world, selection));
if let Some(AssetSelection::Folder(path)) = selection.clone() {
ui.add(egui::Label::new(format!("Selected folder: {path}")).truncate());
ui.horizontal_wrapped(|ui| {
if ui.button("Open").clicked() {
navigate_content_folder(world, path.clone());
}
if ui.button("Import Here").clicked() {
request_import_to_destination(world, path.clone());
}
if ui.button("Import To...").clicked() {
begin_import_to(world);
}
});
} else if let Some(AssetSelection::SubAsset { label, kind, .. }) = selection.clone() {
ui.add(
egui::Label::new(format!(
"Selected: {}: {}",
subasset_kind_label(kind),
label
))
.truncate(),
);
ui.horizontal_wrapped(|ui| match kind {
AssetSubAssetKind::Mesh => {
if selected_embedded
.as_ref()
.is_some_and(|embedded| embedded.requires_skinned_hierarchy)
{
if ui.button("Place Skinned Model").clicked() {
if let Some(selection) = selection.as_ref() {
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
}
}
} else if ui.button("Place At Origin").clicked() {
if let Some(selection) = selection.as_ref() {
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
}
}
}
AssetSubAssetKind::Texture => {
if ui.button("Apply Texture To Selection").clicked() {
if let Some(asset) = texture_asset_from_subasset_selection(world, &selection) {
apply_texture_operator(world, asset, selected_entities);
}
}
}
AssetSubAssetKind::Material => {
ui.small(egui::RichText::new("Embedded source material").color(TEXT_DIM));
}
AssetSubAssetKind::Skeleton => {
ui.small(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM));
}
AssetSubAssetKind::AnimationClip => {
let animated_actor = selected_entities
.as_slice()
.iter()
.copied()
.find(|entity| world.get::<shared::SkinnedMeshRenderer>(*entity).is_some());
let action = if animated_actor.is_some() {
"Assign To Selected Actor"
} else {
"Create Animated Actor"
};
if ui.button(action).clicked() {
if let Some(selection) = selection.as_ref() {
if let Some(entity) = animated_actor {
assign_animation_clip_operator(world, selection.clone(), entity);
} else {
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
}
}
}
}
});
} else if let Some(asset) = world.resource::<EditorAssets>().selected_asset().cloned() {
ui.add(egui::Label::new(format!("Selected: {}", asset_label(&asset))).truncate());
ui.horizontal_wrapped(|ui| {
if matches!(asset.kind, EditorAssetKind::Texture)
&& ui.button("Apply Texture To Selection").clicked()
{
apply_texture_operator(world, asset.clone(), selected_entities);
}
if matches!(
asset.kind,
EditorAssetKind::Primitive(_)
| EditorAssetKind::Light(_)
| EditorAssetKind::Model
| EditorAssetKind::AudioClip
| EditorAssetKind::Prefab
) && ui.button("Place At Origin").clicked()
{
place_asset_operator(world, asset.clone(), Vec3::ZERO);
}
if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() {
open_level_asset(world, &asset);
}
});
} else {
ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM));
}
}
pub(super) fn folder_tree_branch( pub(super) fn folder_tree_branch(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,

View File

@ -126,6 +126,43 @@
)); ));
} }
#[test]
fn content_navigation_history_round_trips_like_a_file_manager() {
let mut world = World::new();
let mut assets = EditorAssets::default();
assets.current_folder = "assets".into();
world.insert_resource(assets);
world.insert_resource(AssetBrowserUiState::default());
navigate_content_folder(&mut world, "assets/Props".into());
navigate_content_folder(&mut world, "assets/Props/Office".into());
assert_eq!(
world.resource::<AssetBrowserUiState>().navigation_back,
["assets", "assets/Props"]
);
navigate_content_back(&mut world);
assert_eq!(
world.resource::<EditorAssets>().current_folder,
"assets/Props"
);
navigate_content_back(&mut world);
assert_eq!(world.resource::<EditorAssets>().current_folder, "assets");
navigate_content_forward(&mut world);
assert_eq!(
world.resource::<EditorAssets>().current_folder,
"assets/Props"
);
navigate_content_folder(&mut world, "assets/Materials".into());
assert!(
world
.resource::<AssetBrowserUiState>()
.navigation_forward
.is_empty()
);
}
#[test] #[test]
fn audio_filter_matches_only_audio_clips() { fn audio_filter_matches_only_audio_clips() {
let audio = EditorAsset { let audio = EditorAsset {

View File

@ -5,182 +5,201 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
let selection_count = world.resource::<EditorAssets>().selections.len(); let selection_count = world.resource::<EditorAssets>().selections.len();
let has_editable_selection = !selected_content_paths(world).is_empty(); let has_editable_selection = !selected_content_paths(world).is_empty();
let can_rename = selection_count == 1 && has_editable_selection; let can_rename = selection_count == 1 && has_editable_selection;
ui.horizontal(|ui| { let pending_repairs = world
if icon_button_small(ui, icons::ARROWS_CLOCKWISE, "Refresh").clicked() { .get_resource::<PendingExternalMoveRepair>()
crate::assets::refresh_content_browser(world); .and_then(|pending| pending.review.as_ref())
} .map_or(0, |review| review.conflicts.len());
let pending_repairs = world let (
.get_resource::<PendingExternalMoveRepair>() search,
.and_then(|pending| pending.review.as_ref()) view,
.map_or(0, |review| review.conflicts.len()); sort,
if ui kind_filter,
.add_enabled( thumbnail_size,
pending_repairs > 0, recursive,
egui::Button::new(format!("Resolve Moves ({pending_repairs})")), show_details,
) can_undo,
.on_hover_text("Choose which stable asset identities match ambiguous external moves") can_paste,
.clicked() can_go_back,
{ can_go_forward,
if let Some(review) = world ) = {
.resource_mut::<PendingExternalMoveRepair>() let state = world.resource::<AssetBrowserUiState>();
.review (
.as_mut() state.search.clone(),
{ state.view,
review.open = true; state.sort,
state.kind_filter,
state.thumbnail_size,
state.recursive,
state.show_details,
!state.content_undo.is_empty(),
state.clipboard.is_some() && current_folder != BUILTINS_FOLDER,
!state.navigation_back.is_empty(),
!state.navigation_forward.is_empty(),
)
};
let model = editor_ui::content_browser::ContentBrowserToolbarViewModel {
path: content_path_segments(&current_folder),
search,
kind_filter: kind_filter.into(),
sort: sort.into(),
view: view.into(),
recursive,
show_details,
thumbnail_size,
can_go_parent: current_folder.contains('/'),
can_go_back,
can_go_forward,
can_edit_folder: current_folder != BUILTINS_FOLDER,
can_rename,
can_edit_selection: has_editable_selection,
can_paste,
can_undo,
pending_repairs,
};
let actions = editor_ui::content_browser::content_browser_toolbar(ui, &model);
let mut visible_results_changed = false;
for action in actions {
use editor_ui::content_browser::ContentBrowserToolbarAction as Action;
match action {
Action::Back => navigate_content_back(world),
Action::Forward => navigate_content_forward(world),
Action::Parent => navigate_to_parent(world),
Action::Refresh => crate::assets::refresh_content_browser(world),
Action::Navigate(path) => navigate_content_folder(world, path),
Action::ImportHere => request_import_to_destination(world, current_folder.clone()),
Action::ImportTo => begin_import_to(world),
Action::NewFolder => begin_new_folder(world, current_folder.clone()),
Action::Rename => begin_rename(world),
Action::Duplicate => duplicate_selection(world),
Action::Undo => undo_last_content_operation(world),
Action::Cut => set_content_clipboard(world, true),
Action::Copy => set_content_clipboard(world, false),
Action::Paste => paste_content_clipboard(world),
Action::OpenTrash => world.resource_mut::<AssetBrowserUiState>().show_trash = true,
Action::ReviewMoves => {
if let Some(review) = world
.resource_mut::<PendingExternalMoveRepair>()
.review
.as_mut()
{
review.open = true;
}
}
Action::SetSearch(search) => {
world.resource_mut::<AssetBrowserUiState>().search = search;
visible_results_changed = true;
}
Action::SetKindFilter(filter) => {
world.resource_mut::<AssetBrowserUiState>().kind_filter = filter.into();
visible_results_changed = true;
}
Action::SetSort(sort) => world.resource_mut::<AssetBrowserUiState>().sort = sort.into(),
Action::SetView(view) => world.resource_mut::<AssetBrowserUiState>().view = view.into(),
Action::SetRecursive(recursive) => {
world.resource_mut::<AssetBrowserUiState>().recursive = recursive;
visible_results_changed = true;
}
Action::SetShowDetails(show) => {
world.resource_mut::<AssetBrowserUiState>().show_details = show;
}
Action::SetThumbnailSize(size) => {
world.resource_mut::<AssetBrowserUiState>().thumbnail_size = size;
} }
} }
if icon_button_small(ui, icons::UPLOAD, "Import Here").clicked() { }
request_import_to_destination(world, current_folder.clone()); clear_selection_when_visible_results_change(world, visible_results_changed);
} }
if ui
.add_enabled(
current_folder != BUILTINS_FOLDER,
egui::Button::new("Import To..."),
)
.clicked()
{
begin_import_to(world);
}
if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() {
navigate_to_parent(world);
}
if ui
.add_enabled(
current_folder != BUILTINS_FOLDER,
egui::Button::new("New Folder"),
)
.clicked()
{
begin_new_folder(world, current_folder.clone());
}
if ui
.add_enabled(can_rename, egui::Button::new("Rename"))
.clicked()
{
begin_rename(world);
}
if ui
.add_enabled(has_editable_selection, egui::Button::new("Duplicate"))
.clicked()
{
duplicate_selection(world);
}
let can_undo_content = !world
.resource::<AssetBrowserUiState>()
.content_undo
.is_empty();
if ui
.add_enabled(can_undo_content, egui::Button::new("Undo Content"))
.clicked()
{
undo_last_content_operation(world);
}
if ui
.add_enabled(has_editable_selection, egui::Button::new("Cut"))
.clicked()
{
set_content_clipboard(world, true);
}
if ui
.add_enabled(has_editable_selection, egui::Button::new("Copy"))
.clicked()
{
set_content_clipboard(world, false);
}
let can_paste = world.resource::<AssetBrowserUiState>().clipboard.is_some()
&& world.resource::<EditorAssets>().current_folder != BUILTINS_FOLDER;
if ui
.add_enabled(can_paste, egui::Button::new("Paste"))
.clicked()
{
paste_content_clipboard(world);
}
if ui.small_button("Open Trash").clicked() {
world.resource_mut::<AssetBrowserUiState>().show_trash = true;
}
ui.separator(); fn content_path_segments(
current_folder: &str,
) -> Vec<editor_ui::content_browser::ContentPathSegment> {
let mut segments = Vec::new();
let mut current = String::new();
for part in current_folder.split('/').filter(|part| !part.is_empty()) {
if !current.is_empty() {
current.push('/');
}
current.push_str(part);
segments.push(editor_ui::content_browser::ContentPathSegment {
path: current.clone(),
label: if current == BUILTINS_FOLDER {
"Built-ins".into()
} else {
part.to_string()
},
});
}
segments
}
let visible_results_changed = { impl From<AssetBrowserView> for editor_ui::content_browser::ContentBrowserViewMode {
let mut state = world.resource_mut::<AssetBrowserUiState>(); fn from(value: AssetBrowserView) -> Self {
let mut visible_results_changed = ui match value {
.add( AssetBrowserView::Grid => Self::Grid,
egui::TextEdit::singleline(&mut state.search) AssetBrowserView::List => Self::List,
.hint_text("Search assets...") }
.desired_width(fit_width(ui, 120.0, 190.0)), }
) }
.changed();
visible_results_changed |= ui.checkbox(&mut state.recursive, "Subfolders").changed();
let previous_kind_filter = state.kind_filter; impl From<editor_ui::content_browser::ContentBrowserViewMode> for AssetBrowserView {
egui::ComboBox::from_id_salt("asset_kind_filter") fn from(value: editor_ui::content_browser::ContentBrowserViewMode) -> Self {
.selected_text(kind_filter_label(state.kind_filter)) match value {
.show_ui(ui, |ui| { editor_ui::content_browser::ContentBrowserViewMode::Grid => Self::Grid,
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::All, "All"); editor_ui::content_browser::ContentBrowserViewMode::List => Self::List,
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Model, "Models"); }
ui.selectable_value( }
&mut state.kind_filter, }
AssetKindFilter::Texture,
"Textures",
);
ui.selectable_value(
&mut state.kind_filter,
AssetKindFilter::Material,
"Materials",
);
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Audio, "Audio");
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Level, "Levels");
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Prefab, "Prefabs");
ui.selectable_value(
&mut state.kind_filter,
AssetKindFilter::Builtin,
"Built-ins",
);
});
visible_results_changed |= state.kind_filter != previous_kind_filter;
egui::ComboBox::from_id_salt("asset_sort") impl From<AssetSort> for editor_ui::content_browser::ContentBrowserSort {
.selected_text(sort_label(state.sort)) fn from(value: AssetSort) -> Self {
.show_ui(ui, |ui| { match value {
ui.selectable_value(&mut state.sort, AssetSort::Name, "Name"); AssetSort::Name => Self::Name,
ui.selectable_value(&mut state.sort, AssetSort::Kind, "Type"); AssetSort::Kind => Self::Kind,
ui.selectable_value(&mut state.sort, AssetSort::Modified, "Modified"); AssetSort::Modified => Self::Modified,
ui.selectable_value(&mut state.sort, AssetSort::Size, "Size"); AssetSort::Size => Self::Size,
}); }
}
}
ui.separator(); impl From<editor_ui::content_browser::ContentBrowserSort> for AssetSort {
if tool_button( fn from(value: editor_ui::content_browser::ContentBrowserSort) -> Self {
ui, match value {
icons::GRID_FOUR, editor_ui::content_browser::ContentBrowserSort::Name => Self::Name,
state.view == AssetBrowserView::Grid, editor_ui::content_browser::ContentBrowserSort::Kind => Self::Kind,
"Grid view", editor_ui::content_browser::ContentBrowserSort::Modified => Self::Modified,
) editor_ui::content_browser::ContentBrowserSort::Size => Self::Size,
.clicked() }
{ }
state.view = AssetBrowserView::Grid; }
}
if tool_button( impl From<AssetKindFilter> for editor_ui::content_browser::ContentBrowserKindFilter {
ui, fn from(value: AssetKindFilter) -> Self {
icons::LIST, match value {
state.view == AssetBrowserView::List, AssetKindFilter::All => Self::All,
"List view", AssetKindFilter::Model => Self::Model,
) AssetKindFilter::Texture => Self::Texture,
.clicked() AssetKindFilter::Material => Self::Material,
{ AssetKindFilter::Audio => Self::Audio,
state.view = AssetBrowserView::List; AssetKindFilter::Level => Self::Level,
} AssetKindFilter::Prefab => Self::Prefab,
if ui.available_width() > 180.0 { AssetKindFilter::Builtin => Self::Builtin,
ui.label(icon_text(icons::IMAGE_SQUARE, 13.0).color(TEXT_DIM)); }
ui.add_sized( }
[96.0, 20.0], }
egui::Slider::new(&mut state.thumbnail_size, 48.0..=112.0).show_value(false),
); impl From<editor_ui::content_browser::ContentBrowserKindFilter> for AssetKindFilter {
} fn from(value: editor_ui::content_browser::ContentBrowserKindFilter) -> Self {
ui.checkbox(&mut state.show_details, "Details"); match value {
visible_results_changed editor_ui::content_browser::ContentBrowserKindFilter::All => Self::All,
}; editor_ui::content_browser::ContentBrowserKindFilter::Model => Self::Model,
clear_selection_when_visible_results_change(world, visible_results_changed); editor_ui::content_browser::ContentBrowserKindFilter::Texture => Self::Texture,
}); editor_ui::content_browser::ContentBrowserKindFilter::Material => Self::Material,
editor_ui::content_browser::ContentBrowserKindFilter::Audio => Self::Audio,
editor_ui::content_browser::ContentBrowserKindFilter::Level => Self::Level,
editor_ui::content_browser::ContentBrowserKindFilter::Prefab => Self::Prefab,
editor_ui::content_browser::ContentBrowserKindFilter::Builtin => Self::Builtin,
}
}
} }
pub(super) fn clear_selection_when_visible_results_change(world: &mut World, changed: bool) { pub(super) fn clear_selection_when_visible_results_change(world: &mut World, changed: bool) {

View File

@ -25,6 +25,51 @@ pub(super) fn navigate_to_parent(world: &mut World) {
} }
pub(super) fn navigate_content_folder(world: &mut World, folder: String) { pub(super) fn navigate_content_folder(world: &mut World, folder: String) {
let current = world.resource::<EditorAssets>().current_folder.clone();
if current == folder {
return;
}
{
let mut state = world.resource_mut::<AssetBrowserUiState>();
state.navigation_back.push(current);
state.navigation_forward.clear();
}
set_content_folder(world, folder);
}
pub(super) fn navigate_content_back(world: &mut World) {
let Some(folder) = world
.resource_mut::<AssetBrowserUiState>()
.navigation_back
.pop()
else {
return;
};
let current = world.resource::<EditorAssets>().current_folder.clone();
world
.resource_mut::<AssetBrowserUiState>()
.navigation_forward
.push(current);
set_content_folder(world, folder);
}
pub(super) fn navigate_content_forward(world: &mut World) {
let Some(folder) = world
.resource_mut::<AssetBrowserUiState>()
.navigation_forward
.pop()
else {
return;
};
let current = world.resource::<EditorAssets>().current_folder.clone();
world
.resource_mut::<AssetBrowserUiState>()
.navigation_back
.push(current);
set_content_folder(world, folder);
}
fn set_content_folder(world: &mut World, folder: String) {
let mut assets = world.resource_mut::<EditorAssets>(); let mut assets = world.resource_mut::<EditorAssets>();
assets.current_folder = folder.clone(); assets.current_folder = folder.clone();
assets.clear_selection(); assets.clear_selection();

View File

@ -7,7 +7,7 @@ use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
use bevy::prelude::*; use bevy::prelude::*;
use shared::MaterialAsset; use shared::MaterialAsset;
pub(crate) const ASSET_DETAILS_DEFAULT_WIDTH: f32 = 260.0; pub(crate) const ASSET_DETAILS_DEFAULT_WIDTH: f32 = 290.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AssetBrowserView { pub(crate) enum AssetBrowserView {
@ -44,6 +44,9 @@ pub struct AssetBrowserUiState {
pub(crate) thumbnail_size: f32, pub(crate) thumbnail_size: f32,
pub(crate) recursive: bool, pub(crate) recursive: bool,
pub(crate) show_details: bool, pub(crate) show_details: bool,
/// File-manager navigation history. Content topology operations do not rewrite these stacks.
pub(crate) navigation_back: Vec<String>,
pub(crate) navigation_forward: Vec<String>,
/// Width of the details pane for the current editor session. /// Width of the details pane for the current editor session.
pub(crate) details_width: f32, pub(crate) details_width: f32,
pub(crate) expanded_folders: HashSet<String>, pub(crate) expanded_folders: HashSet<String>,
@ -183,9 +186,11 @@ impl Default for AssetBrowserUiState {
view: AssetBrowserView::Grid, view: AssetBrowserView::Grid,
sort: AssetSort::Name, sort: AssetSort::Name,
kind_filter: AssetKindFilter::All, kind_filter: AssetKindFilter::All,
thumbnail_size: 72.0, thumbnail_size: 96.0,
recursive: false, recursive: false,
show_details: true, show_details: true,
navigation_back: Vec::new(),
navigation_forward: Vec::new(),
details_width: ASSET_DETAILS_DEFAULT_WIDTH, details_width: ASSET_DETAILS_DEFAULT_WIDTH,
expanded_folders, expanded_folders,
expanded_assets: HashSet::new(), expanded_assets: HashSet::new(),

View File

@ -166,6 +166,9 @@ struct PendingPropertyBlockPromotion {
slot_id: ComponentInstanceId, slot_id: ComponentInstanceId,
slot_name: String, slot_name: String,
base: MaterialRef, base: MaterialRef,
/// Actor-owned assignment captured when the review opened. `None` means the selected base
/// was inherited from the model or project defaults and must remain inherited until commit.
actor_assignment: Option<MaterialRef>,
block: MaterialPropertyBlock, block: MaterialPropertyBlock,
project_root: PathBuf, project_root: PathBuf,
destination_path: PathBuf, destination_path: PathBuf,
@ -362,6 +365,8 @@ mod component_lifecycle;
mod dispatch; mod dispatch;
#[path = "inspector/imported_material_preview.rs"] #[path = "inspector/imported_material_preview.rs"]
mod imported_material_preview; mod imported_material_preview;
#[path = "inspector/material_resolution.rs"]
mod material_resolution;
#[path = "inspector/material_slots.rs"] #[path = "inspector/material_slots.rs"]
mod material_slots; mod material_slots;
#[path = "inspector/mesh_renderers.rs"] #[path = "inspector/mesh_renderers.rs"]
@ -384,6 +389,7 @@ pub(crate) use component_lifecycle::apply_component_card_response;
use component_lifecycle::*; use component_lifecycle::*;
pub use dispatch::authoring_inspector_ui; pub use dispatch::authoring_inspector_ui;
pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors}; pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors};
use material_resolution::*;
use material_slots::*; use material_slots::*;
use mesh_renderers::*; use mesh_renderers::*;
use primitive_domains::*; use primitive_domains::*;

View File

@ -0,0 +1,174 @@
//! Authoring-layer material resolution shared by Inspector display and guarded transactions.
use super::*;
/// Returns only the actor-owned assignment for an exact surface slot.
pub(super) fn actor_material_assignment_for_slot(
world: &World,
entity: Entity,
slot_id: &ComponentInstanceId,
) -> Option<MaterialRef> {
world
.get::<SkinnedMeshRenderer>(entity)
.and_then(|renderer| renderer.materials.slot(slot_id))
.and_then(|slot| slot.material.clone())
.or_else(|| {
world
.get::<StaticMeshRenderer>(entity)
.and_then(|renderer| renderer.materials.slot(slot_id))
.and_then(|slot| slot.material.clone())
})
.or_else(|| {
world
.get::<Primitive>(entity)
.filter(|primitive| primitive.surface.id == *slot_id)
.and_then(|primitive| primitive.surface.material.clone())
})
}
/// Resolves authored actor/model/project layers for an exact actor slot.
pub(super) fn effective_material_for_actor_slot(
world: &World,
entity: Entity,
slot_id: &ComponentInstanceId,
) -> Option<MaterialRef> {
actor_material_assignment_for_slot(world, entity, slot_id)
.or_else(|| model_material_for_actor_slot(world, entity, slot_id).map(|(base, _)| base))
.or_else(|| {
world
.get_resource::<shared::ProjectContentDefaults>()
.and_then(|defaults| defaults.default_material.clone())
})
}
pub(super) fn model_material_for_actor_slot(
world: &World,
entity: Entity,
slot_id: &ComponentInstanceId,
) -> Option<(MaterialRef, crate::ui::materials::MaterialLayerBadge)> {
let asset_id = world
.get::<StaticMeshRenderer>(entity)
.and_then(|renderer| {
renderer
.slots
.iter()
.find(|part| part.material_slot_id == *slot_id)
.map(|part| part.mesh.asset_id.clone())
})
.or_else(|| {
world
.get::<SkinnedMeshRenderer>(entity)
.map(|renderer| renderer.asset_id.clone())
})?;
let record = world
.get_resource::<AssetRegistry>()?
.records
.iter()
.find(|record| record.id.as_string() == asset_id)?;
let settings = record.model_import();
match crate::assets::static_mesh::material_selection(settings, &slot_id.0) {
ModelMaterialSelection::Project(reference) => Some((
reference.clone(),
crate::ui::materials::MaterialLayerBadge::ModelDefault,
)),
ModelMaterialSelection::Default => None,
ModelMaterialSelection::Source => {
let manifest = settings
.static_mesh_manifest_path
.as_deref()
.and_then(|path| load_static_mesh_manifest(path).ok())?;
let part = manifest.parts.iter().find(|part| {
let part_id = if part.id.trim().is_empty() {
part_id_from_label(&part.mesh_label)
} else {
part.id.clone()
};
slot_id.0 == format!("slot:{part_id}")
})?;
let material_id = part
.material_id
.clone()
.filter(|id| !id.trim().is_empty())
.or_else(|| part.material_label.as_deref().map(material_id_from_label))?;
Some((
MaterialRef::new(EditorAssetRef::new(
manifest.asset_id,
material_id,
part.material_slot_name.clone(),
)),
crate::ui::materials::MaterialLayerBadge::ModelSource,
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn material_ref(id: &str) -> MaterialRef {
MaterialRef::new(EditorAssetRef::new(id, "material:source", id))
}
#[test]
fn project_default_is_effective_without_becoming_actor_owned() {
let mut world = World::new();
let fallback = material_ref("project-default");
world.insert_resource(shared::ProjectContentDefaults {
default_material: Some(fallback.clone()),
});
let primitive = Primitive::default();
let slot_id = primitive.surface.id.clone();
let entity = world.spawn(primitive).id();
assert_eq!(
actor_material_assignment_for_slot(&world, entity, &slot_id),
None
);
assert_eq!(
effective_material_for_actor_slot(&world, entity, &slot_id),
Some(fallback)
);
}
#[test]
fn static_and_skinned_actor_assignments_override_project_defaults() {
let mut world = World::new();
world.insert_resource(shared::ProjectContentDefaults {
default_material: Some(material_ref("project-default")),
});
let slot_id = ComponentInstanceId::new("slot:surface");
let explicit = material_ref("actor-material");
let materials = shared::MaterialSlotSet {
slots: vec![shared::MaterialSlot {
id: slot_id.clone(),
name: "Surface".into(),
material: Some(explicit.clone()),
}],
orphaned_assignments: Vec::new(),
};
let static_entity = world
.spawn(StaticMeshRenderer {
materials: materials.clone(),
..StaticMeshRenderer::default()
})
.id();
let skinned_entity = world
.spawn(SkinnedMeshRenderer {
materials,
..SkinnedMeshRenderer::default()
})
.id();
for entity in [static_entity, skinned_entity] {
assert_eq!(
actor_material_assignment_for_slot(&world, entity, &slot_id),
Some(explicit.clone())
);
assert_eq!(
effective_material_for_actor_slot(&world, entity, &slot_id),
Some(explicit.clone())
);
}
}
}

View File

@ -218,7 +218,7 @@ pub(super) fn material_slot_widget_ui(
}), }),
can_clear: slot.material.is_some(), can_clear: slot.material.is_some(),
can_extract: is_imported_source, can_extract: is_imported_source,
can_create_instance: is_project_material, can_create_instance: is_project_material || is_project_instance,
parameters_initially_open: true, parameters_initially_open: true,
expanded_header_when_parameters_closed, expanded_header_when_parameters_closed,
}; };
@ -327,8 +327,15 @@ pub(super) fn material_slot_widget_ui(
.get::<MaterialPropertyBlocks>(entity) .get::<MaterialPropertyBlocks>(entity)
.and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id)) .and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id))
.cloned(); .cloned();
if property_block_promotion_ui(ui, property_block.as_ref(), slot.material.is_some()) { let project_promotion_base = (is_project_material || is_project_instance)
if let (Some(base), Some(block)) = (slot.material.clone(), property_block) { .then(|| effective.clone())
.flatten();
if property_block_promotion_ui(
ui,
property_block.as_ref(),
project_promotion_base.is_some(),
) {
if let (Some(base), Some(block)) = (project_promotion_base, property_block) {
open_property_block_promotion_review( open_property_block_promotion_review(
world, world,
entity, entity,
@ -349,67 +356,6 @@ fn stable_actor_widget_id(world: &World, entity: Entity) -> String {
.unwrap_or_else(|| format!("runtime:{entity:?}")) .unwrap_or_else(|| format!("runtime:{entity:?}"))
} }
fn model_material_for_actor_slot(
world: &World,
entity: Entity,
slot_id: &ComponentInstanceId,
) -> Option<(MaterialRef, crate::ui::materials::MaterialLayerBadge)> {
let asset_id = world
.get::<StaticMeshRenderer>(entity)
.and_then(|renderer| {
renderer
.slots
.iter()
.find(|part| part.material_slot_id == *slot_id)
.map(|part| part.mesh.asset_id.clone())
})
.or_else(|| {
world
.get::<SkinnedMeshRenderer>(entity)
.map(|renderer| renderer.asset_id.clone())
})?;
let record = world
.get_resource::<AssetRegistry>()?
.records
.iter()
.find(|record| record.id.as_string() == asset_id)?;
let settings = record.model_import();
match crate::assets::static_mesh::material_selection(settings, &slot_id.0) {
ModelMaterialSelection::Project(reference) => Some((
reference.clone(),
crate::ui::materials::MaterialLayerBadge::ModelDefault,
)),
ModelMaterialSelection::Default => None,
ModelMaterialSelection::Source => {
let manifest = settings
.static_mesh_manifest_path
.as_deref()
.and_then(|path| load_static_mesh_manifest(path).ok())?;
let part = manifest.parts.iter().find(|part| {
let part_id = if part.id.trim().is_empty() {
part_id_from_label(&part.mesh_label)
} else {
part.id.clone()
};
slot_id.0 == format!("slot:{part_id}")
})?;
let material_id = part
.material_id
.clone()
.filter(|id| !id.trim().is_empty())
.or_else(|| part.material_label.as_deref().map(material_id_from_label))?;
Some((
MaterialRef::new(EditorAssetRef::new(
manifest.asset_id,
material_id,
part.material_slot_name.clone(),
)),
crate::ui::materials::MaterialLayerBadge::ModelSource,
))
}
}
}
fn create_instance_and_assign_slot( fn create_instance_and_assign_slot(
world: &mut World, world: &mut World,
entity: Entity, entity: Entity,

View File

@ -1,6 +1,7 @@
use super::*; use super::*;
pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh);
let material_candidates = brush_face_material_ref_candidates(world); let material_candidates = brush_face_material_ref_candidates(world);
let material_drop_candidate = world let material_drop_candidate = world
.get_resource::<EditorAssets>() .get_resource::<EditorAssets>()
@ -16,6 +17,8 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
let original = renderer.clone(); let original = renderer.clone();
let mut changed = false; let mut changed = false;
let mut accepted_drop = false; let mut accepted_drop = false;
let mut browse_mesh = false;
let mut locate_mesh = false;
let mut options = ComponentCardOptions::removable( let mut options = ComponentCardOptions::removable(
COMPONENT_SKINNED_MESH_RENDERER, COMPONENT_SKINNED_MESH_RENDERER,
"Skinned Mesh Renderer", "Skinned Mesh Renderer",
@ -26,73 +29,131 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
options.resettable = false; options.resettable = false;
options.copyable = false; options.copyable = false;
options.summary = "Skeleton-bound renderer"; options.summary = "Skeleton-bound renderer";
options.body_margin = 0;
let context = component_card_context(world, entity, options); let context = component_card_context(world, entity, options);
let response = component_card(ui, &context, |ui| { let response = component_card(ui, &context, |ui| {
property_row(ui, "Source", |ui| { let renderer_open_id = ui.make_persistent_id(("skinned_renderer_open", entity));
ui.add( let mut renderer_open = ui
egui::Label::new(if renderer.path.trim().is_empty() { .ctx()
"Unassigned" .data_mut(|data| data.get_persisted::<bool>(renderer_open_id))
} else { .unwrap_or(true);
renderer.path.as_str() let source_candidate = mesh_candidates
}) .iter()
.truncate(), .find(|candidate| candidate.reference.asset_id == renderer.asset_id);
); let label = Path::new(&renderer.path)
}); .file_stem()
property_row(ui, "Scene", |ui| { .and_then(|stem| stem.to_str())
ui.label(renderer.scene_index.to_string()); .filter(|label| !label.trim().is_empty())
}); .unwrap_or("Skinned mesh");
property_row(ui, "Asset ID", |ui| { let thumbnail = source_candidate.and_then(|candidate| candidate.texture_id);
ui.add(
egui::Label::new(if renderer.asset_id.trim().is_empty() { renderer_panel(ui, |ui| {
"Legacy/path-only" renderer_panel_header(
} else { ui,
renderer.asset_id.as_str() RendererPanelHeaderViewModel {
}) index: 0,
.truncate(), label,
); path: if renderer.path.trim().is_empty() {
}); "No model source assigned"
ui.label( } else {
egui::RichText::new( renderer.path.as_str()
"Preserves the imported skeleton hierarchy and Bevy skinned-mesh bindings.",
)
.small()
.color(TEXT_DIM),
);
ui.add_space(8.0);
if renderer.materials.slots.is_empty() {
ui.label(
egui::RichText::new("No imported slots; reimport the source model")
.small()
.color(TEXT_DIM),
);
}
let materials = crate::ui::materials::MaterialsSectionViewModel {
slot_ids: renderer
.materials
.slots
.iter()
.map(|slot| slot.id.0.clone())
.collect(),
};
crate::ui::materials::materials_section(ui, &materials, |ui| {
for slot in &mut renderer.materials.slots {
let widget = material_slot_widget_ui(
world,
ui,
entity,
slot,
MaterialSlotWidgetContext {
candidates: &material_candidates,
drop_candidate: material_drop_candidate.as_ref(),
invalid_drop_reason: invalid_material_drop,
expanded_header_when_parameters_closed: true,
}, },
); slot_count: renderer.materials.slots.len(),
texture_id: thumbnail,
},
&mut renderer_open,
|ui| {
if editor_ui::design_system::controls::icon_button(
ui,
icons::CROSSHAIR,
"Locate model",
source_candidate.is_some(),
)
.clicked()
{
locate_mesh = true;
}
if editor_ui::design_system::controls::icon_button(
ui,
icons::FOLDER_OPEN,
"Browse models",
true,
)
.clicked()
{
browse_mesh = true;
}
},
);
if !renderer_open {
return;
}
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
renderer_properties_header(ui);
property_row(ui, "Source", |ui| {
ui.add(
egui::Label::new(if renderer.path.trim().is_empty() {
"Unassigned"
} else {
renderer.path.as_str()
})
.truncate(),
);
});
property_row(ui, "Scene", |ui| {
ui.label(renderer.scene_index.to_string());
});
property_row(ui, "Asset ID", |ui| {
ui.add(
egui::Label::new(if renderer.asset_id.trim().is_empty() {
"Unresolved"
} else {
renderer.asset_id.as_str()
})
.truncate(),
);
});
ui.small(
egui::RichText::new(
"Imported skeleton hierarchy and skin bindings remain model-owned.",
)
.color(TEXT_DIM),
);
});
renderer_material_slots_header(ui, renderer.materials.slots.len());
if renderer.materials.slots.is_empty() {
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
ui.small(
egui::RichText::new("No imported slots; reimport the source model")
.color(TEXT_DIM),
);
});
}
for (index, slot) in renderer.materials.slots.iter_mut().enumerate() {
let widget = renderer_material_slot_row(ui, index, |ui| {
material_slot_widget_ui(
world,
ui,
entity,
slot,
MaterialSlotWidgetContext {
candidates: &material_candidates,
drop_candidate: material_drop_candidate.as_ref(),
invalid_drop_reason: invalid_material_drop,
expanded_header_when_parameters_closed: true,
},
)
});
changed |= widget.changed; changed |= widget.changed;
accepted_drop |= widget.accepted_drop; accepted_drop |= widget.accepted_drop;
ui.add_space(8.0);
} }
}); });
ui.ctx()
.data_mut(|data| data.insert_persisted(renderer_open_id, renderer_open));
for orphan in &renderer.materials.orphaned_assignments { for orphan in &renderer.materials.orphaned_assignments {
ui.label( ui.label(
egui::RichText::new(format!( egui::RichText::new(format!(
@ -114,6 +175,17 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
} }
}); });
apply_component_card_response(world, entity, response); apply_component_card_response(world, entity, response);
if browse_mesh {
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
}
if locate_mesh {
if let Some(candidate) = mesh_candidates
.iter()
.find(|candidate| candidate.reference.asset_id == renderer.asset_id)
{
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
}
}
if accepted_drop { if accepted_drop {
clear_asset_drag(world); clear_asset_drag(world);
} }
@ -153,6 +225,8 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
let mut changed = false; let mut changed = false;
let mut accepted_drop = false; let mut accepted_drop = false;
let mut remove_entry = None; let mut remove_entry = None;
let mut browse_mesh = false;
let mut locate_mesh = None;
for index in 0..renderer.slots.len() { for index in 0..renderer.slots.len() {
ensure_slot_id(&mut renderer.slots[index], index); ensure_slot_id(&mut renderer.slots[index], index);
let part = &renderer.slots[index]; let part = &renderer.slots[index];
@ -222,18 +296,26 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
{ {
remove_entry = Some(index); remove_entry = Some(index);
} }
editor_ui::design_system::controls::icon_button( if editor_ui::design_system::controls::icon_button(
ui, ui,
icons::CROSSHAIR, icons::CROSSHAIR,
"Locate mesh", "Locate mesh",
true, true,
); )
editor_ui::design_system::controls::icon_button( .clicked()
{
locate_mesh = Some(entry.mesh.clone());
}
if editor_ui::design_system::controls::icon_button(
ui, ui,
icons::FOLDER_OPEN, icons::FOLDER_OPEN,
"Browse mesh", "Browse mesh",
true, true,
); )
.clicked()
{
browse_mesh = true;
}
}, },
); );
if !renderer_open { if !renderer_open {
@ -325,6 +407,12 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
} }
}); });
apply_component_card_response(world, entity, card_response); apply_component_card_response(world, entity, card_response);
if browse_mesh {
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
}
if let Some(mesh) = locate_mesh.as_ref() {
locate_asset_ref(world, Some(mesh), &mesh_candidates);
}
if accepted_drop { if accepted_drop {
clear_asset_drag(world); clear_asset_drag(world);
} }

View File

@ -175,6 +175,7 @@ pub(super) fn plan_material_instance_assignment(
slot_id: block.slot_id.clone(), slot_id: block.slot_id.clone(),
slot_name: slot_name.to_string(), slot_name: slot_name.to_string(),
base: base.clone(), base: base.clone(),
actor_assignment: actor_material_assignment_for_slot(world, entity, &block.slot_id),
block: block.clone(), block: block.clone(),
project_root, project_root,
destination_path, destination_path,
@ -243,25 +244,22 @@ pub(super) fn validate_property_block_promotion(
); );
} }
} }
let current_base = world let current_actor_assignment =
.get::<SkinnedMeshRenderer>(promotion.entity) actor_material_assignment_for_slot(world, promotion.entity, &promotion.slot_id);
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id)) if current_actor_assignment != promotion.actor_assignment {
.and_then(|slot| slot.material.as_ref()) return Err(
.or_else(|| { "the target renderer slot assignment changed; refresh the promotion review".to_string(),
world );
.get::<StaticMeshRenderer>(promotion.entity) }
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id)) let current_base =
.and_then(|slot| slot.material.as_ref()) effective_material_for_actor_slot(world, promotion.entity, &promotion.slot_id).ok_or_else(
}) || "the target renderer slot is missing or has no project base".to_string(),
.or_else(|| { )?;
world if current_base != promotion.base {
.get::<Primitive>(promotion.entity) return Err(
.filter(|primitive| primitive.surface.id == promotion.slot_id) "the target renderer slot's effective base changed; refresh the promotion review"
.and_then(|primitive| primitive.surface.material.as_ref()) .to_string(),
}) );
.ok_or_else(|| "the target renderer slot is missing or has no project base".to_string())?;
if current_base != &promotion.base {
return Err("the target renderer slot changed; refresh the promotion review".to_string());
} }
for source in &promotion.source_snapshots { for source in &promotion.source_snapshots {
if FileSnapshot::capture(&source.path)? != source.snapshot { if FileSnapshot::capture(&source.path)? != source.snapshot {

View File

@ -0,0 +1,564 @@
//! Content Browser presentation shared by production and the UI gallery.
//!
//! The widget owns deterministic Penpot geometry and returns intent. Project navigation,
//! transactions, selection, and authored-document state remain in the editor crate.
use egui;
use egui_phosphor_icons::icons;
use crate::design_system;
use crate::design_system::typography::TypeRole;
pub const TOOLBAR_HEIGHT: f32 = 124.0;
pub const STATUS_BAR_HEIGHT: f32 = 32.0;
pub const COMPACT_BREAKPOINT: f32 = 720.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentBrowserViewMode {
Grid,
List,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentBrowserKindFilter {
All,
Model,
Texture,
Material,
Audio,
Level,
Prefab,
Builtin,
}
impl ContentBrowserKindFilter {
pub const ALL: [Self; 8] = [
Self::All,
Self::Model,
Self::Texture,
Self::Material,
Self::Audio,
Self::Level,
Self::Prefab,
Self::Builtin,
];
pub const fn label(self) -> &'static str {
match self {
Self::All => "All assets",
Self::Model => "Models",
Self::Texture => "Textures",
Self::Material => "Materials",
Self::Audio => "Audio",
Self::Level => "Levels",
Self::Prefab => "Prefabs",
Self::Builtin => "Built-ins",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentBrowserSort {
Name,
Kind,
Modified,
Size,
}
impl ContentBrowserSort {
pub const ALL: [Self; 4] = [Self::Name, Self::Kind, Self::Modified, Self::Size];
pub const fn label(self) -> &'static str {
match self {
Self::Name => "Name",
Self::Kind => "Type",
Self::Modified => "Modified",
Self::Size => "Size",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentPathSegment {
pub path: String,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct ContentBrowserToolbarViewModel {
pub path: Vec<ContentPathSegment>,
pub search: String,
pub kind_filter: ContentBrowserKindFilter,
pub sort: ContentBrowserSort,
pub view: ContentBrowserViewMode,
pub recursive: bool,
pub show_details: bool,
pub thumbnail_size: f32,
pub can_go_parent: bool,
pub can_go_back: bool,
pub can_go_forward: bool,
pub can_edit_folder: bool,
pub can_rename: bool,
pub can_edit_selection: bool,
pub can_paste: bool,
pub can_undo: bool,
pub pending_repairs: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ContentBrowserToolbarAction {
Back,
Forward,
Parent,
Refresh,
Navigate(String),
ImportHere,
ImportTo,
NewFolder,
Rename,
Duplicate,
Undo,
Cut,
Copy,
Paste,
OpenTrash,
ReviewMoves,
SetSearch(String),
SetKindFilter(ContentBrowserKindFilter),
SetSort(ContentBrowserSort),
SetView(ContentBrowserViewMode),
SetRecursive(bool),
SetShowDetails(bool),
SetThumbnailSize(f32),
}
pub fn content_browser_toolbar(
ui: &mut egui::Ui,
model: &ContentBrowserToolbarViewModel,
) -> Vec<ContentBrowserToolbarAction> {
let palette = design_system::palette(ui);
let mut actions = Vec::new();
let width = ui.available_width().max(1.0);
let compact = width < COMPACT_BREAKPOINT;
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, TOOLBAR_HEIGHT), egui::Sense::hover());
ui.painter().rect_filled(rect, 0.0, palette.recessed);
ui.painter().line_segment(
[rect.left_bottom(), rect.right_bottom()],
egui::Stroke::new(1.0_f32, palette.border),
);
let title_bar = egui::Rect::from_min_max(
rect.min + egui::vec2(8.0, 2.0),
egui::pos2(rect.right() - 8.0, rect.top() + 31.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(title_bar), |ui| {
ui.horizontal_centered(|ui| {
ui.label(
egui::RichText::new(icons::FOLDER_OPEN.as_str())
.font(egui::FontId::new(
14.0,
egui::FontFamily::Name("phosphor-bold".into()),
))
.color(palette.text_secondary),
);
ui.label(TypeRole::AssetTitle.text("Content Browser"));
let project = egui::Button::new(TypeRole::Eyebrow.text("PROJECT"))
.fill(palette.accent_dark)
.stroke(egui::Stroke::new(1.0_f32, palette.accent))
.corner_radius(egui::CornerRadius::same(4));
ui.add(project).on_hover_text("Managed project content");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
design_system::controls::icon_button(
ui,
icons::DOTS_THREE_VERTICAL,
"Content Browser options",
true,
);
});
});
});
let primary = egui::Rect::from_min_max(
egui::pos2(rect.left() + 8.0, rect.top() + 38.0),
egui::pos2(rect.right() - 8.0, rect.top() + 76.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(primary), |ui| {
ui.horizontal_centered(|ui| {
if design_system::controls::icon_button(
ui,
icons::ARROW_LEFT,
"Back",
model.can_go_back,
)
.clicked()
{
actions.push(ContentBrowserToolbarAction::Back);
}
if design_system::controls::icon_button(
ui,
icons::ARROW_RIGHT,
"Forward",
model.can_go_forward,
)
.clicked()
{
actions.push(ContentBrowserToolbarAction::Forward);
}
if design_system::controls::icon_button(
ui,
icons::ARROW_UP,
"Parent folder",
model.can_go_parent,
)
.clicked()
{
actions.push(ContentBrowserToolbarAction::Parent);
}
if design_system::controls::icon_button(
ui,
icons::ARROWS_CLOCKWISE,
"Refresh content",
true,
)
.clicked()
{
actions.push(ContentBrowserToolbarAction::Refresh);
}
if ui
.add(
egui::Button::new(design_system::controls::icon_label(
icons::UPLOAD_SIMPLE,
"Import Here",
TypeRole::Control,
13.0,
palette.text_primary,
))
.fill(palette.accent_dark)
.stroke(egui::Stroke::new(1.0_f32, palette.accent))
.corner_radius(egui::CornerRadius::same(4)),
)
.clicked()
{
actions.push(ContentBrowserToolbarAction::ImportHere);
}
if !compact
&& ui
.add_enabled(
model.can_edit_folder,
egui::Button::new(design_system::controls::icon_label(
icons::FOLDER_PLUS,
"New Folder",
TypeRole::Control,
13.0,
palette.text_primary,
)),
)
.clicked()
{
actions.push(ContentBrowserToolbarAction::NewFolder);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
content_operations_menu(ui, model, &mut actions);
if model.pending_repairs > 0
&& ui
.button(format!("Resolve {} moves", model.pending_repairs))
.on_hover_text("Review ambiguous external move identities")
.clicked()
{
actions.push(ContentBrowserToolbarAction::ReviewMoves);
}
if !compact {
let mut details = model.show_details;
if ui
.add(egui::Button::new(design_system::controls::icon_label(
icons::INFO,
"Details",
TypeRole::Control,
13.0,
palette.text_primary,
)))
.clicked()
{
details = !details;
actions.push(ContentBrowserToolbarAction::SetShowDetails(details));
}
}
for (mode, icon, tooltip) in [
(ContentBrowserViewMode::List, icons::LIST, "List view"),
(ContentBrowserViewMode::Grid, icons::GRID_FOUR, "Grid view"),
] {
let response = design_system::controls::icon_button(ui, icon, tooltip, true);
if model.view == mode {
ui.painter().rect_stroke(
response.rect,
4.0,
egui::Stroke::new(1.0_f32, palette.accent),
egui::StrokeKind::Inside,
);
}
if response.clicked() {
actions.push(ContentBrowserToolbarAction::SetView(mode));
}
}
let mut filter = model.kind_filter;
egui::ComboBox::from_id_salt("content_browser_kind")
.width(if compact { 72.0 } else { 92.0 })
.selected_text(filter.label())
.show_ui(ui, |ui| {
for option in ContentBrowserKindFilter::ALL {
ui.selectable_value(&mut filter, option, option.label());
}
});
if filter != model.kind_filter {
actions.push(ContentBrowserToolbarAction::SetKindFilter(filter));
}
let mut search = model.search.clone();
if ui
.add_sized(
[if compact { 116.0 } else { 184.0 }, 24.0],
egui::TextEdit::singleline(&mut search).hint_text("Search project assets"),
)
.changed()
{
actions.push(ContentBrowserToolbarAction::SetSearch(search));
}
});
});
});
let secondary = egui::Rect::from_min_max(
egui::pos2(rect.left() + 8.0, rect.top() + 84.0),
egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(secondary), |ui| {
path_bar(ui, &model.path, &mut actions);
});
actions
}
fn path_bar(
ui: &mut egui::Ui,
path: &[ContentPathSegment],
actions: &mut Vec<ContentBrowserToolbarAction>,
) {
let palette = design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), 24.0),
egui::Sense::hover(),
);
ui.painter().rect(
rect,
4.0,
palette.recessed,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
ui.scope_builder(
egui::UiBuilder::new().max_rect(rect.shrink2(egui::vec2(8.0, 2.0))),
|ui| {
ui.horizontal_centered(|ui| {
for (index, segment) in path.iter().enumerate() {
if index > 0 {
ui.label(TypeRole::Small.text("/").color(palette.text_muted));
}
let response = ui
.add(egui::Button::new(TypeRole::Small.text(&segment.label)).frame(false));
if response.clicked() {
actions.push(ContentBrowserToolbarAction::Navigate(segment.path.clone()));
}
response.on_hover_text(&segment.path);
}
});
},
);
}
fn content_operations_menu(
ui: &mut egui::Ui,
model: &ContentBrowserToolbarViewModel,
actions: &mut Vec<ContentBrowserToolbarAction>,
) {
let response = ui.menu_button(
design_system::controls::icon_label(
icons::DOTS_THREE,
"Actions",
TypeRole::Control,
13.0,
design_system::palette(ui).text_primary,
),
|ui| {
operation(
ui,
"Import To…",
model.can_edit_folder,
ContentBrowserToolbarAction::ImportTo,
actions,
);
operation(
ui,
"New Folder",
model.can_edit_folder,
ContentBrowserToolbarAction::NewFolder,
actions,
);
ui.separator();
operation(
ui,
"Rename",
model.can_rename,
ContentBrowserToolbarAction::Rename,
actions,
);
operation(
ui,
"Duplicate",
model.can_edit_selection,
ContentBrowserToolbarAction::Duplicate,
actions,
);
operation(
ui,
"Cut",
model.can_edit_selection,
ContentBrowserToolbarAction::Cut,
actions,
);
operation(
ui,
"Copy",
model.can_edit_selection,
ContentBrowserToolbarAction::Copy,
actions,
);
operation(
ui,
"Paste",
model.can_paste,
ContentBrowserToolbarAction::Paste,
actions,
);
ui.separator();
operation(
ui,
"Undo Content Operation",
model.can_undo,
ContentBrowserToolbarAction::Undo,
actions,
);
operation(
ui,
"Open Trash",
true,
ContentBrowserToolbarAction::OpenTrash,
actions,
);
ui.separator();
let mut recursive = model.recursive;
if ui.checkbox(&mut recursive, "Include subfolders").changed() {
actions.push(ContentBrowserToolbarAction::SetRecursive(recursive));
}
let mut details = model.show_details;
if ui.checkbox(&mut details, "Show details pane").changed() {
actions.push(ContentBrowserToolbarAction::SetShowDetails(details));
}
ui.label(TypeRole::Caption.text("Thumbnail size"));
let mut thumbnail_size = model.thumbnail_size;
if ui
.add(egui::Slider::new(&mut thumbnail_size, 48.0..=112.0).show_value(false))
.changed()
{
actions.push(ContentBrowserToolbarAction::SetThumbnailSize(
thumbnail_size,
));
}
ui.label(TypeRole::Caption.text("Sort by"));
let mut sort = model.sort;
egui::ComboBox::from_id_salt("content_browser_sort")
.selected_text(sort.label())
.show_ui(ui, |ui| {
for option in ContentBrowserSort::ALL {
ui.selectable_value(&mut sort, option, option.label());
}
});
if sort != model.sort {
actions.push(ContentBrowserToolbarAction::SetSort(sort));
}
},
);
response.response.on_hover_text("Content operations");
}
fn operation(
ui: &mut egui::Ui,
label: &str,
enabled: bool,
action: ContentBrowserToolbarAction,
actions: &mut Vec<ContentBrowserToolbarAction>,
) {
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
actions.push(action);
ui.close();
}
}
#[derive(Debug, Clone)]
pub struct ContentBrowserStatusViewModel<'a> {
pub summary: &'a str,
pub location: &'a str,
pub status: &'a str,
}
pub fn content_browser_status_bar(ui: &mut egui::Ui, model: ContentBrowserStatusViewModel<'_>) {
let palette = design_system::palette(ui);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(1.0), STATUS_BAR_HEIGHT),
egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 0.0, palette.recessed);
ui.painter().line_segment(
[rect.left_top(), rect.right_top()],
egui::Stroke::new(1.0_f32, palette.border),
);
ui.painter().text(
rect.left_center() + egui::vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
model.summary,
TypeRole::Small.font(),
palette.text_secondary,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
model.location,
TypeRole::Caption.font(),
palette.text_muted,
);
ui.painter().text(
rect.right_center() - egui::vec2(10.0, 0.0),
egui::Align2::RIGHT_CENTER,
model.status,
TypeRole::Small.font(),
palette.text_muted,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penpot_content_browser_geometry_is_stable() {
assert_eq!(TOOLBAR_HEIGHT, 124.0);
assert_eq!(STATUS_BAR_HEIGHT, 32.0);
assert_eq!(COMPACT_BREAKPOINT, 720.0);
}
#[test]
fn filters_and_sorts_have_stable_labels() {
assert_eq!(ContentBrowserKindFilter::ALL.len(), 8);
assert_eq!(ContentBrowserKindFilter::All.label(), "All assets");
assert_eq!(ContentBrowserSort::ALL.len(), 4);
assert_eq!(ContentBrowserSort::Kind.label(), "Type");
}
}

View File

@ -4,6 +4,7 @@
//! not own a Bevy world, project state, authored-document publication, scene mutations, or content //! not own a Bevy world, project state, authored-document publication, scene mutations, or content
//! processing. //! processing.
pub mod content_browser;
pub mod design_system; pub mod design_system;
pub mod fonts; pub mod fonts;
pub mod inspector; pub mod inspector;