Add brush authoring schema
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 05:42:19 -04:00
parent cc6acb188d
commit 9f95ba3082
24 changed files with 891 additions and 78 deletions

View File

@ -181,6 +181,8 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
details when you need full `SceneRoot` loading for animation/skinning/scene data. Expanding a
model in the Asset Browser exposes normalized mesh/material/texture subassets; dragging a mesh
subasset places that part through the same static mesh renderer path.
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; the MVP hydrates additive convex
cube brushes into generated preview meshes while face/edge/CSG editing remains roadmap work.
- Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable.
- Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save.
- **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored
@ -295,6 +297,7 @@ crates/
- [x] Advanced rendering: `GiMode`, post-process volumes, Rendering panel, requested/effective render stack, Solari integration, emissive materials, post FX assets ([ADR 0013](docs/adr/0013-rendering-tiers-and-post-process-volumes.md), [ADR 0016](docs/adr/0016-unified-rendering-contract.md), [rendering guide](docs/editor/rendering.md))
- [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md))
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
- [x] Brush authoring schema MVP with `ActorKind::Brush`, cube `BrushDesc`, generated mesh hydration, scene migration, and inspector Add Component support ([ADR 0021](docs/adr/0021-brush-authoring-schema.md))
## Notes / Future Work

View File

@ -455,6 +455,7 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<Edit
name: Some(asset.label.clone()),
transform: Transform::from_translation(translation),
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,
@ -654,6 +655,7 @@ pub fn spawn_subasset_at(
name: Some(label.clone()),
transform: Transform::from_translation(translation + Vec3::Y * 0.1),
primitive: None,
brush: None,
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(slot)),
material: None,
material_override: None,
@ -723,6 +725,7 @@ fn apply_static_mesh_placement_mode(
}),
transform,
primitive: None,
brush: None,
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(entry)),
material: None,
material_override: None,

View File

@ -1,9 +1,9 @@
use bevy::prelude::*;
use shared::{
ActorId, ActorKind, ActorName, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc,
MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc,
PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn,
TriggerVolume, WeaponSpawn,
ActorId, ActorKind, ActorName, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
LightDesc, MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody,
PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer,
TeamSpawn, TriggerVolume, WeaponSpawn,
};
#[derive(Debug, Clone)]
pub struct EditorEntitySnapshot {
@ -13,6 +13,7 @@ pub struct EditorEntitySnapshot {
pub name: Option<String>,
pub transform: Transform,
pub primitive: Option<Primitive>,
pub brush: Option<BrushDesc>,
pub static_mesh_renderer: Option<StaticMeshRenderer>,
pub material: Option<MaterialDesc>,
pub material_override: Option<MaterialOverride>,
@ -102,6 +103,11 @@ pub enum EditorCommand {
old: Option<Primitive>,
new: Primitive,
},
SetBrush {
entity: Entity,
old: Option<BrushDesc>,
new: BrushDesc,
},
SetStaticMeshRenderer {
entity: Entity,
old: Option<StaticMeshRenderer>,
@ -165,6 +171,7 @@ impl EditorCommand {
EditorCommand::SetRigidBody { .. } => "Set Rigid Body",
EditorCommand::SetCollider { .. } => "Set Collider",
EditorCommand::SetPrimitive { .. } => "Set Primitive",
EditorCommand::SetBrush { .. } => "Set Brush",
EditorCommand::SetStaticMeshRenderer { .. } => "Set Static Mesh Renderer",
EditorCommand::SetPostProcessVolume { .. } => "Set Post Process Volume",
EditorCommand::SetTransformGroup { .. } => "Move Selection",

View File

@ -1,6 +1,6 @@
use bevy::prelude::*;
use shared::{
infer_actor_kind, ActorId, ActorKind, ActorName, ColliderDesc, EditorVisibility,
infer_actor_kind, ActorId, ActorKind, ActorName, BrushDesc, ColliderDesc, EditorVisibility,
HierarchySiblingIndex, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialOverride,
ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance,
PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
@ -127,6 +127,7 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option<EditorEntitySnap
.map(|name| name.as_str().to_string()),
transform: entity_ref.get::<Transform>().copied().unwrap_or_default(),
primitive: entity_ref.get::<Primitive>().cloned(),
brush: entity_ref.get::<BrushDesc>().cloned(),
static_mesh_renderer: entity_ref.get::<StaticMeshRenderer>().cloned(),
material: entity_ref.get::<MaterialDesc>().cloned(),
material_override: entity_ref.get::<MaterialOverride>().cloned(),
@ -185,6 +186,9 @@ fn spawn_snapshot_with_parent(
if let Some(primitive) = &snapshot.primitive {
entity_mut.insert(primitive.clone());
}
if let Some(brush) = &snapshot.brush {
entity_mut.insert(brush.clone());
}
if let Some(renderer) = &snapshot.static_mesh_renderer {
entity_mut.insert(renderer.clone());
}
@ -453,6 +457,20 @@ pub fn set_primitive_with_history(world: &mut World, entity: Entity, new: Primit
push_history(world, EditorCommand::SetPrimitive { entity, old, new });
}
pub fn set_brush_with_history(world: &mut World, entity: Entity, new: BrushDesc) {
if !is_level_object(world, entity) {
return;
}
let old = world.get::<BrushDesc>(entity).cloned();
if old.as_ref() == Some(&new) {
return;
}
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(new.clone());
}
push_history(world, EditorCommand::SetBrush { entity, old, new });
}
pub fn set_static_mesh_renderer_with_history(
world: &mut World,
entity: Entity,
@ -558,6 +576,7 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) {
name: Some("Group".to_string()),
transform: Transform::default(),
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,
@ -600,6 +619,7 @@ pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
name: Some("Empty".to_string()),
transform: Transform::default(),
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,
@ -777,6 +797,9 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
EditorCommand::SetPrimitive { entity, old, .. } => {
apply_primitive(world, *entity, old);
}
EditorCommand::SetBrush { entity, old, .. } => {
apply_brush(world, *entity, old);
}
EditorCommand::SetStaticMeshRenderer { entity, old, .. } => {
apply_static_mesh_renderer(world, *entity, old);
}
@ -889,6 +912,11 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
entity_mut.insert(new.clone());
}
}
EditorCommand::SetBrush { entity, new, .. } => {
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
entity_mut.insert(new.clone());
}
}
EditorCommand::SetStaticMeshRenderer { entity, new, .. } => {
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
entity_mut.insert(new.clone());
@ -1045,6 +1073,19 @@ fn apply_primitive(world: &mut World, entity: Entity, primitive: &Option<Primiti
}
}
fn apply_brush(world: &mut World, entity: Entity, brush: &Option<BrushDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match brush {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<BrushDesc>();
}
}
}
}
fn apply_static_mesh_renderer(
world: &mut World,
entity: Entity,
@ -1098,6 +1139,9 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito
if let Some(v) = &snapshot.primitive {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.brush {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.static_mesh_renderer {
entity_mut.insert(v.clone());
}
@ -1154,6 +1198,9 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit
if snapshot.primitive.is_some() {
entity_mut.remove::<Primitive>();
}
if snapshot.brush.is_some() {
entity_mut.remove::<BrushDesc>();
}
if snapshot.static_mesh_renderer.is_some() {
entity_mut.remove::<StaticMeshRenderer>();
}

View File

@ -11,7 +11,7 @@ use scene::{document::SceneDocument, strip_schema_version, validate_level_text};
use serde::de::DeserializeSeed;
use shared::{
flush_level_object_hydration, infer_actor_kind, strip_hydrated_entity, validate_actor, ActorId,
ActorKind, ActorName, ActorValidationError, ColliderDesc, EditorVisibility,
ActorKind, ActorName, ActorValidationError, BrushDesc, ColliderDesc, EditorVisibility,
HierarchySiblingIndex, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialOverride,
ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance,
PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
@ -389,6 +389,21 @@ fn format_actor_validation(err: ActorValidationError) -> String {
ActorValidationError::MissingTransform => {
"Save failed: level object missing Transform".into()
}
ActorValidationError::BrushMissingDesc => {
"Save failed: Brush actor requires BrushDesc".into()
}
ActorValidationError::BrushHasPrimitive => {
"Save failed: Brush actor cannot have Primitive".into()
}
ActorValidationError::BrushHasStaticMeshRenderer => {
"Save failed: Brush actor cannot have StaticMeshRenderer".into()
}
ActorValidationError::BrushHasLight => {
"Save failed: Brush actor cannot have LightDesc".into()
}
ActorValidationError::BrushHasModelRef => {
"Save failed: Brush actor cannot have ModelRef".into()
}
ActorValidationError::StaticMeshMissingPrimitive => {
"Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot"
.into()
@ -462,6 +477,7 @@ fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Resul
.allow_component::<ActorKind>()
.allow_component::<InspectorOrder>()
.allow_component::<Primitive>()
.allow_component::<BrushDesc>()
.allow_component::<StaticMeshRenderer>()
.allow_component::<MaterialDesc>()
.allow_component::<MaterialOverride>()

View File

@ -86,6 +86,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
match kind {
ActorKind::StaticMesh
| ActorKind::Brush
| ActorKind::ImportedModel
| ActorKind::Light
| ActorKind::Empty
@ -110,7 +111,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
match kind {
ActorKind::StaticMesh | ActorKind::ImportedModel => icons::CUBE,
ActorKind::StaticMesh | ActorKind::Brush | ActorKind::ImportedModel => icons::CUBE,
ActorKind::Light => icons::LIGHTBULB,
ActorKind::PrefabAnchor => icons::PACKAGE,
ActorKind::PlayerSpawn => icons::PERSON_SIMPLE_RUN,

View File

@ -51,10 +51,31 @@ impl Default for EditorComponentRegistry {
description: "Renders one or more normalized imported mesh slots.",
search_terms: &["mesh", "renderer", "rendering", "model", "static"],
recommended: &[],
conflicts_with: &["shared::components::Primitive"],
conflicts_with: &[
"shared::components::Primitive",
"shared::components::BrushDesc",
],
hydration_effect:
"Hydrates into generated mesh children and material bindings.",
},
EditorComponentDescriptor {
type_name: "shared::components::BrushDesc",
display_name: "Brush",
category: EditorComponentCategory::Authoring,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::CUBE.as_str(),
description: "Creates authored convex brush geometry for blockout.",
search_terms: &["brush", "blockout", "convex", "csg", "geometry"],
recommended: &["shared::components::MaterialDesc"],
conflicts_with: &[
"shared::components::Primitive",
"shared::components::StaticMeshRenderer",
],
hydration_effect: "Hydrates into generated brush mesh children.",
},
EditorComponentDescriptor {
type_name: "shared::components::MaterialDesc",
display_name: "Authoring Material",
@ -127,7 +148,10 @@ impl Default for EditorComponentRegistry {
description: "Creates an authored built-in primitive shape.",
search_terms: &["box", "sphere", "ramp", "primitive"],
recommended: &[],
conflicts_with: &["shared::components::StaticMeshRenderer"],
conflicts_with: &[
"shared::components::StaticMeshRenderer",
"shared::components::BrushDesc",
],
hydration_effect:
"Hydrates into generated primitive render and collision data.",
},

View File

@ -89,6 +89,7 @@ pub fn light_snapshot(name: &str, translation: Vec3) -> EditorEntitySnapshot {
name: Some(name.to_string()),
transform: Transform::from_translation(translation),
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,
@ -136,6 +137,7 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
name: Some("Player Start".to_string()),
transform,
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,
@ -223,6 +225,7 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
name: Some("Scene Sun".to_string()),
transform,
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,

View File

@ -106,7 +106,7 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
.copied()
.map(|kind| match kind {
ActorKind::Empty => 0,
ActorKind::StaticMesh | ActorKind::ImportedModel => 1,
ActorKind::Brush | ActorKind::StaticMesh | ActorKind::ImportedModel => 1,
ActorKind::Light => 2,
ActorKind::PrefabAnchor => 3,
ActorKind::PlayerSpawn => 4,
@ -336,6 +336,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
use egui_phosphor_icons::icons;
match kind {
ActorKind::Empty => icons::FOLDER.as_str(),
ActorKind::Brush => icons::CUBE.as_str(),
ActorKind::StaticMesh => icons::CUBE.as_str(),
ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(),
ActorKind::Light => icons::LIGHTBULB.as_str(),

View File

@ -7,25 +7,25 @@ use bevy::prelude::*;
use bevy_egui::egui;
use egui_phosphor_icons::{icons, Icon};
use shared::{
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, ColliderDesc,
ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject,
LightDesc, MaterialDesc, MaterialParameter, MaterialParameterValue, MaterialShaderKind,
ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive,
PrimitiveShape, ProjectSun, RigidBodyDesc, StaticMeshRenderer, StaticMeshRendererEntry,
TeamSpawn, TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX,
AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC,
COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY,
COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE,
COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC,
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME,
COMPONENT_WEAPON_SPAWN,
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind,
ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter,
MaterialParameterValue, MaterialShaderKind, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, WeaponSpawn,
AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_BRUSH_DESC,
COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN,
COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE,
COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER,
COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
};
use crate::history::{
material_eq, set_collider_with_history, set_inspector_order_with_history,
set_light_with_history, set_material_with_history, set_physics_with_history,
set_post_process_volume_with_history, set_primitive_with_history, set_rigid_body_with_history,
set_static_mesh_renderer_with_history, EditorEntitySnapshot,
material_eq, set_brush_with_history, set_collider_with_history,
set_inspector_order_with_history, set_light_with_history, set_material_with_history,
set_physics_with_history, set_post_process_volume_with_history, set_primitive_with_history,
set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot,
};
use crate::selection::SelectedEntity;
use crate::ui::theme::{
@ -131,6 +131,7 @@ enum AddComponentShelfDirection {
#[derive(Debug, Clone)]
enum CopiedComponent {
Primitive(Primitive),
BrushDesc(BrushDesc),
StaticMeshRenderer(StaticMeshRenderer),
MaterialDesc(MaterialDesc),
LightDesc(LightDesc),
@ -149,6 +150,7 @@ impl CopiedComponent {
fn type_name(&self) -> &'static str {
match self {
Self::Primitive(_) => COMPONENT_PRIMITIVE,
Self::BrushDesc(_) => COMPONENT_BRUSH_DESC,
Self::StaticMeshRenderer(_) => COMPONENT_STATIC_MESH_RENDERER,
Self::MaterialDesc(_) => COMPONENT_MATERIAL_DESC,
Self::LightDesc(_) => COMPONENT_LIGHT_DESC,
@ -357,6 +359,9 @@ fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static s
if world.get::<Primitive>(entity).is_some() {
present.push(COMPONENT_PRIMITIVE);
}
if world.get::<BrushDesc>(entity).is_some() {
present.push(COMPONENT_BRUSH_DESC);
}
if world.get::<MaterialDesc>(entity).is_some() || world.get::<Primitive>(entity).is_some() {
present.push(COMPONENT_MATERIAL_DESC);
}
@ -405,6 +410,10 @@ fn copy_component(world: &mut World, entity: Entity, type_name: &str) {
.get::<Primitive>(entity)
.cloned()
.map(CopiedComponent::Primitive),
COMPONENT_BRUSH_DESC => world
.get::<BrushDesc>(entity)
.cloned()
.map(CopiedComponent::BrushDesc),
COMPONENT_STATIC_MESH_RENDERER => world
.get::<StaticMeshRenderer>(entity)
.cloned()
@ -468,6 +477,9 @@ fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
Some(CopiedComponent::Primitive(value)) if type_name == COMPONENT_PRIMITIVE => {
set_primitive_with_history(world, entity, value);
}
Some(CopiedComponent::BrushDesc(value)) if type_name == COMPONENT_BRUSH_DESC => {
set_brush_with_history(world, entity, value);
}
Some(CopiedComponent::StaticMeshRenderer(value))
if type_name == COMPONENT_STATIC_MESH_RENDERER =>
{
@ -517,6 +529,7 @@ fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
fn reset_component(world: &mut World, entity: Entity, type_name: &str) {
match type_name {
COMPONENT_PRIMITIVE => set_primitive_with_history(world, entity, Primitive::default()),
COMPONENT_BRUSH_DESC => set_brush_with_history(world, entity, BrushDesc::default()),
COMPONENT_STATIC_MESH_RENDERER => {
set_static_mesh_renderer_with_history(world, entity, StaticMeshRenderer::default());
}
@ -575,6 +588,10 @@ fn remove_registered_component(world: &mut World, entity: Entity, type_name: &st
entity_mut.remove::<Primitive>();
true
}
COMPONENT_BRUSH_DESC if before.brush.is_some() => {
entity_mut.remove::<BrushDesc>();
true
}
COMPONENT_STATIC_MESH_RENDERER if before.static_mesh_renderer.is_some() => {
entity_mut.remove::<StaticMeshRenderer>();
true
@ -670,6 +687,7 @@ fn draw_authoring_component_by_type(
) {
match type_name {
COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity),
COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity),
COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity),
COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity),
COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity),
@ -1090,6 +1108,7 @@ fn component_display_name(
fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
match type_name {
"shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
"shared::components::BrushDesc" => world.get::<BrushDesc>(entity).is_some(),
"shared::components::StaticMeshRenderer" => {
world.get::<StaticMeshRenderer>(entity).is_some()
}
@ -1112,10 +1131,19 @@ fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) {
match type_name {
"shared::components::Primitive" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(Primitive::cuboid(Vec3::ONE));
world
.entity_mut(e)
.insert((shared::ActorKind::StaticMesh, Primitive::cuboid(Vec3::ONE)));
}),
"shared::components::BrushDesc" => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((shared::ActorKind::Brush, BrushDesc::default()));
}),
"shared::components::StaticMeshRenderer" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(StaticMeshRenderer::default());
world
.entity_mut(e)
.insert((shared::ActorKind::StaticMesh, StaticMeshRenderer::default()));
}),
"shared::components::MaterialDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(MaterialDesc::default());
@ -1189,6 +1217,7 @@ fn diff_added(before: &EditorEntitySnapshot, after: &EditorEntitySnapshot) -> Ed
.primitive
.clone()
.filter(|_| before.primitive.is_none()),
brush: after.brush.clone().filter(|_| before.brush.is_none()),
static_mesh_renderer: after
.static_mesh_renderer
.clone()
@ -2370,6 +2399,62 @@ 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 Some(mut brush) = world.get::<BrushDesc>(entity).cloned() else {
return;
};
let original = brush.clone();
let mut changed = false;
let mut reset_cube = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_BRUSH_DESC, "Brush", icons::CUBE),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Kind", |ui| {
egui::ComboBox::from_id_salt("brush_kind")
.selected_text(match brush.kind {
BrushKind::Additive => "Additive",
BrushKind::SubtractiveMarker => "Subtractive Marker",
})
.show_ui(ui, |ui| {
changed |= ui
.selectable_value(&mut brush.kind, BrushKind::Additive, "Additive")
.changed();
changed |= ui
.selectable_value(
&mut brush.kind,
BrushKind::SubtractiveMarker,
"Subtractive Marker",
)
.changed();
});
});
property_row(ui, "Faces", |ui| {
ui.label(format!("{}", brush.faces.len()));
});
property_row(ui, "Shadows", |ui| {
ui.horizontal_wrapped(|ui| {
changed |= ui.checkbox(&mut brush.cast_shadows, "Cast").changed();
changed |= ui.checkbox(&mut brush.receive_shadows, "Receive").changed();
});
});
if ui.button("Reset Cube Brush").clicked() {
reset_cube = true;
}
});
apply_component_card_response(world, entity, card_response);
if reset_cube {
brush = BrushDesc::default();
changed = true;
}
if changed && brush != original {
set_brush_with_history(world, entity, brush);
}
}
fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut primitive) = world.get::<Primitive>(entity).cloned() else {
return;

View File

@ -603,6 +603,10 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
};
match kind {
ActorKind::Brush => ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh,
},
ActorKind::StaticMesh => {
if components.has_primitive || components.has_static_mesh_renderer {
ActorIconSpec {

View File

@ -455,6 +455,7 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
name: Some("Post Process Volume".into()),
transform: Transform::from_translation(translation),
primitive: None,
brush: None,
static_mesh_renderer: None,
material: None,
material_override: None,

View File

@ -1,3 +1,4 @@
use bevy::ecs::system::SystemParam;
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction};
use bevy::prelude::*;
@ -36,6 +37,15 @@ pub struct ViewportPickStack {
pub struct EditorSelectionPlugin;
#[derive(SystemParam)]
struct PickTargetQueries<'w, 's> {
level_objects: Query<'w, 's, Entity, (With<LevelObject>, Without<EditorOnly>)>,
proxies: Query<'w, 's, &'static EditorVisualizerProxy>,
icon_proxies: Query<'w, 's, &'static ActorIconProxy>,
parents: Query<'w, 's, &'static ChildOf>,
editor_only: Query<'w, 's, (), With<EditorOnly>>,
}
impl Plugin for EditorSelectionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SelectedEntity>()
@ -88,9 +98,7 @@ fn handle_pick_events(
pointers: Query<&PointerInteraction>,
editor_cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut mesh_ray_cast: MeshRayCast,
level_objects: Query<Entity, (With<LevelObject>, Without<EditorOnly>)>,
proxies: Query<&EditorVisualizerProxy>,
icon_proxies: Query<&ActorIconProxy>,
pick_targets: PickTargetQueries,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
@ -132,9 +140,7 @@ fn handle_pick_events(
&mut pick_stack,
&editor_cameras,
&mut mesh_ray_cast,
&level_objects,
&proxies,
&icon_proxies,
&pick_targets,
pointer_pos,
additive,
);
@ -152,9 +158,11 @@ fn handle_pick_events(
let mut picked = None;
for interaction in &pointers {
if let Some(entity) = interaction.as_slice().iter().find_map(|(entity, _hit)| {
pick_target_for_entity(*entity, &level_objects, &proxies, &icon_proxies)
}) {
if let Some(entity) = interaction
.as_slice()
.iter()
.find_map(|(entity, _hit)| pick_target_for_entity(*entity, &pick_targets))
{
picked = Some(entity);
break;
}
@ -167,9 +175,7 @@ fn handle_pick_events(
let hits = collect_viewport_pick_hits(
&editor_cameras,
&mut mesh_ray_cast,
&level_objects,
&proxies,
&icon_proxies,
&pick_targets,
ui_state.viewport_rect,
pointer_pos,
);
@ -231,18 +237,14 @@ fn apply_viewport_pick(
pick_stack: &mut ViewportPickStack,
editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mesh_ray_cast: &mut MeshRayCast,
level_objects: &Query<Entity, (With<LevelObject>, Without<EditorOnly>)>,
proxies: &Query<&EditorVisualizerProxy>,
icon_proxies: &Query<&ActorIconProxy>,
pick_targets: &PickTargetQueries,
pointer_pos: egui::Pos2,
additive: bool,
) {
let hits = collect_viewport_pick_hits(
editor_cameras,
mesh_ray_cast,
level_objects,
proxies,
icon_proxies,
pick_targets,
ui_state.viewport_rect,
pointer_pos,
);
@ -277,9 +279,7 @@ fn apply_pick_result(
fn collect_viewport_pick_hits(
editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mesh_ray_cast: &mut MeshRayCast,
level_objects: &Query<Entity, (With<LevelObject>, Without<EditorOnly>)>,
proxies: &Query<&EditorVisualizerProxy>,
icon_proxies: &Query<&ActorIconProxy>,
pick_targets: &PickTargetQueries,
scene_rect: egui::Rect,
pointer_pos: egui::Pos2,
) -> Vec<Entity> {
@ -298,15 +298,7 @@ fn collect_viewport_pick_hits(
let Some(ray) = scene_view_ray(camera, transform, pointer_pos, scene_rect) else {
return Vec::new();
};
let filter = |entity: Entity| {
level_objects.contains(entity)
|| proxies
.get(entity)
.is_ok_and(|proxy| level_objects.contains(proxy.target) || proxy.target != entity)
|| icon_proxies
.get(entity)
.is_ok_and(|proxy| level_objects.contains(proxy.target))
};
let filter = |entity: Entity| pick_target_for_entity(entity, pick_targets).is_some();
let settings = MeshRayCastSettings::default()
.with_visibility(RayCastVisibility::Any)
.with_filter(&filter)
@ -317,13 +309,11 @@ fn collect_viewport_pick_hits(
let mut ordered = Vec::new();
for icon_priority in [true, false] {
for (entity, _) in hits {
let is_icon = icon_proxies.get(*entity).is_ok();
let is_icon = pick_targets.icon_proxies.get(*entity).is_ok();
if is_icon != icon_priority {
continue;
}
let Some(target) =
pick_target_for_entity(*entity, level_objects, proxies, icon_proxies)
else {
let Some(target) = pick_target_for_entity(*entity, pick_targets) else {
continue;
};
if seen.insert(target) {
@ -334,19 +324,41 @@ fn collect_viewport_pick_hits(
ordered
}
fn pick_target_for_entity(
entity: Entity,
level_objects: &Query<Entity, (With<LevelObject>, Without<EditorOnly>)>,
proxies: &Query<&EditorVisualizerProxy>,
icon_proxies: &Query<&ActorIconProxy>,
) -> Option<Entity> {
if let Ok(proxy) = icon_proxies.get(entity) {
return level_objects.contains(proxy.target).then_some(proxy.target);
fn pick_target_for_entity(entity: Entity, pick_targets: &PickTargetQueries) -> Option<Entity> {
if let Ok(proxy) = pick_targets.icon_proxies.get(entity) {
return pick_targets
.level_objects
.contains(proxy.target)
.then_some(proxy.target);
}
if let Ok(proxy) = proxies.get(entity) {
if let Ok(proxy) = pick_targets.proxies.get(entity) {
return Some(proxy.target);
}
level_objects.contains(entity).then_some(entity)
if pick_targets.editor_only.contains(entity) {
return None;
}
if pick_targets.level_objects.contains(entity) {
return Some(entity);
}
let mut current = pick_targets
.parents
.get(entity)
.ok()
.map(|parent| parent.parent());
while let Some(parent) = current {
if pick_targets.editor_only.contains(parent) {
return None;
}
if pick_targets.level_objects.contains(parent) {
return Some(parent);
}
current = pick_targets
.parents
.get(parent)
.ok()
.map(|parent| parent.parent());
}
None
}
fn sync_gizmo_targets(

View File

@ -92,6 +92,9 @@ fn infer_kind_from_block(block: &str) -> &'static str {
if block.contains("\"shared::components::ModelRef\"") {
return actor_kind_ron(ActorKind::ImportedModel);
}
if block.contains("\"shared::components::BrushDesc\"") {
return actor_kind_ron(ActorKind::Brush);
}
if block.contains("\"shared::components::Primitive\"")
|| block.contains("\"shared::components::StaticMeshRenderer\"")
{
@ -108,6 +111,7 @@ fn infer_kind_from_block(block: &str) -> &'static str {
fn actor_kind_ron(kind: ActorKind) -> &'static str {
match kind {
ActorKind::Empty => "Empty",
ActorKind::Brush => "Brush",
ActorKind::StaticMesh => "StaticMesh",
ActorKind::ImportedModel => "ImportedModel",
ActorKind::Light => "Light",
@ -208,6 +212,29 @@ mod tests {
assert!(migrated.contains("StaticMesh"));
}
#[test]
fn backfills_actor_kind_for_brush_entity() {
let body = r#"(
resources: {},
entities: {
1: (
components: {
"shared::components::LevelObject": (),
"shared::components::BrushDesc": (
kind: Additive,
faces: [],
cast_shadows: true,
receive_shadows: true,
),
},
),
},
)"#;
let migrated = migrate_v1_to_v2(body);
assert!(migrated.contains("ActorKind"));
assert!(migrated.contains("Brush"));
}
#[test]
fn fixes_low_directional_lux() {
let body = r#"(

View File

@ -4,7 +4,7 @@ use bevy::ecs::world::EntityRef;
use bevy::prelude::*;
use crate::{
ActorKind, LevelObject, LightDesc, ModelRef, ObjectiveMarker, PlayerSpawn,
ActorKind, BrushDesc, LevelObject, LightDesc, ModelRef, ObjectiveMarker, PlayerSpawn,
PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, StaticMeshRenderer, TeamSpawn,
TriggerVolume, WeaponSpawn,
};
@ -35,6 +35,9 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
if entity.get::<LightDesc>().is_some() {
return Some(ActorKind::Light);
}
if entity.get::<BrushDesc>().is_some() {
return Some(ActorKind::Brush);
}
if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
return Some(ActorKind::StaticMesh);
}
@ -51,6 +54,11 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
pub enum ActorValidationError {
MissingActorKind,
MissingTransform,
BrushMissingDesc,
BrushHasPrimitive,
BrushHasStaticMeshRenderer,
BrushHasLight,
BrushHasModelRef,
StaticMeshMissingPrimitive,
StaticMeshHasLight,
StaticMeshHasModelRef,
@ -90,6 +98,23 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
}
match kind {
ActorKind::Brush => {
if entity.get::<BrushDesc>().is_none() {
return Err(ActorValidationError::BrushMissingDesc);
}
if entity.get::<Primitive>().is_some() {
return Err(ActorValidationError::BrushHasPrimitive);
}
if entity.get::<StaticMeshRenderer>().is_some() {
return Err(ActorValidationError::BrushHasStaticMeshRenderer);
}
if entity.get::<LightDesc>().is_some() {
return Err(ActorValidationError::BrushHasLight);
}
if entity.get::<ModelRef>().is_some() {
return Err(ActorValidationError::BrushHasModelRef);
}
}
ActorKind::StaticMesh => {
let has_static_mesh_renderer = entity
.get::<StaticMeshRenderer>()
@ -213,6 +238,30 @@ mod tests {
);
}
#[test]
fn validate_brush_ok() {
let mut world = World::new();
let e = level_entity(&mut world, (ActorKind::Brush, BrushDesc::default()));
assert!(validate_actor(world.entity(e)).is_ok());
}
#[test]
fn validate_brush_rejects_static_mesh_renderer() {
let mut world = World::new();
let e = level_entity(
&mut world,
(
ActorKind::Brush,
BrushDesc::default(),
StaticMeshRenderer::default(),
),
);
assert_eq!(
validate_actor(world.entity(e)),
Err(ActorValidationError::BrushHasStaticMeshRenderer)
);
}
#[test]
fn validate_post_process_volume_ok() {
let mut world = World::new();

View File

@ -146,6 +146,7 @@ pub fn inspector_component_active(order: Option<&InspectorOrder>, type_name: &st
}
pub const COMPONENT_PRIMITIVE: &str = "shared::components::Primitive";
pub const COMPONENT_BRUSH_DESC: &str = "shared::components::BrushDesc";
pub const COMPONENT_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc";
pub const COMPONENT_MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride";
@ -267,6 +268,185 @@ impl Default for Primitive {
}
}
/// Brush boolean role. Subtractive brushes are authored now but CSG is applied
/// by later tooling.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum BrushKind {
#[default]
Additive,
SubtractiveMarker,
}
/// Serializable convex brush plane. Plane equation is `normal.dot(point) = distance`.
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct BrushPlaneDesc {
pub normal: Vec3,
pub distance: f32,
}
impl Default for BrushPlaneDesc {
fn default() -> Self {
Self {
normal: Vec3::Y,
distance: 0.0,
}
}
}
/// One polygon face on a convex authoring brush.
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct BrushFaceDesc {
#[serde(default)]
pub id: ComponentInstanceId,
pub plane: BrushPlaneDesc,
#[serde(default)]
pub vertices: Vec<Vec3>,
#[serde(default)]
pub material: Option<EditorAssetRef>,
#[serde(default)]
pub texture: Option<EditorAssetRef>,
#[serde(default)]
pub uv_offset: Vec2,
#[serde(default = "default_uv_scale")]
pub uv_scale: Vec2,
#[serde(default)]
pub uv_rotation: f32,
#[serde(default)]
pub smoothing_group: u32,
}
impl Default for BrushFaceDesc {
fn default() -> Self {
Self {
id: ComponentInstanceId::default(),
plane: BrushPlaneDesc::default(),
vertices: Vec::new(),
material: None,
texture: None,
uv_offset: Vec2::ZERO,
uv_scale: default_uv_scale(),
uv_rotation: 0.0,
smoothing_group: 0,
}
}
}
fn default_uv_scale() -> Vec2 {
Vec2::ONE
}
/// Convex brush authoring data. Hydration turns this into generated mesh
/// children while the scene stores only this component.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct BrushDesc {
pub kind: BrushKind,
#[serde(default)]
pub faces: Vec<BrushFaceDesc>,
#[serde(default = "default_true")]
pub cast_shadows: bool,
#[serde(default = "default_true")]
pub receive_shadows: bool,
}
impl BrushDesc {
pub fn cuboid(size: Vec3) -> Self {
let hx = size.x.max(0.001) * 0.5;
let hy = size.y.max(0.001) * 0.5;
let hz = size.z.max(0.001) * 0.5;
let faces = vec![
brush_face(
"face:+x",
Vec3::X,
vec![
Vec3::new(hx, -hy, -hz),
Vec3::new(hx, hy, -hz),
Vec3::new(hx, hy, hz),
Vec3::new(hx, -hy, hz),
],
),
brush_face(
"face:-x",
Vec3::NEG_X,
vec![
Vec3::new(-hx, -hy, hz),
Vec3::new(-hx, hy, hz),
Vec3::new(-hx, hy, -hz),
Vec3::new(-hx, -hy, -hz),
],
),
brush_face(
"face:+y",
Vec3::Y,
vec![
Vec3::new(-hx, hy, -hz),
Vec3::new(-hx, hy, hz),
Vec3::new(hx, hy, hz),
Vec3::new(hx, hy, -hz),
],
),
brush_face(
"face:-y",
Vec3::NEG_Y,
vec![
Vec3::new(-hx, -hy, hz),
Vec3::new(-hx, -hy, -hz),
Vec3::new(hx, -hy, -hz),
Vec3::new(hx, -hy, hz),
],
),
brush_face(
"face:+z",
Vec3::Z,
vec![
Vec3::new(hx, -hy, hz),
Vec3::new(hx, hy, hz),
Vec3::new(-hx, hy, hz),
Vec3::new(-hx, -hy, hz),
],
),
brush_face(
"face:-z",
Vec3::NEG_Z,
vec![
Vec3::new(-hx, -hy, -hz),
Vec3::new(-hx, hy, -hz),
Vec3::new(hx, hy, -hz),
Vec3::new(hx, -hy, -hz),
],
),
];
Self {
kind: BrushKind::Additive,
faces,
cast_shadows: true,
receive_shadows: true,
}
}
}
impl Default for BrushDesc {
fn default() -> Self {
Self::cuboid(Vec3::ONE)
}
}
fn brush_face(id: &str, normal: Vec3, vertices: Vec<Vec3>) -> BrushFaceDesc {
let distance = vertices
.first()
.map(|vertex| normal.dot(*vertex))
.unwrap_or(0.0);
BrushFaceDesc {
id: ComponentInstanceId::new(id),
plane: BrushPlaneDesc { normal, distance },
vertices,
..Default::default()
}
}
/// Legacy static mesh collision setting kept only for loading older scenes.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
@ -736,6 +916,7 @@ impl Default for EditorVisibility {
#[reflect(Component, Debug, PartialEq, Serialize, Deserialize)]
pub enum ActorKind {
Empty,
Brush,
StaticMesh,
ImportedModel,
Light,

View File

@ -0,0 +1,231 @@
//! Convex brush hydration from authored face polygons.
use avian3d::prelude::ColliderConstructor;
use bevy::asset::RenderAssetUsages;
use bevy::light::{NotShadowCaster, NotShadowReceiver};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use crate::{
inspector_component_active, BrushDesc, BrushFaceDesc, ColliderDesc, InspectorOrder,
LevelObject, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
COMPONENT_MATERIAL_DESC,
};
use super::materials::material_from_desc;
/// Runtime marker for generated brush render/collider children.
#[derive(Component, Reflect, Default, Debug, Clone, Copy)]
#[reflect(Component, Default, Debug)]
pub struct HydratedBrushMesh;
#[allow(clippy::type_complexity)]
pub fn hydrate_brushes(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
brushes: Query<
(
Entity,
&BrushDesc,
Option<&MaterialDesc>,
Option<&ColliderDesc>,
Option<&InspectorOrder>,
),
(
With<LevelObject>,
Or<(
Added<BrushDesc>,
Changed<BrushDesc>,
Changed<MaterialDesc>,
Changed<ColliderDesc>,
Changed<InspectorOrder>,
)>,
),
>,
children: Query<&Children>,
generated_brushes: Query<(), With<HydratedBrushMesh>>,
) {
for (entity, brush, material, collider, order) in &brushes {
despawn_brush_mesh(&mut commands, entity, &children, &generated_brushes);
if !inspector_component_active(order, COMPONENT_BRUSH_DESC) {
continue;
}
spawn_brush_mesh(
&mut commands,
&asset_server,
&mut meshes,
&mut materials,
entity,
brush,
material.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC)),
collider.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC)),
);
}
}
pub fn spawn_brush_mesh(
commands: &mut Commands,
asset_server: &AssetServer,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
parent: Entity,
brush: &BrushDesc,
material: Option<&MaterialDesc>,
collider: Option<&ColliderDesc>,
) {
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()
})
});
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);
}
}
pub fn despawn_brush_mesh(
commands: &mut Commands,
entity: Entity,
children: &Query<&Children>,
generated_brushes: &Query<(), With<HydratedBrushMesh>>,
) {
let Ok(child_list) = children.get(entity) else {
return;
};
for child in child_list.iter() {
if generated_brushes.get(child).is_ok() {
commands.entity(child).despawn();
}
}
}
pub(crate) fn brush_mesh(brush: &BrushDesc) -> 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 {
append_face(face, &mut positions, &mut normals, &mut uvs, &mut indices)?;
}
if positions.is_empty() || indices.is_empty() {
return None;
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(indices));
if let Err(error) = mesh.generate_tangents() {
warn!("Brush mesh is not Solari-compatible: failed to generate tangents: {error:?}");
}
Some(mesh)
}
fn append_face(
face: &BrushFaceDesc,
positions: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>,
uvs: &mut Vec<[f32; 2]>,
indices: &mut Vec<u32>,
) -> Option<()> {
if face.vertices.len() < 3 {
return None;
}
if !face.vertices.iter().all(|vertex| vertex.is_finite()) {
return None;
}
let normal = face.plane.normal.try_normalize()?;
if !normal.is_finite() {
return None;
}
let base = u32::try_from(positions.len()).ok()?;
let (u_axis, v_axis) = face_axes(normal);
for vertex in &face.vertices {
positions.push([vertex.x, vertex.y, vertex.z]);
normals.push([normal.x, normal.y, normal.z]);
let uv = Vec2::new(vertex.dot(u_axis), vertex.dot(v_axis)) * face.uv_scale + face.uv_offset;
uvs.push([uv.x, uv.y]);
}
let flip = (face.vertices[1] - face.vertices[0])
.cross(face.vertices[2] - face.vertices[0])
.dot(normal)
< 0.0;
for i in 1..(face.vertices.len() - 1) {
let i = u32::try_from(i).ok()?;
if flip {
indices.extend_from_slice(&[base, base + i + 1, base + i]);
} else {
indices.extend_from_slice(&[base, base + i, base + i + 1]);
}
}
Some(())
}
fn face_axes(normal: Vec3) -> (Vec3, Vec3) {
let tangent_seed = if normal.y.abs() > 0.9 {
Vec3::X
} else {
Vec3::Y
};
let u = tangent_seed.cross(normal).normalize_or_zero();
let v = normal.cross(u).normalize_or_zero();
(u, v)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BrushDesc;
#[test]
fn cube_brush_builds_triangle_mesh() {
let mesh = brush_mesh(&BrushDesc::default()).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());
assert!(mesh.attribute(Mesh::ATTRIBUTE_UV_0).is_some());
assert!(mesh.attribute(Mesh::ATTRIBUTE_TANGENT).is_some());
assert!(matches!(mesh.indices(), Some(Indices::U32(indices)) if indices.len() == 36));
assert!(mesh.enable_raytracing);
}
#[test]
fn invalid_brush_returns_none() {
let mut brush = BrushDesc::default();
brush.faces[0].vertices.clear();
assert!(brush_mesh(&brush).is_none());
}
}

View File

@ -1,5 +1,6 @@
//! Generates runtime render/physics/light components from reflectable authoring data.
mod brushes;
mod lights;
mod materials;
mod models;
@ -16,6 +17,7 @@ pub use strip::{strip_hydrated, strip_hydrated_entity};
use bevy::ecs::system::SystemState;
use bevy::prelude::*;
use brushes::{hydrate_brushes, spawn_brush_mesh, HydratedBrushMesh};
use lights::{hydrate_lights, reconcile_missing_runtime_lights};
use materials::hydrate_materials;
pub use materials::material_from_desc;
@ -30,10 +32,10 @@ use static_meshes::{
use visibility::{init_editor_visibility_on_spawn, sync_editor_visibility};
use crate::{
inspector_component_active, ColliderDesc, EditorVisibility, InspectorOrder, LevelObject,
MaterialDesc, MaterialOverride, Primitive, StaticMeshRenderer, COMPONENT_COLLIDER_DESC,
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_PRIMITIVE,
COMPONENT_STATIC_MESH_RENDERER,
inspector_component_active, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
LevelObject, MaterialDesc, MaterialOverride, Primitive, StaticMeshRenderer,
COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
COMPONENT_PRIMITIVE, COMPONENT_STATIC_MESH_RENDERER,
};
/// Registers hydration systems in deterministic order.
@ -46,6 +48,7 @@ impl Plugin for HydrationPlugin {
Update,
(
hydrate_primitives,
hydrate_brushes,
hydrate_materials,
hydrate_lights,
reconcile_missing_runtime_lights,
@ -83,6 +86,45 @@ pub fn flush_level_object_hydration(world: &mut World) {
.map(|(entity, desc, _)| (entity, desc.clone()))
.collect();
let brush_entities_for_cleanup: Vec<Entity> = world
.query_filtered::<(Entity, &BrushDesc), With<LevelObject>>()
.iter(world)
.map(|(entity, _)| entity)
.collect();
let brush_targets: Vec<(
Entity,
BrushDesc,
Option<MaterialDesc>,
Option<ColliderDesc>,
Option<InspectorOrder>,
)> = world
.query_filtered::<(
Entity,
&BrushDesc,
Option<&MaterialDesc>,
Option<&ColliderDesc>,
Option<&InspectorOrder>,
), With<LevelObject>>()
.iter(world)
.map(|(entity, brush, material, collider, order)| {
(
entity,
brush.clone(),
material
.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC))
.cloned(),
collider
.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC))
.cloned(),
order.cloned(),
)
})
.filter(|(_, _, _, _, order)| {
inspector_component_active(order.as_ref(), COMPONENT_BRUSH_DESC)
})
.collect();
let static_mesh_entities_for_cleanup: Vec<Entity> = world
.query_filtered::<(Entity, &StaticMeshRenderer), With<LevelObject>>()
.iter(world)
@ -138,6 +180,17 @@ pub fn flush_level_object_hydration(world: &mut World) {
}
}
let mut generated_brush_meshes = Vec::new();
for entity in &brush_entities_for_cleanup {
if let Some(children) = world.get::<Children>(*entity) {
for child in children.iter() {
if world.get::<HydratedBrushMesh>(child).is_some() {
generated_brush_meshes.push(child);
}
}
}
}
let visibility_targets: Vec<(Entity, EditorVisibility)> = world
.query_filtered::<(Entity, &EditorVisibility), With<LevelObject>>()
.iter(world)
@ -187,6 +240,23 @@ pub fn flush_level_object_hydration(world: &mut World) {
commands.entity(entity).insert(MeshMaterial3d(material));
}
for child in generated_brush_meshes {
commands.entity(child).despawn();
}
for (entity, brush, material, collider, _) in &brush_targets {
spawn_brush_mesh(
&mut commands,
&asset_server,
&mut meshes,
&mut materials,
*entity,
brush,
material.as_ref(),
collider.as_ref(),
);
}
for child in generated_static_mesh_parts {
commands.entity(child).despawn();
}

View File

@ -54,6 +54,10 @@ impl Plugin for SharedTypesPlugin {
.register_type::<ProjectSun>()
.register_type::<Primitive>()
.register_type::<PrimitiveShape>()
.register_type::<BrushDesc>()
.register_type::<BrushKind>()
.register_type::<BrushPlaneDesc>()
.register_type::<BrushFaceDesc>()
.register_type::<StaticMeshRenderer>()
.register_type::<StaticMeshRendererEntry>()
.register_type::<StaticMeshAssetRef>()

View File

@ -35,6 +35,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0018](adr/0018-componentized-actor-inspector-and-materials.md) | Componentized actor inspector, imported asset refs, collider split, material overrides |
| [0019](adr/0019-local-bevy-render-timeout-patch.md) | Local `bevy_render` patch for transient Linux swapchain timeouts |
| [0020](adr/0020-jackdaw-inspired-editor-roadmap.md) | Jackdaw-inspired roadmap source policy and attribution requirements |
| [0021](adr/0021-brush-authoring-schema.md) | Brush authoring schema and hydration contract |
## Editor framework
@ -46,6 +47,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/roadmap.md](editor/roadmap.md) | Phased editor roadmap and status |
| [editor/brp.md](editor/brp.md) | BRP automation and authoring-only policy |
| [editor/debt-audit.md](editor/debt-audit.md) | Zero-debt phase gates |
| [editor/brushes.md](editor/brushes.md) | Brush authoring schema, hydration behavior, and MVP limits |
## Working plans (not canonical long-term)

View File

@ -0,0 +1,21 @@
# ADR 0021: Brush Authoring Schema
## Status
Accepted
## Context
Jackdaw roadmap work needs blockout geometry that is editable as authored data, survives scene saves, hydrates into runtime render/physics components, and can later support face/edge/vertex tools and CSG operators. Treating brushes as imported static meshes would hide the authoring intent and make later geometry edits depend on generated artifacts.
## Decision
Add `ActorKind::Brush` and `BrushDesc` as persisted shared authoring data. A brush stores convex polygon faces with stable face IDs, plane data, material/texture references, UV parameters, smoothing group, and shadow flags. The MVP supports additive cube brushes and hydrates active brush components into generated mesh children; generated children are stripped from saved scenes.
Brush actors conflict with `Primitive`, `StaticMeshRenderer`, `LightDesc`, and `ModelRef` in actor validation. Scene v1-to-v2 kind backfill infers `ActorKind::Brush` when an older level object contains `BrushDesc`.
## Consequences
Brush geometry is now a first-class scene authoring type rather than a renderer slot variant. Future face editing, clipping, and CSG tools should mutate `BrushDesc` and rebuild the generated preview mesh through hydration.
The MVP intentionally does not implement viewport face/edge/vertex tools, non-convex validation UI, or subtractive CSG evaluation. Those remain roadmap work.

View File

@ -11,12 +11,14 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [brp.md](brp.md) | Remote protocol, authoring-only policy, commands |
| [debt-audit.md](debt-audit.md) | Zero-debt phase gates and remaining items |
| [rendering.md](rendering.md) | GI modes, post-process volumes, presets, post FX assets |
| [brushes.md](brushes.md) | Brush authoring schema, hydration behavior, and MVP limits |
## Subsystems (code → doc)
| Code module | Responsibility | Doc |
|-------------|----------------|-----|
| `lib.rs` / `EditorPluginGroup` | Ordered plugin bundle, lib/bin split | ADR 0008, architecture.md |
| `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 |
| `scene/` | Level I/O, schema, viewport render-target setup | architecture.md |
| `viewport/` | Camera, selection, gizmos, render views | architecture.md |
| `play/` | PIE session, editor mode state | architecture.md |
@ -41,6 +43,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **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. 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/`.
- **Static mesh renderer** — model drag/drop defaults to `ActorKind::StaticMesh + StaticMeshRenderer` using normalized artifacts under `assets/meshes/generated/`. Renderer slots store imported `EditorAssetRef` mesh/material references, not source FBX/glTF paths. `SceneInstance` placement keeps the legacy `ImportedModel + ModelRef` path for full scene playback.
- **Brush authoring**`ActorKind::Brush + BrushDesc` stores persisted convex blockout faces and hydrates active brushes into generated mesh children. See [brushes.md](brushes.md).
- **Collider split** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references.
- **Material assets**`MaterialAsset` RON (`label` + `MaterialDesc`) is shader-schema aware. Actor material edits live on the actor `MaterialDesc`; static mesh slot material refs are source/default selectors, and content-browser asset inspectors are the path for editing shared material assets.
- **Prefab overrides**`PrefabInstance.overrides_ron` stores transform/material/child-visibility overrides; inspector collapsible groups with **Apply** / **Revert** (replaces transform-only revert v1).
@ -57,7 +60,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.
- **Viewport shading** (toolbar): **Lit** (default WYSIWYG), **Unlit** (base-color debug), **Colliders** (hide meshes, emphasize collider gizmos). Light gizmos show range spheres, spot cones, and directional sun arrows. A **mode badge** (bottom-right of the viewport) shows the active mode; Collider mode displays a warning banner.
- **Hierarchy** sort modes (**Manual**, **Name**, **Type**) use stable tie-breakers so duplicate names (e.g. several `Pillar` rows) do not reorder on every click. Drag-and-drop reparenting disables panel scroll-to-drag; drop indicators appear only while dragging.
- **Viewport picking**: click selects nearest object, actor root icon, or visualizer hit, with actor icons preferred when clicked over meshes; **Tab** cycles overlapping targets at the last click (selection pin deferred).
- **Viewport picking**: click selects nearest object, generated mesh child resolved to its authored actor, actor root icon, or visualizer hit, with actor icons preferred when clicked over meshes; **Tab** cycles overlapping targets at the last click (selection pin deferred).
- **Settings data** lives in `crates/settings`; **settings UI** lives in `settings_ui.rs`.
When implementing or changing any of the above, update this file and the root README in the same change.

View File

@ -147,6 +147,7 @@ On play enter/exit the editor snapshots **player sim state only** (transform, ve
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through the Bevy-free `scene::document::SceneDocument` seam. The document layer preserves the current RON storage while exposing stable actor/component document concepts and patch vocabulary for tools.
5. Hydration systems in `game` spawn meshes, colliders, lights, and static mesh renderer parts at runtime. `ActorId` and `ComponentInstanceId` are the persisted identities; raw Bevy `Entity` IDs are runtime-only and must not become tool-facing document IDs.
6. Brush actors store authored convex geometry as `BrushDesc`; hydration rebuilds generated mesh children from those faces and strips the children before save.
## Model import (glTF + FBX)

17
docs/editor/brushes.md Normal file
View File

@ -0,0 +1,17 @@
# Brush Authoring
Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc`.
## MVP Scope
- `BrushDesc::default()` creates an additive cube brush.
- Each face stores a stable ID, plane, vertices, optional material/texture refs, UV offset/scale/rotation, and smoothing group.
- Hydration builds generated mesh children from active brush components and strips those children before scene save.
- Brush actors validate separately from primitives and imported/static mesh actors.
- The Actor Inspector Add Component shelf can add a **Brush** component. The Brush card exposes kind, face count, shadow flags, and cube reset.
## Current Limits
- Only convex authored faces are supported by the mesh builder.
- Subtractive brushes are stored as markers but are not evaluated as CSG.
- Face, edge, vertex, clipping, and primitive brush creation tools are future roadmap work.