diff --git a/interface/resources/icons/checkmark-stroke.svg b/interface/resources/icons/checkmark-stroke.svg new file mode 100644 index 0000000000..cc343c421b --- /dev/null +++ b/interface/resources/icons/checkmark-stroke.svg @@ -0,0 +1,4 @@ + + + + diff --git a/interface/resources/icons/loader-snake-256-wf.gif b/interface/resources/icons/loader-snake-256-wf.gif new file mode 100644 index 0000000000..c0d5eec1ef Binary files /dev/null and b/interface/resources/icons/loader-snake-256-wf.gif differ diff --git a/interface/resources/icons/loader-snake-256.gif b/interface/resources/icons/loader-snake-256.gif new file mode 100644 index 0000000000..ebcbf54bd7 Binary files /dev/null and b/interface/resources/icons/loader-snake-256.gif differ diff --git a/interface/resources/images/loader-snake-128.png b/interface/resources/images/loader-snake-128.png new file mode 100644 index 0000000000..b8ee577664 Binary files /dev/null and b/interface/resources/images/loader-snake-128.png differ diff --git a/interface/resources/qml/controlsUit/Button.qml b/interface/resources/qml/controlsUit/Button.qml index c5c879a24c..3c5626e29e 100644 --- a/interface/resources/qml/controlsUit/Button.qml +++ b/interface/resources/qml/controlsUit/Button.qml @@ -32,6 +32,10 @@ Original.Button { width: hifi.dimensions.buttonWidth height: hifi.dimensions.controlLineHeight + property size implicitPadding: Qt.size(20, 16) + property int implicitWidth: buttonContentItem.implicitWidth + implicitPadding.width + property int implicitHeight: buttonContentItem.implicitHeight + implicitPadding.height + HifiConstants { id: hifi } onHoveredChanged: { @@ -94,6 +98,8 @@ Original.Button { contentItem: Item { id: buttonContentItem + implicitWidth: (buttonGlyph.visible ? buttonGlyph.implicitWidth : 0) + buttonText.implicitWidth + implicitHeight: buttonText.implicitHeight TextMetrics { id: buttonGlyphTextMetrics; font: buttonGlyph.font; diff --git a/interface/resources/qml/hifi/AvatarPackagerWindow.qml b/interface/resources/qml/hifi/AvatarPackagerWindow.qml new file mode 100644 index 0000000000..82bcd3fa40 --- /dev/null +++ b/interface/resources/qml/hifi/AvatarPackagerWindow.qml @@ -0,0 +1,24 @@ +import QtQuick 2.6 +import "../stylesUit" 1.0 +import "../windows" as Windows +import "avatarPackager" 1.0 + +Windows.ScrollingWindow { + id: root + objectName: "AvatarPackager" + width: 480 + height: 706 + title: "Avatar Packager" + resizable: false + opacity: parent.opacity + destroyOnHidden: true + implicitWidth: 384; implicitHeight: 640 + minSize: Qt.vector2d(480, 706) + + HifiConstants { id: hifi } + + AvatarPackagerApp { + height: pane.height + width: pane.width + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerApp.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerApp.qml new file mode 100644 index 0000000000..b4293d5eee --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerApp.qml @@ -0,0 +1,396 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import QtQml.Models 2.1 +import QtGraphicalEffects 1.0 +import Hifi.AvatarPackager.AvatarProjectStatus 1.0 +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 +import "../../controls" 1.0 +import "../../dialogs" +import "../avatarapp" 1.0 as AvatarApp + +Item { + id: windowContent + + HifiConstants { id: hifi } + + property alias desktopObject: avatarPackager.desktopObject + + MouseArea { + anchors.fill: parent + + onClicked: { + unfocusser.forceActiveFocus(); + } + Item { + id: unfocusser + visible: false + } + } + + InfoBox { + id: fileListPopup + + title: "List of Files" + + content: Rectangle { + id: fileList + + color: "#404040" + + anchors.fill: parent + anchors.topMargin: 10 + anchors.bottomMargin: 10 + anchors.leftMargin: 29 + anchors.rightMargin: 29 + + clip: true + + ListView { + anchors.fill: parent + model: AvatarPackagerCore.currentAvatarProject === null ? [] : AvatarPackagerCore.currentAvatarProject.projectFiles + delegate: Rectangle { + width: parent.width + height: fileText.implicitHeight + 8 + color: "#404040" + RalewaySemiBold { + id: fileText + size: 16 + elide: Text.ElideLeft + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 4 + width: parent.width - 10 + color: "white" + text: modelData + } + } + } + } + } + + InfoBox { + id: errorPopup + + property string errorMessage + + boxWidth: 380 + boxHeight: 293 + + content: RalewayRegular { + + id: bodyMessage + + anchors.fill: parent + anchors.bottomMargin: 10 + anchors.leftMargin: 29 + anchors.rightMargin: 29 + + size: 20 + color: "white" + text: errorPopup.errorMessage + width: parent.width + wrapMode: Text.WordWrap + } + + function show(title, message) { + errorPopup.title = title; + errorMessage = message; + errorPopup.open(); + } + } + + Rectangle { + id: modalOverlay + anchors.fill: parent + z: 20 + color: "#a15d5d5d" + visible: false + + // This mouse area captures the cursor events while the modalOverlay is active + MouseArea { + anchors.fill: parent + propagateComposedEvents: false + hoverEnabled: true + } + } + + AvatarApp.MessageBox { + id: popup + anchors.fill: parent + visible: false + closeOnClickOutside: true + } + + Column { + id: avatarPackager + anchors.fill: parent + state: "main" + states: [ + State { + name: AvatarPackagerState.main + PropertyChanges { target: avatarPackagerHeader; title: qsTr("Avatar Packager"); docsEnabled: true; backButtonVisible: false } + PropertyChanges { target: avatarPackagerMain; visible: true } + PropertyChanges { target: avatarPackagerFooter; content: avatarPackagerMain.footer } + }, + State { + name: AvatarPackagerState.createProject + PropertyChanges { target: avatarPackagerHeader; title: qsTr("Create Project") } + PropertyChanges { target: createAvatarProject; visible: true } + PropertyChanges { target: avatarPackagerFooter; content: createAvatarProject.footer } + }, + State { + name: AvatarPackagerState.project + PropertyChanges { target: avatarPackagerHeader; title: AvatarPackagerCore.currentAvatarProject.name; canRename: true } + PropertyChanges { target: avatarProject; visible: true } + PropertyChanges { target: avatarPackagerFooter; content: avatarProject.footer } + }, + State { + name: AvatarPackagerState.projectUpload + PropertyChanges { target: avatarPackagerHeader; title: AvatarPackagerCore.currentAvatarProject.name; backButtonEnabled: false } + PropertyChanges { target: avatarUploader; visible: true } + PropertyChanges { target: avatarPackagerFooter; visible: false } + } + ] + + property alias showModalOverlay: modalOverlay.visible + + property var desktopObject: desktop + + function openProject(path) { + let status = AvatarPackagerCore.openAvatarProject(path); + if (status !== AvatarProjectStatus.SUCCESS) { + displayErrorMessage(status); + return status; + } + avatarProject.reset(); + avatarPackager.state = AvatarPackagerState.project; + return status; + } + + function displayErrorMessage(status) { + if (status === AvatarProjectStatus.SUCCESS) { + return; + } + switch (status) { + case AvatarProjectStatus.ERROR_CREATE_PROJECT_NAME: + errorPopup.show("Project Folder Already Exists", "A folder with that name already exists at that location. Please choose a different project name or location."); + break; + case AvatarProjectStatus.ERROR_CREATE_CREATING_DIRECTORIES: + errorPopup.show("Project Folders Creation Error", "There was a problem creating the Avatar Project directory. Please check the project location and try again."); + break; + case AvatarProjectStatus.ERROR_CREATE_FIND_MODEL: + errorPopup.show("Cannot Find Model File", "There was a problem while trying to find the specified model file. Please verify that it exists at the specified location."); + break; + case AvatarProjectStatus.ERROR_CREATE_OPEN_MODEL: + errorPopup.show("Cannot Open Model File", "There was a problem while trying to open the specified model file."); + break; + case AvatarProjectStatus.ERROR_CREATE_READ_MODEL: + errorPopup.show("Error Read Model File", "There was a problem while trying to read the specified model file. Please check that the file is a valid FBX file and try again."); + break; + case AvatarProjectStatus.ERROR_CREATE_WRITE_FST: + errorPopup.show("Error Writing Project File", "There was a problem while trying to write the FST file."); + break; + case AvatarProjectStatus.ERROR_OPEN_INVALID_FILE_TYPE: + errorPopup.show("Invalid Project Path", "The avatar packager can only open FST files."); + break; + case AvatarProjectStatus.ERROR_OPEN_PROJECT_FOLDER: + errorPopup.show("Project Missing", "Project folder cannot be found. Please locate the folder and copy/move it to its original location."); + break; + case AvatarProjectStatus.ERROR_OPEN_FIND_FST: + errorPopup.show("File Missing", "We cannot find the project file (.fst) in the project folder. Please locate it and move it to the project folder."); + break; + case AvatarProjectStatus.ERROR_OPEN_OPEN_FST: + errorPopup.show("File Read Error", "We cannot read the project file (.fst)."); + break; + case AvatarProjectStatus.ERROR_OPEN_FIND_MODEL: + errorPopup.show("File Missing", "We cannot find the avatar model file (.fbx) in the project folder. Please locate it and move it to the project folder."); + break; + default: + errorPopup.show("Error Message Missing", "Error message missing for status " + status); + } + + } + + function openDocs() { + Qt.openUrlExternally("https://docs.highfidelity.com/create/avatars/create-avatars#how-to-package-your-avatar"); + } + + AvatarPackagerHeader { + z: 100 + + id: avatarPackagerHeader + colorScheme: root.colorScheme + onBackButtonClicked: { + avatarPackager.state = AvatarPackagerState.main; + } + onDocsButtonClicked: { + avatarPackager.openDocs(); + } + } + + Item { + height: windowContent.height - avatarPackagerHeader.height - avatarPackagerFooter.height + width: windowContent.width + + Rectangle { + anchors.fill: parent + color: "#404040" + } + + AvatarProject { + id: avatarProject + colorScheme: root.colorScheme + anchors.fill: parent + } + + AvatarProjectUpload { + id: avatarUploader + anchors.fill: parent + root: avatarProject + } + + CreateAvatarProject { + id: createAvatarProject + colorScheme: root.colorScheme + anchors.fill: parent + } + + Item { + id: avatarPackagerMain + visible: false + anchors.fill: parent + + property var footer: Item { + anchors.fill: parent + anchors.rightMargin: 17 + HifiControls.Button { + id: createProjectButton + anchors.verticalCenter: parent.verticalCenter + anchors.right: openProjectButton.left + anchors.rightMargin: 22 + height: 40 + width: 134 + text: qsTr("New Project") + colorScheme: root.colorScheme + onClicked: { + createAvatarProject.clearInputs(); + avatarPackager.state = AvatarPackagerState.createProject; + } + } + + HifiControls.Button { + id: openProjectButton + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + height: 40 + width: 133 + text: qsTr("Open Project") + color: hifi.buttons.blue + colorScheme: root.colorScheme + onClicked: { + avatarPackager.showModalOverlay = true; + + let browser = avatarPackager.desktopObject.fileDialog({ + selectDirectory: false, + dir: fileDialogHelper.pathToUrl(AvatarPackagerCore.AVATAR_PROJECTS_PATH), + filter: "Avatar Project FST Files (*.fst)", + title: "Open Project (.fst)", + }); + + browser.canceled.connect(function() { + avatarPackager.showModalOverlay = false; + }); + + browser.selectedFile.connect(function(fileUrl) { + let fstFilePath = fileDialogHelper.urlToPath(fileUrl); + avatarPackager.showModalOverlay = false; + avatarPackager.openProject(fstFilePath); + }); + } + } + } + + Flow { + visible: AvatarPackagerCore.recentProjects.length === 0 + anchors { + fill: parent + topMargin: 18 + leftMargin: 16 + rightMargin: 16 + } + RalewayRegular { + size: 20 + color: "white" + text: "Use a custom avatar of your choice." + width: parent.width + wrapMode: Text.WordWrap + } + RalewayRegular { + size: 20 + color: "white" + text: "Visit our docs to learn more about using the packager." + linkColor: "#00B4EF" + width: parent.width + wrapMode: Text.WordWrap + onLinkActivated: { + avatarPackager.openDocs(); + } + } + } + + Item { + anchors.fill: parent + + visible: AvatarPackagerCore.recentProjects.length > 0 + + RalewayRegular { + id: recentProjectsText + + color: 'white' + + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: 16 + anchors.leftMargin: 16 + + size: 20 + + text: "Recent Projects" + + onLinkActivated: fileListPopup.open() + } + + Column { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + top: recentProjectsText.bottom + topMargin: 16 + leftMargin: 16 + rightMargin: 16 + } + spacing: 10 + + Repeater { + model: AvatarPackagerCore.recentProjects + AvatarProjectCard { + title: modelData.name + path: modelData.projectPath + onOpen: avatarPackager.openProject(modelData.path) + } + } + } + } + } + } + AvatarPackagerFooter { + id: avatarPackagerFooter + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerFooter.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerFooter.qml new file mode 100644 index 0000000000..31e05672d2 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerFooter.qml @@ -0,0 +1,41 @@ +import QtQuick 2.6 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + +Rectangle { + id: avatarPackagerFooter + + color: "#404040" + height: content === defaultContent ? 0 : 74 + visible: content !== defaultContent + width: parent.width + + property var content: Item { id: defaultContent } + + children: [background, content] + + property var background: Rectangle { + anchors.fill: parent + color: "#404040" + + Rectangle { + id: topBorder1 + + anchors.top: parent.top + + color: "#252525" + height: 1 + width: parent.width + } + Rectangle { + id: topBorder2 + + anchors.top: topBorder1.bottom + + color: "#575757" + height: 1 + width: parent.width + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerHeader.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerHeader.qml new file mode 100644 index 0000000000..25201bf81e --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerHeader.qml @@ -0,0 +1,144 @@ +import QtQuick 2.6 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 +import "../avatarapp" 1.0 + +ShadowRectangle { + id: root + + width: parent.width + height: 74 + color: "#252525" + + property string title: qsTr("Avatar Packager") + property alias docsEnabled: docs.visible + property bool backButtonVisible: true // If false, is not visible and does not take up space + property bool backButtonEnabled: true // If false, is not visible but does not affect space + property bool canRename: false + property int colorScheme + + property color textColor: "white" + property color hoverTextColor: "gray" + property color pressedTextColor: "#6A6A6A" + + signal backButtonClicked + signal docsButtonClicked + + RalewayButton { + id: back + + visible: backButtonEnabled && backButtonVisible + + size: 28 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.leftMargin: 16 + + text: "◀" + + onClicked: root.backButtonClicked() + } + Item { + id: titleArea + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: root.backButtonVisible ? back.right : parent.left + anchors.leftMargin: root.backButtonVisible ? 11 : 21 + anchors.right: docs.left + states: [ + State { + name: "renaming" + PropertyChanges { target: title; visible: false } + PropertyChanges { target: titleInputArea; visible: true } + } + ] + + Item { + id: title + anchors.fill: parent + + RalewaySemiBold { + id: titleNotRenameable + + visible: !root.canRename + + size: 28 + anchors.fill: parent + text: root.title + color: "white" + } + + RalewayButton { + id: titleRenameable + + visible: root.canRename + enabled: root.canRename + + size: 28 + anchors.fill: parent + text: root.title + + onClicked: { + if (!root.canRename || AvatarPackagerCore.currentAvatarProject === null) { + return; + } + + titleArea.state = "renaming"; + titleInput.text = AvatarPackagerCore.currentAvatarProject.name; + titleInput.selectAll(); + titleInput.forceActiveFocus(Qt.MouseFocusReason); + } + } + } + Item { + id: titleInputArea + visible: false + anchors.fill: parent + + HifiControls.TextField { + id: titleInput + anchors.fill: parent + text: "" + colorScheme: root.colorScheme + font.family: "Fira Sans" + font.pixelSize: 28 + z: 200 + onFocusChanged: { + if (titleArea.state === "renaming" && !focus) { + accepted(); + } + } + Keys.onPressed: { + if (event.key === Qt.Key_Escape) { + titleArea.state = ""; + } + } + onAccepted: { + if (acceptableInput) { + AvatarPackagerCore.currentAvatarProject.name = text; + } + titleArea.state = ""; + } + } + } + } + + RalewayButton { + id: docs + visible: false + size: 28 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.rightMargin: 16 + + text: qsTr("Docs") + + onClicked: { + docsButtonClicked(); + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarPackagerState.qml b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerState.qml new file mode 100644 index 0000000000..c81173a080 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarPackagerState.qml @@ -0,0 +1,10 @@ +pragma Singleton +import QtQuick 2.6 + +Item { + id: singleton + readonly property string main: "main" + readonly property string project: "project" + readonly property string createProject: "createProject" + readonly property string projectUpload: "projectUpload" +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarProject.qml b/interface/resources/qml/hifi/avatarPackager/AvatarProject.qml new file mode 100644 index 0000000000..85ef821a4a --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarProject.qml @@ -0,0 +1,336 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import QtGraphicalEffects 1.0 + +import QtQuick.Controls 2.2 as Original + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + + +Item { + id: root + + HifiConstants { id: hifi } + + Style { id: style } + + property int colorScheme + property var uploader: null + + property bool hasSuccessfullyUploaded: true + + visible: false + anchors.fill: parent + anchors.margins: 10 + + function reset() { + hasSuccessfullyUploaded = false; + uploader = null; + } + + property var footer: Item { + anchors.fill: parent + + Item { + id: uploadFooter + + visible: !root.uploader || root.finished || root.uploader.state !== 4 + + anchors.fill: parent + anchors.rightMargin: 17 + + HifiControls.Button { + id: uploadButton + + visible: AvatarPackagerCore.currentAvatarProject && !AvatarPackagerCore.currentAvatarProject.fst.hasMarketplaceID && !root.hasSuccessfullyUploaded + enabled: Account.loggedIn + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + text: qsTr("Upload") + color: hifi.buttons.blue + colorScheme: root.colorScheme + width: 133 + height: 40 + onClicked: { + uploadNew(); + } + } + HifiControls.Button { + id: updateButton + + visible: AvatarPackagerCore.currentAvatarProject && AvatarPackagerCore.currentAvatarProject.fst.hasMarketplaceID && !root.hasSuccessfullyUploaded + enabled: Account.loggedIn + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + text: qsTr("Update") + color: hifi.buttons.blue + colorScheme: root.colorScheme + width: 134 + height: 40 + onClicked: { + showConfirmUploadPopup(uploadNew, uploadUpdate); + } + } + Item { + anchors.fill: parent + visible: root.hasSuccessfullyUploaded + + HifiControls.Button { + enabled: Account.loggedIn + + anchors.verticalCenter: parent.verticalCenter + anchors.right: viewInInventoryButton.left + anchors.rightMargin: 16 + + text: qsTr("Update") + color: hifi.buttons.white + colorScheme: root.colorScheme + width: 134 + height: 40 + onClicked: { + showConfirmUploadPopup(uploadNew, uploadUpdate); + } + } + HifiControls.Button { + id: viewInInventoryButton + + enabled: Account.loggedIn + + width: 168 + height: 40 + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + text: qsTr("View in Inventory") + color: hifi.buttons.blue + colorScheme: root.colorScheme + + onClicked: AvatarPackagerCore.currentAvatarProject.openInInventory() + } + } + } + + Rectangle { + id: uploadingItemFooter + + anchors.fill: parent + anchors.topMargin: 1 + visible: !!root.uploader && !root.finished && root.uploader.state === 4 + + color: "#00B4EF" + + LoadingCircle { + id: runningImage + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 16 + + width: 28 + height: 28 + + white: true + } + RalewayRegular { + id: stepText + + size: 20 + + anchors.verticalCenter: parent.verticalCenter + anchors.left: runningImage.right + anchors.leftMargin: 16 + + text: "Adding item to Inventory" + color: "white" + } + } + } + + function uploadNew() { + upload(false); + } + function uploadUpdate() { + upload(true); + } + + Connections { + target: root.uploader + onStateChanged: { + root.hasSuccessfullyUploaded = newState >= 4; + } + } + + function upload(updateExisting) { + root.uploader = AvatarPackagerCore.currentAvatarProject.upload(updateExisting); + console.log("uploader: "+ root.uploader); + root.uploader.send(); + avatarPackager.state = AvatarPackagerState.projectUpload; + } + + function showConfirmUploadPopup() { + popup.titleText = 'Overwrite Avatar'; + popup.bodyText = 'You have previously uploaded the avatar file from this project.' + + ' This will overwrite that avatar and you won’t be able to access the older version.'; + + popup.button1text = 'CREATE NEW'; + popup.button2text = 'OVERWRITE'; + + popup.onButton2Clicked = function() { + popup.close(); + uploadUpdate(); + }; + popup.onButton1Clicked = function() { + popup.close(); + showConfirmCreateNewPopup(); + }; + + popup.open(); + } + + function showConfirmCreateNewPopup(confirmCallback) { + popup.titleText = 'Create New'; + popup.bodyText = 'This will upload your current files with the same avatar name.' + + ' You will lose the ability to update the previously uploaded avatar. Are you sure you want to continue?'; + + popup.button1text = 'CANCEL'; + popup.button2text = 'CONFIRM'; + + popup.onButton1Clicked = function() { + popup.close() + }; + popup.onButton2Clicked = function() { + popup.close(); + uploadNew(); + }; + + popup.open(); + } + + RalewayRegular { + id: infoMessage + + states: [ + State { + when: root.hasSuccessfullyUploaded + name: "upload-success" + PropertyChanges { + target: infoMessage + text: "Your avatar has been successfully uploaded to our servers. Make changes to your avatar by editing and uploading the project files." + } + }, + State { + name: "has-previous-success" + when: !!AvatarPackagerCore.currentAvatarProject && AvatarPackagerCore.currentAvatarProject.fst.hasMarketplaceID + PropertyChanges { + target: infoMessage + text: "Click \"Update\" to overwrite the hosted files and update the avatar in your inventory. You will have to “Wear” the avatar again to see changes." + } + } + ] + + color: 'white' + size: 20 + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + anchors.bottomMargin: 24 + + wrapMode: Text.Wrap + + text: "You can upload your files to our servers to always access them, and to make your avatar visible to other users." + } + + HifiControls.Button { + id: openFolderButton + + visible: false + width: parent.width + anchors.top: infoMessage.bottom + anchors.topMargin: 10 + text: qsTr("Open Project Folder") + colorScheme: root.colorScheme + height: 30 + onClicked: { + fileDialogHelper.openDirectory(fileDialogHelper.pathToUrl(AvatarPackagerCore.currentAvatarProject.projectFolderPath)); + } + } + + RalewayRegular { + id: showFilesText + + color: 'white' + linkColor: style.colors.blueHighlight + + visible: AvatarPackagerCore.currentAvatarProject !== null + + anchors.bottom: loginRequiredMessage.top + anchors.bottomMargin: 10 + + size: 20 + + text: AvatarPackagerCore.currentAvatarProject ? AvatarPackagerCore.currentAvatarProject.projectFiles.length + " files in project. See list" : "" + + onLinkActivated: fileListPopup.open() + } + + Rectangle { + id: loginRequiredMessage + + visible: !Account.loggedIn + height: !Account.loggedIn ? loginRequiredTextRow.height + 20 : 0 + + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + } + + color: "#FFD6AD" + + border.color: "#F39622" + border.width: 2 + radius: 2 + + Item { + id: loginRequiredTextRow + + height: Math.max(loginWarningGlyph.implicitHeight, loginWarningText.implicitHeight) + anchors.fill: parent + anchors.margins: 10 + + HiFiGlyphs { + id: loginWarningGlyph + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + width: implicitWidth + + size: 48 + text: "+" + color: "black" + } + RalewayRegular { + id: loginWarningText + + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 16 + anchors.left: loginWarningGlyph.right + anchors.right: parent.right + + text: "Please login to upload your avatar to High Fidelity hosting." + size: 18 + wrapMode: Text.Wrap + } + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarProjectCard.qml b/interface/resources/qml/hifi/avatarPackager/AvatarProjectCard.qml new file mode 100644 index 0000000000..25222c814c --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarProjectCard.qml @@ -0,0 +1,102 @@ +import QtQuick 2.0 +import QtGraphicalEffects 1.0 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + + +Item { + id: projectCard + height: 80 + width: parent.width + + property alias title: title.text + property alias path: path.text + + property color textColor: "#E3E3E3" + property color hoverTextColor: "#121212" + property color pressedTextColor: "#121212" + + property color backgroundColor: "#121212" + property color hoverBackgroundColor: "#E3E3E3" + property color pressedBackgroundColor: "#6A6A6A" + + signal open + + state: mouseArea.pressed ? "pressed" : (mouseArea.containsMouse ? "hover" : "normal") + states: [ + State { + name: "normal" + PropertyChanges { target: background; color: backgroundColor } + PropertyChanges { target: title; color: textColor } + PropertyChanges { target: path; color: textColor } + }, + State { + name: "hover" + PropertyChanges { target: background; color: hoverBackgroundColor } + PropertyChanges { target: title; color: hoverTextColor } + PropertyChanges { target: path; color: hoverTextColor } + }, + State { + name: "pressed" + PropertyChanges { target: background; color: pressedBackgroundColor } + PropertyChanges { target: title; color: pressedTextColor } + PropertyChanges { target: path; color: pressedTextColor } + } + ] + + Rectangle { + id: background + width: parent.width + height: parent.height + color: "#121212" + radius: 4 + + RalewayBold { + id: title + elide: "ElideRight" + anchors { + top: parent.top + topMargin: 13 + left: parent.left + leftMargin: 16 + right: parent.right + rightMargin: 16 + } + text: "" + size: 24 + } + + RalewayRegular { + id: path + anchors { + top: title.bottom + left: parent.left + leftMargin: 32 + right: background.right + rightMargin: 16 + } + elide: "ElideLeft" + horizontalAlignment: Text.AlignRight + text: "<path missing>" + size: 20 + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: open() + } + } + + DropShadow { + id: shadow + anchors.fill: background + radius: 4 + horizontalOffset: 0 + verticalOffset: 4 + color: Qt.rgba(0, 0, 0, 0.25) + source: background + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarProjectUpload.qml b/interface/resources/qml/hifi/avatarPackager/AvatarProjectUpload.qml new file mode 100644 index 0000000000..68f465f514 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarProjectUpload.qml @@ -0,0 +1,202 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import QtGraphicalEffects 1.0 + +import QtQuick.Controls 2.2 as Original + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + +Item { + id: uploadingScreen + + property var root: undefined + visible: false + anchors.fill: parent + + Timer { + id: backToProjectTimer + interval: 2000 + running: false + repeat: false + onTriggered: { + if (avatarPackager.state === AvatarPackagerState.projectUpload) { + avatarPackager.state = AvatarPackagerState.project; + } + } + } + + function stateChangedCallback(newState) { + if (newState >= 4) { + root.uploader.stateChanged.disconnect(stateChangedCallback); + backToProjectTimer.start(); + } + } + + onVisibleChanged: { + if (visible) { + root.uploader.stateChanged.connect(stateChangedCallback); + root.uploader.finishedChanged.connect(function() { + if (root.uploader.error === 0) { + backToProjectTimer.start(); + } + }); + } + } + + Item { + id: uploadStatus + + anchors.fill: parent + + Item { + id: statusItem + + width: parent.width + height: 256 + + states: [ + State { + name: "success" + when: root.uploader.state >= 4 && root.uploader.error === 0 + PropertyChanges { target: uploadSpinner; visible: false } + PropertyChanges { target: errorIcon; visible: false } + PropertyChanges { target: successIcon; visible: true } + }, + State { + name: "error" + when: root.uploader.finished && root.uploader.error !== 0 + PropertyChanges { target: uploadSpinner; visible: false } + PropertyChanges { target: errorIcon; visible: true } + PropertyChanges { target: successIcon; visible: false } + PropertyChanges { target: errorFooter; visible: true } + PropertyChanges { target: errorMessage; visible: true } + } + ] + + AnimatedImage { + id: uploadSpinner + + visible: true + + anchors { + horizontalCenter: parent.horizontalCenter + verticalCenter: parent.verticalCenter + } + + width: 164 + height: 164 + + source: "../../../icons/loader-snake-256.gif" + playing: true + } + + HiFiGlyphs { + id: errorIcon + + visible: false + + anchors { + horizontalCenter: parent.horizontalCenter + verticalCenter: parent.verticalCenter + } + + size: 315 + text: "+" + color: "#EA4C5F" + } + + Image { + id: successIcon + + visible: false + + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + + width: 148 + height: 148 + + source: "../../../icons/checkmark-stroke.svg" + } + } + + Item { + id: statusRows + + anchors.top: statusItem.bottom + anchors.left: parent.left + anchors.leftMargin: 12 + + AvatarUploadStatusItem { + id: statusCategories + uploader: root.uploader + text: "Retrieving categories" + + uploaderState: 1 + } + AvatarUploadStatusItem { + id: statusUploading + uploader: root.uploader + anchors.top: statusCategories.bottom + text: "Uploading data" + + uploaderState: 2 + } + AvatarUploadStatusItem { + id: statusResponse + uploader: root.uploader + anchors.top: statusUploading.bottom + text: "Waiting for response" + + uploaderState: 3 + } + } + + RalewayRegular { + id: errorMessage + + visible: false + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: errorFooter.top + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 32 + + size: 28 + wrapMode: Text.Wrap + color: "white" + text: "We couldn't upload your avatar at this time. Please try again later." + } + + AvatarPackagerFooter { + id: errorFooter + + anchors.bottom: parent.bottom + visible: false + + content: Item { + anchors.fill: parent + anchors.rightMargin: 17 + HifiControls.Button { + id: backButton + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + text: qsTr("Back") + color: hifi.buttons.blue + colorScheme: root.colorScheme + width: 133 + height: 40 + onClicked: { + avatarPackager.state = AvatarPackagerState.project; + } + } + } + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/AvatarUploadStatusItem.qml b/interface/resources/qml/hifi/avatarPackager/AvatarUploadStatusItem.qml new file mode 100644 index 0000000000..70a0ea0672 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/AvatarUploadStatusItem.qml @@ -0,0 +1,96 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + +Item { + id: root + + height: 48 + + property string text: "NO STEP TEXT" + property int uploaderState; + property var uploader; + + states: [ + State { + name: "" + when: root.uploader === null + }, + State { + name: "success" + when: root.uploader.state > uploaderState + PropertyChanges { target: stepText; color: "white" } + PropertyChanges { target: successGlyph; visible: true } + }, + State { + name: "fail" + when: root.uploader.error !== 0 + PropertyChanges { target: stepText; color: "#EA4C5F" } + PropertyChanges { target: failGlyph; visible: true } + }, + State { + name: "running" + when: root.uploader.state === uploaderState + PropertyChanges { target: stepText; color: "white" } + PropertyChanges { target: runningImage; visible: true; playing: true } + } + ] + + Item { + id: statusItem + + width: 48 + height: parent.height + + LoadingCircle { + id: runningImage + + visible: false + + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + + width: 32 + height: 32 + } + Image { + id: successGlyph + + visible: false + + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + + width: 30 + height: 30 + + source: "../../../icons/checkmark-stroke.svg" + } + HiFiGlyphs { + id: failGlyph + + visible: false + + width: implicitWidth + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + + size: 48 + text: "+" + color: "#EA4C5F" + } + } + RalewayRegular { + id: stepText + + anchors.left: statusItem.right + anchors.verticalCenter: parent.verticalCenter + + text: root.text + size: 28 + color: "#777777" + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/ClickableArea.qml b/interface/resources/qml/hifi/avatarPackager/ClickableArea.qml new file mode 100644 index 0000000000..0f7b201f72 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/ClickableArea.qml @@ -0,0 +1,63 @@ +import QtQuick 2.6 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + +import TabletScriptingInterface 1.0 + +Item { + id: root + + readonly property bool pressed: mouseArea.state == "pressed" + readonly property bool hovered: mouseArea.state == "hovering" + + signal clicked() + + MouseArea { + id: mouseArea + + anchors.fill: parent + + hoverEnabled: true + + onClicked: { + root.focus = true + Tablet.playSound(TabletEnums.ButtonClick); + root.clicked(); + } + + property string lastState: "" + + states: [ + State { + name: "" + StateChangeScript { + script: { + mouseArea.lastState = mouseArea.state; + } + } + }, + State { + name: "pressed" + when: mouseArea.containsMouse && mouseArea.pressed + StateChangeScript { + script: { + mouseArea.lastState = mouseArea.state; + } + } + }, + State { + name: "hovering" + when: mouseArea.containsMouse + StateChangeScript { + script: { + if (mouseArea.lastState == "") { + Tablet.playSound(TabletEnums.ButtonHover); + } + mouseArea.lastState = mouseArea.state; + } + } + } + ] + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/CreateAvatarProject.qml b/interface/resources/qml/hifi/avatarPackager/CreateAvatarProject.qml new file mode 100644 index 0000000000..c299417c27 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/CreateAvatarProject.qml @@ -0,0 +1,135 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 + +import Hifi.AvatarPackager.AvatarProjectStatus 1.0 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + +Item { + id: root + + HifiConstants { id: hifi } + + property int colorScheme + + property var footer: Item { + anchors.fill: parent + anchors.rightMargin: 17 + HifiControls.Button { + id: createButton + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + height: 30 + width: 133 + text: qsTr("Create") + enabled: false + onClicked: { + let status = AvatarPackagerCore.createAvatarProject(projectLocation.text, name.text, avatarModel.text, textureFolder.text); + if (status !== AvatarProjectStatus.SUCCESS) { + avatarPackager.displayErrorMessage(status); + return; + } + avatarProject.reset(); + avatarPackager.state = AvatarPackagerState.project; + } + } + } + + visible: false + anchors.fill: parent + height: parent.height + width: parent.width + + function clearInputs() { + name.text = projectLocation.text = avatarModel.text = textureFolder.text = ""; + } + + function checkErrors() { + let newErrorMessageText = ""; + + let projectName = name.text; + let projectFolder = projectLocation.text; + + let hasProjectNameError = projectName !== "" && projectFolder !== "" && !AvatarPackagerCore.isValidNewProjectName(projectFolder, projectName); + + if (hasProjectNameError) { + newErrorMessageText = "A folder with that name already exists at that location. Please choose a different project name or location."; + } + + name.error = projectLocation.error = hasProjectNameError; + errorMessage.text = newErrorMessageText; + createButton.enabled = newErrorMessageText === "" && requiredFieldsFilledIn(); + } + + function requiredFieldsFilledIn() { + return name.text !== "" && projectLocation.text !== "" && avatarModel.text !== ""; + } + + RalewayRegular { + id: errorMessage + visible: text !== "" + text: "" + color: "#EA4C5F" + wrapMode: Text.WordWrap + size: 20 + anchors { + left: parent.left + right: parent.right + } + } + + Column { + id: createAvatarColumns + anchors.top: errorMessage.visible ? errorMessage.bottom : parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 10 + + spacing: 17 + + property string defaultFileBrowserPath: fileDialogHelper.pathToUrl(AvatarPackagerCore.AVATAR_PROJECTS_PATH) + + ProjectInputControl { + id: name + label: "Name" + colorScheme: root.colorScheme + onTextChanged: checkErrors() + } + + ProjectInputControl { + id: projectLocation + label: "Specify Project Location" + colorScheme: root.colorScheme + browseEnabled: true + browseFolder: true + browseDir: text !== "" ? fileDialogHelper.pathToUrl(text) : createAvatarColumns.defaultFileBrowserPath + browseTitle: "Project Location" + onTextChanged: checkErrors() + } + + ProjectInputControl { + id: avatarModel + label: "Specify Avatar Model (.fbx)" + colorScheme: root.colorScheme + browseEnabled: true + browseFolder: false + browseDir: text !== "" ? fileDialogHelper.pathToUrl(text) : createAvatarColumns.defaultFileBrowserPath + browseFilter: "Avatar Model File (*.fbx)" + browseTitle: "Open Avatar Model (.fbx)" + onTextChanged: checkErrors() + } + + ProjectInputControl { + id: textureFolder + label: "Specify Texture Folder - <i>Optional</i>" + colorScheme: root.colorScheme + browseEnabled: true + browseFolder: true + browseDir: text !== "" ? fileDialogHelper.pathToUrl(text) : createAvatarColumns.defaultFileBrowserPath + browseTitle: "Texture Folder" + onTextChanged: checkErrors() + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/InfoBox.qml b/interface/resources/qml/hifi/avatarPackager/InfoBox.qml new file mode 100644 index 0000000000..e33e427af0 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/InfoBox.qml @@ -0,0 +1,120 @@ +import Hifi 1.0 as Hifi +import QtQuick 2.5 +import stylesUit 1.0 +import controlsUit 1.0 as HifiControlsUit +import "../../controls" as HifiControls + +Rectangle { + id: root + visible: false + color: Qt.rgba(.34, .34, .34, 0.6) + z: 999; + + anchors.fill: parent + + property alias title: titleText.text + property alias content: loader.sourceComponent + + property bool closeOnClickOutside: false; + + property alias boxWidth: mainContainer.width + property alias boxHeight: mainContainer.height + + onVisibleChanged: { + if (visible) { + focus = true; + } + } + + function open() { + visible = true; + } + + function close() { + visible = false; + } + + HifiConstants { + id: hifi + } + + // This object is always used in a popup. + // This MouseArea is used to prevent a user from being + // able to click on a button/mouseArea underneath the popup. + MouseArea { + anchors.fill: parent; + propagateComposedEvents: false; + hoverEnabled: true; + onClicked: { + if (closeOnClickOutside) { + root.close() + } + } + } + + Rectangle { + id: mainContainer + + width: Math.max(parent.width * 0.8, 400) + height: parent.height * 0.6 + + MouseArea { + anchors.fill: parent + propagateComposedEvents: false + hoverEnabled: true + onClicked: function(ev) { + ev.accepted = true; + } + } + + anchors.centerIn: parent + + color: "#252525" + + // TextStyle1 + RalewaySemiBold { + id: titleText + size: 24 + color: "white" + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 30 + + text: "Title not defined" + } + + Item { + anchors.topMargin: 10 + anchors.top: titleText.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: button.top + + Loader { + id: loader + anchors.fill: parent + } + } + + Item { + id: button + + height: 40 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 12 + + HifiControlsUit.Button { + anchors.centerIn: parent + + text: "CLOSE" + onClicked: close() + + color: hifi.buttons.noneBorderlessWhite; + colorScheme: hifi.colorSchemes.dark; + } + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/LoadingCircle.qml b/interface/resources/qml/hifi/avatarPackager/LoadingCircle.qml new file mode 100644 index 0000000000..a1fac72ae4 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/LoadingCircle.qml @@ -0,0 +1,16 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import QtGraphicalEffects 1.0 + +AnimatedImage { + id: root + + width: 128 + height: 128 + + property bool white: false + + source: white ? "../../../icons/loader-snake-256-wf.gif" : "../../../icons/loader-snake-256.gif" + playing: true +} diff --git a/interface/resources/qml/hifi/avatarPackager/ProjectInputControl.qml b/interface/resources/qml/hifi/avatarPackager/ProjectInputControl.qml new file mode 100644 index 0000000000..f0a3aac8a7 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/ProjectInputControl.qml @@ -0,0 +1,78 @@ +import QtQuick 2.6 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + +Column { + id: control + + anchors.left: parent.left + anchors.leftMargin: 21 + anchors.right: parent.right + anchors.rightMargin: 16 + + height: 75 + + spacing: 4 + + property alias label: label.text + property alias browseEnabled: browseButton.visible + property bool browseFolder: false + property string browseFilter: "All Files (*.*)" + property string browseTitle: "Open file" + property string browseDir: "" + property alias text: input.text + property alias error: input.error + + property int colorScheme + + Row { + RalewaySemiBold { + id: label + size: 20 + font.weight: Font.Medium + text: "" + color: "white" + } + } + Row { + width: control.width + spacing: 16 + height: 40 + HifiControls.TextField { + id: input + colorScheme: control.colorScheme + font.family: "Fira Sans" + font.pixelSize: 18 + height: parent.height + width: browseButton.visible ? parent.width - browseButton.width - parent.spacing : parent.width + } + + HifiControls.Button { + id: browseButton + visible: false + height: parent.height + width: 133 + text: qsTr("Browse") + colorScheme: root.colorScheme + onClicked: { + avatarPackager.showModalOverlay = true; + let browser = avatarPackager.desktopObject.fileDialog({ + selectDirectory: browseFolder, + dir: browseDir, + filter: browseFilter, + title: browseTitle, + }); + + browser.canceled.connect(function() { + avatarPackager.showModalOverlay = false; + }); + + browser.selectedFile.connect(function(fileUrl) { + input.text = fileDialogHelper.urlToPath(fileUrl); + avatarPackager.showModalOverlay = false; + }); + } + } + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/RalewayButton.qml b/interface/resources/qml/hifi/avatarPackager/RalewayButton.qml new file mode 100644 index 0000000000..86742ddccd --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/RalewayButton.qml @@ -0,0 +1,26 @@ +import QtQuick 2.6 + +import "../../controlsUit" 1.0 as HifiControls +import "../../stylesUit" 1.0 + +import TabletScriptingInterface 1.0 + +RalewaySemiBold { + id: root + + property color idleColor: "white" + property color hoverColor: "#AFAFAF" + property color pressedColor: "#575757" + + color: clickable.hovered ? root.hoverColor : (clickable.pressed ? root.pressedColor : root.idleColor) + + signal clicked() + + ClickableArea { + id: clickable + + anchors.fill: root + + onClicked: root.clicked() + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/Style.qml b/interface/resources/qml/hifi/avatarPackager/Style.qml new file mode 100644 index 0000000000..a1dcc8f0c1 --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/Style.qml @@ -0,0 +1,20 @@ +import QtQuick 2.5 +import QtQuick.Window 2.2 + +import "../../stylesUit" 1.0 + +QtObject { + readonly property QtObject colors: QtObject { + readonly property color lightGrayBackground: "#f2f2f2" + readonly property color black: "#000000" + readonly property color white: "#ffffff" + readonly property color blueHighlight: "#00b4ef" + readonly property color inputFieldBackground: "#d4d4d4" + readonly property color yellowishOrange: "#ffb017" + readonly property color blueAccent: "#0093c5" + readonly property color greenHighlight: "#1fc6a6" + readonly property color lightGray: "#afafaf" + readonly property color redHighlight: "#ea4c5f" + readonly property color orangeAccent: "#ff6309" + } +} diff --git a/interface/resources/qml/hifi/avatarPackager/qmldir b/interface/resources/qml/hifi/avatarPackager/qmldir new file mode 100644 index 0000000000..4204b6d89f --- /dev/null +++ b/interface/resources/qml/hifi/avatarPackager/qmldir @@ -0,0 +1,2 @@ +module AvatarPackager +singleton AvatarPackagerState 1.0 AvatarPackagerState.qml diff --git a/interface/resources/qml/hifi/avatarapp/MessageBox.qml b/interface/resources/qml/hifi/avatarapp/MessageBox.qml index 1834364fe4..88f7f888cb 100644 --- a/interface/resources/qml/hifi/avatarapp/MessageBox.qml +++ b/interface/resources/qml/hifi/avatarapp/MessageBox.qml @@ -23,6 +23,8 @@ Rectangle { property string button2color: hifi.buttons.blue; property string button2text: '' + property bool closeOnClickOutside: false; + property var onButton2Clicked; property var onButton1Clicked; property var onLinkClicked; @@ -56,6 +58,11 @@ Rectangle { anchors.fill: parent; propagateComposedEvents: false; hoverEnabled: true; + onClicked: { + if (closeOnClickOutside) { + root.close() + } + } } Rectangle { @@ -68,6 +75,15 @@ Rectangle { console.debug('mainContainer: height = ', height) } + MouseArea { + anchors.fill: parent; + propagateComposedEvents: false; + hoverEnabled: true; + onClicked: function(ev) { + ev.accepted = true; + } + } + anchors.centerIn: parent color: "white" diff --git a/interface/resources/qml/hifi/tablet/AvatarPackager.qml b/interface/resources/qml/hifi/tablet/AvatarPackager.qml new file mode 100644 index 0000000000..c1c234dd73 --- /dev/null +++ b/interface/resources/qml/hifi/tablet/AvatarPackager.qml @@ -0,0 +1,15 @@ +import QtQuick 2.0 +import "../avatarPackager" 1.0 + +Item { + id: root + width: 480 + height: 706 + + AvatarPackagerApp { + width: parent.width + height: parent.height + + desktopObject: tabletRoot + } +} diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index b6b4e8e2a1..2816dbcb04 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -158,6 +158,7 @@ #include "audio/AudioScope.h" #include "avatar/AvatarManager.h" #include "avatar/MyHead.h" +#include "avatar/AvatarPackager.h" #include "CrashRecoveryHandler.h" #include "CrashHandler.h" #include "devices/DdeFaceTracker.h" @@ -922,6 +923,7 @@ bool setupEssentials(int& argc, char** argv, bool runningMarkerExisted) { DependencyManager::set<Keyboard>(); DependencyManager::set<KeyboardScriptingInterface>(); DependencyManager::set<GrabManager>(); + DependencyManager::set<AvatarPackager>(); return previousSessionCrashed; } @@ -2617,6 +2619,7 @@ void Application::cleanupBeforeQuit() { DependencyManager::destroy<PickManager>(); DependencyManager::destroy<KeyboardScriptingInterface>(); DependencyManager::destroy<Keyboard>(); + DependencyManager::destroy<AvatarPackager>(); qCDebug(interfaceapp) << "Application::cleanupBeforeQuit() complete"; } diff --git a/interface/src/Menu.cpp b/interface/src/Menu.cpp index 140d2a7ccc..810e21daf5 100644 --- a/interface/src/Menu.cpp +++ b/interface/src/Menu.cpp @@ -35,6 +35,7 @@ #include "assets/ATPAssetMigrator.h" #include "audio/AudioScope.h" #include "avatar/AvatarManager.h" +#include "avatar/AvatarPackager.h" #include "AvatarBookmarks.h" #include "devices/DdeFaceTracker.h" #include "MainWindow.h" @@ -144,9 +145,13 @@ Menu::Menu() { assetServerAction->setEnabled(nodeList->getThisNodeCanWriteAssets()); } - // Edit > Package Avatar as .fst... - addActionToQMenuAndActionHash(editMenu, MenuOption::PackageModel, 0, - qApp, SLOT(packageModel())); + // Edit > Avatar Packager +#ifndef Q_OS_ANDROID + action = addActionToQMenuAndActionHash(editMenu, MenuOption::AvatarPackager); + connect(action, &QAction::triggered, [] { + DependencyManager::get<AvatarPackager>()->open(); + }); +#endif // Edit > Reload All Content addActionToQMenuAndActionHash(editMenu, MenuOption::ReloadContent, 0, qApp, SLOT(reloadResourceCaches())); @@ -645,6 +650,8 @@ Menu::Menu() { addCheckableActionToQMenuAndActionHash(avatarDebugMenu, MenuOption::ShowTrackedObjects, 0, false, qApp, SLOT(setShowTrackedObjects(bool))); + addActionToQMenuAndActionHash(avatarDebugMenu, MenuOption::PackageModel, 0, qApp, SLOT(packageModel())); + // Developer > Hands >>> MenuWrapper* handOptionsMenu = developerMenu->addMenu("Hands"); addCheckableActionToQMenuAndActionHash(handOptionsMenu, MenuOption::DisplayHandTargets, 0, false, diff --git a/interface/src/Menu.h b/interface/src/Menu.h index 7168b7294e..3611faaf8f 100644 --- a/interface/src/Menu.h +++ b/interface/src/Menu.h @@ -46,6 +46,7 @@ namespace MenuOption { const QString AutoMuteAudio = "Auto Mute Microphone"; const QString AvatarReceiveStats = "Show Receive Stats"; const QString AvatarBookmarks = "Avatar Bookmarks"; + const QString AvatarPackager = "Avatar Packager"; const QString Back = "Back"; const QString BinaryEyelidControl = "Binary Eyelid Control"; const QString BookmarkAvatar = "Bookmark Avatar"; diff --git a/interface/src/avatar/AvatarPackager.cpp b/interface/src/avatar/AvatarPackager.cpp new file mode 100644 index 0000000000..fa70eee374 --- /dev/null +++ b/interface/src/avatar/AvatarPackager.cpp @@ -0,0 +1,149 @@ +// +// AvatarPackager.cpp +// +// +// Created by Thijs Wenker on 12/6/2018 +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "AvatarPackager.h" + +#include "Application.h" + +#include <QQmlContext> +#include <QQmlEngine> +#include <QUrl> + +#include <OffscreenUi.h> +#include "ModelSelector.h" +#include <avatar/MarketplaceItemUploader.h> + +#include <mutex> +#include "ui/TabletScriptingInterface.h" + +std::once_flag setupQMLTypesFlag; +AvatarPackager::AvatarPackager() { + std::call_once(setupQMLTypesFlag, []() { + qmlRegisterType<FST>(); + qmlRegisterType<MarketplaceItemUploader>(); + qRegisterMetaType<AvatarPackager*>(); + qRegisterMetaType<AvatarProject*>(); + qRegisterMetaType<AvatarProjectStatus::AvatarProjectStatus>(); + qmlRegisterUncreatableMetaObject( + AvatarProjectStatus::staticMetaObject, + "Hifi.AvatarPackager.AvatarProjectStatus", + 1, 0, + "AvatarProjectStatus", + "Error: only enums" + ); + }); + + recentProjectsFromVariantList(_recentProjectsSetting.get()); + + QDir defaultProjectsDir(AvatarProject::getDefaultProjectsPath()); + defaultProjectsDir.mkpath("."); +} + +bool AvatarPackager::open() { + const auto packageModelDialogCreated = [=](QQmlContext* context, QObject* newObject) { + context->setContextProperty("AvatarPackagerCore", this); + }; + + static const QString SYSTEM_TABLET = "com.highfidelity.interface.tablet.system"; + auto tablet = dynamic_cast<TabletProxy*>(DependencyManager::get<TabletScriptingInterface>()->getTablet(SYSTEM_TABLET)); + + if (tablet->getToolbarMode()) { + static const QUrl url{ "hifi/AvatarPackagerWindow.qml" }; + DependencyManager::get<OffscreenUi>()->show(url, "AvatarPackager", packageModelDialogCreated); + return true; + } + + static const QUrl url{ "hifi/tablet/AvatarPackager.qml" }; + if (!tablet->isPathLoaded(url)) { + tablet->getTabletSurface()->getSurfaceContext()->setContextProperty("AvatarPackagerCore", this); + tablet->pushOntoStack(url); + return true; + } + + return false; +} + +void AvatarPackager::addCurrentProjectToRecentProjects() { + const int MAX_RECENT_PROJECTS = 5; + const QString& fstPath = _currentAvatarProject->getFSTPath(); + auto removeProjects = QVector<RecentAvatarProject>(); + for (const auto& project : _recentProjects) { + if (project.getProjectFSTPath() == fstPath) { + removeProjects.append(project); + } + } + for (const auto& removeProject : removeProjects) { + _recentProjects.removeOne(removeProject); + } + + const auto newRecentProject = RecentAvatarProject(_currentAvatarProject->getProjectName(), fstPath); + _recentProjects.prepend(newRecentProject); + + while (_recentProjects.size() > MAX_RECENT_PROJECTS) { + _recentProjects.pop_back(); + } + + _recentProjectsSetting.set(recentProjectsToVariantList(false)); + emit recentProjectsChanged(); +} + +QVariantList AvatarPackager::recentProjectsToVariantList(bool includeProjectPaths) const { + QVariantList result; + for (const auto& project : _recentProjects) { + QVariantMap projectVariant; + projectVariant.insert("name", project.getProjectName()); + projectVariant.insert("path", project.getProjectFSTPath()); + if (includeProjectPaths) { + projectVariant.insert("projectPath", project.getProjectPath()); + } + result.append(projectVariant); + } + + return result; +} +void AvatarPackager::recentProjectsFromVariantList(QVariantList projectsVariant) { + _recentProjects.clear(); + for (const auto& projectVariant : projectsVariant) { + auto map = projectVariant.toMap(); + _recentProjects.append(RecentAvatarProject(map.value("name").toString(), map.value("path").toString())); + } +} + +AvatarProjectStatus::AvatarProjectStatus AvatarPackager::openAvatarProject(const QString& avatarProjectFSTPath) { + AvatarProjectStatus::AvatarProjectStatus status; + setAvatarProject(AvatarProject::openAvatarProject(avatarProjectFSTPath, status)); + return status; +} + +AvatarProjectStatus::AvatarProjectStatus AvatarPackager::createAvatarProject(const QString& projectsFolder, + const QString& avatarProjectName, + const QString& avatarModelPath, + const QString& textureFolder) { + AvatarProjectStatus::AvatarProjectStatus status; + setAvatarProject(AvatarProject::createAvatarProject(projectsFolder, avatarProjectName, avatarModelPath, textureFolder, status)); + return status; +} + +void AvatarPackager::setAvatarProject(AvatarProject* avatarProject) { + if (avatarProject == _currentAvatarProject) { + return; + } + if (_currentAvatarProject) { + _currentAvatarProject->deleteLater(); + } + _currentAvatarProject = avatarProject; + if (_currentAvatarProject) { + addCurrentProjectToRecentProjects(); + connect(_currentAvatarProject, &AvatarProject::nameChanged, this, &AvatarPackager::addCurrentProjectToRecentProjects); + QQmlEngine::setObjectOwnership(_currentAvatarProject, QQmlEngine::CppOwnership); + } + emit avatarProjectChanged(); +} diff --git a/interface/src/avatar/AvatarPackager.h b/interface/src/avatar/AvatarPackager.h new file mode 100644 index 0000000000..ec954a60d7 --- /dev/null +++ b/interface/src/avatar/AvatarPackager.h @@ -0,0 +1,100 @@ +// +// AvatarPackager.h +// +// +// Created by Thijs Wenker on 12/6/2018 +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#pragma once +#ifndef hifi_AvatarPackager_h +#define hifi_AvatarPackager_h + +#include <QObject> +#include <DependencyManager.h> + +#include "FileDialogHelper.h" + +#include "avatar/AvatarProject.h" +#include "SettingHandle.h" + +class RecentAvatarProject { +public: + RecentAvatarProject() = default; + + + RecentAvatarProject(QString projectName, QString projectFSTPath) { + _projectName = projectName; + _projectFSTPath = projectFSTPath; + } + RecentAvatarProject(const RecentAvatarProject& other) { + _projectName = other._projectName; + _projectFSTPath = other._projectFSTPath; + } + + QString getProjectName() const { return _projectName; } + + QString getProjectFSTPath() const { return _projectFSTPath; } + + QString getProjectPath() const { + return QFileInfo(_projectFSTPath).absoluteDir().absolutePath(); + } + + bool operator==(const RecentAvatarProject& other) const { + return _projectName == other._projectName && _projectFSTPath == other._projectFSTPath; + } + +private: + QString _projectName; + QString _projectFSTPath; + +}; + +class AvatarPackager : public QObject, public Dependency { + Q_OBJECT + SINGLETON_DEPENDENCY + Q_PROPERTY(AvatarProject* currentAvatarProject READ getAvatarProject NOTIFY avatarProjectChanged) + Q_PROPERTY(QString AVATAR_PROJECTS_PATH READ getAvatarProjectsPath CONSTANT) + Q_PROPERTY(QVariantList recentProjects READ getRecentProjects NOTIFY recentProjectsChanged) +public: + AvatarPackager(); + bool open(); + + Q_INVOKABLE AvatarProjectStatus::AvatarProjectStatus createAvatarProject(const QString& projectsFolder, + const QString& avatarProjectName, + const QString& avatarModelPath, + const QString& textureFolder); + + Q_INVOKABLE AvatarProjectStatus::AvatarProjectStatus openAvatarProject(const QString& avatarProjectFSTPath); + Q_INVOKABLE bool isValidNewProjectName(const QString& projectPath, const QString& projectName) const { + return AvatarProject::isValidNewProjectName(projectPath, projectName); + } + +signals: + void avatarProjectChanged(); + void recentProjectsChanged(); + +private: + Q_INVOKABLE AvatarProject* getAvatarProject() const { return _currentAvatarProject; }; + Q_INVOKABLE QString getAvatarProjectsPath() const { return AvatarProject::getDefaultProjectsPath(); } + Q_INVOKABLE QVariantList getRecentProjects() const { return recentProjectsToVariantList(true); } + + void setAvatarProject(AvatarProject* avatarProject); + + void addCurrentProjectToRecentProjects(); + + AvatarProject* _currentAvatarProject { nullptr }; + QVector<RecentAvatarProject> _recentProjects; + + QVariantList recentProjectsToVariantList(bool includeProjectPaths) const; + + void recentProjectsFromVariantList(QVariantList projectsVariant); + + + Setting::Handle<QVariantList> _recentProjectsSetting { "io.highfidelity.avatarPackager.recentProjects", QVariantList() }; +}; + +#endif // hifi_AvatarPackager_h diff --git a/interface/src/avatar/AvatarProject.cpp b/interface/src/avatar/AvatarProject.cpp new file mode 100644 index 0000000000..728917e673 --- /dev/null +++ b/interface/src/avatar/AvatarProject.cpp @@ -0,0 +1,260 @@ +// +// AvatarProject.cpp +// +// +// Created by Thijs Wenker on 12/7/2018 +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "AvatarProject.h" + +#include <FSTReader.h> + +#include <QFile> +#include <QFileInfo> +#include <QQmlEngine> +#include <QTimer> + +#include "FBXSerializer.h" +#include <ui/TabletScriptingInterface.h> +#include "scripting/HMDScriptingInterface.h" + +AvatarProject* AvatarProject::openAvatarProject(const QString& path, AvatarProjectStatus::AvatarProjectStatus& status) { + status = AvatarProjectStatus::NONE; + + if (!path.toLower().endsWith(".fst")) { + status = AvatarProjectStatus::ERROR_OPEN_INVALID_FILE_TYPE; + return nullptr; + } + + QFileInfo fstFileInfo{ path }; + if (!fstFileInfo.absoluteDir().exists()) { + status = AvatarProjectStatus::ERROR_OPEN_PROJECT_FOLDER; + return nullptr; + } + + if (!fstFileInfo.exists()) { + status = AvatarProjectStatus::ERROR_OPEN_FIND_FST; + return nullptr; + } + + QFile file{ fstFileInfo.filePath() }; + if (!file.open(QIODevice::ReadOnly)) { + status = AvatarProjectStatus::ERROR_OPEN_OPEN_FST; + return nullptr; + } + + const auto project = new AvatarProject(path, file.readAll()); + + QFileInfo fbxFileInfo{ project->getFBXPath() }; + if (!fbxFileInfo.exists()) { + project->deleteLater(); + status = AvatarProjectStatus::ERROR_OPEN_FIND_MODEL; + return nullptr; + } + + QQmlEngine::setObjectOwnership(project, QQmlEngine::CppOwnership); + status = AvatarProjectStatus::SUCCESS; + return project; +} + +AvatarProject* AvatarProject::createAvatarProject(const QString& projectsFolder, const QString& avatarProjectName, + const QString& avatarModelPath, const QString& textureFolder, + AvatarProjectStatus::AvatarProjectStatus& status) { + status = AvatarProjectStatus::NONE; + + if (!isValidNewProjectName(projectsFolder, avatarProjectName)) { + status = AvatarProjectStatus::ERROR_CREATE_PROJECT_NAME; + return nullptr; + } + + QDir projectDir(projectsFolder + "/" + avatarProjectName); + if (!projectDir.mkpath(".")) { + status = AvatarProjectStatus::ERROR_CREATE_CREATING_DIRECTORIES; + return nullptr; + } + + QDir projectTexturesDir(projectDir.path() + "/textures"); + if (!projectTexturesDir.mkpath(".")) { + status = AvatarProjectStatus::ERROR_CREATE_CREATING_DIRECTORIES; + return nullptr; + } + + QDir projectScriptsDir(projectDir.path() + "/scripts"); + if (!projectScriptsDir.mkpath(".")) { + status = AvatarProjectStatus::ERROR_CREATE_CREATING_DIRECTORIES; + return nullptr; + } + + const auto fileName = QFileInfo(avatarModelPath).fileName(); + const auto newModelPath = projectDir.absoluteFilePath(fileName); + const auto newFSTPath = projectDir.absoluteFilePath("avatar.fst"); + QFile::copy(avatarModelPath, newModelPath); + + QFileInfo fbxInfo{ newModelPath }; + if (!fbxInfo.exists() || !fbxInfo.isFile()) { + status = AvatarProjectStatus::ERROR_CREATE_FIND_MODEL; + return nullptr; + } + + QFile fbx{ fbxInfo.filePath() }; + if (!fbx.open(QIODevice::ReadOnly)) { + status = AvatarProjectStatus::ERROR_CREATE_OPEN_MODEL; + return nullptr; + } + + std::shared_ptr<hfm::Model> hfmModel; + + try { + const QByteArray fbxContents = fbx.readAll(); + hfmModel = FBXSerializer().read(fbxContents, QVariantHash(), fbxInfo.filePath()); + } catch (const QString& error) { + Q_UNUSED(error) + status = AvatarProjectStatus::ERROR_CREATE_READ_MODEL; + return nullptr; + } + QStringList textures{}; + + auto addTextureToList = [&textures](hfm::Texture texture) mutable { + if (!texture.filename.isEmpty() && texture.content.isEmpty() && !textures.contains(texture.filename)) { + textures << texture.filename; + } + }; + + foreach(const HFMMaterial material, hfmModel->materials) { + addTextureToList(material.normalTexture); + addTextureToList(material.albedoTexture); + addTextureToList(material.opacityTexture); + addTextureToList(material.glossTexture); + addTextureToList(material.roughnessTexture); + addTextureToList(material.specularTexture); + addTextureToList(material.metallicTexture); + addTextureToList(material.emissiveTexture); + addTextureToList(material.occlusionTexture); + addTextureToList(material.scatteringTexture); + addTextureToList(material.lightmapTexture); + } + + QDir textureDir(textureFolder.isEmpty() ? fbxInfo.absoluteDir() : textureFolder); + + for (const auto& texture : textures) { + QString sourcePath = textureDir.path() + "/" + texture; + QString targetPath = projectTexturesDir.path() + "/" + texture; + + QFileInfo sourceTexturePath(sourcePath); + if (sourceTexturePath.exists()) { + QFile::copy(sourcePath, targetPath); + } + } + + auto fst = FST::createFSTFromModel(newFSTPath, newModelPath, *hfmModel); + fst->setName(avatarProjectName); + + if (!fst->write()) { + status = AvatarProjectStatus::ERROR_CREATE_WRITE_FST; + return nullptr; + } + + status = AvatarProjectStatus::SUCCESS; + return new AvatarProject(fst); +} + +QStringList AvatarProject::getScriptPaths(const QDir& scriptsDir) const { + QStringList result{}; + constexpr auto flags = QDir::Files | QDir::NoSymLinks | QDir::NoDotAndDotDot | QDir::Hidden; + if (!scriptsDir.exists()) { + return result; + } + + for (const auto& script : scriptsDir.entryInfoList({}, flags)) { + if (script.fileName().toLower().endsWith(".js")) { + result.push_back("scripts/" + script.fileName()); + } + } + + return result; +} + +bool AvatarProject::isValidNewProjectName(const QString& projectPath, const QString& projectName) { + if (projectPath.trimmed().isEmpty() || projectName.trimmed().isEmpty()) { + return false; + } + QDir dir(projectPath + "/" + projectName); + return !dir.exists(); +} + +AvatarProject::AvatarProject(const QString& fstPath, const QByteArray& data) : + AvatarProject::AvatarProject(new FST(fstPath, FSTReader::readMapping(data))) { +} +AvatarProject::AvatarProject(FST* fst) { + _fst = fst; + auto fileInfo = QFileInfo(getFSTPath()); + _directory = fileInfo.absoluteDir(); + + _fst->setScriptPaths(getScriptPaths(QDir(_directory.path() + "/scripts"))); + _fst->write(); + + refreshProjectFiles(); + + _projectPath = fileInfo.absoluteDir().absolutePath(); +} + +void AvatarProject::appendDirectory(const QString& prefix, const QDir& dir) { + constexpr auto flags = QDir::Dirs | QDir::Files | QDir::NoSymLinks | QDir::NoDotAndDotDot | QDir::Hidden; + for (auto& entry : dir.entryInfoList({}, flags)) { + if (entry.isFile()) { + _projectFiles.append({ entry.absoluteFilePath(), prefix + entry.fileName() }); + } else if (entry.isDir()) { + appendDirectory(prefix + entry.fileName() + "/", entry.absoluteFilePath()); + } + } +} + +void AvatarProject::refreshProjectFiles() { + _projectFiles.clear(); + appendDirectory("", _directory); +} + +QStringList AvatarProject::getProjectFiles() const { + QStringList paths; + for (auto& path : _projectFiles) { + paths.append(path.relativePath); + } + return paths; +} + +MarketplaceItemUploader* AvatarProject::upload(bool updateExisting) { + QUuid itemID; + if (updateExisting) { + itemID = _fst->getMarketplaceID(); + } + auto uploader = new MarketplaceItemUploader(getProjectName(), "", QFileInfo(getFSTPath()).fileName(), + itemID, _projectFiles); + connect(uploader, &MarketplaceItemUploader::completed, this, [this, uploader]() { + if (uploader->getError() == MarketplaceItemUploader::Error::None) { + _fst->setMarketplaceID(uploader->getMarketplaceID()); + _fst->write(); + } + }); + + return uploader; +} + +void AvatarProject::openInInventory() const { + constexpr int TIME_TO_WAIT_FOR_INVENTORY_TO_OPEN_MS { 1000 }; + + auto tablet = dynamic_cast<TabletProxy*>( + DependencyManager::get<TabletScriptingInterface>()->getTablet("com.highfidelity.interface.tablet.system")); + tablet->loadQMLSource("hifi/commerce/wallet/Wallet.qml"); + DependencyManager::get<HMDScriptingInterface>()->openTablet(); + tablet->getTabletRoot()->forceActiveFocus(); + auto name = getProjectName(); + + // I'm not a fan of this, but it's the only current option. + QTimer::singleShot(TIME_TO_WAIT_FOR_INVENTORY_TO_OPEN_MS, [name, tablet]() { + tablet->sendToQml(QVariantMap({ { "method", "updatePurchases" }, { "filterText", name } })); + }); +} diff --git a/interface/src/avatar/AvatarProject.h b/interface/src/avatar/AvatarProject.h new file mode 100644 index 0000000000..1710282a3e --- /dev/null +++ b/interface/src/avatar/AvatarProject.h @@ -0,0 +1,115 @@ +// +// AvatarProject.h +// +// +// Created by Thijs Wenker on 12/7/2018 +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#pragma once +#ifndef hifi_AvatarProject_h +#define hifi_AvatarProject_h + +#include "MarketplaceItemUploader.h" +#include "ProjectFile.h" +#include "FST.h" + +#include <QObject> +#include <QDir> +#include <QVariantHash> +#include <QStandardPaths> + +namespace AvatarProjectStatus { + Q_NAMESPACE + enum AvatarProjectStatus { + NONE, + SUCCESS, + ERROR_CREATE_PROJECT_NAME, + ERROR_CREATE_CREATING_DIRECTORIES, + ERROR_CREATE_FIND_MODEL, + ERROR_CREATE_OPEN_MODEL, + ERROR_CREATE_READ_MODEL, + ERROR_CREATE_WRITE_FST, + ERROR_OPEN_INVALID_FILE_TYPE, + ERROR_OPEN_PROJECT_FOLDER, + ERROR_OPEN_FIND_FST, + ERROR_OPEN_OPEN_FST, + ERROR_OPEN_FIND_MODEL + }; + Q_ENUM_NS(AvatarProjectStatus) +} + + +class AvatarProject : public QObject { + Q_OBJECT + Q_PROPERTY(FST* fst READ getFST CONSTANT) + + Q_PROPERTY(QStringList projectFiles READ getProjectFiles NOTIFY projectFilesChanged) + + Q_PROPERTY(QString projectFolderPath READ getProjectPath CONSTANT) + Q_PROPERTY(QString projectFSTPath READ getFSTPath CONSTANT) + Q_PROPERTY(QString projectFBXPath READ getFBXPath CONSTANT) + Q_PROPERTY(QString name READ getProjectName WRITE setProjectName NOTIFY nameChanged) + +public: + Q_INVOKABLE MarketplaceItemUploader* upload(bool updateExisting); + Q_INVOKABLE void openInInventory() const; + Q_INVOKABLE QStringList getProjectFiles() const; + + Q_INVOKABLE QString getProjectName() const { return _fst->getName(); } + Q_INVOKABLE void setProjectName(const QString& newProjectName) { + if (newProjectName.trimmed().length() > 0) { + _fst->setName(newProjectName); + _fst->write(); + emit nameChanged(); + } + } + Q_INVOKABLE QString getProjectPath() const { return _projectPath; } + Q_INVOKABLE QString getFSTPath() const { return _fst->getPath(); } + Q_INVOKABLE QString getFBXPath() const { + return QDir::cleanPath(QDir(_projectPath).absoluteFilePath(_fst->getModelPath())); + } + + /** + * returns the AvatarProject or a nullptr on failure. + */ + static AvatarProject* openAvatarProject(const QString& path, AvatarProjectStatus::AvatarProjectStatus& status); + static AvatarProject* createAvatarProject(const QString& projectsFolder, + const QString& avatarProjectName, + const QString& avatarModelPath, + const QString& textureFolder, + AvatarProjectStatus::AvatarProjectStatus& status); + + static bool isValidNewProjectName(const QString& projectPath, const QString& projectName); + + static QString getDefaultProjectsPath() { + return QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation) + "/High Fidelity Projects"; + } + +signals: + void nameChanged(); + void projectFilesChanged(); + +private: + AvatarProject(const QString& fstPath, const QByteArray& data); + AvatarProject(FST* fst); + + ~AvatarProject() { _fst->deleteLater(); } + + FST* getFST() { return _fst; } + + void refreshProjectFiles(); + void appendDirectory(const QString& prefix, const QDir& dir); + QStringList getScriptPaths(const QDir& scriptsDir) const; + + FST* _fst; + + QDir _directory; + QList<ProjectFilePath> _projectFiles{}; + QString _projectPath; +}; + +#endif // hifi_AvatarProject_h diff --git a/interface/src/avatar/MarketplaceItemUploader.cpp b/interface/src/avatar/MarketplaceItemUploader.cpp new file mode 100644 index 0000000000..53b37eba4f --- /dev/null +++ b/interface/src/avatar/MarketplaceItemUploader.cpp @@ -0,0 +1,321 @@ +// +// MarketplaceItemUploader.cpp +// +// +// Created by Ryan Huffman on 12/10/2018 +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "MarketplaceItemUploader.h" + +#include <AccountManager.h> +#include <DependencyManager.h> + +#ifndef Q_OS_ANDROID +#include <quazip5/quazip.h> +#include <quazip5/quazipfile.h> +#endif + +#include <QTimer> +#include <QBuffer> + +#include <QFile> +#include <QFileInfo> + +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QUuid> + +MarketplaceItemUploader::MarketplaceItemUploader(QString title, + QString description, + QString rootFilename, + QUuid marketplaceID, + QList<ProjectFilePath> filePaths) : + _title(title), + _description(description), + _rootFilename(rootFilename), + _marketplaceID(marketplaceID), + _filePaths(filePaths) { +} + +void MarketplaceItemUploader::setState(State newState) { + Q_ASSERT(_state != State::Complete); + Q_ASSERT(_error == Error::None); + Q_ASSERT(newState != _state); + + _state = newState; + emit stateChanged(newState); + if (newState == State::Complete) { + emit completed(); + emit finishedChanged(); + } +} + +void MarketplaceItemUploader::setError(Error error) { + Q_ASSERT(_state != State::Complete); + Q_ASSERT(_error == Error::None); + + _error = error; + emit errorChanged(error); + emit finishedChanged(); +} + +void MarketplaceItemUploader::send() { + doGetCategories(); +} + +void MarketplaceItemUploader::doGetCategories() { + setState(State::GettingCategories); + + static const QString path = "/api/v1/marketplace/categories"; + + auto accountManager = DependencyManager::get<AccountManager>(); + auto request = accountManager->createRequest(path, AccountManagerAuth::None); + + qWarning() << "Request url is: " << request.url(); + + QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance(); + + QNetworkReply* reply = networkAccessManager.get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + auto error = reply->error(); + if (error == QNetworkReply::NoError) { + auto doc = QJsonDocument::fromJson(reply->readAll()); + auto extractCategoryID = [&doc]() -> std::pair<bool, int> { + auto items = doc.object()["data"].toObject()["items"]; + if (!items.isArray()) { + qWarning() << "Categories parse error: data.items is not an array"; + return { false, 0 }; + } + + auto itemsArray = items.toArray(); + for (const auto item : itemsArray) { + if (!item.isObject()) { + qWarning() << "Categories parse error: item is not an object"; + return { false, 0 }; + } + + auto itemObject = item.toObject(); + if (itemObject["name"].toString() == "Avatars") { + auto idValue = itemObject["id"]; + if (!idValue.isDouble()) { + qWarning() << "Categories parse error: id is not a number"; + return { false, 0 }; + } + return { true, (int)idValue.toDouble() }; + } + } + + qWarning() << "Categories parse error: could not find a category for 'Avatar'"; + return { false, 0 }; + }; + + bool success; + std::tie(success, _categoryID) = extractCategoryID(); + if (!success) { + qWarning() << "Failed to find marketplace category id"; + setError(Error::Unknown); + } else { + qDebug() << "Marketplace Avatar category ID is" << _categoryID; + doUploadAvatar(); + } + } else { + setError(Error::Unknown); + } + }); +} + +void MarketplaceItemUploader::doUploadAvatar() { +#ifdef Q_OS_ANDROID + qWarning() << "Marketplace uploading is not supported on Android"; + setError(Error::Unknown); + return; +#else + QBuffer buffer{ &_fileData }; + QuaZip zip{ &buffer }; + if (!zip.open(QuaZip::Mode::mdAdd)) { + qWarning() << "Failed to open zip"; + setError(Error::Unknown); + return; + } + + for (auto& filePath : _filePaths) { + qWarning() << "Zipping: " << filePath.absolutePath << filePath.relativePath; + QFileInfo fileInfo{ filePath.absolutePath }; + + QuaZipFile zipFile{ &zip }; + if (!zipFile.open(QIODevice::WriteOnly, QuaZipNewInfo(filePath.relativePath))) { + qWarning() << "Could not open zip file:" << zipFile.getZipError(); + setError(Error::Unknown); + return; + } + QFile file{ filePath.absolutePath }; + if (file.open(QIODevice::ReadOnly)) { + zipFile.write(file.readAll()); + } else { + qWarning() << "Failed to open: " << filePath.absolutePath; + } + file.close(); + zipFile.close(); + if (zipFile.getZipError() != UNZ_OK) { + qWarning() << "Could not close zip file: " << zipFile.getZipError(); + setState(State::Complete); + return; + } + } + + zip.close(); + + qDebug() << "Finished zipping, size: " << (buffer.size() / (1000.0f)) << "KB"; + + QString path = "/api/v1/marketplace/items"; + bool creating = true; + if (!_marketplaceID.isNull()) { + creating = false; + auto idWithBraces = _marketplaceID.toString(); + auto idWithoutBraces = idWithBraces.mid(1, idWithBraces.length() - 2); + path += "/" + idWithoutBraces; + } + auto accountManager = DependencyManager::get<AccountManager>(); + auto request = accountManager->createRequest(path, AccountManagerAuth::Required); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + + // TODO(huffman) add JSON escaping + auto escapeJson = [](QString str) -> QString { return str; }; + + QString jsonString = "{\"marketplace_item\":{"; + jsonString += "\"title\":\"" + escapeJson(_title) + "\""; + + // Items cannot have their description updated after they have been submitted. + if (creating) { + jsonString += ",\"description\":\"" + escapeJson(_description) + "\""; + } + + jsonString += ",\"root_file_key\":\"" + escapeJson(_rootFilename) + "\""; + jsonString += ",\"category_ids\":[" + QStringLiteral("%1").arg(_categoryID) + "]"; + jsonString += ",\"license\":0"; + jsonString += ",\"files\":\"" + QString::fromLatin1(_fileData.toBase64()) + "\"}}"; + + QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance(); + + QNetworkReply* reply{ nullptr }; + if (creating) { + reply = networkAccessManager.post(request, jsonString.toUtf8()); + } else { + reply = networkAccessManager.put(request, jsonString.toUtf8()); + } + + connect(reply, &QNetworkReply::uploadProgress, this, [this](float bytesSent, float bytesTotal) { + if (_state == State::UploadingAvatar) { + emit uploadProgress(bytesSent, bytesTotal); + if (bytesSent >= bytesTotal) { + setState(State::WaitingForUploadResponse); + } + } + }); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + _responseData = reply->readAll(); + + auto error = reply->error(); + if (error == QNetworkReply::NoError) { + auto doc = QJsonDocument::fromJson(_responseData.toLatin1()); + auto status = doc.object()["status"].toString(); + if (status == "success") { + _marketplaceID = QUuid::fromString(doc["data"].toObject()["marketplace_id"].toString()); + _itemVersion = doc["data"].toObject()["version"].toDouble(); + setState(State::WaitingForInventory); + doWaitForInventory(); + } else { + qWarning() << "Got error response while uploading avatar: " << _responseData; + setError(Error::Unknown); + } + } else { + qWarning() << "Got error while uploading avatar: " << reply->error() << reply->errorString() << _responseData; + setError(Error::Unknown); + } + }); + + setState(State::UploadingAvatar); +#endif +} + +void MarketplaceItemUploader::doWaitForInventory() { + static const QString path = "/api/v1/commerce/inventory"; + + auto accountManager = DependencyManager::get<AccountManager>(); + auto request = accountManager->createRequest(path, AccountManagerAuth::Required); + + QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance(); + + QNetworkReply* reply = networkAccessManager.post(request, ""); + + _numRequestsForInventory++; + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + auto data = reply->readAll(); + + bool success = false; + + auto error = reply->error(); + if (error == QNetworkReply::NoError) { + // Parse response data + auto doc = QJsonDocument::fromJson(data); + auto isAssetAvailable = [this, &doc]() -> bool { + if (!doc.isObject()) { + return false; + } + auto root = doc.object(); + auto status = root["status"].toString(); + if (status != "success") { + return false; + } + auto data = root["data"]; + if (!data.isObject()) { + return false; + } + auto assets = data.toObject()["assets"]; + if (!assets.isArray()) { + return false; + } + for (auto asset : assets.toArray()) { + auto assetObject = asset.toObject(); + auto id = QUuid::fromString(assetObject["id"].toString()); + if (id.isNull()) { + continue; + } + if (id == _marketplaceID) { + auto version = assetObject["version"]; + auto valid = assetObject["valid"]; + if (version.isDouble() && valid.isBool()) { + if ((int)version.toDouble() >= _itemVersion && valid.toBool()) { + return true; + } + } + } + } + return false; + }; + + success = isAssetAvailable(); + } + if (success) { + qDebug() << "Found item in inventory"; + setState(State::Complete); + } else { + constexpr int MAX_INVENTORY_REQUESTS { 8 }; + constexpr int TIME_BETWEEN_INVENTORY_REQUESTS_MS { 5000 }; + if (_numRequestsForInventory > MAX_INVENTORY_REQUESTS) { + qDebug() << "Failed to find item in inventory"; + setError(Error::Unknown); + } else { + QTimer::singleShot(TIME_BETWEEN_INVENTORY_REQUESTS_MS, [this]() { doWaitForInventory(); }); + } + } + }); +} diff --git a/interface/src/avatar/MarketplaceItemUploader.h b/interface/src/avatar/MarketplaceItemUploader.h new file mode 100644 index 0000000000..998413da88 --- /dev/null +++ b/interface/src/avatar/MarketplaceItemUploader.h @@ -0,0 +1,105 @@ +// +// MarketplaceItemUploader.h +// +// +// Created by Ryan Huffman on 12/10/2018 +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#pragma once +#ifndef hifi_MarketplaceItemUploader_h +#define hifi_MarketplaceItemUploader_h + +#include "ProjectFile.h" + +#include <QObject> +#include <QUuid> + +class QNetworkReply; + +class MarketplaceItemUploader : public QObject { + Q_OBJECT + + Q_PROPERTY(bool finished READ getFinished NOTIFY finishedChanged) + + Q_PROPERTY(bool complete READ getComplete NOTIFY stateChanged) + Q_PROPERTY(State state READ getState NOTIFY stateChanged) + Q_PROPERTY(Error error READ getError NOTIFY errorChanged) + Q_PROPERTY(QString responseData READ getResponseData) +public: + enum class Error { + None, + Unknown, + }; + Q_ENUM(Error); + + enum class State { + Idle, + GettingCategories, + UploadingAvatar, + WaitingForUploadResponse, + WaitingForInventory, + Complete, + }; + Q_ENUM(State); + + MarketplaceItemUploader(QString title, + QString description, + QString rootFilename, + QUuid marketplaceID, + QList<ProjectFilePath> filePaths); + + Q_INVOKABLE void send(); + + void setError(Error error); + + QString getResponseData() const { return _responseData; } + void setState(State newState); + State getState() const { return _state; } + bool getComplete() const { return _state == State::Complete; } + + QUuid getMarketplaceID() const { return _marketplaceID; } + + Error getError() const { return _error; } + bool getFinished() const { return _state == State::Complete || _error != Error::None; } + +signals: + void uploadProgress(qint64 bytesSent, qint64 bytesTotal); + void completed(); + + void stateChanged(State newState); + void errorChanged(Error error); + + // Triggered when the upload has finished, either succesfully completing, or stopping with an error + void finishedChanged(); + +private: + void doGetCategories(); + void doUploadAvatar(); + void doWaitForInventory(); + + QNetworkReply* _reply; + + State _state { State::Idle }; + Error _error { Error::None }; + + QString _title; + QString _description; + QString _rootFilename; + QUuid _marketplaceID; + int _categoryID; + int _itemVersion; + + QString _responseData; + + int _numRequestsForInventory { 0 }; + + QString _rootFilePath; + QList<ProjectFilePath> _filePaths; + QByteArray _fileData; +}; + +#endif // hifi_MarketplaceItemUploader_h diff --git a/libraries/avatars/src/ProjectFile.h b/libraries/avatars/src/ProjectFile.h new file mode 100644 index 0000000000..4040eb1ce5 --- /dev/null +++ b/libraries/avatars/src/ProjectFile.h @@ -0,0 +1,13 @@ +#ifndef hifi_AvatarProjectFile_h +#define hifi_AvatarProjectFile_h + +#include <QObject> + +class ProjectFilePath { + Q_GADGET; +public: + QString absolutePath; + QString relativePath; +}; + +#endif // hifi_AvatarProjectFile_h diff --git a/libraries/fbx/src/FST.cpp b/libraries/fbx/src/FST.cpp new file mode 100644 index 0000000000..7828037c74 --- /dev/null +++ b/libraries/fbx/src/FST.cpp @@ -0,0 +1,190 @@ +// +// FST.cpp +// +// Created by Ryan Huffman on 12/11/15. +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + + +#include "FST.h" + +#include <QDir> +#include <QFileInfo> +#include <hfm/HFM.h> + +constexpr float DEFAULT_SCALE { 1.0f }; + +FST::FST(QString fstPath, QVariantHash data) : _fstPath(std::move(fstPath)) { + + auto setValueFromFSTData = [&data] (const QString& propertyID, auto &targetProperty) mutable { + if (data.contains(propertyID)) { + targetProperty = data[propertyID].toString(); + data.remove(propertyID); + } + }; + setValueFromFSTData(NAME_FIELD, _name); + setValueFromFSTData(FILENAME_FIELD, _modelPath); + setValueFromFSTData(MARKETPLACE_ID_FIELD, _marketplaceID); + + if (data.contains(SCRIPT_FIELD)) { + QVariantList scripts = data.values(SCRIPT_FIELD); + for (const auto& script : scripts) { + _scriptPaths.push_back(script.toString()); + } + data.remove(SCRIPT_FIELD); + } + + _other = data; +} + +FST* FST::createFSTFromModel(const QString& fstPath, const QString& modelFilePath, const hfm::Model& hfmModel) { + QVariantHash mapping; + + // mixamo files - in the event that a mixamo file was edited by some other tool, it's likely the applicationName will + // be rewritten, so we detect the existence of several different blendshapes which indicate we're likely a mixamo file + bool likelyMixamoFile = hfmModel.applicationName == "mixamo.com" || + (hfmModel.blendshapeChannelNames.contains("BrowsDown_Right") && + hfmModel.blendshapeChannelNames.contains("MouthOpen") && + hfmModel.blendshapeChannelNames.contains("Blink_Left") && + hfmModel.blendshapeChannelNames.contains("Blink_Right") && + hfmModel.blendshapeChannelNames.contains("Squint_Right")); + + mapping.insert(NAME_FIELD, QFileInfo(fstPath).baseName()); + mapping.insert(FILENAME_FIELD, QFileInfo(modelFilePath).fileName()); + mapping.insert(TEXDIR_FIELD, "textures"); + + // mixamo/autodesk defaults + mapping.insert(SCALE_FIELD, DEFAULT_SCALE); + QVariantHash joints = mapping.value(JOINT_FIELD).toHash(); + joints.insert("jointEyeLeft", hfmModel.jointIndices.contains("jointEyeLeft") ? "jointEyeLeft" : + (hfmModel.jointIndices.contains("EyeLeft") ? "EyeLeft" : "LeftEye")); + + joints.insert("jointEyeRight", hfmModel.jointIndices.contains("jointEyeRight") ? "jointEyeRight" : + hfmModel.jointIndices.contains("EyeRight") ? "EyeRight" : "RightEye"); + + joints.insert("jointNeck", hfmModel.jointIndices.contains("jointNeck") ? "jointNeck" : "Neck"); + joints.insert("jointRoot", "Hips"); + joints.insert("jointLean", "Spine"); + joints.insert("jointLeftHand", "LeftHand"); + joints.insert("jointRightHand", "RightHand"); + + const char* topName = likelyMixamoFile ? "HeadTop_End" : "HeadEnd"; + joints.insert("jointHead", hfmModel.jointIndices.contains(topName) ? topName : "Head"); + + mapping.insert(JOINT_FIELD, joints); + + QVariantHash jointIndices; + for (int i = 0; i < hfmModel.joints.size(); i++) { + jointIndices.insert(hfmModel.joints.at(i).name, QString::number(i)); + } + mapping.insert(JOINT_INDEX_FIELD, jointIndices); + + mapping.insertMulti(FREE_JOINT_FIELD, "LeftArm"); + mapping.insertMulti(FREE_JOINT_FIELD, "LeftForeArm"); + mapping.insertMulti(FREE_JOINT_FIELD, "RightArm"); + mapping.insertMulti(FREE_JOINT_FIELD, "RightForeArm"); + + + // If there are no blendshape mappings, and we detect that this is likely a mixamo file, + // then we can add the default mixamo to "faceshift" mappings + if (likelyMixamoFile) { + QVariantHash blendshapes; + blendshapes.insertMulti("BrowsD_L", QVariantList() << "BrowsDown_Left" << 1.0); + blendshapes.insertMulti("BrowsD_R", QVariantList() << "BrowsDown_Right" << 1.0); + blendshapes.insertMulti("BrowsU_C", QVariantList() << "BrowsUp_Left" << 1.0); + blendshapes.insertMulti("BrowsU_C", QVariantList() << "BrowsUp_Right" << 1.0); + blendshapes.insertMulti("BrowsU_L", QVariantList() << "BrowsUp_Left" << 1.0); + blendshapes.insertMulti("BrowsU_R", QVariantList() << "BrowsUp_Right" << 1.0); + blendshapes.insertMulti("ChinLowerRaise", QVariantList() << "Jaw_Up" << 1.0); + blendshapes.insertMulti("ChinUpperRaise", QVariantList() << "UpperLipUp_Left" << 0.5); + blendshapes.insertMulti("ChinUpperRaise", QVariantList() << "UpperLipUp_Right" << 0.5); + blendshapes.insertMulti("EyeBlink_L", QVariantList() << "Blink_Left" << 1.0); + blendshapes.insertMulti("EyeBlink_R", QVariantList() << "Blink_Right" << 1.0); + blendshapes.insertMulti("EyeOpen_L", QVariantList() << "EyesWide_Left" << 1.0); + blendshapes.insertMulti("EyeOpen_R", QVariantList() << "EyesWide_Right" << 1.0); + blendshapes.insertMulti("EyeSquint_L", QVariantList() << "Squint_Left" << 1.0); + blendshapes.insertMulti("EyeSquint_R", QVariantList() << "Squint_Right" << 1.0); + blendshapes.insertMulti("JawFwd", QVariantList() << "JawForeward" << 1.0); + blendshapes.insertMulti("JawLeft", QVariantList() << "JawRotateY_Left" << 0.5); + blendshapes.insertMulti("JawOpen", QVariantList() << "MouthOpen" << 0.7); + blendshapes.insertMulti("JawRight", QVariantList() << "Jaw_Right" << 1.0); + blendshapes.insertMulti("LipsFunnel", QVariantList() << "JawForeward" << 0.39); + blendshapes.insertMulti("LipsFunnel", QVariantList() << "Jaw_Down" << 0.36); + blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthNarrow_Left" << 1.0); + blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthNarrow_Right" << 1.0); + blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthWhistle_NarrowAdjust_Left" << 0.5); + blendshapes.insertMulti("LipsFunnel", QVariantList() << "MouthWhistle_NarrowAdjust_Right" << 0.5); + blendshapes.insertMulti("LipsFunnel", QVariantList() << "TongueUp" << 1.0); + blendshapes.insertMulti("LipsLowerClose", QVariantList() << "LowerLipIn" << 1.0); + blendshapes.insertMulti("LipsLowerDown", QVariantList() << "LowerLipDown_Left" << 0.7); + blendshapes.insertMulti("LipsLowerDown", QVariantList() << "LowerLipDown_Right" << 0.7); + blendshapes.insertMulti("LipsLowerOpen", QVariantList() << "LowerLipOut" << 1.0); + blendshapes.insertMulti("LipsPucker", QVariantList() << "MouthNarrow_Left" << 1.0); + blendshapes.insertMulti("LipsPucker", QVariantList() << "MouthNarrow_Right" << 1.0); + blendshapes.insertMulti("LipsUpperClose", QVariantList() << "UpperLipIn" << 1.0); + blendshapes.insertMulti("LipsUpperOpen", QVariantList() << "UpperLipOut" << 1.0); + blendshapes.insertMulti("LipsUpperUp", QVariantList() << "UpperLipUp_Left" << 0.7); + blendshapes.insertMulti("LipsUpperUp", QVariantList() << "UpperLipUp_Right" << 0.7); + blendshapes.insertMulti("MouthDimple_L", QVariantList() << "Smile_Left" << 0.25); + blendshapes.insertMulti("MouthDimple_R", QVariantList() << "Smile_Right" << 0.25); + blendshapes.insertMulti("MouthFrown_L", QVariantList() << "Frown_Left" << 1.0); + blendshapes.insertMulti("MouthFrown_R", QVariantList() << "Frown_Right" << 1.0); + blendshapes.insertMulti("MouthLeft", QVariantList() << "Midmouth_Left" << 1.0); + blendshapes.insertMulti("MouthRight", QVariantList() << "Midmouth_Right" << 1.0); + blendshapes.insertMulti("MouthSmile_L", QVariantList() << "Smile_Left" << 1.0); + blendshapes.insertMulti("MouthSmile_R", QVariantList() << "Smile_Right" << 1.0); + blendshapes.insertMulti("Puff", QVariantList() << "CheekPuff_Left" << 1.0); + blendshapes.insertMulti("Puff", QVariantList() << "CheekPuff_Right" << 1.0); + blendshapes.insertMulti("Sneer", QVariantList() << "NoseScrunch_Left" << 0.75); + blendshapes.insertMulti("Sneer", QVariantList() << "NoseScrunch_Right" << 0.75); + blendshapes.insertMulti("Sneer", QVariantList() << "Squint_Left" << 0.5); + blendshapes.insertMulti("Sneer", QVariantList() << "Squint_Right" << 0.5); + mapping.insert(BLENDSHAPE_FIELD, blendshapes); + } + return new FST(fstPath, mapping); +} + +QString FST::absoluteModelPath() const { + QFileInfo fileInfo{ _fstPath }; + QDir dir{ fileInfo.absoluteDir() }; + return dir.absoluteFilePath(_modelPath); +} + +void FST::setName(const QString& name) { + _name = name; + emit nameChanged(name); +} + +void FST::setModelPath(const QString& modelPath) { + _modelPath = modelPath; + emit modelPathChanged(modelPath); +} + +QVariantHash FST::getMapping() const { + QVariantHash mapping; + mapping.unite(_other); + mapping.insert(NAME_FIELD, _name); + mapping.insert(FILENAME_FIELD, _modelPath); + mapping.insert(MARKETPLACE_ID_FIELD, _marketplaceID); + for (const auto& scriptPath : _scriptPaths) { + mapping.insertMulti(SCRIPT_FIELD, scriptPath); + } + return mapping; +} + +bool FST::write() { + QFile fst(_fstPath); + if (!fst.open(QIODevice::WriteOnly)) { + return false; + } + fst.write(FSTReader::writeMapping(getMapping())); + return true; +} + +void FST::setMarketplaceID(QUuid marketplaceID) { + _marketplaceID = marketplaceID; + emit marketplaceIDChanged(); +} diff --git a/libraries/fbx/src/FST.h b/libraries/fbx/src/FST.h new file mode 100644 index 0000000000..0f4c1ecd3a --- /dev/null +++ b/libraries/fbx/src/FST.h @@ -0,0 +1,71 @@ +// +// FST.h +// +// Created by Ryan Huffman on 12/11/15. +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#ifndef hifi_FST_h +#define hifi_FST_h + +#include <QVariantHash> +#include <QUuid> +#include "FSTReader.h" + +namespace hfm { + class Model; +}; + +class FST : public QObject { + Q_OBJECT + Q_PROPERTY(QString name READ getName WRITE setName NOTIFY nameChanged) + Q_PROPERTY(QString modelPath READ getModelPath WRITE setModelPath NOTIFY modelPathChanged) + Q_PROPERTY(QUuid marketplaceID READ getMarketplaceID) + Q_PROPERTY(bool hasMarketplaceID READ getHasMarketplaceID NOTIFY marketplaceIDChanged) +public: + FST(QString fstPath, QVariantHash data); + + static FST* createFSTFromModel(const QString& fstPath, const QString& modelFilePath, const hfm::Model& hfmModel); + + QString absoluteModelPath() const; + + QString getName() const { return _name; } + void setName(const QString& name); + + QString getModelPath() const { return _modelPath; } + void setModelPath(const QString& modelPath); + + Q_INVOKABLE bool getHasMarketplaceID() const { return !_marketplaceID.isNull(); } + QUuid getMarketplaceID() const { return _marketplaceID; } + void setMarketplaceID(QUuid marketplaceID); + + QStringList getScriptPaths() const { return _scriptPaths; } + void setScriptPaths(QStringList scriptPaths) { _scriptPaths = scriptPaths; } + + QString getPath() const { return _fstPath; } + + QVariantHash getMapping() const; + + bool write(); + +signals: + void nameChanged(const QString& name); + void modelPathChanged(const QString& modelPath); + void marketplaceIDChanged(); + +private: + QString _fstPath; + + QString _name{}; + QString _modelPath{}; + QUuid _marketplaceID{}; + + QStringList _scriptPaths{}; + + QVariantHash _other{}; +}; + +#endif // hifi_FST_h diff --git a/libraries/fbx/src/FSTReader.cpp b/libraries/fbx/src/FSTReader.cpp index 75596862d2..43806560dc 100644 --- a/libraries/fbx/src/FSTReader.cpp +++ b/libraries/fbx/src/FSTReader.cpp @@ -84,7 +84,7 @@ void FSTReader::writeVariant(QBuffer& buffer, QVariantHash::const_iterator& it) QByteArray FSTReader::writeMapping(const QVariantHash& mapping) { static const QStringList PREFERED_ORDER = QStringList() << NAME_FIELD << TYPE_FIELD << SCALE_FIELD << FILENAME_FIELD - << TEXDIR_FIELD << SCRIPT_FIELD << JOINT_FIELD << FREE_JOINT_FIELD + << MARKETPLACE_ID_FIELD << TEXDIR_FIELD << SCRIPT_FIELD << JOINT_FIELD << FREE_JOINT_FIELD << BLENDSHAPE_FIELD << JOINT_INDEX_FIELD; QBuffer buffer; buffer.open(QIODevice::WriteOnly); diff --git a/libraries/fbx/src/FSTReader.h b/libraries/fbx/src/FSTReader.h index 00244877b3..993d7c3148 100644 --- a/libraries/fbx/src/FSTReader.h +++ b/libraries/fbx/src/FSTReader.h @@ -18,6 +18,7 @@ static const QString NAME_FIELD = "name"; static const QString TYPE_FIELD = "type"; static const QString FILENAME_FIELD = "filename"; +static const QString MARKETPLACE_ID_FIELD = "marketplaceID"; static const QString TEXDIR_FIELD = "texdir"; static const QString LOD_FIELD = "lod"; static const QString JOINT_INDEX_FIELD = "jointIndex"; diff --git a/libraries/networking/src/AccountManager.cpp b/libraries/networking/src/AccountManager.cpp index 5721ac9334..989661cb81 100644 --- a/libraries/networking/src/AccountManager.cpp +++ b/libraries/networking/src/AccountManager.cpp @@ -208,6 +208,44 @@ void AccountManager::setSessionID(const QUuid& sessionID) { } } +QNetworkRequest AccountManager::createRequest(QString path, AccountManagerAuth::Type authType) { + QNetworkRequest networkRequest; + networkRequest.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + networkRequest.setHeader(QNetworkRequest::UserAgentHeader, _userAgentGetter()); + + networkRequest.setRawHeader(METAVERSE_SESSION_ID_HEADER, + uuidStringWithoutCurlyBraces(_sessionID).toLocal8Bit()); + + QUrl requestURL = _authURL; + + if (requestURL.isEmpty()) { // Assignment client doesn't set _authURL. + requestURL = getMetaverseServerURL(); + } + + if (path.startsWith("/")) { + requestURL.setPath(path); + } else { + requestURL.setPath("/" + path); + } + + if (authType != AccountManagerAuth::None ) { + if (hasValidAccessToken()) { + networkRequest.setRawHeader(ACCESS_TOKEN_AUTHORIZATION_HEADER, + _accountInfo.getAccessToken().authorizationHeaderValue()); + } else { + if (authType == AccountManagerAuth::Required) { + qCDebug(networking) << "No valid access token present. Bailing on invoked request to" + << path << "that requires authentication"; + return QNetworkRequest(); + } + } + } + + networkRequest.setUrl(requestURL); + + return networkRequest; +} + void AccountManager::sendRequest(const QString& path, AccountManagerAuth::Type authType, QNetworkAccessManager::Operation operation, @@ -231,46 +269,10 @@ void AccountManager::sendRequest(const QString& path, QNetworkAccessManager& networkAccessManager = NetworkAccessManager::getInstance(); - QNetworkRequest networkRequest; - networkRequest.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); - networkRequest.setHeader(QNetworkRequest::UserAgentHeader, _userAgentGetter()); - - networkRequest.setRawHeader(METAVERSE_SESSION_ID_HEADER, - uuidStringWithoutCurlyBraces(_sessionID).toLocal8Bit()); - - QUrl requestURL = _authURL; - - if (requestURL.isEmpty()) { // Assignment client doesn't set _authURL. - requestURL = getMetaverseServerURL(); - } - - if (path.startsWith("/")) { - requestURL.setPath(path); - } else { - requestURL.setPath("/" + path); - } - - if (!query.isEmpty()) { - requestURL.setQuery(query); - } - - if (authType != AccountManagerAuth::None ) { - if (hasValidAccessToken()) { - networkRequest.setRawHeader(ACCESS_TOKEN_AUTHORIZATION_HEADER, - _accountInfo.getAccessToken().authorizationHeaderValue()); - } else { - if (authType == AccountManagerAuth::Required) { - qCDebug(networking) << "No valid access token present. Bailing on invoked request to" - << path << "that requires authentication"; - return; - } - } - } - - networkRequest.setUrl(requestURL); + QNetworkRequest networkRequest = createRequest(path, authType); if (VERBOSE_HTTP_REQUEST_DEBUGGING) { - qCDebug(networking) << "Making a request to" << qPrintable(requestURL.toString()); + qCDebug(networking) << "Making a request to" << qPrintable(networkRequest.url().toString()); if (!dataByteArray.isEmpty()) { qCDebug(networking) << "The POST/PUT body -" << QString(dataByteArray); diff --git a/libraries/networking/src/AccountManager.h b/libraries/networking/src/AccountManager.h index d5406707e7..ca2b826c98 100644 --- a/libraries/networking/src/AccountManager.h +++ b/libraries/networking/src/AccountManager.h @@ -28,7 +28,8 @@ class JSONCallbackParameters { public: - JSONCallbackParameters(QObject* callbackReceiver = nullptr, const QString& jsonCallbackMethod = QString(), + JSONCallbackParameters(QObject* callbackReceiver = nullptr, + const QString& jsonCallbackMethod = QString(), const QString& errorCallbackMethod = QString()); bool isEmpty() const { return !callbackReceiver; } @@ -39,11 +40,11 @@ public: }; namespace AccountManagerAuth { - enum Type { - None, - Required, - Optional - }; +enum Type { + None, + Required, + Optional, +}; } Q_DECLARE_METATYPE(AccountManagerAuth::Type); @@ -60,6 +61,7 @@ class AccountManager : public QObject, public Dependency { public: AccountManager(UserAgentGetter userAgentGetter = DEFAULT_USER_AGENT_GETTER); + QNetworkRequest createRequest(QString path, AccountManagerAuth::Type authType); Q_INVOKABLE void sendRequest(const QString& path, AccountManagerAuth::Type authType, QNetworkAccessManager::Operation operation = QNetworkAccessManager::GetOperation, @@ -84,7 +86,7 @@ public: void requestProfile(); DataServerAccountInfo& getAccountInfo() { return _accountInfo; } - void setAccountInfo(const DataServerAccountInfo &newAccountInfo); + void setAccountInfo(const DataServerAccountInfo& newAccountInfo); static QJsonObject dataObjectFromResponse(QNetworkReply* requestReply); @@ -104,7 +106,10 @@ public: public slots: void requestAccessToken(const QString& login, const QString& password); void requestAccessTokenWithSteam(QByteArray authSessionTicket); - void requestAccessTokenWithAuthCode(const QString& authCode, const QString& clientId, const QString& clientSecret, const QString& redirectUri); + void requestAccessTokenWithAuthCode(const QString& authCode, + const QString& clientId, + const QString& clientSecret, + const QString& redirectUri); void refreshAccessToken(); void requestAccessTokenFinished(); @@ -159,4 +164,4 @@ private: bool _limitedCommerce { false }; }; -#endif // hifi_AccountManager_h +#endif // hifi_AccountManager_h