diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
| commit | bc2821adb11a30244fc4663f4fafb756877d9508 (patch) | |
| tree | fea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/rig.js | |
| parent | 0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff) | |
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/rig.js')
| -rw-r--r-- | bluebey-studio/src/rig.js | 212 |
1 files changed, 212 insertions, 0 deletions
diff --git a/bluebey-studio/src/rig.js b/bluebey-studio/src/rig.js new file mode 100644 index 0000000..e3c8a8e --- /dev/null +++ b/bluebey-studio/src/rig.js @@ -0,0 +1,212 @@ +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(); + +/** + * The same angle shifted by whole turns to sit next to `reference`. On an exact + * half-turn tie the lower (smaller-magnitude) value wins, which keeps the + * canonical -180..180 reading when two are equally close. + */ +const nearestAngle = (angle, reference) => { + const turns = (reference - angle) / 360; + const low = angle + 360 * Math.floor(turns); + const high = angle + 360 * Math.ceil(turns); + return Math.abs(low - reference) <= Math.abs(high - reference) ? low : high; +}; + +/** True when two quaternions describe the same rotation (double cover included). */ +const sameRotation = (a, b) => Math.abs(Math.abs(a.dot(b)) - 1) < 1e-6; + +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; + // The delta last handed to (or written by) the UI, in degrees. An XYZ Euler + // has several equivalent triples for one rotation, so getDelta returns the + // one closest to this. That keeps a single-axis drag smooth through the + // wrap at 90 deg (the middle axis) instead of jumping to the gimbal-flipped + // twin, which made the sliders and the next setDelta spin the character. + this.lastDelta = new Map(); + + this.controls = new TransformControls(camera, domElement); + this.controls.setMode('rotate'); + this.controls.setSpace('local'); + this.controls.setSize(0.8); + // A *hidden* TransformControls still grabs the pointer in three: its picker + // stays raycastable even when the helper is not drawn, so grabbing the body + // (the rings sit on the `master` bone, right where you grab) silently turned + // the character. Keep the controls disabled until the gizmo is actually + // shown, so the mouse can only move the character (see setGizmoVisible). + this.controls.enabled = false; + 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); + }); + } + + /* ------------------------------------------------------------ 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; + } + // Only an actually-shown gizmo may take the pointer (see the constructor). + this.controls.enabled = this.helper.visible; + if (!silent) this.onChange?.(this.selected); + } + + // Clicking the model no longer reaches for a bone: the mouse is for composing + // (see the drag handling in src/main.js), and bones are picked from the panel's + // bone list, which calls `select()` directly. So there is no pointer handler on + // the canvas here at all. + + /* ----------------------------------------------------------------- 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 base = new THREE.Euler().setFromQuaternion(delta, 'XYZ'); + const reference = this.lastDelta.get(entry) ?? { x: 0, y: 0, z: 0 }; + + // For one rotation an XYZ Euler has a second solution - its "flipped" twin + // `(x+180, 180-y, z+180)`. Three always returns the one whose middle angle + // stays within +/-90, so past 90 the twin is what continues the drag; offer + // both and keep whichever is closest to the last value. Near gimbal lock the + // twin no longer reproduces the rotation, so it is dropped by the check. + const candidates = [{ x: base.x, y: base.y, z: base.z }]; + const flipped = new THREE.Euler(base.x + Math.PI, Math.PI - base.y, base.z + Math.PI, 'XYZ'); + if (sameRotation(delta, new THREE.Quaternion().setFromEuler(flipped))) { + candidates.push({ x: flipped.x, y: flipped.y, z: flipped.z }); + } + + let best = { x: 0, y: 0, z: 0 }; + let bestDistance = Infinity; + for (const candidate of candidates) { + const x = nearestAngle(candidate.x * DEG, reference.x); + const y = nearestAngle(candidate.y * DEG, reference.y); + const z = nearestAngle(candidate.z * DEG, reference.z); + const distance = (x - reference.x) ** 2 + (y - reference.y) ** 2 + (z - reference.z) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y, z }; + } + } + + this.lastDelta.set(entry, best); + return { ...best }; + } + + 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); + // Remember exactly what the UI asked for so getDelta can hand it straight + // back (this keeps applyPose/getPose an exact round-trip). Keyed by the bone + // entry, not the name, so a raw name and its normalised alias agree. + this.lastDelta.set(entry, { x, y, z }); + } + + /** `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; + // A hidden gizmo must not grab the pointer (see the constructor). + this.controls.enabled = this.helper.visible; + } + + dispose() { + this.controls.detach(); + this.controls.dispose(); + this.helper.removeFromParent(); + } +} + +const round1 = (value) => Math.round(value * 10) / 10; |
