Add experimental routed CSG surfaces
This commit is contained in:
parent
84371de2a2
commit
8f639d715b
4 changed files with 420 additions and 33 deletions
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
221
src/assembler.rs
221
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
src/convex.rs
198
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<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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue