rig-c 2.35.1 → 2.37.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.35.1",
3
+ "version": "2.37.0",
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": {
@@ -706,6 +706,108 @@ export function posedBones(doc: CompiledDocument, timelines: CoreAnimationTimeli
706
706
  });
707
707
  }
708
708
 
709
+ /**
710
+ * A player's bone-local adjustment (issue #1336): a function the live track
711
+ * (`openTrack`'s `adjust`, `./track.ts`) calls once per pose, after the
712
+ * animation's timelines have posed the bone locals at the step's time and
713
+ * before the world transforms, the constraints and the physics step — so a
714
+ * write is what the rest of the step reads: the bone's children, the
715
+ * attachments drawn through it, every constraint and the physics constraint
716
+ * stepped on top of it. It is spine-core's "application code" writing
717
+ * `Bone.pose` between `AnimationState.apply` and `updateWorldTransform`
718
+ * (`CO50` holds the two equal at tolerance 0).
719
+ *
720
+ * - **The view is the step's locals, posed from the setup pose.** Every pose
721
+ * of a walk is posed from the setup pose at its time (`./raw.ts`,
722
+ * `TrackState`), so a write lives for the pose it was written in and the
723
+ * next step's hook reads the timelines' value again, not the write. A
724
+ * channel no timeline keys reads its setup value on every step.
725
+ * - **A bone the hook does not write is untouched**: the timelines' bone
726
+ * object as it was, the setup bone itself where nothing keys it. A written
727
+ * bone is copied before the write, so the document is never written.
728
+ * - **A write that is not a finite number is refused by name**, the bone, the
729
+ * channel and the value — where the core refuses a non-finite value, by
730
+ * name, rather than posing a picture with it (`finite` in `./raw.ts`).
731
+ */
732
+ export interface BoneLocals {
733
+ rotation: number;
734
+ x: number;
735
+ y: number;
736
+ scaleX: number;
737
+ scaleY: number;
738
+ shearX: number;
739
+ shearY: number;
740
+ }
741
+
742
+ /** The locals of a step's bones, by name (`BoneLocals`). */
743
+ export interface AdjustableLocals {
744
+ /** The document's bones, in its order. */
745
+ readonly names: readonly string[];
746
+ /** One bone's locals, read and written in place; a name the document has no bone for is refused by name. */
747
+ bone(name: string): BoneLocals;
748
+ }
749
+
750
+ /** What `openTrack`'s `adjust` is: called once per pose with the step's locals (`BoneLocals`). */
751
+ export type LocalAdjust = (locals: AdjustableLocals) => void;
752
+
753
+ /** The seven channels a `BoneLocals` reads and writes, each with the value an unstated one reads as (`worldTransforms`' defaults). */
754
+ const LOCAL_CHANNELS = { rotation: 0, x: 0, y: 0, scaleX: 1, scaleY: 1, shearX: 0, shearY: 0 } as const;
755
+ type LocalChannel = keyof typeof LOCAL_CHANNELS;
756
+
757
+ /** One bone of a step's `bones`, read through and written through, copied on its first write (`BoneLocals`' second rule). */
758
+ class StepLocals implements BoneLocals {
759
+ constructor(
760
+ private readonly bones: ModelBone[],
761
+ private readonly index: number,
762
+ private readonly setup: ModelBone,
763
+ ) {}
764
+
765
+ private read(channel: LocalChannel): number {
766
+ return this.bones[this.index][channel] ?? LOCAL_CHANNELS[channel];
767
+ }
768
+
769
+ private write(channel: LocalChannel, value: number): void {
770
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new CoreInputError(`the adjustment wrote bone "${this.setup.name}" ${channel} = ${String(value)}, not a finite number`);
771
+ if (this.bones[this.index] === this.setup) this.bones[this.index] = { ...this.setup };
772
+ this.bones[this.index][channel] = value;
773
+ }
774
+
775
+ get rotation(): number { return this.read('rotation'); }
776
+ set rotation(v: number) { this.write('rotation', v); }
777
+ get x(): number { return this.read('x'); }
778
+ set x(v: number) { this.write('x', v); }
779
+ get y(): number { return this.read('y'); }
780
+ set y(v: number) { this.write('y', v); }
781
+ get scaleX(): number { return this.read('scaleX'); }
782
+ set scaleX(v: number) { this.write('scaleX', v); }
783
+ get scaleY(): number { return this.read('scaleY'); }
784
+ set scaleY(v: number) { this.write('scaleY', v); }
785
+ get shearX(): number { return this.read('shearX'); }
786
+ set shearX(v: number) { this.write('shearX', v); }
787
+ get shearY(): number { return this.read('shearY'); }
788
+ set shearY(v: number) { this.write('shearY', v); }
789
+ }
790
+
791
+ /** The adjustment run over a step's `bones` (`posedBones`' array, in the document's order), which it may replace bone by bone (`BoneLocals`). */
792
+ function adjustLocals(doc: CompiledDocument, bones: ModelBone[], adjust: LocalAdjust): void {
793
+ const views = new Map<string, StepLocals>();
794
+ const names = doc.bones.map((b) => b.name);
795
+ adjust({
796
+ names,
797
+ bone(name: string): BoneLocals {
798
+ let view = views.get(name);
799
+ if (view === undefined) {
800
+ const index = names.indexOf(name);
801
+ if (index < 0) throw new CoreInputError(`the adjustment asked for bone ${JSON.stringify(name)}, which is not one of this document's bones`);
802
+ // A copy the timelines made is this step's own; the setup bone is the document's, copied on its first write.
803
+ view = new StepLocals(bones, index, doc.bones[index]);
804
+ views.set(name, view);
805
+ }
806
+ return view;
807
+ },
808
+ });
809
+ }
810
+
709
811
  const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
710
812
 
711
813
  /**
@@ -722,22 +824,13 @@ export function posedSlots(
722
824
  /** Filled with each slot's placeholder once the sample's own timelines have moved it, before the sliders — what the attachments are posed from (`attachmentStates`). */
723
825
  placeholders?: Map<string, string | null>,
724
826
  ): { rows: CoreSlotRow[]; conflicts: string[] } {
725
- const resolve = plant.shown ?? shownAttachment;
726
827
  const colour = plant.colour ?? readColour;
727
- const blend = plant.blend ?? readBlend;
728
- const round = plant.round ?? gridRound;
729
828
  const byName = new Map(timelines.slots.map((s) => [s.name, s]));
730
- const rows: CoreSlotRow[] = [];
731
- const conflicts: string[] = [];
732
- const active = activeBones(doc);
733
- const gate = plant.slotTimelines ?? slotTimelinesApply;
734
- for (const slot of doc.slots) {
829
+ return slotRowsFrom(doc, plant, sliders, placeholders, (slot, live) => {
735
830
  let placeholder = slot.setup;
736
831
  const light = slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color);
737
832
  const setupDark = slot.dark === undefined ? null : readColour(slot.dark);
738
833
  const dark = setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]];
739
- // A slot on a bone the skin leaves inactive is not animated — not by the sample's timelines, not by a slider (`./skins.ts`).
740
- const live = gate(doc, slot, active);
741
834
  for (const tl of live ? (byName.get(slot.name)?.timelines ?? []) : []) {
742
835
  if (tl.kind === 'attachment') {
743
836
  const key = keyAt(tl.keys, t, plant);
@@ -753,6 +846,48 @@ export function posedSlots(
753
846
  else if (dark !== null && setupDark !== null) dark[at - 4] = v === null ? setupDark[at - 4] : clamp01(v[i]);
754
847
  });
755
848
  }
849
+ return { placeholder, light, dark };
850
+ });
851
+ }
852
+
853
+ /**
854
+ * What a slot holds once the timelines have posed it and before the sliders:
855
+ * the placeholder it shows, its light colour and its dark one (`null` where
856
+ * the slot states none). `posedSlots` reads it off one animation at `t`; the
857
+ * live tracks off their mixed entries (`./tracks.ts`, issue #1336).
858
+ */
859
+ export interface SlotBase {
860
+ placeholder: string | null;
861
+ light: number[];
862
+ dark: number[] | null;
863
+ }
864
+
865
+ /**
866
+ * The slot rows from each slot's `SlotBase` — `baseOf`, called once per slot
867
+ * in the document's order with whether the slot is animated at all — then the
868
+ * sliders, the resolution and the oracle's row: `posedSlots`' body past the
869
+ * timelines, which the live tracks share (`./tracks.ts`).
870
+ */
871
+ export function slotRowsFrom(
872
+ doc: CompiledDocument,
873
+ plant: TimelinePlant,
874
+ sliders: readonly SliderApplication[],
875
+ placeholders: Map<string, string | null> | undefined,
876
+ baseOf: (slot: ModelSlot, live: boolean) => SlotBase,
877
+ ): { rows: CoreSlotRow[]; conflicts: string[] } {
878
+ const resolve = plant.shown ?? shownAttachment;
879
+ const blend = plant.blend ?? readBlend;
880
+ const round = plant.round ?? gridRound;
881
+ const rows: CoreSlotRow[] = [];
882
+ const conflicts: string[] = [];
883
+ const active = activeBones(doc);
884
+ const gate = plant.slotTimelines ?? slotTimelinesApply;
885
+ for (const slot of doc.slots) {
886
+ // A slot on a bone the skin leaves inactive is not animated — not by the sample's timelines, not by a slider (`./skins.ts`).
887
+ const live = gate(doc, slot, active);
888
+ const base = baseOf(slot, live);
889
+ let placeholder = base.placeholder;
890
+ const { light, dark } = base;
756
891
  // The sliders, after the animation, in constraint order (`./constraints_slider.ts`).
757
892
  // What the slot shows after the sample's own timelines: the attachments' deform and sequence timelines are matched against it (`./deform.ts`, *A switch*).
758
893
  placeholders?.set(slot.name, placeholder);
@@ -778,6 +913,21 @@ export function posedSlots(
778
913
  return { rows, conflicts };
779
914
  }
780
915
 
916
+ /**
917
+ * One step of the stepped phase, as a walk hands it to the bones: the physics
918
+ * context and the clock before the step (`./constraints_physics.ts`), and the
919
+ * caller's adjustment of the locals (`LocalAdjust`). The adjustment rides here
920
+ * rather than on the plant because it is not a rule the core measured and a
921
+ * control plants back — it is the caller's input to one walk — and because
922
+ * the step context exists exactly where a walk is stepped: the live track's
923
+ * every pose but a `reset: 'setup'` pose 0, which no animation is applied to.
924
+ */
925
+ export interface PoseStep {
926
+ ctx: PhysicsStepContext;
927
+ before: number;
928
+ adjust?: LocalAdjust;
929
+ }
930
+
781
931
  /**
782
932
  * The bone rows at `t`, in the oracle's shape and rounding. With `constraints`
783
933
  * — the animation's constraint timelines — the document's constraints are
@@ -792,7 +942,7 @@ export function posedBoneRows(doc: CompiledDocument, timelines: CoreAnimationTim
792
942
  }
793
943
 
794
944
  /** `posedBoneRows`, with the world transforms the rows were read off — what the sample's attachments are posed through. */
795
- export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant = {}, constraints?: CoreConstraintTimelines, sliders?: SliderApplication[], step?: { ctx: PhysicsStepContext; before: number }): { rows: CoreBoneRow[]; world: Map<string, CoreWorld> } {
945
+ export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant = {}, constraints?: CoreConstraintTimelines, sliders?: SliderApplication[], step?: PoseStep): { rows: CoreBoneRow[]; world: Map<string, CoreWorld> } {
796
946
  const posed = posedBoneStep(doc, timelines, t, plant, constraints, sliders, step);
797
947
  return { rows: stepRows(posed), world: posed.world };
798
948
  }
@@ -804,7 +954,7 @@ export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTi
804
954
  * no row, so the scan's walk (`scanWalkIn` in `./raw.ts`, A10's) asks for
805
955
  * this; the raw entry's walk still forms the rows it does not read, unchanged.
806
956
  */
807
- export function posedBoneWorldAlone(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: { ctx: PhysicsStepContext; before: number } | undefined): Map<string, CoreWorld> {
957
+ export function posedBoneWorldAlone(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): Map<string, CoreWorld> {
808
958
  return posedBoneStep(doc, timelines, t, plant, constraints, sliders, step).world;
809
959
  }
810
960
 
@@ -835,15 +985,15 @@ function stepRows(posed: PosedStep): CoreBoneRow[] {
835
985
  }
836
986
 
837
987
  /** `posedBoneWorld` up to its rows, refusing a bone the evaluator gave no transform (`PosedStep`). */
838
- function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: { ctx: PhysicsStepContext; before: number } | undefined): PosedStep {
839
- const active = activeBones(doc);
988
+ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): PosedStep {
840
989
  const bones = posedBones(doc, timelines, t, plant);
990
+ // One step of the stepped phase (issue #956, `./constraints_physics.ts`): the physics records posed by their timelines and stepped under the walk's context; the deformed curve a path walks as above.
991
+ if (constraints !== undefined && step !== undefined) return steppedLocals(doc, bones, plant, () => pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, sliders, step);
992
+ const active = activeBones(doc);
993
+ // The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
994
+ if (step?.adjust !== undefined) adjustLocals(doc, bones, step.adjust);
841
995
  let world = (plant.evaluate ?? worldTransforms)(bones, active);
842
- if (constraints !== undefined && step !== undefined) {
843
- // One step of the stepped phase (issue #956, `./constraints_physics.ts`): the physics records posed by their timelines and stepped under the walk's context; the deformed curve a path walks as above. No previous pass: under the step it is the previous step's, and `poseAnimations` leaves such bones out.
844
- const records = stepPhysicsRecords(pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, active, step.ctx, step.before);
845
- world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(records) : records, null, sliders, step.ctx, undefined, solverRules(plant.solver));
846
- } else if (constraints !== undefined) {
996
+ if (constraints !== undefined) {
847
997
  const setupRecords = constraintRecords(doc);
848
998
  // A path walks the curve the sample's deform timelines left on its slot (`./deform.ts`); a slider's deform of a walked path leaves the bones out (`pathAnimationsWhy`).
849
999
  const records = pathDeformed(doc, posedRecords(setupRecords, constraints, t), timelines, t, plant);
@@ -855,6 +1005,40 @@ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines,
855
1005
  return { bones, world, active, round: plant.round ?? gridRound };
856
1006
  }
857
1007
 
1008
+ /**
1009
+ * One step of the stepped phase from locals already posed (issue #1336): the
1010
+ * caller's adjustment (`LocalAdjust`), the world transforms, the physics
1011
+ * records posed by `records` and stepped under the walk's context, then every
1012
+ * constraint. No previous pass: under the step it is the previous step's, and
1013
+ * `poseAnimations` leaves such bones out. A single animation's walk reaches it
1014
+ * through `posedBoneStep` with the locals `posedBones` posed; the live tracks
1015
+ * (`./tracks.ts`) with the locals their mixed entries posed — one pose path
1016
+ * past the timelines for both.
1017
+ */
1018
+ function steppedLocals(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, records: () => CoreConstraintRecord[], physicsKeyed: NonNullable<CoreConstraintTimelines['physicsKeyed']>, t: number, sliders: SliderApplication[] | undefined, step: PoseStep): PosedStep {
1019
+ const active = activeBones(doc);
1020
+ // The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
1021
+ if (step.adjust !== undefined) adjustLocals(doc, bones, step.adjust);
1022
+ let world = (plant.evaluate ?? worldTransforms)(bones, active);
1023
+ const stepped = stepPhysicsRecords(records(), physicsKeyed, t, active, step.ctx, step.before);
1024
+ world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
1025
+ for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
1026
+ return { bones, world, active, round: plant.round ?? gridRound };
1027
+ }
1028
+
1029
+ /**
1030
+ * `steppedLocals`' world transforms, the oracle's rows formed beside them when
1031
+ * `rows` is set (as `posedBoneWorld` forms them and `posedBoneWorldAlone` does
1032
+ * not): the live tracks' entry to the stepped phase (`./tracks.ts`). The
1033
+ * document's constraint records as they stand — the live tracks mix no
1034
+ * constraint timeline — and no physics timeline keyed.
1035
+ */
1036
+ export function steppedLocalsWorld(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, sliders: SliderApplication[], step: PoseStep, rows: boolean): Map<string, CoreWorld> {
1037
+ const posed = steppedLocals(doc, bones, plant, () => constraintRecords(doc), [], 0, sliders, step);
1038
+ if (rows) stepRows(posed);
1039
+ return posed.world;
1040
+ }
1041
+
858
1042
  /** Each path record with the curve its slot shows deformed by the sample's own deform timelines at `t` (`./deform.ts`), or as it was. */
859
1043
  function pathDeformed(doc: CompiledDocument, records: CoreConstraintRecord[], timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant): CoreConstraintRecord[] {
860
1044
  if (timelines.attachments.every((a) => a.deform === null)) return records;
@@ -536,6 +536,14 @@ export function attachmentStates(
536
536
  sample: { timelines: CoreAnimationTimelines; t: number } | null,
537
537
  sliders: readonly AttachmentApplication[],
538
538
  plant: { deform?: DeformEvaluator; deformBlend?: DeformBlender; sequence?: SequenceEvaluator; slotTimelines?: SlotTimelineGate } = {},
539
+ /**
540
+ * The live tracks' deforms (`./tracks.ts`, issue #1336), in place of a
541
+ * sample's: each slot's deform array as the mixed entries left it (`null`
542
+ * none) and the records their deform timelines keyed on it — what the
543
+ * sample's own timelines would have set and seen. Absent on every other
544
+ * caller.
545
+ */
546
+ mixed?: ReadonlyMap<string, { deform: number[] | null; seen: readonly string[] }>,
539
547
  ): { shown: ShownGeometry[]; why: string[] } {
540
548
  const evalDeform = plant.deform ?? deformAt;
541
549
  const blendOf = plant.deformBlend ?? blendDeform;
@@ -548,9 +556,10 @@ export function attachmentStates(
548
556
  // A slot on a bone the skin leaves inactive is not animated: no deform, no frame, no slider switch (`./skins.ts`).
549
557
  const live = gate(doc, slot, active);
550
558
  let placeholder = placeholders.get(slot.name) ?? null;
551
- let deform: number[] | null = null;
559
+ const base = live ? mixed?.get(slot.name) : undefined;
560
+ let deform: number[] | null = base?.deform ?? null;
552
561
  let frame: number | null = null;
553
- const seen: string[] = [];
562
+ const seen: string[] = base === undefined ? [] : [...base.seen];
554
563
  // What the slot shows now, the record its timelines are keyed as, and what a deform or a frame is computed on.
555
564
  const state = (): { s: CoreShown; identity: string | null; vertices: ModelVertices | null; count: number | undefined } | null => {
556
565
  if (placeholder === null) return null;
package/src/core/entry.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * browser and holds the bundle to zero `node:` strings.
17
17
  *
18
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).
19
+ * or asked for it in writing before it could be (`openTrack`, issue #1276;
20
+ * `openTracks`, issue #1336).
20
21
  * The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
21
22
  * `./core`, and a symbol added here and not there is not promised.
22
23
  */
@@ -26,7 +27,9 @@ export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setup
26
27
  export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
27
28
  export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
28
29
  export { openTrack } from './track.ts';
29
- export type { LiveTrack, TrackOptions } from './track.ts';
30
+ export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust } from './track.ts';
31
+ export { openTracks } from './tracks.ts';
32
+ export type { LiveTracks, TracksOptions, MixOptions, QueueOptions, TrackEntryView } from './tracks.ts';
30
33
  export type { WalkPose } from './walk.ts';
31
34
  export { documentPageLookup, drawnRegions, regionPageUvs, meshPageUvs, readUvSequences } from './uvs.ts';
32
35
  export type { DrawnRegion, UvPage, UvRegion, UvSource } from './uvs.ts';
package/src/core/raw.ts CHANGED
@@ -114,12 +114,12 @@
114
114
  * missing would draw a different picture in silence.
115
115
  */
116
116
  import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
- import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type TimelinePlant } from './animation.ts';
117
+ import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
118
118
  import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
119
119
  import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.ts';
120
120
  import { attachmentStates } from './deform.ts';
121
- import { drawOrderAt } from './draw_order.ts';
122
- import { eventsFired } from './events.ts';
121
+ import { drawOrderAt, type CoreDrawOrderKey } from './draw_order.ts';
122
+ import { eventsFired, type CoreEventRow } from './events.ts';
123
123
  import { REGION_TRIANGLES, REGION_UVS, type CoreClippedRow, type ShapeClipper } from './clipping.ts';
124
124
  import { poseGeometry, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
125
125
  import { RUNTIME_DEG, type CoreWorld } from './world.ts';
@@ -612,6 +612,8 @@ export interface TrackState<P> {
612
612
  readonly resolve: NonNullable<TimelinePlant['shown']>;
613
613
  readonly shape: PoseShape<P>;
614
614
  readonly ctx: ReturnType<typeof freshStepContext>;
615
+ /** The caller's adjustment of each step's locals (`LocalAdjust` in `./animation.ts`): the live track's `adjust`, absent on every batch walk. */
616
+ readonly adjust?: LocalAdjust;
615
617
  /** The running sum of the steps taken. */
616
618
  trackTime: number;
617
619
  /** The animation time of the step before, −1 before the first. */
@@ -631,12 +633,12 @@ function animationOf(doc: CompiledDocument, animation: string): CompiledDocument
631
633
  * `reset: 'setup'` pose 0 is the setup pose, reset; under `animation` it is the
632
634
  * animation applied at 0 and reset there, a step of 0 (`stepWalk`'s `first`).
633
635
  */
634
- function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>): { track: TrackState<P>; first: P } {
636
+ function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, adjust?: LocalAdjust): { track: TrackState<P>; first: P } {
635
637
  const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
636
638
  if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
637
639
  const ctx = freshStepContext(plant.physicsStep);
638
640
  if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
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 };
641
+ const track: TrackState<P> = { doc, anim, duration: anim.timelines.duration, mode, plant, raw: { ...plant, ...roundPlant(mode) }, resolve: plant.shown ?? shownAttachment, shape, ctx, adjust, trackTime: 0, last: -1 };
640
642
  if (reset === 'setup') {
641
643
  // The reset taken at the setup pose, before the animation is applied (`src/deformmeasure.ts`'s `poseAt`): pose 0 is the setup pose.
642
644
  const setup = poseSetup(doc, { ...plant, ...roundPlant(mode) }, ctx);
@@ -657,35 +659,112 @@ function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations']
657
659
  * resets the physics there — and nothing else passes it.
658
660
  */
659
661
  function stepWalk<P>(track: TrackState<P>, dt: number, first = false): P {
660
- const { doc, anim, duration, mode, plant, raw, resolve, shape, ctx } = track;
662
+ const { doc, anim, duration, mode, plant, raw, resolve, shape, ctx, adjust } = track;
661
663
  track.trackTime += dt;
662
664
  const trackTime = track.trackTime;
663
665
  const last = track.last;
664
666
  const t = mode.loop ? (mode.time ?? loopedTime)(trackTime, duration, trackTime - dt) : trackTime < duration ? trackTime : duration;
665
667
  const before = ctx.time;
666
668
  ctx.time += mode.clock === undefined ? dt : mode.clock(dt, t - (first ? 0 : Math.max(last, 0)));
669
+ // 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
670
+ // `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.
671
+ const fire = plant.events ?? eventsFired;
672
+ const keys = anim.timelines.events;
673
+ const pose = poseStepFrom(doc, plant, resolve, shape, mode, ctx, first, trackTime, t, {
674
+ world: (sliders) => (shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjust }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjust })),
675
+ slots: (sliders, placeholders) => posedSlots(doc, anim.timelines, t, raw, sliders, placeholders),
676
+ drawOrder: anim.timelines.drawOrder,
677
+ at: t,
678
+ sample: { timelines: anim.timelines, t },
679
+ events: () => (mode.loop && mode.live !== true ? [] : mode.loop ? fire(keys, last, t, rawNumber, t < last) : fire(keys, last, t, rawNumber)),
680
+ });
681
+ track.last = t;
682
+ return pose;
683
+ }
684
+
685
+ /**
686
+ * What decides one step's pose before it is assembled (issue #1336): the bone
687
+ * world through the stepped phase, filling the sliders it applied; the slot
688
+ * rows, filling the placeholders the timelines left; the draw-order keys and
689
+ * the time they are read at; the deform and sequence timelines' sample, or the
690
+ * mixed deforms in its place; the events, computed when called. A walk's step
691
+ * reads every one off one animation at its time (`stepWalk`); the live tracks
692
+ * off their mixed entries (`./tracks.ts`). Everything past these is one body,
693
+ * `poseStepFrom`.
694
+ */
695
+ export interface StepPosers {
696
+ world: (sliders: SliderApplication[]) => Map<string, CoreWorld>;
697
+ slots: (sliders: readonly SliderApplication[], placeholders: Map<string, string | null>) => { rows: CoreSlotRow[]; conflicts: string[] };
698
+ drawOrder: readonly CoreDrawOrderKey[];
699
+ at: number;
700
+ sample: { timelines: CoreAnimationTimelines; t: number } | null;
701
+ mixed?: ReadonlyMap<string, { deform: number[] | null; seen: readonly string[] }>;
702
+ events: () => CoreEventRow[];
703
+ }
704
+
705
+ /**
706
+ * One step's pose from what decided it (`StepPosers`): the bone world, the
707
+ * physics phase moved past the reset on `first`, the slot rows, the draw
708
+ * order (the keys' order, else the setup's, then each slider's key), the
709
+ * attachments drawn in it, and the pose assembled in `shape` — refused by name
710
+ * where the core leaves a block out, in that order. The one body a walk's step
711
+ * and a live tracks' step share.
712
+ */
713
+ function poseStepFrom<P>(doc: CompiledDocument, plant: TimelinePlant, resolve: NonNullable<TimelinePlant['shown']>, shape: PoseShape<P>, mode: WalkMode, ctx: ReturnType<typeof freshStepContext>, first: boolean, trackTime: number, animationTime: number, posers: StepPosers): P {
667
714
  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 });
715
+ const world = posers.world(sliders);
669
716
  if (first) ctx.phase = 'update';
670
717
  const placeholders = new Map<string, string | null>();
671
- const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
718
+ const slots = posers.slots(sliders, placeholders);
672
719
  if (slots.conflicts.length > 0) throw new CoreInputError(`the raw walk leaves the slots out: ${slots.conflicts.join('; ')}`);
673
720
  const evalOrder = plant.drawOrder ?? drawOrderAt;
674
- let order = evalOrder(doc.slots.length, anim.timelines.drawOrder, t) ?? doc.slots.map((_s, k) => k);
721
+ let order = evalOrder(doc.slots.length, posers.drawOrder, posers.at) ?? doc.slots.map((_s, k) => k);
675
722
  for (const app of sliders) order = evalOrder(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
676
723
  const drawOrder = order.map((k) => doc.slots[k].name);
677
- const states = attachmentStates(doc, resolve, placeholders, { timelines: anim.timelines, t }, sliders, plant);
724
+ const states = attachmentStates(doc, resolve, placeholders, posers.sample, sliders, plant, posers.mixed);
678
725
  if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
679
726
  const rank = new Map(drawOrder.map((n, r) => [n, r]));
680
727
  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;
728
+ const events = (): RawEvent[] => posers.events().map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]);
729
+ return shape.step(doc, { trackTime, animationTime, world, slots: slots.rows, drawOrder, shown, events }, plant, mode);
730
+ }
731
+
732
+ /**
733
+ * The live tracks' side of the raw pose path (issue #1336, `./tracks.ts`):
734
+ * the refusals a walk makes before its first pose, a fresh physics context,
735
+ * the plant with the raw entry's rounding, and the two ways a pose is made —
736
+ * the setup pose reset (`reset: 'setup'`'s pose 0) and one step from what the
737
+ * mixed entries decided, assembled as the raw entry's pose (`RAW_SHAPE`,
738
+ * `RAW_MODE`: a non-finite value refused, every clipped row cut).
739
+ */
740
+ export interface RawTracksPath {
741
+ readonly ctx: ReturnType<typeof freshStepContext>;
742
+ /** `plant` with the raw entry's rounding. */
743
+ readonly raw: TimelinePlant;
744
+ setupPose(): RawPose;
745
+ step(first: boolean, trackTime: number, animationTime: number, posers: StepPosers): RawPose;
746
+ }
747
+
748
+ /** `RawTracksPath` opened on `doc`, refused by name where a walk on it would be (`openWalk`). */
749
+ export function openRawTracksPath(doc: CompiledDocument, plant: TimelinePlant): RawTracksPath {
750
+ const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
751
+ if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
752
+ const ctx = freshStepContext(plant.physicsStep);
753
+ const raw: TimelinePlant = { ...plant, ...roundPlant(RAW_MODE) };
754
+ const resolve = plant.shown ?? shownAttachment;
755
+ return {
756
+ ctx,
757
+ raw,
758
+ setupPose: () => {
759
+ const setup = poseSetup(doc, raw, ctx);
760
+ const absent = setup.absent.filter(([block]) => block !== 'setup.clipped');
761
+ if (absent.length > 0) throw new CoreInputError(`the raw walk's reset pose leaves ${absent.map(([b, w]) => `${b} out (${w})`).join('; ')}`);
762
+ 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');
763
+ ctx.phase = 'update';
764
+ return RAW_SHAPE.setup(doc, setup.world, setup.setup.slots, setup.setup.drawOrder, setup.shown, plant, RAW_MODE);
765
+ },
766
+ step: (first, trackTime, animationTime, posers) => poseStepFrom(doc, plant, resolve, RAW_SHAPE, RAW_MODE, ctx, first, trackTime, animationTime, posers),
767
+ };
689
768
  }
690
769
 
691
770
  /** `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`. */
@@ -706,8 +785,8 @@ function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly n
706
785
  * `stepRawTrack` steps. Every pose is assembled as the raw entry's
707
786
  * (`RAW_SHAPE`).
708
787
  */
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);
788
+ export function openRawTrack(doc: CompiledDocument, animation: string, plant: TimelinePlant, reset: RawReset, mode: WalkMode, adjust?: LocalAdjust): { track: TrackState<RawPose>; first: RawPose } {
789
+ return openWalk(doc, animationOf(doc, animation), plant, reset, mode, RAW_SHAPE, adjust);
711
790
  }
712
791
 
713
792
  /** One step of an opened raw track (`openRawTrack`): `walkIn`'s step, the step itself checked by the caller. */
package/src/core/track.ts CHANGED
@@ -17,6 +17,11 @@
17
17
  * scan on A10's schedule and at 60 fps over three cycles — 157,771 poses, every
18
18
  * one the same bits (the PR of issue #1276 carries the table).
19
19
  *
20
+ * Several animations on several tracks, set, queued and mixed as spine-core's
21
+ * `AnimationState` mixes them, are `openTracks` (`./tracks.ts`, issue #1336):
22
+ * a second state machine over the same pose path past the timelines, which
23
+ * this track reaches with the locals its one animation posed.
24
+ *
20
25
  * ## The walk
21
26
  *
22
27
  * - **Held** (`loop: false`): the raw entry's walk — the track time the
@@ -95,6 +100,41 @@
95
100
  * and the events (4,009 of 4,009 on the tree row, 690 of them cutting 12,420
96
101
  * rows).
97
102
  *
103
+ * ## The adjustment (issue #1336)
104
+ *
105
+ * `adjust` is a player's own bone-local value — a breath's scale, a look's
106
+ * rotation — written onto the bones every pose, after the animation's
107
+ * timelines posed the locals at the step's time and before the world
108
+ * transforms, the constraints and the physics step (`LocalAdjust` in
109
+ * `./animation.ts`, which states the view's rules). It runs on every pose the
110
+ * animation is applied to — every `step`, and pose 0 under `reset:
111
+ * 'animation'` — and not on pose 0 under `reset: 'setup'`, the setup pose
112
+ * with nothing applied: a player applies its adjustment wherever it applies
113
+ * the animation state, the first frame included, and spine-core's reset at
114
+ * the setup pose applies none. Absent, every pose is the one the track posed
115
+ * before it existed, bit for bit (`CO46`, unchanged; `CO50` holds a hook that
116
+ * writes nothing to the same bits).
117
+ *
118
+ * `CO50` holds it to spine-core 4.3.13 on gallery/look, whose `head` carries
119
+ * the deformed head mesh and every face bone, and the physics-driven
120
+ * `ahoge_whip` two bones below it: the same `scaleY` written on `head` by the
121
+ * core's hook and on `Bone.pose` between `AnimationState.apply` and
122
+ * `updateWorldTransform(Physics.update)`, every bone's world transform and
123
+ * every drawn vertex equal at tolerance 0 over looping and held walks under
124
+ * both resets — spine-core's skeleton brought back to the setup pose
125
+ * (`setupPoseBones`) before each `apply`, which is the core's own reading:
126
+ * every pose is posed from the setup pose. Measured, and held red there:
127
+ *
128
+ * - **The write carried from frame to frame** — spine-core without that
129
+ * reset, where no timeline writes an unkeyed channel back, so a `scaleY`
130
+ * multiplied every frame compounds (every walk off from pose 2). The core
131
+ * carries no local from one step to the next; a value a player wants
132
+ * carried it keeps and writes again.
133
+ * - **The write at draw time** — made after `updateWorldTransform`, the
134
+ * bone's own matrix alone brought up to date: the bone moves, its
135
+ * children, the meshes they carry and the physics do not.
136
+ * - **Pose 0 not adjusted** under `reset: 'animation'`.
137
+ *
98
138
  * ## The oracle chain
99
139
  *
100
140
  * Nothing here is held to spine-core directly but the events: the live track
@@ -106,19 +146,22 @@
106
146
  *
107
147
  * ⛔ **A track refused part-way stays refused.** A step is checked before
108
148
  * 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
149
+ * non-finite vertex or write — comes after the track time and the physics
150
+ * have moved, so every later `step` refuses naming the step it was refused
151
+ * at, and the caller opens the track again. So does an error the caller's own
152
+ * `adjust` throws: it is thrown mid-step, after the same moves. Seeking is the caller's too: a physics rig has
112
153
  * history, so a seek is "open, then step to the time".
113
154
  */
114
155
  import { CoreInputError, type CompiledDocument } from './index.ts';
115
156
  import { openRawTrack, stepRawTrack, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
116
- import type { TimelinePlant } from './animation.ts';
157
+ import type { LocalAdjust, TimelinePlant } from './animation.ts';
158
+ export type { AdjustableLocals, BoneLocals, LocalAdjust } from './animation.ts';
117
159
 
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. */
160
+ /** How a live track is opened: whether it loops, and where its physics is reset (`RawReset`) — both the caller's, neither with a default — and the caller's adjustment of every pose's bone locals (the header's *The adjustment*). */
119
161
  export interface TrackOptions {
120
162
  loop: boolean;
121
163
  reset: RawReset;
164
+ adjust?: LocalAdjust;
122
165
  }
123
166
 
124
167
  /** A live track (`openTrack`): pose 0, then one pose per `step`. */
@@ -147,7 +190,8 @@ const LOOPED: WalkMode = { loop: true, keep: false, live: true };
147
190
  export function openTrack(doc: CompiledDocument, animation: string, options: TrackOptions, plant: TimelinePlant = {}): LiveTrack {
148
191
  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
192
  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);
193
+ if (options.adjust !== undefined && typeof options.adjust !== 'function') throw new CoreInputError(`the live track's options.adjust is ${typeof options.adjust}, not a function`);
194
+ const { track, first } = openRawTrack(doc, animation, plant, options.reset, options.loop ? LOOPED : HELD, options.adjust);
151
195
  return new Track(track, first);
152
196
  }
153
197
 
@@ -186,7 +230,7 @@ class Track implements LiveTrack {
186
230
  this.latest = pose.animationTime;
187
231
  return pose;
188
232
  } catch (err) {
189
- if (err instanceof CoreInputError) this.refused = `${n} (${err.message})`;
233
+ this.refused = `${n} (${err instanceof Error ? err.message : String(err)})`;
190
234
  throw err;
191
235
  }
192
236
  }