rig-c 2.34.0 → 2.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-c",
3
- "version": "2.34.0",
3
+ "version": "2.35.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/core/entry.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  * `CUR123` holds the closure, by name; the install smoke bundles it for a
16
16
  * browser and holds the bundle to zero `node:` strings.
17
17
  *
18
- * ⚠️ A symbol is listed here only because a dependant was seen using it.
19
- * The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` row for
18
+ * ⚠️ A symbol is listed here only because a dependant was seen using it,
19
+ * or asked for it in writing before it could be (`openTrack`, issue #1276).
20
+ * The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
20
21
  * `./core`, and a symbol added here and not there is not promised.
21
22
  */
22
23
  export { readModel, underSkin, underNoSkin, CoreInputError, CORE_DOCUMENT_SPEC, CORE_DOCUMENT_SPECS, CORE_BLEND_MODES, activeBones } from './index.ts';
@@ -24,6 +25,8 @@ export type { CompiledDocument, CoreSlotRow, CoreBlendMode } from './index.ts';
24
25
  export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds } from './raw.ts';
25
26
  export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
26
27
  export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
28
+ export { openTrack } from './track.ts';
29
+ export type { LiveTrack, TrackOptions } from './track.ts';
27
30
  export type { WalkPose } from './walk.ts';
28
31
  export { documentPageLookup, drawnRegions, regionPageUvs, meshPageUvs, readUvSequences } from './uvs.ts';
29
32
  export type { DrawnRegion, UvPage, UvRegion, UvSource } from './uvs.ts';
@@ -126,10 +126,18 @@ export function readEventKeys(value: unknown, label: string, defs: ReadonlyMap<s
126
126
  return out;
127
127
  }
128
128
 
129
- /** What lists the events fired over `(last, t]`: `eventsFired` unless a plant passes another. */
130
- export type EventsFired = (keys: readonly CoreEventKey[], last: number, t: number, round?: (v: number) => number | null) => CoreEventRow[];
129
+ /** What lists the events fired over `(last, t]` — or across the wrap when `wrapped` — : `eventsFired` unless a plant passes another. */
130
+ export type EventsFired = (keys: readonly CoreEventKey[], last: number, t: number, round?: (v: number) => number | null, wrapped?: boolean) => CoreEventRow[];
131
131
 
132
- /** The rows of the keys fired over `(last, t]`, in key order — the header's rules, rounded as the oracle rounds. */
133
- export function eventsFired(keys: readonly CoreEventKey[], last: number, t: number, round: (v: number) => number | null = gridRound): CoreEventRow[] {
134
- return keys.filter((k) => k.time > last && k.time <= t).map((k): CoreEventRow => [k.name, round(k.time), k.int, round(k.float), k.string]);
132
+ /**
133
+ * The rows of the keys fired over `(last, t]`, in key order — the header's
134
+ * rules, rounded as the oracle rounds. `wrapped` is a looping track's step
135
+ * whose animation time went down (`t < last`, the wrap): the keys after
136
+ * `last`, in key order, then the keys at or before `t`, in key order — the
137
+ * header's *Across the wrap*. Nothing but a looping track passes it.
138
+ */
139
+ export function eventsFired(keys: readonly CoreEventKey[], last: number, t: number, round: (v: number) => number | null = gridRound, wrapped = false): CoreEventRow[] {
140
+ const row = (k: CoreEventKey): CoreEventRow => [k.name, round(k.time), k.int, round(k.float), k.string];
141
+ if (wrapped) return [...keys.filter((k) => k.time > last), ...keys.filter((k) => k.time <= t)].map(row);
142
+ return keys.filter((k) => k.time > last && k.time <= t).map(row);
135
143
  }
package/src/core/raw.ts CHANGED
@@ -261,6 +261,13 @@ export interface WalkMode {
261
261
  clock?: (dt: number, moved: number) => number;
262
262
  /** A plant only (`CO26`): `false` fires no `reset` key across the wrap — the reading the walk was measured against and rejected. */
263
263
  wrapResets?: boolean;
264
+ /**
265
+ * The live track's looping mode (`./track.ts`, issue #1276): a looping walk
266
+ * that fires its events across the wrap and cuts its clipped rows, as the
267
+ * raw entry does. Absent on A10's looping walk (`./walk.ts`), which does
268
+ * neither — its one reader reads no event and no clipped row.
269
+ */
270
+ live?: boolean;
264
271
  }
265
272
 
266
273
  /** The raw entry's mode: a track that holds at its duration, and a non-finite value refused. */
@@ -332,7 +339,7 @@ function scanBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>)
332
339
  */
333
340
  function drawnGeometry(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode): { rows: CoreAttachmentRow[]; clips: CoreClipRow[]; drawnClipped: CoreClippedRow[] } {
334
341
  const draw = drawWalkOf(doc, order, plant);
335
- const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, mode.loop && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
342
+ const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, mode.loop && mode.live !== true && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
336
343
  if (geometry.attachments === null) throw new CoreInputError(`the raw pose leaves the attachments out: ${geometry.attachmentsWhy}`);
337
344
  // The drawn rows (issue #964): the oracle's `clipped` rows where it is posed, and a concave or inverse clip cut through the core's own decomposition.
338
345
  if (geometry.drawnClipped === null) throw new CoreInputError(`the raw pose leaves the clipped triangles out: ${geometry.drawnClippedWhy}`);
@@ -583,22 +590,53 @@ function scanShape(scanPlant: ScanPlant): PoseShape<ScanPose> {
583
590
  }
584
591
  const SCAN_SHAPE: PoseShape<ScanPose> = scanShape({});
585
592
 
586
- /** `walkIn`'s walk, each pose assembled in `shape` and handed to `visit` in order as it is posed (`poseRawAnimationEach`). */
587
- function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void): void {
593
+ /**
594
+ * A walk between two of its steps (issue #1276): everything `walkEach` carries
595
+ * from one step to the next, held in one object, so the walk can be stepped one
596
+ * `dt` at a time (`openTrack` in `./track.ts`) as well as over a list. That is
597
+ * the physics context (`ctx`: the skeleton's clock, the phase, each physics
598
+ * constraint's state, the animation time a `reset` key is crossed from, whether
599
+ * the track loops), the running track time, the animation time of the step
600
+ * before (−1 before the first) and what the walk was opened with. Nothing else
601
+ * survives a step: every pose is posed from the setup pose at its time.
602
+ */
603
+ export interface TrackState<P> {
604
+ readonly doc: CompiledDocument;
605
+ readonly anim: CompiledDocument['animations'][number];
606
+ /** The runtime's duration (`CoreAnimationTimelines.duration`). */
607
+ readonly duration: number;
608
+ readonly mode: WalkMode;
609
+ readonly plant: TimelinePlant;
610
+ /** `plant` with the mode's rounding (`roundPlant`). */
611
+ readonly raw: TimelinePlant;
612
+ readonly resolve: NonNullable<TimelinePlant['shown']>;
613
+ readonly shape: PoseShape<P>;
614
+ readonly ctx: ReturnType<typeof freshStepContext>;
615
+ /** The running sum of the steps taken. */
616
+ trackTime: number;
617
+ /** The animation time of the step before, −1 before the first. */
618
+ last: number;
619
+ }
620
+
621
+ /** The document's animation named `animation`, or the walk refused naming the document's animations. */
622
+ function animationOf(doc: CompiledDocument, animation: string): CompiledDocument['animations'][number] {
588
623
  const anim = doc.animations.find((a) => a.name === animation);
589
624
  if (anim === undefined) throw new CoreInputError(`animation "${animation}" is not one of this document's [${doc.animations.map((a) => a.name).join(', ')}]`);
590
- const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
591
- if (bad >= 0) throw new CoreInputError(`step ${bad} is ${steps[bad]}, not a finite time at or above 0`);
625
+ return anim;
626
+ }
627
+
628
+ /**
629
+ * A walk opened (issue #1276): the refusals a walk makes before its first pose,
630
+ * then pose 0 — the reset — and the state the walk steps on from. Under
631
+ * `reset: 'setup'` pose 0 is the setup pose, reset; under `animation` it is the
632
+ * animation applied at 0 and reset there, a step of 0 (`stepWalk`'s `first`).
633
+ */
634
+ function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>): { track: TrackState<P>; first: P } {
592
635
  const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
593
636
  if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
594
- const raw: TimelinePlant = { ...plant, ...roundPlant(mode) };
595
- const resolve = plant.shown ?? shownAttachment;
596
- const duration = anim.timelines.duration;
597
637
  const ctx = freshStepContext(plant.physicsStep);
598
638
  if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
599
- let visited = 0;
600
- let trackTime = 0;
601
- let last = -1;
639
+ const track: TrackState<P> = { doc, anim, duration: anim.timelines.duration, mode, plant, raw: { ...plant, ...roundPlant(mode) }, resolve: plant.shown ?? shownAttachment, shape, ctx, trackTime: 0, last: -1 };
602
640
  if (reset === 'setup') {
603
641
  // The reset taken at the setup pose, before the animation is applied (`src/deformmeasure.ts`'s `poseAt`): pose 0 is the setup pose.
604
642
  const setup = poseSetup(doc, { ...plant, ...roundPlant(mode) }, ctx);
@@ -606,36 +644,75 @@ function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly n
606
644
  if (absent.length > 0) throw new CoreInputError(`the raw walk's reset pose leaves ${absent.map(([b, w]) => `${b} out (${w})`).join('; ')}`);
607
645
  if (setup.world === null || setup.shown === null || setup.setup.slots === null || setup.setup.drawOrder === null) throw new CoreInputError('the raw walk\'s reset pose was not posed');
608
646
  ctx.phase = 'update';
609
- visit(shape.setup(doc, setup.world, setup.setup.slots, setup.setup.drawOrder, setup.shown, plant, mode), visited++);
610
- }
611
- for (let i = reset === 'setup' ? 1 : 0; i <= steps.length; i++) {
612
- const dt = i === 0 ? 0 : steps[i - 1];
613
- trackTime += dt;
614
- const t = mode.loop ? (mode.time ?? loopedTime)(trackTime, duration, trackTime - dt) : trackTime < duration ? trackTime : duration;
615
- const before = ctx.time;
616
- ctx.time += mode.clock === undefined ? dt : mode.clock(dt, t - (i === 0 ? 0 : Math.max(last, 0)));
617
- const sliders: SliderApplication[] = [];
618
- const world = shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before });
619
- if (i === 0) ctx.phase = 'update';
620
- const placeholders = new Map<string, string | null>();
621
- const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
622
- if (slots.conflicts.length > 0) throw new CoreInputError(`the raw walk leaves the slots out: ${slots.conflicts.join('; ')}`);
623
- const evalOrder = plant.drawOrder ?? drawOrderAt;
624
- let order = evalOrder(doc.slots.length, anim.timelines.drawOrder, t) ?? doc.slots.map((_s, k) => k);
625
- for (const app of sliders) order = evalOrder(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
626
- const drawOrder = order.map((k) => doc.slots[k].name);
627
- const states = attachmentStates(doc, resolve, placeholders, { timelines: anim.timelines, t }, sliders, plant);
628
- if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
629
- const rank = new Map(drawOrder.map((n, r) => [n, r]));
630
- const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
631
- // A looping walk fires no event here: what fires across the wrap was not measured, and the walk's one reader (A10) reads none. Nor does it cut its
632
- // drawn attachments through a clip (`NO_CLIPPED_ROWS`), so its `clipped` rows are empty.
633
- const from = last;
634
- const events = (): RawEvent[] => (mode.loop ? [] : (plant.events ?? eventsFired)(anim.timelines.events, from, t, rawNumber).map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]));
635
- const pose = shape.step(doc, { trackTime, animationTime: t, world, slots: slots.rows, drawOrder, shown, events }, plant, mode);
636
- last = t;
637
- visit(pose, visited++);
647
+ return { track, first: shape.setup(doc, setup.world, setup.setup.slots, setup.setup.drawOrder, setup.shown, plant, mode) };
638
648
  }
649
+ return { track, first: stepWalk(track, 0, true) };
650
+ }
651
+
652
+ /**
653
+ * One step of a walk (the header's *One step*): the track time moved by `dt`,
654
+ * the animation applied at the mode's time from the setup pose, the physics
655
+ * clock moved by the step, and the pose assembled in the track's shape.
656
+ * `first` is the reset pose under `reset: 'animation'` — a step of 0 that
657
+ * resets the physics there — and nothing else passes it.
658
+ */
659
+ function stepWalk<P>(track: TrackState<P>, dt: number, first = false): P {
660
+ const { doc, anim, duration, mode, plant, raw, resolve, shape, ctx } = track;
661
+ track.trackTime += dt;
662
+ const trackTime = track.trackTime;
663
+ const last = track.last;
664
+ const t = mode.loop ? (mode.time ?? loopedTime)(trackTime, duration, trackTime - dt) : trackTime < duration ? trackTime : duration;
665
+ const before = ctx.time;
666
+ ctx.time += mode.clock === undefined ? dt : mode.clock(dt, t - (first ? 0 : Math.max(last, 0)));
667
+ const sliders: SliderApplication[] = [];
668
+ const world = shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before });
669
+ if (first) ctx.phase = 'update';
670
+ const placeholders = new Map<string, string | null>();
671
+ const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
672
+ if (slots.conflicts.length > 0) throw new CoreInputError(`the raw walk leaves the slots out: ${slots.conflicts.join('; ')}`);
673
+ const evalOrder = plant.drawOrder ?? drawOrderAt;
674
+ let order = evalOrder(doc.slots.length, anim.timelines.drawOrder, t) ?? doc.slots.map((_s, k) => k);
675
+ for (const app of sliders) order = evalOrder(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
676
+ const drawOrder = order.map((k) => doc.slots[k].name);
677
+ const states = attachmentStates(doc, resolve, placeholders, { timelines: anim.timelines, t }, sliders, plant);
678
+ if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
679
+ const rank = new Map(drawOrder.map((n, r) => [n, r]));
680
+ const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
681
+ // A10's looping walk fires no event here — its one reader reads none — nor cuts its drawn attachments through a clip (`NO_CLIPPED_ROWS`), so its
682
+ // `clipped` rows are empty. The live track's looping mode (`live`) fires its events across the wrap by the rule `./track.ts` states as measured.
683
+ const fire = plant.events ?? eventsFired;
684
+ const keys = anim.timelines.events;
685
+ const events = (): RawEvent[] => (mode.loop && mode.live !== true ? [] : (mode.loop ? fire(keys, last, t, rawNumber, t < last) : fire(keys, last, t, rawNumber)).map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]));
686
+ const pose = shape.step(doc, { trackTime, animationTime: t, world, slots: slots.rows, drawOrder, shown, events }, plant, mode);
687
+ track.last = t;
688
+ return pose;
689
+ }
690
+
691
+ /** `walkIn`'s walk, each pose assembled in `shape` and handed to `visit` in order as it is posed (`poseRawAnimationEach`): the walk opened, then stepped over `steps`. */
692
+ function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void): void {
693
+ const anim = animationOf(doc, animation);
694
+ const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
695
+ if (bad >= 0) throw new CoreInputError(`step ${bad} is ${steps[bad]}, not a finite time at or above 0`);
696
+ const { track, first } = openWalk(doc, anim, plant, reset, mode, shape);
697
+ let visited = 0;
698
+ visit(first, visited++);
699
+ for (const dt of steps) visit(stepWalk(track, dt), visited++);
700
+ }
701
+
702
+ /**
703
+ * The raw entry's walk opened for stepping one `dt` at a time (issue #1276,
704
+ * `openTrack` in `./track.ts`): the same refusals in the same order as
705
+ * `walkIn`'s past the step list, pose 0 as `walkIn` poses it, and the state
706
+ * `stepRawTrack` steps. Every pose is assembled as the raw entry's
707
+ * (`RAW_SHAPE`).
708
+ */
709
+ export function openRawTrack(doc: CompiledDocument, animation: string, plant: TimelinePlant, reset: RawReset, mode: WalkMode): { track: TrackState<RawPose>; first: RawPose } {
710
+ return openWalk(doc, animationOf(doc, animation), plant, reset, mode, RAW_SHAPE);
711
+ }
712
+
713
+ /** One step of an opened raw track (`openRawTrack`): `walkIn`'s step, the step itself checked by the caller. */
714
+ export function stepRawTrack(track: TrackState<RawPose>, dt: number): RawPose {
715
+ return stepWalk(track, dt);
639
716
  }
640
717
 
641
718
  // --- #907 the setup-pose bounding box: begin ---
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The core's live track (issue #1276): one animation opened on a track and
3
+ * stepped one `dt` at a time — a player's frame loop, whose steps are not known
4
+ * in advance — every pose the raw entry's (`RawPose` in `./raw.ts`), the doubles
5
+ * the core computed.
6
+ *
7
+ * It is not new math. Both batch walks — the raw entry's `poseRawAnimation`
8
+ * and A10's looping walk (`./walk.ts`) — take their steps up front and pose
9
+ * every step from the setup pose, carrying from one step to the next only the
10
+ * physics context, the running track time and the animation time of the step
11
+ * before. That carry is one object (`TrackState` in `./raw.ts`), and the batch
12
+ * walk is "open, then step over the list"; this track opens the same walk and
13
+ * takes its steps as they come. Measured before and after that split, over the
14
+ * nineteen tree rows' 54 animations: 956 batch walks — the raw entry at 12, 24
15
+ * and 60 fps from each reset, held three times past the duration, its two
16
+ * one-step jumps under each reset, `poseRawAnimationEach`, A10's walk and its
17
+ * scan on A10's schedule and at 60 fps over three cycles — 157,771 poses, every
18
+ * one the same bits (the PR of issue #1276 carries the table).
19
+ *
20
+ * ## The walk
21
+ *
22
+ * - **Held** (`loop: false`): the raw entry's walk — the track time the
23
+ * running sum of the steps, the animation time held at the duration, the
24
+ * physics clock moved by the step, events over `(previous animation time,
25
+ * animation time]` (`./raw.ts`, *One step*). `CO46` holds it to
26
+ * `poseRawAnimation` pose for pose, every field, bit for bit.
27
+ * - **Looped** (`loop: true`): A10's looping walk's time, clock and `reset`
28
+ * keys across the wrap (`./walk.ts`, *The walk*) — and, which that walk does
29
+ * not carry because its one reader reads neither, the events it fires across
30
+ * the wrap and the clipped rows it cuts. `CO46` holds it to
31
+ * `poseLoopingWalk` narrowed to what that walk carries, bit for bit.
32
+ * - **The reset is the caller's**, as on the raw entry (`RawReset`): pose 0
33
+ * (`first`) is posed when the track is opened, the batch walk's pose 0
34
+ * under the same reset, and every `step` is one pose after it.
35
+ * - **Neither keeps a value that is not finite** (`keep: false`, the raw
36
+ * entry's argument: a renderer handed one draws a different picture in
37
+ * silence). What the raw entry refuses is a drawn vertex, a clip or clipped
38
+ * vertex, a clipped UV and an event's numbers; a bone's matrix and a slot's
39
+ * colour channel it passes on (NaN, `null`), and so does this track. Of
40
+ * 108 forged animations of the tree rows (a leaf and its parent scaled past
41
+ * the largest double), 98 were refused at the first non-finite vertex and 4
42
+ * walked holding a non-finite bone no drawn vertex reads; `CO49` holds both
43
+ * halves, the second on a slotless bone chain.
44
+ *
45
+ * ## Across the wrap — what a looping track fires, measured
46
+ *
47
+ * Read off spine-core 4.3.13 — `AnimationState` with `setAnimation(0, name,
48
+ * true)`, pose 0 applied at 0 and reset there or reset at the setup pose
49
+ * with nothing applied, then per step `update(dt)`, `apply`,
50
+ * `Skeleton.update(dt)`, `updateWorldTransform(Physics.update)` — by a
51
+ * listener collecting `event` and `complete` in the order queued, on the
52
+ * tree rows' six animations keying events and on 300 seeded probes whose keys
53
+ * sit at 0, 1e-3 after it, in the middle, one float32 ulp and 1e-3 before the
54
+ * duration, on it, and one ulp and 1e-3 past the bone's last key (where the
55
+ * key sets the duration); over step lists that cross the wrap once and
56
+ * several times, land exactly on the duration, and carry the track past it by
57
+ * more than a cycle: 4,284 walks, 229,656 steps, 13,436 across the wrap, 8,846
58
+ * longer than a cycle, 52,880 events and 24,587 completes, each in the
59
+ * listener's order on every step. `CO47` holds the rule on its own population.
60
+ *
61
+ * - **A step whose animation time rose fires `(previous time, time]`**, as the
62
+ * raw entry's — so a step that carries the track past the duration once or
63
+ * more while its animation time still rises fires only that interval: the
64
+ * keys it passed on the way are not fired.
65
+ * - **A step whose animation time went down — the wrap — fires the keys after
66
+ * the previous time, in key order, then the keys at or before the new time,
67
+ * in key order** (`eventsFired`'s `wrapped`): the second interval opens at
68
+ * −1, so a key at 0 fires on every wrap. Read in key order instead, 7,680 of
69
+ * the steps read off; with the second interval opening at 0, 6,066; with
70
+ * nothing fired across the wrap, 12,956.
71
+ * - **A key at exactly the duration fires on the wrap step and on no other**,
72
+ * before the keys from 0: 11,978 such firings, every one on a wrap step. A
73
+ * key one float32 ulp before the duration is an ordinary key — it fired on a
74
+ * rising step 545 times, where the animation time reached it before the wrap.
75
+ * - **An equal time is not a wrap** (`loopedTime`: a track time of exactly the
76
+ * duration applies at 0). A track stepped by exactly the duration applies at
77
+ * 0 on every step, so it fires its keys at 0 on its first step and nothing
78
+ * after; stepped by half the duration it wraps on every second step.
79
+ * - **`complete` is not carried** — a listener is the player's. Measured, it is
80
+ * queued once on a step whose track time's whole cycles rose past 0 —
81
+ * `⌊T / d⌋ > 0` and `⌊T / d⌋ > ⌊T_before / d⌋`, on every step at a duration
82
+ * of 0 — however many cycles the step crossed, after the keys after the
83
+ * previous time and before the keys from 0. A player derives it from
84
+ * `trackTime` and `duration`.
85
+ *
86
+ * ## The clipped rows
87
+ *
88
+ * The looping mode cuts every drawn attachment under a clip as the raw entry
89
+ * does — the oracle's rows, and the core's own decomposition under a concave
90
+ * or inverse clip (`clipThrough`) — where A10's walk cuts none. That is a
91
+ * mode and not a measurement: the cut is per pose and does not read whether
92
+ * the track loops. `CO48` holds it: on the clipping probe and the tree row
93
+ * that clips, every looping pose, past the wrap included, equals the raw
94
+ * entry's one step to its animation time in every field but the track time
95
+ * and the events (4,009 of 4,009 on the tree row, 690 of them cutting 12,420
96
+ * rows).
97
+ *
98
+ * ## The oracle chain
99
+ *
100
+ * Nothing here is held to spine-core directly but the events: the live track
101
+ * is held to the batch walk (`CO46`), the held batch walk to spine-core's
102
+ * render and deform-measure recipes (`CR03`), the looped one to A10's recipe
103
+ * (`CO25`), and both to `tools/pose_oracle.ts`'s stepped dump, whose schedule
104
+ * is `stepSchedule` in `./constraints_physics.ts`. A second dump would be the
105
+ * same numbers twice.
106
+ *
107
+ * ⛔ **A track refused part-way stays refused.** A step is checked before
108
+ * anything moves; a refusal past that — a construct the core leaves out, a
109
+ * non-finite vertex — comes after the track time and the physics have moved,
110
+ * so every later `step` refuses naming the step it was refused at, and the
111
+ * caller opens the track again. Seeking is the caller's too: a physics rig has
112
+ * history, so a seek is "open, then step to the time".
113
+ */
114
+ import { CoreInputError, type CompiledDocument } from './index.ts';
115
+ import { openRawTrack, stepRawTrack, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
116
+ import type { TimelinePlant } from './animation.ts';
117
+
118
+ /** How a live track is opened: whether it loops, and where its physics is reset (`RawReset`). Both are the caller's and neither has a default. */
119
+ export interface TrackOptions {
120
+ loop: boolean;
121
+ reset: RawReset;
122
+ }
123
+
124
+ /** A live track (`openTrack`): pose 0, then one pose per `step`. */
125
+ export interface LiveTrack {
126
+ /** Pose 0, posed when the track was opened: the batch walk's first pose under the same `reset`. */
127
+ readonly first: RawPose;
128
+ /** The next pose: the track time moved by `dt`, a finite time at or above 0. */
129
+ step(dt: number): RawPose;
130
+ /** The running sum of the steps taken, 0 before the first. */
131
+ readonly trackTime: number;
132
+ /** The time the latest pose applied the animation at (0 for pose 0). */
133
+ readonly animationTime: number;
134
+ /** The runtime's duration of the animation (`CoreAnimationTimelines.duration`). */
135
+ readonly duration: number;
136
+ }
137
+
138
+ /** The two modes: a track that holds at its duration is the raw entry's walk; a looping one fires its events across the wrap and cuts its clipped rows (`live`). Neither keeps a non-finite value. */
139
+ const HELD: WalkMode = { loop: false, keep: false };
140
+ const LOOPED: WalkMode = { loop: true, keep: false, live: true };
141
+
142
+ /**
143
+ * One animation opened on a live track (the header): refused by name where the
144
+ * batch walk refuses the same document and animation, pose 0 posed, then
145
+ * stepped one `dt` at a time.
146
+ */
147
+ export function openTrack(doc: CompiledDocument, animation: string, options: TrackOptions, plant: TimelinePlant = {}): LiveTrack {
148
+ if (typeof options !== 'object' || options === null || typeof options.loop !== 'boolean') throw new CoreInputError(`the live track's options.loop is ${JSON.stringify((options as Partial<TrackOptions> | null)?.loop)}, not true or false`);
149
+ if (options.reset !== 'animation' && options.reset !== 'setup') throw new CoreInputError(`the live track's options.reset is ${JSON.stringify(options.reset)}, not "animation" or "setup"`);
150
+ const { track, first } = openRawTrack(doc, animation, plant, options.reset, options.loop ? LOOPED : HELD);
151
+ return new Track(track, first);
152
+ }
153
+
154
+ class Track implements LiveTrack {
155
+ readonly first: RawPose;
156
+ private readonly track: TrackState<RawPose>;
157
+ private steps = 0;
158
+ private latest: number;
159
+ private refused: string | null = null;
160
+
161
+ constructor(track: TrackState<RawPose>, first: RawPose) {
162
+ this.track = track;
163
+ this.first = first;
164
+ this.latest = first.animationTime;
165
+ }
166
+
167
+ get trackTime(): number {
168
+ return this.track.trackTime;
169
+ }
170
+
171
+ get animationTime(): number {
172
+ return this.latest;
173
+ }
174
+
175
+ get duration(): number {
176
+ return this.track.duration;
177
+ }
178
+
179
+ step(dt: number): RawPose {
180
+ const n = this.steps + 1;
181
+ if (this.refused !== null) throw new CoreInputError(`the live track was refused at step ${this.refused}, after its time and physics had moved; open the track again`);
182
+ if (typeof dt !== 'number' || !Number.isFinite(dt) || dt < 0) throw new CoreInputError(`the live track's step ${n} is ${String(dt)}, not a finite time at or above 0`);
183
+ try {
184
+ const pose = stepRawTrack(this.track, dt);
185
+ this.steps = n;
186
+ this.latest = pose.animationTime;
187
+ return pose;
188
+ } catch (err) {
189
+ if (err instanceof CoreInputError) this.refused = `${n} (${err.message})`;
190
+ throw err;
191
+ }
192
+ }
193
+ }
package/src/core/walk.ts CHANGED
@@ -57,8 +57,10 @@
57
57
  * refuses, this entry writes the double; on 4,333 poses holding one, every
58
58
  * NaN and Infinity stood where spine-core's did.
59
59
  *
60
- * Events are not carried: A10 reads none, and what a looping track fires
61
- * across the wrap was not measured. Nor are the clipped rows, and the walk
60
+ * Events are not carried: A10 reads none. What a looping track fires across
61
+ * the wrap is measured where a reader wants it — the live track
62
+ * (`./track.ts`, issue #1276), whose looping mode is this walk's with the
63
+ * events and the clipped rows put back. Nor are the clipped rows, and the walk
62
64
  * does not cut them (issue #1179): every clip it starts is still planned, so
63
65
  * a clip the core does not draw refuses the walk by name, as below.
64
66
  *