@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 +17 -1
- package/examples/README.md +2 -0
- package/examples/gpu-draw-list.tsx +1 -1
- package/examples/gpu-shared-params.tsx +135 -0
- package/package.json +5 -5
- package/src/arena.ts +60 -0
- package/src/color.ts +5 -2
- package/src/gpu.ts +94 -69
- package/src/image.ts +8 -20
- package/src/index.ts +8 -1
- package/src/pan.ts +105 -0
- package/src/renderer.ts +4 -1
- package/src/runtime-modules.d.ts +5 -1
- package/src/transform.ts +300 -0
- package/src/types.d.ts +12 -12
- package/src/window.ts +35 -7
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.
|
|
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
|
package/examples/README.md
CHANGED
|
@@ -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.
|
|
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.
|
|
30
|
+
"@solidrt/flux-types": "0.0.47"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@solidjs/signals": "2.0.0-beta.
|
|
34
|
-
"@solidjs/universal": "2.0.0-beta.
|
|
35
|
-
"solid-js": "2.0.0-beta.
|
|
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
|
|
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
|
-
//
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|
|
49
|
-
// auto-free, for resources whose lifetime is managed by hand
|
|
50
|
-
// signal changes inside a long-lived component, handed across
|
|
51
|
-
// Without
|
|
52
|
-
// owner: a leak until unmount, then a double-free
|
|
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 = {
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
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
|
|
118
|
-
//
|
|
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)
|
|
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
|
|
169
|
-
// texture's bytes back
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// paint pass
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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 `{
|
|
198
|
-
* the auto-free and own the disposal yourself even inside a reactive
|
|
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 (
|
|
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 `{
|
|
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 (
|
|
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 `
|
|
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 `{
|
|
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 (
|
|
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 `
|
|
282
|
-
* uniforms with `<texture params>` or `
|
|
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 `
|
|
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.
|
|
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 (
|
|
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 `
|
|
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 (
|
|
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
|
-
* `
|
|
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.
|
|
464
|
+
gpu.setTargetSize(currentId, next.width, next.height)
|
|
440
465
|
}
|
|
441
466
|
if (!sameRecord(next.params, current.params) && next.params) {
|
|
442
|
-
gpu.
|
|
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
|
-
* `
|
|
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 `
|
|
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 (
|
|
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 `{
|
|
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 (
|
|
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.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
11
|
-
|
|
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("
|
|
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)
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -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
|
package/src/transform.ts
ADDED
|
@@ -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
|
|
423
|
-
*
|
|
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
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
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.
|
|
42
|
-
*
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|