@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/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
|
|
package/src/parts.ts
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* cut from, which shares its `from`. The record is in draw order, and a piece
|
|
41
41
|
* drawn `"back"` comes before its base, so order cannot say which of two parts
|
|
42
42
|
* sharing a layer is the piece; this field does. A `parts.json` written before
|
|
43
|
-
* 1.
|
|
43
|
+
* 1.6.0 has none, and still reads.
|
|
44
44
|
*
|
|
45
45
|
* ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
|
|
46
46
|
* painting's own, so it is all visible (the painting shows it; nothing of it
|
|
@@ -81,7 +81,7 @@ export interface PartRecord {
|
|
|
81
81
|
/**
|
|
82
82
|
* Written only on a piece an `assemble.cuts` entry made (issue #230): the
|
|
83
83
|
* name of the plan part it was cut from, which has the same `from`. Absent
|
|
84
|
-
* on every other part, and in a `parts.json` written before 1.
|
|
84
|
+
* on every other part, and in a `parts.json` written before 1.6.0.
|
|
85
85
|
*/
|
|
86
86
|
cut_from?: string;
|
|
87
87
|
x: number;
|