diff --git a/src/context/SceneContext.jsx b/src/context/SceneContext.jsx index 6e69215c..def00018 100644 --- a/src/context/SceneContext.jsx +++ b/src/context/SceneContext.jsx @@ -38,8 +38,6 @@ export const SceneProvider = (props) => { const [controls, setControls] = useState(null) const [bonePicker, setBonePicker] = useState(null) const [transformControlsObj, setTransformControlsObj] = useState(null) - const [attachToTransformControlsFn, setAttachToTransformControlsFn] = useState(null) - const [detachTransformControlsFn, setDetachTransformControlsFn] = useState(null) const [transformMode, setTransformMode] = useState('translate') const [transformSnap, setTransformSnap] = useState({ t: 0.05, r: 5, s: 0.05 }) const [transformTarget, setTransformTarget] = useState(null) @@ -56,15 +54,13 @@ export const SceneProvider = (props) => { loaded = true; const init = sceneInitializer("editor-scene"); - const { scene, camera, controls, characterManager, sceneElements } = init; - setBonePicker(init.bonePicker); - setTransformControlsObj(init.transformControls); - setAttachToTransformControlsFn(() => init.attachToTransformControls); - setDetachTransformControlsFn(() => init.detachTransformControls); + const { scene, camera, controls, characterManager, sceneElements, transformControls } = init; + setTransformControlsObj(transformControls); setCamera(camera); setScene(scene); setCharacterManager(characterManager); setSceneElements(sceneElements); + setBonePicker(characterManager.bonePicker); setAnimationManager(characterManager.animationManager) setLookAtManager(characterManager.lookAtManager) setDecalManager(characterManager.overlayedTextureManager) @@ -73,20 +69,21 @@ export const SceneProvider = (props) => { setSpriteAtlasGenerator(new SpriteAtlasGenerator(characterManager)) setThumbnailsGenerator(new ThumbnailGenerator(characterManager)) },[]) + useEffect(()=>{ if (!transformControlsObj) return // apply mode - transformControlsObj.setMode(transformMode) + transformControlsObj.transform.setMode(transformMode) // apply snaps - transformControlsObj.setTranslationSnap(transformSnap.t || null) - transformControlsObj.setRotationSnap(transformSnap.r ? (transformSnap.r * Math.PI / 180) : null) - transformControlsObj.setScaleSnap(transformSnap.s || null) + transformControlsObj.transform.setTranslationSnap(transformSnap.t || null) + transformControlsObj.transform.setRotationSnap(transformSnap.r ? (transformSnap.r * Math.PI / 180) : null) + transformControlsObj.transform.setScaleSnap(transformSnap.s || null) },[transformControlsObj, transformMode, transformSnap]) // Keyboard shortcuts W/E/R like Blender useEffect(()=>{ const onKey = (e) => { - if (!transformControlsObj) return + if (!transformControlsObj?.transform) return if (e.key === 'w' || e.key === 'W') setTransformMode('translate') if (e.key === 'e' || e.key === 'E') setTransformMode('rotate') if (e.key === 'r' || e.key === 'R') setTransformMode('scale') @@ -98,12 +95,12 @@ export const SceneProvider = (props) => { const attachTransformTarget = (obj) => { setTransformTarget(obj) if (characterManager?.setClickCullingEnabled) characterManager.setClickCullingEnabled(false) - if (attachToTransformControlsFn) attachToTransformControlsFn(obj) + if (transformControlsObj.attachToTransformControlsFn) transformControlsObj.attachToTransformControlsFn(obj) } const detachTransformTarget = () => { setTransformTarget(null) if (characterManager?.setClickCullingEnabled) characterManager.setClickCullingEnabled(true) - if (detachTransformControlsFn) detachTransformControlsFn() + if (transformControlsObj.detachTransformControlsFn) transformControlsObj.detachTransformControlsFn() } // Direct manipulation helpers (used by bottom panel toolbar) @@ -230,8 +227,6 @@ export const SceneProvider = (props) => { sceneElements, bonePicker, transformControls: transformControlsObj, - attachToTransformControls: attachToTransformControlsFn, - detachTransformControls: detachTransformControlsFn, transformMode, setTransformMode, transformSnap, diff --git a/src/library/bonePicker.js b/src/library/bonePicker.js index f4242dc9..79c57f58 100644 --- a/src/library/bonePicker.js +++ b/src/library/bonePicker.js @@ -7,13 +7,21 @@ import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; * BonePicker renders small gizmos on humanoid bones and enables hover/click selection. */ export class BonePicker { + /** + * Whether the instance is enabled; + * @type {boolean} + * defaults to false + */ + _allowBonePicking = false + /** * @param {import('./characterManager').CharacterManager} characterManager * @param {THREE.Camera} camera */ - constructor(characterManager, camera) { + constructor(characterManager, canvasID, camera) { this.characterManager = characterManager; this.camera = camera; + this.canvasID = canvasID; /** @type {Array} */ this.markers = []; @@ -35,12 +43,7 @@ export class BonePicker { this.hoverMaterial = new THREE.MeshBasicMaterial({ color: 0xffcc00, depthTest: false }); this.resolution = new THREE.Vector2(window.innerWidth, window.innerHeight); - this._onResize = () => { - this.resolution.set(window.innerWidth, window.innerHeight); - this.boneLines.forEach((ln) => { - if (ln.material && ln.material.resolution) ln.material.resolution.copy(this.resolution); - }) - } + this.majorBones = new Set([ "hips","spine","chest","upperChest","neck","head", @@ -50,13 +53,86 @@ export class BonePicker { "leftFoot","rightFoot" ]); - this._escListener = (e) => { + } + /** + * + * @param {boolean} enabled Optional + */ + toggleAllowBonePicking(enabled=undefined){ + this._allowBonePicking = enabled !== undefined ? enabled : !this._allowBonePicking; + if (this._allowBonePicking){ + this._addListeners(); + } else { + this._removeListeners(); + this.disable(); + } + } + + _escListener = (e) => { if (e.key === "Escape") { this.disable(); } } + + _onResize = () => { + this.resolution.set(window.innerWidth, window.innerHeight); + this.boneLines.forEach((ln) => { + if (ln.material && ln.material.resolution) ln.material.resolution.copy(this.resolution); + }) + } + + setTransformControls(transformControlHelper){ + this.transformControls = transformControlHelper; + } + + + _handleMouseMove = (event) => { + const canvasRef = document.getElementById(this.canvasID) + if(!canvasRef) return; + + const rect = canvasRef.getBoundingClientRect(); + const mousex = ((event.clientX - rect.left) / rect.width) * 2 - 1; + const mousey = -((event.clientY - rect.top) / rect.height) * 2 + 1; + this.handleHover(mousex, mousey); + } + + _handleMouseClick = (event) => { + const canvasRef = document.getElementById(this.canvasID) + if(!canvasRef) return; + + const rect = canvasRef.getBoundingClientRect(); + const mousex = ((event.clientX - rect.left) / rect.width) * 2 - 1; + const mousey = -((event.clientY - rect.top) / rect.height) * 2 + 1; + // If gizmo is being interacted with, ignore clicks for bone selection + if (this.transformControls && !this.transformControls.transform.dragging) { + this.handleClick(mousex, mousey); + } + }; + + _addListeners(){ + const canvasRef = document.getElementById(this.canvasID) + if(canvasRef) { + canvasRef.addEventListener("mousemove", this._handleMouseMove); + canvasRef.addEventListener("click", this._handleMouseClick); + } + } + + _removeListeners(){ + const canvasRef = document.getElementById(this.canvasID) + if(canvasRef) { + canvasRef.removeEventListener("click", this._handleMouseClick); + canvasRef.removeEventListener("mousemove", this._handleMouseMove); + } } + dispose(){ + this._removeListeners(); + this.disable(); + this.transformControls = null; + } + + + /** * Create markers on all humanoid bones of the base skeleton. */ @@ -127,7 +203,7 @@ export class BonePicker { * @param {number} mouseX normalized device coordinate [-1,1] * @param {number} mouseY normalized device coordinate [-1,1] */ - handleHover(mouseX, mouseY) { + handleHover=(mouseX, mouseY) => { if (!this.isActive) return; this.mouse.set(mouseX, mouseY); this.raycaster.setFromCamera(this.mouse, this.camera); diff --git a/src/library/characterManager.js b/src/library/characterManager.js index 643582c3..a67a95f2 100644 --- a/src/library/characterManager.js +++ b/src/library/characterManager.js @@ -11,6 +11,7 @@ import { getNodesWithColliders, saveVRMCollidersToUserData, renameMorphTargets} import { cullHiddenMeshes, setTextureToChildMeshes, addChildAtFirst } from "./utils"; import { LipSync } from "./lipsync"; import { LookAtManager } from "./lookatManager"; +import { BonePicker} from "./bonePicker"; import OverlayedTextureManager from "./OverlayTextureManager"; import { ManifestDataManager } from "./manifestDataManager"; import { WalletCollections } from "./walletCollections"; @@ -49,6 +50,12 @@ export class CharacterManager { * @type {ScreenshotManager} */ screenshotManager + + /** + * @type {BonePicker} + */ + bonePicker + constructor(options){ this._start(options); } @@ -231,6 +238,12 @@ export class CharacterManager { } //this.toggleCharacterLookAtMouse(enable) } + + addBonePicker(canvasID,camera){ + this.bonePicker = new BonePicker(this, canvasID, camera); + this.bonePicker.toggleAllowBonePicking(true); + } + toggleCharacterLookAtMouse(enable){ if (this.lookAtManager != null){ this.lookAtManager.setActive(enable); diff --git a/src/library/sceneInitializer.js b/src/library/sceneInitializer.js index 014137b3..f4f0ceb7 100644 --- a/src/library/sceneInitializer.js +++ b/src/library/sceneInitializer.js @@ -1,8 +1,7 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"; -import { TransformControls } from "three/examples/jsm/controls/TransformControls"; +import TransformControlHelper from "./transformControlHelper"; import { CharacterManager } from "./characterManager"; -import { BonePicker } from "./bonePicker"; import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'; @@ -39,8 +38,8 @@ export function sceneInitializer(canvasId) { const characterManager = new CharacterManager({parentModel: scene, createAnimationManager : true, renderCamera:camera}) - const bonePicker = new BonePicker(characterManager, camera); characterManager.addLookAtMouse(80,canvasId, camera, true); + //"editor-scene" const canvasRef = document.getElementById(canvasId); @@ -54,32 +53,12 @@ export function sceneInitializer(canvasId) { const controls = new OrbitControls(camera, renderer.domElement); controls.minDistance = 1; controls.maxDistance = 4; - const transformControls = new TransformControls(camera, renderer.domElement); - transformControls.enabled = true; - transformControls.setSpace('local'); - transformControls.setSize(2.0); - transformControls.setMode('translate'); - transformControls.addEventListener('dragging-changed', function (event) { - controls.enabled = !event.value; - }); + const transformControls = new TransformControlHelper(controls,camera, renderer.domElement); + characterManager.addBonePicker(canvasId,camera); + characterManager.bonePicker.setTransformControls(transformControls); // Add gizmo to scene to render handles - scene.add(transformControls); + scene.add(transformControls.transform); - const attachToTransformControls = (object3d) => { - transformControls.detach(); - if (object3d && object3d.isObject3D) { - transformControls.attach(object3d); - transformControls.visible = true; - } - } - const detachTransformControls = () => { - if (transformControls.object){ - const axes = transformControls.object.getObjectByName('__gizmoAxes'); - if (axes && axes.parent) axes.parent.remove(axes); - } - transformControls.detach(); - transformControls.visible = false; - }; controls.maxPolarAngle = Math.PI / 2; controls.enablePan = true; @@ -106,7 +85,7 @@ export function sceneInitializer(canvasId) { if (characterManager.getLastAttachedObject){ const target = characterManager.getLastAttachedObject(); if (target && transformControls.object !== target) { - attachToTransformControls(target); + transformControls.attachToTransformControls(target); } } } @@ -123,23 +102,6 @@ export function sceneInitializer(canvasId) { animate(); - const handleMouseMove = (event) => { - const rect = canvasRef.getBoundingClientRect(); - const mousex = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const mousey = -((event.clientY - rect.top) / rect.height) * 2 + 1; - bonePicker.handleHover(mousex, mousey); - } - - const handleMouseClick = (event) => { - const rect = canvasRef.getBoundingClientRect(); - const mousex = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const mousey = -((event.clientY - rect.top) / rect.height) * 2 + 1; - // If gizmo is being interacted with, ignore clicks for bone selection - if (!transformControls.dragging) { - bonePicker.handleClick(mousex, mousey); - } - }; - async function fetchScene() { // // load environment @@ -152,19 +114,12 @@ export function sceneInitializer(canvasId) { } fetchScene(); - - canvasRef.addEventListener("mousemove", handleMouseMove); - canvasRef.addEventListener("click", handleMouseClick); - return { scene, camera, controls, characterManager, - bonePicker, transformControls, - attachToTransformControls, - detachTransformControls, sceneElements, clock }; diff --git a/src/library/transformControlHelper.js b/src/library/transformControlHelper.js new file mode 100644 index 00000000..03fd95a5 --- /dev/null +++ b/src/library/transformControlHelper.js @@ -0,0 +1,72 @@ +import { TransformControls } from "three/examples/jsm/controls/TransformControls"; + + +export default class TransformControlHelper { + /** + * @type {TransformControls} transformControls + */ + transformControls + /** + * OrbitControls instance to disable when dragging + */ + controls + + /** + * @type {THREE.Camera} camera + */ + camera + /** + * @type {HTMLElement} domElement + */ + domElement + + /** + * + * @param {OrbitControls} controls + * @param {THREE.Camera} camera + * @param {HTMLCanvasElement} domElement + */ + constructor(controls, camera, domElement) { + this.controls = controls; + this.camera = camera; + this.domElement = domElement; + + this.transformControls = new TransformControls(camera, domElement); + this.transformControls.enabled = true; + this.transformControls.setSpace('local'); + this.transformControls.setSize(2.0); + this.transformControls.setMode('translate'); + this.transformControls.addEventListener('dragging-changed', function (event) { + controls.enabled = !event.value; + }); + } + + get transform (){ + return this.transformControls; + } + + get object(){ + return this.transformControls.object; + } + + attachToTransformControls = (object3d) => { + this.transformControls.detach(); + if (object3d && object3d.isObject3D) { + this.transformControls.attach(object3d); + this.transformControls.visible = true; + } + } + + detachTransformControls = () => { + if (this.transformControls.object){ + const axes = this.transformControls.object.getObjectByName('__gizmoAxes'); + if (axes && axes.parent) axes.parent.remove(axes); + } + this.transformControls.detach(); + this.transformControls.visible = false; + }; + + + + +} \ No newline at end of file diff --git a/src/pages/Appearance.jsx b/src/pages/Appearance.jsx index 77bc6506..8b88ba74 100644 --- a/src/pages/Appearance.jsx +++ b/src/pages/Appearance.jsx @@ -42,8 +42,9 @@ function Appearance() { animationManager, moveCamera, bonePicker, - attachToTransformControls, - detachTransformControls, + transformControls, + // attachToTransformControls, + // detachTransformControls, attachTransformTarget, } = React.useContext(SceneContext) @@ -78,7 +79,6 @@ function Appearance() { const [isPickingColor, setIsPickingColor] = React.useState(false) const [colorPicked, setColorPicked] = React.useState({ background: '#ffffff' }) const [selectingBone, setSelectingBone] = React.useState(false) - const [modelFile, setModelFile] = React.useState(null) const [modelUrl, setModelUrl] = React.useState(null) const modelUrlRef = React.useRef(null) @@ -132,7 +132,7 @@ function Appearance() { console.warn("Please select a group trait first.") } } - const handleModelDrop = (file) =>{ + const handleGLBDrop = (file) =>{ if (selectedTraitGroup != null && selectedTraitGroup.trait != ""){ console.log(selectedTraitGroup); console.log("dropeed glb"); @@ -145,7 +145,6 @@ function Appearance() { }catch(e){ console.error("Failed to create object URL", e) } - setModelFile(file); if (bonePicker){ bonePicker.enable((boneName)=>{ placeModelOnBone(boneName) @@ -272,7 +271,7 @@ function Appearance() { handleJsonDrop(files); } if (file && (file.name.toLowerCase().endsWith('.gltf') || file.name.toLowerCase().endsWith('.glb') )) { - handleModelDrop(file); + handleGLBDrop(file); } }; @@ -335,7 +334,6 @@ function Appearance() { } characterManager.loadCustomModelTrait(selectedTraitGroup.trait, urlToUse, boneName).then(()=>{ setIsLoading(false); - setModelFile(null) URL.revokeObjectURL(urlToUse); setModelUrl(null); modelUrlRef.current = null; @@ -348,15 +346,15 @@ function Appearance() { const bone = characterManager.baseSkeletonVRM.humanoid.humanBones[boneName]?.node const last = bone && bone.children && bone.children[bone.children.length-1] if (last) { - attachToTransformControls(last) + transformControls.attachToTransformControls(last) if (attachTransformTarget) attachTransformTarget(last) return } } - if (attachToTransformControls && node){ + if (transformControls.attachToTransformControls && node){ // Attach the GLTF root for direct manipulation (defer one tick to ensure it is in scene graph) requestAnimationFrame(()=>{ - attachToTransformControls(node); + transformControls.attachToTransformControls(node); if (attachTransformTarget) attachTransformTarget(node) }) }