@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.
- package/dist/agents/helpers.js +38 -38
- package/dist/agents/page-state.js +49 -50
- package/dist/agents/vendor/color.js +146 -127
- package/dist/agents/vendor/draw.js +617 -508
- package/dist/agents/vendor/engine.js +698 -599
- package/dist/agents/vendor/mesh.js +653 -683
- package/dist/agents/vendor/plastic.js +3913 -3112
- package/dist/agents/vendor/presets.js +163 -164
- package/dist/agents/vendor/shapes.js +21 -20
- package/dist/agents/vendor/ticker.js +41 -42
- package/dist/agents/vendor/types.js +1 -0
- package/dist/agents/vendor/wear.js +1421 -1448
- package/dist/ai/confirmation/confirmation-context.svelte.js +6 -6
- package/dist/ai/confirmation/index.js +3 -1
- package/dist/ai/index.js +33 -0
- package/dist/ai/prompt-input/index.js +3 -1
- package/dist/ai/reasoning/index.js +3 -1
- package/dist/ai/reasoning/reasoning-context.svelte.js +6 -6
- package/dist/ai/suggestion/index.js +3 -1
- package/dist/ai/typing-dots/index.js +3 -1
- package/dist/animations/text-swap.js +24 -24
- package/dist/brand/index.js +54 -48
- package/dist/canvas/card/resize.js +5 -3
- package/dist/canvas/edges/edge-path.js +255 -242
- package/dist/canvas/editor/context-menu.js +39 -36
- package/dist/canvas/editor/dispatch.svelte.js +154 -151
- package/dist/canvas/editor/editor-context.js +7 -2
- package/dist/canvas/editor/editor-mode.js +1 -0
- package/dist/canvas/editor/frame.js +73 -76
- package/dist/canvas/editor/hand-context.js +5 -2
- package/dist/canvas/editor/history-core.js +163 -191
- package/dist/canvas/editor/keymap.js +95 -169
- package/dist/canvas/editor/layout-actions.js +143 -144
- package/dist/canvas/editor/lock.js +52 -51
- package/dist/canvas/editor/rebase.js +217 -240
- package/dist/canvas/editor/style.js +132 -137
- package/dist/canvas/editor/text-field-context.js +7 -2
- package/dist/canvas/editor/z-order.js +65 -70
- package/dist/canvas/headless/alignment.js +22 -16
- package/dist/canvas/headless/break-apart-find.js +286 -279
- package/dist/canvas/headless/break-apart-pieces.js +62 -58
- package/dist/canvas/headless/break-apart-plan.js +299 -325
- package/dist/canvas/headless/break-apart-style.js +75 -70
- package/dist/canvas/headless/break-apart.js +188 -181
- package/dist/canvas/headless/canvas-cleanup.js +78 -58
- package/dist/canvas/headless/edge-anchors.js +115 -116
- package/dist/canvas/headless/edge-memo.js +24 -22
- package/dist/canvas/headless/edge-serialization.js +17 -27
- package/dist/canvas/headless/figure-geometry.js +232 -264
- package/dist/canvas/headless/fit-viewport.js +84 -67
- package/dist/canvas/headless/grid.js +88 -86
- package/dist/canvas/headless/icon-node.js +44 -36
- package/dist/canvas/headless/index.js +1 -0
- package/dist/canvas/headless/linkify.js +17 -19
- package/dist/canvas/headless/pen-marks.js +101 -95
- package/dist/canvas/headless/sketch.js +231 -209
- package/dist/canvas/headless/snap-guides.js +99 -83
- package/dist/canvas/headless/svg-figure.js +133 -117
- package/dist/canvas/headless/table-layout.js +146 -107
- package/dist/canvas/index.js +2 -0
- package/dist/canvas/lod/lod-mode.svelte.js +20 -18
- package/dist/canvas/lod/render-all.svelte.js +36 -39
- package/dist/canvas/ops/arrange.js +78 -71
- package/dist/canvas/ops/ops.js +260 -255
- package/dist/canvas/pen/colors.js +37 -35
- package/dist/canvas/pen/eraser.js +28 -27
- package/dist/canvas/pen/geometry.js +120 -130
- package/dist/canvas/pen/index.js +1 -0
- package/dist/canvas/pen/ops.js +11 -11
- package/dist/canvas/pen/pen-session.js +150 -159
- package/dist/canvas/pen/plugin.js +107 -104
- package/dist/canvas/pen/shapes.js +73 -68
- package/dist/canvas/pen/stroke-path.js +32 -30
- package/dist/canvas/pen/types.js +21 -21
- package/dist/canvas/pen/ui.js +10 -8
- package/dist/canvas/plugin.js +46 -46
- package/dist/canvas/screen/glyphs.js +12 -11
- package/dist/canvas/screen/labels.js +22 -22
- package/dist/canvas/screen/parts.js +98 -111
- package/dist/canvas/screen/plugin.js +47 -56
- package/dist/canvas/screen/types.js +30 -35
- package/dist/canvas/screen/ui.js +7 -7
- package/dist/canvas/test-utils.js +14 -14
- package/dist/canvas/types.js +1 -0
- package/dist/canvas/ui/forward-wheel.js +24 -25
- package/dist/canvas/ui/icons.js +64 -131
- package/dist/canvas/ui/place-panel.js +58 -49
- package/dist/canvas/ui/shortcut-tip.js +14 -12
- package/dist/canvas/ui/tools.js +300 -385
- package/dist/charts/dashboard/chart.js +4 -0
- package/dist/charts/dashboard/data-table.js +11 -0
- package/dist/charts/dashboard/windows.js +8 -3
- package/dist/charts/edge-fade.js +10 -5
- package/dist/charts/hover-band.js +16 -11
- package/dist/charts/index.js +0 -1
- package/dist/charts/motion.js +11 -7
- package/dist/charts/primitives/chart-utils.js +12 -12
- package/dist/components/copy/clipboard.js +32 -32
- package/dist/components/copy/copied.svelte.js +31 -23
- package/dist/components/data-table/data-table.js +39 -39
- package/dist/components/docs/index.js +4 -0
- package/dist/components/docs/scroll-spy.svelte.js +73 -68
- package/dist/components/list-page/filter-section.js +4 -1
- package/dist/components/list-page/index.js +2 -0
- package/dist/components/list-page/list-table.js +7 -2
- package/dist/components/list-page/queue-rail.js +14 -0
- package/dist/components/list-page/section-host.js +4 -2
- package/dist/components/list-page/step.js +4 -3
- package/dist/components/list-page/types.js +1 -0
- package/dist/components/picker/trigger.js +48 -30
- package/dist/components/picker/types.js +1 -0
- package/dist/components/segmented/collapse.js +38 -27
- package/dist/components/segmented/index.js +8 -0
- package/dist/components/segmented/segmented.js +67 -42
- package/dist/components/star-rating-input/index.js +3 -1
- package/dist/components/table-view/types.js +1 -0
- package/dist/components/tags-input/tags.js +81 -69
- package/dist/components/theme/index.js +4 -0
- package/dist/components/toolbar/index.js +4 -0
- package/dist/components/toolbar/toolbar-trigger.js +14 -1
- package/dist/editor/anchors.js +17 -13
- package/dist/editor/block-id.js +52 -51
- package/dist/editor/block-ops.js +108 -80
- package/dist/editor/doc-blocks.js +155 -166
- package/dist/editor/extensions.js +139 -180
- package/dist/editor/index.js +4 -0
- package/dist/editor/markdown-test-helpers.js +4 -2
- package/dist/editor/markdown.js +420 -408
- package/dist/editor/serializer.js +204 -200
- package/dist/editor/slash-items.js +89 -101
- package/dist/editor/youtube-url.js +25 -20
- package/dist/effects/animated-beam/path-calculator.svelte.js +43 -38
- package/dist/effects/animated-beam/types.js +1 -0
- package/dist/effects/border-beam/css.js +92 -78
- package/dist/effects/border-beam/presets.js +1705 -1720
- package/dist/effects/border-beam/register-properties.js +43 -33
- package/dist/effects/border-beam/types.js +1 -0
- package/dist/effects/image-comparison/compare.js +3 -2
- package/dist/effects/image-reveal/types.js +1 -0
- package/dist/effects/magnetic/magnetic.js +58 -52
- package/dist/effects/reveal/observe.js +50 -40
- package/dist/effects/reveal/types.js +1 -0
- package/dist/effects/shader-gradient/color.js +36 -29
- package/dist/effects/shader-gradient/renderer.js +384 -295
- package/dist/effects/shader-gradient/shaders.js +31 -0
- package/dist/effects/shader-gradient/types.js +1 -0
- package/dist/icons/apple-symbol-exports.js +99 -124
- package/dist/icons/sf-symbols.js +357 -461
- package/dist/icons/trades.js +43 -28
- package/dist/inbox/index.js +1 -0
- package/dist/inbox/logic.js +126 -122
- package/dist/inbox/types.js +43 -43
- package/dist/lib/arrive.js +19 -12
- package/dist/lib/icon-types.js +1 -0
- package/dist/lib/initials.js +16 -10
- package/dist/lib/layout.js +5 -0
- package/dist/lib/panel.js +13 -0
- package/dist/lib/result.js +22 -22
- package/dist/lib/sliding-pill.js +59 -44
- package/dist/lib/theme-transition.js +43 -43
- package/dist/lib/tone.js +31 -24
- package/dist/lib/utils.js +3 -1
- package/dist/onboarding/define-tours.js +24 -23
- package/dist/onboarding/onboarding.svelte.js +16 -12
- package/dist/onboarding/pick.js +10 -6
- package/dist/onboarding/stores.js +40 -41
- package/dist/onboarding/wait.js +24 -23
- package/dist/panel/side-panel-types.js +1 -0
- package/dist/record/demo-data.js +459 -486
- package/dist/record/fields.js +129 -131
- package/dist/record/format.js +70 -82
- package/dist/record/gutter.js +1 -0
- package/dist/record/kinds.js +95 -105
- package/dist/record/logic.js +238 -219
- package/dist/record/types.js +49 -48
- package/dist/shadcn/accordion/index.js +3 -1
- package/dist/shadcn/alert/index.js +3 -1
- package/dist/shadcn/alert-dialog/index.js +3 -1
- package/dist/shadcn/attachment/index.js +3 -1
- package/dist/shadcn/avatar/index.js +3 -1
- package/dist/shadcn/breadcrumb/index.js +3 -1
- package/dist/shadcn/bubble/index.js +3 -1
- package/dist/shadcn/button/index.js +3 -1
- package/dist/shadcn/calendar/index.js +3 -1
- package/dist/shadcn/card/index.js +3 -1
- package/dist/shadcn/checkbox/index.js +3 -1
- package/dist/shadcn/code-block/index.js +3 -1
- package/dist/shadcn/collapsible/index.js +3 -1
- package/dist/shadcn/command/index.js +4 -1
- package/dist/shadcn/context-menu/index.js +3 -1
- package/dist/shadcn/dialog/index.js +3 -1
- package/dist/shadcn/dropdown-menu/menu-item.js +40 -20
- package/dist/shadcn/empty/index.js +3 -1
- package/dist/shadcn/field/index.js +3 -1
- package/dist/shadcn/hooks/highlighter.js +77 -75
- package/dist/shadcn/hooks/is-mobile.svelte.js +3 -3
- package/dist/shadcn/hooks/use-highlighted-code.svelte.js +62 -62
- package/dist/shadcn/input/index.js +3 -1
- package/dist/shadcn/kbd/index.js +3 -1
- package/dist/shadcn/label/index.js +3 -1
- package/dist/shadcn/marker/index.js +3 -1
- package/dist/shadcn/message/index.js +3 -1
- package/dist/shadcn/message-scroller/index.js +3 -1
- package/dist/shadcn/popover/index.js +3 -1
- package/dist/shadcn/progress/index.js +3 -1
- package/dist/shadcn/progress/tone.js +8 -7
- package/dist/shadcn/radio-group/index.js +3 -1
- package/dist/shadcn/scroll-area/index.js +3 -1
- package/dist/shadcn/select/index.js +3 -1
- package/dist/shadcn/separator/index.js +3 -1
- package/dist/shadcn/sheet/index.js +3 -1
- package/dist/shadcn/sidebar/context.svelte.js +42 -28
- package/dist/shadcn/sidebar/index.js +3 -1
- package/dist/shadcn/skeleton/index.js +3 -1
- package/dist/shadcn/slider/index.js +3 -1
- package/dist/shadcn/switch/index.js +3 -1
- package/dist/shadcn/table/index.js +3 -1
- package/dist/shadcn/tabs/index.js +3 -1
- package/dist/shadcn/textarea/index.js +3 -1
- package/dist/shadcn/toggle/index.js +3 -1
- package/dist/shadcn/toggle-group/index.js +3 -1
- package/dist/shadcn/tooltip/context.js +7 -2
- package/dist/shadcn/tooltip/index.js +3 -1
- package/dist/shadcn/trace/index.js +3 -1
- package/dist/slides/bridges.js +65 -59
- package/dist/slides/deck-controller.svelte.js +210 -207
- package/dist/slides/export/capture.js +66 -71
- package/dist/slides/export/ffmpeg.js +76 -72
- package/dist/slides/export/index.js +3 -0
- package/dist/slides/export/launch.js +9 -6
- package/dist/slides/export/pdf.js +60 -54
- package/dist/slides/export/types.js +1 -0
- package/dist/slides/labels.js +24 -24
- package/dist/slides/layout.js +39 -41
- package/dist/slides/model.js +9 -7
- package/dist/slides/navigation.js +78 -73
- package/dist/slides/types.js +2 -2
- package/dist/status/define-statuses.js +18 -14
- package/dist/status/status-types.js +1 -0
- package/dist/test-setup.js +24 -20
- package/dist/thread/logic.js +73 -77
- package/dist/thread/types.js +21 -21
- package/dist/work/board-order.js +94 -90
- package/dist/work/dates.js +16 -9
- package/dist/work/detail/detail-types.js +18 -18
- package/dist/work/docs/edited.js +32 -29
- package/dist/work/docs/index.js +4 -0
- package/dist/work/docs/labels.js +34 -34
- package/dist/work/docs/menu.js +113 -106
- package/dist/work/docs/outline.js +39 -29
- package/dist/work/gantt/logic.js +211 -235
- package/dist/work/inbox/inbox-logic.js +71 -75
- package/dist/work/inbox/inbox-statuses.js +75 -72
- package/dist/work/inbox/inbox-types.js +1 -0
- package/dist/work/kanban-drag.js +27 -22
- package/dist/work/kanban-types.js +1 -0
- package/dist/work/presets/sales.js +36 -32
- package/dist/work/reorder.js +9 -8
- package/dist/work/task-types.js +1 -0
- package/dist/work/time/duration.js +51 -46
- package/dist/work/time/time-types.js +23 -23
- package/dist/work/use-kanban-dnd.svelte.js +126 -125
- package/package.json +3 -1
- package/src/components/list-page/master-detail.svelte +3 -1
- package/src/components/list-page/rail-row.svelte +4 -4
- 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
|
-
|
|
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
|
-
|
|
9
|
-
const
|
|
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
|
-
|
|
14
|
-
|
|
20
|
+
eyes: 1,
|
|
21
|
+
mouth: -3.5
|
|
15
22
|
};
|
|
16
23
|
const paletteCache = new Map();
|
|
17
24
|
function palette(color, shadow, highlight) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 =
|
|
57
|
-
const WHIRL_TILT =
|
|
75
|
+
const WHIRL_RATIO = .4;
|
|
76
|
+
const WHIRL_TILT = -.28;
|
|
58
77
|
const whirlInkCache = new Map();
|
|
59
78
|
function whirlInk(color) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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(
|
|
541
|
+
const MOUTH_SIN = Math.sin(.684), MOUTH_COS = Math.cos(.684);
|
|
449
542
|
const mouthPaths = new Map();
|
|
450
543
|
function mouthPath(m) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
}
|