@voqalize/avatar 0.0.1

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.
Files changed (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +692 -0
  3. package/client/dist/Avatar.d.ts +24 -0
  4. package/client/dist/Avatar.d.ts.map +1 -0
  5. package/client/dist/Avatar.js +7 -0
  6. package/client/dist/Avatar.js.map +1 -0
  7. package/client/dist/AvatarClient.d.ts +173 -0
  8. package/client/dist/AvatarClient.d.ts.map +1 -0
  9. package/client/dist/AvatarClient.js +274 -0
  10. package/client/dist/AvatarClient.js.map +1 -0
  11. package/client/dist/pipecat.d.ts +21 -0
  12. package/client/dist/pipecat.d.ts.map +1 -0
  13. package/client/dist/pipecat.js +21 -0
  14. package/client/dist/pipecat.js.map +1 -0
  15. package/client/dist/react.d.ts +16 -0
  16. package/client/dist/react.d.ts.map +1 -0
  17. package/client/dist/react.js +17 -0
  18. package/client/dist/react.js.map +1 -0
  19. package/client/dist/types.d.ts +101 -0
  20. package/client/dist/types.d.ts.map +1 -0
  21. package/client/dist/types.js +31 -0
  22. package/client/dist/types.js.map +1 -0
  23. package/client/dist/useAvatar.d.ts +53 -0
  24. package/client/dist/useAvatar.d.ts.map +1 -0
  25. package/client/dist/useAvatar.js +68 -0
  26. package/client/dist/useAvatar.js.map +1 -0
  27. package/client/src/Avatar.tsx +38 -0
  28. package/client/src/AvatarClient.ts +343 -0
  29. package/client/src/pipecat.ts +38 -0
  30. package/client/src/react.ts +34 -0
  31. package/client/src/types.ts +127 -0
  32. package/client/src/useAvatar.ts +113 -0
  33. package/docs/contract-avatar.md +337 -0
  34. package/docs/contract-protocol.md +401 -0
  35. package/package.json +89 -0
  36. package/src/audio-fallback.js +100 -0
  37. package/src/avatar.d.ts +241 -0
  38. package/src/avatar.js +722 -0
  39. package/src/clips.js +144 -0
  40. package/src/emotions.js +55 -0
  41. package/src/face-core.js +154 -0
  42. package/src/face-myna.js +725 -0
  43. package/src/face-peep.js +767 -0
  44. package/src/face-wren.js +470 -0
  45. package/src/gaze.js +155 -0
  46. package/src/idle.js +535 -0
  47. package/src/interjections.js +578 -0
  48. package/src/line-art.js +111 -0
  49. package/src/params.js +176 -0
  50. package/src/perform.js +105 -0
  51. package/src/visemes.js +230 -0
@@ -0,0 +1,767 @@
1
+ /**
2
+ * Avatar: "peep".
3
+ *
4
+ * A face for the shared rig — `createFace(mount, theme) -> { svg, apply,
5
+ * theme, destroy }`, consuming the same ~30-channel vector from `params.js`.
6
+ * Nothing above the renderer changes.
7
+ *
8
+ * Several notes below set a number against `blue-shirt`'s. That was the traced
9
+ * rig, retired on 2026-08-06 along with `classic` once stakeholders accepted
10
+ * only the line-art pair. The comparisons are kept because they are the reason
11
+ * these numbers are what they are — peep's were derived by converting from a
12
+ * rig 2.6x its size, and the conversion is where the bodies are buried. Both
13
+ * modules are in git history if a comparison needs checking.
14
+ *
15
+ * Where the art comes from
16
+ * ------------------------
17
+ * Hand-authored, in the manner of Open Peeps (openpeeps.com, CC0). Four
18
+ * concepts were drawn and this is the one that was picked: a taper fade with a
19
+ * spiked fringe, a polo, and the accent on the collar edge and two buttons.
20
+ * Character: male, urban Indian, late twenties to early thirties.
21
+ *
22
+ * The one construction fact that matters
23
+ * --------------------------------------
24
+ * OPEN PEEPS HAS NO STROKES. Taking the reference SVG apart, every part of a
25
+ * peep is two filled paths — a white background and a black ink — and there is
26
+ * not a single `stroke` attribute anywhere in it. That is why the lines swell
27
+ * along their length and come to a point at the ends, and it is the whole
28
+ * difference between "vector illustration" and "someone drew this". None of it
29
+ * is reachable with `stroke-width` + `stroke-linecap="round"`, which is a rope
30
+ * of constant thickness with a blob at each end.
31
+ *
32
+ * So this file carries a small variable-width stroke engine (`taper`,
33
+ * `taperRing`) and every line in the avatar is a filled outline generated by
34
+ * it. That is unusual for this codebase and it is deliberate: the other two
35
+ * avatars are painted forms with shading, this one is pure line, and the line
36
+ * quality IS the character.
37
+ *
38
+ * The second construction fact
39
+ * ----------------------------
40
+ * THE FACE IS SIX MARKS: two brows, two eyes, a nose, a mouth. No sclera, no
41
+ * iris, no lashes, no nostrils, no lower-lip line, no ear interior beyond one
42
+ * comma, no shading anywhere. An earlier pass had roughly five times that many
43
+ * marks and it read as a corporate mascot rather than a peep. Every mark added
44
+ * below this line has to earn its place against that.
45
+ *
46
+ * What the style costs the rig, honestly
47
+ * --------------------------------------
48
+ * 1. GAZE IS WEAKER THAN ON THE OTHER TWO AVATARS. There is no sclera, so
49
+ * `pupilX/pupilY` cannot move an iris against a white field — the whole eye
50
+ * bean translates instead. That is the correct reading of the style and it
51
+ * is what real peeps do, but it carries less information per unit of travel.
52
+ * Mitigated by giving the bean more travel than blue-shirt gives its iris
53
+ * (11 units on a 25-wide eye, against blue-shirt's 19 on a 74-wide one —
54
+ * as a share of frame width that is still less, and it took the DISTRACTED
55
+ * state reading as eye contact to learn 7 was not enough) and by leaning on
56
+ * gaze.js's head-follow, which is avatar-agnostic and does most of the work
57
+ * anyway.
58
+ * 2. THE JAW DOES NOT DROP. The chin is part of the head's ink outline, and
59
+ * translating a piece of that outline breaks the silhouette. Peeps do not
60
+ * have moving jaws either. The cost is real — see MOUTH_APERTURE for how the
61
+ * aperture is sized to stay off the chin without one.
62
+ * 3. ONE SHOULDER CANNOT RISE WITHOUT THE OTHER. The torso is a single filled
63
+ * path, as it is on blue-shirt, so an asymmetric shrug is a small rotation
64
+ * about the sternum. Same compromise, same reason.
65
+ *
66
+ * Arms
67
+ * ----
68
+ * There are none, and the armscye seam and sleeve hem below are GARMENT lines,
69
+ * not an arm. They exist because without them the bust reads as one undivided
70
+ * wedge of shirt. They are static geometry in the body layer and there is no
71
+ * parameter channel anywhere near them. See the note in params.js.
72
+ */
73
+
74
+ import { clamp, lerp } from './params.js';
75
+ import {
76
+ f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
77
+ } from './face-core.js';
78
+ import { taper, taperRing, region, polyD, rng } from './line-art.js';
79
+
80
+ export const THEME = {
81
+ // Paper, not white: a pure #fff field makes the ink look printed on glass,
82
+ // and every Open Peeps mock sits the figure on a warm off-white.
83
+ bg0: '#f6f3ee',
84
+ bg1: '#e8e2d8',
85
+ ink: '#1b1b1b',
86
+ paper: '#ffffff',
87
+ accent: '#f97415',
88
+ // The inside of an open mouth is the same ink as the outline. Anything
89
+ // lighter reads as a hole in the head rather than as a mouth, because in a
90
+ // pure line drawing there is no shading to say which is which.
91
+ mouthIn: '#1b1b1b',
92
+ teeth: '#ffffff',
93
+ // The only grey in the file. A warm one — a neutral grey tongue reads as a
94
+ // rendering artefact sitting inside the black.
95
+ tongue: '#8d7f79',
96
+ };
97
+
98
+ // Every colour the rig paints is a key here, and a host can override any of
99
+ // them per instance. That is as far as theming goes on purpose. A value-swapped
100
+ // dark variant was built and thrown away: inverting a two-value line drawing
101
+ // turns the black hair white, which ages the character a couple of decades, and
102
+ // fixing that means outlining the hair mass — a geometry change wearing a
103
+ // colour change's clothes. The keys stay; the second palette does not.
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Frame.
107
+ //
108
+ // Native art space is 760x950; the viewBox is a portrait window onto it, by the
109
+ // same rule blue-shirt's crop follows: about an eighth of a head of air above
110
+ // the crown, a bit over half a head below the chin, head at ~61% of frame
111
+ // height, mouth near the optical centre.
112
+ //
113
+ // The width is set by a constraint the other avatars do not have. This shirt is
114
+ // WHITE on a near-white ground, so where blue-shirt could let background show
115
+ // past the shoulder and lose nothing, here the shoulder line has to leave the
116
+ // frame or the figure reads as a paper cut-out floating in the middle. 576 is
117
+ // the widest window whose bottom corners are still inside the shirt.
118
+ // ---------------------------------------------------------------------------
119
+ const VB = { x: 92, y: 76, w: 576, h: 800 };
120
+
121
+ /**
122
+ * The avatar descriptor: what a host or tool may know about this face without
123
+ * opening it. `viewBox` is the framing; `mouthCrop` frames the mouth for close
124
+ * inspection, e.g. the contact sheet's viseme-detail row.
125
+ */
126
+ export const META = {
127
+ viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
128
+ mouthCrop: { x: 296, y: 434, w: 168, h: 98 },
129
+ };
130
+
131
+ // --- landmarks --------------------------------------------------------------
132
+ // Vertical proportions are measured off the reference peep, not off anatomy.
133
+ // The features sit LOW: with the hair mass counted, the eye line lands at 57%
134
+ // of total height rather than the canonical 50%, and the whole cluster occupies
135
+ // about a third of the head's width. Most of a peep's head is empty, and
136
+ // spreading the features to fill it — which is what "correct" proportion pushes
137
+ // you toward — is the single fastest way to lose the style.
138
+ const CX = 380;
139
+ const HEAD_TOP = 120;
140
+ const CHIN_Y = 597;
141
+
142
+ // Brows are given as the DRAWN control points, not as an inner/outer pair for a
143
+ // generator to invent a curve between. That is a correction: a generator that
144
+ // synthesised the arch from two endpoints and a peak offset produced a hard V in
145
+ // every pose — an angry face at rest — because the shape a brow makes is not
146
+ // recoverable from its endpoints. Poses deform these points; they never replace
147
+ // them, so the drawing survives every channel.
148
+ //
149
+ // The left brow is longer and flatter and the right sits 3 units higher. Baked
150
+ // in here, so the asymmetry survives every pose too.
151
+ // Flatter than they were drawn, and lifted. At the concept's 19 units of arch
152
+ // over a 79-unit span the inner ends sat low and close to the eye, and the
153
+ // resting face read as annoyed — which is the last thing an attentive agent should
154
+ // read as. 12 units of arch is still an arch; the difference is entirely in how
155
+ // the neutral pose is received.
156
+ const BROW_L = [[CX - 20, 347], [CX - 40, 340], [CX - 60, 336], [CX - 76, 337],
157
+ [CX - 86, 339], [CX - 94, 343], [CX - 99, 348]];
158
+ const BROW_R = [[CX + 22, 344], [CX + 40, 337], [CX + 58, 333], [CX + 73, 335],
159
+ [CX + 83, 337], [CX + 90, 341], [CX + 95, 346]];
160
+ const EYE = { y: 386, dx: 55, rx: 14, ry: 16.5 };
161
+ const NOSE_TOP = 402;
162
+ const MOUTH = { cx: CX, cy: 488 };
163
+
164
+ // How far the aperture may open, measured as VISIBLE DARK at mouthOpen = 1 —
165
+ // the lip band sits outside this and adds another 11 units of ink around it.
166
+ // Bounded by the chin, not by taste: there is no jaw drop in this rig (see the
167
+ // header), so a fully open mouth has to fit in the lower face that is already
168
+ // there. At 38 the dark ends at y=517 and the lower lip at about y=523, leaving
169
+ // roughly a chin's worth below it, which is what stops a wide "ah" from reading
170
+ // as a hole in the jaw.
171
+ const MOUTH_APERTURE = 38;
172
+
173
+ // The variable-width stroke engine lives in line-art.js — `taper`, `taperRing`,
174
+ // `region` and friends were authored here and extracted verbatim once a second
175
+ // line-art character needed them. The width PROFILES stay per-character.
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Static art: head
179
+ //
180
+ // The jaw is three segments — a near-vertical RAMUS off the ear, a corner at
181
+ // the GONION, then the BODY running forward to a narrow chin PAD. One unbroken
182
+ // curve from ear to ear is an egg, and an egg has no age and no sex. This is
183
+ // the same finding both other avatars' headers record, arrived at a third time;
184
+ // treat it as a rule.
185
+ //
186
+ // The right side is a few units wider than the left and the chin sits slightly
187
+ // off the midline. Not sloppiness — a mirror-symmetric head is the loudest
188
+ // available signal that no hand was involved.
189
+ // ---------------------------------------------------------------------------
190
+ const HEAD = [
191
+ [CX, HEAD_TOP],
192
+ [462, 119], [536, 180], [540, 272],
193
+ [543, 330], [539, 384], [534, 430],
194
+ [531, 470], [526, 496], [509, 524],
195
+ [491, 552], [458, 578], [428, 590],
196
+ [409, 597], [352, 596], [333, 587],
197
+ [305, 574], [277, 552], [259, 522],
198
+ [242, 494], [237, 470], [240, 430],
199
+ [233, 384], [227, 330], [230, 272],
200
+ [234, 178], [300, 117], [CX, HEAD_TOP],
201
+ ];
202
+ // Heavy along the jaw, nearly nothing at the crown. The crown is under the hair
203
+ // mass, and ink drawn where it will be covered only ever shows up as a dark
204
+ // fringe along the hairline.
205
+ const HEAD_W = [4, 8, 13, 16, 17, 15, 11.5, 8, 5, 4];
206
+
207
+ const EAR_L = [[236, 366], [208, 352], [186, 380], [192, 418], [198, 450], [224, 462], [239, 452]];
208
+ const EAR_R = [[537, 372], [563, 360], [583, 388], [576, 424], [570, 452], [546, 462], [533, 452]];
209
+ const EAR_L_IN = [[219, 386], [206, 396], [204, 418], [212, 434]];
210
+ const EAR_R_IN = [[551, 392], [563, 402], [564, 422], [557, 437]];
211
+
212
+ /**
213
+ * Neck.
214
+ *
215
+ * Fused into the head layer and run down to y=790 — well past the shirt's
216
+ * neckline — for exactly the reason blue-shirt's header gives: a neck that is
217
+ * its own layer at its own parallax slides out from behind the collar under
218
+ * yaw, and a bare block wider than the neck draws a hard-edged box across the
219
+ * throat. The fill's side edges are the same curve as the ink lines, so the
220
+ * lines sit on their own fill's edge rather than near it.
221
+ *
222
+ * Short, and that is a correction: the first pass ran the neck to 100 units and
223
+ * every concept on the sheet read as a giraffe. 48 units show.
224
+ */
225
+ // The neck is a truncated cone, not a column. It starts about as wide as the
226
+ // chin pad and widens into the trapezius. Drawn with parallel sides — which is
227
+ // where it started, at 156 units against a 316-wide head — it read as a tree
228
+ // trunk, and no amount of work on the head above it fixed that.
229
+ const NECK_FILL =
230
+ 'M334 536C324 590 316 640 314 700L314 790L446 790L446 700C444 640 436 590 426 536Z';
231
+ // Both lines run to 750, well past any neckline, for the same reason the fill
232
+ // does: the neck is in the `head` layer and the shirt is in `body`, so a shrug
233
+ // slides the collar down 30 units while the neck stays put. Ending these where
234
+ // the resting collar happens to be leaves two ink lines stopping in mid-air
235
+ // above it. The shirt is painted after the head, so the overshoot costs nothing.
236
+ const NECK_L = [[334, 552], [327, 600], [321, 662], [318, 750]];
237
+ const NECK_R = [[426, 548], [433, 598], [439, 660], [442, 750]];
238
+
239
+ /**
240
+ * The underside of the jaw. Drawn on the NECK, below the chin line — put on the
241
+ * chin itself, which is where it first went, the identical mark reads as a
242
+ * second chin.
243
+ */
244
+ const JAW_UNDER = [[329, 584], [348, 610], [414, 612], [435, 582]];
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Static art: hair — a taper fade with a spiked fringe.
248
+ //
249
+ // One filled black mass, no outline, because the mass is already ink. It is a
250
+ // single path: the silhouette left-to-right over the crown, then the hairline
251
+ // right-to-left back, so the forehead is a hole and what is left down each side
252
+ // is the hair on the sides of the skull.
253
+ //
254
+ // That continuity is the whole trick. The first version drew the sideburns as
255
+ // their own shapes beside the mass; separate blocks abut the mass rather than
256
+ // being continuous with it, and at any weight the join shows as two floating
257
+ // rectangles by the ears.
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * The spiked fringe, as a zigzag polyline.
262
+ *
263
+ * Both the tooth depth AND the tooth pitch are jittered. Even pitch is a saw
264
+ * blade; it is the uneven pitch that reads as hair.
265
+ */
266
+ function spikes(x0, x1, n, baseAt, depth, seed) {
267
+ const r = rng(seed);
268
+ const pts = [];
269
+ for (let i = 0; i <= n; i++) {
270
+ const j = i === 0 || i === n ? 0 : (r() - 0.5) * ((x1 - x0) / n) * 0.7;
271
+ const x = x0 + ((x1 - x0) * i) / n + j;
272
+ pts.push([x, baseAt(x) + (i % 2 ? depth * (0.4 + 1.0 * r()) : 0)]);
273
+ }
274
+ return polyD(pts, 'L');
275
+ }
276
+
277
+ const HAIR_D = (() => {
278
+ const base = (x) => 250 + 0.0009 * (x - 380) * (x - 380);
279
+ return (
280
+ 'M228 398' +
281
+ 'C220 356 218 306 226 262' +
282
+ 'C233 178 302 118 380 121' +
283
+ 'C460 117 532 178 538 264' +
284
+ 'C544 308 542 354 536 398' +
285
+ // The sideburn ends on a diagonal: the inner boundary comes back UP past
286
+ // where the outer one stopped, so the strip closes to a wedge. A flat cut
287
+ // is the one shape a razor never leaves.
288
+ 'L521 374' +
289
+ 'C524 340 524 304 518 272' +
290
+ spikes(508, 252, 13, base, 32, 7) +
291
+ 'C246 304 246 340 249 374Z'
292
+ );
293
+ })();
294
+
295
+ /** The tuft in front of the ear. Without it the fade reads as a swim cap. */
296
+ const HAIR_TUFT = [[236, 372], [227, 388], [229, 410], [240, 414],
297
+ [247, 414], [248, 396], [245, 376]];
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Static art: torso
301
+ //
302
+ // The armscye seam and the short-sleeve hem are what separate arm from torso.
303
+ // Not mirrored — the left seam sits further out and the right runs straighter,
304
+ // which is what a shirt on a real torso does.
305
+ // ---------------------------------------------------------------------------
306
+ // One continuous convex curve from hem to neck is a balloon, whatever its
307
+ // radius — that was the first pass, and it read as a figure wearing a beanbag.
308
+ // A shoulder has two distinct runs meeting at the acromion: the outer arm edge,
309
+ // which is nearly vertical low down and turns hard, and the trapezius shelf,
310
+ // which leaves that corner almost horizontal before rising into the neck. The
311
+ // slope break at points 3 and 12 is the whole difference.
312
+ const TORSO = [
313
+ [46, 950],
314
+ [48, 862], [92, 754], [176, 726],
315
+ [236, 720], [280, 692], [316, 650],
316
+ [330, 684], [430, 686], [446, 646],
317
+ [482, 688], [528, 716], [582, 722],
318
+ [664, 750], [712, 860], [714, 950],
319
+ ];
320
+ const TORSO_W = [10, 11, 8.5, 8, 11, 10];
321
+
322
+ const SEAM_L = [[178, 728], [158, 786], [164, 866], [180, 950]];
323
+ const SEAM_R = [[580, 724], [601, 782], [597, 862], [584, 950]];
324
+ const HEM_L = [[57, 884], [98, 918], [146, 914], [176, 878]];
325
+ const HEM_R = [[703, 886], [664, 920], [622, 916], [596, 880]];
326
+
327
+ /**
328
+ * Shirt creases. Three marks, all on one side.
329
+ *
330
+ * On one side deliberately: a symmetric pair of creases reads as a printed
331
+ * pattern rather than as fabric. Cloth folds where the body pulls it, and a
332
+ * body does not pull evenly.
333
+ */
334
+ const CREASES = [
335
+ { p: [[300, 726], [305, 754], [307, 780], [303, 802]], w: [2, 7, 2] },
336
+ { p: [[326, 740], [331, 766], [332, 788], [329, 806]], w: [2, 5.5, 2] },
337
+ { p: [[336, 872], [345, 898], [352, 918], [349, 936]], w: [2, 6, 2] },
338
+ ];
339
+
340
+ // Polo collar. The two blades are different shapes and set at different angles;
341
+ // in the reference that one asymmetry does more for the hand-drawn read than
342
+ // any amount of wobble along the lines.
343
+ const BLADE_L = [[314, 646], [322, 678], [336, 702], [353, 718],
344
+ [359, 702], [366, 686], [371, 668]];
345
+ const BLADE_R = [[448, 644], [442, 670], [432, 690], [417, 704],
346
+ [411, 692], [405, 680], [401, 666]];
347
+ const TRIM_L = [[322, 654], [329, 680], [341, 700], [354, 712]];
348
+ const TRIM_R = [[441, 652], [436, 674], [428, 690], [416, 700]];
349
+ const PLACKET = [[387, 700], [390, 734], [385, 768], [389, 800]];
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Layers.
353
+ //
354
+ // Four, the same set blue-shirt runs. Draw order is head, body, features, hair:
355
+ // the shirt has to be over the neck, and the fringe has to be over the brows.
356
+ // ---------------------------------------------------------------------------
357
+ const PARALLAX = { head: 1.0, body: 0.1, features: 1.22, hair: 1.12 };
358
+ const LAYERS = ['head', 'body', 'features', 'hair'];
359
+
360
+ // Roll pivots at the base of the neck. About the chin, the cranium swings
361
+ // sideways and the whole thing reads as a puppet on a stick.
362
+ const PIVOT = { x: CX, y: 700 };
363
+
364
+ // Pose mechanics for face-core's poseTransforms, in this rig's own art units
365
+ // (native 760x950 — about 2.6x smaller than blue-shirt's, hence units: 1 and
366
+ // its own literals; see the art-unit rules in docs/contract-avatar.md).
367
+ //
368
+ // The shrug tilt is 1.6 degrees, not blue-shirt's 1.9. This collar is a
369
+ // shallow polo opening over a white neck on a white shirt, so there is no
370
+ // tonal difference to hide a mismatch: past about 1.6 the neck's ink lines
371
+ // swing out from behind the blades and read as two stray marks on the chest.
372
+ // The 30-unit lift is the S-conversion lesson in the flesh: carried over
373
+ // unconverted from blue-shirt the shoulders moved 1.6% of frame height and
374
+ // the channel that params.js calls the single most legible bid for the floor
375
+ // did not read. Degrees carry over unconverted; travels do not.
376
+ const POSE = {
377
+ leanTravel: 23, leanPivot: { x: PIVOT.x, y: 560 },
378
+ shrugLift: 30, shrugTiltDeg: 1.6, shrugPivot: { x: PIVOT.x, y: 800 },
379
+ yawPx: 28, pitchPx: 17,
380
+ pivot: PIVOT,
381
+ // 1.2% at full inhale. Chest circumference changes ~2-3% in quiet
382
+ // breathing, so a little over half that in linear scale is the calm end of
383
+ // real — and it renders as the shoulder line rising 3 units and the chest
384
+ // widening 4 either side, against a hem that stays where it is. The
385
+ // predecessor (a 4.2-unit rigid slide of the whole shirt) moved more pixels
386
+ // and read as less alive, which is the entire argument for the swell.
387
+ breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
388
+ // Lateral trunk travel. 16 units is ~2% of the frame width — the trunk
389
+ // re-settling is meant to be noticed peripherally and never watched.
390
+ turnPx: 16,
391
+ layers: LAYERS, parallax: PARALLAX,
392
+ torsoLayers: ['body'],
393
+ units: 1,
394
+ };
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Generators: mouth
398
+ //
399
+ // The mouth is ONE closed contour used three ways — filled with ink for the
400
+ // interior, outlined with a tapered ring for the lips, and used as the clip for
401
+ // teeth and tongue. That is the whole model, and it is why the closed mouth
402
+ // needs no special case: at rest the contour is a degenerate lens, the fill
403
+ // collapses to nothing, and what is left is the ring — a tapered line, thick in
404
+ // the middle and pointed at the corners, which is exactly the rest mouth the
405
+ // concept art was drawn with.
406
+ // ---------------------------------------------------------------------------
407
+ function mouthGeometry(p) {
408
+ const cx = MOUTH.cx;
409
+ const cy = MOUTH.cy;
410
+ const open = clamp(p.mouthOpen);
411
+ const round = clamp(p.mouthRound);
412
+ const tuck = clamp(p.mouthTuck);
413
+
414
+ const w = (26 + clamp(p.mouthWidth) * 32) * (1 - 0.36 * round);
415
+
416
+ // Width profile around the ring: s=0 is the left corner, 0.25 the middle of
417
+ // the upper lip, 0.5 the right corner, 0.75 the middle of the lower. Pointed
418
+ // at the corners, heavy in the middles. `mouthPress` thins the whole thing —
419
+ // pressed lips are thin lips — and `mouthTuck` fattens the lower one, because
420
+ // a lip drawn in under the teeth is a compressed lip.
421
+ const t = 1 - 0.4 * clamp(p.mouthPress);
422
+ const profile = [2.5 * t, 10.5 * t, 3 * t, 11.5 * t * (1 + 0.35 * tuck), 2.5 * t];
423
+ const halfUp = profile[1] / 2;
424
+ const halfLo = profile[3] / 2;
425
+
426
+ // `mouthOpen` is the height of the DARK, not the separation of the two
427
+ // centrelines. Those are not the same thing and the difference is the whole
428
+ // lip band — about 11 units here — so with the naive reading the mouth stayed
429
+ // shut until mouthOpen passed 0.25. Two of the nine visemes live below that:
430
+ // B at 0.16 and G at 0.20, both of which ask for upper teeth. Neither could
431
+ // ever show them, and B, G and X all collapsed onto the same drawn line.
432
+ const h = open * MOUTH_APERTURE;
433
+
434
+ // ...but only once the lips have actually parted. Compensating at open = 0
435
+ // would prise the two centrelines apart by the lip thickness and the resting
436
+ // mouth would be a fat lens instead of the single tapered stroke the whole
437
+ // face is built around. The ramp buys the compensation in over the first
438
+ // fifth of the channel, which is also roughly where real lips separate.
439
+ const k = clamp(open / 0.18);
440
+
441
+ // The corners rise while the middle stays put. That is a smile; blue-shirt's
442
+ // mouth translates the whole aperture with the corners instead, which works
443
+ // there because its cupid's bow and modulated seam carry the expression. This
444
+ // face has no such detail to fall back on — the bow IS the expression, so it
445
+ // has to be geometric.
446
+ //
447
+ // The constant 6 is resting pleasantness. An attentive agent's neutral is not a
448
+ // flat line, and at mouthCorner's REST value of 0.1 the corner term alone
449
+ // gives 2.4 units of bow across an 84-wide mouth, which is nothing.
450
+ const yL = cy - 6 - p.mouthCornerL * 24;
451
+ const yR = cy - 6 - p.mouthCornerR * 24;
452
+
453
+ // The aperture opens DOWNWARD, 3:1. The upper lip is anchored to the maxilla
454
+ // and barely moves; the lower rides the jaw. Splitting it evenly is what makes
455
+ // an open mouth read as a cat's.
456
+ const apTop = cy - h * 0.25;
457
+ let apBot = cy + h * 0.75;
458
+ // For F/V the lower lip rides up under the upper teeth, closing the aperture
459
+ // from below rather than from above. The floor matters more than the lift: an
460
+ // F that shuts completely is an M, and the one thing that has to survive is
461
+ // the sliver of upper teeth resting on the lip.
462
+ if (tuck > 0) apBot = Math.max(apTop + 6, apBot - tuck * (h * 0.6 + 4));
463
+
464
+ // Solve back from where the aperture must be to where the control points go.
465
+ // A cubic reaches only 3/4 of the way from its endpoints to its controls, and
466
+ // both cubics here share the two corner endpoints, so the curve midpoint is
467
+ // cornerMid + 0.75 * ctrlY. Inverting that is the only reason this is not
468
+ // simply an offset.
469
+ const cornerMid = (yL + yR) / 8;
470
+ const topY = (apTop - k * halfUp - cornerMid) / 0.75;
471
+ const botY = (apBot + k * halfLo - cornerMid) / 0.75;
472
+
473
+ const contour = [
474
+ [cx - w, yL],
475
+ [cx - w * 0.55, topY], [cx + w * 0.55, topY], [cx + w, yR],
476
+ [cx + w * 0.55, botY], [cx - w * 0.55, botY], [cx - w, yL],
477
+ ];
478
+
479
+ // Where the DARK actually is. Below the compensation ramp these come out
480
+ // crossed — innerBot above innerTop — which is the correct answer for a shut
481
+ // mouth and is what teethPath and the tongue test against. Recomputed rather
482
+ // than assumed equal to apTop/apBot, because the clamp above and the ramp
483
+ // both mean the aperture asked for is not always the aperture drawn.
484
+ const innerTop = cornerMid + 0.75 * topY + halfUp;
485
+ const innerBot = cornerMid + 0.75 * botY - halfLo;
486
+
487
+ return { contour, profile, cx, cy, w, h, topY, botY, innerTop, innerBot, open, tuck };
488
+ }
489
+
490
+ /**
491
+ * The upper teeth as a dental arch rather than a bar.
492
+ *
493
+ * The incisal edge follows the smile arc; a dead-straight one is most of what
494
+ * makes a white rect read as a strip of paper in the mouth. Deliberately no
495
+ * per-tooth lines: at avatar size they are sub-pixel, and a single midline tick
496
+ * is exactly what makes a cheap avatar look like it has a gap tooth. The top
497
+ * runs well above the lip and is cut off by the aperture clip, so the strip's
498
+ * upper boundary is always the lip's own inner contour.
499
+ */
500
+ function teethPath(m, amt, lower) {
501
+ if (amt < 0.01) return '';
502
+ const gap = m.innerBot - m.innerTop;
503
+ if (gap < 2) return '';
504
+ const tw = m.w * (lower ? 0.6 : 0.76);
505
+ // Normally never more than half the aperture: teeth that meet across the gap
506
+ // close the mouth optically no matter how far mouthOpen has driven the
507
+ // contour. F/V is the exception and the reason the cap is not a constant —
508
+ // there the upper teeth are literally resting ON the lower lip, so the dark
509
+ // is a rim rather than a cavity, and holding G to the same half-and-half
510
+ // split as B left the two letters drawing the same slit.
511
+ const cap = lower ? 0.5 : 0.5 + 0.35 * m.tuck;
512
+ const th = Math.min(amt * (lower ? 13 : 20), gap * cap);
513
+
514
+ if (lower) {
515
+ // The strip runs off past the lip on the far side so its outer boundary is
516
+ // always the clip, never an edge of its own.
517
+ const base = m.innerBot + 8;
518
+ const edge = m.innerBot - th; // deepest point, at the midline
519
+ const end = m.innerBot - th * 0.35; // shallowest, at the corners
520
+ return (
521
+ `M${f(m.cx - tw)} ${f(base)}L${f(m.cx + tw)} ${f(base)}` +
522
+ `L${f(m.cx + tw * 0.92)} ${f(end)}` +
523
+ `Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
524
+ );
525
+ }
526
+ const top = m.innerTop - 8;
527
+ const edge = m.innerTop + th;
528
+ const end = m.innerTop + th * 0.35;
529
+ return (
530
+ `M${f(m.cx - tw)} ${f(top)}L${f(m.cx + tw)} ${f(top)}` +
531
+ `L${f(m.cx + tw * 0.92)} ${f(end)}` +
532
+ `Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
533
+ );
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Generators: eyes
538
+ //
539
+ // A solid ink bean, built as two cubics meeting at pointed corners: an upper
540
+ // lid arc and a lower lid arc. Not an <ellipse> — an ellipse cannot blink, and
541
+ // it cannot have corners.
542
+ //
543
+ // Blinking is the top edge descending toward the bottom one. At full closure
544
+ // both edges end up ABOVE the corners, so the shape is a thin crescent bowing
545
+ // upward: the relaxed closed lid every peep is drawn with. Collapsing it to a
546
+ // straight line instead reads as the eyes being deleted for a frame.
547
+ // ---------------------------------------------------------------------------
548
+ function eyePath(cx, cy, lid, squint, tiltDeg) {
549
+ const L = clamp(lid);
550
+ // 1.05 rather than 0.75 because a cubic reaches only ~0.75 of its control
551
+ // offset at the midpoint; this is what makes the open bean EYE.ry tall. It
552
+ // was 1.4, which drew a bean noticeably taller than it was wide — read as a
553
+ // stare rather than as a peep, whose eyes sit at or below square.
554
+ const topY = lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, L);
555
+ const botY = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.7;
556
+ const rx = EYE.rx;
557
+ const a = (tiltDeg * Math.PI) / 180;
558
+ const ca = Math.cos(a), sa = Math.sin(a);
559
+ const R = (x, y) => {
560
+ const dx = x - cx, dy = y - cy;
561
+ return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
562
+ };
563
+ return (
564
+ `M${R(cx - rx, cy)}` +
565
+ `C${R(cx - rx * 0.5, topY)} ${R(cx + rx * 0.5, topY)} ${R(cx + rx, cy)}` +
566
+ `C${R(cx + rx * 0.5, botY)} ${R(cx - rx * 0.5, botY)} ${R(cx - rx, cy)}Z`
567
+ );
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Generators: brows
572
+ //
573
+ // The heaviest marks on the face, and the ones doing the most affective work,
574
+ // so they are generated rather than transformed: `browAngle` has to pivot the
575
+ // tail without moving the head of the brow, which a rotation about any single
576
+ // point cannot do.
577
+ //
578
+ // The left brow is longer and flatter and the right sits 3 units higher. That
579
+ // asymmetry is baked into the control points, so it survives every pose.
580
+ // ---------------------------------------------------------------------------
581
+ function browPath(pts, raise, angle, inner) {
582
+ const n = pts.length - 1;
583
+ const out = pts.map(([x, y], i) => {
584
+ // u runs 0 at the inner end to 1 at the outer, so `inner` and `angle` each
585
+ // pivot their own end and fade out across the brow rather than translating
586
+ // the whole mark. That is the only reason these are separate channels.
587
+ const u = i / n;
588
+ return [x, y - raise * 15 - inner * 11 * (1 - u) - angle * 12 * u];
589
+ });
590
+ return taper(out, [3.5, 17, 8], 6);
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // Static markup
595
+ // ---------------------------------------------------------------------------
596
+ const HEAD_FILL = region(HEAD);
597
+ const HEAD_RING = taperRing(HEAD, HEAD_W, 10);
598
+ const EAR_L_FILL = region(EAR_L), EAR_R_FILL = region(EAR_R);
599
+ const EAR_L_RING = taper(EAR_L, [4, 7.5, 4]), EAR_R_RING = taper(EAR_R, [4, 7, 4]);
600
+
601
+ /** The nose: one mark. Down the bridge, then a hook at the base. A straight
602
+ * vertical tick is a scratch; the hook is the entire nose. */
603
+ const NOSE_D = taper(
604
+ [[CX - 5, NOSE_TOP], [CX - 14, 422], [CX - 20, 441], [CX - 13, 452],
605
+ [CX - 5, 461], [CX + 11, 459], [CX + 18, 448]],
606
+ [2.5, 9.5, 3.5]
607
+ );
608
+
609
+ function markup(id, t) {
610
+ const ink = (d) => `<path d="${d}" fill="${t.ink}"/>`;
611
+ return `
612
+ <svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
613
+ preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
614
+ <defs>
615
+ <radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
616
+ <stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
617
+ </radialGradient>
618
+ <clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
619
+ </defs>
620
+
621
+ <rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
622
+
623
+ <!-- head, ears and neck. Ears go under the head fill so the loop's inner half
624
+ is covered and only the rim reads. -->
625
+ <g id="${id}-head">
626
+ <path d="${NECK_FILL}" fill="${t.paper}"/>
627
+ ${ink(taper(NECK_L, [3, 8, 6]))}
628
+ ${ink(taper(NECK_R, [3, 8, 6]))}
629
+ <path d="${EAR_L_FILL}" fill="${t.paper}"/>
630
+ <path d="${EAR_R_FILL}" fill="${t.paper}"/>
631
+ ${ink(EAR_L_RING)}
632
+ ${ink(EAR_R_RING)}
633
+ <path d="${HEAD_FILL}" fill="${t.paper}"/>
634
+ ${ink(HEAD_RING)}
635
+ ${ink(taper(EAR_L_IN, [2.5, 5, 2.5]))}
636
+ ${ink(taper(EAR_R_IN, [2.5, 4.5, 2.5]))}
637
+ ${ink(taper(JAW_UNDER, [2, 5.5, 2]))}
638
+ <!-- Hair underlay: the same mass, locked to the skull at head parallax.
639
+ The hair layer runs faster, so under yaw it slides a few units across
640
+ the cranium; the mass abuts the head outline with no overlap margin, so
641
+ that slide would otherwise open a bright white sliver of scalp along the
642
+ leading edge. On a white-on-white avatar that sliver is far more visible
643
+ than blue-shirt's equivalent. Cheap insurance; do not remove it. -->
644
+ <path d="${HAIR_D}" fill="${t.ink}"/>
645
+ </g>
646
+
647
+ <!-- shirt: silhouette, arm separation, creases, collar -->
648
+ <g id="${id}-body">
649
+ <path d="${region(TORSO)}" fill="${t.paper}"/>
650
+ ${ink(taper(TORSO, TORSO_W))}
651
+ ${ink(taper(SEAM_L, [7, 8, 6]))}
652
+ ${ink(taper(SEAM_R, [7, 8, 6]))}
653
+ ${ink(taper(HEM_L, [4, 8, 4]))}
654
+ ${ink(taper(HEM_R, [4, 8, 4]))}
655
+ ${CREASES.map((c) => ink(taper(c.p, c.w))).join('\n ')}
656
+ <path d="${region(BLADE_L)}" fill="${t.paper}"/>
657
+ <path d="${region(BLADE_R)}" fill="${t.paper}"/>
658
+ ${ink(taper(BLADE_L, [5, 9, 5]))}
659
+ ${ink(taper(BLADE_R, [5, 8, 4.5]))}
660
+ <path d="${taper(TRIM_L, [3, 7, 3])}" fill="${t.accent}"/>
661
+ <path d="${taper(TRIM_R, [3, 6, 3])}" fill="${t.accent}"/>
662
+ ${ink(taper(PLACKET, [4, 5, 3.5]))}
663
+ <circle cx="391" cy="722" r="7" fill="${t.accent}"/>
664
+ <circle cx="387" cy="766" r="6.5" fill="${t.accent}"/>
665
+ </g>
666
+
667
+ <!-- features: six marks and nothing else -->
668
+ <g id="${id}-features">
669
+ <path id="${id}-browL" fill="${t.ink}"/>
670
+ <path id="${id}-browR" fill="${t.ink}"/>
671
+ <g id="${id}-eyes">
672
+ <path id="${id}-eyeL" fill="${t.ink}"/>
673
+ <path id="${id}-eyeR" fill="${t.ink}"/>
674
+ </g>
675
+ <path d="${NOSE_D}" fill="${t.ink}"/>
676
+ <g id="${id}-mouth">
677
+ <path id="${id}-mouthIn" fill="${t.mouthIn}"/>
678
+ <g clip-path="url(#${id}-clipMouth)">
679
+ <ellipse id="${id}-tongue" fill="${t.tongue}"/>
680
+ <path id="${id}-teeth" fill="${t.teeth}"/>
681
+ <path id="${id}-teethLo" fill="${t.teeth}" opacity=".85"/>
682
+ </g>
683
+ <path id="${id}-lips" fill="${t.ink}"/>
684
+ </g>
685
+ </g>
686
+
687
+ <!-- hair -->
688
+ <g id="${id}-hair">
689
+ <path d="${HAIR_D}" fill="${t.ink}"/>
690
+ <path d="${region(HAIR_TUFT)}" fill="${t.ink}"/>
691
+ </g>
692
+ </svg>`;
693
+ }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // Renderer
697
+ //
698
+ // The third data point for the finding that `apply` is not per-avatar — which
699
+ // produced face-core.js, now the owner of the pose mechanics and the shared
700
+ // teeth fragment. This rig keeps its own eye block (a bean, not an iris under
701
+ // lids), its point-list brows, and its contour mouth.
702
+ // ---------------------------------------------------------------------------
703
+ let uid = 0;
704
+
705
+ export function createFace(mount, theme = {}) {
706
+ const t = Object.assign({}, THEME, theme);
707
+ const id = `peep${++uid}`;
708
+ const { svg, $, set } = createFaceShell(mount, id, markup(id, t));
709
+
710
+ const el = {
711
+ head: $('head'), body: $('body'), features: $('features'), hair: $('hair'),
712
+ browL: $('browL'), browR: $('browR'),
713
+ eyes: $('eyes'), eyeL: $('eyeL'), eyeR: $('eyeR'),
714
+ mouthIn: $('mouthIn'), lips: $('lips'), clipMouth: $('clipMouthP'),
715
+ teeth: $('teeth'), teethLo: $('teethLo'), tongue: $('tongue'),
716
+ };
717
+
718
+ function apply(p) {
719
+ poseTransforms(p, set, el, POSE);
720
+
721
+ // --- eyes -------------------------------------------------------------
722
+ // With no sclera the whole bean travels; see the header for what that costs
723
+ // and why it is still the right call. 7 units on a 25-wide eye is a lot of
724
+ // relative travel — more than blue-shirt's iris gets — and it has to be, or
725
+ // a gaze target does not read at all.
726
+ set(el.eyes, 'transform', `translate(${f(p.pupilX * 11)} ${f(p.pupilY * 8)})`);
727
+
728
+ // The lid follows vertical gaze downward only. Looking up genuinely does
729
+ // widen the aperture, so there is nothing to add on that side.
730
+ const lidFollow = Math.max(0, p.pupilY) * 0.22;
731
+ set(el.eyeL, 'd', eyePath(CX - EYE.dx, EYE.y + 1, p.lidL + lidFollow, p.squintL, -9));
732
+ set(el.eyeR, 'd', eyePath(CX + EYE.dx, EYE.y - 1, p.lidR + lidFollow, p.squintR, 8));
733
+
734
+ // --- brows ------------------------------------------------------------
735
+ set(el.browL, 'd', browPath(BROW_L, p.browRaiseL, p.browAngleL, p.browInnerL));
736
+ set(el.browR, 'd', browPath(BROW_R, p.browRaiseR, p.browAngleR, p.browInnerR));
737
+
738
+ // --- mouth ------------------------------------------------------------
739
+ const m = mouthGeometry(p);
740
+ const contour = region(m.contour);
741
+ set(el.mouthIn, 'd', contour);
742
+ set(el.clipMouth, 'd', contour);
743
+ set(el.lips, 'd', taperRing(m.contour, m.profile, 12));
744
+
745
+ // Fade the interior in as the lips part. At rest the contour is degenerate
746
+ // and fills to nothing anyway, but the first few percent of opening produce
747
+ // a sliver narrower than the lip ring, and drawing it makes the closed mouth
748
+ // flicker between two blacks as a viseme track passes through.
749
+ // Keyed off the gap that is actually drawn, not off mouthOpen. Ramping on
750
+ // the requested aperture leaves the interior part-transparent wherever the
751
+ // lips have parted but the channel is still low, and a half-opaque interior
752
+ // renders as GREY — which on a face with exactly two values reads as a
753
+ // rendering fault. F, at mouthOpen 0.22, was a grey hole.
754
+ set(el.mouthIn, 'opacity', f(clamp((m.innerBot - m.innerTop) / 3)));
755
+
756
+ pairedTeeth(p, set, el, teethPath, m);
757
+
758
+ const tg = clamp(p.tongue);
759
+ set(el.tongue, 'cx', f(m.cx));
760
+ set(el.tongue, 'cy', f(m.innerBot + 6 - tg * ((m.innerBot - m.innerTop) * 0.8 + 6)));
761
+ set(el.tongue, 'rx', f(m.w * 0.58));
762
+ set(el.tongue, 'ry', '8');
763
+ set(el.tongue, 'opacity', tg > 0.02 ? '1' : '0');
764
+ }
765
+
766
+ return faceApi(mount, svg, apply, t);
767
+ }