249 lines
7.6 KiB
Rust
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);
|
|
}
|
|
}
|