diff --git a/README.md b/README.md index 8b7fa53..baad12d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/editor/src/assets/catalog.rs b/crates/editor/src/assets/catalog.rs index 406afcf..fa0433e 100644 --- a/crates/editor/src/assets/catalog.rs +++ b/crates/editor/src/assets/catalog.rs @@ -455,6 +455,7 @@ pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option, pub transform: Transform, pub primitive: Option, + pub brush: Option, pub static_mesh_renderer: Option, pub material: Option, pub material_override: Option, @@ -102,6 +103,11 @@ pub enum EditorCommand { old: Option, new: Primitive, }, + SetBrush { + entity: Entity, + old: Option, + new: BrushDesc, + }, SetStaticMeshRenderer { entity: Entity, old: Option, @@ -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", diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index 265e339..175bae2 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -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().copied().unwrap_or_default(), primitive: entity_ref.get::().cloned(), + brush: entity_ref.get::().cloned(), static_mesh_renderer: entity_ref.get::().cloned(), material: entity_ref.get::().cloned(), material_override: entity_ref.get::().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::(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) { + if let Ok(mut entity_mut) = world.get_entity_mut(entity) { + match brush { + Some(value) => { + entity_mut.insert(value.clone()); + } + None => { + entity_mut.remove::(); + } + } + } +} + 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::(); } + if snapshot.brush.is_some() { + entity_mut.remove::(); + } if snapshot.static_mesh_renderer.is_some() { entity_mut.remove::(); } diff --git a/crates/editor/src/scene/scene_io.rs b/crates/editor/src/scene/scene_io.rs index 3cc4ccb..5d2637c 100644 --- a/crates/editor/src/scene/scene_io.rs +++ b/crates/editor/src/scene/scene_io.rs @@ -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) -> Resul .allow_component::() .allow_component::() .allow_component::() + .allow_component::() .allow_component::() .allow_component::() .allow_component::() diff --git a/crates/editor/src/ui/actor_inspector/mod.rs b/crates/editor/src/ui/actor_inspector/mod.rs index 5f4427c..ffca47f 100644 --- a/crates/editor/src/ui/actor_inspector/mod.rs +++ b/crates/editor/src/ui/actor_inspector/mod.rs @@ -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, diff --git a/crates/editor/src/ui/component_registry.rs b/crates/editor/src/ui/component_registry.rs index a452e58..3d139fd 100644 --- a/crates/editor/src/ui/component_registry.rs +++ b/crates/editor/src/ui/component_registry.rs @@ -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.", }, diff --git a/crates/editor/src/ui/helpers.rs b/crates/editor/src/ui/helpers.rs index cab9700..b87453b 100644 --- a/crates/editor/src/ui/helpers.rs +++ b/crates/editor/src/ui/helpers.rs @@ -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, diff --git a/crates/editor/src/ui/hierarchy_ops.rs b/crates/editor/src/ui/hierarchy_ops.rs index 86cd84d..2eeed79 100644 --- a/crates/editor/src/ui/hierarchy_ops.rs +++ b/crates/editor/src/ui/hierarchy_ops.rs @@ -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(), diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index a050188..424440f 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -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::(entity).is_some() { present.push(COMPONENT_PRIMITIVE); } + if world.get::(entity).is_some() { + present.push(COMPONENT_BRUSH_DESC); + } if world.get::(entity).is_some() || world.get::(entity).is_some() { present.push(COMPONENT_MATERIAL_DESC); } @@ -405,6 +410,10 @@ fn copy_component(world: &mut World, entity: Entity, type_name: &str) { .get::(entity) .cloned() .map(CopiedComponent::Primitive), + COMPONENT_BRUSH_DESC => world + .get::(entity) + .cloned() + .map(CopiedComponent::BrushDesc), COMPONENT_STATIC_MESH_RENDERER => world .get::(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::(); true } + COMPONENT_BRUSH_DESC if before.brush.is_some() => { + entity_mut.remove::(); + true + } COMPONENT_STATIC_MESH_RENDERER if before.static_mesh_renderer.is_some() => { entity_mut.remove::(); 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::(entity).is_some(), + "shared::components::BrushDesc" => world.get::(entity).is_some(), "shared::components::StaticMeshRenderer" => { world.get::(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::(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::(entity).cloned() else { return; diff --git a/crates/editor/src/viewport/actor_icons.rs b/crates/editor/src/viewport/actor_icons.rs index 53fb26d..7048b51 100644 --- a/crates/editor/src/viewport/actor_icons.rs +++ b/crates/editor/src/viewport/actor_icons.rs @@ -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 { diff --git a/crates/editor/src/viewport/rendering_diagnostics.rs b/crates/editor/src/viewport/rendering_diagnostics.rs index 22855aa..3d75f4c 100644 --- a/crates/editor/src/viewport/rendering_diagnostics.rs +++ b/crates/editor/src/viewport/rendering_diagnostics.rs @@ -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, diff --git a/crates/editor/src/viewport/selection.rs b/crates/editor/src/viewport/selection.rs index f90cd96..d7558b4 100644 --- a/crates/editor/src/viewport/selection.rs +++ b/crates/editor/src/viewport/selection.rs @@ -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, Without)>, + 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>, +} + impl Plugin for EditorSelectionPlugin { fn build(&self, app: &mut App) { app.init_resource::() @@ -88,9 +98,7 @@ fn handle_pick_events( pointers: Query<&PointerInteraction>, editor_cameras: Query<(&Camera, &GlobalTransform), With>, mut mesh_ray_cast: MeshRayCast, - level_objects: Query, Without)>, - proxies: Query<&EditorVisualizerProxy>, - icon_proxies: Query<&ActorIconProxy>, + pick_targets: PickTargetQueries, keys: Res>, buttons: Res>, display: Res, @@ -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>, mesh_ray_cast: &mut MeshRayCast, - level_objects: &Query, Without)>, - 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>, mesh_ray_cast: &mut MeshRayCast, - level_objects: &Query, Without)>, - proxies: &Query<&EditorVisualizerProxy>, - icon_proxies: &Query<&ActorIconProxy>, + pick_targets: &PickTargetQueries, scene_rect: egui::Rect, pointer_pos: egui::Pos2, ) -> Vec { @@ -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, Without)>, - proxies: &Query<&EditorVisualizerProxy>, - icon_proxies: &Query<&ActorIconProxy>, -) -> Option { - 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 { + 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( diff --git a/crates/scene/src/migrate.rs b/crates/scene/src/migrate.rs index de2703d..7eee9a1 100644 --- a/crates/scene/src/migrate.rs +++ b/crates/scene/src/migrate.rs @@ -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#"( diff --git a/crates/shared/src/actor.rs b/crates/shared/src/actor.rs index ecca053..bd59ba6 100644 --- a/crates/shared/src/actor.rs +++ b/crates/shared/src/actor.rs @@ -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 { if entity.get::().is_some() { return Some(ActorKind::Light); } + if entity.get::().is_some() { + return Some(ActorKind::Brush); + } if entity.get::().is_some() || entity.get::().is_some() { return Some(ActorKind::StaticMesh); } @@ -51,6 +54,11 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option { 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::().is_none() { + return Err(ActorValidationError::BrushMissingDesc); + } + if entity.get::().is_some() { + return Err(ActorValidationError::BrushHasPrimitive); + } + if entity.get::().is_some() { + return Err(ActorValidationError::BrushHasStaticMeshRenderer); + } + if entity.get::().is_some() { + return Err(ActorValidationError::BrushHasLight); + } + if entity.get::().is_some() { + return Err(ActorValidationError::BrushHasModelRef); + } + } ActorKind::StaticMesh => { let has_static_mesh_renderer = entity .get::() @@ -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(); diff --git a/crates/shared/src/components.rs b/crates/shared/src/components.rs index ef2c6ea..d204a2d 100644 --- a/crates/shared/src/components.rs +++ b/crates/shared/src/components.rs @@ -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, + #[serde(default)] + pub material: Option, + #[serde(default)] + pub texture: Option, + #[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, + #[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) -> 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, diff --git a/crates/shared/src/hydration/brushes.rs b/crates/shared/src/hydration/brushes.rs new file mode 100644 index 0000000..c03c87a --- /dev/null +++ b/crates/shared/src/hydration/brushes.rs @@ -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, + mut meshes: ResMut>, + mut materials: ResMut>, + brushes: Query< + ( + Entity, + &BrushDesc, + Option<&MaterialDesc>, + Option<&ColliderDesc>, + Option<&InspectorOrder>, + ), + ( + With, + Or<( + Added, + Changed, + Changed, + Changed, + Changed, + )>, + ), + >, + children: Query<&Children>, + generated_brushes: Query<(), With>, +) { + 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, + materials: &mut Assets, + 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>, +) { + 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 { + 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::::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, +) -> 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()); + } +} diff --git a/crates/shared/src/hydration/mod.rs b/crates/shared/src/hydration/mod.rs index f567e66..4f2dd2b 100644 --- a/crates/shared/src/hydration/mod.rs +++ b/crates/shared/src/hydration/mod.rs @@ -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 = world + .query_filtered::<(Entity, &BrushDesc), With>() + .iter(world) + .map(|(entity, _)| entity) + .collect(); + + let brush_targets: Vec<( + Entity, + BrushDesc, + Option, + Option, + Option, + )> = world + .query_filtered::<( + Entity, + &BrushDesc, + Option<&MaterialDesc>, + Option<&ColliderDesc>, + Option<&InspectorOrder>, + ), With>() + .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 = world .query_filtered::<(Entity, &StaticMeshRenderer), With>() .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::(*entity) { + for child in children.iter() { + if world.get::(child).is_some() { + generated_brush_meshes.push(child); + } + } + } + } + let visibility_targets: Vec<(Entity, EditorVisibility)> = world .query_filtered::<(Entity, &EditorVisibility), With>() .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(); } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 28e6277..0caf62c 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -54,6 +54,10 @@ impl Plugin for SharedTypesPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() diff --git a/docs/README.md b/docs/README.md index 31c13bb..e419add 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/adr/0021-brush-authoring-schema.md b/docs/adr/0021-brush-authoring-schema.md new file mode 100644 index 0000000..1aa97df --- /dev/null +++ b/docs/adr/0021-brush-authoring-schema.md @@ -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. diff --git a/docs/editor/README.md b/docs/editor/README.md index d63aacd..87d0ef1 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -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. diff --git a/docs/editor/architecture.md b/docs/editor/architecture.md index 7ff5cce..8fafc12 100644 --- a/docs/editor/architecture.md +++ b/docs/editor/architecture.md @@ -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) diff --git a/docs/editor/brushes.md b/docs/editor/brushes.md new file mode 100644 index 0000000..48e2496 --- /dev/null +++ b/docs/editor/brushes.md @@ -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.