@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.
Files changed (191) hide show
  1. package/LICENSE-APACHE-2.0 +201 -0
  2. package/README.md +11 -98
  3. package/assets/README.md +28 -17
  4. package/assets/tanvi.glb +0 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tess.glb +0 -0
  8. package/assets/tushar.glb +0 -0
  9. package/client/internal.ts +4 -0
  10. package/client/supports.ts +6 -7
  11. package/client/three/assets.ts +2 -0
  12. package/client/three/budgets.ts +1 -1
  13. package/client/three/{tara-rig.ts → character-rig.ts} +708 -547
  14. package/client/three/createCharacter.ts +109 -0
  15. package/client/three/holds.ts +1 -1
  16. package/client/three/internal.ts +4 -3
  17. package/client/three/motion-limits.json +5 -1
  18. package/client/three/tanvi-asset.ts +6 -0
  19. package/client/three/tanvi.ts +30 -0
  20. package/client/three/tanya.ts +18 -58
  21. package/client/three/tara.ts +19 -60
  22. package/client/three/tess.ts +17 -59
  23. package/client/three/tushar.ts +18 -55
  24. package/dist/internal.d.ts +1 -1
  25. package/dist/internal.d.ts.map +1 -1
  26. package/dist/internal.js +5 -1
  27. package/dist/internal.js.map +1 -1
  28. package/dist/supports.d.ts +6 -7
  29. package/dist/supports.d.ts.map +1 -1
  30. package/dist/supports.js +6 -7
  31. package/dist/supports.js.map +1 -1
  32. package/dist/three/assets.d.ts +1 -0
  33. package/dist/three/assets.d.ts.map +1 -1
  34. package/dist/three/assets.js +2 -0
  35. package/dist/three/assets.js.map +1 -1
  36. package/dist/three/budgets.d.ts +1 -1
  37. package/dist/three/budgets.js +1 -1
  38. package/dist/three/character-rig.d.ts +366 -0
  39. package/dist/three/character-rig.d.ts.map +1 -0
  40. package/dist/three/{tara-rig.js → character-rig.js} +664 -525
  41. package/dist/three/character-rig.js.map +1 -0
  42. package/dist/three/createCharacter.d.ts +60 -0
  43. package/dist/three/createCharacter.d.ts.map +1 -0
  44. package/dist/three/createCharacter.js +84 -0
  45. package/dist/three/createCharacter.js.map +1 -0
  46. package/dist/three/holds.js +1 -1
  47. package/dist/three/holds.js.map +1 -1
  48. package/dist/three/internal.d.ts +3 -3
  49. package/dist/three/internal.d.ts.map +1 -1
  50. package/dist/three/internal.js +2 -2
  51. package/dist/three/internal.js.map +1 -1
  52. package/dist/three/motion-limits.json +5 -1
  53. package/dist/three/tanvi-asset.d.ts +7 -0
  54. package/dist/three/tanvi-asset.d.ts.map +1 -0
  55. package/dist/three/tanvi-asset.js +7 -0
  56. package/dist/three/tanvi-asset.js.map +1 -0
  57. package/dist/three/tanvi.d.ts +24 -0
  58. package/dist/three/tanvi.d.ts.map +1 -0
  59. package/dist/three/tanvi.js +24 -0
  60. package/dist/three/tanvi.js.map +1 -0
  61. package/dist/three/tanya.d.ts +16 -28
  62. package/dist/three/tanya.d.ts.map +1 -1
  63. package/dist/three/tanya.js +15 -43
  64. package/dist/three/tanya.js.map +1 -1
  65. package/dist/three/tara.d.ts +17 -22
  66. package/dist/three/tara.d.ts.map +1 -1
  67. package/dist/three/tara.js +16 -45
  68. package/dist/three/tara.js.map +1 -1
  69. package/dist/three/tess.d.ts +15 -29
  70. package/dist/three/tess.d.ts.map +1 -1
  71. package/dist/three/tess.js +14 -44
  72. package/dist/three/tess.js.map +1 -1
  73. package/dist/three/tushar.d.ts +16 -25
  74. package/dist/three/tushar.d.ts.map +1 -1
  75. package/dist/three/tushar.js +15 -40
  76. package/dist/three/tushar.js.map +1 -1
  77. package/package.json +7 -50
  78. package/src/avatar.d.ts +2 -0
  79. package/src/avatar.js +99 -220
  80. package/src/gaze.js +1 -3
  81. package/src/idle.js +10 -2
  82. package/src/prosody.js +7 -3
  83. package/src/speech-timing.js +28 -0
  84. package/src/visemes.js +107 -13
  85. package/client/arjun.ts +0 -26
  86. package/client/createCanvasAvatar.ts +0 -72
  87. package/client/interviewer-female.ts +0 -4
  88. package/client/interviewer-male.ts +0 -4
  89. package/client/ishita.ts +0 -26
  90. package/client/kabir.ts +0 -26
  91. package/client/meera.ts +0 -26
  92. package/client/naina.ts +0 -26
  93. package/client/professional-female-a.ts +0 -4
  94. package/client/professional-female-b.ts +0 -4
  95. package/client/professional-male-a.ts +0 -4
  96. package/client/professional-male-b.ts +0 -4
  97. package/client/vikram.ts +0 -26
  98. package/dist/arjun.d.ts +0 -7
  99. package/dist/arjun.d.ts.map +0 -1
  100. package/dist/arjun.js +0 -20
  101. package/dist/arjun.js.map +0 -1
  102. package/dist/createCanvasAvatar.d.ts +0 -22
  103. package/dist/createCanvasAvatar.d.ts.map +0 -1
  104. package/dist/createCanvasAvatar.js +0 -47
  105. package/dist/createCanvasAvatar.js.map +0 -1
  106. package/dist/interviewer-female.d.ts +0 -4
  107. package/dist/interviewer-female.d.ts.map +0 -1
  108. package/dist/interviewer-female.js +0 -3
  109. package/dist/interviewer-female.js.map +0 -1
  110. package/dist/interviewer-male.d.ts +0 -4
  111. package/dist/interviewer-male.d.ts.map +0 -1
  112. package/dist/interviewer-male.js +0 -3
  113. package/dist/interviewer-male.js.map +0 -1
  114. package/dist/ishita.d.ts +0 -7
  115. package/dist/ishita.d.ts.map +0 -1
  116. package/dist/ishita.js +0 -20
  117. package/dist/ishita.js.map +0 -1
  118. package/dist/kabir.d.ts +0 -7
  119. package/dist/kabir.d.ts.map +0 -1
  120. package/dist/kabir.js +0 -20
  121. package/dist/kabir.js.map +0 -1
  122. package/dist/meera.d.ts +0 -7
  123. package/dist/meera.d.ts.map +0 -1
  124. package/dist/meera.js +0 -20
  125. package/dist/meera.js.map +0 -1
  126. package/dist/naina.d.ts +0 -7
  127. package/dist/naina.d.ts.map +0 -1
  128. package/dist/naina.js +0 -20
  129. package/dist/naina.js.map +0 -1
  130. package/dist/professional-female-a.d.ts +0 -4
  131. package/dist/professional-female-a.d.ts.map +0 -1
  132. package/dist/professional-female-a.js +0 -3
  133. package/dist/professional-female-a.js.map +0 -1
  134. package/dist/professional-female-b.d.ts +0 -4
  135. package/dist/professional-female-b.d.ts.map +0 -1
  136. package/dist/professional-female-b.js +0 -3
  137. package/dist/professional-female-b.js.map +0 -1
  138. package/dist/professional-male-a.d.ts +0 -4
  139. package/dist/professional-male-a.d.ts.map +0 -1
  140. package/dist/professional-male-a.js +0 -3
  141. package/dist/professional-male-a.js.map +0 -1
  142. package/dist/professional-male-b.d.ts +0 -4
  143. package/dist/professional-male-b.d.ts.map +0 -1
  144. package/dist/professional-male-b.js +0 -3
  145. package/dist/professional-male-b.js.map +0 -1
  146. package/dist/three/tara-rig.d.ts +0 -423
  147. package/dist/three/tara-rig.d.ts.map +0 -1
  148. package/dist/three/tara-rig.js.map +0 -1
  149. package/dist/vikram.d.ts +0 -7
  150. package/dist/vikram.d.ts.map +0 -1
  151. package/dist/vikram.js +0 -20
  152. package/dist/vikram.js.map +0 -1
  153. package/src/canvas/author/parts/eye.mjs +0 -722
  154. package/src/canvas/author/parts/hand.mjs +0 -1156
  155. package/src/canvas/author/parts/mouth.mjs +0 -741
  156. package/src/canvas/author/parts/nose.mjs +0 -100
  157. package/src/canvas/author/parts/skin-detail.mjs +0 -67
  158. package/src/canvas/author/path.mjs +0 -283
  159. package/src/canvas/author/rig.mjs +0 -405
  160. package/src/canvas/avatars/round/face.d.mts +0 -3
  161. package/src/canvas/avatars/round/face.mjs +0 -1307
  162. package/src/canvas/create-rig.d.ts +0 -15
  163. package/src/canvas/create-rig.js +0 -100
  164. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  165. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  166. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  167. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  168. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  169. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  170. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  171. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  172. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  173. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  174. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  175. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  176. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  177. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  178. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  179. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  180. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  181. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  182. package/src/canvas/data/interviewer-female.rig.json +0 -1
  183. package/src/canvas/data/interviewer-male.rig.json +0 -1
  184. package/src/canvas/data/professional-female-a.rig.json +0 -1
  185. package/src/canvas/data/professional-female-b.rig.json +0 -1
  186. package/src/canvas/data/professional-male-a.rig.json +0 -1
  187. package/src/canvas/data/professional-male-b.rig.json +0 -1
  188. package/src/canvas/src/live.js +0 -508
  189. package/src/canvas/src/render2d.js +0 -218
  190. package/src/canvas/src/rig.js +0 -297
  191. 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
- };