diff --git a/crates/vg_csg/src/assembler.rs b/crates/vg_csg/src/assembler.rs index aa7016b..952b3b4 100644 --- a/crates/vg_csg/src/assembler.rs +++ b/crates/vg_csg/src/assembler.rs @@ -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 { fn subtract_from_solids(solids: &[ConvexSolid], cutter: &ConvexSolid) -> Vec { 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 { + 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); + } } diff --git a/crates/vg_csg/src/brush.rs b/crates/vg_csg/src/brush.rs index 1004932..f331abb 100644 --- a/crates/vg_csg/src/brush.rs +++ b/crates/vg_csg/src/brush.rs @@ -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, + ]) } diff --git a/crates/vg_csg/src/convex.rs b/crates/vg_csg/src/convex.rs index f19235f..e892a3e 100644 --- a/crates/vg_csg/src/convex.rs +++ b/crates/vg_csg/src/convex.rs @@ -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, 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, 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::() / self.vertices.len() as f32 + } + + fn with_vertices(&self, vertices: Vec) -> 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, pub clip_planes: Vec, 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 { + 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) -> Vec { @@ -317,6 +379,55 @@ fn planes_from_polygons(polygons: &[ConvexPolygon]) -> Vec { .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 { + 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( diff --git a/crates/vg_csg/src/tree.rs b/crates/vg_csg/src/tree.rs index eb7780a..e32b581 100644 --- a/crates/vg_csg/src/tree.rs +++ b/crates/vg_csg/src/tree.rs @@ -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 { + 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 { + 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); + } } diff --git a/crates/vg_csg/tests/realtime_csg_parity.rs b/crates/vg_csg/tests/realtime_csg_parity.rs index 3517e48..7079802 100644 --- a/crates/vg_csg/tests/realtime_csg_parity.rs +++ b/crates/vg_csg/tests/realtime_csg_parity.rs @@ -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) + ); +} diff --git a/docs/research/realtime-csg-bevy-assembler.md b/docs/research/realtime-csg-bevy-assembler.md index 9bba816..809b8a9 100644 --- a/docs/research/realtime-csg-bevy-assembler.md +++ b/docs/research/realtime-csg-bevy-assembler.md @@ -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 diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c29b624..7f23abe 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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 diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 4ac3358..c12b7f4 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml index 1b0ce9f..daaf38c 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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