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
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,
@@ -128,6 +129,8 @@ import {
128
129
  type BoneTransform,
129
130
  } from './transform.ts';
130
131
  import { emitSkeleton, PHYSICS_PARAMS, UNSTATED_REFERENCE_SCALE, type SkeletonHeader } from './emit_spine.ts';
132
+ import { curveLengthTable } from './core/constraints_path.ts';
133
+ import { frameRegionName } from './core/uvs.ts';
131
134
  import {
132
135
  isModelVertexAttachment,
133
136
  type CarriedFromCompileResult,
@@ -1979,8 +1982,9 @@ function resolveFromAtlas(
1979
1982
  * ⭐ *Either way* was not true for a pack that turns a region until issue #570:
1980
1983
  * `extractRegion` refused a `rotate: 90`, so how the art was delivered decided
1981
1984
  * whether a measurement could be taken at all — on a foreign pack, where turning
1982
- * is the norm rather than the exception. It now transcribes the runtime's own
1983
- * mapping for all four rotations, which is what makes the sentence above a
1985
+ * is the norm rather than the exception. It now lifts every rotation, measured
1986
+ * as `extractRegion` states (`PKR02` against the runtime's own sampling, `PK29`
1987
+ * against the packer's fixture), which is what makes the sentence above a
1984
1988
  * description rather than an aspiration.
1985
1989
  */
1986
1990
  function partPlate(img: CompiledImage): Plate {
@@ -2561,8 +2565,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2561
2565
  }
2562
2566
  const boneNames = new Set(bones.map((b) => b.name));
2563
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>;
2564
2572
  try {
2565
2573
  transforms = computeWorldTransforms(bones);
2574
+ exactFrames = computeExactFrameTransforms(bones);
2566
2575
  } catch (err) {
2567
2576
  if (err instanceof TransformError) throw new CompileError(err.message);
2568
2577
  throw err;
@@ -2740,7 +2749,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2740
2749
 
2741
2750
  if (part) {
2742
2751
  const perSlot: Record<string, SkinTableEntry> = {};
2743
- 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;
2744
2753
  for (const name of names) {
2745
2754
  const img = images.find((im) => im.region === name);
2746
2755
  if (!img) throw new CompileError(`internal: no image for attachment ${name}`);
@@ -2780,6 +2789,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2780
2789
  images,
2781
2790
  bones,
2782
2791
  transforms,
2792
+ exactFrames,
2783
2793
  meshBones,
2784
2794
  meshes,
2785
2795
  slotName: rigSlot.name,
@@ -3767,6 +3777,8 @@ interface AttachmentContext {
3767
3777
  images: CompiledImage[];
3768
3778
  bones: ModelBone[];
3769
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>;
3770
3782
  meshBones: Set<string>;
3771
3783
  meshes: CompileResult['meshes'];
3772
3784
  slotName: string;
@@ -3802,10 +3814,12 @@ interface AttachmentContext {
3802
3814
  * Linked meshes whose `source` is still unresolved — `resolveLinkedMeshes`
3803
3815
  * empties it once every skin has been built.
3804
3816
  *
3805
- * ⭐ Deferred for the reason spine-core defers its own (`this.linkedMeshes`,
3806
- * `SkeletonJson.js:56` filled at `:581` and drained at `:427-449`): a link may
3807
- * name a source in a skin or a slot this loop has not reached yet, so resolving
3808
- * it in place would refuse a correct rig on nothing but declaration order.
3817
+ * ⭐ Deferred because the format resolves a link only once every skin is
3818
+ * read — the reader queues each one (`this.linkedMeshes`, `SkeletonJson.js:56`
3819
+ * filled at `:581`) and resolves the queue after the skins (`:427-449`) — so
3820
+ * a link may name a source in a skin or a slot this loop has not reached yet,
3821
+ * and resolving it in place would refuse a correct rig on nothing but
3822
+ * declaration order.
3809
3823
  */
3810
3824
  links: PendingLink[];
3811
3825
  }
@@ -4195,8 +4209,11 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4195
4209
  }
4196
4210
 
4197
4211
  /**
4198
- * Cumulative arc length at the end of each curve of the chain, in world units —
4199
- * **the runtime's own measurement, restated line for line**.
4212
+ * Cumulative length at the end of each curve of the chain, in world units —
4213
+ * **the core's curve table** (`curveLengthTable` in
4214
+ * [`src/core/constraints_path.ts`](core/constraints_path.ts), issue #1015), the
4215
+ * one the core's path constraint walks at constant speed. This function only
4216
+ * flattens the chain and counts its curves.
4200
4217
  *
4201
4218
  * One entry per curve, which is what `lengths[curve]` indexes: the parser walks
4202
4219
  * curves with `if (p > lengths[curve]) continue`, and reads `lengths[curveCount]`
@@ -4206,12 +4223,16 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4206
4223
  * fact about the Bezier, it is the number the consumer of the field computes for
4207
4224
  * itself when it is not given one. `PathConstraint.computeWorldPositions`
4208
4225
  * (`PathConstraint.js:289-324` in `@esotericsoftware/spine-core` 4.3.13) measures
4209
- * a `constantSpeed` path with a cubic **forward difference** taken at `t = 1/4`
4210
- * — `0.1875 = 3t²`, `0.09375 = 6t³`, `0.75 = 3t`, `0.16666667` standing in for
4211
- * 1/6 — accumulating four `Math.sqrt` terms per curve into a running
4212
- * `pathLength`, and writing the running value into `curves[i]` at each curve's
4213
- * end. The Spine editor's exported `lengths` are that same computation, and the
4214
- * loop below is a transcription of it, not a sampler that happens to agree.
4226
+ * a `constantSpeed` path for itself, and the Spine editor's exported `lengths`
4227
+ * are the same number. What that number is was measured, not read: the core's
4228
+ * table — four forward-difference steps per curve at `t = 1/4` (`0.1875`,
4229
+ * `0.09375`, `0.75`, `0.16666667`), four `Math.sqrt` terms accumulated into a
4230
+ * running total carried across curves — is the rule its header states with the
4231
+ * readings that missed beside it, and `PS67`, `PS68` and `PS186` in
4232
+ * `selftest.ts` hold this function to `PathConstraint`'s own `curves` array read
4233
+ * off a posed skeleton, **bit for bit**. Since issue #1015 the core suite also
4234
+ * holds the swap: 100,000 seeded chains through this function and through the
4235
+ * arithmetic it replaced, 0 differing.
4215
4236
  *
4216
4237
  * 🚨 **What that computation is fed decides the number, and this comment once
4217
4238
  * claimed more than was measured** (issue #804). It said the loop matched the
@@ -4223,8 +4244,8 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4223
4244
  * vertices. rigc blends weighted vertices through its own setup transforms,
4224
4245
  * which until #804 ignored bone scale, shear and `inherit` — 2.35× the
4225
4246
  * runtime's own `curves` on a production rig's weighted path, 0.752× on a
4226
- * 50/50 probe over a bone at scale 2. `computeWorldTransforms` is now the
4227
- * runtime's, and the gap is gone on both.
4247
+ * 50/50 probe over a bone at scale 2. `computeWorldTransforms` honours all
4248
+ * three now, and the gap is gone on both.
4228
4249
  * - **Constraints.** The runtime measures the pose its update order hands the
4229
4250
  * path constraint at the first `updateWorldTransform`: a transform
4230
4251
  * constraint on the path's slot bone ordered BEFORE the path constraint
@@ -4232,11 +4253,11 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4232
4253
  * not. A production 4.2 export's four numbers are the constrained pose's to
4233
4254
  * the digit, and its unconstrained setup measures 76.08 against 76.65 on the
4234
4255
  * first curve. Reproducing that means solving every constraint type in
4235
- * update order, which is posing — and `src/compile.ts` does not link the
4236
- * runtime. ⇒ **An omitted `lengths` is measured on the unconstrained setup
4237
- * pose**, and a path whose bones a constraint moves at rest gets that
4238
- * figure. What closes the gap for an editor export is not measuring at all:
4239
- * a stated `lengths` is carried (`buildRigPath`).
4256
+ * update order, which is posing — and `src/compile.ts` does not pose. ⇒
4257
+ * **An omitted `lengths` is measured on the unconstrained setup pose**, and
4258
+ * a path whose bones a constraint moves at rest gets that figure. What
4259
+ * closes the gap for an editor export is not measuring at all: a stated
4260
+ * `lengths` is carried (`buildRigPath`).
4240
4261
  *
4241
4262
  * ⚠️ rigc measured this with a 64-chord sum until issue #560, and the comment
4242
4263
  * that stood here argued the difference was inside anything's tolerance. It was
@@ -4247,46 +4268,17 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4247
4268
  * two apart and since issue #716 rigc's own file can only where the two land on
4248
4269
  * either side of a float's boundary. Under the six-decimal rounding that stood
4249
4270
  * until then it could: on both measured rigs the two spellings differed on the
4250
- * LAST curve, where the accumulated difference is largest. `PS67`, `PS68` and `PS186` in `selftest.ts` compare
4251
- * this against `PathConstraint`'s own `curves` array read off a posed skeleton,
4252
- * which is the only oracle that can see that gap.
4253
- *
4254
- * 🔒 The transcription is deliberate down to the spelling: `Math.sqrt(dx * dx +
4255
- * dy * dy)` rather than `Math.hypot`, `0.16666667` rather than `1 / 6`, and the
4256
- * running total carried across curves rather than restarted. Each of those is a
4257
- * place where a more accurate line would emit a different file.
4271
+ * LAST curve, where the accumulated difference is largest.
4272
+ *
4273
+ * 🔒 The spelling is load-bearing: `Math.sqrt(dx * dx + dy * dy)` rather than
4274
+ * `Math.hypot`, `0.16666667` rather than `1 / 6`, and the running total carried
4275
+ * across curves rather than restarted. Each of those is a place where a more
4276
+ * accurate line would emit a different file.
4258
4277
  */
4259
4278
  export function pathCurveLengths(chain: Array<[number, number]>): number[] {
4260
- const out: number[] = [];
4261
- let total = 0;
4262
- for (let c = 0; c + 3 < chain.length; c += 3) {
4263
- const [x1, y1] = chain[c];
4264
- const [cx1, cy1] = chain[c + 1];
4265
- const [cx2, cy2] = chain[c + 2];
4266
- const [x2, y2] = chain[c + 3];
4267
- const tmpx = (x1 - cx1 * 2 + cx2) * 0.1875;
4268
- const tmpy = (y1 - cy1 * 2 + cy2) * 0.1875;
4269
- const dddfx = ((cx1 - cx2) * 3 - x1 + x2) * 0.09375;
4270
- const dddfy = ((cy1 - cy2) * 3 - y1 + y2) * 0.09375;
4271
- let ddfx = tmpx * 2 + dddfx;
4272
- let ddfy = tmpy * 2 + dddfy;
4273
- let dfx = (cx1 - x1) * 0.75 + tmpx + dddfx * 0.16666667;
4274
- let dfy = (cy1 - y1) * 0.75 + tmpy + dddfy * 0.16666667;
4275
- total += Math.sqrt(dfx * dfx + dfy * dfy);
4276
- dfx += ddfx;
4277
- dfy += ddfy;
4278
- ddfx += dddfx;
4279
- ddfy += dddfy;
4280
- total += Math.sqrt(dfx * dfx + dfy * dfy);
4281
- dfx += ddfx;
4282
- dfy += ddfy;
4283
- total += Math.sqrt(dfx * dfx + dfy * dfy);
4284
- dfx += ddfx + dddfx;
4285
- dfy += ddfy + dddfy;
4286
- total += Math.sqrt(dfx * dfx + dfy * dfy);
4287
- out.push(total);
4288
- }
4289
- return out;
4279
+ const flat: number[] = [];
4280
+ for (const [x, y] of chain) flat.push(x, y);
4281
+ return curveLengthTable(flat, Math.max(0, Math.floor((chain.length - 1) / 3)));
4290
4282
  }
4291
4283
 
4292
4284
  /**
@@ -4516,20 +4508,22 @@ function meshTextureKeys(att: { name?: string; path?: string; image?: string; co
4516
4508
  }
4517
4509
 
4518
4510
  /**
4519
- * The atlas region frame `i` of a sequence resolves to — `Sequence.getPath`
4520
- * (`Sequence.js:124-132`) transcribed: the stem, then `start + i` left-padded
4521
- * with zeros to `digits`. `start` and `digits` take the parser's own defaults
4522
- * (`readSequence`: 1 and 0), which are the format's, not a guess.
4523
- *
4524
- * ⚠️ A second transcription of the rule exists in `validate.ts`
4525
- * (`attachmentRegionLookups`, the walk `A08` joins the atlas with), and that is
4526
- * deliberate: this module links no runtime, and the two are held to the loader
4527
- * independently — the one by `A08`, both by `A00`'s round trip, which asks the
4528
- * runtime's own `getPath` for every frame.
4511
+ * The atlas region frame `i` of a sequence resolves to — the core's
4512
+ * `frameRegionName` (`src/core/uvs.ts`, issue #1015): the stem, then `start + i`
4513
+ * left-padded with zeros to `digits`. `start` and `digits` take the parser's
4514
+ * defaults when the spec leaves them out (`readSequence`: 1 and 0).
4515
+ *
4516
+ * ⚠️ The same name is derived in `validate.ts` (`attachmentRegionLookups`, the
4517
+ * walk `A08` joins the atlas with) through the same function. What holds it to
4518
+ * the loader is a measurement, not this sentence: the core suite reads
4519
+ * `Sequence.getPath` and the parser's defaults off spine-core over 7,680 cases
4520
+ * and compares this function, the core's and A08's walk with them, and `A00`'s
4521
+ * round trip asks the runtime's own `getPath` for every frame a build emits.
4522
+ *
4523
+ * Exported for the selftest alone.
4529
4524
  */
4530
- function sequenceFrameRegion(stem: string, seq: RigSequence, i: number): string {
4531
- const frame = String((seq.start ?? 1) + i);
4532
- return `${stem}${'0'.repeat(Math.max(0, (seq.digits ?? 0) - frame.length))}${frame}`;
4525
+ export function sequenceFrameRegion(stem: string, seq: RigSequence, i: number): string {
4526
+ return frameRegionName(stem, { count: seq.count, start: seq.start ?? 1, digits: seq.digits ?? 0, setup: seq.setup ?? 0 }, i);
4533
4527
  }
4534
4528
 
4535
4529
  /** The model's `sequence` record: the four fields exactly as the spec stated them. */
@@ -4796,8 +4790,8 @@ function bindNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Attac
4796
4790
  * an `image` under `--atlas-in` ENDED the build, by a refusal raised inside a
4797
4791
  * measurement, on 36 of 42 atlases of the pack the card was filed from. The
4798
4792
  * repair was to make the refusal unnecessary rather than to catch it: reading a
4799
- * turned region is a transcription of `MeshAttachment.computeUVs` and is now
4800
- * what `extractRegion` does, so there is no unmeasurable case left for this
4793
+ * turned region is now what `extractRegion` does, measured against
4794
+ * `MeshAttachment.computeUVs` (`PKR02`), so there is no unmeasurable case left for this
4801
4795
  * function to report and no catch here to keep reachable.
4802
4796
  */
4803
4797
  function measureAuthoredFit(
@@ -5230,9 +5224,11 @@ function buildGeneratedMesh(
5230
5224
  // refusals, because the ring's control angles need the transform and the bind
5231
5225
  // needs the bone — and the two must refuse a bone the rig lacks in the same
5232
5226
  // words whichever of them asks for it first.
5233
- 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 => {
5234
5230
  if (!ctx.bones.some((b) => b.name === name)) throw new CompileError(`${where}: mesh bone "${name}" is not in the rig's bone list`);
5235
- const transform = ctx.transforms.get(name);
5231
+ const transform = frames.get(name);
5236
5232
  if (!transform) throw new CompileError(`${where}: no setup transform for mesh bone "${name}"`);
5237
5233
  return transform;
5238
5234
  };
@@ -5249,7 +5245,11 @@ function buildGeneratedMesh(
5249
5245
  const anchor = ctx.transforms.get(ctx.anchorBone);
5250
5246
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
5251
5247
  const place = (px: number, py: number): [number, number] => [anchor.worldX + px - w / 2, anchor.worldY + h / 2 - py];
5252
- 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];
5253
5253
  let geometry;
5254
5254
  try {
5255
5255
  geometry =
@@ -5262,7 +5262,7 @@ function buildGeneratedMesh(
5262
5262
  size: generator.size,
5263
5263
  bias: generator.bias,
5264
5264
  controlAngles: ringControlAngles(controls, generator.center, (name) => {
5265
- const transform = resolve(name);
5265
+ const transform = resolve(name, ctx.exactFrames);
5266
5266
  return toPartLocal(transform.worldX, transform.worldY);
5267
5267
  }),
5268
5268
  });
@@ -5371,7 +5371,12 @@ function sampleMeshDepth(
5371
5371
  /**
5372
5372
  * A part-local pixel to the BIND space this mesh's vertices are emitted in —
5373
5373
  * the same composition `bindWeightedVertices` applies, handed in rather than
5374
- * 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).
5375
5380
  *
5376
5381
  * 🚨 The ceiling has to be taken in bind space and in no other. A deform
5377
5382
  * offset is authored there, so a rotated slot bone MIXES the two axes: a
@@ -5800,7 +5805,8 @@ function buildGridAttachment(
5800
5805
  throw new CompileError(`${where}: the spec says height ${att.height} and "${att.image}" measures ${h}`);
5801
5806
  }
5802
5807
  const anchor = ctx.transforms.get(ctx.anchorBone);
5803
- 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`);
5804
5810
  if (!ctx.bones.some((b) => b.name === ctx.anchorBone)) {
5805
5811
  throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
5806
5812
  }
@@ -5815,7 +5821,7 @@ function buildGridAttachment(
5815
5821
  'grid',
5816
5822
  geometry.points,
5817
5823
  geometry.triangles,
5818
- (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),
5819
5825
  // The lattice's geometry takes nothing off the texels — its window is
5820
5826
  // the plate's size, which is the atlas's own `offsets` — so a page
5821
5827
  // whose file is not its declared size costs a grid only this one
@@ -6000,7 +6006,11 @@ function buildSegmentsAttachment(
6000
6006
 
6001
6007
  // -- the placement ---------------------------------------------------------
6002
6008
  const slotBone = ctx.transforms.get(ctx.anchorBone);
6003
- 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`);
6004
6014
  let anchorAt: [number, number] = [w / 2, h / 2];
6005
6015
  if (generator.anchor !== undefined) {
6006
6016
  const stated = finitePair(generator.anchor);
@@ -6013,10 +6023,12 @@ function buildSegmentsAttachment(
6013
6023
  anchorAt = stated;
6014
6024
  }
6015
6025
  const [ax, ay] = anchorAt;
6016
- const place = (px: number, py: number): [number, number] => [
6017
- slotBone.worldX + (px - ax),
6018
- 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)),
6019
6029
  ];
6030
+ const place = placeIn(slotBone);
6031
+ const measuredAt = placeIn(exactSlotBone);
6020
6032
 
6021
6033
  // -- the segments ----------------------------------------------------------
6022
6034
  if (!Array.isArray(generator.bones) || generator.bones.length === 0) {
@@ -6040,7 +6052,7 @@ function buildSegmentsAttachment(
6040
6052
  named.push(name);
6041
6053
  refs.push(meshBoneRef(name, where, ctx));
6042
6054
  }
6043
- return { index, transform: ctx.transforms.get(name)! };
6055
+ return { index, transform: ctx.exactFrames.get(name)! };
6044
6056
  };
6045
6057
  const tipOf = (name: string, transform: BoneTransform, at: string, chained: boolean): [number, number] => {
6046
6058
  const length = ctx.bones.find((b) => b.name === name)?.length ?? 0;
@@ -6108,7 +6120,7 @@ function buildSegmentsAttachment(
6108
6120
  `${JSON.stringify(from === null ? span.from : span.to)}; both ends are [x, y] in the part's pixels, y down`,
6109
6121
  );
6110
6122
  }
6111
- 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]) });
6112
6124
  return;
6113
6125
  }
6114
6126
  throw new CompileError(
@@ -6120,7 +6132,7 @@ function buildSegmentsAttachment(
6120
6132
  // -- the weights -----------------------------------------------------------
6121
6133
  const falloff = { power, radius: f.radius, maxBones, minWeight };
6122
6134
  const weights: MeshVertexWeight[][] = lattice.points.map(([px, py], v) => {
6123
- const got = segmentShares(place(px, py), segments, falloff);
6135
+ const got = segmentShares(measuredAt(px, py), segments, falloff);
6124
6136
  if ('weights' in got) return got.weights;
6125
6137
  throw new CompileError(
6126
6138
  `${where}: vertex ${v}, at (${px}, ${py}) in the part's pixels, keeps no bone — the ${got.dropped.length} ` +
@@ -6299,7 +6311,8 @@ function buildContourAttachment(
6299
6311
  throw new CompileError(`${where}: the spec says height ${att.height} and "${att.image}" measures ${h}`);
6300
6312
  }
6301
6313
  const anchor = ctx.transforms.get(ctx.anchorBone);
6302
- 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`);
6303
6316
  if (!ctx.bones.some((b) => b.name === ctx.anchorBone)) {
6304
6317
  throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
6305
6318
  }
@@ -6323,7 +6336,7 @@ function buildContourAttachment(
6323
6336
  'contour',
6324
6337
  geometry.points,
6325
6338
  geometry.triangles,
6326
- (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),
6327
6340
  alpha,
6328
6341
  sheetGridOf(img, plate),
6329
6342
  where,
@@ -7135,6 +7148,8 @@ function buildMesh(
7135
7148
  manifest: FaceManifest,
7136
7149
  bones: ModelBone[],
7137
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>,
7138
7153
  anchorName: string,
7139
7154
  ): { attachment: ModelMeshAttachment; kind: 'ring' | 'ribbon' } {
7140
7155
  const spec = part.mesh!;
@@ -7167,9 +7182,12 @@ function buildMesh(
7167
7182
  // measures in crop pixels, y down, and Spine world is that crop flipped, so
7168
7183
  // `cropH - worldY` is the whole of it. `spec.center` is in the same crop
7169
7184
  // pixels, which is why the centre passed here is not the window-local
7170
- // `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.
7171
7189
  const controlAngles = ringControlAngles(controls, spec.center!, (name) => {
7172
- const m = transforms.get(name);
7190
+ const m = exactFrames.get(name);
7173
7191
  if (!m) throw new CompileError(`internal: no setup transform for control bone "${name}"`);
7174
7192
  return [m.worldX, cropH - m.worldY];
7175
7193
  });
@@ -7909,6 +7927,13 @@ export function deformGeometryOf(
7909
7927
  // what makes that provable — the alternative, folding it into the world path
7910
7928
  // below, would re-evaluate every existing `transform` key at world
7911
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.
7912
7937
  if (boneCounts.every((n) => n === 1) && bones.size === 1) {
7913
7938
  return { ...common, setup: bindSpace, influenceBones: null, setupWhy: null };
7914
7939
  }
@@ -44,8 +44,9 @@
44
44
  *
45
45
  * 1. **A table of the curves' lengths**, each curve four forward-difference
46
46
  * steps (`0.1875`, `0.09375`, `0.75` and `0.16666667`, the running total
47
- * carried across curves) — `pathCurveLengths` in `src/compile.ts`, the
48
- * same arithmetic. A ten-step table missed 460 of the grid's 976. The
47
+ * carried across curves) — `curveLengthTable`, which `src/compile.ts`'s
48
+ * `pathCurveLengths` also calls (issue #1015). A ten-step table missed
49
+ * 460 of the grid's 976. The
49
50
  * last entry is the path's length; `percent` multiplies the position by
50
51
  * it. The stated `lengths` are not read: the stated total in their place
51
52
  * missed 680 of 1,000.
@@ -217,6 +218,60 @@ import type { CoreCurve, CoreKey } from './animation.ts';
217
218
  import type { CoreConstraintRecord } from './constraints.ts';
218
219
 
219
220
  const RAD = RUNTIME_PI / 180;
221
+
222
+ /**
223
+ * The constant-speed walk's table of the curves' lengths — the header's *The
224
+ * walk*, step 1: each curve four forward-difference steps (`0.1875`,
225
+ * `0.09375`, `0.75`, `0.16666667`), the running total carried across curves,
226
+ * one cumulative entry per curve. `chain` is flat, `x, y` per point, knot
227
+ * handle handle knot …; `curveCount` curves are read off it.
228
+ *
229
+ * Exported because it has a second caller: an omitted `lengths` on a path
230
+ * attachment is measured by `src/compile.ts` through this function (issue
231
+ * #1015), so the number rigc writes into a file and the number the core walks
232
+ * are one computation. Measured as the header states it on the grid (a
233
+ * ten-step table missed 460 of 976) and held there by the core suite's `CP`
234
+ * controls.
235
+ */
236
+ export function curveLengthTable(chain: readonly number[], curveCount: number): number[] {
237
+ const curveEnds: number[] = [];
238
+ let total = 0;
239
+ let x1 = chain[0];
240
+ let y1 = chain[1];
241
+ for (let i = 0, w = 2; i < curveCount; i++, w += 6) {
242
+ const cx1 = chain[w];
243
+ const cy1 = chain[w + 1];
244
+ const cx2 = chain[w + 2];
245
+ const cy2 = chain[w + 3];
246
+ const x2 = chain[w + 4];
247
+ const y2 = chain[w + 5];
248
+ const h2x = (x1 - cx1 * 2 + cx2) * 0.1875;
249
+ const h2y = (y1 - cy1 * 2 + cy2) * 0.1875;
250
+ const d3x = ((cx1 - cx2) * 3 - x1 + x2) * 0.09375;
251
+ const d3y = ((cy1 - cy2) * 3 - y1 + y2) * 0.09375;
252
+ let d2x = h2x * 2 + d3x;
253
+ let d2y = h2y * 2 + d3y;
254
+ let d1x = (cx1 - x1) * 0.75 + h2x + d3x * 0.16666667;
255
+ let d1y = (cy1 - y1) * 0.75 + h2y + d3y * 0.16666667;
256
+ total += Math.sqrt(d1x * d1x + d1y * d1y);
257
+ d1x += d2x;
258
+ d1y += d2y;
259
+ d2x += d3x;
260
+ d2y += d3y;
261
+ total += Math.sqrt(d1x * d1x + d1y * d1y);
262
+ d1x += d2x;
263
+ d1y += d2y;
264
+ total += Math.sqrt(d1x * d1x + d1y * d1y);
265
+ d1x += d2x + d3x;
266
+ d1y += d2y + d3y;
267
+ total += Math.sqrt(d1x * d1x + d1y * d1y);
268
+ curveEnds.push(total);
269
+ x1 = x2;
270
+ y1 = y2;
271
+ }
272
+ return curveEnds;
273
+ }
274
+
220
275
  const EPSILON = 0.00001;
221
276
 
222
277
  /** The three modes a path constraint names, each as the model spells it once its first letter is lower-cased. */
@@ -556,47 +611,14 @@ function positionsOf(state: PathSolverState, c: CorePathRecord, g: CorePathGeome
556
611
  chain.push(...P(0), ...P(1));
557
612
  } else for (let k = 1; k < n - 1; k++) chain.push(...P(k));
558
613
  const curveCount = closed ? n / 3 : n / 3 - 1;
559
- const curveEnds: number[] = [];
560
- let total = 0;
561
- let x1 = chain[0];
562
- let y1 = chain[1];
563
- for (let i = 0, w = 2; i < curveCount; i++, w += 6) {
564
- const cx1 = chain[w];
565
- const cy1 = chain[w + 1];
566
- const cx2 = chain[w + 2];
567
- const cy2 = chain[w + 3];
568
- const x2 = chain[w + 4];
569
- const y2 = chain[w + 5];
570
- const h2x = (x1 - cx1 * 2 + cx2) * 0.1875;
571
- const h2y = (y1 - cy1 * 2 + cy2) * 0.1875;
572
- const d3x = ((cx1 - cx2) * 3 - x1 + x2) * 0.09375;
573
- const d3y = ((cy1 - cy2) * 3 - y1 + y2) * 0.09375;
574
- let d2x = h2x * 2 + d3x;
575
- let d2y = h2y * 2 + d3y;
576
- let d1x = (cx1 - x1) * 0.75 + h2x + d3x * 0.16666667;
577
- let d1y = (cy1 - y1) * 0.75 + h2y + d3y * 0.16666667;
578
- total += Math.sqrt(d1x * d1x + d1y * d1y);
579
- d1x += d2x;
580
- d1y += d2y;
581
- d2x += d3x;
582
- d2y += d3y;
583
- total += Math.sqrt(d1x * d1x + d1y * d1y);
584
- d1x += d2x;
585
- d1y += d2y;
586
- total += Math.sqrt(d1x * d1x + d1y * d1y);
587
- d1x += d2x + d3x;
588
- d1y += d2y + d3y;
589
- total += Math.sqrt(d1x * d1x + d1y * d1y);
590
- curveEnds.push(total);
591
- x1 = x2;
592
- y1 = y2;
593
- }
614
+ const curveEnds = curveLengthTable(chain, curveCount);
615
+ const total = curveEnds.length > 0 ? curveEnds[curveEnds.length - 1] : 0;
594
616
  if (c.positionMode === 'percent') position *= total;
595
617
  const spaceScale = c.spacingMode === 'percent' ? total : c.spacingMode === 'proportional' ? total / spaceCount : 1;
596
618
  const segmentEnds: number[] = new Array(10).fill(0);
597
619
  let curveTotal = 0;
598
620
  let lastCurve = -1;
599
- let cx1 = 0, cy1 = 0, cx2 = 0, cy2 = 0, x2 = 0, y2 = 0;
621
+ let x1 = 0, y1 = 0, cx1 = 0, cy1 = 0, cx2 = 0, cy2 = 0, x2 = 0, y2 = 0;
600
622
  for (let i = 0, o = 0, curve = 0, segment = 0; i < spaceCount; i++, o += 3) {
601
623
  const space = spaces[i] * spaceScale;
602
624
  position += space;
@@ -561,6 +561,27 @@ export function physicsState(ctx: PhysicsStepContext, name: string): PhysicsStat
561
561
  const PI2 = RUNTIME_PI * 2;
562
562
  const INV_PI2 = 1 / PI2;
563
563
 
564
+ /** What a physics constraint's step moves, by its five component fields. */
565
+ export interface PhysicsDrives {
566
+ x: boolean;
567
+ y: boolean;
568
+ /** `rotate` or `shearX` above 0: the two share the angular part of the step. */
569
+ rotateOrShearX: boolean;
570
+ scaleX: boolean;
571
+ }
572
+
573
+ /**
574
+ * Which parts of the step a constraint drives: a component above 0 drives its
575
+ * part and nothing else does — the test `stepPhysics` takes before each part
576
+ * of *The step* above, posed against the runtime with the rest of it. Exported
577
+ * because `src/ingest.ts` asks the same question of a raw file (a
578
+ * constraint that drives none of the four moves no bone, issue #731) and
579
+ * answers it through this function (issue #1015).
580
+ */
581
+ export function physicsDrives(r: { x: number; y: number; rotate: number; shearX: number; scaleX: number }): PhysicsDrives {
582
+ return { x: r.x > 0, y: r.y > 0, rotateOrShearX: r.rotate > 0 || r.shearX > 0, scaleX: r.scaleX > 0 };
583
+ }
584
+
564
585
  /**
565
586
  * One physics constraint updated on its bone's world transform `w`
566
587
  * (mutated), `length` the bone's length — the header's *The step*, every
@@ -572,10 +593,7 @@ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number,
572
593
  const mix = r.mix;
573
594
  if (mix === 0) return false;
574
595
  const s = physicsState(ctx, r.name);
575
- const x = r.x > 0;
576
- const y = r.y > 0;
577
- const rotateOrShearX = r.rotate > 0 || r.shearX > 0;
578
- const scaleX = r.scaleX > 0;
596
+ const { x, y, rotateOrShearX, scaleX } = physicsDrives(r);
579
597
  const l = length;
580
598
  if (ctx.phase === 'reset') resetPhysicsState(s, ctx.time);
581
599
  const delta = Math.max(ctx.time - s.lastTime, 0);