spine-rigc 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/world.ts CHANGED
@@ -3,9 +3,23 @@
3
3
  * the compiled model's bones (issue #925). Written from what a bone's fields
4
4
  * mean and from measurement against the runtime's pose dump
5
5
  * (`tools/pose_oracle.ts dump`, spine-core 4.3.13), and held to it by the core
6
- * suite's equivalence controls; it shares no code with `src/transform.ts`,
7
- * which is the compiler's arithmetic and stays frozen so that no emitted byte
8
- * moves.
6
+ * suite's equivalence controls.
7
+ *
8
+ * ⭐ **The compiler's setup transforms are this evaluator too** (issue #1015).
9
+ * `src/transform.ts` held a second evaluator of its own until then, and the
10
+ * two differed in exactly three places, none of them a different matrix: the
11
+ * degree factor (`Math.PI / 180` there, the runtime's 3.1415927 here), the
12
+ * frame of a bone with scale 1 and no shear (`[cos, −sin, sin, cos]` of the
13
+ * rotation there, the y column at `rotation + 90` here), and how a radian
14
+ * angle is turned into degrees (divided by the degree factor there,
15
+ * multiplied by `180 / pi` here). Every byte rigc emits was computed with the
16
+ * compiler's three, so they are a parameter of this evaluator
17
+ * (`WorldArithmetic`) rather than a rewrite of the emitter's numbers: the
18
+ * compiler passes its own and the posing core passes none, which is
19
+ * `RUNTIME_ARITHMETIC`, the measured choices below. Measured with the
20
+ * compiler's degree factor and frame taken from here instead: 2 of 19
21
+ * recipes moved, by at most 4e-5 (issue #1015's report) — whether the
22
+ * compiler should bind with the runtime's constant is its own question.
9
23
  *
10
24
  * ## What each measured choice is
11
25
  *
@@ -99,6 +113,28 @@ export const RUNTIME_DEG = 180 / RUNTIME_PI;
99
113
  /** A parent x axis whose squared length is at most this is collapsed for `noRotationOrReflection` (issue #979): `1e-5` squared, as measured — see the header. */
100
114
  export const COLLAPSED_X_AXIS_SQ = 0.00001 * 0.00001;
101
115
 
116
+ /**
117
+ * The three places an evaluator's arithmetic may differ in the last bit while
118
+ * computing the same matrix (issue #1015) — see the header. The core poses with
119
+ * `RUNTIME_ARITHMETIC`; `src/transform.ts` passes the compiler's, so that what
120
+ * rigc has always emitted is what it still emits.
121
+ */
122
+ export interface WorldArithmetic {
123
+ /** Degrees to radians: every angle is multiplied by it. */
124
+ radiansPerDegree: number;
125
+ /** Radians to degrees — the angle `noRotationOrReflection` takes off a parent's axis. */
126
+ degreesOf: (radians: number) => number;
127
+ /** A bone with scale 1 and no shear takes `[cos r, −sin r, sin r, cos r]` instead of the y column at `r + 90`; the general frame everywhere else. */
128
+ rotationOnlyFrame: boolean;
129
+ }
130
+
131
+ /** The runtime's arithmetic, as measured (the header's three choices): what the core poses with. */
132
+ export const RUNTIME_ARITHMETIC: WorldArithmetic = {
133
+ radiansPerDegree: RAD,
134
+ degreesOf: (radians) => radians * RUNTIME_DEG,
135
+ rotationOnlyFrame: false,
136
+ };
137
+
102
138
  /** One bone's world transform: the matrix `[a b; c d]` and the origin, y up. */
103
139
  export interface CoreWorld {
104
140
  a: number;
@@ -114,16 +150,27 @@ export type CoreInheritMode = 'normal' | 'onlyTranslation' | 'noRotationOrReflec
114
150
 
115
151
  export type M2 = [number, number, number, number];
116
152
 
117
- /** What computes a child's world matrix from its mode, its parent's world and its own fields. */
118
- export type InheritComputation = (mode: CoreInheritMode, parent: CoreWorld, bone: ModelBone) => M2;
153
+ /** What computes a child's world matrix from its mode, its parent's world, its own fields and the arithmetic in force. */
154
+ export type InheritComputation = (mode: CoreInheritMode, parent: CoreWorld, bone: ModelBone, arithmetic: WorldArithmetic) => M2;
119
155
 
120
156
  /** The matrix of a rotation, shears and scales, in the column form the header states. */
121
- function frame(rotation: number, shearX: number, shearY: number, scaleX: number, scaleY: number): M2 {
122
- const xAngle = (rotation + shearX) * RAD;
123
- const yAngle = (rotation + 90 + shearY) * RAD;
157
+ function frame(rotation: number, shearX: number, shearY: number, scaleX: number, scaleY: number, rad: number = RAD): M2 {
158
+ const xAngle = (rotation + shearX) * rad;
159
+ const yAngle = (rotation + 90 + shearY) * rad;
124
160
  return [Math.cos(xAngle) * scaleX, Math.cos(yAngle) * scaleY, Math.sin(xAngle) * scaleX, Math.sin(yAngle) * scaleY];
125
161
  }
126
162
 
163
+ /** A bone's own frame — a root's matrix, and the local factor of `normal` and `onlyTranslation` — under `arithmetic`. */
164
+ function localFrame(arithmetic: WorldArithmetic, rotation: number, shearX: number, shearY: number, scaleX: number, scaleY: number): M2 {
165
+ const rad = arithmetic.radiansPerDegree;
166
+ if (arithmetic.rotationOnlyFrame && scaleX === 1 && scaleY === 1 && shearX === 0 && shearY === 0) {
167
+ const cos = Math.cos(rotation * rad);
168
+ const sin = Math.sin(rotation * rad);
169
+ return [cos, -sin, sin, cos];
170
+ }
171
+ return frame(rotation, shearX, shearY, scaleX, scaleY, rad);
172
+ }
173
+
127
174
  function times(p: M2, q: M2): M2 {
128
175
  return [p[0] * q[0] + p[1] * q[2], p[0] * q[1] + p[1] * q[3], p[2] * q[0] + p[3] * q[2], p[2] * q[1] + p[3] * q[3]];
129
176
  }
@@ -135,8 +182,8 @@ function times(p: M2, q: M2): M2 {
135
182
  * which builds the frame to read the bone's columns in from the rotation it
136
183
  * has just read, through this same arithmetic (issue #966).
137
184
  */
138
- export function noScaleDirection(p: M2, rotation: number): [number, number] {
139
- const r = rotation * RAD;
185
+ export function noScaleDirection(p: M2, rotation: number, rad: number = RAD): [number, number] {
186
+ const r = rotation * rad;
140
187
  const cos = Math.cos(r);
141
188
  const sin = Math.sin(r);
142
189
  const ux = p[0] * cos + p[1] * sin;
@@ -146,19 +193,20 @@ export function noScaleDirection(p: M2, rotation: number): [number, number] {
146
193
  return [ux * inverse, uy * inverse];
147
194
  }
148
195
 
149
- /** The world matrix of a child under `parent` in `mode`. */
150
- function modeMatrix(mode: CoreInheritMode, parent: CoreWorld, bone: ModelBone): M2 {
196
+ /** The world matrix of a child under `parent` in `mode`, under `arithmetic` (the runtime's unless a caller passes its own). */
197
+ function modeMatrix(mode: CoreInheritMode, parent: CoreWorld, bone: ModelBone, arithmetic: WorldArithmetic = RUNTIME_ARITHMETIC): M2 {
151
198
  const rotation = bone.rotation ?? 0;
152
199
  const shearX = bone.shearX ?? 0;
153
200
  const shearY = bone.shearY ?? 0;
154
201
  const scaleX = bone.scaleX ?? 1;
155
202
  const scaleY = bone.scaleY ?? 1;
203
+ const rad = arithmetic.radiansPerDegree;
156
204
  const p: M2 = [parent.a, parent.b, parent.c, parent.d];
157
205
  switch (mode) {
158
206
  case 'normal':
159
- return times(p, frame(rotation, shearX, shearY, scaleX, scaleY));
207
+ return times(p, localFrame(arithmetic, rotation, shearX, shearY, scaleX, scaleY));
160
208
  case 'onlyTranslation':
161
- return frame(rotation, shearX, shearY, scaleX, scaleY);
209
+ return localFrame(arithmetic, rotation, shearX, shearY, scaleX, scaleY);
162
210
  case 'noRotationOrReflection': {
163
211
  const xLengthSq = p[0] * p[0] + p[2] * p[2];
164
212
  let conformal: M2;
@@ -166,23 +214,23 @@ function modeMatrix(mode: CoreInheritMode, parent: CoreWorld, bone: ModelBone):
166
214
  if (xLengthSq > COLLAPSED_X_AXIS_SQ) {
167
215
  const k = Math.abs(p[0] * p[3] - p[1] * p[2]) / xLengthSq;
168
216
  conformal = [p[0], -p[2] * k, p[2], p[0] * k];
169
- r = rotation - Math.atan2(p[2], p[0]) * RUNTIME_DEG;
217
+ r = rotation - arithmetic.degreesOf(Math.atan2(p[2], p[0]));
170
218
  } else {
171
219
  // A collapsed x axis (issue #979): the parent's y column with its x component negated, and the rotation less 90 plus the y column's angle — see the header.
172
220
  conformal = [0, -p[1], 0, p[3]];
173
- r = rotation - 90 + Math.atan2(p[3], p[1]) * RUNTIME_DEG;
221
+ r = rotation - 90 + arithmetic.degreesOf(Math.atan2(p[3], p[1]));
174
222
  }
175
223
  // The y angle adds the shear before the right angle here, unlike `frame` (issue #966): `(r + 90 + shearY)` reads last-bit off on 81 of 205 sheared or scaled bones at a 1e9 amplifier, `(r + shearY + 90)` on none; `r = rotation − parentAngle` taken first (adding the shear before the parent's angle is taken off reads 102 of 205 off).
176
- const xAngle = (r + shearX) * RAD;
177
- const yAngle = (r + shearY + 90) * RAD;
224
+ const xAngle = (r + shearX) * rad;
225
+ const yAngle = (r + shearY + 90) * rad;
178
226
  return times(conformal, [Math.cos(xAngle) * scaleX, Math.cos(yAngle) * scaleY, Math.sin(xAngle) * scaleX, Math.sin(yAngle) * scaleY]);
179
227
  }
180
228
  case 'noScale':
181
229
  case 'noScaleOrReflection': {
182
- const [ux, uy] = noScaleDirection(p, rotation);
230
+ const [ux, uy] = noScaleDirection(p, rotation, rad);
183
231
  const flip = mode === 'noScale' && p[0] * p[3] - p[1] * p[2] < 0 ? -1 : 1;
184
232
  const turned: M2 = [ux, -uy * flip, uy, ux * flip];
185
- return times(turned, frame(0, shearX, shearY, scaleX, scaleY));
233
+ return times(turned, frame(0, shearX, shearY, scaleX, scaleY, rad));
186
234
  }
187
235
  }
188
236
  }
@@ -205,9 +253,16 @@ function modeOf(bone: ModelBone): CoreInheritMode {
205
253
  * world as a skeleton is created — and a bone under it, active or not, is
206
254
  * posed from those zeros like any child. `inherit` replaces the mode
207
255
  * computation — the core suite's plant passes a copy with one mode's sign
208
- * flipped, and nothing else does.
256
+ * flipped, and nothing else does. `arithmetic` is the runtime's unless a
257
+ * caller states its own: `src/transform.ts` passes the compiler's (issue
258
+ * #1015), and no posing path does.
209
259
  */
210
- export function worldTransforms(bones: readonly ModelBone[], active: ReadonlySet<string> | null = null, inherit: InheritComputation = modeMatrix): Map<string, CoreWorld> {
260
+ export function worldTransforms(
261
+ bones: readonly ModelBone[],
262
+ active: ReadonlySet<string> | null = null,
263
+ inherit: InheritComputation = modeMatrix,
264
+ arithmetic: WorldArithmetic = RUNTIME_ARITHMETIC,
265
+ ): Map<string, CoreWorld> {
211
266
  const out = new Map<string, CoreWorld>();
212
267
  for (const bone of bones) {
213
268
  if (active !== null && !active.has(bone.name)) {
@@ -217,13 +272,13 @@ export function worldTransforms(bones: readonly ModelBone[], active: ReadonlySet
217
272
  const x = bone.x ?? 0;
218
273
  const y = bone.y ?? 0;
219
274
  if (bone.parent === undefined) {
220
- const [a, b, c, d] = frame(bone.rotation ?? 0, bone.shearX ?? 0, bone.shearY ?? 0, bone.scaleX ?? 1, bone.scaleY ?? 1);
275
+ const [a, b, c, d] = localFrame(arithmetic, bone.rotation ?? 0, bone.shearX ?? 0, bone.shearY ?? 0, bone.scaleX ?? 1, bone.scaleY ?? 1);
221
276
  out.set(bone.name, { a, b, c, d, worldX: x, worldY: y });
222
277
  continue;
223
278
  }
224
279
  const parent = out.get(bone.parent);
225
280
  if (parent === undefined) throw new Error(`bone "${bone.name}": parent "${bone.parent}" is not posed before it`);
226
- const [a, b, c, d] = inherit(modeOf(bone), parent, bone);
281
+ const [a, b, c, d] = inherit(modeOf(bone), parent, bone, arithmetic);
227
282
  out.set(bone.name, { a, b, c, d, worldX: parent.a * x + parent.b * y + parent.worldX, worldY: parent.c * x + parent.d * y + parent.worldY });
228
283
  }
229
284
  return out;
@@ -1610,9 +1610,12 @@ interface PoseOfFrame {
1610
1610
  /**
1611
1611
  * What `Slider.update` stores in `SliderPose.time` for a wanted animation time.
1612
1612
  *
1613
- * The two clamps at the end of its bone branch, transcribed, because the
1614
- * verification below compares against what the runtime STORED and not against
1615
- * what was asked for. ⚠️ `loop` on a zero-length animation gives NaN here exactly
1613
+ * On a slider driven by a bone: the time wrapped into the animation under
1614
+ * `loop`, else held at 0 or above. Not assumed — `poseDial`'s step 3 reads
1615
+ * `SliderPose.time` off the skeleton each time it poses a dial and compares
1616
+ * it with this, and a disagreement is reported, never absorbed. It is stated at all because that
1617
+ * verification compares against what the runtime STORED and not against what
1618
+ * was asked for. ⚠️ `loop` on a zero-length animation gives NaN here exactly
1616
1619
  * as it does there, which is `A37_SLIDER_CONSTRAINT_EFFECTIVE`'s refusal.
1617
1620
  */
1618
1621
  function sliderTimeFor(slider: SliderData, time: number): number {
package/src/emit.ts CHANGED
@@ -91,7 +91,24 @@ export interface CopyImagesResult {
91
91
  */
92
92
  export function copyAtlasPages(atlasText: string, outDir: string): CopyImagesResult {
93
93
  mkdirSync(outDir, { recursive: true });
94
+ const planned = plannedPageCopies(atlasText, outDir);
95
+ for (const page of planned.pages) {
96
+ const source = resolve(outDir, page.from);
97
+ const destination = join(outDir, page.to);
98
+ if (source !== resolve(destination)) copyFileSync(source, destination);
99
+ }
100
+ return planned;
101
+ }
94
102
 
103
+ /**
104
+ * What `copyAtlasPages` will do, without doing it: the rewritten atlas text
105
+ * and each page's source and copy name, from the text and `outDir` alone — no
106
+ * file is read or written (issue #1016). `build --copy-images` spells the
107
+ * model document from this text BEFORE the gate, because the document states
108
+ * where each region sits on its page (`pages`) and the copy renames every
109
+ * page; `copyAtlasPages` returns exactly this, after copying.
110
+ */
111
+ export function plannedPageCopies(atlasText: string, outDir: string): CopyImagesResult {
95
112
  const parsed = parseAtlasText(atlasText);
96
113
  const claimedBy = new Map<string, string>(); // destination filename -> absolute source holding it
97
114
  const pages: CopiedPage[] = [];
@@ -113,11 +130,5 @@ export function copyAtlasPages(atlasText: string, outDir: string): CopyImagesRes
113
130
  pages.push({ from: page.name, to: name, regions: page.regions.length });
114
131
  }
115
132
 
116
- for (const page of pages) {
117
- const source = resolve(outDir, page.from);
118
- const destination = join(outDir, page.to);
119
- if (source !== resolve(destination)) copyFileSync(source, destination);
120
- }
121
-
122
133
  return { atlasText: rewritePageNames(parsed, (_name, index) => pages[index].to), pages };
123
134
  }
package/src/ingest.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  SPINE_VERSION,
53
53
  } from './compile.ts';
54
54
  import { CompileError } from './errors.ts';
55
+ import { physicsDrives } from './core/constraints_physics.ts';
55
56
  import { CHANNELS_BY_KIND } from './timelines.ts';
56
57
  import {
57
58
  LEGACY_BONE_INHERIT_KEY,
@@ -303,13 +304,14 @@ const CONSTRAINT_FIELDS: Record<string, string[]> = {
303
304
  * The physics constraints that drive nothing, by name, each with the component
304
305
  * fields it DOES state — every one of them at most 0 (issue #731).
305
306
  *
306
- * 🔑 The runtime's own predicate, not a reading of it: `PhysicsConstraint.update`
307
- * decides what it applies with `this.data.x > 0` and its four siblings
308
- * (`PhysicsConstraint.js:112`) and nothing else, so a constraint with none of
309
- * `PHYSICS_COMPONENTS` above 0 moves no bone. `build` refuses exactly that shape
310
- * by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the gate — so carrying one
311
- * through made the decompiled spec of a file an editor exports unbuildable as a
312
- * whole, over a constraint that did nothing in it.
307
+ * 🔑 The question is the core's `physicsDrives` (`src/core/constraints_physics.ts`,
308
+ * issue #1015): a component above 0 drives its part of the step and nothing
309
+ * else does, so a constraint with none of `PHYSICS_COMPONENTS` above 0 moves no
310
+ * bone — `PhysicsConstraint.update` applies a part only on that test
311
+ * (`PhysicsConstraint.js:112`). An unstated component is the parser's 0. `build`
312
+ * refuses exactly that shape by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the
313
+ * gate — so carrying one through made the decompiled spec of a file an editor
314
+ * exports unbuildable as a whole, over a constraint that did nothing in it.
313
315
  *
314
316
  * ⚠️ A component that is present and NOT a number is not inert: the parser takes
315
317
  * it as written and `"0.5" > 0` is true in the runtime's comparison, so such a
@@ -321,7 +323,10 @@ function inertPhysics(root: JsonObject): Map<string, string[]> {
321
323
  const constraint = obj(raw);
322
324
  if (constraint.type !== 'physics' || typeof constraint.name !== 'string') continue;
323
325
  const stated = PHYSICS_COMPONENTS.filter((field) => constraint[field] !== undefined);
324
- if (!stated.every((field) => typeof constraint[field] === 'number' && !((constraint[field] as number) > 0))) continue;
326
+ if (!stated.every((field) => typeof constraint[field] === 'number')) continue;
327
+ const value = (field: (typeof PHYSICS_COMPONENTS)[number]): number => (constraint[field] === undefined ? 0 : (constraint[field] as number));
328
+ const drives = physicsDrives({ x: value('x'), y: value('y'), rotate: value('rotate'), shearX: value('shearX'), scaleX: value('scaleX') });
329
+ if (drives.x || drives.y || drives.rotateOrShearX || drives.scaleX) continue;
325
330
  out.set(
326
331
  constraint.name,
327
332
  stated.map((field) => `${field} ${String(constraint[field])}`),
@@ -1305,9 +1310,9 @@ function ingestAttachment(
1305
1310
  const type = att.type === undefined ? 'region' : String(att.type);
1306
1311
  const out: JsonObject = {};
1307
1312
 
1308
- // A LINKED mesh, in either of the format's two spellings. The test is the
1309
- // parser's own — one branch for `mesh` and `linkedmesh`, and a truthy `source`
1310
- // decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
1313
+ // A LINKED mesh, in either of the format's two spellings. The format decides
1314
+ // it — one branch of the reader for `mesh` and `linkedmesh`, and a truthy
1315
+ // `source` decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
1311
1316
  // `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
1312
1317
  // is read as an ordinary mesh, whose `uvs` it does not have, and the parser
1313
1318
  // throws on it. Reading the second as a link would be this module inventing a
@@ -1327,8 +1332,8 @@ function ingestAttachment(
1327
1332
  // `buildRigRegion` writes `path` when the image basename differs from the
1328
1333
  // name the attachment carries — its stated `name`, else the placeholder —
1329
1334
  // so naming the image after the region this attachment RESOLVES
1330
- // (`path ?? name ?? placeholder`, the parser's own defaults at
1331
- // `SkeletonJson.js:526`, `:529`, `:560`) reproduces the same `path`
1335
+ // (`path ?? name ?? placeholder`, the defaults the format's reader applies
1336
+ // at `SkeletonJson.js:526`, `:529`, `:560`) reproduces the same `path`
1332
1337
  // decision AND the same atlas region name.
1333
1338
  if (opts.art === 'loose') {
1334
1339
  const region = att.path ?? (typeof att.name === 'string' ? att.name : placeholder);
package/src/keyorder.ts CHANGED
@@ -288,8 +288,8 @@ export function inEditorKeyOrder<T extends object>(skeleton: T, table: KeyOrderT
288
288
  // ## Why this is a table and not a parse
289
289
  //
290
290
  // The parser is the only authority on what a default is, and this table is
291
- // not a second one: it is a transcription the selftest holds to the parser row
292
- // by row (`S103` on the in-tree builds, `IG82` on the corpus) by loading an
291
+ // not a second one: it is a table of the parser's readings that the selftest
292
+ // holds to the parser row by row (`S103` on the in-tree builds, `IG82` on the corpus) by loading an
293
293
  // object of each kind with the key at the row's value and without it, and
294
294
  // comparing everything `SkeletonData` holds — and then one float32 step off,
295
295
  // which must differ. It cannot BE a parse: `compile.ts` must not link the
package/src/model.ts CHANGED
@@ -82,10 +82,12 @@
82
82
  * they are the atlas emitter's constants, and the model does not carry them.
83
83
  *
84
84
  * 📄 **It is written as a document** (issue #922, cut 1f): `modelDocument` below
85
- * spells it as `rigc-compiled/1`, and `build` writes that text into `--out` as
86
- * `skeleton.model.json` beside the Spine files, after the gate, like them.
85
+ * spells it as `rigc-compiled/2` (`/1` until issue #1016 added `pages`), and
86
+ * `build` writes that text into `--out` as `skeleton.model.json` beside the
87
+ * Spine files, after the gate, like them.
87
88
  */
88
89
  import { createHash } from 'node:crypto';
90
+ import { parseAtlasText } from './atlas.ts';
89
91
  import { CompileError } from './errors.ts';
90
92
  import type { BoneTransform } from './transform.ts';
91
93
  import type { RigSkinConstraintKey } from './rig.ts';
@@ -211,10 +213,12 @@ export interface ModelSequence {
211
213
  * (#931: the atlas's `rotate` transposed into the corners was exact on 2018 of
212
214
  * 3000 probes, ignored on 3000 of 3000). The trim and the original size do not
213
215
  * move under either: rigc's packer never trims or rotates (`src/atlas.ts`).
214
- * The draw, which does need the four — a region's page and page UVs — reads
216
+ * The draw, which does need the four — a region's page and page UVs — read
215
217
  * them off the atlas written beside this document, as a second input
216
- * (`src/core/uvs.ts`, issue #967): measured at tolerance 0 against the
217
- * runtime's own arrays on every corpus row with the model unchanged.
218
+ * (`src/core/uvs.ts`, issue #967), until issue #1016 gave the document a
219
+ * `pages` section spelled from the atlas text `build` writes (`pagesOfAtlas`
220
+ * below): the four are still not this record's, because they are the written
221
+ * arrangement's, and the section moves with it.
218
222
  */
219
223
  export interface ModelAtlasRect {
220
224
  width: number;
@@ -613,11 +617,17 @@ export interface CompiledModel extends CarriedFromCompileResult {
613
617
  }
614
618
 
615
619
  // ---------------------------------------------------------------------------
616
- // the document: `rigc-compiled/1` (issue #922, cut 1f)
620
+ // the document: `rigc-compiled/2` (issue #922, cut 1f; `pages` and `/2`, issue #1016)
617
621
  // ---------------------------------------------------------------------------
618
622
 
619
- /** The document's `spec` value. */
620
- export const MODEL_DOCUMENT_SPEC = 'rigc-compiled/1';
623
+ /**
624
+ * The document's `spec` value. `/2` since issue #1016 added the `pages`
625
+ * section: a `/1` reader (`readModel` of rigc 1.6) refuses a section it does
626
+ * not know by name, so the same spec over a new section would be refused by
627
+ * every reader already installed rather than read wrongly — a new spec says so
628
+ * before the first section is opened. `readModel` reads both.
629
+ */
630
+ export const MODEL_DOCUMENT_SPEC = 'rigc-compiled/2';
621
631
 
622
632
  /** The file `build` writes the document to, in `--out` beside `skeleton.json` and `skeleton.atlas`. */
623
633
  export const MODEL_DOCUMENT_FILE = 'skeleton.model.json';
@@ -809,7 +819,7 @@ const MODEL_DOCUMENT_FIELDS: readonly string[] = [
809
819
  const MODEL_DOCUMENT_LEFT_OUT: readonly string[] = ['setupWorld'];
810
820
 
811
821
  /**
812
- * The compiled model as a document: `rigc-compiled/1`, `JSON.stringify(doc,
822
+ * The compiled model as a document: `rigc-compiled/2`, `JSON.stringify(doc,
813
823
  * null, 2)` and a newline — the text `build` writes to `skeleton.model.json`,
814
824
  * and the record rigc's own posing core reads (`readModel` in
815
825
  * `src/core/index.ts`, issue #380, step 2). Its cost in a build is measured in
@@ -820,7 +830,9 @@ const MODEL_DOCUMENT_LEFT_OUT: readonly string[] = ['setupWorld'];
820
830
  * `constraints`, `events`, `animations` — then the fields carried from `CompileResult` in
821
831
  * `CarriedFromCompileResult`'s order: `images`, `pageGrids`, `droppedStates`,
822
832
  * `absentParts`, `meshBones`, `meshes`, `physics`, `deformTransforms`,
823
- * `trackDerivations`, `rig`; and last `spine`, the digest of the
833
+ * `trackDerivations`, `rig`; then `pages`, where each region sits on its page
834
+ * in the atlas written beside it (`pagesOfAtlas`, issue #1016), which is why
835
+ * the atlas text is the third argument; and last `spine`, the digest of the
824
836
  * `skeleton.json` written beside it (`spineFileSha256`, issue #968), which is
825
837
  * why the Spine text is the second argument. Inside a model record, its interface's field
826
838
  * order (`ModelBone`, `ModelSlot`, `ModelSkin`, each attachment kind,
@@ -894,7 +906,7 @@ const MODEL_DOCUMENT_LEFT_OUT: readonly string[] = ['setupWorld'];
894
906
  * What the Spine emitter adds (`emitSkeleton`'s header, its spellings, its
895
907
  * orders and omissions) is not in the model and so not here.
896
908
  */
897
- export function modelDocument(model: CompiledModel, skeletonText: string): string {
909
+ export function modelDocument(model: CompiledModel, skeletonText: string, atlasText: string): string {
898
910
  for (const key of Object.keys(model)) {
899
911
  if (!MODEL_DOCUMENT_FIELDS.includes(key) && !MODEL_DOCUMENT_LEFT_OUT.includes(key)) {
900
912
  throw new CompileError(`internal: the model document has no place for the model's field "${key}"; it writes [${MODEL_DOCUMENT_FIELDS.join(', ')}] and leaves out [${MODEL_DOCUMENT_LEFT_OUT.join(', ')}]`);
@@ -921,11 +933,97 @@ export function modelDocument(model: CompiledModel, skeletonText: string): strin
921
933
  deformTransforms: plain(model.deformTransforms, 'deformTransforms'),
922
934
  trackDerivations: plain(model.trackDerivations, 'trackDerivations'),
923
935
  rig: plain(model.rig, 'rig'),
936
+ pages: plain(pagesOfAtlas(atlasText), 'pages'),
924
937
  spine: { sha256: spineFileSha256(skeletonText) },
925
938
  };
926
939
  return `${JSON.stringify(doc, null, 2)}\n`;
927
940
  }
928
941
 
942
+ // --- #1016 where each region sits on its page: begin ---
943
+ /**
944
+ * One region of a page, as the document's `pages` section states it: the
945
+ * region's name exactly as the atlas line spells it (untrimmed — the core finds
946
+ * a region by that spelling, `./core/uvs.ts`), its rectangle's top-left `x`,
947
+ * `y` on the page (y down) and `width`, `height` in the drawing's orientation,
948
+ * the trim (`offsetX` from the drawing's left, `offsetY` from its bottom) and
949
+ * the untrimmed size, its turn in `degrees`, and its `index:` field — every
950
+ * number in the page's texels, exactly as `parseAtlasText` reads it.
951
+ */
952
+ export interface ModelPageRegion {
953
+ name: string;
954
+ x: number;
955
+ y: number;
956
+ width: number;
957
+ height: number;
958
+ offsetX: number;
959
+ offsetY: number;
960
+ originalWidth: number;
961
+ originalHeight: number;
962
+ degrees: number;
963
+ index: number;
964
+ }
965
+
966
+ /** One page: its name (a path from the directory `build` writes into, trimmed), its `size`, and its regions in file order. */
967
+ export interface ModelPage {
968
+ name: string;
969
+ width: number;
970
+ height: number;
971
+ regions: ModelPageRegion[];
972
+ }
973
+
974
+ /** The fields of a page and of a region, in the order the document writes them — `readModel` mirrors both lists. */
975
+ export const MODEL_PAGE_FIELDS = ['name', 'width', 'height', 'regions'] as const;
976
+ export const MODEL_PAGE_REGION_FIELDS = ['name', 'x', 'y', 'width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight', 'degrees', 'index'] as const;
977
+
978
+ /**
979
+ * The document's `pages` section (issue #1016): every page of `atlasText` and
980
+ * every region on it, in file order, with the numbers the draw reads — where
981
+ * each drawing sits, which `./core/uvs.ts` turns into page UVs. `atlasText`
982
+ * is the atlas `build` writes beside the document, the one that run's gate
983
+ * last read: after `--pack` the packed text, after `--copy-images` the text
984
+ * with the copies' page names.
985
+ *
986
+ * ⭐ **Why it is a function of the written atlas rather than a model field.**
987
+ * Issue #939 left the page, `x`, `y` and `rotate` out of `ModelAtlasRect`
988
+ * because `--pack` moves them after `compile` returns and `--copy-images`
989
+ * renames every page — a value the model held from `compile` would state a
990
+ * place the written atlas does not have. The section is spelled from the
991
+ * atlas text instead, so it moves exactly when that text does, and `build`
992
+ * spells the document from the text it writes (`cli.ts`): the document a pack
993
+ * writes is spelled from the packed text and gated by the packed pass, where
994
+ * `A18` compares it with a second compile's document spelled from a second,
995
+ * independent pack.
996
+ *
997
+ * 🔸 **What it leaves out.** The page's `format`, `filter`, `repeat`, `pma`
998
+ * and `scale:` lines: nothing that draws reads them (the rasteriser samples
999
+ * one way, `src/render.ts`'s header; the pose reads only the ratios the
1000
+ * trimmed and original sizes make, #939's decision 2). Every region of every
1001
+ * page is written, drawn or not: which regions a rig draws is the core's
1002
+ * lookup rule (`./core/uvs.ts`, *Which region, on which page*), and a writer
1003
+ * choosing a subset would restate it.
1004
+ */
1005
+ export function pagesOfAtlas(atlasText: string): ModelPage[] {
1006
+ return parseAtlasText(atlasText).pages.map((page) => ({
1007
+ name: page.name,
1008
+ width: page.width,
1009
+ height: page.height,
1010
+ regions: page.regions.map((r) => ({
1011
+ name: r.name,
1012
+ x: r.x,
1013
+ y: r.y,
1014
+ width: r.width,
1015
+ height: r.height,
1016
+ offsetX: r.offsetX,
1017
+ offsetY: r.offsetY,
1018
+ originalWidth: r.originalWidth,
1019
+ originalHeight: r.originalHeight,
1020
+ degrees: r.degrees,
1021
+ index: r.index,
1022
+ })),
1023
+ }));
1024
+ }
1025
+ // --- #1016 where each region sits on its page: end ---
1026
+
929
1027
  // --- #968 the Spine file the document was written beside: begin ---
930
1028
  /**
931
1029
  * The document's last section, `spine`: `{ "sha256": "<64 lowercase hex>" }`,