From 02247f029a460347936ed7876d7d7b9d8d8fba2b Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sun, 12 Jul 2026 13:14:47 -0400 Subject: [PATCH] Add material library and targeted surface drops --- ...brary_and_targeted_drop_2026-07-12.plan.md | 54 +- README.md | 6 +- crates/editor/src/assets/materials.rs | 7 +- crates/editor/src/assets/mod.rs | 4 +- crates/editor/src/assets/thumbnails/cache.rs | 45 +- crates/editor/src/assets/thumbnails/mod.rs | 5 +- crates/editor/src/lib.rs | 3 + crates/editor/src/ui/asset_browser/panel.rs | 8 +- crates/editor/src/ui/dock_tabs.rs | 16 +- crates/editor/src/ui/layout.rs | 7 +- crates/editor/src/ui/material_library.rs | 1080 ++++++++++++++ crates/editor/src/ui/mod.rs | 20 + crates/editor/src/ui/viewport_chrome.rs | 160 +- crates/editor/src/viewport/brush_edit.rs | 11 + crates/editor/src/viewport/material_drop.rs | 1310 +++++++++++++++++ crates/editor/src/viewport/mod.rs | 2 + crates/editor/src/viewport/selection.rs | 18 + crates/shared/src/hydration/brushes.rs | 7 +- crates/shared/src/hydration/mod.rs | 16 +- crates/shared/src/hydration/static_meshes.rs | 24 +- crates/shared/src/lib.rs | 6 +- crates/shared/src/material_asset.rs | 89 ++ docs/editor/README.md | 3 +- docs/editor/material-system.md | 43 +- 24 files changed, 2794 insertions(+), 150 deletions(-) create mode 100644 crates/editor/src/ui/material_library.rs create mode 100644 crates/editor/src/viewport/material_drop.rs diff --git a/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md b/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md index 07a7975..7ab7374 100644 --- a/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md +++ b/.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md @@ -8,21 +8,26 @@ present; this slice completes the production-facing catalog and drag/drop workfl ## Status -Source audit complete. Implementation starts on a clean branch after collaborative-safety `#49` is -published, because both slices touch the Asset Browser and should remain independently reviewable. +Implemented on `codex/material-library-targeted-drop` after collaborative-safety `#49` was +published and integrated into `main`. Focused and full source-only verification plus live editor +acceptance are green; ticket evidence remains before `#16` / `#18` close. -## Current Gaps +## Resolved Gaps -- Material assets are edited inside generic Asset Browser Details; no dedicated Material Library or - scene-usage view exists. -- Viewport material/texture drops act on the existing selection instead of the surface under the - pointer. -- Multi-slot renderers silently assign all slots; the hydrated draw hit already carries - `HydratedRendererMaterialBinding`, but the drop path does not use it. -- Primitive and brush-face targeting is not explicit, and unsupported authored actors can receive a - generic `MaterialDesc` instead of an actionable rejection. -- Drag UI describes an eventual action but does not preview the actual affected surface or restore a - preview on cancel/target change. +- The dockable Material Library now provides cross-folder Material/Instance catalog, usage counts, + health, creation, thumbnails, drag sources, and the guarded existing details editors. +- Viewport Material/Texture drops resolve the surface under the pointer and never broaden to the + current selection or every renderer slot. +- `HydratedRendererMaterialBinding` identifies the exact static/skinned renderer slot; primitives + and stable brush-face IDs use their authored component paths. +- Unsupported, locked, linked, missing, and payload-incompatible targets reject with an explicit + reason and no scene mutation. +- Hover previews are reversible, target changes restore the prior snapshot, and release records one + typed operator/history transaction. +- Viewport pointer tracking remains active while another egui widget owns the drag, so exact target + resolution cannot stall at the viewport boundary. +- Visibility hierarchy setup now precedes generated hydration content, preventing Bevy parent/child + visibility warnings when brush draws are created after scene load. ## Material Library @@ -73,12 +78,17 @@ menu; a viewport hit never silently broadens from one slot to all slots. ## Verification -- Unit-test ray-hit to authored target/slot mapping, primitive/face targeting, and invalid reasons. -- Use `OperatorInvariantHarness` for preview target changes, release commit, Escape/outside cancel, - dirty-state preservation, helper cleanup, and one-step undo/redo. -- Cover static one/multi-slot, skinned multi-slot, primitive, brush face, Texture rejection on a - renderer slot, missing material, and linked/locked prefab boundaries. -- Verify Material Library filtering, scene-usage counts, dependency diagnostics, and drag sources in - headless egui tests. -- Run full source/headless checks. Packaged testing remains deferred until the project owner requests - it again; live visual acceptance is still required before closing `#16`/`#18`. +- Focused tests cover exact multi-slot assignment/undo, brush-face-only preview/restore, primitive + Material/Texture resolution, renderer Texture rejection, Material Library filtering, and scene + usage counts. +- Source checks include editor all-target check, focused tests, and strict editor Clippy. +- Live acceptance covered the docked catalog, search/type/usage filters, grid/list views, resolved + Material Instance thumbnails, exact primitive preview, green actor/surface feedback, one-step + commit/undo, and Escape rollback. The same relaunch produced no hydration hierarchy warnings. +- Static/skinned exact-slot, brush-face, locked/linked, incompatible Texture, and non-dirty preview + contracts remain covered by focused automated tests; shared-asset file creation was deliberately + not invoked during live QA to avoid adding throwaway authored files. +- Full verification passed: workspace all-target check, strict workspace Clippy, all workspace tests, + strict editor Clippy with both `dev` and `hot-reload`, level validation with zero blockers, and + navigation artifact freshness. Packaged testing remains deferred until the project owner requests + it again. diff --git a/README.md b/README.md index 4473210..4bb40e5 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,9 @@ deep-stale variants. | Asset Browser project/file views | Browse `assets/`, search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; audition audio; drag supported assets/submeshes into the viewport | | Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead | | Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` | +| Window → Material Library | Search/filter project Materials and Material Instances, inspect scene usage and dependency health, create/edit shared assets, and drag them into the viewport | +| Drag Material/Instance onto viewport surface | Preview and assign the exact renderer slot, primitive, or brush face under the pointer; release commits one undo step, while Escape/right-click/outside restores the preview | +| Drag Texture onto viewport surface | Set a primitive base-color texture or exact brush face; renderer slots reject loose textures and direct you to a Material Instance | | Static/Skinned Mesh Renderer material slots | Assign a shared Material/Material Instance per slot; Browse/Select/Locate the reference, or Clear the override to restore the imported source material | | `Ctrl+P` | Centered command palette; search human labels or stable command IDs, use arrow keys to select, Enter to run | | `F7` | While paused in Play: advance one sim tick | @@ -434,6 +437,7 @@ crates/ - [x] FBX/glTF model import + normalized static/skinned renderer routing; explicit generic scene-instance load via `bevy_ufbx` / `ModelRef` - [x] Asset browser model thumbnails (unified `assets/thumbnails/` pipeline; `ThumbnailState` cache; FBX via `FbxThumbnailSource`) - [x] Shared Material/Material Instance assets, stable static/skinned renderer material slots, imported-source fallback, orphan preservation, and runtime-only property blocks ([ADR 0035](docs/adr/0035-shared-material-assets-and-renderer-slots.md), [material-system guide](docs/editor/material-system.md)) +- [x] Docked Material Library with cross-folder type/usage filters, resolved Material Instance thumbnails, shared guarded editors, and exact reversible viewport Material/Texture drops for renderer slots, primitives, and brush faces ([material-system guide](docs/editor/material-system.md); [Gitea #16](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/16), [Gitea #18](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/18)) - [x] Prefab v2 core: shared stable nested override paths, property/component/structural scopes, recursive graph validation, linked-root variants, conflict recovery, transactional source Apply, and undoable unpack/convert ([ADR 0027](docs/adr/0027-stable-prefab-ownership-and-variants.md)) - [x] Prefab v2 production acceptance: committed base/nested/variant fixtures pass workspace tests, recursive headless validation, packaged release startup, and live editor placement/inspection regression coverage ([Gitea #43](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/43)) - [x] Shared project validation: editor Diagnostics and `cargo validate-levels --json` use one owner-attributed dependency/finding report across project settings, registry/import artifacts, materials, shaders, scenes, prefabs, brushes, and colliders; valid/missing/cyclic/incompatible fixtures fail on blocking content errors ([ADR 0028](docs/adr/0028-authoritative-project-content-validation.md), [Gitea #45](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/45)) @@ -476,7 +480,7 @@ crates/ **Unpack Layer** preserves nested links; **Convert to Local** recursively removes them. Current authoring UI coverage and production-acceptance gaps are tracked in [prefab-authoring.md](docs/editor/prefab-authoring.md). -- Material and direct-base Material Instance assets live under `assets/materials/`; shader schemas live under `assets/shaders/`. In the Asset Browser, select a Material and use **Create Instance** for sparse inherited variants. Static and skinned renderers own stable per-draw material slots, with explicit assignments taking precedence over imported source defaults; shared edits propagate through live-updated handles without reloading skinned geometry. Custom Surface evaluators share one constrained ABI between raster and Solari-eligible non-deformed geometry. Dynamic skinned/morph Solari geometry and a persisted property-block authoring workflow remain future work; see the [material-system guide](docs/editor/material-system.md). +- Material and direct-base Material Instance assets live under `assets/materials/`; shader schemas live under `assets/shaders/`. **Window > Material Library** provides cross-folder creation, editing, usage filters, and drag sources. Viewport Material drops target one exact renderer slot, primitive, or brush face and use reversible preview plus one-step history; loose Textures target primitives/brush faces and are rejected on renderer slots. Shared edits propagate through live-updated handles without reloading skinned geometry. Custom Surface evaluators share one constrained ABI between raster and Solari-eligible non-deformed geometry. Dynamic skinned/morph Solari geometry and a persisted property-block authoring workflow remain future work; see the [material-system guide](docs/editor/material-system.md). - 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/materials.rs b/crates/editor/src/assets/materials.rs index 362f95c..ccb9d87 100644 --- a/crates/editor/src/assets/materials.rs +++ b/crates/editor/src/assets/materials.rs @@ -16,10 +16,7 @@ pub fn load_material_asset(catalog_path: &str) -> Result } pub fn material_desc_from_asset(catalog_path: &str) -> Result { - let asset = load_material_asset(catalog_path)?; - let mut desc = asset.material; - desc.material_asset_path = Some(catalog_path.to_string()); - Ok(desc) + shared::load_resolved_material_from_path(catalog_path).map(|(desc, _)| desc) } pub fn apply_material_asset_to_selection( @@ -110,7 +107,7 @@ pub fn apply_material_asset_to_selection( Ok(()) } -fn ensure_static_material_slots(renderer: &mut shared::StaticMeshRenderer) { +pub(crate) fn ensure_static_material_slots(renderer: &mut shared::StaticMeshRenderer) { for part in &mut renderer.slots { if part.material_slot_id.0.trim().is_empty() { part.material_slot_id = if part.id.0.trim().is_empty() { diff --git a/crates/editor/src/assets/mod.rs b/crates/editor/src/assets/mod.rs index cf406bd..57da715 100644 --- a/crates/editor/src/assets/mod.rs +++ b/crates/editor/src/assets/mod.rs @@ -13,8 +13,8 @@ pub mod thumbnails; pub use catalog::*; pub use thumbnails::{ draw_asset_cell_with, gltf_skinned_primitive_labels, invalidate_on_catalog_refresh, kind_icon, - prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState, - ThumbnailStudio, ThumbnailsPlugin, + prefetch_asset_thumbnails, prefetch_folder_thumbnails, AssetThumbnailCache, + ThumbnailCacheSnapshot, ThumbnailState, ThumbnailStudio, ThumbnailsPlugin, }; /// Refreshes both generated contracts derived from one imported model source. diff --git a/crates/editor/src/assets/thumbnails/cache.rs b/crates/editor/src/assets/thumbnails/cache.rs index 3de9eff..2f4a2e7 100644 --- a/crates/editor/src/assets/thumbnails/cache.rs +++ b/crates/editor/src/assets/thumbnails/cache.rs @@ -188,18 +188,24 @@ impl AssetThumbnailCache { { return; } - let material = match shared::MaterialAsset::load_from_path(&path) { - Ok(asset) => asset, + let material = match shared::load_resolved_material_from_path(&path) { + Ok((material, _)) => material, Err(error) => { self.mark_studio_failed(&key, &error, true); return; } }; + let label = shared::MaterialAsset::load_from_path(&path) + .map(|asset| asset.label) + .or_else(|_| { + shared::MaterialInstanceAsset::load_from_path(&path).map(|asset| asset.label) + }) + .unwrap_or_else(|_| "Material".into()); if studio.enqueue_source( key.clone(), ThumbnailJobSource::MaterialAsset { - label: material.label.clone(), - material: Box::new(material.material), + label, + material: Box::new(material), }, ) { self.studio_pending.insert(key); @@ -482,6 +488,37 @@ pub fn prefetch_folder_thumbnails(world: &mut World, folder: &str) { .prefetched_folder = Some(folder.to_string()); } +/// Requests thumbnails for an explicit cross-folder asset set. +pub fn prefetch_asset_thumbnails(world: &mut World, assets: &[EditorAsset]) { + let requests = assets + .iter() + .filter_map(|asset| { + Some(( + asset_cache_key(asset), + asset.path.clone()?, + asset.kind.clone(), + )) + }) + .collect::>(); + let asset_server = world.resource::().clone(); + world.resource_scope(|world, mut cache: Mut| { + world.resource_scope(|_world, mut studio: Mut| { + for (key, path, kind) in requests { + match kind { + EditorAssetKind::Texture => cache.request_texture(key, path, &asset_server), + EditorAssetKind::Model => { + cache.request_model(key, path, &asset_server, &mut studio) + } + EditorAssetKind::Material => { + cache.request_material_asset(key, path, &mut studio) + } + _ => {} + } + } + }); + }); +} + pub fn invalidate_on_catalog_refresh(world: &mut World) { world.resource_mut::().invalidate_all(); if !world.contains_resource::() { diff --git a/crates/editor/src/assets/thumbnails/mod.rs b/crates/editor/src/assets/thumbnails/mod.rs index c28f54c..411d4d9 100644 --- a/crates/editor/src/assets/thumbnails/mod.rs +++ b/crates/editor/src/assets/thumbnails/mod.rs @@ -8,8 +8,9 @@ mod sources; mod studio; pub use cache::{ - draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_folder_thumbnails, - AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, ThumbnailState, + draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_asset_thumbnails, + prefetch_folder_thumbnails, AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, + ThumbnailState, }; pub use job::{ThumbnailJob, ThumbnailJobSource}; pub use sources::{ diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 5618e11..3a98e4b 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -37,6 +37,7 @@ pub use viewport::brush_edit; pub use viewport::brush_tool; pub use viewport::camera; pub use viewport::gizmos; +pub use viewport::material_drop; pub use viewport::render_view; pub use viewport::rendering_diagnostics; pub use viewport::selection; @@ -62,6 +63,7 @@ use extensibility::ExtensibilityPlugin; use gizmos::EditorGizmoPlugin; use history::EditorHistoryPlugin; use infra::EditorInfraPlugin; +use material_drop::MaterialDropPlugin; use operators::OperatorPlugin; use play::audio_preview::AudioPreviewPlugin; use play::PlaySessionPlugin; @@ -107,6 +109,7 @@ impl PluginGroup for EditorPluginGroup { .add(EditorCameraPlugin) .add(AudioPreviewPlugin) .add(ActorIconsPlugin) + .add(MaterialDropPlugin) .add(EditorSelectionPlugin) .add(SelectionOutlinePlugin) .add(EditorGizmoPlugin) diff --git a/crates/editor/src/ui/asset_browser/panel.rs b/crates/editor/src/ui/asset_browser/panel.rs index 735155e..b19c4f7 100644 --- a/crates/editor/src/ui/asset_browser/panel.rs +++ b/crates/editor/src/ui/asset_browser/panel.rs @@ -1855,7 +1855,7 @@ fn details_panel(world: &mut World, ui: &mut egui::Ui, selected_entities: &Selec } } -fn top_level_asset_details_panel( +pub(crate) fn top_level_asset_details_panel( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, @@ -2524,7 +2524,11 @@ fn save_material_draft(world: &mut World) { } } -fn create_material_instance_from_base(world: &mut World, base_path: &str, base_label: &str) { +pub(crate) fn create_material_instance_from_base( + world: &mut World, + base_path: &str, + base_label: &str, +) { let base_reference = world .get_resource_mut::() .ok_or_else(|| "Asset registry is unavailable".to_string()) diff --git a/crates/editor/src/ui/dock_tabs.rs b/crates/editor/src/ui/dock_tabs.rs index f5134b6..90225b7 100644 --- a/crates/editor/src/ui/dock_tabs.rs +++ b/crates/editor/src/ui/dock_tabs.rs @@ -36,7 +36,10 @@ impl PanelNodes { EditorTab::Viewport | EditorTab::GameView => self.main_view(), EditorTab::Hierarchy => self.hierarchy(), EditorTab::Inspector => self.inspector(), - EditorTab::AssetBrowser | EditorTab::Build | EditorTab::Toolbar => self.bottom(), + EditorTab::AssetBrowser + | EditorTab::MaterialLibrary + | EditorTab::Build + | EditorTab::Toolbar => self.bottom(), } } @@ -51,20 +54,22 @@ impl PanelNodes { EditorTab::Viewport | EditorTab::GameView => nodes.main_view = path.node.0, EditorTab::Hierarchy => nodes.hierarchy = path.node.0, EditorTab::Inspector => nodes.inspector = path.node.0, - EditorTab::AssetBrowser | EditorTab::Build | EditorTab::Toolbar => { - nodes.bottom = path.node.0 - } + EditorTab::AssetBrowser + | EditorTab::MaterialLibrary + | EditorTab::Build + | EditorTab::Toolbar => nodes.bottom = path.node.0, } } nodes } } -pub const PANEL_TABS: [EditorTab; 5] = [ +pub const PANEL_TABS: [EditorTab; 6] = [ EditorTab::Viewport, EditorTab::Hierarchy, EditorTab::Inspector, EditorTab::AssetBrowser, + EditorTab::MaterialLibrary, EditorTab::Build, ]; @@ -74,6 +79,7 @@ pub fn tab_label(tab: &EditorTab) -> &'static str { EditorTab::Hierarchy => "Hierarchy", EditorTab::Inspector => "Inspector", EditorTab::AssetBrowser => "Asset Browser", + EditorTab::MaterialLibrary => "Material Library", EditorTab::Build => "Build", EditorTab::Toolbar => "Toolbar (legacy)", } diff --git a/crates/editor/src/ui/layout.rs b/crates/editor/src/ui/layout.rs index 6efca92..8f6ee85 100644 --- a/crates/editor/src/ui/layout.rs +++ b/crates/editor/src/ui/layout.rs @@ -32,8 +32,11 @@ pub fn default_dock_layout() -> DockLayoutSnapshot { tree.split_right(NodeIndex::root(), 0.76, vec![EditorTab::Inspector]); let [main_view_node, hierarchy_node] = tree.split_left(main_view_node, 0.2, vec![EditorTab::Hierarchy]); - let [main_view_node, bottom_node] = - tree.split_below(main_view_node, 0.88, vec![EditorTab::AssetBrowser]); + let [main_view_node, bottom_node] = tree.split_below( + main_view_node, + 0.88, + vec![EditorTab::AssetBrowser, EditorTab::MaterialLibrary], + ); let panel_nodes = PanelNodes { main_view: main_view_node.0, diff --git a/crates/editor/src/ui/material_library.rs b/crates/editor/src/ui/material_library.rs new file mode 100644 index 0000000..4c4788f --- /dev/null +++ b/crates/editor/src/ui/material_library.rs @@ -0,0 +1,1080 @@ +//! Docked project material catalog, usage view, and editor entry point. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use bevy::prelude::*; +use bevy_egui::egui; +use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; +use egui_phosphor_icons::icons; +use shared::{ + BrushDesc, MaterialAsset, MaterialDesc, MaterialInstanceAsset, SkinnedMeshRenderer, + StaticMeshRenderer, +}; + +use crate::asset_db::{ensure_asset_record, AssetRegistry}; +use crate::assets::{ + draw_asset_cell_with, invalidate_on_catalog_refresh, prefetch_asset_thumbnails, AssetSelection, + AssetThumbnailCache, EditorAsset, EditorAssetKind, EditorAssets, ThumbnailCacheSnapshot, +}; +use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; +use crate::scene_io::SceneIo; + +use super::asset_browser::{create_material_instance_from_base, top_level_asset_details_panel}; +use super::theme::{ + panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SELECTION, SUCCESS, TEXT, TEXT_DIM, + TEXT_MUTED, WIDGET_BG, +}; +use super::widgets::{icon_button_small, phosphor_icon, tool_button}; +use super::{DockTabRequest, EditorTab}; + +const MATERIALS_FOLDER: &str = "assets/materials"; +const DETAILS_BREAKPOINT: f32 = 760.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaterialLibraryFilter { + All, + Materials, + Instances, + Used, + Unused, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaterialLibraryView { + Grid, + List, +} + +#[derive(Resource, Debug)] +pub struct MaterialLibraryState { + search: String, + filter: MaterialLibraryFilter, + view: MaterialLibraryView, + thumbnail_size: f32, + show_details: bool, +} + +impl Default for MaterialLibraryState { + fn default() -> Self { + Self { + search: String::new(), + filter: MaterialLibraryFilter::All, + view: MaterialLibraryView::Grid, + thumbnail_size: 72.0, + show_details: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaterialDocumentKind { + Material, + Instance, + Invalid, +} + +impl MaterialDocumentKind { + fn short_label(self) -> &'static str { + match self { + Self::Material => "MAT", + Self::Instance => "MI", + Self::Invalid => "ERR", + } + } + + fn label(self) -> &'static str { + match self { + Self::Material => "Material", + Self::Instance => "Material Instance", + Self::Invalid => "Invalid Material", + } + } +} + +#[derive(Debug, Clone)] +struct MaterialLibraryRow { + asset: EditorAsset, + selection: AssetSelection, + kind: MaterialDocumentKind, + scene_users: Vec, + dependent_instances: usize, + error: Option, +} + +#[derive(Debug, Clone)] +struct MaterialSceneUser { + entity: Entity, + actor_label: String, + detail: String, +} + +#[derive(Debug, Clone)] +struct CachedMaterialDocument { + asset: EditorAsset, + selection: AssetSelection, + kind: MaterialDocumentKind, + base_path: Option, + error: Option, +} + +#[derive(Resource, Debug, Default)] +pub struct MaterialLibraryCatalog { + documents: Vec, +} + +#[derive(Debug, Clone)] +struct MaterialLibrarySnapshot { + search: String, + filter: MaterialLibraryFilter, + view: MaterialLibraryView, + thumbnail_size: f32, + show_details: bool, +} + +pub fn material_library_ui( + world: &mut World, + ui: &mut egui::Ui, + selected_entities: &mut SelectedEntities, +) { + let selected = world.resource::().selected.clone(); + let rows = material_rows(world); + let visible_rows = { + let state = world.resource::(); + rows.iter() + .filter(|row| row_matches(row, state)) + .cloned() + .collect::>() + }; + let visible_assets = visible_rows + .iter() + .map(|row| row.asset.clone()) + .collect::>(); + prefetch_asset_thumbnails(world, &visible_assets); + let cache = world.resource::().snapshot(); + + material_library_toolbar(world, ui, &rows, &selected); + ui.separator(); + + let state = { + let state = world.resource::(); + MaterialLibrarySnapshot { + search: state.search.clone(), + filter: state.filter, + view: state.view, + thumbnail_size: state.thumbnail_size, + show_details: state.show_details, + } + }; + let selected_row = selected + .as_ref() + .and_then(|selection| rows.iter().find(|row| &row.selection == selection)) + .cloned(); + let wide = ui.available_width() >= DETAILS_BREAKPOINT; + + if !wide && state.show_details { + if let Some(row) = selected_row.as_ref() { + egui::ScrollArea::vertical() + .id_salt("material_library_narrow_details") + .auto_shrink([false, false]) + .show(ui, |ui| { + material_usage_ui(world, ui, row, selected_entities); + top_level_asset_details_panel(world, ui, selected_entities, &row.asset); + }); + return; + } + } + + ui.horizontal(|ui| { + let details_width = if wide && state.show_details { + (ui.available_width() * 0.32).clamp(320.0, 430.0) + } else { + 0.0 + }; + let catalog_width = (ui.available_width() - details_width - 10.0).max(1.0); + ui.allocate_ui_with_layout( + egui::vec2(catalog_width, ui.available_height()), + egui::Layout::top_down(egui::Align::Min), + |ui| { + material_catalog_header(ui, &visible_rows, &rows, &state); + egui::ScrollArea::vertical() + .id_salt("material_library_catalog") + .auto_shrink([false, false]) + .show(ui, |ui| { + if visible_rows.is_empty() { + ui.label( + egui::RichText::new("No materials match the current filters.") + .color(TEXT_DIM), + ); + } else if state.view == MaterialLibraryView::Grid { + material_grid( + world, + ui, + &visible_rows, + &selected, + &cache, + state.thumbnail_size, + selected_entities, + ); + } else { + material_list( + world, + ui, + &visible_rows, + &selected, + &cache, + selected_entities, + ); + } + }); + }, + ); + + if details_width > 0.0 { + ui.separator(); + ui.allocate_ui_with_layout( + egui::vec2(details_width, ui.available_height()), + egui::Layout::top_down(egui::Align::Min), + |ui| { + egui::ScrollArea::vertical() + .id_salt("material_library_details") + .auto_shrink([false, false]) + .show(ui, |ui| { + if let Some(row) = selected_row.as_ref() { + material_usage_ui(world, ui, row, selected_entities); + top_level_asset_details_panel( + world, + ui, + selected_entities, + &row.asset, + ); + } else { + ui.label(panel_heading("Material Details")); + ui.separator(); + ui.label( + egui::RichText::new("No material selected").color(TEXT_DIM), + ); + } + }); + }, + ); + } + }); +} + +fn material_library_toolbar( + world: &mut World, + ui: &mut egui::Ui, + rows: &[MaterialLibraryRow], + selected: &Option, +) { + let selected_base = selected.as_ref().and_then(|selection| { + rows.iter() + .find(|row| &row.selection == selection && row.kind == MaterialDocumentKind::Material) + .map(|row| row.asset.clone()) + }); + let mut create_material = false; + let mut create_instance = false; + let mut reveal_in_assets = false; + let mut refresh = false; + ui.horizontal(|ui| { + if icon_button_small(ui, icons::ARROWS_CLOCKWISE, "Refresh materials").clicked() { + refresh = true; + } + if ui + .button(format!("{} Material", icons::PLUS.as_str())) + .on_hover_text("Create Material") + .clicked() + { + create_material = true; + } + if ui + .add_enabled( + selected_base.is_some(), + egui::Button::new(format!("{} Instance", icons::COPY.as_str())), + ) + .on_hover_text("Create Material Instance from selected Material") + .clicked() + { + create_instance = true; + } + if icon_button_small(ui, icons::FOLDER_OPEN, "Show in Asset Browser").clicked() { + reveal_in_assets = true; + } + + ui.separator(); + let mut state = world.resource_mut::(); + ui.label(phosphor_icon(icons::MAGNIFYING_GLASS, 14.0).color(TEXT_DIM)); + ui.add( + egui::TextEdit::singleline(&mut state.search) + .hint_text("Search materials...") + .desired_width((ui.available_width() * 0.22).clamp(120.0, 220.0)), + ); + ui.separator(); + for (filter, label) in [ + (MaterialLibraryFilter::All, "All"), + (MaterialLibraryFilter::Materials, "Materials"), + (MaterialLibraryFilter::Instances, "Instances"), + (MaterialLibraryFilter::Used, "Used"), + (MaterialLibraryFilter::Unused, "Unused"), + ] { + ui.selectable_value(&mut state.filter, filter, label); + } + ui.separator(); + if tool_button( + ui, + icons::GRID_FOUR, + state.view == MaterialLibraryView::Grid, + "Grid view", + ) + .clicked() + { + state.view = MaterialLibraryView::Grid; + } + if tool_button( + ui, + icons::LIST, + state.view == MaterialLibraryView::List, + "List view", + ) + .clicked() + { + state.view = MaterialLibraryView::List; + } + if tool_button(ui, icons::SLIDERS, state.show_details, "Material details").clicked() { + state.show_details = !state.show_details; + } + if state.view == MaterialLibraryView::Grid && ui.available_width() > 120.0 { + ui.add_sized( + [88.0, 20.0], + egui::Slider::new(&mut state.thumbnail_size, 48.0..=112.0).show_value(false), + ); + } + }); + + if refresh { + world.resource_mut::().refresh(); + invalidate_on_catalog_refresh(world); + } + if create_material { + if let Err(error) = create_material_asset(world) { + world.resource_mut::().status = error; + } + } + if create_instance { + if let Some(asset) = selected_base { + if let Some(path) = asset.path.as_deref() { + create_material_instance_from_base(world, path, &asset.label); + } + } + } + if reveal_in_assets { + if let Some(asset) = selected + .as_ref() + .and_then(|selection| rows.iter().find(|row| &row.selection == selection)) + { + world.resource_mut::().current_folder = asset.asset.folder_path.clone(); + world.resource_mut::().0 = Some(EditorTab::AssetBrowser); + } + } +} + +fn material_catalog_header( + ui: &mut egui::Ui, + visible: &[MaterialLibraryRow], + all: &[MaterialLibraryRow], + state: &MaterialLibrarySnapshot, +) { + ui.horizontal_wrapped(|ui| { + ui.label(panel_heading("Project Materials")); + ui.label( + egui::RichText::new(format!("{} / {}", visible.len(), all.len())) + .monospace() + .color(TEXT_DIM), + ); + if !state.search.trim().is_empty() { + ui.label(egui::RichText::new(format!("\"{}\"", state.search)).color(ACCENT)); + } + let filter = match state.filter { + MaterialLibraryFilter::All => None, + MaterialLibraryFilter::Materials => Some("Materials"), + MaterialLibraryFilter::Instances => Some("Instances"), + MaterialLibraryFilter::Used => Some("Used in scene"), + MaterialLibraryFilter::Unused => Some("Unused in scene"), + }; + if let Some(filter) = filter { + ui.label(egui::RichText::new(filter).color(SELECTION).small()); + } + }); +} + +fn material_grid( + world: &mut World, + ui: &mut egui::Ui, + rows: &[MaterialLibraryRow], + selected: &Option, + cache: &ThumbnailCacheSnapshot, + thumbnail_size: f32, + selected_entities: &mut SelectedEntities, +) { + let cell_width = thumbnail_size.clamp(48.0, 112.0) + 26.0; + let columns = (ui.available_width() / cell_width).floor().max(1.0) as usize; + for chunk in rows.chunks(columns) { + ui.horizontal(|ui| { + for row in chunk { + let response = draw_asset_cell_with( + ui, + &row.asset, + cache.texture_for(&row.asset), + cache.is_pending(&row.asset), + cache.is_failed(&row.asset).then_some("failed"), + selected.as_ref() == Some(&row.selection), + thumbnail_size, + ); + draw_material_grid_badges(ui, &response, row); + handle_material_response(world, response, row, selected_entities); + } + }); + } +} + +fn draw_material_grid_badges(ui: &egui::Ui, response: &egui::Response, row: &MaterialLibraryRow) { + let kind_color = match row.kind { + MaterialDocumentKind::Material => ACCENT, + MaterialDocumentKind::Instance => SELECTION, + MaterialDocumentKind::Invalid => ERROR, + }; + let kind_rect = egui::Rect::from_min_size( + response.rect.left_top() + egui::vec2(6.0, 6.0), + egui::vec2(30.0, 17.0), + ); + ui.painter().rect( + kind_rect, + 3.0, + ELEVATED_BG, + egui::Stroke::new(1.0, kind_color), + egui::StrokeKind::Inside, + ); + ui.painter().text( + kind_rect.center(), + egui::Align2::CENTER_CENTER, + row.kind.short_label(), + egui::FontId::monospace(9.0), + kind_color, + ); + if !row.scene_users.is_empty() { + let usage_rect = egui::Rect::from_min_size( + response.rect.right_top() + egui::vec2(-35.0, 6.0), + egui::vec2(29.0, 17.0), + ); + ui.painter().rect( + usage_rect, + 3.0, + ELEVATED_BG, + egui::Stroke::new(1.0, SUCCESS), + egui::StrokeKind::Inside, + ); + ui.painter().text( + usage_rect.center(), + egui::Align2::CENTER_CENTER, + row.scene_users.len().to_string(), + egui::FontId::monospace(9.0), + SUCCESS, + ); + } +} + +fn material_list( + world: &mut World, + ui: &mut egui::Ui, + rows: &[MaterialLibraryRow], + selected: &Option, + cache: &ThumbnailCacheSnapshot, + selected_entities: &mut SelectedEntities, +) { + for row in rows { + let is_selected = selected.as_ref() == Some(&row.selection); + let height = 42.0; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), height), + egui::Sense::click_and_drag(), + ); + ui.painter().rect( + rect, + 3.0, + if is_selected { + crate::ui::theme::SELECTION_BG_MUTED + } else if response.hovered() { + ELEVATED_BG + } else { + WIDGET_BG + }, + egui::Stroke::new(1.0, if is_selected { SELECTION } else { BORDER }), + egui::StrokeKind::Inside, + ); + let thumb_rect = egui::Rect::from_min_size( + rect.left_top() + egui::vec2(5.0, 5.0), + egui::vec2(32.0, 32.0), + ); + if let Some(texture) = cache.texture_for(&row.asset) { + ui.painter().image( + texture, + thumb_rect, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else { + ui.painter().text( + thumb_rect.center(), + egui::Align2::CENTER_CENTER, + icons::PALETTE.as_str(), + egui::FontId::new(20.0, egui::FontFamily::Name("phosphor-regular".into())), + if row.error.is_some() { ERROR } else { TEXT }, + ); + } + ui.painter().text( + egui::pos2(44.0 + rect.left(), rect.center().y - 7.0), + egui::Align2::LEFT_CENTER, + row.asset.label.as_str(), + egui::FontId::proportional(13.0), + TEXT, + ); + ui.painter().text( + egui::pos2(44.0 + rect.left(), rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + row.asset.path.as_deref().unwrap_or(""), + egui::FontId::monospace(10.0), + TEXT_MUTED, + ); + ui.painter().text( + rect.right_center() - egui::vec2(10.0, 0.0), + egui::Align2::RIGHT_CENTER, + format!( + "{} | {} scene | {} instances", + row.kind.label(), + row.scene_users.len(), + row.dependent_instances + ), + egui::FontId::proportional(11.0), + if row.error.is_some() { ERROR } else { TEXT_DIM }, + ); + handle_material_response(world, response, row, selected_entities); + } +} + +fn handle_material_response( + world: &mut World, + response: egui::Response, + row: &MaterialLibraryRow, + selected_entities: &mut SelectedEntities, +) { + let response = response.on_hover_ui(|ui| { + ui.strong(&row.asset.label); + ui.label(row.kind.label()); + ui.monospace(row.asset.path.as_deref().unwrap_or("")); + ui.label(format!("Scene references: {}", row.scene_users.len())); + if row.dependent_instances > 0 { + ui.label(format!("Derived instances: {}", row.dependent_instances)); + } + if let Some(error) = row.error.as_ref() { + ui.colored_label(ERROR, error); + } + }); + if response.clicked() { + world + .resource_mut::() + .select(row.selection.clone()); + } + if response.drag_started() && row.error.is_none() { + world + .resource_mut::() + .start_drag(row.selection.clone()); + } + response.context_menu(|ui| { + if row.kind == MaterialDocumentKind::Material && ui.button("Create Instance").clicked() { + if let Some(path) = row.asset.path.as_deref() { + create_material_instance_from_base(world, path, &row.asset.label); + } + ui.close(); + } + if !row.scene_users.is_empty() { + ui.menu_button("Scene Users", |ui| { + for user in &row.scene_users { + if ui + .button(format!("{} / {}", user.actor_label, user.detail)) + .clicked() + { + select_scene_user(world, selected_entities, user.entity); + ui.close(); + } + } + }); + } + if ui.button("Show in Asset Browser").clicked() { + world.resource_mut::().current_folder = row.asset.folder_path.clone(); + world + .resource_mut::() + .select(row.selection.clone()); + world.resource_mut::().0 = Some(EditorTab::AssetBrowser); + ui.close(); + } + }); +} + +fn material_rows(world: &mut World) -> Vec { + let usages = collect_scene_material_usage(world); + let documents = world.resource::().documents.clone(); + let mut instance_dependencies = HashMap::::new(); + for document in &documents { + if let Some(base_path) = document.base_path.as_deref() { + *instance_dependencies + .entry(base_path.to_string()) + .or_default() += 1; + } + } + + let mut rows = documents + .into_iter() + .map(|document| { + let normalized = normalize_path(document.asset.path.as_deref().unwrap_or_default()); + MaterialLibraryRow { + selection: document.selection, + scene_users: usages.get(&normalized).cloned().unwrap_or_default(), + dependent_instances: instance_dependencies + .get(&normalized) + .copied() + .unwrap_or_default(), + asset: document.asset, + kind: document.kind, + error: document.error, + } + }) + .collect::>(); + rows.sort_by(|left, right| { + left.asset + .label + .to_ascii_lowercase() + .cmp(&right.asset.label.to_ascii_lowercase()) + .then_with(|| left.asset.path.cmp(&right.asset.path)) + }); + rows +} + +fn row_matches(row: &MaterialLibraryRow, state: &MaterialLibraryState) -> bool { + let search = state.search.trim().to_ascii_lowercase(); + let search_matches = search.is_empty() + || row.asset.label.to_ascii_lowercase().contains(&search) + || row + .asset + .path + .as_deref() + .is_some_and(|path| path.to_ascii_lowercase().contains(&search)); + let filter_matches = match state.filter { + MaterialLibraryFilter::All => true, + MaterialLibraryFilter::Materials => row.kind == MaterialDocumentKind::Material, + MaterialLibraryFilter::Instances => row.kind == MaterialDocumentKind::Instance, + MaterialLibraryFilter::Used => !row.scene_users.is_empty(), + MaterialLibraryFilter::Unused => row.scene_users.is_empty(), + }; + search_matches && filter_matches +} + +fn collect_scene_material_usage(world: &mut World) -> HashMap> { + let mut usages = HashMap::new(); + let mut static_renderers = world.query::<(Entity, &StaticMeshRenderer, Option<&Name>)>(); + for (entity, renderer, name) in static_renderers.iter(world) { + for slot in &renderer.materials.slots { + if let Some(path) = slot + .effective_material() + .and_then(|reference| reference.0.source_path.as_deref()) + { + add_usage( + &mut usages, + path, + MaterialSceneUser { + entity, + actor_label: actor_label(entity, name), + detail: format!("Static / {}", slot.name), + }, + ); + } + } + } + let mut skinned_renderers = world.query::<(Entity, &SkinnedMeshRenderer, Option<&Name>)>(); + for (entity, renderer, name) in skinned_renderers.iter(world) { + for slot in &renderer.materials.slots { + if let Some(path) = slot + .effective_material() + .and_then(|reference| reference.0.source_path.as_deref()) + { + add_usage( + &mut usages, + path, + MaterialSceneUser { + entity, + actor_label: actor_label(entity, name), + detail: format!("Skinned / {}", slot.name), + }, + ); + } + } + } + let mut materials = world.query::<(Entity, &MaterialDesc, Option<&Name>)>(); + for (entity, material, name) in materials.iter(world) { + if let Some(path) = material.material_asset_path.as_deref() { + add_usage( + &mut usages, + path, + MaterialSceneUser { + entity, + actor_label: actor_label(entity, name), + detail: "Surface".into(), + }, + ); + } + } + let mut brushes = world.query::<(Entity, &BrushDesc, Option<&Name>)>(); + for (entity, brush, name) in brushes.iter(world) { + for face in &brush.faces { + if let Some(path) = face + .material + .as_ref() + .and_then(|reference| reference.source_path.as_deref()) + { + add_usage( + &mut usages, + path, + MaterialSceneUser { + entity, + actor_label: actor_label(entity, name), + detail: face.id.0.clone(), + }, + ); + } + } + } + usages +} + +fn add_usage( + usages: &mut HashMap>, + path: &str, + user: MaterialSceneUser, +) { + usages.entry(normalize_path(path)).or_default().push(user); +} + +fn actor_label(entity: Entity, name: Option<&Name>) -> String { + name.map(|name| name.as_str().to_string()) + .unwrap_or_else(|| format!("Actor {entity:?}")) +} + +fn normalize_path(path: &str) -> String { + path.replace('\\', "/") +} + +pub(super) fn refresh_material_library_catalog( + assets: Res, + mut catalog: ResMut, +) { + if !assets.is_changed() && !catalog.documents.is_empty() { + return; + } + catalog.documents = assets + .assets + .iter() + .filter(|asset| matches!(asset.kind, EditorAssetKind::Material)) + .cloned() + .map(|asset| { + let path = asset.path.as_deref().unwrap_or_default(); + let instance = MaterialInstanceAsset::load_from_path(path).ok(); + let kind = if instance.is_some() { + MaterialDocumentKind::Instance + } else if MaterialAsset::load_from_path(path).is_ok() { + MaterialDocumentKind::Material + } else { + MaterialDocumentKind::Invalid + }; + let base_path = instance + .as_ref() + .and_then(|instance| instance.base.0.source_path.as_deref()) + .map(normalize_path); + CachedMaterialDocument { + selection: AssetSelection::from_asset(&asset), + error: shared::load_resolved_material_from_path(path).err(), + asset, + kind, + base_path, + } + }) + .collect(); +} + +fn material_usage_ui( + world: &mut World, + ui: &mut egui::Ui, + row: &MaterialLibraryRow, + selected_entities: &mut SelectedEntities, +) { + ui.horizontal(|ui| { + ui.label(panel_heading("Scene Usage")); + ui.label( + egui::RichText::new(row.scene_users.len().to_string()) + .monospace() + .color(if row.scene_users.is_empty() { + TEXT_DIM + } else { + SUCCESS + }), + ); + }); + for user in &row.scene_users { + if ui + .button(format!( + "{} {} / {}", + icons::CROSSHAIR.as_str(), + user.actor_label, + user.detail + )) + .clicked() + { + select_scene_user(world, selected_entities, user.entity); + } + } + ui.separator(); +} + +fn select_scene_user(world: &mut World, selected_entities: &mut SelectedEntities, entity: Entity) { + if world.get_entity(entity).is_err() { + return; + } + selected_entities.select_replace(entity); + world.resource_mut::().0 = Some(entity); + super::selection_ops::focus_editor_camera_on_selection(world); + world.resource_mut::().0 = Some(EditorTab::Viewport); +} + +fn create_material_asset(world: &mut World) -> Result<(), String> { + let path = next_material_path(Path::new(MATERIALS_FOLDER)); + let label = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("new_material") + .replace('_', " ") + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + chars + .next() + .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() + }) + .collect::>() + .join(" "); + let asset = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: label.clone(), + shader: None, + shader_ref: None, + render_state: shared::MaterialRenderState::default(), + material: MaterialDesc::default(), + }; + let text = ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default()) + .map_err(|error| format!("could not serialize new material: {error}"))?; + publish_authored_file( + world, + &path, + text.as_bytes(), + &FileSnapshot::missing(), + FileWriteIntent::Material, + )?; + let catalog_path = normalize_path(&path.to_string_lossy()); + { + let Some(mut registry) = world.get_resource_mut::() else { + return Err(format!( + "Created {catalog_path}, but the asset registry is unavailable" + )); + }; + ensure_asset_record(&mut registry, catalog_path.clone(), label, "Material")?; + } + { + let mut assets = world.resource_mut::(); + assets.refresh(); + assets.current_folder = MATERIALS_FOLDER.into(); + assets.select(AssetSelection::File(catalog_path.clone())); + } + invalidate_on_catalog_refresh(world); + world.resource_mut::().status = format!("Created material {catalog_path}"); + Ok(()) +} + +fn next_material_path(folder: &Path) -> PathBuf { + for index in 1.. { + let name = if index == 1 { + "new_material.ron".to_string() + } else { + format!("new_material_{index}.ron") + }; + let candidate = folder.join(name); + if !candidate.exists() { + return candidate; + } + } + unreachable!("unbounded material filename search") +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::{ + ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot, + }; + + #[test] + fn usage_counts_exact_authored_material_paths() { + let material_path = "assets/materials/steel.ron"; + let mut world = World::new(); + world.spawn(StaticMeshRenderer { + slots: Vec::new(), + materials: RendererMaterialSet { + slots: vec![RendererMaterialSlot { + id: ComponentInstanceId::new("slot:body"), + name: "Body".into(), + source_material: None, + material: Some(MaterialRef::new( + EditorAssetRef::new("steel", "material:source", "Steel") + .with_source_path(material_path), + )), + }], + orphaned_assignments: Vec::new(), + }, + }); + world.spawn(MaterialDesc { + material_asset_path: Some(material_path.into()), + ..Default::default() + }); + let mut brush = BrushDesc::cuboid(Vec3::ONE); + brush.faces[0].material = Some( + EditorAssetRef::new("steel", "material:source", "Steel") + .with_source_path(material_path), + ); + world.spawn(brush); + + let usage = collect_scene_material_usage(&mut world); + + assert_eq!(usage.get(material_path).map(Vec::len), Some(3)); + } + + #[test] + fn filtering_distinguishes_instances_and_scene_usage() { + let base = MaterialLibraryRow { + asset: EditorAsset { + label: "Steel".into(), + path: Some("assets/materials/steel.ron".into()), + folder_path: MATERIALS_FOLDER.into(), + kind: EditorAssetKind::Material, + }, + selection: AssetSelection::File("assets/materials/steel.ron".into()), + kind: MaterialDocumentKind::Material, + scene_users: vec![ + MaterialSceneUser { + entity: Entity::from_bits(1), + actor_label: "Crate".into(), + detail: "Body".into(), + }, + MaterialSceneUser { + entity: Entity::from_bits(2), + actor_label: "Floor".into(), + detail: "Surface".into(), + }, + ], + dependent_instances: 1, + error: None, + }; + let mut state = MaterialLibraryState { + filter: MaterialLibraryFilter::Used, + ..Default::default() + }; + assert!(row_matches(&base, &state)); + state.filter = MaterialLibraryFilter::Instances; + assert!(!row_matches(&base, &state)); + state.search = "steel".into(); + state.filter = MaterialLibraryFilter::All; + assert!(row_matches(&base, &state)); + } + + #[test] + fn catalog_refresh_classifies_resolved_material_instances_once_assets_change() { + let root = std::env::temp_dir().join(format!( + "blacksite-material-library-cache-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let base_path = root.join("base.ron"); + let instance_path = root.join("instance.ron"); + let base = MaterialAsset { + schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION, + label: "Base".into(), + shader: None, + shader_ref: None, + render_state: Default::default(), + material: MaterialDesc::default(), + }; + std::fs::write( + &base_path, + ron::ser::to_string_pretty(&base, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + let instance = MaterialInstanceAsset { + schema_version: shared::MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "Instance".into(), + base: MaterialRef::new( + EditorAssetRef::new("base", "material:source", "Base") + .with_source_path(base_path.to_string_lossy().into_owned()), + ), + parameters: Vec::new(), + textures: Vec::new(), + }; + std::fs::write( + &instance_path, + ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + let material_asset = |label: &str, path: &Path| EditorAsset { + label: label.into(), + path: Some(path.to_string_lossy().into_owned()), + folder_path: root.to_string_lossy().into_owned(), + kind: EditorAssetKind::Material, + }; + let mut app = App::new(); + app.insert_resource(EditorAssets { + folders: Vec::new(), + assets: vec![ + material_asset("Base", &base_path), + material_asset("Instance", &instance_path), + ], + current_folder: root.to_string_lossy().into_owned(), + selected: None, + dragging: None, + status: String::new(), + }) + .init_resource::() + .add_systems(Update, refresh_material_library_catalog); + + app.update(); + + let catalog = app.world().resource::(); + assert_eq!(catalog.documents.len(), 2); + let cached_instance = catalog + .documents + .iter() + .find(|document| document.kind == MaterialDocumentKind::Instance) + .unwrap(); + assert_eq!( + cached_instance.base_path.as_deref(), + Some(normalize_path(&base_path.to_string_lossy()).as_str()) + ); + assert!(cached_instance.error.is_none()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index c50dd18..00770df 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -13,6 +13,7 @@ pub mod hierarchy_ops; pub mod hierarchy_state; pub(crate) mod inspector; mod layout; +mod material_library; mod menu; pub(crate) mod navigation_inspector; mod play_controls; @@ -75,6 +76,9 @@ pub struct UiState { last_mode_tab: Option, } +#[derive(Resource, Default)] +struct DockTabRequest(Option); + pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool { ctx.egui_wants_keyboard_input() } @@ -187,6 +191,10 @@ impl UiState { .show_inside(root_ui, &mut viewer); }); + if let Some(tab) = world.resource_mut::().0.take() { + dock_tabs::open_and_focus_tab(&mut self.dock_state, tab, &self.panel_nodes); + } + self.panel_nodes = PanelNodes::discover(&self.dock_state, self.panel_nodes); mark_layout_dirty_if_changed( @@ -283,6 +291,7 @@ pub enum EditorTab { Inspector, Toolbar, AssetBrowser, + MaterialLibrary, Build, } @@ -298,6 +307,9 @@ impl Plugin for EditorUiPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -315,6 +327,10 @@ impl Plugin for EditorUiPlugin { apply_ui_state_from_prefs .after(crate::project_io::load_user_preferences_on_startup), ) + .add_systems( + EguiPrimaryContextPass, + material_library::refresh_material_library_catalog.before(show_ui_system), + ) .add_systems(EguiPrimaryContextPass, show_ui_system) .add_systems( Update, @@ -421,6 +437,9 @@ impl egui_dock::TabViewer for TabViewer<'_> { EditorTab::AssetBrowser => { asset_browser::asset_browser_ui(self.world, ui, self.selected_entities); } + EditorTab::MaterialLibrary => { + material_library::material_library_ui(self.world, ui, self.selected_entities); + } EditorTab::Build => build::build_ui(self.world, ui), } } @@ -432,6 +451,7 @@ impl egui_dock::TabViewer for TabViewer<'_> { EditorTab::Inspector => (icons::SLIDERS, "Inspector"), EditorTab::Toolbar => (icons::WRENCH, "Toolbar"), EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"), + EditorTab::MaterialLibrary => (icons::PALETTE, "Material Library"), EditorTab::Build => (icons::HAMMER, "Build"), }; tab_title(icon, label) diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index 4c913a0..39a3519 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -7,8 +7,8 @@ use egui_phosphor_icons::icons; use transform_gizmo_bevy::prelude::GizmoOptions; use crate::assets::operators::{ - apply_material_operator, apply_texture_operator, assign_animation_clip_operator, - assign_audio_clip_operator, place_asset_operator, place_subasset_operator, + assign_animation_clip_operator, assign_audio_clip_operator, place_asset_operator, + place_subasset_operator, }; use crate::assets::thumbnails::kind_icon; use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets}; @@ -21,8 +21,8 @@ use crate::viewport::actor_icons::ActorIconSettings; use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection}; use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState}; use crate::viewport::{ - snap_translation, viewport_ground_position, EditorViewportMode, ViewportDisplayMode, - ViewportSettings, + material_drop::is_surface_asset_selection, snap_translation, viewport_ground_position, + EditorViewportMode, MaterialDropState, ViewportDisplayMode, ViewportSettings, }; use super::helpers::{ @@ -30,7 +30,8 @@ use super::helpers::{ }; use super::theme::{ overlay_chip_frame, viewport_toolbar_frame, ACCENT, ACCENT_HOVER, AXIS_X, AXIS_Y, AXIS_Z, - BORDER, BORDER_STRONG, ELEVATED_BG, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, WARNING, + BORDER, BORDER_STRONG, ELEVATED_BG, ERROR, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, + WARNING, }; use super::widgets::{ icon_button_small, panel_toolbar_row, phosphor_icon, tool_button, tool_button_accent, @@ -69,9 +70,17 @@ pub fn viewport_tab_ui( let clean_game_view = world.resource::().clean_game_view; let response = ui.allocate_rect(rect, egui::Sense::HOVER); - *viewport_pointer_pos = response - .interact_pointer_pos() - .or_else(|| response.hover_pos()) + // A drag source owns egui's interaction response, so `Response::hover_pos()` + // is intentionally empty while an asset is dragged over the viewport. The + // global pointer position remains available and is the correct input for + // drag previews and exact surface targeting. + *viewport_pointer_pos = ui + .input(|input| { + input + .pointer + .interact_pos() + .or_else(|| input.pointer.hover_pos()) + }) .filter(|pos| rect.contains(*pos)); *pointer_in_viewport = viewport_pointer_pos.is_some(); @@ -820,11 +829,44 @@ fn viewport_asset_drop_ui( .resource::() .dragging_selection() .cloned(); - let drag_descriptor = drag_selection + let surface_drag = drag_selection + .as_ref() + .is_some_and(|selection| is_surface_asset_selection(world, selection)); + let mut drag_descriptor = drag_selection .as_ref() .and_then(|selection| asset_drag_descriptor(world, selection, selected)); let over_viewport = pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)); + let surface_feedback = surface_drag + .then(|| world.get_resource::()) + .flatten() + .and_then(MaterialDropState::feedback) + .cloned(); + if surface_drag && over_viewport { + if let Some((_, _, action)) = drag_descriptor.as_mut() { + *action = surface_feedback + .as_ref() + .map(|feedback| feedback.action.clone()) + .unwrap_or_else(|| "Finding an assignable surface".into()); + } + } + let drop_valid = (surface_drag && over_viewport) + .then(|| surface_feedback.as_ref().map(|feedback| feedback.valid)) + .flatten(); + let highlight_color = match drop_valid { + Some(true) => SUCCESS, + Some(false) => ERROR, + None => ACCENT_HOVER, + }; + let drag_visual_state = if !over_viewport { + AssetDragVisualState::Outside + } else { + match drop_valid { + Some(true) => AssetDragVisualState::Valid, + Some(false) => AssetDragVisualState::Invalid, + None => AssetDragVisualState::Targeted, + } + }; if let (Some(pointer), Some((icon, label, action))) = (pointer_pos, drag_descriptor.as_ref()) { draw_asset_drag_preview( ui, @@ -833,7 +875,7 @@ fn viewport_asset_drop_ui( *icon, label, action, - over_viewport, + drag_visual_state, ); } @@ -841,7 +883,7 @@ fn viewport_asset_drop_ui( ui.painter().rect_stroke( viewport_rect.shrink(2.0), 0.0, - egui::Stroke::new(2.0, ACCENT_HOVER), + egui::Stroke::new(2.0, highlight_color), egui::StrokeKind::Inside, ); } @@ -851,6 +893,11 @@ fn viewport_asset_drop_ui( return; } + if surface_drag && over_viewport { + // The Last-schedule surface-drop session owns exact hit resolution and commit. + return; + } + if !pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)) { world.resource_mut::().clear_drag(); return; @@ -903,26 +950,6 @@ fn viewport_asset_drop_ui( return; } - if let AssetSelection::SubAsset { - kind: AssetSubAssetKind::Texture, - source_path: Some(path), - label, - .. - } = &selection - { - let asset = crate::assets::EditorAsset { - label: label.clone(), - path: Some(path.clone()), - folder_path: std::path::Path::new(path) - .parent() - .map(|parent| parent.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|| crate::assets::ASSETS_ROOT.to_string()), - kind: crate::assets::EditorAssetKind::Texture, - }; - apply_texture_operator(world, asset, selected); - return; - } - let asset = { let assets = world.resource::(); assets.asset_for_selection(&selection).cloned() @@ -943,18 +970,6 @@ fn viewport_asset_drop_ui( } return; } - match asset.kind { - crate::assets::EditorAssetKind::Texture => { - apply_texture_operator(world, asset, selected); - return; - } - crate::assets::EditorAssetKind::Material => { - apply_material_operator(world, asset, selected); - return; - } - _ => {} - } - place_asset_operator(world, asset, placement); } @@ -996,11 +1011,11 @@ fn asset_drag_descriptor( AssetSelection::SubAsset { kind: AssetSubAssetKind::Texture, .. - } if !selected.is_empty() => "Apply texture to selection", + } => "Drop onto a primitive or brush face", AssetSelection::SubAsset { kind: AssetSubAssetKind::Material, .. - } if !selected.is_empty() => "Apply material to selection", + } => "Drop onto an exact renderer slot", AssetSelection::SubAsset { kind: AssetSubAssetKind::Skeleton, .. @@ -1020,12 +1035,8 @@ fn asset_drag_descriptor( .. } => "Create animated actor", _ => match asset.kind { - crate::assets::EditorAssetKind::Texture if !selected.is_empty() => { - "Apply texture to selection" - } - crate::assets::EditorAssetKind::Material if !selected.is_empty() => { - "Apply material to selection" - } + crate::assets::EditorAssetKind::Texture => "Drop onto a primitive or brush face", + crate::assets::EditorAssetKind::Material => "Drop onto a renderable surface", crate::assets::EditorAssetKind::AudioClip if selected .as_slice() @@ -1041,6 +1052,29 @@ fn asset_drag_descriptor( Some((icon, label, action.to_string())) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AssetDragVisualState { + Outside, + Targeted, + Valid, + Invalid, +} + +impl AssetDragVisualState { + fn over_viewport(self) -> bool { + self != Self::Outside + } + + fn color(self) -> egui::Color32 { + match self { + Self::Outside => TEXT_DIM, + Self::Targeted => ACCENT_HOVER, + Self::Valid => SUCCESS, + Self::Invalid => ERROR, + } + } +} + fn draw_asset_drag_preview( ui: &egui::Ui, viewport_rect: egui::Rect, @@ -1048,39 +1082,41 @@ fn draw_asset_drag_preview( icon: egui_phosphor_icons::Icon, label: &str, action: &str, - over_viewport: bool, + visual_state: AssetDragVisualState, ) { + let over_viewport = visual_state.over_viewport(); + let highlight_color = visual_state.color(); if over_viewport { let painter = ui.painter(); - painter.circle_filled(pointer, 3.0, ACCENT_HOVER); - painter.circle_stroke(pointer, 11.0, egui::Stroke::new(1.5, ACCENT_HOVER)); + painter.circle_filled(pointer, 3.0, highlight_color); + painter.circle_stroke(pointer, 11.0, egui::Stroke::new(1.5, highlight_color)); painter.line_segment( [ pointer - egui::vec2(17.0, 0.0), pointer - egui::vec2(7.0, 0.0), ], - egui::Stroke::new(1.5, ACCENT_HOVER), + egui::Stroke::new(1.5, highlight_color), ); painter.line_segment( [ pointer + egui::vec2(7.0, 0.0), pointer + egui::vec2(17.0, 0.0), ], - egui::Stroke::new(1.5, ACCENT_HOVER), + egui::Stroke::new(1.5, highlight_color), ); painter.line_segment( [ pointer - egui::vec2(0.0, 17.0), pointer - egui::vec2(0.0, 7.0), ], - egui::Stroke::new(1.5, ACCENT_HOVER), + egui::Stroke::new(1.5, highlight_color), ); painter.line_segment( [ pointer + egui::vec2(0.0, 7.0), pointer + egui::vec2(0.0, 17.0), ], - egui::Stroke::new(1.5, ACCENT_HOVER), + egui::Stroke::new(1.5, highlight_color), ); } @@ -1101,7 +1137,7 @@ fn draw_asset_drag_preview( ui.set_width(228.0); ui.horizontal(|ui| { ui.label(phosphor_icon(icon, 20.0).color(if over_viewport { - ACCENT_HOVER + highlight_color } else { TEXT_DIM })); @@ -1116,7 +1152,7 @@ fn draw_asset_drag_preview( }) .size(11.0) .color(if over_viewport { - ACCENT_HOVER + highlight_color } else { TEXT_MUTED }), @@ -1152,9 +1188,9 @@ fn draw_asset_drag_preview( ), ] { ui.painter() - .line_segment([a, a + b], egui::Stroke::new(3.0, ACCENT_HOVER)); + .line_segment([a, a + b], egui::Stroke::new(3.0, highlight_color)); ui.painter() - .line_segment([a, a + c], egui::Stroke::new(3.0, ACCENT_HOVER)); + .line_segment([a, a + c], egui::Stroke::new(3.0, highlight_color)); } } } diff --git a/crates/editor/src/viewport/brush_edit.rs b/crates/editor/src/viewport/brush_edit.rs index 0d60978..821dd66 100644 --- a/crates/editor/src/viewport/brush_edit.rs +++ b/crates/editor/src/viewport/brush_edit.rs @@ -1047,6 +1047,17 @@ fn pick_face( best } +pub(crate) fn pick_brush_face_id( + brush: &BrushDesc, + transform: &GlobalTransform, + ray: Ray3d, +) -> Option<(ComponentInstanceId, f32)> { + let (BrushElementKey::Face { face }, distance) = pick_face(brush, transform, ray)? else { + return None; + }; + Some((face, distance)) +} + fn world_vertices(face: &BrushFaceDesc, transform: &GlobalTransform) -> Vec { face.vertices .iter() diff --git a/crates/editor/src/viewport/material_drop.rs b/crates/editor/src/viewport/material_drop.rs new file mode 100644 index 0000000..69cf5b8 --- /dev/null +++ b/crates/editor/src/viewport/material_drop.rs @@ -0,0 +1,1310 @@ +//! Exact viewport targeting and reversible preview for material and texture drops. + +use bevy::ecs::system::SystemParam; +use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility}; +use bevy::prelude::*; +use shared::{ + BrushDesc, ComponentInstanceId, EditorAssetRef, HydratedRendererMaterialBinding, LevelObject, + MaterialDesc, MaterialInstanceAsset, MaterialRef, Primitive, SkinnedMeshRenderer, + StaticMeshRenderer, +}; + +use crate::asset_db::{find_asset_by_path, AssetRegistry}; +use crate::assets::materials::ensure_static_material_slots; +use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssetKind, EditorAssets}; +use crate::camera::EditorCamera; +use crate::history::{ + reflected_component_transaction, set_brush_with_history, set_material_with_history, +}; +use crate::infra::EditorOnly; +use crate::operators::{ + run_immediate_operator, ActiveOperator, OperatorAvailability, OperatorPhase, OperatorStatus, +}; +use crate::scene_io::SceneIo; +use crate::selection::ViewportClick; +use crate::ui::selection_ops::is_mutable_level_object; +use crate::ui::UiState; +use crate::viewport::brush_edit::pick_brush_face_id; +use crate::viewport::{scene_view_ray, ViewportDisplayMode}; + +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MaterialDropSet; + +pub struct MaterialDropPlugin; + +impl Plugin for MaterialDropPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Last, resolve_material_drop_target.in_set(MaterialDropSet)) + .add_systems( + Last, + update_material_drop_session + .after(resolve_material_drop_target) + .in_set(MaterialDropSet), + ) + .add_systems(Update, draw_material_drop_target); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterialDropFeedback { + pub valid: bool, + pub action: String, + pub target: String, +} + +#[derive(Resource, Debug, Default)] +pub struct MaterialDropState { + resolved: Option, + preview: Option, + feedback: Option, + captures_viewport_input: bool, + cancel_consumed: bool, +} + +impl MaterialDropState { + pub fn feedback(&self) -> Option<&MaterialDropFeedback> { + self.feedback.as_ref() + } + + pub fn captures_viewport_input(&self) -> bool { + self.captures_viewport_input || self.cancel_consumed + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RendererKind { + Static, + Skinned, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MaterialDropTargetKey { + RendererSlot { + owner: Entity, + slot_id: ComponentInstanceId, + renderer: RendererKind, + }, + Primitive(Entity), + BrushFace { + owner: Entity, + face_id: ComponentInstanceId, + }, +} + +#[derive(Debug, Clone)] +enum MaterialDropTarget { + RendererSlot { + owner: Entity, + owner_label: String, + slot_id: ComponentInstanceId, + slot_label: String, + renderer: RendererKind, + hit_point: Vec3, + hit_normal: Vec3, + }, + Primitive { + owner: Entity, + owner_label: String, + hit_point: Vec3, + hit_normal: Vec3, + }, + BrushFace { + owner: Entity, + owner_label: String, + face_id: ComponentInstanceId, + hit_point: Vec3, + hit_normal: Vec3, + }, + Invalid { + reason: String, + hit_point: Option, + hit_normal: Option, + }, +} + +impl MaterialDropTarget { + fn key(&self) -> Option { + match self { + Self::RendererSlot { + owner, + slot_id, + renderer, + .. + } => Some(MaterialDropTargetKey::RendererSlot { + owner: *owner, + slot_id: slot_id.clone(), + renderer: *renderer, + }), + Self::Primitive { owner, .. } => Some(MaterialDropTargetKey::Primitive(*owner)), + Self::BrushFace { owner, face_id, .. } => Some(MaterialDropTargetKey::BrushFace { + owner: *owner, + face_id: face_id.clone(), + }), + Self::Invalid { .. } => None, + } + } + + fn label(&self) -> String { + match self { + Self::RendererSlot { + owner_label, + slot_label, + .. + } => format!("{owner_label} / {slot_label}"), + Self::Primitive { owner_label, .. } => format!("{owner_label} / Surface"), + Self::BrushFace { + owner_label, + face_id, + .. + } => format!("{owner_label} / {}", face_id.0), + Self::Invalid { reason, .. } => reason.clone(), + } + } + + fn hit(&self) -> Option<(Vec3, Vec3)> { + match self { + Self::RendererSlot { + hit_point, + hit_normal, + .. + } + | Self::Primitive { + hit_point, + hit_normal, + .. + } + | Self::BrushFace { + hit_point, + hit_normal, + .. + } => Some((*hit_point, *hit_normal)), + Self::Invalid { + hit_point, + hit_normal, + .. + } => hit_point.zip(*hit_normal), + } + } +} + +#[derive(Debug, Clone)] +enum SurfaceDropPayload { + Material { + label: String, + reference: MaterialRef, + resolved: Option>, + }, + Texture { + label: String, + reference: EditorAssetRef, + source_path: String, + }, +} + +impl SurfaceDropPayload { + fn label(&self) -> &str { + match self { + Self::Material { label, .. } | Self::Texture { label, .. } => label, + } + } + + fn kind_label(&self) -> &'static str { + match self { + Self::Material { .. } => "material", + Self::Texture { .. } => "texture", + } + } +} + +#[derive(Debug)] +struct MaterialDropPreview { + selection: AssetSelection, + target_key: MaterialDropTargetKey, + label: String, + snapshot: MaterialDropSnapshot, +} + +#[derive(Debug)] +enum MaterialDropSnapshot { + StaticRenderer { + entity: Entity, + before: StaticMeshRenderer, + after: StaticMeshRenderer, + }, + SkinnedRenderer { + entity: Entity, + before: SkinnedMeshRenderer, + after: SkinnedMeshRenderer, + }, + Material { + entity: Entity, + before: Option>, + after: Box, + }, + Brush { + entity: Entity, + before: BrushDesc, + after: BrushDesc, + }, +} + +impl MaterialDropSnapshot { + fn apply_preview(&self, world: &mut World) -> Result<(), String> { + let mut entity = world + .get_entity_mut(self.entity()) + .map_err(|_| "drop target no longer exists".to_string())?; + match self { + Self::StaticRenderer { after, .. } => { + entity.insert(after.clone()); + } + Self::SkinnedRenderer { after, .. } => { + entity.insert(after.clone()); + } + Self::Material { after, .. } => { + entity.insert(after.as_ref().clone()); + } + Self::Brush { after, .. } => { + entity.insert(after.clone()); + } + } + Ok(()) + } + + fn restore(&self, world: &mut World) { + let Ok(mut entity) = world.get_entity_mut(self.entity()) else { + return; + }; + match self { + Self::StaticRenderer { before, .. } => { + entity.insert(before.clone()); + } + Self::SkinnedRenderer { before, .. } => { + entity.insert(before.clone()); + } + Self::Material { before, .. } => { + if let Some(before) = before { + entity.insert(before.as_ref().clone()); + } else { + entity.remove::(); + } + } + Self::Brush { before, .. } => { + entity.insert(before.clone()); + } + } + } + + fn entity(&self) -> Entity { + match self { + Self::StaticRenderer { entity, .. } + | Self::SkinnedRenderer { entity, .. } + | Self::Material { entity, .. } + | Self::Brush { entity, .. } => *entity, + } + } +} + +#[derive(SystemParam)] +struct MaterialDropQueries<'w, 's> { + cameras: Query<'w, 's, (&'static Camera, &'static GlobalTransform), With>, + bindings: Query<'w, 's, &'static HydratedRendererMaterialBinding>, + levels: Query<'w, 's, (), With>, + parents: Query<'w, 's, &'static ChildOf>, + editor_only: Query<'w, 's, (), With>, + brushes: Query<'w, 's, (&'static BrushDesc, &'static GlobalTransform)>, + primitives: Query<'w, 's, (), With>, + static_renderers: Query<'w, 's, &'static StaticMeshRenderer>, + skinned_renderers: Query<'w, 's, &'static SkinnedMeshRenderer>, + names: Query<'w, 's, &'static Name>, +} + +fn resolve_material_drop_target( + assets: Res, + ui_state: Res, + display: Res, + mut state: ResMut, + mut mesh_ray_cast: MeshRayCast, + queries: MaterialDropQueries, +) { + state.cancel_consumed = false; + let Some(selection) = assets.dragging_selection() else { + state.resolved = None; + return; + }; + if !is_surface_asset_selection_from_assets(&assets, selection) + || display.clean_game_view + || !ui_state.pointer_in_viewport + { + state.resolved = None; + return; + } + let Some(pointer_pos) = ui_state.viewport_pointer_pos else { + state.resolved = None; + return; + }; + let Some((camera, transform)) = queries + .cameras + .iter() + .find(|(camera, _)| camera.is_active) + .or_else(|| queries.cameras.iter().next()) + else { + state.resolved = Some(MaterialDropTarget::Invalid { + reason: "No active editor camera".into(), + hit_point: None, + hit_normal: None, + }); + return; + }; + let Some(ray) = scene_view_ray(camera, transform, pointer_pos, ui_state.viewport_rect) else { + state.resolved = None; + return; + }; + let filter = |entity| queries.editor_only.get(entity).is_err(); + let settings = MeshRayCastSettings::default() + .with_visibility(RayCastVisibility::Any) + .with_filter(&filter) + .never_early_exit(); + let hits = mesh_ray_cast.cast_ray(ray, &settings); + + for (hit_entity, hit) in hits { + if let Ok(binding) = queries.bindings.get(*hit_entity) { + let renderer = if queries.static_renderers.get(binding.owner).is_ok() { + RendererKind::Static + } else if queries.skinned_renderers.get(binding.owner).is_ok() { + RendererKind::Skinned + } else { + continue; + }; + let owner_label = entity_label(binding.owner, &queries.names); + let slot_label = renderer_slot_label( + binding.owner, + &binding.slot_id, + renderer, + &queries.static_renderers, + &queries.skinned_renderers, + ); + state.resolved = Some(MaterialDropTarget::RendererSlot { + owner: binding.owner, + owner_label, + slot_id: binding.slot_id.clone(), + slot_label, + renderer, + hit_point: hit.point, + hit_normal: hit.normal.normalize_or_zero(), + }); + return; + } + + let Some(owner) = authored_owner(*hit_entity, &queries.levels, &queries.parents) else { + continue; + }; + let owner_label = entity_label(owner, &queries.names); + if let Ok((brush, transform)) = queries.brushes.get(owner) { + let Some((face_id, _)) = pick_brush_face_id(brush, transform, ray) else { + state.resolved = Some(MaterialDropTarget::Invalid { + reason: format!("No brush face under pointer on {owner_label}"), + hit_point: Some(hit.point), + hit_normal: Some(hit.normal.normalize_or_zero()), + }); + return; + }; + state.resolved = Some(MaterialDropTarget::BrushFace { + owner, + owner_label, + face_id, + hit_point: hit.point, + hit_normal: hit.normal.normalize_or_zero(), + }); + return; + } + if queries.primitives.get(owner).is_ok() { + state.resolved = Some(MaterialDropTarget::Primitive { + owner, + owner_label, + hit_point: hit.point, + hit_normal: hit.normal.normalize_or_zero(), + }); + return; + } + + state.resolved = Some(MaterialDropTarget::Invalid { + reason: format!("{owner_label} has no assignable surface"), + hit_point: Some(hit.point), + hit_normal: Some(hit.normal.normalize_or_zero()), + }); + return; + } + + state.resolved = Some(MaterialDropTarget::Invalid { + reason: "No renderable surface under pointer".into(), + hit_point: None, + hit_normal: None, + }); +} + +fn authored_owner( + entity: Entity, + levels: &Query<(), With>, + parents: &Query<&ChildOf>, +) -> Option { + let mut current = entity; + for _ in 0..64 { + if levels.get(current).is_ok() { + return Some(current); + } + current = parents.get(current).ok()?.parent(); + } + None +} + +fn entity_label(entity: Entity, names: &Query<&Name>) -> String { + names + .get(entity) + .map(|name| name.as_str().to_string()) + .unwrap_or_else(|_| format!("Actor {entity:?}")) +} + +fn renderer_slot_label( + owner: Entity, + slot_id: &ComponentInstanceId, + renderer: RendererKind, + static_renderers: &Query<&StaticMeshRenderer>, + skinned_renderers: &Query<&SkinnedMeshRenderer>, +) -> String { + let label = match renderer { + RendererKind::Static => static_renderers + .get(owner) + .ok() + .and_then(|renderer| renderer.materials.slot(slot_id)) + .map(|slot| slot.name.as_str()), + RendererKind::Skinned => skinned_renderers + .get(owner) + .ok() + .and_then(|renderer| renderer.materials.slot(slot_id)) + .map(|slot| slot.name.as_str()), + }; + label + .filter(|label| !label.trim().is_empty()) + .unwrap_or(&slot_id.0) + .to_string() +} + +pub fn is_surface_asset_selection(world: &World, selection: &AssetSelection) -> bool { + world + .get_resource::() + .is_some_and(|assets| is_surface_asset_selection_from_assets(assets, selection)) +} + +fn is_surface_asset_selection_from_assets( + assets: &EditorAssets, + selection: &AssetSelection, +) -> bool { + match selection { + AssetSelection::SubAsset { + kind: AssetSubAssetKind::Material | AssetSubAssetKind::Texture, + .. + } => true, + AssetSelection::File(_) | AssetSelection::Builtin(_) => { + assets.asset_for_selection(selection).is_some_and(|asset| { + matches!( + asset.kind, + EditorAssetKind::Material | EditorAssetKind::Texture + ) + }) + } + _ => false, + } +} + +fn surface_drop_payload( + world: &World, + selection: &AssetSelection, +) -> Result { + let assets = world + .get_resource::() + .ok_or_else(|| "asset catalog is unavailable".to_string())?; + let registry = world + .get_resource::() + .ok_or_else(|| "asset registry is unavailable".to_string())?; + match selection { + AssetSelection::File(path) => { + let asset = assets + .asset_for_selection(selection) + .ok_or_else(|| format!("asset is no longer in the catalog: {path}"))?; + let record = find_asset_by_path(registry, path) + .ok_or_else(|| format!("asset is missing from the registry: {path}"))?; + match asset.kind { + EditorAssetKind::Material => { + let sub_asset_id = if MaterialInstanceAsset::load_from_path(path).is_ok() { + "material:instance" + } else { + "material:source" + }; + let reference = MaterialRef::new( + EditorAssetRef::new( + record.id.as_string(), + sub_asset_id, + asset.label.clone(), + ) + .with_source_path(path.clone()), + ); + let (resolved, _) = shared::load_resolved_material_from_path(path)?; + Ok(SurfaceDropPayload::Material { + label: asset.label.clone(), + reference, + resolved: Some(Box::new(resolved)), + }) + } + EditorAssetKind::Texture => Ok(SurfaceDropPayload::Texture { + label: asset.label.clone(), + reference: EditorAssetRef::new( + record.id.as_string(), + "texture:source", + asset.label.clone(), + ) + .with_source_path(path.clone()), + source_path: path.clone(), + }), + _ => Err(format!("{} is not a material or texture", asset.label)), + } + } + AssetSelection::SubAsset { + parent_path, + sub_asset_id, + label, + kind: AssetSubAssetKind::Material, + .. + } => { + let record = find_asset_by_path(registry, parent_path).ok_or_else(|| { + format!("parent asset is missing from the registry: {parent_path}") + })?; + Ok(SurfaceDropPayload::Material { + label: label.clone(), + reference: MaterialRef::new(EditorAssetRef::new( + record.id.as_string(), + sub_asset_id.clone(), + label.clone(), + )), + resolved: None, + }) + } + AssetSelection::SubAsset { + parent_path, + sub_asset_id, + label, + kind: AssetSubAssetKind::Texture, + source_path, + } => { + let record = find_asset_by_path(registry, parent_path).ok_or_else(|| { + format!("parent asset is missing from the registry: {parent_path}") + })?; + let source_path = source_path + .clone() + .ok_or_else(|| format!("texture {label} has no source path"))?; + Ok(SurfaceDropPayload::Texture { + label: label.clone(), + reference: EditorAssetRef::new( + record.id.as_string(), + sub_asset_id.clone(), + label.clone(), + ) + .with_source_path(source_path.clone()), + source_path, + }) + } + _ => Err("selection is not a material or texture".into()), + } +} + +fn begin_material_drop_preview( + world: &mut World, + selection: AssetSelection, + target: &MaterialDropTarget, + payload: &SurfaceDropPayload, +) -> Result { + let target_key = target.key().ok_or_else(|| target.label())?; + let entity = match &target_key { + MaterialDropTargetKey::RendererSlot { owner, .. } + | MaterialDropTargetKey::Primitive(owner) + | MaterialDropTargetKey::BrushFace { owner, .. } => *owner, + }; + if !is_mutable_level_object(world, entity) { + return Err("Target actor is locked, linked, or read-only".into()); + } + + let snapshot = match &target_key { + MaterialDropTargetKey::RendererSlot { + owner, + slot_id, + renderer: RendererKind::Static, + } => { + let SurfaceDropPayload::Material { reference, .. } = payload else { + return Err("Textures cannot be assigned directly to renderer slots; create or edit a Material Instance".into()); + }; + let before = world + .get::(*owner) + .cloned() + .ok_or_else(|| "static renderer no longer exists".to_string())?; + let mut after = before.clone(); + ensure_static_material_slots(&mut after); + let slot = after + .materials + .slot_mut(slot_id) + .ok_or_else(|| format!("renderer slot {} no longer exists", slot_id.0))?; + slot.material = Some(reference.clone()); + MaterialDropSnapshot::StaticRenderer { + entity: *owner, + before, + after, + } + } + MaterialDropTargetKey::RendererSlot { + owner, + slot_id, + renderer: RendererKind::Skinned, + } => { + let SurfaceDropPayload::Material { reference, .. } = payload else { + return Err("Textures cannot be assigned directly to renderer slots; create or edit a Material Instance".into()); + }; + let before = world + .get::(*owner) + .cloned() + .ok_or_else(|| "skinned renderer no longer exists".to_string())?; + let mut after = before.clone(); + let slot = after + .materials + .slot_mut(slot_id) + .ok_or_else(|| format!("renderer slot {} no longer exists", slot_id.0))?; + slot.material = Some(reference.clone()); + MaterialDropSnapshot::SkinnedRenderer { + entity: *owner, + before, + after, + } + } + MaterialDropTargetKey::Primitive(owner) => { + if world.get::(*owner).is_none() { + return Err("primitive target no longer exists".into()); + } + let before = world.get::(*owner).cloned(); + let after = material_desc_for_surface(before.clone(), payload)?; + MaterialDropSnapshot::Material { + entity: *owner, + before: before.map(Box::new), + after: Box::new(after), + } + } + MaterialDropTargetKey::BrushFace { owner, face_id } => { + let before = world + .get::(*owner) + .cloned() + .ok_or_else(|| "brush target no longer exists".to_string())?; + let mut after = before.clone(); + let face = after + .faces + .iter_mut() + .find(|face| &face.id == face_id) + .ok_or_else(|| format!("brush face {} no longer exists", face_id.0))?; + match payload { + SurfaceDropPayload::Material { + reference, + resolved, + .. + } => { + if resolved.is_none() || reference.0.source_path.is_none() { + return Err( + "Embedded source materials can only be assigned to renderer slots" + .into(), + ); + } + face.material = Some(reference.0.clone()); + } + SurfaceDropPayload::Texture { reference, .. } => { + face.texture = Some(reference.clone()); + } + } + MaterialDropSnapshot::Brush { + entity: *owner, + before, + after, + } + } + }; + snapshot.apply_preview(world)?; + Ok(MaterialDropPreview { + selection, + target_key, + label: format!("Assign {} to {}", payload.label(), target.label()), + snapshot, + }) +} + +fn material_desc_for_surface( + before: Option, + payload: &SurfaceDropPayload, +) -> Result { + match payload { + SurfaceDropPayload::Material { + resolved: Some(desc), + .. + } => Ok(desc.as_ref().clone()), + SurfaceDropPayload::Material { resolved: None, .. } => { + Err("Embedded source materials can only be assigned to renderer slots".into()) + } + SurfaceDropPayload::Texture { source_path, .. } => { + let mut material = before.unwrap_or_default(); + material.base_color_texture = Some(source_path.clone()); + Ok(material) + } + } +} + +fn update_material_drop_session(world: &mut World) { + let mut state = world + .remove_resource::() + .unwrap_or_default(); + let selection = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()) + .filter(|selection| is_surface_asset_selection(world, selection)); + let primary_released = world + .get_resource::>() + .is_some_and(|buttons| buttons.just_released(MouseButton::Left)); + let cancel_requested = world + .get_resource::>() + .is_some_and(|keys| keys.just_pressed(KeyCode::Escape)) + || world + .get_resource::>() + .is_some_and(|buttons| buttons.just_pressed(MouseButton::Right)); + + if cancel_requested && (selection.is_some() || state.preview.is_some()) { + restore_material_drop_preview(world, state.preview.take()); + clear_drag_and_viewport_click(world); + state.feedback = None; + state.captures_viewport_input = false; + state.cancel_consumed = true; + set_drop_operator_status( + world, + OperatorPhase::Canceled, + "Surface assignment canceled", + ); + world.insert_resource(state); + return; + } + + let Some(selection) = selection else { + let had_preview = state.preview.is_some(); + restore_material_drop_preview(world, state.preview.take()); + state.feedback = None; + state.captures_viewport_input = false; + if had_preview { + set_drop_operator_status( + world, + OperatorPhase::Canceled, + "Surface assignment canceled", + ); + } + world.insert_resource(state); + return; + }; + state.captures_viewport_input = true; + + let Some(target) = state.resolved.clone() else { + restore_material_drop_preview(world, state.preview.take()); + state.feedback = None; + if primary_released { + clear_drag_and_viewport_click(world); + state.captures_viewport_input = false; + } + world.insert_resource(state); + return; + }; + + let payload = match surface_drop_payload(world, &selection) { + Ok(payload) => payload, + Err(error) => { + restore_material_drop_preview(world, state.preview.take()); + state.feedback = Some(MaterialDropFeedback { + valid: false, + action: error.clone(), + target: target.label(), + }); + set_drop_operator_status(world, OperatorPhase::Blocked, error); + if primary_released { + clear_drag_and_viewport_click(world); + state.captures_viewport_input = false; + } + world.insert_resource(state); + return; + } + }; + + let target_key = target.key(); + let preview_matches = state.preview.as_ref().is_some_and(|preview| { + preview.selection == selection && Some(&preview.target_key) == target_key.as_ref() + }); + if !preview_matches { + restore_material_drop_preview(world, state.preview.take()); + match begin_material_drop_preview(world, selection.clone(), &target, &payload) { + Ok(preview) => state.preview = Some(preview), + Err(error) => { + state.feedback = Some(MaterialDropFeedback { + valid: false, + action: error.clone(), + target: target.label(), + }); + set_drop_operator_status(world, OperatorPhase::Blocked, error); + if primary_released { + clear_drag_and_viewport_click(world); + state.captures_viewport_input = false; + } + world.insert_resource(state); + return; + } + } + } + + state.feedback = Some(MaterialDropFeedback { + valid: true, + action: format!("Assign {} to {}", payload.kind_label(), target.label()), + target: target.label(), + }); + set_drop_operator_status( + world, + OperatorPhase::Preview, + format!("Release to assign {}", payload.kind_label()), + ); + + if primary_released { + let preview = state.preview.take(); + if let Some(preview) = preview.as_ref() { + preview.snapshot.restore(world); + } + if let Some(preview) = preview { + commit_material_drop_preview(world, preview); + } + clear_drag_and_viewport_click(world); + state.captures_viewport_input = false; + } + world.insert_resource(state); +} + +fn restore_material_drop_preview(world: &mut World, preview: Option) { + if let Some(preview) = preview { + preview.snapshot.restore(world); + } +} + +fn commit_material_drop_preview(world: &mut World, preview: MaterialDropPreview) -> bool { + let label = preview.label.clone(); + let snapshot = preview.snapshot; + let committed = run_immediate_operator( + world, + "assets.assign_surface", + &label, + OperatorAvailability::Ready, + move |world| commit_material_drop_snapshot(world, snapshot), + ); + if committed { + if let Some(mut io) = world.get_resource_mut::() { + io.status = label; + } + } + committed +} + +fn commit_material_drop_snapshot( + world: &mut World, + snapshot: MaterialDropSnapshot, +) -> Result<(), String> { + let entity = snapshot.entity(); + if !is_mutable_level_object(world, entity) { + return Err("Target actor is no longer editable".into()); + } + match snapshot { + MaterialDropSnapshot::StaticRenderer { after, .. } => reflected_component_transaction( + world, + entity, + "Assign Renderer Material Slot", + shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER, + shared::COMPONENT_STATIC_MESH_RENDERER, + move |world, entity| { + world.entity_mut(entity).insert(after); + Ok(()) + }, + ), + MaterialDropSnapshot::SkinnedRenderer { after, .. } => reflected_component_transaction( + world, + entity, + "Assign Renderer Material Slot", + shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER, + shared::COMPONENT_SKINNED_MESH_RENDERER, + move |world, entity| { + world.entity_mut(entity).insert(after); + Ok(()) + }, + ), + MaterialDropSnapshot::Material { after, .. } => { + set_material_with_history(world, entity, *after); + Ok(()) + } + MaterialDropSnapshot::Brush { after, .. } => { + set_brush_with_history(world, entity, after); + Ok(()) + } + } +} + +fn clear_drag_and_viewport_click(world: &mut World) { + if let Some(mut assets) = world.get_resource_mut::() { + assets.clear_drag(); + } + if let Some(mut click) = world.get_resource_mut::() { + click.0 = None; + } +} + +fn set_drop_operator_status(world: &mut World, phase: OperatorPhase, hint: impl Into) { + if let Some(mut active) = world.get_resource_mut::() { + active.status = Some(OperatorStatus { + id: "assets.assign_surface".into(), + label: "Assign Surface Asset".into(), + phase, + hint: hint.into(), + warnings: Vec::new(), + }); + } +} + +fn draw_material_drop_target( + state: Res, + brushes: Query<(&BrushDesc, &GlobalTransform)>, + mut gizmos: Gizmos, +) { + if !state.captures_viewport_input { + return; + } + let Some(target) = state.resolved.as_ref() else { + return; + }; + let valid = state + .feedback + .as_ref() + .is_some_and(|feedback| feedback.valid); + let color = if valid { + Color::srgba(0.33, 0.9, 0.58, 0.98) + } else { + Color::srgba(0.95, 0.3, 0.34, 0.98) + }; + if let Some((point, normal)) = target.hit() { + gizmos.sphere(point, 0.09, color); + if normal.length_squared() > 0.001 { + gizmos.line(point, point + normal * 0.42, color); + } + } + let MaterialDropTarget::BrushFace { owner, face_id, .. } = target else { + return; + }; + let Ok((brush, transform)) = brushes.get(*owner) else { + return; + }; + let Some(face) = brush.faces.iter().find(|face| &face.id == face_id) else { + return; + }; + let vertices = face + .vertices + .iter() + .map(|vertex| transform.transform_point(*vertex)) + .collect::>(); + for index in 0..vertices.len() { + gizmos.line( + vertices[index], + vertices[(index + 1) % vertices.len()], + color, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history::{apply_command_undo, EditorHistory}; + use shared::{ActorKind, RendererMaterialSet, RendererMaterialSlot, SharedTypesPlugin}; + + fn test_app() -> App { + let mut app = App::new(); + app.add_plugins(SharedTypesPlugin); + let world = app.world_mut(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + app + } + + fn material_payload(id: &str) -> SurfaceDropPayload { + SurfaceDropPayload::Material { + label: "Steel".into(), + reference: MaterialRef::new( + EditorAssetRef::new(id, "material:source", "Steel") + .with_source_path("assets/materials/steel.ron"), + ), + resolved: Some(Box::new(MaterialDesc { + roughness: 0.2, + material_asset_path: Some("assets/materials/steel.ron".into()), + ..Default::default() + })), + } + } + + #[test] + fn renderer_preview_and_commit_touch_only_the_hit_slot() { + let mut app = test_app(); + let world = app.world_mut(); + let slot_a = ComponentInstanceId::new("slot:a"); + let slot_b = ComponentInstanceId::new("slot:b"); + let entity = world + .spawn(( + LevelObject, + ActorKind::StaticMesh, + StaticMeshRenderer { + slots: Vec::new(), + materials: RendererMaterialSet { + slots: vec![ + RendererMaterialSlot { + id: slot_a.clone(), + name: "A".into(), + source_material: None, + material: None, + }, + RendererMaterialSlot { + id: slot_b.clone(), + name: "B".into(), + source_material: None, + material: None, + }, + ], + orphaned_assignments: Vec::new(), + }, + }, + )) + .id(); + let target = MaterialDropTarget::RendererSlot { + owner: entity, + owner_label: "Crate".into(), + slot_id: slot_b.clone(), + slot_label: "B".into(), + renderer: RendererKind::Static, + hit_point: Vec3::ZERO, + hit_normal: Vec3::Y, + }; + let preview = begin_material_drop_preview( + world, + AssetSelection::File("assets/materials/steel.ron".into()), + &target, + &material_payload("steel"), + ) + .unwrap(); + + let renderer = world.get::(entity).unwrap(); + assert!(renderer.materials.slot(&slot_a).unwrap().material.is_none()); + assert_eq!( + renderer + .materials + .slot(&slot_b) + .unwrap() + .material + .as_ref() + .unwrap() + .0 + .asset_id, + "steel" + ); + assert!(!world.resource::().dirty); + assert_eq!(world.resource::().undo_depth(), 0); + + preview.snapshot.restore(world); + assert!(world + .get::(entity) + .unwrap() + .materials + .slot(&slot_b) + .unwrap() + .material + .is_none()); + assert!(commit_material_drop_preview(world, preview)); + assert_eq!(world.resource::().undo_depth(), 1); + assert!(world.resource::().dirty); + assert!(world + .get::(entity) + .unwrap() + .materials + .slot(&slot_a) + .unwrap() + .material + .is_none()); + + apply_command_undo(world); + assert!(world + .get::(entity) + .unwrap() + .materials + .slot(&slot_b) + .unwrap() + .material + .is_none()); + } + + #[test] + fn brush_preview_restores_and_targets_only_one_face() { + let mut app = test_app(); + let world = app.world_mut(); + let brush = BrushDesc::cuboid(Vec3::ONE); + let target_face = brush.faces[2].id.clone(); + let untouched_face = brush.faces[3].id.clone(); + let entity = world + .spawn((LevelObject, ActorKind::Brush, brush.clone())) + .id(); + let target = MaterialDropTarget::BrushFace { + owner: entity, + owner_label: "Blockout".into(), + face_id: target_face.clone(), + hit_point: Vec3::ZERO, + hit_normal: Vec3::Y, + }; + let preview = begin_material_drop_preview( + world, + AssetSelection::File("assets/materials/steel.ron".into()), + &target, + &material_payload("steel"), + ) + .unwrap(); + + let previewed = world.get::(entity).unwrap(); + assert!(previewed + .faces + .iter() + .find(|face| face.id == target_face) + .unwrap() + .material + .is_some()); + assert!(previewed + .faces + .iter() + .find(|face| face.id == untouched_face) + .unwrap() + .material + .is_none()); + + preview.snapshot.restore(world); + assert_eq!(world.get::(entity), Some(&brush)); + assert!(!world.resource::().dirty); + } + + #[test] + fn texture_drop_rejects_renderer_slots_without_mutating_them() { + let mut app = test_app(); + let world = app.world_mut(); + let slot_id = ComponentInstanceId::new("slot:body"); + let renderer = StaticMeshRenderer { + slots: Vec::new(), + materials: RendererMaterialSet { + slots: vec![RendererMaterialSlot { + id: slot_id.clone(), + name: "Body".into(), + source_material: None, + material: None, + }], + orphaned_assignments: Vec::new(), + }, + }; + let entity = world + .spawn((LevelObject, ActorKind::StaticMesh, renderer.clone())) + .id(); + let target = MaterialDropTarget::RendererSlot { + owner: entity, + owner_label: "Crate".into(), + slot_id, + slot_label: "Body".into(), + renderer: RendererKind::Static, + hit_point: Vec3::ZERO, + hit_normal: Vec3::Y, + }; + let payload = SurfaceDropPayload::Texture { + label: "Grid".into(), + reference: EditorAssetRef::new("grid", "texture:source", "Grid") + .with_source_path("assets/textures/grid.png"), + source_path: "assets/textures/grid.png".into(), + }; + + let error = begin_material_drop_preview( + world, + AssetSelection::File("assets/textures/grid.png".into()), + &target, + &payload, + ) + .unwrap_err(); + + assert!(error.contains("Material Instance")); + assert_eq!(world.get::(entity), Some(&renderer)); + assert_eq!(world.resource::().undo_depth(), 0); + } + + #[test] + fn resolved_project_material_is_supported_for_primitives() { + let mut material = material_desc_for_surface(None, &material_payload("steel")).unwrap(); + assert_eq!(material.roughness, 0.2); + assert_eq!( + material.material_asset_path.as_deref(), + Some("assets/materials/steel.ron") + ); + material.base_color_texture = None; + let texture = SurfaceDropPayload::Texture { + label: "Grid".into(), + reference: EditorAssetRef::default(), + source_path: "assets/textures/grid.png".into(), + }; + let textured = material_desc_for_surface(Some(material), &texture).unwrap(); + assert_eq!( + textured.base_color_texture.as_deref(), + Some("assets/textures/grid.png") + ); + } + + #[test] + fn locked_target_rejects_preview_without_mutation_or_history() { + let mut app = test_app(); + let world = app.world_mut(); + let original = MaterialDesc::default(); + let entity = world + .spawn(( + LevelObject, + ActorKind::Empty, + Primitive::default(), + original.clone(), + )) + .id(); + let mut hierarchy = crate::ui::hierarchy_state::HierarchyPanelState::default(); + hierarchy.locked.insert(entity); + world.insert_resource(hierarchy); + let target = MaterialDropTarget::Primitive { + owner: entity, + owner_label: "Locked Cube".into(), + hit_point: Vec3::ZERO, + hit_normal: Vec3::Y, + }; + + let error = begin_material_drop_preview( + world, + AssetSelection::File("assets/materials/steel.ron".into()), + &target, + &material_payload("steel"), + ) + .unwrap_err(); + + assert!(error.contains("locked")); + assert_eq!(world.get::(entity), Some(&original)); + assert_eq!(world.resource::().undo_depth(), 0); + assert!(!world.resource::().dirty); + } +} diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index 1fa4665..8f22e99 100644 --- a/crates/editor/src/viewport/mod.rs +++ b/crates/editor/src/viewport/mod.rs @@ -6,6 +6,7 @@ pub mod brush_edit; pub mod brush_tool; pub mod camera; pub mod gizmos; +pub mod material_drop; mod panel; pub mod render_view; pub mod rendering_diagnostics; @@ -21,5 +22,6 @@ pub use brush_csg::{ }; pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection}; pub use brush_tool::{BrushToolPlugin, BrushToolState}; +pub use material_drop::{MaterialDropFeedback, MaterialDropPlugin, MaterialDropState}; pub use panel::*; pub use viewport_mode::EditorViewportMode; diff --git a/crates/editor/src/viewport/selection.rs b/crates/editor/src/viewport/selection.rs index 1e541a9..d244a71 100644 --- a/crates/editor/src/viewport/selection.rs +++ b/crates/editor/src/viewport/selection.rs @@ -21,6 +21,7 @@ use crate::viewport::ViewportDisplayMode; use crate::visualizers::EditorVisualizerProxy; use bevy_egui::egui; +use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState}; use crate::viewport::scene_view_ray; #[derive(Resource, Default, Debug, Clone, Copy)] @@ -47,6 +48,7 @@ struct PickTargetQueries<'w, 's> { icon_proxies: Query<'w, 's, &'static ActorIconProxy>, parents: Query<'w, 's, &'static ChildOf>, editor_only: Query<'w, 's, (), With>, + material_drop: Option>, } impl Plugin for EditorSelectionPlugin { @@ -68,6 +70,7 @@ impl Plugin for EditorSelectionPlugin { Last, (handle_pick_events, cycle_overlapping_viewport_pick) .chain() + .after(MaterialDropSet) .run_if(scene_tools_active), ); } @@ -110,6 +113,14 @@ fn handle_pick_events( gizmo_targets: Query<&GizmoTarget>, hierarchy: Option>, ) -> Result { + if pick_targets + .material_drop + .as_deref() + .is_some_and(MaterialDropState::captures_viewport_input) + { + viewport_click.0 = None; + return Ok(()); + } if ui_state.pointer_in_viewport && !buttons.any_pressed([MouseButton::Right, MouseButton::Middle]) && keys.just_pressed(KeyCode::Escape) @@ -216,7 +227,14 @@ fn cycle_overlapping_viewport_pick( keys: Res>, buttons: Res>, display: Res, + material_drop: Option>, ) -> Result { + if material_drop + .as_deref() + .is_some_and(MaterialDropState::captures_viewport_input) + { + return Ok(()); + } if display.clean_game_view { return Ok(()); } diff --git a/crates/shared/src/hydration/brushes.rs b/crates/shared/src/hydration/brushes.rs index 5f80e27..2edb67b 100644 --- a/crates/shared/src/hydration/brushes.rs +++ b/crates/shared/src/hydration/brushes.rs @@ -11,8 +11,7 @@ use bevy::prelude::*; use crate::{ asset_server_path, authoring_component_active, brush_math::validate_brush, AuthoringComponentStates, BrushDesc, BrushFaceDesc, ColliderDesc, InspectorOrder, LevelObject, - MaterialAsset, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, - COMPONENT_MATERIAL_DESC, + MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC, }; use super::materials::material_from_desc; @@ -214,8 +213,8 @@ fn brush_group_material( texture_path: Option<&str>, ) -> StandardMaterial { let mut material = material_path - .and_then(|path| MaterialAsset::load_from_path(path).ok()) - .map(|asset| material_from_desc(asset_server, &asset.material)) + .and_then(|path| crate::load_resolved_material_from_path(path).ok()) + .map(|(desc, _)| material_from_desc(asset_server, &desc)) .or_else(|| parent_material.map(|material| material_from_desc(asset_server, material))) .unwrap_or_else(default_brush_material); if let Some(texture_path) = texture_path { diff --git a/crates/shared/src/hydration/mod.rs b/crates/shared/src/hydration/mod.rs index 69e8d22..630c51e 100644 --- a/crates/shared/src/hydration/mod.rs +++ b/crates/shared/src/hydration/mod.rs @@ -52,12 +52,22 @@ use crate::{ /// Registers hydration systems in deterministic order. pub struct HydrationPlugin; +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum HydrationSet { + Visibility, + Content, +} + impl Plugin for HydrationPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_observer(tag_hydrated_prefab_members) .add_observer(mark_skinned_materials_ready) .add_observer(crate::prefab_overrides::apply_prefab_overrides_on_ready) + .configure_sets( + Update, + (HydrationSet::Visibility, HydrationSet::Content).chain(), + ) .add_systems( Update, ( @@ -74,7 +84,8 @@ impl Plugin for HydrationPlugin { hydrate_prefabs, hydrate_physics, ) - .chain(), + .chain() + .in_set(HydrationSet::Content), ) .add_systems( Update, @@ -83,7 +94,8 @@ impl Plugin for HydrationPlugin { sync_editor_visibility, init_editor_visibility_on_spawn, ) - .chain(), + .chain() + .in_set(HydrationSet::Visibility), ); } } diff --git a/crates/shared/src/hydration/static_meshes.rs b/crates/shared/src/hydration/static_meshes.rs index 1d77fe5..9b65166 100644 --- a/crates/shared/src/hydration/static_meshes.rs +++ b/crates/shared/src/hydration/static_meshes.rs @@ -10,10 +10,9 @@ use std::collections::{HashMap, HashSet}; use crate::{ asset_server_path, authoring_component_active, standard_material_asset_path, AuthoringComponentStates, ColliderDesc, ColliderShapeDesc, EditorAssetRef, - HydratedRendererMaterialBinding, InspectorOrder, LevelObject, MaterialAlphaMode, MaterialAsset, - MaterialDesc, MaterialInstanceAsset, MaterialOverride, MaterialRef, StaticMeshRenderer, - StaticMeshRendererEntry, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC, - COMPONENT_STATIC_MESH_RENDERER, + HydratedRendererMaterialBinding, InspectorOrder, LevelObject, MaterialAlphaMode, MaterialDesc, + MaterialOverride, MaterialRef, StaticMeshRenderer, StaticMeshRendererEntry, + COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_STATIC_MESH_RENDERER, }; use super::materials::material_from_desc; @@ -457,22 +456,7 @@ fn resolve_shared_material( return Some(handle.clone()); } let path = reference.0.source_path.as_deref()?; - let desc_and_state = MaterialAsset::load_from_path(path) - .map(|asset| (asset.material, asset.render_state)) - .or_else(|_| { - let instance = MaterialInstanceAsset::load_from_path(path)?; - let base_path = instance.base.0.source_path.as_deref().ok_or_else(|| { - format!( - "material instance {} has no loadable base path", - instance.label - ) - })?; - let base = MaterialAsset::load_from_path(base_path)?; - let mut desc = base.material; - instance.apply_to(&mut desc); - Ok::<_, String>((desc, base.render_state)) - }) - .ok()?; + let desc_and_state = crate::load_resolved_material_from_path(path).ok()?; let mut material = material_from_desc(asset_server, &desc_and_state.0); material.alpha_mode = match desc_and_state.1.alpha_mode { MaterialAlphaMode::Opaque => AlphaMode::Opaque, diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index e2d510d..f2c667d 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -25,9 +25,9 @@ pub use hydration::{ HydratedPrefabReady, HydratedSkinnedMeshRoot, HydrationPlugin, PrefabHydrationBlocked, }; pub use material_asset::{ - MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset, MaterialRenderState, - ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset, MATERIAL_ASSET_SCHEMA_VERSION, - MATERIAL_INSTANCE_SCHEMA_VERSION, SURFACE_SHADER_SCHEMA_VERSION, + load_resolved_material_from_path, MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset, + MaterialRenderState, ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset, + MATERIAL_ASSET_SCHEMA_VERSION, MATERIAL_INSTANCE_SCHEMA_VERSION, SURFACE_SHADER_SCHEMA_VERSION, }; pub use navigation::*; pub use post_process_effect_asset::{PostProcessEffectAsset, PostProcessEffectKind}; diff --git a/crates/shared/src/material_asset.rs b/crates/shared/src/material_asset.rs index c8cf9b2..13bad01 100644 --- a/crates/shared/src/material_asset.rs +++ b/crates/shared/src/material_asset.rs @@ -79,6 +79,33 @@ impl MaterialAsset { } } +/// Loads a project Material or direct-base Material Instance into one effective descriptor. +/// +/// The returned descriptor retains the selected asset path so primitive/brush authoring can locate +/// the shared source while renderer slots continue to preserve their stable [`MaterialRef`]. +pub fn load_resolved_material_from_path( + catalog_path: &str, +) -> Result<(MaterialDesc, MaterialRenderState), String> { + if let Ok(asset) = MaterialAsset::load_from_path(catalog_path) { + let mut material = asset.material; + material.material_asset_path = Some(catalog_path.to_string()); + return Ok((material, asset.render_state)); + } + + let instance = MaterialInstanceAsset::load_from_path(catalog_path)?; + let base_path = instance.base.0.source_path.as_deref().ok_or_else(|| { + format!( + "material instance {} has no loadable base path", + instance.label + ) + })?; + let base = MaterialAsset::load_from_path(base_path)?; + let mut material = base.material; + instance.apply_to(&mut material); + material.material_asset_path = Some(catalog_path.to_string()); + Ok((material, base.render_state)) +} + /// Explicit reusable overrides over one project Material asset. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MaterialInstanceAsset { @@ -270,4 +297,66 @@ mod tests { .iter() .any(|value| value.name == "edge_width")); } + + #[test] + fn resolved_material_instance_applies_sparse_overrides_and_keeps_instance_path() { + let root = std::env::temp_dir().join(format!( + "blacksite-resolved-material-instance-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let base_path = root.join("base.ron"); + let instance_path = root.join("instance.ron"); + std::fs::create_dir_all(&root).unwrap(); + let base = MaterialAsset { + schema_version: MATERIAL_ASSET_SCHEMA_VERSION, + label: "Base".into(), + shader: None, + shader_ref: None, + render_state: MaterialRenderState { + double_sided: true, + ..Default::default() + }, + material: MaterialDesc { + roughness: 0.8, + ..Default::default() + }, + }; + std::fs::write( + &base_path, + ron::ser::to_string_pretty(&base, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + let instance = MaterialInstanceAsset { + schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, + label: "Instance".into(), + base: MaterialRef::new( + EditorAssetRef::new("base", "material:source", "Base") + .with_source_path(base_path.to_string_lossy().into_owned()), + ), + parameters: vec![MaterialParameter { + name: "roughness".into(), + value: crate::MaterialParameterValue::Float(0.25), + }], + textures: Vec::new(), + }; + std::fs::write( + &instance_path, + ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()).unwrap(), + ) + .unwrap(); + + let (resolved, state) = + load_resolved_material_from_path(instance_path.to_str().unwrap()).unwrap(); + + assert_eq!(resolved.roughness, 0.25); + assert_eq!( + resolved.material_asset_path.as_deref(), + instance_path.to_str() + ); + assert!(state.double_sided); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/docs/editor/README.md b/docs/editor/README.md index 6ea2a87..ba9ed5a 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -40,7 +40,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | `play/audio_preview.rs` / `game::audio` | Non-dirty editor audition and authored runtime playback/bus adapter | audio-authoring.md, ADR 0030 | | `assets/animation.rs` / `ui/animation_inspector.rs` / `game::animation` | Generated animation manifests, controller authoring/preview, and runtime state adapter | animation-authoring.md, ADR 0031 | | `ui/navigation_inspector.rs` / `scene::navigation` / `game::navigation` | Navigation authoring, deterministic bake artifacts, viewport preview, and runtime path queries | navigation-authoring.md, ADR 0032 | -| `assets/materials.rs` / `shared::renderer_material` / `shared::hydration::{static_meshes,skinned_meshes}` | Shared material discovery, stable renderer slots, and draw binding | material-system.md, ADR 0035 | +| `assets/materials.rs` / `ui/material_library.rs` / `viewport/material_drop.rs` / `shared::renderer_material` | Shared material discovery, docked catalog/usage UI, exact reversible surface drops, stable renderer slots, and draw binding | material-system.md, ADR 0035 | | `blacksite_surface` / `game_hot::rendering::solari` / `third_party/bevy_solari` | Surface ABI packing, raster composition, Solari evaluator dispatch, and deformation eligibility | material-system.md, rendering.md, ADR 0036 | | `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 | | `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 | @@ -68,6 +68,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping. - **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera. - **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. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips under `assets/audio/`, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references that retain a runtime source path. 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/`. Before thumbnail loading, glTF sources preflight local external buffers/images and show a stable non-retryable failure state when a dependency is missing instead of repeatedly invoking the asset loader. +- **Material Library** is a dockable bottom-panel catalog for cross-folder Material and direct-base Material Instance authoring. It provides search, type and scene-usage filters, grid/list thumbnails, dependency health, usage counts, creation, guarded details editing, and first-class drag sources. Viewport drops resolve an exact renderer slot, primitive, or brush face under the pointer, preview transiently, reject incompatible/read-only targets explicitly, restore on target change/cancel, and commit one typed undo step on release. - **Static/skinned renderer split** — model drag/drop uses normalized artifacts under `assets/meshes/generated/`. Unrigged, non-animated sources create `ActorKind::StaticMesh + StaticMeshRenderer`; skin-bound or animated sources create `ActorKind::SkinnedMesh + SkinnedMeshRenderer` and preserve the imported hierarchy. Static slots never contain marked skinned primitives or geometry from animated sources. `SceneInstance` placement keeps `ImportedModel + ModelRef` for generic full-source scenes. See [ADR 0033](../adr/0033-dedicated-skinned-mesh-renderer.md). - **Animation authoring** — glTF/GLB imports generate UUID-keyed rig/clip manifests and expandable skeleton/clip shelves. Clip drag creates a skinned actor or adds one exact-signature-compatible state to a selected skinned actor through typed history. `AnimationControllerDesc` exposes named states, ranges, loop/speed, default/crossfade, and non-dirty runtime-backed preview; project validation resolves the same stable refs and blocks animated/skinned FBX with conversion guidance. Production acceptance completed in Gitea #46. See [animation-authoring.md](animation-authoring.md) and [ADR 0031](../adr/0031-animation-authoring-runtime-contract.md). - **Navigation authoring** — bounds, obstacles, areas, and links use typed history and viewport visualizers. Rerecast produces a versioned fingerprinted artifact; Polyanya powers both editor path preview and the game-owned runtime API. The existing horizontal toolbar path button creates or bakes bounds, while Scene > Navigation exposes the complete workflow. See [navigation-authoring.md](navigation-authoring.md) and [ADR 0032](../adr/0032-versioned-navigation-bake-and-runtime-query.md). diff --git a/docs/editor/material-system.md b/docs/editor/material-system.md index 259c798..cef955e 100644 --- a/docs/editor/material-system.md +++ b/docs/editor/material-system.md @@ -21,17 +21,31 @@ material, but may not reference another instance. ## Assign materials -1. Select a static- or skinned-mesh actor. -2. In **Static Mesh Renderer** or **Skinned Mesh Renderer**, find the material slot by its imported - name and stable ID. -3. Use **Browse** or **Select** to assign a Material or Material Instance. **Locate** reveals the - current asset in the Asset Browser. -4. Use **Clear** to remove only the explicit assignment. The slot returns to its read-only imported - source material. +Open **Window > Material Library** for the cross-folder Material/Material Instance catalog. It +supports search, Material/Instance/scene-usage filters, grid/list thumbnails, dependency health, +scene-reference counts, creation, drag sources, and the guarded shared-asset editor. It lives in the +existing bottom dock and does not add permanent viewport chrome. Scene-usage rows select the exact +actor, focus the viewport, and identify its renderer slot, primitive surface, or brush face. + +Assign through either exact workflow: + +1. Drag a Material or Material Instance from the Material Library or Asset Browser onto the desired + viewport surface. A hydrated static/skinned draw targets its exact stable renderer slot, a + primitive targets its actor material, and a brush targets the exact face under the pointer. +2. Check the pointer card and target visual. Green names a valid actor/slot/face; red gives the + blocking reason. Release commits one undoable assignment. Move away, leave the viewport, press + Escape, or right-click to restore the pre-drag state without history or dirtying the scene. +3. Alternatively, select a static- or skinned-mesh actor and use **Browse** or **Select** on the + named slot in its renderer inspector. **Locate** reveals the current asset in the Asset Browser; + **Clear** removes only the explicit assignment and restores the read-only imported source. From the Asset Browser, **Apply Material** assigns the selected material to every material slot on -each selected static or skinned renderer. Use the per-slot inspector when different draws need -different assignments. +each selected static or skinned renderer. This is the explicit bulk path. A viewport hit never +silently broadens to all slots. + +Texture drops target a primitive's base-color texture or one brush face. Direct Texture drops on a +renderer slot are rejected because a renderer slot stores a Material reference; create or edit a +Material Instance and assign the texture there. Static draw slots and material slots are separate. Removing a static draw retains its explicit material assignment as an orphan rather than guessing a replacement. Reimport also reconciles @@ -39,10 +53,11 @@ slots by stable ID, never by display name. Resolve an orphan explicitly in the r ## Author shared assets -Material files and material-instance files are RON documents under `assets/materials/`. The Asset -Browser identifies the document kind, exposes its schema-driven properties and texture bindings, -and uses stable subasset IDs (`material:source` or `material:instance`). Built-in shader schemas -live under `assets/shaders/`. +Material files and material-instance files are RON documents under `assets/materials/`. The +Material Library and Asset Browser identify the document kind, expose the same guarded +schema-driven editor and texture bindings, and use stable subasset IDs (`material:source` or +`material:instance`). Material Instance thumbnails resolve the direct base plus sparse overrides. +Built-in shader schemas live under `assets/shaders/`. Select a Material and use **Create Instance** to create a direct-base variant beside it. The instance inspector exposes its base Material plus sparse property and texture override checkboxes; @@ -147,3 +162,5 @@ bases, and unsupported project content through the same editor/headless finding | Custom normal has no visible effect | Verify the mesh has tangents. | | Animated actor is missing from Solari GI | Expected for current skinned/morph geometry; inspect the deformed-exclusion diagnostic. | | Material reference is unresolved | Run project validation and repair/reselect the stable asset reference; do not hand-edit a display-name match. | +| Texture drop is red on an imported renderer | Renderer slots accept Materials, not loose textures. Create/edit a Material Instance, set its texture, then assign it. | +| Drag preview remains after leaving a target | This is a regression: target changes, viewport exit, Escape, right-click, and invalid release must restore the exact snapshot. |