diff --git a/.eslintrc.js b/.eslintrc.js
index 9635142d1a..b4d88777f2 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -34,6 +34,7 @@ module.exports = {
"Quat": false,
"Rates": false,
"Recording": false,
+ "Resource": false,
"Reticle": false,
"Scene": false,
"Script": false,
diff --git a/interface/resources/icons/tablet-icons/doppleganger-a.svg b/interface/resources/icons/tablet-icons/doppleganger-a.svg
new file mode 100644
index 0000000000..100986647e
--- /dev/null
+++ b/interface/resources/icons/tablet-icons/doppleganger-a.svg
@@ -0,0 +1,94 @@
+
+
+
+
\ No newline at end of file
diff --git a/interface/resources/icons/tablet-icons/doppleganger-i.svg b/interface/resources/icons/tablet-icons/doppleganger-i.svg
new file mode 100644
index 0000000000..0c55e0e0c7
--- /dev/null
+++ b/interface/resources/icons/tablet-icons/doppleganger-i.svg
@@ -0,0 +1,94 @@
+
+
+
+
\ No newline at end of file
diff --git a/interface/src/avatar/Avatar.cpp b/interface/src/avatar/Avatar.cpp
index 5b996a3cdf..f29efb8c32 100644
--- a/interface/src/avatar/Avatar.cpp
+++ b/interface/src/avatar/Avatar.cpp
@@ -929,6 +929,17 @@ QVector Avatar::getJointRotations() const {
return jointRotations;
}
+QVector Avatar::getJointTranslations() const {
+ if (QThread::currentThread() != thread()) {
+ return AvatarData::getJointTranslations();
+ }
+ QVector jointTranslations(_skeletonModel->getJointStateCount());
+ for (int i = 0; i < _skeletonModel->getJointStateCount(); ++i) {
+ _skeletonModel->getJointTranslation(i, jointTranslations[i]);
+ }
+ return jointTranslations;
+}
+
glm::quat Avatar::getJointRotation(int index) const {
glm::quat rotation;
_skeletonModel->getJointRotation(index, rotation);
diff --git a/interface/src/avatar/Avatar.h b/interface/src/avatar/Avatar.h
index 8c055885fd..14d1da530a 100644
--- a/interface/src/avatar/Avatar.h
+++ b/interface/src/avatar/Avatar.h
@@ -112,6 +112,7 @@ public:
virtual QVector getJointRotations() const override;
virtual glm::quat getJointRotation(int index) const override;
+ virtual QVector getJointTranslations() const override;
virtual glm::vec3 getJointTranslation(int index) const override;
virtual int getJointIndex(const QString& name) const override;
virtual QStringList getJointNames() const override;
diff --git a/interface/src/ui/overlays/ModelOverlay.cpp b/interface/src/ui/overlays/ModelOverlay.cpp
index ccaa1d4fbc..e993166558 100644
--- a/interface/src/ui/overlays/ModelOverlay.cpp
+++ b/interface/src/ui/overlays/ModelOverlay.cpp
@@ -126,6 +126,55 @@ void ModelOverlay::setProperties(const QVariantMap& properties) {
QMetaObject::invokeMethod(_model.get(), "setTextures", Qt::AutoConnection,
Q_ARG(const QVariantMap&, textureMap));
}
+
+ // relative
+ auto jointTranslationsValue = properties["jointTranslations"];
+ if (jointTranslationsValue.canConvert(QVariant::List)) {
+ const QVariantList& jointTranslations = jointTranslationsValue.toList();
+ int translationCount = jointTranslations.size();
+ int jointCount = _model->getJointStateCount();
+ if (translationCount < jointCount) {
+ jointCount = translationCount;
+ }
+ for (int i=0; i < jointCount; i++) {
+ const auto& translationValue = jointTranslations[i];
+ if (translationValue.isValid()) {
+ _model->setJointTranslation(i, true, vec3FromVariant(translationValue), 1.0f);
+ }
+ }
+ _updateModel = true;
+ }
+
+ // relative
+ auto jointRotationsValue = properties["jointRotations"];
+ if (jointRotationsValue.canConvert(QVariant::List)) {
+ const QVariantList& jointRotations = jointRotationsValue.toList();
+ int rotationCount = jointRotations.size();
+ int jointCount = _model->getJointStateCount();
+ if (rotationCount < jointCount) {
+ jointCount = rotationCount;
+ }
+ for (int i=0; i < jointCount; i++) {
+ const auto& rotationValue = jointRotations[i];
+ if (rotationValue.isValid()) {
+ _model->setJointRotation(i, true, quatFromVariant(rotationValue), 1.0f);
+ }
+ }
+ _updateModel = true;
+ }
+}
+
+template
+vectorType ModelOverlay::mapJoints(mapFunction function) const {
+ vectorType result;
+ if (_model && _model->isActive()) {
+ const int jointCount = _model->getJointStateCount();
+ result.reserve(jointCount);
+ for (int i = 0; i < jointCount; i++) {
+ result << function(i);
+ }
+ }
+ return result;
}
QVariant ModelOverlay::getProperty(const QString& property) {
@@ -150,6 +199,58 @@ QVariant ModelOverlay::getProperty(const QString& property) {
}
}
+ if (property == "jointNames") {
+ if (_model && _model->isActive()) {
+ // note: going through Rig because Model::getJointNames() (which proxies to FBXGeometry) was always empty
+ const RigPointer rig = _model->getRig();
+ if (rig) {
+ return mapJoints([rig](int jointIndex) -> QString {
+ return rig->nameOfJoint(jointIndex);
+ });
+ }
+ }
+ }
+
+ // relative
+ if (property == "jointRotations") {
+ return mapJoints(
+ [this](int jointIndex) -> QVariant {
+ glm::quat rotation;
+ _model->getJointRotation(jointIndex, rotation);
+ return quatToVariant(rotation);
+ });
+ }
+
+ // relative
+ if (property == "jointTranslations") {
+ return mapJoints(
+ [this](int jointIndex) -> QVariant {
+ glm::vec3 translation;
+ _model->getJointTranslation(jointIndex, translation);
+ return vec3toVariant(translation);
+ });
+ }
+
+ // absolute
+ if (property == "jointOrientations") {
+ return mapJoints(
+ [this](int jointIndex) -> QVariant {
+ glm::quat orientation;
+ _model->getJointRotationInWorldFrame(jointIndex, orientation);
+ return quatToVariant(orientation);
+ });
+ }
+
+ // absolute
+ if (property == "jointPositions") {
+ return mapJoints(
+ [this](int jointIndex) -> QVariant {
+ glm::vec3 position;
+ _model->getJointPositionInWorldFrame(jointIndex, position);
+ return vec3toVariant(position);
+ });
+ }
+
return Volume3DOverlay::getProperty(property);
}
diff --git a/interface/src/ui/overlays/ModelOverlay.h b/interface/src/ui/overlays/ModelOverlay.h
index a3ddeed480..8afe9a20b6 100644
--- a/interface/src/ui/overlays/ModelOverlay.h
+++ b/interface/src/ui/overlays/ModelOverlay.h
@@ -41,6 +41,12 @@ public:
void locationChanged(bool tellPhysics) override;
+protected:
+ // helper to extract metadata from our Model's rigged joints
+ template using mapFunction = std::function;
+ template
+ vectorType mapJoints(mapFunction function) const;
+
private:
ModelPointer _model;
diff --git a/libraries/avatars/src/AvatarData.cpp b/libraries/avatars/src/AvatarData.cpp
index 20894104ff..90f2fb5342 100644
--- a/libraries/avatars/src/AvatarData.cpp
+++ b/libraries/avatars/src/AvatarData.cpp
@@ -1392,6 +1392,22 @@ void AvatarData::setJointRotations(QVector jointRotations) {
}
}
+QVector AvatarData::getJointTranslations() const {
+ if (QThread::currentThread() != thread()) {
+ QVector result;
+ QMetaObject::invokeMethod(const_cast(this),
+ "getJointTranslations", Qt::BlockingQueuedConnection,
+ Q_RETURN_ARG(QVector, result));
+ return result;
+ }
+ QReadLocker readLock(&_jointDataLock);
+ QVector jointTranslations(_jointData.size());
+ for (int i = 0; i < _jointData.size(); ++i) {
+ jointTranslations[i] = _jointData[i].translation;
+ }
+ return jointTranslations;
+}
+
void AvatarData::setJointTranslations(QVector jointTranslations) {
if (QThread::currentThread() != thread()) {
QVector result;
diff --git a/libraries/avatars/src/AvatarData.h b/libraries/avatars/src/AvatarData.h
index 8319eb5249..e05bdce162 100644
--- a/libraries/avatars/src/AvatarData.h
+++ b/libraries/avatars/src/AvatarData.h
@@ -497,6 +497,7 @@ public:
Q_INVOKABLE glm::vec3 getJointTranslation(const QString& name) const;
Q_INVOKABLE virtual QVector getJointRotations() const;
+ Q_INVOKABLE virtual QVector getJointTranslations() const;
Q_INVOKABLE virtual void setJointRotations(QVector jointRotations);
Q_INVOKABLE virtual void setJointTranslations(QVector jointTranslations);
diff --git a/libraries/avatars/src/ScriptAvatarData.cpp b/libraries/avatars/src/ScriptAvatarData.cpp
index f579eb9763..01d7f293d8 100644
--- a/libraries/avatars/src/ScriptAvatarData.cpp
+++ b/libraries/avatars/src/ScriptAvatarData.cpp
@@ -210,6 +210,13 @@ QVector ScriptAvatarData::getJointRotations() const {
return QVector();
}
}
+QVector ScriptAvatarData::getJointTranslations() const {
+ if (AvatarSharedPointer sharedAvatarData = _avatarData.lock()) {
+ return sharedAvatarData->getJointTranslations();
+ } else {
+ return QVector();
+ }
+}
bool ScriptAvatarData::isJointDataValid(const QString& name) const {
if (AvatarSharedPointer sharedAvatarData = _avatarData.lock()) {
return sharedAvatarData->isJointDataValid(name);
diff --git a/libraries/avatars/src/ScriptAvatarData.h b/libraries/avatars/src/ScriptAvatarData.h
index 683306e847..d763b6e97a 100644
--- a/libraries/avatars/src/ScriptAvatarData.h
+++ b/libraries/avatars/src/ScriptAvatarData.h
@@ -106,6 +106,7 @@ public:
Q_INVOKABLE glm::quat getJointRotation(const QString& name) const;
Q_INVOKABLE glm::vec3 getJointTranslation(const QString& name) const;
Q_INVOKABLE QVector getJointRotations() const;
+ Q_INVOKABLE QVector getJointTranslations() const;
Q_INVOKABLE bool isJointDataValid(const QString& name) const;
Q_INVOKABLE int getJointIndex(const QString& name) const;
Q_INVOKABLE QStringList getJointNames() const;
diff --git a/scripts/system/app-doppleganger.js b/scripts/system/app-doppleganger.js
new file mode 100644
index 0000000000..d7f85e5767
--- /dev/null
+++ b/scripts/system/app-doppleganger.js
@@ -0,0 +1,85 @@
+// doppleganger-app.js
+//
+// Created by Timothy Dedischew on 04/21/2017.
+// Copyright 2017 High Fidelity, Inc.
+//
+// This Client script creates an instance of a Doppleganger that can be toggled on/off via tablet button.
+// (for more info see doppleganger.js)
+//
+// Distributed under the Apache License, Version 2.0.
+// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
+//
+
+var DopplegangerClass = Script.require('./doppleganger.js');
+
+var tablet = Tablet.getTablet('com.highfidelity.interface.tablet.system'),
+ button = tablet.addButton({
+ icon: "icons/tablet-icons/doppleganger-i.svg",
+ activeIcon: "icons/tablet-icons/doppleganger-a.svg",
+ text: 'MIRROR'
+ });
+
+Script.scriptEnding.connect(function() {
+ tablet.removeButton(button);
+ button = null;
+});
+
+var doppleganger = new DopplegangerClass({
+ avatar: MyAvatar,
+ mirrored: true,
+ autoUpdate: true
+});
+
+// hide the doppleganger if this client script is unloaded
+Script.scriptEnding.connect(doppleganger, 'stop');
+
+// hide the doppleganger if the user switches domains (which might place them arbitrarily far away in world space)
+function onDomainChanged() {
+ if (doppleganger.active) {
+ doppleganger.stop('domain_changed');
+ }
+}
+Window.domainChanged.connect(onDomainChanged);
+Window.domainConnectionRefused.connect(onDomainChanged);
+Script.scriptEnding.connect(function() {
+ Window.domainChanged.disconnect(onDomainChanged);
+ Window.domainConnectionRefused.disconnect(onDomainChanged);
+});
+
+// toggle on/off via tablet button
+button.clicked.connect(doppleganger, 'toggle');
+
+// highlight tablet button based on current doppleganger state
+doppleganger.activeChanged.connect(function(active, reason) {
+ if (button) {
+ button.editProperties({ isActive: active });
+ print('doppleganger.activeChanged', active, reason);
+ }
+});
+
+// alert the user if there was an error applying their skeletonModelURL
+doppleganger.modelOverlayLoaded.connect(function(error, result) {
+ if (doppleganger.active && error) {
+ Window.alert('doppleganger | ' + error + '\n' + doppleganger.skeletonModelURL);
+ }
+});
+
+// add debug indicators, but only if the user has configured the settings value
+if (Settings.getValue('debug.doppleganger', false)) {
+ DopplegangerClass.addDebugControls(doppleganger);
+}
+
+UserActivityLogger.logAction('doppleganger_app_load');
+doppleganger.activeChanged.connect(function(active, reason) {
+ if (active) {
+ UserActivityLogger.logAction('doppleganger_enable');
+ } else {
+ if (reason === 'stop') {
+ // user intentionally toggled the doppleganger
+ UserActivityLogger.logAction('doppleganger_disable');
+ } else {
+ print('doppleganger stopped:', reason);
+ UserActivityLogger.logAction('doppleganger_autodisable', { reason: reason });
+ }
+ }
+});
diff --git a/scripts/system/doppleganger.js b/scripts/system/doppleganger.js
new file mode 100644
index 0000000000..271a9a67c5
--- /dev/null
+++ b/scripts/system/doppleganger.js
@@ -0,0 +1,494 @@
+"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;
+
+// @property {int} - the maximum time in seconds to wait for the model overlay to finish loading
+Doppleganger.MAX_WAIT_SECS = 10;
+
+// @function - derive mirrored joint names from a list of regular joint names
+// @param {Array} - list of joint names to mirror
+// @return {Array} - list of mirrored joint names (note: entries for non-mirrored joints will be `undefined`)
+Doppleganger.getMirroredJointNames = function(jointNames) {
+ return jointNames.map(function(name, i) {
+ if (/Left/.test(name)) {
+ return name.replace('Left', 'Right');
+ }
+ if (/Right/.test(name)) {
+ return name.replace('Right', 'Left');
+ }
+ return undefined;
+ });
+};
+
+// @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.overlayID = null; // current doppleganger's Overlay id
+ this.frame = 0; // current joint update frame
+
+ // @signal - emitted when .active state changes
+ this.activeChanged = signal(function(active, reason) {});
+ // @signal - emitted once model overlay is either loaded or errors out
+ this.modelOverlayLoaded = signal(function(error, result){});
+ // @signal - emitted each time the model overlay's joint data has been synchronized
+ this.jointsUpdated = signal(function(overlayID){});
+}
+
+Doppleganger.prototype = {
+ // @public @method - toggles doppleganger on/off
+ toggle: function() {
+ if (this.active) {
+ log('toggling off');
+ this.stop();
+ } else {
+ log('toggling on');
+ this.start();
+ }
+ return this.active;
+ },
+
+ // @public @method - synchronize the joint data between Avatar / doppleganger
+ update: function() {
+ this.frame++;
+ try {
+ if (!this.overlayID) {
+ throw new Error('!this.overlayID');
+ }
+
+ if (this.avatar.skeletonModelURL !== this.skeletonModelURL) {
+ return this.stop('avatar_changed');
+ }
+
+ var rotations = this.avatar.getJointRotations();
+ var translations = this.avatar.getJointTranslations();
+ var size = rotations.length;
+
+ // note: this mismatch can happen when the avatar's model is actively changing
+ if (size !== translations.length ||
+ (this.jointStateCount && size !== this.jointStateCount)) {
+ log('mismatched joint counts (avatar model likely changed)', size, translations.length, this.jointStateCount);
+ this.stop('avatar_changed_joints');
+ return;
+ }
+ this.jointStateCount = size;
+
+ if (this.mirrored) {
+ var mirroredIndexes = this.mirroredIndexes;
+ var outRotations = new Array(size);
+ var outTranslations = new Array(size);
+ for (var i=0; i < size; i++) {
+ var index = mirroredIndexes[i];
+ if (index < 0 || index === false) {
+ index = 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.overlayID, {
+ jointRotations: rotations,
+ jointTranslations: translations
+ });
+
+ this.jointsUpdated(this.overlayID);
+ } catch (e) {
+ log('.update error: '+ e, index);
+ this.stop('update_error');
+ }
+ },
+
+ // @public @method - show the doppleganger (and start the update thread, if options.autoUpdate was specified).
+ // @param {vec3} [options.position=(in front of avatar)] - starting position
+ // @param {quat} [options.orientation=avatar.orientation] - starting orientation
+ start: function(options) {
+ options = options || {};
+ if (this.overlayID) {
+ log('start() called but overlay model already exists', this.overlayID);
+ return;
+ }
+ var avatar = this.avatar;
+ if (!avatar.jointNames.length) {
+ return this.stop('joints_unavailable');
+ }
+
+ this.frame = 0;
+ this.position = options.position || Vec3.sum(avatar.position, Quat.getForward(avatar.orientation));
+ this.orientation = options.orientation || avatar.orientation;
+ this.skeletonModelURL = avatar.skeletonModelURL;
+ this.jointStateCount = 0;
+ this.jointNames = avatar.jointNames;
+ this.mirroredNames = Doppleganger.getMirroredJointNames(this.jointNames);
+ this.mirroredIndexes = this.mirroredNames.map(function(name) {
+ return name ? avatar.getJointIndex(name) : false;
+ });
+
+ this.overlayID = Overlays.addOverlay('model', {
+ visible: false,
+ url: this.skeletonModelURL,
+ position: this.position,
+ rotation: this.orientation
+ });
+
+ this.onModelOverlayLoaded = function(error, result) {
+ if (error) {
+ return this.stop(error);
+ }
+ log('ModelOverlay is ready; # joints == ' + result.jointNames.length);
+ Overlays.editOverlay(this.overlayID, { visible: true });
+ if (!options.position) {
+ this.syncVerticalPosition();
+ }
+ if (this.autoUpdate) {
+ this._createUpdateThread();
+ }
+ };
+ this.modelOverlayLoaded.connect(this, 'onModelOverlayLoaded');
+
+ log('doppleganger created; overlayID =', this.overlayID);
+
+ // trigger clean up (and stop updates) if the overlay gets deleted
+ this.onDeletedOverlay = function(uuid) {
+ if (uuid === this.overlayID) {
+ log('onDeletedOverlay', uuid);
+ this.stop('overlay_deleted');
+ }
+ };
+ Overlays.overlayDeleted.connect(this, 'onDeletedOverlay');
+
+ if ('onLoadComplete' in avatar) {
+ // stop the current doppleganger if Avatar loads a different model URL
+ this.onLoadComplete = function() {
+ if (avatar.skeletonModelURL !== this.skeletonModelURL) {
+ this.stop('avatar_changed_load');
+ }
+ };
+ avatar.onLoadComplete.connect(this, 'onLoadComplete');
+ }
+
+ this.activeChanged(this.active = true, 'start');
+ this._waitForModel(ModelCache.prefetch(this.skeletonModelURL));
+ },
+
+ // @public @method - hide the doppleganger
+ // @param {String} [reason=stop] - the reason stop was called
+ stop: function(reason) {
+ reason = reason || 'stop';
+ if (this.onUpdate) {
+ Script.update.disconnect(this, 'onUpdate');
+ delete this.onUpdate;
+ }
+ if (this._interval) {
+ Script.clearInterval(this._interval);
+ this._interval = undefined;
+ }
+ if (this.onDeletedOverlay) {
+ Overlays.overlayDeleted.disconnect(this, 'onDeletedOverlay');
+ delete this.onDeletedOverlay;
+ }
+ if (this.onLoadComplete) {
+ this.avatar.onLoadComplete.disconnect(this, 'onLoadComplete');
+ delete this.onLoadComplete;
+ }
+ if (this.onModelOverlayLoaded) {
+ this.modelOverlayLoaded.disconnect(this, 'onModelOverlayLoaded');
+ }
+ if (this.overlayID) {
+ Overlays.deleteOverlay(this.overlayID);
+ this.overlayID = undefined;
+ }
+ if (this.active) {
+ this.activeChanged(this.active = false, reason);
+ } else if (reason) {
+ log('already stopped so not triggering another activeChanged; latest reason was:', reason);
+ }
+ },
+
+ // @public @method - Reposition the doppleganger so it sees "eye to eye" with the Avatar.
+ // @param {String} [byJointName=Hips] - the reference joint used to align the Doppleganger and Avatar
+ syncVerticalPosition: function(byJointName) {
+ byJointName = byJointName || 'Hips';
+ var names = Overlays.getProperty(this.overlayID, 'jointNames'),
+ positions = Overlays.getProperty(this.overlayID, 'jointPositions'),
+ dopplePosition = Overlays.getProperty(this.overlayID, '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.overlayID, { position: this.position });
+ },
+
+ // @private @method - creates the update thread to synchronize joint data
+ _createUpdateThread: function() {
+ 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 - waits for model to load and handles timeouts
+ // @param {ModelResource} resource - a prefetched resource to monitor loading state against
+ _waitForModel: function(resource) {
+ var RECHECK_MS = 50;
+ var id = this.overlayID,
+ watchdogTimer = null;
+
+ function waitForJointNames() {
+ var error = null, result = null;
+ if (!watchdogTimer) {
+ error = 'joints_unavailable';
+ } else if (resource.state === Resource.State.FAILED) {
+ error = 'prefetch_failed';
+ } else if (resource.state === Resource.State.FINISHED) {
+ var names = Overlays.getProperty(id, 'jointNames');
+ if (Array.isArray(names) && names.length) {
+ result = { overlayID: id, jointNames: names };
+ }
+ }
+ if (error || result !== null) {
+ Script.clearInterval(this._interval);
+ this._interval = null;
+ if (watchdogTimer) {
+ Script.clearTimeout(watchdogTimer);
+ }
+ this.modelOverlayLoaded(error, result);
+ }
+ }
+ watchdogTimer = Script.setTimeout(function() {
+ watchdogTimer = null;
+ }, Doppleganger.MAX_WAIT_SECS * 1000);
+ this._interval = Script.setInterval(bind(this, waitForJointNames), RECHECK_MS);
+ }
+};
+
+// @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 - Qt signal polyfill
+function signal(template) {
+ var callbacks = [];
+ return Object.defineProperties(function() {
+ var args = [].slice.call(arguments);
+ callbacks.forEach(function(obj) {
+ obj.handler.apply(obj.scope, args);
+ });
+ }, {
+ connect: { value: function(scope, handler) {
+ callbacks.push({scope: scope, handler: scope[handler] || handler || scope});
+ }},
+ disconnect: { value: function(scope, handler) {
+ var match = {scope: scope, handler: scope[handler] || handler || scope};
+ callbacks = callbacks.filter(function(obj) {
+ return !(obj.scope === match.scope && obj.handler === match.handler);
+ });
+ }}
+ });
+}
+
+// @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) {
+ DebugControls.COLOR_DEFAULT = { red: 255, blue: 255, green: 255 };
+ DebugControls.COLOR_SELECTED = { red: 0, blue: 255, green: 0 };
+
+ function DebugControls() {
+ this.enableIndicators = true;
+ this.selectedJointName = null;
+ this.debugOverlayIDs = undefined;
+ this.jointSelected = signal(function(result) {});
+ }
+ DebugControls.prototype = {
+ start: function() {
+ if (!this.onMousePressEvent) {
+ this.onMousePressEvent = this._onMousePressEvent;
+ Controller.mousePressEvent.connect(this, 'onMousePressEvent');
+ }
+ },
+
+ stop: function() {
+ this.removeIndicators();
+ if (this.onMousePressEvent) {
+ Controller.mousePressEvent.disconnect(this, 'onMousePressEvent');
+ delete this.onMousePressEvent;
+ }
+ },
+
+ createIndicators: function(jointNames) {
+ this.jointNames = jointNames;
+ return jointNames.map(function(name, i) {
+ return Overlays.addOverlay('shape', {
+ shape: 'Icosahedron',
+ scale: 0.1,
+ solid: false,
+ alpha: 0.5
+ });
+ });
+ },
+
+ removeIndicators: function() {
+ if (this.debugOverlayIDs) {
+ this.debugOverlayIDs.forEach(Overlays.deleteOverlay);
+ this.debugOverlayIDs = undefined;
+ }
+ },
+
+ onJointsUpdated: function(overlayID) {
+ if (!this.enableIndicators) {
+ return;
+ }
+ var jointNames = Overlays.getProperty(overlayID, 'jointNames'),
+ jointOrientations = Overlays.getProperty(overlayID, 'jointOrientations'),
+ jointPositions = Overlays.getProperty(overlayID, 'jointPositions'),
+ selectedIndex = jointNames.indexOf(this.selectedJointName);
+
+ if (!this.debugOverlayIDs) {
+ this.debugOverlayIDs = this.createIndicators(jointNames);
+ }
+
+ // batch all updates into a single call (using the editOverlays({ id: {props...}, ... }) API)
+ var updatedOverlays = this.debugOverlayIDs.reduce(function(updates, id, i) {
+ updates[id] = {
+ position: jointPositions[i],
+ rotation: jointOrientations[i],
+ color: i === selectedIndex ? DebugControls.COLOR_SELECTED : DebugControls.COLOR_DEFAULT,
+ solid: i === selectedIndex
+ };
+ return updates;
+ }, {});
+ Overlays.editOverlays(updatedOverlays);
+ },
+
+ _onMousePressEvent: function(evt) {
+ if (!evt.isLeftButton || !this.enableIndicators || !this.debugOverlayIDs) {
+ return;
+ }
+ var ray = Camera.computePickRay(evt.x, evt.y),
+ hit = Overlays.findRayIntersection(ray, true, this.debugOverlayIDs);
+
+ hit.jointIndex = this.debugOverlayIDs.indexOf(hit.overlayID);
+ hit.jointName = this.jointNames[hit.jointIndex];
+ this.jointSelected(hit);
+ }
+ };
+
+ if ('$debugControls' in doppleganger) {
+ throw new Error('only one set of debug controls can be added per doppleganger');
+ }
+ var debugControls = new DebugControls();
+ doppleganger.$debugControls = debugControls;
+
+ function onMousePressEvent(evt) {
+ if (evt.isRightButton) {
+ if (evt.isShifted) {
+ debugControls.enableIndicators = !debugControls.enableIndicators;
+ if (!debugControls.enableIndicators) {
+ debugControls.removeIndicators();
+ }
+ } else {
+ doppleganger.mirrored = !doppleganger.mirrored;
+ }
+ }
+ }
+
+ doppleganger.activeChanged.connect(function(active) {
+ if (active) {
+ debugControls.start();
+ doppleganger.jointsUpdated.connect(debugControls, 'onJointsUpdated');
+ Controller.mousePressEvent.connect(onMousePressEvent);
+ } else {
+ Controller.mousePressEvent.disconnect(onMousePressEvent);
+ doppleganger.jointsUpdated.disconnect(debugControls, 'onJointsUpdated');
+ debugControls.stop();
+ }
+ });
+
+ debugControls.jointSelected.connect(function(hit) {
+ debugControls.selectedJointName = hit.jointName;
+ if (hit.jointIndex < 0) {
+ return;
+ }
+ hit.mirroredJointName = Doppleganger.getMirroredJointNames([hit.jointName])[0];
+ log('selected joint:', JSON.stringify(hit, 0, 2));
+ });
+
+ Script.scriptEnding.connect(debugControls, 'removeIndicators');
+
+ return doppleganger;
+};