@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-myna.js
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar: "myna".
|
|
3
|
+
*
|
|
4
|
+
* The third line-art character, authored against a user-supplied reference
|
|
5
|
+
* asset (a raster illustration): a young contemporary Indian woman — wavy
|
|
6
|
+
* shoulder-length dark hair with paper highlight slashes, thin geometric
|
|
7
|
+
* glasses, orange hoop earrings, a white crew tee under an open orange
|
|
8
|
+
* overshirt. Hand-authored geometry with the reference on screen; nothing is
|
|
9
|
+
* traced. Same idiom as peep and wren — no strokes anywhere, every mark a
|
|
10
|
+
* filled taper — and the same 30-channel contract underneath.
|
|
11
|
+
*
|
|
12
|
+
* What is deliberately different from the siblings, and why:
|
|
13
|
+
* - THE HAIR IS A HORSESHOE, NOT A CLOUD. One closed loop: wavy outer
|
|
14
|
+
* silhouette down to pointed lock tips IN FRONT of both shoulders, then the
|
|
15
|
+
* inner edge back up the face, joined across the forehead by the fringe
|
|
16
|
+
* sweep. The enclosed "hole" (face, neck, chest V) opens downward between
|
|
17
|
+
* the two lock groups, so the region needs no fill-rule tricks.
|
|
18
|
+
* - THE EARS SIT ON THE HAIR. In the reference the mass runs behind the ears,
|
|
19
|
+
* so ears and hoops are drawn in the hair layer, after the mass. The
|
|
20
|
+
* head-layer underlay (same loop at head parallax) backfills the ±3-unit
|
|
21
|
+
* slide between the two layers under yaw — same insurance as peep's, doing
|
|
22
|
+
* one extra job here.
|
|
23
|
+
* - THE ACCENT IS A GARMENT, not trim (peep) or frames (wren): the overshirt
|
|
24
|
+
* is two accent-filled panels over a paper tee. Background leaks behind an
|
|
25
|
+
* orange shirt are loud, which is why the tee runs edge-to-edge under both
|
|
26
|
+
* panels and everything overshoots the frame.
|
|
27
|
+
* - THE MOUTH HAS LIPS. The proven aperture-contour model, but the ring's
|
|
28
|
+
* width profile carries a cupid's-bow dip at the top centre and a fuller
|
|
29
|
+
* lower lip. (An under-lip shadow mark existed through v7; it was deleted —
|
|
30
|
+
* a mark inside the deformation zone either rides every open perfectly or
|
|
31
|
+
* it is a defect, and its ink went to the lip band instead.)
|
|
32
|
+
*
|
|
33
|
+
* v8 calibration note: the expressive gains here (brow travel, corner lift,
|
|
34
|
+
* squint, lip weights, the lid mapping) are authored against a 130 px
|
|
35
|
+
* DOWNSAMPLE of the rest/emotion renders, not the close-up — this face ships
|
|
36
|
+
* in a call tile. Slightly theatrical at full size is correct.
|
|
37
|
+
*
|
|
38
|
+
* Like peep, there is no drawn jaw line to drop, so `jaw` has nothing to
|
|
39
|
+
* move — same documented character decision.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { clamp, lerp } from './params.js';
|
|
43
|
+
import {
|
|
44
|
+
f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
|
|
45
|
+
} from './face-core.js';
|
|
46
|
+
import { taper, taperRing, region } from './line-art.js';
|
|
47
|
+
|
|
48
|
+
export const THEME = {
|
|
49
|
+
// Sampled from the reference card: a warmer, creamier ground than peep's.
|
|
50
|
+
bg0: '#f2ead9',
|
|
51
|
+
bg1: '#e6d9c2',
|
|
52
|
+
ink: '#191919',
|
|
53
|
+
paper: '#ffffff',
|
|
54
|
+
// The overshirt orange — one key, hoops use it too. ~9% duskier than the
|
|
55
|
+
// asset's sample: at tile size the sampled value out-shouted the face.
|
|
56
|
+
accent: '#dc8432',
|
|
57
|
+
mouthIn: '#191919',
|
|
58
|
+
teeth: '#ffffff',
|
|
59
|
+
tongue: '#8d7f79',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Same native 760x950 art space as the siblings; window lifted like wren's
|
|
63
|
+
// because the hair crowns high.
|
|
64
|
+
const VB = { x: 92, y: 50, w: 576, h: 800 };
|
|
65
|
+
|
|
66
|
+
export const META = {
|
|
67
|
+
viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
|
|
68
|
+
mouthCrop: { x: 298, y: 436, w: 164, h: 100 },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// --- landmarks --------------------------------------------------------------
|
|
72
|
+
const CX = 380;
|
|
73
|
+
const HEAD_TOP = 150;
|
|
74
|
+
|
|
75
|
+
const EYE = { y: 386, dx: 56, rx: 15, ry: 16 };
|
|
76
|
+
const MOUTH = { cx: CX, cy: 492 };
|
|
77
|
+
const MOUTH_APERTURE = 36;
|
|
78
|
+
|
|
79
|
+
// Lenses: measured off the reference's fully visible left lens, converted by
|
|
80
|
+
// face-height fraction (lens height ≈ 0.22 of hairline→chin; w:h ≈ 1.3;
|
|
81
|
+
// corner radius ≈ 0.38 of lens height — a soft rect verging on superellipse).
|
|
82
|
+
// Centred ON the eye line: the reference pupil sits at lens mid-height, and
|
|
83
|
+
// its "just under the brows" top gap is 0.21 of lens height, which lands the
|
|
84
|
+
// same way here. Lens centres sit 4 units outboard of the eyes so the bridge
|
|
85
|
+
// keeps the reference's narrow 18-unit gap without moving the pupils.
|
|
86
|
+
const LENS = { dx: 60, hw: 51, hh: 40, r: 30 };
|
|
87
|
+
|
|
88
|
+
// Brows: bold, long and clearly arched, with real air between them and the
|
|
89
|
+
// frames — the reference's brows are its strongest single mark. The rest
|
|
90
|
+
// shape is a gentle taper-arc: v7's outer third dropped 17 units in 15 and
|
|
91
|
+
// over a resting smile the kink composited into a smirk that curdled across
|
|
92
|
+
// a long session. The kink is still available — as a browAngle POSE.
|
|
93
|
+
const BROW_L = [[CX - 22, 340], [CX - 40, 327], [CX - 60, 320], [CX - 76, 319],
|
|
94
|
+
[CX - 88, 322], [CX - 97, 327], [CX - 103, 332]];
|
|
95
|
+
// Near-mirrored: the right arch rides 1 unit higher (personality, small
|
|
96
|
+
// enough to survive the mirror-flip levelness check, which is judged on the
|
|
97
|
+
// glasses and eye lines). Anything bigger read as a baked-in head tilt —
|
|
98
|
+
// the mixer's headRoll adds on top of the art and nothing can subtract a
|
|
99
|
+
// drawn lean.
|
|
100
|
+
const BROW_R = [[CX + 22, 340], [CX + 40, 326], [CX + 60, 319], [CX + 76, 318],
|
|
101
|
+
[CX + 88, 321], [CX + 97, 327], [CX + 103, 332]];
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Static art: head.
|
|
105
|
+
//
|
|
106
|
+
// Same three-segment jaw rule as every head here, but the runs are the softest
|
|
107
|
+
// of the three rigs: rounded gonion, full cheek, small chin pad slightly off
|
|
108
|
+
// the midline. Chin at 568 against wren's 572 and peep's 597 — the shorter
|
|
109
|
+
// lower face is most of the "mid twenties" read.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
const HEAD = [
|
|
112
|
+
[CX, HEAD_TOP],
|
|
113
|
+
[452, 149], [518, 206], [522, 288],
|
|
114
|
+
[525, 338], [522, 386], [518, 424],
|
|
115
|
+
[516, 456], [510, 482], [498, 508],
|
|
116
|
+
[484, 530], [452, 556], [422, 566],
|
|
117
|
+
[404, 572], [350, 571], [332, 563],
|
|
118
|
+
[306, 551], [282, 529], [266, 505],
|
|
119
|
+
[252, 479], [246, 453], [250, 423],
|
|
120
|
+
[244, 385], [240, 337], [243, 287],
|
|
121
|
+
[247, 205], [308, 148], [CX, HEAD_TOP],
|
|
122
|
+
];
|
|
123
|
+
// Lighter than wren's ring by ~20% — the reference line is confident but not
|
|
124
|
+
// woodcut. Heavy low on the jaw, nothing at the crown (under hair).
|
|
125
|
+
const HEAD_W = [3, 6, 9.5, 12, 13, 11.5, 9, 6, 4, 3];
|
|
126
|
+
|
|
127
|
+
// Neck: the shared truncated-cone construction, slim; fused into the head
|
|
128
|
+
// layer and overshot past every neckline for the standard parallax reasons.
|
|
129
|
+
const NECK_FILL =
|
|
130
|
+
'M344 512C336 570 330 620 328 680L328 790L434 790L434 680C432 620 426 570 418 512Z';
|
|
131
|
+
const NECK_L = [[344, 530], [338, 580], [333, 634], [331, 720]];
|
|
132
|
+
const NECK_R = [[418, 528], [424, 578], [429, 632], [431, 720]];
|
|
133
|
+
const JAW_UNDER = [[340, 558], [356, 582], [404, 584], [422, 556]];
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Static art: hair — the wavy lob.
|
|
137
|
+
//
|
|
138
|
+
// One closed horseshoe (see header). Waves are S-curves on the outer edge and
|
|
139
|
+
// pointed tips at the bottom; the fringe sweeps from a part left of centre
|
|
140
|
+
// down across the forehead, bottoming out above the right brow. Interior
|
|
141
|
+
// highlight slashes are separate paper marks on top of the mass.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
const HAIR = [
|
|
144
|
+
// left lock tip (in front of the shoulder), then one curl and a smooth
|
|
145
|
+
// outer edge going up — deep or repeated scallops here read as braids,
|
|
146
|
+
// which is what sank the first two drafts
|
|
147
|
+
[252, 712],
|
|
148
|
+
[224, 706], [204, 684], [198, 654],
|
|
149
|
+
[186, 622], [178, 588], [182, 554],
|
|
150
|
+
[172, 520], [168, 480], [176, 444],
|
|
151
|
+
[170, 404], [174, 360], [186, 320],
|
|
152
|
+
[188, 270], [210, 226], [240, 196],
|
|
153
|
+
[258, 158], [296, 130], [336, 114],
|
|
154
|
+
[366, 100], [420, 96], [458, 112],
|
|
155
|
+
[498, 116], [534, 142], [548, 178],
|
|
156
|
+
// outer right, down with the same gentle waves
|
|
157
|
+
[574, 202], [586, 248], [580, 288],
|
|
158
|
+
[594, 326], [594, 382], [582, 418],
|
|
159
|
+
[586, 452], [578, 498], [566, 528],
|
|
160
|
+
[574, 560], [566, 600], [550, 626],
|
|
161
|
+
[556, 648], [540, 674], [516, 686],
|
|
162
|
+
[508, 700], [484, 704], [470, 690],
|
|
163
|
+
// right front lock: inner boundary climbing over the chest to the jaw
|
|
164
|
+
[458, 670], [454, 642], [462, 612],
|
|
165
|
+
[478, 588], [500, 564], [518, 540],
|
|
166
|
+
// right inner edge up the face side to the temple
|
|
167
|
+
[530, 502], [530, 454], [532, 410],
|
|
168
|
+
[532, 382], [524, 358], [510, 346],
|
|
169
|
+
// the fringe hairline, right to left: a HIGH arc — the reference forehead
|
|
170
|
+
// is largely open (brow-to-hairline reads close to brow-to-mouth). The
|
|
171
|
+
// right descent is short, a strand tip dipping to just above the right
|
|
172
|
+
// frame corner; v6 let the arc sag to the brows and the whole upper face
|
|
173
|
+
// went dark
|
|
174
|
+
[500, 344], [494, 322], [488, 298],
|
|
175
|
+
[478, 268], [462, 250], [448, 240],
|
|
176
|
+
[436, 236], [427, 242], [416, 238],
|
|
177
|
+
// …the cleft dips at the part (right of centre, as drawn), then the left
|
|
178
|
+
// branch is one long high swoop to the temple — it must clear the left
|
|
179
|
+
// brow tail entirely, so the descent happens outboard of x≈276
|
|
180
|
+
[398, 236], [376, 242], [352, 252],
|
|
181
|
+
[328, 264], [308, 278], [294, 292],
|
|
182
|
+
[284, 306], [276, 320], [270, 332],
|
|
183
|
+
[264, 344], [258, 356], [256, 366],
|
|
184
|
+
// left inner edge: down the face side, then slanting AWAY from the neck —
|
|
185
|
+
// an edge that hugs the neck makes the side mass a rope
|
|
186
|
+
[250, 392], [244, 432], [248, 478],
|
|
187
|
+
[252, 516], [242, 554], [230, 592],
|
|
188
|
+
[224, 622], [220, 654], [228, 684],
|
|
189
|
+
[236, 700], [244, 708], [252, 712],
|
|
190
|
+
];
|
|
191
|
+
const HAIR_D = region(HAIR);
|
|
192
|
+
|
|
193
|
+
// Paper highlight slashes: crescent cuts following the flow. These, plus the
|
|
194
|
+
// wavy silhouette, are the contemporary read; without them the mass is a
|
|
195
|
+
// helmet whatever its outline does.
|
|
196
|
+
// v8: fewer, bolder. Eight slivers flickered during nods — the most-played
|
|
197
|
+
// motion in the product — so each side-fall keeps two large crescents instead
|
|
198
|
+
// of three ticks, and every survivor gained width. The silhouette, not the
|
|
199
|
+
// interior, is the identity feature.
|
|
200
|
+
const SHINE = [
|
|
201
|
+
{ p: [[330, 150], [300, 168], [280, 196], [272, 228]], w: [2, 7, 2] },
|
|
202
|
+
// the long echo of the fringe sweep — the reference's most prominent slash;
|
|
203
|
+
// it lives in the band ABOVE the hairline, tight to the sweep's direction
|
|
204
|
+
{ p: [[468, 220], [446, 196], [416, 176], [382, 166]], w: [1.5, 5.5, 1.5] },
|
|
205
|
+
{ p: [[196, 336], [187, 380], [187, 424], [196, 462]], w: [2, 8, 2] },
|
|
206
|
+
{ p: [[204, 522], [196, 574], [206, 648], [242, 700]], w: [1.5, 7, 1.5] },
|
|
207
|
+
{ p: [[574, 330], [583, 372], [583, 414], [574, 448]], w: [2, 8, 2] },
|
|
208
|
+
{ p: [[550, 538], [552, 592], [530, 652], [482, 684]], w: [1.5, 7, 1.5] },
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
// Ears and hoops: painted ON the hair mass (see header). Small C-shapes; the
|
|
212
|
+
// hoop is a thin accent ring hanging from the lobe — the reference's one
|
|
213
|
+
// piece of jewellery.
|
|
214
|
+
const EAR_L = [[246, 404], [224, 396], [212, 418], [216, 446], [222, 468], [240, 474], [250, 464]];
|
|
215
|
+
const EAR_R = [[514, 404], [536, 396], [548, 418], [544, 446], [538, 468], [520, 474], [510, 464]];
|
|
216
|
+
const EAR_L_IN = [[230, 420], [220, 430], [221, 446], [228, 455]];
|
|
217
|
+
const EAR_R_IN = [[530, 420], [540, 430], [539, 446], [532, 455]];
|
|
218
|
+
|
|
219
|
+
const K = 0.5523;
|
|
220
|
+
function circlePts(cx, cy, r) {
|
|
221
|
+
const k = r * K;
|
|
222
|
+
return [
|
|
223
|
+
[cx + r, cy], [cx + r, cy + k], [cx + k, cy + r], [cx, cy + r],
|
|
224
|
+
[cx - k, cy + r], [cx - r, cy + k], [cx - r, cy], [cx - r, cy - k],
|
|
225
|
+
[cx - k, cy - r], [cx, cy - r], [cx + k, cy - r], [cx + r, cy - k],
|
|
226
|
+
[cx + r, cy],
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
const HOOP_L = taperRing(circlePts(236, 488, 12), [4.5, 5, 4.5]);
|
|
230
|
+
const HOOP_R = taperRing(circlePts(524, 488, 12), [4.5, 5, 4.5]);
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Static art: glasses — thin geometric rounded rectangles in ink.
|
|
234
|
+
//
|
|
235
|
+
// Wider than tall, corners genuinely rounded, thin ring. Temples run outward
|
|
236
|
+
// and vanish under the hair's inner edge. Drawn after the eyes so the frame
|
|
237
|
+
// passes over the bean at gaze extremes rather than under it.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
function roundRectPts(cx, cy, hw, hh, r) {
|
|
240
|
+
const k = r * K;
|
|
241
|
+
const L = cx - hw, R = cx + hw, T = cy - hh, B = cy + hh;
|
|
242
|
+
return [
|
|
243
|
+
[R, cy],
|
|
244
|
+
[R, cy + (hh - r) / 2], [R, B - r - k * 0.2], [R, B - r],
|
|
245
|
+
[R, B - r + k], [R - r + k, B], [R - r, B],
|
|
246
|
+
[R - r - (hw - r) / 2, B], [L + r + (hw - r) / 2, B], [L + r, B],
|
|
247
|
+
[L + r - k, B], [L, B - r + k], [L, B - r],
|
|
248
|
+
[L, B - r - k * 0.2], [L, cy + (hh - r) / 2], [L, cy],
|
|
249
|
+
[L, cy - (hh - r) / 2], [L, T + r + k * 0.2], [L, T + r],
|
|
250
|
+
[L, T + r - k], [L + r - k, T], [L + r, T],
|
|
251
|
+
[L + r + (hw - r) / 2, T], [R - r - (hw - r) / 2, T], [R - r, T],
|
|
252
|
+
[R - r + k, T], [R, T + r - k], [R, T + r],
|
|
253
|
+
[R, T + r + k * 0.2], [R, cy - (hh - r) / 2], [R, cy],
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
const LENS_L = roundRectPts(CX - LENS.dx, EYE.y, LENS.hw, LENS.hh, LENS.r);
|
|
257
|
+
const LENS_R = roundRectPts(CX + LENS.dx, EYE.y, LENS.hw, LENS.hh, LENS.r);
|
|
258
|
+
const RING_W = [3, 3.4, 3, 3.4, 3];
|
|
259
|
+
// Bridge at the upper third and temples off the top outer corners, sweeping
|
|
260
|
+
// up toward the ears — both as drawn in the reference.
|
|
261
|
+
const BRIDGE = [[CX - 9, 371], [CX - 3, 366], [CX + 3, 366], [CX + 9, 371]];
|
|
262
|
+
// Attach on the top-outer corner arc (the rounded corner pulls the rim in to
|
|
263
|
+
// ~x±108 at this height), sweeping up to vanish under the hair's inner edge.
|
|
264
|
+
const TEMPLE_L = [[CX - 108, 364], [CX - 124, 357], [CX - 141, 352], [CX - 158, 350]];
|
|
265
|
+
const TEMPLE_R = [[CX + 108, 364], [CX + 124, 357], [CX + 141, 352], [CX + 158, 350]];
|
|
266
|
+
|
|
267
|
+
/** The nose: a tiny soft hook, tip only — the glasses own the bridge. It sits
|
|
268
|
+
* lower than v6 (the reference's nose mark tops out at ~55% of eye→mouth,
|
|
269
|
+
* not 39%), which is also what gives the taller lenses their clearance. */
|
|
270
|
+
const NOSE_D = taper(
|
|
271
|
+
[[CX + 3, 439], [CX - 2, 448], [CX - 6, 456], [CX - 4, 462],
|
|
272
|
+
[CX + 1, 465], [CX + 7, 463], [CX + 10, 458]],
|
|
273
|
+
[1.5, 5.5, 2]
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Static art: torso — white crew tee under an open orange overshirt.
|
|
278
|
+
//
|
|
279
|
+
// Three fills in order: the tee (paper, edge-to-edge under everything), then
|
|
280
|
+
// the two overshirt panels (accent) whose inner edges make the open V. Both
|
|
281
|
+
// panels keep the house two-run shoulder: outer arm edge, hard acromion turn,
|
|
282
|
+
// near-horizontal trapezius shelf. Everything overshoots the frame.
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
const TEE = [
|
|
285
|
+
[288, 960],
|
|
286
|
+
[290, 860], [292, 760], [296, 688],
|
|
287
|
+
[300, 656], [318, 636], [352, 626],
|
|
288
|
+
[364, 623], [396, 623], [408, 625],
|
|
289
|
+
[442, 634], [460, 658], [464, 688],
|
|
290
|
+
[468, 760], [470, 860], [472, 960],
|
|
291
|
+
[410, 960], [348, 960], [288, 960],
|
|
292
|
+
];
|
|
293
|
+
const TEE_NECK = [[318, 640], [338, 652], [358, 658], [380, 660],
|
|
294
|
+
[402, 657], [422, 650], [440, 638]];
|
|
295
|
+
|
|
296
|
+
// The shoulder line sits high — chin to trapezius is ~75 units. v1 had it 60
|
|
297
|
+
// lower and the neck read as a column; the reference's neck is short.
|
|
298
|
+
const PANEL_L = [
|
|
299
|
+
[302, 960],
|
|
300
|
+
[306, 890], [308, 810], [314, 742],
|
|
301
|
+
[318, 710], [322, 676], [318, 638],
|
|
302
|
+
[298, 634], [264, 644], [232, 656],
|
|
303
|
+
[186, 672], [132, 702], [106, 748],
|
|
304
|
+
[72, 814], [60, 882], [58, 960],
|
|
305
|
+
[180, 960], [240, 960], [302, 960],
|
|
306
|
+
];
|
|
307
|
+
const PANEL_R = [
|
|
308
|
+
[454, 960],
|
|
309
|
+
[450, 890], [448, 810], [442, 742],
|
|
310
|
+
[438, 710], [434, 676], [438, 636],
|
|
311
|
+
[458, 632], [492, 642], [524, 654],
|
|
312
|
+
[570, 670], [624, 700], [650, 746],
|
|
313
|
+
[684, 812], [696, 882], [698, 960],
|
|
314
|
+
[620, 960], [540, 960], [454, 960],
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
// Ink runs for the panels: outer silhouette from under the collar over the
|
|
318
|
+
// shoulder and down the arm; inner run down the open front edge.
|
|
319
|
+
const PANEL_L_OUT = [
|
|
320
|
+
[318, 638],
|
|
321
|
+
[298, 634], [264, 644], [232, 656],
|
|
322
|
+
[186, 672], [132, 702], [106, 748],
|
|
323
|
+
[72, 814], [60, 882], [58, 950],
|
|
324
|
+
];
|
|
325
|
+
const PANEL_L_IN = [[318, 638], [322, 676], [318, 710], [314, 742],
|
|
326
|
+
[308, 810], [306, 890], [304, 950]];
|
|
327
|
+
const PANEL_R_OUT = [
|
|
328
|
+
[438, 636],
|
|
329
|
+
[458, 632], [492, 642], [524, 654],
|
|
330
|
+
[570, 670], [624, 700], [650, 746],
|
|
331
|
+
[684, 812], [696, 882], [698, 950],
|
|
332
|
+
];
|
|
333
|
+
const PANEL_R_IN = [[438, 636], [434, 676], [438, 710], [442, 742],
|
|
334
|
+
[448, 810], [450, 890], [452, 950]];
|
|
335
|
+
const PANEL_W = [7, 8, 7, 8, 7, 6];
|
|
336
|
+
|
|
337
|
+
// The collar: a down-pointing folded flap hugging each side of the neck —
|
|
338
|
+
// v1's up-pointing lapel triangles read as horns. The lower vertex is a
|
|
339
|
+
// single sharp point so the notch against the shoulder reads as tailoring,
|
|
340
|
+
// not a blob. Not mirrored; the reference's collar is not symmetric and
|
|
341
|
+
// neither is any worn one.
|
|
342
|
+
const COLLAR_L = [[338, 600], [316, 604], [298, 624], [288, 648],
|
|
343
|
+
[296, 672], [310, 686], [324, 662],
|
|
344
|
+
[330, 640], [335, 618], [338, 600]];
|
|
345
|
+
const COLLAR_R = [[424, 598], [446, 602], [464, 622], [474, 646],
|
|
346
|
+
[466, 670], [452, 684], [438, 660],
|
|
347
|
+
[432, 638], [427, 616], [424, 598]];
|
|
348
|
+
|
|
349
|
+
// Folds where the cloth is actually pulled, tapered to nothing at both ends:
|
|
350
|
+
// out of the collar roll toward each armpit, and off the shoulder seam down
|
|
351
|
+
// the sleeve. Few and curved — v6 scattered short parallel ticks and they
|
|
352
|
+
// read as scratches on the garment, not drape.
|
|
353
|
+
const CREASES = [
|
|
354
|
+
{ p: [[300, 692], [284, 722], [272, 756], [268, 788]], w: [1, 4.5, 1] },
|
|
355
|
+
{ p: [[150, 700], [168, 742], [178, 788], [180, 830]], w: [1.5, 5, 1.5] },
|
|
356
|
+
{ p: [[118, 742], [134, 776], [143, 810], [144, 838]], w: [1, 3.5, 1] },
|
|
357
|
+
{ p: [[464, 690], [478, 722], [488, 758], [491, 792]], w: [1, 4.5, 1] },
|
|
358
|
+
{ p: [[610, 698], [594, 740], [585, 786], [586, 828]], w: [1.5, 5, 1.5] },
|
|
359
|
+
{ p: [[644, 752], [630, 786], [621, 816], [619, 842]], w: [1, 3.5, 1] },
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Layers: the house four, same order, same roles.
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
const PARALLAX = { head: 1.0, body: 0.1, features: 1.22, hair: 1.12 };
|
|
366
|
+
const LAYERS = ['head', 'body', 'features', 'hair'];
|
|
367
|
+
const PIVOT = { x: CX, y: 700 };
|
|
368
|
+
|
|
369
|
+
const POSE = {
|
|
370
|
+
leanTravel: 23, leanPivot: { x: PIVOT.x, y: 560 },
|
|
371
|
+
shrugLift: 30, shrugTiltDeg: 1.7, shrugPivot: { x: PIVOT.x, y: 800 },
|
|
372
|
+
// Under peep's 28 for the same reason as wren: a big hair mass at full
|
|
373
|
+
// parallax slides like a wig.
|
|
374
|
+
yawPx: 26, pitchPx: 16,
|
|
375
|
+
pivot: PIVOT,
|
|
376
|
+
breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
|
|
377
|
+
turnPx: 16,
|
|
378
|
+
layers: LAYERS, parallax: PARALLAX,
|
|
379
|
+
torsoLayers: ['body'],
|
|
380
|
+
units: 1,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Generators: mouth — the proven aperture-contour model with myna's lips.
|
|
385
|
+
//
|
|
386
|
+
// See face-peep.js for the full derivation (aperture-not-centreline, the 0.18
|
|
387
|
+
// ramp, 3:1 downward opening, the 0.75 cubic solve). Myna's additions: a
|
|
388
|
+
// 9-stop ring profile whose top centre dips (the cupid's bow) and whose lower
|
|
389
|
+
// centre swells (the fuller lip), and an under-lip shadow drawn off the
|
|
390
|
+
// contour bottom in apply().
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// v8: both bands up ~15% from v7 — at a 130 px tile the closed smile was
|
|
393
|
+
// merely *present*; the floor raise is what makes it read as smiling there.
|
|
394
|
+
const LIP_UP = 10.5; // upper lip band thickness at its fullest
|
|
395
|
+
const LIP_LO = 15; // lower lip band at centre
|
|
396
|
+
|
|
397
|
+
function mouthGeometry(p) {
|
|
398
|
+
const cx = MOUTH.cx;
|
|
399
|
+
const cy = MOUTH.cy;
|
|
400
|
+
const open = clamp(p.mouthOpen);
|
|
401
|
+
const round = clamp(p.mouthRound);
|
|
402
|
+
const tuck = clamp(p.mouthTuck);
|
|
403
|
+
const press = clamp(p.mouthPress);
|
|
404
|
+
|
|
405
|
+
const w = (28 + clamp(p.mouthWidth) * 30) * (1 - 0.36 * round);
|
|
406
|
+
|
|
407
|
+
// Press changes SHAPE, not just weight. A bilabial closure (viseme A,
|
|
408
|
+
// press .55) must render as a pressed band — corners neutralized, bow
|
|
409
|
+
// scallop ironed flat, thickness nearly kept — or A is indistinguishable
|
|
410
|
+
// from idle X and lipsync reads mushy however good the timing is. The
|
|
411
|
+
// pressed band is the anchor viewers use to verify sync.
|
|
412
|
+
const t = 1 - 0.18 * press;
|
|
413
|
+
// Nine stops around the ring: s=0 left corner, 0.25 top centre, 0.5 right
|
|
414
|
+
// corner, 0.75 bottom centre. The dip at 0.25 is the bow notch (a close-up
|
|
415
|
+
// detail, deliberately light); press irons it up to band thickness.
|
|
416
|
+
const bow = lerp(5, LIP_UP * 0.92, clamp(press * 1.6));
|
|
417
|
+
const lo = LIP_LO * t * (1 + 0.35 * tuck);
|
|
418
|
+
const profile = [
|
|
419
|
+
3 * t, LIP_UP * t, bow * t, LIP_UP * t, 3.5 * t,
|
|
420
|
+
11.5 * t, lo, 11.5 * t, 3 * t,
|
|
421
|
+
];
|
|
422
|
+
const halfUp = (LIP_UP * t) / 2;
|
|
423
|
+
const halfLo = lo / 2;
|
|
424
|
+
|
|
425
|
+
const h = open * MOUTH_APERTURE;
|
|
426
|
+
const k = clamp(open / 0.18);
|
|
427
|
+
|
|
428
|
+
// Corner travel 28 (was 23): the tile swallows less. Press straightens the
|
|
429
|
+
// band by pulling the corner lift toward zero.
|
|
430
|
+
const cLift = 1 - 0.85 * press;
|
|
431
|
+
const yL = cy - (6 + p.mouthCornerL * 28) * cLift;
|
|
432
|
+
const yR = cy - (6 + p.mouthCornerR * 28) * cLift;
|
|
433
|
+
|
|
434
|
+
const apTop = cy - h * 0.25;
|
|
435
|
+
let apBot = cy + h * 0.75;
|
|
436
|
+
if (tuck > 0) apBot = Math.max(apTop + 6, apBot - tuck * (h * 0.6 + 4));
|
|
437
|
+
|
|
438
|
+
const cornerMid = (yL + yR) / 8;
|
|
439
|
+
const topY = (apTop - k * halfUp - cornerMid) / 0.75;
|
|
440
|
+
const botY = (apBot + k * halfLo - cornerMid) / 0.75;
|
|
441
|
+
|
|
442
|
+
const contour = [
|
|
443
|
+
[cx - w, yL],
|
|
444
|
+
[cx - w * 0.55, topY], [cx + w * 0.55, topY], [cx + w, yR],
|
|
445
|
+
[cx + w * 0.55, botY], [cx - w * 0.55, botY], [cx - w, yL],
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
const innerTop = cornerMid + 0.75 * topY + halfUp;
|
|
449
|
+
const innerBot = cornerMid + 0.75 * botY - halfLo;
|
|
450
|
+
|
|
451
|
+
return { contour, profile, cx, cy, w, h, topY, botY, innerTop, innerBot, open, tuck };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** peep's dental-arch teeth, myna's width factors. */
|
|
455
|
+
function teethPath(m, amt, lower) {
|
|
456
|
+
if (amt < 0.01) return '';
|
|
457
|
+
const gap = m.innerBot - m.innerTop;
|
|
458
|
+
if (gap < 2) return '';
|
|
459
|
+
const tw = m.w * (lower ? 0.6 : 0.76);
|
|
460
|
+
const cap = lower ? 0.5 : 0.5 + 0.35 * m.tuck;
|
|
461
|
+
const th = Math.min(amt * (lower ? 13 : 20), gap * cap);
|
|
462
|
+
|
|
463
|
+
if (lower) {
|
|
464
|
+
const base = m.innerBot + 8;
|
|
465
|
+
const edge = m.innerBot - th;
|
|
466
|
+
const end = m.innerBot - th * 0.35;
|
|
467
|
+
return (
|
|
468
|
+
`M${f(m.cx - tw)} ${f(base)}L${f(m.cx + tw)} ${f(base)}` +
|
|
469
|
+
`L${f(m.cx + tw * 0.92)} ${f(end)}` +
|
|
470
|
+
`Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const top = m.innerTop - 8;
|
|
474
|
+
const edge = m.innerTop + th;
|
|
475
|
+
const end = m.innerTop + th * 0.35;
|
|
476
|
+
return (
|
|
477
|
+
`M${f(m.cx - tw)} ${f(top)}L${f(m.cx + tw)} ${f(top)}` +
|
|
478
|
+
`L${f(m.cx + tw * 0.92)} ${f(end)}` +
|
|
479
|
+
`Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Eyes: the bean model, near-round — the reference's pupils are big and dark
|
|
484
|
+
// and do most of the face's expressive work with the lashes.
|
|
485
|
+
|
|
486
|
+
/** Top-lid height for a given lid value — shared by bean and lash so they can
|
|
487
|
+
* never disagree. The mapping is a 0.6-power, not linear: the mixer's rest
|
|
488
|
+
* lid is 0.12 (params.js: real neutral eyes graze the iris) and on beans
|
|
489
|
+
* this big a linear map spent only 7% of the travel by then — rest rendered
|
|
490
|
+
* as a wide-eyed stare, a long-session comfort failure. The power curve
|
|
491
|
+
* spends ~28% by 0.12 (a visible flat on the bean top: attentive-relaxed,
|
|
492
|
+
* not sleepy) while a true wide (curious sends lid −0.10 → clamps to ~0)
|
|
493
|
+
* still rounds fully open, and closed is unchanged. */
|
|
494
|
+
function lidTopY(cy, lid) {
|
|
495
|
+
return lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, Math.pow(clamp(lid), 0.6));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function eyePath(cx, cy, lid, squint, tiltDeg) {
|
|
499
|
+
const L = clamp(lid);
|
|
500
|
+
const topY = lidTopY(cy, lid);
|
|
501
|
+
// Squint gain .95 (was .7): the smile-squint is most of what separates warm
|
|
502
|
+
// from neutral at tile size.
|
|
503
|
+
const botY = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.95;
|
|
504
|
+
const rx = EYE.rx;
|
|
505
|
+
const a = (tiltDeg * Math.PI) / 180;
|
|
506
|
+
const ca = Math.cos(a), sa = Math.sin(a);
|
|
507
|
+
const R = (x, y) => {
|
|
508
|
+
const dx = x - cx, dy = y - cy;
|
|
509
|
+
return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
|
|
510
|
+
};
|
|
511
|
+
return (
|
|
512
|
+
`M${R(cx - rx, cy)}` +
|
|
513
|
+
`C${R(cx - rx * 0.5, topY)} ${R(cx + rx * 0.5, topY)} ${R(cx + rx, cy)}` +
|
|
514
|
+
`C${R(cx + rx * 0.5, botY)} ${R(cx - rx * 0.5, botY)} ${R(cx - rx, cy)}Z`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The upper lash line: lies ON the bean's top edge and ends in a small
|
|
520
|
+
* outward flick at the outer corner. The body samples the bean's own top
|
|
521
|
+
* cubic (controls at ±rx/2, topY, so y(u) = 3u(1−u)·(topY−cy)) rather than
|
|
522
|
+
* approximating it — v6 floated a straight slash near the lens top and it
|
|
523
|
+
* read as a second, angrier brow inside the glasses. Same lid value as the
|
|
524
|
+
* bean, so a blink carries the lash down with the closing lid; the flick is
|
|
525
|
+
* anchored at the corner, which the lid never moves.
|
|
526
|
+
* `dir` is −1 for the left eye (flick outward-left), +1 for the right.
|
|
527
|
+
*/
|
|
528
|
+
function lashPath(cx, cy, lid, dir) {
|
|
529
|
+
const topY = lidTopY(cy, lid);
|
|
530
|
+
const rx = EYE.rx;
|
|
531
|
+
const at = (u) => {
|
|
532
|
+
const v = 1 - u;
|
|
533
|
+
const x = v * v * v * -rx + 3 * v * v * u * -rx * 0.5
|
|
534
|
+
+ 3 * v * u * u * rx * 0.5 + u * u * u * rx;
|
|
535
|
+
// Proud of the edge by 1.5 so the liner shows against the dark bean.
|
|
536
|
+
return [cx + dir * x, cy + 3 * v * u * (topY - cy) - 1.5];
|
|
537
|
+
};
|
|
538
|
+
const pts = [
|
|
539
|
+
at(0.3), at(0.5), at(0.7), at(0.88), at(1),
|
|
540
|
+
[cx + dir * (rx + 5), cy - 4.5],
|
|
541
|
+
[cx + dir * (rx + 9), cy - 9],
|
|
542
|
+
];
|
|
543
|
+
return taper(pts, [2.5, 4, 3, 1.5], 6);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Brows: the point-list deformation. Travel is ASYMMETRIC by design:
|
|
547
|
+
// - Raises land at 20 (v7: 16) — the minimal-face lesson says small deltas
|
|
548
|
+
// vanish, and at a 130 px tile the emotion poses collapsed below this.
|
|
549
|
+
// - Descent is capped at 8.5 and weighted toward the inner half (corrugator
|
|
550
|
+
// pull). The cap is the frame-clearance envelope: the arch points that sit
|
|
551
|
+
// over the lens's top bar (y≈319–322 at rest) must keep ≥3.5 units of air
|
|
552
|
+
// above the bar's ink at browRaise −1, or the brow merges with the frame
|
|
553
|
+
// and brows-down — concentration — silently dies. The inner weighting also
|
|
554
|
+
// keeps the outer tail off the frame's corner arc. What travel can't carry,
|
|
555
|
+
// thickness does: a knitted brow bulks up 30% at full descent.
|
|
556
|
+
function browPath(pts, raise, angle, inner) {
|
|
557
|
+
const n = pts.length - 1;
|
|
558
|
+
const up = Math.max(0, raise);
|
|
559
|
+
const dn = Math.max(0, -raise);
|
|
560
|
+
const out = pts.map(([x, y], i) => {
|
|
561
|
+
const u = i / n;
|
|
562
|
+
return [x, y - up * 20 + dn * 8.5 * (1 - 0.45 * u)
|
|
563
|
+
- inner * 15 * (1 - u) - angle * 16 * u];
|
|
564
|
+
});
|
|
565
|
+
const wk = 1 + 0.3 * dn;
|
|
566
|
+
return taper(out, [3 * wk, 12 * wk, 4 * wk], 6);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// Static markup
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
const HEAD_FILL = region(HEAD);
|
|
573
|
+
const HEAD_RING = taperRing(HEAD, HEAD_W, 10);
|
|
574
|
+
|
|
575
|
+
function markup(id, t) {
|
|
576
|
+
const ink = (d) => `<path d="${d}" fill="${t.ink}"/>`;
|
|
577
|
+
const paper = (d) => `<path d="${d}" fill="${t.paper}"/>`;
|
|
578
|
+
return `
|
|
579
|
+
<svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
|
|
580
|
+
preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
|
|
581
|
+
<defs>
|
|
582
|
+
<radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
|
|
583
|
+
<stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
|
|
584
|
+
</radialGradient>
|
|
585
|
+
<clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
|
|
586
|
+
</defs>
|
|
587
|
+
|
|
588
|
+
<rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
|
|
589
|
+
|
|
590
|
+
<!-- head and neck; hair underlay at head parallax is the anti-sliver
|
|
591
|
+
insurance AND the backfill behind the hair-layer ears (see header). -->
|
|
592
|
+
<g id="${id}-head">
|
|
593
|
+
<path d="${NECK_FILL}" fill="${t.paper}"/>
|
|
594
|
+
${ink(taper(NECK_L, [3, 7, 5]))}
|
|
595
|
+
${ink(taper(NECK_R, [3, 7, 5]))}
|
|
596
|
+
<path d="${HEAD_FILL}" fill="${t.paper}"/>
|
|
597
|
+
${ink(HEAD_RING)}
|
|
598
|
+
${ink(taper(JAW_UNDER, [2, 5, 2]))}
|
|
599
|
+
<path d="${HAIR_D}" fill="${t.ink}"/>
|
|
600
|
+
</g>
|
|
601
|
+
|
|
602
|
+
<!-- tee, then the two overshirt panels and their tailoring -->
|
|
603
|
+
<g id="${id}-body">
|
|
604
|
+
${paper(region(TEE))}
|
|
605
|
+
${ink(taper(TEE_NECK, [3, 5.5, 3]))}
|
|
606
|
+
<path d="${region(PANEL_L)}" fill="${t.accent}"/>
|
|
607
|
+
<path d="${region(PANEL_R)}" fill="${t.accent}"/>
|
|
608
|
+
${ink(taper(PANEL_L_OUT, PANEL_W))}
|
|
609
|
+
${ink(taper(PANEL_R_OUT, PANEL_W))}
|
|
610
|
+
${ink(taper(PANEL_L_IN, [6, 7, 5]))}
|
|
611
|
+
${ink(taper(PANEL_R_IN, [6, 7, 5]))}
|
|
612
|
+
<path d="${region(COLLAR_L)}" fill="${t.accent}"/>
|
|
613
|
+
<path d="${region(COLLAR_R)}" fill="${t.accent}"/>
|
|
614
|
+
${ink(taperRing(COLLAR_L, [3, 4.5, 3], 6))}
|
|
615
|
+
${ink(taperRing(COLLAR_R, [3, 4.5, 3], 6))}
|
|
616
|
+
${CREASES.map((c) => ink(taper(c.p, c.w))).join('\n ')}
|
|
617
|
+
</g>
|
|
618
|
+
|
|
619
|
+
<!-- features: brows, eyes+lashes, nose, glasses, mouth -->
|
|
620
|
+
<g id="${id}-features">
|
|
621
|
+
<path id="${id}-browL" fill="${t.ink}"/>
|
|
622
|
+
<path id="${id}-browR" fill="${t.ink}"/>
|
|
623
|
+
<g id="${id}-eyes">
|
|
624
|
+
<path id="${id}-eyeL" fill="${t.ink}"/>
|
|
625
|
+
<path id="${id}-eyeR" fill="${t.ink}"/>
|
|
626
|
+
<path id="${id}-lashL" fill="${t.ink}"/>
|
|
627
|
+
<path id="${id}-lashR" fill="${t.ink}"/>
|
|
628
|
+
</g>
|
|
629
|
+
<path d="${NOSE_D}" fill="${t.ink}"/>
|
|
630
|
+
${ink(taperRing(LENS_L, RING_W, 6))}
|
|
631
|
+
${ink(taperRing(LENS_R, RING_W, 6))}
|
|
632
|
+
${ink(taper(BRIDGE, [3, 4, 3]))}
|
|
633
|
+
${ink(taper(TEMPLE_L, [3, 3.5, 4]))}
|
|
634
|
+
${ink(taper(TEMPLE_R, [3, 3.5, 4]))}
|
|
635
|
+
<g id="${id}-mouth">
|
|
636
|
+
<path id="${id}-mouthIn" fill="${t.mouthIn}"/>
|
|
637
|
+
<g clip-path="url(#${id}-clipMouth)">
|
|
638
|
+
<ellipse id="${id}-tongue" fill="${t.tongue}"/>
|
|
639
|
+
<path id="${id}-teeth" fill="${t.teeth}"/>
|
|
640
|
+
<path id="${id}-teethLo" fill="${t.teeth}" opacity=".85"/>
|
|
641
|
+
</g>
|
|
642
|
+
<path id="${id}-lips" fill="${t.ink}"/>
|
|
643
|
+
</g>
|
|
644
|
+
</g>
|
|
645
|
+
|
|
646
|
+
<!-- hair: the mass, its highlights, then ears and hoops on top -->
|
|
647
|
+
<g id="${id}-hair">
|
|
648
|
+
<path d="${HAIR_D}" fill="${t.ink}"/>
|
|
649
|
+
${SHINE.map((s) => paper(taper(s.p, s.w, 6))).join('\n ')}
|
|
650
|
+
<path d="${region(EAR_L)}" fill="${t.paper}"/>
|
|
651
|
+
<path d="${region(EAR_R)}" fill="${t.paper}"/>
|
|
652
|
+
${ink(taper(EAR_L, [3, 6, 3]))}
|
|
653
|
+
${ink(taper(EAR_R, [3, 6, 3]))}
|
|
654
|
+
${ink(taper(EAR_L_IN, [2, 4, 2]))}
|
|
655
|
+
${ink(taper(EAR_R_IN, [2, 4, 2]))}
|
|
656
|
+
<path d="${HOOP_L}" fill="${t.accent}"/>
|
|
657
|
+
<path d="${HOOP_R}" fill="${t.accent}"/>
|
|
658
|
+
</g>
|
|
659
|
+
</svg>`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// Renderer — the invariant apply() shape; only the travels and the extra lip
|
|
664
|
+
// marks are myna's own.
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
let uid = 0;
|
|
667
|
+
|
|
668
|
+
export function createFace(mount, theme = {}) {
|
|
669
|
+
const t = Object.assign({}, THEME, theme);
|
|
670
|
+
const id = `myna${++uid}`;
|
|
671
|
+
const { svg, $, set } = createFaceShell(mount, id, markup(id, t));
|
|
672
|
+
|
|
673
|
+
const el = {
|
|
674
|
+
head: $('head'), body: $('body'), features: $('features'), hair: $('hair'),
|
|
675
|
+
browL: $('browL'), browR: $('browR'),
|
|
676
|
+
eyes: $('eyes'), eyeL: $('eyeL'), eyeR: $('eyeR'),
|
|
677
|
+
lashL: $('lashL'), lashR: $('lashR'),
|
|
678
|
+
mouthIn: $('mouthIn'), lips: $('lips'),
|
|
679
|
+
clipMouth: $('clipMouthP'),
|
|
680
|
+
teeth: $('teeth'), teethLo: $('teethLo'), tongue: $('tongue'),
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
function apply(p) {
|
|
684
|
+
poseTransforms(p, set, el, POSE);
|
|
685
|
+
|
|
686
|
+
// Re-derived for the big lenses: outboard the bean edge at full travel is
|
|
687
|
+
// still ~24 units clear of the rim (56+14+15 vs the 109-unit interior
|
|
688
|
+
// edge), inboard ~16; vertically the 38-unit half-interior leaves ~15.
|
|
689
|
+
// Past these the pupil starts reading pressed against the frame.
|
|
690
|
+
set(el.eyes, 'transform', `translate(${f(p.pupilX * 14)} ${f(p.pupilY * 10)})`);
|
|
691
|
+
|
|
692
|
+
const lidFollow = Math.max(0, p.pupilY) * 0.22;
|
|
693
|
+
const lidL = p.lidL + lidFollow;
|
|
694
|
+
const lidR = p.lidR + lidFollow;
|
|
695
|
+
// Both beans exactly on EYE.y: a ±1 stagger here read as head tilt at
|
|
696
|
+
// rest, and rest must be level (see BROW_R).
|
|
697
|
+
set(el.eyeL, 'd', eyePath(CX - EYE.dx, EYE.y, lidL, p.squintL, -3));
|
|
698
|
+
set(el.eyeR, 'd', eyePath(CX + EYE.dx, EYE.y, lidR, p.squintR, 3));
|
|
699
|
+
set(el.lashL, 'd', lashPath(CX - EYE.dx, EYE.y, lidL, -1));
|
|
700
|
+
set(el.lashR, 'd', lashPath(CX + EYE.dx, EYE.y, lidR, 1));
|
|
701
|
+
|
|
702
|
+
set(el.browL, 'd', browPath(BROW_L, p.browRaiseL, p.browAngleL, p.browInnerL));
|
|
703
|
+
set(el.browR, 'd', browPath(BROW_R, p.browRaiseR, p.browAngleR, p.browInnerR));
|
|
704
|
+
|
|
705
|
+
const m = mouthGeometry(p);
|
|
706
|
+
const contour = region(m.contour);
|
|
707
|
+
set(el.mouthIn, 'd', contour);
|
|
708
|
+
set(el.clipMouth, 'd', contour);
|
|
709
|
+
set(el.lips, 'd', taperRing(m.contour, m.profile, 12));
|
|
710
|
+
// Keyed off the drawn gap, not mouthOpen — grey is a rendering fault on a
|
|
711
|
+
// two-value face (peep's F bug).
|
|
712
|
+
set(el.mouthIn, 'opacity', f(clamp((m.innerBot - m.innerTop) / 3)));
|
|
713
|
+
|
|
714
|
+
pairedTeeth(p, set, el, teethPath, m);
|
|
715
|
+
|
|
716
|
+
const tg = clamp(p.tongue);
|
|
717
|
+
set(el.tongue, 'cx', f(m.cx));
|
|
718
|
+
set(el.tongue, 'cy', f(m.innerBot + 6 - tg * ((m.innerBot - m.innerTop) * 0.8 + 6)));
|
|
719
|
+
set(el.tongue, 'rx', f(m.w * 0.58));
|
|
720
|
+
set(el.tongue, 'ry', '8');
|
|
721
|
+
set(el.tongue, 'opacity', tg > 0.02 ? '1' : '0');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return faceApi(mount, svg, apply, t);
|
|
725
|
+
}
|