@heyramzi/ui 6.0.1 → 7.0.0

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 (114) hide show
  1. package/COMPONENTS.md +7 -26
  2. package/README.md +12 -5
  3. package/package.json +14 -16
  4. package/scripts/sync-shadcn.mjs +12 -3
  5. package/src/agents/agent-avatar.svelte +639 -0
  6. package/src/agents/agent-cast.svelte +173 -0
  7. package/src/agents/demo.svelte +78 -0
  8. package/src/agents/index.ts +11 -0
  9. package/src/agents/types.ts +72 -0
  10. package/{LICENSE → src/agents/vendor/LICENSE} +1 -1
  11. package/src/agents/vendor/color.ts +155 -0
  12. package/src/agents/vendor/draw.ts +840 -0
  13. package/src/agents/vendor/engine.ts +845 -0
  14. package/src/agents/vendor/mesh.ts +838 -0
  15. package/src/agents/vendor/plastic.ts +4958 -0
  16. package/src/agents/vendor/presets.ts +103 -0
  17. package/src/agents/vendor/shapes.ts +41 -0
  18. package/src/agents/vendor/ticker.ts +67 -0
  19. package/src/agents/vendor/types.ts +261 -0
  20. package/src/agents/vendor/wear.ts +1699 -0
  21. package/src/ai/index.ts +0 -18
  22. package/src/animations/README.md +3 -3
  23. package/src/lib/sliding-pill.ts +68 -0
  24. package/src/lib/theme-transition.ts +4 -2
  25. package/src/shadcn/code-block/code-block-actions.svelte +23 -0
  26. package/src/shadcn/code-block/code-block-content.svelte +55 -0
  27. package/src/shadcn/code-block/code-block-copy-button.svelte +59 -0
  28. package/src/shadcn/code-block/code-block-footer.svelte +20 -0
  29. package/src/shadcn/code-block/code-block-header.svelte +23 -0
  30. package/src/shadcn/code-block/code-block-line.svelte +71 -0
  31. package/src/shadcn/code-block/code-block-title.svelte +23 -0
  32. package/src/shadcn/code-block/code-block.svelte +47 -0
  33. package/src/shadcn/code-block/index.ts +42 -0
  34. package/src/shadcn/hooks/highlighter.ts +132 -0
  35. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +98 -0
  36. package/src/shadcn/kbd/index.ts +10 -0
  37. package/src/shadcn/kbd/kbd-group.svelte +20 -0
  38. package/src/shadcn/kbd/kbd.svelte +23 -0
  39. package/src/shadcn/message-scroller/index.ts +28 -0
  40. package/src/shadcn/message-scroller/message-scroller-button.svelte +52 -0
  41. package/src/shadcn/message-scroller/message-scroller-content.svelte +17 -0
  42. package/src/shadcn/message-scroller/message-scroller-item.svelte +22 -0
  43. package/src/shadcn/message-scroller/message-scroller-provider.svelte +7 -0
  44. package/src/shadcn/message-scroller/message-scroller-viewport.svelte +20 -0
  45. package/src/shadcn/message-scroller/message-scroller.svelte +20 -0
  46. package/src/shadcn/tabs/tabs-list.svelte +13 -2
  47. package/src/shadcn/tabs/tabs-trigger.svelte +1 -1
  48. package/src/shadcn/trace/index.ts +29 -0
  49. package/src/shadcn/trace/trace-collapsible-content.svelte +29 -0
  50. package/src/shadcn/trace/trace-collapsible-trigger.svelte +29 -0
  51. package/src/shadcn/trace/trace-collapsible.svelte +20 -0
  52. package/src/shadcn/trace/trace-description.svelte +20 -0
  53. package/src/shadcn/trace/trace-icon.svelte +24 -0
  54. package/src/shadcn/trace/trace-line.svelte +31 -0
  55. package/src/shadcn/trace/trace-title.svelte +20 -0
  56. package/src/shadcn/trace/trace.svelte +23 -0
  57. package/src/showcase/AiSection.svelte +0 -7
  58. package/src/showcase/demos/shadcn/code-block.svelte +80 -0
  59. package/src/showcase/demos/shadcn/index.ts +24 -0
  60. package/src/showcase/demos/shadcn/message-scroller.svelte +44 -0
  61. package/src/showcase/demos/shadcn/trace.svelte +51 -0
  62. package/src/showcase/kit.ts +1 -2
  63. package/src/ai/conversation/Conversation.svelte +0 -28
  64. package/src/ai/conversation/ConversationContent.svelte +0 -35
  65. package/src/ai/conversation/ConversationEmptyState.svelte +0 -46
  66. package/src/ai/conversation/ConversationScrollButton.svelte +0 -51
  67. package/src/ai/conversation/index.ts +0 -33
  68. package/src/ai/conversation/stick-to-bottom-context.svelte.ts +0 -131
  69. package/src/charts/animated-value.css +0 -7
  70. package/src/charts/animated-value.svelte +0 -110
  71. package/src/charts/area-chart.css +0 -29
  72. package/src/charts/area-chart.svelte +0 -195
  73. package/src/charts/bar-chart.css +0 -119
  74. package/src/charts/bar-chart.svelte +0 -269
  75. package/src/charts/charts.css +0 -201
  76. package/src/charts/crosshair.svelte +0 -35
  77. package/src/charts/donut-chart.css +0 -56
  78. package/src/charts/donut-chart.svelte +0 -158
  79. package/src/charts/edge-fade.svelte +0 -43
  80. package/src/charts/edge-fade.ts +0 -16
  81. package/src/charts/figure.css +0 -100
  82. package/src/charts/figure.svelte +0 -122
  83. package/src/charts/gauge-chart.css +0 -94
  84. package/src/charts/gauge-chart.svelte +0 -303
  85. package/src/charts/hover-band.test.ts +0 -30
  86. package/src/charts/hover-band.ts +0 -21
  87. package/src/charts/hover-focus.svelte +0 -85
  88. package/src/charts/index.ts +0 -30
  89. package/src/charts/legend.css +0 -131
  90. package/src/charts/legend.svelte +0 -112
  91. package/src/charts/line-chart.css +0 -26
  92. package/src/charts/line-chart.svelte +0 -256
  93. package/src/charts/motion.ts +0 -19
  94. package/src/charts/number-chart.css +0 -168
  95. package/src/charts/number-chart.svelte +0 -177
  96. package/src/charts/primitives/chart-container.svelte +0 -57
  97. package/src/charts/primitives/chart-style.svelte +0 -36
  98. package/src/charts/primitives/chart-tooltip.svelte +0 -161
  99. package/src/charts/primitives/chart-utils.ts +0 -54
  100. package/src/charts/primitives/index.ts +0 -11
  101. package/src/charts/reveal.svelte +0 -61
  102. package/src/charts/ring-chart.css +0 -96
  103. package/src/charts/ring-chart.svelte +0 -158
  104. package/src/charts/sparkline.css +0 -45
  105. package/src/charts/sparkline.svelte +0 -140
  106. package/src/charts/stat-card.css +0 -103
  107. package/src/charts/stat-card.svelte +0 -267
  108. package/src/charts/time-series-chart.css +0 -28
  109. package/src/charts/time-series-chart.svelte +0 -179
  110. package/src/charts/trend-pill.css +0 -38
  111. package/src/charts/trend-pill.svelte +0 -48
  112. package/src/lib/hooks/is-mobile.svelte.ts +0 -11
  113. package/src/showcase/ChartsSection.svelte +0 -458
  114. package/src/showcase/demos/ai/conversation.svelte +0 -59
@@ -0,0 +1,840 @@
1
+ // @ts-nocheck WHY: vendored engine, kept as upstream wrote it so a re-sync stays a clean diff. Strict flags in consumers (review) would flag ~870 spots.
2
+ /* The renderer. The body is a stack of copies of its outline, spaced along
3
+ a depth axis with a pillow profile (smaller at the caps); each copy is
4
+ projected with the head's yaw and pitch, so the stack reads as a rounded
5
+ extruded solid that turns, flips and shows its side. Nearest copies are
6
+ lit with a directional gradient, the far side sits in shade. The face
7
+ lives on the front cap and follows it. */
8
+
9
+ import type { Pose } from "./engine";
10
+ import type { BotAvatarFace, BotAvatarShading } from "./types";
11
+ import { shade } from "./color";
12
+ import { drawPlasticCap, mulAffine, surfaceAt, type FurStyle } from "./plastic";
13
+ import {
14
+ drawGlasses,
15
+ drawWearBehind,
16
+ drawWearFront,
17
+ type EyeSpot,
18
+ type Wear,
19
+ type WearRig,
20
+ } from "./wear";
21
+
22
+ export interface DrawConfig {
23
+ path: Path2D;
24
+ face: BotAvatarFace;
25
+ faceX: number;
26
+ faceY: number;
27
+ faceScale: number;
28
+ color: string;
29
+ ink: string;
30
+ shading: BotAvatarShading;
31
+ /** intensities and geometry of the lighting; omitted means the stock look */
32
+ shadow?: number;
33
+ highlight?: number;
34
+ depth?: number;
35
+ /** degrees clockwise from the top, where the light comes from */
36
+ light?: number;
37
+ rim?: number;
38
+ spread?: number;
39
+ /** plastic and fabric: the profile through the depth — 1 (the default) a
40
+ cushion, thickest in the middle and rounding off to nothing at the
41
+ outline all round; toward 0 a slab with softened edges */
42
+ roundness?: number;
43
+ /** fabric: the pile's style — length, density, edge fuzz, tufts, curl */
44
+ fur?: FurStyle;
45
+ /** plastic and fabric: 0–1, how much colour the light keeps past the
46
+ palette's full saturation — highlights, sheen and rim stay coloured
47
+ rather than paling toward white */
48
+ vivid?: number;
49
+ /** fabric: where the back light comes from, degrees clockwise from the
50
+ top; by default above the toy, toward the side away from the key */
51
+ backLight?: number;
52
+ /** fabric: how far round to the front the key sits, degrees — 0 grazes
53
+ the toy from the side, 90 lights it from the camera (default 32) */
54
+ lightFront?: number;
55
+ /** fabric: the strength of the highlights along single fibres (default 0) */
56
+ shine?: number;
57
+ /** fabric: 0–1, how soft the back light is — 0 a small source caught on
58
+ single fibres, 1 a large one, an even glow (default 1) */
59
+ backSoftness?: number;
60
+ /** fabric: the smooth, satin sheen over the body's edge — the key's
61
+ sheen and the back light's band as even gradients (default 0) */
62
+ sheen?: number;
63
+ /** identifies the outline for the material caches (the type name) */
64
+ typeKey?: string;
65
+ /** no animation loop follows this draw (reduced motion, paused): build
66
+ * materials now instead of on idle time */
67
+ still?: boolean;
68
+ /** with `still`, bake fabric's pile on idle time all the same; the caller
69
+ * draws again when it lands (`takeMissed` and `onLanded` in plastic.ts),
70
+ * as the component does */
71
+ furOnIdle?: boolean;
72
+ /** thin parts (antennae) drawn behind the body with `partsDepth` of its
73
+ depth; a list draws each piece as its own solid */
74
+ parts?: Path2D | Path2D[];
75
+ partsDepth?: number;
76
+ /** the resolved surface: the whirl is white on dark, black on light */
77
+ theme?: "dark" | "light";
78
+ /** the device pixel ratio the context is scaled by: with it given the
79
+ context's transform is taken as that scale and never read back */
80
+ dpr?: number;
81
+ /** plastic's side slices: filled as vectors, or blitted from sprites of
82
+ the outline. `auto` (the default) blits on WebKit, where a
83
+ conic-gradient fill costs thirty times a flat one. */
84
+ sides?: "auto" | "vector" | "sprite";
85
+ /** the whirl's knobs; 1 everywhere is the stock look */
86
+ whirl?: { strength: number; size: number; width: number; length: number; tilt: number };
87
+ /** what the bot wears: a hat, glasses, headphones, a bow tie */
88
+ wear?: Wear;
89
+ }
90
+
91
+ /* The canvas is drawn larger than the avatar's layout box, so a hop or a
92
+ flip can leave the box without being clipped. */
93
+ export const OVERSCAN = 1.5;
94
+ /** the body's centre sits this fraction of the box below the canvas
95
+ centre: hops and flips need the room above, not below */
96
+ export const RISE = 0.1;
97
+
98
+ /* Copies through the depth, and the stock half-depth in body units. */
99
+ const SLICES = 17;
100
+ const HALF_DEPTH = 15;
101
+ /* Cap scale at the ends of the pillow, at the stock rim width. */
102
+ const CAP = 0.9;
103
+ const profile = (z: number, cap: number) => cap + (1 - cap) * Math.sqrt(Math.max(0, 1 - z * z));
104
+
105
+ /* Big, plain dark eyes: they carry the face at 24px. */
106
+ const EYE_GAP = 25;
107
+ const EYE_RX = 6.3;
108
+ const EYE_Y = { eyes: 1, mouth: -3.5 } as const;
109
+
110
+ interface Palette {
111
+ base: string;
112
+ far: string;
113
+ near: string;
114
+ light: string;
115
+ dark: string;
116
+ capTop: string;
117
+ capBottom: string;
118
+ /** the slice colours by draw order (far → near), crisp and smooth */
119
+ crispMix: string[];
120
+ smoothMix: string[];
121
+ /** crisp: the lit side and cap gradients, for a light direction */
122
+ grad: { lx: number; ly: number; lit: CanvasGradient; cap: CanvasGradient } | null;
123
+ }
124
+ const paletteCache = new Map<string, Palette>();
125
+ function palette(color: string, shadow: number, highlight: number): Palette {
126
+ const key = `${color}|${shadow}|${highlight}`;
127
+ let p = paletteCache.get(key);
128
+ if (!p) {
129
+ const far = shade(color, -0.3 * shadow, 0.05 * shadow);
130
+ const near = shade(color, -0.12 * shadow, 0.03 * shadow);
131
+ const crispMix: string[] = [],
132
+ smoothMix: string[] = [];
133
+ for (let j = 0; j < SLICES; j++) {
134
+ const t = j / (SLICES - 1);
135
+ crispMix.push(t > 0.6 ? "" : mixCss(far, near, t / 0.6));
136
+ smoothMix.push(t >= 0.5 ? color : mixCss(far, color, t / 0.5));
137
+ }
138
+ p = {
139
+ base: color,
140
+ far,
141
+ near,
142
+ light: shade(color, 0.04 * highlight),
143
+ dark: shade(color, -0.3 * shadow, 0.05 * shadow),
144
+ capTop: shade(color, 0.035 * highlight),
145
+ capBottom: shade(color, -0.035 * shadow),
146
+ crispMix,
147
+ smoothMix,
148
+ grad: null,
149
+ };
150
+ if (paletteCache.size > 200) paletteCache.clear();
151
+ paletteCache.set(key, p);
152
+ }
153
+ return p;
154
+ }
155
+
156
+ /* the numbers of an hsl() string; any other colour is normalised through shade() first */
157
+ const hslNums = (c: string) =>
158
+ (c.startsWith("hsl(") ? c : shade(c, 0)).match(/[\d.]+/g)!.map(Number);
159
+ function mixCss(a: string, b: string, t: number): string {
160
+ /* interpolate the hsl numbers */
161
+ const pa = hslNums(a);
162
+ const pb = hslNums(b);
163
+ const m = pa.map((v, i) => v + (pb[i] - v) * t);
164
+ return `hsl(${m[0].toFixed(1)} ${m[1].toFixed(1)}% ${m[2].toFixed(1)}%)`;
165
+ }
166
+
167
+ /* The whirl: the cartoon motion round a spinning body — one tapered
168
+ trail on a tilted ring, made of the body's own material: a translucent
169
+ plastic tube in the body colour, lit from the same light — a lighter
170
+ flank toward it, a darker underside, a white specular ridge along the
171
+ top — with a soft halo so it reads as a puff of plastic cloud. The
172
+ ring lies in the body's equatorial plane seen a little from above,
173
+ with a touch of perspective: the near half (sin > 0) is larger,
174
+ thicker and stronger and is drawn over the body and face, casting a
175
+ soft shadow on them; the far half is smaller and fainter and goes
176
+ behind. */
177
+ const WHIRL_SEGMENTS = 34;
178
+ const WHIRL_SPAN = Math.PI * 1.55;
179
+ const WHIRL_RX = 57;
180
+ const WHIRL_RATIO = 0.4;
181
+ const WHIRL_TILT = -0.28;
182
+ interface WhirlInk {
183
+ base: string;
184
+ light: string;
185
+ dark: string;
186
+ halo: string;
187
+ }
188
+ const whirlInkCache = new Map<string, WhirlInk>();
189
+ function whirlInk(color: string): WhirlInk {
190
+ let w = whirlInkCache.get(color);
191
+ if (!w) {
192
+ w = {
193
+ base: shade(color, 0.1, 0.02),
194
+ light: shade(color, 0.3, 0.04),
195
+ dark: shade(color, -0.22, 0.08),
196
+ halo: shade(color, 0.2),
197
+ };
198
+ if (whirlInkCache.size > 200) whirlInkCache.clear();
199
+ whirlInkCache.set(color, w);
200
+ }
201
+ return w;
202
+ }
203
+ /* an hsl() from shade() with an alpha */
204
+ const withAlpha = (hsl: string, a: number) =>
205
+ hsl.replace(")", ` / ${Math.max(0, Math.min(1, a)).toFixed(3)})`);
206
+
207
+ function drawWhirl(
208
+ ctx: CanvasRenderingContext2D,
209
+ pose: Pose,
210
+ color: string,
211
+ lx: number,
212
+ ly: number,
213
+ near: boolean,
214
+ knobs?: DrawConfig["whirl"],
215
+ ) {
216
+ const strength = knobs?.strength ?? 0;
217
+ const k = Math.min(1, pose.whirl * strength);
218
+ if (k <= 0.01) return;
219
+ const sizeK = knobs?.size ?? 1,
220
+ widthK = knobs?.width ?? 1,
221
+ lengthK = knobs?.length ?? 1,
222
+ tiltK = knobs?.tilt ?? 1;
223
+ const span = WHIRL_SPAN * lengthK;
224
+ const ink = whirlInk(color);
225
+ /* the ring runs the way the body's near face moves: to the right */
226
+ const head = -pose.whirlAngle;
227
+ const rx = WHIRL_RX * sizeK;
228
+ const ry = rx * WHIRL_RATIO * tiltK * (near ? 1.14 : 0.86);
229
+ /* where round the ring the light falls, in the ring's own frame */
230
+ const lightA = Math.atan2(ly, lx) - WHIRL_TILT;
231
+ ctx.save();
232
+ ctx.rotate(WHIRL_TILT);
233
+ ctx.translate(0, 5);
234
+ ctx.lineCap = "butt";
235
+ const seg = (a0: number, a1: number, width: number, style: string, dy: number) => {
236
+ ctx.strokeStyle = style;
237
+ ctx.lineWidth = width;
238
+ ctx.beginPath();
239
+ ctx.ellipse(0, dy, rx, ry, 0, a0, a1, false);
240
+ ctx.stroke();
241
+ };
242
+ /* the near half casts a soft shadow on the body it crosses */
243
+ if (near) {
244
+ for (let i = 0; i < WHIRL_SEGMENTS; i++) {
245
+ const f = i / WHIRL_SEGMENTS;
246
+ const a1 = head + f * span,
247
+ a0 = a1 + span / WHIRL_SEGMENTS + 0.012;
248
+ if (Math.sin((a0 + a1) / 2) <= 0) continue;
249
+ const fade = Math.pow(1 - f, 1.3);
250
+ seg(a1, a0, (2 + 8 * fade) * 1.5 * widthK, `rgba(0,0,0,${(0.2 * k * fade).toFixed(3)})`, 3.5);
251
+ }
252
+ }
253
+ for (let i = 0; i < WHIRL_SEGMENTS; i++) {
254
+ const f = i / WHIRL_SEGMENTS;
255
+ /* the trail lies at the angles the head has already passed */
256
+ const a1 = head + f * span,
257
+ a0 = a1 + span / WHIRL_SEGMENTS + 0.012;
258
+ const mid = (a0 + a1) / 2;
259
+ if (Math.sin(mid) > 0 !== near) continue;
260
+ /* perspective and depth: the nearest point of the ring is fullest */
261
+ const depth = 0.6 + 0.4 * Math.sin(mid);
262
+ const fade = Math.pow(1 - f, 1.3);
263
+ /* a gentle puff along the trail */
264
+ const puff = 1 + 0.18 * Math.sin(f * 9 + 1.2);
265
+ const width = (2 + 8 * fade) * depth * widthK * puff;
266
+ const a = k * (0.3 + 0.7 * fade) * depth;
267
+ /* how much this stretch of the ring faces the light */
268
+ const facing = 0.5 + 0.5 * Math.cos(mid - lightA);
269
+ /* halo, underside, body, lit flank, specular ridge: a plastic tube */
270
+ seg(a1, a0, width * 2.6, withAlpha(ink.halo, a * 0.2), 0);
271
+ seg(a1, a0, width * 0.8, withAlpha(ink.dark, a * 0.45), width * 0.32);
272
+ seg(a1, a0, width, withAlpha(ink.base, a * 0.72), 0);
273
+ seg(a1, a0, width * 0.62, withAlpha(ink.light, a * 0.78 * (0.4 + 0.6 * facing)), -width * 0.16);
274
+ seg(
275
+ a1,
276
+ a0,
277
+ width * 0.24,
278
+ `rgba(255,255,255,${(a * 0.9 * (0.15 + 0.85 * facing * facing)).toFixed(3)})`,
279
+ -width * 0.3,
280
+ );
281
+ }
282
+ ctx.restore();
283
+ }
284
+
285
+ /**
286
+ * Draw one frame. `box` is the avatar's layout size in CSS px; the canvas
287
+ * is `box * OVERSCAN` square with the body's centre `RISE * box` below
288
+ * its middle, and the context already scaled for the device pixel ratio.
289
+ */
290
+ /* the back light's direction on screen from its angle, when one is given */
291
+ const backOf = (deg: number | undefined): { bx?: number; by?: number } => {
292
+ if (deg === undefined || !Number.isFinite(deg)) return {};
293
+ const a = (deg * Math.PI) / 180;
294
+ return { bx: Math.sin(a), by: -Math.cos(a) };
295
+ };
296
+
297
+ /** The light each material looks its best in where the config leaves it:
298
+ a plush pile wants a broad, low studio light that shows its lobes and
299
+ grain, a clear coat a brighter one for its gloss. */
300
+ export const LIGHT_DEFAULTS = {
301
+ fabric: { shadow: 1.15, highlight: 1.45, light: 295, rim: 0.6, spread: 1.6 },
302
+ other: { shadow: 0.35, highlight: 1.3, light: 300, rim: 0.5, spread: 1.55 },
303
+ };
304
+
305
+ export function draw(ctx: CanvasRenderingContext2D, box: number, pose: Pose, cfg: DrawConfig) {
306
+ const full = box * OVERSCAN;
307
+ ctx.clearRect(0, 0, full, full);
308
+ const S = box / 100;
309
+ /* the context's transform as given (the device scale) is the base of
310
+ every transform set here; known from `dpr`, else read once */
311
+ let dpr: number, base: readonly number[];
312
+ if (cfg.dpr !== undefined) {
313
+ dpr = cfg.dpr;
314
+ base = [dpr, 0, 0, dpr, 0, 0];
315
+ } else if (ctx.getTransform) {
316
+ const t = ctx.getTransform();
317
+ base = [t.a, t.b, t.c, t.d, t.e, t.f];
318
+ dpr = t.a || 1;
319
+ } else {
320
+ dpr = 1;
321
+ base = [1, 0, 0, 1, 0, 0];
322
+ }
323
+ const lit = LIGHT_DEFAULTS[cfg.shading === "fabric" ? "fabric" : "other"];
324
+ const shadow = cfg.shadow ?? lit.shadow,
325
+ highlight = cfg.highlight ?? lit.highlight;
326
+ const halfDepth = HALF_DEPTH * (cfg.depth ?? 0.65);
327
+ const rimK = cfg.rim ?? lit.rim;
328
+ const cap = 1 - (1 - CAP) * rimK;
329
+ const spread = cfg.spread ?? lit.spread;
330
+ /* the light's direction on screen: a unit vector toward the source */
331
+ const la = ((cfg.light ?? lit.light) * Math.PI) / 180;
332
+ const lx = Math.sin(la),
333
+ ly = -Math.cos(la);
334
+ const pal = palette(cfg.color, shadow, highlight);
335
+
336
+ const cy0 = Math.cos(pose.yaw),
337
+ sy = Math.sin(pose.yaw);
338
+ const cp0 = Math.cos(pose.pitch),
339
+ sp = Math.sin(pose.pitch);
340
+ /* which cap faces the viewer: the front while this is positive */
341
+ const facing = cy0 * cp0;
342
+ /* edge-on, every slice would thin to a line and the stack would show
343
+ gaps; a floor on the foreshortening keeps it a solid */
344
+ const floor = (v: number) => (Math.abs(v) < 0.22 ? (v < 0 ? -0.22 : 0.22) : v);
345
+ const cy = floor(cy0),
346
+ cp = floor(cp0);
347
+
348
+ /* body space: the box centre plus the pose's offset, its roll and
349
+ squash. The squash and stretch scale about the body's base (y = 50),
350
+ so a landing keeps the feet on the ground and presses the top down,
351
+ and a stretch rises from the base. */
352
+ const cr = Math.cos(pose.roll),
353
+ sr = Math.sin(pose.roll),
354
+ kx = pose.sx * S,
355
+ ky = pose.sy * S;
356
+ const lift = 50 * (1 - pose.sy) * S;
357
+ const body = mulAffine(base, [
358
+ cr * kx,
359
+ sr * kx,
360
+ -sr * ky,
361
+ cr * ky,
362
+ full / 2 + pose.x * S - sr * lift,
363
+ full / 2 + RISE * box + pose.y * S + cr * lift,
364
+ ]);
365
+ ctx.save();
366
+ ctx.setTransform(body[0], body[1], body[2], body[3], body[4], body[5]);
367
+ ctx.lineCap = "round";
368
+ ctx.lineJoin = "round";
369
+
370
+ const mode = cfg.shading;
371
+ /* one solid: the slice stack (or the plastic material) for an outline
372
+ at a depth; the thin parts come first with a fraction of the depth,
373
+ then the body over them. A plush toy's antennae are moulded plastic:
374
+ a pile on a rod that thin is all fringe, so parts take the plastic
375
+ when the body is fabric (`plush` false) */
376
+ const drawSolid = (path: Path2D, key: string, halfDepth: number, plush = true): boolean => {
377
+ /* the lit gradient, in the body's own space: light from the upper left */
378
+ let lit: CanvasGradient | string = pal.near;
379
+ let capFill: CanvasGradient | string = pal.base;
380
+ if (mode === "crisp") {
381
+ if (!pal.grad || pal.grad.lx !== lx || pal.grad.ly !== ly) {
382
+ const g = ctx.createLinearGradient(lx * 56, ly * 56, -lx * 56, -ly * 56);
383
+ g.addColorStop(0, pal.light);
384
+ g.addColorStop(0.45, pal.near);
385
+ g.addColorStop(1, pal.dark);
386
+ const c = ctx.createLinearGradient(lx * 46, ly * 46, -lx * 46, -ly * 46);
387
+ c.addColorStop(0, pal.capTop);
388
+ c.addColorStop(1, pal.capBottom);
389
+ pal.grad = { lx, ly, lit: g, cap: c };
390
+ }
391
+ lit = pal.grad.lit;
392
+ capFill = pal.grad.cap;
393
+ }
394
+
395
+ /* plastic and fabric: the material module draws the whole body, an
396
+ inflated relief of the lit texture. While a form is still baking on
397
+ idle time it declines, and the stock slices with the smooth overlay
398
+ stand in for that frame. */
399
+ let plasticDone = false;
400
+ if (mode === "plastic" || mode === "fabric") {
401
+ plasticDone = drawPlasticCap(
402
+ ctx,
403
+ { ...cfg, path, typeKey: key, shading: plush ? mode : "plastic" },
404
+ {
405
+ cy,
406
+ sy,
407
+ cp,
408
+ sp,
409
+ facing,
410
+ roll: pose.roll,
411
+ halfDepth,
412
+ cap,
413
+ lx,
414
+ ly,
415
+ ...backOf(cfg.backLight),
416
+ dev: box * dpr,
417
+ ctm: body,
418
+ still: cfg.still,
419
+ round: cfg.roundness ?? 1,
420
+ },
421
+ pal,
422
+ null,
423
+ {
424
+ shadow,
425
+ highlight,
426
+ spread,
427
+ rim: rimK,
428
+ vivid: cfg.vivid ?? 0,
429
+ front: (Math.min(85, Math.max(0, cfg.lightFront ?? 32)) * Math.PI) / 180,
430
+ shine: cfg.shine ?? 0,
431
+ sheen: cfg.sheen ?? 0,
432
+ backSoft: cfg.backSoftness ?? 1,
433
+ },
434
+ );
435
+ }
436
+ const mode2: BotAvatarShading =
437
+ (mode === "plastic" || mode === "fabric") && !plasticDone ? "smooth" : mode;
438
+ const soft = mode2 === "smooth";
439
+ const union = soft && typeof Path2D === "function" ? new Path2D() : null;
440
+ /* slices, far to near; each sets its transform outright from the
441
+ body's, no save/restore */
442
+ const order = facing >= 0 ? 1 : -1;
443
+ const [ca, cb, cc, cd, ce, cf] = body;
444
+ let fill: CanvasGradient | string | null = null;
445
+ /* each slice's affine is applied relative to the previous slice's: one
446
+ transform() per slice, no save/restore */
447
+ let pa = 1,
448
+ pb = 0,
449
+ pc = 0,
450
+ pd = 1,
451
+ pe = 0,
452
+ pf = 0;
453
+ for (let j = 0; j < SLICES && !plasticDone; j++) {
454
+ const k = order > 0 ? j : SLICES - 1 - j;
455
+ const z = -1 + (2 * k) / (SLICES - 1);
456
+ const s = profile(z, cap);
457
+ const near = j / (SLICES - 1);
458
+ /* yaw about Y then pitch about X, orthographic: an affine per slice,
459
+ then the path's own origin at its centre */
460
+ const m0 = cy * s,
461
+ m1 = sy * sp * s,
462
+ m3 = cp * s;
463
+ const e = z * sy * halfDepth - 50 * m0,
464
+ fo = -z * cy * sp * halfDepth - 50 * m1 - 50 * m3;
465
+ const det = pa * pd - pb * pc;
466
+ const ia = pd / det,
467
+ ib = -pb / det,
468
+ ic = -pc / det,
469
+ id = pa / det,
470
+ ie = (pc * pf - pd * pe) / det,
471
+ jf = (pb * pe - pa * pf) / det;
472
+ ctx.transform(
473
+ ia * m0 + ic * m1,
474
+ ib * m0 + id * m1,
475
+ ic * m3,
476
+ id * m3,
477
+ ia * e + ic * fo + ie,
478
+ ib * e + id * fo + jf,
479
+ );
480
+ pa = m0;
481
+ pb = m1;
482
+ pc = 0;
483
+ pd = m3;
484
+ pe = e;
485
+ pf = fo;
486
+ let style: CanvasGradient | string;
487
+ /* smooth: one colour ramp through the depth to the front, no edge at the cap */
488
+ if (soft) style = pal.smoothMix[j];
489
+ else if (j === SLICES - 1) style = capFill;
490
+ else if (near > 0.6) style = lit;
491
+ else style = pal.crispMix[j];
492
+ if (style !== fill) ctx.fillStyle = fill = style;
493
+ ctx.fill(path);
494
+ if (union) union.addPath(path, { a: m0, b: m1, c: 0, d: m3, e, f: fo });
495
+ }
496
+ if (!plasticDone) ctx.setTransform(ca, cb, cc, cd, ce, cf);
497
+
498
+ /* smooth: a soft shadow from the lower right and a light from the upper
499
+ left, laid over the whole form so nothing has an edge */
500
+ if (union && mode2 === "smooth") {
501
+ /* the two gradients are made per frame on purpose: a kept one is
502
+ slower to use in Safari than a fresh one */
503
+ ctx.save();
504
+ ctx.clip(union);
505
+ const sa = Math.min(1, 0.34 * shadow);
506
+ const sg = ctx.createRadialGradient(
507
+ -lx * 45,
508
+ -ly * 45,
509
+ 4 * spread,
510
+ -lx * 45,
511
+ -ly * 45,
512
+ 84 * spread,
513
+ );
514
+ sg.addColorStop(0, `rgba(0,0,0,${sa})`);
515
+ sg.addColorStop(0.5, `rgba(0,0,0,${sa * 0.35})`);
516
+ sg.addColorStop(1, "rgba(0,0,0,0)");
517
+ ctx.globalCompositeOperation = "multiply";
518
+ ctx.fillStyle = sg;
519
+ ctx.fillRect(-120, -120, 240, 240);
520
+ ctx.globalCompositeOperation = "source-over";
521
+ const ha = Math.min(1, 0.22 * highlight);
522
+ const hg = ctx.createRadialGradient(lx * 37, ly * 37, 0, lx * 37, ly * 37, 62 * spread);
523
+ hg.addColorStop(0, `rgba(255,255,255,${ha})`);
524
+ hg.addColorStop(0.6, `rgba(255,255,255,${ha * 0.23})`);
525
+ hg.addColorStop(1, "rgba(255,255,255,0)");
526
+ ctx.fillStyle = hg;
527
+ ctx.fillRect(-120, -120, 240, 240);
528
+ ctx.restore();
529
+ }
530
+
531
+ return plasticDone;
532
+ };
533
+
534
+ /* the far half of the whirl sits behind everything */
535
+ drawWhirl(ctx, pose, cfg.color, lx, ly, false, cfg.whirl);
536
+
537
+ /* what the bot wears, placed on its own geometry and turned with it */
538
+ const wear = cfg.wear;
539
+ const worn =
540
+ !!wear && (wear.hat !== "none" || wear.glasses !== "none" || wear.headphones || wear.bowTie);
541
+ const wr: WearRig = { cy, sy, cp, sp, facing, halfDepth, lx, ly };
542
+ if (worn) drawWearBehind(ctx, cfg.path, wr, wear!);
543
+
544
+ const key = cfg.typeKey ?? "custom";
545
+ /* each piece rounds on its own: two antennae inflated as one shape
546
+ would be scaled toward the middle between them, slice by slice */
547
+ const parts = cfg.parts ? (Array.isArray(cfg.parts) ? cfg.parts : [cfg.parts]) : [];
548
+ for (let i = 0; i < parts.length; i++)
549
+ drawSolid(
550
+ parts[i],
551
+ Array.isArray(cfg.parts) ? `${key}:parts${i}/${parts.length}` : `${key}:parts`,
552
+ halfDepth * (cfg.partsDepth ?? 0.4),
553
+ false,
554
+ );
555
+ const plasticDone = drawSolid(cfg.path, key, halfDepth);
556
+
557
+ /* the face: each feature sits on a sphere behind the front cap, so a
558
+ turn slides it round the head — the eye moving toward the edge
559
+ narrows, the other comes to the front, and past the side they go */
560
+ /* the front's outline in body space: the face's clip, and where worn
561
+ things cast their shadows. The extruded modes have a front cap; the
562
+ cushion of plastic and fabric has none, so its outline is taken a
563
+ little over halfway up the face's own height */
564
+ const zf = facing >= 0 ? 1 : -1;
565
+ const round = cfg.roundness ?? 1;
566
+ const cushion = plasticDone;
567
+ const sf = cushion ? 1 : profile(zf, cap);
568
+ const surface = (x: number, y: number) =>
569
+ cushion ? (surfaceAt(key, halfDepth, x, y, round) ?? 0.8 * halfDepth) : halfDepth * sf;
570
+ const zFace = cushion ? 0.55 * surface(cfg.faceX, cfg.faceY) : halfDepth;
571
+ const fm0 = cy * sf,
572
+ fm1 = sy * sp * sf,
573
+ fm3 = cp * sf;
574
+ const fe = zf * sy * zFace - 50 * fm0;
575
+ const ffo = -zf * cy * sp * zFace - 50 * fm1 - 50 * fm3;
576
+ let frontClip: Path2D | null = null;
577
+ if (worn && typeof Path2D === "function") {
578
+ frontClip = new Path2D();
579
+ frontClip.addPath(cfg.path, { a: fm0, b: fm1, c: 0, d: fm3, e: fe, f: ffo });
580
+ }
581
+
582
+ if (facing > -0.2) {
583
+ ctx.save();
584
+ /* The face is printed on the front of the body, so it cannot leave
585
+ it: clip to the front slice's own outline first. On a wide turn a
586
+ feature's place on the sphere can reach past the body's foreshortened
587
+ silhouette, and without this it floats off the side. */
588
+ {
589
+ const [ca, cb, cc, cd, ce, cf] = body;
590
+ ctx.setTransform(
591
+ ca * fm0 + cc * fm1,
592
+ cb * fm0 + cd * fm1,
593
+ cc * fm3,
594
+ cd * fm3,
595
+ ca * fe + cc * ffo + ce,
596
+ cb * fe + cd * ffo + cf,
597
+ );
598
+ ctx.clip(cfg.path);
599
+ ctx.setTransform(ca, cb, cc, cd, ce, cf);
600
+ }
601
+ ctx.translate(cfg.faceX - 50, cfg.faceY - 50);
602
+ ctx.scale(cfg.faceScale, cfg.faceScale);
603
+ /* under a clear coat the print shows the gloss faintly through it */
604
+ if (mode === "plastic" && plasticDone) ctx.globalAlpha = 0.93;
605
+ const eyes = drawFace(ctx, pose, cfg);
606
+ ctx.globalAlpha = 1;
607
+ /* glasses: their shadow falls on the face (inside its clip), the frames
608
+ themselves may reach past the body's edge */
609
+ const glasses = wear && wear.glasses !== "none" ? wear.glasses : null;
610
+ const turn = { yaw: pose.yaw, pitch: pose.pitch };
611
+ if (glasses) drawGlasses(ctx, glasses, eyes, { lx, ly }, "shadow", turn);
612
+ ctx.restore();
613
+ if (glasses) {
614
+ ctx.save();
615
+ ctx.translate(cfg.faceX - 50, cfg.faceY - 50);
616
+ ctx.scale(cfg.faceScale, cfg.faceScale);
617
+ drawGlasses(ctx, glasses, eyes, { lx, ly }, "frame", turn);
618
+ ctx.restore();
619
+ }
620
+ }
621
+ if (worn)
622
+ drawWearFront(ctx, cfg.path, wr, wear!, frontClip, surface, {
623
+ y: cfg.faceY,
624
+ scale: cfg.faceScale,
625
+ });
626
+ /* the near half of the whirl passes in front of the face */
627
+ drawWhirl(ctx, pose, cfg.color, lx, ly, true, cfg.whirl);
628
+ ctx.restore();
629
+ }
630
+
631
+ /* Radius of the sphere the face is drawn on, in body units. */
632
+ const FACE_R = 30;
633
+
634
+ /* A feature's place on the sphere: longitude and latitude from its
635
+ design position, turned by the head. Returns its screen offset, its
636
+ foreshortening, and how much it faces the viewer. */
637
+ function onSphere(x: number, y: number, yaw: number, pitch: number) {
638
+ const lon = Math.asin(Math.max(-1, Math.min(1, x / FACE_R))) + yaw;
639
+ const lat = Math.asin(Math.max(-1, Math.min(1, -y / FACE_R))) + pitch;
640
+ const cl = Math.cos(lat);
641
+ return {
642
+ x: FACE_R * Math.sin(lon) * cl,
643
+ y: -FACE_R * Math.sin(lat),
644
+ sx: Math.cos(lon),
645
+ sy: cl,
646
+ z: Math.cos(lon) * cl,
647
+ };
648
+ }
649
+
650
+ /* An eye's curve as a short polyline with round joins, cached by its
651
+ numbers. Stroking the open eye's hairpin curve directly gives a pill in
652
+ Chrome but a teardrop in Safari, whose stroker does not round a cusp;
653
+ both stroke a polyline the same way. */
654
+ const EYE_STEPS = 8;
655
+ const eyePaths = new Map<number, Path2D>();
656
+ function eyePath(x0: number, y0: number, cy: number): Path2D {
657
+ const qx = Math.round(x0 * 50),
658
+ qy = Math.round(y0 * 50),
659
+ qc = Math.round(cy * 50);
660
+ const key = qx + 2000 * qy + 4e6 * qc;
661
+ let p = eyePaths.get(key);
662
+ if (!p) {
663
+ const ax = qx / 50,
664
+ ay = qy / 50,
665
+ ac = qc / 50;
666
+ let d = `M${-ax} ${ay}`;
667
+ for (let i = 1; i <= EYE_STEPS; i++) {
668
+ const t = i / EYE_STEPS,
669
+ mt = 1 - t;
670
+ d += ` L${(mt * mt * -ax + t * t * ax).toFixed(3)} ${((mt * mt + t * t) * ay + 2 * mt * t * ac).toFixed(3)}`;
671
+ }
672
+ p = new Path2D(d);
673
+ if (eyePaths.size > 256) eyePaths.clear();
674
+ eyePaths.set(key, p);
675
+ }
676
+ return p;
677
+ }
678
+
679
+ /* The mouth's outline as a path, cached by its numbers: corners at ±hw on
680
+ y = 0, a top edge from the left corner to the right with its controls
681
+ a·hw in from the corners at depth yt, a bottom edge back at depth yb
682
+ with controls ab·hw in, and round caps of radius t0 round each corner,
683
+ set square to the smile's end slope. */
684
+ interface Mouth {
685
+ hw: number;
686
+ t0: number;
687
+ a: number;
688
+ yt: number;
689
+ ab: number;
690
+ yb: number;
691
+ }
692
+ const MOUTH_SIN = Math.sin(0.684),
693
+ MOUTH_COS = Math.cos(0.684);
694
+ const mouthPaths = new Map<string, Path2D>();
695
+ function mouthPath(m: Mouth): Path2D {
696
+ const q = (v: number) => Math.round(v * 50) / 50;
697
+ const hw = q(m.hw),
698
+ t0 = q(m.t0),
699
+ a = q(m.a),
700
+ yt = q(m.yt),
701
+ ab = q(m.ab),
702
+ yb = q(m.yb);
703
+ const key = `${hw},${t0},${a},${yt},${ab},${yb}`;
704
+ let p = mouthPaths.get(key);
705
+ if (p) return p;
706
+ const f = (v: number) => v.toFixed(3);
707
+ /* the corners, offset along the end normal for the caps */
708
+ const nx = t0 * MOUTH_SIN,
709
+ ny = t0 * MOUTH_COS;
710
+ const ltx = -hw + nx,
711
+ lty = -ny,
712
+ rtx = hw - nx,
713
+ rty = -ny;
714
+ const lbx = -hw - nx,
715
+ lby = ny,
716
+ rbx = hw + nx,
717
+ rby = ny;
718
+ /* the caps bulge outward along the end tangents */
719
+ const cx = (4 / 3) * t0 * MOUTH_COS,
720
+ cy = (4 / 3) * t0 * MOUTH_SIN;
721
+ const d =
722
+ `M${f(ltx)} ${f(lty)}` +
723
+ `C${f(-hw + a * hw)} ${f(yt - t0)} ${f(hw - a * hw)} ${f(yt - t0)} ${f(rtx)} ${f(rty)}` +
724
+ `C${f(rtx + cx)} ${f(rty - cy)} ${f(rbx + cx)} ${f(rby - cy)} ${f(rbx)} ${f(rby)}` +
725
+ `C${f(hw - ab * hw)} ${f(yb + t0)} ${f(-hw + ab * hw)} ${f(yb + t0)} ${f(lbx)} ${f(lby)}` +
726
+ `C${f(lbx - cx)} ${f(lby - cy)} ${f(ltx - cx)} ${f(lty - cy)} ${f(ltx)} ${f(lty)}Z`;
727
+ p = new Path2D(d);
728
+ if (mouthPaths.size > 256) mouthPaths.clear();
729
+ mouthPaths.set(key, p);
730
+ return p;
731
+ }
732
+
733
+ function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, cfg: DrawConfig): [EyeSpot, EyeSpot] {
734
+ const [wd, ww, ws] = pose.w;
735
+ const ink = cfg.ink;
736
+ const ey = EYE_Y[cfg.face];
737
+ const half = EYE_GAP / 2;
738
+ const lx = pose.lookX,
739
+ ly = pose.lookY;
740
+ const { yaw, pitch } = pose;
741
+
742
+ /* place a feature: skip it once it has gone round the side */
743
+ const at = (x: number, y: number, fn: () => void, alpha = 1) => {
744
+ const q = onSphere(x, y, yaw, pitch);
745
+ if (q.z <= 0.02 || alpha <= 0.01) return;
746
+ ctx.save();
747
+ ctx.globalAlpha = alpha * Math.min(1, q.z * 5);
748
+ ctx.translate(q.x, q.y);
749
+ ctx.scale(Math.max(0.02, q.sx), Math.max(0.02, q.sy));
750
+ fn();
751
+ ctx.restore();
752
+ };
753
+
754
+ /* Each eye is one stroked curve — endpoints at ±x0,y0, a control point
755
+ at 0,cy, width w, round caps — so every look is the same shape with
756
+ different numbers, and a blend of the numbers is a real morph: the
757
+ upright pill of an open eye squashes into a shut line, swings up into
758
+ a laughing arc, or droops into a sleeping lid. */
759
+ /* the eyes take the head's aim, but only once it really aims
760
+ somewhere: at rest and through the small drift of a breath they keep
761
+ their own shape, and past that they draw taller looking up, shorter
762
+ looking down, and a little wider from the corner of a sideways
763
+ glance — the mimic that makes a turn read as a look */
764
+ const past = (v: number, d: number) =>
765
+ Math.abs(v) <= d ? 0 : (Math.sign(v) * (Math.abs(v) - d)) / (1 - d);
766
+ const clamp1 = (v: number) => Math.max(-1, Math.min(1, v));
767
+ const up = past(clamp1(-pose.pitch / 0.26 - pose.lookY / 7), 0.34);
768
+ const side = Math.abs(past(clamp1(pose.lookX / 4.5), 0.4));
769
+ const tall = Math.max(0.3, 1 + 0.55 * up - 0.1 * side);
770
+ const wide = 1 - 0.05 * up + 0.12 * side;
771
+ const open = wd + ww * (1 - pose.laugh);
772
+ const laugh = ww * pose.laugh;
773
+ const lift = Math.max(0, -pose.y) / 26;
774
+ const sag = 0.5 + 0.5 * pose.breath;
775
+ for (const side of [-1, 1] as const) {
776
+ const lid = side < 0 ? pose.blinkL : pose.blinkR;
777
+ const e = Math.max(0, Math.min(1, pose.eyeOpen * (1 - lid)));
778
+ const kOpen = open * e,
779
+ kShut = open * (1 - e),
780
+ kLaugh = laugh,
781
+ kSleep = ws;
782
+ const x0 = kOpen * 0.01 + kShut * 5.4 + kLaugh * 6.2 + kSleep * 6;
783
+ const y0 =
784
+ kOpen * 1.1 * tall + kShut * 0.6 + kLaugh * (2.2 - lift * 1.5) + kSleep * (-1.4 + sag);
785
+ const cy =
786
+ kOpen * -3.3 * tall + kShut * 0.6 + kLaugh * (-11.4 - 4 * lift) + kSleep * (5.4 + 2 * sag);
787
+ const w = kOpen * EYE_RX * 2 * wide + kShut * 2.8 + kLaugh * 4.4 + kSleep * 4;
788
+ /* the eyes drift toward the look when open, less so when shut */
789
+ const dx = lx * (kOpen + 0.5 * (kShut + kLaugh)),
790
+ dy = ly * (kOpen + 0.5 * kShut);
791
+ at(side * half + dx, ey + dy, () => {
792
+ ctx.strokeStyle = ink;
793
+ ctx.lineWidth = w;
794
+ ctx.stroke(eyePath(x0, y0, cy));
795
+ });
796
+ }
797
+
798
+ if (cfg.face === "mouth") {
799
+ const mx = lx * 0.35;
800
+ /* One mouth for every state, so a switch morphs it rather than fading
801
+ one shape into another: two cubic edges between the corners, round
802
+ caps of radius t0 at the corners (the smile is a thick line; the
803
+ open mouth and the sleeping "o" have sharp corners), every number a
804
+ blend of the three states'. The smile widens on the in-breath, the
805
+ open mouth at the top of a hop, the "o" swells with each breath. */
806
+ const kd = (0.6 + 0.4 * wd) * (1 + 0.06 * pose.breath);
807
+ const kw = (0.6 + 0.4 * ww) * (1 + (0.25 * Math.max(0, -pose.y)) / 26);
808
+ const r = 2.7 * ws * (1 + 0.25 * pose.breath);
809
+ const b = (d: number, w: number, s: number) => wd * d + ww * w + ws * s;
810
+ const m = {
811
+ hw: b(6.5 * kd, 9.5 * kw, r),
812
+ t0: b(1.9, 0, 0),
813
+ a: b(2 / 3, 2 / 3, 0),
814
+ yt: b(3.53 * kd, 1.6 * kw, (-4 * r) / 3),
815
+ ab: b(2 / 3, 0, 0),
816
+ yb: b(3.53 * kd, 17.3 * kw, (4 * r) / 3),
817
+ };
818
+ at(mx, b(12.5, 11.6, 15.5), () => {
819
+ ctx.fillStyle = ink;
820
+ ctx.fill(mouthPath(m));
821
+ });
822
+ }
823
+
824
+ /* where the eyes sit on the face, for anything worn over them: the
825
+ design spots on the sphere, without the look's drift */
826
+ const spot = (x: number): EyeSpot => {
827
+ const q = onSphere(x, ey, yaw, pitch);
828
+ return {
829
+ x: q.x,
830
+ y: q.y,
831
+ sx: q.sx,
832
+ sy: q.sy,
833
+ z: q.z,
834
+ ox: x,
835
+ oy: ey,
836
+ fz: Math.sqrt(Math.max(0, FACE_R * FACE_R - x * x - ey * ey)),
837
+ };
838
+ };
839
+ return [spot(-half), spot(half)];
840
+ }