spine-rigc 1.5.3 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -80,14 +80,9 @@ import {
80
80
  } from './src/check.ts';
81
81
  import { writeCheckPictures } from './src/checkpics.ts';
82
82
  import { compile, CompileError, droppedStateReason, relativeImagesPath, type CompileOptions } from './src/compile.ts';
83
- import {
84
- skeletonDataFromText,
85
- surveyDeformKeys,
86
- unreachableWhy,
87
- type DeformExtreme,
88
- type DeformKeyMeasure,
89
- type DeformSpan,
90
- } from './src/deformmeasure.ts';
83
+ import { MODEL_DOCUMENT_FILE, modelDocument } from './src/model.ts';
84
+ import { deformReportBlock } from './src/deformreport.ts';
85
+ import { surveyOfBuild } from './src/deformmeasure.ts';
91
86
  import {
92
87
  diffLines,
93
88
  diffSkeletons,
@@ -112,7 +107,6 @@ import {
112
107
  } from './src/atlas.ts';
113
108
  import { parseJsonWithPosition } from './src/json-position.ts';
114
109
  import { PARSER_DEFAULTS, parserReading } from './src/keyorder.ts';
115
- import { float32Step } from './src/timelines.ts';
116
110
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
117
111
  import {
118
112
  DEFAULT_MAX_RESIDUAL,
@@ -146,6 +140,8 @@ import {
146
140
  framingViewport,
147
141
  GEOMETRY_FILE,
148
142
  GeometryError,
143
+ UnframeablePoseError,
144
+ skinRosterOf,
149
145
  geometryFileOf,
150
146
  geometryText,
151
147
  loadPosable,
@@ -159,10 +155,16 @@ import {
159
155
  sidecarViewport,
160
156
  SlotSubsetError,
161
157
  slotSubsetOf,
158
+ candidatePosers,
159
+ POSER_NAMES,
160
+ PoserChoiceError,
161
+ throughPoser,
162
162
  type Frame,
163
163
  type FramesSidecar,
164
164
  type FrameSet,
165
165
  type Posable,
166
+ type PoserChoice,
167
+ type PoserName,
166
168
  type SlotSubset,
167
169
  } from './src/render.ts';
168
170
  import {
@@ -451,18 +453,21 @@ interface AtlasOverride {
451
453
 
452
454
  function runGate(
453
455
  result: CompileResult,
456
+ modelText: string,
454
457
  opts: CompileOptions,
455
458
  profile: ValidateProfile,
456
459
  atlas?: AtlasOverride,
457
460
  ): number {
458
- // The determinism check compares a second, independent compile.
461
+ // The determinism check compares a second, independent compile — its model
462
+ // document included, which is the text `build` writes beside the pair.
459
463
  const again = compile(opts);
460
464
  const report = validate({
461
465
  skeletonText: result.skeletonText,
462
466
  atlasText: atlas ? atlas.text : result.atlasText,
463
467
  atlasDir: opts.outDir,
464
468
  declaredDurations: result.declaredDurations,
465
- reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText },
469
+ modelText,
470
+ reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText) },
466
471
  rig: result.rig,
467
472
  profile,
468
473
  });
@@ -728,18 +733,6 @@ function meshBudget(rig: CompileResult['rig']): string {
728
733
  return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
729
734
  }
730
735
 
731
- /**
732
- * One extreme, as `x0.637306 tri 0`, or an em dash when no triangle on the key
733
- * could carry the quantity.
734
- *
735
- * A dash rather than `x1.000000`: a key over a mesh whose every triangle is a
736
- * hair has no ratio and no map, and printing the identity there would report a
737
- * measurement that was never taken — this repository's favourite false green.
738
- */
739
- function deformExtreme(extreme: DeformExtreme | null): string {
740
- return extreme === null ? '—'.padEnd(9) : `x${extreme.value.toFixed(6)} tri ${extreme.triangle}`;
741
- }
742
-
743
736
  /**
744
737
  * The `MEMBER` report block — a group track's per-member values, side by side
745
738
  * (issue #295).
@@ -814,28 +807,6 @@ function memberReportLines(result: CompileResult): string[] {
814
807
  return out;
815
808
  }
816
809
 
817
- /** `head/head key 1`, which is how A39's own message names a key. */
818
- function deformKeyName(key: DeformKeyMeasure): string {
819
- return `${key.slot}/${key.attachment} key ${key.key}`;
820
- }
821
-
822
- /**
823
- * Does this compiled `transform` report belong to this loaded key?
824
- *
825
- * ⚠️ The two times are not the same number and cannot be compared with `===`.
826
- * The report's is the spec's own `t`; the survey's came back through
827
- * `Float32Array`, because that is what `spine-core` reads a timeline's frames
828
- * into — a key written `0.62` arrives as `0.6200000047683716`. So the tolerance
829
- * is one float32 step at this magnitude (`float32Step`): the compiler emits a
830
- * key time as a float's name — the spec's own time when it names one, the float
831
- * below it when it does not (issue #716) — so the loaded float is within one
832
- * step of the spec's `t` either way, which is narrower than any key spacing the
833
- * format can hold.
834
- */
835
- function sameKeyTime(specTime: number, loaded: number): boolean {
836
- return Math.abs(specTime - loaded) <= float32Step(loaded);
837
- }
838
-
839
810
  /**
840
811
  * The `DEFORM` report block — what each deform key does to the geometry, per key
841
812
  * and then per animation (issue #316).
@@ -890,327 +861,11 @@ function sameKeyTime(specTime: number, loaded: number): boolean {
890
861
  * deserves the answer rather than a silence. What does move is the stretch.
891
862
  */
892
863
  function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>): string[] {
893
- const survey = surveyDeformKeys(skeletonDataFromText(result.skeletonText, result.atlasText));
894
- if (survey.timelines === 0) return [];
895
- const out: string[] = ['', 'deform (what each key does to the geometry — figures with names, never a bar; issue #316)'];
896
- // The legend costs six lines and is worth them exactly once — on a report that
897
- // has figures in it. A bounding box or a clipping polygon deformed and nothing
898
- // else gets the reason it has no figures and no essay about them.
899
- if (survey.keys.length) {
900
- out.push(
901
- ' .. every key measured at its OWN time against the same pose with the deform CLEARED, so the',
902
- ' .. denominator is 1.000 by definition and a NEGATIVE area ratio IS a reversed triangle',
903
- ' .. the FRAME is on each key line: on a track, or the slider that applies the animation with the',
904
- ' .. dial value its mapping was inverted to — a slider picks the time, so the key\'s time IS it',
905
- ' .. stretch is the two singular values of the map from the cleared triangle to the deformed one —',
906
- ' .. the worst stretch and the worst squash the drawing takes there; their product is |area ratio|',
907
- ' .. coverage is NOT here: it is rasterised from the uvs, which no deform moves, so the figure on',
908
- ' .. the `meshes` line below is already the deformed one',
909
- );
910
- }
911
- if (survey.notAMesh.length) {
912
- out.push(` .. ${survey.notAMesh.join(', ')} deform an attachment with no triangles — nothing to measure`);
913
- }
914
- for (const key of survey.keys) {
915
- const model = result.deformTransforms.find(
916
- (g) =>
917
- g.animation === key.animation &&
918
- g.skin === key.skin &&
919
- g.slot === key.slot &&
920
- g.attachment === key.placeholder &&
921
- sameKeyTime(g.time, key.time),
922
- );
923
- // A stated model is quoted rather than reduced to its results: `yaw
924
- // radius=170 degrees=12` is what a reviewer checks the ratios against, and an
925
- // authored table says so instead of saying nothing, because "no model here"
926
- // is itself the thing a reader of a wrong ratio needs to know.
927
- const states = model === undefined ? 'authored table' : `transform ${model.kind} ${model.stated}`;
928
- out.push(
929
- ` DEFORM ${key.animation} ${key.skin}/${key.slot}/${key.placeholder} key ${key.key} ` +
930
- `t=${key.time.toFixed(6)} ${states}`,
931
- );
932
- // 🔒 The frame, on every key, because the derivation is shared with A39 and
933
- // this block is where a reader finds out which one it was (issue #407). A
934
- // track frame says so in three words; a slider frame names the dial value
935
- // its own mapping inverts this time to, which is the number an author sets.
936
- out.push(
937
- ` frame ${key.reach.label}` +
938
- (key.dial === null
939
- ? ''
940
- : `, dial ${key.dial.value.toFixed(6)}` +
941
- // ⚠️ `reach.drive` names the field when it is NOT the one `property`
942
- // names, which happens under `local: false` on a rotated parent — a
943
- // bare figure there would read as a value of the wrong field (#419).
944
- (key.reach.local || key.dial.driven === key.dial.value
945
- ? ''
946
- : ` (bone local ${key.reach.drive === null ? '' : `${key.reach.drive} `}${key.dial.driven.toFixed(6)})`) +
947
- ` -> t=${key.dial.applied.toFixed(6)}`),
948
- );
949
- // A key at a time no dial selects: the figures below are the frame the
950
- // runtime DOES land on, which is some other time's geometry, so the line
951
- // that says so comes before them and the gate reads none of them.
952
- if (key.dial?.unreachable === true) {
953
- out.push(
954
- ` unreachable A39 gates nothing here: ${unreachableWhy(key)}. Every figure below is that other ` +
955
- "frame's, not this key's",
956
- );
957
- }
958
- // ⚠️ An exemption nobody can see is how a gate comes to look kept while
959
- // checking nothing (issue #401). A key the gate passed over because the mesh
960
- // draws no pixels there says so on its own line, in the survey's own words,
961
- // whether or not it folds.
962
- if (key.draw.blank !== null) {
963
- out.push(
964
- ` skipped A39 reads no winding off this key: ${key.draw.blank} — a triangle that draws no ` +
965
- 'pixels cannot draw them backwards',
966
- );
967
- }
968
- // And when the slot shows something else, the figures below would be a
969
- // second falsehood rather than a caveat: the runtime applies no deform to a
970
- // slot that is not showing the mesh (`DeformTimeline.applyToSlot`), so every
971
- // figure would be the identity and `moved 0` would read as "this key is the
972
- // setup pose" — which is exactly what the key is NOT.
973
- if (!key.draw.showsThisMesh) {
974
- out.push(
975
- ` .. the slot shows ${key.draw.shown === null ? 'no attachment' : `"${key.draw.shown}"`} ` +
976
- 'here, so the runtime applied no deform and there is no posed geometry to measure' +
977
- (key.draw.blank === null
978
- ? ' — but the mesh IS drawn in another slot this deform reaches (timelineSlots), so nothing here is exempt'
979
- : ''),
980
- );
981
- continue;
982
- }
983
- // A key that moves nothing gets one line and no figures. `{ "t": 2.2 }` with
984
- // no run is the format's own way of writing "back to the setup pose" (§4.11),
985
- // and its geometry is bit-identical to the cleared pose it would be measured
986
- // against — so `x1.000000` there is the definition and not a measurement, and
987
- // four lines of it on every loop's opening and closing key is the noise that
988
- // stops the block being read. It is still counted in the rollup below,
989
- // because A39 measures it too.
990
- if (key.moved === 0) {
991
- out.push(
992
- ` moved 0 of ${key.vertices} vertices — this key IS the setup pose, so every ` +
993
- `figure is the identity (${key.triangles} triangles, all kept)`,
994
- );
995
- continue;
996
- }
997
- out.push(
998
- ` moved ${key.moved} of ${key.vertices} vertices, ` +
999
- `worst ${key.maxDisplacement.toFixed(4)}px at v${key.maxDisplacementVertex}`,
1000
- );
1001
- out.push(
1002
- ` area min ${deformExtreme(key.areaRatioMin)} max ${deformExtreme(key.areaRatioMax)} ` +
1003
- `(${key.triangles} triangles, ${key.degenerate} with no area at the cleared pose, band ${key.band.toFixed(6)}px²)`,
1004
- );
1005
- out.push(
1006
- ` stretch max ${deformExtreme(key.stretchMax)} min ${deformExtreme(key.stretchMin)}`,
1007
- );
1008
- // The marker has to know about the exemption, or it says the false half of
1009
- // the truth on the one build where it matters: a declared fold IS a fold and
1010
- // A39 does not refuse it — it SKIPs the slot entirely.
1011
- const exempted = exempt.has(key.slot);
1012
- const fold = key.reversed.length
1013
- ? key.draw.blank !== null
1014
- ? ' <- a fold, and nothing gates it: this key draws no pixels (see above)'
1015
- : exempted
1016
- ? ' <- a fold, and A39 does not gate it — see below'
1017
- : ' <- a fold: A39 refuses this key by name'
1018
- : '';
1019
- out.push(
1020
- ` winding ${key.triangles - key.reversed.length} of ${key.triangles} kept, ` +
1021
- `${key.collapsed} collapsed${fold}`,
1022
- );
1023
- if (exempted) {
1024
- out.push(
1025
- ` .. A39 is exempt on "${key.slot}" (invariants.deformMayFold), so nothing here is gated`,
1026
- );
1027
- }
1028
- }
1029
- // The folds at times no key lands on (issue #403), printed after the keys they
1030
- // lie between rather than interleaved: they are a different measurement — the
1031
- // closed form named the time and the runtime was posed there — and a reader
1032
- // needs to be able to tell the two apart at a glance.
1033
- for (const span of survey.spans) {
1034
- if (span.fold === null) continue;
1035
- const at = span.fold;
1036
- out.push(
1037
- ` BETWEEN ${span.animation}${span.reach.kind === 'slider' ? ` via ${span.reach.slider}` : ''} ` +
1038
- `${span.skin}/${span.slot}/${span.placeholder} key ${span.fromKey} -> ` +
1039
- `${span.toKey} t=${at.time.toFixed(6)} ${span.curve}` +
1040
- (span.curve === 'stepped' ? ' (held, not interpolated)' : ` ${(at.percent * 100).toFixed(1)}% of the way`),
1041
- );
1042
- out.push(
1043
- ` winding ${at.measure.triangles - at.measure.reversed.length} of ${at.measure.triangles} kept, ` +
1044
- `${at.measure.collapsed} collapsed <- a fold at a time no key lands on` +
1045
- (at.measure.draw.blank !== null
1046
- ? ', and nothing gates it: nothing is drawn there'
1047
- : exempt.has(span.slot)
1048
- ? ', and A39 does not gate it (invariants.deformMayFold)'
1049
- : `: A39 refuses this span by name, at alpha ${at.measure.draw.alpha.toFixed(4)}`),
1050
- );
1051
- }
1052
- // The rollup, per animation: the worst key by each quantity. A timeline's own
1053
- // eight keys are eight blocks above, and "which of them is the one to look at"
1054
- // is the question the sweep in issue #313's landing comment answered by hand.
1055
- //
1056
- // ⚠️ Per animation AND per frame (issue #407). Two sliders applying one
1057
- // animation are two frames and two rollups: merging them would average a fold
1058
- // one dial reaches into a run of keys another one is clean over, which is the
1059
- // hiding the two frames exist to prevent.
1060
- // 🔒 ONE derivation of a rollup's identity, read by every filter below.
1061
- //
1062
- // ⚠️ It was spelled three times and one of them drifted. The two key filters
1063
- // separate animation from slider with a NUL; the span filter used a SPACE, so
1064
- // its `=== id` never matched on any rig and `spans` was always empty — the
1065
- // "N span(s) … scanned" line silently stopped printing everywhere, taking with
1066
- // it the one thing issue #403 added it to say: that the scan RAN and found
1067
- // nothing, as opposed to never having run. A dead branch is the same silence
1068
- // this tool exists to convert into a named failure, and it survived because
1069
- // the identity was a literal at each site rather than a derivation (#440).
1070
- const rollupId = (animation: string, slider: string | null): string => `${animation}\u0000${slider ?? ''}`;
1071
- /**
1072
- * The readings A39 puts on its stats line for the three things this rollup
1073
- * reports — each spelled ONCE here and nowhere else in this file.
1074
- *
1075
- * 🔒 Checked rather than derived, and the difference is forced: `src/validate.ts`
1076
- * sets these on a `Record<string, number | string>`, so there is no type to take
1077
- * a name off and no constant to import. So they are spelled here and a control
1078
- * compiles a rig that triggers each one, then asserts the breadcrumb names a
1079
- * reading A39 really printed — two independent derivations compared, which is
1080
- * the shape `CUR07` uses for the same reason.
1081
- */
1082
- const A39_COUNTS = {
1083
- notDrawn: 'deformKeysNotDrawn',
1084
- unreachable: 'deformKeysUnreachable',
1085
- dialsDisagreed: 'deformDialsDisagreed',
1086
- dialDisagreed: 'deformDialDisagreed',
1087
- } as const;
1088
- const rollups = new Map<string, { animation: string; label: string }>();
1089
- for (const key of survey.keys) {
1090
- rollups.set(rollupId(key.animation, key.reach.slider), {
1091
- animation: key.animation,
1092
- label: key.reach.kind === 'slider' ? `${key.animation} via ${key.reach.slider}` : key.animation,
1093
- });
1094
- }
1095
- for (const [id, { animation, label }] of rollups) {
1096
- // Only the keys the gate ran on, because the line ends by claiming A39 reads
1097
- // the same two counts and A39 reads none of a key that draws nothing, nor of
1098
- // one at a time no dial selects. The ones it left out get their own line
1099
- // rather than a silence (issues #401, #407).
1100
- const mine = survey.keys.filter((k) => rollupId(k.animation, k.reach.slider) === id);
1101
- const unreachable = mine.filter((k) => k.dial?.unreachable === true);
1102
- const keys = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank === null);
1103
- const blank = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
1104
- const worst = (
1105
- pick: (key: DeformKeyMeasure) => DeformExtreme | null,
1106
- better: (a: number, b: number) => boolean,
1107
- ): string => {
1108
- let best: { key: DeformKeyMeasure; extreme: DeformExtreme } | null = null;
1109
- for (const key of keys) {
1110
- const extreme = pick(key);
1111
- if (extreme === null) continue;
1112
- if (best === null || better(extreme.value, best.extreme.value)) best = { key, extreme };
1113
- }
1114
- return best === null ? '—' : `x${best.extreme.value.toFixed(6)} (${deformKeyName(best.key)} tri ${best.extreme.triangle})`;
1115
- };
1116
- const reversed = keys.reduce((n, k) => n + k.reversed.length, 0);
1117
- const collapsed = keys.reduce((n, k) => n + k.collapsed, 0);
1118
- const samples = keys.reduce((n, k) => n + k.triangles, 0);
1119
- if (keys.length) {
1120
- out.push(
1121
- ` WORST ${label} area ${worst((k) => k.areaRatioMin, (a, b) => a < b)} ` +
1122
- `stretch ${worst((k) => k.stretchMax, (a, b) => a > b)} ` +
1123
- `squash ${worst((k) => k.stretchMin, (a, b) => a < b)}`,
1124
- );
1125
- out.push(
1126
- ` .. ${''.padEnd(label.length)} reversed ${reversed}, collapsed ${collapsed}, over ` +
1127
- `${keys.length} key(s) and ${samples} triangle sample(s) <- A39 reads the same two counts`,
1128
- );
1129
- }
1130
- if (blank.length) {
1131
- out.push(
1132
- ` .. ${keys.length ? ''.padEnd(label.length) : label} ${blank.length} key(s) draw no pixels ` +
1133
- `at their own time and are read for no winding, carrying ` +
1134
- `${blank.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates <- A39 counts ` +
1135
- `them as ${A39_COUNTS.notDrawn}`,
1136
- );
1137
- }
1138
- if (unreachable.length) {
1139
- out.push(
1140
- ` .. ${keys.length || blank.length ? ''.padEnd(label.length) : label} ${unreachable.length} key(s) ` +
1141
- 'at a time no dial selects, measured in the frame the runtime lands on instead and read for no winding, ' +
1142
- `carrying ${unreachable.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates ` +
1143
- ` <- A39 counts them as ${A39_COUNTS.unreachable}`,
1144
- );
1145
- }
1146
- // ⚠️ The dial rigc's two halves disagree about (issues #427, #440). It is a
1147
- // property of the SLIDER and not of any one key, so it is placed by the
1148
- // rollup's own identity rather than by a key filter — and it is REPORTED,
1149
- // never gated: A39 refuses nothing for it, because a disagreement can pose
1150
- // every frame correctly. Both readings are named because they differ by one
1151
- // letter, and a breadcrumb that named only one of `deformDialsDisagreed` /
1152
- // `deformDialDisagreed` would send a reader to grep for the other.
1153
- const disputes = survey.dialDisputes.filter((dispute) => rollupId(animation, dispute.slider) === id);
1154
- if (disputes.length) {
1155
- out.push(
1156
- ` .. ${keys.length || blank.length || unreachable.length ? ''.padEnd(label.length) : label} ` +
1157
- `${disputes.length} dial(s) the skeleton and the probe disagree about: ` +
1158
- disputes
1159
- .map(
1160
- (dispute) =>
1161
- `the skeleton reads ${dispute.bone}.${dispute.stated} and the probe drives ` +
1162
- `${dispute.bone}.${dispute.drive}, ` +
1163
- (dispute.outside.length === 0
1164
- ? 'and both answers pose the same frames'
1165
- : `${dispute.outside.length} key time(s) outside what the skeleton's own field reaches`),
1166
- )
1167
- .join('; ') +
1168
- ` <- A39 counts them as ${A39_COUNTS.dialsDisagreed} and spells them out as ${A39_COUNTS.dialDisagreed}`,
1169
- );
1170
- }
1171
- // ⚠️ Printed on a clean animation too. "The scan ran and found nothing" and
1172
- // "the scan never ran" are the two things a gate must never say the same
1173
- // way, and this line is the only place an author can tell them apart
1174
- // (issue #403).
1175
- const spans = survey.spans.filter((s) => rollupId(s.animation, s.reach.slider) === id);
1176
- if (spans.length) {
1177
- out.push(
1178
- ` .. ${keys.length || blank.length || unreachable.length || disputes.length ? ''.padEnd(label.length) : label} ` +
1179
- `${spans.length} span(s) between consecutive keys scanned for a fold no key lands on: ` +
1180
- `${spanTally(spans)} <- A39 reads the same scan`,
1181
- );
1182
- }
1183
- }
1184
- // ⚠️ And the spans that were NOT scanned, once, because a scan that did not
1185
- // run has to be distinguishable from one that ran and found nothing — the same
1186
- // rule the line above keeps, on the other side of it (issue #407).
1187
- if (survey.spansNotScanned) {
1188
- out.push(
1189
- ` .. ${survey.spansNotScanned} span(s) NOT scanned: one of the two keys bounding each is at a time no ` +
1190
- 'dial selects, so the interpolation between them is between two poses of some other time',
1191
- );
1192
- }
1193
- return out;
1194
- }
1195
-
1196
- /** What the between-keys scan found, in one clause (issue #403). */
1197
- function spanTally(spans: readonly DeformSpan[]): string {
1198
- const folds = spans.filter((s) => s.fold !== null).length;
1199
- const notDrawn = spans.filter((s) => s.notDrawn > 0).length;
1200
- const unconfirmed = spans.filter((s) => s.unconfirmed).length;
1201
- const probes = spans.reduce((n, s) => n + s.probed.length, 0);
1202
- if (folds === 0 && notDrawn === 0 && unconfirmed === 0) {
1203
- return `none folds (the closed form flagged nothing, so no span cost a posed measurement)`;
1204
- }
1205
- return (
1206
- [
1207
- folds ? `${folds} fold(s)` : '',
1208
- notDrawn ? `${notDrawn} folding only where nothing is drawn` : '',
1209
- unconfirmed ? `${unconfirmed} predicted a fold no probe reproduced` : '',
1210
- ]
1211
- .filter(Boolean)
1212
- .join(', ') + `, at a cost of ${probes} posed measurement(s)`
1213
- );
864
+ // Through rigc's own core over the model document the build carries (issue #969), spine-core when the core
865
+ // refuses it; the survey's record says which (`source`), and `tools/survey_hashes.ts` holds the two to one block.
866
+ // The block itself is `deformReportBlock` (`src/deformreport.ts`), so a control renders it off either poser's survey.
867
+ const survey = surveyOfBuild({ skeletonText: result.skeletonText, atlasText: result.atlasText, modelText: modelDocument(result.model, result.skeletonText) }, new Set(), 'auto');
868
+ return deformReportBlock(survey, result.deformTransforms, exempt);
1214
869
  }
1215
870
 
1216
871
  /**
@@ -1456,8 +1111,12 @@ function cmdBuild(flags: Record<string, string>): void {
1456
1111
  );
1457
1112
  }
1458
1113
 
1114
+ // The model's document is spelled before the gate, so the text A18 compares
1115
+ // is the text written, and a model the document cannot carry is refused
1116
+ // before anything is (issue #922).
1117
+ const modelText = modelDocument(result.model, result.skeletonText);
1459
1118
  console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
1460
- const failures = runGate(result, opts, profile);
1119
+ const failures = runGate(result, modelText, opts, profile);
1461
1120
  if (failures > 0) {
1462
1121
  console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
1463
1122
  process.exit(1);
@@ -1538,7 +1197,7 @@ function cmdBuild(flags: Record<string, string>): void {
1538
1197
  })),
1539
1198
  packOpts,
1540
1199
  );
1541
- const packFailures = runGate(result, opts, profile, { text: atlasText, again: packAgain.atlasText });
1200
+ const packFailures = runGate(result, modelText, opts, profile, { text: atlasText, again: packAgain.atlasText });
1542
1201
  if (packFailures > 0) {
1543
1202
  console.error(
1544
1203
  `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
@@ -1550,8 +1209,13 @@ function cmdBuild(flags: Record<string, string>): void {
1550
1209
 
1551
1210
  writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
1552
1211
  writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
1212
+ // rigc's own record of the compiled rig (`rigc-compiled/1`, issue #922),
1213
+ // written with the pair and only after the same gate. rigc's own posing core
1214
+ // reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
1215
+ writeFileSync(join(opts.outDir, MODEL_DOCUMENT_FILE), modelText);
1553
1216
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
1554
1217
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
1218
+ console.log(`rigc: wrote ${join(opts.outDir, MODEL_DOCUMENT_FILE)}`);
1555
1219
  // The next command is part of the message (issue #837). A green build is the
1556
1220
  // moment somebody wants to see what came out, and the one page rigc writes
1557
1221
  // for that is `preview` of exactly this directory — so the line names it,
@@ -1782,12 +1446,17 @@ function runCheck(
1782
1446
  plates?: CheckPlates,
1783
1447
  ): CheckReport {
1784
1448
  const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
1449
+ // The poser is `render`'s choice, over the same two files (issue #968): the
1450
+ // paths are what `candidatePosers` looks beside for `skeleton.model.json`.
1451
+ const poser = readPoserFlag(flags);
1785
1452
  return checkAgainstFrames({
1786
1453
  skeletonText: readFileSync(skeletonPath, 'utf8'),
1787
1454
  atlasText: readFileSync(atlasPath, 'utf8'),
1788
1455
  atlasDir: dirname(atlasPath),
1789
1456
  framesDir,
1790
1457
  labels: { skeleton: skeletonPath, atlas: atlasPath },
1458
+ candidatePaths: { skeleton: skeletonPath, atlas: atlasPath },
1459
+ ...(poser === undefined ? {} : { poser }),
1791
1460
  ...readCheckFlags(flags),
1792
1461
  ...(plates === undefined ? {} : { plates }),
1793
1462
  });
@@ -1947,6 +1616,26 @@ function readSlotSubsetFlags(
1947
1616
  }
1948
1617
  }
1949
1618
 
1619
+ /**
1620
+ * `--poser core|spine`, resolved against the candidate (issue #968): both
1621
+ * posers for it, and which one this run asked for. `core` on an input that
1622
+ * cannot carry it is refused as a usage error by name; absent, the input's own
1623
+ * choice stands — see `candidatePosers`.
1624
+ */
1625
+ function readPoserChoice(flags: Record<string, string>, data: Posable['data'], skeletonPath: string, atlasPath: string): PoserChoice {
1626
+ return candidatePosers(data, skeletonPath, atlasPath, readPoserFlag(flags));
1627
+ }
1628
+
1629
+ /** `--poser` as spelled, checked against the posers there are — shared by `render` and `check`. */
1630
+ function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
1631
+ const raw = flags.poser;
1632
+ const forced = POSER_NAMES.find((name) => name === raw);
1633
+ if (raw !== undefined && forced === undefined) {
1634
+ throw new UsageError(`--poser ${JSON.stringify(raw)}: known posers are ${POSER_NAMES.join(', ')}`);
1635
+ }
1636
+ return forced;
1637
+ }
1638
+
1950
1639
  /** A resolved subset as the one field it is spelled as, in `PoseOptions` and in `frames.json` alike. */
1951
1640
  function subsetFields(subset: SlotSubset | undefined): { slots?: string[]; hidden?: string[] } {
1952
1641
  if (subset === undefined) return {};
@@ -2053,39 +1742,56 @@ function cmdRender(flags: Record<string, string>): void {
2053
1742
  if (skin !== undefined) console.log(` .. skin ${skin}`);
2054
1743
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
2055
1744
 
2056
- // `null` is a skeleton that posed no vertex at all. One that posed a vertex
2057
- // it cannot frame — Infinity or NaN — is thrown from the framing as a
2058
- // `GeometryError` naming the number (issue #873), and never reaches this.
2059
- const viewport = framingViewport(data, maxSide, pose);
2060
- if (!viewport) {
2061
- throw new UsageError(
2062
- `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
2063
- skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
2064
- } — there is nothing to draw`,
2065
- );
2066
- }
1745
+ // Which implementation of the posing seam draws this (issue #968): rigc's own
1746
+ // core when the candidate is a rigc build — `skeleton.model.json` beside the
1747
+ // pair — and spine-core otherwise, or where the core refuses the input by
1748
+ // name. Never silently: the `poser` line below says which, and why.
1749
+ const choice = readPoserChoice(flags, data, skeletonPath, atlasPath);
1750
+
1751
+ // Everything is posed before anything is written, so a core refusal partway
1752
+ // re-poses the whole input on spine-core rather than leaving half of a frame
1753
+ // set drawn by each.
1754
+ const posed = throughPoser(choice, (poser) => {
1755
+ // `null` is a skeleton that posed no vertex at all. One that posed a vertex
1756
+ // it cannot frame — Infinity or NaN — is thrown from the framing as a
1757
+ // `GeometryError` naming the number (issue #873), and one whose every vertex
1758
+ // sits at one point — every drawn bone unposed by the skin, or collapsed — as
1759
+ // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
1760
+ // lets the second name the skins that pose a bone, whichever poser draws.
1761
+ const viewport = framingViewport(poser, maxSide, pose, skinRosterOf(data));
1762
+ if (!viewport) {
1763
+ throw new UsageError(
1764
+ `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
1765
+ skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
1766
+ } — there is nothing to draw`,
1767
+ );
1768
+ }
2067
1769
 
2068
- // `sampleAll` covers the skeleton with no animation at all, which files its one
2069
- // setup-pose frame under the reserved name. Narrowing to one animation reuses
2070
- // the same sampler rather than a second path through it.
2071
- //
2072
- // `--geometry` rides on the SAME call (issue #864): the bones and the whole
2073
- // attachments are read off the skeleton at the step that drew each frame, so
2074
- // the export's grid is this frame set's by construction.
2075
- const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
2076
- const sampled: Map<string, Frame[]> =
2077
- only === undefined
2078
- ? sampleAll(data, fps, sampling)
2079
- : new Map([[only, sampleAnimation(data, only, fps, sampling)]]);
2080
- // Every file's text before the first write, so a refused number leaves the
2081
- // output directory as it was rather than half of a frame set behind it.
2082
- const geometryTexts = new Map<string, string>();
2083
- if (geometry) {
2084
- for (const [name, frames] of sampled) {
2085
- const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
2086
- geometryTexts.set(name, geometryText(geometryFileOf(data, animation, fps, frames, viewport, skin)));
1770
+ // `sampleAll` covers the skeleton with no animation at all, which files its one
1771
+ // setup-pose frame under the reserved name. Narrowing to one animation reuses
1772
+ // the same sampler rather than a second path through it.
1773
+ //
1774
+ // `--geometry` rides on the SAME call (issue #864): the bones and the whole
1775
+ // attachments are read off the skeleton at the step that drew each frame, so
1776
+ // the export's grid is this frame set's by construction.
1777
+ const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
1778
+ const sampled: Map<string, Frame[]> =
1779
+ only === undefined
1780
+ ? sampleAll(poser, fps, sampling)
1781
+ : new Map([[only, sampleAnimation(poser, only, fps, sampling)]]);
1782
+ // Every file's text before the first write, so a refused number leaves the
1783
+ // output directory as it was rather than half of a frame set behind it.
1784
+ const geometryTexts = new Map<string, string>();
1785
+ if (geometry) {
1786
+ for (const [name, frames] of sampled) {
1787
+ const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
1788
+ geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
1789
+ }
2087
1790
  }
2088
- }
1791
+ return { viewport, sampled, geometryTexts };
1792
+ });
1793
+ const { viewport, sampled, geometryTexts } = posed.value;
1794
+ console.log(` .. poser ${posed.note}`);
2089
1795
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
2090
1796
  if (!declaresSetupStage(data)) console.log(` .. ${STAGELESS_FRAMING.render}`);
2091
1797
 
@@ -3812,7 +3518,7 @@ function cmdSkills(flags: Record<string, string>, positional: string[]): void {
3812
3518
  const FLAG_MEANINGS: Record<string, string> = {
3813
3519
  rig: 'the rig spec — skeleton structure',
3814
3520
  motion: 'the motion spec — time',
3815
- out: 'directory for skeleton.json + skeleton.atlas; atlas page paths and skeleton.images are written relative to it',
3521
+ out: 'directory for skeleton.json + skeleton.atlas (and, from build, skeleton.model.json); atlas page paths and skeleton.images are written relative to it',
3816
3522
  images: "override the rig spec's own images directory (relative to your working directory)",
3817
3523
  manifest: 'a cut manifest, for a rig with measured art behind it; a foreign skeleton has none',
3818
3524
  'copy-images':
@@ -3850,6 +3556,11 @@ const FLAG_MEANINGS: Record<string, string> = {
3850
3556
  `also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
3851
3557
  "slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
3852
3558
  'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
3559
+ poser:
3560
+ "`render` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
3561
+ 'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
3562
+ "beside the skeleton and the skeleton is the one the document records (spine.sha256), `spine` otherwise and wherever the core refuses the input by name; the `poser` " +
3563
+ 'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name',
3853
3564
  'texture-from':
3854
3565
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
3855
3566
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -3954,6 +3665,7 @@ const FLAG_VALUES: Record<string, string> = {
3954
3665
  padding: '<px>',
3955
3666
  'page-edges': 'pot|free',
3956
3667
  'texture-from': '<path>',
3668
+ poser: 'core|spine',
3957
3669
  reference: '<dir|skeleton.json>',
3958
3670
  'reference-atlas': '<path>',
3959
3671
  bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
@@ -4130,7 +3842,7 @@ const COMMANDS: CommandDoc[] = [
4130
3842
  {
4131
3843
  name: 'check',
4132
3844
  usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
4133
- flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'all-frames', 'json', 'out'],
3845
+ flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'poser', 'all-frames', 'json', 'out'],
4134
3846
  overrides: {
4135
3847
  skin: {
4136
3848
  meaning:
@@ -4190,10 +3902,10 @@ const COMMANDS: CommandDoc[] = [
4190
3902
  {
4191
3903
  name: 'render',
4192
3904
  usage: [
4193
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--out render/]',
3905
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--poser core|spine] [--out render/]',
4194
3906
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
4195
3907
  ],
4196
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'out'],
3908
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'poser', 'out'],
4197
3909
  overrides: {
4198
3910
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
4199
3911
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -4545,10 +4257,26 @@ try {
4545
4257
  // the skeleton posed a NaN or an infinity, so exit 1 like a file that is not
4546
4258
  // a PNG. Raised by the geometry export and by the framing — the one sentence
4547
4259
  // naming the bone or vertex and its value — before the first file is written.
4260
+ // A pose whose every drawn vertex sits at one point (issue #997), from
4261
+ // `render` or `check`: exit 2 like *nothing to draw*, the other framing
4262
+ // refusal, because the usual fix is the invocation's — `--skin` — and nothing
4263
+ // was written. A `GeometryError` in kind, so it is caught before that.
4264
+ if (err instanceof UnframeablePoseError) {
4265
+ console.error(`rigc ${command}: ${err.message}`);
4266
+ process.exit(2);
4267
+ }
4548
4268
  if (err instanceof GeometryError) {
4549
4269
  console.error(`rigc render: ${err.message}`);
4550
4270
  process.exit(1);
4551
4271
  }
4272
+ // `--poser core` on an input the core cannot carry (issue #968): a refusal of
4273
+ // the invocation, nothing written, and the message names the input and why —
4274
+ // the usage under it would bury that. Without the flag the same refusal is a
4275
+ // fallback to spine-core, named on the render's `poser` line instead.
4276
+ if (err instanceof PoserChoiceError) {
4277
+ console.error(`rigc ${command}: ${err.message}`);
4278
+ process.exit(2);
4279
+ }
4552
4280
  if (err instanceof NotAPngError) {
4553
4281
  console.error(`rigc: ${err.message}`);
4554
4282
  process.exit(1);