Add brush face asset pickers
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
4dc22cd3b6
commit
015ae6da63
@ -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,53 +2822,67 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 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));
|
||||
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 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) {
|
||||
let Some(mut primitive) = world.get::<Primitive>(entity).cloned() else {
|
||||
return;
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user