diff --git a/.claude/worktrees/agent-a2f28e507557a5b11 b/.claude/worktrees/agent-a2f28e507557a5b11 new file mode 160000 index 0000000..6acc2b6 --- /dev/null +++ b/.claude/worktrees/agent-a2f28e507557a5b11 @@ -0,0 +1 @@ +Subproject commit 6acc2b6227f768d1f8355a8cf66154568a0cb76b diff --git a/.claude/worktrees/agent-a8983b474d3226b17 b/.claude/worktrees/agent-a8983b474d3226b17 new file mode 160000 index 0000000..6acc2b6 --- /dev/null +++ b/.claude/worktrees/agent-a8983b474d3226b17 @@ -0,0 +1 @@ +Subproject commit 6acc2b6227f768d1f8355a8cf66154568a0cb76b diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 9169fa9..3858f66 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -205,6 +205,15 @@ fn perf(args: &[String]) -> Result<(), String> { let total = ms(t); rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0))); + // One edit, then a pick: only the touched chunk is re-indexed. + let mut edited = d.clone(); + if let Some(h) = edited.model.last().map(|e| e.handle) { + edited.model.modify(h, |e| e.common.layer = "0".into()); + } + let t = Instant::now(); + let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap); + rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new())); + let mut sel = 0usize; let t = Instant::now(); for k in 0..20 { diff --git a/crates/doc/src/store.rs b/crates/doc/src/store.rs index 5b4a863..5540e68 100644 --- a/crates/doc/src/store.rs +++ b/crates/doc/src/store.rs @@ -194,6 +194,12 @@ impl EntityStore { pub fn same_as(&self, other: &EntityStore) -> bool { self.len == other.len && self.chunks.len() == other.chunks.len() && self.chunks.iter().zip(&other.chunks).all(|(a, b)| Arc::ptr_eq(a, b)) } + /// The chunks in draw order as (identity key, entities). A key is stable for as long as any + /// clone of this store is alive and changes whenever the chunk is edited (copy-on-write), so + /// caches holding a clone can reuse per-chunk work. + pub fn chunk_slices(&self) -> impl Iterator])> + '_ { + self.chunks.iter().map(|c| (Arc::as_ptr(c) as usize, c.items.as_slice())) + } /// The last entity in draw order (AutoCAD's "Last" selection). pub fn last(&self) -> Option<&Arc> { self.chunks.last().and_then(|c| c.items.last()) diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index 97a41de..45bbcaf 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -25,73 +25,157 @@ pub const MIN_INDEXED: usize = 256; /// Indexes kept alive (open drawings × spaces, plus a few undo states). const CACHE_SIZE: usize = 4; -type Item = GeomWithData, u32>; +/// Tree items carry (chunk serial, index within the chunk); draw indices are derived at query +/// time so inserting or removing entities never invalidates other chunks' items. +type Item = GeomWithData, (u32, u32)>; -/// An R-tree over one space's entity bounds. +static NEXT_SERIAL: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1); + +/// One store chunk's entities and bounds, shared between successive indexes while unchanged. +struct ChunkIx { + serial: u32, + key: usize, + ents: Vec>, + bounds: Vec, + /// Local indices of xlines and rays (unbounded; always candidates for pick and crossing). + infinite: Vec, +} + +impl ChunkIx { + fn build(d: &Drawing, key: usize, items: &[Arc]) -> ChunkIx { + let mut infinite = Vec::new(); + let bounds = items + .iter() + .enumerate() + .map(|(i, e)| { + if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) { + infinite.push(u32::try_from(i).unwrap_or(u32::MAX)); + } + entity_bounds(d, e, 0) + }) + .collect(); + let serial = NEXT_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + ChunkIx { serial, key, ents: items.to_vec(), bounds, infinite } + } + fn items(&self) -> impl Iterator + '_ { + self.bounds.iter().enumerate().filter(|(_, b)| !b.is_empty()).map(|(i, b)| { + GeomWithData::new(Rectangle::from_corners([b.min.x, b.min.y], [b.max.x, b.max.y]), (self.serial, u32::try_from(i).unwrap_or(u32::MAX))) + }) + } +} + +/// An R-tree over one space's entity bounds. Built incrementally from the previous index: only +/// store chunks that changed are re-measured and patched into the tree. pub struct SpatialIndex { store: EntityStore, blocks: BTreeMap>, dim_styles: Vec, dimscale: f64, - ents: Vec>, - bounds: Vec, + /// (draw index of the chunk's first entity, chunk), in draw order. + chunks: Vec<(u32, Arc)>, + /// Chunk serial → draw offset. + offsets: std::collections::HashMap, tree: RTree, - /// Draw indices of xlines and rays (unbounded; always candidates for pick and crossing). + /// Draw indices of xlines and rays. infinite: Vec, + len: usize, } impl SpatialIndex { /// Build an index over a store (no caching). pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex { - let mut ents = Vec::with_capacity(store.len()); - let mut bounds = Vec::with_capacity(store.len()); - let mut items = Vec::with_capacity(store.len()); - let mut infinite = Vec::new(); - for (i, e) in store.iter().enumerate() { - let Ok(idx) = u32::try_from(i) else { break }; - let b = entity_bounds(d, e, 0); - if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) { - infinite.push(idx); - } - if !b.is_empty() { - items.push(GeomWithData::new(Rectangle::from_corners([b.min.x, b.min.y], [b.max.x, b.max.y]), idx)); - } - ents.push(e.clone()); - bounds.push(b); + Self::build_from(d, store, None) + } + + /// Build, reusing `prev` for chunks the two stores share (only when the bounds context — + /// blocks, dimension styles, DIMSCALE — is the same). + fn build_from(d: &Drawing, store: &EntityStore, prev: Option<&SpatialIndex>) -> SpatialIndex { + let prev = prev.filter(|p| p.same_context(d)); + let old: std::collections::HashMap> = + prev.map(|p| p.chunks.iter().map(|(_, c)| (c.key, c.clone())).collect()).unwrap_or_default(); + let mut chunks = Vec::new(); + let mut fresh = Vec::new(); + let mut kept = std::collections::HashSet::new(); + let mut offset = 0u32; + for (key, items) in store.chunk_slices() { + let c = match old.get(&key) { + Some(c) if c.ents.len() == items.len() => { + kept.insert(c.serial); + c.clone() + } + _ => { + let c = Arc::new(ChunkIx::build(d, key, items)); + fresh.push(c.clone()); + c + } + }; + let n = u32::try_from(c.ents.len()).unwrap_or(u32::MAX); + chunks.push((offset, c)); + offset = offset.saturating_add(n); } + // Patch the previous tree when most of it survives; otherwise bulk-load. + let tree = match prev { + Some(p) if kept.len() * 2 >= chunks.len().max(1) => { + let mut tree = p.tree.clone(); + for (_, c) in p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)) { + for it in c.items() { + tree.remove(&it); + } + } + for c in &fresh { + for it in c.items() { + tree.insert(it); + } + } + tree + } + _ => RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()), + }; + let offsets = chunks.iter().map(|(off, c)| (c.serial, *off)).collect(); + let infinite = chunks.iter().flat_map(|(off, c)| c.infinite.iter().map(move |i| off + i)).collect(); SpatialIndex { store: store.clone(), blocks: d.blocks.clone(), dim_styles: d.dim_styles.clone(), dimscale: d.header.f64("DIMSCALE", 1.0), - ents, - bounds, - tree: RTree::bulk_load(items), + chunks, + offsets, + tree, infinite, + len: offset as usize, } } - fn matches(&self, d: &Drawing, store: &EntityStore) -> bool { - self.store.same_as(store) - && self.blocks.len() == d.blocks.len() + fn same_context(&self, d: &Drawing) -> bool { + self.blocks.len() == d.blocks.len() && self.blocks.iter().zip(&d.blocks).all(|((ka, a), (kb, b))| ka == kb && Arc::ptr_eq(a, b)) && self.dimscale.to_bits() == d.header.f64("DIMSCALE", 1.0).to_bits() && self.dim_styles == d.dim_styles } + fn matches(&self, d: &Drawing, store: &EntityStore) -> bool { + self.store.same_as(store) && self.same_context(d) + } + pub fn len(&self) -> usize { - self.ents.len() + self.len } pub fn is_empty(&self) -> bool { - self.ents.is_empty() + self.len == 0 + } + fn locate(&self, i: u32) -> Option<(&ChunkIx, usize)> { + let pos = self.chunks.partition_point(|(off, _)| *off <= i).checked_sub(1)?; + let (off, c) = self.chunks.get(pos)?; + Some((c, (i - off) as usize)) } /// The entity at draw index `i`. pub fn entity(&self, i: u32) -> Option<&Arc> { - self.ents.get(i as usize) + let (c, l) = self.locate(i)?; + c.ents.get(l) } /// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them). pub fn bounds(&self, i: u32) -> Bounds2 { - self.bounds.get(i as usize).copied().unwrap_or(Bounds2::EMPTY) + self.locate(i).and_then(|(c, l)| c.bounds.get(l).copied()).unwrap_or(Bounds2::EMPTY) } /// Draw indices (ascending) of entities whose bounds touch `b`, optionally with the infinite @@ -106,7 +190,8 @@ impl SpatialIndex { // Pad a little so rounding in the callers' own predicates never loses a candidate. let pad = (b.width() + b.height()) * 1e-9 + (b.min.x.abs() + b.min.y.abs() + b.max.x.abs() + b.max.y.abs()) * 1e-12 + 1e-300; let env = AABB::from_corners([b.min.x - pad, b.min.y - pad], [b.max.x + pad, b.max.y + pad]); - let mut out: Vec = self.tree.locate_in_envelope_intersecting(&env).map(|it| it.data).collect(); + let mut out: Vec = + self.tree.locate_in_envelope_intersecting(&env).filter_map(|it| self.offsets.get(&it.data.0).map(|off| off + it.data.1)).collect(); if with_infinite { out.extend_from_slice(&self.infinite); } @@ -138,8 +223,10 @@ pub fn index_store(d: &Drawing, store: &EntityStore) -> Arc { return ix; } } - // Build outside the lock so other threads aren't blocked. - let ix = Arc::new(SpatialIndex::build(d, store)); + // The most recent index is the likeliest ancestor of this store (one edit ago): reuse its + // unchanged chunks. Build outside the lock so other threads aren't blocked. + let prev = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).last().cloned(); + let ix = Arc::new(SpatialIndex::build_from(d, store, prev.as_deref())); let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // Drop entries whose store this one supersedes cheaply: keep the most recent ones. while cache.len() >= CACHE_SIZE { @@ -370,4 +457,45 @@ mod tests { assert!(index(&d, &Space::Model).is_none()); assert!(index(&d, &Space::Paper("nope".into())).is_none()); } + + #[test] + fn incremental_index_matches_fresh_build() { + let mut d = drawing(7, 3000); + let mut r = Rng(99); + let mut prev = SpatialIndex::build(&d, &d.model); + for step in 0..60 { + let hs = d.model.handles(); + let h = hs[(r.f() * hs.len() as f64) as usize % hs.len()]; + match step % 4 { + 0 => { + d.model.remove(h); + } + 1 => { + let c = v3(r.r(-50.0, 150.0), r.r(-50.0, 150.0)); + d.add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: c, radius: r.r(0.1, 5.0) })).unwrap(); + } + 2 => { + d.model.send_to_back(h); + } + _ => { + let (dx, dy) = (r.r(-20.0, 20.0), r.r(-20.0, 20.0)); + d.modify_entity(h, |e| e.kind.transform(&cadcraft_geom::Mat3::translate(Vec2::new(dx, dy)))).unwrap(); + } + } + let inc = SpatialIndex::build_from(&d, &d.model, Some(&prev)); + let fresh = SpatialIndex::build(&d, &d.model); + assert_eq!(inc.len(), fresh.len()); + for _ in 0..20 { + let c = Vec2::new(r.r(-50.0, 150.0), r.r(-50.0, 150.0)); + let w = r.r(0.1, 30.0); + let b = Bounds2::new(c, c + Vec2::new(w, w)); + let (a, f) = (inc.query(&b, true).unwrap(), fresh.query(&b, true).unwrap()); + assert_eq!(a, f, "step {step}"); + for i in a { + assert_eq!(inc.entity(i).map(|e| e.handle), fresh.entity(i).map(|e| e.handle)); + } + } + prev = inc; + } + } }