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 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 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. 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. - 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. - 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 - **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] 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] 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] 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 ## 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()), name: Some(asset.label.clone()),
transform: Transform::from_translation(translation), transform: Transform::from_translation(translation),
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: None, static_mesh_renderer: None,
material: None, material: None,
material_override: None, material_override: None,
@ -654,6 +655,7 @@ pub fn spawn_subasset_at(
name: Some(label.clone()), name: Some(label.clone()),
transform: Transform::from_translation(translation + Vec3::Y * 0.1), transform: Transform::from_translation(translation + Vec3::Y * 0.1),
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(slot)), static_mesh_renderer: Some(shared::StaticMeshRenderer::single(slot)),
material: None, material: None,
material_override: None, material_override: None,
@ -723,6 +725,7 @@ fn apply_static_mesh_placement_mode(
}), }),
transform, transform,
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(entry)), static_mesh_renderer: Some(shared::StaticMeshRenderer::single(entry)),
material: None, material: None,
material_override: None, material_override: None,

View File

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

View File

@ -1,6 +1,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use shared::{ 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, HierarchySiblingIndex, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialOverride,
ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance,
PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, 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()), .map(|name| name.as_str().to_string()),
transform: entity_ref.get::<Transform>().copied().unwrap_or_default(), transform: entity_ref.get::<Transform>().copied().unwrap_or_default(),
primitive: entity_ref.get::<Primitive>().cloned(), primitive: entity_ref.get::<Primitive>().cloned(),
brush: entity_ref.get::<BrushDesc>().cloned(),
static_mesh_renderer: entity_ref.get::<StaticMeshRenderer>().cloned(), static_mesh_renderer: entity_ref.get::<StaticMeshRenderer>().cloned(),
material: entity_ref.get::<MaterialDesc>().cloned(), material: entity_ref.get::<MaterialDesc>().cloned(),
material_override: entity_ref.get::<MaterialOverride>().cloned(), material_override: entity_ref.get::<MaterialOverride>().cloned(),
@ -185,6 +186,9 @@ fn spawn_snapshot_with_parent(
if let Some(primitive) = &snapshot.primitive { if let Some(primitive) = &snapshot.primitive {
entity_mut.insert(primitive.clone()); entity_mut.insert(primitive.clone());
} }
if let Some(brush) = &snapshot.brush {
entity_mut.insert(brush.clone());
}
if let Some(renderer) = &snapshot.static_mesh_renderer { if let Some(renderer) = &snapshot.static_mesh_renderer {
entity_mut.insert(renderer.clone()); 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 }); 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( pub fn set_static_mesh_renderer_with_history(
world: &mut World, world: &mut World,
entity: Entity, entity: Entity,
@ -558,6 +576,7 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) {
name: Some("Group".to_string()), name: Some("Group".to_string()),
transform: Transform::default(), transform: Transform::default(),
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: None, static_mesh_renderer: None,
material: None, material: None,
material_override: 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()), name: Some("Empty".to_string()),
transform: Transform::default(), transform: Transform::default(),
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: None, static_mesh_renderer: None,
material: None, material: None,
material_override: None, material_override: None,
@ -777,6 +797,9 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
EditorCommand::SetPrimitive { entity, old, .. } => { EditorCommand::SetPrimitive { entity, old, .. } => {
apply_primitive(world, *entity, old); apply_primitive(world, *entity, old);
} }
EditorCommand::SetBrush { entity, old, .. } => {
apply_brush(world, *entity, old);
}
EditorCommand::SetStaticMeshRenderer { entity, old, .. } => { EditorCommand::SetStaticMeshRenderer { entity, old, .. } => {
apply_static_mesh_renderer(world, *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()); 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, .. } => { EditorCommand::SetStaticMeshRenderer { entity, new, .. } => {
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
entity_mut.insert(new.clone()); 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( fn apply_static_mesh_renderer(
world: &mut World, world: &mut World,
entity: Entity, entity: Entity,
@ -1098,6 +1139,9 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito
if let Some(v) = &snapshot.primitive { if let Some(v) = &snapshot.primitive {
entity_mut.insert(v.clone()); entity_mut.insert(v.clone());
} }
if let Some(v) = &snapshot.brush {
entity_mut.insert(v.clone());
}
if let Some(v) = &snapshot.static_mesh_renderer { if let Some(v) = &snapshot.static_mesh_renderer {
entity_mut.insert(v.clone()); 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() { if snapshot.primitive.is_some() {
entity_mut.remove::<Primitive>(); entity_mut.remove::<Primitive>();
} }
if snapshot.brush.is_some() {
entity_mut.remove::<BrushDesc>();
}
if snapshot.static_mesh_renderer.is_some() { if snapshot.static_mesh_renderer.is_some() {
entity_mut.remove::<StaticMeshRenderer>(); 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 serde::de::DeserializeSeed;
use shared::{ use shared::{
flush_level_object_hydration, infer_actor_kind, strip_hydrated_entity, validate_actor, ActorId, 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, HierarchySiblingIndex, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialOverride,
ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance,
PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
@ -389,6 +389,21 @@ fn format_actor_validation(err: ActorValidationError) -> String {
ActorValidationError::MissingTransform => { ActorValidationError::MissingTransform => {
"Save failed: level object missing Transform".into() "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 => { ActorValidationError::StaticMeshMissingPrimitive => {
"Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot" "Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot"
.into() .into()
@ -462,6 +477,7 @@ fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Resul
.allow_component::<ActorKind>() .allow_component::<ActorKind>()
.allow_component::<InspectorOrder>() .allow_component::<InspectorOrder>()
.allow_component::<Primitive>() .allow_component::<Primitive>()
.allow_component::<BrushDesc>()
.allow_component::<StaticMeshRenderer>() .allow_component::<StaticMeshRenderer>()
.allow_component::<MaterialDesc>() .allow_component::<MaterialDesc>()
.allow_component::<MaterialOverride>() .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 { match kind {
ActorKind::StaticMesh ActorKind::StaticMesh
| ActorKind::Brush
| ActorKind::ImportedModel | ActorKind::ImportedModel
| ActorKind::Light | ActorKind::Light
| ActorKind::Empty | 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 { fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
match kind { match kind {
ActorKind::StaticMesh | ActorKind::ImportedModel => icons::CUBE, ActorKind::StaticMesh | ActorKind::Brush | ActorKind::ImportedModel => icons::CUBE,
ActorKind::Light => icons::LIGHTBULB, ActorKind::Light => icons::LIGHTBULB,
ActorKind::PrefabAnchor => icons::PACKAGE, ActorKind::PrefabAnchor => icons::PACKAGE,
ActorKind::PlayerSpawn => icons::PERSON_SIMPLE_RUN, 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.", description: "Renders one or more normalized imported mesh slots.",
search_terms: &["mesh", "renderer", "rendering", "model", "static"], search_terms: &["mesh", "renderer", "rendering", "model", "static"],
recommended: &[], recommended: &[],
conflicts_with: &["shared::components::Primitive"], conflicts_with: &[
"shared::components::Primitive",
"shared::components::BrushDesc",
],
hydration_effect: hydration_effect:
"Hydrates into generated mesh children and material bindings.", "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 { EditorComponentDescriptor {
type_name: "shared::components::MaterialDesc", type_name: "shared::components::MaterialDesc",
display_name: "Authoring Material", display_name: "Authoring Material",
@ -127,7 +148,10 @@ impl Default for EditorComponentRegistry {
description: "Creates an authored built-in primitive shape.", description: "Creates an authored built-in primitive shape.",
search_terms: &["box", "sphere", "ramp", "primitive"], search_terms: &["box", "sphere", "ramp", "primitive"],
recommended: &[], recommended: &[],
conflicts_with: &["shared::components::StaticMeshRenderer"], conflicts_with: &[
"shared::components::StaticMeshRenderer",
"shared::components::BrushDesc",
],
hydration_effect: hydration_effect:
"Hydrates into generated primitive render and collision data.", "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()), name: Some(name.to_string()),
transform: Transform::from_translation(translation), transform: Transform::from_translation(translation),
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: None, static_mesh_renderer: None,
material: None, material: None,
material_override: 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()), name: Some("Player Start".to_string()),
transform, transform,
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: None, static_mesh_renderer: None,
material: None, material: None,
material_override: 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()), name: Some("Scene Sun".to_string()),
transform, transform,
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: None, static_mesh_renderer: None,
material: None, material: None,
material_override: None, material_override: None,

View File

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

View File

@ -7,25 +7,25 @@ use bevy::prelude::*;
use bevy_egui::egui; use bevy_egui::egui;
use egui_phosphor_icons::{icons, Icon}; use egui_phosphor_icons::{icons, Icon};
use shared::{ use shared::{
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, ColliderDesc, inspector_component_active, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind,
ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
LightDesc, MaterialDesc, MaterialParameter, MaterialParameterValue, MaterialShaderKind, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter,
ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, Primitive, MaterialParameterValue, MaterialShaderKind, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PrimitiveShape, ProjectSun, RigidBodyDesc, StaticMeshRenderer, StaticMeshRendererEntry, PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
TeamSpawn, TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, WeaponSpawn,
AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, COMPONENT_BRUSH_DESC,
COMPONENT_MATERIAL_DESC, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
COMPONENT_PLAYER_SPAWN, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN,
COMPONENT_PRIMITIVE, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE,
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_STATIC_MESH_RENDERER,
COMPONENT_WEAPON_SPAWN, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
}; };
use crate::history::{ use crate::history::{
material_eq, set_collider_with_history, set_inspector_order_with_history, material_eq, set_brush_with_history, set_collider_with_history,
set_light_with_history, set_material_with_history, set_physics_with_history, set_inspector_order_with_history, set_light_with_history, set_material_with_history,
set_post_process_volume_with_history, set_primitive_with_history, set_rigid_body_with_history, set_physics_with_history, set_post_process_volume_with_history, set_primitive_with_history,
set_static_mesh_renderer_with_history, EditorEntitySnapshot, set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot,
}; };
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::ui::theme::{ use crate::ui::theme::{
@ -131,6 +131,7 @@ enum AddComponentShelfDirection {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum CopiedComponent { enum CopiedComponent {
Primitive(Primitive), Primitive(Primitive),
BrushDesc(BrushDesc),
StaticMeshRenderer(StaticMeshRenderer), StaticMeshRenderer(StaticMeshRenderer),
MaterialDesc(MaterialDesc), MaterialDesc(MaterialDesc),
LightDesc(LightDesc), LightDesc(LightDesc),
@ -149,6 +150,7 @@ impl CopiedComponent {
fn type_name(&self) -> &'static str { fn type_name(&self) -> &'static str {
match self { match self {
Self::Primitive(_) => COMPONENT_PRIMITIVE, Self::Primitive(_) => COMPONENT_PRIMITIVE,
Self::BrushDesc(_) => COMPONENT_BRUSH_DESC,
Self::StaticMeshRenderer(_) => COMPONENT_STATIC_MESH_RENDERER, Self::StaticMeshRenderer(_) => COMPONENT_STATIC_MESH_RENDERER,
Self::MaterialDesc(_) => COMPONENT_MATERIAL_DESC, Self::MaterialDesc(_) => COMPONENT_MATERIAL_DESC,
Self::LightDesc(_) => COMPONENT_LIGHT_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() { if world.get::<Primitive>(entity).is_some() {
present.push(COMPONENT_PRIMITIVE); 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() { if world.get::<MaterialDesc>(entity).is_some() || world.get::<Primitive>(entity).is_some() {
present.push(COMPONENT_MATERIAL_DESC); present.push(COMPONENT_MATERIAL_DESC);
} }
@ -405,6 +410,10 @@ fn copy_component(world: &mut World, entity: Entity, type_name: &str) {
.get::<Primitive>(entity) .get::<Primitive>(entity)
.cloned() .cloned()
.map(CopiedComponent::Primitive), .map(CopiedComponent::Primitive),
COMPONENT_BRUSH_DESC => world
.get::<BrushDesc>(entity)
.cloned()
.map(CopiedComponent::BrushDesc),
COMPONENT_STATIC_MESH_RENDERER => world COMPONENT_STATIC_MESH_RENDERER => world
.get::<StaticMeshRenderer>(entity) .get::<StaticMeshRenderer>(entity)
.cloned() .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 => { Some(CopiedComponent::Primitive(value)) if type_name == COMPONENT_PRIMITIVE => {
set_primitive_with_history(world, entity, value); 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)) Some(CopiedComponent::StaticMeshRenderer(value))
if type_name == COMPONENT_STATIC_MESH_RENDERER => 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) { fn reset_component(world: &mut World, entity: Entity, type_name: &str) {
match type_name { match type_name {
COMPONENT_PRIMITIVE => set_primitive_with_history(world, entity, Primitive::default()), 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 => { COMPONENT_STATIC_MESH_RENDERER => {
set_static_mesh_renderer_with_history(world, entity, StaticMeshRenderer::default()); 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>(); entity_mut.remove::<Primitive>();
true 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() => { COMPONENT_STATIC_MESH_RENDERER if before.static_mesh_renderer.is_some() => {
entity_mut.remove::<StaticMeshRenderer>(); entity_mut.remove::<StaticMeshRenderer>();
true true
@ -670,6 +687,7 @@ fn draw_authoring_component_by_type(
) { ) {
match type_name { match type_name {
COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity), 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_PRIMITIVE => primitive_editor_ui(world, ui, entity),
COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity), COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity),
COMPONENT_LIGHT_DESC => light_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 { fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
match type_name { match type_name {
"shared::components::Primitive" => world.get::<Primitive>(entity).is_some(), "shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
"shared::components::BrushDesc" => world.get::<BrushDesc>(entity).is_some(),
"shared::components::StaticMeshRenderer" => { "shared::components::StaticMeshRenderer" => {
world.get::<StaticMeshRenderer>(entity).is_some() 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) { fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) {
match type_name { match type_name {
"shared::components::Primitive" => insert_component(world, entity, |world, e| { "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| { "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| { "shared::components::MaterialDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(MaterialDesc::default()); world.entity_mut(e).insert(MaterialDesc::default());
@ -1189,6 +1217,7 @@ fn diff_added(before: &EditorEntitySnapshot, after: &EditorEntitySnapshot) -> Ed
.primitive .primitive
.clone() .clone()
.filter(|_| before.primitive.is_none()), .filter(|_| before.primitive.is_none()),
brush: after.brush.clone().filter(|_| before.brush.is_none()),
static_mesh_renderer: after static_mesh_renderer: after
.static_mesh_renderer .static_mesh_renderer
.clone() .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) { fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut primitive) = world.get::<Primitive>(entity).cloned() else { let Some(mut primitive) = world.get::<Primitive>(entity).cloned() else {
return; return;

View File

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

View File

@ -455,6 +455,7 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
name: Some("Post Process Volume".into()), name: Some("Post Process Volume".into()),
transform: Transform::from_translation(translation), transform: Transform::from_translation(translation),
primitive: None, primitive: None,
brush: None,
static_mesh_renderer: None, static_mesh_renderer: None,
material: None, material: None,
material_override: 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::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction}; use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction};
use bevy::prelude::*; use bevy::prelude::*;
@ -36,6 +37,15 @@ pub struct ViewportPickStack {
pub struct EditorSelectionPlugin; 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 { impl Plugin for EditorSelectionPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<SelectedEntity>() app.init_resource::<SelectedEntity>()
@ -88,9 +98,7 @@ fn handle_pick_events(
pointers: Query<&PointerInteraction>, pointers: Query<&PointerInteraction>,
editor_cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>, editor_cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut mesh_ray_cast: MeshRayCast, mut mesh_ray_cast: MeshRayCast,
level_objects: Query<Entity, (With<LevelObject>, Without<EditorOnly>)>, pick_targets: PickTargetQueries,
proxies: Query<&EditorVisualizerProxy>,
icon_proxies: Query<&ActorIconProxy>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
@ -132,9 +140,7 @@ fn handle_pick_events(
&mut pick_stack, &mut pick_stack,
&editor_cameras, &editor_cameras,
&mut mesh_ray_cast, &mut mesh_ray_cast,
&level_objects, &pick_targets,
&proxies,
&icon_proxies,
pointer_pos, pointer_pos,
additive, additive,
); );
@ -152,9 +158,11 @@ fn handle_pick_events(
let mut picked = None; let mut picked = None;
for interaction in &pointers { for interaction in &pointers {
if let Some(entity) = interaction.as_slice().iter().find_map(|(entity, _hit)| { if let Some(entity) = interaction
pick_target_for_entity(*entity, &level_objects, &proxies, &icon_proxies) .as_slice()
}) { .iter()
.find_map(|(entity, _hit)| pick_target_for_entity(*entity, &pick_targets))
{
picked = Some(entity); picked = Some(entity);
break; break;
} }
@ -167,9 +175,7 @@ fn handle_pick_events(
let hits = collect_viewport_pick_hits( let hits = collect_viewport_pick_hits(
&editor_cameras, &editor_cameras,
&mut mesh_ray_cast, &mut mesh_ray_cast,
&level_objects, &pick_targets,
&proxies,
&icon_proxies,
ui_state.viewport_rect, ui_state.viewport_rect,
pointer_pos, pointer_pos,
); );
@ -231,18 +237,14 @@ fn apply_viewport_pick(
pick_stack: &mut ViewportPickStack, pick_stack: &mut ViewportPickStack,
editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>, editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mesh_ray_cast: &mut MeshRayCast, mesh_ray_cast: &mut MeshRayCast,
level_objects: &Query<Entity, (With<LevelObject>, Without<EditorOnly>)>, pick_targets: &PickTargetQueries,
proxies: &Query<&EditorVisualizerProxy>,
icon_proxies: &Query<&ActorIconProxy>,
pointer_pos: egui::Pos2, pointer_pos: egui::Pos2,
additive: bool, additive: bool,
) { ) {
let hits = collect_viewport_pick_hits( let hits = collect_viewport_pick_hits(
editor_cameras, editor_cameras,
mesh_ray_cast, mesh_ray_cast,
level_objects, pick_targets,
proxies,
icon_proxies,
ui_state.viewport_rect, ui_state.viewport_rect,
pointer_pos, pointer_pos,
); );
@ -277,9 +279,7 @@ fn apply_pick_result(
fn collect_viewport_pick_hits( fn collect_viewport_pick_hits(
editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>, editor_cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mesh_ray_cast: &mut MeshRayCast, mesh_ray_cast: &mut MeshRayCast,
level_objects: &Query<Entity, (With<LevelObject>, Without<EditorOnly>)>, pick_targets: &PickTargetQueries,
proxies: &Query<&EditorVisualizerProxy>,
icon_proxies: &Query<&ActorIconProxy>,
scene_rect: egui::Rect, scene_rect: egui::Rect,
pointer_pos: egui::Pos2, pointer_pos: egui::Pos2,
) -> Vec<Entity> { ) -> Vec<Entity> {
@ -298,15 +298,7 @@ fn collect_viewport_pick_hits(
let Some(ray) = scene_view_ray(camera, transform, pointer_pos, scene_rect) else { let Some(ray) = scene_view_ray(camera, transform, pointer_pos, scene_rect) else {
return Vec::new(); return Vec::new();
}; };
let filter = |entity: Entity| { let filter = |entity: Entity| pick_target_for_entity(entity, pick_targets).is_some();
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 settings = MeshRayCastSettings::default() let settings = MeshRayCastSettings::default()
.with_visibility(RayCastVisibility::Any) .with_visibility(RayCastVisibility::Any)
.with_filter(&filter) .with_filter(&filter)
@ -317,13 +309,11 @@ fn collect_viewport_pick_hits(
let mut ordered = Vec::new(); let mut ordered = Vec::new();
for icon_priority in [true, false] { for icon_priority in [true, false] {
for (entity, _) in hits { 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 { if is_icon != icon_priority {
continue; continue;
} }
let Some(target) = let Some(target) = pick_target_for_entity(*entity, pick_targets) else {
pick_target_for_entity(*entity, level_objects, proxies, icon_proxies)
else {
continue; continue;
}; };
if seen.insert(target) { if seen.insert(target) {
@ -334,19 +324,41 @@ fn collect_viewport_pick_hits(
ordered ordered
} }
fn pick_target_for_entity( fn pick_target_for_entity(entity: Entity, pick_targets: &PickTargetQueries) -> Option<Entity> {
entity: Entity, if let Ok(proxy) = pick_targets.icon_proxies.get(entity) {
level_objects: &Query<Entity, (With<LevelObject>, Without<EditorOnly>)>, return pick_targets
proxies: &Query<&EditorVisualizerProxy>, .level_objects
icon_proxies: &Query<&ActorIconProxy>, .contains(proxy.target)
) -> Option<Entity> { .then_some(proxy.target);
if let Ok(proxy) = icon_proxies.get(entity) {
return 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); 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( 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\"") { if block.contains("\"shared::components::ModelRef\"") {
return actor_kind_ron(ActorKind::ImportedModel); return actor_kind_ron(ActorKind::ImportedModel);
} }
if block.contains("\"shared::components::BrushDesc\"") {
return actor_kind_ron(ActorKind::Brush);
}
if block.contains("\"shared::components::Primitive\"") if block.contains("\"shared::components::Primitive\"")
|| block.contains("\"shared::components::StaticMeshRenderer\"") || 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 { fn actor_kind_ron(kind: ActorKind) -> &'static str {
match kind { match kind {
ActorKind::Empty => "Empty", ActorKind::Empty => "Empty",
ActorKind::Brush => "Brush",
ActorKind::StaticMesh => "StaticMesh", ActorKind::StaticMesh => "StaticMesh",
ActorKind::ImportedModel => "ImportedModel", ActorKind::ImportedModel => "ImportedModel",
ActorKind::Light => "Light", ActorKind::Light => "Light",
@ -208,6 +212,29 @@ mod tests {
assert!(migrated.contains("StaticMesh")); 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] #[test]
fn fixes_low_directional_lux() { fn fixes_low_directional_lux() {
let body = r#"( let body = r#"(

View File

@ -4,7 +4,7 @@ use bevy::ecs::world::EntityRef;
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{
ActorKind, LevelObject, LightDesc, ModelRef, ObjectiveMarker, PlayerSpawn, ActorKind, BrushDesc, LevelObject, LightDesc, ModelRef, ObjectiveMarker, PlayerSpawn,
PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, StaticMeshRenderer, TeamSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, StaticMeshRenderer, TeamSpawn,
TriggerVolume, WeaponSpawn, TriggerVolume, WeaponSpawn,
}; };
@ -35,6 +35,9 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
if entity.get::<LightDesc>().is_some() { if entity.get::<LightDesc>().is_some() {
return Some(ActorKind::Light); 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() { if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
return Some(ActorKind::StaticMesh); return Some(ActorKind::StaticMesh);
} }
@ -51,6 +54,11 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
pub enum ActorValidationError { pub enum ActorValidationError {
MissingActorKind, MissingActorKind,
MissingTransform, MissingTransform,
BrushMissingDesc,
BrushHasPrimitive,
BrushHasStaticMeshRenderer,
BrushHasLight,
BrushHasModelRef,
StaticMeshMissingPrimitive, StaticMeshMissingPrimitive,
StaticMeshHasLight, StaticMeshHasLight,
StaticMeshHasModelRef, StaticMeshHasModelRef,
@ -90,6 +98,23 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
} }
match kind { 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 => { ActorKind::StaticMesh => {
let has_static_mesh_renderer = entity let has_static_mesh_renderer = entity
.get::<StaticMeshRenderer>() .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] #[test]
fn validate_post_process_volume_ok() { fn validate_post_process_volume_ok() {
let mut world = World::new(); 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_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_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc"; pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc";
pub const COMPONENT_MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride"; 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. /// Legacy static mesh collision setting kept only for loading older scenes.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] #[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
@ -736,6 +916,7 @@ impl Default for EditorVisibility {
#[reflect(Component, Debug, PartialEq, Serialize, Deserialize)] #[reflect(Component, Debug, PartialEq, Serialize, Deserialize)]
pub enum ActorKind { pub enum ActorKind {
Empty, Empty,
Brush,
StaticMesh, StaticMesh,
ImportedModel, ImportedModel,
Light, 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. //! Generates runtime render/physics/light components from reflectable authoring data.
mod brushes;
mod lights; mod lights;
mod materials; mod materials;
mod models; mod models;
@ -16,6 +17,7 @@ pub use strip::{strip_hydrated, strip_hydrated_entity};
use bevy::ecs::system::SystemState; use bevy::ecs::system::SystemState;
use bevy::prelude::*; use bevy::prelude::*;
use brushes::{hydrate_brushes, spawn_brush_mesh, HydratedBrushMesh};
use lights::{hydrate_lights, reconcile_missing_runtime_lights}; use lights::{hydrate_lights, reconcile_missing_runtime_lights};
use materials::hydrate_materials; use materials::hydrate_materials;
pub use materials::material_from_desc; pub use materials::material_from_desc;
@ -30,10 +32,10 @@ use static_meshes::{
use visibility::{init_editor_visibility_on_spawn, sync_editor_visibility}; use visibility::{init_editor_visibility_on_spawn, sync_editor_visibility};
use crate::{ use crate::{
inspector_component_active, ColliderDesc, EditorVisibility, InspectorOrder, LevelObject, inspector_component_active, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
MaterialDesc, MaterialOverride, Primitive, StaticMeshRenderer, COMPONENT_COLLIDER_DESC, LevelObject, MaterialDesc, MaterialOverride, Primitive, StaticMeshRenderer,
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_PRIMITIVE, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_PRIMITIVE, COMPONENT_STATIC_MESH_RENDERER,
}; };
/// Registers hydration systems in deterministic order. /// Registers hydration systems in deterministic order.
@ -46,6 +48,7 @@ impl Plugin for HydrationPlugin {
Update, Update,
( (
hydrate_primitives, hydrate_primitives,
hydrate_brushes,
hydrate_materials, hydrate_materials,
hydrate_lights, hydrate_lights,
reconcile_missing_runtime_lights, reconcile_missing_runtime_lights,
@ -83,6 +86,45 @@ pub fn flush_level_object_hydration(world: &mut World) {
.map(|(entity, desc, _)| (entity, desc.clone())) .map(|(entity, desc, _)| (entity, desc.clone()))
.collect(); .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 let static_mesh_entities_for_cleanup: Vec<Entity> = world
.query_filtered::<(Entity, &StaticMeshRenderer), With<LevelObject>>() .query_filtered::<(Entity, &StaticMeshRenderer), With<LevelObject>>()
.iter(world) .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 let visibility_targets: Vec<(Entity, EditorVisibility)> = world
.query_filtered::<(Entity, &EditorVisibility), With<LevelObject>>() .query_filtered::<(Entity, &EditorVisibility), With<LevelObject>>()
.iter(world) .iter(world)
@ -187,6 +240,23 @@ pub fn flush_level_object_hydration(world: &mut World) {
commands.entity(entity).insert(MeshMaterial3d(material)); 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 { for child in generated_static_mesh_parts {
commands.entity(child).despawn(); commands.entity(child).despawn();
} }

View File

@ -54,6 +54,10 @@ impl Plugin for SharedTypesPlugin {
.register_type::<ProjectSun>() .register_type::<ProjectSun>()
.register_type::<Primitive>() .register_type::<Primitive>()
.register_type::<PrimitiveShape>() .register_type::<PrimitiveShape>()
.register_type::<BrushDesc>()
.register_type::<BrushKind>()
.register_type::<BrushPlaneDesc>()
.register_type::<BrushFaceDesc>()
.register_type::<StaticMeshRenderer>() .register_type::<StaticMeshRenderer>()
.register_type::<StaticMeshRendererEntry>() .register_type::<StaticMeshRendererEntry>()
.register_type::<StaticMeshAssetRef>() .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 | | [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 | | [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 | | [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 ## 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/roadmap.md](editor/roadmap.md) | Phased editor roadmap and status |
| [editor/brp.md](editor/brp.md) | BRP automation and authoring-only policy | | [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/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) ## 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 | | [brp.md](brp.md) | Remote protocol, authoring-only policy, commands |
| [debt-audit.md](debt-audit.md) | Zero-debt phase gates and remaining items | | [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 | | [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) ## Subsystems (code → doc)
| Code module | Responsibility | Doc | | Code module | Responsibility | Doc |
|-------------|----------------|-----| |-------------|----------------|-----|
| `lib.rs` / `EditorPluginGroup` | Ordered plugin bundle, lib/bin split | ADR 0008, architecture.md | | `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 | | `scene/` | Level I/O, schema, viewport render-target setup | architecture.md |
| `viewport/` | Camera, selection, gizmos, render views | architecture.md | | `viewport/` | Camera, selection, gizmos, render views | architecture.md |
| `play/` | PIE session, editor mode state | 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. - **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/`. - **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. - **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. - **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. - **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). - **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. - **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. - **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. - **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`. - **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. 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. 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. 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. 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) ## 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.