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