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.js56
1 files changed, 54 insertions, 2 deletions
diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js
index 1afe669..e3c8a8e 100644
--- a/public/bluebey-studio/src/rig.js
+++ b/public/bluebey-studio/src/rig.js
@@ -16,6 +16,21 @@ 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;
@@ -31,6 +46,12 @@ export class Rig {
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');
@@ -87,8 +108,35 @@ export class Rig {
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 };
+ 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 }) {
@@ -98,6 +146,10 @@ export class Rig {
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. */