Advance CSG category kernel groundwork
This commit is contained in:
parent
915e0b37ce
commit
f8591e0920
9 changed files with 543 additions and 52 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue