aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/rig.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/rig.js')
-rw-r--r--public/bluebey-studio/src/rig.js212
1 files changed, 0 insertions, 212 deletions
diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js
deleted file mode 100644
index e3c8a8e..0000000
--- a/public/bluebey-studio/src/rig.js
+++ /dev/null
@@ -1,212 +0,0 @@
-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;