diff --git a/src/components/Avatar/Avatar.component.tsx b/src/components/Avatar/Avatar.component.tsx index 92eeee39..d1852a73 100644 --- a/src/components/Avatar/Avatar.component.tsx +++ b/src/components/Avatar/Avatar.component.tsx @@ -24,7 +24,7 @@ import { MaterialConfiguration, SpawnState } from 'src/types'; -import { AnimationAction, Vector3 } from 'three'; +import { AnimationAction, Vector3, Mesh } from 'three'; import Lights from 'src/components/Lights/Lights.component'; import { spawnState } from 'src/state/spawnAtom'; @@ -179,6 +179,22 @@ export interface AvatarProps extends LightingProps, EnvironmentProps, Omit void; + /** + * Callback for when a mesh hover starts. + */ + onMeshHoverStart?: (mesh: Mesh) => void; + /** + * Callback for when a mesh hover ends. + */ + onMeshHoverEnd?: (mesh: Mesh) => void; + /** + * Callback for processing meshes on model load. + */ + meshCallback?: (mesh: Mesh) => void; } /** @@ -229,7 +245,11 @@ const Avatar: FC = ({ materialConfig, controlsMinDistance, controlsMaxDistance, - canvasConfig + canvasConfig, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback }) => { const setSpawnState = useSetAtom(spawnState); @@ -254,6 +274,10 @@ const Avatar: FC = ({ bloom={effects?.bloom} onAnimationEnd={onAnimationEnd} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); } @@ -270,6 +294,10 @@ const Avatar: FC = ({ headMovement={headMovement} bloom={effects?.bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); } @@ -285,6 +313,10 @@ const Avatar: FC = ({ headMovement={headMovement} bloom={effects?.bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); } @@ -299,6 +331,10 @@ const Avatar: FC = ({ onLoaded={onLoaded} bloom={effects?.bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); } @@ -311,6 +347,10 @@ const Avatar: FC = ({ emotion={emotion} bloom={effects?.bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); }, [ @@ -327,7 +367,11 @@ const Avatar: FC = ({ materialConfig, onAnimationEnd, idleRotation, - headMovement + headMovement, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback ]); useEffect(() => triggerCallback(onLoading), [modelSrc, animationSrc, onLoading]); diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx index 637ff1ab..6fdd76a8 100644 --- a/src/components/Avatar/Avatar.stories.tsx +++ b/src/components/Avatar/Avatar.stories.tsx @@ -241,6 +241,10 @@ export default { cameraInitialDistance: { control: { type: 'range', min: 0, max: 2.5, step: 0.01 } }, onLoaded: { control: false }, environment: { options: Object.keys(environmentPresets), control: { type: 'select' } }, - fov: { control: { type: 'range', min: 30, max: 100, step: 1 } } + fov: { control: { type: 'range', min: 30, max: 100, step: 1 } }, + onMeshClick: { control: false }, + onMeshHoverStart: { control: false }, + onMeshHoverEnd: { control: false }, + meshCallback: { control: false } } }; diff --git a/src/components/Avatar/Examples.stories.tsx b/src/components/Avatar/Examples.stories.tsx index 5fc8e361..6d7e3b4a 100644 --- a/src/components/Avatar/Examples.stories.tsx +++ b/src/components/Avatar/Examples.stories.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { StoryFn } from '@storybook/react'; import { Sparkles as SparklesDrei } from '@react-three/drei'; import type { Meta } from '@storybook/react'; import { Avatar as AvatarWrapper, CAMERA } from 'src/components/Avatar'; import { getStoryAssetPath } from 'src/services'; import { ignoreArgTypesOnExamples, emotions, modelPresets, animationPresets } from 'src/services/Stories.service'; -import { Vector3 } from 'three'; +import { Mesh, MeshStandardMaterial, Vector3 } from 'three'; import { AvatarProps } from './Avatar.component'; import { Static } from './Avatar.stories'; import { BloomConfiguration } from '../../types'; @@ -154,3 +154,85 @@ IdleBlinking.argTypes = { modelSrc: { options: Object.values({ seven: modelPresets.seven }), control: { type: 'select' } }, animationSrc: { options: Object.values(animationPresets), control: { type: 'select' } } }; + +const highlightMaterial = new MeshStandardMaterial({ color: '#ff0000', transparent: false }); + +export const MouseActions: StoryFn = (args) => { + const [originalMaterials] = useState(new Map()); + + const handleModelClick = (mesh: Mesh) => { + console.log('Clicked mesh:', mesh); + }; + + const dimClothMaterials = (mesh: Mesh) => { + const meshWhitelist = ['Wolf3D_Outfit_Top', 'Wolf3D_Outfit_Bottom', 'Wolf3D_Hair', 'Wolf3D_Outfit_Footwear']; + + if (!meshWhitelist.includes(mesh.name)) { + return; + } + + const material = mesh.material as MeshStandardMaterial; + material.color.setHex(0x404040); + material.roughness = 0.8; + material.metalness = 0.1; + material.emissive.setHex(0xffffff); + material.emissiveIntensity = 0.1; + }; + + const onMeshHoverStart = useCallback( + (mesh: Mesh) => { + const meshWhiteList = ['Wolf3D_Outfit_Top', 'Wolf3D_Outfit_Bottom', 'Wolf3D_Hair', 'Wolf3D_Outfit_Footwear']; + + if (!meshWhiteList.includes(mesh?.name)) { + return; + } + + document.body.style.cursor = 'pointer'; + + // Store original material and set hover highlight for the new mesh + if (!originalMaterials.has(mesh.name)) { + originalMaterials.set(mesh.name, mesh.material); + + // eslint-disable-next-line no-param-reassign + mesh.material = highlightMaterial; + } + }, + [originalMaterials] + ); + + const onMeshHoverEnd = useCallback( + (mesh: Mesh) => { + const originalMaterial = originalMaterials.get(mesh.name); + + if (originalMaterial) { + document.body.style.cursor = 'default'; + // eslint-disable-next-line no-param-reassign + mesh.material = originalMaterial; + originalMaterials.delete(mesh.name); + } + }, + [originalMaterials] + ); + + return ( + + ); +}; + +MouseActions.args = { + ...Static.args, + modelSrc: getStoryAssetPath('male.glb'), + cameraTarget: CAMERA.TARGET.FULL_BODY.MALE, + cameraInitialDistance: CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE, + background: { + color: 'rgba(134, 157, 169, 1)' + } +}; + +MouseActions.argTypes = {}; diff --git a/src/components/Models/AnimationModel/AnimationModel.component.tsx b/src/components/Models/AnimationModel/AnimationModel.component.tsx index 7c53e398..df68e6f5 100644 --- a/src/components/Models/AnimationModel/AnimationModel.component.tsx +++ b/src/components/Models/AnimationModel/AnimationModel.component.tsx @@ -36,7 +36,11 @@ export const AnimationModel: FC = ({ headMovement = false, emotion, bloom, - materialConfig + materialConfig, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback }) => { const ref = useRef(null); const [animationRunning, setAnimationRunning] = useState(true); @@ -57,7 +61,7 @@ export const AnimationModel: FC = ({ assetMixerRef.current = null; }; - }, [scene]); + }, [scene, embeddedAnimations]); const animationClip = useMemo(async () => { const clip = await loadAnimationClips(animationSrc); @@ -108,6 +112,10 @@ export const AnimationModel: FC = ({ onSpawnAnimationFinish={onSpawnAnimationFinish} bloom={bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); }; diff --git a/src/components/Models/FloatingModel/FloatingModel.component.tsx b/src/components/Models/FloatingModel/FloatingModel.component.tsx index 3ce654fc..aa62517f 100644 --- a/src/components/Models/FloatingModel/FloatingModel.component.tsx +++ b/src/components/Models/FloatingModel/FloatingModel.component.tsx @@ -10,7 +10,16 @@ export interface FloatingModelProps extends BaseModelProps { scale?: number; } -export const FloatingModel: FC = ({ modelSrc, scale = 1.0, onLoaded, bloom }) => { +export const FloatingModel: FC = ({ + modelSrc, + scale = 1.0, + onLoaded, + bloom, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback +}) => { const ref = useRef(null); const { scene } = useGltfLoader(modelSrc); @@ -23,5 +32,17 @@ export const FloatingModel: FC = ({ modelSrc, scale = 1.0, o } }); - return ; + return ( + + ); }; diff --git a/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx b/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx index 754b318c..4e22a9cf 100644 --- a/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx +++ b/src/components/Models/HalfBodyModel/HalfBodyModel.component.tsx @@ -27,7 +27,11 @@ export const HalfBodyModel: FC = ({ onLoaded, headMovement = false, bloom, - materialConfig + materialConfig, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback }) => { const ref = useRef(null); const { scene } = useGltfLoader(modelSrc); @@ -72,6 +76,10 @@ export const HalfBodyModel: FC = ({ onLoaded={onLoaded} bloom={bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); }; diff --git a/src/components/Models/Model/Model.component.tsx b/src/components/Models/Model/Model.component.tsx index 3140a895..42638871 100644 --- a/src/components/Models/Model/Model.component.tsx +++ b/src/components/Models/Model/Model.component.tsx @@ -1,7 +1,7 @@ import React, { FC, Ref, useEffect, useState, useCallback, useMemo } from 'react'; import { Group, Mesh } from 'three'; -import { normaliseMaterialsConfig, triggerCallback, usePersistantRotation } from 'src/services'; -import { useThree } from '@react-three/fiber'; +import { normaliseMaterialsConfig, traverseMeshes, triggerCallback, usePersistantRotation } from 'src/services'; +import { ThreeEvent, useThree } from '@react-three/fiber'; import { hasWindow } from 'src/services/Client.service'; import { BaseModelProps } from 'src/types'; import { Spawn } from '../../Spawn/Spawn'; @@ -22,7 +22,11 @@ export const Model: FC = ({ onLoaded, onSpawnAnimationFinish, bloom, - materialConfig + materialConfig, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback }) => { const { gl } = useThree(); const [isTouching, setIsTouching] = useState(false); @@ -71,6 +75,55 @@ export const Model: FC = ({ } }); + if (meshCallback) { + traverseMeshes(scene, meshCallback); + } + + const onClick = useCallback( + (event: ThreeEvent) => { + if (!onMeshClick) { + return; + } + + const mesh = event.object as Mesh; + + if (mesh && mesh.isMesh) { + onMeshClick(mesh); + } + }, + [onMeshClick] + ); + + const handlePointerEnter = useCallback( + (event: ThreeEvent) => { + if (!onMeshHoverStart) { + return; + } + const mesh = event.object as Mesh; + + if (mesh && mesh.isMesh) { + event.stopPropagation(); + onMeshHoverStart(mesh); + } + }, + [onMeshHoverStart] + ); + + const handlePointerLeave = useCallback( + (event: ThreeEvent) => { + if (!onMeshHoverEnd) { + return; + } + const mesh = event.object as Mesh; + + if (mesh && mesh.isMesh) { + event.stopPropagation(); + onMeshHoverEnd(mesh); + } + }, + [onMeshHoverEnd] + ); + useEffect(() => triggerCallback(onLoaded), [scene, onLoaded]); useEffect(() => { @@ -101,7 +154,14 @@ export const Model: FC = ({ ); return ( - + {spawnComponent} diff --git a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx index 1c5b756b..ece3ad96 100644 --- a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx +++ b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx @@ -32,7 +32,11 @@ export const MultipleAnimationModel: FC = ({ emotion, bloom, materialConfig, - onAnimationEnd + onAnimationEnd, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback }) => { const armatureMixerRef = useRef(null); const assetMixerRef = useRef | null>(null); @@ -66,7 +70,7 @@ export const MultipleAnimationModel: FC = ({ disposeAssetAnimations(assetMixerRef.current, scene); assetMixerRef.current = null; }; - }, [scene]); + }, [scene, embeddedAnimations]); useEffect(() => { const mixer = armatureMixerRef.current; @@ -119,5 +123,17 @@ export const MultipleAnimationModel: FC = ({ useIdleExpression('blink', scene); useFallbackScene(scene, setModelFallback); - return ; + return ( + + ); }; diff --git a/src/components/Models/PoseModel/PoseModel.component.tsx b/src/components/Models/PoseModel/PoseModel.component.tsx index b74798f5..02f83ec8 100644 --- a/src/components/Models/PoseModel/PoseModel.component.tsx +++ b/src/components/Models/PoseModel/PoseModel.component.tsx @@ -23,7 +23,11 @@ export const PoseModel: FC = ({ setModelFallback, onLoaded, bloom, - materialConfig + materialConfig, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback }) => { const { scene } = useGltfLoader(modelSrc); const { nodes } = useGraph(scene); @@ -42,6 +46,10 @@ export const PoseModel: FC = ({ onLoaded={onLoaded} bloom={bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); }; diff --git a/src/components/Models/StaticModel/StaticModel.component.tsx b/src/components/Models/StaticModel/StaticModel.component.tsx index b410cb90..364e4e8a 100644 --- a/src/components/Models/StaticModel/StaticModel.component.tsx +++ b/src/components/Models/StaticModel/StaticModel.component.tsx @@ -21,7 +21,11 @@ export const StaticModel: FC = ({ onLoaded, emotion, bloom, - materialConfig + materialConfig, + onMeshClick, + onMeshHoverStart, + onMeshHoverEnd, + meshCallback }) => { const { scene } = useGltfLoader(modelSrc); const { nodes } = useGraph(scene); @@ -37,6 +41,10 @@ export const StaticModel: FC = ({ onLoaded={onLoaded} bloom={bloom} materialConfig={materialConfig} + onMeshClick={onMeshClick} + onMeshHoverStart={onMeshHoverStart} + onMeshHoverEnd={onMeshHoverEnd} + meshCallback={meshCallback} /> ); }; diff --git a/src/services/Models.service.tsx b/src/services/Models.service.tsx index 1c0851c2..b0045765 100644 --- a/src/services/Models.service.tsx +++ b/src/services/Models.service.tsx @@ -85,6 +85,14 @@ function traverseMaterials(object: Object3D, callback: (material: Material) => v }); } +export function traverseMeshes(object: Object3D, callback: (material: Mesh) => void) { + object.traverse((node) => { + const mesh = node as Mesh; + if (!mesh.geometry || !mesh.isMesh) return; + callback(mesh); + }); +} + const disposeGltfScene = (scene: Group) => { scene.traverse((node) => { if (node instanceof SkinnedMesh && node.skeleton) { diff --git a/src/types/index.ts b/src/types/index.ts index 5e0ecf9f..435c9731 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import { Vector3 } from 'three'; +import { Vector3, Mesh } from 'three'; import { PresetsType } from '@react-three/drei/helpers/environment-assets'; export type Required = { @@ -53,6 +53,22 @@ export interface BaseModelProps { setModelFallback?: (fallback: JSX.Element) => void; bloom?: BloomConfiguration; materialConfig?: MaterialConfiguration; + /** + * Callback for when a mesh is clicked. + */ + onMeshClick?: (mesh: Mesh) => void; + /** + * Callback for when a mesh hover starts. + */ + onMeshHoverStart?: (mesh: Mesh) => void; + /** + * Callback for when a mesh hover ends. + */ + onMeshHoverEnd?: (mesh: Mesh) => void; + /** + * Callback for processing meshes on model load. + */ + meshCallback?: (material: Mesh) => void; } export type HeadBlendShapeType =