use std::cell::RefCell; use bevy_math::Vec3; use crate::{ Aabb, Brush, BrushId, BrushOp, ConvexSolid, MaterialId, Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, append_floret_arm, primitives::{DomeCapZSpec, FloretArmSpec}, }; #[derive(Clone, Debug, PartialEq, Eq)] pub enum BuildWarning { SubtractIgnoredForNonBox { brush: String }, IntersectIgnoredForNonBox { brush: String }, } #[derive(Clone, Debug, Default)] pub struct BuildReport { pub input_brushes: usize, pub emitted_convex_fragments: usize, pub operator_brushes: usize, pub candidate_pairs: usize, pub rejected_pairs: usize, pub warnings: Vec, } #[derive(Clone, Debug)] pub struct BuildOutput { pub mesh: TriangleMesh, pub report: BuildReport, pub generation: u64, } #[derive(Clone, Debug, Default)] pub struct Assembler { brushes: Vec, compiled: Vec, cache: RefCell>, next_id: u32, generation: u64, } #[derive(Clone, Debug)] struct CompiledBrush { op: BrushOp, material: MaterialId, bounds: Aabb, geometry: CompiledGeometry, name: String, } #[derive(Clone, Debug)] enum CompiledGeometry { Box(Aabb), Convex(ConvexSolid), CylinderZ { center: Vec3, radius: f32, depth: f32, segments: usize, }, DomeCapZ { center: Vec3, radius: f32, height: f32, rings: usize, segments: usize, }, FloretArm { anchor: Vec3, direction: Vec3, length: f32, root_width: f32, tip_width: f32, thickness: f32, tip_lift: f32, }, } impl Assembler { pub fn new() -> Self { Self::default() } pub fn brushes(&self) -> &[Brush] { &self.brushes } pub fn generation(&self) -> u64 { self.generation } pub fn add_brush( &mut self, name: impl Into, op: BrushOp, primitive: Primitive, material: MaterialId, ) -> BrushId { let id = BrushId(self.next_id); self.next_id += 1; let brush = Brush::new(id, name, op, primitive, material); self.compiled.push(CompiledBrush::from_brush(&brush)); self.brushes.push(brush); self.generation = self.generation.wrapping_add(1); self.cache.borrow_mut().take(); id } pub fn solid_box( &mut self, name: impl Into, bounds: Aabb, material: MaterialId, ) -> BrushId { self.add_brush(name, BrushOp::Add, Primitive::Box { bounds }, material) } pub fn cut_box(&mut self, name: impl Into, bounds: Aabb) -> BrushId { self.add_brush( name, BrushOp::Subtract, Primitive::Box { bounds }, MaterialId::default(), ) } pub fn solid_oriented_box( &mut self, name: impl Into, center: Vec3, size: Vec3, rotation: bevy_math::Quat, material: MaterialId, ) -> BrushId { self.add_brush( name, BrushOp::Add, Primitive::OrientedBox { center, size, rotation, }, material, ) } pub fn cut_oriented_box( &mut self, name: impl Into, center: Vec3, size: Vec3, rotation: bevy_math::Quat, ) -> BrushId { self.add_brush( name, BrushOp::Subtract, Primitive::OrientedBox { center, size, rotation, }, MaterialId::default(), ) } pub fn build(&self) -> BuildOutput { if let Some(output) = self .cache .borrow() .as_ref() .filter(|output| output.generation == self.generation) { return output.clone(); } let output = self.build_uncached(); *self.cache.borrow_mut() = Some(output.clone()); output } pub fn rebuild(&self) -> BuildOutput { self.build_uncached() } fn build_uncached(&self) -> BuildOutput { if self .compiled .iter() .all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_))) { return self.build_axis_aligned_boxes(); } let mut report = BuildReport { input_brushes: self.brushes.len(), ..BuildReport::default() }; let mut mesh = TriangleMesh::new(); let mut solids: Vec = Vec::new(); for brush in &self.compiled { match brush.op { BrushOp::Add => match &brush.geometry { CompiledGeometry::Box(bounds) => { solids.push(ConvexSolid::from_aabb(*bounds, brush.material)); } CompiledGeometry::Convex(solid) => solids.push(solid.clone()), CompiledGeometry::CylinderZ { center, radius, depth, segments, } => append_cylinder_z( &mut mesh, *center, *radius, *depth, *segments, brush.material, ), CompiledGeometry::DomeCapZ { center, radius, height, rings, segments, } => append_dome_cap_z( &mut mesh, DomeCapZSpec { center: *center, radius: *radius, height: *height, rings: *rings, segments: *segments, material: brush.material, }, ), CompiledGeometry::FloretArm { anchor, direction, length, root_width, tip_width, thickness, tip_lift, } => append_floret_arm( &mut mesh, FloretArmSpec { anchor: *anchor, direction: *direction, length: *length, root_width: *root_width, tip_width: *tip_width, thickness: *thickness, tip_lift: *tip_lift, material: brush.material, }, ), }, BrushOp::Subtract => { report.operator_brushes += 1; if !brush .bounds .intersects_any(solids.iter().map(|solid| solid.bounds)) { report.rejected_pairs += solids.len(); continue; } if let Some(cutter) = brush.convex_cutter() { solids = subtract_from_solids(solids, &cutter, &mut report); } else { let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report); if has_candidate { report .warnings .push(BuildWarning::SubtractIgnoredForNonBox { brush: brush.name.clone(), }); } } } BrushOp::Intersect => { report.operator_brushes += 1; if !brush .bounds .intersects_any(solids.iter().map(|solid| solid.bounds)) { report.rejected_pairs += solids.len(); solids.clear(); continue; } if let Some(cutter) = brush.convex_cutter() { solids = intersect_solids(solids, &cutter, &mut report); } else { let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report); solids.clear(); if has_candidate { report .warnings .push(BuildWarning::IntersectIgnoredForNonBox { brush: brush.name.clone(), }); } } } } } report.emitted_convex_fragments = solids.len(); for solid in solids { solid.append_to_mesh(&mut mesh); } BuildOutput { mesh, report, generation: self.generation, } } fn build_axis_aligned_boxes(&self) -> BuildOutput { let mut report = BuildReport { input_brushes: self.brushes.len(), ..BuildReport::default() }; let mut boxes: Vec<(Aabb, MaterialId)> = Vec::new(); for brush in &self.compiled { let CompiledGeometry::Box(bounds) = brush.geometry else { unreachable!("box-only build path received a non-box brush"); }; match brush.op { BrushOp::Add => boxes.push((bounds, brush.material)), BrushOp::Subtract => { report.operator_brushes += 1; if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) { report.rejected_pairs += boxes.len(); continue; } let mut out = Vec::with_capacity(boxes.len() + 4); for (solid, material) in boxes { if solid.intersects(bounds) { report.candidate_pairs += 1; out.extend( solid .subtract_box(bounds) .into_iter() .map(|piece| (piece, material)), ); } else { report.rejected_pairs += 1; out.push((solid, material)); } } boxes = out; } BrushOp::Intersect => { report.operator_brushes += 1; if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) { report.rejected_pairs += boxes.len(); boxes.clear(); continue; } boxes = boxes .into_iter() .filter_map(|(solid, material)| { if let Some(hit) = solid.intersection(bounds) { report.candidate_pairs += 1; Some((hit, material)) } else { report.rejected_pairs += 1; None } }) .collect(); } } } let mut mesh = TriangleMesh::new(); report.emitted_convex_fragments = boxes.len(); for (bounds, material) in boxes { mesh.append_box(bounds, material); } BuildOutput { mesh, report, generation: self.generation, } } pub fn sample_room_with_door() -> Self { let wall = MaterialId(1); let floor = MaterialId(2); let mut asm = Self::new(); asm.solid_box( "floor", Aabb::from_center_size(Vec3::new(0.0, 0.0, -0.1), Vec3::new(8.0, 8.0, 0.2)), floor, ); asm.solid_box( "north wall", Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.5), Vec3::new(8.0, 0.25, 3.0)), wall, ); asm.cut_box( "door void", Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)), ); asm } } impl CompiledBrush { fn from_brush(brush: &Brush) -> Self { let bounds = brush.bounds(); let geometry = match &brush.primitive { Primitive::Box { bounds } => CompiledGeometry::Box(*bounds), Primitive::OrientedBox { center, size, rotation, } => CompiledGeometry::Convex(ConvexSolid::box_from_center_size( *center, *size, *rotation, brush.material, )), Primitive::CylinderZ { center, radius, depth, segments, } => CompiledGeometry::CylinderZ { center: *center, radius: *radius, depth: *depth, segments: *segments, }, Primitive::DomeCapZ { center, radius, height, rings, segments, } => CompiledGeometry::DomeCapZ { center: *center, radius: *radius, height: *height, rings: *rings, segments: *segments, }, Primitive::FloretArm { anchor, direction, length, root_width, tip_width, thickness, tip_lift, } => CompiledGeometry::FloretArm { anchor: *anchor, direction: *direction, length: *length, root_width: *root_width, tip_width: *tip_width, thickness: *thickness, tip_lift: *tip_lift, }, }; Self { op: brush.op, material: brush.material, bounds, geometry, name: brush.name.clone(), } } fn convex_cutter(&self) -> Option { match &self.geometry { CompiledGeometry::Box(bounds) => Some(ConvexSolid::from_aabb(*bounds, self.material)), CompiledGeometry::Convex(solid) => Some(solid.clone()), _ => None, } } } fn subtract_from_solids( solids: Vec, cutter: &ConvexSolid, report: &mut BuildReport, ) -> Vec { let mut out = Vec::with_capacity(solids.len() + 4); for solid in solids { if solid.bounds.intersects(cutter.bounds) { report.candidate_pairs += 1; out.extend(solid.subtract_convex_owned(cutter)); } else { report.rejected_pairs += 1; out.push(solid); } } out } fn intersect_solids( solids: Vec, cutter: &ConvexSolid, report: &mut BuildReport, ) -> Vec { let mut out = Vec::with_capacity(solids.len()); for solid in solids { if !solid.bounds.intersects(cutter.bounds) { report.rejected_pairs += 1; continue; } report.candidate_pairs += 1; if let Some(fragment) = solid.intersect_convex_owned(cutter) { out.push(fragment); } } out } fn record_solid_pairs(solids: &[ConvexSolid], bounds: Aabb, report: &mut BuildReport) -> bool { let mut has_candidate = false; for solid in solids { if solid.bounds.intersects(bounds) { report.candidate_pairs += 1; has_candidate = true; } else { report.rejected_pairs += 1; } } has_candidate } #[cfg(test)] mod tests { use bevy_math::Vec3; use super::*; #[test] fn centered_subtract_splits_box_into_six_fragments() { let source = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)); let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); let pieces = source.subtract_box(cutter); assert_eq!(pieces.len(), 6); assert!(pieces.iter().all(|piece| piece.is_valid())); assert!(pieces.iter().all(|piece| !piece.intersects(cutter))); } #[test] fn door_cut_leaves_no_box_fragment_inside_void() { let output = Assembler::sample_room_with_door().build(); let cutter = Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)); assert!(output.report.emitted_convex_fragments > 2); for tri in output.mesh.indices.chunks_exact(3) { let center = (output.mesh.positions[tri[0] as usize] + output.mesh.positions[tri[1] as usize] + output.mesh.positions[tri[2] as usize]) / 3.0; assert!(!cutter.contains_point_strict(center, 1.0e-4)); } } #[test] fn rotated_door_cut_produces_diagonal_portal_faces() { let mut asm = Assembler::new(); asm.solid_box( "wall", Aabb::from_center_size(Vec3::new(0.0, 0.0, 1.5), Vec3::new(5.0, 0.5, 3.0)), MaterialId(1), ); asm.cut_oriented_box( "angled void", Vec3::new(0.0, 0.0, 1.5), Vec3::new(1.5, 2.0, 2.0), bevy_math::Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), ); let output = asm.build(); assert!(output.report.emitted_convex_fragments > 2); assert!(output.mesh.normals.iter().any(|normal| { normal.x.abs() > 0.2 && normal.y.abs() > 0.2 && normal.z.abs() < 0.1 })); } #[test] fn mesh_box_emits_twelve_triangles() { let mut mesh = TriangleMesh::new(); mesh.append_box(Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), MaterialId(7)); assert_eq!(mesh.triangle_count(), 12); assert_eq!(mesh.triangle_materials.len(), 12); assert_eq!(mesh.triangle_materials[0], MaterialId(7)); } #[test] fn distant_subtract_skips_convex_split_and_keeps_source_solid() { let mut asm = Assembler::new(); asm.solid_box( "source", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), MaterialId(1), ); asm.cut_box( "far void", Aabb::from_center_size(Vec3::splat(10.0), Vec3::splat(1.0)), ); let output = asm.build(); assert_eq!(output.report.emitted_convex_fragments, 1); assert_eq!(output.report.operator_brushes, 1); assert_eq!(output.report.candidate_pairs, 0); assert_eq!(output.report.rejected_pairs, 1); assert_eq!(output.mesh.triangle_count(), 12); } #[test] fn intersecting_box_keeps_common_convex_region() { let mut asm = Assembler::new(); asm.solid_box( "a", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), MaterialId(1), ); asm.add_brush( "b", BrushOp::Intersect, Primitive::Box { bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), }, MaterialId(1), ); let output = asm.build(); assert_eq!(output.report.emitted_convex_fragments, 1); assert_eq!(output.report.operator_brushes, 1); assert_eq!(output.report.candidate_pairs, 1); assert_eq!(output.report.rejected_pairs, 0); assert_eq!(output.report.warnings.len(), 0); assert_eq!(output.mesh.triangle_count(), 12); } #[test] fn cached_build_and_dirty_rebuild_emit_same_output() { let mut asm = Assembler::new(); asm.solid_box( "slab", Aabb::from_center_size(Vec3::ZERO, Vec3::new(8.0, 8.0, 2.0)), MaterialId(1), ); asm.cut_box( "center void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), ); asm.cut_oriented_box( "angled void", Vec3::new(1.0, 1.0, 0.0), Vec3::new(1.25, 4.0, 2.5), bevy_math::Quat::from_rotation_z(0.35), ); let stable = asm.build(); let cached = asm.build(); let dirty = asm.rebuild(); assert_eq!( stable.report.emitted_convex_fragments, dirty.report.emitted_convex_fragments ); assert_eq!( stable.report.operator_brushes, dirty.report.operator_brushes ); assert_eq!(stable.report.candidate_pairs, dirty.report.candidate_pairs); assert_eq!(stable.report.rejected_pairs, dirty.report.rejected_pairs); assert_eq!(stable.report.warnings, dirty.report.warnings); assert_eq!(stable.mesh.triangle_count(), dirty.mesh.triangle_count()); assert_eq!(stable.mesh.vertex_count(), dirty.mesh.vertex_count()); assert_eq!(stable.mesh.positions, dirty.mesh.positions); assert_eq!(stable.mesh.indices, dirty.mesh.indices); assert_eq!(cached.mesh.positions, stable.mesh.positions); assert_eq!(cached.mesh.indices, stable.mesh.indices); } }