@heyramzi/ui 2.11.1 → 2.11.2

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 (266) hide show
  1. package/dist/agents/helpers.js +38 -38
  2. package/dist/agents/page-state.js +49 -50
  3. package/dist/agents/vendor/color.js +146 -127
  4. package/dist/agents/vendor/draw.js +617 -508
  5. package/dist/agents/vendor/engine.js +698 -599
  6. package/dist/agents/vendor/mesh.js +653 -683
  7. package/dist/agents/vendor/plastic.js +3913 -3112
  8. package/dist/agents/vendor/presets.js +163 -164
  9. package/dist/agents/vendor/shapes.js +21 -20
  10. package/dist/agents/vendor/ticker.js +41 -42
  11. package/dist/agents/vendor/types.js +1 -0
  12. package/dist/agents/vendor/wear.js +1421 -1448
  13. package/dist/ai/confirmation/confirmation-context.svelte.js +6 -6
  14. package/dist/ai/confirmation/index.js +3 -1
  15. package/dist/ai/index.js +33 -0
  16. package/dist/ai/prompt-input/index.js +3 -1
  17. package/dist/ai/reasoning/index.js +3 -1
  18. package/dist/ai/reasoning/reasoning-context.svelte.js +6 -6
  19. package/dist/ai/suggestion/index.js +3 -1
  20. package/dist/ai/typing-dots/index.js +3 -1
  21. package/dist/animations/text-swap.js +24 -24
  22. package/dist/brand/index.js +54 -48
  23. package/dist/canvas/card/resize.js +5 -3
  24. package/dist/canvas/edges/edge-path.js +255 -242
  25. package/dist/canvas/editor/context-menu.js +39 -36
  26. package/dist/canvas/editor/dispatch.svelte.js +154 -151
  27. package/dist/canvas/editor/editor-context.js +7 -2
  28. package/dist/canvas/editor/editor-mode.js +1 -0
  29. package/dist/canvas/editor/frame.js +73 -76
  30. package/dist/canvas/editor/hand-context.js +5 -2
  31. package/dist/canvas/editor/history-core.js +163 -191
  32. package/dist/canvas/editor/keymap.js +95 -169
  33. package/dist/canvas/editor/layout-actions.js +143 -144
  34. package/dist/canvas/editor/lock.js +52 -51
  35. package/dist/canvas/editor/rebase.js +217 -240
  36. package/dist/canvas/editor/style.js +132 -137
  37. package/dist/canvas/editor/text-field-context.js +7 -2
  38. package/dist/canvas/editor/z-order.js +65 -70
  39. package/dist/canvas/headless/alignment.js +22 -16
  40. package/dist/canvas/headless/break-apart-find.js +286 -279
  41. package/dist/canvas/headless/break-apart-pieces.js +62 -58
  42. package/dist/canvas/headless/break-apart-plan.js +299 -325
  43. package/dist/canvas/headless/break-apart-style.js +75 -70
  44. package/dist/canvas/headless/break-apart.js +188 -181
  45. package/dist/canvas/headless/canvas-cleanup.js +78 -58
  46. package/dist/canvas/headless/edge-anchors.js +115 -116
  47. package/dist/canvas/headless/edge-memo.js +24 -22
  48. package/dist/canvas/headless/edge-serialization.js +17 -27
  49. package/dist/canvas/headless/figure-geometry.js +232 -264
  50. package/dist/canvas/headless/fit-viewport.js +84 -67
  51. package/dist/canvas/headless/grid.js +88 -86
  52. package/dist/canvas/headless/icon-node.js +44 -36
  53. package/dist/canvas/headless/index.js +1 -0
  54. package/dist/canvas/headless/linkify.js +17 -19
  55. package/dist/canvas/headless/pen-marks.js +101 -95
  56. package/dist/canvas/headless/sketch.js +231 -209
  57. package/dist/canvas/headless/snap-guides.js +99 -83
  58. package/dist/canvas/headless/svg-figure.js +133 -117
  59. package/dist/canvas/headless/table-layout.js +146 -107
  60. package/dist/canvas/index.js +2 -0
  61. package/dist/canvas/lod/lod-mode.svelte.js +20 -18
  62. package/dist/canvas/lod/render-all.svelte.js +36 -39
  63. package/dist/canvas/ops/arrange.js +78 -71
  64. package/dist/canvas/ops/ops.js +260 -255
  65. package/dist/canvas/pen/colors.js +37 -35
  66. package/dist/canvas/pen/eraser.js +28 -27
  67. package/dist/canvas/pen/geometry.js +120 -130
  68. package/dist/canvas/pen/index.js +1 -0
  69. package/dist/canvas/pen/ops.js +11 -11
  70. package/dist/canvas/pen/pen-session.js +150 -159
  71. package/dist/canvas/pen/plugin.js +107 -104
  72. package/dist/canvas/pen/shapes.js +73 -68
  73. package/dist/canvas/pen/stroke-path.js +32 -30
  74. package/dist/canvas/pen/types.js +21 -21
  75. package/dist/canvas/pen/ui.js +10 -8
  76. package/dist/canvas/plugin.js +46 -46
  77. package/dist/canvas/screen/glyphs.js +12 -11
  78. package/dist/canvas/screen/labels.js +22 -22
  79. package/dist/canvas/screen/parts.js +98 -111
  80. package/dist/canvas/screen/plugin.js +47 -56
  81. package/dist/canvas/screen/types.js +30 -35
  82. package/dist/canvas/screen/ui.js +7 -7
  83. package/dist/canvas/test-utils.js +14 -14
  84. package/dist/canvas/types.js +1 -0
  85. package/dist/canvas/ui/forward-wheel.js +24 -25
  86. package/dist/canvas/ui/icons.js +64 -131
  87. package/dist/canvas/ui/place-panel.js +58 -49
  88. package/dist/canvas/ui/shortcut-tip.js +14 -12
  89. package/dist/canvas/ui/tools.js +300 -385
  90. package/dist/charts/dashboard/chart.js +4 -0
  91. package/dist/charts/dashboard/data-table.js +11 -0
  92. package/dist/charts/dashboard/windows.js +8 -3
  93. package/dist/charts/edge-fade.js +10 -5
  94. package/dist/charts/hover-band.js +16 -11
  95. package/dist/charts/index.js +0 -1
  96. package/dist/charts/motion.js +11 -7
  97. package/dist/charts/primitives/chart-utils.js +12 -12
  98. package/dist/components/copy/clipboard.js +32 -32
  99. package/dist/components/copy/copied.svelte.js +31 -23
  100. package/dist/components/data-table/data-table.js +39 -39
  101. package/dist/components/docs/index.js +4 -0
  102. package/dist/components/docs/scroll-spy.svelte.js +73 -68
  103. package/dist/components/list-page/filter-section.js +4 -1
  104. package/dist/components/list-page/index.js +2 -0
  105. package/dist/components/list-page/list-table.js +7 -2
  106. package/dist/components/list-page/queue-rail.js +14 -0
  107. package/dist/components/list-page/section-host.js +4 -2
  108. package/dist/components/list-page/step.js +4 -3
  109. package/dist/components/list-page/types.js +1 -0
  110. package/dist/components/picker/trigger.js +48 -30
  111. package/dist/components/picker/types.js +1 -0
  112. package/dist/components/segmented/collapse.js +38 -27
  113. package/dist/components/segmented/index.js +8 -0
  114. package/dist/components/segmented/segmented.js +67 -42
  115. package/dist/components/star-rating-input/index.js +3 -1
  116. package/dist/components/table-view/types.js +1 -0
  117. package/dist/components/tags-input/tags.js +81 -69
  118. package/dist/components/theme/index.js +4 -0
  119. package/dist/components/toolbar/index.js +4 -0
  120. package/dist/components/toolbar/toolbar-trigger.js +14 -1
  121. package/dist/editor/anchors.js +17 -13
  122. package/dist/editor/block-id.js +52 -51
  123. package/dist/editor/block-ops.js +108 -80
  124. package/dist/editor/doc-blocks.js +155 -166
  125. package/dist/editor/extensions.js +139 -180
  126. package/dist/editor/index.js +4 -0
  127. package/dist/editor/markdown-test-helpers.js +4 -2
  128. package/dist/editor/markdown.js +420 -408
  129. package/dist/editor/serializer.js +204 -200
  130. package/dist/editor/slash-items.js +89 -101
  131. package/dist/editor/youtube-url.js +25 -20
  132. package/dist/effects/animated-beam/path-calculator.svelte.js +43 -38
  133. package/dist/effects/animated-beam/types.js +1 -0
  134. package/dist/effects/border-beam/css.js +92 -78
  135. package/dist/effects/border-beam/presets.js +1705 -1720
  136. package/dist/effects/border-beam/register-properties.js +43 -33
  137. package/dist/effects/border-beam/types.js +1 -0
  138. package/dist/effects/image-comparison/compare.js +3 -2
  139. package/dist/effects/image-reveal/types.js +1 -0
  140. package/dist/effects/magnetic/magnetic.js +58 -52
  141. package/dist/effects/reveal/observe.js +50 -40
  142. package/dist/effects/reveal/types.js +1 -0
  143. package/dist/effects/shader-gradient/color.js +36 -29
  144. package/dist/effects/shader-gradient/renderer.js +384 -295
  145. package/dist/effects/shader-gradient/shaders.js +31 -0
  146. package/dist/effects/shader-gradient/types.js +1 -0
  147. package/dist/icons/apple-symbol-exports.js +99 -124
  148. package/dist/icons/sf-symbols.js +357 -461
  149. package/dist/icons/trades.js +43 -28
  150. package/dist/inbox/index.js +1 -0
  151. package/dist/inbox/logic.js +126 -122
  152. package/dist/inbox/types.js +43 -43
  153. package/dist/lib/arrive.js +19 -12
  154. package/dist/lib/icon-types.js +1 -0
  155. package/dist/lib/initials.js +16 -10
  156. package/dist/lib/layout.js +5 -0
  157. package/dist/lib/panel.js +13 -0
  158. package/dist/lib/result.js +22 -22
  159. package/dist/lib/sliding-pill.js +59 -44
  160. package/dist/lib/theme-transition.js +43 -43
  161. package/dist/lib/tone.js +31 -24
  162. package/dist/lib/utils.js +3 -1
  163. package/dist/onboarding/define-tours.js +24 -23
  164. package/dist/onboarding/onboarding.svelte.js +16 -12
  165. package/dist/onboarding/pick.js +10 -6
  166. package/dist/onboarding/stores.js +40 -41
  167. package/dist/onboarding/wait.js +24 -23
  168. package/dist/panel/side-panel-types.js +1 -0
  169. package/dist/record/demo-data.js +459 -486
  170. package/dist/record/fields.js +129 -131
  171. package/dist/record/format.js +70 -82
  172. package/dist/record/gutter.js +1 -0
  173. package/dist/record/kinds.js +95 -105
  174. package/dist/record/logic.js +238 -219
  175. package/dist/record/types.js +49 -48
  176. package/dist/shadcn/accordion/index.js +3 -1
  177. package/dist/shadcn/alert/index.js +3 -1
  178. package/dist/shadcn/alert-dialog/index.js +3 -1
  179. package/dist/shadcn/attachment/index.js +3 -1
  180. package/dist/shadcn/avatar/index.js +3 -1
  181. package/dist/shadcn/breadcrumb/index.js +3 -1
  182. package/dist/shadcn/bubble/index.js +3 -1
  183. package/dist/shadcn/button/index.js +3 -1
  184. package/dist/shadcn/calendar/index.js +3 -1
  185. package/dist/shadcn/card/index.js +3 -1
  186. package/dist/shadcn/checkbox/index.js +3 -1
  187. package/dist/shadcn/code-block/index.js +3 -1
  188. package/dist/shadcn/collapsible/index.js +3 -1
  189. package/dist/shadcn/command/index.js +4 -1
  190. package/dist/shadcn/context-menu/index.js +3 -1
  191. package/dist/shadcn/dialog/index.js +3 -1
  192. package/dist/shadcn/dropdown-menu/menu-item.js +40 -20
  193. package/dist/shadcn/empty/index.js +3 -1
  194. package/dist/shadcn/field/index.js +3 -1
  195. package/dist/shadcn/hooks/highlighter.js +77 -75
  196. package/dist/shadcn/hooks/is-mobile.svelte.js +3 -3
  197. package/dist/shadcn/hooks/use-highlighted-code.svelte.js +62 -62
  198. package/dist/shadcn/input/index.js +3 -1
  199. package/dist/shadcn/kbd/index.js +3 -1
  200. package/dist/shadcn/label/index.js +3 -1
  201. package/dist/shadcn/marker/index.js +3 -1
  202. package/dist/shadcn/message/index.js +3 -1
  203. package/dist/shadcn/message-scroller/index.js +3 -1
  204. package/dist/shadcn/popover/index.js +3 -1
  205. package/dist/shadcn/progress/index.js +3 -1
  206. package/dist/shadcn/progress/tone.js +8 -7
  207. package/dist/shadcn/radio-group/index.js +3 -1
  208. package/dist/shadcn/scroll-area/index.js +3 -1
  209. package/dist/shadcn/select/index.js +3 -1
  210. package/dist/shadcn/separator/index.js +3 -1
  211. package/dist/shadcn/sheet/index.js +3 -1
  212. package/dist/shadcn/sidebar/context.svelte.js +42 -28
  213. package/dist/shadcn/sidebar/index.js +3 -1
  214. package/dist/shadcn/skeleton/index.js +3 -1
  215. package/dist/shadcn/slider/index.js +3 -1
  216. package/dist/shadcn/switch/index.js +3 -1
  217. package/dist/shadcn/table/index.js +3 -1
  218. package/dist/shadcn/tabs/index.js +3 -1
  219. package/dist/shadcn/textarea/index.js +3 -1
  220. package/dist/shadcn/toggle/index.js +3 -1
  221. package/dist/shadcn/toggle-group/index.js +3 -1
  222. package/dist/shadcn/tooltip/context.js +7 -2
  223. package/dist/shadcn/tooltip/index.js +3 -1
  224. package/dist/shadcn/trace/index.js +3 -1
  225. package/dist/slides/bridges.js +65 -59
  226. package/dist/slides/deck-controller.svelte.js +210 -207
  227. package/dist/slides/export/capture.js +66 -71
  228. package/dist/slides/export/ffmpeg.js +76 -72
  229. package/dist/slides/export/index.js +3 -0
  230. package/dist/slides/export/launch.js +9 -6
  231. package/dist/slides/export/pdf.js +60 -54
  232. package/dist/slides/export/types.js +1 -0
  233. package/dist/slides/labels.js +24 -24
  234. package/dist/slides/layout.js +39 -41
  235. package/dist/slides/model.js +9 -7
  236. package/dist/slides/navigation.js +78 -73
  237. package/dist/slides/types.js +2 -2
  238. package/dist/status/define-statuses.js +18 -14
  239. package/dist/status/status-types.js +1 -0
  240. package/dist/test-setup.js +24 -20
  241. package/dist/thread/logic.js +73 -77
  242. package/dist/thread/types.js +21 -21
  243. package/dist/work/board-order.js +94 -90
  244. package/dist/work/dates.js +16 -9
  245. package/dist/work/detail/detail-types.js +18 -18
  246. package/dist/work/docs/edited.js +32 -29
  247. package/dist/work/docs/index.js +4 -0
  248. package/dist/work/docs/labels.js +34 -34
  249. package/dist/work/docs/menu.js +113 -106
  250. package/dist/work/docs/outline.js +39 -29
  251. package/dist/work/gantt/logic.js +211 -235
  252. package/dist/work/inbox/inbox-logic.js +71 -75
  253. package/dist/work/inbox/inbox-statuses.js +75 -72
  254. package/dist/work/inbox/inbox-types.js +1 -0
  255. package/dist/work/kanban-drag.js +27 -22
  256. package/dist/work/kanban-types.js +1 -0
  257. package/dist/work/presets/sales.js +36 -32
  258. package/dist/work/reorder.js +9 -8
  259. package/dist/work/task-types.js +1 -0
  260. package/dist/work/time/duration.js +51 -46
  261. package/dist/work/time/time-types.js +23 -23
  262. package/dist/work/use-kanban-dnd.svelte.js +126 -125
  263. package/package.json +3 -1
  264. package/src/components/list-page/master-detail.svelte +3 -1
  265. package/src/components/list-page/rail-row.svelte +4 -4
  266. package/src/editor/markdown.ts +30 -35
@@ -1,548 +1,657 @@
1
1
  import { shade } from "./color.js";
2
2
  import { drawPlasticCap, mulAffine, surfaceAt } from "./plastic.js";
3
3
  import { drawGlasses, drawWearBehind, drawWearFront } from "./wear.js";
4
+ /* The canvas is drawn larger than the avatar's layout box, so a hop or a
5
+ flip can leave the box without being clipped. */
4
6
  export const OVERSCAN = 1.5;
5
- export const RISE = 0.1;
7
+ /** the body's centre sits this fraction of the box below the canvas
8
+ centre: hops and flips need the room above, not below */
9
+ export const RISE = .1;
10
+ /* Copies through the depth, and the stock half-depth in body units. */
6
11
  const SLICES = 17;
7
12
  const HALF_DEPTH = 15;
8
- const CAP = 0.9;
9
- const profile = (z, cap)=>cap + (1 - cap) * Math.sqrt(Math.max(0, 1 - z * z));
13
+ /* Cap scale at the ends of the pillow, at the stock rim width. */
14
+ const CAP = .9;
15
+ const profile = (z, cap) => cap + (1 - cap) * Math.sqrt(Math.max(0, 1 - z * z));
16
+ /* Big, plain eyes, a little taller than wide: a round dot reads as a button at 24px. */
10
17
  const EYE_GAP = 25;
11
18
  const EYE_RX = 5.6;
12
19
  const EYE_Y = {
13
- eyes: 1,
14
- mouth: -3.5
20
+ eyes: 1,
21
+ mouth: -3.5
15
22
  };
16
23
  const paletteCache = new Map();
17
24
  function palette(color, shadow, highlight) {
18
- const key = `${color}|${shadow}|${highlight}`;
19
- let p = paletteCache.get(key);
20
- if (!p) {
21
- const far = shade(color, -0.3 * shadow, 0.05 * shadow);
22
- const near = shade(color, -0.12 * shadow, 0.03 * shadow);
23
- const crispMix = [], smoothMix = [];
24
- for(let j = 0; j < SLICES; j++){
25
- const t = j / (SLICES - 1);
26
- crispMix.push(t > 0.6 ? "" : mixCss(far, near, t / 0.6));
27
- smoothMix.push(t >= 0.5 ? color : mixCss(far, color, t / 0.5));
28
- }
29
- p = {
30
- base: color,
31
- far,
32
- near,
33
- light: shade(color, 0.04 * highlight),
34
- dark: shade(color, -0.3 * shadow, 0.05 * shadow),
35
- capTop: shade(color, 0.035 * highlight),
36
- capBottom: shade(color, -0.035 * shadow),
37
- crispMix,
38
- smoothMix,
39
- grad: null
40
- };
41
- if (paletteCache.size > 200) paletteCache.clear();
42
- paletteCache.set(key, p);
43
- }
44
- return p;
25
+ const key = `${color}|${shadow}|${highlight}`;
26
+ let p = paletteCache.get(key);
27
+ if (!p) {
28
+ const far = shade(color, -.3 * shadow, .05 * shadow);
29
+ const near = shade(color, -.12 * shadow, .03 * shadow);
30
+ const crispMix = [], smoothMix = [];
31
+ for (let j = 0; j < SLICES; j++) {
32
+ const t = j / (SLICES - 1);
33
+ crispMix.push(t > .6 ? "" : mixCss(far, near, t / .6));
34
+ smoothMix.push(t >= .5 ? color : mixCss(far, color, t / .5));
35
+ }
36
+ p = {
37
+ base: color,
38
+ far,
39
+ near,
40
+ light: shade(color, .04 * highlight),
41
+ dark: shade(color, -.3 * shadow, .05 * shadow),
42
+ capTop: shade(color, .035 * highlight),
43
+ capBottom: shade(color, -.035 * shadow),
44
+ crispMix,
45
+ smoothMix,
46
+ grad: null
47
+ };
48
+ if (paletteCache.size > 200) paletteCache.clear();
49
+ paletteCache.set(key, p);
50
+ }
51
+ return p;
45
52
  }
46
- const hslNums = (c)=>(c.startsWith("hsl(") ? c : shade(c, 0)).match(/[\d.]+/g).map(Number);
53
+ /* the numbers of an hsl() string; any other colour is normalised through shade() first */
54
+ const hslNums = (c) => (c.startsWith("hsl(") ? c : shade(c, 0)).match(/[\d.]+/g).map(Number);
47
55
  function mixCss(a, b, t) {
48
- const pa = hslNums(a);
49
- const pb = hslNums(b);
50
- const m = pa.map((v, i)=>v + (pb[i] - v) * t);
51
- return `hsl(${m[0].toFixed(1)} ${m[1].toFixed(1)}% ${m[2].toFixed(1)}%)`;
56
+ /* interpolate the hsl numbers */
57
+ const pa = hslNums(a);
58
+ const pb = hslNums(b);
59
+ const m = pa.map((v, i) => v + (pb[i] - v) * t);
60
+ return `hsl(${m[0].toFixed(1)} ${m[1].toFixed(1)}% ${m[2].toFixed(1)}%)`;
52
61
  }
62
+ /* The whirl: the cartoon motion round a spinning body — one tapered
63
+ trail on a tilted ring, made of the body's own material: a translucent
64
+ plastic tube in the body colour, lit from the same light — a lighter
65
+ flank toward it, a darker underside, a white specular ridge along the
66
+ top — with a soft halo so it reads as a puff of plastic cloud. The
67
+ ring lies in the body's equatorial plane seen a little from above,
68
+ with a touch of perspective: the near half (sin > 0) is larger,
69
+ thicker and stronger and is drawn over the body and face, casting a
70
+ soft shadow on them; the far half is smaller and fainter and goes
71
+ behind. */
53
72
  const WHIRL_SEGMENTS = 34;
54
73
  const WHIRL_SPAN = Math.PI * 1.55;
55
74
  const WHIRL_RX = 57;
56
- const WHIRL_RATIO = 0.4;
57
- const WHIRL_TILT = -0.28;
75
+ const WHIRL_RATIO = .4;
76
+ const WHIRL_TILT = -.28;
58
77
  const whirlInkCache = new Map();
59
78
  function whirlInk(color) {
60
- let w = whirlInkCache.get(color);
61
- if (!w) {
62
- w = {
63
- base: shade(color, 0.1, 0.02),
64
- light: shade(color, 0.3, 0.04),
65
- dark: shade(color, -0.22, 0.08),
66
- halo: shade(color, 0.2)
67
- };
68
- if (whirlInkCache.size > 200) whirlInkCache.clear();
69
- whirlInkCache.set(color, w);
70
- }
71
- return w;
79
+ let w = whirlInkCache.get(color);
80
+ if (!w) {
81
+ w = {
82
+ base: shade(color, .1, .02),
83
+ light: shade(color, .3, .04),
84
+ dark: shade(color, -.22, .08),
85
+ halo: shade(color, .2)
86
+ };
87
+ if (whirlInkCache.size > 200) whirlInkCache.clear();
88
+ whirlInkCache.set(color, w);
89
+ }
90
+ return w;
72
91
  }
73
- const withAlpha = (hsl, a)=>hsl.replace(")", ` / ${Math.max(0, Math.min(1, a)).toFixed(3)})`);
92
+ /* an hsl() from shade() with an alpha */
93
+ const withAlpha = (hsl, a) => hsl.replace(")", ` / ${Math.max(0, Math.min(1, a)).toFixed(3)})`);
74
94
  function drawWhirl(ctx, pose, color, lx, ly, near, knobs) {
75
- const strength = knobs?.strength ?? 0;
76
- const k = Math.min(1, pose.whirl * strength);
77
- if (k <= 0.01) return;
78
- const sizeK = knobs?.size ?? 1, widthK = knobs?.width ?? 1, lengthK = knobs?.length ?? 1, tiltK = knobs?.tilt ?? 1;
79
- const span = WHIRL_SPAN * lengthK;
80
- const ink = whirlInk(color);
81
- const head = -pose.whirlAngle;
82
- const rx = WHIRL_RX * sizeK;
83
- const ry = rx * WHIRL_RATIO * tiltK * (near ? 1.14 : 0.86);
84
- const lightA = Math.atan2(ly, lx) - WHIRL_TILT;
85
- ctx.save();
86
- ctx.rotate(WHIRL_TILT);
87
- ctx.translate(0, 5);
88
- ctx.lineCap = "butt";
89
- const seg = (a0, a1, width, style, dy)=>{
90
- ctx.strokeStyle = style;
91
- ctx.lineWidth = width;
92
- ctx.beginPath();
93
- ctx.ellipse(0, dy, rx, ry, 0, a0, a1, false);
94
- ctx.stroke();
95
- };
96
- if (near) {
97
- for(let i = 0; i < WHIRL_SEGMENTS; i++){
98
- const f = i / WHIRL_SEGMENTS;
99
- const a1 = head + f * span, a0 = a1 + span / WHIRL_SEGMENTS + 0.012;
100
- if (Math.sin((a0 + a1) / 2) <= 0) continue;
101
- const fade = Math.pow(1 - f, 1.3);
102
- seg(a1, a0, (2 + 8 * fade) * 1.5 * widthK, `rgba(0,0,0,${(0.2 * k * fade).toFixed(3)})`, 3.5);
103
- }
104
- }
105
- for(let i = 0; i < WHIRL_SEGMENTS; i++){
106
- const f = i / WHIRL_SEGMENTS;
107
- const a1 = head + f * span, a0 = a1 + span / WHIRL_SEGMENTS + 0.012;
108
- const mid = (a0 + a1) / 2;
109
- if (Math.sin(mid) > 0 !== near) continue;
110
- const depth = 0.6 + 0.4 * Math.sin(mid);
111
- const fade = Math.pow(1 - f, 1.3);
112
- const puff = 1 + 0.18 * Math.sin(f * 9 + 1.2);
113
- const width = (2 + 8 * fade) * depth * widthK * puff;
114
- const a = k * (0.3 + 0.7 * fade) * depth;
115
- const facing = 0.5 + 0.5 * Math.cos(mid - lightA);
116
- seg(a1, a0, width * 2.6, withAlpha(ink.halo, a * 0.2), 0);
117
- seg(a1, a0, width * 0.8, withAlpha(ink.dark, a * 0.45), width * 0.32);
118
- seg(a1, a0, width, withAlpha(ink.base, a * 0.72), 0);
119
- seg(a1, a0, width * 0.62, withAlpha(ink.light, a * 0.78 * (0.4 + 0.6 * facing)), -width * 0.16);
120
- seg(a1, a0, width * 0.24, `rgba(255,255,255,${(a * 0.9 * (0.15 + 0.85 * facing * facing)).toFixed(3)})`, -width * 0.3);
121
- }
122
- ctx.restore();
95
+ const strength = knobs?.strength ?? 0;
96
+ const k = Math.min(1, pose.whirl * strength);
97
+ if (k <= .01) return;
98
+ const sizeK = knobs?.size ?? 1, widthK = knobs?.width ?? 1, lengthK = knobs?.length ?? 1, tiltK = knobs?.tilt ?? 1;
99
+ const span = WHIRL_SPAN * lengthK;
100
+ const ink = whirlInk(color);
101
+ /* the ring runs the way the body's near face moves: to the right */
102
+ const head = -pose.whirlAngle;
103
+ const rx = WHIRL_RX * sizeK;
104
+ const ry = rx * WHIRL_RATIO * tiltK * (near ? 1.14 : .86);
105
+ /* where round the ring the light falls, in the ring's own frame */
106
+ const lightA = Math.atan2(ly, lx) - WHIRL_TILT;
107
+ ctx.save();
108
+ ctx.rotate(WHIRL_TILT);
109
+ ctx.translate(0, 5);
110
+ ctx.lineCap = "butt";
111
+ const seg = (a0, a1, width, style, dy) => {
112
+ ctx.strokeStyle = style;
113
+ ctx.lineWidth = width;
114
+ ctx.beginPath();
115
+ ctx.ellipse(0, dy, rx, ry, 0, a0, a1, false);
116
+ ctx.stroke();
117
+ };
118
+ /* the near half casts a soft shadow on the body it crosses */
119
+ if (near) {
120
+ for (let i = 0; i < WHIRL_SEGMENTS; i++) {
121
+ const f = i / WHIRL_SEGMENTS;
122
+ const a1 = head + f * span, a0 = a1 + span / WHIRL_SEGMENTS + .012;
123
+ if (Math.sin((a0 + a1) / 2) <= 0) continue;
124
+ const fade = Math.pow(1 - f, 1.3);
125
+ seg(a1, a0, (2 + 8 * fade) * 1.5 * widthK, `rgba(0,0,0,${(.2 * k * fade).toFixed(3)})`, 3.5);
126
+ }
127
+ }
128
+ for (let i = 0; i < WHIRL_SEGMENTS; i++) {
129
+ const f = i / WHIRL_SEGMENTS;
130
+ /* the trail lies at the angles the head has already passed */
131
+ const a1 = head + f * span, a0 = a1 + span / WHIRL_SEGMENTS + .012;
132
+ const mid = (a0 + a1) / 2;
133
+ if (Math.sin(mid) > 0 !== near) continue;
134
+ /* perspective and depth: the nearest point of the ring is fullest */
135
+ const depth = .6 + .4 * Math.sin(mid);
136
+ const fade = Math.pow(1 - f, 1.3);
137
+ /* a gentle puff along the trail */
138
+ const puff = 1 + .18 * Math.sin(f * 9 + 1.2);
139
+ const width = (2 + 8 * fade) * depth * widthK * puff;
140
+ const a = k * (.3 + .7 * fade) * depth;
141
+ /* how much this stretch of the ring faces the light */
142
+ const facing = .5 + .5 * Math.cos(mid - lightA);
143
+ /* halo, underside, body, lit flank, specular ridge: a plastic tube */
144
+ seg(a1, a0, width * 2.6, withAlpha(ink.halo, a * .2), 0);
145
+ seg(a1, a0, width * .8, withAlpha(ink.dark, a * .45), width * .32);
146
+ seg(a1, a0, width, withAlpha(ink.base, a * .72), 0);
147
+ seg(a1, a0, width * .62, withAlpha(ink.light, a * .78 * (.4 + .6 * facing)), -width * .16);
148
+ seg(a1, a0, width * .24, `rgba(255,255,255,${(a * .9 * (.15 + .85 * facing * facing)).toFixed(3)})`, -width * .3);
149
+ }
150
+ ctx.restore();
123
151
  }
124
- const backOf = (deg)=>{
125
- if (deg === undefined || !Number.isFinite(deg)) return {};
126
- const a = deg * Math.PI / 180;
127
- return {
128
- bx: Math.sin(a),
129
- by: -Math.cos(a)
130
- };
152
+ /**
153
+ * Draw one frame. `box` is the avatar's layout size in CSS px; the canvas
154
+ * is `box * OVERSCAN` square with the body's centre `RISE * box` below
155
+ * its middle, and the context already scaled for the device pixel ratio.
156
+ */
157
+ /* the back light's direction on screen from its angle, when one is given */
158
+ const backOf = (deg) => {
159
+ if (deg === undefined || !Number.isFinite(deg)) return {};
160
+ const a = deg * Math.PI / 180;
161
+ return {
162
+ bx: Math.sin(a),
163
+ by: -Math.cos(a)
164
+ };
131
165
  };
166
+ /** The light each material looks its best in where the config leaves it:
167
+ a plush pile wants a broad, low studio light that shows its lobes and
168
+ grain, a clear coat a brighter one for its gloss. */
132
169
  export const LIGHT_DEFAULTS = {
133
- fabric: {
134
- shadow: 1.15,
135
- highlight: 1.45,
136
- light: 295,
137
- rim: 0.6,
138
- spread: 1.6
139
- },
140
- other: {
141
- shadow: 0.35,
142
- highlight: 1.3,
143
- light: 300,
144
- rim: 0.5,
145
- spread: 1.55
146
- }
170
+ fabric: {
171
+ shadow: 1.15,
172
+ highlight: 1.45,
173
+ light: 295,
174
+ rim: .6,
175
+ spread: 1.6
176
+ },
177
+ other: {
178
+ shadow: .35,
179
+ highlight: 1.3,
180
+ light: 300,
181
+ rim: .5,
182
+ spread: 1.55
183
+ }
147
184
  };
148
185
  export function draw(ctx, box, pose, cfg) {
149
- const full = box * OVERSCAN;
150
- ctx.clearRect(0, 0, full, full);
151
- const S = box / 100;
152
- let dpr, base;
153
- if (cfg.dpr !== undefined) {
154
- dpr = cfg.dpr;
155
- base = [
156
- dpr,
157
- 0,
158
- 0,
159
- dpr,
160
- 0,
161
- 0
162
- ];
163
- } else if (ctx.getTransform) {
164
- const t = ctx.getTransform();
165
- base = [
166
- t.a,
167
- t.b,
168
- t.c,
169
- t.d,
170
- t.e,
171
- t.f
172
- ];
173
- dpr = t.a || 1;
174
- } else {
175
- dpr = 1;
176
- base = [
177
- 1,
178
- 0,
179
- 0,
180
- 1,
181
- 0,
182
- 0
183
- ];
184
- }
185
- const lit = LIGHT_DEFAULTS[cfg.shading === "fabric" ? "fabric" : "other"];
186
- const shadow = cfg.shadow ?? lit.shadow, highlight = cfg.highlight ?? lit.highlight;
187
- const halfDepth = HALF_DEPTH * (cfg.depth ?? 0.65);
188
- const rimK = cfg.rim ?? lit.rim;
189
- const cap = 1 - (1 - CAP) * rimK;
190
- const spread = cfg.spread ?? lit.spread;
191
- const la = (cfg.light ?? lit.light) * Math.PI / 180;
192
- const lx = Math.sin(la), ly = -Math.cos(la);
193
- const pal = palette(cfg.color, shadow, highlight);
194
- const cy0 = Math.cos(pose.yaw), sy = Math.sin(pose.yaw);
195
- const cp0 = Math.cos(pose.pitch), sp = Math.sin(pose.pitch);
196
- const facing = cy0 * cp0;
197
- const floor = (v)=>Math.abs(v) < 0.22 ? v < 0 ? -0.22 : 0.22 : v;
198
- const cy = floor(cy0), cp = floor(cp0);
199
- const cr = Math.cos(pose.roll), sr = Math.sin(pose.roll), kx = pose.sx * S, ky = pose.sy * S;
200
- const lift = 50 * (1 - pose.sy) * S;
201
- const body = mulAffine(base, [
202
- cr * kx,
203
- sr * kx,
204
- -sr * ky,
205
- cr * ky,
206
- full / 2 + pose.x * S - sr * lift,
207
- full / 2 + RISE * box + pose.y * S + cr * lift
208
- ]);
209
- ctx.save();
210
- ctx.setTransform(body[0], body[1], body[2], body[3], body[4], body[5]);
211
- ctx.lineCap = "round";
212
- ctx.lineJoin = "round";
213
- const mode = cfg.shading;
214
- const drawSolid = (path, key, halfDepth, plush = true)=>{
215
- let lit = pal.near;
216
- let capFill = pal.base;
217
- if (mode === "crisp") {
218
- if (!pal.grad || pal.grad.lx !== lx || pal.grad.ly !== ly) {
219
- const g = ctx.createLinearGradient(lx * 56, ly * 56, -lx * 56, -ly * 56);
220
- g.addColorStop(0, pal.light);
221
- g.addColorStop(0.45, pal.near);
222
- g.addColorStop(1, pal.dark);
223
- const c = ctx.createLinearGradient(lx * 46, ly * 46, -lx * 46, -ly * 46);
224
- c.addColorStop(0, pal.capTop);
225
- c.addColorStop(1, pal.capBottom);
226
- pal.grad = {
227
- lx,
228
- ly,
229
- lit: g,
230
- cap: c
231
- };
232
- }
233
- lit = pal.grad.lit;
234
- capFill = pal.grad.cap;
235
- }
236
- let plasticDone = false;
237
- if (mode === "plastic" || mode === "fabric") {
238
- plasticDone = drawPlasticCap(ctx, {
239
- ...cfg,
240
- path,
241
- typeKey: key,
242
- shading: plush ? mode : "plastic"
243
- }, {
244
- cy,
245
- sy,
246
- cp,
247
- sp,
248
- facing,
249
- roll: pose.roll,
250
- halfDepth,
251
- cap,
252
- lx,
253
- ly,
254
- ...backOf(cfg.backLight),
255
- dev: box * dpr,
256
- ctm: body,
257
- still: cfg.still,
258
- round: cfg.roundness ?? 1
259
- }, pal, null, {
260
- shadow,
261
- highlight,
262
- spread,
263
- rim: rimK,
264
- vivid: cfg.vivid ?? 0,
265
- front: Math.min(85, Math.max(0, cfg.lightFront ?? 32)) * Math.PI / 180,
266
- shine: cfg.shine ?? 0,
267
- sheen: cfg.sheen ?? 0,
268
- backSoft: cfg.backSoftness ?? 1
269
- });
270
- }
271
- const mode2 = (mode === "plastic" || mode === "fabric") && !plasticDone ? "smooth" : mode;
272
- const soft = mode2 === "smooth";
273
- const union = soft && typeof Path2D === "function" ? new Path2D() : null;
274
- const order = facing >= 0 ? 1 : -1;
275
- const [ca, cb, cc, cd, ce, cf] = body;
276
- let fill = null;
277
- let pa = 1, pb = 0, pc = 0, pd = 1, pe = 0, pf = 0;
278
- for(let j = 0; j < SLICES && !plasticDone; j++){
279
- const k = order > 0 ? j : SLICES - 1 - j;
280
- const z = -1 + 2 * k / (SLICES - 1);
281
- const s = profile(z, cap);
282
- const near = j / (SLICES - 1);
283
- const m0 = cy * s, m1 = sy * sp * s, m3 = cp * s;
284
- const e = z * sy * halfDepth - 50 * m0, fo = -z * cy * sp * halfDepth - 50 * m1 - 50 * m3;
285
- const det = pa * pd - pb * pc;
286
- const ia = pd / det, ib = -pb / det, ic = -pc / det, id = pa / det, ie = (pc * pf - pd * pe) / det, jf = (pb * pe - pa * pf) / det;
287
- ctx.transform(ia * m0 + ic * m1, ib * m0 + id * m1, ic * m3, id * m3, ia * e + ic * fo + ie, ib * e + id * fo + jf);
288
- pa = m0;
289
- pb = m1;
290
- pc = 0;
291
- pd = m3;
292
- pe = e;
293
- pf = fo;
294
- let style;
295
- if (soft) style = pal.smoothMix[j];
296
- else if (j === SLICES - 1) style = capFill;
297
- else if (near > 0.6) style = lit;
298
- else style = pal.crispMix[j];
299
- if (style !== fill) ctx.fillStyle = fill = style;
300
- ctx.fill(path);
301
- if (union) union.addPath(path, {
302
- a: m0,
303
- b: m1,
304
- c: 0,
305
- d: m3,
306
- e,
307
- f: fo
308
- });
309
- }
310
- if (!plasticDone) ctx.setTransform(ca, cb, cc, cd, ce, cf);
311
- if (union && mode2 === "smooth") {
312
- ctx.save();
313
- ctx.clip(union);
314
- const sa = Math.min(1, 0.34 * shadow);
315
- const sg = ctx.createRadialGradient(-lx * 45, -ly * 45, 4 * spread, -lx * 45, -ly * 45, 84 * spread);
316
- sg.addColorStop(0, `rgba(0,0,0,${sa})`);
317
- sg.addColorStop(0.5, `rgba(0,0,0,${sa * 0.35})`);
318
- sg.addColorStop(1, "rgba(0,0,0,0)");
319
- ctx.globalCompositeOperation = "multiply";
320
- ctx.fillStyle = sg;
321
- ctx.fillRect(-120, -120, 240, 240);
322
- ctx.globalCompositeOperation = "source-over";
323
- const ha = Math.min(1, 0.22 * highlight);
324
- const hg = ctx.createRadialGradient(lx * 37, ly * 37, 0, lx * 37, ly * 37, 62 * spread);
325
- hg.addColorStop(0, `rgba(255,255,255,${ha})`);
326
- hg.addColorStop(0.6, `rgba(255,255,255,${ha * 0.23})`);
327
- hg.addColorStop(1, "rgba(255,255,255,0)");
328
- ctx.fillStyle = hg;
329
- ctx.fillRect(-120, -120, 240, 240);
330
- ctx.restore();
331
- }
332
- return plasticDone;
333
- };
334
- drawWhirl(ctx, pose, cfg.color, lx, ly, false, cfg.whirl);
335
- const wear = cfg.wear;
336
- const worn = !!wear && (wear.hat !== "none" || wear.glasses !== "none" || wear.headphones || wear.bowTie);
337
- const wr = {
338
- cy,
339
- sy,
340
- cp,
341
- sp,
342
- facing,
343
- halfDepth,
344
- lx,
345
- ly
346
- };
347
- if (worn) drawWearBehind(ctx, cfg.path, wr, wear);
348
- const key = cfg.typeKey ?? "custom";
349
- const parts = cfg.parts ? Array.isArray(cfg.parts) ? cfg.parts : [
350
- cfg.parts
351
- ] : [];
352
- for(let i = 0; i < parts.length; i++)drawSolid(parts[i], Array.isArray(cfg.parts) ? `${key}:parts${i}/${parts.length}` : `${key}:parts`, halfDepth * (cfg.partsDepth ?? 0.4), false);
353
- const plasticDone = drawSolid(cfg.path, key, halfDepth);
354
- const zf = facing >= 0 ? 1 : -1;
355
- const round = cfg.roundness ?? 1;
356
- const cushion = plasticDone;
357
- const sf = cushion ? 1 : profile(zf, cap);
358
- const surface = (x, y)=>cushion ? surfaceAt(key, halfDepth, x, y, round) ?? 0.8 * halfDepth : halfDepth * sf;
359
- const zFace = cushion ? 0.55 * surface(cfg.faceX, cfg.faceY) : halfDepth;
360
- const fm0 = cy * sf, fm1 = sy * sp * sf, fm3 = cp * sf;
361
- const fe = zf * sy * zFace - 50 * fm0;
362
- const ffo = -zf * cy * sp * zFace - 50 * fm1 - 50 * fm3;
363
- let frontClip = null;
364
- if (worn && typeof Path2D === "function") {
365
- frontClip = new Path2D();
366
- frontClip.addPath(cfg.path, {
367
- a: fm0,
368
- b: fm1,
369
- c: 0,
370
- d: fm3,
371
- e: fe,
372
- f: ffo
373
- });
374
- }
375
- if (facing > -0.2) {
376
- ctx.save();
377
- {
378
- const [ca, cb, cc, cd, ce, cf] = body;
379
- ctx.setTransform(ca * fm0 + cc * fm1, cb * fm0 + cd * fm1, cc * fm3, cd * fm3, ca * fe + cc * ffo + ce, cb * fe + cd * ffo + cf);
380
- ctx.clip(cfg.path);
381
- ctx.setTransform(ca, cb, cc, cd, ce, cf);
382
- }
383
- ctx.translate(cfg.faceX - 50, cfg.faceY - 50);
384
- ctx.scale(cfg.faceScale, cfg.faceScale);
385
- if (mode === "plastic" && plasticDone) ctx.globalAlpha = 0.93;
386
- const eyes = drawFace(ctx, pose, cfg);
387
- ctx.globalAlpha = 1;
388
- const glasses = wear && wear.glasses !== "none" ? wear.glasses : null;
389
- const turn = {
390
- yaw: pose.yaw,
391
- pitch: pose.pitch
392
- };
393
- if (glasses) drawGlasses(ctx, glasses, eyes, {
394
- lx,
395
- ly
396
- }, "shadow", turn);
397
- ctx.restore();
398
- if (glasses) {
399
- ctx.save();
400
- ctx.translate(cfg.faceX - 50, cfg.faceY - 50);
401
- ctx.scale(cfg.faceScale, cfg.faceScale);
402
- drawGlasses(ctx, glasses, eyes, {
403
- lx,
404
- ly
405
- }, "frame", turn);
406
- ctx.restore();
407
- }
408
- }
409
- if (worn) drawWearFront(ctx, cfg.path, wr, wear, frontClip, surface, {
410
- y: cfg.faceY,
411
- scale: cfg.faceScale
412
- });
413
- drawWhirl(ctx, pose, cfg.color, lx, ly, true, cfg.whirl);
414
- ctx.restore();
186
+ const full = box * OVERSCAN;
187
+ ctx.clearRect(0, 0, full, full);
188
+ const S = box / 100;
189
+ /* the context's transform as given (the device scale) is the base of
190
+ every transform set here; known from `dpr`, else read once */
191
+ let dpr, base;
192
+ if (cfg.dpr !== undefined) {
193
+ dpr = cfg.dpr;
194
+ base = [
195
+ dpr,
196
+ 0,
197
+ 0,
198
+ dpr,
199
+ 0,
200
+ 0
201
+ ];
202
+ } else if (ctx.getTransform) {
203
+ const t = ctx.getTransform();
204
+ base = [
205
+ t.a,
206
+ t.b,
207
+ t.c,
208
+ t.d,
209
+ t.e,
210
+ t.f
211
+ ];
212
+ dpr = t.a || 1;
213
+ } else {
214
+ dpr = 1;
215
+ base = [
216
+ 1,
217
+ 0,
218
+ 0,
219
+ 1,
220
+ 0,
221
+ 0
222
+ ];
223
+ }
224
+ const lit = LIGHT_DEFAULTS[cfg.shading === "fabric" ? "fabric" : "other"];
225
+ const shadow = cfg.shadow ?? lit.shadow, highlight = cfg.highlight ?? lit.highlight;
226
+ const halfDepth = HALF_DEPTH * (cfg.depth ?? .65);
227
+ const rimK = cfg.rim ?? lit.rim;
228
+ const cap = 1 - (1 - CAP) * rimK;
229
+ const spread = cfg.spread ?? lit.spread;
230
+ /* the light's direction on screen: a unit vector toward the source */
231
+ const la = (cfg.light ?? lit.light) * Math.PI / 180;
232
+ const lx = Math.sin(la), ly = -Math.cos(la);
233
+ const pal = palette(cfg.color, shadow, highlight);
234
+ const cy0 = Math.cos(pose.yaw), sy = Math.sin(pose.yaw);
235
+ const cp0 = Math.cos(pose.pitch), sp = Math.sin(pose.pitch);
236
+ /* which cap faces the viewer: the front while this is positive */
237
+ const facing = cy0 * cp0;
238
+ /* edge-on, every slice would thin to a line and the stack would show
239
+ gaps; a floor on the foreshortening keeps it a solid */
240
+ const floor = (v) => Math.abs(v) < .22 ? v < 0 ? -.22 : .22 : v;
241
+ const cy = floor(cy0), cp = floor(cp0);
242
+ /* body space: the box centre plus the pose's offset, its roll and
243
+ squash. The squash and stretch scale about the body's base (y = 50),
244
+ so a landing keeps the feet on the ground and presses the top down,
245
+ and a stretch rises from the base. */
246
+ const cr = Math.cos(pose.roll), sr = Math.sin(pose.roll), kx = pose.sx * S, ky = pose.sy * S;
247
+ const lift = 50 * (1 - pose.sy) * S;
248
+ const body = mulAffine(base, [
249
+ cr * kx,
250
+ sr * kx,
251
+ -sr * ky,
252
+ cr * ky,
253
+ full / 2 + pose.x * S - sr * lift,
254
+ full / 2 + RISE * box + pose.y * S + cr * lift
255
+ ]);
256
+ ctx.save();
257
+ ctx.setTransform(body[0], body[1], body[2], body[3], body[4], body[5]);
258
+ ctx.lineCap = "round";
259
+ ctx.lineJoin = "round";
260
+ const mode = cfg.shading;
261
+ /* one solid: the slice stack (or the plastic material) for an outline
262
+ at a depth; the thin parts come first with a fraction of the depth,
263
+ then the body over them. A plush toy's antennae are moulded plastic:
264
+ a pile on a rod that thin is all fringe, so parts take the plastic
265
+ when the body is fabric (`plush` false) */
266
+ const drawSolid = (path, key, halfDepth, plush = true) => {
267
+ /* the lit gradient, in the body's own space: light from the upper left */
268
+ let lit = pal.near;
269
+ let capFill = pal.base;
270
+ if (mode === "crisp") {
271
+ if (!pal.grad || pal.grad.lx !== lx || pal.grad.ly !== ly) {
272
+ const g = ctx.createLinearGradient(lx * 56, ly * 56, -lx * 56, -ly * 56);
273
+ g.addColorStop(0, pal.light);
274
+ g.addColorStop(.45, pal.near);
275
+ g.addColorStop(1, pal.dark);
276
+ const c = ctx.createLinearGradient(lx * 46, ly * 46, -lx * 46, -ly * 46);
277
+ c.addColorStop(0, pal.capTop);
278
+ c.addColorStop(1, pal.capBottom);
279
+ pal.grad = {
280
+ lx,
281
+ ly,
282
+ lit: g,
283
+ cap: c
284
+ };
285
+ }
286
+ lit = pal.grad.lit;
287
+ capFill = pal.grad.cap;
288
+ }
289
+ /* plastic and fabric: the material module draws the whole body, an
290
+ inflated relief of the lit texture. While a form is still baking on
291
+ idle time it declines, and the stock slices with the smooth overlay
292
+ stand in for that frame. */
293
+ let plasticDone = false;
294
+ if (mode === "plastic" || mode === "fabric") {
295
+ plasticDone = drawPlasticCap(ctx, {
296
+ ...cfg,
297
+ path,
298
+ typeKey: key,
299
+ shading: plush ? mode : "plastic"
300
+ }, {
301
+ cy,
302
+ sy,
303
+ cp,
304
+ sp,
305
+ facing,
306
+ roll: pose.roll,
307
+ halfDepth,
308
+ cap,
309
+ lx,
310
+ ly,
311
+ ...backOf(cfg.backLight),
312
+ dev: box * dpr,
313
+ ctm: body,
314
+ still: cfg.still,
315
+ round: cfg.roundness ?? 1
316
+ }, pal, null, {
317
+ shadow,
318
+ highlight,
319
+ spread,
320
+ rim: rimK,
321
+ vivid: cfg.vivid ?? 0,
322
+ front: Math.min(85, Math.max(0, cfg.lightFront ?? 32)) * Math.PI / 180,
323
+ shine: cfg.shine ?? 0,
324
+ sheen: cfg.sheen ?? 0,
325
+ backSoft: cfg.backSoftness ?? 1
326
+ });
327
+ }
328
+ const mode2 = (mode === "plastic" || mode === "fabric") && !plasticDone ? "smooth" : mode;
329
+ const soft = mode2 === "smooth";
330
+ const union = soft && typeof Path2D === "function" ? new Path2D() : null;
331
+ /* slices, far to near; each sets its transform outright from the
332
+ body's, no save/restore */
333
+ const order = facing >= 0 ? 1 : -1;
334
+ const [ca, cb, cc, cd, ce, cf] = body;
335
+ let fill = null;
336
+ /* each slice's affine is applied relative to the previous slice's: one
337
+ transform() per slice, no save/restore */
338
+ let pa = 1, pb = 0, pc = 0, pd = 1, pe = 0, pf = 0;
339
+ for (let j = 0; j < SLICES && !plasticDone; j++) {
340
+ const k = order > 0 ? j : SLICES - 1 - j;
341
+ const z = -1 + 2 * k / (SLICES - 1);
342
+ const s = profile(z, cap);
343
+ const near = j / (SLICES - 1);
344
+ /* yaw about Y then pitch about X, orthographic: an affine per slice,
345
+ then the path's own origin at its centre */
346
+ const m0 = cy * s, m1 = sy * sp * s, m3 = cp * s;
347
+ const e = z * sy * halfDepth - 50 * m0, fo = -z * cy * sp * halfDepth - 50 * m1 - 50 * m3;
348
+ const det = pa * pd - pb * pc;
349
+ const ia = pd / det, ib = -pb / det, ic = -pc / det, id = pa / det, ie = (pc * pf - pd * pe) / det, jf = (pb * pe - pa * pf) / det;
350
+ ctx.transform(ia * m0 + ic * m1, ib * m0 + id * m1, ic * m3, id * m3, ia * e + ic * fo + ie, ib * e + id * fo + jf);
351
+ pa = m0;
352
+ pb = m1;
353
+ pc = 0;
354
+ pd = m3;
355
+ pe = e;
356
+ pf = fo;
357
+ let style;
358
+ /* smooth: one colour ramp through the depth to the front, no edge at the cap */
359
+ if (soft) style = pal.smoothMix[j];
360
+ else if (j === SLICES - 1) style = capFill;
361
+ else if (near > .6) style = lit;
362
+ else style = pal.crispMix[j];
363
+ if (style !== fill) ctx.fillStyle = fill = style;
364
+ ctx.fill(path);
365
+ if (union) union.addPath(path, {
366
+ a: m0,
367
+ b: m1,
368
+ c: 0,
369
+ d: m3,
370
+ e,
371
+ f: fo
372
+ });
373
+ }
374
+ if (!plasticDone) ctx.setTransform(ca, cb, cc, cd, ce, cf);
375
+ /* smooth: a soft shadow from the lower right and a light from the upper
376
+ left, laid over the whole form so nothing has an edge */
377
+ if (union && mode2 === "smooth") {
378
+ /* the two gradients are made per frame on purpose: a kept one is
379
+ slower to use in Safari than a fresh one */
380
+ ctx.save();
381
+ ctx.clip(union);
382
+ const sa = Math.min(1, .34 * shadow);
383
+ const sg = ctx.createRadialGradient(-lx * 45, -ly * 45, 4 * spread, -lx * 45, -ly * 45, 84 * spread);
384
+ sg.addColorStop(0, `rgba(0,0,0,${sa})`);
385
+ sg.addColorStop(.5, `rgba(0,0,0,${sa * .35})`);
386
+ sg.addColorStop(1, "rgba(0,0,0,0)");
387
+ ctx.globalCompositeOperation = "multiply";
388
+ ctx.fillStyle = sg;
389
+ ctx.fillRect(-120, -120, 240, 240);
390
+ ctx.globalCompositeOperation = "source-over";
391
+ const ha = Math.min(1, .22 * highlight);
392
+ const hg = ctx.createRadialGradient(lx * 37, ly * 37, 0, lx * 37, ly * 37, 62 * spread);
393
+ hg.addColorStop(0, `rgba(255,255,255,${ha})`);
394
+ hg.addColorStop(.6, `rgba(255,255,255,${ha * .23})`);
395
+ hg.addColorStop(1, "rgba(255,255,255,0)");
396
+ ctx.fillStyle = hg;
397
+ ctx.fillRect(-120, -120, 240, 240);
398
+ ctx.restore();
399
+ }
400
+ return plasticDone;
401
+ };
402
+ /* the far half of the whirl sits behind everything */
403
+ drawWhirl(ctx, pose, cfg.color, lx, ly, false, cfg.whirl);
404
+ /* what the bot wears, placed on its own geometry and turned with it */
405
+ const wear = cfg.wear;
406
+ const worn = !!wear && (wear.hat !== "none" || wear.glasses !== "none" || wear.headphones || wear.bowTie);
407
+ const wr = {
408
+ cy,
409
+ sy,
410
+ cp,
411
+ sp,
412
+ facing,
413
+ halfDepth,
414
+ lx,
415
+ ly
416
+ };
417
+ if (worn) drawWearBehind(ctx, cfg.path, wr, wear);
418
+ const key = cfg.typeKey ?? "custom";
419
+ /* each piece rounds on its own: two antennae inflated as one shape
420
+ would be scaled toward the middle between them, slice by slice */
421
+ const parts = cfg.parts ? Array.isArray(cfg.parts) ? cfg.parts : [cfg.parts] : [];
422
+ for (let i = 0; i < parts.length; i++) drawSolid(parts[i], Array.isArray(cfg.parts) ? `${key}:parts${i}/${parts.length}` : `${key}:parts`, halfDepth * (cfg.partsDepth ?? .4), false);
423
+ const plasticDone = drawSolid(cfg.path, key, halfDepth);
424
+ /* the face: each feature sits on a sphere behind the front cap, so a
425
+ turn slides it round the head — the eye moving toward the edge
426
+ narrows, the other comes to the front, and past the side they go */
427
+ /* the front's outline in body space: the face's clip, and where worn
428
+ things cast their shadows. The extruded modes have a front cap; the
429
+ cushion of plastic and fabric has none, so its outline is taken a
430
+ little over halfway up the face's own height */
431
+ const zf = facing >= 0 ? 1 : -1;
432
+ const round = cfg.roundness ?? 1;
433
+ const cushion = plasticDone;
434
+ const sf = cushion ? 1 : profile(zf, cap);
435
+ const surface = (x, y) => cushion ? surfaceAt(key, halfDepth, x, y, round) ?? .8 * halfDepth : halfDepth * sf;
436
+ const zFace = cushion ? .55 * surface(cfg.faceX, cfg.faceY) : halfDepth;
437
+ const fm0 = cy * sf, fm1 = sy * sp * sf, fm3 = cp * sf;
438
+ const fe = zf * sy * zFace - 50 * fm0;
439
+ const ffo = -zf * cy * sp * zFace - 50 * fm1 - 50 * fm3;
440
+ let frontClip = null;
441
+ if (worn && typeof Path2D === "function") {
442
+ frontClip = new Path2D();
443
+ frontClip.addPath(cfg.path, {
444
+ a: fm0,
445
+ b: fm1,
446
+ c: 0,
447
+ d: fm3,
448
+ e: fe,
449
+ f: ffo
450
+ });
451
+ }
452
+ if (facing > -.2) {
453
+ ctx.save();
454
+ /* The face is printed on the front of the body, so it cannot leave
455
+ it: clip to the front slice's own outline first. On a wide turn a
456
+ feature's place on the sphere can reach past the body's foreshortened
457
+ silhouette, and without this it floats off the side. */
458
+ {
459
+ const [ca, cb, cc, cd, ce, cf] = body;
460
+ ctx.setTransform(ca * fm0 + cc * fm1, cb * fm0 + cd * fm1, cc * fm3, cd * fm3, ca * fe + cc * ffo + ce, cb * fe + cd * ffo + cf);
461
+ ctx.clip(cfg.path);
462
+ ctx.setTransform(ca, cb, cc, cd, ce, cf);
463
+ }
464
+ ctx.translate(cfg.faceX - 50, cfg.faceY - 50);
465
+ ctx.scale(cfg.faceScale, cfg.faceScale);
466
+ /* under a clear coat the print shows the gloss faintly through it */
467
+ if (mode === "plastic" && plasticDone) ctx.globalAlpha = .93;
468
+ const eyes = drawFace(ctx, pose, cfg);
469
+ ctx.globalAlpha = 1;
470
+ /* glasses: their shadow falls on the face (inside its clip), the frames
471
+ themselves may reach past the body's edge */
472
+ const glasses = wear && wear.glasses !== "none" ? wear.glasses : null;
473
+ const turn = {
474
+ yaw: pose.yaw,
475
+ pitch: pose.pitch
476
+ };
477
+ if (glasses) drawGlasses(ctx, glasses, eyes, {
478
+ lx,
479
+ ly
480
+ }, "shadow", turn);
481
+ ctx.restore();
482
+ if (glasses) {
483
+ ctx.save();
484
+ ctx.translate(cfg.faceX - 50, cfg.faceY - 50);
485
+ ctx.scale(cfg.faceScale, cfg.faceScale);
486
+ drawGlasses(ctx, glasses, eyes, {
487
+ lx,
488
+ ly
489
+ }, "frame", turn);
490
+ ctx.restore();
491
+ }
492
+ }
493
+ if (worn) drawWearFront(ctx, cfg.path, wr, wear, frontClip, surface, {
494
+ y: cfg.faceY,
495
+ scale: cfg.faceScale
496
+ });
497
+ /* the near half of the whirl passes in front of the face */
498
+ drawWhirl(ctx, pose, cfg.color, lx, ly, true, cfg.whirl);
499
+ ctx.restore();
415
500
  }
501
+ /* Radius of the sphere the face is drawn on, in body units. */
416
502
  const FACE_R = 30;
503
+ /* A feature's place on the sphere: longitude and latitude from its
504
+ design position, turned by the head. Returns its screen offset, its
505
+ foreshortening, and how much it faces the viewer. */
417
506
  function onSphere(x, y, yaw, pitch) {
418
- const lon = Math.asin(Math.max(-1, Math.min(1, x / FACE_R))) + yaw;
419
- const lat = Math.asin(Math.max(-1, Math.min(1, -y / FACE_R))) + pitch;
420
- const cl = Math.cos(lat);
421
- return {
422
- x: FACE_R * Math.sin(lon) * cl,
423
- y: -FACE_R * Math.sin(lat),
424
- sx: Math.cos(lon),
425
- sy: cl,
426
- z: Math.cos(lon) * cl
427
- };
507
+ const lon = Math.asin(Math.max(-1, Math.min(1, x / FACE_R))) + yaw;
508
+ const lat = Math.asin(Math.max(-1, Math.min(1, -y / FACE_R))) + pitch;
509
+ const cl = Math.cos(lat);
510
+ return {
511
+ x: FACE_R * Math.sin(lon) * cl,
512
+ y: -FACE_R * Math.sin(lat),
513
+ sx: Math.cos(lon),
514
+ sy: cl,
515
+ z: Math.cos(lon) * cl
516
+ };
428
517
  }
518
+ /* An eye's curve as a short polyline with round joins, cached by its
519
+ numbers. Stroking the open eye's hairpin curve directly gives a pill in
520
+ Chrome but a teardrop in Safari, whose stroker does not round a cusp;
521
+ both stroke a polyline the same way. */
429
522
  const EYE_STEPS = 8;
430
523
  const eyePaths = new Map();
431
524
  function eyePath(x0, y0, cy) {
432
- const qx = Math.round(x0 * 50), qy = Math.round(y0 * 50), qc = Math.round(cy * 50);
433
- const key = qx + 2000 * qy + 4e6 * qc;
434
- let p = eyePaths.get(key);
435
- if (!p) {
436
- const ax = qx / 50, ay = qy / 50, ac = qc / 50;
437
- let d = `M${-ax} ${ay}`;
438
- for(let i = 1; i <= EYE_STEPS; i++){
439
- const t = i / EYE_STEPS, mt = 1 - t;
440
- d += ` L${(mt * mt * -ax + t * t * ax).toFixed(3)} ${((mt * mt + t * t) * ay + 2 * mt * t * ac).toFixed(3)}`;
441
- }
442
- p = new Path2D(d);
443
- if (eyePaths.size > 256) eyePaths.clear();
444
- eyePaths.set(key, p);
445
- }
446
- return p;
525
+ const qx = Math.round(x0 * 50), qy = Math.round(y0 * 50), qc = Math.round(cy * 50);
526
+ const key = qx + 2e3 * qy + 4e6 * qc;
527
+ let p = eyePaths.get(key);
528
+ if (!p) {
529
+ const ax = qx / 50, ay = qy / 50, ac = qc / 50;
530
+ let d = `M${-ax} ${ay}`;
531
+ for (let i = 1; i <= EYE_STEPS; i++) {
532
+ const t = i / EYE_STEPS, mt = 1 - t;
533
+ d += ` L${(mt * mt * -ax + t * t * ax).toFixed(3)} ${((mt * mt + t * t) * ay + 2 * mt * t * ac).toFixed(3)}`;
534
+ }
535
+ p = new Path2D(d);
536
+ if (eyePaths.size > 256) eyePaths.clear();
537
+ eyePaths.set(key, p);
538
+ }
539
+ return p;
447
540
  }
448
- const MOUTH_SIN = Math.sin(0.684), MOUTH_COS = Math.cos(0.684);
541
+ const MOUTH_SIN = Math.sin(.684), MOUTH_COS = Math.cos(.684);
449
542
  const mouthPaths = new Map();
450
543
  function mouthPath(m) {
451
- const q = (v)=>Math.round(v * 50) / 50;
452
- const hw = q(m.hw), t0 = q(m.t0), a = q(m.a), yt = q(m.yt), ab = q(m.ab), yb = q(m.yb);
453
- const key = `${hw},${t0},${a},${yt},${ab},${yb}`;
454
- let p = mouthPaths.get(key);
455
- if (p) return p;
456
- const f = (v)=>v.toFixed(3);
457
- const nx = t0 * MOUTH_SIN, ny = t0 * MOUTH_COS;
458
- const ltx = -hw + nx, lty = -ny, rtx = hw - nx, rty = -ny;
459
- const lbx = -hw - nx, lby = ny, rbx = hw + nx, rby = ny;
460
- const cx = 4 / 3 * t0 * MOUTH_COS, cy = 4 / 3 * t0 * MOUTH_SIN;
461
- const d = `M${f(ltx)} ${f(lty)}` + `C${f(-hw + a * hw)} ${f(yt - t0)} ${f(hw - a * hw)} ${f(yt - t0)} ${f(rtx)} ${f(rty)}` + `C${f(rtx + cx)} ${f(rty - cy)} ${f(rbx + cx)} ${f(rby - cy)} ${f(rbx)} ${f(rby)}` + `C${f(hw - ab * hw)} ${f(yb + t0)} ${f(-hw + ab * hw)} ${f(yb + t0)} ${f(lbx)} ${f(lby)}` + `C${f(lbx - cx)} ${f(lby - cy)} ${f(ltx - cx)} ${f(lty - cy)} ${f(ltx)} ${f(lty)}Z`;
462
- p = new Path2D(d);
463
- if (mouthPaths.size > 256) mouthPaths.clear();
464
- mouthPaths.set(key, p);
465
- return p;
544
+ const q = (v) => Math.round(v * 50) / 50;
545
+ const hw = q(m.hw), t0 = q(m.t0), a = q(m.a), yt = q(m.yt), ab = q(m.ab), yb = q(m.yb);
546
+ const key = `${hw},${t0},${a},${yt},${ab},${yb}`;
547
+ let p = mouthPaths.get(key);
548
+ if (p) return p;
549
+ const f = (v) => v.toFixed(3);
550
+ /* the corners, offset along the end normal for the caps */
551
+ const nx = t0 * MOUTH_SIN, ny = t0 * MOUTH_COS;
552
+ const ltx = -hw + nx, lty = -ny, rtx = hw - nx, rty = -ny;
553
+ const lbx = -hw - nx, lby = ny, rbx = hw + nx, rby = ny;
554
+ /* the caps bulge outward along the end tangents */
555
+ const cx = 4 / 3 * t0 * MOUTH_COS, cy = 4 / 3 * t0 * MOUTH_SIN;
556
+ const d = `M${f(ltx)} ${f(lty)}` + `C${f(-hw + a * hw)} ${f(yt - t0)} ${f(hw - a * hw)} ${f(yt - t0)} ${f(rtx)} ${f(rty)}` + `C${f(rtx + cx)} ${f(rty - cy)} ${f(rbx + cx)} ${f(rby - cy)} ${f(rbx)} ${f(rby)}` + `C${f(hw - ab * hw)} ${f(yb + t0)} ${f(-hw + ab * hw)} ${f(yb + t0)} ${f(lbx)} ${f(lby)}` + `C${f(lbx - cx)} ${f(lby - cy)} ${f(ltx - cx)} ${f(lty - cy)} ${f(ltx)} ${f(lty)}Z`;
557
+ p = new Path2D(d);
558
+ if (mouthPaths.size > 256) mouthPaths.clear();
559
+ mouthPaths.set(key, p);
560
+ return p;
466
561
  }
467
562
  function drawFace(ctx, pose, cfg) {
468
- const [wd, ww, ws] = pose.w;
469
- const ink = cfg.ink;
470
- const ey = EYE_Y[cfg.face];
471
- const half = EYE_GAP / 2;
472
- const lx = pose.lookX, ly = pose.lookY;
473
- const { yaw, pitch } = pose;
474
- const at = (x, y, fn, alpha = 1)=>{
475
- const q = onSphere(x, y, yaw, pitch);
476
- if (q.z <= 0.02 || alpha <= 0.01) return;
477
- ctx.save();
478
- ctx.globalAlpha = alpha * Math.min(1, q.z * 5);
479
- ctx.translate(q.x, q.y);
480
- ctx.scale(Math.max(0.02, q.sx), Math.max(0.02, q.sy));
481
- fn();
482
- ctx.restore();
483
- };
484
- const past = (v, d)=>Math.abs(v) <= d ? 0 : Math.sign(v) * (Math.abs(v) - d) / (1 - d);
485
- const clamp1 = (v)=>Math.max(-1, Math.min(1, v));
486
- const up = past(clamp1(-pose.pitch / 0.26 - pose.lookY / 7), 0.34);
487
- const side = Math.abs(past(clamp1(pose.lookX / 4.5), 0.4));
488
- const tall = Math.max(0.3, 1 + 0.55 * up - 0.1 * side);
489
- const wide = 1 - 0.05 * up + 0.12 * side;
490
- const open = wd + ww * (1 - pose.laugh);
491
- const laugh = ww * pose.laugh;
492
- const lift = Math.max(0, -pose.y) / 26;
493
- const sag = 0.5 + 0.5 * pose.breath;
494
- for (const side of [
495
- -1,
496
- 1
497
- ]){
498
- const lid = side < 0 ? pose.blinkL : pose.blinkR;
499
- const e = Math.max(0, Math.min(1, pose.eyeOpen * (1 - lid)));
500
- const kOpen = open * e, kShut = open * (1 - e), kLaugh = laugh, kSleep = ws;
501
- const x0 = kOpen * 0.01 + kShut * 5.4 + kLaugh * 6.2 + kSleep * 6;
502
- const y0 = kOpen * 2 * tall + kShut * 0.6 + kLaugh * (2.2 - lift * 1.5) + kSleep * (-1.4 + sag);
503
- const cy = kOpen * -6 * tall + kShut * 0.6 + kLaugh * (-11.4 - 4 * lift) + kSleep * (5.4 + 2 * sag);
504
- const w = kOpen * EYE_RX * 2 * wide + kShut * 2.8 + kLaugh * 4.4 + kSleep * 4;
505
- const dx = lx * (kOpen + 0.5 * (kShut + kLaugh)), dy = ly * (kOpen + 0.5 * kShut);
506
- at(side * half + dx, ey + dy, ()=>{
507
- ctx.strokeStyle = ink;
508
- ctx.lineWidth = w;
509
- ctx.stroke(eyePath(x0, y0, cy));
510
- });
511
- }
512
- if (cfg.face === "mouth") {
513
- const mx = lx * 0.35;
514
- const kd = (0.6 + 0.4 * wd) * (1 + 0.06 * pose.breath);
515
- const kw = (0.6 + 0.4 * ww) * (1 + 0.25 * Math.max(0, -pose.y) / 26);
516
- const r = 2.7 * ws * (1 + 0.25 * pose.breath);
517
- const b = (d, w, s)=>wd * d + ww * w + ws * s;
518
- const m = {
519
- hw: b(6.5 * kd, 9.5 * kw, r),
520
- t0: b(1.9, 0, 0),
521
- a: b(2 / 3, 2 / 3, 0),
522
- yt: b(3.53 * kd, 1.6 * kw, -4 * r / 3),
523
- ab: b(2 / 3, 0, 0),
524
- yb: b(3.53 * kd, 17.3 * kw, 4 * r / 3)
525
- };
526
- at(mx, b(12.5, 11.6, 15.5), ()=>{
527
- ctx.fillStyle = ink;
528
- ctx.fill(mouthPath(m));
529
- });
530
- }
531
- const spot = (x)=>{
532
- const q = onSphere(x, ey, yaw, pitch);
533
- return {
534
- x: q.x,
535
- y: q.y,
536
- sx: q.sx,
537
- sy: q.sy,
538
- z: q.z,
539
- ox: x,
540
- oy: ey,
541
- fz: Math.sqrt(Math.max(0, FACE_R * FACE_R - x * x - ey * ey))
542
- };
543
- };
544
- return [
545
- spot(-half),
546
- spot(half)
547
- ];
563
+ const [wd, ww, ws] = pose.w;
564
+ const ink = cfg.ink;
565
+ const ey = EYE_Y[cfg.face];
566
+ const half = EYE_GAP / 2;
567
+ const lx = pose.lookX, ly = pose.lookY;
568
+ const { yaw, pitch } = pose;
569
+ /* place a feature: skip it once it has gone round the side */
570
+ const at = (x, y, fn, alpha = 1) => {
571
+ const q = onSphere(x, y, yaw, pitch);
572
+ if (q.z <= .02 || alpha <= .01) return;
573
+ ctx.save();
574
+ ctx.globalAlpha = alpha * Math.min(1, q.z * 5);
575
+ ctx.translate(q.x, q.y);
576
+ ctx.scale(Math.max(.02, q.sx), Math.max(.02, q.sy));
577
+ fn();
578
+ ctx.restore();
579
+ };
580
+ /* Each eye is one stroked curve — endpoints at ±x0,y0, a control point
581
+ at 0,cy, width w, round caps — so every look is the same shape with
582
+ different numbers, and a blend of the numbers is a real morph: the
583
+ upright pill of an open eye squashes into a shut line, swings up into
584
+ a laughing arc, or droops into a sleeping lid. */
585
+ /* the eyes take the head's aim, but only once it really aims
586
+ somewhere: at rest and through the small drift of a breath they keep
587
+ their own shape, and past that they draw taller looking up, shorter
588
+ looking down, and a little wider from the corner of a sideways
589
+ glance — the mimic that makes a turn read as a look */
590
+ const past = (v, d) => Math.abs(v) <= d ? 0 : Math.sign(v) * (Math.abs(v) - d) / (1 - d);
591
+ const clamp1 = (v) => Math.max(-1, Math.min(1, v));
592
+ const up = past(clamp1(-pose.pitch / .26 - pose.lookY / 7), .34);
593
+ const side = Math.abs(past(clamp1(pose.lookX / 4.5), .4));
594
+ const tall = Math.max(.3, 1 + .55 * up - .1 * side);
595
+ const wide = 1 - .05 * up + .12 * side;
596
+ const open = wd + ww * (1 - pose.laugh);
597
+ const laugh = ww * pose.laugh;
598
+ const lift = Math.max(0, -pose.y) / 26;
599
+ const sag = .5 + .5 * pose.breath;
600
+ for (const side of [-1, 1]) {
601
+ const lid = side < 0 ? pose.blinkL : pose.blinkR;
602
+ const e = Math.max(0, Math.min(1, pose.eyeOpen * (1 - lid)));
603
+ const kOpen = open * e, kShut = open * (1 - e), kLaugh = laugh, kSleep = ws;
604
+ const x0 = kOpen * .01 + kShut * 5.4 + kLaugh * 6.2 + kSleep * 6;
605
+ const y0 = kOpen * 2 * tall + kShut * .6 + kLaugh * (2.2 - lift * 1.5) + kSleep * (-1.4 + sag);
606
+ const cy = kOpen * -6 * tall + kShut * .6 + kLaugh * (-11.4 - 4 * lift) + kSleep * (5.4 + 2 * sag);
607
+ const w = kOpen * EYE_RX * 2 * wide + kShut * 2.8 + kLaugh * 4.4 + kSleep * 4;
608
+ /* the eyes drift toward the look when open, less so when shut */
609
+ const dx = lx * (kOpen + .5 * (kShut + kLaugh)), dy = ly * (kOpen + .5 * kShut);
610
+ at(side * half + dx, ey + dy, () => {
611
+ ctx.strokeStyle = ink;
612
+ ctx.lineWidth = w;
613
+ ctx.stroke(eyePath(x0, y0, cy));
614
+ });
615
+ }
616
+ if (cfg.face === "mouth") {
617
+ const mx = lx * .35;
618
+ /* One mouth for every state, so a switch morphs it rather than fading
619
+ one shape into another: two cubic edges between the corners, round
620
+ caps of radius t0 at the corners (the smile is a thick line; the
621
+ open mouth and the sleeping "o" have sharp corners), every number a
622
+ blend of the three states'. The smile widens on the in-breath, the
623
+ open mouth at the top of a hop, the "o" swells with each breath. */
624
+ const kd = (.6 + .4 * wd) * (1 + .06 * pose.breath);
625
+ const kw = (.6 + .4 * ww) * (1 + .25 * Math.max(0, -pose.y) / 26);
626
+ const r = 2.7 * ws * (1 + .25 * pose.breath);
627
+ const b = (d, w, s) => wd * d + ww * w + ws * s;
628
+ const m = {
629
+ hw: b(6.5 * kd, 9.5 * kw, r),
630
+ t0: b(1.9, 0, 0),
631
+ a: b(2 / 3, 2 / 3, 0),
632
+ yt: b(3.53 * kd, 1.6 * kw, -4 * r / 3),
633
+ ab: b(2 / 3, 0, 0),
634
+ yb: b(3.53 * kd, 17.3 * kw, 4 * r / 3)
635
+ };
636
+ at(mx, b(12.5, 11.6, 15.5), () => {
637
+ ctx.fillStyle = ink;
638
+ ctx.fill(mouthPath(m));
639
+ });
640
+ }
641
+ /* where the eyes sit on the face, for anything worn over them: the
642
+ design spots on the sphere, without the look's drift */
643
+ const spot = (x) => {
644
+ const q = onSphere(x, ey, yaw, pitch);
645
+ return {
646
+ x: q.x,
647
+ y: q.y,
648
+ sx: q.sx,
649
+ sy: q.sy,
650
+ z: q.z,
651
+ ox: x,
652
+ oy: ey,
653
+ fz: Math.sqrt(Math.max(0, FACE_R * FACE_R - x * x - ey * ey))
654
+ };
655
+ };
656
+ return [spot(-half), spot(half)];
548
657
  }