@rig-c/parts 1.6.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 +16 -11
- package/cli.ts +17 -6
- package/docs/AUTHORING.md +65 -16
- package/docs/STABILITY.md +1 -1
- package/package.json +2 -2
- package/src/assemble.ts +199 -16
- package/src/build.ts +172 -11
- package/src/check.ts +7 -4
- package/src/headframe.ts +207 -0
- package/src/motion.ts +20 -3
- package/src/parts.ts +2 -2
- package/src/propose.ts +687 -27
- package/src/rig.ts +322 -12
- package/src/scene.ts +14 -1
package/src/rig.ts
CHANGED
|
@@ -27,9 +27,45 @@
|
|
|
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
|
|
31
|
-
*
|
|
32
|
-
* that
|
|
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. 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).
|
|
33
69
|
* - **Meshes** over the parts named in `meshes`: a square lattice (`src/mesh.ts`)
|
|
34
70
|
* weighted by distance to the mesh's candidate segments (`src/weights.ts`),
|
|
35
71
|
* written in rigc's by-name `weights` form. The slot's bone is the first
|
|
@@ -84,6 +120,8 @@
|
|
|
84
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';
|
|
85
121
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
86
122
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
123
|
+
import { eyeLineDegrees } from './headframe.ts';
|
|
124
|
+
import { f32 } from 'rig-c/compile';
|
|
87
125
|
import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
|
|
88
126
|
import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
|
|
89
127
|
import { type ResidualRow, residualRow, slotEnvelope, withSkinning } from './autoenvelope.ts';
|
|
@@ -112,7 +150,8 @@ import { type ContourChainRibs, type ContourReport, contourMesh, type ContourReg
|
|
|
112
150
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
113
151
|
import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
|
|
114
152
|
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';
|
|
153
|
+
import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
154
|
+
import { EYE_GROUP_TAGS } from './propose.ts';
|
|
116
155
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
117
156
|
import { pyRound } from './round.ts';
|
|
118
157
|
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
@@ -151,6 +190,117 @@ function places(v: number): number {
|
|
|
151
190
|
return placed(v, OFFSET_PLACES);
|
|
152
191
|
}
|
|
153
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The face bones that take the face's axis (issue #239): the bones `propose`
|
|
195
|
+
* writes for the eyes, the brows and the mouth, which the blink and an
|
|
196
|
+
* expression scale or translate. Read by name, as the blink's groups name
|
|
197
|
+
* them; a config without one of them has nothing of that name to turn.
|
|
198
|
+
*/
|
|
199
|
+
export const FACE_BONES: readonly string[] = ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'mouth'];
|
|
200
|
+
|
|
201
|
+
/** The eye line the face bones are turned to, or why there is none (issue #239). */
|
|
202
|
+
export interface FaceAxis {
|
|
203
|
+
/** The eye line's angle in Spine degrees (counter-clockwise, y up), as written; null with no line. */
|
|
204
|
+
degrees: number | null;
|
|
205
|
+
/** The eyewhite parts that make the line exist, `eyewhite-r`'s first; fewer than two when they are what is missing. */
|
|
206
|
+
parts: string[];
|
|
207
|
+
/** The face bones turned to the line, in {@link FACE_BONES} order; empty with no line. */
|
|
208
|
+
turned: string[];
|
|
209
|
+
/** The face bones a `tip` of their own turns instead, in {@link FACE_BONES} order. */
|
|
210
|
+
ownTip: string[];
|
|
211
|
+
/** Why there is no line, or null. */
|
|
212
|
+
none: string | null;
|
|
213
|
+
/** The `invariants.axes` entry written for the line (issue #239, rig-c's `A51`), or null with the reason in {@link FaceAxis.noAxis}. */
|
|
214
|
+
axis: RigAxis | null;
|
|
215
|
+
/** Why no axes entry was written, or null when one was (or there is no line, which {@link FaceAxis.none} says). */
|
|
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;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One `invariants.axes` entry: rig-c's `RigAxis`, held by its gate rule `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` (2.40.0). */
|
|
228
|
+
export interface RigAxis {
|
|
229
|
+
name: string;
|
|
230
|
+
through: [string, string];
|
|
231
|
+
bones: string[];
|
|
232
|
+
tolerance: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The name of the face's `invariants.axes` entry. */
|
|
236
|
+
export const FACE_AXIS_NAME = 'face';
|
|
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
|
+
|
|
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. */
|
|
262
|
+
const WRITTEN_UNIT = 10 ** -6;
|
|
263
|
+
|
|
264
|
+
/** 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. */
|
|
265
|
+
function lineGap(a: number, b: number): number {
|
|
266
|
+
const d = (((a - b) % 180) + 180) % 180;
|
|
267
|
+
return Math.min(d, 180 - d);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 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. */
|
|
271
|
+
export const EYE_BONES: readonly [string, string] = ['eye_r', 'eye_l'];
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The eye line (issue #239). It exists when parts.json holds an eye on each
|
|
275
|
+
* side — the first part tagged `eyewhite-r` and the first tagged `eyewhite-l`
|
|
276
|
+
* (a cut's piece is neither), the parts `propose` stands `eye_r` and `eye_l`
|
|
277
|
+
* on — and config.bones declares both eye bones. Its angle is read off the
|
|
278
|
+
* two eye bones' origins as the config writes them, image-left to
|
|
279
|
+
* image-right, in Spine's axes through the one y flip: the very line rigc's
|
|
280
|
+
* A51 draws through those bones, so the face bones stand on what the gate
|
|
281
|
+
* measures (the eyewhites' unrounded centres would sit up to a rounding off
|
|
282
|
+
* it — demo's half pixel was 0.47 degrees), and a moved eye bone moves the
|
|
283
|
+
* line with it. Null with the reason when an eyewhite or an eye bone is
|
|
284
|
+
* missing, or when the two eye bones share a column, so the line has no
|
|
285
|
+
* left-to-right direction to read.
|
|
286
|
+
*/
|
|
287
|
+
export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>): { degrees: number; parts: [string, string] } | { degrees: null; parts: string[]; none: string } {
|
|
288
|
+
const H = parts.rig_size[1];
|
|
289
|
+
const found = EYE_GROUP_TAGS.map((tag) => ({ tag, part: parts.parts.find((p) => tagOf(p.from) === tag && p.cut_from === undefined) }));
|
|
290
|
+
const have = found.flatMap((f) => (f.part === undefined ? [] : [f.part]));
|
|
291
|
+
if (have.length < 2) {
|
|
292
|
+
const missing = found.filter((f) => f.part === undefined).map((f) => f.tag);
|
|
293
|
+
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` };
|
|
294
|
+
}
|
|
295
|
+
const names = have.map((p) => p.name);
|
|
296
|
+
const absent = EYE_BONES.filter((n) => !eyeBones.has(n));
|
|
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` };
|
|
298
|
+
const [a, b] = EYE_BONES.map((n) => eyeBones.get(n) as Point);
|
|
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` };
|
|
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]] };
|
|
302
|
+
}
|
|
303
|
+
|
|
154
304
|
/**
|
|
155
305
|
* The weights a contour vertex is written with: a vertex no region reaches is
|
|
156
306
|
* rounded as the lattice rounds (each to 5 places, the last `1 − sum(others)`),
|
|
@@ -249,7 +399,7 @@ export interface RigSpec {
|
|
|
249
399
|
* the idle keys a mesh-driving bone. `detached`: written only when a
|
|
250
400
|
* constraint follows a bone ({@link detachedRules}).
|
|
251
401
|
*/
|
|
252
|
-
invariants?: { idleDrivesMeshes?: { why: string }; detached?: DetachedRule[]; deformMayFold?: Array<{ slot: string; why: string }
|
|
402
|
+
invariants?: { idleDrivesMeshes?: { why: string }; detached?: DetachedRule[]; deformMayFold?: Array<{ slot: string; why: string }>; axes?: RigAxis[] };
|
|
253
403
|
}
|
|
254
404
|
|
|
255
405
|
/** One `invariants.detached` entry: rig-c's `RigDetachedRule`, checked by its gate rule `A25`. */
|
|
@@ -541,6 +691,8 @@ export interface RigOutput {
|
|
|
541
691
|
loopPasses: Record<string, number>;
|
|
542
692
|
/** Per automatic mesh, in parts.json order: what the rig stage's motion gate compares (`src/automotion.ts`). Nothing written. */
|
|
543
693
|
autoMotion: AutoMotionCase[];
|
|
694
|
+
/** The eye line the face bones were turned to, or why there is none (issue #239). */
|
|
695
|
+
faceAxis: FaceAxis;
|
|
544
696
|
}
|
|
545
697
|
|
|
546
698
|
/**
|
|
@@ -614,6 +766,8 @@ export function buildRig(
|
|
|
614
766
|
const chains = new Map<string, string[]>();
|
|
615
767
|
/** Each chain's points and tip, rig px, as declared (issue #188: the ribs are placed along them). */
|
|
616
768
|
const chainLines = new Map<string, Point[]>();
|
|
769
|
+
/** The single bones that state a `tip` (issue #239: each is turned towards it). */
|
|
770
|
+
const singleTips = new Set<string>();
|
|
617
771
|
cfg.bones.forEach((e: BoneEntry, i) => {
|
|
618
772
|
if ('chain' in e) {
|
|
619
773
|
const names: string[] = [];
|
|
@@ -641,7 +795,17 @@ export function buildRig(
|
|
|
641
795
|
chainLines.set(e.chain, polyline);
|
|
642
796
|
} else {
|
|
643
797
|
B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1] });
|
|
644
|
-
if (e.tip !== undefined)
|
|
798
|
+
if (e.tip !== undefined) {
|
|
799
|
+
if (same(e.at, e.tip)) {
|
|
800
|
+
fail(
|
|
801
|
+
'RIG_TIP_DISTINCT',
|
|
802
|
+
`config.bones[${i}] "${e.name}".tip`,
|
|
803
|
+
`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`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
tips.set(e.name, [e.tip[0], e.tip[1]]);
|
|
807
|
+
singleTips.add(e.name);
|
|
808
|
+
}
|
|
645
809
|
}
|
|
646
810
|
});
|
|
647
811
|
|
|
@@ -781,6 +945,8 @@ export function buildRig(
|
|
|
781
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`);
|
|
782
946
|
}
|
|
783
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`);
|
|
784
950
|
// A two-bone ik over a link and its child link stops being one when the
|
|
785
951
|
// child gets a control: `<child>_ctl` stands between them. rig-c 2.15.0
|
|
786
952
|
// refuses that pair by name (issue #103); the rig stage adds the flag to
|
|
@@ -824,6 +990,42 @@ export function buildRig(
|
|
|
824
990
|
if (controls.includes(l)) aim.set(`${l}${CONTROL_SUFFIX}`, a);
|
|
825
991
|
}
|
|
826
992
|
}
|
|
993
|
+
// A single bone with a tip points at it the same way, and its control with it (issue #239).
|
|
994
|
+
for (const n of singleTips) {
|
|
995
|
+
const b = B.get(n) as Bone;
|
|
996
|
+
const end = tips.get(n) as Point;
|
|
997
|
+
const dx = spineX(end[0]) - spineX(b.x);
|
|
998
|
+
const dy = spineY(end[1]) - spineY(b.y);
|
|
999
|
+
const a = { rotation: (Math.atan2(dy, dx) * 180) / Math.PI, length: pyRound(Math.sqrt(dx * dx + dy * dy), 3) };
|
|
1000
|
+
aim.set(n, a);
|
|
1001
|
+
if (controls.includes(n)) aim.set(`${n}${CONTROL_SUFFIX}`, a);
|
|
1002
|
+
}
|
|
1003
|
+
// ---- the face's axis (issue #239) --------------------------------------
|
|
1004
|
+
// Each face bone config.bones declares as a single bone with no tip of its own stands on the eye line, and its
|
|
1005
|
+
// control with it; a bone under a face-framed bone that nothing else turns keeps that frame.
|
|
1006
|
+
const eyeAt = new Map<string, Point>();
|
|
1007
|
+
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]]);
|
|
1008
|
+
const line = eyeLine(parts, eyeAt);
|
|
1009
|
+
const declared = FACE_BONES.filter((n) => cfg.bones.some((e) => !('chain' in e) && e.name === n));
|
|
1010
|
+
const faceAxis: FaceAxis = {
|
|
1011
|
+
degrees: line.degrees,
|
|
1012
|
+
parts: line.parts,
|
|
1013
|
+
turned: line.degrees === null ? [] : declared.filter((n) => !singleTips.has(n)),
|
|
1014
|
+
ownTip: declared.filter((n) => singleTips.has(n)),
|
|
1015
|
+
none: line.degrees === null ? line.none : null,
|
|
1016
|
+
axis: null,
|
|
1017
|
+
noAxis: null,
|
|
1018
|
+
tipAxis: null,
|
|
1019
|
+
tipOff: [],
|
|
1020
|
+
headUp: null,
|
|
1021
|
+
headUpNone: null,
|
|
1022
|
+
};
|
|
1023
|
+
const faceTurn = new Map<string, number>();
|
|
1024
|
+
for (const n of faceAxis.turned) {
|
|
1025
|
+
faceTurn.set(n, line.degrees as number);
|
|
1026
|
+
if (controls.includes(n)) faceTurn.set(`${n}${CONTROL_SUFFIX}`, line.degrees as number);
|
|
1027
|
+
}
|
|
1028
|
+
const faceRoots = new Set(declared.flatMap((n) => (controls.includes(n) ? [n, `${n}${CONTROL_SUFFIX}`] : [n])));
|
|
827
1029
|
// Each bone's local rotation turns it from its parent's world rotation to
|
|
828
1030
|
// the one it should have: its chain's direction, or 0 for every other bone,
|
|
829
1031
|
// so a named bone under a chain link is turned back upright. `turn` is the
|
|
@@ -831,6 +1033,8 @@ export function buildRig(
|
|
|
831
1033
|
// tree. Each local offset is the bone's origin carried into its parent's
|
|
832
1034
|
// setup frame as written, through rigc's inverse (`src/coords.ts`).
|
|
833
1035
|
const turn = new Map<string, number>();
|
|
1036
|
+
/** The bones whose frame is the face's (issue #239): the face bones, their controls, and what hangs under them unturned. */
|
|
1037
|
+
const faceFramed = new Set<string>();
|
|
834
1038
|
const bones: RigBone[] = [];
|
|
835
1039
|
for (const b of ordered) {
|
|
836
1040
|
if (b.parent === null) {
|
|
@@ -840,14 +1044,105 @@ export function buildRig(
|
|
|
840
1044
|
}
|
|
841
1045
|
const up = turn.get(b.parent) as number;
|
|
842
1046
|
const a = aim.get(b.name);
|
|
843
|
-
const
|
|
1047
|
+
const inherits = a === undefined && !faceRoots.has(b.name) && faceFramed.has(b.parent);
|
|
1048
|
+
if (faceRoots.has(b.name) || inherits) faceFramed.add(b.name);
|
|
1049
|
+
const target = a?.rotation ?? faceTurn.get(b.name) ?? (inherits ? up : 0);
|
|
1050
|
+
const rotation = places(normaliseDegrees(target - up));
|
|
844
1051
|
turn.set(b.name, places(up + rotation));
|
|
845
1052
|
const frame = computeExactFrameTransforms(bones).get(b.parent) as BoneTransform;
|
|
846
1053
|
const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
|
|
847
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) });
|
|
848
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
|
+
|
|
849
1082
|
const world = computeExactFrameTransforms(bones);
|
|
850
1083
|
|
|
1084
|
+
// ---- the face's axes entry (issue #239, rig-c 2.40.0's A51) -------------
|
|
1085
|
+
// The gate draws its line through the eye bones' setup origins, and the face bones were turned to the line through
|
|
1086
|
+
// the same two origins as the config writes them ({@link eyeLine}); what separates the two is only how the numbers
|
|
1087
|
+
// are stored. So the tolerance is measured, not chosen: the largest gap, as lines, between the line through
|
|
1088
|
+
// 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
|
|
1089
|
+
// and rotation through rig-c's own `f32`, the float32 the skeleton stores — 36.869898 is stored 1.6e-6 degrees off,
|
|
1090
|
+
// more than a 6-place unit), rounded up to the places a rotation is written to, plus one unit of that last place —
|
|
1091
|
+
// the float floor of the emitted rig, to the precision this stage writes. A bone turned further off is A51's.
|
|
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)) {
|
|
1100
|
+
const through: [string, string] = [EYE_BONES[0], EYE_BONES[1]];
|
|
1101
|
+
const emitted = computeExactFrameTransforms(
|
|
1102
|
+
bones.map((bn) => ({ ...bn, x: f32(bn.x), y: f32(bn.y), ...(bn.rotation === undefined ? {} : { rotation: f32(bn.rotation) }) })),
|
|
1103
|
+
);
|
|
1104
|
+
const [a, b] = through.map((n) => emitted.get(n) as BoneTransform);
|
|
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
|
+
};
|
|
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`;
|
|
1116
|
+
else {
|
|
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
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
851
1146
|
// ---- keys a turned frame would change ----------------------------------
|
|
852
1147
|
// A rotate key turns a bone about its origin whatever its frame, so the
|
|
853
1148
|
// idle's chain sways mean what they meant. A translate key moves a bone
|
|
@@ -866,7 +1161,8 @@ export function buildRig(
|
|
|
866
1161
|
const translate = k.prop.startsWith('translate');
|
|
867
1162
|
const frameBone = translate ? parent : k.bone;
|
|
868
1163
|
const deg = turn.get(frameBone) as number;
|
|
869
|
-
|
|
1164
|
+
// A key in a face-framed bone's frame means the face's axes: that is the point of the turn (issue #239).
|
|
1165
|
+
if (deg === 0 || faceFramed.has(frameBone)) continue;
|
|
870
1166
|
const axis = k.prop.slice(-1);
|
|
871
1167
|
fail(
|
|
872
1168
|
'RIG_KEY_FRAME_UNTURNED',
|
|
@@ -1359,7 +1655,10 @@ export function buildRig(
|
|
|
1359
1655
|
// issue #126 item 3: an automatic part whose author set motion.deformMayFold — written only then, so no other rig moves.
|
|
1360
1656
|
const mayFold = autoMotion.filter((c) => c.motion?.deformMayFold === true).map((c) => ({ slot: c.part, why: DEFORM_MAY_FOLD_WHY }));
|
|
1361
1657
|
if (mayFold.length > 0) rig.invariants = { ...rig.invariants, deformMayFold: mayFold };
|
|
1362
|
-
|
|
1658
|
+
// issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
|
|
1659
|
+
const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
|
|
1660
|
+
if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
|
|
1661
|
+
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis };
|
|
1363
1662
|
}
|
|
1364
1663
|
|
|
1365
1664
|
/**
|
|
@@ -1377,7 +1676,11 @@ export function rigJsonText(value: unknown): string {
|
|
|
1377
1676
|
* weight's bind `x, y` and each region's `x, y` is carried out of its frame as
|
|
1378
1677
|
* written (rigc's `toWorld`, `src/coords.ts`) and back in as a difference from
|
|
1379
1678
|
* the frame's origin, at the reference's 3 places; `length` and `rotation` are
|
|
1380
|
-
* dropped
|
|
1679
|
+
* dropped, and so is `invariants.axes` (issue #239): it holds bones to a
|
|
1680
|
+
* line they lie along only when turned, which the flat form undoes, and the
|
|
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.
|
|
1381
1684
|
*
|
|
1382
1685
|
* It exists so the reference's outputs stay an oracle: the chain suite and
|
|
1383
1686
|
* `scripts/rig_oracle.ts` compare `flattenRig(built)` with the reference's
|
|
@@ -1403,7 +1706,8 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1403
1706
|
const [wx, wy] = toWorld(m, x, y);
|
|
1404
1707
|
return [placed(wx - m.worldX, FLAT_PLACES), placed(wy - m.worldY, FLAT_PLACES)];
|
|
1405
1708
|
};
|
|
1406
|
-
|
|
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) => {
|
|
1407
1711
|
if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
|
|
1408
1712
|
const [x, y] = out(origin(b.parent), b.x, b.y);
|
|
1409
1713
|
return { name: b.name, parent: b.parent, x, y };
|
|
@@ -1436,5 +1740,11 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1436
1740
|
skin[slot] = flat;
|
|
1437
1741
|
}
|
|
1438
1742
|
refuseIfAny(problems);
|
|
1439
|
-
|
|
1743
|
+
const flat: RigSpec = { ...rig, bones, skins: { default: skin } };
|
|
1744
|
+
if (rig.invariants?.axes !== undefined) {
|
|
1745
|
+
const { axes: _axes, ...rest } = rig.invariants;
|
|
1746
|
+
if (Object.keys(rest).length > 0) flat.invariants = rest;
|
|
1747
|
+
else delete flat.invariants;
|
|
1748
|
+
}
|
|
1749
|
+
return flat;
|
|
1440
1750
|
}
|
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);
|