@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/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
+ }
@@ -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