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", "collider-from-mesh",
"serialize", "serialize",
] } ] }
bevy = { version = "0.18", features = ["serialize"] } bevy = { version = "0.18", features = ["serialize", "jpeg"] }
bevy_core_pipeline = "0.18" bevy_core_pipeline = "0.18"
bevy_solari = "0.18" bevy_solari = "0.18"
bevy_ufbx = "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] 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] 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] 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 ## Notes / Future Work
@ -311,7 +311,7 @@ crates/
edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation
playback is not supported by the static mesh path yet. 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. - 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. - 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, - 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. 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 catalog;
mod import; mod import;
pub mod materials; pub mod materials;
pub mod operators;
pub mod prefab_overrides; pub mod prefab_overrides;
pub mod static_mesh; pub mod static_mesh;
pub mod thumbnails; 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::{ use crate::assets::{
spawn_asset_at, spawn_subasset_at, AssetSelection, AssetSubAssetKind, EditorAsset, AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT,
EditorAssetKind, EditorAssets, ASSETS_ROOT, BUILTINS_FOLDER, BUILTINS_FOLDER,
}; };
use crate::scene_io::{SceneIo, SceneIoRequest}; 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, find_asset_by_path, find_asset_mut_by_path, save_registry, update_import_settings,
ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode, 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::{ use crate::assets::static_mesh::{
load_static_mesh_manifest, material_id_from_label, part_id_from_label, load_static_mesh_manifest, material_id_from_label, part_id_from_label,
refresh_static_mesh_artifact, refresh_static_mesh_artifact,
@ -37,9 +40,7 @@ use crate::assets::{
asset_cache_key, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, asset_cache_key, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon,
prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailStudio, prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailStudio,
}; };
use crate::ui::helpers::{ use crate::ui::helpers::asset_label;
apply_material_asset_to_selection, apply_texture_to_selection, asset_label,
};
use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG}; use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG};
use crate::ui::widgets::{icon_button_small, tool_button}; 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 => { AssetSubAssetKind::Mesh => {
if ui.button("Place At Origin").clicked() { if ui.button("Place At Origin").clicked() {
if let Some(selection) = selection.as_ref() { if let Some(selection) = selection.as_ref() {
spawn_subasset_at(world, selection, Vec3::ZERO); place_subasset_operator(world, selection.clone(), Vec3::ZERO);
} }
} }
} }
AssetSubAssetKind::Texture => { AssetSubAssetKind::Texture => {
if ui.button("Apply Texture To Selection").clicked() { if ui.button("Apply Texture To Selection").clicked() {
if let Some(asset) = texture_asset_from_subasset_selection(world, &selection) { 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) if matches!(asset.kind, EditorAssetKind::Texture)
&& ui.button("Apply Texture To Selection").clicked() && 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!( if matches!(
asset.kind, asset.kind,
@ -756,7 +757,7 @@ fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &Se
| EditorAssetKind::Level | EditorAssetKind::Level
) && ui.button("Place At Origin").clicked() ) && ui.button("Place At Origin").clicked()
{ {
spawn_asset_at(world, &asset, Vec3::ZERO); place_asset_operator(world, asset.clone(), Vec3::ZERO);
} }
}); });
} else { } else {
@ -1796,7 +1797,7 @@ fn subasset_details_panel(
match embedded.kind { match embedded.kind {
AssetSubAssetKind::Mesh => { AssetSubAssetKind::Mesh => {
if ui.button("Place At Origin").clicked() { if ui.button("Place At Origin").clicked() {
spawn_subasset_at(world, selection, Vec3::ZERO); place_subasset_operator(world, selection.clone(), Vec3::ZERO);
} }
} }
AssetSubAssetKind::Texture => { AssetSubAssetKind::Texture => {
@ -1804,7 +1805,7 @@ fn subasset_details_panel(
if let Some(asset) = if let Some(asset) =
texture_asset_from_subasset_selection(world, &Some(selection.clone())) 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| { ui.horizontal_wrapped(|ui| {
if matches!(asset.kind, EditorAssetKind::Texture) && ui.button("Apply Texture").clicked() { 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() 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!( if matches!(
asset.kind, asset.kind,
@ -1847,7 +1848,7 @@ fn asset_action_buttons(
| EditorAssetKind::Level | EditorAssetKind::Level
) && ui.button("Place At Origin").clicked() ) && 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) if matches!(asset.kind, EditorAssetKind::Model)
&& has_embedded_assets(world, asset) && has_embedded_assets(world, asset)
@ -2808,7 +2809,7 @@ fn asset_context_menu(
| EditorAssetKind::Level | EditorAssetKind::Level
) && ui.button("Place at Origin").clicked() ) && ui.button("Place at Origin").clicked()
{ {
spawn_asset_at(world, asset, Vec3::ZERO); place_asset_operator(world, asset.clone(), Vec3::ZERO);
ui.close(); ui.close();
} }
if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) { 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) if matches!(asset.kind, EditorAssetKind::Texture)
&& ui.button("Apply as Base Color Texture").clicked() && 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(); ui.close();
} }
if matches!(asset.kind, EditorAssetKind::Material) if matches!(asset.kind, EditorAssetKind::Material)
&& ui.button("Apply Material To Selection").clicked() && 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(); ui.close();
} }
if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() { if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() {
@ -2880,7 +2881,7 @@ fn subasset_context_menu(
match embedded.kind { match embedded.kind {
AssetSubAssetKind::Mesh => { AssetSubAssetKind::Mesh => {
if ui.button("Place at Origin").clicked() { 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(); ui.close();
} }
} }
@ -2889,7 +2890,7 @@ fn subasset_context_menu(
if let Some(asset) = if let Some(asset) =
texture_asset_from_subasset_selection(world, &Some(embedded.selection.clone())) 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(); ui.close();
} }

View File

@ -9,23 +9,23 @@ use egui_phosphor_icons::{icons, Icon};
use shared::{ use shared::{
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, ColliderDesc, inspector_component_active, AuthoringLightKind, AuthoringRigidBody, ColliderDesc,
ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject,
LightDesc, MaterialDesc, MaterialOverride, MaterialParameter, MaterialParameterValue, LightDesc, MaterialDesc, MaterialParameter, MaterialParameterValue, MaterialShaderKind,
MaterialShaderKind, MaterialSlotOverride, ObjectiveMarker, PhysicsBody, PlayerSpawn, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive,
PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc, PrimitiveShape, ProjectSun, RigidBodyDesc, StaticMeshRenderer, StaticMeshRendererEntry,
StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, WeaponSpawn, TeamSpawn, TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX,
AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_COLLIDER_DESC, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC,
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER, COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY,
COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE,
COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC,
COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME,
COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN, COMPONENT_WEAPON_SPAWN,
}; };
use crate::history::{ use crate::history::{
material_eq, set_collider_with_history, set_inspector_order_with_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_light_with_history, set_material_with_history, set_physics_with_history,
set_physics_with_history, set_post_process_volume_with_history, set_primitive_with_history, set_post_process_volume_with_history, set_primitive_with_history, set_rigid_body_with_history,
set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot, set_static_mesh_renderer_with_history, EditorEntitySnapshot,
}; };
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::ui::theme::{ use crate::ui::theme::{
@ -46,8 +46,8 @@ use crate::assets::static_mesh::{
}; };
use crate::assets::thumbnails::ThumbnailStudio; use crate::assets::thumbnails::ThumbnailStudio;
use crate::assets::{ use crate::assets::{
asset_cache_key, AssetSelection, AssetThumbnailCache, EditorAsset, EditorAssetKind, asset_cache_key, AssetSelection, AssetSubAssetKind, AssetThumbnailCache, EditorAsset,
EditorAssets, EditorAssetKind, EditorAssets,
}; };
const COMPACT_INSPECTOR_WIDTH: f32 = 360.0; const COMPACT_INSPECTOR_WIDTH: f32 = 360.0;
@ -98,6 +98,15 @@ struct AssetSelectorResponse {
locate: bool, 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)] #[derive(Resource, Default, Debug, Clone)]
pub(crate) struct InspectorClipboard { pub(crate) struct InspectorClipboard {
component: Option<CopiedComponent>, component: Option<CopiedComponent>,
@ -1311,6 +1320,112 @@ fn static_mesh_asset_ref_candidates(
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( fn source_material_ref_for_part(
asset_id: &str, asset_id: &str,
part: &crate::assets::static_mesh::StaticMeshPart, 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 { let Some(mut renderer) = world.get::<StaticMeshRenderer>(entity).cloned() else {
return; return;
}; };
let mut material_override = world
.get::<MaterialOverride>(entity)
.cloned()
.unwrap_or_default();
let original = renderer.clone(); let original = renderer.clone();
let original_override = material_override.clone();
let mut changed = false; let mut changed = false;
let mut override_changed = false;
let mut remove_entry = None; let mut remove_entry = None;
let card = component_card_context( 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( let material_response = asset_selector_row(
ui, ui,
"Material", "Source material",
icons::PALETTE, icons::PALETTE,
entry.material.as_ref(), entry.material.as_ref(),
inherited_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") .checkbox(&mut entry.receive_shadows, "Receive shadows")
.changed(); .changed();
}); });
override_changed |= material_override_slot_ui(
ui,
entry,
inherited_material.as_ref(),
&mut material_override,
);
}; };
if ui.available_width() < 430.0 { 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); apply_component_card_response(world, entity, card_response);
if let Some(index) = remove_entry { 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); renderer.slots.remove(index);
changed = true; changed = true;
} }
if changed && renderer != original { if changed && renderer != original {
set_static_mesh_renderer_with_history(world, entity, renderer); 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) { 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 { fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool {
let mut changed = false; let mut changed = false;
property_row(ui, "Shader", |ui| { 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) .get::<MaterialDesc>(entity)
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
request_texture_asset_thumbnails(world);
let texture_candidates = texture_asset_candidates(world);
let original = material.clone(); let original = material.clone();
let mut changed = false; let mut changed = false;
let mut options = ComponentCardOptions::removable( 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();
}); });
changed |= option_string_ui(ui, "Base color texture", &mut material.base_color_texture); changed |= texture_asset_picker_ui(
changed |= option_string_ui(ui, "Emissive texture", &mut material.emissive_texture); world,
changed |= option_string_ui(ui, "Normal map", &mut material.normal_map_texture); ui,
changed |= option_string_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, ui,
"Metallic/roughness texture", "Metallic/roughness texture",
&mut material.metallic_roughness_texture, &mut material.metallic_roughness_texture,
&texture_candidates,
); );
if crate::assets::materials::material_asset_picker_ui( 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 { fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) -> bool {
let mut text = value.clone().unwrap_or_default(); let mut text = value.clone().unwrap_or_default();
let before = text.clone(); let before = text.clone();

View File

@ -6,6 +6,9 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_phosphor_icons::icons; use egui_phosphor_icons::icons;
use transform_gizmo_bevy::prelude::GizmoOptions; 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::assets::{AssetSelection, AssetSubAssetKind, EditorAssets};
use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace}; use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace};
use crate::render_target::ViewportRenderTarget; 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; return;
} }
@ -565,7 +568,7 @@ fn viewport_asset_drop_ui(
.unwrap_or_else(|| crate::assets::ASSETS_ROOT.to_string()), .unwrap_or_else(|| crate::assets::ASSETS_ROOT.to_string()),
kind: crate::assets::EditorAssetKind::Texture, kind: crate::assets::EditorAssetKind::Texture,
}; };
crate::ui::helpers::apply_texture_to_selection(world, &asset, selected); apply_texture_operator(world, asset, selected);
return; return;
} }
@ -578,15 +581,15 @@ fn viewport_asset_drop_ui(
}; };
match asset.kind { match asset.kind {
crate::assets::EditorAssetKind::Texture => { crate::assets::EditorAssetKind::Texture => {
crate::ui::helpers::apply_texture_to_selection(world, &asset, selected); apply_texture_operator(world, asset, selected);
return; return;
} }
crate::assets::EditorAssetKind::Material => { crate::assets::EditorAssetKind::Material => {
crate::ui::helpers::apply_material_asset_to_selection(world, &asset, selected); apply_material_operator(world, asset, selected);
return; 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>, parent_material: Option<&MaterialDesc>,
material_override: Option<&MaterialOverride>, material_override: Option<&MaterialOverride>,
) -> Handle<StandardMaterial> { ) -> 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| { if let Some(override_material) = material_override.and_then(|overrides| {
overrides overrides
.slots .slots
@ -326,10 +330,6 @@ fn material_for_entry(
return asset_server.load(labeled_asset_path(&source_path, label)); 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()) 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 ## Status
@ -35,17 +35,19 @@ Component footer rather than an ad hoc button near Transform.
`ColliderDesc` and `RigidBodyDesc`. Mesh collider generation creates `ColliderDesc` and `RigidBodyDesc`. Mesh collider generation creates
`ColliderDesc::StaticMesh` instead of renderer collider flags. `ColliderDesc::StaticMesh` instead of renderer collider flags.
- Materials are shader-schema aware. `MaterialDesc` stores a `ShaderRefDesc`, - Materials are shader-schema aware. `MaterialDesc` stores a `ShaderRefDesc`,
typed shader parameters, and texture bindings. Actor-level material edits are typed shader parameters, texture bindings, StandardMaterial fields, and
stored in `MaterialOverride`; shared material assets remain content-browser optional asset references. When an actor has an active `MaterialDesc`, static
assets. 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 ## Consequences
- New model placement creates `StaticMeshRenderer` plus optional - New model placement creates `StaticMeshRenderer` plus optional
`RigidBodyDesc`/`ColliderDesc`; legacy `PhysicsBody` remains loadable for old `RigidBodyDesc`/`ColliderDesc`; legacy `PhysicsBody` remains loadable for old
scenes. scenes.
- Actor inspectors show imported asset selectors for mesh/material slots instead - Actor inspectors show imported asset selectors for mesh/source-material slots
of source-path text fields. instead of source-path text fields.
- Disabling a component in the inspector keeps its authoring data but prevents - Disabling a component in the inspector keeps its authoring data but prevents
hydration/runtime systems from using it. Mesh, material, light, physics, and hydration/runtime systems from using it. Mesh, material, light, physics, and
post-process hydration strip stale runtime components when active state 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/`. - **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. - **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. - **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). - **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. - **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. - **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`. - **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. - **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. - **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. - **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. - **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`. - **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, 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 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 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. PIE stop restores player simulation state only; authored `LevelObject` edits made during PIE remain in the scene.