Advance CSG category kernel groundwork

This commit is contained in:
Meta Vens 2026-05-07 01:42:03 +01:00
commit 83fc507071
6 changed files with 530 additions and 42 deletions

View file

@ -69,10 +69,16 @@ assert!(output.mesh.triangle_count() > 12);
The first actual CSG operator was exact AABB subtraction against additive AABB
solids. The current kernel has moved to convex decomposition: an additive
convex solid is split against each outward cutter plane, outside fragments are
emitted, and the inside remainder is discarded after the final plane. This
supports angled box cuts and gives us the correct shape for a future general
convex brush kernel.
convex solid is split against each outward cutter plane, subtraction emits the
outside fragments, and intersection keeps the inside remainder. This supports
angled box cuts, real `Common` branch output for convex boxes, and gives us the
correct shape for a future general convex brush kernel.
Polygons now carry the first routing metadata the mature kernel needs:
category, visibility, reversal, and bounds. Whole polygons can already be
classified as inside, outside, aligned, or reverse-aligned against a convex
brush when they do not cross cutter planes. That is deliberately not sold as
the full router yet. It is the labeled wire we will plug the splitter into.
The important doctrinal adjustment from the public demo and blog series is that
real-time CSG should think in **classification**, not only carving. A polygon is
@ -134,12 +140,13 @@ For interactive work, the brush list is the durable design surface. The mesh is
a cache. This matters because agents should revise intent by moving, adding, or
renaming brushes, not by poking triangles after the fact.
Use CSG where negative space carries meaning:
Use CSG where boolean space carries meaning:
- doorways, windows, hatches, service trenches
- docking sockets and tether anchor gaps
- ring terraces and mechanically repeated cutouts
- fast blockouts where the same cutter can punch many solids
- common-volume filters where rooms, shafts, or constraint regions overlap
Use additive procedural primitives where a form is better described by a field
or sweep than by carving:
@ -148,10 +155,10 @@ or sweep than by carving:
- city-field parcels after street generation
- flower-like utility crests and other habitat-specific silhouettes
The immediate next upgrade is not "more primitives." It is a stronger convex
brush kernel: plane classification, polygon clipping, category routing,
fragment merge policy, and a BVH or spatial hash so cutter cost does not scale
like a punishment.
The immediate next upgrade is not "more primitives." It is the category-router
kernel: split crossing polygons, route classified source polygons through
boolean branches, preserve material/surface metadata, and then add a BVH or
spatial hash so cutter cost does not scale like a punishment.
## Limits
@ -159,7 +166,7 @@ like a punishment.
- subtractive convex support currently covers axis-aligned and oriented boxes
- non-box subtractors are reported and ignored
- intersect brushes are reported and ignored
- intersect/common support currently covers axis-aligned and oriented boxes
- output is Bevy-compatible mesh data, not yet a Bevy `Mesh` asset constructor
- there is no editor gizmo, ECS plugin, or incremental spatial index yet
- current subtraction emits split fragments with generated cap polygons; the

View file

@ -9,7 +9,7 @@ use crate::{
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BuildWarning {
SubtractIgnoredForNonBox { brush: String },
IntersectUnsupported { brush: String },
IntersectIgnoredForNonBox { brush: String },
}
#[derive(Clone, Debug, Default)]
@ -208,9 +208,15 @@ impl Assembler {
}
}
BrushOp::Intersect => {
report.warnings.push(BuildWarning::IntersectUnsupported {
brush: brush.name.clone(),
});
if let Some(cutter) = convex_cutter_for_brush(brush) {
solids = intersect_solids(&solids, &cutter);
} else {
report
.warnings
.push(BuildWarning::IntersectIgnoredForNonBox {
brush: brush.name.clone(),
});
}
}
}
}
@ -269,7 +275,23 @@ fn convex_cutter_for_brush(brush: &Brush) -> Option<ConvexSolid> {
fn subtract_from_solids(solids: &[ConvexSolid], cutter: &ConvexSolid) -> Vec<ConvexSolid> {
let mut out = Vec::with_capacity(solids.len() + 4);
for solid in solids {
out.extend(solid.subtract_convex(cutter));
if solid.bounds.intersects(cutter.bounds) {
out.extend(solid.subtract_convex(cutter));
} else {
out.push(solid.clone());
}
}
out
}
fn intersect_solids(solids: &[ConvexSolid], cutter: &ConvexSolid) -> Vec<ConvexSolid> {
let mut out = Vec::with_capacity(solids.len());
for solid in solids {
if solid.bounds.intersects(cutter.bounds)
&& let Some(fragment) = solid.intersect_convex(cutter)
{
out.push(fragment);
}
}
out
}
@ -337,4 +359,45 @@ mod tests {
assert_eq!(mesh.triangle_materials.len(), 12);
assert_eq!(mesh.triangle_materials[0], MaterialId(7));
}
#[test]
fn distant_subtract_skips_convex_split_and_keeps_source_solid() {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
MaterialId(1),
);
asm.cut_box(
"far void",
Aabb::from_center_size(Vec3::splat(10.0), Vec3::splat(1.0)),
);
let output = asm.build();
assert_eq!(output.report.emitted_convex_fragments, 1);
assert_eq!(output.mesh.triangle_count(), 12);
}
#[test]
fn intersecting_box_keeps_common_convex_region() {
let mut asm = Assembler::new();
asm.solid_box(
"a",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
MaterialId(1),
);
asm.add_brush(
"b",
BrushOp::Intersect,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)),
},
MaterialId(1),
);
let output = asm.build();
assert_eq!(output.report.emitted_convex_fragments, 1);
assert_eq!(output.report.warnings.len(), 0);
assert_eq!(output.mesh.triangle_count(), 12);
}
}

View file

@ -28,6 +28,13 @@ pub struct Aabb {
}
impl Aabb {
pub fn empty() -> Self {
Self {
min: Vec3::splat(f32::INFINITY),
max: Vec3::splat(f32::NEG_INFINITY),
}
}
pub fn from_center_size(center: Vec3, size: Vec3) -> Self {
let half = size * 0.5;
Self {
@ -40,6 +47,32 @@ impl Aabb {
Self { min, max }
}
pub fn from_points(points: &[Vec3]) -> Self {
let mut bounds = Self::empty();
for point in points {
bounds.include_point(*point);
}
bounds
}
pub fn include_point(&mut self, point: Vec3) {
self.min = self.min.min(point);
self.max = self.max.max(point);
}
pub fn union(self, other: Self) -> Self {
if !self.has_finite_extents() {
return other;
}
if !other.has_finite_extents() {
return self;
}
Self {
min: self.min.min(other.min),
max: self.max.max(other.max),
}
}
pub fn size(self) -> Vec3 {
self.max - self.min
}
@ -52,6 +85,10 @@ impl Aabb {
self.max.x > self.min.x && self.max.y > self.min.y && self.max.z > self.min.z
}
pub fn has_finite_extents(self) -> bool {
self.min.is_finite() && self.max.is_finite()
}
pub fn intersects(self, other: Self) -> bool {
self.min.x < other.max.x
&& self.max.x > other.min.x
@ -226,4 +263,103 @@ impl Brush {
_ => None,
}
}
pub fn bounds(&self) -> Aabb {
self.primitive.bounds()
}
}
impl Primitive {
pub fn bounds(&self) -> Aabb {
match *self {
Self::Box { bounds } => bounds,
Self::OrientedBox {
center,
size,
rotation,
} => oriented_box_bounds(center, size, rotation),
Self::CylinderZ {
center,
radius,
depth,
..
} => Aabb::new(
center - Vec3::new(radius, radius, depth * 0.5),
center + Vec3::new(radius, radius, depth * 0.5),
),
Self::DomeCapZ {
center,
radius,
height,
..
} => Aabb::new(
center - Vec3::new(radius, radius, 0.0),
center + Vec3::new(radius, radius, height),
),
Self::FloretArm {
anchor,
direction,
length,
root_width,
tip_width,
thickness,
tip_lift,
} => floret_bounds(
anchor, direction, length, root_width, tip_width, thickness, tip_lift,
),
}
}
}
fn oriented_box_bounds(center: Vec3, size: Vec3, rotation: Quat) -> Aabb {
let half = size * 0.5;
let local_corners = [
Vec3::new(-half.x, -half.y, -half.z),
Vec3::new(half.x, -half.y, -half.z),
Vec3::new(half.x, half.y, -half.z),
Vec3::new(-half.x, half.y, -half.z),
Vec3::new(-half.x, -half.y, half.z),
Vec3::new(half.x, -half.y, half.z),
Vec3::new(half.x, half.y, half.z),
Vec3::new(-half.x, half.y, half.z),
];
let corners = local_corners.map(|point| center + rotation * point);
Aabb::from_points(&corners)
}
fn floret_bounds(
anchor: Vec3,
direction: Vec3,
length: f32,
root_width: f32,
tip_width: f32,
thickness: f32,
tip_lift: f32,
) -> Aabb {
let forward = direction.normalize_or_zero();
let forward = if forward.length_squared() == 0.0 {
Vec3::X
} else {
forward
};
let mut side = forward.cross(Vec3::Z).normalize_or_zero();
if side.length_squared() == 0.0 {
side = Vec3::Y;
}
let root_center = anchor;
let tip_center = anchor + forward * length + Vec3::Z * tip_lift;
let z = Vec3::Z * (thickness * 0.5);
let r = side * (root_width * 0.5);
let t = side * (tip_width * 0.5);
Aabb::from_points(&[
root_center - r - z,
root_center + r - z,
root_center - r + z,
root_center + r + z,
tip_center - t - z,
tip_center + t - z,
tip_center - t + z,
tip_center + t + z,
])
}

View file

@ -1,6 +1,6 @@
use bevy_math::{Quat, Vec2, Vec3};
use crate::{Aabb, MaterialId, TriangleMesh};
use crate::{Aabb, MaterialId, PolygonCategory, TriangleMesh};
const EPSILON: f32 = 1.0e-5;
@ -22,6 +22,11 @@ impl Plane {
pub fn signed_distance(self, point: Vec3) -> f32 {
self.normal.dot(point) + self.distance
}
pub fn is_coplanar_with(self, other: Self) -> bool {
self.normal.dot(other.normal).abs() > 1.0 - EPSILON
&& (self.distance - other.distance).abs() <= EPSILON
}
}
#[derive(Clone, Debug)]
@ -29,6 +34,43 @@ pub struct ConvexPolygon {
pub vertices: Vec<Vec3>,
pub normal: Vec3,
pub material: MaterialId,
pub category: PolygonCategory,
pub visible: bool,
pub reversed: bool,
pub bounds: Aabb,
}
impl ConvexPolygon {
pub fn new(vertices: Vec<Vec3>, normal: Vec3, material: MaterialId) -> Self {
Self {
bounds: Aabb::from_points(&vertices),
vertices,
normal: normal.normalize_or_zero(),
material,
category: PolygonCategory::Aligned,
visible: true,
reversed: false,
}
}
pub fn centroid(&self) -> Vec3 {
if self.vertices.is_empty() {
return Vec3::ZERO;
}
self.vertices.iter().copied().sum::<Vec3>() / self.vertices.len() as f32
}
fn with_vertices(&self, vertices: Vec<Vec3>) -> Self {
Self {
bounds: Aabb::from_points(&vertices),
vertices,
normal: self.normal,
material: self.material,
category: self.category,
visible: self.visible,
reversed: self.reversed,
}
}
}
#[derive(Clone, Debug)]
@ -36,6 +78,7 @@ pub struct ConvexSolid {
pub polygons: Vec<ConvexPolygon>,
pub clip_planes: Vec<Plane>,
pub material: MaterialId,
pub bounds: Aabb,
}
impl ConvexSolid {
@ -76,14 +119,11 @@ impl ConvexSolid {
let normal = rotation * local_normal;
let vertices = indices.map(|index| corners[index]).to_vec();
clip_planes.push(Plane::from_point_normal(vertices[0], normal));
polygons.push(ConvexPolygon {
vertices,
normal,
material,
});
polygons.push(ConvexPolygon::new(vertices, normal, material));
}
Self {
bounds: Aabb::from_points(&corners),
polygons,
clip_planes,
material,
@ -120,16 +160,40 @@ impl ConvexSolid {
fragments
}
pub fn intersect_convex(&self, cutter: &Self) -> Option<Self> {
let mut remainder = self.clone();
for plane in &cutter.clip_planes {
remainder = match remainder.split(*plane) {
SplitResult::Front(_) => return None,
SplitResult::Back(back) | SplitResult::Coplanar(back) => back,
SplitResult::Both { back, .. } => back,
};
}
Some(remainder)
}
pub fn append_to_mesh(&self, mesh: &mut TriangleMesh) {
for polygon in &self.polygons {
if polygon.vertices.len() < 3 {
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(
[base, polygon.vertices[i], polygon.vertices[i + 1]],
polygon.normal,
triangle,
normal,
[Vec2::ZERO, Vec2::X, Vec2::Y],
polygon.material,
);
@ -137,6 +201,14 @@ impl ConvexSolid {
}
}
pub fn categorize_whole_polygons_against(&mut self, cutter: &Self) {
for polygon in &mut self.polygons {
if let Some(category) = whole_polygon_category_against(polygon, cutter) {
polygon.category = category;
}
}
}
fn split(&self, plane: Plane) -> SplitResult {
let mut saw_front = false;
let mut saw_back = false;
@ -162,20 +234,12 @@ impl ConvexSolid {
for polygon in &self.polygons {
let front = clip_polygon(&polygon.vertices, plane, KeepSide::Front, &mut cap_points);
if front.len() >= 3 {
front_polygons.push(ConvexPolygon {
normal: polygon.normal,
vertices: front,
material: polygon.material,
});
front_polygons.push(polygon.with_vertices(front));
}
let back = clip_polygon(&polygon.vertices, plane, KeepSide::Back, &mut cap_points);
if back.len() >= 3 {
back_polygons.push(ConvexPolygon {
normal: polygon.normal,
vertices: back,
material: polygon.material,
});
back_polygons.push(polygon.with_vertices(back));
}
}
@ -187,11 +251,13 @@ impl ConvexSolid {
SplitResult::Both {
front: Self {
bounds: bounds_from_polygons(&front_polygons),
clip_planes: planes_from_polygons(&front_polygons),
polygons: front_polygons,
material: self.material,
},
back: Self {
bounds: bounds_from_polygons(&back_polygons),
clip_planes: planes_from_polygons(&back_polygons),
polygons: back_polygons,
material: self.material,
@ -284,11 +350,7 @@ fn make_cap_polygon(points: &[Vec3], normal: Vec3, material: MaterialId) -> Conv
vertices.reverse();
}
ConvexPolygon {
vertices,
normal,
material,
}
ConvexPolygon::new(vertices, normal, material)
}
fn unique_points_on_plane(points: Vec<Vec3>) -> Vec<Vec3> {
@ -317,6 +379,55 @@ fn planes_from_polygons(polygons: &[ConvexPolygon]) -> Vec<Plane> {
.collect()
}
fn bounds_from_polygons(polygons: &[ConvexPolygon]) -> Aabb {
polygons.iter().fold(Aabb::empty(), |bounds, polygon| {
bounds.union(polygon.bounds)
})
}
fn whole_polygon_category_against(
polygon: &ConvexPolygon,
cutter: &ConvexSolid,
) -> Option<PolygonCategory> {
let polygon_plane = polygon
.vertices
.first()
.map(|point| Plane::from_point_normal(*point, polygon.normal))?;
for cutter_plane in &cutter.clip_planes {
if polygon_plane.is_coplanar_with(*cutter_plane)
&& polygon
.vertices
.iter()
.all(|vertex| cutter_plane.signed_distance(*vertex).abs() <= EPSILON)
{
return Some(if polygon.normal.dot(cutter_plane.normal) >= 0.0 {
PolygonCategory::Aligned
} else {
PolygonCategory::ReverseAligned
});
}
}
let mut fully_inside = true;
for cutter_plane in &cutter.clip_planes {
let mut all_outside_this_plane = true;
for vertex in &polygon.vertices {
let distance = cutter_plane.signed_distance(*vertex);
if distance > EPSILON {
fully_inside = false;
} else {
all_outside_this_plane = false;
}
}
if all_outside_this_plane {
return Some(PolygonCategory::Outside);
}
}
fully_inside.then_some(PolygonCategory::Inside)
}
fn polygon_normal(vertices: &[Vec3]) -> Vec3 {
if vertices.len() < 3 {
return Vec3::ZERO;
@ -383,6 +494,30 @@ mod tests {
);
}
#[test]
fn intersect_overlapping_boxes_emits_single_common_solid() {
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::X,
Vec3::splat(4.0),
Quat::IDENTITY,
MaterialId(0),
);
let common = source.intersect_convex(&cutter).expect("intersection");
assert!(common.bounds.is_valid());
assert_eq!(common.polygons.len(), 6);
assert!(common.bounds.contains_point(Vec3::ZERO));
assert!(common.bounds.contains_point(Vec3::new(2.0, 0.0, 0.0)));
assert!(!common.bounds.contains_point(Vec3::new(-2.0, 0.0, 0.0)));
}
#[test]
fn subtract_rotated_box_creates_non_axis_aligned_faces() {
let source = ConvexSolid::box_from_center_size(

View file

@ -1,4 +1,4 @@
use crate::{Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive};
use crate::{Aabb, Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgNodeId(pub u32);
@ -160,6 +160,13 @@ impl CsgTreeArena {
Some(children)
}
pub fn node_bounds(&self, id: CsgNodeId) -> Option<Aabb> {
match self.node(id)? {
CsgNode::Brush(brush) => Some(brush.bounds()),
CsgNode::Branch { op, children, .. } => self.branch_bounds(*op, children),
}
}
pub fn compile_tree_to_assembler(&self, tree: CsgTree) -> Assembler {
let mut assembler = Assembler::new();
self.compile_node(tree.root, &mut assembler, None);
@ -207,13 +214,32 @@ impl CsgTreeArena {
}
}
CsgBranchOp::Common => {
for child in children {
self.compile_node(*child, assembler, Some(CsgOperationType::Intersecting));
if let Some((first, rest)) = children.split_first() {
self.compile_node(*first, assembler, operation_override);
for child in rest {
self.compile_node(
*child,
assembler,
Some(CsgOperationType::Intersecting),
);
}
}
}
},
}
}
fn branch_bounds(&self, op: CsgBranchOp, children: &[CsgNodeId]) -> Option<Aabb> {
let mut child_bounds = children.iter().filter_map(|child| self.node_bounds(*child));
match op {
CsgBranchOp::Addition => child_bounds.reduce(Aabb::union),
CsgBranchOp::Subtraction => child_bounds.next(),
CsgBranchOp::Common => {
let first = child_bounds.next()?;
child_bounds.try_fold(first, |bounds, child| bounds.intersection(child))
}
}
}
}
impl CsgOperationType {
@ -303,4 +329,73 @@ mod tests {
assert_eq!(arena.brush_count(), 2);
assert_eq!(arena.branch_count(), 1);
}
#[test]
fn tree_bounds_follow_boolean_branch_shape() {
let mut arena = CsgTreeArena::new();
let a = arena.generate_brush(
"a",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
},
MaterialId(1),
);
let b = arena.generate_brush(
"b",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::X * 2.5, Vec3::splat(2.0)),
},
MaterialId(1),
);
let union = arena.generate_branch("union", CsgBranchOp::Addition, [a.node, b.node]);
let common = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]);
let subtract =
arena.generate_branch("subtract", CsgBranchOp::Subtraction, [a.node, b.node]);
assert_eq!(
arena.node_bounds(union.node).expect("union"),
Aabb::new(Vec3::splat(-2.0), Vec3::new(3.5, 2.0, 2.0))
);
assert_eq!(
arena.node_bounds(common.node).expect("common"),
Aabb::new(Vec3::new(1.5, -1.0, -1.0), Vec3::new(2.0, 1.0, 1.0))
);
assert_eq!(
arena.node_bounds(subtract.node).expect("subtract"),
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0))
);
}
#[test]
fn tree_common_compiles_to_additive_source_then_intersections() {
let mut arena = CsgTreeArena::new();
let a = arena.generate_brush(
"a",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
},
MaterialId(1),
);
let b = arena.generate_brush(
"b",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)),
},
MaterialId(1),
);
let branch = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]);
let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node));
let output = assembler.build();
assert_eq!(assembler.brushes()[0].op, BrushOp::Add);
assert_eq!(assembler.brushes()[1].op, BrushOp::Intersect);
assert_eq!(output.report.emitted_convex_fragments, 1);
assert_eq!(output.report.warnings.len(), 0);
assert_eq!(output.mesh.triangle_count(), 12);
}
}

View file

@ -89,3 +89,55 @@ fn parity_center_box_subtraction_has_six_surviving_regions() {
assert_eq!(output.report.emitted_convex_fragments, 6);
assert_eq!(output.report.warnings.len(), 0);
}
#[test]
fn parity_identical_box_polygons_classify_as_aligned() {
let cutter = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(0),
);
let mut source = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(1),
);
source.categorize_whole_polygons_against(&cutter);
assert_eq!(source.polygons.len(), 6);
assert!(
source
.polygons
.iter()
.all(|polygon| polygon.category == PolygonCategory::Aligned)
);
}
#[test]
fn parity_outer_box_polygons_classify_as_outside_inner_box() {
let cutter = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(0),
);
let mut source = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(4.0),
Quat::IDENTITY,
MaterialId(1),
);
source.categorize_whole_polygons_against(&cutter);
assert_eq!(source.polygons.len(), 6);
assert!(
source
.polygons
.iter()
.all(|polygon| polygon.category == PolygonCategory::Outside)
);
}