diff options
Diffstat (limited to 'public/bluebey-studio/src/rig.js')
| -rw-r--r-- | public/bluebey-studio/src/rig.js | 196 |
1 files changed, 196 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js new file mode 100644 index 0000000..2922dfa --- /dev/null +++ b/public/bluebey-studio/src/rig.js @@ -0,0 +1,196 @@ +import * as THREE from 'three'; +import { TransformControls } from 'three/addons/controls/TransformControls.js'; + +/** + * Posing. Each bone is driven as a rotation *relative to its rest pose*, so + * every bone reads 0/0/0 in the original stance and a saved pose stays valid + * even if the model is re-exported with different bone orientations. + * + * `bone.quaternion = rest * delta` and `delta = rest⁻¹ * quaternion`, with the + * delta expressed as XYZ Euler angles in degrees for the UI. + */ + +const RAD = Math.PI / 180; +const DEG = 180 / Math.PI; + +/** three sanitises node names, so `armsupport.l` arrives as `armsupportl`. */ +const normalizeName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase(); + +export class Rig { + constructor({ bones, scene, camera, domElement, orbit, pickTargets, onChange = null }) { + this.bones = bones; + this.byName = new Map(); + for (const entry of bones) { + this.byName.set(entry.name, entry); + const alias = normalizeName(entry.name); + if (!this.byName.has(alias)) this.byName.set(alias, entry); + } + this.scene = scene; + this.domElement = domElement; + this.orbit = orbit; + this.pickTargets = pickTargets; + this.onChange = onChange; + this.selected = null; + + this.controls = new TransformControls(camera, domElement); + this.controls.setMode('rotate'); + this.controls.setSpace('local'); + this.controls.setSize(0.8); + this.helper = this.controls.getHelper(); + this.helper.visible = false; + // Whether the user wants the gizmo at all. It starts OFF: the model is the + // point, and the rings sat on top of it until you went looking for them. + // Selecting a bone must not switch it back on by itself, so `select()` + // consults this instead of always showing the helper. + this.gizmoWanted = false; + scene.add(this.helper); + + this.controls.addEventListener('dragging-changed', (event) => { + this.orbit.enabled = !event.value; + }); + this.controls.addEventListener('objectChange', () => { + if (this.selected) this.onChange?.(this.selected); + }); + + this.raycaster = new THREE.Raycaster(); + this.pointer = new THREE.Vector2(); + this.pressAt = null; + this.onPointerDown = this.handlePointerDown.bind(this); + this.onPointerUp = this.handlePointerUp.bind(this); + domElement.addEventListener('pointerdown', this.onPointerDown); + domElement.addEventListener('pointerup', this.onPointerUp); + } + + /* ------------------------------------------------------------ selection */ + + select(name, { silent = false } = {}) { + if (name && !this.byName.has(name)) return; + this.selected = name ?? null; + if (name) { + this.controls.attach(this.byName.get(name).bone); + this.helper.visible = this.gizmoWanted; + } else { + this.controls.detach(); + this.helper.visible = false; + } + if (!silent) this.onChange?.(this.selected); + } + + selectAt(event) { + const rect = this.domElement.getBoundingClientRect(); + this.pointer.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + this.raycaster.setFromCamera(this.pointer, this.controls.camera); + + const targets = this.pickTargets().filter((mesh) => mesh.visible); + const hits = this.raycaster.intersectObjects(targets, false); + if (!hits.length) return null; + + const point = hits[0].point; + let best = null; + let bestDistance = Infinity; + const world = new THREE.Vector3(); + for (const entry of this.bones) { + entry.bone.getWorldPosition(world); + const distance = world.distanceToSquared(point); + if (distance < bestDistance) { + bestDistance = distance; + best = entry.name; + } + } + if (best) this.select(best); + return best; + } + + handlePointerDown(event) { + // Remember where the press started so a drag never changes the selection. + this.pressAt = { x: event.clientX, y: event.clientY, axis: this.controls.axis }; + } + + handlePointerUp(event) { + const press = this.pressAt; + this.pressAt = null; + if (!press || press.axis) return; // pressed on the gizmo, not the model + if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4) return; + this.selectAt(event); + } + + /* ----------------------------------------------------------------- pose */ + + getDelta(name) { + const entry = this.byName.get(name); + if (!entry) return { x: 0, y: 0, z: 0 }; + const delta = entry.rest.clone().invert().multiply(entry.bone.quaternion); + const euler = new THREE.Euler().setFromQuaternion(delta, 'XYZ'); + return { x: euler.x * DEG, y: euler.y * DEG, z: euler.z * DEG }; + } + + setDelta(name, { x = 0, y = 0, z = 0 }) { + const entry = this.byName.get(name); + if (!entry) return; + const delta = new THREE.Quaternion().setFromEuler( + new THREE.Euler(x * RAD, y * RAD, z * RAD, 'XYZ'), + ); + entry.bone.quaternion.copy(entry.rest).multiply(delta); + } + + /** `pose.bones` maps a bone name to `[x, y, z]` degrees; `pose.root` is a translation. */ + applyPose(pose = {}) { + // Presets and saved files use the glTF bone names (`armsupport.l`) while + // three sanitises them to `armsupportl`, so compare on the normalised form - + // otherwise every preset that touches an arm or leg is silently ignored. + const rotations = new Map(); + for (const [key, value] of Object.entries(pose.bones ?? {})) { + rotations.set(normalizeName(key), value); + } + for (const entry of this.bones) { + const value = rotations.get(normalizeName(entry.name)); + if (Array.isArray(value)) this.setDelta(entry.name, { x: value[0], y: value[1], z: value[2] }); + else this.setDelta(entry.name, { x: 0, y: 0, z: 0 }); + } + } + + getPose({ onlyMoved = true } = {}) { + const rotations = {}; + for (const entry of this.bones) { + const { x, y, z } = this.getDelta(entry.name); + const rounded = [round1(x), round1(y), round1(z)]; + if (onlyMoved && rounded.every((value) => value === 0)) continue; + rotations[entry.name] = rounded; + } + return { bones: rotations }; + } + + reset(name) { + if (name) { + this.setDelta(name, { x: 0, y: 0, z: 0 }); + return; + } + for (const entry of this.bones) this.setDelta(entry.name, { x: 0, y: 0, z: 0 }); + } + + /** True when every bone sits at its rest rotation. */ + isRestPose() { + return this.bones.every((entry) => { + const { x, y, z } = this.getDelta(entry.name); + return Math.abs(x) < 0.01 && Math.abs(y) < 0.01 && Math.abs(z) < 0.01; + }); + } + + setGizmoVisible(visible) { + this.gizmoWanted = visible !== false; + this.helper.visible = this.gizmoWanted && this.selected != null; + } + + dispose() { + this.domElement.removeEventListener('pointerdown', this.onPointerDown); + this.domElement.removeEventListener('pointerup', this.onPointerUp); + this.controls.detach(); + this.controls.dispose(); + this.helper.removeFromParent(); + } +} + +const round1 = (value) => Math.round(value * 10) / 10; |
