@voqalize/avatar 0.4.2 → 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 (191) hide show
  1. package/LICENSE-APACHE-2.0 +201 -0
  2. package/README.md +11 -98
  3. package/assets/README.md +28 -17
  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 +4 -0
  10. package/client/supports.ts +6 -7
  11. package/client/three/assets.ts +2 -0
  12. package/client/three/budgets.ts +1 -1
  13. package/client/three/{tara-rig.ts → character-rig.ts} +673 -545
  14. package/client/three/createCharacter.ts +109 -0
  15. package/client/three/holds.ts +1 -1
  16. package/client/three/internal.ts +4 -3
  17. package/client/three/motion-limits.json +5 -1
  18. package/client/three/tanvi-asset.ts +6 -0
  19. package/client/three/tanvi.ts +30 -0
  20. package/client/three/tanya.ts +18 -58
  21. package/client/three/tara.ts +19 -60
  22. package/client/three/tess.ts +17 -59
  23. package/client/three/tushar.ts +18 -55
  24. package/dist/internal.d.ts +1 -1
  25. package/dist/internal.d.ts.map +1 -1
  26. package/dist/internal.js +5 -1
  27. package/dist/internal.js.map +1 -1
  28. package/dist/supports.d.ts +6 -7
  29. package/dist/supports.d.ts.map +1 -1
  30. package/dist/supports.js +6 -7
  31. package/dist/supports.js.map +1 -1
  32. package/dist/three/assets.d.ts +1 -0
  33. package/dist/three/assets.d.ts.map +1 -1
  34. package/dist/three/assets.js +2 -0
  35. package/dist/three/assets.js.map +1 -1
  36. package/dist/three/budgets.d.ts +1 -1
  37. package/dist/three/budgets.js +1 -1
  38. package/dist/three/character-rig.d.ts +366 -0
  39. package/dist/three/character-rig.d.ts.map +1 -0
  40. package/dist/three/{tara-rig.js → character-rig.js} +629 -523
  41. package/dist/three/character-rig.js.map +1 -0
  42. package/dist/three/createCharacter.d.ts +60 -0
  43. package/dist/three/createCharacter.d.ts.map +1 -0
  44. package/dist/three/createCharacter.js +84 -0
  45. package/dist/three/createCharacter.js.map +1 -0
  46. package/dist/three/holds.js +1 -1
  47. package/dist/three/holds.js.map +1 -1
  48. package/dist/three/internal.d.ts +3 -3
  49. package/dist/three/internal.d.ts.map +1 -1
  50. package/dist/three/internal.js +2 -2
  51. package/dist/three/internal.js.map +1 -1
  52. package/dist/three/motion-limits.json +5 -1
  53. package/dist/three/tanvi-asset.d.ts +7 -0
  54. package/dist/three/tanvi-asset.d.ts.map +1 -0
  55. package/dist/three/tanvi-asset.js +7 -0
  56. package/dist/three/tanvi-asset.js.map +1 -0
  57. package/dist/three/tanvi.d.ts +24 -0
  58. package/dist/three/tanvi.d.ts.map +1 -0
  59. package/dist/three/tanvi.js +24 -0
  60. package/dist/three/tanvi.js.map +1 -0
  61. package/dist/three/tanya.d.ts +16 -28
  62. package/dist/three/tanya.d.ts.map +1 -1
  63. package/dist/three/tanya.js +15 -43
  64. package/dist/three/tanya.js.map +1 -1
  65. package/dist/three/tara.d.ts +17 -22
  66. package/dist/three/tara.d.ts.map +1 -1
  67. package/dist/three/tara.js +16 -45
  68. package/dist/three/tara.js.map +1 -1
  69. package/dist/three/tess.d.ts +15 -29
  70. package/dist/three/tess.d.ts.map +1 -1
  71. package/dist/three/tess.js +14 -44
  72. package/dist/three/tess.js.map +1 -1
  73. package/dist/three/tushar.d.ts +16 -25
  74. package/dist/three/tushar.d.ts.map +1 -1
  75. package/dist/three/tushar.js +15 -40
  76. package/dist/three/tushar.js.map +1 -1
  77. package/package.json +7 -50
  78. package/src/avatar.d.ts +2 -0
  79. package/src/avatar.js +87 -216
  80. package/src/gaze.js +1 -3
  81. package/src/idle.js +10 -2
  82. package/src/prosody.js +7 -3
  83. package/src/speech-timing.js +9 -0
  84. package/src/visemes.js +107 -13
  85. package/client/arjun.ts +0 -26
  86. package/client/createCanvasAvatar.ts +0 -72
  87. package/client/interviewer-female.ts +0 -4
  88. package/client/interviewer-male.ts +0 -4
  89. package/client/ishita.ts +0 -26
  90. package/client/kabir.ts +0 -26
  91. package/client/meera.ts +0 -26
  92. package/client/naina.ts +0 -26
  93. package/client/professional-female-a.ts +0 -4
  94. package/client/professional-female-b.ts +0 -4
  95. package/client/professional-male-a.ts +0 -4
  96. package/client/professional-male-b.ts +0 -4
  97. package/client/vikram.ts +0 -26
  98. package/dist/arjun.d.ts +0 -7
  99. package/dist/arjun.d.ts.map +0 -1
  100. package/dist/arjun.js +0 -20
  101. package/dist/arjun.js.map +0 -1
  102. package/dist/createCanvasAvatar.d.ts +0 -22
  103. package/dist/createCanvasAvatar.d.ts.map +0 -1
  104. package/dist/createCanvasAvatar.js +0 -47
  105. package/dist/createCanvasAvatar.js.map +0 -1
  106. package/dist/interviewer-female.d.ts +0 -4
  107. package/dist/interviewer-female.d.ts.map +0 -1
  108. package/dist/interviewer-female.js +0 -3
  109. package/dist/interviewer-female.js.map +0 -1
  110. package/dist/interviewer-male.d.ts +0 -4
  111. package/dist/interviewer-male.d.ts.map +0 -1
  112. package/dist/interviewer-male.js +0 -3
  113. package/dist/interviewer-male.js.map +0 -1
  114. package/dist/ishita.d.ts +0 -7
  115. package/dist/ishita.d.ts.map +0 -1
  116. package/dist/ishita.js +0 -20
  117. package/dist/ishita.js.map +0 -1
  118. package/dist/kabir.d.ts +0 -7
  119. package/dist/kabir.d.ts.map +0 -1
  120. package/dist/kabir.js +0 -20
  121. package/dist/kabir.js.map +0 -1
  122. package/dist/meera.d.ts +0 -7
  123. package/dist/meera.d.ts.map +0 -1
  124. package/dist/meera.js +0 -20
  125. package/dist/meera.js.map +0 -1
  126. package/dist/naina.d.ts +0 -7
  127. package/dist/naina.d.ts.map +0 -1
  128. package/dist/naina.js +0 -20
  129. package/dist/naina.js.map +0 -1
  130. package/dist/professional-female-a.d.ts +0 -4
  131. package/dist/professional-female-a.d.ts.map +0 -1
  132. package/dist/professional-female-a.js +0 -3
  133. package/dist/professional-female-a.js.map +0 -1
  134. package/dist/professional-female-b.d.ts +0 -4
  135. package/dist/professional-female-b.d.ts.map +0 -1
  136. package/dist/professional-female-b.js +0 -3
  137. package/dist/professional-female-b.js.map +0 -1
  138. package/dist/professional-male-a.d.ts +0 -4
  139. package/dist/professional-male-a.d.ts.map +0 -1
  140. package/dist/professional-male-a.js +0 -3
  141. package/dist/professional-male-a.js.map +0 -1
  142. package/dist/professional-male-b.d.ts +0 -4
  143. package/dist/professional-male-b.d.ts.map +0 -1
  144. package/dist/professional-male-b.js +0 -3
  145. package/dist/professional-male-b.js.map +0 -1
  146. package/dist/three/tara-rig.d.ts +0 -423
  147. package/dist/three/tara-rig.d.ts.map +0 -1
  148. package/dist/three/tara-rig.js.map +0 -1
  149. package/dist/vikram.d.ts +0 -7
  150. package/dist/vikram.d.ts.map +0 -1
  151. package/dist/vikram.js +0 -20
  152. package/dist/vikram.js.map +0 -1
  153. package/src/canvas/author/parts/eye.mjs +0 -722
  154. package/src/canvas/author/parts/hand.mjs +0 -1156
  155. package/src/canvas/author/parts/mouth.mjs +0 -741
  156. package/src/canvas/author/parts/nose.mjs +0 -100
  157. package/src/canvas/author/parts/skin-detail.mjs +0 -67
  158. package/src/canvas/author/path.mjs +0 -283
  159. package/src/canvas/author/rig.mjs +0 -405
  160. package/src/canvas/avatars/round/face.d.mts +0 -3
  161. package/src/canvas/avatars/round/face.mjs +0 -1307
  162. package/src/canvas/create-rig.d.ts +0 -15
  163. package/src/canvas/create-rig.js +0 -100
  164. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  165. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  166. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  167. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  168. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  169. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  170. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  171. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  172. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  173. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  174. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  175. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  176. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  177. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  178. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  179. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  180. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  181. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  182. package/src/canvas/data/interviewer-female.rig.json +0 -1
  183. package/src/canvas/data/interviewer-male.rig.json +0 -1
  184. package/src/canvas/data/professional-female-a.rig.json +0 -1
  185. package/src/canvas/data/professional-female-b.rig.json +0 -1
  186. package/src/canvas/data/professional-male-a.rig.json +0 -1
  187. package/src/canvas/data/professional-male-b.rig.json +0 -1
  188. package/src/canvas/src/live.js +0 -508
  189. package/src/canvas/src/render2d.js +0 -218
  190. package/src/canvas/src/rig.js +0 -297
  191. package/src/canvas/src/vocab.js +0 -96
@@ -1,1156 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // author/parts/hand.mjs — the SECONDARY GESTURE hand: one drawing, placed by
3
- // four numbers, that rises from under the bottom edge of the frame, holds
4
- // beside the face and drops back out.
5
- //
6
- // It is a PART in the sense ./README.md means, with one difference worth
7
- // saying out loud: every other part in this directory is a piece of a FACE and
8
- // is driven by the control vector's face channels. This one is not on the face
9
- // at all. It is the nearest object in the frame, it lives in FRAME space, and
10
- // the only thing that reaches it is a single `HandFrame`.
11
- //
12
- // CONSTRUCTION (build time, once per persona)
13
- // makeHand({ P, PALETTE, solid, frame, pen, group })
14
- // `frame` is the four numbers below, `PALETTE` supplies the skin rungs, so
15
- // the hand is the same character's hand as the face above it.
16
- //
17
- // DRIVING part.draws(h, env) -> the thirteen draws (twenty-six with a
18
- // `pen`: every silhouette gains a line), in paint order
19
- // REST handRest() -> the `hand:` block of the control vector; also
20
- // `part.rest`. At rest there is no gesture: every draw is at alpha
21
- // 0 AND parked below the frame, which is two independent reasons
22
- // for a hand not to be on screen and exactly one more than a rig
23
- // with a wardrobe, a pose blend and a live evaluator deserves.
24
- //
25
- // ---------------------------------------------------------------------------
26
- // THE DRIVER BLOCK. voqalize's `HandFrame`, verbatim, all three fields:
27
- //
28
- // field rest meaning
29
- // gesture null 'greet' | 'farewell' | 'approve' | 'wait', or null
30
- // / anything unknown, which is "no hand"
31
- // progress 0 0..1 through that gesture. The MIXER owns the
32
- // clock: it computes `(now - start) / dur` and we
33
- // never see a millisecond. `GESTURES[g].dur` is here
34
- // for documentation and for a host that wants to
35
- // drive the sweep at the upstream tempo.
36
- // side 'right' 'right' puts the hand on the VIEWER's right (the
37
- // character gesturing with its left hand), 'left' on
38
- // the viewer's left. A persistent per-avatar setting
39
- // upstream, not something a gesture chooses.
40
- //
41
- // Both ends of that are honest: the thumb splays AWAY from the body on either
42
- // side, because the drawing is mirrored rather than translated.
43
- //
44
- // ---------------------------------------------------------------------------
45
- // THE FOUR NUMBERS, and why they are the caller's and not ours.
46
- //
47
- // cx design x of the frame's centre
48
- // bottom design y of the frame's BOTTOM EDGE — the visible one, the
49
- // one `meta.artboard` + `meta.align` crop to, not the
50
- // artboard's
51
- // reach design units per ART unit: the hand's size
52
- // outboardLimit how far from `cx` ink may travel before it leaves the
53
- // frame through a SIDE edge
54
- //
55
- // A part cannot derive those. `bottom` is the camera window's, which is
56
- // metadata an avatar owns (avatars/round/face.mjs, CAMERA). The head height is
57
- // the avatar's own landmark measurement, so a wider camera does not make the
58
- // hand a different size. The avatar hands both in, and this file's geometry is
59
- // in ART units throughout — one multiply by `reach` on the way out.
60
- //
61
- // ---------------------------------------------------------------------------
62
- // WHERE THE DRAWING COMES FROM. Not from upstream's point tables, and that is
63
- // the second cut of this file rather than the first.
64
- //
65
- // The first cut copied voqalize's `hand.js` outlines verbatim, on the argument
66
- // that the same character has to wave in both renderers. What that argument
67
- // misses is that upstream draws every shape with a black ink contour, and a
68
- // contour carries an enormous amount of read: three lumps and a stub are a
69
- // hand once a line goes round them. Round has no outlines. The same three
70
- // lumps, filled flat, are a mitten — which is exactly what came back from the
71
- // first 1x judging, in those words.
72
- //
73
- // So the shapes here are MEASURED, the same way `parts/mouth` and `parts/eye`
74
- // are measured: `parts/sheet.py --kind hand` renders one 1536x1024 reference
75
- // sheet of three hands in a flat, front-lit, outline-free vocabulary, and
76
- // `parts/ref/hand/v1-medium.png` is traced into silhouette profiles — column
77
- // tops for the fingertips, row run-lengths for the finger boxes and the gaps,
78
- // the narrowest row of the lower third for the wrist, the crease and shade
79
- // components for the interior marks. Every number below is that trace, in ART
80
- // units, at
81
- //
82
- // 1 art unit = 1 reference-sheet pixel / 6.667 (PX_TO_ART, below)
83
- //
84
- // with the origin at the WRIST CENTRE of that cell and y NEGATIVE UP.
85
- //
86
- // The trace also settled the finger proportions, and settled them the way a
87
- // hand is actually read: not "each finger is 92% of the middle one's LENGTH",
88
- // which depends on where you decide the knuckle is, but "each fingertip stands
89
- // at 92% of the middle fingertip's HEIGHT ABOVE THE WRIST", which is what the
90
- // silhouette shows. Measured: index .924, middle 1, ring .944, little .806.
91
- //
92
- // ---------------------------------------------------------------------------
93
- // HOW BIG, and the one number in this file that is a compromise rather than a
94
- // measurement.
95
- //
96
- // The size is set against peep's hand — the same gesture, in the same demo, at
97
- // the same 1x toggle — because that is the hand a viewer has already seen, and
98
- // because that toggle sizes the two frames so BOTH HEADS measure 229 px across
99
- // (adapter/demo.html, ONE_X), which is the only thing that makes a pixel over
100
- // there comparable with a pixel over here.
101
- //
102
- // Measured rather than guessed: peep's hand group rasterised on its own,
103
- // alpha-boxed, sampled every 80 ms through greet. Through the hold it is
104
- // 130-132 wide by 80-88 tall; at the top of the hold, 131 x 85 CSS px.
105
- //
106
- // Those two numbers cannot both be matched, and the reason is anatomy rather
107
- // than tuning: peep's hand is a squat caricature, 1.54 wide for every 1 tall
108
- // of what the frame lets you see. A hand with five fingers on it is 0.67 wide
109
- // for every 1 tall — the trace says 430 x 644 — and no arrangement of five
110
- // fingers says otherwise. Matching the WIDTH would make ours half as tall
111
- // again as peep's whole hand; matching the HEIGHT would make ours two thirds
112
- // as wide, and at 1x a finger would be eight pixels across, which is where the
113
- // mitten came from.
114
- //
115
- // What is matched instead is the AREA of that bounding box — the ink on
116
- // screen, which is what "the same size hand" means to an eye, and which is
117
- // also the measure by which the first cut of this file was called 1.6x too
118
- // big. Ours comes out 99 x 112 against peep's 131 x 85: the same ink to within
119
- // half a percent, 24% narrower, 32% taller. That height is 1.18 of round's
120
- // chin-to-eyes distance (95 px at 1x) and 0.50 of its head — a hand, not a
121
- // head, which is the rule the size was asked to obey.
122
- //
123
- // ---------------------------------------------------------------------------
124
- // THE FOUR RULES the drawing obeys, which are upstream's and which are the
125
- // whole reason a hand with no arm reads as a hand with an arm:
126
- //
127
- // 1. THE WRIST NEVER ENTERS THE FRAME. Every silhouette runs to art y +300
128
- // and is cut by the bottom edge; the wrist line itself is always at least
129
- // ~24/576 of the frame width BELOW it. A lid drawn across the wrist is a
130
- // severed hand, and the eye reads a severed hand as a sticker.
131
- // The NARROWING is visible even though the narrowest point is not: each
132
- // `hold` below is solved so the crop falls 16-19 art units above the
133
- // wrist (hold / sc, the four of them 19.0 / 18.5 / 16.0 / 17.0), where the
134
- // heel has already lost a quarter of its width and the plane painted
135
- // across it has already turned it under. What continues below the crop is
136
- // the forearm.
137
- // 2. THE HAND IS BIG, but measured against peep and not against the frame —
138
- // see the section above. A correctly-scaled hand reads as a prop.
139
- // 3. WHAT RISES PAST THE FIST IS ONLY EVER A DIGIT, and nothing reaches the
140
- // chin. The stations came down when the drawing came down in size (a
141
- // smaller hand cannot hold its fingertips at the jaw AND keep its wrist
142
- // out of frame — the two constraints meet only at a hand this file no
143
- // longer draws), so the four holds land at .725 / .728 / .683 / .820 of
144
- // the way from the bottom edge to the chin, and the only marks that clear
145
- // the fist are the thumb of `approve` and the index of `wait`.
146
- // 4. ONLY THE BOTTOM EDGE CUTS THE HAND. `outboardLimit` is checked against
147
- // the shape's own width AND its rotation — a hand this tall swinging 16
148
- // degrees about a wrist below the frame throws its corner a long way
149
- // sideways, and the swing counts against the budget. `checkFraming()`
150
- // below is that check, run by the avatar's build.
151
- //
152
- // ---------------------------------------------------------------------------
153
- // WHAT WE DO NOT DO, all of it deliberate:
154
- //
155
- // * no arm above the forearm stub, no shoulder — see rule 1. The arm is
156
- // implied by the crop; drawing it would put a limb on a character that has
157
- // none.
158
- // * no idle hand. The hand exists for the length of one gesture and is not
159
- // on screen otherwise. There is no resting-hand pose to blend to.
160
- // * no clock. `progress` crosses the seam; the mixer owns start, duration,
161
- // interrupt and queue, exactly as upstream does.
162
- // * no per-gesture side. `side` is the avatar's, set once.
163
- // * no fingernails, no knuckle wrinkles, no palm lines beyond one. The
164
- // reference sheet was asked for none of them either: at 94 px across, the
165
- // marks that survive are the ones separating MASSES, and the rest reads as
166
- // dirt on the lens.
167
- //
168
- // THE ROUND IDIOM. Flat skin planes off the persona's own palette — `face` for
169
- // the lit mass (the hand is nearer the light than the neck behind it), an
170
- // OPAQUE rung below it for the planes turning away (see `paints()` for why it
171
- // may not be `PALETTE.shade`), `crease` for the interior marks. No outline:
172
- // `pen` is the hook the ink idiom takes (see below), and it is off unless
173
- // somebody asks for it. Which is why the SEPARATIONS here are tonal and not linear: a
174
- // thumb tucked at the side of a fist in the same flat skin as the fist is part
175
- // of the fist, so `point`'s folded thumb is drawn a rung darker — on the true
176
- // side of the form as well, since a thumb crossing a fist IS a plane of it
177
- // turning away. `approve`'s thumb is the exception and for the same reason
178
- // read the other way: it stands UP, clear of the fist against the background,
179
- // in the same light as everything else, and a darker one read as a bruise.
180
- //
181
- // THE INK IDIOM, which is `pen` and which is the same drawing with a line round
182
- // it. Ink's own numbers, not this file's: `PALETTE.ink` (rgb 25,35,51) at
183
- // W_SIL = 4.0 artboard units on every silhouette — the mass, the digits, the
184
- // raised thumb, the folded one — and W_FINE = 2.4 on the interior marks, which
185
- // are the same two weights avatars/ink/face.mjs gives the head's outline and
186
- // its jaw accent. A stroke width is a constant of a draw (`rig.js` copies it
187
- // from base to out and never blends it), so it does NOT shrink with the
188
- // gesture's depth `sc`: a pen has one nib and a hand pushed at the camera is
189
- // still drawn with it.
190
- //
191
- // What changes besides the outline, all of it because a line is not a tone:
192
- // * the interior marks are STROKED runs rather than filled tapered rings, so
193
- // an outlined palette is never asked for a `crease` rung (ink has none);
194
- // * the JOINT marks go, because the distal segment's own contour draws that
195
- // line already;
196
- // * the folded thumb of `point` moves from under the fist to on top of it, as
197
- // a line. Under it, it is invisible in both idioms — it clears the fist's
198
- // edge by under 1.5 art units — and round says "folded thumb" with the one
199
- // thing a flat style has, a darker rung. A pen says it with a contour.
200
- //
201
- // THE FIXED-OPCODE GUARANTEE. Every run in here has a length fixed at module
202
- // load; the per-frame work is an affine over points and then `spline`. The
203
- // three shapes have three different topologies, so all thirteen draws — 26
204
- // under a pen — are emitted every frame and the gesture chooses which are at
205
- // alpha 1. Nine of the thirteen (eighteen of the 26) are always at zero, which
206
- // costs the renderer that many comparisons and buys a display list that never
207
- // changes shape.
208
- // ---------------------------------------------------------------------------
209
-
210
- import { spline, openSpline, strip, contours, circle } from '../path.mjs';
211
- import { clamp, drawPusher } from '../rig.mjs';
212
-
213
- // --- the front door --------------------------------------------------------
214
-
215
- // Design units per ART unit at this character's 664-unit skull. Art units are
216
- // the reference sheet's pixels over 6.667 (PX_TO_ART), so this number and that
217
- // one together are the whole of "how big is the hand"; this is the half that
218
- // stays put when the drawing is re-traced.
219
- export const REACH_AT_664_HEAD = 6.455555555555555;
220
-
221
- // Rule 4's margin, in the same 576-wide frame units.
222
- export const SIDE_MARGIN = 8;
223
-
224
- // Rule 1, in ART units: how far below the frame's bottom edge the parked hand
225
- // sits. It has to clear the tallest shape at its own depth scale — `wait`'s
226
- // index finger, 93.75 * 0.75 = 70.3 — with room for the exit's rotation.
227
- export const HIDE = 80;
228
-
229
- // The reference sheet is 1536 x 1024 for three cells, so a cell is 512 wide and
230
- // the hand in it is 430 x 644 px. This is the only place those pixels become
231
- // art units, and the divisor is chosen so the placed hand's bounding box
232
- // matches peep's in AREA — see "HOW BIG" above. Nothing else knows the sheet
233
- // exists; the tables below are already converted.
234
- export const PX_TO_ART = 1 / 6.667;
235
-
236
- // Mark widths are quoted in upstream's SVG units, where the drawing was tuned;
237
- // this converts them to art units so they scale with `reach` like everything
238
- // else. Halved against the first cut, because the drawing they decorate is
239
- // half the width it was, and a crease that keeps its absolute weight while the
240
- // form under it halves stops being a crease and becomes a scar.
241
- const MARK_K = 1 / 4.80;
242
-
243
- /**
244
- * The four placement numbers, from the avatar's own camera window.
245
- * @param {{x:number,y:number,w:number,h:number}} win the VISIBLE rectangle in
246
- * design space — what `meta.artboard` + `meta.align` crop to.
247
- * @param {number} headHeight crown-to-chin height in the same design space.
248
- */
249
- export function handFrameOf(win, headHeight) {
250
- const k = headHeight / 664;
251
- return {
252
- cx: win.x + win.w / 2,
253
- bottom: win.y + win.h,
254
- reach: REACH_AT_664_HEAD * k,
255
- outboardLimit: win.w / 2 - SIDE_MARGIN * (win.w / 576),
256
- };
257
- }
258
-
259
- /** The `hand:` block of a control vector. */
260
- export function handRest() {
261
- return { hand: { gesture: null, progress: 0, side: 'right' } };
262
- }
263
-
264
- // ---------------------------------------------------------------------------
265
- // TWO GENERATORS, both run once at module load.
266
- //
267
- // They exist because a finger written out as twelve literal points is twelve
268
- // numbers nobody can check against a measurement, and there are nine fingers
269
- // and thumb segments in this file. Written as an axis and two widths, every one
270
- // of them can be read straight off the trace — and the point COUNT is a
271
- // constant, which is the whole of the fixed-opcode guarantee.
272
- // ---------------------------------------------------------------------------
273
-
274
- // A tapered digit with a rounded tip and a base meant to be buried in the mass
275
- // it grows from. 12 points, always.
276
- //
277
- // base/tip the axis, in art units
278
- // wb/wt width ACROSS the digit at the base and at the tip
279
- //
280
- // The cap is a half-circle of radius wt/2 about the tip, sampled at 0, 45 and
281
- // 90 degrees each side, which is what makes a fingertip round rather than
282
- // chamfered; the base runs 15% of the length PAST the base point so the join
283
- // into the palm has no shoulder to catch the eye.
284
- const digit = (base, tip, wb, wt) => {
285
- const dx = tip[0] - base[0], dy = tip[1] - base[1];
286
- const L = Math.hypot(dx, dy) || 1;
287
- const ux = dx / L, uy = dy / L; // along the digit, toward the tip
288
- const nx = -uy, ny = ux; // across it
289
- const at = (t, s) => [base[0] + ux * L * t + nx * s, base[1] + uy * L * t + ny * s];
290
- const w = (t) => (wb + (wt - wb) * t) / 2;
291
- const r = (wt / 2) / L; // the cap, as a fraction of the axis
292
- const W = w(1 - r), c = 0.7071;
293
- return [
294
- at(0, -w(0)), at(0.35, -w(0.35)), at(0.7, -w(0.7)), at(1 - r, -W),
295
- at(1 - r + r * c, -W * c), at(1, 0), at(1 - r + r * c, W * c), at(1 - r, W),
296
- at(0.7, w(0.7)), at(0.35, w(0.35)), at(0, w(0)), at(-0.15, 0),
297
- ];
298
- };
299
-
300
- // Force a run clockwise in the renderer's y-down space. Several contours of one
301
- // path are filled `nonzero`, so two runs that disagree about which way round
302
- // they go punch a hole where they overlap instead of merging — which is fine
303
- // until a thumb crosses a palm. Cheap, once, at load.
304
- const cw = (pts) => {
305
- let a = 0;
306
- for (let i = 0; i < pts.length; i++) {
307
- const p = pts[i], q = pts[(i + 1) % pts.length];
308
- a += p[0] * q[1] - q[0] * p[1];
309
- }
310
- return a < 0 ? pts.slice().reverse() : pts;
311
- };
312
-
313
- // ---------------------------------------------------------------------------
314
- // THE SHAPES, in ART units, y NEGATIVE UP, origin at the WRIST CENTRE.
315
- //
316
- // PALM the palmar view. A palm mass with the fingers rising out of it as
317
- // four SEPARATE runs, because the gaps between spread fingers are
318
- // BACKGROUND and not marks — that is the single biggest thing the trace
319
- // changed, and the reason five fingers can be counted at 1x. Plus a
320
- // two-segment thumb set low on the radial side, the thenar ball as a
321
- // shade blob, and one crease across the palm.
322
- // FIST the thumbs-up. Four curled fingers as a stack of bands separated by
323
- // three creases (the trace found them horizontal, which is the emoji's
324
- // own idiom and the reason a fist reads as a fist at any size), and a
325
- // two-segment thumb standing out of the top on the radial side, drawn
326
- // UNDER the fist so its base is swallowed.
327
- // POINT the SAME fist — literally the same point table — with the index
328
- // extended in two segments with a bend at the base knuckle, and the
329
- // thumb folded down the near side as a darker band.
330
- //
331
- // The last points before and after the two +300 tails are the wrist; the tails
332
- // are the forearm running off the bottom of the frame. Rule 1 lives there.
333
- // ---------------------------------------------------------------------------
334
-
335
- // -- PALM -------------------------------------------------------------------
336
- // Left edge -19.4 at the index base, the heel narrowing to a 13.5 half-width
337
- // wrist; right edge bulging to 22 at the little finger. Trace: 366 px across
338
- // the palm alone, 430 px including the thumb, 644 px tip to wrist.
339
- const PALM_MASS = cw([
340
- [-19.4, -53.5], [-19.6, -46], [-19.2, -38], [-18.3, -30], [-16.6, -18],
341
- [-14.8, -6], [-13.5, 0], [-14.6, 20], [-15.5, 300],
342
- [15.5, 300], [14.6, 20], [13.5, 0],
343
- [15.4, -7], [18.0, -16], [20.2, -28], [21.6, -40], [22.0, -50], [21.6, -54],
344
- [12, -54.5], [0, -55], [-10, -54.5],
345
- ]);
346
-
347
- // base x, tip x, tip y, base width, tip width. The base y is the same for all
348
- // four — 7.5 art below the palm's top edge, so no finger has a visible
349
- // shoulder — and the tips splay outward, which is what "slightly spread" is.
350
- const FINGERS = [
351
- [-15.0, -18.5, -89.10, 8.8, 7.0], // index .924 of the middle's height
352
- [-4.0, -5.5, -96.45, 8.6, 7.2], // middle 1
353
- [7.2, 7.6, -91.05, 8.8, 7.0], // ring .944
354
- [17.8, 20.8, -77.70, 7.4, 5.9], // little .806
355
- ].map(([bx, tx, ty, wb, wt]) => cw(digit([bx, -46], [tx, ty], wb, wt)));
356
-
357
- // The thumb, two segments about an interphalangeal joint at (-29.5, -39.5). It
358
- // is set 27 art units below the finger bases and swung 55 degrees off them,
359
- // which is the trace's own angle and is what stops it reading as a fifth
360
- // finger somebody shortened.
361
- const PALM_THUMB = [
362
- cw(digit([-15, -19], [-29.5, -39.5], 13.5, 10.5)),
363
- cw(digit([-28, -37], [-37.5, -51.5], 10.5, 8.0)),
364
- ];
365
-
366
- // The thenar ball: the one place a palm is not flat. Not a region of the
367
- // silhouette but a bulge INSIDE it, and not a circle either — it is the muscle
368
- // that works the thumb and it lies ALONG the thumb, so it is built with the
369
- // same rounded-capped helper the digits are and pointed the same way. It runs
370
- // past the palm's edge at its upper end on purpose: the thumb is painted after
371
- // it and takes that end back.
372
- const PALM_THENAR = cw(digit([-8.5, -12], [-17.5, -31], 16, 13));
373
-
374
- // The distal crease, higher on the radial side, running out to the ulnar edge.
375
- const PALM_CREASE = [[-10.5, -39.4], [-3, -36.6], [5, -33.6], [12.4, -31.8]];
376
- // The thumb's joint, across its axis.
377
- const PALM_THUMB_JOINT = [[-32.7, -36.9], [-29, -39.5], [-25.3, -42.1]];
378
-
379
- // -- FIST -------------------------------------------------------------------
380
- // 36 art across against the palm's 41, and 55 tall to the top of the knuckles.
381
- const FIST_MASS = cw([
382
- [-14.5, -52], [-16.6, -43.5], [-16.4, -32.5], [-15.8, -21], [-14.9, -13.5],
383
- [-13.4, -6], [-12.7, 0], [-14.0, 20], [-15.0, 300],
384
- [15.0, 300], [14.0, 20], [12.7, 0],
385
- [14.6, -6], [17.6, -13.5], [19.1, -21], [19.6, -32.5], [19.6, -43.5],
386
- // The knuckle row. Four rises with three shallow dips between them, 1.5 art
387
- // deep — about two pixels at 1x, which is nothing there and is exactly what
388
- // stops the top edge reading as a dome at 3x.
389
- [17.9, -51], [15.0, -53.6], [11.8, -52.4], [8.6, -55.6], [4.6, -54.2],
390
- [0.6, -56.4], [-3.4, -54.9], [-7.4, -55.2], [-11.0, -53.6],
391
- ]);
392
-
393
- // The raised thumb, and the two numbers that matter are the ones that make it
394
- // NOT the pointing index. First cut had it 25 art above the knuckles, 8.8 wide,
395
- // rising from the middle of the fist — which is the description of an index
396
- // finger, and at 1x `approve` and `wait` were the same picture. A thumb is
397
- // SHORT and THICK and it comes off the SIDE: 21 art of clearance against the
398
- // index's 38, 12.5 wide against 8, and its base is over the fist's thumb flank
399
- // (negative x, the same side `point` folds its thumb down) rather than the
400
- // centre. The tip leans a little further out than the base, which is the last
401
- // of it: a thumb's axis is not the forearm's.
402
- const FIST_THUMB = [
403
- cw(digit([-11.5, -44], [-13.2, -63], 15.5, 12.8)),
404
- cw(digit([-13.0, -60], [-14.6, -76], 12.6, 11.0)),
405
- ];
406
- const FIST_THUMB_JOINT = [[-19.2, -62.6], [-13.1, -61.8], [-7.0, -61.0]];
407
-
408
- // The three creases between the four curled fingers. Traced at y -43.0, -31.8
409
- // and -20.7, spanning x -7.9 to +15.8 — they stop well short of the silhouette
410
- // at both ends, which is what keeps them creases and not cuts.
411
- const FIST_BANDS = [
412
- [[-7.5, -43.6], [0, -43.1], [8, -42.7], [15.6, -42.6]],
413
- [[-7.5, -32.4], [0, -31.9], [8, -31.5], [15.6, -31.4]],
414
- [[-7.0, -21.3], [0, -20.8], [8, -20.4], [15.0, -20.3]],
415
- ];
416
-
417
- // -- POINT ------------------------------------------------------------------
418
- // The index, in two segments with a bend at the base knuckle: the proximal
419
- // leans out, the distal comes back. Trace: tip at -93.75, 9.3 wide at the base.
420
- const POINT_INDEX = [
421
- cw(digit([5.2, -48], [3.4, -70], 9.6, 8.0)),
422
- cw(digit([3.5, -68], [2.4, -93.75], 8.0, 6.6)),
423
- ];
424
- const POINT_INDEX_JOINT = [[-0.4, -69.4], [3.5, -69.2], [7.4, -69.0]];
425
- // The thumb, folded down the near side of the fist.
426
- const POINT_THUMB = cw(digit([-9.5, -26], [-10.5, -50], 10.5, 9.5));
427
- // The same three creases, started clear of the folded thumb.
428
- const POINT_BANDS = FIST_BANDS.map((r) => r.map(([x, y], i) => [i === 0 ? -4 : x, y]));
429
-
430
- // Mark width profiles, in upstream's units, sampled along the run 0..1.
431
- const W_CREASE = [2, 8, 2];
432
- const W_JOINT = [3, 7, 3];
433
- const W_BAND = [2.5, 6, 6, 2.5];
434
-
435
- // ---------------------------------------------------------------------------
436
- // THE KNUCKLE BANDS, AGAIN, FOR A PEN — and why the pen cannot have the flat
437
- // idiom's geometry here when it takes the flat idiom's geometry everywhere else.
438
- //
439
- // The three runs above are the trace's, and they are right for a TONE: round
440
- // fills them with a translucent crease rung, edge to edge across the fist, and
441
- // a tone that runs out at the silhouette has no ends to notice. Painted in the
442
- // pen's near-black at a constant weight they are three parallel rules across a
443
- // rounded form, they meet the silhouette line at both ends, and what a viewer
444
- // counts at 1x is lines and not fingers — "three ruled lines" was the 1x note
445
- // and it is the right note. Two of the three run within five art units of the
446
- // frame's own bottom cut, which is the second-crop-line problem `lowerRegion`
447
- // spends a paragraph on, arriving from the other direction.
448
- //
449
- // So the pen gets its own bands, and it is the only place in this file where an
450
- // idiom changes a MEASUREMENT rather than a paint. Four changes, all of them
451
- // the same idea — a crease is an EVENT on a form, not a division of it:
452
- //
453
- // * SHORTENED to the middle of the traced run, so a band starts and ends over
454
- // bare skin and never touches the outline it would otherwise cut;
455
- // * STAGGERED, longest at the index/middle end of the stack and shortest at
456
- // the row nearest the wrist, so three marks are not one ruled grid;
457
- // * BOWED toward the fingertips, which are DOWN the page in a curled fist —
458
- // the knuckle row is this shape's top edge — so each band sags where the
459
- // finger's own mass is thickest;
460
- // * TAPERED to nothing at both ends by the ribbon below, which is the other
461
- // half of "no ends to notice": round's marks always had that and a stroked
462
- // run, whose `lineWidth` is one scalar, could not.
463
- //
464
- // And `wait` drops the row nearest the wrist entirely. It sits under four art
465
- // units above the crop on that gesture — a horizontal a viewer reads against
466
- // the frame edge rather than against the hand — and `wait` is the gesture with
467
- // an index finger of its own to carry the read.
468
- //
469
- // The shortened band no longer reaches the fist's thumb flank, which retires
470
- // `POINT_BANDS` for the pen: the whole of that table was "the same three
471
- // creases, started clear of the folded thumb", and a band that stops at x -0.6
472
- // was never near it. The pen therefore takes FIST's bands on both shapes.
473
- const PEN_BAND_N = 5; // points per band
474
- const PEN_BAND_KEEP = [0.70, 0.60, 0.50]; // fraction of the traced run kept
475
- const PEN_BAND_BOW = 1.1; // art units of sag at mid-span
476
- // Width along a band and along the palm crease, as fractions of the pen's mark
477
- // width. Both end at 0: the ring closes to a point, which is the taper.
478
- const A_BAND = [0, 0.88, 1, 0.88, 0];
479
- const A_CREASE = [0, 1, 1, 0];
480
- // A ribbon that peaks at the nib's own width carries LESS ink than a stroke of
481
- // that width, because it is only that wide in the middle. The gain buys the
482
- // difference back at the one place it is read.
483
- const PEN_MARK_GAIN = 1.2;
484
- // …and the alpha, which is per SHAPE because the two kinds of interior mark in
485
- // this drawing are not the same kind of mark. See `penMarkA` in SHAPE.
486
- const A_ONE = 1; // one accent on a form: full ink, like `jawInk`
487
- const A_MANY = 0.62; // a repeated mark inside a silhouette: read after it
488
-
489
- // The point at arc-length fraction t along an open polyline. The bands are
490
- // four points of a near-straight run, so "the middle 58%" has to be measured
491
- // along the run and not counted in points.
492
- const alongRun = (pts, t) => {
493
- const seg = [];
494
- let total = 0;
495
- for (let i = 1; i < pts.length; i++) {
496
- const d = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
497
- seg.push(d);
498
- total += d;
499
- }
500
- let want = clamp(t, 0, 1) * total;
501
- for (let i = 0; i < seg.length; i++) {
502
- if (want <= seg[i] || i === seg.length - 1) {
503
- const u = seg[i] ? clamp(want / seg[i], 0, 1) : 0;
504
- return [pts[i][0] + (pts[i + 1][0] - pts[i][0]) * u,
505
- pts[i][1] + (pts[i + 1][1] - pts[i][1]) * u];
506
- }
507
- want -= seg[i];
508
- }
509
- return pts[pts.length - 1].slice();
510
- };
511
-
512
- const penBand = (run, keep, bow) => Array.from({ length: PEN_BAND_N }, (_, i) => {
513
- const u = i / (PEN_BAND_N - 1);
514
- const [x, y] = alongRun(run, 0.5 - keep / 2 + keep * u);
515
- return [x, y + bow * Math.sin(Math.PI * u)];
516
- });
517
-
518
- const PEN_BANDS = FIST_BANDS.map((r, i) => penBand(r, PEN_BAND_KEEP[i], PEN_BAND_BOW));
519
-
520
- // ---------------------------------------------------------------------------
521
- // Three more build-time helpers, for the interior marks and the shade plane.
522
- // ---------------------------------------------------------------------------
523
-
524
- // Sample a width profile at t in 0..1.
525
- const profile = (ws) => (t) => {
526
- if (ws.length === 1) return ws[0];
527
- const u = clamp(t, 0, 1) * (ws.length - 1);
528
- const i = Math.min(Math.floor(u), ws.length - 2);
529
- return ws[i] + (ws[i + 1] - ws[i]) * (u - i);
530
- };
531
-
532
- // Offset an OPEN run along its own normal. `bulge` in ../path.mjs offsets in y
533
- // only, which is right for a brow and wrong for a thumb joint that runs at 50
534
- // degrees; this is the same idea with the normal taken from the neighbours.
535
- // Fixed-opcode: n points in, n points out.
536
- const offsetRun = (pts, wAt) => {
537
- const n = pts.length;
538
- return pts.map(([x, y], i) => {
539
- const p = pts[Math.max(0, i - 1)], q = pts[Math.min(n - 1, i + 1)];
540
- const dx = q[0] - p[0], dy = q[1] - p[1], l = Math.hypot(dx, dy) || 1;
541
- const w = wAt(n === 1 ? 0 : i / (n - 1));
542
- return [x - (dy / l) * w, y + (dx / l) * w];
543
- });
544
- };
545
-
546
- // A tapered interior mark, as the closed ring `strip` wants: the run pushed off
547
- // both ways by half the profile. Widths arrive in ART units.
548
- const ribbon = (pts, ws) => {
549
- const half = profile(ws.map((w) => w / 2));
550
- return cw(strip(offsetRun(pts, half), offsetRun(pts, (t) => -half(t))));
551
- };
552
-
553
- // The same, for the flat idiom's marks, whose widths are quoted in upstream's
554
- // units because that is where they were tuned.
555
- const mark = (pts, ws) => ribbon(pts, ws.map((w) => w * MARK_K));
556
-
557
- // The part of a closed silhouette below a line, as its own closed run: the
558
- // silhouette's own points from where it crosses the line, round through the two
559
- // tails, back to where it crosses again, closed by a straight chord.
560
- //
561
- // This is the shade plane, and the line is TILTED rather than horizontal. Two
562
- // separate reasons, and the second one was learnt at 1x:
563
- //
564
- // * `side` mirrors the drawing in x, so a plane down one flank would be on
565
- // the shadow side for one hand and on the lit side for the other. A plane
566
- // across the BOTTOM is the same plane either way, and it is also the true
567
- // one: the heel of a hand held up in front of a face is the part turning
568
- // away from a light that is above it.
569
- // * but a straight horizontal edge, a dozen art units above the frame cut and
570
- // PARALLEL to it, is a second crop line. At 1x the first cut of this drawing
571
- // read as a hand resting on a shelf — two horizontals across a rounded form,
572
- // and the eye takes the pair for an object and its edge. `tilt` is how many
573
- // art units the line falls across a half-width; at 5 over 22 it is about 12
574
- // degrees off the crop, which is enough that the two lines stop rhyming and
575
- // the band reads as the heel turning under.
576
- //
577
- // Fixed-opcode: the two crossings and the chord's interior points are a fixed
578
- // count, and which silhouette points are below a CONSTANT line is fixed too.
579
- // `inset` is the one number that is not geometry: a closed Catmull-Rom rounds
580
- // the two corners where the chord meets the silhouette, and it rounds them
581
- // OUTWARD, which puts shade on the background beside the hand. Pulling the two
582
- // crossings toward each other along the chord moves those corners back inside
583
- // by more than the rounding takes them out. There is no clipping anywhere in
584
- // this pipeline, so this is the only place to fix it.
585
- const HALF_W = 22;
586
- const lowerRegion = (pts, ys, { inset = 2, mids = 3, tilt = 5 } = {}) => {
587
- const n = pts.length, at = (i) => pts[((i % n) + n) % n];
588
- const lineY = (x) => ys + (tilt * x) / HALF_W;
589
- const below = (p) => p[1] > lineY(p[0]);
590
- const cross = (a, b) => {
591
- const fa = a[1] - lineY(a[0]), fb = b[1] - lineY(b[0]);
592
- const t = fa / (fa - fb);
593
- return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
594
- };
595
- // Seed on a point that IS below the line before walking out from it. Index 0
596
- // is not it: every silhouette here starts at the TOP of the shape, so seeding
597
- // at 0 made both `cross` calls interpolate between two points on the same
598
- // side of the line, `t` came out far outside 0..1, and the chord shot off as
599
- // a hairline sliver across the shoulder. The deepest point is always below.
600
- let seed = 0;
601
- for (let i = 1; i < n; i++) if (pts[i][1] > pts[seed][1]) seed = i;
602
- let i0 = seed; while (below(at(i0 - 1))) i0--;
603
- let i1 = seed; while (below(at(i1 + 1))) i1++;
604
- const a0 = cross(at(i0 - 1), at(i0)), a1 = cross(at(i1), at(i1 + 1));
605
- const dx = Math.sign(a1[0] - a0[0]) * inset;
606
- const start = [a0[0] + dx, lineY(a0[0] + dx)], end = [a1[0] - dx, lineY(a1[0] - dx)];
607
- const run = [start];
608
- for (let i = i0; i <= i1; i++) run.push(at(i).slice());
609
- run.push(end);
610
- for (let j = 1; j <= mids; j++) {
611
- const t = j / (mids + 1);
612
- run.push([end[0] + (start[0] - end[0]) * t, end[1] + (start[1] - end[1]) * t]);
613
- }
614
- return cw(run);
615
- };
616
-
617
- // ---------------------------------------------------------------------------
618
- // The three shapes, as lists of runs BY PAINT. Runs that share a paint and do
619
- // not need anything between them share a draw, which is how a drawing with
620
- // nine digits in it still costs thirteen draws — and 26 with a pen, where a
621
- // run that overlaps another can no longer share one (`splitAt`, below).
622
- //
623
- // under painted first, a rung darker, so the mass swallows its base
624
- // lit the face rung: [ [mass], [digits] ]
625
- // shade the form plane, plus any bulge inside the outline
626
- // marks [run, width profile] pairs, all of them one crease-coloured draw
627
- //
628
- // The form line of each shape — the `lowerRegion` cut — is chosen against that
629
- // gesture's own hold, so the visible band is the lower third of what is above
630
- // the frame edge and its bottom runs off frame. A shade that ENDS at the crop
631
- // draws a second edge there and the crop stops reading as a crop.
632
- // ---------------------------------------------------------------------------
633
-
634
- // Where a group of runs stops being disjoint, and why only a PEN cares.
635
- //
636
- // Every run in a `lit` or `under` group shares one flat fill, so a flat idiom
637
- // can put all of them in one draw: overlapping fills of one colour have no
638
- // visible edge. An OUTLINED idiom cannot. The two segments of a thumb (or of
639
- // `wait`'s index) overlap at the joint by construction — each one's base runs
640
- // 15% past its base point so the join has no shoulder — and two contours over
641
- // one fill is two curves crossing where a hand has one crease. So the pen
642
- // splits such a group at the given index and paints the far half after the near
643
- // half's line, which is the same hidden-line removal the rest of this order is:
644
- // the distal segment's fill covers the proximal one's cap, and what is left is
645
- // one line across the digit.
646
- //
647
- // `null` is "one layer", which is what round asks for and what keeps its bytes.
648
- const splitAt = (runs, i) => (i == null ? [runs] : [runs.slice(0, i), runs.slice(i)]);
649
-
650
- // ...and the other half of the same problem, which `splitAt` cannot reach: a
651
- // digit does not only overlap the digit beside it, it grows OUT OF THE MASS.
652
- // Every base in this file is buried on purpose — `digit()` runs the base 15%
653
- // past its base point "so the join into the palm has no shoulder" — and a flat
654
- // idiom never sees it, because the buried end is the mass's own colour. A pen
655
- // draws it: the four fingers came out as four capsules with rounded bottoms
656
- // lying ON the palm, and `approve`'s thumb as a lobe with the fist's edge line
657
- // ruled straight through it. Both are the same false line, on either side of
658
- // the same boundary.
659
- //
660
- // So the pen strokes only the part of a run that is OUTSIDE the mass, and the
661
- // fills do the rest of the work in this order:
662
- //
663
- // mass fill -> shade -> MASS LINE -> digit fill -> DIGIT ARC
664
- //
665
- // The digit's fill covers the mass's line where the two overlap; the digit's
666
- // arc starts and ends exactly where the mass's line reappears, because both
667
- // endpoints are ON the mass's contour. What comes out is the union outline,
668
- // with no clipping anywhere and no boolean geometry — two draws in the right
669
- // order and a run cut at two points, once, at module load.
670
- //
671
- // Even-odd crossing test, and the first hit along a segment. Both are plain
672
- // O(n*m) on tables of at most 26 points, run once for nine runs.
673
- const inPoly = (p, poly) => {
674
- let hit = false;
675
- for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
676
- const [xi, yi] = poly[i], [xj, yj] = poly[j];
677
- if ((yi > p[1]) !== (yj > p[1])
678
- && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) hit = !hit;
679
- }
680
- return hit;
681
- };
682
-
683
- const segHit = (a, b, poly) => {
684
- const rx = b[0] - a[0], ry = b[1] - a[1];
685
- let best = null;
686
- for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
687
- const c = poly[j], d = poly[i];
688
- const sx = d[0] - c[0], sy = d[1] - c[1];
689
- const den = rx * sy - ry * sx;
690
- if (!den) continue;
691
- const t = ((c[0] - a[0]) * sy - (c[1] - a[1]) * sx) / den;
692
- const u = ((c[0] - a[0]) * ry - (c[1] - a[1]) * rx) / den;
693
- if (t >= 0 && t <= 1 && u >= 0 && u <= 1 && (best === null || t < best)) best = t;
694
- }
695
- return best === null ? null : [a[0] + rx * best, a[1] + ry * best];
696
- };
697
-
698
- // The arc of a closed run that lies outside `mass`, as an OPEN run with its two
699
- // ends interpolated onto the mass's boundary — or `null` for "nothing to cut",
700
- // which is both "wholly outside" (a distal segment, whose own base cap IS the
701
- // joint line and must stay) and "wholly inside" (`point`'s folded thumb, which
702
- // is a line laid on the fist on purpose). A run that leaves and re-enters more
703
- // than once is refused the same way, because none of these do and a silent
704
- // wrong answer is worse than the line it was meant to remove.
705
- //
706
- // Fixed-opcode: the tables are constants, so which vertices are outside is a
707
- // constant and so is the point count.
708
- const carve = (run, mass) => {
709
- const n = run.length;
710
- const out = run.map((p) => !inPoly(p, mass));
711
- const k = out.filter(Boolean).length;
712
- if (k === 0 || k === n) return null;
713
- let blocks = 0, i0 = -1;
714
- for (let i = 0; i < n; i++) if (out[i] && !out[(i + n - 1) % n]) { blocks++; i0 = i; }
715
- if (blocks !== 1) return null;
716
- let i1 = i0;
717
- while (out[(i1 + 1) % n]) i1++;
718
- const A = segHit(run[i0], run[(i0 + n - 1) % n], mass);
719
- const B = segHit(run[i1 % n], run[(i1 + 1) % n], mass);
720
- const arc = A ? [A] : [];
721
- for (let i = i0; i <= i1; i++) arc.push(run[i % n]);
722
- if (B) arc.push(B);
723
- return arc;
724
- };
725
-
726
- const SHAPE = {
727
- PALM: {
728
- under: [],
729
- lit: [[PALM_MASS], [...FINGERS, ...PALM_THUMB]],
730
- shade: [lowerRegion(PALM_MASS, -32), PALM_THENAR],
731
- marks: [[PALM_CREASE, W_CREASE], [PALM_THUMB_JOINT, W_JOINT, 'joint']],
732
- // Pen only: the same crease, the joint dropped (the distal segment's own
733
- // contour is that line), and the widths solved on the way out. See below.
734
- penMarks: [[PALM_CREASE, A_CREASE]], penMarkA: A_ONE,
735
- // Pen only: where the runs of a group stop being disjoint. See `splitAt`.
736
- split: { 1: 5 }, // the four fingers and the thumb's root, then its tip
737
- },
738
- FIST: {
739
- // The raised thumb is the whole of this gesture and it is in the SAME light
740
- // as the fist: `underLit`. It is still painted first so the mass swallows
741
- // its base — under, but not darker.
742
- under: FIST_THUMB, underLit: true,
743
- lit: [[FIST_MASS]],
744
- shade: [lowerRegion(FIST_MASS, -28)],
745
- marks: [[FIST_THUMB_JOINT, W_JOINT, 'joint'], ...FIST_BANDS.map((b) => [b, W_BAND])],
746
- penMarks: PEN_BANDS.map((b) => [b, A_BAND]), penMarkA: A_MANY,
747
- split: { under: 1 },
748
- },
749
- POINT: {
750
- // This thumb is tucked at the side of the fist rather than raised, so here
751
- // the darker rung is the point: it is the part of the hand turned away.
752
- under: [POINT_THUMB], underLit: false,
753
- lit: [[FIST_MASS], POINT_INDEX],
754
- shade: [lowerRegion(FIST_MASS, -28)],
755
- marks: [[POINT_INDEX_JOINT, W_JOINT, 'joint'], ...POINT_BANDS.map((b) => [b, W_BAND])],
756
- // Two bands, not three: the row nearest the wrist goes on this gesture.
757
- penMarks: PEN_BANDS.slice(0, 2).map((b) => [b, A_BAND]), penMarkA: A_MANY,
758
- split: { 1: 1 },
759
- },
760
- };
761
- for (const s of Object.values(SHAPE)) {
762
- s.markRuns = s.marks.map(([p, w]) => mark(p, w));
763
- // every point the shape owns, for checkFraming
764
- s.hull = [...s.under, ...s.lit.flat(), s.shade[0]].flat();
765
- // Pen only, and computed here rather than in `draws` because it is geometry
766
- // and geometry in this file is a constant: the arc of every digit and every
767
- // thumb segment that is not swallowed by the mass it grows from. `null` per
768
- // run means "stroke the closed run" — see `carve`.
769
- const mass = s.lit[0][0];
770
- s.penUnder = s.under.map((r) => (s.underLit ? carve(r, mass) : null));
771
- s.penLit = s.lit.map((grp, i) => (i === 0 ? grp.map(() => null) : grp.map((r) => carve(r, mass))));
772
- }
773
-
774
- // ---------------------------------------------------------------------------
775
- // THE GESTURES. Upstream's four timelines, reparametrised.
776
- //
777
- // Upstream keys in MILLISECONDS against its own duration, and its `dy` keys
778
- // are absolute distances below a bottom edge that is 200 units under peep's
779
- // chin. Neither survives a change of framing, so both were re-expressed:
780
- //
781
- // * the KEY is `progress`, the fraction of the gesture the mixer sends. So
782
- // `dur` is documentation and the shape of the motion is duration-free.
783
- // * `out` is in ART units — a multiple of the hand's own size — so the hand
784
- // sits the same distance from the head whatever the frame is.
785
- // * `dy` is a TRAVEL FRACTION `up`, from HIDE toward the gesture's `hold`:
786
- // `dy = HIDE - up * (HIDE - hold)`. That keeps everything the curve was
787
- // carrying — the ballistic placement (55-74% of the rise inside the first
788
- // 12% of the gesture), the ~2% overshoot and settle, the breathing hold,
789
- // the steeper exit — and lets `hold` be solved for OUR frame.
790
- //
791
- // `hold` is in art units and is solved from the CROP rather than from a
792
- // station: `hold = crop * sc`, where `crop` is how far above the wrist the
793
- // frame's bottom edge falls. Sixteen to eighteen art units up is where the heel
794
- // has already lost a fifth of its width, so the narrowing shows and the wrist
795
- // does not (rule 1). What comes out, as a fraction of the distance from the
796
- // bottom edge to the chin (464.5 design units on round), is rule 3:
797
- //
798
- // greet 0.725 fingertips at the neck, a clear head-height below the chin
799
- // farewell 0.728 the same, a hair higher, because it waves longer
800
- // approve 0.728 the fist at the neck, and the THUMB alone above it
801
- // wait 0.820 the index highest of the four, and still short of the chin
802
- //
803
- // Three of those four are the same number, and that is the floor talking: at
804
- // this size rule 1 binds before rule 3 does, so `hold` is not free to place
805
- // greet lower than approve any more. The only gesture with room above it is
806
- // `wait`, and it is the one that is supposed to have room.
807
- //
808
- // `sc` is depth: how far the hand is pushed toward the lens. A thumbs-up is
809
- // pushed at the camera and a wave is thrown out to the side, which is why
810
- // `approve` is the biggest and `greet` the smallest. Upstream's four ratios,
811
- // all four scaled by 0.96 — the last 4% of the area match against peep.
812
- //
813
- // `rot` is in degrees and is scale-free, so it is upstream's, unchanged. The
814
- // swings are what makes a wave a wave: three for greet, four for farewell,
815
- // and a near-still hold for the other two.
816
- // ---------------------------------------------------------------------------
817
-
818
- export const GESTURES = {
819
- greet: {
820
- dur: 1250, shape: 'PALM', sc: 0.67, hold: 12.73, swings: 3,
821
- out: [[0, 0], [0.12, 30.833], [0.24, 47.5], [0.80, 47.5], [1, 12.5]],
822
- up: [[0, 0], [0.12, 0.6399], [0.24, 1.0230], [0.312, 0.9847], [0.56, 1.0038],
823
- [0.80, 0.9885], [0.896, 0.8391], [1, 0]],
824
- rot: [[0, -3], [0.12, 2], [0.248, 16], [0.38, -2], [0.512, 16], [0.644, -1],
825
- [0.776, 12], [0.80, 8], [1, -3]],
826
- },
827
- farewell: {
828
- dur: 1550, shape: 'PALM', sc: 0.67, hold: 12.40, swings: 4,
829
- out: [[0, 0], [0.10968, 31.667], [0.20645, 48.333], [0.83871, 48.333], [1, 13.333]],
830
- up: [[0, 0], [0.10323, 0.6505], [0.20645, 1.0250], [0.26452, 0.9834],
831
- [0.45161, 1.0061], [0.64516, 0.9871], [0.83871, 0.9985], [0.91613, 0.8434], [1, 0]],
832
- rot: [[0, -3], [0.10968, 2], [0.21290, 16], [0.32903, -2], [0.44516, 16],
833
- [0.56129, -2], [0.67742, 16], [0.79355, -1], [0.83871, 8], [1, -3]],
834
- },
835
- approve: {
836
- dur: 1300, shape: 'FIST', sc: 0.79, hold: 12.64, swings: 0,
837
- out: [[0, 0], [0.12308, 22.5], [0.23077, 35], [0.80769, 35], [1, 13.333]],
838
- up: [[0, 0], [0.12308, 0.7421], [0.23077, 1.0204], [0.30, 0.9870],
839
- [0.53846, 1.0019], [0.80769, 0.9907], [0.89231, 0.8423], [1, 0]],
840
- rot: [[0, -6], [0.12308, -2], [0.23077, 3], [0.30769, 0], [0.53846, 1.5],
841
- [0.76923, 0], [0.80769, 0], [1, -8]],
842
- },
843
- wait: {
844
- dur: 1700, shape: 'POINT', sc: 0.75, hold: 12.75, swings: 0,
845
- out: [[0, 0], [0.10, 25], [0.18824, 40], [0.82353, 40], [1, 14.167]],
846
- up: [[0, 0], [0.10, 0.6257], [0.18824, 1.0277], [0.24118, 0.9860],
847
- [0.41176, 1.0011], [0.64706, 0.9879], [0.82353, 0.9973], [0.89412, 0.8305], [1, 0]],
848
- rot: [[0, -7], [0.10, -2], [0.18824, 2], [0.24706, 0], [0.47059, 1],
849
- [0.70588, -0.5], [0.82353, 0], [1, -9]],
850
- },
851
- };
852
-
853
- // The depth each SHAPE is drawn at, which is a property of the shape and not of
854
- // the gesture only because the two waves happen to agree: greet and farewell
855
- // are both PALM at 0.67, approve is FIST, wait is POINT. The pen needs it — a
856
- // filled ribbon scales with `sc` where a stroke's `lineWidth` does not, and the
857
- // mark has to come out the same weight on screen either way — so the agreement
858
- // is asserted here rather than assumed at the call site. A fifth gesture that
859
- // reused a shape at a new depth would fail the build, which is the right place
860
- // to find out.
861
- const SHAPE_SC = {};
862
- for (const [name, g] of Object.entries(GESTURES)) {
863
- if (SHAPE_SC[g.shape] != null && SHAPE_SC[g.shape] !== g.sc) {
864
- throw new Error(`hand: ${name} draws ${g.shape} at depth ${g.sc}, not ${SHAPE_SC[g.shape]}`);
865
- }
866
- SHAPE_SC[g.shape] = g.sc;
867
- }
868
-
869
- // Smoothstep between keys, upstream's easing. Linear between two keys of a
870
- // ballistic curve is what an arc-less rig looks like: the hand arrives, stops
871
- // dead, and reads as a sprite being placed.
872
- const smoothstep = (t) => t * t * (3 - 2 * t);
873
-
874
- export function sample(keys, t) {
875
- if (t <= keys[0][0]) return keys[0][1];
876
- const last = keys[keys.length - 1];
877
- if (t >= last[0]) return last[1];
878
- for (let i = 1; i < keys.length; i++) {
879
- if (t <= keys[i][0]) {
880
- const [t0, v0] = keys[i - 1], [t1, v1] = keys[i];
881
- return v0 + (v1 - v0) * smoothstep((t - t0) / (t1 - t0));
882
- }
883
- }
884
- return last[1];
885
- }
886
-
887
- // ---------------------------------------------------------------------------
888
- // makeHand
889
- // ---------------------------------------------------------------------------
890
-
891
- /**
892
- * @param {object} o
893
- * @param {object} o.P the avatar's parameter block (unused geometry-
894
- * side; taken so the part has the same front door as the others and so a
895
- * future idiom can reach the character's own proportions)
896
- * @param {object} o.PALETTE needs `face` and an opaque plane a rung below it
897
- * (`deep`, or `neck`, or last resort `shade`), plus `crease` — but only when
898
- * there is no `pen`: a pen draws every interior mark as a LINE, so an
899
- * outlined style is never asked for a crease rung it does not have. (ink's
900
- * palette has none: `makePalette` there ends at the translucent overlays.)
901
- * @param {Function} o.solid the paint registry's `solid`
902
- * @param {object} o.frame { cx, bottom, reach, outboardLimit }
903
- * @param {?object} o.pen the ink idiom's outline:
904
- * `{ paint, w, mark, cap, join }`. `w` is the silhouette's stroke width and
905
- * `mark` the interior marks', both in ARTBOARD units and both construction
906
- * constants — `rig.js` copies a stroke from base to out and never blends it,
907
- * so a pen width can never be a channel and a pen line never tapers. Off by
908
- * default: round has no outlines and its bytes do not move when this is null.
909
- * @param {string} o.group the draw group. NOT the head group and NOT the
910
- * body group: the hand is in frame space and neither the head matrix nor the
911
- * breath rise may reach it (src/live.js reads `meta.live.hand.slots`).
912
- */
913
- export function makeHand({ P, PALETTE, solid, frame, pen = null, group = 'hand' }) {
914
- const { cx, bottom, reach } = frame;
915
- // The paints are resolved on the FIRST draw and not here, which is a
916
- // one-line rule with a byte behind it: a paint registry hands out indices in
917
- // the order it is first asked, so a part that registers at CONSTRUCTION time
918
- // puts its colours in front of the face's and renumbers every draw in the
919
- // rig. This part reuses three paints the face has already asked for by the
920
- // time the hand is drawn, so asking late costs nothing and asking early
921
- // costs a rebuild of every file. Three Map lookups on the first frame.
922
- //
923
- // THREE TONES, and the middle one is not `PALETTE.shade`. That entry is the
924
- // face's side plane and it is TRANSLUCENT on purpose (round: 17% warm brown),
925
- // which works there because there is always skin under it. Nothing is under
926
- // the hand: it is the frontmost thing in the frame, over the shirt and over
927
- // the background. Painted at 17% the heel plane vanished against skin and the
928
- // raised thumb came out a green ghost over the tee — measured (91,111,94),
929
- // which is neither a hand colour nor a shirt colour. So the plane takes an
930
- // OPAQUE rung a step below the lit one; `neck` already is exactly that (one
931
- // skin rung down, opaque) and costs no new paint. The crease may stay
932
- // translucent: every mark is inside the silhouette with skin beneath it.
933
- let paint = null;
934
- const paints = () => (paint || (paint = {
935
- face: solid(PALETTE.face),
936
- deep: solid(PALETTE.deep || PALETTE.neck || PALETTE.shade),
937
- crease: pen ? solid(pen.paint) : solid(PALETTE.crease),
938
- pen: pen ? solid(pen.paint) : 0,
939
- }));
940
- // The one stroke descriptor, built once. `stroke.paint` is deliberately not
941
- // among its keys — `render2d.js` strokes with the DRAW's paint and
942
- // `validate.mjs` refuses a rig that carries a field nothing reads.
943
- //
944
- // There is no second nib for the marks any more, and that is the whole of the
945
- // taper: a stroke's `lineWidth` is ONE SCALAR for the length of the run, so a
946
- // stroked mark ends in a blunt round cap and cannot do anything else. The pen
947
- // draws its marks as filled ribbons instead — the same tapered ring round
948
- // fills, in the pen's own colour — which costs no draw (the marks were
949
- // already one draw per shape) and buys ends that go to a point.
950
- const nib = (w) => ({ w, cap: pen.cap || 'round', join: pen.join || 'round' });
951
- const PEN_SIL = pen ? nib(pen.w) : null;
952
-
953
- // …and the ribbons themselves, once, here rather than at module load, because
954
- // their width is the only geometry in this file that is NOT in art units:
955
- // `pen.mark` is a distance on the artboard, the way a `lineWidth` is, and it
956
- // has to come out that thick on screen whatever the frame and whatever the
957
- // gesture's depth. Solving `art = mark / (reach * sc)` is what holds the mark
958
- // to one nib the way a stroke did — see SHAPE_SC — and it is the reason a
959
- // ribbon can replace a stroke without the drawing getting heavier when the
960
- // hand is pushed at the lens.
961
- const penMarks = {};
962
- if (pen) {
963
- const wm = pen.mark || pen.w;
964
- for (const [key, s] of Object.entries(SHAPE)) {
965
- const w = (wm * PEN_MARK_GAIN) / (reach * SHAPE_SC[key]);
966
- penMarks[key] = s.penMarks.map(([run, prof]) => ribbon(run, prof.map((k) => k * w)));
967
- }
968
- }
969
-
970
- // The affine, once per run per frame: rotate about the wrist, scale by depth
971
- // (and MIRROR in x by `dir`, which is what makes one drawing two hands), then
972
- // translate to the placement. `reach` is folded into the scale so the tables
973
- // above stay in art units.
974
- const place = (pts, x, y, rot, sc, dir) => {
975
- const r = (rot * Math.PI) / 180, cs = Math.cos(r), sn = Math.sin(r);
976
- const kx = -dir * sc * reach, ky = sc * reach;
977
- return pts.map(([px, py]) => [
978
- x + kx * (px * cs - py * sn),
979
- y + ky * (px * sn + py * cs),
980
- ]);
981
- };
982
-
983
- // Where a shape sits at `t` through its gesture, or parked.
984
- const at = (g, t, dir) => (g
985
- ? {
986
- x: cx + dir * sample(g.out, t) * reach,
987
- y: bottom + (HIDE - sample(g.up, t) * (HIDE - g.hold)) * reach,
988
- rot: sample(g.rot, t), sc: g.sc, dir,
989
- }
990
- : { x: cx, y: bottom + HIDE * reach, rot: 0, sc: 1, dir });
991
-
992
- /**
993
- * @param {?{gesture:?string, progress:number, side:string}} h a HandFrame
994
- * @param {object} env unused; the part convention's fourth argument
995
- */
996
- function draws(h, env = {}) {
997
- const out = [];
998
- const push = drawPusher(out);
999
- const C = paints();
1000
-
1001
- const g = h && GESTURES[h.gesture] ? GESTURES[h.gesture] : null;
1002
- const t = g ? clamp(typeof h.progress === 'number' ? h.progress : 0, 0, 1) : 0;
1003
- const dir = h && h.side === 'left' ? -1 : 1;
1004
- const p = at(g, t, dir);
1005
- const T = (pts) => spline(place(pts, p.x, p.y, p.rot, p.sc, p.dir), 1);
1006
- const many = (runs) => contours(...runs.map(T));
1007
- // The same, with two free ends: what a pen's interior marks are, and what a
1008
- // carved silhouette arc is, where a flat idiom fills a tapered ring or
1009
- // buries the end in the mass instead.
1010
- const TO = (pts) => openSpline(place(pts, p.x, p.y, p.rot, p.sc, p.dir), 1);
1011
- // A pen's layer: runs paired with their carved arc (`null` = stroke the
1012
- // closed run), split where the group stops being disjoint. The fill is
1013
- // always the whole run; only the LINE is cut.
1014
- const layers = (runs, arcs, k) => splitAt(runs.map((r, i) => [r, arcs[i]]), k);
1015
- const fillOf = (L) => many(L.map(([r]) => r));
1016
- const inkOf = (L) => contours(...L.map(([r, c]) => (c ? TO(c) : T(r))));
1017
-
1018
- // All three shape sets, every frame, with alpha choosing between them —
1019
- // three topologies cannot be one interpolatable draw and pretending they
1020
- // can is how a display list stops being interpolatable.
1021
- for (const [key, s] of Object.entries(SHAPE)) {
1022
- const a = g && g.shape === key ? 1 : 0;
1023
- const n = key.toLowerCase();
1024
- // ---- THE FLAT IDIOM ------------------------------------------------
1025
- // Thirteen draws, and round's bytes are this branch: nothing below it
1026
- // may be reachable with `pen` null.
1027
- //
1028
- // UNDER first: `approve`'s thumb and `point`'s folded thumb go beneath
1029
- // the mass they belong to, so the mass swallows their base and they
1030
- // emerge exactly as far as they should. Then MASS, the planes, and the
1031
- // DIGITS on top of them. That order is not arbitrary and it is not
1032
- // back-to-front either: the thenar ball is a bulge of the palm and the
1033
- // thumb lies over it, so a shade drawn after the digits painted that ball
1034
- // on top of its own thumb. Everything the shade draw contains belongs to
1035
- // the mass; nothing in it belongs in front of a finger.
1036
- if (!pen) {
1037
- if (s.under.length) {
1038
- push(`hand/${n}Thumb`, group, many(s.under), s.underLit ? C.face : C.deep, a);
1039
- }
1040
- push(`hand/${n}`, group, many(s.lit[0]), C.face, a);
1041
- push(`hand/${n}Shade`, group, many(s.shade), C.deep, a);
1042
- for (let i = 1; i < s.lit.length; i++) {
1043
- push(`hand/${n}Digits`, group, many(s.lit[i]), C.face, a);
1044
- }
1045
- push(`hand/${n}Marks`, group, many(s.markRuns), C.crease, a);
1046
- continue;
1047
- }
1048
-
1049
- // ---- THE INK IDIOM -------------------------------------------------
1050
- // Twenty-six draws, in one order that is nothing but hidden-line removal
1051
- // in a pipeline with no clipping: every line is drawn, and then whatever
1052
- // covers it is drawn over it.
1053
- const split = s.split || {};
1054
- // A FOLDED thumb is a PLANE, not a silhouette — `point`'s clears the
1055
- // fist's edge by under 1.5 art units, so it is invisible in any idiom.
1056
- // Its tone goes under the mass; its line goes over it, below.
1057
- if (s.under.length && !s.underLit) {
1058
- push(`hand/${n}Thumb`, group, many(s.under), C.deep, a);
1059
- }
1060
- push(`hand/${n}`, group, many(s.lit[0]), C.face, a);
1061
- push(`hand/${n}Shade`, group, many(s.shade), C.deep, a);
1062
- // The mass's line goes AFTER its shade plane — which would otherwise eat
1063
- // the inner half of it, the reason ink's face draws `faceInk` after its
1064
- // own shadow band — and BEFORE everything that grows out of the mass.
1065
- push(`hand/${n}Ink`, group, many(s.lit[0]), C.pen, a, { stroke: PEN_SIL });
1066
- // …and here is the folded thumb: a LINE laid over the fist, because in an
1067
- // outlined idiom a fold is a line. Round says the same thing with a tone,
1068
- // which is the only way a flat style can say it.
1069
- if (s.under.length && !s.underLit) {
1070
- push(`hand/${n}ThumbInk`, group, many(s.under), C.pen, a, { stroke: PEN_SIL });
1071
- }
1072
- // A RAISED thumb IS part of the silhouette. Its fill comes after the
1073
- // mass's line and cuts that line out of itself; its own line is only the
1074
- // arc outside the fist (`carve`), so the two read as one contour instead
1075
- // of a lobe with a chord ruled through it.
1076
- if (s.under.length && s.underLit) {
1077
- layers(s.under, s.penUnder, split.under).forEach((L, j) => {
1078
- const slot = `hand/${n}Thumb${j ? j + 1 : ''}`;
1079
- push(slot, group, fillOf(L), C.face, a);
1080
- push(`${slot}Ink`, group, inkOf(L), C.pen, a, { stroke: PEN_SIL });
1081
- });
1082
- }
1083
- // The digits, the same way: fill cuts the mass's top edge out from under
1084
- // the fingers growing through it, and the carved arc picks that edge back
1085
- // up at both ends.
1086
- for (let i = 1; i < s.lit.length; i++) {
1087
- layers(s.lit[i], s.penLit[i], split[i]).forEach((L, j) => {
1088
- const slot = `hand/${n}Digits${j ? j + 1 : ''}`;
1089
- push(slot, group, fillOf(L), C.face, a);
1090
- push(`${slot}Ink`, group, inkOf(L), C.pen, a, { stroke: PEN_SIL });
1091
- });
1092
- }
1093
- // THE MARKS, and this is the one draw where the pen does NOT take the flat
1094
- // idiom's geometry: `penMarks` above, shortened, staggered, bowed and one
1095
- // band lighter on `wait`. The JOINTS are gone from both — the distal
1096
- // segment's own contour already draws a line across the digit exactly
1097
- // there, and two of them is the double line this drawing spends its whole
1098
- // paint order avoiding.
1099
- //
1100
- // Alpha, per shape, and it is ink's own tiering rather than a fudge.
1101
- // `avatars/ink/face.mjs` draws an EDGE and a lone accent at full ink
1102
- // (`faceInk`, `jawInk`, `noseInk`, `chinInk`) and a mark that REPEATS
1103
- // inside a feature translucent — 0.58 for the eyelid crease, 0.42 for the
1104
- // tooth separators, whose own comment is "gaps, not a barcode". The palm
1105
- // crease is the first kind: one accent on a bare palm, and it is the mark
1106
- // that says the palm is a palm. The knuckle bands are the second: three
1107
- // of them at full ink out-weigh the fist they are drawn inside, which is
1108
- // what a barcode is, in a hand.
1109
- push(`hand/${n}Marks`, group, many(penMarks[key]), C.pen, a * s.penMarkA);
1110
- }
1111
- return out;
1112
- }
1113
-
1114
- return { rest: handRest(), draws, frame, place, at };
1115
- }
1116
-
1117
- // ---------------------------------------------------------------------------
1118
- // checkFraming — rule 4, and rule 1, as an assertion the build runs.
1119
- //
1120
- // It walks every gesture at 2% steps, transforms every point the shape owns
1121
- // (rotation included: the swing is most of the budget), and reports the worst
1122
- // outboard excursion and the smallest wrist drop. It is exported rather than
1123
- // run here because a part does not know what frame it is going to be given.
1124
- // ---------------------------------------------------------------------------
1125
-
1126
- export function checkFraming(frame) {
1127
- const { reach, outboardLimit } = frame;
1128
- const rows = [];
1129
- for (const [name, g] of Object.entries(GESTURES)) {
1130
- const pts = SHAPE[g.shape].hull;
1131
- let worst = 0, drop = Infinity, top = -Infinity;
1132
- for (let i = 0; i <= 50; i++) {
1133
- const t = i / 50;
1134
- const ox = sample(g.out, t) * reach;
1135
- const dy = (HIDE - sample(g.up, t) * (HIDE - g.hold)) * reach;
1136
- const r = (sample(g.rot, t) * Math.PI) / 180, cs = Math.cos(r), sn = Math.sin(r);
1137
- for (const [px, py] of pts) {
1138
- // the forearm tails: below the frame by construction, and a 16-degree
1139
- // swing about a point 300 art units away would report an excursion the
1140
- // viewer never sees.
1141
- if (py > 100) continue;
1142
- const x = ox + g.sc * reach * (px * cs - py * sn);
1143
- const y = dy + g.sc * reach * (px * sn + py * cs);
1144
- worst = Math.max(worst, Math.abs(x));
1145
- top = Math.max(top, -y);
1146
- }
1147
- drop = Math.min(drop, dy);
1148
- }
1149
- rows.push({ gesture: name, outboard: worst, limit: outboardLimit, wristDrop: drop, rise: top });
1150
- if (worst > outboardLimit) {
1151
- throw new Error(`hand: ${name} pushes ink ${worst.toFixed(0)} from centre, past ${outboardLimit.toFixed(0)}`);
1152
- }
1153
- if (drop <= 0) throw new Error(`hand: ${name} brings the wrist into the frame`);
1154
- }
1155
- return rows;
1156
- }