Blacksite/crates/editor/src/viewport/brush_csg.rs
Rbanh 02beeb7085
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add brush CSG commands
2026-06-06 06:06:19 -04:00

249 lines
7.6 KiB
Rust

//! 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);
}
}