spine-rigc 1.7.0 → 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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The model side's supply of `SlotColourFacts` (issue #1025, step 4c of #380).
3
+ *
4
+ * - **The slot timelines, in the file's order and spelling.** The document
5
+ * holds each animation's slot timelines in the order they are applied, with
6
+ * every key as the compiler built it; the Spine file keys the animations in
7
+ * the editor's order and passes every key through two passes. So the
8
+ * animations are put in order by `editorAnimationOrder` (`src/compile.ts`,
9
+ * the function the emitter is handed), and the timelines are laid out as the
10
+ * file lays them and handed to the emitter's own `withoutParserDefaults` and
11
+ * `inEditorKeyOrder` (`src/keyorder.ts`) — so a key field the emitter would
12
+ * leave out at the parser's default is left out here too, and a body
13
+ * printing `JSON.stringify(key.value)` prints what it prints over the file.
14
+ * Nothing here restates either rule.
15
+ * - **Whether an animation exists**: the document's own roster.
16
+ * - **A slot's posed colour**: the core's raw entry (`poseRawAnimation`,
17
+ * `src/core/raw.ts`) taking one step of the time on a fresh non-looping
18
+ * track, under the view the core poses with no skin set (`noSkinView`,
19
+ * `src/render_core.ts`) — the reading of what `validate()` does through
20
+ * spine-core (a fresh skeleton, no skin, setup pose, the track stepped to the
21
+ * time and applied). Its slot rows are the ones `CR03` holds bit for bit to
22
+ * spine-core on every frame of the nineteen recipes; that the two readings
23
+ * print the same A45 line is the selftest's supplier check, on every call
24
+ * that poses one (`VF02`). A channel the pose read as no number is NaN, as a
25
+ * non-finite channel reads on the runtime's side.
26
+ *
27
+ * Links nothing from the runtime.
28
+ */
29
+ import { editorAnimationOrder } from '../../compile.ts';
30
+ import type { CompiledDocument } from '../../core/index.ts';
31
+ import { poseRawAnimation } from '../../core/raw.ts';
32
+ import { inEditorKeyOrder, withoutParserDefaults } from '../../keyorder.ts';
33
+ import { noSkinView } from '../../render_core.ts';
34
+ import type { SlotColourFacts, SlotTimelines } from '../facts/slot_colour.ts';
35
+ import { isObj, type Json } from '../values.ts';
36
+ import type { ReadDocument } from './parse.ts';
37
+
38
+ const list = (v: unknown): Json[] => (Array.isArray(v) ? v.filter(isObj) : []);
39
+
40
+ /** Every animation's slot timelines as the Spine file states them: `animations.<name>.slots.<slot>.<timeline> = keys`. */
41
+ export function fileSlotTimelines(read: ReadDocument): SlotTimelines[] {
42
+ const byName = new Map<string, Json>();
43
+ for (const anim of list(read.json.animations)) if (typeof anim.name === 'string') byName.set(anim.name, anim);
44
+ const animations: Json = {};
45
+ for (const name of editorAnimationOrder([...byName.keys()])) {
46
+ const slots: Json = {};
47
+ for (const slot of list((byName.get(name) as Json).slots)) {
48
+ if (typeof slot.name !== 'string') continue;
49
+ const timelines: Json = {};
50
+ for (const timeline of list(slot.timelines)) {
51
+ if (typeof timeline.name === 'string') timelines[timeline.name] = list(timeline.keys).map((key) => ({ ...key }));
52
+ }
53
+ slots[slot.name] = timelines;
54
+ }
55
+ // An animation keying no slot writes no `slots` group — `emitAnimation`'s `if (animation.slots.size)`.
56
+ animations[name] = Object.keys(slots).length > 0 ? { slots } : {};
57
+ }
58
+ const file = inEditorKeyOrder(withoutParserDefaults({ animations }));
59
+ const out: SlotTimelines[] = [];
60
+ for (const [animation, anim] of Object.entries(file.animations)) {
61
+ if (!isObj(anim) || !isObj(anim.slots)) continue;
62
+ for (const [slot, timelines] of Object.entries(anim.slots)) if (isObj(timelines)) out.push({ animation, slot, timelines });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ export function modelSlotColour(read: ReadDocument): SlotColourFacts {
68
+ let view: CompiledDocument | null = null;
69
+ return {
70
+ slotTimelines: fileSlotTimelines(read),
71
+ hasAnimation: (name) => read.doc.animations.some((a) => a.name === name),
72
+ posedSlot: (animation, slot, time) => {
73
+ view ??= noSkinView(read.doc);
74
+ const row = poseRawAnimation(view, animation, [time], {}, 'animation')[1].slots.find((r) => r[0] === slot);
75
+ if (row === undefined) return undefined;
76
+ const channel = (v: number | null): number => (v === null ? Number.NaN : v);
77
+ return { color: { r: channel(row[2]), g: channel(row[3]), b: channel(row[4]), a: channel(row[5]) } };
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * What a SKIP says, by what the assertion was denied (issues #568, #580, #608).
3
+ *
4
+ * Moved here from `src/validate.ts` by issue #1025 (step 4c of #380), words
5
+ * unchanged: an assertion body now lives in a module that links nothing from
6
+ * the runtime (`./bodies/`), and the bodies print these sentences on both
7
+ * sides — over what spine-core loaded and over the model document. A sentence
8
+ * kept in the file that links spine-core could not be read by the second.
9
+ * `src/validate.ts` re-exports every one, so nothing that imported them from
10
+ * there moves.
11
+ */
12
+
13
+ /**
14
+ * What a SKIP says when `A00_ROUNDTRIP_PARSE` handed an assertion nothing to
15
+ * look at.
16
+ *
17
+ * Two of them rather than one, because what an assertion was denied is the whole
18
+ * content of its SKIP: `A20` wanted the loaded skeleton and `A06` wanted the
19
+ * loaded atlas, and a reader who is told which can tell a rule that is waiting
20
+ * on the parse from one that is waiting on the art. Both point at A00 instead of
21
+ * restating its detail, which the FAIL row prints in full.
22
+ *
23
+ * Exported so a control compares against them rather than quoting them — the
24
+ * same reason `ASSERTION_NAMES` is exported.
25
+ */
26
+ export const SKIP_NO_SKELETON = 'the round trip did not produce a skeleton to measure (A00 owns that failure)';
27
+ export const SKIP_NO_ATLAS = 'the round trip did not produce an atlas to measure (A00 owns that failure)';
28
+ /**
29
+ * The third of them, and it is NOT waiting on A00 (issue #608): the atlas parsed
30
+ * and it has no pages, which is what a compile that measured no art writes. The
31
+ * four rules whose only subject is a page — A06, A17, A19, A27 — have nothing to
32
+ * look at, and an empty loop walking out of `check()` is reported as a PASS.
33
+ */
34
+ /*
35
+ * **An empty subject list: SKIP or PASS, and which one is derived (issue #580).**
36
+ *
37
+ * `check()` records a pass when a body runs to its end without a `fail()` or a
38
+ * `skip()`, so a body whose main construct is a loop over a list that is EMPTY
39
+ * passes having measured nothing — the vacuous green one ring out from the bare
40
+ * `return` guards issue #568 converted. Two different things can be true of such
41
+ * a loop, and the criterion decides between them by reading the rule's own name
42
+ * and its `fail()` sentences rather than by preference:
43
+ *
44
+ * * A name of the form ⟨subject⟩_⟨property⟩ — `REGION_WIDTH_HEIGHT_FINITE`,
45
+ * `MESH_TRIANGLES_AND_ENCODING`, `PHYSICS_CONSTRAINT_EFFECTIVE`,
46
+ * `ATLAS_PAGE_SIZE_MATCHES_PNG` — quantifies over the subject it names, and
47
+ * its `fail()` names a member of that subject, the value found and the value
48
+ * required. With no member, nothing was measured: it **SKIPs**, and the
49
+ * reason names the subject that was absent.
50
+ * * A name of the form NO_⟨construct⟩ — `NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS`,
51
+ * `NO_BONE_TRANSFORM_KEY`, `NO_CLIPPING_ATTACHMENTS`, `NO_DARK_COLOR`,
52
+ * `NO_FULL_FRAME_MESH` — quantifies over occurrences of something a correct
53
+ * artifact has NONE of, and its `fail()` reports that the construct is
54
+ * present rather than measuring a value on it. Zero occurrences IS the
55
+ * measurement, so it **PASSes**: the loop is a search of the artifact, and
56
+ * the artifact is what it measured.
57
+ * * A name carrying both halves takes each at its word.
58
+ * `A15_IDLE_NO_MESH_BONE_KEYS` is the pattern and already reads this way: no
59
+ * `idle` animation, or an `idle` with no bone timeline, is the named subject
60
+ * absent and SKIPs; no mesh-driving bone among the bones `idle` does key is
61
+ * the construct absent and passes. `A10_NO_NAN_AFTER_STEPPING` was the same
62
+ * shape until issue #902, and is the next bullet's now: #882 gave it a
63
+ * setup-pose clause whose subject is the bones, beside the stepping clause
64
+ * whose subject is the animations.
65
+ * * An assertion with more than one clause SKIPs only when EVERY clause had
66
+ * nothing to measure, which is the shape `A09`, `A33` and `A38` already
67
+ * carry (`polygons.length === 0 && endsChecked === 0`), and `A10` since #902.
68
+ * `A13_MESH_BUDGET` is why the clause is stated: a declared slot budget is
69
+ * measured against a count of mesh slots, and zero is a count, so that half
70
+ * passes on a rig with no mesh — while a rig that declares only a TRIANGLE
71
+ * budget has nothing left to measure and skips.
72
+ *
73
+ * ⚠️ What the criterion is not allowed to become is a table of assertions with
74
+ * their verdicts written beside them. Every row above is decided by reading the
75
+ * name and the sentences the assertion already prints, so a rule added tomorrow
76
+ * is decided by the same reading and nobody re-opens this question.
77
+ */
78
+
79
+ /**
80
+ * What a SKIP says when the artifact carries no member of the subject a rule
81
+ * quantifies over (issue #580), by subject.
82
+ *
83
+ * One constant per SUBJECT rather than one per assertion, for the reason the two
84
+ * above are two: what the rule was denied is the whole content of its SKIP, and
85
+ * three rules denied the same thing should say so in the same words. Exported so
86
+ * a control compares against them rather than quoting them.
87
+ */
88
+ export const SKIP_NO_REGION_ATTACHMENT = 'the skeleton carries no region attachment';
89
+ export const SKIP_NO_MESH_ATTACHMENT = 'the skeleton carries no mesh attachment';
90
+ export const SKIP_NO_ANIMATION = 'the skeleton carries no animation';
91
+ /**
92
+ * A10's (issue #902): its setup clause reads the bones and its stepping clause
93
+ * the animations, so it skips only when the skeleton carries neither — and the
94
+ * sentence is `SKIP_NO_ANIMATION`'s with the second subject added, so the two
95
+ * cannot drift into different words for the same absence.
96
+ */
97
+ export const SKIP_NO_POSE = `${SKIP_NO_ANIMATION} and no bone, so there is no setup pose to read and nothing to step`;
98
+ export const SKIP_NO_TIMELINE = 'no animation here carries a timeline';
99
+ export const SKIP_NO_PHYSICS_CONSTRAINT = 'the skeleton declares no physics constraint';
100
+ export const SKIP_NO_ATLAS_PAGE = 'the atlas declares no page';
101
+ export const SKIP_NO_ATLAS_REGION = 'the atlas declares no region';
102
+ export const SKIP_NO_ATTACHMENT_REGION_JOIN =
103
+ 'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
104
+ export const SKIP_NO_TWO_COLOR_TINT =
105
+ 'no slot declares a "dark" colour and no animation keys an "rgba2" or "rgb2" timeline, so there is no two-colour tint to read back';
106
+ export const SKIP_NO_SEPARABLE_COLOR =
107
+ 'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
108
+ export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
109
+ export const SKIP_NO_SEQUENCE =
110
+ 'no attachment carries a "sequence" block and no animation keys a "sequence" timeline, so there is no numbered series to read back';
111
+ /**
112
+ * A09's, which predates this list and joins it rather than being rewritten: it
113
+ * is the same fact about the same subject, and a control that compares against
114
+ * eight constants and quotes the ninth is a control with a hand-kept exception
115
+ * in it.
116
+ */
117
+ export const SKIP_NO_DECLARED_DURATION =
118
+ 'the motion spec declares no animations and the skeleton has none — a static rig has no duration to compare';
119
+
120
+ /**
121
+ * The model side's `SKIP_NO_SKELETON` (issue #1025): the reader refused the
122
+ * document, or the document names a region its pages lack, so a body that
123
+ * reads the model has nothing to read. It names the two model-side parse
124
+ * rules rather than A00, because on this side there is no round trip — the
125
+ * reader and the region rule are what stand in its place (`./model/parse.ts`).
126
+ */
127
+ export const SKIP_NO_MODEL = 'the model side read no document to measure (A00_MODEL_READ or A00_MODEL_REGIONS_ON_PAGES owns that failure)';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * How an assertion body reads a value — the helpers the bodies under
3
+ * `./bodies/` share with the ones still in `src/validate.ts` (issue #1025,
4
+ * step 4c of #380), moved here unchanged so both read a value one way. Links
5
+ * nothing from the runtime.
6
+ */
7
+
8
+ /** A JSON object as `JSON.parse` returns it. */
9
+ export type Json = Record<string, unknown>;
10
+
11
+ /** A JSON object, and not an array or `null`. */
12
+ export function isObj(v: unknown): v is Json {
13
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
14
+ }
15
+
16
+ /**
17
+ * The time to pose a key at so that the runtime is AT it: the later of the
18
+ * file's number and that number as spine-core stores it (issue #771).
19
+ *
20
+ * 🚨 Every timeline keeps its key times in a `Float32Array`
21
+ * (`Utils.newFloatArray`), and a key time the float cannot hold exactly is
22
+ * stored at the nearest one — for `0.2`, `0.20000000298…`, which is LATER than
23
+ * the double `0.2`. Stepped to the file's own number, a timeline is then just
24
+ * BEFORE its key: before a first key it writes the setup value (`time <
25
+ * frames[0]`), and past a stepped key it still holds the one before
26
+ * (`frames[i] > time`). That is a pose one float step from the key, not the
27
+ * key's — measured on the selftest's own `ingest_probe`, whose `alpha` key at
28
+ * 0.2 posed the setup 1.0 and was refused as `the key states value 0.4`.
29
+ *
30
+ * ⚠️ The later of the two rather than `Math.fround` alone: where the float
31
+ * rounds DOWN, the file's number is already past the stored key, and a runtime
32
+ * built without typed arrays stores the double itself — in both, the file's
33
+ * number is the one at or after the key. What a key time rounds to is the
34
+ * runtime's storage and not the file's statement, so a rule judging what a KEY
35
+ * states poses at the key; the rounding itself is nothing an author can repair.
36
+ */
37
+ export function atStoredKey(time: number): number {
38
+ return Math.max(time, Math.fround(time));
39
+ }
package/src/check.ts CHANGED
@@ -878,8 +878,12 @@ export interface TextureFromReport {
878
878
  atlas: string;
879
879
  /** Every `scale:` its text declares — the line that says a pack is coarser. */
880
880
  scales: number[];
881
- /** The same for the CANDIDATE's own atlas, so the two are read side by side. */
882
- candidateScales: number[];
881
+ /**
882
+ * The same for the CANDIDATE's own atlas, so the two are read side by side —
883
+ * `null` when the candidate has no atlas beside it to read them off (issue
884
+ * #1020), which is not the same claim as declaring none.
885
+ */
886
+ candidateScales: number[] | null;
883
887
  /**
884
888
  * Regions the substituting atlas does not have, or the candidate packs rotated
885
889
  * so its own corner order cannot be inverted — see `artUvsOf`. Those pieces kept
@@ -987,7 +991,13 @@ export interface CheckReport {
987
991
 
988
992
  export interface CheckOptions {
989
993
  skeletonText: string;
990
- atlasText: string;
994
+ /**
995
+ * The candidate's atlas, or `null` when there is no file (issue #1020): a
996
+ * rigc build the core poses from a `rigc-compiled/2` document is drawn
997
+ * without one, and anything else is refused naming the file
998
+ * (`CandidateAtlasError`).
999
+ */
1000
+ atlasText: string | null;
991
1001
  /** Where the atlas's page paths resolve from. */
992
1002
  atlasDir: string;
993
1003
  framesDir: string;
@@ -1161,8 +1171,13 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1161
1171
  // The substitution is loaded before anything is posed, because posing has to
1162
1172
  // record each piece's original-art UVs for it and that is the one thing about
1163
1173
  // this measure that cannot be added afterwards — see `PieceTexture`.
1174
+ //
1175
+ // Read by rigc's own atlas reader when the core poses the candidate, and by
1176
+ // spine-core's when the runtime does (issue #1020, `SubstitutionReader`): a
1177
+ // rigc build's `--texture-from` reads nothing through the runtime, and an
1178
+ // export's reading is the one it always had.
1164
1179
  const substitution = options.textureFrom
1165
- ? textureSubstitutionFromText(options.textureFrom.atlasText, options.textureFrom.atlasDir)
1180
+ ? textureSubstitutionFromText(options.textureFrom.atlasText, options.textureFrom.atlasDir, choice.core === null ? 'spine' : 'rigc')
1166
1181
  : null;
1167
1182
  // The skin is refused here rather than deeper in the sampler, for the reason
1168
1183
  // every miss in this project is refused where the names are: the skeleton is
@@ -1600,7 +1615,11 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1600
1615
  );
1601
1616
  }
1602
1617
 
1603
- const candidateScales = atlasScales(options.atlasText);
1618
+ // `null` when the candidate has no atlas to read them off (issue #1020): the
1619
+ // model document a build is drawn from without one states where each region
1620
+ // sits, not the `scale:` line, so the report says it was not read rather than
1621
+ // that none was declared.
1622
+ const candidateScales = options.atlasText === null ? null : atlasScales(options.atlasText);
1604
1623
  if (substitution === null) {
1605
1624
  // ⭐ Named at the top of every report rather than only when it is measured.
1606
1625
  // Issue #171's finding was not that the floor was mis-measured; it was that
@@ -1610,7 +1629,13 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1610
1629
  notes.push(
1611
1630
  'part of every MAE below is TEXTURE, not animation, and it is not attributed here. The frames were rendered ' +
1612
1631
  "through the reference's own atlas; this candidate samples its own" +
1613
- `${candidateScales.length > 0 ? ` (declared at scale: ${[...new Set(candidateScales)].join(', ')})` : ''}` +
1632
+ `${
1633
+ candidateScales === null
1634
+ ? ' (its atlas is not beside it, so whether that declares a scale: line is not read — the model document does not state one)'
1635
+ : candidateScales.length > 0
1636
+ ? ` (declared at scale: ${[...new Set(candidateScales)].join(', ')})`
1637
+ : ''
1638
+ }` +
1614
1639
  ', and if the two were packed at different scales every edge of every part is filtered from a different ' +
1615
1640
  'source in every frame. That difference is a constant no key can move and is invisible to the content box, ' +
1616
1641
  'the fit residual and the whole-pixel refinement. Pass --texture-from <the atlas the frames were rendered ' +
@@ -3540,8 +3565,8 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
3540
3565
  }
3541
3566
  if (report.textureFrom) {
3542
3567
  const t = report.textureFrom;
3543
- const scale = (values: number[]): string =>
3544
- values.length === 0 ? 'no scale: line' : `scale: ${[...new Set(values)].join(', ')}`;
3568
+ const scale = (values: number[] | null): string =>
3569
+ values === null ? 'no atlas beside it to read a scale: line from' : values.length === 0 ? 'no scale: line' : `scale: ${[...new Set(values)].join(', ')}`;
3545
3570
  lines.push(
3546
3571
  ` texture from ${t.atlas} (${scale(t.scales)}) against the candidate's own (${scale(t.candidateScales)})` +
3547
3572
  `${t.unmatched.length === 0 ? '' : ` ⚠️ ${t.unmatched.length} region(s) not substituted`}`,
package/src/compile.ts CHANGED
@@ -117,6 +117,7 @@ import {
117
117
  import { evaluateDeformTransform } from './deformgen.ts';
118
118
  import { evaluateTrackDerive, TRACK_DERIVE_PROJECTIONS, type TrackDeriveMember } from './trackgen.ts';
119
119
  import {
120
+ computeExactFrameTransforms,
120
121
  computeWorldTransforms,
121
122
  cropToSpineY,
122
123
  normaliseDegrees,
@@ -2564,8 +2565,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2564
2565
  }
2565
2566
  const boneNames = new Set(bones.map((b) => b.name));
2566
2567
  let transforms: Map<string, BoneTransform>;
2568
+ // The frames a depth turn ceiling is measured in — the same bones without the
2569
+ // runtime's last bits (`computeExactFrameTransforms`, issue #1021). Nothing is
2570
+ // bound through them.
2571
+ let exactFrames: Map<string, BoneTransform>;
2567
2572
  try {
2568
2573
  transforms = computeWorldTransforms(bones);
2574
+ exactFrames = computeExactFrameTransforms(bones);
2569
2575
  } catch (err) {
2570
2576
  if (err instanceof TransformError) throw new CompileError(err.message);
2571
2577
  throw err;
@@ -2743,7 +2749,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2743
2749
 
2744
2750
  if (part) {
2745
2751
  const perSlot: Record<string, SkinTableEntry> = {};
2746
- const mesh = part.mesh ? buildMesh(part, manifest!, bones, transforms, rigSlot.bone) : null;
2752
+ const mesh = part.mesh ? buildMesh(part, manifest!, bones, transforms, exactFrames, rigSlot.bone) : null;
2747
2753
  for (const name of names) {
2748
2754
  const img = images.find((im) => im.region === name);
2749
2755
  if (!img) throw new CompileError(`internal: no image for attachment ${name}`);
@@ -2783,6 +2789,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2783
2789
  images,
2784
2790
  bones,
2785
2791
  transforms,
2792
+ exactFrames,
2786
2793
  meshBones,
2787
2794
  meshes,
2788
2795
  slotName: rigSlot.name,
@@ -3770,6 +3777,8 @@ interface AttachmentContext {
3770
3777
  images: CompiledImage[];
3771
3778
  bones: ModelBone[];
3772
3779
  transforms: Map<string, BoneTransform>;
3780
+ /** The same bones in `computeExactFrameTransforms`' frames: what a depth turn ceiling is measured in, and nothing else (issue #1021). */
3781
+ exactFrames: Map<string, BoneTransform>;
3773
3782
  meshBones: Set<string>;
3774
3783
  meshes: CompileResult['meshes'];
3775
3784
  slotName: string;
@@ -5215,9 +5224,11 @@ function buildGeneratedMesh(
5215
5224
  // refusals, because the ring's control angles need the transform and the bind
5216
5225
  // needs the bone — and the two must refuse a bone the rig lacks in the same
5217
5226
  // words whichever of them asks for it first.
5218
- const resolve = (name: string): BoneTransform => {
5227
+ // Two readings of one bone (issue #1021): the runtime's matrices to bind
5228
+ // through, and the exact frame to measure the control angles in.
5229
+ const resolve = (name: string, frames: Map<string, BoneTransform> = ctx.transforms): BoneTransform => {
5219
5230
  if (!ctx.bones.some((b) => b.name === name)) throw new CompileError(`${where}: mesh bone "${name}" is not in the rig's bone list`);
5220
- const transform = ctx.transforms.get(name);
5231
+ const transform = frames.get(name);
5221
5232
  if (!transform) throw new CompileError(`${where}: no setup transform for mesh bone "${name}"`);
5222
5233
  return transform;
5223
5234
  };
@@ -5234,7 +5245,11 @@ function buildGeneratedMesh(
5234
5245
  const anchor = ctx.transforms.get(ctx.anchorBone);
5235
5246
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
5236
5247
  const place = (px: number, py: number): [number, number] => [anchor.worldX + px - w / 2, anchor.worldY + h / 2 - py];
5237
- const toPartLocal = (wx: number, wy: number): [number, number] => [wx - anchor.worldX + w / 2, anchor.worldY + h / 2 - wy];
5248
+ // The inverse a control angle is measured through, in the exact frame: the
5249
+ // anchor's and the control's positions as the spec put them, not as the
5250
+ // runtime's last bits put them (issue #1021).
5251
+ const exactAnchor = resolve(ctx.anchorBone, ctx.exactFrames);
5252
+ const toPartLocal = (wx: number, wy: number): [number, number] => [wx - exactAnchor.worldX + w / 2, exactAnchor.worldY + h / 2 - wy];
5238
5253
  let geometry;
5239
5254
  try {
5240
5255
  geometry =
@@ -5247,7 +5262,7 @@ function buildGeneratedMesh(
5247
5262
  size: generator.size,
5248
5263
  bias: generator.bias,
5249
5264
  controlAngles: ringControlAngles(controls, generator.center, (name) => {
5250
- const transform = resolve(name);
5265
+ const transform = resolve(name, ctx.exactFrames);
5251
5266
  return toPartLocal(transform.worldX, transform.worldY);
5252
5267
  }),
5253
5268
  });
@@ -5356,7 +5371,12 @@ function sampleMeshDepth(
5356
5371
  /**
5357
5372
  * A part-local pixel to the BIND space this mesh's vertices are emitted in —
5358
5373
  * the same composition `bindWeightedVertices` applies, handed in rather than
5359
- * rebuilt so the two cannot diverge.
5374
+ * rebuilt so the two cannot diverge — taken in the slot bone's exact frame
5375
+ * (`computeExactFrameTransforms`) rather than the runtime's. The two differ
5376
+ * in the last bits only, and those bits are the runtime's π rather than the
5377
+ * mesh: an unrotated bone reads `b = −2.3e-8` there, which turns the exact
5378
+ * zero this ceiling reads as "this axis cannot fold" into a fold at
5379
+ * 89.99997° (issue #1021; `TC03` and `TC06` are that claim).
5360
5380
  *
5361
5381
  * 🚨 The ceiling has to be taken in bind space and in no other. A deform
5362
5382
  * offset is authored there, so a rotated slot bone MIXES the two axes: a
@@ -5785,7 +5805,8 @@ function buildGridAttachment(
5785
5805
  throw new CompileError(`${where}: the spec says height ${att.height} and "${att.image}" measures ${h}`);
5786
5806
  }
5787
5807
  const anchor = ctx.transforms.get(ctx.anchorBone);
5788
- if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
5808
+ const frame = ctx.exactFrames.get(ctx.anchorBone);
5809
+ if (!anchor || !frame) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
5789
5810
  if (!ctx.bones.some((b) => b.name === ctx.anchorBone)) {
5790
5811
  throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
5791
5812
  }
@@ -5800,7 +5821,7 @@ function buildGridAttachment(
5800
5821
  'grid',
5801
5822
  geometry.points,
5802
5823
  geometry.triangles,
5803
- (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
5824
+ (px, py) => toBoneLocal(frame, frame.worldX + px * toArt - w / 2, frame.worldY + h / 2 - py * toArt),
5804
5825
  // The lattice's geometry takes nothing off the texels — its window is
5805
5826
  // the plate's size, which is the atlas's own `offsets` — so a page
5806
5827
  // whose file is not its declared size costs a grid only this one
@@ -5985,7 +6006,11 @@ function buildSegmentsAttachment(
5985
6006
 
5986
6007
  // -- the placement ---------------------------------------------------------
5987
6008
  const slotBone = ctx.transforms.get(ctx.anchorBone);
5988
- if (!slotBone) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
6009
+ // The exact frame (issue #1021) is what the segments and the vertices are
6010
+ // MEASURED in — distances, falloff, `minWeight`, the shared-origin refusal —
6011
+ // and the runtime's is what the vertices are BOUND through (`place`).
6012
+ const exactSlotBone = ctx.exactFrames.get(ctx.anchorBone);
6013
+ if (!slotBone || !exactSlotBone) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
5989
6014
  let anchorAt: [number, number] = [w / 2, h / 2];
5990
6015
  if (generator.anchor !== undefined) {
5991
6016
  const stated = finitePair(generator.anchor);
@@ -5998,10 +6023,12 @@ function buildSegmentsAttachment(
5998
6023
  anchorAt = stated;
5999
6024
  }
6000
6025
  const [ax, ay] = anchorAt;
6001
- const place = (px: number, py: number): [number, number] => [
6002
- slotBone.worldX + (px - ax),
6003
- slotBone.worldY + (cropToSpineY(py, h) - cropToSpineY(ay, h)),
6026
+ const placeIn = (bone: BoneTransform) => (px: number, py: number): [number, number] => [
6027
+ bone.worldX + (px - ax),
6028
+ bone.worldY + (cropToSpineY(py, h) - cropToSpineY(ay, h)),
6004
6029
  ];
6030
+ const place = placeIn(slotBone);
6031
+ const measuredAt = placeIn(exactSlotBone);
6005
6032
 
6006
6033
  // -- the segments ----------------------------------------------------------
6007
6034
  if (!Array.isArray(generator.bones) || generator.bones.length === 0) {
@@ -6025,7 +6052,7 @@ function buildSegmentsAttachment(
6025
6052
  named.push(name);
6026
6053
  refs.push(meshBoneRef(name, where, ctx));
6027
6054
  }
6028
- return { index, transform: ctx.transforms.get(name)! };
6055
+ return { index, transform: ctx.exactFrames.get(name)! };
6029
6056
  };
6030
6057
  const tipOf = (name: string, transform: BoneTransform, at: string, chained: boolean): [number, number] => {
6031
6058
  const length = ctx.bones.find((b) => b.name === name)?.length ?? 0;
@@ -6093,7 +6120,7 @@ function buildSegmentsAttachment(
6093
6120
  `${JSON.stringify(from === null ? span.from : span.to)}; both ends are [x, y] in the part's pixels, y down`,
6094
6121
  );
6095
6122
  }
6096
- segments.push({ bone: index, a: place(from[0], from[1]), b: place(to[0], to[1]) });
6123
+ segments.push({ bone: index, a: measuredAt(from[0], from[1]), b: measuredAt(to[0], to[1]) });
6097
6124
  return;
6098
6125
  }
6099
6126
  throw new CompileError(
@@ -6105,7 +6132,7 @@ function buildSegmentsAttachment(
6105
6132
  // -- the weights -----------------------------------------------------------
6106
6133
  const falloff = { power, radius: f.radius, maxBones, minWeight };
6107
6134
  const weights: MeshVertexWeight[][] = lattice.points.map(([px, py], v) => {
6108
- const got = segmentShares(place(px, py), segments, falloff);
6135
+ const got = segmentShares(measuredAt(px, py), segments, falloff);
6109
6136
  if ('weights' in got) return got.weights;
6110
6137
  throw new CompileError(
6111
6138
  `${where}: vertex ${v}, at (${px}, ${py}) in the part's pixels, keeps no bone — the ${got.dropped.length} ` +
@@ -6284,7 +6311,8 @@ function buildContourAttachment(
6284
6311
  throw new CompileError(`${where}: the spec says height ${att.height} and "${att.image}" measures ${h}`);
6285
6312
  }
6286
6313
  const anchor = ctx.transforms.get(ctx.anchorBone);
6287
- if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
6314
+ const frame = ctx.exactFrames.get(ctx.anchorBone);
6315
+ if (!anchor || !frame) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
6288
6316
  if (!ctx.bones.some((b) => b.name === ctx.anchorBone)) {
6289
6317
  throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
6290
6318
  }
@@ -6308,7 +6336,7 @@ function buildContourAttachment(
6308
6336
  'contour',
6309
6337
  geometry.points,
6310
6338
  geometry.triangles,
6311
- (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
6339
+ (px, py) => toBoneLocal(frame, frame.worldX + px * toArt - w / 2, frame.worldY + h / 2 - py * toArt),
6312
6340
  alpha,
6313
6341
  sheetGridOf(img, plate),
6314
6342
  where,
@@ -7120,6 +7148,8 @@ function buildMesh(
7120
7148
  manifest: FaceManifest,
7121
7149
  bones: ModelBone[],
7122
7150
  transforms: Map<string, BoneTransform>,
7151
+ /** The same bones in the exact frame (`computeExactFrameTransforms`): what the control angles are measured in. */
7152
+ exactFrames: Map<string, BoneTransform>,
7123
7153
  anchorName: string,
7124
7154
  ): { attachment: ModelMeshAttachment; kind: 'ring' | 'ribbon' } {
7125
7155
  const spec = part.mesh!;
@@ -7152,9 +7182,12 @@ function buildMesh(
7152
7182
  // measures in crop pixels, y down, and Spine world is that crop flipped, so
7153
7183
  // `cropH - worldY` is the whole of it. `spec.center` is in the same crop
7154
7184
  // pixels, which is why the centre passed here is not the window-local
7155
- // `centre` two lines up.
7185
+ // `centre` two lines up. The positions are read in the exact frame
7186
+ // (issue #1021): an angle is a statement about where the spec put the
7187
+ // bone, it orders the split and refuses a tie, and the runtime's last
7188
+ // bits turned an authored 90 into 89.99999734089101 in that refusal.
7156
7189
  const controlAngles = ringControlAngles(controls, spec.center!, (name) => {
7157
- const m = transforms.get(name);
7190
+ const m = exactFrames.get(name);
7158
7191
  if (!m) throw new CompileError(`internal: no setup transform for control bone "${name}"`);
7159
7192
  return [m.worldX, cropH - m.worldY];
7160
7193
  });
@@ -7894,6 +7927,13 @@ export function deformGeometryOf(
7894
7927
  // what makes that provable — the alternative, folding it into the world path
7895
7928
  // below, would re-evaluate every existing `transform` key at world
7896
7929
  // coordinates and emit a different file for a spec nobody edited.
7930
+ //
7931
+ // 🔸 Issue #1021: this reads the runtime's frame, not the exact one. The
7932
+ // offsets are added by the runtime to exactly these numbers, so the model is
7933
+ // evaluated on what the file holds; a compiler-bound vertex therefore carries
7934
+ // the runtime's last bits (`b = −2.3e-8` on an unrotated bone) into its
7935
+ // offset, about 1e-6 on gallery/look's hair locks. Evaluating on the authored
7936
+ // positions instead would need them carried here from the binder.
7897
7937
  if (boneCounts.every((n) => n === 1) && bones.size === 1) {
7898
7938
  return { ...common, setup: bindSpace, influenceBones: null, setupWhy: null };
7899
7939
  }
package/src/core/index.ts CHANGED
@@ -154,11 +154,13 @@
154
154
  * World transforms come from the core's own evaluator, `worldTransforms` in
155
155
  * `./world.ts`, written from what a bone's fields mean and from measurement
156
156
  * against the runtime's dump; its header states each measured choice. It is
157
- * not `src/transform.ts`'s `computeWorldTransforms`, and cannot be: that is
158
- * the compiler's arithmetic, frozen so that no emitted byte moves, and called
159
- * as it is it read IDENTICAL on 1 of the 12 recipes the core poses (DIFF on
160
- * 11, worst 61 millionths against a tolerance of one — issue #925's report).
161
- * The owner's decision is recorded on issue #380. `poseSetup` takes a
157
+ * not reached through `src/transform.ts`'s `computeWorldTransforms`: that is
158
+ * the compiler's adapter over this same evaluator (issues #1015 and #1021 —
159
+ * the same arithmetic since #1021), and the core importing the compiler would
160
+ * be a cycle. The compiler's evaluator before #1015, called as it was, read
161
+ * IDENTICAL on 1 of the 12 recipes the core poses (DIFF on 11, worst 61
162
+ * millionths against a tolerance of one — issue #925's report). The owner's
163
+ * decision is recorded on issue #380. `poseSetup` takes a
162
164
  * `CorePlant` — the evaluator, the skin resolution, the colour and blend readings — so
163
165
  * the suite's plants can pass a copy of one of them; nothing else passes one.
164
166
  *
package/src/core/world.ts CHANGED
@@ -5,21 +5,22 @@
5
5
  * (`tools/pose_oracle.ts dump`, spine-core 4.3.13), and held to it by the core
6
6
  * suite's equivalence controls.
7
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.
8
+ * ⭐ **The compiler's setup transforms are this evaluator too** (issue #1015),
9
+ * under the same arithmetic (issue #1021). `src/transform.ts` held a second
10
+ * evaluator of its own until #1015, and the two differed in exactly three
11
+ * places, none of them a different matrix: the degree factor (`Math.PI / 180`
12
+ * there, the runtime's 3.1415927 here), the frame of a bone with scale 1 and
13
+ * no shear (`[cos, −sin, sin, cos]` of the rotation there, the y column at
14
+ * `rotation + 90` here), and how a radian angle is turned into degrees
15
+ * (divided by the degree factor there, multiplied by `180 / pi` here). #1015
16
+ * made them a parameter (`WorldArithmetic`) so that no byte moved; #1021
17
+ * measured which binds closer — every point the compiler binds from a world
18
+ * position, posed by spine-core from the emitted build, against that position
19
+ * — and the runtime's three put every one at the float32 floor where the
20
+ * compiler's left `gallery/look`'s up to 8.6e-5 off. So the compiler passes
21
+ * none now, and `RUNTIME_ARITHMETIC`, the measured choices below, is the only
22
+ * arithmetic anything here runs under; the parameter stays for the plants
23
+ * that swap one choice back in to show a control fire.
23
24
  *
24
25
  * ## What each measured choice is
25
26
  *
@@ -115,9 +116,9 @@ export const COLLAPSED_X_AXIS_SQ = 0.00001 * 0.00001;
115
116
 
116
117
  /**
117
118
  * 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.
119
+ * computing the same matrix (issue #1015) — see the header. The core poses and
120
+ * the compiler binds with `RUNTIME_ARITHMETIC` (issue #1021); another value is
121
+ * a plant's.
121
122
  */
122
123
  export interface WorldArithmetic {
123
124
  /** Degrees to radians: every angle is multiplied by it. */
@@ -254,8 +255,8 @@ function modeOf(bone: ModelBone): CoreInheritMode {
254
255
  * posed from those zeros like any child. `inherit` replaces the mode
255
256
  * computation — the core suite's plant passes a copy with one mode's sign
256
257
  * 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.
258
+ * caller states its own, and only a plant does: `src/transform.ts` takes the
259
+ * default since issue #1021, as every posing path does.
259
260
  */
260
261
  export function worldTransforms(
261
262
  bones: readonly ModelBone[],