Compare commits

..

2 Commits

Author SHA1 Message Date
3794faa4aa Document material workflow acceptance
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
2026-07-12 13:18:40 -04:00
02247f029a Add material library and targeted surface drops 2026-07-12 13:14:47 -04:00
26 changed files with 2848 additions and 150 deletions

View File

@ -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.

View File

@ -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.

View File

@ -16,10 +16,7 @@ pub fn load_material_asset(catalog_path: &str) -> Result<MaterialAsset, String>
}
pub fn material_desc_from_asset(catalog_path: &str) -> Result<MaterialDesc, String> {
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() {

View File

@ -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.

View File

@ -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::<Vec<_>>();
let asset_server = world.resource::<AssetServer>().clone();
world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
world.resource_scope(|_world, mut studio: Mut<ThumbnailStudio>| {
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::<AssetThumbnailCache>().invalidate_all();
if !world.contains_resource::<ThumbnailStudio>() {

View File

@ -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::{

View File

@ -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)

View File

@ -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::<crate::asset_db::AssetRegistry>()
.ok_or_else(|| "Asset registry is unavailable".to_string())

View File

@ -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)",
}

View File

@ -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,

File diff suppressed because it is too large Load Diff

View File

@ -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<EditorMode>,
}
#[derive(Resource, Default)]
struct DockTabRequest(Option<EditorTab>);
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::<DockTabRequest>().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::<component_registry::EditorComponentRegistry>()
.init_resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.init_resource::<asset_browser::AssetBrowserUiState>()
.init_resource::<material_library::MaterialLibraryState>()
.init_resource::<material_library::MaterialLibraryCatalog>()
.init_resource::<DockTabRequest>()
.init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>()
.init_resource::<animation_inspector::AnimationInspectorState>()
@ -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)

View File

@ -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::<ViewportDisplayMode>().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::<EditorAssets>()
.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::<MaterialDropState>())
.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::<EditorAssets>().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::<EditorAssets>();
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));
}
}
}

View File

@ -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<Vec3> {
face.vertices
.iter()

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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<EditorOnly>>,
material_drop: Option<Res<'w, MaterialDropState>>,
}
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<Res<HierarchyPanelState>>,
) -> 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<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
material_drop: Option<Res<MaterialDropState>>,
) -> Result {
if material_drop
.as_deref()
.is_some_and(MaterialDropState::captures_viewport_input)
{
return Ok(());
}
if display.clean_game_view {
return Ok(());
}

View File

@ -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 {

View File

@ -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::<StaticMeshArtifactCache>()
.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),
);
}
}

View File

@ -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,

View File

@ -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};

View File

@ -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();
}
}

View File

@ -25,6 +25,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
| [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers |
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
| [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops |
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
@ -40,7 +41,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 +69,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).

View File

@ -0,0 +1,47 @@
# Material Library And Targeted Drop Evaluation
Date: 2026-07-12
Branch: `codex/material-library-targeted-drop`
Implementation commit: `02247f0`
This record captures live acceptance for the docked Material Library and exact viewport
Material/Texture assignment workflow. The permanent user workflow lives in the
[material-system guide](../../material-system.md), and the implementation plan is
[material_library_and_targeted_drop_2026-07-12.plan.md](../../../../.cursor/plans/material_library_and_targeted_drop_2026-07-12.plan.md).
## Live Editor Evidence
The image below is a native Wayland capture from the debug editor. The assignment was previewed,
committed once, undone, previewed again, and canceled with Escape; the authored scene was not saved.
![Material Instance preview on the exact Ground surface with the docked Material Library and green target feedback](material-instance-surface-preview.png)
The Material Library resolves `surface_tint_instance` through its base Material and sparse
overrides. While the pointer is over the viewport, the green card names `Ground / Surface`, the
inspector reflects the transient material, and the scene-usage section identifies the exact actor
and surface. The preview does not push history or dirty the scene.
## Acceptance Results
| Area | Result | Evidence |
|------|--------|----------|
| Catalog workflow | Pass | Window menu entry, persisted bottom dock, search, Material/Instance/Used/Unused filters, grid/list views, resolved instance thumbnail, and details selection |
| Exact targeting | Pass | Primitive preview resolves one actor surface; automated coverage resolves one stable renderer slot and one stable brush face |
| Feedback | Pass | Neutral acquisition changes to green valid actor/surface feedback; invalid payload and read-only paths are covered by focused tests |
| Preview lifecycle | Pass | Live material and usage update without a history entry; Escape restores the exact prior material |
| Commit lifecycle | Pass | Release creates one typed history/operator entry; undo restores the prior surface |
| Startup hygiene | Pass | Relaunching the authored showcase after visibility-order correction produced no Bevy hierarchy warnings |
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed here |
## Automated Verification
| Command/suite | Result |
|---------------|--------|
| `cargo fmt --all -- --check` | Pass |
| `cargo check --workspace --all-targets` | Pass |
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass |
| `cargo test --workspace` | Pass |
| Editor strict Clippy with `dev` and `hot-reload` features | Pass |
| `cargo validate-levels --project .` | 59 dependencies, 5 non-blocking findings, 0 blocking errors |
| `cargo bake-navigation --project . --check` | 1 deterministic artifact current |
| `git diff --check` | Pass |

View File

@ -21,17 +21,34 @@ 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.
See the [Material Library and targeted-drop evaluation](evaluations/material-library-targeted-drop/)
for the live preview, commit/undo, cancel, and source-verification record.
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 +56,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 +165,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. |