Add Bevy-compatible CSG assembler

This commit is contained in:
Meta Vens 2026-05-07 00:08:17 +01:00
commit eab8baaefb
15 changed files with 2251 additions and 38 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@ __pycache__/
*.pyc
.venv/
node_modules/
target/
external/
state/scratch-compaction-*.md
experiments/renders/

1081
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
members = ["crates/vg_csg"]
resolver = "3"

12
crates/vg_csg/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "vg_csg"
version = "0.1.0"
edition = "2024"
description = "Lean Bevy-math constructive geometry assembler for VibeGeometry blockouts"
license = "MIT"
[dependencies]
bevy_math = "0.17"
[dev-dependencies]
approx = "0.5"

View file

@ -0,0 +1,57 @@
use bevy_math::Vec3;
use vg_csg::{DomeCapZSpec, FloretArmSpec, LevelDsl, MaterialId};
fn main() {
let mut level = LevelDsl::new();
level.solid_box(
"floor",
Vec3::new(0.0, 0.0, -0.1),
Vec3::new(10.0, 10.0, 0.2),
MaterialId(1),
);
level.solid_box(
"wall with doorway",
Vec3::new(0.0, 5.0, 1.5),
Vec3::new(10.0, 0.3, 3.0),
MaterialId(2),
);
level.cut_box(
"doorway",
Vec3::new(0.0, 5.0, 1.0),
Vec3::new(1.4, 0.6, 2.0),
);
level.dome_cap_z(
"flat-base city dome",
DomeCapZSpec {
center: Vec3::new(0.0, 0.0, 0.0),
radius: 3.0,
height: 1.8,
rings: 8,
segments: 32,
material: MaterialId(3),
},
);
level.floret_arm(
"sunflower radiator arm",
FloretArmSpec {
anchor: Vec3::new(3.0, 0.0, 0.1),
direction: Vec3::X,
length: 16.0,
root_width: 1.5,
tip_width: 4.0,
thickness: 0.08,
tip_lift: 0.8,
material: MaterialId(4),
},
);
let output = level.assemble();
println!(
"brushes={} box_fragments={} vertices={} triangles={} warnings={}",
output.report.input_brushes,
output.report.emitted_box_fragments,
output.mesh.vertex_count(),
output.mesh.triangle_count(),
output.report.warnings.len()
);
}

View file

@ -0,0 +1,262 @@
use bevy_math::Vec3;
use crate::{
Aabb, Brush, BrushId, BrushOp, MaterialId, Primitive, TriangleMesh, append_cylinder_z,
append_dome_cap_z, append_floret_arm,
primitives::{DomeCapZSpec, FloretArmSpec},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BuildWarning {
SubtractIgnoredForNonBox { brush: String },
IntersectUnsupported { brush: String },
}
#[derive(Clone, Debug, Default)]
pub struct BuildReport {
pub input_brushes: usize,
pub emitted_box_fragments: usize,
pub warnings: Vec<BuildWarning>,
}
#[derive(Clone, Debug)]
pub struct BuildOutput {
pub mesh: TriangleMesh,
pub report: BuildReport,
pub generation: u64,
}
#[derive(Clone, Debug)]
struct BoxSolid {
bounds: Aabb,
material: MaterialId,
}
#[derive(Clone, Debug, Default)]
pub struct Assembler {
brushes: Vec<Brush>,
next_id: u32,
generation: u64,
}
impl Assembler {
pub fn new() -> Self {
Self::default()
}
pub fn brushes(&self) -> &[Brush] {
&self.brushes
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn add_brush(
&mut self,
name: impl Into<String>,
op: BrushOp,
primitive: Primitive,
material: MaterialId,
) -> BrushId {
let id = BrushId(self.next_id);
self.next_id += 1;
self.brushes
.push(Brush::new(id, name, op, primitive, material));
self.generation = self.generation.wrapping_add(1);
id
}
pub fn solid_box(
&mut self,
name: impl Into<String>,
bounds: Aabb,
material: MaterialId,
) -> BrushId {
self.add_brush(name, BrushOp::Add, Primitive::Box { bounds }, material)
}
pub fn cut_box(&mut self, name: impl Into<String>, bounds: Aabb) -> BrushId {
self.add_brush(
name,
BrushOp::Subtract,
Primitive::Box { bounds },
MaterialId::default(),
)
}
pub fn build(&self) -> BuildOutput {
let mut report = BuildReport {
input_brushes: self.brushes.len(),
..BuildReport::default()
};
let mut mesh = TriangleMesh::new();
let mut boxes: Vec<BoxSolid> = Vec::new();
for brush in &self.brushes {
match brush.op {
BrushOp::Add => match &brush.primitive {
Primitive::Box { bounds } => boxes.push(BoxSolid {
bounds: *bounds,
material: brush.material,
}),
Primitive::CylinderZ {
center,
radius,
depth,
segments,
} => append_cylinder_z(
&mut mesh,
*center,
*radius,
*depth,
*segments,
brush.material,
),
Primitive::DomeCapZ {
center,
radius,
height,
rings,
segments,
} => append_dome_cap_z(
&mut mesh,
DomeCapZSpec {
center: *center,
radius: *radius,
height: *height,
rings: *rings,
segments: *segments,
material: brush.material,
},
),
Primitive::FloretArm {
anchor,
direction,
length,
root_width,
tip_width,
thickness,
tip_lift,
} => append_floret_arm(
&mut mesh,
FloretArmSpec {
anchor: *anchor,
direction: *direction,
length: *length,
root_width: *root_width,
tip_width: *tip_width,
thickness: *thickness,
tip_lift: *tip_lift,
material: brush.material,
},
),
},
BrushOp::Subtract => {
if let Some(cutter) = brush.as_box() {
boxes = subtract_from_boxes(&boxes, cutter);
} else {
report
.warnings
.push(BuildWarning::SubtractIgnoredForNonBox {
brush: brush.name.clone(),
});
}
}
BrushOp::Intersect => {
report.warnings.push(BuildWarning::IntersectUnsupported {
brush: brush.name.clone(),
});
}
}
}
report.emitted_box_fragments = boxes.len();
for solid in boxes {
mesh.append_box(solid.bounds, solid.material);
}
BuildOutput {
mesh,
report,
generation: self.generation,
}
}
pub fn sample_room_with_door() -> Self {
let wall = MaterialId(1);
let floor = MaterialId(2);
let mut asm = Self::new();
asm.solid_box(
"floor",
Aabb::from_center_size(Vec3::new(0.0, 0.0, -0.1), Vec3::new(8.0, 8.0, 0.2)),
floor,
);
asm.solid_box(
"north wall",
Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.5), Vec3::new(8.0, 0.25, 3.0)),
wall,
);
asm.cut_box(
"door void",
Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)),
);
asm
}
}
fn subtract_from_boxes(boxes: &[BoxSolid], cutter: Aabb) -> Vec<BoxSolid> {
let mut out = Vec::with_capacity(boxes.len() + 4);
for solid in boxes {
for bounds in solid.bounds.subtract_box(cutter) {
out.push(BoxSolid {
bounds,
material: solid.material,
});
}
}
out
}
#[cfg(test)]
mod tests {
use bevy_math::Vec3;
use super::*;
#[test]
fn centered_subtract_splits_box_into_six_fragments() {
let source = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0));
let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0));
let pieces = source.subtract_box(cutter);
assert_eq!(pieces.len(), 6);
assert!(pieces.iter().all(|piece| piece.is_valid()));
assert!(pieces.iter().all(|piece| !piece.intersects(cutter)));
}
#[test]
fn door_cut_leaves_no_box_fragment_inside_void() {
let output = Assembler::sample_room_with_door().build();
let cutter = Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0));
assert!(output.report.emitted_box_fragments > 2);
for tri in output.mesh.indices.chunks_exact(3) {
let center = (output.mesh.positions[tri[0] as usize]
+ output.mesh.positions[tri[1] as usize]
+ output.mesh.positions[tri[2] as usize])
/ 3.0;
assert!(!cutter.contains_point_strict(center, 1.0e-4));
}
}
#[test]
fn mesh_box_emits_twelve_triangles() {
let mut mesh = TriangleMesh::new();
mesh.append_box(Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), MaterialId(7));
assert_eq!(mesh.triangle_count(), 12);
assert_eq!(mesh.triangle_materials.len(), 12);
assert_eq!(mesh.triangle_materials[0], MaterialId(7));
}
}

216
crates/vg_csg/src/brush.rs Normal file
View file

@ -0,0 +1,216 @@
use bevy_math::{Quat, Vec3};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct BrushId(pub u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct MaterialId(pub u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BrushOp {
Add,
Subtract,
Intersect,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Aabb {
pub min: Vec3,
pub max: Vec3,
}
impl Aabb {
pub fn from_center_size(center: Vec3, size: Vec3) -> Self {
let half = size * 0.5;
Self {
min: center - half,
max: center + half,
}
}
pub fn new(min: Vec3, max: Vec3) -> Self {
Self { min, max }
}
pub fn size(self) -> Vec3 {
self.max - self.min
}
pub fn center(self) -> Vec3 {
(self.min + self.max) * 0.5
}
pub fn is_valid(self) -> bool {
self.max.x > self.min.x && self.max.y > self.min.y && self.max.z > self.min.z
}
pub fn intersects(self, other: Self) -> bool {
self.min.x < other.max.x
&& self.max.x > other.min.x
&& self.min.y < other.max.y
&& self.max.y > other.min.y
&& self.min.z < other.max.z
&& self.max.z > other.min.z
}
pub fn intersection(self, other: Self) -> Option<Self> {
if !self.intersects(other) {
return None;
}
let min = self.min.max(other.min);
let max = self.max.min(other.max);
let hit = Self { min, max };
hit.is_valid().then_some(hit)
}
pub fn contains_point(self, point: Vec3) -> bool {
point.x >= self.min.x
&& point.x <= self.max.x
&& point.y >= self.min.y
&& point.y <= self.max.y
&& point.z >= self.min.z
&& point.z <= self.max.z
}
pub fn contains_point_strict(self, point: Vec3, epsilon: f32) -> bool {
point.x > self.min.x + epsilon
&& point.x < self.max.x - epsilon
&& point.y > self.min.y + epsilon
&& point.y < self.max.y - epsilon
&& point.z > self.min.z + epsilon
&& point.z < self.max.z - epsilon
}
pub fn subtract_box(self, cutter: Self) -> Vec<Self> {
let Some(hit) = self.intersection(cutter) else {
return vec![self];
};
let mut pieces = Vec::with_capacity(6);
push_valid(
&mut pieces,
Self::new(self.min, Vec3::new(hit.min.x, self.max.y, self.max.z)),
);
push_valid(
&mut pieces,
Self::new(Vec3::new(hit.max.x, self.min.y, self.min.z), self.max),
);
let x_min = self.min.x.max(hit.min.x);
let x_max = self.max.x.min(hit.max.x);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, self.min.y, self.min.z),
Vec3::new(x_max, hit.min.y, self.max.z),
),
);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, hit.max.y, self.min.z),
Vec3::new(x_max, self.max.y, self.max.z),
),
);
let y_min = self.min.y.max(hit.min.y);
let y_max = self.max.y.min(hit.max.y);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, y_min, self.min.z),
Vec3::new(x_max, y_max, hit.min.z),
),
);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, y_min, hit.max.z),
Vec3::new(x_max, y_max, self.max.z),
),
);
pieces
}
}
fn push_valid(pieces: &mut Vec<Aabb>, bounds: Aabb) {
if bounds.is_valid() {
pieces.push(bounds);
}
}
#[derive(Clone, Debug)]
pub enum Primitive {
Box {
bounds: Aabb,
},
CylinderZ {
center: Vec3,
radius: f32,
depth: f32,
segments: usize,
},
DomeCapZ {
center: Vec3,
radius: f32,
height: f32,
rings: usize,
segments: usize,
},
FloretArm {
anchor: Vec3,
direction: Vec3,
length: f32,
root_width: f32,
tip_width: f32,
thickness: f32,
tip_lift: f32,
},
}
#[derive(Clone, Debug)]
pub struct Brush {
pub id: BrushId,
pub name: String,
pub op: BrushOp,
pub primitive: Primitive,
pub material: MaterialId,
pub rotation: Quat,
pub generation: u64,
}
impl Brush {
pub fn new(
id: BrushId,
name: impl Into<String>,
op: BrushOp,
primitive: Primitive,
material: MaterialId,
) -> Self {
Self {
id,
name: name.into(),
op,
primitive,
material,
rotation: Quat::IDENTITY,
generation: 0,
}
}
pub fn set_dirty(&mut self) {
self.generation = self.generation.wrapping_add(1);
}
pub fn as_box(&self) -> Option<Aabb> {
match self.primitive {
Primitive::Box { bounds } => Some(bounds),
_ => None,
}
}
}

140
crates/vg_csg/src/dsl.rs Normal file
View file

@ -0,0 +1,140 @@
use bevy_math::Vec3;
use crate::{
Aabb, Assembler, BrushId, BrushOp, BuildOutput, DomeCapZSpec, FloretArmSpec, MaterialId,
Primitive,
};
#[derive(Clone, Debug, Default)]
pub struct LevelDsl {
assembler: Assembler,
}
impl LevelDsl {
pub fn new() -> Self {
Self::default()
}
pub fn solid_box(
&mut self,
name: impl Into<String>,
center: Vec3,
size: Vec3,
material: MaterialId,
) -> BrushId {
self.assembler
.solid_box(name, Aabb::from_center_size(center, size), material)
}
pub fn cut_box(&mut self, name: impl Into<String>, center: Vec3, size: Vec3) -> BrushId {
self.assembler
.cut_box(name, Aabb::from_center_size(center, size))
}
pub fn cylinder_z(
&mut self,
name: impl Into<String>,
center: Vec3,
radius: f32,
depth: f32,
segments: usize,
material: MaterialId,
) -> BrushId {
self.assembler.add_brush(
name,
BrushOp::Add,
Primitive::CylinderZ {
center,
radius,
depth,
segments,
},
material,
)
}
pub fn dome_cap_z(&mut self, name: impl Into<String>, spec: DomeCapZSpec) -> BrushId {
self.assembler.add_brush(
name,
BrushOp::Add,
Primitive::DomeCapZ {
center: spec.center,
radius: spec.radius,
height: spec.height,
rings: spec.rings,
segments: spec.segments,
},
spec.material,
)
}
pub fn floret_arm(&mut self, name: impl Into<String>, spec: FloretArmSpec) -> BrushId {
self.assembler.add_brush(
name,
BrushOp::Add,
Primitive::FloretArm {
anchor: spec.anchor,
direction: spec.direction,
length: spec.length,
root_width: spec.root_width,
tip_width: spec.tip_width,
thickness: spec.thickness,
tip_lift: spec.tip_lift,
},
spec.material,
)
}
pub fn assemble(&self) -> BuildOutput {
self.assembler.build()
}
pub fn into_assembler(self) -> Assembler {
self.assembler
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dsl_builds_room_and_habitat_primitives() {
let mut dsl = LevelDsl::new();
dsl.solid_box(
"wall",
Vec3::new(0.0, 0.0, 1.5),
Vec3::new(5.0, 0.25, 3.0),
MaterialId(1),
);
dsl.cut_box("door", Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.5, 2.0));
dsl.dome_cap_z(
"city dome",
DomeCapZSpec {
center: Vec3::new(0.0, 0.0, 0.0),
radius: 3.0,
height: 1.5,
rings: 4,
segments: 16,
material: MaterialId(2),
},
);
dsl.floret_arm(
"solar petal",
FloretArmSpec {
anchor: Vec3::new(3.0, 0.0, 0.0),
direction: Vec3::X,
length: 12.0,
root_width: 1.2,
tip_width: 3.0,
thickness: 0.08,
tip_lift: 0.6,
material: MaterialId(3),
},
);
let output = dsl.assemble();
assert!(output.mesh.triangle_count() > 12);
assert!(output.report.warnings.is_empty());
}
}

21
crates/vg_csg/src/lib.rs Normal file
View file

@ -0,0 +1,21 @@
//! Lean constructive geometry tools for VibeGeometry.
//!
//! This crate borrows RealtimeCSG's useful public shape: ordered brushes,
//! operation types, dirtied generations, and a rebuildable output mesh. The
//! hidden native RealtimeCSG kernel is not public, so this starts with the
//! smallest honest CSG organ: exact box subtraction against additive boxes,
//! plus additive procedural primitives for the habitat forms we keep needing.
mod assembler;
mod brush;
mod dsl;
mod mesh;
mod primitives;
pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, Primitive};
pub use dsl::LevelDsl;
pub use mesh::TriangleMesh;
pub use primitives::{
DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm,
};

102
crates/vg_csg/src/mesh.rs Normal file
View file

@ -0,0 +1,102 @@
use bevy_math::{Vec2, Vec3};
use crate::{Aabb, MaterialId};
#[derive(Clone, Debug, Default)]
pub struct TriangleMesh {
pub positions: Vec<Vec3>,
pub normals: Vec<Vec3>,
pub uvs: Vec<Vec2>,
pub indices: Vec<u32>,
pub triangle_materials: Vec<MaterialId>,
}
impl TriangleMesh {
pub fn new() -> Self {
Self::default()
}
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
pub fn vertex_count(&self) -> usize {
self.positions.len()
}
pub fn append_box(&mut self, bounds: Aabb, material: MaterialId) {
let min = bounds.min;
let max = bounds.max;
let corners = [
Vec3::new(min.x, min.y, min.z),
Vec3::new(max.x, min.y, min.z),
Vec3::new(max.x, max.y, min.z),
Vec3::new(min.x, max.y, min.z),
Vec3::new(min.x, min.y, max.z),
Vec3::new(max.x, min.y, max.z),
Vec3::new(max.x, max.y, max.z),
Vec3::new(min.x, max.y, max.z),
];
self.append_quad(
[corners[0], corners[3], corners[2], corners[1]],
Vec3::NEG_Z,
material,
);
self.append_quad(
[corners[4], corners[5], corners[6], corners[7]],
Vec3::Z,
material,
);
self.append_quad(
[corners[0], corners[1], corners[5], corners[4]],
Vec3::NEG_Y,
material,
);
self.append_quad(
[corners[3], corners[7], corners[6], corners[2]],
Vec3::Y,
material,
);
self.append_quad(
[corners[0], corners[4], corners[7], corners[3]],
Vec3::NEG_X,
material,
);
self.append_quad(
[corners[1], corners[2], corners[6], corners[5]],
Vec3::X,
material,
);
}
pub fn append_quad(&mut self, points: [Vec3; 4], normal: Vec3, material: MaterialId) {
let base = self.positions.len() as u32;
self.positions.extend(points);
self.normals.extend([normal; 4]);
self.uvs.extend([
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(0.0, 1.0),
]);
self.indices
.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
self.triangle_materials.extend([material; 2]);
}
pub fn append_triangle(
&mut self,
points: [Vec3; 3],
normal: Vec3,
uvs: [Vec2; 3],
material: MaterialId,
) {
let base = self.positions.len() as u32;
self.positions.extend(points);
self.normals.extend([normal; 3]);
self.uvs.extend(uvs);
self.indices.extend([base, base + 1, base + 2]);
self.triangle_materials.push(material);
}
}

View file

@ -0,0 +1,172 @@
use bevy_math::{Vec2, Vec3};
use crate::{MaterialId, TriangleMesh};
#[derive(Clone, Copy, Debug)]
pub struct DomeCapZSpec {
pub center: Vec3,
pub radius: f32,
pub height: f32,
pub rings: usize,
pub segments: usize,
pub material: MaterialId,
}
#[derive(Clone, Copy, Debug)]
pub struct FloretArmSpec {
pub anchor: Vec3,
pub direction: Vec3,
pub length: f32,
pub root_width: f32,
pub tip_width: f32,
pub thickness: f32,
pub tip_lift: f32,
pub material: MaterialId,
}
pub fn append_cylinder_z(
mesh: &mut TriangleMesh,
center: Vec3,
radius: f32,
depth: f32,
segments: usize,
material: MaterialId,
) {
let segments = segments.max(3);
let half = depth * 0.5;
let bottom_center = center - Vec3::Z * half;
let top_center = center + Vec3::Z * half;
for i in 0..segments {
let a0 = ring_angle(i, segments);
let a1 = ring_angle(i + 1, segments);
let p0 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, -half);
let p1 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, -half);
let p2 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, half);
let p3 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, half);
let normal = Vec3::new(((a0 + a1) * 0.5).cos(), ((a0 + a1) * 0.5).sin(), 0.0);
mesh.append_quad([p0, p1, p2, p3], normal, material);
mesh.append_triangle(
[bottom_center, p1, p0],
Vec3::NEG_Z,
[Vec2::ZERO, Vec2::X, Vec2::Y],
material,
);
mesh.append_triangle(
[top_center, p3, p2],
Vec3::Z,
[Vec2::ZERO, Vec2::X, Vec2::Y],
material,
);
}
}
pub fn append_dome_cap_z(mesh: &mut TriangleMesh, spec: DomeCapZSpec) {
let rings = spec.rings.max(2);
let segments = spec.segments.max(6);
let top = spec.center + Vec3::Z * spec.height;
for ring in 0..rings {
let t0 = ring as f32 / rings as f32;
let t1 = (ring + 1) as f32 / rings as f32;
let r0 = spec.radius * (1.0 - t0 * t0).sqrt();
let r1 = spec.radius * (1.0 - t1 * t1).sqrt();
let z0 = spec.center.z + spec.height * t0;
let z1 = spec.center.z + spec.height * t1;
for seg in 0..segments {
let a0 = ring_angle(seg, segments);
let a1 = ring_angle(seg + 1, segments);
let p0 = Vec3::new(
spec.center.x + a0.cos() * r0,
spec.center.y + a0.sin() * r0,
z0,
);
let p1 = Vec3::new(
spec.center.x + a1.cos() * r0,
spec.center.y + a1.sin() * r0,
z0,
);
if ring + 1 == rings {
let normal = (p0 - spec.center)
.cross(top - spec.center)
.normalize_or_zero();
mesh.append_triangle(
[p0, p1, top],
normal,
[Vec2::ZERO, Vec2::X, Vec2::Y],
spec.material,
);
} else {
let p2 = Vec3::new(
spec.center.x + a1.cos() * r1,
spec.center.y + a1.sin() * r1,
z1,
);
let p3 = Vec3::new(
spec.center.x + a0.cos() * r1,
spec.center.y + a0.sin() * r1,
z1,
);
let normal = ((p0 + p1 + p2 + p3) * 0.25 - spec.center).normalize_or_zero();
mesh.append_quad([p0, p1, p2, p3], normal, spec.material);
}
}
}
}
pub fn append_floret_arm(mesh: &mut TriangleMesh, spec: FloretArmSpec) {
let forward = spec.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 = spec.anchor;
let tip_center = spec.anchor + forward * spec.length + Vec3::Z * spec.tip_lift;
let z = Vec3::Z * (spec.thickness * 0.5);
let r = side * (spec.root_width * 0.5);
let t = side * (spec.tip_width * 0.5);
let bottom = [
root_center - r - z,
root_center + r - z,
tip_center + t - z,
tip_center - t - z,
];
let top = [
root_center - r + z,
root_center + r + z,
tip_center + t + z,
tip_center - t + z,
];
mesh.append_quad(
[bottom[0], bottom[3], bottom[2], bottom[1]],
Vec3::NEG_Z,
spec.material,
);
mesh.append_quad([top[0], top[1], top[2], top[3]], Vec3::Z, spec.material);
mesh.append_quad(
[bottom[0], bottom[1], top[1], top[0]],
-forward,
spec.material,
);
mesh.append_quad(
[bottom[3], top[3], top[2], bottom[2]],
forward,
spec.material,
);
mesh.append_quad([bottom[0], top[0], top[3], bottom[3]], -side, spec.material);
mesh.append_quad([bottom[1], bottom[2], top[2], top[1]], side, spec.material);
}
fn ring_angle(index: usize, segments: usize) -> f32 {
index as f32 * std::f32::consts::TAU / segments as f32
}

View file

@ -0,0 +1,136 @@
# Realtime CSG Bevy Assembler
## Source Read
LogicalError's RealtimeCSG is useful as an architectural reference, not as a
kernel we can directly port. The public repository describes a Unity editor
plugin for CSG level editing and states that runtime editing is not supported
through a maintained API. It also states that the native C++ source is not
public.
The public C# layer still exposes the parts worth stealing cleanly:
- control meshes made from vertices, half edges, polygons, and texgen/surface
metadata
- tree, branch, and brush handles
- explicit brush operation types
- dirty generation counters
- a rebuild step that turns authored brushes into renderable meshes
That shape maps well to Bevy: keep authoring data as lightweight brush records,
rebuild only dirty worlds, and emit plain mesh buffers that can later be handed
to Bevy render assets.
## Current Port Shape
`crates/vg_csg` is the first lean Rust organ.
It uses `bevy_math` for `Vec3`, `Vec2`, and Bevy-compatible spatial types while
keeping output as simple arrays:
```rust
use bevy_math::Vec3;
use vg_csg::{LevelDsl, MaterialId};
let mut level = LevelDsl::new();
level.solid_box(
"wall",
Vec3::new(0.0, 0.0, 1.5),
Vec3::new(6.0, 0.3, 3.0),
MaterialId(1),
);
level.cut_box(
"door",
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(1.2, 0.6, 2.0),
);
let output = level.assemble();
assert!(output.mesh.triangle_count() > 12);
```
The first actual CSG operator is exact AABB subtraction against additive AABB
solids. A cutter splits a source box into up to six surviving slabs. This is
enough for doors, windows, vents, corridor punches, terraces, anchor trenches,
and level blockout loops.
Procedural non-CSG primitives are additive:
- `CylinderZ` for tethers, shafts, columns, conduits, and ring anchors
- `DomeCapZ` for flat-base pressure bubbles and caps
- `FloretArm` for sunflower/Citadel-scale solar and radiator petals
Those primitives are intentionally in the same brush stream as CSG boxes, so a
level script can mix hard architectural cuts with habitat-specific forms:
```rust
level.dome_cap_z(
"city dome",
vg_csg::DomeCapZSpec {
center: Vec3::ZERO,
radius: 20.0,
height: 8.0,
rings: 12,
segments: 64,
material: MaterialId(20),
},
);
level.floret_arm(
"radiator petal",
vg_csg::FloretArmSpec {
anchor: Vec3::new(20.0, 0.0, 0.2),
direction: Vec3::X,
length: 120.0,
root_width: 6.0,
tip_width: 18.0,
thickness: 0.12,
tip_lift: 3.0,
material: MaterialId(21),
},
);
```
## Doctrine
Think of brushes as legal claims on volume.
An additive brush says: "this matter exists." A subtractive brush says: "this
space must remain empty." The assembler is the clerk that resolves claims in
order and emits triangles only after the claims stop arguing.
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:
- 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
Use additive procedural primitives where a form is better described by a field
or sweep than by carving:
- domes, rings, tethers, panels, radiators
- 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, fragment merge policy,
and a BVH or spatial hash so cutter cost does not scale like a punishment.
## Limits
`vg_csg` is not yet a full RealtimeCSG replacement.
- only box subtractors affect box solids
- non-box subtractors are reported and ignored
- intersect brushes are reported and ignored
- 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
Those limits are explicit because invisible ambition is how you get haunted by
your own abstractions. The useful loop exists now: script brushes, assemble,
inspect mesh stats, and replace the kernel under the same DSL when the target
demands it.

View file

@ -22,37 +22,33 @@ git log --oneline -5
## Current State
VibeGeometry is still an incubation scaffold: persistence, graph-translation
corpus, repo-local Geometry Script tooling, verifiers, and doctrine exist; the
builder library and artifact schemas do not.
VibeGeometry is an incubation scaffold with persistence, graph-translation
corpus notes, repo-local Geometry Script tooling, verifiers, doctrine, a Python
helper library, and now a first Rust CSG assembler. Artifact schemas are still
pending.
Corpus inventory, graph maps, verification notes, and doctrine live in
`docs/research/`. This handoff is not the ledger.
`docs/research/`; this handoff is not the ledger. Accepted corpus families
include local Shriinivas samples, Quellenform `CurveToMeshUV`, official Blender
demos, and IRCSS French Houses footholds. IRCSS blockers remain repeat-zone
feedback, repeat `Top` outputs, foreach zones, gizmo nodes, and the rejected
empty-output `WindowBeams` harness.
Accepted corpus families include local Shriinivas samples, Quellenform
`CurveToMeshUV`, official Blender demos, and IRCSS French Houses footholds.
IRCSS blockers remain repeat-zone feedback, repeat `Top` outputs, foreach
zones, gizmo nodes, and the rejected empty-output `WindowBeams` harness. Maps
live in `docs/research/translations/ircss-french-houses-*.md`.
From-scratch story-bound builds live in `examples/geometry_script/`:
From-scratch story-bound builds:
- `aetheria_bloom_habitat.py`: whole Bloom study with explicit lore coordinate
frames, cylindrical/endcap attachment, golden-angle transfer curves,
deterministic settlement noise, batched dense detail, and inspection cameras.
- `lucent_tether_habitat.py`: Ghostlight/Lucent tether habitat with orthogonal
spin-gravity city plane, flat-cut dome base, street-field city, shader work,
tether attachment, and exterior Citadel-scale solar/radiator florets.
- `examples/geometry_script/aetheria_bloom_habitat.py` generates the Aetheria
Bloom whole-habitat study. It carries the key spatial lessons: explicit lore
coordinate frames, cylindrical/endcap attachment, golden-angle transfer
curves, deterministic settlement noise, batched dense detail, and inspection
cameras. Build notes: `docs/research/builds/aetheria-bloom-habitat.md`;
verifier: `tools/verify_aetheria_bloom_habitat.py`.
- `examples/geometry_script/lucent_tether_habitat.py` generates the Lucent
tether habitat from Ghostlight `Lucent Hostage Feed`: tether axis, city bubble
below, media-eye lounge, crisis threshold, overlays, control/side panels, and
security doors. Accepted revision uses Cycles, shader nodes, explicit bubble
attachment, an orthogonal spin-gravity city plane, a flat-cut dome base,
radial/tangential streets, irregular parcels, prestige anchor towers, parks,
faux-rural edge falloff, and an exterior sunflower-like crest of alternating
Citadel-scale solar panels/radiators.
Build notes: `docs/research/builds/lucent-tether-habitat.md`; verifier:
`tools/verify_lucent_tether_habitat.py`.
Build notes and verifiers:
- `docs/research/builds/aetheria-bloom-habitat.md`
- `docs/research/builds/lucent-tether-habitat.md`
- `tools/verify_aetheria_bloom_habitat.py`
- `tools/verify_lucent_tether_habitat.py`
Current authoring prior: use the full Python stack. Python structures intent,
tables, loops, variants, cleanup, validation, docs, and scene/render
@ -64,6 +60,15 @@ coordinate-frame helpers, deterministic field/noise helpers, mesh batching,
thin Blender object/material/camera helpers, and verifier helpers. The Bloom
script imports these primitives directly, so the library is not just a shrine.
`crates/vg_csg` is the first Rust/Bevy-side interactive geometry organ.
RealtimeCSG's public Unity repo exposes a useful brush/tree/operation/generation
model, but not the optimized native C++ kernel or a supported runtime editing
API. `vg_csg` therefore ports the architecture shape: ordered brushes, exact
AABB subtraction for box solids, additive `CylinderZ`, `DomeCapZ`, and
`FloretArm` primitives, Bevy-math mesh buffers, `LevelDsl`, tests, clippy, and
an example room. Research note:
`docs/research/realtime-csg-bevy-assembler.md`.
The repo-local Geometry Script clone has Blender 5.1 work for nested `@tree`
group reuse, a ported `nodes_to_script` converter, and menu-socket default
tolerance. See `docs/research/geometry-script-fork-notes.md`. Run
@ -78,11 +83,11 @@ The GameCult fork is intentionally pruned to `main` plus VibeGeometry-owned
tooling branches. Do not copy upstream branch soup forward unless a future patch
actually needs it.
The current next action is to extract the first higher-level reusable builder
seam from the completed story-bound builds: either threshold/control-rail
interface kits from the Lucent habitat or endcap terrace/spiral artery kits
from the Bloom, then bind that seam to scene-brief, manifest,
inspection-render, and verifier contract drafts.
The current next action is to extend `vg_csg` from a tested brush assembler into
an interactive Bevy loop: add Bevy `Mesh` conversion, an ECS plugin/resource
boundary, visual debug rendering for brush claims versus emitted mesh, and then
upgrade the CSG kernel from AABB slabs toward convex half-space clipping and
spatial indexing.
## Important Invariants

View file

@ -48,3 +48,4 @@
{"ts": "2026-05-05T03:01:59+00:00", "type": "implementation", "status": "accepted", "note": "Corrected Lucent dome city coordinate frame: ground is now a YZ disk orthogonal to the tether/spin axis, buildings extrude along +X, dome is boolean-cut flat at the base, city fills to the rim, and a Perlin-edged sunflower ray-floret crest rises from the base partway up the dome."}
{"ts": "2026-05-05T03:16:04+00:00", "type": "implementation", "status": "accepted", "note": "Moved Lucent sunflower ray florets outside the dome: alternating solar panel and radiator meshes now radiate sunward from the flat base rim, with verifier checks that their geometry remains outside the dome cut."}
{"ts": "2026-05-05T03:31:22+00:00", "type": "implementation", "status": "accepted", "note": "Scaled Lucent external ray florets to Citadel-like utility arms: alternating solar/radiator panels now dwarf the dome and span far along the tether, so a folded defensive posture can plausibly reach toward the media-eye end."}
{"ts": "2026-05-06T23:06:51+00:00", "type": "implementation", "status": "accepted", "note": "Added vg_csg, a Rust workspace crate for the new interactive Bevy-compatible CSG path: RealtimeCSG-inspired brush ordering and generations, exact AABB box subtraction, additive cylinder/dome/floret primitives, mesh-array output, DSL, tests, clippy, example, and research doctrine. Public RealtimeCSG source informed the shape but not the hidden native kernel."}

View file

@ -13,8 +13,9 @@ mission:
geometry-node graphs that produce specific visual results from reference
images, text instructions, and iterative render review.
near_term: >
Define the scene brief, node graph manifest, Python builder, render runner,
and review loop before building a large library of graph idioms.
Define the scene brief, node graph manifest, Python/Geometry Script builder,
Rust/Bevy CSG assembler, render runner, and review loop before building a
large library of graph idioms.
hard_boundaries:
- Blender 5.0+ is the target runtime unless a task explicitly chooses another version.
@ -44,6 +45,7 @@ live_architecture:
- `vibegeometry/` provides reusable Python helpers for coordinate contracts, deterministic fields, batched geometry, Blender object/material/camera setup, and verifier checks.
- Geometry Script emits inspectable reusable Geometry Nodes groups.
- bpy handles Blender objects, modifiers, materials, cameras, lights, files, renders, and evaluated-geometry checks.
- Rust/Bevy-side CSG tooling provides an interactive brush-and-boolean assembler path for fast level geometry, starting with box CSG and habitat-specific additive primitives.
- External Python tools inspect source blends, generate converter drafts, compare outputs, and maintain corpus evidence.
persistence:
- `state/map.yaml` stores canonical mission, boundaries, architecture, artifacts, and next action.
@ -75,6 +77,8 @@ current_artifacts:
- docs/research/translations/ircss-french-houses-node-mechanisms.md
- docs/research/builds/aetheria-bloom-habitat.md
- docs/research/builds/lucent-tether-habitat.md
- docs/research/realtime-csg-bevy-assembler.md
- crates/vg_csg/
- vibegeometry/
tools:
- tools/vibegeometry_state.py
@ -101,8 +105,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. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Extract the first higher-level reusable builder seam from the completed story-bound builds: either threshold/control-rail interface kits from the Lucent habitat or endcap terrace/spiral artery kits from the Bloom, then bind that seam to scene-brief, manifest, inspection-render, and verifier contract drafts.
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. A new Rust workspace crate, `vg_csg`, starts the interactive Bevy-compatible geometry path: RealtimeCSG-inspired ordered brushes, operation types, generation tracking, exact AABB subtraction for box solids, additive cylinder/dome/floret primitives, mesh-array output, tests, clippy coverage, and a sample room. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Extend `vg_csg` from a tested brush assembler into an interactive Bevy loop: add Bevy `Mesh` conversion, an ECS plugin/resource boundary, visual debug rendering for brush claims versus emitted mesh, and then upgrade the CSG kernel from AABB slabs toward convex half-space clipping and spatial indexing.
remotes:
vibegeometry: https://github.com/GameCult/VibeGeometry
geometry_script_fork: https://github.com/GameCult/geometry-script