Add CSG dirty frontier editing seam

This commit is contained in:
Meta Vens 2026-05-07 08:49:33 +01:00
commit ef942ab0c1
4 changed files with 263 additions and 3 deletions

View file

@ -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`:

View file

@ -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<usize> {
self.brushes.iter().position(|brush| brush.id == id)
}
pub fn dirty_frontier_for_indices(
&self,
dirty_indices: impl IntoIterator<Item = usize>,
) -> DirtyDemandFrontier {
DirtyDemandFrontier::from_dirty_indices(&self.brushes, dirty_indices)
}
pub fn dirty_frontier_for_brushes(
&self,
dirty_brushes: impl IntoIterator<Item = BrushId>,
) -> DirtyDemandFrontier {
let indices = dirty_brushes
.into_iter()
.filter_map(|id| self.brush_index(id))
.collect::<Vec<_>>();
self.dirty_frontier_for_indices(indices)
}
pub fn add_brush(
&mut self,
name: impl Into<String>,
@ -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<String>,
@ -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();

View file

@ -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<usize>,
pub pairs: Vec<DemandPair>,
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<Item = usize>,
) -> 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::<usize>::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<usize> {
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);
}
}

View file

@ -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,