@solidrt/core 0.0.45 → 0.0.47

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/AGENTS.md CHANGED
@@ -94,7 +94,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
94
94
  ```
95
95
 
96
96
  Peer deps @solidjs/signals and @solidjs/universal must match (currently
97
- 2.0.0-beta.26); bun resolves them from peerDependencies.
97
+ 2.0.0-beta.31); bun resolves them from peerDependencies.
98
98
 
99
99
  ## Element model (the parts that are easy to get wrong)
100
100
 
@@ -174,6 +174,22 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
174
174
  app-global shortcut point; `stopPropagation()` ends the walk. `focusable`
175
175
  declares focus-navigation candidacy (enumerate via getFocusables()).
176
176
 
177
+ - Gesture recognizers, shared by every package: `createPan` (single-pointer
178
+ drag, axis-aware slop, per-event dx/dy) and `createTransform` (merged
179
+ pan + pinch + rotate over the whole pointer set, Flutter-Scale style: streams
180
+ `{ dx, dy, scale, rotation, x, y, pointers }` once per FRAME - positions
181
+ update per event, but the cross-pointer measure waits for the
182
+ `pointerFrame` batch terminator, when every pointer is the same age; one
183
+ finger degrades to a plain pan, and `pointers` is how a consumer gives one-
184
+ and two-finger translation different meanings - dx/dy alone cannot tell
185
+ them apart).
186
+ Spread the returned `.handlers` onto the receiving element. They
187
+ arbitrate through the exported `arena` (ONE per app): a press claims its
188
+ pointer provisionally, movement evidence steals and resolves it, the loser's
189
+ `cancel()` retracts its feedback. Custom recognizers should join the arena
190
+ rather than track pointers ad hoc, or they will double-handle against
191
+ scrollers and pressables.
192
+
177
193
  - Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
178
194
  is as you expect, but `createEffect` takes the 2.0 two-function shape: a
179
195
  TRACKED compute that reads signals and returns a value, then an UNTRACKED
@@ -42,6 +42,8 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
42
42
  - `gpu-pipeline.tsx` - `createPipelineTexture`: a custom vertex+fragment pair drawing an interleaved vertex buffer (position and color attributes resolved by name), a depth-tested spinning cube whose `uTime` uniform is driven through `<texture params>` exactly like a fragment shader's.
43
43
  - `gpu-particles.tsx` - an additive particle field: `topology: "points"` with `blend: "add"`, so overlapping gaussian splats (`gl_PointSize` from the vertex stage, `gl_PointCoord` falloff, premultiplied output) accumulate into a glowing volume instead of overwriting. The tints are typed vec3 uniforms filled from 3-number array params.
44
44
  - `gpu-instancing.tsx` - instanced drawing: one 3-vertex triangle drawn hundreds of times via `instanceCount`, each instance placed and tinted on a phyllotaxis spiral from `gl_InstanceID` alone. `setDraw(id, { instanceCount })` merges into the draw range per frame (absent keys keep their values, like params), so the population breathes without touching the buffer.
45
+ - `gpu-draw-list.tsx` - `createDrawTarget`: one render target holding an ordered, mutable LIST of draws - two orbiting triangles from different programs sharing one depth buffer (the target owns the depth storage, each pipeline the test/write behavior), a third entry added and removed live via its stable `DrawId`, and `setDrawParams` as the per-object channel.
46
+ - `gpu-shared-params.tsx` - shared target state on a draw target: `setTargetParams`/`setTargetTextures` write values every entry reads - a ring of quads spins and color-cycles from ONE write per frame, and the shared sampler source swaps for the whole target at once. The spin is imperative (`setTargetParams` in `onFrame`) while the tint rides the `<texture params>` prop - the declarative channel, which on a draw target writes the same shared record. The two entries that seeded their own uTint/uMap show the precedence rule (an entry's own value beats the shared one), and mixed programs show partial coverage (a program that does not declare a shared name skips it). `createDrawTarget`'s positional `params` and `opts.textures` seed both channels before any entry exists.
45
47
  - `window-shader.tsx` - the `shader` prop on `<window>`: the finished frame drawn through a raw-linked warp program before present, click to toggle between warp and identity.
46
48
  - `window-shader-history.tsx` - the window shader's frame history: `previous` binds last frame as uPrevious, drawn as a one-frame motion echo behind an orbiting square; click toggles the echo term.
47
49
 
@@ -73,7 +73,7 @@ function App() {
73
73
  attributes: attrs,
74
74
  })
75
75
 
76
- let target = createDrawTarget(512, 512, { depth: true, clearColor: [0.04, 0.04, 0.08, 1], label: "orbits" })
76
+ let target = createDrawTarget(512, 512, null, { depth: true, clearColor: [0.04, 0.04, 0.08, 1], label: "orbits" })
77
77
  let warmDraw = addDraw(target, warm, { uAngle: 0 }, { buffer: triangle })
78
78
  let coolDraw = addDraw(target, cool, { uAngle: Math.PI }, { buffer: triangle })
79
79
 
@@ -0,0 +1,135 @@
1
+ // Shared target state on a draw target: values every entry reads, written
2
+ // ONCE per target instead of once per entry. A ring of quads spins under a
3
+ // single "camera" uniform (uView) and color-cycles under a shared tint
4
+ // (uTint) - one target-level write per frame however many entries the
5
+ // list holds, where per-entry setDrawParams would cost one call and one
6
+ // value's worth of JS arithmetic per quad (the cost profile @solidrt/3d's
7
+ // camera rides on). setTargetTextures is the sampler analog: the patterned
8
+ // quads read one shared uMap source, swapped for the whole target every
9
+ // two seconds.
10
+ //
11
+ // The rules to notice: an entry's OWN value beats the shared one - the
12
+ // amber quad seeds uTint at addDraw and ignores the color cycle, the
13
+ // striped quad binds its own uMap and ignores the swap - and coverage may
14
+ // be partial: the tint program never declares uMap and the patterned
15
+ // program never declares uTint, so each shared write simply skips entries
16
+ // whose program does not declare the name. Shared state is target state:
17
+ // createDrawTarget seeds it (positional params + opts.textures) before any
18
+ // entry exists, and entry add/remove/rebuild cannot lose it.
19
+ //
20
+ // Two channels drive the same shared params. uView goes imperatively
21
+ // (setTargetParams in onFrame), uTint goes declaratively: the `<texture
22
+ // params>` prop means "the target's params" on every target kind, so on a
23
+ // draw target it writes the shared record - a signal into the prop is all
24
+ // the wiring a shared value needs.
25
+ import { render, onFrame, createSignal } from "@solidrt/core"
26
+ import {
27
+ addDraw,
28
+ compileShader,
29
+ createBuffer,
30
+ createDrawTarget,
31
+ createRenderPipeline,
32
+ createTexture,
33
+ glsl,
34
+ linkProgram,
35
+ setTargetParams,
36
+ setTargetTextures,
37
+ } from "@solidrt/core/gpu"
38
+
39
+ let VERTEX = glsl`
40
+ in vec2 aPos;
41
+ uniform float uView;
42
+ uniform vec2 uCenter;
43
+
44
+ void main() {
45
+ vec2 p = uCenter + aPos * 0.13;
46
+ float c = cos(uView), s = sin(uView);
47
+ gl_Position = vec4(c * p.x - s * p.y, s * p.x + c * p.y, 0.0, 1.0);
48
+ }
49
+ `
50
+
51
+ let FRAGMENT_TINT = glsl`
52
+ uniform vec4 uTint;
53
+ void main() {
54
+ fragColor = uTint;
55
+ }
56
+ `
57
+
58
+ let FRAGMENT_MAP = glsl`
59
+ uniform sampler2D uMap;
60
+ void main() {
61
+ fragColor = texture(uMap, gl_FragCoord.xy / 32.0);
62
+ }
63
+ `
64
+
65
+ function App() {
66
+ let quad = createBuffer(new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]), { label: "quad" })
67
+ let vs = compileShader("vertex", VERTEX, { header: true })
68
+ let attrs = [{ name: "aPos", format: "vec2" as const }]
69
+ let tint = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_TINT, { header: true })), {
70
+ attributes: attrs,
71
+ })
72
+ let mapped = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_MAP, { header: true })), {
73
+ attributes: attrs,
74
+ })
75
+
76
+ // 2x2 patterns, nearest + repeat, so gl_FragCoord tiling shows hard cells.
77
+ let pattern = (a: number[], b: number[]) =>
78
+ createTexture(new Uint8Array([...a, ...b, ...b, ...a]), 2, 2, { filter: "nearest", wrap: "repeat" })
79
+ let checker = pattern([15, 15, 20, 255], [235, 235, 235, 255])
80
+ let ember = pattern([250, 160, 30, 255], [45, 10, 60, 255])
81
+ let stripes = createTexture(new Uint8Array([220, 40, 60, 255, 245, 245, 245, 255]), 2, 1, {
82
+ filter: "nearest",
83
+ wrap: "repeat",
84
+ })
85
+
86
+ // The positional params argument and opts.textures seed the shared state
87
+ // before any entry exists; the entries added below pick it up.
88
+ let target = createDrawTarget(
89
+ 512,
90
+ 512,
91
+ { uView: 0, uTint: [0.3, 0.8, 0.9, 1] },
92
+ { textures: { uMap: checker }, clearColor: [0.05, 0.05, 0.09, 1], label: "shared-ring" },
93
+ )
94
+
95
+ const RING = 8
96
+ for (let i = 0; i < RING; i++) {
97
+ let a = (i / RING) * Math.PI * 2
98
+ let uCenter = [0.62 * Math.cos(a), 0.62 * Math.sin(a)]
99
+ if (i === 1 || i === 5) {
100
+ // Patterned entries read the shared uMap; the one at i === 5 brings
101
+ // its own binding and keeps its stripes through every shared swap.
102
+ addDraw(target, mapped, { uCenter }, i === 5 ? { buffer: quad, textures: { uMap: stripes } } : { buffer: quad })
103
+ } else if (i === 3) {
104
+ // The override quad: its own uTint beats the shared color cycle.
105
+ addDraw(target, tint, { uCenter, uTint: [1, 0.62, 0.1, 1] }, { buffer: quad })
106
+ } else {
107
+ addDraw(target, tint, { uCenter }, { buffer: quad })
108
+ }
109
+ }
110
+
111
+ let [sharedTint, setSharedTint] = createSignal([0.3, 0.8, 0.9, 1])
112
+ let mapFlip = -1
113
+ onFrame(tick => {
114
+ let t = tick / 1000
115
+ // The whole ring, one write: uView spins every entry. uTint takes the
116
+ // declarative channel instead - the signal feeds the params prop below.
117
+ setTargetParams(target, { uView: t * 0.5 })
118
+ setSharedTint([0.45 + 0.45 * Math.sin(t), 0.45 + 0.45 * Math.sin(t + 2.1), 0.45 + 0.45 * Math.sin(t + 4.2), 1])
119
+ // The shared sampler source swaps every two seconds; only the entry
120
+ // with its own uMap keeps its pattern.
121
+ let flip = Math.floor(t / 2) % 2
122
+ if (flip !== mapFlip) {
123
+ mapFlip = flip
124
+ setTargetTextures(target, { uMap: flip === 0 ? checker : ember })
125
+ }
126
+ })
127
+
128
+ return (
129
+ <window alignItems="center" justifyContent="center">
130
+ <texture src={target} width={420} height={420} params={{ uTint: sharedTint() }} />
131
+ </window>
132
+ )
133
+ }
134
+
135
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,11 +27,11 @@
27
27
  "colord": "^2.9.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.45"
30
+ "@solidrt/flux-types": "0.0.47"
31
31
  },
32
32
  "peerDependencies": {
33
- "@solidjs/signals": "2.0.0-beta.26",
34
- "@solidjs/universal": "2.0.0-beta.26",
35
- "solid-js": "2.0.0-beta.26"
33
+ "@solidjs/signals": "2.0.0-beta.31",
34
+ "@solidjs/universal": "2.0.0-beta.31",
35
+ "solid-js": "2.0.0-beta.31"
36
36
  }
37
37
  }
package/src/arena.ts ADDED
@@ -0,0 +1,60 @@
1
+ // The gesture arena: per-pointer ownership arbitration between recognizers.
2
+ // Raw pointer events keep bubbling along the frozen down path regardless; the
3
+ // arena only decides which recognizer's gesture a pointer belongs to. It lives
4
+ // in core beside that routing (window.ts) because arbitration only works with
5
+ // exactly ONE claims map per app: recognizers from every package (components'
6
+ // press and pan, 3d's orbit transform, future runtime-level recognizers) must
7
+ // see each other's claims, and two arenas cannot arbitrate against each other.
8
+ //
9
+ // Two claim strengths mirror how gestures resolve. A press claims its pointer
10
+ // provisionally on the down: it is the presumed winner (innermost-wins falls
11
+ // out of leaf-to-root dispatch, first claim sticks) but a later recognizer
12
+ // with positive evidence of a different gesture (a pan crossing its movement
13
+ // slop) may steal the pointer, cancelling the press. A steal resolves the
14
+ // arena: the pointer is won outright and cannot be stolen again, so e.g. the
15
+ // outer axis of two nested scrollers cannot take a drag the inner one already
16
+ // owns. Plain state on purpose: claims must be visible across recognizers
17
+ // within one synchronous bubble dispatch, before any signal flush.
18
+
19
+ export type ArenaOwner = {
20
+ /** Retract the gesture without firing; invoked when the pointer is stolen. */
21
+ cancel(): void
22
+ }
23
+
24
+ type Claim = { owner: ArenaOwner; resolved: boolean }
25
+
26
+ let claims = new Map<number, Claim>()
27
+
28
+ export let arena = {
29
+ /**
30
+ * Provisionally claim an unowned pointer. Returns false (claim refused) when
31
+ * any recognizer already owns it. The claim is stealable until the owner
32
+ * releases it or a steal resolves the arena.
33
+ */
34
+ claim(pointerId: number, owner: ArenaOwner): boolean {
35
+ if (claims.has(pointerId)) return false
36
+ claims.set(pointerId, { owner, resolved: false })
37
+ return true
38
+ },
39
+
40
+ /**
41
+ * Take the pointer on positive evidence of a gesture, cancelling the current
42
+ * provisional owner (if any), and resolve the arena: the resulting claim
43
+ * cannot be stolen. Returns false when the arena is already resolved, in
44
+ * which case the caller lost and must stand down.
45
+ */
46
+ steal(pointerId: number, owner: ArenaOwner): boolean {
47
+ let current = claims.get(pointerId)
48
+ if (current) {
49
+ if (current.resolved) return false
50
+ current.owner.cancel()
51
+ }
52
+ claims.set(pointerId, { owner, resolved: true })
53
+ return true
54
+ },
55
+
56
+ /** Release the claim on a pointer, if `owner` still holds it. */
57
+ release(pointerId: number, owner: ArenaOwner): void {
58
+ if (claims.get(pointerId)?.owner === owner) claims.delete(pointerId)
59
+ },
60
+ }
package/src/color.ts CHANGED
@@ -7,10 +7,13 @@ extend([namesPlugin, mixPlugin])
7
7
  * Parses a CSS color string (named, hex, `rgb()`, `hsl()`, ...) into a packed
8
8
  * `0xRRGGBBAA` u32: red in the high byte, alpha in the low byte. Alpha is scaled
9
9
  * from colord's 0..1 to 0..255. This is the wire format the runtime expects for
10
- * the `color` property.
10
+ * the `color` property. Throws on a string that is not a valid CSS color, so a
11
+ * typo fails on the line that wrote it instead of silently painting black.
11
12
  */
12
13
  export function parseColor(color: string): number {
13
- let { r, g, b, a } = colord(color).toRgb()
14
+ let c = colord(color)
15
+ if (!c.isValid()) throw new Error(`Invalid color "${color}"`)
16
+ let { r, g, b, a } = c.toRgb()
14
17
  return (((r & 0xFF) << 24) | ((g & 0xFF) << 16) | ((b & 0xFF) << 8) | ((a * 255) & 0xFF)) >>> 0
15
18
  }
16
19
 
package/src/gpu.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  // GPU textures and shaders, reactive (SolidJS) layer: the create* helpers free
2
2
  // their texture automatically when the reactive owner is disposed. Drive a
3
- // shader's uniforms declaratively with `<texture src={id} params={{...}} />`
3
+ // target's uniforms declaratively with `<texture src={id} params={{...}} />`
4
4
  // (see TextureProps) - the preferred way, deferred to the next real repaint so
5
- // a fast-changing signal stays paced to actual frames. setShaderParams is the
6
- // imperative exception: reach for it only when there is no `<texture>` element
7
- // to hold a params prop, e.g. a shader that only feeds another shader as a
8
- // sampler2D input. The imperative primitives (uploadTexture, setShaderParams,
9
- // destroyTexture, ...) live in the `flux:gpu` module.
5
+ // a fast-changing signal stays paced to actual frames; the prop means "the
6
+ // target's params" on every kind (on a draw target, its shared params).
7
+ // setTargetParams is the imperative exception: reach for it only when there
8
+ // is no `<texture>` element to hold a params prop, e.g. a target that only
9
+ // feeds another shader as a sampler2D input. The imperative primitives
10
+ // (uploadTexture, setTargetParams, destroyTexture, ...) live in the
11
+ // `flux:gpu` module.
10
12
  //
11
13
  // Sampling is a per-texture property declared at creation: `filter`
12
14
  // ("linear" default | "nearest") and `wrap` ("clamp" default | "repeat") on
@@ -45,15 +47,16 @@
45
47
  import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
46
48
  import * as gpu from "flux:gpu"
47
49
 
48
- // The create* helpers accept { manual: true } to opt out of the owner-scoped
49
- // auto-free, for resources whose lifetime is managed by hand (rebuilt on
50
- // signal changes inside a long-lived component, handed across owners, ...).
51
- // Without it, each rebuild would stack another onCleanup on the component
52
- // owner: a leak until unmount, then a double-free against manual destroys.
50
+ // The create* helpers accept { autoFree: false } to opt out of the
51
+ // owner-scoped auto-free, for resources whose lifetime is managed by hand
52
+ // (rebuilt on signal changes inside a long-lived component, handed across
53
+ // owners, ...). Without the opt-out, each rebuild would stack another
54
+ // onCleanup on the component owner: a leak until unmount, then a double-free
55
+ // against the by-hand destroys.
53
56
  // `label` is a free-form debug name (WebGPU's label): surfaced by the dev
54
57
  // tooling's GPU inventory and engine log messages, never interpreted, kept
55
58
  // across id-stable resizes.
56
- export type CreateOptions = { manual?: boolean; label?: string }
59
+ export type CreateOptions = { autoFree?: boolean; label?: string }
57
60
 
58
61
  // Sampling options every texture-producing create* helper accepts, applied at
59
62
  // creation as a property of the texture id (there is no set-sampler-later).
@@ -77,19 +80,28 @@ export type { BufferId, DrawId, ProgramId, RenderPipelineId, ShaderStageId, Text
77
80
  // -- need not import flux directly: destroyTexture for the manual-cleanup path
78
81
  // (textures made outside a reactive scope, e.g. after an await, are not
79
82
  // auto-freed), uploadTexture to push new pixels into a mutable texture, and
80
- // setShaderParams as the non-reactive exception described above - prefer
81
- // `<texture params={...}>` when a `<texture>` element is already in the tree.
82
- // resizeTexture and setShaderSize resize in place at a stable id (so
83
- // `<texture src>` and sampler bindings stay valid); because the id survives,
84
- // the owner-scoped auto-free registered at creation keeps working and no
85
- // re-registration is needed. setShaderTextures is the sampler analog of
86
- // setShaderParams: retarget a shader's sampler2D inputs without recompiling.
83
+ // the target-level verbs. setTargetParams writes a target's params on ANY
84
+ // target kind - the non-reactive exception described above, so prefer
85
+ // `<texture params={...}>` when a `<texture>` element is already in the
86
+ // tree. On a single-program target (fragment texture, pipeline target) the
87
+ // names validate strictly against its one program; on a draw target they are
88
+ // the SHARED params every entry reads (a camera's view-projection: one write
89
+ // per camera move instead of one per mesh), applied before each entry's own
90
+ // params so an entry naming the same uniform overrides the shared value, and
91
+ // a name only some entries' programs declare applies where declared.
92
+ // setTargetTextures is its sampler analog: retarget sampler2D inputs without
93
+ // recompiling (on a draw target, shared sources every entry reads - an
94
+ // environment map, a LUT - bound where an entry's program declares the name
95
+ // and its own bindings do not override it). resizeTexture and setTargetSize
96
+ // resize in place at a stable id (so `<texture src>` and sampler bindings
97
+ // stay valid); because the id survives, the owner-scoped auto-free
98
+ // registered at creation keeps working and no re-registration is needed.
87
99
  export {
88
100
  destroyTexture,
89
101
  resizeTexture,
90
- setShaderParams,
91
- setShaderSize,
92
- setShaderTextures,
102
+ setTargetParams,
103
+ setTargetSize,
104
+ setTargetTextures,
93
105
  uploadTexture,
94
106
  } from "flux:gpu"
95
107
 
@@ -102,8 +114,7 @@ export {
102
114
  // the explicit render verb for `render: "manual"` targets - targets whose
103
115
  // pass is state (accumulation, feedback) rather than a pure function of its
104
116
  // inputs, which the runtime therefore never renders on its own; the app
105
- // steps them, usually from onFrame. (`render: "manual"` is the render mode;
106
- // the unrelated `manual: true` create option is the lifetime opt-out above.)
117
+ // steps them, usually from onFrame.
107
118
  // copyTexture overwrites a manual target with another texture's pixels
108
119
  // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
109
120
  // ping-pong buffer, reset state to a known image.
@@ -114,10 +125,12 @@ export type { BlendMode, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRa
114
125
  // target (see createDrawTarget below), so there is no per-entry lifetime to
115
126
  // wrap. addDraw adds an entry (appended, or inserted via opts.before) and
116
127
  // returns its stable DrawId; removeDraw drops one; setDrawParams /
117
- // setDrawTextures / setDrawRange are the per-entry forms of setShaderParams /
118
- // setShaderTextures / setDraw, taking (target, draw, value) with identical
128
+ // setDrawTextures / setDrawRange are the per-entry forms of setTargetParams /
129
+ // setTargetTextures / setDraw, taking (target, draw, value) with identical
119
130
  // merge and validation semantics. The per-object hot path is setDrawParams (a
120
- // moved mesh = one call with its new matrix). setDrawOrder replaces the whole
131
+ // moved mesh = one call with its new matrix); the per-target one is
132
+ // setTargetParams (exported above), which on a draw target writes the SHARED
133
+ // params every entry reads. setDrawOrder replaces the whole
121
134
  // list order with a full permutation of the live ids - the sorting verb
122
135
  // (opaque front-to-back, transparent back-to-front, re-issued when the
123
136
  // camera moves).
@@ -165,24 +178,22 @@ export {
165
178
  */
166
179
  export let glsl = String.raw
167
180
 
168
- // captureSnapshot renders a node to a texture and readTexture reads any
169
- // texture's bytes back. A laid-out node captures its layout box; a `d-*` node
170
- // captures its painted box - its own w/h when set, else the nearest laid-out
171
- // ancestor's box, its x/y offset mapped to the texture origin. Re-exported raw
172
- // (no reactive auto-cleanup wrapper):
173
- // captureSnapshot resolves asynchronously, by which point the reactive owner is
174
- // no longer current, so the caller owns the returned id and frees it with
175
- // destroyTexture (as with any texture created after an await).
181
+ // captureSnapshot renders a node to pixels and readTexture reads any
182
+ // texture's bytes back; both resolve the same { width, height, data } shape.
183
+ // A laid-out node captures its layout box; a `d-*` node captures its painted
184
+ // box - its own w/h when set, else the nearest laid-out ancestor's box, its
185
+ // x/y offset mapped to the texture origin. A capture creates no texture and
186
+ // nothing needs freeing; to display or sample the result, upload it with
187
+ // createTexture.
176
188
  //
177
- // Together they are the one-shot bake path: draw something only the engine can
178
- // produce (shaped text, an SVG, a themed view), capture it, read the pixels and
179
- // process them on the CPU - baking a glyph atlas is the worked example. Not a
180
- // rendering path: a capture rasterizes the subtree offscreen, reads it back to
181
- // the CPU and re-uploads it, costing a full GPU -> CPU -> GPU round trip and a
182
- // paint pass of latency every call. Batch captures (one paint pass services
183
- // many), never run them per frame, and do not use them to feed live screen
184
- // content into a shader - for that the source has to update in place (another
185
- // pipeline's target, a camera texture).
189
+ // This is the one-shot bake path: draw something only the engine can produce
190
+ // (shaped text, an SVG, a themed view), capture it and process the pixels on
191
+ // the CPU - baking a glyph atlas is the worked example. Not a rendering path:
192
+ // a capture rasterizes the subtree offscreen and reads it back to the CPU,
193
+ // costing a readback stall and a paint pass of latency every call. Batch
194
+ // captures (one paint pass services many), never run them per frame, and do
195
+ // not use them to feed live screen content into a shader - for that the
196
+ // source has to update in place (another pipeline's target, a camera texture).
186
197
  export { captureSnapshot, readTexture } from "flux:gpu"
187
198
 
188
199
  /**
@@ -194,8 +205,9 @@ export { captureSnapshot, readTexture } from "flux:gpu"
194
205
  * reactive scope the texture is freed automatically once that owner is
195
206
  * disposed; when called outside one (e.g. after an `await`, where the owner
196
207
  * is no longer current) nothing is registered and you must call
197
- * `destroyTexture` (from flux:gpu) yourself. Pass `{ manual: true }` to skip
198
- * the auto-free and own the disposal yourself even inside a reactive scope.
208
+ * `destroyTexture` (from flux:gpu) yourself. Pass `{ autoFree: false }` to
209
+ * skip the auto-free and own the disposal yourself even inside a reactive
210
+ * scope.
199
211
  */
200
212
  export function createTexture(
201
213
  data: Uint8Array,
@@ -204,7 +216,7 @@ export function createTexture(
204
216
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
205
217
  ): gpu.TextureId {
206
218
  let id = gpu.createTexture(data, width, height, opts)
207
- if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
219
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
208
220
  return id
209
221
  }
210
222
 
@@ -214,7 +226,7 @@ export function createTexture(
214
226
  * `data` must hold at least `width * height` pixels at the declared format's
215
227
  * size (`* 4` bytes for the default "rgba8", `* 1` for "r8"; it may hold
216
228
  * several frames). Like `createTexture`, the texture is freed automatically
217
- * when the reactive owner is disposed (opt out with `{ manual: true }`);
229
+ * when the reactive owner is disposed (opt out with `{ autoFree: false }`);
218
230
  * created outside a reactive scope you must call `destroyTexture` (from
219
231
  * flux:gpu) yourself.
220
232
  */
@@ -225,7 +237,7 @@ export function createMutableTexture(
225
237
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
226
238
  ): gpu.TextureId {
227
239
  let id = gpu.createMutableTexture(data, width, height, opts)
228
- if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
240
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
229
241
  return id
230
242
  }
231
243
 
@@ -238,7 +250,7 @@ export function createMutableTexture(
238
250
  * scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
239
251
  * array); drive their values with `<texture src={id} params={{...}} />`
240
252
  * (preferred) or, when there is no `<texture>` element for it, imperatively
241
- * with `setShaderParams`. `params` is its own argument - it seeds the same
253
+ * with `setTargetParams`. `params` is its own argument - it seeds the same
242
254
  * live channel those two drive - and takes `null` (or nothing) for a shader
243
255
  * without uniforms. A time-driven shader declares its own time uniform
244
256
  * (`uniform float uTime;`) and the app drives it like any other value.
@@ -248,7 +260,7 @@ export function createMutableTexture(
248
260
  * re-renders whenever a source changes - including a sampled target
249
261
  * re-rendering, transitively through chains. Frees the
250
262
  * texture and shader program when the reactive owner is disposed (opt out
251
- * with `{ manual: true }`); create outside any reactive scope for
263
+ * with `{ autoFree: false }`); create outside any reactive scope for
252
264
  * app-lifetime shaders. For a shader whose source or inputs change
253
265
  * reactively, use {@link createShaderTextureMemo} instead.
254
266
  *
@@ -271,15 +283,15 @@ export function createShaderTexture(
271
283
  opts?: CreateOptions & SamplerOptions & { textures?: Record<string, gpu.TextureId> },
272
284
  ): gpu.TextureId {
273
285
  let id = gpu.createShaderTexture(fragmentSrc, width, height, params, opts)
274
- if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
286
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
275
287
  return id
276
288
  }
277
289
 
278
290
  /**
279
291
  * Creates a render target over a pipeline from `createRenderPipeline` and
280
292
  * renders it once, returning the texture id (usable anywhere a normal
281
- * texture id is, e.g. `<texture src>`; resize with `setShaderSize`, drive
282
- * uniforms with `<texture params>` or `setShaderParams`). Many targets may
293
+ * texture id is, e.g. `<texture src>`; resize with `setTargetSize`, drive
294
+ * uniforms with `<texture params>` or `setTargetParams`). Many targets may
283
295
  * share one pipeline, and creating a target compiles nothing. The target
284
296
  * brings the per-target half: size, the concrete vertex `buffer` the
285
297
  * pipeline's attribute layout describes, the `instanceBuffer` its
@@ -295,7 +307,7 @@ export function createShaderTexture(
295
307
  * `instanceAttributes`, `topology`, `blend`, `cull`, `depth`, `depthWrite`)
296
308
  * lives on the pipeline
297
309
  * and throws here. Frees the target when the reactive owner is disposed (opt
298
- * out with `opts.manual`); the pipeline is yours and outlives it.
310
+ * out with `autoFree: false`); the pipeline is yours and outlives it.
299
311
  *
300
312
  * `render: "manual"` makes it a manual target: the runtime never renders it
301
313
  * (it starts cleared to `clearColor`), only an explicit `renderTarget(id)`
@@ -304,8 +316,7 @@ export function createShaderTexture(
304
316
  * previous contents under each draw - single-target accumulation - while
305
317
  * the default `"clear"` clears to `clearColor` per render; state that must
306
318
  * read its own pixels (decay, blur, simulation) still ping-pongs across two
307
- * manual targets, and `copyTexture` seeds either shape. Distinct from the
308
- * `manual` lifetime option: `render` is who renders, `manual` is who frees.
319
+ * manual targets, and `copyTexture` seeds either shape.
309
320
  */
310
321
  export function createShaderTarget(
311
322
  pipeline: gpu.RenderPipelineId,
@@ -324,7 +335,7 @@ export function createShaderTarget(
324
335
  SamplerOptions,
325
336
  ): gpu.TextureId {
326
337
  let id = gpu.createShaderTarget(pipeline, width, height, params, opts)
327
- if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
338
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
328
339
  return id
329
340
  }
330
341
 
@@ -340,27 +351,41 @@ export function createShaderTarget(
340
351
  * whether an entry tests/writes it stays pipeline state, and a depth-testing
341
352
  * pipeline into a depthless target throws at `addDraw`.
342
353
  *
354
+ * `params` seeds the target's SHARED params - values every entry reads,
355
+ * written once per target instead of once per entry (a camera's
356
+ * view-projection is the motivating case: one `setTargetParams` per camera
357
+ * move instead of one `setDrawParams` per mesh). Shared values apply before
358
+ * each entry's own params, so an entry naming the same uniform overrides
359
+ * the shared value; a name only some entries' programs declare is applied
360
+ * where declared and skipped elsewhere. They are target state: entry
361
+ * add/remove/rebuild cannot lose them. `opts.textures` is the sampler
362
+ * analog - shared sources every entry reads (an environment map, a LUT),
363
+ * driven later with `setTargetTextures`, same precedence and coverage
364
+ * rules.
365
+ *
343
366
  * The render contract is unchanged: the list is input data, so an ordinary
344
367
  * (`render: "auto"`) draw target re-renders exactly when its entries or
345
368
  * their inputs change - a static scene costs zero passes, and one render is
346
369
  * one pass regardless of entry count. `render: "manual"` and `loadOp` work
347
370
  * as on `createShaderTarget`. Returns the texture id; frees on owner
348
- * disposal (opt out with `opts.manual`), taking its entries with it - the
371
+ * disposal (opt out with `autoFree: false`), taking its entries with it - the
349
372
  * entries' pipelines and buffers are yours and outlive it.
350
373
  */
351
374
  export function createDrawTarget(
352
375
  width: number,
353
376
  height: number,
377
+ params?: gpu.ShaderParams | null,
354
378
  opts?: {
355
379
  depth?: boolean
380
+ textures?: Record<string, gpu.TextureId>
356
381
  clearColor?: [number, number, number, number]
357
382
  render?: "auto" | "manual"
358
383
  loadOp?: "clear" | "load"
359
384
  } & CreateOptions &
360
385
  SamplerOptions,
361
386
  ): gpu.TextureId {
362
- let id = gpu.createDrawTarget(width, height, opts)
363
- if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
387
+ let id = gpu.createDrawTarget(width, height, params, opts)
388
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
364
389
  return id
365
390
  }
366
391
 
@@ -399,7 +424,7 @@ function sameRecord(
399
424
  * current texture id (use it as `<texture src={id()} />`) and keeps the GPU
400
425
  * resource in step with `spec` from then on. Changes that keep the compiled
401
426
  * program valid mutate in place at a stable id - a size change routes to
402
- * `setShaderSize`, a params change to `setShaderParams` - while a change to
427
+ * `setTargetSize`, a params change to `setTargetParams` - while a change to
403
428
  * the fragment source or the sampler bindings rebuilds at a fresh id, updates
404
429
  * the accessor, and destroys the old id. That destroy is frame-safe (the
405
430
  * runtime reclaims an id only once the render tree no longer references it),
@@ -436,10 +461,10 @@ export function createShaderTextureMemo(
436
461
  ) {
437
462
  // Program and inputs unchanged: mutate in place, the id stays stable.
438
463
  if (next.width !== current.width || next.height !== current.height) {
439
- gpu.setShaderSize(currentId, next.width, next.height)
464
+ gpu.setTargetSize(currentId, next.width, next.height)
440
465
  }
441
466
  if (!sameRecord(next.params, current.params) && next.params) {
442
- gpu.setShaderParams(currentId, next.params)
467
+ gpu.setTargetParams(currentId, next.params)
443
468
  }
444
469
  current = next
445
470
  return
@@ -483,7 +508,7 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
483
508
  * reference `iResolution` and any uniform they declare (`float`/`int`
484
509
  * scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
485
510
  * array); drive values with `<texture src={id} params={{...}} />` or
486
- * `setShaderParams`, exactly like a fragment shader.
511
+ * `setTargetParams`, exactly like a fragment shader.
487
512
  * `opts.depth` attaches a private depth buffer (cleared + tested per render);
488
513
  * `opts.depthWrite: false` (requires depth) keeps the test but stops the
489
514
  * draw from writing depth. `opts.blend: "add"` makes the draw accumulate
@@ -505,7 +530,7 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
505
530
  * `opts.loadOp` behave exactly as on {@link createShaderTarget}: step the
506
531
  * target with `renderTarget(id)`, and `loadOp: "load"` (manual-only) keeps
507
532
  * the previous contents under each draw. Frees the texture and GL program when the reactive
508
- * owner is disposed (opt out with `opts.manual`); create outside any reactive
533
+ * owner is disposed (opt out with `autoFree: false`); create outside any reactive
509
534
  * scope for app-lifetime pipelines.
510
535
  */
511
536
  export function createPipelineTexture(
@@ -533,7 +558,7 @@ export function createPipelineTexture(
533
558
  SamplerOptions,
534
559
  ): gpu.TextureId {
535
560
  let id = gpu.createPipelineTexture(vertexSrc, fragmentSrc, width, height, params, opts)
536
- if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
561
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
537
562
  return id
538
563
  }
539
564
 
@@ -542,13 +567,13 @@ export function createPipelineTexture(
542
567
  * Float32Array laid out to match the pipeline's interleaved attribute list).
543
568
  * Update it later with {@link writeBuffer}; the buffer's byte size is fixed at
544
569
  * creation, so reserve room up front for dynamic geometry. Freed automatically
545
- * when the reactive owner is disposed (opt out with `{ manual: true }`);
570
+ * when the reactive owner is disposed (opt out with `{ autoFree: false }`);
546
571
  * created outside a reactive scope you must call `destroyBuffer` yourself.
547
572
  * (Destruction order relative to pipelines does not matter.)
548
573
  */
549
574
  export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions): gpu.BufferId {
550
575
  let id = gpu.createBuffer(toUint8(data), opts)
551
- if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyBuffer(id))
576
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyBuffer(id))
552
577
  return id
553
578
  }
554
579
 
package/src/image.ts CHANGED
@@ -1,27 +1,15 @@
1
- // CPU image codec plus the reactive load-and-upload convenience. decodeImage is
2
- // the raw primitive (no GPU involved); createImage is the owner-aware layer on
3
- // top that fetches/decodes/uploads for you and swaps the texture when the source
4
- // changes - the same relationship createTexture/createShaderTexture have to
5
- // flux:gpu.
1
+ // CPU image codec plus the reactive load-and-upload convenience. The codec is
2
+ // flux:image, re-exported here (like the flux:gpu re-exports in gpu.ts) so
3
+ // applications import everything image-shaped from one place; createImage is
4
+ // the owner-aware layer on top that fetches/decodes/uploads for you and swaps
5
+ // the texture when the source changes.
6
6
 
7
7
  import { createMemo, onCleanup } from "@solidjs/signals"
8
+ import { decodeImage, type DecodedImage } from "flux:image"
8
9
  import { createTexture, destroyTexture, type TextureId } from "./gpu"
9
10
 
10
- export type DecodedImage = {
11
- data: Uint8Array
12
- width: number
13
- height: number
14
- }
15
-
16
- /**
17
- * Decodes encoded image bytes (PNG, JPEG, and the other formats the runtime's
18
- * image decoder supports) into raw, tightly-packed RGBA8 pixels plus the
19
- * decoded dimensions. Feed the result straight into `createTexture`. Use this
20
- * when you want manual control; for the common case reach for `createImage`.
21
- */
22
- export function decodeImage(bytes: Uint8Array): DecodedImage {
23
- return image.decodeImage(bytes)
24
- }
11
+ export { decodeImage, encodeImage } from "flux:image"
12
+ export type { DecodedImage } from "flux:image"
25
13
 
26
14
  export type ImageSource = string | Uint8Array
27
15
 
package/src/index.ts CHANGED
@@ -14,13 +14,20 @@ export { capabilities } from "./capabilities"
14
14
  export type { Capabilities, WindowSizeClass } from "./capabilities"
15
15
  export { createTexture } from "./gpu"
16
16
  export type { TextureId } from "./gpu"
17
- export { createImage, decodeImage } from "./image"
17
+ export { createImage, decodeImage, encodeImage } from "./image"
18
18
  export type { DecodedImage, ImageSource } from "./image"
19
19
  export { parseSvg, svg } from "./svg"
20
20
  export type { SvgDocument, SvgDraw } from "./svg"
21
21
  export { createScroll } from "./scroll"
22
22
  export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
23
+ export { arena } from "./arena"
24
+ export type { ArenaOwner } from "./arena"
25
+ export { createPan } from "./pan"
26
+ export type { PanAxis, PanOptions } from "./pan"
27
+ export { createTransform } from "./transform"
28
+ export type { TransformDelta, TransformOptions } from "./transform"
23
29
  export type {
30
+ Element,
24
31
  LayoutProps,
25
32
  TransformProps,
26
33
  PointerProps,
package/src/pan.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { onSettled } from "@solidjs/signals"
2
+ import type { PointerEvent } from "./types"
3
+ import { arena } from "./arena"
4
+
5
+ // Movement in logical pixels before a pan activates. Below this a drag still
6
+ // reads as a press (tap wiggle); crossing it is the positive evidence that the
7
+ // gesture is a pan, at which point the pan steals the pointer in the arena.
8
+ const PAN_SLOP = 8
9
+
10
+ export type PanAxis = "vertical" | "horizontal" | "both"
11
+
12
+ export interface PanOptions {
13
+ /**
14
+ * Which movement direction activates the pan. Slop is axis-aware: a
15
+ * "vertical" pan only activates once vertical travel crosses the threshold,
16
+ * so nested cross-axis scrollers each take only drags along their own axis.
17
+ * Default "both" (straight-line distance).
18
+ */
19
+ axis?: PanAxis
20
+ onPanStart?: () => void
21
+ /** Pointer movement since the previous event; positive dx is rightward, positive dy downward. */
22
+ onPanMove?: (dx: number, dy: number) => void
23
+ onPanEnd?: () => void
24
+ }
25
+
26
+ // The pan recognizer: turns a drag into a movement-delta stream. On a down it
27
+ // arms and starts measuring; when travel from the down point crosses the slop
28
+ // along the enabled axis it activates, stealing the pointer in the arena (a
29
+ // press that provisionally owned it is cancelled - its feedback retracts) and
30
+ // resolving it so no other recognizer can take the drag over. If the arena is
31
+ // already resolved (an inner pan won first) the recognizer disarms and stays
32
+ // out. The slop distance itself is swallowed: deltas stream from the
33
+ // activation point on. Moves and the up arrive on the frozen down path, so an
34
+ // active pan keeps streaming when the pointer leaves the node or the window.
35
+ // cancel() is the external-cancel hook; it ends an active pan without
36
+ // onPanEnd. Options are read at event time. Single-pointer by design; for
37
+ // multi-pointer pinch/rotate compose createTransform instead.
38
+ export function createPan(options: PanOptions) {
39
+ // Down position while armed; last delivered position while active.
40
+ let origin: { x: number; y: number } | null = null
41
+ let active: number | null = null
42
+ let armed: number | null = null
43
+
44
+ let past = (e: PointerEvent) => {
45
+ if (!origin) return false
46
+ let dx = Math.abs(e.clientX - origin.x)
47
+ let dy = Math.abs(e.clientY - origin.y)
48
+ let axis = options.axis ?? "both"
49
+ if (axis === "vertical") return dy >= PAN_SLOP
50
+ if (axis === "horizontal") return dx >= PAN_SLOP
51
+ return dx * dx + dy * dy >= PAN_SLOP * PAN_SLOP
52
+ }
53
+
54
+ let reset = () => {
55
+ if (active != null) {
56
+ arena.release(active, owner)
57
+ active = null
58
+ }
59
+ armed = null
60
+ origin = null
61
+ }
62
+ let cancel = reset
63
+ let owner = { cancel }
64
+
65
+ // An unmount mid-drag must not leave a resolved claim behind.
66
+ onSettled(() => reset)
67
+
68
+ let handlers = {
69
+ onPointerDown: (e: PointerEvent) => {
70
+ if (e.button != null && e.button !== 0) return
71
+ if (armed == null && active == null) {
72
+ armed = e.pointerId
73
+ origin = { x: e.clientX, y: e.clientY }
74
+ }
75
+ },
76
+ onPointerMove: (e: PointerEvent) => {
77
+ if (armed === e.pointerId && past(e)) {
78
+ if (arena.steal(e.pointerId, owner)) {
79
+ active = e.pointerId
80
+ armed = null
81
+ origin = { x: e.clientX, y: e.clientY }
82
+ options.onPanStart?.()
83
+ } else {
84
+ // The arena is resolved against us; the drag belongs elsewhere.
85
+ reset()
86
+ }
87
+ return
88
+ }
89
+ if (active === e.pointerId && origin) {
90
+ options.onPanMove?.(e.clientX - origin.x, e.clientY - origin.y)
91
+ origin = { x: e.clientX, y: e.clientY }
92
+ }
93
+ },
94
+ onPointerUp: (e: PointerEvent) => {
95
+ if (active === e.pointerId) {
96
+ reset()
97
+ options.onPanEnd?.()
98
+ } else if (armed === e.pointerId) {
99
+ reset()
100
+ }
101
+ },
102
+ }
103
+
104
+ return { handlers, cancel }
105
+ }
package/src/renderer.ts CHANGED
@@ -148,8 +148,11 @@ function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
148
148
  try {
149
149
  tree.setProperty(node.id, name, value)
150
150
  } catch (e) {
151
+ // Name-level rejections (the exact prefixes apply_jsx in flux emits) are
152
+ // warn-and-continue so a stale prop does not kill the app; a bad VALUE for
153
+ // a known property rethrows, per the throw-in-dev validation policy.
151
154
  let message = String(e)
152
- if (!message.includes("unknown property") && !message.includes("detached-only")) throw e
155
+ if (!message.includes("Unknown property") && !message.includes("Detached-only")) throw e
153
156
  let key = node.elementType + "." + name
154
157
  if (warnedRejectedProps.has(key)) return
155
158
  warnedRejectedProps.add(key)
@@ -49,7 +49,11 @@ declare module "*.ogg" {
49
49
  }
50
50
 
51
51
  // UI event bus (lattice), provided by the runtime as a builtin module.
52
- // on/once return an unsubscribe function.
52
+ // on/once return an unsubscribe function. Notable events: the routed pointer
53
+ // stream ("pointerMove"/"pointerDown"/... consumed by window.ts),
54
+ // "pointerFrame" (the move-batch terminator: fires after all of a frame's
55
+ // pointer moves have dispatched, every pointer the same age - multi-pointer
56
+ // recognizers measure there), and "render" (the per-frame signal).
53
57
  declare module "srt:events" {
54
58
  export function on(event: string, callback: (data: any) => void): () => void
55
59
  export function once(event: string, callback: (data: any) => void): () => void
@@ -0,0 +1,300 @@
1
+ import { onSettled } from "@solidjs/signals"
2
+ import { on } from "srt:events"
3
+ import type { PointerEvent } from "./types"
4
+ import { arena, type ArenaOwner } from "./arena"
5
+
6
+ // Focal travel or span change in logical pixels before the transform
7
+ // activates; the same threshold createPan uses, so the two race fairly.
8
+ const SLOP = 8
9
+ // Per-frame EMA weight for the span that feeds `scale` (~50ms time constant
10
+ // at a 65Hz frame rate - see the zoom-noise note below). The pre-batching
11
+ // weight was 0.15 stepped per event at ~130Hz combined; steps now come once
12
+ // per frame, so the weight compounds: 1 - (1 - 0.15)^2.
13
+ const SPAN_SMOOTH = 0.28
14
+ // Pinch-vs-hold discrimination on the span RATE, an EMA (QUIET_SMOOTH
15
+ // weight) of the smoothed span's per-frame change. Engaging needs both a
16
+ // SLOP excursion and rate >= ENGAGE (~40px/s at 65Hz); an engaged pinch
17
+ // re-locks when rate falls under QUIET (~20px/s). Creep sits near 0.14
18
+ // px/frame, deliberate pinches at 1.5+ - the 2x gap between the two
19
+ // thresholds is the hysteresis that keeps the gate from chattering.
20
+ // All three step-based constants assume a ~65Hz frame rate (the captured
21
+ // tablet); a 120Hz panel halves per-step deltas. If that ever bites,
22
+ // convert the thresholds to px/s with performance.now() read at the
23
+ // pointerFrame terminator.
24
+ const QUIET = 0.3
25
+ const ENGAGE = 0.6
26
+ const QUIET_SMOOTH = 0.19
27
+
28
+ export type TransformDelta = {
29
+ /** Focal-point movement since the previous frame, logical px. */
30
+ dx: number
31
+ dy: number
32
+ /** Multiplicative span change since the previous frame (1 = unchanged, >1 = fingers spreading). */
33
+ scale: number
34
+ /** Rotation of the pointer pair since the previous frame, radians (0 with a single pointer). */
35
+ rotation: number
36
+ /** Current focal point in window coordinates (the clientX/clientY frame) - the zoom-about anchor. */
37
+ x: number
38
+ y: number
39
+ /** How many pointers are down right now. Consumers that give one- and
40
+ * two-finger translation different meanings (rotate vs pan) route on this;
41
+ * dx/dy alone cannot tell them apart, both are focal movement. */
42
+ pointers: number
43
+ }
44
+
45
+ export interface TransformOptions {
46
+ onTransformStart?: () => void
47
+ /** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
48
+ onTransformMove?: (t: TransformDelta) => void
49
+ onTransformEnd?: () => void
50
+ }
51
+
52
+ // The merged transform recognizer: pan + pinch + rotate as ONE gesture over
53
+ // the set of pointers down on the node (Flutter's Scale model). Splitting
54
+ // them into separate recognizers would make them fight in the arena over the
55
+ // same fingers, so the genuinely-simultaneous case (drag while pinching a
56
+ // photo, one-finger orbit vs two-finger zoom) is a single recognizer that
57
+ // streams focal-point translation, span scale, and pair rotation together;
58
+ // consumers use the components they care about (an orbit camera reads dx/dy
59
+ // and scale, ignores rotation).
60
+ //
61
+ // Pointers arm silently on their down (no arena claim); when focal travel or
62
+ // span change from the armed configuration crosses the slop, that is positive
63
+ // evidence and the recognizer steals EVERY tracked pointer, all or nothing:
64
+ // if any is already resolved elsewhere (an inner pan won that finger) the
65
+ // whole gesture stands down. The slop is swallowed - deltas stream from the
66
+ // activation configuration on. With one finger it degrades to a plain pan
67
+ // (scale 1, rotation 0), so a drag on the node gets arena arbitration too.
68
+ //
69
+ // Measurement is per FRAME, not per event. Move events update pointer
70
+ // positions and arena state as they bubble (claims must stay visible
71
+ // synchronously within the dispatch walk), but the cross-pointer measure -
72
+ // centroid, span, angle - waits for the "pointerFrame" terminator the
73
+ // runtime emits after all of a frame's moves have dispatched. At that point
74
+ // every pointer is the same age, so the per-event scissor is gone by
75
+ // construction (measuring on each event paired one fresh position with the
76
+ // other pointers' frame-stale ones, and the span oscillated around its true
77
+ // value under perfectly smooth motion), and one delta emits per frame - the
78
+ // cadence anything painting consumes anyway. Anchors defer to the same
79
+ // terminator for the same reason: a mid-batch anchor would bake one
80
+ // mixed-age jolt into the first delta after it.
81
+ //
82
+ // Residual span noise survives batching, tuned against a captured tablet
83
+ // stream (sensor dither +-1-3px/event - and a resting pair's span WANDERS
84
+ // 28-76px over seconds, human fingers cannot hold separation, so no
85
+ // threshold alone can stay closed):
86
+ // - a slop gate: scale is 1 until the span leaves its gesture baseline by
87
+ // SLOP, so a pan or hold that never really pinches never zooms;
88
+ // - a low-pass: once engaged, scale comes from an EMA of the span
89
+ // (SPAN_SMOOTH). Zero-mean dither averages out; deliberate pinching - a
90
+ // sustained signed drift - passes with ~50ms lag. With the scissor gone
91
+ // this is belt-and-braces; a retirement candidate after an on-device
92
+ // A/B against the raw batched span.
93
+ // - a re-lock: pressed fingertips that HOLD still slowly flatten and roll
94
+ // toward each other, which the sensor reports as a genuine ~9px/s span
95
+ // shrink - a held pinch would zoom out forever. Deliberate pinches
96
+ // measured 100-160px/s, an order of magnitude above creep, so when the
97
+ // smoothed span rate stays under QUIET the gate re-locks and rebases;
98
+ // crossing SLOP from the new base re-engages instantly. No amount of
99
+ // batching removes creep: it is real reported motion.
100
+ // dx/dy have no gate or filter: their noise term is a fraction of a pixel.
101
+ //
102
+ // Set changes rebase: a finger joining (stolen outright - the gesture is
103
+ // already established) or lifting mid-gesture re-anchors the reference
104
+ // configuration, so the change itself never emits a jump delta. The gesture
105
+ // ends when the last finger lifts. Moves and ups arrive on the frozen down
106
+ // path, so an active transform survives leaving the node or the window.
107
+ // cancel() is the external-cancel hook; it ends an active gesture without
108
+ // onTransformEnd. Options are read at event time.
109
+ export function createTransform(options: TransformOptions) {
110
+ // All tracked pointers, in down order (the first two define the rotation pair).
111
+ let pointers = new Map<number, { x: number; y: number }>()
112
+ let active = false
113
+ // Slop baseline while armed; last delivered configuration while active.
114
+ let ref: { x: number; y: number; span: number; angle: number } | null = null
115
+ // The zoom gate and filter (see the zoom-noise note above): scale stays 1
116
+ // until the SMOOTHED span leaves spanBase by SLOP, then streams smoothed
117
+ // ratios for the rest of the gesture.
118
+ let pinch = false
119
+ let spanBase = 0
120
+ let smoothSpan = 0
121
+ // Warm-started at each engage so a fresh pinch cannot instantly re-lock.
122
+ let spanRate = 0
123
+ // Motion arrived this frame; measured and emitted at the terminator.
124
+ let dirty = false
125
+ // The anchor must be retaken at the terminator (activation and set-change
126
+ // rebases): mid-batch the pointer map is mixed-age, so anchoring
127
+ // immediately would bake a jolt into the next delta. A rebase frame
128
+ // emits nothing.
129
+ let rebase = false
130
+
131
+ let measure = () => {
132
+ let n = pointers.size
133
+ let x = 0
134
+ let y = 0
135
+ for (let p of pointers.values()) {
136
+ x += p.x
137
+ y += p.y
138
+ }
139
+ x /= n
140
+ y /= n
141
+ let span = 0
142
+ for (let p of pointers.values()) span += Math.hypot(p.x - x, p.y - y)
143
+ span /= n
144
+ let angle = 0
145
+ if (n >= 2) {
146
+ let pair = pointers.values()
147
+ let a = pair.next().value!
148
+ let b = pair.next().value!
149
+ angle = Math.atan2(b.y - a.y, b.x - a.x)
150
+ }
151
+ return { x, y, span, angle }
152
+ }
153
+
154
+ let reset = () => {
155
+ if (active) for (let id of pointers.keys()) arena.release(id, owner)
156
+ pointers.clear()
157
+ active = false
158
+ ref = null
159
+ pinch = false
160
+ dirty = false
161
+ rebase = false
162
+ }
163
+ let cancel = reset
164
+ let owner: ArenaOwner = { cancel }
165
+
166
+ // The per-frame measure point: runs at the pointerFrame terminator, when
167
+ // every tracked pointer's position is the same age.
168
+ let flush = () => {
169
+ if (!active) return
170
+ if (rebase) {
171
+ // Anchor from same-age positions; emits nothing - an activation or
172
+ // set change must not produce a jump delta. Motion that arrived in
173
+ // the same batch folds into the anchor.
174
+ ref = measure()
175
+ spanBase = ref.span
176
+ smoothSpan = ref.span
177
+ rebase = false
178
+ dirty = false
179
+ return
180
+ }
181
+ if (!dirty || !ref) return
182
+ dirty = false
183
+ let m = measure()
184
+ let prevSpan = smoothSpan
185
+ smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
186
+ spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
187
+ if (!pinch && Math.abs(smoothSpan - spanBase) >= SLOP) {
188
+ // A slop excursion at speed is a pinch; at creep speed the base
189
+ // just follows, so drift can wander forever without engaging.
190
+ if (spanRate >= ENGAGE) pinch = true
191
+ else spanBase = smoothSpan
192
+ }
193
+ let scale = pinch && prevSpan > 0 && smoothSpan > 0 ? smoothSpan / prevSpan : 1
194
+ if (pinch && spanRate < QUIET) {
195
+ // Holding, not pinching (see the re-lock note above).
196
+ pinch = false
197
+ spanBase = smoothSpan
198
+ }
199
+ let rotation = 0
200
+ if (pointers.size >= 2) {
201
+ rotation = m.angle - ref.angle
202
+ if (rotation > Math.PI) rotation -= 2 * Math.PI
203
+ else if (rotation < -Math.PI) rotation += 2 * Math.PI
204
+ }
205
+ let dx = m.x - ref.x
206
+ let dy = m.y - ref.y
207
+ ref = m
208
+ options.onTransformMove?.({ dx, dy, scale, rotation, x: m.x, y: m.y, pointers: pointers.size })
209
+ }
210
+
211
+ // An unmount mid-gesture must not leave resolved claims behind (or a live
212
+ // terminator subscription).
213
+ onSettled(() => {
214
+ let unsub = on("pointerFrame", flush)
215
+ return () => {
216
+ unsub()
217
+ reset()
218
+ }
219
+ })
220
+
221
+ let handlers = {
222
+ onPointerDown: (e: PointerEvent) => {
223
+ if (e.button != null && e.button !== 0) return
224
+ if (pointers.has(e.pointerId)) return
225
+ if (active) {
226
+ // A finger joining an established gesture belongs to it outright; if
227
+ // the arena refuses (resolved elsewhere) the finger stays out.
228
+ if (!arena.steal(e.pointerId, owner)) return
229
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
230
+ rebase = true
231
+ return
232
+ }
233
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
234
+ ref = measure()
235
+ },
236
+ onPointerMove: (e: PointerEvent) => {
237
+ let p = pointers.get(e.pointerId)
238
+ if (!p || !ref) return
239
+ p.x = e.clientX
240
+ p.y = e.clientY
241
+ if (!active) {
242
+ // Arming runs per event: the slop test tolerates a mixed-age
243
+ // measure (8px against <=1 frame of staleness), and the arena
244
+ // steal must happen synchronously inside the dispatch walk.
245
+ let m = measure()
246
+ let travel = Math.hypot(m.x - ref.x, m.y - ref.y)
247
+ if (travel < SLOP && Math.abs(m.span - ref.span) < SLOP) return
248
+ let taken: number[] = []
249
+ let refused = false
250
+ for (let id of pointers.keys()) {
251
+ if (arena.steal(id, owner)) taken.push(id)
252
+ else {
253
+ refused = true
254
+ break
255
+ }
256
+ }
257
+ if (refused) {
258
+ // The gesture belongs elsewhere; hand back what we took and disarm.
259
+ for (let id of taken) arena.release(id, owner)
260
+ pointers.clear()
261
+ ref = null
262
+ return
263
+ }
264
+ active = true
265
+ // A span-driven activation IS a deliberate pinch - engaging the
266
+ // zoom gate now avoids demanding a second slop-crossing from it.
267
+ pinch = Math.abs(m.span - ref.span) >= SLOP
268
+ spanRate = pinch ? 1 : 0
269
+ rebase = true
270
+ options.onTransformStart?.()
271
+ return
272
+ }
273
+ dirty = true
274
+ },
275
+ onPointerUp: (e: PointerEvent) => {
276
+ if (!pointers.has(e.pointerId)) return
277
+ if (active) {
278
+ arena.release(e.pointerId, owner)
279
+ pointers.delete(e.pointerId)
280
+ if (pointers.size === 0) {
281
+ active = false
282
+ ref = null
283
+ pinch = false
284
+ dirty = false
285
+ rebase = false
286
+ options.onTransformEnd?.()
287
+ } else {
288
+ rebase = true
289
+ pinch = pinch && pointers.size >= 2
290
+ spanRate = 1
291
+ }
292
+ return
293
+ }
294
+ pointers.delete(e.pointerId)
295
+ ref = pointers.size > 0 ? measure() : null
296
+ },
297
+ }
298
+
299
+ return { handlers, cancel }
300
+ }
package/src/types.d.ts CHANGED
@@ -23,10 +23,6 @@ declare global {
23
23
  readonly env: { readonly DEV: boolean }
24
24
  }
25
25
 
26
- let image: {
27
- decodeImage(bytes: Uint8Array): { data: Uint8Array, width: number, height: number }
28
- }
29
-
30
26
  let speech: {
31
27
  start(options: {
32
28
  model: Uint8Array, vadModel: Uint8Array, lang?: string, microphone?: number,
@@ -419,8 +415,10 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
419
415
  * transform - it never sizes the element, so give the box its size with
420
416
  * layout props. Composed innermost: the transform props still operate in
421
417
  * box space, and pointer events on children arrive in design coordinates.
422
- * The natural wrapper for parseSvg draws, or any d-* subtree authored in
423
- * fixed design units.
418
+ * The overflow clip and scrollX/scrollY stay box properties too: the clip
419
+ * rect is the layout box and scroll offsets are box pixels, regardless of
420
+ * fit scale. The natural wrapper for parseSvg draws, or any d-* subtree
421
+ * authored in fixed design units.
424
422
  */
425
423
  viewBox?: [number, number]
426
424
  /**
@@ -586,11 +584,13 @@ export interface TextureProps extends PaintProps, PointerProps {
586
584
  srcY?: number
587
585
  srcW?: number
588
586
  srcH?: number
589
- // Shader uniform values, when src names a shader texture. Applied at the
590
- // next repaint (not synchronously), so a fast-changing signal stays paced
591
- // to real frames rather than triggering a GL render pass per write. A
592
- // number drives a scalar (`float`/`int`); a flat number array drives the
593
- // declared GLSL type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major)
594
- // for `mat4`.
587
+ // Shader uniform values, when src names a render target: the same channel
588
+ // setTargetParams drives, written through it directly - prop and
589
+ // imperative writes validate and error identically (an unknown name
590
+ // throws, and set src before params: a write with no src to route to
591
+ // throws). However often a signal writes, the target renders once per
592
+ // frame at the raster flush. A number drives a scalar (`float`/`int`); a
593
+ // flat number array drives the declared GLSL type: 2/3/4 for
594
+ // `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
595
595
  params?: Record<string, number | number[]>
596
596
  }
package/src/window.ts CHANGED
@@ -38,26 +38,42 @@ let refreshRate = 60
38
38
  * the present count, and `rate` is the current refresh rate in Hz. `tick` is paced
39
39
  * by the runtime (one refresh period per present, slow-corrected toward the wall
40
40
  * clock) so animations driven off it stay smooth even when swap-return times
41
- * jitter. For raw, continuous wall-clock time (e.g. measuring code), use
42
- * performance.now() instead.
41
+ * jitter. performance.now() and timers report/march on this same paced timeline
42
+ * (so the whole time surface freezes together under the dev tools' clock
43
+ * control); for real wall-clock time use Date.now().
43
44
  * Returns a cleanup function; also auto-cleans within a reactive scope.
44
45
  */
45
46
  export function onFrame(fn: (tick: number, frame: number, rate: number) => void) {
46
47
  let frameId: number = null!
48
+ // Cancellation is a flag, not map membership: while a frame runs the whole
49
+ // callback map is swapped out, so a cleanup() called from another onFrame
50
+ // callback in the same tick could not reach this entry through the map.
51
+ let cancelled = false
47
52
 
48
53
  let extendedFn = (tick: number, frame: number, rate: number) => {
49
- fn(tick, frame, rate)
54
+ if (cancelled) return
55
+ // Re-register BEFORE running fn, so a throwing callback stays subscribed
56
+ // (event-listener semantics: the error is reported, the subscription
57
+ // lives) and a cleanup() from inside fn sees the current frameId. A
58
+ // pending onFrame callback is a standing request for the next frame.
50
59
  frameId = nextFrameId++
51
60
  animationFrames.set(frameId, extendedFn)
52
- // A pending onFrame callback is a standing request for the next frame.
53
61
  requestFrame()
62
+ try {
63
+ fn(tick, frame, rate)
64
+ } catch (err) {
65
+ console.error("Error in onFrame callback:", err)
66
+ }
54
67
  }
55
68
 
56
69
  frameId = nextFrameId++
57
70
  animationFrames.set(frameId, extendedFn)
58
71
  requestFrame()
59
72
 
60
- let cleanup = () => animationFrames.delete(frameId)
73
+ let cleanup = () => {
74
+ cancelled = true
75
+ animationFrames.delete(frameId)
76
+ }
61
77
  onCleanup(cleanup)
62
78
  return cleanup
63
79
  }
@@ -253,7 +269,13 @@ export function attachWindow(nodeId: number) {
253
269
  animationFrames = new Map()
254
270
  for (let fn of frames.values()) fn(t, frame, refreshRate)
255
271
  }
256
- flush()
272
+ try {
273
+ // A throwing effect must not skip renderFrame: the frame still paints
274
+ // whatever state committed before the throw.
275
+ flush()
276
+ } catch (err) {
277
+ console.error("Error in reactive flush:", err)
278
+ }
257
279
  scanForOrphans(t)
258
280
  renderFrame()
259
281
  }
@@ -298,7 +320,13 @@ export function attachWindow(nodeId: number) {
298
320
  e.localY = localY[i]!
299
321
  e.parentX = parentX[i]!
300
322
  e.parentY = parentY[i]!
301
- getEventHandler(targets[i]!, handler)?.(e)
323
+ try {
324
+ getEventHandler(targets[i]!, handler)?.(e)
325
+ } catch (err) {
326
+ // One throwing handler must not suppress delivery to the rest of
327
+ // the path (or the focus/blur step that follows the loop).
328
+ console.error(`Error in ${handler} handler:`, err)
329
+ }
302
330
  if (stopped) break
303
331
  }
304
332
  }