From 84371de2a28792bf7949f2325ba7d6dc55cb0862 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 08:20:25 +0100 Subject: [PATCH] Add CSG demand frontier counters --- docs/csg-performance-fixtures.md | 10 ++ examples/csg_perf_fixture.rs | 18 +++- src/assembler.rs | 98 +++++++++++++++--- src/frontier.rs | 167 +++++++++++++++++++++++++++++++ src/lib.rs | 2 + 5 files changed, 276 insertions(+), 19 deletions(-) create mode 100644 src/frontier.rs diff --git a/docs/csg-performance-fixtures.md b/docs/csg-performance-fixtures.md index 96bb8bc..524c4be 100644 --- a/docs/csg-performance-fixtures.md +++ b/docs/csg-performance-fixtures.md @@ -17,6 +17,7 @@ The script builds `vg_csg` in release mode and writes JSONL to - warmup and measured iteration counts - `mean_ns`, `min_ns`, `p50_ns`, `p95_ns`, `max_ns` - emitted triangle, fragment, and warning counts +- demand-frontier `candidate_pairs` and `rejected_pairs` The `vg_csg` fixture measures the stable-tree path after warmup. `Assembler` caches evaluated output by generation, so repeated `build()` calls on an @@ -85,10 +86,19 @@ inside/outside/aligned/reverse-aligned categories, crossing polygon splits, and logical routing tables. Use it as readable doctrine. Use the native bridge as the performance target. +`vg_csg` now reports the first demand-frontier counters. These are not yet a +new kernel path; they expose the ordered brush stream as affected source/operator +pairs and rejected bounds pairs so future router/index work can prove it is +refusing real work rather than merely rearranging it. + ## Latest Local Baseline Captured on 2026-05-07 with `.\tools\run_csg_perf.ps1 -UseRealtimeCsgCpp`: +This baseline predates the `candidate_pairs`/`rejected_pairs` fields. New +fixture runs include them; keep the old block until a fresh C++ comparison is +captured rather than mixing timing environments. + ```jsonl {"kernel":"vg_csg","mode":"stable","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":948,"min_ns":800,"p50_ns":900,"p95_ns":1100,"max_ns":1200,"triangles":72,"fragments":6,"warnings":0} {"kernel":"vg_csg","mode":"dirty","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":6123,"min_ns":4300,"p50_ns":5500,"p95_ns":6000,"max_ns":44700,"triangles":72,"fragments":6,"warnings":0} diff --git a/examples/csg_perf_fixture.rs b/examples/csg_perf_fixture.rs index 7922ae6..20c092c 100644 --- a/examples/csg_perf_fixture.rs +++ b/examples/csg_perf_fixture.rs @@ -87,6 +87,8 @@ fn run_case(case: PerfCase, mode: PerfMode) { let mut triangles = 0; let mut fragments = 0; let mut warnings = 0; + let mut candidate_pairs = 0; + let mut rejected_pairs = 0; for _ in 0..MEASURE_ITERS { let start = Instant::now(); @@ -95,7 +97,15 @@ fn run_case(case: PerfCase, mode: PerfMode) { triangles = output.mesh.triangle_count(); fragments = output.report.emitted_convex_fragments; warnings = output.report.warnings.len(); - black_box((triangles, fragments, warnings)); + candidate_pairs = output.report.candidate_pairs; + rejected_pairs = output.report.rejected_pairs; + black_box(( + triangles, + fragments, + warnings, + candidate_pairs, + rejected_pairs, + )); timings.push(elapsed); } @@ -110,7 +120,7 @@ fn run_case(case: PerfCase, mode: PerfMode) { let p95_ns = percentile_ns(&timings, 95); println!( - "{{\"kernel\":\"vg_csg\",\"mode\":\"{}\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{}}}", + "{{\"kernel\":\"vg_csg\",\"mode\":\"{}\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{},\"candidate_pairs\":{},\"rejected_pairs\":{}}}", mode.as_str(), case.name, case.brushes, @@ -123,7 +133,9 @@ fn run_case(case: PerfCase, mode: PerfMode) { max_ns, triangles, fragments, - warnings + warnings, + candidate_pairs, + rejected_pairs ); } diff --git a/src/assembler.rs b/src/assembler.rs index 777f888..35251e0 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -18,6 +18,9 @@ pub enum BuildWarning { pub struct BuildReport { pub input_brushes: usize, pub emitted_convex_fragments: usize, + pub operator_brushes: usize, + pub candidate_pairs: usize, + pub rejected_pairs: usize, pub warnings: Vec, } @@ -256,38 +259,49 @@ impl Assembler { ), }, BrushOp::Subtract => { + report.operator_brushes += 1; if !brush .bounds .intersects_any(solids.iter().map(|solid| solid.bounds)) { + report.rejected_pairs += solids.len(); continue; } if let Some(cutter) = brush.convex_cutter() { - solids = subtract_from_solids(solids, &cutter); + solids = subtract_from_solids(solids, &cutter, &mut report); } else { - report - .warnings - .push(BuildWarning::SubtractIgnoredForNonBox { - brush: brush.name.clone(), - }); + let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report); + if has_candidate { + report + .warnings + .push(BuildWarning::SubtractIgnoredForNonBox { + brush: brush.name.clone(), + }); + } } } BrushOp::Intersect => { + report.operator_brushes += 1; if !brush .bounds .intersects_any(solids.iter().map(|solid| solid.bounds)) { + report.rejected_pairs += solids.len(); solids.clear(); continue; } if let Some(cutter) = brush.convex_cutter() { - solids = intersect_solids(solids, &cutter); + solids = intersect_solids(solids, &cutter, &mut report); } else { - report - .warnings - .push(BuildWarning::IntersectIgnoredForNonBox { - brush: brush.name.clone(), - }); + let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report); + solids.clear(); + if has_candidate { + report + .warnings + .push(BuildWarning::IntersectIgnoredForNonBox { + brush: brush.name.clone(), + }); + } } } } @@ -319,12 +333,15 @@ impl Assembler { match brush.op { BrushOp::Add => boxes.push((bounds, brush.material)), BrushOp::Subtract => { + report.operator_brushes += 1; if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) { + report.rejected_pairs += boxes.len(); continue; } let mut out = Vec::with_capacity(boxes.len() + 4); for (solid, material) in boxes { if solid.intersects(bounds) { + report.candidate_pairs += 1; out.extend( solid .subtract_box(bounds) @@ -332,20 +349,29 @@ impl Assembler { .map(|piece| (piece, material)), ); } else { + report.rejected_pairs += 1; out.push((solid, material)); } } boxes = out; } BrushOp::Intersect => { + report.operator_brushes += 1; if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) { + report.rejected_pairs += boxes.len(); boxes.clear(); continue; } boxes = boxes .into_iter() .filter_map(|(solid, material)| { - solid.intersection(bounds).map(|hit| (hit, material)) + if let Some(hit) = solid.intersection(bounds) { + report.candidate_pairs += 1; + Some((hit, material)) + } else { + report.rejected_pairs += 1; + None + } }) .collect(); } @@ -463,24 +489,36 @@ impl CompiledBrush { } } -fn subtract_from_solids(solids: Vec, cutter: &ConvexSolid) -> Vec { +fn subtract_from_solids( + solids: Vec, + cutter: &ConvexSolid, + report: &mut BuildReport, +) -> Vec { let mut out = Vec::with_capacity(solids.len() + 4); for solid in solids { if solid.bounds.intersects(cutter.bounds) { + report.candidate_pairs += 1; out.extend(solid.subtract_convex_owned(cutter)); } else { + report.rejected_pairs += 1; out.push(solid); } } out } -fn intersect_solids(solids: Vec, cutter: &ConvexSolid) -> Vec { +fn intersect_solids( + solids: Vec, + cutter: &ConvexSolid, + report: &mut BuildReport, +) -> Vec { let mut out = Vec::with_capacity(solids.len()); for solid in solids { if !solid.bounds.intersects(cutter.bounds) { + report.rejected_pairs += 1; continue; } + report.candidate_pairs += 1; if let Some(fragment) = solid.intersect_convex_owned(cutter) { out.push(fragment); } @@ -488,6 +526,19 @@ fn intersect_solids(solids: Vec, cutter: &ConvexSolid) -> Vec bool { + let mut has_candidate = false; + for solid in solids { + if solid.bounds.intersects(bounds) { + report.candidate_pairs += 1; + has_candidate = true; + } else { + report.rejected_pairs += 1; + } + } + has_candidate +} + #[cfg(test)] mod tests { use bevy_math::Vec3; @@ -567,6 +618,9 @@ mod tests { let output = asm.build(); assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.operator_brushes, 1); + assert_eq!(output.report.candidate_pairs, 0); + assert_eq!(output.report.rejected_pairs, 1); assert_eq!(output.mesh.triangle_count(), 12); } @@ -589,6 +643,9 @@ mod tests { let output = asm.build(); assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.operator_brushes, 1); + assert_eq!(output.report.candidate_pairs, 1); + assert_eq!(output.report.rejected_pairs, 0); assert_eq!(output.report.warnings.len(), 0); assert_eq!(output.mesh.triangle_count(), 12); } @@ -616,7 +673,16 @@ mod tests { let cached = asm.build(); let dirty = asm.rebuild(); - assert_eq!(stable.report.emitted_convex_fragments, dirty.report.emitted_convex_fragments); + assert_eq!( + stable.report.emitted_convex_fragments, + dirty.report.emitted_convex_fragments + ); + assert_eq!( + stable.report.operator_brushes, + dirty.report.operator_brushes + ); + assert_eq!(stable.report.candidate_pairs, dirty.report.candidate_pairs); + assert_eq!(stable.report.rejected_pairs, dirty.report.rejected_pairs); assert_eq!(stable.report.warnings, dirty.report.warnings); assert_eq!(stable.mesh.triangle_count(), dirty.mesh.triangle_count()); assert_eq!(stable.mesh.vertex_count(), dirty.mesh.vertex_count()); diff --git a/src/frontier.rs b/src/frontier.rs new file mode 100644 index 0000000..09d51f1 --- /dev/null +++ b/src/frontier.rs @@ -0,0 +1,167 @@ +use crate::{Brush, BrushOp}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DemandPair { + pub source_index: usize, + pub operator_index: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DemandFrontier { + pub pairs: Vec, + pub rejected_pairs: usize, + pub operator_brushes: usize, +} + +impl DemandFrontier { + pub fn from_ordered_brushes(brushes: &[Brush]) -> Self { + let mut frontier = 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 += 1; + frontier.collect_pairs(brushes, &active_sources, operator_index); + } + BrushOp::Intersect => { + frontier.operator_brushes += 1; + let retained = frontier.collect_pairs(brushes, &active_sources, operator_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, + ) -> 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) { + self.pairs.push(DemandPair { + source_index, + operator_index, + }); + retained.push(source_index); + } else { + self.rejected_pairs += 1; + } + } + + retained + } +} + +#[cfg(test)] +mod tests { + use bevy_math::{Quat, Vec3}; + + use super::*; + use crate::{Aabb, Brush, MaterialId, Primitive}; + + fn brush(name: &str, op: BrushOp, bounds: Aabb) -> Brush { + Brush::new( + crate::BrushId(0), + name, + op, + Primitive::Box { bounds }, + MaterialId(1), + ) + } + + #[test] + fn frontier_keeps_only_touching_ordered_pairs() { + let brushes = vec![ + brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + ), + brush( + "far cutter", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::splat(20.0), Vec3::ONE), + ), + brush( + "near cutter", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + ), + ]; + + let frontier = DemandFrontier::from_ordered_brushes(&brushes); + + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.rejected_pairs, 1); + assert_eq!( + frontier.pairs, + vec![DemandPair { + source_index: 0, + operator_index: 2, + }] + ); + } + + #[test] + fn intersection_frontier_reduces_active_sources() { + let brushes = vec![ + brush( + "a", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ), + brush( + "b", + BrushOp::Add, + Aabb::from_center_size(Vec3::new(20.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + Brush::new( + crate::BrushId(2), + "common", + BrushOp::Intersect, + Primitive::OrientedBox { + center: Vec3::ZERO, + size: Vec3::splat(3.0), + rotation: Quat::IDENTITY, + }, + MaterialId(1), + ), + brush( + "cut", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + ), + ]; + + let frontier = DemandFrontier::from_ordered_brushes(&brushes); + + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.rejected_pairs, 1); + assert_eq!( + frontier.pairs, + vec![ + DemandPair { + source_index: 0, + operator_index: 2, + }, + DemandPair { + source_index: 0, + operator_index: 3, + }, + ] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 48fb597..096a36b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ mod assembler; mod brush; mod convex; mod dsl; +mod frontier; mod mesh; mod primitives; mod tree; @@ -18,6 +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}; pub use dsl::LevelDsl; +pub use frontier::{DemandFrontier, DemandPair}; pub use mesh::TriangleMesh; pub use primitives::{ DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm,