spine-rigc 1.6.2 → 1.8.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.
Files changed (39) hide show
  1. package/cli.ts +166 -60
  2. package/docs/AUTHORING.md +25 -16
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a11.ts +15 -0
  6. package/src/assertions/bodies/a17.ts +26 -0
  7. package/src/assertions/bodies/a45.ts +172 -0
  8. package/src/assertions/facts/atlas_pages.ts +19 -0
  9. package/src/assertions/facts/skin_entries.ts +35 -0
  10. package/src/assertions/facts/slot_colour.ts +43 -0
  11. package/src/assertions/harness.ts +109 -0
  12. package/src/assertions/kinds.ts +102 -0
  13. package/src/assertions/model/atlas_pages.ts +15 -0
  14. package/src/assertions/model/index.ts +130 -0
  15. package/src/assertions/model/parse.ts +136 -0
  16. package/src/assertions/model/skin_entries.ts +58 -0
  17. package/src/assertions/model/slot_colour.ts +80 -0
  18. package/src/assertions/reasons.ts +127 -0
  19. package/src/assertions/values.ts +39 -0
  20. package/src/atlas.ts +26 -16
  21. package/src/check.ts +70 -47
  22. package/src/compile.ts +118 -93
  23. package/src/core/constraints_path.ts +60 -38
  24. package/src/core/constraints_physics.ts +22 -4
  25. package/src/core/index.ts +102 -15
  26. package/src/core/uvs.ts +25 -5
  27. package/src/core/world.ts +80 -24
  28. package/src/deformmeasure.ts +383 -157
  29. package/src/deformreport.ts +3 -0
  30. package/src/deformstructure.ts +335 -0
  31. package/src/emit.ts +17 -6
  32. package/src/ingest.ts +18 -13
  33. package/src/keyorder.ts +2 -2
  34. package/src/model.ts +114 -13
  35. package/src/render.ts +520 -59
  36. package/src/render_core.ts +178 -21
  37. package/src/transform.ts +113 -128
  38. package/src/types.ts +6 -5
  39. package/src/validate.ts +200 -490
@@ -117,6 +117,19 @@ import {
117
117
  // #969: the core's side of the seam — the model document read, and the survey's hooks (`./core/hooks.ts`).
118
118
  import { CoreInputError, readModel, underSkin, type CompiledDocument } from './core/index.ts';
119
119
  import { float32Rows, meshWorld, poseDial as corePoseDial, poseJump, recordIdentity, sliderRecordOf, slotDraw, type CoreSurveyPose } from './core/hooks.ts';
120
+ // #1019: what the survey reads of the skeleton's structure, and the model document's reading of it.
121
+ import {
122
+ modelStructure,
123
+ type SurveyAnimation,
124
+ type SurveyCurve,
125
+ type SurveyDeformTimeline,
126
+ type SurveyMesh,
127
+ type SurveySkin,
128
+ type SurveySlider,
129
+ type SurveyStructure,
130
+ } from './deformstructure.ts';
131
+ import { spineFileSha256 } from './model.ts';
132
+ import { SpineRuntimeError } from './render.ts';
120
133
 
121
134
  /**
122
135
  * How near zero a triangle's area has to be, as a fraction of the largest
@@ -810,16 +823,19 @@ export function skeletonDataFromText(skeletonText: string, atlasText: string): S
810
823
  * printed; what it is not is gated.
811
824
  */
812
825
  export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string> = new Set()): DeformSurvey {
813
- return { ...surveyWith(data, exempt, spinePoser(data)), source: { used: 'spine-core', why: null } };
826
+ const side = runtimeSide(data);
827
+ return { ...surveyWith(side.structure, exempt, side.poser), source: { used: 'spine-core', why: null } };
814
828
  }
815
829
 
816
830
  /**
817
- * The survey through one poser (issue #969): `surveyDeformKeys`' whole body,
818
- * with every pose taken through `poser` — spine-core's or the core's. What it
819
- * reads off `data` is the skeleton's structure (its timelines, keys, curves,
820
- * sliders and skins), which is data and not a pose.
831
+ * The survey through one reader and one poser (issues #969, #1019):
832
+ * `surveyDeformKeys`' whole body, with the skeleton's structure — its
833
+ * timelines, keys, curves, sliders and skins — read through `structure`
834
+ * (`./deformstructure.ts`), and every pose taken through `poser`. The two come
835
+ * in pairs: spine-core's reading of the Spine skeleton with spine-core's
836
+ * poses, or the model document's with the core's.
821
837
  */
822
- function surveyWith(data: SkeletonData, exempt: ReadonlySet<string>, poser: SurveyPoser): Omit<DeformSurvey, 'source'> {
838
+ function surveyWith(structure: SurveyStructure, exempt: ReadonlySet<string>, poser: SurveyPoser): Omit<DeformSurvey, 'source'> {
823
839
  const keys: DeformKeyMeasure[] = [];
824
840
  const spans: DeformSpan[] = [];
825
841
  const exempted = new Set<string>();
@@ -837,15 +853,16 @@ function surveyWith(data: SkeletonData, exempt: ReadonlySet<string>, poser: Surv
837
853
  /**
838
854
  * `reachesOf` under one skin, computed once per skin the survey reaches.
839
855
  *
840
- * ⚠️ Keyed on the `Skin` OBJECT, which is what `placementOf` recovered. A map
841
- * keyed on the name would fold two same-named skins into one entry and hand
842
- * the second one the first's plans (#583).
856
+ * ⚠️ Keyed on the skin's HANDLE, which is what `placementOf` recovered — one
857
+ * per skin, whichever reader made it. A map keyed on the name would fold two
858
+ * same-named skins into one entry and hand the second one the first's plans
859
+ * (#583).
843
860
  */
844
- const reachCache = new Map<Skin | null, Map<string, Array<DialPlan | null>>>();
845
- const reachesUnder = (skin: Skin | null): Map<string, Array<DialPlan | null>> => {
861
+ const reachCache = new Map<SurveySkin | null, Map<string, Array<DialPlan | null>>>();
862
+ const reachesUnder = (skin: SurveySkin | null): Map<string, Array<DialPlan | null>> => {
846
863
  const already = reachCache.get(skin);
847
864
  if (already !== undefined) return already;
848
- const built = reachesOf(data, skin, poser);
865
+ const built = reachesOf(structure, skin, poser);
849
866
  reachCache.set(skin, built);
850
867
  return built;
851
868
  };
@@ -861,7 +878,7 @@ function surveyWith(data: SkeletonData, exempt: ReadonlySet<string>, poser: Surv
861
878
  * refused for putting a constraint or a bone in two skins.
862
879
  */
863
880
  const dialsReported = new Set<string>();
864
- for (const anim of data.animations) {
881
+ for (const anim of structure.animations) {
865
882
  /**
866
883
  * What each of this animation's deform timelines is, and which skin holds
867
884
  * its attachment — settled in one pass, BEFORE the frames loop (issue #583).
@@ -872,21 +889,20 @@ function surveyWith(data: SkeletonData, exempt: ReadonlySet<string>, poser: Surv
872
889
  * tally sat inside the loops below.
873
890
  */
874
891
  const placed: Array<{
875
- timeline: DeformTimeline;
876
- attachment: MeshAttachment;
892
+ timeline: SurveyDeformTimeline;
893
+ attachment: SurveyMesh;
877
894
  triangles: ArrayLike<number>;
878
895
  slotName: string;
879
- placement: ReturnType<typeof placementOf>;
896
+ placement: ReturnType<SurveyDeformTimeline['placement']>;
880
897
  }> = [];
881
- for (const timeline of anim.timelines) {
882
- if (!(timeline instanceof DeformTimeline)) continue;
898
+ for (const timeline of anim.deforms) {
883
899
  timelines++;
884
- const attachment = timeline.attachment;
885
- const slotName = data.slots[timeline.slotIndex]?.name ?? `#${timeline.slotIndex}`;
900
+ const attachment = timeline.mesh;
901
+ const slotName = structure.slotName(timeline.slotIndex);
886
902
  // A bounding box, a clipping polygon and a path all have a vertex array
887
903
  // and NO triangles, so they have no winding to keep and no area to take a
888
904
  // ratio of. Saying nothing about them beats inventing a measurement.
889
- if (!(attachment instanceof MeshAttachment)) {
905
+ if (attachment === null) {
890
906
  notAMesh.add(`"${slotName}"`);
891
907
  continue;
892
908
  }
@@ -901,7 +917,7 @@ function surveyWith(data: SkeletonData, exempt: ReadonlySet<string>, poser: Surv
901
917
  attachment,
902
918
  triangles,
903
919
  slotName,
904
- placement: placementOf(data, timeline.slotIndex, attachment),
920
+ placement: timeline.placement(),
905
921
  });
906
922
  }
907
923
  if (placed.length === 0) continue;
@@ -1180,7 +1196,7 @@ interface DialDiscovery {
1180
1196
  * measured off the runtime rather than assumed.
1181
1197
  */
1182
1198
  interface DialPlan {
1183
- slider: SliderData;
1199
+ slider: SurveySlider;
1184
1200
  reach: DeformReach;
1185
1201
  /**
1186
1202
  * The probe's tie, when it had one — the survey collects these so a `build`
@@ -1360,15 +1376,14 @@ function skeletonUnderSkin(data: SkeletonData, skin: Skin | null): Skeleton {
1360
1376
  * what the skeleton is wearing, and the answer that matters is the one under the
1361
1377
  * skin holding the mesh being measured.
1362
1378
  */
1363
- function reachesOf(data: SkeletonData, skin: Skin | null, poser: SurveyPoser): Map<string, Array<DialPlan | null>> {
1379
+ function reachesOf(structure: SurveyStructure, skin: SurveySkin | null, poser: SurveyPoser): Map<string, Array<DialPlan | null>> {
1364
1380
  const out = new Map<string, Array<DialPlan | null>>();
1365
- for (const anim of data.animations) out.set(anim.name, []);
1366
- for (const constraint of data.constraints) {
1367
- if (!(constraint instanceof SliderData)) continue;
1368
- if (constraint.setupPose.mix === 0) continue;
1369
- const list = out.get(constraint.animation?.name ?? '');
1381
+ for (const anim of structure.animations) out.set(anim.name, []);
1382
+ for (const slider of structure.sliders) {
1383
+ if (slider.mix === 0) continue;
1384
+ const list = out.get(slider.animation);
1370
1385
  if (list === undefined) continue;
1371
- const plan = planDial(poser, skin, constraint);
1386
+ const plan = planDial(poser, skin, slider);
1372
1387
  if (plan !== null) list.push(plan);
1373
1388
  }
1374
1389
  for (const list of out.values()) if (list.length === 0) list.push(null);
@@ -1416,15 +1431,15 @@ function sliderOn(skeleton: Skeleton, data: SliderData): Slider | null {
1416
1431
  * ⛔ Nothing here falls back to `rotation`, or to any field, when the search does
1417
1432
  * not settle. Two answers that disagree are two answers, printed.
1418
1433
  */
1419
- function planDial(poser: SurveyPoser, skin: Skin | null, slider: SliderData): DialPlan | null {
1420
- const boneName = slider.bone?.name ?? '?';
1434
+ function planDial(poser: SurveyPoser, skin: SurveySkin | null, slider: SurveySlider): DialPlan | null {
1435
+ const boneName = slider.bone ?? '?';
1421
1436
  const where = slider.local ? ' (local)' : ' (world)';
1422
1437
  const reach = (discovery: DialDiscovery | null): DeformReach => {
1423
1438
  if (discovery === null) {
1424
1439
  return {
1425
1440
  kind: 'slider',
1426
1441
  slider: slider.name,
1427
- bone: slider.bone?.name ?? null,
1442
+ bone: slider.bone,
1428
1443
  property: null,
1429
1444
  drive: null,
1430
1445
  local: slider.local,
@@ -1438,7 +1453,7 @@ function planDial(poser: SurveyPoser, skin: Skin | null, slider: SliderData): Di
1438
1453
  return {
1439
1454
  kind: 'slider',
1440
1455
  slider: slider.name,
1441
- bone: slider.bone?.name ?? null,
1456
+ bone: slider.bone,
1442
1457
  property,
1443
1458
  drive,
1444
1459
  local: slider.local,
@@ -1474,14 +1489,14 @@ function planDial(poser: SurveyPoser, skin: Skin | null, slider: SliderData): Di
1474
1489
  // that broke ties would put the arbitrary choice back where nothing sees it.
1475
1490
  probes.sort((a, b) => b.response - a.response);
1476
1491
  const winner = probes[0];
1477
- const stated = readerField(slider.property);
1492
+ const stated = slider.stated;
1478
1493
  const leaders = probes.filter((p) => p.response * DIAL_PROBE_MARGIN >= winner.response);
1479
1494
  const decisive = leaders.length === 1;
1480
1495
  const chosen = decisive || stated === null ? winner : (leaders.find((p) => p.field === stated) ?? winner);
1481
1496
  const verdict: DialVerdict = chosen.field === stated ? (decisive ? 'agreed' : 'settled') : 'disagreed';
1482
1497
  const discovery: DialDiscovery = {
1483
1498
  stated,
1484
- reader: slider.property.constructor.name,
1499
+ reader: slider.reader,
1485
1500
  drive: chosen.field,
1486
1501
  driveResponse: chosen.response,
1487
1502
  rivals: leaders.filter((p) => p.field !== chosen.field).map((p) => ({ field: p.field, response: p.response })),
@@ -1550,15 +1565,15 @@ function planDial(poser: SurveyPoser, skin: Skin | null, slider: SliderData): Di
1550
1565
  * interval contains. That is why the frames a disputed dial could not have posed
1551
1566
  * are POSED rather than read off here.
1552
1567
  */
1553
- function dialReachOf(slider: SliderData, map: DialProbe): DialSpan | null {
1568
+ function dialReachOf(slider: SurveySlider, map: DialProbe): DialSpan | null {
1554
1569
  const timeAt = (u: number): number => {
1555
1570
  const value = map.v0 + ((u - map.u0) * (map.v1 - map.v0)) / (map.u1 - map.u0);
1556
- return slider.offset + (value - slider.property.offset) * slider.scale;
1571
+ return slider.to + (value - slider.from) * slider.scale;
1557
1572
  };
1558
1573
  const ends = [timeAt(-DIAL_DRIVE_LIMIT), timeAt(DIAL_DRIVE_LIMIT)];
1559
1574
  if (!ends.every((t) => Number.isFinite(t))) return null;
1560
1575
  const lo = Math.max(0, Math.min(ends[0], ends[1]));
1561
- const hi = Math.min(slider.animation.duration, Math.max(ends[0], ends[1]));
1576
+ const hi = Math.min(slider.duration, Math.max(ends[0], ends[1]));
1562
1577
  return lo <= hi ? { lo, hi } : null;
1563
1578
  }
1564
1579
 
@@ -1610,14 +1625,17 @@ interface PoseOfFrame {
1610
1625
  /**
1611
1626
  * What `Slider.update` stores in `SliderPose.time` for a wanted animation time.
1612
1627
  *
1613
- * The two clamps at the end of its bone branch, transcribed, because the
1614
- * verification below compares against what the runtime STORED and not against
1615
- * what was asked for. ⚠️ `loop` on a zero-length animation gives NaN here exactly
1628
+ * On a slider driven by a bone: the time wrapped into the animation under
1629
+ * `loop`, else held at 0 or above. Not assumed — `poseDial`'s step 3 reads
1630
+ * `SliderPose.time` off the skeleton each time it poses a dial and compares
1631
+ * it with this, and a disagreement is reported, never absorbed. It is stated at all because that
1632
+ * verification compares against what the runtime STORED and not against what
1633
+ * was asked for. ⚠️ `loop` on a zero-length animation gives NaN here exactly
1616
1634
  * as it does there, which is `A37_SLIDER_CONSTRAINT_EFFECTIVE`'s refusal.
1617
1635
  */
1618
- function sliderTimeFor(slider: SliderData, time: number): number {
1636
+ function sliderTimeFor(slider: SurveySlider, time: number): number {
1619
1637
  if (slider.bone === null) return time;
1620
- if (slider.loop) return slider.animation.duration + (time % slider.animation.duration);
1638
+ if (slider.loop) return slider.duration + (time % slider.duration);
1621
1639
  return Math.max(0, time);
1622
1640
  }
1623
1641
 
@@ -1643,12 +1661,12 @@ function sliderTimeFor(slider: SliderData, time: number): number {
1643
1661
  * against `sliderTimeFor` — spine-core's answer, not this function's. A time
1644
1662
  * no dial value selects is reported and never guessed at.
1645
1663
  */
1646
- function poseDial(poser: SurveyPoser, skin: Skin | null, plan: DialPlan, time: number): PoseOfFrame {
1664
+ function poseDial(poser: SurveyPoser, skin: SurveySkin | null, plan: DialPlan, time: number): PoseOfFrame {
1647
1665
  const slider = plan.slider;
1648
1666
  const wanted = sliderTimeFor(slider, time);
1649
- const value = plan.field === null ? time : slider.property.offset + (time - slider.offset) / slider.scale;
1667
+ const value = plan.field === null ? time : slider.from + (time - slider.to) / slider.scale;
1650
1668
  const session = poser.dial(skin, slider);
1651
- if (session === null) return { posed: poser.track(skin, slider.animation.name, time), dial: null };
1669
+ if (session === null) return { posed: poser.track(skin, slider.animation, time), dial: null };
1652
1670
  /** Pose with the driving field at `candidate`, and read both sides back. */
1653
1671
  const field = session.hasBone ? plan.field : null;
1654
1672
  const at = (candidate: number): { read: number; applied: number } => session.at(field, candidate);
@@ -1759,7 +1777,7 @@ function measurePosed(
1759
1777
  posed: SurveyPose,
1760
1778
  time: number,
1761
1779
  slotIndex: number,
1762
- attachment: MeshAttachment,
1780
+ attachment: SurveyMesh,
1763
1781
  triangles: ArrayLike<number>,
1764
1782
  reach: DeformReach,
1765
1783
  dial: DeformDial | null,
@@ -1898,33 +1916,37 @@ interface CurveLeg {
1898
1916
  * The runtime's own interpolation fraction over one span, as a polyline in
1899
1917
  * `(time, fraction)`.
1900
1918
  *
1901
- * ⭐ **Read off `timeline.curves`, not re-derived from the cubic.**
1902
- * `DeformTimeline.getCurvePercent` evaluates a bezier by walking the ten points
1903
- * the parser sampled into that array and interpolating *linearly* between them —
1904
- * so the polyline below is not an approximation of what the runtime does, it is
1905
- * what the runtime does. A curve that overshoots (a fraction below 0 or above 1,
1906
- * which the format allows and `back`-style easings produce) is therefore inside
1907
- * this rather than assumed away.
1919
+ * ⭐ **The points the runtime stores, not the cubic.**
1920
+ * `DeformTimeline.getCurvePercent` evaluates a bezier by walking the points the
1921
+ * parser sampled into its curve storage and interpolating *linearly* between
1922
+ * them — so the polyline below is not an approximation of what the runtime does,
1923
+ * it is what the runtime does. A curve that overshoots (a fraction below 0 or
1924
+ * above 1, which the format allows and `back`-style easings produce) is therefore
1925
+ * inside this rather than assumed away. Which reader supplies the points is the
1926
+ * structure's (`SurveyCurve`, issue #1019): spine-core's reads that storage
1927
+ * (`curveStorage`); the model document's computes the same nine from the key's
1928
+ * handles with the core's deform curve (`./deformstructure.ts`), measured equal
1929
+ * point for point on every corpus Bézier.
1908
1930
  */
1909
- function curveLegs(timeline: DeformTimeline, frame: number): { kind: DeformSpanCurve; legs: CurveLeg[] } {
1931
+ function curveLegs(timeline: SurveyDeformTimeline, frame: number): { kind: DeformSpanCurve; legs: CurveLeg[] } {
1910
1932
  const t0 = timeline.frames[frame];
1911
1933
  const t1 = timeline.frames[frame + 1];
1912
- const curves = curveStorage(timeline);
1913
- const code = curves[frame];
1914
- // 1 is STEPPED: `getCurvePercent` returns a flat 0 across the whole span, so
1915
- // the runtime holds the earlier key's geometry and interpolates nothing.
1916
- if (code === 1) return { kind: 'stepped', legs: [{ t0, p0: 0, t1, p1: 0 }] };
1917
- if (code === 0) return { kind: 'linear', legs: [{ t0, p0: 0, t1, p1: 1 }] };
1918
- // 2 + i is BEZIER, with the sampled points starting at `i`. Nine of them are
1919
- // stored; the runtime ramps into the first from (t0, 0) and out of the last to
1920
- // (t1, 1), which is the two legs added either side.
1934
+ const curve = timeline.curve(frame);
1935
+ // STEPPED: `getCurvePercent` returns a flat 0 across the whole span, so the
1936
+ // runtime holds the earlier key's geometry and interpolates nothing.
1937
+ if (curve.kind === 'stepped') return { kind: 'stepped', legs: [{ t0, p0: 0, t1, p1: 0 }] };
1938
+ if (curve.kind === 'linear') return { kind: 'linear', legs: [{ t0, p0: 0, t1, p1: 1 }] };
1939
+ // BEZIER: nine sampled points are stored; the runtime ramps into the first
1940
+ // from (t0, 0) and out of the last to (t1, 1), which is the two legs added
1941
+ // either side.
1942
+ const points = curve.points;
1921
1943
  const legs: CurveLeg[] = [];
1922
1944
  let x = t0;
1923
1945
  let y = 0;
1924
- for (let i = code - 2, n = code - 2 + BEZIER_POINTS * 2; i < n; i += 2) {
1925
- legs.push({ t0: x, p0: y, t1: curves[i], p1: curves[i + 1] });
1926
- x = curves[i];
1927
- y = curves[i + 1];
1946
+ for (let i = 0; i < BEZIER_POINTS * 2; i += 2) {
1947
+ legs.push({ t0: x, p0: y, t1: points[i], p1: points[i + 1] });
1948
+ x = points[i];
1949
+ y = points[i + 1];
1928
1950
  }
1929
1951
  legs.push({ t0: x, p0: y, t1, p1: 1 });
1930
1952
  return { kind: 'bezier', legs };
@@ -1948,6 +1970,16 @@ export const BEZIER_POINTS = 9;
1948
1970
  * thing this file's opening paragraph forbids — and would then be checking
1949
1971
  * rigc's copy of spine-core's maths rather than spine-core's.
1950
1972
  *
1973
+ * ⚠️ That alternative is what the model document's reader does since issue
1974
+ * #1019, and the objection does not reach it, because it is not a copy written
1975
+ * here: it is the core's own deform curve (`bezierPolyline` with
1976
+ * `DEFORM_CURVE_END`, `./core/deform.ts`), the one the core POSES with, measured
1977
+ * against the runtime there. On a build the core poses, the pose and the span
1978
+ * scan read one derivation rather than two, and `DM12` (the gallery) and
1979
+ * `tools/survey_hashes.ts structure` (every corpus row) hold its points to this
1980
+ * array's. What spine-core's reader of the survey reads
1981
+ * is still this array.
1982
+ *
1951
1983
  * `A05` already gates the emitted curve arrays, and `DW18` is the control that
1952
1984
  * the reading here matches what the runtime does with them.
1953
1985
  *
@@ -2091,7 +2123,7 @@ function wrongSignFractions(
2091
2123
  return a > 0 ? clip(left, right) : [...clip(-Infinity, left), ...clip(right, Infinity)];
2092
2124
  }
2093
2125
 
2094
- /**
2126
+ /*
2095
2127
  * The times at which the slot's **visibility** can change across a span.
2096
2128
  *
2097
2129
  * ⚠️ This is the half of issue #403 that is about the fade rather than the fold,
@@ -2106,21 +2138,11 @@ function wrongSignFractions(
2106
2138
  * window at those times leaves pieces on which "does this draw?" has one answer,
2107
2139
  * and the probe inside a piece is representative of the whole piece.
2108
2140
  *
2109
- * Every timeline carrying a `slotIndex` counts, for this slot and for any other
2110
- * slot the deform reaches (`timelineSlots`) — duck-typed rather than matched
2111
- * against a list of classes, because a list is a thing that goes stale when the
2112
- * format grows a timeline and the failure would be silent.
2141
+ * Every timeline filed under this slot, or under any other slot the deform
2142
+ * reaches (`timelineSlots`), counts — `SurveyAnimation.slotKeyTimes`, which each
2143
+ * reader answers (`visibilityKeyTimes` below for spine-core's; the model
2144
+ * document's slot and attachment timelines for the core's).
2113
2145
  */
2114
- function visibilityKeyTimes(timelines: readonly Timeline[], slots: ReadonlySet<number>): number[] {
2115
- const times: number[] = [];
2116
- for (const timeline of timelines) {
2117
- const carrier = timeline as unknown as { slotIndex?: unknown };
2118
- if (typeof carrier.slotIndex !== 'number' || !slots.has(carrier.slotIndex)) continue;
2119
- const entries = timeline.getFrameEntries();
2120
- for (let i = 0; i < timeline.getFrameCount(); i++) times.push(timeline.frames[i * entries]);
2121
- }
2122
- return times;
2123
- }
2124
2146
 
2125
2147
  /**
2126
2148
  * Scan the interval between two consecutive deform keys (issue #403).
@@ -2176,9 +2198,9 @@ function visibilityKeyTimes(timelines: readonly Timeline[], slots: ReadonlySet<n
2176
2198
  * being refused anyway.
2177
2199
  */
2178
2200
  function scanDeformSpan(
2179
- anim: { name: string; timelines: Timeline[] },
2180
- timeline: DeformTimeline,
2181
- attachment: MeshAttachment,
2201
+ anim: SurveyAnimation,
2202
+ timeline: SurveyDeformTimeline,
2203
+ attachment: SurveyMesh,
2182
2204
  triangles: ArrayLike<number>,
2183
2205
  named: { animation: string; skin: string; slot: string; attachment: string; placeholder: string },
2184
2206
  /** The index of the key the span starts at — `frame + 1` is the one it ends at. */
@@ -2208,8 +2230,8 @@ function scanDeformSpan(
2208
2230
  unconfirmed: false,
2209
2231
  };
2210
2232
  const reach = reachedFractions(legs);
2211
- const v1 = timeline.vertices[frame];
2212
- const v2 = timeline.vertices[frame + 1];
2233
+ const v1 = timeline.vertices(frame);
2234
+ const v2 = timeline.vertices(frame + 1);
2213
2235
  if (!v1 || !v2) return span;
2214
2236
 
2215
2237
  const windows: Interval[] = [];
@@ -2225,7 +2247,7 @@ function scanDeformSpan(
2225
2247
  // both of the span's own key poses are taken and the union used. `DW17` is
2226
2248
  // the control for the claim: a bone rotation across the span moves nothing
2227
2249
  // about where an unweighted mesh is found to fold.
2228
- for (const anchor of attachment.bones === null ? [from] : [from, to]) {
2250
+ for (const anchor of attachment.weighted ? [from, to] : [from]) {
2229
2251
  // The two ends of the straight line every vertex travels, evaluated at THIS
2230
2252
  // anchor's bones. `measurePosed` has already emptied the slot's deform array
2231
2253
  // to take its plain side, so writing into it is how the two are posed.
@@ -2278,7 +2300,7 @@ function scanDeformSpan(
2278
2300
  // straddles a change in what is drawn. Every point of a merged window is
2279
2301
  // inside at least one triangle's window, so its middle is a fold and not a gap.
2280
2302
  const slots = new Set<number>([timeline.slotIndex, ...attachment.timelineSlots]);
2281
- const cuts = visibilityKeyTimes(anim.timelines, slots);
2303
+ const cuts = anim.slotKeyTimes(slots);
2282
2304
  const pieces: Interval[] = [];
2283
2305
  for (const window of mergeIntervals(windows)) {
2284
2306
  const inner = [...new Set(cuts.filter((t) => t > window.lo && t < window.hi))].sort((x, y) => x - y);
@@ -2410,7 +2432,7 @@ function shownAt(posed: Skeleton, slotIndex: number, attachment: MeshAttachment)
2410
2432
  * green. What it must not do is leave the reader guessing which dress the
2411
2433
  * verdict was taken in, so the skin is in the sentence.
2412
2434
  */
2413
- function drawOfKey(posed: SurveyPose, slotIndex: number, attachment: MeshAttachment): DeformKeyDraw {
2435
+ function drawOfKey(posed: SurveyPose, slotIndex: number, attachment: SurveyMesh): DeformKeyDraw {
2414
2436
  const own = posed.shownAt(slotIndex, attachment);
2415
2437
  const under = posed.under();
2416
2438
  const draw = { shown: own.shown, showsThisMesh: own.showsThisMesh, alpha: own.alpha, under };
@@ -2479,7 +2501,7 @@ function drawOfKey(posed: SurveyPose, slotIndex: number, attachment: MeshAttachm
2479
2501
  function placementOf(
2480
2502
  data: SkeletonData,
2481
2503
  slotIndex: number,
2482
- attachment: MeshAttachment,
2504
+ attachment: Attachment,
2483
2505
  ): { skin: string; holder: Skin | null; placeholder: string } {
2484
2506
  for (const skin of [data.defaultSkin, ...data.skins]) {
2485
2507
  if (!skin) continue;
@@ -2500,8 +2522,12 @@ function placementOf(
2500
2522
  // spine-core over the Spine skeleton (the survey as it always was, and
2501
2523
  // `validate.ts`'s A39, which hands it the SkeletonData it round-tripped), and
2502
2524
  // rigc's own core over the model document (`./core/hooks.ts`, issue #969),
2503
- // which a build carries and `explain` hands in. The survey's record names which
2504
- // one it used (`DeformSurvey.source`), and `tools/survey_hashes.ts` holds the
2525
+ // which a build carries and `explain` hands in. Since issue #1019 the
2526
+ // structure comes in the same two kinds, paired with the posers: spine-core's
2527
+ // parse read by `runtimeSide` below, and the model document read by
2528
+ // `modelStructure` (`./deformstructure.ts`) — so a build the core poses is
2529
+ // surveyed without touching the runtime at all. The survey's record names which
2530
+ // pair it used (`DeformSurvey.source`), and `tools/survey_hashes.ts` holds the
2505
2531
  // two to one survey, byte for byte, on every corpus.
2506
2532
 
2507
2533
  /** Which poser a survey used (`DeformSurvey.source`). */
@@ -2528,19 +2554,19 @@ export interface ShownReading {
2528
2554
  export interface SurveyPose {
2529
2555
  /** The skin the skeleton wears, off the pose itself (`DeformKeyDraw.under`). */
2530
2556
  under(): string | null;
2531
- shownAt(slotIndex: number, attachment: MeshAttachment): ShownReading;
2557
+ shownAt(slotIndex: number, attachment: SurveyMesh): ShownReading;
2532
2558
  /** The mesh's world vertices with the slot's deform as posed. */
2533
- deformed(slotIndex: number, attachment: MeshAttachment): Float32Array;
2559
+ deformed(slotIndex: number, attachment: SurveyMesh): Float32Array;
2534
2560
  /** The same bones with the slot's deform cleared. */
2535
- plain(slotIndex: number, attachment: MeshAttachment): Float32Array;
2561
+ plain(slotIndex: number, attachment: SurveyMesh): Float32Array;
2536
2562
  /** The same bones with `deform` written into the slot. */
2537
- withDeform(slotIndex: number, attachment: MeshAttachment, deform: ArrayLike<number>): Float32Array;
2563
+ withDeform(slotIndex: number, attachment: SurveyMesh, deform: ArrayLike<number>): Float32Array;
2538
2564
  /**
2539
2565
  * The doubles those three are stored from — the slot's deform as posed,
2540
2566
  * cleared, or replaced — leaving the pose as it found it. The survey reads
2541
2567
  * the float32 rows; this is what measures the rows' source (`tools/survey_hashes.ts hooks`).
2542
2568
  */
2543
- rows(slotIndex: number, attachment: MeshAttachment, deform: 'posed' | 'cleared' | ArrayLike<number>): number[];
2569
+ rows(slotIndex: number, attachment: SurveyMesh, deform: 'posed' | 'cleared' | ArrayLike<number>): number[];
2544
2570
  }
2545
2571
 
2546
2572
  /** One slider's dial, posed again and again (`planDial`'s probe, `poseDial`'s solve). */
@@ -2557,23 +2583,186 @@ export interface DialSession {
2557
2583
 
2558
2584
  /** The two ways the survey poses: a jump on a track, and a slider's dial. */
2559
2585
  export interface SurveyPoser {
2560
- track(skin: Skin | null, animation: string, time: number): SurveyPose;
2586
+ track(skin: SurveySkin | null, animation: string, time: number): SurveyPose;
2561
2587
  /** `null` when the skeleton carries no such slider. */
2562
- dial(skin: Skin | null, slider: SliderData): DialSession | null;
2588
+ dial(skin: SurveySkin | null, slider: SurveySlider): DialSession | null;
2563
2589
  }
2564
2590
 
2591
+ // --- #1019 the runtime's reading of the structure: begin ---
2592
+ //
2593
+ // `src/deformstructure.ts` states what the survey reads of a skeleton's
2594
+ // structure and gives the model document's reading of it. This is the other
2595
+ // one — the survey's reads as they always were, off spine-core's parsed
2596
+ // `SkeletonData`, moved behind the interface unchanged — and the spine-core
2597
+ // poser over the same objects. The two are made together because the poser
2598
+ // needs the parsed objects the handles stand for: a handle the model
2599
+ // document's reader made carries none, and is refused here by name.
2600
+
2601
+ /** The parsed objects behind the runtime reader's handles — what spine-core's poser is handed. */
2602
+ interface RuntimeLinks {
2603
+ skin(handle: SurveySkin | null): Skin | null;
2604
+ mesh(handle: SurveyMesh): MeshAttachment;
2605
+ slider(handle: SurveySlider): SliderData;
2606
+ }
2607
+
2608
+ /**
2609
+ * The slot timelines' key times on `slots` — every timeline carrying a
2610
+ * `slotIndex`, duck-typed (see `scanDeformSpan`'s visibility split).
2611
+ *
2612
+ * ⭐ Duck-typed rather than matched against a list of classes, because a list
2613
+ * is a thing that goes stale when the format grows a timeline and the failure
2614
+ * would be silent.
2615
+ */
2616
+ function visibilityKeyTimes(timelines: readonly Timeline[], slots: ReadonlySet<number>): number[] {
2617
+ const times: number[] = [];
2618
+ for (const timeline of timelines) {
2619
+ const carrier = timeline as unknown as { slotIndex?: unknown };
2620
+ if (typeof carrier.slotIndex !== 'number' || !slots.has(carrier.slotIndex)) continue;
2621
+ const entries = timeline.getFrameEntries();
2622
+ for (let i = 0; i < timeline.getFrameCount(); i++) times.push(timeline.frames[i * entries]);
2623
+ }
2624
+ return times;
2625
+ }
2626
+
2627
+ /**
2628
+ * spine-core's parsed skeleton read as the survey's structure, and posed by
2629
+ * spine-core — the survey as it always was (the section's note).
2630
+ */
2631
+ function runtimeSide(data: SkeletonData): { structure: SurveyStructure; poser: SurveyPoser; links: RuntimeLinks } {
2632
+ const skins = new Map<Skin, SurveySkin>();
2633
+ const skinBack = new Map<SurveySkin, Skin>();
2634
+ const skinOf = (skin: Skin | null): SurveySkin | null => {
2635
+ if (skin === null) return null;
2636
+ let handle = skins.get(skin);
2637
+ if (handle === undefined) {
2638
+ handle = { name: skin.name };
2639
+ skins.set(skin, handle);
2640
+ skinBack.set(handle, skin);
2641
+ }
2642
+ return handle;
2643
+ };
2644
+ const meshes = new Map<MeshAttachment, SurveyMesh>();
2645
+ const meshBack = new Map<SurveyMesh, MeshAttachment>();
2646
+ const meshOf = (attachment: MeshAttachment, slotIndex: number): SurveyMesh => {
2647
+ const already = meshes.get(attachment);
2648
+ if (already !== undefined) return already;
2649
+ const placed = placementOf(data, slotIndex, attachment);
2650
+ const handle: SurveyMesh = {
2651
+ name: attachment.name,
2652
+ triangles: attachment.triangles,
2653
+ worldVerticesLength: attachment.worldVerticesLength,
2654
+ weighted: attachment.bones !== null,
2655
+ timelineSlots: attachment.timelineSlots,
2656
+ record: placed.holder === null ? null : { skin: placed.skin, slot: data.slots[slotIndex]?.name ?? `#${slotIndex}`, placeholder: placed.placeholder },
2657
+ };
2658
+ meshes.set(attachment, handle);
2659
+ meshBack.set(handle, attachment);
2660
+ return handle;
2661
+ };
2662
+ const deformOf = (timeline: DeformTimeline): SurveyDeformTimeline => {
2663
+ const attachment = timeline.attachment;
2664
+ const mesh = attachment instanceof MeshAttachment ? meshOf(attachment, timeline.slotIndex) : null;
2665
+ return {
2666
+ slotIndex: timeline.slotIndex,
2667
+ mesh,
2668
+ frames: timeline.frames,
2669
+ vertices: (frame) => timeline.vertices[frame],
2670
+ curve: (frame): SurveyCurve => {
2671
+ const curves = curveStorage(timeline);
2672
+ const code = curves[frame];
2673
+ // 1 is STEPPED and 0 LINEAR; 2 + i is BEZIER, its sampled points starting at `i`.
2674
+ if (code === 1) return { kind: 'stepped' };
2675
+ if (code === 0) return { kind: 'linear' };
2676
+ const points: number[] = [];
2677
+ for (let i = code - 2, n = code - 2 + BEZIER_POINTS * 2; i < n; i++) points.push(curves[i]);
2678
+ return { kind: 'bezier', points };
2679
+ },
2680
+ placement: () => {
2681
+ const placed = placementOf(data, timeline.slotIndex, attachment);
2682
+ return { skin: placed.skin, holder: skinOf(placed.holder), placeholder: placed.placeholder };
2683
+ },
2684
+ };
2685
+ };
2686
+ const animations: SurveyAnimation[] = data.animations.map((anim) => ({
2687
+ name: anim.name,
2688
+ deforms: anim.timelines.flatMap((timeline) => (timeline instanceof DeformTimeline ? [deformOf(timeline)] : [])),
2689
+ slotKeyTimes: (slots) => visibilityKeyTimes(anim.timelines, slots),
2690
+ }));
2691
+ const sliderBack = new Map<SurveySlider, SliderData>();
2692
+ const sliders: SurveySlider[] = [];
2693
+ for (const constraint of data.constraints) {
2694
+ if (!(constraint instanceof SliderData)) continue;
2695
+ // The animation and the property are read when they are used, as they always were: only a slider with an
2696
+ // animation is planned, and only one with a bone has a property — a bone-less one's is not set at all.
2697
+ const handle: SurveySlider = {
2698
+ name: constraint.name,
2699
+ mix: constraint.setupPose.mix,
2700
+ animation: constraint.animation?.name ?? '',
2701
+ get duration() {
2702
+ return constraint.animation.duration;
2703
+ },
2704
+ bone: constraint.bone?.name ?? null,
2705
+ local: constraint.local,
2706
+ get stated() {
2707
+ return readerField(constraint.property);
2708
+ },
2709
+ get reader() {
2710
+ return constraint.property.constructor.name;
2711
+ },
2712
+ get from() {
2713
+ return constraint.property.offset;
2714
+ },
2715
+ to: constraint.offset,
2716
+ scale: constraint.scale,
2717
+ loop: constraint.loop,
2718
+ };
2719
+ sliders.push(handle);
2720
+ sliderBack.set(handle, constraint);
2721
+ }
2722
+ const structure: SurveyStructure = {
2723
+ skins: [data.defaultSkin, ...data.skins].flatMap((k, i, all) => (k !== null && all.indexOf(k) === i ? [skinOf(k) as SurveySkin] : [])),
2724
+ animations,
2725
+ sliders,
2726
+ slotName: (index) => data.slots[index]?.name ?? `#${index}`,
2727
+ skinsNamed: (name) => data.skins.filter((k) => k.name === name).length,
2728
+ };
2729
+ const foreign = (what: string): Error => new Error(`internal: spine-core's poser was handed a ${what} another reader made — it poses the parsed skeleton's own objects`);
2730
+ const links: RuntimeLinks = {
2731
+ skin: (handle) => {
2732
+ if (handle === null) return null;
2733
+ const skin = skinBack.get(handle);
2734
+ if (skin === undefined) throw foreign(`skin "${handle.name}"`);
2735
+ return skin;
2736
+ },
2737
+ mesh: (handle) => {
2738
+ const mesh = meshBack.get(handle);
2739
+ if (mesh === undefined) throw foreign(`mesh "${handle.name}"`);
2740
+ return mesh;
2741
+ },
2742
+ slider: (handle) => {
2743
+ const slider = sliderBack.get(handle);
2744
+ if (slider === undefined) throw foreign(`slider "${handle.name}"`);
2745
+ return slider;
2746
+ },
2747
+ };
2748
+ return { structure, poser: spinePoser(data, links), links };
2749
+ }
2750
+ // --- #1019 the runtime's reading of the structure: end ---
2751
+
2565
2752
  /** A live spine-core skeleton, read the way the survey always read it. */
2566
- function spinePose(skeleton: Skeleton): SurveyPose {
2753
+ function spinePose(skeleton: Skeleton, links: RuntimeLinks): SurveyPose {
2567
2754
  return {
2568
2755
  under: () => skeleton.skin?.name ?? null,
2569
- shownAt: (slotIndex, attachment) => shownAt(skeleton, slotIndex, attachment),
2570
- deformed: (slotIndex, attachment) => {
2756
+ shownAt: (slotIndex, handle) => shownAt(skeleton, slotIndex, links.mesh(handle)),
2757
+ deformed: (slotIndex, handle) => {
2758
+ const attachment = links.mesh(handle);
2571
2759
  const count = attachment.worldVerticesLength;
2572
2760
  const world = new Float32Array(count);
2573
2761
  attachment.computeWorldVertices(skeleton, skeleton.slots[slotIndex], 0, count, world, 0, 2);
2574
2762
  return world;
2575
2763
  },
2576
- plain: (slotIndex, attachment) => {
2764
+ plain: (slotIndex, handle) => {
2765
+ const attachment = links.mesh(handle);
2577
2766
  const count = attachment.worldVerticesLength;
2578
2767
  const slot = skeleton.slots[slotIndex];
2579
2768
  slot.appliedPose.deform.length = 0;
@@ -2581,8 +2770,9 @@ function spinePose(skeleton: Skeleton): SurveyPose {
2581
2770
  attachment.computeWorldVertices(skeleton, slot, 0, count, world, 0, 2);
2582
2771
  return world;
2583
2772
  },
2584
- withDeform: (slotIndex, attachment, deform) => worldWithDeform(skeleton, slotIndex, attachment, deform),
2585
- rows: (slotIndex, attachment, deform) => {
2773
+ withDeform: (slotIndex, handle, deform) => worldWithDeform(skeleton, slotIndex, links.mesh(handle), deform),
2774
+ rows: (slotIndex, handle, deform) => {
2775
+ const attachment = links.mesh(handle);
2586
2776
  const slot = skeleton.slots[slotIndex];
2587
2777
  const array = slot.appliedPose.deform;
2588
2778
  const kept = [...array];
@@ -2601,11 +2791,12 @@ function spinePose(skeleton: Skeleton): SurveyPose {
2601
2791
  }
2602
2792
 
2603
2793
  /** The survey's poses through spine-core — the recipes the survey has always taken (`poseAt`; the dial below). */
2604
- function spinePoser(data: SkeletonData): SurveyPoser {
2794
+ function spinePoser(data: SkeletonData, links: RuntimeLinks): SurveyPoser {
2605
2795
  return {
2606
- track: (skin, animation, time) => spinePose(poseAt(data, skin, animation, time)),
2607
- dial: (skin, slider) => {
2608
- const skeleton = skeletonUnderSkin(data, skin);
2796
+ track: (skin, animation, time) => spinePose(poseAt(data, links.skin(skin), animation, time), links),
2797
+ dial: (skin, handle) => {
2798
+ const slider = links.slider(handle);
2799
+ const skeleton = skeletonUnderSkin(data, links.skin(skin));
2609
2800
  const instance = sliderOn(skeleton, slider);
2610
2801
  if (instance === null) return null;
2611
2802
  const bone = instance.bone;
@@ -2627,41 +2818,25 @@ function spinePoser(data: SkeletonData): SurveyPoser {
2627
2818
  if (slider.local) bone.appliedPose.validateLocalTransform(skeleton);
2628
2819
  return { read: slider.property.value(skeleton, bone.appliedPose, slider.local, DIAL_ZERO_OFFSETS), applied: instance.appliedPose.time };
2629
2820
  },
2630
- pose: () => spinePose(skeleton),
2821
+ pose: () => spinePose(skeleton, links),
2631
2822
  };
2632
2823
  },
2633
2824
  };
2634
2825
  }
2635
2826
 
2636
- /** Which model record each Spine attachment object is, in `placementOf`'s search order. */
2637
- function recordsOf(data: SkeletonData): Map<Attachment, { skin: string; slot: string; placeholder: string }> {
2638
- const out = new Map<Attachment, { skin: string; slot: string; placeholder: string }>();
2639
- for (const skin of [data.defaultSkin, ...data.skins]) {
2640
- if (!skin) continue;
2641
- for (let slotIndex = 0; slotIndex < data.slots.length; slotIndex++) {
2642
- const entries: Array<{ placeholder: string; attachment: Attachment }> = [];
2643
- skin.getAttachmentsForSlot(slotIndex, entries as Parameters<typeof skin.getAttachmentsForSlot>[1]);
2644
- for (const entry of entries) {
2645
- if (!out.has(entry.attachment)) out.set(entry.attachment, { skin: skin.name, slot: data.slots[slotIndex].name, placeholder: entry.placeholder });
2646
- }
2647
- }
2648
- }
2649
- return out;
2650
- }
2651
-
2652
- /** A pose of the core's, read through the Spine skeleton's names. */
2653
- function corePose(pose: CoreSurveyPose, data: SkeletonData, records: ReadonlyMap<Attachment, { skin: string; slot: string; placeholder: string }>): SurveyPose {
2827
+ /** A pose of the core's, read through the structure's slot names and each mesh's model record. */
2828
+ function corePose(pose: CoreSurveyPose, structure: SurveyStructure): SurveyPose {
2654
2829
  const slotName = (slotIndex: number): string => {
2655
- const slot = data.slots[slotIndex];
2656
- if (slot === undefined) throw new CoreInputError(`slot #${slotIndex} is not a slot of the skeleton`);
2657
- return slot.name;
2830
+ const name = structure.slotName(slotIndex);
2831
+ if (name === `#${slotIndex}`) throw new CoreInputError(`slot #${slotIndex} is not a slot of the skeleton`);
2832
+ return name;
2658
2833
  };
2659
- const recordOf = (attachment: MeshAttachment): { skin: string; slot: string; placeholder: string } => {
2660
- const r = records.get(attachment);
2661
- if (r === undefined) throw new CoreInputError(`mesh "${attachment.name}" is in no skin of the skeleton, so it names no model record`);
2834
+ const recordOf = (attachment: SurveyMesh): { skin: string; slot: string; placeholder: string } => {
2835
+ const r = attachment.record;
2836
+ if (r === null) throw new CoreInputError(`mesh "${attachment.name}" is in no skin of the skeleton, so it names no model record`);
2662
2837
  return r;
2663
2838
  };
2664
- const world = (slotIndex: number, attachment: MeshAttachment, deform: 'posed' | 'cleared' | ArrayLike<number>): Float32Array =>
2839
+ const world = (slotIndex: number, attachment: SurveyMesh, deform: 'posed' | 'cleared' | ArrayLike<number>): Float32Array =>
2665
2840
  float32Rows(meshWorld(pose, slotName(slotIndex), recordOf(attachment), deform));
2666
2841
  return {
2667
2842
  under: () => pose.doc.skin,
@@ -2684,13 +2859,14 @@ function corePose(pose: CoreSurveyPose, data: SkeletonData, records: ReadonlyMap
2684
2859
  * (`underSkin`) — so a skeleton declaring two skins of one name, and a pose
2685
2860
  * wearing none, are refused by name: neither reaches the survey through
2686
2861
  * `SkeletonJson` (`placementOf`'s note), and neither has a view to pose.
2862
+ * `structure` is whichever reader's: the model document's on a build the core
2863
+ * poses, the runtime's where the hooks are held to spine-core's.
2687
2864
  */
2688
- function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2689
- const records = recordsOf(data);
2865
+ function corePoser(structure: SurveyStructure, doc: CompiledDocument): SurveyPoser {
2690
2866
  const views = new Map<string, CompiledDocument>();
2691
- const viewOf = (skin: Skin | null): CompiledDocument => {
2867
+ const viewOf = (skin: SurveySkin | null): CompiledDocument => {
2692
2868
  if (skin === null) throw new CoreInputError('the survey asked for a pose wearing no skin, and the core poses one skin\'s view');
2693
- if (data.skins.filter((k) => k.name === skin.name).length > 1) throw new CoreInputError(`the skeleton declares two skins named "${skin.name}", and the model names a skin by its name`);
2869
+ if (structure.skinsNamed(skin.name) > 1) throw new CoreInputError(`the skeleton declares two skins named "${skin.name}", and the model names a skin by its name`);
2694
2870
  const cached = views.get(skin.name);
2695
2871
  if (cached !== undefined) return cached;
2696
2872
  const view = underSkin(doc, skin.name);
@@ -2698,7 +2874,7 @@ function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2698
2874
  return view;
2699
2875
  };
2700
2876
  return {
2701
- track: (skin, animation, time) => corePose(poseJump(viewOf(skin), animation, time), data, records),
2877
+ track: (skin, animation, time) => corePose(poseJump(viewOf(skin), animation, time), structure),
2702
2878
  dial: (skin, slider) => {
2703
2879
  const view = viewOf(skin);
2704
2880
  const record = sliderRecordOf(view, slider.name);
@@ -2719,7 +2895,7 @@ function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2719
2895
  },
2720
2896
  pose: () => {
2721
2897
  if (last === null) throw new CoreInputError(`slider "${slider.name}"'s dial was asked for its pose before it was posed`);
2722
- return corePose(last, data, records);
2898
+ return corePose(last, structure);
2723
2899
  },
2724
2900
  };
2725
2901
  },
@@ -2727,13 +2903,19 @@ function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2727
2903
  }
2728
2904
 
2729
2905
  /**
2730
- * Both posers over one build — spine-core's and the core's, answering the same
2731
- * calls — for measurement: `tools/survey_hashes.ts hooks` and the selftest's
2732
- * `DM` controls hold every hook of the one to the other at tolerance 0.
2906
+ * Both readers and both posers over one build, for measurement:
2907
+ * `tools/survey_hashes.ts` holds every hook of the core's poser to
2908
+ * spine-core's at tolerance 0 over the runtime's structure (`hooks`), and the
2909
+ * model document's structure to the runtime's (`structure`); the selftest's
2910
+ * `DM` controls hold the same. `spine` and `core` both pose `structure`'s
2911
+ * handles; `model` is the model document's reading, which only `core` and the
2912
+ * model's own poser can pose.
2733
2913
  */
2734
- export function deformPosers(input: DeformSurveyInput & { modelText: string }): { data: SkeletonData; spine: SurveyPoser; core: SurveyPoser } {
2914
+ export function deformPosers(input: DeformSurveyInput & { modelText: string }): { data: SkeletonData; structure: SurveyStructure; model: SurveyStructure; spine: SurveyPoser; core: SurveyPoser } {
2735
2915
  const data = skeletonDataFromText(input.skeletonText, input.atlasText);
2736
- return { data, spine: spinePoser(data), core: corePoser(data, readModel(input.modelText)) };
2916
+ const side = runtimeSide(data);
2917
+ const doc = readModel(input.modelText);
2918
+ return { data, structure: side.structure, model: modelStructure(doc), spine: side.poser, core: corePoser(side.structure, doc) };
2737
2919
  }
2738
2920
 
2739
2921
  /** A build's three texts, as `explain` and `tools/survey_hashes.ts` hold them. */
@@ -2742,25 +2924,69 @@ export interface DeformSurveyInput {
2742
2924
  atlasText: string;
2743
2925
  /** The model document (`skeleton.model.json`), or `null` when the input carries none (a Spine export). */
2744
2926
  modelText: string | null;
2927
+ /** What a refusal names the survey's input as; `the deform survey` unstated. */
2928
+ label?: string;
2929
+ }
2930
+
2931
+ /**
2932
+ * Touch the runtime once, before the skeleton is parsed through it, and refuse
2933
+ * by name when it cannot be used (issue #1019) — the sentence `render` and
2934
+ * `check` give a run that needs the runtime and cannot use it
2935
+ * (`SpineRuntimeError`, issue #1014). A survey whose input the core poses
2936
+ * never comes here.
2937
+ */
2938
+ function requireSpineRuntime(label: string, why: string): void {
2939
+ try {
2940
+ // A property read on the class the parse starts from: no runtime code runs, and a runtime that cannot be used throws here.
2941
+ void TextureAtlas.prototype;
2942
+ } catch (err) {
2943
+ throw new SpineRuntimeError(
2944
+ `${label} is posed through spine-core (${why}), and the runtime could not be used: ${(err as Error).message}. ` +
2945
+ 'spine-core is what reads and poses a Spine export, --poser spine and a fallback the survey names; ' +
2946
+ 'a rigc build the core poses reads nothing through it',
2947
+ );
2948
+ }
2949
+ }
2950
+
2951
+ /**
2952
+ * The survey off a model document alone: its structure read by `read` —
2953
+ * `modelStructure` unless a control plants a misreading (`DM13`) — and posed
2954
+ * through the core, as `surveyOfBuild`'s model path takes it.
2955
+ */
2956
+ export function surveyOfModel(doc: CompiledDocument, exempt: ReadonlySet<string>, read: (doc: CompiledDocument) => SurveyStructure = modelStructure): DeformSurvey {
2957
+ const structure = read(doc);
2958
+ return { ...surveyWith(structure, exempt, corePoser(structure, doc)), source: { used: 'model', why: null } };
2745
2959
  }
2746
2960
 
2747
2961
  /**
2748
- * The survey of a build, through the poser asked for (issue #969): `model` —
2749
- * the core over the model document, refused when there is none; `spine-core` —
2750
- * the runtime over the Spine skeleton; `auto` — the model document when the
2751
- * input carries one and the core poses it, spine-core otherwise, the reason
2752
- * named in `source.why`.
2962
+ * The survey of a build, through the reader and poser asked for (issues #969,
2963
+ * #1019): `model` — the model document's structure posed by the core,
2964
+ * refused when there is none or when the Spine file beside it is not the one
2965
+ * it records; `spine-core` — the Spine skeleton read and posed by the runtime;
2966
+ * `auto` — the model document when the input carries one and the core poses
2967
+ * it, spine-core otherwise, the reason named in `source.why`. On the model
2968
+ * path spine-core is not touched: the Spine text is hashed, never parsed.
2753
2969
  */
2754
2970
  export function surveyOfBuild(input: DeformSurveyInput, exempt: ReadonlySet<string>, asked: 'auto' | DeformSurveySource): DeformSurvey {
2755
- const data = skeletonDataFromText(input.skeletonText, input.atlasText);
2756
- const throughSpine = (why: string | null): DeformSurvey => ({ ...surveyWith(data, exempt, spinePoser(data)), source: { used: 'spine-core', why } });
2971
+ const label = input.label ?? 'the deform survey';
2972
+ const throughSpine = (why: string | null): DeformSurvey => {
2973
+ requireSpineRuntime(label, why ?? '--poser spine');
2974
+ const side = runtimeSide(skeletonDataFromText(input.skeletonText, input.atlasText));
2975
+ return { ...surveyWith(side.structure, exempt, side.poser), source: { used: 'spine-core', why } };
2976
+ };
2757
2977
  if (asked === 'spine-core') return throughSpine(null);
2758
2978
  if (input.modelText === null) {
2759
2979
  if (asked === 'model') throw new CoreInputError('the survey was asked to pose the model document, and the input carries none');
2760
2980
  return throughSpine('the input carries no model document (skeleton.model.json), so the survey posed the Spine skeleton through spine-core');
2761
2981
  }
2762
2982
  try {
2763
- return { ...surveyWith(data, exempt, corePoser(data, readModel(input.modelText))), source: { used: 'model', why: null } };
2983
+ const doc = readModel(input.modelText);
2984
+ // The document reads the rig it was built with; the Spine file beside it must be that build's (issue #968's rule).
2985
+ const found = spineFileSha256(input.skeletonText);
2986
+ if (found !== doc.spine.sha256) {
2987
+ throw new CoreInputError(`the skeleton is not the one the model document was written beside: its sha256 is ${found}, the document records ${doc.spine.sha256 || 'none'}`);
2988
+ }
2989
+ return surveyOfModel(doc, exempt);
2764
2990
  } catch (err) {
2765
2991
  if (!(err instanceof CoreInputError) || asked === 'model') throw err;
2766
2992
  return throughSpine(`the core refused to pose the model document — ${err.message}`);