@voqalize/avatar 0.2.1 → 0.3.0
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 +21 -661
- package/README.md +144 -663
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +523 -0
- package/client/arjun.ts +24 -0
- package/client/createAvatar.ts +139 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +39 -0
- package/client/internal.ts +80 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +24 -0
- package/client/kabir.ts +24 -0
- package/client/meera.ts +24 -0
- package/client/naina.ts +24 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/types.ts +146 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +24 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +196 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +450 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +6 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +18 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +99 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +30 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +33 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +6 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +18 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +6 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +18 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +6 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +18 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +6 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +18 -0
- package/dist/naina.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +93 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +6 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +18 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +81 -24
- package/src/avatar.d.ts +161 -120
- package/src/avatar.js +234 -146
- package/src/behavior.d.ts +26 -0
- package/src/behavior.js +102 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +4 -3
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +119 -4
- package/src/hand.js +120 -91
- package/src/idle.js +18 -101
- package/src/interjections.js +156 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +28 -12
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
package/src/face-peep.js
CHANGED
|
@@ -37,29 +37,44 @@
|
|
|
37
37
|
*
|
|
38
38
|
* The second construction fact
|
|
39
39
|
* ----------------------------
|
|
40
|
-
* THE FACE IS SIX MARKS: two brows, two eyes, a nose, a mouth. No
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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.
|
|
45
70
|
*
|
|
46
71
|
* What the style costs the rig, honestly
|
|
47
72
|
* --------------------------------------
|
|
48
|
-
* 1.
|
|
49
|
-
* `pupilX/pupilY` cannot move an iris against a white field — the whole eye
|
|
50
|
-
* bean translates instead. That is the correct reading of the style and it
|
|
51
|
-
* is what real peeps do, but it carries less information per unit of travel.
|
|
52
|
-
* Mitigated by giving the bean more travel than blue-shirt gives its iris
|
|
53
|
-
* (11 units on a 25-wide eye, against blue-shirt's 19 on a 74-wide one —
|
|
54
|
-
* as a share of frame width that is still less, and it took the DISTRACTED
|
|
55
|
-
* state reading as eye contact to learn 7 was not enough) and by leaning on
|
|
56
|
-
* gaze.js's head-follow, which is avatar-agnostic and does most of the work
|
|
57
|
-
* anyway.
|
|
58
|
-
* 2. THE JAW DOES NOT DROP. The chin is part of the head's ink outline, and
|
|
73
|
+
* 1. THE JAW DOES NOT DROP. The chin is part of the head's ink outline, and
|
|
59
74
|
* translating a piece of that outline breaks the silhouette. Peeps do not
|
|
60
75
|
* have moving jaws either. The cost is real — see MOUTH_APERTURE for how the
|
|
61
76
|
* aperture is sized to stay off the chin without one.
|
|
62
|
-
*
|
|
77
|
+
* 2. ONE SHOULDER CANNOT RISE WITHOUT THE OTHER. The torso is a single filled
|
|
63
78
|
* path, as it is on blue-shirt, so an asymmetric shrug is a small rotation
|
|
64
79
|
* about the sternum. Same compromise, same reason.
|
|
65
80
|
*
|
|
@@ -76,12 +91,9 @@ import {
|
|
|
76
91
|
f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
|
|
77
92
|
} from './face-core.js';
|
|
78
93
|
import { taper, taperRing, region, polyD, rng } from './line-art.js';
|
|
94
|
+
import { viewBoxForHead } from './camera.js';
|
|
79
95
|
|
|
80
96
|
export const THEME = {
|
|
81
|
-
// Paper, not white: a pure #fff field makes the ink look printed on glass,
|
|
82
|
-
// and every Open Peeps mock sits the figure on a warm off-white.
|
|
83
|
-
bg0: '#f6f3ee',
|
|
84
|
-
bg1: '#e8e2d8',
|
|
85
97
|
ink: '#1b1b1b',
|
|
86
98
|
paper: '#ffffff',
|
|
87
99
|
accent: '#f97415',
|
|
@@ -102,21 +114,31 @@ export const THEME = {
|
|
|
102
114
|
// fixing that means outlining the hair mass — a geometry change wearing a
|
|
103
115
|
// colour change's clothes. The keys stay; the second palette does not.
|
|
104
116
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// height, mouth near the optical centre.
|
|
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.
|
|
112
123
|
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
const
|
|
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);
|
|
120
142
|
|
|
121
143
|
/**
|
|
122
144
|
* The avatar descriptor: what a host or tool may know about this face without
|
|
@@ -125,7 +147,10 @@ const VB = { x: 92, y: 76, w: 576, h: 800 };
|
|
|
125
147
|
*/
|
|
126
148
|
export const META = {
|
|
127
149
|
viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
|
|
128
|
-
|
|
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 },
|
|
129
154
|
};
|
|
130
155
|
|
|
131
156
|
// --- landmarks --------------------------------------------------------------
|
|
@@ -135,9 +160,11 @@ export const META = {
|
|
|
135
160
|
// about a third of the head's width. Most of a peep's head is empty, and
|
|
136
161
|
// spreading the features to fill it — which is what "correct" proportion pushes
|
|
137
162
|
// you toward — is the single fastest way to lose the style.
|
|
138
|
-
|
|
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.
|
|
139
167
|
const HEAD_TOP = 120;
|
|
140
|
-
const CHIN_Y = 597;
|
|
141
168
|
|
|
142
169
|
// Brows are given as the DRAWN control points, not as an inner/outer pair for a
|
|
143
170
|
// generator to invent a curve between. That is a correction: a generator that
|
|
@@ -157,7 +184,11 @@ const BROW_L = [[CX - 20, 347], [CX - 40, 340], [CX - 60, 336], [CX - 76, 337],
|
|
|
157
184
|
[CX - 86, 339], [CX - 94, 343], [CX - 99, 348]];
|
|
158
185
|
const BROW_R = [[CX + 22, 344], [CX + 40, 337], [CX + 58, 333], [CX + 73, 335],
|
|
159
186
|
[CX + 83, 337], [CX + 90, 341], [CX + 95, 346]];
|
|
160
|
-
|
|
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 };
|
|
161
192
|
const NOSE_TOP = 402;
|
|
162
193
|
const MOUTH = { cx: CX, cy: 488 };
|
|
163
194
|
|
|
@@ -202,7 +233,11 @@ const HEAD = [
|
|
|
202
233
|
// Heavy along the jaw, nearly nothing at the crown. The crown is under the hair
|
|
203
234
|
// mass, and ink drawn where it will be covered only ever shows up as a dark
|
|
204
235
|
// fringe along the hairline.
|
|
205
|
-
|
|
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];
|
|
206
241
|
|
|
207
242
|
const EAR_L = [[236, 366], [208, 352], [186, 380], [192, 418], [198, 450], [224, 462], [239, 452]];
|
|
208
243
|
const EAR_R = [[537, 372], [563, 360], [583, 388], [576, 424], [570, 452], [546, 462], [533, 452]];
|
|
@@ -354,8 +389,8 @@ const PLACKET = [[387, 700], [390, 734], [385, 768], [389, 800]];
|
|
|
354
389
|
// Four, the same set blue-shirt runs. Draw order is head, body, features, hair:
|
|
355
390
|
// the shirt has to be over the neck, and the fringe has to be over the brows.
|
|
356
391
|
// ---------------------------------------------------------------------------
|
|
357
|
-
const PARALLAX = {
|
|
358
|
-
const LAYERS = ['
|
|
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'];
|
|
359
394
|
|
|
360
395
|
// Roll pivots at the base of the neck. About the chin, the cranium swings
|
|
361
396
|
// sideways and the whole thing reads as a puppet on a stick.
|
|
@@ -363,7 +398,7 @@ const PIVOT = { x: CX, y: 700 };
|
|
|
363
398
|
|
|
364
399
|
// Pose mechanics for face-core's poseTransforms, in this rig's own art units
|
|
365
400
|
// (native 760x950 — about 2.6x smaller than blue-shirt's, hence units: 1 and
|
|
366
|
-
// its own literals; see the art-unit rules in docs/
|
|
401
|
+
// its own literals; see the art-unit rules in docs/authoring-a-face.md).
|
|
367
402
|
//
|
|
368
403
|
// The shrug tilt is 1.6 degrees, not blue-shirt's 1.9. This collar is a
|
|
369
404
|
// shallow polo opening over a white neck on a white shirt, so there is no
|
|
@@ -378,13 +413,23 @@ const POSE = {
|
|
|
378
413
|
shrugLift: 30, shrugTiltDeg: 1.6, shrugPivot: { x: PIVOT.x, y: 800 },
|
|
379
414
|
yawPx: 28, pitchPx: 17,
|
|
380
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
|
+
},
|
|
381
426
|
// 1.2% at full inhale. Chest circumference changes ~2-3% in quiet
|
|
382
427
|
// breathing, so a little over half that in linear scale is the calm end of
|
|
383
428
|
// real — and it renders as the shoulder line rising 3 units and the chest
|
|
384
429
|
// widening 4 either side, against a hem that stays where it is. The
|
|
385
430
|
// predecessor (a 4.2-unit rigid slide of the whole shirt) moved more pixels
|
|
386
431
|
// and read as less alive, which is the entire argument for the swell.
|
|
387
|
-
breathSwell: 0.
|
|
432
|
+
breathSwell: 0.008, swellPivot: { x: CX, y: 950 },
|
|
388
433
|
// Lateral trunk travel. 16 units is ~2% of the frame width — the trunk
|
|
389
434
|
// re-settling is meant to be noticed peripherally and never watched.
|
|
390
435
|
turnPx: 16,
|
|
@@ -444,11 +489,11 @@ function mouthGeometry(p) {
|
|
|
444
489
|
// face has no such detail to fall back on — the bow IS the expression, so it
|
|
445
490
|
// has to be geometric.
|
|
446
491
|
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
const yL = cy -
|
|
451
|
-
const yR = cy -
|
|
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;
|
|
452
497
|
|
|
453
498
|
// The aperture opens DOWNWARD, 3:1. The upper lip is anchored to the maxilla
|
|
454
499
|
// and barely moves; the lower rides the jaw. Splitting it evenly is what makes
|
|
@@ -536,37 +581,107 @@ function teethPath(m, amt, lower) {
|
|
|
536
581
|
// ---------------------------------------------------------------------------
|
|
537
582
|
// Generators: eyes
|
|
538
583
|
//
|
|
539
|
-
// A
|
|
540
|
-
//
|
|
541
|
-
//
|
|
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.
|
|
542
587
|
//
|
|
543
588
|
// Blinking is the top edge descending toward the bottom one. At full closure
|
|
544
589
|
// both edges end up ABOVE the corners, so the shape is a thin crescent bowing
|
|
545
590
|
// upward: the relaxed closed lid every peep is drawn with. Collapsing it to a
|
|
546
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.
|
|
547
613
|
// ---------------------------------------------------------------------------
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 '';
|
|
557
626
|
const a = (tiltDeg * Math.PI) / 180;
|
|
558
627
|
const ca = Math.cos(a), sa = Math.sin(a);
|
|
559
628
|
const R = (x, y) => {
|
|
560
629
|
const dx = x - cx, dy = y - cy;
|
|
561
630
|
return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
|
|
562
631
|
};
|
|
632
|
+
const ctlTop = g.cyMid + (g.yTop - g.cyMid) / BULGE;
|
|
633
|
+
const ctlBot = g.cyMid + (g.yBot - g.cyMid) / BULGE;
|
|
563
634
|
return (
|
|
564
|
-
`M${R(cx - rx,
|
|
565
|
-
`C${R(cx - rx * 0.5,
|
|
566
|
-
`C${R(cx + rx * 0.5,
|
|
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`
|
|
567
638
|
);
|
|
568
639
|
}
|
|
569
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
|
+
|
|
570
685
|
// ---------------------------------------------------------------------------
|
|
571
686
|
// Generators: brows
|
|
572
687
|
//
|
|
@@ -612,36 +727,47 @@ function markup(id, t) {
|
|
|
612
727
|
<svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
|
|
613
728
|
preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
|
|
614
729
|
<defs>
|
|
615
|
-
<radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
|
|
616
|
-
<stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
|
|
617
|
-
</radialGradient>
|
|
618
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>
|
|
619
733
|
</defs>
|
|
620
734
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
<g id="${id}-head">
|
|
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">
|
|
626
739
|
<path d="${NECK_FILL}" fill="${t.paper}"/>
|
|
627
740
|
${ink(taper(NECK_L, [3, 8, 6]))}
|
|
628
741
|
${ink(taper(NECK_R, [3, 8, 6]))}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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>
|
|
645
771
|
</g>
|
|
646
772
|
|
|
647
773
|
<!-- shirt: silhouette, arm separation, creases, collar -->
|
|
@@ -666,28 +792,36 @@ function markup(id, t) {
|
|
|
666
792
|
|
|
667
793
|
<!-- features: six marks and nothing else -->
|
|
668
794
|
<g id="${id}-features">
|
|
669
|
-
<
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
<
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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}"/>
|
|
682
815
|
</g>
|
|
683
|
-
<path id="${id}-lips" fill="${t.ink}"/>
|
|
684
816
|
</g>
|
|
685
817
|
</g>
|
|
686
818
|
|
|
687
819
|
<!-- hair -->
|
|
688
820
|
<g id="${id}-hair">
|
|
689
|
-
<
|
|
690
|
-
|
|
821
|
+
<g transform="${RESHAPE}">
|
|
822
|
+
<path d="${HAIR_D}" fill="${t.ink}"/>
|
|
823
|
+
<path d="${region(HAIR_TUFT)}" fill="${t.ink}"/>
|
|
824
|
+
</g>
|
|
691
825
|
</g>
|
|
692
826
|
</svg>`;
|
|
693
827
|
}
|
|
@@ -708,9 +842,12 @@ export function createFace(mount, theme = {}) {
|
|
|
708
842
|
const { svg, $, set } = createFaceShell(mount, id, markup(id, t));
|
|
709
843
|
|
|
710
844
|
const el = {
|
|
711
|
-
|
|
845
|
+
neck: $('neck'), skull: $('skull'), body: $('body'), features: $('features'), hair: $('hair'),
|
|
712
846
|
browL: $('browL'), browR: $('browR'),
|
|
713
|
-
|
|
847
|
+
eyeL: $('eyeL'), eyeR: $('eyeR'),
|
|
848
|
+
clipEyeL: $('clipEyeLP'), clipEyeR: $('clipEyeRP'),
|
|
849
|
+
apertureL: $('apertureL'), apertureR: $('apertureR'),
|
|
850
|
+
irisL: $('irisL'), irisR: $('irisR'),
|
|
714
851
|
mouthIn: $('mouthIn'), lips: $('lips'), clipMouth: $('clipMouthP'),
|
|
715
852
|
teeth: $('teeth'), teethLo: $('teethLo'), tongue: $('tongue'),
|
|
716
853
|
};
|
|
@@ -719,17 +856,31 @@ export function createFace(mount, theme = {}) {
|
|
|
719
856
|
poseTransforms(p, set, el, POSE);
|
|
720
857
|
|
|
721
858
|
// --- eyes -------------------------------------------------------------
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
// a gaze target does not read at all.
|
|
726
|
-
set(el.eyes, 'transform', `translate(${f(p.pupilX * 11)} ${f(p.pupilY * 8)})`);
|
|
727
|
-
|
|
728
|
-
// The lid follows vertical gaze downward only. Looking up genuinely does
|
|
729
|
-
// widen the aperture, so there is nothing to add on that side.
|
|
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.
|
|
730
862
|
const lidFollow = Math.max(0, p.pupilY) * 0.22;
|
|
731
|
-
|
|
732
|
-
|
|
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'));
|
|
733
884
|
|
|
734
885
|
// --- brows ------------------------------------------------------------
|
|
735
886
|
set(el.browL, 'd', browPath(BROW_L, p.browRaiseL, p.browAngleL, p.browInnerL));
|
|
@@ -765,3 +916,10 @@ export function createFace(mount, theme = {}) {
|
|
|
765
916
|
|
|
766
917
|
return faceApi(mount, svg, apply, t);
|
|
767
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 };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AvatarMeta, Face, FaceFactory } from "./avatar.js";
|
|
2
|
+
|
|
3
|
+
export const createFace: FaceFactory;
|
|
4
|
+
export const META: AvatarMeta;
|
|
5
|
+
/** Default palette. Passed as `createAvatar({ theme })`; see authoring-a-face.md. */
|
|
6
|
+
export const THEME: Readonly<Record<string, string>>;
|
|
7
|
+
export const wren: Face;
|