@voqalize/avatar 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE-2.0 +201 -0
- package/README.md +11 -98
- package/assets/README.md +28 -17
- 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/internal.ts +4 -0
- package/client/supports.ts +6 -7
- package/client/three/assets.ts +2 -0
- package/client/three/budgets.ts +1 -1
- package/client/three/{tara-rig.ts → character-rig.ts} +673 -545
- package/client/three/createCharacter.ts +109 -0
- package/client/three/holds.ts +1 -1
- package/client/three/internal.ts +4 -3
- package/client/three/motion-limits.json +5 -1
- package/client/three/tanvi-asset.ts +6 -0
- package/client/three/tanvi.ts +30 -0
- package/client/three/tanya.ts +18 -58
- package/client/three/tara.ts +19 -60
- package/client/three/tess.ts +17 -59
- package/client/three/tushar.ts +18 -55
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +5 -1
- package/dist/internal.js.map +1 -1
- package/dist/supports.d.ts +6 -7
- package/dist/supports.d.ts.map +1 -1
- package/dist/supports.js +6 -7
- package/dist/supports.js.map +1 -1
- package/dist/three/assets.d.ts +1 -0
- package/dist/three/assets.d.ts.map +1 -1
- package/dist/three/assets.js +2 -0
- package/dist/three/assets.js.map +1 -1
- package/dist/three/budgets.d.ts +1 -1
- package/dist/three/budgets.js +1 -1
- package/dist/three/character-rig.d.ts +366 -0
- package/dist/three/character-rig.d.ts.map +1 -0
- package/dist/three/{tara-rig.js → character-rig.js} +629 -523
- package/dist/three/character-rig.js.map +1 -0
- package/dist/three/createCharacter.d.ts +60 -0
- package/dist/three/createCharacter.d.ts.map +1 -0
- package/dist/three/createCharacter.js +84 -0
- package/dist/three/createCharacter.js.map +1 -0
- package/dist/three/holds.js +1 -1
- package/dist/three/holds.js.map +1 -1
- package/dist/three/internal.d.ts +3 -3
- package/dist/three/internal.d.ts.map +1 -1
- package/dist/three/internal.js +2 -2
- package/dist/three/internal.js.map +1 -1
- package/dist/three/motion-limits.json +5 -1
- package/dist/three/tanvi-asset.d.ts +7 -0
- package/dist/three/tanvi-asset.d.ts.map +1 -0
- package/dist/three/tanvi-asset.js +7 -0
- package/dist/three/tanvi-asset.js.map +1 -0
- package/dist/three/tanvi.d.ts +24 -0
- package/dist/three/tanvi.d.ts.map +1 -0
- package/dist/three/tanvi.js +24 -0
- package/dist/three/tanvi.js.map +1 -0
- package/dist/three/tanya.d.ts +16 -28
- package/dist/three/tanya.d.ts.map +1 -1
- package/dist/three/tanya.js +15 -43
- package/dist/three/tanya.js.map +1 -1
- package/dist/three/tara.d.ts +17 -22
- package/dist/three/tara.d.ts.map +1 -1
- package/dist/three/tara.js +16 -45
- package/dist/three/tara.js.map +1 -1
- package/dist/three/tess.d.ts +15 -29
- package/dist/three/tess.d.ts.map +1 -1
- package/dist/three/tess.js +14 -44
- package/dist/three/tess.js.map +1 -1
- package/dist/three/tushar.d.ts +16 -25
- package/dist/three/tushar.d.ts.map +1 -1
- package/dist/three/tushar.js +15 -40
- package/dist/three/tushar.js.map +1 -1
- package/package.json +7 -50
- package/src/avatar.d.ts +2 -0
- package/src/avatar.js +87 -216
- package/src/gaze.js +1 -3
- package/src/idle.js +10 -2
- package/src/prosody.js +7 -3
- package/src/speech-timing.js +9 -0
- package/src/visemes.js +107 -13
- package/client/arjun.ts +0 -26
- package/client/createCanvasAvatar.ts +0 -72
- package/client/interviewer-female.ts +0 -4
- package/client/interviewer-male.ts +0 -4
- package/client/ishita.ts +0 -26
- package/client/kabir.ts +0 -26
- package/client/meera.ts +0 -26
- package/client/naina.ts +0 -26
- package/client/professional-female-a.ts +0 -4
- package/client/professional-female-b.ts +0 -4
- package/client/professional-male-a.ts +0 -4
- package/client/professional-male-b.ts +0 -4
- package/client/vikram.ts +0 -26
- package/dist/arjun.d.ts +0 -7
- package/dist/arjun.d.ts.map +0 -1
- package/dist/arjun.js +0 -20
- package/dist/arjun.js.map +0 -1
- package/dist/createCanvasAvatar.d.ts +0 -22
- package/dist/createCanvasAvatar.d.ts.map +0 -1
- package/dist/createCanvasAvatar.js +0 -47
- package/dist/createCanvasAvatar.js.map +0 -1
- package/dist/interviewer-female.d.ts +0 -4
- package/dist/interviewer-female.d.ts.map +0 -1
- package/dist/interviewer-female.js +0 -3
- package/dist/interviewer-female.js.map +0 -1
- package/dist/interviewer-male.d.ts +0 -4
- package/dist/interviewer-male.d.ts.map +0 -1
- package/dist/interviewer-male.js +0 -3
- package/dist/interviewer-male.js.map +0 -1
- package/dist/ishita.d.ts +0 -7
- package/dist/ishita.d.ts.map +0 -1
- package/dist/ishita.js +0 -20
- package/dist/ishita.js.map +0 -1
- package/dist/kabir.d.ts +0 -7
- package/dist/kabir.d.ts.map +0 -1
- package/dist/kabir.js +0 -20
- package/dist/kabir.js.map +0 -1
- package/dist/meera.d.ts +0 -7
- package/dist/meera.d.ts.map +0 -1
- package/dist/meera.js +0 -20
- package/dist/meera.js.map +0 -1
- package/dist/naina.d.ts +0 -7
- package/dist/naina.d.ts.map +0 -1
- package/dist/naina.js +0 -20
- package/dist/naina.js.map +0 -1
- package/dist/professional-female-a.d.ts +0 -4
- package/dist/professional-female-a.d.ts.map +0 -1
- package/dist/professional-female-a.js +0 -3
- package/dist/professional-female-a.js.map +0 -1
- package/dist/professional-female-b.d.ts +0 -4
- package/dist/professional-female-b.d.ts.map +0 -1
- package/dist/professional-female-b.js +0 -3
- package/dist/professional-female-b.js.map +0 -1
- package/dist/professional-male-a.d.ts +0 -4
- package/dist/professional-male-a.d.ts.map +0 -1
- package/dist/professional-male-a.js +0 -3
- package/dist/professional-male-a.js.map +0 -1
- package/dist/professional-male-b.d.ts +0 -4
- package/dist/professional-male-b.d.ts.map +0 -1
- package/dist/professional-male-b.js +0 -3
- package/dist/professional-male-b.js.map +0 -1
- package/dist/three/tara-rig.d.ts +0 -423
- package/dist/three/tara-rig.d.ts.map +0 -1
- package/dist/three/tara-rig.js.map +0 -1
- package/dist/vikram.d.ts +0 -7
- package/dist/vikram.d.ts.map +0 -1
- package/dist/vikram.js +0 -20
- package/dist/vikram.js.map +0 -1
- package/src/canvas/author/parts/eye.mjs +0 -722
- package/src/canvas/author/parts/hand.mjs +0 -1156
- package/src/canvas/author/parts/mouth.mjs +0 -741
- package/src/canvas/author/parts/nose.mjs +0 -100
- package/src/canvas/author/parts/skin-detail.mjs +0 -67
- package/src/canvas/author/path.mjs +0 -283
- package/src/canvas/author/rig.mjs +0 -405
- package/src/canvas/avatars/round/face.d.mts +0 -3
- package/src/canvas/avatars/round/face.mjs +0 -1307
- package/src/canvas/create-rig.d.ts +0 -15
- package/src/canvas/create-rig.js +0 -100
- 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 +0 -1
- package/src/canvas/data/interviewer-male.rig.json +0 -1
- package/src/canvas/data/professional-female-a.rig.json +0 -1
- package/src/canvas/data/professional-female-b.rig.json +0 -1
- package/src/canvas/data/professional-male-a.rig.json +0 -1
- package/src/canvas/data/professional-male-b.rig.json +0 -1
- package/src/canvas/src/live.js +0 -508
- package/src/canvas/src/render2d.js +0 -218
- package/src/canvas/src/rig.js +0 -297
- package/src/canvas/src/vocab.js +0 -96
|
@@ -1,722 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// author/parts/eye.mjs — the round idiom's eye (lids, iris stack, lashes,
|
|
3
|
-
// crease and BROW), as a PART, driven in the DRIVER's channel space.
|
|
4
|
-
//
|
|
5
|
-
// The part convention is written out in full at the top of ./mouth.mjs and in
|
|
6
|
-
// ./README.md; the one thing this part does differently is that it is drawn
|
|
7
|
-
// PER SIDE. `draws(c, L, side, env)` returns the eighteen draws of ONE eye, in
|
|
8
|
-
// paint order, with the side's `L`/`R` suffix already on every slot name — the
|
|
9
|
-
// avatar calls it twice, `for (const side of [-1, 1])`.
|
|
10
|
-
//
|
|
11
|
-
// CONSTRUCTION (build time, once per persona)
|
|
12
|
-
// makeEye({ P, PALETTE, solid, irisBase, lashWeight, browWeight, group })
|
|
13
|
-
// `irisBase`, `lashWeight` and `browWeight` are the three places the
|
|
14
|
-
// persona reaches into this geometry: the solved base iris paint (the one
|
|
15
|
-
// paint the 39-rung hue ladder swaps), the lash's mass, and the brow's
|
|
16
|
-
// half-thickness about its own centre line.
|
|
17
|
-
//
|
|
18
|
-
// DRIVING part.draws(c, L, side, env) -> ordered draws for that side
|
|
19
|
-
// REST eyeChannelRest() -> the `eye:`/`eyeL:`/`eyeR:` blocks of the
|
|
20
|
-
// control vector; `part.rest` is the same object.
|
|
21
|
-
// TABLES EYE_TABLE (the six eye states) and GAZE_TABLE (the four optional
|
|
22
|
-
// `gaze/*` poses) live here too, because a table written in channel
|
|
23
|
-
// units is a statement about what these channels DO and belongs
|
|
24
|
-
// next to the code that spends them.
|
|
25
|
-
//
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// THE DRIVER BLOCK (phase 2). Voqalize's eye + brow channels, per side, plus
|
|
28
|
-
// two part-local extras this idiom cannot express without.
|
|
29
|
-
//
|
|
30
|
-
// channel rest range sign / meaning
|
|
31
|
-
// lid 0.12 0..1 0 = wide open, 1 = shut. Rest is 0.12 and
|
|
32
|
-
// that is not "nearly open": the lid must
|
|
33
|
-
// already GRAZE the iris at rest (below).
|
|
34
|
-
// squint 0.0 0..1 lower lid raised, independent of `lid`
|
|
35
|
-
// pupilX 0.0 -1..1 gaze, + = viewer's RIGHT (screen +x)
|
|
36
|
-
// pupilY 0.05 -1..1 gaze, + = DOWN. Rest is 0.05, a hair
|
|
37
|
-
// below level.
|
|
38
|
-
// browRaise 0.0 -1..1 whole brow up (+) / down (-)
|
|
39
|
-
// browAngle 0.0 -1.4..1.4 + = OUTER end up (voqalize's browAngle*)
|
|
40
|
-
// browInner 0.0 -1..1 + = INNER end up (AU1, worry)
|
|
41
|
-
//
|
|
42
|
-
// PART-LOCAL, no counterpart in the contract, kept because this idiom's
|
|
43
|
-
// closed eye is a drawn curve rather than a bean that shrinks:
|
|
44
|
-
// curveUp 0.0 bows the CLOSED lid line into a ^ or a v
|
|
45
|
-
// — what makes happy_closed an arch and not
|
|
46
|
-
// a slit. voqalize says that with squint +
|
|
47
|
-
// brow; we keep the mark.
|
|
48
|
-
// outerDroop 0.0 outer canthus falls (the sad eye).
|
|
49
|
-
//
|
|
50
|
-
// `c.eyeSize` / `c.eyeSpace` stay IDENTITY morphs on the outer vector
|
|
51
|
-
// (`morph/eyes_±100`, `morph/distance_±100`), not performance channels.
|
|
52
|
-
//
|
|
53
|
-
// SIDEDNESS, and how a patch is written. The contract is per side (lidL/lidR,
|
|
54
|
-
// squintL/squintR, browRaiseL/R…); writing every table entry twice would be a
|
|
55
|
-
// tax paid on the 95% of poses that are symmetric. So the vector carries THREE
|
|
56
|
-
// blocks and the part reads them through `eyeSide(c, side)`:
|
|
57
|
-
//
|
|
58
|
-
// eye: { … } both eyes
|
|
59
|
-
// eyeL: { … } overrides for the viewer's-left eye (side -1, slots …L)
|
|
60
|
-
// eyeR: { … } overrides for the viewer's-right eye (side +1, slots …R)
|
|
61
|
-
//
|
|
62
|
-
// ctrl({ eye: { lid: 0.12 } }) both lids graze
|
|
63
|
-
// ctrl({ eye: { lid: 0.12 }, eyeR: { lid: 1 } }) …and a wink on the right
|
|
64
|
-
//
|
|
65
|
-
// `makeCtrl`'s patch is a one-level Object.assign, so all three blocks merge
|
|
66
|
-
// per key exactly like `mouth:` does, and a symmetric state stays one line.
|
|
67
|
-
// GAZE is deliberately NOT mirrored: `pupilX` is screen space, so both eyes
|
|
68
|
-
// carry the same sign and the pair looks at one point rather than crossing.
|
|
69
|
-
//
|
|
70
|
-
// THE OLD UNITS, and where they went. The px this part was tuned in have not
|
|
71
|
-
// been thrown away — they are the constants below, which is what makes the map
|
|
72
|
-
// auditable rather than a re-tune:
|
|
73
|
-
//
|
|
74
|
-
// open 0..1.3 -> lid 1..0 through LID_TOP/LID_MEET/LID_GAMMA, chosen so
|
|
75
|
-
// lid = 0.12 lands the upper apex on the OLD rest px to the
|
|
76
|
-
// second decimal, and lid = 0 lands it within 1.3 px of the
|
|
77
|
-
// old `open: 1.30` wide.
|
|
78
|
-
// lowerRaise -> squint same 0..1 number, new name.
|
|
79
|
-
// browRaise px -> browRaise * BROW_PX.raise (18 px per unit)
|
|
80
|
-
// browInner px -> browInner * BROW_PX.inner (26 px per unit)
|
|
81
|
-
// browOuter px -> browAngle * BROW_PX.angle (11 px per unit)
|
|
82
|
-
//
|
|
83
|
-
// The brow's LANDMARKS (`bwI/bwM/bwO`) are still solved in the avatar's own
|
|
84
|
-
// `landmarks(c)` — that is where those three channels are actually spent,
|
|
85
|
-
// because brow height carries a little of the forehead mesh with it and the
|
|
86
|
-
// mesh is not the part's to own. `BROW_PX` is exported so the avatar spends
|
|
87
|
-
// them in this part's units.
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
import { spline, openSpline, arc, shift, circle, strip, band, bulge, contours, ring } from '../path.mjs';
|
|
91
|
-
import { clamp, lerp, drawPusher } from '../rig.mjs';
|
|
92
|
-
|
|
93
|
-
// --- the front door: every scale constant the channel map is made of --------
|
|
94
|
-
|
|
95
|
-
export const LID_REST = 0.12; // the contract's rest lid, and ours
|
|
96
|
-
export const PUPIL_Y_REST = 0.05; // …and its rest gaze, a hair below level
|
|
97
|
-
|
|
98
|
-
// The lid curve. `LID_TOP` is where the upper apex sits at lid = 0, in units of
|
|
99
|
-
// P.eyeTopH ABOVE the eye centre; `LID_MEET` is where the two lids touch at
|
|
100
|
-
// lid = 1, in units of P.eyeBotH BELOW it; `LID_GAMMA` bends the ride between
|
|
101
|
-
// them. Gamma is the whole of "a wide eye opens the top far more than a sleepy
|
|
102
|
-
// eye closes it": the aperture is steep at the open end, so lid 0.12 -> 0 lifts
|
|
103
|
-
// the upper apex 19.8 px while lid 0.12 -> 0.26 drops it 20.2 px, and the HALF
|
|
104
|
-
// state's aperture lands at 0.76 of rest against the reference sheet's 0.775.
|
|
105
|
-
//
|
|
106
|
-
// The one place this deliberately falls short of the reference is WIDE: the
|
|
107
|
-
// sheet's wide upper apex is 1.70x its open one, and 1.54 puts ours at 1.41x
|
|
108
|
-
// (113 px of aperture against 92). That is not a miss, it is this eye's own
|
|
109
|
-
// proportion — the reference eye is an almond whose OPEN aperture is 0.475 of
|
|
110
|
-
// its width, ours is a round one at 0.59, and taking a rounder eye out to 1.70
|
|
111
|
-
// puts the whole iris inside the opening with white all round it, which is a
|
|
112
|
-
// startle and not an alert. 1.54 also lands lid = 0 within 1.3 px of the widest
|
|
113
|
-
// px this rig ever drew (the old `open: 1.30`), so nothing that was tuned
|
|
114
|
-
// against the wide state has moved under it.
|
|
115
|
-
const LID_TOP = 1.54;
|
|
116
|
-
const LID_MEET = 0.60;
|
|
117
|
-
const LID_GAMMA = 1.9;
|
|
118
|
-
|
|
119
|
-
// The lower lid. It is parked at its open position until the eye is nearly
|
|
120
|
-
// shut (`LID_LO_KNEE`), because the upper lid does almost all of a blink; it
|
|
121
|
-
// takes `SQUINT_LIFT` of the eye's lower half at squint = 1; and it drops a
|
|
122
|
-
// LITTLE further, but only as the eye opens PAST rest, which is the reference
|
|
123
|
-
// sheet exactly: its WIDE cell puts the lower arc at 1.08x open while its HALF
|
|
124
|
-
// cell leaves it untouched. Hence the one-sided `max` at the call site rather
|
|
125
|
-
// than a symmetric term — a narrowing eye does not lift its lower lid, that is
|
|
126
|
-
// what `squint` is for.
|
|
127
|
-
const LID_LO_KNEE = 0.32;
|
|
128
|
-
const SQUINT_LIFT = 1.45;
|
|
129
|
-
const LID_LO_WIDEN = 0.73;
|
|
130
|
-
|
|
131
|
-
// `curveUp`, in px of lift at 1.0 — the part-local channel that bows the CLOSED
|
|
132
|
-
// lid line. It has to out-reach `LID_MEET`: the seam is parked 24 px BELOW the
|
|
133
|
-
// canthus line so a plain blink dips the way the reference sheet's CLOSED cell
|
|
134
|
-
// does, and a happy_closed eye has to climb back over that line to read as the
|
|
135
|
-
// ^ it is rather than as a sad bowl. 38 puts it 14 px above the eye centre and
|
|
136
|
-
// 20 above the inner canthus, which is an arch you can read at 1x; at 16 (what
|
|
137
|
-
// this was before the seam moved down) happy_closed and idle_closed were nearly
|
|
138
|
-
// the same shape and the smile lived entirely in the brow.
|
|
139
|
-
//
|
|
140
|
-
// It is spent LINEARLY, not scaled by `lid`, because a smiling OPEN eye bows a
|
|
141
|
-
// little too — which is why EYE_TABLE's `eyes-happy` asks for 0.12 rather than
|
|
142
|
-
// the 0.30 it used to: at 38 px a unit, 0.12 is the same 4.6 px of bow the old
|
|
143
|
-
// 0.30 bought at 16, and any more of it would leave a smiling eye's upper lid
|
|
144
|
-
// higher than a resting one's, which is not a smile. The lower lid takes 0.81
|
|
145
|
-
// of the same lift, the ratio the two numbers always had.
|
|
146
|
-
const CURVE_UP = 44;
|
|
147
|
-
const CURVE_UP_LO = 0.81;
|
|
148
|
-
// …and the two ENDS of that line have to come with it, or the bow is tilted
|
|
149
|
-
// rather than an arch. The inner canthus sits 10 px BELOW the outer one (`yi`
|
|
150
|
-
// = ey+6, `yo` = ey-4), and `curveUp` used to lift only the middle station, so
|
|
151
|
-
// `happy_closed`'s apex landed ~60% of the way to the outer corner and the pair
|
|
152
|
-
// read cheerful rather than `^_^`. `CURVE_UP_OUT` is exactly that 10 px offset,
|
|
153
|
-
// spent on the OUTER endpoint, so a full curveUp levels the two canthi and the
|
|
154
|
-
// arch is symmetric; a NEGATIVE curveUp (`sad_closed`, -0.40) drops the outer
|
|
155
|
-
// corner 4 px instead, which is the falling canthus that state wants anyway.
|
|
156
|
-
const CURVE_UP_OUT = 10;
|
|
157
|
-
|
|
158
|
-
// Gaze travel, as a fraction of the room the iris actually has.
|
|
159
|
-
//
|
|
160
|
-
// X is the sclera half-width, less the iris radius, LESS the canthus inset —
|
|
161
|
-
// and that last term is the one that had to be measured rather than assumed.
|
|
162
|
-
// The lid arcs run all the way out to `xi`/`xo`, but the SCLERA's two corner
|
|
163
|
-
// points are pulled inboard of them by 9 and 10 px (`cIn`/`cOut` below), which
|
|
164
|
-
// leaves a thin wedge at each corner that neither the sclera nor the two lid
|
|
165
|
-
// plates cover. Travel computed against `hw` alone drives the iris into that
|
|
166
|
-
// wedge and a blue spur appears OUTSIDE the eye — which is exactly what the
|
|
167
|
-
// first cut of this did at 4x. So the room is `hw - CANTHUS_INSET - ir`, and
|
|
168
|
-
// the 0.92 is what keeps the iris edge a whisker inside the corner rather than
|
|
169
|
-
// on it: 25.7 px at pupilX = ±1, an iris edge at 67.7 against a corner at 68.
|
|
170
|
-
//
|
|
171
|
-
// That is a smaller number than "hard against the canthus" and it is the right
|
|
172
|
-
// one: an iris whose rim touches the corner reads as an eye about to pop, and
|
|
173
|
-
// the caricature economy wants ±1 to be an extreme a driver can actually hold.
|
|
174
|
-
//
|
|
175
|
-
// Y is a fraction of the iris radius: there is no room under the lids for a
|
|
176
|
-
// whole-iris travel, and there should not be, since an eye that looks down
|
|
177
|
-
// tucks its iris UNDER the lower lid — the lid plates are painted over the
|
|
178
|
-
// stack, so that tuck costs nothing and is the whole read of a look-down.
|
|
179
|
-
const CANTHUS_INSET = 10;
|
|
180
|
-
const GAZE_X_K = 0.76;
|
|
181
|
-
// Y is split by DIRECTION, and that is not symmetry for its own sake. Nothing
|
|
182
|
-
// in `uMid`/`lMid` reads `pupilY`: the lid does not follow the gaze in the
|
|
183
|
-
// part, ON PURPOSE — voqalize's mixer owns that (it applies `lidBias =
|
|
184
|
-
// pupilY·0.34`), and baking it here too would double-apply it. So an unmixed
|
|
185
|
-
// `gaze/down` at -1 is an iris that has dropped under a lid that has not
|
|
186
|
-
// moved, which reads STARTLED — sclera above the iris. 0.36 down against 0.55
|
|
187
|
-
// up keeps the pose honest on its own and still leaves the mixer its half.
|
|
188
|
-
const GAZE_Y_K = { up: 0.55, down: 0.36 };
|
|
189
|
-
|
|
190
|
-
// Brow channels, in px per unit of channel. Spent by the avatar's landmark
|
|
191
|
-
// solver, not here; exported so there is one place to read them from.
|
|
192
|
-
export const BROW_PX = { raise: 24, inner: 26, angle: 11 };
|
|
193
|
-
|
|
194
|
-
// The same three channels, spent AGAIN and on something else: the brow's own
|
|
195
|
-
// SHAPE. `BROW_PX` above moves the three landmarks, which is a translation of
|
|
196
|
-
// the mark plus the forehead mesh it drags; a brow that only translates says
|
|
197
|
-
// almost nothing, because what a brow means is in its ARCH and its TILT. These
|
|
198
|
-
// are px of shape per unit of channel, on top of that travel:
|
|
199
|
-
//
|
|
200
|
-
// arch/archIn browRaise lifts the peak more than the head, so raising is a
|
|
201
|
-
// brow gaining curvature and lowering is one going flat — the
|
|
202
|
-
// difference between "interested" and "moved up 5 px".
|
|
203
|
-
// tilt browAngle rotates the whole mark about its middle station,
|
|
204
|
-
// outer end up and inner end DOWN. voqalize pivots the tail
|
|
205
|
-
// alone; the tail alone at this size is under 2 px at 1x.
|
|
206
|
-
// tip/cap browInner lifts the inner END, and lifts its rounded cap
|
|
207
|
-
// further still, so the head of the brow ROTATES up rather
|
|
208
|
-
// than sliding — which is the whole read of AU1 (worry).
|
|
209
|
-
// pinch …and draws it in toward the midline with it (the knot).
|
|
210
|
-
//
|
|
211
|
-
// They are shape, so they scale with `px` like every other length here.
|
|
212
|
-
const BROW_SHAPE = { arch: 9, archIn: 4, tilt: 9, tip: 9, cap: 14, pinch: 6 };
|
|
213
|
-
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
// TWO IDIOMS, ONE PART. Everything above is round's eye; ink's is the same
|
|
216
|
-
// SOLVE — the same lid ride, the same gaze translation, the same brow arch —
|
|
217
|
-
// drawn with a different set of marks and an outline pen over them. So the
|
|
218
|
-
// three things that differ became three CONSTRUCTION bags, merged over these
|
|
219
|
-
// defaults, and the defaults are round's own numbers to the byte.
|
|
220
|
-
//
|
|
221
|
-
// px one scale for every constant below that is a DESIGN PIXEL rather
|
|
222
|
-
// than a ratio of a `P` dimension. ink's eye is 0.73 of round's
|
|
223
|
-
// (eyeTopH 32 against 44), and a 13 px lid fold on a 32 px lid is a
|
|
224
|
-
// different eye, not the same eye smaller. Ratios — LID_TOP,
|
|
225
|
-
// SQUINT_LIFT, the catchlight offsets — are already scale-free and
|
|
226
|
-
// are NOT touched by it.
|
|
227
|
-
// shape the lid/canthus geometry. Two of these are the whole difference
|
|
228
|
-
// between a round eye and an almond one: `meet` (where the lids
|
|
229
|
-
// touch — round's is 0.60 of the lower half, ink's 0.28, which is
|
|
230
|
-
// why ink's closed eye is a line near the centre and round's is a
|
|
231
|
-
// bowl) and `widest` (how far inboard of centre each lid arc peaks).
|
|
232
|
-
// marks WHICH of the layers this idiom draws, and how. A flat-fill eye
|
|
233
|
-
// models with translucent planes (`water`, `lidFold`, `squintShade`)
|
|
234
|
-
// where an outlined one models with LINES and would rather have the
|
|
235
|
-
// pen; the two never wanted the same list.
|
|
236
|
-
// catch the catchlight pair, as offsets and radii in units of the iris
|
|
237
|
-
// radius. ink's spark is half again as big and sits a little higher.
|
|
238
|
-
// pen the outline, or null for a style that has none. `{ paint, lid,
|
|
239
|
-
// crease, brow, lash, cap, join }` — a paint and a width per line.
|
|
240
|
-
// A draw in this runtime is stroked XOR filled, so every entry here
|
|
241
|
-
// is a draw of its own laid over the fills.
|
|
242
|
-
// ---------------------------------------------------------------------------
|
|
243
|
-
|
|
244
|
-
export const EYE_SHAPE = {
|
|
245
|
-
canthus: [6, -4], // inner / outer canthus, px below the eye centre
|
|
246
|
-
meet: LID_MEET, // where the lids touch when shut, in P.eyeBotH
|
|
247
|
-
top: LID_TOP, // upper apex at lid = 0, in P.eyeTopH
|
|
248
|
-
gamma: LID_GAMMA,
|
|
249
|
-
widest: [0.04, 0.04], // arc peak, inboard(+)/outboard(-) as a share of hw
|
|
250
|
-
curveUp: CURVE_UP, curveUpLo: CURVE_UP_LO, curveUpOut: CURVE_UP_OUT,
|
|
251
|
-
over: 4, // how far each lid plate bites into the opening
|
|
252
|
-
bury: 3, // …and how far the sclera is buried under it
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
export const EYE_MARKS = {
|
|
256
|
-
shade: 'cover', // 'cover' = round's corner-and-crescent plane, drawn
|
|
257
|
-
// before the iris; 'crescent' = ink's cast shadow, hung
|
|
258
|
-
// off the lid arc and drawn AFTER the pupil; false = none
|
|
259
|
-
glow: true, // the iris's inner glow, offset away from the light
|
|
260
|
-
limbal: 'ring', // 'ring' = a filled evenodd annulus; 'stroke' = a pen
|
|
261
|
-
water: true, // the waterline inside the lower lid
|
|
262
|
-
squintShade: true, // the cheek roll under a raised lower lid
|
|
263
|
-
lidFold: true, // the upper lid's own thickness, as a plane
|
|
264
|
-
lash: 'bulge', // 'bulge', or a per-station height profile, in artboard units
|
|
265
|
-
lashLo: true, // two lower lash ticks at the outer corner
|
|
266
|
-
crease: 'band', // 'band' = a filled hairline; 'line' = a pen swept off the
|
|
267
|
-
// lid; an ARRAY = a pen at those per-station offsets
|
|
268
|
-
// (5 = the lid's inner stations), in artboard units; false
|
|
269
|
-
lidLine: false, // ink's lower-lid pen, faded out as the eye shuts
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
export const EYE_CATCH = { r: 0.22, at: [-0.42, -0.42], r2: 0.072, at2: [0.37, 0.37] };
|
|
273
|
-
|
|
274
|
-
// Persona-local finishing, deliberately separate from the driver's channels.
|
|
275
|
-
// These are quiet rest-shape choices: they soften a heavy brow head and keep
|
|
276
|
-
// the upper-lid plane from reading as a sticker at call-tile scale. Every
|
|
277
|
-
// default is the former construction, so a face that does not opt in is byte
|
|
278
|
-
// identical.
|
|
279
|
-
export const EYE_REFINE = {
|
|
280
|
-
lidFoldDepth: 13,
|
|
281
|
-
lidFoldAlpha: 1,
|
|
282
|
-
creaseDepth: 3.4,
|
|
283
|
-
brow: { head: 1, peak: 1, tail: 1 },
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
// The `eye:` / `eyeL:` / `eyeR:` blocks of the control vector, at rest. The six
|
|
287
|
-
// named eye states, the four gaze poses and the idle clips are patches on these
|
|
288
|
-
// nine numbers and nothing else.
|
|
289
|
-
export function eyeChannelRest() {
|
|
290
|
-
return {
|
|
291
|
-
eye: {
|
|
292
|
-
lid: LID_REST, squint: 0,
|
|
293
|
-
pupilX: 0, pupilY: PUPIL_Y_REST,
|
|
294
|
-
browRaise: 0, browAngle: 0, browInner: 0,
|
|
295
|
-
curveUp: 0, outerDroop: 0,
|
|
296
|
-
},
|
|
297
|
-
eyeL: {}, eyeR: {},
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// One eye's channels: the shared block with that side's overrides on top.
|
|
302
|
-
export const eyeSide = (c, side) => ({ ...c.eye, ...(side < 0 ? c.eyeL : c.eyeR) });
|
|
303
|
-
|
|
304
|
-
// The eye block as `avatars/{facet,ink}` still hold it — today's px-and-ratio
|
|
305
|
-
// names, frozen. `author/rig.mjs` composes REST_CONTROLS out of this, so the
|
|
306
|
-
// two generators that are not parts yet keep the vector they were tuned
|
|
307
|
-
// against; `round` overrides the block with `eyeChannelRest()` on its own copy,
|
|
308
|
-
// which is what REST_CONTROLS' own comment says a generator that grows a
|
|
309
|
-
// channel should do. It goes when ink and facet become parts.
|
|
310
|
-
export function eyeRest() {
|
|
311
|
-
return {
|
|
312
|
-
open: 1, // 1 = neutral, 0 = shut, >1 = wide
|
|
313
|
-
lowerRaise: 0, // lower lid pushes up (squint / smile)
|
|
314
|
-
curveUp: 0, // bows the closed lid line into a ^ or a v
|
|
315
|
-
outerDroop: 0, // outer corner of the upper lid falls
|
|
316
|
-
browRaise: 0, browInner: 0, browOuter: 0,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ---------------------------------------------------------------------------
|
|
321
|
-
// The six eye states, in channel space. `cheekRaise` rides along because a
|
|
322
|
-
// Duchenne smile is not a squint alone — the cheek that raises the lower lid is
|
|
323
|
-
// the same cheek, and the driver reaches these states by NAME rather than by
|
|
324
|
-
// channel, so the state is allowed to know it. Everything MOUTH about a state
|
|
325
|
-
// stays in the avatar: a mouth is not the eye's to move.
|
|
326
|
-
//
|
|
327
|
-
// Read against the old table: `open: 0.86` became `lid: 0.17` through the curve
|
|
328
|
-
// above, `open: 1.30` became `lid: 0` (the contract's own wide), and both
|
|
329
|
-
// closed states became `lid: 1` — the old 0.02/0.03/0.04 were "as shut as that
|
|
330
|
-
// lerp got", and shut has a name now.
|
|
331
|
-
// ---------------------------------------------------------------------------
|
|
332
|
-
export const EYE_TABLE = {
|
|
333
|
-
'eyes-idle_closed': { eye: { lid: 1, browRaise: -0.17 } },
|
|
334
|
-
'eyes-idle_wide': { eye: { lid: 0, browRaise: 0.83 } },
|
|
335
|
-
'eyes-happy': { eye: { lid: 0.17, squint: 0.44, curveUp: 0.12, browRaise: 0.67, browAngle: 0.55 }, cheekRaise: 1 },
|
|
336
|
-
'eyes-happy_closed': { eye: { lid: 1, squint: 0.30, curveUp: 1.0, browRaise: 0.72, browAngle: 0.64 }, cheekRaise: 1 },
|
|
337
|
-
'eyes-sad': { eye: { lid: 0.26, outerDroop: 0.9, browRaise: 0.06, browInner: 0.92, browAngle: -1.18 }, cheekRaise: -0.5 },
|
|
338
|
-
'eyes-sad_closed': { eye: { lid: 1, curveUp: -0.40, outerDroop: 0.5, browRaise: 0.06, browInner: 0.92, browAngle: -1.18 }, cheekRaise: -0.5 },
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
// The four gaze poses, at the channel's own extremes, named for what a VIEWER
|
|
342
|
-
// sees: `gaze/left` is the eye looking towards the left of the screen, which is
|
|
343
|
-
// pupilX = -1 because + is the viewer's right. They are OPTIONAL vocabulary
|
|
344
|
-
// (src/vocab.js): a rig without them is complete, and a mixer reaches an
|
|
345
|
-
// arbitrary gaze by holding one of a pair at |pupilX| — the poses are linear in
|
|
346
|
-
// the channel by construction, since the iris stack is a pure translation.
|
|
347
|
-
export const GAZE_TABLE = {
|
|
348
|
-
'gaze/left': { eye: { pupilX: -1 } },
|
|
349
|
-
'gaze/right': { eye: { pupilX: 1 } },
|
|
350
|
-
'gaze/up': { eye: { pupilY: -1 } },
|
|
351
|
-
'gaze/down': { eye: { pupilY: 1 } },
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
// A point at fractional position t in [0,1] along a polyline. Used to hang the
|
|
355
|
-
// lower lashes off the lid arc so they ride every lid pose for free. It came
|
|
356
|
-
// over from the avatar with the eye: it has exactly one call site and that
|
|
357
|
-
// call site is here.
|
|
358
|
-
const onRun = (pts, t) => {
|
|
359
|
-
const u = clamp(t, 0, 1) * (pts.length - 1);
|
|
360
|
-
const i = Math.min(Math.floor(u), pts.length - 2), f = u - i;
|
|
361
|
-
return [lerp(pts[i][0], pts[i + 1][0], f), lerp(pts[i][1], pts[i + 1][1], f)];
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
// P keys read: eyeHalfW, eyeTopH, eyeBotH, irisR, pupilR.
|
|
365
|
-
// PALETTE keys read: sclera, scleraShade, eyeShade, carun, irisGlow, limbal,
|
|
366
|
-
// pupil, catch, catch2, water, face, lidFold, lash, crease,
|
|
367
|
-
// brow, browR.
|
|
368
|
-
// L keys read: eyeC{L,R}, bwI{L,R}, bwM{L,R}, bwO{L,R}.
|
|
369
|
-
// c keys read: eyeSize, eye/eyeL/eyeR.*.
|
|
370
|
-
export function makeEye({
|
|
371
|
-
P, PALETTE, solid, irisBase, lashWeight = 1, browWeight = 1, group = 'head',
|
|
372
|
-
px = 1, shape = {}, marks = {}, catch: catchOpt = {}, refine = {}, pen = null,
|
|
373
|
-
}) {
|
|
374
|
-
const HEAD = group;
|
|
375
|
-
const S = { ...EYE_SHAPE, ...shape };
|
|
376
|
-
const M = { ...EYE_MARKS, ...marks };
|
|
377
|
-
const C = { ...EYE_CATCH, ...catchOpt };
|
|
378
|
-
const R = { ...EYE_REFINE, ...refine, brow: { ...EYE_REFINE.brow, ...(refine.brow || {}) } };
|
|
379
|
-
// A stroke descriptor, or nothing. `rig.js` copies a stroke straight from
|
|
380
|
-
// base to out and never blends it, so a width is a constant of the draw and
|
|
381
|
-
// can never be a pose channel — which is why it is a construction input.
|
|
382
|
-
const nib = (w) => ({ w, cap: (pen && pen.cap) || 'round', join: (pen && pen.join) || 'round' });
|
|
383
|
-
const INK = pen ? solid(pen.paint) : null;
|
|
384
|
-
|
|
385
|
-
function draws(c, L, side, env = {}) {
|
|
386
|
-
const out = [];
|
|
387
|
-
const push = drawPusher(out);
|
|
388
|
-
|
|
389
|
-
// The eye opening's box. A pure function of `c.eyeSize` and the params, so
|
|
390
|
-
// computing it per side is the same three numbers the avatar used to hoist
|
|
391
|
-
// above the loop.
|
|
392
|
-
const hw = P.eyeHalfW * (1 + 0.20 * c.eyeSize);
|
|
393
|
-
const th = P.eyeTopH * (1 + 0.24 * c.eyeSize);
|
|
394
|
-
const bh = P.eyeBotH * (1 + 0.24 * c.eyeSize);
|
|
395
|
-
|
|
396
|
-
const k = side < 0 ? 'L' : 'R', s = side;
|
|
397
|
-
const [ex, ey] = L['eyeC' + k];
|
|
398
|
-
// The eye opening's two corners. Sclera, both lids and the lash line all
|
|
399
|
-
// pass through exactly these points, which is what stops white slivers
|
|
400
|
-
// leaking out of the corners when the lids move.
|
|
401
|
-
const xi = ex - s * hw, yi = ey + S.canthus[0]; // inner canthus (sits lower)
|
|
402
|
-
const xo = ex + s * hw, yo = ey + S.canthus[1]; // outer canthus (slightly up)
|
|
403
|
-
|
|
404
|
-
const e = eyeSide(c, side);
|
|
405
|
-
const lid = clamp(e.lid, 0, 1);
|
|
406
|
-
const squint = clamp(e.squint, -1, 1);
|
|
407
|
-
|
|
408
|
-
// --- lid -> px. The whole channel map, in four lines. -------------------
|
|
409
|
-
// `aperture` is the ride from shut (0) to wide (1); at lid = LID_REST it is
|
|
410
|
-
// 0.7838, which puts the upper apex on ey - th * 1.10 — the px this eye was
|
|
411
|
-
// drawn at — and that is the number the "graze" is measured from: the lash
|
|
412
|
-
// band's lower edge then lands 2.2 px INSIDE the top of the iris.
|
|
413
|
-
const meet = ey + bh * S.meet; // where the lids touch when shut
|
|
414
|
-
const aperture = Math.pow(1 - lid, S.gamma);
|
|
415
|
-
const uMid = lerp(meet, ey - th * S.top, aperture) - e.curveUp * S.curveUp
|
|
416
|
-
+ e.outerDroop * 4 * px;
|
|
417
|
-
// The lower lid holds still through most of a blink and then closes late,
|
|
418
|
-
// takes the squint, and opens a little further than rest when the eye goes
|
|
419
|
-
// wide.
|
|
420
|
-
const loOpen = ey + bh * 1.10 + Math.max(LID_REST - lid, 0) * bh * LID_LO_WIDEN
|
|
421
|
-
- squint * bh * SQUINT_LIFT;
|
|
422
|
-
const lMid = lerp(meet, loOpen, clamp((1 - lid) / LID_LO_KNEE, 0, 1))
|
|
423
|
-
- e.curveUp * S.curveUp * S.curveUpLo;
|
|
424
|
-
|
|
425
|
-
// A round eye wants its widest point near the middle, not pushed inboard
|
|
426
|
-
// the way an almond eye does — which is exactly what `shape.widest` says.
|
|
427
|
-
// The outer endpoint carries two channels of its own: `outerDroop` falls
|
|
428
|
-
// (20 px at 1.0 — at 13 the sad eye's canthus barely read at 1x) and
|
|
429
|
-
// `curveUp` levels it against the inner canthus (see CURVE_UP_OUT).
|
|
430
|
-
const outY = yo + e.outerDroop * 20 * px + e.curveUp * S.curveUpOut;
|
|
431
|
-
const up = arc([xi, yi - e.outerDroop * 2 * px], [ex - s * hw * S.widest[0], uMid],
|
|
432
|
-
[xo, outY]);
|
|
433
|
-
const lo = arc([xi, yi - e.outerDroop * 2 * px], [ex + s * hw * S.widest[1], lMid],
|
|
434
|
-
[xo, outY]);
|
|
435
|
-
const OVER = S.over; // how far each lid bites in
|
|
436
|
-
const BURY = S.bury;
|
|
437
|
-
|
|
438
|
-
const sTop = shift(up, -BURY);
|
|
439
|
-
const sBot = shift(lo, BURY);
|
|
440
|
-
const cIn = [xi + s * 9 * px, yi], cOut = [xo - s * 10 * px, yo];
|
|
441
|
-
const opening = strip(sTop, sBot.slice(1, -1));
|
|
442
|
-
opening[0] = cIn;
|
|
443
|
-
opening[up.length - 1] = cOut;
|
|
444
|
-
|
|
445
|
-
push('sclera' + k, HEAD, spline(opening, 0.85),
|
|
446
|
-
solid(side < 0 ? PALETTE.sclera : PALETTE.scleraShade));
|
|
447
|
-
|
|
448
|
-
// --- the lid's cast shadow, and the corners ---------------------------
|
|
449
|
-
// Two idioms, one slot. `cover` is the flat-fill one: the opening's own top
|
|
450
|
-
// edge, with a lower edge that is raised most in the middle (`cover` -> 0.34
|
|
451
|
-
// of the sclera's local height) and not at all at the two canthi. One shape,
|
|
452
|
-
// therefore, is both the crescent under the upper lid and the darkening into
|
|
453
|
-
// the corners, and because both of its edges are built from the same arcs
|
|
454
|
-
// the sclera is, it cannot leak past the lids in any pose. It is painted
|
|
455
|
-
// BEFORE the iris, because it is the whole socket in shadow.
|
|
456
|
-
if (M.shade === 'cover') {
|
|
457
|
-
const shTop = opening.slice(0, sTop.length);
|
|
458
|
-
const shBot = shTop.map((p, i) => {
|
|
459
|
-
const t = i / (shTop.length - 1);
|
|
460
|
-
// 0.50 at the canthi, 0.32 under the middle of the lid. 1.0 buried the
|
|
461
|
-
// two slivers of sclera the (now larger) iris leaves and the eye read as
|
|
462
|
-
// dirty; 0.66 was still dark enough at both corners that the pair read
|
|
463
|
-
// as narrowed at 1x. 0.50 keeps the modelling without the squint.
|
|
464
|
-
const cover = 0.50 - 0.18 * Math.pow(Math.sin(Math.PI * t), 0.7);
|
|
465
|
-
return [lerp(p[0], sBot[i][0], cover), p[1] + (sBot[i][1] - p[1]) * cover];
|
|
466
|
-
});
|
|
467
|
-
push('eyeShade' + k, HEAD, band(shTop, shBot, 0.85), solid(PALETTE.eyeShade));
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// caruncle: the pink of the tear duct, sitting in the darkened inner corner
|
|
471
|
-
push('carun' + k, HEAD, spline([
|
|
472
|
-
[cIn[0] + s * 1 * px, cIn[1] - 0.5 * px],
|
|
473
|
-
[cIn[0] + s * 7 * px, cIn[1] - 4.5 * px],
|
|
474
|
-
[cIn[0] + s * 7.5 * px, cIn[1] + 3.5 * px],
|
|
475
|
-
], 0.7), solid(PALETTE.carun));
|
|
476
|
-
|
|
477
|
-
// --- the iris, five layers deep, and where it is LOOKING ---------------
|
|
478
|
-
// iris body (the ONE paint the hue ladder swaps) -> inner glow, offset
|
|
479
|
-
// down-right, away from the light -> limbal ring -> pupil -> a primary
|
|
480
|
-
// catchlight upper-left and a smaller, dimmer secondary lower-right.
|
|
481
|
-
//
|
|
482
|
-
// GAZE is one translation of the whole stack: `ix`/`iy` slide and every
|
|
483
|
-
// layer is built off them, so the eye keeps its modelling at every gaze and
|
|
484
|
-
// no layer needs a pose entry of its own. Two decisions in it:
|
|
485
|
-
//
|
|
486
|
-
// * The travel is clamped by CONSTRUCTION rather than by a clamp() — the
|
|
487
|
-
// scale is the room the iris has inside its own opening, measured to the
|
|
488
|
-
// SCLERA's corners rather than to the lid arcs' (see GAZE_X_K). The lid
|
|
489
|
-
// plates are painted over the stack, so the VERTICAL travel is allowed
|
|
490
|
-
// to exceed the aperture where the horizontal one is not.
|
|
491
|
-
// * The catchlights TRAVEL WITH THE IRIS, and their offsets inside it do
|
|
492
|
-
// not change. That is the compromise this style wants: physically a
|
|
493
|
-
// catchlight is a reflection in the cornea and barely moves, but a hard
|
|
494
|
-
// white dot left behind on the sclera reads as a blemish rather than as
|
|
495
|
-
// a light, while a dot that keeps its 10-o'clock offset within the iris
|
|
496
|
-
// still says "one light source, up and to the left" at every gaze. The
|
|
497
|
-
// primary is on the SAME side in both eyes (no `s` in its offset);
|
|
498
|
-
// mirroring it, which is what this rig used to do, is exactly what made
|
|
499
|
-
// the pair read as subtly cross-eyed close up.
|
|
500
|
-
const ir = P.irisR * (1 + 0.16 * c.eyeSize);
|
|
501
|
-
const gx = clamp(e.pupilX, -1, 1) * (hw - CANTHUS_INSET * px - ir) * GAZE_X_K;
|
|
502
|
-
const dy = clamp(e.pupilY, -1, 1) - PUPIL_Y_REST; // + is DOWN the screen
|
|
503
|
-
const gy = dy * ir * (dy > 0 ? GAZE_Y_K.down : GAZE_Y_K.up);
|
|
504
|
-
const ix = ex + s * 2 * px + gx, iy = ey + 2 * px + gy;
|
|
505
|
-
push('iris' + k, HEAD, spline(circle(ix, iy, ir, 12), 1), solid(irisBase));
|
|
506
|
-
if (M.glow) {
|
|
507
|
-
push('glow' + k, HEAD, spline(circle(ix + ir * 0.07, iy + ir * 0.10, ir * 0.60, 10), 1), solid(PALETTE.irisGlow));
|
|
508
|
-
}
|
|
509
|
-
// The limbal ring, either way round: a filled annulus for a style that
|
|
510
|
-
// models with fills, or — the research sheet's own note for an outlined one,
|
|
511
|
-
// "don't double up an outline and a separate ring fill, pick one" — a STROKE
|
|
512
|
-
// at the iris edge, its radius pulled in by half its own width so the ring
|
|
513
|
-
// sits ON the boundary rather than outside it.
|
|
514
|
-
if (M.limbal === 'stroke') {
|
|
515
|
-
const w = pen.limbal || pen.lid;
|
|
516
|
-
push('limb' + k, HEAD, spline(circle(ix, iy, ir - w / 2, 12), 1),
|
|
517
|
-
solid(PALETTE.limbal), 1, { stroke: nib(w) });
|
|
518
|
-
} else {
|
|
519
|
-
push('limb' + k, HEAD, ring(ix, iy, ir, ir - ir * 0.085, 12), solid(PALETTE.limbal), 1, { rule: 'evenodd' });
|
|
520
|
-
}
|
|
521
|
-
push('pupil' + k, HEAD, spline(circle(ix, iy, P.pupilR * (1 + 0.16 * c.eyeSize), 10), 1), solid(PALETTE.pupil));
|
|
522
|
-
|
|
523
|
-
// The other idiom's shadow: a crescent hung off the SAME arc the lid is, so
|
|
524
|
-
// it rides the blink for free and is swallowed by the lid plate when the
|
|
525
|
-
// arcs meet; deepest at the middle, zero at both canthi. It goes AFTER the
|
|
526
|
-
// iris, not before it — a shadow that stops dead at the limbus reads as a
|
|
527
|
-
// grey stripe painted on the white.
|
|
528
|
-
if (M.shade === 'crescent') {
|
|
529
|
-
push('eyeShade' + k, HEAD, band(
|
|
530
|
-
shift(up, 0.6 * px),
|
|
531
|
-
bulge(up, 9 * px, { floor: 0.6 * px, power: 0.6 }),
|
|
532
|
-
0.85,
|
|
533
|
-
), solid(PALETTE.eyeShade));
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
push('catch' + k, HEAD, spline(circle(ix + ir * C.at[0], iy + ir * C.at[1], ir * C.r, 7), 1), solid(PALETTE.catch));
|
|
537
|
-
push('catch2' + k, HEAD, spline(circle(ix + ir * C.at2[0], iy + ir * C.at2[1], ir * C.r2, 6), 1), solid(PALETTE.catch2));
|
|
538
|
-
|
|
539
|
-
// --- waterline ---------------------------------------------------------
|
|
540
|
-
// A soft line inside the lower lid, above the edge `lidLo` will paint over
|
|
541
|
-
// (`lo - OVER`, i.e. `sBot - 7`), so a 7px band at -16..-9 stays visible
|
|
542
|
-
// without floating free of the lid. It crosses the bottom of the iris,
|
|
543
|
-
// which is what the real lower lid does.
|
|
544
|
-
if (M.water) {
|
|
545
|
-
const wl = (dy2) => bulge(sBot, -dy2, { power: 0.5 });
|
|
546
|
-
push('water' + k, HEAD, band(wl(16.5 * px), wl(9.5 * px), 0.8), solid(PALETTE.water));
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Lid plates. In a faceted face these were planes in their own right; here
|
|
550
|
-
// they are the face tone, and everything above is painted BEFORE them, so a
|
|
551
|
-
// blink occludes the eyeball by overlap and not by a mask. They can be one
|
|
552
|
-
// flat colour on both sides only because the shading plane is translucent
|
|
553
|
-
// and painted after them.
|
|
554
|
-
const loRun = shift(lo, -OVER); // the lower lid's painted edge
|
|
555
|
-
push('lidLo' + k, HEAD, spline([
|
|
556
|
-
...loRun,
|
|
557
|
-
[xo + s * 22 * px, ey + bh + 22 * px], [ex, ey + bh + 32 * px], [xi - s * 20 * px, ey + bh + 18 * px],
|
|
558
|
-
], 0.85), solid(PALETTE.face));
|
|
559
|
-
|
|
560
|
-
// The squint's own mark. A raised lower lid is not just a higher arc: it is
|
|
561
|
-
// a roll of cheek pushed up under the eye, and without the shade under it
|
|
562
|
-
// the arc reads as the lid having been TRIMMED rather than lifted — which
|
|
563
|
-
// is exactly how a Duchenne smile fails. A crescent hung off the lower
|
|
564
|
-
// lid's painted edge, deepest a little outboard of centre, fading in with
|
|
565
|
-
// the channel. It sits on the cheek side of that edge, so it never touches
|
|
566
|
-
// the sclera whatever the lid is doing.
|
|
567
|
-
if (M.squintShade) {
|
|
568
|
-
push('squintSh' + k, HEAD, band(
|
|
569
|
-
bulge(loRun, 5 * px, { skew: 1.15 }),
|
|
570
|
-
bulge(loRun, 21 * px, { skew: 1.15, power: 0.8 }), 0.85,
|
|
571
|
-
), solid(PALETTE.lidFold), clamp(squint * 1.35, 0, 1));
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
push('lidUp' + k, HEAD, spline([
|
|
575
|
-
...shift(up, OVER),
|
|
576
|
-
[xo + s * 26 * px, ey - th - 34 * px], [ex, ey - th - 46 * px], [xi - s * 22 * px, ey - th - 30 * px],
|
|
577
|
-
], 0.85), solid(PALETTE.face));
|
|
578
|
-
|
|
579
|
-
// The upper lid's THICKNESS. The plate above is flat face tone, so without
|
|
580
|
-
// this the lid line reads as a cut in the face rather than as an edge with
|
|
581
|
-
// a lid behind it: a 13 px band of lid plane, pinned to both canthi so it
|
|
582
|
-
// comes to a point where the lid does, sitting between the lash below it
|
|
583
|
-
// and the crease above. It rides `up`, so it is the same shape at every lid
|
|
584
|
-
// value and needs no pose logic of its own.
|
|
585
|
-
if (M.lidFold) {
|
|
586
|
-
push('lidFold' + k, HEAD, band(
|
|
587
|
-
bulge(up, -R.lidFoldDepth * px, { skew: 1.1, pin: true }), up, 0.85,
|
|
588
|
-
), solid(PALETTE.lidFold), R.lidFoldAlpha);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// The lower lid LINE — an outlined style's answer to the waterline, and the
|
|
592
|
-
// one mark here whose alpha is not a channel but a consequence: both lid
|
|
593
|
-
// runs converge on the same arc when the eye closes, and two coincident
|
|
594
|
-
// strokes read as one lumpy heavier one. Width is not a pose channel, so
|
|
595
|
-
// fading it out is the only way to say "this line is not there".
|
|
596
|
-
if (M.lidLine && pen) {
|
|
597
|
-
push('lidLoInk' + k, HEAD, openSpline(lo, 0.85), INK,
|
|
598
|
-
clamp((aperture - 0.08) / 0.30, 0, 1), { stroke: nib(pen.lid) });
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// crease: a hairline arc a little above the lash, the one bit of "line
|
|
602
|
-
// work" a flat-fill style allows itself above the eye; an outlined one has
|
|
603
|
-
// a pen for it and draws the same offset run with it, thinner and
|
|
604
|
-
// translucent so a crease never competes with the lash line.
|
|
605
|
-
// A pen crease can take its offsets from a table instead: a real fold is
|
|
606
|
-
// deepest over the outer third and converges at both canthi, and five
|
|
607
|
-
// authored numbers say that more exactly than any easing of a bulge does.
|
|
608
|
-
const cr = shift(up, -17 * px);
|
|
609
|
-
if (pen && (Array.isArray(M.crease) || M.crease === 'line')) {
|
|
610
|
-
const inner = up.slice(1, -1);
|
|
611
|
-
push('crease' + k, HEAD, openSpline(
|
|
612
|
-
Array.isArray(M.crease)
|
|
613
|
-
? inner.map(([x, y], j) => [x, y - M.crease[j]])
|
|
614
|
-
: bulge(inner, -6 * px, { floor: -8 * px, skew: 1.4, power: 0.7 }),
|
|
615
|
-
0.9,
|
|
616
|
-
), INK, 0.58, { stroke: nib(pen.crease) });
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// lash line: the upper arc thickened by a bump that tapers to nothing at
|
|
620
|
-
// both canthi, so it comes to a point rather than a stub, plus a small
|
|
621
|
-
// outward flick at the outer corner.
|
|
622
|
-
// The two ends are pinned to the canthi exactly. A lash that tapers to a
|
|
623
|
-
// constant +1.5px instead leaves a pair of free-floating dark whiskers
|
|
624
|
-
// just outside the eye corners — invisible in the source, obvious at 2x.
|
|
625
|
-
// The bulge peaks at t = 0.68 — `up` runs inner canthus -> outer, so that
|
|
626
|
-
// is the outer third the research puts the mass of a lash in, not the 0.43
|
|
627
|
-
// this used to peak at. The +2.2 floor is the lash line continuing inward
|
|
628
|
-
// as a thin dark lid line rather than disappearing.
|
|
629
|
-
// `lash.weight` scales the bump and its floor together, so a lighter lash
|
|
630
|
-
// is the same line drawn thinner rather than a shorter one.
|
|
631
|
-
//
|
|
632
|
-
// At lid = 1 this band IS the closed eye: both arcs sit on `meet`, the
|
|
633
|
-
// sclera between them has no height left, and what is on the face is one
|
|
634
|
-
// curve with a lash on it, bowed 20 px below the line between the two
|
|
635
|
-
// canthi. `LID_MEET` is what buys that bow — the reference sheet's CLOSED
|
|
636
|
-
// cell dips 0.17 of the eye's width, and a lid that meets on the canthus
|
|
637
|
-
// line reads as a drawn-on dash.
|
|
638
|
-
//
|
|
639
|
-
// An OUTLINED eye splits that in two: the lid line is a stroke (it is the
|
|
640
|
-
// whole of a closed eye and it has to be the same pen as everything else on
|
|
641
|
-
// the face), and the mass over it is a filled wedge whose profile is given
|
|
642
|
-
// per station — thick at the outer third, nothing at the inner canthus, and
|
|
643
|
-
// NOT pinned at the outer end, because that flick is the lash.
|
|
644
|
-
const lw = lashWeight;
|
|
645
|
-
if (pen && pen.lash) push('lashInk' + k, HEAD, openSpline(up, 0.85), INK, 1, { stroke: nib(pen.lash) });
|
|
646
|
-
if (Array.isArray(M.lash)) {
|
|
647
|
-
push('lash' + k, HEAD, band(
|
|
648
|
-
up.map(([x, y], i) => [x, y - M.lash[i] * lw]),
|
|
649
|
-
shift(up, (pen ? pen.lash : 0) * 0.42),
|
|
650
|
-
0.9,
|
|
651
|
-
), solid(PALETTE.lash));
|
|
652
|
-
} else {
|
|
653
|
-
const lashB = bulge(up, 12.5 * px * lw, { floor: 2.2 * px * lw, skew: 1.8, pin: true });
|
|
654
|
-
push('lash' + k, HEAD, band(up, lashB, 0.8), solid(PALETTE.lash));
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Two lower lash ticks at the outer corner and nowhere else — full lower
|
|
658
|
-
// lashes turn a friendly face into a doll's. They hang off `lo - OVER` —
|
|
659
|
-
// the edge `lidLo` actually paints to — not off the buried `sBot`, so they
|
|
660
|
-
// ride every lid pose. Anchored 3px below that edge the whole tick lands on
|
|
661
|
-
// skin and reads as a detached claw; straddling it, it reads as a lash.
|
|
662
|
-
// Their alpha follows the lid so a closed eye has no lashes standing under
|
|
663
|
-
// a shut lid.
|
|
664
|
-
if (M.lashLo) {
|
|
665
|
-
push('lashLo' + k, HEAD, contours(
|
|
666
|
-
...[[0.74, 5.5], [0.88, 4.2]].map(([t, len]) => {
|
|
667
|
-
const q = onRun(loRun, t);
|
|
668
|
-
return spline([[q[0] - s * 1.7 * px, q[1] - 3 * px], [q[0] + s * 2.2 * px, q[1] + len * px * lw], [q[0] + s * 1.7 * px, q[1] - 3 * px]], 0.6);
|
|
669
|
-
}),
|
|
670
|
-
), solid(PALETTE.lash), clamp((1 - lid) * 2 - 0.6, 0, 1) * 0.5 * lw);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (M.crease === 'band') {
|
|
674
|
-
const crB = bulge(cr, R.creaseDepth * px, { pin: true });
|
|
675
|
-
push('crease' + k, HEAD, band(cr, crB, 0.8), solid(PALETTE.crease));
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// --- brow: a soft tapered arch peaking at the outer third ---------------
|
|
679
|
-
// A CENTRE LINE plus a half-thickness, so `brow.weight` thickens the brow
|
|
680
|
-
// about its own axis and does not move its length, its tilt or where it
|
|
681
|
-
// sits: inner station centre +3 half 9, middle station centre -0.5 half
|
|
682
|
-
// 10.5. The outer tip and the inner cap lie ON the centre line and do not
|
|
683
|
-
// scale at all — which is what keeps a heavy brow a brow rather than a
|
|
684
|
-
// rectangle, since the taper at both ends is the shape's whole character.
|
|
685
|
-
// At weight 1 these are the six points that were hard-coded here.
|
|
686
|
-
const bw = browWeight;
|
|
687
|
-
const bI = L['bwI' + k], bM = L['bwM' + k], bO = L['bwO' + k];
|
|
688
|
-
// The shape half of the three brow channels (BROW_SHAPE). Every term is a
|
|
689
|
-
// product with a channel, so every one of them is EXACTLY zero at rest and
|
|
690
|
-
// the six points above are the six points that were here.
|
|
691
|
-
const B = BROW_SHAPE;
|
|
692
|
-
// `u` is -1 at the inner end and +1 at the outer, measured about the middle
|
|
693
|
-
// station in the face's own direction, which is what makes `tilt` a
|
|
694
|
-
// rotation rather than a shear that also moves the head of the brow.
|
|
695
|
-
const half = Math.max(1, Math.abs(bO[0] - bI[0]) / 2);
|
|
696
|
-
const tilt = (x) => e.browAngle * B.tilt * px * (((x - bM[0]) * s) / half);
|
|
697
|
-
const archM = e.browRaise * B.arch * px;
|
|
698
|
-
const archI = e.browRaise * B.archIn * px;
|
|
699
|
-
const tipI = e.browInner * B.tip * px;
|
|
700
|
-
const tipC = e.browInner * B.cap * px;
|
|
701
|
-
const pinch = e.browInner * B.pinch * px;
|
|
702
|
-
const tI = tilt(bI[0]), tM = tilt(bM[0]), tO = tilt(bO[0]);
|
|
703
|
-
const browPts = [
|
|
704
|
-
[bI[0] - s * 2 * px - s * pinch, bI[1] + 3 * px - 9 * px * bw * R.brow.head - tI - tipI + archI],
|
|
705
|
-
[bM[0], bM[1] - 0.5 * px - 10.5 * px * bw * R.brow.peak - tM - archM],
|
|
706
|
-
[bO[0] + s * 11 * px * R.brow.tail, bO[1] + 3 * px - tO],
|
|
707
|
-
[bM[0], bM[1] - 0.5 * px + 10.5 * px * bw * R.brow.peak - tM - archM],
|
|
708
|
-
[bI[0] - s * 2 * px - s * pinch, bI[1] + 3 * px + 9 * px * bw * R.brow.head - tI - tipI + archI],
|
|
709
|
-
// cap: rounds the inner head of the brow — and carries `browInner`'s
|
|
710
|
-
// rotation, being the point furthest from the pivot.
|
|
711
|
-
[bI[0] - s * 9 * px * R.brow.head - s * pinch, bI[1] + 3 * px - tI - tipC + archI],
|
|
712
|
-
];
|
|
713
|
-
push('brow' + k, HEAD, spline(browPts, 0.9), solid(side < 0 ? PALETTE.brow : PALETTE.browR));
|
|
714
|
-
if (pen && pen.brow) {
|
|
715
|
-
push('browInk' + k, HEAD, spline(browPts, 0.9), INK, 1, { stroke: nib(pen.brow) });
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
return out;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
return { rest: eyeChannelRest(), draws };
|
|
722
|
-
}
|