@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
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/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// The AI Assistant's avatar: Druplicon, standing at the top of the assistant panel, alive while it is
|
|
2
|
+
// shown — breathing, blinking, its eyes on the pointer, thinking while a reply is on its way, its mouth
|
|
3
|
+
// moving with the spoken summary, and a gesture where one fits.
|
|
4
|
+
//
|
|
5
|
+
// Loaded on demand, like Ace: three.js and three-vrm are 1.1 MB and a page that does not show the
|
|
6
|
+
// avatar never fetches them. The model, public/avatar/druplicon.vrm, is built in code by the avatar
|
|
7
|
+
// app's tools/build-druplicon.mjs (github.com/Natshah/druplicon-avatar); Druplicon is licensed under
|
|
8
|
+
// the GPL, and Drupal is a registered trademark of Dries Buytaert.
|
|
9
|
+
//
|
|
10
|
+
// The stage is extensible (registerStageExtension below): a board, a video or anything else can sit
|
|
11
|
+
// beside the avatar later without this file knowing about it.
|
|
12
|
+
|
|
13
|
+
import * as THREE from 'three';
|
|
14
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
15
|
+
import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
|
|
16
|
+
import { MOVE_SETS } from '/assistant-avatar-moves.js';
|
|
17
|
+
|
|
18
|
+
// ---- The pose: rotations added per bone, on top of the rest pose --------------------------------
|
|
19
|
+
|
|
20
|
+
const BONES = ['hips', 'spine', 'chest', 'neck', 'head', 'leftShoulder', 'rightShoulder',
|
|
21
|
+
'leftUpperArm', 'leftLowerArm', 'leftHand', 'rightUpperArm', 'rightLowerArm', 'rightHand',
|
|
22
|
+
'leftUpperLeg', 'leftLowerLeg', 'rightUpperLeg', 'rightLowerLeg'];
|
|
23
|
+
const DIGITS = { Thumb: ['Metacarpal', 'Proximal', 'Distal'], Index: ['Proximal', 'Intermediate', 'Distal'], Middle: ['Proximal', 'Intermediate', 'Distal'], Ring: ['Proximal', 'Intermediate', 'Distal'], Little: ['Proximal', 'Intermediate', 'Distal'] };
|
|
24
|
+
for (const side of ['left', 'right']) for (const [f, joints] of Object.entries(DIGITS)) for (const j of joints) BONES.push(`${side}${f}${j}`);
|
|
25
|
+
|
|
26
|
+
// Arms down from the T-pose, elbows soft. Normalized bones: the avatar faces +Z and its left is +X.
|
|
27
|
+
const REST = {
|
|
28
|
+
leftUpperArm: { z: -1.2, y: 0.05 }, rightUpperArm: { z: 1.2, y: -0.05 },
|
|
29
|
+
leftLowerArm: { y: -0.25 }, rightLowerArm: { y: 0.25 },
|
|
30
|
+
leftHand: { z: -0.1 }, rightHand: { z: 0.1 },
|
|
31
|
+
};
|
|
32
|
+
// A closed finger, joint by joint; a relaxed hand curls each finger a little more than the one before.
|
|
33
|
+
const CLOSE = { Thumb: [0.35, 0.7, 0.8], Index: [1.45, 1.6, 1.0], Middle: [1.5, 1.65, 1.0], Ring: [1.5, 1.65, 1.0], Little: [1.5, 1.6, 1.0] };
|
|
34
|
+
const RELAXED = { Thumb: 0.25, Index: 0.12, Middle: 0.16, Ring: 0.2, Little: 0.24 };
|
|
35
|
+
const SHAPES = {
|
|
36
|
+
open: { Thumb: -0.25, Index: -0.12, Middle: -0.16, Ring: -0.2, Little: -0.24 },
|
|
37
|
+
point: { Thumb: 0.35, Index: -0.12, Middle: 0.7, Ring: 0.75, Little: 0.75 },
|
|
38
|
+
fist: { Thumb: 0.5, Index: 0.85, Middle: 0.85, Ring: 0.8, Little: 0.76 },
|
|
39
|
+
thumbsup: { Thumb: -0.45, Index: 0.85, Middle: 0.85, Ring: 0.8, Little: 0.76 },
|
|
40
|
+
};
|
|
41
|
+
function curl(p, side, finger, k) {
|
|
42
|
+
const m = side === 'left' ? -1 : 1;
|
|
43
|
+
DIGITS[finger].forEach((j, i) => {
|
|
44
|
+
const a = CLOSE[finger][i] * k;
|
|
45
|
+
if (finger === 'Thumb') p.add(`${side}Thumb${j}`, a, 0, 0); else p.add(`${side}${finger}${j}`, 0, 0, m * a);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
for (const side of ['left', 'right']) {
|
|
49
|
+
for (const f of Object.keys(DIGITS)) curl({ add: (b, x, y, z) => { REST[b] = { x, y, z }; } }, side, f, RELAXED[f]);
|
|
50
|
+
}
|
|
51
|
+
const hand = (p, side, shape, e) => { for (const [f, k] of Object.entries(SHAPES[shape])) curl(p, side, f, k * e); };
|
|
52
|
+
|
|
53
|
+
class Pose {
|
|
54
|
+
constructor() { this.r = {}; }
|
|
55
|
+
reset() { for (const b of BONES) { const r = REST[b] || {}; this.r[b] = { x: r.x || 0, y: r.y || 0, z: r.z || 0 }; } }
|
|
56
|
+
add(b, x, y, z) { const r = this.r[b]; if (r) { r.x += x; r.y += y; r.z += z; } }
|
|
57
|
+
/** A hand shape ('open', 'point', 'fist', 'thumbsup') at strength e, for the move sets, which cannot import `hand`. */
|
|
58
|
+
hand(side, shape, e) { hand(this, side, shape, e); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
|
|
62
|
+
const envelope = (t, a = 0.2, b = 0.2) => (t < a ? ease(t / a) : t > 1 - b ? ease((1 - t) / b) : 1);
|
|
63
|
+
|
|
64
|
+
// The gestures a colleague uses at work: hello and goodbye, agreeing, thinking it over, explaining,
|
|
65
|
+
// pointing the way, an idea, a shrug, well done. Each adds rotations for t in [0, 1].
|
|
66
|
+
export const GESTURES = {
|
|
67
|
+
wave: { duration: 2.4, pose(t, p) {
|
|
68
|
+
const e = envelope(t, 0.18, 0.2);
|
|
69
|
+
p.add('rightUpperArm', 0, -0.25 * e, -1.4 * e);
|
|
70
|
+
p.add('rightLowerArm', 0, 0.3 * e, -1.2 * e + Math.sin(t * Math.PI * 10) * 0.35 * e);
|
|
71
|
+
p.add('rightHand', 0, 0, Math.sin(t * Math.PI * 10 + 0.6) * 0.2 * e);
|
|
72
|
+
p.add('head', 0, -0.12 * e, 0.08 * e);
|
|
73
|
+
hand(p, 'right', 'open', e);
|
|
74
|
+
}, face: (t) => ({ happy: envelope(t) }) },
|
|
75
|
+
nod: { duration: 1.4, pose(t, p) {
|
|
76
|
+
const e = envelope(t, 0.1, 0.25);
|
|
77
|
+
p.add('head', Math.max(0, Math.sin(t * Math.PI * 4)) * 0.28 * e, 0, 0);
|
|
78
|
+
p.add('neck', Math.max(0, Math.sin(t * Math.PI * 4)) * 0.1 * e, 0, 0);
|
|
79
|
+
} },
|
|
80
|
+
think: { duration: 3, pose(t, p) {
|
|
81
|
+
const e = envelope(t, 0.2, 0.2);
|
|
82
|
+
p.add('rightUpperArm', 0, 0.9 * e, -0.25 * e);
|
|
83
|
+
p.add('rightLowerArm', 0, 1.9 * e, -0.2 * e);
|
|
84
|
+
p.add('rightHand', 0, 0, -0.3 * e);
|
|
85
|
+
hand(p, 'right', 'fist', 0.5 * e);
|
|
86
|
+
p.add('head', -0.15 * e, -0.15 * e, 0.12 * e + Math.sin(t * Math.PI * 3) * 0.03 * e);
|
|
87
|
+
} },
|
|
88
|
+
explain: { duration: 4, pose(t, p) {
|
|
89
|
+
const e = envelope(t, 0.12, 0.2);
|
|
90
|
+
const a = t * Math.PI * 4;
|
|
91
|
+
p.add('leftUpperArm', 0, -0.5 * e, 0.35 * e);
|
|
92
|
+
p.add('rightUpperArm', 0, 0.5 * e, -0.35 * e);
|
|
93
|
+
p.add('leftLowerArm', 0, -(1.0 + 0.35 * Math.sin(a)) * e, 0);
|
|
94
|
+
p.add('rightLowerArm', 0, (1.0 + 0.35 * Math.sin(a + Math.PI)) * e, 0);
|
|
95
|
+
hand(p, 'left', 'open', 0.6 * e);
|
|
96
|
+
hand(p, 'right', 'open', 0.6 * e);
|
|
97
|
+
p.add('head', 0.06 * Math.sin(a * 1.5) * e, 0, 0);
|
|
98
|
+
}, face: (t) => ({ happy: 0.4 * envelope(t) }) },
|
|
99
|
+
point: { duration: 2, pose(t, p) {
|
|
100
|
+
const e = envelope(t, 0.2, 0.25);
|
|
101
|
+
p.add('rightUpperArm', 0, 1.1 * e, -1.0 * e);
|
|
102
|
+
p.add('rightLowerArm', 0, -0.2 * e, 0);
|
|
103
|
+
hand(p, 'right', 'point', e);
|
|
104
|
+
p.add('head', 0, -0.1 * e, 0);
|
|
105
|
+
} },
|
|
106
|
+
idea: { duration: 2.2, pose(t, p) {
|
|
107
|
+
const e = envelope(t, 0.15, 0.25);
|
|
108
|
+
p.add('rightUpperArm', 0, 0.1 * e, -2.6 * e);
|
|
109
|
+
p.add('rightLowerArm', 0, 0, -0.3 * e);
|
|
110
|
+
hand(p, 'right', 'point', e);
|
|
111
|
+
p.add('head', -0.2 * e, 0, 0.08 * e);
|
|
112
|
+
}, face: (t) => ({ happy: 0.8 * envelope(t), surprised: 0.25 * envelope(t) }) },
|
|
113
|
+
shrug: { duration: 1.8, pose(t, p) {
|
|
114
|
+
const e = envelope(t, 0.25, 0.3);
|
|
115
|
+
p.add('leftUpperArm', 0, -0.2 * e, 0.45 * e);
|
|
116
|
+
p.add('rightUpperArm', 0, 0.2 * e, -0.45 * e);
|
|
117
|
+
p.add('leftLowerArm', 0, -1.3 * e, 0.2 * e);
|
|
118
|
+
p.add('rightLowerArm', 0, 1.3 * e, -0.2 * e);
|
|
119
|
+
hand(p, 'left', 'open', e);
|
|
120
|
+
hand(p, 'right', 'open', e);
|
|
121
|
+
p.add('head', 0, 0, 0.18 * e);
|
|
122
|
+
} },
|
|
123
|
+
thumbsup: { duration: 1.8, pose(t, p) {
|
|
124
|
+
const e = envelope(t, 0.15, 0.25);
|
|
125
|
+
p.add('rightUpperArm', 0, 0.9 * e, -0.6 * e);
|
|
126
|
+
p.add('rightLowerArm', 0, 0, -1.2 * e);
|
|
127
|
+
hand(p, 'right', 'thumbsup', e);
|
|
128
|
+
p.add('head', 0, 0, 0.1 * e);
|
|
129
|
+
}, face: (t) => ({ happy: 0.8 * envelope(t) }) },
|
|
130
|
+
cheer: { duration: 2.2, pose(t, p, o) {
|
|
131
|
+
const e = envelope(t, 0.15, 0.2);
|
|
132
|
+
hand(p, 'left', 'fist', e);
|
|
133
|
+
hand(p, 'right', 'fist', e);
|
|
134
|
+
const pump = Math.sin(t * Math.PI * 8) * 0.15 * e;
|
|
135
|
+
p.add('leftUpperArm', 0, 0, 2.3 * e + pump);
|
|
136
|
+
p.add('rightUpperArm', 0, 0, -2.3 * e - pump);
|
|
137
|
+
p.add('leftLowerArm', 0, 0, 0.3 * e);
|
|
138
|
+
p.add('rightLowerArm', 0, 0, -0.3 * e);
|
|
139
|
+
p.add('head', -0.15 * e, 0, 0);
|
|
140
|
+
o.hipsY += Math.abs(Math.sin(t * Math.PI * 4)) * 0.06 * e;
|
|
141
|
+
}, face: () => ({ happy: 1 }) },
|
|
142
|
+
clap: { duration: 2, pose(t, p) {
|
|
143
|
+
const e = envelope(t, 0.15, 0.2);
|
|
144
|
+
const c = (Math.sin(t * Math.PI * 12) * 0.5 + 0.5) * e;
|
|
145
|
+
p.add('leftUpperArm', 0, -0.9 * e - 0.15 * c, 0.55 * e);
|
|
146
|
+
p.add('rightUpperArm', 0, 0.9 * e + 0.15 * c, -0.55 * e);
|
|
147
|
+
p.add('leftLowerArm', 0, -0.9 * e, 0);
|
|
148
|
+
p.add('rightLowerArm', 0, 0.9 * e, 0);
|
|
149
|
+
hand(p, 'left', 'open', e);
|
|
150
|
+
hand(p, 'right', 'open', e);
|
|
151
|
+
}, face: () => ({ happy: 0.8 }) },
|
|
152
|
+
crossarms: { duration: 3, pose(t, p) {
|
|
153
|
+
const e = envelope(t, 0.2, 0.2);
|
|
154
|
+
p.add('leftUpperArm', 0, -1.0 * e, 0.45 * e);
|
|
155
|
+
p.add('rightUpperArm', 0, 1.0 * e, -0.35 * e);
|
|
156
|
+
p.add('leftLowerArm', 0, -2.1 * e, 0);
|
|
157
|
+
p.add('rightLowerArm', 0, 2.0 * e, 0);
|
|
158
|
+
p.add('head', 0, 0.12 * e, 0.06 * e);
|
|
159
|
+
}, face: (t) => ({ relaxed: 0.3 * envelope(t) }) },
|
|
160
|
+
look: { duration: 3, pose(t, p) {
|
|
161
|
+
const e = envelope(t, 0.1, 0.2);
|
|
162
|
+
const yaw = Math.sin(t * Math.PI * 2) * 0.75 * e;
|
|
163
|
+
p.add('head', 0, yaw, 0);
|
|
164
|
+
p.add('neck', 0, 0.3 * yaw, 0);
|
|
165
|
+
p.add('rightUpperArm', 0, 0.7 * e, -1.6 * e);
|
|
166
|
+
p.add('rightLowerArm', 0, 1.9 * e, 0);
|
|
167
|
+
hand(p, 'right', 'open', e);
|
|
168
|
+
}, face: (t) => ({ surprised: 0.2 * envelope(t) }) },
|
|
169
|
+
// Flying from one place on the page to another: arms out like wings, leaning into it, bobbing.
|
|
170
|
+
fly: { duration: 1.4, pose(t, p, o) {
|
|
171
|
+
const e = envelope(t, 0.2, 0.25);
|
|
172
|
+
const flap = Math.sin(t * Math.PI * 6) * 0.25 * e;
|
|
173
|
+
p.add('leftUpperArm', 0, 0, 1.0 * e + flap);
|
|
174
|
+
p.add('rightUpperArm', 0, 0, -1.0 * e - flap);
|
|
175
|
+
hand(p, 'left', 'open', e);
|
|
176
|
+
hand(p, 'right', 'open', e);
|
|
177
|
+
p.add('spine', 0.18 * e, 0, 0);
|
|
178
|
+
p.add('leftUpperLeg', -0.25 * e, 0, 0);
|
|
179
|
+
p.add('rightUpperLeg', 0.1 * e, 0, 0);
|
|
180
|
+
o.hipsY += Math.sin(t * Math.PI) * 0.08 * e;
|
|
181
|
+
}, face: (t) => ({ happy: 0.7 * envelope(t) }) },
|
|
182
|
+
bye: { duration: 2.4, pose(t, p) {
|
|
183
|
+
const e = envelope(t, 0.15, 0.2);
|
|
184
|
+
const w = Math.sin(t * Math.PI * 10) * 0.35 * e;
|
|
185
|
+
p.add('leftUpperArm', 0, 0.25 * e, 1.4 * e);
|
|
186
|
+
p.add('rightUpperArm', 0, -0.25 * e, -1.4 * e);
|
|
187
|
+
p.add('leftLowerArm', 0, -0.3 * e, 1.0 * e + w);
|
|
188
|
+
p.add('rightLowerArm', 0, 0.3 * e, -1.0 * e - w);
|
|
189
|
+
hand(p, 'left', 'open', e);
|
|
190
|
+
hand(p, 'right', 'open', e);
|
|
191
|
+
}, face: (t) => ({ happy: envelope(t) }) },
|
|
192
|
+
// For the agent's statuses while it works, as the Claude Code CLI names them (assistant-avatar-ui.js,
|
|
193
|
+
// STATUSES): tempering is hammering on an anvil, brewing is stirring, coding is typing, searching is a
|
|
194
|
+
// hand at the brow looking left and right.
|
|
195
|
+
hammer: { duration: 2, pose(t, p, o) {
|
|
196
|
+
const e = envelope(t, 0.12, 0.15);
|
|
197
|
+
const up = Math.max(0, Math.sin(t * Math.PI * 6));
|
|
198
|
+
p.add('rightUpperArm', 0, 0.7 * e, -(0.35 + 0.95 * up) * e);
|
|
199
|
+
p.add('rightLowerArm', 0, (0.5 + 0.9 * up) * e, 0);
|
|
200
|
+
hand(p, 'right', 'fist', e);
|
|
201
|
+
p.add('leftUpperArm', 0, -0.7 * e, 0.2 * e);
|
|
202
|
+
p.add('leftLowerArm', 0, -0.9 * e, 0);
|
|
203
|
+
hand(p, 'left', 'fist', 0.6 * e);
|
|
204
|
+
p.add('head', 0.18 * e, 0, 0);
|
|
205
|
+
p.add('spine', 0.08 * e + 0.04 * (1 - up) * e, 0, 0);
|
|
206
|
+
o.hipsY -= 0.012 * (1 - up) * e;
|
|
207
|
+
}, face: (t) => ({ happy: 0.3 * envelope(t), relaxed: 0.3 }) },
|
|
208
|
+
stir: { duration: 2.4, pose(t, p) {
|
|
209
|
+
const e = envelope(t, 0.15, 0.15);
|
|
210
|
+
const a = t * Math.PI * 6;
|
|
211
|
+
p.add('rightUpperArm', 0, (0.8 + 0.15 * Math.sin(a)) * e, (-0.3 + 0.15 * Math.cos(a)) * e);
|
|
212
|
+
p.add('rightLowerArm', 0, 1.2 * e, 0);
|
|
213
|
+
hand(p, 'right', 'fist', e);
|
|
214
|
+
p.add('leftUpperArm', 0, -0.5 * e, 0.15 * e);
|
|
215
|
+
p.add('leftLowerArm', 0, -0.8 * e, 0);
|
|
216
|
+
p.add('head', 0.15 * e, 0.08 * Math.sin(a / 3) * e, 0.05 * e);
|
|
217
|
+
}, face: (t) => ({ happy: 0.5 * envelope(t) }) },
|
|
218
|
+
type: { duration: 2, pose(t, p) {
|
|
219
|
+
const e = envelope(t, 0.15, 0.15);
|
|
220
|
+
const tap = (k) => Math.max(0, Math.sin(t * Math.PI * 14 + k)) * 0.12 * e;
|
|
221
|
+
p.add('leftUpperArm', 0, -0.55 * e, 0.25 * e);
|
|
222
|
+
p.add('rightUpperArm', 0, 0.55 * e, -0.25 * e);
|
|
223
|
+
p.add('leftLowerArm', 0, -1.35 * e, -tap(0));
|
|
224
|
+
p.add('rightLowerArm', 0, 1.35 * e, tap(Math.PI));
|
|
225
|
+
hand(p, 'left', 'open', 0.5 * e);
|
|
226
|
+
hand(p, 'right', 'open', 0.5 * e);
|
|
227
|
+
p.add('head', 0.2 * e, 0, 0);
|
|
228
|
+
} },
|
|
229
|
+
scan: { duration: 2.4, pose(t, p) {
|
|
230
|
+
const e = envelope(t, 0.15, 0.2);
|
|
231
|
+
p.add('rightUpperArm', 0, 0.9 * e, -1.25 * e);
|
|
232
|
+
p.add('rightLowerArm', 0, 1.9 * e, 0);
|
|
233
|
+
hand(p, 'right', 'open', e);
|
|
234
|
+
p.add('head', -0.06 * e, Math.sin(t * Math.PI * 2) * 0.5 * e, 0);
|
|
235
|
+
p.add('spine', 0, Math.sin(t * Math.PI * 2) * 0.12 * e, 0);
|
|
236
|
+
}, face: (t) => ({ surprised: 0.15 * envelope(t) }) },
|
|
237
|
+
// For the big moves, where the whole figure travels the page (assistant-avatar-ui.js): a stride, a
|
|
238
|
+
// full turn, and a crouch-and-spring.
|
|
239
|
+
walk: { duration: 1.1, pose(t, p, o) {
|
|
240
|
+
const e = envelope(t, 0.12, 0.12);
|
|
241
|
+
const s = Math.sin(t * Math.PI * 2);
|
|
242
|
+
p.add('leftUpperLeg', -0.45 * s * e, 0, 0);
|
|
243
|
+
p.add('rightUpperLeg', 0.45 * s * e, 0, 0);
|
|
244
|
+
p.add('leftLowerLeg', 0.5 * Math.max(0, s) * e, 0, 0);
|
|
245
|
+
p.add('rightLowerLeg', 0.5 * Math.max(0, -s) * e, 0, 0);
|
|
246
|
+
p.add('leftUpperArm', 0.35 * s * e, 0, 0);
|
|
247
|
+
p.add('rightUpperArm', -0.35 * s * e, 0, 0);
|
|
248
|
+
p.add('spine', 0.05 * e, 0.06 * s * e, 0);
|
|
249
|
+
o.hipsY += Math.abs(Math.cos(t * Math.PI * 2)) * 0.02 * e;
|
|
250
|
+
} },
|
|
251
|
+
spin: { duration: 1.6, pose(t, p, o) {
|
|
252
|
+
const e = envelope(t, 0.15, 0.15);
|
|
253
|
+
p.add('hips', 0, Math.PI * 2 * ease(t), 0);
|
|
254
|
+
p.add('leftUpperArm', 0, 0, 0.7 * e);
|
|
255
|
+
p.add('rightUpperArm', 0, 0, -0.7 * e);
|
|
256
|
+
hand(p, 'left', 'open', e);
|
|
257
|
+
hand(p, 'right', 'open', e);
|
|
258
|
+
o.hipsY += Math.sin(t * Math.PI) * 0.05;
|
|
259
|
+
}, face: (t) => ({ happy: envelope(t) }) },
|
|
260
|
+
leap: { duration: 1.3, pose(t, p, o) {
|
|
261
|
+
const crouch = t < 0.25 ? ease(t / 0.25) : t < 0.35 ? 1 - ease((t - 0.25) / 0.1) : t > 0.85 ? Math.sin(((t - 0.85) / 0.15) * Math.PI) : 0;
|
|
262
|
+
const up = t > 0.25 && t < 0.9 ? Math.sin(((t - 0.25) / 0.65) * Math.PI) : 0;
|
|
263
|
+
p.add('leftUpperLeg', -0.6 * crouch + 0.2 * up, 0, 0);
|
|
264
|
+
p.add('rightUpperLeg', -0.6 * crouch - 0.3 * up, 0, 0);
|
|
265
|
+
p.add('leftLowerLeg', 1.0 * crouch + 0.4 * up, 0, 0);
|
|
266
|
+
p.add('rightLowerLeg', 1.0 * crouch + 0.6 * up, 0, 0);
|
|
267
|
+
p.add('leftUpperArm', 0, 0, 2.2 * up);
|
|
268
|
+
p.add('rightUpperArm', 0, 0, -2.2 * up);
|
|
269
|
+
hand(p, 'left', 'open', up);
|
|
270
|
+
hand(p, 'right', 'open', up);
|
|
271
|
+
p.add('spine', 0.25 * crouch - 0.1 * up, 0, 0);
|
|
272
|
+
o.hipsY += -0.07 * crouch;
|
|
273
|
+
}, face: (t) => ({ happy: envelope(t), surprised: 0.3 * envelope(t, 0.3, 0.5) }) },
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// ---- Stage extensions: what can sit beside the avatar (a board, a video …) -----------------------
|
|
277
|
+
//
|
|
278
|
+
// registerStageExtension({ id, label, icon, open(stage, avatar), close() }) adds a button to the stage's
|
|
279
|
+
// own tool row; open() receives the stage element to draw into and the avatar to gesture with.
|
|
280
|
+
const EXTENSIONS = new Map();
|
|
281
|
+
export function registerStageExtension(ext) {
|
|
282
|
+
if (!ext || !/^[a-z0-9-]{1,40}$/.test(String(ext.id || '')) || typeof ext.open !== 'function') return false;
|
|
283
|
+
EXTENSIONS.set(ext.id, ext);
|
|
284
|
+
document.dispatchEvent(new CustomEvent('ws-avatar-extensions'));
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
export const stageExtensions = () => [...EXTENSIONS.values()];
|
|
288
|
+
|
|
289
|
+
// ---- The avatar ---------------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
const VOWELS = { a: 'aa', e: 'ee', i: 'ih', o: 'oh', u: 'ou', y: 'ih' };
|
|
292
|
+
|
|
293
|
+
// `box()`, when given, returns the rectangle on the page where the figure stands (the card you drag):
|
|
294
|
+
// the canvas then covers the whole window and the figure is drawn in that rectangle, so its moves reach
|
|
295
|
+
// anywhere on the page with no edge to cut them. Without it the figure fills the canvas.
|
|
296
|
+
// `look`: '2d' (flat: no perspective and an even light, a cartoon drawing, the default) or '3d' (perspective, lit).
|
|
297
|
+
export async function mountAvatar(canvas, { model = '/avatar/druplicon.vrm', moves = '', hair = '', box: boxOf = null, look: lookAs = '2d' } = {}) {
|
|
298
|
+
// The shared moves, and this avatar's own where it has a set: its own win where the names meet.
|
|
299
|
+
const gestures = { ...GESTURES, ...(MOVE_SETS[moves] || {}) };
|
|
300
|
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
301
|
+
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
|
302
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
303
|
+
const scene = new THREE.Scene();
|
|
304
|
+
const hemi = new THREE.HemisphereLight(0xffffff, 0x8899aa, 1.6);
|
|
305
|
+
scene.add(hemi);
|
|
306
|
+
const key = new THREE.DirectionalLight(0xffffff, 2.0);
|
|
307
|
+
key.position.set(1.2, 2.5, 2.5);
|
|
308
|
+
scene.add(key);
|
|
309
|
+
// A rim light from behind, cool and faintly brand-tinted, so the figure's edge stands out from the page.
|
|
310
|
+
const rim = new THREE.DirectionalLight(0xdeedff, 1.4);
|
|
311
|
+
rim.position.set(-1.5, 2.2, -2.5);
|
|
312
|
+
scene.add(rim);
|
|
313
|
+
// 2D: one even light, so every colour reads flat, as drawn.
|
|
314
|
+
// As bright as the 3D look's lights together, so a colour reads the same in 2D, not dimmed.
|
|
315
|
+
const even = new THREE.AmbientLight(0xffffff, 2.3);
|
|
316
|
+
scene.add(even);
|
|
317
|
+
const camera = new THREE.PerspectiveCamera(26, 1, 0.1, 30);
|
|
318
|
+
const flatCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 30);
|
|
319
|
+
let flat = false;
|
|
320
|
+
function setLook(l) {
|
|
321
|
+
flat = l === '2d';
|
|
322
|
+
hemi.visible = !flat; key.visible = !flat; rim.visible = !flat; even.visible = flat;
|
|
323
|
+
framed = '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const loader = new GLTFLoader();
|
|
327
|
+
loader.register((parser) => new VRMLoaderPlugin(parser));
|
|
328
|
+
const gltf = await loader.loadAsync(model);
|
|
329
|
+
const vrm = gltf.userData.vrm;
|
|
330
|
+
VRMUtils.removeUnnecessaryVertices(gltf.scene);
|
|
331
|
+
VRMUtils.rotateVRM0(vrm);
|
|
332
|
+
vrm.scene.traverse((o) => { o.frustumCulled = false; });
|
|
333
|
+
scene.add(vrm.scene);
|
|
334
|
+
const bones = {};
|
|
335
|
+
for (const b of BONES) bones[b] = vrm.humanoid.getNormalizedBoneNode(b);
|
|
336
|
+
// A VRM 0 model (most of the open-source avatars) turns the other way about x and z: the same moves,
|
|
337
|
+
// mirrored back, so its arms come down at rest instead of going up.
|
|
338
|
+
const flip = vrm.meta && vrm.meta.metaVersion === '0' ? -1 : 1;
|
|
339
|
+
const hipsBase = bones.hips ? bones.hips.position.clone() : new THREE.Vector3();
|
|
340
|
+
// A clock face keeps the real time: a model's `HourHand`, `MinuteHand` and `SecondHand` (the Druplicon's)
|
|
341
|
+
// are built pointing to twelve and turned here, clockwise as the viewer sees them.
|
|
342
|
+
const clockHands = ['HourHand', 'MinuteHand', 'SecondHand'].map((n) => vrm.scene.getObjectByName(n));
|
|
343
|
+
const em = vrm.expressionManager;
|
|
344
|
+
// Colours: the model's materials in parts, by name (every hair material is one part, "Hair"), each with its
|
|
345
|
+
// colour as it came, to go back to. Outlines, seams and the face (drawn in vertex colours) are not parts.
|
|
346
|
+
const parts = new Map(); // part -> [{ m, color, emissive }]
|
|
347
|
+
const partOf = (m) => {
|
|
348
|
+
const name = String(m.name || '');
|
|
349
|
+
if (!m.color || m.vertexColors || /outline|seam|^face$/i.test(name)) return '';
|
|
350
|
+
if (/hair/i.test(name)) return 'Hair';
|
|
351
|
+
return name.replace(/[_.\-]?\d+$/, '').replace(/[_.-]+/g, ' ').trim().slice(0, 24) || 'Body';
|
|
352
|
+
};
|
|
353
|
+
vrm.scene.traverse((o) => {
|
|
354
|
+
for (const m of [].concat(o.material || [])) {
|
|
355
|
+
const part = partOf(m);
|
|
356
|
+
if (!part) continue;
|
|
357
|
+
const list = parts.get(part) || [];
|
|
358
|
+
if (!list.some((x) => x.m === m)) list.push({ m, color: m.color.clone(), emissive: m.emissive ? m.emissive.clone() : null });
|
|
359
|
+
parts.set(part, list);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
const lum = (x) => 0.3 * x.r + 0.59 * x.g + 0.11 * x.b;
|
|
363
|
+
/** Colour a part (#rrggbb); anything else puts it back as the model came. Each material of the part keeps
|
|
364
|
+
* how much lighter or deeper it was than the first (a hair fall stays deeper than the cap). */
|
|
365
|
+
function setColour(part, hex) {
|
|
366
|
+
const list = parts.get(part) || [];
|
|
367
|
+
const ok = /^#[0-9a-f]{6}$/i.test(String(hex || ''));
|
|
368
|
+
for (const x of list) {
|
|
369
|
+
if (!ok) { x.m.color.copy(x.color); if (x.emissive) x.m.emissive.copy(x.emissive); continue; }
|
|
370
|
+
const c = new THREE.Color(hex);
|
|
371
|
+
const k = Math.min(1.4, lum(x.color) / Math.max(0.05, lum(list[0].color)));
|
|
372
|
+
// A part lit by its own glow (most of the Druplicon's) takes the colour as its glow, and a deeper body
|
|
373
|
+
// colour under it, so it reads as the colour picked.
|
|
374
|
+
const glow = x.emissive && lum(x.emissive) > 0;
|
|
375
|
+
x.m.color.copy(c).multiplyScalar(k * (glow ? 0.8 : 1));
|
|
376
|
+
if (glow) x.m.emissive.copy(c).multiplyScalar(k);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** The parts and the colour each shows now, as #rrggbb. */
|
|
380
|
+
const colourParts = () => [...parts.entries()].map(([part, list]) => {
|
|
381
|
+
const x = list[0]; const glow = x.m.emissive && lum(x.m.emissive) > 0;
|
|
382
|
+
return { part, hex: `#${(glow ? x.m.emissive : x.m.color).getHexString()}` };
|
|
383
|
+
});
|
|
384
|
+
const setHair = (hex) => setColour('Hair', hex);
|
|
385
|
+
if (hair) setHair(hair);
|
|
386
|
+
vrm.update(0);
|
|
387
|
+
|
|
388
|
+
// Frame the whole figure, from its shoes to the tip of the drop.
|
|
389
|
+
const box = new THREE.Box3().setFromObject(vrm.scene);
|
|
390
|
+
const height = box.max.y - box.min.y;
|
|
391
|
+
const aim = new THREE.Vector3(0, box.min.y + height * 0.5, 0);
|
|
392
|
+
// The figure's rectangle, and the last one it was framed for.
|
|
393
|
+
const frameRect = () => (boxOf ? boxOf() : canvas.getBoundingClientRect());
|
|
394
|
+
let framed = '';
|
|
395
|
+
function resize() {
|
|
396
|
+
const w = canvas.clientWidth || 1; const h = canvas.clientHeight || 1;
|
|
397
|
+
renderer.setSize(w, h, false);
|
|
398
|
+
framed = '';
|
|
399
|
+
place();
|
|
400
|
+
}
|
|
401
|
+
// Framed for the rectangle: far enough that the figure fits its shorter side, with a little room. With a
|
|
402
|
+
// whole-window canvas, a view offset then draws that framing at the rectangle's place on the page.
|
|
403
|
+
function place() {
|
|
404
|
+
const r = frameRect(); const cw = canvas.clientWidth || 1; const ch = canvas.clientHeight || 1;
|
|
405
|
+
const key = `${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)},${cw},${ch}`;
|
|
406
|
+
if (key === framed) return;
|
|
407
|
+
framed = key;
|
|
408
|
+
const bw = Math.max(1, r.width); const bh = Math.max(1, r.height);
|
|
409
|
+
camera.aspect = bw / bh;
|
|
410
|
+
const fitH = (height * 0.62) / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
|
|
411
|
+
const fitW = fitH / Math.min(1, camera.aspect * 1.4);
|
|
412
|
+
camera.position.set(0, aim.y + height * 0.06, Math.max(fitH, fitW));
|
|
413
|
+
camera.lookAt(aim);
|
|
414
|
+
// The flat camera frames the same: the figure's height and a little room, wider when the box is narrow.
|
|
415
|
+
const halfH = (height * 0.62) / Math.min(1, camera.aspect * 1.4);
|
|
416
|
+
flatCam.top = halfH; flatCam.bottom = -halfH; flatCam.left = -halfH * camera.aspect; flatCam.right = halfH * camera.aspect;
|
|
417
|
+
flatCam.position.copy(camera.position); flatCam.lookAt(aim);
|
|
418
|
+
for (const cam of [camera, flatCam]) {
|
|
419
|
+
if (boxOf) {
|
|
420
|
+
const c = canvas.getBoundingClientRect();
|
|
421
|
+
cam.setViewOffset(bw, bh, c.left - r.left, c.top - r.top, cw, ch);
|
|
422
|
+
} else cam.clearViewOffset();
|
|
423
|
+
cam.updateProjectionMatrix();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const sizer = new ResizeObserver(resize);
|
|
427
|
+
sizer.observe(canvas);
|
|
428
|
+
resize();
|
|
429
|
+
setLook(lookAs);
|
|
430
|
+
|
|
431
|
+
const look = new THREE.Vector2(); const lookTo = new THREE.Vector2();
|
|
432
|
+
const onPointer = (e) => {
|
|
433
|
+
const r = frameRect();
|
|
434
|
+
lookTo.set(THREE.MathUtils.clamp(((e.clientX - r.left) / Math.max(1, r.width)) * 2 - 1, -1, 1),
|
|
435
|
+
THREE.MathUtils.clamp(((e.clientY - r.top) / Math.max(1, r.height)) * 2 - 1, -1, 1));
|
|
436
|
+
};
|
|
437
|
+
window.addEventListener('pointermove', onPointer);
|
|
438
|
+
|
|
439
|
+
const pose = new Pose();
|
|
440
|
+
const timer = new THREE.Timer();
|
|
441
|
+
let active = []; // gestures playing: { name, start }
|
|
442
|
+
let thinking = false; let nextThink = 0;
|
|
443
|
+
let speaking = false; let mouth = 0; let vowel = 'aa'; let mouthUntil = 0;
|
|
444
|
+
let blink = 0; let nextBlink = 1.5;
|
|
445
|
+
let running = true;
|
|
446
|
+
const expr = (name, v) => { if (em && em.getExpression(name)) em.setValue(name, v); };
|
|
447
|
+
|
|
448
|
+
function frame() {
|
|
449
|
+
if (!running) return;
|
|
450
|
+
timer.update();
|
|
451
|
+
const dt = Math.min(0.05, timer.getDelta());
|
|
452
|
+
const t = timer.getElapsed();
|
|
453
|
+
pose.reset();
|
|
454
|
+
const face = { happy: 0.25 };
|
|
455
|
+
|
|
456
|
+
// Idle: breathing, a slow sway, the eyes on the pointer.
|
|
457
|
+
const breath = Math.sin(t * 1.6);
|
|
458
|
+
pose.add('spine', breath * 0.012, 0, Math.sin(t * 0.45) * 0.015);
|
|
459
|
+
pose.add('head', 0.02 * Math.sin(t * 0.7) + look.y * 0.12, look.x * 0.25, Math.sin(t * 0.5) * 0.02);
|
|
460
|
+
look.lerp(lookTo, 1 - Math.exp(-dt * 3));
|
|
461
|
+
const o = { hipsX: 0, hipsY: breath * 0.004, hipsZ: 0, mouth: 0, clockSpin: 0 }; // clockSpin: extra turns of a clock face's hands
|
|
462
|
+
|
|
463
|
+
// Thinking while a reply is on its way: the think pose, again and again.
|
|
464
|
+
if (thinking && t > nextThink && !active.some((g) => g.name === 'think')) { active.push({ name: 'think', start: t }); nextThink = t + 3.6; }
|
|
465
|
+
active = active.filter((g) => {
|
|
466
|
+
const def = gestures[g.name];
|
|
467
|
+
const k = (t - g.start) / def.duration;
|
|
468
|
+
if (k >= 1) return false;
|
|
469
|
+
def.pose(Math.max(0, k), pose, o);
|
|
470
|
+
if (def.face) Object.assign(face, def.face(k));
|
|
471
|
+
return true;
|
|
472
|
+
});
|
|
473
|
+
for (const b of BONES) { const n = bones[b]; if (n) { const r = pose.r[b]; n.rotation.set(r.x * flip, r.y, r.z * flip); } }
|
|
474
|
+
if (bones.hips) bones.hips.position.set(hipsBase.x + o.hipsX, hipsBase.y + o.hipsY, hipsBase.z + o.hipsZ);
|
|
475
|
+
if (clockHands[0] || clockHands[1] || clockHands[2]) {
|
|
476
|
+
const now = new Date(); const sec = now.getSeconds() + now.getMilliseconds() / 1000; const min = now.getMinutes() + sec / 60;
|
|
477
|
+
const turns = [((now.getHours() % 12) + min / 60) / 12, min / 60, sec / 60];
|
|
478
|
+
clockHands.forEach((h, i) => { if (h) h.rotation.z = -(turns[i] + o.clockSpin * (i === 0 ? 1 / 12 : 1)) * Math.PI * 2; });
|
|
479
|
+
}
|
|
480
|
+
if (o.mouth > mouth) { mouth = Math.min(1, o.mouth); vowel = 'aa'; }
|
|
481
|
+
|
|
482
|
+
// The face: a blink now and then, and the mouth with the words being spoken.
|
|
483
|
+
if (t > nextBlink) { blink = 1; nextBlink = t + 2 + Math.random() * 3.5; }
|
|
484
|
+
blink = Math.max(0, blink - dt * 7);
|
|
485
|
+
if (speaking && t < mouthUntil) mouth = Math.min(1, mouth + dt * 14); else mouth = Math.max(0, mouth - dt * 10);
|
|
486
|
+
for (const v of ['aa', 'ee', 'ih', 'oh', 'ou']) expr(v, v === vowel ? mouth * 0.75 : 0);
|
|
487
|
+
for (const [k, v] of Object.entries(face)) expr(k, Math.min(1, v));
|
|
488
|
+
expr('blink', blink > 0.5 ? 1 : blink * 2);
|
|
489
|
+
expr('lookLeft', Math.max(0, look.x)); expr('lookRight', Math.max(0, -look.x));
|
|
490
|
+
expr('lookUp', Math.max(0, -look.y)); expr('lookDown', Math.max(0, look.y));
|
|
491
|
+
|
|
492
|
+
vrm.update(dt);
|
|
493
|
+
if (boxOf) place(); // the card flies, resizes and is dragged: the figure follows it
|
|
494
|
+
renderer.render(scene, flat ? flatCam : camera);
|
|
495
|
+
}
|
|
496
|
+
renderer.setAnimationLoop(frame);
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
/** Play a gesture by name; unknown names are ignored. */
|
|
500
|
+
gesture(name) { if (gestures[name] && running) active.push({ name, start: timer.getElapsed() }); },
|
|
501
|
+
/** '3d' or '2d'. */
|
|
502
|
+
setLook,
|
|
503
|
+
/** Whether the model has hair that can take a colour. */
|
|
504
|
+
hasHair: parts.has('Hair'),
|
|
505
|
+
/** Colour the hair (#rrggbb), or put it back as the model came with ''. */
|
|
506
|
+
setHair,
|
|
507
|
+
/** Colour any part (#rrggbb, or '' for the model's own), and list the parts with their colours now. */
|
|
508
|
+
setColour,
|
|
509
|
+
colourParts,
|
|
510
|
+
/** True from a message being sent until its reply arrives. */
|
|
511
|
+
setThinking(on) { thinking = Boolean(on); if (on) nextThink = 0; },
|
|
512
|
+
/** The voice started or stopped. */
|
|
513
|
+
setSpeaking(on) { speaking = Boolean(on); if (!on) mouthUntil = 0; },
|
|
514
|
+
/** A word is being spoken: open the mouth on its first vowel for about the word's length. */
|
|
515
|
+
word(text) {
|
|
516
|
+
const w = String(text || '').toLowerCase();
|
|
517
|
+
const v = (w.match(/[aeiouy]/) || ['a'])[0];
|
|
518
|
+
vowel = VOWELS[v] || 'aa';
|
|
519
|
+
mouthUntil = timer.getElapsed() + Math.min(0.45, 0.09 + w.length * 0.045);
|
|
520
|
+
},
|
|
521
|
+
dispose() {
|
|
522
|
+
running = false;
|
|
523
|
+
renderer.setAnimationLoop(null);
|
|
524
|
+
sizer.disconnect();
|
|
525
|
+
window.removeEventListener('pointermove', onPointer);
|
|
526
|
+
VRMUtils.deepDispose(vrm.scene);
|
|
527
|
+
renderer.dispose();
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
Binary file
|