spine-rigc 2.9.0 → 2.10.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": "spine-rigc",
3
- "version": "2.9.0",
3
+ "version": "2.10.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": {
@@ -600,15 +600,33 @@ export function keyIndexAt(keys: ReadonlyArray<{ time: number }>, t: number): nu
600
600
  return found;
601
601
  }
602
602
 
603
+ /**
604
+ * Each Bézier segment's polylines, one per channel, kept with the key that
605
+ * starts the segment and the key that ends it (issue #1134): a polyline is a
606
+ * function of those two keys' stated numbers and handles only, and a walk
607
+ * evaluated the same segment's on every step. A key read is never written; a
608
+ * plant that changes a key passes a copy, which keeps polylines of its own.
609
+ */
610
+ const segmentPolylines = new WeakMap<CoreKey, { end: CoreKey; channels: Array<number[] | undefined> }>();
611
+
603
612
  /** Channel `channel` of the segment starting at key `index` (not the last), at `t` — the header's three curves. */
604
613
  export function channelAt(keys: readonly CoreKey[], index: number, channel: number, t: number): number {
605
614
  const a = keys[index];
606
615
  const b = keys[index + 1];
607
616
  if (b === undefined || a.curve === 'stepped') return a.values[channel];
608
617
  if (a.curve === 'linear') return a.values[channel] + ((t - a.time) / (b.time - a.time)) * (b.values[channel] - a.values[channel]);
609
- const c = a.curve.slice(channel * 4, channel * 4 + 4);
610
- const inner = bezierPolyline(a.stated.time, a.stated.values[channel], c[0], c[1], c[2], c[3], b.stated.time, b.stated.values[channel]);
611
- const points = [a.time, a.values[channel], ...inner, b.time, b.values[channel]];
618
+ let kept = segmentPolylines.get(a);
619
+ if (kept === undefined || kept.end !== b) {
620
+ kept = { end: b, channels: [] };
621
+ segmentPolylines.set(a, kept);
622
+ }
623
+ let points = kept.channels[channel];
624
+ if (points === undefined) {
625
+ const c = a.curve.slice(channel * 4, channel * 4 + 4);
626
+ const inner = bezierPolyline(a.stated.time, a.stated.values[channel], c[0], c[1], c[2], c[3], b.stated.time, b.stated.values[channel]);
627
+ points = [a.time, a.values[channel], ...inner, b.time, b.values[channel]];
628
+ kept.channels[channel] = points;
629
+ }
612
630
  let i = 2;
613
631
  while (i < points.length - 2 && points[i] < t) i += 2;
614
632
  const [x0, y0, x1, y1] = [points[i - 2], points[i - 1], points[i], points[i + 1]];
@@ -635,9 +653,22 @@ function keyAt(keys: readonly CoreKey[], t: number, plant: TimelinePlant): CoreK
635
653
  return index < 0 ? null : keys[index];
636
654
  }
637
655
 
656
+ /** Each animation's bone timelines by bone name, kept with the timelines they index (`posedBones`). */
657
+ const boneTimelinesByName = new WeakMap<CoreAnimationTimelines, ReadonlyMap<string, CoreTarget<BoneTimelineKind>>>();
658
+
659
+ /** The animation's bone timelines by bone name, built once per timelines object (issue #1134: a walk built it again on every step); timelines read are never written. */
660
+ function boneTimelinesOf(timelines: CoreAnimationTimelines): ReadonlyMap<string, CoreTarget<BoneTimelineKind>> {
661
+ let byName = boneTimelinesByName.get(timelines);
662
+ if (byName === undefined) {
663
+ byName = new Map(timelines.bones.map((b) => [b.name, b]));
664
+ boneTimelinesByName.set(timelines, byName);
665
+ }
666
+ return byName;
667
+ }
668
+
638
669
  /** The bones posed by the animation's bone timelines at `t`: copies of the setup bones with the posed fields — the header's rules. */
639
670
  export function posedBones(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant = {}): ModelBone[] {
640
- const byName = new Map(timelines.bones.map((b) => [b.name, b]));
671
+ const byName = boneTimelinesOf(timelines);
641
672
  return doc.bones.map((setup) => {
642
673
  const target = byName.get(setup.name);
643
674
  if (target === undefined) return setup;
@@ -762,6 +793,38 @@ export function posedBoneRows(doc: CompiledDocument, timelines: CoreAnimationTim
762
793
 
763
794
  /** `posedBoneRows`, with the world transforms the rows were read off — what the sample's attachments are posed through. */
764
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> } {
796
+ const posed = posedBoneStep(doc, timelines, t, plant, constraints, sliders, step);
797
+ return { rows: stepRows(posed), world: posed.world };
798
+ }
799
+
800
+ /**
801
+ * One pose of the bones before its rows are read: the world transforms, and
802
+ * what the rows are rounded from. A stepped walk reads rows only off the step
803
+ * that lands on a sample (issue #1134: of the stepped run's poses, nine in ten
804
+ * are steps between samples), so `stepRows` rounds them when they are read,
805
+ * once; every bone's transform is required here, at every step.
806
+ */
807
+ interface PosedStep {
808
+ bones: ModelBone[];
809
+ world: Map<string, CoreWorld>;
810
+ active: ReadonlySet<string>;
811
+ round: (v: number) => number | null;
812
+ rows?: CoreBoneRow[];
813
+ }
814
+
815
+ /** A step's bone rows, in the oracle's shape and rounding, rounded the first time they are read. */
816
+ function stepRows(posed: PosedStep): CoreBoneRow[] {
817
+ if (posed.rows !== undefined) return posed.rows;
818
+ const { world, active, round } = posed;
819
+ posed.rows = posed.bones.map((b): CoreBoneRow => {
820
+ const w = world.get(b.name) as CoreWorld;
821
+ return [b.name, round(w.worldX), round(w.worldY), round(w.a), round(w.b), round(w.c), round(w.d), active.has(b.name) ? 1 : 0, b.parent ?? null];
822
+ });
823
+ return posed.rows;
824
+ }
825
+
826
+ /** `posedBoneWorld` up to its rows, refusing a bone the evaluator gave no transform (`PosedStep`). */
827
+ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: { ctx: PhysicsStepContext; before: number } | undefined): PosedStep {
765
828
  const active = activeBones(doc);
766
829
  const bones = posedBones(doc, timelines, t, plant);
767
830
  let world = (plant.evaluate ?? worldTransforms)(bones, active);
@@ -777,14 +840,8 @@ export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTi
777
840
  const previous = setupRecords.some((r) => r.kind === 'path') ? applyConstraints(doc.bones, (plant.evaluate ?? worldTransforms)(doc.bones, active), active, plant.constraints ? plant.constraints(setupRecords) : setupRecords, null, undefined, undefined, undefined, solverRules(plant.solver)) : null;
778
841
  world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, undefined, solverRules(plant.solver));
779
842
  }
780
- const posed = world;
781
- const round = plant.round ?? gridRound;
782
- const rows = bones.map((b): CoreBoneRow => {
783
- const w = posed.get(b.name);
784
- if (w === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
785
- return [b.name, round(w.worldX), round(w.worldY), round(w.a), round(w.b), round(w.c), round(w.d), active.has(b.name) ? 1 : 0, b.parent ?? null];
786
- });
787
- return { rows, world: posed };
843
+ for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
844
+ return { bones, world, active, round: plant.round ?? gridRound };
788
845
  }
789
846
 
790
847
  /** 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. */
@@ -904,9 +961,9 @@ export function poseAnimations(doc: CompiledDocument, phase: SamplePhase, n: num
904
961
  const samples: CoreSample[] = [];
905
962
  let last = -1;
906
963
  // Under `--physics step` (issue #956): one walk per animation — reset at 0, then the oracle's schedule, each sample posed off its last step (`./constraints_physics.ts`).
907
- const walk = dt === undefined || bonesReason !== null ? null : { ctx: freshStepContext(plant.physicsStep), now: 0, schedule: stepSchedule(phase, d, n, dt), sliders: [] as SliderApplication[], posed: null as { rows: CoreBoneRow[]; world: Map<string, CoreWorld> } | null };
964
+ const walk = dt === undefined || bonesReason !== null ? null : { ctx: freshStepContext(plant.physicsStep), now: 0, schedule: stepSchedule(phase, d, n, dt), sliders: [] as SliderApplication[], posed: null as PosedStep | null };
908
965
  if (walk !== null) {
909
- walk.posed = posedBoneWorld(doc, anim.timelines, 0, plant, anim.constraints, walk.sliders, { ctx: walk.ctx, before: 0 });
966
+ walk.posed = posedBoneStep(doc, anim.timelines, 0, plant, anim.constraints, walk.sliders, { ctx: walk.ctx, before: 0 });
910
967
  walk.ctx.phase = 'update';
911
968
  }
912
969
  for (let i = 0; i < n; i++) {
@@ -918,11 +975,11 @@ export function poseAnimations(doc: CompiledDocument, phase: SamplePhase, n: num
918
975
  const before = walk.ctx.time;
919
976
  walk.ctx.time += s - walk.now;
920
977
  walk.sliders = [];
921
- walk.posed = posedBoneWorld(doc, anim.timelines, s, plant, anim.constraints, walk.sliders, { ctx: walk.ctx, before });
978
+ walk.posed = posedBoneStep(doc, anim.timelines, s, plant, anim.constraints, walk.sliders, { ctx: walk.ctx, before });
922
979
  walk.now = s;
923
980
  }
924
981
  sliders = walk.sliders;
925
- posed = walk.posed;
982
+ posed = walk.posed === null ? null : { rows: stepRows(walk.posed), world: walk.posed.world };
926
983
  } else posed = bonesReason === null ? posedBoneWorld(doc, anim.timelines, t, plant, anim.constraints, sliders) : null;
927
984
  const placeholders = new Map<string, string | null>();
928
985
  const slots = posedSlots(doc, anim.timelines, t, plant, sliders, placeholders);
@@ -1518,7 +1518,12 @@ export type ConstraintPlant = (records: CoreConstraintRecord[]) => CoreConstrain
1518
1518
  * `./constraints_physics.ts`); without it, it applies nothing.
1519
1519
  */
1520
1520
  export function applyConstraints(bones: readonly ModelBone[], world: ReadonlyMap<string, CoreWorld>, active: ReadonlySet<string>, records: readonly CoreConstraintRecord[], previous: ReadonlyMap<string, CoreWorld> | null = null, applied?: SliderApplication[], physics?: PhysicsStepContext, settled?: (state: SolverState) => void, rules: Readonly<SolverRules> = RUNTIME_SOLVER_RULES): Map<string, CoreWorld> {
1521
- const state: SolverState = { bones: bones.map((b) => ({ ...b })), index: new Map(bones.map((b, i) => [b.name, i])), world: new Map(world), active, rules };
1521
+ // No record: nothing to solve, so the world as given, copied (issue #1134) — and what `sliderPhysicsTarget` leaves on the step's context with no record, an empty carry, under the plant that carries one.
1522
+ if (records.length === 0 && settled === undefined) {
1523
+ if (physics !== undefined && !rules.sliderPhysicsLastsOnePass) physics.carried = new Map();
1524
+ return new Map(world);
1525
+ }
1526
+ const state: SolverState = { bones: bones.map((b) => ({ ...b })), index: boneIndex(bones, active), world: new Map(world), active, rules };
1522
1527
  const skipped = new Set<number>();
1523
1528
  records.forEach((c, i) => {
1524
1529
  if (inactiveWhy(state, c) !== null) skipped.add(i);
@@ -1573,19 +1578,45 @@ export function applyConstraints(bones: readonly ModelBone[], world: ReadonlyMap
1573
1578
  return state.world;
1574
1579
  }
1575
1580
 
1581
+ /** The last bone index each view's active set was asked for, with the names it was built over (`boneIndex`). */
1582
+ const indexOfView = new WeakMap<ReadonlySet<string>, { names: string[]; index: Map<string, number> }>();
1583
+
1584
+ /**
1585
+ * Each bone's position in `bones`, by name — the solver's index. Every pose of
1586
+ * a view lists the same bones in the same order (issue #1134: a walk built it
1587
+ * anew on each of its passes), so the index last built under the view's
1588
+ * active set (`activeBones`, one object per view) is reused when the names
1589
+ * agree position by position, and built again otherwise. Read, never written.
1590
+ */
1591
+ function boneIndex(bones: readonly ModelBone[], active: ReadonlySet<string>): Map<string, number> {
1592
+ const kept = indexOfView.get(active);
1593
+ if (kept !== undefined && kept.names.length === bones.length && bones.every((b, i) => b.name === kept.names[i])) return kept.index;
1594
+ const index = new Map(bones.map((b, i) => [b.name, i]));
1595
+ indexOfView.set(active, { names: bones.map((b) => b.name), index });
1596
+ return index;
1597
+ }
1598
+
1576
1599
  /** After a constraint: the bones it moved in world space read back into local values, and every bone below one it moved posed again (the header's update order). */
1577
1600
  function repose(state: SolverState, changed: readonly string[], inWorld: readonly string[]): void {
1578
1601
  for (const name of inWorld) {
1579
1602
  const b = bone(state, name);
1580
1603
  localFromWorld(b, parentWorld(state, b), state.world.get(name) as CoreWorld, state.rules);
1581
1604
  }
1582
- const moved = new Set(changed);
1605
+ // `changed` and `inWorld` are a constraint's few bones: read as they are rather than copied into sets on each of a walk's passes (issue #1134).
1606
+ const moved = changed;
1583
1607
  const below = new Set(changed);
1584
- const keep = new Set(inWorld);
1585
- for (const b of state.bones) {
1608
+ const keep = inWorld;
1609
+ // Bones are parents first (`readModel` refuses any other order) and a bone joins `below` only through its parent, so none before the first bone moved can: the walk starts there (issue #1134).
1610
+ let from = state.bones.length;
1611
+ for (const name of changed) {
1612
+ const at = state.index.get(name);
1613
+ if (at !== undefined && at < from) from = at;
1614
+ }
1615
+ for (let k = from; k < state.bones.length; k++) {
1616
+ const b = state.bones[k];
1586
1617
  // Not through an inactive bone the constraint did not move itself (issue #979): the bones below it keep what the constraints wrote into them.
1587
- if (b.parent !== undefined && below.has(b.parent) && (!state.rules.inactiveHoldsItsWorld || state.active.has(b.parent) || moved.has(b.parent))) below.add(b.name);
1588
- if (below.has(b.name) && !keep.has(b.name)) poseBone(state, b);
1618
+ if (b.parent !== undefined && below.has(b.parent) && (!state.rules.inactiveHoldsItsWorld || state.active.has(b.parent) || moved.includes(b.parent))) below.add(b.name);
1619
+ if (below.has(b.name) && !keep.includes(b.name)) poseBone(state, b);
1589
1620
  }
1590
1621
  }
1591
1622
 
package/src/core/index.ts CHANGED
@@ -1111,8 +1111,26 @@ export const NOT_ADMITTED: ReadonlyArray<readonly [string, string]> = [];
1111
1111
  /** The setup blocks the core poses, in the document's order. */
1112
1112
  const POSED_BLOCKS = ['setup.bones', 'setup.slots', 'setup.drawOrder', 'setup.attachments', 'setup.clips', 'setup.clipped'] as const;
1113
1113
 
1114
- /** Bones active under the skin view posed — the rule measured in the header (`all`) and in `./skins.ts` (a named skin). */
1115
- export function activeBones(doc: CompiledDocument): Set<string> {
1114
+ /** Each view's active bones, kept for the view (`activeBones`). */
1115
+ const activeOfView = new WeakMap<CompiledDocument, ReadonlySet<string>>();
1116
+
1117
+ /**
1118
+ * Bones active under the skin view posed — the rule measured in the header
1119
+ * (`all`) and in `./skins.ts` (a named skin). Derived once per view object and
1120
+ * kept with it (issue #1134): the answer reads the view's bones and skins
1121
+ * only, a view is never written once it is posed, and every pose of a walk
1122
+ * asked again. Read-only to every caller; a view built anew (`underSkin`,
1123
+ * `underNoSkin`, a plant's) derives its own.
1124
+ */
1125
+ export function activeBones(doc: CompiledDocument): ReadonlySet<string> {
1126
+ const kept = activeOfView.get(doc);
1127
+ if (kept !== undefined) return kept;
1128
+ const made = deriveActiveBones(doc);
1129
+ activeOfView.set(doc, made);
1130
+ return made;
1131
+ }
1132
+
1133
+ function deriveActiveBones(doc: CompiledDocument): ReadonlySet<string> {
1116
1134
  const named = new Set(appliedSkins(doc).flatMap((s) => s.bones));
1117
1135
  const parentOf = new Map(doc.bones.map((b) => [b.name, b.parent]));
1118
1136
  const reached = new Set<string>();
@@ -1150,26 +1168,50 @@ export function shownRow(shown: CoreShown): { name: string; path: string | null
1150
1168
  return { name, path: CORE_REGION_KINDS.has(shown.record.kind) ? (shown.record.path ?? name) : null };
1151
1169
  }
1152
1170
 
1171
+ /** Each view's resolutions, by slot and placeholder, kept for the view (`shownAttachment`). */
1172
+ const shownOfView = new WeakMap<CompiledDocument, Map<string, Map<string, ShownResolution>>>();
1173
+
1153
1174
  /**
1154
1175
  * What a slot shows at setup under every skin at once — the header's rule:
1155
1176
  * nothing for a `null` placeholder or one no skin fills; the one record where
1156
1177
  * every skin filling the placeholder gives the same row; a `conflict`, naming
1157
1178
  * each skin in the model's order, where they differ.
1179
+ *
1180
+ * The answer reads the view's skins and the slot's name and placeholder only,
1181
+ * so it is resolved once per view, slot and placeholder and kept (issue
1182
+ * #1134: every sample of a walk resolved every slot again). Read-only to every
1183
+ * caller; a view built anew resolves its own.
1158
1184
  */
1159
1185
  export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownResolution {
1160
1186
  if (slot.setup === null) return null;
1161
- const placeholder = slot.setup;
1187
+ let bySlot = shownOfView.get(doc);
1188
+ if (bySlot === undefined) {
1189
+ bySlot = new Map();
1190
+ shownOfView.set(doc, bySlot);
1191
+ }
1192
+ let byPlaceholder = bySlot.get(slot.name);
1193
+ if (byPlaceholder === undefined) {
1194
+ byPlaceholder = new Map();
1195
+ bySlot.set(slot.name, byPlaceholder);
1196
+ }
1197
+ if (byPlaceholder.has(slot.setup)) return byPlaceholder.get(slot.setup) as ShownResolution;
1198
+ const resolved = resolveShown(doc, slot.name, slot.setup);
1199
+ byPlaceholder.set(slot.setup, resolved);
1200
+ return resolved;
1201
+ }
1202
+
1203
+ function resolveShown(doc: CompiledDocument, slotName: string, placeholder: string): ShownResolution {
1162
1204
  // Under a named skin: that skin's record, else the default skin's, else nothing (`./skins.ts`).
1163
1205
  if (doc.skin !== CORE_ALL_SKINS) {
1164
1206
  for (const skin of lookupSkins(doc)) {
1165
- const record = skin.attachments[slot.name]?.[placeholder];
1207
+ const record = skin.attachments[slotName]?.[placeholder];
1166
1208
  if (record !== undefined) return { skin: skin.name, placeholder, record };
1167
1209
  }
1168
1210
  return null;
1169
1211
  }
1170
1212
  const filling: CoreShown[] = [];
1171
1213
  for (const skin of doc.skins) {
1172
- const record = skin.attachments[slot.name]?.[placeholder];
1214
+ const record = skin.attachments[slotName]?.[placeholder];
1173
1215
  if (record !== undefined) filling.push({ skin: skin.name, placeholder, record });
1174
1216
  }
1175
1217
  if (filling.length === 0) return null;
package/src/core/uvs.ts CHANGED
@@ -369,7 +369,8 @@ export function shownAtSetup(doc: CompiledDocument): { shown: ShownGeometry[]; w
369
369
  */
370
370
  export function shownAtSample(doc: CompiledDocument, anim: CoreAnimation, t: number): { shown: ShownGeometry[]; why: string[] } {
371
371
  const sliders: SliderApplication[] = [];
372
- posedBoneWorld(doc, anim.timelines, t, {}, anim.constraints, sliders);
372
+ // The bones are posed for what the sliders apply, and only a slider constraint applies one (`applySlider`): with none, the list stays empty and the pose would be read for nothing (issue #1134).
373
+ if (doc.constraints.some((c) => c.kind === 'slider')) posedBoneWorld(doc, anim.timelines, t, {}, anim.constraints, sliders);
373
374
  const placeholders = new Map<string, string | null>();
374
375
  posedSlots(doc, anim.timelines, t, {}, sliders, placeholders);
375
376
  return attachmentStates(doc, shownAttachment, placeholders, { timelines: anim.timelines, t }, sliders);