Hydrate brush face materials
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 06:35:21 -04:00
parent 09f707b139
commit dc593978d8
4 changed files with 147 additions and 45 deletions

View File

@ -1405,7 +1405,8 @@ fn texture_asset_ref_candidates(world: &World) -> Vec<AssetRefCandidate> {
record.id.as_string(),
"texture:source",
asset.label.clone(),
),
)
.with_source_path(path),
label: asset.label.clone(),
detail: path.to_string(),
selection: AssetSelection::File(path.to_string()),
@ -1449,7 +1450,8 @@ fn brush_face_material_ref_candidates(world: &mut World) -> Vec<AssetRefCandidat
let Some(record) = find_asset_by_path(registry, path) else {
continue;
};
let reference = EditorAssetRef::new(record.id.as_string(), "material:source", &asset.label);
let reference = EditorAssetRef::new(record.id.as_string(), "material:source", &asset.label)
.with_source_path(path);
let key = (reference.asset_id.clone(), reference.sub_asset_id.clone());
if !seen.insert(key) {
continue;
@ -1490,7 +1492,8 @@ fn asset_ref_candidate_from_selection(
record.id.as_string(),
"material:source",
asset.label.clone(),
),
)
.with_source_path(path),
label: asset.label.clone(),
detail: path.clone(),
selection: selection.clone(),
@ -1514,7 +1517,8 @@ fn asset_ref_candidate_from_selection(
record.id.as_string(),
"texture:source",
asset.label.clone(),
),
)
.with_source_path(path),
label: asset.label.clone(),
detail: path.clone(),
selection: selection.clone(),
@ -1564,7 +1568,8 @@ fn asset_ref_candidate_from_selection(
record.id.as_string(),
sub_asset_id.clone(),
label.clone(),
),
)
.with_source_path(source_path.clone().unwrap_or_else(|| parent_path.clone())),
label: label.clone(),
detail: source_path.clone().unwrap_or_else(|| parent_path.clone()),
selection: selection.clone(),

View File

@ -175,6 +175,10 @@ pub struct EditorAssetRef {
/// UI label cached for inspectors. The asset registry/artifact remains authoritative.
#[serde(default)]
pub label: String,
/// Optional loadable project path cached for runtime hydration when the
/// referenced asset is not available through an imported artifact lookup.
#[serde(default)]
pub source_path: Option<String>,
}
impl EditorAssetRef {
@ -187,9 +191,15 @@ impl EditorAssetRef {
asset_id: asset_id.into(),
sub_asset_id: sub_asset_id.into(),
label: label.into(),
source_path: None,
}
}
pub fn with_source_path(mut self, source_path: impl Into<String>) -> Self {
self.source_path = Some(source_path.into());
self
}
pub fn is_resolved(&self) -> bool {
!self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty()
}

View File

@ -1,5 +1,7 @@
//! Convex brush hydration from authored face polygons.
use std::collections::BTreeMap;
use avian3d::prelude::ColliderConstructor;
use bevy::asset::RenderAssetUsages;
use bevy::light::{NotShadowCaster, NotShadowReceiver};
@ -7,9 +9,9 @@ use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use crate::{
brush_math::validate_brush, inspector_component_active, BrushDesc, BrushFaceDesc, ColliderDesc,
InspectorOrder, LevelObject, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
COMPONENT_MATERIAL_DESC,
asset_server_path, brush_math::validate_brush, inspector_component_active, BrushDesc,
BrushFaceDesc, ColliderDesc, InspectorOrder, LevelObject, MaterialAsset, MaterialDesc,
COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC,
};
use super::materials::material_from_desc;
@ -85,38 +87,37 @@ pub fn spawn_brush_mesh(
}
return;
}
let Some(mesh) = brush_mesh(brush) else {
warn!("Brush hydration skipped invalid brush on {parent:?}");
return;
};
let mesh = meshes.add(mesh);
let material = material
.map(|material| materials.add(material_from_desc(asset_server, material)))
.unwrap_or_else(|| {
materials.add(StandardMaterial {
base_color: Color::srgb(0.64, 0.68, 0.72),
perceptual_roughness: 0.8,
..default()
})
});
for group in brush_material_groups(brush) {
let Some(mesh) = brush_mesh_for_faces(group.faces.iter().copied()) else {
warn!("Brush hydration skipped invalid face batch on {parent:?}");
continue;
};
let mesh = meshes.add(mesh);
let material = materials.add(brush_group_material(
asset_server,
material,
group.material_path.as_deref(),
group.texture_path.as_deref(),
));
let mut brush_child = commands.spawn((
HydratedBrushMesh,
Name::new("Hydrated Brush Mesh"),
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::default(),
Visibility::Visible,
ChildOf(parent),
));
if !brush.cast_shadows {
brush_child.insert(NotShadowCaster);
}
if !brush.receive_shadows {
brush_child.insert(NotShadowReceiver);
}
if collider.is_some_and(|collider| collider.enabled) {
brush_child.insert(ColliderConstructor::TrimeshFromMesh);
let mut brush_child = commands.spawn((
HydratedBrushMesh,
Name::new("Hydrated Brush Mesh"),
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::default(),
Visibility::Visible,
ChildOf(parent),
));
if !brush.cast_shadows {
brush_child.insert(NotShadowCaster);
}
if !brush.receive_shadows {
brush_child.insert(NotShadowReceiver);
}
if collider.is_some_and(|collider| collider.enabled) {
brush_child.insert(ColliderConstructor::TrimeshFromMesh);
}
}
}
@ -136,13 +137,13 @@ pub fn despawn_brush_mesh(
}
}
pub(crate) fn brush_mesh(brush: &BrushDesc) -> Option<Mesh> {
fn brush_mesh_for_faces<'a>(faces: impl IntoIterator<Item = &'a BrushFaceDesc>) -> Option<Mesh> {
let mut positions = Vec::<[f32; 3]>::new();
let mut normals = Vec::<[f32; 3]>::new();
let mut uvs = Vec::<[f32; 2]>::new();
let mut indices = Vec::<u32>::new();
for face in &brush.faces {
for face in faces {
append_face(face, &mut positions, &mut normals, &mut uvs, &mut indices)?;
}
if positions.is_empty() || indices.is_empty() {
@ -163,6 +164,63 @@ pub(crate) fn brush_mesh(brush: &BrushDesc) -> Option<Mesh> {
Some(mesh)
}
#[derive(Debug)]
struct BrushMaterialGroup<'a> {
material_path: Option<String>,
texture_path: Option<String>,
faces: Vec<&'a BrushFaceDesc>,
}
fn brush_material_groups(brush: &BrushDesc) -> Vec<BrushMaterialGroup<'_>> {
let mut groups = BTreeMap::<(Option<String>, Option<String>), Vec<&BrushFaceDesc>>::new();
for face in &brush.faces {
let key = (
face.material
.as_ref()
.and_then(|reference| reference.source_path.clone()),
face.texture
.as_ref()
.and_then(|reference| reference.source_path.clone()),
);
groups.entry(key).or_default().push(face);
}
groups
.into_iter()
.map(
|((material_path, texture_path), faces)| BrushMaterialGroup {
material_path,
texture_path,
faces,
},
)
.collect()
}
fn brush_group_material(
asset_server: &AssetServer,
parent_material: Option<&MaterialDesc>,
material_path: Option<&str>,
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))
.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 {
material.base_color_texture = Some(asset_server.load(asset_server_path(texture_path)));
}
material
}
fn default_brush_material() -> StandardMaterial {
StandardMaterial {
base_color: Color::srgb(0.64, 0.68, 0.72),
perceptual_roughness: 0.8,
..default()
}
}
fn append_face(
face: &BrushFaceDesc,
positions: &mut Vec<[f32; 3]>,
@ -222,7 +280,8 @@ mod tests {
#[test]
fn cube_brush_builds_triangle_mesh() {
let mesh = brush_mesh(&BrushDesc::default()).expect("cube brush mesh");
let brush = BrushDesc::default();
let mesh = brush_mesh_for_faces(brush.faces.iter()).expect("cube brush mesh");
assert_eq!(mesh.primitive_topology(), PrimitiveTopology::TriangleList);
assert!(mesh.attribute(Mesh::ATTRIBUTE_POSITION).is_some());
assert!(mesh.attribute(Mesh::ATTRIBUTE_NORMAL).is_some());
@ -232,10 +291,38 @@ mod tests {
assert!(mesh.enable_raytracing);
}
#[test]
fn brush_faces_group_by_material_paths() {
let mut brush = BrushDesc::default();
brush.faces[0].texture = Some(
crate::EditorAssetRef::new("texture-a", "texture:source", "Texture A")
.with_source_path("assets/textures/a.png"),
);
brush.faces[1].texture = Some(
crate::EditorAssetRef::new("texture-a", "texture:source", "Texture A")
.with_source_path("assets/textures/a.png"),
);
brush.faces[2].material = Some(
crate::EditorAssetRef::new("material-a", "material:source", "Material A")
.with_source_path("assets/materials/a.ron"),
);
let groups = brush_material_groups(&brush);
assert_eq!(groups.len(), 3);
assert!(groups.iter().any(|group| group.faces.len() == 3));
assert!(groups.iter().any(|group| {
group.texture_path.as_deref() == Some("assets/textures/a.png") && group.faces.len() == 2
}));
assert!(groups.iter().any(|group| {
group.material_path.as_deref() == Some("assets/materials/a.ron")
&& group.faces.len() == 1
}));
}
#[test]
fn invalid_brush_returns_none() {
let mut brush = BrushDesc::default();
brush.faces[0].vertices.clear();
assert!(brush_mesh(&brush).is_none());
assert!(brush_mesh_for_faces(brush.faces.iter()).is_none());
}
}

View File

@ -20,7 +20,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, validates the result, and commits one undoable brush edit when the drag releases. Invalid drag results are rejected with operator status text and reverted to the pre-drag brush. 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. 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.
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. Face refs cache loadable source paths when available; hydration groups faces by material/texture path and spawns separate generated mesh children for those batches.
## Boolean Operations
@ -38,4 +38,4 @@ These operations validate selected brushes before applying results and validate
- Brush diagnostics currently focus on face validity, plane normals, finite vertices, duplicate vertices, degenerate area, and UV scale warnings.
- 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 and are editable through Asset Browser-aware controls, but generated brush meshes still hydrate through a single material batch.
- Imported source-material subasset refs persist for future material-preserving CSG, but only face refs with loadable source paths can drive brush material hydration today.