@rig-c/parts 1.5.5 → 1.7.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/src/rig.ts CHANGED
@@ -27,9 +27,38 @@
27
27
  * numbers the reference wrote. A key the turn would re-aim — a translate
28
28
  * key under a turned parent, a scale or shear key on a turned bone — is
29
29
  * refused (`RIG_KEY_FRAME_UNTURNED`); rotate keys mean what they meant.
30
- * A single bone with a `tip` is not turned (its tip is still a segment
31
- * end for the weights); giving it a lever would be the same change on
32
- * that bone and its children, and is not made here.
30
+ * A single bone with a `tip` is turned the same way (issue #239): its
31
+ * `rotation` is the direction from its origin to its tip and its `length`
32
+ * that distance, the segment its weights are already measured along, so a
33
+ * `tip` the config states is never accepted and ignored. A tip on its own
34
+ * origin has no direction and is refused (`RIG_TIP_DISTINCT`).
35
+ * - **The face's axis** (issue #239). The five face bones a blink or an
36
+ * expression scales or translates, {@link FACE_BONES}, take the eye line's
37
+ * angle as their world rotation, so the blink's `scaley` squashes across
38
+ * the eye line and not across the page's horizontal on a tilted head. The
39
+ * line exists when parts.json holds the parts tagged `eyewhite-r` and
40
+ * `eyewhite-l` (the first of each, a cut's piece not counted — the parts
41
+ * `propose` stands `eye_r` and `eye_l` on) and config.bones declares both
42
+ * eye bones; it runs through the two eye bones' origins, read from the
43
+ * image-left one to the image-right one, so the angle is always the
44
+ * measured one, inside (−90°, 90°), which tag is which side does not enter
45
+ * it, and it is the line rigc's A51 draws. With one eyewhite or none, an
46
+ * eye bone missing, or two eye bones in one column, there is no line:
47
+ * {@link FaceAxis} says why and the face bones
48
+ * keep the page's axes. A face bone with its own `tip` takes the tip. A
49
+ * bone under a face bone that nothing else turns keeps its parent's axis,
50
+ * so a key on it means the face's axis too; the face bones' controls carry
51
+ * the turn as a link's do. Regions on these bones are turned back as every
52
+ * region is, so the setup pose is the same picture; a scale key on a face
53
+ * bone, and a translate key on a bone under one, mean the face's axes and
54
+ * are not refused. A translate key on a face bone itself still moves it
55
+ * along its parent's (the head's) axes — Spine's rule, which turning the
56
+ * bone does not change. With a line the rig spec also declares
57
+ * `invariants.axes` (rig-c 2.40.0): one entry, {@link FACE_AXIS_NAME},
58
+ * through `eye_r` and `eye_l`, holding the turned bones and their controls,
59
+ * which rigc's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` checks. Its
60
+ * tolerance is measured on the bones as rigc emits them (see where it is
61
+ * written), the float floor; with no line no entry is written.
33
62
  * - **Meshes** over the parts named in `meshes`: a square lattice (`src/mesh.ts`)
34
63
  * weighted by distance to the mesh's candidate segments (`src/weights.ts`),
35
64
  * written in rigc's by-name `weights` form. The slot's bone is the first
@@ -84,6 +113,7 @@
84
113
  import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, weightsABone } from './config.ts';
85
114
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
86
115
  import { type Problem, refuseIfAny } from './errors.ts';
116
+ import { f32 } from 'rig-c/compile';
87
117
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
88
118
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
89
119
  import { type ResidualRow, residualRow, slotEnvelope, withSkinning } from './autoenvelope.ts';
@@ -112,7 +142,8 @@ import { type ContourChainRibs, type ContourReport, contourMesh, type ContourReg
112
142
  import { type LocalInfluence, localInfluences } from './localweights.ts';
113
143
  import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
114
144
  import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
115
- import { PAINTING_RUN, type PartsFile, readFrom } from './parts.ts';
145
+ import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
146
+ import { EYE_GROUP_TAGS } from './propose.ts';
116
147
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
117
148
  import { pyRound } from './round.ts';
118
149
  import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
@@ -151,6 +182,88 @@ function places(v: number): number {
151
182
  return placed(v, OFFSET_PLACES);
152
183
  }
153
184
 
185
+ /**
186
+ * The face bones that take the face's axis (issue #239): the bones `propose`
187
+ * writes for the eyes, the brows and the mouth, which the blink and an
188
+ * expression scale or translate. Read by name, as the blink's groups name
189
+ * them; a config without one of them has nothing of that name to turn.
190
+ */
191
+ export const FACE_BONES: readonly string[] = ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'mouth'];
192
+
193
+ /** The eye line the face bones are turned to, or why there is none (issue #239). */
194
+ export interface FaceAxis {
195
+ /** The eye line's angle in Spine degrees (counter-clockwise, y up), as written; null with no line. */
196
+ degrees: number | null;
197
+ /** The eyewhite parts that make the line exist, `eyewhite-r`'s first; fewer than two when they are what is missing. */
198
+ parts: string[];
199
+ /** The face bones turned to the line, in {@link FACE_BONES} order; empty with no line. */
200
+ turned: string[];
201
+ /** The face bones a `tip` of their own turns instead, in {@link FACE_BONES} order. */
202
+ ownTip: string[];
203
+ /** Why there is no line, or null. */
204
+ none: string | null;
205
+ /** The `invariants.axes` entry written for the line (issue #239, rig-c's `A51`), or null with the reason in {@link FaceAxis.noAxis}. */
206
+ axis: RigAxis | null;
207
+ /** Why no axes entry was written, or null when one was (or there is no line, which {@link FaceAxis.none} says). */
208
+ noAxis: string | null;
209
+ }
210
+
211
+ /** One `invariants.axes` entry: rig-c's `RigAxis`, held by its gate rule `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` (2.40.0). */
212
+ export interface RigAxis {
213
+ name: string;
214
+ through: [string, string];
215
+ bones: string[];
216
+ tolerance: number;
217
+ }
218
+
219
+ /** The name of the face's `invariants.axes` entry. */
220
+ export const FACE_AXIS_NAME = 'face';
221
+
222
+ /** One unit in the last place a rotation is written to ({@link OFFSET_PLACES}): the axes entry's tolerance stands this far above the gap it measures. */
223
+ const WRITTEN_UNIT = 10 ** -6;
224
+
225
+ /** Two directions' gap as lines, 0 to 90 degrees — rig-c's `lineGap` (A51), restated here because it is not on an entry this package imports. */
226
+ function lineGap(a: number, b: number): number {
227
+ const d = (((a - b) % 180) + 180) % 180;
228
+ return Math.min(d, 180 - d);
229
+ }
230
+
231
+ /** The bones the face's line runs through: rigc's A51 draws its line through their origins, and the face bones are turned to that same line. */
232
+ export const EYE_BONES: readonly [string, string] = ['eye_r', 'eye_l'];
233
+
234
+ /**
235
+ * The eye line (issue #239). It exists when parts.json holds an eye on each
236
+ * side — the first part tagged `eyewhite-r` and the first tagged `eyewhite-l`
237
+ * (a cut's piece is neither), the parts `propose` stands `eye_r` and `eye_l`
238
+ * on — and config.bones declares both eye bones. Its angle is read off the
239
+ * two eye bones' origins as the config writes them, image-left to
240
+ * image-right, in Spine's axes through the one y flip: the very line rigc's
241
+ * A51 draws through those bones, so the face bones stand on what the gate
242
+ * measures (the eyewhites' unrounded centres would sit up to a rounding off
243
+ * it — demo's half pixel was 0.47 degrees), and a moved eye bone moves the
244
+ * line with it. Null with the reason when an eyewhite or an eye bone is
245
+ * missing, or when the two eye bones share a column, so the line has no
246
+ * left-to-right direction to read.
247
+ */
248
+ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>): { degrees: number; parts: [string, string] } | { degrees: null; parts: string[]; none: string } {
249
+ const H = parts.rig_size[1];
250
+ const found = EYE_GROUP_TAGS.map((tag) => ({ tag, part: parts.parts.find((p) => tagOf(p.from) === tag && p.cut_from === undefined) }));
251
+ const have = found.flatMap((f) => (f.part === undefined ? [] : [f.part]));
252
+ if (have.length < 2) {
253
+ const missing = found.filter((f) => f.part === undefined).map((f) => f.tag);
254
+ return { degrees: null, parts: have.map((p) => p.name), none: `parts.json holds no part tagged ${missing.join(' or ')} (a cut's piece is not counted), so there is no eye line` };
255
+ }
256
+ const names = have.map((p) => p.name);
257
+ const absent = EYE_BONES.filter((n) => !eyeBones.has(n));
258
+ if (absent.length > 0) return { degrees: null, parts: names, none: `config.bones declares no single bone ${absent.join(' or ')}, so there is no pair of eye bones for the eye line to run through` };
259
+ const [a, b] = EYE_BONES.map((n) => eyeBones.get(n) as Point);
260
+ if (a[0] === b[0]) return { degrees: null, parts: names, none: `${EYE_BONES[0]} ${pt(a)} and ${EYE_BONES[1]} ${pt(b)} share the column x = ${a[0]}, so the eye line has no left-to-right direction to read` };
261
+ const [l, r] = a[0] < b[0] ? [a, b] : [b, a];
262
+ const dx = r[0] - l[0];
263
+ const dy = cropToSpineY(r[1], H) - cropToSpineY(l[1], H);
264
+ return { degrees: (Math.atan2(dy, dx) * 180) / Math.PI, parts: [names[0], names[1]] };
265
+ }
266
+
154
267
  /**
155
268
  * The weights a contour vertex is written with: a vertex no region reaches is
156
269
  * rounded as the lattice rounds (each to 5 places, the last `1 − sum(others)`),
@@ -249,7 +362,7 @@ export interface RigSpec {
249
362
  * the idle keys a mesh-driving bone. `detached`: written only when a
250
363
  * constraint follows a bone ({@link detachedRules}).
251
364
  */
252
- invariants?: { idleDrivesMeshes?: { why: string }; detached?: DetachedRule[]; deformMayFold?: Array<{ slot: string; why: string }> };
365
+ invariants?: { idleDrivesMeshes?: { why: string }; detached?: DetachedRule[]; deformMayFold?: Array<{ slot: string; why: string }>; axes?: RigAxis[] };
253
366
  }
254
367
 
255
368
  /** One `invariants.detached` entry: rig-c's `RigDetachedRule`, checked by its gate rule `A25`. */
@@ -541,6 +654,8 @@ export interface RigOutput {
541
654
  loopPasses: Record<string, number>;
542
655
  /** Per automatic mesh, in parts.json order: what the rig stage's motion gate compares (`src/automotion.ts`). Nothing written. */
543
656
  autoMotion: AutoMotionCase[];
657
+ /** The eye line the face bones were turned to, or why there is none (issue #239). */
658
+ faceAxis: FaceAxis;
544
659
  }
545
660
 
546
661
  /**
@@ -614,6 +729,8 @@ export function buildRig(
614
729
  const chains = new Map<string, string[]>();
615
730
  /** Each chain's points and tip, rig px, as declared (issue #188: the ribs are placed along them). */
616
731
  const chainLines = new Map<string, Point[]>();
732
+ /** The single bones that state a `tip` (issue #239: each is turned towards it). */
733
+ const singleTips = new Set<string>();
617
734
  cfg.bones.forEach((e: BoneEntry, i) => {
618
735
  if ('chain' in e) {
619
736
  const names: string[] = [];
@@ -641,7 +758,17 @@ export function buildRig(
641
758
  chainLines.set(e.chain, polyline);
642
759
  } else {
643
760
  B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1] });
644
- if (e.tip !== undefined) tips.set(e.name, [e.tip[0], e.tip[1]]);
761
+ if (e.tip !== undefined) {
762
+ if (same(e.at, e.tip)) {
763
+ fail(
764
+ 'RIG_TIP_DISTINCT',
765
+ `config.bones[${i}] "${e.name}".tip`,
766
+ `is ${pt(e.tip)}, the bone's own origin; a bone is turned from its origin towards its tip (issue #239), and a tip on the origin has no direction — give a tip other than ${pt(e.at)}, or leave the tip out`,
767
+ );
768
+ }
769
+ tips.set(e.name, [e.tip[0], e.tip[1]]);
770
+ singleTips.add(e.name);
771
+ }
645
772
  }
646
773
  });
647
774
 
@@ -824,6 +951,38 @@ export function buildRig(
824
951
  if (controls.includes(l)) aim.set(`${l}${CONTROL_SUFFIX}`, a);
825
952
  }
826
953
  }
954
+ // A single bone with a tip points at it the same way, and its control with it (issue #239).
955
+ for (const n of singleTips) {
956
+ const b = B.get(n) as Bone;
957
+ const end = tips.get(n) as Point;
958
+ const dx = spineX(end[0]) - spineX(b.x);
959
+ const dy = spineY(end[1]) - spineY(b.y);
960
+ const a = { rotation: (Math.atan2(dy, dx) * 180) / Math.PI, length: pyRound(Math.sqrt(dx * dx + dy * dy), 3) };
961
+ aim.set(n, a);
962
+ if (controls.includes(n)) aim.set(`${n}${CONTROL_SUFFIX}`, a);
963
+ }
964
+ // ---- the face's axis (issue #239) --------------------------------------
965
+ // Each face bone config.bones declares as a single bone with no tip of its own stands on the eye line, and its
966
+ // control with it; a bone under a face-framed bone that nothing else turns keeps that frame.
967
+ const eyeAt = new Map<string, Point>();
968
+ for (const e of cfg.bones) if (!('chain' in e) && (EYE_BONES as readonly string[]).includes(e.name)) eyeAt.set(e.name, [e.at[0], e.at[1]]);
969
+ const line = eyeLine(parts, eyeAt);
970
+ const declared = FACE_BONES.filter((n) => cfg.bones.some((e) => !('chain' in e) && e.name === n));
971
+ const faceAxis: FaceAxis = {
972
+ degrees: line.degrees,
973
+ parts: line.parts,
974
+ turned: line.degrees === null ? [] : declared.filter((n) => !singleTips.has(n)),
975
+ ownTip: declared.filter((n) => singleTips.has(n)),
976
+ none: line.degrees === null ? line.none : null,
977
+ axis: null,
978
+ noAxis: null,
979
+ };
980
+ const faceTurn = new Map<string, number>();
981
+ for (const n of faceAxis.turned) {
982
+ faceTurn.set(n, line.degrees as number);
983
+ if (controls.includes(n)) faceTurn.set(`${n}${CONTROL_SUFFIX}`, line.degrees as number);
984
+ }
985
+ const faceRoots = new Set(declared.flatMap((n) => (controls.includes(n) ? [n, `${n}${CONTROL_SUFFIX}`] : [n])));
827
986
  // Each bone's local rotation turns it from its parent's world rotation to
828
987
  // the one it should have: its chain's direction, or 0 for every other bone,
829
988
  // so a named bone under a chain link is turned back upright. `turn` is the
@@ -831,6 +990,8 @@ export function buildRig(
831
990
  // tree. Each local offset is the bone's origin carried into its parent's
832
991
  // setup frame as written, through rigc's inverse (`src/coords.ts`).
833
992
  const turn = new Map<string, number>();
993
+ /** The bones whose frame is the face's (issue #239): the face bones, their controls, and what hangs under them unturned. */
994
+ const faceFramed = new Set<string>();
834
995
  const bones: RigBone[] = [];
835
996
  for (const b of ordered) {
836
997
  if (b.parent === null) {
@@ -840,7 +1001,10 @@ export function buildRig(
840
1001
  }
841
1002
  const up = turn.get(b.parent) as number;
842
1003
  const a = aim.get(b.name);
843
- const rotation = places(normaliseDegrees((a?.rotation ?? 0) - up));
1004
+ const inherits = a === undefined && !faceRoots.has(b.name) && faceFramed.has(b.parent);
1005
+ if (faceRoots.has(b.name) || inherits) faceFramed.add(b.name);
1006
+ const target = a?.rotation ?? faceTurn.get(b.name) ?? (inherits ? up : 0);
1007
+ const rotation = places(normaliseDegrees(target - up));
844
1008
  turn.set(b.name, places(up + rotation));
845
1009
  const frame = computeExactFrameTransforms(bones).get(b.parent) as BoneTransform;
846
1010
  const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
@@ -848,6 +1012,34 @@ export function buildRig(
848
1012
  }
849
1013
  const world = computeExactFrameTransforms(bones);
850
1014
 
1015
+ // ---- the face's axes entry (issue #239, rig-c 2.40.0's A51) -------------
1016
+ // The gate draws its line through the eye bones' setup origins, and the face bones were turned to the line through
1017
+ // the same two origins as the config writes them ({@link eyeLine}); what separates the two is only how the numbers
1018
+ // are stored. So the tolerance is measured, not chosen: the largest gap, as lines, between the line through
1019
+ // eye_r -> eye_l and any listed bone's world x axis, in the setup frames of the bones as rigc emits them (each x, y
1020
+ // and rotation through rig-c's own `f32`, the float32 the skeleton stores — 36.869898 is stored 1.6e-6 degrees off,
1021
+ // more than a 6-place unit), rounded up to the places a rotation is written to, plus one unit of that last place —
1022
+ // the float floor of the emitted rig, to the precision this stage writes. A bone turned further off is A51's.
1023
+ if (faceAxis.turned.length > 0) {
1024
+ const through: [string, string] = [EYE_BONES[0], EYE_BONES[1]];
1025
+ const listed = bones.map((b) => b.name).filter((n) => faceTurn.has(n));
1026
+ const emitted = computeExactFrameTransforms(
1027
+ bones.map((bn) => ({ ...bn, x: f32(bn.x), y: f32(bn.y), ...(bn.rotation === undefined ? {} : { rotation: f32(bn.rotation) }) })),
1028
+ );
1029
+ const [a, b] = through.map((n) => emitted.get(n) as BoneTransform);
1030
+ const gateLine = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
1031
+ if (a.worldX === b.worldX && a.worldY === b.worldY) faceAxis.noAxis = `eye_r and eye_l share the world origin (${a.worldX}, ${a.worldY}), so they draw no line`;
1032
+ else {
1033
+ const gap = Math.max(...listed.map((n) => {
1034
+ const t = emitted.get(n) as BoneTransform;
1035
+ return lineGap((Math.atan2(t.c, t.a) * 180) / Math.PI, gateLine);
1036
+ }));
1037
+ const tolerance = pyRound(Math.ceil(gap / WRITTEN_UNIT) * WRITTEN_UNIT + WRITTEN_UNIT, OFFSET_PLACES);
1038
+ if (tolerance >= 90) faceAxis.noAxis = `the face bones lie ${pyRound(gap, 6)} degrees off the line through eye_r and eye_l, and rigc takes a tolerance below 90 only`;
1039
+ else faceAxis.axis = { name: FACE_AXIS_NAME, through, bones: listed, tolerance };
1040
+ }
1041
+ }
1042
+
851
1043
  // ---- keys a turned frame would change ----------------------------------
852
1044
  // A rotate key turns a bone about its origin whatever its frame, so the
853
1045
  // idle's chain sways mean what they meant. A translate key moves a bone
@@ -866,7 +1058,8 @@ export function buildRig(
866
1058
  const translate = k.prop.startsWith('translate');
867
1059
  const frameBone = translate ? parent : k.bone;
868
1060
  const deg = turn.get(frameBone) as number;
869
- if (deg === 0) continue;
1061
+ // A key in a face-framed bone's frame means the face's axes: that is the point of the turn (issue #239).
1062
+ if (deg === 0 || faceFramed.has(frameBone)) continue;
870
1063
  const axis = k.prop.slice(-1);
871
1064
  fail(
872
1065
  'RIG_KEY_FRAME_UNTURNED',
@@ -1359,7 +1552,9 @@ export function buildRig(
1359
1552
  // issue #126 item 3: an automatic part whose author set motion.deformMayFold — written only then, so no other rig moves.
1360
1553
  const mayFold = autoMotion.filter((c) => c.motion?.deformMayFold === true).map((c) => ({ slot: c.part, why: DEFORM_MAY_FOLD_WHY }));
1361
1554
  if (mayFold.length > 0) rig.invariants = { ...rig.invariants, deformMayFold: mayFold };
1362
- return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion };
1555
+ // issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
1556
+ if (faceAxis.axis !== null) rig.invariants = { ...rig.invariants, axes: [faceAxis.axis] };
1557
+ return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis };
1363
1558
  }
1364
1559
 
1365
1560
  /**
@@ -1377,7 +1572,9 @@ export function rigJsonText(value: unknown): string {
1377
1572
  * weight's bind `x, y` and each region's `x, y` is carried out of its frame as
1378
1573
  * written (rigc's `toWorld`, `src/coords.ts`) and back in as a difference from
1379
1574
  * the frame's origin, at the reference's 3 places; `length` and `rotation` are
1380
- * dropped. Everything else is copied.
1575
+ * dropped, and so is `invariants.axes` (issue #239): it holds bones to a
1576
+ * line they lie along only when turned, which the flat form undoes, and the
1577
+ * reference declared none. Everything else is copied.
1381
1578
  *
1382
1579
  * It exists so the reference's outputs stay an oracle: the chain suite and
1383
1580
  * `scripts/rig_oracle.ts` compare `flattenRig(built)` with the reference's
@@ -1436,5 +1633,11 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1436
1633
  skin[slot] = flat;
1437
1634
  }
1438
1635
  refuseIfAny(problems);
1439
- return { ...rig, bones, skins: { default: skin } };
1636
+ const flat: RigSpec = { ...rig, bones, skins: { default: skin } };
1637
+ if (rig.invariants?.axes !== undefined) {
1638
+ const { axes: _axes, ...rest } = rig.invariants;
1639
+ if (Object.keys(rest).length > 0) flat.invariants = rest;
1640
+ else delete flat.invariants;
1641
+ }
1642
+ return flat;
1440
1643
  }
package/src/scene.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  *
18
18
  * - **Names, totally.** Every bone, slot, attachment (skin entry), constraint
19
19
  * (its name and every bone or slot it names), `invariants.detached` bone,
20
+ * `invariants.axes` entry (its name, its `through` and its `bones`; issue #239),
20
21
  * track, group and easing of character `<id>` is renamed `<id>:<name>`
21
22
  * ({@link prefixed}); `root` is the one shared root. A name resolves to
22
23
  * exactly one character, and nothing resolves across characters without its
@@ -382,7 +383,7 @@ const BONE_KEYS = ['name', 'parent', 'length', 'rotation', 'x', 'y'];
382
383
  const SLOT_KEYS = ['name', 'bone', 'attachment'];
383
384
  const ATTACHMENT_KEYS = ['type', 'image', 'width', 'height', 'uvs', 'triangles', 'hull', 'weights', 'x', 'y', 'rotation'];
384
385
  const RIG_KEYS = ['spec', 'name', 'images', 'skeleton', 'bones', 'slots', 'skins', 'constraints', 'invariants'];
385
- const INVARIANT_KEYS = ['idleDrivesMeshes', 'detached'];
386
+ const INVARIANT_KEYS = ['idleDrivesMeshes', 'detached', 'axes'];
386
387
  const MOTION_KEYS = ['spec', 'archetype', 'cut', 'easings', 'groups', 'animations'];
387
388
  const IDLE_KEYS = ['duration', 'loop', 'note', 'tracks'];
388
389
  const TRACK_KEYS = ['bone', 'group', 'property', 'keys'];
@@ -965,6 +966,10 @@ export function composeScene(
965
966
  ownBone(b.id, d.bone, `character "${b.id}" rig.json invariants.detached[${k}].bone`);
966
967
  ownBone(b.id, d.notUnder, `character "${b.id}" rig.json invariants.detached[${k}].notUnder`);
967
968
  }
969
+ for (const [k, ax] of (b.rig.invariants?.axes ?? []).entries()) {
970
+ ax.through.forEach((n, j) => ownBone(b.id, n, `character "${b.id}" rig.json invariants.axes[${k}].through[${j}]`));
971
+ ax.bones.forEach((n, j) => ownBone(b.id, n, `character "${b.id}" rig.json invariants.axes[${k}].bones[${j}]`));
972
+ }
968
973
  }
969
974
  // The root is shared: a character that drives it would move every character.
970
975
  for (const b of ready) {
@@ -1030,6 +1035,7 @@ export function composeScene(
1030
1035
  }
1031
1036
  const constraints: Array<Record<string, unknown>> = [];
1032
1037
  const detached: NonNullable<NonNullable<RigSpec['invariants']>['detached']> = [];
1038
+ const axes: NonNullable<NonNullable<RigSpec['invariants']>['axes']> = [];
1033
1039
  scene.characters.forEach((c) => {
1034
1040
  const b = byId.get(c.id) as CharacterBuild;
1035
1041
  const id = c.id;
@@ -1090,6 +1096,8 @@ export function composeScene(
1090
1096
  constraints.push(out);
1091
1097
  }
1092
1098
  for (const d of b.rig.invariants?.detached ?? []) detached.push({ ...d, bone: P(id, d.bone), notUnder: P(id, d.notUnder) });
1099
+ // An axis entry's name is unique within one rig, and two characters each carry a "face": the name is prefixed too.
1100
+ for (const ax of b.rig.invariants?.axes ?? []) axes.push({ name: prefixed(id, ax.name), through: [P(id, ax.through[0]), P(id, ax.through[1])], bones: ax.bones.map((n) => P(id, n)), tolerance: ax.tolerance });
1093
1101
  });
1094
1102
  const slots: RigSpec['slots'] = [];
1095
1103
  if (scene.plate !== null && plate !== null) slots.push({ name: PLATE, bone: PLATE, attachment: PLATE });
@@ -1101,6 +1109,7 @@ export function composeScene(
1101
1109
  const invariants: NonNullable<RigSpec['invariants']> = {};
1102
1110
  if (whys.length === 1) invariants.idleDrivesMeshes = { why: whys[0] };
1103
1111
  if (detached.length > 0) invariants.detached = detached;
1112
+ if (axes.length > 0) invariants.axes = axes;
1104
1113
  if (Object.keys(invariants).length > 0) rig.invariants = invariants;
1105
1114
 
1106
1115
  // ---- the motion ---------------------------------------------------------------
@@ -1246,6 +1255,10 @@ export function unprefixedNames(rig: RigSpec, motion: MotionSpec, ids: readonly
1246
1255
  name('invariants.detached bone', d.bone, false);
1247
1256
  name('invariants.detached notUnder', d.notUnder, false);
1248
1257
  }
1258
+ for (const ax of rig.invariants?.axes ?? []) {
1259
+ name('invariants.axes name', ax.name, false);
1260
+ for (const n of [...ax.through, ...ax.bones]) name(`invariants.axes ${ax.name} bone`, n, false);
1261
+ }
1249
1262
  for (const [k, v] of Object.entries(motion.easings)) name('easing', k, false);
1250
1263
  for (const [g, members] of Object.entries(motion.groups)) {
1251
1264
  name('group', g, false);