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.js196
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;