diff --git a/docs/csg-performance-fixtures.md b/docs/csg-performance-fixtures.md index 524c4be..1014e9b 100644 --- a/docs/csg-performance-fixtures.md +++ b/docs/csg-performance-fixtures.md @@ -19,9 +19,17 @@ The script builds `vg_csg` in release mode and writes JSONL to - 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 -unchanged graph return the cached mesh. Dirty generations still rebuild. +The `vg_csg` fixture measures: + +- `stable`: cached output after warmup +- `dirty`: full rebuild through the current ordered assembler +- `routed`: experimental surface-router rebuild, falling back to the ordered + assembler outside its current single-source, single-candidate subtractive + convex contract. The fixture only emits routed records for supported cases. + +`Assembler` caches evaluated output by generation, so repeated `build()` calls +on an unchanged graph return the cached mesh. Dirty and routed generations +still rebuild. ## Scenarios @@ -91,6 +99,17 @@ new kernel path; they expose the ordered brush stream as affected source/operato pairs and rejected bounds pairs so future router/index work can prove it is refusing real work rather than merely rearranging it. +The first routed surface experiment is intentionally narrow. It cleans a single +subtractive convex cut into boundary polygons instead of closed fragment boxes, +but dense repeated cutters currently create surface-piece explosion. Keep dense +rotated subtraction on the ordered kernel until the category router has compact +frontier batching and scratch storage. + +Current routed lesson: for `single_center_cut`, routed output emits 48 boundary +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. + ## Latest Local Baseline Captured on 2026-05-07 with `.\tools\run_csg_perf.ps1 -UseRealtimeCsgCpp`: diff --git a/examples/csg_perf_fixture.rs b/examples/csg_perf_fixture.rs index 20c092c..9b370ec 100644 --- a/examples/csg_perf_fixture.rs +++ b/examples/csg_perf_fixture.rs @@ -41,6 +41,7 @@ fn main() { for case in cases { run_case(case, PerfMode::Stable); run_case(case, PerfMode::Dirty); + run_case(case, PerfMode::Routed); } } @@ -55,6 +56,7 @@ struct PerfCase { enum PerfMode { Stable, Dirty, + Routed, } impl PerfMode { @@ -62,6 +64,7 @@ impl PerfMode { match self { Self::Stable => "stable", Self::Dirty => "dirty", + Self::Routed => "routed", } } @@ -69,12 +72,16 @@ impl PerfMode { match self { Self::Stable => assembler.build(), Self::Dirty => assembler.rebuild(), + Self::Routed => assembler.rebuild_routed_surfaces(), } } } fn run_case(case: PerfCase, mode: PerfMode) { let assembler = (case.build)(); + if matches!(mode, PerfMode::Routed) && !assembler.supports_routed_surfaces() { + return; + } let warmup = mode.build(&assembler); black_box(warmup.mesh.triangle_count()); diff --git a/src/assembler.rs b/src/assembler.rs index 35251e0..a854138 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -3,7 +3,7 @@ use std::cell::RefCell; use bevy_math::Vec3; use crate::{ - Aabb, Brush, BrushId, BrushOp, ConvexSolid, MaterialId, Primitive, TriangleMesh, + Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, MaterialId, Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, append_floret_arm, primitives::{DomeCapZSpec, FloretArmSpec}, }; @@ -183,6 +183,111 @@ impl Assembler { self.build_uncached() } + pub fn rebuild_routed_surfaces(&self) -> BuildOutput { + self.try_build_routed_surfaces() + .unwrap_or_else(|| self.build_uncached()) + } + + pub fn supports_routed_surfaces(&self) -> bool { + self.supports_routed_surface_subset() + } + + fn try_build_routed_surfaces(&self) -> Option { + if !self.supports_routed_surface_subset() { + return None; + } + + let mut report = BuildReport { + input_brushes: self.brushes.len(), + ..BuildReport::default() + }; + let mut source = None::; + let mut surfaces = Vec::::new(); + let mut previous_cutters = Vec::::new(); + + for brush in &self.compiled { + match brush.op { + BrushOp::Add => { + if source.is_some() { + return None; + } + let solid = brush.convex_cutter()?; + surfaces = solid.polygons.clone(); + source = Some(solid); + } + BrushOp::Subtract => { + report.operator_brushes += 1; + let source_solid = source.as_ref()?; + if !source_solid.bounds.intersects(brush.bounds) { + report.rejected_pairs += 1; + continue; + } + let cutter = brush.convex_cutter()?; + report.candidate_pairs += 1; + surfaces = ConvexSolid::route_polygons_outside_of(surfaces, &cutter); + + let mut caps = ConvexSolid::route_polygons_inside_of( + cutter.polygons.clone(), + source_solid, + ); + for previous in &previous_cutters { + caps = ConvexSolid::route_polygons_outside_of(caps, previous); + } + for cap in &mut caps { + cap.material = source_solid.material; + cap.reversed = !cap.reversed; + } + surfaces.extend(caps); + previous_cutters.push(cutter); + } + BrushOp::Intersect => return None, + } + } + + let mut mesh = TriangleMesh::new(); + ConvexSolid::append_polygons_to_mesh(&surfaces, &mut mesh); + report.emitted_convex_fragments = surfaces.len(); + + Some(BuildOutput { + mesh, + report, + generation: self.generation, + }) + } + + fn supports_routed_surface_subset(&self) -> bool { + let mut source = None::; + let mut candidate_subtracts = 0usize; + + for brush in &self.compiled { + match brush.op { + BrushOp::Add => { + if source.is_some() || brush.convex_cutter().is_none() { + return false; + } + source = Some(brush.bounds); + } + BrushOp::Subtract => { + let Some(source_bounds) = source else { + return false; + }; + if brush.convex_cutter().is_none() { + return false; + } + if source_bounds.intersects(brush.bounds) { + candidate_subtracts += 1; + if candidate_subtracts > 1 { + return false; + } + } + } + BrushOp::Intersect => return false, + } + } + + source.is_some() && candidate_subtracts == 1 + } + fn build_uncached(&self) -> BuildOutput { if self .compiled @@ -691,4 +796,118 @@ mod tests { assert_eq!(cached.mesh.positions, stable.mesh.positions); assert_eq!(cached.mesh.indices, stable.mesh.indices); } + + #[test] + fn routed_surface_rebuild_emits_clean_center_cut_boundary() { + let mut asm = Assembler::new(); + let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box("void", cutter); + + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!(routed.report.operator_brushes, 1); + assert_eq!(routed.report.candidate_pairs, 1); + assert_eq!(routed.report.rejected_pairs, 0); + assert_eq!(routed.report.emitted_convex_fragments, 24); + assert!(routed.mesh.triangle_count() < asm.rebuild().mesh.triangle_count()); + for tri in routed.mesh.indices.chunks_exact(3) { + let center = (routed.mesh.positions[tri[0] as usize] + + routed.mesh.positions[tri[1] as usize] + + routed.mesh.positions[tri[2] as usize]) + / 3.0; + assert!(!cutter.contains_point_strict(center, 1.0e-4)); + } + } + + #[test] + fn routed_surface_rebuild_falls_back_for_intersections() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.add_brush( + "common", + BrushOp::Intersect, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_falls_back_for_multiple_candidate_cutters() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_oriented_box( + "a", + Vec3::new(-0.5, 0.0, 0.0), + Vec3::new(1.0, 5.0, 5.0), + bevy_math::Quat::from_rotation_z(0.25), + ); + asm.cut_oriented_box( + "b", + Vec3::new(0.5, 0.0, 0.0), + Vec3::new(1.0, 5.0, 5.0), + bevy_math::Quat::from_rotation_z(-0.25), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_falls_back_when_no_cutter_touches_source() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box( + "far", + Aabb::from_center_size(Vec3::splat(20.0), Vec3::splat(2.0)), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } } diff --git a/src/convex.rs b/src/convex.rs index 5bf3f1c..3b98ddc 100644 --- a/src/convex.rs +++ b/src/convex.rs @@ -256,39 +256,75 @@ impl ConvexSolid { } pub fn categorize_polygons_against(&self, cutter: &Self) -> CategorizedPolygons { - let mut categorized = CategorizedPolygons::default(); + categorize_polygon_list_against(&self.polygons, cutter) + } - for polygon in &self.polygons { - let mut pieces = vec![CategorizationPiece { - polygon: polygon.clone(), - aligned_category: None, - }]; + pub fn route_polygons_outside_of( + polygons: Vec, + cutter: &Self, + ) -> Vec { + let categorized = categorize_polygon_list_against(&polygons, cutter); + categorized.outside + } - 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; - } + pub fn route_polygons_inside_of( + polygons: Vec, + cutter: &Self, + ) -> Vec { + let categorized = categorize_polygon_list_against(&polygons, cutter); + let mut inside = categorized.inside; + inside.extend(categorized.aligned); + inside.extend(categorized.reverse_aligned); + inside + } - 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); - } - _ => categorized.inside.push(piece.polygon), - } - } + pub fn subtract_convex_routed_polygons(&self, cutter: &Self) -> Vec { + let mut polygons = Vec::new(); + let source = self.categorize_polygons_against(cutter); + polygons.extend(source.outside); + + let cutter_inside_source = cutter.categorize_polygons_against(self); + for mut polygon in cutter_inside_source.inside { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + for mut polygon in cutter_inside_source.aligned { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + for mut polygon in cutter_inside_source.reverse_aligned { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); } - categorized + polygons + } + + pub fn append_polygons_to_mesh(polygons: &[ConvexPolygon], mesh: &mut TriangleMesh) { + for polygon in polygons { + if !polygon.visible || polygon.vertices.len() < 3 { + continue; + } + let base = polygon.vertices[0]; + for i in 1..polygon.vertices.len() - 1 { + let triangle = if polygon.reversed { + [base, polygon.vertices[i + 1], polygon.vertices[i]] + } else { + [base, polygon.vertices[i], polygon.vertices[i + 1]] + }; + let normal = if polygon.reversed { + -polygon.normal + } else { + polygon.normal + }; + mesh.append_triangle( + triangle, + normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + polygon.material, + ); + } + } } fn split_owned(self, plane: Plane) -> SplitResult { @@ -348,6 +384,40 @@ impl ConvexSolid { } } +fn categorize_polygon_list_against( + 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 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); + } + _ => categorized.inside.push(piece.polygon), + } + } + } + + categorized +} + enum SplitResult { Front(ConvexSolid), Back(ConvexSolid), @@ -731,4 +801,76 @@ mod tests { .any(|polygon| polygon.normal.x.abs() > 0.01 && polygon.normal.y.abs() > 0.01) })); } + + #[test] + fn routed_subtraction_emits_source_shell_and_reversed_cutter_shell() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let routed = source.subtract_convex_routed_polygons(&cutter); + + assert_eq!(routed.len(), 24); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(1)) + .count(), + 18 + ); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(0)) + .count(), + 6 + ); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(0) && polygon.reversed) + .count(), + 6 + ); + } + + #[test] + fn routed_subtraction_clips_crossing_cutter_surfaces_to_source() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::new(1.5, 0.0, 0.0), + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let routed = source.subtract_convex_routed_polygons(&cutter); + + assert!(!routed.is_empty()); + assert!( + routed + .iter() + .flat_map(|polygon| polygon.vertices.iter()) + .all(|vertex| source.bounds.contains_point(*vertex)) + ); + assert!( + routed + .iter() + .any(|polygon| polygon.material == MaterialId(0) && polygon.reversed) + ); + } }