Add brush authoring schema
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
cc6acb188d
commit
9f95ba3082
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>();
|
||||
}
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.",
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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#"(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
231
crates/shared/src/hydration/brushes.rs
Normal file
231
crates/shared/src/hydration/brushes.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
21
docs/adr/0021-brush-authoring-schema.md
Normal file
21
docs/adr/0021-brush-authoring-schema.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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
17
docs/editor/brushes.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user