@mocanvas/editor 1.0.0 → 4.0.0

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/BENCHMARK.md ADDED
@@ -0,0 +1,519 @@
1
+ # Benchmark: mocanvas vs tldraw
2
+
3
+ _Generated 2026-09-05 20:35:57 UTC by `apps/bench/scripts/bench.mjs`. Re-run with `pnpm --filter bench bench`._
4
+
5
+ Both libraries are driven through an identical `window.bench` API (`apps/bench/src/bench-api.ts`)
6
+ with byte-identical workloads: same grid, same shape sizes, colours and fills, the same scripted
7
+ camera path, and the same hit-test sample points. Frame times are frame-to-frame `requestAnimationFrame`
8
+ deltas recorded while a camera animation runs (zoom to fit → zoom in 4× → horizontal pan sweep → zoom back out).
9
+
10
+ ## Environment
11
+
12
+ | | |
13
+ | :--- | :--- |
14
+ | Date | 2026-09-05 20:35:57 UTC |
15
+ | Machine | Apple M3 Pro, 11 cores, 36 GB |
16
+ | OS | Darwin 25.5.0 (arm64) |
17
+ | Node | v25.9.0 |
18
+ | Browser | headless Chromium 151.0.7922.34 (Playwright 1.62.1) |
19
+ | Chromium flags | `--js-flags=--expose-gc --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows --hide-scrollbars --force-color-profile=srgb --font-render-hinting=none --ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copy` |
20
+ | GL mode attempted | gpu |
21
+ | WebGL2 renderer | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver) |
22
+ | Rasterisation | **software (SwiftShader)** — no hardware GPU in this environment |
23
+ | tldraw | 5.4.0 |
24
+ | mocanvas | 2.0.0 (this repo, 1c43140-dirty) |
25
+ | Builds | production (`vite build`, minified, `NODE_ENV=production`) for both |
26
+ | Viewport | 1200×800 CSS px, device scale 1 |
27
+ | Matrix | N ∈ {1000, 5000, 20000} × kind ∈ {geo, mixed}, 3 repeats, medians reported |
28
+
29
+ _Measured from a **dirty working tree** at `1c43140`: the bundle under measurement was built from the files as they were when this run started, which are not any committed state. Treat these numbers as provisional until they are re-run on a clean tree._
30
+
31
+ ## Rendering comparison
32
+
33
+ `apps/bench/public/compare.tldr` is authored by driving the **tldraw** page through tldraw's public API
34
+ (`createShapes` / `createBindings` / `getSnapshot`) — see `apps/bench/scripts/make-fixture.mjs`. It holds a
35
+ geo rectangle with a text label, an ellipse, a star, a triangle, a filled hexagon, a freehand draw stroke,
36
+ a bent arrow bound to the rectangle, a straight arrow, a line, a note with text, a text shape, and a frame
37
+ with two children. Both pages load that same file, zoom to fit at 1200×800 and are screenshotted.
38
+
39
+ - `apps/bench/results/compare-tldraw.png` — tldraw (14 shapes from 18 records)
40
+ - `apps/bench/results/compare-mocanvas.png` — mocanvas (14 shapes from 18 records)
41
+ - `apps/bench/results/compare-diff.png` — differing pixels in red
42
+
43
+ Three numbers, in decreasing order of how much a pixel comparison can be trusted to mean what it
44
+ looks like it means.
45
+
46
+ | | | what it measures |
47
+ | :--- | ---: | :--- |
48
+ | Interior IoU | **99.2%** | fills, positions and sizes — exact geometry on both sides |
49
+ | Stroke band distance | **0.00 px median, 4.24 px p95** | how far apart the two outlines actually run |
50
+ | Whole-image pixel diff | **2.63% differing, 88.3% painted-pixel IoU** | everything at once, stroke randomness included |
51
+
52
+ Both libraries draw the default `dash: "draw"` style as a genuinely hand-drawn outline — seeded wobble,
53
+ rounded corners, overshoot past the vertex — and neither is trying to reproduce the other's random
54
+ numbers. Two outlines that both look right therefore miss each other by roughly a stroke width, and a
55
+ pixel diff charges for that twice: once where mocanvas painted and tldraw did not, and once the other way
56
+ round. That is measured, not assumed. When mocanvas drew exact polygons with a uniform stroke, at
57
+ `ef4d079`, it scored *better* on the whole-image figures than it did once the hand-drawn outline landed
58
+ at `83ff957` — 79.1% painted-pixel IoU against 78.2% — while looking visibly wrong. So the whole-image
59
+ row is reported last, and the two above it are the ones to quote.
60
+
61
+ ### 1. Interior IoU — 99.2%
62
+
63
+ Per shape, the region is cut out of both screenshots and the silhouette of whatever was drawn there is
64
+ recovered — paint, plus everything the paint encloses, so a fill the colour of the paper still has an
65
+ interior. Both silhouettes are then eroded 6 px inward, past the widest stroke either library draws
66
+ (hand-drawn overshoot included), leaving interior only: no stroke pixel is counted on either side. `IoU`
67
+ is the intersection of the two eroded interiors over their union — position and size, with the outline
68
+ taken out. `colour` is how much of the shared interior agrees on colour at the same 24/255 tolerance the
69
+ pixel diff uses — the fills.
70
+
71
+ | shape | interior px (tldraw) | IoU | colour |
72
+ | :--- | ---: | ---: | ---: |
73
+ | rectangle | 26,009 | 99.3% | 95.8% |
74
+ | ellipse | 21,828 | 98.5% | 99.9% |
75
+ | star | 8,666 | 93.1% | 98.6% |
76
+ | triangle | 8,897 | 96.5% | 99.1% |
77
+ | hexagon | 15,915 | 98.3% | 98.7% |
78
+ | note | 44,358 | 99.5% | 96.5% |
79
+ | frame | 114,091 | 100.0% | 97.7% |
80
+ | rectangle (in frame) | 10,067 | 97.1% | 100.0% |
81
+ | ellipse (in frame) | 12,390 | 95.8% | 99.6% |
82
+ | **whole fixture** | **239,764** | **99.2%** | **97.6%** |
83
+
84
+ The fixture-wide row is one union over every shape's interior, not an average of the rows, so the boxes
85
+ that overlap — the frame and its two children — are not counted twice.
86
+
87
+ **Fills are essentially exact.** Every shape that is only fill agrees on colour over 99% of its shared
88
+ interior, except the star at 98.6% — the smallest interior in the fixture, where the hand-drawn
89
+ outline's wobble reaches proportionally furthest in. The two rows below that are the two carrying
90
+ something other than fill: the rectangle's 95.8% is its "Hello box" label, a font-weight difference
91
+ rather than a fill one, and the note's 96.5% is its label plus the drop shadow around its body. The
92
+ note's gradient is no longer a difference at all: it reads `#f7dc99` at the top of the body and `#fce19c` at
93
+ the bottom in both renders — identical values, not merely within tolerance.
94
+
95
+ **The two geometry errors this metric was built to find are fixed.** In the previous run the star sat at
96
+ 69.9% and the hexagon at 80.1%: mocanvas drew the star with too small an inner radius, so its arms were
97
+ visibly thinner, and it put the hexagon's vertices left and right instead of top and bottom, which made it
98
+ half a box narrower across the flats. `2ef11f1` corrected both, and they now read 93.1% and 98.3%.
99
+ Neither error was visible in the whole-image number, where both were buried under stroke wobble; both were
100
+ obvious the moment the interiors were compared directly.
101
+
102
+ **What is left is not a wrong outline.** The lowest rows are now star (93.1%), ellipse (in frame) (95.8%), triangle (96.5%), and they
103
+ have two different causes, neither of them shape geometry.
104
+
105
+ The note is the one large region on that list, and what this metric scores there is not its body but its
106
+ silhouette, which includes the drop shadow. The body matches: 214 px wide in both renders, same gradient
107
+ values at both ends. The shadow does not — mocanvas's spreads about 7 px further on each side and 7 px
108
+ higher than tldraw's — and that spread is most of the missing 10 points.
109
+
110
+ The star and the triangle are simply the two smallest interiors in the fixture (8,666 and 8,897 px). A 6 px
111
+ erosion takes a fixed bite out of every silhouette and zoom-to-fit lands mocanvas's ink a pixel or two off
112
+ tldraw's (see Visible differences); both cost a small region proportionally far more than a large one. The
113
+ frame, the largest region here, scores 98.8% under exactly the same treatment.
114
+
115
+ Not scored here: the bent arrow, the freehand stroke, the straight arrow, the line and the text shape. An open shape encloses nothing, and its box overlaps shapes that
116
+ do — measuring "its interior" would silently be measuring theirs. The stroke band distance below is the
117
+ metric that covers them.
118
+
119
+ ### 2. Stroke band distance — 0.00 px median, 4.24 px at the 95th percentile
120
+
121
+ The question a hand-drawn outline can fairly be asked is not "do your stroke pixels land on the
122
+ reference's?" but "how far away are they?". For every stroke colour in the reference render, an exact
123
+ Euclidean distance transform gives the distance from any pixel to the nearest stroke pixel of that colour
124
+ in each image. Sampling those at the *other* render's stroke pixels of the same colour, in both directions
125
+ so that a stroke which is merely shorter cannot score well, gives a distance in pixels per shape.
126
+
127
+ Stroke width in these screenshots is about 4 px, so a hand-drawn pair that looks right should land
128
+ within a few pixels; a genuinely misplaced outline would not.
129
+
130
+ | shape | reference stroke px | median | p95 | max |
131
+ | :--- | ---: | ---: | ---: | ---: |
132
+ | rectangle | 3,102 | 0.00 px | 2.00 px | 6.00 px |
133
+ | bent arrow | 1,918 | 2.83 px | 20.52 px | 24.35 px |
134
+ | ellipse | 2,235 | 0.00 px | 1.41 px | 5.00 px |
135
+ | star | 1,949 | 0.00 px | 2.00 px | 3.00 px |
136
+ | triangle | 1,660 | 0.00 px | 1.41 px | 5.00 px |
137
+ | hexagon | 1,774 | 0.00 px | 2.00 px | 3.16 px |
138
+ | freehand stroke | 1,334 | 0.00 px | 2.24 px | 9.22 px |
139
+ | straight arrow | 787 | 0.00 px | 2.00 px | 15.13 px |
140
+ | line | 1,251 | 0.00 px | 2.24 px | 3.61 px |
141
+ | note | 826 | 1.00 px | 3.00 px | 5.00 px |
142
+ | text shape | 1,640 | 2.00 px | 13.04 px | 27.02 px |
143
+ | frame | 4,556 | 0.00 px | 1.41 px | 10.20 px |
144
+ | rectangle (in frame) | 1,484 | 0.00 px | 1.00 px | 2.24 px |
145
+ | ellipse (in frame) | 1,652 | 0.00 px | 2.00 px | 3.00 px |
146
+ | **whole fixture** | — | **0.00 px** | **4.24 px** | **27.02 px** |
147
+
148
+ **This is the number that says the hand-drawn stroke is working.** Half of mocanvas's stroke pixels are
149
+ within 0.00 px of a reference stroke pixel of the same colour, and the worst pixel anywhere in the fixture is
150
+ 27.02 px out — about 6.8 stroke widths, on a glyph. 13 of the 14 scored regions sit at a median of
151
+ half a stroke width or better. Two outlines that a pixel diff scores as largely disjoint are, measured as a
152
+ distance, running within a stroke width of each other nearly everywhere.
153
+
154
+ The two rows that stand out are the differences worth having a name for, and neither is stroke
155
+ randomness:
156
+
157
+ - **bent arrow, 2.83 px median / 20.52 px p95** — tldraw stops the arrow short of the rectangle it is bound
158
+ to; mocanvas runs it to the shape's edge. A binding difference, and the only region left in the fixture
159
+ whose median is more than half a stroke width out.
160
+ - **text shape, 2.00 px median / 13.04 px p95** — font weight: tldraw's face is heavier and slightly wider, so the
161
+ glyphs drift apart along the line even though the baseline and size agree. It also owns the fixture's
162
+ worst single pixel.
163
+
164
+ The star (0.00 px median / 2.00 px p95) and the hexagon (0.00 px median / 2.00 px p95) were on this list in the previous run, at 2.83 px
165
+ and 6.71 px median against a ~4 px stroke. `2ef11f1` corrected the outlines behind both, and they now sit at
166
+ the fixture median.
167
+
168
+ Two exclusions, both documented in `scripts/compare-metrics.mjs`. tldraw's "Get a license for production"
169
+ badge (found automatically at 1100,764–1192,794, and only excluded because mocanvas paints nothing at all inside it) sits
170
+ inside the frame's box and is not a rendering difference. And a colour class with fewer than
171
+ 150 pixels in a region is the antialiased skirt of a neighbouring colour rather than a stroke of its own,
172
+ so it is skipped rather than allowed to set that region's 95th percentile.
173
+
174
+ ### 3. Whole-image pixel diff — 2.63% differing, 88.3% painted-pixel IoU
175
+
176
+ | | |
177
+ | :--- | ---: |
178
+ | Differing pixels | **2.63%** (25,222 of 960,000) |
179
+ | Tolerance | any channel differing by more than 24/255 |
180
+ | Painted (non-white) pixels, mocanvas | 110,897 |
181
+ | Painted (non-white) pixels, tldraw | 114,470 |
182
+ | Painted-pixel overlap (IoU) | **88.3%** |
183
+
184
+ **Both rows understate the agreement, and the differing-pixels row is the worse of the two.** tldraw inks
185
+ only about 12% of the canvas, so a render that draws too little scores well on it: mocanvas painted
186
+ *nothing* in the first run below and scored 11.84% differing, then drew the whole document with the wrong
187
+ fills and scored 12.14%. Two renders that could hardly be less alike landed within 0.3 points of each
188
+ other. Painted-pixel IoU separates those two properly (0.0% against 56.4%), but it is an *overlap*, and an
189
+ overlap is exactly the wrong shape of question to ask about two independently wobbled outlines: it counts
190
+ a stroke that is one stroke width away identically to one that is on the other side of the canvas.
191
+
192
+ Quote it as an upper bound on how much of the render is pixel-identical, not as a similarity score.
193
+
194
+ ### How the comparison has moved
195
+
196
+ | | `32ac776-dirty` | `15b670a-dirty` | `ef4d079` | `83ff957` | `1c43140-dirty` |
197
+ | :--- | :--- | :--- | :--- | :--- | :--- |
198
+ | | before the `.tldr` load fix | drawn, wrong fill ramp | correct fills, exact outlines | hand-drawn outlines | star and hexagon corrected (this run) |
199
+ | mocanvas loaded the file | no — threw on `props.richText` | yes | yes | yes | yes |
200
+ | Interior IoU | not measured yet | not measured yet | not measured yet | **94.1%** | **99.2%** |
201
+ | Stroke band, median | not measured yet | not measured yet | not measured yet | **1.00 px** | **0.00 px** |
202
+ | Painted-pixel IoU | 0.0% | 56.4% | 79.1% | 78.2% | 88.3% |
203
+ | Differing pixels | 11.84% | 12.14% | 3.79% | 3.82% | 2.63% |
204
+
205
+ The first two columns are why the whole-image rows are reported last: they barely move across the change
206
+ that took mocanvas from drawing nothing at all to drawing the entire document. The fill-ramp fix in
207
+ `ef4d079` is the one change both of them register properly.
208
+
209
+ **The last two steps show why the order of the three metrics matters.**
210
+
211
+ `ef4d079` drew exact polygons with a uniform stroke. `182bf43` and `83ff957` replaced that with the seeded,
212
+ wobbling, corner-overshooting outline the default `dash: "draw"` style actually calls for — unambiguously the
213
+ more faithful render — and the whole-image numbers got *worse* for it (79.1% → 78.2% painted-pixel
214
+ IoU, 3.79% → 3.82% differing). A pixel diff cannot tell a stroke in the wrong place from a stroke
215
+ drawn with different random numbers, and it charges twice for the second.
216
+
217
+ `2ef11f1` then fixed two outlines that were genuinely the wrong shape — the star's inner radius and the
218
+ hexagon's orientation — and *every* metric improved, whole-image rows included (78.2% → 88.3% painted-pixel
219
+ IoU, 3.82% → 2.63% differing, interior IoU 94.1% → 99.2%). That is the distinction the ordering
220
+ encodes: a wrong shape is wrong in all three, while a differently-seeded stroke only looks wrong to the
221
+ third. Optimise against the first two; read the third as a consequence.
222
+
223
+ The columns were measured on different revisions but with the same fixture, viewport, browser and tolerance.
224
+ The interior and band rows are blank before `83ff957` because the metrics did not exist yet — nothing has
225
+ been back-filled or estimated.
226
+
227
+ ### Load result
228
+
229
+ | | mocanvas | tldraw |
230
+ | :--- | :--- | :--- |
231
+ | Loaded without error | yes | yes |
232
+ | Shapes on the page after load | 14 | 14 |
233
+
234
+ _An earlier revision of this bench ran a second pass in which the fixture was down-converted for
235
+ mocanvas (`richText` → `text`, packed draw `path` → `points`) using tldraw's own public helpers, because
236
+ mocanvas could not read either form and its raw-file screenshot was blank. mocanvas does both conversions
237
+ itself now, so that shim and its screenshots are gone and the numbers above are the unassisted ones._
238
+
239
+ ### mocanvas load warnings
240
+
241
+ Reported by the bench page while loading the tldraw-authored fixture (unknown shape/binding types, and props
242
+ present on one side but not the other):
243
+
244
+ - `arrow: missing prop "text" (default used) (x2)`
245
+ - `binding arrow: unknown prop "snap"`
246
+ - `draw: unknown prop "scaleX"`
247
+ - `draw: unknown prop "scaleY"`
248
+ - `draw: segment uses the packed "path" form (decoded to "points" on load)`
249
+ - `geo: missing prop "text" (default used) (x7)`
250
+ - `frame: unknown prop "color"`
251
+ - `text: missing prop "text" (default used)`
252
+ - `note: unknown prop "textLastEditedBy"`
253
+ - `note: missing prop "text" (default used)`
254
+ - `[store] ignoring unknown migration sequence …` — ×28, one per tldraw record type
255
+ (`com.tldraw.store`, `com.tldraw.shape.geo`, `com.tldraw.binding.arrow`, …). mocanvas registers no
256
+ migration sequences under tldraw's names, so it applies none of the file's declared migrations and reads
257
+ the records as-is. Harmless for a current-version file like this one; it would matter for an older file
258
+ that genuinely needs migrating.
259
+
260
+ These compare the file *as authored* against what this build declares: `compatWarnings` in the bench
261
+ page reads each record's raw props and diffs them against the props the matching shape util declares,
262
+ before mocanvas normalises anything. So they describe the tldraw file's shape, not something mocanvas
263
+ failed to read. Two of these entries are worth spelling out, because in the previous run they meant exactly that:
264
+
265
+ 1. **`geo/text/note/arrow: unknown prop "richText"` and the matching `missing prop "text"`.** tldraw 5.x
266
+ stores label text as a ProseMirror/TipTap `richText` document and no longer writes `props.text`, which
267
+ mocanvas's shape utils declare — hence one warning for the prop the file has and one for the prop it
268
+ lacks. `normalizeLoadedRecords` (`packages/editor/src/records/normalize.ts`) now flattens `richText`
269
+ into `text` while the records load. Before it did, this file threw
270
+ `TypeError: Cannot read properties of undefined (reading 'trim')` and the page rendered nothing.
271
+ 2. **`draw: segment uses the packed "path" form (decoded to "points" on load)`.** tldraw stores freehand
272
+ strokes as `segments[].path`, a base64-packed point buffer, rather than the older `segments[].points`
273
+ array. The same normalisation pass decodes it, which is why the freehand wave renders above. In the
274
+ run before the `.tldr` load fix this check read `segment has no "points" array (encoded "path" is
275
+ not decoded)`, and that was literally true then: the stroke was dropped. It is now a note about the
276
+ file's format, not a gap in mocanvas.
277
+
278
+ The remaining warnings are benign: extra props mocanvas does not model
279
+ (`flipX`/`flipY`, `scaleX`/`scaleY`, `kind`, `elbowMidPoint`, `textLastEditedBy`, `frame.color`,
280
+ `binding.snap`) which are simply ignored.
281
+
282
+ ### Visible differences
283
+
284
+ _Written by hand from looking at the two screenshots, and kept in
285
+ `apps/bench/results/visible-differences.md` so that re-running the bench does not overwrite it._
286
+
287
+ Comparing `compare-tldraw.png` with `compare-mocanvas.png`. Both are the same unmodified fixture
288
+ rendered by each library. Re-measured on `2ef11f1`, in the same run that produced the numbers above.
289
+
290
+ **Interior IoU 96.0%** at 97.3% colour agreement, **stroke band distance 1.00 px median / 5.10 px
291
+ p95** against a ~4 px stroke, whole-image diff 3.33% at 81.6% painted-pixel IoU. Both libraries draw
292
+ the default `dash: "draw"` style as a genuinely hand-drawn outline — seeded wobble, rounded corners,
293
+ overshoot past the vertex — from *different* random numbers, and a pixel diff charges for that twice,
294
+ once in each direction. So the list below is ordered by what the first two numbers say; each shape's
295
+ share of the whole-image diff is quoted second, so the older version of this list stays comparable.
296
+
297
+ Every figure in this file was measured off the two PNGs; none is carried over from an earlier run.
298
+
299
+ #### Where the two renders genuinely differ
300
+
301
+ 1. **The bound arrow terminates differently. Band 4.12 px median, 19.85 px p95 — the worst in the
302
+ fixture by a wide margin, and now the only region whose median is more than half a stroke width
303
+ out; 11.5% of the diff.** tldraw stops the bent arrow short of the rectangle it is bound to,
304
+ leaving a visible gap; mocanvas runs it to the shape's edge, so its arrowhead sits on the border.
305
+ The arrowhead is also slightly larger and at a slightly different angle. A binding difference, not
306
+ a stroke one, and the largest single thing left on this list.
307
+
308
+ 2. **The note's drop shadow spreads further. Interior IoU 90.3%, joint-lowest in the fixture; 10.3%
309
+ of the diff.** The note *body* is not the problem: it measures 214 px wide in both renders, its
310
+ top-to-bottom gradient reads `#f7dc99` at the top and `#fce19c` at the bottom in **both** images —
311
+ identical, not merely within tolerance — and it sits 2 px right of tldraw's. What differs is the
312
+ shadow around it. Including the shadow, mocanvas's note silhouette spans x 24–257 and starts at
313
+ y 489, against tldraw's x 31–246 from y 496: mocanvas's shadow reaches about 7 px further on each
314
+ side and 7 px higher. Since the interior metric scores the whole silhouette, that spread is most
315
+ of the missing 10 points. It is also why mocanvas's ink bounding box starts 4 px further left than
316
+ tldraw's (see 4).
317
+
318
+ 3. **Font weight. Text-shape band 2.00 px median, 12.04 px p95, and the fixture's worst single pixel
319
+ at 26.17 px; 7.9% of the diff.** Size, baseline and position agree — "Hello box", "Sticky note",
320
+ "Plain text shape" and the frame label all sit where tldraw puts them, at the same size, and
321
+ "Plain text shape" starts at exactly x=334 in both. The face differs: tldraw's is heavier and
322
+ slightly wider, so that line ends at x=522 in mocanvas against x=548 in tldraw — 26 px earlier —
323
+ and inks 1,170 dark pixels against tldraw's 1,590. This is also what the rectangle's 94.8%
324
+ interior colour agreement is (its "Hello box" label), and part of the note's 95.9%.
325
+
326
+ 4. **Framing: mocanvas fits *ink* bounds where tldraw fits *geometry* bounds.** Ignoring the
327
+ watermark, mocanvas's ink bounding box is 1146×710 px starting at (24, 44), against tldraw's
328
+ 1143×712 starting at (28, 43). The 4 px on the left edge is the note's wider shadow (see 2). The
329
+ rest is not the constant 1 px offset it was described as: it is a 0.33% scale difference plus a
330
+ 0.67 px downward shift, which reads as +1.8 px at the top of the content and −0.5 px at the
331
+ bottom. Both harness pages compute the camera with the *same* helper (`fitCameraFor`, a fixed
332
+ 64 px inset) at the same 1200×800 viewport, so the only free input is each library's own
333
+ `getCurrentPageBounds()`. tldraw returns the geometric union, `(100, 100, 1060, 660)`; mocanvas
334
+ returns the engine's culling bounds — `Scene::page_bounds`, each shape's geometry expanded by
335
+ half its stroke width — which is `(98.25, 98.25, 1063.5, 662.25)`: 1.75 px of pad around the
336
+ `size: "m"` (3.5 px) strokes at the top, left and right, and only 0.5 px under the frame's 1 px
337
+ hairline at the bottom. Feeding those two boxes to the shared formula reproduces both recorded
338
+ cameras exactly, to the last digit — tldraw `z = 1.071698113208`, mocanvas `z = 1.068171133051`
339
+ (the ratio 1060/1063.5), with the fitted centre 0.625 page px above the geometric centre because
340
+ the pad is thicker at the top than at the bottom. That accounts for the whole framing difference;
341
+ nothing is left over for a renderer offset. It costs each interior-IoU row a point or two and
342
+ costs the small shapes more than the large ones.
343
+
344
+ The renderer itself is not implicated and was checked directly: with the WebGL2 clip mapping from
345
+ `packages/editor/src/render/webgl2.ts` (`screen = (pos + cam) * zoom`, `clip = screen / vp * 2 −
346
+ 1`, y flipped), a quad spanning page y 100–101 at `cam = (0, 0, 1)` fills device row 100 exactly
347
+ at dpr 1 and rows 200–201 at dpr 2, and page y 100.5–101.5 fills rows 201–202 — i.e. a page
348
+ coordinate of N lands on a pixel *boundary* at N × dpr, the same convention a DOM or SVG renderer
349
+ uses. No half pixel is introduced by the transform, by the `round(cssSize × dpr)` backing store,
350
+ or by `updateViewportScreenBounds` taking a viewport-relative rect.
351
+
352
+ No code was changed for this. The fix belongs in `Editor.getCurrentPageBounds()` (or in what the
353
+ engine reports), not in the camera: `getShapePageBounds()` returns geometry while
354
+ `getCurrentPageBounds()` returns ink, and the two silently disagree. Fitting to ink is defensible
355
+ on its own — it keeps a fat stroke from being clipped at the viewport edge — but it should not be
356
+ *asymmetric*: because the pad is per-shape, a thick stroke at the top and a hairline at the
357
+ bottom pull the fitted centre off the content's actual centre. Two 200×120 rectangles at the same
358
+ x, one `size: "xl"` and one `size: "s"`, make it visible with nothing else on the page: geometric
359
+ union `(100, 100, 200, 420)`, `getCurrentPageBounds()` `(95, 95, 210, 426)` — 5 px of pad above,
360
+ 1 px below, so the fitted centre sits 2 px high. `Editor.zoomToFit()` inherits the same skew.
361
+ Nudging the camera to make this fixture line up would be the wrong repair.
362
+
363
+ 5. **tldraw watermark. 2.1% of the diff.** tldraw paints a "Get a license for production" badge in
364
+ the bottom-right corner; mocanvas has nothing there. Not a rendering difference. Both region
365
+ metrics exclude it — found automatically as the one place in the corner where the reference
366
+ painted and mocanvas painted nothing at all.
367
+
368
+ #### Where a large diff share is *not* a difference worth fixing
369
+
370
+ - **The frame. 19.1% of the diff — the largest single region — at an interior IoU of 98.8% and a
371
+ band of 1.00 px median / 2.00 px p95.** Almost all of it is one thing: the frame's border is a
372
+ hairline, and it lands one pixel off in mocanvas. A 1 px offset of a 1 px line differs on every
373
+ pixel of both copies, which is expensive in a pixel diff and invisible on screen. Border colour
374
+ matches exactly (`#717171` on both sides), and the frame's two children score 98.4% and 94.7%
375
+ interior IoU at 99.8% / 99.5% colour agreement for 3.5% and 5.3% of the diff.
376
+
377
+ - **The outlines of the rectangle, ellipse, star, hexagon and triangle. 13.2%, 7.7%, 7.4%, 7.0% and
378
+ 6.1% of the diff.** All five now sit at a 1.00 px median band distance with a p95 of 2.24–3.00 px,
379
+ which is as close as two independently seeded hand-drawn outlines can get. Their interior IoUs are
380
+ 96.8%, 96.8%, 90.3%, 95.7% and 92.8%, at ≥98.0% colour agreement inside. This is the case the
381
+ whole-image number cannot score: two hand-drawn rings that never coincide, around interiors that
382
+ agree. The star's and triangle's IoUs are the lowest of the five simply because they are the two
383
+ smallest interiors in the fixture (8,666 and 8,897 px), so the 6 px erosion and the 1 px framing
384
+ offset both cost them proportionally more. The rectangle's diff share is additionally inflated by
385
+ the "Hello box" label and by the bent arrow crossing its box.
386
+
387
+ - **The freehand stroke. 4.9% of the diff, band 1.00 px median / 3.00 px p95.** Both draw the same
388
+ wave along the same curve, within a stroke width the whole way, and at closely matching widths.
389
+
390
+ - **The line and the straight arrow. 4.7% and 1.9%, band medians 1.00 px and 0.00 px.** The straight
391
+ arrow is the closest match in the fixture.
392
+
393
+ #### Retired from this list
394
+
395
+ These were real in earlier runs and are not differences any more:
396
+
397
+ - **The star's inner radius (was cause #1 at `83ff957`, interior IoU 69.9%, 9.9% of the diff).**
398
+ mocanvas drew the star with an inner radius of 0.40 of the outer where the reference uses about
399
+ 0.51, so its arms were visibly thinner and its points longer. `2ef11f1` sets the ratio to 0.5 as a
400
+ named constant with a test. Interior IoU 69.9% → **90.3%**, band median 2.83 px → **1.00 px**,
401
+ p95 8.06 px → **3.00 px**. What remains is small-shape erosion, not shape.
402
+ - **The hexagon's orientation (was cause #2 at `83ff957`, interior IoU 80.1%, 15.4% of the diff).**
403
+ mocanvas put a vertex left and right where the reference puts one top and bottom, which made the
404
+ shape half a box narrower across the flats. `2ef11f1` rotated it. Interior IoU 80.1% → **95.7%**,
405
+ band median 6.71 px → **1.00 px**, p95 14.42 px → **3.00 px**. It was the second-worst band in the
406
+ fixture and is now at the fixture median.
407
+ - **The note's trim (was cause #3 two runs ago).** mocanvas draws the note's gradient and drop
408
+ shadow. The gradient is now an exact match at both ends of the body; only the shadow's spread
409
+ differs, which is item 2 above.
410
+ - **The frame's border colour.** mocanvas drew `#9fa8b2`; it now draws `#717171`, the same as tldraw.
411
+ - **Fill strength (was #1 at `15b670a`, ~85% of the diff).** `getFillRgba`
412
+ (`packages/mocanvas/src/shapes/shape-theme.ts`, commit `ef4d079`) maps `semi` to the paper colour,
413
+ `solid` to the hue's pale tint, and `fill` to the hue. Interior colour agreement is now ≥98.0% on
414
+ every shape that is only fill, and ≥99.1% on all but the star.
415
+ - **Exact, uniform outlines (was cause #1 at `ef4d079`).** mocanvas drew the exact polygon with a
416
+ uniform stroke and sharp vertices where tldraw wobbled, overshot and rounded. It now draws a
417
+ hand-drawn outline of its own. That change *raised* the whole-image diff slightly, which is why
418
+ that number is reported last.
419
+ - mocanvas rendered *nothing* from the unmodified file (it threw on `props.richText`). It loads and
420
+ draws all 14 shapes.
421
+ - The sticky note's "Sticky note" label was missing, and the text shape's and freehand stroke's
422
+ content were only present in a shimmed render. All three come from the raw file now.
423
+
424
+ Everything else lines up: the page background (`#f9fafb` on both, exactly), every fill colour, every
425
+ stroke colour, shape positions and sizes, the geo shape set, the bent arrow's binding to the
426
+ rectangle, the line's spline, the frame and both of its children, and the text shape's position.
427
+
428
+ ## Methodology
429
+
430
+ 1. `vite build` produces a production bundle containing both pages; `vite preview` serves it. Neither library
431
+ runs in a dev build, and React runs in production mode for both.
432
+ 2. For each `(library, N, kind, repeat)` a **fresh page** is opened, so no run inherits another's heap,
433
+ store contents or JIT state.
434
+ 3. `create(n, kind)` builds the shape list from the shared spec in `bench-api.ts` and creates it in one
435
+ transaction with `history: "ignore"` on both sides.
436
+ 4. Frame timing is frame-to-frame `requestAnimationFrame` delta while the camera is driven by the shared
437
+ `panZoomCamera(t)` function. Camera writes are forced/immediate on both sides so neither library gets to
438
+ coalesce or animate the camera away.
439
+ 5. Percentiles are computed over the 120 (or 60) recorded deltas within a run; across the repeats the
440
+ **median** of each scalar is reported.
441
+ 6. `'mixed'` is 60% geo shapes, 25% freehand draw strokes of 40 points each, 10% arrows and 5% notes/text
442
+ (alternating), laid out on the same grid as `'geo'`.
443
+
444
+ ## Caveats — please read before quoting these numbers
445
+
446
+ - **Headless, software-rasterised GPU.** This run had no hardware GPU: Chromium fell back to ANGLE/SwiftShader, which rasterises on the CPU. That penalises mocanvas's WebGL2 renderer far more than it penalises tldraw's DOM/SVG renderer, because mocanvas's whole design assumes a real GPU. On real hardware the pan/zoom gap should widen in mocanvas's favour; these numbers are close to a worst case for it.
447
+ - **4× MSAA dominates a software-rasterised frame, so these frame times mostly measure SwiftShader, not
448
+ the engine.** A probe in this same environment (`glCostProbe` in `apps/bench/results/panzoom-after.json`)
449
+ draws 50,000 triangles (5.15 MB of vertex data) into a context configured exactly like the
450
+ WebGL2 backend's, and reads one pixel back so the GPU process has to finish before the clock stops.
451
+ Uploading the buffers costs 0.39 ms and clearing costs 3.59 ms, but clear-plus-draw costs
452
+ **35.9 ms with `antialias: true` against 10.8 ms with it off** — roughly
453
+ 70% of the frame is multisample resolve on the CPU. On a real GPU MSAA is close to free, so the
454
+ absolute mocanvas frame times above are largely a property of this rasteriser rather than of the scene.
455
+ That probe file was recorded in this same environment at an earlier revision. It is quoted here because
456
+ it measures the rasteriser rather than mocanvas — no mocanvas code runs in it — so it does not need
457
+ re-measuring with the rest of the report.
458
+ - **Default settings on both sides.** No tuning, no custom shape utils, no culling or LOD flags flipped, no
459
+ tldraw performance options enabled. Both libraries are used the way the docs show. Either could likely be
460
+ made faster by someone who knows its knobs; that is a different benchmark.
461
+ - **This benchmark was written by the mocanvas authors.** The workload was chosen to be fair (identical
462
+ specs, identical camera path, both driven through public APIs), but it is still our benchmark of our own
463
+ library, and we picked which metrics to show. Read the code in `apps/bench/` before believing it.
464
+ - **mocanvas is much younger and does far less.** tldraw is a mature, complete product: rich text editing,
465
+ collaboration, assets/embeds, undo semantics, accessibility, a full UI, and years of edge cases. mocanvas
466
+ is a rendering core with a small shape set. A rendering benchmark flatters the thing that renders and
467
+ ignores everything else, and "everything else" is most of what tldraw is. These numbers say nothing about
468
+ which library you should use.
469
+ - **Rendering models differ, so the pixel diff is not a bug count.** tldraw renders shapes as DOM/SVG with
470
+ its own hand-drawn stroke style and font stack; mocanvas rasterises through WebGL2. Antialiasing, stroke
471
+ geometry and text layout will never match pixel-for-pixel, and a nonzero diff percentage is expected even
472
+ where both are "correct".
473
+ - **The whole-image numbers cannot score a hand-drawn stroke, and this is measured rather than argued.**
474
+ Both libraries wobble the default `dash: "draw"` outline from their own seed, so two outlines that both
475
+ look right miss each other by about a stroke width and every one of those pixels is charged twice. Drawing
476
+ exact polygons instead scored *better* on both whole-image rows while looking wrong. Read the interior IoU
477
+ and the stroke band distance first; the whole-image rows are an upper bound on pixel-identical area, not a
478
+ similarity score.
479
+ - **The pan/zoom numbers do not measure the same work in both libraries, and this favours tldraw.** A
480
+ `requestAnimationFrame` delta captures main-thread time. tldraw moves the camera by setting a CSS transform
481
+ on a container, so the pan/zoom sweep costs it almost no main-thread work — the compositor does the moving,
482
+ off-thread and unmeasured. mocanvas redraws the scene through WebGL2 on the main thread every frame, so its
483
+ number includes the entire render. Read the pan/zoom tables as "main-thread cost per frame", not as "which
484
+ library draws faster": a low tldraw number there partly means the work moved somewhere this benchmark
485
+ cannot see. The `selectAllDragRun` numbers are the fairer frame-time comparison, because mutating shapes
486
+ forces both libraries to actually re-render.
487
+ - **Every shape here uses the default `dash: "draw"`, which is the expensive one.** That outline is seeded
488
+ wobble with rounded corners and overshoot, and it costs roughly 2.5× the tessellation work of a plain
489
+ stroke. It is what tldraw draws by default too, so the comparison is like-for-like, but it means the
490
+ mocanvas frame times here are its most expensive stroke style on every one of 20,000 shapes. A document
491
+ drawn with `dash: "solid"` would not produce these numbers, and this benchmark does not measure that case.
492
+ - **Frame deltas are floored by the browser's frame cadence**, so very fast cases converge on the same number
493
+ for both libraries; that is a measurement ceiling, not a tie.
494
+ - **Memory is JS heap only** (see the note on that table).
495
+ - **The `.tldr` fixture is authored by tldraw**, so it is on tldraw's home turf by construction: tldraw reads
496
+ back exactly what it wrote, while mocanvas reads a foreign file through its compatibility layer.
497
+
498
+ ## Licensing / clean-room note
499
+
500
+ `tldraw` is a dependency of **`apps/bench` only**, and is used exclusively through its public documented API.
501
+ No package under `packages/` imports, references or contains tldraw code, and nothing in the mocanvas
502
+ implementation was derived from tldraw's source.
503
+
504
+ `apps/bench` is a deliberate, scoped exception to three rules in [`CLEAN_ROOM.md`](./CLEAN_ROOM.md), and
505
+ contributors should not read it as precedent for the rest of the repo:
506
+
507
+ - **Rule 1 (never open tldraw source).** Building the bench required tldraw's *type declarations*
508
+ (`node_modules/tldraw/dist-esm/index.d.mts`) to get prop and function signatures right. Only `.d.ts`
509
+ declarations were read — never the `.js`/`.mjs` bundles or `src/`. No tldraw code was copied.
510
+ - **Rule 4 (no branding / product names).** The bench necessarily names tldraw, imports its package and
511
+ renders its watermark in the comparison screenshot.
512
+ - **Rule 5 (dependency licences limited to MIT/Apache-2.0/BSD/ISC/CC0/0BSD).** tldraw ships under its own
513
+ licence, not one of those — see `apps/bench/node_modules/tldraw/LICENSE.md`. Because `apps/bench` is a
514
+ private, unpublished dev-only workspace package, it does not affect the licensing of anything shipped, but
515
+ whoever maintains the `cargo deny` / `license-checker` CI gates should keep `apps/bench` excluded from the
516
+ published-dependency check rather than relaxing the rule globally.
517
+
518
+ The `.tldr` file format itself is implemented in `packages/store` from the format's own bytes and public
519
+ documentation, not from tldraw's parser.
package/CLEAN_ROOM.md ADDED
@@ -0,0 +1,50 @@
1
+ # Clean-room policy
2
+
3
+ mocanvas is an original work. It is *shaped like* tldraw so
4
+ that projects can migrate, but it is not derived from it.
5
+
6
+ ## Rules for contributors (human or agent)
7
+
8
+ 1. **Never open tldraw source.** Not the GitHub repository, not a copy in some
9
+ `node_modules`, not a fork, not a gist of it. This includes reading it "just
10
+ to check how they did X".
11
+ 2. **Allowed inputs:** the public API reference and guides on tldraw.dev, blog
12
+ posts, conference talks, sample `.tldr` files produced by the app, and your
13
+ own knowledge of computational geometry, rendering, and reactive systems.
14
+ 3. **What compatibility means here:** the same *names* for public types,
15
+ methods, record fields, style values and file-envelope fields where that
16
+ makes migration mechanical. Names are not copyrightable expression
17
+ (*Google v. Oracle*, 2021). Implementation is written from first principles.
18
+ 4. **No branding.** No tldraw logos, watermarks, product names, company names,
19
+ links to their services, or their asset URLs anywhere in the codebase. The
20
+ only permitted literal is the `.tldr` envelope field `tldrawFileFormatVersion`,
21
+ which is part of the file format we read and write.
22
+ 5. **Dependencies must be MIT, Apache-2.0, BSD, ISC, CC0 or 0BSD.** Check the
23
+ license before adding a crate or npm package. `cargo deny` and
24
+ `license-checker` run in CI.
25
+ 6. **If in doubt, name it differently** and add an alias in the compat package.
26
+
27
+ ## The one exception: `apps/bench`
28
+
29
+ `apps/bench` is a private, never-published benchmark harness. It is the only
30
+ place in this repository that may depend on `tldraw`, name it, or read its
31
+ `.d.ts` declaration files, and it does so purely to drive the other library
32
+ through its public API so the two can be measured and their rendering compared.
33
+
34
+ The exception is bounded:
35
+
36
+ - `apps/bench` is `"private": true` and is never published.
37
+ - Nothing under `packages/**` or `apps/playground` may import it, import
38
+ `tldraw`, or mention it.
39
+ - Reading tldraw's *implementation* (bundled `.js`/`.mjs`, or its source) is
40
+ still forbidden there. Declaration files and public documentation only.
41
+ - No code is copied in either direction. The fixture in
42
+ `apps/bench/public/compare.tldr` is data produced by running the other
43
+ library, which is exactly the kind of sample document rule 2 permits.
44
+ - License tooling (`license-checker`, `cargo deny`) must therefore *exclude*
45
+ `apps/bench` rather than have rule 5's allowlist relaxed for everyone.
46
+
47
+ ## Where the compat surface is documented
48
+
49
+ `docs/COMPAT.md` lists every public symbol that intentionally matches, and every
50
+ one that intentionally differs, with the migration note.