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 &&
+
![]({undoIcon})
+
}
{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