diff --git a/applications/cam360/cam360.css b/applications/cam360/cam360.css new file mode 100644 index 0000000..53f8d61 --- /dev/null +++ b/applications/cam360/cam360.css @@ -0,0 +1,330 @@ +/* + cam360.css + + Created by Alezia Kurdis, August 23rd 2025. + Copyright 2025, Overte e.V. + + CSS for the UI for an application to take 360 degrees photo by throwing a camera in the air. + + Distributed under the Apache License, Version 2.0. + See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +*/ + +@font-face { + font-family: FiraSans-SemiBold; + src: url(resources/fonts/FiraSans-SemiBold.ttf); +} +html { + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +body.throwMode { + background-color: #BBBBBB; + font-family: FiraSans-SemiBold; + font-size: 12px; + color: #000000; + font-weight: 600; + text-decoration: none; + font-style: normal; + font-variant: normal; + text-transform: none; + width: 100%; + height: 100%; + min-height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +body.positionMode { + background-color: #222222; + font-family: FiraSans-SemiBold; + font-size: 12px; + color: #FFFFFF; + font-weight: 600; + text-decoration: none; + font-style: normal; + font-variant: normal; + text-transform: none; + width: 100%; + height: 100%; + min-height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +h1 { + padding: 0px 0px 0px 10px; + font-size: 22px; + color: #ffe657; +} +.cameraOnOffthrowMode { + color: #222222; + background-color: #bbbbbb; + font-family: FiraSans-SemiBold; + font-size: 16px; + padding: 6px 10px 6px 10px; + min-width: 80px; + margin-top: 5px; + border-radius: 6px; + box-shadow: 0px 0px 2px 0px #dddddd inset, 0px 0px 2px 1px #222222; +} + +.cameraOnOffthrowMode:hover { + background-color: #dddddd; +} + +.cameraOnOffpositionMode { + color: #bbbbbb; + background-color: #222222; + font-family: FiraSans-SemiBold; + font-size: 16px; + padding: 6px 10px 6px 10px; + min-width: 80px; + margin-top: 5px; + border-radius: 6px; + box-shadow: 0px 0px 3px 0px #444444 inset, 0px 0px 2px 1px #000000; +} + +.cameraOnOffpositionMode:hover { + background-color: #444444; +} + +#cameraIndicator { + margin: 2px 5px 2px 5px; + vertical-align: middle; +} +#flashOnOff { + border: 1px solid #777777; + border-radius: 6px; + background-color: #222222; + padding: 2px; + margin: 6px; +} + +#flashOnOff:hover { + border: 1px solid #999999; +} + +.folderSetting { + color: #ffffff; + background-color: #333333; + font-family: FiraSans-SemiBold; + font-size: 12px; + padding: 2px 7px 2px 7px; + min-width: 90px; + margin: 10px 6px 6px 6px; + border-radius: 6px; + box-shadow: 0px 0px 3px 0px #444444 inset, 0px 0px 2px 1px #000000; +} + +.folderSetting:hover { + background-color: #555555; +} + +*:focus { + outline: none; +} +.modeBtnOn { + border: 1px solid #ffe657; + color: #ffe657; + background-color: #8f8029; + font-family: FiraSans-SemiBold; + font-size: 18px; + padding: 2px 7px 2px 7px; + min-width: 200px; + margin: 0; +} +.modeBtnOn:hover { + border: 1px solid #ffdd00; + color: #ffdd00; + background-color: #ad9b2f; +} + +.modeBtnOff { + border: 1px solid #111111; + color: #555555; + background-color: #222222; + font-family: FiraSans-SemiBold; + font-size: 18px; + padding: 2px 7px 2px 7px; + min-width: 200px; + margin: 0; +} + +.modeBtnOff:hover { + border: 1px solid #777777; + color: #777777; + background-color: #333333; +} + +#throwModeControls { + display: block; + width: 100%; + padding: 0; + text-align: center; + margin: 0; +} +#noticeThrow { + font-family: FiraSans-SemiBold; + font-size: 15px; + width: 100%; + background-color: #222222; + color: #ffff00; + padding: 3px; + box-shadow: 0px 0px 2px 0px #dddddd inset, 0px 0px 2px 1px #222222; +} + +#positionModeControls { + display: none; + font-family: FiraSans-SemiBold; + font-size: 11px; + width: 100%; + color: #aaaaaa; + text-align: left; + margin: 0; + padding: 0; +} + +#subPositionContainer { + text-align: center; + width: 90%; +} + +#capture { + box-shadow: 0px 0px 3px 0px #444444 inset, 0px 0px 2px 1px #000000; + color: #ff5429; + background-color: #222222; + font-family: FiraSans-SemiBold; + font-size: 18px; + padding: 10px 5px 10px 5px; + min-width: 120px; + height: 60px; + margin: 0; + border-radius: 30px; +} + +#capture:hover { + background-color: #666666; +} + +#capture:disabled { + color: #777777; +} + +#showLastCapture { + color: #ffffff; + background-color: #444444; + font-family: FiraSans-SemiBold; + font-size: 16px; + padding: 10px 5px 10px 5px; + min-width: 240px; + margin: 6px; + border-radius: 13px; + box-shadow: 0px 0px 3px 0px #444444 inset, 0px 0px 2px 1px #000000; +} + +#showLastCapture:hover { + background-color: #666666; +} + +button.navViewer { + color: #ffffff; + background-color: #444444; + font-family: FiraSans-SemiBold; + font-size: 16px; + padding: 10px 5px 10px 5px; + min-width: 50px; + margin: 6px; + border-radius: 13px; + box-shadow: 0px 0px 3px 0px #444444 inset, 0px 0px 2px 1px #000000; +} + +button.navViewer:hover { + background-color: #666666; +} + +#uninstall { + font-family: FiraSans-SemiBold; + background-color: #666666; + font-size: 9px; + color: #cccccc; + border-radius: 3px; + border: 0px solid #000000; + transition-duration: 0.2s; + width: 100px; + padding: 3px; + margin-right: 20px; + margin-bottom: 15px; +} + +#uninstall:hover { + background-color: #888888; + color: #ffffff; +} + +#uninstall:focus { + outline: none; +} + +#topContainer { + width: 100%; + padding: 10px; + background-color: #444444; + box-shadow: -3px 3px 8px #000000; +} + +#viewerContainer { + position: fixed; + left: 0px; + top: 50%; + width: 100%; + background-color: #444444; + box-shadow: -3px -3px 8px #000000; + color: #ffffff; +} + +div.overborder { + padding: 0; + border: 1px solid #000000; + display: inline-block; +} + +#postShotContainer { + text-align: left; + width: 96%; + padding: 4px; +} + +#alpha { + width: 200px; +} + +#settingContainer { + width: 100%; + padding: 10px; + background-color: #333333; + box-shadow: -3px -3px 8px #000000; + color: #ffffff; + text-align: left; +} + +#uninstallContainer { + width: 100%; + padding: 10px; + background-color: #222222; + box-shadow: -3px -3px 8px #000000; + color: #ffffff; + text-align: right; +} + +#hideVRnotice { + font-family: FiraSans-SemiBold; + color: #bbbbbb; + font-size: 10px; + font-style: italic; +} diff --git a/applications/cam360/cam360.html b/applications/cam360/cam360.html index 768090a..a86887a 100644 --- a/applications/cam360/cam360.html +++ b/applications/cam360/cam360.html @@ -3,7 +3,7 @@ cam360.html Created by Alezia Kurdis, August 27th 2022. - Copyright 2022, Overte e.V. + Copyright 2022-2025, Overte e.V. UI for an application to take 360 degrees photo by throwing a camera in the air. @@ -13,394 +13,104 @@
- - - + + - -
- CAM360 v2.0- |
- - - | -||||||||||||
|
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/applications/cam360/cam360.js b/applications/cam360/cam360.js
index abad3b2..0e4d183 100644
--- a/applications/cam360/cam360.js
+++ b/applications/cam360/cam360.js
@@ -2,9 +2,9 @@
//
// cam360.js
//
-// Created by Zach Fox on 2018-10-26
+// Created by Zach Fox on October 26th, 2018.
// Copyright 2018 High Fidelity, Inc.
-// Copyright 2022, Overte e.V.
+// Copyright 2022-2025, Overte e.V.
//
// Application to take 360 degrees photo by throwing a camera in the air (as in Ready Player One (RPO)) or as a standard positionned camera.
// version 2.0
@@ -14,7 +14,8 @@
//
(function () { // BEGIN LOCAL_SCOPE
-
+ var controllerStandard = Controller.Standard;
+
// Function Name: inFrontOf()
// Description:
// - Returns the position in front of the given "position" argument, where the forward vector is based off
@@ -25,8 +26,10 @@
}
// Function Name: rpo360On()
- var CAMERA_NAME = "CAM360 Camera";
- var SETTING_LAST_360_CAPTURE = "overte_app_cam360_last_capture";
+ const CAMERA_NAME = "CAM360 Camera";
+ const SETTING_LAST_360_CAPTURE = "overte_app_cam360_last_capture";
+ const SETTING_POST_SHOT_BAHAVIOR = "overte_app_cam360_post_shot_behavior";
+ const SETTING_VISUALIZER_ALPHA = "overte_app_cam360_visualizer_alpha";
var secondaryCameraConfig = Render.getConfig("SecondaryCamera");
var camera = false;
var cameraRotation;
@@ -34,7 +37,13 @@
var cameraGravity = {x: 0, y: -5, z: 0};
var velocityLoopInterval = false;
var isThrowMode = true;
-
+ var visualizerID = Uuid.NONE;
+ var visualizerMaterialID = Uuid.NONE;
+ const HISTORY_LENGTH = 20;
+ let currentPreviewIndex = 0;
+ let visualizerAlpha = Settings.getValue(SETTING_VISUALIZER_ALPHA, 1.0);
+ let postShotBehavior = Settings.getValue(SETTING_POST_SHOT_BAHAVIOR, "HOME");
+
function rpo360On() {
// Rez the camera model, and attach
// the secondary camera to the rezzed model.
@@ -43,27 +52,31 @@
var properties;
var hostType = "";
if (isThrowMode) {
- properties = {
- "angularDamping": 0.08,
- "canCastShadow": false,
- "damping": 0.01,
- "collisionMask": 7,
- "modelURL": Script.resolvePath("resources/models/cam360white.fst"),
- "name": CAMERA_NAME,
- "rotation": cameraRotation,
- "position": cameraPosition,
- "shapeType": "simple-compound",
- "type": "Model",
- "grab": {
- "grabbable": true
- },
- "script": Script.resolvePath("grabDetection.js"),
- "userData": "",
- "isVisibleInSecondaryCamera": false,
- "gravity": cameraGravity,
- "dynamic": true
- };
- hostType = "avatar";
+ if ( Entities.canRezAvatarEntities() ) {
+ properties = {
+ "angularDamping": 0.08,
+ "canCastShadow": false,
+ "damping": 0.01,
+ "collisionMask": 7,
+ "modelURL": Script.resolvePath("resources/models/cam360white.fst"),
+ "name": CAMERA_NAME,
+ "rotation": cameraRotation,
+ "position": cameraPosition,
+ "shapeType": "simple-compound",
+ "type": "Model",
+ "grab": {
+ "grabbable": true
+ },
+ "script": Script.resolvePath("grabDetection.js"),
+ "userData": "",
+ "isVisibleInSecondaryCamera": false,
+ "gravity": cameraGravity,
+ "dynamic": true
+ };
+ hostType = "avatar";
+ } else {
+ Window.displayAnnouncement("Sorry. The Throwing Camera can't be created in this domain.");
+ }
} else {
properties = {
"canCastShadow": false,
@@ -80,7 +93,7 @@
"userData": "",
"isVisibleInSecondaryCamera": false
};
- hostType = "avatar";
+ hostType = Entities.canRezAvatarEntities() ? "avatar" : "local";
}
camera = Entities.addEntity(properties, hostType);
@@ -88,7 +101,7 @@
// Play a little sound to let the user know we've rezzed the camera
Audio.playSound(SOUND_CAMERA_ON, {
- "volume": 0.15,
+ "volume": 0.2,
"position": cameraPosition,
"localOnly": true
});
@@ -110,7 +123,9 @@
// Start the velocity loop interval at 70ms
// This is used to determine when the 360 photo should be snapped
if (isThrowMode) {
- velocityLoopInterval = Script.setInterval(velocityLoop, 70);
+ velocityLoopInterval = Script.setInterval(function () {
+ velocityLoop();
+ }, 70);
}
}
@@ -125,10 +140,10 @@
var useFlash = false;
function velocityLoop() {
// Get the velocity and angular velocity of the camera model
- var properties = Entities.getEntityProperties(camera, ["velocity", "angularVelocity", "userData"]);
+ var properties = Entities.getEntityProperties(camera, ["velocity", "angularVelocity", "description"]);
var velocity = properties.velocity;
var angularVelocity = properties.angularVelocity;
- var releasedState = properties.userData;
+ var releasedState = properties.description;
if (releasedState === "RELEASED" && !hasBeenThrown) {
hasBeenThrown = true;
@@ -150,7 +165,7 @@
// Don't take a snapshot if the camera hasn't been in the air for very long
if (Date.now() - cameraReleaseTime <= MIN_AIRTIME_MS) {
Entities.editEntity(camera, {
- "userData": ""
+ "description": ""
});
return;
}
@@ -166,70 +181,52 @@
"grab": {
"grabbable": false
},
- "userData": ""
+ "description": ""
});
// Add a "flash" to the camera that illuminates the ground below the camera
if (useFlash) {
- 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": CAMERA_NAME + "_Flash",
- "type": "Light",
- "parentID": camera
- }, "avatar");
+ flash = genFlash(camera);
}
// Take the snapshot!
maybeTake360Snapshot();
}
}
+ function genFlash(parentID) {
+ return 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": CAMERA_NAME + "_Flash",
+ "type": "Light",
+ "parentID": parentID
+ }, "local");
+ }
+
function capture() {
if (!isThrowMode) {
if (useFlash) {
- 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": CAMERA_NAME + "_Flash",
- "type": "Light",
- "parentID": camera
- }, "avatar");
+ flash = genFlash(camera);
}
// Take the snapshot!
maybeTake360Snapshot();
}
}
- // Function Name: rpo360Off()
var WAIT_AFTER_DOMAIN_SWITCH_BEFORE_CAMERA_DELETE_MS = 1 * 1000;
function rpo360Off(isChangingDomains) {
if (velocityLoopInterval) {
@@ -246,7 +243,6 @@
Entities.deleteEntity(camera);
camera = false;
}
- //buttonActive(ui.isOpen);
}
secondaryCameraConfig.attachedEntityId = false;
@@ -311,25 +307,50 @@
Entities.deleteEntity(flash);
flash = false;
}
- //console.log('360 Snapshot taken. Path: ' + path);
- //update UI
- tablet.emitScriptEvent(JSON.stringify({
- "channel": channel,
- "method": "last360ThumbnailURL",
- "last360ThumbnailURL": path
- }));
- last360ThumbnailURL = path;
- Settings.setValue(SETTING_LAST_360_CAPTURE, last360ThumbnailURL);
+ updateSnapshot360HistorySetting(path);
+
+ updateVisualizer();
+
processing360Snapshot = false;
tablet.emitScriptEvent(JSON.stringify({
"channel": channel,
"method": "finishedProcessing360Snapshot"
}));
+
+ if (isThrowMode) {
+ if (postShotBehavior === "TURNOFF") {
+ rpo360Off();
+ tablet.emitScriptEvent(JSON.stringify({
+ "channel": channel,
+ "method": "initializeUI",
+ "masterSwitchOn": !!camera,
+ "processing360Snapshot": processing360Snapshot,
+ "useFlash": useFlash,
+ "isThrowMode": isThrowMode,
+ "postShotBehavior": postShotBehavior,
+ "visualizerAlpha": visualizerAlpha
+ }));
+ } else if (postShotBehavior === "HOME") {
+ rpo360Off(false);
+ rpo360On();
+ }
+ }
+ }
+
+ function updateSnapshot360HistorySetting(url) {
+ let updatedHistory = ["file:///" + url];
+ for (let i = 0; i < HISTORY_LENGTH - 1; i++ ) {
+ if (i >= last360ThumbnailURL.length) {
+ break;
+ }
+ updatedHistory.push(last360ThumbnailURL[i]);
+ }
+ last360ThumbnailURL = updatedHistory.slice(0, HISTORY_LENGTH);
+ Settings.setValue(SETTING_LAST_360_CAPTURE, last360ThumbnailURL);
}
-
- var last360ThumbnailURL = Settings.getValue(SETTING_LAST_360_CAPTURE, "");
+ var last360ThumbnailURL = Settings.getValue(SETTING_LAST_360_CAPTURE, [Script.resolvePath("resources/images/default.jpg")]);
var used360AppToTakeThisSnapshot = false;
function onDomainChanged() {
@@ -339,7 +360,7 @@
// 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"));
-
+ var SOUND_WHOOSH = SoundCache.getSound(Script.resolvePath("resources/sounds/whoosh.mp3"));
var jsMainFileName = "cam360.js";
var ROOT = Script.resolvePath('').split(jsMainFileName)[0];
@@ -377,10 +398,16 @@
tablet.webEventReceived.disconnect(onAppWebEventReceived);
tablet.gotoHomeScreen();
appStatus = false;
+ if (visualizerID !== Uuid.NONE) {
+ Entities.deleteEntity(visualizerID);
+ visualizerID = Uuid.NONE;
+ Script.update.disconnect(whooshTimer);
+ }
}else{
tablet.gotoWebScreen(APP_URL);
tablet.webEventReceived.connect(onAppWebEventReceived);
appStatus = true;
+ registerButtonMappings();
}
button.editProperties({
@@ -432,10 +459,11 @@
"channel": channel,
"method": "initializeUI",
"masterSwitchOn": !!camera,
- "last360ThumbnailURL": last360ThumbnailURL,
"processing360Snapshot": processing360Snapshot,
"useFlash": useFlash,
- "isThrowMode": isThrowMode
+ "isThrowMode": isThrowMode,
+ "postShotBehavior": postShotBehavior,
+ "visualizerAlpha": visualizerAlpha
}));
} else if (messageObj.method === "ThrowMode" && (n - timestamp) > INTERCALL_DELAY) {
@@ -460,21 +488,133 @@
if (camera) {
capture();
}
- }
+ } else if (messageObj.method === "showLastCapture" && (n - timestamp) > INTERCALL_DELAY) {
+ d = new Date();
+ timestamp = d.getTime();
+ currentPreviewIndex = 0;
+ showVisualizer();
+ } else if (messageObj.method === "showPreviousCapture" && (n - timestamp) > INTERCALL_DELAY) {
+ d = new Date();
+ timestamp = d.getTime();
+ currentPreviewIndex = currentPreviewIndex + 1;
+ if (currentPreviewIndex === last360ThumbnailURL.length) {
+ currentPreviewIndex = 0;
+ }
+ updateVisualizer();
+ } else if (messageObj.method === "showNextCapture" && (n - timestamp) > INTERCALL_DELAY) {
+ d = new Date();
+ timestamp = d.getTime();
+ currentPreviewIndex = currentPreviewIndex - 1;
+ if (currentPreviewIndex < 0) {
+ currentPreviewIndex = last360ThumbnailURL.length - 1;
+ }
+ updateVisualizer();
+ } else if (messageObj.method === "setVisualizerAlpha" && (n - timestamp) > INTERCALL_DELAY) {
+ d = new Date();
+ timestamp = d.getTime();
+ visualizerAlpha = messageObj.alpha;
+ if (visualizerAlpha < 0.01 || visualizerAlpha > 1.0) {
+ visualizerAlpha = 1.0;
+ }
+ Settings.setValue(SETTING_VISUALIZER_ALPHA, visualizerAlpha);
+ updateVisualizer();
+ } else if (messageObj.method === "setPostBehavior" && (n - timestamp) > INTERCALL_DELAY) {
+ d = new Date();
+ timestamp = d.getTime();
+ postShotBehavior = messageObj.postShotBehavior;
+ Settings.setValue(SETTING_POST_SHOT_BAHAVIOR, postShotBehavior);
+ } else if (messageObj.method === "SELF_UNINSTALL" && (n - timestamp) > INTERCALL_DELAY) {
+ d = new Date();
+ timestamp = d.getTime();
+ ScriptDiscoveryService.stopScript(Script.resolvePath(''), false);
+ }
+ }
+ }
+
+ function showVisualizer() {
+ if (visualizerID === Uuid.NONE) {
+ visualizerID = Entities.addEntity({
+ "type": "Model",
+ "shapeType": "none",
+ "name": "Lastest 360 capture",
+ "dimensions": Vec3.multiply({ "x": 3.0, "y": 3.0, "z": 3.0 }, MyAvatar.scale),
+ "modelURL": Script.resolvePath("resources/models/invertedSphere.glb"),
+ "position": MyAvatar.getEyePosition(),
+ "grab": {
+ "grabbable": false
+ },
+ "useOriginalPivot": true,
+ "canCastShadow": false,
+ "isVisibleInSecondaryCamera": false,
+ "ignorePickIntersection": true
+ }, "local");
+ visualizerMaterialID = Entities.addEntity({
+ "type": "Material",
+ "parentID": visualizerID,
+ "materialURL": "materialData",
+ "priority": 2,
+ "materialData": JSON.stringify({
+ "materialVersion": 1,
+ "materials": {
+ "albedo": [1.0, 1.0, 1.0],
+ "albedoMap": last360ThumbnailURL[currentPreviewIndex],
+ "unlit": true,
+ "metallic": 0.01,
+ "roughness": 0.5,
+ "opacity": visualizerAlpha,
+ "cullFaceMode": "CULL_BACK"
+ }
+ })
+
+ }, "local");
+
+ tablet.emitScriptEvent(JSON.stringify({
+ "channel": channel,
+ "method": "visualizator_is_active"
+ }));
+ Script.update.connect(whooshTimer);
+ } else {
+ Entities.deleteEntity(visualizerID);
+ visualizerID = Uuid.NONE;
+ tablet.emitScriptEvent(JSON.stringify({
+ "channel": channel,
+ "method": "visualizator_is_inactive"
+ }));
+ Script.update.disconnect(whooshTimer);
}
+
}
- var udateSignateDisconnected = true;
+
+ function updateVisualizer() {
+ if (visualizerID !== Uuid.NONE) {
+ Entities.editEntity(visualizerID, {"position": MyAvatar.getEyePosition()});
+ Entities.editEntity(visualizerMaterialID, {
+ "materialData": JSON.stringify({
+ "materialVersion": 1,
+ "materials": {
+ "albedo": [1.0, 1.0, 1.0],
+ "albedoMap": last360ThumbnailURL[currentPreviewIndex],
+ "unlit": true,
+ "metallic": 0.01,
+ "roughness": 0.5,
+ "opacity": visualizerAlpha,
+ "cullFaceMode": "CULL_BACK"
+ }
+ })
+ });
+ }
+ }
+
function onScreenChanged(type, url) {
if (type === "Web" && url.indexOf(APP_URL) !== -1) {
appStatus = true;
- Script.update.connect(myTimer);
- udateSignateDisconnected = false;
} else {
appStatus = false;
- if (!udateSignateDisconnected) {
- Script.update.disconnect(myTimer);
- udateSignateDisconnected = true;
+ if (visualizerID !== Uuid.NONE) {
+ Entities.deleteEntity(visualizerID);
+ visualizerID = Uuid.NONE;
+ Script.update.disconnect(whooshTimer);
}
}
@@ -483,50 +623,19 @@
});
}
- function myTimer(deltaTime) {
- var yaw = 0.0;
- var pitch = 0.0;
- var roll = 0.0;
- var euler;
- if (!HMD.active) {
- //Use cuser camera for destop
- euler = Quat.safeEulerAngles(Camera.orientation);
- yaw = -euler.y;
- pitch = -euler.x;
- roll = -euler.z;
- } else {
- //Use Tablet orientation for HMD
- var tabletRotation = Entities.getEntityProperties(HMD.tabletID, ["rotation"]).rotation;
- var noRoll = Quat.cancelOutRoll(tabletRotation); //Pushing the roll is getting quite complexe
- euler = Quat.safeEulerAngles(noRoll);
- yaw = euler.y - 180;
- if (yaw < -180) { yaw = yaw + 360;}
- yaw = -yaw;
- pitch = euler.x;
- roll = 0;
- }
-
- tablet.emitScriptEvent(JSON.stringify({
- "channel": channel,
- "method": "yawPitchRoll",
- "yaw": yaw,
- "pitch": pitch,
- "roll": roll
- }));
-
- }
-
function cleanup() {
if (appStatus) {
tablet.gotoHomeScreen();
tablet.webEventReceived.disconnect(onAppWebEventReceived);
- if (!udateSignateDisconnected) {
- Script.update.disconnect(myTimer);
- udateSignateDisconnected = true;
- }
}
-
+
+ if (visualizerID !== Uuid.NONE) {
+ Entities.deleteEntity(visualizerID);
+ visualizerID = Uuid.NONE;
+ Script.update.disconnect(whooshTimer);
+ }
+
tablet.screenChanged.disconnect(onScreenChanged);
tablet.removeButton(button);
@@ -542,6 +651,31 @@
}
Script.scriptEnding.connect(cleanup);
+
+ //Whoosh viewer
+ function whooshTimer(deltaTime) {
+ if (HMD.active) {
+ checkHands();
+ }
+ }
+
+ function checkHands() {
+ var myLeftHand = Controller.getPoseValue(controllerStandard.LeftHand);
+ var myRightHand = Controller.getPoseValue(controllerStandard.RightHand);
+ var eyesPosition = MyAvatar.getEyePosition();
+ var hipsPosition = MyAvatar.getJointPosition("Hips");
+ var eyesRelativeHeight = eyesPosition.y - hipsPosition.y;
+ if (myLeftHand.translation.y > eyesRelativeHeight || myRightHand.translation.y > eyesRelativeHeight) {
+ Audio.playSound(SOUND_WHOOSH, {
+ "position": MyAvatar.position,
+ "localOnly": true,
+ "volume": 1.0
+ });
+ if (visualizerID !== Uuid.NONE) {
+ showVisualizer();
+ }
+ }
+ }
//controller
function setTakePhotoControllerMappingStatus() {
@@ -559,8 +693,8 @@
var takePhotoControllerMappingName = 'Overte-cam360-Mapping-Capture';
function registerTakePhotoControllerMapping() {
takePhotoControllerMapping = Controller.newMapping(takePhotoControllerMappingName);
- if (controllerType === "OculusTouch") {
- takePhotoControllerMapping.from(Controller.Standard.RS).to(function (value) {
+ if (controllerType === "OculusTouch" || controllerType === "OpenXR") {
+ takePhotoControllerMapping.from(Controller.Standard.LS).to(function (value) {
if (value === 1.0) {
if (camera) {
capture();
@@ -569,7 +703,7 @@
return;
});
} else if (controllerType === "Vive") {
- takePhotoControllerMapping.from(Controller.Standard.RightPrimaryThumb).to(function (value) {
+ takePhotoControllerMapping.from(Controller.Standard.LeftPrimaryThumb).to(function (value) {
if (value === 1.0) {
if (camera) {
capture();
@@ -588,8 +722,10 @@
controllerType = "Vive";
} else if (VRDevices.indexOf("OculusTouch") !== -1) {
controllerType = "OculusTouch";
+ } else if (VRDevices.indexOf("OpenXR") !== -1) {
+ controllerType = "OpenXR";
} else {
- return; // Neither Vive nor Touch detected
+ return;
}
}
@@ -600,6 +736,6 @@
function onHMDChanged(isHMDMode) {
registerButtonMappings();
- }
+ }
}());
diff --git a/applications/cam360/cam360Html.js b/applications/cam360/cam360Html.js
new file mode 100644
index 0000000..d4ebfa7
--- /dev/null
+++ b/applications/cam360/cam360Html.js
@@ -0,0 +1,240 @@
+//
+// cam360Html.js
+//
+// Created by Alezia Kurdis, August 23rd 2022.
+// Copyright 2025, Overte e.V.
+//
+// js for the html UI for an application to take 360 degrees photo by throwing a camera in the air.
+//
+// Distributed under the Apache License, Version 2.0.
+// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
+//
+
+
+//Paths
+var thisPageName = "cam360.html";
+var currentPath = window.location.protocol + "//" + window.location.host + window.location.pathname;
+var ROOTPATH = currentPath.replace(thisPageName, "");
+var channel = "org.overte.applications.cam360";
+
+var isCameraActive = false;
+var isPhotoProcessing = false;
+var useFlash = false;
+var isThrowMode = true;
+var postShotBehavior = "HOME";
+var DEG_TO_RAD = Math.PI/180;
+
+//LISTENER FROM JS FILE:
+EventBridge.scriptEventReceived.connect(function (message) {
+ var messageObj = JSON.parse(message);
+ if (messageObj.channel === channel && messageObj.method === "initializeUI") {
+ isCameraActive = messageObj.masterSwitchOn;
+ isPhotoProcessing = messageObj.processing360Snapshot;
+ useFlash = messageObj.useFlash;
+ isThrowMode = messageObj.isThrowMode;
+ postShotBehavior = messageObj.postShotBehavior;
+ renderCameraStatus();
+ initiateAlpha(messageObj.visualizerAlpha);
+ initiatePostBehavior();
+ setFlashButton();
+ renderMode();
+
+ } else if (messageObj.channel === channel && messageObj.method === "finishedProcessing360Snapshot") {
+ document.getElementById("processing").innerHTML = "";
+ } else if (messageObj.channel === channel && messageObj.method === "startedProcessing360Snapshot") {
+ document.getElementById("processing").innerHTML = "
+
+
+ + + +
+
+
+ ";
+ } else if (messageObj.channel === channel && messageObj.method === "visualizator_is_active") {
+ document.getElementById("showLastCapture").innerHTML = "Hide Viewer";
+ document.getElementById("hideVRnotice").innerHTML = "In VR, raise a controller above your head to 'Whoosh' and hide the viewer.";
+ } else if (messageObj.channel === channel && messageObj.method === "visualizator_is_inactive") {
+ document.getElementById("showLastCapture").innerHTML = "View Last 360° Capture";
+ document.getElementById("hideVRnotice").innerHTML = " ";
+ }
+});
+
+function initiatePostBehavior() {
+ const radios = document.getElementsByName("postShotBehavior");
+ for (let i = 0; i < radios.length; i++) {
+ if (radios[i].value === postShotBehavior) {
+ radios[i].checked = true;
+ } else {
+ radios[i].checked = false;
+ }
+ }
+}
+
+function setPostBehavior(str) {
+ postShotBehavior = str;
+ const messageToSend = {
+ "channel": channel,
+ "method": "setPostBehavior",
+ "postShotBehavior": postShotBehavior
+ };
+ EventBridge.emitWebEvent(JSON.stringify(messageToSend));
+}
+
+function initiateAlpha(alphaValue) {
+ document.getElementById("alpha").value = Math.floor(alphaValue * 100);
+}
+
+function setAlpha() {
+ let alpha = document.getElementById("alpha").value / 100;
+ const messageToSend = {
+ "channel": channel,
+ "method": "setVisualizerAlpha",
+ "alpha": alpha
+ };
+ EventBridge.emitWebEvent(JSON.stringify(messageToSend));
+}
+
+function setFlashButton() {
+ if (useFlash) {
+ document.getElementById("flashOnOff").innerHTML = " ";
+ } else {
+ document.getElementById("flashOnOff").innerHTML = " ";
+ }
+}
+
+function toggleFlash() {
+ var messageToSend;
+ if (useFlash) {
+ useFlash = false;
+ messageToSend = {
+ "channel": channel,
+ "method": "disableFlash"
+ };
+ } else {
+ useFlash = true;
+ messageToSend = {
+ "channel": channel,
+ "method": "enableFlash"
+ };
+ }
+ setFlashButton();
+ EventBridge.emitWebEvent(JSON.stringify(messageToSend));
+}
+
+function openSetting() {
+ var messageToSend = {
+ "channel": channel,
+ "method": "openSettings"
+ };
+ EventBridge.emitWebEvent(JSON.stringify(messageToSend));
+}
+
+function activateRpo360() {
+ var messageToSend;
+ if (isCameraActive) {
+ messageToSend = {
+ "channel": channel,
+ "method": "rpo360Off"
+ };
+ isCameraActive = false;
+ } else {
+ messageToSend = {
+ "channel": channel,
+ "method": "rpo360On"
+ };
+ isCameraActive = true;
+ }
+ EventBridge.emitWebEvent(JSON.stringify(messageToSend));
+ renderCameraStatus();
+}
+
+function renderCameraStatus() {
+ if (isCameraActive) {
+ document.getElementById("cameraIndicator").src = "resources/images/on.png";
+ document.getElementById("cameraOnOff").innerHTML = "STOPCAMERA"; + if (!isThrowMode) { + document.getElementById("capture").disabled = false; + } + } else { + document.getElementById("cameraIndicator").src = "resources/images/off.png"; + document.getElementById("cameraOnOff").innerHTML = "START CAMERA"; + if (!isThrowMode) { + document.getElementById("capture").disabled = true; + } + } +} + +function setMode(mode) { + var messageToSend; + if (isThrowMode) { + messageToSend = { + "channel": channel, + "method": "PositionMode" + }; + isThrowMode = false; + } else { + messageToSend = { + "channel": channel, + "method": "ThrowMode" + }; + isThrowMode = true; + } + EventBridge.emitWebEvent(JSON.stringify(messageToSend)); + renderMode(); +} + +function renderMode() { + if (isThrowMode) { + document.getElementById("body").className = "throwMode"; + document.getElementById("cameraOnOff").className = "cameraOnOffthrowMode"; + document.getElementById("modeThrow").className = "modeBtnOn"; + document.getElementById("modePosition").className = "modeBtnOff"; + document.getElementById("throwModeControls").style.display = "block"; + document.getElementById("positionModeControls").style.display = "none"; + } else { + document.getElementById("body").className = "positionMode"; + document.getElementById("cameraOnOff").className = "cameraOnOffpositionMode"; + document.getElementById("modeThrow").className = "modeBtnOff"; + document.getElementById("modePosition").className = "modeBtnOn"; + document.getElementById("throwModeControls").style.display = "none"; + document.getElementById("positionModeControls").style.display = "block"; + } +} + +function capture() { + var messageToSend= { + "channel": channel, + "method": "Capture" + }; + EventBridge.emitWebEvent(JSON.stringify(messageToSend)); +} + +function showLastCapture() { + var messageToSend= { + "channel": channel, + "method": "showLastCapture" + }; + EventBridge.emitWebEvent(JSON.stringify(messageToSend)); +} + +function showPreviousCapture() { + var messageToSend= { + "channel": channel, + "method": "showPreviousCapture" + }; + EventBridge.emitWebEvent(JSON.stringify(messageToSend)); +} + +function showNextCapture() { + var messageToSend= { + "channel": channel, + "method": "showNextCapture" + }; + EventBridge.emitWebEvent(JSON.stringify(messageToSend)); +} + +function uninstall() { + var messageToSend = { + "channel": channel, + "method": "SELF_UNINSTALL" + }; + EventBridge.emitWebEvent(JSON.stringify(messageToSend)); +} + +EventBridge.emitWebEvent(JSON.stringify({ + "channel": channel, + "method": "uiReady" +})); diff --git a/applications/cam360/grabDetection.js b/applications/cam360/grabDetection.js index 5d40445..5b8a3ae 100644 --- a/applications/cam360/grabDetection.js +++ b/applications/cam360/grabDetection.js @@ -3,17 +3,17 @@ // grabDetection.js // // Created by Alezia Kurdis on August 26th, 2022 -// Copyright 2022 Overte e.V. +// Copyright 2022-2025 Overte e.V. // // Distributed under the Apache License, Version 2.0 // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // (function(){ - var _this; + //var _this; - DetectGrabbed = function() { - _this = this; + var DetectGrabbed = function() { + //_this = this; }; DetectGrabbed.prototype = { @@ -40,7 +40,7 @@ //print("I was released... entity:" + this.entityID); var ownerID = Entities.getEntityProperties( this.entityID, ["owningAvatarID"] ).owningAvatarID; if ( ownerID === MyAvatar.sessionUUID) { - Entities.editEntity(this.entityID, {"userData": "RELEASED"}); + Entities.editEntity(this.entityID, {"description": "RELEASED"}); } }, diff --git a/applications/cam360/resources/images/flashOff.jpg b/applications/cam360/resources/images/flashOff.jpg new file mode 100644 index 0000000..20d5894 Binary files /dev/null and b/applications/cam360/resources/images/flashOff.jpg differ diff --git a/applications/cam360/resources/images/flashOn.jpg b/applications/cam360/resources/images/flashOn.jpg new file mode 100644 index 0000000..e5ed03f Binary files /dev/null and b/applications/cam360/resources/images/flashOn.jpg differ diff --git a/applications/cam360/resources/images/opacityScale.png b/applications/cam360/resources/images/opacityScale.png new file mode 100644 index 0000000..1630b70 Binary files /dev/null and b/applications/cam360/resources/images/opacityScale.png differ diff --git a/applications/cam360/resources/marzipano/LICENSE.txt b/applications/cam360/resources/marzipano/LICENSE.txt deleted file mode 100644 index d645695..0000000 --- a/applications/cam360/resources/marzipano/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/applications/cam360/resources/marzipano/marzipano.js b/applications/cam360/resources/marzipano/marzipano.js deleted file mode 100644 index 3e84187..0000000 --- a/applications/cam360/resources/marzipano/marzipano.js +++ /dev/null @@ -1,16 +0,0 @@ -// Marzipano - a 360° media viewer for the modern web (v0.10.2) -// -// Copyright 2016 Google Inc. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -!function(t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).Marzipano=t()}(function(){var Vt;return function r(n,o,s){function a(e,t){if(!o[e]){if(!n[e]){var i="function"==typeof require&&require;if(!t&&i)return i(e,!0);if(h)return h(e,!0);throw(i=new Error("Cannot find module '"+e+"'")).code="MODULE_NOT_FOUND",i}i=o[e]={exports:{}},n[e][0].call(i.exports,function(t){return a(n[e][1][t]||t)},i,i.exports,r,n,o,s)}return o[e].exports}for(var h="function"==typeof require&&require,t=0;t - The 'Position' mode: Position then click to capture. Ideal to generate an ambient light texture for any scene. This includes a 360° media viewer.", + "description": "This camera takes spherical snapshots. It has two capture modes: - The 'Throw' mode: Rez the cam, grab it, then toss it high in the sky to capture an incredible view of of any event. - The 'Position' mode: Position then click to capture. Ideal to generate an ambient light texture for any scene. This includes a 360° viewer.", "jsfile": "cam360/cam360.js", "icon": "cam360/resources/images/icons/cam360-i.svg", "caption": "CAM360" | |||||||||||||