@rig-c/play 1.14.0 → 1.15.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/README.md CHANGED
@@ -556,6 +556,64 @@ active (`null`), track evaluation runs with zero extra work and produces
556
556
  byte-identical poses. An unknown set name throws by name; on a build without
557
557
  `sets.json`, `sets()` is empty (`[]`) and `setSet` throws.
558
558
 
559
+ **Several sets at once** (#168): sets that touch disjoint slots can be active
560
+ together — a mouth viseme driven by audio beside an arm gesture driven by
561
+ input, without building every combination as its own set:
562
+
563
+ ```ts
564
+ player.setSets(['viseme_o', 'gesture_chin']); // exactly these, together
565
+ player.removeSet('viseme_o'); // the gesture stays
566
+ player.addSet('viseme_a'); // beside the active ones; already active = no change
567
+ player.activeSets; // ['viseme_a', 'gesture_chin'] in sets.json order
568
+ player.set; // null: several are active (one active → its name)
569
+ player.setSets([]); // restore base / track attachments
570
+ ```
571
+
572
+ A set *touches* every slot it covers, every slot it `hides` and the
573
+ `<base>_still` companion it hides. Two active sets touching the same slot are
574
+ refused by name — `sets "viseme_o" and "talk_point" both touch slot "mouth"` —
575
+ and nothing changes; precedence is never guessed. Disjoint sets compose as the
576
+ union of each alone, whatever the order they were named or added in, and every
577
+ rule above (applied to the held pose, after track evaluation, uncovered slots
578
+ untouched) holds for the composition. `setSet(name)` keeps meaning *exactly this
579
+ one* (`setSets([name])`) and `setSet(null)` is `setSets([])`; an unknown or
580
+ repeated name throws by name. `slotIdentity()` reports the list as `sets`
581
+ beside `set`.
582
+
583
+ **Drawn bones** (#170): a set from `@rig-c/parts` that replaces a limb
584
+ (a *gesture* subset set) carries `drawn` — the world rotation, in degrees
585
+ (CCW, Y up), each of its bones was painted at — and its attachments are
586
+ placed in the frame of the bones posed there:
587
+
588
+ ```json
589
+ "wave": {
590
+ "slots": { "arm_upper": "arm_upper_wave", "arm_lower": "arm_lower_wave" },
591
+ "drawn": { "shoulder": 50, "elbow": 95 }
592
+ }
593
+ ```
594
+
595
+ While such a set is active its bones are posed at those angles, the way
596
+ `rig-parts check --set` poses them: in document order, each drawn bone's
597
+ local rotation is the drawn angle less its world rotation at local 0 under
598
+ its already-posed parents (no constraint applied), computed once when the set
599
+ is activated. On a track that local rotation replaces the clip's rotation
600
+ of that bone on every pose, before `adjust` (so `adjust` still has the last
601
+ word); every other bone keeps playing. Activating or clearing a drawn set
602
+ poses the held pose again at the same time. Two active sets drawing the same
603
+ bone are refused by name, as two sets touching one slot are. A `drawn` naming
604
+ a bone the document does not have is refused when `sets.json` is loaded.
605
+
606
+ `sets.json` keys are read strictly. Besides `slots`, `hides` and `drawn`, a
607
+ set may carry `drawnLength`, `registration` and `threshold`, which parts
608
+ writes and which place nothing under `check --set`: play reads past them by
609
+ name and gives them no meaning. Any other key, on a set or on the document
610
+ (which takes `spec`, `sets` and `threshold`), is refused by name rather than
611
+ ignored.
612
+
613
+ To switch one driver's set while another stays — a new viseme while a gesture
614
+ holds — name the new list in one call (`setSets(['viseme_a', 'gesture_chin'])`)
615
+ or `removeSet` then `addSet`; the first re-poses once, the second twice.
616
+
559
617
  ### Slot identity snapshot (`slotIdentity()`)
560
618
 
561
619
  To inspect which attachment key, owning skin and visibility state each slot
@@ -566,7 +624,8 @@ holds on the current frame — for a parts explorer or review overlay — withou
566
624
  const snap = player.slotIdentity();
567
625
  // {
568
626
  // generation: number, // matches player.generation and player.posedFrame().generation
569
- // set: string | null, // active set name (player.set)
627
+ // set: string | null, // the one active set (player.set); null when none or several
628
+ // sets: readonly string[], // every active set (player.activeSets), sets.json order
570
629
  // skin: string | null, // active skin name (player.skin ?? null)
571
630
  // slots: readonly [ // every slot in posed draw order (z = 0 .. slots.length - 1)
572
631
  // {
@@ -589,7 +648,7 @@ ones included) appears in `snap.slots`:
589
648
  and the skin that owns it (`skin`).
590
649
  - Non-drawn slots report `attachment: null`, `skin: null`, and why they are
591
650
  absent from `posedFrame().order`:
592
- - `'set-hide'` — hidden by the active attachment set (`set.hides` or a
651
+ - `'set-hide'` — hidden by an active attachment set (`set.hides` or a
593
652
  `<base>_still` companion of a slot the set covers or hides).
594
653
  - `'timeline-null'` — cleared by an active attachment timeline key (`null`).
595
654
  - `'no-attachment'` — no attachment in the setup pose or under the active skin
package/dist/index.js CHANGED
@@ -8579,6 +8579,9 @@ function readSetupMesh(assets, target) {
8579
8579
  return readSetupMesh$1(underNoSkin(assets.document.doc), target);
8580
8580
  }
8581
8581
  const SETS_SPECS = ["spine-parts-sets/1", "rig-parts-sets/1"];
8582
+ const SET_KEYS_NOT_POSED = ["drawnLength", "registration", "threshold"];
8583
+ const SET_KEYS = ["slots", "hides", "drawn", ...SET_KEYS_NOT_POSED];
8584
+ const SETS_DOCUMENT_KEYS = ["spec", "sets", "threshold"];
8582
8585
  function isRecord(value) {
8583
8586
  return typeof value === "object" && value !== null && !Array.isArray(value);
8584
8587
  }
@@ -8597,10 +8600,16 @@ function readSetsText(text, where, doc) {
8597
8600
  `${where}: spec is ${JSON.stringify(raw.spec)}, not "${SETS_SPECS[0]}" or "${SETS_SPECS[1]}"`
8598
8601
  );
8599
8602
  }
8603
+ for (const key of Object.keys(raw)) {
8604
+ if (!SETS_DOCUMENT_KEYS.includes(key)) {
8605
+ throw new Error(`${where}: unknown key ${JSON.stringify(key)} (known: ${SETS_DOCUMENT_KEYS.join(", ")})`);
8606
+ }
8607
+ }
8600
8608
  if (!isRecord(raw.sets) && !Array.isArray(raw.sets)) {
8601
8609
  throw new Error(`${where}: "sets" must be an object or an array`);
8602
8610
  }
8603
8611
  const knownSlots = new Set(doc.slots.map((slot) => slot.name));
8612
+ const knownBones = new Set(doc.bones.map((bone2) => bone2.name));
8604
8613
  const attachmentsBySlot = /* @__PURE__ */ new Map();
8605
8614
  for (const skin of doc.skins) {
8606
8615
  for (const [slotName, table] of Object.entries(skin.attachments)) {
@@ -8636,6 +8645,12 @@ function readSetsText(text, where, doc) {
8636
8645
  throw new Error(`${where}: duplicate set "${name}"`);
8637
8646
  }
8638
8647
  seenNames.add(name);
8648
+ for (const key of Object.keys(item)) {
8649
+ if (Array.isArray(raw.sets) && key === "name") continue;
8650
+ if (!SET_KEYS.includes(key)) {
8651
+ throw new Error(`${where}: set "${name}" has unknown key ${JSON.stringify(key)} (known: ${SET_KEYS.join(", ")})`);
8652
+ }
8653
+ }
8639
8654
  if (!isRecord(item.slots)) {
8640
8655
  throw new Error(`${where}: set "${name}" must have a "slots" object`);
8641
8656
  }
@@ -8670,11 +8685,28 @@ function readSetsText(text, where, doc) {
8670
8685
  if (!hides.includes(hidden)) hides.push(hidden);
8671
8686
  }
8672
8687
  }
8688
+ let drawn;
8689
+ if (item.drawn !== void 0) {
8690
+ if (!isRecord(item.drawn)) {
8691
+ throw new Error(`${where}: set "${name}" "drawn" must be an object of bone name → degrees`);
8692
+ }
8693
+ drawn = {};
8694
+ for (const [boneName, degrees] of Object.entries(item.drawn)) {
8695
+ if (!knownBones.has(boneName)) {
8696
+ throw new Error(`${where}: set "${name}" draws unknown bone "${boneName}"`);
8697
+ }
8698
+ if (typeof degrees !== "number" || !Number.isFinite(degrees)) {
8699
+ throw new Error(`${where}: set "${name}" drawn bone "${boneName}" must be a finite number of degrees`);
8700
+ }
8701
+ drawn[boneName] = degrees;
8702
+ }
8703
+ }
8673
8704
  out.push(
8674
8705
  Object.freeze({
8675
8706
  name,
8676
8707
  slots: Object.freeze(slots),
8677
- hides: Object.freeze(hides)
8708
+ hides: Object.freeze(hides),
8709
+ ...drawn === void 0 || Object.keys(drawn).length === 0 ? {} : { drawn: Object.freeze(drawn) }
8678
8710
  })
8679
8711
  );
8680
8712
  }
@@ -8942,6 +8974,47 @@ function hiddenBySet(slotName, set) {
8942
8974
  }
8943
8975
  return false;
8944
8976
  }
8977
+ function touchedBySet(view, set) {
8978
+ const touched = /* @__PURE__ */ new Set();
8979
+ for (const slot of view.slots) {
8980
+ if (slot.name in set.slots || hiddenBySet(slot.name, set)) touched.add(slot.name);
8981
+ }
8982
+ return touched;
8983
+ }
8984
+ function normaliseDegrees(deg) {
8985
+ let v = (deg % 360 + 360) % 360;
8986
+ if (v > 180) v -= 360;
8987
+ return v;
8988
+ }
8989
+ function drawnLocals(view, drawn) {
8990
+ const names = new Set(view.bones.map((b) => b.name));
8991
+ const realParent = (parent) => parent !== void 0 && parent.endsWith("_d") && names.has(parent.slice(0, -2)) ? parent.slice(0, -2) : parent;
8992
+ const bones = view.bones.map((b) => ({ ...b, parent: realParent(b.parent) }));
8993
+ const locals = /* @__PURE__ */ new Map();
8994
+ for (let i = 0; i < bones.length; i++) {
8995
+ const target = drawn[bones[i].name];
8996
+ if (target === void 0) continue;
8997
+ bones[i] = { ...bones[i], rotation: 0 };
8998
+ const pose = poseRawSetup({ ...view, bones, slots: [], constraints: [] });
8999
+ const world = pose.bones.find((b) => b.name === bones[i].name);
9000
+ if (world === void 0) throw new Error(`drawn bone ${JSON.stringify(bones[i].name)} was not posed`);
9001
+ const rotation = normaliseDegrees(target - normaliseDegrees(Math.atan2(world.c, world.a) * 180 / Math.PI));
9002
+ bones[i] = { ...bones[i], rotation };
9003
+ locals.set(bones[i].name, rotation);
9004
+ }
9005
+ return locals;
9006
+ }
9007
+ function composeSets(sets) {
9008
+ if (sets.length === 0) return null;
9009
+ if (sets.length === 1) return sets[0];
9010
+ const drawn = sets.filter((s) => s.drawn !== void 0);
9011
+ return {
9012
+ name: sets.map((s) => s.name).join("+"),
9013
+ slots: Object.assign({}, ...sets.map((s) => s.slots)),
9014
+ hides: sets.flatMap((s) => s.hides),
9015
+ ...drawn.length === 0 ? {} : { drawn: Object.assign({}, ...drawn.map((s) => s.drawn)) }
9016
+ };
9017
+ }
8945
9018
  function applySetToPose(view, raw, set) {
8946
9019
  const coveredNames = new Set(Object.keys(set.slots));
8947
9020
  const overrideSlots = view.slots.flatMap((slot) => {
@@ -9182,7 +9255,12 @@ class RigPlayer {
9182
9255
  cachedIdentityGen = -1;
9183
9256
  setList;
9184
9257
  setsByName;
9258
+ /** The active sets in declaration order (#168); `activeSet` is their composition. */
9259
+ activeList = [];
9185
9260
  activeSet = null;
9261
+ touchedSets = /* @__PURE__ */ new Map();
9262
+ /** The active sets' drawn bones as local rotations, applied before `adjust` on every pose (#170); null when none draws. */
9263
+ drawnPose = null;
9186
9264
  adjustFn = null;
9187
9265
  /** Current preview patches, reapplied through the public core API on replay; no edit history. */
9188
9266
  physicsPatches = /* @__PURE__ */ new Map();
@@ -9193,6 +9271,7 @@ class RigPlayer {
9193
9271
  draws = () => true;
9194
9272
  /** Handed to every track the player opens; reads the current `adjust` so setting it later needs no reopen. */
9195
9273
  adjustHook = (locals, ctx) => {
9274
+ if (this.drawnPose !== null) for (const [bone2, rotation] of this.drawnPose) locals.bone(bone2).rotation = rotation;
9196
9275
  if (this.adjustFn !== null) this.adjustFn(locals, ctx);
9197
9276
  };
9198
9277
  listeners = {
@@ -9726,8 +9805,7 @@ class RigPlayer {
9726
9805
  this.reopen();
9727
9806
  this.stepTo(time);
9728
9807
  } else if (this.rawPose !== null) {
9729
- this.setup = poseRawSetup(this.view);
9730
- this.setRawPose(this.setup);
9808
+ this.setRawPose(this.drawnSetup());
9731
9809
  } else {
9732
9810
  this.poseGeneration++;
9733
9811
  this.cachedIdentity = null;
@@ -9741,9 +9819,17 @@ class RigPlayer {
9741
9819
  sets() {
9742
9820
  return this.setList;
9743
9821
  }
9744
- /** The currently active attachment set's name, or `null` when the base attachments are active. */
9822
+ /**
9823
+ * The active attachment set's name when exactly one is active; `null` when
9824
+ * the base attachments are active or when several sets are (read
9825
+ * `activeSets` for the list).
9826
+ */
9745
9827
  get set() {
9746
- return this.activeSet?.name ?? null;
9828
+ return this.activeList.length === 1 ? this.activeList[0].name : null;
9829
+ }
9830
+ /** Every active attachment set's name, in `sets.json` declaration order (`[]` when none). */
9831
+ get activeSets() {
9832
+ return this.activeList.map((s) => s.name);
9747
9833
  }
9748
9834
  /**
9749
9835
  * Switches the active attachment set (#134): every slot the set covers takes
@@ -9757,6 +9843,12 @@ class RigPlayer {
9757
9843
  * frames untouched; when no set is active (`null`), track evaluation runs
9758
9844
  * with zero overhead and produces byte-identical poses.
9759
9845
  *
9846
+ * A set with `drawn` (#170) also poses its bones at those world angles, as
9847
+ * `@rig-c/parts check --set` does, replacing a clip's rotation of them.
9848
+ *
9849
+ * `setSet(name)` means exactly this one set: any other active sets are
9850
+ * cleared (`setSets([name])`), and `setSet(null)` is `setSets([])`.
9851
+ *
9760
9852
  * A name not in `sets.json` throws by name; a build without `sets.json` has
9761
9853
  * `sets()` empty and `setSet` throws.
9762
9854
  */
@@ -9764,27 +9856,135 @@ class RigPlayer {
9764
9856
  if (this.setList.length === 0) {
9765
9857
  throw new Error(`cannot setSet(${JSON.stringify(name)}): this model has no sets.json`);
9766
9858
  }
9767
- if (name === null) {
9768
- this.activeSet = null;
9769
- this.clearSeekCache();
9770
- const base2 = this.rawPose ?? (this.setup ?? (this.setup = poseRawSetup(this.view)));
9771
- this.rawPose = base2;
9772
- this.pose = base2;
9773
- this.poseGeneration++;
9774
- this.cachedIdentity = null;
9775
- return;
9859
+ this.activate(name === null ? [] : [this.namedSet(name)]);
9860
+ }
9861
+ /**
9862
+ * Makes exactly `names` the active attachment sets (#168), together: each
9863
+ * set covers and hides its slots as it does alone, provided no two of them
9864
+ * touch the same slot (cover it, hide it, or hide it as a `<base>_still`
9865
+ * companion). Composition does not depend on the order of `names`. An
9866
+ * unknown name, a repeated name or two sets touching one slot throw by name
9867
+ * and leave the active sets as they were; `[]` restores the base.
9868
+ */
9869
+ setSets(names) {
9870
+ if (this.setList.length === 0) {
9871
+ throw new Error(`cannot setSets(${JSON.stringify(names)}): this model has no sets.json`);
9872
+ }
9873
+ const sets = names.map((name) => this.namedSet(name));
9874
+ for (let i = 0; i < sets.length; i++) {
9875
+ for (let j = 0; j < i; j++) {
9876
+ if (sets[i] === sets[j]) throw new Error(`set ${JSON.stringify(sets[i].name)} is named twice in setSets`);
9877
+ }
9776
9878
  }
9879
+ this.activate(sets);
9880
+ }
9881
+ /**
9882
+ * Activates `name` beside the sets already active (#168): a set already
9883
+ * active is left as it is; one touching a slot an active set touches throws
9884
+ * by name (the slot and both sets) and changes nothing.
9885
+ */
9886
+ addSet(name) {
9887
+ if (this.setList.length === 0) {
9888
+ throw new Error(`cannot addSet(${JSON.stringify(name)}): this model has no sets.json`);
9889
+ }
9890
+ const found = this.namedSet(name);
9891
+ if (this.activeList.includes(found)) return;
9892
+ this.activate([...this.activeList, found]);
9893
+ }
9894
+ /**
9895
+ * Deactivates `name`, keeping every other active set (#168). An unknown name
9896
+ * throws by name; a set that is not active is left as it is.
9897
+ */
9898
+ removeSet(name) {
9899
+ if (this.setList.length === 0) {
9900
+ throw new Error(`cannot removeSet(${JSON.stringify(name)}): this model has no sets.json`);
9901
+ }
9902
+ const found = this.namedSet(name);
9903
+ if (!this.activeList.includes(found)) return;
9904
+ this.activate(this.activeList.filter((s) => s !== found));
9905
+ }
9906
+ /** The declared set named `name`, or a throw naming it and the available sets. */
9907
+ namedSet(name) {
9777
9908
  const found = this.setsByName.get(name);
9778
9909
  if (found === void 0) {
9779
9910
  throw new Error(
9780
9911
  `no set named ${JSON.stringify(name)} in sets.json (available: ${this.setList.map((s) => s.name).join(", ")})`
9781
9912
  );
9782
9913
  }
9783
- this.activeSet = found;
9914
+ return found;
9915
+ }
9916
+ /**
9917
+ * Poses again after the drawn bones changed (#170): through the tracks at
9918
+ * the same time when the player has played, else the setup pose with the
9919
+ * drawn bones at their rotations.
9920
+ */
9921
+ reposeDrawn() {
9922
+ const stepper = this.tracks ?? this.track;
9923
+ if (stepper !== null && this.pose !== null) {
9924
+ this.setRawPose(stepper.step(0));
9925
+ return;
9926
+ }
9927
+ this.setRawPose(this.drawnSetup());
9928
+ }
9929
+ /** The setup pose with the active sets' drawn bones at their rotations (#170); the plain setup pose when none draws. */
9930
+ drawnSetup() {
9931
+ const setup = this.setup ?? (this.setup = poseRawSetup(this.view));
9932
+ if (this.drawnPose === null) return setup;
9933
+ return poseRawSetup({
9934
+ ...this.view,
9935
+ bones: this.view.bones.map((b) => {
9936
+ const rotation = this.drawnPose?.get(b.name);
9937
+ return rotation === void 0 ? b : { ...b, rotation };
9938
+ })
9939
+ });
9940
+ }
9941
+ /** The slots `set` touches, computed once per set. */
9942
+ touched(set) {
9943
+ let touched = this.touchedSets.get(set.name);
9944
+ if (touched === void 0) {
9945
+ touched = touchedBySet(this.view, set);
9946
+ this.touchedSets.set(set.name, touched);
9947
+ }
9948
+ return touched;
9949
+ }
9950
+ /**
9951
+ * Makes `sets` (distinct) the active sets: refuses any two touching one slot
9952
+ * before changing anything, then applies their composition to the held pose.
9953
+ */
9954
+ activate(sets) {
9955
+ for (let i = 0; i < sets.length; i++) {
9956
+ for (let j = 0; j < i; j++) {
9957
+ const other = this.touched(sets[j]);
9958
+ for (const slot of this.touched(sets[i])) {
9959
+ if (other.has(slot)) {
9960
+ throw new Error(
9961
+ `sets ${JSON.stringify(sets[j].name)} and ${JSON.stringify(sets[i].name)} both touch slot ${JSON.stringify(slot)}; active sets must cover and hide disjoint slots`
9962
+ );
9963
+ }
9964
+ }
9965
+ const otherDrawn = sets[j].drawn;
9966
+ for (const bone2 of Object.keys(sets[i].drawn ?? {})) {
9967
+ if (otherDrawn !== void 0 && bone2 in otherDrawn) {
9968
+ throw new Error(
9969
+ `sets ${JSON.stringify(sets[j].name)} and ${JSON.stringify(sets[i].name)} both pose bone ${JSON.stringify(bone2)}; active sets must draw disjoint bones`
9970
+ );
9971
+ }
9972
+ }
9973
+ }
9974
+ }
9975
+ const drawnBefore = this.activeSet?.drawn;
9976
+ this.activeList = this.setList.filter((s) => sets.includes(s));
9977
+ this.activeSet = composeSets(this.activeList);
9784
9978
  this.clearSeekCache();
9979
+ const drawn = this.activeSet?.drawn;
9980
+ if (drawn !== drawnBefore && !(drawn === void 0 && drawnBefore === void 0)) {
9981
+ this.drawnPose = drawn === void 0 ? null : drawnLocals(this.view, drawn);
9982
+ this.reposeDrawn();
9983
+ return;
9984
+ }
9785
9985
  const base = this.rawPose ?? (this.setup ?? (this.setup = poseRawSetup(this.view)));
9786
9986
  this.rawPose = base;
9787
- this.pose = applySetToPose(this.view, base, found);
9987
+ this.pose = this.activeSet === null ? base : applySetToPose(this.view, base, this.activeSet);
9788
9988
  this.poseGeneration++;
9789
9989
  this.cachedIdentity = null;
9790
9990
  }
@@ -10020,7 +10220,8 @@ class RigPlayer {
10020
10220
  }
10021
10221
  const snapshot = Object.freeze({
10022
10222
  generation: this.poseGeneration,
10023
- set: this.activeSet?.name ?? null,
10223
+ set: this.set,
10224
+ sets: Object.freeze(this.activeSets),
10024
10225
  skin: this.skinName ?? null,
10025
10226
  slots: Object.freeze(slots)
10026
10227
  });