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/assignment-client/src/AssignmentClient.cpp b/assignment-client/src/AssignmentClient.cpp index eb0ffefe47..0869329d68 100644 --- a/assignment-client/src/AssignmentClient.cpp +++ b/assignment-client/src/AssignmentClient.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include "AssignmentFactory.h" #include "AssignmentDynamicFactory.h" @@ -66,6 +67,7 @@ AssignmentClient::AssignmentClient(Assignment::Type requestAssignmentType, QStri DependencyManager::registerInheritance(); auto dynamicFactory = DependencyManager::set(); DependencyManager::set(); + DependencyManager::set(); // setup a thread for the NodeList and its PacketReceiver QThread* nodeThread = new QThread(this); diff --git a/assignment-client/src/assets/SendAssetTask.cpp b/assignment-client/src/assets/SendAssetTask.cpp index ca8733d660..eab88e0d46 100644 --- a/assignment-client/src/assets/SendAssetTask.cpp +++ b/assignment-client/src/assets/SendAssetTask.cpp @@ -11,6 +11,8 @@ #include "SendAssetTask.h" +#include + #include #include @@ -21,6 +23,7 @@ #include #include "AssetUtils.h" +#include "ByteRange.h" #include "ClientServerUtils.h" SendAssetTask::SendAssetTask(QSharedPointer message, const SharedNodePointer& sendToNode, const QDir& resourcesDir) : @@ -34,20 +37,21 @@ SendAssetTask::SendAssetTask(QSharedPointer message, const Shar void SendAssetTask::run() { MessageID messageID; - DataOffset start, end; - + ByteRange byteRange; + _message->readPrimitive(&messageID); QByteArray assetHash = _message->read(SHA256_HASH_LENGTH); // `start` and `end` indicate the range of data to retrieve for the asset identified by `assetHash`. // `start` is inclusive, `end` is exclusive. Requesting `start` = 1, `end` = 10 will retrieve 9 bytes of data, // starting at index 1. - _message->readPrimitive(&start); - _message->readPrimitive(&end); + _message->readPrimitive(&byteRange.fromInclusive); + _message->readPrimitive(&byteRange.toExclusive); QString hexHash = assetHash.toHex(); - qDebug() << "Received a request for the file (" << messageID << "): " << hexHash << " from " << start << " to " << end; + qDebug() << "Received a request for the file (" << messageID << "): " << hexHash << " from " + << byteRange.fromInclusive << " to " << byteRange.toExclusive; qDebug() << "Starting task to send asset: " << hexHash << " for messageID " << messageID; auto replyPacketList = NLPacketList::create(PacketType::AssetGetReply, QByteArray(), true, true); @@ -56,7 +60,7 @@ void SendAssetTask::run() { replyPacketList->writePrimitive(messageID); - if (end <= start) { + if (!byteRange.isValid()) { replyPacketList->writePrimitive(AssetServerError::InvalidByteRange); } else { QString filePath = _resourcesDir.filePath(QString(hexHash)); @@ -64,15 +68,40 @@ void SendAssetTask::run() { QFile file { filePath }; if (file.open(QIODevice::ReadOnly)) { - if (file.size() < end) { + + // first fixup the range based on the now known file size + byteRange.fixupRange(file.size()); + + // check if we're being asked to read data that we just don't have + // because of the file size + if (file.size() < byteRange.fromInclusive || file.size() < byteRange.toExclusive) { replyPacketList->writePrimitive(AssetServerError::InvalidByteRange); - qCDebug(networking) << "Bad byte range: " << hexHash << " " << start << ":" << end; + qCDebug(networking) << "Bad byte range: " << hexHash << " " + << byteRange.fromInclusive << ":" << byteRange.toExclusive; } else { - auto size = end - start; - file.seek(start); - replyPacketList->writePrimitive(AssetServerError::NoError); - replyPacketList->writePrimitive(size); - replyPacketList->write(file.read(size)); + // we have a valid byte range, handle it and send the asset + auto size = byteRange.size(); + + if (byteRange.fromInclusive >= 0) { + + // this range is positive, meaning we just need to seek into the file and then read from there + file.seek(byteRange.fromInclusive); + replyPacketList->writePrimitive(AssetServerError::NoError); + replyPacketList->writePrimitive(size); + replyPacketList->write(file.read(size)); + } else { + // this range is negative, at least the first part of the read will be back into the end of the file + + // seek to the part of the file where the negative range begins + file.seek(file.size() + byteRange.fromInclusive); + + replyPacketList->writePrimitive(AssetServerError::NoError); + replyPacketList->writePrimitive(size); + + // first write everything from the negative range to the end of the file + replyPacketList->write(file.read(size)); + } + qCDebug(networking) << "Sending asset: " << hexHash; } file.close(); diff --git a/assignment-client/src/scripts/EntityScriptServer.cpp b/assignment-client/src/scripts/EntityScriptServer.cpp index 954c25a342..270a22e17b 100644 --- a/assignment-client/src/scripts/EntityScriptServer.cpp +++ b/assignment-client/src/scripts/EntityScriptServer.cpp @@ -481,14 +481,14 @@ void EntityScriptServer::deletingEntity(const EntityItemID& entityID) { } } -void EntityScriptServer::entityServerScriptChanging(const EntityItemID& entityID, const bool reload) { +void EntityScriptServer::entityServerScriptChanging(const EntityItemID& entityID, bool reload) { if (_entityViewer.getTree() && !_shuttingDown) { _entitiesScriptEngine->unloadEntityScript(entityID, true); checkAndCallPreload(entityID, reload); } } -void EntityScriptServer::checkAndCallPreload(const EntityItemID& entityID, const bool reload) { +void EntityScriptServer::checkAndCallPreload(const EntityItemID& entityID, bool reload) { if (_entityViewer.getTree() && !_shuttingDown && _entitiesScriptEngine) { EntityItemPointer entity = _entityViewer.getTree()->findEntityByEntityItemID(entityID); diff --git a/assignment-client/src/scripts/EntityScriptServer.h b/assignment-client/src/scripts/EntityScriptServer.h index a468e62958..687641d6be 100644 --- a/assignment-client/src/scripts/EntityScriptServer.h +++ b/assignment-client/src/scripts/EntityScriptServer.h @@ -67,8 +67,8 @@ private: void addingEntity(const EntityItemID& entityID); void deletingEntity(const EntityItemID& entityID); - void entityServerScriptChanging(const EntityItemID& entityID, const bool reload); - void checkAndCallPreload(const EntityItemID& entityID, const bool reload = false); + void entityServerScriptChanging(const EntityItemID& entityID, bool reload); + void checkAndCallPreload(const EntityItemID& entityID, bool reload = false); void cleanupOldKilledListeners(); diff --git a/interface/resources/icons/loader-red-countdown-ring.gif b/interface/resources/icons/loader-red-countdown-ring.gif new file mode 100644 index 0000000000..eb15b9aedd Binary files /dev/null and b/interface/resources/icons/loader-red-countdown-ring.gif differ diff --git a/interface/resources/icons/tablet-icons/avatar-record-a.svg b/interface/resources/icons/tablet-icons/avatar-record-a.svg new file mode 100644 index 0000000000..7358bdb0db --- /dev/null +++ b/interface/resources/icons/tablet-icons/avatar-record-a.svg @@ -0,0 +1,109 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/interface/resources/icons/tablet-icons/avatar-record-i.svg b/interface/resources/icons/tablet-icons/avatar-record-i.svg new file mode 100644 index 0000000000..5e139a6497 --- /dev/null +++ b/interface/resources/icons/tablet-icons/avatar-record-i.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + +image/svg+xml \ 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 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/interface/resources/qml/desktop/Desktop.qml b/interface/resources/qml/desktop/Desktop.qml index d8aedf6666..42db16aa72 100644 --- a/interface/resources/qml/desktop/Desktop.qml +++ b/interface/resources/qml/desktop/Desktop.qml @@ -466,6 +466,11 @@ FocusScope { return fileDialogBuilder.createObject(desktop, properties); } + Component { id: assetDialogBuilder; AssetDialog { } } + function assetDialog(properties) { + return assetDialogBuilder.createObject(desktop, properties); + } + function unfocusWindows() { // First find the active focus item, and unfocus it, all the way // up the parent chain to the window diff --git a/interface/resources/qml/dialogs/AssetDialog.qml b/interface/resources/qml/dialogs/AssetDialog.qml new file mode 100644 index 0000000000..8d19d38efb --- /dev/null +++ b/interface/resources/qml/dialogs/AssetDialog.qml @@ -0,0 +1,58 @@ +// +// AssetDialog.qml +// +// Created by David Rowe on 18 Apr 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 +// + +import QtQuick 2.5 +import QtQuick.Controls 1.4 +import Qt.labs.settings 1.0 + +import "../styles-uit" +import "../windows" + +import "assetDialog" + +ModalWindow { + id: root + resizable: true + implicitWidth: 480 + implicitHeight: 360 + + minSize: Qt.vector2d(360, 240) + draggable: true + + Settings { + category: "AssetDialog" + property alias width: root.width + property alias height: root.height + property alias x: root.x + property alias y: root.y + } + + // Set from OffscreenUi::assetDialog(). + property alias caption: root.title + property alias dir: assetDialogContent.dir + property alias filter: assetDialogContent.filter + property alias options: assetDialogContent.options + + // Dialog results. + signal selectedAsset(var asset); + signal canceled(); + + property int titleWidth: 0 // For ModalFrame. + + HifiConstants { id: hifi } + + AssetDialogContent { + id: assetDialogContent + + width: pane.width + height: pane.height + anchors.margins: 0 + } +} diff --git a/interface/resources/qml/dialogs/TabletAssetDialog.qml b/interface/resources/qml/dialogs/TabletAssetDialog.qml new file mode 100644 index 0000000000..016deec094 --- /dev/null +++ b/interface/resources/qml/dialogs/TabletAssetDialog.qml @@ -0,0 +1,53 @@ +// +// TabletAssetDialog.qml +// +// Created by David Rowe on 18 Apr 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 +// + +import QtQuick 2.5 +import QtQuick.Controls 1.4 + +import "../styles-uit" +import "../windows" + +import "assetDialog" + +TabletModalWindow { + id: root + anchors.fill: parent + width: parent.width + height: parent.height + + // Set from OffscreenUi::assetDialog(). + property alias caption: root.title + property alias dir: assetDialogContent.dir + property alias filter: assetDialogContent.filter + property alias options: assetDialogContent.options + + // Dialog results. + signal selectedAsset(var asset); + signal canceled(); + + property int titleWidth: 0 // For TabletModalFrame. + + TabletModalFrame { + id: frame + anchors.fill: parent + + AssetDialogContent { + id: assetDialogContent + singleClickNavigate: true + width: parent.width - 12 + height: parent.height - frame.frameMarginTop - 12 + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: parent.height - height - 6 + } + } + } +} diff --git a/interface/resources/qml/dialogs/assetDialog/AssetDialogContent.qml b/interface/resources/qml/dialogs/assetDialog/AssetDialogContent.qml new file mode 100644 index 0000000000..8c0501e3b4 --- /dev/null +++ b/interface/resources/qml/dialogs/assetDialog/AssetDialogContent.qml @@ -0,0 +1,536 @@ +// +// AssetDialogContent.qml +// +// Created by David Rowe on 19 Apr 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 +// + +import QtQuick 2.5 +import QtQuick.Controls 1.4 + +import "../../controls-uit" +import "../../styles-uit" + +import "../fileDialog" + +Item { + // Set from OffscreenUi::assetDialog() + property alias dir: assetTableModel.folder + property alias filter: selectionType.filtersString // FIXME: Currently only supports simple filters, "*.xxx". + property int options // Not used. + + property bool selectDirectory: false + + // Not implemented. + //property bool saveDialog: false; + //property bool multiSelect: false; + + property bool singleClickNavigate: false + + HifiConstants { id: hifi } + + Component.onCompleted: { + homeButton.destination = dir; + + if (selectDirectory) { + d.currentSelectionIsFolder = true; + d.currentSelectionPath = assetTableModel.folder; + } + + assetTableView.forceActiveFocus(); + } + + Item { + id: assetDialogItem + anchors.fill: parent + clip: true + + MouseArea { + // Clear selection when click on internal unused area. + anchors.fill: parent + drag.target: root + onClicked: { + d.clearSelection(); + frame.forceActiveFocus(); + assetTableView.forceActiveFocus(); + } + } + + Row { + id: navControls + anchors { + top: parent.top + topMargin: hifi.dimensions.contentMargin.y + left: parent.left + } + spacing: hifi.dimensions.contentSpacing.x + + GlyphButton { + id: upButton + glyph: hifi.glyphs.levelUp + width: height + size: 30 + enabled: assetTableModel.parentFolder !== "" + onClicked: d.navigateUp(); + } + + GlyphButton { + id: homeButton + property string destination: "" + glyph: hifi.glyphs.home + size: 28 + width: height + enabled: destination !== "" + //onClicked: d.navigateHome(); + onClicked: assetTableModel.folder = destination; + } + } + + ComboBox { + id: pathSelector + anchors { + top: parent.top + topMargin: hifi.dimensions.contentMargin.y + left: navControls.right + leftMargin: hifi.dimensions.contentSpacing.x + right: parent.right + } + z: 10 + + property string lastValidFolder: assetTableModel.folder + + function calculatePathChoices(folder) { + var folders = folder.split("/"), + choices = [], + i, length; + + if (folders[folders.length - 1] === "") { + folders.pop(); + } + + choices.push(folders[0]); + + for (i = 1, length = folders.length; i < length; i++) { + choices.push(choices[i - 1] + "/" + folders[i]); + } + + if (folders[0] === "") { + choices[0] = "/"; + } + + choices.reverse(); + + if (choices.length > 0) { + pathSelector.model = choices; + } + } + + onLastValidFolderChanged: { + var folder = lastValidFolder; + calculatePathChoices(folder); + } + + onCurrentTextChanged: { + var folder = currentText; + + if (folder !== "/") { + folder += "/"; + } + + if (folder !== assetTableModel.folder) { + if (root.selectDirectory) { + currentSelection.text = currentText; + d.currentSelectionPath = currentText; + } + assetTableModel.folder = folder; + assetTableView.forceActiveFocus(); + } + } + } + + QtObject { + id: d + + property string currentSelectionPath + property bool currentSelectionIsFolder + property var tableViewConnection: Connections { target: assetTableView; onCurrentRowChanged: d.update(); } + + function update() { + var row = assetTableView.currentRow; + + if (row === -1) { + if (!root.selectDirectory) { + currentSelection.text = ""; + currentSelectionIsFolder = false; + } + return; + } + + var rowInfo = assetTableModel.get(row); + currentSelectionPath = rowInfo.filePath; + currentSelectionIsFolder = rowInfo.fileIsDir; + if (root.selectDirectory || !currentSelectionIsFolder) { + currentSelection.text = currentSelectionPath; + } else { + currentSelection.text = ""; + } + } + + function navigateUp() { + if (assetTableModel.parentFolder !== "") { + assetTableModel.folder = assetTableModel.parentFolder; + return true; + } + return false; + } + + function navigateHome() { + assetTableModel.folder = homeButton.destination; + return true; + } + + function clearSelection() { + assetTableView.selection.clear(); + assetTableView.currentRow = -1; + update(); + } + } + + ListModel { + id: assetTableModel + + property string folder + property string parentFolder: "" + readonly property string rootFolder: "/" + + onFolderChanged: { + parentFolder = calculateParentFolder(); + update(); + } + + function calculateParentFolder() { + if (folder !== "/") { + return folder.slice(0, folder.slice(0, -1).lastIndexOf("/") + 1); + } + return ""; + } + + function isFolder(row) { + if (row === -1) { + return false; + } + return get(row).fileIsDir; + } + + function onGetAllMappings(error, map) { + var mappings, + fileTypeFilter, + index, + path, + fileName, + fileType, + fileIsDir, + isValid, + subDirectory, + subDirectories = [], + fileNameSort, + rows = 0, + lower, + middle, + upper, + i, + length; + + clear(); + + if (error === "") { + mappings = Object.keys(map); + fileTypeFilter = filter.replace("*", "").toLowerCase(); + + for (i = 0, length = mappings.length; i < length; i++) { + index = mappings[i].lastIndexOf("/"); + + path = mappings[i].slice(0, mappings[i].lastIndexOf("/") + 1); + fileName = mappings[i].slice(path.length); + fileType = fileName.slice(fileName.lastIndexOf(".")); + fileIsDir = false; + isValid = false; + + if (fileType.toLowerCase() === fileTypeFilter) { + if (path === folder) { + isValid = !selectDirectory; + } else if (path.length > folder.length) { + subDirectory = path.slice(folder.length); + index = subDirectory.indexOf("/"); + if (index === subDirectory.lastIndexOf("/")) { + fileName = subDirectory.slice(0, index); + if (subDirectories.indexOf(fileName) === -1) { + fileIsDir = true; + isValid = true; + subDirectories.push(fileName); + } + } + } + } + + if (isValid) { + fileNameSort = (fileIsDir ? "*" : "") + fileName.toLowerCase(); + + lower = 0; + upper = rows; + while (lower < upper) { + middle = Math.floor((lower + upper) / 2); + var lessThan; + if (fileNameSort < get(middle)["fileNameSort"]) { + lessThan = true; + upper = middle; + } else { + lessThan = false; + lower = middle + 1; + } + } + + insert(lower, { + fileName: fileName, + filePath: path + (fileIsDir ? "" : fileName), + fileIsDir: fileIsDir, + fileNameSort: fileNameSort + }); + + rows++; + } + } + + } else { + console.log("Error getting mappings from Asset Server"); + } + } + + function update() { + d.clearSelection(); + clear(); + Assets.getAllMappings(onGetAllMappings); + } + } + + Table { + id: assetTableView + colorScheme: hifi.colorSchemes.light + anchors { + top: navControls.bottom + topMargin: hifi.dimensions.contentSpacing.y + left: parent.left + right: parent.right + bottom: currentSelection.top + bottomMargin: hifi.dimensions.contentSpacing.y + currentSelection.controlHeight - currentSelection.height + } + + model: assetTableModel + + focus: true + + onClicked: { + if (singleClickNavigate) { + navigateToRow(row); + } + } + + onDoubleClicked: navigateToRow(row); + Keys.onReturnPressed: navigateToCurrentRow(); + Keys.onEnterPressed: navigateToCurrentRow(); + + itemDelegate: Item { + clip: true + + FontLoader { id: firaSansSemiBold; source: "../../../fonts/FiraSans-SemiBold.ttf"; } + FontLoader { id: firaSansRegular; source: "../../../fonts/FiraSans-Regular.ttf"; } + + FiraSansSemiBold { + text: styleData.value + elide: styleData.elideMode + anchors { + left: parent.left + leftMargin: hifi.dimensions.tablePadding + right: parent.right + rightMargin: hifi.dimensions.tablePadding + verticalCenter: parent.verticalCenter + } + size: hifi.fontSizes.tableText + color: hifi.colors.baseGrayHighlight + font.family: (styleData.row !== -1 && assetTableView.model.get(styleData.row).fileIsDir) + ? firaSansSemiBold.name : firaSansRegular.name + } + } + + TableViewColumn { + id: fileNameColumn + role: "fileName" + title: "Name" + width: assetTableView.width + movable: false + resizable: false + } + + function navigateToRow(row) { + currentRow = row; + navigateToCurrentRow(); + } + + function navigateToCurrentRow() { + if (model.isFolder(currentRow)) { + model.folder = model.get(currentRow).filePath; + } else { + okAction.trigger(); + } + } + + Timer { + id: prefixClearTimer + interval: 1000 + repeat: false + running: false + onTriggered: assetTableView.prefix = ""; + } + + property string prefix: "" + + function addToPrefix(event) { + if (!event.text || event.text === "") { + return false; + } + var newPrefix = prefix + event.text.toLowerCase(); + var matchedIndex = -1; + for (var i = 0; i < model.count; ++i) { + var name = model.get(i).fileName.toLowerCase(); + if (0 === name.indexOf(newPrefix)) { + matchedIndex = i; + break; + } + } + + if (matchedIndex !== -1) { + assetTableView.selection.clear(); + assetTableView.selection.select(matchedIndex); + assetTableView.currentRow = matchedIndex; + assetTableView.prefix = newPrefix; + } + prefixClearTimer.restart(); + return true; + } + + Keys.onPressed: { + switch (event.key) { + case Qt.Key_Backspace: + case Qt.Key_Tab: + case Qt.Key_Backtab: + event.accepted = false; + break; + + default: + if (addToPrefix(event)) { + event.accepted = true + } else { + event.accepted = false; + } + break; + } + } + } + + TextField { + id: currentSelection + label: selectDirectory ? "Directory:" : "File name:" + anchors { + left: parent.left + right: selectionType.visible ? selectionType.left: parent.right + rightMargin: selectionType.visible ? hifi.dimensions.contentSpacing.x : 0 + bottom: buttonRow.top + bottomMargin: hifi.dimensions.contentSpacing.y + } + readOnly: true + activeFocusOnTab: !readOnly + onActiveFocusChanged: if (activeFocus) { selectAll(); } + onAccepted: okAction.trigger(); + } + + FileTypeSelection { + id: selectionType + anchors { + top: currentSelection.top + left: buttonRow.left + right: parent.right + } + visible: !selectDirectory && filtersCount > 1 + KeyNavigation.left: assetTableView + KeyNavigation.right: openButton + } + + Action { + id: okAction + text: currentSelection.text && root.selectDirectory && assetTableView.currentRow === -1 ? "Choose" : "Open" + enabled: currentSelection.text || !root.selectDirectory && d.currentSelectionIsFolder ? true : false + onTriggered: { + if (!root.selectDirectory && !d.currentSelectionIsFolder + || root.selectDirectory && assetTableView.currentRow === -1) { + selectedAsset(d.currentSelectionPath); + root.destroy(); + } else { + assetTableView.navigateToCurrentRow(); + } + } + } + + Action { + id: cancelAction + text: "Cancel" + onTriggered: { + canceled(); + root.destroy(); + } + } + + Row { + id: buttonRow + anchors { + right: parent.right + bottom: parent.bottom + } + spacing: hifi.dimensions.contentSpacing.y + + Button { + id: openButton + color: hifi.buttons.blue + action: okAction + Keys.onReturnPressed: okAction.trigger() + KeyNavigation.up: selectionType + KeyNavigation.left: selectionType + KeyNavigation.right: cancelButton + } + + Button { + id: cancelButton + action: cancelAction + KeyNavigation.up: selectionType + KeyNavigation.left: openButton + KeyNavigation.right: assetTableView.contentItem + Keys.onReturnPressed: { canceled(); root.enabled = false } + } + } + } + + Keys.onPressed: { + switch (event.key) { + case Qt.Key_Backspace: + event.accepted = d.navigateUp(); + break; + + case Qt.Key_Home: + event.accepted = d.navigateHome(); + break; + + } + } +} diff --git a/interface/resources/qml/hifi/tablet/Edit.qml b/interface/resources/qml/hifi/tablet/Edit.qml index 4abe698fbc..ea31eb26d8 100644 --- a/interface/resources/qml/hifi/tablet/Edit.qml +++ b/interface/resources/qml/hifi/tablet/Edit.qml @@ -1,19 +1,11 @@ import QtQuick 2.5 import QtQuick.Controls 1.4 -import QtWebEngine 1.1 -import QtWebChannel 1.0 -import QtQuick.Controls.Styles 1.4 -import "../../controls" -import "../toolbars" -import HFWebEngineProfile 1.0 -import QtGraphicalEffects 1.0 -import "../../controls-uit" as HifiControls import "../../styles-uit" StackView { id: editRoot objectName: "stack" - initialItem: editBasePage + initialItem: Qt.resolvedUrl('EditTabView.qml') property var eventBridge; signal sendToScript(var message); @@ -30,270 +22,10 @@ StackView { editRoot.pop(); } - - Component { - id: editBasePage - TabView { - id: editTabView - // anchors.fill: parent - height: 60 - - Tab { - title: "CREATE" - active: true - enabled: true - property string originalUrl: "" - - Rectangle { - color: "#404040" - - Text { - color: "#ffffff" - text: "Choose an Entity Type to Create:" - font.pixelSize: 14 - font.bold: true - anchors.top: parent.top - anchors.topMargin: 28 - anchors.left: parent.left - anchors.leftMargin: 28 - } - - Flow { - id: createEntitiesFlow - spacing: 35 - anchors.right: parent.right - anchors.rightMargin: 55 - anchors.left: parent.left - anchors.leftMargin: 55 - anchors.top: parent.top - anchors.topMargin: 70 - - - NewEntityButton { - icon: "icons/create-icons/94-model-01.svg" - text: "MODEL" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newModelButton" } - }); - editTabView.currentIndex = 2 - } - } - - NewEntityButton { - icon: "icons/create-icons/21-cube-01.svg" - text: "CUBE" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newCubeButton" } - }); - editTabView.currentIndex = 2 - } - } - - NewEntityButton { - icon: "icons/create-icons/22-sphere-01.svg" - text: "SPHERE" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newSphereButton" } - }); - editTabView.currentIndex = 2 - } - } - - NewEntityButton { - icon: "icons/create-icons/24-light-01.svg" - text: "LIGHT" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newLightButton" } - }); - editTabView.currentIndex = 2 - } - } - - NewEntityButton { - icon: "icons/create-icons/20-text-01.svg" - text: "TEXT" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newTextButton" } - }); - editTabView.currentIndex = 2 - } - } - - NewEntityButton { - icon: "icons/create-icons/25-web-1-01.svg" - text: "WEB" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newWebButton" } - }); - editTabView.currentIndex = 2 - } - } - - NewEntityButton { - icon: "icons/create-icons/23-zone-01.svg" - text: "ZONE" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newZoneButton" } - }); - editTabView.currentIndex = 2 - } - } - - NewEntityButton { - icon: "icons/create-icons/90-particles-01.svg" - text: "PARTICLE" - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "newParticleButton" } - }); - editTabView.currentIndex = 2 - } - } - } - - HifiControls.Button { - id: assetServerButton - text: "Open This Domain's Asset Server" - color: hifi.buttons.black - colorScheme: hifi.colorSchemes.dark - anchors.right: parent.right - anchors.rightMargin: 55 - anchors.left: parent.left - anchors.leftMargin: 55 - anchors.top: createEntitiesFlow.bottom - anchors.topMargin: 35 - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "openAssetBrowserButton" } - }); - } - } - - HifiControls.Button { - text: "Import Entities (.json)" - color: hifi.buttons.black - colorScheme: hifi.colorSchemes.dark - anchors.right: parent.right - anchors.rightMargin: 55 - anchors.left: parent.left - anchors.leftMargin: 55 - anchors.top: assetServerButton.bottom - anchors.topMargin: 20 - onClicked: { - editRoot.sendToScript({ - method: "newEntityButtonClicked", params: { buttonName: "importEntitiesButton" } - }); - } - } - } - } - - Tab { - title: "LIST" - active: true - enabled: true - property string originalUrl: "" - - WebView { - id: entityListToolWebView - url: "../../../../../scripts/system/html/entityList.html" - eventBridge: editRoot.eventBridge - anchors.fill: parent - enabled: true - } - } - - Tab { - title: "PROPERTIES" - active: true - enabled: true - property string originalUrl: "" - - WebView { - id: entityPropertiesWebView - url: "../../../../../scripts/system/html/entityProperties.html" - eventBridge: editRoot.eventBridge - anchors.fill: parent - enabled: true - } - } - - Tab { - title: "GRID" - active: true - enabled: true - property string originalUrl: "" - - WebView { - id: gridControlsWebView - url: "../../../../../scripts/system/html/gridControls.html" - eventBridge: editRoot.eventBridge - anchors.fill: parent - enabled: true - } - } - - Tab { - title: "P" - active: true - enabled: true - property string originalUrl: "" - - WebView { - id: particleExplorerWebView - url: "../../../../../scripts/system/particle_explorer/particleExplorer.html" - eventBridge: editRoot.eventBridge - anchors.fill: parent - enabled: true - } - } - - - style: TabViewStyle { - frameOverlap: 1 - tab: Rectangle { - color: styleData.selected ? "#404040" :"black" - implicitWidth: text.width + 42 - implicitHeight: 40 - Text { - id: text - anchors.centerIn: parent - text: styleData.title - font.pixelSize: 16 - font.bold: true - color: styleData.selected ? "white" : "white" - property string glyphtext: "" - HiFiGlyphs { - anchors.centerIn: parent - size: 30 - color: "#ffffff" - text: text.glyphtext - } - Component.onCompleted: if (styleData.title == "P") { - text.text = " "; - text.glyphtext = "\ue004"; - } - } - } - tabBar: Rectangle { - color: "black" - anchors.right: parent.right - anchors.rightMargin: 0 - anchors.left: parent.left - anchors.leftMargin: 0 - anchors.bottom: parent.bottom - anchors.bottomMargin: 0 - anchors.top: parent.top - anchors.topMargin: 0 - } - } - } + // Passes script messages to the item on the top of the stack + function fromScript(message) { + var currentItem = editRoot.currentItem; + if (currentItem && currentItem.fromScript) + currentItem.fromScript(message); } } diff --git a/interface/resources/qml/hifi/tablet/EditTabView.qml b/interface/resources/qml/hifi/tablet/EditTabView.qml new file mode 100644 index 0000000000..35f2b82f0f --- /dev/null +++ b/interface/resources/qml/hifi/tablet/EditTabView.qml @@ -0,0 +1,318 @@ +import QtQuick 2.5 +import QtQuick.Controls 1.4 +import QtWebEngine 1.1 +import QtWebChannel 1.0 +import QtQuick.Controls.Styles 1.4 +import "../../controls" +import "../toolbars" +import HFWebEngineProfile 1.0 +import QtGraphicalEffects 1.0 +import "../../controls-uit" as HifiControls +import "../../styles-uit" + + +TabView { + id: editTabView + // anchors.fill: parent + height: 60 + + Tab { + title: "CREATE" + active: true + enabled: true + property string originalUrl: "" + + Rectangle { + color: "#404040" + + Text { + color: "#ffffff" + text: "Choose an Entity Type to Create:" + font.pixelSize: 14 + font.bold: true + anchors.top: parent.top + anchors.topMargin: 28 + anchors.left: parent.left + anchors.leftMargin: 28 + } + + Flow { + id: createEntitiesFlow + spacing: 35 + anchors.right: parent.right + anchors.rightMargin: 55 + anchors.left: parent.left + anchors.leftMargin: 55 + anchors.top: parent.top + anchors.topMargin: 70 + + + NewEntityButton { + icon: "icons/create-icons/94-model-01.svg" + text: "MODEL" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newModelButton" } + }); + editTabView.currentIndex = 2 + } + } + + NewEntityButton { + icon: "icons/create-icons/21-cube-01.svg" + text: "CUBE" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newCubeButton" } + }); + editTabView.currentIndex = 2 + } + } + + NewEntityButton { + icon: "icons/create-icons/22-sphere-01.svg" + text: "SPHERE" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newSphereButton" } + }); + editTabView.currentIndex = 2 + } + } + + NewEntityButton { + icon: "icons/create-icons/24-light-01.svg" + text: "LIGHT" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newLightButton" } + }); + editTabView.currentIndex = 2 + } + } + + NewEntityButton { + icon: "icons/create-icons/20-text-01.svg" + text: "TEXT" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newTextButton" } + }); + editTabView.currentIndex = 2 + } + } + + NewEntityButton { + icon: "icons/create-icons/25-web-1-01.svg" + text: "WEB" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newWebButton" } + }); + editTabView.currentIndex = 2 + } + } + + NewEntityButton { + icon: "icons/create-icons/23-zone-01.svg" + text: "ZONE" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newZoneButton" } + }); + editTabView.currentIndex = 2 + } + } + + NewEntityButton { + icon: "icons/create-icons/90-particles-01.svg" + text: "PARTICLE" + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "newParticleButton" } + }); + editTabView.currentIndex = 4 + } + } + } + + HifiControls.Button { + id: assetServerButton + text: "Open This Domain's Asset Server" + color: hifi.buttons.black + colorScheme: hifi.colorSchemes.dark + anchors.right: parent.right + anchors.rightMargin: 55 + anchors.left: parent.left + anchors.leftMargin: 55 + anchors.top: createEntitiesFlow.bottom + anchors.topMargin: 35 + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "openAssetBrowserButton" } + }); + } + } + + HifiControls.Button { + text: "Import Entities (.json)" + color: hifi.buttons.black + colorScheme: hifi.colorSchemes.dark + anchors.right: parent.right + anchors.rightMargin: 55 + anchors.left: parent.left + anchors.leftMargin: 55 + anchors.top: assetServerButton.bottom + anchors.topMargin: 20 + onClicked: { + editRoot.sendToScript({ + method: "newEntityButtonClicked", params: { buttonName: "importEntitiesButton" } + }); + } + } + } + } + + Tab { + title: "LIST" + active: true + enabled: true + property string originalUrl: "" + + WebView { + id: entityListToolWebView + url: "../../../../../scripts/system/html/entityList.html" + eventBridge: editRoot.eventBridge + anchors.fill: parent + enabled: true + } + } + + Tab { + title: "PROPERTIES" + active: true + enabled: true + property string originalUrl: "" + + WebView { + id: entityPropertiesWebView + url: "../../../../../scripts/system/html/entityProperties.html" + eventBridge: editRoot.eventBridge + anchors.fill: parent + enabled: true + } + } + + Tab { + title: "GRID" + active: true + enabled: true + property string originalUrl: "" + + WebView { + id: gridControlsWebView + url: "../../../../../scripts/system/html/gridControls.html" + eventBridge: editRoot.eventBridge + anchors.fill: parent + enabled: true + } + } + + Tab { + title: "P" + active: true + enabled: true + property string originalUrl: "" + + WebView { + id: particleExplorerWebView + url: "../../../../../scripts/system/particle_explorer/particleExplorer.html" + eventBridge: editRoot.eventBridge + anchors.fill: parent + enabled: true + } + } + + + style: TabViewStyle { + frameOverlap: 1 + tab: Rectangle { + color: styleData.selected ? "#404040" :"black" + implicitWidth: text.width + 42 + implicitHeight: 40 + Text { + id: text + anchors.centerIn: parent + text: styleData.title + font.pixelSize: 16 + font.bold: true + color: styleData.selected ? "white" : "white" + property string glyphtext: "" + HiFiGlyphs { + anchors.centerIn: parent + size: 30 + color: "#ffffff" + text: text.glyphtext + } + Component.onCompleted: if (styleData.title == "P") { + text.text = " "; + text.glyphtext = "\ue004"; + } + } + } + tabBar: Rectangle { + color: "black" + anchors.right: parent.right + anchors.rightMargin: 0 + anchors.left: parent.left + anchors.leftMargin: 0 + anchors.bottom: parent.bottom + anchors.bottomMargin: 0 + anchors.top: parent.top + anchors.topMargin: 0 + } + } + + function fromScript(message) { + switch (message.method) { + case 'selectTab': + selectTab(message.params.id); + break; + default: + console.warn('Unrecognized message:', JSON.stringify(message)); + } + } + + // Changes the current tab based on tab index or title as input + function selectTab(id) { + if (typeof id === 'number') { + if (id >= 0 && id <= 4) { + editTabView.currentIndex = id; + } else { + console.warn('Attempt to switch to invalid tab:', id); + } + } else if (typeof id === 'string'){ + switch (id.toLowerCase()) { + case 'create': + editTabView.currentIndex = 0; + break; + case 'list': + editTabView.currentIndex = 1; + break; + case 'properties': + editTabView.currentIndex = 2; + break; + case 'grid': + editTabView.currentIndex = 3; + break; + case 'particle': + editTabView.currentIndex = 4; + break; + default: + console.warn('Attempt to switch to invalid tab:', id); + } + } else { + console.warn('Attempt to switch tabs with invalid input:', JSON.stringify(id)); + } + } +} \ No newline at end of file diff --git a/interface/resources/qml/hifi/tablet/InputRecorder.qml b/interface/resources/qml/hifi/tablet/InputRecorder.qml new file mode 100644 index 0000000000..76b122d07d --- /dev/null +++ b/interface/resources/qml/hifi/tablet/InputRecorder.qml @@ -0,0 +1,170 @@ +// +// Created by Dante Ruiz 2017/04/17 +// 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 +// + +import QtQuick 2.5 +import Hifi 1.0 +import QtQuick.Controls 1.4 +import QtQuick.Dialogs 1.2 as OriginalDialogs + +import "../../styles-uit" +import "../../controls-uit" as HifiControls +import "../../windows" +import "../../dialogs" + +Rectangle { + id: inputRecorder + property var eventBridge; + HifiConstants { id: hifi } + signal sendToScript(var message); + color: hifi.colors.baseGray; + property string path: "" + property string dir: "" + property var dialog: null; + property bool recording: false; + + Component { id: fileDialog; TabletFileDialog { } } + Row { + id: topButtons + width: parent.width + height: 40 + spacing: 40 + anchors { + left: parent.left + right: parent.right + top: parent.top + topMargin: 10 + } + + HifiControls.Button { + id: start + text: "Start Recoring" + color: hifi.buttons.black + enabled: true + onClicked: { + if (inputRecorder.recording) { + sendToScript({method: "Stop"}); + inputRecorder.recording = false; + start.text = "Start Recording"; + selectedFile.text = "Current recording is not saved"; + } else { + sendToScript({method: "Start"}); + inputRecorder.recording = true; + start.text = "Stop Recording"; + } + } + } + + HifiControls.Button { + id: save + text: "Save Recording" + color: hifi.buttons.black + enabled: true + onClicked: { + sendToScript({method: "Save"}); + selectedFile.text = ""; + } + } + + HifiControls.Button { + id: playBack + anchors.right: browse.left + anchors.top: selectedFile.bottom + anchors.topMargin: 10 + + text: "Play Recording" + color: hifi.buttons.black + enabled: true + onClicked: { + sendToScript({method: "playback"}); + HMD.closeTablet(); + } + } + + } + + HifiControls.VerticalSpacer {} + + HifiControls.TextField { + id: selectedFile + anchors.left: parent.left + anchors.right: parent.right + anchors.top: topButtons.top + anchors.topMargin: 40 + + colorScheme: hifi.colorSchemes.dark + readOnly: true + + } + + + + HifiControls.Button { + id: browse + anchors.right: parent.right + anchors.top: selectedFile.bottom + anchors.topMargin: 10 + + text: "Load..." + color: hifi.buttons.black + enabled: true + onClicked: { + dialog = fileDialog.createObject(inputRecorder); + dialog.caption = "InputRecorder"; + console.log(dialog.dir); + dialog.dir = "file:///" + inputRecorder.dir; + dialog.selectedFile.connect(getFileSelected); + } + } + + Column { + id: notes + anchors.centerIn: parent; + spacing: 20 + + Text { + text: "All files are saved under the folder 'hifi-input-recording' in AppData directory"; + color: "white" + font.pointSize: 10 + } + + Text { + text: "To cancel a recording playback press Alt-B" + color: "white" + font.pointSize: 10 + } + } + + function getFileSelected(file) { + selectedFile.text = file; + inputRecorder.path = file; + sendToScript({method: "Load", params: {file: path }}); + } + + function fromScript(message) { + switch (message.method) { + case "update": + updateButtonStatus(message.params); + break; + case "path": + console.log(message.params); + inputRecorder.dir = message.params; + break; + } + } + + function updateButtonStatus(status) { + inputRecorder.recording = status; + + if (inputRecorder.recording) { + start.text = "Stop Recording"; + } else { + start.text = "Start Recording"; + } + } +} + diff --git a/interface/resources/qml/hifi/tablet/TabletAudioPreferences.qml b/interface/resources/qml/hifi/tablet/TabletAudioPreferences.qml index b21bc238ac..2046071e4c 100644 --- a/interface/resources/qml/hifi/tablet/TabletAudioPreferences.qml +++ b/interface/resources/qml/hifi/tablet/TabletAudioPreferences.qml @@ -23,7 +23,7 @@ StackView { signal sendToScript(var message); function pushSource(path) { - profileRoot.push(Qt.reslovedUrl(path)); + profileRoot.push(Qt.resolvedUrl(path)); } function popSource() { diff --git a/interface/resources/qml/hifi/tablet/TabletGeneralPreferences.qml b/interface/resources/qml/hifi/tablet/TabletGeneralPreferences.qml index d23daddd8d..85377aaeda 100644 --- a/interface/resources/qml/hifi/tablet/TabletGeneralPreferences.qml +++ b/interface/resources/qml/hifi/tablet/TabletGeneralPreferences.qml @@ -23,7 +23,7 @@ StackView { signal sendToScript(var message); function pushSource(path) { - profileRoot.push(Qt.reslovedUrl(path)); + profileRoot.push(Qt.resolvedUrl(path)); } function popSource() { diff --git a/interface/resources/qml/hifi/tablet/TabletGraphicsPreferences.qml b/interface/resources/qml/hifi/tablet/TabletGraphicsPreferences.qml index 67c466f991..95ee2c3a72 100644 --- a/interface/resources/qml/hifi/tablet/TabletGraphicsPreferences.qml +++ b/interface/resources/qml/hifi/tablet/TabletGraphicsPreferences.qml @@ -23,7 +23,7 @@ StackView { signal sendToScript(var message); function pushSource(path) { - profileRoot.push(Qt.reslovedUrl(path)); + profileRoot.push(Qt.resolvedUrl(path)); } function popSource() { diff --git a/interface/resources/qml/hifi/tablet/TabletLodPreferences.qml b/interface/resources/qml/hifi/tablet/TabletLodPreferences.qml index f61f6f8c4e..6f38fee8b9 100644 --- a/interface/resources/qml/hifi/tablet/TabletLodPreferences.qml +++ b/interface/resources/qml/hifi/tablet/TabletLodPreferences.qml @@ -23,7 +23,7 @@ StackView { signal sendToScript(var message); function pushSource(path) { - profileRoot.push(Qt.reslovedUrl(path)); + profileRoot.push(Qt.resolvedUrl(path)); } function popSource() { diff --git a/interface/resources/qml/hifi/tablet/TabletNetworkingPreferences.qml b/interface/resources/qml/hifi/tablet/TabletNetworkingPreferences.qml index db47c78c48..7184d91044 100644 --- a/interface/resources/qml/hifi/tablet/TabletNetworkingPreferences.qml +++ b/interface/resources/qml/hifi/tablet/TabletNetworkingPreferences.qml @@ -23,7 +23,7 @@ StackView { signal sendToScript(var message); function pushSource(path) { - profileRoot.push(Qt.reslovedUrl(path)); + profileRoot.push(Qt.resolvedUrl(path)); } function popSource() { diff --git a/interface/resources/qml/hifi/tablet/TabletRoot.qml b/interface/resources/qml/hifi/tablet/TabletRoot.qml index 446d4c91ff..31e6174563 100644 --- a/interface/resources/qml/hifi/tablet/TabletRoot.qml +++ b/interface/resources/qml/hifi/tablet/TabletRoot.qml @@ -44,6 +44,12 @@ Item { return openModal; } + Component { id: assetDialogBuilder; TabletAssetDialog { } } + function assetDialog(properties) { + openModal = assetDialogBuilder.createObject(tabletRoot, properties); + return openModal; + } + function setMenuProperties(rootMenu, subMenu) { tabletRoot.rootMenu = rootMenu; tabletRoot.subMenu = subMenu; diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index f51c3d2b46..44c2918f9d 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -78,6 +78,7 @@ #include #include #include +#include #include #include #include @@ -1465,46 +1466,53 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo const auto testScript = property(hifi::properties::TEST).toUrl(); scriptEngines->loadScript(testScript, false); } else { - // Get sandbox content set version, if available + enum HandControllerType { + Vive, + Oculus + }; + static const std::map MIN_CONTENT_VERSION = { + { Vive, 1 }, + { Oculus, 27 } + }; + + // Get sandbox content set version auto acDirPath = PathUtils::getAppDataPath() + "../../" + BuildInfo::MODIFIED_ORGANIZATION + "/assignment-client/"; auto contentVersionPath = acDirPath + "content-version.txt"; qCDebug(interfaceapp) << "Checking " << contentVersionPath << " for content version"; - auto contentVersion = 0; + int contentVersion = 0; QFile contentVersionFile(contentVersionPath); if (contentVersionFile.open(QIODevice::ReadOnly | QIODevice::Text)) { QString line = contentVersionFile.readAll(); - // toInt() returns 0 if the conversion fails, so we don't need to specifically check for failure - contentVersion = line.toInt(); + contentVersion = line.toInt(); // returns 0 if conversion fails } - qCDebug(interfaceapp) << "Server content version: " << contentVersion; - static const int MIN_VIVE_CONTENT_VERSION = 1; - static const int MIN_OCULUS_TOUCH_CONTENT_VERSION = 27; - - bool hasSufficientTutorialContent = false; + // Get controller availability bool hasHandControllers = false; - - // Only specific hand controllers are currently supported, so only send users to the tutorial - // if they have one of those hand controllers. + HandControllerType handControllerType = Vive; if (PluginUtils::isViveControllerAvailable()) { hasHandControllers = true; - hasSufficientTutorialContent = contentVersion >= MIN_VIVE_CONTENT_VERSION; + handControllerType = Vive; } else if (PluginUtils::isOculusTouchControllerAvailable()) { hasHandControllers = true; - hasSufficientTutorialContent = contentVersion >= MIN_OCULUS_TOUCH_CONTENT_VERSION; + handControllerType = Oculus; } + // Check tutorial content versioning + bool hasTutorialContent = contentVersion >= MIN_CONTENT_VERSION.at(handControllerType); + + // Check HMD use (may be technically available without being in use) + bool hasHMD = PluginUtils::isHMDAvailable(); + bool isUsingHMD = hasHMD && hasHandControllers && _displayPlugin->isHmd(); + + Setting::Handle tutorialComplete { "tutorialComplete", false }; Setting::Handle firstRun { Settings::firstRun, true }; - bool hasHMDAndHandControllers = PluginUtils::isHMDAvailable() && hasHandControllers; - Setting::Handle tutorialComplete { "tutorialComplete", false }; + bool isTutorialComplete = tutorialComplete.get(); + bool shouldGoToTutorial = isUsingHMD && hasTutorialContent && !isTutorialComplete; - bool shouldGoToTutorial = hasHMDAndHandControllers && hasSufficientTutorialContent && !tutorialComplete.get(); - - qCDebug(interfaceapp) << "Has HMD + Hand Controllers: " << hasHMDAndHandControllers << ", current plugin: " << _displayPlugin->getName(); - qCDebug(interfaceapp) << "Has sufficient tutorial content (" << contentVersion << ") : " << hasSufficientTutorialContent; - qCDebug(interfaceapp) << "Tutorial complete: " << tutorialComplete.get(); - qCDebug(interfaceapp) << "Should go to tutorial: " << shouldGoToTutorial; + qCDebug(interfaceapp) << "HMD:" << hasHMD << ", Hand Controllers: " << hasHandControllers << ", Using HMD: " << isUsingHMD; + qCDebug(interfaceapp) << "Tutorial version:" << contentVersion << ", sufficient:" << hasTutorialContent << + ", complete:" << isTutorialComplete << ", should go:" << shouldGoToTutorial; // when --url in command line, teleport to location const QString HIFI_URL_COMMAND_LINE_KEY = "--url"; @@ -1541,7 +1549,7 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo // If this is a first run we short-circuit the address passed in if (isFirstRun) { - if (hasHMDAndHandControllers) { + if (isUsingHMD) { if (sandboxIsRunning) { qCDebug(interfaceapp) << "Home sandbox appears to be running, going to Home."; DependencyManager::get()->goToLocalSandbox(); @@ -2746,6 +2754,9 @@ void Application::keyPressEvent(QKeyEvent* event) { if (isMeta) { auto offscreenUi = DependencyManager::get(); offscreenUi->load("Browser.qml"); + } else if (isOption) { + controller::InputRecorder* inputRecorder = controller::InputRecorder::getInstance(); + inputRecorder->stopPlayback(); } break; 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/scripting/WindowScriptingInterface.cpp b/interface/src/scripting/WindowScriptingInterface.cpp index 39c2f2e402..39cf99f349 100644 --- a/interface/src/scripting/WindowScriptingInterface.cpp +++ b/interface/src/scripting/WindowScriptingInterface.cpp @@ -28,6 +28,7 @@ static const QString DESKTOP_LOCATION = QStandardPaths::writableLocation(QStandardPaths::DesktopLocation); static const QString LAST_BROWSE_LOCATION_SETTING = "LastBrowseLocation"; +static const QString LAST_BROWSE_ASSETS_LOCATION_SETTING = "LastBrowseAssetsLocation"; QScriptValue CustomPromptResultToScriptValue(QScriptEngine* engine, const CustomPromptResult& result) { @@ -149,6 +150,15 @@ void WindowScriptingInterface::setPreviousBrowseLocation(const QString& location Setting::Handle(LAST_BROWSE_LOCATION_SETTING).set(location); } +QString WindowScriptingInterface::getPreviousBrowseAssetLocation() const { + QString ASSETS_ROOT_PATH = "/"; + return Setting::Handle(LAST_BROWSE_ASSETS_LOCATION_SETTING, ASSETS_ROOT_PATH).get(); +} + +void WindowScriptingInterface::setPreviousBrowseAssetLocation(const QString& location) { + Setting::Handle(LAST_BROWSE_ASSETS_LOCATION_SETTING).set(location); +} + /// Makes sure that the reticle is visible, use this in blocking forms that require a reticle and /// might be in same thread as a script that sets the reticle to invisible void WindowScriptingInterface::ensureReticleVisible() const { @@ -202,6 +212,31 @@ QScriptValue WindowScriptingInterface::save(const QString& title, const QString& return result.isEmpty() ? QScriptValue::NullValue : QScriptValue(result); } +/// Display a select asset dialog that lets the user select an asset from the Asset Server. If `directory` is an invalid +/// directory the browser will start at the root directory. +/// \param const QString& title title of the window +/// \param const QString& directory directory to start the asset browser at +/// \param const QString& nameFilter filter to filter asset names by - see `QFileDialog` +/// \return QScriptValue asset path as a string if one was selected, otherwise `QScriptValue::NullValue` +QScriptValue WindowScriptingInterface::browseAssets(const QString& title, const QString& directory, const QString& nameFilter) { + ensureReticleVisible(); + QString path = directory; + if (path.isEmpty()) { + path = getPreviousBrowseAssetLocation(); + } + if (path.left(1) != "/") { + path = "/" + path; + } + if (path.right(1) != "/") { + path = path + "/"; + } + QString result = OffscreenUi::getOpenAssetName(nullptr, title, path, nameFilter); + if (!result.isEmpty()) { + setPreviousBrowseAssetLocation(QFileInfo(result).absolutePath()); + } + return result.isEmpty() ? QScriptValue::NullValue : QScriptValue(result); +} + void WindowScriptingInterface::showAssetServer(const QString& upload) { QMetaObject::invokeMethod(qApp, "showAssetServerWidget", Qt::QueuedConnection, Q_ARG(QString, upload)); } diff --git a/interface/src/scripting/WindowScriptingInterface.h b/interface/src/scripting/WindowScriptingInterface.h index d4ff278fea..6934dea0af 100644 --- a/interface/src/scripting/WindowScriptingInterface.h +++ b/interface/src/scripting/WindowScriptingInterface.h @@ -53,6 +53,7 @@ public slots: CustomPromptResult customPrompt(const QVariant& config); QScriptValue browse(const QString& title = "", const QString& directory = "", const QString& nameFilter = ""); QScriptValue save(const QString& title = "", const QString& directory = "", const QString& nameFilter = ""); + QScriptValue browseAssets(const QString& title = "", const QString& directory = "", const QString& nameFilter = ""); void showAssetServer(const QString& upload = ""); void copyToClipboard(const QString& text); void takeSnapshot(bool notify = true, bool includeAnimated = false, float aspectRatio = 0.0f); @@ -88,6 +89,9 @@ private: QString getPreviousBrowseLocation() const; void setPreviousBrowseLocation(const QString& location); + QString getPreviousBrowseAssetLocation() const; + void setPreviousBrowseAssetLocation(const QString& location); + void ensureReticleVisible() const; int createMessageBox(QString title, QString text, int buttons, int defaultButton); 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/audio/src/SoundCache.cpp b/libraries/audio/src/SoundCache.cpp index 6b34c68959..1646540da6 100644 --- a/libraries/audio/src/SoundCache.cpp +++ b/libraries/audio/src/SoundCache.cpp @@ -14,6 +14,8 @@ #include "AudioLogging.h" #include "SoundCache.h" +static const int SOUNDS_LOADING_PRIORITY { -7 }; // Make sure sounds load after the low rez texture mips + int soundPointerMetaTypeId = qRegisterMetaType(); SoundCache::SoundCache(QObject* parent) : @@ -37,5 +39,7 @@ SharedSoundPointer SoundCache::getSound(const QUrl& url) { QSharedPointer SoundCache::createResource(const QUrl& url, const QSharedPointer& fallback, const void* extra) { qCDebug(audio) << "Requesting sound at" << url.toString(); - return QSharedPointer(new Sound(url), &Resource::deleter); + auto resource = QSharedPointer(new Sound(url), &Resource::deleter); + resource->setLoadPriority(this, SOUNDS_LOADING_PRIORITY); + return resource; } 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/libraries/controllers/CMakeLists.txt b/libraries/controllers/CMakeLists.txt index 384218691a..bf226f2647 100644 --- a/libraries/controllers/CMakeLists.txt +++ b/libraries/controllers/CMakeLists.txt @@ -10,4 +10,4 @@ GroupSources("src/controllers") add_dependency_external_projects(glm) find_package(GLM REQUIRED) -target_include_directories(${TARGET_NAME} PUBLIC ${GLM_INCLUDE_DIRS}) +target_include_directories(${TARGET_NAME} PUBLIC ${GLM_INCLUDE_DIRS} "${CMAKE_BINARY_DIR}/includes") diff --git a/libraries/controllers/src/controllers/InputRecorder.cpp b/libraries/controllers/src/controllers/InputRecorder.cpp new file mode 100644 index 0000000000..2d2cd40739 --- /dev/null +++ b/libraries/controllers/src/controllers/InputRecorder.cpp @@ -0,0 +1,290 @@ +// +// Created by Dante Ruiz 2017/04/16 +// 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 +// + +#include "InputRecorder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +QString SAVE_DIRECTORY = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/" + BuildInfo::MODIFIED_ORGANIZATION + "/" + BuildInfo::INTERFACE_NAME + "/hifi-input-recordings/"; +QString FILE_PREFIX_NAME = "input-recording-"; +QString COMPRESS_EXTENSION = ".tar.gz"; +namespace controller { + + QJsonObject poseToJsonObject(const Pose pose) { + QJsonObject newPose; + + QJsonArray translation; + translation.append(pose.translation.x); + translation.append(pose.translation.y); + translation.append(pose.translation.z); + + QJsonArray rotation; + rotation.append(pose.rotation.x); + rotation.append(pose.rotation.y); + rotation.append(pose.rotation.z); + rotation.append(pose.rotation.w); + + QJsonArray velocity; + velocity.append(pose.velocity.x); + velocity.append(pose.velocity.y); + velocity.append(pose.velocity.z); + + QJsonArray angularVelocity; + angularVelocity.append(pose.angularVelocity.x); + angularVelocity.append(pose.angularVelocity.y); + angularVelocity.append(pose.angularVelocity.z); + + newPose["translation"] = translation; + newPose["rotation"] = rotation; + newPose["velocity"] = velocity; + newPose["angularVelocity"] = angularVelocity; + newPose["valid"] = pose.valid; + + return newPose; + } + + Pose jsonObjectToPose(const QJsonObject object) { + Pose pose; + QJsonArray translation = object["translation"].toArray(); + QJsonArray rotation = object["rotation"].toArray(); + QJsonArray velocity = object["velocity"].toArray(); + QJsonArray angularVelocity = object["angularVelocity"].toArray(); + + pose.valid = object["valid"].toBool(); + + pose.translation.x = translation[0].toDouble(); + pose.translation.y = translation[1].toDouble(); + pose.translation.z = translation[2].toDouble(); + + pose.rotation.x = rotation[0].toDouble(); + pose.rotation.y = rotation[1].toDouble(); + pose.rotation.z = rotation[2].toDouble(); + pose.rotation.w = rotation[3].toDouble(); + + pose.velocity.x = velocity[0].toDouble(); + pose.velocity.y = velocity[1].toDouble(); + pose.velocity.z = velocity[2].toDouble(); + + pose.angularVelocity.x = angularVelocity[0].toDouble(); + pose.angularVelocity.y = angularVelocity[1].toDouble(); + pose.angularVelocity.z = angularVelocity[2].toDouble(); + + return pose; + } + + + void exportToFile(QJsonObject& object) { + if (!QDir(SAVE_DIRECTORY).exists()) { + QDir().mkdir(SAVE_DIRECTORY); + } + + QString timeStamp = QDateTime::currentDateTime().toString(Qt::ISODate); + timeStamp.replace(":", "-"); + QString fileName = SAVE_DIRECTORY + FILE_PREFIX_NAME + timeStamp + COMPRESS_EXTENSION; + qDebug() << fileName; + QFile saveFile (fileName); + if (!saveFile.open(QIODevice::WriteOnly)) { + qWarning() << "could not open file: " << fileName; + return; + } + QJsonDocument saveData(object); + QByteArray compressedData = qCompress(saveData.toJson(QJsonDocument::Compact)); + saveFile.write(compressedData); + } + + QJsonObject openFile(const QString& file, bool& status) { + QJsonObject object; + QFile openFile(file); + if (!openFile.open(QIODevice::ReadOnly)) { + qWarning() << "could not open file: " << file; + status = false; + return object; + } + QByteArray compressedData = qUncompress(openFile.readAll()); + QJsonDocument jsonDoc = QJsonDocument::fromJson(compressedData); + object = jsonDoc.object(); + status = true; + return object; + } + + InputRecorder::InputRecorder() {} + + InputRecorder::~InputRecorder() {} + + InputRecorder* InputRecorder::getInstance() { + static InputRecorder inputRecorder; + return &inputRecorder; + } + + QString InputRecorder::getSaveDirectory() { + return SAVE_DIRECTORY; + } + + void InputRecorder::startRecording() { + _recording = true; + _playback = false; + _framesRecorded = 0; + _poseStateList.clear(); + _actionStateList.clear(); + } + + void InputRecorder::saveRecording() { + QJsonObject data; + data["frameCount"] = _framesRecorded; + + QJsonArray actionArrayList; + QJsonArray poseArrayList; + for(const ActionStates actionState: _actionStateList) { + QJsonArray actionArray; + for (const float value: actionState) { + actionArray.append(value); + } + actionArrayList.append(actionArray); + } + + for (const PoseStates poseState: _poseStateList) { + QJsonArray poseArray; + for (const Pose pose: poseState) { + poseArray.append(poseToJsonObject(pose)); + } + poseArrayList.append(poseArray); + } + + data["actionList"] = actionArrayList; + data["poseList"] = poseArrayList; + exportToFile(data); + } + + void InputRecorder::loadRecording(const QString& path) { + _recording = false; + _playback = false; + _loading = true; + _playCount = 0; + resetFrame(); + _poseStateList.clear(); + _actionStateList.clear(); + QString filePath = path; + filePath.remove(0,8); + QFileInfo info(filePath); + QString extension = info.suffix(); + if (extension != "gz") { + qWarning() << "can not load file with exentsion of " << extension; + return; + } + bool success = false; + QJsonObject data = openFile(info.absoluteFilePath(), success); + if (success) { + _framesRecorded = data["frameCount"].toInt(); + QJsonArray actionArrayList = data["actionList"].toArray(); + QJsonArray poseArrayList = data["poseList"].toArray(); + + for (int actionIndex = 0; actionIndex < actionArrayList.size(); actionIndex++) { + QJsonArray actionState = actionArrayList[actionIndex].toArray(); + for (int index = 0; index < actionState.size(); index++) { + _currentFrameActions[index] = actionState[index].toInt(); + } + _actionStateList.push_back(_currentFrameActions); + _currentFrameActions = ActionStates(toInt(Action::NUM_ACTIONS)); + } + + for (int poseIndex = 0; poseIndex < poseArrayList.size(); poseIndex++) { + QJsonArray poseState = poseArrayList[poseIndex].toArray(); + for (int index = 0; index < poseState.size(); index++) { + _currentFramePoses[index] = jsonObjectToPose(poseState[index].toObject()); + } + _poseStateList.push_back(_currentFramePoses); + _currentFramePoses = PoseStates(toInt(Action::NUM_ACTIONS)); + } + } + + _loading = false; + } + + void InputRecorder::stopRecording() { + _recording = false; + } + + void InputRecorder::startPlayback() { + _playback = true; + _recording = false; + _playCount = 0; + } + + void InputRecorder::stopPlayback() { + _playback = false; + _playCount = 0; + } + + void InputRecorder::setActionState(controller::Action action, float value) { + if (_recording) { + _currentFrameActions[toInt(action)] += value; + } + } + + void InputRecorder::setActionState(controller::Action action, const controller::Pose pose) { + if (_recording) { + _currentFramePoses[toInt(action)] = pose; + } + } + + void InputRecorder::resetFrame() { + if (_recording) { + for(auto& channel : _currentFramePoses) { + channel = Pose(); + } + + for(auto& channel : _currentFrameActions) { + channel = 0.0f; + } + } + } + + float InputRecorder::getActionState(controller::Action action) { + if (_actionStateList.size() > 0 ) { + return _actionStateList[_playCount][toInt(action)]; + } + + return 0.0f; + } + + controller::Pose InputRecorder::getPoseState(controller::Action action) { + if (_poseStateList.size() > 0) { + return _poseStateList[_playCount][toInt(action)]; + } + + return Pose(); + } + + void InputRecorder::frameTick() { + if (_recording) { + _framesRecorded++; + _poseStateList.push_back(_currentFramePoses); + _actionStateList.push_back(_currentFrameActions); + } + + if (_playback) { + _playCount++; + if (_playCount == _framesRecorded) { + _playCount = 0; + } + } + } +} diff --git a/libraries/controllers/src/controllers/InputRecorder.h b/libraries/controllers/src/controllers/InputRecorder.h new file mode 100644 index 0000000000..d1cc9a32eb --- /dev/null +++ b/libraries/controllers/src/controllers/InputRecorder.h @@ -0,0 +1,62 @@ +// +// Created by Dante Ruiz on 2017/04/16 +// 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 +// + +#ifndef hifi_InputRecorder_h +#define hifi_InputRecorder_h + +#include +#include +#include + +#include + +#include "Pose.h" +#include "Actions.h" + +namespace controller { + class InputRecorder { + public: + using PoseStates = std::vector; + using ActionStates = std::vector; + + InputRecorder(); + ~InputRecorder(); + + static InputRecorder* getInstance(); + + void saveRecording(); + void loadRecording(const QString& path); + void startRecording(); + void startPlayback(); + void stopPlayback(); + void stopRecording(); + void toggleRecording() { _recording = !_recording; } + void togglePlayback() { _playback = !_playback; } + void resetFrame(); + bool isRecording() { return _recording; } + bool isPlayingback() { return (_playback && !_loading); } + void setActionState(controller::Action action, float value); + void setActionState(controller::Action action, const controller::Pose pose); + float getActionState(controller::Action action); + controller::Pose getPoseState(controller::Action action); + QString getSaveDirectory(); + void frameTick(); + private: + bool _recording { false }; + bool _playback { false }; + bool _loading { false }; + std::vector _poseStateList = std::vector(); + std::vector _actionStateList = std::vector(); + PoseStates _currentFramePoses = PoseStates(toInt(Action::NUM_ACTIONS)); + ActionStates _currentFrameActions = ActionStates(toInt(Action::NUM_ACTIONS)); + + int _framesRecorded { 0 }; + int _playCount { 0 }; + }; +} +#endif diff --git a/libraries/controllers/src/controllers/ScriptingInterface.cpp b/libraries/controllers/src/controllers/ScriptingInterface.cpp index d32acb3d82..16db22401f 100644 --- a/libraries/controllers/src/controllers/ScriptingInterface.cpp +++ b/libraries/controllers/src/controllers/ScriptingInterface.cpp @@ -23,6 +23,7 @@ #include "impl/MappingBuilderProxy.h" #include "Logging.h" #include "InputDevice.h" +#include "InputRecorder.h" static QRegularExpression SANITIZE_NAME_EXPRESSION{ "[\\(\\)\\.\\s]" }; @@ -154,6 +155,41 @@ namespace controller { return DependencyManager::get()->triggerHapticPulse(strength, SHORT_HAPTIC_DURATION_MS, hand); } + void ScriptingInterface::startInputRecording() { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + inputRecorder->startRecording(); + } + + void ScriptingInterface::stopInputRecording() { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + inputRecorder->stopRecording(); + } + + void ScriptingInterface::startInputPlayback() { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + inputRecorder->startPlayback(); + } + + void ScriptingInterface::stopInputPlayback() { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + inputRecorder->stopPlayback(); + } + + void ScriptingInterface::saveInputRecording() { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + inputRecorder->saveRecording(); + } + + void ScriptingInterface::loadInputRecording(const QString& file) { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + inputRecorder->loadRecording(file); + } + + QString ScriptingInterface::getInputRecorderSaveDirectory() { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + return inputRecorder->getSaveDirectory(); + } + bool ScriptingInterface::triggerHapticPulseOnDevice(unsigned int device, float strength, float duration, controller::Hand hand) const { return DependencyManager::get()->triggerHapticPulseOnDevice(device, strength, duration, hand); } diff --git a/libraries/controllers/src/controllers/ScriptingInterface.h b/libraries/controllers/src/controllers/ScriptingInterface.h index b47a6fea31..2c60ca25f5 100644 --- a/libraries/controllers/src/controllers/ScriptingInterface.h +++ b/libraries/controllers/src/controllers/ScriptingInterface.h @@ -99,6 +99,13 @@ namespace controller { Q_INVOKABLE const QVariantMap& getHardware() { return _hardware; } Q_INVOKABLE const QVariantMap& getActions() { return _actions; } Q_INVOKABLE const QVariantMap& getStandard() { return _standard; } + Q_INVOKABLE void startInputRecording(); + Q_INVOKABLE void stopInputRecording(); + Q_INVOKABLE void startInputPlayback(); + Q_INVOKABLE void stopInputPlayback(); + Q_INVOKABLE void saveInputRecording(); + Q_INVOKABLE void loadInputRecording(const QString& file); + Q_INVOKABLE QString getInputRecorderSaveDirectory(); bool isMouseCaptured() const { return _mouseCaptured; } bool isTouchCaptured() const { return _touchCaptured; } diff --git a/libraries/controllers/src/controllers/UserInputMapper.cpp b/libraries/controllers/src/controllers/UserInputMapper.cpp index fe50f023c3..71b052bfe4 100755 --- a/libraries/controllers/src/controllers/UserInputMapper.cpp +++ b/libraries/controllers/src/controllers/UserInputMapper.cpp @@ -22,7 +22,7 @@ #include "StandardController.h" #include "StateController.h" - +#include "InputRecorder.h" #include "Logging.h" #include "impl/conditionals/AndConditional.h" @@ -243,10 +243,11 @@ void fixBisectedAxis(float& full, float& negative, float& positive) { void UserInputMapper::update(float deltaTime) { Locker locker(_lock); - + InputRecorder* inputRecorder = InputRecorder::getInstance(); static uint64_t updateCount = 0; ++updateCount; + inputRecorder->resetFrame(); // Reset the axis state for next loop for (auto& channel : _actionStates) { channel = 0.0f; @@ -298,6 +299,7 @@ void UserInputMapper::update(float deltaTime) { emit inputEvent(input.id, value); } } + inputRecorder->frameTick(); } Input::NamedVector UserInputMapper::getAvailableInputs(uint16 deviceID) const { diff --git a/libraries/controllers/src/controllers/impl/endpoints/ActionEndpoint.cpp b/libraries/controllers/src/controllers/impl/endpoints/ActionEndpoint.cpp index b671d8e93c..6c14533f02 100644 --- a/libraries/controllers/src/controllers/impl/endpoints/ActionEndpoint.cpp +++ b/libraries/controllers/src/controllers/impl/endpoints/ActionEndpoint.cpp @@ -11,19 +11,32 @@ #include #include "../../UserInputMapper.h" +#include "../../InputRecorder.h" using namespace controller; void ActionEndpoint::apply(float newValue, const Pointer& source) { + InputRecorder* inputRecorder = InputRecorder::getInstance(); + if(inputRecorder->isPlayingback()) { + newValue = inputRecorder->getActionState(Action(_input.getChannel())); + } + _currentValue += newValue; if (_input != Input::INVALID_INPUT) { auto userInputMapper = DependencyManager::get(); userInputMapper->deltaActionState(Action(_input.getChannel()), newValue); } + inputRecorder->setActionState(Action(_input.getChannel()), newValue); } void ActionEndpoint::apply(const Pose& value, const Pointer& source) { _currentPose = value; + InputRecorder* inputRecorder = InputRecorder::getInstance(); + inputRecorder->setActionState(Action(_input.getChannel()), _currentPose); + if (inputRecorder->isPlayingback()) { + _currentPose = inputRecorder->getPoseState(Action(_input.getChannel())); + } + if (!_currentPose.isValid()) { return; } diff --git a/libraries/entities-renderer/src/EntityTreeRenderer.cpp b/libraries/entities-renderer/src/EntityTreeRenderer.cpp index 20d07b1e6d..1de476c825 100644 --- a/libraries/entities-renderer/src/EntityTreeRenderer.cpp +++ b/libraries/entities-renderer/src/EntityTreeRenderer.cpp @@ -1015,11 +1015,11 @@ void EntityTreeRenderer::addEntityToScene(EntityItemPointer entity) { } -void EntityTreeRenderer::entityScriptChanging(const EntityItemID& entityID, const bool reload) { +void EntityTreeRenderer::entityScriptChanging(const EntityItemID& entityID, bool reload) { checkAndCallPreload(entityID, reload, true); } -void EntityTreeRenderer::checkAndCallPreload(const EntityItemID& entityID, const bool reload, const bool unloadFirst) { +void EntityTreeRenderer::checkAndCallPreload(const EntityItemID& entityID, bool reload, bool unloadFirst) { if (_tree && !_shuttingDown) { EntityItemPointer entity = getTree()->findEntityByEntityItemID(entityID); if (!entity) { @@ -1027,11 +1027,11 @@ void EntityTreeRenderer::checkAndCallPreload(const EntityItemID& entityID, const } bool shouldLoad = entity->shouldPreloadScript() && _entitiesScriptEngine; QString scriptUrl = entity->getScript(); - if (shouldLoad && (unloadFirst || scriptUrl.isEmpty())) { + if ((shouldLoad && unloadFirst) || scriptUrl.isEmpty()) { _entitiesScriptEngine->unloadEntityScript(entityID); entity->scriptHasUnloaded(); } - if (shouldLoad && !scriptUrl.isEmpty()) { + if (shouldLoad) { scriptUrl = ResourceManager::normalizeURL(scriptUrl); _entitiesScriptEngine->loadEntityScript(entityID, scriptUrl, reload); entity->scriptHasPreloaded(); diff --git a/libraries/entities-renderer/src/EntityTreeRenderer.h b/libraries/entities-renderer/src/EntityTreeRenderer.h index ec9f707962..f4717dca51 100644 --- a/libraries/entities-renderer/src/EntityTreeRenderer.h +++ b/libraries/entities-renderer/src/EntityTreeRenderer.h @@ -152,7 +152,7 @@ private: bool applySkyboxAndHasAmbient(); bool applyLayeredZones(); - void checkAndCallPreload(const EntityItemID& entityID, const bool reload = false, const bool unloadFirst = false); + void checkAndCallPreload(const EntityItemID& entityID, bool reload = false, bool unloadFirst = false); QList _releasedModels; RayToEntityIntersectionResult findRayIntersectionWorker(const PickRay& ray, Octree::lockType lockType, diff --git a/libraries/gpu-gl/src/gpu/gl/GLTexture.cpp b/libraries/gpu-gl/src/gpu/gl/GLTexture.cpp index 27a31ca678..7f075a1698 100644 --- a/libraries/gpu-gl/src/gpu/gl/GLTexture.cpp +++ b/libraries/gpu-gl/src/gpu/gl/GLTexture.cpp @@ -448,10 +448,10 @@ void GLVariableAllocationSupport::updateMemoryPressure() { float pressure = (float)totalVariableMemoryAllocation / (float)allowedMemoryAllocation; auto newState = MemoryPressureState::Idle; - if (pressure > OVERSUBSCRIBED_PRESSURE_VALUE && canDemote) { - newState = MemoryPressureState::Oversubscribed; - } else if (pressure < UNDERSUBSCRIBED_PRESSURE_VALUE && unallocated != 0 && canPromote) { + if (pressure < UNDERSUBSCRIBED_PRESSURE_VALUE && (unallocated != 0 && canPromote)) { newState = MemoryPressureState::Undersubscribed; + } else if (pressure > OVERSUBSCRIBED_PRESSURE_VALUE && canDemote) { + newState = MemoryPressureState::Oversubscribed; } else if (hasTransfers) { newState = MemoryPressureState::Transfer; } @@ -529,6 +529,7 @@ void GLVariableAllocationSupport::processWorkQueues() { } if (workQueue.empty()) { + _memoryPressureState = MemoryPressureState::Idle; _memoryPressureStateStale = true; } } diff --git a/libraries/gpu-gl/src/gpu/gl/GLTexture.h b/libraries/gpu-gl/src/gpu/gl/GLTexture.h index b21ff53dd8..e0b8a63a99 100644 --- a/libraries/gpu-gl/src/gpu/gl/GLTexture.h +++ b/libraries/gpu-gl/src/gpu/gl/GLTexture.h @@ -113,7 +113,7 @@ protected: static void manageMemory(); //bool canPromoteNoAllocate() const { return _allocatedMip < _populatedMip; } - bool canPromote() const { return _allocatedMip > 0; } + bool canPromote() const { return _allocatedMip > _minAllocatedMip; } bool canDemote() const { return _allocatedMip < _maxAllocatedMip; } bool hasPendingTransfers() const { return _populatedMip > _allocatedMip; } void executeNextTransfer(const TexturePointer& currentTexture); @@ -131,6 +131,9 @@ protected: // The highest (lowest resolution) mip that we will support, relative to the number // of mips in the gpu::Texture object uint16 _maxAllocatedMip { 0 }; + // The lowest (highest resolution) mip that we will support, relative to the number + // of mips in the gpu::Texture object + uint16 _minAllocatedMip { 0 }; // Contains a series of lambdas that when executed will transfer data to the GPU, modify // the _populatedMip and update the sampler in order to fully populate the allocated texture // until _populatedMip == _allocatedMip diff --git a/libraries/gpu-gl/src/gpu/gl41/GL41BackendTexture.cpp b/libraries/gpu-gl/src/gpu/gl41/GL41BackendTexture.cpp index bff5bf3f2c..5db924dd5c 100644 --- a/libraries/gpu-gl/src/gpu/gl41/GL41BackendTexture.cpp +++ b/libraries/gpu-gl/src/gpu/gl41/GL41BackendTexture.cpp @@ -55,6 +55,18 @@ GLTexture* GL41Backend::syncGPUObject(const TexturePointer& texturePointer) { default: Q_UNREACHABLE(); } + } else { + if (texture.getUsageType() == TextureUsageType::RESOURCE) { + auto varTex = static_cast (object); + + if (varTex->_minAllocatedMip > 0) { + auto minAvailableMip = texture.minAvailableMipLevel(); + if (minAvailableMip < varTex->_minAllocatedMip) { + varTex->_minAllocatedMip = minAvailableMip; + GL41VariableAllocationTexture::_memoryPressureStateStale = true; + } + } + } } return object; @@ -231,15 +243,20 @@ using GL41VariableAllocationTexture = GL41Backend::GL41VariableAllocationTexture GL41VariableAllocationTexture::GL41VariableAllocationTexture(const std::weak_ptr& backend, const Texture& texture) : GL41Texture(backend, texture) { auto mipLevels = texture.getNumMips(); _allocatedMip = mipLevels; + _maxAllocatedMip = _populatedMip = mipLevels; + _minAllocatedMip = texture.minAvailableMipLevel(); + uvec3 mipDimensions; - for (uint16_t mip = 0; mip < mipLevels; ++mip) { + for (uint16_t mip = _minAllocatedMip; mip < mipLevels; ++mip) { if (glm::all(glm::lessThanEqual(texture.evalMipDimensions(mip), INITIAL_MIP_TRANSFER_DIMENSIONS))) { _maxAllocatedMip = _populatedMip = mip; break; } } - uint16_t allocatedMip = _populatedMip - std::min(_populatedMip, 2); + auto targetMip = _populatedMip - std::min(_populatedMip, 2); + uint16_t allocatedMip = std::max(_minAllocatedMip, targetMip); + allocateStorage(allocatedMip); _memoryPressureStateStale = true; size_t maxFace = GLTexture::getFaceCount(_target); @@ -292,6 +309,10 @@ void GL41VariableAllocationTexture::syncSampler() const { void GL41VariableAllocationTexture::promote() { PROFILE_RANGE(render_gpu_gl, __FUNCTION__); Q_ASSERT(_allocatedMip > 0); + + uint16_t targetAllocatedMip = _allocatedMip - std::min(_allocatedMip, 2); + targetAllocatedMip = std::max(_minAllocatedMip, targetAllocatedMip); + GLuint oldId = _id; auto oldSize = _size; // create new texture @@ -299,7 +320,7 @@ void GL41VariableAllocationTexture::promote() { uint16_t oldAllocatedMip = _allocatedMip; // allocate storage for new level - allocateStorage(_allocatedMip - std::min(_allocatedMip, 2)); + allocateStorage(targetAllocatedMip); withPreservedTexture([&] { GLuint fbo { 0 }; diff --git a/libraries/gpu-gl/src/gpu/gl45/GL45BackendTexture.cpp b/libraries/gpu-gl/src/gpu/gl45/GL45BackendTexture.cpp index c6f1ef41ae..120be923f5 100644 --- a/libraries/gpu-gl/src/gpu/gl45/GL45BackendTexture.cpp +++ b/libraries/gpu-gl/src/gpu/gl45/GL45BackendTexture.cpp @@ -80,6 +80,19 @@ GLTexture* GL45Backend::syncGPUObject(const TexturePointer& texturePointer) { default: Q_UNREACHABLE(); } + } else { + + if (texture.getUsageType() == TextureUsageType::RESOURCE) { + auto varTex = static_cast (object); + + if (varTex->_minAllocatedMip > 0) { + auto minAvailableMip = texture.minAvailableMipLevel(); + if (minAvailableMip < varTex->_minAllocatedMip) { + varTex->_minAllocatedMip = minAvailableMip; + GL45VariableAllocationTexture::_memoryPressureStateStale = true; + } + } + } } return object; @@ -109,6 +122,10 @@ GL45Texture::GL45Texture(const std::weak_ptr& backend, const Texture& GLuint GL45Texture::allocate(const Texture& texture) { GLuint result; glCreateTextures(getGLTextureType(texture), 1, &result); +#ifdef DEBUG + auto source = texture.source(); + glObjectLabel(GL_TEXTURE, result, source.length(), source.data()); +#endif return result; } diff --git a/libraries/gpu-gl/src/gpu/gl45/GL45BackendVariableTexture.cpp b/libraries/gpu-gl/src/gpu/gl45/GL45BackendVariableTexture.cpp index a453d4207d..92d820e5f0 100644 --- a/libraries/gpu-gl/src/gpu/gl45/GL45BackendVariableTexture.cpp +++ b/libraries/gpu-gl/src/gpu/gl45/GL45BackendVariableTexture.cpp @@ -43,16 +43,22 @@ using GL45ResourceTexture = GL45Backend::GL45ResourceTexture; GL45ResourceTexture::GL45ResourceTexture(const std::weak_ptr& backend, const Texture& texture) : GL45VariableAllocationTexture(backend, texture) { auto mipLevels = texture.getNumMips(); _allocatedMip = mipLevels; + _maxAllocatedMip = _populatedMip = mipLevels; + _minAllocatedMip = texture.minAvailableMipLevel(); + uvec3 mipDimensions; - for (uint16_t mip = 0; mip < mipLevels; ++mip) { + for (uint16_t mip = _minAllocatedMip; mip < mipLevels; ++mip) { if (glm::all(glm::lessThanEqual(texture.evalMipDimensions(mip), INITIAL_MIP_TRANSFER_DIMENSIONS))) { _maxAllocatedMip = _populatedMip = mip; break; } } - uint16_t allocatedMip = _populatedMip - std::min(_populatedMip, 2); + auto targetMip = _populatedMip - std::min(_populatedMip, 2); + uint16_t allocatedMip = std::max(_minAllocatedMip, targetMip); + allocateStorage(allocatedMip); + _memoryPressureStateStale = true; copyMipsFromTexture(); syncSampler(); @@ -70,6 +76,7 @@ void GL45ResourceTexture::allocateStorage(uint16 allocatedMip) { for (uint16_t mip = _allocatedMip; mip < mipLevels; ++mip) { _size += _gpuObject.evalMipSize(mip); } + Backend::updateTextureGPUMemoryUsage(0, _size); } @@ -93,13 +100,17 @@ void GL45ResourceTexture::syncSampler() const { void GL45ResourceTexture::promote() { PROFILE_RANGE(render_gpu_gl, __FUNCTION__); Q_ASSERT(_allocatedMip > 0); + + uint16_t targetAllocatedMip = _allocatedMip - std::min(_allocatedMip, 2); + targetAllocatedMip = std::max(_minAllocatedMip, targetAllocatedMip); + GLuint oldId = _id; auto oldSize = _size; // create new texture const_cast(_id) = allocate(_gpuObject); uint16_t oldAllocatedMip = _allocatedMip; // allocate storage for new level - allocateStorage(_allocatedMip - std::min(_allocatedMip, 2)); + allocateStorage(targetAllocatedMip); uint16_t mips = _gpuObject.getNumMips(); // copy pre-existing mips for (uint16_t mip = _populatedMip; mip < mips; ++mip) { diff --git a/libraries/gpu/src/gpu/Texture.cpp b/libraries/gpu/src/gpu/Texture.cpp index 4d66d71567..3e6ed166a7 100755 --- a/libraries/gpu/src/gpu/Texture.cpp +++ b/libraries/gpu/src/gpu/Texture.cpp @@ -118,6 +118,7 @@ Texture::Size Texture::getAllowedGPUMemoryUsage() { return _allowedCPUMemoryUsage; } + void Texture::setAllowedGPUMemoryUsage(Size size) { qCDebug(gpulogging) << "New MAX texture memory " << BYTES_TO_MB(size) << " MB"; _allowedCPUMemoryUsage = size; @@ -411,6 +412,7 @@ const Element& Texture::getStoredMipFormat() const { } void Texture::assignStoredMip(uint16 level, Size size, const Byte* bytes) { + // TODO Skip the extra allocation here storage::StoragePointer storage = std::make_shared(size, bytes); assignStoredMip(level, storage); } @@ -474,6 +476,10 @@ void Texture::assignStoredMipFace(uint16 level, uint8 face, storage::StoragePoin } } +bool Texture::isStoredMipFaceAvailable(uint16 level, uint8 face) const { + return _storage->isMipAvailable(level, face); +} + void Texture::setAutoGenerateMips(bool enable) { bool changed = false; if (!_autoGenerateMips) { diff --git a/libraries/gpu/src/gpu/Texture.h b/libraries/gpu/src/gpu/Texture.h index 2f63bd6719..9b23b4e695 100755 --- a/libraries/gpu/src/gpu/Texture.h +++ b/libraries/gpu/src/gpu/Texture.h @@ -28,10 +28,17 @@ namespace ktx { struct KTXDescriptor; using KTXDescriptorPointer = std::unique_ptr; struct Header; + struct KeyValue; + using KeyValues = std::list; } namespace gpu { + +const std::string SOURCE_HASH_KEY { "hifi.sourceHash" }; + +const uint8 SOURCE_HASH_BYTES = 16; + // THe spherical harmonics is a nice tool for cubemap, so if required, the irradiance SH can be automatically generated // with the cube texture class Texture; @@ -150,7 +157,7 @@ protected: Desc _desc; }; -enum class TextureUsageType { +enum class TextureUsageType : uint8 { RENDERBUFFER, // Used as attachments to a framebuffer RESOURCE, // Resource textures, like materials... subject to memory manipulation STRICT_RESOURCE, // Resource textures not subject to manipulation, like the normal fitting texture @@ -271,6 +278,7 @@ public: virtual void assignMipData(uint16 level, const storage::StoragePointer& storage) = 0; virtual void assignMipFaceData(uint16 level, uint8 face, const storage::StoragePointer& storage) = 0; virtual bool isMipAvailable(uint16 level, uint8 face = 0) const = 0; + virtual uint16 minAvailableMipLevel() const { return 0; } Texture::Type getType() const { return _type; } Stamp getStamp() const { return _stamp; } @@ -308,24 +316,30 @@ public: KtxStorage(const std::string& filename); PixelsPointer getMipFace(uint16 level, uint8 face = 0) const override; Size getMipFaceSize(uint16 level, uint8 face = 0) const override; - // By convention, all mip levels and faces MUST be populated when using KTX backing - bool isMipAvailable(uint16 level, uint8 face = 0) const override { return true; } + bool isMipAvailable(uint16 level, uint8 face = 0) const override; + void assignMipData(uint16 level, const storage::StoragePointer& storage) override; + void assignMipFaceData(uint16 level, uint8 face, const storage::StoragePointer& storage) override; + uint16 minAvailableMipLevel() const override; - void assignMipData(uint16 level, const storage::StoragePointer& storage) override { - throw std::runtime_error("Invalid call"); - } - - void assignMipFaceData(uint16 level, uint8 face, const storage::StoragePointer& storage) override { - throw std::runtime_error("Invalid call"); - } void reset() override { } protected: + std::shared_ptr maybeOpenFile(); + + std::mutex _cacheFileCreateMutex; + std::mutex _cacheFileWriteMutex; + std::weak_ptr _cacheFile; + std::string _filename; + std::atomic _minMipLevelAvailable; + size_t _offsetToMinMipKV; + ktx::KTXDescriptorPointer _ktxDescriptor; friend class Texture; }; + uint16 minAvailableMipLevel() const { return _storage->minAvailableMipLevel(); }; + static const uint16 MAX_NUM_MIPS = 0; static const uint16 SINGLE_MIP = 1; static TexturePointer create1D(const Element& texelFormat, uint16 width, uint16 numMips = SINGLE_MIP, const Sampler& sampler = Sampler()); @@ -469,7 +483,7 @@ public: // Access the stored mips and faces const PixelsPointer accessStoredMipFace(uint16 level, uint8 face = 0) const { return _storage->getMipFace(level, face); } - bool isStoredMipFaceAvailable(uint16 level, uint8 face = 0) const { return _storage->isMipAvailable(level, face); } + bool isStoredMipFaceAvailable(uint16 level, uint8 face = 0) const; Size getStoredMipFaceSize(uint16 level, uint8 face = 0) const { return _storage->getMipFaceSize(level, face); } Size getStoredMipSize(uint16 level) const; Size getStoredSize() const; @@ -503,9 +517,12 @@ public: ExternalUpdates getUpdates() const; - // Textures can be serialized directly to ktx data file, here is how + // Serialize a texture into a KTX file static ktx::KTXUniquePointer serialize(const Texture& texture); - static TexturePointer unserialize(const std::string& ktxFile, TextureUsageType usageType = TextureUsageType::RESOURCE, Usage usage = Usage(), const Sampler::Desc& sampler = Sampler::Desc()); + + static TexturePointer unserialize(const std::string& ktxFile); + static TexturePointer unserialize(const std::string& ktxFile, const ktx::KTXDescriptor& descriptor); + static bool evalKTXFormat(const Element& mipFormat, const Element& texelFormat, ktx::Header& header); static bool evalTextureFormat(const ktx::Header& header, Element& mipFormat, Element& texelFormat); diff --git a/libraries/gpu/src/gpu/Texture_ktx.cpp b/libraries/gpu/src/gpu/Texture_ktx.cpp index 50e9cb6d07..efff6c7afe 100644 --- a/libraries/gpu/src/gpu/Texture_ktx.cpp +++ b/libraries/gpu/src/gpu/Texture_ktx.cpp @@ -12,44 +12,114 @@ #include "Texture.h" +#include + #include + +#include "GPULogging.h" + using namespace gpu; using PixelsPointer = Texture::PixelsPointer; using KtxStorage = Texture::KtxStorage; struct GPUKTXPayload { + using Version = uint8; + + static const std::string KEY; + static const Version CURRENT_VERSION { 1 }; + static const size_t PADDING { 2 }; + static const size_t SIZE { sizeof(Version) + sizeof(Sampler::Desc) + sizeof(uint32) + sizeof(TextureUsageType) + PADDING }; + static_assert(GPUKTXPayload::SIZE == 36, "Packing size may differ between platforms"); + static_assert(GPUKTXPayload::SIZE % 4 == 0, "GPUKTXPayload is not 4 bytes aligned"); + Sampler::Desc _samplerDesc; Texture::Usage _usage; TextureUsageType _usageType; + Byte* serialize(Byte* data) const { + *(Version*)data = CURRENT_VERSION; + data += sizeof(Version); + + memcpy(data, &_samplerDesc, sizeof(Sampler::Desc)); + data += sizeof(Sampler::Desc); + + // We can't copy the bitset in Texture::Usage in a crossplateform manner + // So serialize it manually + *(uint32*)data = _usage._flags.to_ulong(); + data += sizeof(uint32); + + *(TextureUsageType*)data = _usageType; + data += sizeof(TextureUsageType); + + return data + PADDING; + } + + bool unserialize(const Byte* data, size_t size) { + if (size != SIZE) { + return false; + } + + Version version = *(const Version*)data; + if (version != CURRENT_VERSION) { + glm::vec4 borderColor(1.0f); + if (memcmp(&borderColor, data, sizeof(glm::vec4)) == 0) { + memcpy(this, data, sizeof(GPUKTXPayload)); + return true; + } else { + return false; + } + } + data += sizeof(Version); + + memcpy(&_samplerDesc, data, sizeof(Sampler::Desc)); + data += sizeof(Sampler::Desc); + + // We can't copy the bitset in Texture::Usage in a crossplateform manner + // So unserialize it manually + _usage = Texture::Usage(*(const uint32*)data); + data += sizeof(uint32); + + _usageType = *(const TextureUsageType*)data; + return true; + } - static std::string KEY; static bool isGPUKTX(const ktx::KeyValue& val) { return (val._key.compare(KEY) == 0); } static bool findInKeyValues(const ktx::KeyValues& keyValues, GPUKTXPayload& payload) { - auto found = std::find_if(keyValues.begin(), keyValues.end(), isGPUKTX); + auto found = std::find_if(keyValues.begin(), keyValues.end(), isGPUKTX); if (found != keyValues.end()) { - if ((*found)._value.size() == sizeof(GPUKTXPayload)) { - memcpy(&payload, (*found)._value.data(), sizeof(GPUKTXPayload)); - return true; - } + auto value = found->_value; + return payload.unserialize(value.data(), value.size()); } return false; } }; - -std::string GPUKTXPayload::KEY { "hifi.gpu" }; +const std::string GPUKTXPayload::KEY { "hifi.gpu" }; KtxStorage::KtxStorage(const std::string& filename) : _filename(filename) { { - ktx::StoragePointer storage { new storage::FileStorage(_filename.c_str()) }; + // We are doing a lot of work here just to get descriptor data + ktx::StoragePointer storage{ new storage::FileStorage(_filename.c_str()) }; auto ktxPointer = ktx::KTX::create(storage); _ktxDescriptor.reset(new ktx::KTXDescriptor(ktxPointer->toDescriptor())); + if (_ktxDescriptor->images.size() < _ktxDescriptor->header.numberOfMipmapLevels) { + qWarning() << "Bad images found in ktx"; + } + + _offsetToMinMipKV = _ktxDescriptor->getValueOffsetForKey(ktx::HIFI_MIN_POPULATED_MIP_KEY); + if (_offsetToMinMipKV) { + auto data = storage->data() + ktx::KTX_HEADER_SIZE + _offsetToMinMipKV; + _minMipLevelAvailable = *data; + } else { + // Assume all mip levels are available + _minMipLevelAvailable = 0; + } } + // now that we know the ktx, let's get the header info to configure this Texture::Storage: Format mipFormat = Format::COLOR_BGRA_32; Format texelFormat = Format::COLOR_SRGBA_32; @@ -58,6 +128,27 @@ KtxStorage::KtxStorage(const std::string& filename) : _filename(filename) { } } +std::shared_ptr KtxStorage::maybeOpenFile() { + std::shared_ptr file = _cacheFile.lock(); + if (file) { + return file; + } + + { + std::lock_guard lock{ _cacheFileCreateMutex }; + + file = _cacheFile.lock(); + if (file) { + return file; + } + + file = std::make_shared(_filename.c_str()); + _cacheFile = file; + } + + return file; +} + PixelsPointer KtxStorage::getMipFace(uint16 level, uint8 face) const { storage::StoragePointer result; auto faceOffset = _ktxDescriptor->getMipFaceTexelsOffset(level, face); @@ -72,6 +163,58 @@ Size KtxStorage::getMipFaceSize(uint16 level, uint8 face) const { return _ktxDescriptor->getMipFaceTexelsSize(level, face); } + +bool KtxStorage::isMipAvailable(uint16 level, uint8 face) const { + return level >= _minMipLevelAvailable; +} + +uint16 KtxStorage::minAvailableMipLevel() const { + return _minMipLevelAvailable; +} + +void KtxStorage::assignMipData(uint16 level, const storage::StoragePointer& storage) { + if (level != _minMipLevelAvailable - 1) { + qWarning() << "Invalid level to be stored, expected: " << (_minMipLevelAvailable - 1) << ", got: " << level << " " << _filename.c_str(); + return; + } + + if (level >= _ktxDescriptor->images.size()) { + throw std::runtime_error("Invalid level"); + } + + if (storage->size() != _ktxDescriptor->images[level]._imageSize) { + qWarning() << "Invalid image size: " << storage->size() << ", expected: " << _ktxDescriptor->images[level]._imageSize + << ", level: " << level << ", filename: " << QString::fromStdString(_filename); + return; + } + + auto file = maybeOpenFile(); + + auto imageData = file->mutableData(); + imageData += ktx::KTX_HEADER_SIZE + _ktxDescriptor->header.bytesOfKeyValueData + _ktxDescriptor->images[level]._imageOffset; + imageData += ktx::IMAGE_SIZE_WIDTH; + + { + std::lock_guard lock { _cacheFileWriteMutex }; + + if (level != _minMipLevelAvailable - 1) { + qWarning() << "Invalid level to be stored"; + return; + } + + memcpy(imageData, storage->data(), _ktxDescriptor->images[level]._imageSize); + _minMipLevelAvailable = level; + if (_offsetToMinMipKV > 0) { + auto minMipKeyData = file->mutableData() + ktx::KTX_HEADER_SIZE + _offsetToMinMipKV; + memcpy(minMipKeyData, (void*)&_minMipLevelAvailable, 1); + } + } +} + +void KtxStorage::assignMipFaceData(uint16 level, uint8 face, const storage::StoragePointer& storage) { + throw std::runtime_error("Invalid call"); +} + void Texture::setKtxBacking(const std::string& filename) { // Check the KTX file for validity before using it as backing storage { @@ -86,6 +229,7 @@ void Texture::setKtxBacking(const std::string& filename) { setStorage(newBacking); } + ktx::KTXUniquePointer Texture::serialize(const Texture& texture) { ktx::Header header; @@ -141,19 +285,21 @@ ktx::KTXUniquePointer Texture::serialize(const Texture& texture) { header.numberOfMipmapLevels = texture.getNumMips(); ktx::Images images; + uint32_t imageOffset = 0; for (uint32_t level = 0; level < header.numberOfMipmapLevels; level++) { auto mip = texture.accessStoredMipFace(level); if (mip) { if (numFaces == 1) { - images.emplace_back(ktx::Image((uint32_t)mip->getSize(), 0, mip->readData())); + images.emplace_back(ktx::Image(imageOffset, (uint32_t)mip->getSize(), 0, mip->readData())); } else { ktx::Image::FaceBytes cubeFaces(Texture::CUBE_FACE_COUNT); cubeFaces[0] = mip->readData(); for (uint32_t face = 1; face < Texture::CUBE_FACE_COUNT; face++) { cubeFaces[face] = texture.accessStoredMipFace(level, face)->readData(); } - images.emplace_back(ktx::Image((uint32_t)mip->getSize(), 0, cubeFaces)); + images.emplace_back(ktx::Image(imageOffset, (uint32_t)mip->getSize(), 0, cubeFaces)); } + imageOffset += static_cast(mip->getSize()) + ktx::IMAGE_SIZE_WIDTH; } } @@ -161,13 +307,18 @@ ktx::KTXUniquePointer Texture::serialize(const Texture& texture) { keyval._samplerDesc = texture.getSampler().getDesc(); keyval._usage = texture.getUsage(); keyval._usageType = texture.getUsageType(); - ktx::KeyValues keyValues; - keyValues.emplace_back(ktx::KeyValue(GPUKTXPayload::KEY, sizeof(GPUKTXPayload), (ktx::Byte*) &keyval)); + Byte keyvalPayload[GPUKTXPayload::SIZE]; + keyval.serialize(keyvalPayload); + + ktx::KeyValues keyValues; + keyValues.emplace_back(GPUKTXPayload::KEY, (uint32)GPUKTXPayload::SIZE, (ktx::Byte*) &keyvalPayload); - static const std::string SOURCE_HASH_KEY = "hifi.sourceHash"; auto hash = texture.sourceHash(); if (!hash.empty()) { - keyValues.emplace_back(ktx::KeyValue(SOURCE_HASH_KEY, static_cast(hash.size()), (ktx::Byte*) hash.c_str())); + // the sourceHash is an std::string in hex + // we use QByteArray to take the hex and turn it into the smaller binary representation (16 bytes) + auto binaryHash = QByteArray::fromHex(QByteArray::fromStdString(hash)); + keyValues.emplace_back(SOURCE_HASH_KEY, static_cast(binaryHash.size()), (ktx::Byte*) binaryHash.data()); } auto ktxBuffer = ktx::KTX::create(header, images, keyValues); @@ -200,13 +351,17 @@ ktx::KTXUniquePointer Texture::serialize(const Texture& texture) { return ktxBuffer; } -TexturePointer Texture::unserialize(const std::string& ktxfile, TextureUsageType usageType, Usage usage, const Sampler::Desc& sampler) { - std::unique_ptr ktxPointer = ktx::KTX::create(ktx::StoragePointer { new storage::FileStorage(ktxfile.c_str()) }); +TexturePointer Texture::unserialize(const std::string& ktxfile) { + std::unique_ptr ktxPointer = ktx::KTX::create(std::make_shared(ktxfile.c_str())); if (!ktxPointer) { return nullptr; } ktx::KTXDescriptor descriptor { ktxPointer->toDescriptor() }; + return unserialize(ktxfile, ktxPointer->toDescriptor()); +} + +TexturePointer Texture::unserialize(const std::string& ktxfile, const ktx::KTXDescriptor& descriptor) { const auto& header = descriptor.header; Format mipFormat = Format::COLOR_BGRA_32; @@ -232,28 +387,28 @@ TexturePointer Texture::unserialize(const std::string& ktxfile, TextureUsageType type = TEX_3D; } - - // If found, use the GPUKTXPayload gpuktxKeyValue; - bool isGPUKTXPayload = GPUKTXPayload::findInKeyValues(descriptor.keyValues, gpuktxKeyValue); + if (!GPUKTXPayload::findInKeyValues(descriptor.keyValues, gpuktxKeyValue)) { + qCWarning(gpulogging) << "Could not find GPUKTX key values."; + return TexturePointer(); + } - auto tex = Texture::create( (isGPUKTXPayload ? gpuktxKeyValue._usageType : usageType), - type, - texelFormat, - header.getPixelWidth(), - header.getPixelHeight(), - header.getPixelDepth(), - 1, // num Samples - header.getNumberOfSlices(), - header.getNumberOfLevels(), - (isGPUKTXPayload ? gpuktxKeyValue._samplerDesc : sampler)); - - tex->setUsage((isGPUKTXPayload ? gpuktxKeyValue._usage : usage)); + auto texture = create(gpuktxKeyValue._usageType, + type, + texelFormat, + header.getPixelWidth(), + header.getPixelHeight(), + header.getPixelDepth(), + 1, // num Samples + header.getNumberOfSlices(), + header.getNumberOfLevels(), + gpuktxKeyValue._samplerDesc); + texture->setUsage(gpuktxKeyValue._usage); // Assing the mips availables - tex->setStoredMipFormat(mipFormat); - tex->setKtxBacking(ktxfile); - return tex; + texture->setStoredMipFormat(mipFormat); + texture->setKtxBacking(ktxfile); + return texture; } bool Texture::evalKTXFormat(const Element& mipFormat, const Element& texelFormat, ktx::Header& header) { diff --git a/libraries/ktx/src/ktx/KTX.cpp b/libraries/ktx/src/ktx/KTX.cpp index 6fca39788b..38bb91e5c2 100644 --- a/libraries/ktx/src/ktx/KTX.cpp +++ b/libraries/ktx/src/ktx/KTX.cpp @@ -12,6 +12,7 @@ #include "KTX.h" #include //min max and more +#include using namespace ktx; @@ -34,30 +35,80 @@ uint32_t Header::evalMaxDimension() const { return std::max(getPixelWidth(), std::max(getPixelHeight(), getPixelDepth())); } -uint32_t Header::evalPixelWidth(uint32_t level) const { - return std::max(getPixelWidth() >> level, 1U); +uint32_t Header::evalPixelOrBlockWidth(uint32_t level) const { + auto pixelWidth = std::max(getPixelWidth() >> level, 1U); + if (getGLType() == GLType::COMPRESSED_TYPE) { + return (pixelWidth + 3) / 4; + } else { + return pixelWidth; + } } -uint32_t Header::evalPixelHeight(uint32_t level) const { - return std::max(getPixelHeight() >> level, 1U); +uint32_t Header::evalPixelOrBlockHeight(uint32_t level) const { + auto pixelWidth = std::max(getPixelHeight() >> level, 1U); + if (getGLType() == GLType::COMPRESSED_TYPE) { + auto format = getGLInternaFormat_Compressed(); + switch (format) { + case GLInternalFormat_Compressed::COMPRESSED_SRGB_S3TC_DXT1_EXT: // BC1 + case GLInternalFormat_Compressed::COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT: // BC1A + case GLInternalFormat_Compressed::COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT: // BC3 + case GLInternalFormat_Compressed::COMPRESSED_RED_RGTC1: // BC4 + case GLInternalFormat_Compressed::COMPRESSED_RG_RGTC2: // BC5 + return (pixelWidth + 3) / 4; + default: + throw std::runtime_error("Unknown format"); + } + } else { + return pixelWidth; + } } -uint32_t Header::evalPixelDepth(uint32_t level) const { +uint32_t Header::evalPixelOrBlockDepth(uint32_t level) const { return std::max(getPixelDepth() >> level, 1U); } -size_t Header::evalPixelSize() const { - return glTypeSize; // Really we should generate the size from the FOrmat etc +size_t Header::evalPixelOrBlockSize() const { + if (getGLType() == GLType::COMPRESSED_TYPE) { + auto format = getGLInternaFormat_Compressed(); + if (format == GLInternalFormat_Compressed::COMPRESSED_SRGB_S3TC_DXT1_EXT) { + return 8; + } else if (format == GLInternalFormat_Compressed::COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT) { + return 8; + } else if (format == GLInternalFormat_Compressed::COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT) { + return 16; + } else if (format == GLInternalFormat_Compressed::COMPRESSED_RED_RGTC1) { + return 8; + } else if (format == GLInternalFormat_Compressed::COMPRESSED_RG_RGTC2) { + return 16; + } + } else { + auto baseFormat = getGLBaseInternalFormat(); + if (baseFormat == GLBaseInternalFormat::RED) { + return 1; + } else if (baseFormat == GLBaseInternalFormat::RG) { + return 2; + } else if (baseFormat == GLBaseInternalFormat::RGB) { + return 3; + } else if (baseFormat == GLBaseInternalFormat::RGBA) { + return 4; + } + } + + qWarning() << "Unknown ktx format: " << glFormat << " " << glBaseInternalFormat << " " << glInternalFormat; + return 0; } size_t Header::evalRowSize(uint32_t level) const { - auto pixWidth = evalPixelWidth(level); - auto pixSize = evalPixelSize(); + auto pixWidth = evalPixelOrBlockWidth(level); + auto pixSize = evalPixelOrBlockSize(); + if (pixSize == 0) { + return 0; + } auto netSize = pixWidth * pixSize; auto padding = evalPadding(netSize); return netSize + padding; } size_t Header::evalFaceSize(uint32_t level) const { - auto pixHeight = evalPixelHeight(level); - auto pixDepth = evalPixelDepth(level); + auto pixHeight = evalPixelOrBlockHeight(level); + auto pixDepth = evalPixelOrBlockDepth(level); auto rowSize = evalRowSize(level); return pixDepth * pixHeight * rowSize; } @@ -71,6 +122,47 @@ size_t Header::evalImageSize(uint32_t level) const { } +size_t KTXDescriptor::getValueOffsetForKey(const std::string& key) const { + size_t offset { 0 }; + for (auto& kv : keyValues) { + if (kv._key == key) { + return offset + ktx::KV_SIZE_WIDTH + kv._key.size() + 1; + } + offset += kv.serializedByteSize(); + } + return 0; +} + +ImageDescriptors Header::generateImageDescriptors() const { + ImageDescriptors descriptors; + + size_t imageOffset = 0; + for (uint32_t level = 0; level < numberOfMipmapLevels; ++level) { + auto imageSize = static_cast(evalImageSize(level)); + if (imageSize == 0) { + return ImageDescriptors(); + } + ImageHeader header { + numberOfFaces == NUM_CUBEMAPFACES, + imageOffset, + imageSize, + 0 + }; + + imageOffset += (imageSize * numberOfFaces) + ktx::IMAGE_SIZE_WIDTH; + + ImageHeader::FaceOffsets offsets; + // TODO Add correct face offsets + for (uint32_t i = 0; i < numberOfFaces; ++i) { + offsets.push_back(0); + } + descriptors.push_back(ImageDescriptor(header, offsets)); + } + + return descriptors; +} + + KeyValue::KeyValue(const std::string& key, uint32_t valueByteSize, const Byte* value) : _byteSize((uint32_t) key.size() + 1 + valueByteSize), // keyString size + '\0' ending char + the value size _key(key), @@ -209,4 +301,4 @@ KTXDescriptor KTX::toDescriptor() const { KTX::KTX(const StoragePointer& storage, const Header& header, const KeyValues& keyValues, const Images& images) : _header(header), _storage(storage), _keyValues(keyValues), _images(images) { -} \ No newline at end of file +} diff --git a/libraries/ktx/src/ktx/KTX.h b/libraries/ktx/src/ktx/KTX.h index 043de573ed..e8fa019a07 100644 --- a/libraries/ktx/src/ktx/KTX.h +++ b/libraries/ktx/src/ktx/KTX.h @@ -71,6 +71,8 @@ end namespace ktx { const uint32_t PACKING_SIZE { sizeof(uint32_t) }; + const std::string HIFI_MIN_POPULATED_MIP_KEY{ "hifi.minMip" }; + using Byte = uint8_t; enum class GLType : uint32_t { @@ -292,6 +294,11 @@ namespace ktx { using Storage = storage::Storage; using StoragePointer = std::shared_ptr; + struct ImageDescriptor; + using ImageDescriptors = std::vector; + + bool checkIdentifier(const Byte* identifier); + // Header struct Header { static const size_t IDENTIFIER_LENGTH = 12; @@ -330,11 +337,11 @@ namespace ktx { uint32_t getNumberOfLevels() const { return (numberOfMipmapLevels ? numberOfMipmapLevels : 1); } uint32_t evalMaxDimension() const; - uint32_t evalPixelWidth(uint32_t level) const; - uint32_t evalPixelHeight(uint32_t level) const; - uint32_t evalPixelDepth(uint32_t level) const; + uint32_t evalPixelOrBlockWidth(uint32_t level) const; + uint32_t evalPixelOrBlockHeight(uint32_t level) const; + uint32_t evalPixelOrBlockDepth(uint32_t level) const; - size_t evalPixelSize() const; + size_t evalPixelOrBlockSize() const; size_t evalRowSize(uint32_t level) const; size_t evalFaceSize(uint32_t level) const; size_t evalImageSize(uint32_t level) const; @@ -378,7 +385,12 @@ namespace ktx { void setCube(uint32_t width, uint32_t height) { setDimensions(width, height, 0, 0, NUM_CUBEMAPFACES); } void setCubeArray(uint32_t width, uint32_t height, uint32_t numSlices) { setDimensions(width, height, 0, (numSlices > 0 ? numSlices : 1), NUM_CUBEMAPFACES); } + ImageDescriptors generateImageDescriptors() const; }; + static const size_t KTX_HEADER_SIZE = 64; + static_assert(sizeof(Header) == KTX_HEADER_SIZE, "KTX Header size is static and should not change from the spec"); + static const size_t KV_SIZE_WIDTH = 4; // Number of bytes for keyAndValueByteSize + static const size_t IMAGE_SIZE_WIDTH = 4; // Number of bytes for imageSize // Key Values struct KeyValue { @@ -405,12 +417,17 @@ namespace ktx { struct ImageHeader { using FaceOffsets = std::vector; using FaceBytes = std::vector; + + // This is the byte offset from the _start_ of the image region. For example, level 0 + // will have a byte offset of 0. const uint32_t _numFaces; + const size_t _imageOffset; const uint32_t _imageSize; const uint32_t _faceSize; const uint32_t _padding; - ImageHeader(bool cube, uint32_t imageSize, uint32_t padding) : + ImageHeader(bool cube, size_t imageOffset, uint32_t imageSize, uint32_t padding) : _numFaces(cube ? NUM_CUBEMAPFACES : 1), + _imageOffset(imageOffset), _imageSize(imageSize * _numFaces), _faceSize(imageSize), _padding(padding) { @@ -419,22 +436,22 @@ namespace ktx { struct Image; + // Image without the image data itself struct ImageDescriptor : public ImageHeader { const FaceOffsets _faceOffsets; ImageDescriptor(const ImageHeader& header, const FaceOffsets& offsets) : ImageHeader(header), _faceOffsets(offsets) {} Image toImage(const ktx::StoragePointer& storage) const; }; - using ImageDescriptors = std::vector; - + // Image with the image data itself struct Image : public ImageHeader { FaceBytes _faceBytes; Image(const ImageHeader& header, const FaceBytes& faces) : ImageHeader(header), _faceBytes(faces) {} - Image(uint32_t imageSize, uint32_t padding, const Byte* bytes) : - ImageHeader(false, imageSize, padding), + Image(size_t imageOffset, uint32_t imageSize, uint32_t padding, const Byte* bytes) : + ImageHeader(false, imageOffset, imageSize, padding), _faceBytes(1, bytes) {} - Image(uint32_t pageSize, uint32_t padding, const FaceBytes& cubeFaceBytes) : - ImageHeader(true, pageSize, padding) + Image(size_t imageOffset, uint32_t pageSize, uint32_t padding, const FaceBytes& cubeFaceBytes) : + ImageHeader(true, imageOffset, pageSize, padding) { if (cubeFaceBytes.size() == NUM_CUBEMAPFACES) { _faceBytes = cubeFaceBytes; @@ -457,6 +474,7 @@ namespace ktx { const ImageDescriptors images; size_t getMipFaceTexelsSize(uint16_t mip = 0, uint8_t face = 0) const; size_t getMipFaceTexelsOffset(uint16_t mip = 0, uint8_t face = 0) const; + size_t getValueOffsetForKey(const std::string& key) const; }; class KTX { @@ -471,6 +489,7 @@ namespace ktx { // This path allocate the Storage where to store header, keyvalues and copy mips // Then COPY all the data static std::unique_ptr create(const Header& header, const Images& images, const KeyValues& keyValues = KeyValues()); + static std::unique_ptr createBare(const Header& header, const KeyValues& keyValues = KeyValues()); // Instead of creating a full Copy of the src data in a KTX object, the write serialization can be performed with the // following two functions @@ -484,10 +503,14 @@ namespace ktx { // // This is exactly what is done in the create function static size_t evalStorageSize(const Header& header, const Images& images, const KeyValues& keyValues = KeyValues()); + static size_t evalStorageSize(const Header& header, const ImageDescriptors& images, const KeyValues& keyValues = KeyValues()); static size_t write(Byte* destBytes, size_t destByteSize, const Header& header, const Images& images, const KeyValues& keyValues = KeyValues()); + static size_t writeWithoutImages(Byte* destBytes, size_t destByteSize, const Header& header, const ImageDescriptors& descriptors, const KeyValues& keyValues = KeyValues()); static size_t writeKeyValues(Byte* destBytes, size_t destByteSize, const KeyValues& keyValues); static Images writeImages(Byte* destBytes, size_t destByteSize, const Images& images); + void writeMipData(uint16_t level, const Byte* sourceBytes, size_t source_size); + // Parse a block of memory and create a KTX object from it static std::unique_ptr create(const StoragePointer& src); diff --git a/libraries/ktx/src/ktx/Reader.cpp b/libraries/ktx/src/ktx/Reader.cpp index bf72faeba5..b22f262e85 100644 --- a/libraries/ktx/src/ktx/Reader.cpp +++ b/libraries/ktx/src/ktx/Reader.cpp @@ -144,6 +144,7 @@ namespace ktx { while ((currentPtr - srcBytes) + sizeof(uint32_t) <= (srcSize)) { // Grab the imageSize coming up + uint32_t imageOffset = currentPtr - srcBytes; size_t imageSize = *reinterpret_cast(currentPtr); currentPtr += sizeof(uint32_t); @@ -158,10 +159,10 @@ namespace ktx { faces[face] = currentPtr; currentPtr += faceSize; } - images.emplace_back(Image((uint32_t) faceSize, padding, faces)); + images.emplace_back(Image(imageOffset, (uint32_t) faceSize, padding, faces)); currentPtr += padding; } else { - images.emplace_back(Image((uint32_t) imageSize, padding, currentPtr)); + images.emplace_back(Image(imageOffset, (uint32_t) imageSize, padding, currentPtr)); currentPtr += imageSize + padding; } } else { diff --git a/libraries/ktx/src/ktx/Writer.cpp b/libraries/ktx/src/ktx/Writer.cpp index 25b363d31b..4226b8fa84 100644 --- a/libraries/ktx/src/ktx/Writer.cpp +++ b/libraries/ktx/src/ktx/Writer.cpp @@ -40,6 +40,24 @@ namespace ktx { return create(storagePointer); } + std::unique_ptr KTX::createBare(const Header& header, const KeyValues& keyValues) { + auto descriptors = header.generateImageDescriptors(); + + Byte minMip = header.numberOfMipmapLevels; + auto newKeyValues = keyValues; + newKeyValues.emplace_back(KeyValue(HIFI_MIN_POPULATED_MIP_KEY, sizeof(Byte), &minMip)); + + StoragePointer storagePointer; + { + auto storageSize = ktx::KTX::evalStorageSize(header, descriptors, newKeyValues); + auto memoryStorage = new storage::MemoryStorage(storageSize); + qDebug() << "Memory storage size is: " << storageSize; + ktx::KTX::writeWithoutImages(memoryStorage->data(), memoryStorage->size(), header, descriptors, newKeyValues); + storagePointer.reset(memoryStorage); + } + return create(storagePointer); + } + size_t KTX::evalStorageSize(const Header& header, const Images& images, const KeyValues& keyValues) { size_t storageSize = sizeof(Header); @@ -59,6 +77,25 @@ namespace ktx { return storageSize; } + size_t KTX::evalStorageSize(const Header& header, const ImageDescriptors& imageDescriptors, const KeyValues& keyValues) { + size_t storageSize = sizeof(Header); + + if (!keyValues.empty()) { + size_t keyValuesSize = KeyValue::serializedKeyValuesByteSize(keyValues); + storageSize += keyValuesSize; + } + + auto numMips = header.getNumberOfLevels(); + for (uint32_t l = 0; l < numMips; l++) { + if (imageDescriptors.size() > l) { + storageSize += sizeof(uint32_t); + storageSize += imageDescriptors[l]._imageSize; + storageSize += Header::evalPadding(imageDescriptors[l]._imageSize); + } + } + return storageSize; + } + size_t KTX::write(Byte* destBytes, size_t destByteSize, const Header& header, const Images& srcImages, const KeyValues& keyValues) { // Check again that we have enough destination capacity if (!destBytes || (destByteSize < evalStorageSize(header, srcImages, keyValues))) { @@ -87,6 +124,43 @@ namespace ktx { return destByteSize; } + size_t KTX::writeWithoutImages(Byte* destBytes, size_t destByteSize, const Header& header, const ImageDescriptors& descriptors, const KeyValues& keyValues) { + // Check again that we have enough destination capacity + if (!destBytes || (destByteSize < evalStorageSize(header, descriptors, keyValues))) { + return 0; + } + + auto currentDestPtr = destBytes; + // Header + auto destHeader = reinterpret_cast(currentDestPtr); + memcpy(currentDestPtr, &header, sizeof(Header)); + currentDestPtr += sizeof(Header); + + + // KeyValues + if (!keyValues.empty()) { + destHeader->bytesOfKeyValueData = (uint32_t) writeKeyValues(currentDestPtr, destByteSize - sizeof(Header), keyValues); + } else { + // Make sure the header contains the right bytesOfKeyValueData size + destHeader->bytesOfKeyValueData = 0; + } + currentDestPtr += destHeader->bytesOfKeyValueData; + + for (size_t i = 0; i < descriptors.size(); ++i) { + auto ptr = reinterpret_cast(currentDestPtr); + *ptr = descriptors[i]._imageSize; + ptr++; +#ifdef DEBUG + for (size_t k = 0; k < descriptors[i]._imageSize/4; k++) { + *(ptr + k) = 0xFFFFFFFF; + } +#endif + currentDestPtr += descriptors[i]._imageSize + sizeof(uint32_t); + } + + return destByteSize; + } + uint32_t KeyValue::writeSerializedKeyAndValue(Byte* destBytes, uint32_t destByteSize, const KeyValue& keyval) { uint32_t keyvalSize = keyval.serializedByteSize(); if (keyvalSize > destByteSize) { @@ -134,6 +208,7 @@ namespace ktx { for (uint32_t l = 0; l < srcImages.size(); l++) { if (currentDataSize + sizeof(uint32_t) < allocatedImagesDataSize) { + uint32_t imageOffset = currentPtr - destBytes; size_t imageSize = srcImages[l]._imageSize; *(reinterpret_cast (currentPtr)) = (uint32_t) imageSize; currentPtr += sizeof(uint32_t); @@ -146,7 +221,7 @@ namespace ktx { // Single face vs cubes if (srcImages[l]._numFaces == 1) { memcpy(currentPtr, srcImages[l]._faceBytes[0], imageSize); - destImages.emplace_back(Image((uint32_t) imageSize, padding, currentPtr)); + destImages.emplace_back(Image(imageOffset, (uint32_t) imageSize, padding, currentPtr)); currentPtr += imageSize; } else { Image::FaceBytes faceBytes(NUM_CUBEMAPFACES); @@ -156,7 +231,7 @@ namespace ktx { faceBytes[face] = currentPtr; currentPtr += faceSize; } - destImages.emplace_back(Image(faceSize, padding, faceBytes)); + destImages.emplace_back(Image(imageOffset, faceSize, padding, faceBytes)); } currentPtr += padding; @@ -168,4 +243,11 @@ namespace ktx { return destImages; } + void KTX::writeMipData(uint16_t level, const Byte* sourceBytes, size_t sourceSize) { + Q_ASSERT(level > 0); + Q_ASSERT(level < _images.size()); + Q_ASSERT(sourceSize == _images[level]._imageSize); + + //memcpy(reinterpret_cast(_images[level]._faceBytes[0]), sourceBytes, sourceSize); + } } diff --git a/libraries/model-networking/src/model-networking/TextureCache.cpp b/libraries/model-networking/src/model-networking/TextureCache.cpp index f6e256bb06..55704236e3 100644 --- a/libraries/model-networking/src/model-networking/TextureCache.cpp +++ b/libraries/model-networking/src/model-networking/TextureCache.cpp @@ -30,8 +30,6 @@ #include -#include - #include #include @@ -40,6 +38,7 @@ #include #include +#include "NetworkLogging.h" #include "ModelNetworkingLogging.h" #include #include @@ -51,6 +50,8 @@ Q_LOGGING_CATEGORY(trace_resource_parse_image_ktx, "trace.resource.parse.image.k const std::string TextureCache::KTX_DIRNAME { "ktx_cache" }; const std::string TextureCache::KTX_EXT { "ktx" }; +static const int SKYBOX_LOAD_PRIORITY { 10 }; // Make sure skybox loads first + TextureCache::TextureCache() : _ktxCache(KTX_DIRNAME, KTX_EXT) { setUnusedResourceCacheSize(0); @@ -260,15 +261,20 @@ QSharedPointer TextureCache::createResource(const QUrl& url, const QSh auto content = textureExtra ? textureExtra->content : QByteArray(); auto maxNumPixels = textureExtra ? textureExtra->maxNumPixels : ABSOLUTE_MAX_TEXTURE_NUM_PIXELS; NetworkTexture* texture = new NetworkTexture(url, type, content, maxNumPixels); + if (type == image::TextureUsage::CUBE_TEXTURE) { + texture->setLoadPriority(this, SKYBOX_LOAD_PRIORITY); + } return QSharedPointer(texture, &Resource::deleter); } NetworkTexture::NetworkTexture(const QUrl& url, image::TextureUsage::Type type, const QByteArray& content, int maxNumPixels) : Resource(url), _type(type), + _sourceIsKTX(url.path().endsWith(".ktx")), _maxNumPixels(maxNumPixels) { _textureSource = std::make_shared(); + _lowestRequestedMipLevel = 0; if (!url.isValid()) { _loaded = true; @@ -324,11 +330,333 @@ private: int _maxNumPixels; }; +const uint16_t NetworkTexture::NULL_MIP_LEVEL = std::numeric_limits::max(); +void NetworkTexture::makeRequest() { + if (!_sourceIsKTX) { + Resource::makeRequest(); + return; + } + + // We special-handle ktx requests to run 2 concurrent requests right off the bat + PROFILE_ASYNC_BEGIN(resource, "Resource:" + getType(), QString::number(_requestID), { { "url", _url.toString() }, { "activeURL", _activeUrl.toString() } }); + + if (_ktxResourceState == PENDING_INITIAL_LOAD) { + _ktxResourceState = LOADING_INITIAL_DATA; + + // Add a fragment to the base url so we can identify the section of the ktx being requested when debugging + // The actual requested url is _activeUrl and will not contain the fragment + _url.setFragment("head"); + _ktxHeaderRequest = ResourceManager::createResourceRequest(this, _activeUrl); + + if (!_ktxHeaderRequest) { + qCDebug(networking).noquote() << "Failed to get request for" << _url.toDisplayString(); + + PROFILE_ASYNC_END(resource, "Resource:" + getType(), QString::number(_requestID)); + return; + } + + ByteRange range; + range.fromInclusive = 0; + range.toExclusive = 1000; + _ktxHeaderRequest->setByteRange(range); + + emit loading(); + + connect(_ktxHeaderRequest, &ResourceRequest::progress, this, &NetworkTexture::ktxHeaderRequestProgress); + connect(_ktxHeaderRequest, &ResourceRequest::finished, this, &NetworkTexture::ktxHeaderRequestFinished); + + _bytesReceived = _bytesTotal = _bytes = 0; + + _ktxHeaderRequest->send(); + + startMipRangeRequest(NULL_MIP_LEVEL, NULL_MIP_LEVEL); + } else if (_ktxResourceState == PENDING_MIP_REQUEST) { + if (_lowestKnownPopulatedMip > 0) { + _ktxResourceState = REQUESTING_MIP; + + // Add a fragment to the base url so we can identify the section of the ktx being requested when debugging + // The actual requested url is _activeUrl and will not contain the fragment + uint16_t nextMip = _lowestKnownPopulatedMip - 1; + _url.setFragment(QString::number(nextMip)); + startMipRangeRequest(nextMip, nextMip); + } + } else { + qWarning(networking) << "NetworkTexture::makeRequest() called while not in a valid state: " << _ktxResourceState; + } + +} + +void NetworkTexture::startRequestForNextMipLevel() { + if (_lowestKnownPopulatedMip == 0) { + qWarning(networking) << "Requesting next mip level but all have been fulfilled: " << _lowestKnownPopulatedMip + << " " << _textureSource->getGPUTexture()->minAvailableMipLevel() << " " << _url; + return; + } + + if (_ktxResourceState == WAITING_FOR_MIP_REQUEST) { + _ktxResourceState = PENDING_MIP_REQUEST; + + init(); + setLoadPriority(this, -static_cast(_originalKtxDescriptor->header.numberOfMipmapLevels) + _lowestKnownPopulatedMip); + _url.setFragment(QString::number(_lowestKnownPopulatedMip - 1)); + TextureCache::attemptRequest(_self); + } +} + +// Load mips in the range [low, high] (inclusive) +void NetworkTexture::startMipRangeRequest(uint16_t low, uint16_t high) { + if (_ktxMipRequest) { + return; + } + + bool isHighMipRequest = low == NULL_MIP_LEVEL && high == NULL_MIP_LEVEL; + + _ktxMipRequest = ResourceManager::createResourceRequest(this, _activeUrl); + + if (!_ktxMipRequest) { + qCWarning(networking).noquote() << "Failed to get request for" << _url.toDisplayString(); + + PROFILE_ASYNC_END(resource, "Resource:" + getType(), QString::number(_requestID)); + return; + } + + _ktxMipLevelRangeInFlight = { low, high }; + if (isHighMipRequest) { + static const int HIGH_MIP_MAX_SIZE = 5516; + // This is a special case where we load the high 7 mips + ByteRange range; + range.fromInclusive = -HIGH_MIP_MAX_SIZE; + _ktxMipRequest->setByteRange(range); + } else { + ByteRange range; + range.fromInclusive = ktx::KTX_HEADER_SIZE + _originalKtxDescriptor->header.bytesOfKeyValueData + + _originalKtxDescriptor->images[low]._imageOffset + ktx::IMAGE_SIZE_WIDTH; + range.toExclusive = ktx::KTX_HEADER_SIZE + _originalKtxDescriptor->header.bytesOfKeyValueData + + _originalKtxDescriptor->images[high + 1]._imageOffset; + _ktxMipRequest->setByteRange(range); + } + + connect(_ktxMipRequest, &ResourceRequest::progress, this, &NetworkTexture::ktxMipRequestProgress); + connect(_ktxMipRequest, &ResourceRequest::finished, this, &NetworkTexture::ktxMipRequestFinished); + + _ktxMipRequest->send(); +} + + +void NetworkTexture::ktxHeaderRequestFinished() { + Q_ASSERT(_ktxResourceState == LOADING_INITIAL_DATA); + + _ktxHeaderRequestFinished = true; + maybeHandleFinishedInitialLoad(); +} + +void NetworkTexture::ktxMipRequestFinished() { + Q_ASSERT(_ktxResourceState == LOADING_INITIAL_DATA || _ktxResourceState == REQUESTING_MIP); + + if (_ktxResourceState == LOADING_INITIAL_DATA) { + _ktxHighMipRequestFinished = true; + maybeHandleFinishedInitialLoad(); + } else if (_ktxResourceState == REQUESTING_MIP) { + Q_ASSERT(_ktxMipLevelRangeInFlight.first != NULL_MIP_LEVEL); + TextureCache::requestCompleted(_self); + + if (_ktxMipRequest->getResult() == ResourceRequest::Success) { + Q_ASSERT(_ktxMipLevelRangeInFlight.second - _ktxMipLevelRangeInFlight.first == 0); + + auto texture = _textureSource->getGPUTexture(); + if (texture) { + texture->assignStoredMip(_ktxMipLevelRangeInFlight.first, + _ktxMipRequest->getData().size(), reinterpret_cast(_ktxMipRequest->getData().data())); + _lowestKnownPopulatedMip = _textureSource->getGPUTexture()->minAvailableMipLevel(); + } + else { + qWarning(networking) << "Trying to update mips but texture is null"; + } + finishedLoading(true); + _ktxResourceState = WAITING_FOR_MIP_REQUEST; + } + else { + finishedLoading(false); + if (handleFailedRequest(_ktxMipRequest->getResult())) { + _ktxResourceState = PENDING_MIP_REQUEST; + } + else { + qWarning(networking) << "Failed to load mip: " << _url; + _ktxResourceState = FAILED_TO_LOAD; + } + } + + _ktxMipRequest->deleteLater(); + _ktxMipRequest = nullptr; + + if (_ktxResourceState == WAITING_FOR_MIP_REQUEST && _lowestRequestedMipLevel < _lowestKnownPopulatedMip) { + startRequestForNextMipLevel(); + } + } + else { + qWarning() << "Mip request finished in an unexpected state: " << _ktxResourceState; + } +} + +// This is called when the header or top mips have been loaded +void NetworkTexture::maybeHandleFinishedInitialLoad() { + Q_ASSERT(_ktxResourceState == LOADING_INITIAL_DATA); + + if (_ktxHeaderRequestFinished && _ktxHighMipRequestFinished) { + + TextureCache::requestCompleted(_self); + + if (_ktxHeaderRequest->getResult() != ResourceRequest::Success || _ktxMipRequest->getResult() != ResourceRequest::Success) { + if (handleFailedRequest(_ktxMipRequest->getResult())) { + _ktxResourceState = PENDING_INITIAL_LOAD; + } + else { + _ktxResourceState = FAILED_TO_LOAD; + } + + _ktxHeaderRequest->deleteLater(); + _ktxHeaderRequest = nullptr; + _ktxMipRequest->deleteLater(); + _ktxMipRequest = nullptr; + } else { + // create ktx... + auto ktxHeaderData = _ktxHeaderRequest->getData(); + auto ktxHighMipData = _ktxMipRequest->getData(); + + auto header = reinterpret_cast(ktxHeaderData.data()); + + if (!ktx::checkIdentifier(header->identifier)) { + qWarning() << "Cannot load " << _url << ", invalid header identifier"; + _ktxResourceState = FAILED_TO_LOAD; + finishedLoading(false); + return; + } + + auto kvSize = header->bytesOfKeyValueData; + if (kvSize > (ktxHeaderData.size() - ktx::KTX_HEADER_SIZE)) { + qWarning() << "Cannot load " << _url << ", did not receive all kv data with initial request"; + _ktxResourceState = FAILED_TO_LOAD; + finishedLoading(false); + return; + } + + auto keyValues = ktx::KTX::parseKeyValues(header->bytesOfKeyValueData, reinterpret_cast(ktxHeaderData.data()) + ktx::KTX_HEADER_SIZE); + + auto imageDescriptors = header->generateImageDescriptors(); + if (imageDescriptors.size() == 0) { + qWarning(networking) << "Failed to process ktx file " << _url; + _ktxResourceState = FAILED_TO_LOAD; + finishedLoading(false); + } + _originalKtxDescriptor.reset(new ktx::KTXDescriptor(*header, keyValues, imageDescriptors)); + + // Create bare ktx in memory + auto found = std::find_if(keyValues.begin(), keyValues.end(), [](const ktx::KeyValue& val) -> bool { + return val._key.compare(gpu::SOURCE_HASH_KEY) == 0; + }); + std::string filename; + std::string hash; + if (found == keyValues.end() || found->_value.size() != gpu::SOURCE_HASH_BYTES) { + qWarning("Invalid source hash key found, bailing"); + _ktxResourceState = FAILED_TO_LOAD; + finishedLoading(false); + return; + } else { + // at this point the source hash is in binary 16-byte form + // and we need it in a hexadecimal string + auto binaryHash = QByteArray(reinterpret_cast(found->_value.data()), gpu::SOURCE_HASH_BYTES); + hash = filename = binaryHash.toHex().toStdString(); + } + + auto textureCache = DependencyManager::get(); + + gpu::TexturePointer texture = textureCache->getTextureByHash(hash); + + if (!texture) { + KTXFilePointer ktxFile = textureCache->_ktxCache.getFile(hash); + if (ktxFile) { + texture = gpu::Texture::unserialize(ktxFile->getFilepath()); + if (texture) { + texture = textureCache->cacheTextureByHash(hash, texture); + } + } + } + + if (!texture) { + + auto memKtx = ktx::KTX::createBare(*header, keyValues); + if (!memKtx) { + qWarning() << " Ktx could not be created, bailing"; + finishedLoading(false); + return; + } + + // Move ktx to file + const char* data = reinterpret_cast(memKtx->_storage->data()); + size_t length = memKtx->_storage->size(); + KTXFilePointer file; + auto& ktxCache = textureCache->_ktxCache; + if (!memKtx || !(file = ktxCache.writeFile(data, KTXCache::Metadata(filename, length)))) { + qCWarning(modelnetworking) << _url << " failed to write cache file"; + _ktxResourceState = FAILED_TO_LOAD; + finishedLoading(false); + return; + } else { + _file = file; + } + + auto newKtxDescriptor = memKtx->toDescriptor(); + + texture = gpu::Texture::unserialize(_file->getFilepath(), newKtxDescriptor); + texture->setKtxBacking(file->getFilepath()); + texture->setSource(filename); + + auto& images = _originalKtxDescriptor->images; + size_t imageSizeRemaining = ktxHighMipData.size(); + uint8_t* ktxData = reinterpret_cast(ktxHighMipData.data()); + ktxData += ktxHighMipData.size(); + // TODO Move image offset calculation to ktx ImageDescriptor + for (int level = static_cast(images.size()) - 1; level >= 0; --level) { + auto& image = images[level]; + if (image._imageSize > imageSizeRemaining) { + break; + } + ktxData -= image._imageSize; + texture->assignStoredMip(static_cast(level), image._imageSize, ktxData); + ktxData -= ktx::IMAGE_SIZE_WIDTH; + imageSizeRemaining -= (image._imageSize + ktx::IMAGE_SIZE_WIDTH); + } + + // We replace the texture with the one stored in the cache. This deals with the possible race condition of two different + // images with the same hash being loaded concurrently. Only one of them will make it into the cache by hash first and will + // be the winner + texture = textureCache->cacheTextureByHash(filename, texture); + } + + _lowestKnownPopulatedMip = texture->minAvailableMipLevel(); + + _ktxResourceState = WAITING_FOR_MIP_REQUEST; + setImage(texture, header->getPixelWidth(), header->getPixelHeight()); + + _ktxHeaderRequest->deleteLater(); + _ktxHeaderRequest = nullptr; + _ktxMipRequest->deleteLater(); + _ktxMipRequest = nullptr; + } + startRequestForNextMipLevel(); + } +} + void NetworkTexture::downloadFinished(const QByteArray& data) { loadContent(data); } void NetworkTexture::loadContent(const QByteArray& content) { + if (_sourceIsKTX) { + assert(false); + return; + } + QThreadPool::globalInstance()->start(new ImageReader(_self, _url, content, _maxNumPixels)); } @@ -451,6 +779,7 @@ void ImageReader::read() { if (texture && textureCache) { auto memKtx = gpu::Texture::serialize(*texture); + // Move the texture into a memory mapped file if (memKtx) { const char* data = reinterpret_cast(memKtx->_storage->data()); size_t length = memKtx->_storage->size(); diff --git a/libraries/model-networking/src/model-networking/TextureCache.h b/libraries/model-networking/src/model-networking/TextureCache.h index d0600c3dce..1e61b9ecee 100644 --- a/libraries/model-networking/src/model-networking/TextureCache.h +++ b/libraries/model-networking/src/model-networking/TextureCache.h @@ -23,6 +23,7 @@ #include #include #include +#include #include "KTXCache.h" @@ -59,7 +60,16 @@ public: signals: void networkTextureCreated(const QWeakPointer& self); +public slots: + void ktxHeaderRequestProgress(uint64_t bytesReceived, uint64_t bytesTotal) { } + void ktxHeaderRequestFinished(); + + void ktxMipRequestProgress(uint64_t bytesReceived, uint64_t bytesTotal) { } + void ktxMipRequestFinished(); + protected: + void makeRequest() override; + virtual bool isCacheable() const override { return _loaded; } virtual void downloadFinished(const QByteArray& data) override; @@ -67,12 +77,51 @@ protected: Q_INVOKABLE void loadContent(const QByteArray& content); Q_INVOKABLE void setImage(gpu::TexturePointer texture, int originalWidth, int originalHeight); + void startRequestForNextMipLevel(); + + void startMipRangeRequest(uint16_t low, uint16_t high); + void maybeHandleFinishedInitialLoad(); + private: friend class KTXReader; friend class ImageReader; image::TextureUsage::Type _type; + + static const uint16_t NULL_MIP_LEVEL; + enum KTXResourceState { + PENDING_INITIAL_LOAD = 0, + LOADING_INITIAL_DATA, // Loading KTX Header + Low Resolution Mips + WAITING_FOR_MIP_REQUEST, // Waiting for the gpu layer to report that it needs higher resolution mips + PENDING_MIP_REQUEST, // We have added ourselves to the ResourceCache queue + REQUESTING_MIP, // We have a mip in flight + FAILED_TO_LOAD + }; + + bool _sourceIsKTX { false }; + KTXResourceState _ktxResourceState { PENDING_INITIAL_LOAD }; + + // TODO Can this be removed? KTXFilePointer _file; + + // The current mips that are currently being requested w/ _ktxMipRequest + std::pair _ktxMipLevelRangeInFlight{ NULL_MIP_LEVEL, NULL_MIP_LEVEL }; + + ResourceRequest* _ktxHeaderRequest { nullptr }; + ResourceRequest* _ktxMipRequest { nullptr }; + bool _ktxHeaderRequestFinished{ false }; + bool _ktxHighMipRequestFinished{ false }; + + uint16_t _lowestRequestedMipLevel { NULL_MIP_LEVEL }; + uint16_t _lowestKnownPopulatedMip { NULL_MIP_LEVEL }; + + // This is a copy of the original KTX descriptor from the source url. + // We need this because the KTX that will be cached will likely include extra data + // in its key/value data, and so will not match up with the original, causing + // mip offsets to change. + ktx::KTXDescriptorPointer _originalKtxDescriptor; + + int _originalWidth { 0 }; int _originalHeight { 0 }; int _width { 0 }; diff --git a/libraries/networking/src/AssetClient.cpp b/libraries/networking/src/AssetClient.cpp index 37b1af0996..15e0b8c9b5 100644 --- a/libraries/networking/src/AssetClient.cpp +++ b/libraries/networking/src/AssetClient.cpp @@ -67,7 +67,6 @@ void AssetClient::init() { } } - void AssetClient::cacheInfoRequest(QObject* reciever, QString slot) { if (QThread::currentThread() != thread()) { QMetaObject::invokeMethod(this, "cacheInfoRequest", Qt::QueuedConnection, @@ -182,8 +181,8 @@ RenameMappingRequest* AssetClient::createRenameMappingRequest(const AssetPath& o return request; } -AssetRequest* AssetClient::createRequest(const AssetHash& hash) { - auto request = new AssetRequest(hash); +AssetRequest* AssetClient::createRequest(const AssetHash& hash, const ByteRange& byteRange) { + auto request = new AssetRequest(hash, byteRange); // Move to the AssetClient thread in case we are not currently on that thread (which will usually be the case) request->moveToThread(thread()); diff --git a/libraries/networking/src/AssetClient.h b/libraries/networking/src/AssetClient.h index c0d58cd8e6..6f9cc3cd31 100644 --- a/libraries/networking/src/AssetClient.h +++ b/libraries/networking/src/AssetClient.h @@ -21,6 +21,7 @@ #include #include "AssetUtils.h" +#include "ByteRange.h" #include "ClientServerUtils.h" #include "LimitedNodeList.h" #include "Node.h" @@ -55,7 +56,7 @@ public: Q_INVOKABLE DeleteMappingsRequest* createDeleteMappingsRequest(const AssetPathList& paths); Q_INVOKABLE SetMappingRequest* createSetMappingRequest(const AssetPath& path, const AssetHash& hash); Q_INVOKABLE RenameMappingRequest* createRenameMappingRequest(const AssetPath& oldPath, const AssetPath& newPath); - Q_INVOKABLE AssetRequest* createRequest(const AssetHash& hash); + Q_INVOKABLE AssetRequest* createRequest(const AssetHash& hash, const ByteRange& byteRange = ByteRange()); Q_INVOKABLE AssetUpload* createUpload(const QString& filename); Q_INVOKABLE AssetUpload* createUpload(const QByteArray& data); diff --git a/libraries/networking/src/AssetRequest.cpp b/libraries/networking/src/AssetRequest.cpp index 8d663933ca..341c3b45da 100644 --- a/libraries/networking/src/AssetRequest.cpp +++ b/libraries/networking/src/AssetRequest.cpp @@ -23,10 +23,12 @@ static int requestID = 0; -AssetRequest::AssetRequest(const QString& hash) : +AssetRequest::AssetRequest(const QString& hash, const ByteRange& byteRange) : _requestID(++requestID), - _hash(hash) + _hash(hash), + _byteRange(byteRange) { + } AssetRequest::~AssetRequest() { @@ -34,9 +36,6 @@ AssetRequest::~AssetRequest() { if (_assetRequestID) { assetClient->cancelGetAssetRequest(_assetRequestID); } - if (_assetInfoRequestID) { - assetClient->cancelGetAssetInfoRequest(_assetInfoRequestID); - } } void AssetRequest::start() { @@ -62,108 +61,74 @@ void AssetRequest::start() { // Try to load from cache _data = loadFromCache(getUrl()); if (!_data.isNull()) { - _info.hash = _hash; - _info.size = _data.size(); _error = NoError; _state = Finished; emit finished(this); return; } - - _state = WaitingForInfo; - + + _state = WaitingForData; + auto assetClient = DependencyManager::get(); - _assetInfoRequestID = assetClient->getAssetInfo(_hash, - [this](bool responseReceived, AssetServerError serverError, AssetInfo info) { + auto that = QPointer(this); // Used to track the request's lifetime + auto hash = _hash; - _assetInfoRequestID = INVALID_MESSAGE_ID; + _assetRequestID = assetClient->getAsset(_hash, _byteRange.fromInclusive, _byteRange.toExclusive, + [this, that, hash](bool responseReceived, AssetServerError serverError, const QByteArray& data) { - _info = info; + if (!that) { + qCWarning(asset_client) << "Got reply for dead asset request " << hash << "- error code" << _error; + // If the request is dead, return + return; + } + _assetRequestID = INVALID_MESSAGE_ID; if (!responseReceived) { _error = NetworkError; } else if (serverError != AssetServerError::NoError) { - switch(serverError) { + switch (serverError) { case AssetServerError::AssetNotFound: _error = NotFound; break; + case AssetServerError::InvalidByteRange: + _error = InvalidByteRange; + break; default: _error = UnknownError; break; } - } + } else { + if (_byteRange.isSet()) { + // we had a byte range, the size of the data does not match what we expect, so we return an error + if (data.size() != _byteRange.size()) { + _error = SizeVerificationFailed; + } + } else if (hashData(data).toHex() != _hash) { + // the hash of the received data does not match what we expect, so we return an error + _error = HashVerificationFailed; + } + if (_error == NoError) { + _data = data; + _totalReceived += data.size(); + emit progress(_totalReceived, data.size()); + + saveToCache(getUrl(), data); + } + } + if (_error != NoError) { - qCWarning(asset_client) << "Got error retrieving asset info for" << _hash; - _state = Finished; - emit finished(this); - + qCWarning(asset_client) << "Got error retrieving asset" << _hash << "- error code" << _error; + } + + _state = Finished; + emit finished(this); + }, [this, that](qint64 totalReceived, qint64 total) { + if (!that) { + // If the request is dead, return return; } - - _state = WaitingForData; - _data.resize(info.size); - - qCDebug(asset_client) << "Got size of " << _hash << " : " << info.size << " bytes"; - - int start = 0, end = _info.size; - - auto assetClient = DependencyManager::get(); - auto that = QPointer(this); // Used to track the request's lifetime - auto hash = _hash; - _assetRequestID = assetClient->getAsset(_hash, start, end, - [this, that, hash, start, end](bool responseReceived, AssetServerError serverError, const QByteArray& data) { - if (!that) { - qCWarning(asset_client) << "Got reply for dead asset request " << hash << "- error code" << _error; - // If the request is dead, return - return; - } - _assetRequestID = INVALID_MESSAGE_ID; - - if (!responseReceived) { - _error = NetworkError; - } else if (serverError != AssetServerError::NoError) { - switch (serverError) { - case AssetServerError::AssetNotFound: - _error = NotFound; - break; - case AssetServerError::InvalidByteRange: - _error = InvalidByteRange; - break; - default: - _error = UnknownError; - break; - } - } else { - Q_ASSERT(data.size() == (end - start)); - - // we need to check the hash of the received data to make sure it matches what we expect - if (hashData(data).toHex() == _hash) { - memcpy(_data.data() + start, data.constData(), data.size()); - _totalReceived += data.size(); - emit progress(_totalReceived, _info.size); - - saveToCache(getUrl(), data); - } else { - // hash doesn't match - we have an error - _error = HashVerificationFailed; - } - - } - - if (_error != NoError) { - qCWarning(asset_client) << "Got error retrieving asset" << _hash << "- error code" << _error; - } - - _state = Finished; - emit finished(this); - }, [this, that](qint64 totalReceived, qint64 total) { - if (!that) { - // If the request is dead, return - return; - } - emit progress(totalReceived, total); - }); + emit progress(totalReceived, total); }); } diff --git a/libraries/networking/src/AssetRequest.h b/libraries/networking/src/AssetRequest.h index 1632a55336..b808ae0ca6 100644 --- a/libraries/networking/src/AssetRequest.h +++ b/libraries/networking/src/AssetRequest.h @@ -17,15 +17,15 @@ #include #include "AssetClient.h" - #include "AssetUtils.h" +#include "ByteRange.h" + class AssetRequest : public QObject { Q_OBJECT public: enum State { NotStarted = 0, - WaitingForInfo, WaitingForData, Finished }; @@ -36,11 +36,12 @@ public: InvalidByteRange, InvalidHash, HashVerificationFailed, + SizeVerificationFailed, NetworkError, UnknownError }; - AssetRequest(const QString& hash); + AssetRequest(const QString& hash, const ByteRange& byteRange = ByteRange()); virtual ~AssetRequest() override; Q_INVOKABLE void start(); @@ -59,13 +60,12 @@ private: int _requestID; State _state = NotStarted; Error _error = NoError; - AssetInfo _info; uint64_t _totalReceived { 0 }; QString _hash; QByteArray _data; int _numPendingRequests { 0 }; MessageID _assetRequestID { INVALID_MESSAGE_ID }; - MessageID _assetInfoRequestID { INVALID_MESSAGE_ID }; + const ByteRange _byteRange; }; #endif diff --git a/libraries/networking/src/AssetResourceRequest.cpp b/libraries/networking/src/AssetResourceRequest.cpp index 540fb4767f..092e0ccb3d 100644 --- a/libraries/networking/src/AssetResourceRequest.cpp +++ b/libraries/networking/src/AssetResourceRequest.cpp @@ -114,7 +114,7 @@ void AssetResourceRequest::requestMappingForPath(const AssetPath& path) { void AssetResourceRequest::requestHash(const AssetHash& hash) { // Make request to atp auto assetClient = DependencyManager::get(); - _assetRequest = assetClient->createRequest(hash); + _assetRequest = assetClient->createRequest(hash, _byteRange); connect(_assetRequest, &AssetRequest::progress, this, &AssetResourceRequest::onDownloadProgress); connect(_assetRequest, &AssetRequest::finished, this, [this](AssetRequest* req) { diff --git a/libraries/networking/src/ByteRange.h b/libraries/networking/src/ByteRange.h new file mode 100644 index 0000000000..6fd3559154 --- /dev/null +++ b/libraries/networking/src/ByteRange.h @@ -0,0 +1,53 @@ +// +// ByteRange.h +// libraries/networking/src +// +// Created by Stephen Birarda on 4/17/17. +// 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 +// + +#ifndef hifi_ByteRange_h +#define hifi_ByteRange_h + +struct ByteRange { + int64_t fromInclusive { 0 }; + int64_t toExclusive { 0 }; + + bool isSet() const { return fromInclusive < 0 || fromInclusive < toExclusive; } + int64_t size() const { return toExclusive - fromInclusive; } + + // byte ranges are invalid if: + // (1) the toExclusive of the range is negative + // (2) the toExclusive of the range is less than the fromInclusive, and isn't zero + // (3) the fromExclusive of the range is negative, and the toExclusive isn't zero + bool isValid() { + return toExclusive >= 0 + && (toExclusive >= fromInclusive || toExclusive == 0) + && (fromInclusive >= 0 || toExclusive == 0); + } + + void fixupRange(int64_t fileSize) { + if (!isSet()) { + // if the byte range is not set, force it to be from 0 to the end of the file + fromInclusive = 0; + toExclusive = fileSize; + } + + if (fromInclusive > 0 && toExclusive == 0) { + // we have a left side of the range that is non-zero + // if the RHS of the range is zero, set it to the end of the file now + toExclusive = fileSize; + } else if (-fromInclusive >= fileSize) { + // we have a negative range that is equal or greater than the full size of the file + // so we just set this to be a range across the entire file, from 0 + fromInclusive = 0; + toExclusive = fileSize; + } + } +}; + + +#endif // hifi_ByteRange_h diff --git a/libraries/networking/src/FileResourceRequest.cpp b/libraries/networking/src/FileResourceRequest.cpp index 58a2074103..1e549e5fa3 100644 --- a/libraries/networking/src/FileResourceRequest.cpp +++ b/libraries/networking/src/FileResourceRequest.cpp @@ -11,6 +11,8 @@ #include "FileResourceRequest.h" +#include + #include void FileResourceRequest::doSend() { @@ -21,17 +23,39 @@ void FileResourceRequest::doSend() { if (filename.isEmpty()) { filename = _url.toString(); } - - QFile file(filename); - if (file.exists()) { - if (file.open(QFile::ReadOnly)) { - _data = file.readAll(); - _result = ResourceRequest::Success; - } else { - _result = ResourceRequest::AccessDenied; - } + + if (!_byteRange.isValid()) { + _result = ResourceRequest::InvalidByteRange; } else { - _result = ResourceRequest::NotFound; + QFile file(filename); + if (file.exists()) { + if (file.open(QFile::ReadOnly)) { + + if (file.size() < _byteRange.fromInclusive || file.size() < _byteRange.toExclusive) { + _result = ResourceRequest::InvalidByteRange; + } else { + // fix it up based on the known size of the file + _byteRange.fixupRange(file.size()); + + if (_byteRange.fromInclusive >= 0) { + // this is a positive byte range, simply skip to that part of the file and read from there + file.seek(_byteRange.fromInclusive); + _data = file.read(_byteRange.size()); + } else { + // this is a negative byte range, we'll need to grab data from the end of the file first + file.seek(file.size() + _byteRange.fromInclusive); + _data = file.read(_byteRange.size()); + } + + _result = ResourceRequest::Success; + } + + } else { + _result = ResourceRequest::AccessDenied; + } + } else { + _result = ResourceRequest::NotFound; + } } _state = Finished; diff --git a/libraries/networking/src/HTTPResourceRequest.cpp b/libraries/networking/src/HTTPResourceRequest.cpp index 85da5de5b8..c6a4b93e51 100644 --- a/libraries/networking/src/HTTPResourceRequest.cpp +++ b/libraries/networking/src/HTTPResourceRequest.cpp @@ -59,6 +59,18 @@ void HTTPResourceRequest::doSend() { networkRequest.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); } + if (_byteRange.isSet()) { + QString byteRange; + if (_byteRange.fromInclusive < 0) { + byteRange = QString("bytes=%1").arg(_byteRange.fromInclusive); + } else { + // HTTP byte ranges are inclusive on the `to` end: [from, to] + byteRange = QString("bytes=%1-%2").arg(_byteRange.fromInclusive).arg(_byteRange.toExclusive - 1); + } + networkRequest.setRawHeader("Range", byteRange.toLatin1()); + } + networkRequest.setAttribute(QNetworkRequest::HttpPipeliningAllowedAttribute, false); + _reply = NetworkAccessManager::getInstance().get(networkRequest); connect(_reply, &QNetworkReply::finished, this, &HTTPResourceRequest::onRequestFinished); @@ -72,12 +84,60 @@ void HTTPResourceRequest::onRequestFinished() { Q_ASSERT(_reply); cleanupTimer(); - + + // Content-Range headers have the form: + // + // Content-Range: -/ + // Content-Range: -/* + // Content-Range: */ + // + auto parseContentRangeHeader = [](QString contentRangeHeader) -> std::pair { + auto unitRangeParts = contentRangeHeader.split(' '); + if (unitRangeParts.size() != 2) { + return { false, 0 }; + } + + auto rangeSizeParts = unitRangeParts[1].split('/'); + if (rangeSizeParts.size() != 2) { + return { false, 0 }; + } + + auto sizeStr = rangeSizeParts[1]; + if (sizeStr == "*") { + return { true, 0 }; + } else { + bool ok; + auto size = sizeStr.toLong(&ok); + return { ok, size }; + } + }; + switch(_reply->error()) { case QNetworkReply::NoError: _data = _reply->readAll(); _loadedFromCache = _reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool(); _result = Success; + + if (_byteRange.isSet()) { + auto statusCode = _reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statusCode == 206) { + _rangeRequestSuccessful = true; + auto contentRangeHeader = _reply->rawHeader("Content-Range"); + bool success; + uint64_t size; + std::tie(success, size) = parseContentRangeHeader(contentRangeHeader); + if (success) { + _totalSizeOfResource = size; + } else { + qWarning(networking) << "Error parsing content-range header: " << contentRangeHeader; + _totalSizeOfResource = 0; + } + } else { + _rangeRequestSuccessful = false; + _totalSizeOfResource = _data.size(); + } + } + break; case QNetworkReply::TimeoutError: @@ -130,6 +190,7 @@ void HTTPResourceRequest::onDownloadProgress(qint64 bytesReceived, qint64 bytesT } void HTTPResourceRequest::onTimeout() { + qDebug() << "Timeout: " << _url << ":" << _reply->isFinished(); Q_ASSERT(_state == InProgress); _reply->disconnect(this); _reply->abort(); diff --git a/libraries/networking/src/NetworkAccessManager.cpp b/libraries/networking/src/NetworkAccessManager.cpp index 73096825e0..fd356c3e94 100644 --- a/libraries/networking/src/NetworkAccessManager.cpp +++ b/libraries/networking/src/NetworkAccessManager.cpp @@ -13,6 +13,7 @@ #include "AtpReply.h" #include "NetworkAccessManager.h" +#include QThreadStorage networkAccessManagers; diff --git a/libraries/networking/src/ResourceCache.cpp b/libraries/networking/src/ResourceCache.cpp index 4031ff8bf7..7ae75b9538 100644 --- a/libraries/networking/src/ResourceCache.cpp +++ b/libraries/networking/src/ResourceCache.cpp @@ -474,8 +474,9 @@ int ResourceCache::getLoadingRequestCount() { bool ResourceCache::attemptRequest(QSharedPointer resource) { Q_ASSERT(!resource.isNull()); - auto sharedItems = DependencyManager::get(); + + auto sharedItems = DependencyManager::get(); if (_requestsActive >= _requestLimit) { // wait until a slot becomes available sharedItems->appendPendingRequest(resource); @@ -490,6 +491,7 @@ bool ResourceCache::attemptRequest(QSharedPointer resource) { void ResourceCache::requestCompleted(QWeakPointer resource) { auto sharedItems = DependencyManager::get(); + sharedItems->removeRequest(resource); --_requestsActive; @@ -553,6 +555,10 @@ void Resource::clearLoadPriority(const QPointer& owner) { } float Resource::getLoadPriority() { + if (_loadPriorities.size() == 0) { + return 0; + } + float highestPriority = -FLT_MAX; for (QHash, float>::iterator it = _loadPriorities.begin(); it != _loadPriorities.end(); ) { if (it.key().isNull()) { @@ -637,12 +643,12 @@ void Resource::attemptRequest() { void Resource::finishedLoading(bool success) { if (success) { qCDebug(networking).noquote() << "Finished loading:" << _url.toDisplayString(); + _loadPriorities.clear(); _loaded = true; } else { qCDebug(networking).noquote() << "Failed to load:" << _url.toDisplayString(); _failedToLoad = true; } - _loadPriorities.clear(); emit finished(success); } @@ -676,6 +682,8 @@ void Resource::makeRequest() { return; } + _request->setByteRange(_requestByteRange); + qCDebug(resourceLog).noquote() << "Starting request for:" << _url.toDisplayString(); emit loading(); @@ -722,34 +730,7 @@ void Resource::handleReplyFinished() { emit loaded(data); downloadFinished(data); } else { - switch (result) { - case ResourceRequest::Result::Timeout: { - qCDebug(networking) << "Timed out loading" << _url << "received" << _bytesReceived << "total" << _bytesTotal; - // Fall through to other cases - } - case ResourceRequest::Result::ServerUnavailable: { - // retry with increasing delays - const int BASE_DELAY_MS = 1000; - if (_attempts++ < MAX_ATTEMPTS) { - auto waitTime = BASE_DELAY_MS * (int)pow(2.0, _attempts); - - qCDebug(networking).noquote() << "Server unavailable for" << _url << "- may retry in" << waitTime << "ms" - << "if resource is still needed"; - - QTimer::singleShot(waitTime, this, &Resource::attemptRequest); - break; - } - // fall through to final failure - } - default: { - qCDebug(networking) << "Error loading " << _url; - auto error = (result == ResourceRequest::Timeout) ? QNetworkReply::TimeoutError - : QNetworkReply::UnknownNetworkError; - emit failed(error); - finishedLoading(false); - break; - } - } + handleFailedRequest(result); } _request->disconnect(this); @@ -757,6 +738,41 @@ void Resource::handleReplyFinished() { _request = nullptr; } +bool Resource::handleFailedRequest(ResourceRequest::Result result) { + bool willRetry = false; + switch (result) { + case ResourceRequest::Result::Timeout: { + qCDebug(networking) << "Timed out loading" << _url << "received" << _bytesReceived << "total" << _bytesTotal; + // Fall through to other cases + } + case ResourceRequest::Result::ServerUnavailable: { + // retry with increasing delays + const int BASE_DELAY_MS = 1000; + if (_attempts++ < MAX_ATTEMPTS) { + auto waitTime = BASE_DELAY_MS * (int)pow(2.0, _attempts); + + qCDebug(networking).noquote() << "Server unavailable for" << _url << "- may retry in" << waitTime << "ms" + << "if resource is still needed"; + + QTimer::singleShot(waitTime, this, &Resource::attemptRequest); + willRetry = true; + break; + } + // fall through to final failure + } + default: { + qCDebug(networking) << "Error loading " << _url; + auto error = (result == ResourceRequest::Timeout) ? QNetworkReply::TimeoutError + : QNetworkReply::UnknownNetworkError; + emit failed(error); + willRetry = false; + finishedLoading(false); + break; + } + } + return willRetry; +} + uint qHash(const QPointer& value, uint seed) { return qHash(value.data(), seed); } diff --git a/libraries/networking/src/ResourceCache.h b/libraries/networking/src/ResourceCache.h index 53ccd2c386..3a28c6c313 100644 --- a/libraries/networking/src/ResourceCache.h +++ b/libraries/networking/src/ResourceCache.h @@ -424,6 +424,11 @@ protected slots: protected: virtual void init(); + /// Called by ResourceCache to begin loading this Resource. + /// This method can be overriden to provide custom request functionality. If this is done, + /// downloadFinished and ResourceCache::requestCompleted must be called. + virtual void makeRequest(); + /// Checks whether the resource is cacheable. virtual bool isCacheable() const { return true; } @@ -440,16 +445,27 @@ protected: Q_INVOKABLE void allReferencesCleared(); + /// Return true if the resource will be retried + bool handleFailedRequest(ResourceRequest::Result result); + QUrl _url; QUrl _activeUrl; + ByteRange _requestByteRange; bool _startedLoading = false; bool _failedToLoad = false; bool _loaded = false; QHash, float> _loadPriorities; QWeakPointer _self; QPointer _cache; - -private slots: + + qint64 _bytesReceived{ 0 }; + qint64 _bytesTotal{ 0 }; + qint64 _bytes{ 0 }; + + int _requestID; + ResourceRequest* _request{ nullptr }; + +public slots: void handleDownloadProgress(uint64_t bytesReceived, uint64_t bytesTotal); void handleReplyFinished(); @@ -459,20 +475,14 @@ private: void setLRUKey(int lruKey) { _lruKey = lruKey; } - void makeRequest(); void retry(); void reinsert(); bool isInScript() const { return _isInScript; } void setInScript(bool isInScript) { _isInScript = isInScript; } - int _requestID; - ResourceRequest* _request{ nullptr }; int _lruKey{ 0 }; QTimer* _replyTimer{ nullptr }; - qint64 _bytesReceived{ 0 }; - qint64 _bytesTotal{ 0 }; - qint64 _bytes{ 0 }; int _attempts{ 0 }; bool _isInScript{ false }; }; diff --git a/libraries/networking/src/ResourceManager.h b/libraries/networking/src/ResourceManager.h index 162892abaf..d193c39cae 100644 --- a/libraries/networking/src/ResourceManager.h +++ b/libraries/networking/src/ResourceManager.h @@ -26,6 +26,7 @@ const QString URL_SCHEME_ATP = "atp"; class ResourceManager { public: + static void setUrlPrefixOverride(const QString& prefix, const QString& replacement); static QString normalizeURL(const QString& urlString); static QUrl normalizeURL(const QUrl& url); diff --git a/libraries/networking/src/ResourceRequest.h b/libraries/networking/src/ResourceRequest.h index 7588fca046..ef40cb3455 100644 --- a/libraries/networking/src/ResourceRequest.h +++ b/libraries/networking/src/ResourceRequest.h @@ -17,6 +17,8 @@ #include +#include "ByteRange.h" + class ResourceRequest : public QObject { Q_OBJECT public: @@ -35,6 +37,7 @@ public: Timeout, ServerUnavailable, AccessDenied, + InvalidByteRange, InvalidURL, NotFound }; @@ -46,8 +49,11 @@ public: QString getResultString() const; QUrl getUrl() const { return _url; } bool loadedFromCache() const { return _loadedFromCache; } + bool getRangeRequestSuccessful() const { return _rangeRequestSuccessful; } + bool getTotalSizeOfResource() const { return _totalSizeOfResource; } void setCacheEnabled(bool value) { _cacheEnabled = value; } + void setByteRange(ByteRange byteRange) { _byteRange = byteRange; } public slots: void send(); @@ -65,6 +71,9 @@ protected: QByteArray _data; bool _cacheEnabled { true }; bool _loadedFromCache { false }; + ByteRange _byteRange; + bool _rangeRequestSuccessful { false }; + uint64_t _totalSizeOfResource { 0 }; }; #endif diff --git a/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp b/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp index 9c29e87f16..ff69363570 100644 --- a/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp +++ b/libraries/networking/src/UserActivityLoggerScriptingInterface.cpp @@ -13,28 +13,28 @@ #include "UserActivityLogger.h" void UserActivityLoggerScriptingInterface::enabledEdit() { - logAction("enabled_edit"); + doLogAction("enabled_edit"); } void UserActivityLoggerScriptingInterface::openedTablet(bool visibleToOthers) { - logAction("opened_tablet", { { "visible_to_others", visibleToOthers } }); + doLogAction("opened_tablet", { { "visible_to_others", visibleToOthers } }); } void UserActivityLoggerScriptingInterface::closedTablet() { - logAction("closed_tablet"); + doLogAction("closed_tablet"); } void UserActivityLoggerScriptingInterface::openedMarketplace() { - logAction("opened_marketplace"); + doLogAction("opened_marketplace"); } void UserActivityLoggerScriptingInterface::toggledAway(bool isAway) { - logAction("toggled_away", { { "is_away", isAway } }); + doLogAction("toggled_away", { { "is_away", isAway } }); } void UserActivityLoggerScriptingInterface::tutorialProgress( QString stepName, int stepNumber, float secondsToComplete, float tutorialElapsedTime, QString tutorialRunID, int tutorialVersion, QString controllerType) { - logAction("tutorial_progress", { + doLogAction("tutorial_progress", { { "tutorial_run_id", tutorialRunID }, { "tutorial_version", tutorialVersion }, { "step", stepName }, @@ -52,11 +52,11 @@ void UserActivityLoggerScriptingInterface::palAction(QString action, QString tar if (target.length() > 0) { payload["target"] = target; } - logAction("pal_activity", payload); + doLogAction("pal_activity", payload); } void UserActivityLoggerScriptingInterface::palOpened(float secondsOpened) { - logAction("pal_opened", { + doLogAction("pal_opened", { { "seconds_opened", secondsOpened } }); } @@ -68,10 +68,14 @@ void UserActivityLoggerScriptingInterface::makeUserConnection(QString otherID, b if (detailsString.length() > 0) { payload["details"] = detailsString; } - logAction("makeUserConnection", payload); + doLogAction("makeUserConnection", payload); } -void UserActivityLoggerScriptingInterface::logAction(QString action, QJsonObject details) { +void UserActivityLoggerScriptingInterface::logAction(QString action, QVariantMap details) { + doLogAction(action, QJsonObject::fromVariantMap(details)); +} + +void UserActivityLoggerScriptingInterface::doLogAction(QString action, QJsonObject details) { QMetaObject::invokeMethod(&UserActivityLogger::getInstance(), "logAction", Q_ARG(QString, action), Q_ARG(QJsonObject, details)); diff --git a/libraries/networking/src/UserActivityLoggerScriptingInterface.h b/libraries/networking/src/UserActivityLoggerScriptingInterface.h index b68c7beb95..b141e930f2 100644 --- a/libraries/networking/src/UserActivityLoggerScriptingInterface.h +++ b/libraries/networking/src/UserActivityLoggerScriptingInterface.h @@ -29,9 +29,10 @@ public: float tutorialElapsedTime, QString tutorialRunID = "", int tutorialVersion = 0, QString controllerType = ""); Q_INVOKABLE void palAction(QString action, QString target); Q_INVOKABLE void palOpened(float secondsOpen); - Q_INVOKABLE void makeUserConnection(QString otherUser, bool success, QString details=""); + Q_INVOKABLE void makeUserConnection(QString otherUser, bool success, QString details = ""); + Q_INVOKABLE void logAction(QString action, QVariantMap details = QVariantMap{}); private: - void logAction(QString action, QJsonObject details = {}); + void doLogAction(QString action, QJsonObject details = {}); }; #endif // hifi_UserActivityLoggerScriptingInterface_h diff --git a/libraries/networking/src/udt/PacketHeaders.cpp b/libraries/networking/src/udt/PacketHeaders.cpp index 3ad4dbf28d..863f1bfda6 100644 --- a/libraries/networking/src/udt/PacketHeaders.cpp +++ b/libraries/networking/src/udt/PacketHeaders.cpp @@ -64,7 +64,7 @@ PacketVersion versionForPacketType(PacketType packetType) { case PacketType::AssetGetInfo: case PacketType::AssetGet: case PacketType::AssetUpload: - return static_cast(AssetServerPacketVersion::VegasCongestionControl); + return static_cast(AssetServerPacketVersion::RangeRequestSupport); case PacketType::NodeIgnoreRequest: return 18; // Introduction of node ignore request (which replaced an unused packet tpye) diff --git a/libraries/networking/src/udt/PacketHeaders.h b/libraries/networking/src/udt/PacketHeaders.h index 074876862f..87af3513b5 100644 --- a/libraries/networking/src/udt/PacketHeaders.h +++ b/libraries/networking/src/udt/PacketHeaders.h @@ -214,7 +214,8 @@ enum class EntityQueryPacketVersion: PacketVersion { }; enum class AssetServerPacketVersion: PacketVersion { - VegasCongestionControl = 19 + VegasCongestionControl = 19, + RangeRequestSupport }; enum class AvatarMixerPacketVersion : PacketVersion { diff --git a/libraries/procedural/CMakeLists.txt b/libraries/procedural/CMakeLists.txt index 8c66442c59..3ebd0f3d14 100644 --- a/libraries/procedural/CMakeLists.txt +++ b/libraries/procedural/CMakeLists.txt @@ -1,5 +1,5 @@ set(TARGET_NAME procedural) AUTOSCRIBE_SHADER_LIB(gpu model) setup_hifi_library() -link_hifi_libraries(shared gpu gpu-gl networking model model-networking image) +link_hifi_libraries(shared gpu gpu-gl networking model model-networking ktx image) diff --git a/libraries/render-utils/src/MeshPartPayload.cpp b/libraries/render-utils/src/MeshPartPayload.cpp index 51ce0fffa7..2e08420073 100644 --- a/libraries/render-utils/src/MeshPartPayload.cpp +++ b/libraries/render-utils/src/MeshPartPayload.cpp @@ -118,7 +118,7 @@ void MeshPartPayload::drawCall(gpu::Batch& batch) const { batch.drawIndexed(gpu::TRIANGLES, _drawPart._numIndices, _drawPart._startIndex); } -void MeshPartPayload::bindMesh(gpu::Batch& batch) const { +void MeshPartPayload::bindMesh(gpu::Batch& batch) { batch.setIndexBuffer(gpu::UINT32, (_drawMesh->getIndexBuffer()._buffer), 0); batch.setInputFormat((_drawMesh->getVertexFormat())); @@ -255,7 +255,7 @@ void MeshPartPayload::bindTransform(gpu::Batch& batch, const ShapePipeline::Loca } -void MeshPartPayload::render(RenderArgs* args) const { +void MeshPartPayload::render(RenderArgs* args) { PerformanceTimer perfTimer("MeshPartPayload::render"); gpu::Batch& batch = *(args->_batch); @@ -485,7 +485,7 @@ ShapeKey ModelMeshPartPayload::getShapeKey() const { return builder.build(); } -void ModelMeshPartPayload::bindMesh(gpu::Batch& batch) const { +void ModelMeshPartPayload::bindMesh(gpu::Batch& batch) { if (!_isBlendShaped) { batch.setIndexBuffer(gpu::UINT32, (_drawMesh->getIndexBuffer()._buffer), 0); @@ -517,7 +517,7 @@ void ModelMeshPartPayload::bindTransform(gpu::Batch& batch, const ShapePipeline: batch.setModelTransform(_transform); } -float ModelMeshPartPayload::computeFadeAlpha() const { +float ModelMeshPartPayload::computeFadeAlpha() { if (_fadeState == FADE_WAITING_TO_START) { return 0.0f; } @@ -536,7 +536,7 @@ float ModelMeshPartPayload::computeFadeAlpha() const { return Interpolate::simpleNonLinearBlend(fadeAlpha); } -void ModelMeshPartPayload::render(RenderArgs* args) const { +void ModelMeshPartPayload::render(RenderArgs* args) { PerformanceTimer perfTimer("ModelMeshPartPayload::render"); if (!_model->addedToScene() || !_model->isVisible()) { @@ -544,7 +544,7 @@ void ModelMeshPartPayload::render(RenderArgs* args) const { } if (_fadeState == FADE_WAITING_TO_START) { - if (_model->isLoaded() && _model->getGeometry()->areTexturesLoaded()) { + if (_model->isLoaded()) { if (EntityItem::getEntitiesShouldFadeFunction()()) { _fadeStartTime = usecTimestampNow(); _fadeState = FADE_IN_PROGRESS; @@ -557,6 +557,11 @@ void ModelMeshPartPayload::render(RenderArgs* args) const { } } + if (_materialNeedsUpdate && _model->getGeometry()->areTexturesLoaded()) { + _model->setRenderItemsNeedUpdate(); + _materialNeedsUpdate = false; + } + if (!args) { return; } diff --git a/libraries/render-utils/src/MeshPartPayload.h b/libraries/render-utils/src/MeshPartPayload.h index ef74011c40..11d1bbf6a7 100644 --- a/libraries/render-utils/src/MeshPartPayload.h +++ b/libraries/render-utils/src/MeshPartPayload.h @@ -46,11 +46,11 @@ public: virtual render::ItemKey getKey() const; virtual render::Item::Bound getBound() const; virtual render::ShapeKey getShapeKey() const; // shape interface - virtual void render(RenderArgs* args) const; + virtual void render(RenderArgs* args); // ModelMeshPartPayload functions to perform render void drawCall(gpu::Batch& batch) const; - virtual void bindMesh(gpu::Batch& batch) const; + virtual void bindMesh(gpu::Batch& batch); virtual void bindMaterial(gpu::Batch& batch, const render::ShapePipeline::LocationsPointer locations, bool enableTextures) const; virtual void bindTransform(gpu::Batch& batch, const render::ShapePipeline::LocationsPointer locations, RenderArgs::RenderMode renderMode) const; @@ -93,16 +93,16 @@ public: const Transform& boundTransform, const gpu::BufferPointer& buffer); - float computeFadeAlpha() const; + float computeFadeAlpha(); // Render Item interface render::ItemKey getKey() const override; int getLayer() const; render::ShapeKey getShapeKey() const override; // shape interface - void render(RenderArgs* args) const override; + void render(RenderArgs* args) override; // ModelMeshPartPayload functions to perform render - void bindMesh(gpu::Batch& batch) const override; + void bindMesh(gpu::Batch& batch) override; void bindTransform(gpu::Batch& batch, const render::ShapePipeline::LocationsPointer locations, RenderArgs::RenderMode renderMode) const override; void initCache(); @@ -116,11 +116,12 @@ public: int _shapeID; bool _isSkinned{ false }; - bool _isBlendShaped{ false }; + bool _isBlendShaped { false }; + bool _materialNeedsUpdate { true }; private: - mutable quint64 _fadeStartTime { 0 }; - mutable uint8_t _fadeState { FADE_WAITING_TO_START }; + quint64 _fadeStartTime { 0 }; + uint8_t _fadeState { FADE_WAITING_TO_START }; }; namespace render { diff --git a/libraries/script-engine/CMakeLists.txt b/libraries/script-engine/CMakeLists.txt index 7b176a6973..39338fd767 100644 --- a/libraries/script-engine/CMakeLists.txt +++ b/libraries/script-engine/CMakeLists.txt @@ -16,6 +16,6 @@ if (NOT ANDROID) endif () -link_hifi_libraries(shared networking octree gpu ui procedural model model-networking recording avatars fbx entities controllers animation audio physics image) +link_hifi_libraries(shared networking octree gpu ui procedural model model-networking ktx recording avatars fbx entities controllers animation audio physics image) # ui includes gl, but link_hifi_libraries does not use transitive includes, so gl must be explicit include_hifi_library_headers(gl) diff --git a/libraries/script-engine/src/AssetScriptingInterface.cpp b/libraries/script-engine/src/AssetScriptingInterface.cpp index 00fa1f3ba5..65259987c4 100644 --- a/libraries/script-engine/src/AssetScriptingInterface.cpp +++ b/libraries/script-engine/src/AssetScriptingInterface.cpp @@ -44,7 +44,8 @@ void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValu QObject::connect(setMappingRequest, &SetMappingRequest::finished, this, [this, callback](SetMappingRequest* request) mutable { if (callback.isFunction()) { - QScriptValueList args { }; + QString error = request->getErrorString(); + QScriptValueList args { error }; callback.call(_engine->currentContext()->thisObject(), args); } request->deleteLater(); diff --git a/libraries/script-engine/src/AssetScriptingInterface.h b/libraries/script-engine/src/AssetScriptingInterface.h index d8bc319256..0238329b73 100644 --- a/libraries/script-engine/src/AssetScriptingInterface.h +++ b/libraries/script-engine/src/AssetScriptingInterface.h @@ -72,6 +72,7 @@ public: /**jsdoc * Called when setMapping is complete * @callback Assets~setMappingCallback + * @param {string} error */ Q_INVOKABLE void setMapping(QString path, QString hash, QScriptValue callback); diff --git a/libraries/script-engine/src/RecordingScriptingInterface.cpp b/libraries/script-engine/src/RecordingScriptingInterface.cpp index 36de1c1ef7..98838441d2 100644 --- a/libraries/script-engine/src/RecordingScriptingInterface.cpp +++ b/libraries/script-engine/src/RecordingScriptingInterface.cpp @@ -210,9 +210,11 @@ bool RecordingScriptingInterface::saveRecordingToAsset(QScriptValue getClipAtpUr } if (QThread::currentThread() != thread()) { + bool result; QMetaObject::invokeMethod(this, "saveRecordingToAsset", Qt::BlockingQueuedConnection, + Q_RETURN_ARG(bool, result), Q_ARG(QScriptValue, getClipAtpUrl)); - return false; + return result; } if (!_lastClip) { diff --git a/libraries/script-engine/src/ScriptEngine.cpp b/libraries/script-engine/src/ScriptEngine.cpp index 067c7c1412..c904062507 100644 --- a/libraries/script-engine/src/ScriptEngine.cpp +++ b/libraries/script-engine/src/ScriptEngine.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -678,6 +679,8 @@ void ScriptEngine::init() { registerGlobalObject("Model", new ModelScriptingInterface(this)); qScriptRegisterMetaType(this, meshToScriptValue, meshFromScriptValue); qScriptRegisterMetaType(this, meshesToScriptValue, meshesFromScriptValue); + + registerGlobalObject("UserActivityLogger", DependencyManager::get().data()); } void ScriptEngine::registerValue(const QString& valueName, QScriptValue value) { @@ -2317,6 +2320,8 @@ void ScriptEngine::unloadEntityScript(const EntityItemID& entityID, bool shouldR if (_entityScripts.contains(entityID)) { const EntityScriptDetails &oldDetails = _entityScripts[entityID]; + auto scriptText = oldDetails.scriptText; + if (isEntityScriptRunning(entityID)) { callEntityScriptMethod(entityID, "unload"); } @@ -2334,14 +2339,14 @@ void ScriptEngine::unloadEntityScript(const EntityItemID& entityID, bool shouldR newDetails.status = EntityScriptStatus::UNLOADED; newDetails.lastModified = QDateTime::currentMSecsSinceEpoch(); // keep scriptText populated for the current need to "debouce" duplicate calls to unloadEntityScript - newDetails.scriptText = oldDetails.scriptText; + newDetails.scriptText = scriptText; setEntityScriptDetails(entityID, newDetails); } stopAllTimersForEntityScript(entityID); { // FIXME: shouldn't have to do this here, but currently something seems to be firing unloads moments after firing initial load requests - processDeferredEntityLoads(oldDetails.scriptText, entityID); + processDeferredEntityLoads(scriptText, entityID); } } } diff --git a/libraries/shared/src/shared/Storage.cpp b/libraries/shared/src/shared/Storage.cpp index 3c46347a49..aae1f8455f 100644 --- a/libraries/shared/src/shared/Storage.cpp +++ b/libraries/shared/src/shared/Storage.cpp @@ -68,7 +68,7 @@ StoragePointer FileStorage::create(const QString& filename, size_t size, const u } FileStorage::FileStorage(const QString& filename) : _file(filename) { - if (_file.open(QFile::ReadOnly)) { + if (_file.open(QFile::ReadWrite)) { _mapped = _file.map(0, _file.size()); if (_mapped) { _valid = true; diff --git a/libraries/shared/src/shared/Storage.h b/libraries/shared/src/shared/Storage.h index 306984040f..da5b773d52 100644 --- a/libraries/shared/src/shared/Storage.h +++ b/libraries/shared/src/shared/Storage.h @@ -20,10 +20,12 @@ namespace storage { class Storage; using StoragePointer = std::shared_ptr; + // Abstract class to represent memory that stored _somewhere_ (in system memory or in a file, for example) class Storage : public std::enable_shared_from_this { public: virtual ~Storage() {} virtual const uint8_t* data() const = 0; + virtual uint8_t* mutableData() = 0; virtual size_t size() const = 0; virtual operator bool() const { return true; } @@ -41,6 +43,7 @@ namespace storage { MemoryStorage(size_t size, const uint8_t* data = nullptr); const uint8_t* data() const override { return _data.data(); } uint8_t* data() { return _data.data(); } + uint8_t* mutableData() override { return _data.data(); } size_t size() const override { return _data.size(); } operator bool() const override { return true; } private: @@ -57,6 +60,7 @@ namespace storage { FileStorage& operator=(const FileStorage& other) = delete; const uint8_t* data() const override { return _mapped; } + uint8_t* mutableData() override { return _mapped; } size_t size() const override { return _file.size(); } operator bool() const override { return _valid; } private: @@ -69,6 +73,7 @@ namespace storage { public: ViewStorage(const storage::StoragePointer& owner, size_t size, const uint8_t* data); const uint8_t* data() const override { return _data; } + uint8_t* mutableData() override { throw std::runtime_error("Cannot modify ViewStorage"); } size_t size() const override { return _size; } operator bool() const override { return *_owner; } private: diff --git a/libraries/ui/src/FileDialogHelper.cpp b/libraries/ui/src/FileDialogHelper.cpp index 2752de8592..6d14adf1db 100644 --- a/libraries/ui/src/FileDialogHelper.cpp +++ b/libraries/ui/src/FileDialogHelper.cpp @@ -26,6 +26,10 @@ QStringList FileDialogHelper::standardPath(StandardLocation location) { return QStandardPaths::standardLocations(static_cast(location)); } +QString FileDialogHelper::writableLocation(StandardLocation location) { + return QStandardPaths::writableLocation(static_cast(location)); +} + QString FileDialogHelper::urlToPath(const QUrl& url) { return url.toLocalFile(); } diff --git a/libraries/ui/src/FileDialogHelper.h b/libraries/ui/src/FileDialogHelper.h index 6c352ecdfc..12fd60daac 100644 --- a/libraries/ui/src/FileDialogHelper.h +++ b/libraries/ui/src/FileDialogHelper.h @@ -48,6 +48,7 @@ public: Q_INVOKABLE QUrl home(); Q_INVOKABLE QStringList standardPath(StandardLocation location); + Q_INVOKABLE QString writableLocation(StandardLocation location); Q_INVOKABLE QStringList drives(); Q_INVOKABLE QString urlToPath(const QUrl& url); Q_INVOKABLE bool urlIsDir(const QUrl& url); diff --git a/libraries/ui/src/OffscreenUi.cpp b/libraries/ui/src/OffscreenUi.cpp index 2a8f3ec9d5..84812b4f60 100644 --- a/libraries/ui/src/OffscreenUi.cpp +++ b/libraries/ui/src/OffscreenUi.cpp @@ -716,6 +716,86 @@ QString OffscreenUi::getExistingDirectory(void* ignored, const QString &caption, return DependencyManager::get()->existingDirectoryDialog(caption, dir, filter, selectedFilter, options); } +class AssetDialogListener : public ModalDialogListener { + // ATP equivalent of FileDialogListener. + Q_OBJECT + + friend class OffscreenUi; + AssetDialogListener(QQuickItem* messageBox) : ModalDialogListener(messageBox) { + if (_finished) { + return; + } + connect(_dialog, SIGNAL(selectedAsset(QVariant)), this, SLOT(onSelectedAsset(QVariant))); + } + + private slots: + void onSelectedAsset(QVariant asset) { + _result = asset; + _finished = true; + disconnect(_dialog); + } +}; + + +QString OffscreenUi::assetDialog(const QVariantMap& properties) { + // ATP equivalent of fileDialog(). + QVariant buildDialogResult; + bool invokeResult; + auto tabletScriptingInterface = DependencyManager::get(); + TabletProxy* tablet = dynamic_cast(tabletScriptingInterface->getTablet("com.highfidelity.interface.tablet.system")); + if (tablet->getToolbarMode()) { + invokeResult = QMetaObject::invokeMethod(_desktop, "assetDialog", + Q_RETURN_ARG(QVariant, buildDialogResult), + Q_ARG(QVariant, QVariant::fromValue(properties))); + } else { + QQuickItem* tabletRoot = tablet->getTabletRoot(); + invokeResult = QMetaObject::invokeMethod(tabletRoot, "assetDialog", + Q_RETURN_ARG(QVariant, buildDialogResult), + Q_ARG(QVariant, QVariant::fromValue(properties))); + emit tabletScriptingInterface->tabletNotification(); + } + + if (!invokeResult) { + qWarning() << "Failed to create asset open dialog"; + return QString(); + } + + QVariant result = AssetDialogListener(qvariant_cast(buildDialogResult)).waitForResult(); + if (!result.isValid()) { + return QString(); + } + qCDebug(uiLogging) << result.toString(); + return result.toUrl().toString(); +} + +QString OffscreenUi::assetOpenDialog(const QString& caption, const QString& dir, const QString& filter, QString* selectedFilter, QFileDialog::Options options) { + // ATP equivalent of fileOpenDialog(). + if (QThread::currentThread() != thread()) { + QString result; + QMetaObject::invokeMethod(this, "assetOpenDialog", Qt::BlockingQueuedConnection, + Q_RETURN_ARG(QString, result), + Q_ARG(QString, caption), + Q_ARG(QString, dir), + Q_ARG(QString, filter), + Q_ARG(QString*, selectedFilter), + Q_ARG(QFileDialog::Options, options)); + return result; + } + + // FIXME support returning the selected filter... somehow? + QVariantMap map; + map.insert("caption", caption); + map.insert("dir", dir); + map.insert("filter", filter); + map.insert("options", static_cast(options)); + return assetDialog(map); +} + +QString OffscreenUi::getOpenAssetName(void* ignored, const QString &caption, const QString &dir, const QString &filter, QString *selectedFilter, QFileDialog::Options options) { + // ATP equivalent of getOpenFileName(). + return DependencyManager::get()->assetOpenDialog(caption, dir, filter, selectedFilter, options); +} + bool OffscreenUi::eventFilter(QObject* originalDestination, QEvent* event) { if (!filterEnabled(originalDestination, event)) { return false; diff --git a/libraries/ui/src/OffscreenUi.h b/libraries/ui/src/OffscreenUi.h index 5813d0bfd2..55fb8b2c3d 100644 --- a/libraries/ui/src/OffscreenUi.h +++ b/libraries/ui/src/OffscreenUi.h @@ -118,6 +118,8 @@ public: Q_INVOKABLE QString fileSaveDialog(const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); Q_INVOKABLE QString existingDirectoryDialog(const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + Q_INVOKABLE QString assetOpenDialog(const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + // Compatibility with QFileDialog::getOpenFileName static QString getOpenFileName(void* ignored, const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); // Compatibility with QFileDialog::getSaveFileName @@ -125,6 +127,8 @@ public: // Compatibility with QFileDialog::getExistingDirectory static QString getExistingDirectory(void* ignored, const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + static QString getOpenAssetName(void* ignored, const QString &caption = QString(), const QString &dir = QString(), const QString &filter = QString(), QString *selectedFilter = 0, QFileDialog::Options options = 0); + Q_INVOKABLE QVariant inputDialog(const Icon icon, const QString& title, const QString& label = QString(), const QVariant& current = QVariant()); Q_INVOKABLE QVariant customInputDialog(const Icon icon, const QString& title, const QVariantMap& config); QQuickItem* createInputDialog(const Icon icon, const QString& title, const QString& label, const QVariant& current); @@ -155,6 +159,7 @@ signals: private: QString fileDialog(const QVariantMap& properties); + QString assetDialog(const QVariantMap& properties); QQuickItem* _desktop { nullptr }; QQuickItem* _toolWindow { nullptr }; diff --git a/plugins/openvr/CMakeLists.txt b/plugins/openvr/CMakeLists.txt index 2300a38e56..bc62117e70 100644 --- a/plugins/openvr/CMakeLists.txt +++ b/plugins/openvr/CMakeLists.txt @@ -13,7 +13,7 @@ if (WIN32) setup_hifi_plugin(OpenGL Script Qml Widgets) link_hifi_libraries(shared gl networking controllers ui plugins display-plugins ui-plugins input-plugins script-engine - render-utils model gpu gpu-gl render model-networking fbx image) + render-utils model gpu gpu-gl render model-networking fbx ktx image) include_hifi_library_headers(octree) diff --git a/scripts/developer/inputRecording.js b/scripts/developer/inputRecording.js new file mode 100644 index 0000000000..85bda623b3 --- /dev/null +++ b/scripts/developer/inputRecording.js @@ -0,0 +1,103 @@ +// +// Created by Dante Ruiz 2017/04/17 +// 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 +// + +(function() { + var recording = false; + var onRecordingScreen = false; + var passedSaveDirectory = false; + var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + var button = tablet.addButton({ + text: "IRecord" + }); + function onClick() { + if (onRecordingScreen) { + tablet.gotoHomeScreen(); + onRecordingScreen = false; + } else { + tablet.loadQMLSource("InputRecorder.qml"); + onRecordingScreen = true; + } + } + + function onScreenChanged(type, url) { + onRecordingScreen = false; + passedSaveDirectory = false; + } + + button.clicked.connect(onClick); + tablet.fromQml.connect(fromQml); + tablet.screenChanged.connect(onScreenChanged); + function fromQml(message) { + switch (message.method) { + case "Start": + startRecording(); + break; + case "Stop": + stopRecording(); + break; + case "Save": + saveRecording(); + break; + case "Load": + loadRecording(message.params.file); + break; + case "playback": + startPlayback(); + break; + } + + } + + function startRecording() { + Controller.startInputRecording(); + recording = true; + } + + function stopRecording() { + Controller.stopInputRecording(); + recording = false; + } + + function saveRecording() { + Controller.saveInputRecording(); + } + + function loadRecording(file) { + Controller.loadInputRecording(file); + } + + function startPlayback() { + Controller.startInputPlayback(); + } + + function sendToQml(message) { + tablet.sendToQml(message); + } + + function update() { + + if (!passedSaveDirectory) { + var directory = Controller.getInputRecorderSaveDirectory(); + sendToQml({method: "path", params: directory}); + passedSaveDirectory = true; + } + sendToQml({method: "update", params: recording}); + } + + Script.setInterval(update, 60); + + Script.scriptEnding.connect(function () { + button.clicked.disconnect(onClick); + if (tablet) { + tablet.removeButton(button); + } + + Controller.stopInputRecording(); + }); + +}()); 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/assets/sounds/countdown-tick.wav b/scripts/system/assets/sounds/countdown-tick.wav new file mode 100644 index 0000000000..015e1f642e Binary files /dev/null and b/scripts/system/assets/sounds/countdown-tick.wav differ diff --git a/scripts/system/assets/sounds/finish-recording.wav b/scripts/system/assets/sounds/finish-recording.wav new file mode 100644 index 0000000000..f224049f97 Binary files /dev/null and b/scripts/system/assets/sounds/finish-recording.wav differ diff --git a/scripts/system/assets/sounds/start-recording.wav b/scripts/system/assets/sounds/start-recording.wav new file mode 100644 index 0000000000..71c69f3372 Binary files /dev/null and b/scripts/system/assets/sounds/start-recording.wav differ diff --git a/scripts/system/controllers/handControllerGrab.js b/scripts/system/controllers/handControllerGrab.js index f1a2e7bd08..026a382e58 100644 --- a/scripts/system/controllers/handControllerGrab.js +++ b/scripts/system/controllers/handControllerGrab.js @@ -1330,7 +1330,7 @@ function MyController(hand) { if (this.stylus) { return; } - + var stylusProperties = { name: "stylus", url: Script.resourcesPath() + "meshes/tablet-stylus-fat.fbx", @@ -1420,6 +1420,14 @@ function MyController(hand) { } }; + // Turns off indicators used for searching. Overlay line and sphere. + this.searchIndicatorOff = function() { + this.searchSphereOff(); + if (PICK_WITH_HAND_RAY) { + this.overlayLineOff(); + } + } + this.otherGrabbingLineOn = function(avatarPosition, entityPosition, color) { if (this.otherGrabbingLine === null) { var lineProperties = { @@ -1791,6 +1799,15 @@ function MyController(hand) { } this.processStylus(); + + if (isInEditMode() && !this.isNearStylusTarget) { + // Always showing lasers while in edit mode and hands/stylus is not active. + var rayPickInfo = this.calcRayPickInfo(this.hand); + this.intersectionDistance = (rayPickInfo.entityID || rayPickInfo.overlayID) ? rayPickInfo.distance : 0; + this.searchIndicatorOn(rayPickInfo.searchRay); + } else { + this.searchIndicatorOff(); + } }; this.handleLaserOnHomeButton = function(rayPickInfo) { @@ -2237,15 +2254,22 @@ function MyController(hand) { return; } } - + if (isInEditMode()) { this.searchIndicatorOn(rayPickInfo.searchRay); if (this.triggerSmoothedGrab()) { - if (!this.editTriggered && rayPickInfo.entityID) { - Messages.sendLocalMessage("entityToolUpdates", JSON.stringify({ - method: "selectEntity", - entityID: rayPickInfo.entityID - })); + if (!this.editTriggered){ + if (rayPickInfo.entityID) { + Messages.sendLocalMessage("entityToolUpdates", JSON.stringify({ + method: "selectEntity", + entityID: rayPickInfo.entityID + })); + } else if (rayPickInfo.overlayID) { + Messages.sendLocalMessage("entityToolUpdates", JSON.stringify({ + method: "selectOverlay", + overlayID: rayPickInfo.overlayID + })); + } } this.editTriggered = true; } @@ -2274,7 +2298,7 @@ function MyController(hand) { if (this.getOtherHandController().state === STATE_DISTANCE_HOLDING) { this.setState(STATE_DISTANCE_ROTATING, "distance rotate '" + name + "'"); } else { - this.setState(STATE_DISTANCE_HOLDING, "distance hold '" + name + "'"); + this.setState(STATE_DISTANCE_HOLDING, "distance hold '" + name + "'"); } return; } else { @@ -3341,7 +3365,14 @@ function MyController(hand) { }; this.offEnter = function() { + // Reuse the existing search distance if lasers were active since + // they will be shown in OFF state while in edit mode. + var existingSearchDistance = this.searchSphereDistance; this.release(); + + if (isInEditMode()) { + this.searchSphereDistance = existingSearchDistance; + } }; this.entityLaserTouchingEnter = function() { 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; +}; diff --git a/scripts/system/edit.js b/scripts/system/edit.js index 9988df425d..6fabeb2ec6 100644 --- a/scripts/system/edit.js +++ b/scripts/system/edit.js @@ -12,7 +12,8 @@ // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // -/* global Script, SelectionDisplay, LightOverlayManager, CameraManager, Grid, GridTool, EntityListTool, Vec3, SelectionManager, Overlays, OverlayWebWindow, UserActivityLogger, Settings, Entities, Tablet, Toolbars, Messages, Menu, Camera, progressDialog, tooltip, MyAvatar, Quat, Controller, Clipboard, HMD, UndoStack, ParticleExplorerTool */ +/* global Script, SelectionDisplay, LightOverlayManager, CameraManager, Grid, GridTool, EntityListTool, Vec3, SelectionManager, Overlays, OverlayWebWindow, UserActivityLogger, + Settings, Entities, Tablet, Toolbars, Messages, Menu, Camera, progressDialog, tooltip, MyAvatar, Quat, Controller, Clipboard, HMD, UndoStack, ParticleExplorerTool */ (function() { // BEGIN LOCAL_SCOPE @@ -96,6 +97,10 @@ selectionManager.addEventListener(function () { particleExplorerTool.webView.emitScriptEvent(JSON.stringify(particleData)); } }); + + // Switch to particle explorer + var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + tablet.sendToQml({method: 'selectTab', params: {id: 'particle'}}); } else { needToDestroyParticleExplorer = true; } @@ -213,6 +218,8 @@ function hideMarketplace() { // } var TOOLS_PATH = Script.resolvePath("assets/images/tools/"); +var GRABBABLE_ENTITIES_MENU_CATEGORY = "Edit"; +var GRABBABLE_ENTITIES_MENU_ITEM = "Create Entities As Grabbable"; var toolBar = (function () { var EDIT_SETTING = "io.highfidelity.isEditting"; // for communication with other scripts @@ -227,8 +234,11 @@ var toolBar = (function () { var position = getPositionToCreateEntity(); var entityID = null; if (position !== null && position !== undefined) { - position = grid.snapToSurface(grid.snapToGrid(position, false, dimensions), dimensions), - properties.position = position; + position = grid.snapToSurface(grid.snapToGrid(position, false, dimensions), dimensions); + properties.position = position; + if (Menu.isOptionChecked(GRABBABLE_ENTITIES_MENU_ITEM)) { + properties.userData = JSON.stringify({ grabbableKey: { grabbable: true } }); + } entityID = Entities.addEntity(properties); if (properties.type == "ParticleEffect") { selectParticleEntity(entityID); @@ -253,6 +263,7 @@ var toolBar = (function () { if (systemToolbar) { systemToolbar.removeButton(EDIT_TOGGLE_BUTTON); } + Menu.removeMenuItem(GRABBABLE_ENTITIES_MENU_CATEGORY, GRABBABLE_ENTITIES_MENU_ITEM); } var buttonHandlers = {}; // only used to tablet mode @@ -638,6 +649,27 @@ function findClickedEntity(event) { }; } +// Handles selections on overlays while in edit mode by querying entities from +// entityIconOverlayManager. +function handleOverlaySelectionToolUpdates(channel, message, sender) { + if (sender !== MyAvatar.sessionUUID || channel !== 'entityToolUpdates') + return; + + var data = JSON.parse(message); + + if (data.method === "selectOverlay") { + print("setting selection to overlay " + data.overlayID); + var entity = entityIconOverlayManager.findEntity(data.overlayID); + + if (entity !== null) { + selectionManager.setSelections([entity]); + } + } +} + +Messages.subscribe("entityToolUpdates"); +Messages.messageReceived.connect(handleOverlaySelectionToolUpdates); + var mouseHasMovedSincePress = false; var mousePressStartTime = 0; var mousePressStartPosition = { @@ -903,11 +935,21 @@ function setupModelMenus() { afterItem: "Parent Entity to Last", grouping: "Advanced" }); + + Menu.addMenuItem({ + menuName: GRABBABLE_ENTITIES_MENU_CATEGORY, + menuItemName: GRABBABLE_ENTITIES_MENU_ITEM, + afterItem: "Unparent Entity", + isCheckable: true, + isChecked: true, + grouping: "Advanced" + }); + Menu.addMenuItem({ menuName: "Edit", menuItemName: "Allow Selecting of Large Models", shortcutKey: "CTRL+META+L", - afterItem: "Unparent Entity", + afterItem: GRABBABLE_ENTITIES_MENU_ITEM, isCheckable: true, isChecked: true, grouping: "Advanced" @@ -1047,6 +1089,13 @@ Script.scriptEnding.connect(function () { Controller.keyReleaseEvent.disconnect(keyReleaseEvent); Controller.keyPressEvent.disconnect(keyPressEvent); + + Controller.mousePressEvent.disconnect(mousePressEvent); + Controller.mouseMoveEvent.disconnect(mouseMoveEventBuffered); + Controller.mouseReleaseEvent.disconnect(mouseReleaseEvent); + + Messages.messageReceived.disconnect(handleOverlaySelectionToolUpdates); + Messages.unsubscribe("entityToolUpdates"); }); var lastOrientation = null; @@ -2013,7 +2062,11 @@ function selectParticleEntity(entityID) { selectedParticleEntity = entityID; particleExplorerTool.setActiveParticleEntity(entityID); - particleExplorerTool.webView.emitScriptEvent(JSON.stringify(particleData)); + particleExplorerTool.webView.emitScriptEvent(JSON.stringify(particleData)); + + // Switch to particle explorer + var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + tablet.sendToQml({method: 'selectTab', params: {id: 'particle'}}); } entityListTool.webView.webEventReceived.connect(function (data) { diff --git a/scripts/system/html/css/record.css b/scripts/system/html/css/record.css new file mode 100644 index 0000000000..35751379b9 --- /dev/null +++ b/scripts/system/html/css/record.css @@ -0,0 +1,218 @@ +/* +// record.css +// +// Created by David Rowe on 5 Apr 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 +*/ + + +body { + padding: 0; + overflow: hidden; +} + +.title { + padding-left: 21px; +} + +.title label { + font-size: 18px; + position: relative; + top: 12px; +} + + +#recordings { + height: 100%; + position: relative; + box-sizing: border-box; + padding: 51px 0 185px 0; + margin: 0 21px 0 21px; +} + +#recordings #table-container { + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: auto; + box-sizing: border-box; + border-left: 2px solid #575757; + border-right: 2px solid #575757; + background-color: #2e2e2e; +} + +#recordings table { + border: none; +} + +#recordings thead { + position: absolute; + top: 21px; + left: 0; + width: 100%; + box-sizing: border-box; + border: 2px solid #575757; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + border-bottom: 1px solid #575757; + position: absolute; + word-wrap: nowrap; + white-space: nowrap; + overflow: hidden; +} + +#recordings table col#unload-column { + width: 100px; +} + +#recordings thead th:last-child { + width: 100px; +} + +#recordings table td { + text-overflow: ellipsis; +} + +#recordings table td:nth-child(2) { + text-align: center; +} + +#recordings tbody tr.filler td { + height: auto; + border-top: 1px solid #1c1c1c; +} + +#recordings-list input { + height: 22px; + width: 22px; + min-width: 22px; + font-size: 16px; + padding: 0 1px 0 0; +} + +#recordings tfoot { + position: absolute; + bottom: 159px; + left: 0; + width: 100%; + box-sizing: border-box; + border: 2px solid #575757; + border-bottom-left-radius: 7px; + border-bottom-right-radius: 7px; + border-top: 1px solid #575757; +} + +#recordings tfoot tr, #recordings tfoot td { + background: none; +} + + +#spinner { + text-align: center; + margin-top: 25%; + position: relative; +} + +#spinner span { + display: block; + position: relative; + top: -101px; + color: #e2334d; + font-size: 60px; + font-weight: bold; +} + + +#recordings tfoot tr { + height: 24px; +} + + +#instructions td { + white-space: normal; +} + +#instructions h1 { + font-size: 16px; + margin-top: 28px; +} + +#instructions h1 + p { + margin-top: 14px; +} + +#instructions p, #instructions ul { + margin-top: 21px; + font-size: 14px; +} + +#instructions p { + font-family: Raleway-Bold; +} + +#instructions ul { + font-family: Raleway-SemiBold; + margin-left: 21px; + font-weight: normal; +} + +#instructions li { + margin-top: 7px; +} + +#instructions ul input { + margin-left: 1px; + margin-top: 6px; + font-size: 14px; + padding: 0 7px; +} + + +#show-info-button { + font-family: HiFi-Glyphs; + font-size: 32px; + height: 16px; + line-height: 16px; + display: inline-block; + position: absolute; + top: 15px; + right: 5px; + margin-top: -11px; + margin-left: 7px; +} + +#show-info-button:hover { + color: #00b4ef; +} + + +#record-controls { + position: absolute; + bottom: 7px; + width: 100%; +} + +#record-controls #load-container { + position: absolute; + left: 21px; +} + +#record-controls #record-container { + text-align: center; +} + +#record-controls #checkbox-container { + margin-top: 31px; +} + +#record-controls div.property { + padding-left: 21px; +} + + +.hidden { + display: none; +} diff --git a/scripts/system/html/js/record.js b/scripts/system/html/js/record.js new file mode 100644 index 0000000000..c78500307d --- /dev/null +++ b/scripts/system/html/js/record.js @@ -0,0 +1,298 @@ +"use strict"; + +// +// record.js +// +// Created by David Rowe on 5 Apr 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 +// + +var isUsingToolbar = false, + isDisplayingInstructions = false, + isRecording = false, + numberOfPlayers = 0, + recordingsBeingPlayed = [], + elRecordings, + elRecordingsTable, + elRecordingsList, + elInstructions, + elPlayersUnused, + elHideInfoButton, + elShowInfoButton, + elLoadButton, + elSpinner, + elCountdownNumber, + elRecordButton, + elFinishOnOpen, + elFinishOnOpenLabel, + EVENT_BRIDGE_TYPE = "record", + BODY_LOADED_ACTION = "bodyLoaded", + USING_TOOLBAR_ACTION = "usingToolbar", + RECORDINGS_BEING_PLAYED_ACTION = "recordingsBeingPlayed", + NUMBER_OF_PLAYERS_ACTION = "numberOfPlayers", + STOP_PLAYING_RECORDING_ACTION = "stopPlayingRecording", + LOAD_RECORDING_ACTION = "loadRecording", + START_RECORDING_ACTION = "startRecording", + SET_COUNTDOWN_NUMBER_ACTION = "setCountdownNumber", + STOP_RECORDING_ACTION = "stopRecording", + FINISH_ON_OPEN_ACTION = "finishOnOpen"; + +function stopPlayingRecording(event) { + var playerID = event.target.getAttribute("playerID"); + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: STOP_PLAYING_RECORDING_ACTION, + value: playerID + })); +} + +function updatePlayersUnused() { + elPlayersUnused.innerHTML = numberOfPlayers - recordingsBeingPlayed.length; +} + +function orderRecording(a, b) { + return a.filename > b.filename ? 1 : -1; +} + +function updateRecordings() { + var tbody, + tr, + td, + input, + ths, + tds, + length, + i, + HIFI_GLYPH_CLOSE = "w"; + + recordingsBeingPlayed.sort(orderRecording); + + tbody = document.createElement("tbody"); + tbody.id = "recordings-list"; + + + // Filename + for (i = 0, length = recordingsBeingPlayed.length; i < length; i += 1) { + tr = document.createElement("tr"); + td = document.createElement("td"); + td.innerHTML = recordingsBeingPlayed[i].filename.slice(4); + tr.appendChild(td); + td = document.createElement("td"); + input = document.createElement("input"); + input.setAttribute("type", "button"); + input.setAttribute("class", "glyph red"); + input.setAttribute("value", HIFI_GLYPH_CLOSE); + input.setAttribute("playerID", recordingsBeingPlayed[i].playerID); + input.addEventListener("click", stopPlayingRecording); + td.appendChild(input); + tr.appendChild(td); + tbody.appendChild(tr); + } + + // Empty rows representing available players. + for (i = recordingsBeingPlayed.length, length = numberOfPlayers; i < length; i += 1) { + tr = document.createElement("tr"); + td = document.createElement("td"); + td.colSpan = 2; + tr.appendChild(td); + tbody.appendChild(tr); + } + + // Filler row for extra table space. + tr = document.createElement("tr"); + tr.classList.add("filler"); + td = document.createElement("td"); + td.colSpan = 2; + tr.appendChild(td); + tbody.appendChild(tr); + + // Update table content. + elRecordingsTable.replaceChild(tbody, elRecordingsList); + elRecordingsList = document.getElementById("recordings-list"); + + // Update header cell widths to match content widths. + ths = document.querySelectorAll("#recordings-table thead th"); + tds = document.querySelectorAll("#recordings-table tbody tr:first-child td"); + for (i = 0; i < ths.length; i += 1) { + ths[i].width = tds[i].offsetWidth; + } +} + +function updateInstructions() { + // Display show/hide instructions buttons if players are available. + if (numberOfPlayers === 0) { + elHideInfoButton.classList.add("hidden"); + elShowInfoButton.classList.add("hidden"); + } else { + elHideInfoButton.classList.remove("hidden"); + elShowInfoButton.classList.remove("hidden"); + } + + // Display instructions if user requested or no players available. + if (isDisplayingInstructions || numberOfPlayers === 0) { + elRecordingsList.classList.add("hidden"); + elInstructions.classList.remove("hidden"); + } else { + elInstructions.classList.add("hidden"); + elRecordingsList.classList.remove("hidden"); + } +} + +function showInstructions() { + isDisplayingInstructions = true; + updateInstructions(); +} + +function hideInstructions() { + isDisplayingInstructions = false; + updateInstructions(); +} + +function updateLoadButton() { + if (isRecording || numberOfPlayers <= recordingsBeingPlayed.length) { + elLoadButton.setAttribute("disabled", "disabled"); + } else { + elLoadButton.removeAttribute("disabled"); + } +} + +function updateSpinner() { + if (isRecording) { + elRecordings.classList.add("hidden"); + elSpinner.classList.remove("hidden"); + } else { + elSpinner.classList.add("hidden"); + elRecordings.classList.remove("hidden"); + } +} + +function updateFinishOnOpenLabel() { + var WINDOW_FINISH_ON_OPEN_LABEL = "Stop recording automatically when reopen this window", + TABLET_FINISH_ON_OPEN_LABEL = "Stop recording automatically when reopen tablet or window"; + + elFinishOnOpenLabel.innerHTML = isUsingToolbar ? WINDOW_FINISH_ON_OPEN_LABEL : TABLET_FINISH_ON_OPEN_LABEL; +} + +function onScriptEventReceived(data) { + var message = JSON.parse(data); + if (message.type === EVENT_BRIDGE_TYPE) { + switch (message.action) { + case USING_TOOLBAR_ACTION: + isUsingToolbar = message.value; + updateFinishOnOpenLabel(); + break; + case FINISH_ON_OPEN_ACTION: + elFinishOnOpen.checked = message.value; + break; + case START_RECORDING_ACTION: + isRecording = true; + elRecordButton.value = "Stop"; + updateSpinner(); + updateLoadButton(); + break; + case SET_COUNTDOWN_NUMBER_ACTION: + elCountdownNumber.innerHTML = message.value; + break; + case STOP_RECORDING_ACTION: + isRecording = false; + elRecordButton.value = "Record"; + updateSpinner(); + updateLoadButton(); + break; + case RECORDINGS_BEING_PLAYED_ACTION: + recordingsBeingPlayed = JSON.parse(message.value); + updateRecordings(); + updatePlayersUnused(); + updateInstructions(); + updateLoadButton(); + break; + case NUMBER_OF_PLAYERS_ACTION: + numberOfPlayers = message.value; + updateRecordings(); + updatePlayersUnused(); + updateInstructions(); + updateLoadButton(); + break; + } + } +} + +function onLoadButtonClicked() { + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: LOAD_RECORDING_ACTION + })); +} + +function onRecordButtonClicked() { + if (!isRecording) { + elRecordButton.value = "Stop"; + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: START_RECORDING_ACTION + })); + isRecording = true; + updateSpinner(); + updateLoadButton(); + } else { + elRecordButton.value = "Record"; + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: STOP_RECORDING_ACTION + })); + isRecording = false; + updateSpinner(); + updateLoadButton(); + } +} + +function onFinishOnOpenClicked() { + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: FINISH_ON_OPEN_ACTION, + value: elFinishOnOpen.checked + })); +} + +function signalBodyLoaded() { + EventBridge.emitWebEvent(JSON.stringify({ + type: EVENT_BRIDGE_TYPE, + action: BODY_LOADED_ACTION + })); +} + +function onBodyLoaded() { + + EventBridge.scriptEventReceived.connect(onScriptEventReceived); + + elRecordings = document.getElementById("recordings"); + + elRecordingsTable = document.getElementById("recordings-table"); + elRecordingsList = document.getElementById("recordings-list"); + elInstructions = document.getElementById("instructions"); + elPlayersUnused = document.getElementById("players-unused"); + + elHideInfoButton = document.getElementById("hide-info-button"); + elHideInfoButton.onclick = hideInstructions; + elShowInfoButton = document.getElementById("show-info-button"); + elShowInfoButton.onclick = showInstructions; + + elLoadButton = document.getElementById("load-button"); + elLoadButton.onclick = onLoadButtonClicked; + + elSpinner = document.getElementById("spinner"); + elCountdownNumber = document.getElementById("countdown-number"); + + elRecordButton = document.getElementById("record-button"); + elRecordButton.onclick = onRecordButtonClicked; + + elFinishOnOpen = document.getElementById("finish-on-open"); + elFinishOnOpen.onclick = onFinishOnOpenClicked; + + elFinishOnOpenLabel = document.getElementById("finish-on-open-label"); + + signalBodyLoaded(); +} diff --git a/scripts/system/html/record.html b/scripts/system/html/record.html new file mode 100644 index 0000000000..14a3708a6a --- /dev/null +++ b/scripts/system/html/record.html @@ -0,0 +1,87 @@ + + + + + Record + + + + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
Recordings Being PlayedUnload
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + + diff --git a/scripts/system/libraries/entityIconOverlayManager.js b/scripts/system/libraries/entityIconOverlayManager.js index 7f7a293bc3..f557a05f60 100644 --- a/scripts/system/libraries/entityIconOverlayManager.js +++ b/scripts/system/libraries/entityIconOverlayManager.js @@ -32,20 +32,25 @@ EntityIconOverlayManager = function(entityTypes, getOverlayPropertiesFunc) { } }; + // Finds the id for the corresponding entity that is associated with an overlay id. + // Returns null if the overlay id is not contained in this manager. + this.findEntity = function(overlayId) { + for (var id in entityOverlays) { + if (overlayId === entityOverlays[id]) { + return entityIDs[id]; + } + } + + return null; + }; + this.findRayIntersection = function(pickRay) { var result = Overlays.findRayIntersection(pickRay); - var found = false; if (result.intersects) { - for (var id in entityOverlays) { - if (result.overlayID === entityOverlays[id]) { - result.entityID = entityIDs[id]; - found = true; - break; - } - } + result.entityID = this.findEntity(result.overlayID); - if (!found) { + if (result.entityID === null) { result.intersects = false; } } diff --git a/scripts/system/makeUserConnection.js b/scripts/system/makeUserConnection.js index 704450d8c3..0ffea0c568 100644 --- a/scripts/system/makeUserConnection.js +++ b/scripts/system/makeUserConnection.js @@ -1,6 +1,6 @@ "use strict"; // -// makeUserConnetion.js +// makeUserConnection.js // scripts/system // // Created by David Kelly on 3/7/2017. @@ -11,854 +11,904 @@ // (function() { // BEGIN LOCAL_SCOPE -const label = "makeUserConnection"; -const MAX_AVATAR_DISTANCE = 0.2; // m -const GRIP_MIN = 0.05; // goes from 0-1, so 5% pressed is pressed -const MESSAGE_CHANNEL = "io.highfidelity.makeUserConnection"; -const STATES = { - inactive : 0, - waiting: 1, - connecting: 2, - makingConnection: 3 -}; -const STATE_STRINGS = ["inactive", "waiting", "connecting", "makingConnection"]; -const WAITING_INTERVAL = 100; // ms -const CONNECTING_INTERVAL = 100; // ms -const MAKING_CONNECTION_TIMEOUT = 800; // ms -const CONNECTING_TIME = 1600; // ms -const PARTICLE_RADIUS = 0.15; // m -const PARTICLE_ANGLE_INCREMENT = 360/45; // 1hz -const HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/4beat_sweep.wav"; -const SUCCESSFUL_HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/3rdbeat_success_bell.wav"; -const HAPTIC_DATA = { - initial: { duration: 20, strength: 0.6}, // duration is in ms - background: { duration: 100, strength: 0.3 }, // duration is in ms - success: { duration: 60, strength: 1.0} // duration is in ms -}; -const PARTICLE_EFFECT_PROPS = { - "alpha": 0.8, - "azimuthFinish": Math.PI, - "azimuthStart": -1*Math.PI, - "emitRate": 500, - "emitSpeed": 0.0, - "emitterShouldTrail": 1, - "isEmitting": 1, - "lifespan": 3, - "maxParticles": 1000, - "particleRadius": 0.003, - "polarStart": 1, - "polarFinish": 1, - "radiusFinish": 0.008, - "radiusStart": 0.0025, - "speedSpread": 0.025, - "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png", - "color": {"red": 255, "green": 255, "blue": 255}, - "colorFinish": {"red": 0, "green": 164, "blue": 255}, - "colorStart": {"red": 255, "green": 255, "blue": 255}, - "emitOrientation": {"w": -0.71, "x":0.0, "y":0.0, "z": 0.71}, - "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0}, - "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0}, - "dimensions": {"x":0.05, "y": 0.05, "z": 0.05}, - "type": "ParticleEffect" -}; -const MAKING_CONNECTION_PARTICLE_PROPS = { - "alpha": 0.07, - "alphaStart":0.011, - "alphaSpread": 0, - "alphaFinish": 0, - "azimuthFinish": Math.PI, - "azimuthStart": -1*Math.PI, - "emitRate": 2000, - "emitSpeed": 0.0, - "emitterShouldTrail": 1, - "isEmitting": 1, - "lifespan": 3.6, - "maxParticles": 4000, - "particleRadius": 0.048, - "polarStart": 0, - "polarFinish": 1, - "radiusFinish": 0.3, - "radiusStart": 0.04, - "speedSpread": 0.01, - "radiusSpread": 0.9, - "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png", - "color": {"red": 200, "green": 170, "blue": 255}, - "colorFinish": {"red": 0, "green": 134, "blue": 255}, - "colorStart": {"red": 185, "green": 222, "blue": 255}, - "emitOrientation": {"w": -0.71, "x":0.0, "y":0.0, "z": 0.71}, - "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0}, - "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0}, - "dimensions": {"x":0.05, "y": 0.05, "z": 0.05}, - "type": "ParticleEffect" -}; + var LABEL = "makeUserConnection"; + var MAX_AVATAR_DISTANCE = 0.2; // m + var GRIP_MIN = 0.05; // goes from 0-1, so 5% pressed is pressed + var MESSAGE_CHANNEL = "io.highfidelity.makeUserConnection"; + var STATES = { + INACTIVE: 0, + WAITING: 1, + CONNECTING: 2, + MAKING_CONNECTION: 3 + }; + var STATE_STRINGS = ["inactive", "waiting", "connecting", "makingConnection"]; + var WAITING_INTERVAL = 100; // ms + var CONNECTING_INTERVAL = 100; // ms + var MAKING_CONNECTION_TIMEOUT = 800; // ms + var CONNECTING_TIME = 1600; // ms + var PARTICLE_RADIUS = 0.15; // m + var PARTICLE_ANGLE_INCREMENT = 360/45; // 1hz + var HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/4beat_sweep.wav"; + var SUCCESSFUL_HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/3rdbeat_success_bell.wav"; + var PREFERRER_HAND_JOINT_POSTFIX_ORDER = ['Middle1', 'Index1', '']; + var HAPTIC_DATA = { + initial: { duration: 20, strength: 0.6 }, // duration is in ms + background: { duration: 100, strength: 0.3 }, // duration is in ms + success: { duration: 60, strength: 1.0 } // duration is in ms + }; + var PARTICLE_EFFECT_PROPS = { + "alpha": 0.8, + "azimuthFinish": Math.PI, + "azimuthStart": -1*Math.PI, + "emitRate": 500, + "emitSpeed": 0.0, + "emitterShouldTrail": 1, + "isEmitting": 1, + "lifespan": 3, + "maxParticles": 1000, + "particleRadius": 0.003, + "polarStart": 1, + "polarFinish": 1, + "radiusFinish": 0.008, + "radiusStart": 0.0025, + "speedSpread": 0.025, + "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png", + "color": {"red": 255, "green": 255, "blue": 255}, + "colorFinish": {"red": 0, "green": 164, "blue": 255}, + "colorStart": {"red": 255, "green": 255, "blue": 255}, + "emitOrientation": {"w": -0.71, "x":0.0, "y":0.0, "z": 0.71}, + "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0}, + "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0}, + "dimensions": {"x":0.05, "y": 0.05, "z": 0.05}, + "type": "ParticleEffect" + }; + var MAKING_CONNECTION_PARTICLE_PROPS = { + "alpha": 0.07, + "alphaStart": 0.011, + "alphaSpread": 0, + "alphaFinish": 0, + "azimuthFinish": Math.PI, + "azimuthStart": -1*Math.PI, + "emitRate": 2000, + "emitSpeed": 0.0, + "emitterShouldTrail": 1, + "isEmitting": 1, + "lifespan": 3.6, + "maxParticles": 4000, + "particleRadius": 0.048, + "polarStart": 0, + "polarFinish": 1, + "radiusFinish": 0.3, + "radiusStart": 0.04, + "speedSpread": 0.01, + "radiusSpread": 0.9, + "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png", + "color": {"red": 200, "green": 170, "blue": 255}, + "colorFinish": {"red": 0, "green": 134, "blue": 255}, + "colorStart": {"red": 185, "green": 222, "blue": 255}, + "emitOrientation": {"w": -0.71, "x":0.0, "y":0.0, "z": 0.71}, + "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0}, + "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0}, + "dimensions": {"x":0.05, "y": 0.05, "z": 0.05}, + "type": "ParticleEffect" + }; -var currentHand; -var state = STATES.inactive; -var connectingInterval; -var waitingInterval; -var makingConnectionTimeout; -var animHandlerId; -var connectingId; -var connectingHand; -var waitingList = {}; -var particleEffect; -var waitingBallScale; -var particleRotationAngle = 0.0; -var makingConnectionParticleEffect; -var makingConnectionEmitRate = 2000; -var particleEmitRate = 500; -var handshakeInjector; -var successfulHandshakeInjector; -var handshakeSound; -var successfulHandshakeSound; + var currentHand = undefined; + var currentHandJointIndex = -1; + var state = STATES.INACTIVE; + var connectingInterval; + var waitingInterval; + var makingConnectionTimeout; + var animHandlerId; + var connectingId; + var connectingHandString; + var connectingHandJointIndex = -1; + var waitingList = {}; + var particleEffect; + var particleRotationAngle = 0.0; + var makingConnectionParticleEffect; + var makingConnectionEmitRate = 2000; + var particleEmitRate = 500; + var handshakeInjector; + var successfulHandshakeInjector; + var handshakeSound; + var successfulHandshakeSound; -function debug() { - var stateString = "<" + STATE_STRINGS[state] + ">"; - var connecting = "[" + connectingId + "/" + connectingHand + "]"; - print.apply(null, [].concat.apply([label, stateString, JSON.stringify(waitingList), connecting], [].map.call(arguments, JSON.stringify))); -} + function debug() { + var stateString = "<" + STATE_STRINGS[state] + ">"; + var connecting = "[" + connectingId + "/" + connectingHandString + "]"; + print.apply(null, [].concat.apply([LABEL, stateString, JSON.stringify(waitingList), connecting], + [].map.call(arguments, JSON.stringify))); + } -function cleanId(guidWithCurlyBraces) { - return guidWithCurlyBraces.slice(1, -1); -} -function request(options, callback) { // cb(error, responseOfCorrectContentType) of url. A subset of npm request. - var httpRequest = new XMLHttpRequest(), key; - // QT bug: apparently doesn't handle onload. Workaround using readyState. - httpRequest.onreadystatechange = function () { - var READY_STATE_DONE = 4; - var HTTP_OK = 200; - if (httpRequest.readyState >= READY_STATE_DONE) { - var error = (httpRequest.status !== HTTP_OK) && httpRequest.status.toString() + ':' + httpRequest.statusText, - response = !error && httpRequest.responseText, - contentType = !error && httpRequest.getResponseHeader('content-type'); - if (!error && contentType.indexOf('application/json') === 0) { // ignoring charset, etc. - try { - response = JSON.parse(response); - } catch (e) { - error = e; + function cleanId(guidWithCurlyBraces) { + return guidWithCurlyBraces.slice(1, -1); + } + function request(options, callback) { // cb(error, responseOfCorrectContentType) of url. A subset of npm request. + var httpRequest = new XMLHttpRequest(), key; + // QT bug: apparently doesn't handle onload. Workaround using readyState. + httpRequest.onreadystatechange = function () { + var READY_STATE_DONE = 4; + var HTTP_OK = 200; + if (httpRequest.readyState >= READY_STATE_DONE) { + var error = (httpRequest.status !== HTTP_OK) && httpRequest.status.toString() + ':' + httpRequest.statusText, + response = !error && httpRequest.responseText, + contentType = !error && httpRequest.getResponseHeader('content-type'); + if (!error && contentType.indexOf('application/json') === 0) { // ignoring charset, etc. + try { + response = JSON.parse(response); + } catch (e) { + error = e; + } + } + if (error) { + response = {statusCode: httpRequest.status}; + } + callback(error, response); + } + }; + if (typeof options === 'string') { + options = {uri: options}; + } + if (options.url) { + options.uri = options.url; + } + if (!options.method) { + options.method = 'GET'; + } + if (options.body && (options.method === 'GET')) { // add query parameters + var params = [], appender = (-1 === options.uri.search('?')) ? '?' : '&'; + for (key in options.body) { + if (options.body.hasOwnProperty(key)) { + params.push(key + '=' + options.body[key]); } } - if (error) { - response = {statusCode: httpRequest.status}; + options.uri += appender + params.join('&'); + delete options.body; + } + if (options.json) { + options.headers = options.headers || {}; + options.headers["Content-type"] = "application/json"; + options.body = JSON.stringify(options.body); + } + for (key in options.headers || {}) { + if (options.headers.hasOwnProperty(key)) { + httpRequest.setRequestHeader(key, options.headers[key]); } - callback(error, response); } - }; - if (typeof options === 'string') { - options = {uri: options}; + httpRequest.open(options.method, options.uri, true); + httpRequest.send(options.body); } - if (options.url) { - options.uri = options.url; - } - if (!options.method) { - options.method = 'GET'; - } - if (options.body && (options.method === 'GET')) { // add query parameters - var params = [], appender = (-1 === options.uri.search('?')) ? '?' : '&'; - for (key in options.body) { - params.push(key + '=' + options.body[key]); + + function handToString(hand) { + if (hand === Controller.Standard.RightHand) { + return "RightHand"; + } else if (hand === Controller.Standard.LeftHand) { + return "LeftHand"; } - options.uri += appender + params.join('&'); - delete options.body; + debug("handToString called without valid hand! value: ", hand); + return ""; } - if (options.json) { - options.headers = options.headers || {}; - options.headers["Content-type"] = "application/json"; - options.body = JSON.stringify(options.body); - } - for (key in options.headers || {}) { - httpRequest.setRequestHeader(key, options.headers[key]); - } - httpRequest.open(options.method, options.uri, true); - httpRequest.send(options.body); -} -function handToString(hand) { - if (hand === Controller.Standard.RightHand) { - return "RightHand"; - } else if (hand === Controller.Standard.LeftHand) { - return "LeftHand"; - } - debug("handToString called without valid hand!"); - return ""; -} - -function stringToHand(hand) { - if (hand == "RightHand") { - return Controller.Standard.RightHand; - } else if (hand == "LeftHand") { - return Controller.Standard.LeftHand; - } - debug("stringToHand called with bad hand string:", hand); - return 0; -} - -function handToHaptic(hand) { - if (hand === Controller.Standard.RightHand) { - return 1; - } else if (hand === Controller.Standard.LeftHand) { + function stringToHand(hand) { + if (hand === "RightHand") { + return Controller.Standard.RightHand; + } else if (hand === "LeftHand") { + return Controller.Standard.LeftHand; + } + debug("stringToHand called with bad hand string:", hand); return 0; } - debug("handToHaptic called without a valid hand!"); - return -1; -} -function stopWaiting() { - if (waitingInterval) { - waitingInterval = Script.clearInterval(waitingInterval); - } -} - -function stopConnecting() { - if (connectingInterval) { - connectingInterval = Script.clearInterval(connectingInterval); - } -} - -function stopMakingConnection() { - if (makingConnectionTimeout) { - makingConnectionTimeout = Script.clearTimeout(makingConnectionTimeout); - } -} - -// This returns the position of the palm, really. Which relies on the avatar -// having the expected middle1 joint. TODO: fallback for when this isn't part -// of the avatar? -function getHandPosition(avatar, hand) { - if (!hand) { - debug("calling getHandPosition with no hand! (returning avatar position but this is a BUG)"); - debug(new Error().stack); - return avatar.position; - } - var jointName = handToString(hand) + "Middle1"; - return avatar.getJointPosition(avatar.getJointIndex(jointName)); -} - -function shakeHandsAnimation(animationProperties) { - // all we are doing here is moving the right hand to a spot - // that is in front of and a bit above the hips. Basing how - // far in front as scaling with the avatar's height (say hips - // to head distance) - var headIndex = MyAvatar.getJointIndex("Head"); - var offset = 0.5; // default distance of hand in front of you - var result = {}; - if (headIndex) { - offset = 0.8 * MyAvatar.getAbsoluteJointTranslationInObjectFrame(headIndex).y; - } - var handPos = Vec3.multiply(offset, {x: -0.25, y: 0.8, z: 1.3}); - result.rightHandPosition = handPos; - result.rightHandRotation = Quat.fromPitchYawRollDegrees(90, 0, 90); - return result; -} - -function positionFractionallyTowards(posA, posB, frac) { - return Vec3.sum(posA, Vec3.multiply(frac, Vec3.subtract(posB, posA))); -} - -function deleteParticleEffect() { - if (particleEffect) { - particleEffect = Entities.deleteEntity(particleEffect); - } -} - -function deleteMakeConnectionParticleEffect() { - if (makingConnectionParticleEffect) { - makingConnectionParticleEffect = Entities.deleteEntity(makingConnectionParticleEffect); - } -} - -function stopHandshakeSound() { - if (handshakeInjector) { - handshakeInjector.stop(); - handshakeInjector = null; - } -} - -function calcParticlePos(myHand, otherHand, otherOrientation, reset) { - if (reset) { - particleRotationAngle = 0.0; - } - var position = positionFractionallyTowards(myHand, otherHand, 0.5); - particleRotationAngle += PARTICLE_ANGLE_INCREMENT; // about 0.5 hz - var radius = Math.min(PARTICLE_RADIUS, PARTICLE_RADIUS * particleRotationAngle / 360); - var axis = Vec3.mix(Quat.getFront(MyAvatar.orientation), Quat.inverse(Quat.getFront(otherOrientation)), 0.5); - return Vec3.sum(position, Vec3.multiplyQbyV(Quat.angleAxis(particleRotationAngle, axis), {x: 0, y: radius, z: 0})); -} - -// this is called frequently, but usually does nothing -function updateVisualization() { - if (state == STATES.inactive) { - deleteParticleEffect(); - deleteMakeConnectionParticleEffect(); - // this should always be true if inactive, but just in case: - currentHand = undefined; - return; + function handToHaptic(hand) { + if (hand === Controller.Standard.RightHand) { + return 1; + } else if (hand === Controller.Standard.LeftHand) { + return 0; + } + debug("handToHaptic called without a valid hand!"); + return -1; } - var myHandPosition = getHandPosition(MyAvatar, currentHand); - var otherHand; - var otherOrientation; - if (connectingId) { - var other = AvatarList.getAvatar(connectingId); - if (other) { - otherOrientation = other.orientation; - otherHand = getHandPosition(other, stringToHand(connectingHand)); + function stopWaiting() { + if (waitingInterval) { + waitingInterval = Script.clearInterval(waitingInterval); } } - var wrist = MyAvatar.getJointPosition(MyAvatar.getJointIndex(handToString(currentHand))); - var d = Math.min(MAX_AVATAR_DISTANCE, Vec3.distance(wrist, myHandPosition)); - switch (state) { - case STATES.waiting: - // no visualization while waiting + function stopConnecting() { + if (connectingInterval) { + connectingInterval = Script.clearInterval(connectingInterval); + } + } + + function stopMakingConnection() { + if (makingConnectionTimeout) { + makingConnectionTimeout = Script.clearTimeout(makingConnectionTimeout); + } + } + + // This returns the ideal hand joint index for the avatar. + // [hand]middle1 -> [hand]index1 -> [hand] + function getIdealHandJointIndex(avatar, hand) { + debug("got hand " + hand + " for avatar " + avatar.sessionUUID); + var handString = handToString(hand); + for (var i = 0; i < PREFERRER_HAND_JOINT_POSTFIX_ORDER.length; i++) { + var jointName = handString + PREFERRER_HAND_JOINT_POSTFIX_ORDER[i]; + var jointIndex = avatar.getJointIndex(jointName); + if (jointIndex !== -1) { + debug('found joint ' + jointName + ' (' + jointIndex + ')'); + return jointIndex; + } + } + debug('no hand joint found.'); + return -1; + } + + // This returns the preferred hand position. + function getHandPosition(avatar, handJointIndex) { + if (handJointIndex === -1) { + debug("calling getHandPosition with no hand joint index! (returning avatar position but this is a BUG)"); + debug(new Error().stack); + return avatar.position; + } + return avatar.getJointPosition(handJointIndex); + } + + function shakeHandsAnimation(animationProperties) { + // all we are doing here is moving the right hand to a spot + // that is in front of and a bit above the hips. Basing how + // far in front as scaling with the avatar's height (say hips + // to head distance) + var headIndex = MyAvatar.getJointIndex("Head"); + var offset = 0.5; // default distance of hand in front of you + var result = {}; + if (headIndex) { + offset = 0.8 * MyAvatar.getAbsoluteJointTranslationInObjectFrame(headIndex).y; + } + result.rightHandPosition = Vec3.multiply(offset, {x: -0.25, y: 0.8, z: 1.3}); + result.rightHandRotation = Quat.fromPitchYawRollDegrees(90, 0, 90); + return result; + } + + function positionFractionallyTowards(posA, posB, frac) { + return Vec3.sum(posA, Vec3.multiply(frac, Vec3.subtract(posB, posA))); + } + + function deleteParticleEffect() { + if (particleEffect) { + particleEffect = Entities.deleteEntity(particleEffect); + } + } + + function deleteMakeConnectionParticleEffect() { + if (makingConnectionParticleEffect) { + makingConnectionParticleEffect = Entities.deleteEntity(makingConnectionParticleEffect); + } + } + + function stopHandshakeSound() { + if (handshakeInjector) { + handshakeInjector.stop(); + handshakeInjector = null; + } + } + + function calcParticlePos(myHand, otherHand, otherOrientation, reset) { + if (reset) { + particleRotationAngle = 0.0; + } + var position = positionFractionallyTowards(myHand, otherHand, 0.5); + particleRotationAngle += PARTICLE_ANGLE_INCREMENT; // about 0.5 hz + var radius = Math.min(PARTICLE_RADIUS, PARTICLE_RADIUS * particleRotationAngle / 360); + var axis = Vec3.mix(Quat.getFront(MyAvatar.orientation), Quat.inverse(Quat.getFront(otherOrientation)), 0.5); + return Vec3.sum(position, Vec3.multiplyQbyV(Quat.angleAxis(particleRotationAngle, axis), {x: 0, y: radius, z: 0})); + } + + // this is called frequently, but usually does nothing + function updateVisualization() { + if (state === STATES.INACTIVE) { deleteParticleEffect(); deleteMakeConnectionParticleEffect(); - stopHandshakeSound(); - break; - case STATES.connecting: - var particleProps = {}; - // put the position between the 2 hands, if we have a connectingId. This - // helps define the plane in which the particles move. - positionFractionallyTowards(myHandPosition, otherHand, 0.5); - // now manage the rest of the entity - if (!particleEffect) { - particleRotationAngle = 0.0; - particleEmitRate = 500; - particleProps = PARTICLE_EFFECT_PROPS; - particleProps.isEmitting = 0; - particleProps.position = calcParticlePos(myHandPosition, otherHand, otherOrientation); - particleProps.parentID = MyAvatar.sessionUUID; - particleEffect = Entities.addEntity(particleProps, true); - } else { - particleProps.position = calcParticlePos(myHandPosition, otherHand, otherOrientation); - particleProps.isEmitting = 1; - Entities.editEntity(particleEffect, particleProps); - } - if (!makingConnectionParticleEffect) { - var props = MAKING_CONNECTION_PARTICLE_PROPS; - props.parentID = MyAvatar.sessionUUID; - makingConnectionEmitRate = 2000; - props.emitRate = makingConnectionEmitRate; - props.position = myHandPosition; - makingConnectionParticleEffect = Entities.addEntity(props, true); - } else { - makingConnectionEmitRate *= 0.5; - Entities.editEntity(makingConnectionParticleEffect, {emitRate: makingConnectionEmitRate, position: myHandPosition, isEmitting: 1}); - } - break; - case STATES.makingConnection: - particleEmitRate = Math.max(50, particleEmitRate * 0.5); - Entities.editEntity(makingConnectionParticleEffect, {emitRate: 0, isEmitting: 0, position: myHandPosition}); - Entities.editEntity(particleEffect, {position: calcParticlePos(myHandPosition, otherHand, otherOrientation), emitRate: particleEmitRate}); - break; - default: - debug("unexpected state", state); - break; - } -} - -function isNearby(id, hand) { - if (currentHand) { - var handPos = getHandPosition(MyAvatar, currentHand); - var avatar = AvatarList.getAvatar(id); - if (avatar) { - var otherHand = stringToHand(hand); - var distance = Vec3.distance(getHandPosition(avatar, otherHand), handPos); - return (distance < MAX_AVATAR_DISTANCE); - } - } - return false; -} - -function findNearestWaitingAvatar() { - var handPos = getHandPosition(MyAvatar, currentHand); - var minDistance = MAX_AVATAR_DISTANCE; - var nearestAvatar = {}; - Object.keys(waitingList).forEach(function (identifier) { - var avatar = AvatarList.getAvatar(identifier); - if (avatar) { - var hand = stringToHand(waitingList[identifier]); - var distance = Vec3.distance(getHandPosition(avatar, hand), handPos); - if (distance < minDistance) { - minDistance = distance; - nearestAvatar = {avatar: identifier, hand: hand}; - } - } - }); - return nearestAvatar; -} - - -// As currently implemented, we select the closest waiting avatar (if close enough) and send -// them a connectionRequest. If nobody is close enough we send a waiting message, and wait for a -// connectionRequest. If the 2 people who want to connect are both somewhat out of range when they -// initiate the shake, they will race to see who sends the connectionRequest after noticing the -// waiting message. Either way, they will start connecting eachother at that point. -function startHandshake(fromKeyboard) { - if (fromKeyboard) { - debug("adding animation"); - // just in case order of press/unpress is broken - if (animHandlerId) { - animHandlerId = MyAvatar.removeAnimationStateHandler(animHandlerId); - } - animHandlerId = MyAvatar.addAnimationStateHandler(shakeHandsAnimation, []); - } - debug("starting handshake for", currentHand); - pollCount = 0; - state = STATES.waiting; - connectingId = undefined; - connectingHand = undefined; - // just in case - stopWaiting(); - stopConnecting(); - stopMakingConnection(); - - var nearestAvatar = findNearestWaitingAvatar(); - if (nearestAvatar.avatar) { - connectingId = nearestAvatar.avatar; - connectingHand = handToString(nearestAvatar.hand); - debug("sending connectionRequest to", connectingId); - messageSend({ - key: "connectionRequest", - id: connectingId, - hand: handToString(currentHand) - }); - } else { - // send waiting message - debug("sending waiting message"); - messageSend({ - key: "waiting", - hand: handToString(currentHand) - }); - lookForWaitingAvatar(); - } -} - -function endHandshake() { - debug("ending handshake for", currentHand); - - deleteParticleEffect(); - deleteMakeConnectionParticleEffect(); - currentHand = undefined; - // note that setting the state to inactive should really - // only be done here, unless we change how the triggering works, - // as we ignore the key release event when inactive. See updateTriggers - // below. - state = STATES.inactive; - connectingId = undefined; - connectingHand = undefined; - stopWaiting(); - stopConnecting(); - stopMakingConnection(); - stopHandshakeSound(); - // send done to let connection know you are not making connections now - messageSend({ - key: "done" - }); - - if (animHandlerId) { - debug("removing animation"); - MyAvatar.removeAnimationStateHandler(animHandlerId); - } - // No-op if we were successful, but this way we ensure that failures and abandoned handshakes don't leave us in a weird state. - request({uri: requestUrl, method: 'DELETE'}, debug); -} - -function updateTriggers(value, fromKeyboard, hand) { - if (currentHand && hand !== currentHand) { - debug("currentHand", currentHand, "ignoring messages from", hand); - return; - } - if (!currentHand) { - currentHand = hand; - } - // ok now, we are either initiating or quitting... - var isGripping = value > GRIP_MIN; - if (isGripping) { - debug("updateTriggers called - gripping", handToString(hand)); - if (state != STATES.inactive) { - return; - } else { - startHandshake(fromKeyboard); - } - } else { - // TODO: should we end handshake even when inactive? Ponder - debug("updateTriggers called -- no longer gripping", handToString(hand)); - if (state != STATES.inactive) { - endHandshake(); - } else { return; } - } -} -function messageSend(message) { - Messages.sendMessage(MESSAGE_CHANNEL, JSON.stringify(message)); -} + var myHandPosition = getHandPosition(MyAvatar, currentHandJointIndex); + var otherHand; + var otherOrientation; + if (connectingId) { + var other = AvatarList.getAvatar(connectingId); + if (other) { + otherOrientation = other.orientation; + otherHand = getHandPosition(other, connectingHandJointIndex); + } + } -function lookForWaitingAvatar() { - // we started with nobody close enough, but maybe I've moved - // or they did. Note that 2 people doing this race, so stop - // as soon as you have a connectingId (which means you got their - // message before noticing they were in range in this loop) - - // just in case we reenter before stopping - stopWaiting(); - debug("started looking for waiting avatars"); - waitingInterval = Script.setInterval(function () { - if (state == STATES.waiting && !connectingId) { - // find the closest in-range avatar, and send connection request - var nearestAvatar = findNearestWaitingAvatar(); - if (nearestAvatar.avatar) { - connectingId = nearestAvatar.avatar; - connectingHand = handToString(nearestAvatar.hand); - debug("sending connectionRequest to", connectingId); - messageSend({ - key: "connectionRequest", - id: connectingId, - hand: handToString(currentHand) + switch (state) { + case STATES.WAITING: + // no visualization while waiting + deleteParticleEffect(); + deleteMakeConnectionParticleEffect(); + stopHandshakeSound(); + break; + case STATES.CONNECTING: + var particleProps = {}; + // put the position between the 2 hands, if we have a connectingId. This + // helps define the plane in which the particles move. + positionFractionallyTowards(myHandPosition, otherHand, 0.5); + // now manage the rest of the entity + if (!particleEffect) { + particleRotationAngle = 0.0; + particleEmitRate = 500; + particleProps = PARTICLE_EFFECT_PROPS; + particleProps.isEmitting = 0; + particleProps.position = calcParticlePos(myHandPosition, otherHand, otherOrientation); + particleProps.parentID = MyAvatar.sessionUUID; + particleEffect = Entities.addEntity(particleProps, true); + } else { + particleProps.position = calcParticlePos(myHandPosition, otherHand, otherOrientation); + particleProps.isEmitting = 1; + Entities.editEntity(particleEffect, particleProps); + } + if (!makingConnectionParticleEffect) { + var props = MAKING_CONNECTION_PARTICLE_PROPS; + props.parentID = MyAvatar.sessionUUID; + makingConnectionEmitRate = 2000; + props.emitRate = makingConnectionEmitRate; + props.position = myHandPosition; + makingConnectionParticleEffect = Entities.addEntity(props, true); + } else { + makingConnectionEmitRate *= 0.5; + Entities.editEntity(makingConnectionParticleEffect, { + emitRate: makingConnectionEmitRate, + position: myHandPosition, + isEmitting: true + }); + } + break; + case STATES.MAKING_CONNECTION: + particleEmitRate = Math.max(50, particleEmitRate * 0.5); + Entities.editEntity(makingConnectionParticleEffect, {emitRate: 0, isEmitting: 0, position: myHandPosition}); + Entities.editEntity(particleEffect, { + position: calcParticlePos(myHandPosition, otherHand, otherOrientation), + emitRate: particleEmitRate }); + break; + default: + debug("unexpected state", state); + break; + } + } + + function isNearby(id, hand) { + if (currentHand) { + var handPos = getHandPosition(MyAvatar, currentHandJointIndex); + var avatar = AvatarList.getAvatar(id); + if (avatar) { + var otherHand = stringToHand(hand); + var otherHandJointIndex = getIdealHandJointIndex(avatar, otherHand); + var distance = Vec3.distance(getHandPosition(avatar, otherHandJointIndex), handPos); + return (distance < MAX_AVATAR_DISTANCE); } - } else { - // something happened, stop looking for avatars to connect - stopWaiting(); - debug("stopped looking for waiting avatars"); } - }, WAITING_INTERVAL); -} - -/* There is a mini-state machine after entering STATES.makingConnection. - We make a request (which might immediately succeed, fail, or neither. - If we immediately fail, we tell the user. - Otherwise, we wait MAKING_CONNECTION_TIMEOUT. At that time, we poll until success or fail. - */ -var result, requestBody, pollCount = 0, requestUrl = location.metaverseServerUrl + '/api/v1/user/connection_request'; -function connectionRequestCompleted() { // Final result is in. Do effects. - if (result.status === 'success') { // set earlier - if (!successfulHandshakeInjector) { - successfulHandshakeInjector = Audio.playSound(successfulHandshakeSound, {position: getHandPosition(MyAvatar, currentHand), volume: 0.5, localOnly: true}); - } else { - successfulHandshakeInjector.restart(); - } - Controller.triggerHapticPulse(HAPTIC_DATA.success.strength, HAPTIC_DATA.success.duration, handToHaptic(currentHand)); - // don't change state (so animation continues while gripped) - // but do send a notification, by calling the slot that emits the signal for it - Window.makeConnection(true, result.connection.new_connection ? "You and " + result.connection.username + " are now connected!" : result.connection.username); - UserActivityLogger.makeUserConnection(connectingId, true, result.connection.new_connection ? "new connection" : "already connected"); - return; - } // failed - endHandshake(); - debug("failing with result data", result); - // IWBNI we also did some fail sound/visual effect. - Window.makeConnection(false, result.connection); - UserActivityLogger.makeUserConnection(connectingId, false, result.connection); -} -var POLL_INTERVAL_MS = 200, POLL_LIMIT = 5; -function handleConnectionResponseAndMaybeRepeat(error, response) { - // If response is 'pending', set a short timeout to try again. - // If we fail other than pending, set result and immediately call connectionRequestCompleted. - // If we succceed, set result and call connectionRequestCompleted immediately (if we've been polling), and otherwise on a timeout. - if (response && (response.connection === 'pending')) { - debug(response, 'pollCount', pollCount); - if (pollCount++ >= POLL_LIMIT) { // server will expire, but let's not wait that long. - debug('POLL LIMIT REACHED; TIMEOUT: expired message generated by CLIENT'); - result = {status: 'error', connection: 'expired'}; - connectionRequestCompleted(); - } else { // poll - Script.setTimeout(function () { - request({ - uri: requestUrl, - // N.B.: server gives bad request if we specify json content type, so don't do that. - body: requestBody - }, handleConnectionResponseAndMaybeRepeat); - }, POLL_INTERVAL_MS); - } - } else if (error || (response.status !== 'success')) { - debug('server fail', error, response.status); - if (response && (response.statusCode === 401)) { - error = "All participants must be logged in to connect."; - } - result = error ? {status: 'error', connection: error} : response; - UserActivityLogger.makeUserConnection(connectingId, false, error || response); - connectionRequestCompleted(); - } else { - result = response; - debug('server success', result); - if (pollCount++) { - connectionRequestCompleted(); - } else { // Wait for other guy, so that final succcess is at roughly the same time. - Script.setTimeout(connectionRequestCompleted, MAKING_CONNECTION_TIMEOUT); - } - } -} - -// this should be where we make the appropriate connection call. For now just make the -// visualization change. -function makeConnection(id) { - // send done to let the connection know you have made connection. - messageSend({ - key: "done", - connectionId: id - }); - - state = STATES.makingConnection; - - // continue the haptic background until the timeout fires. When we make calls, we will have an interval - // probably, in which we do this. - Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, MAKING_CONNECTION_TIMEOUT, handToHaptic(currentHand)); - requestBody = {node_id: cleanId(MyAvatar.sessionUUID), proposed_node_id: cleanId(id)}; // for use when repeating - - // It would be "simpler" to skip this and just look at the response, but: - // 1. We don't want to bother the metaverse with request that we know will fail. - // 2. We don't want our code here to be dependent on precisely how the metaverse responds (400, 401, etc.) - if (!Account.isLoggedIn()) { - handleConnectionResponseAndMaybeRepeat("401:Unauthorized", {statusCode: 401}); - return; + return false; } - // This will immediately set response if successfull (e.g., the other guy got his request in first), or immediate failure, - // and will otherwise poll (using the requestBody we just set). - request({ // - uri: requestUrl, - method: 'POST', - json: true, - body: {user_connection_request: requestBody} - }, handleConnectionResponseAndMaybeRepeat); -} - -// we change states, start the connectionInterval where we check -// to be sure the hand is still close enough. If not, we terminate -// the interval, go back to the waiting state. If we make it -// the entire CONNECTING_TIME, we make the connection. -function startConnecting(id, hand) { - var count = 0; - debug("connecting", id, "hand", hand); - // do we need to do this? - connectingId = id; - connectingHand = hand; - state = STATES.connecting; - - // play sound - if (!handshakeInjector) { - handshakeInjector = Audio.playSound(handshakeSound, {position: getHandPosition(MyAvatar, currentHand), volume: 0.5, localOnly: true}); - } else { - handshakeInjector.restart(); + function findNearestWaitingAvatar() { + var handPos = getHandPosition(MyAvatar, currentHandJointIndex); + var minDistance = MAX_AVATAR_DISTANCE; + var nearestAvatar = {}; + Object.keys(waitingList).forEach(function (identifier) { + var avatar = AvatarList.getAvatar(identifier); + if (avatar) { + var hand = stringToHand(waitingList[identifier]); + var handJointIndex = getIdealHandJointIndex(avatar, hand); + var distance = Vec3.distance(getHandPosition(avatar, handJointIndex), handPos); + if (distance < minDistance) { + minDistance = distance; + nearestAvatar = {avatar: identifier, hand: hand, avatarObject: avatar}; + } + } + }); + return nearestAvatar; } - // send message that we are connecting with them - messageSend({ - key: "connecting", - id: id, - hand: handToString(currentHand) - }); - Controller.triggerHapticPulse(HAPTIC_DATA.initial.strength, HAPTIC_DATA.initial.duration, handToHaptic(currentHand)); - connectingInterval = Script.setInterval(function () { - count += 1; - Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, HAPTIC_DATA.background.duration, handToHaptic(currentHand)); - if (state != STATES.connecting) { - debug("stopping connecting interval, state changed"); - stopConnecting(); - } else if (!isNearby(id, hand)) { - // gotta go back to waiting - debug(id, "moved, back to waiting"); - stopConnecting(); + // As currently implemented, we select the closest waiting avatar (if close enough) and send + // them a connectionRequest. If nobody is close enough we send a waiting message, and wait for a + // connectionRequest. If the 2 people who want to connect are both somewhat out of range when they + // initiate the shake, they will race to see who sends the connectionRequest after noticing the + // waiting message. Either way, they will start connecting eachother at that point. + function startHandshake(fromKeyboard) { + if (fromKeyboard) { + debug("adding animation"); + // just in case order of press/unpress is broken + if (animHandlerId) { + animHandlerId = MyAvatar.removeAnimationStateHandler(animHandlerId); + } + animHandlerId = MyAvatar.addAnimationStateHandler(shakeHandsAnimation, []); + } + debug("starting handshake for", currentHand); + pollCount = 0; + state = STATES.WAITING; + connectingId = undefined; + connectingHandString = undefined; + connectingHandJointIndex = -1; + // just in case + stopWaiting(); + stopConnecting(); + stopMakingConnection(); + + var nearestAvatar = findNearestWaitingAvatar(); + if (nearestAvatar.avatar) { + connectingId = nearestAvatar.avatar; + connectingHandString = handToString(nearestAvatar.hand); + connectingHandJointIndex = getIdealHandJointIndex(nearestAvatar.avatarObject, nearestAvatar.hand); + currentHandJointIndex = getIdealHandJointIndex(MyAvatar, currentHand); + debug("sending connectionRequest to", connectingId); messageSend({ - key: "done" - }); - startHandshake(); - } else if (count > CONNECTING_TIME/CONNECTING_INTERVAL) { - debug("made connection with " + id); - makeConnection(id); - stopConnecting(); - } - }, CONNECTING_INTERVAL); -} -/* -A simple sequence diagram: NOTE that the ConnectionAck is somewhat -vestigial, and probably should be removed shortly. - - Avatar A Avatar B - | | - | <-----(waiting) ----- startHandshake -startHandshake - (connectionRequest) -> | - | | - | <----(connectionAck) -------- | - | <-----(connecting) -- startConnecting - startConnecting ---(connecting) ----> | - | | - | connected - connected | - | <--------- (done) ---------- | - | ---------- (done) ---------> | -*/ -function messageHandler(channel, messageString, senderID) { - if (channel !== MESSAGE_CHANNEL) { - return; - } - if (MyAvatar.sessionUUID === senderID) { // ignore my own - return; - } - var message = {}; - try { - message = JSON.parse(messageString); - } catch (e) { - debug(e); - } - switch (message.key) { - case "waiting": - // add this guy to waiting object. Any other message from this person will - // remove it from the list - waitingList[senderID] = message.hand; - break; - case "connectionRequest": - delete waitingList[senderID]; - if (state == STATES.waiting && message.id == MyAvatar.sessionUUID && (!connectingId || connectingId == senderID)) { - // you were waiting for a connection request, so send the ack. Or, you and the other - // guy raced and both send connectionRequests. Handle that too - connectingId = senderID; - connectingHand = message.hand; - messageSend({ - key: "connectionAck", - id: senderID, + key: "connectionRequest", + id: connectingId, hand: handToString(currentHand) }); } else { - if (state == STATES.waiting && connectingId == senderID) { - // the person you are trying to connect sent a request to someone else. See the - // if statement above. So, don't cry, just start the handshake over again - startHandshake(); - } + // send waiting message + debug("sending waiting message"); + messageSend({ + key: "waiting", + hand: handToString(currentHand) + }); + lookForWaitingAvatar(); } - break; - case "connectionAck": - delete waitingList[senderID]; - if (state == STATES.waiting && (!connectingId || connectingId == senderID)) { - if (message.id == MyAvatar.sessionUUID) { - // start connecting... - connectingId = senderID; - connectingHand = message.hand; - stopWaiting(); - startConnecting(senderID, message.hand); + } + + function endHandshake() { + debug("ending handshake for", currentHand); + + deleteParticleEffect(); + deleteMakeConnectionParticleEffect(); + currentHand = undefined; + currentHandJointIndex = -1; + // note that setting the state to inactive should really + // only be done here, unless we change how the triggering works, + // as we ignore the key release event when inactive. See updateTriggers + // below. + state = STATES.INACTIVE; + connectingId = undefined; + connectingHandString = undefined; + connectingHandJointIndex = -1; + stopWaiting(); + stopConnecting(); + stopMakingConnection(); + stopHandshakeSound(); + // send done to let connection know you are not making connections now + messageSend({ + key: "done" + }); + + if (animHandlerId) { + debug("removing animation"); + MyAvatar.removeAnimationStateHandler(animHandlerId); + } + // No-op if we were successful, but this way we ensure that failures and abandoned handshakes don't leave us + // in a weird state. + request({uri: requestUrl, method: 'DELETE'}, debug); + } + + function updateTriggers(value, fromKeyboard, hand) { + if (currentHand && hand !== currentHand) { + debug("currentHand", currentHand, "ignoring messages from", hand); + return; + } + if (!currentHand) { + currentHand = hand; + currentHandJointIndex = getIdealHandJointIndex(MyAvatar, currentHand); + } + // ok now, we are either initiating or quitting... + var isGripping = value > GRIP_MIN; + if (isGripping) { + debug("updateTriggers called - gripping", handToString(hand)); + if (state !== STATES.INACTIVE) { + return; } else { - if (connectingId) { - // this is for someone else (we lost race in connectionRequest), - // so lets start over - startHandshake(); - } - } - } - // TODO: check to see if we are waiting for this but the person we are connecting sent it to - // someone else, and try again - break; - case "connecting": - delete waitingList[senderID]; - if (state == STATES.waiting && senderID == connectingId) { - // temporary logging - if (connectingHand != message.hand) { - debug("connecting hand", connectingHand, "not same as connecting hand in message", message.hand); - } - connectingHand = message.hand; - if (message.id != MyAvatar.sessionUUID) { - // the person we were trying to connect is connecting to someone else - // so try again - startHandshake(); - break; - } - startConnecting(senderID, message.hand); - } - break; - case "done": - delete waitingList[senderID]; - if (state == STATES.connecting && connectingId == senderID) { - // if they are done, and didn't connect us, terminate our - // connecting - if (message.connectionId !== MyAvatar.sessionUUID) { - stopConnecting(); - // now just call startHandshake. Should be ok to do so without a - // value for isKeyboard, as we should not change the animation - // state anyways (if any) - startHandshake(); + startHandshake(fromKeyboard); } } else { - // if waiting or inactive, lets clear the connecting id. If in makingConnection, - // do nothing - if (state != STATES.makingConnection && connectingId == senderID) { - connectingId = undefined; - connectingHand = undefined; - if (state != STATES.inactive) { - startHandshake(); - } + // TODO: should we end handshake even when inactive? Ponder + debug("updateTriggers called -- no longer gripping", handToString(hand)); + if (state !== STATES.INACTIVE) { + endHandshake(); + } else { + return; } } - break; - default: - debug("unknown message", message); - break; } -} -Messages.subscribe(MESSAGE_CHANNEL); -Messages.messageReceived.connect(messageHandler); - - -function makeGripHandler(hand, animate) { - // determine if we are gripping or un-gripping - if (animate) { - return function(value) { - updateTriggers(value, true, hand); - }; - - } else { - return function (value) { - updateTriggers(value, false, hand); - }; + function messageSend(message) { + Messages.sendMessage(MESSAGE_CHANNEL, JSON.stringify(message)); } -} -function keyPressEvent(event) { - if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && !event.isAlt) { - updateTriggers(1.0, true, Controller.Standard.RightHand); + function lookForWaitingAvatar() { + // we started with nobody close enough, but maybe I've moved + // or they did. Note that 2 people doing this race, so stop + // as soon as you have a connectingId (which means you got their + // message before noticing they were in range in this loop) + + // just in case we re-enter before stopping + stopWaiting(); + debug("started looking for waiting avatars"); + waitingInterval = Script.setInterval(function () { + if (state === STATES.WAITING && !connectingId) { + // find the closest in-range avatar, and send connection request + var nearestAvatar = findNearestWaitingAvatar(); + if (nearestAvatar.avatar) { + connectingId = nearestAvatar.avatar; + connectingHandString = handToString(nearestAvatar.hand); + debug("sending connectionRequest to", connectingId); + messageSend({ + key: "connectionRequest", + id: connectingId, + hand: handToString(currentHand) + }); + } + } else { + // something happened, stop looking for avatars to connect + stopWaiting(); + debug("stopped looking for waiting avatars"); + } + }, WAITING_INTERVAL); } -} -function keyReleaseEvent(event) { - if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && !event.isAlt) { - updateTriggers(0.0, true, Controller.Standard.RightHand); + + /* There is a mini-state machine after entering STATES.makingConnection. + We make a request (which might immediately succeed, fail, or neither. + If we immediately fail, we tell the user. + Otherwise, we wait MAKING_CONNECTION_TIMEOUT. At that time, we poll until success or fail. + */ + var result, requestBody, pollCount = 0, requestUrl = location.metaverseServerUrl + '/api/v1/user/connection_request'; + function connectionRequestCompleted() { // Final result is in. Do effects. + if (result.status === 'success') { // set earlier + if (!successfulHandshakeInjector) { + successfulHandshakeInjector = Audio.playSound(successfulHandshakeSound, { + position: getHandPosition(MyAvatar, currentHandJointIndex), + volume: 0.5, + localOnly: true + }); + } else { + successfulHandshakeInjector.restart(); + } + Controller.triggerHapticPulse(HAPTIC_DATA.success.strength, HAPTIC_DATA.success.duration, + handToHaptic(currentHand)); + // don't change state (so animation continues while gripped) + // but do send a notification, by calling the slot that emits the signal for it + Window.makeConnection(true, result.connection.new_connection ? + "You and " + result.connection.username + " are now connected!" : result.connection.username); + UserActivityLogger.makeUserConnection(connectingId, true, result.connection.new_connection ? + "new connection" : "already connected"); + return; + } // failed + endHandshake(); + debug("failing with result data", result); + // IWBNI we also did some fail sound/visual effect. + Window.makeConnection(false, result.connection); + UserActivityLogger.makeUserConnection(connectingId, false, result.connection); + } + var POLL_INTERVAL_MS = 200, POLL_LIMIT = 5; + function handleConnectionResponseAndMaybeRepeat(error, response) { + // If response is 'pending', set a short timeout to try again. + // If we fail other than pending, set result and immediately call connectionRequestCompleted. + // If we succeed, set result and call connectionRequestCompleted immediately (if we've been polling), + // and otherwise on a timeout. + if (response && (response.connection === 'pending')) { + debug(response, 'pollCount', pollCount); + if (pollCount++ >= POLL_LIMIT) { // server will expire, but let's not wait that long. + debug('POLL LIMIT REACHED; TIMEOUT: expired message generated by CLIENT'); + result = {status: 'error', connection: 'expired'}; + connectionRequestCompleted(); + } else { // poll + Script.setTimeout(function () { + request({ + uri: requestUrl, + // N.B.: server gives bad request if we specify json content type, so don't do that. + body: requestBody + }, handleConnectionResponseAndMaybeRepeat); + }, POLL_INTERVAL_MS); + } + } else if (error || (response.status !== 'success')) { + debug('server fail', error, response.status); + if (response && (response.statusCode === 401)) { + error = "All participants must be logged in to connect."; + } + result = error ? {status: 'error', connection: error} : response; + UserActivityLogger.makeUserConnection(connectingId, false, error || response); + connectionRequestCompleted(); + } else { + result = response; + debug('server success', result); + if (pollCount++) { + connectionRequestCompleted(); + } else { // Wait for other guy, so that final success is at roughly the same time. + Script.setTimeout(connectionRequestCompleted, MAKING_CONNECTION_TIMEOUT); + } + } } -} -// map controller actions -var connectionMapping = Controller.newMapping(Script.resolvePath('') + '-grip'); -connectionMapping.from(Controller.Standard.LeftGrip).peek().to(makeGripHandler(Controller.Standard.LeftHand)); -connectionMapping.from(Controller.Standard.RightGrip).peek().to(makeGripHandler(Controller.Standard.RightHand)); -// setup keyboard initiation -Controller.keyPressEvent.connect(keyPressEvent); -Controller.keyReleaseEvent.connect(keyReleaseEvent); + // this should be where we make the appropriate connection call. For now just make the + // visualization change. + function makeConnection(id) { + // send done to let the connection know you have made connection. + messageSend({ + key: "done", + connectionId: id + }); -// xbox controller cuz that's important -connectionMapping.from(Controller.Standard.RB).peek().to(makeGripHandler(Controller.Standard.RightHand, true)); + state = STATES.MAKING_CONNECTION; -// it is easy to forget this and waste a lot of time for nothing -connectionMapping.enable(); + // continue the haptic background until the timeout fires. When we make calls, we will have an interval + // probably, in which we do this. + Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, MAKING_CONNECTION_TIMEOUT, handToHaptic(currentHand)); + requestBody = {'node_id': cleanId(MyAvatar.sessionUUID), 'proposed_node_id': cleanId(id)}; // for use when repeating -// connect updateVisualization to update frequently -Script.update.connect(updateVisualization); + // It would be "simpler" to skip this and just look at the response, but: + // 1. We don't want to bother the metaverse with request that we know will fail. + // 2. We don't want our code here to be dependent on precisely how the metaverse responds (400, 401, etc.) + if (!Account.isLoggedIn()) { + handleConnectionResponseAndMaybeRepeat("401:Unauthorized", {statusCode: 401}); + return; + } -// load the sounds when the script loads -handshakeSound = SoundCache.getSound(HANDSHAKE_SOUND_URL); -successfulHandshakeSound = SoundCache.getSound(SUCCESSFUL_HANDSHAKE_SOUND_URL); + // This will immediately set response if successful (e.g., the other guy got his request in first), + // or immediate failure, and will otherwise poll (using the requestBody we just set). + request({ // + uri: requestUrl, + method: 'POST', + json: true, + body: {'user_connection_request': requestBody} + }, handleConnectionResponseAndMaybeRepeat); + } -Script.scriptEnding.connect(function () { - debug("removing controller mappings"); - connectionMapping.disable(); - debug("removing key mappings"); - Controller.keyPressEvent.disconnect(keyPressEvent); - Controller.keyReleaseEvent.disconnect(keyReleaseEvent); - debug("disconnecting updateVisualization"); - Script.update.disconnect(updateVisualization); - deleteParticleEffect(); - deleteMakeConnectionParticleEffect(); -}); + // we change states, start the connectionInterval where we check + // to be sure the hand is still close enough. If not, we terminate + // the interval, go back to the waiting state. If we make it + // the entire CONNECTING_TIME, we make the connection. + function startConnecting(id, hand) { + var count = 0; + debug("connecting", id, "hand", hand); + // do we need to do this? + connectingId = id; + connectingHandString = hand; + connectingHandJointIndex = AvatarList.getAvatarIdentifiers().indexOf(connectingId) !== -1 ? + getIdealHandJointIndex(AvatarList.getAvatar(connectingId), stringToHand(connectingHandString)) : -1; + state = STATES.CONNECTING; + + // play sound + if (!handshakeInjector) { + handshakeInjector = Audio.playSound(handshakeSound, { + position: getHandPosition(MyAvatar, currentHandJointIndex), + volume: 0.5, + localOnly: true + }); + } else { + handshakeInjector.restart(); + } + + // send message that we are connecting with them + messageSend({ + key: "connecting", + id: id, + hand: handToString(currentHand) + }); + Controller.triggerHapticPulse(HAPTIC_DATA.initial.strength, HAPTIC_DATA.initial.duration, handToHaptic(currentHand)); + + connectingInterval = Script.setInterval(function () { + count += 1; + Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, HAPTIC_DATA.background.duration, + handToHaptic(currentHand)); + if (state !== STATES.CONNECTING) { + debug("stopping connecting interval, state changed"); + stopConnecting(); + } else if (!isNearby(id, hand)) { + // gotta go back to waiting + debug(id, "moved, back to waiting"); + stopConnecting(); + messageSend({ + key: "done" + }); + startHandshake(); + } else if (count > CONNECTING_TIME/CONNECTING_INTERVAL) { + debug("made connection with " + id); + makeConnection(id); + stopConnecting(); + } + }, CONNECTING_INTERVAL); + } + /* + A simple sequence diagram: NOTE that the ConnectionAck is somewhat + vestigial, and probably should be removed shortly. + + Avatar A Avatar B + | | + | <-----(waiting) ----- startHandshake + startHandshake - (connectionRequest) -> | + | | + | <----(connectionAck) -------- | + | <-----(connecting) -- startConnecting + startConnecting ---(connecting) ----> | + | | + | connected + connected | + | <--------- (done) ---------- | + | ---------- (done) ---------> | + */ + function messageHandler(channel, messageString, senderID) { + if (channel !== MESSAGE_CHANNEL) { + return; + } + if (MyAvatar.sessionUUID === senderID) { // ignore my own + return; + } + var message = {}; + try { + message = JSON.parse(messageString); + } catch (e) { + debug(e); + } + switch (message.key) { + case "waiting": + // add this guy to waiting object. Any other message from this person will + // remove it from the list + waitingList[senderID] = message.hand; + break; + case "connectionRequest": + delete waitingList[senderID]; + if (state === STATES.WAITING && message.id === MyAvatar.sessionUUID && + (!connectingId || connectingId === senderID)) { + // you were waiting for a connection request, so send the ack. Or, you and the other + // guy raced and both send connectionRequests. Handle that too + connectingId = senderID; + connectingHandString = message.hand; + connectingHandJointIndex = AvatarList.getAvatarIdentifiers().indexOf(connectingId) !== -1 ? + getIdealHandJointIndex(AvatarList.getAvatar(connectingId), stringToHand(connectingHandString)) : -1; + messageSend({ + key: "connectionAck", + id: senderID, + hand: handToString(currentHand) + }); + } else if (state === STATES.WAITING && connectingId === senderID) { + // the person you are trying to connect sent a request to someone else. See the + // if statement above. So, don't cry, just start the handshake over again + startHandshake(); + } + break; + case "connectionAck": + delete waitingList[senderID]; + if (state === STATES.WAITING && (!connectingId || connectingId === senderID)) { + if (message.id === MyAvatar.sessionUUID) { + // start connecting... + connectingId = senderID; + connectingHandString = message.hand; + connectingHandJointIndex = AvatarList.getAvatarIdentifiers().indexOf(connectingId) !== -1 ? + getIdealHandJointIndex(AvatarList.getAvatar(connectingId), stringToHand(connectingHandString)) : -1; + stopWaiting(); + startConnecting(senderID, connectingHandString); + } else if (connectingId) { + // this is for someone else (we lost race in connectionRequest), + // so lets start over + startHandshake(); + } + } + // TODO: check to see if we are waiting for this but the person we are connecting sent it to + // someone else, and try again + break; + case "connecting": + delete waitingList[senderID]; + if (state === STATES.WAITING && senderID === connectingId) { + // temporary logging + if (connectingHandString !== message.hand) { + debug("connecting hand", connectingHandString, "not same as connecting hand in message", message.hand); + } + connectingHandString = message.hand; + if (message.id !== MyAvatar.sessionUUID) { + // the person we were trying to connect is connecting to someone else + // so try again + startHandshake(); + break; + } + startConnecting(senderID, message.hand); + } + break; + case "done": + delete waitingList[senderID]; + if (state === STATES.CONNECTING && connectingId === senderID) { + // if they are done, and didn't connect us, terminate our + // connecting + if (message.connectionId !== MyAvatar.sessionUUID) { + stopConnecting(); + // now just call startHandshake. Should be ok to do so without a + // value for isKeyboard, as we should not change the animation + // state anyways (if any) + startHandshake(); + } + } else { + // if waiting or inactive, lets clear the connecting id. If in makingConnection, + // do nothing + if (state !== STATES.MAKING_CONNECTION && connectingId === senderID) { + connectingId = undefined; + connectingHandString = undefined; + connectingHandJointIndex = -1; + if (state !== STATES.INACTIVE) { + startHandshake(); + } + } + } + break; + default: + debug("unknown message", message); + break; + } + } + + Messages.subscribe(MESSAGE_CHANNEL); + Messages.messageReceived.connect(messageHandler); + + + function makeGripHandler(hand, animate) { + // determine if we are gripping or un-gripping + if (animate) { + return function(value) { + updateTriggers(value, true, hand); + }; + + } else { + return function (value) { + updateTriggers(value, false, hand); + }; + } + } + + function keyPressEvent(event) { + if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && + !event.isAlt) { + updateTriggers(1.0, true, Controller.Standard.RightHand); + } + } + function keyReleaseEvent(event) { + if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && + !event.isAlt) { + updateTriggers(0.0, true, Controller.Standard.RightHand); + } + } + // map controller actions + var connectionMapping = Controller.newMapping(Script.resolvePath('') + '-grip'); + connectionMapping.from(Controller.Standard.LeftGrip).peek().to(makeGripHandler(Controller.Standard.LeftHand)); + connectionMapping.from(Controller.Standard.RightGrip).peek().to(makeGripHandler(Controller.Standard.RightHand)); + + // setup keyboard initiation + Controller.keyPressEvent.connect(keyPressEvent); + Controller.keyReleaseEvent.connect(keyReleaseEvent); + + // Xbox controller because that is important + connectionMapping.from(Controller.Standard.RB).peek().to(makeGripHandler(Controller.Standard.RightHand, true)); + + // it is easy to forget this and waste a lot of time for nothing + connectionMapping.enable(); + + // connect updateVisualization to update frequently + Script.update.connect(updateVisualization); + + // load the sounds when the script loads + handshakeSound = SoundCache.getSound(HANDSHAKE_SOUND_URL); + successfulHandshakeSound = SoundCache.getSound(SUCCESSFUL_HANDSHAKE_SOUND_URL); + + Script.scriptEnding.connect(function () { + debug("removing controller mappings"); + connectionMapping.disable(); + debug("removing key mappings"); + Controller.keyPressEvent.disconnect(keyPressEvent); + Controller.keyReleaseEvent.disconnect(keyReleaseEvent); + debug("disconnecting updateVisualization"); + Script.update.disconnect(updateVisualization); + deleteParticleEffect(); + deleteMakeConnectionParticleEffect(); + }); }()); // END LOCAL_SCOPE - diff --git a/scripts/system/particle_explorer/particleExplorer.html b/scripts/system/particle_explorer/particleExplorer.html index d12ceac14b..d0d86d79da 100644 --- a/scripts/system/particle_explorer/particleExplorer.html +++ b/scripts/system/particle_explorer/particleExplorer.html @@ -14,10 +14,13 @@ --> + + +