This commit is contained in:
parent
93ab6c2ea2
commit
02beeb7085
@ -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<EditorCommandRegistry>) {
|
||||
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<String> {
|
||||
(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<String> {
|
||||
(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<String> {
|
||||
(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 {
|
||||
|
||||
248
crates/editor/src/viewport/brush_csg.rs
Normal file
248
crates/editor/src/viewport/brush_csg.rs
Normal file
@ -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<Self> {
|
||||
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::<UiState>()
|
||||
.selected_entities
|
||||
.iter()
|
||||
.filter(|entity| world.get::<BrushDesc>(*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<Entity> = world
|
||||
.resource::<UiState>()
|
||||
.selected_entities
|
||||
.iter()
|
||||
.filter(|entity| {
|
||||
world.get::<LevelObject>(*entity).is_some() && world.get::<BrushDesc>(*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::<Transform>(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<BrushBounds> {
|
||||
let brush = world.get::<BrushDesc>(entity)?;
|
||||
let transform = world
|
||||
.get::<GlobalTransform>(entity)
|
||||
.map(|global| global.affine())
|
||||
.or_else(|| {
|
||||
world
|
||||
.get::<Transform>(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<BrushBounds> {
|
||||
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<String>) {
|
||||
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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::*;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user