Advance CSG category kernel groundwork

This commit is contained in:
Meta Vens 2026-05-07 01:42:03 +01:00
commit f8591e0920
9 changed files with 543 additions and 52 deletions

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

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

@ -45,10 +45,11 @@ model, but not the optimized native C++ kernel or a supported runtime editing
API. Sander van Rossen's older public `LogicalError/Realtime-CSG-demo` and blog
series are now the preferred clean research inputs. Current `vg_csg`: public
CSG operation/branch/tree/brush handle surface, child and operation mutation,
ordered assembler backend, exact AABB and oriented-box subtraction via convex
plane splitting, additive `CylinderZ`, `DomeCapZ`, and `FloretArm` primitives,
Bevy-math mesh buffers, tests, clippy, and examples. Research note:
`docs/research/realtime-csg-bevy-assembler.md`.
ordered assembler backend, exact AABB and oriented-box subtraction and
intersection via convex plane splitting, polygon category/visibility/reversal
metadata, primitive/solid/tree bounds, additive `CylinderZ`, `DomeCapZ`, and
`FloretArm` primitives, Bevy-math mesh buffers, tests, clippy, and examples.
Research note: `docs/research/realtime-csg-bevy-assembler.md`.
Where `vg_csg` overlaps public RealtimeCSG/demo behavior, add exact observable
parity fixtures instead of approximate plausibility tests.
@ -65,10 +66,11 @@ Repo-local Geometry Script clone notes live in
`.\tools\setup_geometry_script_clone.ps1` if `external/geometry-script` is
missing.
The current next action is to replace the ordered assembler backend with the
category-router kernel behind the existing CSG tree API: implement source
polygon categories, visible/reversed flags, brush/branch bounds, and
boolean-tree classification while preserving current parity fixtures.
The current next action is to continue replacing the ordered assembler backend
with the category-router kernel behind the existing CSG tree API: split crossing
polygons into classifiable pieces, route inside/outside/aligned/reverse-aligned
categories through boolean branches, and preserve material/surface metadata
while keeping the current parity fixtures green.
## Important Invariants

View file

@ -55,3 +55,4 @@
{"ts": "2026-05-07T00:19:35+00:00", "type": "implementation", "status": "accepted", "note": "Extended the Dream Machine grammar with local frames, oriented claims, deterministic seeded variation, RuleSet composition, and GalleryChainSpec. The dream_room example now emits a rotated four-room chain as 42 semantic claims into vg_csg; workspace tests and clippy prove the grammar still preserves void claims through CSG assembly."}
{"ts": "2026-05-07T00:21:11+00:00", "type": "implementation", "status": "accepted", "note": "Added exact parity testing doctrine and first vg_csg parity fixtures for public RealtimeCSG-overlapping seams: polygon category vocabulary, six-plane/six-polygon box brush counts, and six surviving regions for centered box subtraction. Future matching API surface should get countable parity fixtures, not approximate plausibility checks."}
{"ts": "2026-05-07T00:31:59+00:00", "type": "implementation", "status": "accepted", "note": "Expanded the Rust API toward RealtimeCSG shape: added CsgTreeArena, CsgTree/CsgTreeBranch/CsgTreeBrush handles, CsgOperationType ordinals, CsgBranchOp vocabulary, branch child/operation mutation, brush operation mutation, tree-to-assembler compilation, grammar ClaimTree CSG tree emission, and parity fixtures for operation ordinals, branch vocabulary, and handle topology."}
{"ts": "2026-05-07T00:41:39+00:00", "type": "implementation", "status": "accepted", "note": "Advanced vg_csg toward the category-router kernel: ConvexPolygon now carries category/visibility/reversal/bounds metadata, primitives/solids/tree nodes expose bounds for early rejection, whole non-crossing polygons classify as inside/outside/aligned/reverse-aligned against convex cutters, convex intersection now powers Intersect brushes and Common branches, and parity/workspace tests keep the RealtimeCSG-shaped API seams honest."}

View file

@ -110,8 +110,8 @@ data_flow:
- If state grows faster than understanding, cut it back.
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 via convex plane splitting, additive cylinder/dome/floret primitives, mesh-array output, 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. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Replace the ordered assembler backend with the category-router kernel behind the existing CSG tree API: implement source polygon categories, visible/reversed flags, brush/branch bounds, and boolean-tree classification while preserving current parity fixtures.
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, whole-polygon category metadata, primitive/solid/tree bounds, additive cylinder/dome/floret primitives, mesh-array output, 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. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Continue replacing the ordered assembler backend with the category-router kernel behind the existing CSG tree API: split crossing polygons into classifiable pieces, route inside/outside/aligned/reverse-aligned categories through boolean branches, and preserve material/surface metadata while keeping current parity fixtures green.
remotes:
vibegeometry: https://github.com/GameCult/VibeGeometry
geometry_script_fork: https://github.com/GameCult/geometry-script