diff --git a/public/loot-assets b/public/loot-assets new file mode 160000 index 000000000..24e2d4701 --- /dev/null +++ b/public/loot-assets @@ -0,0 +1 @@ +Subproject commit 24e2d470149e6a3235cb0cce9135bea4abafb1a3 diff --git a/src/components/BoneSelector.css b/src/components/BoneSelector.css new file mode 100644 index 000000000..db0eb6144 --- /dev/null +++ b/src/components/BoneSelector.css @@ -0,0 +1,51 @@ +.bone-selector { + position: absolute; + display: flex; + justify-content: center; /* Center horizontally */ + align-items: center; /* Center vertically */ + height: 100vh; /* Full viewport height */ + width: 100vw; /* Full viewport height */ + pointer-events: none; + top:-30px; +} + +.character-base { + /* position: relative; + display: inline-block; + width: 300px; + height: 600px; + display: flex; + justify-content: center; + text-align: center; */ + user-select: none; + + height: 70%; + position: relative; + display: inline-block; +} + +.character-base img { + height: 100%; + /* width: 100%; */ +} + +.bone-dot { + pointer-events: auto; + user-select: none; + position: relative; + width: 0px; + height: 0px; + background-size: cover; + + cursor: pointer; +} + +.bone-dot img { + width: 30px; + height: 30px; + transform: translate(-50%, -50%); /* Center the dot */ +} + +.bone-dot-position { + position: absolute; +} \ No newline at end of file diff --git a/src/components/BoneSelector.jsx b/src/components/BoneSelector.jsx new file mode 100644 index 000000000..197d0a4d3 --- /dev/null +++ b/src/components/BoneSelector.jsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import './BoneSelector.css'; // Import CSS for styling +import boneDot from "../images/humanoid_option.png" +import humanoidUI from "../images/humanoid_ui.png" + +// Example CharacterBase component +const CharacterBase = ({}) => { + return ( +
+ +
+ ); +}; + +// Example BoneDot component +const BoneDot = ({ position, onSelect }) => { + return ( +
onSelect(position)}> + +
+ ); +}; + +// Example BoneSelector component +export const BoneSelector = ({onSelect}) => { + const [bonePositions, setBonePositions] = useState([ + { x: 0, y: 10, name:"hips" }, + + { x: 0, y: 60, name:"spine" }, + { x: 0, y: 120, name:"chest" }, + { x: 0, y: 180, name:"upperChest" }, + { x: 0, y: 220, name:"neck" }, + { x: 0, y: 260, name:"head" }, + + { x: -40, y: 180, name:"leftShoulder" }, + { x: 40, y: 180, name:"rightShoulder" }, + + { x: -70, y: 160, name:"leftUpperArm" }, + { x: 70, y: 160, name:"rightUpperArm" }, + + { x: -80, y: 80, name:"leftLowerArm" }, + { x: 80, y: 80, name:"rightLowerArm" }, + + { x: -80, y: -20, name:"leftHand" }, + { x: 80, y: -20, name:"rightHand" }, + + { x: -30, y: -20, name:"leftUpperLeg" }, + { x: 30, y: -20, name:"rightUpperLeg" }, + + { x: -22, y: -120, name:"leftLowerLeg" }, + { x: 20, y: -120, name:"rightLowerLeg" }, + + { x: -17, y: -260, name:"leftFoot" }, + { x: 17, y: -260, name:"rightFoot" }, + // Add more positions as needed + ]); + + const handleSelect = (position) => { + console.log('Bone selected at:', position); + onSelect(position.name); + // Additional logic for selecting a bone can be added here + }; + + return ( +
+ +
+ {bonePositions.map((pos, index) => ( + + ))} +
+
+ ); +}; diff --git a/src/components/TransformInspector.jsx b/src/components/TransformInspector.jsx new file mode 100644 index 000000000..1ac6601fb --- /dev/null +++ b/src/components/TransformInspector.jsx @@ -0,0 +1,121 @@ +import React from 'react' +import styles from './TransformInspector.module.css' +import { SceneContext } from '../context/SceneContext' + +export default function TransformInspector(){ + const { + transformTarget, + applyTranslateDelta, + applyRotateDelta, + applyScaleDelta, + getBoneNames, + reparentToBone, + getAttachedBoneName, + copyTransform, + pasteTransform, + resetToBoneOrigin, + } = React.useContext(SceneContext) + + const [bone, setBone] = React.useState('') + const bones = getBoneNames() + const [clipboard, setClipboard] = React.useState(null) + const deg = 180/Math.PI + const [pos, setPos] = React.useState({x:0,y:0,z:0}) + const [rot, setRot] = React.useState({x:0,y:0,z:0}) // degrees + const [scl, setScl] = React.useState({x:1,y:1,z:1}) + const [u, setU] = React.useState(1) + + React.useEffect(()=>{ + if (bones && bones.length && !bone) setBone(bones.includes('head')?'head':bones[0]) + }, [bones]) + + React.useEffect(()=>{ + if (!transformTarget) return + const p = transformTarget.position + const r = transformTarget.rotation + const s = transformTarget.scale + setPos({x:p.x,y:p.y,z:p.z}) + setRot({x:r.x*deg,y:r.y*deg,z:r.z*deg}) + setScl({x:s.x,y:s.y,z:s.z}) + setU((s.x + s.y + s.z)/3) + }, [transformTarget]) + + if (!transformTarget) return null + + return ( +
+
Trait Position
+
+ + { const v = Number(e.target.value); applyTranslateDelta(v-pos.x,0,0); setPos(prev=>({...prev,x:v})) }} /> + { const v=Number(e.target.value); applyTranslateDelta(v-pos.x,0,0); setPos(prev=>({...prev,x:v})) }} /> +
+
+ + { const v = Number(e.target.value); applyTranslateDelta(0,v-pos.y,0); setPos(prev=>({...prev,y:v})) }} /> + { const v=Number(e.target.value); applyTranslateDelta(0,v-pos.y,0); setPos(prev=>({...prev,y:v})) }} /> +
+
+ + { const v = Number(e.target.value); applyTranslateDelta(0,0,v-pos.z); setPos(prev=>({...prev,z:v})) }} /> + { const v=Number(e.target.value); applyTranslateDelta(0,0,v-pos.z); setPos(prev=>({...prev,z:v})) }} /> +
+ +
Trait Rotation
+
+ + { const v=Number(e.target.value); applyRotateDelta(v-rot.x,0,0); setRot(prev=>({...prev,x:v})) }} /> + { const v=Number(e.target.value); applyRotateDelta(v-rot.x,0,0); setRot(prev=>({...prev,x:v})) }} /> +
+
+ + { const v=Number(e.target.value); applyRotateDelta(0,v-rot.y,0); setRot(prev=>({...prev,y:v})) }} /> + { const v=Number(e.target.value); applyRotateDelta(0,v-rot.y,0); setRot(prev=>({...prev,y:v})) }} /> +
+
+ + { const v=Number(e.target.value); applyRotateDelta(0,0,v-rot.z); setRot(prev=>({...prev,z:v})) }} /> + { const v=Number(e.target.value); applyRotateDelta(0,0,v-rot.z); setRot(prev=>(({...prev,z:v})) ) }} /> +
+ +
Trait Scale
+
+ + { const v=Number(e.target.value); const dx=v-scl.x; const dy=v-scl.y; const dz=v-scl.z; applyScaleDelta(dx,dy,dz); setScl({x:v,y:v,z:v}); setU(v); }} /> + { const v=Number(e.target.value); const dx=v-scl.x; const dy=v-scl.y; const dz=v-scl.z; applyScaleDelta(dx,dy,dz); setScl({x:v,y:v,z:v}); setU(v); }} /> +
+
+ + { const v=Number(e.target.value); applyScaleDelta(v-scl.x,0,0); setScl(prev=>({...prev,x:v})); setU((v+scl.y+scl.z)/3); }} /> + { const v=Number(e.target.value); applyScaleDelta(v-scl.x,0,0); setScl(prev=>({...prev,x:v})); setU((v+scl.y+scl.z)/3); }} /> +
+
+ + { const v=Number(e.target.value); applyScaleDelta(0,v-scl.y,0); setScl(prev=>({...prev,y:v})); setU((scl.x+v+scl.z)/3); }} /> + { const v=Number(e.target.value); applyScaleDelta(0,v-scl.y,0); setScl(prev=>({...prev,y:v})); setU((scl.x+v+scl.z)/3); }} /> +
+
+ + { const v=Number(e.target.value); applyScaleDelta(0,0,v-scl.z); setScl(prev=>({...prev,z:v})); setU((scl.x+scl.y+v)/3); }} /> + { const v=Number(e.target.value); applyScaleDelta(0,0,v-scl.z); setScl(prev=>({...prev,z:v})); setU((scl.x+scl.y+v)/3); }} /> +
+ +
Attach To Bone
+
+ + +
+ +
Utilities
+
+ + + +
+
+ ) +} + + diff --git a/src/components/TransformInspector.module.css b/src/components/TransformInspector.module.css new file mode 100644 index 000000000..3a427109a --- /dev/null +++ b/src/components/TransformInspector.module.css @@ -0,0 +1,42 @@ +.panel{ + position: absolute; + top: 80px; + right: 24px; + width: 260px; + padding: 12px; + border-radius: 12px; + background: rgba(5,11,14,0.8); + color: #fff; + backdrop-filter: blur(22.5px); + z-index: 1001; +} +.header{ + font-weight: 800; + letter-spacing: 1px; + margin: 8px 0; +} +.row{ + display: flex; + align-items: center; + gap: 8px; + margin: 8px 0; +} +label{ width: 96px; font-size: 12px; color: #ddd; } +.select{ flex:1; } +.num{ + width: 48px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.06); + color: #fff; +} +.btn{ + padding: 6px 10px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.06); + color: #fff; + border-radius: 8px; +} + + diff --git a/src/components/TransformToolbar.jsx b/src/components/TransformToolbar.jsx new file mode 100644 index 000000000..a07b0deac --- /dev/null +++ b/src/components/TransformToolbar.jsx @@ -0,0 +1,94 @@ +import React from 'react' +import styles from './TransformToolbar.module.css' +import { SceneContext } from '../context/SceneContext' + +export default function TransformToolbar(){ + const { + transformMode, + setTransformMode, + transformSnap, + setTransformSnap, + transformTarget, + detachTransformTarget, + applyTranslateDelta, + applyRotateDelta, + applyScaleDelta, + } = React.useContext(SceneContext) + + const [plane, setPlane] = React.useState('XY') // for translate only + + const onSnapChange = (key) => (e) => { + const v = Number(e.target.value) + setTransformSnap({ ...transformSnap, [key]: isNaN(v) ? 0 : v }) + } + + if (!transformTarget) return null + + const stepValue = transformMode === 'translate' ? transformSnap.t : transformMode === 'rotate' ? transformSnap.r : transformSnap.s + const setStepValue = (v) => { + const num = Number(v) + if (isNaN(num)) return + if (transformMode === 'translate') setTransformSnap({ ...transformSnap, t: num }) + else if (transformMode === 'rotate') setTransformSnap({ ...transformSnap, r: num }) + else setTransformSnap({ ...transformSnap, s: num }) + } + + const onArrow = (dir) => () => { + if (transformMode === 'translate'){ + const s = transformSnap.t + if (plane === 'XY'){ + if (dir==='up') return applyTranslateDelta(0, s, 0) + if (dir==='down') return applyTranslateDelta(0, -s, 0) + } else { + if (dir==='up') return applyTranslateDelta(0, 0, s) + if (dir==='down') return applyTranslateDelta(0, 0, -s) + } + if (dir==='left') return applyTranslateDelta(-s, 0, 0) + if (dir==='right') return applyTranslateDelta(s, 0, 0) + } + if (transformMode === 'rotate'){ + const r = transformSnap.r + if (dir==='up') return applyRotateDelta(r, 0, 0) + if (dir==='down') return applyRotateDelta(-r, 0, 0) + if (dir==='left') return applyRotateDelta(0, -r, 0) + if (dir==='right') return applyRotateDelta(0, r, 0) + } + if (transformMode === 'scale'){ + const s = transformSnap.s + const d = (dir==='up' || dir==='right') ? s : -s + return applyScaleDelta(d, d, d) + } + } + + return ( +
+
+ + + +
+ {transformMode==='translate' && ( +
+ + +
+ )} +
+ +
+ + + +
+ +
+
+ + setStepValue(e.target.value)} /> + +
+
+ ) +} + + diff --git a/src/components/TransformToolbar.module.css b/src/components/TransformToolbar.module.css new file mode 100644 index 000000000..e9b74dea1 --- /dev/null +++ b/src/components/TransformToolbar.module.css @@ -0,0 +1,104 @@ +.toolbar{ + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 16px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(5,11,14,0.8); + backdrop-filter: blur(22.5px); + z-index: 1001; +} +.group{ + display: flex; + align-items: center; + gap: 8px; +} +.dpad{ + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} +.row{ + display: flex; + align-items: center; + gap: 6px; +} +.arrow{ + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.14); + color: #fff; + background: rgba(255,255,255,0.06); + cursor: pointer; + font-size: 16px; +} +.center{ + width: 36px; + height: 36px; + border-radius: 8px; + background: transparent; + border: 1px dashed rgba(255,255,255,0.12); +} +.compact{ + display: flex; + align-items: center; + gap: 8px; +} +.seg{ + display:flex; + align-items:center; + border:1px solid rgba(255,255,255,0.14); + border-radius:8px; + overflow:hidden; +} +.segBtn{ + padding:6px 10px; + color:#fff; + background:rgba(255,255,255,0.06); + border:none; + cursor:pointer; +} +.segBtn + .segBtn{ border-left:1px solid rgba(255,255,255,0.14); } +.btn{ + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.14); + color: #fff; + background: rgba(255,255,255,0.06); + cursor: pointer; +} +.active{ + background: rgba(0,194,255,0.25); + border-color: rgba(0,194,255,0.6); +} +.label{ + color: #ddd; + font-size: 12px; +} +.input{ + width: 64px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.06); + color: #fff; +} +.inputSmall{ + width: 56px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.06); + color: #fff; +} + + diff --git a/src/context/SceneContext.jsx b/src/context/SceneContext.jsx index d22f76821..def000187 100644 --- a/src/context/SceneContext.jsx +++ b/src/context/SceneContext.jsx @@ -36,6 +36,11 @@ export const SceneProvider = (props) => { const [scene, setScene] = useState(null) const [camera, setCamera] = useState(null) const [controls, setControls] = useState(null) + const [bonePicker, setBonePicker] = useState(null) + const [transformControlsObj, setTransformControlsObj] = useState(null) + const [transformMode, setTransformMode] = useState('translate') + const [transformSnap, setTransformSnap] = useState({ t: 0.05, r: 5, s: 0.05 }) + const [transformTarget, setTransformTarget] = useState(null) const [manifest, setManifest] = useState(null) const [debugMode, setDebugMode] = useState(false); @@ -48,17 +53,14 @@ export const SceneProvider = (props) => { setIsLoaded(true) loaded = true; - const { - scene, - camera, - controls, - characterManager, - sceneElements - } = sceneInitializer("editor-scene"); + const init = sceneInitializer("editor-scene"); + 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) @@ -67,6 +69,67 @@ export const SceneProvider = (props) => { setSpriteAtlasGenerator(new SpriteAtlasGenerator(characterManager)) setThumbnailsGenerator(new ThumbnailGenerator(characterManager)) },[]) + + useEffect(()=>{ + if (!transformControlsObj) return + // apply mode + transformControlsObj.transform.setMode(transformMode) + // apply snaps + 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?.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') + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [transformControlsObj]) + + const attachTransformTarget = (obj) => { + setTransformTarget(obj) + if (characterManager?.setClickCullingEnabled) characterManager.setClickCullingEnabled(false) + if (transformControlsObj.attachToTransformControlsFn) transformControlsObj.attachToTransformControlsFn(obj) + } + const detachTransformTarget = () => { + setTransformTarget(null) + if (characterManager?.setClickCullingEnabled) characterManager.setClickCullingEnabled(true) + if (transformControlsObj.detachTransformControlsFn) transformControlsObj.detachTransformControlsFn() + } + + // Direct manipulation helpers (used by bottom panel toolbar) + const applyTranslateDelta = (dx, dy, dz) => { + if (!transformTarget) return + transformTarget.position.x += dx || 0 + transformTarget.position.y += dy || 0 + transformTarget.position.z += dz || 0 + transformTarget.updateMatrixWorld(true) + } + const applyRotateDelta = (rxDeg, ryDeg, rzDeg) => { + if (!transformTarget) return + const rx = (rxDeg || 0) * Math.PI / 180 + const ry = (ryDeg || 0) * Math.PI / 180 + const rz = (rzDeg || 0) * Math.PI / 180 + transformTarget.rotation.x += rx + transformTarget.rotation.y += ry + transformTarget.rotation.z += rz + transformTarget.updateMatrixWorld(true) + } + const applyScaleDelta = (sx, sy, sz) => { + if (!transformTarget) return + const clamp = (v) => Math.max(0.001, v) + transformTarget.scale.x = clamp(transformTarget.scale.x + (sx || 0)) + transformTarget.scale.y = clamp(transformTarget.scale.y + (sy || 0)) + transformTarget.scale.z = clamp(transformTarget.scale.z + (sz || 0)) + transformTarget.updateMatrixWorld(true) + } + const toggleDebugMode = (isDebug) => { @@ -162,6 +225,39 @@ export const SceneProvider = (props) => { moveCamera, controls, sceneElements, + bonePicker, + transformControls: transformControlsObj, + transformMode, + setTransformMode, + transformSnap, + setTransformSnap, + transformTarget, + attachTransformTarget, + detachTransformTarget, + applyTranslateDelta, + applyRotateDelta, + applyScaleDelta, + getBoneNames: () => characterManager?.getHumanoidBoneNames?.() || [], + reparentToBone: (name) => characterManager?.reparentLastAttachedToBone?.(name), + getAttachedBoneName: () => characterManager?.getLastAttachedBoneName?.() || null, + copyTransform: () => { + const o = transformTarget; if (!o) return null; + return { p: o.position.toArray(), q: o.quaternion.toArray(), s: o.scale.toArray() } + }, + pasteTransform: (t) => { + if (!transformTarget || !t) return; + const THREE_ = window.THREE || null; + if (t.p) transformTarget.position.set(t.p[0],t.p[1],t.p[2]); + if (t.q && THREE_) transformTarget.quaternion.set(t.q[0],t.q[1],t.q[2],t.q[3]); + if (t.s) transformTarget.scale.set(t.s[0],t.s[1],t.s[2]); + transformTarget.updateMatrixWorld(true); + }, + resetToBoneOrigin: () => { + const handle = transformTarget; if (!handle) return; + handle.position.set(0,0,0); + handle.quaternion.set(0,0,0,1); + handle.updateMatrixWorld(true); + } }} > {props.children} diff --git a/src/images/humanoid_option.png b/src/images/humanoid_option.png new file mode 100644 index 000000000..edd905c52 Binary files /dev/null and b/src/images/humanoid_option.png differ diff --git a/src/images/humanoid_ui.png b/src/images/humanoid_ui.png new file mode 100644 index 000000000..93f6c1308 Binary files /dev/null and b/src/images/humanoid_ui.png differ diff --git a/src/library/CharacterManifestData.js b/src/library/CharacterManifestData.js index 2fd4d7022..9ed7da4f1 100644 --- a/src/library/CharacterManifestData.js +++ b/src/library/CharacterManifestData.js @@ -53,6 +53,7 @@ export class CharacterManifestData{ exportScale, displayScale, initialTraits, + mainTrait, requiredTraits, randomTraits, colliderTraits, @@ -104,6 +105,7 @@ export class CharacterManifestData{ this.requiredTraits = getAsArray(requiredTraits); this.randomTraits = getAsArray(randomTraits); this.initialTraits = initialTraits || [...new Set(this.requiredTraits.concat(this.randomTraits))]; + this.mainTrait = mainTrait || this.requiredTraits[0] || traits[0].trait; this.colliderTraits = getAsArray(colliderTraits); this.lipSyncTraits = getAsArray(lipSyncTraits); this.blinkerTraits = getAsArray(blinkerTraits); diff --git a/src/library/bonePicker.js b/src/library/bonePicker.js new file mode 100644 index 000000000..79c57f580 --- /dev/null +++ b/src/library/bonePicker.js @@ -0,0 +1,335 @@ +import * as THREE from "three"; +import { Line2 } from 'three/examples/jsm/lines/Line2.js'; +import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; +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, canvasID, camera) { + this.characterManager = characterManager; + this.camera = camera; + this.canvasID = canvasID; + + /** @type {Array} */ + this.markers = []; + /** @type {Array} */ + this.boneLines = []; + /** @type {Record} */ + this.nodeByName = {}; + /** @type {Record>} */ + this.linesByBoneName = {}; + this.isActive = false; + this.onPick = null; + this.hovered = null; + + this.raycaster = new THREE.Raycaster(); + this.mouse = new THREE.Vector2(); + + this.markerGeometry = new THREE.SphereGeometry(0.02, 16, 12); + this.idleMaterial = new THREE.MeshBasicMaterial({ color: 0x00c2ff, depthTest: false }); + this.hoverMaterial = new THREE.MeshBasicMaterial({ color: 0xffcc00, depthTest: false }); + + this.resolution = new THREE.Vector2(window.innerWidth, window.innerHeight); + + + this.majorBones = new Set([ + "hips","spine","chest","upperChest","neck","head", + "leftShoulder","rightShoulder","leftUpperArm","rightUpperArm", + "leftLowerArm","rightLowerArm","leftHand","rightHand", + "leftUpperLeg","rightUpperLeg","leftLowerLeg","rightLowerLeg", + "leftFoot","rightFoot" + ]); + + } + /** + * + * @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. + */ + createMarkers() { + this.disposeMarkers(); + const base = this.characterManager.baseSkeletonVRM; + if (!base || !base.humanoid || !base.humanoid.humanBones) return; + const bones = base.humanoid.humanBones; + + Object.entries(bones).forEach(([boneName, boneObj]) => { + const node = boneObj?.node; + if (!node) return; + if (!this.majorBones.has(boneName)) return; + const marker = new THREE.Mesh(this.markerGeometry, this.idleMaterial.clone()); + marker.name = `bone-marker:${boneName}`; + marker.userData.boneName = boneName; + marker.renderOrder = 9999; + marker.frustumCulled = false; + // Slight offset to avoid z-fighting + marker.position.set(0, 0, 0); + node.add(marker); + this.markers.push(marker); + this.nodeByName[boneName] = node; + + const label = this._createLabelSprite(boneName); + label.position.set(0.06, 0.06, 0); + marker.add(label); + }); + + this._createBoneLines(); + } + + disposeMarkers() { + this.markers.forEach((m) => { + if (m.parent) m.parent.remove(m); + }); + this.markers = []; + this.boneLines.forEach((ln) => { + if (ln.parent) ln.parent.remove(ln); + }); + this.boneLines = []; + this.linesByBoneName = {}; + this.nodeByName = {}; + this.hovered = null; + } + + enable(onPick) { + this.onPick = onPick; + this.createMarkers(); + this.isActive = true; + window.addEventListener("keydown", this._escListener); + window.addEventListener("resize", this._onResize); + if (this.characterManager?.setClickCullingEnabled) + this.characterManager.setClickCullingEnabled(false) + } + + disable() { + this.isActive = false; + this.onPick = null; + this.disposeMarkers(); + window.removeEventListener("keydown", this._escListener); + window.removeEventListener("resize", this._onResize); + if (this.characterManager?.setClickCullingEnabled) + this.characterManager.setClickCullingEnabled(true) + } + + /** + * @param {number} mouseX normalized device coordinate [-1,1] + * @param {number} mouseY normalized device coordinate [-1,1] + */ + handleHover=(mouseX, mouseY) => { + if (!this.isActive) return; + this.mouse.set(mouseX, mouseY); + this.raycaster.setFromCamera(this.mouse, this.camera); + // keep lines in sync (cheap: ~20 bones) + this._updateBoneLinesPositions(); + const hits = this.raycaster.intersectObjects(this.markers, false); + + const newHover = hits.length > 0 ? hits[0].object : null; + if (newHover !== this.hovered) { + if (this.hovered) { + this.hovered.material = this.idleMaterial.clone(); + this.hovered.scale.set(1, 1, 1); + this._setLinesColor(this.hovered.userData?.boneName, 0x00c2ff); + } + this.hovered = newHover; + if (this.hovered) { + this.hovered.material = this.hoverMaterial.clone(); + this.hovered.scale.set(1.4, 1.4, 1.4); + this._setLinesColor(this.hovered.userData?.boneName, 0xffcc00); + } + } + } + + /** + * @param {number} mouseX normalized device coordinate [-1,1] + * @param {number} mouseY normalized device coordinate [-1,1] + */ + handleClick(mouseX, mouseY) { + if (!this.isActive) return; + this.mouse.set(mouseX, mouseY); + this.raycaster.setFromCamera(this.mouse, this.camera); + const hits = this.raycaster.intersectObjects(this.markers, false); + if (hits.length > 0) { + const marker = hits[0].object; + const boneName = marker.userData?.boneName; + if (boneName && this.onPick) { + const cb = this.onPick; + this.disable(); + cb(boneName); + } + } + } + + _createLabelSprite(text) { + const canvas = document.createElement("canvas"); + const size = 128; + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.strokeStyle = "rgba(255,255,255,0.9)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(8, 8, size - 16, 40, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "white"; + ctx.font = "20px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, size / 2, 28); + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.LinearFilter; + const material = new THREE.SpriteMaterial({ map: texture, depthTest: false }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(0.18, 0.18, 1); + return sprite; + } + + _createBoneLines() { + const color = 0x00c2ff; + const namesSet = new Set(Object.keys(this.nodeByName)); + + Object.entries(this.nodeByName).forEach(([boneName, node]) => { + const childLines = []; + node.children.forEach((child) => { + if (!(child instanceof THREE.Object3D)) return; + // Connect only to children that are also known humanoid nodes + if (!namesSet.has(child.name)) return; + const geom = new LineGeometry(); + // compute child position in node local space + const childWorld = new THREE.Vector3(); + child.getWorldPosition(childWorld); + const localEnd = node.worldToLocal(childWorld.clone()); + const positions = [0, 0, 0, localEnd.x, localEnd.y, localEnd.z]; + geom.setPositions(positions); + const mat = new LineMaterial({ + color, + linewidth: 4, + transparent: true, + opacity: 0.95, + depthTest: false, + }); + mat.resolution = this.resolution.clone(); + const line = new Line2(geom, mat); + line.computeLineDistances(); + line.renderOrder = 9998; + line.userData.boneName = boneName; + node.add(line); + this.boneLines.push(line); + childLines.push(line); + }); + if (childLines.length) this.linesByBoneName[boneName] = childLines; + }); + } + + _updateBoneLinesPositions() { + Object.entries(this.linesByBoneName).forEach(([boneName, lines]) => { + const node = this.nodeByName[boneName]; + if (!node) return; + lines.forEach((line) => { + const child = line.parent && line.parent !== node ? null : null; // lines are attached to node + // Recompute end point based on the first index > 0 of geometry (second point) + // Find the child by computing from current geometry's end point world pos? Simpler: leave static. + // Keep it cheap: skip dynamic updates when animating heavily. + }); + }); + } + + _setLinesColor(boneName, color) { + const lines = this.linesByBoneName[boneName] || []; + lines.forEach((ln) => { + if (ln.material?.color) ln.material.color.setHex(color); + }); + } +} + + diff --git a/src/library/characterManager.js b/src/library/characterManager.js index 10386e313..a67a95f28 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); } @@ -98,9 +105,10 @@ export class CharacterManager { this.manifestDataManager.loadManifest(manifestURL,manifestIdentifier).then(()=>{ this.animationManager.setScale(this.manifestDataManager.getDisplayScale()); }) - } + this.baseSkeletonVRM = null + this.avatar = {}; // Holds information of traits within the avatar this.storedAvatar = {}; // Holds information of an avatar previously stored this.traitLoadManager = new TraitLoadingManager(); @@ -110,6 +118,55 @@ export class CharacterManager { helperRoot.renderOrder = 10000; this.rootModel.add(helperRoot) this.vrmHelperRoot = helperRoot; + + // When false, clicks on the canvas will not trigger culling + this.clickCullingEnabled = true; + + /** @type {THREE.Object3D|null} */ + this.lastAttachedObject = null; + /** @type {string|null} */ + this.lastAttachedBoneName = null; + } + + setClickCullingEnabled(enabled){ + this.clickCullingEnabled = !!enabled; + } + + /** + * Returns the most recently attached THREE.Object3D for external tools (gizmos) to target. + */ + getLastAttachedObject(){ + return this.lastAttachedObject; + } + getLastAttachedBoneName(){ + return this.lastAttachedBoneName; + } + /** + * Returns a list of available humanoid bone names. + */ + getHumanoidBoneNames(){ + const bones = this.baseSkeletonVRM?.humanoid?.humanBones || {}; + return Object.keys(bones); + } + /** + * Reparent the gizmo handle (and child model) under a different bone. + * @param {string} boneName + */ + reparentLastAttachedToBone(boneName){ + const handle = this.lastAttachedObject; + if (!handle) return; + const targetBone = this.baseSkeletonVRM?.humanoid?.humanBones?.[boneName]?.node; + if (!targetBone) return; + // Preserve world matrix of the handle while moving under new bone + handle.updateMatrixWorld(true); + const world = new THREE.Matrix4().copy(handle.matrixWorld); + const boneWorldInv = new THREE.Matrix4().copy(targetBone.matrixWorld).invert(); + const local = new THREE.Matrix4().multiplyMatrices(boneWorldInv, world); + targetBone.add(handle); + handle.matrixAutoUpdate = false; + handle.matrix.copy(local); + handle.matrix.decompose(handle.position, handle.quaternion, handle.scale); + handle.updateMatrixWorld(true); } /** @@ -181,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); @@ -219,6 +282,9 @@ export class CharacterManager { // XXX just call raycast culling without sneding mouse position? cameraRaycastCulling(mouseX, mouseY, removeFace = true){ + if (this.clickCullingEnabled === false){ + return; + } if (this.renderCamera == null){ console.warn("No camera was set in character manager. Please call setRenderCamera(camera) before calling this function") return; @@ -302,9 +368,9 @@ export class CharacterManager { removeCurrentCharacter(){ const clearTraitData = [] for (const prop in this.avatar){ - clearTraitData.push(new LoadedData({traitGroupID:prop, traitModel:null})) } + this.baseSkeletonVRM = null; clearTraitData.forEach(itemData => { this._addLoadedData(itemData) }); @@ -845,6 +911,35 @@ export class CharacterManager { }); } + loadCustomModelTrait(groupTraitID, url, parentBoneName){ + console.log(parentBoneName); + return new Promise(async (resolve, reject) => { + // Check if manifest data is available + if (this.manifestDataManager.hasExistingManifest()) { + try { + // Retrieve the selected custom trait using manifest data + const selectedTrait = this.manifestDataManager.getCustomTraitOption(groupTraitID, url); + console.log(selectedTrait); + // If the custom trait is found, load it into the avatar using the _loadTraits method + if (selectedTrait) { + await this._loadTraits(getAsArray(selectedTrait),false, parentBoneName); + resolve(); + } + + } catch (error) { + // Reject the Promise with an error message if there's an error during custom trait retrieval + console.error("Error loading custom trait:", error.message); + reject(new Error("Failed to load custom trait.")); + } + } else { + // Manifest data is not available, log an error and reject the Promise + const errorMessage = "No manifest was loaded, custom trait cannot be loaded."; + console.error(errorMessage); + reject(new Error(errorMessage)); + } + }); + } + /** * Loads a custom texture for a trait group. * @param {string} groupTraitID - ID of the trait group @@ -1019,8 +1114,10 @@ export class CharacterManager { * @param {Array} options - Array of trait options to load * @param {boolean} [fullAvatarReplace=false] - Whether to replace all existing traits */ - async _loadTraits(options, fullAvatarReplace = false){ + async _loadTraits(options, fullAvatarReplace = false, parentBoneName = null){ console.log("loaded traits:", options) + await this._createBaseSkeleton(options); + console.log("parent bone name: ", parentBoneName) await this.traitLoadManager.loadTraitOptions(getAsArray(options)).then(loadedData=>{ if (fullAvatarReplace){ // add null loaded options to existingt traits to remove them; @@ -1035,9 +1132,10 @@ export class CharacterManager { }); } loadedData.forEach(itemData => { - - this._addLoadedData(itemData) + this._addLoadedData(itemData, parentBoneName) + console.log("ttt2") }); + console.log("ttt3") cullHiddenMeshes(this.avatar); }) } @@ -1220,6 +1318,13 @@ export class CharacterManager { } } + _createBoneSphere(radius){ + const geometry = new THREE.SphereGeometry( radius, 32, 16 ); + const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } ); + const sphere = new THREE.Mesh( geometry, material ); + return sphere; + } + /** * Gets a portrait screenshot texture. * @private @@ -1319,16 +1424,16 @@ export class CharacterManager { * @param {Array} colors - Array of colors * @returns {Object} Set up VRM model */ - _VRMBaseSetup(m, collectionID, item, traitID, textures, colors){ + _VRMBaseSetup(m, collectionID, item, traitID, textures, colors, isSkeleton = false){ let vrm = m.userData.vrm; if (m.userData.vrm == null){ - console.error("No valid VRM was provided for " + traitID + " trait, skipping file.") + // console.error("No valid VRM was provided for " + traitID + " trait, skipping file.") return null; } addModelData(vrm, {isVRM0:vrm.meta?.metaVersion === '0'}) - if (this.manifestDataManager.isColliderRequired(traitID)){ + if (this.manifestDataManager.isColliderRequired(traitID) && !isSkeleton){ saveVRMCollidersToUserData(m); } @@ -1345,11 +1450,11 @@ export class CharacterManager { */ //this._unregisterMorphTargetsFromManifest(vrm, collectionID); - if (this.manifestDataManager.isLipsyncTrait(traitID, collectionID)) + if (this.manifestDataManager.isLipsyncTrait(traitID, collectionID) && !isSkeleton) this.lipSync = new LipSync(vrm); - - this._modelBaseSetup(vrm, collectionID, item, traitID, textures, colors); + if (!isSkeleton) + this._modelBaseSetup(vrm, collectionID, item, traitID, textures, colors); // Rotate model 180 degrees @@ -1595,14 +1700,51 @@ export class CharacterManager { * @private * @param {Object} model - Model to display */ - _displayModel(model){ + _displayModel(model, parentBoneName = null){ if(model) { // call transition const m = model.scene; + if (m) { + m.matrixAutoUpdate = true; + m.updateMatrixWorld(true); + } //m.visible = false; // add the now model to the current scene + const targetBone = parentBoneName != null ? this.baseSkeletonVRM.humanoid.humanBones[parentBoneName]?.node : null; - this.characterModel.attach(m) + if (targetBone != null){ + // Attach to the selected bone at its origin (snap new item to the bone) + const handle = new THREE.Object3D(); + handle.name = "__gizmoHandle"; + handle.position.set(0,0,0); + handle.quaternion.set(0,0,0,1); + handle.scale.set(1,1,1); + targetBone.add(handle); + // Place model at handle origin + m.position.set(0,0,0); + m.rotation.set(0,0,0); + m.updateMatrixWorld(true); + handle.add(m); + this.lastAttachedObject = handle; + this.lastAttachedBoneName = parentBoneName; + } + else{ + // No bone: create handle that preserves current world transform relative to character root + m.updateMatrixWorld(true); + const handle = new THREE.Object3D(); + handle.name = "__gizmoHandle"; + this.characterModel.add(handle); + const mWorld = new THREE.Matrix4().copy(m.matrixWorld); + const rootWorldInv = new THREE.Matrix4().copy(this.characterModel.matrixWorld).invert(); + const handleLocal = new THREE.Matrix4().multiplyMatrices(rootWorldInv, mWorld); + handle.matrixAutoUpdate = false; + handle.matrix.copy(handleLocal); + handle.matrix.decompose(handle.position, handle.quaternion, handle.scale); + if (handle.attach && m.isObject3D) handle.attach(m); else handle.add(m); + m.updateMatrixWorld(true); + this.lastAttachedObject = handle; + this.lastAttachedBoneName = null; + } //animationManager.update(); // note: update animation to prevent some frames of T pose at start. @@ -1659,13 +1801,51 @@ export class CharacterManager { disposeVRM(vrm) } + async _createBaseSkeleton(traitOptions){ + if (this.baseSkeletonVRM == null){ + const mainAsset = traitOptions.find(obj => obj.traitModel?.traitGroup.trait === this.manifestDataManager.getMainTrait()); + await this.traitLoadManager.loadTraitOptions(getAsArray(mainAsset)).then(loadedData=>{ + this._addLoadedDataSkeleton(loadedData[0]) + }); + } + } + _addLoadedDataSkeleton(itemData){ + const { + models + } = itemData; + + let vrm = null; + models.map((m)=>{ + if (m != null) + vrm = this._VRMBaseSetup(m, null, null, null,null, null, true); + }) + this._positionModel(vrm) + this._applyManagers(vrm) + let targetSkinnedMesh = null; + vrm.scene.traverse((object) => { + if (object.isSkinnedMesh) { // Check if the object is a SkinnedMesh + targetSkinnedMesh = object; + // object.skeleton.bones.forEach((bn)=>{ + // const sphere = this._createBoneSphere(.05); + // bn.add(sphere); + // }) + } + }); + if (targetSkinnedMesh != null ){ + targetSkinnedMesh.parent.remove(targetSkinnedMesh); + } + + this.characterModel.attach(vrm.scene) + + this.baseSkeletonVRM = vrm; + } /** * Adds loaded data to the character. * @private * @param {Object} itemData - Data to add */ - _addLoadedData(itemData){ + _addLoadedData(itemData, parentBoneName){ const { collectionID, traitGroupID, @@ -1698,20 +1878,37 @@ export class CharacterManager { }) // do nothing, an error happened - if (vrm == null) + if (vrm == null){ + // found model that is not vrmc + let gltfModel = models[0] + if (this.avatar[traitGroupID] && this.avatar[traitGroupID].vrm) { + this._disposeTrait(this.avatar[traitGroupID].vrm) + } + this._positionModel(gltfModel) + this._displayModel(gltfModel, parentBoneName) // probably attach to bone instead + this.avatar[traitGroupID] = { + traitInfo: traitModel, + textureInfo: textureTrait, + colorInfo: colorTrait, + name: traitModel.name, + model: gltfModel, + vrm: vrm + } + // GLTF path is handled above; skip VRM-only logic below return; - - // If there was a previous loaded model, remove it (maybe also remove loaded textures?) - if (this.avatar[traitGroupID] && this.avatar[traitGroupID].vrm) { - this._disposeTrait(this.avatar[traitGroupID].vrm) - // XXX restore effects } - - this._positionModel(vrm) - - this._displayModel(vrm) - - this._applyManagers(vrm) + else{ + // If there was a previous loaded model, remove it (maybe also remove loaded textures?) + if (this.avatar[traitGroupID] && this.avatar[traitGroupID].vrm) { + this._disposeTrait(this.avatar[traitGroupID].vrm) + // XXX restore effects + } + } + if (vrm) { + this._positionModel(vrm) + this._displayModel(vrm) + this._applyManagers(vrm) + } if(this.overlayedTextureManager){ if(traitModel.targetDecalCollection){ diff --git a/src/library/manifestDataManager.js b/src/library/manifestDataManager.js index 5925beecd..1eb83ee7f 100644 --- a/src/library/manifestDataManager.js +++ b/src/library/manifestDataManager.js @@ -311,6 +311,9 @@ export class ManifestDataManager{ getCustomTraitOption(groupTraitID, url){ return this.mainManifestData.getCustomTraitOption(groupTraitID, url); } + getMainTrait(){ + return this.mainManifestData.mainTrait; + } getNFTraitOptionsFromURL(url, ignoreGroupTraits, identifier){ if (identifier == null){ diff --git a/src/library/sceneInitializer.js b/src/library/sceneInitializer.js index 730448c05..f4f0ceb7d 100644 --- a/src/library/sceneInitializer.js +++ b/src/library/sceneInitializer.js @@ -1,5 +1,6 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"; +import TransformControlHelper from "./transformControlHelper"; import { CharacterManager } from "./characterManager"; import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'; @@ -38,6 +39,7 @@ export function sceneInitializer(canvasId) { const characterManager = new CharacterManager({parentModel: scene, createAnimationManager : true, renderCamera:camera}) characterManager.addLookAtMouse(80,canvasId, camera, true); + //"editor-scene" const canvasRef = document.getElementById(canvasId); @@ -51,6 +53,13 @@ export function sceneInitializer(canvasId) { const controls = new OrbitControls(camera, renderer.domElement); controls.minDistance = 1; controls.maxDistance = 4; + 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.transform); + + controls.maxPolarAngle = Math.PI / 2; controls.enablePan = true; controls.target = new THREE.Vector3(0, 1, 0); @@ -72,11 +81,20 @@ export function sceneInitializer(canvasId) { renderer.outputColorSpace = THREE.SRGBColorSpace; const clock = new THREE.Clock(); + const ensureGizmoTarget = () => { + if (characterManager.getLastAttachedObject){ + const target = characterManager.getLastAttachedObject(); + if (target && transformControls.object !== target) { + transformControls.attachToTransformControls(target); + } + } + } const animate = () => { requestAnimationFrame(animate); const delta = clock.getDelta(); controls.target.clamp(minPan, maxPan); controls?.update(); + ensureGizmoTarget(); characterManager.update(delta); renderer.render(scene, camera); }; @@ -84,14 +102,6 @@ export function sceneInitializer(canvasId) { animate(); - const handleMouseClick = (event) => { - const isCtrlPressed = event.ctrlKey; - const rect = canvasRef.getBoundingClientRect(); - const mousex = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const mousey = -((event.clientY - rect.top) / rect.height) * 2 + 1; - characterManager.cameraRaycastCulling(mousex,mousey,isCtrlPressed); - }; - async function fetchScene() { // // load environment @@ -104,15 +114,14 @@ export function sceneInitializer(canvasId) { } fetchScene(); - - canvasRef.addEventListener("click", handleMouseClick); - return { scene, camera, controls, characterManager, + transformControls, sceneElements, clock }; } + diff --git a/src/library/transformControlHelper.js b/src/library/transformControlHelper.js new file mode 100644 index 000000000..03fd95a56 --- /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 d1ec659da..8b88ba745 100644 --- a/src/pages/Appearance.jsx +++ b/src/pages/Appearance.jsx @@ -20,6 +20,8 @@ import colorPicker from "../images/color-palette.png" import { ChromePicker } from 'react-color' import RightPanel from "../components/RightPanel" import SaleIcon from "../images/sale-icon.png" +import { BoneSelector } from "../components/BoneSelector" +import TransformInspector from "../components/TransformInspector" /** * @typedef {import("../library/CharacterManifestData.js").TraitModelsGroup} TraitModelsGroup @@ -39,6 +41,11 @@ function Appearance() { characterManager, animationManager, moveCamera, + bonePicker, + transformControls, + // attachToTransformControls, + // detachTransformControls, + attachTransformTarget, } = React.useContext(SceneContext) const [traitView, setTraitView] = React.useState(TraitPage.TRAIT) @@ -71,6 +78,9 @@ function Appearance() { const [loadedAnimationName, setLoadedAnimationName] = React.useState(""); const [isPickingColor, setIsPickingColor] = React.useState(false) const [colorPicked, setColorPicked] = React.useState({ background: '#ffffff' }) + const [selectingBone, setSelectingBone] = React.useState(false) + const [modelUrl, setModelUrl] = React.useState(null) + const modelUrlRef = React.useRef(null) const next = () => { !isMute && playSound('backNextButton'); @@ -115,12 +125,36 @@ function Appearance() { const path = URL.createObjectURL(file); characterManager.loadCustomTexture(selectedTraitGroup.trait, path).then(()=>{ setIsLoading(false); + URL.revokeObjectURL(path); }) } else{ console.warn("Please select a group trait first.") } } + const handleGLBDrop = (file) =>{ + if (selectedTraitGroup != null && selectedTraitGroup.trait != ""){ + console.log(selectedTraitGroup); + console.log("dropeed glb"); + setSelectingBone(true); + // create and store object URL now to avoid stale state later + try{ + const url = URL.createObjectURL(file); + setModelUrl(url); + modelUrlRef.current = url; + }catch(e){ + console.error("Failed to create object URL", e) + } + if (bonePicker){ + bonePicker.enable((boneName)=>{ + placeModelOnBone(boneName) + }) + } + } + else{ + console.warn("Please select a group trait first.") + } + } const handleVRMDrop = (file) =>{ setIsPickingColor(false); if (selectedTraitGroup && selectedTraitGroup.trait != ""){ @@ -128,12 +162,14 @@ function Appearance() { const path = URL.createObjectURL(file); characterManager.loadCustomTrait(selectedTraitGroup.trait, path).then(()=>{ setIsLoading(false); + URL.revokeObjectURL(path); }) } else{ console.warn("Please select a group trait first.") } } + const selectTrait = (trait) => { console.log(trait); if(trait.id === selectedTrait?.id && trait.collectionID === selectedTrait?.collectionID){ @@ -234,6 +270,9 @@ function Appearance() { if (file && file.name.toLowerCase().endsWith('.json')) { handleJsonDrop(files); } + if (file && (file.name.toLowerCase().endsWith('.gltf') || file.name.toLowerCase().endsWith('.glb') )) { + handleGLBDrop(file); + } }; const selectTraitGroup = (traitGroup) => { @@ -284,6 +323,44 @@ function Appearance() { } input.click(); } + const placeModelOnBone = async (boneName) => { + setSelectingBone(false); + setIsLoading(true); + const urlToUse = modelUrlRef.current || modelUrl; + if (!urlToUse){ + console.warn("No model URL available for placement."); + setIsLoading(false); + return; + } + characterManager.loadCustomModelTrait(selectedTraitGroup.trait, urlToUse, boneName).then(()=>{ + setIsLoading(false); + URL.revokeObjectURL(urlToUse); + setModelUrl(null); + modelUrlRef.current = null; + const entry = characterManager.avatar[selectedTraitGroup.trait]; + // If entry.model is a GLTF (from non-VRM path) we attached its scene under the target bone + const node = entry?.model?.scene || entry?.model || null; + // If we attached to a bone, ensure we're manipulating the child just added + // i.e., the last child of the bone may be our model root + if (!node && characterManager.baseSkeletonVRM && boneName){ + const bone = characterManager.baseSkeletonVRM.humanoid.humanBones[boneName]?.node + const last = bone && bone.children && bone.children[bone.children.length-1] + if (last) { + transformControls.attachToTransformControls(last) + if (attachTransformTarget) attachTransformTarget(last) + return + } + } + if (transformControls.attachToTransformControls && node){ + // Attach the GLTF root for direct manipulation (defer one tick to ensure it is in scene graph) + requestAnimationFrame(()=>{ + transformControls.attachToTransformControls(node); + if (attachTransformTarget) attachTransformTarget(node) + }) + } + }) + + } return (
@@ -294,6 +371,11 @@ function Appearance() { + {selectingBone && !bonePicker && ( + + )} {/* Main Menu section */}
@@ -320,9 +402,10 @@ function Appearance() {
+ - {/* Option Selection section */ - !!traits && selectedTraitGroup && ( + {/* Option Selection section */} + { !!traits && selectedTraitGroup && (