mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-15 06:46:45 -04:00
Incremental R-tree: patch only changed store chunks after an edit
A pick after one edit at 200k entities drops from ~110 ms (full rebuild) to ~4 ms; steady-state picks stay at ~1 us. Adds a parity test against fresh builds and a perf row for the incremental path. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f9aa1b279a
commit
2287134dbd
5 files changed
+178
-33
No files matched your search
+1
Submodule .claude/worktrees/agent-a2f28e507557a5b11 added at 6acc2b6227.
+1
Submodule .claude/worktrees/agent-a8983b474d3226b17 added at 6acc2b6227.
@@ -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 {
|
||||
|
||||
@@ -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<Item = (usize, &[Arc<Entity>])> + '_ {
|
||||
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<Entity>> {
|
||||
self.chunks.last().and_then(|c| c.items.last())
|
||||
|
||||
+161
-33
@@ -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<Rectangle<[f64; 2]>, 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<Rectangle<[f64; 2]>, (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<Arc<Entity>>,
|
||||
bounds: Vec<Bounds2>,
|
||||
/// Local indices of xlines and rays (unbounded; always candidates for pick and crossing).
|
||||
infinite: Vec<u32>,
|
||||
}
|
||||
|
||||
impl ChunkIx {
|
||||
fn build(d: &Drawing, key: usize, items: &[Arc<Entity>]) -> 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<Item = Item> + '_ {
|
||||
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<String, Arc<Block>>,
|
||||
dim_styles: Vec<DimStyle>,
|
||||
dimscale: f64,
|
||||
ents: Vec<Arc<Entity>>,
|
||||
bounds: Vec<Bounds2>,
|
||||
/// (draw index of the chunk's first entity, chunk), in draw order.
|
||||
chunks: Vec<(u32, Arc<ChunkIx>)>,
|
||||
/// Chunk serial → draw offset.
|
||||
offsets: std::collections::HashMap<u32, u32>,
|
||||
tree: RTree<Item>,
|
||||
/// Draw indices of xlines and rays (unbounded; always candidates for pick and crossing).
|
||||
/// Draw indices of xlines and rays.
|
||||
infinite: Vec<u32>,
|
||||
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<usize, Arc<ChunkIx>> =
|
||||
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<Entity>> {
|
||||
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<u32> = self.tree.locate_in_envelope_intersecting(&env).map(|it| it.data).collect();
|
||||
let mut out: Vec<u32> =
|
||||
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<SpatialIndex> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user