@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.
@@ -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
- const key: MotionKey = { t: pyRound(t, 6), v: [pyRound(v, 4)] };
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 - 1e-9 && k / fps <= to + 1e-9) out.push(k);
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.5.6 has none, and still reads.
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.5.6.
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;