From e0cbecf5399701ada08d2f1507a2e4d4b58054d1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 01:32:51 +0100 Subject: [PATCH] Add CSG tree API surface --- docs/realtime-csg-doctrine.md | 14 ++ src/lib.rs | 5 + src/tree.rs | 306 ++++++++++++++++++++++++++++++++++ tests/realtime_csg_parity.rs | 43 ++++- 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 src/tree.rs diff --git a/docs/realtime-csg-doctrine.md b/docs/realtime-csg-doctrine.md index e6953d8..ee47b14 100644 --- a/docs/realtime-csg-doctrine.md +++ b/docs/realtime-csg-doctrine.md @@ -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`. diff --git a/src/lib.rs b/src/lib.rs index c47e809..f1c2807 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, +}; diff --git a/src/tree.rs b/src/tree.rs new file mode 100644 index 0000000..eb7780a --- /dev/null +++ b/src/tree.rs @@ -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, + }, +} + +#[derive(Clone, Debug, Default)] +pub struct CsgTreeArena { + nodes: Vec, +} + +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, + 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, + op: CsgBranchOp, + children: impl IntoIterator, + ) -> 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, + ) -> 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, + ) { + 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 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); + } +} diff --git a/tests/realtime_csg_parity.rs b/tests/realtime_csg_parity.rs index 9116e42..3517e48 100644 --- a/tests/realtime_csg_parity.rs +++ b/tests/realtime_csg_parity.rs @@ -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(