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