Add experimental routed CSG surfaces

This commit is contained in:
Meta Vens 2026-05-07 08:31:23 +01:00
commit 053b244295
7 changed files with 431 additions and 34 deletions

View file

@ -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());

View file

@ -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<BuildOutput> {
if !self.supports_routed_surface_subset() {
return None;
}
let mut report = BuildReport {
input_brushes: self.brushes.len(),
..BuildReport::default()
};
let mut source = None::<ConvexSolid>;
let mut surfaces = Vec::<ConvexPolygon>::new();
let mut previous_cutters = Vec::<ConvexSolid>::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::<Aabb>;
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);
}
}

View file

@ -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<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
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<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
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<ConvexPolygon> {
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)
);
}
}

View file

@ -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`:

View file

@ -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.

View file

@ -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."}

View file

@ -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