overte-AleziaKurdis/scripts/system/doppleganger.js
2017-04-25 16:30:05 -04:00

404 lines
14 KiB
JavaScript

"use strict";
// doppleganger.js
//
// Created by Timothy Dedischew on 04/21/2017.
// Copyright 2017 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
/* global module */
// @module doppleganger
//
// This module contains the `Doppleganger` class implementation for creating an inspectable replica of
// an Avatar (as a model directly in front of and facing them). Joint positions and rotations are copied
// over in an update thread, so that the model automatically mirrors the Avatar's joint movements.
// An Avatar can then for example walk around "themselves" and examine from the back, etc.
//
// This should be helpful for inspecting your own look and debugging avatars, etc.
//
// The doppleganger is created as an overlay so that others do not see it -- and this also allows for the
// highest possible update rate when keeping joint data in sync.
module.exports = Doppleganger;
// @property {bool} - when set true, Script.update will be used instead of setInterval for syncing joint data
Doppleganger.USE_SCRIPT_UPDATE = false;
// @property {int} - the frame rate to target when using setInterval for joint updates
Doppleganger.TARGET_FPS = 60;
// @class Doppleganger - Creates a new instance of a Doppleganger.
// @param {Avatar} [options.avatar=MyAvatar] - Avatar used to retrieve position and joint data.
// @param {bool} [options.mirrored=true] - Apply "symmetric mirroring" of Left/Right joints.
// @param {bool} [options.autoUpdate=true] - Automatically sync joint data.
function Doppleganger(options) {
options = options || {};
this.avatar = options.avatar || MyAvatar;
this.mirrored = 'mirrored' in options ? options.mirrored : true;
this.autoUpdate = 'autoUpdate' in options ? options.autoUpdate : true;
// @public
this.active = false; // whether doppleganger is currently being displayed/updated
this.uuid = null; // current doppleganger's Overlay id
this.ready = false; // whether the underlying ModelOverlay has finished loading
this.frame = 0; // current joint update frame
}
Doppleganger.prototype = {
// @public @method - toggles doppleganger on/off
toggle: function() {
if (this.active) {
log('toggling off');
this.off();
this.active = false;
} else {
log('toggling on');
this.on();
this.active = true;
}
return this.active;
},
// @public @method - shutdown the dopplgeganger completely
cleanup: function() {
this.off();
this.active = false;
},
// @public @method - synchronize the joint data between Avatar / doppleganger
update: function() {
this.frame++;
try {
if (!this.uuid) {
throw new Error('!this.uuid');
}
var rotations = this.avatar.getJointRotations();
var translations = this.avatar.getJointTranslations();
if (this.mirrored) {
var mirroredIndexes = this.mirroredIndexes;
var size = rotations.length;
var outRotations = new Array(size);
var outTranslations = new Array(size);
for (var i=0; i < size; i++) {
var index = mirroredIndexes[i] === false ? i : mirroredIndexes[i];
var rot = rotations[index];
var trans = translations[index];
trans.x *= -1;
rot.y *= -1;
rot.z *= -1;
outRotations[i] = rot;
outTranslations[i] = trans;
}
rotations = outRotations;
translations = outTranslations;
}
Overlays.editOverlay(this.uuid, {
jointRotations: rotations,
jointTranslations: translations
});
// debug plumbing
if (this.$update) {
this.$update();
}
} catch (e) {
log('update ERROR: '+ e);
this.off();
}
},
// @public @method - show the doppleganger (and start the update thread, if options.autoUpdate was specified).
on: function() {
if (this.uuid) {
log('on() called but overlay model already exists', this.uuid);
return;
}
this.ready = false;
this.frame = 0;
this.position = Vec3.sum(this.avatar.position, Quat.getFront(this.avatar.orientation));
this.orientation = this.avatar.orientation;
this.skeletonModelURL = this.avatar.skeletonModelURL;
this.jointNames = this.avatar.jointNames;
this.mirroredNames = this.jointNames.map(function(name, i) {
if (/Left/.test(name)) {
return name.replace('Left', 'Right');
}
if (/Right/.test(name)) {
return name.replace('Right', 'Left');
}
});
var avatar = this.avatar;
this.mirroredIndexes = this.mirroredNames.map(function(name) {
return name ? avatar.getJointIndex(name) : false;
});
this.uuid = Overlays.addOverlay('model', {
visible: false,
url: this.skeletonModelURL,
position: this.position,
rotation: this.orientation
});
this._onModelOverlayReady(bind(this, function() {
this.ready = true;
Overlays.editOverlay(this.uuid, { visible: true });
this.syncVerticalPosition();
log('ModelOverlay is ready; # joints == ' + Overlays.getProperty(this.uuid, 'jointNames').length);
if (this.autoUpdate) {
this._createUpdateThread();
}
}));
log('doppleganger created; overlayID =', this.uuid);
// trigger clean up (and stop updates) if the overlay gets deleted
this.onDeletedOverlay = this._onDeletedOverlay;
Overlays.overlayDeleted.connect(this, 'onDeletedOverlay');
// debug plumbing
if (this.$on) {
this.$on();
}
},
// @public @method - hide the doppleganger
off: function() {
this.ready = false;
if (this.onUpdate) {
Script.update.disconnect(this, 'onUpdate');
delete this.onUpdate;
}
if (this.onDeletedOverlay) {
Overlays.overlayDeleted.disconnect(this, 'onDeletedOverlay');
delete this.onDeletedOverlay;
}
if (this._interval) {
Script.clearInterval(this._interval);
this._interval = undefined;
}
if (this.uuid) {
Overlays.deleteOverlay(this.uuid);
this.uuid = undefined;
}
// debug plumbing
if (this.$off) {
this.$off();
}
},
// @public @method - Reposition the doppleganger so it sees "eye to eye" with the Avatar.
// @param {String} [byJointName=Hips] - the reference joint that will be used to vertically match positions with Avatar
// @note This method attempts to make a direct measurement and then calculate where the doppleganger needs to be
// in order to line-up vertically with the Avatar. Otherwise, animations such as "away" mode can
// result in the doppleganger floating above or below ground level.
syncVerticalPosition: function s(byJointName) {
byJointName = byJointName || 'Hips';
var names = Overlays.getProperty(this.uuid, 'jointNames'),
positions = Overlays.getProperty(this.uuid, 'jointPositions'),
dopplePosition = Overlays.getProperty(this.uuid, 'position'),
doppleJointIndex = names.indexOf(byJointName),
doppleJointPosition = positions[doppleJointIndex];
var avatarPosition = this.avatar.position,
avatarJointIndex = this.avatar.getJointIndex(byJointName),
avatarJointPosition = this.avatar.getJointPosition(avatarJointIndex);
var offset = avatarJointPosition.y - doppleJointPosition.y;
log('adjusting for offset', offset);
dopplePosition.y = avatarPosition.y + offset;
this.position = dopplePosition;
Overlays.editOverlay(this.uuid, { position: this.position });
},
// @private @method - signal handler for Overlays.overlayDeleted
_onDeletedOverlay: function(uuid) {
log('onDeletedOverlay', uuid);
if (uuid === this.uuid) {
this.off();
}
},
// @private @method - creates the update thread to synchronize joint data
_createUpdateThread: function() {
if (!this.autoUpdate) {
log('options.autoUpdate == false -- call .update() manually to sync joint data');
return;
}
if (Doppleganger.USE_SCRIPT_UPDATE) {
log('creating Script.update thread');
this.onUpdate = this.update;
Script.update.connect(this, 'onUpdate');
} else {
log('creating Script.setInterval thread @ ~', Doppleganger.TARGET_FPS +'fps');
var timeout = 1000 / Doppleganger.TARGET_FPS;
this._interval = Script.setInterval(bind(this, 'update'), timeout);
}
},
// @private @method - Invokes a callback once the ModelOverlay is fully-initialized.
// @param {Function} callback
// @note This is needed because sometimes it takes a few frames for the underlying model
// to become loaded even when already cached locally.
_onModelOverlayReady: function(callback) {
var RECHECK_MS = 50, MAX_WAIT_MS = 10000;
var id = this.uuid,
watchdogTimer = null,
boundCallback = bind(this, callback);
function waitForJointNames() {
if (!watchdogTimer) {
log('stopping waitForJointNames...');
return;
}
var names = Overlays.getProperty(id, 'jointNames');
if (Array.isArray(names) && names.length) {
log('ModelOverlay ready -- jointNames:', names);
boundCallback(names);
Script.clearTimeout(watchdogTimer);
} else {
return Script.setTimeout(waitForJointNames, RECHECK_MS);
}
}
watchdogTimer = Script.setTimeout(function() {
watchdogTimer = null;
}, MAX_WAIT_MS);
waitForJointNames();
}
};
// @function - bind a function to a `this` context
// @param {Object} - the `this` context
// @param {Function|String} - function or method name
function bind(thiz, method) {
method = thiz[method] || method;
return function() {
return method.apply(thiz, arguments);
};
}
// @function - debug logging
function log() {
print('doppleganger | ' + [].slice.call(arguments).join(' '));
}
// -- ADVANCED DEBUGGING --
// @function - Add debug joint indicators / extra debugging info.
// @param {Doppleganger} - existing Doppleganger instance to add controls to
//
// @note:
// * rightclick toggles mirror mode on/off
// * shift-rightclick toggles the debug indicators on/off
// * clicking on an indicator displays the joint name and mirrored joint name in the debug log.
//
// Example use:
// var doppleganger = new Doppleganger();
// Doppleganger.addDebugControls(doppleganger);
Doppleganger.addDebugControls = function(doppleganger) {
var onMousePressEvent,
debugOverlayIDs,
selectedJointName;
if ('$update' in doppleganger) {
throw new Error('only one set of debug controls can be added per doppleganger');
}
function $on() {
onMousePressEvent = _onMousePressEvent;
Controller.mousePressEvent.connect(this, _onMousePressEvent);
}
function createOverlays(jointNames) {
return jointNames.map(function(name, i) {
return Overlays.addOverlay('shape', {
shape: 'Icosahedron',
scale: 0.1,
solid: false,
alpha: 0.5
});
});
}
function cleanupOverlays() {
if (debugOverlayIDs) {
debugOverlayIDs.forEach(Overlays.deleteOverlay);
debugOverlayIDs = undefined;
}
}
function $off() {
if (onMousePressEvent) {
Controller.mousePressEvent.disconnect(this, onMousePressEvent);
onMousePressEvent = undefined;
}
cleanupOverlays();
}
function $update() {
var COLOR_DEFAULT = { red: 255, blue: 255, green: 255 };
var COLOR_SELECTED = { red: 0, blue: 255, green: 0 };
var id = this.uuid,
jointOrientations = Overlays.getProperty(id, 'jointOrientations'),
jointPositions = Overlays.getProperty(id, 'jointPositions'),
selectedIndex = this.jointNames.indexOf(selectedJointName);
if (!debugOverlayIDs) {
debugOverlayIDs = createOverlays(this.jointNames);
}
// batch all updates into a single call (using the editOverlays({ id: {props...}, ... }) API)
var updatedOverlays = debugOverlayIDs.reduce(function(updates, id, i) {
updates[id] = {
position: jointPositions[i],
rotation: jointOrientations[i],
color: i === selectedIndex ? COLOR_SELECTED : COLOR_DEFAULT,
solid: i === selectedIndex
};
return updates;
}, {});
Overlays.editOverlays(updatedOverlays);
}
function _onMousePressEvent(evt) {
if (evt.isRightButton) {
if (evt.isShifted) {
if (this.$update) {
// toggle debug overlays off
cleanupOverlays();
delete this.$update;
} else {
this.$update = $update;
}
} else {
this.mirrored = !this.mirrored;
}
return;
}
if (!evt.isLeftButton || !debugOverlayIDs) {
return;
}
var ray = Camera.computePickRay(evt.x, evt.y),
hit = Overlays.findRayIntersection(ray, true, debugOverlayIDs);
hit.jointIndex = debugOverlayIDs.indexOf(hit.overlayID);
hit.jointName = this.jointNames[hit.jointIndex];
hit.mirroredJointName = this.mirroredNames[hit.jointIndex];
selectedJointName = hit.jointName;
log('selected joint:', JSON.stringify(hit, 0, 2));
}
doppleganger.$on = $on;
doppleganger.$off = $off;
doppleganger.$update = $update;
return doppleganger;
};