Add brush face asset pickers
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 06:20:08 -04:00
parent 4dc22cd3b6
commit 015ae6da63
2 changed files with 306 additions and 46 deletions

View File

@ -42,7 +42,7 @@ use super::component_registry::{
use super::dock_tabs::open_and_focus_tab;
use super::helpers::create_scene_sun_override_from_project_settings;
use super::{EditorTab, UiState};
use crate::assets::asset_db::AssetRegistry;
use crate::assets::asset_db::{find_asset_by_path, AssetRegistry};
use crate::assets::static_mesh::{
load_static_mesh_manifest, material_id_from_label, part_id_from_label,
};
@ -91,6 +91,7 @@ struct AssetRefCandidate {
enum AssetRefCandidateKind {
Mesh,
Material,
Texture,
}
#[derive(Default)]
@ -98,6 +99,7 @@ struct AssetSelectorResponse {
selected: Option<EditorAssetRef>,
clear: bool,
locate: bool,
accepted_drop: bool,
}
#[derive(Debug, Clone)]
@ -1335,6 +1337,7 @@ fn static_mesh_asset_ref_candidates(
texture_id: None,
}
}
AssetRefCandidateKind::Texture => continue,
};
let key = (
@ -1380,6 +1383,200 @@ fn texture_asset_candidates(world: &World) -> Vec<TextureAssetCandidate> {
candidates
}
fn texture_asset_ref_candidates(world: &World) -> Vec<AssetRefCandidate> {
let Some(catalog) = world.get_resource::<EditorAssets>() else {
return Vec::new();
};
let Some(registry) = world.get_resource::<AssetRegistry>() else {
return Vec::new();
};
let snapshot = world
.get_resource::<AssetThumbnailCache>()
.map(AssetThumbnailCache::snapshot);
let mut candidates: Vec<_> = catalog
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Texture))
.filter_map(|asset| {
let path = asset.path.as_deref()?;
let record = find_asset_by_path(registry, path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
"texture:source",
asset.label.clone(),
),
label: asset.label.clone(),
detail: path.to_string(),
selection: AssetSelection::File(path.to_string()),
folder_path: asset.folder_path.clone(),
source_material: None,
texture_id: snapshot
.as_ref()
.and_then(|snapshot| snapshot.texture_for(asset)),
})
})
.collect();
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail)));
candidates
}
fn brush_face_material_ref_candidates(world: &mut World) -> Vec<AssetRefCandidate> {
let mut candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Material);
let Some(catalog) = world.get_resource::<EditorAssets>() else {
return candidates;
};
let Some(registry) = world.get_resource::<AssetRegistry>() else {
return candidates;
};
let mut seen: HashSet<_> = candidates
.iter()
.map(|candidate| {
(
candidate.reference.asset_id.clone(),
candidate.reference.sub_asset_id.clone(),
)
})
.collect();
for asset in catalog
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Material))
{
let Some(path) = asset.path.as_deref() else {
continue;
};
let Some(record) = find_asset_by_path(registry, path) else {
continue;
};
let reference = EditorAssetRef::new(record.id.as_string(), "material:source", &asset.label);
let key = (reference.asset_id.clone(), reference.sub_asset_id.clone());
if !seen.insert(key) {
continue;
}
candidates.push(AssetRefCandidate {
reference,
label: asset.label.clone(),
detail: path.to_string(),
selection: AssetSelection::File(path.to_string()),
folder_path: asset.folder_path.clone(),
source_material: None,
texture_id: None,
});
}
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail)));
candidates
}
fn asset_ref_candidate_from_selection(
world: &World,
selection: &AssetSelection,
kind: AssetRefCandidateKind,
) -> Option<AssetRefCandidate> {
let registry = world.get_resource::<AssetRegistry>()?;
match (kind, selection) {
(AssetRefCandidateKind::Material, AssetSelection::File(path)) => {
let asset = world
.get_resource::<EditorAssets>()?
.assets
.iter()
.find(|asset| {
matches!(asset.kind, EditorAssetKind::Material)
&& asset.path.as_deref() == Some(path.as_str())
})?;
let record = find_asset_by_path(registry, path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
"material:source",
asset.label.clone(),
),
label: asset.label.clone(),
detail: path.clone(),
selection: selection.clone(),
folder_path: asset.folder_path.clone(),
source_material: None,
texture_id: None,
})
}
(AssetRefCandidateKind::Texture, AssetSelection::File(path)) => {
let asset = world
.get_resource::<EditorAssets>()?
.assets
.iter()
.find(|asset| {
matches!(asset.kind, EditorAssetKind::Texture)
&& asset.path.as_deref() == Some(path.as_str())
})?;
let record = find_asset_by_path(registry, path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
"texture:source",
asset.label.clone(),
),
label: asset.label.clone(),
detail: path.clone(),
selection: selection.clone(),
folder_path: asset.folder_path.clone(),
source_material: None,
texture_id: None,
})
}
(
AssetRefCandidateKind::Material,
AssetSelection::SubAsset {
parent_path,
sub_asset_id,
label,
kind: AssetSubAssetKind::Material,
..
},
) => {
let record = find_asset_by_path(registry, parent_path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
sub_asset_id.clone(),
label.clone(),
),
label: label.clone(),
detail: parent_path.clone(),
selection: selection.clone(),
folder_path: fallback_asset_folder(parent_path),
source_material: None,
texture_id: None,
})
}
(
AssetRefCandidateKind::Texture,
AssetSelection::SubAsset {
parent_path,
sub_asset_id,
label,
kind: AssetSubAssetKind::Texture,
source_path,
},
) => {
let record = find_asset_by_path(registry, parent_path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
sub_asset_id.clone(),
label.clone(),
),
label: label.clone(),
detail: source_path.clone().unwrap_or_else(|| parent_path.clone()),
selection: selection.clone(),
folder_path: fallback_asset_folder(parent_path),
source_material: None,
texture_id: None,
})
}
_ => None,
}
}
fn request_texture_asset_thumbnails(world: &mut World) {
let requests: Vec<_> = world
.get_resource::<EditorAssets>()
@ -1624,6 +1821,7 @@ fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
None,
false,
&mesh_candidates,
None,
);
if let Some(selected) = mesh_response.selected {
entry.mesh = selected;
@ -1641,6 +1839,7 @@ fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
inherited_material.as_ref(),
true,
&material_candidates,
None,
);
if let Some(selected) = material_response.selected {
entry.material = Some(selected);
@ -1830,6 +2029,7 @@ fn asset_selector_row(
inherited_asset: Option<&EditorAssetRef>,
clearable: bool,
candidates: &[AssetRefCandidate],
drop_candidate: Option<&AssetRefCandidate>,
) -> AssetSelectorResponse {
let mut response = AssetSelectorResponse::default();
if ui.available_width() < COMPACT_INSPECTOR_WIDTH {
@ -1844,6 +2044,7 @@ fn asset_selector_row(
clearable,
control_width,
candidates,
drop_candidate,
&mut response,
);
});
@ -1861,6 +2062,7 @@ fn asset_selector_row(
clearable,
control_width,
candidates,
drop_candidate,
&mut response,
);
});
@ -1876,6 +2078,7 @@ fn asset_selector_control(
clearable: bool,
control_width: f32,
candidates: &[AssetRefCandidate],
drop_candidate: Option<&AssetRefCandidate>,
response: &mut AssetSelectorResponse,
) {
let display_asset = asset.or(inherited_asset);
@ -1887,10 +2090,25 @@ fn asset_selector_control(
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_clip_rect(ui.max_rect());
let rect = ui.max_rect();
let valid_drag = drop_candidate.is_some();
let drop_hovered = valid_drag && ui.rect_contains_pointer(rect);
let stroke = if drop_hovered {
egui::Stroke::new(2.0, egui::Color32::from_rgb(125, 198, 255))
} else if valid_drag {
egui::Stroke::new(1.0, egui::Color32::from_rgb(58, 88, 122))
} else {
egui::Stroke::new(1.0, BORDER)
};
let fill = if drop_hovered {
egui::Color32::from_rgb(29, 57, 86)
} else {
WIDGET_BG.linear_multiply(0.75)
};
let inner_width = (selector_width - 18.0).max(1.0);
egui::Frame::new()
.fill(WIDGET_BG.linear_multiply(0.75))
.stroke(egui::Stroke::new(1.0, BORDER))
.fill(fill)
.stroke(stroke)
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
@ -1981,6 +2199,12 @@ fn asset_selector_control(
});
});
});
if drop_hovered && ui.input(|input| input.pointer.any_released()) {
if let Some(candidate) = drop_candidate {
response.selected = Some(candidate.reference.clone());
response.accepted_drop = true;
}
}
},
);
}
@ -2402,6 +2626,9 @@ pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
}
fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let material_candidates = brush_face_material_ref_candidates(world);
request_texture_asset_thumbnails(world);
let texture_candidates = texture_asset_ref_candidates(world);
let Some(mut brush) = world.get::<BrushDesc>(entity).cloned() else {
return;
};
@ -2443,7 +2670,14 @@ fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
});
});
brush_validation_ui(ui, &brush);
changed |= selected_brush_face_controls(world, ui, entity, &mut brush);
changed |= selected_brush_face_controls(
world,
ui,
entity,
&mut brush,
&material_candidates,
&texture_candidates,
);
if ui.button("Reset Cube Brush").clicked() {
reset_cube = true;
}
@ -2514,10 +2748,12 @@ fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) {
}
fn selected_brush_face_controls(
world: &World,
world: &mut World,
ui: &mut egui::Ui,
entity: Entity,
brush: &mut BrushDesc,
material_candidates: &[AssetRefCandidate],
texture_candidates: &[AssetRefCandidate],
) -> bool {
let Some(selection) = world.get_resource::<BrushElementSelection>() else {
return false;
@ -2537,6 +2773,15 @@ fn selected_brush_face_controls(
return false;
}
let dragging_selection = world
.get_resource::<EditorAssets>()
.and_then(|assets| assets.dragging_selection().cloned());
let material_drop_candidate = dragging_selection.as_ref().and_then(|selection| {
asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Material)
});
let texture_drop_candidate = dragging_selection.as_ref().and_then(|selection| {
asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Texture)
});
let mut changed = false;
ui.add_space(6.0);
ui.separator();
@ -2577,51 +2822,65 @@ fn selected_brush_face_controls(
.add(egui::DragValue::new(&mut face.uv_rotation).speed(1.0))
.changed();
});
changed |= editor_asset_ref_option_ui(ui, "Material", &mut face.material);
changed |= editor_asset_ref_option_ui(ui, "Texture", &mut face.texture);
let material_response = asset_selector_row(
ui,
"Material",
icons::PALETTE,
face.material.as_ref(),
None,
true,
material_candidates,
material_drop_candidate.as_ref(),
);
if let Some(selected) = material_response.selected {
face.material = Some(selected);
changed = true;
}
if material_response.clear {
face.material = None;
changed = true;
}
if material_response.locate {
locate_asset_ref(world, face.material.as_ref(), material_candidates);
}
if material_response.accepted_drop {
clear_asset_drag(world);
}
let texture_response = asset_selector_row(
ui,
"Texture",
icons::IMAGE,
face.texture.as_ref(),
None,
true,
texture_candidates,
texture_drop_candidate.as_ref(),
);
if let Some(selected) = texture_response.selected {
face.texture = Some(selected);
changed = true;
}
if texture_response.clear {
face.texture = None;
changed = true;
}
if texture_response.locate {
locate_asset_ref(world, face.texture.as_ref(), texture_candidates);
}
if texture_response.accepted_drop {
clear_asset_drag(world);
}
});
}
changed
}
fn editor_asset_ref_option_ui(
ui: &mut egui::Ui,
label: &str,
value: &mut Option<EditorAssetRef>,
) -> bool {
let mut changed = false;
property_row(ui, label, |ui| {
let mut enabled = value.is_some();
if ui.checkbox(&mut enabled, "").changed() {
if enabled {
*value = Some(EditorAssetRef::default());
} else {
*value = None;
}
changed = true;
}
if let Some(reference) = value.as_mut() {
ui.vertical(|ui| {
ui.set_min_width(MIN_INLINE_CONTROL_WIDTH);
changed |= ui
.text_edit_singleline(&mut reference.label)
.on_hover_text("Cached display label")
.changed();
changed |= ui
.text_edit_singleline(&mut reference.asset_id)
.on_hover_text("Asset registry ID")
.changed();
changed |= ui
.text_edit_singleline(&mut reference.sub_asset_id)
.on_hover_text("Imported sub-asset ID")
.changed();
});
} else {
ui.label(egui::RichText::new("(none)").color(TEXT_DIM));
}
});
changed
fn clear_asset_drag(world: &mut World) {
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.clear_drag();
}
}
fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
@ -2914,6 +3173,7 @@ fn collider_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
None,
false,
&mesh_candidates,
None,
);
if let Some(selected) = mesh_response.selected {
*mesh = selected;

View File

@ -18,7 +18,7 @@ With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip
Vertex, edge, and face selections can be dragged on the viewport floor plane. The editor moves matching duplicated corner vertices across all brush faces so simple cube/prism brushes stay welded, recomputes face planes, and commits one undoable brush edit when the drag releases. Clip mode currently provides element visualization/selection only; split application is future CSG work.
When one or more faces are selected in Face mode, the Brush inspector shows selected-face controls for UV offset, UV scale, UV rotation, material ref, and texture ref. These fields edit the persisted `BrushFaceDesc` data through undoable brush updates. The current controls are data-oriented; Asset Browser picker/drop polish and per-face material batching in hydration are still follow-up work.
When one or more faces are selected in Face mode, the Brush inspector shows selected-face controls for UV offset, UV scale, UV rotation, material ref, and texture ref. Material and texture rows use Asset Browser-aware picker controls with clear, locate, browse, and compatible drag/drop assignment. These fields edit the persisted `BrushFaceDesc` data through undoable brush updates. Per-face material batching in hydration is still follow-up work.
## Boolean Operations
@ -36,4 +36,4 @@ These operations commit through history but are not full arbitrary-face CSG yet.
- Brush diagnostics currently focus on face validity, plane normals, finite vertices, duplicate vertices, degenerate area, and UV scale warnings; a dedicated multi-brush diagnostics window is future work.
- Subtractive brush markers are stored but not automatically evaluated.
- Clip split application and arbitrary-face CSG remain future roadmap work.
- Per-face material and texture refs persist, but generated brush meshes still hydrate through a single material batch.
- Per-face material and texture refs persist and are editable through Asset Browser-aware controls, but generated brush meshes still hydrate through a single material batch.