507 lines
18 KiB
JavaScript
507 lines
18 KiB
JavaScript
"use strict";
|
|
/*jslint vars:true, plusplus:true, forin:true*/
|
|
/*global Tablet, Script, */
|
|
/* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */
|
|
//
|
|
// RPO360.js
|
|
//
|
|
// Created by Zach Fox on 2018-10-26
|
|
// Copyright 2018 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
|
|
//
|
|
|
|
(function () { // BEGIN LOCAL_SCOPE
|
|
var AppUi = Script.require('appUi');
|
|
|
|
// Function Name: inFrontOf()
|
|
//
|
|
// Description:
|
|
// - Returns the position in front of the given "position" argument, where the forward vector is based off
|
|
// the "orientation" argument and the amount in front is based off the "distance" argument.
|
|
function inFrontOf(distance, position, orientation) {
|
|
return Vec3.sum(position || MyAvatar.position,
|
|
Vec3.multiply(distance, Quat.getForward(orientation || MyAvatar.orientation)));
|
|
}
|
|
|
|
// Function Name: rpo360On()
|
|
var secondaryCameraConfig = Render.getConfig("SecondaryCamera");
|
|
var camera = false;
|
|
var cameraRotation;
|
|
var cameraPosition;
|
|
var cameraGravity = {x: 0, y: -5, z: 0};
|
|
var velocityLoopInterval = false;
|
|
function rpo360On() {
|
|
// Rez the camera model, and attach
|
|
// the secondary camera to the rezzed model.
|
|
cameraRotation = MyAvatar.orientation;
|
|
cameraPosition = inFrontOf(1.0, Vec3.sum(MyAvatar.position, { x: 0, y: 0.3, z: 0 }));
|
|
camera = Entities.addEntity({
|
|
"angularDamping": 0.08,
|
|
"canCastShadow": false,
|
|
"damping": 0.01,
|
|
"collidesWith": "static,dynamic,kinematic,",
|
|
"collisionMask": 7,
|
|
"modelURL": Script.resolvePath("resources/models/rpo360.fbx"),
|
|
"name": "RPO360 Camera",
|
|
"rotation": cameraRotation,
|
|
"position": cameraPosition,
|
|
"shapeType": "simple-compound",
|
|
"type": "Model",
|
|
"userData": '{"grabbableKey":{"grabbable":true}}',
|
|
"isVisibleInSecondaryCamera": false,
|
|
"gravity": cameraGravity,
|
|
"dynamic": true
|
|
}, true);
|
|
secondaryCameraConfig.attachedEntityId = camera;
|
|
|
|
// Make the button go active if the UI is open OR the camera is on
|
|
// (in this case it'll be both)
|
|
buttonActive(ui.isOpen);
|
|
|
|
// Play a little sound to let the user know we've rezzed the camera
|
|
Audio.playSound(SOUND_CAMERA_ON, {
|
|
volume: 0.15,
|
|
position: cameraPosition,
|
|
localOnly: true
|
|
});
|
|
|
|
// Remove the existing camera model from the domain if one exists.
|
|
// It's easy for this to happen if the user crashes while the RPO360 Camera is on.
|
|
// We do this down here (after the new one is rezzed) so that we don't accidentally delete
|
|
// the newly-rezzed model.
|
|
var entityIDs = Entities.findEntitiesByName("RPO360 Camera", MyAvatar.position, 100, false);
|
|
entityIDs.forEach(function (currentEntityID) {
|
|
var currentEntityOwner = Entities.getEntityProperties(currentEntityID, ['owningAvatarID']).owningAvatarID;
|
|
if (currentEntityOwner === MyAvatar.sessionUUID && currentEntityID !== camera) {
|
|
Entities.deleteEntity(currentEntityID);
|
|
}
|
|
});
|
|
|
|
// Start the velocity loop interval at 70ms
|
|
// This is used to determine when the 360 photo should be snapped
|
|
velocityLoopInterval = Script.setInterval(velocityLoop, 70);
|
|
}
|
|
|
|
// Function Name: velocityLoop()
|
|
var hasBeenThrown = false;
|
|
var hasBeenGrabbed = false;
|
|
var snapshotVelocity = false;
|
|
var snapshotAngularVelocity = false;
|
|
var velocityWasPositive = false;
|
|
var cameraReleaseTime = false;
|
|
var MIN_AIRTIME_MS = 500;
|
|
var flash = false;
|
|
function velocityLoop() {
|
|
// Get the velocity and angular velocity of the camera model
|
|
var properties = Entities.getEntityProperties(camera, [
|
|
'velocity',
|
|
'angularVelocity'
|
|
]);
|
|
var velocity = properties.velocity;
|
|
var angularVelocity = properties.angularVelocity;
|
|
|
|
// ActionIDs refer to the actions on the entity
|
|
// Actions can be things like: NearGrab, FarGrab, Equip, etc.
|
|
var actionIDs = Entities.getActionIDs(camera);
|
|
|
|
// If there's an action on the entity...
|
|
if (actionIDs.length > 0) {
|
|
// Set the "hasBeenGrabbed" flag to "true"
|
|
hasBeenGrabbed = true;
|
|
// Make sure we record that we haven't yet been thrown
|
|
hasBeenThrown = false;
|
|
// If we've previously been grabbed, and there are currently no actions
|
|
// on the camera model...
|
|
} else if (hasBeenGrabbed) {
|
|
// Reset this flag to false
|
|
hasBeenGrabbed = false;
|
|
// We've been thrown now!
|
|
hasBeenThrown = true;
|
|
// Record the time at which a user has thrown the camera
|
|
cameraReleaseTime = Date.now();
|
|
}
|
|
|
|
// If we've been thrown UP...
|
|
if (hasBeenThrown && velocity.y > 0) {
|
|
// Set this flag to true
|
|
velocityWasPositive = true;
|
|
}
|
|
|
|
// If we've been thrown UP in the past, but now we're coming DOWN...
|
|
if (hasBeenThrown && velocityWasPositive && velocity.y < 0) {
|
|
// Reset the state machine
|
|
hasBeenThrown = false;
|
|
velocityWasPositive = false;
|
|
// Don't take a snapshot if the camera hasn't been in the air for very long
|
|
if (Date.now() - cameraReleaseTime <= MIN_AIRTIME_MS) {
|
|
return;
|
|
}
|
|
// Save these properties so that the camera falls realistically
|
|
// after it's taken the 360 snapshot
|
|
snapshotVelocity = velocity;
|
|
snapshotAngularVelocity = angularVelocity;
|
|
// Freeze the camera model and make it not grabbable
|
|
Entities.editEntity(camera, {
|
|
velocity: {x: 0, y: 0, z: 0},
|
|
angularVelocity: {x: 0, y: 0, z: 0},
|
|
gravity: {x: 0, y: 0, z: 0},
|
|
"userData": '{"grabbableKey":{"grabbable":false}}',
|
|
});
|
|
// Add a "flash" to the camera that illuminates the ground below the camera
|
|
flash = Entities.addEntity({
|
|
"collidesWith": "",
|
|
"collisionMask": 0,
|
|
"color": {
|
|
"blue": 173,
|
|
"green": 252,
|
|
"red": 255
|
|
},
|
|
"dimensions": {
|
|
"x": 100,
|
|
"y": 100,
|
|
"z": 100
|
|
},
|
|
"dynamic": false,
|
|
"falloffRadius": 10,
|
|
"intensity": 1,
|
|
"isSpotlight": false,
|
|
"localRotation": { w: 1, x: 0, y: 0, z: 0 },
|
|
"name": "RPO360 Camera Flash",
|
|
"type": "Light",
|
|
"parentID": camera
|
|
});
|
|
// Take the snapshot!
|
|
maybeTake360Snapshot();
|
|
}
|
|
}
|
|
|
|
// Function Name: rpo360Off()
|
|
var WAIT_AFTER_DOMAIN_SWITCH_BEFORE_CAMERA_DELETE_MS = 1 * 1000;
|
|
function rpo360Off(isChangingDomains) {
|
|
if (velocityLoopInterval) {
|
|
Script.clearInterval(velocityLoopInterval);
|
|
velocityLoopInterval = false;
|
|
}
|
|
|
|
function deleteCamera() {
|
|
if (flash) {
|
|
Entities.deleteEntity(flash);
|
|
flash = false;
|
|
}
|
|
if (camera) {
|
|
Entities.deleteEntity(camera);
|
|
camera = false;
|
|
}
|
|
buttonActive(ui.isOpen);
|
|
}
|
|
|
|
secondaryCameraConfig.attachedEntityId = false;
|
|
if (camera) {
|
|
// Workaround for Avatar Entities not immediately having properties after
|
|
// the "Window.domainChanged()" signal is emitted.
|
|
// May no longer be necessary; untested...
|
|
if (isChangingDomains) {
|
|
Script.setTimeout(function () {
|
|
deleteCamera();
|
|
rpo360On();
|
|
}, WAIT_AFTER_DOMAIN_SWITCH_BEFORE_CAMERA_DELETE_MS);
|
|
} else {
|
|
deleteCamera();
|
|
}
|
|
}
|
|
}
|
|
|
|
var isCurrentlyTaking360Snapshot = false;
|
|
var processing360Snapshot = false;
|
|
function maybeTake360Snapshot() {
|
|
// Don't take a snapshot if we're currently in the middle of taking one
|
|
// or if the camera entity doesn't exist
|
|
if (!isCurrentlyTaking360Snapshot && camera) {
|
|
isCurrentlyTaking360Snapshot = true;
|
|
var currentCameraPosition = Entities.getEntityProperties(camera, ['position']).position;
|
|
// Play a sound at the current camera position
|
|
Audio.playSound(SOUND_SNAPSHOT, {
|
|
position: { x: currentCameraPosition.x, y: currentCameraPosition.y, z: currentCameraPosition.z },
|
|
localOnly: false,
|
|
volume: 0.8
|
|
});
|
|
Window.takeSecondaryCamera360Snapshot(currentCameraPosition);
|
|
used360AppToTakeThisSnapshot = true;
|
|
processing360Snapshot = true;
|
|
|
|
// Let the QML know we're processing a 360 snapshot now
|
|
ui.sendMessage({
|
|
method: 'startedProcessing360Snapshot'
|
|
});
|
|
}
|
|
}
|
|
|
|
function on360SnapshotTaken(path) {
|
|
isCurrentlyTaking360Snapshot = false;
|
|
// Make the camera fall back to the ground with the same
|
|
// physical properties as when it froze in the air
|
|
Entities.editEntity(camera, {
|
|
velocity: snapshotVelocity,
|
|
angularVelocity: snapshotAngularVelocity,
|
|
gravity: cameraGravity,
|
|
"userData": '{"grabbableKey":{"grabbable":true}}',
|
|
});
|
|
// Delete the flash entity
|
|
if (flash) {
|
|
Entities.deleteEntity(flash);
|
|
flash = false;
|
|
}
|
|
console.log('360 Snapshot taken. Path: ' + path + "\nUploading now...");
|
|
// Upload the 360 snapshot to our S3
|
|
Window.shareSnapshot(path);
|
|
}
|
|
|
|
var last360SnapshotUrl = false;
|
|
var last360ThumbnailURL = false;
|
|
var used360AppToTakeThisSnapshot = false;
|
|
// We process this signal after any snapshot is uploaded to the HiFi website
|
|
function snapshotUploaded(isError, reply) {
|
|
if (!isError) {
|
|
// This isn't foolproof - there's a race condition here. A user could
|
|
// take a snapshot using the Snap app or some other method, upload it,
|
|
// just before using this app to take a snapshot. At that point,
|
|
// it'd be possible to enter this conditional accidentally.
|
|
// But the likelihood of that happening is very low.
|
|
if (used360AppToTakeThisSnapshot) {
|
|
var replyJson = JSON.parse(reply),
|
|
storyID = replyJson.user_story.id,
|
|
imageURL = replyJson.user_story.details.image_url,
|
|
thumbnailURL = replyJson.user_story.thumbnail_url;
|
|
|
|
used360AppToTakeThisSnapshot = false;
|
|
last360SnapshotUrl = imageURL;
|
|
last360ThumbnailURL = thumbnailURL;
|
|
console.log('SUCCESS: Snapshot uploaded! Story with audience:for_url created! ID:', storyID);
|
|
console.log("Image URL: " + last360SnapshotUrl);
|
|
console.log("Thumbnail URL: " + last360ThumbnailURL);
|
|
ui.sendMessage({
|
|
method: 'last360ThumbnailURL',
|
|
last360ThumbnailURL: last360ThumbnailURL
|
|
});
|
|
processing360Snapshot = false;
|
|
ui.sendMessage({
|
|
method: 'finishedProcessing360Snapshot'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// This is the globe with the 360 image printed on the OUTSIDE.
|
|
var globe = false;
|
|
function rez360Globe() {
|
|
var properties = {
|
|
"type": 'Sphere',
|
|
|
|
"name": "Globe by " + MyAvatar.sessionDisplayName,
|
|
"description": "Created with RPO360 Cam",
|
|
|
|
"dimensions": { "x": 1.0, "y": 1.0, "z": 1.0 },
|
|
"position": inFrontOf(2.0, Vec3.sum(MyAvatar.position, { x: 0, y: 0.3, z: 0 })),
|
|
|
|
"density": 200,
|
|
"restitution": 0.15,
|
|
"gravity": { "x": 0, "y": -0.5, "z": 0 },
|
|
"damping": 0.45,
|
|
|
|
"dynamic": true,
|
|
"collisionsWillMove": true,
|
|
|
|
"grab": { "grabbable": true }
|
|
};
|
|
|
|
globe = Entities.addEntity(properties);
|
|
|
|
var globeMaterial = Entities.addEntity({
|
|
type: "Material",
|
|
name: "Globe Texture",
|
|
parentID: globe,
|
|
materialURL: "materialData",
|
|
priority: 1,
|
|
materialData: JSON.stringify({
|
|
materialVersion: 1,
|
|
materials: {
|
|
"model": "hifi_pbr",
|
|
"albedoMap": last360SnapshotUrl,
|
|
"emissiveMap": last360SnapshotUrl
|
|
}
|
|
})
|
|
});
|
|
}
|
|
|
|
// This is the globe with the 360 image printed on the INSIDE.
|
|
var streetViewGlobe = false;
|
|
function rezStreetViewGlobe() {
|
|
var properties = {
|
|
"type": 'Model',
|
|
"shapeType": "simple-compound",
|
|
|
|
"name": "Street View Globe by " + MyAvatar.sessionDisplayName,
|
|
"description": "Created with RPO360 Cam",
|
|
|
|
"dimensions": { "x": 3.0, "y": 3.0, "z": 3.0 },
|
|
"modelURL": Script.resolvePath("resources/models/invertedSphere.fbx"),
|
|
"position": inFrontOf(3.0, Vec3.sum(MyAvatar.position, { x: 0, y: 0.55, z: 0 })),
|
|
|
|
"density": 200,
|
|
"restitution": 0.15,
|
|
"gravity": { "x": 0, "y": -0.5, "z": 0 },
|
|
"damping": 0.45,
|
|
|
|
"dynamic": true,
|
|
|
|
"collidesWith": "static,dynamic,kinematic,",
|
|
"collisionMask": 7,
|
|
"collisionsWillMove": true,
|
|
|
|
"userData": "{\"grabbableKey\":{\"grabbable\":true}}"
|
|
};
|
|
|
|
streetViewGlobe = Entities.addEntity(properties);
|
|
|
|
var globeMaterial = Entities.addEntity({
|
|
type: "Material",
|
|
name: "Globe Texture",
|
|
parentID: streetViewGlobe,
|
|
materialURL: "materialData",
|
|
priority: 1,
|
|
materialData: JSON.stringify({
|
|
materialVersion: 1,
|
|
materials: {
|
|
"model": "hifi_pbr",
|
|
"albedoMap": last360SnapshotUrl,
|
|
"emissiveMap": last360SnapshotUrl
|
|
}
|
|
})
|
|
});
|
|
}
|
|
|
|
// Stolen from `controllerDispatcherUtils.js`
|
|
// The client team probably wouldn't like the "disable/enable grab highlighting"
|
|
// feature that this app implements :)
|
|
const DISPATCHER_HOVERING_LIST = "dispactherHoveringList";
|
|
const DISPATCHER_HOVERING_STYLE = {
|
|
isOutlineSmooth: true,
|
|
outlineWidth: 0,
|
|
outlineUnoccludedColor: {red: 255, green: 128, blue: 128},
|
|
outlineUnoccludedAlpha: 0.0,
|
|
outlineOccludedColor: {red: 255, green: 128, blue: 128},
|
|
outlineOccludedAlpha:0.0,
|
|
fillUnoccludedColor: {red: 255, green: 255, blue: 255},
|
|
fillUnoccludedAlpha: 0.12,
|
|
fillOccludedColor: {red: 255, green: 255, blue: 255},
|
|
fillOccludedAlpha: 0.0
|
|
};
|
|
|
|
// Function Name: fromQml()
|
|
function fromQml(message) {
|
|
switch (message.method) {
|
|
case 'rpo360On':
|
|
rpo360On();
|
|
break;
|
|
case 'rpo360Off':
|
|
rpo360Off();
|
|
break;
|
|
case 'openSettings':
|
|
if ((HMD.active && Settings.getValue("hmdTabletBecomesToolbar", false))
|
|
|| (!HMD.active && Settings.getValue("desktopTabletBecomesToolbar", true))) {
|
|
Desktop.show("hifi/dialogs/GeneralPreferencesDialog.qml", "GeneralPreferencesDialog");
|
|
} else {
|
|
tablet.pushOntoStack("hifi/tablet/TabletGeneralPreferences.qml");
|
|
}
|
|
break;
|
|
case 'rezGlobe':
|
|
rez360Globe();
|
|
break;
|
|
case 'rezStreetViewGlobe':
|
|
rezStreetViewGlobe();
|
|
break;
|
|
case 'disableGrabHighlighting':
|
|
Selection.disableListHighlight(DISPATCHER_HOVERING_LIST);
|
|
break;
|
|
case 'enableGrabHighlighting':
|
|
Selection.enableListHighlight(DISPATCHER_HOVERING_LIST, DISPATCHER_HOVERING_STYLE);
|
|
break;
|
|
default:
|
|
print('Unrecognized message from RPO360.qml:', JSON.stringify(message));
|
|
}
|
|
}
|
|
|
|
// Function Name: shutdown()
|
|
//
|
|
// Description:
|
|
// -shutdown() will be called when the script ends (i.e. is stopped).
|
|
function shutdown() {
|
|
rpo360Off();
|
|
Window.domainChanged.disconnect(onDomainChanged);
|
|
Window.snapshot360Taken.disconnect(on360SnapshotTaken);
|
|
Window.snapshotShared.disconnect(snapshotUploaded);
|
|
ui.tablet.tabletShownChanged.disconnect(tabletVisibilityChanged);
|
|
}
|
|
|
|
// Function Name: onDomainChanged()
|
|
//
|
|
// Description:
|
|
// -A small utility function used when the Window.domainChanged() signal is fired.
|
|
function onDomainChanged() {
|
|
rpo360Off(true);
|
|
}
|
|
|
|
// These functions will be called when the script is loaded.
|
|
var SOUND_CAMERA_ON = SoundCache.getSound(Script.resolvePath("resources/sounds/cameraOn.wav"));
|
|
var SOUND_SNAPSHOT = SoundCache.getSound(Script.resolvePath("resources/sounds/snap.wav"));
|
|
|
|
function buttonActive(isActive) {
|
|
ui.button.editProperties({isActive: isActive || camera});
|
|
}
|
|
|
|
function onOpened() {
|
|
// In the case of a remote QML app, it takes a bit of time
|
|
// for the event bridge to actually connect, so we have to wait...
|
|
Script.setTimeout(function () {
|
|
if (ui.isOpen) {
|
|
ui.sendMessage({
|
|
method: 'initializeUI',
|
|
masterSwitchOn: !!camera,
|
|
last360ThumbnailURL: last360ThumbnailURL || "",
|
|
processing360Snapshot: processing360Snapshot
|
|
});
|
|
}
|
|
}, 700);
|
|
}
|
|
|
|
function tabletVisibilityChanged() {
|
|
if (!ui.tablet.tabletShown && ui.isOpen) {
|
|
ui.close();
|
|
}
|
|
}
|
|
|
|
// Function Name: startup()
|
|
var ui;
|
|
var RPO_360_QML_SOURCE = Script.resolvePath("RPO360.qml");
|
|
function startup() {
|
|
ui = new AppUi({
|
|
buttonName: "RPO360",
|
|
home: RPO_360_QML_SOURCE,
|
|
onMessage: fromQml,
|
|
buttonActive: buttonActive,
|
|
onOpened: onOpened,
|
|
graphicsDirectory: Script.resolvePath("./resources/images/icons/")
|
|
});
|
|
|
|
Window.domainChanged.connect(onDomainChanged);
|
|
Window.snapshot360Taken.connect(on360SnapshotTaken);
|
|
Window.snapshotShared.connect(snapshotUploaded);
|
|
ui.tablet.tabletShownChanged.connect(tabletVisibilityChanged);
|
|
camera = false;
|
|
}
|
|
|
|
startup();
|
|
Script.scriptEnding.connect(shutdown);
|
|
}()); // END LOCAL_SCOPE
|