spine-rigc 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/check.ts CHANGED
@@ -69,7 +69,7 @@ import {
69
69
  BACKGROUND,
70
70
  frameGeometry,
71
71
  PROTOCOL_FPS,
72
- posableFromText,
72
+ loadCandidate,
73
73
  renderFrame,
74
74
  sampleAnimation,
75
75
  sampleSetupPose,
@@ -83,11 +83,8 @@ import {
83
83
  nonFinitePoseOf,
84
84
  unframeableSentence,
85
85
  UnframeablePoseError,
86
- skinRosterOf,
87
- candidatePosers,
88
- spinePoser,
86
+ refuseUnchosen,
89
87
  throughPoser,
90
- PoserChoiceError,
91
88
  SHEET_COLUMNS,
92
89
  SHEET_FILE,
93
90
  SHEET_GAP,
@@ -95,9 +92,10 @@ import {
95
92
  type Frame,
96
93
  type FramesSidecar,
97
94
  type FrameSet,
95
+ type MakeCorePoser,
96
+ type Posable,
98
97
  type PoseOptions,
99
98
  type Poser,
100
- type PoserChoice,
101
99
  type PoserName,
102
100
  type TextureSubstitution,
103
101
  type Viewport,
@@ -1062,22 +1060,11 @@ export interface CheckOptions {
1062
1060
  * What builds the core poser — `candidatePosers`' own `make`. The suite's
1063
1061
  * `CH01` passes a planted copy, and nothing else passes any.
1064
1062
  */
1065
- makeCorePoser?: Parameters<typeof candidatePosers>[4];
1063
+ makeCorePoser?: MakeCorePoser;
1066
1064
  }
1067
1065
 
1068
- /**
1069
- * Both posers for the candidate `check` is handed, and which one it asked for:
1070
- * `candidatePosers` over the candidate's files when the caller named them, and
1071
- * spine-core alone when it handed texts only — there is then no directory to
1072
- * find a model document in, and the reason says so.
1073
- */
1074
- function checkPoserChoice(data: Parameters<typeof spinePoser>[0], options: CheckOptions): PoserChoice {
1075
- const paths = options.candidatePaths;
1076
- if (paths !== undefined) return candidatePosers(data, paths.skeleton, paths.atlas, options.poser, options.makeCorePoser);
1077
- const why = 'the candidate was handed to check as text, with no path to find a skeleton.model.json beside';
1078
- if (options.poser === 'core') throw new PoserChoiceError(`--poser core: ${why}`);
1079
- return { forced: options.poser, core: null, why: options.poser === 'spine' ? '--poser spine' : why, spine: spinePoser(data) };
1080
- }
1066
+ /** Why a candidate handed to `check` as text alone is posed through spine-core — there is no directory to find a model document in. */
1067
+ const UNPLACED_CANDIDATE = 'the candidate was handed to check as text, with no path to find a skeleton.model.json beside';
1081
1068
 
1082
1069
  // ---------------------------------------------------------------------------
1083
1070
 
@@ -1145,14 +1132,27 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1145
1132
  }
1146
1133
  const notes: string[] = [];
1147
1134
 
1148
- const posable = posableFromText(options.skeletonText, options.atlasText, options.atlasDir);
1135
+ // Which implementation poses the candidate (issue #968), chosen once for the
1136
+ // whole run — every set, the setup pose and the non-finite sentence — so no
1137
+ // two figures in one report can come from two posers: `candidatePaths` over
1138
+ // the candidate's files, as `render` chooses, and spine-core alone when it
1139
+ // was handed texts only. Chosen BEFORE anything is read off the candidate
1140
+ // (issue #1014), so a rigc build the core poses reads its names, its stage,
1141
+ // its bone tree and its pages without loading spine-core.
1142
+ const { choice, facts, pages } = loadCandidate(
1143
+ { skeletonText: options.skeletonText, atlasText: options.atlasText, atlasDir: options.atlasDir, label: options.labels?.skeleton ?? 'the candidate' },
1144
+ options.candidatePaths ?? null,
1145
+ options.poser,
1146
+ { ...(options.makeCorePoser === undefined ? {} : { make: options.makeCorePoser }), unplaced: UNPLACED_CANDIDATE },
1147
+ );
1148
+ const posable: Pick<Posable, 'pages'> = { pages };
1149
1149
  // A candidate that declares no stage is framed exactly as one that does,
1150
1150
  // because no framing here reads a stage: the world box is fitted from what the
1151
1151
  // candidate draws. Said on the one candidate where a reader can ask what box
1152
1152
  // stood in for the absent one (issue #714) — `SkeletonJson` copies the header
1153
1153
  // fields across unconditionally (`SkeletonJson.js:70-73`), so an omitted
1154
- // extent is `undefined` here, not 0.
1155
- if (typeof posable.data.width !== 'number' || typeof posable.data.height !== 'number') {
1154
+ // extent is `undefined` there, not 0, and the header says the same.
1155
+ if (!facts.declaresStage) {
1156
1156
  notes.push(
1157
1157
  'the candidate declares no stage (no `skeleton.width`/`height`), and nothing stands in for one: its world ' +
1158
1158
  'box is fitted from the pixels it draws, as it is for every candidate, so the absence moves no figure below.',
@@ -1167,18 +1167,16 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1167
1167
  // The skin is refused here rather than deeper in the sampler, for the reason
1168
1168
  // every miss in this project is refused where the names are: the skeleton is
1169
1169
  // open on this line and the alternatives can be listed.
1170
- if (options.skin !== undefined && !posable.data.skins.some((s) => s.name === options.skin)) {
1170
+ if (options.skin !== undefined && !facts.skins.includes(options.skin)) {
1171
1171
  throw new CheckError(
1172
1172
  `the candidate declares no skin ${JSON.stringify(options.skin)}; it declares [${
1173
- posable.data.skins.map((s) => s.name).join(', ') || 'none'
1173
+ facts.skins.join(', ') || 'none'
1174
1174
  }]`,
1175
1175
  );
1176
1176
  }
1177
- // Which implementation poses the candidate (issue #968), chosen once for the
1178
- // whole run — every set, the setup pose and the non-finite sentence — so no
1179
- // two figures in one report can come from two posers. `--poser core` on an
1180
- // input that cannot carry it is refused here, before anything is posed.
1181
- const choice = checkPoserChoice(posable.data, options);
1177
+ // `--poser core` on an input that cannot carry it is refused here, before
1178
+ // anything is posed.
1179
+ refuseUnchosen(choice);
1182
1180
  const poseOptions: PoseOptions | undefined =
1183
1181
  substitution || options.skin !== undefined
1184
1182
  ? { ...(substitution ? { texture: true } : {}), ...(options.skin === undefined ? {} : { skin: options.skin }) }
@@ -1220,7 +1218,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1220
1218
  sets = [
1221
1219
  {
1222
1220
  dir,
1223
- animation: posable.data.animations.length === 0 ? null : animation,
1221
+ animation: facts.animations.length === 0 ? null : animation,
1224
1222
  fps,
1225
1223
  sampled: disk[disk.length - 1].index + 1,
1226
1224
  written: disk.length,
@@ -1292,8 +1290,8 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1292
1290
  // poser runs — the model document lists the spec's order, and a refusal that
1293
1291
  // listed the names in another order would be a byte of the report that
1294
1292
  // depends on the poser.
1295
- const have = posable.data.animations.map((a) => a.name);
1296
- const posing = throughPoser(choice, (poser) => {
1293
+ const have = [...facts.animations];
1294
+ const posing = throughPoser(choice, (poser, roster) => {
1297
1295
  const sampled = sets.map((set) => prepareSet(located.root, set, poser, have, options.as, poseOptions));
1298
1296
  // 🔒 A pose that is not finite is refused before anything measures it (issue
1299
1297
  // #873), in the words `render` and the geometry export use for it. Measured
@@ -1325,7 +1323,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1325
1323
  posedSets.map((p) => p.frames),
1326
1324
  poser.slots,
1327
1325
  options.skin,
1328
- skinRosterOf(posable.data),
1326
+ roster,
1329
1327
  );
1330
1328
  if (unframeable !== null) throw new UnframeablePoseError(unframeable);
1331
1329
  return sampled;
@@ -1433,7 +1431,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1433
1431
  throw new CheckError(
1434
1432
  `no reference frame could be compared, so there is nothing to frame against${nothingToFrameWhy(
1435
1433
  prepared,
1436
- posable.data.animations.map((a) => a.name),
1434
+ [...facts.animations],
1437
1435
  options.as,
1438
1436
  located.sidecar !== null,
1439
1437
  )}`,
@@ -1574,8 +1572,8 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1574
1572
  // `src/chains.ts`. Reading the CANDIDATE's tree is what keeps this on the right
1575
1573
  // side of the honesty rule: the reference is still nothing but pixels.
1576
1574
  const chains = chainsOf(
1577
- posable.data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
1578
- posable.data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
1575
+ facts.bones.map(({ name, parent }) => ({ name, parent })),
1576
+ facts.slots.map(({ name, bone }) => ({ name, bone })),
1579
1577
  );
1580
1578
  const chainOfSlot = new Map<string, number>();
1581
1579
  chains.forEach((chain, index) => {
@@ -2629,7 +2627,7 @@ function nothingToFrameWhy(
2629
2627
  function checkOneSet(
2630
2628
  root: string,
2631
2629
  prepared: PreparedSet,
2632
- posable: ReturnType<typeof posableFromText>,
2630
+ posable: Pick<Posable, 'pages'>,
2633
2631
  framing: SetFraming,
2634
2632
  background: RGBA,
2635
2633
  chains: BoneChain[],
@@ -3310,7 +3308,7 @@ function labelBox(index: number): { width: number; height: number } {
3310
3308
  function checkAgainstSheet(
3311
3309
  root: string,
3312
3310
  prepared: PreparedSet,
3313
- posable: ReturnType<typeof posableFromText>,
3311
+ posable: Pick<Posable, 'pages'>,
3314
3312
  viewport: Viewport,
3315
3313
  background: RGBA,
3316
3314
  ): { sheet: SheetCheck | null; notes: string[] } {
package/src/compile.ts CHANGED
@@ -128,6 +128,8 @@ import {
128
128
  type BoneTransform,
129
129
  } from './transform.ts';
130
130
  import { emitSkeleton, PHYSICS_PARAMS, UNSTATED_REFERENCE_SCALE, type SkeletonHeader } from './emit_spine.ts';
131
+ import { curveLengthTable } from './core/constraints_path.ts';
132
+ import { frameRegionName } from './core/uvs.ts';
131
133
  import {
132
134
  isModelVertexAttachment,
133
135
  type CarriedFromCompileResult,
@@ -1979,8 +1981,9 @@ function resolveFromAtlas(
1979
1981
  * ⭐ *Either way* was not true for a pack that turns a region until issue #570:
1980
1982
  * `extractRegion` refused a `rotate: 90`, so how the art was delivered decided
1981
1983
  * 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
1984
+ * is the norm rather than the exception. It now lifts every rotation, measured
1985
+ * as `extractRegion` states (`PKR02` against the runtime's own sampling, `PK29`
1986
+ * against the packer's fixture), which is what makes the sentence above a
1984
1987
  * description rather than an aspiration.
1985
1988
  */
1986
1989
  function partPlate(img: CompiledImage): Plate {
@@ -3802,10 +3805,12 @@ interface AttachmentContext {
3802
3805
  * Linked meshes whose `source` is still unresolved — `resolveLinkedMeshes`
3803
3806
  * empties it once every skin has been built.
3804
3807
  *
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.
3808
+ * ⭐ Deferred because the format resolves a link only once every skin is
3809
+ * read — the reader queues each one (`this.linkedMeshes`, `SkeletonJson.js:56`
3810
+ * filled at `:581`) and resolves the queue after the skins (`:427-449`) — so
3811
+ * a link may name a source in a skin or a slot this loop has not reached yet,
3812
+ * and resolving it in place would refuse a correct rig on nothing but
3813
+ * declaration order.
3809
3814
  */
3810
3815
  links: PendingLink[];
3811
3816
  }
@@ -4195,8 +4200,11 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4195
4200
  }
4196
4201
 
4197
4202
  /**
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**.
4203
+ * Cumulative length at the end of each curve of the chain, in world units —
4204
+ * **the core's curve table** (`curveLengthTable` in
4205
+ * [`src/core/constraints_path.ts`](core/constraints_path.ts), issue #1015), the
4206
+ * one the core's path constraint walks at constant speed. This function only
4207
+ * flattens the chain and counts its curves.
4200
4208
  *
4201
4209
  * One entry per curve, which is what `lengths[curve]` indexes: the parser walks
4202
4210
  * curves with `if (p > lengths[curve]) continue`, and reads `lengths[curveCount]`
@@ -4206,12 +4214,16 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4206
4214
  * fact about the Bezier, it is the number the consumer of the field computes for
4207
4215
  * itself when it is not given one. `PathConstraint.computeWorldPositions`
4208
4216
  * (`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.
4217
+ * a `constantSpeed` path for itself, and the Spine editor's exported `lengths`
4218
+ * are the same number. What that number is was measured, not read: the core's
4219
+ * table — four forward-difference steps per curve at `t = 1/4` (`0.1875`,
4220
+ * `0.09375`, `0.75`, `0.16666667`), four `Math.sqrt` terms accumulated into a
4221
+ * running total carried across curves — is the rule its header states with the
4222
+ * readings that missed beside it, and `PS67`, `PS68` and `PS186` in
4223
+ * `selftest.ts` hold this function to `PathConstraint`'s own `curves` array read
4224
+ * off a posed skeleton, **bit for bit**. Since issue #1015 the core suite also
4225
+ * holds the swap: 100,000 seeded chains through this function and through the
4226
+ * arithmetic it replaced, 0 differing.
4215
4227
  *
4216
4228
  * 🚨 **What that computation is fed decides the number, and this comment once
4217
4229
  * claimed more than was measured** (issue #804). It said the loop matched the
@@ -4223,8 +4235,8 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4223
4235
  * vertices. rigc blends weighted vertices through its own setup transforms,
4224
4236
  * which until #804 ignored bone scale, shear and `inherit` — 2.35× the
4225
4237
  * 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.
4238
+ * 50/50 probe over a bone at scale 2. `computeWorldTransforms` honours all
4239
+ * three now, and the gap is gone on both.
4228
4240
  * - **Constraints.** The runtime measures the pose its update order hands the
4229
4241
  * path constraint at the first `updateWorldTransform`: a transform
4230
4242
  * constraint on the path's slot bone ordered BEFORE the path constraint
@@ -4232,11 +4244,11 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4232
4244
  * not. A production 4.2 export's four numbers are the constrained pose's to
4233
4245
  * the digit, and its unconstrained setup measures 76.08 against 76.65 on the
4234
4246
  * 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`).
4247
+ * update order, which is posing — and `src/compile.ts` does not pose. ⇒
4248
+ * **An omitted `lengths` is measured on the unconstrained setup pose**, and
4249
+ * a path whose bones a constraint moves at rest gets that figure. What
4250
+ * closes the gap for an editor export is not measuring at all: a stated
4251
+ * `lengths` is carried (`buildRigPath`).
4240
4252
  *
4241
4253
  * ⚠️ rigc measured this with a 64-chord sum until issue #560, and the comment
4242
4254
  * that stood here argued the difference was inside anything's tolerance. It was
@@ -4247,46 +4259,17 @@ export function pathChain(points: Array<[number, number]>, closed: boolean): Arr
4247
4259
  * two apart and since issue #716 rigc's own file can only where the two land on
4248
4260
  * either side of a float's boundary. Under the six-decimal rounding that stood
4249
4261
  * 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.
4262
+ * LAST curve, where the accumulated difference is largest.
4263
+ *
4264
+ * 🔒 The spelling is load-bearing: `Math.sqrt(dx * dx + dy * dy)` rather than
4265
+ * `Math.hypot`, `0.16666667` rather than `1 / 6`, and the running total carried
4266
+ * across curves rather than restarted. Each of those is a place where a more
4267
+ * accurate line would emit a different file.
4258
4268
  */
4259
4269
  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;
4270
+ const flat: number[] = [];
4271
+ for (const [x, y] of chain) flat.push(x, y);
4272
+ return curveLengthTable(flat, Math.max(0, Math.floor((chain.length - 1) / 3)));
4290
4273
  }
4291
4274
 
4292
4275
  /**
@@ -4516,20 +4499,22 @@ function meshTextureKeys(att: { name?: string; path?: string; image?: string; co
4516
4499
  }
4517
4500
 
4518
4501
  /**
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.
4502
+ * The atlas region frame `i` of a sequence resolves to — the core's
4503
+ * `frameRegionName` (`src/core/uvs.ts`, issue #1015): the stem, then `start + i`
4504
+ * left-padded with zeros to `digits`. `start` and `digits` take the parser's
4505
+ * defaults when the spec leaves them out (`readSequence`: 1 and 0).
4506
+ *
4507
+ * ⚠️ The same name is derived in `validate.ts` (`attachmentRegionLookups`, the
4508
+ * walk `A08` joins the atlas with) through the same function. What holds it to
4509
+ * the loader is a measurement, not this sentence: the core suite reads
4510
+ * `Sequence.getPath` and the parser's defaults off spine-core over 7,680 cases
4511
+ * and compares this function, the core's and A08's walk with them, and `A00`'s
4512
+ * round trip asks the runtime's own `getPath` for every frame a build emits.
4513
+ *
4514
+ * Exported for the selftest alone.
4529
4515
  */
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}`;
4516
+ export function sequenceFrameRegion(stem: string, seq: RigSequence, i: number): string {
4517
+ return frameRegionName(stem, { count: seq.count, start: seq.start ?? 1, digits: seq.digits ?? 0, setup: seq.setup ?? 0 }, i);
4533
4518
  }
4534
4519
 
4535
4520
  /** The model's `sequence` record: the four fields exactly as the spec stated them. */
@@ -4796,8 +4781,8 @@ function bindNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Attac
4796
4781
  * an `image` under `--atlas-in` ENDED the build, by a refusal raised inside a
4797
4782
  * measurement, on 36 of 42 atlases of the pack the card was filed from. The
4798
4783
  * 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
4784
+ * turned region is now what `extractRegion` does, measured against
4785
+ * `MeshAttachment.computeUVs` (`PKR02`), so there is no unmeasurable case left for this
4801
4786
  * function to report and no catch here to keep reachable.
4802
4787
  */
4803
4788
  function measureAuthoredFit(
@@ -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);