diff --git a/Cargo.toml b/Cargo.toml index 6f0f892..bf03ba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 3f4aa9f..8b7fa53 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/editor/src/assets/mod.rs b/crates/editor/src/assets/mod.rs index 965e241..c1d3d39 100644 --- a/crates/editor/src/assets/mod.rs +++ b/crates/editor/src/assets/mod.rs @@ -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; diff --git a/crates/editor/src/assets/operators.rs b/crates/editor/src/assets/operators.rs new file mode 100644 index 0000000..7581600 --- /dev/null +++ b/crates/editor/src/assets/operators.rs @@ -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(()) + }, + ) +} diff --git a/crates/editor/src/ui/asset_browser/panel.rs b/crates/editor/src/ui/asset_browser/panel.rs index 4be2d5e..3683477 100644 --- a/crates/editor/src/ui/asset_browser/panel.rs +++ b/crates/editor/src/ui/asset_browser/panel.rs @@ -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(); } diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index 3ab210e..a050188 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -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, +} + #[derive(Resource, Default, Debug, Clone)] pub(crate) struct InspectorClipboard { component: Option, @@ -1311,6 +1320,112 @@ fn static_mesh_asset_ref_candidates( candidates } +fn texture_asset_candidates(world: &World) -> Vec { + let Some(catalog) = world.get_resource::() else { + return Vec::new(); + }; + let snapshot = world + .get_resource::() + .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::() + .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::().is_none() { + return; + } + let asset_server = world.resource::().clone(); + world.resource_scope(|_world, mut cache: Mut| { + for (key, path) in requests { + cache.request_texture(key, path, &asset_server); + } + }); +} + +fn texture_path_from_selection( + world: &World, + selection: &AssetSelection, +) -> Option { + match selection { + AssetSelection::File(path) => { + let asset = world + .get_resource::()? + .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::().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::(entity).cloned() else { return; }; - let mut material_override = world - .get::(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::(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, + candidates: &[TextureAssetCandidate], +) -> bool { + let current_path = value.clone(); + let dragging_selection = world + .get_resource::() + .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 = 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::() { + 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::() { + scene_io.status = format!("Could not locate texture {path}"); + } + return; + }; + + if let Some(mut assets) = world.get_resource_mut::() { + assets.current_folder = candidate.folder_path.clone(); + assets.select(candidate.selection.clone()); + } + if let Some(mut ui_state) = world.get_resource_mut::() { + 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) -> bool { let mut text = value.clone().unwrap_or_default(); let before = text.clone(); diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index 3899143..ecdc3cd 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -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); } diff --git a/crates/shared/src/hydration/static_meshes.rs b/crates/shared/src/hydration/static_meshes.rs index b6ecdda..ab36829 100644 --- a/crates/shared/src/hydration/static_meshes.rs +++ b/crates/shared/src/hydration/static_meshes.rs @@ -297,6 +297,10 @@ fn material_for_entry( parent_material: Option<&MaterialDesc>, material_override: Option<&MaterialOverride>, ) -> Handle { + 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()) } diff --git a/docs/adr/0018-componentized-actor-inspector-and-materials.md b/docs/adr/0018-componentized-actor-inspector-and-materials.md index 86ff09a..6cb3001 100644 --- a/docs/adr/0018-componentized-actor-inspector-and-materials.md +++ b/docs/adr/0018-componentized-actor-inspector-and-materials.md @@ -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 diff --git a/docs/editor/README.md b/docs/editor/README.md index 4790173..d63aacd 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -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`. diff --git a/docs/editor/architecture.md b/docs/editor/architecture.md index 8ac4b26..7ff5cce 100644 --- a/docs/editor/architecture.md +++ b/docs/editor/architecture.md @@ -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.