@voqalize/avatar 0.2.1 → 0.3.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.
Files changed (201) hide show
  1. package/LICENSE +21 -661
  2. package/README.md +144 -663
  3. package/client/Avatar.tsx +57 -0
  4. package/client/AvatarClient.ts +523 -0
  5. package/client/arjun.ts +24 -0
  6. package/client/createAvatar.ts +139 -0
  7. package/client/createCanvasAvatar.ts +72 -0
  8. package/client/index.ts +39 -0
  9. package/client/internal.ts +80 -0
  10. package/client/interviewer-female.ts +4 -0
  11. package/client/interviewer-male.ts +4 -0
  12. package/client/ishita.ts +24 -0
  13. package/client/kabir.ts +24 -0
  14. package/client/meera.ts +24 -0
  15. package/client/naina.ts +24 -0
  16. package/client/professional-female-a.ts +4 -0
  17. package/client/professional-female-b.ts +4 -0
  18. package/client/professional-male-a.ts +4 -0
  19. package/client/professional-male-b.ts +4 -0
  20. package/client/react.ts +13 -0
  21. package/client/types.ts +146 -0
  22. package/client/useAvatar.ts +74 -0
  23. package/client/vikram.ts +24 -0
  24. package/dist/Avatar.d.ts +44 -0
  25. package/dist/Avatar.d.ts.map +1 -0
  26. package/{client/dist → dist}/Avatar.js +2 -2
  27. package/dist/Avatar.js.map +1 -0
  28. package/dist/AvatarClient.d.ts +196 -0
  29. package/dist/AvatarClient.d.ts.map +1 -0
  30. package/dist/AvatarClient.js +450 -0
  31. package/dist/AvatarClient.js.map +1 -0
  32. package/dist/arjun.d.ts +6 -0
  33. package/dist/arjun.d.ts.map +1 -0
  34. package/dist/arjun.js +18 -0
  35. package/dist/arjun.js.map +1 -0
  36. package/dist/createAvatar.d.ts +99 -0
  37. package/dist/createAvatar.d.ts.map +1 -0
  38. package/dist/createAvatar.js +75 -0
  39. package/dist/createAvatar.js.map +1 -0
  40. package/dist/createCanvasAvatar.d.ts +22 -0
  41. package/dist/createCanvasAvatar.d.ts.map +1 -0
  42. package/dist/createCanvasAvatar.js +47 -0
  43. package/dist/createCanvasAvatar.js.map +1 -0
  44. package/dist/index.d.ts +30 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +29 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/internal.d.ts +30 -0
  49. package/dist/internal.d.ts.map +1 -0
  50. package/dist/internal.js +33 -0
  51. package/dist/internal.js.map +1 -0
  52. package/dist/interviewer-female.d.ts +4 -0
  53. package/dist/interviewer-female.d.ts.map +1 -0
  54. package/dist/interviewer-female.js +3 -0
  55. package/dist/interviewer-female.js.map +1 -0
  56. package/dist/interviewer-male.d.ts +4 -0
  57. package/dist/interviewer-male.d.ts.map +1 -0
  58. package/dist/interviewer-male.js +3 -0
  59. package/dist/interviewer-male.js.map +1 -0
  60. package/dist/ishita.d.ts +6 -0
  61. package/dist/ishita.d.ts.map +1 -0
  62. package/dist/ishita.js +18 -0
  63. package/dist/ishita.js.map +1 -0
  64. package/dist/kabir.d.ts +6 -0
  65. package/dist/kabir.d.ts.map +1 -0
  66. package/dist/kabir.js +18 -0
  67. package/dist/kabir.js.map +1 -0
  68. package/dist/meera.d.ts +6 -0
  69. package/dist/meera.d.ts.map +1 -0
  70. package/dist/meera.js +18 -0
  71. package/dist/meera.js.map +1 -0
  72. package/dist/naina.d.ts +6 -0
  73. package/dist/naina.d.ts.map +1 -0
  74. package/dist/naina.js +18 -0
  75. package/dist/naina.js.map +1 -0
  76. package/dist/professional-female-a.d.ts +4 -0
  77. package/dist/professional-female-a.d.ts.map +1 -0
  78. package/dist/professional-female-a.js +3 -0
  79. package/dist/professional-female-a.js.map +1 -0
  80. package/dist/professional-female-b.d.ts +4 -0
  81. package/dist/professional-female-b.d.ts.map +1 -0
  82. package/dist/professional-female-b.js +3 -0
  83. package/dist/professional-female-b.js.map +1 -0
  84. package/dist/professional-male-a.d.ts +4 -0
  85. package/dist/professional-male-a.d.ts.map +1 -0
  86. package/dist/professional-male-a.js +3 -0
  87. package/dist/professional-male-a.js.map +1 -0
  88. package/dist/professional-male-b.d.ts +4 -0
  89. package/dist/professional-male-b.d.ts.map +1 -0
  90. package/dist/professional-male-b.js +3 -0
  91. package/dist/professional-male-b.js.map +1 -0
  92. package/dist/react.d.ts +13 -0
  93. package/dist/react.d.ts.map +1 -0
  94. package/dist/react.js +13 -0
  95. package/dist/react.js.map +1 -0
  96. package/dist/types.d.ts +94 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +93 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/useAvatar.d.ts +39 -0
  101. package/dist/useAvatar.d.ts.map +1 -0
  102. package/dist/useAvatar.js +35 -0
  103. package/dist/useAvatar.js.map +1 -0
  104. package/dist/vikram.d.ts +6 -0
  105. package/dist/vikram.d.ts.map +1 -0
  106. package/dist/vikram.js +18 -0
  107. package/dist/vikram.js.map +1 -0
  108. package/package.json +81 -24
  109. package/src/avatar.d.ts +161 -120
  110. package/src/avatar.js +234 -146
  111. package/src/behavior.d.ts +26 -0
  112. package/src/behavior.js +102 -0
  113. package/src/camera.js +29 -0
  114. package/src/canvas/author/parts/eye.mjs +722 -0
  115. package/src/canvas/author/parts/hand.mjs +1156 -0
  116. package/src/canvas/author/parts/mouth.mjs +741 -0
  117. package/src/canvas/author/parts/nose.mjs +100 -0
  118. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  119. package/src/canvas/author/path.mjs +283 -0
  120. package/src/canvas/author/rig.mjs +405 -0
  121. package/src/canvas/avatars/round/face.d.mts +3 -0
  122. package/src/canvas/avatars/round/face.mjs +1307 -0
  123. package/src/canvas/create-rig.d.ts +15 -0
  124. package/src/canvas/create-rig.js +100 -0
  125. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  126. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  127. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  128. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  129. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  130. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  131. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  132. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  133. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  134. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  135. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  136. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  137. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  138. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  139. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  140. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  141. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  142. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  143. package/src/canvas/data/interviewer-female.rig.json +1 -0
  144. package/src/canvas/data/interviewer-male.rig.json +1 -0
  145. package/src/canvas/data/professional-female-a.rig.json +1 -0
  146. package/src/canvas/data/professional-female-b.rig.json +1 -0
  147. package/src/canvas/data/professional-male-a.rig.json +1 -0
  148. package/src/canvas/data/professional-male-b.rig.json +1 -0
  149. package/src/canvas/src/live.js +508 -0
  150. package/src/canvas/src/render2d.js +218 -0
  151. package/src/canvas/src/rig.js +297 -0
  152. package/src/canvas/src/vocab.js +96 -0
  153. package/src/clips.js +18 -7
  154. package/src/conformance.js +119 -0
  155. package/src/emotions.js +4 -3
  156. package/src/face-core.js +27 -1
  157. package/src/face-myna.d.ts +7 -0
  158. package/src/face-myna.js +192 -131
  159. package/src/face-peep-control-plane.js +167 -0
  160. package/src/face-peep.d.ts +7 -0
  161. package/src/face-peep.js +274 -116
  162. package/src/face-wren.d.ts +7 -0
  163. package/src/face-wren.js +17 -19
  164. package/src/faces.d.ts +12 -0
  165. package/src/faces.js +53 -0
  166. package/src/gaze.js +119 -4
  167. package/src/hand.js +120 -91
  168. package/src/idle.js +18 -101
  169. package/src/interjections.js +156 -31
  170. package/src/params.js +8 -4
  171. package/src/perform.js +7 -9
  172. package/src/rig.d.ts +24 -0
  173. package/src/rig.js +32 -0
  174. package/src/speech-timing.js +23 -0
  175. package/src/visemes.js +28 -12
  176. package/client/dist/Avatar.d.ts +0 -27
  177. package/client/dist/Avatar.d.ts.map +0 -1
  178. package/client/dist/Avatar.js.map +0 -1
  179. package/client/dist/AvatarClient.d.ts +0 -139
  180. package/client/dist/AvatarClient.d.ts.map +0 -1
  181. package/client/dist/AvatarClient.js +0 -223
  182. package/client/dist/AvatarClient.js.map +0 -1
  183. package/client/dist/index.d.ts +0 -22
  184. package/client/dist/index.d.ts.map +0 -1
  185. package/client/dist/index.js +0 -22
  186. package/client/dist/index.js.map +0 -1
  187. package/client/dist/types.d.ts +0 -86
  188. package/client/dist/types.d.ts.map +0 -1
  189. package/client/dist/types.js +0 -31
  190. package/client/dist/types.js.map +0 -1
  191. package/client/dist/useAvatar.d.ts +0 -42
  192. package/client/dist/useAvatar.d.ts.map +0 -1
  193. package/client/dist/useAvatar.js +0 -51
  194. package/client/dist/useAvatar.js.map +0 -1
  195. package/client/src/Avatar.tsx +0 -33
  196. package/client/src/AvatarClient.ts +0 -270
  197. package/client/src/index.ts +0 -22
  198. package/client/src/types.ts +0 -104
  199. package/client/src/useAvatar.ts +0 -85
  200. package/docs/contract-avatar.md +0 -371
  201. package/docs/contract-protocol.md +0 -461
package/src/face-myna.js CHANGED
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * The third line-art character, authored against a user-supplied reference
5
5
  * asset (a raster illustration): a young contemporary Indian woman — wavy
6
- * shoulder-length dark hair with paper highlight slashes, thin geometric
7
- * glasses, orange hoop earrings, a white crew tee under an open orange
8
- * overshirt. Hand-authored geometry with the reference on screen; nothing is
9
- * traced. Same idiom as peep and wren — no strokes anywhere, every mark a
10
- * filled taper — and the same 30-channel contract underneath.
6
+ * shoulder-length dark hair with paper highlight slashes, orange hoop
7
+ * earrings, a white crew tee under an open overshirt. Hand-authored geometry
8
+ * with the reference on screen; nothing is traced. Same idiom as peep and
9
+ * wren — no strokes anywhere, every mark a filled taper — and the same
10
+ * 30-channel contract underneath.
11
11
  *
12
12
  * What is deliberately different from the siblings, and why:
13
13
  * - THE HAIR IS A HORSESHOE, NOT A CLOUD. One closed loop: wavy outer
@@ -20,10 +20,9 @@
20
20
  * head-layer underlay (same loop at head parallax) backfills the ±3-unit
21
21
  * slide between the two layers under yaw — same insurance as peep's, doing
22
22
  * one extra job here.
23
- * - THE ACCENT IS A GARMENT, not trim (peep) or frames (wren): the overshirt
24
- * is two accent-filled panels over a paper tee. Background leaks behind an
25
- * orange shirt are loud, which is why the tee runs edge-to-edge under both
26
- * panels and everything overshoots the frame.
23
+ * - THE OVERSHIRT IS TWO PAPER PANELS over a paper tee, distinguished only by
24
+ * ink outline — no fill colour on the garment. The tee still runs
25
+ * edge-to-edge under both panels and everything overshoots the frame.
27
26
  * - THE MOUTH HAS LIPS. The proven aperture-contour model, but the ring's
28
27
  * width profile carries a cupid's-bow dip at the top centre and a fuller
29
28
  * lower lip. (An under-lip shadow mark existed through v7; it was deleted —
@@ -44,59 +43,74 @@ import {
44
43
  f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
45
44
  } from './face-core.js';
46
45
  import { taper, taperRing, region } from './line-art.js';
46
+ import { viewBoxForHead } from './camera.js';
47
47
 
48
48
  export const THEME = {
49
- // Sampled from the reference card: a warmer, creamier ground than peep's.
50
- bg0: '#f2ead9',
51
- bg1: '#e6d9c2',
52
49
  ink: '#191919',
53
50
  paper: '#ffffff',
54
- // The overshirt orange — one key, hoops use it too. ~9% duskier than the
55
- // asset's sample: at tile size the sampled value out-shouted the face.
51
+ // The hoop earrings' orange. ~9% duskier than the asset's sample: at tile
52
+ // size the sampled value out-shouted the face.
56
53
  accent: '#dc8432',
57
54
  mouthIn: '#191919',
58
55
  teeth: '#ffffff',
59
56
  tongue: '#8d7f79',
60
57
  };
61
58
 
62
- // Same native 760x950 art space as the siblings; window lifted like wren's
63
- // because the hair crowns high.
64
- const VB = { x: 92, y: 50, w: 576, h: 800 };
59
+ // --- reshape ------------------------------------------------------------
60
+ // Same move as peep, for the same reason: built next to the Canvas2D
61
+ // professional identities, this head read tall and narrow — 285 units wide in
62
+ // a 907-unit 4:3 frame — where the canvas avatars were calibrated at call-tile
63
+ // size from the start. One static, non-animated transform on the head cluster
64
+ // only (skull, hair, features — never neck or body), pivoted at CHIN_Y so the
65
+ // chin's own silhouette point does not move and the jaw-to-collar join needs
66
+ // no rework. Every landmark constant below still describes the original art
67
+ // space. See face-peep.js for the full derivation, including why "broader"
68
+ // here means a tighter 4:3 crop rather than a wider aspect.
69
+ const CX = 380;
70
+ const CHIN_Y = 572;
71
+ const RESHAPE_SX = 1.10;
72
+ const RESHAPE_SY = 0.88;
73
+ const RESHAPE = `translate(${CX} ${CHIN_Y}) scale(${RESHAPE_SX} ${RESHAPE_SY}) translate(${-CX} ${-CHIN_Y})`;
74
+
75
+ // The outer wave of hair, rather than HEAD_TOP under it, is the crown a viewer
76
+ // measures. The chin is the lowest point of the resting jaw silhouette.
77
+ // crownY is the reshaped crown: CHIN_Y + (96 − CHIN_Y) × RESHAPE_SY, worked out
78
+ // by hand because the camera reads landmarks, not the transform above it.
79
+ const FRAME = { centerX: CX, crownY: 153, chinY: CHIN_Y };
80
+ const VB = viewBoxForHead(FRAME);
65
81
 
66
82
  export const META = {
67
83
  viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
68
- mouthCrop: { x: 298, y: 436, w: 164, h: 100 },
84
+ // The original crop (298, 436, 164, 100) with its corners carried through
85
+ // RESHAPE — a camera change does not move a path, but the reshape is
86
+ // geometry rather than camera, so the inspection crop has to follow it.
87
+ mouthCrop: { x: 290, y: 452, w: 180, h: 88 },
69
88
  };
70
89
 
71
90
  // --- landmarks --------------------------------------------------------------
72
- const CX = 380;
91
+ // These are the NATIVE art space and RESHAPE never enters them: it is a
92
+ // wrapper on the rendered group, so `apply()` and every number below still
93
+ // mean what they meant before it existed.
73
94
  const HEAD_TOP = 150;
74
95
 
75
- const EYE = { y: 386, dx: 56, rx: 15, ry: 16 };
96
+ // rx widened from 15 — the bean was very nearly round, and cutting an almond
97
+ // aperture from it without an absurd lid line needed the same headroom peep's
98
+ // eye needed. See the eyes section.
99
+ const EYE = { y: 386, dx: 56, rx: 17, ry: 16 };
76
100
  const MOUTH = { cx: CX, cy: 492 };
77
101
  const MOUTH_APERTURE = 36;
78
102
 
79
- // Lenses: measured off the reference's fully visible left lens, converted by
80
- // face-height fraction (lens height ≈ 0.22 of hairline→chin; w:h ≈ 1.3;
81
- // corner radius ≈ 0.38 of lens height — a soft rect verging on superellipse).
82
- // Centred ON the eye line: the reference pupil sits at lens mid-height, and
83
- // its "just under the brows" top gap is 0.21 of lens height, which lands the
84
- // same way here. Lens centres sit 4 units outboard of the eyes so the bridge
85
- // keeps the reference's narrow 18-unit gap without moving the pupils.
86
- const LENS = { dx: 60, hw: 51, hh: 40, r: 30 };
87
-
88
- // Brows: bold, long and clearly arched, with real air between them and the
89
- // frames — the reference's brows are its strongest single mark. The rest
90
- // shape is a gentle taper-arc: v7's outer third dropped 17 units in 15 and
91
- // over a resting smile the kink composited into a smirk that curdled across
92
- // a long session. The kink is still available — as a browAngle POSE.
103
+ // Brows: bold, long and clearly arched, with real air between them — the
104
+ // reference's brows are its strongest single mark. The rest shape is a
105
+ // gentle taper-arc: v7's outer third dropped 17 units in 15 and over a
106
+ // resting smile the kink composited into a smirk that curdled across a long
107
+ // session. The kink is still available — as a browAngle POSE.
93
108
  const BROW_L = [[CX - 22, 340], [CX - 40, 327], [CX - 60, 320], [CX - 76, 319],
94
109
  [CX - 88, 322], [CX - 97, 327], [CX - 103, 332]];
95
110
  // Near-mirrored: the right arch rides 1 unit higher (personality, small
96
111
  // enough to survive the mirror-flip levelness check, which is judged on the
97
- // glasses and eye lines). Anything bigger read as a baked-in head tilt —
98
- // the mixer's headRoll adds on top of the art and nothing can subtract a
99
- // drawn lean.
112
+ // eye lines). Anything bigger read as a baked-in head tilt — the mixer's
113
+ // headRoll adds on top of the art and nothing can subtract a drawn lean.
100
114
  const BROW_R = [[CX + 22, 340], [CX + 40, 326], [CX + 60, 319], [CX + 76, 318],
101
115
  [CX + 88, 321], [CX + 97, 327], [CX + 103, 332]];
102
116
 
@@ -122,7 +136,11 @@ const HEAD = [
122
136
  ];
123
137
  // Lighter than wren's ring by ~20% — the reference line is confident but not
124
138
  // woodcut. Heavy low on the jaw, nothing at the crown (under hair).
125
- const HEAD_W = [3, 6, 9.5, 12, 13, 11.5, 9, 6, 4, 3];
139
+ //
140
+ // Scaled to ~0.65 of the original profile ([3, 6, 9.5, 12, 13, 11.5, 9, 6, 4,
141
+ // 3]) when RESHAPE landed: the same taper shape, but a line rather than a rope
142
+ // once the head is drawn broader and the frame tighter around it.
143
+ const HEAD_W = [2, 4, 6, 8, 8.5, 7.5, 6, 4, 2.5, 2];
126
144
 
127
145
  // Neck: the shared truncated-cone construction, slim; fused into the head
128
146
  // layer and overshot past every neckline for the standard parallax reasons.
@@ -229,44 +247,8 @@ function circlePts(cx, cy, r) {
229
247
  const HOOP_L = taperRing(circlePts(236, 488, 12), [4.5, 5, 4.5]);
230
248
  const HOOP_R = taperRing(circlePts(524, 488, 12), [4.5, 5, 4.5]);
231
249
 
232
- // ---------------------------------------------------------------------------
233
- // Static art: glasses — thin geometric rounded rectangles in ink.
234
- //
235
- // Wider than tall, corners genuinely rounded, thin ring. Temples run outward
236
- // and vanish under the hair's inner edge. Drawn after the eyes so the frame
237
- // passes over the bean at gaze extremes rather than under it.
238
- // ---------------------------------------------------------------------------
239
- function roundRectPts(cx, cy, hw, hh, r) {
240
- const k = r * K;
241
- const L = cx - hw, R = cx + hw, T = cy - hh, B = cy + hh;
242
- return [
243
- [R, cy],
244
- [R, cy + (hh - r) / 2], [R, B - r - k * 0.2], [R, B - r],
245
- [R, B - r + k], [R - r + k, B], [R - r, B],
246
- [R - r - (hw - r) / 2, B], [L + r + (hw - r) / 2, B], [L + r, B],
247
- [L + r - k, B], [L, B - r + k], [L, B - r],
248
- [L, B - r - k * 0.2], [L, cy + (hh - r) / 2], [L, cy],
249
- [L, cy - (hh - r) / 2], [L, T + r + k * 0.2], [L, T + r],
250
- [L, T + r - k], [L + r - k, T], [L + r, T],
251
- [L + r + (hw - r) / 2, T], [R - r - (hw - r) / 2, T], [R - r, T],
252
- [R - r + k, T], [R, T + r - k], [R, T + r],
253
- [R, T + r + k * 0.2], [R, cy - (hh - r) / 2], [R, cy],
254
- ];
255
- }
256
- const LENS_L = roundRectPts(CX - LENS.dx, EYE.y, LENS.hw, LENS.hh, LENS.r);
257
- const LENS_R = roundRectPts(CX + LENS.dx, EYE.y, LENS.hw, LENS.hh, LENS.r);
258
- const RING_W = [3, 3.4, 3, 3.4, 3];
259
- // Bridge at the upper third and temples off the top outer corners, sweeping
260
- // up toward the ears — both as drawn in the reference.
261
- const BRIDGE = [[CX - 9, 371], [CX - 3, 366], [CX + 3, 366], [CX + 9, 371]];
262
- // Attach on the top-outer corner arc (the rounded corner pulls the rim in to
263
- // ~x±108 at this height), sweeping up to vanish under the hair's inner edge.
264
- const TEMPLE_L = [[CX - 108, 364], [CX - 124, 357], [CX - 141, 352], [CX - 158, 350]];
265
- const TEMPLE_R = [[CX + 108, 364], [CX + 124, 357], [CX + 141, 352], [CX + 158, 350]];
266
-
267
- /** The nose: a tiny soft hook, tip only — the glasses own the bridge. It sits
268
- * lower than v6 (the reference's nose mark tops out at ~55% of eye→mouth,
269
- * not 39%), which is also what gives the taller lenses their clearance. */
250
+ /** The nose: a tiny soft hook, tip only. It sits lower than v6 (the
251
+ * reference's nose mark tops out at ~55% of eye→mouth, not 39%). */
270
252
  const NOSE_D = taper(
271
253
  [[CX + 3, 439], [CX - 2, 448], [CX - 6, 456], [CX - 4, 462],
272
254
  [CX + 1, 465], [CX + 7, 463], [CX + 10, 458]],
@@ -274,12 +256,13 @@ const NOSE_D = taper(
274
256
  );
275
257
 
276
258
  // ---------------------------------------------------------------------------
277
- // Static art: torso — white crew tee under an open orange overshirt.
259
+ // Static art: torso — a white crew tee under an open overshirt, both paper,
260
+ // distinguished only by ink outline.
278
261
  //
279
- // Three fills in order: the tee (paper, edge-to-edge under everything), then
280
- // the two overshirt panels (accent) whose inner edges make the open V. Both
281
- // panels keep the house two-run shoulder: outer arm edge, hard acromion turn,
282
- // near-horizontal trapezius shelf. Everything overshoots the frame.
262
+ // The tee is edge-to-edge under everything; the two overshirt panels sit on
263
+ // top, their inner edges making the open V. Both panels keep the house
264
+ // two-run shoulder: outer arm edge, hard acromion turn, near-horizontal
265
+ // trapezius shelf. Everything overshoots the frame.
283
266
  // ---------------------------------------------------------------------------
284
267
  const TEE = [
285
268
  [288, 960],
@@ -373,7 +356,7 @@ const POSE = {
373
356
  // parallax slides like a wig.
374
357
  yawPx: 26, pitchPx: 16,
375
358
  pivot: PIVOT,
376
- breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
359
+ breathSwell: 0.008, swellPivot: { x: CX, y: 950 },
377
360
  turnPx: 16,
378
361
  layers: LAYERS, parallax: PARALLAX,
379
362
  torsoLayers: ['body'],
@@ -428,8 +411,8 @@ function mouthGeometry(p) {
428
411
  // Corner travel 28 (was 23): the tile swallows less. Press straightens the
429
412
  // band by pulling the corner lift toward zero.
430
413
  const cLift = 1 - 0.85 * press;
431
- const yL = cy - (6 + p.mouthCornerL * 28) * cLift;
432
- const yR = cy - (6 + p.mouthCornerR * 28) * cLift;
414
+ const yL = cy - (1.5 + p.mouthCornerL * 28) * cLift;
415
+ const yR = cy - (1.5 + p.mouthCornerR * 28) * cLift;
433
416
 
434
417
  const apTop = cy - h * 0.25;
435
418
  let apBot = cy + h * 0.75;
@@ -480,8 +463,12 @@ function teethPath(m, amt, lower) {
480
463
  );
481
464
  }
482
465
 
483
- // Eyes: the bean model, near-round — the reference's pupils are big and dark
484
- // and do most of the face's expressive work with the lashes.
466
+ // Eyes: the lid-line-plus-iris model, ported from face-peep.js. It reads
467
+ // better against the "big and dark pupils" brief than the old whole-bean
468
+ // translation did, not worse — the reference's pupils get a real iris that
469
+ // carries the gaze while the lash and lid line hold still. See face-peep.js
470
+ // for the two wrong passes (paper-on-ink, then a too-small pupil that read as
471
+ // a startled target) that are not recoverable from the numbers alone.
485
472
 
486
473
  /** Top-lid height for a given lid value — shared by bean and lash so they can
487
474
  * never disagree. The mapping is a 0.6-power, not linear: the mixer's rest
@@ -495,26 +482,70 @@ function lidTopY(cy, lid) {
495
482
  return lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, Math.pow(clamp(lid), 0.6));
496
483
  }
497
484
 
498
- function eyePath(cx, cy, lid, squint, tiltDeg) {
499
- const L = clamp(lid);
500
- const topY = lidTopY(cy, lid);
501
- // Squint gain .95 (was .7): the smile-squint is most of what separates warm
502
- // from neutral at tile size.
503
- const botY = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.95;
504
- const rx = EYE.rx;
485
+ // A cubic whose two controls share a y reaches only 3/4 of the way to it.
486
+ // Everything below works in DRAWN extents and divides that back out, so an
487
+ // inset of n units is n units on screen — see face-peep.js for why the inset
488
+ // below cannot be proportional instead.
489
+ const BULGE = 0.75;
490
+
491
+ /** Two cubics between (cx±rx, cyMid), bulging to yTop and yBot; tilt about cy. */
492
+ function lensPath(cx, cy, g, tiltDeg) {
493
+ if (!g) return '';
505
494
  const a = (tiltDeg * Math.PI) / 180;
506
495
  const ca = Math.cos(a), sa = Math.sin(a);
507
496
  const R = (x, y) => {
508
497
  const dx = x - cx, dy = y - cy;
509
498
  return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
510
499
  };
500
+ const ctlTop = g.cyMid + (g.yTop - g.cyMid) / BULGE;
501
+ const ctlBot = g.cyMid + (g.yBot - g.cyMid) / BULGE;
511
502
  return (
512
- `M${R(cx - rx, cy)}` +
513
- `C${R(cx - rx * 0.5, topY)} ${R(cx + rx * 0.5, topY)} ${R(cx + rx, cy)}` +
514
- `C${R(cx + rx * 0.5, botY)} ${R(cx - rx * 0.5, botY)} ${R(cx - rx, cy)}Z`
503
+ `M${R(cx - g.rx, g.cyMid)}` +
504
+ `C${R(cx - g.rx * 0.5, ctlTop)} ${R(cx + g.rx * 0.5, ctlTop)} ${R(cx + g.rx, g.cyMid)}` +
505
+ `C${R(cx + g.rx * 0.5, ctlBot)} ${R(cx - g.rx * 0.5, ctlBot)} ${R(cx - g.rx, g.cyMid)}Z`
515
506
  );
516
507
  }
517
508
 
509
+ /** The lid silhouette, in drawn extents. Same power-curve lid mapping and
510
+ * squint gain (.95: the smile-squint is most of what separates warm from
511
+ * neutral at tile size) as before the eye was opened out. */
512
+ function eyeGeom(cy, lid, squint) {
513
+ const L = clamp(lid);
514
+ const ctlTop = lidTopY(cy, lid);
515
+ const ctlBot = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.95;
516
+ return {
517
+ cyMid: cy,
518
+ rx: EYE.rx,
519
+ yTop: cy + BULGE * (ctlTop - cy),
520
+ yBot: cy + BULGE * (ctlBot - cy),
521
+ };
522
+ }
523
+
524
+ // The lid line, as constant distances — a proportional inset inverts the
525
+ // moment a closing lid carries the bean's lower edge above the eye centre and
526
+ // leaks paper out through a shut eye.
527
+ const LASH_X = 3.0;
528
+ const LASH_TOP = 5.5;
529
+ const LASH_BOT = 4.5;
530
+
531
+ /** The paper almond inside the lid line, or null once the lid has shut on it. */
532
+ function apertureGeom(g) {
533
+ const yTop = g.yTop + LASH_TOP;
534
+ const yBot = g.yBot - LASH_BOT;
535
+ const rx = g.rx - LASH_X;
536
+ if (yBot - yTop < 0.5 || rx < 0.5) return null;
537
+ return { cyMid: (yTop + yBot) / 2, rx, yTop, yBot };
538
+ }
539
+
540
+ // Sized to fill the resting aperture's height rather than float inside it —
541
+ // this is what "big and dark pupils" means now that the eye has a real iris
542
+ // instead of a translating bean.
543
+ const IRIS_R = 8.0;
544
+
545
+ // Travel, native units: one corner's worth of paper. At either extreme one
546
+ // sliver closes and the other doubles, which is the whole gaze signal.
547
+ const IRIS_TRAVEL = { x: 4.5, y: 2.2 };
548
+
518
549
  /**
519
550
  * The upper lash line: lies ON the bean's top edge and ends in a small
520
551
  * outward flick at the outer corner. The body samples the bean's own top
@@ -547,12 +578,9 @@ function lashPath(cx, cy, lid, dir) {
547
578
  // - Raises land at 20 (v7: 16) — the minimal-face lesson says small deltas
548
579
  // vanish, and at a 130 px tile the emotion poses collapsed below this.
549
580
  // - Descent is capped at 8.5 and weighted toward the inner half (corrugator
550
- // pull). The cap is the frame-clearance envelope: the arch points that sit
551
- // over the lens's top bar (y≈319–322 at rest) must keep ≥3.5 units of air
552
- // above the bar's ink at browRaise −1, or the brow merges with the frame
553
- // and brows-down — concentration — silently dies. The inner weighting also
554
- // keeps the outer tail off the frame's corner arc. What travel can't carry,
555
- // thickness does: a knitted brow bulks up 30% at full descent.
581
+ // pull), so brows-down reads as concentration rather than a merged smear.
582
+ // What travel can't carry, thickness does: a knitted brow bulks up 30% at
583
+ // full descent.
556
584
  function browPath(pts, raise, angle, inner) {
557
585
  const n = pts.length - 1;
558
586
  const up = Math.max(0, raise);
@@ -579,59 +607,63 @@ function markup(id, t) {
579
607
  <svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
580
608
  preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
581
609
  <defs>
582
- <radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
583
- <stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
584
- </radialGradient>
585
610
  <clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
611
+ <clipPath id="${id}-clipEyeL"><path id="${id}-clipEyeLP" d=""/></clipPath>
612
+ <clipPath id="${id}-clipEyeR"><path id="${id}-clipEyeRP" d=""/></clipPath>
586
613
  </defs>
587
614
 
588
- <rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
589
-
590
615
  <!-- head and neck; hair underlay at head parallax is the anti-sliver
591
- insurance AND the backfill behind the hair-layer ears (see header). -->
616
+ insurance AND the backfill behind the hair-layer ears (see header).
617
+
618
+ RESHAPE wraps the art INSIDE the parallax group, never the group itself:
619
+ poseTransforms writes the group's own transform every frame and would
620
+ overwrite it. Same for features and hair below. The neck stays outside
621
+ it — the reshape is pivoted on the chin so that join still lands. -->
592
622
  <g id="${id}-head">
593
623
  <path d="${NECK_FILL}" fill="${t.paper}"/>
594
624
  ${ink(taper(NECK_L, [3, 7, 5]))}
595
625
  ${ink(taper(NECK_R, [3, 7, 5]))}
596
- <path d="${HEAD_FILL}" fill="${t.paper}"/>
597
- ${ink(HEAD_RING)}
598
- ${ink(taper(JAW_UNDER, [2, 5, 2]))}
599
- <path d="${HAIR_D}" fill="${t.ink}"/>
626
+ <g transform="${RESHAPE}">
627
+ <path d="${HEAD_FILL}" fill="${t.paper}"/>
628
+ ${ink(HEAD_RING)}
629
+ ${ink(taper(JAW_UNDER, [2, 5, 2]))}
630
+ <path d="${HAIR_D}" fill="${t.ink}"/>
631
+ </g>
600
632
  </g>
601
633
 
602
634
  <!-- tee, then the two overshirt panels and their tailoring -->
603
635
  <g id="${id}-body">
604
636
  ${paper(region(TEE))}
605
637
  ${ink(taper(TEE_NECK, [3, 5.5, 3]))}
606
- <path d="${region(PANEL_L)}" fill="${t.accent}"/>
607
- <path d="${region(PANEL_R)}" fill="${t.accent}"/>
638
+ <path d="${region(PANEL_L)}" fill="${t.paper}"/>
639
+ <path d="${region(PANEL_R)}" fill="${t.paper}"/>
608
640
  ${ink(taper(PANEL_L_OUT, PANEL_W))}
609
641
  ${ink(taper(PANEL_R_OUT, PANEL_W))}
610
642
  ${ink(taper(PANEL_L_IN, [6, 7, 5]))}
611
643
  ${ink(taper(PANEL_R_IN, [6, 7, 5]))}
612
- <path d="${region(COLLAR_L)}" fill="${t.accent}"/>
613
- <path d="${region(COLLAR_R)}" fill="${t.accent}"/>
644
+ <path d="${region(COLLAR_L)}" fill="${t.paper}"/>
645
+ <path d="${region(COLLAR_R)}" fill="${t.paper}"/>
614
646
  ${ink(taperRing(COLLAR_L, [3, 4.5, 3], 6))}
615
647
  ${ink(taperRing(COLLAR_R, [3, 4.5, 3], 6))}
616
648
  ${CREASES.map((c) => ink(taper(c.p, c.w))).join('\n ')}
617
649
  </g>
618
650
 
619
- <!-- features: brows, eyes+lashes, nose, glasses, mouth -->
651
+ <!-- features: brows, eyes+lashes, nose, mouth -->
620
652
  <g id="${id}-features">
653
+ <g transform="${RESHAPE}">
621
654
  <path id="${id}-browL" fill="${t.ink}"/>
622
655
  <path id="${id}-browR" fill="${t.ink}"/>
623
656
  <g id="${id}-eyes">
624
657
  <path id="${id}-eyeL" fill="${t.ink}"/>
625
658
  <path id="${id}-eyeR" fill="${t.ink}"/>
659
+ <path id="${id}-apertureL" fill="${t.paper}"/>
660
+ <path id="${id}-apertureR" fill="${t.paper}"/>
661
+ <g clip-path="url(#${id}-clipEyeL)"><circle id="${id}-irisL" fill="${t.ink}"/></g>
662
+ <g clip-path="url(#${id}-clipEyeR)"><circle id="${id}-irisR" fill="${t.ink}"/></g>
626
663
  <path id="${id}-lashL" fill="${t.ink}"/>
627
664
  <path id="${id}-lashR" fill="${t.ink}"/>
628
665
  </g>
629
666
  <path d="${NOSE_D}" fill="${t.ink}"/>
630
- ${ink(taperRing(LENS_L, RING_W, 6))}
631
- ${ink(taperRing(LENS_R, RING_W, 6))}
632
- ${ink(taper(BRIDGE, [3, 4, 3]))}
633
- ${ink(taper(TEMPLE_L, [3, 3.5, 4]))}
634
- ${ink(taper(TEMPLE_R, [3, 3.5, 4]))}
635
667
  <g id="${id}-mouth">
636
668
  <path id="${id}-mouthIn" fill="${t.mouthIn}"/>
637
669
  <g clip-path="url(#${id}-clipMouth)">
@@ -641,10 +673,12 @@ function markup(id, t) {
641
673
  </g>
642
674
  <path id="${id}-lips" fill="${t.ink}"/>
643
675
  </g>
676
+ </g>
644
677
  </g>
645
678
 
646
679
  <!-- hair: the mass, its highlights, then ears and hoops on top -->
647
680
  <g id="${id}-hair">
681
+ <g transform="${RESHAPE}">
648
682
  <path d="${HAIR_D}" fill="${t.ink}"/>
649
683
  ${SHINE.map((s) => paper(taper(s.p, s.w, 6))).join('\n ')}
650
684
  <path d="${region(EAR_L)}" fill="${t.paper}"/>
@@ -655,6 +689,7 @@ function markup(id, t) {
655
689
  ${ink(taper(EAR_R_IN, [2, 4, 2]))}
656
690
  <path d="${HOOP_L}" fill="${t.accent}"/>
657
691
  <path d="${HOOP_R}" fill="${t.accent}"/>
692
+ </g>
658
693
  </g>
659
694
  </svg>`;
660
695
  }
@@ -673,7 +708,10 @@ export function createFace(mount, theme = {}) {
673
708
  const el = {
674
709
  head: $('head'), body: $('body'), features: $('features'), hair: $('hair'),
675
710
  browL: $('browL'), browR: $('browR'),
676
- eyes: $('eyes'), eyeL: $('eyeL'), eyeR: $('eyeR'),
711
+ eyeL: $('eyeL'), eyeR: $('eyeR'),
712
+ clipEyeL: $('clipEyeLP'), clipEyeR: $('clipEyeRP'),
713
+ apertureL: $('apertureL'), apertureR: $('apertureR'),
714
+ irisL: $('irisL'), irisR: $('irisR'),
677
715
  lashL: $('lashL'), lashR: $('lashR'),
678
716
  mouthIn: $('mouthIn'), lips: $('lips'),
679
717
  clipMouth: $('clipMouthP'),
@@ -683,19 +721,35 @@ export function createFace(mount, theme = {}) {
683
721
  function apply(p) {
684
722
  poseTransforms(p, set, el, POSE);
685
723
 
686
- // Re-derived for the big lenses: outboard the bean edge at full travel is
687
- // still ~24 units clear of the rim (56+14+15 vs the 109-unit interior
688
- // edge), inboard ~16; vertically the 38-unit half-interior leaves ~15.
689
- // Past these the pupil starts reading pressed against the frame.
690
- set(el.eyes, 'transform', `translate(${f(p.pupilX * 14)} ${f(p.pupilY * 10)})`);
691
-
724
+ // The lid line holds still and the iris carries the gaze — see the eyes
725
+ // section. The lid follows vertical gaze downward only: looking up
726
+ // genuinely does widen the aperture, so there is nothing to add there.
692
727
  const lidFollow = Math.max(0, p.pupilY) * 0.22;
693
728
  const lidL = p.lidL + lidFollow;
694
729
  const lidR = p.lidR + lidFollow;
695
730
  // Both beans exactly on EYE.y: a ±1 stagger here read as head tilt at
696
731
  // rest, and rest must be level (see BROW_R).
697
- set(el.eyeL, 'd', eyePath(CX - EYE.dx, EYE.y, lidL, p.squintL, -3));
698
- set(el.eyeR, 'd', eyePath(CX + EYE.dx, EYE.y, lidR, p.squintR, 3));
732
+ const eye = (cx, cy, lid, squint, tilt, aperture, iris, clip) => {
733
+ const g = eyeGeom(cy, lid, squint);
734
+ const ap = apertureGeom(g);
735
+ const apD = lensPath(cx, cy, ap, tilt);
736
+ set(el[aperture], 'd', apD);
737
+ // The iris is clipped to the APERTURE, not to the lid line, so it is
738
+ // cropped by the same edge the paper is — a shut lid, which has no
739
+ // aperture at all, takes the iris with it and needs no opacity logic.
740
+ set(el[clip], 'd', apD);
741
+ set(el[iris], 'cx', f(cx + p.pupilX * IRIS_TRAVEL.x));
742
+ // Anchored to the aperture's midline rather than the eye's, so a
743
+ // half-closed eye keeps the iris centred in the slit instead of showing
744
+ // a band of paper above it.
745
+ set(el[iris], 'cy', f((ap ? ap.cyMid : cy) + p.pupilY * IRIS_TRAVEL.y));
746
+ set(el[iris], 'r', f(IRIS_R));
747
+ return lensPath(cx, cy, g, tilt);
748
+ };
749
+ set(el.eyeL, 'd', eye(CX - EYE.dx, EYE.y, lidL, p.squintL, -3,
750
+ 'apertureL', 'irisL', 'clipEyeL'));
751
+ set(el.eyeR, 'd', eye(CX + EYE.dx, EYE.y, lidR, p.squintR, 3,
752
+ 'apertureR', 'irisR', 'clipEyeR'));
699
753
  set(el.lashL, 'd', lashPath(CX - EYE.dx, EYE.y, lidL, -1));
700
754
  set(el.lashR, 'd', lashPath(CX + EYE.dx, EYE.y, lidR, 1));
701
755
 
@@ -723,3 +777,10 @@ export function createFace(mount, theme = {}) {
723
777
 
724
778
  return faceApi(mount, svg, apply, t);
725
779
  }
780
+
781
+ /**
782
+ * This face as a **Face** record — `{ create, meta }`, the shape
783
+ * `createAvatar({ face })` takes. Importing it costs this drawing and nothing
784
+ * else; `src/faces.js` is the all-three table, for tooling.
785
+ */
786
+ export const myna = { create: createFace, meta: META };