Add experimental routed CSG surfaces

This commit is contained in:
Meta Vens 2026-05-07 08:31:23 +01:00
commit 8f639d715b
4 changed files with 420 additions and 33 deletions

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

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