spine-rigc 1.7.0 → 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.
@@ -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
 
@@ -1618,9 +1633,9 @@ interface PoseOfFrame {
1618
1633
  * was asked for. ⚠️ `loop` on a zero-length animation gives NaN here exactly
1619
1634
  * as it does there, which is `A37_SLIDER_CONSTRAINT_EFFECTIVE`'s refusal.
1620
1635
  */
1621
- function sliderTimeFor(slider: SliderData, time: number): number {
1636
+ function sliderTimeFor(slider: SurveySlider, time: number): number {
1622
1637
  if (slider.bone === null) return time;
1623
- if (slider.loop) return slider.animation.duration + (time % slider.animation.duration);
1638
+ if (slider.loop) return slider.duration + (time % slider.duration);
1624
1639
  return Math.max(0, time);
1625
1640
  }
1626
1641
 
@@ -1646,12 +1661,12 @@ function sliderTimeFor(slider: SliderData, time: number): number {
1646
1661
  * against `sliderTimeFor` — spine-core's answer, not this function's. A time
1647
1662
  * no dial value selects is reported and never guessed at.
1648
1663
  */
1649
- 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 {
1650
1665
  const slider = plan.slider;
1651
1666
  const wanted = sliderTimeFor(slider, time);
1652
- 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;
1653
1668
  const session = poser.dial(skin, slider);
1654
- 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 };
1655
1670
  /** Pose with the driving field at `candidate`, and read both sides back. */
1656
1671
  const field = session.hasBone ? plan.field : null;
1657
1672
  const at = (candidate: number): { read: number; applied: number } => session.at(field, candidate);
@@ -1762,7 +1777,7 @@ function measurePosed(
1762
1777
  posed: SurveyPose,
1763
1778
  time: number,
1764
1779
  slotIndex: number,
1765
- attachment: MeshAttachment,
1780
+ attachment: SurveyMesh,
1766
1781
  triangles: ArrayLike<number>,
1767
1782
  reach: DeformReach,
1768
1783
  dial: DeformDial | null,
@@ -1901,33 +1916,37 @@ interface CurveLeg {
1901
1916
  * The runtime's own interpolation fraction over one span, as a polyline in
1902
1917
  * `(time, fraction)`.
1903
1918
  *
1904
- * ⭐ **Read off `timeline.curves`, not re-derived from the cubic.**
1905
- * `DeformTimeline.getCurvePercent` evaluates a bezier by walking the ten points
1906
- * the parser sampled into that array and interpolating *linearly* between them —
1907
- * so the polyline below is not an approximation of what the runtime does, it is
1908
- * what the runtime does. A curve that overshoots (a fraction below 0 or above 1,
1909
- * which the format allows and `back`-style easings produce) is therefore inside
1910
- * 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.
1911
1930
  */
1912
- function curveLegs(timeline: DeformTimeline, frame: number): { kind: DeformSpanCurve; legs: CurveLeg[] } {
1931
+ function curveLegs(timeline: SurveyDeformTimeline, frame: number): { kind: DeformSpanCurve; legs: CurveLeg[] } {
1913
1932
  const t0 = timeline.frames[frame];
1914
1933
  const t1 = timeline.frames[frame + 1];
1915
- const curves = curveStorage(timeline);
1916
- const code = curves[frame];
1917
- // 1 is STEPPED: `getCurvePercent` returns a flat 0 across the whole span, so
1918
- // the runtime holds the earlier key's geometry and interpolates nothing.
1919
- if (code === 1) return { kind: 'stepped', legs: [{ t0, p0: 0, t1, p1: 0 }] };
1920
- if (code === 0) return { kind: 'linear', legs: [{ t0, p0: 0, t1, p1: 1 }] };
1921
- // 2 + i is BEZIER, with the sampled points starting at `i`. Nine of them are
1922
- // stored; the runtime ramps into the first from (t0, 0) and out of the last to
1923
- // (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;
1924
1943
  const legs: CurveLeg[] = [];
1925
1944
  let x = t0;
1926
1945
  let y = 0;
1927
- for (let i = code - 2, n = code - 2 + BEZIER_POINTS * 2; i < n; i += 2) {
1928
- legs.push({ t0: x, p0: y, t1: curves[i], p1: curves[i + 1] });
1929
- x = curves[i];
1930
- 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];
1931
1950
  }
1932
1951
  legs.push({ t0: x, p0: y, t1, p1: 1 });
1933
1952
  return { kind: 'bezier', legs };
@@ -1951,6 +1970,16 @@ export const BEZIER_POINTS = 9;
1951
1970
  * thing this file's opening paragraph forbids — and would then be checking
1952
1971
  * rigc's copy of spine-core's maths rather than spine-core's.
1953
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
+ *
1954
1983
  * `A05` already gates the emitted curve arrays, and `DW18` is the control that
1955
1984
  * the reading here matches what the runtime does with them.
1956
1985
  *
@@ -2094,7 +2123,7 @@ function wrongSignFractions(
2094
2123
  return a > 0 ? clip(left, right) : [...clip(-Infinity, left), ...clip(right, Infinity)];
2095
2124
  }
2096
2125
 
2097
- /**
2126
+ /*
2098
2127
  * The times at which the slot's **visibility** can change across a span.
2099
2128
  *
2100
2129
  * ⚠️ This is the half of issue #403 that is about the fade rather than the fold,
@@ -2109,21 +2138,11 @@ function wrongSignFractions(
2109
2138
  * window at those times leaves pieces on which "does this draw?" has one answer,
2110
2139
  * and the probe inside a piece is representative of the whole piece.
2111
2140
  *
2112
- * Every timeline carrying a `slotIndex` counts, for this slot and for any other
2113
- * slot the deform reaches (`timelineSlots`) — duck-typed rather than matched
2114
- * against a list of classes, because a list is a thing that goes stale when the
2115
- * 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).
2116
2145
  */
2117
- function visibilityKeyTimes(timelines: readonly Timeline[], slots: ReadonlySet<number>): number[] {
2118
- const times: number[] = [];
2119
- for (const timeline of timelines) {
2120
- const carrier = timeline as unknown as { slotIndex?: unknown };
2121
- if (typeof carrier.slotIndex !== 'number' || !slots.has(carrier.slotIndex)) continue;
2122
- const entries = timeline.getFrameEntries();
2123
- for (let i = 0; i < timeline.getFrameCount(); i++) times.push(timeline.frames[i * entries]);
2124
- }
2125
- return times;
2126
- }
2127
2146
 
2128
2147
  /**
2129
2148
  * Scan the interval between two consecutive deform keys (issue #403).
@@ -2179,9 +2198,9 @@ function visibilityKeyTimes(timelines: readonly Timeline[], slots: ReadonlySet<n
2179
2198
  * being refused anyway.
2180
2199
  */
2181
2200
  function scanDeformSpan(
2182
- anim: { name: string; timelines: Timeline[] },
2183
- timeline: DeformTimeline,
2184
- attachment: MeshAttachment,
2201
+ anim: SurveyAnimation,
2202
+ timeline: SurveyDeformTimeline,
2203
+ attachment: SurveyMesh,
2185
2204
  triangles: ArrayLike<number>,
2186
2205
  named: { animation: string; skin: string; slot: string; attachment: string; placeholder: string },
2187
2206
  /** The index of the key the span starts at — `frame + 1` is the one it ends at. */
@@ -2211,8 +2230,8 @@ function scanDeformSpan(
2211
2230
  unconfirmed: false,
2212
2231
  };
2213
2232
  const reach = reachedFractions(legs);
2214
- const v1 = timeline.vertices[frame];
2215
- const v2 = timeline.vertices[frame + 1];
2233
+ const v1 = timeline.vertices(frame);
2234
+ const v2 = timeline.vertices(frame + 1);
2216
2235
  if (!v1 || !v2) return span;
2217
2236
 
2218
2237
  const windows: Interval[] = [];
@@ -2228,7 +2247,7 @@ function scanDeformSpan(
2228
2247
  // both of the span's own key poses are taken and the union used. `DW17` is
2229
2248
  // the control for the claim: a bone rotation across the span moves nothing
2230
2249
  // about where an unweighted mesh is found to fold.
2231
- for (const anchor of attachment.bones === null ? [from] : [from, to]) {
2250
+ for (const anchor of attachment.weighted ? [from, to] : [from]) {
2232
2251
  // The two ends of the straight line every vertex travels, evaluated at THIS
2233
2252
  // anchor's bones. `measurePosed` has already emptied the slot's deform array
2234
2253
  // to take its plain side, so writing into it is how the two are posed.
@@ -2281,7 +2300,7 @@ function scanDeformSpan(
2281
2300
  // straddles a change in what is drawn. Every point of a merged window is
2282
2301
  // inside at least one triangle's window, so its middle is a fold and not a gap.
2283
2302
  const slots = new Set<number>([timeline.slotIndex, ...attachment.timelineSlots]);
2284
- const cuts = visibilityKeyTimes(anim.timelines, slots);
2303
+ const cuts = anim.slotKeyTimes(slots);
2285
2304
  const pieces: Interval[] = [];
2286
2305
  for (const window of mergeIntervals(windows)) {
2287
2306
  const inner = [...new Set(cuts.filter((t) => t > window.lo && t < window.hi))].sort((x, y) => x - y);
@@ -2413,7 +2432,7 @@ function shownAt(posed: Skeleton, slotIndex: number, attachment: MeshAttachment)
2413
2432
  * green. What it must not do is leave the reader guessing which dress the
2414
2433
  * verdict was taken in, so the skin is in the sentence.
2415
2434
  */
2416
- function drawOfKey(posed: SurveyPose, slotIndex: number, attachment: MeshAttachment): DeformKeyDraw {
2435
+ function drawOfKey(posed: SurveyPose, slotIndex: number, attachment: SurveyMesh): DeformKeyDraw {
2417
2436
  const own = posed.shownAt(slotIndex, attachment);
2418
2437
  const under = posed.under();
2419
2438
  const draw = { shown: own.shown, showsThisMesh: own.showsThisMesh, alpha: own.alpha, under };
@@ -2482,7 +2501,7 @@ function drawOfKey(posed: SurveyPose, slotIndex: number, attachment: MeshAttachm
2482
2501
  function placementOf(
2483
2502
  data: SkeletonData,
2484
2503
  slotIndex: number,
2485
- attachment: MeshAttachment,
2504
+ attachment: Attachment,
2486
2505
  ): { skin: string; holder: Skin | null; placeholder: string } {
2487
2506
  for (const skin of [data.defaultSkin, ...data.skins]) {
2488
2507
  if (!skin) continue;
@@ -2503,8 +2522,12 @@ function placementOf(
2503
2522
  // spine-core over the Spine skeleton (the survey as it always was, and
2504
2523
  // `validate.ts`'s A39, which hands it the SkeletonData it round-tripped), and
2505
2524
  // rigc's own core over the model document (`./core/hooks.ts`, issue #969),
2506
- // which a build carries and `explain` hands in. The survey's record names which
2507
- // 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
2508
2531
  // two to one survey, byte for byte, on every corpus.
2509
2532
 
2510
2533
  /** Which poser a survey used (`DeformSurvey.source`). */
@@ -2531,19 +2554,19 @@ export interface ShownReading {
2531
2554
  export interface SurveyPose {
2532
2555
  /** The skin the skeleton wears, off the pose itself (`DeformKeyDraw.under`). */
2533
2556
  under(): string | null;
2534
- shownAt(slotIndex: number, attachment: MeshAttachment): ShownReading;
2557
+ shownAt(slotIndex: number, attachment: SurveyMesh): ShownReading;
2535
2558
  /** The mesh's world vertices with the slot's deform as posed. */
2536
- deformed(slotIndex: number, attachment: MeshAttachment): Float32Array;
2559
+ deformed(slotIndex: number, attachment: SurveyMesh): Float32Array;
2537
2560
  /** The same bones with the slot's deform cleared. */
2538
- plain(slotIndex: number, attachment: MeshAttachment): Float32Array;
2561
+ plain(slotIndex: number, attachment: SurveyMesh): Float32Array;
2539
2562
  /** The same bones with `deform` written into the slot. */
2540
- withDeform(slotIndex: number, attachment: MeshAttachment, deform: ArrayLike<number>): Float32Array;
2563
+ withDeform(slotIndex: number, attachment: SurveyMesh, deform: ArrayLike<number>): Float32Array;
2541
2564
  /**
2542
2565
  * The doubles those three are stored from — the slot's deform as posed,
2543
2566
  * cleared, or replaced — leaving the pose as it found it. The survey reads
2544
2567
  * the float32 rows; this is what measures the rows' source (`tools/survey_hashes.ts hooks`).
2545
2568
  */
2546
- rows(slotIndex: number, attachment: MeshAttachment, deform: 'posed' | 'cleared' | ArrayLike<number>): number[];
2569
+ rows(slotIndex: number, attachment: SurveyMesh, deform: 'posed' | 'cleared' | ArrayLike<number>): number[];
2547
2570
  }
2548
2571
 
2549
2572
  /** One slider's dial, posed again and again (`planDial`'s probe, `poseDial`'s solve). */
@@ -2560,23 +2583,186 @@ export interface DialSession {
2560
2583
 
2561
2584
  /** The two ways the survey poses: a jump on a track, and a slider's dial. */
2562
2585
  export interface SurveyPoser {
2563
- track(skin: Skin | null, animation: string, time: number): SurveyPose;
2586
+ track(skin: SurveySkin | null, animation: string, time: number): SurveyPose;
2564
2587
  /** `null` when the skeleton carries no such slider. */
2565
- dial(skin: Skin | null, slider: SliderData): DialSession | null;
2588
+ dial(skin: SurveySkin | null, slider: SurveySlider): DialSession | null;
2566
2589
  }
2567
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
+
2568
2752
  /** A live spine-core skeleton, read the way the survey always read it. */
2569
- function spinePose(skeleton: Skeleton): SurveyPose {
2753
+ function spinePose(skeleton: Skeleton, links: RuntimeLinks): SurveyPose {
2570
2754
  return {
2571
2755
  under: () => skeleton.skin?.name ?? null,
2572
- shownAt: (slotIndex, attachment) => shownAt(skeleton, slotIndex, attachment),
2573
- deformed: (slotIndex, attachment) => {
2756
+ shownAt: (slotIndex, handle) => shownAt(skeleton, slotIndex, links.mesh(handle)),
2757
+ deformed: (slotIndex, handle) => {
2758
+ const attachment = links.mesh(handle);
2574
2759
  const count = attachment.worldVerticesLength;
2575
2760
  const world = new Float32Array(count);
2576
2761
  attachment.computeWorldVertices(skeleton, skeleton.slots[slotIndex], 0, count, world, 0, 2);
2577
2762
  return world;
2578
2763
  },
2579
- plain: (slotIndex, attachment) => {
2764
+ plain: (slotIndex, handle) => {
2765
+ const attachment = links.mesh(handle);
2580
2766
  const count = attachment.worldVerticesLength;
2581
2767
  const slot = skeleton.slots[slotIndex];
2582
2768
  slot.appliedPose.deform.length = 0;
@@ -2584,8 +2770,9 @@ function spinePose(skeleton: Skeleton): SurveyPose {
2584
2770
  attachment.computeWorldVertices(skeleton, slot, 0, count, world, 0, 2);
2585
2771
  return world;
2586
2772
  },
2587
- withDeform: (slotIndex, attachment, deform) => worldWithDeform(skeleton, slotIndex, attachment, deform),
2588
- 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);
2589
2776
  const slot = skeleton.slots[slotIndex];
2590
2777
  const array = slot.appliedPose.deform;
2591
2778
  const kept = [...array];
@@ -2604,11 +2791,12 @@ function spinePose(skeleton: Skeleton): SurveyPose {
2604
2791
  }
2605
2792
 
2606
2793
  /** The survey's poses through spine-core — the recipes the survey has always taken (`poseAt`; the dial below). */
2607
- function spinePoser(data: SkeletonData): SurveyPoser {
2794
+ function spinePoser(data: SkeletonData, links: RuntimeLinks): SurveyPoser {
2608
2795
  return {
2609
- track: (skin, animation, time) => spinePose(poseAt(data, skin, animation, time)),
2610
- dial: (skin, slider) => {
2611
- 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));
2612
2800
  const instance = sliderOn(skeleton, slider);
2613
2801
  if (instance === null) return null;
2614
2802
  const bone = instance.bone;
@@ -2630,41 +2818,25 @@ function spinePoser(data: SkeletonData): SurveyPoser {
2630
2818
  if (slider.local) bone.appliedPose.validateLocalTransform(skeleton);
2631
2819
  return { read: slider.property.value(skeleton, bone.appliedPose, slider.local, DIAL_ZERO_OFFSETS), applied: instance.appliedPose.time };
2632
2820
  },
2633
- pose: () => spinePose(skeleton),
2821
+ pose: () => spinePose(skeleton, links),
2634
2822
  };
2635
2823
  },
2636
2824
  };
2637
2825
  }
2638
2826
 
2639
- /** Which model record each Spine attachment object is, in `placementOf`'s search order. */
2640
- function recordsOf(data: SkeletonData): Map<Attachment, { skin: string; slot: string; placeholder: string }> {
2641
- const out = new Map<Attachment, { skin: string; slot: string; placeholder: string }>();
2642
- for (const skin of [data.defaultSkin, ...data.skins]) {
2643
- if (!skin) continue;
2644
- for (let slotIndex = 0; slotIndex < data.slots.length; slotIndex++) {
2645
- const entries: Array<{ placeholder: string; attachment: Attachment }> = [];
2646
- skin.getAttachmentsForSlot(slotIndex, entries as Parameters<typeof skin.getAttachmentsForSlot>[1]);
2647
- for (const entry of entries) {
2648
- if (!out.has(entry.attachment)) out.set(entry.attachment, { skin: skin.name, slot: data.slots[slotIndex].name, placeholder: entry.placeholder });
2649
- }
2650
- }
2651
- }
2652
- return out;
2653
- }
2654
-
2655
- /** A pose of the core's, read through the Spine skeleton's names. */
2656
- 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 {
2657
2829
  const slotName = (slotIndex: number): string => {
2658
- const slot = data.slots[slotIndex];
2659
- if (slot === undefined) throw new CoreInputError(`slot #${slotIndex} is not a slot of the skeleton`);
2660
- 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;
2661
2833
  };
2662
- const recordOf = (attachment: MeshAttachment): { skin: string; slot: string; placeholder: string } => {
2663
- const r = records.get(attachment);
2664
- 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`);
2665
2837
  return r;
2666
2838
  };
2667
- 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 =>
2668
2840
  float32Rows(meshWorld(pose, slotName(slotIndex), recordOf(attachment), deform));
2669
2841
  return {
2670
2842
  under: () => pose.doc.skin,
@@ -2687,13 +2859,14 @@ function corePose(pose: CoreSurveyPose, data: SkeletonData, records: ReadonlyMap
2687
2859
  * (`underSkin`) — so a skeleton declaring two skins of one name, and a pose
2688
2860
  * wearing none, are refused by name: neither reaches the survey through
2689
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.
2690
2864
  */
2691
- function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2692
- const records = recordsOf(data);
2865
+ function corePoser(structure: SurveyStructure, doc: CompiledDocument): SurveyPoser {
2693
2866
  const views = new Map<string, CompiledDocument>();
2694
- const viewOf = (skin: Skin | null): CompiledDocument => {
2867
+ const viewOf = (skin: SurveySkin | null): CompiledDocument => {
2695
2868
  if (skin === null) throw new CoreInputError('the survey asked for a pose wearing no skin, and the core poses one skin\'s view');
2696
- 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`);
2697
2870
  const cached = views.get(skin.name);
2698
2871
  if (cached !== undefined) return cached;
2699
2872
  const view = underSkin(doc, skin.name);
@@ -2701,7 +2874,7 @@ function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2701
2874
  return view;
2702
2875
  };
2703
2876
  return {
2704
- track: (skin, animation, time) => corePose(poseJump(viewOf(skin), animation, time), data, records),
2877
+ track: (skin, animation, time) => corePose(poseJump(viewOf(skin), animation, time), structure),
2705
2878
  dial: (skin, slider) => {
2706
2879
  const view = viewOf(skin);
2707
2880
  const record = sliderRecordOf(view, slider.name);
@@ -2722,7 +2895,7 @@ function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2722
2895
  },
2723
2896
  pose: () => {
2724
2897
  if (last === null) throw new CoreInputError(`slider "${slider.name}"'s dial was asked for its pose before it was posed`);
2725
- return corePose(last, data, records);
2898
+ return corePose(last, structure);
2726
2899
  },
2727
2900
  };
2728
2901
  },
@@ -2730,13 +2903,19 @@ function corePoser(data: SkeletonData, doc: CompiledDocument): SurveyPoser {
2730
2903
  }
2731
2904
 
2732
2905
  /**
2733
- * Both posers over one build — spine-core's and the core's, answering the same
2734
- * calls — for measurement: `tools/survey_hashes.ts hooks` and the selftest's
2735
- * `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.
2736
2913
  */
2737
- 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 } {
2738
2915
  const data = skeletonDataFromText(input.skeletonText, input.atlasText);
2739
- 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) };
2740
2919
  }
2741
2920
 
2742
2921
  /** A build's three texts, as `explain` and `tools/survey_hashes.ts` hold them. */
@@ -2745,25 +2924,69 @@ export interface DeformSurveyInput {
2745
2924
  atlasText: string;
2746
2925
  /** The model document (`skeleton.model.json`), or `null` when the input carries none (a Spine export). */
2747
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 } };
2748
2959
  }
2749
2960
 
2750
2961
  /**
2751
- * The survey of a build, through the poser asked for (issue #969): `model` —
2752
- * the core over the model document, refused when there is none; `spine-core` —
2753
- * the runtime over the Spine skeleton; `auto` — the model document when the
2754
- * input carries one and the core poses it, spine-core otherwise, the reason
2755
- * 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.
2756
2969
  */
2757
2970
  export function surveyOfBuild(input: DeformSurveyInput, exempt: ReadonlySet<string>, asked: 'auto' | DeformSurveySource): DeformSurvey {
2758
- const data = skeletonDataFromText(input.skeletonText, input.atlasText);
2759
- 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
+ };
2760
2977
  if (asked === 'spine-core') return throughSpine(null);
2761
2978
  if (input.modelText === null) {
2762
2979
  if (asked === 'model') throw new CoreInputError('the survey was asked to pose the model document, and the input carries none');
2763
2980
  return throughSpine('the input carries no model document (skeleton.model.json), so the survey posed the Spine skeleton through spine-core');
2764
2981
  }
2765
2982
  try {
2766
- 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);
2767
2990
  } catch (err) {
2768
2991
  if (!(err instanceof CoreInputError) || asked === 'model') throw err;
2769
2992
  return throughSpine(`the core refused to pose the model document — ${err.message}`);