Add material library and targeted surface drops
This commit is contained in:
parent
3931de1f0a
commit
02247f029a
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>() {
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)",
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
1080
crates/editor/src/ui/material_library.rs
Normal file
1080
crates/editor/src/ui/material_library.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
1310
crates/editor/src/viewport/material_drop.rs
Normal file
1310
crates/editor/src/viewport/material_drop.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
|
||||
@ -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(());
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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. |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user