@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/README.md +9 -9
- package/cli.ts +8 -2
- package/docs/AUTHORING.md +41 -11
- package/package.json +1 -1
- package/src/assemble.ts +199 -16
- package/src/build.ts +13 -3
- package/src/check.ts +7 -4
- package/src/headframe.ts +207 -0
- package/src/motion.ts +20 -3
- package/src/propose.ts +406 -17
- package/src/rig.ts +124 -17
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|