diff --git a/crates/vg_csg/examples/csg_perf_fixture.rs b/crates/vg_csg/examples/csg_perf_fixture.rs index 20c092c..9b370ec 100644 --- a/crates/vg_csg/examples/csg_perf_fixture.rs +++ b/crates/vg_csg/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/crates/vg_csg/src/assembler.rs b/crates/vg_csg/src/assembler.rs index 35251e0..a854138 100644 --- a/crates/vg_csg/src/assembler.rs +++ b/crates/vg_csg/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/crates/vg_csg/src/convex.rs b/crates/vg_csg/src/convex.rs index 5bf3f1c..3b98ddc 100644 --- a/crates/vg_csg/src/convex.rs +++ b/crates/vg_csg/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) + ); + } } diff --git a/docs/research/csg-performance-fixtures.md b/docs/research/csg-performance-fixtures.md index 524c4be..1014e9b 100644 --- a/docs/research/csg-performance-fixtures.md +++ b/docs/research/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/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 3a70c67..c19f48e 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -98,6 +98,15 @@ First implementation slice: `vg_csg` exports `DemandFrontier`/`DemandPair`, and `rejected_pairs`. These counters are currently observational and follow existing bounds gates; they are not yet a replacement router. +Second implementation slice: `Assembler::rebuild_routed_surfaces()` now exposes +an experimental surface-router path for exactly one convex source and one +candidate subtractive convex cutter. It emits surviving source boundary polygons +plus reversed cutter cap polygons, so the centered cut drops from 72 closed +fragment-carver triangles to 48 boundary triangles. The perf fixture only emits +`mode:"routed"` for supported cases. Dense repeated cutters must stay on the +ordered kernel until the router has compact frontier batching and scratch +storage; naive surface-list routing exploded polygon counts. + ## Important Invariants - Target Blender 5.0+ unless the task says otherwise. diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c1cb1db..c744eed 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -63,3 +63,4 @@ {"ts": "2026-05-07T03:31:28+00:00", "type": "implementation", "status": "ok", "note": "Added explicit stable and dirty vg_csg perf modes, public rebuild() bypassing stable output cache, cached-vs-dirty output parity test, and convex containment shortcuts. Latest fixture shows dirty box-heavy and distant-rejection paths are respectable; dirty dense rotated subtraction remains the next category-router target."} {"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."} diff --git a/state/map.yaml b/state/map.yaml index 4d79755..aa73f66 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -111,7 +111,7 @@ data_flow: current_status: summary: VibeGeometry has verified Geometry Script translations for local samples, official Blender demos, and IRCSS French Houses footholds, plus from-scratch Aetheria Bloom and Lucent tether habitat builds. Durable lessons now driving the pipeline: Python orchestrates intent and batching; Geometry Script emits inspectable node groups; source-bound scenes need explicit coordinate contracts before ornament; spin-gravity habitats need ground planes orthogonal to their tether/rotation axis; utility petals, radiators, and solar arrays belong outside pressure glass and may need Citadel-scale presence; large habitat parts need visible load paths across coordinate frames; organic city maps need street fields, irregular parcels, parks, and land-use gradients rather than grids; dense detail must be batched; institutional spaces should model power as surfaces, rails, overlays, thresholds, shader behavior, and camera evidence; and visual acceptance needs domain-facing inspection renders plus verifier checks, not only overview renders. The first reusable `vibegeometry/` helper library now carries coordinate frames, fields, batching, Blender scene primitives, and verifier helpers used by the Bloom and Lucent builds. The Rust crate `vg_csg` now has a RealtimeCSG-shaped API skeleton plus a convex-brush stepping-stone kernel: CSG operation ordinals, branch vocabulary, tree/branch/brush handles, child and operation mutation, exact parity fixtures, ordered assembler compilation, AABB/oriented-box subtraction/intersection via convex plane splitting, crossing polygon category buckets, primitive/solid/tree bounds, additive cylinder/dome/floret primitives, JSONL performance fixtures with stable and dirty modes, mesh-array output, stable-generation output caching, tests, clippy, and examples. The `vg_grammar` crate is the Dream Machine authoring layer: semantic solid/void claims with tags compile into flat `vg_csg` brushes or generated CSG tree intent; it supports local frames, oriented claims, deterministic seeded variation, rule composition, and generated gallery chains. Public RealtimeCSG demo/source research points the target away from fragment carving and toward faster polygon classification through boolean trees. A standalone RealtimeCSG C++ P/Invoke bridge now mirrors the managed wrapper surface, verifies a native health cube, extracts generated mesh descriptions, and appends direct native timing records. Current perf pass: `vg_csg` has a fast axis-aligned path, cached compiled brush geometry, owned convex splitting, convex containment shortcuts, stable-tree output caching, and stable/dirty perf records; dirty box-heavy and distant-rejection paths are respectable, but dirty dense rotated subtraction still needs the category-router kernel. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes. - next_action: Use the new demand-frontier counters to replace the ordered `vg_csg` dense rotated subtraction path with a category-router kernel; keep cached-vs-dirty output parity tests green, compare against the C++ timing oracle, and only add BVH/grid storage after frontier batching proves it needs one. + next_action: Generalize the experimental `vg_csg` routed-surface path from the single-candidate subtract case into a compact dense category-router using frontier batches and scratch storage; keep cached-vs-dirty output parity tests green, compare against the C++ timing oracle, and only add BVH/grid storage after batching proves it needs one. remotes: vibegeometry: https://github.com/GameCult/VibeGeometry geometry_script_fork: https://github.com/GameCult/geometry-script