Add routed CSG scratch guardrails

This commit is contained in:
Meta Vens 2026-05-07 08:38:20 +01:00
commit 236cb2ebe4
6 changed files with 116 additions and 48 deletions

View file

@ -3,8 +3,8 @@ use std::cell::RefCell;
use bevy_math::Vec3;
use crate::{
Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, MaterialId, Primitive, TriangleMesh,
append_cylinder_z, append_dome_cap_z, append_floret_arm,
Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, MaterialId, PolygonRouteScratch,
Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, append_floret_arm,
primitives::{DomeCapZSpec, FloretArmSpec},
};
@ -204,6 +204,7 @@ impl Assembler {
let mut source = None::<ConvexSolid>;
let mut surfaces = Vec::<ConvexPolygon>::new();
let mut previous_cutters = Vec::<ConvexSolid>::new();
let mut scratch = PolygonRouteScratch::default();
for brush in &self.compiled {
match brush.op {
@ -224,14 +225,11 @@ impl Assembler {
}
let cutter = brush.convex_cutter()?;
report.candidate_pairs += 1;
surfaces = ConvexSolid::route_polygons_outside_of(surfaces, &cutter);
surfaces = scratch.route_outside_of(surfaces, &cutter);
let mut caps = ConvexSolid::route_polygons_inside_of(
cutter.polygons.clone(),
source_solid,
);
let mut caps = scratch.route_inside_of(cutter.polygons.clone(), source_solid);
for previous in &previous_cutters {
caps = ConvexSolid::route_polygons_outside_of(caps, previous);
caps = scratch.route_outside_of(caps, previous);
}
for cap in &mut caps {
cap.material = source_solid.material;
@ -910,4 +908,33 @@ mod tests {
assert_eq!(routed.mesh.positions, stable.mesh.positions);
assert_eq!(routed.mesh.indices, stable.mesh.indices);
}
#[test]
fn rotated_cut_stack_regression_preserves_ordered_output_shape() {
let mut asm = Assembler::new();
asm.solid_box(
"slab",
Aabb::from_center_size(Vec3::ZERO, Vec3::new(32.0, 32.0, 4.0)),
MaterialId(1),
);
for index in 0..64 {
let angle = index as f32 * 0.173;
let radius = 11.0 + (index % 7) as f32 * 0.35;
let center = Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0);
asm.cut_oriented_box(
format!("rotated_void_{index}"),
center,
Vec3::new(1.0 + (index % 3) as f32 * 0.3, 8.0, 5.0),
bevy_math::Quat::from_rotation_z(angle),
);
}
let output = asm.rebuild();
assert_eq!(output.report.emitted_convex_fragments, 280);
assert_eq!(output.mesh.triangle_count(), 3404);
assert_eq!(output.report.candidate_pairs, 804);
assert_eq!(output.report.rejected_pairs, 9417);
}
}

View file

@ -89,6 +89,13 @@ pub struct CategorizedPolygons {
pub reverse_aligned: Vec<ConvexPolygon>,
}
#[derive(Default)]
pub struct PolygonRouteScratch {
pieces: Vec<CategorizationPiece>,
inside_pieces: Vec<CategorizationPiece>,
cap_points: Vec<Vec3>,
}
impl ConvexSolid {
pub fn from_aabb(bounds: Aabb, material: MaterialId) -> Self {
Self::box_from_center_size(bounds.center(), bounds.size(), Quat::IDENTITY, material)
@ -256,14 +263,14 @@ impl ConvexSolid {
}
pub fn categorize_polygons_against(&self, cutter: &Self) -> CategorizedPolygons {
categorize_polygon_list_against(&self.polygons, cutter)
PolygonRouteScratch::default().categorize_against(&self.polygons, cutter)
}
pub fn route_polygons_outside_of(
polygons: Vec<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
let categorized = categorize_polygon_list_against(&polygons, cutter);
let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter);
categorized.outside
}
@ -271,7 +278,7 @@ impl ConvexSolid {
polygons: Vec<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
let categorized = categorize_polygon_list_against(&polygons, cutter);
let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter);
let mut inside = categorized.inside;
inside.extend(categorized.aligned);
inside.extend(categorized.reverse_aligned);
@ -384,38 +391,68 @@ impl ConvexSolid {
}
}
fn categorize_polygon_list_against(
polygons: &[ConvexPolygon],
cutter: &ConvexSolid,
) -> CategorizedPolygons {
let mut categorized = CategorizedPolygons::default();
impl PolygonRouteScratch {
pub fn categorize_against(
&mut self,
polygons: &[ConvexPolygon],
cutter: &ConvexSolid,
) -> CategorizedPolygons {
let mut categorized = CategorizedPolygons::default();
for polygon in polygons {
let mut pieces = vec![CategorizationPiece {
polygon: polygon.clone(),
aligned_category: None,
}];
for polygon in polygons {
self.pieces.clear();
self.pieces.push(CategorizationPiece {
polygon: polygon.clone(),
aligned_category: None,
});
for plane in &cutter.clip_planes {
let mut inside_pieces = Vec::new();
for piece in pieces {
classify_piece_against_plane(piece, *plane, &mut inside_pieces, &mut categorized);
}
pieces = inside_pieces;
}
for piece in pieces {
match piece.aligned_category {
Some(PolygonCategory::Aligned) => categorized.aligned.push(piece.polygon),
Some(PolygonCategory::ReverseAligned) => {
categorized.reverse_aligned.push(piece.polygon);
for plane in &cutter.clip_planes {
self.inside_pieces.clear();
for piece in self.pieces.drain(..) {
classify_piece_against_plane(
piece,
*plane,
&mut self.inside_pieces,
&mut categorized,
&mut self.cap_points,
);
}
std::mem::swap(&mut self.pieces, &mut self.inside_pieces);
}
for piece in self.pieces.drain(..) {
match piece.aligned_category {
Some(PolygonCategory::Aligned) => categorized.aligned.push(piece.polygon),
Some(PolygonCategory::ReverseAligned) => {
categorized.reverse_aligned.push(piece.polygon);
}
_ => categorized.inside.push(piece.polygon),
}
_ => categorized.inside.push(piece.polygon),
}
}
categorized
}
categorized
pub fn route_outside_of(
&mut self,
polygons: Vec<ConvexPolygon>,
cutter: &ConvexSolid,
) -> Vec<ConvexPolygon> {
self.categorize_against(&polygons, cutter).outside
}
pub fn route_inside_of(
&mut self,
polygons: Vec<ConvexPolygon>,
cutter: &ConvexSolid,
) -> Vec<ConvexPolygon> {
let categorized = self.categorize_against(&polygons, cutter);
let mut inside = categorized.inside;
inside.extend(categorized.aligned);
inside.extend(categorized.reverse_aligned);
inside
}
}
enum SplitResult {
@ -444,6 +481,7 @@ fn classify_piece_against_plane(
plane: Plane,
inside_pieces: &mut Vec<CategorizationPiece>,
categorized: &mut CategorizedPolygons,
cap_points: &mut Vec<Vec3>,
) {
let piece_plane = piece
.polygon
@ -477,25 +515,15 @@ fn classify_piece_against_plane(
}
(false, true) | (false, false) => inside_pieces.push(piece),
(true, true) => {
let mut cap_points = Vec::new();
let outside = clip_polygon(
&piece.polygon.vertices,
plane,
KeepSide::Front,
&mut cap_points,
);
cap_points.clear();
let outside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Front, cap_points);
if outside.len() >= 3 {
let mut outside_polygon = piece.polygon.with_vertices(outside);
outside_polygon.category = PolygonCategory::Outside;
categorized.outside.push(outside_polygon);
}
let inside = clip_polygon(
&piece.polygon.vertices,
plane,
KeepSide::Back,
&mut cap_points,
);
let inside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Back, cap_points);
if inside.len() >= 3 {
inside_pieces.push(CategorizationPiece {
polygon: piece.polygon.with_vertices(inside),

View file

@ -17,7 +17,7 @@ mod tree;
pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive};
pub use convex::{CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane};
pub use convex::{CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane, PolygonRouteScratch};
pub use dsl::LevelDsl;
pub use frontier::{DemandFrontier, DemandPair};
pub use mesh::TriangleMesh;

View file

@ -110,6 +110,11 @@ triangles instead of the fragment carver's 72 closed-fragment triangles. That is
cleaner surface output, not yet faster output. The implementation still moves
owned polygon vectors around, so it is a topology proof before it is a hot path.
Dense-kernel regression guardrail: `rotated_cut_stack_64` is pinned by tests at
280 fragments, 3404 triangles, 804 candidate pairs, and 9417 rejected pairs.
This protects against "optimizations" that alter ordered CSG semantics while
looking faster. Output-contract changes need to be explicit.
## Latest Local Baseline
Captured on 2026-05-07 with `.\tools\run_csg_perf.ps1 -UseRealtimeCsgCpp`:

View file

@ -107,6 +107,13 @@ fragment-carver triangles to 48 boundary triangles. The perf fixture only emits
ordered kernel until the router has compact frontier batching and scratch
storage; naive surface-list routing exploded polygon counts.
Latest dense-kernel guardrail: `PolygonRouteScratch` now reuses routing scratch
vectors for polygon classification. An attempted in-place fragment frontier
changed `rotated_cut_stack_64` output shape, so it was reverted. A regression
now pins that fixture at 280 fragments, 3404 triangles, 804 candidate pairs, and
9417 rejected pairs. Future speed passes must either preserve that ordered
output exactly or make an explicit, reviewed output-contract transition.
## Important Invariants
- Target Blender 5.0+ unless the task says otherwise.

View file

@ -64,3 +64,4 @@
{"ts": "2026-05-07T06:57:45+00:00", "type": "research", "status": "accepted", "note": "EpiphanyAquarium persisted memory changes the vg_csg acceleration prior: before adding a generic BVH/grid/sweep index, prototype demand-shaped spatial frontiers driven by dirty brushes, branch bounds, requested output scope, and visible/tile surfaces. GigaVoxels/froxel/Dreams doctrine treats rays or consumers as the query oracle; indexes are storage layouts for that frontier, not the central idea."}
{"ts": "2026-05-07T07:19:04+00:00", "type": "implementation", "status": "ok", "note": "Started the meaner vg_csg pass: persisted the CSG roadmap, added exported DemandFrontier/DemandPair for ordered brush affected-pair planning, and added BuildReport/perf JSONL candidate_pairs and rejected_pairs counters along existing bounds gates. Tests/clippy pass; perf counters show rotated_cut_stack_64 still burns many rejected fragment/operator checks, so category-router plus frontier batching remains the next code target."}
{"ts": "2026-05-07T07:30:39+00:00", "type": "implementation", "status": "ok", "note": "Added the first experimental vg_csg routed-surface path: single convex source plus exactly one candidate subtractive convex cutter emits surviving source boundary polygons plus reversed cutter cap polygons, reducing centered-cut triangles from 72 fragment-carver triangles to 48 boundary triangles. Dense repeated cutters are deliberately not routed yet: naive surface-list routing caused surface-piece explosion, so the next dense-kernel move must use compact category/frontier batching and scratch storage rather than unbounded polygon Vec churn."}
{"ts": "2026-05-07T07:37:47+00:00", "type": "implementation", "status": "ok", "note": "Added PolygonRouteScratch for routed polygon classification and wired it into rebuild_routed_surfaces. An attempted in-place fragment frontier sped nothing safely: it changed rotated_cut_stack_64 output shape, so it was reverted and a regression now pins that scenario at 280 fragments, 3404 triangles, 804 candidate pairs, and 9417 rejected pairs. Future dense speed work must preserve ordered output or introduce an explicit accepted-output transition, not silently mutate semantics."}