spine-rigc 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING.md +75 -19
- package/docs/INGEST.md +20 -13
- package/docs/MOTION.md +138 -85
- package/package.json +1 -1
- package/src/compile.ts +35 -1
- package/src/keys.ts +297 -2
- package/src/motion.ts +123 -3
- package/src/pose.ts +84 -6
- package/src/render.ts +32 -0
- package/src/rig.ts +241 -31
- package/src/types.ts +42 -0
- package/src/validate.ts +34 -4
package/src/keys.ts
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* is **not** a schema: the known-key sets live beside the shapes they describe
|
|
21
21
|
* (`RIG_KEYS` in [`rig.ts`](rig.ts), `MOTION_KEYS` in [`motion.ts`](motion.ts)),
|
|
22
22
|
* because a set of key names two files away from its interface is a set that
|
|
23
|
-
* drifts from it. `
|
|
24
|
-
* own source text and compares, so the pair cannot drift in silence, and `
|
|
23
|
+
* drifts from it. `CUR17` in `selftest.ts` derives each set from the interface's
|
|
24
|
+
* own source text and compares, so the pair cannot drift in silence, and `CUR18`
|
|
25
25
|
* refuses a declared key that occurs nowhere else in the tree — which is the
|
|
26
26
|
* exact shape `scaleYMode` had.
|
|
27
27
|
*
|
|
@@ -115,3 +115,298 @@ export function refuseUnknownKeys(
|
|
|
115
115
|
(known.length === 0 ? 'This shape has no fields at all.' : `Known here: ${[...known].sort().join(', ')}.`),
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The largest float32, `3.4028234663852886e38`. The skeleton file is written at
|
|
121
|
+
* float32 precision (`f32` in `src/compile.ts` rounds every emitted number), so
|
|
122
|
+
* this is the largest magnitude a stated number can have and still come out of
|
|
123
|
+
* the emitter as a number.
|
|
124
|
+
*/
|
|
125
|
+
export const FLOAT32_MAX = 3.4028234663852886e38;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Refuse the first number in an input file that the skeleton file cannot carry
|
|
129
|
+
* — one that is not finite, or that is finite only as a double (issue #881).
|
|
130
|
+
*
|
|
131
|
+
* 🚨 The defect this closes was a green build. `JSON.parse` reads `1e309` as
|
|
132
|
+
* `Infinity`, the emitter wrote it, and `JSON.stringify(Infinity)` is `null`:
|
|
133
|
+
* a bone stated at `x: 1e309` built green on the overlay probe as `"x": null`
|
|
134
|
+
* and was read as 0, a value the spec never stated. `1e308` did exactly the same
|
|
135
|
+
* — it is a finite double, but every emitted number is rounded to a float32 and
|
|
136
|
+
* `Math.fround(1e308)` is `Infinity` — so the line is float32's range and not
|
|
137
|
+
* the double's: `3.4028234663852886e38` built green and was emitted as itself,
|
|
138
|
+
* the next double up that rounds past it (`3.4028235677973366e38`) was emitted
|
|
139
|
+
* as `null`. The predicate is therefore `Number.isFinite(Math.fround(n))`,
|
|
140
|
+
* which is `NaN`, both infinities and exactly that overflow.
|
|
141
|
+
*
|
|
142
|
+
* ⭐ It walks the file, not the emitter's route — `checkRigSpecKeys`'s design
|
|
143
|
+
* and its reason. Before this, a handful of readers in `compile.ts` carried a
|
|
144
|
+
* finite check of their own (vertex arrays, a path's lengths, a segments
|
|
145
|
+
* falloff, a slider's mapping, an event's payload), each right about its own
|
|
146
|
+
* field, and none of them was a rule. Measured when this was written, planting
|
|
147
|
+
* `1e309` at every numeric leaf of nine rig specs — 610 plants over 57 kinds
|
|
148
|
+
* of field: 19 kinds were refused by a rule of their own (30 of those 70
|
|
149
|
+
* refusals printing the Infinity as `null`, and some only by a later symptom
|
|
150
|
+
* such as *hull Infinity disagrees with the triangles*), 1 was caught only by
|
|
151
|
+
* the gate, and 37 built green with a `null` in them on at least one rig. One
|
|
152
|
+
* pass over every number the document holds cannot miss a reader added next
|
|
153
|
+
* month, and it runs before any of them — so a range rule further down (a
|
|
154
|
+
* radius that is positive, a weight in 0..1) never has to print `NaN`.
|
|
155
|
+
*
|
|
156
|
+
* 🔒 The report order is fixed: `Object.keys` of parsed JSON is the
|
|
157
|
+
* document's key order (integer-like keys first, as the language orders them)
|
|
158
|
+
* and arrays are visited by index, so the refusal names the same number on
|
|
159
|
+
* every run.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ What it cannot see is a number spelled as something else. `"x": "NaN"`
|
|
162
|
+
* is a string; there is no number here to refuse, and that is
|
|
163
|
+
* `refuseValuesOfTheWrongType`'s question (issue #890), which the parsers ask
|
|
164
|
+
* before this walk on the rig spec and the manifest, and after the motion
|
|
165
|
+
* spec's own field checks.
|
|
166
|
+
*
|
|
167
|
+
* `place` turns a path into the words the refusal uses for it.
|
|
168
|
+
*/
|
|
169
|
+
export function refuseNumbersTheFileCannotCarry(
|
|
170
|
+
raw: unknown,
|
|
171
|
+
where: string,
|
|
172
|
+
place: (path: ReadonlyArray<string | number>) => string,
|
|
173
|
+
): void {
|
|
174
|
+
const visit = (node: unknown, path: Array<string | number>): void => {
|
|
175
|
+
if (typeof node === 'number') {
|
|
176
|
+
if (Number.isFinite(Math.fround(node))) return;
|
|
177
|
+
const why = Number.isNaN(node)
|
|
178
|
+
? ''
|
|
179
|
+
: Number.isFinite(node)
|
|
180
|
+
? ' This one is finite as a double and has no float32 but Infinity.'
|
|
181
|
+
: ' JSON has no spelling for Infinity: a literal past ±1.7976931348623157e308, such as 1e309, parses to it.';
|
|
182
|
+
throw new CompileError(
|
|
183
|
+
`${where}: ${place(path)} is ${String(node)}; a number in this file is finite at float32 precision, at most ` +
|
|
184
|
+
`±${String(FLOAT32_MAX)}, because the skeleton is written as float32 — past that it would be emitted as ` +
|
|
185
|
+
`Infinity, which JSON writes as null, a value the file never stated.${why}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(node)) {
|
|
189
|
+
node.forEach((child, i) => visit(child, [...path, i]));
|
|
190
|
+
} else if (node !== null && typeof node === 'object') {
|
|
191
|
+
const record = node as Record<string, unknown>;
|
|
192
|
+
for (const key of Object.keys(record)) visit(record[key], [...path, key]);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
visit(raw, []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A path as the file spells it: `.key`, `."odd key"`, `[3]`. */
|
|
199
|
+
export function dottedPath(path: ReadonlyArray<string | number>): string {
|
|
200
|
+
let out = '';
|
|
201
|
+
for (const step of path) {
|
|
202
|
+
if (typeof step === 'number') out += `[${step}]`;
|
|
203
|
+
else out += /^[A-Za-z_][A-Za-z0-9_]*$/.test(step) ? `${out === '' ? '' : '.'}${step}` : `${out === '' ? '' : '.'}${JSON.stringify(step)}`;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The type a field of an input file holds — the vocabulary of the per-shape
|
|
210
|
+
* type tables (`RIG_TYPES` in [`rig.ts`](rig.ts), `MOTION_TYPES` in
|
|
211
|
+
* [`motion.ts`](motion.ts), `MANIFEST_TYPES` in [`types.ts`](types.ts)).
|
|
212
|
+
*
|
|
213
|
+
* The first twelve are **checked** by `refuseValuesOfTheWrongType`. The last
|
|
214
|
+
* six are named so that every key has a type, and are deliberately not checked
|
|
215
|
+
* here, because each already has an owner that says more than a type could:
|
|
216
|
+
*
|
|
217
|
+
* - `object`, `object[]`, `object[][]`, `map of object` — a nested shape. Its
|
|
218
|
+
* own keys are typed in its own row, and whether the container is the right
|
|
219
|
+
* kind of container is the refusal of the reader that walks it (a rig spec's
|
|
220
|
+
* `"bones"` that is not an array is *a rig spec needs a non-empty "bones"
|
|
221
|
+
* array*);
|
|
222
|
+
* - `enum` — a closed set of names, refused by name where it is read, with the
|
|
223
|
+
* names that exist listed (a slot's `blend`, a constraint's `type`);
|
|
224
|
+
* - `mixed` — a union of kinds whose owner decides by the value (`MotionKey.v`
|
|
225
|
+
* is numbers on `rotate`, a name on `attachment`, a member map on a group).
|
|
226
|
+
*/
|
|
227
|
+
export const SPEC_VALUE_TYPES = [
|
|
228
|
+
'number',
|
|
229
|
+
'string',
|
|
230
|
+
'boolean',
|
|
231
|
+
'number | null',
|
|
232
|
+
'string | null',
|
|
233
|
+
'number[]',
|
|
234
|
+
'number[] | null',
|
|
235
|
+
'string[]',
|
|
236
|
+
'number[][]',
|
|
237
|
+
'map of number[]',
|
|
238
|
+
'map of string[]',
|
|
239
|
+
'map of string | null',
|
|
240
|
+
'object',
|
|
241
|
+
'object[]',
|
|
242
|
+
'object[][]',
|
|
243
|
+
'map of object',
|
|
244
|
+
'enum',
|
|
245
|
+
'mixed',
|
|
246
|
+
] as const;
|
|
247
|
+
|
|
248
|
+
export type SpecValueType = (typeof SPEC_VALUE_TYPES)[number];
|
|
249
|
+
|
|
250
|
+
/** A shape's keys and the type each one holds. */
|
|
251
|
+
export type SpecTypeRow = Readonly<Record<string, SpecValueType>>;
|
|
252
|
+
|
|
253
|
+
/** One node of an input file that a key scan visited, as the type walk needs it. */
|
|
254
|
+
export interface ShapeVisit {
|
|
255
|
+
node: Record<string, unknown>;
|
|
256
|
+
/** The row of the type table this node's keys are typed by. */
|
|
257
|
+
shape: string;
|
|
258
|
+
/** The words a refusal uses for a path below this node — `x`, `weights[3][1]`, `color[2]`. */
|
|
259
|
+
name: (tail: ReadonlyArray<string | number>) => string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** What a JSON value is, in the words the type refusal uses: `a string "5"`, `an array [1]`, `null`. */
|
|
263
|
+
function typeFound(value: unknown): string {
|
|
264
|
+
if (value === null) return 'null';
|
|
265
|
+
const kind = Array.isArray(value) ? 'array' : typeof value === 'object' ? 'object' : typeof value;
|
|
266
|
+
const shown = JSON.stringify(value) ?? String(value);
|
|
267
|
+
return `${/^[aeiou]/.test(kind) ? 'an' : 'a'} ${kind} ${shown.length > 60 ? `${shown.slice(0, 57)}...` : shown}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A checked type as the refusal requires it: `a number`, `an array of numbers`, `a string or null`. */
|
|
271
|
+
const REQUIRED: Record<string, string> = {
|
|
272
|
+
number: 'a number',
|
|
273
|
+
string: 'a string',
|
|
274
|
+
boolean: 'a boolean (true or false)',
|
|
275
|
+
'number | null': 'a number or null',
|
|
276
|
+
'string | null': 'a string or null',
|
|
277
|
+
'number[]': 'an array of numbers',
|
|
278
|
+
'number[] | null': 'an array of numbers, or null',
|
|
279
|
+
'string[]': 'an array of strings',
|
|
280
|
+
'number[][]': 'an array of arrays of numbers',
|
|
281
|
+
'map of number[]': 'an object whose every value is an array of numbers',
|
|
282
|
+
'map of string[]': 'an object whose every value is an array of strings',
|
|
283
|
+
'map of string | null': 'an object whose every value is a string or null',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** The types `refuseValuesOfTheWrongType` checks — every one `REQUIRED` can word. */
|
|
287
|
+
export const CHECKED_SPEC_VALUE_TYPES: readonly SpecValueType[] = SPEC_VALUE_TYPES.filter((t) => REQUIRED[t] !== undefined);
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The first place under `value` that is not of `type`, and what was required
|
|
291
|
+
* there — or `null` when it is. An element fault names its own index, so an
|
|
292
|
+
* array of numbers with a string in slot 3 is refused at `[3]` rather than as
|
|
293
|
+
* a whole array.
|
|
294
|
+
*/
|
|
295
|
+
function wrongTypeAt(
|
|
296
|
+
value: unknown,
|
|
297
|
+
type: SpecValueType,
|
|
298
|
+
): { tail: Array<string | number>; value: unknown; required: string } | null {
|
|
299
|
+
const is = (v: unknown, t: SpecValueType): boolean => {
|
|
300
|
+
switch (t) {
|
|
301
|
+
case 'number':
|
|
302
|
+
return typeof v === 'number';
|
|
303
|
+
case 'string':
|
|
304
|
+
return typeof v === 'string';
|
|
305
|
+
case 'boolean':
|
|
306
|
+
return typeof v === 'boolean';
|
|
307
|
+
case 'number | null':
|
|
308
|
+
return v === null || typeof v === 'number';
|
|
309
|
+
case 'string | null':
|
|
310
|
+
return v === null || typeof v === 'string';
|
|
311
|
+
default:
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const fault = (tail: Array<string | number>, v: unknown, t: SpecValueType) => ({ tail, value: v, required: REQUIRED[t] });
|
|
316
|
+
const list = (v: unknown, element: SpecValueType, whole: SpecValueType, tail: Array<string | number>) => {
|
|
317
|
+
if (!Array.isArray(v)) return fault(tail, v, whole);
|
|
318
|
+
for (const [i, item] of v.entries()) if (!is(item, element)) return fault([...tail, i], item, element);
|
|
319
|
+
return null;
|
|
320
|
+
};
|
|
321
|
+
const map = (v: unknown, each: SpecValueType, whole: SpecValueType) => {
|
|
322
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return fault([], v, whole);
|
|
323
|
+
const record = v as Record<string, unknown>;
|
|
324
|
+
for (const key of Object.keys(record)) {
|
|
325
|
+
const inner = wrongTypeAt(record[key], each);
|
|
326
|
+
if (inner !== null) return { ...inner, tail: [key, ...inner.tail] };
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
};
|
|
330
|
+
switch (type) {
|
|
331
|
+
case 'number':
|
|
332
|
+
case 'string':
|
|
333
|
+
case 'boolean':
|
|
334
|
+
case 'number | null':
|
|
335
|
+
case 'string | null':
|
|
336
|
+
return is(value, type) ? null : fault([], value, type);
|
|
337
|
+
case 'number[]':
|
|
338
|
+
return list(value, 'number', type, []);
|
|
339
|
+
case 'number[] | null':
|
|
340
|
+
return value === null ? null : list(value, 'number', type, []);
|
|
341
|
+
case 'string[]':
|
|
342
|
+
return list(value, 'string', type, []);
|
|
343
|
+
case 'number[][]': {
|
|
344
|
+
if (!Array.isArray(value)) return fault([], value, type);
|
|
345
|
+
for (const [i, row] of value.entries()) {
|
|
346
|
+
const inner = list(row, 'number', 'number[]', [i]);
|
|
347
|
+
if (inner !== null) return inner;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
case 'map of number[]':
|
|
352
|
+
return map(value, 'number[]', type);
|
|
353
|
+
case 'map of string[]':
|
|
354
|
+
return map(value, 'string[]', type);
|
|
355
|
+
case 'map of string | null':
|
|
356
|
+
return map(value, 'string | null', type);
|
|
357
|
+
default:
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Refuse the first value in an input file whose JSON type is not the one its
|
|
364
|
+
* field holds (issue #890).
|
|
365
|
+
*
|
|
366
|
+
* 🚨 The defect this closes was a green build, and it sat directly under the
|
|
367
|
+
* rule #881 wrote. A number that is not a number is coerced by the arithmetic
|
|
368
|
+
* that reads it before any rule sees a number at all: measured on the root bone
|
|
369
|
+
* of the generated probes, `"x": "5"` built as `5`, `"rotation": "90"` as `90`,
|
|
370
|
+
* `"x": true` as `1`, `"x": [1]` as `1` and `"x": "NaN"` as `null`, every one
|
|
371
|
+
* green. The compiler was writing a value the spec never stated, or one it
|
|
372
|
+
* guessed.
|
|
373
|
+
*
|
|
374
|
+
* ⭐ It walks the nodes the key scan visited, not the emitter's route, and it
|
|
375
|
+
* reads the type table that sits beside the key table — so a key cannot be
|
|
376
|
+
* admitted by the scan and untyped here (a selftest control holds the two
|
|
377
|
+
* tables equal, and so does `satisfies` over the key table's own type). The
|
|
378
|
+
* refusal names the field, the value found and the type required:
|
|
379
|
+
* `bone "hip" x is a string "5"; a number is required`.
|
|
380
|
+
*
|
|
381
|
+
* 🔒 The report order is fixed — visits in the scan's order, keys in the
|
|
382
|
+
* document's order, arrays by index — so the refusal names the same value on
|
|
383
|
+
* every run.
|
|
384
|
+
*
|
|
385
|
+
* ⚠️ A JSON `null` is a value, not an absence. It is accepted only where the
|
|
386
|
+
* field's type says `null` (a slot's setup `attachment`, a stage stated absent);
|
|
387
|
+
* everywhere else it is refused as `null`, because the readers downstream
|
|
388
|
+
* treated it as whatever `null` coerces to in the arithmetic they happened to do
|
|
389
|
+
* — measured, a bone's `"rotation": null` built green with its bytes moved.
|
|
390
|
+
*/
|
|
391
|
+
export function refuseValuesOfTheWrongType(
|
|
392
|
+
visits: readonly ShapeVisit[],
|
|
393
|
+
types: Readonly<Record<string, SpecTypeRow>>,
|
|
394
|
+
where: string,
|
|
395
|
+
): void {
|
|
396
|
+
for (const visit of visits) {
|
|
397
|
+
const row = types[visit.shape];
|
|
398
|
+
if (row === undefined) throw new Error(`no type row for shape ${visit.shape} (a key scan visited a shape the type table does not have)`);
|
|
399
|
+
for (const key of Object.keys(visit.node)) {
|
|
400
|
+
const type = row[key];
|
|
401
|
+
// An unknown key is the key scan's refusal and it has already run; a
|
|
402
|
+
// shape without a scan (the cut manifest) tolerates keys it does not
|
|
403
|
+
// read, and this walk does not change what a shape accepts.
|
|
404
|
+
if (type === undefined) continue;
|
|
405
|
+
const wrong = wrongTypeAt(visit.node[key], type);
|
|
406
|
+
if (wrong === null) continue;
|
|
407
|
+
throw new CompileError(
|
|
408
|
+
`${where}: ${visit.name([key, ...wrong.tail])} is ${typeFound(wrong.value)}; ${wrong.required} is required`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
package/src/motion.ts
CHANGED
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
* [`keys.ts`](keys.ts) shared with the rig parser.
|
|
55
55
|
*/
|
|
56
56
|
import { CompileError } from './errors.ts';
|
|
57
|
-
import { refuseUnknownKeys } from './keys.ts';
|
|
57
|
+
import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType } from './keys.ts';
|
|
58
|
+
import type { ShapeVisit, SpecValueType } from './keys.ts';
|
|
58
59
|
import { SEQUENCE_MODES } from './timelines.ts';
|
|
59
60
|
import type { MotionSpec } from './types.ts';
|
|
60
61
|
|
|
@@ -112,7 +113,7 @@ const CONSTRAINT_GROUPS = ['ik', 'transform'] as const;
|
|
|
112
113
|
* it — the runtime shadow of types TypeScript erases, and the other half of
|
|
113
114
|
* `RIG_KEYS` in [`rig.ts`](rig.ts).
|
|
114
115
|
*
|
|
115
|
-
* 🔒 Held to those interfaces by `
|
|
116
|
+
* 🔒 Held to those interfaces by `CUR17` in `selftest.ts`, which reads the
|
|
116
117
|
* declaring source and compares. The interfaces are spread over three modules —
|
|
117
118
|
* `types.ts` for the format, [`trackgen.ts`](trackgen.ts) for a track's `derive`
|
|
118
119
|
* and [`deformgen.ts`](deformgen.ts) for a deform key's `transform` — and the
|
|
@@ -152,6 +153,80 @@ export const MOTION_KEYS = {
|
|
|
152
153
|
DeformBend: ['kind', 'amount', 'from', 'to', 'power', 'along', 'axis'],
|
|
153
154
|
} as const satisfies Record<string, readonly string[]>;
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The type every key of `MOTION_KEYS` holds, shape by shape — what
|
|
158
|
+
* `refuseValuesOfTheWrongType` refuses a value against (issue #890), held to
|
|
159
|
+
* the key table by `satisfies MotionTypeTable` and by the selftest, exactly as
|
|
160
|
+
* `RIG_TYPES` is.
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ The walk runs LAST in `parseMotionSpec`, after every field check above,
|
|
163
|
+
* so a field this file already checks keeps its own sentence (a physics tuning
|
|
164
|
+
* number, a key's `t`, an easing's handles) and the walk is what covers the
|
|
165
|
+
* fields nothing here reads — an ik key's `mix`, a deform key's `offset`, an
|
|
166
|
+
* event key's `float`, a derive's `degrees`.
|
|
167
|
+
*
|
|
168
|
+
* Where an unchecked type is refused: `spec` — the version check; a track's
|
|
169
|
+
* `property` — `compileTrack`; a derive's `kind` — `evaluateTrackDerive`; a deform
|
|
170
|
+
* transform's `kind`, `along` and `axis` — `evaluateDeformTransform`;
|
|
171
|
+
* `MotionKey.v`, a key's `curve`, a derive's `depth` and `mix.pairs` (`mixed`)
|
|
172
|
+
* — the track compiler by property, the curve reader, the derive evaluator and
|
|
173
|
+
* `parseMix`. A sequence key's `mode` is a closed set too, but its interface
|
|
174
|
+
* declares it `string` and so does this row: `parseSequence` refuses a name
|
|
175
|
+
* outside `SEQUENCE_MODES` before the walk runs, and a non-string with it.
|
|
176
|
+
*/
|
|
177
|
+
type MotionTypeTable = {
|
|
178
|
+
readonly [S in keyof typeof MOTION_KEYS]: { readonly [K in (typeof MOTION_KEYS)[S][number]]: SpecValueType };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const MOTION_TYPES = {
|
|
182
|
+
MotionSpec: {
|
|
183
|
+
spec: 'enum', archetype: 'string', cut: 'string', note: 'string', easings: 'map of number[]', groups: 'map of string[]',
|
|
184
|
+
setup: 'map of object', physics: 'map of object', animations: 'map of object', mix: 'object',
|
|
185
|
+
},
|
|
186
|
+
MotionMix: { default: 'number', pairs: 'mixed' },
|
|
187
|
+
MotionSetupSlot: { attachment: 'string | null', color: 'number[]' },
|
|
188
|
+
MotionPhysics: {
|
|
189
|
+
bone: 'string', x: 'number', y: 'number', rotate: 'number', scaleX: 'number', shearX: 'number', inertia: 'number',
|
|
190
|
+
strength: 'number', damping: 'number', mass: 'number', wind: 'number', gravity: 'number', mix: 'number',
|
|
191
|
+
fps: 'number', limit: 'number', note: 'string',
|
|
192
|
+
},
|
|
193
|
+
MotionAnimation: {
|
|
194
|
+
duration: 'number', loop: 'boolean', note: 'string', tracks: 'object[]', ik: 'object[]', transform: 'object[]',
|
|
195
|
+
deform: 'object[]', sequence: 'object[]', drawOrder: 'object[]', events: 'object[]',
|
|
196
|
+
},
|
|
197
|
+
MotionTrack: {
|
|
198
|
+
slot: 'string', group: 'string', bone: 'string', physics: 'string', path: 'string', slider: 'string',
|
|
199
|
+
property: 'enum', lag: 'number', stagger: 'number', keys: 'object[]',
|
|
200
|
+
},
|
|
201
|
+
MotionKey: { t: 'number', v: 'mixed', derive: 'object', ease: 'string', curve: 'mixed' },
|
|
202
|
+
MotionIkTrack: { constraint: 'string', keys: 'object[]' },
|
|
203
|
+
MotionIkKey: {
|
|
204
|
+
t: 'number', mix: 'number', softness: 'number', bendPositive: 'boolean', compress: 'boolean', stretch: 'boolean',
|
|
205
|
+
ease: 'string', curve: 'mixed',
|
|
206
|
+
},
|
|
207
|
+
MotionTransformTrack: { constraint: 'string', keys: 'object[]' },
|
|
208
|
+
MotionTransformKey: {
|
|
209
|
+
t: 'number', mixRotate: 'number', mixX: 'number', mixY: 'number', mixScaleX: 'number', mixScaleY: 'number',
|
|
210
|
+
mixShearY: 'number', ease: 'string', curve: 'mixed',
|
|
211
|
+
},
|
|
212
|
+
MotionDeformTrack: { skin: 'string', slot: 'string', attachment: 'string', keys: 'object[]' },
|
|
213
|
+
MotionDeformKey: {
|
|
214
|
+
t: 'number', offset: 'number', fromVertex: 'number', vertices: 'number[] | null', transform: 'object', ease: 'string', curve: 'mixed',
|
|
215
|
+
},
|
|
216
|
+
MotionSequenceTrack: { skin: 'string', slot: 'string', attachment: 'string', keys: 'object[]' },
|
|
217
|
+
MotionSequenceKey: { t: 'number', mode: 'string', index: 'number', delay: 'number' },
|
|
218
|
+
MotionDrawOrderKey: { t: 'number', offsets: 'object[]' },
|
|
219
|
+
MotionDrawOrderOffset: { slot: 'string', offset: 'number' },
|
|
220
|
+
MotionEventKey: {
|
|
221
|
+
t: 'number', name: 'string', int: 'number', float: 'number', string: 'string', volume: 'number', balance: 'number',
|
|
222
|
+
},
|
|
223
|
+
TrackDeriveTurn: { kind: 'enum', degrees: 'number', depth: 'mixed', carried: 'number', about: 'number' },
|
|
224
|
+
DeformTurn: { kind: 'enum', radius: 'number', depth: 'boolean', degrees: 'number', about: 'number' },
|
|
225
|
+
DeformAffine: { kind: 'enum', scale: 'number[]', about: 'number[]' },
|
|
226
|
+
DeformWave: { kind: 'enum', amplitude: 'number', wavelength: 'number', phase: 'number', along: 'enum', axis: 'enum' },
|
|
227
|
+
DeformBend: { kind: 'enum', amount: 'number', from: 'number', to: 'number', power: 'number', along: 'enum', axis: 'enum' },
|
|
228
|
+
} as const satisfies MotionTypeTable;
|
|
229
|
+
|
|
155
230
|
/** A deform key's `transform` kinds, and the shape each one's keys come from. */
|
|
156
231
|
const DEFORM_TRANSFORM_SHAPE: Record<string, keyof typeof MOTION_KEYS> = {
|
|
157
232
|
yaw: 'DeformTurn',
|
|
@@ -199,9 +274,27 @@ function refuse(where: string, key: string, is: unknown, hint: string): never {
|
|
|
199
274
|
* the wrong field.
|
|
200
275
|
*/
|
|
201
276
|
function known(node: unknown, shape: keyof typeof MOTION_KEYS, where: string, at: string): void {
|
|
202
|
-
if (isObj(node))
|
|
277
|
+
if (!isObj(node)) return;
|
|
278
|
+
refuseUnknownKeys(node, MOTION_KEYS[shape], where, `\`${at}\``);
|
|
279
|
+
// The root is named by its keys alone — `archetype`, not `this motion spec.archetype`.
|
|
280
|
+
const prefix = shape === 'MotionSpec' ? '' : at;
|
|
281
|
+
visiting?.push({
|
|
282
|
+
node,
|
|
283
|
+
shape,
|
|
284
|
+
name: (tail) => `\`${prefix}${tail.map((step, i) => (typeof step === 'number' ? `[${step}]` : prefix === '' && i === 0 ? step : `.${step}`)).join('')}\``,
|
|
285
|
+
});
|
|
203
286
|
}
|
|
204
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The nodes `known` admitted during the parse in progress, in the order it
|
|
290
|
+
* admitted them — what `refuseValuesOfTheWrongType` walks at the end of
|
|
291
|
+
* `parseMotionSpec`. Module state rather than a parameter because `known` is
|
|
292
|
+
* called from a dozen readers that would otherwise each carry it; it is set and
|
|
293
|
+
* cleared by `parseMotionSpec` alone, around a parse that is synchronous and
|
|
294
|
+
* does not re-enter, so no two parses ever share it.
|
|
295
|
+
*/
|
|
296
|
+
let visiting: ShapeVisit[] | null = null;
|
|
297
|
+
|
|
205
298
|
// --- the leaf checks, each returning the value it just proved ---------------
|
|
206
299
|
|
|
207
300
|
function needObj(v: unknown, where: string, key: string, hint: string): Record<string, unknown> {
|
|
@@ -633,6 +726,16 @@ function parseAnimation(raw: unknown, where: string, name: string): void {
|
|
|
633
726
|
* otherwise no way to tell which of them is at fault (issue #227).
|
|
634
727
|
*/
|
|
635
728
|
export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
|
|
729
|
+
const visits: ShapeVisit[] = [];
|
|
730
|
+
visiting = visits;
|
|
731
|
+
try {
|
|
732
|
+
return parseMotionSpecInto(raw, where, visits);
|
|
733
|
+
} finally {
|
|
734
|
+
visiting = null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function parseMotionSpecInto(raw: unknown, where: string, visits: readonly ShapeVisit[]): MotionSpec {
|
|
636
739
|
if (!isObj(raw)) {
|
|
637
740
|
throw new CompileError(`${where}: a motion spec must be a JSON object, and this file holds ${describe(raw)}`);
|
|
638
741
|
}
|
|
@@ -665,5 +768,22 @@ export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
|
|
|
665
768
|
parseAnimation(anim, where, name);
|
|
666
769
|
}
|
|
667
770
|
|
|
771
|
+
// After every field check above, so a field this file checks keeps its own
|
|
772
|
+
// sentence, and before the finite walk, so a value that is not a number is
|
|
773
|
+
// named as that rather than skipped (issue #890). What reaches this line is a
|
|
774
|
+
// field nothing above reads. Measured before the walk, every one of 303
|
|
775
|
+
// wrong-typed plants over 18 motion shapes was refused somewhere — but later,
|
|
776
|
+
// by the timeline compiler, and 7 of those sentences named the wrong fault:
|
|
777
|
+
// an event key's `"float": "1"` as *float 1 is not finite*, a `"volume": "1"`
|
|
778
|
+
// as an event with no audio, a yaw transform's `"depth": "true"` as a radius
|
|
779
|
+
// that is undefined.
|
|
780
|
+
refuseValuesOfTheWrongType(visits, MOTION_TYPES, where);
|
|
781
|
+
|
|
782
|
+
// Last, so every field check above keeps its own sentence for a number that
|
|
783
|
+
// is not finite; what reaches this line is a finite double the float32 file
|
|
784
|
+
// cannot carry — a key at 1e308 built green with a `null` in it before issue
|
|
785
|
+
// #881 — and any number in a field no check above reads.
|
|
786
|
+
refuseNumbersTheFileCannotCarry(raw, where, (path) => `\`${dottedPath(path)}\``);
|
|
787
|
+
|
|
668
788
|
return raw as unknown as MotionSpec;
|
|
669
789
|
}
|
package/src/pose.ts
CHANGED
|
@@ -274,8 +274,9 @@ const MINIMA_PER_SCALE = 5;
|
|
|
274
274
|
const REFINE_CANDIDATES = 15;
|
|
275
275
|
|
|
276
276
|
/**
|
|
277
|
-
* The
|
|
278
|
-
* stops — half a coarse scale rung, the resolution the coarse ladder itself had
|
|
277
|
+
* The factors a converged full-resolution polish tries its scale up by before it
|
|
278
|
+
* stops — half a coarse scale rung, the resolution the coarse ladder itself had,
|
|
279
|
+
* and a quarter of one (issue #886); the better of the two re-fits wins.
|
|
279
280
|
*
|
|
280
281
|
* 🔍 What the pattern search cannot do, and the trace table of issue #877 is
|
|
281
282
|
* how it was seen: every probe moves ONE degree of freedom, and the objective
|
|
@@ -306,8 +307,32 @@ const REFINE_CANDIDATES = 15;
|
|
|
306
307
|
* margin, and the blurred 24 px front shin (walk) now places AT its truth
|
|
307
308
|
* (0.03 px, 0.00989) where it placed 0.57 px off at 0.0191 — the old answer is
|
|
308
309
|
* still reported beside it, 0.0094 above, inside the 0.01 absolute margin.
|
|
310
|
+
*
|
|
311
|
+
* 🔍 The quarter rung is issue #886, and the fixed-scale profile is what
|
|
312
|
+
* named it. MOTION.md §6's post (14x96, pose A, `--scale 0.85,1.2`) came back
|
|
313
|
+
* at scale 0.917 and residual 0.0649 where the page had recorded 0.975 at
|
|
314
|
+
* 0.0589 — the old placement still scores 0.0589 on this objective, so the
|
|
315
|
+
* search missed it. The trace: the coarse cell nearest the truth (2.4 px off
|
|
316
|
+
* it, at the 2x level) kept a half-turned post, its polish ended 6.7 px from
|
|
317
|
+
* the truth, the full-resolution rotation re-grid set it upright there, and
|
|
318
|
+
* the polish climbed the scale–position valley to 0.917.
|
|
319
|
+
* The half-rung escape then tried 1.029: best position there 0.0980, worse
|
|
320
|
+
* than 0.0649, so it declined — while the best position at every scale
|
|
321
|
+
* between 0.94 and 1.00 scores 0.0619–0.0589. A part whose material fills its
|
|
322
|
+
* image pays for every pixel pushed past the figure, so its profile above the
|
|
323
|
+
* truth is a cliff, and half a rung stepped over the basin onto it. A quarter
|
|
324
|
+
* rung lands at 0.971 (0.0593, 0.1 px off the page's placement).
|
|
325
|
+
*
|
|
326
|
+
* 📏 Both, not the quarter alone, and measured on the grid rather than argued:
|
|
327
|
+
* the quarter alone found 234 (3 gained, 6 lost — the native arms #877 was
|
|
328
|
+
* made for need the half); half then quarter, the quarter tried only when the
|
|
329
|
+
* half declines, 237 (1, 1); both, the better kept, 238 (2 gained, 1 lost) and
|
|
330
|
+
* misses 8 → 6, for 16% more user CPU over the grid. The one it loses is a
|
|
331
|
+
* margin reading of the kind named above: the blurred 32 px front shin (walk)
|
|
332
|
+
* now reports its truth as its BEST (0.07 px, 0.00968) with the old 0.51 px
|
|
333
|
+
* answer 0.0045 above it, inside the 0.01 absolute margin.
|
|
309
334
|
*/
|
|
310
|
-
const
|
|
335
|
+
const POLISH_SCALE_ESCAPES = [2 ** (1 / (4 * SCALE_STEPS_PER_OCTAVE)), 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE))];
|
|
311
336
|
|
|
312
337
|
/** Sample budgets per stage. The reported residual uses every pixel regardless. */
|
|
313
338
|
const COARSE_SAMPLES = 96;
|
|
@@ -347,8 +372,8 @@ export const POSE_FLOOR = {
|
|
|
347
372
|
span: 24,
|
|
348
373
|
detail: 0.5,
|
|
349
374
|
/** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
|
|
350
|
-
rateBelow: 0.
|
|
351
|
-
rateAbove: 0.
|
|
375
|
+
rateBelow: 0.066,
|
|
376
|
+
rateAbove: 0.772,
|
|
352
377
|
};
|
|
353
378
|
|
|
354
379
|
/** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
|
|
@@ -681,6 +706,15 @@ export interface PoseTraceLevel {
|
|
|
681
706
|
out: PoseTraceCandidate[];
|
|
682
707
|
/** This level's objective at `PoseTrace.probe`, when one was given. */
|
|
683
708
|
probeResidual: number | null;
|
|
709
|
+
/**
|
|
710
|
+
* At the coarse level only, and `null` on every other (issue #892): the
|
|
711
|
+
* rotation ladder the coarse field scanned, and per seed the field's own
|
|
712
|
+
* objective at that seed's anchor cell and scale for every rung of it, in
|
|
713
|
+
* ladder order — the numbers the field picked the seed's one rotation from.
|
|
714
|
+
* Computed with the field's arithmetic, so the rung a seed carries reads the
|
|
715
|
+
* least value of its row.
|
|
716
|
+
*/
|
|
717
|
+
coarseRotations: { ladder: number[]; residuals: number[][] } | null;
|
|
684
718
|
}
|
|
685
719
|
|
|
686
720
|
/**
|
|
@@ -1064,6 +1098,22 @@ function residualAt(level: Level, plate: Plate, s: Samples, cand: Candidate, smo
|
|
|
1064
1098
|
* A per-cell best rather than a global top-K, because the thing this instrument
|
|
1065
1099
|
* must not lose is the SECOND place a part could sit — and a global top-K fills
|
|
1066
1100
|
* up with a hundred neighbours of the single best cell before it ever reaches it.
|
|
1101
|
+
*
|
|
1102
|
+
* 📏 ONE rotation per cell, and issue #892 measured what that costs rather than
|
|
1103
|
+
* assuming it. Over `tools/pose_floor.ts`'s 420 trials, the coarse seed nearest
|
|
1104
|
+
* the truth is within 2.4 px of it and a half-turn off on 2 of the 238 found
|
|
1105
|
+
* trials (both a mouth at 48 px, both found anyway) and on none of the 6
|
|
1106
|
+
* misses; every failed trial whose nearest seed is a quarter- or half-turn off
|
|
1107
|
+
* but one is a flat part, and none of those 26 is a miss: each is the objective
|
|
1108
|
+
* preferring another placement or the truth reported and tied. MOTION.md
|
|
1109
|
+
* §6's post is the one case, and a second rotation per cell would not reach it:
|
|
1110
|
+
* at its nearest cell the upright rung scores 0.305 against the half-turn's
|
|
1111
|
+
* 0.168 — 1.8 times, wider than the 1.73 band `REFINE_CANDIDATES` rejected.
|
|
1112
|
+
* The other two levers the card named were measured on the grid and lost found
|
|
1113
|
+
* trials outside the ambiguity margin: re-gridding rotation at this level
|
|
1114
|
+
* rather than the next (238 → 236: 7 gained, 9 lost, six of them native, three
|
|
1115
|
+
* guns now 37–151 px off), and suppressing minima only within a rotation family
|
|
1116
|
+
* (238 → 231: 2 gained, 10 lost, among them the 48 px fist `PO23` holds).
|
|
1067
1117
|
*/
|
|
1068
1118
|
interface CoarseField {
|
|
1069
1119
|
residual: Float64Array;
|
|
@@ -1140,6 +1190,25 @@ function coarseScan(level: Level, s: Samples, scale: number, rotations: number[]
|
|
|
1140
1190
|
return field;
|
|
1141
1191
|
}
|
|
1142
1192
|
|
|
1193
|
+
/**
|
|
1194
|
+
* The coarse field's objective at one anchor, scale and rotation, summed in the
|
|
1195
|
+
* order `coarseScan` sums it and without its early exit — the trace's reading of
|
|
1196
|
+
* a cell, never the search's.
|
|
1197
|
+
*/
|
|
1198
|
+
function coarseCellResidual(level: Level, s: Samples, scale: number, rotDeg: number, ax: number, ay: number): number {
|
|
1199
|
+
if (s.count === 0) return Infinity;
|
|
1200
|
+
const k = scale / level.reduction;
|
|
1201
|
+
const cos = Math.cos(rotDeg * DEG) * k;
|
|
1202
|
+
const sin = Math.sin(rotDeg * DEG) * k;
|
|
1203
|
+
let acc = 0;
|
|
1204
|
+
for (let i = 0; i < s.count; i++) {
|
|
1205
|
+
const dx = s.u[i] * cos - s.v[i] * sin;
|
|
1206
|
+
const dy = s.u[i] * sin + s.v[i] * cos;
|
|
1207
|
+
acc += s.w[i] * errNearest(level, ax + dx, ay + dy, s.r[i], s.g[i], s.b[i]);
|
|
1208
|
+
}
|
|
1209
|
+
return acc / s.weight;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1143
1212
|
/**
|
|
1144
1213
|
* The distinct places a part could sit, best first.
|
|
1145
1214
|
*
|
|
@@ -1934,7 +2003,7 @@ function placePart(
|
|
|
1934
2003
|
}
|
|
1935
2004
|
const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
|
|
1936
2005
|
candidates = seeds.map((seed, i) =>
|
|
1937
|
-
polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ?
|
|
2006
|
+
polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? POLISH_SCALE_ESCAPES : [], trace === undefined ? undefined : paths[i]),
|
|
1938
2007
|
);
|
|
1939
2008
|
const polished = candidates.slice();
|
|
1940
2009
|
candidates.sort((a, b) => a.residual - b.residual);
|
|
@@ -1984,6 +2053,15 @@ function placePart(
|
|
|
1984
2053
|
polished: polished.map(said),
|
|
1985
2054
|
out: candidates.map(said),
|
|
1986
2055
|
probeResidual,
|
|
2056
|
+
coarseRotations:
|
|
2057
|
+
li === coarseIndex
|
|
2058
|
+
? {
|
|
2059
|
+
ladder: searchRotations.slice(),
|
|
2060
|
+
residuals: seeds.map((seed) =>
|
|
2061
|
+
searchRotations.map((rotDeg) => coarseCellResidual(level, coarseSamples, seed.scale, rotDeg, seed.cx, seed.cy)),
|
|
2062
|
+
),
|
|
2063
|
+
}
|
|
2064
|
+
: null,
|
|
1987
2065
|
});
|
|
1988
2066
|
}
|
|
1989
2067
|
if (li > 0) {
|
package/src/render.ts
CHANGED
|
@@ -1288,6 +1288,38 @@ function firstNonFinite(where: string, entries: readonly PosedVertices[], bones:
|
|
|
1288
1288
|
return null;
|
|
1289
1289
|
}
|
|
1290
1290
|
|
|
1291
|
+
/**
|
|
1292
|
+
* The same sentence for one skeleton as it stands posed now — its bones, then
|
|
1293
|
+
* the vertices of every region and mesh its slots show, in draw order — or
|
|
1294
|
+
* `null` when every one of those numbers is finite.
|
|
1295
|
+
*
|
|
1296
|
+
* ⭐ This is how `A10_NO_NAN_AFTER_STEPPING` reads each pose it steps (issue
|
|
1297
|
+
* #882), so the gate and the renderer hold one definition of "not finite": the
|
|
1298
|
+
* six terms `firstNonFinite` reads off a bone, and the vertices the runtime
|
|
1299
|
+
* computes from them. Before it A10 read the world POSITION alone, and a bone at
|
|
1300
|
+
* `rotation: 1e309` — finite position, NaN `a`, `b`, `c`, `d` — was gated green
|
|
1301
|
+
* and then refused here. The vertices are read too because a bone can be finite
|
|
1302
|
+
* and still carry one that is not: a two-bone `scaleX` chain of 1e154 × 1e154
|
|
1303
|
+
* leaves the child's `a` at 1e308, finite, and every vertex of its attachment
|
|
1304
|
+
* past the largest double. Only computed once every bone is finite, so a
|
|
1305
|
+
* broken bone is named as the cause rather than through its vertices.
|
|
1306
|
+
*/
|
|
1307
|
+
export function nonFiniteOfPosed(where: string, skeleton: Skeleton): string | null {
|
|
1308
|
+
const bones: WorldTransform[] = skeleton.bones.map((bone) => {
|
|
1309
|
+
const { a, b, c, d, worldX, worldY } = bone.appliedPose;
|
|
1310
|
+
return { name: bone.data.name, a, b, c, d, worldX, worldY };
|
|
1311
|
+
});
|
|
1312
|
+
const found = firstNonFinite(where, [], bones);
|
|
1313
|
+
if (found !== null) return found;
|
|
1314
|
+
const entries: PosedVertices[] = [];
|
|
1315
|
+
for (const slot of skeleton.drawOrder.appliedPose) {
|
|
1316
|
+
const attachment = slot.appliedPose.attachment;
|
|
1317
|
+
if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
|
|
1318
|
+
entries.push({ slot: slot.data.name, attachment: attachment.name, vertices: worldVerticesOf(skeleton, slot, attachment) });
|
|
1319
|
+
}
|
|
1320
|
+
return firstNonFinite(where, entries, []);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1291
1323
|
/**
|
|
1292
1324
|
* How a sampled frame is named in that sentence: the animation, the frame's
|
|
1293
1325
|
* index at the rate it was sampled and its time — or the bare index for the one
|