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); + }); + }); +});