forked from 1b-agent/opencadstudio-upstream
perf(scene): incremental spatial index + annotative scan (phase 2)
The pick/snap spatial index (entity_index) rebuilt its whole quadtree on every geometry_epoch bump, and annotation_affects_wires re-scanned every entity to decide whether any is annotative — both O(document) on each single-entity edit. Drive both off the delta journal. entity_index now replays the changes since its cached epoch and applies them to the existing quadtree (which already supports insert/remove/update, out-of-root items falling into overflow): Removed drops the handle, Added/Modified update its AABB or move it to the unbounded list, and only a full/overflow delta triggers the old full rebuild. annotation_affects_wires stays warm unless an annotative entity changed (PSLTSCALE toggles arrive as a full delta and still recompute). Adds a differential-oracle test asserting the incrementally-patched index equals a from-scratch rebuild after add / move / erase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
57401f6f32
commit
88dbbb76af
1 file changed
+125
@@ -2554,6 +2554,19 @@ impl Scene {
|
|||||||
if epoch == self.geometry_epoch {
|
if epoch == self.geometry_epoch {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
// Skip the whole-document annotative scan when nothing annotative
|
||||||
|
// changed since (PSLTSCALE toggles route through a full delta, which
|
||||||
|
// fails this check and forces the recompute below).
|
||||||
|
if self.category_cache_valid(epoch, |h| {
|
||||||
|
self.document
|
||||||
|
.get_entity(h)
|
||||||
|
.map(|e| crate::scene::annotative::is_annotative(&self.document, e))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}) {
|
||||||
|
self.annotation_affects_wires
|
||||||
|
.set(Some((self.geometry_epoch, v)));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let v = self.document.header.paper_space_linetype_scaling
|
let v = self.document.header.paper_space_linetype_scaling
|
||||||
|| self
|
|| self
|
||||||
@@ -4082,6 +4095,65 @@ impl Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Incremental path: if the cache is only a few edits behind, patch just
|
||||||
|
// the changed handles into the existing quadtree (which supports
|
||||||
|
// insert/remove/update, out-of-root items falling into overflow) instead
|
||||||
|
// of re-walking every entity. Resolve each change against the document
|
||||||
|
// first, then apply under one borrow.
|
||||||
|
enum IdxOp {
|
||||||
|
Remove(Handle),
|
||||||
|
SetBounded(Handle, [f64; 4]),
|
||||||
|
SetUnbounded(Handle),
|
||||||
|
}
|
||||||
|
let cached_epoch = self.entity_index_cache.borrow().as_ref().map(|c| c.0);
|
||||||
|
if let Some(ce) = cached_epoch {
|
||||||
|
if let Some(deltas) = self.replay_since(ce) {
|
||||||
|
let mut ops: Vec<IdxOp> = Vec::with_capacity(deltas.len());
|
||||||
|
for (h, kind) in &deltas {
|
||||||
|
match kind {
|
||||||
|
ChangeKind::Removed => ops.push(IdxOp::Remove(*h)),
|
||||||
|
ChangeKind::Added | ChangeKind::Modified => {
|
||||||
|
match self.document.get_entity(*h) {
|
||||||
|
None => ops.push(IdxOp::Remove(*h)),
|
||||||
|
Some(e) if is_unindexable_entity(e) => {
|
||||||
|
ops.push(IdxOp::Remove(*h))
|
||||||
|
}
|
||||||
|
Some(e) => match entity_world_aabb_f64(e) {
|
||||||
|
Some(ab) => ops.push(IdxOp::SetBounded(*h, ab)),
|
||||||
|
None => ops.push(IdxOp::SetUnbounded(*h)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cache = self.entity_index_cache.borrow_mut();
|
||||||
|
if let Some((epoch, idx)) = cache.as_mut() {
|
||||||
|
for op in ops {
|
||||||
|
match op {
|
||||||
|
IdxOp::Remove(h) => {
|
||||||
|
idx.tree.remove(h);
|
||||||
|
idx.unbounded_handles.retain(|x| *x != h);
|
||||||
|
}
|
||||||
|
IdxOp::SetBounded(h, ab) => {
|
||||||
|
idx.tree.update(h, ab);
|
||||||
|
idx.unbounded_handles.retain(|x| *x != h);
|
||||||
|
}
|
||||||
|
IdxOp::SetUnbounded(h) => {
|
||||||
|
idx.tree.remove(h);
|
||||||
|
idx.unbounded_handles.retain(|x| *x != h);
|
||||||
|
idx.unbounded_handles.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*epoch = self.geometry_epoch;
|
||||||
|
drop(cache);
|
||||||
|
return std::cell::Ref::map(self.entity_index_cache.borrow(), |c| {
|
||||||
|
&c.as_ref().unwrap().1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut items: Vec<(Handle, [f64; 4])> = Vec::new();
|
let mut items: Vec<(Handle, [f64; 4])> = Vec::new();
|
||||||
let mut unbounded: Vec<Handle> = Vec::new();
|
let mut unbounded: Vec<Handle> = Vec::new();
|
||||||
let mut union: Option<[f64; 4]> = None;
|
let mut union: Option<[f64; 4]> = None;
|
||||||
@@ -4459,6 +4531,59 @@ mod journal_tests {
|
|||||||
assert!(s.replay_since(recent).is_some());
|
assert!(s.replay_since(recent).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Differential oracle: the incrementally-patched entity index must always
|
||||||
|
// equal a from-scratch rebuild after any add / move / erase.
|
||||||
|
#[test]
|
||||||
|
fn entity_index_incremental_matches_full_rebuild() {
|
||||||
|
use acadrust::entities::Line;
|
||||||
|
use acadrust::types::Vector3;
|
||||||
|
|
||||||
|
fn line(a: (f64, f64), b: (f64, f64)) -> EntityType {
|
||||||
|
EntityType::Line(Line::from_points(
|
||||||
|
Vector3::new(a.0, a.1, 0.0),
|
||||||
|
Vector3::new(b.0, b.1, 0.0),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Set of handles the index reports over the whole plane (bounded via the
|
||||||
|
// quadtree + always-surfaced unbounded).
|
||||||
|
fn indexed(s: &Scene) -> HashSet<Handle> {
|
||||||
|
let idx = s.entity_index();
|
||||||
|
let mut set: HashSet<Handle> =
|
||||||
|
idx.tree.query_rect([-1e9, -1e9, 1e9, 1e9]).into_iter().collect();
|
||||||
|
set.extend(idx.unbounded_handles.iter().copied());
|
||||||
|
set
|
||||||
|
}
|
||||||
|
fn full_rebuild(s: &Scene) -> HashSet<Handle> {
|
||||||
|
*s.entity_index_cache.borrow_mut() = None;
|
||||||
|
indexed(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut s = Scene::new();
|
||||||
|
let h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0)));
|
||||||
|
let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0)));
|
||||||
|
// Prime the index (full build), then each edit must keep it == rebuild.
|
||||||
|
let _ = indexed(&s);
|
||||||
|
|
||||||
|
// Add
|
||||||
|
let h3 = s.add_entity(line((100.0, 100.0), (140.0, 90.0)));
|
||||||
|
assert_eq!(indexed(&s), full_rebuild(&s), "after add");
|
||||||
|
|
||||||
|
// Modify (move h1 far away)
|
||||||
|
{
|
||||||
|
let e = line((900.0, 900.0), (950.0, 970.0));
|
||||||
|
let mut e = e;
|
||||||
|
e.common_mut().handle = h1;
|
||||||
|
s.update_entity(e);
|
||||||
|
}
|
||||||
|
assert_eq!(indexed(&s), full_rebuild(&s), "after modify");
|
||||||
|
|
||||||
|
// Erase h2
|
||||||
|
s.erase_entities(&[h2]);
|
||||||
|
assert_eq!(indexed(&s), full_rebuild(&s), "after erase");
|
||||||
|
assert!(indexed(&s).contains(&h3) && indexed(&s).contains(&h1));
|
||||||
|
assert!(!indexed(&s).contains(&h2));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn category_gate_keeps_unrelated_caches_warm() {
|
fn category_gate_keeps_unrelated_caches_warm() {
|
||||||
let mut s = Scene::new();
|
let mut s = Scene::new();
|
||||||
|
|||||||
Reference in new issue
Block a user