Add CSG tree API surface

This commit is contained in:
Meta Vens 2026-05-07 01:32:51 +01:00
commit e0cbecf539
4 changed files with 367 additions and 1 deletions

View file

@ -247,6 +247,20 @@ Process brush:
emit visible pieces into render mesh
```
`vg_csg` now exposes the first version of that public tree surface:
- `CsgOperationType::{Additive, Subtractive, Intersecting}` with parity-tested
ordinals matching the public API
- `CsgBranchOp::{Addition, Subtraction, Common}` matching the public demo
- `CsgTreeArena`, `CsgTree`, `CsgTreeBranch`, `CsgTreeBrush`, and `CsgNodeId`
- branch child replacement and operation mutation
- brush operation mutation
- compilation from tree intent into the current ordered assembler backend
This is still not the final classifier kernel. It is the API skeleton that lets
grammar and editor code speak in RealtimeCSG-shaped boolean trees while the
backend grows into the category router.
Algorithm migration steps:
1. Add polygon categories and visible/reversed flags to `vg_csg`.

View file

@ -12,6 +12,7 @@ mod convex;
mod dsl;
mod mesh;
mod primitives;
mod tree;
pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive};
@ -21,3 +22,7 @@ pub use mesh::TriangleMesh;
pub use primitives::{
DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm,
};
pub use tree::{
CsgBranchOp, CsgNode, CsgNodeId, CsgOperationType, CsgTree, CsgTreeArena, CsgTreeBranch,
CsgTreeBrush,
};

306
src/tree.rs Normal file
View file

@ -0,0 +1,306 @@
use crate::{Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgNodeId(pub u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgTreeBrush {
pub node: CsgNodeId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgTreeBranch {
pub node: CsgNodeId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgTree {
pub root: CsgNodeId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CsgOperationType {
Additive = 0,
Subtractive = 1,
Intersecting = 2,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CsgBranchOp {
Addition,
Subtraction,
Common,
}
#[derive(Clone, Debug)]
pub enum CsgNode {
Brush(Brush),
Branch {
name: String,
op: CsgBranchOp,
children: Vec<CsgNodeId>,
},
}
#[derive(Clone, Debug, Default)]
pub struct CsgTreeArena {
nodes: Vec<CsgNode>,
}
impl CsgTreeArena {
pub fn new() -> Self {
Self::default()
}
pub fn nodes(&self) -> &[CsgNode] {
&self.nodes
}
pub fn node(&self, id: CsgNodeId) -> Option<&CsgNode> {
self.nodes.get(id.0 as usize)
}
pub fn node_mut(&mut self, id: CsgNodeId) -> Option<&mut CsgNode> {
self.nodes.get_mut(id.0 as usize)
}
pub fn brush_count(&self) -> usize {
self.nodes
.iter()
.filter(|node| matches!(node, CsgNode::Brush(_)))
.count()
}
pub fn branch_count(&self) -> usize {
self.nodes
.iter()
.filter(|node| matches!(node, CsgNode::Branch { .. }))
.count()
}
pub fn generate_brush(
&mut self,
name: impl Into<String>,
operation: CsgOperationType,
primitive: Primitive,
material: MaterialId,
) -> CsgTreeBrush {
let id = CsgNodeId(self.nodes.len() as u32);
let brush = Brush::new(
BrushId(id.0),
name,
operation.into_brush_op(),
primitive,
material,
);
self.nodes.push(CsgNode::Brush(brush));
CsgTreeBrush { node: id }
}
pub fn generate_branch(
&mut self,
name: impl Into<String>,
op: CsgBranchOp,
children: impl IntoIterator<Item = CsgNodeId>,
) -> CsgTreeBranch {
let id = CsgNodeId(self.nodes.len() as u32);
self.nodes.push(CsgNode::Branch {
name: name.into(),
op,
children: children.into_iter().collect(),
});
CsgTreeBranch { node: id }
}
pub fn generate_tree(&self, root: CsgNodeId) -> CsgTree {
CsgTree { root }
}
pub fn set_brush_operation_type(
&mut self,
brush: CsgTreeBrush,
operation: CsgOperationType,
) -> bool {
let Some(CsgNode::Brush(node)) = self.node_mut(brush.node) else {
return false;
};
node.op = operation.into_brush_op();
node.set_dirty();
true
}
pub fn set_branch_operation_type(&mut self, branch: CsgTreeBranch, op: CsgBranchOp) -> bool {
let Some(CsgNode::Branch { op: current_op, .. }) = self.node_mut(branch.node) else {
return false;
};
*current_op = op;
true
}
pub fn set_child_nodes(
&mut self,
branch: CsgTreeBranch,
children: impl IntoIterator<Item = CsgNodeId>,
) -> bool {
let Some(CsgNode::Branch {
children: current_children,
..
}) = self.node_mut(branch.node)
else {
return false;
};
*current_children = children.into_iter().collect();
true
}
pub fn child_nodes(&self, branch: CsgTreeBranch) -> Option<&[CsgNodeId]> {
let Some(CsgNode::Branch { children, .. }) = self.node(branch.node) else {
return None;
};
Some(children)
}
pub fn compile_tree_to_assembler(&self, tree: CsgTree) -> Assembler {
let mut assembler = Assembler::new();
self.compile_node(tree.root, &mut assembler, None);
assembler
}
fn compile_node(
&self,
id: CsgNodeId,
assembler: &mut Assembler,
operation_override: Option<CsgOperationType>,
) {
let Some(node) = self.node(id) else {
return;
};
match node {
CsgNode::Brush(brush) => {
let op = operation_override
.map(CsgOperationType::into_brush_op)
.unwrap_or(brush.op);
assembler.add_brush(
brush.name.clone(),
op,
brush.primitive.clone(),
brush.material,
);
}
CsgNode::Branch { op, children, .. } => match op {
CsgBranchOp::Addition => {
for child in children {
self.compile_node(*child, assembler, operation_override);
}
}
CsgBranchOp::Subtraction => {
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::Subtractive),
);
}
}
}
CsgBranchOp::Common => {
for child in children {
self.compile_node(*child, assembler, Some(CsgOperationType::Intersecting));
}
}
},
}
}
}
impl CsgOperationType {
pub fn into_brush_op(self) -> BrushOp {
match self {
Self::Additive => BrushOp::Add,
Self::Subtractive => BrushOp::Subtract,
Self::Intersecting => BrushOp::Intersect,
}
}
}
impl From<BrushOp> for CsgOperationType {
fn from(value: BrushOp) -> Self {
match value {
BrushOp::Add => Self::Additive,
BrushOp::Subtract => Self::Subtractive,
BrushOp::Intersect => Self::Intersecting,
}
}
}
#[cfg(test)]
mod tests {
use bevy_math::Vec3;
use super::*;
use crate::{Aabb, Primitive};
#[test]
fn tree_subtraction_compiles_to_ordered_brush_stream() {
let mut arena = CsgTreeArena::new();
let solid = arena.generate_brush(
"solid",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
},
MaterialId(1),
);
let void = arena.generate_brush(
"void",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
},
MaterialId(0),
);
let branch = arena.generate_branch(
"solid minus void",
CsgBranchOp::Subtraction,
[solid.node, void.node],
);
let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node));
assert_eq!(assembler.brushes().len(), 2);
assert_eq!(assembler.brushes()[0].op, BrushOp::Add);
assert_eq!(assembler.brushes()[1].op, BrushOp::Subtract);
assert_eq!(assembler.build().report.emitted_convex_fragments, 6);
}
#[test]
fn tree_edit_api_replaces_children_and_operations() {
let mut arena = CsgTreeArena::new();
let a = arena.generate_brush(
"a",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
},
MaterialId(1),
);
let b = arena.generate_brush(
"b",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(1.0)),
},
MaterialId(1),
);
let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [a.node]);
assert!(arena.set_child_nodes(branch, [a.node, b.node]));
assert_eq!(arena.child_nodes(branch).expect("branch").len(), 2);
assert!(arena.set_branch_operation_type(branch, CsgBranchOp::Subtraction));
assert!(arena.set_brush_operation_type(b, CsgOperationType::Subtractive));
assert_eq!(arena.brush_count(), 2);
assert_eq!(arena.branch_count(), 1);
}
}

View file

@ -1,5 +1,8 @@
use bevy_math::{Quat, Vec3};
use vg_csg::{Aabb, Assembler, ConvexSolid, MaterialId, PolygonCategory};
use vg_csg::{
Aabb, Assembler, ConvexSolid, CsgBranchOp, CsgOperationType, CsgTreeArena, MaterialId,
PolygonCategory, Primitive,
};
#[test]
fn parity_polygon_category_vocabulary_matches_public_demo() {
@ -13,6 +16,44 @@ fn parity_polygon_category_vocabulary_matches_public_demo() {
assert_eq!(categories.len(), 4);
}
#[test]
fn parity_operation_type_ordinals_match_public_api() {
assert_eq!(CsgOperationType::Additive as u8, 0);
assert_eq!(CsgOperationType::Subtractive as u8, 1);
assert_eq!(CsgOperationType::Intersecting as u8, 2);
}
#[test]
fn parity_demo_branch_vocabulary_matches_public_demo() {
let branches = [
CsgBranchOp::Addition,
CsgBranchOp::Subtraction,
CsgBranchOp::Common,
];
assert_eq!(branches.len(), 3);
}
#[test]
fn parity_tree_api_exposes_brush_branch_tree_handles() {
let mut arena = CsgTreeArena::new();
let brush = arena.generate_brush(
"brush",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::ONE),
},
MaterialId(1),
);
let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [brush.node]);
let tree = arena.generate_tree(branch.node);
assert_eq!(arena.brush_count(), 1);
assert_eq!(arena.branch_count(), 1);
assert_eq!(arena.child_nodes(branch).expect("branch"), &[brush.node]);
assert_eq!(tree.root, branch.node);
}
#[test]
fn parity_box_brush_observable_counts_match_realtime_csg_demo() {
let solid = ConvexSolid::box_from_center_size(