diff --git a/README.md b/README.md
index d4701b0d..a431ca12 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ npm install @readyplayerme/visage
Make sure to install peer-dependencies if your project doesn't already include them:
```sh
-npm install @react-three/drei @react-three/fiber three three-stdlib
+npm install @react-three/drei @react-three/fiber three three-stdlib suspend-react
```
# Documentation & examples
@@ -25,11 +25,11 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { Exhibit } from '@readyplayerme/visage';
-const modelUrl = 'https://readyplayerme.github.io/visage/male.glb'; // this can be a relative or absolute URL
+const modelSrc = 'https://readyplayerme.github.io/visage/male.glb'; // this can be a relative or absolute URL
function App() {
return (
-
+
);
}
diff --git a/package-lock.json b/package-lock.json
index 54734ca2..37edd048 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -56,6 +56,7 @@
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-terser": "^7.0.2",
+ "suspend-react": "0.0.8",
"three": "^0.138.3",
"three-stdlib": "^2.8.8",
"typescript": "^4.6.2",
@@ -71,6 +72,7 @@
"@react-three/fiber": ">=7.0",
"react": ">=17.0",
"react-dom": ">=17.0",
+ "suspend-react": "0.0.8",
"three": ">=0.138",
"three-stdlib": ">=2.8"
}
@@ -4149,6 +4151,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
"integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "deprecated": "This functionality has been moved to @npmcli/fs",
"dev": true,
"dependencies": {
"mkdirp": "^1.0.4",
@@ -10429,6 +10432,25 @@
"react-scripts": ">=3.0.0"
}
},
+ "node_modules/@storybook/preset-create-react-app/node_modules/@storybook/react-docgen-typescript-plugin": {
+ "version": "1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0",
+ "resolved": "https://registry.npmjs.org/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0.tgz",
+ "integrity": "sha512-eVg3BxlOm2P+chijHBTByr90IZVUtgRW56qEOLX7xlww2NBuKrcavBlcmn+HH7GIUktquWkMPtvy6e0W0NgA5w==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.1",
+ "endent": "^2.0.1",
+ "find-cache-dir": "^3.3.1",
+ "flat-cache": "^3.0.4",
+ "micromatch": "^4.0.2",
+ "react-docgen-typescript": "^2.1.1",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "typescript": ">= 3.x",
+ "webpack": ">= 4"
+ }
+ },
"node_modules/@storybook/preset-create-react-app/node_modules/pnp-webpack-plugin": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz",
@@ -10441,6 +10463,12 @@
"node": ">=6"
}
},
+ "node_modules/@storybook/preset-create-react-app/node_modules/tslib": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
+ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
+ "dev": true
+ },
"node_modules/@storybook/preview-web": {
"version": "6.4.19",
"resolved": "https://registry.npmjs.org/@storybook/preview-web/-/preview-web-6.4.19.tgz",
@@ -15761,6 +15789,7 @@
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
"integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==",
+ "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"dev": true,
"hasInstallScript": true,
"funding": {
@@ -15795,6 +15824,7 @@
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.21.1.tgz",
"integrity": "sha512-12VZfFIu+wyVbBebyHmRTuEE/tZrB4tJToWcwAMcsp3h4+sHR+fMJWbKpYiCRWlhFBq+KNyO8rIV9rTkeVmznQ==",
+ "deprecated": "core-js-pure@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js-pure.",
"dev": true,
"hasInstallScript": true,
"funding": {
@@ -31244,6 +31274,7 @@
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
+ "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility",
"dev": true
},
"node_modules/stack-utils": {
@@ -33477,6 +33508,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
"integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==",
+ "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.",
"dev": true,
"dependencies": {
"browser-process-hrtime": "^1.0.0"
@@ -42694,6 +42726,21 @@
"semver": "^7.3.5"
},
"dependencies": {
+ "@storybook/react-docgen-typescript-plugin": {
+ "version": "1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0",
+ "resolved": "https://registry.npmjs.org/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0.tgz",
+ "integrity": "sha512-eVg3BxlOm2P+chijHBTByr90IZVUtgRW56qEOLX7xlww2NBuKrcavBlcmn+HH7GIUktquWkMPtvy6e0W0NgA5w==",
+ "dev": true,
+ "requires": {
+ "debug": "^4.1.1",
+ "endent": "^2.0.1",
+ "find-cache-dir": "^3.3.1",
+ "flat-cache": "^3.0.4",
+ "micromatch": "^4.0.2",
+ "react-docgen-typescript": "^2.1.1",
+ "tslib": "^2.0.0"
+ }
+ },
"pnp-webpack-plugin": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz",
@@ -42702,6 +42749,12 @@
"requires": {
"ts-pnp": "^1.1.6"
}
+ },
+ "tslib": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
+ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
+ "dev": true
}
}
},
diff --git a/package.json b/package.json
index 47ea75f3..3d981a35 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,8 @@
"react": ">=17.0",
"react-dom": ">=17.0",
"three": ">=0.138",
- "three-stdlib": ">=2.8"
+ "three-stdlib": ">=2.8",
+ "suspend-react": "0.0.8"
},
"devDependencies": {
"@commitlint/cli": "^16.2.3",
@@ -120,6 +121,7 @@
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-terser": "^7.0.2",
+ "suspend-react": "0.0.8",
"three": "^0.138.3",
"three-stdlib": "^2.8.8",
"typescript": "^4.6.2",
diff --git a/src/App/App.component.tsx b/src/App/App.component.tsx
index 8d8edfc4..09a237b8 100644
--- a/src/App/App.component.tsx
+++ b/src/App/App.component.tsx
@@ -9,13 +9,13 @@ function App() {
localhost playground
- Paste your content in there to test the avatar props without shrinking the canvas
+ Drop your content in there to test the avatar props without shrinking the canvas
diff --git a/src/components/Avatar/Avatar.component.tsx b/src/components/Avatar/Avatar.component.tsx
index 372cf747..aada4642 100644
--- a/src/components/Avatar/Avatar.component.tsx
+++ b/src/components/Avatar/Avatar.component.tsx
@@ -7,7 +7,7 @@ import { AnimationModel } from 'src/components/Models/AnimationModel/AnimationMo
import { LightingProps } from 'src/types';
import { BaseCanvas } from 'src/components/BaseCanvas';
import { HalfBodyModel, StaticModel, PoseModel } from 'src/components/Models';
-import { isValidGlbUrl } from 'src/services';
+import { isValidGlbFormat } from 'src/services';
import Capture, { CaptureType } from '../Capture/Capture.component';
import Box, { Background } from '../Background/Box/Box.component';
import Shadow from '../Shadow/Shadow.components';
@@ -40,21 +40,19 @@ export type Emotion = Record;
export interface AvatarProps extends LightingProps {
/**
- * Path to `.glb` file of the 3D model.
- * Can be a relative or absolute URL.
+ * Arbitrary binary data (base64 string, Blob) of a `.glb` file or path (URL) to a `.glb` resource.
*/
- modelUrl: string;
+ modelSrc: string | Blob;
/**
- * Path to `.glb` animation file of the 3D model.
- * Can be a relative or absolute URL.
+ * Arbitrary binary data (base64 string, Blob) of a `.glb` file or path (URL) to a `.glb` resource.
+ * The animation will be run for the 3D model provided in `modelSrc`.
*/
- animationUrl?: string;
+ animationSrc?: string | Blob;
/**
- * Path to `.glb` file which will be used to map Bone placements onto the underlying 3D model.
+ * Arbitrary binary data (base64 string, Blob) or a path (URL) to `.glb` file which will be used to map Bone placements onto the underlying 3D model.
* Applied when not specifying an animation.
- * Can be a relative or absolute URL.
*/
- poseUrl?: string;
+ poseSrc?: string | Blob;
/**
* Canvas background color. Supports all CSS color value types.
*/
@@ -115,9 +113,9 @@ export interface AvatarProps extends LightingProps {
* Optimised for full-body and half-body avatars.
*/
export const Avatar: FC = ({
- modelUrl,
- animationUrl = undefined,
- poseUrl = undefined,
+ modelSrc,
+ animationSrc = undefined,
+ poseSrc = undefined,
backgroundColor = '#f0f0f0',
environment = 'city',
halfBody = false,
@@ -140,26 +138,26 @@ export const Avatar: FC = ({
loader
}) => {
const AvatarModel = useMemo(() => {
- if (!isValidGlbUrl(modelUrl)) {
+ if (!isValidGlbFormat(modelSrc)) {
return null;
}
- if (!!animationUrl && !halfBody && isValidGlbUrl(animationUrl)) {
+ if (!!animationSrc && !halfBody && isValidGlbFormat(animationSrc)) {
return (
-
+
);
}
if (halfBody) {
- return ;
+ return ;
}
- if (isValidGlbUrl(poseUrl)) {
- return ;
+ if (isValidGlbFormat(poseSrc)) {
+ return ;
}
- return ;
- }, [halfBody, animationUrl, modelUrl, scale, poseUrl, idleRotation, emotion]);
+ return ;
+ }, [halfBody, animationSrc, modelSrc, scale, poseSrc, idleRotation, emotion]);
return (
}>
diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx
index d5a63a13..e1b6f471 100644
--- a/src/components/Avatar/Avatar.stories.tsx
+++ b/src/components/Avatar/Avatar.stories.tsx
@@ -2,14 +2,20 @@ import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { getStoryAssetPath } from 'src/services';
import { Vector3 } from 'three';
+import { FileDropper } from 'src/components/FileDropper/FileDropper.component';
import { Avatar, CAMERA } from './Avatar.component';
const Template: ComponentStory = (args) => ;
+const DropTemplate: ComponentStory = (args) => (
+
+
+
+);
export const Static = Template.bind({});
Static.args = {
backgroundColor: '#f0f0f0',
- modelUrl: getStoryAssetPath('female.glb'),
+ modelSrc: getStoryAssetPath('female.glb'),
scale: 1,
environment: 'city',
shadows: false,
@@ -26,27 +32,11 @@ Static.args = {
cameraInitialDistance: CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE
};
-export default {
- title: 'Components/Avatar',
- component: Avatar,
- argTypes: {
- backgroundColor: { control: 'color' },
- ambientLightColor: { control: 'color' },
- dirLightColor: { control: 'color' },
- spotLightColor: { control: 'color' },
- ambientLightIntensity: { control: { type: 'range', min: 0, max: 10, step: 0.1 } },
- spotLightAngle: { control: { type: 'range', min: 0, max: 10, step: 0.01 } },
- cameraTarget: { control: { type: 'range', min: 0, max: 10, step: 0.01 } },
- scale: { control: { type: 'range', min: 0.01, max: 10, step: 0.01 } },
- cameraInitialDistance: { control: { type: 'range', min: 0, max: 2.5, step: 0.01 } }
- }
-} as ComponentMeta;
-
export const Animated = Template.bind({});
Animated.args = {
...Static.args,
- modelUrl: getStoryAssetPath('male.glb'),
- animationUrl: getStoryAssetPath('male-idle.glb'),
+ modelSrc: getStoryAssetPath('male.glb'),
+ animationSrc: getStoryAssetPath('male-idle.glb'),
cameraTarget: CAMERA.TARGET.FULL_BODY,
cameraInitialDistance: CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE
};
@@ -54,7 +44,7 @@ Animated.args = {
export const HalfBody = Template.bind({});
HalfBody.args = {
...Static.args,
- modelUrl: getStoryAssetPath('half-body.glb'),
+ modelSrc: getStoryAssetPath('half-body.glb'),
halfBody: true,
cameraTarget: CAMERA.TARGET.HALF_BODY,
cameraInitialDistance: CAMERA.INITIAL_DISTANCE.HALF_BODY
@@ -63,8 +53,37 @@ HalfBody.args = {
export const Posing = Template.bind({});
Posing.args = {
...Static.args,
- modelUrl: getStoryAssetPath('male.glb'),
- poseUrl: getStoryAssetPath('male-pose-standing.glb'),
+ modelSrc: getStoryAssetPath('male.glb'),
+ poseSrc: getStoryAssetPath('male-pose-standing.glb'),
+ cameraTarget: CAMERA.TARGET.FULL_BODY,
+ cameraInitialDistance: CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE
+};
+
+/* eslint-disable */
+export const _BinaryInput = DropTemplate.bind({});
+_BinaryInput.args = {
+ ...Static.args,
+ modelSrc: '',
cameraTarget: CAMERA.TARGET.FULL_BODY,
cameraInitialDistance: CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE
};
+_BinaryInput.argTypes = {
+ modelSrc: { control: false }
+};
+/* eslint-enable */
+
+export default {
+ title: 'Components/Avatar',
+ component: Avatar,
+ argTypes: {
+ backgroundColor: { control: 'color' },
+ ambientLightColor: { control: 'color' },
+ dirLightColor: { control: 'color' },
+ spotLightColor: { control: 'color' },
+ ambientLightIntensity: { control: { type: 'range', min: 0, max: 10, step: 0.1 } },
+ spotLightAngle: { control: { type: 'range', min: 0, max: 10, step: 0.01 } },
+ cameraTarget: { control: { type: 'range', min: 0, max: 10, step: 0.01 } },
+ scale: { control: { type: 'range', min: 0.01, max: 10, step: 0.01 } },
+ cameraInitialDistance: { control: { type: 'range', min: 0, max: 2.5, step: 0.01 } }
+ }
+} as ComponentMeta;
diff --git a/src/components/Exhibit/Exhibit.component.tsx b/src/components/Exhibit/Exhibit.component.tsx
index 7d04e910..d52dbefa 100644
--- a/src/components/Exhibit/Exhibit.component.tsx
+++ b/src/components/Exhibit/Exhibit.component.tsx
@@ -1,17 +1,16 @@
import React, { Suspense, FC } from 'react';
import { PresentationControls, Environment, ContactShadows } from '@react-three/drei';
import type { PresetsType } from '@react-three/drei/helpers/environment-assets';
-import { isValidGlbUrl } from 'src/services';
+import { isValidGlbFormat } from 'src/services';
import { CameraProps } from 'src/types';
import { BaseCanvas } from '../BaseCanvas';
import { FloatingModel } from '../Models/FloatingModel';
export interface ExhibitProps extends CameraProps {
/**
- * Path to `.glb` file of the 3D model.
- * Can be a relative or absolute URL.
+ * Arbitrary binary data (base64 string | Blob) of a `.glb` file or path (URL) to a `.glb` resource.
*/
- modelUrl: string;
+ modelSrc: string;
/**
* Size of the rendered GLB model.
*/
@@ -30,7 +29,7 @@ export interface ExhibitProps extends CameraProps {
* Interactive presentation of any GLTF (.glb) asset.
*/
export const Exhibit: FC = ({
- modelUrl,
+ modelSrc,
scale = 1.0,
backgroundColor = '#f0f0f0',
environment = 'city',
@@ -48,7 +47,7 @@ export const Exhibit: FC = ({
polar={[-Math.PI / 3, Math.PI / 3]}
azimuth={[-Math.PI / 1.4, Math.PI / 2]}
>
- {isValidGlbUrl(modelUrl) && }
+ {isValidGlbFormat(modelSrc) && }
= (args) => ;
+const DropTemplate: ComponentStory = (args) => (
+
+
+
+);
export const Default = Template.bind({});
Default.args = {
backgroundColor: '#f0f0f0',
- modelUrl: getStoryAssetPath('headwear.glb'),
+ modelSrc: getStoryAssetPath('headwear.glb'),
scale: 3,
- environment: 'city'
+ environment: 'city',
+ position: new Vector3(0, 0, 5)
};
+/* eslint-disable */
+export const _BinaryInput = DropTemplate.bind({});
+_BinaryInput.args = {
+ backgroundColor: '#f0f0f0',
+ scale: 3,
+ environment: 'city',
+ position: new Vector3(0, 0, 5)
+};
+_BinaryInput.argTypes = {
+ modelSrc: { control: false }
+};
+/* eslint-enable */
+
export default {
title: 'Components/Exhibit',
component: Exhibit,
diff --git a/src/components/Exhibit/Exhibit.test.tsx b/src/components/Exhibit/Exhibit.test.tsx
index 558f30f9..bb565f59 100644
--- a/src/components/Exhibit/Exhibit.test.tsx
+++ b/src/components/Exhibit/Exhibit.test.tsx
@@ -7,7 +7,7 @@ describe('Exhibit component', () => {
it('should mount without errors', async () => {
let renderer: RenderResult = null!;
act(() => {
- renderer = render();
+ renderer = render();
});
expect(renderer.container).toMatchSnapshot();
@@ -20,7 +20,7 @@ describe('Exhibit component', () => {
let renderer: RenderResult = null!;
act(() => {
- renderer = render();
+ renderer = render();
});
expect(renderer.container).toMatchSnapshot();
diff --git a/src/components/FileDropper/FileDropper.component.tsx b/src/components/FileDropper/FileDropper.component.tsx
new file mode 100644
index 00000000..62c9d2dc
--- /dev/null
+++ b/src/components/FileDropper/FileDropper.component.tsx
@@ -0,0 +1,77 @@
+import React, { useEffect, FC, useRef, useState, ReactNode, ReactElement, Children, cloneElement } from 'react';
+import styles from './FileDropper.module.scss';
+
+interface DropContainerProps {
+ children?: ReactNode | ReactNode[];
+ placeholder?: string;
+}
+
+/**
+ * This component is only for using in Storybook for showcasing drag'n'drop functionality.
+ */
+export const FileDropper: FC = ({ children, placeholder = `Drag and Drop a .glb file here` }) => {
+ const ref = useRef(null);
+ const [modelSrc, setModelSrc] = useState('');
+
+ const getBase64 = (file: File): Promise =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = (error) => reject(error);
+ });
+
+ const handleDrag = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ const handleDragIn = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+ const handleDragOut = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+ const handleDrop = async (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (e.dataTransfer?.items[0].kind === 'file') {
+ const file = e.dataTransfer.items[0].getAsFile();
+
+ if (file!.name?.endsWith('.glb')) {
+ setModelSrc(await getBase64(file!));
+ }
+ const b64 = await getBase64(file!);
+ setModelSrc(b64 as string);
+ }
+ };
+
+ useEffect(() => {
+ const element = ref.current!;
+
+ element.addEventListener('dragenter', handleDragIn);
+ element.addEventListener('dragleave', handleDragOut);
+ element.addEventListener('dragover', handleDrag);
+ element.addEventListener('drop', handleDrop);
+
+ return () => {
+ element.removeEventListener('dragenter', handleDragIn);
+ element.removeEventListener('dragleave', handleDragOut);
+ element.removeEventListener('dragover', handleDrag);
+ element.removeEventListener('drop', handleDrop);
+ };
+ });
+
+ return (
+
+ {modelSrc.length < 1 ? (
+
{placeholder}
+ ) : (
+ Children.map(Children.toArray(children), (child) => cloneElement(child as ReactElement, { modelSrc }))
+ )}
+
+ );
+};
diff --git a/src/components/FileDropper/FileDropper.module.scss b/src/components/FileDropper/FileDropper.module.scss
new file mode 100644
index 00000000..b63f997b
--- /dev/null
+++ b/src/components/FileDropper/FileDropper.module.scss
@@ -0,0 +1,15 @@
+.fileDropContainer {
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ padding: 1rem;
+ background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23333' stroke-width='4' stroke-dasharray='10' stroke-dashoffset='7' stroke-linecap='square'/%3e%3c/svg%3e");
+ border-radius: 16px;
+ align-items: center;
+ justify-content: center;
+}
+.placeholder {
+ color: #2c2c2c;
+ font-weight: 500;
+}
\ No newline at end of file
diff --git a/src/components/FileDropper/index.ts b/src/components/FileDropper/index.ts
new file mode 100644
index 00000000..f4b549ef
--- /dev/null
+++ b/src/components/FileDropper/index.ts
@@ -0,0 +1 @@
+export { FileDropper } from './FileDropper.component';
diff --git a/src/components/Models/AnimationModel/AnimationModel.component.tsx b/src/components/Models/AnimationModel/AnimationModel.component.tsx
index 4ad01895..97dab14c 100644
--- a/src/components/Models/AnimationModel/AnimationModel.component.tsx
+++ b/src/components/Models/AnimationModel/AnimationModel.component.tsx
@@ -1,13 +1,12 @@
import React, { useRef, FC } from 'react';
-import { useFrame, useGraph, useLoader } from '@react-three/fiber';
+import { useFrame, useGraph } from '@react-three/fiber';
import { AnimationMixer, Group } from 'three';
-import { GLTFLoader } from 'three-stdlib';
import { Model } from 'src/components/Models/Model';
-import { useHeadMovement } from 'src/services';
+import { useHeadMovement, useGltfLoader } from 'src/services';
interface AnimationModelProps {
- modelUrl: string;
- animationUrl: string;
+ modelSrc: string | Blob;
+ animationSrc: string | Blob;
rotation?: number;
scale?: number;
idleRotation?: boolean;
@@ -16,17 +15,17 @@ interface AnimationModelProps {
let currentRotation = 0;
export const AnimationModel: FC = ({
- modelUrl,
- animationUrl,
+ modelSrc,
+ animationSrc,
rotation = 20 * (Math.PI / 180),
scale = 1,
idleRotation = false
}) => {
const ref = useRef();
- const { scene } = useLoader(GLTFLoader, modelUrl);
+ const { scene } = useGltfLoader(modelSrc);
const { nodes } = useGraph(scene);
- const animationSource = useLoader(GLTFLoader, animationUrl);
+ const animationSource = useGltfLoader(animationSrc);
const mixer = new AnimationMixer(nodes.Armature);
mixer.clipAction(animationSource.animations[0]).play();
mixer.update(0);
diff --git a/src/components/Models/FloatingModel/FloatingModel.component.tsx b/src/components/Models/FloatingModel/FloatingModel.component.tsx
index 769a571d..e4a0d688 100644
--- a/src/components/Models/FloatingModel/FloatingModel.component.tsx
+++ b/src/components/Models/FloatingModel/FloatingModel.component.tsx
@@ -1,17 +1,17 @@
import React, { useRef, FC } from 'react';
-import { GLTFLoader } from 'three-stdlib';
-import { useFrame, useLoader } from '@react-three/fiber';
+import { useFrame } from '@react-three/fiber';
import type { Group } from 'three';
import { Model } from 'src/components/Models/Model';
+import { useGltfLoader } from 'src/services';
interface FloatingModelProps {
- modelUrl: string;
+ modelSrc: string;
scale?: number;
}
-export const FloatingModel: FC = ({ modelUrl, scale = 1.0 }) => {
+export const FloatingModel: FC = ({ modelSrc, scale = 1.0 }) => {
const ref = useRef();
- const { scene } = useLoader(GLTFLoader, modelUrl);
+ const { scene } = useGltfLoader(modelSrc);
useFrame((state) => {
const t = state.clock.getElapsedTime();
diff --git a/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx b/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx
index 0ac5e6a4..c2b356a6 100644
--- a/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx
+++ b/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx
@@ -1,13 +1,12 @@
import React, { FC, useRef } from 'react';
-import { useFrame, useGraph, useLoader } from '@react-three/fiber';
-import { GLTFLoader } from 'three-stdlib';
+import { useFrame, useGraph } from '@react-three/fiber';
import { Model } from 'src/components/Models/Model';
-import { useEmotion, useHeadMovement } from 'src/services';
+import { useEmotion, useHeadMovement, useGltfLoader } from 'src/services';
import { Group } from 'three';
import { Emotion } from '../../Avatar/Avatar.component';
interface HalfBodyModelProps {
- modelUrl: string;
+ modelSrc: string | Blob;
rotation?: number;
scale?: number;
idleRotation?: boolean;
@@ -17,14 +16,14 @@ interface HalfBodyModelProps {
let currentRotation = 0;
export const HalfBodyModel: FC = ({
- modelUrl,
+ modelSrc,
scale = 1,
rotation = 20 * (Math.PI / 180),
idleRotation = false,
emotion
}) => {
const ref = useRef();
- const { scene } = useLoader(GLTFLoader, modelUrl);
+ const { scene } = useGltfLoader(modelSrc);
const { nodes } = useGraph(scene);
scene.traverse((object) => {
diff --git a/src/components/Models/PoseModel/PoseModel.component.tsx b/src/components/Models/PoseModel/PoseModel.component.tsx
index 79b3a690..06a2c76b 100644
--- a/src/components/Models/PoseModel/PoseModel.component.tsx
+++ b/src/components/Models/PoseModel/PoseModel.component.tsx
@@ -1,23 +1,22 @@
import React, { FC, MutableRefObject } from 'react';
-import { useGraph, useLoader } from '@react-three/fiber';
-import { GLTFLoader } from 'three-stdlib';
+import { useGraph } from '@react-three/fiber';
import { Model } from 'src/components/Models/Model';
import { Group } from 'three';
-import { mutatePose, useEmotion } from 'src/services';
+import { mutatePose, useEmotion, useGltfLoader } from 'src/services';
import { Emotion } from '../../Avatar/Avatar.component';
interface PoseModelProps {
- modelUrl: string;
- poseUrl: string;
+ modelSrc: string | Blob;
+ poseSrc: string | Blob;
modelRef?: MutableRefObject;
scale?: number;
emotion?: Emotion;
}
-export const PoseModel: FC = ({ modelUrl, poseUrl, modelRef, scale = 1, emotion }) => {
- const { scene } = useLoader(GLTFLoader, modelUrl);
+export const PoseModel: FC = ({ modelSrc, poseSrc, modelRef, scale = 1, emotion }) => {
+ const { scene } = useGltfLoader(modelSrc);
const { nodes } = useGraph(scene);
- const pose = useLoader(GLTFLoader, poseUrl);
+ const pose = useGltfLoader(poseSrc);
const { nodes: sourceNodes } = useGraph(pose.scene);
mutatePose(nodes, sourceNodes);
diff --git a/src/components/Models/StaticModel/StaticModel.component.tsx b/src/components/Models/StaticModel/StaticModel.component.tsx
index 55455554..0a4a4979 100644
--- a/src/components/Models/StaticModel/StaticModel.component.tsx
+++ b/src/components/Models/StaticModel/StaticModel.component.tsx
@@ -1,17 +1,16 @@
import React, { FC, MutableRefObject } from 'react';
-import { useLoader } from '@react-three/fiber';
-import { GLTFLoader } from 'three-stdlib';
import { Model } from 'src/components/Models/Model';
+import { useGltfLoader } from 'src/services';
import { Group } from 'three';
interface StaticModelProps {
- modelUrl: string;
+ modelSrc: string | Blob;
modelRef?: MutableRefObject;
scale?: number;
}
-export const StaticModel: FC = ({ modelUrl, modelRef, scale = 1 }) => {
- const { scene } = useLoader(GLTFLoader, modelUrl);
+export const StaticModel: FC = ({ modelSrc, modelRef, scale = 1 }) => {
+ const { scene } = useGltfLoader(modelSrc);
return ;
};
diff --git a/src/services/Models.service.ts b/src/services/Models.service.ts
index f4c5a7db..c5e150e5 100644
--- a/src/services/Models.service.ts
+++ b/src/services/Models.service.ts
@@ -1,19 +1,27 @@
import { LinearFilter, MeshStandardMaterial, Material, Vector2, Object3D, SkinnedMesh } from 'three';
import { useFrame } from '@react-three/fiber';
import type { ObjectMap } from '@react-three/fiber';
-import { Emotion } from '../components/Avatar/Avatar.component';
+import { GLTF, GLTFLoader } from 'three-stdlib';
+import { suspend } from 'suspend-react';
+import { Emotion } from 'src/components/Avatar/Avatar.component';
export const getStoryAssetPath = (publicAsset: string) =>
`${process.env.NODE_ENV === 'production' ? '/visage' : ''}/${publicAsset}`;
-export const isValidGlbUrl = (url: string | string[] | undefined): boolean => {
- if (Array.isArray(url)) {
- return url.length > 0 && url.every(isValidGlbUrl);
+export const isValidGlbFormat = (source: string | string[] | Blob | undefined | null): boolean => {
+ if (Array.isArray(source)) {
+ return source.length > 0 && source.every(isValidGlbFormat);
}
- if (typeof url === 'string') {
- const expression = new RegExp(/(.glb|.glb[?].*)$/g);
- return expression.test(url);
+ if (typeof source === 'string') {
+ const fileEndExpression = new RegExp(/(.glb|.glb[?].*)$/g);
+ const uploadFileExpression = new RegExp(/^data:application\/octet-stream;base64,/g);
+ const gltfModelExpression = new RegExp(/^data:model\/gltf-binary;base64,/g);
+ return fileEndExpression.test(source) || uploadFileExpression.test(source) || gltfModelExpression.test(source);
+ }
+
+ if (source instanceof Blob) {
+ return source.type === 'model/gltf-binary';
}
return false;
@@ -143,3 +151,16 @@ export const useEmotion = (nodes: ObjectMap['nodes'], emotion?: Emotion) => {
}
});
};
+
+export const useGltfLoader = (source: Blob | string): GLTF =>
+ suspend(async () => {
+ const loader = new GLTFLoader();
+
+ if (source instanceof Blob) {
+ const buffer = await source.arrayBuffer();
+ return (await loader.parseAsync(buffer, '')) as unknown as GLTF;
+ }
+
+ const gltf = await loader.loadAsync(source);
+ return gltf;
+ }, [source]);
diff --git a/src/services/Models.test.ts b/src/services/Models.test.ts
new file mode 100644
index 00000000..75e6e0ff
--- /dev/null
+++ b/src/services/Models.test.ts
@@ -0,0 +1,48 @@
+import { isValidGlbFormat } from './Models.service';
+
+describe('Models service unit tests', () => {
+ describe('#isValidGlbFormat', () => {
+ const testCases = [
+ { name: 'undefined URL/base64', format: undefined, expected: false },
+ { name: 'null URL/base64', format: null, expected: false },
+ { name: 'invalid URL', format: '/3d-test-model.fbx', expected: false },
+ { name: 'invalid empty URL/base64', format: '', expected: false },
+ { name: 'invalid URL with query parameters', format: '/3d-test-model.gl?morphTargets=true', expected: false },
+ {
+ name: 'invalid base64 format',
+ format: 'data:application/octet-stream;base64Z2xURgIAAAC4aKAAqA4CAEpTT...',
+ expected: false
+ },
+ { name: 'valid URL', format: '/3d-test-model.glb', expected: true },
+ {
+ name: 'valid URL with query parameters',
+ format: '/3d-test-model.glb?morphTargets=true&blendShapes=[1,2,3]',
+ expected: true
+ },
+ {
+ name: 'valid base64 format',
+ format: 'data:application/octet-stream;base64,Z2xURgIAAAC4aKAAqA4CAEpTT...',
+ expected: true
+ },
+ {
+ name: 'valid base64 gltf-model format',
+ format: 'data:model/gltf-binary;base64,Z2xURgIAAAC4aKAAqA4CAEpTT...',
+ expected: true
+ },
+ {
+ name: 'invalid Blob format',
+ format: new Blob(),
+ expected: false
+ },
+ {
+ name: 'valid Blob format',
+ format: new Blob(undefined, { type: 'model/gltf-binary' }),
+ expected: true
+ }
+ ];
+
+ test.each(testCases)('$name', ({ format, expected }): void => {
+ expect(isValidGlbFormat(format)).toBe(expected);
+ });
+ });
+});