spine-rigc 0.30.0 → 0.31.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
@@ -39,8 +39,9 @@ import { parseJsonWithPosition } from './json-position.ts';
39
39
  // of it — the same search serves `refuseUnknownKeys`, and a second copy here with
40
40
  // a threshold edited is how such a pair drifts apart.
41
41
  import { nearMisses } from './keys.ts';
42
- import { parseMotionSpec } from './motion.ts';
42
+ import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
43
43
  import {
44
+ BONE_INHERIT_KNOWN,
44
45
  constraintAt,
45
46
  declaresNoStage,
46
47
  parseRigSpec,
@@ -50,6 +51,7 @@ import {
50
51
  RIG_PATH_SPACING_MODES,
51
52
  RIG_SCALE_Y_MODES,
52
53
  RIG_SKIN_CONSTRAINT_KEYS,
54
+ resolveBoneInherit,
53
55
  splitRigSkin,
54
56
  type RigAttachment,
55
57
  type RigBone,
@@ -89,6 +91,8 @@ import { Plate, readPlate } from '../tools/plate.ts';
89
91
  import {
90
92
  extractRegion,
91
93
  pageFootprint,
94
+ pageGridSaid,
95
+ pageGridSentence,
92
96
  parseAtlasText,
93
97
  rewritePageNames,
94
98
  writeAtlasText,
@@ -864,6 +868,17 @@ interface ValueTrackShape {
864
868
  fields: string[];
865
869
  identity: number[];
866
870
  bounds?: Array<PhysicsPoseRule | null>;
871
+ /**
872
+ * A timeline whose key holds one NAMED value instead of numbers — the JSON
873
+ * field it is written as, and the resolver that turns a spelling into the
874
+ * name the file carries (or `undefined` for one the runtime cannot resolve).
875
+ *
876
+ * ⚠️ Such a timeline is stepped by the format rather than by choice: its
877
+ * reader builds no curve at all, so an `ease` or a `curve` on its key would be
878
+ * accepted by the parser and never read. `compileValueTrack` refuses both.
879
+ * One entry has it — the bone's `inherit` (issue #733).
880
+ */
881
+ named?: { field: string; resolve: (value: unknown) => string | undefined; known: string };
867
882
  }
868
883
 
869
884
  /**
@@ -898,6 +913,12 @@ const BONE_TRACKS: Record<string, ValueTrackShape> = {
898
913
  shearx: { fields: ['value'], identity: [0] },
899
914
  sheary: { fields: ['value'], identity: [0] },
900
915
  rotate: { fields: ['value'], identity: [0] },
916
+ // The eleventh case of the runtime's bone switch, and the one that keys no
917
+ // number: `InheritTimeline` sets `pose.inherit` at the key and
918
+ // `updateWorldTransform` reads it that frame. Resolved by the SAME call the
919
+ // setup field is (`resolveBoneInherit`), so the five a key may name are the
920
+ // five a bone may rest in and a refusal prints the one list (issue #733).
921
+ inherit: { fields: [], identity: [], named: { field: 'inherit', resolve: resolveBoneInherit, known: BONE_INHERIT_KNOWN } },
901
922
  };
902
923
 
903
924
  /**
@@ -1505,6 +1526,11 @@ interface AtlasSource {
1505
1526
  parsed: ParsedAtlas;
1506
1527
  /** Trimmed region name -> the region, first occurrence wins (as `findRegion` does). */
1507
1528
  byName: Map<string, { region: AtlasRegion; page: ParsedAtlas['pages'][number] }>;
1529
+ /**
1530
+ * Page name -> what its file is against what the atlas declares, for every
1531
+ * page on disk whose PNG is not the declared size, in page order (issue #750).
1532
+ */
1533
+ grids: Map<string, NonNullable<CompiledImage['pageGrid']>>;
1508
1534
  }
1509
1535
 
1510
1536
  function readAtlasIn(path: string): AtlasSource {
@@ -1527,12 +1553,28 @@ function readAtlasIn(path: string): AtlasSource {
1527
1553
  // one sentence, rather than one region at a time or at `A06` after the whole
1528
1554
  // compile. A page that is not on disk is left to the region that sits on it,
1529
1555
  // which already names it; saying so twice would be two refusals of one fact.
1556
+ //
1557
+ // 📐 **What each page MEASURES, read in the same pass and not refused here**
1558
+ // (issue #750). A page whose PNG is not the size the atlas declares is a
1559
+ // file every runtime still draws — the region mapping is a fraction of the
1560
+ // declared size — and the only readers it breaks are the ones that address
1561
+ // it in texels. Refusing it here would refuse every pack that has one,
1562
+ // including a pack of region attachments nothing reads a texel of, and on
1563
+ // `build` it would replace `A06`'s verdict with a compile error for the same
1564
+ // fact: `A06` already refuses the pack, names the ratio and prints the
1565
+ // `scale:` repair, before anything is written. So the page is RECORDED, as
1566
+ // `pageGridSentence` states it, and the readers that would take a figure off
1567
+ // its texels decline to on the part that asks (`partTexels` below).
1530
1568
  const unread: string[] = [];
1569
+ const grids: AtlasSource['grids'] = new Map();
1531
1570
  for (const page of parsed.pages) {
1532
1571
  const abs = resolve(dirname(path), page.name);
1533
1572
  if (!existsSync(abs)) continue;
1534
- const { problem } = readPngHeader(abs);
1573
+ const { info, problem } = readPngHeader(abs);
1535
1574
  if (problem !== null) unread.push(`page "${page.name}": ${problem}`);
1575
+ if (info === null) continue;
1576
+ const sentence = pageGridSentence(page, info, page.regions);
1577
+ if (sentence !== null) grids.set(page.name, { said: pageGridSaid(page, info), sentence });
1536
1578
  }
1537
1579
  if (unread.length > 0) {
1538
1580
  throw new CompileError(
@@ -1540,7 +1582,7 @@ function readAtlasIn(path: string): AtlasSource {
1540
1582
  `nothing was compiled against the pack — ${unread.join('; ')}`,
1541
1583
  );
1542
1584
  }
1543
- return { path, dir: dirname(path), parsed, byName };
1585
+ return { path, dir: dirname(path), parsed, byName, grids };
1544
1586
  }
1545
1587
 
1546
1588
  /**
@@ -1634,6 +1676,7 @@ function resolveFromAtlas(
1634
1676
  const info = readPngInfo(absPath);
1635
1677
  const page = relative(outDir, absPath).split('\\').join('/');
1636
1678
  const scale = found.page.scale;
1679
+ const grid = atlas.grids.get(found.page.name);
1637
1680
  return {
1638
1681
  region,
1639
1682
  page,
@@ -1647,6 +1690,7 @@ function resolveFromAtlas(
1647
1690
  isBase,
1648
1691
  atlas: found.region,
1649
1692
  ...(scale === 1 ? {} : { atlasScale: scale }),
1693
+ ...(grid === undefined ? {} : { pageGrid: grid }),
1650
1694
  };
1651
1695
  }
1652
1696
 
@@ -2395,6 +2439,16 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2395
2439
  /** kind -> the names it declares, for the "the rig declares: …" half of one. */
2396
2440
  const constraintNamesOfKind = new Map<string, string[]>();
2397
2441
  const declareConstraint = (name: string, type: string): void => {
2442
+ // Reserved, because a track's `"physics": "*"` is the timeline that names no
2443
+ // constraint (issue #726): a physics constraint of that name is one no track
2444
+ // could key by name without meaning every global one instead.
2445
+ if (type === 'physics' && name === EVERY_GLOBAL_PHYSICS) {
2446
+ throw new CompileError(
2447
+ `physics constraint "${EVERY_GLOBAL_PHYSICS}": the name is reserved — a motion spec's \`"physics": ` +
2448
+ `"${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives every physics constraint ` +
2449
+ 'declaring the keyed property global, so a constraint called that could not be keyed by name. Rename it',
2450
+ );
2451
+ }
2398
2452
  constraintDeclared.add(constraintAt(type, name));
2399
2453
  constraintKinds.set(name, [...(constraintKinds.get(name) ?? []), type]);
2400
2454
  constraintNamesOfKind.set(type, [...(constraintNamesOfKind.get(type) ?? []), name]);
@@ -2568,7 +2622,40 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2568
2622
  const perMember = resolveMemberTrack(track, animName, targets, bones, trackDerivations);
2569
2623
  targets.forEach((target, index) => {
2570
2624
  const resolved = perMember.get(target)!;
2571
- if (family !== null) {
2625
+ // The timeline that names no constraint (issue #726). Resolved against
2626
+ // the constraints' own `…Global` flags rather than a name, and refused
2627
+ // when it reaches none — the runtime would walk every physics
2628
+ // constraint, skip each one, and the file would parse and do nothing.
2629
+ const everyGlobal = family === 'physics' && target === EVERY_GLOBAL_PHYSICS;
2630
+ if (everyGlobal && track.physics === undefined) {
2631
+ throw new CompileError(
2632
+ `animation "${animName}": group "${String(track.group)}" lists "${EVERY_GLOBAL_PHYSICS}", which is not a ` +
2633
+ `constraint but the target that names none — write it as the track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\``,
2634
+ );
2635
+ }
2636
+ if (everyGlobal) {
2637
+ const physics = constraints.filter((one) => one.type === 'physics');
2638
+ const flag = `${track.property}Global`;
2639
+ const reached =
2640
+ track.property === 'reset'
2641
+ ? physics
2642
+ : physics.filter((one) => one[flag] === true);
2643
+ if (reached.length === 0) {
2644
+ throw new CompileError(
2645
+ `animation "${animName}" keys physics "${EVERY_GLOBAL_PHYSICS}" ${track.property}, the timeline that ` +
2646
+ `names no constraint and drives every physics constraint ` +
2647
+ (track.property === 'reset' ? 'the rig has' : `declaring "${flag}": true`) +
2648
+ ', and ' +
2649
+ (physics.length === 0
2650
+ ? 'the rig declares no physics constraint'
2651
+ : `none of ${physics.map((one) => `"${one.name}"`).join(', ')} does`) +
2652
+ ' — it would parse and move nothing. ' +
2653
+ (track.property === 'reset'
2654
+ ? 'Declare a physics constraint, or remove the track'
2655
+ : `Set "${flag}": true on the constraints it is for, or key one by name`),
2656
+ );
2657
+ }
2658
+ } else if (family !== null) {
2572
2659
  const label = CONSTRAINT_TRACK_FAMILIES[family].label;
2573
2660
  // Resolved by name AND by type in the parser
2574
2661
  // (`findConstraint(name, PathConstraintData)`), which returns null on
@@ -2650,7 +2737,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2650
2737
  ? compileValueTrack(resolved, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
2651
2738
  : compileTrack(resolved, motion, animName, anim.duration, target, shift, attachmentIndex, darkSlots);
2652
2739
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
2653
- if (family !== null) (familyTimelines[family][target] ??= {})[track.property] = keys;
2740
+ // Under the empty name, which is the file's own spelling of it.
2741
+ if (family !== null) (familyTimelines[family][everyGlobal ? '' : target] ??= {})[track.property] = keys;
2654
2742
  else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
2655
2743
  else (slotTimelines[target] ??= {})[track.property] = keys;
2656
2744
  });
@@ -2887,6 +2975,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2887
2975
  skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
2888
2976
  atlasText,
2889
2977
  images,
2978
+ pageGrids: atlasIn === null ? [] : [...atlasIn.grids].map(([page, grid]) => ({ page, said: grid.said })),
2890
2979
  droppedStates,
2891
2980
  absentParts,
2892
2981
  declaredDurations,
@@ -3986,11 +4075,20 @@ function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Att
3986
4075
  * what `extractRegion` does, so there is no unmeasurable case left for this
3987
4076
  * function to report and no catch here to keep reachable.
3988
4077
  */
3989
- function measureAuthoredFit(att: RigMeshAttachment, ctx: AttachmentContext): MeshFitReport | null {
4078
+ function measureAuthoredFit(
4079
+ att: RigMeshAttachment,
4080
+ ctx: AttachmentContext,
4081
+ ): MeshFitReport | { withheld: string } | null {
3990
4082
  if (att.image === undefined || att.uvs === undefined || att.triangles === undefined) return null;
3991
4083
  const region = basename(att.image, '.png');
3992
4084
  const img = ctx.images.find((im) => im.region === region);
3993
4085
  if (!img) return null;
4086
+ // A third case, and neither of the two above (issue #750): there IS art to
4087
+ // compare against, and its texels cannot be located, because its page's file
4088
+ // is not the size the atlas declares. Withheld and said, rather than measured
4089
+ // over whatever the declared coordinates land on — on a half-resolution pack
4090
+ // that read 68.49% where the page it was packed from reads 100.00%.
4091
+ if (img.pageGrid !== undefined) return { withheld: img.pageGrid.said };
3994
4092
  const plate = partPlate(img);
3995
4093
  const alpha = new Uint8Array(plate.width * plate.height);
3996
4094
  for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3];
@@ -4159,7 +4257,8 @@ function buildRigMesh(
4159
4257
  // read this and skip rather than measuring a ring that was never a ring.
4160
4258
  ctx.meshBones.add(ctx.anchorBone);
4161
4259
  for (const name of boundBones) ctx.meshBones.add(name);
4162
- const fit = measureAuthoredFit(att, ctx);
4260
+ const measured = measureAuthoredFit(att, ctx);
4261
+ const fit = measured === null || 'withheld' in measured ? null : measured;
4163
4262
  ctx.meshes.push({
4164
4263
  slot: ctx.slotName,
4165
4264
  kind: 'authored',
@@ -4169,6 +4268,7 @@ function buildRigMesh(
4169
4268
  bones: boundBones.length ? boundBones : [ctx.anchorBone],
4170
4269
  coverage: fit === null ? undefined : r6(fit.coverage),
4171
4270
  overshoot: fit?.overshoot,
4271
+ ...(measured !== null && 'withheld' in measured ? { fitWithheld: measured.withheld } : {}),
4172
4272
  });
4173
4273
  return out;
4174
4274
  }
@@ -4526,8 +4626,13 @@ function sampleMeshDepth(
4526
4626
  *
4527
4627
  * The two are different questions and the header says why. This one has no
4528
4628
  * refusal behind it: it is counted, reported, and left to the author.
4629
+ *
4630
+ * `unlocated` in its place when the part's texels cannot be located — a
4631
+ * region lifted off a page whose file is not its declared size (issue #750),
4632
+ * carrying `pageGridSaid`'s clause — and the count is then `null` with that
4633
+ * clause beside it: withheld, rather than taken off another part of the page.
4529
4634
  */
4530
- partAlpha: Uint8Array,
4635
+ partAlpha: Uint8Array | { unlocated: string },
4531
4636
  partWidth: number,
4532
4637
  partHeight: number,
4533
4638
  where: string,
@@ -4587,7 +4692,7 @@ function sampleMeshDepth(
4587
4692
  // Zero, not a threshold: "the part image draws nothing here" is a fact
4588
4693
  // about the file, and any other cut-off would be rigc deciding how faint
4589
4694
  // a texel has to be before it stops counting as art.
4590
- if (partAlpha[at] === 0) drawn = false;
4695
+ if (partAlpha instanceof Uint8Array && partAlpha[at] === 0) drawn = false;
4591
4696
  }
4592
4697
  if (!opaqueEverywhere && !covered) uncovered.push(v);
4593
4698
  if (!drawn) undrawn++;
@@ -4632,7 +4737,7 @@ function sampleMeshDepth(
4632
4737
  zScale: spec.zScale,
4633
4738
  tone,
4634
4739
  range: [r6(lo), r6(hi)],
4635
- undrawn,
4740
+ ...(partAlpha instanceof Uint8Array ? { undrawn } : { undrawn: null, unlocated: partAlpha.unlocated }),
4636
4741
  ceiling: turnCeiling(
4637
4742
  points.map(([px, py]) => toBind(px, py)),
4638
4743
  z,
@@ -4870,7 +4975,11 @@ function buildGridAttachment(
4870
4975
  geometry.points,
4871
4976
  geometry.triangles,
4872
4977
  (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
4873
- plateAlpha(plate),
4978
+ // The lattice's geometry takes nothing off the texels — its window is
4979
+ // the plate's size, which is the atlas's own `offsets` — so a page
4980
+ // whose file is not its declared size costs a grid only this one
4981
+ // count, and the count is what is withheld (issue #750).
4982
+ img.pageGrid === undefined ? plateAlpha(plate) : { unlocated: img.pageGrid.said },
4874
4983
  plate.width,
4875
4984
  plate.height,
4876
4985
  where,
@@ -4959,6 +5068,24 @@ function buildContourAttachment(
4959
5068
  );
4960
5069
  }
4961
5070
  const img = atlasedImage(att.image, where, ctx);
5071
+ // 🔒 **A trace needs the part's texels, and on a page that is not its
5072
+ // declared size the lift does not have them** (issue #750). `extractRegion`
5073
+ // addresses the page at the coordinates the atlas states; on a file of any
5074
+ // other size those are a different part of the picture — measured on a
5075
+ // half-resolution pack, the lift came back with no pixel above alpha 0 and
5076
+ // the build refused with "there is no silhouette to trace", a true sentence
5077
+ // about the wrong texels. Unlike an authored mesh's fit, which is a
5078
+ // measurement and is withheld, this outline IS the geometry: there is no mesh
5079
+ // to build without it, and the compiler invents none. So it is refused, with
5080
+ // `A06`'s own sentence, which names the ratio and the repair — on `build`
5081
+ // this refusal arrives before the gate, where `A06` would have said it.
5082
+ if (img.pageGrid !== undefined) {
5083
+ throw new CompileError(
5084
+ `${where}: a "contour" generator traces the part's own alpha, and "${att.image}" is lifted off a packed ` +
5085
+ 'page at the coordinates the atlas states, which on this file are not where its texels are — so there is ' +
5086
+ `no silhouette here to trace, only another part of the page. ${img.pageGrid.sentence}`,
5087
+ );
5088
+ }
4962
5089
  // ⚠️ Nothing here reads the PNG's colour type. `hasAlpha` answers "where does
4963
5090
  // this file keep its alpha", not "is any pixel of it transparent" — a tRNS
4964
5091
  // chunk is real transparency (issue #215) and an all-255 alpha channel is
@@ -5968,6 +6095,26 @@ function compileValueTrack(
5968
6095
  throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
5969
6096
  }
5970
6097
  checkKeyTime(where, time, key.t, duration);
6098
+ // A named-value timeline (`inherit`): the key's `v` is one name, stepped by
6099
+ // the format. The name is resolved rather than copied, and what is WRITTEN
6100
+ // is the resolver's spelling — so `NoScale`, which the runtime also reads,
6101
+ // emits as the `noScale` an editor writes, and a spelling the runtime
6102
+ // cannot resolve never reaches the file at all.
6103
+ if (shape.named !== undefined) {
6104
+ if (key.ease !== undefined || key.curve !== undefined) {
6105
+ throw new CompileError(
6106
+ `${where} key at t=${key.t} carries ${key.ease !== undefined ? `an easing ("${key.ease}")` : 'a curve'}, and ` +
6107
+ `this timeline is stepped by the format — its reader builds no curve, so the mode changes AT the key and ` +
6108
+ 'holds until the next one. Remove it',
6109
+ );
6110
+ }
6111
+ const mode = shape.named.resolve(key.v);
6112
+ if (mode === undefined) {
6113
+ throw new CompileError(`${where} key at t=${key.t} names mode ${JSON.stringify(key.v)}; ${shape.named.known}`);
6114
+ }
6115
+ out.push({ time, [shape.named.field]: mode });
6116
+ continue;
6117
+ }
5971
6118
  // A no-field timeline (`reset`) is an event: the key IS the value, so it
5972
6119
  // carries none. Anything else must match the field count exactly.
5973
6120
  if (shape.fields.length === 0) {
package/src/ingest.ts CHANGED
@@ -54,8 +54,8 @@ import {
54
54
  spineGeneration,
55
55
  TOPLEVEL_CONSTRAINT_ARRAYS,
56
56
  } from './generation.ts';
57
- import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
58
- import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
57
+ import { EVERY_GLOBAL_PHYSICS, MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
58
+ import { parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
59
59
  import type { MotionSpec } from './types.ts';
60
60
 
61
61
  /**
@@ -374,6 +374,18 @@ const BONE_TRACKS: Record<string, TrackShape> = {
374
374
  rotate: [['value', 0]],
375
375
  };
376
376
 
377
+ /**
378
+ * The bone timeline whose key holds a NAME rather than numbers — `inherit`,
379
+ * `{ time, inherit }`, stepped by the format (its reader builds no curve). The
380
+ * field and the default `SkeletonJson`'s `inherit` branch reads where a key
381
+ * omits it: `getValue(aFrame, "inherit", "Normal")`, which the table spells
382
+ * `normal`. Inverts `compileValueTrack`'s named branch (issue #733).
383
+ */
384
+ const INHERIT_TRACK = { property: 'inherit', field: 'inherit', dflt: 'normal' } as const;
385
+
386
+ /** Every bone timeline the motion spec has a track for, in the order the refusal prints them. */
387
+ const BONE_TRACK_NAMES = [...Object.keys(BONE_TRACKS), INHERIT_TRACK.property];
388
+
377
389
  /**
378
390
  * The eight physics timelines — `PHYSICS_TRACKS` in `compile.ts`, same order.
379
391
  * `reset` carries no value at all — `compileValueTrack`'s zero-field branch.
@@ -516,7 +528,7 @@ export const UNSPELT_SLOT_TRACKS = Object.keys(CHANNELS_BY_KIND.slot).filter((na
516
528
  export const INGEST_VOCABULARY = {
517
529
  attachments: ATTACHMENT_TYPES,
518
530
  constraints: Object.keys(CONSTRAINT_FIELDS),
519
- boneTracks: Object.keys(BONE_TRACKS),
531
+ boneTracks: BONE_TRACK_NAMES,
520
532
  slotTracks: SLOT_TRACKS,
521
533
  path: Object.keys(PATH_TRACKS),
522
534
  physics: Object.keys(PHYSICS_TRACKS),
@@ -1448,6 +1460,16 @@ function ingestAnimation(
1448
1460
  * `lossy`, on the argument that the rebuilt skeleton plays identically — is
1449
1461
  * a decision about all three families and is not made here.
1450
1462
  */
1463
+ /**
1464
+ * The physics constraints the rebuild carries — every one the file declares
1465
+ * but those `ingest` omits as inert — which is what an unnamed physics
1466
+ * timeline can reach in it.
1467
+ */
1468
+ const carriedPhysics = arr(root.constraints)
1469
+ .map(obj)
1470
+ .filter((one) => one.type === 'physics' && !(typeof one.name === 'string' && inert.has(one.name)));
1471
+ /** Unnamed physics timelines that reach nothing in the rebuild, said once the duration is known. */
1472
+ const unreached: Array<{ property: string; lastKey: number }> = [];
1451
1473
  const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
1452
1474
  for (const [name, timelines] of objEntries(anim[group])) {
1453
1475
  for (const [property, keys] of arrEntries(timelines)) {
@@ -1471,6 +1493,26 @@ function ingestAnimation(
1471
1493
  note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
1472
1494
  continue;
1473
1495
  }
1496
+ // The empty name is the physics group's timeline that names no
1497
+ // constraint (issue #726), which the motion spec spells `"*"`. It
1498
+ // writes every carried constraint declaring the property global —
1499
+ // `reset` every one — and one that reaches none is a no-op the rebuild
1500
+ // would be refused over by name, so it goes, and is said, instead.
1501
+ if (group === 'physics' && name === '') {
1502
+ const reaches =
1503
+ property === 'reset' ? carriedPhysics.length > 0 : carriedPhysics.some((one) => Boolean(one[`${property}Global`]));
1504
+ if (!reaches) {
1505
+ let lastKey = 0;
1506
+ for (const raw of keys) {
1507
+ const t = obj(raw).time;
1508
+ if (typeof t === 'number' && t > lastKey) lastKey = t;
1509
+ }
1510
+ unreached.push({ property, lastKey });
1511
+ continue;
1512
+ }
1513
+ valueTrack({ physics: EVERY_GLOBAL_PHYSICS }, property, keys, shape, where);
1514
+ continue;
1515
+ }
1474
1516
  valueTrack({ [group]: name }, property, keys, shape, where);
1475
1517
  }
1476
1518
  }
@@ -1480,12 +1522,46 @@ function ingestAnimation(
1480
1522
  for (const [property, keys] of arrEntries(timelines)) {
1481
1523
  const shape = BONE_TRACKS[property];
1482
1524
  const where = `animation "${animName}" bone "${bone}" ${property}`;
1525
+ if (property === INHERIT_TRACK.property) {
1526
+ // `{ time, inherit }` -> `{ t, v: mode }`. The mode is carried in the
1527
+ // table's spelling, which is what `build` writes back; a key that omits
1528
+ // it, or spells it with a capital the runtime also folds, is a key the
1529
+ // rebuild states differently and is counted as such. A spelling the
1530
+ // runtime cannot resolve at all is carried as written — the file plays
1531
+ // no mode there, and `build` refuses it by name rather than guessing one.
1532
+ let restated = 0;
1533
+ const out = keys.map((raw) => {
1534
+ const key = obj(raw);
1535
+ const entry: JsonObject = { t: timeOf(key) };
1536
+ const written = key[INHERIT_TRACK.field];
1537
+ const mode = written === undefined ? INHERIT_TRACK.dflt : resolveBoneInherit(written);
1538
+ if (mode !== written && mode !== undefined) restated++;
1539
+ entry.v = mode ?? (written as JsonObject[string]);
1540
+ for (const field of Object.keys(key)) {
1541
+ if (field === 'time' || field === INHERIT_TRACK.field) continue;
1542
+ note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
1543
+ }
1544
+ return entry;
1545
+ });
1546
+ if (restated > 0) {
1547
+ note(
1548
+ 'lossy',
1549
+ 'TIMELINE_KEY_RESTATED',
1550
+ where,
1551
+ `${restated} key(s) omit the mode or spell it with a capital first letter, and are written out as the ` +
1552
+ `mode the runtime reads there (an omitted one is ${INHERIT_TRACK.dflt}) — the same mode, in the ` +
1553
+ 'spelling the editor writes',
1554
+ );
1555
+ }
1556
+ tracks.push({ bone, property, keys: out });
1557
+ continue;
1558
+ }
1483
1559
  if (shape === undefined) {
1484
1560
  note(
1485
1561
  'blocker',
1486
1562
  'BONE_TIMELINE',
1487
1563
  where,
1488
- `timeline "${property}" has no track in the motion spec — a bone track is ${Object.keys(BONE_TRACKS).join(', ')} ` +
1564
+ `timeline "${property}" has no track in the motion spec — a bone track is ${BONE_TRACK_NAMES.join(', ')} ` +
1489
1565
  'and nothing else, so the rebuild plays nothing here',
1490
1566
  );
1491
1567
  continue;
@@ -1667,6 +1743,26 @@ function ingestAnimation(
1667
1743
  });
1668
1744
  }
1669
1745
 
1746
+ for (const { property, lastKey } of unreached) {
1747
+ const flag = `${property}Global`;
1748
+ note(
1749
+ 'lossy',
1750
+ 'PHYSICS_GLOBAL_REACHES_NOTHING',
1751
+ `animation "${animName}" physics "" ${property}`,
1752
+ 'names no constraint, so the runtime writes it into every physics constraint ' +
1753
+ (property === 'reset' ? 'the skeleton has' : `declaring "${flag}"`) +
1754
+ ', and the rebuild carries ' +
1755
+ (carriedPhysics.length === 0
1756
+ ? 'no physics constraint'
1757
+ : `none that does (${carriedPhysics.map((one) => `"${String(one.name)}"`).join(', ')})`) +
1758
+ ` — it moves nothing, and \`build\` would refuse its \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` track by name. ` +
1759
+ 'The motion spec omits it' +
1760
+ (lastKey > maxT
1761
+ ? `, and an animation's duration is the last key it has left, so the rebuilt animation ends at ${maxT}s rather than ${lastKey}s`
1762
+ : '; the rebuild differs from the source by exactly this no-op'),
1763
+ );
1764
+ }
1765
+
1670
1766
  for (const group of Object.keys(anim)) {
1671
1767
  if (ANIMATION_GROUPS.includes(group)) continue;
1672
1768
  note(
package/src/motion.ts CHANGED
@@ -59,6 +59,24 @@ import type { MotionSpec } from './types.ts';
59
59
 
60
60
  export const MOTION_SPEC_VERSION = 'rigc-motion/1';
61
61
 
62
+ /**
63
+ * A track's `physics` target for the timeline that names NO constraint (issue
64
+ * #726) — the one the runtime applies to every physics constraint whose own data
65
+ * declares the keyed property global (`"strengthGlobal": true` for `strength`,
66
+ * and so on; `reset` resets every physics constraint and asks no flag).
67
+ * `compile` emits it under the empty name, which is the skeleton file's own
68
+ * spelling of it (`SkeletonJson.js:1048-1054`).
69
+ *
70
+ * 🔑 Not the empty string itself, and that is the choice rather than a detail:
71
+ * `""` is the likeliest shape of a value somebody forgot to fill in, and a
72
+ * forgotten target that silently became "every global constraint" is the exact
73
+ * silence this format exists to name. So `"physics": ""` is refused by name and
74
+ * points here, and `"*"` is reserved the other way round: `compile` refuses a
75
+ * physics constraint that is CALLED `"*"`, because a track naming it could not
76
+ * say which of the two it meant.
77
+ */
78
+ export const EVERY_GLOBAL_PHYSICS = '*';
79
+
62
80
  /**
63
81
  * The six fields that pick a track's target family. Listed here as well as in
64
82
  * `compile`'s `resolveTargets` because the two ask different questions of it:
@@ -292,7 +310,15 @@ function parsePhysics(raw: unknown, where: string): void {
292
310
  const table = needObj(raw, where, 'physics', 'it is a table keyed by constraint name, `{ "<name>": { "bone": … } }`');
293
311
  for (const [name, entry] of Object.entries(table)) {
294
312
  const key = `physics."${name}"`;
295
- const spec = needObj(entry, where, key, 'a physics constraint is an object naming the bone it drives and the components it drives it in');
313
+ if (name === EVERY_GLOBAL_PHYSICS) {
314
+ throw new CompileError(
315
+ `${where}: \`${key}\` names a physics constraint "${EVERY_GLOBAL_PHYSICS}", and that name is reserved: a ` +
316
+ `track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives ` +
317
+ 'every one declaring the keyed property global, so a constraint called ' +
318
+ `"${EVERY_GLOBAL_PHYSICS}" could not be keyed by name. Give it another name`,
319
+ );
320
+ }
321
+ const spec = needObj(entry, where, key,'a physics constraint is an object naming the bone it drives and the components it drives it in');
296
322
  known(spec, 'MotionPhysics', where, key);
297
323
  needString(spec.bone, where, `${key}.bone`, 'a physics constraint drives one bone, named here');
298
324
  for (const field of PHYSICS_NUMBERS) {
@@ -378,6 +404,17 @@ function parseTracks(raw: unknown, where: string, at: string): void {
378
404
  for (const field of TARGET_FIELDS) {
379
405
  optString(track[field], where, `${key}.${field}`, `a track's "${field}" is the name of the ${field === 'slot' || field === 'bone' ? field : `${field} it targets`}`);
380
406
  }
407
+ if (track.physics === '') {
408
+ refuse(
409
+ where,
410
+ `${key}.physics`,
411
+ track.physics,
412
+ 'the empty name is how a skeleton file spells a physics timeline that names no constraint, and a motion ' +
413
+ `spec spells that "${EVERY_GLOBAL_PHYSICS}" — it drives every physics constraint that declares the keyed ` +
414
+ 'property global (`"strengthGlobal": true` for `strength`). Name one constraint, or write ' +
415
+ `"${EVERY_GLOBAL_PHYSICS}"`,
416
+ );
417
+ }
381
418
  optFinite(track.lag, where, `${key}.lag`, '"lag" is seconds added to every key time of this track, so a finite number — a string is CONCATENATED onto each time and a boolean adds 1');
382
419
  optFinite(track.stagger, where, `${key}.stagger`, '"stagger" is the extra per-member delay inside a group, in seconds, so a finite number');
383
420
  const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, v }` keys');
package/src/rig.ts CHANGED
@@ -162,6 +162,38 @@ export const RIG_BONE_INHERIT: readonly RigBoneInherit[] = [
162
162
  'noScaleOrReflection',
163
163
  ];
164
164
 
165
+ /**
166
+ * The mode a spelling of `inherit` resolves to, in the table's own spelling —
167
+ * or `undefined` for one the runtime cannot resolve.
168
+ *
169
+ * ⭐ **One rule for both places the format spells a mode**: a bone's setup
170
+ * `inherit` and an `inherit` timeline key are read by the same call,
171
+ * `Utils.enumValue(Inherit, name)`, which is `Inherit[name[0].toUpperCase() +
172
+ * name.slice(1)]` — the FIRST letter is folded and nothing else. So `noScale`
173
+ * and `NoScale` resolve and `NOSCALE` or `noscale` do not, and a spelling that
174
+ * misses loads as `undefined`: the setup pose holds no mode at all and a
175
+ * timeline frame holds NaN, and in both cases `updateWorldTransform`'s switch
176
+ * matches no case and leaves the world matrix where it was. Nothing throws.
177
+ *
178
+ * 🚨 The setup check was **case-insensitive** until issue #733, which is wider
179
+ * than the runtime's rule by exactly that silence: `"inherit": "NOSCALE"`
180
+ * compiled, gated green on all 45 assertions, and loaded `setupPose.inherit ===
181
+ * undefined`. Measured, not argued — and a key read through the same wide rule
182
+ * would have shipped the same spelling into a timeline.
183
+ */
184
+ export function resolveBoneInherit(value: unknown): RigBoneInherit | undefined {
185
+ if (typeof value !== 'string' || value.length === 0) return undefined;
186
+ const folded = value[0].toLowerCase() + value.slice(1);
187
+ return RIG_BONE_INHERIT.find((mode) => mode === folded);
188
+ }
189
+
190
+ /**
191
+ * The value REQUIRED, as both refusals of an unresolvable mode print it: the
192
+ * five, and the one liberty the runtime's lookup allows.
193
+ */
194
+ export const BONE_INHERIT_KNOWN =
195
+ `known: ${RIG_BONE_INHERIT.join(', ')} — the runtime folds the case of the first letter and of nothing else`;
196
+
165
197
  /**
166
198
  * Take a bone's setup transform from the cut manifest rather than from a literal.
167
199
  *
@@ -1802,9 +1834,9 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
1802
1834
  `${where}: bone "${bone.name}" names parent ${JSON.stringify(bone.parent)}, which is not declared before it`,
1803
1835
  );
1804
1836
  }
1805
- if (bone.inherit !== undefined && !RIG_BONE_INHERIT.some((v) => v.toLowerCase() === String(bone.inherit).toLowerCase())) {
1837
+ if (bone.inherit !== undefined && resolveBoneInherit(bone.inherit) === undefined) {
1806
1838
  throw new CompileError(
1807
- `${where}: bone "${bone.name}" has inherit ${JSON.stringify(bone.inherit)}; known: ${RIG_BONE_INHERIT.join(', ')}`,
1839
+ `${where}: bone "${bone.name}" has inherit ${JSON.stringify(bone.inherit)}; ${BONE_INHERIT_KNOWN}`,
1808
1840
  );
1809
1841
  }
1810
1842
  const from = bone.from;