@solidrt/core 0.0.63 → 0.0.65
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 +92 -9
- package/README.md +1 -1
- package/agents/performance.md +7 -2
- package/docs/index.md +9 -0
- package/package.json +7 -5
- package/src/camera-control.ts +9 -2
- package/src/core.ts +6 -1
- package/src/double-tap.ts +2 -2
- package/src/gamepad.ts +32 -1
- package/src/gpu.ts +25 -10
- package/src/image.ts +10 -2
- package/src/index.ts +8 -4
- package/src/input-gamepad-device.ts +38 -14
- package/src/input-gamepad.ts +6 -4
- package/src/input-keyboard.ts +11 -2
- package/src/input-map.ts +12 -1
- package/src/input-pointer.ts +3 -3
- package/src/input-processors.ts +65 -25
- package/src/pan.ts +4 -3
- package/src/renderer.ts +1 -1
- package/src/runtime-modules.d.ts +68 -1
- package/src/swipe.ts +6 -6
- package/src/textures.ts +333 -0
- package/src/transform.ts +16 -7
- package/src/types.d.ts +66 -12
- package/src/velocity.ts +113 -50
- package/src/window.ts +41 -3
package/src/textures.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// @solidrt/core/textures - what a texture bake and a loader share, without
|
|
2
|
+
// the runtime: this entry imports nothing the runtime provides, so a bake
|
|
3
|
+
// script under bun and an extension's pure modules use it as it is
|
|
4
|
+
// (tests/textures.test.ts imports it on the bare flux binary and fails the
|
|
5
|
+
// moment a gui or srt: import creeps in). Any extension that bakes or loads textures
|
|
6
|
+
// builds on this, the first-party 3d one included.
|
|
7
|
+
//
|
|
8
|
+
// isKtx2 tells a compressed texture file from a PNG or JPEG.
|
|
9
|
+
//
|
|
10
|
+
// textureSettings reads how an app's textures are compressed at bake
|
|
11
|
+
// time: the `textures` group of the `solidrt` key in its package.json.
|
|
12
|
+
//
|
|
13
|
+
// "solidrt": {
|
|
14
|
+
// "textures": {
|
|
15
|
+
// "compress": true,
|
|
16
|
+
// "data": { "codec": "uastc", "quality": 0.9 },
|
|
17
|
+
// "files": [
|
|
18
|
+
// { "match": "assets/sponza/textures/lion_*.png", "quality": 0.95 },
|
|
19
|
+
// { "match": ["assets/ui/**", "assets/hero.glb#lut"], "compress": false }
|
|
20
|
+
// ]
|
|
21
|
+
// }
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// Three levels, the narrower one winning: the app (`compress`), a kind of
|
|
25
|
+
// texture (`compress`, `codec`, `quality`), and the entries of `files`,
|
|
26
|
+
// each naming files by glob pattern and setting the same three for them.
|
|
27
|
+
// Whatever is left out keeps what the level above says, and a null is a
|
|
28
|
+
// key left out. Anything unknown throws naming the key.
|
|
29
|
+
//
|
|
30
|
+
// A pattern is matched against a file's path from the project root,
|
|
31
|
+
// written with "/". A texture that is a file of its own is that file; one
|
|
32
|
+
// embedded in another file (an image inside a .glb) is that file, and
|
|
33
|
+
// "#name" after the pattern picks it by its name there. The pattern
|
|
34
|
+
// language is matchesGlob's (flux:path), which the caller passes in, as
|
|
35
|
+
// it passes the scan (glob, flux:fs): this entry stays without a runtime.
|
|
36
|
+
|
|
37
|
+
// The first twelve bytes of every KTX2 file.
|
|
38
|
+
const KTX2_MAGIC = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
39
|
+
|
|
40
|
+
/** Whether `bytes` starts as a KTX2 file, the compressed texture
|
|
41
|
+
* `transcodeTexture` takes: what tells one from a PNG or JPEG. */
|
|
42
|
+
export function isKtx2(bytes: Uint8Array): boolean {
|
|
43
|
+
return bytes.length >= KTX2_MAGIC.length && KTX2_MAGIC.every((b, i) => bytes[i] === b)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The codecs a texture is encoded with: `encodeTexture`'s (flux:image),
|
|
47
|
+
* named here so that this entry needs no runtime type. "etc1s" is the
|
|
48
|
+
* small one, "uastc" the accurate one. */
|
|
49
|
+
export type TextureCodec = "etc1s" | "uastc"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the texels of a texture ARE, which decides how it may be
|
|
53
|
+
* compressed. Not what draws it: an extension maps its own uses (a
|
|
54
|
+
* material's slots, a sprite sheet, a terrain's layers) onto these.
|
|
55
|
+
*
|
|
56
|
+
* - `color`: color as the eye sees it, sRGB-encoded. A photo, a painted
|
|
57
|
+
* surface, a sprite, a tile set.
|
|
58
|
+
* - `normal`: directions, unit vectors packed into the channels. An error
|
|
59
|
+
* tilts the lighting computed from them.
|
|
60
|
+
* - `data`: any other linear values that weigh a computation and tolerate
|
|
61
|
+
* a small error: a mask, a roughness, a height for parallax.
|
|
62
|
+
*
|
|
63
|
+
* Values that must come back EXACT are none of these and are not block
|
|
64
|
+
* compressed at all, at any setting: a lookup table, a distance field, an
|
|
65
|
+
* index map. The same goes for sharp art and text. An entry of `files`
|
|
66
|
+
* with `compress: false` keeps such a texture as its file.
|
|
67
|
+
*/
|
|
68
|
+
export type TextureKind = "color" | "normal" | "data"
|
|
69
|
+
|
|
70
|
+
export type TextureSetting = {
|
|
71
|
+
/** Whether the texture is compressed at all. False keeps its file as it
|
|
72
|
+
* is, and `codec` and `quality` say nothing then. */
|
|
73
|
+
compress: boolean
|
|
74
|
+
codec: TextureCodec
|
|
75
|
+
/** 0..1, higher is larger and closer to the source. */
|
|
76
|
+
quality: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One entry of `files`: the files it names and what it sets for them. */
|
|
80
|
+
export type TextureFileRule = {
|
|
81
|
+
/** The patterns, any of which names a file of this entry. */
|
|
82
|
+
match: string[]
|
|
83
|
+
compress?: boolean
|
|
84
|
+
codec?: TextureCodec
|
|
85
|
+
quality?: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type TextureSettings = { readonly [kind in TextureKind]: Readonly<TextureSetting> } & {
|
|
89
|
+
/** The entries in the order written; where several name one file, a
|
|
90
|
+
* later entry's fields win over an earlier one's. */
|
|
91
|
+
readonly files: readonly Readonly<TextureFileRule>[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What a texture is to the settings: the file holding it, as its path
|
|
95
|
+
* from the project root, and for a texture embedded in that file its name
|
|
96
|
+
* there. */
|
|
97
|
+
export type TextureSource = { file: string; name?: string }
|
|
98
|
+
|
|
99
|
+
/** Whether a path matches a glob pattern: `matchesGlob` of flux:path. */
|
|
100
|
+
export type GlobMatcher = (path: string, pattern: string) => boolean
|
|
101
|
+
|
|
102
|
+
/** The files a glob pattern matches, from the project root: `glob` of
|
|
103
|
+
* flux:fs. */
|
|
104
|
+
export type GlobScan = (pattern: string) => Promise<string[]>
|
|
105
|
+
|
|
106
|
+
const KINDS: TextureKind[] = ["color", "normal", "data"]
|
|
107
|
+
const CODECS: TextureCodec[] = ["etc1s", "uastc"]
|
|
108
|
+
|
|
109
|
+
// What parts a pattern that picks an embedded texture: the file before it,
|
|
110
|
+
// the name after it.
|
|
111
|
+
const NAME_MARK = "#"
|
|
112
|
+
|
|
113
|
+
// Textures are compressed unless the app says otherwise: a quarter of the
|
|
114
|
+
// GPU memory and a faster load are what an app that says nothing wants.
|
|
115
|
+
const DEFAULT_COMPRESS = true
|
|
116
|
+
|
|
117
|
+
// The defaults, by what each kind tolerates. Color takes the small codec
|
|
118
|
+
// (ETC1S moves little across its scale, so it sits high). Directions show
|
|
119
|
+
// their errors as blotches in the lighting, and data is often several
|
|
120
|
+
// unrelated values packed into the channels of one image, which the small
|
|
121
|
+
// codec handles badly: both take the accurate one, at the quality below
|
|
122
|
+
// which it gives up detail for almost no bytes. This is what the Khronos
|
|
123
|
+
// KTX guide and glTF-Transform recommend. The accurate codec is the larger
|
|
124
|
+
// download; an app that prefers the smaller one says so in its settings.
|
|
125
|
+
const SMALL_CODEC_QUALITY = 0.75
|
|
126
|
+
const ACCURATE_CODEC_QUALITY = 0.9
|
|
127
|
+
|
|
128
|
+
function frozen(settings: { [kind in TextureKind]: TextureSetting }, files: TextureFileRule[]): TextureSettings {
|
|
129
|
+
for (let kind of KINDS) Object.freeze(settings[kind])
|
|
130
|
+
for (let rule of files) {
|
|
131
|
+
Object.freeze(rule.match)
|
|
132
|
+
Object.freeze(rule)
|
|
133
|
+
}
|
|
134
|
+
return Object.freeze({ ...settings, files: Object.freeze(files) })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The settings of an app that declares none. Frozen, like every
|
|
138
|
+
* settings object this entry hands out. */
|
|
139
|
+
export const DEFAULT_TEXTURE_SETTINGS: TextureSettings = frozen(
|
|
140
|
+
{
|
|
141
|
+
color: { compress: DEFAULT_COMPRESS, codec: "etc1s", quality: SMALL_CODEC_QUALITY },
|
|
142
|
+
normal: { compress: DEFAULT_COMPRESS, codec: "uastc", quality: ACCURATE_CODEC_QUALITY },
|
|
143
|
+
data: { compress: DEFAULT_COMPRESS, codec: "uastc", quality: ACCURATE_CODEC_QUALITY },
|
|
144
|
+
},
|
|
145
|
+
[],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The fields a kind and an entry share, read into `into`. `key` names the
|
|
153
|
+
// group in a message; `others` are the group's further fields, which the
|
|
154
|
+
// caller reads.
|
|
155
|
+
function readFields(group: Record<string, unknown>, key: string, into: Partial<TextureSetting>, others: string[]) {
|
|
156
|
+
for (let [field, given] of Object.entries(group)) {
|
|
157
|
+
if (given === null || others.includes(field)) continue
|
|
158
|
+
let name = `${key}.${field}`
|
|
159
|
+
if (field === "compress") {
|
|
160
|
+
if (typeof given !== "boolean") throw new Error(`${name}: ${JSON.stringify(given)} is not true or false`)
|
|
161
|
+
into.compress = given
|
|
162
|
+
} else if (field === "codec") {
|
|
163
|
+
if (!CODECS.includes(given as TextureCodec)) throw new Error(`${name}: unknown codec ${JSON.stringify(given)} (expected "etc1s" or "uastc")`)
|
|
164
|
+
into.codec = given as TextureCodec
|
|
165
|
+
} else if (field === "quality") {
|
|
166
|
+
if (typeof given !== "number" || !(given >= 0 && given <= 1)) throw new Error(`${name}: ${JSON.stringify(given)} is not a number in 0..1`)
|
|
167
|
+
into.quality = given
|
|
168
|
+
} else {
|
|
169
|
+
throw new Error(`${name}: unknown field (expected ${[...others, "compress", "codec", "quality"].join(", ")})`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A pattern as its file part and, after the mark, its name part. A mark
|
|
175
|
+
// inside a set ("[#]") is a character of the file's name.
|
|
176
|
+
function splitPattern(pattern: string): { file: string; name?: string } {
|
|
177
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
178
|
+
let c = pattern[i]
|
|
179
|
+
if (c === "[") {
|
|
180
|
+
// A "]" right after the opening, or after the negation, is a member.
|
|
181
|
+
let first = pattern[i + 1] === "!" ? i + 2 : i + 1
|
|
182
|
+
let close = pattern.indexOf("]", first + 1)
|
|
183
|
+
if (close < 0) break
|
|
184
|
+
i = close
|
|
185
|
+
} else if (c === NAME_MARK) {
|
|
186
|
+
return { file: pattern.slice(0, i), name: pattern.slice(i + 1) }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { file: pattern }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readRule(given: unknown, key: string): TextureFileRule {
|
|
193
|
+
if (!isRecord(given)) throw new Error(`${key} must be an object with match and what it sets`)
|
|
194
|
+
let match = typeof given.match === "string" ? [given.match] : given.match
|
|
195
|
+
if (!Array.isArray(match) || match.length === 0 || !match.every((pattern) => typeof pattern === "string" && pattern !== "")) {
|
|
196
|
+
throw new Error(`${key}.match must be a pattern or a list of patterns`)
|
|
197
|
+
}
|
|
198
|
+
for (let pattern of match as string[]) {
|
|
199
|
+
let { file, name } = splitPattern(pattern)
|
|
200
|
+
if (file === "" || name === "") {
|
|
201
|
+
throw new Error(`${key}.match: ${JSON.stringify(pattern)} must name a file before "${NAME_MARK}" and a texture after it`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
let rule: TextureFileRule = { match: match as string[] }
|
|
205
|
+
readFields(given, key, rule, ["match"])
|
|
206
|
+
if (rule.compress === undefined && rule.codec === undefined && rule.quality === undefined) {
|
|
207
|
+
throw new Error(`${key} sets nothing (expected compress, codec or quality)`)
|
|
208
|
+
}
|
|
209
|
+
if (rule.compress === false && (rule.codec !== undefined || rule.quality !== undefined)) {
|
|
210
|
+
throw new Error(`${key}: compress is false, which leaves codec and quality nothing to apply to`)
|
|
211
|
+
}
|
|
212
|
+
return rule
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The texture settings of a parsed package.json: its `solidrt.textures`
|
|
217
|
+
* group over the defaults. A package.json without the group (or none at
|
|
218
|
+
* all: pass undefined) gives the defaults.
|
|
219
|
+
*
|
|
220
|
+
* `run.compress` stands in for the app's own `compress` for this one run
|
|
221
|
+
* (a bake's `--compress` and `--no-compress`); what a kind or an entry
|
|
222
|
+
* says about itself still holds.
|
|
223
|
+
*
|
|
224
|
+
* Throws on an unknown key or field, a codec that does not exist, a
|
|
225
|
+
* quality outside 0..1, an entry without a pattern or that sets nothing.
|
|
226
|
+
* The patterns themselves are read by the matcher: `unmatchedTextureFiles`
|
|
227
|
+
* runs every one of them, so a bake that starts with it meets a malformed
|
|
228
|
+
* pattern before it encodes anything.
|
|
229
|
+
*/
|
|
230
|
+
export function textureSettings(packageJson: unknown, run: { compress?: boolean } = {}): TextureSettings {
|
|
231
|
+
let solidrt = isRecord(packageJson) ? packageJson.solidrt : undefined
|
|
232
|
+
let group = isRecord(solidrt) ? (solidrt.textures ?? undefined) : undefined
|
|
233
|
+
if (group !== undefined && !isRecord(group)) {
|
|
234
|
+
throw new Error("solidrt.textures must be an object (compress, files, and the texture kinds color, normal, data)")
|
|
235
|
+
}
|
|
236
|
+
let given: Record<string, unknown> = group ?? {}
|
|
237
|
+
|
|
238
|
+
let app: Partial<TextureSetting> = {}
|
|
239
|
+
if (given.compress !== undefined && given.compress !== null) {
|
|
240
|
+
if (typeof given.compress !== "boolean") throw new Error(`solidrt.textures.compress: ${JSON.stringify(given.compress)} is not true or false`)
|
|
241
|
+
app.compress = given.compress
|
|
242
|
+
}
|
|
243
|
+
let compress = run.compress ?? app.compress ?? DEFAULT_COMPRESS
|
|
244
|
+
|
|
245
|
+
let kinds = {} as { [kind in TextureKind]: TextureSetting }
|
|
246
|
+
for (let kind of KINDS) kinds[kind] = { ...DEFAULT_TEXTURE_SETTINGS[kind], compress }
|
|
247
|
+
let files: TextureFileRule[] = []
|
|
248
|
+
for (let [key, value] of Object.entries(given)) {
|
|
249
|
+
if (key === "compress" || value === null) continue
|
|
250
|
+
let name = `solidrt.textures.${key}`
|
|
251
|
+
if (key === "files") {
|
|
252
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be a list of entries, each with match and what it sets`)
|
|
253
|
+
files = value.map((rule, i) => readRule(rule, `${name}[${i}]`))
|
|
254
|
+
} else if (KINDS.includes(key as TextureKind)) {
|
|
255
|
+
if (!isRecord(value)) throw new Error(`${name} must be an object with compress, codec and quality`)
|
|
256
|
+
readFields(value, name, kinds[key as TextureKind], [])
|
|
257
|
+
} else {
|
|
258
|
+
throw new Error(`${name}: unknown key (expected compress, files, or a texture kind: color, normal, data)`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return frozen(kinds, files)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Whether a pattern names the source: its file part matches the file, and
|
|
265
|
+
// its name part, when it has one, the name of a texture embedded there.
|
|
266
|
+
function patternNames(pattern: string, source: TextureSource, matches: GlobMatcher): boolean {
|
|
267
|
+
let { file, name } = splitPattern(pattern)
|
|
268
|
+
if (!matches(source.file, file)) return false
|
|
269
|
+
return name === undefined || (source.name !== undefined && matches(source.name, name))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* How one texture is compressed: what its kind says, under the fields of
|
|
274
|
+
* every entry of `files` that names it, in the order written. A pattern
|
|
275
|
+
* without a name part names a file and everything embedded in it; one
|
|
276
|
+
* with a name part names the embedded textures of that name, never a
|
|
277
|
+
* texture that is a file of its own.
|
|
278
|
+
*
|
|
279
|
+
* @param matches `matchesGlob` of flux:path.
|
|
280
|
+
*/
|
|
281
|
+
export function textureSettingFor(settings: TextureSettings, kind: TextureKind, source: TextureSource, matches: GlobMatcher): TextureSetting {
|
|
282
|
+
let setting: TextureSetting = { ...settings[kind] }
|
|
283
|
+
for (let rule of settings.files) {
|
|
284
|
+
if (!rule.match.some((pattern) => patternNames(pattern, source, matches))) continue
|
|
285
|
+
if (rule.compress !== undefined) setting.compress = rule.compress
|
|
286
|
+
if (rule.codec !== undefined) setting.codec = rule.codec
|
|
287
|
+
if (rule.quality !== undefined) setting.quality = rule.quality
|
|
288
|
+
}
|
|
289
|
+
return setting
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The patterns of `files`, as written, whose file matches nothing in the
|
|
294
|
+
* project: a misspelled pattern, which would otherwise do nothing and say
|
|
295
|
+
* nothing. The settings are the app's and a bake sees one file of it, so
|
|
296
|
+
* the question goes to the project's files, not to what is being baked.
|
|
297
|
+
* A bake calls this first and fails on what it returns.
|
|
298
|
+
*
|
|
299
|
+
* @param scan `(pattern) => glob(pattern, { cwd })` of flux:fs, with the
|
|
300
|
+
* project root as cwd. It throws on a malformed pattern.
|
|
301
|
+
*/
|
|
302
|
+
export async function unmatchedTextureFiles(settings: TextureSettings, scan: GlobScan): Promise<string[]> {
|
|
303
|
+
let unmatched: string[] = []
|
|
304
|
+
for (let rule of settings.files) {
|
|
305
|
+
for (let pattern of rule.match) {
|
|
306
|
+
let found = await scan(splitPattern(pattern).file)
|
|
307
|
+
if (found.length === 0) unmatched.push(pattern)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return unmatched
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The patterns of `files`, as written, that pick an embedded texture of
|
|
315
|
+
* `file` by a name none of its textures has. What `unmatchedTextureFiles`
|
|
316
|
+
* cannot see, since it needs the file opened: the bake of that file calls
|
|
317
|
+
* this with the names it found and fails on what it returns.
|
|
318
|
+
*
|
|
319
|
+
* @param file The file being baked, as its path from the project root.
|
|
320
|
+
* @param names The names of the textures embedded in it.
|
|
321
|
+
* @param matches `matchesGlob` of flux:path.
|
|
322
|
+
*/
|
|
323
|
+
export function unmatchedTextureNames(settings: TextureSettings, file: string, names: string[], matches: GlobMatcher): string[] {
|
|
324
|
+
let unmatched: string[] = []
|
|
325
|
+
for (let rule of settings.files) {
|
|
326
|
+
for (let pattern of rule.match) {
|
|
327
|
+
let split = splitPattern(pattern)
|
|
328
|
+
if (split.name === undefined || !matches(file, split.file)) continue
|
|
329
|
+
if (!names.some((name) => matches(name, split.name!))) unmatched.push(pattern)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return unmatched
|
|
333
|
+
}
|
package/src/transform.ts
CHANGED
|
@@ -21,8 +21,8 @@ const SPAN_SMOOTH = 0.28
|
|
|
21
21
|
// thresholds is the hysteresis that keeps the gate from chattering.
|
|
22
22
|
// All three step-based constants assume a ~65Hz frame rate (the captured
|
|
23
23
|
// tablet); a 120Hz panel halves per-step deltas. If that ever bites,
|
|
24
|
-
// convert the thresholds to px/s with
|
|
25
|
-
//
|
|
24
|
+
// convert the thresholds to px/s with the pointerFrame terminator's
|
|
25
|
+
// timeStamp.
|
|
26
26
|
const QUIET = 0.3
|
|
27
27
|
const ENGAGE = 0.6
|
|
28
28
|
const QUIET_SMOOTH = 0.19
|
|
@@ -147,6 +147,8 @@ export function createTransform(options: TransformOptions) {
|
|
|
147
147
|
let spanRate = 0
|
|
148
148
|
// Motion arrived this frame; measured and emitted at the terminator.
|
|
149
149
|
let dirty = false
|
|
150
|
+
// A move of the current batch was predicted (see flush).
|
|
151
|
+
let predicted = false
|
|
150
152
|
// The anchor must be retaken at the terminator (activation and set-change
|
|
151
153
|
// rebases): mid-batch the pointer map is mixed-age, so anchoring
|
|
152
154
|
// immediately would bake a jolt into the next delta. A rebase frame
|
|
@@ -208,8 +210,14 @@ export function createTransform(options: TransformOptions) {
|
|
|
208
210
|
let owner: ArenaOwner = { cancel }
|
|
209
211
|
|
|
210
212
|
// The per-frame measure point: runs at the pointerFrame terminator, when
|
|
211
|
-
// every tracked pointer's
|
|
212
|
-
|
|
213
|
+
// every tracked pointer's move of the batch has dispatched: the
|
|
214
|
+
// terminator's timeStamp (the latest of theirs) is the time of what is
|
|
215
|
+
// measured here.
|
|
216
|
+
let flush = (frame: { timeStamp: number }) => {
|
|
217
|
+
// The measure follows a predicted position; the velocity history takes
|
|
218
|
+
// real ones only.
|
|
219
|
+
let real = !predicted
|
|
220
|
+
predicted = false
|
|
213
221
|
if (!active) return
|
|
214
222
|
if (rebase) {
|
|
215
223
|
// Anchor from same-age positions; emits nothing - an activation or
|
|
@@ -219,7 +227,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
219
227
|
let prev = ref
|
|
220
228
|
ref = measure()
|
|
221
229
|
if (prev) tracker.shift(ref.px - prev.px, ref.py - prev.py)
|
|
222
|
-
tracker.push(ref.px, ref.py)
|
|
230
|
+
if (real) tracker.push(ref.px, ref.py, frame.timeStamp)
|
|
223
231
|
spanBase = ref.span
|
|
224
232
|
smoothSpan = ref.span
|
|
225
233
|
rebase = false
|
|
@@ -229,7 +237,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
229
237
|
if (!dirty || !ref) return
|
|
230
238
|
dirty = false
|
|
231
239
|
let m = measure()
|
|
232
|
-
tracker.push(m.px, m.py)
|
|
240
|
+
if (real) tracker.push(m.px, m.py, frame.timeStamp)
|
|
233
241
|
let prevSpan = smoothSpan
|
|
234
242
|
smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
|
|
235
243
|
spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
|
|
@@ -285,6 +293,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
285
293
|
onPointerMove: (e: PointerEvent) => {
|
|
286
294
|
if (!pointers.has(e.pointerId) || !ref) return
|
|
287
295
|
pointers.set(e.pointerId, at(e))
|
|
296
|
+
if (e.predicted) predicted = true
|
|
288
297
|
if (!active) {
|
|
289
298
|
// Arming runs per event: the slop test tolerates a mixed-age
|
|
290
299
|
// measure (8px against <=1 frame of staleness), and the arena
|
|
@@ -328,7 +337,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
328
337
|
if (pointers.size === 0) {
|
|
329
338
|
// The lift's own position is not measured (no terminator
|
|
330
339
|
// follows it); the fit over the delivered frames is the speed.
|
|
331
|
-
let velocity = flingVelocity(tracker.velocity())
|
|
340
|
+
let velocity = flingVelocity(tracker.velocity(e.timeStamp))
|
|
332
341
|
active = false
|
|
333
342
|
ref = null
|
|
334
343
|
pinch = false
|
package/src/types.d.ts
CHANGED
|
@@ -189,7 +189,11 @@ export interface PaintProps {
|
|
|
189
189
|
* paint inside, so the box is the outer edge) - except on `path`, where the
|
|
190
190
|
* shadow mirrors the element's own fill/stroke silhouette and `spread` is
|
|
191
191
|
* rejected (an arbitrary path cannot be inflated exactly). `color` is
|
|
192
|
-
* required; there is no currentColor to inherit.
|
|
192
|
+
* required; there is no currentColor to inherit. Animates as one value
|
|
193
|
+
* under `transition={{ shadow }}`: offset, blur, spread and color move
|
|
194
|
+
* together (the color in oklab), an unset shadow counts as none (zero,
|
|
195
|
+
* transparent), so a shadow fades in from nothing and a `null` write
|
|
196
|
+
* fades it out.
|
|
193
197
|
*/
|
|
194
198
|
export interface ShadowProps {
|
|
195
199
|
x?: number
|
|
@@ -276,6 +280,33 @@ export interface TransformProps {
|
|
|
276
280
|
// the DOM MouseEvent). pointerType distinguishes mouse from touch; button is the
|
|
277
281
|
// pressed button on down/up (0 = primary); the modifier flags mirror the DOM.
|
|
278
282
|
export interface PointerEvent {
|
|
283
|
+
/**
|
|
284
|
+
* When the event happened, in milliseconds. Every input event carries one,
|
|
285
|
+
* on one clock, so two stamps subtract to the time between two events: a
|
|
286
|
+
* tap's length, the gap of a double tap, the age of the last move at a
|
|
287
|
+
* lift. It is the input's own time, not its delivery's: a move carries the
|
|
288
|
+
* time the pointer was at the position it reports, however late the
|
|
289
|
+
* frame that delivers it, and a down, an up, a wheel or a key the time
|
|
290
|
+
* the platform recorded for it. A `predicted` move runs ahead of its
|
|
291
|
+
* pointer, so the real move after it can carry an earlier time. Use it
|
|
292
|
+
* where you would reach for `performance.now()` in a handler: that one
|
|
293
|
+
* measures when the handler ran, not when the input happened, and it does
|
|
294
|
+
* not follow the dev clock, `srt render` or a test. The origin is
|
|
295
|
+
* arbitrary (compare stamps with each other, not with `performance.now()`,
|
|
296
|
+
* a timer or the `onFrame` tick).
|
|
297
|
+
*/
|
|
298
|
+
timeStamp: number
|
|
299
|
+
/**
|
|
300
|
+
* True for a move whose position the pointer was never reported at. Touch
|
|
301
|
+
* is delivered in batches, and a batch can miss the frame it belongs to;
|
|
302
|
+
* the runtime then bridges the gap with one predicted step, so a drag
|
|
303
|
+
* does not stall and jump. Draw with it, as with any move. Do not measure
|
|
304
|
+
* with it: leave it out of a velocity or a path. If the finger had in
|
|
305
|
+
* fact stopped, the next move reports where it really is, with the time
|
|
306
|
+
* it got there, which is earlier than the predicted move's. False on
|
|
307
|
+
* every other event.
|
|
308
|
+
*/
|
|
309
|
+
predicted: boolean
|
|
279
310
|
clientX: number
|
|
280
311
|
clientY: number
|
|
281
312
|
/**
|
|
@@ -336,6 +367,9 @@ export interface WheelEvent extends PointerEvent {
|
|
|
336
367
|
// to the window root alone. <window onKeyDown> is therefore the app-global
|
|
337
368
|
// shortcut point.
|
|
338
369
|
export interface KeyEvent {
|
|
370
|
+
/** When the key event happened, in milliseconds, on the clock of
|
|
371
|
+
* `PointerEvent.timeStamp`. */
|
|
372
|
+
timeStamp: number
|
|
339
373
|
key: string
|
|
340
374
|
code: string
|
|
341
375
|
repeat: boolean
|
|
@@ -359,7 +393,19 @@ export interface TextEvent {
|
|
|
359
393
|
text: string
|
|
360
394
|
}
|
|
361
395
|
|
|
362
|
-
export interface
|
|
396
|
+
export interface NodeProps {
|
|
397
|
+
/**
|
|
398
|
+
* A stable name for the node, for whoever has to find it again with
|
|
399
|
+
* nothing visible to find it by: a test (`find({ label })` from
|
|
400
|
+
* `@solidrt/test`) or a dev tool (the tree record carries it and the
|
|
401
|
+
* tree query matches it). Never affects layout, paint or input. Prefer
|
|
402
|
+
* finding a node by its text where it has one: such a test breaks when
|
|
403
|
+
* what the user sees breaks.
|
|
404
|
+
*/
|
|
405
|
+
label?: string
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export interface PointerProps extends NodeProps {
|
|
363
409
|
onPointerDown?: (event: PointerEvent) => void
|
|
364
410
|
onPointerUp?: (event: PointerEvent) => void
|
|
365
411
|
onPointerMove?: (event: PointerEvent) => void
|
|
@@ -484,7 +530,7 @@ export type TransitionCurve = "linear" | "ease" | "ease-in" | "ease-out" | "ease
|
|
|
484
530
|
* spring entry is a tween of the entry's duration.
|
|
485
531
|
*/
|
|
486
532
|
export interface TransitionEndpoint {
|
|
487
|
-
value: number | string
|
|
533
|
+
value: number | string | ShadowProps | null
|
|
488
534
|
duration?: number
|
|
489
535
|
curve?: TransitionCurve
|
|
490
536
|
bounce?: number
|
|
@@ -509,11 +555,12 @@ export interface TransitionSpring {
|
|
|
509
555
|
* Mount-time enter animation: at the element's first attach the property
|
|
510
556
|
* snaps to this value and animates to the value it mounted with. Numbers
|
|
511
557
|
* for the scalar properties; the color property takes a CSS color string
|
|
512
|
-
* or packed number
|
|
558
|
+
* or packed number; the shadow property a {@link ShadowProps} object or
|
|
559
|
+
* `null` for none; or a {@link TransitionEndpoint} giving the enter its
|
|
513
560
|
* own curve, duration, bounce or delay. Per-property entries only (not
|
|
514
561
|
* under `all`); a later move or reorder re-runs nothing.
|
|
515
562
|
*/
|
|
516
|
-
from?: number | string | TransitionEndpoint
|
|
563
|
+
from?: number | string | ShadowProps | null | TransitionEndpoint
|
|
517
564
|
/**
|
|
518
565
|
* Removal exit animation: when an element unmounts, every `exit`
|
|
519
566
|
* declared on it or under it animates its property to this value
|
|
@@ -528,7 +575,7 @@ export interface TransitionSpring {
|
|
|
528
575
|
* motion: an ease-out enter reads wrong played backwards, so pair it with
|
|
529
576
|
* `exit: { value, curve: "ease-in" }`.
|
|
530
577
|
*/
|
|
531
|
-
exit?: number | string | TransitionEndpoint
|
|
578
|
+
exit?: number | string | ShadowProps | null | TransitionEndpoint
|
|
532
579
|
}
|
|
533
580
|
|
|
534
581
|
/**
|
|
@@ -547,11 +594,12 @@ export interface TransitionTween {
|
|
|
547
594
|
* Mount-time enter animation: at the element's first attach the property
|
|
548
595
|
* snaps to this value and animates to the value it mounted with. Numbers
|
|
549
596
|
* for the scalar properties; the color property takes a CSS color string
|
|
550
|
-
* or packed number
|
|
597
|
+
* or packed number; the shadow property a {@link ShadowProps} object or
|
|
598
|
+
* `null` for none; or a {@link TransitionEndpoint} giving the enter its
|
|
551
599
|
* own curve, duration, bounce or delay. Per-property entries only (not
|
|
552
600
|
* under `all`); a later move or reorder re-runs nothing.
|
|
553
601
|
*/
|
|
554
|
-
from?: number | string | TransitionEndpoint
|
|
602
|
+
from?: number | string | ShadowProps | null | TransitionEndpoint
|
|
555
603
|
/**
|
|
556
604
|
* Removal exit animation: when an element unmounts, every `exit`
|
|
557
605
|
* declared on it or under it animates its property to this value
|
|
@@ -566,7 +614,7 @@ export interface TransitionTween {
|
|
|
566
614
|
* motion: an ease-out enter reads wrong played backwards, so pair it with
|
|
567
615
|
* `exit: { value, curve: "ease-in" }`.
|
|
568
616
|
*/
|
|
569
|
-
exit?: number | string | TransitionEndpoint
|
|
617
|
+
exit?: number | string | ShadowProps | null | TransitionEndpoint
|
|
570
618
|
}
|
|
571
619
|
|
|
572
620
|
/**
|
|
@@ -579,7 +627,11 @@ export type TransitionShorthand = string
|
|
|
579
627
|
|
|
580
628
|
export type Transition = TransitionSpring | TransitionTween | TransitionShorthand
|
|
581
629
|
|
|
582
|
-
/**
|
|
630
|
+
/**
|
|
631
|
+
* The property names a transition can cover: the numeric scalars, `color`
|
|
632
|
+
* (solid colors, in oklab) and `shadow` (the whole {@link ShadowProps}
|
|
633
|
+
* value).
|
|
634
|
+
*/
|
|
583
635
|
export type TransitionPropName =
|
|
584
636
|
| "x"
|
|
585
637
|
| "y"
|
|
@@ -612,6 +664,7 @@ export type TransitionPropName =
|
|
|
612
664
|
| "strokeWidth"
|
|
613
665
|
| "radius"
|
|
614
666
|
| "color"
|
|
667
|
+
| "shadow"
|
|
615
668
|
|
|
616
669
|
/**
|
|
617
670
|
* Payload of onTransitionEnd: which animated property finished. `"layout"`
|
|
@@ -637,8 +690,9 @@ export interface TransitionProps {
|
|
|
637
690
|
* properties the element carries animate (a d-rect has x, a view's x is
|
|
638
691
|
* its transform); the initial values (every write before the element's
|
|
639
692
|
* first paint) never animate unless the entry sets `from` (an enter
|
|
640
|
-
* animation), and a
|
|
641
|
-
* animation and snaps
|
|
693
|
+
* animation), and a write the property cannot animate (null on a scalar,
|
|
694
|
+
* a gradient color) cancels the running animation and snaps; `shadow`
|
|
695
|
+
* alone animates to and from `null`, its none. `null` clears the
|
|
642
696
|
* declaration; already-running animations finish. A spec built in a
|
|
643
697
|
* conditional widens `curve` to `string` for TypeScript; write
|
|
644
698
|
* `satisfies Transition` on the branch.
|