Skip to content

Add SpatialIndexManager for R-tree spatial indexing#7676

Merged
MitjaBezensek merged 31 commits intomainfrom
mitja/indexing-again
Jan 16, 2026
Merged

Add SpatialIndexManager for R-tree spatial indexing#7676
MitjaBezensek merged 31 commits intomainfrom
mitja/indexing-again

Conversation

@MitjaBezensek
Copy link
Copy Markdown
Contributor

@MitjaBezensek MitjaBezensek commented Jan 12, 2026

Adds SpatialIndexManager using an R-tree (RBush) for O(log n) spatial queries instead of O(n) iteration.

pr-7676-walkthrough.mp4

Changes

  1. SpatialIndexManager - R-tree based spatial index

    • Accessible via editor.spatialIndex
    • Uses RBush library for efficient spatial queries
    • Incremental updates using filterHistory pattern
    • Per-page index (rebuilds on page change)
    • Optimized for viewport culling and selection queries
  2. Early return optimizations for tools

    • Brushing, scribble brushing, and erasing skip expensive shape iteration when spatial index returns no candidates
    • Significantly improves performance when selecting/erasing in empty areas
  3. Upgraded RBush to v4.0.1

    • ESM-only build (dropped CommonJS/IE11 support)

API changes

New exports from @tldraw/editor

SpatialIndexManager (class)

New editor property

editor.spatialIndex: SpatialIndexManager

Methods:

  • getShapeIdsInsideBounds(bounds: Box): Set<TLShapeId> — Returns shape IDs that intersect with the given rectangular area.
  • getShapeIdsAtPoint(point: {x, y}, margin?: number): Set<TLShapeId> — Returns shape IDs near a point within an optional margin.
  • dispose(): void — Cleans up the spatial index.

Change type

  • improvement

Test plan

  1. Create many shapes on the canvas
  2. Use brush selection, scribble selection, and eraser tools
  3. Verify selection/erasing works correctly
  4. Performance should be noticeably better with many shapes

Release notes

  • Adds SpatialIndexManager for efficient spatial queries using R-tree indexing
  • New editor.spatialIndex API with getShapeIdsInsideBounds() and getShapeIdsAtPoint() methods
  • Improves performance of selection and erasing operations, especially with many shapes

Note

Adds an R-tree–backed spatial index and integrates it across core editor paths for faster queries.

  • New SpatialIndexManager (exported) with editor.spatialIndex providing getShapeIdsInsideBounds and getShapeIdsAtPoint
  • Editor integrates spatial search in getShapesAtPoint and adds getShapeIdsInsideBounds(bounds); refines getShapeAtPoint candidate filtering and increases search margin; maintains per-page index with incremental updates
  • Reworks notVisibleShapes to use spatial index; adds cached equality in getCulledShapes for stable Set identity; minor canvas reflow optimization via reference check
  • Tools (Brushing, ScribbleBrushing, Erasing) use spatial candidates and early returns to avoid full-shape iteration
  • Dependencies: add rbush and @types/rbush; export in src/index.ts; add comprehensive tests for visibility/culling

Written by Cursor Bugbot for commit 7686e2f. This will update automatically on new commits. Configure here.

@vercel
Copy link
Copy Markdown

vercel bot commented Jan 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
examples Ready Ready Preview Jan 16, 2026 0:15am
5 Skipped Deployments
Project Deployment Review Updated (UTC)
analytics Ignored Ignored Preview Jan 16, 2026 0:15am
chat-template Ignored Ignored Preview Jan 16, 2026 0:15am
tldraw-docs Ignored Ignored Preview Jan 16, 2026 0:15am
tldraw-shader Ignored Ignored Preview Jan 16, 2026 0:15am
workflow-template Ignored Ignored Preview Jan 16, 2026 0:15am

Review with Vercel Agent

@huppy-bot
Copy link
Copy Markdown
Contributor

huppy-bot bot commented Jan 12, 2026

API Changes Check Passed

Great! The PR description now includes the required "### API changes" section. This helps reviewers and SDK users understand the impact of your changes.

@MitjaBezensek MitjaBezensek changed the title Spatial index again. Add granular perf logging for spatial index and refactor to manager pattern Jan 12, 2026
@MitjaBezensek MitjaBezensek changed the title Add granular perf logging for spatial index and refactor to manager pattern Add SpatialIndexManager with granular perf logging Jan 12, 2026
@MitjaBezensek
Copy link
Copy Markdown
Contributor Author

MitjaBezensek commented Jan 12, 2026

Some quick performance tests using the added logging. This is while having many shapes on the page. With a small number of shapes the performance is pretty close if not slightly worse.

Erasing

📊 [Perf Summary] erasing (old)
94 operations | avg: 1.84ms | min: 0.80ms | max: 8.10ms (checked 576/576 shapes)

📊 [Perf Summary] erasing (spatial)
103 operations | avg: 0.88ms | min: 0.20ms | max: 1.30ms (checked 0/576 shapes)

Panning (tests culling)

📊 [Perf Summary] notVisibleShapes (old)
218 operations | avg: 0.78ms | min: 0.30ms | max: 15.80ms (unchanged)

📊 [Perf Summary] notVisibleShapes (spatial)
314 operations | avg: 0.37ms | min: 0.10ms | max: 5.40ms (unchanged)

Zooming

📊 [Perf Summary] notVisibleShapes (old)
29 operations | avg: 0.59ms | min: 0.40ms | max: 0.80ms (589 non-visible)

📊 [Perf Summary] notVisibleShapes (spatial)
41 operations | avg: 0.42ms | min: 0.10ms | max: 0.80ms (618 non-visible)

Switching pages (has to build the index as it is per page)

[perf] setCurrentPage (old) (total with render+paint): 755.30ms

[perf] setCurrentPage (spatial) (total with render+paint): 786.00ms

Get shape at point

📊 [Perf Summary] getShapeAtPoint (old)
112 operations | avg: 2.35ms | min: 1.40ms | max: 3.10ms (checked 624 shapes → no hit)

📊 [Perf Summary] getShapeAtPoint (spatial)
207 operations | avg: 0.90ms | min: 0.20ms | max: 2.10ms (checked 0 shapes → no hit)

@MitjaBezensek MitjaBezensek requested a review from ds300 January 13, 2026 10:33
@tldraw tldraw deleted a comment from bhaktatejas922 Jan 15, 2026
@bhaktatejas922
Copy link
Copy Markdown

(sorry about previous comments, rouge agent)

Copy link
Copy Markdown
Collaborator

@ds300 ds300 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks excellent! The incremental derivation seems to be updated correctly and the perf gains are fantastic. Well done mitja!

@MitjaBezensek MitjaBezensek changed the title Add SpatialIndexManager with granular perf logging Add SpatialIndexManager for R-tree spatial indexing Jan 16, 2026
@MitjaBezensek MitjaBezensek marked this pull request as ready for review January 16, 2026 12:09
@huppy-bot huppy-bot bot added the improvement Product improvement label Jan 16, 2026
@MitjaBezensek MitjaBezensek added this pull request to the merge queue Jan 16, 2026
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

}

const allShapes = editor.getCurrentPageRenderingShapesSorted()
const currentPageShapes = allShapes.filter((shape) => candidateIds.has(shape.id))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frames not checked when spatial index returns no candidates

Medium Severity

Frame labels are positioned above frame shapes, outside their bounds. In Editor.ts, getShapeAtPoint and getShapesAtPoint handle this by always including frames regardless of spatial index results. However, the erasing, brushing, and scribble brushing tools filter shapes using only candidateIds.has(shape.id) without the frame exception. This causes frames to be missed when only their label intersects the erase/brush area but their body bounds don't.

Additional Locations (2)

Fix in Cursor Fix in Web

processedShapeIds.add(to.id)

const wasOnPage = this.editor.getAncestorPageId(from) === this.lastPageId
const isOnPage = this.editor.getAncestorPageId(to) === this.lastPageId
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Page change detection uses current state, not historical state

Medium Severity

The getAncestorPageId method always fetches the current shape state via this.getShape(id), ignoring the passed shape object. When checking wasOnPage using from (the old state), it actually returns the current page, not the previous page. This causes shapes moved away from the current page to remain in the spatial index incorrectly, since both wasOnPage and isOnPage will return the same value (current state), and the removal branch is never taken.

Fix in Cursor Fix in Web

Merged via the queue into main with commit 2643056 Jan 16, 2026
19 checks passed
@MitjaBezensek MitjaBezensek deleted the mitja/indexing-again branch January 16, 2026 12:27
github-merge-queue bot pushed a commit that referenced this pull request Jan 16, 2026
Follow-up to #7676. I've reconsidered and made the spatial index
private.

This PR makes the SpatialIndexManager internal to the editor rather than
part of the public API. The spatial index is an implementation detail
used for efficient shape queries.

Changes:
- Mark `SpatialIndexManager` class as `@internal`
- Remove `editor.spatialIndex` public property (now `private
_spatialIndex`)
- Mark `editor.getShapeIdsInsideBounds()` as `@internal`

The public API for shape queries remains `editor.getShapeAtPoint()` and
`editor.getShapesAtPoint()`, which handle edge cases like frame labels
being outside their bounds.

### Change type

- [x] `api`

### API changes

- Removed `editor.spatialIndex` property from public API
- Changed `editor.getShapeIdsInsideBounds()` from `@public` to
`@internal`
- Changed `SpatialIndexManager` class from `@public` to `@internal`

### Test plan

1. Existing tests pass
2. Internal usages in brushing/erasing tools still work

### Release notes

- Remove `editor.spatialIndex` from public API

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Makes spatial indexing an internal implementation detail and removes
it from the public API.
> 
> - Mark `SpatialIndexManager` as `@internal`; update class docs
accordingly
> - Remove public `editor.spatialIndex`; introduce private
`editor._spatialIndex` and update usages in hit-testing and bounds
queries
> - Mark `editor.getShapeIdsInsideBounds()` as `@internal` and clarify
its bounds-only behavior
> - Public querying remains via `editor.getShapeAtPoint()` /
`editor.getShapesAtPoint()`
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
f366bfb. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Product improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants