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;