From 02beeb7085712d741eb1ad060313061f74499a84 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 6 Jun 2026 06:06:19 -0400 Subject: [PATCH] Add brush CSG commands --- crates/editor/src/ext/extensibility.rs | 67 +++++++ crates/editor/src/viewport/brush_csg.rs | 248 ++++++++++++++++++++++++ crates/editor/src/viewport/mod.rs | 5 + docs/editor/README.md | 2 +- docs/editor/brushes.md | 14 +- 5 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 crates/editor/src/viewport/brush_csg.rs diff --git a/crates/editor/src/ext/extensibility.rs b/crates/editor/src/ext/extensibility.rs index 1cc46d8..0b7e11b 100644 --- a/crates/editor/src/ext/extensibility.rs +++ b/crates/editor/src/ext/extensibility.rs @@ -13,6 +13,10 @@ use crate::ui::selection_ops::{focus_editor_camera_on_selection, reset_selected_ use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM}; use crate::ui::UiState; use crate::viewport::brush_tool::start_draw_brush_tool; +use crate::viewport::{ + intersect_selected_brushes, merge_selected_brushes, selected_brush_count, + subtract_selected_brushes, +}; /// Named editor command invokable from the palette and BRP. pub trait EditorCommand: Send + Sync { @@ -182,6 +186,9 @@ fn register_builtin_commands(mut registry: ResMut) { registry.register(Box::new(FocusActiveVolumesCommand)); registry.register(Box::new(SelectVolumesAtCameraCommand)); registry.register(Box::new(DrawBrushCommand)); + registry.register(Box::new(IntersectBrushesCommand)); + registry.register(Box::new(MergeBrushesCommand)); + registry.register(Box::new(SubtractBrushesCommand)); } fn run_palette_commands(world: &mut World) { @@ -354,6 +361,66 @@ impl EditorCommand for DrawBrushCommand { } } +struct IntersectBrushesCommand; + +impl EditorCommand for IntersectBrushesCommand { + fn name(&self) -> &str { + "brush.intersect" + } + + fn label(&self) -> &str { + "Brush Intersect" + } + + fn disabled_reason(&self, world: &World) -> Option { + (selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string()) + } + + fn execute(&self, world: &mut World) { + intersect_selected_brushes(world); + } +} + +struct MergeBrushesCommand; + +impl EditorCommand for MergeBrushesCommand { + fn name(&self) -> &str { + "brush.merge_convex" + } + + fn label(&self) -> &str { + "Brush Convex Merge" + } + + fn disabled_reason(&self, world: &World) -> Option { + (selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string()) + } + + fn execute(&self, world: &mut World) { + merge_selected_brushes(world); + } +} + +struct SubtractBrushesCommand; + +impl EditorCommand for SubtractBrushesCommand { + fn name(&self) -> &str { + "brush.subtract" + } + + fn label(&self) -> &str { + "Brush Subtract" + } + + fn disabled_reason(&self, world: &World) -> Option { + (selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string()) + } + + fn execute(&self, world: &mut World) { + subtract_selected_brushes(world); + } +} + struct CreatePostProcessVolumeCommand; impl EditorCommand for CreatePostProcessVolumeCommand { diff --git a/crates/editor/src/viewport/brush_csg.rs b/crates/editor/src/viewport/brush_csg.rs new file mode 100644 index 0000000..96f2a92 --- /dev/null +++ b/crates/editor/src/viewport/brush_csg.rs @@ -0,0 +1,248 @@ +//! MVP brush CSG operations for convex cuboid/prism blockout brushes. + +use bevy::math::Affine3A; +use bevy::prelude::*; +use shared::{BrushDesc, LevelObject}; + +use crate::history::{ + delete_entities_with_history, set_brush_with_history, set_transform_with_history, +}; +use crate::scene_io::SceneIo; +use crate::ui::UiState; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct BrushBounds { + min: Vec3, + max: Vec3, +} + +impl BrushBounds { + fn union(self, other: Self) -> Self { + Self { + min: self.min.min(other.min), + max: self.max.max(other.max), + } + } + + fn intersection(self, other: Self) -> Option { + let min = self.min.max(other.min); + let max = self.max.min(other.max); + ((max.x - min.x) > 0.001 && (max.y - min.y) > 0.001 && (max.z - min.z) > 0.001) + .then_some(Self { min, max }) + } + + fn center(self) -> Vec3 { + (self.min + self.max) * 0.5 + } + + fn size(self) -> Vec3 { + self.max - self.min + } + + fn volume(self) -> f32 { + let size = self.size(); + size.x.max(0.0) * size.y.max(0.0) * size.z.max(0.0) + } +} + +pub fn selected_brush_count(world: &World) -> usize { + world + .resource::() + .selected_entities + .iter() + .filter(|entity| world.get::(*entity).is_some()) + .count() +} + +pub fn intersect_selected_brushes(world: &mut World) { + run_bounds_csg(world, BrushCsgOp::Intersect); +} + +pub fn merge_selected_brushes(world: &mut World) { + run_bounds_csg(world, BrushCsgOp::Merge); +} + +pub fn subtract_selected_brushes(world: &mut World) { + run_bounds_csg(world, BrushCsgOp::Subtract); +} + +#[derive(Debug, Clone, Copy)] +enum BrushCsgOp { + Intersect, + Merge, + Subtract, +} + +fn run_bounds_csg(world: &mut World, op: BrushCsgOp) { + let selected: Vec = world + .resource::() + .selected_entities + .iter() + .filter(|entity| { + world.get::(*entity).is_some() && world.get::(*entity).is_some() + }) + .collect(); + if selected.len() < 2 { + set_status(world, "Select at least two brushes for CSG"); + return; + } + + let Some(primary_bounds) = brush_world_bounds(world, selected[0]) else { + set_status(world, "Primary brush has no valid bounds"); + return; + }; + + let result = match op { + BrushCsgOp::Merge => selected + .iter() + .skip(1) + .filter_map(|entity| brush_world_bounds(world, *entity)) + .fold(primary_bounds, BrushBounds::union), + BrushCsgOp::Intersect => { + let mut result = primary_bounds; + for entity in selected.iter().skip(1) { + let Some(bounds) = brush_world_bounds(world, *entity) else { + continue; + }; + let Some(intersection) = result.intersection(bounds) else { + set_status(world, "Brush intersect failed: no overlap"); + return; + }; + result = intersection; + } + result + } + BrushCsgOp::Subtract => { + let Some(cutter_bounds) = brush_world_bounds(world, selected[1]) else { + set_status(world, "Subtract failed: cutter brush has no valid bounds"); + return; + }; + let Some(overlap) = primary_bounds.intersection(cutter_bounds) else { + set_status(world, "Subtract failed: brushes do not overlap"); + return; + }; + let Some(result) = largest_remaining_slab(primary_bounds, overlap) else { + set_status(world, "Subtract failed: result would be empty"); + return; + }; + result + } + }; + + apply_bounds_result(world, selected[0], result); + if matches!(op, BrushCsgOp::Merge | BrushCsgOp::Intersect) { + delete_entities_with_history(world, &selected[1..]); + } + set_status( + world, + match op { + BrushCsgOp::Intersect => "Brush intersect committed", + BrushCsgOp::Merge => "Brush convex merge committed", + BrushCsgOp::Subtract => "Brush subtract committed", + }, + ); +} + +fn apply_bounds_result(world: &mut World, entity: Entity, bounds: BrushBounds) { + let center = bounds.center(); + let size = bounds.size(); + let new_brush = BrushDesc::cuboid(size); + let old_transform = world.get::(entity).copied().unwrap_or_default(); + let mut new_transform = old_transform; + new_transform.translation = center; + new_transform.rotation = Quat::IDENTITY; + new_transform.scale = Vec3::ONE; + set_transform_with_history(world, entity, old_transform, new_transform); + set_brush_with_history(world, entity, new_brush); +} + +fn brush_world_bounds(world: &World, entity: Entity) -> Option { + let brush = world.get::(entity)?; + let transform = world + .get::(entity) + .map(|global| global.affine()) + .or_else(|| { + world + .get::(entity) + .map(|transform| transform.compute_affine()) + }) + .unwrap_or(Affine3A::IDENTITY); + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + let mut found = false; + for vertex in brush + .faces + .iter() + .flat_map(|face| face.vertices.iter().copied()) + { + let world_vertex = transform.transform_point3(vertex); + min = min.min(world_vertex); + max = max.max(world_vertex); + found = true; + } + found.then_some(BrushBounds { min, max }) +} + +fn largest_remaining_slab(source: BrushBounds, overlap: BrushBounds) -> Option { + let candidates = [ + BrushBounds { + min: source.min, + max: Vec3::new(overlap.min.x, source.max.y, source.max.z), + }, + BrushBounds { + min: Vec3::new(overlap.max.x, source.min.y, source.min.z), + max: source.max, + }, + BrushBounds { + min: source.min, + max: Vec3::new(source.max.x, overlap.min.y, source.max.z), + }, + BrushBounds { + min: Vec3::new(source.min.x, overlap.max.y, source.min.z), + max: source.max, + }, + BrushBounds { + min: source.min, + max: Vec3::new(source.max.x, source.max.y, overlap.min.z), + }, + BrushBounds { + min: Vec3::new(source.min.x, source.min.y, overlap.max.z), + max: source.max, + }, + ]; + candidates + .into_iter() + .filter(|bounds| bounds.size().cmpgt(Vec3::splat(0.001)).all()) + .max_by(|a, b| a.volume().total_cmp(&b.volume())) +} + +fn set_status(world: &mut World, status: impl Into) { + if let Some(mut scene_io) = world.get_resource_mut::() { + scene_io.status = status.into(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bounds(min: Vec3, max: Vec3) -> BrushBounds { + BrushBounds { min, max } + } + + #[test] + fn intersects_overlapping_bounds() { + let a = bounds(Vec3::ZERO, Vec3::splat(2.0)); + let b = bounds(Vec3::ONE, Vec3::splat(3.0)); + assert_eq!(a.intersection(b), Some(bounds(Vec3::ONE, Vec3::splat(2.0)))); + } + + #[test] + fn subtract_picks_largest_remaining_slab() { + let source = bounds(Vec3::ZERO, Vec3::new(4.0, 2.0, 2.0)); + let overlap = bounds(Vec3::ZERO, Vec3::new(1.0, 2.0, 2.0)); + let result = largest_remaining_slab(source, overlap).expect("remaining slab"); + assert_eq!(result.min, Vec3::new(1.0, 0.0, 0.0)); + assert_eq!(result.max, source.max); + } +} diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index 017e7c8..7d20927 100644 --- a/crates/editor/src/viewport/mod.rs +++ b/crates/editor/src/viewport/mod.rs @@ -1,6 +1,7 @@ //! Viewport chrome, editor camera, selection, gizmos, and render views. pub mod actor_icons; +pub mod brush_csg; pub mod brush_edit; pub mod brush_tool; pub mod camera; @@ -14,6 +15,10 @@ pub mod viewport_mode; pub mod visualizers; pub use actor_icons::ActorIconsPlugin; +pub use brush_csg::{ + intersect_selected_brushes, merge_selected_brushes, selected_brush_count, + subtract_selected_brushes, +}; pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection}; pub use brush_tool::{BrushToolPlugin, BrushToolState}; pub use panel::*; diff --git a/docs/editor/README.md b/docs/editor/README.md index 4cf8d65..36adcb6 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -55,7 +55,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live. - **Rendering** — `GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD. - **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/source-material selectors, visibility, and shadow flags. Actor material authoring lives in the `Authoring Material` component, whose texture refs use Asset Browser picker/drop controls. Empty source material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`. -- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `brush.draw`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands. +- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `brush.draw`, `brush.subtract`, `brush.intersect`, `brush.merge_convex`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands. - **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active. - **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`. - **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders. diff --git a/docs/editor/brushes.md b/docs/editor/brushes.md index ecf4900..13b1d29 100644 --- a/docs/editor/brushes.md +++ b/docs/editor/brushes.md @@ -17,8 +17,18 @@ With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip Vertex, edge, and face selections can be dragged on the viewport floor plane. The editor moves matching duplicated corner vertices across all brush faces so simple cube/prism brushes stay welded, recomputes face planes, and commits one undoable brush edit when the drag releases. Clip mode currently provides element visualization/selection only; split application is future CSG work. +## Boolean Operations + +The command palette exposes `brush.subtract`, `brush.intersect`, and `brush.merge_convex` for selected brush actors. The current implementation is a conservative bounds-based MVP intended for cuboid/prism blockout brushes: + +- **Intersect** replaces the primary selected brush with the overlapping bounds of all selected brushes and deletes the other selected brushes. +- **Convex Merge** replaces the primary selected brush with the bounding cuboid union and deletes the other selected brushes. +- **Subtract** clips the primary selected brush to the largest remaining axis-aligned slab after subtracting the second selected brush; the cutter remains in the scene. + +These operations commit through history but are not full arbitrary-face CSG yet. Full convex clipping, multi-output subtraction, and material-preserving face matching remain future work. + ## 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. +- Subtractive brush markers are stored but not automatically evaluated. +- Clip split application and arbitrary-face CSG remain future roadmap work.