@mocanvas/editor 1.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ # mocanvas architecture
2
+
3
+ mocanvas is an infinite-canvas SDK with an API surface and document format that
4
+ a project using tldraw can migrate to with a mechanical find-and-replace. The
5
+ engine is written from scratch. The hot paths (geometry, hit-testing, spatial
6
+ indexing, culling, tessellation) live in Rust compiled to WebAssembly; the
7
+ document model, tools, and UI live in TypeScript.
8
+
9
+ ## Goals
10
+
11
+ - Drop-in-shaped API: `Editor`, `ShapeUtil`, `StateNode`, record store with
12
+ `shape:` / `page:` ids, `.tldr` load and save.
13
+ - 10× fewer JS objects touched per frame than a DOM/React-per-shape design.
14
+ - 100k shapes on a page at 60 fps for pan/zoom, 10k selected shapes dragged at
15
+ 60 fps, sub-millisecond hit-testing.
16
+ - Renderer backend behind an interface: WebGL2 today, WebGPU later.
17
+ - Custom shapes as React components still work (DOM overlay).
18
+
19
+ ## Non-goals (v1)
20
+
21
+ - Wire compatibility with tldraw's sync protocol.
22
+ - Slot-compatible UI components.
23
+ - Pixel-identical rendering of every built-in shape.
24
+
25
+ ## Clean-room rule
26
+
27
+ No tldraw source is read or referenced while building this. Only public API
28
+ documentation and sample `.tldr` documents inform names and formats. Every
29
+ dependency must be MIT/Apache/BSD/CC0 licensed. See `docs/CLEAN_ROOM.md`.
30
+
31
+ ## Layer split
32
+
33
+ ```
34
+ ┌────────────────────────────────────────────────────────────────────┐
35
+ │ apps/playground │
36
+ ├────────────────────────────────────────────────────────────────────┤
37
+ │ mocanvas default shapes, default tools, default UI │
38
+ ├────────────────────────────────────────────────────────────────────┤
39
+ │ @mocanvas/editor Editor, ShapeUtil, StateNode, camera, │
40
+ │ selection, snapping, React <Canvas/>, │
41
+ │ renderer frontend (WebGL2 backend) │
42
+ ├──────────────────────────┬─────────────────────────────────────────┤
43
+ │ @mocanvas/store │ @mocanvas/wasm (glue over crates/) │
44
+ │ records, ids, schema, │ scene mirror, geometry, BVH, hit-test, │
45
+ │ history, .tldr IO │ culling, tessellation → GPU buffers │
46
+ ├──────────────────────────┴─────────────────────────────────────────┤
47
+ │ @mocanvas/state signals: atom / computed / react / tx │
48
+ └────────────────────────────────────────────────────────────────────┘
49
+ ```
50
+
51
+ ### Ownership: who holds the truth
52
+
53
+ | Concern | Owner | Why |
54
+ | ------------------------------------ | ----- | -------------------------------------------------- |
55
+ | Records (`props`, `meta`, ids, pages) | TS | Custom shapes carry arbitrary JSON; compat requires JS objects from `getShape()`. |
56
+ | Undo/redo history | TS | Operates on record diffs; not a hot path. |
57
+ | Transform, parent, z-order, flags | WASM | Mirror of the record fields that geometry depends on. Struct-of-arrays. |
58
+ | Shape outline geometry | WASM | Built-ins computed in Rust from props; custom shapes upload a path command buffer. |
59
+ | Bounds, spatial index, culling | WASM | Bulk numeric work. |
60
+ | Hit-testing, snapping candidates | WASM | Called on every pointer move. |
61
+ | Tessellated meshes, GPU buffers | WASM | Written directly into linear memory; JS uploads by pointer. |
62
+ | Tools (state machines) | TS | Must be user-extensible with the `StateNode` API. |
63
+ | Text layout | TS → WASM (phase 2) | Phase 1 DOM overlay; phase 2 glyph atlas in WASM. |
64
+
65
+ The TS store is canonical. Every committed change to a shape record is pushed
66
+ to WASM as a compact binary command. WASM never mutates the document; it only
67
+ mirrors and derives.
68
+
69
+ ## Bridge ABI
70
+
71
+ All traffic crosses the boundary through a small number of calls per frame,
72
+ never per shape.
73
+
74
+ ### Handles
75
+
76
+ Each record id (`shape:abc`) is interned once to a `u32` handle in TS
77
+ (`HandleTable`). WASM only ever sees handles. Handle 0 is reserved (null).
78
+
79
+ ### Command buffer (TS → WASM)
80
+
81
+ A `Uint32Array`/`Float32Array` pair over one `ArrayBuffer` that TS fills during
82
+ a store transaction and flushes once with `engine.apply(cmdPtr, len)`. Opcode
83
+ layout (`u32` words; floats are bit-cast):
84
+
85
+ ```
86
+ UPSERT_SHAPE op=1 handle kind parent zkey_lo zkey_hi flags x y rot w h (12 words)
87
+ REMOVE_SHAPE op=2 handle (2 words)
88
+ SET_GEOMETRY op=3 handle nwords [path command words...] (3+n words)
89
+ SET_STYLE op=4 handle fill stroke stroke_w_f32 dash opacity_f32 seed (8 words)
90
+ CLEAR op=5 (1 word)
91
+ SET_TEXTURE op=6 handle texture (3 words)
92
+ SET_GEO op=7 handle kind flags w_f32 h_f32 stroke_w_f32 (7 words)
93
+ SET_SPLINE op=8 handle flags npoints [x y (f32)]... (4+2n words)
94
+ SET_POLY op=9 handle flags npoints [x y (f32)]... (4+2n words)
95
+ SET_DRAW op=10 handle flags nsegs [segflags npoints (x y f32)...]...
96
+
97
+ Word counts include the opcode.
98
+ ```
99
+
100
+ `geo_flag` bits: `FLIP_X=1`, `FLIP_Y=2` on `SET_GEO`; `CLOSED=1` on
101
+ `SET_SPLINE` / `SET_POLY` / `SET_DRAW`; `FREEHAND=1` per `SET_DRAW` segment.
102
+
103
+ `SET_GEO`'s stroke width reaches only the open marks drawn *inside* an outline —
104
+ the X of an x-box, whose ends sit on the corners and whose round caps would
105
+ otherwise spike half a stroke through the box. The outline itself does not move
106
+ with it, and a kind with no such marks ignores it.
107
+
108
+ **Opcodes 7-10 are the parametric fast path.** A built-in shape sends the
109
+ numbers that *describe* its outline and the engine generates it, instead of the
110
+ host building a `Geometry2d` in JavaScript and uploading its vertices. A shape
111
+ util opts in by implementing `getEngineGeometry`; returning `undefined` — the
112
+ default, and what every custom shape does — keeps `SET_GEOMETRY`, which is why
113
+ opcode 3 is not going away.
114
+
115
+ Measured at 20,000 shapes (`pnpm --filter bench geo-microbench`): host-side work
116
+ drops 10-20×, total time 1.5-1.6×, and the command stream shrinks 1.4-4.6×,
117
+ because a rectangle travels as `(kind, w, h)` rather than as its vertices. The
118
+ engine half is unchanged — it is dominated by the spatial reindex, not by path
119
+ construction, so building the outline in Rust is close to free.
120
+
121
+ The generators live in `crates/mocanvas-geo/src/shapes.rs` and cover all 20 geo
122
+ kinds, cubic splines and freehand smoothing. They compute in `f64` and narrow to
123
+ `f32` only when a coordinate enters the path, exactly as the host does, so the
124
+ two are **byte-identical** — pinned by `crates/mocanvas-geo/tests/ts_parity.rs`
125
+ against fixtures generated from the host implementation itself
126
+ (`apps/bench/scripts/dump-shape-fixtures.mts`), not hand-typed.
127
+
128
+ An unknown geo kind is consumed and reported through `take_error` rather than
129
+ desynchronising the stream, and leaves the shape's previous outline in place.
130
+
131
+ `flags` bits: `HIDDEN=1`, `LOCKED=2`, `OVERLAY=4` (DOM only), `NO_FILL=8`,
132
+ `LABEL=16` (GPU + DOM label), `CLIP=32`. A `CLIP` shape (frames) clips every
133
+ descendant to its page-space geometry AABB; the nearest clipping ancestor wins
134
+ and nested clips intersect. The shape itself is not clipped by its own rect.
135
+
136
+ `SET_TEXTURE` sets the host texture id of a shape's fill (`0` = none) and is
137
+ independent of `SET_STYLE`, which keeps the current texture. With a texture the
138
+ fill mesh is replaced by one quad over the shape's local geometry bounds with
139
+ `uv` `(0,0)` at the min corner and `(1,1)` at the max corner, coloured
140
+ white × opacity; the stroke is drawn as usual. Texture ids are allocated by the
141
+ host and uploaded through `RenderBackend.uploadTexture(id, source)`.
142
+
143
+ `kind` is a `u16` shape-kind id registered at startup for each `ShapeUtil`.
144
+ `zkey` is the fractional index converted to a 64-bit sortable key (see
145
+ `@mocanvas/store` `zkey.ts`). Path command words follow `mocanvas-geo::PathCmd`
146
+ (MoveTo=0, LineTo=1, QuadTo=2, CubicTo=3, Close=4) followed by their f32 args.
147
+
148
+ ### Frame (WASM → TS)
149
+
150
+ ```
151
+ engine.frame(cam_x, cam_y, cam_z, vp_w, vp_h, tess_budget) -> FrameInfo
152
+ engine.frame_version() -> f64 // bumped only when the buffers are rebuilt
153
+ engine.frame_dirty() -> bool // did this call rebuild them?
154
+ engine.frame_pending() -> bool // shapes still queued behind the budget
155
+ ```
156
+
157
+ `FrameInfo` exposes pointers and lengths into WASM memory for:
158
+
159
+ - `vertices: f32[]` interleaved `x y u v r g b a` (8 floats, 32-byte stride);
160
+ solid geometry has `u = v = 0`
161
+ - `indices: u32[]`
162
+ - `batches: u32[]`, 8 words each:
163
+ `first_index index_count texture clip_minx clip_miny clip_maxx clip_maxy
164
+ isolate`. The clip words are page-space f32 bits; all four zero = unclipped.
165
+ A new batch starts whenever the texture, the clip rect or the isolation group
166
+ changes.
167
+
168
+ `isolate` is 0 for an ordinary batch. Non-zero means the batch is one
169
+ translucent shape's whole mark and the backend must cover each of its pixels
170
+ once — the engine bakes opacity into vertex alpha, so a stroke crossing itself
171
+ would blend twice and show the crossing as a dark knot, where every other
172
+ renderer treats shape opacity as a group. Only a mark that is a single colour
173
+ gets a group (a stroke with no fill and no texture): painting once equals
174
+ compositing the group exactly when the group has one member. The number is
175
+ unique per frame and rises in draw order; the WebGL2 backend uses it as a
176
+ stencil reference, and clears the stencil when 8 bits of it wrap.
177
+ - `overlay: u32[]`, 10 words each, for visible shapes that need the DOM
178
+ overlay, in z-order: `handle x y w h rot clip_minx clip_miny clip_maxx
179
+ clip_maxy` as f32 bits, with `x y w h` the page-space bounds and the clip
180
+ rect as in `batches`.
181
+
182
+ Shapes whose page bounds fall entirely outside their clip rect are culled and
183
+ never reach either buffer. Level-of-detail quads and textured quads carry the
184
+ clip like any other geometry.
185
+
186
+ The vertex data is page space and the camera is a shader uniform, so the buffers
187
+ do not depend on the camera. `frame()` therefore reuses the previous build while
188
+ the scene epoch is unchanged, the viewport is still inside the box that build
189
+ covered, and the zoom is in the same √2 bucket; it then leaves the buffers alone
190
+ and reports `frame_dirty() == false`, and the host skips re-uploading them.
191
+ `engine.set_viewport_pad(p)` grows the built box by a fraction of the viewport so
192
+ that panning can reuse it too — off by default, because the pad submits
193
+ `(1 + 2p)²` more geometry on every frame in exchange for skipping uploads on some
194
+ of them, which only pays when uploads are expensive relative to per-triangle cost.
195
+
196
+ `tess_budget` caps how many shapes one call may tessellate (0 = no cap, default
197
+ `DEFAULT_TESS_BUDGET`). Shapes past the cap are drawn as level-of-detail quads and
198
+ picked up by a later frame; `frame_pending()` stays true until the backlog clears,
199
+ and the host keeps scheduling frames while it does. This trades a brief flat-quad
200
+ stand-in for the multi-hundred-millisecond stall a viewport full of never-seen
201
+ shapes used to cause.
202
+
203
+ TS wraps these in typed-array views (no copy) and issues one
204
+ `bufferSubData` (only when `frame_version()` moved) + one `drawElements` per batch. The WebGL2 backend binds the
205
+ batch's texture (a 1×1 white texture for `0`, sampled in the fragment shader)
206
+ and, for clipped batches only, enables `SCISSOR_TEST` with the clip rect
207
+ mapped page → device pixels (`(p + cam) * zoom * dpr`, y flipped).
208
+
209
+ ### Queries
210
+
211
+ ```
212
+ engine.hit_test(x, y, tolerance, filter_flags) -> handle | 0
213
+ engine.query_box(minx, miny, maxx, maxy, mode) -> ptr,len of u32 handles
214
+ engine.bounds(handle) -> ptr to 4 f32
215
+ engine.selection_bounds(ptr_handles, len) -> ptr to 4 f32
216
+ ```
217
+
218
+ ## Render pipeline (per frame)
219
+
220
+ 1. TS: signals mark `camera` or `scene` dirty → `requestAnimationFrame`.
221
+ 2. WASM `frame()`: if the scene, viewport and zoom bucket still match the last
222
+ build, stop here and report the buffers unchanged. Otherwise: viewport box →
223
+ BVH query → visible handles sorted by zkey.
224
+ 3. For each visible shape whose `mesh_version != shape_version`, up to the
225
+ frame's tessellation budget: tessellate fill and stroke with lyon into a
226
+ per-shape mesh cache (local space). Shapes past the budget are drawn as
227
+ level-of-detail quads until a later frame reaches them.
228
+ 4. Append transformed vertices into the frame vertex buffer, batching by
229
+ texture. Shapes flagged `OVERLAY` skip the mesh and go into the overlay list.
230
+ 5. TS uploads the buffers (unless the engine reports them unchanged) and draws. Overlay list is diffed against the React
231
+ overlay layer, which positions DOM shapes with `transform` and passes
232
+ pointer events through except when editing.
233
+
234
+ Stroke widths are in page units and scale with zoom, so meshes never need
235
+ retessellation while zooming. Dash patterns (`dash` style word: 0 solid,
236
+ 1 dashed, 2 dotted, 3 draw) are applied in the tessellator. `dashed` and
237
+ `dotted` split the flattened outline into open dash subpaths before stroking.
238
+
239
+ `draw` — the default for every shape — replaces the outline with a hand-drawn
240
+ one. The flattened path is reduced to *anchors*: every detected corner, plus the
241
+ smooth runs between them walked and split whenever the arc covered reaches a
242
+ sixth of the subpath **or** the chord since the last anchor has bowed away from
243
+ the outline by more than 1.5% of the local radius of curvature. Bounding that
244
+ sagitta rather than fixing a piece count is what keeps a circle a circle — six
245
+ anchors make a hexagon however gently it is then perturbed, whereas the sagitta
246
+ rule spends anchors only where the outline actually bends and leaves straight
247
+ runs alone (a page-sized circle takes about twenty-five, a rectangle four). The
248
+ bound yields only to a floor of one stroke width per piece, where the chord error
249
+ hides under the stroke anyway; measured over circles of radius 4–200 and stroke
250
+ widths 0.5–11.5, the chord never misses by more than 1.5% of the radius or half a
251
+ stroke width, whichever is larger.
252
+
253
+ Each anchor is then nudged perpendicular to the local direction, and each span
254
+ between anchors is bowed sideways through a quadratic aimed at the outline's own
255
+ mid-point — so a curve reduced to a few anchors is followed rather than cut
256
+ across by its chords. Both amplitudes are capped by a fraction of the adjacent
257
+ span lengths and, on a smooth run, by 6% of the local radius of curvature
258
+ (estimated as span over turn), so a long straight edge still gets a visible bow
259
+ while a small circle is barely touched. The offsets come from a smooth field
260
+ indexed by *arc length* and periodic around the subpath rather than one draw per
261
+ anchor: that keeps the wobble's wavelength at about six lobes however densely
262
+ roundness asks the anchors to be placed, so a finely sampled circle reads as a
263
+ shaky hand and not as fur.
264
+
265
+ Anchors that are real corners (not points the resampling dropped on a smooth run)
266
+ are additionally pushed out along their bisector, past the true vertex, and every
267
+ interior anchor is cut back by a radius taken from the stroke width plus a little
268
+ of the shorter adjacent span, clamped to 0.35 of it — or, at a smooth anchor,
269
+ 0.12 of it, since there the bridge only has to hide the tangent step between two
270
+ spans and the wider corner radius would swallow the short spans roundness now
271
+ asks for. The cut is bridged by a quadratic whose
272
+ control point is where the two spans' tangents meet, which keeps the tangent
273
+ continuous across the join: aiming it at the vertex instead leaves a visible kink
274
+ at every anchor of a curved outline, and a control point that lands behind either
275
+ end folds the bridge into a cusp, so both are rejected in favour of the midpoint.
276
+ A closed subpath is emitted open, starting at its first corner and running a
277
+ little past it, so the outline overshoots where the pen came back around. Every
278
+ amplitude scales with the stroke width, not the shape, and the whole outline
279
+ stays within `DRAW_MAX_DEVIATION` (3) stroke widths of the true geometry — which
280
+ is itself untouched, and is what fills, bounds and hit-testing keep using.
281
+
282
+ A `draw` *shape* is exempt. Its points are a recorded pen movement already, so
283
+ sketching them a second time only adds bulges the hand never made;
284
+ `DrawShapeUtil.getRenderStyle` hands the engine the solid dash id while leaving
285
+ `props.dash` alone, so the style panel and a `.tldr` round trip still see `draw`,
286
+ and the shape's own `dashed`/`dotted` styles are unaffected. The engine sees only
287
+ paths and has no notion of which shape one came from, so the choice belongs to
288
+ the host.
289
+
290
+ Width variation is approximated by stroking that outline twice, at 0.85× and
291
+ 0.6× the nominal width over slightly different perturbations of the same anchors
292
+ (they share most of their jitter, so the two passes never drift apart far enough
293
+ to open a gap). A tapered single stroke was the alternative; lyon's stroker takes
294
+ one width per call, so tapering would mean tessellating the outline as a filled
295
+ ribbon by hand, and unlike a taper the double pass also reproduces the doubled-back
296
+ look of a pen going over a line twice. Outlines with more than 64 anchors drop to a
297
+ single full-width pass; that test reads only the geometry, so it can never flip
298
+ with zoom or shape count and make a shape shimmer. Over a mixed page the draw
299
+ style costs about 2.3× the triangles and 2.3× the tessellation time of plain
300
+ strokes (8.6 µs against 3.8 µs per shape, so a full 256-shape tessellation budget
301
+ is ~2.2 ms and a page of 5,000 draw-styled shapes is ~43 ms spread over 20
302
+ frames).
303
+
304
+ The wobble is deterministic: it comes from a `splitmix32` stream seeded by the
305
+ `seed` style word, which the host hashes from the shape's stable id (not its
306
+ engine handle, which is recycled). The mesh cache keys on the geometry version
307
+ alone, so the same shape has to perturb identically on every rebuild or it would
308
+ shimmer whenever it was re-tessellated.
309
+
310
+ Shapes flagged `LABEL` are drawn on the GPU *and* reported in the overlay
311
+ list, so a filled shape can carry a DOM text label.
312
+
313
+ ## Hybrid overlay compositing
314
+
315
+ GPU shapes are always below DOM shapes. Shapes that must interleave with DOM
316
+ shapes get promoted to the overlay layer as well (rendered by their
317
+ `ShapeUtil.component`, which built-ins also implement as a fallback). This is
318
+ the same compromise Figma makes for text editing and plugin UI.
319
+
320
+ ## Compatibility surface
321
+
322
+ See `docs/COMPAT.md` for the method-by-method map. Highlights:
323
+
324
+ - `Editor` methods keep tldraw names where the semantics match
325
+ (`createShapes`, `updateShapes`, `deleteShapes`, `select`, `getShape`,
326
+ `getSelectedShapeIds`, `screenToPage`, `zoomToFit`, `setCurrentTool`...).
327
+ - `ShapeUtil<T>` keeps `getDefaultProps`, `getGeometry`, `component`,
328
+ `indicator`, `onResize`, `canBind`, etc. `getGeometry` returns a
329
+ `Geometry2d` that serializes to a path command buffer.
330
+ - `StateNode` keeps `id`, `initial`, `children`, `onEnter`, `onExit`,
331
+ `onPointerDown/Move/Up`, `onKeyDown`, `transition`.
332
+ - Records: `typeName`, `id`, `type`, `x`, `y`, `rotation`, `index`,
333
+ `parentId`, `isLocked`, `opacity`, `props`, `meta`.
334
+ - `.tldr`: `{ tldrawFileFormatVersion, schema, records }` is read and written.
335
+ The mocanvas file writer emits the same envelope so files round-trip.
336
+
337
+ ## Repository layout
338
+
339
+ ```
340
+ crates/mocanvas-geo Vec2, Mat2d, Box2d, PathCmd, Bezier, hit tests, intersections
341
+ crates/mocanvas-scene SoA scene mirror, zkey ordering, BVH (rstar), queries
342
+ crates/mocanvas-render lyon tessellation, mesh cache, frame builder, batching
343
+ crates/mocanvas-wasm wasm-bindgen facade: Engine, command buffer, FrameInfo
344
+ packages/state signals
345
+ packages/store records, ids, zkey, schema, history, .tldr IO
346
+ packages/wasm build output + typed loader
347
+ packages/editor Editor, ShapeUtil, StateNode, Canvas, renderer frontend
348
+ packages/mocanvas default shapes/tools/UI, <Mocanvas/> component
349
+ apps/playground Vite app
350
+ ```
351
+
352
+ ## Phases
353
+
354
+ 0. Toolchain, monorepo, this document.
355
+ 1. Vertical slice: geo + scene + render crates, WASM facade, signals, store,
356
+ Editor with camera/selection, select/hand/geo/draw tools, WebGL2 renderer,
357
+ playground. `.tldr` load and save.
358
+ 2. (done) Text via DOM overlay, arrows and bindings, snapping, resize/rotate
359
+ handles, export SVG/PNG, clipboard, groups, style panel, dash patterns,
360
+ LOD quads, `@mocanvas/compat` aliases.
361
+ 3. (done) Image assets with GPU textures, text rasterised to textures, frame
362
+ clipping on the GPU, WebGPU backend, arrow/line/frame tools, the icon set,
363
+ and the `.tldr` compatibility layer for documents written by current
364
+ releases (`richText`, encoded draw segments).
365
+ 4. (done) Collaboration (`@mocanvas/sync`: presence, transports, a relay),
366
+ frame reuse and a tessellation budget, the migration guide, and the
367
+ benchmark against the reference implementation.
368
+
369
+ The hand-drawn stroke costs about 2.5x the triangles and the tessellation time
370
+ of a plain one, and it is the default style, so it is paid on most pages. The
371
+ per-frame tessellation budget keeps that off the critical path when shapes
372
+ first appear, but the extra triangles are transformed and rasterised every
373
+ frame; `docs/BENCHMARK.md` measures a 7-50% cost in the drag redraw path
374
+ against a plain stroke. Turning it off is a per-shape style change
375
+ (`dash: "solid"`), not a build flag.
376
+
377
+ Concurrent edits are merged by a per-field CRDT
378
+ (`packages/sync/src/crdt.ts`); what it does and does not promise is in
379
+ `packages/sync/README.md`.
380
+
381
+ ## Known performance defects
382
+
383
+ Two were found by measurement rather than by reading, and neither is fixed yet.
384
+ Both are recorded here because they are the kind of thing that reads as a hang
385
+ rather than as a slowdown.
386
+
387
+ **The spatial index degenerates on coincident boxes.** Creating 20,000 shapes
388
+ that all share a bounding box — every one at the origin — makes `Engine::apply()`
389
+ take about **1,660 ms instead of 25 ms**. The rstar index cannot split entries
390
+ whose boxes are identical, so the tree collapses. This is not an exotic input:
391
+ an app that creates shapes and positions them afterwards hits it directly, which
392
+ is what a paste, an import, a template instantiation or a generated layout does.
393
+ Candidate fixes, in the order worth trying: an infinitesimal deterministic jitter
394
+ applied *in the index only*, never to stored geometry; bulk-loading the index
395
+ when a batch arrives instead of inserting one at a time; or detecting
396
+ pathological overlap and falling back to a linear scan.
397
+
398
+ **Snapping rebuilds geometry the engine already holds.** `SnapManager` takes its
399
+ candidates from the spatial index — that part is fine — and then calls
400
+ `getShapePageBounds` per candidate, where `getShapeGeometry` is not memoised and
401
+ reconstructs an entire `Geometry2d` for a box the engine has to hand. Measured at
402
+ **0.92 µs per candidate**, which is nothing for a few hundred but about
403
+ **18.5 ms per drag frame** when zoomed out over a 20,000-shape page — missing
404
+ 60 fps on its own. The fix is a bulk read (`Engine::bounds_many`, shaped like the
405
+ existing `union_bounds`), not a Rust port of the snap loop: once the boxes are to
406
+ hand the loop is trivial arithmetic.
407
+
408
+ ## Assessed and deliberately left in TypeScript
409
+
410
+ **Arrow routing.** `resolveBody` needs the store — binding terminals, and the
411
+ bound shape's outline for the edge intersection — and the label rect needs canvas
412
+ text measurement, so a parametric arrow command would cover only the middle third
413
+ of the work. The payoff is small: an arrow's path is 10-40 words against a geo
414
+ shape's 13-90, and the host-side cost is around 1 µs each, so it would matter
415
+ only on a page of 20,000 *arrows*. Worth revisiting if a profile ever shows
416
+ arrows dominating.
417
+
418
+ **Text line breaking.** Breaks are decided from measurements taken by the
419
+ browser's own shaper, so moving the loop to Rust would mean shipping every glyph
420
+ advance across the boundary per measurement, and the results are already cached.
421
+ This becomes a candidate together with the phase-2 glyph atlas, not before.