spine-rigc 0.22.2 → 0.24.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.
@@ -107,6 +107,7 @@ import {
107
107
  Skeleton,
108
108
  type SkeletonData,
109
109
  SkeletonJson,
110
+ type Skin,
110
111
  Slider,
111
112
  SliderData,
112
113
  TextureAtlas,
@@ -277,6 +278,18 @@ export interface DeformKeyDraw {
277
278
  * none of this mesh is drawn.
278
279
  */
279
280
  alpha: number;
281
+ /**
282
+ * The skin the skeleton was **wearing** when all of the above was read, off
283
+ * `Skeleton.skin` itself — the skin that holds this timeline's attachment
284
+ * (issue #583) — or `null` when it wore none.
285
+ *
286
+ * ⭐ Read off the posed skeleton rather than passed in beside it, so it cannot
287
+ * disagree with what was actually set. It is what makes `blank` a statement
288
+ * about a pose instead of a verdict on the rig: "the slot shows nothing" and
289
+ * "the slot shows nothing in the dress this mesh lives in" are different
290
+ * claims, and only the second is measurable.
291
+ */
292
+ under: string | null;
280
293
  /**
281
294
  * Why this mesh puts no pixels on the screen at that time, in the words the
282
295
  * `DEFORM` block and A39's stats line both print — or `null` when it puts some
@@ -794,147 +807,211 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
794
807
  let spansNotScanned = 0;
795
808
  const dialTies: DeformDialTie[] = [];
796
809
  const dialDisputes: DeformDialDispute[] = [];
797
- const reaches = reachesOf(data);
810
+ /**
811
+ * `reachesOf` under one skin, computed once per skin the survey reaches.
812
+ *
813
+ * ⚠️ Keyed on the `Skin` OBJECT, which is what `placementOf` recovered. A map
814
+ * keyed on the name would fold two same-named skins into one entry and hand
815
+ * the second one the first's plans (#583).
816
+ */
817
+ const reachCache = new Map<Skin | null, Map<string, Array<DialPlan | null>>>();
818
+ const reachesUnder = (skin: Skin | null): Map<string, Array<DialPlan | null>> => {
819
+ const already = reachCache.get(skin);
820
+ if (already !== undefined) return already;
821
+ const built = reachesOf(data, skin);
822
+ reachCache.set(skin, built);
823
+ return built;
824
+ };
825
+ /**
826
+ * Sliders whose tie or dispute is already in the rollup, by name.
827
+ *
828
+ * A slider used to be planned once per animation and therefore reported once.
829
+ * It is now planned once per (animation, skin) — so the guard keeps the rollup
830
+ * at one line per slider, and it loses nothing: a slider the skins do not
831
+ * touch (`skinRequired` false, and a driving bone that is active under every
832
+ * skin) probes identically under all of them, and one they do touch is
833
+ * `active` under exactly the one skin that lists it, because a rig spec is
834
+ * refused for putting a constraint or a bone in two skins.
835
+ */
836
+ const dialsReported = new Set<string>();
798
837
  for (const anim of data.animations) {
799
- // One pass per way in (issue #407). The animations nothing applies get the
800
- // single track pass this loop has always been.
801
- for (const dials of reaches.get(anim.name) ?? [null]) {
802
- const poseFrame = (time: number): PoseOfFrame =>
803
- dials === null ? { posed: poseAt(data, anim.name, time), dial: null } : poseDial(data, dials, time);
804
- const reach = dials === null ? TRACK_REACH : dials.reach;
805
- /**
806
- * The key times this plan actually **reached**, for the reach comparison
807
- * below (#427).
808
- *
809
- * ⚠️ A key the drive itself could not select is not in here. It is already
810
- * named on the stats line as `deformKeysUnreachable`, with the ask and the
811
- * bound, and the artifact's answer cannot reach it either — so listing it
812
- * as something the artifact's answer missed would count one defect twice
813
- * and inflate a disagreement with a frame the disagreement did not cost.
814
- */
815
- const posedTimes = new Set<number>();
816
- for (const timeline of anim.timelines) {
817
- if (!(timeline instanceof DeformTimeline)) continue;
818
- timelines++;
819
- const attachment = timeline.attachment;
820
- const slotName = data.slots[timeline.slotIndex]?.name ?? `#${timeline.slotIndex}`;
821
- // A bounding box, a clipping polygon and a path all have a vertex array
822
- // and NO triangles, so they have no winding to keep and no area to take a
823
- // ratio of. Saying nothing about them beats inventing a measurement.
824
- if (!(attachment instanceof MeshAttachment)) {
825
- notAMesh.add(`"${slotName}"`);
826
- continue;
827
- }
828
- if (exempt.has(slotName)) {
829
- exempted.add(`"${slotName}"`);
830
- continue;
831
- }
832
- const triangles = attachment.triangles;
833
- if (!triangles || triangles.length < 3) continue; // A04 owns a mesh with no triangles
834
- const placement = placementOf(data, timeline.slotIndex, attachment);
835
- const named = {
836
- animation: anim.name,
837
- skin: placement.skin,
838
- slot: slotName,
839
- attachment: attachment.name,
840
- placeholder: placement.placeholder,
841
- };
842
- /** The previous key's posed frame, kept so the span between them can be scanned. */
843
- let previous: PosedFrame | null = null;
844
- for (let frame = 0; frame < timeline.frames.length; frame++) {
845
- const time = timeline.frames[frame];
846
- const at = poseFrame(time);
847
- const frameMeasure = measurePosed(at.posed, time, timeline.slotIndex, attachment, triangles, reach, at.dial);
848
- // A key that draws no pixels — or one at a time no dial can select —
849
- // is measured and then left out of the totals, because those totals
850
- // are "what the gate ran on": A39 reads them onto its stats line and
851
- // the report's rollup has to match them.
852
- if (at.dial?.unreachable === true) {
853
- notReachable++;
854
- notReachableReversed += frameMeasure.measure.reversed.length;
855
- } else if (frameMeasure.measure.draw.blank === null) {
856
- trianglesMeasured += frameMeasure.measure.triangles;
857
- collapsedTotal += frameMeasure.measure.collapsed;
858
- } else {
859
- notDrawn++;
860
- notDrawnReversed += frameMeasure.measure.reversed.length;
861
- }
862
- keys.push({ ...named, key: frame, time, ...frameMeasure.measure });
863
- if (at.dial?.unreachable !== true) posedTimes.add(time);
864
- if (previous !== null) {
865
- // ⚠️ A span whose end is a frame the runtime cannot reach has no
866
- // interpolation to scan: the anchors it would solve the quadratic
867
- // over are two poses of some other time. Counted, never silent.
868
- if (at.dial?.unreachable === true || previous.measure.dial?.unreachable === true) {
869
- spansNotScanned++;
838
+ /**
839
+ * What each of this animation's deform timelines is, and which skin holds
840
+ * its attachment — settled in one pass, BEFORE the frames loop (issue #583).
841
+ *
842
+ * 🚨 Once per timeline, not once per (timeline, skin, way-in). `timelines`,
843
+ * `notAMesh` and `exempted` are counts of the animation's own contents, and
844
+ * a skeleton with meshes in two skins would report each of them twice if the
845
+ * tally sat inside the loops below.
846
+ */
847
+ const placed: Array<{
848
+ timeline: DeformTimeline;
849
+ attachment: MeshAttachment;
850
+ triangles: ArrayLike<number>;
851
+ slotName: string;
852
+ placement: ReturnType<typeof placementOf>;
853
+ }> = [];
854
+ for (const timeline of anim.timelines) {
855
+ if (!(timeline instanceof DeformTimeline)) continue;
856
+ timelines++;
857
+ const attachment = timeline.attachment;
858
+ const slotName = data.slots[timeline.slotIndex]?.name ?? `#${timeline.slotIndex}`;
859
+ // A bounding box, a clipping polygon and a path all have a vertex array
860
+ // and NO triangles, so they have no winding to keep and no area to take a
861
+ // ratio of. Saying nothing about them beats inventing a measurement.
862
+ if (!(attachment instanceof MeshAttachment)) {
863
+ notAMesh.add(`"${slotName}"`);
864
+ continue;
865
+ }
866
+ if (exempt.has(slotName)) {
867
+ exempted.add(`"${slotName}"`);
868
+ continue;
869
+ }
870
+ const triangles = attachment.triangles;
871
+ if (!triangles || triangles.length < 3) continue; // A04 owns a mesh with no triangles
872
+ placed.push({
873
+ timeline,
874
+ attachment,
875
+ triangles,
876
+ slotName,
877
+ placement: placementOf(data, timeline.slotIndex, attachment),
878
+ });
879
+ }
880
+ if (placed.length === 0) continue;
881
+ // One pass per skin the animation's meshes live in, in the order its
882
+ // timelines name them. Every rig rigc compiles today has exactly one, and
883
+ // then this loop runs once and the keys come out in timeline order as they
884
+ // always have.
885
+ for (const skin of new Set(placed.map((p) => p.placement.holder))) {
886
+ // One pass per way in (issue #407). The animations nothing applies get the
887
+ // single track pass this loop has always been.
888
+ for (const dials of reachesUnder(skin).get(anim.name) ?? [null]) {
889
+ const poseFrame = (time: number): PoseOfFrame =>
890
+ dials === null
891
+ ? { posed: poseAt(data, skin, anim.name, time), dial: null }
892
+ : poseDial(data, skin, dials, time);
893
+ const reach = dials === null ? TRACK_REACH : dials.reach;
894
+ /**
895
+ * The key times this plan actually **reached**, for the reach comparison
896
+ * below (#427).
897
+ *
898
+ * ⚠️ A key the drive itself could not select is not in here. It is already
899
+ * named on the stats line as `deformKeysUnreachable`, with the ask and the
900
+ * bound, and the artifact's answer cannot reach it either — so listing it
901
+ * as something the artifact's answer missed would count one defect twice
902
+ * and inflate a disagreement with a frame the disagreement did not cost.
903
+ */
904
+ const posedTimes = new Set<number>();
905
+ for (const { timeline, attachment, triangles, slotName, placement } of placed) {
906
+ // Measured in the dress the format keys this timeline on, and only
907
+ // there: a mesh in another skin is another skin's pose (issue #583).
908
+ if (placement.holder !== skin) continue;
909
+ const named = {
910
+ animation: anim.name,
911
+ skin: placement.skin,
912
+ slot: slotName,
913
+ attachment: attachment.name,
914
+ placeholder: placement.placeholder,
915
+ };
916
+ /** The previous key's posed frame, kept so the span between them can be scanned. */
917
+ let previous: PosedFrame | null = null;
918
+ for (let frame = 0; frame < timeline.frames.length; frame++) {
919
+ const time = timeline.frames[frame];
920
+ const at = poseFrame(time);
921
+ const frameMeasure = measurePosed(at.posed, time, timeline.slotIndex, attachment, triangles, reach, at.dial);
922
+ // A key that draws no pixels — or one at a time no dial can select —
923
+ // is measured and then left out of the totals, because those totals
924
+ // are "what the gate ran on": A39 reads them onto its stats line and
925
+ // the report's rollup has to match them.
926
+ if (at.dial?.unreachable === true) {
927
+ notReachable++;
928
+ notReachableReversed += frameMeasure.measure.reversed.length;
929
+ } else if (frameMeasure.measure.draw.blank === null) {
930
+ trianglesMeasured += frameMeasure.measure.triangles;
931
+ collapsedTotal += frameMeasure.measure.collapsed;
870
932
  } else {
871
- spans.push(
872
- scanDeformSpan(
873
- anim,
874
- timeline,
875
- attachment,
876
- triangles,
877
- named,
878
- frame - 1,
879
- previous,
880
- frameMeasure,
881
- poseFrame,
882
- ),
883
- );
933
+ notDrawn++;
934
+ notDrawnReversed += frameMeasure.measure.reversed.length;
884
935
  }
936
+ keys.push({ ...named, key: frame, time, ...frameMeasure.measure });
937
+ if (at.dial?.unreachable !== true) posedTimes.add(time);
938
+ if (previous !== null) {
939
+ // ⚠️ A span whose end is a frame the runtime cannot reach has no
940
+ // interpolation to scan: the anchors it would solve the quadratic
941
+ // over are two poses of some other time. Counted, never silent.
942
+ if (at.dial?.unreachable === true || previous.measure.dial?.unreachable === true) {
943
+ spansNotScanned++;
944
+ } else {
945
+ spans.push(
946
+ scanDeformSpan(
947
+ anim,
948
+ timeline,
949
+ attachment,
950
+ triangles,
951
+ named,
952
+ frame - 1,
953
+ previous,
954
+ frameMeasure,
955
+ poseFrame,
956
+ ),
957
+ );
958
+ }
959
+ }
960
+ previous = frameMeasure;
885
961
  }
886
- previous = frameMeasure;
887
962
  }
888
- }
889
- // --- what the artifact's own answer could NOT have posed (issue #427) ---
890
- //
891
- // ⭐ Posed, not predicted. The survey builds a second plan out of the field
892
- // the SKELETON names and runs it through the same `poseDial` every real
893
- // frame goes through, so each entry is spine-core saying "no settable value
894
- // of this field lands me on that time" rather than this file inferring it
895
- // off an interval. Measured (#427): the list is empty whenever the two
896
- // answers reach the same span, and empty is the reading that says the
897
- // disagreement changed nothing about which frames were measured.
898
- //
899
- // 🚨 And nothing is reported at all about a dial that posed NOTHING — a
900
- // slider whose animation carries no deform timeline. `outside` would be
901
- // empty there for the one reason that must never print as agreement:
902
- // there were no frames to disagree about. A comparison of nothing and a
903
- // comparison that came out equal are the vacuous pass this file exists
904
- // to keep apart.
905
- if (posedTimes.size === 0) continue;
906
- // A plan is visited exactly once — a slider names one animation — so the
907
- // two lists need no de-duplication and come out in the skeleton's own
908
- // constraint order.
909
- if (dials?.tie) dialTies.push(dials.tie);
910
- if (dials?.dispute) dialDisputes.push(dials.dispute);
911
- if (dials?.dispute && dials.statedMap !== null) {
912
- const shadow: DialPlan = {
913
- ...dials,
914
- field: dials.statedMap.field,
915
- u0: dials.statedMap.u0,
916
- v0: dials.statedMap.v0,
917
- u1: dials.statedMap.u1,
918
- v1: dials.statedMap.v1,
919
- };
920
- // ⚠️ The one time posing cannot answer: a field that moves the reading by
921
- // NOTHING has no map to invert, so `poseDial` divides by zero and calls
922
- // every time out of bounds — including the setup time, which that field
923
- // reaches by being left alone. The reach says which one that is, and it
924
- // is a single point. Nothing in spine-core 4.3 has been measured getting
925
- // here (a parent at exactly 90° still moves a world x reading by 2.3e-8),
926
- // and a report that over-stated a disagreement by one key would be the
927
- // false red this file has paid for twice.
928
- const flat = dials.statedMap.v1 === dials.statedMap.v0;
929
- const only = dials.dispute.statedReach;
930
- const outside = [...posedTimes]
931
- .filter((time) =>
932
- flat
933
- ? only === null || Math.abs(time - only.lo) > DIAL_TIME_EPSILON
934
- : poseDial(data, shadow, time).dial?.unreachable === true,
935
- )
936
- .sort((a, b) => a - b);
937
- dials.dispute.outside.push(...outside);
963
+ // --- what the artifact's own answer could NOT have posed (issue #427) ---
964
+ //
965
+ // ⭐ Posed, not predicted. The survey builds a second plan out of the field
966
+ // the SKELETON names and runs it through the same `poseDial` every real
967
+ // frame goes through, so each entry is spine-core saying "no settable value
968
+ // of this field lands me on that time" rather than this file inferring it
969
+ // off an interval. Measured (#427): the list is empty whenever the two
970
+ // answers reach the same span, and empty is the reading that says the
971
+ // disagreement changed nothing about which frames were measured.
972
+ //
973
+ // 🚨 And nothing is reported at all about a dial that posed NOTHING — a
974
+ // slider whose animation carries no deform timeline. `outside` would be
975
+ // empty there for the one reason that must never print as agreement:
976
+ // there were no frames to disagree about. A comparison of nothing and a
977
+ // comparison that came out equal are the vacuous pass this file exists
978
+ // to keep apart.
979
+ if (posedTimes.size === 0) continue;
980
+ // A plan is visited once per (animation, skin) — see `dialsReported` for
981
+ // why one line per slider is the whole of it — so the two lists come out
982
+ // in the skeleton's own constraint order.
983
+ const alreadyReported = dials !== null && dialsReported.has(dials.slider.name);
984
+ if (dials !== null) dialsReported.add(dials.slider.name);
985
+ if (dials?.tie && !alreadyReported) dialTies.push(dials.tie);
986
+ if (dials?.dispute && !alreadyReported) dialDisputes.push(dials.dispute);
987
+ if (dials?.dispute && dials.statedMap !== null) {
988
+ const shadow: DialPlan = {
989
+ ...dials,
990
+ field: dials.statedMap.field,
991
+ u0: dials.statedMap.u0,
992
+ v0: dials.statedMap.v0,
993
+ u1: dials.statedMap.u1,
994
+ v1: dials.statedMap.v1,
995
+ };
996
+ // ⚠️ The one time posing cannot answer: a field that moves the reading by
997
+ // NOTHING has no map to invert, so `poseDial` divides by zero and calls
998
+ // every time out of bounds — including the setup time, which that field
999
+ // reaches by being left alone. The reach says which one that is, and it
1000
+ // is a single point. Nothing in spine-core 4.3 has been measured getting
1001
+ // here (a parent at exactly 90° still moves a world x reading by 2.3e-8),
1002
+ // and a report that over-stated a disagreement by one key would be the
1003
+ // false red this file has paid for twice.
1004
+ const flat = dials.statedMap.v1 === dials.statedMap.v0;
1005
+ const only = dials.dispute.statedReach;
1006
+ const outside = [...posedTimes]
1007
+ .filter((time) =>
1008
+ flat
1009
+ ? only === null || Math.abs(time - only.lo) > DIAL_TIME_EPSILON
1010
+ : poseDial(data, skin, shadow, time).dial?.unreachable === true,
1011
+ )
1012
+ .sort((a, b) => a - b);
1013
+ dials.dispute.outside.push(...outside);
1014
+ }
938
1015
  }
939
1016
  }
940
1017
  }
@@ -1181,6 +1258,57 @@ const DIAL_PROBE_MARGIN = 1e3;
1181
1258
  */
1182
1259
  const DIAL_DRIVE_LIMIT = 2 ** 24;
1183
1260
 
1261
+ /**
1262
+ * A fresh skeleton with `skin` worn, which is what every pose below starts from.
1263
+ *
1264
+ * ## Why a skin, and why this one
1265
+ *
1266
+ * A deform timeline is keyed on a `skin / slot / attachment` triple, so the mesh
1267
+ * it deforms belongs to exactly one skin — and a skeleton nobody dressed shows
1268
+ * only what `SkeletonData.defaultSkin` holds. Posing with no skin therefore read
1269
+ * a slot that showed **nothing** for every mesh an author had moved into a named
1270
+ * skin, and the whole survey then reported the rig as undrawn: `A39` went from
1271
+ * PASS to SKIP with a sentence that blamed the rig for what the measurement was
1272
+ * doing (issue #583). `placementOf` already recovers which skin holds a
1273
+ * timeline's attachment, so the pose can wear it.
1274
+ *
1275
+ * ## What `setSkin` changes, off the runtime rather than from memory
1276
+ *
1277
+ * `Skeleton.setSkinBySkin` (spine-core 4.3.13 `Skeleton.js:292-313`) puts the
1278
+ * skin's art into each slot's pose and calls `updateCache`, and `updateCache`
1279
+ * (`Skeleton.js:142-187`) is where the other two thirds live: a `skinRequired`
1280
+ * bone is `active` only if the worn skin lists it, and a `skinRequired`
1281
+ * constraint only if `skin.constraints` includes it. Both were measured on this
1282
+ * fixture before the repair and both were silent in their own way —
1283
+ *
1284
+ * - a slider whose driving bone is skin-required reads a world property that
1285
+ * **nothing moves** while the bone is inactive, so `planDial` found no
1286
+ * responding field, returned `null`, and the animation was reported as
1287
+ * *"played on a track"* — an animation only a slider ever applies;
1288
+ * - a skin-required slider **constraint** is left out of the update cache, so
1289
+ * `SliderPose.time` never leaves its setup value and every key came back as
1290
+ * *"at a time no dial selects"*.
1291
+ *
1292
+ * Each pose below then calls `setupPose()`, which re-resolves every slot's setup
1293
+ * attachment through `Skeleton.getAttachment` — the worn skin first, then
1294
+ * `defaultSkin` (`Skeleton.js:335-346`) — so wearing the skin before the pose is
1295
+ * the whole of what is needed and nothing has to be re-attached afterwards.
1296
+ *
1297
+ * ⭐ It takes the `Skin` **object**, not its name. `placementOf` found it by
1298
+ * identity, and `findSkin` resolves a name to the FIRST skin that carries it —
1299
+ * so a round trip through the name would hand the runtime a different skin on a
1300
+ * skeleton that declares two of one name, and there would be nothing in the
1301
+ * output to say so. `src/render.ts`'s `skeletonUnderSkin` takes a name because
1302
+ * its name came from `--skin` on the command line and refusing an unknown one
1303
+ * **by name, with the names that would have worked** is the whole of its job;
1304
+ * here there is no name to refuse and no lookup that can fail.
1305
+ */
1306
+ function skeletonUnderSkin(data: SkeletonData, skin: Skin | null): Skeleton {
1307
+ const skeleton = new Skeleton(data);
1308
+ if (skin !== null) skeleton.setSkin(skin);
1309
+ return skeleton;
1310
+ }
1311
+
1184
1312
  /**
1185
1313
  * Every way each animation is reached, keyed by animation name.
1186
1314
  *
@@ -1197,8 +1325,15 @@ const DIAL_DRIVE_LIMIT = 2 ** 24;
1197
1325
  * `slider.<name>.mix` from a playing animation, where the frame the deform keys
1198
1326
  * actually occur in is that playing animation's, and rigc has no way to know
1199
1327
  * which one that is.
1328
+ *
1329
+ * ⚠️ **Per skin, because whether a slider runs at all is per skin** (#583). A
1330
+ * `skinRequired` slider is out of the update cache under every skin that does
1331
+ * not list it, and a slider on a `skinRequired` bone reads a world property that
1332
+ * nothing moves there — so the same constraint plans differently depending on
1333
+ * what the skeleton is wearing, and the answer that matters is the one under the
1334
+ * skin holding the mesh being measured.
1200
1335
  */
1201
- function reachesOf(data: SkeletonData): Map<string, Array<DialPlan | null>> {
1336
+ function reachesOf(data: SkeletonData, skin: Skin | null): Map<string, Array<DialPlan | null>> {
1202
1337
  const out = new Map<string, Array<DialPlan | null>>();
1203
1338
  for (const anim of data.animations) out.set(anim.name, []);
1204
1339
  for (const constraint of data.constraints) {
@@ -1206,7 +1341,7 @@ function reachesOf(data: SkeletonData): Map<string, Array<DialPlan | null>> {
1206
1341
  if (constraint.setupPose.mix === 0) continue;
1207
1342
  const list = out.get(constraint.animation?.name ?? '');
1208
1343
  if (list === undefined) continue;
1209
- const plan = planDial(data, constraint);
1344
+ const plan = planDial(data, skin, constraint);
1210
1345
  if (plan !== null) list.push(plan);
1211
1346
  }
1212
1347
  for (const list of out.values()) if (list.length === 0) list.push(null);
@@ -1254,7 +1389,7 @@ function sliderOn(skeleton: Skeleton, data: SliderData): Slider | null {
1254
1389
  * ⛔ Nothing here falls back to `rotation`, or to any field, when the search does
1255
1390
  * not settle. Two answers that disagree are two answers, printed.
1256
1391
  */
1257
- function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
1392
+ function planDial(data: SkeletonData, skin: Skin | null, slider: SliderData): DialPlan | null {
1258
1393
  const boneName = slider.bone?.name ?? '?';
1259
1394
  const where = slider.local ? ' (local)' : ' (world)';
1260
1395
  const reach = (discovery: DialDiscovery | null): DeformReach => {
@@ -1290,7 +1425,7 @@ function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
1290
1425
  if (slider.bone === null) {
1291
1426
  return { slider, reach: reach(null), tie: null, dispute: null, field: null, u0: 0, v0: 0, u1: 1, v1: 1, statedMap: null };
1292
1427
  }
1293
- const skeleton = new Skeleton(data);
1428
+ const skeleton = skeletonUnderSkin(data, skin);
1294
1429
  const instance = sliderOn(skeleton, slider);
1295
1430
  const bone = instance?.bone ?? null;
1296
1431
  if (instance === null || bone === null) return null;
@@ -1497,14 +1632,14 @@ function sliderTimeFor(slider: SliderData, time: number): number {
1497
1632
  * against `sliderTimeFor` — spine-core's answer, not this function's. A time
1498
1633
  * no dial value selects is reported and never guessed at.
1499
1634
  */
1500
- function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame {
1635
+ function poseDial(data: SkeletonData, skin: Skin | null, plan: DialPlan, time: number): PoseOfFrame {
1501
1636
  const slider = plan.slider;
1502
1637
  const wanted = sliderTimeFor(slider, time);
1503
1638
  const value = plan.field === null ? time : slider.property.offset + (time - slider.offset) / slider.scale;
1504
- const posed = new Skeleton(data);
1639
+ const posed = skeletonUnderSkin(data, skin);
1505
1640
  const instance = sliderOn(posed, slider);
1506
1641
  const bone = instance?.bone ?? null;
1507
- if (instance === null) return { posed: poseAt(data, slider.animation.name, time), dial: null };
1642
+ if (instance === null) return { posed: poseAt(data, skin, slider.animation.name, time), dial: null };
1508
1643
  /** Pose with the driving field at `candidate`, and read both sides back. */
1509
1644
  const at = (candidate: number): { read: number; applied: number } => {
1510
1645
  posed.setupPose();
@@ -1586,8 +1721,8 @@ function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame
1586
1721
  * that is what lands the sample exactly ON `time` rather than one update short of
1587
1722
  * it, and it is why a probe between two keys is as trustworthy as a key.
1588
1723
  */
1589
- function poseAt(data: SkeletonData, animation: string, time: number): Skeleton {
1590
- const posed = new Skeleton(data);
1724
+ function poseAt(data: SkeletonData, skin: Skin | null, animation: string, time: number): Skeleton {
1725
+ const posed = skeletonUnderSkin(data, skin);
1591
1726
  const state = new AnimationState(new AnimationStateData(data));
1592
1727
  state.setAnimation(0, animation, false);
1593
1728
  posed.setupPose();
@@ -2266,10 +2401,34 @@ function shownAt(
2266
2401
  * `timelineSlots` of its own, so on a rigc-compiled skeleton the list is empty
2267
2402
  * and this loop runs zero times; it is here because a foreign skeleton reaching
2268
2403
  * `explain` is exactly where a silent false green would be unnoticeable.
2404
+ *
2405
+ * 🚨 **Every sentence here is about ONE skin — the one the skeleton is wearing**
2406
+ * (issue #583), which `under` names off `Skeleton.skin` rather than off whatever
2407
+ * the caller believes it set. That matters most in the loop below: a slot the
2408
+ * deform reaches through `timelineSlots` carries a LINKED mesh, a separate
2409
+ * attachment object that lives in a skin of its own, and `setSkin` dresses the
2410
+ * whole skeleton at once — so a linked copy whose skin is not the one worn here
2411
+ * resolves to nothing and `shownAt` reports alpha 0 for it, exactly as the
2412
+ * runtime would with that same skin on.
2413
+ *
2414
+ * ⛔ Not a reason to pose the key again under each of those other skins. Which
2415
+ * skin is worn is the consumer's, and a measurement taken under a skin the
2416
+ * timeline is not keyed on would be rigc composing a scene to make its own gate
2417
+ * green. What it must not do is leave the reader guessing which dress the
2418
+ * verdict was taken in, so the skin is in the sentence.
2269
2419
  */
2270
2420
  function drawOfKey(posed: Skeleton, slotIndex: number, attachment: MeshAttachment): DeformKeyDraw {
2271
2421
  const own = shownAt(posed, slotIndex, attachment);
2272
- const draw = { shown: own.shown?.name ?? null, showsThisMesh: own.showsThisMesh, alpha: own.alpha };
2422
+ const under = posed.skin?.name ?? null;
2423
+ const draw = { shown: own.shown?.name ?? null, showsThisMesh: own.showsThisMesh, alpha: own.alpha, under };
2424
+ // 🔒 On the "shows something else" branch ONLY, because that is the one branch
2425
+ // the skin decides: what a slot shows is resolved through the worn skin and
2426
+ // then `defaultSkin`, while an alpha is read off the pose and has no skin in
2427
+ // it. Before #583 the pose wore no skin at all, so a mesh in a named skin was
2428
+ // reported as a slot showing nothing — a true sentence about a pose nobody
2429
+ // would ever play, read as a claim about the rig. A clause on every branch
2430
+ // would be noise on the branch it cannot explain.
2431
+ const dress = under === null ? ', with no skin worn' : `, with skin "${under}" worn`;
2273
2432
  for (const other of attachment.timelineSlots) {
2274
2433
  if (other === slotIndex) continue;
2275
2434
  const there = shownAt(posed, other, attachment);
@@ -2285,8 +2444,8 @@ function drawOfKey(posed: Skeleton, slotIndex: number, attachment: MeshAttachmen
2285
2444
  return {
2286
2445
  ...draw,
2287
2446
  blank:
2288
- `the slot shows ${instead} at this time, not this mesh, so the runtime applies no deform to it here ` +
2289
- 'and draws none of it',
2447
+ `the slot shows ${instead} at this time${dress}, not this mesh, so the runtime applies no deform to it ` +
2448
+ 'here and draws none of it',
2290
2449
  };
2291
2450
  }
2292
2451
  if (own.alpha === 0) {
@@ -2311,19 +2470,31 @@ function drawOfKey(posed: Skeleton, slotIndex: number, attachment: MeshAttachmen
2311
2470
  * general: a skin puts its own attachment behind a shared placeholder, which is
2312
2471
  * the whole point of skins. So the scan compares the attachment object, and the
2313
2472
  * placeholder printed is the one the spec wrote.
2473
+ *
2474
+ * ⭐ The `Skin` it found comes back with the name, because that object is what
2475
+ * the pose wears (`skeletonUnderSkin`, issue #583). Handing the pose the name
2476
+ * instead would resolve it again through `SkeletonData.findSkin`, which returns
2477
+ * the FIRST skin of that name — a second resolution that can land somewhere else
2478
+ * on a skeleton declaring two, and land there silently.
2479
+ *
2480
+ * `holder` is `null` only when no skin carries this attachment at all. Nothing
2481
+ * reaches that through `SkeletonJson`, which resolves a deform timeline's
2482
+ * attachment out of a skin before it can build the timeline, so it is the
2483
+ * defensive branch and not a case: the pose then wears nothing and behaves
2484
+ * exactly as every pose here did before #583.
2314
2485
  */
2315
2486
  function placementOf(
2316
2487
  data: SkeletonData,
2317
2488
  slotIndex: number,
2318
2489
  attachment: MeshAttachment,
2319
- ): { skin: string; placeholder: string } {
2490
+ ): { skin: string; holder: Skin | null; placeholder: string } {
2320
2491
  for (const skin of [data.defaultSkin, ...data.skins]) {
2321
2492
  if (!skin) continue;
2322
2493
  const entries: Array<{ placeholder: string; attachment: unknown }> = [];
2323
2494
  skin.getAttachmentsForSlot(slotIndex, entries as Parameters<typeof skin.getAttachmentsForSlot>[1]);
2324
2495
  for (const entry of entries) {
2325
- if (entry.attachment === attachment) return { skin: skin.name, placeholder: entry.placeholder };
2496
+ if (entry.attachment === attachment) return { skin: skin.name, holder: skin, placeholder: entry.placeholder };
2326
2497
  }
2327
2498
  }
2328
- return { skin: 'default', placeholder: attachment.name };
2499
+ return { skin: 'default', holder: null, placeholder: attachment.name };
2329
2500
  }