spine-rigc 0.29.0 → 0.30.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/compile.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { basename, dirname, join, relative, resolve } from 'node:path';
21
21
  import { existsSync, readFileSync } from 'node:fs';
22
- import { readPngInfo } from './png.ts';
22
+ import { readPngHeader, readPngInfo } from './png.ts';
23
23
  import {
24
24
  DEPTH_TONE_IDENTITY,
25
25
  DepthError,
@@ -95,7 +95,14 @@ import {
95
95
  type AtlasRegion,
96
96
  type ParsedAtlas,
97
97
  } from './atlas.ts';
98
- import { KEY_TIME_EPSILON, physicsKeyRefusal, physicsRuleFor, type PhysicsPoseRule } from './timelines.ts';
98
+ import {
99
+ KEY_TIME_EPSILON,
100
+ physicsKeyRefusal,
101
+ physicsRuleFor,
102
+ SLOT_COLOR_CHANNELS,
103
+ type PhysicsPoseRule,
104
+ type SlotColorChannel,
105
+ } from './timelines.ts';
99
106
  import { evaluateDeformTransform } from './deformgen.ts';
100
107
  import { evaluateTrackDerive, TRACK_DERIVE_PROJECTIONS, type TrackDeriveMember } from './trackgen.ts';
101
108
  import {
@@ -783,6 +790,60 @@ function rgba2Hex(v: number[]): { light: string; dark: string } {
783
790
  return { light: v.slice(0, 4).map(channelHex).join(''), dark: v.slice(4, 7).map(channelHex).join('') };
784
791
  }
785
792
 
793
+ /** The five slot colour timelines, which are every `SLOT_TRACKS` shape but `attachment`. */
794
+ type SlotColourShape = 'rgba' | 'rgb' | 'alpha' | 'rgba2' | 'rgb2';
795
+
796
+ /**
797
+ * How one colour key is written, per timeline: how many channels its `v`
798
+ * carries, how the refusal spells that `v`, and the JSON fields the emitted key
799
+ * states them as.
800
+ *
801
+ * ⭐ **Every row is the parser's own reading, and the `v` is always the
802
+ * channels in the order `readCurve` indexes a curve array by** — which is what
803
+ * keeps a key and its raw curve parallel on every shape:
804
+ *
805
+ * - `rgba` → `color: "rrggbbaa"`, four channels (`RGBATimeline`, `frames << 2`).
806
+ * - `rgb` → `color: "rrggbb"`, three. `SkeletonJson`'s `rgb` branch reads the
807
+ * same `Color.fromString(keyMap.color)` and hands `setFrame` r, g and b alone,
808
+ * so an alpha pair written there would be read into a colour and dropped one
809
+ * line later — six digits is emitting what is read.
810
+ * - `alpha` → `value: a`, one channel, read by `readTimeline1` with a per-key
811
+ * default of **0**. It is a number rather than hex because the format stores
812
+ * it as one, so it is not quantised onto a byte the way the four hex shapes
813
+ * are.
814
+ * - `rgba2` → `light: "rrggbbaa"`, `dark: "rrggbb"`, seven (see `rgba2Hex`).
815
+ * - `rgb2` → `light: "rrggbb"`, `dark: "rrggbb"`, six: `RGB2Timeline.setFrame`
816
+ * takes r g b and r2 g2 b2, and neither colour's alpha.
817
+ *
818
+ * The count is checked before anything is written, with one sentence for all
819
+ * five (`rgba value needs 4 channels, got 3`), because `rgbaHex` and `rgba2Hex`
820
+ * already said exactly that for their two and a family whose members refuse in
821
+ * different words is a family an author has to learn twice.
822
+ */
823
+ const COLOUR_KEYS: Record<SlotColourShape, { channels: number; spelling: string; write: (v: number[]) => Record<string, string | number> }> = {
824
+ rgba: { channels: 4, spelling: '[r,g,b,a]', write: (v) => ({ color: rgbaHex(v) }) },
825
+ rgb: { channels: 3, spelling: '[r,g,b]', write: (v) => ({ color: v.map(channelHex).join('') }) },
826
+ alpha: { channels: 1, spelling: '[a]', write: (v) => ({ value: r6(v[0]) }) },
827
+ rgba2: { channels: 7, spelling: '[lr,lg,lb,la,dr,dg,db]', write: (v) => rgba2Hex(v) },
828
+ rgb2: {
829
+ channels: 6,
830
+ spelling: '[lr,lg,lb,dr,dg,db]',
831
+ write: (v) => ({ light: v.slice(0, 3).map(channelHex).join(''), dark: v.slice(3, 6).map(channelHex).join('') }),
832
+ },
833
+ };
834
+
835
+ /** The colour of one key, as its timeline writes it — after the channel count the whole family refuses in one sentence. */
836
+ function colourKey(shape: SlotColourShape, v: number[]): Record<string, string | number> {
837
+ const { channels, write } = COLOUR_KEYS[shape];
838
+ if (v.length !== channels) {
839
+ throw new CompileError(`${shape} value needs ${channels} channel${channels === 1 ? '' : 's'}, got ${v.length}`);
840
+ }
841
+ return write(v);
842
+ }
843
+
844
+ /** A slot colour channel as a sentence names it. */
845
+ const CHANNEL_WORDS: Record<SlotColorChannel, string> = { rgb: 'light rgb', alpha: 'alpha', dark: 'dark colour' };
846
+
786
847
  /**
787
848
  * One timeline a `MotionValueTrack` can name: the JSON fields a key carries, the
788
849
  * per-key default the parser uses for each, and — where the runtime has one — the
@@ -940,8 +1001,18 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
940
1001
  * ⚠️ The KEY is the timeline name as the file carries it, and the VALUE names
941
1002
  * the branch below that writes its keys — so an entry added here without a
942
1003
  * branch to write it is an entry emitted in some other timeline's shape, which
943
- * is the defect this table closed rather than a new affordance. The format has
944
- * three more (`rgb`, `alpha`, `rgb2`) and rigc emits none of them.
1004
+ * is the defect this table closed rather than a new affordance.
1005
+ *
1006
+ * 🎨 `rgb`, `alpha` and `rgb2` joined in issue #730, which makes this the
1007
+ * format's whole slot switch (`SkeletonJson.readAnimation`'s six cases, in its
1008
+ * order). They are the SEPARABLE colour timelines — each poses a subset of the
1009
+ * slot's channels and leaves the rest where they were (`SLOT_COLOR_CHANNELS`)
1010
+ * — and they are not spellings of `rgba`. Folding an `rgb` and an `alpha` with
1011
+ * their own key times into one `rgba` would have to state each channel at the
1012
+ * other's key times, which is a value nobody keyed; and a lone `alpha` has no
1013
+ * `rgba` spelling at all, because an `rgba` key poses the light rgb too. So
1014
+ * each is emitted as itself, and two tracks of one slot that pose a shared
1015
+ * channel are refused rather than layered (see the claim check in `compile`).
945
1016
  *
946
1017
  * 🎨 `rgba2` joined in issue #690, and what it adds is the half of the two-colour
947
1018
  * tint that moves: a slot's `dark` has been an emitted setup field all along
@@ -952,10 +1023,13 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
952
1023
  * never applies it; `--profile spine-html` is where it fires. Emitting a
953
1024
  * construct one consumer drops is exactly what a profile is for.
954
1025
  */
955
- export const SLOT_TRACKS: Record<string, 'attachment' | 'rgba' | 'rgba2'> = {
1026
+ export const SLOT_TRACKS: Record<string, 'attachment' | SlotColourShape> = {
956
1027
  attachment: 'attachment',
957
1028
  rgba: 'rgba',
1029
+ rgb: 'rgb',
1030
+ alpha: 'alpha',
958
1031
  rgba2: 'rgba2',
1032
+ rgb2: 'rgb2',
959
1033
  };
960
1034
 
961
1035
  /**
@@ -1068,8 +1142,14 @@ const CONSTRAINT_TIMELINES: Record<'ik' | 'transform', ConstraintTimelineShape>
1068
1142
  },
1069
1143
  };
1070
1144
 
1071
- /** Physics constraint fields and their parser defaults (SkeletonJson.js:295-319). */
1072
- const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
1145
+ /**
1146
+ * Physics constraint fields and their parser defaults (SkeletonJson.js:295-319).
1147
+ *
1148
+ * `PHYSICS_COMPONENTS` is exported for `ingest.ts`, which omits a constraint that
1149
+ * drives none of them (issue #731) and must read "drives" off the same five
1150
+ * names this module refuses an empty constraint with, not off a copy.
1151
+ */
1152
+ export const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
1073
1153
  const PHYSICS_PARAMS: Array<[string, number]> = [
1074
1154
  ['inertia', 0.5],
1075
1155
  ['strength', 100],
@@ -1404,7 +1484,12 @@ function fromLoosePng(
1404
1484
  // the rig. The validator's messages are the UI, and so are these.
1405
1485
  throw new CompileError(`image "${relPath}" is not on disk at ${absPath}`);
1406
1486
  }
1407
- const info = readPngInfo(absPath);
1487
+ // A loose part IS a page of the atlas this route emits, so it is refused the
1488
+ // way a pack's page is (issue #732): by the one reader's sentence, as a
1489
+ // CompileError, rather than as that reader's throw with a stack under it.
1490
+ const header = readPngHeader(absPath);
1491
+ if (header.problem !== null) throw new CompileError(`image "${relPath}": ${header.problem}`);
1492
+ const info = header.info;
1408
1493
  // Page name is the PNG path *relative to the atlas file*, so the viewer
1409
1494
  // resolves it the way every Spine consumer does: against the atlas URL.
1410
1495
  // The PNGs are not copied: they pass through untouched, and the atlas points
@@ -1436,6 +1521,25 @@ function readAtlasIn(path: string): AtlasSource {
1436
1521
  }
1437
1522
  }
1438
1523
  if (byName.size === 0) throw new CompileError(`--atlas-in ${path} declares no regions`);
1524
+ // 🔒 **What each page IS, where the pack is opened** (issue #732). Every page
1525
+ // the pack names reaches the gate — the atlas this build emits is the pack's
1526
+ // own text — so a page no reader can open is refused here, all of them in
1527
+ // one sentence, rather than one region at a time or at `A06` after the whole
1528
+ // compile. A page that is not on disk is left to the region that sits on it,
1529
+ // which already names it; saying so twice would be two refusals of one fact.
1530
+ const unread: string[] = [];
1531
+ for (const page of parsed.pages) {
1532
+ const abs = resolve(dirname(path), page.name);
1533
+ if (!existsSync(abs)) continue;
1534
+ const { problem } = readPngHeader(abs);
1535
+ if (problem !== null) unread.push(`page "${page.name}": ${problem}`);
1536
+ }
1537
+ if (unread.length > 0) {
1538
+ throw new CompileError(
1539
+ `--atlas-in ${path}: ${unread.length} of its ${parsed.pages.length} page(s) cannot be read as PNG, and ` +
1540
+ `nothing was compiled against the pack — ${unread.join('; ')}`,
1541
+ );
1542
+ }
1439
1543
  return { path, dir: dirname(path), parsed, byName };
1440
1544
  }
1441
1545
 
@@ -2504,6 +2608,30 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2504
2608
  );
2505
2609
  }
2506
2610
  claimed.add(claim);
2611
+ // Two colour timelines of one slot that pose a shared channel are not
2612
+ // two layers of it: each poses its channels at every time, the setup
2613
+ // value before its first key included, so the one the file states
2614
+ // later overwrites the other everywhere (`SLOT_COLOR_CHANNELS`). The
2615
+ // claim above cannot see it — `rgba` and `alpha` are two properties —
2616
+ // and before issue #730 nothing could reach it, because `rgba` and
2617
+ // `rgba2` were the only two and a slot keying both was already a
2618
+ // slot keying its light colour twice. Raised before any key is shaped:
2619
+ // the fault is the pair, not a key.
2620
+ if (family === null && !isBoneTrack) {
2621
+ const mine = SLOT_COLOR_CHANNELS[track.property] ?? [];
2622
+ for (const other of Object.keys(slotTimelines[target] ?? {})) {
2623
+ const shared = (SLOT_COLOR_CHANNELS[other] ?? []).filter((channel) => mine.includes(channel));
2624
+ if (shared.length === 0) continue;
2625
+ throw new CompileError(
2626
+ `animation "${animName}" slot "${target}": tracks "${other}" and "${track.property}" both key the slot's ` +
2627
+ `${shared.map((channel) => CHANNEL_WORDS[channel]).join(' and ')} — a colour timeline poses its ` +
2628
+ 'channels at every time, its setup value before its first key included, so the one the file states ' +
2629
+ 'later overwrites the other everywhere and the first one\'s keys there are read by nothing. Key each ' +
2630
+ 'channel once: "rgb" and "alpha" move the light colour\'s two halves on their own key times, "rgba" ' +
2631
+ 'moves them together, and "rgb2" / "rgba2" add the dark colour',
2632
+ );
2633
+ }
2634
+ }
2507
2635
 
2508
2636
  const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
2509
2637
  const keys =
@@ -7252,12 +7380,18 @@ function compileTrack(
7252
7380
  // …')` inside `RGBA2Timeline.apply1`. That is a crash in the consumer's
7253
7381
  // process, from a file every parser accepts — the exact silence this compiler
7254
7382
  // exists to convert into a name (issue #690).
7255
- if (shape === 'rgba2' && !darkSlots.has(target)) {
7383
+ //
7384
+ // `rgb2` is the same pairing and was measured the same way before it joined
7385
+ // this refusal (issue #730): the forged file loads, `setupPose.darkColor` is
7386
+ // `null`, and `state.apply` throws `TypeError: null is not an object
7387
+ // (evaluating 'dark.r = …')` inside `RGB2Timeline.apply1`. The way out it
7388
+ // names is its own light-only sibling, which for `rgb2` is `rgb`.
7389
+ if ((shape === 'rgba2' || shape === 'rgb2') && !darkSlots.has(target)) {
7256
7390
  throw new CompileError(
7257
- `${where}: slot "${target}" declares no setup "dark", and an "rgba2" timeline poses a slot's dark colour — ` +
7391
+ `${where}: slot "${target}" declares no setup "dark", and an "${shape}" timeline poses a slot's dark colour — ` +
7258
7392
  'the runtime allocates one only for a slot whose setup pose has it, so applying this animation throws ' +
7259
7393
  `instead of tinting. Give slot "${target}" a \`dark\` in the rig spec (the colour it holds at rest), or ` +
7260
- 'key "rgba" if only the light colour moves',
7394
+ `key "${shape === 'rgba2' ? 'rgba' : 'rgb'}" if only the light colour moves`,
7261
7395
  );
7262
7396
  }
7263
7397
  if (!track.keys.length) throw new CompileError(`${where}: no keys`);
@@ -7298,21 +7432,33 @@ function compileTrack(
7298
7432
  continue;
7299
7433
  }
7300
7434
 
7301
- // rgba / rgba2 — the two colour shapes `SLOT_TRACKS` names, and between them
7302
- // the only ways left to reach here: `attachment` returned above and a
7303
- // property the table does not carry was refused before any key was shaped.
7435
+ // The five colour shapes `SLOT_TRACKS` names, and between them the only ways
7436
+ // left to reach here: `attachment` returned above and a property the table
7437
+ // does not carry was refused before any key was shaped.
7304
7438
  //
7305
- // ⚠️ The two differ in three places and nowhere else, so they are read out of
7306
- // `shape` rather than forked into two loops: how many channels a `v` carries,
7307
- // which FIELDS the emitted key spells them as (`color`, or `light` + `dark`),
7308
- // and therefore how long a raw curve array is. Everything else — the
7309
- // ease/curve exclusivity, the stepped case, the hold test, the last-key
7310
- // refusals — is one rule, and a second copy of it is a second thing to keep
7311
- // in step.
7312
- const spelling = shape === 'rgba2' ? '[lr,lg,lb,la,dr,dg,db]' : '[r,g,b,a]';
7313
- const channels = shape === 'rgba2' ? 7 : 4;
7314
- const colourOf = (v: number[]): Record<string, string> => (shape === 'rgba2' ? rgba2Hex(v) : { color: rgbaHex(v) });
7439
+ // ⚠️ The five differ in three places and nowhere else, so they are read out
7440
+ // of `COLOUR_KEYS` rather than forked into five loops: how many channels a
7441
+ // `v` carries, which FIELDS the emitted key spells them as (`color`,
7442
+ // `value`, or `light` + `dark`), and therefore how long a raw curve array
7443
+ // is. Everything else — the ease/curve exclusivity, the stepped case, the
7444
+ // hold test, the last-key refusals — is one rule, and a second copy of it is
7445
+ // a second thing to keep in step.
7446
+ const { spelling, channels } = COLOUR_KEYS[shape];
7447
+ const colourOf = (v: number[]): Record<string, string | number> => colourKey(shape, v);
7315
7448
  if (!Array.isArray(key.v)) throw new CompileError(`${where}: ${shape} key value must be ${spelling}`);
7449
+ if (shape === 'alpha' && key.v.length === 1 && !(Number.isFinite(key.v[0]) && key.v[0] >= 0 && key.v[0] <= 1)) {
7450
+ // The one colour shape written as a number rather than a byte, so the one
7451
+ // that is not clamped onto 0..1 by `channelHex` on the way out. The
7452
+ // runtime clamps the POSED alpha (`AlphaTimeline.apply`: `color.a = a < 0
7453
+ // ? 0 : a > 1 ? 1 : a`) but interpolates the stored value first, so a key
7454
+ // of 1.5 reaches 1 at a different time from a key of 1 — a curve nobody
7455
+ // keyed, from a value no pose ever holds. Refused rather than clamped:
7456
+ // clamping is choosing the value.
7457
+ throw new CompileError(
7458
+ `${where}: key at t=${key.t} is ${String(key.v[0])}; an alpha is a number from 0 to 1 — the runtime clamps ` +
7459
+ 'the posed alpha to that range after interpolating, so a value outside it is one no pose ever holds',
7460
+ );
7461
+ }
7316
7462
  const entry: SpineTimelineKey = { time, ...colourOf(key.v) };
7317
7463
  if (key.ease !== undefined && key.curve !== undefined) {
7318
7464
  throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
@@ -7334,8 +7480,9 @@ function compileTrack(
7334
7480
  }
7335
7481
  const t2 = keyTime(next.t + shift);
7336
7482
  // 4 numbers per channel, in the format's own channel order — 16 for
7337
- // `rgba` (r g b a), 28 for `rgba2` (light r g b a, then dark r g b, which
7338
- // is the order `readCurve` indexes them in). Short arrays become NaN
7483
+ // `rgba` (r g b a), 12 for `rgb`, 4 for `alpha`, 28 for `rgba2` (light r
7484
+ // g b a, then dark r g b, which is the order `readCurve` indexes them in)
7485
+ // and 24 for `rgb2`. Short arrays become NaN
7339
7486
  // curves with no error. The hold test reads the emitted hex rather than
7340
7487
  // the authored floats: two colours that quantise to one byte are one
7341
7488
  // colour in the file, and for `rgba2` that has to be true of BOTH — a key
package/src/ingest.ts CHANGED
@@ -44,7 +44,7 @@
44
44
  * break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
45
45
  * spec.
46
46
  */
47
- import { SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
47
+ import { PHYSICS_COMPONENTS, SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
48
48
  import { CompileError } from './errors.ts';
49
49
  import { CHANNELS_BY_KIND } from './timelines.ts';
50
50
  import {
@@ -292,6 +292,47 @@ const CONSTRAINT_FIELDS: Record<string, string[]> = {
292
292
  slider: carried('RigSliderConstraint', 'name', 'type'),
293
293
  };
294
294
 
295
+ /**
296
+ * The physics constraints that drive nothing, by name, each with the component
297
+ * fields it DOES state — every one of them at most 0 (issue #731).
298
+ *
299
+ * 🔑 The runtime's own predicate, not a reading of it: `PhysicsConstraint.update`
300
+ * decides what it applies with `this.data.x > 0` and its four siblings
301
+ * (`PhysicsConstraint.js:112`) and nothing else, so a constraint with none of
302
+ * `PHYSICS_COMPONENTS` above 0 moves no bone. `build` refuses exactly that shape
303
+ * by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the gate — so carrying one
304
+ * through made the decompiled spec of a file an editor exports unbuildable as a
305
+ * whole, over a constraint that did nothing in it.
306
+ *
307
+ * ⚠️ A component that is present and NOT a number is not inert: the parser takes
308
+ * it as written and `"0.5" > 0` is true in the runtime's comparison, so such a
309
+ * constraint is carried and rigc's own parser says what is wrong with it.
310
+ */
311
+ function inertPhysics(root: JsonObject): Map<string, string[]> {
312
+ const out = new Map<string, string[]>();
313
+ for (const raw of arr(root.constraints)) {
314
+ const constraint = obj(raw);
315
+ if (constraint.type !== 'physics' || typeof constraint.name !== 'string') continue;
316
+ const stated = PHYSICS_COMPONENTS.filter((field) => constraint[field] !== undefined);
317
+ if (!stated.every((field) => typeof constraint[field] === 'number' && !((constraint[field] as number) > 0))) continue;
318
+ out.set(
319
+ constraint.name,
320
+ stated.map((field) => `${field} ${String(constraint[field])}`),
321
+ );
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /** What omitting one inert physics constraint took with it, for its finding. */
327
+ interface PhysicsOmission {
328
+ /** `animation "<a>" <timeline>`, in file order. */
329
+ timelines: string[];
330
+ /** The skins whose `physics` member list named it. */
331
+ skins: string[];
332
+ /** Per animation, the latest key time on an omitted timeline of this constraint. */
333
+ lastKeys: Map<string, number>;
334
+ }
335
+
295
336
  /**
296
337
  * The per-skin member lists (`SkeletonJson` reads them before `attachments`).
297
338
  *
@@ -446,8 +487,11 @@ const SLOT_TRACKS = Object.keys(EMITTED_SLOT_TRACKS);
446
487
 
447
488
  /**
448
489
  * The slot timelines the FORMAT has and the motion spec has no track for —
449
- * `rgb`, `alpha` and `rgb2` as this is written, and whatever is left the next
450
- * time the spec grows one.
490
+ * **none** since issue #730 spelled `rgb`, `alpha` and `rgb2`, which were the
491
+ * last three. It stays derived rather than deleted, because an empty
492
+ * difference of two tables is a measurement and a deleted one is a claim: the
493
+ * day the format grows a seventh slot timeline, this is where it appears, and
494
+ * the blocker's sentence names it without an edit.
451
495
  *
452
496
  * ⭐ Both sides are derived, and that is the whole reason it exists rather than
453
497
  * being spelled into the blocker's sentence. `docs/INGEST.md`'s row for this
@@ -588,6 +632,19 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
588
632
  // declares no stage; when it does, this is the one place that changes.
589
633
  const rigHeader = ingestHeader(obj(root.skeleton), opts, note);
590
634
 
635
+ // -- physics constraints that drive nothing (issue #731) ------------------
636
+ // Read before the skins, because a skin's `physics` member list is one of the
637
+ // three places such a constraint is named and each has to let go of it.
638
+ const inert = inertPhysics(root);
639
+ const omissions = new Map<string, PhysicsOmission>();
640
+ const omission = (name: string): PhysicsOmission => {
641
+ const found = omissions.get(name);
642
+ if (found !== undefined) return found;
643
+ const made: PhysicsOmission = { timelines: [], skins: [], lastKeys: new Map() };
644
+ omissions.set(name, made);
645
+ return made;
646
+ };
647
+
591
648
  // -- bones ----------------------------------------------------------------
592
649
  // Inverts `buildBone`, which copies every declared field and omits the rest.
593
650
  const bones = arr(root.bones).map((raw) => {
@@ -645,7 +702,17 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
645
702
  let anyList = false;
646
703
  for (const list of SKIN_LISTS) {
647
704
  if (entry[list] !== undefined) {
648
- lists[list] = entry[list];
705
+ let members = entry[list];
706
+ if (list === 'physics' && Array.isArray(members)) {
707
+ const named = members.filter((member): member is string => typeof member === 'string' && inert.has(member));
708
+ for (const member of named) omission(member).skins.push(nameOf(entry));
709
+ members = members.filter((member) => !(typeof member === 'string' && inert.has(member)));
710
+ // A list that named nothing BUT omitted constraints goes with them: an
711
+ // empty list and an absent one read the same, and only one of them is
712
+ // what the rebuild writes for a skin with no physics member.
713
+ if (named.length > 0 && (members as unknown[]).length === 0) continue;
714
+ }
715
+ lists[list] = members;
649
716
  anyList = true;
650
717
  }
651
718
  }
@@ -669,6 +736,9 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
669
736
  note('blocker', 'CONSTRAINT_TYPE', who, `type ${JSON.stringify(constraint.type)} is not one of ${Object.keys(CONSTRAINT_FIELDS).join(', ')}`);
670
737
  continue;
671
738
  }
739
+ // Omitted rather than carried, and said below once the timelines it takes
740
+ // with it are known (`PHYSICS_DRIVES_NOTHING`).
741
+ if (type === 'physics' && typeof constraint.name === 'string' && inert.has(constraint.name)) continue;
672
742
  const out: JsonObject = { name: constraint.name, type };
673
743
  for (const field of fields) if (constraint[field] !== undefined) out[field] = constraint[field];
674
744
  for (const key of Object.keys(constraint)) {
@@ -681,7 +751,43 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
681
751
  // -- animations -----------------------------------------------------------
682
752
  const animations: JsonObject = {};
683
753
  for (const [animName, raw] of objEntries(root.animations)) {
684
- animations[animName] = ingestAnimation(animName, raw, root, note);
754
+ animations[animName] = ingestAnimation(animName, raw, root, note, inert, (name, property, lastKey) => {
755
+ const one = omission(name);
756
+ one.timelines.push(`animation "${animName}" ${property}`);
757
+ one.lastKeys.set(animName, Math.max(one.lastKeys.get(animName) ?? 0, lastKey));
758
+ });
759
+ }
760
+
761
+ // One finding per inert constraint, in the file's own order, naming what went
762
+ // with it. ⚠️ An animation's duration is the largest key time it has LEFT, so
763
+ // an omitted timeline that held the last key shortens the rebuilt animation —
764
+ // the one place this omission is not a no-op, and the detail says so there.
765
+ for (const [name, stated] of inert) {
766
+ const gone = omission(name);
767
+ const shortened = [...gone.lastKeys]
768
+ .map(([animName, lastKey]) => [animName, lastKey, Number(obj(animations[animName]).duration)] as const)
769
+ .filter(([, lastKey, duration]) => lastKey > duration)
770
+ .map(
771
+ ([animName, lastKey, duration]) =>
772
+ `animation "${animName}" had its last key at ${lastKey}s on one of them, so the rebuilt animation ends at ${duration}s`,
773
+ );
774
+ note(
775
+ 'lossy',
776
+ 'PHYSICS_DRIVES_NOTHING',
777
+ `constraint "${name}" (physics)`,
778
+ `drives no component: ${PHYSICS_COMPONENTS.join(', ')} are ` +
779
+ (stated.length ? `absent or at most 0 (it states ${stated.join(', ')})` : 'all absent') +
780
+ ', and `PhysicsConstraint.update` applies one only above 0, so it moves no bone and `build` would refuse it ' +
781
+ 'by name (A23_PHYSICS_CONSTRAINT_EFFECTIVE). The rig spec omits it' +
782
+ (gone.timelines.length
783
+ ? `, and with it the ${gone.timelines.length} timeline(s) keyed to it, which would name a constraint the ` +
784
+ `rebuild does not have: ${gone.timelines.join('; ')}`
785
+ : '; no timeline keys it') +
786
+ (gone.skins.length ? `; it is taken off the physics list of skin(s) ${gone.skins.map((skin) => `"${skin}"`).join(', ')}` : '') +
787
+ (shortened.length
788
+ ? `. One thing does move, because a duration is the last key an animation has left: ${shortened.join('; ')}`
789
+ : '. The rebuild differs from the source by exactly these no-ops'),
790
+ );
685
791
  }
686
792
 
687
793
  // -- assemble -------------------------------------------------------------
@@ -1247,7 +1353,14 @@ function ingestAttachment(
1247
1353
  }
1248
1354
 
1249
1355
  /** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
1250
- function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, note: Note): JsonObject {
1356
+ function ingestAnimation(
1357
+ animName: string,
1358
+ anim: JsonObject,
1359
+ root: JsonObject,
1360
+ note: Note,
1361
+ inert: ReadonlyMap<string, unknown>,
1362
+ omitted: (constraint: string, property: string, lastKey: number) => void,
1363
+ ): JsonObject {
1251
1364
  const tracks: JsonObject[] = [];
1252
1365
  let maxT = 0;
1253
1366
  const seeT = (t: number): void => {
@@ -1338,6 +1451,20 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
1338
1451
  const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
1339
1452
  for (const [name, timelines] of objEntries(anim[group])) {
1340
1453
  for (const [property, keys] of arrEntries(timelines)) {
1454
+ // A timeline keyed to a physics constraint `ingest` omitted (issue #731)
1455
+ // goes with it: carried, it names a constraint the rebuilt rig has not
1456
+ // got, and `build` refuses the whole motion spec over it. Its keys are
1457
+ // not seen by `seeT` — they are not in the rebuild — and their latest
1458
+ // time is handed back so the finding can say when that shortened one.
1459
+ if (group === 'physics' && inert.has(name)) {
1460
+ let lastKey = 0;
1461
+ for (const raw of keys) {
1462
+ const t = obj(raw).time;
1463
+ if (typeof t === 'number' && t > lastKey) lastKey = t;
1464
+ }
1465
+ omitted(name, property, lastKey);
1466
+ continue;
1467
+ }
1341
1468
  const shape = shapes[property];
1342
1469
  const where = `animation "${animName}" ${group} "${name}" ${property}`;
1343
1470
  if (shape === undefined) {
@@ -1413,14 +1540,58 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
1413
1540
  return entry;
1414
1541
  }),
1415
1542
  });
1543
+ } else if (property === 'alpha') {
1544
+ // Inverts `compileTrack`'s alpha branch, whose key is `{time, value}` —
1545
+ // the one colour shape the format stores as a number, read by
1546
+ // `readTimeline1` with a per-key default of **0**. That is a value
1547
+ // track's shape exactly, so it goes through the same inversion and
1548
+ // inherits its two findings: an omitted `value` is written out at 0 and
1549
+ // reported as `TIMELINE_KEY_RESTATED`, and a field the shape has no
1550
+ // place for is a `TIMELINE_FIELD` blocker (issue #730).
1551
+ valueTrack({ slot }, property, keys, [['value', 0]], where);
1552
+ } else if (property === 'rgb' || property === 'rgb2') {
1553
+ // Inverts `compileTrack`'s other two separable shapes (issue #730):
1554
+ // `rgb` is `{time, color: "rrggbb"}` and `rgb2` is `{time, light:
1555
+ // "rrggbb", dark: "rrggbb"}`, and the spec's `v` is their channels in
1556
+ // `readCurve`'s order. Each keeps its own name and its own key times:
1557
+ // folding an `rgb` and an `alpha` into one `rgba` would state each
1558
+ // channel at the other's key times, a value nobody keyed.
1559
+ tracks.push({
1560
+ slot,
1561
+ property,
1562
+ keys: keys.map((raw) => {
1563
+ const key = obj(raw);
1564
+ const v =
1565
+ property === 'rgb'
1566
+ ? hexToRgb(String(key.color))
1567
+ : [...hexToRgb(String(key.light)), ...hexToRgb(String(key.dark))];
1568
+ const entry: JsonObject = { t: timeOf(key), v };
1569
+ easing(key, entry);
1570
+ return entry;
1571
+ }),
1572
+ });
1416
1573
  } else {
1574
+ // 🔒 Composed from both tables, so it says the true thing whichever of
1575
+ // two states it is reached in. A name the FORMAT has and the spec does
1576
+ // not is the first; since issue #730 carried the last three there is no
1577
+ // such name (`UNSPELT_SLOT_TRACKS` is empty), and what still reaches
1578
+ // here is a name the format does not have at all — which
1579
+ // `SkeletonJson.readAnimation` throws on (`Invalid timeline type for a
1580
+ // slot`), so the sentence says so instead of printing an empty
1581
+ // "remaining" list about a file no runtime loads.
1582
+ const inFormat = property in CHANNELS_BY_KIND.slot;
1417
1583
  note(
1418
1584
  'blocker',
1419
1585
  'SLOT_TIMELINE',
1420
1586
  where,
1421
- `timeline "${property}" is in the format and the motion spec has no track for it — a slot track is ` +
1422
- `${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here. The format's ` +
1423
- `remaining slot timelines are ${UNSPELT_SLOT_TRACKS.join(', ')}`,
1587
+ (inFormat
1588
+ ? `timeline "${property}" is in the format and the motion spec has no track for it`
1589
+ : `timeline "${property}" is not a slot timeline the format has — the runtime's reader throws ` +
1590
+ '"Invalid timeline type for a slot" on it, so no player loads this file') +
1591
+ ` — a slot track is ${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here` +
1592
+ (UNSPELT_SLOT_TRACKS.length > 0
1593
+ ? `. The format's remaining slot timelines are ${UNSPELT_SLOT_TRACKS.join(', ')}`
1594
+ : ''),
1424
1595
  );
1425
1596
  }
1426
1597
  }