Complete M0 editor asset workflow
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 05:05:52 -04:00
parent d77cc6a40e
commit cc6acb188d
11 changed files with 548 additions and 165 deletions

View File

@ -26,7 +26,7 @@ avian3d = { version = "0.6", default-features = false, features = [
"collider-from-mesh",
"serialize",
] }
bevy = { version = "0.18", features = ["serialize"] }
bevy = { version = "0.18", features = ["serialize", "jpeg"] }
bevy_core_pipeline = "0.18"
bevy_solari = "0.18"
bevy_ufbx = "0.18"

View File

@ -294,7 +294,7 @@ crates/
- [x] Prefab instance overrides v2 (transform/material/child visibility apply-revert groups)
- [x] Advanced rendering: `GiMode`, post-process volumes, Rendering panel, requested/effective render stack, Solari integration, emissive materials, post FX assets ([ADR 0013](docs/adr/0013-rendering-tiers-and-post-process-volumes.md), [ADR 0016](docs/adr/0016-unified-rendering-contract.md), [rendering guide](docs/editor/rendering.md))
- [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md))
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware material data, and per-actor material overrides ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
## Notes / Future Work
@ -311,7 +311,7 @@ crates/
edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation
playback is not supported by the static mesh path yet.
- Prefab instances use stable asset IDs (`PrefabInstance` + registry) with serialized `PrefabOverrides` (transform, material, per-child visibility by name). Inspector **Apply/Revert** per field group; **Unpack** removes the instance link.
- Material assets are RON files under `assets/materials/`; shader schemas live under `assets/shaders/`. `MaterialDesc` stores shader kind, typed parameters, texture bindings, and StandardMaterial fields. The Asset Browser material details editor can apply shader schemas, edit typed parameters/textures, and regenerate sphere thumbnails. Actor inspector edits create per-actor `MaterialOverride` data for static mesh slots instead of mutating shared material assets.
- Material assets are RON files under `assets/materials/`; shader schemas live under `assets/shaders/`. `MaterialDesc` stores shader kind, typed parameters, texture bindings, and StandardMaterial fields. The Asset Browser material details editor can apply shader schemas, edit typed parameters/textures, and regenerate sphere thumbnails. Actor inspector material edits live on the actor `MaterialDesc`; static mesh source-material refs remain imported defaults and shared material assets are edited from the Asset Browser.
- Per-field reflect undo for all components remains future work; typed `shared` inspectors cover the common authoring path.
- The authoring/hydration layer is intentionally small so richer asset workflows (terrain,
material graphs, lighting profiles) can be added without changing the scene format foundation.

View File

@ -4,6 +4,7 @@ pub mod asset_db;
mod catalog;
mod import;
pub mod materials;
pub mod operators;
pub mod prefab_overrides;
pub mod static_mesh;
pub mod thumbnails;

View File

@ -0,0 +1,93 @@
//! Operator wrappers for asset placement and assignment workflows.
use bevy::prelude::*;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use crate::operators::{run_immediate_operator, OperatorAvailability};
use crate::ui::helpers::{
apply_material_asset_to_selection, apply_texture_to_selection, selected_level_entities,
};
use super::{spawn_asset_at, spawn_subasset_at, AssetSelection, EditorAsset};
fn selection_availability(world: &World, selected: &SelectedEntities) -> OperatorAvailability {
if selected_level_entities(world, selected).is_empty() {
OperatorAvailability::Disabled("Select at least one authored actor".to_string())
} else {
OperatorAvailability::Ready
}
}
pub fn place_asset_operator(world: &mut World, asset: EditorAsset, translation: Vec3) -> bool {
let label = format!("Place {}", asset.label);
run_immediate_operator(
world,
"assets.place",
&label,
OperatorAvailability::Ready,
move |world| {
spawn_asset_at(world, &asset, translation)
.map(|_| ())
.ok_or_else(|| format!("Asset cannot be placed: {}", asset.label))
},
)
}
pub fn place_subasset_operator(
world: &mut World,
selection: AssetSelection,
translation: Vec3,
) -> bool {
let label = format!("Place {}", selection.display_label());
run_immediate_operator(
world,
"assets.place_subasset",
&label,
OperatorAvailability::Ready,
move |world| {
spawn_subasset_at(world, &selection, translation)
.map(|_| ())
.ok_or_else(|| format!("Sub-asset cannot be placed: {}", selection.display_label()))
},
)
}
pub fn apply_texture_operator(
world: &mut World,
asset: EditorAsset,
selected: &SelectedEntities,
) -> bool {
let label = format!("Apply Texture {}", asset.label);
let availability = selection_availability(world, selected);
let selected = selected;
run_immediate_operator(
world,
"assets.apply_texture",
&label,
availability,
move |world| {
apply_texture_to_selection(world, &asset, &selected);
Ok(())
},
)
}
pub fn apply_material_operator(
world: &mut World,
asset: EditorAsset,
selected: &SelectedEntities,
) -> bool {
let label = format!("Apply Material {}", asset.label);
let availability = selection_availability(world, selected);
let selected = selected;
run_immediate_operator(
world,
"assets.apply_material",
&label,
availability,
move |world| {
apply_material_asset_to_selection(world, &asset, &selected);
Ok(())
},
)
}

View File

@ -16,8 +16,8 @@ use shared::{
};
use crate::assets::{
spawn_asset_at, spawn_subasset_at, AssetSelection, AssetSubAssetKind, EditorAsset,
EditorAssetKind, EditorAssets, ASSETS_ROOT, BUILTINS_FOLDER,
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT,
BUILTINS_FOLDER,
};
use crate::scene_io::{SceneIo, SceneIoRequest};
@ -29,6 +29,9 @@ use crate::asset_db::{
find_asset_by_path, find_asset_mut_by_path, save_registry, update_import_settings,
ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
};
use crate::assets::operators::{
apply_material_operator, apply_texture_operator, place_asset_operator, place_subasset_operator,
};
use crate::assets::static_mesh::{
load_static_mesh_manifest, material_id_from_label, part_id_from_label,
refresh_static_mesh_artifact,
@ -37,9 +40,7 @@ use crate::assets::{
asset_cache_key, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon,
prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailStudio,
};
use crate::ui::helpers::{
apply_material_asset_to_selection, apply_texture_to_selection, asset_label,
};
use crate::ui::helpers::asset_label;
use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG};
use crate::ui::widgets::{icon_button_small, tool_button};
@ -724,14 +725,14 @@ fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &Se
AssetSubAssetKind::Mesh => {
if ui.button("Place At Origin").clicked() {
if let Some(selection) = selection.as_ref() {
spawn_subasset_at(world, selection, Vec3::ZERO);
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_to_selection(world, &asset, selected_entities);
apply_texture_operator(world, asset, selected_entities);
}
}
}
@ -745,7 +746,7 @@ fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &Se
if matches!(asset.kind, EditorAssetKind::Texture)
&& ui.button("Apply Texture To Selection").clicked()
{
apply_texture_to_selection(world, &asset, selected_entities);
apply_texture_operator(world, asset.clone(), selected_entities);
}
if matches!(
asset.kind,
@ -756,7 +757,7 @@ fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &Se
| EditorAssetKind::Level
) && ui.button("Place At Origin").clicked()
{
spawn_asset_at(world, &asset, Vec3::ZERO);
place_asset_operator(world, asset.clone(), Vec3::ZERO);
}
});
} else {
@ -1796,7 +1797,7 @@ fn subasset_details_panel(
match embedded.kind {
AssetSubAssetKind::Mesh => {
if ui.button("Place At Origin").clicked() {
spawn_subasset_at(world, selection, Vec3::ZERO);
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
}
}
AssetSubAssetKind::Texture => {
@ -1804,7 +1805,7 @@ fn subasset_details_panel(
if let Some(asset) =
texture_asset_from_subasset_selection(world, &Some(selection.clone()))
{
apply_texture_to_selection(world, &asset, selected_entities);
apply_texture_operator(world, asset, selected_entities);
}
}
}
@ -1832,11 +1833,11 @@ fn asset_action_buttons(
) {
ui.horizontal_wrapped(|ui| {
if matches!(asset.kind, EditorAssetKind::Texture) && ui.button("Apply Texture").clicked() {
apply_texture_to_selection(world, asset, selected_entities);
apply_texture_operator(world, asset.clone(), selected_entities);
}
if matches!(asset.kind, EditorAssetKind::Material) && ui.button("Apply Material").clicked()
{
apply_material_asset_to_selection(world, asset, selected_entities);
apply_material_operator(world, asset.clone(), selected_entities);
}
if matches!(
asset.kind,
@ -1847,7 +1848,7 @@ fn asset_action_buttons(
| EditorAssetKind::Level
) && ui.button("Place At Origin").clicked()
{
spawn_asset_at(world, asset, Vec3::ZERO);
place_asset_operator(world, asset.clone(), Vec3::ZERO);
}
if matches!(asset.kind, EditorAssetKind::Model)
&& has_embedded_assets(world, asset)
@ -2808,7 +2809,7 @@ fn asset_context_menu(
| EditorAssetKind::Level
) && ui.button("Place at Origin").clicked()
{
spawn_asset_at(world, asset, Vec3::ZERO);
place_asset_operator(world, asset.clone(), Vec3::ZERO);
ui.close();
}
if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) {
@ -2835,13 +2836,13 @@ fn asset_context_menu(
if matches!(asset.kind, EditorAssetKind::Texture)
&& ui.button("Apply as Base Color Texture").clicked()
{
apply_texture_to_selection(world, asset, selected_entities);
apply_texture_operator(world, asset.clone(), selected_entities);
ui.close();
}
if matches!(asset.kind, EditorAssetKind::Material)
&& ui.button("Apply Material To Selection").clicked()
{
apply_material_asset_to_selection(world, asset, selected_entities);
apply_material_operator(world, asset.clone(), selected_entities);
ui.close();
}
if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() {
@ -2880,7 +2881,7 @@ fn subasset_context_menu(
match embedded.kind {
AssetSubAssetKind::Mesh => {
if ui.button("Place at Origin").clicked() {
spawn_subasset_at(world, &embedded.selection, Vec3::ZERO);
place_subasset_operator(world, embedded.selection.clone(), Vec3::ZERO);
ui.close();
}
}
@ -2889,7 +2890,7 @@ fn subasset_context_menu(
if let Some(asset) =
texture_asset_from_subasset_selection(world, &Some(embedded.selection.clone()))
{
apply_texture_to_selection(world, &asset, selected_entities);
apply_texture_operator(world, asset, selected_entities);
}
ui.close();
}

View File

@ -9,23 +9,23 @@ use egui_phosphor_icons::{icons, Icon};
use shared::{
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, ColliderDesc,
ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject,
LightDesc, MaterialDesc, MaterialOverride, MaterialParameter, MaterialParameterValue,
MaterialShaderKind, MaterialSlotOverride, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, WeaponSpawn,
AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_COLLIDER_DESC,
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER,
COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME,
COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN,
COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN,
COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
LightDesc, MaterialDesc, MaterialParameter, MaterialParameterValue, MaterialShaderKind,
ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive,
PrimitiveShape, ProjectSun, RigidBodyDesc, StaticMeshRenderer, StaticMeshRendererEntry,
TeamSpawn, TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX,
AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC,
COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY,
COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE,
COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC,
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME,
COMPONENT_WEAPON_SPAWN,
};
use crate::history::{
material_eq, set_collider_with_history, set_inspector_order_with_history,
set_light_with_history, set_material_override_with_history, set_material_with_history,
set_physics_with_history, set_post_process_volume_with_history, set_primitive_with_history,
set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot,
set_light_with_history, set_material_with_history, set_physics_with_history,
set_post_process_volume_with_history, set_primitive_with_history, set_rigid_body_with_history,
set_static_mesh_renderer_with_history, EditorEntitySnapshot,
};
use crate::selection::SelectedEntity;
use crate::ui::theme::{
@ -46,8 +46,8 @@ use crate::assets::static_mesh::{
};
use crate::assets::thumbnails::ThumbnailStudio;
use crate::assets::{
asset_cache_key, AssetSelection, AssetThumbnailCache, EditorAsset, EditorAssetKind,
EditorAssets,
asset_cache_key, AssetSelection, AssetSubAssetKind, AssetThumbnailCache, EditorAsset,
EditorAssetKind, EditorAssets,
};
const COMPACT_INSPECTOR_WIDTH: f32 = 360.0;
@ -98,6 +98,15 @@ struct AssetSelectorResponse {
locate: bool,
}
#[derive(Debug, Clone)]
struct TextureAssetCandidate {
label: String,
path: String,
folder_path: String,
selection: AssetSelection,
texture_id: Option<egui::TextureId>,
}
#[derive(Resource, Default, Debug, Clone)]
pub(crate) struct InspectorClipboard {
component: Option<CopiedComponent>,
@ -1311,6 +1320,112 @@ fn static_mesh_asset_ref_candidates(
candidates
}
fn texture_asset_candidates(world: &World) -> Vec<TextureAssetCandidate> {
let Some(catalog) = world.get_resource::<EditorAssets>() else {
return Vec::new();
};
let snapshot = world
.get_resource::<AssetThumbnailCache>()
.map(AssetThumbnailCache::snapshot);
let mut candidates: Vec<_> = catalog
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Texture))
.filter_map(|asset| {
let path = asset.path.clone()?;
let texture_id = snapshot
.as_ref()
.and_then(|snapshot| snapshot.texture_for(asset));
Some(TextureAssetCandidate {
label: asset.label.clone(),
path: path.clone(),
folder_path: asset.folder_path.clone(),
selection: AssetSelection::File(path),
texture_id,
})
})
.collect();
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path)));
candidates
}
fn request_texture_asset_thumbnails(world: &mut World) {
let requests: Vec<_> = world
.get_resource::<EditorAssets>()
.map(|assets| {
assets
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Texture))
.filter_map(|asset| Some((asset_cache_key(asset), asset.path.clone()?)))
.collect()
})
.unwrap_or_default();
if requests.is_empty() || world.get_resource::<AssetThumbnailCache>().is_none() {
return;
}
let asset_server = world.resource::<AssetServer>().clone();
world.resource_scope(|_world, mut cache: Mut<AssetThumbnailCache>| {
for (key, path) in requests {
cache.request_texture(key, path, &asset_server);
}
});
}
fn texture_path_from_selection(
world: &World,
selection: &AssetSelection,
) -> Option<TextureAssetCandidate> {
match selection {
AssetSelection::File(path) => {
let asset = world
.get_resource::<EditorAssets>()?
.assets
.iter()
.find(|asset| {
matches!(asset.kind, EditorAssetKind::Texture)
&& asset.path.as_deref() == Some(path.as_str())
})?;
Some(TextureAssetCandidate {
label: asset.label.clone(),
path: path.clone(),
folder_path: asset.folder_path.clone(),
selection: selection.clone(),
texture_id: None,
})
}
AssetSelection::SubAsset {
label,
kind: AssetSubAssetKind::Texture,
source_path: Some(path),
..
} => {
if let Some(asset) = world.get_resource::<EditorAssets>().and_then(|assets| {
assets
.assets
.iter()
.find(|asset| asset.path.as_deref() == Some(path.as_str()))
}) {
return Some(TextureAssetCandidate {
label: asset.label.clone(),
path: path.clone(),
folder_path: asset.folder_path.clone(),
selection: selection.clone(),
texture_id: None,
});
}
Some(TextureAssetCandidate {
label: label.clone(),
path: path.clone(),
folder_path: fallback_asset_folder(path),
selection: selection.clone(),
texture_id: None,
})
}
_ => None,
}
}
fn source_material_ref_for_part(
asset_id: &str,
part: &crate::assets::static_mesh::StaticMeshPart,
@ -1424,14 +1539,8 @@ fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
let Some(mut renderer) = world.get::<StaticMeshRenderer>(entity).cloned() else {
return;
};
let mut material_override = world
.get::<MaterialOverride>(entity)
.cloned()
.unwrap_or_default();
let original = renderer.clone();
let original_override = material_override.clone();
let mut changed = false;
let mut override_changed = false;
let mut remove_entry = None;
let card = component_card_context(
@ -1495,7 +1604,7 @@ fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
let material_response = asset_selector_row(
ui,
"Material",
"Source material",
icons::PALETTE,
entry.material.as_ref(),
inherited_material.as_ref(),
@ -1522,12 +1631,6 @@ fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
.checkbox(&mut entry.receive_shadows, "Receive shadows")
.changed();
});
override_changed |= material_override_slot_ui(
ui,
entry,
inherited_material.as_ref(),
&mut material_override,
);
};
if ui.available_width() < 430.0 {
@ -1564,21 +1667,12 @@ fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
apply_component_card_response(world, entity, card_response);
if let Some(index) = remove_entry {
if let Some(entry) = renderer.slots.get(index) {
material_override
.slots
.retain(|slot| slot.slot_id != entry.id);
override_changed = true;
}
renderer.slots.remove(index);
changed = true;
}
if changed && renderer != original {
set_static_mesh_renderer_with_history(world, entity, renderer);
}
if override_changed && material_override != original_override {
set_material_override_with_history(world, entity, material_override);
}
}
fn ensure_slot_id(entry: &mut StaticMeshRendererEntry, index: usize) {
@ -1860,90 +1954,6 @@ fn asset_selector_control(
);
}
fn material_override_slot_ui(
ui: &mut egui::Ui,
entry: &StaticMeshRendererEntry,
inherited_material: Option<&EditorAssetRef>,
overrides: &mut MaterialOverride,
) -> bool {
let mut changed = false;
let index = overrides
.slots
.iter()
.position(|slot| slot.slot_id == entry.id);
let mut has_override = index.is_some();
if ui
.checkbox(&mut has_override, "Override material on this actor")
.changed()
{
if has_override {
overrides.slots.push(MaterialSlotOverride {
slot_id: entry.id.clone(),
base_material: entry
.material
.clone()
.or_else(|| inherited_material.cloned()),
material: MaterialDesc::default(),
});
} else if let Some(index) = index {
overrides.slots.remove(index);
}
changed = true;
}
if has_override {
if let Some(slot) = overrides
.slots
.iter_mut()
.find(|slot| slot.slot_id == entry.id)
{
let effective_base = entry
.material
.clone()
.or_else(|| inherited_material.cloned());
if slot.base_material != effective_base {
slot.base_material = effective_base;
changed = true;
}
changed |= material_inline_ui(ui, &mut slot.material);
}
}
changed
}
fn material_inline_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool {
let mut changed = false;
changed |= material_shader_ui(ui, material);
let mut color = [
material.base_color.r,
material.base_color.g,
material.base_color.b,
material.base_color.a,
];
property_row(ui, "Override color", |ui| {
if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() {
material.base_color = ColorDesc {
r: color[0],
g: color[1],
b: color[2],
a: color[3],
};
changed = true;
}
});
property_row(ui, "Metallic", |ui| {
changed |= ui
.add(egui::Slider::new(&mut material.metallic, 0.0..=1.0))
.changed();
});
property_row(ui, "Roughness", |ui| {
changed |= ui
.add(egui::Slider::new(&mut material.roughness, 0.0..=1.0))
.changed();
});
changed
}
fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool {
let mut changed = false;
property_row(ui, "Shader", |ui| {
@ -2243,6 +2253,8 @@ pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
.get::<MaterialDesc>(entity)
.cloned()
.unwrap_or_default();
request_texture_asset_thumbnails(world);
let texture_candidates = texture_asset_candidates(world);
let original = material.clone();
let mut changed = false;
let mut options = ComponentCardOptions::removable(
@ -2312,13 +2324,33 @@ pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
.changed();
});
changed |= option_string_ui(ui, "Base color texture", &mut material.base_color_texture);
changed |= option_string_ui(ui, "Emissive texture", &mut material.emissive_texture);
changed |= option_string_ui(ui, "Normal map", &mut material.normal_map_texture);
changed |= option_string_ui(
changed |= texture_asset_picker_ui(
world,
ui,
"Base color texture",
&mut material.base_color_texture,
&texture_candidates,
);
changed |= texture_asset_picker_ui(
world,
ui,
"Emissive texture",
&mut material.emissive_texture,
&texture_candidates,
);
changed |= texture_asset_picker_ui(
world,
ui,
"Normal map",
&mut material.normal_map_texture,
&texture_candidates,
);
changed |= texture_asset_picker_ui(
world,
ui,
"Metallic/roughness texture",
&mut material.metallic_roughness_texture,
&texture_candidates,
);
if crate::assets::materials::material_asset_picker_ui(
@ -2895,6 +2927,255 @@ fn has_scene_sun_override(world: &mut World) -> bool {
})
}
fn texture_asset_picker_ui(
world: &mut World,
ui: &mut egui::Ui,
label: &str,
value: &mut Option<String>,
candidates: &[TextureAssetCandidate],
) -> bool {
let current_path = value.clone();
let dragging_selection = world
.get_resource::<EditorAssets>()
.and_then(|assets| assets.dragging_selection().cloned());
let drop_candidate = dragging_selection
.as_ref()
.and_then(|selection| texture_path_from_selection(world, selection));
let mut selected_path: Option<String> = None;
let mut clear = false;
let mut locate = false;
let mut accepted_drop = false;
let current_candidate = current_path.as_ref().and_then(|path| {
candidates
.iter()
.find(|candidate| candidate.path == *path)
.cloned()
});
property_row(ui, label, |ui| {
let control_width = ui
.available_width()
.max(MIN_INLINE_CONTROL_WIDTH.min(ui.available_width().max(1.0)));
let row_height = 30.0;
let (rect, _response) =
ui.allocate_exact_size(egui::vec2(control_width, row_height), egui::Sense::hover());
let valid_drag = drop_candidate.is_some();
let row_hovered = ui.rect_contains_pointer(rect);
let drop_hovered = valid_drag && row_hovered;
let stroke = if drop_hovered {
egui::Stroke::new(2.0, egui::Color32::from_rgb(125, 198, 255))
} else if valid_drag {
egui::Stroke::new(1.0, egui::Color32::from_rgb(58, 88, 122))
} else if row_hovered {
egui::Stroke::new(1.0, egui::Color32::from_rgb(92, 102, 118))
} else {
egui::Stroke::new(1.0, BORDER)
};
let fill = if drop_hovered {
egui::Color32::from_rgb(29, 57, 86)
} else if valid_drag {
WIDGET_BG.linear_multiply(0.88)
} else if row_hovered {
WIDGET_BG.linear_multiply(1.05)
} else {
WIDGET_BG.linear_multiply(0.75)
};
ui.painter()
.rect(rect, 4.0, fill, stroke, egui::StrokeKind::Inside);
if drop_hovered {
let badge_rect = egui::Rect::from_min_size(
rect.right_top() + egui::vec2(-82.0, 4.0),
egui::vec2(74.0, 16.0),
);
ui.painter().rect(
badge_rect,
3.0,
egui::Color32::from_rgb(35, 95, 155),
egui::Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
badge_rect.center(),
egui::Align2::CENTER_CENTER,
"Drop texture",
egui::FontId::new(10.0, egui::FontFamily::Proportional),
egui::Color32::from_rgb(225, 241, 255),
);
}
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect.shrink2(egui::vec2(6.0, 4.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
child.set_clip_rect(rect);
let preview_size = 22.0;
let (preview_rect, _preview_response) =
child.allocate_exact_size(egui::vec2(preview_size, preview_size), egui::Sense::hover());
child.painter().rect(
preview_rect,
3.0,
PANEL_BG_DARK,
egui::Stroke::new(1.0, BORDER),
egui::StrokeKind::Inside,
);
if let Some(texture_id) = current_candidate
.as_ref()
.and_then(|candidate| candidate.texture_id)
{
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
child.painter().image(
texture_id,
preview_rect.shrink(1.0),
uv,
egui::Color32::WHITE,
);
} else {
child.painter().text(
preview_rect.center(),
egui::Align2::CENTER_CENTER,
icons::IMAGE.as_str(),
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())),
TEXT_DIM,
);
}
let action_width = 92.0;
let text_width = (child.available_width() - action_width).max(1.0);
child.allocate_ui_with_layout(
egui::vec2(text_width, 22.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let display_label = current_candidate
.as_ref()
.map(|candidate| candidate.label.as_str())
.or(current_path.as_deref())
.unwrap_or("(none)");
ui.add_sized(
[text_width, 20.0],
egui::Label::new(egui::RichText::new(display_label).color(
if current_path.is_some() {
TEXT_DIM.linear_multiply(1.45)
} else {
TEXT_DIM
},
))
.truncate(),
);
},
);
child.allocate_ui_with_layout(
egui::vec2(action_width, 28.0),
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let clear_response = ui.add_enabled(
current_path.is_some(),
egui::Button::new(phosphor_icon(icons::X, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
);
if clear_response.on_hover_text("Clear texture").clicked() {
clear = true;
}
let locate_response = ui.add_enabled(
current_path.is_some(),
egui::Button::new(phosphor_icon(icons::CROSSHAIR, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
);
if locate_response
.on_hover_text("Locate in content browser")
.clicked()
{
locate = true;
}
if candidates.is_empty() {
ui.add_enabled(
false,
egui::Button::new(phosphor_icon(icons::FOLDER_OPEN, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
)
.on_hover_text("No texture assets available");
} else {
let menu = ui.menu_button(phosphor_icon(icons::FOLDER_OPEN, 16.0), |ui| {
ui.set_min_width(240.0);
for candidate in candidates {
let selected = current_path.as_deref() == Some(candidate.path.as_str());
if ui
.selectable_label(selected, candidate.label.as_str())
.on_hover_text(candidate.path.as_str())
.clicked()
{
selected_path = Some(candidate.path.clone());
ui.close();
}
}
});
menu.response.on_hover_text("Browse textures");
}
},
);
if drop_hovered && ui.input(|input| input.pointer.any_released()) {
if let Some(candidate) = drop_candidate.as_ref() {
selected_path = Some(candidate.path.clone());
accepted_drop = true;
}
}
});
if locate {
locate_texture_asset(world, current_path.as_deref(), candidates);
}
if accepted_drop {
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.clear_drag();
}
}
if clear {
*value = None;
return current_path.is_some();
}
if let Some(path) = selected_path {
let changed = current_path.as_deref() != Some(path.as_str());
*value = Some(path);
return changed;
}
false
}
fn locate_texture_asset(
world: &mut World,
path: Option<&str>,
candidates: &[TextureAssetCandidate],
) {
let Some(path) = path else {
return;
};
let Some(candidate) = candidates.iter().find(|candidate| candidate.path == path) else {
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.status = format!("Could not locate texture {path}");
}
return;
};
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.current_folder = candidate.folder_path.clone();
assets.select(candidate.selection.clone());
}
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
let panel_nodes = ui_state.panel_nodes;
open_and_focus_tab(
&mut ui_state.dock_state,
EditorTab::AssetBrowser,
&panel_nodes,
);
}
}
fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) -> bool {
let mut text = value.clone().unwrap_or_default();
let before = text.clone();

View File

@ -6,6 +6,9 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_phosphor_icons::icons;
use transform_gizmo_bevy::prelude::GizmoOptions;
use crate::assets::operators::{
apply_material_operator, apply_texture_operator, place_asset_operator, place_subasset_operator,
};
use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets};
use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace};
use crate::render_target::ViewportRenderTarget;
@ -545,7 +548,7 @@ fn viewport_asset_drop_ui(
..
}
) {
crate::assets::spawn_subasset_at(world, &selection, placement);
place_subasset_operator(world, selection, placement);
return;
}
@ -565,7 +568,7 @@ fn viewport_asset_drop_ui(
.unwrap_or_else(|| crate::assets::ASSETS_ROOT.to_string()),
kind: crate::assets::EditorAssetKind::Texture,
};
crate::ui::helpers::apply_texture_to_selection(world, &asset, selected);
apply_texture_operator(world, asset, selected);
return;
}
@ -578,15 +581,15 @@ fn viewport_asset_drop_ui(
};
match asset.kind {
crate::assets::EditorAssetKind::Texture => {
crate::ui::helpers::apply_texture_to_selection(world, &asset, selected);
apply_texture_operator(world, asset, selected);
return;
}
crate::assets::EditorAssetKind::Material => {
crate::ui::helpers::apply_material_asset_to_selection(world, &asset, selected);
apply_material_operator(world, asset, selected);
return;
}
_ => {}
}
crate::assets::spawn_asset_at(world, &asset, placement);
place_asset_operator(world, asset, placement);
}

View File

@ -297,6 +297,10 @@ fn material_for_entry(
parent_material: Option<&MaterialDesc>,
material_override: Option<&MaterialOverride>,
) -> Handle<StandardMaterial> {
if let Some(parent_material) = parent_material {
return materials.add(material_from_desc(asset_server, parent_material));
}
if let Some(override_material) = material_override.and_then(|overrides| {
overrides
.slots
@ -326,10 +330,6 @@ fn material_for_entry(
return asset_server.load(labeled_asset_path(&source_path, label));
}
if let Some(parent_material) = parent_material {
return materials.add(material_from_desc(asset_server, parent_material));
}
materials.add(StandardMaterial::default())
}

View File

@ -1,4 +1,4 @@
# ADR 0018: Componentized actor inspector and material overrides
# ADR 0018: Componentized actor inspector and materials
## Status
@ -35,17 +35,19 @@ Component footer rather than an ad hoc button near Transform.
`ColliderDesc` and `RigidBodyDesc`. Mesh collider generation creates
`ColliderDesc::StaticMesh` instead of renderer collider flags.
- Materials are shader-schema aware. `MaterialDesc` stores a `ShaderRefDesc`,
typed shader parameters, and texture bindings. Actor-level material edits are
stored in `MaterialOverride`; shared material assets remain content-browser
assets.
typed shader parameters, texture bindings, StandardMaterial fields, and
optional asset references. When an actor has an active `MaterialDesc`, static
mesh hydration uses it before imported slot/source materials. Slot material
references remain source/default selectors, not a separate actor material
editing surface. Shared material assets remain content-browser assets.
## Consequences
- New model placement creates `StaticMeshRenderer` plus optional
`RigidBodyDesc`/`ColliderDesc`; legacy `PhysicsBody` remains loadable for old
scenes.
- Actor inspectors show imported asset selectors for mesh/material slots instead
of source-path text fields.
- Actor inspectors show imported asset selectors for mesh/source-material slots
instead of source-path text fields.
- Disabling a component in the inspector keeps its authoring data but prevents
hydration/runtime systems from using it. Mesh, material, light, physics, and
post-process hydration strip stale runtime components when active state

View File

@ -42,14 +42,14 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`.
- **Static mesh renderer** — model drag/drop defaults to `ActorKind::StaticMesh + StaticMeshRenderer` using normalized artifacts under `assets/meshes/generated/`. Renderer slots store imported `EditorAssetRef` mesh/material references, not source FBX/glTF paths. `SceneInstance` placement keeps the legacy `ImportedModel + ModelRef` path for full scene playback.
- **Collider split** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references.
- **Material assets**`MaterialAsset` RON (`label` + `MaterialDesc`) is shader-schema aware. Actor material edits write `MaterialOverride` entries keyed by renderer slot ID; content-browser asset inspectors are the path for editing shared material assets.
- **Material assets**`MaterialAsset` RON (`label` + `MaterialDesc`) is shader-schema aware. Actor material edits live on the actor `MaterialDesc`; static mesh slot material refs are source/default selectors, and content-browser asset inspectors are the path for editing shared material assets.
- **Prefab overrides**`PrefabInstance.overrides_ron` stores transform/material/child-visibility overrides; inspector collapsible groups with **Apply** / **Revert** (replaces transform-only revert v1).
- **Theme selections** use explicit high-contrast selected text and muted blue fills so hierarchy, asset browser, and inspector selection rows stay readable.
- **Hierarchy** shows authored objects plus useful runtime context (`Player`, `PlayerCamera`, Project Sun) while keeping runtime rows read-only.
- **Player placement** is authored through `PlayerSpawn`; selecting the runtime player in Edit mode redirects to a saved `Player Start`.
- **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live.
- **Rendering**`GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD.
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/material selectors, visibility, shadow flags, and per-actor material overrides. Empty actor material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/source-material selectors, visibility, and shadow flags. Actor material authoring lives in the `Authoring Material` component, whose texture refs use Asset Browser picker/drop controls. Empty source material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands.
- **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active.
- **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`.

View File

@ -164,7 +164,9 @@ Structural edits (spawn, delete, transform, rename) go through `EditorHistory` c
User-facing actions route through the operator lifecycle in `operators.rs`: begin, preview,
commit, cancel, disabled reason, and status text. The first bridge wraps command-palette and queued
editor commands as immediate operators, while existing `EditorHistory` commands remain the undo
payload. Modal tools and asset drops should use the same operator status path as they migrate.
payload. Asset placement, mesh-subasset placement, texture apply, and material apply actions now
use immediate operators from `assets::operators`; modal tools should use the same status path as
they migrate.
PIE stop restores player simulation state only; authored `LevelObject` edits made during PIE remain in the scene.