spine-rigc 1.6.2 → 1.8.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.
Files changed (39) hide show
  1. package/cli.ts +166 -60
  2. package/docs/AUTHORING.md +25 -16
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a11.ts +15 -0
  6. package/src/assertions/bodies/a17.ts +26 -0
  7. package/src/assertions/bodies/a45.ts +172 -0
  8. package/src/assertions/facts/atlas_pages.ts +19 -0
  9. package/src/assertions/facts/skin_entries.ts +35 -0
  10. package/src/assertions/facts/slot_colour.ts +43 -0
  11. package/src/assertions/harness.ts +109 -0
  12. package/src/assertions/kinds.ts +102 -0
  13. package/src/assertions/model/atlas_pages.ts +15 -0
  14. package/src/assertions/model/index.ts +130 -0
  15. package/src/assertions/model/parse.ts +136 -0
  16. package/src/assertions/model/skin_entries.ts +58 -0
  17. package/src/assertions/model/slot_colour.ts +80 -0
  18. package/src/assertions/reasons.ts +127 -0
  19. package/src/assertions/values.ts +39 -0
  20. package/src/atlas.ts +26 -16
  21. package/src/check.ts +70 -47
  22. package/src/compile.ts +118 -93
  23. package/src/core/constraints_path.ts +60 -38
  24. package/src/core/constraints_physics.ts +22 -4
  25. package/src/core/index.ts +102 -15
  26. package/src/core/uvs.ts +25 -5
  27. package/src/core/world.ts +80 -24
  28. package/src/deformmeasure.ts +383 -157
  29. package/src/deformreport.ts +3 -0
  30. package/src/deformstructure.ts +335 -0
  31. package/src/emit.ts +17 -6
  32. package/src/ingest.ts +18 -13
  33. package/src/keyorder.ts +2 -2
  34. package/src/model.ts +114 -13
  35. package/src/render.ts +520 -59
  36. package/src/render_core.ts +178 -21
  37. package/src/transform.ts +113 -128
  38. package/src/types.ts +6 -5
  39. package/src/validate.ts +200 -490
@@ -2,14 +2,16 @@
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
  *
10
12
  * ⭐ Why a module of its own rather than a second block in `src/render.ts`.
11
- * `render.ts` links spine-core (the atlas pages, texture substitution and
12
- * `spinePoser`), so a core poser written there would share an import list
13
+ * `render.ts` links spine-core (an export's atlas pages and texture
14
+ * substitution, and `spinePoser`), so a core poser written there would share an import list
13
15
  * with the runtime it replaces — and "the core poser imports nothing from
14
16
  * spine-core" could then only be said, not read off the file. Here it can be:
15
17
  * the value imports below are the core, the atlas reader and nothing else, and
@@ -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,20 +263,27 @@ 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
  /**
268
278
  * The document posed with no skin set — spine-core's initial state, every slot
269
279
  * resolved through the default skin alone — or refused by name where that
270
280
  * equivalence was not measured (the header's *The skin*).
281
+ *
282
+ * Exported for the model side of the validator (issue #1025), which poses a
283
+ * slot the way `validate()` does — a fresh skeleton, no skin set — and must
284
+ * resolve that state by this rule rather than by a copy of it.
271
285
  */
272
- function noSkinView(doc: CompiledDocument): CompiledDocument {
286
+ export function noSkinView(doc: CompiledDocument): CompiledDocument {
273
287
  if (doc.skins.length === 0) return underSkin(doc, CORE_ALL_SKINS);
274
288
  const fallback = doc.skins.find((k) => k.name === CORE_DEFAULT_SKIN);
275
289
  if (fallback === undefined) {
@@ -300,12 +314,12 @@ function worldOf(pose: RawPose): Map<string, CoreWorld> {
300
314
  }
301
315
 
302
316
  /** `regionKey` in `src/render.ts`: the trimmed name and the sequence index — the key a substitution matches on. */
303
- function regionKey(region: AtlasRegion): string {
317
+ function regionKey(region: TextureRegion): string {
304
318
  return `${region.name.trim()}#${region.index}`;
305
319
  }
306
320
 
307
321
  /** `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 {
322
+ function artUvsOf(drawn: RawDrawn, region: TextureRegion): PieceTexture | undefined {
309
323
  if (drawn.kind === 'mesh') return { region: regionKey(region), artUvs: [...drawn.uvs] };
310
324
  if (region.degrees !== 0) return undefined;
311
325
  const ow = region.originalWidth;
@@ -515,9 +529,70 @@ function restOf(view: CompiledDocument, setup: RawPose, shown: readonly Attachme
515
529
  }
516
530
 
517
531
  /**
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
532
+ * The first place two `pages` sections differ, by path, or `null` when they
533
+ * are the same — the page count, a page's name or size, a region count, or a
534
+ * region's name or one of its numbers.
535
+ */
536
+ export function firstPageDifference(stated: readonly ModelPage[], found: readonly ModelPage[]): string | null {
537
+ if (stated.length !== found.length) return `the document states ${stated.length} page(s), the atlas has ${found.length}`;
538
+ for (let i = 0; i < stated.length; i++) {
539
+ const a = stated[i];
540
+ const b = found[i];
541
+ for (const key of ['name', 'width', 'height'] as const) {
542
+ 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`;
543
+ }
544
+ 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`;
545
+ for (let j = 0; j < a.regions.length; j++) {
546
+ const r = a.regions[j];
547
+ const q = b.regions[j];
548
+ for (const key of Object.keys(r) as Array<keyof typeof r>) {
549
+ 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`;
550
+ }
551
+ }
552
+ }
553
+ return null;
554
+ }
555
+
556
+ /**
557
+ * Where each region a core pose draws sits on its page, read from the
558
+ * document's `pages` section (issue #1016) — or, for a `rigc-compiled/1`
559
+ * document, which has none, from `atlasText` as before. With both, the atlas
560
+ * must be the one the document was written beside: a page or region that
561
+ * differs is refused naming the first difference, so the core never draws the
562
+ * build's placement over another atlas's pages while spine-core, reading that
563
+ * atlas, would draw another picture.
564
+ */
565
+ function placementOf(doc: CompiledDocument, atlasText: string, where: string): { lookup: UvSource['lookup']; region: CoreInput['region'] } {
566
+ if (doc.pages !== null) {
567
+ if (atlasText !== '') {
568
+ const differs = firstPageDifference(doc.pages, pagesOfAtlas(atlasText));
569
+ if (differs !== null) {
570
+ throw new CoreInputError(
571
+ `the atlas beside ${where} is not the one it was written beside: ${differs} — ` +
572
+ 'the core would draw the build\'s placement over pages the atlas has rearranged',
573
+ );
574
+ }
575
+ }
576
+ const lookup = documentPageLookup(doc.pages);
577
+ return { lookup, region: (name) => lookup(name)?.region ?? null };
578
+ }
579
+ if (atlasText === '') {
580
+ throw new CoreInputError(
581
+ `${where} is a ${doc.spec} document, which does not state where each region sits on its page (the pages section, issue #1016), ` +
582
+ 'and no atlas was given to read it from — rebuild it to carry them, or pose it beside the atlas it was built with',
583
+ );
584
+ }
585
+ const lookup = atlasRegionLookup(parseAtlasText(atlasText));
586
+ return { lookup, region: (name) => lookup(name)?.region ?? null };
587
+ }
588
+
589
+ /**
590
+ * `Poser` over rigc's own core: `modelText` a `rigc-compiled/2` document
591
+ * (`skeleton.model.json`), which states where each region sits on its page
592
+ * (`pages`, issue #1016), so `atlasText` may be `''`; given, it is held to
593
+ * the document's `pages` (`placementOf`). A `rigc-compiled/1` document states
594
+ * no placement and is drawn through `atlasText`, the atlas `build` wrote
595
+ * beside it, as before; with none given it is refused by name. With `skeleton`, the Spine file beside the document
521
596
  * is held to the digest the document records (`spine.sha256`, issue #968) and
522
597
  * refused, naming both digests, when it is not that build's. Refused by
523
598
  * `CoreInputError`, naming why, where the document or the atlas cannot be read; a pose the core leaves a block of out
@@ -542,8 +617,7 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
542
617
  throw new CoreInputError(`${where}: not JSON — ${(err as Error).message}`);
543
618
  }
544
619
  const sequences = readUvSequences(parsed);
545
- const atlas = parseAtlasText(atlasText);
546
- const lookup = atlasRegionLookup(atlas);
620
+ const { lookup, region } = placementOf(doc, atlasText, where);
547
621
  const views = new Map<string, CompiledDocument>();
548
622
  const inputOf = (skin: string | undefined): CoreInput => {
549
623
  const key = skin === undefined ? '' : `=${skin}`;
@@ -552,7 +626,7 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
552
626
  view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
553
627
  views.set(key, view);
554
628
  }
555
- return { doc: view, source: { lookup, sequences }, region: (name) => lookup(name)?.region ?? null };
629
+ return { doc: view, source: { lookup, sequences }, region };
556
630
  };
557
631
  const roster: SubsetRoster = {
558
632
  declared: doc.slots.map((s) => s.name),
@@ -580,3 +654,86 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
580
654
  },
581
655
  };
582
656
  }
657
+
658
+ /**
659
+ * The skin roster behind the core poser (`SkinRoster` in `src/render.ts`,
660
+ * issue #1014): the bones each skin leaves unposed, read off the model
661
+ * document — `unposedBones` over `activeBones` of the skin's view, the
662
+ * predicate the raw pose flags its bones with, and under no skin the view the
663
+ * core poses with no skin set (`noSkinView`, refused by `CoreInputError` where
664
+ * it refuses to pose). `skins` is the Spine file's own list, in its order, which
665
+ * the document does not hold (`resolveSkinView`'s note in `./core/index.ts`).
666
+ *
667
+ * Measured against the runtime's reading (`skinRosterOf`, a fresh skeleton's
668
+ * `active` under each skin and under none) on every rigc build the tree
669
+ * carries: the same bones, under every skin. Since issue #1020 it is handed the
670
+ * document `coreDocumentFacts` read for the run's other facts, so the roster
671
+ * costs no reading of its own; a skin's view is still built only when a
672
+ * question is asked of it.
673
+ */
674
+ function coreSkinRoster(doc: CompiledDocument, skins: readonly string[]): SkinRoster {
675
+ return {
676
+ skins,
677
+ unposedUnder: (skin) => {
678
+ const view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
679
+ const active = activeBones(view);
680
+ return unposedBones(view.bones.map((b) => ({ name: b.name, parent: b.parent ?? null, active: active.has(b.name) })));
681
+ },
682
+ };
683
+ }
684
+
685
+ /**
686
+ * What `render` and `check` read off a rigc build's model document besides the
687
+ * pose (issue #1020) — so a build the core poses draws with its
688
+ * `skeleton.atlas` gone, and reads off `skeleton.json` only what the document
689
+ * does not state.
690
+ */
691
+ export interface CoreDocumentFacts {
692
+ /** The document's spec: `rigc-compiled/2` states where each region sits on its page, `rigc-compiled/1` does not. */
693
+ spec: string;
694
+ /**
695
+ * Every page the `pages` section states, by name, in file order — the
696
+ * images the draw samples, read by these names; `null` for a
697
+ * `rigc-compiled/1` document, whose pages only its atlas names.
698
+ */
699
+ pageNames: readonly string[] | null;
700
+ /** The skin roster behind the core poser (`coreSkinRoster`), over this one reading. */
701
+ roster: SkinRoster;
702
+ /**
703
+ * The slot subset's roster (`subsetOver`): the slots in the document's draw
704
+ * order, its default skin, and the skins the document files a slot's
705
+ * attachments under. A refusal lists those skins, and the order it lists
706
+ * them in is the Spine file's, which the document does not hold
707
+ * (`./core/index.ts`, *Several skins filling one placeholder*) — so they are
708
+ * put in `skins`' order, the Spine file's own list.
709
+ */
710
+ subset: SubsetRoster;
711
+ }
712
+
713
+ /**
714
+ * The document's facts for `render` and `check` (`CoreDocumentFacts`), read
715
+ * once. `skins` is the Spine file's skin list, in its order — the one fact
716
+ * here the document does not state. Refused by `CoreInputError` where
717
+ * `readModel` refuses the document.
718
+ */
719
+ export function coreDocumentFacts(modelText: string, where: string, skins: readonly string[]): CoreDocumentFacts {
720
+ const doc = readModel(modelText, where);
721
+ const rank = (name: string): number => {
722
+ const at = skins.indexOf(name);
723
+ return at < 0 ? skins.length : at;
724
+ };
725
+ return {
726
+ spec: doc.spec,
727
+ pageNames: doc.pages === null ? null : doc.pages.map((page) => page.name),
728
+ roster: coreSkinRoster(doc, skins),
729
+ subset: {
730
+ declared: doc.slots.map((s) => s.name),
731
+ carriers: (slot) =>
732
+ doc.skins
733
+ .filter((k) => Object.keys(k.attachments[slot] ?? {}).length > 0)
734
+ .map((k) => k.name)
735
+ .sort((a, b) => rank(a) - rank(b)),
736
+ defaultSkin: doc.skins.some((k) => k.name === CORE_DEFAULT_SKIN) ? CORE_DEFAULT_SKIN : null,
737
+ },
738
+ };
739
+ }
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,44 @@
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. Since
37
+ * issue #1021 it runs under the runtime's own arithmetic (`RUNTIME_ARITHMETIC`,
38
+ * the evaluator's default), so the matrices every point is bound through are the
39
+ * ones the runtime poses the build with, to the bit. This file keeps what is the
40
+ * compiler's own: the coordinate contract, the refusals by name, the world
41
+ * rotation a region cancels, and the inverses.
42
+ *
43
+ * ⚠️ **Why the runtime's arithmetic and not the textbook's** (issue #1021). A
44
+ * point is bound by inverting a setup matrix; the runtime then poses it through
45
+ * its own. The two used to differ in the last bits — `Math.PI` against the
46
+ * runtime's 3.1415927, `[cos, −sin, sin, cos]` against the y column taken at
47
+ * `rotation + 90`, radians to degrees by division against multiplication — and
48
+ * that is not noise below the file's precision: an unrotated root reads
49
+ * `b = −2.3e-8` in the runtime, so a point 256 units above its bone was bound
50
+ * 5.9e-6 off before the float32 spelling was even applied. Measured from inside
51
+ * `compile` (every authored world position `toBoneLocal` was handed) against
52
+ * spine-core's pose of the emitted build, the old arithmetic put `gallery/look`'s
53
+ * 312 bound points up to 8.6e-5 from where they were authored (RMS 5.1e-5) and
54
+ * `gallery/flex`'s 258 up to 2.3e-5; the runtime's puts every one of them at the
55
+ * float32 floor — the distance the float32 spelling of the exact inverse alone
56
+ * accounts for (look 8.1e-6, flex 4.1e-6). `CO24` holds it.
57
+ *
58
+ * 🔸 **The rule has a second half: what is MEASURED reads the exact frame.**
59
+ * The runtime's arithmetic is for what is bound — the matrices a point is
60
+ * inverted through. Anything the compiler measures of the authored rig and
61
+ * reports, compares against a threshold, sorts by or refuses on is a statement
62
+ * about the spec, and reads `computeExactFrameTransforms` (below): a depth turn
63
+ * ceiling, a ring's control angles, a `segments` mesh's segments and falloff.
64
+ * An angle the author stated as 90 has to print as 90.
33
65
  */
66
+ import type { ModelBone } from './model.ts';
67
+ import { modeMatrix, RUNTIME_ARITHMETIC, worldTransforms, type CoreWorld, type WorldArithmetic } from './core/world.ts';
68
+
34
69
  /**
35
70
  * What `computeWorldTransforms` reads off a bone — a structural type, so both a
36
71
  * Spine bone (`SpineBone`, what spine-parts and every skeleton reader hold) and
@@ -68,8 +103,6 @@ export interface BoneTransform {
68
103
 
69
104
  export class TransformError extends Error {}
70
105
 
71
- const DEG = Math.PI / 180;
72
-
73
106
  /**
74
107
  * Crop pixels (y down, origin top-left) -> Spine world (y up, origin at the
75
108
  * bottom-left of the crop).
@@ -85,6 +118,20 @@ export function cropToSpineY(cropY: number, cropHeight: number): number {
85
118
  return cropHeight - cropY;
86
119
  }
87
120
 
121
+ /**
122
+ * The textbook's arithmetic — `Math.PI`, a bone with the default scale and no
123
+ * shear framed as `[cos, −sin, sin, cos]`, radians to degrees by dividing by
124
+ * the degree factor — in which an unrotated bone's frame is the identity
125
+ * exactly. Every point the compiler binds goes through the runtime's instead
126
+ * (the header's ⚠️); this is what `computeExactFrameTransforms` evaluates
127
+ * under, and nothing else.
128
+ */
129
+ const EXACT_FRAME_ARITHMETIC: WorldArithmetic = {
130
+ radiansPerDegree: Math.PI / 180,
131
+ degreesOf: (radians) => radians / (Math.PI / 180),
132
+ rotationOnlyFrame: true,
133
+ };
134
+
88
135
  /**
89
136
  * World transform of every bone, in declaration order.
90
137
  *
@@ -92,69 +139,76 @@ export function cropToSpineY(cropY: number, cropHeight: number): number {
92
139
  * requires (it resolves `parent` by name against the bones already read), so a
93
140
  * violation here is a violation there.
94
141
  *
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.
142
+ * The matrices and origins are the core's (`worldTransforms`, under the
143
+ * runtime's arithmetic — the header's ⚠️). What is decided here is what the core does not say:
144
+ * an undeclared parent and an `inherit` no mode answers to are refused by name
145
+ * as `TransformError`, a bone whose `parent` is empty is a root as it always was
146
+ * here, and `worldRotation` is read off the matrix — a root with the default
147
+ * scale and no shear keeps its stated rotation, unwrapped. The angle is turned
148
+ * into degrees as the runtime turns one (`RUNTIME_DEG`, by multiplying), so
149
+ * the rotation a region writes to cancel it is read back by the runtime as
150
+ * that angle.
103
151
  */
104
152
  export function computeWorldTransforms(bones: readonly PosableBone[]): Map<string, BoneTransform> {
105
- const out = new Map<string, BoneTransform>();
153
+ return evaluate(bones, RUNTIME_ARITHMETIC);
154
+ }
155
+
156
+ /**
157
+ * The same bones under `EXACT_FRAME_ARITHMETIC`: the frame the compiler
158
+ * MEASURES the authored rig in, and binds nothing through (issue #1021) — a
159
+ * mesh's depth turn ceiling (`sampleMeshDepth` in `compile.ts`), a ring's
160
+ * control angles (`ringControlAngles`, which orders the split and refuses a
161
+ * tie by printing the angle), and a `segments` mesh's segments, the distances
162
+ * its falloff and `minWeight` read and its shared-origin refusal. It differs from
163
+ * `computeWorldTransforms` only in the last bits, and that is exactly why it is
164
+ * kept (issue #1021): the ceiling is a statement about the authored mesh, and
165
+ * its reader treats an axis whose area term is exactly zero as one that cannot
166
+ * fold. The runtime frames an unrotated bone with `b = −2.3e-8`, so in its
167
+ * bind space a sheet whose depth rises linearly in x — which cannot fold a
168
+ * pitch at any angle — reads a pitch ceiling of 89.99997°, and `gallery/look`'s
169
+ * two hair locks gained four such ceilings with their counts (`TC03`, `TC06`
170
+ * went red on it). A control angle read through the runtime's frame printed
171
+ * an authored 90 as 89.99999734089101 in its tie refusal (`RF46`). On the 19
172
+ * recipes the only bytes this keeps from moving are look's ceilings; no
173
+ * emitted position goes through it.
174
+ */
175
+ export function computeExactFrameTransforms(bones: readonly PosableBone[]): Map<string, BoneTransform> {
176
+ return evaluate(bones, EXACT_FRAME_ARITHMETIC);
177
+ }
178
+
179
+ function evaluate(bones: readonly PosableBone[], arithmetic: WorldArithmetic): Map<string, BoneTransform> {
180
+ const model: ModelBone[] = [];
181
+ const declared = new Set<string>();
106
182
  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;
183
+ const parent = bone.parent ? bone.parent : undefined;
184
+ if (parent !== undefined && !declared.has(parent)) {
185
+ throw new TransformError(`bone "${bone.name}" names parent "${parent}", which is not declared before it`);
138
186
  }
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,
187
+ model.push({
188
+ name: bone.name,
189
+ parent,
190
+ x: bone.x,
191
+ y: bone.y,
192
+ rotation: bone.rotation,
193
+ scaleX: bone.scaleX,
194
+ scaleY: bone.scaleY,
195
+ shearX: bone.shearX,
196
+ shearY: bone.shearY,
197
+ inheritMode: parent === undefined ? undefined : inheritMode(bone),
150
198
  });
199
+ declared.add(bone.name);
200
+ }
201
+ const worlds = worldTransforms(model, null, modeMatrix, arithmetic);
202
+ const out = new Map<string, BoneTransform>();
203
+ for (const bone of model) {
204
+ const w = worlds.get(bone.name) as CoreWorld;
205
+ const plain = (bone.scaleX ?? 1) === 1 && (bone.scaleY ?? 1) === 1 && (bone.shearX ?? 0) === 0 && (bone.shearY ?? 0) === 0;
206
+ const worldRotation = bone.parent === undefined && plain ? (bone.rotation ?? 0) : (arithmetic.degreesOf(Math.atan2(w.c, w.a)) + 360) % 360;
207
+ out.set(bone.name, { a: w.a, b: w.b, c: w.c, d: w.d, worldX: w.worldX, worldY: w.worldY, worldRotation });
151
208
  }
152
209
  return out;
153
210
  }
154
211
 
155
- /** `MathUtils.epsilon2` — the threshold `noRotationOrReflection` tests a degenerate parent against. */
156
- const EPSILON2 = 0.00001 * 0.00001;
157
-
158
212
  type InheritMode = 'normal' | 'onlyTranslation' | 'noRotationOrReflection' | 'noScale' | 'noScaleOrReflection';
159
213
 
160
214
  const INHERIT_MODES: readonly InheritMode[] = ['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection'];
@@ -177,75 +231,6 @@ function inheritMode(bone: PosableBone): InheritMode {
177
231
  return mode;
178
232
  }
179
233
 
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
234
  /**
250
235
  * World point -> the bone's local space. Used for bind coordinates and offsets.
251
236
  *
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
  *