spine-rigc 1.9.2 → 1.9.4

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 (49) hide show
  1. package/cli.ts +28 -4397
  2. package/docs/AUTHORING.md +4 -4
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a06.ts +53 -3
  5. package/src/assertions/bodies/a13.ts +3 -3
  6. package/src/assertions/bodies/a14.ts +3 -3
  7. package/src/assertions/bodies/a15.ts +5 -5
  8. package/src/assertions/bodies/a19.ts +44 -2
  9. package/src/assertions/bodies/a22.ts +3 -3
  10. package/src/assertions/facts/animated_bones.ts +4 -2
  11. package/src/assertions/facts/mesh_attachments.ts +33 -4
  12. package/src/assertions/model/animated_bones.ts +13 -5
  13. package/src/assertions/model/bone_timelines.ts +5 -3
  14. package/src/assertions/model/declared.ts +127 -0
  15. package/src/assertions/model/index.ts +63 -40
  16. package/src/assertions/model/mesh_attachments.ts +31 -2
  17. package/src/assertions/model/parse.ts +9 -1
  18. package/src/assertions/model/runtime_timelines.ts +78 -0
  19. package/src/assertions/model/slider_composition.ts +10 -8
  20. package/src/assertions/model/stepped_poses.ts +3 -2
  21. package/src/bonedist.ts +4 -5
  22. package/src/check.ts +3 -2
  23. package/src/checkpics.ts +1 -1
  24. package/src/cli/core_commands.ts +1532 -0
  25. package/src/cli/shared.ts +1935 -0
  26. package/src/cli/spine_commands.ts +1141 -0
  27. package/src/core/additive.ts +52 -62
  28. package/src/core/constraints.ts +26 -3
  29. package/src/core/constraints_physics.ts +11 -1
  30. package/src/core/constraints_slider.ts +136 -6
  31. package/src/core/index.ts +27 -3
  32. package/src/core/skins.ts +28 -3
  33. package/src/core/walk.ts +9 -36
  34. package/src/correspondence.ts +15 -0
  35. package/src/deformbuild.ts +60 -0
  36. package/src/deformmeasure.ts +20 -48
  37. package/src/deformreport.ts +2 -2
  38. package/src/framing.ts +1 -1
  39. package/src/pose.ts +1 -1
  40. package/src/preview.ts +1 -1
  41. package/src/region_joins.ts +54 -0
  42. package/src/render.ts +206 -2836
  43. package/src/render_core.ts +19 -36
  44. package/src/render_shared.ts +2887 -0
  45. package/src/slots.ts +1 -1
  46. package/src/spine_side.ts +123 -0
  47. package/src/validate.ts +11 -91
  48. package/src/assertions/facts/skin_meshes.ts +0 -49
  49. package/src/assertions/model/skin_meshes.ts +0 -90
package/src/render.ts CHANGED
@@ -90,6 +90,22 @@
90
90
  * — `src/compile.ts` must stay independent of the runtime so the compiler and
91
91
  * the gate are not checking each other's assumptions — and this file is neither.
92
92
  * It also imports `tools/plate.ts` for the PNG codec, which is dependency-free.
93
+ *
94
+ * ## What is here, and what is not (issue #1052)
95
+ *
96
+ * Only what names the runtime: spine-core's implementation of the posing seam
97
+ * (`spinePoser`), loading a Spine export (`posableFromText`, `loadPosedSkeleton`,
98
+ * and the half of `loadCandidate` an export takes), the atlas class's readings
99
+ * (`atlasPageNames`, the `spine` reader of a texture substitution), and what
100
+ * reads a posed spine-core skeleton (`piecesOf`, `boneSnapshots`,
101
+ * `posedNumbersOf`, the rest table). Everything else — the frame-set contract,
102
+ * the samplers, the candidate and its poser choice, the geometry export, the
103
+ * framing and the rasteriser — is `./render_shared.ts`, which links nothing of
104
+ * the runtime, and every name it holds that this file exported is re-exported
105
+ * from here. That module reaches this one's half through the seam
106
+ * (`./spine_side.ts`): loading this file registers it (`registerSpinePosing`,
107
+ * at the bottom), so every program that imports it renders exactly as before,
108
+ * and one that does not refuses an export by name.
93
109
  */
94
110
  import {
95
111
  AnimationState,
@@ -107,344 +123,141 @@ import {
107
123
  SkeletonData,
108
124
  type Slot,
109
125
  } from '@esotericsoftware/spine-core';
110
- import { existsSync, readFileSync } from 'node:fs';
111
- import { dirname, join, resolve } from 'node:path';
112
- import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
113
- import { pageFootprint, parseAtlasText } from './atlas.ts';
114
- import { CoreInputError } from './core/index.ts';
115
- import { computeUvs } from './core/uvs.ts';
116
- import { ATLAS_SCALE_LINE, MODEL_DOCUMENT_FILE } from './model.ts';
126
+ import { readFileSync } from 'node:fs';
127
+ // `dirname` is read by no body here today: RC23's `bonedist-pages` plant puts a `loadPosable(…, dirname(atlasPath))` back into
128
+ // `loadPosedSkeleton`, and kept, the plant reads red for its own reason rather than for a missing name.
129
+ import { dirname, join } from 'node:path';
130
+ import { Plate, readPlate } from '../tools/plate.ts';
131
+ import { clipSourceOf, corePoser, inactiveBoneSnapshot, subsetOver, unposedBones } from './render_core.ts';
132
+ import { type PosedVertices, type WorldTransform } from './nonfinite.ts';
117
133
  import {
118
- clipSourceOf,
119
- coreDocumentFacts,
120
- corePoser,
121
- inactiveBoneSnapshot,
134
+ atlasScales,
135
+ choosePosers,
136
+ pairRefusal,
137
+ refuseUnchosen,
138
+ regionKey,
139
+ type AttachmentPose,
140
+ type AttachmentRest,
141
+ type BoneSnapshot,
142
+ type DrawOptions,
143
+ type MakeCorePoser,
144
+ type Piece,
145
+ type PieceTexture,
146
+ type PoseOptions,
147
+ type Posed,
148
+ type Poser,
149
+ type PoserChoice,
150
+ type PoserName,
151
+ type SkeletonFacts,
152
+ type SkinRoster,
153
+ type SlotSubset,
154
+ type SubstituteRegion,
155
+ } from './render_shared.ts';
156
+ import { POSING_RUNTIME_TAIL, registerSpinePosing, SpineRuntimeError, spineRuntimeSentence } from './spine_side.ts';
157
+
158
+ // Every name `./render_shared.ts` holds that this file exported before issue #1052, re-exported so a dependant's
159
+ // import resolves where it always did.
160
+ export {
161
+ BACKGROUND,
162
+ CandidateAtlasError,
163
+ CandidatePairError,
164
+ EMPTY_FOOTPRINT,
165
+ FRAMES_SIDECAR,
166
+ FRAMES_SPEC,
167
+ FRAMING_FPS,
168
+ GEOMETRY_COORDINATES,
169
+ GEOMETRY_FILE,
170
+ GEOMETRY_SPEC,
171
+ GeometryError,
172
+ PAD,
173
+ POSER_NAMES,
174
+ PROTOCOL_FPS,
175
+ PoserChoiceError,
176
+ SETUP_POSE_DIR,
177
+ SHEET_COLUMNS,
178
+ SHEET_FILE,
179
+ SHEET_GAP,
180
+ SHEET_LABEL,
181
+ SHEET_RULE,
182
+ SHEET_TILE,
183
+ SUBSTITUTE_PAGE,
122
184
  SlotSubsetError,
123
- subsetOver,
124
- unposedBones,
125
- type CoreDocumentFacts,
126
- type SubsetRoster,
127
- } from './render_core.ts';
128
- import { walkTimelines } from './timelines.ts';
129
- import { firstNonFinite, type PosedVertices, type WorldTransform } from './nonfinite.ts';
130
-
131
- /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
132
- export const BACKGROUND: RGBA = [232, 232, 232, 255];
133
- /** Padding around the union bounding box, as a fraction of its long side. */
134
- export const PAD = 0.04;
135
- /**
136
- * Directory a skeleton with no animation writes its one frame into.
137
- *
138
- * It cannot collide with an animation's directory, because an animation named
139
- * `setup` would have to live in a skeleton that has at least one animation, and
140
- * this name is only ever used when there are none.
141
- */
142
- export const SETUP_POSE_DIR = 'setup';
143
- /**
144
- * The sampling rate the ladder's briefs are written against.
145
- *
146
- * It is a constant rather than a bare `12` in the default because it is also the
147
- * rate at which the directory name says nothing: a rung rendered at the protocol
148
- * rate writes `<animation>/`, and any other rate writes `<animation>@<fps>fps/`.
149
- */
150
- export const PROTOCOL_FPS = 12;
151
- /** The rate the framing box is measured at, whatever `--fps` writes frames at. */
152
- export const FRAMING_FPS = 60;
153
-
154
- // ---------------------------------------------------------------------------
155
- // the frame-set sidecar
156
- // ---------------------------------------------------------------------------
157
- //
158
- // ⭐ A rendered frame set is a picture of a world box, and the box used to be
159
- // nowhere. That cost two things. An author measuring a distance in pixels had no
160
- // way to turn it into the units a rig is authored in except by finding something
161
- // of a known size in the shot; and nothing could render a SECOND skeleton onto
162
- // the same pixel grid, because the grid was a number that existed only inside one
163
- // run of `render_reference.ts`. `frames.json` writes it down.
164
-
165
- /** The sidecar's file name and format tag. */
166
- export const FRAMES_SIDECAR = 'frames.json';
167
- export const FRAMES_SPEC = 'rigc-frames/1';
168
-
169
- /**
170
- * The contact sheet beside a frame set, and the one number its layout needs.
171
- *
172
- * ⭐ A sheet is **part of the frame set**, not an illustration of it: a long shot
173
- * commits a couple of stills and folds every sampled frame into one PNG, so for
174
- * such a set the sheet is the only picture of the 309 frames in between, and
175
- * `check` compares against its tiles (issue #36). That makes the layout a
176
- * contract between two programs — `bench/render_reference.ts` writes the grid and
177
- * `src/check.ts` reads it — so the column count lives here rather than in either.
178
- *
179
- * The tile SIZE is deliberately not here. It is a `--tile` choice per run, and a
180
- * reader can measure it exactly off the sheet's own dimensions given the frame
181
- * count and the column count (`check`'s `sheetGeometry` does), so recording it
182
- * would be a second definition of something already written down in pixels.
183
- */
184
- export const SHEET_COLUMNS = 8;
185
- /** The sheet's file name inside a frame directory. */
186
- export const SHEET_FILE = 'contact.png';
187
- /** One pixel of rule between tiles, and one around the outside. */
188
- export const SHEET_GAP = 1;
189
- /** Default long side of one contact-sheet tile, in pixels. */
190
- export const SHEET_TILE = 128;
191
- /** The rule between tiles, and the frame number drawn in each. */
192
- export const SHEET_RULE: RGBA = [176, 176, 176, 255];
193
- export const SHEET_LABEL: RGBA = [96, 96, 96, 255];
194
-
195
- /** One rendered frame directory: which animation, at what rate, and what is on disk. */
196
- export interface FrameSet {
197
- /** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
198
- dir: string;
199
- /** The animation these frames show, or `null` for a skeleton with none. */
200
- animation: string | null;
201
- fps: number;
202
- /** How many frames the animation sampled to at this rate. */
203
- sampled: number;
204
- /** How many were actually written (a stride writes fewer). */
205
- written: number;
206
- stride: number;
207
- /**
208
- * The last sampled frame's time, in seconds.
209
- *
210
- * ⚠️ Which indices are on disk is deliberately NOT recorded here. The
211
- * directory is the only author of that fact, and a second copy of it in this
212
- * file could only ever be the stale one.
213
- */
214
- duration: number;
215
- }
216
-
217
- export interface FramesSidecar {
218
- spec: string;
219
- example?: string;
220
- rung?: string;
221
- skeleton?: string;
222
- /**
223
- * The skin these frames were posed under, when one was asked for (issue #571).
224
- *
225
- * ⭐ **Absent is not `"default"`.** A render with no skin sets none — every
226
- * slot resolves through `SkeletonData.defaultSkin` alone — and a frame set
227
- * written before this field existed says nothing either, so the two are the
228
- * same fact on disk and the field is omitted for both. That is what keeps
229
- * every frame set in this repository byte-identical across this change, and it
230
- * is why `check` can refuse a mismatch it can SEE (`skin` present and
231
- * different, or present where the run asked for none) and can only NOTE the
232
- * one it cannot (`skin` absent while the run asked for one).
233
- */
234
- skin?: string;
235
- /**
236
- * The slots these frames draw, when `render --slot` narrowed them to a subset
237
- * (issue #835) — in the skeleton's draw order, whatever order they were named in.
238
- *
239
- * ⭐ Absent on a render of every slot, for the reason `skin` is: that is what
240
- * every frame set written before this field existed says too, so the whole-rig
241
- * render stays byte-identical and the key's presence is the claim. A frame set
242
- * carrying this or `hidden` is a picture of PART of the rig, and `check` refuses
243
- * it as a reference by name rather than scoring a whole candidate against it.
244
- */
245
- slots?: string[];
246
- /** The slots these frames leave out, when `render --hide` named them — see `slots`. */
247
- hidden?: string[];
248
- /** The colour the frames were cleared to, straight RGBA 0..255. */
249
- background: RGBA;
250
- viewport: {
251
- /** World box, y up, matching Spine's own coordinates. */
252
- x: number;
253
- y: number;
254
- width: number;
255
- height: number;
256
- /** Frame pixels per world unit. */
257
- scale: number;
258
- pixelWidth: number;
259
- pixelHeight: number;
260
- };
261
- sets: FrameSet[];
262
- }
263
-
264
- // ---------------------------------------------------------------------------
265
- // posing
266
- // ---------------------------------------------------------------------------
267
-
268
- /** What every drawable has in common, whatever shape it is. */
269
- export interface PieceCommon {
270
- /**
271
- * World-space vertex positions, `x, y` per vertex.
272
- *
273
- * ⭐ The one field the framing code reads, and the reason it is spelled the
274
- * same on both shapes: a union over "every posed point" is a loop over this
275
- * array in steps of two, and it does not need to know whether four numbers are
276
- * a rectangle's corners or two hundred are a mesh's hull.
277
- */
278
- world: number[];
279
- /** Slot colour x attachment colour, straight alpha, 0..1. */
280
- tint: [number, number, number, number];
281
- /**
282
- * The slot's **dark** colour, 0..1 — the other half of Spine's two-colour
283
- * tint, and absent on every slot that does not carry one.
284
- *
285
- * ⚠️ Absent rather than black, and that is the whole of why it is optional.
286
- * `(0, 0, 0)` is a real dark colour and the identity of the blend, so the two
287
- * spellings paint the same pixels — but the runtime distinguishes them
288
- * (`SlotPose.darkColor` is `null` for a slot with no `dark`, and `Slot`'s
289
- * constructor never allocates one), and a piece that carried a black default
290
- * would take the two-colour path for every slot in every frame this
291
- * repository renders. `undefined` is what keeps the arithmetic below off the
292
- * ordinary case (issue #690).
293
- *
294
- * Three channels, not four: the format writes `dark` as `rrggbb` and
295
- * `RGBA2Timeline` stores three dark channels. The alpha a shader reads on the
296
- * dark colour is not a colour at all — see `tintChannel`.
297
- */
298
- dark?: [number, number, number];
299
- /** The slot this was drawn for — what per-slot tracking is keyed by. */
300
- slot: string;
301
- /** The atlas page name this samples, so a multi-page atlas resolves. */
302
- page: string;
303
- /**
304
- * What a **texture-only** substitution needs to re-seat this piece on another
305
- * atlas — see `PieceTexture`.
306
- *
307
- * Absent unless `piecesOf` was asked for it, because it is a second copy of the
308
- * UVs and every posed frame of every set is held in memory at once.
309
- */
310
- texture?: PieceTexture;
311
- /**
312
- * The page-UV rectangle this piece may sample, and no further — see `UvWindow`.
313
- *
314
- * Absent on a piece posed from its own atlas: its UVs cover its own region's
315
- * rectangle exactly, so there is nothing to fence off. It is set by
316
- * `substituteTexture`, where the piece's geometry spans an area of the original
317
- * drawing that the substituting atlas may have trimmed away.
318
- */
319
- uvWindow?: UvWindow;
320
- }
321
-
322
- /**
323
- * One piece's texture coordinates in the **original drawing's** own space, plus
324
- * the name of the region it came from.
325
- *
326
- * ## Why original-art space and not the page's
327
- *
328
- * Page UVs are useless for substitution: they name texels in *this* atlas, and
329
- * two atlases pack the same drawing at different places, at different scales, and
330
- * possibly rotated or trimmed. What survives a repack is the position **within the
331
- * drawing** — the coordinate an artist would point at — so that is the space a
332
- * substitution goes through. `(0, 0)` is the untrimmed drawing's top-left corner
333
- * and `(1, 1)` its bottom-right, which is the convention `spine-core`'s own
334
- * `MeshAttachment.computeUVs` reads its `regionUVs` in; going through it is what
335
- * lets `substituteTexture` reuse the runtime's rotation and trim arithmetic
336
- * instead of holding a second opinion about it.
337
- */
338
- export interface PieceTexture {
339
- /** The atlas region this piece samples, by the name its atlas gives it. */
340
- region: string;
341
- /** Original-art coordinates, `u, v` per vertex, parallel to `uvs`. */
342
- artUvs: number[];
343
- /**
344
- * A clipped piece only (`Mesh.source`): the original-art UVs of each drawn
345
- * triangle's SOURCE triangle, six numbers per drawn triangle, parallel to
346
- * `Mesh.source.uvs` — what `substituteTexture` re-seats the source map with.
347
- */
348
- sourceArtUvs?: number[];
349
- }
350
-
351
- /** A page-UV rectangle outside which a piece samples nothing. */
352
- export interface UvWindow {
353
- u0: number;
354
- v0: number;
355
- u1: number;
356
- v1: number;
357
- }
358
-
359
- /** Options for `piecesOf` and the samplers that call it. */
360
- export interface PoseOptions {
361
- /** Also record each piece's original-art UVs — see `PieceTexture`. */
362
- texture?: boolean;
363
- /**
364
- * Also record every bone's world transform — see `BoneSnapshot` and
365
- * `Frame.bones`. Off by default: nothing that draws needs it, and the
366
- * one instrument that does (`bonedist.ts`) needs it on every frame.
367
- */
368
- bones?: boolean;
369
- /**
370
- * Also record every slot's attachment geometry, whole — see `AttachmentPose`
371
- * and `Frame.attachments` (issue #864). Off by default for `bones`' reason:
372
- * nothing that draws reads it, and `render --geometry` is the one caller.
373
- *
374
- * ⭐ **It is not filtered by `slots`/`hidden` and not cut by a clip.** Those
375
- * are statements about which pixels are drawn; the geometry is a statement
376
- * about where the pose put each attachment, and it is the same pose whatever
377
- * a picture of it leaves out.
378
- */
379
- geometry?: boolean;
380
- /**
381
- * Pose under this skin, by the name the skeleton declares for it.
382
- *
383
- * ⭐ Absent means **no skin is set at all**, which is spine-core's own initial
384
- * state (`Skeleton.skin` is null) and resolves every slot through
385
- * `SkeletonData.defaultSkin` alone. That is not the same claim as "the default
386
- * skin was chosen": it is the absence of a choice, and the two are spelled
387
- * differently everywhere this travels — the frames sidecar omits the field
388
- * rather than writing `"default"` into it (issue #571).
389
- *
390
- * ⚠️ Read by the SAMPLERS, never by `piecesOf`, which is handed a skeleton
391
- * somebody else already posed; handing it one is refused by name rather than
392
- * ignored, because a skin quietly dropped here is exactly the silence this
393
- * whole flag exists to remove.
394
- */
395
- skin?: string;
396
- /**
397
- * Draw only these slots, by name (issue #835). `hidden` is the same statement
398
- * the other way round, and the two together are refused.
399
- *
400
- * ⭐ **It is a filter on what is DRAWN, never on what is framed.**
401
- * `framingViewport` takes both off before it samples, so a frame with `head`
402
- * hidden sits on exactly the pixel grid of the frame with it and the two
403
- * overlay — which is the whole use of the picture: *which part is this pixel*
404
- * is answered by the difference between two frames of one grid, and a subset
405
- * re-framed to its own extent would have no second frame to differ from.
406
- *
407
- * ⚠️ Applied in `piecesOf`, where the pieces are collected, and resolved there
408
- * against the posed skeleton's own slots and skin — see `slotSubsetOf` — so a
409
- * name that draws nothing is refused by name rather than quietly matching no
410
- * piece.
411
- */
412
- slots?: string[];
413
- /** Draw every slot but these — see `slots`. */
414
- hidden?: string[];
415
- /**
416
- * Pose every attachment whole, with no clipping attachment applied — set by
417
- * `framingViewport` and by nothing that draws.
418
- *
419
- * ⭐ **The framing box counts what a clip removes**, for the reason it counts
420
- * what `--slot`/`--hide` leave out: the box is a property of the shot, and a
421
- * clip is a statement about which pixels of it are drawn. Framed on the
422
- * clipped geometry, a rig's viewport would move the moment a clip is added or
423
- * keyed, and every frame set already on disk for it — `frames.json`'s world
424
- * box, the grid `check` compares on — would stop describing the frames a
425
- * second render writes.
426
- */
427
- unclipped?: boolean;
428
- }
429
-
430
- /**
431
- * Why a slot subset cannot be drawn — `./render_core.ts` declares it, so the
432
- * spine-core poser and the core poser throw one class (issue #968).
433
- */
434
- export { SlotSubsetError };
435
-
436
- /**
437
- * A slot subset resolved against a skeleton: which half was asked for, and the
438
- * names in the skeleton's **draw order** rather than the order they were typed.
439
- *
440
- * Draw order because the names are a set and the sidecar records them: `--hide
441
- * b,a` and `--hide a,b` are one picture, and a sidecar whose bytes depended on
442
- * the spelling would make two identical frame sets differ.
443
- */
444
- export interface SlotSubset {
445
- mode: 'slots' | 'hidden';
446
- names: string[];
447
- }
185
+ UnframeablePoseError,
186
+ atlasScales,
187
+ bilinear,
188
+ bilinearChannels,
189
+ blitPiece,
190
+ contactSheet,
191
+ fill,
192
+ frameGeometry,
193
+ framingViewport,
194
+ geometryFileOf,
195
+ geometryText,
196
+ loadCandidate,
197
+ nonFinitePoseOf,
198
+ pageFor,
199
+ projector,
200
+ rasteriseMesh,
201
+ rasterisePiece,
202
+ rasteriseQuad,
203
+ refuseUnchosen,
204
+ regionTrim,
205
+ renderFrame,
206
+ sampleAll,
207
+ sampleAnimation,
208
+ sampleSetupPose,
209
+ sidecarViewport,
210
+ skeletonFacts,
211
+ slotsOnUnposedBones,
212
+ substituteTexture,
213
+ textureSubstitutionFromText,
214
+ throughPoser,
215
+ trimmedUnionBounds,
216
+ unframeableSentence,
217
+ unionBounds,
218
+ viewportFor,
219
+ viewportOfSize,
220
+ } from './render_shared.ts';
221
+ export type {
222
+ AttachmentPose,
223
+ AttachmentRest,
224
+ BoneSnapshot,
225
+ Candidate,
226
+ ClipSource,
227
+ DrawOptions,
228
+ EmitPixel,
229
+ Footprint,
230
+ Frame,
231
+ FrameGeometry,
232
+ FrameSet,
233
+ FramesSidecar,
234
+ GeometryBone,
235
+ GeometryFile,
236
+ GeometryFrame,
237
+ MakeCorePoser,
238
+ Mesh,
239
+ Piece,
240
+ PieceCommon,
241
+ PieceTexture,
242
+ PoseOptions,
243
+ Posed,
244
+ Poser,
245
+ PoserChoice,
246
+ PoserName,
247
+ Quad,
248
+ RegionTrim,
249
+ SkeletonFacts,
250
+ SkinRoster,
251
+ SlotSubset,
252
+ SubstituteRegion,
253
+ SubstitutionReader,
254
+ TextureSubstitution,
255
+ UvWindow,
256
+ Viewport,
257
+ } from './render_shared.ts';
258
+ // The refusal of a run that needs the runtime and cannot use it — `./spine_side.ts`'s since issue #1052, where an
259
+ // entry that registered no Spine side is refused in the same words.
260
+ export { SpineRuntimeError };
448
261
 
449
262
  /**
450
263
  * Resolve `slots` / `hidden` against `data` as posed under `skin`, or refuse by name.
@@ -526,38 +339,6 @@ function skeletonUnderSkin(data: SkeletonData, skin: string | undefined): Skelet
526
339
  return skeleton;
527
340
  }
528
341
 
529
- /**
530
- * One bone's world transform in one posed frame.
531
- *
532
- * ⚠️ Read off `spine-core`'s own `BonePose` and derived by its own routines —
533
- * `getWorldRotationX`, `getWorldScaleX` and friends — rather than recomputed
534
- * from `a b c d` here. A second opinion about what a bone's world rotation *is*
535
- * is exactly what an instrument comparing two skeletons must not carry: it
536
- * would show up as a difference between the two rigs.
537
- */
538
- export interface BoneSnapshot {
539
- name: string;
540
- /** World origin. */
541
- worldX: number;
542
- worldY: number;
543
- /**
544
- * The world matrix's linear part, `[a b][c d]`. **Complete**: rotation, scale
545
- * and shear all live in these four numbers, and they are dimensionless — they
546
- * map a local offset to a world offset, both in world units.
547
- */
548
- a: number;
549
- b: number;
550
- c: number;
551
- d: number;
552
- /** The direction the bone points, in degrees CCW. */
553
- rotationX: number;
554
- /** The y axis's own direction — the pair with `rotationX` is where shear shows. */
555
- rotationY: number;
556
- /** Magnitudes, always positive. */
557
- scaleX: number;
558
- scaleY: number;
559
- }
560
-
561
342
  /**
562
343
  * Every bone's world transform in the skeleton's own declaration order — a bone
563
344
  * the posed skin leaves unposed (inactive, or below an inactive bone) written as the zero transform
@@ -586,131 +367,6 @@ export function boneSnapshots(skeleton: Skeleton): BoneSnapshot[] {
586
367
  });
587
368
  }
588
369
 
589
- export interface Quad extends PieceCommon {
590
- kind: 'region';
591
- /** World-space corners, in spine-core's region order: bl, ul, ur, br (verified against computeWorldVertices — the 2026-09-03 run reconstructed this from measurement after the old comment cost it days). */
592
- world: number[];
593
- /** Page UVs for the same four corners. */
594
- uvs: ArrayLike<number>;
595
- }
596
-
597
- /**
598
- * A posed mesh attachment: world vertices, page UVs, and the triangulation.
599
- *
600
- * The vertices arrive from `MeshAttachment.computeWorldVertices`, which is the
601
- * runtime's own routine and therefore the only place the weighting and deform
602
- * arithmetic lives. Reimplementing either here would give `check` a second
603
- * opinion about where a vertex is, and a second opinion is exactly what a gate
604
- * must not have.
605
- */
606
- export interface Mesh extends PieceCommon {
607
- kind: 'mesh';
608
- /** Page UVs, `u, v` per vertex, parallel to `world`. */
609
- uvs: ArrayLike<number>;
610
- /** Vertex index triplets. */
611
- triangles: ArrayLike<number>;
612
- /**
613
- * A piece a clip cut only: for each drawn triangle, in triangle order, the
614
- * SOURCE triangle it was cut from — its three world corners and their page
615
- * UVs, six numbers each per drawn triangle (`ClipSource`). The rasteriser
616
- * samples such a triangle's pixels at the source triangle's affine UV map,
617
- * so the picture does not depend on which convex pieces the clipper cut
618
- * (issue #964). Absent on every piece no clip cut, whose path is unchanged.
619
- */
620
- source?: ClipSource;
621
- }
622
-
623
- /** The source triangles of a clipped piece's drawn triangles — see `Mesh.source`. */
624
- export interface ClipSource {
625
- /** Three world corners (`x, y` each) per drawn triangle. */
626
- world: number[];
627
- /** The page UVs of those corners, parallel to `world`. */
628
- uvs: number[];
629
- }
630
-
631
- /** One drawable in a posed frame. */
632
- export type Piece = Quad | Mesh;
633
-
634
- export interface Frame {
635
- /** Index within the sampled sequence — the number in `f0000.png`. */
636
- index: number;
637
- time: number;
638
- /**
639
- * Everything the frame draws, in draw order.
640
- *
641
- * Named `pieces` rather than `quads` since meshes joined it: a mesh is not a
642
- * quad, and a field that says otherwise is the kind of name a reader trusts
643
- * and then indexes `world[6]` through.
644
- */
645
- pieces: Piece[];
646
- /**
647
- * Every bone's world transform at this frame — present only when
648
- * `PoseOptions.bones` asked for it, so a renderer neither pays for it nor
649
- * sees a field it would have to ignore.
650
- *
651
- * ⭐ It rides on `Frame` rather than being sampled by a loop of its own so
652
- * that the ladder's stage 3 and the reference frames step a skeleton through
653
- * **one** recipe. `sampleAnimation`'s stepping order — `state.update`,
654
- * `state.apply`, `skeleton.update`, `updateWorldTransform(Physics.update)`,
655
- * and `Physics.reset` on the first frame alone — is a sequence two
656
- * implementations would drift on, and a per-frame pose comparison that
657
- * drifted from the renderer would report the drift as a difference between
658
- * the two rigs.
659
- */
660
- bones?: BoneSnapshot[];
661
- /**
662
- * Every slot's attachment as the pose left it, in draw order — present only
663
- * when `PoseOptions.geometry` asked for it (issue #864).
664
- *
665
- * ⚠️ Not `pieces` again. A piece is what gets DRAWN: `--slot`/`--hide` remove
666
- * pieces and a clip replaces one with the clipper's own triangle list, whose
667
- * vertices are not the attachment's and are not numbered like them. A
668
- * consumer comparing a triangle's edges across frames needs vertex `i` to be
669
- * the same vertex in every frame, so these are the attachment's own vertices,
670
- * whole, for every slot that shows a region or a mesh.
671
- *
672
- * Read off the same skeleton at the same step as `pieces` and `bones`, which
673
- * is what puts it on render's frame grid by construction rather than by a
674
- * second derivation of it.
675
- */
676
- attachments?: AttachmentPose[];
677
- }
678
-
679
- /**
680
- * One slot's region or mesh attachment in one posed frame (issue #864).
681
- *
682
- * `vertices` come from the runtime's own `computeWorldVertices` over the whole
683
- * attachment — the call `pieceOf` makes, through the one helper both share —
684
- * so skinning and deform live in spine-core and nowhere here.
685
- */
686
- export interface AttachmentPose {
687
- slot: string;
688
- /** The attachment's own name, which is what a deform or attachment timeline keys. */
689
- attachment: string;
690
- /**
691
- * World positions, `x, y` per vertex, **y up**. A region's four corners are in
692
- * spine-core's order — bottom-left, top-left, top-right, bottom-right — and a
693
- * mesh's vertices in the attachment's own order, so index `i` names the same
694
- * vertex in every frame.
695
- */
696
- vertices: number[];
697
- /** Slot colour x attachment colour, straight alpha, 0..1 — the piece's `tint`, by the same arithmetic. */
698
- color: [number, number, number, number];
699
- }
700
-
701
- /** Where the world sits in a frame: the four world numbers plus the scale. */
702
- export interface Viewport {
703
- minX: number;
704
- minY: number;
705
- maxX: number;
706
- maxY: number;
707
- /** Frame pixels per world unit. */
708
- scale: number;
709
- /** Frame size in pixels. */
710
- width: number;
711
- height: number;
712
- }
713
-
714
370
  /** A loaded skeleton and every atlas page it can sample, keyed by page name. */
715
371
  export interface Posable {
716
372
  data: SkeletonData;
@@ -752,61 +408,6 @@ export function posableFromText(skeletonText: string, atlasText: string, atlasDi
752
408
  return { data, pages };
753
409
  }
754
410
 
755
- // ---------------------------------------------------------------------------
756
- // what a render and a check read off a candidate besides its pose (issue #1014)
757
- // ---------------------------------------------------------------------------
758
- //
759
- // ⭐ `render` and `check` read a handful of facts off the candidate before and
760
- // beside the pose: the animation and skin names their flags are checked
761
- // against, the slot subset's roster, whether a stage is declared, the bone
762
- // tree `check` draws its chains from, the skin roster the framing reads, and
763
- // the atlas pages the rasteriser samples. Until issue #1014 every one of them
764
- // came off the skeleton spine-core had parsed, so a rigc build the core poses
765
- // still loaded and ran the runtime before its poser was chosen. Now the choice
766
- // comes first (`loadCandidate`), and a build the core poses reads each fact
767
- // where it is written: the names, the tree, the subset's roster and the stage
768
- // off the skeleton's own JSON (`skeletonFacts`), the pages off rigc's atlas
769
- // reader, and the skin roster off the model document (`coreSkinRoster` in
770
- // `./render_core.ts`). Every one of those readings was measured equal to the
771
- // spine-core reading it replaces on every input the tree carries — the
772
- // nineteen built corpus rows and the twelve editor exports (the PR of #1014
773
- // carries the counts). A Spine export, `--poser spine` and a fallback the
774
- // poser line names load spine-core as before and read every fact off it.
775
- //
776
- // Since issue #1020 a build the core poses reads what its model document
777
- // states off the document (`coreFacts`): the bone tree, the slot list and the
778
- // subset's roster, and the page images by the names its `pages` section gives
779
- // — so with a `rigc-compiled/2` document the atlas file is not needed at all.
780
- // Since issue #1026 a `rigc-compiled/3` document also states what only the
781
- // Spine files held — the order the animations and skins are listed in (the
782
- // emitter's, not the model's), the stage, and each page's `scale:` line — so
783
- // a build the core poses reads every fact off its document. A `/2` or `/1`
784
- // document's reader still takes those off `skeleton.json` and the atlas, and
785
- // the poser line says so (`unstatedClause`).
786
-
787
- /** What `render` and `check` read off a candidate's skeleton besides its pose. */
788
- export interface SkeletonFacts {
789
- /** Every animation name, in the skeleton's own order. */
790
- readonly animations: readonly string[];
791
- /** Every skin name, in the skeleton's own order. */
792
- readonly skins: readonly string[];
793
- /** Whether the header states a numeric `width` and `height` — a setup stage (issue #714). */
794
- readonly declaresStage: boolean;
795
- /** Every bone in declaration order, and its parent's name. */
796
- readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
797
- /** Every slot in declaration order, and the bone it hangs from. */
798
- readonly slots: ReadonlyArray<{ name: string; bone: string }>;
799
- /** `slotSubsetOf` over this skeleton — refused by `SlotSubsetError`. */
800
- subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
801
- /**
802
- * The `scale:` lines the candidate's atlas declares (`atlasScales`), or, for
803
- * a build posed from a `rigc-compiled/3` document, the ones its pages state
804
- * (issue #1026); `null` where neither was read — a `/2` or `/1` build drawn
805
- * with its atlas gone (issue #1020). `check`'s texture note reads it.
806
- */
807
- readonly atlasScales: readonly number[] | null;
808
- }
809
-
810
411
  /** The facts as spine-core loaded them — an export's reading, and every candidate's before issue #1014; `atlasText` the atlas it was loaded through. */
811
412
  export function spineFacts(data: SkeletonData, atlasText: string | null): SkeletonFacts {
812
413
  return {
@@ -821,235 +422,17 @@ export function spineFacts(data: SkeletonData, atlasText: string | null): Skelet
821
422
  };
822
423
  }
823
424
 
824
- type JsonObject = Record<string, unknown>;
825
-
826
- /** A JSON value as an object, or an empty one where it is not one. */
827
- function objectOf(value: unknown): JsonObject {
828
- return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
829
- }
830
-
831
- /** A JSON value as a list of objects, or an empty list where it is not one. */
832
- function objectsOf(value: unknown): JsonObject[] {
833
- return Array.isArray(value) ? value.map(objectOf) : [];
834
- }
835
-
836
- /**
837
- * The same facts off the skeleton's own JSON — a candidate the core poses
838
- * (issue #1014), whose skeleton spine-core never loads.
839
- *
840
- * Each is the file's own statement, read in the file's order: the animation
841
- * names are the keys of `animations` (the order the parser iterates them), the
842
- * skins `skins[].name` (the default skin the one named `default`), a slot's
843
- * carriers the skins whose `attachments` give it at least one entry, the tree
844
- * `bones[]` and `slots[]`, and the stage `skeleton.width`/`height`. The core
845
- * poses only a rigc build whose `skeleton.json` hashes to the digest its model
846
- * document records, so the file read here is the one `build` wrote and the
847
- * gate round-tripped.
848
- */
849
- export function skeletonFacts(skeletonText: string, atlasText: string | null): SkeletonFacts {
850
- const root = objectOf(JSON.parse(skeletonText));
851
- const skins = objectsOf(root.skins);
852
- const slots = objectsOf(root.slots).map((slot) => ({ name: String(slot.name), bone: String(slot.bone) }));
853
- const header = objectOf(root.skeleton);
854
- const roster: SubsetRoster = {
855
- declared: slots.map((slot) => slot.name),
856
- carriers: (slot) => skins.filter((skin) => Object.keys(objectOf(objectOf(skin.attachments)[slot])).length > 0).map((skin) => String(skin.name)),
857
- defaultSkin: skins.some((skin) => skin.name === 'default') ? 'default' : null,
858
- };
859
- return {
860
- atlasScales: atlasText === null ? null : atlasScales(atlasText),
861
- animations: Object.keys(objectOf(root.animations)),
862
- skins: skins.map((skin) => String(skin.name)),
863
- declaresStage: typeof header.width === 'number' && typeof header.height === 'number',
864
- bones: objectsOf(root.bones).map((bone) => ({ name: String(bone.name), parent: typeof bone.parent === 'string' ? bone.parent : null })),
865
- slots,
866
- subset: (opts, skin) => subsetOver(roster, opts, skin),
867
- };
868
- }
869
-
870
- /**
871
- * Each animation's duration off the skeleton's own JSON, in the file's order —
872
- * what `rosterDifference` holds a model document's durations to without
873
- * loading the skeleton through spine-core (issue #1014).
874
- *
875
- * ⚠️ Skeleton JSON carries no duration, so this is the parser's derivation of
876
- * one, and it is stated as measured rather than as read: the LAST key time of
877
- * each timeline, the largest of them, held as a float32 — equal to
878
- * spine-core's `Animation.duration` on every animation of every input the tree
879
- * carries (the PR of #1014). The timelines are the ones `walkTimelines` walks,
880
- * the walk `A05` and `A12` stand on, so a group it did not descend would be
881
- * missing from both.
882
- */
883
- function skeletonDurations(root: JsonObject): Array<{ name: string; duration: number }> {
884
- return Object.entries(objectOf(root.animations)).map(([name, animation]) => {
885
- let duration = 0;
886
- walkTimelines({ animations: { [name]: animation } }, (_path, _kind, _timeline, keys) => {
887
- if (keys.length === 0) return;
888
- const last = objectOf(keys[keys.length - 1]);
889
- duration = Math.max(duration, Math.fround(typeof last.time === 'number' ? last.time : 0));
890
- });
891
- return { name, duration };
892
- });
893
- }
894
-
895
- /**
896
- * Every page named, read from `atlasDir` by that name — the images a candidate
897
- * is drawn from, whichever poser draws it. A page that is not there is
898
- * `absent`'s refusal, handed the page's absolute path (issues #1033, #1042):
899
- * left to `readPlate`, it surfaced as an ENOENT and a stack. A page that is
900
- * there and is not a PNG says so itself (`readPlate`).
901
- */
902
- function pagesAt(names: readonly string[], atlasDir: string, absent: (page: string) => Error): Map<string, Plate> {
903
- const pages = new Map<string, Plate>();
904
- for (const name of names) {
905
- const path = join(atlasDir, name);
906
- if (!existsSync(path)) throw absent(resolve(path));
907
- pages.set(name, readPlate(path));
908
- }
909
- return pages;
910
- }
911
-
912
- /**
913
- * A candidate with no atlas beside it that has to be read through one —
914
- * refused naming the file and why (issue #1020).
915
- *
916
- * A rigc build whose model document states where each region sits on its page
917
- * (`rigc-compiled/2`, the `pages` section) is drawn by the core without its
918
- * atlas. Everything else reads one: a Spine export, `--poser spine`, a
919
- * `rigc-compiled/1` document, and a build the core refuses and spine-core
920
- * draws instead. A class of its own so `cli.ts` refuses it as an invocation
921
- * (exit 2, nothing written), as it refuses a missing atlas on an export.
922
- */
923
- export class CandidateAtlasError extends Error {}
924
-
925
- /** The refusal for a candidate that has to be read through an atlas it does not have. */
926
- function atlasAbsent(atlasPath: string, label: string, why: string): CandidateAtlasError {
927
- return new CandidateAtlasError(
928
- `nothing at ${atlasPath}: ${label} is drawn through its atlas (${why}). ` +
929
- 'Only a rigc build whose skeleton.model.json is a rigc-compiled/2 or /3 document, posed by the core, is drawn ' +
930
- 'without one — it states where each region sits on its page',
931
- );
932
- }
933
-
934
- /**
935
- * Posing an input through spine-core when the runtime cannot be reached —
936
- * refused naming the input and why it needs the runtime (issue #1014).
937
- *
938
- * A Spine export, `--poser spine` and a fallback the poser line names are what
939
- * spine-core is loaded for; a rigc build the core poses reads nothing through
940
- * it. So the one run that can meet a runtime it cannot use is one of those
941
- * three, and it says so rather than surfacing whatever the first access threw.
942
- */
943
- export class SpineRuntimeError extends Error {}
944
-
945
425
  /** Touch the runtime once, before anything is parsed through it, and refuse by name when it cannot be used. */
946
426
  function requireSpineRuntime(label: string, why: string): void {
947
427
  try {
948
428
  // A property read on the class the load starts from: no runtime code runs, and a runtime that cannot be used throws here.
949
429
  void TextureAtlas.prototype;
950
430
  } catch (err) {
951
- throw new SpineRuntimeError(
952
- `${label} is posed through spine-core (${why}), and the runtime could not be used: ${(err as Error).message}. ` +
953
- 'spine-core is what poses a Spine export, --poser spine and a fallback the poser line names; ' +
954
- 'a rigc build the core poses reads nothing through it',
955
- );
431
+ // The sentence an entry that registered no Spine side is refused in too (`./spine_side.ts`), with the runtime's own words as the reason.
432
+ throw new SpineRuntimeError(spineRuntimeSentence(label, why, (err as Error).message, POSING_RUNTIME_TAIL));
956
433
  }
957
434
  }
958
435
 
959
- /**
960
- * A candidate whose skeleton spine-core cannot load against the atlas beside
961
- * it — refused naming both files, why the runtime was drawing them, and what
962
- * the runtime could not resolve, in its own words (issue #1033).
963
- *
964
- * On a rigc build this is a directory whose files are not one build: a
965
- * `skeleton.json` or a `skeleton.atlas` from another build put beside this
966
- * one's `skeleton.model.json`. The core refuses the pair first (the digest, or
967
- * the atlas's pages, on the poser line's reason), the fallback hands it to
968
- * spine-core, and the runtime stops at the first name it cannot resolve —
969
- * which surfaced as its own uncaught error and a stack. A class of its own so
970
- * `cli.ts` refuses it as an invocation (exit 2, nothing written), as it
971
- * refuses a missing atlas on an export: the files the command was pointed at
972
- * have to change, not the rig.
973
- *
974
- * Since issue #1042 it is also `bonedist`'s (and `bench --bones`'), on either
975
- * side (`loadPosedSkeleton`), and the refusal for a page a candidate is drawn
976
- * from that is not there on every poser's path — the core's included, where a
977
- * build moved whole away from where it was built died on `readPlate`'s ENOENT.
978
- */
979
- export class CandidatePairError extends Error {}
980
-
981
- /** Where a candidate's files sit, as the refusals below name them: the atlas, and the directory when it holds a model document. */
982
- function pairPlace(paths: { skeleton: string; atlas: string } | null): { atlas: string; build: string | null } {
983
- const dir = paths === null ? null : dirname(resolve(paths.skeleton));
984
- return { atlas: paths === null ? 'the atlas handed over with it' : resolve(paths.atlas), build: dir !== null && existsSync(join(dir, MODEL_DOCUMENT_FILE)) ? dir : null };
985
- }
986
-
987
- /** What a directory whose files are not one build is told to do. */
988
- function notOneBuild(dir: string): string {
989
- return (
990
- `${dir} is not one build: ${MODEL_DOCUMENT_FILE}, skeleton.json and skeleton.atlas are written together by one \`rigc build\`, ` +
991
- "and these are not one build's — build it again, or put that build's own files back beside each other"
992
- );
993
- }
994
-
995
- /**
996
- * The refusal for a skeleton the runtime could not load against its atlas:
997
- * `runtime` is the runtime's own message, and `does` what spine-core was
998
- * loading it to do — `draws` for `render` and `check`, `poses` for a command
999
- * that reads the posed bones and draws nothing (`bonedist`, issue #1042).
1000
- */
1001
- function pairRefusal(
1002
- label: string,
1003
- paths: { skeleton: string; atlas: string } | null,
1004
- why: string,
1005
- runtime: string,
1006
- does: 'draws' | 'poses' = 'draws',
1007
- ): CandidatePairError {
1008
- const { atlas, build } = pairPlace(paths);
1009
- const head = `${label} does not load against ${atlas}: spine-core ${does} this pair (${why}) and could not resolve it — ${JSON.stringify(runtime)}. `;
1010
- return new CandidatePairError(head + (build === null ? 'The skeleton and the atlas are not one pair: the atlas has to be the one the skeleton was exported or built with' : notOneBuild(build)));
1011
- }
1012
-
1013
- /**
1014
- * The refusal for a page a candidate is drawn from that is not there — the
1015
- * page's path is relative to the directory of the file that names it, so the
1016
- * file was written somewhere else. What the last clause may claim depends on
1017
- * what is known about the directory:
1018
- *
1019
- * - an export (no model document): only that the page has to be there;
1020
- * - a rigc build whose files the core REFUSED (issue #1033): an atlas copied
1021
- * from another build's directory, and the directory is not one build — the
1022
- * core's own reason, on the poser line's words, says so;
1023
- * - a rigc build the core ACCEPTED, or one `--poser spine` never asked the
1024
- * core about (issue #1042): the build was moved or copied away from where it
1025
- * was built. "Not one build" would be a guess there, and on a build moved
1026
- * whole — measured — a false one.
1027
- *
1028
- * `by` is the file that names the page (`null`: the atlas), `draws` what is
1029
- * drawn from it.
1030
- */
1031
- function pageRefusal(
1032
- page: string,
1033
- paths: { skeleton: string; atlas: string } | null,
1034
- why: string,
1035
- reading: { by: string | null; draws: string; refusedBuild: boolean },
1036
- ): CandidatePairError {
1037
- const { atlas, build } = pairPlace(paths);
1038
- // The poser line's reason, unless it is only the document's path — which the sentence has just named.
1039
- const because = why === reading.by ? '' : ` (${why})`;
1040
- const head =
1041
- `nothing at ${page}: ${reading.by ?? atlas} names it as a page, and ${reading.draws}${because}. ` +
1042
- `A page path is relative to the ${reading.by === null ? "atlas's own" : "build's"} directory`;
1043
- const tail =
1044
- build === null
1045
- ? ', and the page has to be there'
1046
- : reading.refusedBuild
1047
- ? `, so an atlas copied from another build's directory names pages that are not here. ${notOneBuild(build)}`
1048
- : `, so a build moved or copied away from the directory it was built in names pages that are not here — ` +
1049
- 'build it again where it is, or build it with --copy-images, which writes its pages beside it';
1050
- return new CandidatePairError(head + tail);
1051
- }
1052
-
1053
436
  /**
1054
437
  * The one place a candidate's skeleton is handed to the runtime's parser
1055
438
  * against its atlas (issues #1033, #1042): a pair it cannot load is `refuse`'s
@@ -1090,283 +473,9 @@ export function loadPosedSkeleton(skeletonPath: string, atlasPath: string, why:
1090
473
  return spineSkeletonData(readFileSync(skeletonPath, 'utf8'), readFileSync(atlasPath, 'utf8'), (runtime) => pairRefusal(skeletonPath, paths, why, runtime, 'poses'));
1091
474
  }
1092
475
 
1093
- /** A candidate as `render` and `check` read it: the posers, the facts and the pages (issue #1014). */
1094
- export interface Candidate {
1095
- choice: PoserChoice;
1096
- facts: SkeletonFacts;
1097
- pages: Map<string, Plate>;
1098
- }
1099
-
1100
- /**
1101
- * Load a candidate for `render` or `check`, choosing its poser FIRST (issue
1102
- * #1014): a rigc build the core poses reads its facts off its model document
1103
- * and its own JSON (`coreFacts`), its page images by the names the
1104
- * document's `pages` gives (a `rigc-compiled/1` document's, by its atlas's)
1105
- * and its skin roster off the document, and spine-core is never loaded for
1106
- * it; `input.atlasText` is `null` where the atlas file is not there, which only
1107
- * a `rigc-compiled/2` document the core poses is drawn without (issue #1020 —
1108
- * anything else is refused, `CandidateAtlasError`); anything else — a Spine export,
1109
- * `--poser spine`, a candidate handed over as text (`paths` null, `unplaced`
1110
- * the reason) — is loaded through spine-core as `posableFromText` always
1111
- * loaded it. The choice's spine-core poser stays unloaded until something
1112
- * reads it, which on a rigc build is a fallback the poser line names.
1113
- *
1114
- * `--poser core` on an input that cannot carry it is NOT refused here: the
1115
- * caller says it where it always has (`refuseUnchosen`), after the flags that
1116
- * refuse before it.
1117
- */
1118
- export function loadCandidate(
1119
- input: { skeletonText: string; atlasText: string | null; atlasDir: string; label: string },
1120
- paths: { skeleton: string; atlas: string } | null,
1121
- forced: PoserName | undefined,
1122
- options: { make?: MakeCorePoser; unplaced?: string } = {},
1123
- ): Candidate {
1124
- let data: SkeletonData | null = null;
1125
- let choice: PoserChoice | null = null;
1126
- // `null` is an atlas file that is not there (issue #1020): a candidate the core draws from its document needs none, and anything read through one is refused naming the file before the runtime is touched.
1127
- const atlasText = (why: string): string => {
1128
- if (input.atlasText === null) throw atlasAbsent(paths?.atlas ?? '(no atlas)', input.label, why);
1129
- return input.atlasText;
1130
- };
1131
- // A pair the runtime cannot load — a skeleton.json or an atlas from another build beside this one's document — is
1132
- // refused by name rather than surfacing the runtime's own throw and stack (issue #1033); `spineSkeletonData` is
1133
- // where the catch is.
1134
- // Under `--poser core` the runtime is loaded only for the facts the flags are checked against, and nothing would be
1135
- // drawn through it: the refusal that is true there is the flag's, the one `refuseUnchosen` would have said next.
1136
- const refusing = (refusal: CandidatePairError): Error =>
1137
- forced === 'core' && choice !== null && choice.core === null ? new PoserChoiceError(`--poser core: ${choice.why}`) : refusal;
1138
- const spineLoad = (text: string, why: string): SkeletonData =>
1139
- spineSkeletonData(input.skeletonText, text, (runtime) => refusing(pairRefusal(input.label, paths, why, runtime)));
1140
- const spineData = (): SkeletonData => {
1141
- if (data === null) {
1142
- const why = choice === null || choice.core !== null ? 'a fallback from the core poser' : choice.why;
1143
- const text = atlasText(why);
1144
- requireSpineRuntime(input.label, why);
1145
- data = spineLoad(text, why);
1146
- }
1147
- return data;
1148
- };
1149
- const chosen =
1150
- paths === null
1151
- ? { choice: posersOver(forced, null, forced === 'spine' ? '--poser spine' : (options.unplaced ?? 'no path to find a model document beside'), spineData), document: null }
1152
- : choosePosers(paths.skeleton, paths.atlas, input.atlasText, forced, spineData, options.make ?? corePoser);
1153
- choice = chosen.choice;
1154
- if (choice.core !== null && chosen.document !== null) {
1155
- // A rigc-compiled/2 document names its pages; a /1 document is posed only with its atlas beside it, which names them (`choosePosers`).
1156
- const named = chosen.document.pageNames;
1157
- const names = named ?? parseAtlasText(atlasText(choice.why)).pages.map((page) => page.name);
1158
- // A page the build names that is not here is a build moved away from where it was built (issue #1042): the
1159
- // core accepted these files as one build, so the refusal does not say they are not one.
1160
- const by = named === null || paths === null ? null : join(dirname(resolve(paths.skeleton)), MODEL_DOCUMENT_FILE);
1161
- const reading = { by, draws: 'the core draws this build from it', refusedBuild: false };
1162
- const accepted = choice.why;
1163
- const pages = pagesAt(names, input.atlasDir, (page) => pageRefusal(page, paths, accepted, reading));
1164
- return { choice, facts: coreFacts(input.skeletonText, input.atlasText, choice.core, chosen.document), pages };
1165
- }
1166
- const text = atlasText(choice.why);
1167
- requireSpineRuntime(input.label, choice.why);
1168
- // `posableFromText`'s two steps, in its order — every page the atlas declares, then the skeleton — each refusing
1169
- // by name where the pair is not one: a page that is not there (`pageRefusal`), a skeleton the runtime cannot load
1170
- // against the atlas (`spineLoad`). The directory is called "not one build" only where the core refused its files —
1171
- // `--poser spine` never asked the core, and a build moved whole is one build whose pages are elsewhere.
1172
- const reading = { by: null, draws: 'spine-core draws this pair through that atlas', refusedBuild: forced !== 'spine' };
1173
- const why = choice.why;
1174
- const pages = pagesAt(
1175
- new TextureAtlas(text).pages.map((page) => page.name),
1176
- input.atlasDir,
1177
- (page) => refusing(pageRefusal(page, paths, why, reading)),
1178
- );
1179
- data = spineLoad(text, choice.why);
1180
- return { choice, facts: spineFacts(data, text), pages };
1181
- }
1182
-
1183
- /**
1184
- * A candidate's facts when the core poses it (issue #1020): what the model
1185
- * document states, read from it — the bone tree and the slot list (the core
1186
- * poser's own, which `rosterDifference` held to the Spine file's before the
1187
- * core was chosen) and the slot subset's roster (`coreDocumentFacts`). Since
1188
- * issue #1026 a `rigc-compiled/3` document states the rest too — the order
1189
- * the animations and skins are listed in (the editor's, `editorOrder`),
1190
- * whether a stage is declared (`stage`) and its pages' `scale:` lines — and
1191
- * nothing is read off `skeleton.json` or the atlas. A `/2` or `/1` document
1192
- * states none of those, so they are read where they were before: the orders
1193
- * and the stage off the skeleton's own JSON, the scale lines off the atlas
1194
- * (`null` with it gone) — and the poser line says so (`unstatedClause`).
1195
- */
1196
- function coreFacts(skeletonText: string, atlasText: string | null, poser: Poser, document: CoreDocumentFacts): SkeletonFacts {
1197
- const unstated = (): Pick<SkeletonFacts, 'atlasScales' | 'animations' | 'skins' | 'declaresStage'> => {
1198
- const spine = skeletonFacts(skeletonText, atlasText);
1199
- return { atlasScales: spine.atlasScales, animations: spine.animations, skins: spine.skins, declaresStage: spine.declaresStage };
1200
- };
1201
- const read = document.stated === null ? unstated() : { ...document.stated, atlasScales: document.stated.scales };
1202
- return {
1203
- atlasScales: read.atlasScales,
1204
- animations: read.animations,
1205
- skins: read.skins,
1206
- declaresStage: read.declaresStage,
1207
- bones: poser.bones,
1208
- slots: poser.slots,
1209
- subset: (opts, skin) => subsetOver(document.subset, opts, skin),
1210
- };
1211
- }
1212
-
1213
- // ---------------------------------------------------------------------------
1214
- // the posing seam (issue #965, step 3a of #380)
1215
- // ---------------------------------------------------------------------------
1216
- //
1217
- // ⭐ Everything this file reads off a posed skeleton goes through one
1218
- // interface, `Poser`, and the samplers below are written against it alone. The
1219
- // implementation behind it today is spine-core's (`spinePoser`, further down);
1220
- // the core-backed poser of issue #968 is a SECOND implementation of the same
1221
- // interface, handed to the same samplers, rather than a rewrite of them. What
1222
- // the interface fixes is exactly what a consumer of a posed frame reads:
1223
- //
1224
- // - the setup pose, under a skin or under none;
1225
- // - an animation's frames at `i/fps` for `i = 0..count`, one continuous
1226
- // trajectory stepped once per frame (the stepping recipe is the
1227
- // implementation's; the schedule — which `i`, what `count`, what time a
1228
- // frame is filed under — is the sampler's, and stays here);
1229
- // - per posed frame: the pieces in draw order (world vertices, page UVs,
1230
- // triangles, tint, dark, page, clip output), the bone snapshots, and every
1231
- // slot's whole attachment geometry;
1232
- // - the rest table, and the names a geometry file and a slot subset read.
1233
- //
1234
- // The framing samples are not a fourth entry: `framingViewport` is the same
1235
- // animation entry at `FRAMING_FPS` with the clip off.
1236
- //
1237
- // 🔒 What stays spine-core's and outside the seam, deliberately: an export's
1238
- // atlas (`posableFromText`'s pages, and `substituteTexture`'s region lookup on
1239
- // anything spine-core poses — a rigc build the core poses reads both through
1240
- // rigc's own reader since issue #1020) and `posedNumbersOf`, which `validate.ts` calls on a spine-core
1241
- // skeleton it stepped itself (A10's runtime supplier), so the round trip keeps its spine-core
1242
- // entry whatever poses the renders.
1243
-
1244
- /** What a sampler hands a posed frame's draw walk — `PoseOptions` with the subset already resolved. */
1245
- export interface DrawOptions {
1246
- /** The slots drawn, resolved against the posed skeleton (`slotSubsetOf`); `undefined` draws every slot. */
1247
- subset: SlotSubset | undefined;
1248
- /** Every attachment whole, no clip applied — the framing box's reading. */
1249
- unclipped: boolean;
1250
- /** Also record each piece's original-art UVs — see `PieceTexture`. */
1251
- texture: boolean;
1252
- }
1253
-
1254
- /**
1255
- * One posed moment, readable only while the `Poser` call that produced it is
1256
- * running: an implementation may step one skeleton in place, so a reader takes
1257
- * what it needs before the next frame is posed.
1258
- */
1259
- export interface Posed {
1260
- /** The drawables in draw order — see `piecesOf` for the walk and the clip. */
1261
- pieces(draw: DrawOptions): Piece[];
1262
- /** Every bone's world transform, in the skeleton's declaration order. */
1263
- bones(): BoneSnapshot[];
1264
- /** Every slot's region or mesh attachment, whole, in the posed draw order. */
1265
- attachments(): AttachmentPose[];
1266
- }
1267
-
1268
- /**
1269
- * The posing seam: one skeleton, posed on demand.
1270
- *
1271
- * Named for what it does rather than for the runtime behind it, because the
1272
- * point of the name is that there are two: `spinePoser` today, the core's at
1273
- * #968. Every sampler, the framing, the geometry export and the non-finite
1274
- * sentence take one (or a `SkeletonData`, which they wrap in `spinePoser`).
1275
- */
1276
- export interface Poser {
1277
- /** Every animation, in declaration order, with its duration in seconds. */
1278
- readonly animations: ReadonlyArray<{ name: string; duration: number }>;
1279
- /** Every bone in declaration order, and its parent's name. */
1280
- readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
1281
- /** Every slot in declaration order, and the bone it hangs from. */
1282
- readonly slots: ReadonlyArray<{ name: string; bone: string }>;
1283
- /** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
1284
- subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
1285
- /** The setup pose under `skin` (absent: no skin set at all). */
1286
- setup(skin: string | undefined): Posed;
1287
- /**
1288
- * Animation `name` under `skin`, not looping: `visit(i, posed)` for every
1289
- * `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
1290
- */
1291
- animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void): void;
1292
- /** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
1293
- rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
1294
- }
1295
-
1296
476
  /** What every sampler takes: a poser, or spine-core's parsed skeleton, which is posed through `spinePoser`. */
1297
477
  export type PoseSource = Poser | SkeletonData;
1298
478
 
1299
- /**
1300
- * Whether a pose source is a `Poser` — told by the seam's own entries rather
1301
- * than by `instanceof SkeletonData`, which reads the runtime's class and so
1302
- * reached spine-core on every sample a rigc build took through the core
1303
- * (issue #1014). A parsed skeleton carries none of the three.
1304
- */
1305
- function isPoser(source: PoseSource): source is Poser {
1306
- const seam = source as Partial<Poser>;
1307
- return typeof seam.setup === 'function' && typeof seam.animation === 'function' && typeof seam.rest === 'function';
1308
- }
1309
-
1310
- function poserOf(source: PoseSource): Poser {
1311
- return isPoser(source) ? source : spinePoser(source);
1312
- }
1313
-
1314
- /** A sampler's `PoseOptions` as a draw walk reads them, the subset resolved once, on the first frame posed. */
1315
- function drawResolver(poser: Poser, opts: PoseOptions | undefined): () => DrawOptions {
1316
- let draw: DrawOptions | undefined;
1317
- return () => {
1318
- draw ??= { subset: poser.subset(opts, opts?.skin), unclipped: opts?.unclipped === true, texture: opts?.texture === true };
1319
- return draw;
1320
- };
1321
- }
1322
-
1323
- /** One frame off one posed moment, with what `opts` asked to record beside the pieces. */
1324
- function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, opts: PoseOptions | undefined): Frame {
1325
- return {
1326
- index,
1327
- time,
1328
- pieces: posed.pieces(draw),
1329
- ...(opts?.bones ? { bones: posed.bones() } : {}),
1330
- ...(opts?.geometry ? { attachments: posed.attachments() } : {}),
1331
- };
1332
- }
1333
-
1334
- /**
1335
- * Sample one animation at a fixed rate and collect the posed pieces per frame.
1336
- *
1337
- * Frame `i` is the pose at `i/fps`, for `i = 0..round(duration·fps)` — the
1338
- * schedule is this function's; how a pose reaches `i/fps` is the poser's.
1339
- */
1340
- export function sampleAnimation(source: PoseSource, name: string, fps: number, opts?: PoseOptions): Frame[] {
1341
- const poser = poserOf(source);
1342
- const animation = poser.animations.find((a) => a.name === name);
1343
- if (!animation) {
1344
- throw new Error(
1345
- `no animation "${name}" in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`,
1346
- );
1347
- }
1348
- const step = 1 / fps;
1349
- const count = Math.round(animation.duration * fps);
1350
- const draw = drawResolver(poser, opts);
1351
- const frames: Frame[] = [];
1352
- poser.animation(name, opts?.skin, fps, count, (i, posed) => frames.push(frameOf(i, i * step, posed, draw(), opts)));
1353
- return frames;
1354
- }
1355
-
1356
- /**
1357
- * The setup pose as a single frame — what a skeleton with **no animation at all**
1358
- * looks like.
1359
- *
1360
- * ⭐ Not a degenerate case to be tolerated: a static rig is a deliverable. The
1361
- * ladder's first rung ships one (`1-weight-and-mass`'s second export), and its
1362
- * whole content is the setup pose.
1363
- */
1364
- export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
1365
- const poser = poserOf(source);
1366
- const posed = poser.setup(opts?.skin);
1367
- return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
1368
- }
1369
-
1370
479
  // ---------------------------------------------------------------------------
1371
480
  // the spine-core implementation of the seam
1372
481
  // ---------------------------------------------------------------------------
@@ -1440,258 +549,21 @@ function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
1440
549
  }
1441
550
 
1442
551
  /**
1443
- * Every animation of one skeleton at one rate, keyed by the name its frames are
1444
- * filed under. A skeleton with no animation at all contributes its setup pose
1445
- * under `SETUP_POSE_DIR`.
1446
- */
1447
- export function sampleAll(source: PoseSource, fps: number, opts?: PoseOptions): Map<string, Frame[]> {
1448
- const poser = poserOf(source);
1449
- const out = new Map<string, Frame[]>();
1450
- if (poser.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(poser, opts));
1451
- else for (const animation of poser.animations) out.set(animation.name, sampleAnimation(poser, animation.name, fps, opts));
1452
- return out;
1453
- }
1454
-
1455
- // ---------------------------------------------------------------------------
1456
- // which poser a render poses through (issue #968, step 3d of #380)
1457
- // ---------------------------------------------------------------------------
1458
- //
1459
- // ⭐ Two implementations of the seam, chosen by what the input carries and
1460
- // never guessed: a rigc build writes `skeleton.model.json` beside the Spine
1461
- // pair, and that document is what the core poses (`./render_core.ts`); a
1462
- // Spine export (`bench/reference/*`, `examples/*/export/*`) has none and
1463
- // poses through spine-core. The choice, and the reason for it, is returned
1464
- // beside the result so the caller can say it (`render` prints it as its
1465
- // `poser` line) — a render that fell back without saying so would be a
1466
- // second opinion about the pose that nobody could see.
1467
-
1468
- /** Which implementation of the seam posed a render: rigc's own core, or spine-core. */
1469
- export type PoserName = 'core' | 'spine';
1470
-
1471
- /** The `--poser` spellings, in the order the usage lists them. */
1472
- export const POSER_NAMES: readonly PoserName[] = ['core', 'spine'];
1473
-
1474
- /**
1475
- * A `--poser` the input cannot carry — `--poser core` on a Spine export, or on
1476
- * a build whose document the core refuses. A class of its own so `cli.ts`
1477
- * refuses it as a usage error (exit 2, nothing written).
1478
- */
1479
- export class PoserChoiceError extends Error {}
1480
-
1481
- /** Both posers for one input, and why the core one is or is not there. */
1482
- export interface PoserChoice {
1483
- /** What `--poser` asked for, or `undefined` for the input's own choice. */
1484
- forced: PoserName | undefined;
1485
- /** The core poser, or `null` when the input cannot carry it (`why`). */
1486
- core: Poser | null;
1487
- /** For a core poser, the document it poses; otherwise why there is none. */
1488
- why: string;
1489
- /**
1490
- * spine-core's poser over the same skeleton — loaded the first time it is
1491
- * read (issue #1014), which on a rigc build the core poses is a fallback the
1492
- * poser line names, or never.
1493
- */
1494
- readonly spine: Poser;
1495
- /**
1496
- * The skin roster behind `poser` (`SkinRoster`): the model document's for
1497
- * the core poser (`coreSkinRoster`), the parsed skeleton's for spine-core's
1498
- * (`skinRosterOf`) — so a render the core poses reads its framing's roster
1499
- * without loading the runtime (issue #1014).
1500
- */
1501
- rosterOf(poser: Poser): SkinRoster;
1502
- }
1503
-
1504
- /** What builds the core poser — `corePoser`, or a planted copy the suite passes (`RC02`, `CH01`). */
1505
- export type MakeCorePoser = (modelText: string, atlasText: string, where: string, skeleton: { path: string; bytes: Uint8Array }) => Poser;
1506
-
1507
- /** A skeleton's three rosters, as `rosterDifference` compares them. */
1508
- interface SkeletonRosters {
1509
- bones: ReadonlyArray<{ name: string; parent: string | null }>;
1510
- slots: ReadonlyArray<{ name: string; bone: string }>;
1511
- animations: ReadonlyArray<{ name: string; duration: number }>;
1512
- /** Every skin name, in the skeleton's order — the roster `coreSkinRoster` names skins in. */
1513
- skins: readonly string[];
1514
- }
1515
-
1516
- /**
1517
- * The rosters off the skeleton's own JSON (issue #1014): its bones, slots and
1518
- * skins as `skeletonFacts` reads them, and its animations' durations as
1519
- * `skeletonDurations` derives them. Before #1014 these were spine-core's parse
1520
- * of the same file; the two were measured equal on every input the tree
1521
- * carries.
1522
- */
1523
- function skeletonRosters(skeletonText: string): SkeletonRosters {
1524
- const facts = skeletonFacts(skeletonText, null);
1525
- return { bones: facts.bones, slots: facts.slots, animations: skeletonDurations(objectOf(JSON.parse(skeletonText))), skins: facts.skins };
1526
- }
1527
-
1528
- /**
1529
- * The first way a model document's rosters differ from the Spine skeleton they
1530
- * sit beside, or `null` — a document from another build would pose another
1531
- * rig in the same files' name, so it is refused rather than drawn.
1532
- */
1533
- function rosterDifference(core: Poser, skeleton: SkeletonRosters): string | null {
1534
- const bones = (list: ReadonlyArray<{ name: string; parent: string | null }>): string => list.map((b) => `${b.name}<${b.parent ?? ''}`).join('|');
1535
- const slots = (list: ReadonlyArray<{ name: string; bone: string }>): string => list.map((x) => `${x.name}@${x.bone}`).join('|');
1536
- if (bones(core.bones) !== bones(skeleton.bones)) return 'the bones (names, parents or order) differ';
1537
- if (slots(core.slots) !== slots(skeleton.slots)) return 'the slots (names, bones or draw order) differ';
1538
- const animations = (list: ReadonlyArray<{ name: string; duration: number }>): string =>
1539
- list.map((a) => `${a.name}=${a.duration}`).sort().join('|');
1540
- if (animations(core.animations) !== animations(skeleton.animations)) return 'the animations (names or durations) differ';
1541
- return null;
1542
- }
1543
-
1544
- /**
1545
- * A `PoserChoice` over a core poser (or none) and a skeleton spine-core loads
1546
- * only when something reads its side — `spine`, or the roster behind it.
1547
- */
1548
- function posersOver(
1549
- forced: PoserName | undefined,
1550
- core: { poser: Poser; roster: SkinRoster } | null,
1551
- why: string,
1552
- spineData: () => SkeletonData,
1553
- ): PoserChoice {
1554
- let spine: Poser | null = null;
1555
- let spineRoster: SkinRoster | null = null;
1556
- return {
1557
- forced,
1558
- core: core === null ? null : core.poser,
1559
- why,
1560
- get spine(): Poser {
1561
- spine ??= spinePoser(spineData());
1562
- return spine;
1563
- },
1564
- rosterOf: (poser) => {
1565
- if (core !== null && poser === core.poser) return core.roster;
1566
- spineRoster ??= skinRosterOf(spineData());
1567
- return spineRoster;
1568
- },
1569
- };
1570
- }
1571
-
1572
- /**
1573
- * The choice `candidatePosers` and `loadCandidate` share, refusing nothing:
1574
- * the core poser when `skeleton.model.json` sits beside the skeleton, the
1575
- * skeleton's bytes hash to the digest the document records (`spine.sha256`:
1576
- * it is the file that build wrote, not one edited after it), the atlas is the
1577
- * one beside it too, the core reads both and the document's rosters are the
1578
- * skeleton's — read off the skeleton's own JSON (`skeletonRosters`), so
1579
- * choosing loads nothing through spine-core (issue #1014).
552
+ * Both posers for the skeleton at `skeletonPath` drawn through the atlas at
553
+ * `atlasPath`, with spine-core's parse of it already in hand (`data`) — the
554
+ * choice `loadCandidate` makes, for a caller that loaded the pair itself.
555
+ * `forced` is `--poser`; `core` on an input that cannot carry it is refused by
556
+ * name (`PoserChoiceError`).
1580
557
  */
1581
- function choosePosers(
558
+ export function candidatePosers(
559
+ data: SkeletonData,
1582
560
  skeletonPath: string,
1583
561
  atlasPath: string,
1584
- /** The atlas file's text, or `null` when there is no file at `atlasPath` (issue #1020). */
1585
- atlasText: string | null,
1586
562
  forced: PoserName | undefined,
1587
- spineData: () => SkeletonData,
1588
- make: MakeCorePoser,
1589
- ): { choice: PoserChoice; document: CoreDocumentFacts | null } {
1590
- const dir = dirname(resolve(skeletonPath));
1591
- const modelPath = join(dir, MODEL_DOCUMENT_FILE);
1592
- let core: { poser: Poser; roster: SkinRoster } | null = null;
1593
- let document: CoreDocumentFacts | null = null;
1594
- let why: string;
1595
- if (forced === 'spine') why = '--poser spine';
1596
- else if (!existsSync(modelPath)) why = `no ${MODEL_DOCUMENT_FILE} beside ${resolve(skeletonPath)} — a Spine export, not a rigc build`;
1597
- else if (dirname(resolve(atlasPath)) !== dir) {
1598
- why =
1599
- `the atlas ${resolve(atlasPath)} is not beside ${modelPath}: the document's region trims are its own build's ` +
1600
- "atlas's, and the core would pose them against another one's pages";
1601
- } else {
1602
- try {
1603
- const modelText = readFileSync(modelPath, 'utf8');
1604
- const bytes = readFileSync(skeletonPath);
1605
- // No atlas is `''`: a rigc-compiled/2 document draws from its own `pages`, and a /1 document is refused by name (`placementOf`). An atlas that is there is held to the document's `pages` (#1016) and refused, naming the first difference, when it is not the one the build wrote.
1606
- const candidate = make(modelText, atlasText ?? '', modelPath, { path: resolve(skeletonPath), bytes });
1607
- const rosters = skeletonRosters(bytes.toString('utf8'));
1608
- const differs = rosterDifference(candidate, rosters);
1609
- if (differs === null) {
1610
- document = coreDocumentFacts(modelText, modelPath, rosters.skins);
1611
- core = { poser: candidate, roster: document.roster };
1612
- // The poser line names where the placement came from when it is not the document's own (issue #1020): a /1 document states none, and the core reads it from the atlas beside it.
1613
- // And, since issue #1026, where the orders, the stage and the scale lines came from when the document does not state them: a /2 or /1 document's are read off the files beside it.
1614
- const unstated = document.stated === null ? unstatedClause(resolve(skeletonPath), atlasText === null ? null : resolve(atlasPath)) : '';
1615
- why =
1616
- document.pageNames === null
1617
- ? `${modelPath} — a ${document.spec} document, which does not state where each region sits on its page: that is read from ${resolve(atlasPath)}; nor ${unstated}`
1618
- : document.stated === null
1619
- ? `${modelPath} — a ${document.spec} document, which does not state ${unstated}`
1620
- : modelPath;
1621
- } else why = `${modelPath} does not describe ${resolve(skeletonPath)}: ${differs}`;
1622
- } catch (err) {
1623
- if (!(err instanceof CoreInputError)) throw err;
1624
- // The reader names the document itself (`readModel`'s `where`), so a message that already starts with its path is not given it twice.
1625
- why = `the core refused ${err.message.startsWith(`${modelPath}: `) ? err.message : `${modelPath}: ${err.message}`}`;
1626
- }
1627
- }
1628
- return { choice: posersOver(forced, core, why, spineData), document: core === null ? null : document };
1629
- }
1630
-
1631
- /**
1632
- * What a `rigc-compiled/2` or `/1` document does not state and a render or a
1633
- * check reads off the files beside it instead (issue #1026), as the poser line
1634
- * says it: the order the skeleton lists its skins and animations in and its
1635
- * stage, off `skeleton`, and its pages' `scale:` lines, off `atlas` — or not
1636
- * at all where no atlas is beside it (issue #1020).
1637
- */
1638
- function unstatedClause(skeleton: string, atlas: string | null): string {
1639
- return (
1640
- `the order its skins and animations are listed in or its stage, read from ${skeleton}, ` +
1641
- `or its pages' scale: lines, ${atlas === null ? 'not read — no atlas is beside it' : `read from ${atlas}`}`
1642
- );
1643
- }
1644
-
1645
- /**
1646
- * `--poser core` on an input that cannot carry it, refused by name
1647
- * (`PoserChoiceError`) — said by the caller where its refusals have always
1648
- * stood, after the flags that refuse before it. Returns the choice otherwise.
1649
- */
1650
- export function refuseUnchosen(choice: PoserChoice): PoserChoice {
1651
- if (choice.forced === 'core' && choice.core === null) throw new PoserChoiceError(`--poser core: ${choice.why}`);
1652
- return choice;
1653
- }
1654
-
1655
- /**
1656
- * Both posers for the skeleton at `skeletonPath` drawn through the atlas at
1657
- * `atlasPath`, with spine-core's parse of it already in hand (`data`) — the
1658
- * choice `loadCandidate` makes, for a caller that loaded the pair itself.
1659
- * `forced` is `--poser`; `core` on an input that cannot carry it is refused by
1660
- * name (`PoserChoiceError`).
1661
- */
1662
- export function candidatePosers(
1663
- data: SkeletonData,
1664
- skeletonPath: string,
1665
- atlasPath: string,
1666
- forced: PoserName | undefined,
1667
- /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
1668
- make: MakeCorePoser = corePoser,
1669
- ): PoserChoice {
1670
- return refuseUnchosen(choosePosers(skeletonPath, atlasPath, readFileSync(atlasPath, 'utf8'), forced, () => data, make).choice);
1671
- }
1672
-
1673
- /**
1674
- * `run` through the chosen poser: the core one when there is one, and
1675
- * spine-core otherwise — or when the core refuses the input partway
1676
- * (`CoreInputError`, e.g. a clip polygon that is not simple), in
1677
- * which case `run` starts again from nothing on spine-core and `note` names the
1678
- * refusal. Under `--poser core` that refusal is a `PoserChoiceError` instead.
1679
- * `run` must write nothing: a fallback re-runs it whole. It is handed the
1680
- * skin roster behind the poser it runs (`PoserChoice.rosterOf`), so a run
1681
- * through the core reads nothing through spine-core (issue #1014).
1682
- */
1683
- export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser, roster: SkinRoster) => T): { value: T; poser: PoserName; note: string } {
1684
- const through = (poser: Poser): T => run(poser, choice.rosterOf(poser));
1685
- if (choice.core !== null) {
1686
- try {
1687
- return { value: through(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
1688
- } catch (err) {
1689
- if (!(err instanceof CoreInputError)) throw err;
1690
- if (choice.forced === 'core') throw new PoserChoiceError(`--poser core: the core refused this input: ${err.message}`);
1691
- return { value: through(choice.spine), poser: 'spine', note: `spine-core — the core refused this input: ${err.message}` };
1692
- }
1693
- }
1694
- return { value: through(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
563
+ /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
564
+ make: MakeCorePoser = corePoser,
565
+ ): PoserChoice {
566
+ return refuseUnchosen(choosePosers(skeletonPath, atlasPath, readFileSync(atlasPath, 'utf8'), forced, () => data, make).choice);
1695
567
  }
1696
568
 
1697
569
  /**
@@ -1939,165 +811,6 @@ function clippedPiece(piece: Piece, triangles: number[], uvs: Float32Array, clip
1939
811
  return { kind: 'mesh', tint, dark, slot, page, texture, world, uvs: clippedUvs, triangles: clipped, source: { world: source.world, uvs: source.uvs } };
1940
812
  }
1941
813
 
1942
- // ---------------------------------------------------------------------------
1943
- // the geometry export — `render --geometry` (issue #864)
1944
- // ---------------------------------------------------------------------------
1945
- //
1946
- // ⭐ A frame set is pixels, and two judgements a consumer that does not link
1947
- // spine-core wants to make are not about pixels: how far a mesh triangle is
1948
- // stretched over its rest shape, and whether a region holds still in its own
1949
- // bone's frame. Both need the numbers the pose was drawn FROM. So `render` writes
1950
- // them beside the pictures, off the very `Frame`s it drew — one call, one frame
1951
- // grid, one viewport — rather than a second command re-deriving any of the three.
1952
-
1953
- /** The export's file name inside an animation's frame directory, and its format tag. */
1954
- export const GEOMETRY_FILE = 'geometry.json';
1955
- export const GEOMETRY_SPEC = 'rigc-geometry/1';
1956
- /** Stated in the file, so a reader cannot take the numbers for frame pixels. */
1957
- export const GEOMETRY_COORDINATES = 'spine world, y up, world units';
1958
-
1959
- /** A geometry file's frame: `Frame` reduced to the numbers the export promises. */
1960
- export interface GeometryFrame {
1961
- index: number;
1962
- time: number;
1963
- bones: GeometryBone[];
1964
- attachments: AttachmentPose[];
1965
- }
1966
-
1967
- /** One bone's world transform: `world = [a b; c d]·local + (worldX, worldY)`. */
1968
- export interface GeometryBone {
1969
- name: string;
1970
- a: number;
1971
- b: number;
1972
- c: number;
1973
- d: number;
1974
- worldX: number;
1975
- worldY: number;
1976
- }
1977
-
1978
- /**
1979
- * One attachment's rest geometry and its topology — the half of a stretch ratio
1980
- * no frame carries.
1981
- *
1982
- * ⭐ **Rest is the setup pose's bones with no deform**, taken for every
1983
- * attachment any frame of the file shows — including one the setup pose does not
1984
- * show, which a slot only swaps to later. For an attachment the setup pose does
1985
- * show, these vertices are the `setup` entry's own, bit for bit: both come off
1986
- * one skeleton posed by `setupPosed`.
1987
- */
1988
- export interface AttachmentRest {
1989
- slot: string;
1990
- attachment: string;
1991
- kind: 'region' | 'mesh';
1992
- vertices: number[];
1993
- /** Vertex index triplets. A region's are the runtime's own `0 1 2 2 3 0`. */
1994
- triangles: number[];
1995
- /** A mesh's hull vertex count — the first `hull` vertices, as the format's `hull` field counts them. */
1996
- hull?: number;
1997
- /** A mesh's `uvs`, `u, v` per vertex over the untrimmed drawing, y down — the attachment's own, not a page's. */
1998
- uvs?: number[];
1999
- }
2000
-
2001
- export interface GeometryFile {
2002
- spec: string;
2003
- coordinates: string;
2004
- /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
2005
- animation: string | null;
2006
- skin?: string;
2007
- fps: number;
2008
- /** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
2009
- viewport: FramesSidecar['viewport'];
2010
- /** Every bone in the skeleton's declaration order, and its parent's name. */
2011
- bones: Array<{ name: string; parent: string | null }>;
2012
- /**
2013
- * Every slot in the skeleton's declaration order, and the bone it hangs from —
2014
- * which is the bone whose frame "still in its own bone's frame" is read in.
2015
- */
2016
- slots: Array<{ name: string; bone: string }>;
2017
- rest: AttachmentRest[];
2018
- /** The setup pose, sampled the way `sampleSetupPose` samples it. */
2019
- setup: Omit<GeometryFrame, 'index' | 'time'>;
2020
- frames: GeometryFrame[];
2021
- }
2022
-
2023
- /**
2024
- * A pose holding a number that is not finite — refused by the bone or the vertex
2025
- * that holds it, and the value, rather than drawn, framed or written.
2026
- *
2027
- * Two callers throw it, with one sentence between them (`nonFiniteSentence`):
2028
- * the geometry export, because `JSON.stringify` writes `NaN` and `Infinity` as
2029
- * `null` and a consumer would read a hole in the geometry as a vertex at
2030
- * nothing; and `framingViewport`, because a box over an infinite vertex is no
2031
- * box at all (issue #873). Before that second caller the framing answered `null`
2032
- * for it, which `render` prints as "posed no drawable attachment" — true of a
2033
- * skeleton that draws nothing and false of this one, whose attachment is there
2034
- * and posed to a number no picture can hold.
2035
- */
2036
- export class GeometryError extends Error {}
2037
-
2038
- /** `frames.json`'s viewport block for `v` — the one spelling both files use. */
2039
- export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
2040
- return {
2041
- x: v.minX,
2042
- y: v.minY,
2043
- width: v.maxX - v.minX,
2044
- height: v.maxY - v.minY,
2045
- scale: v.scale,
2046
- pixelWidth: v.width,
2047
- pixelHeight: v.height,
2048
- };
2049
- }
2050
-
2051
- /**
2052
- * The geometry file for one frame set — `frames` exactly as a sampler returned
2053
- * them with `{ bones: true, geometry: true }`, which is what makes its grid the
2054
- * frame set's own.
2055
- *
2056
- * Refused by `GeometryError`, naming the frame, the slot, the attachment and the
2057
- * vertex (or the bone), when any number in it is not finite.
2058
- */
2059
- export function geometryFileOf(
2060
- source: PoseSource,
2061
- animation: string | null,
2062
- fps: number,
2063
- frames: Frame[],
2064
- viewport: Viewport,
2065
- skin: string | undefined,
2066
- ): GeometryFile {
2067
- const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
2068
- if (frame.bones === undefined || frame.attachments === undefined) {
2069
- throw new Error(`${where} was sampled without { bones: true, geometry: true }; the geometry export needs both`);
2070
- }
2071
- return {
2072
- bones: frame.bones.map(({ name, a, b, c, d, worldX, worldY }) => ({ name, a, b, c, d, worldX, worldY })),
2073
- attachments: frame.attachments,
2074
- };
2075
- };
2076
- const posed = frames.map((frame) => ({
2077
- index: frame.index,
2078
- time: frame.time,
2079
- ...geometryFrame(frame, `frame ${frame.index}`),
2080
- }));
2081
- const poser = poserOf(source);
2082
- const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
2083
- const setup = geometryFrame(setupFrame, 'the setup pose');
2084
- const file: GeometryFile = {
2085
- spec: GEOMETRY_SPEC,
2086
- coordinates: GEOMETRY_COORDINATES,
2087
- animation,
2088
- ...(skin === undefined ? {} : { skin }),
2089
- fps,
2090
- viewport: sidecarViewport(viewport),
2091
- bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
2092
- slots: poser.slots.map(({ name, bone }) => ({ name, bone })),
2093
- rest: poser.rest(skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
2094
- setup,
2095
- frames: posed,
2096
- };
2097
- refuseNonFinite(file);
2098
- return file;
2099
- }
2100
-
2101
814
  /**
2102
815
  * The rest table: every (slot, attachment) the given frames show, in order of
2103
816
  * first appearance, posed on one setup skeleton with the slot's deform empty.
@@ -2182,130 +895,6 @@ export function posedNumbersOf(skeleton: Skeleton): { bones: WorldTransform[]; d
2182
895
  return { bones, drawn };
2183
896
  }
2184
897
 
2185
- /**
2186
- * How a sampled frame is named in that sentence: the animation, the frame's
2187
- * index at the rate it was sampled and its time — or the bare index for the one
2188
- * frame of a skeleton with no animation, whose setup pose is checked first.
2189
- */
2190
- function frameWhere(animation: string | null, index: number, time: number, fps: number): string {
2191
- if (animation === null) return `frame ${index}`;
2192
- return `animation ${JSON.stringify(animation)} frame ${index} at ${fps} fps (t=${time.toFixed(4)}s)`;
2193
- }
2194
-
2195
- /** One sampled frame's numbers, under the name the sentence gives it. */
2196
- interface NamedPose {
2197
- where: string;
2198
- attachments: readonly PosedVertices[];
2199
- bones: readonly WorldTransform[];
2200
- }
2201
-
2202
- /**
2203
- * ⭐ **The one derivation of the non-finite sentence** (issue #873): the setup
2204
- * pose, then every frame in order, then the rest table. The export and the
2205
- * framing both reach it, so `render` and `render --geometry` refuse one planted
2206
- * overflow in the same words.
2207
- *
2208
- * The setup pose first because a setup bone that overflowed is named there as
2209
- * the BONE, and the rest table — the setup's bones with no deform — would name
2210
- * the same fault one step on, as a vertex. Rest is still checked, last: it holds
2211
- * attachments the setup pose does not show.
2212
- */
2213
- function nonFiniteSentence(
2214
- setup: Pick<NamedPose, 'attachments' | 'bones'>,
2215
- frames: readonly NamedPose[],
2216
- rest: readonly PosedVertices[],
2217
- ): string | null {
2218
- const first = firstNonFinite('the setup pose', setup.attachments, setup.bones);
2219
- if (first !== null) return first;
2220
- for (const frame of frames) {
2221
- const found = firstNonFinite(frame.where, frame.attachments, frame.bones);
2222
- if (found !== null) return found;
2223
- }
2224
- return firstNonFinite('the rest table', rest, []);
2225
- }
2226
-
2227
- /** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
2228
- function refuseNonFinite(file: GeometryFile): void {
2229
- const sentence = nonFiniteSentence(
2230
- file.setup,
2231
- file.frames.map((frame) => ({ ...frame, where: frameWhere(file.animation, frame.index, frame.time, file.fps) })),
2232
- file.rest,
2233
- );
2234
- if (sentence !== null) throw new GeometryError(sentence);
2235
- }
2236
-
2237
- /**
2238
- * Where a skeleton's pose is not finite, as the sentence the geometry export
2239
- * refuses on — or `null` when every bone and vertex of it is finite.
2240
- *
2241
- * Each set is sampled at its own rate, with its bones and whole attachments —
2242
- * `animation: null` is the setup pose alone — so the frame it names is a frame of
2243
- * the caller's own grid. It samples again rather than taking the caller's
2244
- * frames, because a caller that only draws sampled no bones; it is run only
2245
- * once the caller has found a number that is not finite, so a finite pose never
2246
- * pays for it.
2247
- */
2248
- export function nonFinitePoseOf(
2249
- source: PoseSource,
2250
- skin: string | undefined,
2251
- sets: ReadonlyArray<{ animation: string | null; fps: number }>,
2252
- ): string | null {
2253
- const poser = poserOf(source);
2254
- const opts: PoseOptions = { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true };
2255
- // Every attachment list the frames showed, for the rest table's roster.
2256
- const shown: AttachmentPose[][] = [];
2257
- const named = (frame: Frame, where: string): NamedPose => {
2258
- if (frame.bones === undefined || frame.attachments === undefined) {
2259
- throw new Error(`${where} was sampled without { bones: true, geometry: true }`);
2260
- }
2261
- shown.push(frame.attachments);
2262
- return { where, attachments: frame.attachments, bones: frame.bones };
2263
- };
2264
- const setup = named(sampleSetupPose(poser, opts)[0], 'the setup pose');
2265
- const frames = sets.flatMap(({ animation, fps }) =>
2266
- animation === null
2267
- ? []
2268
- : sampleAnimation(poser, animation, fps, opts).map((frame) =>
2269
- named(frame, frameWhere(animation, frame.index, frame.time, fps)),
2270
- ),
2271
- );
2272
- // Not read unless the setup pose and every frame are finite: `restOf` poses
2273
- // what they show, and would be posing the same overflow a third time.
2274
- const found = nonFiniteSentence(setup, frames, []);
2275
- if (found !== null) return found;
2276
- return nonFiniteSentence({ attachments: [], bones: [] }, [], poser.rest(skin, shown));
2277
- }
2278
-
2279
- /**
2280
- * The file's text: a JSON object whose header fields sit one per line and whose
2281
- * `rest` entries and `frames` sit one per line each, so a diff of two exports
2282
- * names the frame that moved.
2283
- *
2284
- * Numbers are `JSON.stringify`'s, which is how every JSON this tree writes prints
2285
- * them — the shortest decimal that reads back as the same double, fixed by the
2286
- * language rather than a locale — so a vertex in the file IS the runtime's
2287
- * vertex, not a rounding of it.
2288
- */
2289
- export function geometryText(file: GeometryFile): string {
2290
- const lines: string[] = [];
2291
- const entries = Object.entries(file);
2292
- entries.forEach(([key, value], i) => {
2293
- const comma = i + 1 < entries.length ? ',' : '';
2294
- if ((key === 'rest' || key === 'frames') && Array.isArray(value)) {
2295
- if (value.length === 0) {
2296
- lines.push(` ${JSON.stringify(key)}: []${comma}`);
2297
- return;
2298
- }
2299
- lines.push(` ${JSON.stringify(key)}: [`);
2300
- value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j + 1 < value.length ? ',' : ''}`));
2301
- lines.push(` ]${comma}`);
2302
- return;
2303
- }
2304
- lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
2305
- });
2306
- return `{\n${lines.join('\n')}\n}\n`;
2307
- }
2308
-
2309
898
  // ---------------------------------------------------------------------------
2310
899
  // texture-only substitution — see `substituteTexture`
2311
900
  // ---------------------------------------------------------------------------
@@ -2354,438 +943,6 @@ function artUvsOf(attachment: MeshAttachment | RegionAttachment, region: Texture
2354
943
  return { region: regionKey(region), artUvs: [s0, tBottom, s0, tTop, s1, tTop, s1, tBottom] };
2355
944
  }
2356
945
 
2357
- /**
2358
- * The name two atlases have to agree on for a substitution to find a region.
2359
- *
2360
- * Trimmed, because `TextureAtlas` names a region after the raw line it was read
2361
- * from — so the same region in a file written with CRLF and one without would be
2362
- * two different strings, and a substitution would report every region unmatched
2363
- * for a reason that is invisible in both files. The index is folded in because a
2364
- * sequence packs several regions under one name and `findRegion` returns only the
2365
- * first of them.
2366
- */
2367
- function regionKey(region: { name: string; index: number }): string {
2368
- return `${region.name.trim()}#${region.index}`;
2369
- }
2370
-
2371
- /**
2372
- * Page names of a substituting atlas carry this prefix, so an own page and a
2373
- * substituted page that happen to share a filename cannot be taken for each other.
2374
- */
2375
- export const SUBSTITUTE_PAGE = 'texture-from:';
2376
-
2377
- /**
2378
- * One region of a substituting atlas, as `substituteTexture` reads it: where
2379
- * it sits on which page, and the mapping from the drawing's own coordinates
2380
- * onto it (`MeshAttachment.computeUVs`'s job).
2381
- */
2382
- export interface SubstituteRegion {
2383
- page: { name: string; width: number; height: number };
2384
- x: number;
2385
- y: number;
2386
- width: number;
2387
- height: number;
2388
- degrees: number;
2389
- /** Art-space UVs (`u, v` per vertex) mapped onto this region of its page, in doubles. */
2390
- pageUvs(art: readonly number[]): number[];
2391
- }
2392
-
2393
- /** An atlas whose texels can stand in for another's — see `substituteTexture`. */
2394
- export interface TextureSubstitution {
2395
- /** Prefixed page name → the page, ready to merge into a render's page map. */
2396
- pages: Map<string, Plate>;
2397
- /** The atlas's regions, by the key both sides agree on — see `regionKey`. */
2398
- regions: Map<string, SubstituteRegion>;
2399
- /** Every `scale:` the atlas text declares, in the order the pages declare them. */
2400
- scales: number[];
2401
- }
2402
-
2403
- /**
2404
- * Who reads a substituting atlas (issue #1020): `rigc` — `parseAtlasText` and
2405
- * `./core/uvs.ts`'s `computeUvs`, for a candidate the core poses, so a rigc
2406
- * build's `check --texture-from` reads nothing through spine-core — or
2407
- * `spine`, the runtime's `TextureAtlas` and `MeshAttachment.computeUVs`, for
2408
- * everything spine-core poses (an export keeps the reading it always had).
2409
- *
2410
- * The two were measured to agree: the region list (`TextureAtlas.regions` is
2411
- * file order, and so is `parseAtlasText`'s, the pairing the selftest holds on
2412
- * every corpus atlas) and the mapping (`computeUvs`, bit for bit in doubles
2413
- * over 6,000 calls — its header). The PR of #1020 carries the substituted
2414
- * frames, pixel for pixel, on the corpus rows that exercise it.
2415
- */
2416
- export type SubstitutionReader = 'rigc' | 'spine';
2417
-
2418
- /** Load an atlas and its pages as a substitution source, read by `reader` — see `SubstitutionReader`. */
2419
- export function textureSubstitutionFromText(atlasText: string, atlasDir: string, reader: SubstitutionReader = 'spine'): TextureSubstitution {
2420
- const regions = new Map<string, SubstituteRegion>();
2421
- const names: string[] = [];
2422
- if (reader === 'rigc') {
2423
- for (const page of parseAtlasText(atlasText).pages) {
2424
- names.push(page.name);
2425
- const at = { name: page.name, width: page.width, height: page.height };
2426
- for (const region of page.regions) {
2427
- regions.set(regionKey(region), { page: at, x: region.x, y: region.y, width: region.width, height: region.height, degrees: region.degrees, pageUvs: (art) => computeUvs(region, at, art) });
2428
- }
2429
- }
2430
- } else {
2431
- const atlas = new TextureAtlas(atlasText);
2432
- for (const page of atlas.pages) names.push(page.name);
2433
- for (const region of atlas.regions) {
2434
- const page = { name: region.page.name, width: region.page.width, height: region.page.height };
2435
- regions.set(regionKey(region), {
2436
- page,
2437
- x: region.x,
2438
- y: region.y,
2439
- width: region.width,
2440
- height: region.height,
2441
- degrees: region.degrees,
2442
- pageUvs: (art) => {
2443
- const uvs = new Array<number>(art.length).fill(0);
2444
- MeshAttachment.computeUVs(region, [...art], uvs);
2445
- return uvs;
2446
- },
2447
- });
2448
- }
2449
- }
2450
- const pages = new Map<string, Plate>();
2451
- for (const name of names) {
2452
- if (name.startsWith(SUBSTITUTE_PAGE)) {
2453
- throw new Error(`atlas page "${name}" starts with the reserved prefix ${JSON.stringify(SUBSTITUTE_PAGE)}`);
2454
- }
2455
- pages.set(SUBSTITUTE_PAGE + name, readPlate(join(atlasDir, name)));
2456
- }
2457
- return { pages, regions, scales: atlasScales(atlasText) };
2458
- }
2459
-
2460
- /**
2461
- * The `scale:` values an atlas text declares, read off the text.
2462
- *
2463
- * ⚠️ Off the text, and reluctantly: `TextureAtlas` drops the field (its page
2464
- * reader silently ignores every key it has no handler for), because `scale:` is
2465
- * an instruction to whoever *imports* the pack — "the artwork was this much
2466
- * bigger than these texels" — and a runtime has nothing to do with it. It is
2467
- * nevertheless the one line that says a pack is coarser than the drawing it came
2468
- * from, which is exactly the fact a reader of an MAE needs (issue #171), so it is
2469
- * read here rather than left unreported.
2470
- *
2471
- * Narrow on purpose: an indented `scale:` line inside a page block, and nothing
2472
- * else. It is not a second parser for the format and must not grow into one.
2473
- * The line's pattern is `ATLAS_SCALE_LINE` (`src/model.ts`), which the model
2474
- * document reads a page's own `scale:` with (issue #1026) — one pattern, so the
2475
- * figure `check` reports off an atlas and off a document cannot drift apart.
2476
- */
2477
- export function atlasScales(atlasText: string): number[] {
2478
- const out: number[] = [];
2479
- for (const line of atlasText.split(/\r\n|\r|\n/)) {
2480
- const m = ATLAS_SCALE_LINE.exec(line);
2481
- if (!m) continue;
2482
- const value = Number(m[1]);
2483
- if (Number.isFinite(value)) out.push(value);
2484
- }
2485
- return out;
2486
- }
2487
-
2488
- /**
2489
- * The page rectangle a region occupies, as UVs — the fence `substituteTexture`
2490
- * puts around a substituted piece.
2491
- *
2492
- * ⚠️ Derived from `region.x/y/width/height` and its rotation rather than read off
2493
- * `region.u2/v2`, and that is not fastidiousness: `TextureAtlas` transposes a
2494
- * rotated region's rectangle when computing `u2/v2` **only at `degrees === 90`**,
2495
- * so at 180 and 270 those two numbers describe a rectangle the page does not have.
2496
- * (The same gap is why `RegionAttachment.computeUVs` draws a 270-packed region
2497
- * wrong, which is what `--atlas` was measuring on rung 7 — issue #199.)
2498
- * `region.u/v` are always `x/pageWidth, y/pageHeight` and are used as they are; the
2499
- * size is `pageFootprint`'s, which is the region's own transposed for a quarter
2500
- * turn — what the atlas format means by `bounds` on a rotated region. That
2501
- * derivation was written out here, and in three other places that wanted the same
2502
- * rectangle; two of them had it wrong at 270 (issue #579), so it is one function
2503
- * now and this is one of its callers.
2504
- */
2505
- function windowOf(region: SubstituteRegion): UvWindow {
2506
- const rect = pageFootprint(region);
2507
- const page = region.page;
2508
- return {
2509
- u0: region.x / page.width,
2510
- v0: region.y / page.height,
2511
- u1: (region.x + rect.width) / page.width,
2512
- v1: (region.y + rect.height) / page.height,
2513
- };
2514
- }
2515
-
2516
- /**
2517
- * The same posed frame, drawn from another atlas's **texels only**.
2518
- *
2519
- * ## 🔒 What is and is not substituted, and why that is the whole point
2520
- *
2521
- * `world` is copied across untouched — every vertex, both shapes — so the
2522
- * substituted frame draws the candidate's own geometry and nothing else. Only
2523
- * `page` and `uvs` change, and they change through the drawing's own coordinates
2524
- * (`PieceTexture`), so the same point of the artwork lands at the same world
2525
- * position on both sides. What is left between the two renders is a difference of
2526
- * **texels**: the same shapes, in the same places, filtered from a different
2527
- * source.
2528
- *
2529
- * That is what `rigc check --atlas <the frames' own atlas>` was being used for and
2530
- * is not: pointing `--atlas` at another atlas re-loads the skeleton against it, and
2531
- * a region attachment's quad is derived from the region rectangle, so a `rotate:`
2532
- * or a trim in the substituting pack moves the geometry too. Measured on rung 7,
2533
- * whose pack is `rotate: 270` and trimmed: that swap sends the reported MAE **up**
2534
- * on every set, which a texture floor cannot do — a coarser texture can only
2535
- * explain error, never add it (issue #199).
2536
- *
2537
- * ## The window
2538
- *
2539
- * A trimmed pack keeps only the drawing's opaque sub-rectangle, while the
2540
- * candidate's own quad spans the whole drawing. Art-space coordinates outside what
2541
- * the pack kept map to page texels **belonging to whatever was packed next door**,
2542
- * so the substituted piece is fenced to its own rectangle (`UvWindow`) and draws
2543
- * nothing outside it. That is the faithful answer rather than a convenience: a
2544
- * packer trims only fully transparent border, so outside the rectangle the drawing
2545
- * *is* empty.
2546
- */
2547
- export function substituteTexture(
2548
- frame: Frame,
2549
- into: TextureSubstitution,
2550
- ): { frame: Frame; unmatched: string[] } {
2551
- const unmatched: string[] = [];
2552
- const pieces: Piece[] = [];
2553
- for (const piece of frame.pieces) {
2554
- const texture = piece.texture;
2555
- const region = texture ? (into.regions.get(texture.region) ?? null) : null;
2556
- if (!texture || !region) {
2557
- unmatched.push(texture ? texture.region : piece.slot);
2558
- pieces.push(piece);
2559
- continue;
2560
- }
2561
- // The mapping from the drawing's coordinates into a page's, which is where
2562
- // `rotate:` (all four of them) and the trim offsets are handled: spine-core's
2563
- // own `MeshAttachment.computeUVs`, or `./core/uvs.ts`'s `computeUvs`, measured
2564
- // equal to it bit for bit — never a third opinion about the atlas format
2565
- // (`SubstitutionReader`).
2566
- const uvs = region.pageUvs(texture.artUvs);
2567
- // A clipped piece's source map is re-seated the same way, through the drawing's own coordinates (`Mesh.source`).
2568
- if (piece.kind === 'mesh' && piece.source !== undefined) {
2569
- if (texture.sourceArtUvs === undefined) throw new Error(`slot "${piece.slot}": a clipped piece carries no original-art UVs for its source triangles`);
2570
- const sourceUvs = region.pageUvs(texture.sourceArtUvs);
2571
- pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region), source: { world: piece.source.world, uvs: sourceUvs } });
2572
- continue;
2573
- }
2574
- pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region) });
2575
- }
2576
- return { frame: { ...frame, pieces }, unmatched };
2577
- }
2578
-
2579
- // ---------------------------------------------------------------------------
2580
- // framing
2581
- // ---------------------------------------------------------------------------
2582
-
2583
- /**
2584
- * The world-space box every posed vertex of these frames fits inside.
2585
- *
2586
- * `world.length` rather than a literal 8: a region contributes its four corners
2587
- * and a mesh every one of its vertices, and the loop does not need to know which
2588
- * it is holding.
2589
- */
2590
- export function unionBounds(frameSets: Iterable<Frame[]>): { minX: number; minY: number; maxX: number; maxY: number } {
2591
- let minX = Infinity;
2592
- let minY = Infinity;
2593
- let maxX = -Infinity;
2594
- let maxY = -Infinity;
2595
- for (const frames of frameSets) {
2596
- for (const frame of frames) {
2597
- for (const piece of frame.pieces) {
2598
- for (let i = 0; i < piece.world.length; i += 2) {
2599
- minX = Math.min(minX, piece.world[i]);
2600
- maxX = Math.max(maxX, piece.world[i]);
2601
- minY = Math.min(minY, piece.world[i + 1]);
2602
- maxY = Math.max(maxY, piece.world[i + 1]);
2603
- }
2604
- }
2605
- }
2606
- }
2607
- return { minX, minY, maxX, maxY };
2608
- }
2609
-
2610
- /**
2611
- * The opaque sub-rectangle of one quad's region, in the quad's own `(s, t)`.
2612
- *
2613
- * `(0,0)` is the region's bottom-left corner and `(1,1)` its top-right, so a trim
2614
- * of `{0, 0, 1, 1}` is a region whose art fills it and anything smaller is the
2615
- * transparent margin the art was exported with.
2616
- */
2617
- export interface RegionTrim {
2618
- minS: number;
2619
- minT: number;
2620
- maxS: number;
2621
- maxT: number;
2622
- }
2623
-
2624
- /**
2625
- * Where a quad's artwork actually is, as opposed to where its rectangle is.
2626
- *
2627
- * ⭐ This is what stops an invisible margin from being able to move anything. A
2628
- * region attachment's quad is the whole PNG, transparent border included, so two
2629
- * exports of the same drawing with different margins pose to different quads and
2630
- * frame themselves differently — which is how rung 5 reported MAE 39.00 for a rig
2631
- * whose every key was right (issue #34). Trimming to the opaque texels makes the
2632
- * box a property of the drawing.
2633
- *
2634
- * Alpha above zero rather than the rasteriser's coverage threshold, deliberately:
2635
- * this is the box that has to CONTAIN the drawing, and a box that is a texel too
2636
- * generous costs nothing while one that is a texel short clips.
2637
- *
2638
- * `cache` is keyed by page and region rectangle, because a scan per quad per frame
2639
- * would be a scan per quad per frame.
2640
- */
2641
- export function regionTrim(page: Plate, quad: Quad, cache: Map<string, RegionTrim | null>): RegionTrim | null {
2642
- const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
2643
- const key = `${quad.page}|${ubr},${vbr},${ubl},${vbl},${uul},${vul}`;
2644
- const seen = cache.get(key);
2645
- if (seen !== undefined) return seen;
2646
-
2647
- const ox = ubl * page.width;
2648
- const oy = vbl * page.height;
2649
- const ex = [(ubr - ubl) * page.width, (vbr - vbl) * page.height];
2650
- const ey = [(uul - ubl) * page.width, (vul - vbl) * page.height];
2651
- const det = ex[0] * ey[1] - ex[1] * ey[0];
2652
- if (Math.abs(det) < 1e-9) {
2653
- cache.set(key, null);
2654
- return null;
2655
- }
2656
- const corners = [
2657
- [ox, oy],
2658
- [ox + ex[0], oy + ex[1]],
2659
- [ox + ey[0], oy + ey[1]],
2660
- [ox + ex[0] + ey[0], oy + ex[1] + ey[1]],
2661
- ];
2662
- const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
2663
- const x1 = Math.min(page.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
2664
- const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
2665
- const y1 = Math.min(page.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
2666
-
2667
- let minS = Infinity;
2668
- let minT = Infinity;
2669
- let maxS = -Infinity;
2670
- let maxT = -Infinity;
2671
- for (let y = y0; y <= y1; y++) {
2672
- for (let x = x0; x <= x1; x++) {
2673
- if (page.get(x, y)[3] === 0) continue;
2674
- const rx = x + 0.5 - ox;
2675
- const ry = y + 0.5 - oy;
2676
- const s = (rx * ey[1] - ry * ey[0]) / det;
2677
- const t = (ex[0] * ry - ex[1] * rx) / det;
2678
- if (s < 0 || s > 1 || t < 0 || t > 1) continue;
2679
- if (s < minS) minS = s;
2680
- if (s > maxS) maxS = s;
2681
- if (t < minT) minT = t;
2682
- if (t > maxT) maxT = t;
2683
- }
2684
- }
2685
- const trim = Number.isFinite(minS) ? { minS, minT, maxS, maxT } : null;
2686
- cache.set(key, trim);
2687
- return trim;
2688
- }
2689
-
2690
- /**
2691
- * The world box every piece's **artwork** fits inside, over these frames.
2692
- *
2693
- * The same union as `unionBounds`, taken over the trimmed rectangles instead of
2694
- * the quads. It is a starting box for `check`'s framing and nothing more — the
2695
- * framing itself is fitted on rendered pixels — but the start has to be free of
2696
- * transparent margins too, or the path the fit takes still depends on them.
2697
- *
2698
- * ⚠️ **A mesh contributes its raw vertices and is not trimmed.** The trim exists
2699
- * because a region attachment's quad is the whole PNG, transparent border and
2700
- * all, so its corners sit where no pixel is. A mesh's hull is authored *onto the
2701
- * drawing* — that is what makes it a mesh — so its vertices already are where the
2702
- * artwork is, and there is no rectangle to invert a margin out of. Passing a
2703
- * triangle fan through the rectangle trim would not be a better estimate of the
2704
- * same box; it would be a different box, computed from a rectangle the mesh does
2705
- * not have.
2706
- */
2707
- export function trimmedUnionBounds(
2708
- frameSets: Iterable<Frame[]>,
2709
- pages: Map<string, Plate>,
2710
- ): { minX: number; minY: number; maxX: number; maxY: number } {
2711
- const cache = new Map<string, RegionTrim | null>();
2712
- let minX = Infinity;
2713
- let minY = Infinity;
2714
- let maxX = -Infinity;
2715
- let maxY = -Infinity;
2716
- const see = (x: number, y: number): void => {
2717
- if (x < minX) minX = x;
2718
- if (x > maxX) maxX = x;
2719
- if (y < minY) minY = y;
2720
- if (y > maxY) maxY = y;
2721
- };
2722
- for (const frames of frameSets) {
2723
- for (const frame of frames) {
2724
- for (const piece of frame.pieces) {
2725
- if (piece.kind === 'mesh') {
2726
- for (let i = 0; i < piece.world.length; i += 2) see(piece.world[i], piece.world[i + 1]);
2727
- continue;
2728
- }
2729
- const quad = piece;
2730
- const [brx, bry, blx, bly, ulx, uly] = quad.world;
2731
- const trim = regionTrim(pageFor(pages, quad), quad, cache);
2732
- if (!trim) {
2733
- for (let i = 0; i < 8; i += 2) see(quad.world[i], quad.world[i + 1]);
2734
- continue;
2735
- }
2736
- const ex = [brx - blx, bry - bly];
2737
- const ey = [ulx - blx, uly - bly];
2738
- for (const [s, t] of [
2739
- [trim.minS, trim.minT],
2740
- [trim.maxS, trim.minT],
2741
- [trim.minS, trim.maxT],
2742
- [trim.maxS, trim.maxT],
2743
- ]) {
2744
- see(blx + s * ex[0] + t * ey[0], bly + s * ex[1] + t * ey[1]);
2745
- }
2746
- }
2747
- }
2748
- }
2749
- return { minX, minY, maxX, maxY };
2750
- }
2751
-
2752
- /**
2753
- * A pose that drew vertices and cannot be framed, because every one of them sits
2754
- * at one point (issue #997) — refused by the slots that drew, the bone each hangs
2755
- * from and, where those bones are unposed, the skins that would pose them.
2756
- *
2757
- * A `GeometryError`, because it is the same family as the non-finite pose of
2758
- * #873: a picture that has no size is as unwritable as a vertex at Infinity. It
2759
- * is a class of its own because the reader's next step is different — there the
2760
- * rig posed a number no picture can hold, here the invocation posed the rig under
2761
- * a skin that leaves every drawn bone without a world transform, and `--skin` is
2762
- * usually the whole fix — so `cli.ts` exits 2 on it, as it does on the other
2763
- * framing refusal, *nothing to draw*.
2764
- *
2765
- * Before it the framing answered a viewport over the one point: `maxSide / 0` is
2766
- * Infinity, `0 · Infinity` is NaN, and `render` wrote a 0×0 frame set with exit
2767
- * 0, its summary saying `NaNxNaNpx` and `frames.json` a viewport of width 0 and
2768
- * scale `null`.
2769
- */
2770
- export class UnframeablePoseError extends GeometryError {}
2771
-
2772
- /**
2773
- * Which bones a skin leaves unposed, and which skins there are — the rig
2774
- * structure the unframeable sentence names a skin from (issue #997). Not a
2775
- * poser's: the `Poser` seam carries no skin roster, and both posers pose the one
2776
- * Spine file this is read from (the core's document is bound to it by hash).
2777
- * Two readings stand behind it — the runtime's (`skinRosterOf`) and the model
2778
- * document's (`coreSkinRoster` in `./render_core.ts`, issue #1014), measured
2779
- * to name the same bones under every skin — and `PoserChoice.rosterOf` hands
2780
- * each poser its own.
2781
- */
2782
- export interface SkinRoster {
2783
- /** Every skin, in declaration order. */
2784
- readonly skins: readonly string[];
2785
- /** The bones left unposed under `skin` (absent: no skin set) — inactive, or below an inactive bone. */
2786
- unposedUnder(skin: string | undefined): Set<string>;
2787
- }
2788
-
2789
946
  /**
2790
947
  * The skin roster of a parsed Spine file, as the runtime flags it —
2791
948
  * `unposedBones` over a fresh skeleton's `active` under each skin, so Spine's
@@ -2803,841 +960,54 @@ export function skinRosterOf(data: SkeletonData): SkinRoster {
2803
960
  };
2804
961
  }
2805
962
 
2806
- /** The roster behind a pose source: its own when it is Spine data, else the one the caller passed. */
2807
- function rosterBehind(source: PoseSource, roster: SkinRoster | undefined): SkinRoster | undefined {
2808
- return isPoser(source) ? roster : skinRosterOf(source);
2809
- }
2810
-
2811
- /**
2812
- * ⭐ **The one derivation of "a drawn slot on an unposed bone"** (issues #997,
2813
- * #1000): the names of the slots of `slots` whose bone `roster` says `skin`
2814
- * leaves unposed — inactive itself, or below an inactive bone. Both posers draw
2815
- * such a slot through the zero matrix, so it draws no pixel and every vertex of
2816
- * it sits at the origin. `framingViewport` takes these slots off the box
2817
- * (#1000) and `unframeableSentence` refuses a pose that drew nothing else
2818
- * (#997), from this one set.
2819
- *
2820
- * Empty without a roster: a bare `Poser` carries no skin structure, and what
2821
- * cannot be read is not guessed — every slot then counts, as it did before.
2822
- */
2823
- export function slotsOnUnposedBones(
2824
- slots: ReadonlyArray<{ name: string; bone: string }>,
2825
- skin: string | undefined,
2826
- roster: SkinRoster | undefined,
2827
- ): Set<string> {
2828
- if (roster === undefined) return new Set();
2829
- const unposed = roster.unposedUnder(skin);
2830
- return new Set(slots.filter((slot) => unposed.has(slot.bone)).map((slot) => slot.name));
2831
- }
2832
-
2833
- /** How many drawn slots a refusal names before it says how many more there are. */
2834
- const UNFRAMEABLE_NAMED = 3;
2835
-
2836
- /**
2837
- * ⭐ **The one derivation of the unframeable sentence** (issue #997): `null`
2838
- * when the vertices of `frameSets` span a box with extent, and otherwise the
2839
- * sentence `UnframeablePoseError` carries. `framingViewport` and `check` both
2840
- * reach it, so `render`, `render --geometry`, `check` and a library caller
2841
- * refuse one rig in the same words.
2842
- *
2843
- * Two sentences, told apart by what the reader must change:
2844
- *
2845
- * - **every drawn slot hangs from a bone the skin leaves unposed** — inactive
2846
- * itself or below an inactive bone (`unposedBones`). Both posers draw such a
2847
- * slot through the zero matrix, so every vertex lands on the origin. Each
2848
- * bone is named with the skins that pose it, read off the runtime's own
2849
- * `active` flag under each skin rather than restating Spine's rule here.
2850
- * - otherwise **every drawn vertex sits at one point** — the bones posed, and
2851
- * collapsed their attachments (a world scale of 0 does).
2852
- *
2853
- * `roster` is the skin structure of the Spine file the poser poses
2854
- * (`skinRosterOf`), which is what says whether a bone is unposed
2855
- * (`slotsOnUnposedBones`, the set the framing box leaves out) and which skins
2856
- * pose it. Without it the first sentence cannot be told from the second, and
2857
- * the second is what is said.
2858
- *
2859
- * Distinct from *nothing to draw*: that is a skeleton that posed no vertex at
2860
- * all, and its fix is art; this one posed vertices, and they have no place.
2861
- */
2862
- export function unframeableSentence(
2863
- frameSets: ReadonlyArray<readonly Frame[]>,
2864
- slots: ReadonlyArray<{ name: string; bone: string }>,
2865
- skin: string | undefined,
2866
- roster: SkinRoster | undefined,
2867
- ): string | null {
2868
- const box = unionBounds(frameSets.map((frames) => [...frames]));
2869
- if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) return null;
2870
- if (box.maxX - box.minX !== 0 || box.maxY - box.minY !== 0) return null;
2871
- const drew = new Set<string>();
2872
- for (const frames of frameSets) {
2873
- for (const frame of frames) for (const piece of frame.pieces) if (piece.world.length > 0) drew.add(piece.slot);
2874
- }
2875
- // Declaration order, so the sentence does not depend on which frame drew first.
2876
- const drawn = slots.filter((slot) => drew.has(slot.name));
2877
- const under = skin === undefined ? 'under no skin' : `under skin ${JSON.stringify(skin)}`;
2878
- const counted = `${drawn.length} drawn slot${drawn.length === 1 ? '' : 's'}`;
2879
- const more = drawn.length > UNFRAMEABLE_NAMED ? `; and ${drawn.length - UNFRAMEABLE_NAMED} more` : '';
2880
- const named = (describe: (slot: { name: string; bone: string }) => string): string =>
2881
- drawn.slice(0, UNFRAMEABLE_NAMED).map(describe).join('; ') + more;
2882
- if (roster !== undefined) {
2883
- const offBox = slotsOnUnposedBones(slots, skin, roster);
2884
- if (drawn.length > 0 && drawn.every((slot) => offBox.has(slot.name))) {
2885
- const bySkin = roster.skins.map((name) => ({ name, unposed: roster.unposedUnder(name) }));
2886
- const posers = (bone: string): string => {
2887
- const names = bySkin.filter((k) => !k.unposed.has(bone)).map((k) => JSON.stringify(k.name));
2888
- if (names.length === 0) return 'which no skin poses';
2889
- return `which skin${names.length === 1 ? '' : 's'} ${names.join(', ')} pose${names.length === 1 ? 's' : ''}`;
2890
- };
2891
- return (
2892
- `${under}, every drawn slot hangs from a bone that skin leaves unposed — ${counted}: ` +
2893
- named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}, ${posers(slot.bone)}`) +
2894
- ' — so no drawn vertex has a world transform and the frame would be 0x0: pose it under a skin that poses ' +
2895
- 'those bones (`--skin`), or name the bones in the skin it is posed under'
2896
- );
2897
- }
2898
- }
2899
- return (
2900
- `${under}, every drawn vertex sits at the one point (${box.minX}, ${box.minY}) — ${counted}: ` +
2901
- named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}`) +
2902
- ' — so the posed box has no extent and the frame would be 0x0: the bones those slots hang from collapse ' +
2903
- 'every vertex onto it (a world scale of 0 does)'
2904
- );
2905
- }
2906
-
2907
963
  /**
2908
- * The viewport a skeleton is framed to: its union box at `FRAMING_FPS`, padded,
2909
- * scaled so the long side is `maxSide` pixels.
2910
- *
2911
- * Measuring the box densely and once makes the framing a property of the SHOT,
2912
- * so every rate of one skeleton lands on the same pixels.
2913
- *
2914
- * `null` means the skeleton posed no vertex at all — nothing to draw. A pose
2915
- * holding a vertex at Infinity or NaN is refused by a `GeometryError` naming the
2916
- * bone or vertex and its value (issue #873), never answered `null`. A pose whose
2917
- * every vertex sits at one point is refused by an `UnframeablePoseError`
2918
- * (`unframeableSentence`, issue #997), never framed at a scale of Infinity.
2919
- *
2920
- * The box is over the slots that POSE (issue #1000): a drawn slot whose bone
2921
- * the skin leaves unposed (`slotsOnUnposedBones`) draws no pixel — both posers
2922
- * put it through the zero matrix — so its vertices at the origin are not part
2923
- * of the shot, and counting them framed every frame of a rig that carries one
2924
- * to a point nothing is drawn at (256x94 where the posed slot alone gives
2925
- * 256x55). When every drawn slot is such a slot there is no posed box at all,
2926
- * and that is #997's refusal, never an empty or invented one.
2927
- *
2928
- * `roster` is the skin roster behind a `Poser` source (`skinRosterOf`) — what
2929
- * says which bones the skin leaves unposed, and lets the refusal name the skins
2930
- * that pose one. Spine data as the source is its own. A bare `Poser` with no
2931
- * roster cannot tell an unposed bone from a posed one, so every drawn slot
2932
- * counts there; every CLI caller passes the roster, so both posers frame alike.
964
+ * An atlas's pages and regions as spine-core's `TextureAtlas` reads them, each
965
+ * region mapping the drawing's own coordinates onto its page through the
966
+ * runtime's `MeshAttachment.computeUVs` — the `spine` reader of
967
+ * `textureSubstitutionFromText` (`SubstitutionReader`), which reaches it
968
+ * through the seam. Moved here unchanged from that function's branch (issue
969
+ * #1052): it names the runtime, and that function does not.
2933
970
  */
2934
- export function framingViewport(source: PoseSource, maxSide: number, opts?: PoseOptions, rosterGiven?: SkinRoster): Viewport | null {
2935
- const poser = poserOf(source);
2936
- // The skin belongs here as much as in the frames: the union box is over the
2937
- // attachments that POSE, and two skins fill a slot with art of different sizes
2938
- // in different places. Framing one skin's shot with another skin's box would
2939
- // put the difference between two skins into every measurement taken in it.
2940
- //
2941
- // ⭐ A slot subset is the opposite case, and is taken off (issue #835): what
2942
- // `--slot`/`--hide` leave out still counts toward the box, so a frame with a
2943
- // part hidden lands on the pixel grid of the frame with it and the two overlay.
2944
- // A subset framed to its own extent would move every pixel it kept.
2945
- //
2946
- // A clip is taken off for the same reason (issue #844): what it removes still
2947
- // counts toward the box, so adding or keying a mask moves no pixel it leaves
2948
- // drawn — see `PoseOptions.unclipped`.
2949
- // Neither the bone snapshots nor the geometry export frame anything, and
2950
- // both would be taken at `FRAMING_FPS` for every animation only to be dropped.
2951
- const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
2952
- const framed: PoseOptions = { ...whole, unclipped: true };
2953
- const sets =
2954
- poser.animations.length === 0
2955
- ? [sampleSetupPose(poser, framed)]
2956
- : poser.animations.map((a) => sampleAnimation(poser, a.name, FRAMING_FPS, framed));
2957
- // ⚠️ Two different reasons a box is not finite, told apart here and nowhere
2958
- // else (issue #873). A skeleton that posed no vertex at all has nothing to
2959
- // draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
2960
- // drawable attachment in a place no box can hold — so it is refused by the
2961
- // bone or vertex and its value, in the geometry export's own sentence. Before
2962
- // this, the first case's `null` covered both, and a single overflowing bone
2963
- // among finite ones reached neither: its box was finite on one side, and
2964
- // `render` wrote a NaN-by-NaN frame set with exit 0.
2965
- const drewAny = (frameSets: ReadonlyArray<readonly Frame[]>): boolean =>
2966
- frameSets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
2967
- if (!drewAny(sets)) return null;
2968
- // ⭐ The box is over the slots that pose (issue #1000). A drawn slot on a bone
2969
- // the skin leaves unposed is taken off — it draws no pixel — unless nothing
2970
- // else drew, in which case every set is kept whole so the pose reaches #997's
2971
- // refusal below exactly as it did before this: the same inputs, the same
2972
- // sentence, never an empty box framed to something.
2973
- const roster = rosterBehind(source, rosterGiven);
2974
- const offBox = slotsOnUnposedBones(poser.slots, framed.skin, roster);
2975
- const posedSets =
2976
- offBox.size === 0 ? sets : sets.map((frames) => frames.map((frame) => ({ ...frame, pieces: frame.pieces.filter((piece) => !offBox.has(piece.slot)) })));
2977
- const boxed = drewAny(posedSets) ? posedSets : sets;
2978
- const box = unionBounds(boxed);
2979
- if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
2980
- const found = nonFinitePoseOf(
2981
- poser,
2982
- framed.skin,
2983
- poser.animations.length === 0
2984
- ? [{ animation: null, fps: FRAMING_FPS }]
2985
- : poser.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
2986
- );
2987
- // Reaching here with nothing found would mean the pieces and the whole
2988
- // attachments disagree about one pose — a defect here, said as one.
2989
- if (found === null) {
2990
- throw new Error(
2991
- `the framing box is not finite (${box.minX}, ${box.minY}, ${box.maxX}, ${box.maxY}) and no bone or ` +
2992
- 'vertex of the pose is — the drawn pieces and the attachments were posed differently',
2993
- );
2994
- }
2995
- throw new GeometryError(found);
971
+ function spineSubstitution(atlasText: string): { pages: string[]; regions: Map<string, SubstituteRegion> } {
972
+ const regions = new Map<string, SubstituteRegion>();
973
+ const names: string[] = [];
974
+ const atlas = new TextureAtlas(atlasText);
975
+ for (const page of atlas.pages) names.push(page.name);
976
+ for (const region of atlas.regions) {
977
+ const page = { name: region.page.name, width: region.page.width, height: region.page.height };
978
+ regions.set(regionKey(region), {
979
+ page,
980
+ x: region.x,
981
+ y: region.y,
982
+ width: region.width,
983
+ height: region.height,
984
+ degrees: region.degrees,
985
+ pageUvs: (art) => {
986
+ const uvs = new Array<number>(art.length).fill(0);
987
+ MeshAttachment.computeUVs(region, [...art], uvs);
988
+ return uvs;
989
+ },
990
+ });
2996
991
  }
2997
- // A finite box over one point (issue #997): every drawn slot on a bone the
2998
- // skin leaves unposed, or every vertex collapsed. Framed, it is a scale of
2999
- // Infinity and a frame of NaN by NaN pixels, written as 0x0 with exit 0.
3000
- const unframeable = unframeableSentence(boxed, poser.slots, framed.skin, roster);
3001
- if (unframeable !== null) throw new UnframeablePoseError(unframeable);
3002
- const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
3003
- return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
3004
- }
3005
-
3006
- /** A viewport over an explicit world box, scaled so its long side is `maxSide`. */
3007
- export function viewportFor(minX: number, minY: number, maxX: number, maxY: number, maxSide: number): Viewport {
3008
- const scale = maxSide / Math.max(maxX - minX, maxY - minY);
3009
- return {
3010
- minX,
3011
- minY,
3012
- maxX,
3013
- maxY,
3014
- scale,
3015
- width: Math.max(1, Math.round((maxX - minX) * scale)),
3016
- height: Math.max(1, Math.round((maxY - minY) * scale)),
3017
- };
3018
- }
3019
-
3020
- /**
3021
- * A viewport over an explicit world box whose pixel size is already known.
3022
- *
3023
- * This is the shape `check` needs: the frames on disk fix the pixel size, and
3024
- * re-deriving it from the box would round to a different integer and silently
3025
- * shift every measurement by up to half a pixel.
3026
- */
3027
- export function viewportOfSize(
3028
- minX: number,
3029
- minY: number,
3030
- width: number,
3031
- height: number,
3032
- scale: number,
3033
- pixelWidth: number,
3034
- pixelHeight: number,
3035
- ): Viewport {
3036
- return { minX, minY, maxX: minX + width, maxY: minY + height, scale, width: pixelWidth, height: pixelHeight };
3037
- }
3038
-
3039
- /** World (y up) to frame pixels (y down). The only place that conversion lives. */
3040
- export function projector(v: Viewport): (wx: number, wy: number) => [number, number] {
3041
- return (wx, wy) => [(wx - v.minX) * v.scale, (v.maxY - wy) * v.scale];
992
+ return { pages: names, regions };
3042
993
  }
3043
994
 
3044
995
  // ---------------------------------------------------------------------------
3045
- // rasterising
996
+ // the Spine side, registered (issue #1052)
3046
997
  // ---------------------------------------------------------------------------
3047
-
3048
- /**
3049
- * One colour channel of one texel, tinted — the whole of what a slot's colours
3050
- * do to a pixel.
3051
- *
3052
- * With no dark colour this is the multiply it always was, to the bit: `dark`
3053
- * absent returns `sample * light` and nothing else, which is why every frame in
3054
- * this repository renders byte for byte as it did before two-colour tinting
3055
- * existed.
3056
- *
3057
- * With one, the light colour multiplies the texel and the dark colour fills in
3058
- * what the texel leaves behind, so a black region can be tinted to any colour
3059
- * while its bright parts keep the light tint. [official] — spine-ts's own
3060
- * two-colour fragment shader, `spine-ts/spine-webgl/src/Shader.ts`
3061
- * (`newTwoColoredTextured`), read at branch `4.3` of `EsotericSoftware/spine-runtimes`:
3062
- *
3063
- * gl_FragColor.a = texColor.a * v_light.a;
3064
- * gl_FragColor.rgb = ((texColor.a - 1.0) * v_dark.a + 1.0 - texColor.rgb) * v_dark.rgb
3065
- * + texColor.rgb * v_light.rgb;
3066
- *
3067
- * ⚠️ `v_dark.a` in that line is **not a colour channel** — it is the
3068
- * premultiplied-alpha flag, which is why the dark colour is six hex digits in
3069
- * the file and four bytes on the vertex. `SkeletonRendererCore` packs it as such:
3070
- * `darkColor = 0xff000000 | …` on the `pma` branch and `darkColor = (r << 16) |
3071
- * (g << 8) | b` — alpha byte **zero** — on the other. This rasteriser composites
3072
- * **straight** alpha (see `premultiplied` below), so the flag is 0 and the shader
3073
- * reduces to the two terms this function computes. Reading `dark.a` out of the
3074
- * file here would be reading a flag as a colour.
3075
- *
3076
- * The clamp is on the dark path only, for the same reason: `sample * light` is
3077
- * already inside the range whenever `light` is, and a clamp on that path would
3078
- * be a change to pixels nothing asked to change.
3079
- */
3080
- function tintChannel(sample: number, light: number, dark: number | undefined): number {
3081
- if (dark === undefined) return sample * light;
3082
- const mixed = sample * light + (255 - sample) * dark;
3083
- return mixed < 0 ? 0 : mixed > 255 ? 255 : mixed;
3084
- }
3085
-
3086
- /**
3087
- * Walk the destination pixels one affine quad covers, sampling the page.
3088
- *
3089
- * The quad is an affine image of the region's rectangle, so a destination pixel
3090
- * maps back to a (s, t) inside it by inverting one 2x2 — no perspective divide,
3091
- * no triangle split. `emit` is called for every covered pixel whose composited
3092
- * alpha clears the coverage threshold, which is what makes "draw it" and
3093
- * "measure where it landed" the same traversal rather than two that can drift.
3094
- */
3095
- export function rasteriseQuad(
3096
- page: Plate,
3097
- quad: Quad,
3098
- project: (wx: number, wy: number) => [number, number],
3099
- clip: { width: number; height: number },
3100
- emit: (px: number, py: number, r: number, g: number, b: number, a: number) => void,
3101
- ): void {
3102
- // spine-core's region order is br, bl, ul, ur.
3103
- const [brx, bry, blx, bly, ulx, uly] = quad.world;
3104
- const bl = project(blx, bly);
3105
- const br = project(brx, bry);
3106
- const ul = project(ulx, uly);
3107
- const ex = [br[0] - bl[0], br[1] - bl[1]];
3108
- const ey = [ul[0] - bl[0], ul[1] - bl[1]];
3109
- const det = ex[0] * ey[1] - ex[1] * ey[0];
3110
- if (Math.abs(det) < 1e-9) return; // degenerate: zero scale, nothing to draw
3111
- const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
3112
-
3113
- const corners = [bl, br, ul, [br[0] + ey[0], br[1] + ey[1]]];
3114
- const minX = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
3115
- const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
3116
- const minY = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
3117
- const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
3118
-
3119
- for (let py = minY; py <= maxY; py++) {
3120
- for (let px = minX; px <= maxX; px++) {
3121
- const rx = px + 0.5 - bl[0];
3122
- const ry = py + 0.5 - bl[1];
3123
- const s = (rx * ey[1] - ry * ey[0]) / det;
3124
- const t = (ex[0] * ry - ex[1] * rx) / det;
3125
- if (s < 0 || s > 1 || t < 0 || t > 1) continue;
3126
- const u = ubl + s * (ubr - ubl) + t * (uul - ubl);
3127
- const v = vbl + s * (vbr - vbl) + t * (vul - vbl);
3128
- if (outsideWindow(quad.uvWindow, u, v)) continue;
3129
- const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
3130
- const alpha = sample[3] * quad.tint[3];
3131
- if (alpha <= 0.5) continue;
3132
- emit(
3133
- px,
3134
- py,
3135
- Math.round(tintChannel(sample[0], quad.tint[0], quad.dark?.[0])),
3136
- Math.round(tintChannel(sample[1], quad.tint[1], quad.dark?.[1])),
3137
- Math.round(tintChannel(sample[2], quad.tint[2], quad.dark?.[2])),
3138
- Math.round(alpha),
3139
- );
3140
- }
3141
- }
3142
- }
3143
-
3144
- /** A destination pixel and the straight-alpha colour a piece put there. */
3145
- export type EmitPixel = (px: number, py: number, r: number, g: number, b: number, a: number) => void;
3146
-
3147
- /**
3148
- * Slack on a `UvWindow`'s edges, in page UVs.
3149
- *
3150
- * The window's bounds *are* the region rectangle's own UVs, and a piece's
3151
- * interpolated UV reaches them exactly at its edge — so the test has to admit
3152
- * equality, and a bare `<` would drop a boundary pixel whenever the arithmetic
3153
- * lands a bit under. A billionth of a page is far below a texel and far above the
3154
- * error of two multiplies.
3155
- */
3156
- const WINDOW_SLACK = 1e-9;
3157
-
3158
- /**
3159
- * Is this texel outside the rectangle its piece is allowed to sample?
3160
- *
3161
- * `undefined` is the ordinary case — a piece posed from its own atlas has no
3162
- * window — and answers `false` without arithmetic, which keeps this off the cost
3163
- * of every reference frame ever rendered.
3164
- */
3165
- function outsideWindow(window: UvWindow | undefined, u: number, v: number): boolean {
3166
- if (window === undefined) return false;
3167
- return (
3168
- u < window.u0 - WINDOW_SLACK ||
3169
- u > window.u1 + WINDOW_SLACK ||
3170
- v < window.v0 - WINDOW_SLACK ||
3171
- v > window.v1 + WINDOW_SLACK
3172
- );
3173
- }
3174
-
3175
- /**
3176
- * Is this edge a top or a left one, for the winding `rasteriseMesh` normalises to?
3177
- *
3178
- * Derived rather than copied, because the answer depends on the sign convention
3179
- * of the edge function and the direction of y. With `edge(p) = dx·(py−y0) −
3180
- * dy·(px−x0)` and y pointing **down**, the triangle `(0,0) → (1,0) → (0,1)` has
3181
- * positive area, and its horizontal edge `(0,0) → (1,0)` — `dx > 0`, `dy = 0` —
3182
- * is the one along its top. Its `(0,1) → (0,0)` edge — `dy < 0`, going up — is
3183
- * the one down its left.
3184
- *
3185
- * What actually makes the rule watertight needs neither of those facts: the two
3186
- * triangles sharing an edge traverse it in opposite directions, so `dy < 0` holds
3187
- * for exactly one of them, and when `dy` is 0 for both, `dx > 0` holds for
3188
- * exactly one. Every shared edge is therefore claimed once. Getting the
3189
- * orientation right on top of that is what keeps the classic meaning — a pixel
3190
- * centre on a boundary belongs to the triangle below-right of it.
3191
- */
3192
- function isTopLeftEdge(dx: number, dy: number): boolean {
3193
- return dy < 0 || (dy === 0 && dx > 0);
3194
- }
3195
-
3196
- /**
3197
- * Walk the destination pixels one posed mesh covers, sampling the page.
3198
- *
3199
- * Each triangle is filled independently with barycentric UV interpolation and no
3200
- * perspective divide — a Spine mesh is a flat 2D deformation, so its UVs are
3201
- * affine in screen space and there is no `w` to divide by. The winding is
3202
- * normalised per triangle (a mesh's triangles are not guaranteed to agree, and a
3203
- * bone with negative scale flips them all anyway), and the top-left rule then
3204
- * makes every interior edge belong to exactly one of the two triangles that
3205
- * share it.
3206
- *
3207
- * `emit` has the same contract as `rasteriseQuad`'s — every covered pixel whose
3208
- * composited alpha clears the same 0.5 threshold — so "draw it" and "measure
3209
- * where it landed" stay one traversal for meshes exactly as they are for regions.
3210
- */
3211
- export function rasteriseMesh(
3212
- page: Plate,
3213
- mesh: Mesh,
3214
- project: (wx: number, wy: number) => [number, number],
3215
- clip: { width: number; height: number },
3216
- emit: EmitPixel,
3217
- ): void {
3218
- const count = mesh.world.length / 2;
3219
- // Project once per vertex, not once per triangle: an interior vertex of a
3220
- // 40-vertex hull belongs to half a dozen triangles, and projecting it six times
3221
- // invites six answers the moment anything about `project` stops being exact.
3222
- const px = new Float64Array(count);
3223
- const py = new Float64Array(count);
3224
- for (let i = 0; i < count; i++) {
3225
- const [x, y] = project(mesh.world[i * 2], mesh.world[i * 2 + 1]);
3226
- px[i] = x;
3227
- py[i] = y;
3228
- }
3229
-
3230
- for (let t = 0; t + 2 < mesh.triangles.length; t += 3) {
3231
- let i0 = mesh.triangles[t];
3232
- let i1 = mesh.triangles[t + 1];
3233
- const i2 = mesh.triangles[t + 2];
3234
- let area = (px[i1] - px[i0]) * (py[i2] - py[i0]) - (py[i1] - py[i0]) * (px[i2] - px[i0]);
3235
- if (area === 0) continue; // degenerate: a zero-height triangle covers nothing
3236
- if (area < 0) {
3237
- const swap = i0;
3238
- i0 = i1;
3239
- i1 = swap;
3240
- area = -area;
3241
- }
3242
-
3243
- const x0 = px[i0];
3244
- const y0 = py[i0];
3245
- const x1 = px[i1];
3246
- const y1 = py[i1];
3247
- const x2 = px[i2];
3248
- const y2 = py[i2];
3249
- const minX = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
3250
- const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(x0, x1, x2)));
3251
- const minY = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
3252
- const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(y0, y1, y2)));
3253
- if (maxX < minX || maxY < minY) continue;
3254
-
3255
- // Edge `k` is the one opposite vertex `k`, so its edge function IS the
3256
- // unnormalised barycentric weight of that vertex.
3257
- const topLeft0 = isTopLeftEdge(x2 - x1, y2 - y1);
3258
- const topLeft1 = isTopLeftEdge(x0 - x2, y0 - y2);
3259
- const topLeft2 = isTopLeftEdge(x1 - x0, y1 - y0);
3260
-
3261
- const u0 = mesh.uvs[i0 * 2];
3262
- const v0 = mesh.uvs[i0 * 2 + 1];
3263
- const u1 = mesh.uvs[i1 * 2];
3264
- const v1 = mesh.uvs[i1 * 2 + 1];
3265
- const u2 = mesh.uvs[i2 * 2];
3266
- const v2 = mesh.uvs[i2 * 2 + 1];
3267
-
3268
- // A clipped piece (`Mesh.source`, issue #964): the UV of a pixel is the SOURCE triangle's affine map, in doubles from its own
3269
- // projected corners and page UVs, so which convex pieces the clipper cut changes no pixel. Coverage is still this triangle's.
3270
- const src = mesh.source === undefined ? null : sourceMap(mesh.source, t / 3, project);
3271
-
3272
- for (let y = minY; y <= maxY; y++) {
3273
- const sy = y + 0.5;
3274
- for (let x = minX; x <= maxX; x++) {
3275
- const sx = x + 0.5;
3276
- const w0 = (x2 - x1) * (sy - y1) - (y2 - y1) * (sx - x1);
3277
- if (topLeft0 ? w0 < 0 : w0 <= 0) continue;
3278
- const w1 = (x0 - x2) * (sy - y2) - (y0 - y2) * (sx - x2);
3279
- if (topLeft1 ? w1 < 0 : w1 <= 0) continue;
3280
- const w2 = (x1 - x0) * (sy - y0) - (y1 - y0) * (sx - x0);
3281
- if (topLeft2 ? w2 < 0 : w2 <= 0) continue;
3282
-
3283
- let u: number;
3284
- let v: number;
3285
- if (src === null) {
3286
- const b0 = w0 / area;
3287
- const b1 = w1 / area;
3288
- const b2 = w2 / area;
3289
- u = b0 * u0 + b1 * u1 + b2 * u2;
3290
- v = b0 * v0 + b1 * v1 + b2 * v2;
3291
- } else [u, v] = src(sx, sy);
3292
- if (outsideWindow(mesh.uvWindow, u, v)) continue;
3293
- const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
3294
- const alpha = sample[3] * mesh.tint[3];
3295
- if (alpha <= 0.5) continue;
3296
- emit(
3297
- x,
3298
- y,
3299
- Math.round(tintChannel(sample[0], mesh.tint[0], mesh.dark?.[0])),
3300
- Math.round(tintChannel(sample[1], mesh.tint[1], mesh.dark?.[1])),
3301
- Math.round(tintChannel(sample[2], mesh.tint[2], mesh.dark?.[2])),
3302
- Math.round(alpha),
3303
- );
3304
- }
3305
- }
3306
- }
3307
- }
3308
-
3309
- /**
3310
- * The UV map of a clipped piece's drawn triangle `t`: its source triangle's
3311
- * affine map (`Mesh.source`), from the three projected source corners and their
3312
- * page UVs, in doubles — the barycentric weights of the pixel centre in the
3313
- * projected source triangle times the corners' UVs. The same for every
3314
- * decomposition of the source triangle, which is the point (issue #964).
3315
- */
3316
- function sourceMap(source: ClipSource, t: number, project: (wx: number, wy: number) => [number, number]): (sx: number, sy: number) => [number, number] {
3317
- const o = t * 6;
3318
- const [ax, ay] = project(source.world[o], source.world[o + 1]);
3319
- const [bx, by] = project(source.world[o + 2], source.world[o + 3]);
3320
- const [cx, cy] = project(source.world[o + 4], source.world[o + 5]);
3321
- const uv = source.uvs;
3322
- const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
3323
- return (sx, sy) => {
3324
- const wa = ((cx - bx) * (sy - by) - (cy - by) * (sx - bx)) / area;
3325
- const wb = ((ax - cx) * (sy - cy) - (ay - cy) * (sx - cx)) / area;
3326
- const wc = ((bx - ax) * (sy - ay) - (by - ay) * (sx - ax)) / area;
3327
- return [wa * uv[o] + wb * uv[o + 2] + wc * uv[o + 4], wa * uv[o + 1] + wb * uv[o + 3] + wc * uv[o + 5]];
3328
- };
3329
- }
3330
-
3331
- /**
3332
- * Rasterise whichever shape this piece is.
3333
- *
3334
- * ⭐ Every caller that used to reach for `rasteriseQuad` goes through here, so
3335
- * "what counts as a covered pixel" has one definition for both shapes — which is
3336
- * what lets `frameGeometry`, the framing box and the drawn frame agree about a
3337
- * mesh without any of them knowing what a triangle is.
3338
- */
3339
- export function rasterisePiece(
3340
- page: Plate,
3341
- piece: Piece,
3342
- project: (wx: number, wy: number) => [number, number],
3343
- clip: { width: number; height: number },
3344
- emit: EmitPixel,
3345
- ): void {
3346
- if (piece.kind === 'mesh') rasteriseMesh(page, piece, project, clip, emit);
3347
- else rasteriseQuad(page, piece, project, clip, emit);
3348
- }
3349
-
3350
- /** Blit one piece onto the plate, source-over. */
3351
- export function blitPiece(
3352
- dst: Plate,
3353
- page: Plate,
3354
- piece: Piece,
3355
- project: (wx: number, wy: number) => [number, number],
3356
- ): void {
3357
- rasterisePiece(page, piece, project, dst, (px, py, r, g, b, a) => dst.blend(px, py, [r, g, b, a]));
3358
- }
3359
-
3360
- /** The four texels one bilinear tap reads, and the fractions between them. */
3361
- interface Taps {
3362
- c00: RGBA;
3363
- c10: RGBA;
3364
- c01: RGBA;
3365
- c11: RGBA;
3366
- fx: number;
3367
- fy: number;
3368
- }
3369
-
3370
- /**
3371
- * The four texels around `(x, y)`, CLAMPED at the page edge.
3372
- *
3373
- * ⚠️ The clamp is load-bearing beyond this function: `src/atlas.ts` sizes the
3374
- * gutter between packed regions against the fact that one tap reaches exactly one
3375
- * texel, and `gallery/portrait`'s lid runs its art flush to its own window
3376
- * because a clamped tap has no transparent neighbour to reach into. Widening the
3377
- * tap is not a local change.
3378
- */
3379
- function taps(page: Plate, x: number, y: number): Taps {
3380
- const x0 = Math.floor(x);
3381
- const y0 = Math.floor(y);
3382
- const at = (ix: number, iy: number): RGBA => {
3383
- const cx = Math.max(0, Math.min(page.width - 1, ix));
3384
- const cy = Math.max(0, Math.min(page.height - 1, iy));
3385
- return page.get(cx, cy);
3386
- };
3387
- return { c00: at(x0, y0), c10: at(x0 + 1, y0), c01: at(x0, y0 + 1), c11: at(x0 + 1, y0 + 1), fx: x - x0, fy: y - y0 };
3388
- }
3389
-
3390
- /** One channel of a bilinear tap: lerp along x on both rows, then between them. */
3391
- function lerpTap(v00: number, v10: number, v01: number, v11: number, fx: number, fy: number): number {
3392
- const top = v00 + (v10 - v00) * fx;
3393
- const bottom = v01 + (v11 - v01) * fx;
3394
- return top + (bottom - top) * fy;
3395
- }
3396
-
3397
- /**
3398
- * Sample a straight-alpha page bilinearly — interpolating in PREMULTIPLIED space.
3399
- *
3400
- * ⭐ **Why the premultiply.** The source is straight alpha, so a transparent
3401
- * texel beside the art is `(0, 0, 0, 0)`: its colour is not a colour, it is the
3402
- * absence of one. Averaging R, G and B against it pulls the sample toward black
3403
- * while alpha only drops part of the way, and the difference between those two
3404
- * rates IS a dark rim, one pixel wide, drawn over whatever is behind the part.
3405
- * Weighting each colour by its own alpha and dividing the sum back out gives the
3406
- * transparent texel no vote in the colour, which is the whole of the fix: two
3407
- * parts of one colour, overlapping, come out that colour. Measured before the
3408
- * fix at −60/255 between two parts sharing one flat field, and −31/255 down
3409
- * `gallery/portrait`'s forehead — issue #292.
3410
- *
3411
- * ⭐ **Why alpha is computed the old way, and why equal alpha short-circuits.**
3412
- * `rasteriseQuad` and `rasteriseMesh` gate coverage on `alpha > 0.5`, so the
3413
- * alpha arithmetic decides WHICH pixels are drawn — and through `frameGeometry`,
3414
- * the framing box every reference frame was rendered inside. `lerpTap` on the
3415
- * alpha channel is therefore the original expression, unchanged, not an
3416
- * algebraically equal rearrangement: equal-but-rearranged is a last-bit
3417
- * difference, and a last bit either side of 0.5 is a pixel.
3418
- *
3419
- * For the same reason the equal-alpha case returns early. When all four taps
3420
- * carry one alpha, premultiplying by it and dividing it back out is the identity
3421
- * — so the straight path is not an approximation there, it is the same number,
3422
- * and taking it reproduces the five committed rungs BIT for bit rather than
3423
- * merely closely. What moves is exactly the mixed-alpha tap: the edges, where the
3424
- * rim was.
3425
- */
3426
- export function bilinear(page: Plate, x: number, y: number): [number, number, number, number] {
3427
- const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
3428
- const a = lerpTap(c00[3], c10[3], c01[3], c11[3], fx, fy);
3429
- if (c00[3] === c10[3] && c00[3] === c01[3] && c00[3] === c11[3]) {
3430
- return [
3431
- lerpTap(c00[0], c10[0], c01[0], c11[0], fx, fy),
3432
- lerpTap(c00[1], c10[1], c01[1], c11[1], fx, fy),
3433
- lerpTap(c00[2], c10[2], c01[2], c11[2], fx, fy),
3434
- a,
3435
- ];
3436
- }
3437
- // Every tap is transparent in some proportion that sums to nothing: there is no
3438
- // colour to recover and no pixel to draw (both callers gate on alpha anyway).
3439
- if (a <= 0) return [0, 0, 0, 0];
3440
- const out: [number, number, number, number] = [0, 0, 0, a];
3441
- for (let c = 0; c < 3; c++) {
3442
- const pm = lerpTap(c00[c] * c00[3], c10[c] * c10[3], c01[c] * c01[3], c11[c] * c11[3], fx, fy);
3443
- // Bounded by 255 in exact arithmetic — the weighted mean of the taps' colours
3444
- // cannot exceed their maximum — so the clamp absorbs float error only. It is
3445
- // here rather than trusted because `Plate`'s store is a `Uint8Array`, which
3446
- // WRAPS: 256 would land as a black pixel in the brightest part of the art.
3447
- out[c] = Math.min(255, pm / a);
3448
- }
3449
- return out;
3450
- }
3451
-
3452
- /**
3453
- * The same tap, each channel interpolated independently — the arithmetic
3454
- * `bilinear` used until #292, kept as the CONTROL that fix is measured against.
3455
- *
3456
- * 🚫 **Nothing in `src/` calls this, and nothing in `src/` should.** It had one
3457
- * production caller until #306: `src/pose.ts`'s `errBilinear`, on the argument
3458
- * that `materialPlate`'s fourth channel is a material mask rather than opacity.
3459
- * That argument was wrong in the direction that mattered — the mask is exactly
3460
- * the weight the colour wanted, because a texel with no material carries the
3461
- * background's colour and not the part's — so `errBilinear` now takes
3462
- * `bilinear` too, and the only importer left is `selftest.ts`.
3463
- *
3464
- * ⭐ It lives here rather than in the suite so the control shares `taps` — the
3465
- * edge clamp above — with the sampler it is a control for. A hand copy in the
3466
- * test file would drift from it silently, and then `SM01` would be comparing the
3467
- * fix against something that is not what the renderer used to do.
3468
- * `SM08_NO_PRODUCTION_MODULE_READS_THE_STRAIGHT_TAP` is what keeps the first
3469
- * paragraph true rather than merely written down.
3470
- */
3471
- export function bilinearChannels(page: Plate, x: number, y: number): [number, number, number, number] {
3472
- const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
3473
- const out: [number, number, number, number] = [0, 0, 0, 0];
3474
- for (let c = 0; c < 4; c++) out[c] = lerpTap(c00[c], c10[c], c01[c], c11[c], fx, fy);
3475
- return out;
3476
- }
3477
-
3478
- export function fill(plate: Plate, colour: RGBA): void {
3479
- for (let y = 0; y < plate.height; y++) for (let x = 0; x < plate.width; x++) plate.set(x, y, colour);
3480
- }
3481
-
3482
- /** Look a page up by name, with a failure that names what the atlas did declare. */
3483
- export function pageFor(pages: Map<string, Plate>, piece: Piece): Plate {
3484
- const page = pages.get(piece.page);
3485
- if (!page) {
3486
- throw new Error(
3487
- `slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`,
3488
- );
3489
- }
3490
- return page;
3491
- }
3492
-
3493
- /** One frame, composited over `background`, at the viewport's pixel size. */
3494
- export function renderFrame(frame: Frame, pages: Map<string, Plate>, viewport: Viewport, background: RGBA): Plate {
3495
- const plate = new Plate(viewport.width, viewport.height);
3496
- fill(plate, background);
3497
- const project = projector(viewport);
3498
- for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
3499
- return plate;
3500
- }
3501
-
3502
- /**
3503
- * Every frame of one animation as one labelled grid, row major.
3504
- *
3505
- * Not decoration: rung 3's subject is *spacing* — how far a thing travels
3506
- * between two consecutive frames — and that is a comparison across frames. A
3507
- * reader flipping through 65 separate files is comparing against memory.
3508
- *
3509
- * ⭐ It lives here rather than beside either caller because the layout is a
3510
- * CONTRACT: `bench/render_reference.ts` writes the grid, `rigc render` writes the
3511
- * same grid for a user's own build, and `src/check.ts` reads a sheet's tiles back
3512
- * out of it (issue #36). Three programs reading one geometry is one definition or
3513
- * it is a bug waiting for the day two of them are edited apart.
3514
- */
3515
- export function contactSheet(frames: Frame[], pages: Map<string, Plate>, viewport: Viewport, tile: number): Plate {
3516
- const tileScale = tile / Math.max(viewport.width, viewport.height);
3517
- const tileW = Math.max(1, Math.round(viewport.width * tileScale));
3518
- const tileH = Math.max(1, Math.round(viewport.height * tileScale));
3519
- const columns = Math.min(SHEET_COLUMNS, frames.length);
3520
- const rows = Math.ceil(frames.length / columns);
3521
- const sheet = new Plate(columns * (tileW + SHEET_GAP) + SHEET_GAP, rows * (tileH + SHEET_GAP) + SHEET_GAP);
3522
- fill(sheet, SHEET_RULE);
3523
- const base = projector(viewport);
3524
- frames.forEach((frame, i) => {
3525
- const col = i % columns;
3526
- const row = Math.floor(i / columns);
3527
- const ox = col * (tileW + SHEET_GAP) + SHEET_GAP;
3528
- const oy = row * (tileH + SHEET_GAP) + SHEET_GAP;
3529
- const plate = new Plate(tileW, tileH);
3530
- fill(plate, BACKGROUND);
3531
- const project = (wx: number, wy: number): [number, number] => {
3532
- const [px, py] = base(wx, wy);
3533
- return [px * tileScale, py * tileScale];
3534
- };
3535
- for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
3536
- plate.text(String(i), 2, 2, 1, SHEET_LABEL);
3537
- for (let y = 0; y < tileH; y++) for (let x = 0; x < tileW; x++) sheet.set(ox + x, oy + y, plate.get(x, y));
3538
- });
3539
- return sheet;
3540
- }
3541
-
3542
- /** Where one thing landed in a frame, in frame pixels. */
3543
- export interface Footprint {
3544
- /** Alpha-weighted count of covered pixels. 0 means nothing was drawn. */
3545
- pixels: number;
3546
- cx: number;
3547
- cy: number;
3548
- minX: number;
3549
- minY: number;
3550
- maxX: number;
3551
- maxY: number;
3552
- }
3553
-
3554
- export const EMPTY_FOOTPRINT: Footprint = { pixels: 0, cx: 0, cy: 0, minX: 0, minY: 0, maxX: 0, maxY: 0 };
3555
-
3556
- /** Where a frame's pixels went: the coverage mask, and each slot's own footprint. */
3557
- export interface FrameGeometry {
3558
- /** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
3559
- coverage: Uint8Array;
3560
- footprints: Map<string, Footprint>;
3561
- /**
3562
- * Which owner drew each pixel last, or `-1` — `null` unless `owners` was given.
3563
- *
3564
- * "Last" is the composite's own rule: pieces arrive in draw order, so the owner
3565
- * left in a pixel is the one you would see there. That is deliberately the
3566
- * opposite of `footprints`, which measures each slot on its own pixels
3567
- * *ignoring* what covers it — a footprint answers "where is this part", and
3568
- * this mask answers "whose part is this pixel", and only the second one can be
3569
- * a partition.
3570
- */
3571
- owner: Int32Array | null;
3572
- }
3573
-
3574
- /**
3575
- * Rasterise one frame for measurement rather than for looking at: which pixels
3576
- * it covers, and where each slot landed.
3577
- *
3578
- * ⚠️ A slot's footprint is measured on the pixels **that slot draws**, ignoring
3579
- * what is drawn over it. That is deliberate. A slot hidden behind another still
3580
- * has a position, and it is the position the rig gives it; measuring it on the
3581
- * composite would report the occluder's geometry instead and call the rig wrong
3582
- * for being covered up. What the composite costs is on the reference side, where
3583
- * an occluded part merges into its occluder's component — and that is what the
3584
- * matcher reports as ambiguity rather than as drift.
3585
- */
3586
- export function frameGeometry(
3587
- frame: Frame,
3588
- pages: Map<string, Plate>,
3589
- viewport: Viewport,
3590
- /** Slot name → owner id, when the caller also wants the per-pixel owner mask. */
3591
- owners?: Map<string, number>,
3592
- ): FrameGeometry {
3593
- const coverage = new Uint8Array(viewport.width * viewport.height);
3594
- const owner = owners === undefined ? null : new Int32Array(viewport.width * viewport.height).fill(-1);
3595
- const footprints = new Map<string, Footprint>();
3596
- const project = projector(viewport);
3597
- for (const piece of frame.pieces) {
3598
- const owned = owners === undefined ? -1 : (owners.get(piece.slot) ?? -1);
3599
- let weight = 0;
3600
- let sx = 0;
3601
- let sy = 0;
3602
- let minX = Infinity;
3603
- let minY = Infinity;
3604
- let maxX = -Infinity;
3605
- let maxY = -Infinity;
3606
- rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
3607
- coverage[py * viewport.width + px] = 1;
3608
- if (owner !== null && owned >= 0) owner[py * viewport.width + px] = owned;
3609
- const w = a / 255;
3610
- weight += w;
3611
- sx += (px + 0.5) * w;
3612
- sy += (py + 0.5) * w;
3613
- if (px < minX) minX = px;
3614
- if (px > maxX) maxX = px;
3615
- if (py < minY) minY = py;
3616
- if (py > maxY) maxY = py;
3617
- });
3618
- const previous = footprints.get(piece.slot);
3619
- const here: Footprint =
3620
- weight === 0
3621
- ? EMPTY_FOOTPRINT
3622
- : { pixels: weight, cx: sx / weight, cy: sy / weight, minX, minY, maxX: maxX + 1, maxY: maxY + 1 };
3623
- // A slot shows one attachment at a time, so this only merges when a caller
3624
- // hands us a frame with two pieces on one slot; merging is still the honest
3625
- // answer, and it keeps the map keyed by slot the way the report reads it.
3626
- footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
3627
- }
3628
- return { coverage, footprints, owner };
3629
- }
3630
-
3631
- function mergeFootprints(a: Footprint, b: Footprint): Footprint {
3632
- if (b.pixels === 0) return a;
3633
- const pixels = a.pixels + b.pixels;
3634
- return {
3635
- pixels,
3636
- cx: (a.cx * a.pixels + b.cx * b.pixels) / pixels,
3637
- cy: (a.cy * a.pixels + b.cy * b.pixels) / pixels,
3638
- minX: Math.min(a.minX, b.minX),
3639
- minY: Math.min(a.minY, b.minY),
3640
- maxX: Math.max(a.maxX, b.maxX),
3641
- maxY: Math.max(a.maxY, b.maxY),
3642
- };
3643
- }
998
+ //
999
+ // ⭐ Loading this file is what fills the seam `./render_shared.ts` reads: every
1000
+ // program that imports it — `cli.ts`, the tools, the selftest — poses an export,
1001
+ // `--poser spine` and a fallback through spine-core exactly as before, and one
1002
+ // that never loads it links nothing of the runtime. Functions only: nothing of
1003
+ // the runtime is touched here, so a runtime that cannot be used is still met
1004
+ // where a run first needs it (`requireSpineRuntime`).
1005
+ registerSpinePosing({
1006
+ requireRuntime: requireSpineRuntime,
1007
+ skeletonData: (skeletonText, atlasText, refuse) => spineSkeletonData(skeletonText, atlasText, refuse),
1008
+ facts: (data, atlasText) => spineFacts(data as SkeletonData, atlasText),
1009
+ poser: (data) => spinePoser(data as SkeletonData),
1010
+ skinRoster: (data) => skinRosterOf(data as SkeletonData),
1011
+ atlasPageNames,
1012
+ substitution: spineSubstitution,
1013
+ });