@weasel-js/core 1.0.0 → 1.0.2

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +604 -0
  2. package/dist/{chunk-D2C6K6FO.js → chunk-PI56ORHE.js} +2162 -1491
  3. package/dist/chunk-PI56ORHE.js.map +1 -0
  4. package/dist/{chunk-DCIU3SRK.js → chunk-W2WB4352.js} +79 -151
  5. package/dist/chunk-W2WB4352.js.map +1 -0
  6. package/dist/clipboard.d.ts +2 -2
  7. package/dist/clipboard.js +0 -1
  8. package/dist/clone.d.ts +2 -2
  9. package/dist/clone.js +0 -1
  10. package/dist/{geometry-DVeZ2i-c.d.ts → geometry-CnISZ11w.d.ts} +1 -1
  11. package/dist/{grid-XcFQVS0f.d.ts → grid-CBiLBnyc.d.ts} +2 -2
  12. package/dist/index.d.ts +202 -54
  13. package/dist/index.js +2 -3
  14. package/dist/insert.d.ts +3 -3
  15. package/dist/insert.js +0 -1
  16. package/dist/insert.js.map +1 -1
  17. package/dist/move.d.ts +5 -5
  18. package/dist/move.js +0 -1
  19. package/dist/move.js.map +1 -1
  20. package/dist/{options-wAvCnOZd.d.ts → options-DoOvx50U.d.ts} +1 -1
  21. package/dist/patterns-builtin.js +0 -1
  22. package/dist/{pointSnapToGrid-BuDZWBrL.d.ts → pointSnapToGrid-Bj4r_lEQ.d.ts} +3 -3
  23. package/dist/{index-ChM3ZJ2v.d.ts → registry-BQL4oyTg.d.ts} +140 -349
  24. package/dist/renderer.d.ts +63 -8
  25. package/dist/renderer.js +2 -3
  26. package/dist/resize.d.ts +5 -5
  27. package/dist/resize.js +0 -1
  28. package/dist/routing.d.ts +105 -6
  29. package/dist/routing.js +1 -2
  30. package/dist/{types-B_-khFM0.d.ts → types-Ch0Goo-j.d.ts} +12 -0
  31. package/dist/{types-BhJJ2OCM.d.ts → types-Ct4n0Z38.d.ts} +1 -1
  32. package/dist/{types-BJ8_cyT7.d.ts → types-Dtxe7jTS.d.ts} +20 -1
  33. package/dist/{types-DUpI7Apc.d.ts → types-Kh_osAq9.d.ts} +8 -1
  34. package/package.json +6 -6
  35. package/dist/chunk-D2C6K6FO.js.map +0 -1
  36. package/dist/chunk-DCIU3SRK.js.map +0 -1
  37. package/dist/chunk-PZ5AY32C.js +0 -9
  38. package/dist/chunk-PZ5AY32C.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,609 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 28710f2: The renderer's solid batch is drained at the sites that decide routing or
8
+ change stencil state, instead of at each emitter that happened to remember.
9
+ Geometry that cannot join a run now goes through `tryStageSolid`, which returns
10
+ `false` only after flushing — so the only way an emitter earns permission to
11
+ draw for itself is to have called the function that drained the batch. `pushClip`
12
+ and `popClip` flush as their first statement, since rasterizing into the stencil
13
+ is what creates the obligation.
14
+
15
+ Nothing about the drawn result changes; this removes a way for a future draw
16
+ path to paint under geometry staged before it.
17
+
18
+ - 2e3fea2: The `routing` namespace is gone from the main barrel, and the reflection
19
+ surface exports only what a consumer can use.
20
+
21
+ `export * as routing` made tool authoring reachable two ways — `core.routing.defineTool`
22
+ and the `@weasel-js/core/routing` subpath — and no consumer ever used the first.
23
+ It survived as the unfinished half of the 2026-05-12 declarative-routing work,
24
+ whose Phase 6 was to move `defineTool` out of `routing/` entirely. **Removing
25
+ it is a breaking change for anyone importing the namespace form**; the subpath
26
+ is unchanged and is what every known consumer already uses.
27
+
28
+ `tools/routing/reflection` now exports `buildRouteRegistry`, `findConflicts`,
29
+ `RegistryEntry` and `Conflict` — the four an external inspector needs.
30
+ `PREDICATE_TARGET`, `findScopedConflicts`, `formatConflict`,
31
+ `reportRouteConflicts` and `ToolScopes` are still there and still used; they
32
+ are just no longer public, since their only caller is inside the kit.
33
+
34
+ - 75ba7b1: Three fixes found by re-checking backlog entries against the code.
35
+
36
+ SVG gradients survive a round trip. The parser now reads `gradientUnits`
37
+ (`objectBoundingBox` → `units: 'bounds'`, `userSpaceOnUse` → `'world'`) and the
38
+ serializer writes back whichever the paint declares, instead of hardcoding
39
+ `userSpaceOnUse` on the way out — which had been reading a box-relative
40
+ gradient's `0..1` geometry as page coordinates, i.e. a gradient the size of a
41
+ pixel.
42
+
43
+ `unpackSvgFiles` keeps gradient fills instead of flattening them to a solid.
44
+ The reason recorded for the flattening — that the `kit:path` painter has no
45
+ gradient slot — had not been true for some time; `NodeFill` is
46
+ `string | FillStyle`, now exported. A `userSpaceOnUse` gradient is normalized
47
+ against the leaf's own box on the way in, so it survives the fit-clamp and
48
+ drop-point placement that move the geometry out from under it. Gradient
49
+ _strokes_ still flatten: `data.stroke` genuinely is a color string.
50
+
51
+ `extractUniformNames` skips precision and interpolation qualifiers. `uniform
52
+ highp float u_t;` — the common spelling in hand-written GLSL — matched nothing
53
+ at all, so the uniform got no location and every write to it was dropped in
54
+ silence. Comma-separated declarator lists (`uniform float a, b;`) read too.
55
+
56
+ Also adds `tests/visual/text-decoration.spec.ts`, a baseline-free assertion
57
+ that underline and strikethrough sit `0.40 em` apart and span only their own
58
+ runs. It measures the gap between two gap-free horizontal ink runs, which is
59
+ something `text.spec.ts`'s 5% diff tolerance cannot see move.
60
+
61
+ - e4a6ec4: Tool authoring moved out of `routing/` and onto the main barrel. `defineTool`,
62
+ `defineViewportTool`, `ToolDef`, `ViewportToolDef` and `ToolKeybinding` now come
63
+ from `@weasel-js/core`:
64
+
65
+ ```ts
66
+ import { defineTool, type ToolDef } from "@weasel-js/core";
67
+ ```
68
+
69
+ **This is a breaking change for anyone importing them from
70
+ `@weasel-js/core/routing`.** That subpath keeps the route grammar (`parseRoute`,
71
+ `formatRoute`, `describeRoute`, the gesture descriptors) and the reflection
72
+ consumers (`buildRouteRegistry`, `findConflicts`) — it is now only the surface
73
+ that _reads_ routes back, matching its name.
74
+
75
+ Finishes Phase 6 of the 2026-05-12 declarative-routing work. Source layout
76
+ follows: the factory and its `ToolDef` types sit at the top of `src/tools/`
77
+ alongside `useTools` and `useKeybindings`, with `routing/types.ts` renamed
78
+ `routeTypes.ts` to clear the existing `tools/types.ts`. Behavior and runtime
79
+ contract are unchanged.
80
+
81
+ - d2a9049: Six backlog entries, all in the input and hit-test layers.
82
+
83
+ Tool overlays can say where they sit. `Contribution.overlay` takes a
84
+ `RenderLayer` or an array of them, and a new `overlayPosition` (`'top'` — the
85
+ default — / `'before-selection'` / `'after-selection'`) anchors them against
86
+ the selection chrome instead of always landing on top; with no selection layer
87
+ in the stack the anchored positions fall back to the tail.
88
+ `getActiveOverlays(position?)` partitions, and `placeToolOverlays` in
89
+ `canvas/layerOrder.ts` does the splice, so the ordering is testable without a
90
+ GL context.
91
+
92
+ `drop` and `paste` are route-grammar gesture names. They shipped without them,
93
+ so the inspector reported `undefined` for every ingestion binding. Both are
94
+ targetless and carry the spec's MIME-glob filter as their arg — `drop(image/*)`.
95
+
96
+ An unhandled paste stays the page's. `onPaste` now dispatches first and calls
97
+ `preventDefault` only on `'handled'`, the shape `onWheel` already used.
98
+ Clipboard items materialize synchronously, so unlike `onDrop` the result is
99
+ known while the default can still be suppressed.
100
+
101
+ Marquee and lasso see the shape a node actually draws. `hitTestArea` asked the
102
+ _pose_ for a silhouette, which meant the kit's own inserted shapes — geometry
103
+ on `node.data.path` behind a plain `{x,y,w,h}` pose — took the AABB path and
104
+ kept every false positive the silhouette test exists to drop. It now asks
105
+ `findShapeSilhouette` for the drawn world-frame boundary. Paying for that is a
106
+ containment short-circuit: a node the rect marquee swallows whole is a hit no
107
+ silhouette can overturn, so neither the kernel nor the painter runs. Net
108
+ against the committed bench baseline, polygon-pose scenes gain (1.25x at 1000
109
+ nodes / 100% area) and plain-rect scenes lose ~8% on the two rows that scan
110
+ everything. The short-circuit is marquee-only, behind `hitTestAreaPolygon`'s
111
+ `areaIsRect` flag, because a node inside a lasso hull's bounding box can still
112
+ miss the hull.
113
+
114
+ Layout siblings reflowing mid-drag render opaque. `OngoingHandle.previewOpaqueIds()`
115
+ names the subset of `previewIds()` that skips the ghost alpha; `moveAction`
116
+ fills it from the destination and source reflow ids. A ghost means "in flight
117
+ under the pointer", which a sibling settling into its destination slot is not.
118
+
119
+ `LayoutStrategy.acceptsDrop(container, dragged)` rejects a drag before any
120
+ drop-target work, so a type-aware container falls through to whatever sits
121
+ under it rather than swallowing everything in its bounds.
122
+
123
+ `flipAction` reads `params.pivot`. `'each'` (the default, unchanged) mirrors
124
+ every pose about its own AABB; `'union'` mirrors about the selection envelope
125
+ so items swap sides, and the `geometryProjection` data op follows the same
126
+ pivot.
127
+
128
+ - 5f05431: `viewport.pinchZoom` now pans as well as zooms. It anchored `zoomAt` on the
129
+ current gesture centroid and never translated by the centroid delta, so two
130
+ fingers travelling together — spread unchanged, zoom factor 1 — moved the view
131
+ not at all.
132
+
133
+ Each frame now anchors the zoom on the previous centroid and then translates by
134
+ how far the centroid travelled. Together those pin the world point under the
135
+ gesture midpoint as the midpoint moves, which is what makes a pinch feel
136
+ attached to the fingers.
137
+
138
+ The action's id and label are unchanged (`viewport.pinchZoom` / `Pinch Zoom`).
139
+ Consumers who bound it get panning with no wiring change.
140
+
141
+ - 5fea43d: Five unrelated small fixes.
142
+
143
+ A tapered stroke with `align: 'inner'` or `'outer'` on a polygon path painted
144
+ at half its requested widths. That alignment renders by tessellating at twice
145
+ the width and stencilling half away, and the doubling reached `stroke.width`
146
+ but not `vertexWidths`. The doubled array is memoized per source array, since
147
+ the ribbon cache compares it by reference.
148
+
149
+ An image insert previews the decoded bitmap inside the drag bounds instead of
150
+ committing on release with no preview at all. It falls back to the bare
151
+ outline until the image decodes, and `useImageTool({ preview: 'outline' })`
152
+ opts out of the bitmap entirely.
153
+
154
+ A HUD widget that claims the pointer reports a `'pointer'` cursor without
155
+ implementing anything — hovering one while a drawing tool was active used to
156
+ keep showing that tool's cursor. The rule is keyed on the `claims` every
157
+ widget already declares, so it covers consumer-authored widgets too;
158
+ decoration claims nothing and the hit walk descends past it. A widget's own
159
+ `cursorAt` still wins, and `button` takes a `cursor` option that feeds it.
160
+
161
+ `composeAffordanceLayer`'s `hitTest` returns a `LayerHit`, carrying the hit
162
+ region's declared cursor and claim instead of dropping them. `AffordanceRegion`
163
+ gains optional `strength` / `claimedKinds` to declare that claim.
164
+
165
+ `ToolPalette` uses the shared `useRovingTabIndex` rather than its own container
166
+ handler, so arrow keys skip tools that are ineligible in the current mode and
167
+ the tab stop no longer sits on one. `ToolButton` takes an `onKeyDown`.
168
+
169
+ - 443d74e: Stroke ribbons are tessellated once per stroke configuration instead of once
170
+ per frame. The renderer rebuilt every stroked path's ribbon geometry on every
171
+ frame and discarded it; it now caches the result on `Path` identity, keyed by
172
+ the parameters that change the ribbon — width, cap, join, miter limit,
173
+ alignment, dash, and flatten tolerance. Paint and vertex colors are not in the
174
+ key, since both are applied over the same triangles at draw time.
175
+
176
+ A ribbon that survives a frame also stops paying for a fresh VAO and two
177
+ buffers on every subsequent frame. One whose path or stroke parameters change
178
+ each frame keeps the transient upload it had before, freed at end of frame —
179
+ so an animated or freshly drawn path never accumulates GL resources waiting on
180
+ garbage collection.
181
+
182
+ Nothing about the drawn result changes. The cache is keyed on object identity,
183
+ matching the fill cache: a `Path` rebuilt with equal coordinates is a distinct
184
+ entry and re-tessellates.
185
+
186
+ - 7decec1: Four more backlog fixes.
187
+
188
+ `@weasel-js/svg` reads and writes `<image>`. A new `SvgImageNode` holds the
189
+ `href` verbatim — an external URL or a `data:` URI, with `xlink:href` accepted
190
+ on the way in — plus a box that inherited transforms collapse onto and an
191
+ element-local rotation. `unpackSvgFiles` maps it onto the `kit:image` painter's
192
+ `data.image.src`, so a dropped SVG carrying raster content now keeps it instead
193
+ of dropping the element on parse. `preserveAspectRatio` is not modeled: the box
194
+ is taken literally and written back as `none`, and a non-`none` source warns.
195
+
196
+ `pickTopMostHit` resolves sibling z-order. An adapter can supply `getZIndex(id)`
197
+ or `compareZ(a, b)`; both compose with the existing parent/child collapse rather
198
+ than replacing it, so a child still beats its own ancestor whatever z the two
199
+ report. Without either, the hit list's own order decides, as before.
200
+
201
+ `useSceneTextEdit` supplies `setStyle`. Clearing a style flag that the _node_
202
+ sets is the one edit the additive run algebra can't express, and `useTextEdit`
203
+ declines it without a writer — so every scene-wired consumer silently refused
204
+ that toggle. Override the projection with `setStyle(data, style)` for a
205
+ non-default data shape.
206
+
207
+ The slops debug overlay draws handle halos at the real hit radius. Affordance
208
+ regions moved to screen-pixel radii, but this layer still scaled its circles by
209
+ the view, so at 4x zoom it drew a 32px halo over an 8px target — the one thing
210
+ a hit-test overlay must not do. Anchor slops now read the anchor radius rather
211
+ than the handle radius.
212
+
213
+ - 24daa08: Five unrelated backlog fixes.
214
+
215
+ The specificity tuple's `phase` dimension is graded rather than binary: an
216
+ atom scores 2 when both its channel and its lifecycle state are concrete, 1
217
+ when one axis is wildcarded, and 0 for `*:*`, which matches everything an
218
+ undeclared phase would have matched. An atom list takes the minimum, since
219
+ `matchPhase` is a union. No existing binding reorders — the four ambient
220
+ actions hold at 1, and the polygon and star tools' `phase: 'engaged'` wheel
221
+ bindings rise to 2, widening a gap they already won.
222
+
223
+ The loupe's pixel mode no longer drops the end of a fast drag: a readback
224
+ requested while `createImageBitmap` is in flight is remembered and re-run when
225
+ that one settles, instead of being discarded.
226
+
227
+ SVG unpack applies the fit-clamp to text on both axes. `fontSize` now scales
228
+ with the file, and a text node's box width is estimated from its longest line
229
+ instead of inheriting the parser's unbounded-width wrap sentinel — which,
230
+ folded into the union AABB, had been clamping any external SVG containing
231
+ text down to a speck. That sentinel is now the exported `UNBOUNDED_TEXT_WIDTH`
232
+ rather than a bare `99999`, so a consumer reading `SvgTextNode.width` can tell
233
+ a measurement from a placeholder.
234
+
235
+ The debug overlay takes per-feature line widths and dashes through
236
+ `DebugConfig.strokes`, alongside the colors `DebugConfig.theme` already
237
+ carried. Defaults are unchanged.
238
+
239
+ A `.dfont` face declines the outline tier by name. Datafork TrueType holds its
240
+ sfnt tables inside a Macintosh resource map, which is still not unpacked, but
241
+ it is now recognized before parsing and reported as itself rather than dying
242
+ on an unrecognized-signature message that never says which format it saw.
243
+
244
+ - f79e4b2: Viewport layers can now answer where a screen point lands inside them.
245
+ `createViewportLayer` returns a `ViewportLayer`, adding:
246
+
247
+ ```ts
248
+ layer.reproject(outer, dims, screen); // → inner-world point, or null if outside
249
+ viewportsAt(layers, outer, dims, screen); // → topmost viewport containing it
250
+ ```
251
+
252
+ `bounds` is already a pure function of `(outer, dims)`, so re-projection
253
+ recomputes the exact rect the frame painted rather than a remembered one — no
254
+ stored state and nothing to go stale. Right and bottom edges are exclusive, so
255
+ adjacent viewports never both claim a pixel.
256
+
257
+ This deliberately does **not** touch the dispatcher, which the previous
258
+ docstring promised it would. Tools still target the outer view; a consumer that
259
+ wants a click inside a viewport to mean something calls `reproject` from its own
260
+ handler. Making tools work _inside_ a viewport raises questions this primitive
261
+ does not answer — which view a pinch zooms, what a drag leaving the rect does —
262
+ and is tracked as its own item.
263
+
264
+ - Updated dependencies [d2a9049]
265
+ - Updated dependencies [24daa08]
266
+ - @weasel-js/gestures@1.0.2
267
+ - @weasel-js/font@1.0.2
268
+ - @weasel-js/geom@1.0.2
269
+ - @weasel-js/history@1.0.2
270
+ - @weasel-js/modes@1.0.2
271
+
272
+ ## 1.0.1
273
+
274
+ ### Patch Changes
275
+
276
+ - d6c2eff: Take the id→node lookup off the render and hit-test paths.
277
+
278
+ Six call sites walked `scene.renderOrder()` and immediately resolved each id
279
+ back through `scene.get` — a map lookup per node, per call, for nodes the
280
+ traversal had already held. They now read `scene.renderOrderNodes()` directly:
281
+ `sceneToAdapter.getNodes`, the move gesture adapter and the default commit
282
+ adapter, `useSceneSelectTool`'s `hitTestArea` and default `pickEvery`, and the
283
+ text-edit hit test. Adapter `getNodes` runs on the render path once a frame.
284
+
285
+ Building the node list is about twice as fast (`npm run bench`, `min`, Apple M2
286
+ Max / Node v26.1.0):
287
+
288
+ | nodes | via `renderOrder` + `get` | via `renderOrderNodes` |
289
+ | ------ | ------------------------- | ---------------------- |
290
+ | 1,000 | 0.031 ms | 0.011 ms |
291
+ | 10,000 | 0.40 ms | 0.19 ms |
292
+
293
+ `tests/bench/scene-ops.bench.ts` gains that comparison, and the committed
294
+ baseline is re-recorded.
295
+
296
+ - d62dc17: Bake the rect batch's transform and alpha into its vertices, so a rotated or
297
+ partly-transparent node no longer breaks a run.
298
+
299
+ `RectBatch.push` now maps the four corners through the model matrix itself and
300
+ the flush draws at `u_model` identity — ~12 flops against the ~66 us a draw call
301
+ costs. An affine maps a rect to a parallelogram, so the two-triangle index
302
+ pattern still covers it. Group alpha multiplies into the vertex alpha the same
303
+ way fill opacity already did. Neither is batch state any more.
304
+
305
+ Frame cost for rects each wrapped in their own transform group — what
306
+ `wrapNodeOutput` emits for a rotated node — M2 Max via ANGLE at 800x600
307
+ (`npm run test:perf`, new `rotated` variant):
308
+
309
+ | rects | before | after |
310
+ | ----- | --------- | ------- |
311
+ | 400 | 24.94 ms | 0.08 ms |
312
+ | 1,600 | 102.78 ms | 0.42 ms |
313
+ | 3,200 | 216.90 ms | 0.52 ms |
314
+
315
+ The color matrix stays a uniform and stays a barrier. The shader applies it and
316
+ its clamp to the straight-alpha source _before_ multiplying by `u_alpha`, so a
317
+ pre-multiplied vertex alpha is the same number only under an identity matrix —
318
+ which is every scene that does not use one. Alpha therefore folds only there,
319
+ and rides `u_alpha` otherwise.
320
+
321
+ - 2604ce2: Merge consecutive solid-fill rects into one draw call.
322
+
323
+ The draw loop cost a flat ~66 us per draw call at every scene size, so a frame
324
+ of 3,200 rects took 212 ms. Solid-fill rects now append into a growable vertex
325
+ buffer and go out as a single `drawElements` at flush. Fill color rides the
326
+ vertices — the batch draws through the existing `pathFillVColor` program with
327
+ `u_color` held at white, which is bit-identical to the flat program's math.
328
+
329
+ Frame cost for a **flat** command stream, M2 Max via ANGLE at 800x600
330
+ (`npm run test:perf`):
331
+
332
+ | rects | before | after |
333
+ | ----- | --------- | ------- |
334
+ | 400 | 25.62 ms | 0.03 ms |
335
+ | 1,600 | 105.63 ms | 0.11 ms |
336
+ | 3,200 | 211.78 ms | 0.15 ms |
337
+
338
+ Painter's order is unchanged. A run absorbs only consecutive commands and
339
+ flushes before anything it cannot express: another fill kind, a stroke, a clip
340
+ push or pop, or a group changing the transform, alpha, or color matrix.
341
+
342
+ That last barrier means `SceneCanvas` does not benefit yet — it emits one
343
+ wrapper group per node, which breaks every run. Consumers building flat command
344
+ streams get the numbers above today. See
345
+ `docs/handoffs/2026-08-14-batched-dispatch.md` for the plan to reach the scene
346
+ path. Nothing else in the loop got faster either: a frame alternating solid and
347
+ gradient rects still costs ~34 us per command, now almost entirely the gradient
348
+ half.
349
+
350
+ - 24ae9f4: Batch solid-fill meshes and stroke ribbons alongside rects, so a stroked shape
351
+ costs one draw instead of two plus a fresh VAO every frame.
352
+
353
+ `RectBatch` becomes `SolidBatch`, with a `pushMesh` alongside `pushRect` that
354
+ appends transformed vertices and rebases the mesh's indices onto the staged run.
355
+ Solid path fills and solid stroke ribbons both take it, and land in the same
356
+ draw as each other: GL rasterizes a draw's primitives in index order, so staging
357
+ the ribbon after its own fill is what keeps the stroke on top.
358
+
359
+ Frame cost at 3,200 commands, M2 Max via ANGLE at 800x600 (`npm run test:perf`,
360
+ new `meshes` and `stroked` variants):
361
+
362
+ | variant | before | after |
363
+ | ----------------------------- | --------- | ------- |
364
+ | solid-fill octagons | 5.63 ms | 0.65 ms |
365
+ | stroked rects (fill + ribbon) | 243.80 ms | 9.41 ms |
366
+
367
+ The stroke figure is the interesting one, and not for the reason the plan
368
+ assumed. A draw call is ~1.8 us when nothing is touched between draws; what
369
+ costs is issuing a draw against a buffer minted that same frame, which is
370
+ exactly what a per-frame stroke ribbon did. Of the 9.41 ms left, 7.9 ms is
371
+ stroke tessellation, which batching does not address.
372
+
373
+ Excluded from a run: stencil fills, inner/outer-aligned polygon strokes, and
374
+ anything carrying per-vertex colors, all as before — plus meshes past a vertex
375
+ cap, since batching re-copies a mesh every frame where the persistent mesh cache
376
+ would not. Rects pay ~0.1 ms per frame at 3,200 for the index buffer becoming a
377
+ per-flush upload rather than a static pattern.
378
+
379
+ - c2ebfdf: Break rect batches on group state rather than on tree shape, so `SceneCanvas`
380
+ gets the batching.
381
+
382
+ A group was a batch barrier because it _might_ move a uniform. `buildSceneTree`
383
+ gives every node its own group with no transform, alpha, colorMatrix or clip, so
384
+ in the scene shape every run broke after one rect and the previous release's
385
+ batching reached nothing the app renders. A run now carries the state it was
386
+ staged under and breaks only when the live state differs by value — which a
387
+ no-op wrapper never does.
388
+
389
+ Scene-shaped frame cost, M2 Max via ANGLE at 800x600 (`npm run test:perf`, new
390
+ `scene` variant — one wrapper group per command):
391
+
392
+ | rects | before | after |
393
+ | ----- | --------- | ------- |
394
+ | 400 | 26.32 ms | 0.03 ms |
395
+ | 1,600 | 105.18 ms | 0.11 ms |
396
+ | 3,200 | 208.72 ms | 0.36 ms |
397
+
398
+ Clips stay hard flush points in both directions: the stencil is GL state that a
399
+ staged run cannot reconstruct, so the flush happens before `pushClip` and before
400
+ `popClip` rather than at group boundaries. Text, images, shaders, strokes and
401
+ non-solid fills flush as before.
402
+
403
+ - dce3306: Cache `renderOrder()` and `renderOrderNodes()` between structural edits.
404
+
405
+ Both walk the whole tree, and both run per frame and per hit-test query, on a
406
+ sequence that only changes when something structural moves. They now build once
407
+ and are served from a cache until it does. On a 10,000-node, four-layer scene a
408
+ repeat call drains in 0.0033 ms against a 0.33 ms rebuild (`npm run bench`,
409
+ `min`).
410
+
411
+ Invalidation hangs off the four writers that can reorder the scene — `attach`,
412
+ `detach`, `kit:setLayer`, `rebuildLayerIndex` — plus `loadState`. Pose and data
413
+ edits do **not** invalidate: they change no order and fire every frame during a
414
+ drag, which is exactly when the cache earns its keep.
415
+
416
+ Repeat calls now return the same array instance rather than a fresh one. It is
417
+ still a snapshot — a structural edit builds a new array, so a reference taken
418
+ earlier keeps the order it was taken with — but callers must not mutate what
419
+ they get back. Both return types have always been `readonly`.
420
+
421
+ - 69395b0: Detached scene renders honor pose rotation and per-node alpha.
422
+
423
+ Rotation and the per-id alpha multiplier were applied by `buildSceneLayer`,
424
+ the main canvas's scene walk. Every other way of painting a scene —
425
+ `<SceneViewCanvas>`, `<MinimapCanvas>`, and `renderSceneToPixels` — goes
426
+ through `buildSceneViewCommands` instead, which applied neither. A rotated
427
+ node came out upright in a minimap, a thumbnail, or a print export, and a
428
+ scene dimmed on screen exported at full strength.
429
+
430
+ Both wraps now live in one helper that both scene walks call, so the detached
431
+ renders match the canvas. Rotation needs nothing from the caller — it comes
432
+ off the pose. Dimming does: `alphaFor` is a new optional prop on
433
+ `<SceneViewCanvas>` and `<MinimapCanvas>`, and a new argument to
434
+ `renderSceneToCanvas`, `renderSceneToPixels`, `planPixelRender`, and
435
+ `buildSceneViewCommands`. Pass the same function `<SceneCanvas>` gets.
436
+
437
+ If you supply a `drawOne` to one of these that rotates its own output, it will
438
+ now rotate twice — emit unrotated geometry and let the pose drive it, which is
439
+ what the main canvas has always required.
440
+
441
+ - 3d93f2e: Detached scene renders now honor layer visibility and container clips.
442
+
443
+ `buildSceneViewCommands` walked `scene.renderOrder()` and painted every node it
444
+ found. That walk knew nothing about scene layers or parentage, so
445
+ `<SceneViewCanvas>`, `<MinimapCanvas>` and `renderSceneToPixels` all painted
446
+ nodes on hidden layers and let a container's children spill past the container.
447
+ The main canvas got both right, because `buildSceneLayer` goes through
448
+ `buildSceneTree`.
449
+
450
+ The detached path now goes through `buildSceneTree` too, which is the dedupe the
451
+ detached-minimap spec called for. One walk, so the two surfaces cannot disagree
452
+ again.
453
+
454
+ Output nesting follows `buildSceneTree`: the view group holds one group per
455
+ **visible** scene layer, each holding one group per node. Code that indexed the
456
+ view group's children as one-per-node — `commands[0].children[i]` — now finds a
457
+ layer group there and needs a further hop. `extraCommands` still come last,
458
+ beside the layer groups.
459
+
460
+ A hand-written `Scene` stand-in must now supply `layers`, `roots`, and
461
+ `children` on containers; scenes from `createScene` and `sceneFromJSON` already
462
+ do.
463
+
464
+ - e367165: One enumeration of the `TargetSpec` forms. `@weasel-js/gestures` now exports
465
+ `parseTargetSpec`, which resolves a target spec to a discriminated
466
+ `TargetSpecForm` (`body` / `kind` / `affordance` / `predicate`), and the three
467
+ places that used to re-derive the string prefixes independently — `matchTarget`,
468
+ and `targetRank` / `targetConsultsAffordance` in core's dispatcher matcher —
469
+ switch on it exhaustively. Adding a form to `TargetSpec` is now a compile error
470
+ at every site that has to handle it.
471
+
472
+ For consumers: `matchTarget`'s `specTarget` parameter and core's
473
+ `targetConsultsAffordance` take `TargetSpec | undefined` instead of `unknown`,
474
+ so a target string that is no known form is a type error rather than a silent
475
+ no-match. The predicate form has a name, `TargetPredicate`, carrying the
476
+ `readsAffordance` flag the exclusive-claim filter reads. Runtime behavior is
477
+ unchanged.
478
+
479
+ - 52e9c57: Walk the scene once in `renderOrder()` instead of once per layer.
480
+
481
+ The generator behind `renderOrder()` and `toJSON()` was layer-major in the
482
+ literal sense: it ran a full DFS of the tree for every layer and yielded only
483
+ the nodes belonging to that pass, so producing N ids cost L×N work. A single
484
+ DFS now buckets each node by its layer and concatenates the buckets, which is
485
+ O(N + L). The emitted sequence is unchanged — same layer-major order, same
486
+ DFS-preorder within each layer, same skip for dangling child ids — and a
487
+ differential test holds the new implementation to a transcription of the old
488
+ one across 200 generated scenes plus mutation, layer-edit and undo sequences.
489
+
490
+ Over 10k nodes the layer sweep goes from 0.37 ms / 1.03 ms / 3.54 ms / 12.93 ms
491
+ at 1 / 4 / 16 / 64 layers to 0.30 / 0.35 / 0.40 / 0.42. The flat single-layer
492
+ case improves too, 0.37 ms → 0.33 ms at 10k nodes and 2.1x at 100 nodes, since
493
+ one pass replaces the per-yield generator overhead.
494
+
495
+ `renderOrder()` now returns an array rather than a generator, so a caller that
496
+ stops early no longer avoids the rest of the walk. Every caller in the repo
497
+ drains it fully except four test helpers reading the first id from a handful of
498
+ nodes. Its declared type stays `Iterable<NodeId>`.
499
+
500
+ - d68e734: Memoize the per-node AABB in the area hit-test, for silhouette poses.
501
+
502
+ `hitTestArea` — the marquee and lasso dep source — recomputed every node's
503
+ bounding box on every query. For a polygon pose that means walking the whole
504
+ command stream and allocating a rect, per node, before the fast-reject could
505
+ discard it. The box is now cached through `nodeMemo`, keyed on the node's
506
+ `pose` and `data` references, so a repeat query over an unedited scene reuses
507
+ it and an edit through any scene op invalidates it.
508
+
509
+ Measured on 24-gon scenes (`npm run bench`, `min` column, same machine
510
+ back-to-back): 10,000 nodes 11.85 ms → 1.16 ms per query, 1,000 nodes
511
+ 1.14 ms → 0.15 ms. Query-rect size now moves the number (0.117 ms at 17 hits
512
+ against 0.141 ms at 1,000 hits on a 1,000-node scene, previously flat at
513
+ ~1.15 ms either way) because the silhouette kernel, not the bounds
514
+ computation, is what survives the reject.
515
+
516
+ Rect-pose scenes pay 5–17% for it: `aabbOfPose` returns a rect pose
517
+ unchanged, so there is nothing to cache, and deciding that per node costs
518
+ more than the call it skips. 10,000 rect nodes go 0.76 ms → 0.85 ms.
519
+
520
+ - ca9673a: Stop re-sending unchanged uniforms on every draw command.
521
+
522
+ `u_proj` is constant for a whole frame and `u_colorMatrix` is the identity in
523
+ every scene that does not use a color matrix, yet both were uploaded for every
524
+ command — along with a fresh `Float32Array(16)` and a transpose per draw to
525
+ build the color matrix, and a fresh `screenToClip` matrix per draw to build the
526
+ projection. GL holds uniform state per program object, so all of that was
527
+ buying nothing.
528
+
529
+ `draw.ts` now remembers what it last sent each program and skips the upload
530
+ when the value has not changed. On a frame of 1,000 solid rects that takes
531
+ `uniformMatrix4fv` from 1,000 calls to 1 and `uniformMatrix3fv` from 2,000 to
532
+ 2, and removes two per-draw allocations.
533
+
534
+ The cache hangs off the `DrawContext`, which is rebuilt per frame, so it cannot
535
+ outlive a frame or go stale against GL state changed between frames. It covers
536
+ only the four uniforms this module is the sole writer of — `u_color` and
537
+ `u_alpha` have several writers and are still sent every draw.
538
+
539
+ This does not measurably change frame time on an M2 Max: the draw loop is bound
540
+ by per-draw-call cost (~68 us per command), not by uniform uploads. It removes
541
+ the calls and the allocations; `docs/TODO.md` tracks what the remaining cost
542
+ actually is.
543
+
544
+ - fa1ed05: Dragging a text node no longer re-lays it out.
545
+
546
+ `layoutRuns` baked the text's position into every quad, decoration rule and
547
+ line box, so `layoutCache` had to carry that position in its key. Panning and
548
+ zooming still hit the cache, but _moving_ a text node missed on every frame —
549
+ at 500 wrapped glyphs a move cost 0.130 ms against a 1.7e-4 ms hit, which is
550
+ the same 0.134 ms a full miss cost. Moving text paid as if there were no cache
551
+ at all.
552
+
553
+ Layout now emits geometry relative to the text's own top-left and `drawText`
554
+ translates while packing vertices, alongside the `verticalAlign` offset it
555
+ already applied there. Position is out of the cache key, so a move is a hit:
556
+ median 0.130 ms → 0.000125 ms, min 0.123 ms → 0.000041 ms. The cold path is
557
+ unchanged (min 0.123 ms → 0.110 ms for a full miss).
558
+
559
+ This is not a rendering change. Alignment, wrapping, tracking, kerning and
560
+ decoration placement read widths and pen deltas, never an absolute coordinate,
561
+ and nothing in the walk rounds or snaps — checked over 247,572 coordinates
562
+ spanning three alignments, four wrap widths, mixed sizes, positive and negative
563
+ tracking, and fractional positions. The only differences were float64 rounding
564
+ from folding the position into the accumulator early, none of them survived the
565
+ conversion to the float32 vertex buffers, and they favor the new code: at a
566
+ position of 1e6 the old path computed 28.800000000046566 where this one gives
567
+ 28.8. The 37-test Playwright visual suite is unchanged.
568
+
569
+ `layoutRuns` and `cachedLayoutRuns` lose their `origin` parameter, and the
570
+ `LayoutRunsOrigin` type is gone. Neither is exported from the package. Callers
571
+ of the public `textLineBoxes` and `measureTextBounds` see no change.
572
+
573
+ - 0a40c29: Add `scene.renderOrderNodes()`, and scan it in the area hit-test.
574
+
575
+ `renderOrder()` hands back ids, and almost every caller immediately resolves
576
+ each one back to a node — a map lookup per node, per query, for a node the
577
+ traversal had in hand and dropped. `renderOrderNodes()` is the same
578
+ layer-major sequence as the nodes themselves. It is a snapshot, freshly built
579
+ per call, exactly like `renderOrder()`.
580
+
581
+ `hitTestArea` (marquee and lasso) now scans it, and reads `pose.kind` inline
582
+ instead of through the `isPathLike` predicate. Together those recover the
583
+ 5–17% the AABB memo cost rect scenes and take a good deal more besides
584
+ (`npm run bench`, `min` column, three alternating runs per build on one
585
+ machine; run-to-run scatter on these was under 3%):
586
+
587
+ | 10,000 nodes, 25% query rect | before | after |
588
+ | ---------------------------- | ------- | ------- |
589
+ | rect poses | 0.94 ms | 0.53 ms |
590
+ | 24-gon silhouettes | 1.21 ms | 0.78 ms |
591
+
592
+ `renderOrder()` itself gets a separate walk for the single-layer case, which
593
+ needs no per-layer buckets and can compare the layer id rather than index it:
594
+ 10,000 nodes over one layer 0.27 ms → 0.19 ms, with multi-layer scenes
595
+ unchanged. `toJSON()` rides the nodes walk and skips its lookups too.
596
+
597
+ `Scene` gains a method, so a hand-written stand-in for a scene needs to
598
+ implement it; scenes from `createScene` and `sceneFromJSON` already do.
599
+
600
+ - Updated dependencies [e367165]
601
+ - @weasel-js/gestures@1.0.1
602
+ - @weasel-js/font@1.0.1
603
+ - @weasel-js/geom@1.0.1
604
+ - @weasel-js/history@1.0.1
605
+ - @weasel-js/modes@1.0.1
606
+
3
607
  ## 1.0.0
4
608
 
5
609
  ### Minor Changes