spine-rigc 0.34.0 → 0.35.0

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/ingest.ts CHANGED
@@ -63,7 +63,8 @@ import {
63
63
  TOPLEVEL_CONSTRAINT_ARRAYS,
64
64
  } from './generation.ts';
65
65
  import { EVERY_GLOBAL_PHYSICS, MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
66
- import { parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
66
+ import { PARSER_DEFAULTS, parserOmits } from './keyorder.ts';
67
+ import { constraintAt, parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
67
68
  import type { MotionSpec } from './types.ts';
68
69
 
69
70
  /**
@@ -126,7 +127,7 @@ export class IngestSpecRefused extends Error {
126
127
  * are exactly two: a stage the caller supplied, and an animation's duration.
127
128
  * - `lossy` — the skeleton's spelling and rigc's differ, on purpose, and the
128
129
  * difference is named: a value rigc re-derives rather than takes (`lengths`,
129
- * the `spine` version), a field the spec has no home for (`hash`, `audio`), or
130
+ * the `spine` version), a field the spec has no home for (`hash`), or
130
131
  * a default the source left to the format and the rebuild writes out
131
132
  * (`HEADER_ORIGIN`, issue #622). The rebuilt file is a different file in that
132
133
  * field; it is not a different rig.
@@ -331,6 +332,93 @@ function inertPhysics(root: JsonObject): Map<string, string[]> {
331
332
  return out;
332
333
  }
333
334
 
335
+ /**
336
+ * The two kinds `A47` / `A48` ask the muted-at-rest question of. An alias rather
337
+ * than the union written at each use, because `IG25`'s scan resolves a composed
338
+ * finding code against the nearest `<name>: '…' | '…'` above it, and `type:` is
339
+ * the identifier `ATTACHMENT_<TYPE>` composes from further down.
340
+ */
341
+ type MixedConstraintKind = 'ik' | 'transform';
342
+
343
+ /**
344
+ * The ik and transform constraints this file rests muted and never switches on
345
+ * — the shape `A47` / `A48` refuse — with the mixes each one reads (issue #784).
346
+ *
347
+ * 🔑 **Read the way the gate reads it, from the raw JSON.** This module does not
348
+ * link the runtime, so the defaults are the parser's own, spelled here: an ik
349
+ * `mix` is 1 when absent, on the constraint and on every key; a transform mix
350
+ * is 1 when absent except `mixY`, which is the same object's `mixX`, and
351
+ * `mixScaleY`, which is its `mixScaleX` (`SkeletonJson.js`, every
352
+ * `getValue(…, 1)`). A transform reads only the mixes of the `to` properties it
353
+ * declares, and one that declares none is `A48`'s other sentence, which no
354
+ * declaration answers — so it is not a candidate. Live is `!== 0`, the
355
+ * runtime's test. A key lifts a mix when the value it states is live, or when
356
+ * its Bezier handles for that channel are — the curve the runtime interpolates
357
+ * through (`curveChannelValues` in `validate.ts`), which is how a 0 → 0 pair
358
+ * with raised handles moves the bones.
359
+ *
360
+ * ⚠️ A disagreement with the gate is loud either way, never silent: an entry on
361
+ * a constraint the gate reads as live is refused by name (declared but already
362
+ * switched on), and a muted one this misses is `A47` / `A48` refusing it.
363
+ */
364
+ function consumerDrivenCandidates(root: JsonObject): Array<{ type: MixedConstraintKind; name: string; reads: string[] }> {
365
+ const TO_MIX: Record<string, string> = {
366
+ rotate: 'mixRotate',
367
+ x: 'mixX',
368
+ y: 'mixY',
369
+ scaleX: 'mixScaleX',
370
+ scaleY: 'mixScaleY',
371
+ shearY: 'mixShearY',
372
+ };
373
+ /** Frame order of a transform timeline's six curve channels. */
374
+ const TRANSFORM_ORDER = ['mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY'];
375
+ const num = (value: unknown, fallback: number): number => (typeof value === 'number' ? value : fallback);
376
+ const transformMixes = (m: JsonObject): Record<string, number> => {
377
+ const mixX = num(m.mixX, 1);
378
+ const mixScaleX = num(m.mixScaleX, 1);
379
+ return {
380
+ mixRotate: num(m.mixRotate, 1),
381
+ mixX,
382
+ mixY: num(m.mixY, mixX),
383
+ mixScaleX,
384
+ mixScaleY: num(m.mixScaleY, mixScaleX),
385
+ mixShearY: num(m.mixShearY, 1),
386
+ };
387
+ };
388
+ /** The Bezier handle values of channel `c` on a key's `curve`; none when it is linear or stepped. */
389
+ const handles = (key: JsonObject, c: number): number[] =>
390
+ Array.isArray(key.curve) ? [key.curve[4 * c + 1], key.curve[4 * c + 3]].filter((v): v is number => typeof v === 'number') : [];
391
+ const keysOf = (kind: MixedConstraintKind, name: string): JsonObject[] =>
392
+ Object.values(obj(root.animations)).flatMap((animation) => arr(obj(obj(animation)[kind])[name]).map(obj));
393
+ const out: Array<{ type: MixedConstraintKind; name: string; reads: string[] }> = [];
394
+ for (const raw of arr(root.constraints)) {
395
+ const constraint = obj(raw);
396
+ if (typeof constraint.name !== 'string') continue;
397
+ if (constraint.type === 'ik') {
398
+ if (num(constraint.mix, 1) !== 0) continue;
399
+ const lifted = keysOf('ik', constraint.name).some((key) => num(key.mix, 1) !== 0 || handles(key, 0).some((v) => v !== 0));
400
+ if (!lifted) out.push({ type: 'ik', name: constraint.name, reads: ['mix'] });
401
+ } else if (constraint.type === 'transform') {
402
+ const reads = [
403
+ ...new Set(
404
+ Object.values(obj(constraint.properties)).flatMap((from) => Object.keys(obj(obj(from).to)).map((to) => TO_MIX[to])),
405
+ ),
406
+ ]
407
+ .filter((mix): mix is string => mix !== undefined)
408
+ .sort((a, b) => TRANSFORM_ORDER.indexOf(a) - TRANSFORM_ORDER.indexOf(b));
409
+ if (reads.length === 0) continue;
410
+ const setup = transformMixes(constraint);
411
+ if (reads.some((mix) => setup[mix] !== 0)) continue;
412
+ const lifted = keysOf('transform', constraint.name).some((key) => {
413
+ const values = transformMixes(key);
414
+ return reads.some((mix) => values[mix] !== 0 || handles(key, TRANSFORM_ORDER.indexOf(mix)).some((v) => v !== 0));
415
+ });
416
+ if (!lifted) out.push({ type: 'transform', name: constraint.name, reads });
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+
334
422
  /** What omitting one inert physics constraint took with it, for its finding. */
335
423
  interface PhysicsOmission {
336
424
  /** `animation "<a>" <timeline>`, in file order. */
@@ -811,11 +899,48 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
811
899
  );
812
900
  }
813
901
 
902
+ // -- constraints the consumer drives (issue #784) --------------------------
903
+ // A constraint resting muted that no animation switches on is exactly what
904
+ // `A47` / `A48` refuse, and the export cannot say whether it is a leftover or
905
+ // a dial a game turns from code: the two are the same bytes. The rebuild
906
+ // reads it as the consumer's — the reading under which the file is correct —
907
+ // and says so twice: in the rig spec, as the declaration the gate reads, and
908
+ // as a finding naming the constraint, so the author sees what the rebuild is
909
+ // claiming. It is a `judgement` for `DURATION`'s reason: a statement the
910
+ // skeleton does not carry, made and printed rather than hidden, with nothing
911
+ // lost — the rebuilt skeleton is the same bytes either way.
912
+ const carried = new Set(constraints.map((c) => constraintAt(String(c.type), String(c.name))));
913
+ const consumerDrivenMix: JsonObject[] = [];
914
+ const animationCount = Object.keys(obj(root.animations)).length;
915
+ for (const { type, name, reads } of consumerDrivenCandidates(root)) {
916
+ if (!carried.has(constraintAt(type, name))) continue;
917
+ const assertion = type === 'ik' ? 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT' : 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT';
918
+ consumerDrivenMix.push({
919
+ constraint: name,
920
+ type,
921
+ why:
922
+ 'the source skeleton rests it muted and no animation keys its mix above 0; `rigc ingest` reads it as a mix ' +
923
+ 'the consumer sets, which an export cannot state. Delete this entry if it is a leftover',
924
+ });
925
+ note(
926
+ 'judgement',
927
+ 'CONSUMER_DRIVEN_MIX',
928
+ `constraint "${name}" (${type})`,
929
+ `rests at ${reads.map((mix) => `${mix} 0`).join(', ')} and none of the ${animationCount} ` +
930
+ `animation${animationCount === 1 ? '' : 's'} keys its mix above 0, so nothing in this file ever switches it ` +
931
+ 'on — and the file cannot say whether that is a leftover or a mix a game sets from code. `build` refuses the ' +
932
+ `shape by name (${assertion}) unless the rig spec says which, so the rig spec now says the consumer drives ` +
933
+ 'it, in invariants.consumerDrivenMix, and the gate SKIPs it by name rather than measuring it. If it is a ' +
934
+ `leftover, delete the entry and rest ${type === 'ik' ? 'its mix' : 'a mix it reads'} above 0 or remove the ` +
935
+ 'constraint, and the gate measures it again',
936
+ );
937
+ }
938
+
814
939
  // -- assemble -------------------------------------------------------------
815
940
  const rig: JsonObject = {
816
941
  spec: RIG_SPEC_VERSION,
817
942
  name: opts.name,
818
- note: provenanceNote(opts, 'rig'),
943
+ note: provenanceNote(opts, 'rig', consumerDrivenMix.length > 0),
819
944
  };
820
945
  if (Object.keys(rigHeader).length) rig.skeleton = rigHeader;
821
946
  // Between `skeleton` and `bones`, which is where `RIG_KEYS.RigSpec` puts it —
@@ -826,10 +951,14 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
826
951
  if (Object.keys(skins).length) rig.skins = skins;
827
952
  if (constraints.length) rig.constraints = constraints;
828
953
  if (isObj(root.events)) rig.events = { ...root.events };
829
- // `invariants` is deliberately absent. A skeleton states no invariant, and
830
- // INGEST §2.1 already says what to do about that: leave the block out, because
831
- // an assertion with nothing to measure reports SKIP and never a pass. Writing
832
- // an invariant here would be certifying a rig nobody measured.
954
+ // `invariants` is absent but for one field. A skeleton states no invariant,
955
+ // and INGEST §2.1 already says what to do about that: leave the block out,
956
+ // because an assertion with nothing to measure reports SKIP and never a pass.
957
+ // Writing an invariant that turns a check ON here would be certifying a rig
958
+ // nobody measured. `consumerDrivenMix` is the other direction — it turns two
959
+ // checks OFF for the constraints named above, which certifies nothing: what it
960
+ // buys is a SKIP by name, and a finding says each one out loud (issue #784).
961
+ if (consumerDrivenMix.length) rig.invariants = { consumerDrivenMix };
833
962
 
834
963
  const motion: JsonObject = {
835
964
  spec: MOTION_SPEC_VERSION,
@@ -1529,20 +1658,25 @@ function ingestAnimation(
1529
1658
  /**
1530
1659
  * A value track of any of the four families. Inverts `compileValueTrack`.
1531
1660
  *
1532
- * ⚠️ `compileValueTrack` never omits a channel, so a rigc-built key states all
1533
- * of them and this reads them straight back. An EDITOR omits a channel that
1534
- * equals the parser's default, and the spec's `v` is positional — so an
1535
- * omission is filled at that channel's default, which is the value the runtime
1536
- * reads there. Reported once per track, because it is a restatement rather
1537
- * than a copy and a reader should know which.
1661
+ * ⚠️ An EDITOR omits a channel that equals the parser's default, and the
1662
+ * spec's `v` is positional — so an omission is filled at that channel's
1663
+ * default, which is the value the runtime reads there. The emitter leaves
1664
+ * the same channel out again wherever `PARSER_DEFAULTS` has a row for the
1665
+ * key's kind (issue #716), so the rebuild is the source's own text there and
1666
+ * nothing is said. What is reported, once per track, is a filled channel the
1667
+ * emitter WILL write — a kind with no measured row — because that one is a
1668
+ * restatement rather than a copy and a reader should know which.
1538
1669
  */
1539
1670
  const valueTrack = (target: JsonObject, property: string, keys: readonly unknown[], shape: TrackShape, where: string): void => {
1540
1671
  const fields = shape.map(([field]) => field);
1541
1672
  const out: JsonObject[] = [];
1542
1673
  let restated = 0;
1674
+ const family = Object.keys(target)[0];
1675
+ const row = PARSER_DEFAULTS[`${family} ${property} key`];
1543
1676
  for (const raw of keys) {
1544
1677
  const key = obj(raw);
1545
1678
  const entry: JsonObject = { t: timeOf(key) };
1679
+ const filled: string[] = [];
1546
1680
  // The zero-field branch: `reset` IS the event, so the key carries no value
1547
1681
  // and the spec spells that `null`.
1548
1682
  entry.v =
@@ -1550,13 +1684,21 @@ function ingestAnimation(
1550
1684
  ? null
1551
1685
  : shape.map(([field, dflt]) => {
1552
1686
  if (key[field] !== undefined) return key[field];
1553
- restated++;
1687
+ filled.push(field);
1554
1688
  // A string default names another field of THIS key (`mixY` ->
1555
1689
  // `mixX`); when that one is absent too the chain ends at 1, which
1556
1690
  // is what `ConstraintChannel.dflt` holds for both.
1557
1691
  if (typeof dflt !== 'string') return dflt;
1558
1692
  return key[dflt] === undefined ? 1 : key[dflt];
1559
1693
  });
1694
+ if (filled.length > 0) {
1695
+ // The key as the emitter will hold it — every channel stated — asked
1696
+ // the one question the emitter asks of it.
1697
+ const emitted: JsonObject = { ...key };
1698
+ shape.forEach(([field], i) => (emitted[field] = (entry.v as JsonObject[string][])[i]));
1699
+ const site = { object: emitted, previous: () => null };
1700
+ restated += filled.filter((field) => row === undefined || !parserOmits(row, site, field)).length;
1701
+ }
1560
1702
  easing(key, entry);
1561
1703
  for (const field of Object.keys(key)) {
1562
1704
  if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
@@ -1571,7 +1713,8 @@ function ingestAnimation(
1571
1713
  where,
1572
1714
  `${restated} channel value(s) the source omits are written out at the parser's default (${shape
1573
1715
  .map(([field, dflt]) => `${field}=${String(dflt)}`)
1574
- .join(', ')}), because the motion spec's \`v\` is positional — the same values the runtime reads`,
1716
+ .join(', ')}), because the motion spec's \`v\` is positional and the emitter has no measured row ` +
1717
+ `for "${family} ${property}" keys to leave them out by — the same values the runtime reads`,
1575
1718
  );
1576
1719
  }
1577
1720
  tracks.push({ ...target, property, keys: out });
@@ -1966,7 +2109,10 @@ function ingestAnimation(
1966
2109
  * not all name the same fields — *"state it on every key or on none"*. An
1967
2110
  * export does not obey that: it omits a field wherever it equals the default.
1968
2111
  * So a field ANY key states is written on EVERY key, at the value the parser
1969
- * would have read there. Identical semantics, larger file.
2112
+ * would have read there. Identical semantics — and, since issue #716, the
2113
+ * same file wherever `PARSER_DEFAULTS` has the key's row, because the
2114
+ * emitter leaves each such value out again. What is still reported is a
2115
+ * value the emitter writes back.
1970
2116
  * 2. 🚨 **`rigFlags`.** `compileConstraintTrack` stamps the rig constraint's
1971
2117
  * non-default `bendPositive`/`compress`/`stretch` onto a key that omits one
1972
2118
  * (issue #273). On rigc's own output that is self-consistent — rigc already
@@ -2004,19 +2150,16 @@ function constraintGroup(
2004
2150
  }
2005
2151
  }
2006
2152
  }
2007
- if (stated.size > 0 && stated.size < fields.length) {
2008
- note(
2009
- 'lossy',
2010
- 'CONSTRAINT_KEY_RESTATED',
2011
- where,
2012
- `${fields.length - stated.size} field(s) the source omits are restated at the parser's default on every key, ` +
2013
- 'because the motion spec requires one field set per track — the same values the runtime reads, spelled out',
2014
- );
2015
- }
2153
+ // A field filled on a key the source left it off is said only where the
2154
+ // emitter will write it: where `PARSER_DEFAULTS`' row for the key would
2155
+ // leave it out again, the rebuild is the source's own text (issue #716).
2156
+ const row = PARSER_DEFAULTS[`${group} key`];
2157
+ let restated = 0;
2016
2158
  const ks = keys.map((raw) => {
2017
2159
  const key = obj(raw);
2018
2160
  const entry: JsonObject = { t: typeof key.time === 'number' ? key.time : 0 };
2019
2161
  seeT(entry.t as number);
2162
+ const filled: string[] = [];
2020
2163
  for (const field of fields) {
2021
2164
  if (!stated.has(field)) continue;
2022
2165
  if (key[field] !== undefined) entry[field] = key[field];
@@ -2025,8 +2168,15 @@ function constraintGroup(
2025
2168
  // `mixY`'s default is the same key's own `mixX`, spelled as that field
2026
2169
  // name in the table above.
2027
2170
  entry[field] = typeof dflt === 'string' ? (key[dflt] !== undefined ? key[dflt] : defaults[dflt]) : dflt;
2171
+ filled.push(field);
2028
2172
  }
2029
2173
  }
2174
+ if (filled.length > 0) {
2175
+ const emitted: JsonObject = { ...key };
2176
+ for (const field of fields) if (entry[field] !== undefined) emitted[field] = entry[field];
2177
+ const site = { object: emitted, previous: () => null };
2178
+ restated += filled.filter((field) => row === undefined || !parserOmits(row, site, field)).length;
2179
+ }
2030
2180
  if (key.curve === 'stepped') entry.ease = 'stepped';
2031
2181
  else if (key.curve !== undefined) entry.curve = key.curve;
2032
2182
  for (const field of Object.keys(key)) {
@@ -2035,6 +2185,16 @@ function constraintGroup(
2035
2185
  }
2036
2186
  return entry;
2037
2187
  });
2188
+ if (restated > 0) {
2189
+ note(
2190
+ 'lossy',
2191
+ 'CONSTRAINT_KEY_RESTATED',
2192
+ where,
2193
+ `${restated} value(s) the source omits are restated at the parser's default, because the motion spec ` +
2194
+ 'requires one field set per track and the emitter writes them back — the same values the runtime reads, ' +
2195
+ 'spelled out',
2196
+ );
2197
+ }
2038
2198
  out.push({ constraint: name, keys: ks });
2039
2199
  }
2040
2200
  return out;
@@ -2052,11 +2212,21 @@ function constraintGroup(
2052
2212
  * independent compiles byte for byte, and a dated note in a spec would break the
2053
2213
  * first rebuild from it.
2054
2214
  */
2055
- function provenanceNote(opts: IngestOptions, which: 'rig' | 'motion'): string {
2215
+ function provenanceNote(opts: IngestOptions, which: 'rig' | 'motion', consumerDriven = false): string {
2056
2216
  const head =
2057
2217
  `DECOMPILED from ${opts.source} by \`rigc ingest\` ${opts.version}. Every number here was read out of that ` +
2058
2218
  'skeleton; nothing was authored, so this file says what the object IS and nothing about why.';
2059
2219
  if (which === 'rig') {
2220
+ // Only where the declaration was written, so every other decompiled rig spec
2221
+ // keeps its note byte for byte.
2222
+ if (consumerDriven) {
2223
+ return (
2224
+ `${head} \`invariants\` holds only \`consumerDrivenMix\`, the constraints this run read as mixes the ` +
2225
+ 'consumer sets (each is a CONSUMER_DRIVEN_MIX finding); it turns a refusal into a SKIP and certifies ' +
2226
+ 'nothing. A skeleton declares no other invariant, and an assertion with nothing to measure must SKIP ' +
2227
+ 'rather than pass.'
2228
+ );
2229
+ }
2060
2230
  return (
2061
2231
  `${head} \`invariants\` is deliberately absent — a skeleton declares none, and an assertion with nothing to ` +
2062
2232
  'measure must SKIP rather than pass.'