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/rig.ts CHANGED
@@ -60,7 +60,8 @@
60
60
  * with no manifest at all declares them here.
61
61
  */
62
62
  import { CompileError, NotImplementedError } from './errors.ts';
63
- import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys } from './keys.ts';
63
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
64
+ import type { ShapeVisit, SpecEnumTable, SpecValueType } from './keys.ts';
64
65
 
65
66
  export { CompileError, NotImplementedError };
66
67
 
@@ -1776,11 +1777,11 @@ function isObj(v: unknown): v is Record<string, unknown> {
1776
1777
  * Every key each shape of this format owns, keyed by the interface above that
1777
1778
  * declares it — the runtime shadow of the types, which TypeScript erases.
1778
1779
  *
1779
- * 🔒 **Hand-written and mechanically held to the interfaces.** `KEY01` in
1780
+ * 🔒 **Hand-written and mechanically held to the interfaces.** `CUR17` in
1780
1781
  * `selftest.ts` reads this file's own source, extracts each named interface's
1781
1782
  * field list and compares it to the entry here, so the pair cannot drift: adding
1782
1783
  * a field and forgetting this table is a red run, not a key an author cannot
1783
- * write. `KEY02` closes the other direction — a key declared here and occurring
1784
+ * write. `CUR18` closes the other direction — a key declared here and occurring
1784
1785
  * nowhere else in the tree is refused, which is exactly what `scaleYMode` was.
1785
1786
  *
1786
1787
  * ⚠️ `RigUnimplementedAttachment` is deliberately absent. It carries
@@ -1854,6 +1855,204 @@ export const RIG_KEYS = {
1854
1855
  RigSoftRegion: ['bone', 'mask'],
1855
1856
  } as const satisfies Record<string, readonly string[]>;
1856
1857
 
1858
+ /**
1859
+ * The type every key of `RIG_KEYS` holds, shape by shape — what
1860
+ * `refuseValuesOfTheWrongType` refuses a value against (issue #890).
1861
+ *
1862
+ * 🔒 **Beside the key table, and held equal to it twice.** `satisfies` over
1863
+ * `RigTypeTable` makes a key typed here and absent there, or admitted there and
1864
+ * untyped here, a type error; the selftest compares the two at runtime as well,
1865
+ * and derives each checked type from the interface the row is named for, so a
1866
+ * field declared `number` and typed `string` here is a red run rather than a
1867
+ * refusal of correct work.
1868
+ *
1869
+ * Where an unchecked `object` or `mixed` row is refused: a nested shape by the
1870
+ * reader that walks it; `RigSegmentsGenerator.bones` (`mixed`: a name, a chain
1871
+ * of names or a span) by the segments generator, by index. Where an `enum` row
1872
+ * is refused is not a list in prose any more: `RIG_ENUMS`, below, has one entry
1873
+ * per `enum` row — a set refused by `refuseValuesOutsideTheirSet`, or the
1874
+ * reader that refuses it — and `satisfies` makes a row without one a type error.
1875
+ *
1876
+ * ⚠️ Until issue #900 this comment carried that list, and ended by naming three
1877
+ * `enum` keys with **no** owner: `boneIndexing`, `from.rotation` and a
1878
+ * generator `kind`. The list was right about the three and wrong by omission
1879
+ * about a fourth — the cut manifest's `mesh.kind`, which `MANIFEST_TYPES` said
1880
+ * "the manifest mesh reader" held and which that reader read as a ribbon in one
1881
+ * place and a ring in another. A list of owners in a comment is a claim nothing
1882
+ * checks; the table is one a control reads.
1883
+ */
1884
+ type RigTypeTable = {
1885
+ readonly [S in keyof typeof RIG_KEYS]: { readonly [K in (typeof RIG_KEYS)[S][number]]: SpecValueType };
1886
+ };
1887
+
1888
+ export const RIG_TYPES = {
1889
+ RigSpec: {
1890
+ spec: 'enum', name: 'string', note: 'string', skeleton: 'object', images: 'string', bones: 'object[]', slots: 'object[]',
1891
+ skins: 'map of object', constraints: 'object[]', events: 'map of object', invariants: 'object',
1892
+ },
1893
+ RigSkeletonHeader: {
1894
+ x: 'number', y: 'number', width: 'number | null', height: 'number | null', fps: 'number', referenceScale: 'number',
1895
+ images: 'string', audio: 'string | null',
1896
+ },
1897
+ RigBone: {
1898
+ name: 'string', parent: 'string', length: 'number', x: 'number', y: 'number', rotation: 'number', scaleX: 'number',
1899
+ scaleY: 'number', shearX: 'number', shearY: 'number', inherit: 'enum', skin: 'boolean', color: 'string', icon: 'string',
1900
+ from: 'object',
1901
+ },
1902
+ RigBoneFrom: { anchor: 'string', slotWindow: 'string', meshCenter: 'string', rotation: 'enum' },
1903
+ RigSlot: { name: 'string', bone: 'string', attachment: 'string | null', color: 'string', dark: 'string', blend: 'enum' },
1904
+ RigEvent: { int: 'number', float: 'number', string: 'string', audio: 'string', volume: 'number', balance: 'number' },
1905
+ RigInvariants: {
1906
+ meshSlots: 'number', meshTriangles: 'number', axisBone: 'string', massBone: 'string', detached: 'object[]',
1907
+ deformMayFold: 'object[]', editorRoundTrip: 'boolean', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object',
1908
+ },
1909
+ RigIdleDrivesMeshes: { why: 'string' },
1910
+ RigDetachedRule: { bone: 'string', notUnder: 'string', why: 'string' },
1911
+ RigDeformFoldExemption: { slot: 'string', why: 'string' },
1912
+ RigConsumerDrivenMix: { constraint: 'string', type: 'enum', why: 'string' },
1913
+ RigSkinEntry: {
1914
+ attachments: 'map of object', bones: 'string[]', ik: 'string[]', transform: 'string[]', path: 'string[]',
1915
+ physics: 'string[]', slider: 'string[]',
1916
+ },
1917
+ RigIkConstraint: {
1918
+ name: 'string', skin: 'boolean', type: 'enum', bones: 'string[]', target: 'string', scaleY: 'enum', mix: 'number',
1919
+ softness: 'number', bendPositive: 'boolean', compress: 'boolean', stretch: 'boolean',
1920
+ },
1921
+ RigTransformConstraint: {
1922
+ name: 'string', skin: 'boolean', type: 'enum', bones: 'string[]', source: 'string', localSource: 'boolean',
1923
+ localTarget: 'boolean', additive: 'boolean', clamp: 'boolean', properties: 'map of object',
1924
+ rotation: 'number', x: 'number', y: 'number', scaleX: 'number', scaleY: 'number', shearY: 'number',
1925
+ mixRotate: 'number', mixX: 'number', mixY: 'number', mixScaleX: 'number', mixScaleY: 'number', mixShearY: 'number',
1926
+ },
1927
+ RigPathConstraint: {
1928
+ name: 'string', skin: 'boolean', type: 'enum', bones: 'string[]', slot: 'string', positionMode: 'enum',
1929
+ spacingMode: 'enum', rotateMode: 'enum', rotation: 'number', position: 'number', spacing: 'number',
1930
+ mixRotate: 'number', mixX: 'number', mixY: 'number',
1931
+ },
1932
+ RigPhysicsConstraint: {
1933
+ name: 'string', skin: 'boolean', type: 'enum', bone: 'string', x: 'number', y: 'number', rotate: 'number',
1934
+ scaleX: 'number', shearX: 'number', scaleY: 'enum', limit: 'number', fps: 'number', inertia: 'number',
1935
+ strength: 'number', damping: 'number', mass: 'number', wind: 'number', gravity: 'number', mix: 'number',
1936
+ inertiaGlobal: 'boolean', strengthGlobal: 'boolean', dampingGlobal: 'boolean', massGlobal: 'boolean',
1937
+ windGlobal: 'boolean', gravityGlobal: 'boolean', mixGlobal: 'boolean',
1938
+ },
1939
+ RigSliderConstraint: {
1940
+ name: 'string', skin: 'boolean', type: 'enum', animation: 'string', mix: 'number', additive: 'boolean',
1941
+ loop: 'boolean', bone: 'string', property: 'enum', from: 'number', to: 'number', scale: 'number', max: 'number',
1942
+ local: 'boolean', time: 'number',
1943
+ },
1944
+ RigTransformProperty: { offset: 'number', to: 'map of object' },
1945
+ RigTransformTo: { offset: 'number', max: 'number', scale: 'number' },
1946
+ RigRegionAttachment: {
1947
+ type: 'enum', name: 'string', path: 'string', image: 'string', x: 'number', y: 'number', rotation: 'number',
1948
+ scaleX: 'number', scaleY: 'number', width: 'number', height: 'number', color: 'string', sequence: 'object',
1949
+ },
1950
+ RigMeshAttachment: {
1951
+ type: 'enum', name: 'string', path: 'string', image: 'string', uvs: 'number[]', triangles: 'number[]',
1952
+ vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', hull: 'number', edges: 'number[]',
1953
+ width: 'number', height: 'number', color: 'string', generator: 'object', sequence: 'object',
1954
+ },
1955
+ RigLinkedMeshAttachment: {
1956
+ type: 'enum', name: 'string', path: 'string', image: 'string', source: 'string', slot: 'string', skin: 'string',
1957
+ timelines: 'boolean', width: 'number', height: 'number', color: 'string', sequence: 'object',
1958
+ uvs: 'number[]', triangles: 'number[]', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum',
1959
+ hull: 'number', edges: 'number[]', generator: 'object',
1960
+ },
1961
+ RigMeshBinding: { bone: 'string', x: 'number', y: 'number', weight: 'number' },
1962
+ RigSequence: { count: 'number', start: 'number', digits: 'number', setup: 'number' },
1963
+ RigBoundingBoxAttachment: {
1964
+ vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
1965
+ },
1966
+ RigClippingAttachment: {
1967
+ vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
1968
+ end: 'string', convex: 'boolean', inverse: 'boolean',
1969
+ },
1970
+ RigPathAttachment: {
1971
+ vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
1972
+ closed: 'boolean', constantSpeed: 'boolean', lengths: 'number[]',
1973
+ },
1974
+ RigRingGenerator: {
1975
+ kind: 'enum', hull: 'number[][]', center: 'number[]', inner: 'number', size: 'number[]', bias: 'object',
1976
+ controls: 'string[]',
1977
+ },
1978
+ RigRibbonGenerator: { kind: 'enum', size: 'number[]', rows: 'number', chain: 'string[]' },
1979
+ RigContourGenerator: {
1980
+ kind: 'enum', tolerance: 'number', margin: 'number', maxVertices: 'number', alpha: 'number', depth: 'object',
1981
+ soft: 'object',
1982
+ },
1983
+ RigGridGenerator: { kind: 'enum', us: 'number[]', vs: 'number[]', cols: 'number', rows: 'number', depth: 'object', soft: 'object' },
1984
+ RigSegmentsGenerator: { kind: 'enum', cell: 'number', bones: 'mixed', falloff: 'object', alpha: 'number', anchor: 'number[]' },
1985
+ RigSegmentsFalloff: { power: 'number', radius: 'number', maxBones: 'number', minWeight: 'number' },
1986
+ RigSegmentSpan: { bone: 'string', from: 'number[]', to: 'number[]' },
1987
+ RigMeshBias: { axis_deg: 'number', ramp: 'number[]' },
1988
+ RigDepthMap: { image: 'string', near: 'enum', zScale: 'number', gamma: 'number', contrast: 'number', bias: 'number' },
1989
+ RigSoftRegion: { bone: 'string', mask: 'string' },
1990
+ } as const satisfies RigTypeTable;
1991
+
1992
+ /** The two sources a bone's `from.rotation` names (`RigBoneFrom.rotation`). */
1993
+ export const RIG_FROM_ROTATIONS = ['axis', 'anchor'] as const satisfies ReadonlyArray<NonNullable<RigBoneFrom['rotation']>>;
1994
+
1995
+ /** The two ways a weighted `vertices` run names its bones (`RigMeshAttachment.boneIndexing`). */
1996
+ export const RIG_BONE_INDEXING = ['name', 'raw'] as const satisfies ReadonlyArray<NonNullable<RigMeshAttachment['boneIndexing']>>;
1997
+
1998
+ /** What `boneIndexing` outside its set was read as, measured before issue #900. */
1999
+ const BONE_INDEXING_READ_AS =
2000
+ 'anything else was read as the default "name" in silence: a weighted "vertices" run was refused as unflagged, ' +
2001
+ 'and every other attachment built byte for byte as if the key were absent';
2002
+
2003
+ /**
2004
+ * Who refuses each `enum` row of `RIG_TYPES` outside its set (issue #900) —
2005
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what the two kinds of entry
2006
+ * mean, and `satisfies` makes an `enum` row without one a type error.
2007
+ *
2008
+ * ⭐ A `set` is stated for exactly the rows nobody held: measured when this
2009
+ * table was written, `boneIndexing` built green at `5`, `"foo"` and `"named"`
2010
+ * (the spelling the issue itself used) as the default, and `from.rotation`
2011
+ * built green at `5` and `"foo"` with the bone's setup rotation dropped. Every
2012
+ * other row already had a reader that refused a planted `5` and `"foo"` by
2013
+ * name, and keeps it; the generator's `kind` is the one of those that is a
2014
+ * dispatch, and its refusal is new — it threw a `TypeError` (*undefined is not
2015
+ * an object (evaluating 'generator.size')*), because the scan skipped a `kind`
2016
+ * it had no row for and the mesh builder fell through to the ring branch.
2017
+ */
2018
+ export const RIG_ENUMS = {
2019
+ RigSpec: { spec: { owner: 'parseRigSpec' } },
2020
+ RigBone: { inherit: { owner: 'parseRigSpec' } },
2021
+ RigBoneFrom: {
2022
+ rotation: {
2023
+ set: RIG_FROM_ROTATIONS,
2024
+ readAs: 'anything else was read as no rotation source, and the bone was emitted without the setup rotation it asked for',
2025
+ },
2026
+ },
2027
+ RigSlot: { blend: { owner: 'parseRigSpec' } },
2028
+ RigConsumerDrivenMix: { type: { owner: 'parseRigSpec' } },
2029
+ RigIkConstraint: { type: { owner: 'buildRigConstraint' }, scaleY: { owner: 'buildRigConstraint' } },
2030
+ RigTransformConstraint: { type: { owner: 'buildRigConstraint' } },
2031
+ RigPathConstraint: {
2032
+ type: { owner: 'buildRigConstraint' },
2033
+ positionMode: { owner: 'buildRigConstraint' },
2034
+ spacingMode: { owner: 'buildRigConstraint' },
2035
+ rotateMode: { owner: 'buildRigConstraint' },
2036
+ },
2037
+ RigPhysicsConstraint: { type: { owner: 'buildRigConstraint' }, scaleY: { owner: 'buildRigConstraint' } },
2038
+ RigSliderConstraint: { type: { owner: 'buildRigConstraint' }, property: { owner: 'buildRigConstraint' } },
2039
+ RigRegionAttachment: { type: { owner: 'buildRigAttachment' } },
2040
+ RigMeshAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2041
+ RigLinkedMeshAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2042
+ RigBoundingBoxAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2043
+ RigClippingAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2044
+ RigPathAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2045
+ // The generator's `kind` chooses the row its other keys are checked against,
2046
+ // so a `kind` outside the five has no row to be visited in: the scan that
2047
+ // dispatches on it is where it is refused.
2048
+ RigRingGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2049
+ RigRibbonGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2050
+ RigContourGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2051
+ RigGridGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2052
+ RigSegmentsGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2053
+ RigDepthMap: { near: { owner: 'sampleMeshDepth' } },
2054
+ } as const satisfies SpecEnumTable<typeof RIG_TYPES>;
2055
+
1857
2056
  /**
1858
2057
  * The five constraint `type` names, and the shape each one's keys come from.
1859
2058
  *
@@ -1880,7 +2079,7 @@ const ATTACHMENT_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
1880
2079
  };
1881
2080
 
1882
2081
  /** The generator `kind` names, and the shape each one's keys come from. */
1883
- const GENERATOR_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
2082
+ const GENERATOR_SHAPE: Record<(typeof RIG_GENERATOR_KINDS)[number], keyof typeof RIG_KEYS> = {
1884
2083
  ring: 'RigRingGenerator',
1885
2084
  ribbon: 'RigRibbonGenerator',
1886
2085
  contour: 'RigContourGenerator',
@@ -1984,22 +2183,25 @@ function checkRigSequence(att: Record<string, unknown>, who: string, where: stri
1984
2183
  * refusal, and naming its keys would be a second opinion on a fault already
1985
2184
  * reported (see the note at the head of `parseMotionSpec`).
1986
2185
  */
1987
- function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
1988
- const at = (node: unknown, shape: keyof typeof RIG_KEYS, what: string): void => {
1989
- if (isObj(node)) refuseUnknownKeys(node, RIG_KEYS[shape], where, what);
2186
+ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{ node: Record<string, unknown>; shape: keyof typeof RIG_KEYS; path: Array<string | number> }> {
2187
+ const visits: Array<{ node: Record<string, unknown>; shape: keyof typeof RIG_KEYS; path: Array<string | number> }> = [];
2188
+ const at = (node: unknown, shape: keyof typeof RIG_KEYS, what: string, path: Array<string | number>): void => {
2189
+ if (!isObj(node)) return;
2190
+ refuseUnknownKeys(node, RIG_KEYS[shape], where, what);
2191
+ visits.push({ node, shape, path });
1990
2192
  };
1991
2193
 
1992
- at(raw, 'RigSpec', 'this rig spec');
1993
- at(raw.skeleton, 'RigSkeletonHeader', '"skeleton"');
2194
+ at(raw, 'RigSpec', 'this rig spec', []);
2195
+ at(raw.skeleton, 'RigSkeletonHeader', '"skeleton"', ['skeleton']);
1994
2196
 
1995
2197
  for (const [i, bone] of (Array.isArray(raw.bones) ? raw.bones : []).entries()) {
1996
2198
  const who = isObj(bone) && typeof bone.name === 'string' ? `bone "${bone.name}"` : `bones[${i}]`;
1997
- at(bone, 'RigBone', who);
1998
- if (isObj(bone)) at(bone.from, 'RigBoneFrom', `${who}'s "from"`);
2199
+ at(bone, 'RigBone', who, ['bones', i]);
2200
+ if (isObj(bone)) at(bone.from, 'RigBoneFrom', `${who}'s "from"`, ['bones', i, 'from']);
1999
2201
  }
2000
2202
 
2001
2203
  for (const [i, slot] of (Array.isArray(raw.slots) ? raw.slots : []).entries()) {
2002
- at(slot, 'RigSlot', isObj(slot) && typeof slot.name === 'string' ? `slot "${slot.name}"` : `slots[${i}]`);
2204
+ at(slot, 'RigSlot', isObj(slot) && typeof slot.name === 'string' ? `slot "${slot.name}"` : `slots[${i}]`, ['slots', i]);
2003
2205
  }
2004
2206
 
2005
2207
  for (const [i, constraint] of (Array.isArray(raw.constraints) ? raw.constraints : []).entries()) {
@@ -2010,32 +2212,32 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
2010
2212
  // that exist; there is no key set to check it against and no honest one to
2011
2213
  // guess, so it goes past here to the message that can say something.
2012
2214
  if (shape === undefined) continue;
2013
- at(constraint, shape, `${named} (${String(constraint.type)})`);
2215
+ at(constraint, shape, `${named} (${String(constraint.type)})`, ['constraints', i]);
2014
2216
  for (const [from, entry] of Object.entries(isObj(constraint.properties) ? constraint.properties : {})) {
2015
- at(entry, 'RigTransformProperty', `${named} properties."${from}"`);
2217
+ at(entry, 'RigTransformProperty', `${named} properties."${from}"`, ['constraints', i, 'properties', from]);
2016
2218
  if (!isObj(entry)) continue;
2017
2219
  for (const [to, driven] of Object.entries(isObj(entry.to) ? entry.to : {})) {
2018
- at(driven, 'RigTransformTo', `${named} properties."${from}".to."${to}"`);
2220
+ at(driven, 'RigTransformTo', `${named} properties."${from}".to."${to}"`, ['constraints', i, 'properties', from, 'to', to]);
2019
2221
  }
2020
2222
  }
2021
2223
  }
2022
2224
 
2023
2225
  for (const [name, event] of Object.entries(isObj(raw.events) ? raw.events : {})) {
2024
- at(event, 'RigEvent', `event "${name}"`);
2226
+ at(event, 'RigEvent', `event "${name}"`, ['events', name]);
2025
2227
  }
2026
2228
 
2027
2229
  if (isObj(raw.invariants)) {
2028
- at(raw.invariants, 'RigInvariants', '"invariants"');
2230
+ at(raw.invariants, 'RigInvariants', '"invariants"', ['invariants']);
2029
2231
  for (const [i, rule] of (Array.isArray(raw.invariants.detached) ? raw.invariants.detached : []).entries()) {
2030
- at(rule, 'RigDetachedRule', `invariants.detached[${i}]`);
2232
+ at(rule, 'RigDetachedRule', `invariants.detached[${i}]`, ['invariants', 'detached', i]);
2031
2233
  }
2032
2234
  for (const [i, rule] of (Array.isArray(raw.invariants.deformMayFold) ? raw.invariants.deformMayFold : []).entries()) {
2033
- at(rule, 'RigDeformFoldExemption', `invariants.deformMayFold[${i}]`);
2235
+ at(rule, 'RigDeformFoldExemption', `invariants.deformMayFold[${i}]`, ['invariants', 'deformMayFold', i]);
2034
2236
  }
2035
2237
  for (const [i, rule] of (Array.isArray(raw.invariants.consumerDrivenMix) ? raw.invariants.consumerDrivenMix : []).entries()) {
2036
- at(rule, 'RigConsumerDrivenMix', `invariants.consumerDrivenMix[${i}]`);
2238
+ at(rule, 'RigConsumerDrivenMix', `invariants.consumerDrivenMix[${i}]`, ['invariants', 'consumerDrivenMix', i]);
2037
2239
  }
2038
- at(raw.invariants.idleDrivesMeshes, 'RigIdleDrivesMeshes', 'invariants.idleDrivesMeshes');
2240
+ at(raw.invariants.idleDrivesMeshes, 'RigIdleDrivesMeshes', 'invariants.idleDrivesMeshes', ['invariants', 'idleDrivesMeshes']);
2039
2241
  }
2040
2242
 
2041
2243
  for (const [skinName, skin] of Object.entries(isObj(raw.skins) ? raw.skins : {})) {
@@ -2044,6 +2246,10 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
2044
2246
  // likeliest cause (a slot left outside `attachments`), so it is reused
2045
2247
  // rather than restated — one refusal per fault.
2046
2248
  const parts = splitRigSkin(skin as RigSkin, `${where}: skin "${skinName}"`);
2249
+ // The long form's own lists are typed too; `splitRigSkin` has just refused
2250
+ // a key outside them, so the row's keys are the node's keys.
2251
+ if (parts.explicit) visits.push({ node: skin, shape: 'RigSkinEntry', path: ['skins', skinName] });
2252
+ const base: Array<string | number> = parts.explicit ? ['skins', skinName, 'attachments'] : ['skins', skinName];
2047
2253
  for (const [slot, placeholders] of Object.entries(parts.attachments)) {
2048
2254
  if (!isObj(placeholders)) continue;
2049
2255
  for (const [placeholder, att] of Object.entries(placeholders)) {
@@ -2075,33 +2281,46 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
2075
2281
  'series would be dropped in silence. Remove it, or put it on a region or a mesh.',
2076
2282
  );
2077
2283
  }
2078
- at(att, shape, `${who} (${type})`);
2284
+ const here = [...base, slot, placeholder];
2285
+ at(att, shape, `${who} (${type})`, here);
2079
2286
  if (att.sequence !== undefined) {
2080
- at(att.sequence, 'RigSequence', `${who} "sequence"`);
2287
+ at(att.sequence, 'RigSequence', `${who} "sequence"`, [...here, 'sequence']);
2081
2288
  checkRigSequence(att, who, where);
2082
2289
  }
2083
2290
  for (const [i, vertex] of (Array.isArray(att.weights) ? att.weights : []).entries()) {
2084
2291
  for (const [j, binding] of (Array.isArray(vertex) ? vertex : []).entries()) {
2085
- at(binding, 'RigMeshBinding', `${who} weights[${i}][${j}]`);
2292
+ at(binding, 'RigMeshBinding', `${who} weights[${i}][${j}]`, [...here, 'weights', i, j]);
2086
2293
  }
2087
2294
  }
2088
2295
  if (!isObj(att.generator)) continue;
2089
2296
  const gen = att.generator;
2090
- const genShape = GENERATOR_SHAPE[String(gen.kind)];
2091
- // Same rule as an unknown attachment type: an unknown `kind` is the mesh
2092
- // builder's refusal, which can name the four that exist.
2093
- if (genShape === undefined) continue;
2094
- at(gen, genShape, `${who} generator (${String(gen.kind)})`);
2095
- at(gen.bias, 'RigMeshBias', `${who} generator.bias`);
2096
- at(gen.depth, 'RigDepthMap', `${who} generator.depth`);
2097
- at(gen.soft, 'RigSoftRegion', `${who} generator.soft`);
2098
- at(gen.falloff, 'RigSegmentsFalloff', `${who} generator.falloff`);
2297
+ const kind = RIG_GENERATOR_KINDS.find((k) => k === gen.kind);
2298
+ const genShape = kind === undefined ? undefined : GENERATOR_SHAPE[kind];
2299
+ // 🚨 An unknown `kind` used to be skipped here as "the mesh builder's
2300
+ // refusal", and the mesh builder had none: `buildGeneratedMesh` fell
2301
+ // through to the ring branch and threw a TypeError reading
2302
+ // `generator.size` (issue #900). `kind` chooses the row every other key
2303
+ // of the generator is checked against, so this dispatch is the one
2304
+ // place that can refuse it — before any of those keys is judged.
2305
+ if (genShape === undefined) {
2306
+ const stated = gen.kind === undefined ? 'absent' : (JSON.stringify(gen.kind) ?? String(gen.kind));
2307
+ throw new CompileError(
2308
+ `${where}: ${who} generator.kind is ${stated}; one of ${RIG_GENERATOR_KINDS.map((k) => JSON.stringify(k)).join(', ')} — ` +
2309
+ 'the kind names the builder, and each builder reads its own keys, so nothing about the mesh can be checked or built without one',
2310
+ );
2311
+ }
2312
+ at(gen, genShape, `${who} generator (${String(gen.kind)})`, [...here, 'generator']);
2313
+ at(gen.bias, 'RigMeshBias', `${who} generator.bias`, [...here, 'generator', 'bias']);
2314
+ at(gen.depth, 'RigDepthMap', `${who} generator.depth`, [...here, 'generator', 'depth']);
2315
+ at(gen.soft, 'RigSoftRegion', `${who} generator.soft`, [...here, 'generator', 'soft']);
2316
+ at(gen.falloff, 'RigSegmentsFalloff', `${who} generator.falloff`, [...here, 'generator', 'falloff']);
2099
2317
  for (const [i, entry] of (Array.isArray(gen.bones) ? gen.bones : []).entries()) {
2100
- at(entry, 'RigSegmentSpan', `${who} generator.bones[${i}]`);
2318
+ at(entry, 'RigSegmentSpan', `${who} generator.bones[${i}]`, [...here, 'generator', 'bones', i]);
2101
2319
  }
2102
2320
  }
2103
2321
  }
2104
2322
  }
2323
+ return visits;
2105
2324
  }
2106
2325
 
2107
2326
 
@@ -2176,11 +2395,26 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2176
2395
  // compiler does not read" for a format it has never read at all. The three
2177
2396
  // throws directly below are the required-key checks, and each of them is the
2178
2397
  // consequence a typo at the root produces.
2179
- checkRigSpecKeys(raw, where);
2398
+ const visits = checkRigSpecKeys(raw, where);
2399
+ const place = rigPlace(raw);
2400
+ // Every value the scan admitted, against the type its key holds, before any
2401
+ // reader — here or in `compile` — has done arithmetic on it (issue #890).
2402
+ // ⚠️ It runs ahead of this function's own field checks on purpose: those read
2403
+ // values too, and a reader that meets a wrong type names the wrong fault —
2404
+ // `constraint.skin === true` reads `"skin": "true"` as a constraint that asks
2405
+ // for no skin. The price, measured, is three controls whose type half pinned an
2406
+ // older sentence (RF86, PS177, PS189); their other halves are unchanged.
2407
+ const shapeVisits: ShapeVisit[] = visits.map((v) => ({ node: v.node, shape: v.shape, name: (tail) => place([...v.path, ...tail]) }));
2408
+ refuseValuesOfTheWrongType(shapeVisits, RIG_TYPES, where);
2409
+ // A name outside an `enum` row's stated set (issue #900), after the type walk
2410
+ // and over the same visits: `boneIndexing: "foo"` built as the default and
2411
+ // `from.rotation: "foo"` as no rotation, both green. A row whose entry names
2412
+ // an owner is that reader's to refuse, in its own words.
2413
+ refuseValuesOutsideTheirSet(shapeVisits, RIG_TYPES, RIG_ENUMS, where);
2180
2414
  // After the key check, so a misspelled key is named as a misspelling before
2181
2415
  // its value is judged, and before every reader below, so no range rule or
2182
2416
  // derived number ever meets a value the file cannot carry (issue #881).
2183
- refuseNumbersTheFileCannotCarry(raw, where, rigPlace(raw));
2417
+ refuseNumbersTheFileCannotCarry(raw, where, place);
2184
2418
 
2185
2419
  if (typeof raw.name !== 'string' || raw.name.length === 0) {
2186
2420
  throw new CompileError(`${where}: a rig spec needs a "name" — a motion spec names it to pick this rig`);
package/src/types.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import type { AtlasRegion } from './atlas.ts';
18
18
  import type { DeformTransform, DeformTransformReport } from './deformgen.ts';
19
19
  import type { TurnCeiling } from './depth.ts';
20
+ import type { SpecEnumTable, SpecTypeRow } from './keys.ts';
20
21
  import type { MeshKind } from './mesh.ts';
21
22
  import type { TrackDerive, TrackDeriveReport } from './trackgen.ts';
22
23
 
@@ -169,6 +170,75 @@ export interface FaceManifest {
169
170
  anchors?: Record<string, number[]>;
170
171
  }
171
172
 
173
+ /**
174
+ * The type every field of the cut manifest holds, shape by shape — what
175
+ * `refuseValuesOfTheWrongType` refuses a manifest value against (issue #890).
176
+ * The three interfaces above name three rows; the inline object types they
177
+ * declare (`crop`, `axis`, `stroke`, `roi`, `mesh.bias`) are rows named
178
+ * `<interface>.<field>`, and the selftest derives every row's keys and checked
179
+ * types from the declarations here.
180
+ *
181
+ * ⚠️ A manifest has no key scan, and this table does not add one: a manifest is
182
+ * often also the record of the pipeline that made the art and carries fields
183
+ * rigc does not read. A key outside a row is left alone, exactly as before; a
184
+ * key inside one must hold its type. Measured before the walk existed, 62 of 84
185
+ * wrong-typed plants on the three fixture manifests built green —
186
+ * `crop.h: "256"` among them — and 4 threw a TypeError from `node:path`.
187
+ *
188
+ * Unchecked by this walk: `mesh.kind` and `mesh.hull` (`enum`) — see
189
+ * `MANIFEST_ENUMS`, below; `crop` and the other nested shapes, `parts` and
190
+ * `mesh` (`object`) — the readers that walk them.
191
+ */
192
+ export const MANIFEST_TYPES = {
193
+ FaceManifest: {
194
+ schema: 'string', crop: 'object', base: 'string', state_machine: 'map of string[]', parts: 'object[]',
195
+ insertion: 'number[]', axis: 'object', stroke: 'object', roi: 'object', anchors: 'map of number[]',
196
+ },
197
+ 'FaceManifest.crop': { x: 'number', y: 'number', w: 'number', h: 'number', resample: 'string' },
198
+ 'FaceManifest.axis': { deg: 'number', unit: 'number[]' },
199
+ 'FaceManifest.stroke': {
200
+ amplitude: 'number', extension: 'number', contact_depth: 'number | null', cap_containment_ceiling: 'number | null',
201
+ },
202
+ 'FaceManifest.roi': { x: 'number', y: 'number', w: 'number', h: 'number' },
203
+ FaceManifestPart: {
204
+ slot: 'string', rig_slot: 'string', draw_order: 'number', image: 'string | null', offset: 'number[]', size: 'number[]',
205
+ state_key: 'string', states: 'map of string | null', polygon: 'number[][]', mesh: 'object',
206
+ },
207
+ FaceManifestMesh: {
208
+ kind: 'enum', hull: 'enum', center: 'number[]', inner: 'number', control_bone: 'string', control_bones: 'string[]',
209
+ rows: 'number', chain: 'string[]', bias: 'object',
210
+ },
211
+ 'FaceManifestMesh.bias': { axis_deg: 'number', ramp: 'number[]', note: 'string' },
212
+ } as const satisfies Record<string, SpecTypeRow>;
213
+
214
+ /** The two mesh generators a manifest part may name (`FaceManifestMesh.kind`); absent means `ring`. */
215
+ export const MANIFEST_MESH_KINDS = ['ring', 'ribbon'] as const satisfies ReadonlyArray<NonNullable<FaceManifestMesh['kind']>>;
216
+
217
+ /**
218
+ * Who refuses each `enum` row of `MANIFEST_TYPES` outside its set (issue #900);
219
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what an entry means.
220
+ *
221
+ * 🚨 `mesh.kind` was listed as held by "the manifest mesh reader", and it was
222
+ * not held at all: that reader has three places that ask the kind, and they
223
+ * disagreed about a value outside the two. The mesh checks read `"foo"` as a
224
+ * ribbon, while the control-bone reader and the geometry builder read it as a
225
+ * ring — so a ring part was refused as *a ribbon mesh needs mesh.rows and a
226
+ * mesh.chain*, and a ribbon part as *a mesh with no control bone deforms
227
+ * nothing*, both naming a fault the part did not have. `hull` is held: the ring
228
+ * check refuses anything but `"polygon"` by name, and keeps that sentence.
229
+ */
230
+ export const MANIFEST_ENUMS = {
231
+ FaceManifestMesh: {
232
+ kind: {
233
+ set: MANIFEST_MESH_KINDS,
234
+ readAs:
235
+ 'anything else was read as a ribbon by the mesh checks and as a ring by the control-bone reader and the ' +
236
+ 'geometry builder, so the refusal it got named a fault the part did not have',
237
+ },
238
+ hull: { owner: 'compileInto' },
239
+ },
240
+ } as const satisfies SpecEnumTable<typeof MANIFEST_TYPES>;
241
+
172
242
  // ---------------------------------------------------------------------------
173
243
  // Motion spec (spec: "rigc-motion/1")
174
244
  // ---------------------------------------------------------------------------