@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,741 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // author/parts/mouth.mjs — the round idiom's mouth, as a PART, in CHANNEL SPACE.
3
- //
4
- // A PART is one feature of a face — a mouth, an eye — packaged so that the
5
- // avatar's build.mjs no longer holds its geometry. It has two APIs, and the
6
- // split between them is the whole idea:
7
- //
8
- // CONSTRUCTION (build time, once per persona)
9
- // makeMouth({ P, PALETTE, solid, group }) -> part
10
- // Everything that is a constant of THIS character: proportions, the
11
- // resolved palette, the persona's paint registry, the group tag. A second
12
- // persona is a second `makeMouth` with a second palette; they never share.
13
- //
14
- // DRIVING (once per control vector, so once per pose)
15
- // part.draws(c, L, env) -> [ { slot, group, cmds, paint, a, ...extra }, ... ]
16
- // An ORDERED array — draw order IS paint order and the part owns it. `c` is
17
- // the control vector, `L` the landmarks the avatar solved for that vector,
18
- // `env` anything else a caller needs to hand in (nothing, for this mouth).
19
- //
20
- // REST BLOCKS
21
- // mouthRestChannels() -> the `mouth:` sub-object THIS part implements.
22
- // mouthRest() -> the LEGACY nine-key block, kept because
23
- // `REST_CONTROLS` is shared with two avatars whose
24
- // mouths are still inline (see below).
25
- //
26
- // The fixed-opcode rule (author/path.mjs) is the part's contract with the
27
- // runtime: every shape here is emitted by a primitive whose opcode count
28
- // depends on the point COUNT and never on the point VALUES, at a point count
29
- // that is a literal. So a pose is a straight rebuild-and-diff (author/rig.mjs,
30
- // `poseHarness`) and topology can never drift — which is why a part gets its
31
- // poses for free and does not carry any of its own.
32
- //
33
- // ---------------------------------------------------------------------------
34
- // THE DRIVER BLOCK (voqalize `PoseChannel`, the 10 mouth channels)
35
- //
36
- // channel rest range meaning (voqalize params.js / avatar.d.ts)
37
- // open 0.02 0..1 VISIBLE aperture height — the dark gap, not
38
- // the lip-centreline distance
39
- // width 0.42 0..1 corner-to-corner span
40
- // round 0.10 0..1 pucker: rounds AND narrows the aperture
41
- // press 0.15 0..1 lips thinned and pressed together
42
- // tuck 0.0 0..1 lower lip drawn under the upper teeth (F/V)
43
- // cornerL/R 0.0 -1.4..1.4 per-side corner lift, + = smile
44
- // teeth 0.0 0..1 upper-teeth reveal
45
- // tongue 0.0 0..1 tongue raised into the aperture
46
- // c.jaw 0.0 0..1 chin drop — TOP-LEVEL, not in this block,
47
- // because the avatar's own landmark solve
48
- // reads it (the whole jaw silhouette moves).
49
- // The mouth reads it too: the lower lip
50
- // follows the chin by JAW_FOLLOW of the drop.
51
- // voqalize derives it as jaw = 0.7 * open.
52
- //
53
- // PART-LOCAL EXTRAS — driver params with no counterpart in the contract, kept
54
- // because a shape here needs them and documented as this part's own:
55
- // tongueUp 0..1 tongue POSITION, not amount: 0 behind the lower teeth,
56
- // 1 up at the upper lip. voqalize has one scalar `tongue`,
57
- // so the `th` viseme (the tongue between the teeth) has
58
- // nowhere else to live. Lerped, never branched on, so it
59
- // interpolates like everything else.
60
- // c.lipFull an IDENTITY morph (`morph/lips_±100`), not a performance
61
- // channel; it stays on the control vector.
62
- //
63
- // WHAT `upRaise` AND `cornerY` BECAME. `upRaise` (px, raised the upper lip's
64
- // inner edge) is gone into `press`, which thins BOTH lips, flattens the bow and
65
- // straightens the seam — a shape change rather than a translation, because
66
- // A-vs-X has to be topological (see the fidelity note). `cornerY` (one px
67
- // value, + = down, both corners) split into `cornerL`/`cornerR` in the
68
- // contract's own sign and units (+ = smile = up), which is what finally lets
69
- // this face smirk.
70
- //
71
- // THE LEGACY BLOCK. `author/rig.mjs`'s `REST_CONTROLS` is shared by all three
72
- // avatars, and `avatars/{facet,ink}/build.mjs` still hold their mouths inline
73
- // against the OLD nine keys (`m.w`, `m.open` in px, `m.protrude`, …). So
74
- // `mouthRest()` still returns those, `REST_CONTROLS` is untouched, and the one
75
- // avatar that has adopted the part composes its own vector:
76
- //
77
- // const ctrl = makeCtrl({ ...REST_CONTROLS, mouth: mouthRestChannels() });
78
- //
79
- // When ink grows `mouth-ink.mjs` it does the same and `mouthRest()` goes.
80
- //
81
- // ---------------------------------------------------------------------------
82
- // FIDELITY — where the numbers below come from
83
- //
84
- // `parts/ref/mouth/v2-high.png` is nine drawn Rhubarb visemes; its
85
- // `.measure.json` is those drawings as numbers, normalised by each cell's own
86
- // corner span. `MAP` is fitted to them (`parts/README.md` for the schema,
87
- // avatars/round/NOTES.md for the residuals). What the sheet taught, in order
88
- // of how much geometry it moved:
89
- //
90
- // * The inner opening is NOT the corner-to-corner gap. It is its own pair of
91
- // arcs, inset from the corners by an amount that is mostly a function of
92
- // `round` — 0.73 of the mouth's width at B, 0.95 at D, 0.12 at F. The old
93
- // mouth ran its lip edges corner to corner, so every open viseme showed an
94
- // aperture the full width of the mouth and F could not pucker at all.
95
- // * The upper lip has a cupid's bow with two peaks and a philtrum dip about
96
- // 13% of the lip's thickness deep, tapering to nothing at the corners; the
97
- // lower lip is ~15% thicker than the upper at rest and stays fuller as the
98
- // mouth opens (the sheet's `lip_th_ratio` is 0.86 at X and 0.70 at D).
99
- // * Both lips thin as the mouth opens (foreshortening) and thicken under
100
- // `round` (the pucker bunches them).
101
- // * The teeth are a row of roughly CONSTANT height — `teeth_reveal` is 0.95 at
102
- // B and 0.08 at D not because the row grows but because the hole does.
103
- //
104
- // And three things the sheet could not tell us, authored by hand from the
105
- // channel semantics instead:
106
- // * G is a second B in the drawing (the model will not draw F/V), so G is
107
- // authored from the tuck: the lower lip rides up under the teeth, the row
108
- // overlaps it, and there is no dark gap left at all.
109
- // * A and X are one drawing. They differ here by `press` alone: thinner lips,
110
- // a flatter bow, a straighter seam and a slightly narrower mouth.
111
- // * The sheet's mouth WIDTH was pinned by the prompt, so width comes from the
112
- // channel (voqalize's own `26 + 32·width`, normalised), not from the sheet.
113
- // ---------------------------------------------------------------------------
114
-
115
- import { eqp, polygon, spline, openSpline, arc, circle, shift, band, bulge, contours } from '../path.mjs';
116
- import { clamp, lerp, drawPusher } from '../rig.mjs';
117
-
118
- // The `mouth:` block of the control vector, at rest — voqalize's REST, verbatim,
119
- // plus this part's one extra. Sixteen visemes and nine Rhubarb letters are
120
- // patches on these ten numbers and nothing else.
121
- export function mouthRestChannels() {
122
- return {
123
- open: 0.02, width: 0.42, round: 0.10, press: 0.15, tuck: 0,
124
- cornerL: 0, cornerR: 0, teeth: 0, tongue: 0,
125
- tongueUp: 0,
126
- };
127
- }
128
-
129
- // The pre-channel block. Only `author/rig.mjs`'s REST_CONTROLS calls this, and
130
- // only because facet and ink still read those keys out of it. See the header.
131
- export function mouthRest() {
132
- return {
133
- w: 1, open: 0, cornerY: 0, upRaise: 0, protrude: 0,
134
- teeth: 0, tongue: 0, tongueUp: 0, lowTuck: 0,
135
- };
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // THE FRONT DOOR: channel units -> design-space pixels.
140
- //
141
- // Every number that converts a 0..1 channel into a distance on this
142
- // character's face is here and nowhere else. The ones marked FIT were chosen
143
- // by least squares against the reference sheet (`parts/fit-mouth.mjs`); the
144
- // rest are style, and say so.
145
- // ---------------------------------------------------------------------------
146
- export const MAP = {
147
- // --- corner span ---------------------------------------------------------
148
- // voqalize's own width law (`w = 26 + 32·mouthWidth`, face-peep.js),
149
- // normalised so that width = 0.42 is exactly this character's `P.mouthHalfW`.
150
- // STYLE: the sheet pinned its corners, so it has nothing to say here.
151
- WIDTH_A: 0.659, WIDTH_B: 0.811,
152
- // voqalize's peep pulls the corners in by 0.36 per unit of round. The sheet
153
- // disagrees and it can be checked: its F is the most puckered mouth in the
154
- // set and still 0.84 of X's width. At 0.36 this face's F was 0.50 of rest,
155
- // and a lower lip 21 units thick inside a 96-unit mouth stopped reading as a
156
- // lip and started reading as a kite. FIT to `width_vs_rest`, F and E.
157
- ROUND_NARROW: 0.18, // pucker pulls the corners in
158
- PRESS_NARROW: 0.30, // pressed lips are a little narrower
159
-
160
- // --- the aperture --------------------------------------------------------
161
- // FIT says 147.4 — the sheet's D is 0.688 of its own corner span. STYLE CAP
162
- // at 105: this face has 190 design units between the nose base and the chin,
163
- // and a 125-unit hole put the mouth interior on the jawline. A deliberate
164
- // -29% deviation from the sheet, and the largest one here.
165
- APER_PX: 105, // visible aperture height at open = 1
166
- AH_ROUND: 0.565, // FIT: a pucker closes the hole vertically too
167
- APER_UP: 0.34, // STYLE: share of it that opens UPWARD. The rest
168
- // goes down, and the jaw takes the corners with it.
169
- APER_POW: 0.80, // STYLE: <1 fattens the lens toward a rectangle
170
- // aperture WIDTH, in units of the REST corner span. FIT.
171
- AW_0: 0.685, AW_W: 0.296, AW_O: 0.138, AW_ROUND: 0.909,
172
- AW_MAX: 0.94, // never wider than this share of the corner span
173
-
174
- // --- corners -------------------------------------------------------------
175
- // STYLE: peep travels 0.61 of its half-span per unit of corner. This face
176
- // carries its corners only 10 units above the opening, and 0.61 put a smile
177
- // in the cheek; 0.33 keeps a +0.3 smile at the 9 px the old `cornerY` used.
178
- CORNER_PX: 32,
179
- CORNER_MID: 0.5, // how much of the corner lift the centre follows
180
-
181
- // --- lip mass ------------------------------------------------------------
182
- // FIT says 0.587 / 0.504 — the sheet's D keeps 0.59 of its upper lip and 0.73
183
- // of its lower. STYLE CAP at 0.35 / 0.30: the sheet's lips are twice this
184
- // character's to begin with (0.166 of the corner span against round's 15/192
185
- // = 0.078), so the same RATIO leaves a 6 px rim where the sheet still has a
186
- // lip. Faithful to the sheet's ratio, this face's D reads as a hole in a chin.
187
- TH_OPEN_U: 0.35, TH_OPEN_L: 0.30, // lips thin as the mouth opens
188
- TH_POW: 0.5, // FIT: …fast at first, then flattening
189
- TH_ROUND_U: 0.084, TH_ROUND_L: 0.046, // FIT: the pucker bunches them
190
- PRESS_THIN: 0.70, // STYLE: the sheet drew A as X, so press is ours
191
- PRESS_STRAIGHT: 1.9, // STYLE: press pulls the seam onto the corner chord
192
- PRESS_BOW: 0.9, // STYLE: press flattens the cupid's bow
193
- BOW: 0.14, // FIT: bow depth as a share of upper-lip thickness
194
- ROUND_RING: 0.85, // STYLE: how far a full pucker pushes the lips' mass
195
- RING_X: [-1, -0.87, -0.42, 0, 0.42, 0.87, 1], // ...out toward these stations
196
- LIPFULL_U: 0.55, LIPFULL_L: 0.60, // the identity morph, unchanged
197
-
198
- // --- teeth, tongue, tuck -------------------------------------------------
199
- TEETH_PX: 24.7, // FIT: height of the upper row at teeth = 1
200
- TEETH_W: 0.82, // its width, as a share of the aperture's
201
- TEETH_FILL: 0.82, // how much of a closed-ish aperture it may fill
202
- TUCK_RISE: 0.85, TUCK_MIN: 6, // lower aperture edge rides up under tuck
203
- TUCK_OVER: 28, // …and the teeth row is allowed that far past it
204
- TUCK_THIN: 0.50, // the tucked lower lip is rolled in, so thinner
205
- TONGUE_RISE: 0.78, // how far up the aperture the tongue comes at 1
206
- TONGUE_W: 0.72, // its width, as a share of the aperture's
207
- // The tongue is drawn BEHIND the lips but it is not CLIPPED by them, so its
208
- // ellipse has to be bounded by the aperture and not by the aperture's height:
209
- // sized off `(dUp+dLo)` alone, D's disc hung ~90 px below a lower lip only 11
210
- // px thick and read as a pink chin. It may reach this far past the lower arc
211
- // and no further, which is just enough for the lip to cover the seam.
212
- TONGUE_BOT_PAD: 2,
213
- // …and its opacity floor: at `tongue` .15 (Rhubarb D) a visible tongue is a
214
- // second lip, so the ramp starts at .10 and still saturates by .56 (H is .9).
215
- TONGUE_A0: 0.10, TONGUE_AK: 2.2,
216
-
217
- // --- the jaw -------------------------------------------------------------
218
- JAW_FOLLOW: 0.24, // share of P.jawDrop the lower lip takes with it
219
- SEAM_FADE: [14, 10], // the seam is gone this many px of aperture later
220
-
221
- // --- expression: what `cornerL/R` and `press` do BESIDES moving a corner --
222
- // The corner channels are the mixer's only mouth-side expression, and a
223
- // corner that only translates reads as a mouth with a hinge. A real smile is
224
- // a composite: the corners go up AND out, the lip line CURVES between them
225
- // (flat through the middle, turning at the ends), the upper lip stretches
226
- // thin over it, the lower lip rolls full, and the commissure pocket deepens.
227
- // A frown is not that composite reversed — the lower lip presses IN, the
228
- // corners draw slightly inward, and the pocket sharpens instead of deepening.
229
- // Every one of these is written so it is EXACTLY 1 (or 0) at cornerL/R = 0:
230
- // `mouthRestChannels()` still renders this character's rest pose to the byte.
231
- SMILE_OUT: 0.085, // corners travel outward, per unit of +corner
232
- FROWN_IN: 0.055, // …and inward on a frown
233
- SMILE_MID: 0.40, // the centre follows the corners LESS the harder
234
- // they pull — which is where the curvature comes from
235
- SEAM_FLAT: 0.50, // …and the lip line flattens in the middle as it does
236
- SMILE_BOWFLAT: 0.55, // the cupid's bow flattens as the upper lip stretches
237
- SMILE_THIN_U: 0.22, // …and the upper lip thins with it
238
- SMILE_FULL_L: 0.16, // the lower lip rolls full
239
- SMILE_LO_BOW: 0.9, // …and rounder: its profile stations bow out
240
- FROWN_THIN_L: 0.22, // a frown presses the lower lip in and thin
241
- CM_R: 0.50, // the commissure pocket deepens with a smile
242
- CM_WIDE: 0.22, // …and flattens, being stretched between the lips
243
- CM_SHARP: 0.35, // a frown makes it smaller
244
- CM_ASP: 0.45, // …and taller: a pinch rather than a pocket
245
- CM_DX: 0.55, // both ride further into the corner
246
- SMILE_SEAM: 0.55, // a smile is READ off the line between the lips, so
247
- // that line thickens as the corners travel: at this
248
- // size the corner displacement is already 1.7x
249
- // peep's and the thing peep beats us on is CONTRAST
250
- // — a 4.5-unit ribbon under a pink lip against a
251
- // 6-unit black stroke on white.
252
- PRESS_SEAM: 0.85, // press above REST widens the seam into a line
253
- PRESS_IN: 0.07, // …and draws the corners in
254
- };
255
-
256
- // The rest normalisers. Every law above is written in the contract's units and
257
- // then divided by its own value at REST, so `mouthRestChannels()` reproduces
258
- // this character's proportions exactly whatever the constants are — which is
259
- // what lets the fit move them without moving the rest pose.
260
- const R = mouthRestChannels();
261
- const widthFactor = (width, round, press) =>
262
- (MAP.WIDTH_A + MAP.WIDTH_B * width) * (1 - MAP.ROUND_NARROW * round) * (1 - MAP.PRESS_NARROW * press);
263
- const upFactor = (open, round, press) =>
264
- (1 - MAP.TH_OPEN_U * Math.pow(open, MAP.TH_POW)) * (1 + MAP.TH_ROUND_U * round) * (1 - MAP.PRESS_THIN * press);
265
- const loFactor = (open, round, press) =>
266
- (1 - MAP.TH_OPEN_L * Math.pow(open, MAP.TH_POW)) * (1 + MAP.TH_ROUND_L * round) * (1 - MAP.PRESS_THIN * press);
267
- const REST_W = () => widthFactor(R.width, R.round, R.press);
268
- const REST_U = () => upFactor(R.open, R.round, R.press);
269
- const REST_L = () => loFactor(R.open, R.round, R.press);
270
-
271
- // ---------------------------------------------------------------------------
272
- // THE FRAME: one control vector's worth of mouth, as numbers and point runs.
273
- // `draws` paints it and `mouthMetrics` measures it, so what the fit measures
274
- // is exactly what the rig draws.
275
- //
276
- // P keys read: mouthHalfW, lipUpTh, lipLowTh, jawDrop.
277
- // L keys read: mcorL, mcorR, mth_c.
278
- // c keys read: mouth.*, jaw, lipFull.
279
- // ---------------------------------------------------------------------------
280
- export function mouthFrame(P, c, L) {
281
- const m = c.mouth;
282
- const open = clamp(m.open, 0, 1), width = clamp(m.width, 0, 1);
283
- const round = clamp(m.round, 0, 1), press = clamp(m.press, 0, 1);
284
- const tuck = clamp(m.tuck, 0, 1), teeth = clamp(m.teeth, 0, 1);
285
- const tongue = clamp(m.tongue, 0, 1);
286
- const cL = clamp(m.cornerL, -1.4, 1.4), cR = clamp(m.cornerR, -1.4, 1.4);
287
- const jaw = clamp(c.jaw, 0, 1);
288
-
289
- const ml = L.mcorL, mr = L.mcorR;
290
- const mcx = (ml[0] + mr[0]) / 2;
291
- const restHW = (mr[0] - ml[0]) / 2;
292
- const hw = restHW * (widthFactor(width, round, press) / REST_W());
293
-
294
- // --- the expression axis, split into the four numbers the shape wants ----
295
- // Per side for everything that has a side (the asymmetry is the smirk), and
296
- // averaged for everything the whole lip line shares. `pAbs` is press ABOVE
297
- // its rest value, so a pressed mouth is a DEPARTURE from this face rather
298
- // than a state this face is already in.
299
- const cPosL = Math.max(0, cL), cPosR = Math.max(0, cR);
300
- const cNegL = Math.max(0, -cL), cNegR = Math.max(0, -cR);
301
- const cAvg = (cL + cR) / 2;
302
- const cPos = Math.max(0, cAvg), cNeg = Math.max(0, -cAvg), cMag = Math.abs(cAvg);
303
- const pAbs = Math.max(0, press - R.press);
304
-
305
- // The corner span, now per side: a smile widens the mouth as it lifts, a
306
- // frown narrows it, and a press draws both corners in. Each factor is
307
- // exactly 1 at rest, so `hwL === hwR === hw` there.
308
- const hwL = hw * (1 + MAP.SMILE_OUT * cPosL - MAP.FROWN_IN * cNegL - MAP.PRESS_IN * pAbs);
309
- const hwR = hw * (1 + MAP.SMILE_OUT * cPosR - MAP.FROWN_IN * cNegR - MAP.PRESS_IN * pAbs);
310
- // …so the normalised run coordinate is per side too.
311
- const fx = (x) => (x < mcx ? (x - mcx) / hwL : (x - mcx) / hwR);
312
-
313
- // Corners, one per side: + is a lift, and screen y grows downward.
314
- const cyL = ml[1] - cL * MAP.CORNER_PX;
315
- const cyR = mr[1] - cR * MAP.CORNER_PX;
316
- const chordMid = (cyL + cyR) / 2;
317
- const chord = (x) => chordMid + fx(x) * (cyR - cyL) / 2;
318
-
319
- // The closed-lip line. It runs corner to corner through a centre that sits
320
- // BELOW the corners — which is this character's resting smile — and `press`
321
- // pulls it back onto the straight chord between them.
322
- // The centre follows the corners only PART of the way, and the harder they
323
- // pull the less of it it follows: at CORNER_MID flat the whole lip line just
324
- // translates, which is the hinge. Backing the follow off with `cMag` is what
325
- // opens the curve — the corners are 32 px up and the centre 5, so the line
326
- // between them has to bend.
327
- const myc = L.mth_c[1] - cAvg * MAP.CORNER_PX * (MAP.CORNER_MID - MAP.SMILE_MID * cMag)
328
- + jaw * P.jawDrop * MAP.JAW_FOLLOW * 0.5;
329
- const seamMid = myc + (chordMid - myc) * MAP.PRESS_STRAIGHT * press;
330
- const dip = seamMid - chordMid;
331
- // …and the PROFILE of that bend changes with it. A parabola peaks at the
332
- // centre and is the shape a hinge makes; a smile is flat through the middle
333
- // and turns in the last third. `(1-f^2)^p` with p < 1 is exactly that, and
334
- // p = 1 is the parabola we had, to the bit.
335
- const seamFlat = MAP.SEAM_FLAT * cPos;
336
- const seamY = (x) => {
337
- const f = fx(x), q = Math.max(0, 1 - f * f);
338
- return chord(x) + dip * (seamFlat > 0 ? Math.pow(q, 1 - seamFlat) : q);
339
- };
340
-
341
- // The aperture. Its width is a law of its own — the sheet's whole point.
342
- const awRel = (MAP.AW_0 + MAP.AW_W * width + MAP.AW_O * open) * (1 - MAP.AW_ROUND * round);
343
- const ahw = Math.max(2, Math.min(P.mouthHalfW * awRel, hw * MAP.AW_MAX));
344
- const h = MAP.APER_PX * open * (1 - MAP.AH_ROUND * round);
345
- const dUp = h * MAP.APER_UP;
346
- // Tuck pulls the lower edge up under the teeth, and may take it past the
347
- // upper one — a tucked lip leaves no dark gap at all, which is the whole
348
- // difference between G and B.
349
- const dLo = Math.max(2 - dUp, h * (1 - MAP.APER_UP) - tuck * (h * MAP.TUCK_RISE + MAP.TUCK_MIN))
350
- + jaw * P.jawDrop * MAP.JAW_FOLLOW * 0.5;
351
-
352
- // The lens profile: 1 at the centre, 0 at ±ahw, fattened by APER_POW.
353
- const lens = (x) => {
354
- const f = clamp((x - mcx) / ahw, -1, 1);
355
- return Math.pow(Math.max(0, 1 - f * f), MAP.APER_POW);
356
- };
357
- const innerUp = (x) => seamY(x) - dUp * lens(x);
358
- const innerLo = (x) => seamY(x) + dLo * lens(x);
359
-
360
- // The nine x stations every corner-to-corner run is sampled at: the corner,
361
- // the midpoint of the closed run, the aperture's own five, and back. A
362
- // literal count, at every control value, which is the fixed-opcode rule.
363
- const XS = [-1, -0.5, 0, 0.5, 1].map((f) => mcx + f * ahw);
364
- const RUN = [mcx - hwL, mcx - (hwL + ahw) / 2, ...XS, mcx + (hwR + ahw) / 2, mcx + hwR];
365
- const upperInner = RUN.map((x) => [x, innerUp(x)]);
366
- const lowerInner = RUN.map((x) => [x, innerLo(x)]);
367
- const apUpper = XS.map((x) => [x, innerUp(x)]);
368
- const apLower = XS.map((x) => [x, innerLo(x)]);
369
-
370
- // Lip mass. Thickness is measured at the centre line, exactly as the sheet
371
- // measures it, and both lips ride on the aperture's arcs.
372
- // A smile STRETCHES the upper lip over the teeth (thinner) and rolls the
373
- // lower one out (fuller); a frown presses the lower lip in against them.
374
- const thUp = P.lipUpTh * (1 + MAP.LIPFULL_U * c.lipFull)
375
- * (upFactor(open, round, press) / REST_U()) * (1 - MAP.SMILE_THIN_U * cPos);
376
- const thLo = P.lipLowTh * (1 + MAP.LIPFULL_L * c.lipFull)
377
- * (loFactor(open, round, press) / REST_L()) * (1 - MAP.TUCK_THIN * tuck)
378
- * (1 + MAP.SMILE_FULL_L * cPos - MAP.FROWN_THIN_L * cNeg);
379
-
380
- // The cupid's bow: two peaks BOW deeper than the philtrum dip, tapering to
381
- // nothing at the corners. `press` flattens it toward a plain arc.
382
- const bowAmp = MAP.BOW * (1 - MAP.PRESS_BOW * press) * (1 - MAP.SMILE_BOWFLAT * cPos);
383
-
384
- // A pucker is not just a narrow mouth: the lips BUNCH, so their mass sits out
385
- // near the commissures instead of tapering away from the centre. Without this
386
- // the sheet's F comes out a WEDGE — a flat bar over a V — because a 20 px lip
387
- // hung on a 60 px half-width has no room to arc, and a wedge does not read as
388
- // lips at 1x. `ring` slides the profile STATIONS outward as `round` climbs, so
389
- // the mass rounds out while both ends still close to a point at the corner —
390
- // raising the corner FACTOR instead grows fins there, which is worse. It moves
391
- // x only, never the centre-line thickness the sheet measures, so the fit is
392
- // untouched.
393
- const ring = MAP.ROUND_RING * round;
394
- const stations = (xs) => xs.map((f, i) =>
395
- mcx + (f + (MAP.RING_X[i] - f) * ring) * (i < 3 ? hwL : i > 3 ? hwR : hw));
396
-
397
- const UP_F = [0, 0.74, 1 + bowAmp, 1, 1 + bowAmp, 0.74, 0];
398
- const UP_X = stations([-1, -0.62, -0.23, 0, 0.23, 0.62, 1]);
399
- const upperOuter = UP_X.map((x, i) => [x, innerUp(x) - thUp * UP_F[i]]);
400
-
401
- // …and the lower lip's own profile rounds out with the smile, which is the
402
- // difference between a full lip and a thicker one.
403
- const loB = clamp(MAP.SMILE_LO_BOW * cPos, 0, 1);
404
- const LO_F = [0, 0.62 + 0.16 * loB, 0.94 + 0.05 * loB, 1, 0.94 + 0.05 * loB, 0.62 + 0.16 * loB, 0];
405
- const LO_X = stations([-1, -0.62, -0.28, 0, 0.28, 0.62, 1]);
406
- const lowerOuter = LO_X.map((x, i) => [x, innerLo(x) + thLo * LO_F[i]]);
407
-
408
- // The teeth row: a near-constant height hanging off the upper arc. What
409
- // changes with the viseme is the hole it hangs in, not the row.
410
- const tw = ahw * MAP.TEETH_W;
411
- const tH = Math.min(MAP.TEETH_PX * teeth,
412
- Math.max(3, (dUp + dLo) * MAP.TEETH_FILL + tuck * MAP.TUCK_OVER));
413
- const TX = [-1, -0.55, 0, 0.55, 1].map((f) => mcx + f * tw);
414
- const teethTop = TX.map((x) => [x, innerUp(x) + 1.5]);
415
- const teethBot = TX.map((x, i) => [x, innerUp(x) + 1.5 + tH * [0.25, 0.88, 1, 0.88, 0.25][i]]);
416
-
417
- // The tongue: one rounded mass, rising out of the lower arc — or up at the
418
- // upper lip when `tongueUp` says so (the `th` viseme).
419
- const tgLow = innerLo(mcx) - MAP.TONGUE_RISE * (dUp + dLo) * tongue;
420
- const tgUp = innerUp(mcx) + 6;
421
- const tgTop = lerp(tgLow, tgUp, clamp(m.tongueUp, 0, 1));
422
- const tgRx = Math.max(8, ahw * MAP.TONGUE_W);
423
- // Bounded by the hole, not by the hole's height: the natural radius is
424
- // `(dUp+dLo)·0.55 + 8`, but the ellipse's BOTTOM is then pinned to the lower
425
- // arc (plus TONGUE_BOT_PAD) whenever that is nearer, so no viseme can hang a
426
- // pink disc below the lower lip.
427
- const tgBot = innerLo(mcx) + MAP.TONGUE_BOT_PAD;
428
- const tgRy = Math.max(6,
429
- (Math.min(tgBot, tgTop + 2 * ((dUp + dLo) * 0.55 + 8)) - tgTop) / 2);
430
-
431
- return {
432
- m, open, width, round, press, tuck, teeth, tongue, jaw,
433
- mcx, hw, hwL, hwR, ahw, h, dUp, dLo, cyL, cyR, seamY, restHW,
434
- cPosL, cPosR, cNegL, cNegR, pAbs,
435
- upperInner, lowerInner, apUpper, apLower, upperOuter, lowerOuter,
436
- thUp, thLo, tw, tH, teethTop, teethBot, tgTop, tgRx, tgRy,
437
- cornerL: [mcx - hwL, cyL], cornerR: [mcx + hwR, cyR],
438
- };
439
- }
440
-
441
- // The sheet's own measurements, taken off the frame instead of off a drawing.
442
- // Lengths are in units of the REST corner span (`2 · P.mouthHalfW`), which is
443
- // what makes them comparable with the sheet's numbers once those are
444
- // un-normalised by each cell's `width_vs_rest` — the sheet's per-cell width is
445
- // framing discipline, not anatomy (parts/README.md).
446
- export function mouthMetrics(P, F) {
447
- const W = 2 * P.mouthHalfW;
448
- const area = (pts) => {
449
- let a = 0;
450
- for (let i = 0; i < pts.length; i++) {
451
- const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length];
452
- a += x0 * y1 - x1 * y0;
453
- }
454
- return Math.abs(a) / 2;
455
- };
456
- const apRing = [...F.apUpper, ...F.apLower.slice().reverse()];
457
- const apArea = area(apRing);
458
- const teethRing = [...F.teethTop, ...F.teethBot.slice().reverse()];
459
- // The row is clipped by the hole it hangs in, so what shows is the overlap.
460
- const shown = Math.min(area(teethRing), apArea + F.tuck * area(teethRing));
461
- const oh = F.dUp + F.dLo, ow = 2 * F.ahw;
462
- return {
463
- open_h: oh / W,
464
- open_w: oh > 3 ? ow / W : 0,
465
- round_ratio: oh > 3 && ow > 0 ? oh / ow : 0,
466
- upper_lip_th: F.thUp / W,
467
- lower_lip_th: F.thLo / W,
468
- lip_th_ratio: F.thUp / F.thLo,
469
- teeth_reveal: apArea > 1 ? clamp(shown / apArea, 0, 1) : 0,
470
- corner_span: 2 * F.hw / W,
471
- };
472
- }
473
-
474
-
475
- // ---------------------------------------------------------------------------
476
- // The mouth has the same two idioms the eye does (parts/README.md § Two idioms):
477
- // a flat-fill face models with shapes, an outlined one models with shapes AND a
478
- // pen over them. `pen` is the whole of the second idiom — omit it and nothing
479
- // below changes — and `marks` turns the individual features on and off.
480
- //
481
- // pen { paint, lip, aper, sep, cap, join } null = no outline anywhere
482
- // `lip` the outer lip contour, one closed ring over both fills
483
- // `aper` the inner lip line round the aperture, alpha-ramped with
484
- // `open` because a stroke width is not a pose channel
485
- // `sep` the tooth separators, drawn as strokes instead of quads
486
- // marks { bow, gum, lipHiFade, lipHiAlpha, bowAlpha, lipCastAlpha,
487
- // toothSep, toothSepA, toothSh, seam, seamAlpha, commiss,
488
- // commissAlpha }
489
- // ---------------------------------------------------------------------------
490
-
491
- /** Which of the mouth's marks exist, and at what strength. */
492
- export const MOUTH_MARKS = {
493
- bow: true, // the cupid's-bow highlight over the upper lip
494
- gum: false, // a gum arc over the top of the teeth row
495
- lipHiFade: 0, // how much `round` fades the lower-lip highlight
496
- lipHiAlpha: 1, // material strength; geometry and timing stay fixed
497
- bowAlpha: 1,
498
- lipCastAlpha: 0, // a soft lower-lip shadow, disabled for legacy rigs
499
- toothSep: [-0.36, 0.05, 0.42], // separator stations, in units of the row's half-width
500
- toothSepA: 0.9, // …and their share of the row's alpha
501
- toothSh: { depth: 5, a: 0.55, blend: 'multiply' }, // the upper lip's cast shadow
502
- seam: [-1.5, 3.0], // the seam ribbon's two offsets off its own run
503
- seamAlpha: 1,
504
- commiss: { r: 0.028, dx: 0.030, blend: 'multiply' }, // the corner pockets
505
- commissAlpha: 1,
506
- philtrum: null, // an optional quiet two-plane neutral-mouth cue
507
- };
508
-
509
- export function makeMouth({ P, PALETTE, solid, group = 'head', marks = {}, pen = null }) {
510
- const HEAD = group;
511
- const M = { ...MOUTH_MARKS, ...marks };
512
- const nib = (w) => ({ w, cap: (pen && pen.cap) || 'round', join: (pen && pen.join) || 'round' });
513
- const INK = pen ? solid(pen.paint) : null;
514
-
515
- // PALETTE keys read: mouthIn, teeth, toothSep, toothSh, tongue, lipLow,
516
- // lipHi, lipUp, lipBow, lipCast, seam, commiss, philtrum.
517
- function draws(c, L, env = {}) {
518
- const out = [];
519
- const push = drawPusher(out);
520
- const F = mouthFrame(P, c, L);
521
- const { mcx, ahw, tw } = F;
522
-
523
- // ---- the hole ----------------------------------------------------------
524
- // Its own two arcs now, not a diamond between the corners: the lips close
525
- // over it OUTSIDE ±ahw, so the aperture's width is a channel and not an
526
- // accident of where the corners are.
527
- push('mouthIn', HEAD, spline([
528
- ...F.apUpper, ...F.apLower.slice().reverse().slice(1, -1),
529
- ], 0.8), solid(PALETTE.mouthIn));
530
-
531
- // ---- the tongue, behind the teeth and behind the lower lip -------------
532
- push('tongue', HEAD, spline(circle(mcx, F.tgTop + F.tgRy, F.tgRx, 8, F.tgRy / F.tgRx), 1),
533
- solid(PALETTE.tongue), clamp((F.tongue - MAP.TONGUE_A0) * MAP.TONGUE_AK, 0, 1));
534
-
535
- // A broad, quiet cast shadow makes the lower lip belong to the muzzle
536
- // rather than float as a coloured symbol. It follows the live lower-lip
537
- // curve, so speech keeps the same topology, closure and timing.
538
- if (M.lipCastAlpha > 0) {
539
- const castTop = F.lowerOuter.slice(1, 6).map(([x, y]) => [x, y + 3]);
540
- push('lipCast', HEAD, band(
541
- castTop,
542
- bulge(castTop, 8, { floor: 2, power: 0.8, pin: true }),
543
- 0.86,
544
- ), solid(PALETTE.lipCast), M.lipCastAlpha);
545
- }
546
-
547
- // ---- the lower lip -----------------------------------------------------
548
- // Fuller than the upper, and it rides on the aperture's lower arc, so it
549
- // travels with every viseme without a number of its own.
550
- push('lipLow', HEAD, spline([
551
- ...F.lowerOuter, ...F.lowerInner.slice().reverse().slice(1, -1),
552
- ], 0.9), solid(PALETTE.lipLow));
553
-
554
- // The lower lip carries the brightest value in a portrait: it is the one
555
- // surface here that tilts up into the light. ~34% of the mouth's width,
556
- // centred, sitting between the lip's inner and outer edges so it travels
557
- // with every viseme.
558
- const loIn0 = F.lowerInner[4][1], loOut0 = F.lowerOuter[3][1];
559
- const hiY = loIn0 + (loOut0 - loIn0) * 0.50;
560
- const hiRx = F.hw * 0.34, hiRy = Math.max(2, (loOut0 - loIn0) * 0.26);
561
- // A rounded viseme both NARROWS the mouth and thickens the lip, which turns
562
- // a 2:1 highlight band into a disc floating in a tall keyhole — the pink
563
- // pill an outlined face's `viseme/U` was reviewed for. `lipHiFade` takes it
564
- // out as the mouth rounds, which is also the physics: a specular lives on a
565
- // lip pointing AT the light, and a protruded one does not.
566
- push('lipHi', HEAD, spline(circle(mcx, hiY, hiRx, 7, hiRy / hiRx), 1), solid(PALETTE.lipHi),
567
- M.lipHiAlpha * clamp(1 - M.lipHiFade * F.round, 0, 1));
568
-
569
- // ---- teeth: ONE band, never individual teeth ---------------------------
570
- // Painted AFTER the lower lip and BEFORE the upper one, which is the whole
571
- // of the F/V tuck: at tuck = 1 the row is allowed past the aperture's lower
572
- // edge and lands ON the lower lip, so G shows teeth resting on a lip and B
573
- // shows teeth over a dark sliver. Two topologies, not two amplitudes.
574
- // Reaching full opacity by teeth = 0.4 matters: at 0.8 over the near-black
575
- // mouth interior the band renders grey and the smile reads as a grimace.
576
- // …and the row all but goes out when the tongue is UP against it: `108 - L`
577
- // and `116 - th` are the same channels except for `tongueUp`, and with a
578
- // full-strength band in front of it the raised tongue is invisible, so the
579
- // two visemes were one drawing. The tongue is BEHIND the teeth in the paint
580
- // order and stays there; what changes is how much of the band is left.
581
- const tA = clamp(F.teeth * 2.6, 0, 1) * (1 - 0.75 * clamp(F.m.tongueUp, 0, 1));
582
- push('teeth', HEAD, band(F.teethTop, F.teethBot, 1), solid(PALETTE.teeth), tA);
583
-
584
- // The gum arc, for a style that wants one. It rides the row's own top edge
585
- // and is deliberately a hair OUTSIDE it — an inner edge leaves a 1-2 unit
586
- // rim of bare enamel tracing the top of the band and the inner upper lip,
587
- // the brightest, sharpest mark in the mouth, and the whole of why a white
588
- // row reads as a glow at 1x. Deeper than the cast shadow below, or it reads
589
- // as grey rather than as gum.
590
- if (M.gum) {
591
- const gTop = shift(F.teethTop, -1.2);
592
- push('gum', HEAD, band(gTop, gTop.map(([x, y]) => [x, y + 1.2 + F.tH * 0.34]), 1),
593
- solid(PALETTE.gum), tA);
594
- }
595
-
596
- // Three separators, spaced non-uniformly so the band does not read as a
597
- // barcode, and stopping short of the lower edge. One draw, three contours,
598
- // and `polygon` rather than a near-straight spline: each is a 3px-wide quad
599
- // that is ~2 device px at 1x, so four beziers per corner bought nothing but
600
- // 57 opcodes in every one of the 21 mouth poses.
601
- const sepRun = (f) => {
602
- const x = mcx + tw * f;
603
- const y0 = F.seamY(x) - F.dUp * Math.pow(Math.max(0, 1 - ((x - mcx) / ahw) ** 2), MAP.APER_POW) + 3;
604
- return [x, y0, y0 + F.tH * 0.88];
605
- };
606
- if (pen && pen.sep) {
607
- // An outlined face draws its gaps with the same pen as everything else;
608
- // three ticks, mid-point included so the run is a fixed 3 points.
609
- push('toothSep', HEAD, contours(
610
- ...M.toothSep.map((f) => {
611
- const [x, y0, y1] = sepRun(f);
612
- return openSpline([[x, y0], [x, (y0 + y1) / 2], [x, y1]], 0.9);
613
- }),
614
- ), INK, tA * M.toothSepA, { stroke: nib(pen.sep) });
615
- } else {
616
- push('toothSep', HEAD, contours(
617
- ...M.toothSep.map((f) => {
618
- const [x, y0, y1] = sepRun(f);
619
- return polygon([[x - 1.6, y0], [x + 1.6, y0], [x + 1.3, y1], [x - 1.3, y1]]);
620
- }),
621
- ), solid(PALETTE.toothSep), tA * M.toothSepA);
622
- }
623
-
624
- // The upper lip's cast shadow across the top of the band, doing the gum
625
- // line's job too (see PALETTE.toothSh).
626
- push('toothSh', HEAD, band(F.teethTop, F.teethTop.map(([x, y]) => [x, y + M.toothSh.depth]), 1),
627
- solid(PALETTE.toothSh), tA * M.toothSh.a,
628
- M.toothSh.blend ? { blend: M.toothSh.blend } : undefined);
629
-
630
- // ---- the upper lip, with a bow ----------------------------------------
631
- push('lipUp', HEAD, spline([
632
- ...F.upperOuter, ...F.upperInner.slice().reverse().slice(1, -1),
633
- ], 0.85), solid(PALETTE.lipUp));
634
-
635
- // cupid's bow: the ridge over the two peaks, as a highlight rather than a
636
- // line. The dip at the centre is already in `upperOuter`.
637
- if (M.bow) {
638
- const bowTop = F.upperOuter.slice(1, 6);
639
- push('lipBow', HEAD, band(bowTop, bowTop.map(([x, y]) => [x, y + 4.5]), 1),
640
- solid(PALETTE.lipBow), M.bowAlpha);
641
- }
642
-
643
- // The philtrum is not a pair of drawn-on lines. It is two very shallow
644
- // planes that narrow into the cupid's-bow dip: enough material change to
645
- // connect nose and mouth in a neutral listening frame, quiet enough to
646
- // disappear into every speech shape. `L.philt` is face-space while the
647
- // lower endpoint comes from this frame's live upper lip, so the cue never
648
- // hangs across an opened or smiling mouth.
649
- if (M.philtrum) {
650
- const { a = 0.5, w = 14 } = M.philtrum;
651
- const [px, py] = L.philt;
652
- const bottom = Math.max(py + 6, F.upperOuter[3][1] - 3);
653
- const plane = (side) => polygon([
654
- [px + side * w * 0.50, py], [px + side * w * 0.16, py + 2],
655
- [px + side * w * 0.22, bottom], [px + side * w * 0.52, bottom - 2],
656
- ]);
657
- push('philtrum', HEAD, contours(plane(-1), plane(1)), solid(PALETTE.philtrum), a);
658
- }
659
-
660
- // ---- the pen, over both fills -----------------------------------------
661
- // The outer lip contour: ONE closed ring round the whole mouth rather than
662
- // an outline per lip, so the two lips do not each get a line where they
663
- // meet — the seam below is what says that, tapered, and a pair of full-
664
- // weight strokes there is the "even-weight line running over the tapered
665
- // wedge" an outlined mouth gets reviewed for.
666
- if (pen && pen.lip) {
667
- push('lipInk', HEAD, spline([
668
- ...F.upperOuter, ...F.lowerOuter.slice().reverse().slice(1, -1),
669
- ], 0.85), INK, 1, { stroke: nib(pen.lip) });
670
- }
671
-
672
- // The inner lip line. It exists to run round an OPENING, and it is only
673
- // that once there is one: as the mouth shuts the ring's two sides walk into
674
- // each other and a round-capped stroke collapses into a blunt black lens
675
- // heavier than the silhouette. Width is not a pose channel, so alpha is the
676
- // only way to say "this line is not there" — off at a closed aperture, full
677
- // by APER_PX·0.55, crossfading against the seam's own fade-out.
678
- if (pen && pen.aper) {
679
- push('aperInk', HEAD, spline([
680
- ...F.apUpper, ...F.apLower.slice().reverse().slice(1, -1),
681
- ], 0.8), INK, clamp((F.dUp + F.dLo - 4) / (MAP.APER_PX * 0.55), 0, 1),
682
- { stroke: nib(pen.aper) });
683
- }
684
-
685
- // ---- the seam ----------------------------------------------------------
686
- // The line between the lips: thickest dead centre, tapering to nothing at
687
- // both corners. The taper is the whole point — a uniform line reads as a
688
- // cut. It fades out as the aperture opens, because past a few px of gap the
689
- // thing between the lips is the mouth, not a seam.
690
- // It also has a job the taper does not advertise: `lipUp`'s lower edge and
691
- // `lipLow`'s upper edge are two Catmull-Rom curves through nearly the same
692
- // points at different tensions, and they bow apart by a px or two either
693
- // side of centre, showing a hairline of FACE colour between the lips. The
694
- // seam straddles that hairline instead of sitting on it.
695
- // 4.5 units wide, biased downward (-1.5 / +3.0) so it sits mostly in the
696
- // shadow under the upper lip; `power` 0.9 makes the taper toward the
697
- // corners a real taper instead of a step up to full width in the first
698
- // tenth of the run.
699
- // `press` above REST widens the ribbon: a pressed mouth is a LINE, and the
700
- // thinning the press already does to both lips leaves nothing between them
701
- // to say so. Keyed on `press - REST.press` so this face at rest is not a
702
- // face already pressing its lips, and so the closed visemes (A at 0.55, G
703
- // at 0.40) get it too — which is the same shape for the same reason.
704
- const seamRun = arc(eqp(F.cornerL), [mcx, F.seamY(mcx) + 1], eqp(F.cornerR), 4);
705
- const seamAt = (dy) => bulge(seamRun, dy, { power: 0.9 });
706
- const [s0, s1] = MAP.SEAM_FADE;
707
- const seamW = 1 + MAP.PRESS_SEAM * F.pAbs + MAP.SMILE_SEAM * Math.max(0, (F.cPosL + F.cPosR) / 2);
708
- push('seam', HEAD, band(seamAt(M.seam[0] * seamW), seamAt(M.seam[1] * seamW), 0.9),
709
- solid(PALETTE.seam), M.seamAlpha * clamp((s0 - (F.dUp + F.dLo)) / s1, 0, 1));
710
-
711
- // Commissures: the shadow pockets the corners of a real mouth pinch into.
712
- // Without them the mouth stays flat however well the lips are shaded.
713
- // Two dots of radius ~2.7 units — under 2 device px at 1x — so they are
714
- // polygons: a 7-point closed spline apiece was 106 opcodes / 639 bytes in
715
- // every one of the 21 mouth poses to draw something smaller than the
716
- // rounding of its own coordinates.
717
- // …and they are where the corner channel finishes its sentence. A smile
718
- // DEEPENS the pocket and stretches it flat between the lips; a frown makes
719
- // it small and tall — a pinch, not a pocket. Both ride further into the
720
- // corner as the corner travels. It is geometry and not alpha because the
721
- // two pockets are ONE draw (one alpha for both sides would kill the
722
- // asymmetry that is the whole point of having cornerL and cornerR) and
723
- // because this mark is already opaque at rest: there is no headroom above
724
- // 1 to deepen into. Radius, aspect and offset have headroom both ways.
725
- const pocket = [[F.cornerL, 1, F.cPosL, F.cNegL], [F.cornerR, -1, F.cPosR, F.cNegR]];
726
- push('commiss', HEAD, contours(
727
- ...pocket.map(([p, d, cp, cn]) =>
728
- polygon(circle(
729
- p[0] + d * F.hw * M.commiss.dx * (1 + MAP.CM_DX * (cp + cn)),
730
- p[1] + 1,
731
- F.hw * M.commiss.r * (1 + MAP.CM_R * cp - MAP.CM_SHARP * cn),
732
- 5,
733
- 0.9 * (1 - MAP.CM_WIDE * cp + MAP.CM_ASP * cn)))),
734
- ), solid(PALETTE.commiss), M.commissAlpha,
735
- M.commiss.blend ? { blend: M.commiss.blend } : undefined);
736
-
737
- return out;
738
- }
739
-
740
- return { rest: mouthRestChannels(), draws, frame: (c, L) => mouthFrame(P, c, L) };
741
- }