@rig-c/parts 1.7.0 → 1.9.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 +13 -12
- package/cli.ts +33 -5
- package/docs/AUTHORING.md +61 -15
- package/docs/STABILITY.md +9 -4
- package/package.json +1 -1
- package/src/assemble.ts +199 -16
- package/src/build.ts +27 -4
- package/src/check.ts +120 -14
- package/src/config.ts +75 -1
- package/src/headframe.ts +207 -0
- package/src/motion.ts +20 -3
- package/src/propose.ts +517 -18
- package/src/rig.ts +236 -19
- package/src/turn.ts +99 -0
package/src/config.ts
CHANGED
|
@@ -55,6 +55,11 @@
|
|
|
55
55
|
* object the loader vouches for, and the rig stage leaves them out of what it
|
|
56
56
|
* hands rigc ({@link constraintForRig}).
|
|
57
57
|
*
|
|
58
|
+
* 🧭 **`turn` is this package's (issue #251, `src/turn.ts`).** An optional
|
|
59
|
+
* `{pivot, depths}`: each carried part's See-through `depth_median` and the
|
|
60
|
+
* depth that stays put, both stated — `propose --full/--head` drafts them —
|
|
61
|
+
* from which the rig stage derives every fraction ({@link checkTurn}).
|
|
62
|
+
*
|
|
58
63
|
* 🔗 **`motion.animations_from` is rig-c's too, verbatim (issue #183).** An
|
|
59
64
|
* optional path, relative to the config file's directory, to a file in
|
|
60
65
|
* rig-c's motion-spec shape whose `animations` are written into `motion.json`
|
|
@@ -75,6 +80,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
|
|
|
75
80
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
76
81
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
77
82
|
import { acceptedTagNames, readTag } from './tags.ts';
|
|
83
|
+
import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
78
84
|
|
|
79
85
|
export type Point = [number, number];
|
|
80
86
|
|
|
@@ -604,6 +610,8 @@ export interface CharacterConfig {
|
|
|
604
610
|
regions: Record<string, string>;
|
|
605
611
|
motion: Motion;
|
|
606
612
|
constraints?: ConfigConstraint[];
|
|
613
|
+
/** The turn control (issue #251, `src/turn.ts`): absent, no control is added and no byte of any output moves. */
|
|
614
|
+
turn?: TurnSpec;
|
|
607
615
|
}
|
|
608
616
|
|
|
609
617
|
/** The fields of one constraint kind, as rig-c's interface for that kind declares them. */
|
|
@@ -885,7 +893,7 @@ export interface AssembleConfig {
|
|
|
885
893
|
assemble: Assemble;
|
|
886
894
|
}
|
|
887
895
|
|
|
888
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints'] as const;
|
|
896
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn'] as const;
|
|
889
897
|
|
|
890
898
|
/**
|
|
891
899
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -1086,6 +1094,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1086
1094
|
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions);
|
|
1087
1095
|
if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
|
|
1088
1096
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1097
|
+
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
|
|
1089
1098
|
const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
|
|
1090
1099
|
const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
|
|
1091
1100
|
if (base !== null) poseBeside(c, t.generation, base);
|
|
@@ -2281,3 +2290,68 @@ function checkConstraints(c: Check, v: Json, bones: Set<string>, parents: Map<st
|
|
|
2281
2290
|
}
|
|
2282
2291
|
});
|
|
2283
2292
|
}
|
|
2293
|
+
|
|
2294
|
+
/** The parts `meshes` and `regions` attach, read off whatever they hold; `turn.depths` may name only these. */
|
|
2295
|
+
function attachedParts(meshes: Json, regions: Json): Set<string> {
|
|
2296
|
+
const out = new Set<string>();
|
|
2297
|
+
for (const v of [meshes, regions]) if (isPlainObject(v)) for (const k of Object.keys(v)) out.add(k);
|
|
2298
|
+
return out;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* `config.turn` (issue #251, `src/turn.ts`): `pivot`, the depth that stays put
|
|
2303
|
+
* when the control moves, and `depths`, each carried part's See-through
|
|
2304
|
+
* `depth_median` as `propose` read it. Neither has a default: a config without
|
|
2305
|
+
* `turn` gets no control, and one with it states both.
|
|
2306
|
+
*
|
|
2307
|
+
* - `CONFIG_FIELD_TYPE` / `CONFIG_FIELD_PRESENT` / `CONFIG_KEY_KNOWN`: not an
|
|
2308
|
+
* object; `pivot` or `depths` absent; another key (records and annotations
|
|
2309
|
+
* aside); a pivot or a depth that is not a finite number; `depths` not an
|
|
2310
|
+
* object or empty.
|
|
2311
|
+
* - `CONFIG_NAME_RESOLVES`: a `depths` key that names no part `meshes` or
|
|
2312
|
+
* `regions` attaches. Its keys are part names, so no annotation or record
|
|
2313
|
+
* door is open there.
|
|
2314
|
+
* - `CONFIG_TURN_PIVOT_BEHIND_NEAREST`: a pivot at or in front of the nearest
|
|
2315
|
+
* part's depth. The law divides by `pivot − nearest`, so at the nearest depth
|
|
2316
|
+
* it has no value, and in front of it every part would travel against the
|
|
2317
|
+
* dial — no part would be nearer than what stays put.
|
|
2318
|
+
* - `CONFIG_TURN_BONE_FREE`: `bones` declares a bone named {@link TURN_BONE},
|
|
2319
|
+
* the control the rig stage adds.
|
|
2320
|
+
*/
|
|
2321
|
+
function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
|
|
2322
|
+
const at = 'config.turn';
|
|
2323
|
+
const o = c.object(at, v, ['pivot', 'depths'], []);
|
|
2324
|
+
if (o === null) return;
|
|
2325
|
+
const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
|
|
2326
|
+
const depths: Record<string, number> = {};
|
|
2327
|
+
let depthsOk = false;
|
|
2328
|
+
if ('depths' in o) {
|
|
2329
|
+
const d = o.depths;
|
|
2330
|
+
if (!isPlainObject(d) || Object.keys(d).length === 0) {
|
|
2331
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.depths`, `is ${show(d)}; a non-empty object of part name -> See-through depth_median is required`);
|
|
2332
|
+
} else {
|
|
2333
|
+
depthsOk = true;
|
|
2334
|
+
for (const [part, depth] of Object.entries(d)) {
|
|
2335
|
+
if (!attached.has(part)) {
|
|
2336
|
+
c.fail('CONFIG_NAME_RESOLVES', `${at}.depths.${part}`, `names "${part}", which neither config.meshes nor config.regions attaches; a part the rig draws is required — a depth for a part nobody draws carries nothing`);
|
|
2337
|
+
depthsOk = false;
|
|
2338
|
+
}
|
|
2339
|
+
if (c.number(`${at}.depths.${part}`, depth)) depths[part] = depth;
|
|
2340
|
+
else depthsOk = false;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
if (pivot !== null && depthsOk) {
|
|
2345
|
+
const nearest = turnNearest(depths);
|
|
2346
|
+
if (nearest !== null && !(pivot > nearest.depth)) {
|
|
2347
|
+
c.fail(
|
|
2348
|
+
'CONFIG_TURN_PIVOT_BEHIND_NEAREST',
|
|
2349
|
+
`${at}.pivot`,
|
|
2350
|
+
`is ${pivot}; the nearest part, "${nearest.part}", lies at depth ${nearest.depth}, and a pivot above that (further back) is required — each part travels (pivot − depth) / (pivot − ${nearest.depth}) of the control's offset, which has no value at the nearest depth and turns every part against the dial in front of it`,
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
if (bones.has(TURN_BONE)) {
|
|
2355
|
+
c.fail('CONFIG_TURN_BONE_FREE', `bone "${TURN_BONE}"`, `is the control the rig stage adds under root for config.turn, and config.bones already declares a bone of that name; rename it`);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
package/src/headframe.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The head's frame, read off the head run's parts (issue #250): the eye line
|
|
3
|
+
* (the bar), and the line from the eye midpoint down through the nose and the
|
|
4
|
+
* mouth (the stem) — the T construction of head-pose estimation from facial
|
|
5
|
+
* landmarks.
|
|
6
|
+
*
|
|
7
|
+
* - **Roll** is the bar's angle. It has one derivation in this package,
|
|
8
|
+
* {@link eyeLineDegrees}: the line through the eye bones' origins as the
|
|
9
|
+
* config writes them, image-left to image-right, in Spine degrees through
|
|
10
|
+
* the one y flip. `propose` reads it on the origins it writes for `eye_r`
|
|
11
|
+
* and `eye_l`; the rig stage turns the five face bones to it and declares
|
|
12
|
+
* the `invariants.axes` entry rigc's A51 draws through those same two
|
|
13
|
+
* origins (`src/rig.ts`, {@link eyeLineDegrees} there too). A roll read
|
|
14
|
+
* anywhere else could disagree with the one the face bones stand on.
|
|
15
|
+
* - **Turn** (yaw), three ways: the stem's skew off the bar's perpendicular
|
|
16
|
+
* (via the nose and via the mouth, each as a sideways and a downward
|
|
17
|
+
* fraction of the inter-eye distance), the two eyewhites' widths along the
|
|
18
|
+
* bar, and the face part's two halves along the bar about the eye
|
|
19
|
+
* midpoint. Never acted on, and not written by `propose`: on a face painted
|
|
20
|
+
* three-quarter the three readings did not agree in order
|
|
21
|
+
* (`FRONTAL_REFERENCE` in `src/propose.ts`), so they stay readings here.
|
|
22
|
+
* - **Pitch** proportions: where the nose sits along eye midpoint -> mouth,
|
|
23
|
+
* and the stem's length (the mouth's downward fraction) against the bar.
|
|
24
|
+
*
|
|
25
|
+
* A drawn face does not carry a human face's ratios, so no figure here is
|
|
26
|
+
* compared with a human model: the frontal reference is read off drawn
|
|
27
|
+
* frontal faces (`FRONTAL_REFERENCE` in `src/propose.ts`, with its source).
|
|
28
|
+
* Nothing is guessed: a feature with no part is named as missing, and its
|
|
29
|
+
* figures are not written.
|
|
30
|
+
*
|
|
31
|
+
* Image coordinates throughout (crop px, y down), but for the roll, which is
|
|
32
|
+
* Spine's (counter-clockwise, y up). Pure.
|
|
33
|
+
*/
|
|
34
|
+
import type { Point } from './config.ts';
|
|
35
|
+
import { cropToSpineY } from './coords.ts';
|
|
36
|
+
import type { Mask } from './raster/types.ts';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The eye line's angle in Spine degrees (counter-clockwise, y up) through two
|
|
40
|
+
* points in crop px on a canvas `H` px tall, read from the image-left point to
|
|
41
|
+
* the image-right one, so it lies inside (−90°, 90°) and which point is which
|
|
42
|
+
* eye does not enter it. The caller refuses two points in one column.
|
|
43
|
+
*/
|
|
44
|
+
export function eyeLineDegrees(a: Point, b: Point, H: number): number {
|
|
45
|
+
const [l, r] = a[0] < b[0] ? [a, b] : [b, a];
|
|
46
|
+
const dx = r[0] - l[0];
|
|
47
|
+
const dy = cropToSpineY(r[1], H) - cropToSpineY(l[1], H);
|
|
48
|
+
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The head's two axes in crop px on a canvas `H` px tall, for a roll in Spine
|
|
53
|
+
* degrees ({@link eyeLineDegrees}; issue #249): `at(p, across, down)` is `p`
|
|
54
|
+
* moved `across` px along the eye line (image-left to image-right) and `down`
|
|
55
|
+
* px along the face's down axis (the eye line's perpendicular toward the
|
|
56
|
+
* chin), and `of(p, q)` is the inverse, q's `[across, down]` from p. The
|
|
57
|
+
* axes are taken in Spine's frame, where the roll is an angle, and every
|
|
58
|
+
* point crosses the y flip through `cropToSpineY` both ways; a roll of 0 is
|
|
59
|
+
* the page's axes.
|
|
60
|
+
*/
|
|
61
|
+
export interface HeadAxes {
|
|
62
|
+
at(p: readonly [number, number], across: number, down: number): [number, number];
|
|
63
|
+
of(p: readonly [number, number], q: readonly [number, number]): [number, number];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function headAxes(roll: number, H: number): HeadAxes {
|
|
67
|
+
const t = (roll * Math.PI) / 180;
|
|
68
|
+
const bar: [number, number] = [Math.cos(t), Math.sin(t)];
|
|
69
|
+
// The face's up axis is the roll + 90 degrees, (−sin, cos) in Spine's frame; down is its opposite.
|
|
70
|
+
const down: [number, number] = [Math.sin(t), -Math.cos(t)];
|
|
71
|
+
return {
|
|
72
|
+
at: (p, across, d) => [p[0] + across * bar[0] + d * down[0], cropToSpineY(cropToSpineY(p[1], H) + across * bar[1] + d * down[1], H)],
|
|
73
|
+
of: (p, q) => {
|
|
74
|
+
const dx = q[0] - p[0];
|
|
75
|
+
const dy = cropToSpineY(q[1], H) - cropToSpineY(p[1], H);
|
|
76
|
+
return [dx * bar[0] + dy * bar[1], dx * down[0] + dy * down[1]];
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One stem reading: a feature's point off the eye midpoint, along the bar and down it, as fractions of the inter-eye distance. */
|
|
82
|
+
export interface StemReading {
|
|
83
|
+
/** The feature's tag (`nose` or `mouth`). */
|
|
84
|
+
feature: string;
|
|
85
|
+
/** The part it was read off. */
|
|
86
|
+
part: string;
|
|
87
|
+
/** The point read: the centroid of the part's biggest 8-connected blob, as the mouth bone is placed. */
|
|
88
|
+
at: [number, number];
|
|
89
|
+
/** Along the bar, positive toward image right. */
|
|
90
|
+
side: number;
|
|
91
|
+
/** Down the face (the bar's perpendicular, toward the chin), positive down. */
|
|
92
|
+
down: number;
|
|
93
|
+
/** The stem's skew off the bar's perpendicular, degrees, positive toward image right: `atan2(side, down)`. */
|
|
94
|
+
skew: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Two extents along the bar: a pair of widths, or the face's two halves about the eye midpoint. */
|
|
98
|
+
export interface BarPair {
|
|
99
|
+
/** The parts read. */
|
|
100
|
+
parts: string[];
|
|
101
|
+
/** The image-left one, px along the bar. */
|
|
102
|
+
left: number;
|
|
103
|
+
/** The image-right one, px along the bar. */
|
|
104
|
+
right: number;
|
|
105
|
+
/** `left / right`: above 1 when the image-right side is the narrower. */
|
|
106
|
+
ratio: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The head's frame (issue #250). */
|
|
110
|
+
export interface HeadFrame {
|
|
111
|
+
/** The eye bones' origins the bar runs through, image-left first. */
|
|
112
|
+
eyes: [Point, Point];
|
|
113
|
+
/** The eye midpoint, crop px. */
|
|
114
|
+
mid: [number, number];
|
|
115
|
+
/** The inter-eye distance, px: the bar's length and the unit of every stem fraction. */
|
|
116
|
+
bar: number;
|
|
117
|
+
/** The bar's angle in Spine degrees ({@link eyeLineDegrees}). */
|
|
118
|
+
roll: number;
|
|
119
|
+
/** The stem via the nose, or why it is not read. */
|
|
120
|
+
nose: StemReading | { missing: string };
|
|
121
|
+
/** The stem via the mouth, or why it is not read. */
|
|
122
|
+
mouth: StemReading | { missing: string };
|
|
123
|
+
/** The two eyewhites' widths along the bar, or why they are not read. */
|
|
124
|
+
eyeWidths: BarPair | { missing: string };
|
|
125
|
+
/** The face part's two halves along the bar about the eye midpoint, or why they are not read. */
|
|
126
|
+
faceHalves: BarPair | { missing: string };
|
|
127
|
+
/** Where the nose sits along eye midpoint -> mouth: the nose's `down` over the mouth's; null without both. */
|
|
128
|
+
noseAlongStem: number | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A feature handed to {@link headFrame}: its part's name and its point, or why there is none. */
|
|
132
|
+
export type FrameFeature = { part: string; at: [number, number] } | { missing: string };
|
|
133
|
+
|
|
134
|
+
/** A part's mask handed to {@link headFrame}: its name and its full-canvas mask, or why there is none. */
|
|
135
|
+
export type FrameMask = { part: string; mask: Mask } | { missing: string };
|
|
136
|
+
|
|
137
|
+
/** The extent of a mask's pixel centres projected on the unit direction `u` from `o`: [min, max], or null for an empty mask. */
|
|
138
|
+
function extentAlong(m: Mask, o: readonly [number, number], u: readonly [number, number]): [number, number] | null {
|
|
139
|
+
let lo = Infinity;
|
|
140
|
+
let hi = -Infinity;
|
|
141
|
+
for (let y = 0; y < m.height; y++) {
|
|
142
|
+
for (let x = 0; x < m.width; x++) {
|
|
143
|
+
if (m.data[y * m.width + x] === 0) continue;
|
|
144
|
+
const s = (x + 0.5 - o[0]) * u[0] + (y + 0.5 - o[1]) * u[1];
|
|
145
|
+
if (s < lo) lo = s;
|
|
146
|
+
if (s > hi) hi = s;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return lo === Infinity ? null : [lo, hi];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The head's frame on a canvas `H` px tall. `eyes` are the two eye bones'
|
|
154
|
+
* origins (any order; read image-left first) and must not share a column —
|
|
155
|
+
* the caller holds that. `eyewhites` are the two eyewhite masks with the eye
|
|
156
|
+
* each belongs to's origin, so each is paired with its side by its origin.
|
|
157
|
+
* A pixel's centre is `(x + 0.5, y + 0.5)`, as a box's centre is
|
|
158
|
+
* `x + w / 2`; a stem point is taken as given.
|
|
159
|
+
*/
|
|
160
|
+
export function headFrame(
|
|
161
|
+
eyes: [Point, Point],
|
|
162
|
+
H: number,
|
|
163
|
+
feats: { nose: FrameFeature; mouth: FrameFeature },
|
|
164
|
+
eyewhites: Array<{ part: string; mask: Mask; eye: Point }>,
|
|
165
|
+
face: FrameMask,
|
|
166
|
+
): HeadFrame {
|
|
167
|
+
const [l, r] = eyes[0][0] < eyes[1][0] ? [eyes[0], eyes[1]] : [eyes[1], eyes[0]];
|
|
168
|
+
const dx = r[0] - l[0];
|
|
169
|
+
const dy = r[1] - l[1];
|
|
170
|
+
const bar = Math.hypot(dx, dy);
|
|
171
|
+
const u: [number, number] = [dx / bar, dy / bar];
|
|
172
|
+
// The bar's perpendicular toward the chin: u turned a quarter clockwise as drawn (y down).
|
|
173
|
+
const n: [number, number] = [-u[1], u[0]];
|
|
174
|
+
const mid: [number, number] = [(l[0] + r[0]) / 2, (l[1] + r[1]) / 2];
|
|
175
|
+
const stem = (feature: string, f: FrameFeature): StemReading | { missing: string } => {
|
|
176
|
+
if ('missing' in f) return { missing: f.missing };
|
|
177
|
+
const v = [f.at[0] - mid[0], f.at[1] - mid[1]];
|
|
178
|
+
const side = (v[0] * u[0] + v[1] * u[1]) / bar;
|
|
179
|
+
const down = (v[0] * n[0] + v[1] * n[1]) / bar;
|
|
180
|
+
return { feature, part: f.part, at: f.at, side, down, skew: (Math.atan2(side, down) * 180) / Math.PI };
|
|
181
|
+
};
|
|
182
|
+
const nose = stem('nose', feats.nose);
|
|
183
|
+
const mouth = stem('mouth', feats.mouth);
|
|
184
|
+
let eyeWidths: BarPair | { missing: string };
|
|
185
|
+
if (eyewhites.length < 2) eyeWidths = { missing: 'there are not two eyewhite parts' };
|
|
186
|
+
else {
|
|
187
|
+
const [wl, wr] = [l, r].map((e) => eyewhites.find((w) => w.eye[0] === e[0] && w.eye[1] === e[1]));
|
|
188
|
+
const el = wl === undefined ? null : extentAlong(wl.mask, mid, u);
|
|
189
|
+
const er = wr === undefined ? null : extentAlong(wr.mask, mid, u);
|
|
190
|
+
if (wl === undefined || wr === undefined || el === null || er === null) eyeWidths = { missing: 'an eyewhite part has no opaque pixel' };
|
|
191
|
+
else {
|
|
192
|
+
const left = el[1] - el[0];
|
|
193
|
+
const right = er[1] - er[0];
|
|
194
|
+
eyeWidths = right > 0 ? { parts: [wl.part, wr.part], left, right, ratio: left / right } : { missing: `${wr.part} is one pixel column wide along the bar` };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let faceHalves: BarPair | { missing: string };
|
|
198
|
+
if ('missing' in face) faceHalves = { missing: face.missing };
|
|
199
|
+
else {
|
|
200
|
+
const e = extentAlong(face.mask, mid, u);
|
|
201
|
+
if (e === null) faceHalves = { missing: `${face.part} has no opaque pixel` };
|
|
202
|
+
else if (e[0] >= 0 || e[1] <= 0) faceHalves = { missing: `${face.part} lies on one side of the eye midpoint along the bar` };
|
|
203
|
+
else faceHalves = { parts: [face.part], left: -e[0], right: e[1], ratio: -e[0] / e[1] };
|
|
204
|
+
}
|
|
205
|
+
const noseAlongStem = 'missing' in nose || 'missing' in mouth || mouth.down === 0 ? null : nose.down / mouth.down;
|
|
206
|
+
return { eyes: [l, r], mid, bar, roll: eyeLineDegrees(l, r, H), nose, mouth, eyeWidths, faceHalves, noseAlongStem };
|
|
207
|
+
}
|
package/src/motion.ts
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* single track without `base` oscillates about 0: `base` is the value the
|
|
20
20
|
* sine is centred on, and an absent one is the sine as written.
|
|
21
21
|
*
|
|
22
|
-
* Times are rounded to 6 decimals and values to 4, as the reference wrote them
|
|
22
|
+
* Times are rounded to 6 decimals and values to 4, as the reference wrote them,
|
|
23
|
+
* but for each track's last key, which is the idle's duration as given (issue
|
|
24
|
+
* #252: 40/12 s is not on the 6-decimal grid).
|
|
23
25
|
*
|
|
24
26
|
* ## Blink
|
|
25
27
|
*
|
|
@@ -87,6 +89,18 @@ export const KEYS_PER_PERIOD = 8;
|
|
|
87
89
|
*/
|
|
88
90
|
export const IDLE_FPS = 12;
|
|
89
91
|
|
|
92
|
+
/**
|
|
93
|
+
* How far apart two times, in seconds, may be and still name the same moment
|
|
94
|
+
* (issue #244): one frame at {@link IDLE_FPS} is 1/12 s, and this is 10^-9 s,
|
|
95
|
+
* eight orders of magnitude below it. A time is written in seconds, and a
|
|
96
|
+
* whole number of frames is often not exact in binary — 40/12 s is
|
|
97
|
+
* 3.3333333333333335, while rigc's last frame of it sits at 40 * (1/12) =
|
|
98
|
+
* 3.333333333333333 — so two times are compared within this, never with `===`.
|
|
99
|
+
* It is the slack {@link framesInside} has always read a frame against a key
|
|
100
|
+
* time with.
|
|
101
|
+
*/
|
|
102
|
+
export const TIME_SLACK = 1e-9;
|
|
103
|
+
|
|
90
104
|
/** The blink's fixed timing, in seconds after `blink.t`. The holds are 1/{@link IDLE_FPS} s rounded up (see the Blink section above). */
|
|
91
105
|
export const BLINK = {
|
|
92
106
|
shut: 0.07,
|
|
@@ -153,7 +167,10 @@ export function sineTrack(bone: string, property: string, amp: number, period: n
|
|
|
153
167
|
raw.push([t, base + amp * Math.sin(arg), amp * w * Math.cos(arg)]);
|
|
154
168
|
}
|
|
155
169
|
const keys: MotionKey[] = raw.map(([t, v, m], i) => {
|
|
156
|
-
|
|
170
|
+
// The last key sits at the duration as given, not rounded (issue #252): 40/12 s rounds to 3.333333, 3.3e-7 s short
|
|
171
|
+
// of the loop's end, and the build's lead-in refuses a track that does not end at the duration. Bytes are unchanged
|
|
172
|
+
// for any duration already on the 6-decimal grid.
|
|
173
|
+
const key: MotionKey = { t: i === raw.length - 1 ? duration : pyRound(t, 6), v: [pyRound(v, 4)] };
|
|
157
174
|
if (i < raw.length - 1) {
|
|
158
175
|
const [t1, v1, m1] = raw[i + 1];
|
|
159
176
|
const dt = t1 - t;
|
|
@@ -232,7 +249,7 @@ export function idleMotion(cfg: CharacterConfig, chains: ReadonlyMap<string, str
|
|
|
232
249
|
*/
|
|
233
250
|
export function framesInside(from: number, to: number, fps: number, count: number): number[] {
|
|
234
251
|
const out: number[] = [];
|
|
235
|
-
for (let k = 0; k < count; k++) if (k / fps >= from -
|
|
252
|
+
for (let k = 0; k < count; k++) if (k / fps >= from - TIME_SLACK && k / fps <= to + TIME_SLACK) out.push(k);
|
|
236
253
|
return out;
|
|
237
254
|
}
|
|
238
255
|
|