@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.
- package/ARCHITECTURE.md +421 -0
- package/BENCHMARK.md +519 -0
- package/CLEAN_ROOM.md +50 -0
- package/COMPAT.md +282 -0
- package/CUSTOM_SHAPES.md +880 -0
- package/MIGRATION.md +807 -0
- package/README.md +4 -4
- package/UI.md +256 -0
- package/dist/index.d.ts +13091 -1419
- package/dist/index.js +12748 -2126
- package/dist/index.js.map +1 -1
- package/package.json +13 -15
package/ARCHITECTURE.md
ADDED
|
@@ -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.
|