From 8c61d2fbf76757c68e321b1954a645731c4593cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 08:38:20 +0100 Subject: [PATCH] Add routed CSG scratch guardrails --- docs/csg-performance-fixtures.md | 5 ++ src/assembler.rs | 43 +++++++++--- src/convex.rs | 110 +++++++++++++++++++------------ src/lib.rs | 2 +- 4 files changed, 110 insertions(+), 50 deletions(-) diff --git a/docs/csg-performance-fixtures.md b/docs/csg-performance-fixtures.md index 1014e9b..e6c49df 100644 --- a/docs/csg-performance-fixtures.md +++ b/docs/csg-performance-fixtures.md @@ -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`: diff --git a/src/assembler.rs b/src/assembler.rs index a854138..8d948c6 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -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::; let mut surfaces = Vec::::new(); let mut previous_cutters = Vec::::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); + } } diff --git a/src/convex.rs b/src/convex.rs index 3b98ddc..76d8aa6 100644 --- a/src/convex.rs +++ b/src/convex.rs @@ -89,6 +89,13 @@ pub struct CategorizedPolygons { pub reverse_aligned: Vec, } +#[derive(Default)] +pub struct PolygonRouteScratch { + pieces: Vec, + inside_pieces: Vec, + cap_points: Vec, +} + 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, cutter: &Self, ) -> Vec { - 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, cutter: &Self, ) -> Vec { - 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, + cutter: &ConvexSolid, + ) -> Vec { + self.categorize_against(&polygons, cutter).outside + } + + pub fn route_inside_of( + &mut self, + polygons: Vec, + cutter: &ConvexSolid, + ) -> Vec { + 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, categorized: &mut CategorizedPolygons, + cap_points: &mut Vec, ) { 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), diff --git a/src/lib.rs b/src/lib.rs index 096a36b..1eef8e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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;