@voqalize/avatar 0.4.4 → 0.5.1

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