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.
@@ -2,8 +2,10 @@
2
2
  * The render's second poser (issue #968, step 3d of issue #380): the posing
3
3
  * seam of `src/render.ts` (`Poser` / `Posed`) implemented over rigc's own
4
4
  * core — the compiled model document (`skeleton.model.json`) posed by
5
- * `src/core/`, and the atlas the render samples read by rigc's own reader
6
- * (`src/atlas.ts`). Nothing here links spine-core, and nothing here is reached
5
+ * `src/core/`, each drawn region placed on its page by the document's own
6
+ * `pages` section (issue #1016; a `rigc-compiled/1` document, which has none,
7
+ * by the atlas beside it, read by rigc's own reader `src/atlas.ts`). Nothing
8
+ * here links spine-core, and nothing here is reached
7
9
  * from `src/core/`: the core stays pure, and this module is the adapter from
8
10
  * its raw entry to the renderer's shapes.
9
11
  *
@@ -32,7 +34,11 @@
32
34
  * region a drawn attachment samples (`drawnRegions` over the pose's `shown`
33
35
  * records and draw order), a region's four UVs (`regionPageUvs`) and a
34
36
  * mesh's (`meshPageUvs`), each held to `sequence.getUVs(index)` at
35
- * tolerance 0 on every corpus by `core_gate`'s `uvs` blocks.
37
+ * tolerance 0 on every corpus by `core_gate`'s `uvs` blocks. Where each
38
+ * region sits — its page, `x`, `y`, turn and the page's size — is the
39
+ * document's `pages` section (issue #1016, `placementOf`), the atlas `build`
40
+ * wrote spelled into the document, so a build draws with its `.atlas`
41
+ * removed.
36
42
  * - **The tint** is the slot's light colour times the attachment's colour,
37
43
  * channel by channel, as `src/render.ts`'s `tintOf` forms it; the dark
38
44
  * colour is the slot's, absent where the slot carries none.
@@ -80,15 +86,16 @@ import type {
80
86
  PoseOptions,
81
87
  Posed,
82
88
  Poser,
89
+ SkinRoster,
83
90
  SlotSubset,
84
91
  } from './render.ts';
85
- import { atlasRegionLookup, parseAtlasText, type AtlasRegion } from './atlas.ts';
86
- import { spineFileSha256 } from './model.ts';
92
+ import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
93
+ import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
87
94
  import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
88
- import { CoreInputError, readModel, sourceOfDoc, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
95
+ import { activeBones, CoreInputError, readModel, sourceOfDoc, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
89
96
  import { poseRawAnimation, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
90
97
  import { CORE_ALL_SKINS, CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
91
- import { drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvSource } from './core/uvs.ts';
98
+ import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
92
99
  import { regionCorners, worldVertices } from './core/vertices.ts';
93
100
  import type { CoreWorld } from './core/world.ts';
94
101
 
@@ -256,12 +263,15 @@ export function clipSourceOf(
256
263
  /** The runtime's own triangulation of a region's quad — `src/render.ts`'s `QUAD_TRIANGLES`. */
257
264
  const QUAD_TRIANGLES: readonly number[] = [0, 1, 2, 2, 3, 0];
258
265
 
259
- /** Everything a core pose reads besides the pose: the document under one skin, and the atlas. */
266
+ /** A region as texture substitution reads it: the page-UV rules' numbers, and the name and `index:` its key is made of (`regionKey`). */
267
+ type TextureRegion = UvRegion & { name: string; index: number };
268
+
269
+ /** Everything a core pose reads besides the pose: the document under one skin, and where each region sits on its page. */
260
270
  interface CoreInput {
261
271
  doc: CompiledDocument;
262
272
  source: UvSource;
263
- /** The atlas region a name draws, as rigc's reader holds it — what an original-art UV reads its trim from. */
264
- region: (name: string) => AtlasRegion | null;
273
+ /** The region a name draws — the document's `pages`, or for a `/1` document the atlas's — what an original-art UV reads its trim from. */
274
+ region: (name: string) => TextureRegion | null;
265
275
  }
266
276
 
267
277
  /**
@@ -300,12 +310,12 @@ function worldOf(pose: RawPose): Map<string, CoreWorld> {
300
310
  }
301
311
 
302
312
  /** `regionKey` in `src/render.ts`: the trimmed name and the sequence index — the key a substitution matches on. */
303
- function regionKey(region: AtlasRegion): string {
313
+ function regionKey(region: TextureRegion): string {
304
314
  return `${region.name.trim()}#${region.index}`;
305
315
  }
306
316
 
307
317
  /** `artUvsOf` in `src/render.ts`, over rigc's atlas reader: a mesh's own UVs, a region's kept rectangle in the drawing's space. */
308
- function artUvsOf(drawn: RawDrawn, region: AtlasRegion): PieceTexture | undefined {
318
+ function artUvsOf(drawn: RawDrawn, region: TextureRegion): PieceTexture | undefined {
309
319
  if (drawn.kind === 'mesh') return { region: regionKey(region), artUvs: [...drawn.uvs] };
310
320
  if (region.degrees !== 0) return undefined;
311
321
  const ow = region.originalWidth;
@@ -515,9 +525,70 @@ function restOf(view: CompiledDocument, setup: RawPose, shown: readonly Attachme
515
525
  }
516
526
 
517
527
  /**
518
- * `Poser` over rigc's own core: `modelText` a `rigc-compiled/1` document
519
- * (`skeleton.model.json`), `atlasText` the atlas the render samples — the one
520
- * `build` wrote beside it. With `skeleton`, the Spine file beside the document
528
+ * The first place two `pages` sections differ, by path, or `null` when they
529
+ * are the same — the page count, a page's name or size, a region count, or a
530
+ * region's name or one of its numbers.
531
+ */
532
+ export function firstPageDifference(stated: readonly ModelPage[], found: readonly ModelPage[]): string | null {
533
+ if (stated.length !== found.length) return `the document states ${stated.length} page(s), the atlas has ${found.length}`;
534
+ for (let i = 0; i < stated.length; i++) {
535
+ const a = stated[i];
536
+ const b = found[i];
537
+ for (const key of ['name', 'width', 'height'] as const) {
538
+ if (a[key] !== b[key]) return `pages[${i}].${key} is ${JSON.stringify(a[key])} in the document and ${JSON.stringify(b[key])} in the atlas`;
539
+ }
540
+ if (a.regions.length !== b.regions.length) return `pages[${i}] "${a.name}" holds ${a.regions.length} region(s) in the document and ${b.regions.length} in the atlas`;
541
+ for (let j = 0; j < a.regions.length; j++) {
542
+ const r = a.regions[j];
543
+ const q = b.regions[j];
544
+ for (const key of Object.keys(r) as Array<keyof typeof r>) {
545
+ if (r[key] !== q[key]) return `pages[${i}] "${a.name}" region ${JSON.stringify(r.name)}: ${key} is ${JSON.stringify(r[key])} in the document and ${JSON.stringify(q[key])} in the atlas`;
546
+ }
547
+ }
548
+ }
549
+ return null;
550
+ }
551
+
552
+ /**
553
+ * Where each region a core pose draws sits on its page, read from the
554
+ * document's `pages` section (issue #1016) — or, for a `rigc-compiled/1`
555
+ * document, which has none, from `atlasText` as before. With both, the atlas
556
+ * must be the one the document was written beside: a page or region that
557
+ * differs is refused naming the first difference, so the core never draws the
558
+ * build's placement over another atlas's pages while spine-core, reading that
559
+ * atlas, would draw another picture.
560
+ */
561
+ function placementOf(doc: CompiledDocument, atlasText: string, where: string): { lookup: UvSource['lookup']; region: CoreInput['region'] } {
562
+ if (doc.pages !== null) {
563
+ if (atlasText !== '') {
564
+ const differs = firstPageDifference(doc.pages, pagesOfAtlas(atlasText));
565
+ if (differs !== null) {
566
+ throw new CoreInputError(
567
+ `the atlas beside ${where} is not the one it was written beside: ${differs} — ` +
568
+ 'the core would draw the build\'s placement over pages the atlas has rearranged',
569
+ );
570
+ }
571
+ }
572
+ const lookup = documentPageLookup(doc.pages);
573
+ return { lookup, region: (name) => lookup(name)?.region ?? null };
574
+ }
575
+ if (atlasText === '') {
576
+ throw new CoreInputError(
577
+ `${where} is a ${doc.spec} document, which does not state where each region sits on its page (the pages section, issue #1016), ` +
578
+ 'and no atlas was given to read it from — rebuild it to carry them, or pose it beside the atlas it was built with',
579
+ );
580
+ }
581
+ const lookup = atlasRegionLookup(parseAtlasText(atlasText));
582
+ return { lookup, region: (name) => lookup(name)?.region ?? null };
583
+ }
584
+
585
+ /**
586
+ * `Poser` over rigc's own core: `modelText` a `rigc-compiled/2` document
587
+ * (`skeleton.model.json`), which states where each region sits on its page
588
+ * (`pages`, issue #1016), so `atlasText` may be `''`; given, it is held to
589
+ * the document's `pages` (`placementOf`). A `rigc-compiled/1` document states
590
+ * no placement and is drawn through `atlasText`, the atlas `build` wrote
591
+ * beside it, as before; with none given it is refused by name. With `skeleton`, the Spine file beside the document
521
592
  * is held to the digest the document records (`spine.sha256`, issue #968) and
522
593
  * refused, naming both digests, when it is not that build's. Refused by
523
594
  * `CoreInputError`, naming why, where the document or the atlas cannot be read; a pose the core leaves a block of out
@@ -542,8 +613,7 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
542
613
  throw new CoreInputError(`${where}: not JSON — ${(err as Error).message}`);
543
614
  }
544
615
  const sequences = readUvSequences(parsed);
545
- const atlas = parseAtlasText(atlasText);
546
- const lookup = atlasRegionLookup(atlas);
616
+ const { lookup, region } = placementOf(doc, atlasText, where);
547
617
  const views = new Map<string, CompiledDocument>();
548
618
  const inputOf = (skin: string | undefined): CoreInput => {
549
619
  const key = skin === undefined ? '' : `=${skin}`;
@@ -552,7 +622,7 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
552
622
  view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
553
623
  views.set(key, view);
554
624
  }
555
- return { doc: view, source: { lookup, sequences }, region: (name) => lookup(name)?.region ?? null };
625
+ return { doc: view, source: { lookup, sequences }, region };
556
626
  };
557
627
  const roster: SubsetRoster = {
558
628
  declared: doc.slots.map((s) => s.name),
@@ -580,3 +650,30 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
580
650
  },
581
651
  };
582
652
  }
653
+
654
+ /**
655
+ * The skin roster behind the core poser (`SkinRoster` in `src/render.ts`,
656
+ * issue #1014): the bones each skin leaves unposed, read off the model
657
+ * document — `unposedBones` over `activeBones` of the skin's view, the
658
+ * predicate the raw pose flags its bones with, and under no skin the view the
659
+ * core poses with no skin set (`noSkinView`, refused by `CoreInputError` where
660
+ * it refuses to pose). `skins` is the Spine file's own list, in its order, which
661
+ * the document does not hold (`resolveSkinView`'s note in `./core/index.ts`).
662
+ *
663
+ * Measured against the runtime's reading (`skinRosterOf`, a fresh skeleton's
664
+ * `active` under each skin and under none) on every rigc build the tree
665
+ * carries: the same bones, under every skin. The document is read on the first
666
+ * question, so a render that frames nothing under a skin never parses it twice.
667
+ */
668
+ export function coreSkinRoster(modelText: string, where: string, skins: readonly string[]): SkinRoster {
669
+ let doc: CompiledDocument | null = null;
670
+ return {
671
+ skins,
672
+ unposedUnder: (skin) => {
673
+ doc ??= readModel(modelText, where);
674
+ const view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
675
+ const active = activeBones(view);
676
+ return unposedBones(view.bones.map((b) => ({ name: b.name, parent: b.parent ?? null, active: active.has(b.name) })));
677
+ },
678
+ };
679
+ }
package/src/transform.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * measured polygon. That is why the old code refused rotation instead of ignoring
12
12
  * it, and why this file exists rather than a relaxed assertion.
13
13
  *
14
- * The composition mirrors spine-core exactly (`Bone.updateWorldTransform`):
14
+ * The composition, for a bone with a parent in the `normal` mode:
15
15
  *
16
16
  * la = cos(rot) lb = cos(rot + 90) = -sin(rot)
17
17
  * lc = sin(rot) ld = sin(rot + 90) = cos(rot)
@@ -28,9 +28,19 @@
28
28
  * at scale 2 measured **0.752×** the runtime's own `PathConstraint.curves` on the
29
29
  * build's posed geometry, and a production rig's weighted path 2.35×. The
30
30
  * weights were blended all along; the matrices they were blended through were
31
- * wrong. `computeWorldTransforms` below is `BonePose.updateWorldTransform`
32
- * (spine-core 4.3.13, `BonePose.js:110-216`) at setup, with a skeleton scale of 1.
31
+ * wrong.
32
+ *
33
+ * ⭐ **`computeWorldTransforms` is the core's evaluator** (`worldTransforms` in
34
+ * [`src/core/world.ts`](core/world.ts), issue #1015), whose every rule — the five
35
+ * inherit modes, the collapsed parent axis, the operation orders — was measured
36
+ * against the runtime's pose dump and is held there by the core suite. This file
37
+ * hands it the compiler's arithmetic (`COMPILER_ARITHMETIC`, below) and keeps
38
+ * what is the compiler's own: the coordinate contract, the refusals by name, the
39
+ * world rotation a region cancels, and the inverses.
33
40
  */
41
+ import type { ModelBone } from './model.ts';
42
+ import { modeMatrix, worldTransforms, type CoreWorld, type WorldArithmetic } from './core/world.ts';
43
+
34
44
  /**
35
45
  * What `computeWorldTransforms` reads off a bone — a structural type, so both a
36
46
  * Spine bone (`SpineBone`, what spine-parts and every skeleton reader hold) and
@@ -70,6 +80,24 @@ export class TransformError extends Error {}
70
80
 
71
81
  const DEG = Math.PI / 180;
72
82
 
83
+ /**
84
+ * The arithmetic every figure rigc emits was computed with, handed to the core's
85
+ * evaluator (issue #1015). It differs from the runtime's in the last bit and in
86
+ * no matrix: degrees to radians with `Math.PI` rather than the runtime's
87
+ * 3.1415927, radians back to degrees by dividing by that factor, and a bone with
88
+ * the default scale and no shear framed as `lb = -sin`, `ld = cos` rather than
89
+ * `cos`/`sin` of `rot + 90°`. One bit can move a float32 spelling in the file, so
90
+ * this is a parameter and not a rewrite: measured with the runtime's arithmetic
91
+ * instead, 2 of the 19 public recipes moved by at most 4e-5. Whether the compiler
92
+ * should bind with the runtime's constant is a question of its own, not settled
93
+ * here.
94
+ */
95
+ const COMPILER_ARITHMETIC: WorldArithmetic = {
96
+ radiansPerDegree: DEG,
97
+ degreesOf: (radians) => radians / DEG,
98
+ rotationOnlyFrame: true,
99
+ };
100
+
73
101
  /**
74
102
  * Crop pixels (y down, origin top-left) -> Spine world (y up, origin at the
75
103
  * bottom-left of the crop).
@@ -92,69 +120,46 @@ export function cropToSpineY(cropY: number, cropHeight: number): number {
92
120
  * requires (it resolves `parent` by name against the bones already read), so a
93
121
  * violation here is a violation there.
94
122
  *
95
- * 🔒 **A bone with the default scale and no shear takes the exact arithmetic
96
- * this function always used** — `lb = -sin`, `ld = cos` rather than the
97
- * runtime's `cos(rot + 90°)`/`sin(rot + 90°)`, which can differ in the last bit.
98
- * Every figure rigc emitted off an unscaled rig was computed that way, and one
99
- * bit there can move a float32 spelling in the file, so the general local matrix
100
- * is taken only where the old one was wrong: nothing an unscaled, normally
101
- * inheriting rig emits moves by a byte. The five `inherit` cases are the
102
- * runtime's, transcribed.
123
+ * The matrices and origins are the core's (`worldTransforms`, under
124
+ * `COMPILER_ARITHMETIC`). What is decided here is what the core does not say:
125
+ * an undeclared parent and an `inherit` no mode answers to are refused by name
126
+ * as `TransformError`, a bone whose `parent` is empty is a root as it always was
127
+ * here, and `worldRotation` is read off the matrix — a root with the default
128
+ * scale and no shear keeps its stated rotation, unwrapped.
103
129
  */
104
130
  export function computeWorldTransforms(bones: readonly PosableBone[]): Map<string, BoneTransform> {
105
- const out = new Map<string, BoneTransform>();
131
+ const model: ModelBone[] = [];
132
+ const declared = new Set<string>();
106
133
  for (const bone of bones) {
107
- const rotation = bone.rotation ?? 0;
108
- const scaleX = bone.scaleX ?? 1;
109
- const scaleY = bone.scaleY ?? 1;
110
- const shearX = bone.shearX ?? 0;
111
- const shearY = bone.shearY ?? 0;
112
- const plain = scaleX === 1 && scaleY === 1 && shearX === 0 && shearY === 0;
113
- let la: number;
114
- let lb: number;
115
- let lc: number;
116
- let ld: number;
117
- if (plain) {
118
- const cos = Math.cos(rotation * DEG);
119
- const sin = Math.sin(rotation * DEG);
120
- la = cos;
121
- lb = -sin;
122
- lc = sin;
123
- ld = cos;
124
- } else {
125
- const rx = (rotation + shearX) * DEG;
126
- const ry = (rotation + 90 + shearY) * DEG;
127
- la = Math.cos(rx) * scaleX;
128
- lb = Math.cos(ry) * scaleY;
129
- lc = Math.sin(rx) * scaleX;
130
- ld = Math.sin(ry) * scaleY;
131
- }
132
- const x = bone.x ?? 0;
133
- const y = bone.y ?? 0;
134
- if (!bone.parent) {
135
- const worldRotation = plain ? rotation : (Math.atan2(lc, la) / DEG + 360) % 360;
136
- out.set(bone.name, { a: la, b: lb, c: lc, d: ld, worldX: x, worldY: y, worldRotation });
137
- continue;
134
+ const parent = bone.parent ? bone.parent : undefined;
135
+ if (parent !== undefined && !declared.has(parent)) {
136
+ throw new TransformError(`bone "${bone.name}" names parent "${parent}", which is not declared before it`);
138
137
  }
139
- const p = out.get(bone.parent);
140
- if (!p) throw new TransformError(`bone "${bone.name}" names parent "${bone.parent}", which is not declared before it`);
141
- const [a, b, c, d] = inheritedMatrix(inheritMode(bone), p, [la, lb, lc, ld], rotation, scaleX, scaleY, shearX, shearY);
142
- out.set(bone.name, {
143
- a,
144
- b,
145
- c,
146
- d,
147
- worldX: p.a * x + p.b * y + p.worldX,
148
- worldY: p.c * x + p.d * y + p.worldY,
149
- worldRotation: (Math.atan2(c, a) / DEG + 360) % 360,
138
+ model.push({
139
+ name: bone.name,
140
+ parent,
141
+ x: bone.x,
142
+ y: bone.y,
143
+ rotation: bone.rotation,
144
+ scaleX: bone.scaleX,
145
+ scaleY: bone.scaleY,
146
+ shearX: bone.shearX,
147
+ shearY: bone.shearY,
148
+ inheritMode: parent === undefined ? undefined : inheritMode(bone),
150
149
  });
150
+ declared.add(bone.name);
151
+ }
152
+ const worlds = worldTransforms(model, null, modeMatrix, COMPILER_ARITHMETIC);
153
+ const out = new Map<string, BoneTransform>();
154
+ for (const bone of model) {
155
+ const w = worlds.get(bone.name) as CoreWorld;
156
+ const plain = (bone.scaleX ?? 1) === 1 && (bone.scaleY ?? 1) === 1 && (bone.shearX ?? 0) === 0 && (bone.shearY ?? 0) === 0;
157
+ const worldRotation = bone.parent === undefined && plain ? (bone.rotation ?? 0) : (Math.atan2(w.c, w.a) / DEG + 360) % 360;
158
+ out.set(bone.name, { a: w.a, b: w.b, c: w.c, d: w.d, worldX: w.worldX, worldY: w.worldY, worldRotation });
151
159
  }
152
160
  return out;
153
161
  }
154
162
 
155
- /** `MathUtils.epsilon2` — the threshold `noRotationOrReflection` tests a degenerate parent against. */
156
- const EPSILON2 = 0.00001 * 0.00001;
157
-
158
163
  type InheritMode = 'normal' | 'onlyTranslation' | 'noRotationOrReflection' | 'noScale' | 'noScaleOrReflection';
159
164
 
160
165
  const INHERIT_MODES: readonly InheritMode[] = ['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection'];
@@ -177,75 +182,6 @@ function inheritMode(bone: PosableBone): InheritMode {
177
182
  return mode;
178
183
  }
179
184
 
180
- /**
181
- * `BonePose.updateWorldTransform`'s switch (`BonePose.js:136-215`) for a bone
182
- * with a parent, at a skeleton scale of 1 — so every `sx`/`sy` factor there is 1
183
- * and is left out rather than multiplied in.
184
- */
185
- function inheritedMatrix(
186
- inherit: InheritMode,
187
- p: BoneTransform,
188
- local: [number, number, number, number],
189
- rotation: number,
190
- scaleX: number,
191
- scaleY: number,
192
- shearX: number,
193
- shearY: number,
194
- ): [number, number, number, number] {
195
- const [la, lb, lc, ld] = local;
196
- if (inherit === 'normal') {
197
- return [p.a * la + p.b * lc, p.a * lb + p.b * ld, p.c * la + p.d * lc, p.c * lb + p.d * ld];
198
- }
199
- if (inherit === 'onlyTranslation') return [la, lb, lc, ld];
200
- if (inherit === 'noRotationOrReflection') {
201
- let pa = p.a;
202
- let pb = p.b;
203
- let pc = p.c;
204
- let pd = p.d;
205
- let s = pa * pa + pc * pc;
206
- let r: number;
207
- if (s > EPSILON2) {
208
- s = Math.abs(pa * pd - pb * pc) / s;
209
- pb = pc * s;
210
- pd = pa * s;
211
- r = rotation - Math.atan2(pc, pa) / DEG;
212
- } else {
213
- pa = 0;
214
- pc = 0;
215
- r = rotation - 90 + Math.atan2(pd, pb) / DEG;
216
- }
217
- const rx = (r + shearX) * DEG;
218
- const ry = (r + shearY + 90) * DEG;
219
- const ra = Math.cos(rx) * scaleX;
220
- const rb = Math.cos(ry) * scaleY;
221
- const rc = Math.sin(rx) * scaleX;
222
- const rd = Math.sin(ry) * scaleY;
223
- return [pa * ra - pb * rc, pa * rb - pb * rd, pc * ra + pd * rc, pc * rb + pd * rd];
224
- }
225
- // noScale / noScaleOrReflection
226
- const r = rotation * DEG;
227
- const cos = Math.cos(r);
228
- const sin = Math.sin(r);
229
- let za = p.a * cos + p.b * sin;
230
- let zc = p.c * cos + p.d * sin;
231
- const s = 1 / Math.sqrt(za * za + zc * zc);
232
- za *= s;
233
- zc *= s;
234
- let zb = -zc;
235
- let zd = za;
236
- if (inherit === 'noScale' && p.a * p.d - p.b * p.c < 0) {
237
- zb = -zb;
238
- zd = -zd;
239
- }
240
- const rx = shearX * DEG;
241
- const ry = (90 + shearY) * DEG;
242
- const ra = Math.cos(rx) * scaleX;
243
- const rb = Math.cos(ry) * scaleY;
244
- const rc = Math.sin(rx) * scaleX;
245
- const rd = Math.sin(ry) * scaleY;
246
- return [za * ra + zb * rc, za * rb + zb * rd, zc * ra + zd * rc, zc * rb + zd * rd];
247
- }
248
-
249
185
  /**
250
186
  * World point -> the bone's local space. Used for bind coordinates and offsets.
251
187
  *
package/src/types.ts CHANGED
@@ -1098,11 +1098,12 @@ export interface SpineClippingAttachment {
1098
1098
  * is re-parameterised by the trip rather than corrupted by it.
1099
1099
  *
1100
1100
  * ⇒ **rigc emits that computation, not a sampler aimed at it** (issue #560).
1101
- * `pathCurveLengths` in [`compile.ts`](compile.ts) is `PathConstraint.js:301-320`
1102
- * transcribed, down to `Math.sqrt(dx * dx + dy * dy)` rather than `Math.hypot`
1103
- * and `0.16666667` rather than `1 / 6`; `PS67`, `PS68` and `PS187` in `selftest.ts` hold it
1104
- * there by requiring it to reproduce a real `PathConstraint.curves` array **bit
1105
- * for bit** on the runtime's own posed chain. Measured after the change, all
1101
+ * `pathCurveLengths` in [`compile.ts`](compile.ts) measures with the core's
1102
+ * curve table (`curveLengthTable` in `src/core/constraints_path.ts`, issue
1103
+ * #1015), down to `Math.sqrt(dx * dx + dy * dy)` rather than `Math.hypot` and
1104
+ * `0.16666667` rather than `1 / 6`; `PS67`, `PS68` and `PS186` in `selftest.ts`
1105
+ * hold it there by requiring it to reproduce a real `PathConstraint.curves`
1106
+ * array **bit for bit** on the runtime's own posed chain. Measured after the change, all
1106
1107
  * seven entries of both editor exports above come back at the precision the
1107
1108
  * editor prints them.
1108
1109
  *
package/src/validate.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  // rectangle on a shared page.
66
66
  import { readPlate } from '../tools/plate.ts';
67
67
  import { pageFootprint, pageGridSentence } from './atlas.ts';
68
+ import { frameRegionName } from './core/uvs.ts';
68
69
  import {
69
70
  BEZIER_POINTS,
70
71
  curveStorage,
@@ -613,6 +614,9 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
613
614
  return arm === undefined ? physicsOutsideSays(rule, poseValue) : physicsBasisSays(arm);
614
615
  }
615
616
 
617
+ /** `physicsTimelineNames`' table, once it has been built. */
618
+ let physicsTimelineNamesTable: Record<number, string> | null = null;
619
+
616
620
  /**
617
621
  * The skeleton-JSON name of each physics timeline, keyed by the runtime's own
618
622
  * `Property` id.
@@ -624,16 +628,25 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
624
628
  * on the right are the ones `SkeletonJson`'s physics branch reads
625
629
  * (`SkeletonJson.js:1063-1094`), which is also what a motion spec's `property`
626
630
  * says.
631
+ *
632
+ * ⚠️ Built on first use, not while the module loads (issue #1014): the
633
+ * computed keys read the runtime's `Property` enum, and seven reads of it at
634
+ * load time were the only spine-core access a module made before a command
635
+ * ran — so `--help`, `render` and `check` on a rigc build all touched the
636
+ * runtime for a table only the gate's physics rules consult.
627
637
  */
628
- export const PHYSICS_TIMELINE_NAMES: Record<number, string> = {
629
- [Property.physicsConstraintInertia]: 'inertia',
630
- [Property.physicsConstraintStrength]: 'strength',
631
- [Property.physicsConstraintDamping]: 'damping',
632
- [Property.physicsConstraintMass]: 'mass',
633
- [Property.physicsConstraintWind]: 'wind',
634
- [Property.physicsConstraintGravity]: 'gravity',
635
- [Property.physicsConstraintMix]: 'mix',
636
- };
638
+ export function physicsTimelineNames(): Record<number, string> {
639
+ physicsTimelineNamesTable ??= {
640
+ [Property.physicsConstraintInertia]: 'inertia',
641
+ [Property.physicsConstraintStrength]: 'strength',
642
+ [Property.physicsConstraintDamping]: 'damping',
643
+ [Property.physicsConstraintMass]: 'mass',
644
+ [Property.physicsConstraintWind]: 'wind',
645
+ [Property.physicsConstraintGravity]: 'gravity',
646
+ [Property.physicsConstraintMix]: 'mix',
647
+ };
648
+ return physicsTimelineNamesTable;
649
+ }
637
650
 
638
651
  /**
639
652
  * Which physics constraints a physics timeline that names NO constraint writes
@@ -733,20 +746,23 @@ function atStoredKey(time: number): number {
733
746
  * The atlas region names one raw skin entry will make the loader look up — or
734
747
  * `null` when the file states a sequence this walk cannot predict.
735
748
  *
736
- * ⚠️ Transcribed from the loader rather than reasoned out, because a join that
749
+ * ⚠️ Measured against the loader rather than reasoned out, because a join that
737
750
  * merely looks right is the thing A08 exists to refuse. `readSequence`
738
751
  * (`dist/SkeletonJson.js:641-649`) turns an absent or null `sequence` into
739
752
  * `new Sequence(1, false)` — one lookup, at the bare path — and a present one
740
753
  * into `new Sequence(count ?? 0, true)`, so a sequence map with no `count`
741
- * looks up nothing at all. `Sequence.getPath` (`dist/Sequence.js:124-132`)
742
- * appends `start + i`, left-padded with zeros to `digits`.
754
+ * looks up nothing at all. Each frame's name is the core's `frameRegionName`
755
+ * (`src/core/uvs.ts`): `start + i`, left-padded with zeros to `digits`, after
756
+ * the path (issue #1015). `PS127` records what the loader asks for and compares,
757
+ * and the core suite holds the frame names to `Sequence.getPath` over 7,680
758
+ * cases.
743
759
  *
744
760
  * Nothing in `examples/` carries a `sequence` (measured: 0 occurrences across
745
761
  * all twelve editor exports), so without this the assertion would have read a
746
762
  * sequence's base path as a region name and refused correct foreign data —
747
763
  * `A21_MESH_RIM_PINNED`'s old `|| 'ring'` default, one file over.
748
764
  */
749
- function attachmentRegionLookups(sequence: unknown, path: string): string[] | null {
765
+ export function attachmentRegionLookups(sequence: unknown, path: string): string[] | null {
750
766
  if (sequence === undefined || sequence === null) return [path];
751
767
  if (!isObj(sequence)) return null;
752
768
  const whole = (value: unknown, fallback: number): number | null => {
@@ -757,11 +773,9 @@ function attachmentRegionLookups(sequence: unknown, path: string): string[] | nu
757
773
  const start = whole(sequence.start, 1);
758
774
  const digits = whole(sequence.digits, 0);
759
775
  if (count === null || start === null || digits === null || count < 0) return null;
776
+ const series = { count, start, digits, setup: 0 };
760
777
  const lookups: string[] = [];
761
- for (let i = 0; i < count; i++) {
762
- const frame = String(start + i);
763
- lookups.push(`${path}${'0'.repeat(Math.max(0, digits - frame.length))}${frame}`);
764
- }
778
+ for (let i = 0; i < count; i++) lookups.push(frameRegionName(path, series, i));
765
779
  return lookups;
766
780
  }
767
781
 
@@ -870,11 +884,11 @@ interface RawLinkedMesh {
870
884
  * `"<skin>\0<slot>\0<placeholder>" -> source` for every linked mesh the raw
871
885
  * skeleton declares, with what the file says about each one (issues #691, #710).
872
886
  *
873
- * 🔑 The test is the parser's own and it is not `type`: `type: "mesh"` and
874
- * `type: "linkedmesh"` share one branch and a truthy `source` is what decides
875
- * between them (`SkeletonJson.js:568-569`, `:582`). An empty `source` is falsy
876
- * there, so it is not a link here either — that map is read as an ordinary mesh,
877
- * which is exactly what the runtime does with it.
887
+ * 🔑 The format decides it, and not by `type`: `type: "mesh"` and
888
+ * `type: "linkedmesh"` share one branch of the reader and a truthy `source` is
889
+ * what decides between them (`SkeletonJson.js:568-569`, `:582`). An empty
890
+ * `source` is falsy there, so it is not a link here either — that map is read
891
+ * as an ordinary mesh, which is exactly what the runtime does with it.
878
892
  */
879
893
  function rawLinkedMeshes(raw: unknown): Map<string, RawLinkedMesh> {
880
894
  const links = new Map<string, RawLinkedMesh>();
@@ -3305,7 +3319,7 @@ export function validate(input: ValidateInput): ValidateReport {
3305
3319
  const reached = keyedLive(
3306
3320
  (timeline): timeline is PhysicsConstraintTimeline =>
3307
3321
  timeline instanceof PhysicsConstraintTimeline &&
3308
- PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])] === rule.timeline,
3322
+ physicsTimelineNames()[Number(timeline.getPropertyIds()[0].split('|')[0])] === rule.timeline,
3309
3323
  (timeline, value) => {
3310
3324
  timeline.set(raised, value);
3311
3325
  return rule.poseOk(raised[rule.field]);
@@ -3388,7 +3402,7 @@ export function validate(input: ValidateInput): ValidateReport {
3388
3402
  // `ConstraintTimeline1` encodes its propertyId as `<Property>|<index>`,
3389
3403
  // so the name comes from the runtime's own enum rather than from a
3390
3404
  // table of strings this file would have to keep in step.
3391
- const name = PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])];
3405
+ const name = physicsTimelineNames()[Number(timeline.getPropertyIds()[0].split('|')[0])];
3392
3406
  if (name === undefined) continue;
3393
3407
  const rule = physicsRuleFor(name);
3394
3408
  // -1 is the global form: the timeline drives every physics constraint
@@ -5029,9 +5043,9 @@ export function validate(input: ValidateInput): ValidateReport {
5029
5043
  // reads each of those off the FILE and names the value. The second POSES
5030
5044
  // every key at sampled times — mid-frame, so a float32 key time cannot land
5031
5045
  // a sample on a frame boundary — and compares the region the slot shows
5032
- // against the one the file's own statement gives: the frame arithmetic of
5033
- // `SequenceTimeline.applyToSlot` transcribed here, and the frame names of
5034
- // `Sequence.getPath` transcribed in `attachmentRegionLookups`. Neither is
5046
+ // against the one the file's own statement gives: the frame the key's mode,
5047
+ // index and delay give (`frameOf` below), and the frame names
5048
+ // `attachmentRegionLookups` derives (the core's `frameRegionName`). Neither is
5035
5049
  // read off the loaded timeline, so the check is not the runtime agreeing
5036
5050
  // with itself.
5037
5051
  //
@@ -5099,7 +5113,15 @@ export function validate(input: ValidateInput): ValidateReport {
5099
5113
  }
5100
5114
  }
5101
5115
 
5102
- /** The frame `SequenceTimeline.applyToSlot` shows — transcribed, not called. */
5116
+ /**
5117
+ * The frame the file's statement gives for one key at one elapsed time —
5118
+ * A46's prediction, not a call into the runtime. What holds it is the
5119
+ * comparison it feeds: every sample sets it against the frame the posed
5120
+ * slot shows, so a reading that disagreed with the runtime would fail A46
5121
+ * on a correct file. A46 passing with samples posed on the selftest's
5122
+ * series probes is that measurement for the modes they key; `M73` is its
5123
+ * red half.
5124
+ */
5103
5125
  const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
5104
5126
  if (mode === 'hold') return index;
5105
5127
  let i = index + Math.trunc(elapsed / delay + 0.00001);
@@ -5436,7 +5458,7 @@ export function validate(input: ValidateInput): ValidateReport {
5436
5458
  // is about rigc's own packer never turning a region, which is a statement
5437
5459
  // about what rigc WRITES. It stopped being a statement about what rigc can
5438
5460
  // read in issue #570 — `extractRegion` now lifts a rotated region back off
5439
- // its page as a transcription of `MeshAttachment.computeUVs` — and the two
5461
+ // its page, measured against `MeshAttachment.computeUVs` (`PKR02`) — and the two
5440
5462
  // must not be re-merged: an artifact under the renderer's own rulebook that
5441
5463
  // rigc did not pack is still a foreign artifact, whatever rigc can measure.
5442
5464
  const regionsPerPage = new Map<string, TextureAtlasRegion[]>();