diff --git a/.env b/.env index 8f443531..a3b590d5 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ # VITE_ASSET_PATH=https://M3-org.github.io/loot-assets # access remote (may have CORS issues) or download from https://github.com/M3-org/loot-assets and load locally #VITE_ASSET_PATH=./ -VITE_ASSET_PATH=./ +VITE_ASSET_PATH=./ \ No newline at end of file diff --git a/src/components/BottomDisplayMenu.jsx b/src/components/BottomDisplayMenu.jsx index be16dec9..7bac8b9a 100644 --- a/src/components/BottomDisplayMenu.jsx +++ b/src/components/BottomDisplayMenu.jsx @@ -1,9 +1,10 @@ import React, {useEffect,useState,useContext} from "react" import styles from "./BottomDisplayMenu.module.css" import { SceneContext } from "../context/SceneContext" -import { ViewContext } from "../context/ViewContext" +import {useUndoHistory} from "../components/hooks/useHistory" import randomizeIcon from "../images/randomize-green.png" import wireframeIcon from "../images/wireframe.png" +import undoIcon from "../images/undo.png" import solidIcon from "../images/solid.png" import mouseFollowIcon from "../images/eye.png" import mouseNoFollowIcon from "../images/no-eye.png" @@ -21,6 +22,7 @@ export default function BottomDisplayMenu({loadedAnimationName, randomize}){ lookAtManager, animationManager } = useContext(SceneContext); + const canUndo = useUndoHistory() const [hasMouseLook, setHasMouseLook] = useState(lookAtManager.userActivated); const [animationName, setAnimationName] = React.useState(animationManager?.getCurrentAnimationName() || ""); @@ -46,6 +48,11 @@ export default function BottomDisplayMenu({loadedAnimationName, randomize}){ animationManager.play() animationManager.setSpeed(speed); } + const handleUndo = () =>{ + if(canUndo){ + characterManager.history.undo(); + } + } const handleMouseLookEnable = () => { lookAtManager.setActive(!hasMouseLook); @@ -132,6 +139,15 @@ export default function BottomDisplayMenu({loadedAnimationName, randomize}){
+ {canUndo &&
+ +
} {randomize &&
{ + const {characterManager} = useContext(SceneContext); + + const [canUndo, setCanUndo] = useState(false); + + const handler = () => { + setCanUndo(characterManager.history.canUndo); + } + + useEffect(() => { + if(characterManager) { + characterManager.history.on('change', handler); + } + + return () => { + if(characterManager) { + characterManager.history.off('change',handler); + } + } + }, [characterManager]); + + + return canUndo +} \ No newline at end of file diff --git a/src/images/undo.png b/src/images/undo.png new file mode 100644 index 00000000..2bf11176 Binary files /dev/null and b/src/images/undo.png differ diff --git a/src/library/characterManager.js b/src/library/characterManager.js index d0cf894f..5b1eb4e0 100644 --- a/src/library/characterManager.js +++ b/src/library/characterManager.js @@ -1,4 +1,5 @@ import * as THREE from "three" +import EventEmitter from 'events' import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader" import { AnimationManager } from "./animationManager" import { ScreenshotManager } from "./screenshotManager"; @@ -34,6 +35,11 @@ export class CharacterManager { * @type {ScreenshotManager} */ screenshotManager + + /** + * @type {History} + */ + history constructor(options){ this._start(options); } @@ -64,7 +70,7 @@ export class CharacterManager { this.overlayedTextureManager = new OverlayedTextureManager(this) this.blinkManager = new BlinkManager(0.1, 0.1, 0.5, 5) this.emotionManager = new EmotionManager(); - + this.history = new History(this) this.rootModel.add(this.characterModel) this.renderCamera = renderCamera; @@ -679,20 +685,22 @@ export class CharacterManager { * @param {string} groupTraitID - The ID of the trait group. * @param {string} traitID - The ID of the specific trait. * @param {boolean} soloView - Should character display only new loaded trait?. + * @param {string} [textureId] - The ID of the texture to load. + * @param {boolean} [addHistory=true] - Should the trait be added to the history? * @returns {Promise} A Promise that resolves if successful, * or rejects with an error message if not. */ - loadTrait(groupTraitID, traitID, soloView = false) { + loadTrait(groupTraitID, traitID, soloView = false,textureId=undefined,addHistory = true) { return new Promise(async (resolve, reject) => { // Check if manifest data is available if (this.manifestData) { try { // Retrieve the selected trait using manifest data - const selectedTrait = this.manifestData.getTraitOption(groupTraitID, traitID); + const selectedTrait = this.manifestData.getTraitOption(groupTraitID, traitID, textureId); this._checkRestrictionsBeforeLoad(groupTraitID,traitID) // If the trait is found, load it into the avatar using the _loadTraits method if (selectedTrait) { - await this._loadTraits(getAsArray(selectedTrait),soloView); + await this._loadTraits(getAsArray(selectedTrait),soloView,addHistory); resolve(); } } catch (error) { @@ -709,6 +717,44 @@ export class CharacterManager { }); } + /** + * + * @param {string} groupTraitID + * @param {string} traitID + * @param {string} textureId + * @param {boolean} [addHistory=true] + * @returns + */ + loadTextureForTrait(groupTraitID, traitID, textureId,addHistory = true){ + return new Promise(async (resolve, reject) => { + // Check if manifest data is available + if (this.manifestData) { + try { + // Retrieve the selected trait using manifest data + const selectedTrait = this.manifestData.getTraitOption(groupTraitID, traitID, textureId); + + // override the model + + // If the trait is found, load it into the avatar using the _loadTraits method + if (selectedTrait) { + await this._loadTexturesForTraits(getAsArray(selectedTrait),addHistory); + resolve(true); + + } + } catch (error) { + // Reject the Promise with an error message if there's an error during trait retrieval + console.error("Error loading specific trait:", error.message); + reject(new Error("Failed to load specific trait.")); + } + } else { + // Manifest data is not available, log an error and reject the Promise + const errorMessage = "No manifest was loaded, specific trait cannot be loaded."; + console.error(errorMessage); + reject(new Error(errorMessage)); + } + }); + } + /** * Loads a custom trait based on group and URL. * @@ -1065,6 +1111,7 @@ export class CharacterManager { if(blendshapeTraitId == null){ // Deactivated the blendshape trait; dont do anything else delete this.avatar[traitGroupID].blendShapeTraitsInfo[blendshapeGroupId] + this.history.addHistory(this.avatar) return } @@ -1078,7 +1125,7 @@ export class CharacterManager { this.toggleBinaryBlendShape(currentTrait.model, blendShape, true); this.avatar[traitGroupID].blendShapeTraitsInfo[blendShape.getGroupId()] = blendShape; - + this.history.addHistory(this.avatar) } /** * @@ -1689,6 +1736,124 @@ class LoadedData{ } +class History extends EventEmitter { + + /** + * @type {Record[]} + */ + _history = [] + + /** + * @type {CharacterManager} + */ + characterManager + + constructor(characterManager) { + super() + this.characterManager = characterManager + } + + get current(){ + return this.characterManager.avatar + } + + get canUndo() { + return this._history.length > 1 + } + + get manifestData(){ + return this.characterManager.manifestData + } + + /** + * + * @param {Record} data + */ + addHistory(data) { + /** + * Convert the avatar object to a record + * @type {Record} + */ + const object = {} + Object.entries(data).forEach(([_, entry]) => { + object[_] = Object.assign({}, entry) + //@ts-ignore + delete object[_].vrm + //@ts-ignore + delete object[_].model + }) + + this._history.push(object) + this.emit("change",this.current) + } + + async undo() { + if(this._history.length === 1) return + const data = this._history[this._history.length - 2] + this._history.pop() + + const traits = this.manifestData.getGroupModelTraits().map((t)=>t.trait) + const addHistory = false + for(const key of traits){ + const previtem = data[key] + const curr = this.current[key] + + if(!curr && !previtem){ + continue + } + + if(!previtem){ + this.characterManager.removeTrait(key, addHistory) + continue + } + + /** + * If the previous item is the same as the current item, we only need to change the texture (if needed) + */ + if(curr && previtem.traitInfo.id === curr.traitInfo.id){ + + /** + * Check if the texture is the same + */ + if(previtem.textureInfo && previtem.textureInfo?.id !== curr.textureInfo?.id){ + await this.characterManager.loadTextureForTrait(previtem.traitInfo.traitGroup.trait,previtem.traitInfo.id,previtem.textureInfo.id,addHistory) + } + /** + * Check if the blendshape keys are the same + */ + const blendshapeKeys = new Set([...Object.keys(previtem.blendShapeTraitsInfo||{}),...Object.keys(curr.blendShapeTraitsInfo||{})]) + for(const blendshapeKey of blendshapeKeys){ + const blendshape = previtem.blendShapeTraitsInfo[blendshapeKey] + const currentBlendshape = curr.blendShapeTraitsInfo[blendshapeKey] + if(currentBlendshape && !blendshape){ + this.characterManager.removeBlendShapeTrait(previtem.traitInfo.traitGroup.trait,currentBlendshape.getGroupId(),addHistory) + }else if((currentBlendshape && blendshape) && currentBlendshape.id !== blendshape.id){ + this.characterManager.loadBlendShapeTrait(previtem.traitInfo.traitGroup.trait,blendshape.getGroupId(),blendshape.id,addHistory) + } + } + + + /** + * If the previous item is different from the current item, we need to load the previous item + */ + } else if(previtem) { + await this.characterManager.loadTrait(previtem.traitInfo.traitGroup.trait,previtem.traitInfo.id,undefined,previtem.textureInfo?.id,addHistory) + + if(previtem.blendShapeTraitsInfo){ + for(const blendshapeKey in previtem.blendShapeTraitsInfo){ + const blendshape = previtem.blendShapeTraitsInfo[blendshapeKey] + if(!blendshape) continue + this.characterManager.loadBlendShapeTrait(previtem.traitInfo.traitGroup.trait,blendshape.getGroupId(),blendshape.id,addHistory) + } + } + } + } + return this.emit("change",this.current) + } + +} + + /** * * @param {THREE.MeshStandardMaterial|MToonMaterial} mat