Merge pull request #14917 from thoys/feat/avatarTools/avatarDoctor

Case 21012: Avatar Doctor
This commit is contained in:
John Conklin II 2019-02-15 15:43:18 -08:00 committed by GitHub
commit 4520236873
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 555 additions and 9 deletions

View file

@ -0,0 +1,127 @@
import QtQuick 2.0
import "../../controlsUit" 1.0 as HifiControls
import "../../stylesUit" 1.0
Item {
id: root
visible: false
property var avatarDoctor: null
property var errors: []
property int minimumDiagnoseTimeMS: 1000
signal doneDiagnosing
onVisibleChanged: {
if (root.avatarDoctor !== null) {
root.avatarDoctor.complete.disconnect(_private.avatarDoctorComplete);
root.avatarDoctor = null;
}
if (doneTimer.running) {
doneTimer.stop();
}
if (!root.visible) {
return;
}
root.avatarDoctor = AvatarPackagerCore.currentAvatarProject.diagnose();
root.avatarDoctor.complete.connect(this, _private.avatarDoctorComplete);
_private.startTime = Date.now();
root.avatarDoctor.startDiagnosing();
}
QtObject {
id: _private
property real startTime: 0
function avatarDoctorComplete(errors) {
if (!root.visible) {
return;
}
console.warn("avatarDoctor.complete " + JSON.stringify(errors));
root.errors = errors;
AvatarPackagerCore.currentAvatarProject.hasErrors = errors.length > 0;
AvatarPackagerCore.addCurrentProjectToRecentProjects();
let timeSpendDiagnosingMS = Date.now() - _private.startTime;
let timeLeftMS = root.minimumDiagnoseTimeMS - timeSpendDiagnosingMS;
doneTimer.interval = timeLeftMS < 0 ? 0 : timeLeftMS;
doneTimer.start();
}
}
Timer {
id: doneTimer
repeat: false
running: false
onTriggered: {
doneDiagnosing();
}
}
property var footer: Item {
anchors.fill: parent
anchors.rightMargin: 17
HifiControls.Button {
id: cancelButton
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
height: 30
width: 133
text: qsTr("Cancel")
onClicked: {
avatarPackager.state = AvatarPackagerState.main;
}
}
}
LoadingCircle {
id: loadingCircle
anchors {
top: parent.top
topMargin: 46
horizontalCenter: parent.horizontalCenter
}
width: 163
height: 163
}
RalewayRegular {
id: testingPackageTitle
anchors {
horizontalCenter: parent.horizontalCenter
top: loadingCircle.bottom
topMargin: 5
}
text: "Testing package for errors"
size: 28
color: "white"
}
RalewayRegular {
id: testingPackageText
anchors {
top: testingPackageTitle.bottom
topMargin: 26
left: parent.left
leftMargin: 21
right: parent.right
rightMargin: 16
}
text: "We are trying to find errors in your project so you can quickly understand and resolve them."
size: 21
color: "white"
lineHeight: 33
lineHeightMode: Text.FixedHeight
wrapMode: Text.Wrap
}
}

View file

@ -0,0 +1,104 @@
import QtQuick 2.0
import "../../controlsUit" 1.0 as HifiControls
import "../../stylesUit" 1.0
Item {
id: errorReport
visible: false
property alias errors: errorRepeater.model
property var footer: Item {
anchors.fill: parent
anchors.rightMargin: 17
HifiControls.Button {
id: tryAgainButton
anchors.verticalCenter: parent.verticalCenter
anchors.right: continueButton.left
anchors.rightMargin: 22
height: 40
width: 134
text: qsTr("Try Again")
onClicked: {
avatarPackager.state = AvatarPackagerState.avatarDoctorDiagnose;
}
}
HifiControls.Button {
id: continueButton
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
height: 40
width: 133
text: qsTr("Continue")
color: hifi.buttons.blue
colorScheme: root.colorScheme
onClicked: {
avatarPackager.state = AvatarPackagerState.project;
}
}
}
HiFiGlyphs {
id: errorReportIcon
text: hifi.glyphs.alert
size: 315
color: "#EA4C5F"
anchors {
top: parent.top
topMargin: -20
horizontalCenter: parent.horizontalCenter
}
}
Column {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
top: errorReportIcon.bottom
topMargin: -40
leftMargin: 13
rightMargin: 13
}
spacing: 7
Repeater {
id: errorRepeater
Item {
height: 37
width: parent.width
HiFiGlyphs {
id: errorIcon
text: hifi.glyphs.alert
size: 56
color: "#EA4C5F"
anchors {
top: parent.top
left: parent.left
leftMargin: -5
}
}
RalewayRegular {
id: errorLink
anchors {
top: parent.top
topMargin: 5
left: errorIcon.right
right: parent.right
}
color: "#00B4EF"
linkColor: "#00B4EF"
size: 28
text: "<a href='javascript:void'>" + modelData.message + "</a>"
onLinkActivated: Qt.openUrlExternally(modelData.url)
elide: Text.ElideRight
}
}
}
}
}

View file

@ -143,6 +143,18 @@ Item {
PropertyChanges { target: createAvatarProject; visible: true }
PropertyChanges { target: avatarPackagerFooter; content: createAvatarProject.footer }
},
State {
name: AvatarPackagerState.avatarDoctorDiagnose
PropertyChanges { target: avatarPackagerHeader; title: AvatarPackagerCore.currentAvatarProject.name }
PropertyChanges { target: avatarDoctorDiagnose; visible: true }
PropertyChanges { target: avatarPackagerFooter; content: avatarDoctorDiagnose.footer }
},
State {
name: AvatarPackagerState.avatarDoctorErrorReport
PropertyChanges { target: avatarPackagerHeader; title: AvatarPackagerCore.currentAvatarProject.name }
PropertyChanges { target: avatarDoctorErrorReport; visible: true }
PropertyChanges { target: avatarPackagerFooter; content: avatarDoctorErrorReport.footer }
},
State {
name: AvatarPackagerState.project
PropertyChanges { target: avatarPackagerHeader; title: AvatarPackagerCore.currentAvatarProject.name; canRename: true }
@ -168,7 +180,7 @@ Item {
return status;
}
avatarProject.reset();
avatarPackager.state = AvatarPackagerState.project;
avatarPackager.state = AvatarPackagerState.avatarDoctorDiagnose;
return status;
}
@ -242,6 +254,23 @@ Item {
color: "#404040"
}
AvatarDoctorDiagnose {
id: avatarDoctorDiagnose
anchors.fill: parent
onErrorsChanged: {
avatarDoctorErrorReport.errors = avatarDoctorDiagnose.errors;
}
onDoneDiagnosing: {
avatarPackager.state = avatarDoctorDiagnose.errors.length > 0 ? AvatarPackagerState.avatarDoctorErrorReport
: AvatarPackagerState.project;
}
}
AvatarDoctorErrorReport {
id: avatarDoctorErrorReport
anchors.fill: parent
}
AvatarProject {
id: avatarProject
colorScheme: root.colorScheme
@ -383,6 +412,7 @@ Item {
title: modelData.name
path: modelData.projectPath
onOpen: avatarPackager.openProject(modelData.path)
hasError: modelData.hadErrors
}
}
}

View file

@ -7,4 +7,6 @@ Item {
readonly property string project: "project"
readonly property string createProject: "createProject"
readonly property string projectUpload: "projectUpload"
readonly property string avatarDoctorDiagnose: "avatarDoctorDiagnose"
readonly property string avatarDoctorErrorReport: "avatarDoctorErrorReport"
}

View file

@ -12,6 +12,7 @@ Item {
property alias title: title.text
property alias path: path.text
property alias hasError: errorIcon.visible
property color textColor: "#E3E3E3"
property color hoverTextColor: "#121212"
@ -54,7 +55,7 @@ Item {
RalewayBold {
id: title
elide: "ElideRight"
elide: Text.ElideRight
anchors {
top: parent.top
topMargin: 13
@ -76,12 +77,24 @@ Item {
right: background.right
rightMargin: 16
}
elide: "ElideLeft"
elide: Text.ElideLeft
horizontalAlignment: Text.AlignRight
text: "<path missing>"
size: 20
}
HiFiGlyphs {
id: errorIcon
visible: false
text: hifi.glyphs.alert
size: 56
color: "#EA4C5F"
anchors {
top: parent.top
right: parent.right
}
}
MouseArea {
id: mouseArea
anchors.fill: parent

View file

@ -32,7 +32,7 @@ Item {
return;
}
avatarProject.reset();
avatarPackager.state = AvatarPackagerState.project;
avatarPackager.state = AvatarPackagerState.avatarDoctorDiagnose;
}
}
}

View file

@ -0,0 +1,196 @@
//
// AvatarDoctor.cpp
//
//
// Created by Thijs Wenker on 2/12/2019.
// Copyright 2019 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 "AvatarDoctor.h"
#include <model-networking/ModelCache.h>
#include <AvatarConstants.h>
#include <ResourceManager.h>
AvatarDoctor::AvatarDoctor(QUrl avatarFSTFileUrl) :
_avatarFSTFileUrl(avatarFSTFileUrl) {
connect(this, &AvatarDoctor::complete, this, [this](QVariantList errors) {
_isDiagnosing = false;
});
}
void AvatarDoctor::startDiagnosing() {
if (_isDiagnosing) {
// One diagnose at a time for now
return;
}
_isDiagnosing = true;
_errors.clear();
_externalTextureCount = 0;
_checkedTextureCount = 0;
_missingTextureCount = 0;
_unsupportedTextureCount = 0;
const auto resource = DependencyManager::get<ModelCache>()->getGeometryResource(_avatarFSTFileUrl);
resource->refresh();
const QUrl DEFAULT_URL = QUrl("https://docs.highfidelity.com/create/avatars/create-avatars.html#create-your-own-avatar");
const auto resourceLoaded = [this, resource, DEFAULT_URL](bool success) {
// MODEL
if (!success) {
_errors.push_back({ "Model file cannot be opened", DEFAULT_URL });
emit complete(getErrors());
return;
}
const auto model = resource.data();
const auto avatarModel = resource.data()->getHFMModel();
if (!avatarModel.originalURL.endsWith(".fbx")) {
_errors.push_back({ "Unsupported avatar model format", DEFAULT_URL });
emit complete(getErrors());
return;
}
// RIG
if (avatarModel.joints.isEmpty()) {
_errors.push_back({ "Avatar has no rig", DEFAULT_URL });
} else {
if (avatarModel.joints.length() > 256) {
_errors.push_back({ "Avatar has over 256 bones", DEFAULT_URL });
}
// Avatar does not have Hips bone mapped
if (!avatarModel.getJointNames().contains("Hips")) {
_errors.push_back({ "Hips are not mapped", DEFAULT_URL });
}
if (!avatarModel.getJointNames().contains("Spine")) {
_errors.push_back({ "Spine is not mapped", DEFAULT_URL });
}
if (!avatarModel.getJointNames().contains("Head")) {
_errors.push_back({ "Head is not mapped", DEFAULT_URL });
}
}
// SCALE
const float RECOMMENDED_MIN_HEIGHT = DEFAULT_AVATAR_HEIGHT * 0.25f;
const float RECOMMENDED_MAX_HEIGHT = DEFAULT_AVATAR_HEIGHT * 1.5f;
const float avatarHeight = avatarModel.bindExtents.largestDimension();
if (avatarHeight < RECOMMENDED_MIN_HEIGHT) {
_errors.push_back({ "Avatar is possibly too small.", DEFAULT_URL });
} else if (avatarHeight > RECOMMENDED_MAX_HEIGHT) {
_errors.push_back({ "Avatar is possibly too large.", DEFAULT_URL });
}
// TEXTURES
QStringList externalTextures{};
QSet<QString> textureNames{};
auto addTextureToList = [&externalTextures](hfm::Texture texture) mutable {
if (!texture.filename.isEmpty() && texture.content.isEmpty() && !externalTextures.contains(texture.name)) {
externalTextures << texture.name;
}
};
foreach(const HFMMaterial material, avatarModel.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);
}
if (!externalTextures.empty()) {
// Check External Textures:
auto modelTexturesURLs = model->getTextures();
_externalTextureCount = externalTextures.length();
foreach(const QString textureKey, externalTextures) {
if (!modelTexturesURLs.contains(textureKey)) {
_missingTextureCount++;
_checkedTextureCount++;
continue;
}
const QUrl textureURL = modelTexturesURLs[textureKey].toUrl();
auto textureResource = DependencyManager::get<TextureCache>()->getTexture(textureURL);
auto checkTextureLoadingComplete = [this, DEFAULT_URL] () mutable {
qDebug() << "checkTextureLoadingComplete" << _checkedTextureCount << "/" << _externalTextureCount;
if (_checkedTextureCount == _externalTextureCount) {
if (_missingTextureCount > 0) {
_errors.push_back({ tr("Missing %n texture(s).","", _missingTextureCount), DEFAULT_URL });
}
if (_unsupportedTextureCount > 0) {
_errors.push_back({ tr("%n unsupported texture(s) found.", "", _unsupportedTextureCount), DEFAULT_URL });
}
emit complete(getErrors());
}
};
auto textureLoaded = [this, textureResource, checkTextureLoadingComplete] (bool success) mutable {
if (!success) {
auto normalizedURL = DependencyManager::get<ResourceManager>()->normalizeURL(textureResource->getURL());
if (normalizedURL.isLocalFile()) {
QFile textureFile(normalizedURL.toLocalFile());
if (textureFile.exists()) {
_unsupportedTextureCount++;
} else {
_missingTextureCount++;
}
} else {
_missingTextureCount++;
}
}
_checkedTextureCount++;
checkTextureLoadingComplete();
};
if (textureResource) {
textureResource->refresh();
if (textureResource->isLoaded()) {
textureLoaded(!textureResource->isFailed());
} else {
connect(textureResource.data(), &NetworkTexture::finished, this, textureLoaded);
}
} else {
_missingTextureCount++;
_checkedTextureCount++;
checkTextureLoadingComplete();
}
}
} else {
emit complete(getErrors());
}
};
if (resource) {
if (resource->isLoaded()) {
resourceLoaded(!resource->isFailed());
} else {
connect(resource.data(), &GeometryResource::finished, this, resourceLoaded);
}
} else {
_errors.push_back({ "Model file cannot be opened", DEFAULT_URL });
emit complete(getErrors());
}
}
QVariantList AvatarDoctor::getErrors() const {
QVariantList result;
for (const auto& error : _errors) {
QVariantMap errorVariant;
errorVariant.insert("message", error.message);
errorVariant.insert("url", error.url);
result.append(errorVariant);
}
return result;
}

View file

@ -0,0 +1,51 @@
//
// AvatarDoctor.h
//
//
// Created by Thijs Wenker on 02/12/2019.
// Copyright 2019 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_AvatarDoctor_h
#define hifi_AvatarDoctor_h
#include <QUrl>
#include <QVector>
#include <QVariantMap>
struct AvatarDiagnosticResult {
QString message;
QUrl url;
};
Q_DECLARE_METATYPE(AvatarDiagnosticResult)
Q_DECLARE_METATYPE(QVector<AvatarDiagnosticResult>)
class AvatarDoctor : public QObject {
Q_OBJECT
public:
AvatarDoctor(QUrl avatarFSTFileUrl);
Q_INVOKABLE void startDiagnosing();
Q_INVOKABLE QVariantList getErrors() const;
signals:
void complete(QVariantList errors);
private:
QUrl _avatarFSTFileUrl;
QVector<AvatarDiagnosticResult> _errors;
int _externalTextureCount = 0;
int _checkedTextureCount = 0;
int _missingTextureCount = 0;
int _unsupportedTextureCount = 0;
bool _isDiagnosing = false;
};
#endif // hifi_AvatarDoctor_h

View file

@ -31,6 +31,7 @@ AvatarPackager::AvatarPackager() {
qmlRegisterType<MarketplaceItemUploader>();
qRegisterMetaType<AvatarPackager*>();
qRegisterMetaType<AvatarProject*>();
qRegisterMetaType<AvatarDoctor*>();
qRegisterMetaType<AvatarProjectStatus::AvatarProjectStatus>();
qmlRegisterUncreatableMetaObject(
AvatarProjectStatus::staticMetaObject,
@ -84,7 +85,7 @@ void AvatarPackager::addCurrentProjectToRecentProjects() {
_recentProjects.removeOne(removeProject);
}
const auto newRecentProject = RecentAvatarProject(_currentAvatarProject->getProjectName(), fstPath);
const auto newRecentProject = RecentAvatarProject(_currentAvatarProject->getProjectName(), fstPath, _currentAvatarProject->getHasErrors());
_recentProjects.prepend(newRecentProject);
while (_recentProjects.size() > MAX_RECENT_PROJECTS) {
@ -101,6 +102,7 @@ QVariantList AvatarPackager::recentProjectsToVariantList(bool includeProjectPath
QVariantMap projectVariant;
projectVariant.insert("name", project.getProjectName());
projectVariant.insert("path", project.getProjectFSTPath());
projectVariant.insert("hadErrors", project.getHadErrors());
if (includeProjectPaths) {
projectVariant.insert("projectPath", project.getProjectPath());
}
@ -113,7 +115,10 @@ 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()));
_recentProjects.append(RecentAvatarProject(
map.value("name").toString(),
map.value("path").toString(),
map.value("hadErrors", false).toBool()));
}
}

View file

@ -26,19 +26,23 @@ public:
RecentAvatarProject() = default;
RecentAvatarProject(QString projectName, QString projectFSTPath) {
RecentAvatarProject(QString projectName, QString projectFSTPath, bool hadErrors) {
_projectName = projectName;
_projectFSTPath = projectFSTPath;
_hadErrors = hadErrors;
}
RecentAvatarProject(const RecentAvatarProject& other) {
_projectName = other._projectName;
_projectFSTPath = other._projectFSTPath;
_hadErrors = other._hadErrors;
}
QString getProjectName() const { return _projectName; }
QString getProjectFSTPath() const { return _projectFSTPath; }
bool getHadErrors() const { return _hadErrors; }
QString getProjectPath() const {
return QFileInfo(_projectFSTPath).absoluteDir().absolutePath();
}
@ -50,6 +54,7 @@ public:
private:
QString _projectName;
QString _projectFSTPath;
bool _hadErrors;
};
@ -73,6 +78,8 @@ public:
return AvatarProject::isValidNewProjectName(projectPath, projectName);
}
Q_INVOKABLE void addCurrentProjectToRecentProjects();
signals:
void avatarProjectChanged();
void recentProjectsChanged();
@ -84,8 +91,6 @@ private:
void setAvatarProject(AvatarProject* avatarProject);
void addCurrentProjectToRecentProjects();
AvatarProject* _currentAvatarProject { nullptr };
QVector<RecentAvatarProject> _recentProjects;

View file

@ -243,6 +243,10 @@ MarketplaceItemUploader* AvatarProject::upload(bool updateExisting) {
return uploader;
}
AvatarDoctor* AvatarProject::diagnose() {
return new AvatarDoctor(QUrl(getFSTPath()));
}
void AvatarProject::openInInventory() const {
constexpr int TIME_TO_WAIT_FOR_INVENTORY_TO_OPEN_MS { 1000 };

View file

@ -14,6 +14,7 @@
#define hifi_AvatarProject_h
#include "MarketplaceItemUploader.h"
#include "AvatarDoctor.h"
#include "ProjectFile.h"
#include "FST.h"
@ -53,11 +54,14 @@ class AvatarProject : public QObject {
Q_PROPERTY(QString projectFSTPath READ getFSTPath CONSTANT)
Q_PROPERTY(QString projectFBXPath READ getFBXPath CONSTANT)
Q_PROPERTY(QString name READ getProjectName WRITE setProjectName NOTIFY nameChanged)
Q_PROPERTY(bool hasErrors READ getHasErrors WRITE setHasErrors NOTIFY hasErrorsChanged)
public:
Q_INVOKABLE MarketplaceItemUploader* upload(bool updateExisting);
Q_INVOKABLE void openInInventory() const;
Q_INVOKABLE QStringList getProjectFiles() const;
Q_INVOKABLE AvatarDoctor* diagnose();
Q_INVOKABLE QString getProjectName() const { return _fst->getName(); }
Q_INVOKABLE void setProjectName(const QString& newProjectName) {
@ -72,6 +76,8 @@ public:
Q_INVOKABLE QString getFBXPath() const {
return QDir::cleanPath(QDir(_projectPath).absoluteFilePath(_fst->getModelPath()));
}
Q_INVOKABLE bool getHasErrors() const { return _hasErrors; }
Q_INVOKABLE void setHasErrors(bool hasErrors) { _hasErrors = hasErrors; }
/**
* returns the AvatarProject or a nullptr on failure.
@ -92,6 +98,7 @@ public:
signals:
void nameChanged();
void projectFilesChanged();
void hasErrorsChanged();
private:
AvatarProject(const QString& fstPath, const QByteArray& data);
@ -110,6 +117,8 @@ private:
QDir _directory;
QList<ProjectFilePath> _projectFiles{};
QString _projectPath;
bool _hasErrors { false };
};
#endif // hifi_AvatarProject_h