@voqalize/avatar 0.4.1 → 0.4.3

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 (203) hide show
  1. package/LICENSE-APACHE-2.0 +201 -0
  2. package/README.md +26 -95
  3. package/assets/README.md +30 -18
  4. package/assets/tanvi.glb +0 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tess.glb +0 -0
  8. package/assets/tushar.glb +0 -0
  9. package/client/internal.ts +24 -0
  10. package/client/supports.ts +6 -7
  11. package/client/three/assets.ts +7 -3
  12. package/client/three/budgets.ts +1 -1
  13. package/client/three/{tara-rig.ts → character-rig.ts} +682 -540
  14. package/client/three/createCharacter.ts +109 -0
  15. package/client/three/holds.ts +8 -3
  16. package/client/three/internal.ts +8 -7
  17. package/client/three/motion-limits.json +10 -2
  18. package/client/three/sequences.ts +5 -5
  19. package/client/three/tanvi-asset.ts +6 -0
  20. package/client/three/tanvi.ts +30 -0
  21. package/client/three/tanya.ts +18 -58
  22. package/client/three/tara.ts +19 -60
  23. package/client/three/tess-asset.ts +6 -0
  24. package/client/three/tess.ts +30 -0
  25. package/client/three/tushar.ts +18 -55
  26. package/dist/internal.d.ts +2 -2
  27. package/dist/internal.d.ts.map +1 -1
  28. package/dist/internal.js +16 -1
  29. package/dist/internal.js.map +1 -1
  30. package/dist/supports.d.ts +6 -7
  31. package/dist/supports.d.ts.map +1 -1
  32. package/dist/supports.js +6 -7
  33. package/dist/supports.js.map +1 -1
  34. package/dist/three/assets.d.ts +2 -0
  35. package/dist/three/assets.d.ts.map +1 -1
  36. package/dist/three/assets.js +7 -3
  37. package/dist/three/assets.js.map +1 -1
  38. package/dist/three/budgets.d.ts +1 -1
  39. package/dist/three/budgets.js +1 -1
  40. package/dist/three/character-rig.d.ts +366 -0
  41. package/dist/three/character-rig.d.ts.map +1 -0
  42. package/dist/three/{tara-rig.js → character-rig.js} +633 -526
  43. package/dist/three/character-rig.js.map +1 -0
  44. package/dist/three/createCharacter.d.ts +60 -0
  45. package/dist/three/createCharacter.d.ts.map +1 -0
  46. package/dist/three/createCharacter.js +84 -0
  47. package/dist/three/createCharacter.js.map +1 -0
  48. package/dist/three/holds.d.ts +7 -2
  49. package/dist/three/holds.d.ts.map +1 -1
  50. package/dist/three/holds.js +8 -3
  51. package/dist/three/holds.js.map +1 -1
  52. package/dist/three/internal.d.ts +7 -7
  53. package/dist/three/internal.d.ts.map +1 -1
  54. package/dist/three/internal.js +6 -6
  55. package/dist/three/internal.js.map +1 -1
  56. package/dist/three/motion-limits.json +10 -2
  57. package/dist/three/sequences.d.ts +5 -5
  58. package/dist/three/sequences.js +5 -5
  59. package/dist/three/tanvi-asset.d.ts +7 -0
  60. package/dist/three/tanvi-asset.d.ts.map +1 -0
  61. package/dist/three/tanvi-asset.js +7 -0
  62. package/dist/three/tanvi-asset.js.map +1 -0
  63. package/dist/three/tanvi.d.ts +24 -0
  64. package/dist/three/tanvi.d.ts.map +1 -0
  65. package/dist/three/tanvi.js +24 -0
  66. package/dist/three/tanvi.js.map +1 -0
  67. package/dist/three/tanya.d.ts +16 -28
  68. package/dist/three/tanya.d.ts.map +1 -1
  69. package/dist/three/tanya.js +15 -43
  70. package/dist/three/tanya.js.map +1 -1
  71. package/dist/three/tara.d.ts +17 -22
  72. package/dist/three/tara.d.ts.map +1 -1
  73. package/dist/three/tara.js +16 -45
  74. package/dist/three/tara.js.map +1 -1
  75. package/dist/three/tess-asset.d.ts +7 -0
  76. package/dist/three/tess-asset.d.ts.map +1 -0
  77. package/dist/three/tess-asset.js +7 -0
  78. package/dist/three/tess-asset.js.map +1 -0
  79. package/dist/three/tess.d.ts +24 -0
  80. package/dist/three/tess.d.ts.map +1 -0
  81. package/dist/three/tess.js +24 -0
  82. package/dist/three/tess.js.map +1 -0
  83. package/dist/three/tushar.d.ts +16 -25
  84. package/dist/three/tushar.d.ts.map +1 -1
  85. package/dist/three/tushar.js +15 -40
  86. package/dist/three/tushar.js.map +1 -1
  87. package/package.json +10 -49
  88. package/src/avatar.d.ts +67 -2
  89. package/src/avatar.js +131 -255
  90. package/src/faces.js +1 -1
  91. package/src/gaze.js +20 -47
  92. package/src/idle.js +69 -27
  93. package/src/prosody.js +109 -15
  94. package/src/rig.d.ts +7 -0
  95. package/src/speech-timing.js +9 -0
  96. package/src/visemes.js +107 -13
  97. package/client/arjun.ts +0 -26
  98. package/client/createCanvasAvatar.ts +0 -72
  99. package/client/interviewer-female.ts +0 -4
  100. package/client/interviewer-male.ts +0 -4
  101. package/client/ishita.ts +0 -26
  102. package/client/kabir.ts +0 -26
  103. package/client/meera.ts +0 -26
  104. package/client/naina.ts +0 -26
  105. package/client/professional-female-a.ts +0 -4
  106. package/client/professional-female-b.ts +0 -4
  107. package/client/professional-male-a.ts +0 -4
  108. package/client/professional-male-b.ts +0 -4
  109. package/client/vikram.ts +0 -26
  110. package/dist/arjun.d.ts +0 -7
  111. package/dist/arjun.d.ts.map +0 -1
  112. package/dist/arjun.js +0 -20
  113. package/dist/arjun.js.map +0 -1
  114. package/dist/createCanvasAvatar.d.ts +0 -22
  115. package/dist/createCanvasAvatar.d.ts.map +0 -1
  116. package/dist/createCanvasAvatar.js +0 -47
  117. package/dist/createCanvasAvatar.js.map +0 -1
  118. package/dist/interviewer-female.d.ts +0 -4
  119. package/dist/interviewer-female.d.ts.map +0 -1
  120. package/dist/interviewer-female.js +0 -3
  121. package/dist/interviewer-female.js.map +0 -1
  122. package/dist/interviewer-male.d.ts +0 -4
  123. package/dist/interviewer-male.d.ts.map +0 -1
  124. package/dist/interviewer-male.js +0 -3
  125. package/dist/interviewer-male.js.map +0 -1
  126. package/dist/ishita.d.ts +0 -7
  127. package/dist/ishita.d.ts.map +0 -1
  128. package/dist/ishita.js +0 -20
  129. package/dist/ishita.js.map +0 -1
  130. package/dist/kabir.d.ts +0 -7
  131. package/dist/kabir.d.ts.map +0 -1
  132. package/dist/kabir.js +0 -20
  133. package/dist/kabir.js.map +0 -1
  134. package/dist/meera.d.ts +0 -7
  135. package/dist/meera.d.ts.map +0 -1
  136. package/dist/meera.js +0 -20
  137. package/dist/meera.js.map +0 -1
  138. package/dist/naina.d.ts +0 -7
  139. package/dist/naina.d.ts.map +0 -1
  140. package/dist/naina.js +0 -20
  141. package/dist/naina.js.map +0 -1
  142. package/dist/professional-female-a.d.ts +0 -4
  143. package/dist/professional-female-a.d.ts.map +0 -1
  144. package/dist/professional-female-a.js +0 -3
  145. package/dist/professional-female-a.js.map +0 -1
  146. package/dist/professional-female-b.d.ts +0 -4
  147. package/dist/professional-female-b.d.ts.map +0 -1
  148. package/dist/professional-female-b.js +0 -3
  149. package/dist/professional-female-b.js.map +0 -1
  150. package/dist/professional-male-a.d.ts +0 -4
  151. package/dist/professional-male-a.d.ts.map +0 -1
  152. package/dist/professional-male-a.js +0 -3
  153. package/dist/professional-male-a.js.map +0 -1
  154. package/dist/professional-male-b.d.ts +0 -4
  155. package/dist/professional-male-b.d.ts.map +0 -1
  156. package/dist/professional-male-b.js +0 -3
  157. package/dist/professional-male-b.js.map +0 -1
  158. package/dist/three/tara-rig.d.ts +0 -409
  159. package/dist/three/tara-rig.d.ts.map +0 -1
  160. package/dist/three/tara-rig.js.map +0 -1
  161. package/dist/vikram.d.ts +0 -7
  162. package/dist/vikram.d.ts.map +0 -1
  163. package/dist/vikram.js +0 -20
  164. package/dist/vikram.js.map +0 -1
  165. package/src/canvas/author/parts/eye.mjs +0 -722
  166. package/src/canvas/author/parts/hand.mjs +0 -1156
  167. package/src/canvas/author/parts/mouth.mjs +0 -741
  168. package/src/canvas/author/parts/nose.mjs +0 -100
  169. package/src/canvas/author/parts/skin-detail.mjs +0 -67
  170. package/src/canvas/author/path.mjs +0 -283
  171. package/src/canvas/author/rig.mjs +0 -405
  172. package/src/canvas/avatars/round/face.d.mts +0 -3
  173. package/src/canvas/avatars/round/face.mjs +0 -1307
  174. package/src/canvas/create-rig.d.ts +0 -15
  175. package/src/canvas/create-rig.js +0 -100
  176. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  177. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  178. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  179. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  180. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  181. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  182. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  183. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  184. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  185. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  186. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  187. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  188. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  189. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  190. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  191. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  192. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  193. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  194. package/src/canvas/data/interviewer-female.rig.json +0 -1
  195. package/src/canvas/data/interviewer-male.rig.json +0 -1
  196. package/src/canvas/data/professional-female-a.rig.json +0 -1
  197. package/src/canvas/data/professional-female-b.rig.json +0 -1
  198. package/src/canvas/data/professional-male-a.rig.json +0 -1
  199. package/src/canvas/data/professional-male-b.rig.json +0 -1
  200. package/src/canvas/src/live.js +0 -508
  201. package/src/canvas/src/render2d.js +0 -218
  202. package/src/canvas/src/rig.js +0 -297
  203. package/src/canvas/src/vocab.js +0 -96
@@ -1,1307 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // avatars/round/face.mjs — this character, as a PURE function of a control
3
- // vector. No filesystem, no poses, no serialiser: the parameters, the persona
4
- // and its palette, the landmark solve, the shape tables, the kit and
5
- // `buildDraws(c, K)`, which is the whole of what round LOOKS like.
6
- //
7
- // It split off `build.mjs` for one reason: `src/live.js` evaluates this file in
8
- // the BROWSER, once per frame, to draw the face from voqalize's 30 channels
9
- // directly — the linearity spike's option (d), because the mouth's channel
10
- // coupling is far too strong for baked poses to reconstruct (a 6 px @1x worst
11
- // vertex and seven alpha ramps categorically wrong; see NOTES.md). So nothing
12
- // in this file, or in anything it imports, may touch a `node:` builtin — which
13
- // is why `writeRig` moved out of `author/rig.mjs` into `author/finish.mjs`.
14
- // `build.mjs` imports this and adds everything that is authoring: the pose
15
- // harness, the tracks, the camera's metadata, the wardrobe, the writer.
16
- //
17
- // The split is a MOVE and nothing else. Every one of the five round rigs is
18
- // byte-identical across it.
19
- // ---------------------------------------------------------------------------
20
-
21
- import { spline, polygon, sampleRun, inward, circle, band } from '../../author/path.mjs';
22
- import {
23
- clamp, lerp, hsl,
24
- paintRegistry, makeCtrl, applyWeights, solveIrisBase, REST_CONTROLS, drawPusher,
25
- } from '../../author/rig.mjs';
26
- import { makeMouth, mouthRestChannels } from '../../author/parts/mouth.mjs';
27
- import { makeEye, eyeChannelRest, eyeSide, BROW_PX, EYE_TABLE } from '../../author/parts/eye.mjs';
28
- import { makeNose } from '../../author/parts/nose.mjs';
29
- import { makeSkinDetail } from '../../author/parts/skin-detail.mjs';
30
- import { makeHand, handRest, handFrameOf } from '../../author/parts/hand.mjs';
31
- import { viewBoxForHead } from '../../../camera.js';
32
-
33
- // ===========================================================================
34
- // 1. PARAMETERS — the whole character's proportions live here.
35
- // ===========================================================================
36
-
37
- export const P = {
38
- artboard: { w: 1080, h: 1625 },
39
- cx: 540,
40
-
41
- // Round/heart head: y 246..890 (644 tall), widest at the cheeks (±296 -> 592
42
- // across) for a w:h of 0.92, narrowing to a soft chin ~38% of the cheek
43
- // width. The opposite of facet's square jaw and rounder than the mascot's
44
- // oval.
45
- headTop: 246,
46
- chinY: 890,
47
-
48
- // Adult proportions (2026-08): the eye line sits at half the head, the eye
49
- // is ~21% of the face width and the lid opening is squarer, per the
50
- // late-twenties reference bust in parts/ref/portrait. The skull itself
51
- // (headTop/chinY) is untouched so the fringe and the hair plates still fit.
52
- browY: 525, // brow centreline, mean of bwI/bwM/bwO
53
- eyeY: 578, // eye centreline, 53% of head height (was 606 / 56%)
54
- noseBaseY: 694,
55
- mouthY: 768,
56
-
57
- eyeGapHalf: 74, // half the inter-canthal gap (1.12 eye-widths apart)
58
- eyeHalfW: 66, // 20.7% of the cheek width; the reference reads 21-22%
59
- eyeTopH: 34,
60
- eyeBotH: 30, // opening h:w 0.48 — an adult lid, not a cute circle
61
- // Iris 0.52 of the eye opening's width — the canon is 0.55-0.65 and a cute
62
- // style pushes higher still, but the adult eye is narrower, so the iris
63
- // comes down with it rather than crowding the lids.
64
- irisR: 34,
65
- pupilR: 13, // 0.38 of the iris diameter: mid dilation
66
-
67
- mouthHalfW: 100,
68
- lipUpTh: 15,
69
- lipLowTh: 24,
70
- restLipGap: 2,
71
-
72
- jawDrop: 34, // how far the chin travels at jaw = 1
73
-
74
- scale: 1.0, // whole-figure zoom, anchored at the crown
75
- shadowInset: 9, // how far the side plane sits inside the silhouette
76
- neckPivot: [540, 1180], // head rotates about the base of the neck
77
- };
78
-
79
- // Flat-vector shading is planes, not a ramp: five skin values exist and only
80
- // three of them are large areas (face, side plane, neck).
81
- const RAMP = 5;
82
-
83
- // ===========================================================================
84
- // 2. COLOUR — THE PERSONA
85
- //
86
- // A persona is everything this face is made of that is not geometry: a skin
87
- // tone, an iris, a pair of lips, a brow. It arrives as a plain blob with
88
- // every key optional, and `makeSpec(persona)` (§10) turns one into a whole
89
- // spec — so a variant of this character is a PALETTE plus a wardrobe
90
- // sidecar, and the sidecar carries both: `author/finish.mjs` hands the
91
- // sidecar's `persona` block straight to the factory without looking inside
92
- // it. Nothing in the library knows what `skin` means, and nothing here
93
- // knows what a blazer is.
94
- //
95
- // The vocabulary is ../ink's, key for key, wherever the two styles mean the
96
- // same thing — `skin`, `lips.{up,low}`, `iris.{hue,saturation,brightness,
97
- // sat,light,eye}`, `brow.{weight,colour}`, `lash.{weight}`. `blush` is the
98
- // one key that is this style's alone, because the rouge is this style's
99
- // alone. NOTES.md keeps the shared / round-only split, and the argument for
100
- // not lifting any of it into the library yet.
101
- //
102
- // skin: [h, s, l] the LIT plane — the TOP rung of the five-plane
103
- // ramp, and the only skin number there is.
104
- // lips: { up, low } [h, s, l] each; the seam and the corner pockets are
105
- // derived off `up`, the two highlights are white and
106
- // are not.
107
- // iris: { hue, saturation, brightness, the driver dial this face boots on
108
- // sat, light, the 39-rung ladder's s and l
109
- // eye } [r,g,b] on screen AT that dial
110
- // brow: { weight, colour } thickness about the brow's own centre line,
111
- // and the tone the lash is derived from
112
- // lash: { weight } the upper lash's mass and the lower ticks
113
- // eye: { aperture } vertical opening only; 1 is the family
114
- // geometry, below 1 narrows without changing
115
- // the adult eye-width ratio
116
- // { irisScale, finish } optional editorial eye proportions and
117
- // softer material contrast
118
- // { refine } optional brow and upper-lid finishing;
119
- // construction-only, never a driver channel
120
- // mouth: { philtrum } optional quiet neutral-mouth plane
121
- // form: { socket, socketDepth, sideShadeAlpha, sideShadeWidth } optional
122
- // static head-attached form;
123
- // it adds no performance channel
124
- // blush: number how much of the cheek rouge survives — 1 is
125
- // the generator's own, 0 turns it off
126
- // sex: 'f' | 'm' which rest geometry the rig is BUILT at and
127
- // which paint conventions apply (§3, SEX_GEO)
128
- // geo: { channel: n } per-channel override of that rest patch
129
- //
130
- // Every default below is the number this file has always written, so
131
- // `makeSpec()` — no persona — rebuilds `data/round.rig.json` byte for byte.
132
- // ===========================================================================
133
-
134
- export const DEFAULT_PERSONA = {
135
- // 'f' or 'm' — see SEX_GEO / sexPaint below §3. The default is the look this
136
- // face has always had, so `makeSpec()` with no persona is unchanged.
137
- sex: 'f',
138
- // A light warm peach: the top of the ramp, rgb(224,201,184) — clearly
139
- // lighter and pinker than the mascot's rgb(208,150,125) and nowhere near
140
- // facet's olive rgb(178,113,79). It used to be rung 4 of a hard-coded
141
- // `skin(i)`; the rung is now the parameter and the ramp is derived from it.
142
- skin: [26, 0.40, 0.80],
143
- // Upper lip 12% darker in L than the lower: it tilts away from the light and
144
- // carries more pigment, and the delta is what stops a two-shape mouth reading
145
- // as one flat sticker.
146
- // Toned down for the adult read (2026-08): 0.42/0.45 was a made-up mouth on
147
- // a young face. 0.30/0.32 is still a lip and not a lipstick.
148
- lips: { up: [358, 0.30, 0.56], low: [2, 0.32, 0.64] },
149
- // hue/saturation/brightness are the driver's own boot state (src/vocab.js,
150
- // DRIVER_DEFAULTS) — the dial this face wants to be found at, written to
151
- // `meta.iris` for a player to start on. sat/light are the LADDER's, i.e.
152
- // what the eye does as somebody drags that dial: the whole excursion across
153
- // the 39 rungs is `ladder(h) - ladder(hue)`, so a dark ladder is what keeps
154
- // a dark eye dark at every hue and a bright one cannot be made to.
155
- // `eye` is the warm hazel this face has always rested on.
156
- iris: { hue: 200, saturation: 0.15, brightness: 0.5, sat: 0.45, light: 0.42, eye: [138, 105, 72] },
157
- brow: { weight: 1, colour: [14, 0.44, 0.27] },
158
- lash: { weight: 1 },
159
- // Half the rouge the cute face wore. A professional in her late twenties has
160
- // cheeks, not a doll's blush spots.
161
- blush: 0.5,
162
- };
163
-
164
- // Auburn bob: three values, back / front / highlight.
165
- const hair = (i) => hsl(lerp(12, 26, i / 2), 0.50 - 0.04 * i, 0.20 + 0.07 * i);
166
- // Muted sage top, two values.
167
- const shirt = (i) => hsl(lerp(148, 154, i), 0.22 - 0.02 * i, 0.33 + 0.07 * i);
168
-
169
- // The tone every offset below was authored against. Hue and saturation travel
170
- // as OFFSETS off the persona's skin; lightness travels as a RATIO of it, which
171
- // is this file's whole answer to the ink lesson (../ink/NOTES.md): a step in
172
- // lightness that is a constant is most of the light a deep brown has left, so
173
- // the jaw plane turns into a hole. A ratio scales itself, and — because the
174
- // side plane here is a translucent overlay rather than an opaque tone — it is
175
- // also what keeps `alpha * (shade - face)` proportional to the face, i.e. the
176
- // plane darkens the same FRACTION on every skin instead of vanishing on the
177
- // dark ones.
178
- const L_REF = DEFAULT_PERSONA.skin[2];
179
-
180
- // ---------------------------------------------------------------------------
181
- // The palette, from a persona.
182
- //
183
- // DERIVED FROM `skin`:
184
- // * the five-plane ramp itself — `face` (rung 4, the persona's own tone),
185
- // `ear` 3.5, `earR` / `neck` 3.0, `nose` 2.9, `earIn` 2.7, `earInR` 2.4.
186
- // Hue walks 22 degrees redder down the ramp and saturation peaks in the
187
- // middle of it, both exactly as before; the LIGHTNESS of every rung is a
188
- // RATIO of the persona's, so rung 3.0 is 89% of the lit plane on a peach
189
- // and 89% of it on a deep brown, and the neck reads as the same lighting
190
- // on both instead of as a hole on the second.
191
- // * `shade` / `neckSh`, the translucent side plane and the chin's cast
192
- // shadow — a much darker, more saturated version of the same skin
193
- // (hue -16, sat +0.15, lightness x 0.425), which at the default is exactly
194
- // the hsl(10, 0.55, 0.34) this file has always written.
195
- // * `crease` and `water`, both skin marks and therefore both wrong the
196
- // moment they are constants: a 0.55-lightness crease on a 0.50-lightness
197
- // skin is a LIGHT line above the eye.
198
- // * `carun`, the tear duct, and `blush`, the cheek — the same skin pushed
199
- // pink; `blush`'s alpha is then scaled by `persona.blush`.
200
- // DERIVED FROM `lips.up`: `seam` and `commiss`, the lip line and the corner
201
- // pockets — the upper lip at 45% / 52% of its lightness. A lip seam is the
202
- // shadow between the lips, so it has to follow the lips and not the style.
203
- // DERIVED FROM `brow.colour`: `brow`, `browR` (the shadow-side brow, 0.05
204
- // darker) and `lash` — one hair colour for everything above the eye.
205
- // NOT DERIVED, deliberately:
206
- // * `sclera`, `scleraShade`, `pupil`, `teeth`, `mouthIn`, `tongue` — an eye
207
- // white and an enamel are MATERIALS, the same on everybody; what has to be
208
- // checked on a dark skin is their contrast, not their value.
209
- // * every translucent overlay whose job is light rather than pigment —
210
- // `eyeShade`, `limbal`, `irisGlow`, `catch`, `catch2`, `lipHi`, `lipBow`,
211
- // `toothSep`, `toothSh`, `hairHi`. author/README.md's overlay convention:
212
- // an overlay is an ink or a white AT AN ALPHA, never a colour derived from
213
- // what it sits on, because the thing underneath is repainted 39 times by
214
- // the hue ladder.
215
- // * `hairBack` / `hairFront` / `shirt` / `collar` — the bald rig's own
216
- // character, and every persona variant covers all four with a bitmap.
217
- // ---------------------------------------------------------------------------
218
-
219
- function makePalette(p) {
220
- const [H, S, L] = p.skin;
221
- const eyeFinish = p.eye?.finish || {};
222
-
223
- // Skin: five planes off one tone. Hue walks 22 degrees redder down the ramp
224
- // and saturation peaks in the middle of it, both exactly as before; the
225
- // LIGHTNESS is a ratio of the persona's, which is the whole of the ink
226
- // lesson (../ink/NOTES.md) and is why `lerp(0.46, 0.80, t)` is still written
227
- // out — those are the five rungs of the tone this ramp was tuned at, and
228
- // every other skin gets them as a proportion. A flat -0.085 of lightness for
229
- // the neck is 11% of a pale peach and 16% of a deep brown, and the 5% is
230
- // exactly the difference between "the light is off it" and "it is a
231
- // different material". The saturation bump travels with it for the mirror
232
- // reason: +0.10 keeps a PALE plane from going grey, and a deep brown has no
233
- // such problem — at l = 0.46 it just makes an orange. At the default skin
234
- // both scalings are 1.0 and the five rungs are the old numbers to the digit.
235
- const skin = (i) => {
236
- const t = clamp(i, 0, RAMP - 1) / (RAMP - 1);
237
- return hsl(lerp(H - 22, H, t),
238
- S + 0.10 * (L / L_REF) * Math.sin(Math.PI * t),
239
- L / L_REF * lerp(0.46, 0.80, t));
240
- };
241
- // A tone authored against the default skin, re-expressed as a relationship.
242
- const off = (dh, ds, l) => hsl(H + dh, S + ds, L / L_REF * l);
243
-
244
- // The side plane and the chin's cast shadow are translucent warm brown rather
245
- // than opaque skin values. Opaque planes force every shape underneath to know
246
- // which plane it sits on — the shadow-side eyelid has to be painted in the
247
- // shadow tone, and it then shows as a patch the moment the plane's boundary
248
- // moves. A translucent plane painted last tints whatever is under it and that
249
- // whole class of bug disappears.
250
- const SHADE = (a) => [...off(-16, 0.15, 0.34).slice(0, 3), a];
251
-
252
- const [uh, us, ul] = p.lips.up;
253
- const UL_REF = DEFAULT_PERSONA.lips.up[2];
254
- const lip = (dh, ds, l) => hsl(uh + dh, us + ds, ul / UL_REF * l);
255
- const [wh, ws, wl] = p.brow.colour;
256
-
257
- return {
258
- face: skin(4),
259
- shade: SHADE(p.form?.sideShadeAlpha ?? 0.17), // the one side plane
260
- ear: skin(3.5),
261
- earR: skin(3.0),
262
- earIn: skin(2.7),
263
- earInR: skin(2.4),
264
- neck: skin(3.0),
265
- neckSh: SHADE(0.15),
266
- nose: skin(2.9),
267
- noseBridge: off(-17, 0.08, 0.34),
268
- noseUnder: off(-19, 0.10, 0.25),
269
- noseAlar: off(-22, 0.10, 0.18),
270
- skinFleck: off(-13, 0.13, 0.53),
271
- skinMole: off(-21, 0.15, 0.31),
272
- hairBack: hair(0),
273
- hairFront: hair(1),
274
- // The fringe highlight was an opaque lozenge; at the webcam crop that read as
275
- // a decal stuck on the hair. Translucent, it becomes a sheen.
276
- hairHi: [...hair(1.9).slice(0, 3), 0.50],
277
- shirt: shirt(1),
278
- collar: shirt(0),
279
- // The rouge, at whatever fraction of itself the persona asks for. It is a
280
- // knob rather than a colour because the thing a persona changes about a
281
- // blush is almost never its hue: 0.20 alpha on a woman's cheek is the
282
- // style, and the same 0.20 on a man's reads as make-up at 1x.
283
- blush: [...off(-20, 0.22, 0.70).slice(0, 3), 0.20 * p.blush],
284
-
285
- // --- the eye stack, back to front ---------------------------------------
286
- // The two scleras differ by less than they used to: the side shading plane
287
- // already tints the shadow-side eye, and doubling that up made the pair look
288
- // asymmetric rather than lit once the camera moved in.
289
- sclera: eyeFinish.sclera || [250, 248, 245, 1],
290
- scleraShade: eyeFinish.scleraShade || [240, 236, 233, 1],
291
- // One shape does both jobs the research asks of the sclera: the upper lid's
292
- // cast shadow across the top, and the corner darkening — it is the opening's
293
- // own outline with its lower edge raised most in the middle and not at all
294
- // at the canthi, so it is deep at the corners and shallow under the lid.
295
- eyeShade: [...hsl(348, 0.30, 0.30).slice(0, 3), eyeFinish.shadeAlpha ?? 0.15],
296
- // Limbal ring and inner glow are NEUTRAL overlays on top of the iris — a
297
- // translucent black annulus and a translucent white disc — never colours
298
- // derived from the iris hue. That is what keeps the 39-rung `hue/*` ladder
299
- // (which only ever swaps the iris paint) correct at every rung.
300
- limbal: [12, 9, 14, eyeFinish.limbalAlpha ?? 0.30],
301
- irisGlow: [255, 255, 255, eyeFinish.glowAlpha ?? 0.13],
302
- pupil: [26, 18, 22, 1],
303
- catch: [255, 255, 255, eyeFinish.catchAlpha ?? 0.95],
304
- catch2: [255, 255, 255, eyeFinish.catch2Alpha ?? 0.25],
305
- // The lower lid's own edge, read as a line rather than a lash.
306
- water: [...off(-20, -0.04, 0.28).slice(0, 3), eyeFinish.waterAlpha ?? 0.22],
307
- carun: [...off(-22, 0, 0.70).slice(0, 3), 0.42],
308
- lash: hsl(wh - 6, ws - 0.02, wl - 0.10),
309
- crease: [...off(-16, -0.06, 0.55).slice(0, 3), 0.42],
310
- // One warm shade for the two soft folds of skin the eye makes: the
311
- // thickness of the upper lid above the lash, and the roll of cheek a
312
- // squint pushes up under the lower one. They are the same material seen
313
- // from the same light, so they are the same paint — and one paint is what
314
- // lets `squintSh` fade in on its own alpha without a second registry entry.
315
- lidFold: [...off(-14, 0.04, 0.62).slice(0, 3), 0.30],
316
- brow: hsl(wh, ws, wl),
317
- browR: hsl(wh - 2, ws, wl - 0.05),
318
- // A shallow upper-orbital plane connects the brow, lid and nose into one
319
- // lit face. It is deliberately static: expression still belongs entirely
320
- // to the existing brow/lid channels, while this only supplies head form.
321
- socket: [...off(-14, 0.02, 0.46).slice(0, 3), p.form?.socketAlpha ?? 0.10],
322
-
323
- // --- the mouth ------------------------------------------------------------
324
- lipUp: hsl(...p.lips.up),
325
- lipLow: hsl(...p.lips.low),
326
- lipHi: [255, 240, 233, 0.24],
327
- lipBow: [255, 238, 231, 0.13],
328
- lipCast: [...off(-17, 0.08, 0.34).slice(0, 3), 0.14],
329
- seam: [...lip(-4, 0.02, 0.26).slice(0, 3), 0.85],
330
- commiss: [...lip(-6, 0.03, 0.30).slice(0, 3), 0.30],
331
- philtrum: [...off(-17, 0.06, 0.46).slice(0, 3), 0.22],
332
- mouthIn: hsl(350, 0.35, 0.25),
333
- teeth: [250, 246, 242, 1],
334
- toothSep: [...hsl(28, 0.14, 0.38).slice(0, 3), 0.55],
335
- // The upper lip's cast shadow doubles as the gum line: it used to be a
336
- // neutral dark multiply with a separate pink `gum` band under it, and the
337
- // multiply ate the pink — 2% contrast, invisible at 1x and at 3x. One
338
- // warmer, pinker shadow says both things in one draw.
339
- toothSh: [...hsl(350, 0.52, 0.30).slice(0, 3), 0.40],
340
- tongue: hsl(352, 0.42, 0.56),
341
- };
342
- }
343
-
344
- // The iris ladder. `hue/NNN` swaps to a solid at that hue; the driver always
345
- // holds one (or two neighbouring) hue poses at full weight, and layers
346
- // `iris/eyes-saturation-0` and `iris/eyes-brightness-0` on top of it, all
347
- // blended in RGBA against the *base* iris paint. So the base is not a colour
348
- // anybody picks: it is solved backwards from where the persona's `eye` should
349
- // land at the persona's OWN dial, so `shown(h) = eye + ladder(h) - ladder(hue)`
350
- // and a dark ladder is the only thing that keeps a dark eye dark at all 39
351
- // rungs. This file used to carry the answer pasted in — `base: [72,144,192]`,
352
- // solved by hand — with a comment saying `solveIrisBase` returns exactly that;
353
- // it does, which is the whole reason it can be wired up now without moving a
354
- // byte. The two overlay rungs are the ladder's own lightness, greyed flat and
355
- // lifted — 100% and 158.8% of it, i.e. the [107,107,107] and [170,170,170]
356
- // this file has always written at light 0.42.
357
- function makeIris(p) {
358
- const IRIS = {
359
- hue: (h) => hsl(h, p.iris.sat, p.iris.light),
360
- grey: hsl(0, 0, p.iris.light),
361
- bright: hsl(0, 0, p.iris.light * 1.588),
362
- target: [...p.iris.eye, 1],
363
- };
364
- IRIS.base = solveIrisBase(IRIS.target, IRIS, {
365
- hue: p.iris.hue, saturation: p.iris.saturation, brightness: p.iris.brightness,
366
- });
367
- return IRIS;
368
- }
369
-
370
- // A persona, filled in from the defaults one level down (each block is a small
371
- // flat record, and a half-given `iris` block wants the rest of the driver's
372
- // defaults, not `undefined`).
373
- function fill(persona = {}) {
374
- const d = DEFAULT_PERSONA;
375
- return sexPaint({
376
- sex: persona.sex || d.sex,
377
- geo: persona.geo || undefined, // JSON.stringify drops it when unset
378
- skin: persona.skin || d.skin,
379
- lips: { ...d.lips, ...(persona.lips || {}) },
380
- iris: { ...d.iris, ...(persona.iris || {}) },
381
- brow: { ...d.brow, ...(persona.brow || {}) },
382
- lash: { ...d.lash, ...(persona.lash || {}) },
383
- // Omit the optional construction block when it was omitted on input, so
384
- // personas that do not use it keep byte-identical `meta.live.persona`.
385
- eye: persona.eye ? { aperture: 1, ...persona.eye } : undefined,
386
- mouth: persona.mouth ? { ...persona.mouth } : undefined,
387
- form: persona.form ? { ...persona.form } : undefined,
388
- nose: persona.nose ? { ...persona.nose } : undefined,
389
- skinDetail: persona.skinDetail ? { ...persona.skinDetail } : undefined,
390
- blush: persona.blush ?? d.blush,
391
- }, persona);
392
- }
393
-
394
- // ---------------------------------------------------------------------------
395
- // SEX. `persona.sex` is 'f' (the default, and the look this family has always
396
- // had) or 'm'. It is one key and it moves two things:
397
- //
398
- // GEOMETRY — `SEX_GEO` is a patch on the rest control vector, so a man is
399
- // this same skull built at a different rest. `persona.geo` overrides it
400
- // channel by channel for a persona that wants, say, a man's jaw at 0.6.
401
- //
402
- // PAINT — the marks that are conventions of femininity in a flat vector
403
- // face, not anatomy: the lash mass, the rouge, and a lip that is a different
404
- // HUE from the skin. Removing them is what stops a male rig reading as a
405
- // woman with a wide jaw. A persona that states the key itself always wins:
406
- // `sexPaint` only reaches for a mark the caller left unsaid.
407
- // ---------------------------------------------------------------------------
408
-
409
- export const SEX_GEO = {
410
- f: {},
411
- m: { jawWidth: 0.85, neckWidth: 1, eyeSize: -0.18, browH: -0.6, headW: 0.12 },
412
- };
413
-
414
- export function geoOf(persona = {}) {
415
- const sex = persona.sex || DEFAULT_PERSONA.sex;
416
- const g = { ...(SEX_GEO[sex] || {}), ...(persona.geo || {}) };
417
- // `plateW` is not a knob: it is `headW` frozen at the vector this rig is
418
- // BUILT at, because the neck's base plate (§5) backs a garment hole that was
419
- // cut against this rig's rest render. Derived here rather than written into
420
- // SEX_GEO so a persona that overrides `headW` gets a plate that follows.
421
- return { ...g, plateW: g.headW || 0 };
422
- }
423
-
424
- // The rest vector this persona's rig is BUILT at. `poseHarness` diffs every
425
- // pose against the builder run at this vector, so the sex geometry costs no
426
- // pose and no runtime channel — it is simply where the face rests.
427
- export function restFor(persona) { return ctrl(geoOf(persona)); }
428
-
429
- function sexPaint(p, given = {}) {
430
- if (p.sex !== 'm') return p;
431
- // A man's lip is not a colour of its own: it is the SKIN PLANE, a few degrees
432
- // toward red, a shade darker and a touch MORE saturated, with the seam under
433
- // it doing the work of saying "mouth". The first version of this rule took
434
- // the woman's lip and desaturated it toward the skin — hue = skin, sat capped
435
- // at 0.15 — which is a different thing and a wrong one: 30% of the lightness
436
- // gone and two thirds of the chroma with it lands on a NEUTRAL, and a neutral
437
- // beside a warm skin does not read as a lip, it reads as grey lipstick. So
438
- // the lip is derived from `skin` now and the female lip is not consulted at
439
- // all: -12% and -7% of the skin's lightness for the upper and lower plane,
440
- // +0.05 and +0.07 of its saturation, 6 and 8 degrees redder. Lightness as a
441
- // RATIO is what keeps the two planes apart on a deep brown as well as on a
442
- // peach, and it is why the lower lip lands lighter and rosier than the upper
443
- // on every skin in the set rather than only on the pale ones.
444
- const [sh, ss, sl] = p.skin;
445
- if (!given.lips) p.lips = { up: [sh - 6, ss + 0.05, sl * 0.88], low: [sh - 8, ss + 0.07, sl * 0.93] };
446
- if (!given.lash) p.lash = { ...p.lash, weight: 0 };
447
- if (given.blush === undefined) p.blush = 0;
448
- if (!given.brow) p.brow = { ...p.brow, weight: Math.max(p.brow.weight, 1.35) };
449
- return p;
450
- }
451
-
452
- // ===========================================================================
453
- // 4. LANDMARKS
454
- // CENTER points sit on the midline; SIDE points are mirrored into a left
455
- // (viewer-left, lit) and a right (shadow) copy, suffixed L / R; FREE points
456
- // carry a signed x and are NOT mirrored — they are what makes the fringe
457
- // sweep to one side and the nose tick sit on the shadow side.
458
- // ===========================================================================
459
-
460
- const CENTER = {
461
- crown: [0, P.headTop],
462
- glab: [0, 520], // glabella, between the brows
463
- ntip: [0, P.noseBaseY],
464
- philt: [0, 732],
465
- mth_c: [0, P.mouthY], // centre of the mouth opening
466
- chin_t: [0, 840], // crease under the lower lip
467
- chin: [0, P.chinY],
468
- // hair
469
- hb_t: [0, 232],
470
- hb_b: [0, 1052],
471
- // neck + throat
472
- nk_t: [0, 818],
473
- nk_b: [0, 1220],
474
- nsh_t: [0, 800],
475
- // the neck's base plate (§5, `platePts`) — a frozen copy of the neck's own
476
- // four points, so the plate is the neck's REST outline and stays there.
477
- pl_t: [0, 818],
478
- pl_b: [0, 1220],
479
- // shirt
480
- colc: [0, 1146],
481
- colc_t: [0, 1132],
482
- colc_b: [0, 1182],
483
- botc: [0, 1700],
484
- };
485
-
486
- const SIDE = {
487
- // silhouette, crown -> chin. Widest at the cheeks (chk), soft small chin.
488
- crn: [162, 262],
489
- tmpT: [260, 344],
490
- tmp: [292, 468],
491
- chk: [302, 600],
492
- lchk: [276, 700],
493
- jaw: [214, 800],
494
- // chin 48% of the cheek width — the adult range is 45-50%; it was 37%
495
- jawm: [145, 862],
496
- // brows: soft arch, peak at the outer third. Flatter than the cute original
497
- // (36px of arch became 22) and closer to the eye: an adult brow-eye gap.
498
- bwI: [80, 530],
499
- bwM: [166, 512],
500
- bwO: [232, 534],
501
- // nose
502
- nwing: [60, 694],
503
- // anchors that drive the smooth inserts, morphed with everything else
504
- eyeC: [P.eyeGapHalf + P.eyeHalfW, P.eyeY],
505
- mcor: [P.mouthHalfW, 758], // 10px above mth_c: the resting smile
506
- // the cheek rouge, as its INNER and OUTER edge rather than a centre: an
507
- // ellipse whose centre is morphed and whose radius is not is a rouge that
508
- // keeps its size while the face under it changes size, and at
509
- // `morph/head_-100` that put 1240 px of it on the page either side of the
510
- // narrowed jaw. Two landmarks 128 apart go through every morph with the rest
511
- // of the mesh, so the width is whatever the mesh did to that span — which is
512
- // more than what it did to the midpoint, because the head-width ramp is
513
- // steeper at 242 than at 114.
514
- blushI: [114, 706],
515
- blushO: [242, 706],
516
- // ears, sitting in the brow -> nose-base band per the Loomis canon
517
- earA: [272, 550],
518
- earB: [328, 558],
519
- earC: [348, 614],
520
- earD: [332, 676],
521
- earE: [288, 702],
522
- earIA: [299, 588],
523
- earIB: [322, 617],
524
- earIC: [307, 654],
525
- earID: [287, 619],
526
- // the bob
527
- hbA: [176, 248],
528
- hbB: [292, 338],
529
- hbC: [338, 470],
530
- hbD: [342, 660],
531
- hbE: [326, 862],
532
- hbF: [302, 1002],
533
- hbG: [176, 1044],
534
- // neck
535
- // neck 49% of the cheek width (was 41%) — an adult neck carries the skull
536
- nk: [148, 826],
537
- nkm: [166, 1020],
538
- nkb: [182, 1220],
539
- // …and the plate's copy of them. Same numbers, different morph exemptions.
540
- plt: [148, 826],
541
- plm: [166, 1020],
542
- plb: [182, 1220],
543
- // The chin's cast shadow falls ON the neck, so it has to fit INSIDE it. These
544
- // two used to be 172 / 170 against a neck edge that renders at 148 / 165, and
545
- // the overhang was invisible only because the vector `shirt` was painted over
546
- // it. A bitmap garment hides `shirt`, and the 15%-alpha plane then landed on
547
- // the page itself as two pale wedges either side of the neck base — worst on
548
- // the men, where `neckWidth` widens the shadow and the neck by the same 22%
549
- // but the SPILL by 22% as well. The curve bulges ~10 past its own points, so
550
- // these two render at 150 at the widest against a neck edge of 165 — inside
551
- // it by 9% of the neck all the way down, with room for the head to turn.
552
- nshA: [131, 812],
553
- nshB: [137, 892],
554
- // shirt
555
- col: [186, 1044],
556
- sho: [452, 1076],
557
- out: [528, 1310],
558
- bot: [548, 1700],
559
- colT: [192, 1036],
560
- colB: [196, 1062],
561
- };
562
-
563
- // Points with a signed x that must NOT be mirrored: the swept fringe, the
564
- // highlight riding on it, the one-stroke nose and the boundary of the side
565
- // shading plane. Symmetry is the default in a face; the small asymmetries are
566
- // most of what stops a generated character looking generated.
567
- const FREE = {
568
- // fringe: outer edge over the crown, then the hairline right -> left with
569
- // the parting left of centre
570
- fr1: [-312, 480], fr2: [-324, 350], fr3: [-210, 250], fr4: [0, 226],
571
- fr5: [215, 252], fr6: [324, 356], fr7: [316, 478],
572
- fr8: [244, 496], fr9: [100, 454], fr10: [-46, 424], fr11: [-192, 462],
573
- // highlight band on the lit side of the fringe
574
- hh1: [-256, 330], hh2: [-150, 268], hh3: [-40, 246],
575
- hh4: [-48, 272], hh5: [-156, 292], hh6: [-248, 352],
576
- // nose: one L-shaped tick, drawn as a tapered closed stroke
577
- no1: [24, 654], no2: [36, 686], no3: [56, 704], no4: [84, 710],
578
- no5: [80, 724], no6: [42, 716], no7: [14, 692], no8: [8, 656],
579
- // bottom of the chin's cast shadow — pushed a little to the shadow side
580
- nsh_b: [12, 946],
581
- };
582
-
583
- // How strongly each point follows the jaw when the mouth opens.
584
- const JAWW = {
585
- chin: 1, chin_t: 0.88, jawm: 0.76, jaw: 0.30, lchk: 0.10,
586
- mcor: 0.40, mth_c: 0.42, philt: 0.16, ntip: 0.04, nwing: 0.05,
587
- blushI: 0.08, blushO: 0.08,
588
- no1: 0.02, no2: 0.03, no3: 0.04, no4: 0.04, no5: 0.04, no6: 0.04, no7: 0.03, no8: 0.02,
589
- };
590
- // Cheek raise (a smile pushes the cheek mass up under the eye).
591
- const CHEEKW = { blushI: 1, blushO: 1, lchk: 0.5, chk: 0.28, mcor: 0.45, sh5: 0.5, sh6: 0.35 };
592
-
593
-
594
- // Landmarks the BODY is made of — the shirt, and the neck's base plate. They
595
- // are exempt from every head morph: nothing in the rig.json format nests a body
596
- // under a head, so "only the head" has to be spelled out as a list of point
597
- // names. The plate is in the list for a second reason as well: the garment's
598
- // neck hole is cut ONCE, against this rig's own rest render, and a head morph
599
- // does not move it — so the thing that backs the hole must not move either.
600
- const SHIRT_PTS = new Set(
601
- ['col', 'sho', 'out', 'bot', 'colT', 'colB', 'plt', 'plm', 'plb']
602
- .flatMap((n) => [n, n + 'L', n + 'R'])
603
- .concat(['colc', 'colc_t', 'colc_b', 'botc', 'pl_t', 'pl_b']));
604
- // The plate's own eight, listed once: exempt from the head morph like the rest
605
- // of the shirt, but widened by `plateW` (§4, `widen`) so it still matches the
606
- // neck this rig was built with.
607
- const PLATE_PTS = ['pl_t', 'pl_b', 'pltL', 'pltR', 'plmL', 'plmR', 'plbL', 'plbR'];
608
-
609
- // Sex-axis geometry. `jawWidth` and `neckWidth` are named point sets rather
610
- // than a y-window scale like headW: a band low enough to catch the jaw also
611
- // catches the mouth corners, the blush and the nose tick, and a wider jaw must
612
- // not widen the mouth. The weight is how much of the full push each point takes.
613
- const JAWWIDE = { lchk: 0.20, jaw: 0.62, jawm: 1 };
614
- // The neck carries its own cast shadow (nshA/nshB) so the shadow never spills
615
- // past the silhouette it is cast on.
616
- const NECKWIDE = { nk: 1, nkm: 1, nkb: 1, nshA: 1, nshB: 1, plt: 1, plm: 1, plb: 1 };
617
- // The collar is the one part of the shirt that a wider neck must move: it is
618
- // what the neck comes out of. `sho`/`out`/`bot`/`colc*` stay put, so the
619
- // shoulders and the shirt body keep the size the wardrobe bitmaps were cut for.
620
- const COLLARWIDE = { col: 1, colT: 1, colB: 1 };
621
-
622
- export function landmarks(c) {
623
- const cx = P.cx, pts = {};
624
- for (const [n, [x, y]] of Object.entries(CENTER)) pts[n] = [cx + x, y];
625
- for (const [n, [dx, y]] of Object.entries(SIDE)) {
626
- pts[n + 'L'] = [cx - dx, y];
627
- pts[n + 'R'] = [cx + dx, y];
628
- }
629
- for (const [n, [x, y]] of Object.entries(FREE)) pts[n] = [cx + x, y];
630
-
631
- // --- identity morphs ----------------------------------------------------
632
- // eye spacing: slide the eye anchor and everything hung off it
633
- for (const s of [-1, 1]) {
634
- const k = s < 0 ? 'L' : 'R';
635
- pts['eyeC' + k][0] += s * 22 * c.eyeSpace;
636
- for (const n of ['bwI', 'bwM', 'bwO']) pts[n + k][0] += s * 13 * c.eyeSpace;
637
- }
638
- // nose width: the wing landmarks and the tick's own eight points
639
- for (const s of [-1, 1]) {
640
- const k = s < 0 ? 'L' : 'R';
641
- pts['nwing' + k][0] = cx + (pts['nwing' + k][0] - cx) * (1 + 0.34 * c.noseW);
642
- }
643
- for (const n of ['no1', 'no2', 'no3', 'no4', 'no5', 'no6', 'no7', 'no8']) {
644
- pts[n][0] = cx + (pts[n][0] - cx) * (1 + 0.30 * c.noseW);
645
- }
646
- // brow height: brows carry a little of the forehead mesh with them
647
- // The three brow channels are per SIDE now (`eyeSide`), and they arrive in
648
- // the driver's -1..1 rather than in px: `BROW_PX` is the part's own statement
649
- // of what one unit of each is worth, so the px live next to the geometry they
650
- // were tuned against and this loop only says WHERE they land.
651
- for (const s of [-1, 1]) {
652
- const k = s < 0 ? 'L' : 'R';
653
- const e = eyeSide(c, s);
654
- for (const [n, g] of [['bwI', 1], ['bwM', 1], ['bwO', 0.85]]) {
655
- pts[n + k][1] -= (20 * c.browH + e.browRaise * BROW_PX.raise
656
- + e.browInner * BROW_PX.inner * (n === 'bwI' ? 1 : n === 'bwM' ? 0.4 : 0)
657
- + e.browAngle * BROW_PX.angle * (n === 'bwO' ? 1 : n === 'bwM' ? 0.4 : 0)) * g * 0.55;
658
- }
659
- }
660
-
661
- // --- head width: a global x-scale that fades in away from the midline ----
662
- // The shirt is deliberately exempt: the head morph must not resize the body,
663
- // and there is no transform hierarchy in the format to give us that for free.
664
- // Unlike facet, which widens hardest at the jaw, a round face widens hardest
665
- // across the cheeks.
666
- const widen = (names, w) => {
667
- if (!w) return;
668
- for (const n of names) {
669
- const dx = pts[n][0] - cx, a = Math.abs(dx);
670
- const t = clamp((a - 30) / 240, 0, 1);
671
- const ramp = 0.14 + 0.86 * (t * t * (3 - 2 * t));
672
- let g = 0.20 * w * ramp;
673
- const y = pts[n][1];
674
- if (y > 540 && y < 760) g += 0.07 * w * ramp; // the cheeks widen most
675
- pts[n][0] = cx + dx * (1 + g);
676
- }
677
- // a wider head is also a slightly shorter one
678
- for (const n of names) {
679
- if (pts[n][1] < 920) pts[n][1] = P.eyeY + (pts[n][1] - P.eyeY) * (1 - 0.045 * w);
680
- }
681
- };
682
- widen(Object.keys(pts).filter((n) => !SHIRT_PTS.has(n)), c.headW);
683
- // The plate is the one SHIRT_PT that takes this transform anyway — off its
684
- // own channel. `plateW` is the head width the rig was BUILT at (§3, geoOf)
685
- // and no pose ever moves it, so the plate lands exactly on the neck's REST
686
- // outline on a man as well as on a woman, while `morph/head_±100` still
687
- // slides the neck across a plate that stays where the garment's hole is.
688
- // Without this the men's plate sat 2.4 px inside their own neck and left a
689
- // hairline of page open along the neckline: 1205 px at 3x on round-m2.
690
- widen(PLATE_PTS, c.plateW);
691
-
692
- // --- jaw width: the silhouette from the cheek down, and the gonial angle --
693
- // A man's mandible is wider at the corner and squarer where it turns; a
694
- // woman's tapers sooner. This is the strongest single cue at 1x.
695
- if (c.jawWidth) {
696
- for (const [n, w] of Object.entries(JAWWIDE)) {
697
- for (const k of ['L', 'R']) {
698
- const q = pts[n + k];
699
- q[0] = cx + (q[0] - cx) * (1 + 0.32 * c.jawWidth * w);
700
- }
701
- }
702
- // squarer corner: the gonial point drops as it widens
703
- for (const k of ['L', 'R']) pts['jaw' + k][1] += 12 * c.jawWidth;
704
- }
705
-
706
- // --- neck width: the neck, its cast shadow, and the collar it wears -------
707
- if (c.neckWidth) {
708
- for (const [n, w] of Object.entries(NECKWIDE)) {
709
- for (const k of ['L', 'R']) {
710
- const q = pts[n + k];
711
- q[0] = cx + (q[0] - cx) * (1 + 0.22 * c.neckWidth * w);
712
- }
713
- }
714
- for (const n of Object.keys(COLLARWIDE)) {
715
- for (const k of ['L', 'R']) {
716
- const q = pts[n + k];
717
- q[0] = cx + (q[0] - cx) * (1 + 0.16 * c.neckWidth);
718
- }
719
- }
720
- }
721
-
722
- // --- eye-derived points (the lid plate bounds, not the eye itself) -------
723
- const hw = P.eyeHalfW * (1 + 0.20 * c.eyeSize);
724
- const bh = P.eyeBotH * (1 + 0.24 * c.eyeSize);
725
- for (const s of [-1, 1]) {
726
- const k = s < 0 ? 'L' : 'R';
727
- const e = pts['eyeC' + k];
728
- pts['eyI' + k] = [e[0] - s * (hw + 20), e[1] + 5];
729
- pts['eyO' + k] = [e[0] + s * (hw + 26), e[1] - 11];
730
- pts['eyB' + k] = [e[0] + s * 4, e[1] + bh + 34];
731
- }
732
-
733
- // --- expression / animation deformation ---------------------------------
734
- if (c.jaw) {
735
- const d = c.jaw * P.jawDrop;
736
- applyWeights(pts, JAWW, (p, w) => {
737
- p[1] += d * w;
738
- if (w > 0.6) p[0] = cx + (p[0] - cx) * (1 - 0.030 * c.jaw);
739
- });
740
- }
741
- if (c.cheekRaise) {
742
- applyWeights(pts, CHEEKW, (p, w) => { p[1] -= c.cheekRaise * 13 * w; });
743
- }
744
- if (P.scale !== 1) {
745
- const K = P.scale, oy = P.headTop;
746
- for (const n of Object.keys(pts)) {
747
- pts[n][0] = cx + (pts[n][0] - cx) * K;
748
- pts[n][1] = oy + (pts[n][1] - oy) * K;
749
- }
750
- }
751
- return pts;
752
- }
753
-
754
- // ===========================================================================
755
- // 5. SHAPES
756
- // [slot, group, [landmark names, in order round a closed loop], tone,
757
- // tension]. Where facet had a table of triangles over its landmarks, this
758
- // has a table of closed splines over them. Nothing is mirrored
759
- // automatically: a loop that crosses the midline has to name both halves.
760
- // ===========================================================================
761
-
762
- // Three groups, and the third one is new. `head` turns with the head matrix,
763
- // `body` does not; `hand` is neither, because the hand is not on the character
764
- // at all — it is the nearest object in the FRAME, placed by the camera window's
765
- // own numbers, and a head that turns must not take it along.
766
- export const HEAD = 'head', BODY = 'body', HAND = 'hand';
767
-
768
- // ---------------------------------------------------------------------------
769
- // `band`, `bulge`, `contours` and `ring` — the shape idioms the fidelity pass
770
- // needed — are in `author/path.mjs`; two of the three generators had each of
771
- // them. `onRun`, the polyline sampler the lower lashes hang off, went the other
772
- // way: it had one call site, the call site moved into `author/parts/eye.mjs`,
773
- // and so did it.
774
- // ---------------------------------------------------------------------------
775
-
776
- const FACE_LOOP = [
777
- 'crown', 'crnL', 'tmpTL', 'tmpL', 'chkL', 'lchkL', 'jawL', 'jawmL',
778
- 'chin', 'jawmR', 'jawR', 'lchkR', 'chkR', 'tmpR', 'tmpTR', 'crnR',
779
- ];
780
-
781
- export const SHAPES = [
782
- // the bob, behind everything
783
- ['hairBack', HEAD, ['hb_t', 'hbAL', 'hbBL', 'hbCL', 'hbDL', 'hbEL', 'hbFL', 'hbGL',
784
- 'hb_b', 'hbGR', 'hbFR', 'hbER', 'hbDR', 'hbCR', 'hbBR', 'hbAR'], 'hairBack', 1],
785
- // ears, in front of the hair and behind the face
786
- ['earL', HEAD, ['earAL', 'earBL', 'earCL', 'earDL', 'earEL'], 'ear', 1],
787
- ['earInL', HEAD, ['earIAL', 'earIBL', 'earICL', 'earIDL'], 'earIn', 1],
788
- ['earR', HEAD, ['earAR', 'earBR', 'earCR', 'earDR', 'earER'], 'earR', 1],
789
- ['earInR', HEAD, ['earIAR', 'earIBR', 'earICR', 'earIDR'], 'earInR', 1],
790
- // neck, and the shadow the chin casts on it
791
- ['neck', HEAD, ['nk_t', 'nkR', 'nkmR', 'nkbR', 'nk_b', 'nkbL', 'nkmL', 'nkL'], 'neck', 1],
792
- ['neckSh', HEAD, ['nshAL', 'nsh_t', 'nshAR', 'nshBR', 'nsh_b', 'nshBL'], 'neckSh', 1],
793
- // shirt (BODY: exempt from the head matrix and every head morph)
794
- ['shirt', BODY, ['colL', 'shoL', 'outL', 'botL', 'botc', 'botR', 'outR', 'shoR', 'colR', 'colc'], 'shirt', 1],
795
- ['collar', BODY, ['colTL', 'colc_t', 'colTR', 'colBR', 'colc_b', 'colBL'], 'collar', 1],
796
- // the face itself
797
- ['face', HEAD, FACE_LOOP, 'face', 1],
798
- ];
799
-
800
- // The slots a wardrobe layer follows when it is a GARMENT rather than hair or
801
- // glasses. The build reads it to find where the neck's base plate has to stop
802
- // (§5, `plateTop`); it is stated here because this table is where the names
803
- // are, and a renamed shirt should break the build rather than the picture.
804
- export const GARMENT_SLOTS = ['shirt', 'collar'];
805
-
806
- // ---------------------------------------------------------------------------
807
- // THE NECK'S BASE PLATE — one static shape, drawn behind everything.
808
- //
809
- // The neck is a HEAD draw and the garment is a BODY one, so under `headRoll`,
810
- // `headYaw` and `headPitch` the neck swings inside a garment neckline that does
811
- // not move: 9 deg about `neckPivot` is 21 px of lateral travel at the collar and
812
- // a yaw is 40 px flat. The wardrobe matte is cut at the neck's REST outline
813
- // (`wardrobe/extract.py`, `roi &= ~neck`) so the two silhouettes have no overlap
814
- // at all, and the page opened up beside the upper neck — ~1345 px at 3x on
815
- // round-m2. Blending the garment toward the head matrix would swing the
816
- // shoulders with it; a second, collar-height bitmap layer on the head matrix
817
- // only moves the seam down to the collar's own join and does nothing for the
818
- // two bald rigs, which wear a vector shirt.
819
- //
820
- // What actually closes it is that the hole is FIXED and the wedge is therefore
821
- // always a subset of the neck's rest footprint: so back the hole with skin.
822
- // `neckPlate` is that backing — the neck's own rest outline, inset 1 px so it
823
- // is strictly inside the neck's opaque interior (hidden at rest, on every rig,
824
- // at every scale), painted in the BODY group so it stays put while the head
825
- // moves. It is pushed FIRST, behind the bob, because where a rig already has
826
- // hair behind the neck the hair is what should show through, not skin.
827
- //
828
- // It has a hard top edge, and where that edge sits is the whole of the rest of
829
- // the design. THE PLATE STOPS WHERE ITS COVER STARTS. Above the garment's own
830
- // neckline nothing ever covers the plate, so a plate carried higher would show
831
- // beside a neck that had rolled out from under it — a second, static neck edge
832
- // standing in the page, which is a worse picture than the wedge it was cutting.
833
- // `plateTop` is that line, in design y, written by the build (build.mjs, §10)
834
- // as the top edge of the topmost bitmap layer that follows a body slot; a rig
835
- // with no such layer wears the vector shirt and falls back to `colT`, the
836
- // collar's own top landmark. Both are ABOVE the neckline they stand for — a
837
- // crop starts at the garment's shoulders, not at its collar — so the wedge is
838
- // always closed, and what is left over is the few rows between the two. The
839
- // `lift` is slack for a stroke's half-width and for the flat cut's own
840
- // sampling error; it costs those rows and nothing else. A sidecar that has
841
- // measured its own neckline may state it and be believed instead — which is
842
- // what the four open-jacket personas do, their crops starting 70 rows above
843
- // the line they draw beside the neck.
844
- //
845
- // The cut also puts the plate below the `wardrobe/hair-back` bitmaps that sit
846
- // behind the neck's edges from design y 865 to 904 on this face. Skin painted
847
- // over hair is the one thing worse than page, and now the plate cannot reach
848
- // that far up on any rig.
849
- // ---------------------------------------------------------------------------
850
-
851
- const PLATE = { inset: 1, lift: 4, per: 32 };
852
- const PLATE_LOOP = ['pl_t', 'pltR', 'plmR', 'plbR', 'pl_b', 'plbL', 'plmL', 'pltL'];
853
-
854
- function platePts(L, c) {
855
- const cx = P.cx, { inset, lift, per } = PLATE;
856
- const loop = PLATE_LOOP.map((n) => L[n]);
857
- // `plateTop` is 0 on a rig the build said nothing about, and a rig the build
858
- // says nothing about is one that wears the vector shirt: the collar's own top
859
- // landmark is where its cover starts.
860
- const top = (c.plateTop || L.colTL[1]) - lift;
861
- const bot = L.pl_b[1] - 2 * inset;
862
- // Sampled off the plate loop's own curve rather than re-splined through its
863
- // four points, so the plate follows the neck's contour instead of a second
864
- // curve that only meets it at the landmarks. Clamped, never filtered: the
865
- // point count of a draw has to be the same in every pose — which is why the
866
- // run is sampled deep. Every sample above the cut collapses onto it keeping
867
- // its own x, so the flat top edge is only as wide as the last sample above
868
- // it; at 32 the neck's flare costs less than a pixel there.
869
- const side = (from, to) => sampleRun(loop, 1, from, to, per).map(([x, y]) => {
870
- const yy = clamp(y, top, bot);
871
- const half = Math.max(0, Math.abs(x - cx) - inset);
872
- return [cx + Math.sign(x - cx) * half, yy];
873
- });
874
- return [...side(1, 3), ...side(5, 7)];
875
- }
876
-
877
- // Drawn after the face: the fringe covers the top of the head, so its outer
878
- // edge deliberately overshoots the silhouette onto the hair behind.
879
- export const SHAPES_TOP = [
880
- ['fringe', HEAD, ['fr1', 'fr2', 'fr3', 'fr4', 'fr5', 'fr6', 'fr7', 'fr8', 'fr9', 'fr10', 'fr11'], 'hairFront', 1],
881
- ['hairHi', HEAD, ['hh1', 'hh2', 'hh3', 'hh4', 'hh5', 'hh6'], 'hairHi', 1],
882
- ];
883
-
884
- // The shading plane is a band that hugs the shadow-side contour: FACE_LOOP
885
- // indices 8 ('chin') up to 14 ('tmpTR', which sits under the fringe). Both of
886
- // its edges are offsets of the same sampled curve, so it is parallel to the
887
- // silhouette by construction and there is no second set of landmarks to keep
888
- // in sync with the first.
889
- const SHADOW_RUN = [8, 14];
890
- const SHADOW_W = 58; // widest point of the band, at the cheek
891
-
892
- // ===========================================================================
893
- // 6. THE KIT — one persona, resolved
894
- // A palette, an iris ladder and a paint registry of its own. Everything
895
- // below takes it as an argument, because a second persona is a second
896
- // display list built by the same builder and it must not share a paint
897
- // table with the first one.
898
- // ===========================================================================
899
-
900
- export function makeKit(persona) {
901
- const p = fill(persona);
902
- const PALETTE = makePalette(p), IRIS = makeIris(p), reg = paintRegistry();
903
- // Eye WIDTH is already an adult 20.7% of the cheek and remains identity
904
- // geometry. `aperture` only scales the two vertical radii, so a persona can
905
- // lose the startled/cute opening without becoming narrow-eyed or moving the
906
- // iris, canthi, brow, wardrobe fit, or head silhouette.
907
- const EYE_P = {
908
- ...P,
909
- eyeTopH: P.eyeTopH * (p.eye?.aperture ?? 1),
910
- eyeBotH: P.eyeBotH * (p.eye?.aperture ?? 1),
911
- irisR: P.irisR * (p.eye?.irisScale ?? 1),
912
- pupilR: P.pupilR * (p.eye?.pupilScale ?? 1),
913
- };
914
- // The two parts are constructed HERE, not in the draw builder: everything
915
- // they take is a constant of this persona — the proportions, the resolved
916
- // palette, this persona's own paint registry, the solved base iris, the lash
917
- // and brow weights. Construction once, `draws(c, …)` once per control vector.
918
- return {
919
- p, PALETTE, IRIS, ...reg,
920
- mouth: makeMouth({ P, PALETTE, solid: reg.solid, group: HEAD, marks: p.mouth }),
921
- eye: makeEye({
922
- P: EYE_P, PALETTE, solid: reg.solid, group: HEAD,
923
- irisBase: IRIS.base, lashWeight: p.lash.weight, browWeight: p.brow.weight,
924
- shape: p.eye?.shape,
925
- marks: p.eye?.marks,
926
- catch: p.eye?.finish?.catch,
927
- refine: p.eye?.refine,
928
- }),
929
- nose: makeNose({ P, PALETTE, solid: reg.solid, group: HEAD, shape: p.nose }),
930
- skinDetail: makeSkinDetail({
931
- PALETTE, solid: reg.solid, group: HEAD, profile: p.skinDetail,
932
- }),
933
- // The third part takes no persona of its own beyond the skin rungs: a
934
- // character's hand is the same character's hand, so `PALETTE.face` /
935
- // `shade` / `crease` are the whole of it, which is also why the paint
936
- // table does not grow by a single entry.
937
- hand: makeHand({ P, PALETTE, solid: reg.solid, frame: HAND_FRAME, group: HAND }),
938
- };
939
- }
940
-
941
- // ===========================================================================
942
- // 7. THE CONTROL VECTOR
943
- // ===========================================================================
944
-
945
- // The channels the driver's vocabulary needs somebody to implement, at rest —
946
- // six identity morphs, a jaw, a cheek, an eye block and a mouth block. The
947
- // table lives in author/rig.mjs (REST_CONTROLS) because all three styles hold
948
- // it byte-for-byte; a style that grew a channel of its own spreads it here,
949
- // which is exactly what this one now does — twice, because both of its parts
950
- // have moved into channel space.
951
- //
952
- // The mouth block is overridden with `author/parts/mouth.mjs`'s: voqalize's ten
953
- // mouth CHANNELS (`open` 0..1, `width` 0..1, `round`, `press`, `tuck`, per-side
954
- // corners) in place of the nine pre-channel keys REST_CONTROLS still carries.
955
- // The eye block likewise carries `author/parts/eye.mjs`'s: `eye:` in channel
956
- // space (lid / squint / pupilX / pupilY / browRaise / browAngle / browInner),
957
- // plus the two empty per-side override blocks `eyeL:` and `eyeR:` that let a
958
- // patch say `{ eye: { lid: 0.12 }, eyeR: { lid: 1 } }` and get a wink.
959
- //
960
- // `facet` and `ink` still hold both features inline and read the px-and-ratio
961
- // blocks REST_CONTROLS ships, so each is an override rather than a change to
962
- // the shared table — until they are parts too, when the overrides go and
963
- // `REST_CONTROLS` changes once.
964
- //
965
- // `hand:` is the third block and the odd one out: it is not a channel vector
966
- // at all, it is voqalize's `HandFrame` — a gesture NAME, a progress and a
967
- // side. It rides the control vector because the control vector is what
968
- // `buildDraws` is given and because a hand is part of what this character is
969
- // doing this frame; it stays OUT of `REST_CONTROLS` because facet and ink have
970
- // no hand and a shared table that carries one would be lying about them.
971
- export const ctrl = makeCtrl({
972
- ...REST_CONTROLS, plateW: 0, plateTop: 0, mouth: mouthRestChannels(), ...eyeChannelRest(), ...handRest(),
973
- });
974
-
975
- // The same factory, rebased on a persona's rest (§3, restFor). `finishRig`
976
- // builds every pose through `spec.ctrl`, so a male rig must hand it THIS one:
977
- // otherwise each pose would silently reset jawWidth/neckWidth to the family
978
- // neutral and every pose would carry the whole face as a diff.
979
- export function ctrlFor(persona) { return makeCtrl(restFor(persona)); }
980
-
981
- // ===========================================================================
982
- // 8. DRAW BUILDER
983
- // Runs the whole character for one control vector. Called once for the
984
- // rest pose (which becomes the base display list) and once per pose; the
985
- // poses are then a straight diff, so topology can never drift.
986
- // `K` is the kit above: the persona's palette, its iris ladder and its
987
- // paint registry. It is a second argument rather than a module constant
988
- // because one process can build several of these characters.
989
- // ===========================================================================
990
-
991
- export function buildDraws(c, K) {
992
- const { PALETTE, solid } = K;
993
- const L = landmarks(c);
994
- const out = [];
995
- // `rule` and `blend` are draw-level fields `toRig` passes through verbatim
996
- // (author/README.md). 'evenodd' is the only way to say "this contour is a
997
- // hole", which the limbal ring needs; 'multiply' is what makes the teeth's
998
- // upper-lip shadow and the mouth-corner pockets darken what is under them
999
- // rather than paint over it.
1000
- const push = drawPusher(out);
1001
-
1002
- // Behind everything, including the bob: the neck's base plate (§5).
1003
- push('neckPlate', BODY, polygon(platePts(L, c)), solid(PALETTE.neck));
1004
-
1005
- const emit = (table) => {
1006
- for (const [slot, group, names, tone, tension] of table) {
1007
- const pts = names.map((n) => {
1008
- if (!L[n]) throw new Error(`shape ${slot}: no landmark "${n}"`);
1009
- return L[n];
1010
- });
1011
- push(slot, group, spline(pts, tension), solid(PALETTE[tone]));
1012
- }
1013
- };
1014
-
1015
- emit(SHAPES);
1016
-
1017
- // ---- cheeks --------------------------------------------------------------
1018
- // Seven points and 0.26 alpha read as a flat lozenge once the camera moved
1019
- // in; eleven points and 0.20 read as a soft ellipse. Same one draw.
1020
- //
1021
- // The WIDTH comes off the two cheek landmarks, so the rouge is warped by the
1022
- // same field as the face it sits on (SIDE.blushI/blushO). The HEIGHT does
1023
- // not: the only thing a head morph does vertically is make a wider head 4.5%
1024
- // shorter, and 1.7 px on this blob is not worth a second pair of landmarks —
1025
- // so it keeps the 37.12 px it was authored at, stated as the radius and the
1026
- // squash it used to be written as. At rest the pair is 128 apart and this is
1027
- // the same eleven points it always was, to the bit.
1028
- const RY = 64 * 0.58;
1029
- for (const side of [-1, 1]) {
1030
- const k = side < 0 ? 'L' : 'R';
1031
- const bi = L['blushI' + k], bo = L['blushO' + k];
1032
- const bx = (bi[0] + bo[0]) / 2, by = (bi[1] + bo[1]) / 2;
1033
- const rx = Math.abs(bo[0] - bi[0]) / 2;
1034
- push('blush' + k, HEAD, spline(circle(bx, by, rx, 11, RY / rx), 1), solid(PALETTE.blush));
1035
- }
1036
-
1037
- // Identity marks sit above the broad blush, below hair and eyes. Their
1038
- // profile uses outer upper-cheek anchors, away from every expressive fold.
1039
- out.push(...K.skinDetail.draws(c, L));
1040
-
1041
- emit(SHAPES_TOP);
1042
-
1043
- // The eye socket is one broad, low-contrast plane, not another expression
1044
- // line. Painting it under the animated eyes lets the lids, sclera and brows
1045
- // keep their existing topology while the forehead-to-eye transition reads
1046
- // as a continuous head instead of separate symbols placed on flat skin.
1047
- if (K.p.form?.socket) {
1048
- const socketDepth = K.p.form.socketDepth ?? 1;
1049
- for (const side of [-1, 1]) {
1050
- const k = side < 0 ? 'L' : 'R';
1051
- const bi = L['bwI' + k], bm = L['bwM' + k], bo = L['bwO' + k];
1052
- const [ex, ey] = L['eyeC' + k];
1053
- push('socket' + k, HEAD, spline([
1054
- [bi[0], bi[1] + 7], [bm[0], bm[1] + 9], [bo[0], bo[1] + 11],
1055
- [bo[0], bo[1] + 11 + 14 * socketDepth],
1056
- [ex, ey - 29 + 6 * socketDepth],
1057
- [bi[0], bi[1] + 9 + 13 * socketDepth],
1058
- ], 0.82), solid(PALETTE.socket));
1059
- }
1060
- }
1061
-
1062
- // ---- eyes ---------------------------------------------------------------
1063
- // Eighteen draws a side, in paint order, from author/parts/eye.mjs. The brow
1064
- // rides with them: its landmarks are solved above (brow height carries a
1065
- // little of the forehead mesh with it, which is the mesh's business), the
1066
- // arch over them is the part's.
1067
- for (const side of [-1, 1]) out.push(...K.eye.draws(c, L, side));
1068
-
1069
- // ---- nose: persona-selectable planes; legacy tick is exact by default ----
1070
- out.push(...K.nose.draws(c, L));
1071
-
1072
- // ---- mouth ---------------------------------------------------------------
1073
- // Eleven draws, in paint order, from author/parts/mouth.mjs. It reads `c.jaw`
1074
- // (the lower lip follows the chin) and `c.lipFull` (the identity morph) as
1075
- // well as its own `c.mouth` block, and nothing outside it reads anything it
1076
- // computes — which is what let it move out whole.
1077
- out.push(...K.mouth.draws(c, L));
1078
-
1079
- // ---- the side shading plane, last ---------------------------------------
1080
- // Painted over the finished face, so it tints the eye and the lip corner on
1081
- // its side too, which is what makes it read as light rather than as a decal.
1082
- // Its outer edge is sampled off the face outline and pulled P.shadowInset px
1083
- // inboard: there is no clip in play, so anything that overshoots the
1084
- // silhouette lands on the background.
1085
- const fc = [P.cx, (P.headTop + P.chinY) / 2 + 40];
1086
- const outline = FACE_LOOP.map((n) => L[n]);
1087
- const contour = sampleRun(outline, 1, SHADOW_RUN[0], SHADOW_RUN[1], 3);
1088
- const nb = contour.length - 1;
1089
- // Widest over the cheek and jaw, tapering to a point at the chin and to
1090
- // nothing up at the temple where the fringe covers the end of it.
1091
- // The tips get pushed further in than the middle. A band that ends *on* the
1092
- // contour looks like a leak: the fill bulges over the chin and onto the neck
1093
- // where the outline curves hardest, and a tapering tip has no width left to
1094
- // hide it. Recessing the ends costs nothing and reads as the shadow dying
1095
- // out.
1096
- const tipIn = (t) => P.shadowInset + 15 * Math.pow(1 - Math.sin(Math.PI * t), 2);
1097
- // Always at least a few px wider than the outer inset, or the two edges
1098
- // cross over and the band ties itself in a knot at the ends.
1099
- const wAt = (t) => tipIn(t) + 4
1100
- + SHADOW_W * (K.p.form?.sideShadeWidth ?? 1)
1101
- * Math.pow(Math.sin(Math.PI * t), 0.75) * (1 - 0.34 * t);
1102
- push('faceShade', HEAD, band(
1103
- contour.map((q, i) => inward(q, fc, tipIn(i / nb))),
1104
- contour.map((q, i) => inward(q, fc, wAt(i / nb))),
1105
- 0.7,
1106
- ), solid(PALETTE.shade));
1107
-
1108
- // ---- the hand ------------------------------------------------------------
1109
- // LAST, and last for a reason: the hand is the nearest object in the frame,
1110
- // so anything drawn over it is a depth lie. That includes the wardrobe's
1111
- // bitmap hair and glasses, which anchor `front-all` — `finishRig` keeps this
1112
- // group behind nothing (author/finish.mjs, the tail group).
1113
- //
1114
- // At rest `c.hand.gesture` is null and all ten draws come back at alpha 0,
1115
- // parked below the bottom edge. Which is two independent reasons for a hand
1116
- // not to be on screen, and that is the right number for a display list that
1117
- // a pose blend, a wardrobe and a live evaluator all write to.
1118
- out.push(...K.hand.draws(c.hand));
1119
-
1120
- return out;
1121
- }
1122
-
1123
- // ===========================================================================
1124
- // 9. PERFORMANCE — the control patches the driver's vocabulary gets filled
1125
- // with. The base build and the pose diffing moved into `makeSpec` (§10):
1126
- // the display list is a function of the persona now, and one process
1127
- // builds several of them.
1128
- // ===========================================================================
1129
-
1130
- // ---- visemes --------------------------------------------------------------
1131
- // The 16 mascot codes live in `author/parts/mouth-tables.mjs` now, next to the
1132
- // channels they are written in — that header carries the translation out of
1133
- // this avatar's old pixel units. Imported rather than re-declared: two idioms
1134
- // driving the same part should be driving it off the same table.
1135
- //
1136
- // `RHUBARB_POSES` is the nine-letter Rhubarb set baked straight from voqalize's
1137
- // own `VISEME_SHAPES` with no hand-tuning, as `rhubarb/A` … `rhubarb/X`. They
1138
- // are OPTIONAL poses (src/vocab.js), so a rig whose mouth is still inline does
1139
- // not have them and still validates.
1140
-
1141
- // ---- eyes -----------------------------------------------------------------
1142
- // The EYE half of all six states now comes from the PART (author/parts/eye.mjs,
1143
- // EYE_TABLE), written in the driver's channel space — lid, squint, curveUp and
1144
- // the three brow channels, plus the `cheekRaise` that belongs to a Duchenne
1145
- // smile. That table is a statement about what those channels DO and is the same
1146
- // statement in any style, so it lives with the code that spends them.
1147
- //
1148
- // What stays HERE is the MOUTH each state wears, which is not the eye's to say
1149
- // and is very much this character's — and it is in the mouth part's channel
1150
- // space too, since §7 hands `mouthRestChannels()` to the control vector. A
1151
- // smile parts the lips: `open: 0.09` is a couple of millimetres of gap and
1152
- // `teeth: 0.45` puts a sliver of the upper band in it, which is what the
1153
- // lip-sync convention says a smile shows. Corners are per side now, so a smirk
1154
- // would be `cornerL` alone — this face just does not happen to want one yet. A
1155
- // sad mouth narrows and turns both corners down. Both closed variants carry the
1156
- // same mouth as their open ones, so a blink over a smile does not shut the
1157
- // mouth with it.
1158
- //
1159
- // So `eyes-happy` is ONE line assembled from two halves: the Duchenne eye out
1160
- // of EYE_TABLE and the corner lift out of SMILE.
1161
- const SMILE = { open: 0.09, width: 0.54, press: 0.15, teeth: 0.45, cornerL: 0.34, cornerR: 0.34 };
1162
- const FROWN = { width: 0.25, cornerL: -0.44, cornerR: -0.44 };
1163
-
1164
- const EYE_MOUTH = {
1165
- 'eyes-happy': SMILE,
1166
- 'eyes-happy_closed': SMILE,
1167
- 'eyes-sad': FROWN,
1168
- 'eyes-sad_closed': FROWN,
1169
- };
1170
- export const EYES = Object.fromEntries(Object.entries(EYE_TABLE).map(
1171
- ([n, patch]) => [n, EYE_MOUTH[n] ? { ...patch, mouth: EYE_MOUTH[n] } : patch],
1172
- ));
1173
-
1174
- // ===========================================================================
1175
- // 9.5 CAMERA — where the webcam is, in design space.
1176
- // `meta.artboard` + `meta.align` crop this rectangle and blow it up to
1177
- // fill a 4:3 frame (author/rig.mjs, `cameraMeta`). It is metadata: no
1178
- // point, pose, stroke width or paint anywhere above changes because of it,
1179
- // which is what makes a re-framing free of fidelity risk.
1180
- // ===========================================================================
1181
-
1182
- // The wardrobe sidecars put the outer hair edge at y=120 and y=135. Their
1183
- // midpoint keeps both identities within 0.7% of the shared headroom while a
1184
- // single live face module continues to serve both. The vector fringe at 226 is
1185
- // hidden inside that silhouette and is not the crown a viewer sees.
1186
- const VISIBLE_CROWN = 127.5;
1187
- const SKULL_H = P.chinY - FREE.fr4[1];
1188
- export const CAMERA_WINDOW = viewBoxForHead({
1189
- centerX: P.cx,
1190
- crownY: VISIBLE_CROWN,
1191
- chinY: P.chinY,
1192
- });
1193
-
1194
- export const CAMERA = {
1195
- frame: { w: 1440, h: 1080 }, // a 4:3 webcam feed
1196
- window: { cx: P.cx, y: CAMERA_WINDOW.y, h: CAMERA_WINDOW.h },
1197
- };
1198
-
1199
- // ...and the same rectangle, spelled out, because the HAND needs it.
1200
- //
1201
- // Everything else in this file is in design space and does not care where the
1202
- // crop is. The hand does: it enters from the frame's BOTTOM EDGE, which is a
1203
- // fact about the camera and not about the character, and keeps its size tied
1204
- // to the same native head. `author/parts/hand.mjs` turns those measurements
1205
- // into its four numbers.
1206
- //
1207
- // The camera crop may change; the hand-to-head relationship must not.
1208
- export const HAND_FRAME = handFrameOf(CAMERA_WINDOW, SKULL_H);
1209
-
1210
- // ===========================================================================
1211
- // 11. LIVE — what `src/live.js` needs on top of a control vector.
1212
- //
1213
- // The runtime evaluator drives `buildDraws` from voqalize's channels, and
1214
- // two things it cannot get from the channels themselves live here, because
1215
- // both are this character's and not the adapter's:
1216
- //
1217
- // REST the control vector at rest, which the adapter patches. It already
1218
- // agrees with voqalize's own REST channel for channel (mouth open
1219
- // 0.02 / width 0.42 / round 0.10 / press 0.15, lid 0.12, pupilY
1220
- // 0.05) — that agreement is why a rest pose comes out as the baked
1221
- // base and not as a face that twitches the moment it is switched on.
1222
- //
1223
- // HEAD how far one unit of headYaw / headPitch / headRoll / breath moves
1224
- // this head. The rig's vocabulary has NO head poses (src/vocab.js:
1225
- // visemes, eyes, morphs, hues — head motion only ever existed inside
1226
- // the ambient TRACKS), so the adapter spends those channels the same
1227
- // way `headMatFactory` does: a rotation about the neck pivot written
1228
- // onto every head draw. The numbers are scaled off this avatar's own
1229
- // idle sway (deg 2.4, tx 7) and its breath key ([0, 0, -3.6, -7]),
1230
- // taken out to a full excursion at channel = 1.
1231
- // ===========================================================================
1232
-
1233
- export const REST = ctrl();
1234
-
1235
- export const HEAD_LIVE = {
1236
- pivot: P.neckPivot,
1237
- yawPx: 40, // headYaw 1 slides the head this far toward the +x side
1238
- pitchPx: 26, // headPitch 1 drops it this far (chin down)
1239
- rollDeg: 9, // headRoll 1 tilts it this many degrees, + = viewer's right
1240
- // The pre-swell breath: a flat rise of head and body, this avatar's own
1241
- // `breathing` key ([0, 0, -3.6, -7]) taken out to channel 1. `BODY_LIVE`
1242
- // now states a `breathSwell` instead and the head's rise is derived from
1243
- // it, so these two are the fallback for a face that declares no swell.
1244
- breathTy: -3.6,
1245
- breathBodyTy: -7,
1246
- };
1247
-
1248
- // BODY — the four channels that move the trunk rather than the head, in the
1249
- // same shape voqalize's own faces spend them (face-core.js `poseTransforms`,
1250
- // constants from face-peep.js `POSE`). Three facts carried over rather than
1251
- // invented:
1252
- //
1253
- // * A LEAN IS A CHANGE OF SCALE. "In a webcam frame a lean is read almost
1254
- // entirely as a change of scale, so that is how it is drawn" — a uniform
1255
- // 1 + 0.055 about a point in the lower face, plus a small drop, and it is
1256
- // applied to the WHOLE figure. The head is not counter-rotated and does
1257
- // not stay put: peep prefixes the same lean transform to every layer it
1258
- // draws, head and torso alike, and so does this.
1259
- // * SHOULDERS AND THE TRUNK'S TURN ARE TORSO-ONLY. peep's `torsoLayers` is
1260
- // `['body']`; nothing above the collar sees them. Ours is `meta.live.body`,
1261
- // which is the same set for the same reason.
1262
- // * ONE SHOULDER CANNOT RISE WITHOUT THE OTHER, because the torso is one
1263
- // filled shape here as it is there. The mean of the two lifts it and the
1264
- // difference rotates it a degree and a half about the sternum.
1265
- //
1266
- // The travels are peep's, converted rather than re-tuned. Both cameras now put
1267
- // crown-to-chin at 70% of frame height, so native head heights are the whole
1268
- // conversion: this face's visible 762.5 units / peep's 480 = 1.5885 design
1269
- // units per peep unit. The two PIVOTS are anchored on the chin
1270
- // rather than on the neck, because peep's are stated against a head (its
1271
- // `leanPivot` is 37 units above its `CHIN_Y`, its `shrugPivot` 203 below) and
1272
- // the two characters have different necks.
1273
- // The last three are the trunk's share of channels the HEAD also takes, and
1274
- // they are what `poseTransforms` does after `torsoT` rather than inside it:
1275
- //
1276
- // * `yawPx` — peep's `PARALLAX.body` is 0.1 against a head layer at 1.0, so
1277
- // at `headYaw 1` its trunk slides 0.1 x 28 = 2.8 units. Converted, 4.45
1278
- // design px here. It is NOT 10% of OUR `yawPx`: the travel is what the
1279
- // viewer measures. On top of `turnPx`, which the mixer drives from the
1280
- // same headYaw at 0.45 and a near-3x tau, arriving late.
1281
- // * `rollDeg` — peep's `ROLL_TORSO 1.5` against its `ROLL_HEAD 5.5`. The
1282
- // RATIO converts, not the number: degrees are degrees, but 1.5 deg is
1283
- // peep's answer to a 5.5-deg head and ours is 9, so the trunk takes
1284
- // 9 x 1.5/5.5 = 2.45. About `HEAD_LIVE.pivot`, which is the pivot peep
1285
- // rolls both its head and its torso about.
1286
- // * `breathSwell` / `swellPivot` — breath as chest expansion. The torso
1287
- // scales about the HEM, so the shoulder line rises and the chest widens
1288
- // while the bottom of the shirt stays put; the head rides the swell's
1289
- // displacement at the neck pivot, which `src/live.js` derives rather than
1290
- // taking a second constant. 0.008 is peep's own figure, unconverted
1291
- // because a fraction has no units. The hem is this shirt's `bot`/`botc`
1292
- // landmark at y 1700, the same anchor peep uses (its `swellPivot` is the
1293
- // bottom edge of its own torso path, y 950).
1294
- export const BODY_LIVE = {
1295
- leanScale: 0.055, // torsoLean 1 scales the figure by this much...
1296
- leanTravel: 36.54, // ...and drops it this far (23 peep units)
1297
- leanPivot: [P.cx, P.chinY - 58.78],
1298
- shrugLift: 47.66, // both shoulders at 1 lift the torso this far
1299
- shrugTiltDeg: 1.6, // one shoulder at 1 rolls it this many degrees
1300
- shrugPivot: [P.cx, P.chinY + 322.47],
1301
- turnPx: 25.42, // torsoTurn 1 slides the torso this far in x
1302
- yawPx: 4.45, // headYaw 1 drags the torso this far as well
1303
- rollDeg: 2.45, // headRoll 1 rolls the torso this much, about
1304
- // HEAD_LIVE.pivot — 1.5/5.5 of the head's 9
1305
- breathSwell: 0.008, // breath 1 swells the torso by this fraction...
1306
- swellPivot: [P.cx, 1700], // ...about the shirt's hem (`bot`/`botc`)
1307
- };