spine-rigc 1.5.1 → 1.5.3

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/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. `KEY01` in `selftest.ts` derives each set from the interface's
24
- * own source text and compares, so the pair cannot drift in silence, and `KEY02`
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
  *
@@ -159,8 +159,10 @@ export const FLOAT32_MAX = 3.4028234663852886e38;
159
159
  * every run.
160
160
  *
161
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 the reader downstream
163
- * that coerces it is a type question this walk does not answer.
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.
164
166
  *
165
167
  * `place` turns a path into the words the refusal uses for it.
166
168
  */
@@ -202,3 +204,283 @@ export function dottedPath(path: ReadonlyArray<string | number>): string {
202
204
  }
203
205
  return out;
204
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. Every `enum` row has an entry in the
223
+ * shape's enum table (`RIG_ENUMS`, `MOTION_ENUMS`, `MANIFEST_ENUMS`, typed by
224
+ * `SpecEnumTable`), which either states the set — and
225
+ * `refuseValuesOutsideTheirSet` refuses a value outside it — or names the
226
+ * reader that refuses it where it reads it, with the names that exist listed
227
+ * (a slot's `blend`, a constraint's `type`);
228
+ * - `mixed` — a union of kinds whose owner decides by the value (`MotionKey.v`
229
+ * is numbers on `rotate`, a name on `attachment`, a member map on a group).
230
+ */
231
+ export const SPEC_VALUE_TYPES = [
232
+ 'number',
233
+ 'string',
234
+ 'boolean',
235
+ 'number | null',
236
+ 'string | null',
237
+ 'number[]',
238
+ 'number[] | null',
239
+ 'string[]',
240
+ 'number[][]',
241
+ 'map of number[]',
242
+ 'map of string[]',
243
+ 'map of string | null',
244
+ 'object',
245
+ 'object[]',
246
+ 'object[][]',
247
+ 'map of object',
248
+ 'enum',
249
+ 'mixed',
250
+ ] as const;
251
+
252
+ export type SpecValueType = (typeof SPEC_VALUE_TYPES)[number];
253
+
254
+ /** A shape's keys and the type each one holds. */
255
+ export type SpecTypeRow = Readonly<Record<string, SpecValueType>>;
256
+
257
+ /** One node of an input file that a key scan visited, as the type walk needs it. */
258
+ export interface ShapeVisit {
259
+ node: Record<string, unknown>;
260
+ /** The row of the type table this node's keys are typed by. */
261
+ shape: string;
262
+ /** The words a refusal uses for a path below this node — `x`, `weights[3][1]`, `color[2]`. */
263
+ name: (tail: ReadonlyArray<string | number>) => string;
264
+ }
265
+
266
+ /** What a JSON value is, in the words the type refusal uses: `a string "5"`, `an array [1]`, `null`. */
267
+ function typeFound(value: unknown): string {
268
+ if (value === null) return 'null';
269
+ const kind = Array.isArray(value) ? 'array' : typeof value === 'object' ? 'object' : typeof value;
270
+ const shown = JSON.stringify(value) ?? String(value);
271
+ return `${/^[aeiou]/.test(kind) ? 'an' : 'a'} ${kind} ${shown.length > 60 ? `${shown.slice(0, 57)}...` : shown}`;
272
+ }
273
+
274
+ /** A checked type as the refusal requires it: `a number`, `an array of numbers`, `a string or null`. */
275
+ const REQUIRED: Record<string, string> = {
276
+ number: 'a number',
277
+ string: 'a string',
278
+ boolean: 'a boolean (true or false)',
279
+ 'number | null': 'a number or null',
280
+ 'string | null': 'a string or null',
281
+ 'number[]': 'an array of numbers',
282
+ 'number[] | null': 'an array of numbers, or null',
283
+ 'string[]': 'an array of strings',
284
+ 'number[][]': 'an array of arrays of numbers',
285
+ 'map of number[]': 'an object whose every value is an array of numbers',
286
+ 'map of string[]': 'an object whose every value is an array of strings',
287
+ 'map of string | null': 'an object whose every value is a string or null',
288
+ };
289
+
290
+ /** The types `refuseValuesOfTheWrongType` checks — every one `REQUIRED` can word. */
291
+ export const CHECKED_SPEC_VALUE_TYPES: readonly SpecValueType[] = SPEC_VALUE_TYPES.filter((t) => REQUIRED[t] !== undefined);
292
+
293
+ /**
294
+ * The first place under `value` that is not of `type`, and what was required
295
+ * there — or `null` when it is. An element fault names its own index, so an
296
+ * array of numbers with a string in slot 3 is refused at `[3]` rather than as
297
+ * a whole array.
298
+ */
299
+ function wrongTypeAt(
300
+ value: unknown,
301
+ type: SpecValueType,
302
+ ): { tail: Array<string | number>; value: unknown; required: string } | null {
303
+ const is = (v: unknown, t: SpecValueType): boolean => {
304
+ switch (t) {
305
+ case 'number':
306
+ return typeof v === 'number';
307
+ case 'string':
308
+ return typeof v === 'string';
309
+ case 'boolean':
310
+ return typeof v === 'boolean';
311
+ case 'number | null':
312
+ return v === null || typeof v === 'number';
313
+ case 'string | null':
314
+ return v === null || typeof v === 'string';
315
+ default:
316
+ return true;
317
+ }
318
+ };
319
+ const fault = (tail: Array<string | number>, v: unknown, t: SpecValueType) => ({ tail, value: v, required: REQUIRED[t] });
320
+ const list = (v: unknown, element: SpecValueType, whole: SpecValueType, tail: Array<string | number>) => {
321
+ if (!Array.isArray(v)) return fault(tail, v, whole);
322
+ for (const [i, item] of v.entries()) if (!is(item, element)) return fault([...tail, i], item, element);
323
+ return null;
324
+ };
325
+ const map = (v: unknown, each: SpecValueType, whole: SpecValueType) => {
326
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return fault([], v, whole);
327
+ const record = v as Record<string, unknown>;
328
+ for (const key of Object.keys(record)) {
329
+ const inner = wrongTypeAt(record[key], each);
330
+ if (inner !== null) return { ...inner, tail: [key, ...inner.tail] };
331
+ }
332
+ return null;
333
+ };
334
+ switch (type) {
335
+ case 'number':
336
+ case 'string':
337
+ case 'boolean':
338
+ case 'number | null':
339
+ case 'string | null':
340
+ return is(value, type) ? null : fault([], value, type);
341
+ case 'number[]':
342
+ return list(value, 'number', type, []);
343
+ case 'number[] | null':
344
+ return value === null ? null : list(value, 'number', type, []);
345
+ case 'string[]':
346
+ return list(value, 'string', type, []);
347
+ case 'number[][]': {
348
+ if (!Array.isArray(value)) return fault([], value, type);
349
+ for (const [i, row] of value.entries()) {
350
+ const inner = list(row, 'number', 'number[]', [i]);
351
+ if (inner !== null) return inner;
352
+ }
353
+ return null;
354
+ }
355
+ case 'map of number[]':
356
+ return map(value, 'number[]', type);
357
+ case 'map of string[]':
358
+ return map(value, 'string[]', type);
359
+ case 'map of string | null':
360
+ return map(value, 'string | null', type);
361
+ default:
362
+ return null;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Refuse the first value in an input file whose JSON type is not the one its
368
+ * field holds (issue #890).
369
+ *
370
+ * 🚨 The defect this closes was a green build, and it sat directly under the
371
+ * rule #881 wrote. A number that is not a number is coerced by the arithmetic
372
+ * that reads it before any rule sees a number at all: measured on the root bone
373
+ * of the generated probes, `"x": "5"` built as `5`, `"rotation": "90"` as `90`,
374
+ * `"x": true` as `1`, `"x": [1]` as `1` and `"x": "NaN"` as `null`, every one
375
+ * green. The compiler was writing a value the spec never stated, or one it
376
+ * guessed.
377
+ *
378
+ * ⭐ It walks the nodes the key scan visited, not the emitter's route, and it
379
+ * reads the type table that sits beside the key table — so a key cannot be
380
+ * admitted by the scan and untyped here (a selftest control holds the two
381
+ * tables equal, and so does `satisfies` over the key table's own type). The
382
+ * refusal names the field, the value found and the type required:
383
+ * `bone "hip" x is a string "5"; a number is required`.
384
+ *
385
+ * 🔒 The report order is fixed — visits in the scan's order, keys in the
386
+ * document's order, arrays by index — so the refusal names the same value on
387
+ * every run.
388
+ *
389
+ * ⚠️ A JSON `null` is a value, not an absence. It is accepted only where the
390
+ * field's type says `null` (a slot's setup `attachment`, a stage stated absent);
391
+ * everywhere else it is refused as `null`, because the readers downstream
392
+ * treated it as whatever `null` coerces to in the arithmetic they happened to do
393
+ * — measured, a bone's `"rotation": null` built green with its bytes moved.
394
+ */
395
+ export function refuseValuesOfTheWrongType(
396
+ visits: readonly ShapeVisit[],
397
+ types: Readonly<Record<string, SpecTypeRow>>,
398
+ where: string,
399
+ ): void {
400
+ for (const visit of visits) {
401
+ const row = types[visit.shape];
402
+ 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)`);
403
+ for (const key of Object.keys(visit.node)) {
404
+ const type = row[key];
405
+ // An unknown key is the key scan's refusal and it has already run; a
406
+ // shape without a scan (the cut manifest) tolerates keys it does not
407
+ // read, and this walk does not change what a shape accepts.
408
+ if (type === undefined) continue;
409
+ const wrong = wrongTypeAt(visit.node[key], type);
410
+ if (wrong === null) continue;
411
+ throw new CompileError(
412
+ `${where}: ${visit.name([key, ...wrong.tail])} is ${typeFound(wrong.value)}; ${wrong.required} is required`,
413
+ );
414
+ }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Who refuses a value outside an `enum` row's closed set (issue #900).
420
+ *
421
+ * - `set` — the names the row accepts, refused from one place by
422
+ * `refuseValuesOutsideTheirSet`; `readAs` is what the readers did with any
423
+ * other value before the set was stated, which the refusal says, because
424
+ * "one of" alone does not tell an author that the build they had was wrong;
425
+ * - `owner` — the function that reads the value and refuses one outside its
426
+ * set by name there, listing the names that exist. It keeps its own sentence,
427
+ * which often says more than a set can (a first letter whose case is free, a
428
+ * row chosen by the value itself).
429
+ */
430
+ export type SpecEnumRule = { readonly set: readonly string[]; readonly readAs: string } | { readonly owner: string };
431
+
432
+ /** The keys of a type row whose type is `enum`. */
433
+ type EnumKeysOf<R> = { [K in keyof R]: R[K] extends 'enum' ? K : never }[keyof R];
434
+
435
+ /**
436
+ * A type table's enum table: one entry per `enum` row, and nothing else.
437
+ *
438
+ * 🔒 `satisfies SpecEnumTable<typeof X_TYPES>` is what makes a fourth unowned
439
+ * enum a type error rather than a row nobody holds: a shape with an `enum` row
440
+ * must appear, with exactly its `enum` keys, and a key of another type cannot.
441
+ * A selftest control holds the same claim at runtime and reads each `owner`'s
442
+ * body for the key it is said to own.
443
+ */
444
+ export type SpecEnumTable<T extends Readonly<Record<string, SpecTypeRow>>> = {
445
+ readonly [S in keyof T as [EnumKeysOf<T[S]>] extends [never] ? never : S]: { readonly [K in EnumKeysOf<T[S]>]: SpecEnumRule };
446
+ };
447
+
448
+ /**
449
+ * Refuse the first value of an `enum` row whose enum table states a set and
450
+ * that is not in it (issue #900).
451
+ *
452
+ * 🚨 The defect this closes was a green build again, one step past #890's:
453
+ * the type walk does not check `enum` (a name's type says nothing about which
454
+ * names exist), and three rows had nobody holding the name — `boneIndexing`,
455
+ * a bone's `from.rotation` and the cut manifest's `mesh.kind`. `"foo"` and `5`
456
+ * were each read as something the spec never said, in silence.
457
+ *
458
+ * It runs after the type walk and over the same visits, so a key the scan
459
+ * refused is never judged here, and before every reader. A row whose table
460
+ * entry names an `owner` is passed over: that reader refuses it with its own
461
+ * sentence. The order is the type walk's — visits in the scan's order, keys in
462
+ * the document's — so the refusal names the same value on every run.
463
+ */
464
+ export function refuseValuesOutsideTheirSet(
465
+ visits: readonly ShapeVisit[],
466
+ types: Readonly<Record<string, SpecTypeRow>>,
467
+ enums: Readonly<Record<string, Readonly<Record<string, SpecEnumRule>>>>,
468
+ where: string,
469
+ ): void {
470
+ for (const visit of visits) {
471
+ const row = types[visit.shape];
472
+ 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)`);
473
+ for (const key of Object.keys(visit.node)) {
474
+ if (row[key] !== 'enum') continue;
475
+ const rule = enums[visit.shape]?.[key];
476
+ if (rule === undefined) throw new Error(`${visit.shape}.${key} is an enum row with no entry in its enum table`);
477
+ if (!('set' in rule)) continue;
478
+ const value = visit.node[key];
479
+ if (typeof value === 'string' && rule.set.includes(value)) continue;
480
+ throw new CompileError(
481
+ `${where}: ${visit.name([key])} is ${JSON.stringify(value) ?? String(value)}; one of ` +
482
+ `${rule.set.map((name) => JSON.stringify(name)).join(', ')} — ${rule.readAs}`,
483
+ );
484
+ }
485
+ }
486
+ }
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 { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys } from './keys.ts';
57
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
58
+ import type { ShapeVisit, SpecEnumTable, 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 `KEY01` in `selftest.ts`, which reads the
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,97 @@ 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: an `enum` row — the entry `MOTION_ENUMS`
169
+ * has for it, below, which is a table a control reads rather than a list here;
170
+ * `MotionKey.v`, a key's `curve`, a derive's `depth` and `mix.pairs` (`mixed`)
171
+ * — the track compiler by property, the curve reader, the derive evaluator and
172
+ * `parseMix`. A sequence key's `mode` is a closed set too, but its interface
173
+ * declares it `string` and so does this row: `parseSequence` refuses a name
174
+ * outside `SEQUENCE_MODES` before the walk runs, and a non-string with it.
175
+ */
176
+ type MotionTypeTable = {
177
+ readonly [S in keyof typeof MOTION_KEYS]: { readonly [K in (typeof MOTION_KEYS)[S][number]]: SpecValueType };
178
+ };
179
+
180
+ export const MOTION_TYPES = {
181
+ MotionSpec: {
182
+ spec: 'enum', archetype: 'string', cut: 'string', note: 'string', easings: 'map of number[]', groups: 'map of string[]',
183
+ setup: 'map of object', physics: 'map of object', animations: 'map of object', mix: 'object',
184
+ },
185
+ MotionMix: { default: 'number', pairs: 'mixed' },
186
+ MotionSetupSlot: { attachment: 'string | null', color: 'number[]' },
187
+ MotionPhysics: {
188
+ bone: 'string', x: 'number', y: 'number', rotate: 'number', scaleX: 'number', shearX: 'number', inertia: 'number',
189
+ strength: 'number', damping: 'number', mass: 'number', wind: 'number', gravity: 'number', mix: 'number',
190
+ fps: 'number', limit: 'number', note: 'string',
191
+ },
192
+ MotionAnimation: {
193
+ duration: 'number', loop: 'boolean', note: 'string', tracks: 'object[]', ik: 'object[]', transform: 'object[]',
194
+ deform: 'object[]', sequence: 'object[]', drawOrder: 'object[]', events: 'object[]',
195
+ },
196
+ MotionTrack: {
197
+ slot: 'string', group: 'string', bone: 'string', physics: 'string', path: 'string', slider: 'string',
198
+ property: 'enum', lag: 'number', stagger: 'number', keys: 'object[]',
199
+ },
200
+ MotionKey: { t: 'number', v: 'mixed', derive: 'object', ease: 'string', curve: 'mixed' },
201
+ MotionIkTrack: { constraint: 'string', keys: 'object[]' },
202
+ MotionIkKey: {
203
+ t: 'number', mix: 'number', softness: 'number', bendPositive: 'boolean', compress: 'boolean', stretch: 'boolean',
204
+ ease: 'string', curve: 'mixed',
205
+ },
206
+ MotionTransformTrack: { constraint: 'string', keys: 'object[]' },
207
+ MotionTransformKey: {
208
+ t: 'number', mixRotate: 'number', mixX: 'number', mixY: 'number', mixScaleX: 'number', mixScaleY: 'number',
209
+ mixShearY: 'number', ease: 'string', curve: 'mixed',
210
+ },
211
+ MotionDeformTrack: { skin: 'string', slot: 'string', attachment: 'string', keys: 'object[]' },
212
+ MotionDeformKey: {
213
+ t: 'number', offset: 'number', fromVertex: 'number', vertices: 'number[] | null', transform: 'object', ease: 'string', curve: 'mixed',
214
+ },
215
+ MotionSequenceTrack: { skin: 'string', slot: 'string', attachment: 'string', keys: 'object[]' },
216
+ MotionSequenceKey: { t: 'number', mode: 'string', index: 'number', delay: 'number' },
217
+ MotionDrawOrderKey: { t: 'number', offsets: 'object[]' },
218
+ MotionDrawOrderOffset: { slot: 'string', offset: 'number' },
219
+ MotionEventKey: {
220
+ t: 'number', name: 'string', int: 'number', float: 'number', string: 'string', volume: 'number', balance: 'number',
221
+ },
222
+ TrackDeriveTurn: { kind: 'enum', degrees: 'number', depth: 'mixed', carried: 'number', about: 'number' },
223
+ DeformTurn: { kind: 'enum', radius: 'number', depth: 'boolean', degrees: 'number', about: 'number' },
224
+ DeformAffine: { kind: 'enum', scale: 'number[]', about: 'number[]' },
225
+ DeformWave: { kind: 'enum', amplitude: 'number', wavelength: 'number', phase: 'number', along: 'enum', axis: 'enum' },
226
+ DeformBend: { kind: 'enum', amount: 'number', from: 'number', to: 'number', power: 'number', along: 'enum', axis: 'enum' },
227
+ } as const satisfies MotionTypeTable;
228
+
229
+ /**
230
+ * Who refuses each `enum` row of `MOTION_TYPES` outside its set (issue #900);
231
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what an entry means. Every one
232
+ * is an owner: measured when this table was written, a planted `5` and `"foo"`
233
+ * on each row was refused by name by the reader named here, listing the names
234
+ * that exist, so none of them states a set. A deform transform's and a derive's
235
+ * `kind` choose the row they are checked against, as a generator's does.
236
+ */
237
+ export const MOTION_ENUMS = {
238
+ MotionSpec: { spec: { owner: 'parseMotionSpecInto' } },
239
+ MotionTrack: { property: { owner: 'compileTrack' } },
240
+ TrackDeriveTurn: { kind: { owner: 'evaluateTrackDerive' } },
241
+ DeformTurn: { kind: { owner: 'evaluateDeformTransform' } },
242
+ DeformAffine: { kind: { owner: 'evaluateDeformTransform' } },
243
+ DeformWave: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
244
+ DeformBend: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
245
+ } as const satisfies SpecEnumTable<typeof MOTION_TYPES>;
246
+
155
247
  /** A deform key's `transform` kinds, and the shape each one's keys come from. */
156
248
  const DEFORM_TRANSFORM_SHAPE: Record<string, keyof typeof MOTION_KEYS> = {
157
249
  yaw: 'DeformTurn',
@@ -199,9 +291,27 @@ function refuse(where: string, key: string, is: unknown, hint: string): never {
199
291
  * the wrong field.
200
292
  */
201
293
  function known(node: unknown, shape: keyof typeof MOTION_KEYS, where: string, at: string): void {
202
- if (isObj(node)) refuseUnknownKeys(node, MOTION_KEYS[shape], where, `\`${at}\``);
294
+ if (!isObj(node)) return;
295
+ refuseUnknownKeys(node, MOTION_KEYS[shape], where, `\`${at}\``);
296
+ // The root is named by its keys alone — `archetype`, not `this motion spec.archetype`.
297
+ const prefix = shape === 'MotionSpec' ? '' : at;
298
+ visiting?.push({
299
+ node,
300
+ shape,
301
+ name: (tail) => `\`${prefix}${tail.map((step, i) => (typeof step === 'number' ? `[${step}]` : prefix === '' && i === 0 ? step : `.${step}`)).join('')}\``,
302
+ });
203
303
  }
204
304
 
305
+ /**
306
+ * The nodes `known` admitted during the parse in progress, in the order it
307
+ * admitted them — what `refuseValuesOfTheWrongType` walks at the end of
308
+ * `parseMotionSpec`. Module state rather than a parameter because `known` is
309
+ * called from a dozen readers that would otherwise each carry it; it is set and
310
+ * cleared by `parseMotionSpec` alone, around a parse that is synchronous and
311
+ * does not re-enter, so no two parses ever share it.
312
+ */
313
+ let visiting: ShapeVisit[] | null = null;
314
+
205
315
  // --- the leaf checks, each returning the value it just proved ---------------
206
316
 
207
317
  function needObj(v: unknown, where: string, key: string, hint: string): Record<string, unknown> {
@@ -633,6 +743,16 @@ function parseAnimation(raw: unknown, where: string, name: string): void {
633
743
  * otherwise no way to tell which of them is at fault (issue #227).
634
744
  */
635
745
  export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
746
+ const visits: ShapeVisit[] = [];
747
+ visiting = visits;
748
+ try {
749
+ return parseMotionSpecInto(raw, where, visits);
750
+ } finally {
751
+ visiting = null;
752
+ }
753
+ }
754
+
755
+ function parseMotionSpecInto(raw: unknown, where: string, visits: readonly ShapeVisit[]): MotionSpec {
636
756
  if (!isObj(raw)) {
637
757
  throw new CompileError(`${where}: a motion spec must be a JSON object, and this file holds ${describe(raw)}`);
638
758
  }
@@ -665,6 +785,20 @@ export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
665
785
  parseAnimation(anim, where, name);
666
786
  }
667
787
 
788
+ // After every field check above, so a field this file checks keeps its own
789
+ // sentence, and before the finite walk, so a value that is not a number is
790
+ // named as that rather than skipped (issue #890). What reaches this line is a
791
+ // field nothing above reads. Measured before the walk, every one of 303
792
+ // wrong-typed plants over 18 motion shapes was refused somewhere — but later,
793
+ // by the timeline compiler, and 7 of those sentences named the wrong fault:
794
+ // an event key's `"float": "1"` as *float 1 is not finite*, a `"volume": "1"`
795
+ // as an event with no audio, a yaw transform's `"depth": "true"` as a radius
796
+ // that is undefined.
797
+ refuseValuesOfTheWrongType(visits, MOTION_TYPES, where);
798
+ // Every `enum` row here names an owner, so this refuses nothing today; it is
799
+ // called so that a row given a set is refused from the day it is given one.
800
+ refuseValuesOutsideTheirSet(visits, MOTION_TYPES, MOTION_ENUMS, where);
801
+
668
802
  // Last, so every field check above keeps its own sentence for a number that
669
803
  // is not finite; what reaches this line is a finite double the float32 file
670
804
  // cannot carry — a key at 1e308 built green with a `null` in it before issue
package/src/pose.ts CHANGED
@@ -706,6 +706,15 @@ export interface PoseTraceLevel {
706
706
  out: PoseTraceCandidate[];
707
707
  /** This level's objective at `PoseTrace.probe`, when one was given. */
708
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;
709
718
  }
710
719
 
711
720
  /**
@@ -1089,6 +1098,22 @@ function residualAt(level: Level, plate: Plate, s: Samples, cand: Candidate, smo
1089
1098
  * A per-cell best rather than a global top-K, because the thing this instrument
1090
1099
  * must not lose is the SECOND place a part could sit — and a global top-K fills
1091
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).
1092
1117
  */
1093
1118
  interface CoarseField {
1094
1119
  residual: Float64Array;
@@ -1165,6 +1190,25 @@ function coarseScan(level: Level, s: Samples, scale: number, rotations: number[]
1165
1190
  return field;
1166
1191
  }
1167
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
+
1168
1212
  /**
1169
1213
  * The distinct places a part could sit, best first.
1170
1214
  *
@@ -2009,6 +2053,15 @@ function placePart(
2009
2053
  polished: polished.map(said),
2010
2054
  out: candidates.map(said),
2011
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,
2012
2065
  });
2013
2066
  }
2014
2067
  if (li > 0) {