spine-rigc 1.6.1 → 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/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * rigc's own core: it reads the compiled model as `build` writes it
3
- * (`rigc-compiled/1`, `skeleton.model.json`) and poses it (issue #925, step 2a
3
+ * (`rigc-compiled/2`, and `/1` before issue #1016; `skeleton.model.json`)
4
+ * and poses it (issue #925, step 2a
4
5
  * of issue #380). It is the second dumper `tools/pose_oracle.ts` was shaped
5
6
  * for: what it poses is written into the same `pose-oracle/2` document the
6
7
  * runtime's dump is, and `compare` holds the two to each other.
@@ -168,7 +169,7 @@
168
169
  * child process, no file system — `readModel` takes the document's TEXT — and
169
170
  * nothing from the Spine runtime package, as a value or as a type.
170
171
  */
171
- import type { ModelAtlasRect, ModelBone, ModelSlot, ModelVertices, SkinTableEntry } from '../model.ts';
172
+ import type { ModelAtlasRect, ModelBone, ModelPage, ModelPageRegion, ModelSlot, ModelVertices, SkinTableEntry } from '../model.ts';
172
173
  import { worldTransforms, type CoreWorld } from './world.ts';
173
174
  import { readAnimationTimelines, type CoreAnimationTimelines } from './animation.ts';
174
175
  import { readEventDefs, type CoreEventDef } from './events.ts';
@@ -183,8 +184,19 @@ import { attachmentStates, type DeformBlender, type DeformEvaluator, type Sequen
183
184
  import { drawOrderAt, type DrawOrderEvaluator } from './draw_order.ts';
184
185
  import type { EventsFired } from './events.ts';
185
186
 
186
- /** The document spec this reader takes. */
187
- export const CORE_DOCUMENT_SPEC = 'rigc-compiled/1';
187
+ /**
188
+ * The document spec `build` writes today, and the one this reader takes with
189
+ * the `pages` section (issue #1016).
190
+ */
191
+ export const CORE_DOCUMENT_SPEC = 'rigc-compiled/2';
192
+
193
+ /**
194
+ * The spec before issue #1016: the same sections without `pages`. Read as
195
+ * before — the draw then takes where each region sits from the atlas beside
196
+ * the document (`corePoser` in `src/render_core.ts` says so by name), because
197
+ * a `/1` document does not state it.
198
+ */
199
+ export const CORE_DOCUMENT_SPEC_1 = 'rigc-compiled/1';
188
200
 
189
201
  /** Who posed a dump the core wrote — the oracle document's `dumper`. */
190
202
  export const CORE_DUMPER = 'rigc-core';
@@ -196,10 +208,19 @@ export class CoreInputError extends Error {}
196
208
  export const CORE_SECTIONS = [
197
209
  'referenceScale', 'bones', 'slots', 'skins', 'constraints', 'events', 'animations',
198
210
  'images', 'pageGrids', 'droppedStates', 'absentParts', 'meshBones', 'meshes', 'physics', 'deformTransforms', 'trackDerivations', 'rig',
211
+ // Issue #1016: where each region sits on its page, in the atlas written beside the document (`pagesOfAtlas` in `src/model.ts`). `/2` only.
212
+ 'pages',
199
213
  // Issue #968: the digest of the `skeleton.json` written beside the document (`spineFileSha256` in `src/model.ts`).
200
214
  'spine',
201
215
  ] as const;
202
216
 
217
+ /** The sections of a `rigc-compiled/1` document: `CORE_SECTIONS` without `pages`. */
218
+ export const CORE_SECTIONS_1: readonly string[] = CORE_SECTIONS.filter((key) => key !== 'pages');
219
+
220
+ /** The fields of a page and of a region in the `pages` section, as the writer lists them (`MODEL_PAGE_FIELDS`, `MODEL_PAGE_REGION_FIELDS` in `src/model.ts`, mirrored). */
221
+ export const CORE_PAGE_FIELDS = ['name', 'width', 'height', 'regions'] as const;
222
+ export const CORE_PAGE_REGION_FIELDS = ['name', 'x', 'y', 'width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight', 'degrees', 'index'] as const;
223
+
203
224
  /** The fields a bone record may carry, as the writer lists them. A field outside this list is refused. */
204
225
  export const CORE_BONE_FIELDS = ['name', 'parent', 'length', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'inheritMode', 'skinRequired', 'editor'] as const;
205
226
  const BONE_NUMBERS = ['length', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY'] as const;
@@ -297,7 +318,7 @@ export interface CoreAnimation {
297
318
  }
298
319
 
299
320
  /**
300
- * A `rigc-compiled/1` document, read. `bones` and `slots` are checked field by
321
+ * A `rigc-compiled/2` (or `/1`) document, read. `bones` and `slots` are checked field by
301
322
  * field against the writer's own records; skins and constraints are read as
302
323
  * far as their names and memberships, and every other section is only
303
324
  * required to be present — no construct this card admits reads it.
@@ -319,6 +340,66 @@ export interface CompiledDocument {
319
340
  animations: CoreAnimation[];
320
341
  /** The digest of the `skeleton.json` `build` wrote beside the document (issue #968) — what a render holds the file beside it to before posing it here. */
321
342
  spine: { sha256: string };
343
+ /**
344
+ * Every page of the atlas `build` wrote beside the document and every region
345
+ * on it, in file order (issue #1016) — where each drawing sits, which the
346
+ * draw's page UVs read. `null` for a `rigc-compiled/1` document, which does
347
+ * not state it.
348
+ */
349
+ pages: ModelPage[] | null;
350
+ }
351
+
352
+ /**
353
+ * The `pages` section, checked: a list of pages, each holding exactly
354
+ * `CORE_PAGE_FIELDS` — a non-empty name, a size of two positive finite
355
+ * numbers, a list of regions — and each region exactly
356
+ * `CORE_PAGE_REGION_FIELDS`, a non-empty name and nine finite numbers. Every
357
+ * fault is named by its path. Nothing is defaulted: a field the writer always
358
+ * writes is required here.
359
+ */
360
+ function readPages(value: unknown, problems: string[]): ModelPage[] {
361
+ if (!Array.isArray(value)) {
362
+ problems.push(`pages is ${JSON.stringify(value) ?? 'absent'}, not a list of pages`);
363
+ return [];
364
+ }
365
+ const out: ModelPage[] = [];
366
+ value.forEach((raw, i) => {
367
+ const where = `pages[${i}]`;
368
+ if (!isRecord(raw)) {
369
+ problems.push(`${where} is not an object`);
370
+ return;
371
+ }
372
+ const before = problems.length;
373
+ const label = typeof raw.name === 'string' ? `${where} "${raw.name}"` : where;
374
+ unknownFields(raw, CORE_PAGE_FIELDS, label, problems);
375
+ if (typeof raw.name !== 'string' || raw.name === '') problems.push(`${where}: name is ${JSON.stringify(raw.name) ?? 'absent'}, not a non-empty string`);
376
+ for (const key of ['width', 'height'] as const) {
377
+ const v = raw[key];
378
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) problems.push(`${label}: ${key} is ${JSON.stringify(v) ?? 'absent'}, not a positive finite number — a page UV divides by it`);
379
+ }
380
+ const regions: ModelPageRegion[] = [];
381
+ if (!Array.isArray(raw.regions)) problems.push(`${label}: regions is not a list`);
382
+ else {
383
+ raw.regions.forEach((region, j) => {
384
+ const at = `${label}.regions[${j}]`;
385
+ if (!isRecord(region)) {
386
+ problems.push(`${at} is not an object`);
387
+ return;
388
+ }
389
+ const named = typeof region.name === 'string' ? `${at} "${region.name}"` : at;
390
+ unknownFields(region, CORE_PAGE_REGION_FIELDS, named, problems);
391
+ if (typeof region.name !== 'string' || region.name === '') problems.push(`${at}: name is ${JSON.stringify(region.name) ?? 'absent'}, not a non-empty string`);
392
+ for (const key of CORE_PAGE_REGION_FIELDS) {
393
+ if (key === 'name') continue;
394
+ const v = region[key];
395
+ if (typeof v !== 'number' || !Number.isFinite(v)) problems.push(`${named}: ${key} is ${JSON.stringify(v) ?? 'absent'}, not a finite number`);
396
+ }
397
+ regions.push(region as unknown as ModelPageRegion);
398
+ });
399
+ }
400
+ if (problems.length === before) out.push({ name: raw.name as string, width: raw.width as number, height: raw.height as number, regions });
401
+ });
402
+ return out;
322
403
  }
323
404
 
324
405
  /** The `spine` section's value, checked: `{ "sha256": <64 lowercase hex> }` and nothing else, each fault named by path (issue #968). */
@@ -615,7 +696,8 @@ function readConstraints(value: unknown, animations: readonly CoreAnimation[], b
615
696
  }
616
697
 
617
698
  /**
618
- * Read a `rigc-compiled/1` document from its text, refusing by name a text
699
+ * Read a `rigc-compiled/2` document — or a `rigc-compiled/1` one, which has
700
+ * no `pages` and is read with `pages: null` — from its text, refusing by name a text
619
701
  * that is not JSON, a wrong `spec`, a missing section (`referenceScale`
620
702
  * among them, issue #958), a section the document does not have, a
621
703
  * `referenceScale` that is not a finite number, and — in the records these constructs read (bones, slots,
@@ -633,12 +715,15 @@ export function readModel(text: string, where = 'the model document'): CompiledD
633
715
  throw new CoreInputError(`${where}: not JSON — ${(err as Error).message}`);
634
716
  }
635
717
  if (!isRecord(value)) throw new CoreInputError(`${where}: not a JSON object`);
636
- if (value.spec !== CORE_DOCUMENT_SPEC) throw new CoreInputError(`${where}: spec is ${JSON.stringify(value.spec)}, not "${CORE_DOCUMENT_SPEC}"`);
718
+ if (value.spec !== CORE_DOCUMENT_SPEC && value.spec !== CORE_DOCUMENT_SPEC_1) throw new CoreInputError(`${where}: spec is ${JSON.stringify(value.spec)}, not "${CORE_DOCUMENT_SPEC}" (or "${CORE_DOCUMENT_SPEC_1}", read without its pages)`);
719
+ const spec = value.spec;
720
+ const sections: readonly string[] = spec === CORE_DOCUMENT_SPEC ? CORE_SECTIONS : CORE_SECTIONS_1;
637
721
  const problems: string[] = [];
638
- for (const key of CORE_SECTIONS) if (!(key in value)) problems.push(`section "${key}" is missing`);
722
+ for (const key of sections) if (!(key in value)) problems.push(`section "${key}" is missing`);
639
723
  for (const key of Object.keys(value)) {
640
- if (key !== 'spec' && !(CORE_SECTIONS as readonly string[]).includes(key)) problems.push(`section "${key}" is not one a ${CORE_DOCUMENT_SPEC} document has`);
724
+ if (key !== 'spec' && !sections.includes(key)) problems.push(`section "${key}" is not one a ${spec} document has`);
641
725
  }
726
+ const pages = spec === CORE_DOCUMENT_SPEC && 'pages' in value ? readPages(value.pages, problems) : null;
642
727
  // The skeleton's reference scale (issue #958): wind and gravity act over it, so a missing one is the section refusal above, and a value the runtime could not read as a number is refused here by name.
643
728
  const referenceScale = typeof value.referenceScale === 'number' && Number.isFinite(value.referenceScale) ? value.referenceScale : NaN;
644
729
  if ('referenceScale' in value && Number.isNaN(referenceScale)) problems.push(`referenceScale is ${JSON.stringify(value.referenceScale)}, not a finite number — wind and gravity act over it`);
@@ -666,7 +751,7 @@ export function readModel(text: string, where = 'the model document'): CompiledD
666
751
  });
667
752
  }
668
753
  if (problems.length > 0) throw new CoreInputError(`${where}: ${problems.length} problem(s): ${problems.join('; ')}`);
669
- const doc: CompiledDocument = { spec: CORE_DOCUMENT_SPEC, skin: CORE_ALL_SKINS, referenceScale, bones, slots, skins, constraints, animations, spine: { sha256: spine } };
754
+ const doc: CompiledDocument = { spec, skin: CORE_ALL_SKINS, referenceScale, bones, slots, skins, constraints, animations, spine: { sha256: spine }, pages };
670
755
  resolveSkinView(doc);
671
756
  return doc;
672
757
  }
package/src/core/uvs.ts CHANGED
@@ -5,12 +5,15 @@
5
5
  * and `sequence.getUVs(index)` — and `MeshAttachment.computeUVs`'s job, the
6
6
  * mapping texture substitution calls, as pure functions.
7
7
  *
8
- * The model document does not carry the page layout (`ModelAtlasRect`'s 🔸 in
8
+ * The page layout is not a model record (`ModelAtlasRect`'s 🔸 in
9
9
  * `src/model.ts`: the page, `x`, `y` and `rotate` are the packer's
10
- * arrangement, not the drawing), and this construct does not change that: the
11
- * atlas is a second input, read by rigc's own reader (`parseAtlasText` and
12
- * `atlasRegionLookup` in `src/atlas.ts`) and handed in as a lookup
13
- * (`UvLookup`), so nothing here links the runtime or opens a file.
10
+ * arrangement, not the drawing). It reaches these rules as a lookup
11
+ * (`UvLookup`), so nothing here links the runtime or opens a file: since
12
+ * issue #1016 from the document's own `pages` section, which `build` spells
13
+ * from the atlas it writes (`documentPageLookup` below); for a
14
+ * `rigc-compiled/1` document, which has none, from the atlas beside it, read
15
+ * by rigc's own reader (`parseAtlasText` and `atlasRegionLookup` in
16
+ * `src/atlas.ts`).
14
17
  *
15
18
  * ## How every rule below was fixed
16
19
  *
@@ -143,6 +146,7 @@ import { applyConstraints } from './constraints.ts';
143
146
  import type { SliderApplication } from './constraints_slider.ts';
144
147
  import { attachmentStates } from './deform.ts';
145
148
  import type { ShownGeometry } from './vertices.ts';
149
+ import type { ModelPage, ModelPageRegion } from '../model.ts';
146
150
  import { worldTransforms } from './world.ts';
147
151
 
148
152
  /** A region as these rules read it — `AtlasRegion` in `src/atlas.ts` is one, and so is spine-core's `TextureAtlasRegion`. */
@@ -168,6 +172,22 @@ export interface UvPage {
168
172
  /** The region an atlas draws under a name, and its page — the first of that name, as the header states; `null` when there is none. */
169
173
  export type UvLookup = (name: string) => { page: UvPage; region: UvRegion } | null;
170
174
 
175
+ /**
176
+ * The lookup over a document's `pages` section (issue #1016): the first region
177
+ * of a name in file order, pages in order and regions in each page's order —
178
+ * the order `pagesOfAtlas` in `src/model.ts` copies from the atlas, so this is
179
+ * `atlasRegionLookup` over the same text, with no atlas read. The name is
180
+ * compared as stored, untrimmed, as the header's *Which region* states.
181
+ */
182
+ export function documentPageLookup(pages: readonly ModelPage[]): (name: string) => { page: UvPage; region: ModelPageRegion } | null {
183
+ const first = new Map<string, { page: UvPage; region: ModelPageRegion }>();
184
+ for (const page of pages) {
185
+ const at: UvPage = { name: page.name, width: page.width, height: page.height };
186
+ for (const region of page.regions) if (!first.has(region.name)) first.set(region.name, { page: at, region });
187
+ }
188
+ return (name) => first.get(name) ?? null;
189
+ }
190
+
171
191
  /**
172
192
  * The readings the header's tables rejected, each a switch — what the core
173
193
  * suite's `CU` plants pass to show the gate names it. The functions below with
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