@rig-c/parts 1.7.0 → 1.8.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
@@ -58,7 +58,14 @@
58
58
  * through `eye_r` and `eye_l`, holding the turned bones and their controls,
59
59
  * which rigc's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` checks. Its
60
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.
61
+ * written), the float floor; with no line no entry is written. A face bone
62
+ * with its own `tip` along the line is held too (issue #258), in a second
63
+ * entry, {@link FACE_TIPS_AXIS_NAME}: one whose tip lies within the
64
+ * whole-pixel rounding bound ({@link tipRoundingBound}) of the line is
65
+ * listed, its tolerance measured the same way, and one further off is its
66
+ * own axis, named with its gap in `FaceAxis.tipOff` — never silent. The
67
+ * roll the face bones stand on has one derivation, `src/headframe.ts`'s
68
+ * `eyeLineDegrees`, which `propose`'s head frame reads too (issue #250).
62
69
  * - **Meshes** over the parts named in `meshes`: a square lattice (`src/mesh.ts`)
63
70
  * weighted by distance to the mesh's candidate segments (`src/weights.ts`),
64
71
  * written in rigc's by-name `weights` form. The slot's bone is the first
@@ -113,6 +120,7 @@
113
120
  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';
114
121
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
115
122
  import { type Problem, refuseIfAny } from './errors.ts';
123
+ import { eyeLineDegrees } from './headframe.ts';
116
124
  import { f32 } from 'rig-c/compile';
117
125
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
118
126
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
@@ -206,6 +214,14 @@ export interface FaceAxis {
206
214
  axis: RigAxis | null;
207
215
  /** Why no axes entry was written, or null when one was (or there is no line, which {@link FaceAxis.none} says). */
208
216
  noAxis: string | null;
217
+ /** The `invariants.axes` entry holding the face bones whose own tip lies along the line within whole-pixel rounding (issue #258), or null when none does. */
218
+ tipAxis: RigAxis | null;
219
+ /** For each face bone whose own tip points further off the line than whole-pixel rounding allows, why it is not held (issue #258); in {@link FACE_BONES} order. */
220
+ tipOff: string[];
221
+ /** {@link HEAD_UP_BONE} as written (issue #250): its world rotation (the eye line + 90) and its length; null with the reason in {@link FaceAxis.headUpNone}, or with no line. */
222
+ headUp: { rotation: number; length: number } | null;
223
+ /** Why {@link HEAD_UP_BONE} was not written, when there is a line; null otherwise. */
224
+ headUpNone: string | null;
209
225
  }
210
226
 
211
227
  /** One `invariants.axes` entry: rig-c's `RigAxis`, held by its gate rule `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` (2.40.0). */
@@ -219,6 +235,29 @@ export interface RigAxis {
219
235
  /** The name of the face's `invariants.axes` entry. */
220
236
  export const FACE_AXIS_NAME = 'face';
221
237
 
238
+ /**
239
+ * The slotless bone the rig stage adds under `head` (issue #250): at head's origin, pointing along the face's up axis
240
+ * (the eye line + 90 degrees), as long as the eye midpoint lies from head's origin along it. `head` keeps its own frame,
241
+ * so no key on it or under it changes meaning; a rule that needs the head's frame (issue #249) reads this bone.
242
+ */
243
+ export const HEAD_UP_BONE = 'head_up';
244
+
245
+ /** The name of the `invariants.axes` entry for the face bones whose own tip lies along the eye line (issue #258). */
246
+ export const FACE_TIPS_AXIS_NAME = 'face_tips';
247
+
248
+ /**
249
+ * The most a bone's direction can sit off a line through whole-pixel
250
+ * rounding of its tip alone (issue #258): a tip written in whole pixels is
251
+ * within half a pixel in each coordinate, sqrt(2)/2 px, of the point on the
252
+ * line it stands for, so at `length` px from the origin its direction is
253
+ * within asin((sqrt(2)/2) / length) degrees. Null when the tip is closer than
254
+ * sqrt(2)/2 px, where rounding alone can point it anywhere.
255
+ */
256
+ export function tipRoundingBound(length: number): number | null {
257
+ const s = Math.SQRT1_2 / length;
258
+ return s >= 1 ? null : (Math.asin(s) * 180) / Math.PI;
259
+ }
260
+
222
261
  /** 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
262
  const WRITTEN_UNIT = 10 ** -6;
224
263
 
@@ -258,10 +297,8 @@ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>):
258
297
  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
298
  const [a, b] = EYE_BONES.map((n) => eyeBones.get(n) as Point);
260
299
  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]] };
300
+ // The one derivation of the roll (issue #250): propose's head frame reads the same function on the same origins.
301
+ return { degrees: eyeLineDegrees(a, b, H), parts: [names[0], names[1]] };
265
302
  }
266
303
 
267
304
  /**
@@ -908,6 +945,8 @@ export function buildRig(
908
945
  fail('RIG_CONTROL_NAME_FREE', `bone "${k}"`, `is keyed by the idle and weighted to by a mesh, so it needs the control bone "${ctl}", and config.bones already declares a bone of that name`);
909
946
  }
910
947
  }
948
+ // The face's up-axis bone is the rig stage's (issue #250): a config bone of that name would be shadowed.
949
+ if (B.has(HEAD_UP_BONE)) fail('RIG_HEAD_UP_NAME_FREE', `bone "${HEAD_UP_BONE}"`, `is the bone the rig stage adds under head to carry the face's up axis (issue #250), and config.bones already declares a bone of that name; rename it`);
911
950
  // A two-bone ik over a link and its child link stops being one when the
912
951
  // child gets a control: `<child>_ctl` stands between them. rig-c 2.15.0
913
952
  // refuses that pair by name (issue #103); the rig stage adds the flag to
@@ -976,6 +1015,10 @@ export function buildRig(
976
1015
  none: line.degrees === null ? line.none : null,
977
1016
  axis: null,
978
1017
  noAxis: null,
1018
+ tipAxis: null,
1019
+ tipOff: [],
1020
+ headUp: null,
1021
+ headUpNone: null,
979
1022
  };
980
1023
  const faceTurn = new Map<string, number>();
981
1024
  for (const n of faceAxis.turned) {
@@ -1010,6 +1053,32 @@ export function buildRig(
1010
1053
  const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
1011
1054
  bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
1012
1055
  }
1056
+ // ---- the head's up axis (issue #250) ----------------------------------
1057
+ // `head` keeps its frame, so every key on it and under it — the idle, the blink's brow drop, an authored
1058
+ // animation's scale or translate — means what it meant. The face's frame is carried by one slotless bone under it,
1059
+ // {@link HEAD_UP_BONE}, at head's origin, turned to the eye line's angle + 90 (the same line, {@link eyeLine}) and
1060
+ // as long as the eye midpoint lies from head's origin along that axis: derived from the three bones the config
1061
+ // writes, so it moves with them. A rule that needs the head's frame reads it here; nothing binds to it or keys it.
1062
+ const headEntry = cfg.bones.find((e) => !('chain' in e) && e.name === 'head');
1063
+ if (line.degrees !== null && headEntry !== undefined && !('chain' in headEntry)) {
1064
+ const up = line.degrees + 90;
1065
+ const [eR, eL] = EYE_BONES.map((n) => eyeAt.get(n) as Point);
1066
+ const mid: Point = [(eR[0] + eL[0]) / 2, (eR[1] + eL[1]) / 2];
1067
+ const h = B.get('head') as Bone;
1068
+ const ux = Math.cos((up * Math.PI) / 180);
1069
+ const uy = Math.sin((up * Math.PI) / 180);
1070
+ const reach = (spineX(mid[0]) - spineX(h.x)) * ux + (spineY(mid[1]) - spineY(h.y)) * uy;
1071
+ if (reach <= 0) {
1072
+ faceAxis.headUpNone = `the eye midpoint ${pt(mid)} lies ${pyRound(-reach, 3)} px below head's origin ${pt([h.x, h.y])} along the face's up axis, so ${HEAD_UP_BONE} would have no length toward the crown; not written`;
1073
+ } else {
1074
+ const parentTurn = turn.get('head') as number;
1075
+ const rotation = places(normaliseDegrees(up - parentTurn));
1076
+ const length = pyRound(reach, 3);
1077
+ bones.push({ name: HEAD_UP_BONE, parent: 'head', length, rotation, x: 0, y: 0 });
1078
+ faceAxis.headUp = { rotation: places(parentTurn + rotation), length };
1079
+ }
1080
+ } else if (line.degrees !== null) faceAxis.headUpNone = 'config.bones declares no single bone "head" for it to hang from';
1081
+
1013
1082
  const world = computeExactFrameTransforms(bones);
1014
1083
 
1015
1084
  // ---- the face's axes entry (issue #239, rig-c 2.40.0's A51) -------------
@@ -1020,23 +1089,57 @@ export function buildRig(
1020
1089
  // and rotation through rig-c's own `f32`, the float32 the skeleton stores — 36.869898 is stored 1.6e-6 degrees off,
1021
1090
  // more than a 6-place unit), rounded up to the places a rotation is written to, plus one unit of that last place —
1022
1091
  // 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) {
1092
+ // Issue #258: a face bone with its own tip is the author's statement of its axis (#239) and is not turned here, but
1093
+ // a tip written to lie along the eye line is held too, in an entry of its own ({@link FACE_TIPS_AXIS_NAME}), so the
1094
+ // turned bones' entry keeps its float floor. Which tips are meant to lie along the line is read, not chosen: a tip
1095
+ // written in whole pixels sits at most half a pixel off in each coordinate, sqrt(2)/2 px, from the point of the line
1096
+ // it stands for, so its direction can sit up to asin((sqrt(2)/2) / length) degrees off the line through rounding
1097
+ // alone ({@link tipRoundingBound}); a tip within that bone's own bound is held, its entry's tolerance measured as the
1098
+ // turned bones' is, and a tip further off is an axis of its own, said by name in faceAxis.tipOff, never silent.
1099
+ if (line.degrees !== null && (faceAxis.turned.length > 0 || faceAxis.ownTip.length > 0)) {
1024
1100
  const through: [string, string] = [EYE_BONES[0], EYE_BONES[1]];
1025
- const listed = bones.map((b) => b.name).filter((n) => faceTurn.has(n));
1026
1101
  const emitted = computeExactFrameTransforms(
1027
1102
  bones.map((bn) => ({ ...bn, x: f32(bn.x), y: f32(bn.y), ...(bn.rotation === undefined ? {} : { rotation: f32(bn.rotation) }) })),
1028
1103
  );
1029
1104
  const [a, b] = through.map((n) => emitted.get(n) as BoneTransform);
1030
1105
  const gateLine = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
1106
+ const gapOf = (n: string): number => {
1107
+ const t = emitted.get(n) as BoneTransform;
1108
+ return lineGap((Math.atan2(t.c, t.a) * 180) / Math.PI, gateLine);
1109
+ };
1110
+ const entry = (name: string, listed: string[]): { axis: RigAxis | null; gap: number } => {
1111
+ const gap = Math.max(...listed.map(gapOf));
1112
+ const tolerance = pyRound(Math.ceil(gap / WRITTEN_UNIT) * WRITTEN_UNIT + WRITTEN_UNIT, OFFSET_PLACES);
1113
+ return { axis: tolerance >= 90 ? null : { name, through, bones: listed, tolerance }, gap };
1114
+ };
1031
1115
  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
1116
  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 };
1117
+ if (faceAxis.turned.length > 0) {
1118
+ const e = entry(FACE_AXIS_NAME, bones.map((bn) => bn.name).filter((n) => faceTurn.has(n)));
1119
+ if (e.axis === null) faceAxis.noAxis = `the face bones lie ${pyRound(e.gap, 6)} degrees off the line through eye_r and eye_l, and rigc takes a tolerance below 90 only`;
1120
+ else faceAxis.axis = e.axis;
1121
+ }
1122
+ const held: string[] = [];
1123
+ for (const n of faceAxis.ownTip) {
1124
+ const length = (aim.get(n) as { length: number }).length;
1125
+ const bound = tipRoundingBound(length);
1126
+ const gap = gapOf(n);
1127
+ if (bound !== null && gap <= bound) {
1128
+ held.push(n);
1129
+ if (controls.includes(n)) held.push(`${n}${CONTROL_SUFFIX}`);
1130
+ } else {
1131
+ faceAxis.tipOff.push(
1132
+ bound === null
1133
+ ? `${n}'s tip is ${length} px from its origin, under the sqrt(2)/2 px a whole-pixel tip can sit off the line, so no direction it states can be read as the line's; its own axis, not held`
1134
+ : `${n}'s tip points ${pyRound(gap, 6)} degrees off the line through eye_r and eye_l, more than the ${pyRound(bound, 6)} degrees a tip ${length} px from its origin can sit off it through whole-pixel rounding; its own axis, not held`,
1135
+ );
1136
+ }
1137
+ }
1138
+ if (held.length > 0) {
1139
+ // In the rig's bone order, as the turned bones' entry lists them.
1140
+ const e = entry(FACE_TIPS_AXIS_NAME, bones.map((bn) => bn.name).filter((n) => held.includes(n)));
1141
+ faceAxis.tipAxis = e.axis;
1142
+ }
1040
1143
  }
1041
1144
  }
1042
1145
 
@@ -1553,7 +1656,8 @@ export function buildRig(
1553
1656
  const mayFold = autoMotion.filter((c) => c.motion?.deformMayFold === true).map((c) => ({ slot: c.part, why: DEFORM_MAY_FOLD_WHY }));
1554
1657
  if (mayFold.length > 0) rig.invariants = { ...rig.invariants, deformMayFold: mayFold };
1555
1658
  // 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] };
1659
+ const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
1660
+ if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
1557
1661
  return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis };
1558
1662
  }
1559
1663
 
@@ -1574,7 +1678,9 @@ export function rigJsonText(value: unknown): string {
1574
1678
  * the frame's origin, at the reference's 3 places; `length` and `rotation` are
1575
1679
  * dropped, and so is `invariants.axes` (issue #239): it holds bones to a
1576
1680
  * line they lie along only when turned, which the flat form undoes, and the
1577
- * reference declared none. Everything else is copied.
1681
+ * reference declared none; and so is {@link HEAD_UP_BONE} (issue #250), a
1682
+ * slotless bone that carries only a direction, which the reference never
1683
+ * wrote. Everything else is copied.
1578
1684
  *
1579
1685
  * It exists so the reference's outputs stay an oracle: the chain suite and
1580
1686
  * `scripts/rig_oracle.ts` compare `flattenRig(built)` with the reference's
@@ -1600,7 +1706,8 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1600
1706
  const [wx, wy] = toWorld(m, x, y);
1601
1707
  return [placed(wx - m.worldX, FLAT_PLACES), placed(wy - m.worldY, FLAT_PLACES)];
1602
1708
  };
1603
- const bones: RigBone[] = rig.bones.map((b) => {
1709
+ // The head's up-axis bone (issue #250) carries only a direction, which the flat form undoes; the reference wrote none.
1710
+ const bones: RigBone[] = rig.bones.filter((b) => b.name !== HEAD_UP_BONE).map((b) => {
1604
1711
  if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
1605
1712
  const [x, y] = out(origin(b.parent), b.x, b.y);
1606
1713
  return { name: b.name, parent: b.parent, x, y };