@voqalize/avatar 0.4.2 → 0.4.4
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} +708 -547
- 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} +664 -525
- 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 +99 -220
- package/src/gaze.js +1 -3
- package/src/idle.js +10 -2
- package/src/prosody.js +7 -3
- package/src/speech-timing.js +28 -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,741 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// author/parts/mouth.mjs — the round idiom's mouth, as a PART, in CHANNEL SPACE.
|
|
3
|
-
//
|
|
4
|
-
// A PART is one feature of a face — a mouth, an eye — packaged so that the
|
|
5
|
-
// avatar's build.mjs no longer holds its geometry. It has two APIs, and the
|
|
6
|
-
// split between them is the whole idea:
|
|
7
|
-
//
|
|
8
|
-
// CONSTRUCTION (build time, once per persona)
|
|
9
|
-
// makeMouth({ P, PALETTE, solid, group }) -> part
|
|
10
|
-
// Everything that is a constant of THIS character: proportions, the
|
|
11
|
-
// resolved palette, the persona's paint registry, the group tag. A second
|
|
12
|
-
// persona is a second `makeMouth` with a second palette; they never share.
|
|
13
|
-
//
|
|
14
|
-
// DRIVING (once per control vector, so once per pose)
|
|
15
|
-
// part.draws(c, L, env) -> [ { slot, group, cmds, paint, a, ...extra }, ... ]
|
|
16
|
-
// An ORDERED array — draw order IS paint order and the part owns it. `c` is
|
|
17
|
-
// the control vector, `L` the landmarks the avatar solved for that vector,
|
|
18
|
-
// `env` anything else a caller needs to hand in (nothing, for this mouth).
|
|
19
|
-
//
|
|
20
|
-
// REST BLOCKS
|
|
21
|
-
// mouthRestChannels() -> the `mouth:` sub-object THIS part implements.
|
|
22
|
-
// mouthRest() -> the LEGACY nine-key block, kept because
|
|
23
|
-
// `REST_CONTROLS` is shared with two avatars whose
|
|
24
|
-
// mouths are still inline (see below).
|
|
25
|
-
//
|
|
26
|
-
// The fixed-opcode rule (author/path.mjs) is the part's contract with the
|
|
27
|
-
// runtime: every shape here is emitted by a primitive whose opcode count
|
|
28
|
-
// depends on the point COUNT and never on the point VALUES, at a point count
|
|
29
|
-
// that is a literal. So a pose is a straight rebuild-and-diff (author/rig.mjs,
|
|
30
|
-
// `poseHarness`) and topology can never drift — which is why a part gets its
|
|
31
|
-
// poses for free and does not carry any of its own.
|
|
32
|
-
//
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// THE DRIVER BLOCK (voqalize `PoseChannel`, the 10 mouth channels)
|
|
35
|
-
//
|
|
36
|
-
// channel rest range meaning (voqalize params.js / avatar.d.ts)
|
|
37
|
-
// open 0.02 0..1 VISIBLE aperture height — the dark gap, not
|
|
38
|
-
// the lip-centreline distance
|
|
39
|
-
// width 0.42 0..1 corner-to-corner span
|
|
40
|
-
// round 0.10 0..1 pucker: rounds AND narrows the aperture
|
|
41
|
-
// press 0.15 0..1 lips thinned and pressed together
|
|
42
|
-
// tuck 0.0 0..1 lower lip drawn under the upper teeth (F/V)
|
|
43
|
-
// cornerL/R 0.0 -1.4..1.4 per-side corner lift, + = smile
|
|
44
|
-
// teeth 0.0 0..1 upper-teeth reveal
|
|
45
|
-
// tongue 0.0 0..1 tongue raised into the aperture
|
|
46
|
-
// c.jaw 0.0 0..1 chin drop — TOP-LEVEL, not in this block,
|
|
47
|
-
// because the avatar's own landmark solve
|
|
48
|
-
// reads it (the whole jaw silhouette moves).
|
|
49
|
-
// The mouth reads it too: the lower lip
|
|
50
|
-
// follows the chin by JAW_FOLLOW of the drop.
|
|
51
|
-
// voqalize derives it as jaw = 0.7 * open.
|
|
52
|
-
//
|
|
53
|
-
// PART-LOCAL EXTRAS — driver params with no counterpart in the contract, kept
|
|
54
|
-
// because a shape here needs them and documented as this part's own:
|
|
55
|
-
// tongueUp 0..1 tongue POSITION, not amount: 0 behind the lower teeth,
|
|
56
|
-
// 1 up at the upper lip. voqalize has one scalar `tongue`,
|
|
57
|
-
// so the `th` viseme (the tongue between the teeth) has
|
|
58
|
-
// nowhere else to live. Lerped, never branched on, so it
|
|
59
|
-
// interpolates like everything else.
|
|
60
|
-
// c.lipFull an IDENTITY morph (`morph/lips_±100`), not a performance
|
|
61
|
-
// channel; it stays on the control vector.
|
|
62
|
-
//
|
|
63
|
-
// WHAT `upRaise` AND `cornerY` BECAME. `upRaise` (px, raised the upper lip's
|
|
64
|
-
// inner edge) is gone into `press`, which thins BOTH lips, flattens the bow and
|
|
65
|
-
// straightens the seam — a shape change rather than a translation, because
|
|
66
|
-
// A-vs-X has to be topological (see the fidelity note). `cornerY` (one px
|
|
67
|
-
// value, + = down, both corners) split into `cornerL`/`cornerR` in the
|
|
68
|
-
// contract's own sign and units (+ = smile = up), which is what finally lets
|
|
69
|
-
// this face smirk.
|
|
70
|
-
//
|
|
71
|
-
// THE LEGACY BLOCK. `author/rig.mjs`'s `REST_CONTROLS` is shared by all three
|
|
72
|
-
// avatars, and `avatars/{facet,ink}/build.mjs` still hold their mouths inline
|
|
73
|
-
// against the OLD nine keys (`m.w`, `m.open` in px, `m.protrude`, …). So
|
|
74
|
-
// `mouthRest()` still returns those, `REST_CONTROLS` is untouched, and the one
|
|
75
|
-
// avatar that has adopted the part composes its own vector:
|
|
76
|
-
//
|
|
77
|
-
// const ctrl = makeCtrl({ ...REST_CONTROLS, mouth: mouthRestChannels() });
|
|
78
|
-
//
|
|
79
|
-
// When ink grows `mouth-ink.mjs` it does the same and `mouthRest()` goes.
|
|
80
|
-
//
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// FIDELITY — where the numbers below come from
|
|
83
|
-
//
|
|
84
|
-
// `parts/ref/mouth/v2-high.png` is nine drawn Rhubarb visemes; its
|
|
85
|
-
// `.measure.json` is those drawings as numbers, normalised by each cell's own
|
|
86
|
-
// corner span. `MAP` is fitted to them (`parts/README.md` for the schema,
|
|
87
|
-
// avatars/round/NOTES.md for the residuals). What the sheet taught, in order
|
|
88
|
-
// of how much geometry it moved:
|
|
89
|
-
//
|
|
90
|
-
// * The inner opening is NOT the corner-to-corner gap. It is its own pair of
|
|
91
|
-
// arcs, inset from the corners by an amount that is mostly a function of
|
|
92
|
-
// `round` — 0.73 of the mouth's width at B, 0.95 at D, 0.12 at F. The old
|
|
93
|
-
// mouth ran its lip edges corner to corner, so every open viseme showed an
|
|
94
|
-
// aperture the full width of the mouth and F could not pucker at all.
|
|
95
|
-
// * The upper lip has a cupid's bow with two peaks and a philtrum dip about
|
|
96
|
-
// 13% of the lip's thickness deep, tapering to nothing at the corners; the
|
|
97
|
-
// lower lip is ~15% thicker than the upper at rest and stays fuller as the
|
|
98
|
-
// mouth opens (the sheet's `lip_th_ratio` is 0.86 at X and 0.70 at D).
|
|
99
|
-
// * Both lips thin as the mouth opens (foreshortening) and thicken under
|
|
100
|
-
// `round` (the pucker bunches them).
|
|
101
|
-
// * The teeth are a row of roughly CONSTANT height — `teeth_reveal` is 0.95 at
|
|
102
|
-
// B and 0.08 at D not because the row grows but because the hole does.
|
|
103
|
-
//
|
|
104
|
-
// And three things the sheet could not tell us, authored by hand from the
|
|
105
|
-
// channel semantics instead:
|
|
106
|
-
// * G is a second B in the drawing (the model will not draw F/V), so G is
|
|
107
|
-
// authored from the tuck: the lower lip rides up under the teeth, the row
|
|
108
|
-
// overlaps it, and there is no dark gap left at all.
|
|
109
|
-
// * A and X are one drawing. They differ here by `press` alone: thinner lips,
|
|
110
|
-
// a flatter bow, a straighter seam and a slightly narrower mouth.
|
|
111
|
-
// * The sheet's mouth WIDTH was pinned by the prompt, so width comes from the
|
|
112
|
-
// channel (voqalize's own `26 + 32·width`, normalised), not from the sheet.
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
import { eqp, polygon, spline, openSpline, arc, circle, shift, band, bulge, contours } from '../path.mjs';
|
|
116
|
-
import { clamp, lerp, drawPusher } from '../rig.mjs';
|
|
117
|
-
|
|
118
|
-
// The `mouth:` block of the control vector, at rest — voqalize's REST, verbatim,
|
|
119
|
-
// plus this part's one extra. Sixteen visemes and nine Rhubarb letters are
|
|
120
|
-
// patches on these ten numbers and nothing else.
|
|
121
|
-
export function mouthRestChannels() {
|
|
122
|
-
return {
|
|
123
|
-
open: 0.02, width: 0.42, round: 0.10, press: 0.15, tuck: 0,
|
|
124
|
-
cornerL: 0, cornerR: 0, teeth: 0, tongue: 0,
|
|
125
|
-
tongueUp: 0,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// The pre-channel block. Only `author/rig.mjs`'s REST_CONTROLS calls this, and
|
|
130
|
-
// only because facet and ink still read those keys out of it. See the header.
|
|
131
|
-
export function mouthRest() {
|
|
132
|
-
return {
|
|
133
|
-
w: 1, open: 0, cornerY: 0, upRaise: 0, protrude: 0,
|
|
134
|
-
teeth: 0, tongue: 0, tongueUp: 0, lowTuck: 0,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
139
|
-
// THE FRONT DOOR: channel units -> design-space pixels.
|
|
140
|
-
//
|
|
141
|
-
// Every number that converts a 0..1 channel into a distance on this
|
|
142
|
-
// character's face is here and nowhere else. The ones marked FIT were chosen
|
|
143
|
-
// by least squares against the reference sheet (`parts/fit-mouth.mjs`); the
|
|
144
|
-
// rest are style, and say so.
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
export const MAP = {
|
|
147
|
-
// --- corner span ---------------------------------------------------------
|
|
148
|
-
// voqalize's own width law (`w = 26 + 32·mouthWidth`, face-peep.js),
|
|
149
|
-
// normalised so that width = 0.42 is exactly this character's `P.mouthHalfW`.
|
|
150
|
-
// STYLE: the sheet pinned its corners, so it has nothing to say here.
|
|
151
|
-
WIDTH_A: 0.659, WIDTH_B: 0.811,
|
|
152
|
-
// voqalize's peep pulls the corners in by 0.36 per unit of round. The sheet
|
|
153
|
-
// disagrees and it can be checked: its F is the most puckered mouth in the
|
|
154
|
-
// set and still 0.84 of X's width. At 0.36 this face's F was 0.50 of rest,
|
|
155
|
-
// and a lower lip 21 units thick inside a 96-unit mouth stopped reading as a
|
|
156
|
-
// lip and started reading as a kite. FIT to `width_vs_rest`, F and E.
|
|
157
|
-
ROUND_NARROW: 0.18, // pucker pulls the corners in
|
|
158
|
-
PRESS_NARROW: 0.30, // pressed lips are a little narrower
|
|
159
|
-
|
|
160
|
-
// --- the aperture --------------------------------------------------------
|
|
161
|
-
// FIT says 147.4 — the sheet's D is 0.688 of its own corner span. STYLE CAP
|
|
162
|
-
// at 105: this face has 190 design units between the nose base and the chin,
|
|
163
|
-
// and a 125-unit hole put the mouth interior on the jawline. A deliberate
|
|
164
|
-
// -29% deviation from the sheet, and the largest one here.
|
|
165
|
-
APER_PX: 105, // visible aperture height at open = 1
|
|
166
|
-
AH_ROUND: 0.565, // FIT: a pucker closes the hole vertically too
|
|
167
|
-
APER_UP: 0.34, // STYLE: share of it that opens UPWARD. The rest
|
|
168
|
-
// goes down, and the jaw takes the corners with it.
|
|
169
|
-
APER_POW: 0.80, // STYLE: <1 fattens the lens toward a rectangle
|
|
170
|
-
// aperture WIDTH, in units of the REST corner span. FIT.
|
|
171
|
-
AW_0: 0.685, AW_W: 0.296, AW_O: 0.138, AW_ROUND: 0.909,
|
|
172
|
-
AW_MAX: 0.94, // never wider than this share of the corner span
|
|
173
|
-
|
|
174
|
-
// --- corners -------------------------------------------------------------
|
|
175
|
-
// STYLE: peep travels 0.61 of its half-span per unit of corner. This face
|
|
176
|
-
// carries its corners only 10 units above the opening, and 0.61 put a smile
|
|
177
|
-
// in the cheek; 0.33 keeps a +0.3 smile at the 9 px the old `cornerY` used.
|
|
178
|
-
CORNER_PX: 32,
|
|
179
|
-
CORNER_MID: 0.5, // how much of the corner lift the centre follows
|
|
180
|
-
|
|
181
|
-
// --- lip mass ------------------------------------------------------------
|
|
182
|
-
// FIT says 0.587 / 0.504 — the sheet's D keeps 0.59 of its upper lip and 0.73
|
|
183
|
-
// of its lower. STYLE CAP at 0.35 / 0.30: the sheet's lips are twice this
|
|
184
|
-
// character's to begin with (0.166 of the corner span against round's 15/192
|
|
185
|
-
// = 0.078), so the same RATIO leaves a 6 px rim where the sheet still has a
|
|
186
|
-
// lip. Faithful to the sheet's ratio, this face's D reads as a hole in a chin.
|
|
187
|
-
TH_OPEN_U: 0.35, TH_OPEN_L: 0.30, // lips thin as the mouth opens
|
|
188
|
-
TH_POW: 0.5, // FIT: …fast at first, then flattening
|
|
189
|
-
TH_ROUND_U: 0.084, TH_ROUND_L: 0.046, // FIT: the pucker bunches them
|
|
190
|
-
PRESS_THIN: 0.70, // STYLE: the sheet drew A as X, so press is ours
|
|
191
|
-
PRESS_STRAIGHT: 1.9, // STYLE: press pulls the seam onto the corner chord
|
|
192
|
-
PRESS_BOW: 0.9, // STYLE: press flattens the cupid's bow
|
|
193
|
-
BOW: 0.14, // FIT: bow depth as a share of upper-lip thickness
|
|
194
|
-
ROUND_RING: 0.85, // STYLE: how far a full pucker pushes the lips' mass
|
|
195
|
-
RING_X: [-1, -0.87, -0.42, 0, 0.42, 0.87, 1], // ...out toward these stations
|
|
196
|
-
LIPFULL_U: 0.55, LIPFULL_L: 0.60, // the identity morph, unchanged
|
|
197
|
-
|
|
198
|
-
// --- teeth, tongue, tuck -------------------------------------------------
|
|
199
|
-
TEETH_PX: 24.7, // FIT: height of the upper row at teeth = 1
|
|
200
|
-
TEETH_W: 0.82, // its width, as a share of the aperture's
|
|
201
|
-
TEETH_FILL: 0.82, // how much of a closed-ish aperture it may fill
|
|
202
|
-
TUCK_RISE: 0.85, TUCK_MIN: 6, // lower aperture edge rides up under tuck
|
|
203
|
-
TUCK_OVER: 28, // …and the teeth row is allowed that far past it
|
|
204
|
-
TUCK_THIN: 0.50, // the tucked lower lip is rolled in, so thinner
|
|
205
|
-
TONGUE_RISE: 0.78, // how far up the aperture the tongue comes at 1
|
|
206
|
-
TONGUE_W: 0.72, // its width, as a share of the aperture's
|
|
207
|
-
// The tongue is drawn BEHIND the lips but it is not CLIPPED by them, so its
|
|
208
|
-
// ellipse has to be bounded by the aperture and not by the aperture's height:
|
|
209
|
-
// sized off `(dUp+dLo)` alone, D's disc hung ~90 px below a lower lip only 11
|
|
210
|
-
// px thick and read as a pink chin. It may reach this far past the lower arc
|
|
211
|
-
// and no further, which is just enough for the lip to cover the seam.
|
|
212
|
-
TONGUE_BOT_PAD: 2,
|
|
213
|
-
// …and its opacity floor: at `tongue` .15 (Rhubarb D) a visible tongue is a
|
|
214
|
-
// second lip, so the ramp starts at .10 and still saturates by .56 (H is .9).
|
|
215
|
-
TONGUE_A0: 0.10, TONGUE_AK: 2.2,
|
|
216
|
-
|
|
217
|
-
// --- the jaw -------------------------------------------------------------
|
|
218
|
-
JAW_FOLLOW: 0.24, // share of P.jawDrop the lower lip takes with it
|
|
219
|
-
SEAM_FADE: [14, 10], // the seam is gone this many px of aperture later
|
|
220
|
-
|
|
221
|
-
// --- expression: what `cornerL/R` and `press` do BESIDES moving a corner --
|
|
222
|
-
// The corner channels are the mixer's only mouth-side expression, and a
|
|
223
|
-
// corner that only translates reads as a mouth with a hinge. A real smile is
|
|
224
|
-
// a composite: the corners go up AND out, the lip line CURVES between them
|
|
225
|
-
// (flat through the middle, turning at the ends), the upper lip stretches
|
|
226
|
-
// thin over it, the lower lip rolls full, and the commissure pocket deepens.
|
|
227
|
-
// A frown is not that composite reversed — the lower lip presses IN, the
|
|
228
|
-
// corners draw slightly inward, and the pocket sharpens instead of deepening.
|
|
229
|
-
// Every one of these is written so it is EXACTLY 1 (or 0) at cornerL/R = 0:
|
|
230
|
-
// `mouthRestChannels()` still renders this character's rest pose to the byte.
|
|
231
|
-
SMILE_OUT: 0.085, // corners travel outward, per unit of +corner
|
|
232
|
-
FROWN_IN: 0.055, // …and inward on a frown
|
|
233
|
-
SMILE_MID: 0.40, // the centre follows the corners LESS the harder
|
|
234
|
-
// they pull — which is where the curvature comes from
|
|
235
|
-
SEAM_FLAT: 0.50, // …and the lip line flattens in the middle as it does
|
|
236
|
-
SMILE_BOWFLAT: 0.55, // the cupid's bow flattens as the upper lip stretches
|
|
237
|
-
SMILE_THIN_U: 0.22, // …and the upper lip thins with it
|
|
238
|
-
SMILE_FULL_L: 0.16, // the lower lip rolls full
|
|
239
|
-
SMILE_LO_BOW: 0.9, // …and rounder: its profile stations bow out
|
|
240
|
-
FROWN_THIN_L: 0.22, // a frown presses the lower lip in and thin
|
|
241
|
-
CM_R: 0.50, // the commissure pocket deepens with a smile
|
|
242
|
-
CM_WIDE: 0.22, // …and flattens, being stretched between the lips
|
|
243
|
-
CM_SHARP: 0.35, // a frown makes it smaller
|
|
244
|
-
CM_ASP: 0.45, // …and taller: a pinch rather than a pocket
|
|
245
|
-
CM_DX: 0.55, // both ride further into the corner
|
|
246
|
-
SMILE_SEAM: 0.55, // a smile is READ off the line between the lips, so
|
|
247
|
-
// that line thickens as the corners travel: at this
|
|
248
|
-
// size the corner displacement is already 1.7x
|
|
249
|
-
// peep's and the thing peep beats us on is CONTRAST
|
|
250
|
-
// — a 4.5-unit ribbon under a pink lip against a
|
|
251
|
-
// 6-unit black stroke on white.
|
|
252
|
-
PRESS_SEAM: 0.85, // press above REST widens the seam into a line
|
|
253
|
-
PRESS_IN: 0.07, // …and draws the corners in
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
// The rest normalisers. Every law above is written in the contract's units and
|
|
257
|
-
// then divided by its own value at REST, so `mouthRestChannels()` reproduces
|
|
258
|
-
// this character's proportions exactly whatever the constants are — which is
|
|
259
|
-
// what lets the fit move them without moving the rest pose.
|
|
260
|
-
const R = mouthRestChannels();
|
|
261
|
-
const widthFactor = (width, round, press) =>
|
|
262
|
-
(MAP.WIDTH_A + MAP.WIDTH_B * width) * (1 - MAP.ROUND_NARROW * round) * (1 - MAP.PRESS_NARROW * press);
|
|
263
|
-
const upFactor = (open, round, press) =>
|
|
264
|
-
(1 - MAP.TH_OPEN_U * Math.pow(open, MAP.TH_POW)) * (1 + MAP.TH_ROUND_U * round) * (1 - MAP.PRESS_THIN * press);
|
|
265
|
-
const loFactor = (open, round, press) =>
|
|
266
|
-
(1 - MAP.TH_OPEN_L * Math.pow(open, MAP.TH_POW)) * (1 + MAP.TH_ROUND_L * round) * (1 - MAP.PRESS_THIN * press);
|
|
267
|
-
const REST_W = () => widthFactor(R.width, R.round, R.press);
|
|
268
|
-
const REST_U = () => upFactor(R.open, R.round, R.press);
|
|
269
|
-
const REST_L = () => loFactor(R.open, R.round, R.press);
|
|
270
|
-
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
// THE FRAME: one control vector's worth of mouth, as numbers and point runs.
|
|
273
|
-
// `draws` paints it and `mouthMetrics` measures it, so what the fit measures
|
|
274
|
-
// is exactly what the rig draws.
|
|
275
|
-
//
|
|
276
|
-
// P keys read: mouthHalfW, lipUpTh, lipLowTh, jawDrop.
|
|
277
|
-
// L keys read: mcorL, mcorR, mth_c.
|
|
278
|
-
// c keys read: mouth.*, jaw, lipFull.
|
|
279
|
-
// ---------------------------------------------------------------------------
|
|
280
|
-
export function mouthFrame(P, c, L) {
|
|
281
|
-
const m = c.mouth;
|
|
282
|
-
const open = clamp(m.open, 0, 1), width = clamp(m.width, 0, 1);
|
|
283
|
-
const round = clamp(m.round, 0, 1), press = clamp(m.press, 0, 1);
|
|
284
|
-
const tuck = clamp(m.tuck, 0, 1), teeth = clamp(m.teeth, 0, 1);
|
|
285
|
-
const tongue = clamp(m.tongue, 0, 1);
|
|
286
|
-
const cL = clamp(m.cornerL, -1.4, 1.4), cR = clamp(m.cornerR, -1.4, 1.4);
|
|
287
|
-
const jaw = clamp(c.jaw, 0, 1);
|
|
288
|
-
|
|
289
|
-
const ml = L.mcorL, mr = L.mcorR;
|
|
290
|
-
const mcx = (ml[0] + mr[0]) / 2;
|
|
291
|
-
const restHW = (mr[0] - ml[0]) / 2;
|
|
292
|
-
const hw = restHW * (widthFactor(width, round, press) / REST_W());
|
|
293
|
-
|
|
294
|
-
// --- the expression axis, split into the four numbers the shape wants ----
|
|
295
|
-
// Per side for everything that has a side (the asymmetry is the smirk), and
|
|
296
|
-
// averaged for everything the whole lip line shares. `pAbs` is press ABOVE
|
|
297
|
-
// its rest value, so a pressed mouth is a DEPARTURE from this face rather
|
|
298
|
-
// than a state this face is already in.
|
|
299
|
-
const cPosL = Math.max(0, cL), cPosR = Math.max(0, cR);
|
|
300
|
-
const cNegL = Math.max(0, -cL), cNegR = Math.max(0, -cR);
|
|
301
|
-
const cAvg = (cL + cR) / 2;
|
|
302
|
-
const cPos = Math.max(0, cAvg), cNeg = Math.max(0, -cAvg), cMag = Math.abs(cAvg);
|
|
303
|
-
const pAbs = Math.max(0, press - R.press);
|
|
304
|
-
|
|
305
|
-
// The corner span, now per side: a smile widens the mouth as it lifts, a
|
|
306
|
-
// frown narrows it, and a press draws both corners in. Each factor is
|
|
307
|
-
// exactly 1 at rest, so `hwL === hwR === hw` there.
|
|
308
|
-
const hwL = hw * (1 + MAP.SMILE_OUT * cPosL - MAP.FROWN_IN * cNegL - MAP.PRESS_IN * pAbs);
|
|
309
|
-
const hwR = hw * (1 + MAP.SMILE_OUT * cPosR - MAP.FROWN_IN * cNegR - MAP.PRESS_IN * pAbs);
|
|
310
|
-
// …so the normalised run coordinate is per side too.
|
|
311
|
-
const fx = (x) => (x < mcx ? (x - mcx) / hwL : (x - mcx) / hwR);
|
|
312
|
-
|
|
313
|
-
// Corners, one per side: + is a lift, and screen y grows downward.
|
|
314
|
-
const cyL = ml[1] - cL * MAP.CORNER_PX;
|
|
315
|
-
const cyR = mr[1] - cR * MAP.CORNER_PX;
|
|
316
|
-
const chordMid = (cyL + cyR) / 2;
|
|
317
|
-
const chord = (x) => chordMid + fx(x) * (cyR - cyL) / 2;
|
|
318
|
-
|
|
319
|
-
// The closed-lip line. It runs corner to corner through a centre that sits
|
|
320
|
-
// BELOW the corners — which is this character's resting smile — and `press`
|
|
321
|
-
// pulls it back onto the straight chord between them.
|
|
322
|
-
// The centre follows the corners only PART of the way, and the harder they
|
|
323
|
-
// pull the less of it it follows: at CORNER_MID flat the whole lip line just
|
|
324
|
-
// translates, which is the hinge. Backing the follow off with `cMag` is what
|
|
325
|
-
// opens the curve — the corners are 32 px up and the centre 5, so the line
|
|
326
|
-
// between them has to bend.
|
|
327
|
-
const myc = L.mth_c[1] - cAvg * MAP.CORNER_PX * (MAP.CORNER_MID - MAP.SMILE_MID * cMag)
|
|
328
|
-
+ jaw * P.jawDrop * MAP.JAW_FOLLOW * 0.5;
|
|
329
|
-
const seamMid = myc + (chordMid - myc) * MAP.PRESS_STRAIGHT * press;
|
|
330
|
-
const dip = seamMid - chordMid;
|
|
331
|
-
// …and the PROFILE of that bend changes with it. A parabola peaks at the
|
|
332
|
-
// centre and is the shape a hinge makes; a smile is flat through the middle
|
|
333
|
-
// and turns in the last third. `(1-f^2)^p` with p < 1 is exactly that, and
|
|
334
|
-
// p = 1 is the parabola we had, to the bit.
|
|
335
|
-
const seamFlat = MAP.SEAM_FLAT * cPos;
|
|
336
|
-
const seamY = (x) => {
|
|
337
|
-
const f = fx(x), q = Math.max(0, 1 - f * f);
|
|
338
|
-
return chord(x) + dip * (seamFlat > 0 ? Math.pow(q, 1 - seamFlat) : q);
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
// The aperture. Its width is a law of its own — the sheet's whole point.
|
|
342
|
-
const awRel = (MAP.AW_0 + MAP.AW_W * width + MAP.AW_O * open) * (1 - MAP.AW_ROUND * round);
|
|
343
|
-
const ahw = Math.max(2, Math.min(P.mouthHalfW * awRel, hw * MAP.AW_MAX));
|
|
344
|
-
const h = MAP.APER_PX * open * (1 - MAP.AH_ROUND * round);
|
|
345
|
-
const dUp = h * MAP.APER_UP;
|
|
346
|
-
// Tuck pulls the lower edge up under the teeth, and may take it past the
|
|
347
|
-
// upper one — a tucked lip leaves no dark gap at all, which is the whole
|
|
348
|
-
// difference between G and B.
|
|
349
|
-
const dLo = Math.max(2 - dUp, h * (1 - MAP.APER_UP) - tuck * (h * MAP.TUCK_RISE + MAP.TUCK_MIN))
|
|
350
|
-
+ jaw * P.jawDrop * MAP.JAW_FOLLOW * 0.5;
|
|
351
|
-
|
|
352
|
-
// The lens profile: 1 at the centre, 0 at ±ahw, fattened by APER_POW.
|
|
353
|
-
const lens = (x) => {
|
|
354
|
-
const f = clamp((x - mcx) / ahw, -1, 1);
|
|
355
|
-
return Math.pow(Math.max(0, 1 - f * f), MAP.APER_POW);
|
|
356
|
-
};
|
|
357
|
-
const innerUp = (x) => seamY(x) - dUp * lens(x);
|
|
358
|
-
const innerLo = (x) => seamY(x) + dLo * lens(x);
|
|
359
|
-
|
|
360
|
-
// The nine x stations every corner-to-corner run is sampled at: the corner,
|
|
361
|
-
// the midpoint of the closed run, the aperture's own five, and back. A
|
|
362
|
-
// literal count, at every control value, which is the fixed-opcode rule.
|
|
363
|
-
const XS = [-1, -0.5, 0, 0.5, 1].map((f) => mcx + f * ahw);
|
|
364
|
-
const RUN = [mcx - hwL, mcx - (hwL + ahw) / 2, ...XS, mcx + (hwR + ahw) / 2, mcx + hwR];
|
|
365
|
-
const upperInner = RUN.map((x) => [x, innerUp(x)]);
|
|
366
|
-
const lowerInner = RUN.map((x) => [x, innerLo(x)]);
|
|
367
|
-
const apUpper = XS.map((x) => [x, innerUp(x)]);
|
|
368
|
-
const apLower = XS.map((x) => [x, innerLo(x)]);
|
|
369
|
-
|
|
370
|
-
// Lip mass. Thickness is measured at the centre line, exactly as the sheet
|
|
371
|
-
// measures it, and both lips ride on the aperture's arcs.
|
|
372
|
-
// A smile STRETCHES the upper lip over the teeth (thinner) and rolls the
|
|
373
|
-
// lower one out (fuller); a frown presses the lower lip in against them.
|
|
374
|
-
const thUp = P.lipUpTh * (1 + MAP.LIPFULL_U * c.lipFull)
|
|
375
|
-
* (upFactor(open, round, press) / REST_U()) * (1 - MAP.SMILE_THIN_U * cPos);
|
|
376
|
-
const thLo = P.lipLowTh * (1 + MAP.LIPFULL_L * c.lipFull)
|
|
377
|
-
* (loFactor(open, round, press) / REST_L()) * (1 - MAP.TUCK_THIN * tuck)
|
|
378
|
-
* (1 + MAP.SMILE_FULL_L * cPos - MAP.FROWN_THIN_L * cNeg);
|
|
379
|
-
|
|
380
|
-
// The cupid's bow: two peaks BOW deeper than the philtrum dip, tapering to
|
|
381
|
-
// nothing at the corners. `press` flattens it toward a plain arc.
|
|
382
|
-
const bowAmp = MAP.BOW * (1 - MAP.PRESS_BOW * press) * (1 - MAP.SMILE_BOWFLAT * cPos);
|
|
383
|
-
|
|
384
|
-
// A pucker is not just a narrow mouth: the lips BUNCH, so their mass sits out
|
|
385
|
-
// near the commissures instead of tapering away from the centre. Without this
|
|
386
|
-
// the sheet's F comes out a WEDGE — a flat bar over a V — because a 20 px lip
|
|
387
|
-
// hung on a 60 px half-width has no room to arc, and a wedge does not read as
|
|
388
|
-
// lips at 1x. `ring` slides the profile STATIONS outward as `round` climbs, so
|
|
389
|
-
// the mass rounds out while both ends still close to a point at the corner —
|
|
390
|
-
// raising the corner FACTOR instead grows fins there, which is worse. It moves
|
|
391
|
-
// x only, never the centre-line thickness the sheet measures, so the fit is
|
|
392
|
-
// untouched.
|
|
393
|
-
const ring = MAP.ROUND_RING * round;
|
|
394
|
-
const stations = (xs) => xs.map((f, i) =>
|
|
395
|
-
mcx + (f + (MAP.RING_X[i] - f) * ring) * (i < 3 ? hwL : i > 3 ? hwR : hw));
|
|
396
|
-
|
|
397
|
-
const UP_F = [0, 0.74, 1 + bowAmp, 1, 1 + bowAmp, 0.74, 0];
|
|
398
|
-
const UP_X = stations([-1, -0.62, -0.23, 0, 0.23, 0.62, 1]);
|
|
399
|
-
const upperOuter = UP_X.map((x, i) => [x, innerUp(x) - thUp * UP_F[i]]);
|
|
400
|
-
|
|
401
|
-
// …and the lower lip's own profile rounds out with the smile, which is the
|
|
402
|
-
// difference between a full lip and a thicker one.
|
|
403
|
-
const loB = clamp(MAP.SMILE_LO_BOW * cPos, 0, 1);
|
|
404
|
-
const LO_F = [0, 0.62 + 0.16 * loB, 0.94 + 0.05 * loB, 1, 0.94 + 0.05 * loB, 0.62 + 0.16 * loB, 0];
|
|
405
|
-
const LO_X = stations([-1, -0.62, -0.28, 0, 0.28, 0.62, 1]);
|
|
406
|
-
const lowerOuter = LO_X.map((x, i) => [x, innerLo(x) + thLo * LO_F[i]]);
|
|
407
|
-
|
|
408
|
-
// The teeth row: a near-constant height hanging off the upper arc. What
|
|
409
|
-
// changes with the viseme is the hole it hangs in, not the row.
|
|
410
|
-
const tw = ahw * MAP.TEETH_W;
|
|
411
|
-
const tH = Math.min(MAP.TEETH_PX * teeth,
|
|
412
|
-
Math.max(3, (dUp + dLo) * MAP.TEETH_FILL + tuck * MAP.TUCK_OVER));
|
|
413
|
-
const TX = [-1, -0.55, 0, 0.55, 1].map((f) => mcx + f * tw);
|
|
414
|
-
const teethTop = TX.map((x) => [x, innerUp(x) + 1.5]);
|
|
415
|
-
const teethBot = TX.map((x, i) => [x, innerUp(x) + 1.5 + tH * [0.25, 0.88, 1, 0.88, 0.25][i]]);
|
|
416
|
-
|
|
417
|
-
// The tongue: one rounded mass, rising out of the lower arc — or up at the
|
|
418
|
-
// upper lip when `tongueUp` says so (the `th` viseme).
|
|
419
|
-
const tgLow = innerLo(mcx) - MAP.TONGUE_RISE * (dUp + dLo) * tongue;
|
|
420
|
-
const tgUp = innerUp(mcx) + 6;
|
|
421
|
-
const tgTop = lerp(tgLow, tgUp, clamp(m.tongueUp, 0, 1));
|
|
422
|
-
const tgRx = Math.max(8, ahw * MAP.TONGUE_W);
|
|
423
|
-
// Bounded by the hole, not by the hole's height: the natural radius is
|
|
424
|
-
// `(dUp+dLo)·0.55 + 8`, but the ellipse's BOTTOM is then pinned to the lower
|
|
425
|
-
// arc (plus TONGUE_BOT_PAD) whenever that is nearer, so no viseme can hang a
|
|
426
|
-
// pink disc below the lower lip.
|
|
427
|
-
const tgBot = innerLo(mcx) + MAP.TONGUE_BOT_PAD;
|
|
428
|
-
const tgRy = Math.max(6,
|
|
429
|
-
(Math.min(tgBot, tgTop + 2 * ((dUp + dLo) * 0.55 + 8)) - tgTop) / 2);
|
|
430
|
-
|
|
431
|
-
return {
|
|
432
|
-
m, open, width, round, press, tuck, teeth, tongue, jaw,
|
|
433
|
-
mcx, hw, hwL, hwR, ahw, h, dUp, dLo, cyL, cyR, seamY, restHW,
|
|
434
|
-
cPosL, cPosR, cNegL, cNegR, pAbs,
|
|
435
|
-
upperInner, lowerInner, apUpper, apLower, upperOuter, lowerOuter,
|
|
436
|
-
thUp, thLo, tw, tH, teethTop, teethBot, tgTop, tgRx, tgRy,
|
|
437
|
-
cornerL: [mcx - hwL, cyL], cornerR: [mcx + hwR, cyR],
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// The sheet's own measurements, taken off the frame instead of off a drawing.
|
|
442
|
-
// Lengths are in units of the REST corner span (`2 · P.mouthHalfW`), which is
|
|
443
|
-
// what makes them comparable with the sheet's numbers once those are
|
|
444
|
-
// un-normalised by each cell's `width_vs_rest` — the sheet's per-cell width is
|
|
445
|
-
// framing discipline, not anatomy (parts/README.md).
|
|
446
|
-
export function mouthMetrics(P, F) {
|
|
447
|
-
const W = 2 * P.mouthHalfW;
|
|
448
|
-
const area = (pts) => {
|
|
449
|
-
let a = 0;
|
|
450
|
-
for (let i = 0; i < pts.length; i++) {
|
|
451
|
-
const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length];
|
|
452
|
-
a += x0 * y1 - x1 * y0;
|
|
453
|
-
}
|
|
454
|
-
return Math.abs(a) / 2;
|
|
455
|
-
};
|
|
456
|
-
const apRing = [...F.apUpper, ...F.apLower.slice().reverse()];
|
|
457
|
-
const apArea = area(apRing);
|
|
458
|
-
const teethRing = [...F.teethTop, ...F.teethBot.slice().reverse()];
|
|
459
|
-
// The row is clipped by the hole it hangs in, so what shows is the overlap.
|
|
460
|
-
const shown = Math.min(area(teethRing), apArea + F.tuck * area(teethRing));
|
|
461
|
-
const oh = F.dUp + F.dLo, ow = 2 * F.ahw;
|
|
462
|
-
return {
|
|
463
|
-
open_h: oh / W,
|
|
464
|
-
open_w: oh > 3 ? ow / W : 0,
|
|
465
|
-
round_ratio: oh > 3 && ow > 0 ? oh / ow : 0,
|
|
466
|
-
upper_lip_th: F.thUp / W,
|
|
467
|
-
lower_lip_th: F.thLo / W,
|
|
468
|
-
lip_th_ratio: F.thUp / F.thLo,
|
|
469
|
-
teeth_reveal: apArea > 1 ? clamp(shown / apArea, 0, 1) : 0,
|
|
470
|
-
corner_span: 2 * F.hw / W,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// ---------------------------------------------------------------------------
|
|
476
|
-
// The mouth has the same two idioms the eye does (parts/README.md § Two idioms):
|
|
477
|
-
// a flat-fill face models with shapes, an outlined one models with shapes AND a
|
|
478
|
-
// pen over them. `pen` is the whole of the second idiom — omit it and nothing
|
|
479
|
-
// below changes — and `marks` turns the individual features on and off.
|
|
480
|
-
//
|
|
481
|
-
// pen { paint, lip, aper, sep, cap, join } null = no outline anywhere
|
|
482
|
-
// `lip` the outer lip contour, one closed ring over both fills
|
|
483
|
-
// `aper` the inner lip line round the aperture, alpha-ramped with
|
|
484
|
-
// `open` because a stroke width is not a pose channel
|
|
485
|
-
// `sep` the tooth separators, drawn as strokes instead of quads
|
|
486
|
-
// marks { bow, gum, lipHiFade, lipHiAlpha, bowAlpha, lipCastAlpha,
|
|
487
|
-
// toothSep, toothSepA, toothSh, seam, seamAlpha, commiss,
|
|
488
|
-
// commissAlpha }
|
|
489
|
-
// ---------------------------------------------------------------------------
|
|
490
|
-
|
|
491
|
-
/** Which of the mouth's marks exist, and at what strength. */
|
|
492
|
-
export const MOUTH_MARKS = {
|
|
493
|
-
bow: true, // the cupid's-bow highlight over the upper lip
|
|
494
|
-
gum: false, // a gum arc over the top of the teeth row
|
|
495
|
-
lipHiFade: 0, // how much `round` fades the lower-lip highlight
|
|
496
|
-
lipHiAlpha: 1, // material strength; geometry and timing stay fixed
|
|
497
|
-
bowAlpha: 1,
|
|
498
|
-
lipCastAlpha: 0, // a soft lower-lip shadow, disabled for legacy rigs
|
|
499
|
-
toothSep: [-0.36, 0.05, 0.42], // separator stations, in units of the row's half-width
|
|
500
|
-
toothSepA: 0.9, // …and their share of the row's alpha
|
|
501
|
-
toothSh: { depth: 5, a: 0.55, blend: 'multiply' }, // the upper lip's cast shadow
|
|
502
|
-
seam: [-1.5, 3.0], // the seam ribbon's two offsets off its own run
|
|
503
|
-
seamAlpha: 1,
|
|
504
|
-
commiss: { r: 0.028, dx: 0.030, blend: 'multiply' }, // the corner pockets
|
|
505
|
-
commissAlpha: 1,
|
|
506
|
-
philtrum: null, // an optional quiet two-plane neutral-mouth cue
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
export function makeMouth({ P, PALETTE, solid, group = 'head', marks = {}, pen = null }) {
|
|
510
|
-
const HEAD = group;
|
|
511
|
-
const M = { ...MOUTH_MARKS, ...marks };
|
|
512
|
-
const nib = (w) => ({ w, cap: (pen && pen.cap) || 'round', join: (pen && pen.join) || 'round' });
|
|
513
|
-
const INK = pen ? solid(pen.paint) : null;
|
|
514
|
-
|
|
515
|
-
// PALETTE keys read: mouthIn, teeth, toothSep, toothSh, tongue, lipLow,
|
|
516
|
-
// lipHi, lipUp, lipBow, lipCast, seam, commiss, philtrum.
|
|
517
|
-
function draws(c, L, env = {}) {
|
|
518
|
-
const out = [];
|
|
519
|
-
const push = drawPusher(out);
|
|
520
|
-
const F = mouthFrame(P, c, L);
|
|
521
|
-
const { mcx, ahw, tw } = F;
|
|
522
|
-
|
|
523
|
-
// ---- the hole ----------------------------------------------------------
|
|
524
|
-
// Its own two arcs now, not a diamond between the corners: the lips close
|
|
525
|
-
// over it OUTSIDE ±ahw, so the aperture's width is a channel and not an
|
|
526
|
-
// accident of where the corners are.
|
|
527
|
-
push('mouthIn', HEAD, spline([
|
|
528
|
-
...F.apUpper, ...F.apLower.slice().reverse().slice(1, -1),
|
|
529
|
-
], 0.8), solid(PALETTE.mouthIn));
|
|
530
|
-
|
|
531
|
-
// ---- the tongue, behind the teeth and behind the lower lip -------------
|
|
532
|
-
push('tongue', HEAD, spline(circle(mcx, F.tgTop + F.tgRy, F.tgRx, 8, F.tgRy / F.tgRx), 1),
|
|
533
|
-
solid(PALETTE.tongue), clamp((F.tongue - MAP.TONGUE_A0) * MAP.TONGUE_AK, 0, 1));
|
|
534
|
-
|
|
535
|
-
// A broad, quiet cast shadow makes the lower lip belong to the muzzle
|
|
536
|
-
// rather than float as a coloured symbol. It follows the live lower-lip
|
|
537
|
-
// curve, so speech keeps the same topology, closure and timing.
|
|
538
|
-
if (M.lipCastAlpha > 0) {
|
|
539
|
-
const castTop = F.lowerOuter.slice(1, 6).map(([x, y]) => [x, y + 3]);
|
|
540
|
-
push('lipCast', HEAD, band(
|
|
541
|
-
castTop,
|
|
542
|
-
bulge(castTop, 8, { floor: 2, power: 0.8, pin: true }),
|
|
543
|
-
0.86,
|
|
544
|
-
), solid(PALETTE.lipCast), M.lipCastAlpha);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// ---- the lower lip -----------------------------------------------------
|
|
548
|
-
// Fuller than the upper, and it rides on the aperture's lower arc, so it
|
|
549
|
-
// travels with every viseme without a number of its own.
|
|
550
|
-
push('lipLow', HEAD, spline([
|
|
551
|
-
...F.lowerOuter, ...F.lowerInner.slice().reverse().slice(1, -1),
|
|
552
|
-
], 0.9), solid(PALETTE.lipLow));
|
|
553
|
-
|
|
554
|
-
// The lower lip carries the brightest value in a portrait: it is the one
|
|
555
|
-
// surface here that tilts up into the light. ~34% of the mouth's width,
|
|
556
|
-
// centred, sitting between the lip's inner and outer edges so it travels
|
|
557
|
-
// with every viseme.
|
|
558
|
-
const loIn0 = F.lowerInner[4][1], loOut0 = F.lowerOuter[3][1];
|
|
559
|
-
const hiY = loIn0 + (loOut0 - loIn0) * 0.50;
|
|
560
|
-
const hiRx = F.hw * 0.34, hiRy = Math.max(2, (loOut0 - loIn0) * 0.26);
|
|
561
|
-
// A rounded viseme both NARROWS the mouth and thickens the lip, which turns
|
|
562
|
-
// a 2:1 highlight band into a disc floating in a tall keyhole — the pink
|
|
563
|
-
// pill an outlined face's `viseme/U` was reviewed for. `lipHiFade` takes it
|
|
564
|
-
// out as the mouth rounds, which is also the physics: a specular lives on a
|
|
565
|
-
// lip pointing AT the light, and a protruded one does not.
|
|
566
|
-
push('lipHi', HEAD, spline(circle(mcx, hiY, hiRx, 7, hiRy / hiRx), 1), solid(PALETTE.lipHi),
|
|
567
|
-
M.lipHiAlpha * clamp(1 - M.lipHiFade * F.round, 0, 1));
|
|
568
|
-
|
|
569
|
-
// ---- teeth: ONE band, never individual teeth ---------------------------
|
|
570
|
-
// Painted AFTER the lower lip and BEFORE the upper one, which is the whole
|
|
571
|
-
// of the F/V tuck: at tuck = 1 the row is allowed past the aperture's lower
|
|
572
|
-
// edge and lands ON the lower lip, so G shows teeth resting on a lip and B
|
|
573
|
-
// shows teeth over a dark sliver. Two topologies, not two amplitudes.
|
|
574
|
-
// Reaching full opacity by teeth = 0.4 matters: at 0.8 over the near-black
|
|
575
|
-
// mouth interior the band renders grey and the smile reads as a grimace.
|
|
576
|
-
// …and the row all but goes out when the tongue is UP against it: `108 - L`
|
|
577
|
-
// and `116 - th` are the same channels except for `tongueUp`, and with a
|
|
578
|
-
// full-strength band in front of it the raised tongue is invisible, so the
|
|
579
|
-
// two visemes were one drawing. The tongue is BEHIND the teeth in the paint
|
|
580
|
-
// order and stays there; what changes is how much of the band is left.
|
|
581
|
-
const tA = clamp(F.teeth * 2.6, 0, 1) * (1 - 0.75 * clamp(F.m.tongueUp, 0, 1));
|
|
582
|
-
push('teeth', HEAD, band(F.teethTop, F.teethBot, 1), solid(PALETTE.teeth), tA);
|
|
583
|
-
|
|
584
|
-
// The gum arc, for a style that wants one. It rides the row's own top edge
|
|
585
|
-
// and is deliberately a hair OUTSIDE it — an inner edge leaves a 1-2 unit
|
|
586
|
-
// rim of bare enamel tracing the top of the band and the inner upper lip,
|
|
587
|
-
// the brightest, sharpest mark in the mouth, and the whole of why a white
|
|
588
|
-
// row reads as a glow at 1x. Deeper than the cast shadow below, or it reads
|
|
589
|
-
// as grey rather than as gum.
|
|
590
|
-
if (M.gum) {
|
|
591
|
-
const gTop = shift(F.teethTop, -1.2);
|
|
592
|
-
push('gum', HEAD, band(gTop, gTop.map(([x, y]) => [x, y + 1.2 + F.tH * 0.34]), 1),
|
|
593
|
-
solid(PALETTE.gum), tA);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Three separators, spaced non-uniformly so the band does not read as a
|
|
597
|
-
// barcode, and stopping short of the lower edge. One draw, three contours,
|
|
598
|
-
// and `polygon` rather than a near-straight spline: each is a 3px-wide quad
|
|
599
|
-
// that is ~2 device px at 1x, so four beziers per corner bought nothing but
|
|
600
|
-
// 57 opcodes in every one of the 21 mouth poses.
|
|
601
|
-
const sepRun = (f) => {
|
|
602
|
-
const x = mcx + tw * f;
|
|
603
|
-
const y0 = F.seamY(x) - F.dUp * Math.pow(Math.max(0, 1 - ((x - mcx) / ahw) ** 2), MAP.APER_POW) + 3;
|
|
604
|
-
return [x, y0, y0 + F.tH * 0.88];
|
|
605
|
-
};
|
|
606
|
-
if (pen && pen.sep) {
|
|
607
|
-
// An outlined face draws its gaps with the same pen as everything else;
|
|
608
|
-
// three ticks, mid-point included so the run is a fixed 3 points.
|
|
609
|
-
push('toothSep', HEAD, contours(
|
|
610
|
-
...M.toothSep.map((f) => {
|
|
611
|
-
const [x, y0, y1] = sepRun(f);
|
|
612
|
-
return openSpline([[x, y0], [x, (y0 + y1) / 2], [x, y1]], 0.9);
|
|
613
|
-
}),
|
|
614
|
-
), INK, tA * M.toothSepA, { stroke: nib(pen.sep) });
|
|
615
|
-
} else {
|
|
616
|
-
push('toothSep', HEAD, contours(
|
|
617
|
-
...M.toothSep.map((f) => {
|
|
618
|
-
const [x, y0, y1] = sepRun(f);
|
|
619
|
-
return polygon([[x - 1.6, y0], [x + 1.6, y0], [x + 1.3, y1], [x - 1.3, y1]]);
|
|
620
|
-
}),
|
|
621
|
-
), solid(PALETTE.toothSep), tA * M.toothSepA);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// The upper lip's cast shadow across the top of the band, doing the gum
|
|
625
|
-
// line's job too (see PALETTE.toothSh).
|
|
626
|
-
push('toothSh', HEAD, band(F.teethTop, F.teethTop.map(([x, y]) => [x, y + M.toothSh.depth]), 1),
|
|
627
|
-
solid(PALETTE.toothSh), tA * M.toothSh.a,
|
|
628
|
-
M.toothSh.blend ? { blend: M.toothSh.blend } : undefined);
|
|
629
|
-
|
|
630
|
-
// ---- the upper lip, with a bow ----------------------------------------
|
|
631
|
-
push('lipUp', HEAD, spline([
|
|
632
|
-
...F.upperOuter, ...F.upperInner.slice().reverse().slice(1, -1),
|
|
633
|
-
], 0.85), solid(PALETTE.lipUp));
|
|
634
|
-
|
|
635
|
-
// cupid's bow: the ridge over the two peaks, as a highlight rather than a
|
|
636
|
-
// line. The dip at the centre is already in `upperOuter`.
|
|
637
|
-
if (M.bow) {
|
|
638
|
-
const bowTop = F.upperOuter.slice(1, 6);
|
|
639
|
-
push('lipBow', HEAD, band(bowTop, bowTop.map(([x, y]) => [x, y + 4.5]), 1),
|
|
640
|
-
solid(PALETTE.lipBow), M.bowAlpha);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// The philtrum is not a pair of drawn-on lines. It is two very shallow
|
|
644
|
-
// planes that narrow into the cupid's-bow dip: enough material change to
|
|
645
|
-
// connect nose and mouth in a neutral listening frame, quiet enough to
|
|
646
|
-
// disappear into every speech shape. `L.philt` is face-space while the
|
|
647
|
-
// lower endpoint comes from this frame's live upper lip, so the cue never
|
|
648
|
-
// hangs across an opened or smiling mouth.
|
|
649
|
-
if (M.philtrum) {
|
|
650
|
-
const { a = 0.5, w = 14 } = M.philtrum;
|
|
651
|
-
const [px, py] = L.philt;
|
|
652
|
-
const bottom = Math.max(py + 6, F.upperOuter[3][1] - 3);
|
|
653
|
-
const plane = (side) => polygon([
|
|
654
|
-
[px + side * w * 0.50, py], [px + side * w * 0.16, py + 2],
|
|
655
|
-
[px + side * w * 0.22, bottom], [px + side * w * 0.52, bottom - 2],
|
|
656
|
-
]);
|
|
657
|
-
push('philtrum', HEAD, contours(plane(-1), plane(1)), solid(PALETTE.philtrum), a);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// ---- the pen, over both fills -----------------------------------------
|
|
661
|
-
// The outer lip contour: ONE closed ring round the whole mouth rather than
|
|
662
|
-
// an outline per lip, so the two lips do not each get a line where they
|
|
663
|
-
// meet — the seam below is what says that, tapered, and a pair of full-
|
|
664
|
-
// weight strokes there is the "even-weight line running over the tapered
|
|
665
|
-
// wedge" an outlined mouth gets reviewed for.
|
|
666
|
-
if (pen && pen.lip) {
|
|
667
|
-
push('lipInk', HEAD, spline([
|
|
668
|
-
...F.upperOuter, ...F.lowerOuter.slice().reverse().slice(1, -1),
|
|
669
|
-
], 0.85), INK, 1, { stroke: nib(pen.lip) });
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// The inner lip line. It exists to run round an OPENING, and it is only
|
|
673
|
-
// that once there is one: as the mouth shuts the ring's two sides walk into
|
|
674
|
-
// each other and a round-capped stroke collapses into a blunt black lens
|
|
675
|
-
// heavier than the silhouette. Width is not a pose channel, so alpha is the
|
|
676
|
-
// only way to say "this line is not there" — off at a closed aperture, full
|
|
677
|
-
// by APER_PX·0.55, crossfading against the seam's own fade-out.
|
|
678
|
-
if (pen && pen.aper) {
|
|
679
|
-
push('aperInk', HEAD, spline([
|
|
680
|
-
...F.apUpper, ...F.apLower.slice().reverse().slice(1, -1),
|
|
681
|
-
], 0.8), INK, clamp((F.dUp + F.dLo - 4) / (MAP.APER_PX * 0.55), 0, 1),
|
|
682
|
-
{ stroke: nib(pen.aper) });
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// ---- the seam ----------------------------------------------------------
|
|
686
|
-
// The line between the lips: thickest dead centre, tapering to nothing at
|
|
687
|
-
// both corners. The taper is the whole point — a uniform line reads as a
|
|
688
|
-
// cut. It fades out as the aperture opens, because past a few px of gap the
|
|
689
|
-
// thing between the lips is the mouth, not a seam.
|
|
690
|
-
// It also has a job the taper does not advertise: `lipUp`'s lower edge and
|
|
691
|
-
// `lipLow`'s upper edge are two Catmull-Rom curves through nearly the same
|
|
692
|
-
// points at different tensions, and they bow apart by a px or two either
|
|
693
|
-
// side of centre, showing a hairline of FACE colour between the lips. The
|
|
694
|
-
// seam straddles that hairline instead of sitting on it.
|
|
695
|
-
// 4.5 units wide, biased downward (-1.5 / +3.0) so it sits mostly in the
|
|
696
|
-
// shadow under the upper lip; `power` 0.9 makes the taper toward the
|
|
697
|
-
// corners a real taper instead of a step up to full width in the first
|
|
698
|
-
// tenth of the run.
|
|
699
|
-
// `press` above REST widens the ribbon: a pressed mouth is a LINE, and the
|
|
700
|
-
// thinning the press already does to both lips leaves nothing between them
|
|
701
|
-
// to say so. Keyed on `press - REST.press` so this face at rest is not a
|
|
702
|
-
// face already pressing its lips, and so the closed visemes (A at 0.55, G
|
|
703
|
-
// at 0.40) get it too — which is the same shape for the same reason.
|
|
704
|
-
const seamRun = arc(eqp(F.cornerL), [mcx, F.seamY(mcx) + 1], eqp(F.cornerR), 4);
|
|
705
|
-
const seamAt = (dy) => bulge(seamRun, dy, { power: 0.9 });
|
|
706
|
-
const [s0, s1] = MAP.SEAM_FADE;
|
|
707
|
-
const seamW = 1 + MAP.PRESS_SEAM * F.pAbs + MAP.SMILE_SEAM * Math.max(0, (F.cPosL + F.cPosR) / 2);
|
|
708
|
-
push('seam', HEAD, band(seamAt(M.seam[0] * seamW), seamAt(M.seam[1] * seamW), 0.9),
|
|
709
|
-
solid(PALETTE.seam), M.seamAlpha * clamp((s0 - (F.dUp + F.dLo)) / s1, 0, 1));
|
|
710
|
-
|
|
711
|
-
// Commissures: the shadow pockets the corners of a real mouth pinch into.
|
|
712
|
-
// Without them the mouth stays flat however well the lips are shaded.
|
|
713
|
-
// Two dots of radius ~2.7 units — under 2 device px at 1x — so they are
|
|
714
|
-
// polygons: a 7-point closed spline apiece was 106 opcodes / 639 bytes in
|
|
715
|
-
// every one of the 21 mouth poses to draw something smaller than the
|
|
716
|
-
// rounding of its own coordinates.
|
|
717
|
-
// …and they are where the corner channel finishes its sentence. A smile
|
|
718
|
-
// DEEPENS the pocket and stretches it flat between the lips; a frown makes
|
|
719
|
-
// it small and tall — a pinch, not a pocket. Both ride further into the
|
|
720
|
-
// corner as the corner travels. It is geometry and not alpha because the
|
|
721
|
-
// two pockets are ONE draw (one alpha for both sides would kill the
|
|
722
|
-
// asymmetry that is the whole point of having cornerL and cornerR) and
|
|
723
|
-
// because this mark is already opaque at rest: there is no headroom above
|
|
724
|
-
// 1 to deepen into. Radius, aspect and offset have headroom both ways.
|
|
725
|
-
const pocket = [[F.cornerL, 1, F.cPosL, F.cNegL], [F.cornerR, -1, F.cPosR, F.cNegR]];
|
|
726
|
-
push('commiss', HEAD, contours(
|
|
727
|
-
...pocket.map(([p, d, cp, cn]) =>
|
|
728
|
-
polygon(circle(
|
|
729
|
-
p[0] + d * F.hw * M.commiss.dx * (1 + MAP.CM_DX * (cp + cn)),
|
|
730
|
-
p[1] + 1,
|
|
731
|
-
F.hw * M.commiss.r * (1 + MAP.CM_R * cp - MAP.CM_SHARP * cn),
|
|
732
|
-
5,
|
|
733
|
-
0.9 * (1 - MAP.CM_WIDE * cp + MAP.CM_ASP * cn)))),
|
|
734
|
-
), solid(PALETTE.commiss), M.commissAlpha,
|
|
735
|
-
M.commiss.blend ? { blend: M.commiss.blend } : undefined);
|
|
736
|
-
|
|
737
|
-
return out;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
return { rest: mouthRestChannels(), draws, frame: (c, L) => mouthFrame(P, c, L) };
|
|
741
|
-
}
|