@voqalize/avatar 0.2.2 → 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
@@ -0,0 +1,722 @@
1
+ // ---------------------------------------------------------------------------
2
+ // author/parts/eye.mjs — the round idiom's eye (lids, iris stack, lashes,
3
+ // crease and BROW), as a PART, driven in the DRIVER's channel space.
4
+ //
5
+ // The part convention is written out in full at the top of ./mouth.mjs and in
6
+ // ./README.md; the one thing this part does differently is that it is drawn
7
+ // PER SIDE. `draws(c, L, side, env)` returns the eighteen draws of ONE eye, in
8
+ // paint order, with the side's `L`/`R` suffix already on every slot name — the
9
+ // avatar calls it twice, `for (const side of [-1, 1])`.
10
+ //
11
+ // CONSTRUCTION (build time, once per persona)
12
+ // makeEye({ P, PALETTE, solid, irisBase, lashWeight, browWeight, group })
13
+ // `irisBase`, `lashWeight` and `browWeight` are the three places the
14
+ // persona reaches into this geometry: the solved base iris paint (the one
15
+ // paint the 39-rung hue ladder swaps), the lash's mass, and the brow's
16
+ // half-thickness about its own centre line.
17
+ //
18
+ // DRIVING part.draws(c, L, side, env) -> ordered draws for that side
19
+ // REST eyeChannelRest() -> the `eye:`/`eyeL:`/`eyeR:` blocks of the
20
+ // control vector; `part.rest` is the same object.
21
+ // TABLES EYE_TABLE (the six eye states) and GAZE_TABLE (the four optional
22
+ // `gaze/*` poses) live here too, because a table written in channel
23
+ // units is a statement about what these channels DO and belongs
24
+ // next to the code that spends them.
25
+ //
26
+ // ---------------------------------------------------------------------------
27
+ // THE DRIVER BLOCK (phase 2). Voqalize's eye + brow channels, per side, plus
28
+ // two part-local extras this idiom cannot express without.
29
+ //
30
+ // channel rest range sign / meaning
31
+ // lid 0.12 0..1 0 = wide open, 1 = shut. Rest is 0.12 and
32
+ // that is not "nearly open": the lid must
33
+ // already GRAZE the iris at rest (below).
34
+ // squint 0.0 0..1 lower lid raised, independent of `lid`
35
+ // pupilX 0.0 -1..1 gaze, + = viewer's RIGHT (screen +x)
36
+ // pupilY 0.05 -1..1 gaze, + = DOWN. Rest is 0.05, a hair
37
+ // below level.
38
+ // browRaise 0.0 -1..1 whole brow up (+) / down (-)
39
+ // browAngle 0.0 -1.4..1.4 + = OUTER end up (voqalize's browAngle*)
40
+ // browInner 0.0 -1..1 + = INNER end up (AU1, worry)
41
+ //
42
+ // PART-LOCAL, no counterpart in the contract, kept because this idiom's
43
+ // closed eye is a drawn curve rather than a bean that shrinks:
44
+ // curveUp 0.0 bows the CLOSED lid line into a ^ or a v
45
+ // — what makes happy_closed an arch and not
46
+ // a slit. voqalize says that with squint +
47
+ // brow; we keep the mark.
48
+ // outerDroop 0.0 outer canthus falls (the sad eye).
49
+ //
50
+ // `c.eyeSize` / `c.eyeSpace` stay IDENTITY morphs on the outer vector
51
+ // (`morph/eyes_±100`, `morph/distance_±100`), not performance channels.
52
+ //
53
+ // SIDEDNESS, and how a patch is written. The contract is per side (lidL/lidR,
54
+ // squintL/squintR, browRaiseL/R…); writing every table entry twice would be a
55
+ // tax paid on the 95% of poses that are symmetric. So the vector carries THREE
56
+ // blocks and the part reads them through `eyeSide(c, side)`:
57
+ //
58
+ // eye: { … } both eyes
59
+ // eyeL: { … } overrides for the viewer's-left eye (side -1, slots …L)
60
+ // eyeR: { … } overrides for the viewer's-right eye (side +1, slots …R)
61
+ //
62
+ // ctrl({ eye: { lid: 0.12 } }) both lids graze
63
+ // ctrl({ eye: { lid: 0.12 }, eyeR: { lid: 1 } }) …and a wink on the right
64
+ //
65
+ // `makeCtrl`'s patch is a one-level Object.assign, so all three blocks merge
66
+ // per key exactly like `mouth:` does, and a symmetric state stays one line.
67
+ // GAZE is deliberately NOT mirrored: `pupilX` is screen space, so both eyes
68
+ // carry the same sign and the pair looks at one point rather than crossing.
69
+ //
70
+ // THE OLD UNITS, and where they went. The px this part was tuned in have not
71
+ // been thrown away — they are the constants below, which is what makes the map
72
+ // auditable rather than a re-tune:
73
+ //
74
+ // open 0..1.3 -> lid 1..0 through LID_TOP/LID_MEET/LID_GAMMA, chosen so
75
+ // lid = 0.12 lands the upper apex on the OLD rest px to the
76
+ // second decimal, and lid = 0 lands it within 1.3 px of the
77
+ // old `open: 1.30` wide.
78
+ // lowerRaise -> squint same 0..1 number, new name.
79
+ // browRaise px -> browRaise * BROW_PX.raise (18 px per unit)
80
+ // browInner px -> browInner * BROW_PX.inner (26 px per unit)
81
+ // browOuter px -> browAngle * BROW_PX.angle (11 px per unit)
82
+ //
83
+ // The brow's LANDMARKS (`bwI/bwM/bwO`) are still solved in the avatar's own
84
+ // `landmarks(c)` — that is where those three channels are actually spent,
85
+ // because brow height carries a little of the forehead mesh with it and the
86
+ // mesh is not the part's to own. `BROW_PX` is exported so the avatar spends
87
+ // them in this part's units.
88
+ // ---------------------------------------------------------------------------
89
+
90
+ import { spline, openSpline, arc, shift, circle, strip, band, bulge, contours, ring } from '../path.mjs';
91
+ import { clamp, lerp, drawPusher } from '../rig.mjs';
92
+
93
+ // --- the front door: every scale constant the channel map is made of --------
94
+
95
+ export const LID_REST = 0.12; // the contract's rest lid, and ours
96
+ export const PUPIL_Y_REST = 0.05; // …and its rest gaze, a hair below level
97
+
98
+ // The lid curve. `LID_TOP` is where the upper apex sits at lid = 0, in units of
99
+ // P.eyeTopH ABOVE the eye centre; `LID_MEET` is where the two lids touch at
100
+ // lid = 1, in units of P.eyeBotH BELOW it; `LID_GAMMA` bends the ride between
101
+ // them. Gamma is the whole of "a wide eye opens the top far more than a sleepy
102
+ // eye closes it": the aperture is steep at the open end, so lid 0.12 -> 0 lifts
103
+ // the upper apex 19.8 px while lid 0.12 -> 0.26 drops it 20.2 px, and the HALF
104
+ // state's aperture lands at 0.76 of rest against the reference sheet's 0.775.
105
+ //
106
+ // The one place this deliberately falls short of the reference is WIDE: the
107
+ // sheet's wide upper apex is 1.70x its open one, and 1.54 puts ours at 1.41x
108
+ // (113 px of aperture against 92). That is not a miss, it is this eye's own
109
+ // proportion — the reference eye is an almond whose OPEN aperture is 0.475 of
110
+ // its width, ours is a round one at 0.59, and taking a rounder eye out to 1.70
111
+ // puts the whole iris inside the opening with white all round it, which is a
112
+ // startle and not an alert. 1.54 also lands lid = 0 within 1.3 px of the widest
113
+ // px this rig ever drew (the old `open: 1.30`), so nothing that was tuned
114
+ // against the wide state has moved under it.
115
+ const LID_TOP = 1.54;
116
+ const LID_MEET = 0.60;
117
+ const LID_GAMMA = 1.9;
118
+
119
+ // The lower lid. It is parked at its open position until the eye is nearly
120
+ // shut (`LID_LO_KNEE`), because the upper lid does almost all of a blink; it
121
+ // takes `SQUINT_LIFT` of the eye's lower half at squint = 1; and it drops a
122
+ // LITTLE further, but only as the eye opens PAST rest, which is the reference
123
+ // sheet exactly: its WIDE cell puts the lower arc at 1.08x open while its HALF
124
+ // cell leaves it untouched. Hence the one-sided `max` at the call site rather
125
+ // than a symmetric term — a narrowing eye does not lift its lower lid, that is
126
+ // what `squint` is for.
127
+ const LID_LO_KNEE = 0.32;
128
+ const SQUINT_LIFT = 1.45;
129
+ const LID_LO_WIDEN = 0.73;
130
+
131
+ // `curveUp`, in px of lift at 1.0 — the part-local channel that bows the CLOSED
132
+ // lid line. It has to out-reach `LID_MEET`: the seam is parked 24 px BELOW the
133
+ // canthus line so a plain blink dips the way the reference sheet's CLOSED cell
134
+ // does, and a happy_closed eye has to climb back over that line to read as the
135
+ // ^ it is rather than as a sad bowl. 38 puts it 14 px above the eye centre and
136
+ // 20 above the inner canthus, which is an arch you can read at 1x; at 16 (what
137
+ // this was before the seam moved down) happy_closed and idle_closed were nearly
138
+ // the same shape and the smile lived entirely in the brow.
139
+ //
140
+ // It is spent LINEARLY, not scaled by `lid`, because a smiling OPEN eye bows a
141
+ // little too — which is why EYE_TABLE's `eyes-happy` asks for 0.12 rather than
142
+ // the 0.30 it used to: at 38 px a unit, 0.12 is the same 4.6 px of bow the old
143
+ // 0.30 bought at 16, and any more of it would leave a smiling eye's upper lid
144
+ // higher than a resting one's, which is not a smile. The lower lid takes 0.81
145
+ // of the same lift, the ratio the two numbers always had.
146
+ const CURVE_UP = 44;
147
+ const CURVE_UP_LO = 0.81;
148
+ // …and the two ENDS of that line have to come with it, or the bow is tilted
149
+ // rather than an arch. The inner canthus sits 10 px BELOW the outer one (`yi`
150
+ // = ey+6, `yo` = ey-4), and `curveUp` used to lift only the middle station, so
151
+ // `happy_closed`'s apex landed ~60% of the way to the outer corner and the pair
152
+ // read cheerful rather than `^_^`. `CURVE_UP_OUT` is exactly that 10 px offset,
153
+ // spent on the OUTER endpoint, so a full curveUp levels the two canthi and the
154
+ // arch is symmetric; a NEGATIVE curveUp (`sad_closed`, -0.40) drops the outer
155
+ // corner 4 px instead, which is the falling canthus that state wants anyway.
156
+ const CURVE_UP_OUT = 10;
157
+
158
+ // Gaze travel, as a fraction of the room the iris actually has.
159
+ //
160
+ // X is the sclera half-width, less the iris radius, LESS the canthus inset —
161
+ // and that last term is the one that had to be measured rather than assumed.
162
+ // The lid arcs run all the way out to `xi`/`xo`, but the SCLERA's two corner
163
+ // points are pulled inboard of them by 9 and 10 px (`cIn`/`cOut` below), which
164
+ // leaves a thin wedge at each corner that neither the sclera nor the two lid
165
+ // plates cover. Travel computed against `hw` alone drives the iris into that
166
+ // wedge and a blue spur appears OUTSIDE the eye — which is exactly what the
167
+ // first cut of this did at 4x. So the room is `hw - CANTHUS_INSET - ir`, and
168
+ // the 0.92 is what keeps the iris edge a whisker inside the corner rather than
169
+ // on it: 25.7 px at pupilX = ±1, an iris edge at 67.7 against a corner at 68.
170
+ //
171
+ // That is a smaller number than "hard against the canthus" and it is the right
172
+ // one: an iris whose rim touches the corner reads as an eye about to pop, and
173
+ // the caricature economy wants ±1 to be an extreme a driver can actually hold.
174
+ //
175
+ // Y is a fraction of the iris radius: there is no room under the lids for a
176
+ // whole-iris travel, and there should not be, since an eye that looks down
177
+ // tucks its iris UNDER the lower lid — the lid plates are painted over the
178
+ // stack, so that tuck costs nothing and is the whole read of a look-down.
179
+ const CANTHUS_INSET = 10;
180
+ const GAZE_X_K = 0.76;
181
+ // Y is split by DIRECTION, and that is not symmetry for its own sake. Nothing
182
+ // in `uMid`/`lMid` reads `pupilY`: the lid does not follow the gaze in the
183
+ // part, ON PURPOSE — voqalize's mixer owns that (it applies `lidBias =
184
+ // pupilY·0.34`), and baking it here too would double-apply it. So an unmixed
185
+ // `gaze/down` at -1 is an iris that has dropped under a lid that has not
186
+ // moved, which reads STARTLED — sclera above the iris. 0.36 down against 0.55
187
+ // up keeps the pose honest on its own and still leaves the mixer its half.
188
+ const GAZE_Y_K = { up: 0.55, down: 0.36 };
189
+
190
+ // Brow channels, in px per unit of channel. Spent by the avatar's landmark
191
+ // solver, not here; exported so there is one place to read them from.
192
+ export const BROW_PX = { raise: 24, inner: 26, angle: 11 };
193
+
194
+ // The same three channels, spent AGAIN and on something else: the brow's own
195
+ // SHAPE. `BROW_PX` above moves the three landmarks, which is a translation of
196
+ // the mark plus the forehead mesh it drags; a brow that only translates says
197
+ // almost nothing, because what a brow means is in its ARCH and its TILT. These
198
+ // are px of shape per unit of channel, on top of that travel:
199
+ //
200
+ // arch/archIn browRaise lifts the peak more than the head, so raising is a
201
+ // brow gaining curvature and lowering is one going flat — the
202
+ // difference between "interested" and "moved up 5 px".
203
+ // tilt browAngle rotates the whole mark about its middle station,
204
+ // outer end up and inner end DOWN. voqalize pivots the tail
205
+ // alone; the tail alone at this size is under 2 px at 1x.
206
+ // tip/cap browInner lifts the inner END, and lifts its rounded cap
207
+ // further still, so the head of the brow ROTATES up rather
208
+ // than sliding — which is the whole read of AU1 (worry).
209
+ // pinch …and draws it in toward the midline with it (the knot).
210
+ //
211
+ // They are shape, so they scale with `px` like every other length here.
212
+ const BROW_SHAPE = { arch: 9, archIn: 4, tilt: 9, tip: 9, cap: 14, pinch: 6 };
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // TWO IDIOMS, ONE PART. Everything above is round's eye; ink's is the same
216
+ // SOLVE — the same lid ride, the same gaze translation, the same brow arch —
217
+ // drawn with a different set of marks and an outline pen over them. So the
218
+ // three things that differ became three CONSTRUCTION bags, merged over these
219
+ // defaults, and the defaults are round's own numbers to the byte.
220
+ //
221
+ // px one scale for every constant below that is a DESIGN PIXEL rather
222
+ // than a ratio of a `P` dimension. ink's eye is 0.73 of round's
223
+ // (eyeTopH 32 against 44), and a 13 px lid fold on a 32 px lid is a
224
+ // different eye, not the same eye smaller. Ratios — LID_TOP,
225
+ // SQUINT_LIFT, the catchlight offsets — are already scale-free and
226
+ // are NOT touched by it.
227
+ // shape the lid/canthus geometry. Two of these are the whole difference
228
+ // between a round eye and an almond one: `meet` (where the lids
229
+ // touch — round's is 0.60 of the lower half, ink's 0.28, which is
230
+ // why ink's closed eye is a line near the centre and round's is a
231
+ // bowl) and `widest` (how far inboard of centre each lid arc peaks).
232
+ // marks WHICH of the layers this idiom draws, and how. A flat-fill eye
233
+ // models with translucent planes (`water`, `lidFold`, `squintShade`)
234
+ // where an outlined one models with LINES and would rather have the
235
+ // pen; the two never wanted the same list.
236
+ // catch the catchlight pair, as offsets and radii in units of the iris
237
+ // radius. ink's spark is half again as big and sits a little higher.
238
+ // pen the outline, or null for a style that has none. `{ paint, lid,
239
+ // crease, brow, lash, cap, join }` — a paint and a width per line.
240
+ // A draw in this runtime is stroked XOR filled, so every entry here
241
+ // is a draw of its own laid over the fills.
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export const EYE_SHAPE = {
245
+ canthus: [6, -4], // inner / outer canthus, px below the eye centre
246
+ meet: LID_MEET, // where the lids touch when shut, in P.eyeBotH
247
+ top: LID_TOP, // upper apex at lid = 0, in P.eyeTopH
248
+ gamma: LID_GAMMA,
249
+ widest: [0.04, 0.04], // arc peak, inboard(+)/outboard(-) as a share of hw
250
+ curveUp: CURVE_UP, curveUpLo: CURVE_UP_LO, curveUpOut: CURVE_UP_OUT,
251
+ over: 4, // how far each lid plate bites into the opening
252
+ bury: 3, // …and how far the sclera is buried under it
253
+ };
254
+
255
+ export const EYE_MARKS = {
256
+ shade: 'cover', // 'cover' = round's corner-and-crescent plane, drawn
257
+ // before the iris; 'crescent' = ink's cast shadow, hung
258
+ // off the lid arc and drawn AFTER the pupil; false = none
259
+ glow: true, // the iris's inner glow, offset away from the light
260
+ limbal: 'ring', // 'ring' = a filled evenodd annulus; 'stroke' = a pen
261
+ water: true, // the waterline inside the lower lid
262
+ squintShade: true, // the cheek roll under a raised lower lid
263
+ lidFold: true, // the upper lid's own thickness, as a plane
264
+ lash: 'bulge', // 'bulge', or a per-station height profile, in artboard units
265
+ lashLo: true, // two lower lash ticks at the outer corner
266
+ crease: 'band', // 'band' = a filled hairline; 'line' = a pen swept off the
267
+ // lid; an ARRAY = a pen at those per-station offsets
268
+ // (5 = the lid's inner stations), in artboard units; false
269
+ lidLine: false, // ink's lower-lid pen, faded out as the eye shuts
270
+ };
271
+
272
+ export const EYE_CATCH = { r: 0.22, at: [-0.42, -0.42], r2: 0.072, at2: [0.37, 0.37] };
273
+
274
+ // Persona-local finishing, deliberately separate from the driver's channels.
275
+ // These are quiet rest-shape choices: they soften a heavy brow head and keep
276
+ // the upper-lid plane from reading as a sticker at call-tile scale. Every
277
+ // default is the former construction, so a face that does not opt in is byte
278
+ // identical.
279
+ export const EYE_REFINE = {
280
+ lidFoldDepth: 13,
281
+ lidFoldAlpha: 1,
282
+ creaseDepth: 3.4,
283
+ brow: { head: 1, peak: 1, tail: 1 },
284
+ };
285
+
286
+ // The `eye:` / `eyeL:` / `eyeR:` blocks of the control vector, at rest. The six
287
+ // named eye states, the four gaze poses and the idle clips are patches on these
288
+ // nine numbers and nothing else.
289
+ export function eyeChannelRest() {
290
+ return {
291
+ eye: {
292
+ lid: LID_REST, squint: 0,
293
+ pupilX: 0, pupilY: PUPIL_Y_REST,
294
+ browRaise: 0, browAngle: 0, browInner: 0,
295
+ curveUp: 0, outerDroop: 0,
296
+ },
297
+ eyeL: {}, eyeR: {},
298
+ };
299
+ }
300
+
301
+ // One eye's channels: the shared block with that side's overrides on top.
302
+ export const eyeSide = (c, side) => ({ ...c.eye, ...(side < 0 ? c.eyeL : c.eyeR) });
303
+
304
+ // The eye block as `avatars/{facet,ink}` still hold it — today's px-and-ratio
305
+ // names, frozen. `author/rig.mjs` composes REST_CONTROLS out of this, so the
306
+ // two generators that are not parts yet keep the vector they were tuned
307
+ // against; `round` overrides the block with `eyeChannelRest()` on its own copy,
308
+ // which is what REST_CONTROLS' own comment says a generator that grows a
309
+ // channel should do. It goes when ink and facet become parts.
310
+ export function eyeRest() {
311
+ return {
312
+ open: 1, // 1 = neutral, 0 = shut, >1 = wide
313
+ lowerRaise: 0, // lower lid pushes up (squint / smile)
314
+ curveUp: 0, // bows the closed lid line into a ^ or a v
315
+ outerDroop: 0, // outer corner of the upper lid falls
316
+ browRaise: 0, browInner: 0, browOuter: 0,
317
+ };
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // The six eye states, in channel space. `cheekRaise` rides along because a
322
+ // Duchenne smile is not a squint alone — the cheek that raises the lower lid is
323
+ // the same cheek, and the driver reaches these states by NAME rather than by
324
+ // channel, so the state is allowed to know it. Everything MOUTH about a state
325
+ // stays in the avatar: a mouth is not the eye's to move.
326
+ //
327
+ // Read against the old table: `open: 0.86` became `lid: 0.17` through the curve
328
+ // above, `open: 1.30` became `lid: 0` (the contract's own wide), and both
329
+ // closed states became `lid: 1` — the old 0.02/0.03/0.04 were "as shut as that
330
+ // lerp got", and shut has a name now.
331
+ // ---------------------------------------------------------------------------
332
+ export const EYE_TABLE = {
333
+ 'eyes-idle_closed': { eye: { lid: 1, browRaise: -0.17 } },
334
+ 'eyes-idle_wide': { eye: { lid: 0, browRaise: 0.83 } },
335
+ 'eyes-happy': { eye: { lid: 0.17, squint: 0.44, curveUp: 0.12, browRaise: 0.67, browAngle: 0.55 }, cheekRaise: 1 },
336
+ 'eyes-happy_closed': { eye: { lid: 1, squint: 0.30, curveUp: 1.0, browRaise: 0.72, browAngle: 0.64 }, cheekRaise: 1 },
337
+ 'eyes-sad': { eye: { lid: 0.26, outerDroop: 0.9, browRaise: 0.06, browInner: 0.92, browAngle: -1.18 }, cheekRaise: -0.5 },
338
+ 'eyes-sad_closed': { eye: { lid: 1, curveUp: -0.40, outerDroop: 0.5, browRaise: 0.06, browInner: 0.92, browAngle: -1.18 }, cheekRaise: -0.5 },
339
+ };
340
+
341
+ // The four gaze poses, at the channel's own extremes, named for what a VIEWER
342
+ // sees: `gaze/left` is the eye looking towards the left of the screen, which is
343
+ // pupilX = -1 because + is the viewer's right. They are OPTIONAL vocabulary
344
+ // (src/vocab.js): a rig without them is complete, and a mixer reaches an
345
+ // arbitrary gaze by holding one of a pair at |pupilX| — the poses are linear in
346
+ // the channel by construction, since the iris stack is a pure translation.
347
+ export const GAZE_TABLE = {
348
+ 'gaze/left': { eye: { pupilX: -1 } },
349
+ 'gaze/right': { eye: { pupilX: 1 } },
350
+ 'gaze/up': { eye: { pupilY: -1 } },
351
+ 'gaze/down': { eye: { pupilY: 1 } },
352
+ };
353
+
354
+ // A point at fractional position t in [0,1] along a polyline. Used to hang the
355
+ // lower lashes off the lid arc so they ride every lid pose for free. It came
356
+ // over from the avatar with the eye: it has exactly one call site and that
357
+ // call site is here.
358
+ const onRun = (pts, t) => {
359
+ const u = clamp(t, 0, 1) * (pts.length - 1);
360
+ const i = Math.min(Math.floor(u), pts.length - 2), f = u - i;
361
+ return [lerp(pts[i][0], pts[i + 1][0], f), lerp(pts[i][1], pts[i + 1][1], f)];
362
+ };
363
+
364
+ // P keys read: eyeHalfW, eyeTopH, eyeBotH, irisR, pupilR.
365
+ // PALETTE keys read: sclera, scleraShade, eyeShade, carun, irisGlow, limbal,
366
+ // pupil, catch, catch2, water, face, lidFold, lash, crease,
367
+ // brow, browR.
368
+ // L keys read: eyeC{L,R}, bwI{L,R}, bwM{L,R}, bwO{L,R}.
369
+ // c keys read: eyeSize, eye/eyeL/eyeR.*.
370
+ export function makeEye({
371
+ P, PALETTE, solid, irisBase, lashWeight = 1, browWeight = 1, group = 'head',
372
+ px = 1, shape = {}, marks = {}, catch: catchOpt = {}, refine = {}, pen = null,
373
+ }) {
374
+ const HEAD = group;
375
+ const S = { ...EYE_SHAPE, ...shape };
376
+ const M = { ...EYE_MARKS, ...marks };
377
+ const C = { ...EYE_CATCH, ...catchOpt };
378
+ const R = { ...EYE_REFINE, ...refine, brow: { ...EYE_REFINE.brow, ...(refine.brow || {}) } };
379
+ // A stroke descriptor, or nothing. `rig.js` copies a stroke straight from
380
+ // base to out and never blends it, so a width is a constant of the draw and
381
+ // can never be a pose channel — which is why it is a construction input.
382
+ const nib = (w) => ({ w, cap: (pen && pen.cap) || 'round', join: (pen && pen.join) || 'round' });
383
+ const INK = pen ? solid(pen.paint) : null;
384
+
385
+ function draws(c, L, side, env = {}) {
386
+ const out = [];
387
+ const push = drawPusher(out);
388
+
389
+ // The eye opening's box. A pure function of `c.eyeSize` and the params, so
390
+ // computing it per side is the same three numbers the avatar used to hoist
391
+ // above the loop.
392
+ const hw = P.eyeHalfW * (1 + 0.20 * c.eyeSize);
393
+ const th = P.eyeTopH * (1 + 0.24 * c.eyeSize);
394
+ const bh = P.eyeBotH * (1 + 0.24 * c.eyeSize);
395
+
396
+ const k = side < 0 ? 'L' : 'R', s = side;
397
+ const [ex, ey] = L['eyeC' + k];
398
+ // The eye opening's two corners. Sclera, both lids and the lash line all
399
+ // pass through exactly these points, which is what stops white slivers
400
+ // leaking out of the corners when the lids move.
401
+ const xi = ex - s * hw, yi = ey + S.canthus[0]; // inner canthus (sits lower)
402
+ const xo = ex + s * hw, yo = ey + S.canthus[1]; // outer canthus (slightly up)
403
+
404
+ const e = eyeSide(c, side);
405
+ const lid = clamp(e.lid, 0, 1);
406
+ const squint = clamp(e.squint, -1, 1);
407
+
408
+ // --- lid -> px. The whole channel map, in four lines. -------------------
409
+ // `aperture` is the ride from shut (0) to wide (1); at lid = LID_REST it is
410
+ // 0.7838, which puts the upper apex on ey - th * 1.10 — the px this eye was
411
+ // drawn at — and that is the number the "graze" is measured from: the lash
412
+ // band's lower edge then lands 2.2 px INSIDE the top of the iris.
413
+ const meet = ey + bh * S.meet; // where the lids touch when shut
414
+ const aperture = Math.pow(1 - lid, S.gamma);
415
+ const uMid = lerp(meet, ey - th * S.top, aperture) - e.curveUp * S.curveUp
416
+ + e.outerDroop * 4 * px;
417
+ // The lower lid holds still through most of a blink and then closes late,
418
+ // takes the squint, and opens a little further than rest when the eye goes
419
+ // wide.
420
+ const loOpen = ey + bh * 1.10 + Math.max(LID_REST - lid, 0) * bh * LID_LO_WIDEN
421
+ - squint * bh * SQUINT_LIFT;
422
+ const lMid = lerp(meet, loOpen, clamp((1 - lid) / LID_LO_KNEE, 0, 1))
423
+ - e.curveUp * S.curveUp * S.curveUpLo;
424
+
425
+ // A round eye wants its widest point near the middle, not pushed inboard
426
+ // the way an almond eye does — which is exactly what `shape.widest` says.
427
+ // The outer endpoint carries two channels of its own: `outerDroop` falls
428
+ // (20 px at 1.0 — at 13 the sad eye's canthus barely read at 1x) and
429
+ // `curveUp` levels it against the inner canthus (see CURVE_UP_OUT).
430
+ const outY = yo + e.outerDroop * 20 * px + e.curveUp * S.curveUpOut;
431
+ const up = arc([xi, yi - e.outerDroop * 2 * px], [ex - s * hw * S.widest[0], uMid],
432
+ [xo, outY]);
433
+ const lo = arc([xi, yi - e.outerDroop * 2 * px], [ex + s * hw * S.widest[1], lMid],
434
+ [xo, outY]);
435
+ const OVER = S.over; // how far each lid bites in
436
+ const BURY = S.bury;
437
+
438
+ const sTop = shift(up, -BURY);
439
+ const sBot = shift(lo, BURY);
440
+ const cIn = [xi + s * 9 * px, yi], cOut = [xo - s * 10 * px, yo];
441
+ const opening = strip(sTop, sBot.slice(1, -1));
442
+ opening[0] = cIn;
443
+ opening[up.length - 1] = cOut;
444
+
445
+ push('sclera' + k, HEAD, spline(opening, 0.85),
446
+ solid(side < 0 ? PALETTE.sclera : PALETTE.scleraShade));
447
+
448
+ // --- the lid's cast shadow, and the corners ---------------------------
449
+ // Two idioms, one slot. `cover` is the flat-fill one: the opening's own top
450
+ // edge, with a lower edge that is raised most in the middle (`cover` -> 0.34
451
+ // of the sclera's local height) and not at all at the two canthi. One shape,
452
+ // therefore, is both the crescent under the upper lid and the darkening into
453
+ // the corners, and because both of its edges are built from the same arcs
454
+ // the sclera is, it cannot leak past the lids in any pose. It is painted
455
+ // BEFORE the iris, because it is the whole socket in shadow.
456
+ if (M.shade === 'cover') {
457
+ const shTop = opening.slice(0, sTop.length);
458
+ const shBot = shTop.map((p, i) => {
459
+ const t = i / (shTop.length - 1);
460
+ // 0.50 at the canthi, 0.32 under the middle of the lid. 1.0 buried the
461
+ // two slivers of sclera the (now larger) iris leaves and the eye read as
462
+ // dirty; 0.66 was still dark enough at both corners that the pair read
463
+ // as narrowed at 1x. 0.50 keeps the modelling without the squint.
464
+ const cover = 0.50 - 0.18 * Math.pow(Math.sin(Math.PI * t), 0.7);
465
+ return [lerp(p[0], sBot[i][0], cover), p[1] + (sBot[i][1] - p[1]) * cover];
466
+ });
467
+ push('eyeShade' + k, HEAD, band(shTop, shBot, 0.85), solid(PALETTE.eyeShade));
468
+ }
469
+
470
+ // caruncle: the pink of the tear duct, sitting in the darkened inner corner
471
+ push('carun' + k, HEAD, spline([
472
+ [cIn[0] + s * 1 * px, cIn[1] - 0.5 * px],
473
+ [cIn[0] + s * 7 * px, cIn[1] - 4.5 * px],
474
+ [cIn[0] + s * 7.5 * px, cIn[1] + 3.5 * px],
475
+ ], 0.7), solid(PALETTE.carun));
476
+
477
+ // --- the iris, five layers deep, and where it is LOOKING ---------------
478
+ // iris body (the ONE paint the hue ladder swaps) -> inner glow, offset
479
+ // down-right, away from the light -> limbal ring -> pupil -> a primary
480
+ // catchlight upper-left and a smaller, dimmer secondary lower-right.
481
+ //
482
+ // GAZE is one translation of the whole stack: `ix`/`iy` slide and every
483
+ // layer is built off them, so the eye keeps its modelling at every gaze and
484
+ // no layer needs a pose entry of its own. Two decisions in it:
485
+ //
486
+ // * The travel is clamped by CONSTRUCTION rather than by a clamp() — the
487
+ // scale is the room the iris has inside its own opening, measured to the
488
+ // SCLERA's corners rather than to the lid arcs' (see GAZE_X_K). The lid
489
+ // plates are painted over the stack, so the VERTICAL travel is allowed
490
+ // to exceed the aperture where the horizontal one is not.
491
+ // * The catchlights TRAVEL WITH THE IRIS, and their offsets inside it do
492
+ // not change. That is the compromise this style wants: physically a
493
+ // catchlight is a reflection in the cornea and barely moves, but a hard
494
+ // white dot left behind on the sclera reads as a blemish rather than as
495
+ // a light, while a dot that keeps its 10-o'clock offset within the iris
496
+ // still says "one light source, up and to the left" at every gaze. The
497
+ // primary is on the SAME side in both eyes (no `s` in its offset);
498
+ // mirroring it, which is what this rig used to do, is exactly what made
499
+ // the pair read as subtly cross-eyed close up.
500
+ const ir = P.irisR * (1 + 0.16 * c.eyeSize);
501
+ const gx = clamp(e.pupilX, -1, 1) * (hw - CANTHUS_INSET * px - ir) * GAZE_X_K;
502
+ const dy = clamp(e.pupilY, -1, 1) - PUPIL_Y_REST; // + is DOWN the screen
503
+ const gy = dy * ir * (dy > 0 ? GAZE_Y_K.down : GAZE_Y_K.up);
504
+ const ix = ex + s * 2 * px + gx, iy = ey + 2 * px + gy;
505
+ push('iris' + k, HEAD, spline(circle(ix, iy, ir, 12), 1), solid(irisBase));
506
+ if (M.glow) {
507
+ push('glow' + k, HEAD, spline(circle(ix + ir * 0.07, iy + ir * 0.10, ir * 0.60, 10), 1), solid(PALETTE.irisGlow));
508
+ }
509
+ // The limbal ring, either way round: a filled annulus for a style that
510
+ // models with fills, or — the research sheet's own note for an outlined one,
511
+ // "don't double up an outline and a separate ring fill, pick one" — a STROKE
512
+ // at the iris edge, its radius pulled in by half its own width so the ring
513
+ // sits ON the boundary rather than outside it.
514
+ if (M.limbal === 'stroke') {
515
+ const w = pen.limbal || pen.lid;
516
+ push('limb' + k, HEAD, spline(circle(ix, iy, ir - w / 2, 12), 1),
517
+ solid(PALETTE.limbal), 1, { stroke: nib(w) });
518
+ } else {
519
+ push('limb' + k, HEAD, ring(ix, iy, ir, ir - ir * 0.085, 12), solid(PALETTE.limbal), 1, { rule: 'evenodd' });
520
+ }
521
+ push('pupil' + k, HEAD, spline(circle(ix, iy, P.pupilR * (1 + 0.16 * c.eyeSize), 10), 1), solid(PALETTE.pupil));
522
+
523
+ // The other idiom's shadow: a crescent hung off the SAME arc the lid is, so
524
+ // it rides the blink for free and is swallowed by the lid plate when the
525
+ // arcs meet; deepest at the middle, zero at both canthi. It goes AFTER the
526
+ // iris, not before it — a shadow that stops dead at the limbus reads as a
527
+ // grey stripe painted on the white.
528
+ if (M.shade === 'crescent') {
529
+ push('eyeShade' + k, HEAD, band(
530
+ shift(up, 0.6 * px),
531
+ bulge(up, 9 * px, { floor: 0.6 * px, power: 0.6 }),
532
+ 0.85,
533
+ ), solid(PALETTE.eyeShade));
534
+ }
535
+
536
+ push('catch' + k, HEAD, spline(circle(ix + ir * C.at[0], iy + ir * C.at[1], ir * C.r, 7), 1), solid(PALETTE.catch));
537
+ push('catch2' + k, HEAD, spline(circle(ix + ir * C.at2[0], iy + ir * C.at2[1], ir * C.r2, 6), 1), solid(PALETTE.catch2));
538
+
539
+ // --- waterline ---------------------------------------------------------
540
+ // A soft line inside the lower lid, above the edge `lidLo` will paint over
541
+ // (`lo - OVER`, i.e. `sBot - 7`), so a 7px band at -16..-9 stays visible
542
+ // without floating free of the lid. It crosses the bottom of the iris,
543
+ // which is what the real lower lid does.
544
+ if (M.water) {
545
+ const wl = (dy2) => bulge(sBot, -dy2, { power: 0.5 });
546
+ push('water' + k, HEAD, band(wl(16.5 * px), wl(9.5 * px), 0.8), solid(PALETTE.water));
547
+ }
548
+
549
+ // Lid plates. In a faceted face these were planes in their own right; here
550
+ // they are the face tone, and everything above is painted BEFORE them, so a
551
+ // blink occludes the eyeball by overlap and not by a mask. They can be one
552
+ // flat colour on both sides only because the shading plane is translucent
553
+ // and painted after them.
554
+ const loRun = shift(lo, -OVER); // the lower lid's painted edge
555
+ push('lidLo' + k, HEAD, spline([
556
+ ...loRun,
557
+ [xo + s * 22 * px, ey + bh + 22 * px], [ex, ey + bh + 32 * px], [xi - s * 20 * px, ey + bh + 18 * px],
558
+ ], 0.85), solid(PALETTE.face));
559
+
560
+ // The squint's own mark. A raised lower lid is not just a higher arc: it is
561
+ // a roll of cheek pushed up under the eye, and without the shade under it
562
+ // the arc reads as the lid having been TRIMMED rather than lifted — which
563
+ // is exactly how a Duchenne smile fails. A crescent hung off the lower
564
+ // lid's painted edge, deepest a little outboard of centre, fading in with
565
+ // the channel. It sits on the cheek side of that edge, so it never touches
566
+ // the sclera whatever the lid is doing.
567
+ if (M.squintShade) {
568
+ push('squintSh' + k, HEAD, band(
569
+ bulge(loRun, 5 * px, { skew: 1.15 }),
570
+ bulge(loRun, 21 * px, { skew: 1.15, power: 0.8 }), 0.85,
571
+ ), solid(PALETTE.lidFold), clamp(squint * 1.35, 0, 1));
572
+ }
573
+
574
+ push('lidUp' + k, HEAD, spline([
575
+ ...shift(up, OVER),
576
+ [xo + s * 26 * px, ey - th - 34 * px], [ex, ey - th - 46 * px], [xi - s * 22 * px, ey - th - 30 * px],
577
+ ], 0.85), solid(PALETTE.face));
578
+
579
+ // The upper lid's THICKNESS. The plate above is flat face tone, so without
580
+ // this the lid line reads as a cut in the face rather than as an edge with
581
+ // a lid behind it: a 13 px band of lid plane, pinned to both canthi so it
582
+ // comes to a point where the lid does, sitting between the lash below it
583
+ // and the crease above. It rides `up`, so it is the same shape at every lid
584
+ // value and needs no pose logic of its own.
585
+ if (M.lidFold) {
586
+ push('lidFold' + k, HEAD, band(
587
+ bulge(up, -R.lidFoldDepth * px, { skew: 1.1, pin: true }), up, 0.85,
588
+ ), solid(PALETTE.lidFold), R.lidFoldAlpha);
589
+ }
590
+
591
+ // The lower lid LINE — an outlined style's answer to the waterline, and the
592
+ // one mark here whose alpha is not a channel but a consequence: both lid
593
+ // runs converge on the same arc when the eye closes, and two coincident
594
+ // strokes read as one lumpy heavier one. Width is not a pose channel, so
595
+ // fading it out is the only way to say "this line is not there".
596
+ if (M.lidLine && pen) {
597
+ push('lidLoInk' + k, HEAD, openSpline(lo, 0.85), INK,
598
+ clamp((aperture - 0.08) / 0.30, 0, 1), { stroke: nib(pen.lid) });
599
+ }
600
+
601
+ // crease: a hairline arc a little above the lash, the one bit of "line
602
+ // work" a flat-fill style allows itself above the eye; an outlined one has
603
+ // a pen for it and draws the same offset run with it, thinner and
604
+ // translucent so a crease never competes with the lash line.
605
+ // A pen crease can take its offsets from a table instead: a real fold is
606
+ // deepest over the outer third and converges at both canthi, and five
607
+ // authored numbers say that more exactly than any easing of a bulge does.
608
+ const cr = shift(up, -17 * px);
609
+ if (pen && (Array.isArray(M.crease) || M.crease === 'line')) {
610
+ const inner = up.slice(1, -1);
611
+ push('crease' + k, HEAD, openSpline(
612
+ Array.isArray(M.crease)
613
+ ? inner.map(([x, y], j) => [x, y - M.crease[j]])
614
+ : bulge(inner, -6 * px, { floor: -8 * px, skew: 1.4, power: 0.7 }),
615
+ 0.9,
616
+ ), INK, 0.58, { stroke: nib(pen.crease) });
617
+ }
618
+
619
+ // lash line: the upper arc thickened by a bump that tapers to nothing at
620
+ // both canthi, so it comes to a point rather than a stub, plus a small
621
+ // outward flick at the outer corner.
622
+ // The two ends are pinned to the canthi exactly. A lash that tapers to a
623
+ // constant +1.5px instead leaves a pair of free-floating dark whiskers
624
+ // just outside the eye corners — invisible in the source, obvious at 2x.
625
+ // The bulge peaks at t = 0.68 — `up` runs inner canthus -> outer, so that
626
+ // is the outer third the research puts the mass of a lash in, not the 0.43
627
+ // this used to peak at. The +2.2 floor is the lash line continuing inward
628
+ // as a thin dark lid line rather than disappearing.
629
+ // `lash.weight` scales the bump and its floor together, so a lighter lash
630
+ // is the same line drawn thinner rather than a shorter one.
631
+ //
632
+ // At lid = 1 this band IS the closed eye: both arcs sit on `meet`, the
633
+ // sclera between them has no height left, and what is on the face is one
634
+ // curve with a lash on it, bowed 20 px below the line between the two
635
+ // canthi. `LID_MEET` is what buys that bow — the reference sheet's CLOSED
636
+ // cell dips 0.17 of the eye's width, and a lid that meets on the canthus
637
+ // line reads as a drawn-on dash.
638
+ //
639
+ // An OUTLINED eye splits that in two: the lid line is a stroke (it is the
640
+ // whole of a closed eye and it has to be the same pen as everything else on
641
+ // the face), and the mass over it is a filled wedge whose profile is given
642
+ // per station — thick at the outer third, nothing at the inner canthus, and
643
+ // NOT pinned at the outer end, because that flick is the lash.
644
+ const lw = lashWeight;
645
+ if (pen && pen.lash) push('lashInk' + k, HEAD, openSpline(up, 0.85), INK, 1, { stroke: nib(pen.lash) });
646
+ if (Array.isArray(M.lash)) {
647
+ push('lash' + k, HEAD, band(
648
+ up.map(([x, y], i) => [x, y - M.lash[i] * lw]),
649
+ shift(up, (pen ? pen.lash : 0) * 0.42),
650
+ 0.9,
651
+ ), solid(PALETTE.lash));
652
+ } else {
653
+ const lashB = bulge(up, 12.5 * px * lw, { floor: 2.2 * px * lw, skew: 1.8, pin: true });
654
+ push('lash' + k, HEAD, band(up, lashB, 0.8), solid(PALETTE.lash));
655
+ }
656
+
657
+ // Two lower lash ticks at the outer corner and nowhere else — full lower
658
+ // lashes turn a friendly face into a doll's. They hang off `lo - OVER` —
659
+ // the edge `lidLo` actually paints to — not off the buried `sBot`, so they
660
+ // ride every lid pose. Anchored 3px below that edge the whole tick lands on
661
+ // skin and reads as a detached claw; straddling it, it reads as a lash.
662
+ // Their alpha follows the lid so a closed eye has no lashes standing under
663
+ // a shut lid.
664
+ if (M.lashLo) {
665
+ push('lashLo' + k, HEAD, contours(
666
+ ...[[0.74, 5.5], [0.88, 4.2]].map(([t, len]) => {
667
+ const q = onRun(loRun, t);
668
+ return spline([[q[0] - s * 1.7 * px, q[1] - 3 * px], [q[0] + s * 2.2 * px, q[1] + len * px * lw], [q[0] + s * 1.7 * px, q[1] - 3 * px]], 0.6);
669
+ }),
670
+ ), solid(PALETTE.lash), clamp((1 - lid) * 2 - 0.6, 0, 1) * 0.5 * lw);
671
+ }
672
+
673
+ if (M.crease === 'band') {
674
+ const crB = bulge(cr, R.creaseDepth * px, { pin: true });
675
+ push('crease' + k, HEAD, band(cr, crB, 0.8), solid(PALETTE.crease));
676
+ }
677
+
678
+ // --- brow: a soft tapered arch peaking at the outer third ---------------
679
+ // A CENTRE LINE plus a half-thickness, so `brow.weight` thickens the brow
680
+ // about its own axis and does not move its length, its tilt or where it
681
+ // sits: inner station centre +3 half 9, middle station centre -0.5 half
682
+ // 10.5. The outer tip and the inner cap lie ON the centre line and do not
683
+ // scale at all — which is what keeps a heavy brow a brow rather than a
684
+ // rectangle, since the taper at both ends is the shape's whole character.
685
+ // At weight 1 these are the six points that were hard-coded here.
686
+ const bw = browWeight;
687
+ const bI = L['bwI' + k], bM = L['bwM' + k], bO = L['bwO' + k];
688
+ // The shape half of the three brow channels (BROW_SHAPE). Every term is a
689
+ // product with a channel, so every one of them is EXACTLY zero at rest and
690
+ // the six points above are the six points that were here.
691
+ const B = BROW_SHAPE;
692
+ // `u` is -1 at the inner end and +1 at the outer, measured about the middle
693
+ // station in the face's own direction, which is what makes `tilt` a
694
+ // rotation rather than a shear that also moves the head of the brow.
695
+ const half = Math.max(1, Math.abs(bO[0] - bI[0]) / 2);
696
+ const tilt = (x) => e.browAngle * B.tilt * px * (((x - bM[0]) * s) / half);
697
+ const archM = e.browRaise * B.arch * px;
698
+ const archI = e.browRaise * B.archIn * px;
699
+ const tipI = e.browInner * B.tip * px;
700
+ const tipC = e.browInner * B.cap * px;
701
+ const pinch = e.browInner * B.pinch * px;
702
+ const tI = tilt(bI[0]), tM = tilt(bM[0]), tO = tilt(bO[0]);
703
+ const browPts = [
704
+ [bI[0] - s * 2 * px - s * pinch, bI[1] + 3 * px - 9 * px * bw * R.brow.head - tI - tipI + archI],
705
+ [bM[0], bM[1] - 0.5 * px - 10.5 * px * bw * R.brow.peak - tM - archM],
706
+ [bO[0] + s * 11 * px * R.brow.tail, bO[1] + 3 * px - tO],
707
+ [bM[0], bM[1] - 0.5 * px + 10.5 * px * bw * R.brow.peak - tM - archM],
708
+ [bI[0] - s * 2 * px - s * pinch, bI[1] + 3 * px + 9 * px * bw * R.brow.head - tI - tipI + archI],
709
+ // cap: rounds the inner head of the brow — and carries `browInner`'s
710
+ // rotation, being the point furthest from the pivot.
711
+ [bI[0] - s * 9 * px * R.brow.head - s * pinch, bI[1] + 3 * px - tI - tipC + archI],
712
+ ];
713
+ push('brow' + k, HEAD, spline(browPts, 0.9), solid(side < 0 ? PALETTE.brow : PALETTE.browR));
714
+ if (pen && pen.brow) {
715
+ push('browInk' + k, HEAD, spline(browPts, 0.9), INK, 1, { stroke: nib(pen.brow) });
716
+ }
717
+
718
+ return out;
719
+ }
720
+
721
+ return { rest: eyeChannelRest(), draws };
722
+ }