diff --git a/docs/csg-performance-fixtures.md b/docs/csg-performance-fixtures.md index e6c49df..5d201a4 100644 --- a/docs/csg-performance-fixtures.md +++ b/docs/csg-performance-fixtures.md @@ -115,6 +115,13 @@ Dense-kernel regression guardrail: `rotated_cut_stack_64` is pinned by tests at This protects against "optimizations" that alter ordered CSG semantics while looking faster. Output-contract changes need to be explicit. +Realtime editing seam: `Assembler` can now mutate brush primitives and +operations by `BrushId`, invalidating cached output and incrementing generation. +`DirtyDemandFrontier` computes the conservative ordered suffix after the first +dirty brush. The prefix before that index is the cacheable region; the suffix is +the live region because ordered CSG decisions propagate forward. This is still a +planning surface, not an incremental mesh rebuild yet. + ## Latest Local Baseline Captured on 2026-05-07 with `.\tools\run_csg_perf.ps1 -UseRealtimeCsgCpp`: diff --git a/src/assembler.rs b/src/assembler.rs index 8d948c6..865e8df 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -3,8 +3,9 @@ use std::cell::RefCell; use bevy_math::Vec3; use crate::{ - Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, MaterialId, PolygonRouteScratch, - Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, append_floret_arm, + Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, DirtyDemandFrontier, MaterialId, + PolygonRouteScratch, Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, + append_floret_arm, primitives::{DomeCapZSpec, FloretArmSpec}, }; @@ -90,6 +91,28 @@ impl Assembler { self.generation } + pub fn brush_index(&self, id: BrushId) -> Option { + self.brushes.iter().position(|brush| brush.id == id) + } + + pub fn dirty_frontier_for_indices( + &self, + dirty_indices: impl IntoIterator, + ) -> DirtyDemandFrontier { + DirtyDemandFrontier::from_dirty_indices(&self.brushes, dirty_indices) + } + + pub fn dirty_frontier_for_brushes( + &self, + dirty_brushes: impl IntoIterator, + ) -> DirtyDemandFrontier { + let indices = dirty_brushes + .into_iter() + .filter_map(|id| self.brush_index(id)) + .collect::>(); + self.dirty_frontier_for_indices(indices) + } + pub fn add_brush( &mut self, name: impl Into, @@ -107,6 +130,30 @@ impl Assembler { id } + pub fn set_brush_primitive(&mut self, id: BrushId, primitive: Primitive) -> bool { + let Some(index) = self.brush_index(id) else { + return false; + }; + self.brushes[index].primitive = primitive; + self.brushes[index].set_dirty(); + self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); + self.generation = self.generation.wrapping_add(1); + self.cache.borrow_mut().take(); + true + } + + pub fn set_brush_op(&mut self, id: BrushId, op: BrushOp) -> bool { + let Some(index) = self.brush_index(id) else { + return false; + }; + self.brushes[index].op = op; + self.brushes[index].set_dirty(); + self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); + self.generation = self.generation.wrapping_add(1); + self.cache.borrow_mut().take(); + true + } + pub fn solid_box( &mut self, name: impl Into, @@ -909,6 +956,59 @@ mod tests { assert_eq!(routed.mesh.indices, stable.mesh.indices); } + #[test] + fn mutating_brush_primitive_invalidates_cache_and_exposes_dirty_frontier() { + let mut asm = Assembler::new(); + let source = asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + let cutter = asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(1.0))); + let before = asm.build(); + + assert!(asm.set_brush_primitive( + cutter, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + }, + )); + let frontier = asm.dirty_frontier_for_brushes([cutter]); + let after = asm.build(); + + assert_eq!(frontier.first_dirty_index, Some(1)); + assert_eq!(frontier.operator_brushes, 1); + assert_eq!(frontier.candidate_pairs(), 1); + assert_eq!(frontier.rejected_pairs, 0); + assert!(after.generation > before.generation); + assert_ne!(after.mesh.positions, before.mesh.positions); + assert_eq!(asm.brush_index(source), Some(0)); + assert_eq!(asm.brush_index(cutter), Some(1)); + } + + #[test] + fn mutating_brush_operation_changes_ordered_output() { + let mut asm = Assembler::new(); + let _source = asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + let other = asm.solid_box( + "other", + Aabb::from_center_size(Vec3::new(0.5, 0.0, 0.0), Vec3::splat(3.0)), + MaterialId(1), + ); + let before = asm.rebuild(); + + assert!(asm.set_brush_op(other, BrushOp::Intersect)); + let after = asm.rebuild(); + + assert_eq!(after.report.operator_brushes, 1); + assert_eq!(after.report.candidate_pairs, 1); + assert!(after.mesh.triangle_count() < before.mesh.triangle_count()); + } + #[test] fn rotated_cut_stack_regression_preserves_ordered_output_shape() { let mut asm = Assembler::new(); diff --git a/src/frontier.rs b/src/frontier.rs index 09d51f1..aff6e18 100644 --- a/src/frontier.rs +++ b/src/frontier.rs @@ -13,6 +13,15 @@ pub struct DemandFrontier { pub operator_brushes: usize, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DirtyDemandFrontier { + pub first_dirty_index: Option, + pub pairs: Vec, + pub rejected_pairs: usize, + pub operator_brushes: usize, + pub skipped_prefix_pairs: usize, +} + impl DemandFrontier { pub fn from_ordered_brushes(brushes: &[Brush]) -> Self { let mut frontier = Self::default(); @@ -65,6 +74,89 @@ impl DemandFrontier { } } +impl DirtyDemandFrontier { + pub fn from_dirty_indices( + brushes: &[Brush], + dirty_indices: impl IntoIterator, + ) -> Self { + let first_dirty_index = dirty_indices + .into_iter() + .filter(|index| *index < brushes.len()) + .min(); + let Some(first_dirty_index) = first_dirty_index else { + return Self::default(); + }; + + let mut frontier = Self { + first_dirty_index: Some(first_dirty_index), + ..Self::default() + }; + let mut active_sources = Vec::::new(); + + for (operator_index, brush) in brushes.iter().enumerate() { + match brush.op { + BrushOp::Add => active_sources.push(operator_index), + BrushOp::Subtract => { + frontier.operator_brushes += usize::from(operator_index >= first_dirty_index); + frontier.collect_pairs( + brushes, + &active_sources, + operator_index, + first_dirty_index, + ); + } + BrushOp::Intersect => { + frontier.operator_brushes += usize::from(operator_index >= first_dirty_index); + let retained = frontier.collect_pairs( + brushes, + &active_sources, + operator_index, + first_dirty_index, + ); + active_sources = retained; + } + } + } + + frontier + } + + pub fn candidate_pairs(&self) -> usize { + self.pairs.len() + } + + fn collect_pairs( + &mut self, + brushes: &[Brush], + active_sources: &[usize], + operator_index: usize, + first_dirty_index: usize, + ) -> Vec { + let operator_bounds = brushes[operator_index].bounds(); + let mut retained = Vec::new(); + + for &source_index in active_sources { + if brushes[source_index].bounds().intersects(operator_bounds) { + if operator_index >= first_dirty_index { + self.pairs.push(DemandPair { + source_index, + operator_index, + }); + } else { + self.skipped_prefix_pairs += 1; + } + retained.push(source_index); + } else if operator_index >= first_dirty_index { + self.rejected_pairs += 1; + } else { + self.skipped_prefix_pairs += 1; + } + } + + retained + } +} + #[cfg(test)] mod tests { use bevy_math::{Quat, Vec3}; @@ -164,4 +256,65 @@ mod tests { ] ); } + + #[test] + fn dirty_frontier_preserves_prefix_before_first_dirty_brush() { + let brushes = vec![ + brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + ), + brush( + "early", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + brush( + "dirty", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ), + brush( + "later", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + ]; + + let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [2]); + + assert_eq!(frontier.first_dirty_index, Some(2)); + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.skipped_prefix_pairs, 1); + assert_eq!(frontier.candidate_pairs(), 2); + assert_eq!( + frontier.pairs, + vec![ + DemandPair { + source_index: 0, + operator_index: 2, + }, + DemandPair { + source_index: 0, + operator_index: 3, + }, + ] + ); + } + + #[test] + fn dirty_frontier_ignores_out_of_range_dirty_indices() { + let brushes = vec![brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + )]; + + let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [99]); + + assert_eq!(frontier.first_dirty_index, None); + assert_eq!(frontier.candidate_pairs(), 0); + assert_eq!(frontier.operator_brushes, 0); + } } diff --git a/src/lib.rs b/src/lib.rs index 1eef8e7..82fae68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,7 @@ pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning}; pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive}; pub use convex::{CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane, PolygonRouteScratch}; pub use dsl::LevelDsl; -pub use frontier::{DemandFrontier, DemandPair}; +pub use frontier::{DemandFrontier, DemandPair, DirtyDemandFrontier}; pub use mesh::TriangleMesh; pub use primitives::{ DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm,