@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,100 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// author/parts/nose.mjs — a formed, low-contrast vector nose.
|
|
3
|
-
//
|
|
4
|
-
// A nose has no performance channel in the 30-float driver vocabulary. It is
|
|
5
|
-
// nevertheless a part: construction chooses a stable anatomical profile once;
|
|
6
|
-
// `draws(c, L)` spends the face's live landmarks every frame, so identity
|
|
7
|
-
// morphs, jaw travel and the head matrix move every plane together. There is
|
|
8
|
-
// no state or emotion inference here.
|
|
9
|
-
//
|
|
10
|
-
// `style: 'legacy'` emits round's former eight-point L tick byte for byte. It
|
|
11
|
-
// is the default because adopting a third author part must not move a rig that
|
|
12
|
-
// did not ask for it. `style: 'mature'` replaces that glyph with four quiet
|
|
13
|
-
// planes: one shadow-side bridge, a shallow underside, and two alar marks.
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
import { circle, spline } from '../path.mjs';
|
|
17
|
-
import { drawPusher, lerp } from '../rig.mjs';
|
|
18
|
-
|
|
19
|
-
export const NOSE_SHAPE = {
|
|
20
|
-
style: 'legacy', // legacy | mature
|
|
21
|
-
bridge: 1, // shadow-side bridge width
|
|
22
|
-
base: 1, // alar span / underside width
|
|
23
|
-
nostril: 1, // micro-alar mark size
|
|
24
|
-
shadow: 1, // all mature-mark opacity
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** A nose has no independent driver block; it is carried by face landmarks. */
|
|
28
|
-
export function noseRest() { return {}; }
|
|
29
|
-
|
|
30
|
-
const between = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t)];
|
|
31
|
-
|
|
32
|
-
export function makeNose({ P, PALETTE, solid, group = 'head', shape = {} }) {
|
|
33
|
-
const HEAD = group;
|
|
34
|
-
const S = { ...NOSE_SHAPE, ...shape };
|
|
35
|
-
|
|
36
|
-
function legacy(L) {
|
|
37
|
-
const out = [];
|
|
38
|
-
drawPusher(out)('nose', HEAD, spline(
|
|
39
|
-
['no1', 'no2', 'no3', 'no4', 'no5', 'no6', 'no7', 'no8'].map((n) => L[n]), 0.9),
|
|
40
|
-
solid(PALETTE.nose));
|
|
41
|
-
return out;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function mature(L) {
|
|
45
|
-
const out = [];
|
|
46
|
-
const push = drawPusher(out);
|
|
47
|
-
const cx = P.cx;
|
|
48
|
-
const tip = L.ntip;
|
|
49
|
-
const wingL = L.nwingL, wingR = L.nwingR;
|
|
50
|
-
const half = Math.max(1, (wingR[0] - wingL[0]) / 2);
|
|
51
|
-
const root = between(L.glab, tip, 0.49);
|
|
52
|
-
const baseY = (wingL[1] + wingR[1] + 2 * tip[1]) / 4;
|
|
53
|
-
|
|
54
|
-
// A formed bridge is a plane, never a line. Its inside edge turns into the
|
|
55
|
-
// tip before it reaches the nostril, so the viewer gets one continuous
|
|
56
|
-
// surface rather than the old L-shaped contour.
|
|
57
|
-
const bridge = [
|
|
58
|
-
[cx + half * 0.10 * S.bridge, root[1] - 1],
|
|
59
|
-
[cx + half * 0.29 * S.bridge, lerp(root[1], baseY, 0.42)],
|
|
60
|
-
[cx + half * 0.55 * S.bridge, lerp(root[1], baseY, 0.79)],
|
|
61
|
-
[cx + half * 0.36 * S.bridge, baseY + 1],
|
|
62
|
-
[cx + half * 0.18 * S.bridge, lerp(root[1], baseY, 0.61)],
|
|
63
|
-
];
|
|
64
|
-
push('noseBridge', HEAD, spline(bridge, 0.72), solid(PALETTE.noseBridge), 0.18 * S.shadow);
|
|
65
|
-
|
|
66
|
-
// The underside makes the tip read in frontal light. It is deliberately
|
|
67
|
-
// shallow: at 1× it is 3–4 screen px high, enough to locate a nose but
|
|
68
|
-
// not enough to become a moustache-shaped outline.
|
|
69
|
-
const span = half * 0.50 * S.base;
|
|
70
|
-
const under = [
|
|
71
|
-
[cx - span, baseY + 3],
|
|
72
|
-
[cx - span * 0.42, baseY + 8],
|
|
73
|
-
[cx, baseY + 10],
|
|
74
|
-
[cx + span * 0.44, baseY + 8],
|
|
75
|
-
[cx + span, baseY + 3],
|
|
76
|
-
[cx + span * 0.36, baseY + 4],
|
|
77
|
-
[cx, baseY + 6],
|
|
78
|
-
[cx - span * 0.35, baseY + 4],
|
|
79
|
-
];
|
|
80
|
-
push('noseUnder', HEAD, spline(under, 0.78), solid(PALETTE.noseUnder), 0.18 * S.shadow);
|
|
81
|
-
|
|
82
|
-
// Two separate micro marks preserve the alar asymmetry of a lit face
|
|
83
|
-
// without asserting a heavy outline. They are tied to the wing span, so
|
|
84
|
-
// `morph/nose_±100` scales a nose rather than leaving two dots behind.
|
|
85
|
-
for (const side of [-1, 1]) {
|
|
86
|
-
const x = cx + side * half * 0.58 * S.base;
|
|
87
|
-
const rx = half * 0.105 * S.nostril;
|
|
88
|
-
const ry = 3.8 * S.nostril;
|
|
89
|
-
push(`noseAlar${side < 0 ? 'L' : 'R'}`, HEAD,
|
|
90
|
-
spline(circle(x, baseY + 5.2, rx, 5, ry / Math.max(rx, 1)), 0.75),
|
|
91
|
-
solid(PALETTE.noseAlar), (side < 0 ? 0.14 : 0.24) * S.shadow);
|
|
92
|
-
}
|
|
93
|
-
return out;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
rest: noseRest(),
|
|
98
|
-
draws(c, L) { return S.style === 'mature' ? mature(L) : legacy(L); },
|
|
99
|
-
};
|
|
100
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// author/parts/skin-detail.mjs — small, stable identity marks.
|
|
3
|
-
//
|
|
4
|
-
// These marks intentionally have no expression channel. A profile is chosen
|
|
5
|
-
// once for a persona, then drawn from its solved upper-cheek landmarks every
|
|
6
|
-
// frame. Keeping them on the outer upper cheek leaves the mouth, eye lids and
|
|
7
|
-
// smile-compressing lower cheek unmarked while the head matrix and identity
|
|
8
|
-
// morphs still carry them with the face.
|
|
9
|
-
//
|
|
10
|
-
// `freckles` is a literal list of `{ side, along, lift, r }` records:
|
|
11
|
-
// side -1 viewer-left, +1 viewer-right
|
|
12
|
-
// along eye centre -> cheek contour, 0..1
|
|
13
|
-
// lift design px below that anchor (negative is higher)
|
|
14
|
-
// r design-px radius
|
|
15
|
-
// `mole` has the same fields. The empty default produces no draws, keeping a
|
|
16
|
-
// persona that did not opt in exactly as it was.
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
import { circle, spline } from '../path.mjs';
|
|
20
|
-
import { drawPusher, lerp } from '../rig.mjs';
|
|
21
|
-
|
|
22
|
-
export const SKIN_DETAIL = {
|
|
23
|
-
opacity: 1,
|
|
24
|
-
freckles: [],
|
|
25
|
-
mole: null,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const sideKey = (side) => side < 0 ? 'L' : 'R';
|
|
29
|
-
|
|
30
|
-
function upperCheek(L, mark) {
|
|
31
|
-
const k = sideKey(mark.side);
|
|
32
|
-
const eye = L['eyeC' + k], cheek = L['chk' + k];
|
|
33
|
-
return [
|
|
34
|
-
lerp(eye[0], cheek[0], mark.along),
|
|
35
|
-
lerp(eye[1], cheek[1], mark.along) + (mark.lift || 0),
|
|
36
|
-
];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function spot(push, slot, group, paint, alpha, L, mark) {
|
|
40
|
-
const [x, y] = upperCheek(L, mark);
|
|
41
|
-
const r = mark.r;
|
|
42
|
-
push(slot, group,
|
|
43
|
-
spline(circle(x, y, r, 5, mark.squash ?? 0.84), 0.72),
|
|
44
|
-
paint, alpha);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function makeSkinDetail({ PALETTE, solid, group = 'head', profile = {} }) {
|
|
48
|
-
const D = { ...SKIN_DETAIL, ...profile };
|
|
49
|
-
const freckles = Array.isArray(D.freckles) ? D.freckles : [];
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
rest: {},
|
|
53
|
-
draws(c, L) {
|
|
54
|
-
if (!freckles.length && !D.mole) return [];
|
|
55
|
-
const out = [], push = drawPusher(out);
|
|
56
|
-
for (let i = 0; i < freckles.length; i++) {
|
|
57
|
-
spot(push, `skinFreckle${i}`, group, solid(PALETTE.skinFleck),
|
|
58
|
-
0.24 * D.opacity, L, freckles[i]);
|
|
59
|
-
}
|
|
60
|
-
if (D.mole) {
|
|
61
|
-
spot(push, 'skinMole', group, solid(PALETTE.skinMole),
|
|
62
|
-
0.54 * D.opacity, L, D.mole);
|
|
63
|
-
}
|
|
64
|
-
return out;
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
}
|
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// author/path.mjs — fixed-opcode path primitives.
|
|
3
|
-
//
|
|
4
|
-
// THE GUARANTEE, and the only reason this module exists:
|
|
5
|
-
//
|
|
6
|
-
// every primitive here emits an opcode sequence that depends only on the
|
|
7
|
-
// point COUNT, never on the point VALUES.
|
|
8
|
-
//
|
|
9
|
-
// `rig.js` blends a pose into the base by interpolating the cmds array float
|
|
10
|
-
// by float, opcodes included. So a pose is only interpolatable if its cmds
|
|
11
|
-
// array decodes to exactly the same opcode sequence as the base draw's: a
|
|
12
|
-
// moveTo (0) blending toward a bezierTo (2) passes through 1.0 = lineTo, and
|
|
13
|
-
// `buildPath` then reads the rest of the array at the wrong offsets and
|
|
14
|
-
// truncates the shape. Equal length is not enough; the sequence has to match.
|
|
15
|
-
//
|
|
16
|
-
// Because the count-not-values rule holds for every emitter below, "re-run the
|
|
17
|
-
// whole builder with a different control vector and diff" (see rig.mjs's pose
|
|
18
|
-
// harness) produces a blendable pose for free. Any emitter that adds a point
|
|
19
|
-
// conditionally — "a crease line when the mouth opens" — breaks the guarantee
|
|
20
|
-
// and does not belong here.
|
|
21
|
-
//
|
|
22
|
-
// Opcodes: 0 moveTo (2 operands), 1 lineTo (2), 2 bezierTo (6), 3 close (0).
|
|
23
|
-
// Authoring-time only; the player never loads this file.
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
|
|
26
|
-
// Coordinates are rounded to 2dp on the way into the cmds array. Poses are
|
|
27
|
-
// diffed for equality against the base, so the rounding also decides what
|
|
28
|
-
// counts as "moved" — sub-1/100th px jitter is not a pose.
|
|
29
|
-
export const r2 = (v) => Math.round(v * 100) / 100;
|
|
30
|
-
|
|
31
|
-
// Copy a point. Landmark tables alias points on purpose; anything that goes
|
|
32
|
-
// into a mutable run wants its own pair.
|
|
33
|
-
export function eqp(a) { return [a[0], a[1]]; }
|
|
34
|
-
|
|
35
|
-
// 3 + 3n opcodes for n points, always.
|
|
36
|
-
export function polygon(pts) {
|
|
37
|
-
const c = [0, r2(pts[0][0]), r2(pts[0][1])];
|
|
38
|
-
for (let i = 1; i < pts.length; i++) c.push(1, r2(pts[i][0]), r2(pts[i][1]));
|
|
39
|
-
c.push(3);
|
|
40
|
-
return c;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Closed Catmull-Rom through the given points, emitted as cubic beziers. One
|
|
44
|
-
// bezier per point, so the opcode count is a pure function of pts.length —
|
|
45
|
-
// which is what keeps every pose of a given draw interpolatable.
|
|
46
|
-
export function spline(pts, tension = 1) {
|
|
47
|
-
const n = pts.length, k = tension / 6;
|
|
48
|
-
const at = (i) => pts[((i % n) + n) % n];
|
|
49
|
-
const c = [0, r2(pts[0][0]), r2(pts[0][1])];
|
|
50
|
-
for (let i = 0; i < n; i++) {
|
|
51
|
-
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
|
|
52
|
-
c.push(2,
|
|
53
|
-
r2(p1[0] + (p2[0] - p0[0]) * k), r2(p1[1] + (p2[1] - p0[1]) * k),
|
|
54
|
-
r2(p2[0] - (p3[0] - p1[0]) * k), r2(p2[1] - (p3[1] - p1[1]) * k),
|
|
55
|
-
r2(p2[0]), r2(p2[1]));
|
|
56
|
-
}
|
|
57
|
-
c.push(3);
|
|
58
|
-
return c;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// The other half of `spline`: a run with two FREE ends. `spline` closes and
|
|
62
|
-
// strokes all the way round; an outlined style also needs a nose tick, a lid
|
|
63
|
-
// line, a hair strand — a stroke that starts somewhere and stops somewhere.
|
|
64
|
-
// 1 moveTo + (n-1) bezierTo and no close, so the opcode sequence is a pure
|
|
65
|
-
// function of pts.length and the guarantee at the top of this file holds.
|
|
66
|
-
//
|
|
67
|
-
// The END POLICY is style, so it is a parameter. `ends` receives the point
|
|
68
|
-
// array and returns the index accessor the Catmull-Rom tangents are read
|
|
69
|
-
// through — i.e. it decides what lies just outside the run. CLAMP_ENDS treats
|
|
70
|
-
// each endpoint as its own neighbour, which zeroes the outward component of
|
|
71
|
-
// the end tangents and is what stops a short run flicking out at the tips; a
|
|
72
|
-
// style that wants an open run to keep flowing past its last point would pass
|
|
73
|
-
// a reflecting or extrapolating accessor instead.
|
|
74
|
-
export const CLAMP_ENDS = (pts) => (i) => pts[i < 0 ? 0 : i >= pts.length ? pts.length - 1 : i];
|
|
75
|
-
|
|
76
|
-
export function openSpline(pts, tension = 1, ends = CLAMP_ENDS) {
|
|
77
|
-
const n = pts.length, k = tension / 6;
|
|
78
|
-
const at = ends(pts);
|
|
79
|
-
const c = [0, r2(pts[0][0]), r2(pts[0][1])];
|
|
80
|
-
for (let i = 0; i < n - 1; i++) {
|
|
81
|
-
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
|
|
82
|
-
c.push(2,
|
|
83
|
-
r2(p1[0] + (p2[0] - p0[0]) * k), r2(p1[1] + (p2[1] - p0[1]) * k),
|
|
84
|
-
r2(p2[0] - (p3[0] - p1[0]) * k), r2(p2[1] - (p3[1] - p1[1]) * k),
|
|
85
|
-
r2(p2[0]), r2(p2[1]));
|
|
86
|
-
}
|
|
87
|
-
return c;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// A quadratic arc through three points, sampled evenly. Both eyelids and the
|
|
91
|
-
// sclera are built from the *same* samples of the same arc, which is the only
|
|
92
|
-
// reliable way to stop the white of the eye leaking past a lid: matching two
|
|
93
|
-
// splines' endpoints is not enough, their tangents differ.
|
|
94
|
-
export const arc = (a, m, b, n = 6) => {
|
|
95
|
-
const q = [2 * m[0] - (a[0] + b[0]) / 2, 2 * m[1] - (a[1] + b[1]) / 2];
|
|
96
|
-
const out = [];
|
|
97
|
-
for (let i = 0; i <= n; i++) {
|
|
98
|
-
const t = i / n, u = 1 - t;
|
|
99
|
-
out.push([u * u * a[0] + 2 * u * t * q[0] + t * t * b[0],
|
|
100
|
-
u * u * a[1] + 2 * u * t * q[1] + t * t * b[1]]);
|
|
101
|
-
}
|
|
102
|
-
return out;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
export const shift = (pts, dy) => pts.map(([x, y]) => [x, y + dy]);
|
|
106
|
-
|
|
107
|
-
// Scale a run about an EXPLICIT centre. Note what this is not: it is not an
|
|
108
|
-
// offset by a distance. Every point moves by `(k-1) * its own distance from
|
|
109
|
-
// (cx, cy)`, so the amount an EDGE moves depends on where that edge sits
|
|
110
|
-
// relative to the centre. That is right for "open the eye socket out from the
|
|
111
|
-
// eye's centre" and wrong for "grow this shape by 2px" — see `outsetTri`.
|
|
112
|
-
export const grow = (pts, cx, cy, k) => pts.map(([x, y]) => [cx + (x - cx) * k, cy + (y - cy) * k]);
|
|
113
|
-
|
|
114
|
-
// Offset all three edges of a triangle OUTWARD by exactly `d`, by scaling it
|
|
115
|
-
// about its INCENTRE by (r + d)/r, where r is the inradius.
|
|
116
|
-
//
|
|
117
|
-
// This is the fix for the hairlines a faceted style gets between abutting
|
|
118
|
-
// triangles: Canvas2D antialiases every fill independently, so two triangles
|
|
119
|
-
// that share an edge composite to ~90% coverage along it and the background
|
|
120
|
-
// shows through. The cure is to make neighbours overlap instead of abut, and
|
|
121
|
-
// the amount of overlap has to be a DISTANCE.
|
|
122
|
-
//
|
|
123
|
-
// `grow(pts, centroid, k)` — the obvious version, and the one that shipped —
|
|
124
|
-
// does not give you one. A long thin triangle's vertices point along its own
|
|
125
|
-
// axis, so a centroid outset slides them end-to-end and moves the long edges
|
|
126
|
-
// almost not at all: the slivers, which are exactly where the hairlines are
|
|
127
|
-
// worst, get the least bleed. Scaling about the incentre offsets all three
|
|
128
|
-
// edges by the same `d` whatever the shape, because the incentre is by
|
|
129
|
-
// definition equidistant from all three.
|
|
130
|
-
//
|
|
131
|
-
// `maxK` caps the scale factor: as a triangle degenerates r -> 0 and the exact
|
|
132
|
-
// answer explodes. What the cap should be is a question about the mesh, not
|
|
133
|
-
// about geometry, so it is the caller's number.
|
|
134
|
-
//
|
|
135
|
-
// Fixed-opcode: 3 points in, 3 points out, values only.
|
|
136
|
-
export const outsetTri = (pts, d, maxK = Infinity) => {
|
|
137
|
-
const e0 = Math.hypot(pts[1][0] - pts[2][0], pts[1][1] - pts[2][1]);
|
|
138
|
-
const e1 = Math.hypot(pts[2][0] - pts[0][0], pts[2][1] - pts[0][1]);
|
|
139
|
-
const e2 = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]);
|
|
140
|
-
const per = e0 + e1 + e2;
|
|
141
|
-
const ix = (e0 * pts[0][0] + e1 * pts[1][0] + e2 * pts[2][0]) / per;
|
|
142
|
-
const iy = (e0 * pts[0][1] + e1 * pts[1][1] + e2 * pts[2][1]) / per;
|
|
143
|
-
const area = Math.abs((pts[1][0] - pts[0][0]) * (pts[2][1] - pts[0][1])
|
|
144
|
-
- (pts[2][0] - pts[0][0]) * (pts[1][1] - pts[0][1])) / 2;
|
|
145
|
-
const rin = (2 * area) / per || 1e-3;
|
|
146
|
-
const k = Math.min(1 + d / rin, maxK);
|
|
147
|
-
return pts.map(([x, y]) => [ix + (x - ix) * k, iy + (y - iy) * k]);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
// Pull a point d px towards c. Used to bury a shape inside another one's
|
|
151
|
-
// silhouette: there is no clipping in the pipeline, so anything drawn on top of
|
|
152
|
-
// the face has to stop *short* of the outline or the overshoot lands on the
|
|
153
|
-
// background.
|
|
154
|
-
export const inward = ([x, y], [cx, cy], d) => {
|
|
155
|
-
const dx = cx - x, dy = cy - y, l = Math.hypot(dx, dy) || 1;
|
|
156
|
-
return [x + (dx / l) * d, y + (dy / l) * d];
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
// Walk the *same* Catmull-Rom curve `spline` emits and sample it. Two closed
|
|
160
|
-
// splines that share a run of control points still diverge, because the
|
|
161
|
-
// tangents at the ends of the run depend on the points outside it. Sampling the
|
|
162
|
-
// real curve and re-splining the samples fixes it for good, and the sample
|
|
163
|
-
// count depends only on the index range, so topology stays fixed.
|
|
164
|
-
export function sampleRun(pts, tension, from, to, per = 3) {
|
|
165
|
-
const n = pts.length, k = tension / 6;
|
|
166
|
-
const at = (i) => pts[((i % n) + n) % n];
|
|
167
|
-
const out = [];
|
|
168
|
-
for (let i = from; i < to; i++) {
|
|
169
|
-
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
|
|
170
|
-
const c1 = [p1[0] + (p2[0] - p0[0]) * k, p1[1] + (p2[1] - p0[1]) * k];
|
|
171
|
-
const c2 = [p2[0] - (p3[0] - p1[0]) * k, p2[1] - (p3[1] - p1[1]) * k];
|
|
172
|
-
for (let j = 0; j < per; j++) {
|
|
173
|
-
const t = j / per, u = 1 - t;
|
|
174
|
-
out.push([
|
|
175
|
-
u * u * u * p1[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t * t * t * p2[0],
|
|
176
|
-
u * u * u * p1[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t * t * t * p2[1],
|
|
177
|
-
]);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
out.push(eqp(at(to)));
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// ---------------------------------------------------------------------------
|
|
185
|
-
// Bands: the shape between two runs.
|
|
186
|
-
//
|
|
187
|
-
// Everything a flat-fill style draws that a stroke cannot — a tapering lash, a
|
|
188
|
-
// lid's cast shadow, a waterline, a lip seam, the shadow plane down one side
|
|
189
|
-
// of a face — is the region between two runs of the SAME length. It is worth a
|
|
190
|
-
// name because of what that buys: both edges are built from the same samples
|
|
191
|
-
// of the same arc, so the shape tracks every pose of that arc for free, with
|
|
192
|
-
// no pose entry of its own and no chance of the two edges drifting apart.
|
|
193
|
-
//
|
|
194
|
-
// It is also the only variable-width mark this pipeline can make. `stroke.w`
|
|
195
|
-
// is a scalar per draw and `rig.js` never blends it (see author/README.md), so
|
|
196
|
-
// a stroke cannot taper; a filled band between an arc and the same arc pushed
|
|
197
|
-
// off by a profile can.
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
|
|
200
|
-
// The closed ring of points: down `top`, back along `bot`. Non-mutating —
|
|
201
|
-
// `bot` is very often a run somebody else is still holding.
|
|
202
|
-
// Fixed-opcode: the ring is always top.length + bot.length points.
|
|
203
|
-
export const strip = (top, bot) => [...top, ...bot.slice().reverse()];
|
|
204
|
-
|
|
205
|
-
// `strip`, splined. The tension is style and there is no useful default beyond
|
|
206
|
-
// `spline`'s own, so say it.
|
|
207
|
-
export const band = (top, bot, tension = 1) => spline(strip(top, bot), tension);
|
|
208
|
-
|
|
209
|
-
// Offset a run vertically by a one-hump profile:
|
|
210
|
-
//
|
|
211
|
-
// y' = y + floor + amp * sin(PI * t^skew) ^ power t = i / (n-1)
|
|
212
|
-
//
|
|
213
|
-
// which is the "same arc, pushed off by a sine" that every band's second edge
|
|
214
|
-
// is made of. Each knob earns its place at a real call site: `amp` is the
|
|
215
|
-
// depth at the peak (negative pushes up), `floor` keeps a thin constant offset
|
|
216
|
-
// where the hump is zero (a lash line that continues inward as a lid line),
|
|
217
|
-
// `skew` slides the peak off centre (t^1.8 puts it at ~0.68, which is where the
|
|
218
|
-
// mass of a real lash sits), `power` sharpens or flattens the hump, and `pin`
|
|
219
|
-
// nails the two endpoints back onto the input.
|
|
220
|
-
//
|
|
221
|
-
// `pin` is not the same as `floor = 0`: a band whose edges meet exactly at the
|
|
222
|
-
// ends closes to a point, and a band that ends `floor` px apart ends in a pair
|
|
223
|
-
// of free-floating whiskers just outside the eye corners — invisible at 1x and
|
|
224
|
-
// obvious at 2x. Which of the two you want is the shape's business.
|
|
225
|
-
//
|
|
226
|
-
// Fixed-opcode: one point out per point in, values only.
|
|
227
|
-
export const bulge = (pts, amp, { floor = 0, skew = 1, power = 1, pin = false } = {}) =>
|
|
228
|
-
pts.map(([x, y], i) => {
|
|
229
|
-
if (pin && (i === 0 || i === pts.length - 1)) return [x, y];
|
|
230
|
-
const t = i / (pts.length - 1);
|
|
231
|
-
return [x, y + floor + amp * Math.pow(Math.sin(Math.PI * Math.pow(t, skew)), power)];
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
// Several disjoint contours in ONE draw. A cmds array may hold any number of
|
|
235
|
-
// closed contours; `nonzero` fills them all, and `rule: 'evenodd'` makes an
|
|
236
|
-
// enclosed one a hole. So a pair of lash ticks, a pair of commissure dots or
|
|
237
|
-
// three tooth separators is one draw and one paint, not two or three — which
|
|
238
|
-
// is the whole of the draw budget, and the reason `rule` is a passthrough
|
|
239
|
-
// field on `toRig`.
|
|
240
|
-
// Fixed-opcode: the concatenation of its arguments' opcode sequences, so it is
|
|
241
|
-
// fixed exactly when each run is.
|
|
242
|
-
export const contours = (...runs) => runs.flat();
|
|
243
|
-
|
|
244
|
-
// `n` points evenly around an ellipse, for feeding to `spline`. It has no
|
|
245
|
-
// default point count on purpose: `n` is this shape's whole topology, and a
|
|
246
|
-
// default is an invitation to write `circle(x, y, r)` for the base draw and
|
|
247
|
-
// `circle(x, y, r, 8)` for a pose, which is a rig that interpolates into
|
|
248
|
-
// garbage and nothing but validate.mjs to catch it. Say the number every time.
|
|
249
|
-
export const circle = (cx, cy, r, n, sq = 1) => {
|
|
250
|
-
const pts = [];
|
|
251
|
-
for (let i = 0; i < n; i++) {
|
|
252
|
-
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
|
|
253
|
-
pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r * sq]);
|
|
254
|
-
}
|
|
255
|
-
return pts;
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
// An annulus: two concentric contours in one path, to be filled `evenodd` so
|
|
259
|
-
// the inner one is a hole. `emit` is the style — `polygon` for a faceted look,
|
|
260
|
-
// the default closed spline for a smooth one — and both contours get the same
|
|
261
|
-
// `n`, so the pair stays interpolatable as one shape.
|
|
262
|
-
//
|
|
263
|
-
// The two RADII are given, not a radius and a width, because the two callers
|
|
264
|
-
// wrote the inner one two different ways (`r - w` and `r * 0.885`) and the
|
|
265
|
-
// library has no opinion about which; taking a width would have forced one of
|
|
266
|
-
// them through an extra subtraction for nothing.
|
|
267
|
-
//
|
|
268
|
-
// Why an avatar wants this at all: it is how a limbal ring stays a TRANSLUCENT
|
|
269
|
-
// overlay (see author/README.md, "the translucent-overlay convention") instead
|
|
270
|
-
// of a darkened copy of the iris colour. The hue ladder repaints the iris 39
|
|
271
|
-
// times and only swaps the iris draws' paint; a derived colour would have to be
|
|
272
|
-
// recomputed at every rung, an overlay does not.
|
|
273
|
-
export const ring = (cx, cy, rOuter, rInner, n, emit = (p) => spline(p, 1)) =>
|
|
274
|
-
contours(emit(circle(cx, cy, rOuter, n)), emit(circle(cx, cy, rInner, n)));
|
|
275
|
-
|
|
276
|
-
// A 6-number affine, rotating `deg` about `pivot` then translating. The format
|
|
277
|
-
// has no transform hierarchy, so this is what "turn the head" is made of.
|
|
278
|
-
export const rotMat = (deg, pivot, tx = 0, ty = 0) => {
|
|
279
|
-
const r = (deg * Math.PI) / 180, c = Math.cos(r), s = Math.sin(r);
|
|
280
|
-
return [r2(c), r2(s), r2(-s), r2(c),
|
|
281
|
-
r2(pivot[0] - (c * pivot[0] - s * pivot[1]) + tx),
|
|
282
|
-
r2(pivot[1] - (s * pivot[0] + c * pivot[1]) + ty)];
|
|
283
|
-
};
|