From eb97a5342fb968ee52ba19bd3ba88a66ad306ca4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 15:14:39 +0100 Subject: [PATCH] Add convex CSG incremental checkpoints --- crates/vg_csg/examples/csg_perf_fixture.rs | 6 +- crates/vg_csg/src/assembler.rs | 419 ++++++++++++++------- docs/research/csg-performance-fixtures.md | 59 +-- notes/fresh-workspace-handoff.md | 9 +- state/evidence.jsonl | 1 + state/map.yaml | 4 +- 6 files changed, 320 insertions(+), 178 deletions(-) diff --git a/crates/vg_csg/examples/csg_perf_fixture.rs b/crates/vg_csg/examples/csg_perf_fixture.rs index ed6a94a..7fd3418 100644 --- a/crates/vg_csg/examples/csg_perf_fixture.rs +++ b/crates/vg_csg/examples/csg_perf_fixture.rs @@ -92,7 +92,11 @@ fn run_case(case: PerfCase, mode: PerfMode) { if matches!(mode, PerfMode::Routed) && !assembler.supports_routed_surfaces() { return; } - let warmup = assembler.build(); + let warmup = if matches!(mode, PerfMode::IncrementalDirty) { + assembler.build_incremental() + } else { + assembler.build() + }; black_box(warmup.mesh.triangle_count()); for _ in 0..WARMUP_ITERS { diff --git a/crates/vg_csg/src/assembler.rs b/crates/vg_csg/src/assembler.rs index 181b603..699a7bb 100644 --- a/crates/vg_csg/src/assembler.rs +++ b/crates/vg_csg/src/assembler.rs @@ -38,6 +38,7 @@ pub struct Assembler { compiled: Vec, cache: RefCell>, box_cache: RefCell>, + general_cache: RefCell>, first_dirty_index: RefCell>, next_id: u32, generation: u64, @@ -83,6 +84,7 @@ enum CompiledGeometry { #[derive(Clone, Debug, Default)] struct BoxBuildCache { generation: u64, + valid_checkpoint_index: usize, checkpoints: Vec, } @@ -92,6 +94,20 @@ struct BoxBuildState { report: BuildReport, } +#[derive(Clone, Debug, Default)] +struct GeneralBuildCache { + generation: u64, + valid_checkpoint_index: usize, + checkpoints: Vec, +} + +#[derive(Clone, Debug, Default)] +struct GeneralBuildState { + mesh: TriangleMesh, + solids: Vec, + report: BuildReport, +} + impl Assembler { pub fn new() -> Self { Self::default() @@ -258,7 +274,7 @@ impl Assembler { 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()); + .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)); *self.cache.borrow_mut() = Some(output.clone()); *self.first_dirty_index.borrow_mut() = None; output @@ -272,7 +288,7 @@ impl Assembler { .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()) + .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)) } pub fn rebuild_incremental_for_brushes( @@ -407,131 +423,15 @@ impl Assembler { return self.build_axis_aligned_boxes(); } - let mut report = BuildReport { - input_brushes: self.brushes.len(), - ..BuildReport::default() - }; - let mut mesh = TriangleMesh::new(); - let mut solids: Vec = Vec::new(); + self.build_general_direct() + } + fn build_general_direct(&self) -> BuildOutput { + let mut state = GeneralBuildState::default(); for brush in &self.compiled { - match brush.op { - BrushOp::Add => match &brush.geometry { - CompiledGeometry::Box(bounds) => { - solids.push(ConvexSolid::from_aabb(*bounds, brush.material)); - } - CompiledGeometry::Convex(solid) => solids.push(solid.clone()), - CompiledGeometry::CylinderZ { - center, - radius, - depth, - segments, - } => append_cylinder_z( - &mut mesh, - *center, - *radius, - *depth, - *segments, - brush.material, - ), - CompiledGeometry::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, - }, - ), - CompiledGeometry::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 => { - report.operator_brushes += 1; - if !brush - .bounds - .intersects_any(solids.iter().map(|solid| solid.bounds)) - { - report.rejected_pairs += solids.len(); - continue; - } - if let Some(cutter) = brush.convex_cutter() { - solids = subtract_from_solids(solids, &cutter, &mut report); - } else { - let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report); - if has_candidate { - report - .warnings - .push(BuildWarning::SubtractIgnoredForNonBox { - brush: brush.name.clone(), - }); - } - } - } - BrushOp::Intersect => { - report.operator_brushes += 1; - if !brush - .bounds - .intersects_any(solids.iter().map(|solid| solid.bounds)) - { - report.rejected_pairs += solids.len(); - solids.clear(); - continue; - } - if let Some(cutter) = brush.convex_cutter() { - solids = intersect_solids(solids, &cutter, &mut report); - } else { - let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report); - solids.clear(); - if has_candidate { - report - .warnings - .push(BuildWarning::IntersectIgnoredForNonBox { - brush: brush.name.clone(), - }); - } - } - } - } - } - - report.emitted_convex_fragments = solids.len(); - for solid in solids { - solid.append_to_mesh(&mut mesh); - } - - BuildOutput { - mesh, - report, - generation: self.generation, + state.apply_compiled_brush(brush); } + state.into_output(self.brushes.len(), self.generation) } fn build_axis_aligned_boxes(&self) -> BuildOutput { @@ -560,6 +460,7 @@ impl Assembler { .as_ref() .filter(|cache| { cache.generation == self.generation + && cache.valid_checkpoint_index == brush_count && cache.checkpoints.len() == brush_count + 1 }) .map_or(0, |_| brush_count), @@ -568,6 +469,7 @@ impl Assembler { .box_cache .borrow() .as_ref() + .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); let (mut state, start_index) = if let Some(state) = cached_state { (state, rebuild_from) @@ -575,34 +477,79 @@ impl Assembler { (BoxBuildState::default(), 0) }; - let mut checkpoints = self - .box_cache - .borrow() - .as_ref() - .map(|cache| { - cache - .checkpoints - .iter() - .take(start_index + 1) - .cloned() - .collect::>() - }) - .filter(|checkpoints| checkpoints.len() == start_index + 1) - .unwrap_or_else(|| vec![BoxBuildState::default()]); + let mut checkpoints = (start_index == 0).then(|| vec![BoxBuildState::default()]); for brush in &self.compiled[start_index..] { state.apply_compiled_box(brush); - checkpoints.push(state.clone()); + if let Some(checkpoints) = &mut checkpoints { + checkpoints.push(state.clone()); + } } - *self.box_cache.borrow_mut() = Some(BoxBuildCache { - generation: self.generation, - checkpoints, - }); + if let Some(checkpoints) = checkpoints { + *self.box_cache.borrow_mut() = Some(BoxBuildCache { + generation: self.generation, + valid_checkpoint_index: brush_count, + checkpoints, + }); + } else if let Some(cache) = self.box_cache.borrow_mut().as_mut() { + cache.generation = self.generation; + cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); + } Some(state.into_output(self.brushes.len(), self.generation)) } + fn build_general_from_cache(&self, first_dirty_index: Option) -> BuildOutput { + let brush_count = self.compiled.len(); + let rebuild_from = match first_dirty_index { + Some(index) => index.min(brush_count), + None => self + .general_cache + .borrow() + .as_ref() + .filter(|cache| { + cache.generation == self.generation + && cache.valid_checkpoint_index == brush_count + && cache.checkpoints.len() == brush_count + 1 + }) + .map_or(0, |_| brush_count), + }; + let cached_state = self + .general_cache + .borrow() + .as_ref() + .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) + .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); + let (mut state, start_index) = if let Some(state) = cached_state { + (state, rebuild_from) + } else { + (GeneralBuildState::default(), 0) + }; + + let mut checkpoints = (start_index == 0).then(|| vec![GeneralBuildState::default()]); + + for brush in &self.compiled[start_index..] { + state.apply_compiled_brush(brush); + if let Some(checkpoints) = &mut checkpoints { + checkpoints.push(state.clone()); + } + } + + if let Some(checkpoints) = checkpoints { + *self.general_cache.borrow_mut() = Some(GeneralBuildCache { + generation: self.generation, + valid_checkpoint_index: brush_count, + checkpoints, + }); + } else if let Some(cache) = self.general_cache.borrow_mut().as_mut() { + cache.generation = self.generation; + cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); + } + + state.into_output(self.brushes.len(), self.generation) + } + pub fn sample_room_with_door() -> Self { let wall = MaterialId(1); let floor = MaterialId(2); @@ -774,6 +721,145 @@ impl BoxBuildState { } } +impl GeneralBuildState { + fn apply_compiled_brush(&mut self, brush: &CompiledBrush) { + match brush.op { + BrushOp::Add => self.add_brush_geometry(brush), + BrushOp::Subtract => { + self.report.operator_brushes += 1; + if !brush + .bounds + .intersects_any(self.solids.iter().map(|solid| solid.bounds)) + { + self.report.rejected_pairs += self.solids.len(); + return; + } + if let Some(cutter) = brush.convex_cutter() { + self.solids = subtract_from_solids( + std::mem::take(&mut self.solids), + &cutter, + &mut self.report, + ); + } else { + let has_candidate = + record_solid_pairs(&self.solids, brush.bounds, &mut self.report); + if has_candidate { + self.report + .warnings + .push(BuildWarning::SubtractIgnoredForNonBox { + brush: brush.name.clone(), + }); + } + } + } + BrushOp::Intersect => { + self.report.operator_brushes += 1; + if !brush + .bounds + .intersects_any(self.solids.iter().map(|solid| solid.bounds)) + { + self.report.rejected_pairs += self.solids.len(); + self.solids.clear(); + return; + } + if let Some(cutter) = brush.convex_cutter() { + self.solids = intersect_solids( + std::mem::take(&mut self.solids), + &cutter, + &mut self.report, + ); + } else { + let has_candidate = + record_solid_pairs(&self.solids, brush.bounds, &mut self.report); + self.solids.clear(); + if has_candidate { + self.report + .warnings + .push(BuildWarning::IntersectIgnoredForNonBox { + brush: brush.name.clone(), + }); + } + } + } + } + } + + fn add_brush_geometry(&mut self, brush: &CompiledBrush) { + match &brush.geometry { + CompiledGeometry::Box(bounds) => { + self.solids + .push(ConvexSolid::from_aabb(*bounds, brush.material)); + } + CompiledGeometry::Convex(solid) => self.solids.push(solid.clone()), + CompiledGeometry::CylinderZ { + center, + radius, + depth, + segments, + } => append_cylinder_z( + &mut self.mesh, + *center, + *radius, + *depth, + *segments, + brush.material, + ), + CompiledGeometry::DomeCapZ { + center, + radius, + height, + rings, + segments, + } => append_dome_cap_z( + &mut self.mesh, + DomeCapZSpec { + center: *center, + radius: *radius, + height: *height, + rings: *rings, + segments: *segments, + material: brush.material, + }, + ), + CompiledGeometry::FloretArm { + anchor, + direction, + length, + root_width, + tip_width, + thickness, + tip_lift, + } => append_floret_arm( + &mut self.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, + }, + ), + } + } + + fn into_output(mut self, input_brushes: usize, generation: u64) -> BuildOutput { + self.report.input_brushes = input_brushes; + self.report.emitted_convex_fragments = self.solids.len(); + for solid in self.solids { + solid.append_to_mesh(&mut self.mesh); + } + + BuildOutput { + mesh: self.mesh, + report: self.report, + generation, + } + } +} + fn subtract_from_solids( solids: Vec, cutter: &ConvexSolid, @@ -1186,7 +1272,7 @@ mod tests { "late", Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), ); - asm.build(); + asm.build_incremental(); assert!(asm.set_brush_primitive( dirty, @@ -1209,6 +1295,51 @@ mod tests { ); } + #[test] + fn incremental_convex_rebuild_matches_full_rebuild() { + let mut asm = Assembler::new(); + asm.solid_box( + "slab", + Aabb::from_center_size(Vec3::ZERO, Vec3::new(16.0, 16.0, 4.0)), + MaterialId(1), + ); + + let mut tail = BrushId(0); + for index in 0..12 { + let angle = index as f32 * 0.217; + let center = Vec3::new(angle.cos() * 4.0, angle.sin() * 4.0, 0.0); + tail = asm.cut_oriented_box( + format!("rotated_void_{index}"), + center, + Vec3::new(1.0, 6.0, 5.0), + bevy_math::Quat::from_rotation_z(angle), + ); + } + asm.build_incremental(); + + assert!(asm.set_brush_primitive( + tail, + Primitive::OrientedBox { + center: Vec3::new(3.0, 0.0, 0.0), + size: Vec3::new(1.25, 6.0, 5.0), + rotation: bevy_math::Quat::from_rotation_z(0.5), + }, + )); + 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 + ); + } + #[test] fn mutating_brush_operation_changes_ordered_output() { let mut asm = Assembler::new(); diff --git a/docs/research/csg-performance-fixtures.md b/docs/research/csg-performance-fixtures.md index ed43bc9..cc661d2 100644 --- a/docs/research/csg-performance-fixtures.md +++ b/docs/research/csg-performance-fixtures.md @@ -120,15 +120,15 @@ 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. The first -implementation now uses that boundary only for box-only prefix checkpoints; the -convex/router path still needs the same treatment. +implementation now uses that boundary for box-only and general convex prefix +checkpoints. The category-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. +Incremental edit seam: ordered CSG now stores prefix checkpoints after each +brush for both the box and general convex builders. 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. Cache validity is +tracked as a prefix boundary so repeated edits do not deep-copy stale +checkpoints. Final mesh emission is still whole-output. ## Latest Local Baseline @@ -163,29 +163,34 @@ 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 +## Incremental Dirty 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. +the tail brush each iteration. `incremental_dirty` uses prefix checkpoints and +falls back to full replay only when no valid checkpoint exists. ```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} +{"kernel":"vg_csg","mode":"stable","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":506,"min_ns":500,"p50_ns":500,"p95_ns":500,"max_ns":800,"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":3937,"min_ns":3700,"p50_ns":3800,"p95_ns":4100,"max_ns":12700,"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":4204,"min_ns":4000,"p50_ns":4100,"p95_ns":5100,"max_ns":6000,"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":68625,"min_ns":41300,"p50_ns":62600,"p95_ns":99600,"max_ns":150000,"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":817095,"min_ns":428800,"p50_ns":664300,"p95_ns":1392000,"max_ns":3642000,"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":104501,"min_ns":45200,"p50_ns":61400,"p95_ns":283600,"max_ns":374000,"triangles":3108,"fragments":259,"warnings":0,"candidate_pairs":65,"rejected_pairs":8127} +{"kernel":"vg_csg","mode":"stable","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":14776,"min_ns":9500,"p50_ns":10300,"p95_ns":14200,"max_ns":233700,"triangles":3404,"fragments":280,"warnings":0,"candidate_pairs":804,"rejected_pairs":9417} +{"kernel":"vg_csg","mode":"dirty","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":3490834,"min_ns":2233100,"p50_ns":3133400,"p95_ns":5120800,"max_ns":6423700,"triangles":3404,"fragments":280,"warnings":0,"candidate_pairs":805,"rejected_pairs":9416} +{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":415318,"min_ns":242200,"p50_ns":305100,"p95_ns":874600,"max_ns":1777700,"triangles":3404,"fragments":280,"warnings":0,"candidate_pairs":805,"rejected_pairs":9416} +{"kernel":"vg_csg","mode":"stable","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":651,"min_ns":500,"p50_ns":600,"p95_ns":700,"max_ns":800,"triangles":45,"fragments":1,"warnings":0,"candidate_pairs":63,"rejected_pairs":0} +{"kernel":"vg_csg","mode":"dirty","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":488343,"min_ns":360200,"p50_ns":394100,"p95_ns":832100,"max_ns":1936700,"triangles":45,"fragments":1,"warnings":0,"candidate_pairs":63,"rejected_pairs":0} +{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":10150,"min_ns":9500,"p50_ns":9600,"p95_ns":13100,"max_ns":17200,"triangles":45,"fragments":1,"warnings":0,"candidate_pairs":63,"rejected_pairs":0} +{"kernel":"vg_csg","mode":"stable","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":218,"min_ns":200,"p50_ns":200,"p95_ns":300,"max_ns":400,"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":110825,"min_ns":35700,"p50_ns":52200,"p95_ns":115600,"max_ns":2843900,"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":2856,"min_ns":2400,"p50_ns":2500,"p95_ns":3600,"max_ns":4100,"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. +Tail-edit lesson: prefix caching cuts the room-grid dirty mean by roughly 7.8x, +the rotated-stack dirty mean by roughly 8.4x, the common-box chain by roughly +48x, and the distant-cutter case by roughly 39x. The single-cut case is still +slightly slower because cache machinery dominates. 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. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index bcce583..0552600 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -119,10 +119,11 @@ 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. 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. +CSG and the general convex builder now have prefix checkpoint caches, and +`build_incremental()` resumes from the checkpoint before the first dirty brush. +Output parity is tested against full rebuilds. Checkpoint validity is tracked as +a prefix boundary to avoid deep-copying stale history after tail edits. Mesh +emission is still whole-output; the category-router path is still separate. ## Important Invariants diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 6dbe999..d962d9f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -67,3 +67,4 @@ {"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."} +{"ts": "2026-05-07T14:14:21+00:00", "type": "implementation", "status": "ok", "note": "Extended vg_csg incremental dirty rebuilds from box-only state to the general convex builder. The cache now tracks a valid prefix boundary so tail edits reuse the checkpoint before the dirty brush without deep-copying stale checkpoint history. Latest smoke fixture shows large tail-edit wins, including rotated_cut_stack_64 dirty mean dropping from about 3.49 ms full rebuild to about 0.42 ms incremental, with parity tests still green."} diff --git a/state/map.yaml b/state/map.yaml index 8db4020..4990335 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -110,8 +110,8 @@ data_flow: - If state grows faster than understanding, cut it back. current_status: - summary: VibeGeometry has verified Geometry Script translations for local samples, official Blender demos, and IRCSS French Houses footholds, plus from-scratch Aetheria Bloom and Lucent tether habitat builds. Durable lessons now driving the pipeline: Python orchestrates intent and batching; Geometry Script emits inspectable node groups; source-bound scenes need explicit coordinate contracts before ornament; spin-gravity habitats need ground planes orthogonal to their tether/rotation axis; utility petals, radiators, and solar arrays belong outside pressure glass and may need Citadel-scale presence; large habitat parts need visible load paths across coordinate frames; organic city maps need street fields, irregular parcels, parks, and land-use gradients rather than grids; dense detail must be batched; institutional spaces should model power as surfaces, rails, overlays, thresholds, shader behavior, and camera evidence; and visual acceptance needs domain-facing inspection renders plus verifier checks, not only overview renders. The first reusable `vibegeometry/` helper library now carries coordinate frames, fields, batching, Blender scene primitives, and verifier helpers used by the Bloom and Lucent builds. The Rust crate `vg_csg` now has a RealtimeCSG-shaped API skeleton plus a convex-brush stepping-stone kernel: CSG operation ordinals, branch vocabulary, tree/branch/brush handles, child and operation mutation, exact parity fixtures, ordered assembler compilation, AABB/oriented-box subtraction/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. + 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, dirty prefix checkpoints for box and general convex builders, 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 valid-prefix incremental dirty caching; tail edits in large box, distant-cutter, common-intersection, and rotated-convex scenes are much faster, but mesh emission is still whole-output and the category-router kernel still needs its own compact incremental cache. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes. + next_action: Stop serializing the whole output on every edit: add emitted mesh range caching or dirty output patching for checkpointed CSG states, then port the valid-prefix checkpoint model into the compact category-router path without relaxing output parity. remotes: vibegeometry: https://github.com/GameCult/VibeGeometry geometry_script_fork: https://github.com/GameCult/geometry-script