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