rig-c 3.2.0 → 3.2.1

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/docs/AUTHORING.md CHANGED
@@ -389,7 +389,7 @@ What the flags mean:
389
389
  | `--sheet-format` | `render --sheet` only: `json-hash` (default, TexturePacker JSON Hash with `frames`, `animations` and `meta`, verified against `pixi.js@8.22.0` and `phaser@3.90.0` by `tools/sheet_readers.ts`). Refused without `--sheet` or on an unknown value, exit 2, nothing written — **§8.10** |
390
390
  | `--viewport`, `--viewport-from` | `render`: give the box every frame is drawn over instead of fitting one — `--viewport <x,y,w,h>`, a world box, y up, `x,y` its bottom-left corner, at the pixel size `--max` gives; or `--viewport-from <frames.json \| dir>`, an earlier render's grid exactly (its box, scale and pixel size), so two candidates land on one pixel grid. A slot that reaches past the box is named by set and frame, and `frames.json` records the box was given under `pinned`. The two together are refused, and so is `--max` beside `--viewport-from` — **§8.6**. On `check` and `inspect`, `--viewport` pins the box those commands draw over instead |
391
391
  | `--environment` | `render` only: the wind and gravity the rig's physics constraints answer, in time — a schedule file `{ "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }, …] }`, x right and **y down**, each step holding from its `t` until the next. Both posers set each change before the step that poses the first frame at or after its `t`, and draw the same frames, `geometry.json` and `frames.json` (`RE01`). `frames.json` and `geometry.json` record it, and the framing reads it. A malformed file, a half no physics constraint answers, two steps on one frame and a step after every set's last frame are refused by name, exit 2, nothing written; `check` refuses a frame set drawn under one as a reference. Without it every file is the bytes it always was. An inspection of the response the rig states, not a scene — **§8.4** |
392
- | `--preroll` | `render` only: `<n>`, a whole number of 1 or more — walk each set `n` whole loops on a **looping** track before the loop recorded, drawn by no frame, the physics carried across every wrap as a player's looping track carries it, so a spring that converges is warm on the first frame. The walk lands on track time `n · duration` exactly, where the track has wrapped `n` times, so frame `i` is the pose at animation time `i / fps` — the time it is filed under — whether or not the duration is a whole number of frames (`PRE06`). Both posers walk it alike and write the same frames, `geometry.json`, sheet and `frames.json` (`PRE01`). `frames.json`, `geometry.json` and `sheet-manifest.json` record it as `preroll`, a sheet's namespace ends `/preroll=<n>`, and the framing reads it. A value that is not a whole number of 1 or more, a walk past what a step list holds, a skeleton with no animation or no physics constraint, a set of 0 frames at the rate, and `--environment` or `--parallax` beside it are refused by name, exit 2, nothing written; `check` refuses a frame set drawn after one as a reference. Without it every file is the bytes it always was — **§8.13** |
392
+ | `--preroll` | `render` only: `<n>`, a whole number of 1 or more — walk each set `n` whole loops on a **looping** track before the loop recorded, drawn by no frame, the physics carried across every wrap as a player's looping track carries it, so a spring that converges is warm on the first frame. The walk lands on track time `n · duration` exactly, where the track has wrapped `n` times, while its physics clock lands on the frame grid point nearest it, so the loop recorded is loop `n + 1` of a plain looping walk to rounding even where the duration is a float32 a hair off a whole number of frames (`PRE07`); so frame `i` is the pose at animation time `i / fps` — the time it is filed under — whether or not the duration is a whole number of frames (`PRE06`). Both posers walk it alike and write the same frames, `geometry.json`, sheet and `frames.json` (`PRE01`). `frames.json`, `geometry.json` and `sheet-manifest.json` record it as `preroll`, a sheet's namespace ends `/preroll=<n>`, and the framing reads it. A value that is not a whole number of 1 or more, a walk past what a step list holds, a skeleton with no animation or no physics constraint, a set of 0 frames at the rate, and `--environment` or `--parallax` beside it are refused by name, exit 2, nothing written; `check` refuses a frame set drawn after one as a reference. Without it every file is the bytes it always was — **§8.13** |
393
393
  | `--poser` | `render`, `inspect`, `check` and `explain`: `core` or `spine` — on `render`, `inspect` and `check`, which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes; its atlas, when one is named or present, the one beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`, `RC34`). On the entry that links none of spine-core — the installed `rigc` without the runtime beside it, `bun cli_core.ts` in a clone — that refusal ends on the route to the entry that does (``the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts` ``); a runtime that is installed and fails to load is refused without it, since installing it is not what that reader has to do. On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1), ending on the same route where the entry links none of it |
394
394
  | `--lever-fold` | `explain` only: a fraction above 0 and at most 1. Also read, on the turn lines' walk, the steps where a physics constraint that drives `scaleX` has its inertia lever divide by less than this fraction of the divisor it takes at the setup pose, and the steps where the scale factor it applies moves by more than the bone's setup scale. **No default**: without it the reading is not taken and one line says so; a value that is not such a fraction is refused by name (exit 2). A reading, not a gate — **§4.6.2** |
395
395
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
@@ -9844,9 +9844,9 @@ first, drawn by no frame, then records the next loop:
9844
9844
  - **The walk.** `count` is the set's frame count at the rate,
9845
9845
  `round(duration · fps)`. From the same reset at frame 0 a render from rest
9846
9846
  takes, the track steps the render's grid `j / fps` up to the last point before
9847
- track time `n · duration`, takes one shorter step onto it, then the `count`
9847
+ track time `n · duration`, takes one step onto it, then the `count`
9848
9848
  steps of the loop recorded, `n · duration + i / fps` — on the render's own
9849
- physics clock (§4.6) — and its animation time is the track time wrapped by the
9849
+ physics clock (§4.6), save the step onto the wrap (below) — and its animation time is the track time wrapped by the
9850
9850
  duration, what spine-core's `setAnimation(0, name, true)` does and what the
9851
9851
  core's live track does looping (`openTrack({ loop: true })`). Both posers add
9852
9852
  the steps up the same way, and every one before the wrap subtracts without
@@ -9865,6 +9865,35 @@ first, drawn by no frame, then records the next loop:
9865
9865
  float and the wrap is hit exactly; for a duration that is not, where the
9866
9866
  product rounds below the wrap — the loop's end rather than its start — the
9867
9867
  walk lands one float past it (`prerollStart`).
9868
+ - **The physics clock lands on the grid, not on the wrap** (issue #1687). The
9869
+ track's step onto the wrap is a frame only where the duration is exactly
9870
+ `count / fps`. A duration read as a float32 a hair below it — 8/3 s is read as
9871
+ 2.6666665 s, `count / fps` less `δ` = 1.6e-7 s — makes that step a frame short
9872
+ by `n · δ`, far more than the render's clock lead has gathered by then (3.2e-7
9873
+ s against 5.0e-9 s at 2 loops at 12 fps); a physics constraint takes only the
9874
+ whole steps that fit, so the clock moved by it dropped one physics step at the
9875
+ landing, and the loop recorded started a fresh transient instead of continuing
9876
+ the settled cycle. So every step of the walk moves the physics clock by its own
9877
+ length except the step onto the wrap, which moves it to the grid point nearest
9878
+ the wrap: a whole frame where the wrap sits just short of one, nothing where it
9879
+ sits just past one and the step onto it is the sliver beyond. The loop recorded
9880
+ is then loop `n + 1` of a plain looping walk on the uniform grid, its physics
9881
+ clock off that walk's by rounding alone, and frame 0 is still the wrap — the
9882
+ keyed bones re-phased onto it by `n · δ`. Where the duration is exactly `count
9883
+ / fps` the step onto the wrap is a frame and nothing moves; on a loop of no
9884
+ whole frame count the physics stays on the frame grid the same way and the
9885
+ keyed bones are re-phased by under half a frame. On the selftest's
9886
+ `preroll_probe` — four springs in a chain under loops read just short of 32
9887
+ and 80 frames and just past 48 — the short step moved the springs 26.4 units
9888
+ off the plain walk's loop on a stepped idle and 37.6 on an eased one at 2 loops
9889
+ and 12 fps; now, on the stepped loops at pre-rolls of 2 and 10 on both posers,
9890
+ every keyed bone is the plain walk's to the bit and every spring is within
9891
+ what one more clock lead moves it (`PRE07`, at most 2.2e-5 of it); restoring
9892
+ the short step on the core is red there on every case, by name (`PRE09`). On
9893
+ the eased loop the springs sit 5.3e-5 units off at 2 loops and 2.6e-4 at 10,
9894
+ linear in `n · δ`: that is the re-phase, which frame 0 being the wrap is. After
9895
+ 10 loops every spring's seam closes within its largest step on both posers,
9896
+ where the short step opened four of five sets (`PRE08`).
9868
9897
  - **What moves.** Only what carries from one step to the next: the physics. A
9869
9898
  keyed bone is posed at the loop time the render from rest draws it at, to the
9870
9899
  rounding of the steps' sums — on the skirt fixture's hip, measured on this walk
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-c",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/render.ts CHANGED
@@ -503,7 +503,7 @@ export function spinePoser(data: SkeletonData): Poser {
503
503
  slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
504
504
  subset: (opts, skin) => slotSubsetOf(data, opts, skin),
505
505
  setup: (skin) => spinePosed(setupPosed(data, skin)),
506
- animation: (name, skin, fps, count, visit, environment, _channels, times, loop) => {
506
+ animation: (name, skin, fps, count, visit, environment, _channels, times, loop, clockSteps) => {
507
507
  const skeleton = skeletonUnderSkin(data, skin);
508
508
  const state = new AnimationState(new AnimationStateData(data));
509
509
  // Not looping: the last frame sits at the animation's duration, and a looping
@@ -522,7 +522,8 @@ export function spinePoser(data: SkeletonData): Poser {
522
522
  state.update(delta);
523
523
  state.apply(skeleton);
524
524
  setSkeletonEnvironment(skeleton, environment?.(i));
525
- skeleton.update(times === undefined ? clock : physicsClockStep(delta));
525
+ // `clockSteps` (issue #1687, `render --preroll`): what the step moves the physics clock by, where that is not its own length.
526
+ skeleton.update(times === undefined ? clock : physicsClockStep(clockSteps?.[i - 1] ?? delta));
526
527
  skeleton.updateWorldTransform(Physics.update);
527
528
  } else {
528
529
  state.apply(skeleton);
@@ -367,18 +367,35 @@ export function keptPieceArrays(): PieceArrays {
367
367
  * one pose is held at a time rather than the animation's whole series. A plant
368
368
  * passes another (`RC44`).
369
369
  */
370
- export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, environment?: StepEnvironment, loop?: boolean) => void;
370
+ export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, environment?: StepEnvironment, loop?: boolean, clock?: (dt: number) => number) => void;
371
371
 
372
372
  /**
373
373
  * The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next, the
374
374
  * physics clock moved by `physicsClockStep` (issue #1380), and `render --environment`'s change for each pose set before it
375
375
  * is posed (issue #1416, `StepEnvironment`). `loop` (issue #1684, `render --preroll`) walks the track looping
376
376
  * (`poseRawLoopEach`) — spine-core's poser sets the same track looping — and absent, the walk is the one it always was.
377
+ * `clock` (issue #1687) is what each step moves the physics clock by in place of `physicsClockStep` of the step — the
378
+ * pre-roll's walk passes one that reads its own step list (`Poser.animation`'s `clockSteps`) — and absent, the clock is
379
+ * the render's, `physicsClockStep`.
377
380
  */
378
- export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment, loop) =>
381
+ export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment, loop, clock = physicsClockStep) =>
379
382
  loop === true
380
- ? poseRawLoopEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep, environment)
381
- : poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep, environment);
383
+ ? poseRawLoopEach(doc, animation, steps, plant, 'animation', visit, clock, environment)
384
+ : poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, clock, environment);
385
+
386
+ /**
387
+ * The physics clock a walk moves by `clockSteps` (issue #1687, `Poser.animation`): the walk asks its clock once for
388
+ * the reset pose — a step of 0, which moves it by `physicsClockStep(0)`, nothing, as spine-core's `update(0)` at frame
389
+ * 0 does — and then once per step in order, and step `i` moves it by `physicsClockStep(clockSteps[i])` in place of
390
+ * `physicsClockStep` of the step's own length. One clock serves one walk: it counts the steps it was asked for.
391
+ */
392
+ function steppedClock(clockSteps: readonly number[]): (dt: number) => number {
393
+ let next = -1;
394
+ return (dt) => {
395
+ const i = next++;
396
+ return physicsClockStep(i < 0 ? dt : clockSteps[i]);
397
+ };
398
+ }
382
399
 
383
400
  /**
384
401
  * The render's physics clock (issue #1380): defined beside the walk that takes it (`./core/raw.ts`), where the mesh
@@ -794,7 +811,7 @@ export function corePoser(
794
811
  const pose = poseRawSetup(input.doc, { through, ...observe });
795
812
  return corePosed(input, pose, through, [], values);
796
813
  },
797
- animation: (name, skin, fps, count, visit, environment, channels, times, loop) => {
814
+ animation: (name, skin, fps, count, visit, environment, channels, times, loop, clockSteps) => {
798
815
  let values: ChannelValue[] | undefined;
799
816
  const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
800
817
  const input = inputOf(skin);
@@ -814,6 +831,7 @@ export function corePoser(
814
831
  if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
815
832
  return moved;
816
833
  };
834
+ const clock = clockSteps === undefined ? undefined : steppedClock(clockSteps);
817
835
  walk(input.doc, name, times === undefined ? new Array<number>(count).fill(step) : times.slice(1).map((t, i) => t - times[i]), { through, physicsStep, ...observe }, (pose, i) => {
818
836
  const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
819
837
  steppedNow.clear();
@@ -823,7 +841,7 @@ export function corePoser(
823
841
  } catch (thrown) {
824
842
  drawFailed.push(thrown);
825
843
  }
826
- }, environment, loop);
844
+ }, environment, loop, clock);
827
845
  if (drawFailed.length > 0) throw drawFailed[0];
828
846
  },
829
847
  rest: (skin, shown) => {
@@ -1198,8 +1198,14 @@ export interface Poser {
1198
1198
  * animation time the track time wrapped by the duration, the physics carried
1199
1199
  * across every wrap, as a player's looping track carries it; absent, the
1200
1200
  * track holds at its duration as it always did.
1201
+ * `clockSteps` (issue #1687, `render --preroll`), beside `times`, is what
1202
+ * each step moves the physics clock by before the lead: step `i` (from 1)
1203
+ * moves it by `physicsClockStep(clockSteps[i - 1])` in place of
1204
+ * `physicsClockStep(times[i] - times[i - 1])`, while the track time still
1205
+ * follows `times` (`prerollTimes` says why the two part). Absent, every
1206
+ * step moves the physics clock by its own length, as it always did.
1201
1207
  */
1202
- animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[], loop?: boolean): void;
1208
+ animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[], loop?: boolean, clockSteps?: readonly number[]): void;
1203
1209
  /** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
1204
1210
  rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
1205
1211
  }
@@ -1280,11 +1286,11 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
1280
1286
  if (!Number.isInteger(preroll) || preroll < 1) throw new Error(`preroll is ${String(preroll)}, not a whole number of loops at or above 1`);
1281
1287
  if (opts?.environment !== undefined) throw new Error('preroll and environment were both given: nothing settles which clock the schedule is read on under a pre-roll');
1282
1288
  // The track loops; every pose before track time `preroll · duration` is walked and drawn by no frame (issue #1684).
1283
- const { times, first } = prerollTimes(animation.duration, fps, preroll);
1289
+ const { times, first, clock } = prerollTimes(animation.duration, fps, preroll);
1284
1290
  const recorded = (i: number, posed: Posed): void => {
1285
1291
  if (i >= first) frames.push(frameOf(i - first, (i - first) * step, posed, draw(), opts));
1286
1292
  };
1287
- poser.animation(name, opts?.skin, fps, times.length - 1, recorded, undefined, opts?.channels === true ? true : undefined, times, true);
1293
+ poser.animation(name, opts?.skin, fps, times.length - 1, recorded, undefined, opts?.channels === true ? true : undefined, times, true, clock);
1288
1294
  return frames;
1289
1295
  }
1290
1296
  const visit = (i: number, posed: Posed): void => {
@@ -1338,13 +1344,14 @@ export function prerollWarmPoints(start: number, fps: number): number {
1338
1344
  }
1339
1345
 
1340
1346
  /**
1341
- * The track times a pre-roll of `preroll` loops walks, for `Poser.animation`'s
1342
- * `times` (issue #1684): the render's grid `j / fps` up to the last point
1343
- * before `prerollStart`, one shorter step onto it, then the loop recorded,
1344
- * `start + i / fps` for `i = 1..count`. `first` is the index of `start` — the
1345
- * pose frame 0 is.
1347
+ * The walk a pre-roll of `preroll` loops takes, for `Poser.animation`'s `times`
1348
+ * and `clockSteps` (issues #1684, #1687): the track times — the render's grid
1349
+ * `j / fps` up to the last point before `prerollStart`, one step onto it, then
1350
+ * the loop recorded, `start + i / fps` for `i = 1..count` — and what each step
1351
+ * moves the physics clock by. `first` is the index of `start`, the pose frame 0
1352
+ * is.
1346
1353
  *
1347
- * ⭐ **The walk lands on `start` exactly.** Both posers step a track by the
1354
+ * ⭐ **The track lands on `start` exactly.** Both posers step a track by the
1348
1355
  * differences of `times` and add them up (`trackTime += delta`), and every
1349
1356
  * difference before `start` is exact — two floats within a factor of two of
1350
1357
  * each other subtract without rounding — so each running sum is the time it
@@ -1352,15 +1359,48 @@ export function prerollWarmPoints(start: number, fps: number): number {
1352
1359
  * `preroll · count` steps instead lands on `preroll · count / fps`, which is
1353
1360
  * the wrap only where `duration · fps` is whole: elsewhere the loop recorded
1354
1361
  * started at the wrong phase while frame 0 was filed at time 0.
1355
- */
1356
- export function prerollTimes(duration: number, fps: number, preroll: number): { times: number[]; first: number } {
1362
+ *
1363
+ * ⭐ **The physics clock lands on the grid, not on `start`** (issue #1687). The
1364
+ * step onto `start` is a frame only where the duration is exactly `count /
1365
+ * fps`. Where it is not — a duration read as a float32, `count / fps` less
1366
+ * `δ` — it is a frame short by `preroll · δ`: 3.2e-7 s at 2 loops of a
1367
+ * 2.6666665 s idle at 12 fps, against the 5.0e-9 s of `PHYSICS_CLOCK_LEAD`
1368
+ * the clock has gathered there. A physics constraint takes the whole steps
1369
+ * that fit, so a clock moved by that short step takes one fewer (4 in place
1370
+ * of 5 at 12 fps against 60 Hz) and leaves 0.99998 of a step remaining where
1371
+ * the plain walk leaves 3e-7 of one; every frame after keeps that phase, and
1372
+ * the loop recorded is a fresh transient — measured on the selftest's
1373
+ * four-link `preroll_probe`, its springs 26.4 units off the plain walk's loop
1374
+ * on a stepped idle and 37.6 on an eased one, and its seam 1.38× its largest
1375
+ * step after 10 loops (`PRE08`). ⚠️ The ⭐ above once stood alone as the
1376
+ * walk's whole claim: it is true of the track and said nothing of the
1377
+ * physics, which took the same step.
1378
+ *
1379
+ * So the physics clock is stepped by the same differences except the step
1380
+ * onto `start`, which moves it to the grid point nearest the wrap,
1381
+ * `round(start · fps) / fps`, and not to `start`: a whole frame where the
1382
+ * wrap sits just short of a grid point, and nothing where it sits just past
1383
+ * one — `δ` below 0, where that grid point is the last before `start` and the
1384
+ * step onto `start` is the sliver past it. The loop recorded is then loop
1385
+ * `preroll + 1` of a plain looping walk on the uniform grid, its physics clock
1386
+ * off that walk's by rounding alone (`PRE07`), and frame 0 is still the wrap:
1387
+ * the animation is re-phased onto it by `preroll · δ`, the one difference
1388
+ * between the two that the physics answers. Where the duration is exactly
1389
+ * `count / fps`, `start` is that grid point and the clock steps are the
1390
+ * track's, so nothing moves; on a loop that is no whole number of frames at
1391
+ * all (`PRE06`'s 1.03 s at 12 fps) the physics stays on the frame grid the
1392
+ * same way and the animation is re-phased by under half a frame.
1393
+ */
1394
+ export function prerollTimes(duration: number, fps: number, preroll: number): { times: number[]; first: number; clock: number[] } {
1357
1395
  const start = prerollStart(duration, preroll);
1358
1396
  const count = Math.round(duration * fps);
1359
1397
  const first = prerollWarmPoints(start, fps);
1360
1398
  const times = Array.from({ length: first }, (_, j) => j / fps);
1361
1399
  times.push(start);
1362
1400
  for (let i = 1; i <= count; i++) times.push(start + i / fps);
1363
- return { times, first };
1401
+ const clock = times.slice(1).map((t, i) => t - times[i]);
1402
+ if (first > 0) clock[first - 1] = Math.round(start * fps) / fps - times[first - 1];
1403
+ return { times, first, clock };
1364
1404
  }
1365
1405
 
1366
1406
  /**