Add box CSG incremental dirty rebuild

This commit is contained in:
Meta Vens 2026-05-07 15:05:55 +01:00
commit 5733e823a5
7 changed files with 391 additions and 80 deletions

View file

@ -41,6 +41,7 @@ fn main() {
for case in cases {
run_case(case, PerfMode::Stable);
run_case(case, PerfMode::Dirty);
run_case(case, PerfMode::IncrementalDirty);
run_case(case, PerfMode::Routed);
}
}
@ -56,6 +57,7 @@ struct PerfCase {
enum PerfMode {
Stable,
Dirty,
IncrementalDirty,
Routed,
}
@ -64,29 +66,37 @@ impl PerfMode {
match self {
Self::Stable => "stable",
Self::Dirty => "dirty",
Self::IncrementalDirty => "incremental_dirty",
Self::Routed => "routed",
}
}
fn build(self, assembler: &Assembler) -> vg_csg::BuildOutput {
fn build(self, assembler: &mut Assembler, iteration: usize) -> vg_csg::BuildOutput {
match self {
Self::Stable => assembler.build(),
Self::Dirty => assembler.rebuild(),
Self::Dirty => {
nudge_tail_brush(assembler, iteration);
assembler.rebuild()
}
Self::IncrementalDirty => {
nudge_tail_brush(assembler, iteration);
assembler.build_incremental()
}
Self::Routed => assembler.rebuild_routed_surfaces(),
}
}
}
fn run_case(case: PerfCase, mode: PerfMode) {
let assembler = (case.build)();
let mut assembler = (case.build)();
if matches!(mode, PerfMode::Routed) && !assembler.supports_routed_surfaces() {
return;
}
let warmup = mode.build(&assembler);
let warmup = assembler.build();
black_box(warmup.mesh.triangle_count());
for _ in 0..WARMUP_ITERS {
let output = mode.build(&assembler);
let output = mode.build(&mut assembler, 0);
black_box(output.mesh.triangle_count());
}
@ -97,9 +107,9 @@ fn run_case(case: PerfCase, mode: PerfMode) {
let mut candidate_pairs = 0;
let mut rejected_pairs = 0;
for _ in 0..MEASURE_ITERS {
for iteration in 0..MEASURE_ITERS {
let start = Instant::now();
let output = mode.build(&assembler);
let output = mode.build(&mut assembler, iteration);
let elapsed = start.elapsed();
triangles = output.mesh.triangle_count();
fragments = output.report.emitted_convex_fragments;
@ -146,6 +156,44 @@ fn run_case(case: PerfCase, mode: PerfMode) {
);
}
fn nudge_tail_brush(assembler: &mut Assembler, iteration: usize) {
let Some(brush) = assembler.brushes().last().cloned() else {
return;
};
let phase = if iteration.is_multiple_of(2) {
0.0
} else {
0.01
};
match brush.primitive {
Primitive::Box { bounds } => {
let center = bounds.center();
let size = bounds.size() + Vec3::splat(phase);
assembler.set_brush_primitive(
brush.id,
Primitive::Box {
bounds: Aabb::from_center_size(center, size),
},
);
}
Primitive::OrientedBox {
center,
size,
rotation,
} => {
assembler.set_brush_primitive(
brush.id,
Primitive::OrientedBox {
center,
size: size + Vec3::splat(phase),
rotation,
},
);
}
_ => {}
}
}
fn percentile_ns(values: &[Duration], percentile: usize) -> u128 {
if values.is_empty() {
return 0;

View file

@ -37,6 +37,8 @@ pub struct Assembler {
brushes: Vec<Brush>,
compiled: Vec<CompiledBrush>,
cache: RefCell<Option<BuildOutput>>,
box_cache: RefCell<Option<BoxBuildCache>>,
first_dirty_index: RefCell<Option<usize>>,
next_id: u32,
generation: u64,
}
@ -78,6 +80,18 @@ enum CompiledGeometry {
},
}
#[derive(Clone, Debug, Default)]
struct BoxBuildCache {
generation: u64,
checkpoints: Vec<BoxBuildState>,
}
#[derive(Clone, Debug, Default)]
struct BoxBuildState {
boxes: Vec<(Aabb, MaterialId)>,
report: BuildReport,
}
impl Assembler {
pub fn new() -> Self {
Self::default()
@ -122,11 +136,13 @@ impl Assembler {
) -> BrushId {
let id = BrushId(self.next_id);
self.next_id += 1;
let index = self.brushes.len();
let brush = Brush::new(id, name, op, primitive, material);
self.compiled.push(CompiledBrush::from_brush(&brush));
self.brushes.push(brush);
self.generation = self.generation.wrapping_add(1);
self.cache.borrow_mut().take();
self.mark_dirty_index(index);
id
}
@ -139,6 +155,7 @@ impl Assembler {
self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]);
self.generation = self.generation.wrapping_add(1);
self.cache.borrow_mut().take();
self.mark_dirty_index(index);
true
}
@ -151,6 +168,7 @@ impl Assembler {
self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]);
self.generation = self.generation.wrapping_add(1);
self.cache.borrow_mut().take();
self.mark_dirty_index(index);
true
}
@ -223,9 +241,51 @@ impl Assembler {
let output = self.build_uncached();
*self.cache.borrow_mut() = Some(output.clone());
*self.first_dirty_index.borrow_mut() = None;
output
}
pub fn build_incremental(&self) -> BuildOutput {
if let Some(output) = self
.cache
.borrow()
.as_ref()
.filter(|output| output.generation == self.generation)
{
return output.clone();
}
let first_dirty_index = *self.first_dirty_index.borrow();
let output = self
.try_build_axis_aligned_boxes_from_cache(first_dirty_index)
.unwrap_or_else(|| self.build_uncached());
*self.cache.borrow_mut() = Some(output.clone());
*self.first_dirty_index.borrow_mut() = None;
output
}
pub fn rebuild_incremental_for_indices(
&self,
dirty_indices: impl IntoIterator<Item = usize>,
) -> BuildOutput {
let first_dirty_index = self
.dirty_frontier_for_indices(dirty_indices)
.first_dirty_index;
self.try_build_axis_aligned_boxes_from_cache(first_dirty_index)
.unwrap_or_else(|| self.build_uncached())
}
pub fn rebuild_incremental_for_brushes(
&self,
dirty_brushes: impl IntoIterator<Item = BrushId>,
) -> BuildOutput {
let indices = dirty_brushes
.into_iter()
.filter_map(|id| self.brush_index(id))
.collect::<Vec<_>>();
self.rebuild_incremental_for_indices(indices)
}
pub fn rebuild(&self) -> BuildOutput {
self.build_uncached()
}
@ -239,6 +299,11 @@ impl Assembler {
self.supports_routed_surface_subset()
}
fn mark_dirty_index(&self, index: usize) {
let mut first_dirty_index = self.first_dirty_index.borrow_mut();
*first_dirty_index = Some(first_dirty_index.map_or(index, |dirty| dirty.min(index)));
}
fn try_build_routed_surfaces(&self) -> Option<BuildOutput> {
if !self.supports_routed_surface_subset() {
return None;
@ -470,75 +535,72 @@ impl Assembler {
}
fn build_axis_aligned_boxes(&self) -> BuildOutput {
let mut report = BuildReport {
input_brushes: self.brushes.len(),
..BuildReport::default()
self.try_build_axis_aligned_boxes_from_cache(Some(0))
.expect("box-only build path received a non-box brush")
}
fn try_build_axis_aligned_boxes_from_cache(
&self,
first_dirty_index: Option<usize>,
) -> Option<BuildOutput> {
if !self
.compiled
.iter()
.all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_)))
{
return None;
}
let brush_count = self.compiled.len();
let rebuild_from = match first_dirty_index {
Some(index) => index.min(brush_count),
None => self
.box_cache
.borrow()
.as_ref()
.filter(|cache| {
cache.generation == self.generation
&& cache.checkpoints.len() == brush_count + 1
})
.map_or(0, |_| brush_count),
};
let cached_state = self
.box_cache
.borrow()
.as_ref()
.and_then(|cache| cache.checkpoints.get(rebuild_from).cloned());
let (mut state, start_index) = if let Some(state) = cached_state {
(state, rebuild_from)
} else {
(BoxBuildState::default(), 0)
};
let mut boxes: Vec<(Aabb, MaterialId)> = Vec::new();
for brush in &self.compiled {
let CompiledGeometry::Box(bounds) = brush.geometry else {
unreachable!("box-only build path received a non-box brush");
};
match brush.op {
BrushOp::Add => boxes.push((bounds, brush.material)),
BrushOp::Subtract => {
report.operator_brushes += 1;
if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) {
report.rejected_pairs += boxes.len();
continue;
}
let mut out = Vec::with_capacity(boxes.len() + 4);
for (solid, material) in boxes {
if solid.intersects(bounds) {
report.candidate_pairs += 1;
out.extend(
solid
.subtract_box(bounds)
.into_iter()
.map(|piece| (piece, material)),
);
} else {
report.rejected_pairs += 1;
out.push((solid, material));
}
}
boxes = out;
}
BrushOp::Intersect => {
report.operator_brushes += 1;
if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) {
report.rejected_pairs += boxes.len();
boxes.clear();
continue;
}
boxes = boxes
.into_iter()
.filter_map(|(solid, material)| {
if let Some(hit) = solid.intersection(bounds) {
report.candidate_pairs += 1;
Some((hit, material))
} else {
report.rejected_pairs += 1;
None
}
})
.collect();
}
}
let mut checkpoints = self
.box_cache
.borrow()
.as_ref()
.map(|cache| {
cache
.checkpoints
.iter()
.take(start_index + 1)
.cloned()
.collect::<Vec<_>>()
})
.filter(|checkpoints| checkpoints.len() == start_index + 1)
.unwrap_or_else(|| vec![BoxBuildState::default()]);
for brush in &self.compiled[start_index..] {
state.apply_compiled_box(brush);
checkpoints.push(state.clone());
}
let mut mesh = TriangleMesh::new();
report.emitted_convex_fragments = boxes.len();
for (bounds, material) in boxes {
mesh.append_box(bounds, material);
}
BuildOutput {
mesh,
report,
*self.box_cache.borrow_mut() = Some(BoxBuildCache {
generation: self.generation,
}
checkpoints,
});
Some(state.into_output(self.brushes.len(), self.generation))
}
pub fn sample_room_with_door() -> Self {
@ -639,6 +701,79 @@ impl CompiledBrush {
}
}
impl BoxBuildState {
fn apply_compiled_box(&mut self, brush: &CompiledBrush) {
let CompiledGeometry::Box(bounds) = brush.geometry else {
unreachable!("box build state received a non-box brush");
};
match brush.op {
BrushOp::Add => self.boxes.push((bounds, brush.material)),
BrushOp::Subtract => {
self.report.operator_brushes += 1;
if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) {
self.report.rejected_pairs += self.boxes.len();
return;
}
let mut out = Vec::with_capacity(self.boxes.len() + 4);
for (solid, material) in self.boxes.drain(..) {
if solid.intersects(bounds) {
self.report.candidate_pairs += 1;
out.extend(
solid
.subtract_box(bounds)
.into_iter()
.map(|piece| (piece, material)),
);
} else {
self.report.rejected_pairs += 1;
out.push((solid, material));
}
}
self.boxes = out;
}
BrushOp::Intersect => {
self.report.operator_brushes += 1;
if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) {
self.report.rejected_pairs += self.boxes.len();
self.boxes.clear();
return;
}
self.boxes = self
.boxes
.drain(..)
.filter_map(|(solid, material)| {
if let Some(hit) = solid.intersection(bounds) {
self.report.candidate_pairs += 1;
Some((hit, material))
} else {
self.report.rejected_pairs += 1;
None
}
})
.collect();
}
}
}
fn into_output(mut self, input_brushes: usize, generation: u64) -> BuildOutput {
let mut mesh = TriangleMesh::new();
self.report.input_brushes = input_brushes;
self.report.emitted_convex_fragments = self.boxes.len();
for (bounds, material) in self.boxes {
mesh.append_box(bounds, material);
}
BuildOutput {
mesh,
report: self.report,
generation,
}
}
}
fn subtract_from_solids(
solids: Vec<ConvexSolid>,
cutter: &ConvexSolid,
@ -986,6 +1121,94 @@ mod tests {
assert_eq!(asm.brush_index(cutter), Some(1));
}
#[test]
fn incremental_box_rebuild_reuses_prefix_and_matches_full_rebuild() {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
MaterialId(1),
);
asm.cut_box(
"early",
Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)),
);
let dirty = asm.cut_box(
"dirty",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
);
asm.cut_box(
"late",
Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)),
);
let before = asm.build();
assert!(asm.set_brush_primitive(
dirty,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)),
},
));
let incremental = asm.build_incremental();
let full = asm.rebuild();
assert_eq!(incremental.mesh.positions, full.mesh.positions);
assert_eq!(incremental.mesh.indices, full.mesh.indices);
assert_eq!(
incremental.report.candidate_pairs,
full.report.candidate_pairs
);
assert_eq!(
incremental.report.rejected_pairs,
full.report.rejected_pairs
);
assert_eq!(incremental.generation, full.generation);
assert_ne!(incremental.mesh.positions, before.mesh.positions);
}
#[test]
fn explicit_incremental_box_rebuild_matches_full_rebuild() {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
MaterialId(1),
);
asm.cut_box(
"early",
Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)),
);
let dirty = asm.cut_box(
"dirty",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
);
asm.cut_box(
"late",
Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)),
);
asm.build();
assert!(asm.set_brush_primitive(
dirty,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)),
},
));
let incremental = asm.rebuild_incremental_for_brushes([dirty]);
let full = asm.rebuild();
assert_eq!(incremental.mesh.positions, full.mesh.positions);
assert_eq!(incremental.mesh.indices, full.mesh.indices);
assert_eq!(
incremental.report.candidate_pairs,
full.report.candidate_pairs
);
assert_eq!(
incremental.report.rejected_pairs,
full.report.rejected_pairs
);
}
#[test]
fn mutating_brush_operation_changes_ordered_output() {
let mut asm = Assembler::new();

View file

@ -1,10 +1,11 @@
//! 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.
//! operation types, dirtied generations, prefix checkpoints, and rebuildable
//! output meshes. 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;

View file

@ -119,8 +119,16 @@ Realtime editing seam: `Assembler` can now mutate brush primitives and
operations by `BrushId`, invalidating cached output and incrementing generation.
`DirtyDemandFrontier` computes the conservative ordered suffix after the first
dirty brush. The prefix before that index is the cacheable region; the suffix is
the live region because ordered CSG decisions propagate forward. This is still a
planning surface, not an incremental mesh rebuild yet.
the live region because ordered CSG decisions propagate forward. The first
implementation now uses that boundary only for box-only prefix checkpoints; the
convex/router path still needs the same treatment.
Incremental edit seam: box-only ordered CSG now stores prefix checkpoints after
each brush. A tail edit can resume from the checkpoint immediately before the
dirty brush and replay only the live suffix. The result is parity-checked
against a full rebuild. This is a real cache boundary, but final mesh emission is
still whole-output, so the current win appears in larger tail-edit cases rather
than tiny scenes.
## Latest Local Baseline
@ -154,3 +162,30 @@ dirty paths are respectable, distant bounds rejection is excellent, and dense
rotated subtraction is still the open wound. The category-router kernel remains
the next architectural move; cached output is a guardrail, not a replacement
for the real engine.
## Incremental Box-Only Smoke Run
Captured on 2026-05-07 with
`cargo run -p vg_csg --release --example csg_perf_fixture`. Dirty modes mutate
the tail brush each iteration. `incremental_dirty` uses the box-prefix
checkpoint cache when the scene is box-only and falls back for non-box/rotated
cases.
```jsonl
{"kernel":"vg_csg","mode":"stable","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":518,"min_ns":500,"p50_ns":500,"p95_ns":600,"max_ns":700,"triangles":72,"fragments":6,"warnings":0,"candidate_pairs":1,"rejected_pairs":0}
{"kernel":"vg_csg","mode":"dirty","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":4287,"min_ns":3800,"p50_ns":4000,"p95_ns":4700,"max_ns":14000,"triangles":72,"fragments":6,"warnings":0,"candidate_pairs":1,"rejected_pairs":0}
{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":4617,"min_ns":4100,"p50_ns":4200,"p95_ns":6600,"max_ns":10600,"triangles":72,"fragments":6,"warnings":0,"candidate_pairs":1,"rejected_pairs":0}
{"kernel":"vg_csg","mode":"stable","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":46415,"min_ns":41500,"p50_ns":42200,"p95_ns":52400,"max_ns":195100,"triangles":3072,"fragments":256,"warnings":0,"candidate_pairs":64,"rejected_pairs":8128}
{"kernel":"vg_csg","mode":"dirty","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":825753,"min_ns":464400,"p50_ns":715400,"p95_ns":1257500,"max_ns":3452200,"triangles":3108,"fragments":259,"warnings":0,"candidate_pairs":65,"rejected_pairs":8127}
{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":516351,"min_ns":112000,"p50_ns":363900,"p95_ns":983900,"max_ns":4876400,"triangles":3108,"fragments":259,"warnings":0,"candidate_pairs":65,"rejected_pairs":8127}
{"kernel":"vg_csg","mode":"stable","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":240,"min_ns":200,"p50_ns":200,"p95_ns":300,"max_ns":500,"triangles":12,"fragments":1,"warnings":0,"candidate_pairs":0,"rejected_pairs":512}
{"kernel":"vg_csg","mode":"dirty","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":52087,"min_ns":35900,"p50_ns":44800,"p95_ns":56700,"max_ns":281000,"triangles":12,"fragments":1,"warnings":0,"candidate_pairs":0,"rejected_pairs":512}
{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":35528,"min_ns":32200,"p50_ns":32500,"p95_ns":47000,"max_ns":50000,"triangles":12,"fragments":1,"warnings":0,"candidate_pairs":0,"rejected_pairs":512}
```
Tail-edit lesson: prefix caching cuts the big box-room dirty mean by roughly a
third and distant-cutter dirty mean by roughly a third. The single-cut case is
slower because cache machinery dominates, and rotated/non-box cases still use
the full path. The next hard target is cached mesh emission or dirty output
patching; replaying less CSG work is not enough if every edit still serializes
the whole result mesh.

View file

@ -118,8 +118,11 @@ Current realtime-editing seam: `Assembler` can mutate brush primitives and
operations by `BrushId`, invalidating cached output and incrementing generation.
`DirtyDemandFrontier` computes the conservative ordered suffix after the first
dirty brush. The prefix before that index is the future cache boundary; the
suffix is live because ordered CSG decisions propagate forward. There is not yet
an incremental mesh rebuild from that plan.
suffix is live because ordered CSG decisions propagate forward. Box-only ordered
CSG now has a real prefix checkpoint cache, and `build_incremental()` resumes
from the checkpoint before the first dirty brush. Output parity is tested against
full rebuilds. Mesh emission is still whole-output, and rotated/non-box paths
still fall back.
## Important Invariants

View file

@ -66,3 +66,4 @@
{"ts": "2026-05-07T07:30:39+00:00", "type": "implementation", "status": "ok", "note": "Added the first experimental vg_csg routed-surface path: single convex source plus exactly one candidate subtractive convex cutter emits surviving source boundary polygons plus reversed cutter cap polygons, reducing centered-cut triangles from 72 fragment-carver triangles to 48 boundary triangles. Dense repeated cutters are deliberately not routed yet: naive surface-list routing caused surface-piece explosion, so the next dense-kernel move must use compact category/frontier batching and scratch storage rather than unbounded polygon Vec churn."}
{"ts": "2026-05-07T07:37:47+00:00", "type": "implementation", "status": "ok", "note": "Added PolygonRouteScratch for routed polygon classification and wired it into rebuild_routed_surfaces. An attempted in-place fragment frontier sped nothing safely: it changed rotated_cut_stack_64 output shape, so it was reverted and a regression now pins that scenario at 280 fragments, 3404 triangles, 804 candidate pairs, and 9417 rejected pairs. Future dense speed work must preserve ordered output or introduce an explicit accepted-output transition, not silently mutate semantics."}
{"ts": "2026-05-07T07:47:54+00:00", "type": "implementation", "status": "ok", "note": "Added the first realtime-editing seam to vg_csg: brushes can now mutate primitive/op by BrushId, invalidating cached output and incrementing generation; DirtyDemandFrontier computes the conservative ordered suffix after the first dirty brush, identifying the future prefix-cache boundary. This is a planning/cache seam, not yet incremental mesh rebuild."}
{"ts": "2026-05-07T14:05:07+00:00", "type": "implementation", "status": "ok", "note": "Added box-only dirty prefix checkpoints to vg_csg. build_incremental resumes from the checkpoint before the first dirty brush and parity tests compare incremental output against full rebuilds. Perf fixture now includes incremental_dirty; tail edits improve in large box-only scenarios, but final mesh emission remains whole-output and non-box paths fall back."}

View file

@ -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/intersection via convex plane splitting, crossing polygon category buckets, primitive/solid/tree bounds, additive cylinder/dome/floret primitives, JSONL performance fixtures with stable and dirty modes, mesh-array output, stable-generation output caching, 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. A standalone RealtimeCSG C++ P/Invoke bridge now mirrors the managed wrapper surface, verifies a native health cube, extracts generated mesh descriptions, and appends direct native timing records. Current perf pass: `vg_csg` has a fast axis-aligned path, cached compiled brush geometry, owned convex splitting, convex containment shortcuts, stable-tree output caching, and stable/dirty perf records; dirty box-heavy and distant-rejection paths are respectable, but dirty dense rotated subtraction still needs the category-router kernel. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Turn `DirtyDemandFrontier` from a conservative suffix plan into an incremental rebuild cache: preserve the prefix before the first dirty brush, rebuild the live suffix through the compact category/router path, keep cached-vs-dirty output parity green, and only add BVH/grid storage after batching proves it needs one.
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, crossing polygon category buckets, primitive/solid/tree bounds, additive cylinder/dome/floret primitives, JSONL performance fixtures with stable, dirty, and incremental dirty modes, mesh-array output, stable-generation output caching, box-only dirty prefix checkpoints, 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. A standalone RealtimeCSG C++ P/Invoke bridge now mirrors the managed wrapper surface, verifies a native health cube, extracts generated mesh descriptions, and appends direct native timing records. Current perf pass: `vg_csg` has a fast axis-aligned path, cached compiled brush geometry, owned convex splitting, convex containment shortcuts, stable-tree output caching, and box-only incremental dirty prefix caching; tail edits in large box-only scenes are faster, but mesh emission is still whole-output and dirty dense rotated subtraction still needs the category-router kernel. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Extend the dirty-prefix cache beyond box-only state: either cache emitted mesh ranges for box scenes or move the same checkpoint/live-suffix model into the convex/category-router path, then benchmark against full dirty rebuilds without relaxing output parity.
remotes:
vibegeometry: https://github.com/GameCult/VibeGeometry
geometry_script_fork: https://github.com/GameCult/geometry-script