rig-c 2.35.1 → 2.36.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.36.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
  /**
@@ -778,6 +880,21 @@ export function posedSlots(
778
880
  return { rows, conflicts };
779
881
  }
780
882
 
883
+ /**
884
+ * One step of the stepped phase, as a walk hands it to the bones: the physics
885
+ * context and the clock before the step (`./constraints_physics.ts`), and the
886
+ * caller's adjustment of the locals (`LocalAdjust`). The adjustment rides here
887
+ * rather than on the plant because it is not a rule the core measured and a
888
+ * control plants back — it is the caller's input to one walk — and because
889
+ * the step context exists exactly where a walk is stepped: the live track's
890
+ * every pose but a `reset: 'setup'` pose 0, which no animation is applied to.
891
+ */
892
+ export interface PoseStep {
893
+ ctx: PhysicsStepContext;
894
+ before: number;
895
+ adjust?: LocalAdjust;
896
+ }
897
+
781
898
  /**
782
899
  * The bone rows at `t`, in the oracle's shape and rounding. With `constraints`
783
900
  * — the animation's constraint timelines — the document's constraints are
@@ -792,7 +909,7 @@ export function posedBoneRows(doc: CompiledDocument, timelines: CoreAnimationTim
792
909
  }
793
910
 
794
911
  /** `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> } {
912
+ export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant = {}, constraints?: CoreConstraintTimelines, sliders?: SliderApplication[], step?: PoseStep): { rows: CoreBoneRow[]; world: Map<string, CoreWorld> } {
796
913
  const posed = posedBoneStep(doc, timelines, t, plant, constraints, sliders, step);
797
914
  return { rows: stepRows(posed), world: posed.world };
798
915
  }
@@ -804,7 +921,7 @@ export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTi
804
921
  * no row, so the scan's walk (`scanWalkIn` in `./raw.ts`, A10's) asks for
805
922
  * this; the raw entry's walk still forms the rows it does not read, unchanged.
806
923
  */
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> {
924
+ export function posedBoneWorldAlone(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): Map<string, CoreWorld> {
808
925
  return posedBoneStep(doc, timelines, t, plant, constraints, sliders, step).world;
809
926
  }
810
927
 
@@ -835,9 +952,11 @@ function stepRows(posed: PosedStep): CoreBoneRow[] {
835
952
  }
836
953
 
837
954
  /** `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 {
955
+ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): PosedStep {
839
956
  const active = activeBones(doc);
840
957
  const bones = posedBones(doc, timelines, t, plant);
958
+ // The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
959
+ if (step?.adjust !== undefined) adjustLocals(doc, bones, step.adjust);
841
960
  let world = (plant.evaluate ?? worldTransforms)(bones, active);
842
961
  if (constraints !== undefined && step !== undefined) {
843
962
  // 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.
package/src/core/entry.ts CHANGED
@@ -26,7 +26,7 @@ export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setup
26
26
  export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
27
27
  export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
28
28
  export { openTrack } from './track.ts';
29
- export type { LiveTrack, TrackOptions } from './track.ts';
29
+ export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust } from './track.ts';
30
30
  export type { WalkPose } from './walk.ts';
31
31
  export { documentPageLookup, drawnRegions, regionPageUvs, meshPageUvs, readUvSequences } from './uvs.ts';
32
32
  export type { DrawnRegion, UvPage, UvRegion, UvSource } from './uvs.ts';
package/src/core/raw.ts CHANGED
@@ -114,7 +114,7 @@
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 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';
@@ -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,7 +659,7 @@ 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;
@@ -665,7 +667,7 @@ function stepWalk<P>(track: TrackState<P>, dt: number, first = false): P {
665
667
  const before = ctx.time;
666
668
  ctx.time += mode.clock === undefined ? dt : mode.clock(dt, t - (first ? 0 : Math.max(last, 0)));
667
669
  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 });
670
+ const world = 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 });
669
671
  if (first) ctx.phase = 'update';
670
672
  const placeholders = new Map<string, string | null>();
671
673
  const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
@@ -706,8 +708,8 @@ function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly n
706
708
  * `stepRawTrack` steps. Every pose is assembled as the raw entry's
707
709
  * (`RAW_SHAPE`).
708
710
  */
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
+ export function openRawTrack(doc: CompiledDocument, animation: string, plant: TimelinePlant, reset: RawReset, mode: WalkMode, adjust?: LocalAdjust): { track: TrackState<RawPose>; first: RawPose } {
712
+ return openWalk(doc, animationOf(doc, animation), plant, reset, mode, RAW_SHAPE, adjust);
711
713
  }
712
714
 
713
715
  /** One step of an opened raw track (`openRawTrack`): `walkIn`'s step, the step itself checked by the caller. */
package/src/core/track.ts CHANGED
@@ -95,6 +95,41 @@
95
95
  * and the events (4,009 of 4,009 on the tree row, 690 of them cutting 12,420
96
96
  * rows).
97
97
  *
98
+ * ## The adjustment (issue #1336)
99
+ *
100
+ * `adjust` is a player's own bone-local value — a breath's scale, a look's
101
+ * rotation — written onto the bones every pose, after the animation's
102
+ * timelines posed the locals at the step's time and before the world
103
+ * transforms, the constraints and the physics step (`LocalAdjust` in
104
+ * `./animation.ts`, which states the view's rules). It runs on every pose the
105
+ * animation is applied to — every `step`, and pose 0 under `reset:
106
+ * 'animation'` — and not on pose 0 under `reset: 'setup'`, the setup pose
107
+ * with nothing applied: a player applies its adjustment wherever it applies
108
+ * the animation state, the first frame included, and spine-core's reset at
109
+ * the setup pose applies none. Absent, every pose is the one the track posed
110
+ * before it existed, bit for bit (`CO46`, unchanged; `CO50` holds a hook that
111
+ * writes nothing to the same bits).
112
+ *
113
+ * `CO50` holds it to spine-core 4.3.13 on gallery/look, whose `head` carries
114
+ * the deformed head mesh and every face bone, and the physics-driven
115
+ * `ahoge_whip` two bones below it: the same `scaleY` written on `head` by the
116
+ * core's hook and on `Bone.pose` between `AnimationState.apply` and
117
+ * `updateWorldTransform(Physics.update)`, every bone's world transform and
118
+ * every drawn vertex equal at tolerance 0 over looping and held walks under
119
+ * both resets — spine-core's skeleton brought back to the setup pose
120
+ * (`setupPoseBones`) before each `apply`, which is the core's own reading:
121
+ * every pose is posed from the setup pose. Measured, and held red there:
122
+ *
123
+ * - **The write carried from frame to frame** — spine-core without that
124
+ * reset, where no timeline writes an unkeyed channel back, so a `scaleY`
125
+ * multiplied every frame compounds (every walk off from pose 2). The core
126
+ * carries no local from one step to the next; a value a player wants
127
+ * carried it keeps and writes again.
128
+ * - **The write at draw time** — made after `updateWorldTransform`, the
129
+ * bone's own matrix alone brought up to date: the bone moves, its
130
+ * children, the meshes they carry and the physics do not.
131
+ * - **Pose 0 not adjusted** under `reset: 'animation'`.
132
+ *
98
133
  * ## The oracle chain
99
134
  *
100
135
  * Nothing here is held to spine-core directly but the events: the live track
@@ -106,19 +141,22 @@
106
141
  *
107
142
  * ⛔ **A track refused part-way stays refused.** A step is checked before
108
143
  * 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
144
+ * non-finite vertex or write — comes after the track time and the physics
145
+ * have moved, so every later `step` refuses naming the step it was refused
146
+ * at, and the caller opens the track again. So does an error the caller's own
147
+ * `adjust` throws: it is thrown mid-step, after the same moves. Seeking is the caller's too: a physics rig has
112
148
  * history, so a seek is "open, then step to the time".
113
149
  */
114
150
  import { CoreInputError, type CompiledDocument } from './index.ts';
115
151
  import { openRawTrack, stepRawTrack, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
116
- import type { TimelinePlant } from './animation.ts';
152
+ import type { LocalAdjust, TimelinePlant } from './animation.ts';
153
+ export type { AdjustableLocals, BoneLocals, LocalAdjust } from './animation.ts';
117
154
 
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. */
155
+ /** 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
156
  export interface TrackOptions {
120
157
  loop: boolean;
121
158
  reset: RawReset;
159
+ adjust?: LocalAdjust;
122
160
  }
123
161
 
124
162
  /** A live track (`openTrack`): pose 0, then one pose per `step`. */
@@ -147,7 +185,8 @@ const LOOPED: WalkMode = { loop: true, keep: false, live: true };
147
185
  export function openTrack(doc: CompiledDocument, animation: string, options: TrackOptions, plant: TimelinePlant = {}): LiveTrack {
148
186
  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
187
  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);
188
+ if (options.adjust !== undefined && typeof options.adjust !== 'function') throw new CoreInputError(`the live track's options.adjust is ${typeof options.adjust}, not a function`);
189
+ const { track, first } = openRawTrack(doc, animation, plant, options.reset, options.loop ? LOOPED : HELD, options.adjust);
151
190
  return new Track(track, first);
152
191
  }
153
192
 
@@ -186,7 +225,7 @@ class Track implements LiveTrack {
186
225
  this.latest = pose.animationTime;
187
226
  return pose;
188
227
  } catch (err) {
189
- if (err instanceof CoreInputError) this.refused = `${n} (${err.message})`;
228
+ this.refused = `${n} (${err instanceof Error ? err.message : String(err)})`;
190
229
  throw err;
191
230
  }
192
231
  }