Add CSG dirty frontier editing seam
This commit is contained in:
parent
8c61d2fbf7
commit
ef942ab0c1
4 changed files with 263 additions and 3 deletions
|
|
@ -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`:
|
||||
|
|
|
|||
104
src/assembler.rs
104
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<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();
|
||||
|
|
|
|||
153
src/frontier.rs
153
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue