From 1c4d716fafff1ddcf7d00645494bbe1e2887fd1a Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 5 Jun 2026 14:55:30 -0400 Subject: [PATCH 1/7] Add Validation-mode platform profile picker for asset folder scan (#370). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose built-in scan profiles in the inspector with QSettings persistence, run scans via an isolated qtmesh subprocess (matching CLI --target), and share profile→ScanConfig merge through buildScanConfigWithPlatformProfile. Co-authored-by: Cursor --- qml/BottomContextPanel.qml | 11 +- qml/PropertiesPanel.qml | 186 +++++++++++++++++++ src/AppSettingsKeys.h | 7 + src/AssetScanController.cpp | 308 +++++++++++++++++++++++++++++++ src/AssetScanController.h | 89 +++++++++ src/AssetScanController_test.cpp | 86 +++++++++ src/CLIPipeline.cpp | 16 +- src/CMakeLists.txt | 2 + src/PlatformProfile.cpp | 24 +++ src/PlatformProfile.h | 15 ++ src/mainwindow.cpp | 5 + 11 files changed, 735 insertions(+), 14 deletions(-) create mode 100644 src/AssetScanController.cpp create mode 100644 src/AssetScanController.h create mode 100644 src/AssetScanController_test.cpp diff --git a/qml/BottomContextPanel.qml b/qml/BottomContextPanel.qml index 94b2963c2..dfcd37451 100644 --- a/qml/BottomContextPanel.qml +++ b/qml/BottomContextPanel.qml @@ -213,13 +213,12 @@ Rectangle { Layout.fillWidth: true spacing: 18 + SummaryText { label: "Profile"; value: AssetScanController.selectedProfileId.length > 0 ? AssetScanController.selectedProfileId : "None" } + SummaryText { label: "Asset Root"; value: root.rootFolderName() } + SummaryText { label: "Folder Scan"; value: AssetScanController.scanning ? "Running" : (AssetScanController.hasResults ? (AssetScanController.summaryErrors + " err / " + AssetScanController.summaryWarnings + " warn") : "Not run") } SummaryText { label: "Selection"; value: MeshValidator.hasSelection ? PropertiesPanelController.selectionName : "None" } - SummaryText { label: "Findings"; value: root.issueSummary() } - SummaryText { label: "Suggestions"; value: root.suggestionSummary() } - SummaryText { label: "Fixable" - value: (MeshValidator.hasFixableIssues - || MeshValidator.hasCacheOptimization) ? "Yes" : "No" } - SummaryText { label: "Status"; value: MeshValidator.validating ? "Running" : (MeshValidator.validated ? "Ready" : "Idle") } + SummaryText { label: "Mesh Findings"; value: root.issueSummary() } + SummaryText { label: "Mesh Status"; value: MeshValidator.validating ? "Running" : (MeshValidator.validated ? "Ready" : "Idle") } Item { Layout.fillWidth: true } } } diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 0e2e044f3..423d7294b 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -6,6 +6,7 @@ import AnimationControl 1.0 import EditorMode 1.0 import MaterialEditorQML 1.0 import ThemeManager 1.0 +import AssetBrowser 1.0 Rectangle { id: root @@ -446,6 +447,17 @@ Rectangle { Component.onCompleted: content = materialPresetsComponent } + // ---- Asset Folder Scan (platform profile) ---- + CollapsibleSection { + title: "Asset Folder Scan" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.ValidationMode, + true) + expanded: true + + Component.onCompleted: content = assetScanComponent + } + // ---- Mesh Validation ---- CollapsibleSection { title: "Mesh Validation" @@ -2299,6 +2311,7 @@ Rectangle { objectName: "workspaceAssetBrowserButton" visible: root.showAllModeTools || EditorModeController.currentMode === EditorModeController.MaterialMode + || EditorModeController.currentMode === EditorModeController.ValidationMode text: "Asset Browser" onClicked: root.revealBottomTool("assetBrowser") } @@ -4216,6 +4229,179 @@ Rectangle { } } + // ---- Asset Folder Scan (Validation mode) ---- + Component { + id: assetScanComponent + + Column { + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + font.pixelSize: 10 + color: PropertiesPanelController.textColor + opacity: 0.75 + text: "Scan the Asset Browser folder with the same rules as " + + "qtmesh scan --target. Uses a separate process so your open scene is untouched." + } + + Row { + spacing: 6 + width: parent.width - 16 + Text { + text: "Profile:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 44 + } + ThemedComboBox { + id: profileCombo + width: parent.width - 50 + model: AssetScanController.profileLabels + onActivated: function(index) { + if (index >= 0 && index < AssetScanController.profileIds.length) + AssetScanController.selectedProfileId = AssetScanController.profileIds[index] + } + ToolTip.visible: hovered && AssetScanController.profileDescription.length > 0 + ToolTip.text: AssetScanController.profileDescription + ToolTip.delay: 400 + + Connections { + target: AssetScanController + function onSelectedProfileIdChanged() { + const idx = AssetScanController.profileIds.indexOf(AssetScanController.selectedProfileId) + if (idx >= 0) + profileCombo.currentIndex = idx + } + } + Component.onCompleted: { + const idx = AssetScanController.profileIds.indexOf(AssetScanController.selectedProfileId) + if (idx >= 0) + currentIndex = idx + } + } + } + + Text { + width: parent.width - 16 + visible: AssetScanController.profileDescription.length > 0 + wrapMode: Text.Wrap + font.pixelSize: 10 + color: PropertiesPanelController.textColor + opacity: 0.7 + text: AssetScanController.profileDescription + } + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + font.pixelSize: 10 + color: PropertiesPanelController.textColor + opacity: 0.85 + text: "Folder: " + AssetBrowserController.rootPath + } + + Rectangle { + width: parent.width - 16; height: 28; radius: 3 + color: scanMouse.pressed ? Qt.darker(PropertiesPanelController.highlightColor, 1.2) + : scanMouse.containsMouse ? Qt.lighter(PropertiesPanelController.highlightColor, 1.1) + : PropertiesPanelController.highlightColor + opacity: AssetScanController.scanning ? 0.55 : 1.0 + Text { + anchors.centerIn: parent + text: AssetScanController.scanning ? "Scanning\u2026" : "Scan Asset Folder" + color: "white" + font.pixelSize: 12 + } + MouseArea { + id: scanMouse + anchors.fill: parent + hoverEnabled: true + enabled: !AssetScanController.scanning + onClicked: AssetScanController.scanFolder(AssetBrowserController.rootPath) + } + } + + Text { + width: parent.width - 16 + visible: AssetScanController.scanning + text: "Running qtmesh scan in background\u2026" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.italic: true + } + + Text { + width: parent.width - 16 + visible: AssetScanController.hasResults && !AssetScanController.scanning + wrapMode: Text.Wrap + font.pixelSize: 10 + color: PropertiesPanelController.textColor + text: "Scanned " + AssetScanController.summaryScanned + + " \u2022 passed " + AssetScanController.summaryPassed + + " \u2022 warnings " + AssetScanController.summaryWarnings + + " \u2022 errors " + AssetScanController.summaryErrors + } + + Column { + width: parent.width - 16 + spacing: 3 + visible: AssetScanController.hasResults && AssetScanController.findings.length > 0 + + Repeater { + model: AssetScanController.findings + + Row { + spacing: 6 + width: parent.width + + Text { + text: modelData.severity === "error" ? "\u2718" : "\u26A0" + color: modelData.severity === "error" ? "#e05050" : "#e0a030" + font.pixelSize: 13 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: (modelData.file ? modelData.file + ": " : "") + modelData.message + color: PropertiesPanelController.textColor + font.pixelSize: 10 + wrapMode: Text.Wrap + width: parent.width - 24 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + + Text { + id: assetScanFeedback + width: parent.width - 16 + wrapMode: Text.Wrap + font.pixelSize: 10 + color: "#c06060" + text: "" + + Connections { + target: AssetScanController + function onError(msg) { + assetScanFeedback.color = "#c06060" + assetScanFeedback.text = msg + } + function onScanFinished(ok, message) { + if (ok) { + assetScanFeedback.color = "#60c060" + assetScanFeedback.text = message + } + } + } + } + } + } + // ---- Mesh Validation Content ---- Component { id: validationComponent diff --git a/src/AppSettingsKeys.h b/src/AppSettingsKeys.h index 544c8c328..d9dfc52e7 100644 --- a/src/AppSettingsKeys.h +++ b/src/AppSettingsKeys.h @@ -98,6 +98,13 @@ inline const QString& cloudUserSlug() return k; } +/** @brief Last platform profile id used for Validation-mode asset folder scan (issue #370). */ +inline const QString& validationPlatformProfileId() +{ + static const QString k(QStringLiteral("Validation/platformProfileId")); + return k; +} + } // namespace AppSettingsKeys #endif // APP_SETTINGS_KEYS_H diff --git a/src/AssetScanController.cpp b/src/AssetScanController.cpp new file mode 100644 index 000000000..5db59e265 --- /dev/null +++ b/src/AssetScanController.cpp @@ -0,0 +1,308 @@ +#include "AssetScanController.h" + +#include "AppSettingsKeys.h" +#include "PlatformProfile.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AssetScanController* AssetScanController::m_pSingleton = nullptr; + +AssetScanController* AssetScanController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new AssetScanController(); + return m_pSingleton; +} + +AssetScanController* AssetScanController::qmlInstance(QQmlEngine* engine, QJSEngine* /*scriptEngine*/) +{ + auto* inst = instance(); + engine->setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void AssetScanController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +AssetScanController::AssetScanController(QObject* parent) + : QObject(parent) +{ + reloadProfiles(); + + QSettings settings; + const QString saved = settings.value(AppSettingsKeys::validationPlatformProfileId()).toString(); + if (!saved.isEmpty() && m_profileIds.contains(saved)) + setSelectedProfileId(saved); + else if (!m_profileIds.isEmpty()) + setSelectedProfileId(m_profileIds.first()); +} + +void AssetScanController::reloadProfiles() +{ + m_profileIds.clear(); + m_profileLabels.clear(); + + const QStringList ids = PlatformProfileLoader::listBuiltinIds(); + for (const QString& id : ids) { + const PlatformProfileLoadResult loaded = PlatformProfileLoader::load(id); + if (!loaded.ok) + continue; + m_profileIds.append(loaded.profile.id); + const QString label = loaded.profile.displayName.trimmed(); + m_profileLabels.append(label.isEmpty() ? loaded.profile.id : label); + } + + emit profilesChanged(); +} + +void AssetScanController::setSelectedProfileId(const QString& id) +{ + const QString trimmed = id.trimmed(); + if (m_selectedProfileId == trimmed) + return; + if (!trimmed.isEmpty() && !m_profileIds.contains(trimmed)) + return; + + m_selectedProfileId = trimmed; + updateProfileDescription(); + + QSettings settings; + settings.setValue(AppSettingsKeys::validationPlatformProfileId(), m_selectedProfileId); + + if (!m_selectedProfileId.isEmpty()) { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("validation profile=%1").arg(m_selectedProfileId)); + } + + emit selectedProfileIdChanged(); +} + +void AssetScanController::updateProfileDescription() +{ + m_profileDescription.clear(); + if (m_selectedProfileId.isEmpty()) + return; + + const PlatformProfileLoadResult loaded = PlatformProfileLoader::load(m_selectedProfileId); + if (loaded.ok) + m_profileDescription = loaded.profile.description.trimmed(); +} + +QString AssetScanController::resolveCliBinaryForTest() +{ + const QDir appDir(QCoreApplication::applicationDirPath()); +#ifdef Q_OS_WIN + const QString launcher = appDir.filePath(QStringLiteral("qtmesh.exe")); + if (QFileInfo::exists(launcher)) + return launcher; +#endif + const QString symlink = appDir.filePath(QStringLiteral("qtmesh")); + if (QFileInfo::exists(symlink)) + return QFileInfo(symlink).canonicalFilePath(); + return QCoreApplication::applicationFilePath(); +} + +bool AssetScanController::parseScanJsonReport(const QByteArray& jsonBytes, + int* scanned, int* passed, int* warnings, int* errors, + QVariantList* findings, QString* errorOut) +{ + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(jsonBytes, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + if (errorOut) + *errorOut = QStringLiteral("Invalid scan JSON: %1").arg(parseError.errorString()); + return false; + } + + const QJsonObject root = doc.object(); + const QJsonObject summary = root.value(QStringLiteral("summary")).toObject(); + if (scanned) + *scanned = summary.value(QStringLiteral("scanned")).toInt(0); + if (passed) + *passed = summary.value(QStringLiteral("passed")).toInt(0); + if (warnings) + *warnings = summary.value(QStringLiteral("warnings")).toInt(0); + if (errors) + *errors = summary.value(QStringLiteral("errors")).toInt(0); + + if (findings) { + findings->clear(); + const QJsonArray assets = root.value(QStringLiteral("assets")).toArray(); + for (const QJsonValue& assetVal : assets) { + const QJsonObject asset = assetVal.toObject(); + const QString file = asset.value(QStringLiteral("file")).toString(); + const QJsonArray assetFindings = asset.value(QStringLiteral("findings")).toArray(); + for (const QJsonValue& findingVal : assetFindings) { + const QJsonObject fo = findingVal.toObject(); + const QString severity = fo.value(QStringLiteral("severity")).toString(); + if (severity == QLatin1String("info")) + continue; + QVariantMap row; + row.insert(QStringLiteral("file"), file); + row.insert(QStringLiteral("rule"), fo.value(QStringLiteral("rule")).toString()); + row.insert(QStringLiteral("severity"), severity); + row.insert(QStringLiteral("message"), fo.value(QStringLiteral("message")).toString()); + findings->append(row); + } + } + } + + return true; +} + +void AssetScanController::setScanning(bool scanning) +{ + if (m_scanning == scanning) + return; + m_scanning = scanning; + emit scanningChanged(); +} + +void AssetScanController::applyScanReport(const QByteArray& jsonBytes) +{ + int scanned = 0; + int passed = 0; + int warnings = 0; + int errors = 0; + QVariantList parsedFindings; + QString parseError; + if (!parseScanJsonReport(jsonBytes, &scanned, &passed, &warnings, &errors, + &parsedFindings, &parseError)) { + emit error(parseError); + emit scanFinished(false, parseError); + return; + } + + m_summaryScanned = scanned; + m_summaryPassed = passed; + m_summaryWarnings = warnings; + m_summaryErrors = errors; + m_findings = parsedFindings; + m_hasResults = true; + emit resultsChanged(); + + const QString message = QStringLiteral("Scanned %1 file(s): %2 passed, %3 warning(s), %4 error(s)") + .arg(scanned) + .arg(passed) + .arg(warnings) + .arg(errors); + emit scanFinished(true, message); +} + +struct ScanSubprocessOutcome { + bool ok = false; + QString message; + QByteArray jsonBytes; +}; + +static ScanSubprocessOutcome runScanSubprocessSync(const QString& rootPath, const QString& profileId) +{ + ScanSubprocessOutcome outcome; + const QString binary = AssetScanController::resolveCliBinaryForTest(); + QStringList args; + const QString baseName = QFileInfo(binary).fileName().toLower(); + if (baseName.contains(QStringLiteral("editor"))) + args << QStringLiteral("--cli"); + args << QStringLiteral("scan") + << QDir(rootPath).absolutePath() + << QStringLiteral("--target") << profileId + << QStringLiteral("--json") + << QStringLiteral("--no-telemetry"); + + QProcess process; + process.setProgram(binary); + process.setArguments(args); + process.setProcessChannelMode(QProcess::SeparateChannels); + process.start(); + if (!process.waitForStarted(15000)) { + outcome.message = QStringLiteral("Could not start scan process: %1").arg(process.errorString()); + return outcome; + } + process.waitForFinished(-1); + + outcome.jsonBytes = process.readAllStandardOutput(); + const QByteArray stderrBytes = process.readAllStandardError(); + if (process.exitStatus() != QProcess::NormalExit) { + outcome.message = QStringLiteral("Scan process crashed"); + return outcome; + } + + if (outcome.jsonBytes.trimmed().isEmpty()) { + outcome.message = stderrBytes.trimmed().isEmpty() + ? QStringLiteral("Scan produced no output (exit %1)").arg(process.exitCode()) + : QString::fromUtf8(stderrBytes).trimmed(); + return outcome; + } + + outcome.ok = true; + return outcome; +} + +void AssetScanController::scanFolder(const QString& rootPath) +{ + if (m_scanning) { + emit error(QStringLiteral("A scan is already running")); + return; + } + + const QString absRoot = QDir(rootPath).absolutePath(); + if (absRoot.isEmpty() || !QFileInfo(absRoot).isDir()) { + const QString msg = QStringLiteral("Choose a valid asset folder first"); + emit error(msg); + emit scanFinished(false, msg); + return; + } + + if (m_selectedProfileId.isEmpty()) { + const QString msg = QStringLiteral("Select a platform profile first"); + emit error(msg); + emit scanFinished(false, msg); + return; + } + + const PlatformProfileScanSetup setup = buildScanConfigWithPlatformProfile(m_selectedProfileId); + if (!setup.ok) { + emit error(setup.error); + emit scanFinished(false, setup.error); + return; + } + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("asset scan start profile=%1 root=%2").arg(m_selectedProfileId, absRoot)); + + setScanning(true); + m_hasResults = false; + emit resultsChanged(); + + const QString profileId = m_selectedProfileId; + auto outcome = std::make_shared(); + QThread* worker = QThread::create([absRoot, profileId, outcome]() { + *outcome = runScanSubprocessSync(absRoot, profileId); + }); + + connect(worker, &QThread::finished, this, [this, worker, outcome]() { + setScanning(false); + if (!outcome->ok) { + emit error(outcome->message); + emit scanFinished(false, outcome->message); + } else { + applyScanReport(outcome->jsonBytes); + } + worker->deleteLater(); + }, Qt::QueuedConnection); + + worker->start(); +} diff --git a/src/AssetScanController.h b/src/AssetScanController.h new file mode 100644 index 000000000..3743ea212 --- /dev/null +++ b/src/AssetScanController.h @@ -0,0 +1,89 @@ +#ifndef ASSETSCANCONTROLLER_H +#define ASSETSCANCONTROLLER_H + +#include +#include +#include + +class AssetScanController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QStringList profileIds READ profileIds NOTIFY profilesChanged) + Q_PROPERTY(QStringList profileLabels READ profileLabels NOTIFY profilesChanged) + Q_PROPERTY(QString selectedProfileId READ selectedProfileId WRITE setSelectedProfileId + NOTIFY selectedProfileIdChanged) + Q_PROPERTY(QString profileDescription READ profileDescription NOTIFY selectedProfileIdChanged) + Q_PROPERTY(bool scanning READ scanning NOTIFY scanningChanged) + Q_PROPERTY(bool hasResults READ hasResults NOTIFY resultsChanged) + Q_PROPERTY(int summaryScanned READ summaryScanned NOTIFY resultsChanged) + Q_PROPERTY(int summaryPassed READ summaryPassed NOTIFY resultsChanged) + Q_PROPERTY(int summaryWarnings READ summaryWarnings NOTIFY resultsChanged) + Q_PROPERTY(int summaryErrors READ summaryErrors NOTIFY resultsChanged) + Q_PROPERTY(QVariantList findings READ findings NOTIFY resultsChanged) + +public: + static AssetScanController* instance(); + static AssetScanController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + QStringList profileIds() const { return m_profileIds; } + QStringList profileLabels() const { return m_profileLabels; } + QString selectedProfileId() const { return m_selectedProfileId; } + QString profileDescription() const { return m_profileDescription; } + bool scanning() const { return m_scanning; } + bool hasResults() const { return m_hasResults; } + int summaryScanned() const { return m_summaryScanned; } + int summaryPassed() const { return m_summaryPassed; } + int summaryWarnings() const { return m_summaryWarnings; } + int summaryErrors() const { return m_summaryErrors; } + QVariantList findings() const { return m_findings; } + + void setSelectedProfileId(const QString& id); + + /// Scan @p rootPath with the selected platform profile (mirrors `qtmesh scan --target`). + Q_INVOKABLE void scanFolder(const QString& rootPath); + + /// Resolve the CLI binary used for isolated subprocess scans (test seam). + static QString resolveCliBinaryForTest(); + + /// Parse `--json` scan stdout into summary + findings (test seam). + static bool parseScanJsonReport(const QByteArray& jsonBytes, + int* scanned, int* passed, int* warnings, int* errors, + QVariantList* findings, QString* errorOut); + +private: + explicit AssetScanController(QObject* parent = nullptr); + ~AssetScanController() override = default; + + void reloadProfiles(); + void updateProfileDescription(); + void setScanning(bool scanning); + void applyScanReport(const QByteArray& jsonBytes); + + static AssetScanController* m_pSingleton; + + QStringList m_profileIds; + QStringList m_profileLabels; + QString m_selectedProfileId; + QString m_profileDescription; + bool m_scanning = false; + bool m_hasResults = false; + int m_summaryScanned = 0; + int m_summaryPassed = 0; + int m_summaryWarnings = 0; + int m_summaryErrors = 0; + QVariantList m_findings; + +signals: + void profilesChanged(); + void selectedProfileIdChanged(); + void scanningChanged(); + void resultsChanged(); + void scanFinished(bool ok, const QString& message); + void error(const QString& message); +}; + +#endif // ASSETSCANCONTROLLER_H diff --git a/src/AssetScanController_test.cpp b/src/AssetScanController_test.cpp new file mode 100644 index 000000000..42d512a2b --- /dev/null +++ b/src/AssetScanController_test.cpp @@ -0,0 +1,86 @@ +#include + +#include "AssetScanController.h" +#include "AppSettingsKeys.h" +#include "PlatformProfile.h" +#include "ScanConfig.h" + +#include +#include +#include +#include +#include + +TEST(AssetScanControllerTest, ParseScanJsonReport_ExtractsSummaryAndFindings) +{ + const QByteArray json = R"({ + "summary": { "scanned": 2, "passed": 1, "warnings": 1, "errors": 1 }, + "assets": [ + { + "file": "a.fbx", + "findings": [ + { "rule": "max_triangle_count", "severity": "error", "message": "too many tris" }, + { "rule": "max_acmr", "severity": "info", "message": "ok acmr" } + ] + }, + { + "file": "b.fbx", + "findings": [ + { "rule": "max_file_size_mb", "severity": "warning", "message": "large file" } + ] + } + ] + })"; + + int scanned = 0; + int passed = 0; + int warnings = 0; + int errors = 0; + QVariantList findings; + QString error; + ASSERT_TRUE(AssetScanController::parseScanJsonReport(json, &scanned, &passed, &warnings, &errors, + &findings, &error)); + EXPECT_EQ(scanned, 2); + EXPECT_EQ(passed, 1); + EXPECT_EQ(warnings, 1); + EXPECT_EQ(errors, 1); + ASSERT_EQ(findings.size(), 2); + EXPECT_EQ(findings.at(0).toMap().value(QStringLiteral("severity")).toString(), QStringLiteral("error")); + EXPECT_EQ(findings.at(1).toMap().value(QStringLiteral("severity")).toString(), QStringLiteral("warning")); +} + +TEST(AssetScanControllerTest, SelectedProfileId_PersistsInQSettings) +{ + AssetScanController::kill(); + QSettings settings; + settings.remove(AppSettingsKeys::validationPlatformProfileId()); + + auto* controller = AssetScanController::instance(); + ASSERT_FALSE(controller->profileIds().isEmpty()); + + const QString id = controller->profileIds().first(); + controller->setSelectedProfileId(id); + EXPECT_EQ(controller->selectedProfileId(), id); + EXPECT_EQ(settings.value(AppSettingsKeys::validationPlatformProfileId()).toString(), id); + + AssetScanController::kill(); +} + +TEST(PlatformProfileScanSetupTest, BuildScanConfigWithPlatformProfile_MatchesLoader) +{ + const QStringList ids = PlatformProfileLoader::listBuiltinIds(); + ASSERT_FALSE(ids.isEmpty()); + + const QString id = ids.first(); + const PlatformProfileScanSetup setup = buildScanConfigWithPlatformProfile(id); + ASSERT_TRUE(setup.ok) << setup.error.toStdString(); + EXPECT_EQ(setup.profileId, id); + + ScanConfig expected = ScanConfig::defaults(); + const PlatformProfileLoadResult loaded = PlatformProfileLoader::load(id); + ASSERT_TRUE(loaded.ok); + applyPlatformProfile(expected, loaded.profile); + + EXPECT_EQ(setup.config.maxTriangleCount, expected.maxTriangleCount); + EXPECT_EQ(setup.config.allowedFormats, expected.allowedFormats); +} diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 56dba8d8b..cb7ca023e 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -4206,18 +4206,18 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) if (!profileId.isEmpty()) { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("scan profile=%1").arg(profileId)); - const PlatformProfileLoadResult loaded = PlatformProfileLoader::load(profileId); - if (!loaded.ok) { - err() << "Error: " << loaded.error << Qt::endl; + const PlatformProfileScanSetup setup = buildScanConfigWithPlatformProfile(profileId); + if (!setup.ok) { + err() << "Error: " << setup.error << Qt::endl; return 2; } - for (const QString& w : loaded.warnings) + for (const QString& w : setup.warnings) err() << "Warning: " << w << Qt::endl; - applyPlatformProfile(config, loaded.profile); - activeProfileId = loaded.profile.id; - err() << "Note: Using platform profile '" << loaded.profile.id << "'." << Qt::endl; + config = setup.config; + activeProfileId = setup.profileId; + err() << "Note: Using platform profile '" << setup.profileId << "'." << Qt::endl; SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), - QStringLiteral("platform profile=%1").arg(loaded.profile.id)); + QStringLiteral("platform profile=%1").arg(setup.profileId)); } if (!projectRoot.isEmpty()) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 68b152a93..f893c7904 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -127,6 +127,7 @@ FeedbackDiagnostics.cpp FeedbackDialog.cpp FeedbackReportHelper.cpp AssetBrowserController.cpp +AssetScanController.cpp MaterialPreviewRenderer.cpp ModelTurntableRenderer.cpp EditableMesh.cpp @@ -243,6 +244,7 @@ FeedbackDiagnostics.h FeedbackDialog.h FeedbackReportHelper.h AssetBrowserController.h +AssetScanController.h MaterialPreviewRenderer.h ModelTurntableRenderer.h EditableMesh.h diff --git a/src/PlatformProfile.cpp b/src/PlatformProfile.cpp index 46c3cad4b..f1083ae8e 100644 --- a/src/PlatformProfile.cpp +++ b/src/PlatformProfile.cpp @@ -398,6 +398,30 @@ PlatformProfileLoadResult PlatformProfileLoader::load(const QString& pathOrId) return res; } +PlatformProfileScanSetup buildScanConfigWithPlatformProfile(const QString& profileIdOrEmpty) +{ + PlatformProfileScanSetup out; + out.config = ScanConfig::defaults(); + + const QString id = profileIdOrEmpty.trimmed(); + if (id.isEmpty()) { + out.ok = true; + return out; + } + + const PlatformProfileLoadResult loaded = PlatformProfileLoader::load(id); + if (!loaded.ok) { + out.error = loaded.error; + return out; + } + + out.warnings = loaded.warnings; + applyPlatformProfile(out.config, loaded.profile); + out.profileId = loaded.profile.id; + out.ok = true; + return out; +} + void applyPlatformProfile(ScanConfig& config, const PlatformProfile& profile) { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), diff --git a/src/PlatformProfile.h b/src/PlatformProfile.h index 623fff2ce..ad94e95ed 100644 --- a/src/PlatformProfile.h +++ b/src/PlatformProfile.h @@ -76,4 +76,19 @@ class PlatformProfileLoader { /// Merge profile rules and scopes onto @p config (does not reset scan/fix/report). void applyPlatformProfile(ScanConfig& config, const PlatformProfile& profile); +/** + * Shared scan-config builder for CLI and GUI (issue #370). + * Applies ScanConfig::defaults() plus an optional platform profile (CLI precedence steps 1–2). + * Project config and CLI flag overrides remain the caller's responsibility. + */ +struct PlatformProfileScanSetup { + bool ok = false; + QString error; + QString profileId; + QStringList warnings; + ScanConfig config; +}; + +PlatformProfileScanSetup buildScanConfigWithPlatformProfile(const QString& profileIdOrEmpty); + #endif // PLATFORMPROFILE_H diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3e120fa41..1429fa420 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -82,6 +82,7 @@ #include "MeshLodController.h" #include "MeshDecimatorController.h" #include "MeshValidator.h" +#include "AssetScanController.h" #include "UvUnwrapController.h" #include "QuadRetopoController.h" #include "SkinWeightsController.h" @@ -597,6 +598,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return MeshValidator::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "AssetScanController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return AssetScanController::qmlInstance(engine, nullptr); + }); qmlRegisterSingletonType("PropertiesPanel", 1, 0, "MaterialPresetLibrary", [](QQmlEngine* engine, QJSEngine*) -> QObject* { return MaterialPresetLibrary::qmlInstance(engine, nullptr); From 8b28d527360c885e1ba5d0d107473019d3c444fd Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 5 Jun 2026 15:14:25 -0400 Subject: [PATCH 2/7] Fix test targets linking AssetScanController from mainwindow. Co-authored-by: Cursor --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dc1c6b3f6..8382a1217 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -138,6 +138,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanEngine.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PlatformProfile.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetBrowserController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetScanController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelTurntableRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.cpp From db7327905243fa9ee4c5546fa6777deb65056b5d Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 5 Jun 2026 15:15:21 -0400 Subject: [PATCH 3/7] Add AssetScanController header to test target sources for AUTOMOC. Co-authored-by: Cursor --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8382a1217..3839840b0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -276,6 +276,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanEngine.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetBrowserController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetScanController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelTurntableRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.h From e1bc19998b354e370c75137d4949cc05db5b43e1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 8 Jun 2026 22:03:23 -0400 Subject: [PATCH 4/7] Hide example profiles in Validation picker and default to modern-console. Add Browse/Open Asset Browser controls in the scan panel so the assets folder is easier to set. Co-authored-by: Cursor --- qml/PropertiesPanel.qml | 77 ++++++++++++++++++++++++++++---- src/AssetScanController.cpp | 21 ++++++++- src/AssetScanController_test.cpp | 18 ++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 423d7294b..3253464b9 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4244,8 +4244,8 @@ Rectangle { font.pixelSize: 10 color: PropertiesPanelController.textColor opacity: 0.75 - text: "Scan the Asset Browser folder with the same rules as " - + "qtmesh scan --target. Uses a separate process so your open scene is untouched." + text: "Pick an assets folder below (or open Asset Browser \u2192 Browse\u2026). " + + "Scan uses the same rules as qtmesh scan --target in a separate process." } Row { @@ -4296,13 +4296,74 @@ Rectangle { text: AssetScanController.profileDescription } - Text { + Row { + spacing: 6 width: parent.width - 16 - wrapMode: Text.Wrap - font.pixelSize: 10 - color: PropertiesPanelController.textColor - opacity: 0.85 - text: "Folder: " + AssetBrowserController.rootPath + + Rectangle { + width: parent.width - 66 + height: 24 + radius: 3 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + clip: true + + Text { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: Text.AlignVCenter + text: AssetBrowserController.rootPath + color: PropertiesPanelController.textColor + font.pixelSize: 10 + elide: Text.ElideLeft + } + } + + Rectangle { + width: 60 + height: 24 + radius: 3 + color: folderBrowseMouse.containsMouse + ? Qt.lighter(PropertiesPanelController.inputColor, 1.3) + : PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + text: "Browse\u2026" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: folderBrowseMouse + anchors.fill: parent + hoverEnabled: true + onClicked: AssetBrowserController.browseForDirectory() + } + } + } + + Rectangle { + width: parent.width - 16; height: 24; radius: 3 + color: browserMouse.containsMouse + ? Qt.lighter(PropertiesPanelController.controlBgColor, 1.08) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + text: "Open Asset Browser panel" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: browserMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.revealBottomTool("assetBrowser") + } } Rectangle { diff --git a/src/AssetScanController.cpp b/src/AssetScanController.cpp index 5db59e265..1047502bd 100644 --- a/src/AssetScanController.cpp +++ b/src/AssetScanController.cpp @@ -15,6 +15,21 @@ #include #include +namespace { + +QString defaultValidationProfileId() +{ + return QStringLiteral("modern-console"); +} + +bool isUiExcludedProfileId(const QString& id) +{ + // Example/CI profiles stay on disk for tests and CLI, but are hidden from the GUI picker. + return id.startsWith(QStringLiteral("example-")); +} + +} // namespace + AssetScanController* AssetScanController::m_pSingleton = nullptr; AssetScanController* AssetScanController::instance() @@ -44,8 +59,10 @@ AssetScanController::AssetScanController(QObject* parent) QSettings settings; const QString saved = settings.value(AppSettingsKeys::validationPlatformProfileId()).toString(); - if (!saved.isEmpty() && m_profileIds.contains(saved)) + if (!saved.isEmpty() && !isUiExcludedProfileId(saved) && m_profileIds.contains(saved)) setSelectedProfileId(saved); + else if (m_profileIds.contains(defaultValidationProfileId())) + setSelectedProfileId(defaultValidationProfileId()); else if (!m_profileIds.isEmpty()) setSelectedProfileId(m_profileIds.first()); } @@ -57,6 +74,8 @@ void AssetScanController::reloadProfiles() const QStringList ids = PlatformProfileLoader::listBuiltinIds(); for (const QString& id : ids) { + if (isUiExcludedProfileId(id)) + continue; const PlatformProfileLoadResult loaded = PlatformProfileLoader::load(id); if (!loaded.ok) continue; diff --git a/src/AssetScanController_test.cpp b/src/AssetScanController_test.cpp index 42d512a2b..0a1f3f44b 100644 --- a/src/AssetScanController_test.cpp +++ b/src/AssetScanController_test.cpp @@ -66,6 +66,24 @@ TEST(AssetScanControllerTest, SelectedProfileId_PersistsInQSettings) AssetScanController::kill(); } +TEST(AssetScanControllerTest, UiProfileList_ExcludesExampleProfilesAndDefaultsModernConsole) +{ + AssetScanController::kill(); + QSettings settings; + settings.remove(AppSettingsKeys::validationPlatformProfileId()); + + auto* controller = AssetScanController::instance(); + const QStringList ids = controller->profileIds(); + ASSERT_FALSE(ids.isEmpty()); + EXPECT_FALSE(ids.contains(QStringLiteral("example-minimal"))); + EXPECT_FALSE(ids.contains(QStringLiteral("example-base"))); + EXPECT_FALSE(ids.contains(QStringLiteral("example-texture-inspect"))); + EXPECT_TRUE(ids.contains(QStringLiteral("modern-console"))); + EXPECT_EQ(controller->selectedProfileId(), QStringLiteral("modern-console")); + + AssetScanController::kill(); +} + TEST(PlatformProfileScanSetupTest, BuildScanConfigWithPlatformProfile_MatchesLoader) { const QStringList ids = PlatformProfileLoader::listBuiltinIds(); From a4faf49c47294310d9ca6e5cab82e032711534f3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 8 Jun 2026 22:09:32 -0400 Subject: [PATCH 5/7] Default Validation picker to Modern Console and simplify profile labels. Migrate stale QSettings on picker version bump, sort Modern Console first, strip validation suffixes from combo labels, and shorten bundled profile display names. Co-authored-by: Cursor --- profiles/dreamcast.json | 2 +- profiles/mobile-low.json | 2 +- profiles/modern-console.json | 2 +- profiles/n64.json | 2 +- profiles/nds.json | 2 +- profiles/ps1.json | 2 +- profiles/steamdeck.json | 2 +- profiles/switch-like.json | 2 +- profiles/vr.json | 2 +- profiles/webgl.json | 2 +- qml/PropertiesPanel.qml | 18 +++---------- src/AppSettingsKeys.h | 7 ++++++ src/AssetScanController.cpp | 43 +++++++++++++++++++++++++++++--- src/AssetScanController_test.cpp | 16 ++++++++++++ 14 files changed, 77 insertions(+), 27 deletions(-) diff --git a/profiles/dreamcast.json b/profiles/dreamcast.json index 0e2c3dab3..d79b32c68 100644 --- a/profiles/dreamcast.json +++ b/profiles/dreamcast.json @@ -1,6 +1,6 @@ { "id": "dreamcast", - "displayName": "Sega Dreamcast (validation)", + "displayName": "Sega Dreamcast", "description": "Validation-only lint preset for Dreamcast-class workflows. PVR-oriented texture format allow-list placeholder (png/jpg/pvr) with triangle, bone, and draw-call budgets aligned with common homebrew targets. Does not export PVR-ready assets or guarantee KallistiOS/Hektic compliance — model export remains engine-specific.", "rules": { "max_triangle_count": 15000, diff --git a/profiles/mobile-low.json b/profiles/mobile-low.json index aea2c3222..1e03bffff 100644 --- a/profiles/mobile-low.json +++ b/profiles/mobile-low.json @@ -1,6 +1,6 @@ { "id": "mobile-low", - "displayName": "Mobile Low-end (source validation)", + "displayName": "Mobile Low-end", "description": "Validation-only preset for low-end mobile GPU/CPU source asset budgets. Tight triangle, material, texture, and bone limits for glTF/FBX prep before Unity/Unreal/Godot mobile cooks. Warnings only — not a substitute for on-device profiling.", "rules": { "allowed_formats": ["fbx", "glb", "gltf", "vrm", "obj"], diff --git a/profiles/modern-console.json b/profiles/modern-console.json index 1b5252286..56b844d3d 100644 --- a/profiles/modern-console.json +++ b/profiles/modern-console.json @@ -1,6 +1,6 @@ { "id": "modern-console", - "displayName": "Modern Console (source validation)", + "displayName": "Modern Console", "description": "Validation-only preset for PS5/Xbox/Series-class source assets (glTF/FBX). Conservative AAA triangle, material, texture, bone, and animation budgets for engine import pipelines. Does not export cooked platform binaries — tune per title and ship via Unreal/Unity/Godot/custom cook steps. require_lod rule is planned once LOD scan rules land.", "rules": { "allowed_formats": ["fbx", "glb", "gltf", "vrm", "obj"], diff --git a/profiles/n64.json b/profiles/n64.json index 2ae69c4c7..be67b1540 100644 --- a/profiles/n64.json +++ b/profiles/n64.json @@ -1,6 +1,6 @@ { "id": "n64", - "displayName": "Nintendo 64 (validation)", + "displayName": "Nintendo 64", "description": "Validation-only lint preset for N64-class pipelines. Warns on triangle, bone, submesh, draw-call, and texture budgets typical of N64 homebrew and preservation ports. Does not emit ROM-ready geometry or guarantee microcode compliance — defaults are tunable starting points, not certification.", "rules": { "max_triangle_count": 12000, diff --git a/profiles/nds.json b/profiles/nds.json index 1a613b75a..207707643 100644 --- a/profiles/nds.json +++ b/profiles/nds.json @@ -1,6 +1,6 @@ { "id": "nds", - "displayName": "Nintendo DS (validation)", + "displayName": "Nintendo DS", "description": "Validation-only lint preset for NDS-class demake and homebrew checks. Tight polygon and texture budgets with palette/texture size warnings suited to dual-screen handheld targets. Does not export NSBMD/NSBTX or validate proprietary SDK output — NSBMD/NSBTX feasibility is separate research.", "rules": { "max_triangle_count": 4000, diff --git a/profiles/ps1.json b/profiles/ps1.json index 0b9e91384..9a64515ad 100644 --- a/profiles/ps1.json +++ b/profiles/ps1.json @@ -1,6 +1,6 @@ { "id": "ps1", - "displayName": "PlayStation 1 (validation)", + "displayName": "PlayStation 1", "description": "Validation-only lint preset for PS1-class homebrew and demake workflows. Applies conservative triangle, material, bone, and texture budgets plus power-of-two texture warnings. Does not export TIM/CLUT or guarantee hardware compliance — tune limits per title. TIM import is future work.", "rules": { "max_triangle_count": 5000, diff --git a/profiles/steamdeck.json b/profiles/steamdeck.json index c20e17b80..b22668223 100644 --- a/profiles/steamdeck.json +++ b/profiles/steamdeck.json @@ -1,6 +1,6 @@ { "id": "steamdeck", - "displayName": "Steam Deck (source validation)", + "displayName": "Steam Deck", "description": "Validation-only preset for Steam Deck / handheld-PC class performance heuristics on glTF/FBX source assets. Higher budgets than switch-like but still warns on draw calls, texture size, and animation bloat. Does not produce cooked builds — validation for engine import only.", "rules": { "allowed_formats": ["fbx", "glb", "gltf", "vrm", "obj"], diff --git a/profiles/switch-like.json b/profiles/switch-like.json index c60ebabc7..86b19c1b8 100644 --- a/profiles/switch-like.json +++ b/profiles/switch-like.json @@ -1,6 +1,6 @@ { "id": "switch-like", - "displayName": "Switch-class (source validation)", + "displayName": "Switch-class", "description": "Validation-only preset for handheld/console-class GPU and CPU heuristics aligned with Nintendo Switch–style glTF/FBX source asset prep. Warns on triangle, material, texture, bone, and animation budgets — not certifiable TRC limits. Export via engine cooked pipelines, not proprietary SDK mesh formats.", "rules": { "allowed_formats": ["fbx", "glb", "gltf", "vrm", "obj"], diff --git a/profiles/vr.json b/profiles/vr.json index 5f4e7f03c..6862df804 100644 --- a/profiles/vr.json +++ b/profiles/vr.json @@ -1,6 +1,6 @@ { "id": "vr", - "displayName": "VR (source validation)", + "displayName": "VR", "description": "Validation-only preset for VR source assets where stereo rendering and frame-time budgets demand lean geometry, materials, and textures. glTF/FBX preparation checks only — export via engine VR cook pipelines, not platform SDK mesh formats.", "rules": { "allowed_formats": ["fbx", "glb", "gltf", "vrm", "obj"], diff --git a/profiles/webgl.json b/profiles/webgl.json index 838c64405..f83503ba9 100644 --- a/profiles/webgl.json +++ b/profiles/webgl.json @@ -1,6 +1,6 @@ { "id": "webgl", - "displayName": "WebGL / WebGPU (source validation)", + "displayName": "WebGL / WebGPU", "description": "Validation-only preset for browser 3D pipelines (WebGL/WebGPU). Caps triangles, draw calls, texture dimensions, and animation keyframes on glTF/FBX source assets. Does not emit web-ready bundles — validate source, then export via your web engine toolchain.", "rules": { "allowed_formats": ["fbx", "glb", "gltf", "vrm", "obj"], diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 3253464b9..9b94c41ef 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4262,6 +4262,10 @@ Rectangle { id: profileCombo width: parent.width - 50 model: AssetScanController.profileLabels + currentIndex: { + const idx = AssetScanController.profileIds.indexOf(AssetScanController.selectedProfileId) + return idx >= 0 ? idx : 0 + } onActivated: function(index) { if (index >= 0 && index < AssetScanController.profileIds.length) AssetScanController.selectedProfileId = AssetScanController.profileIds[index] @@ -4269,20 +4273,6 @@ Rectangle { ToolTip.visible: hovered && AssetScanController.profileDescription.length > 0 ToolTip.text: AssetScanController.profileDescription ToolTip.delay: 400 - - Connections { - target: AssetScanController - function onSelectedProfileIdChanged() { - const idx = AssetScanController.profileIds.indexOf(AssetScanController.selectedProfileId) - if (idx >= 0) - profileCombo.currentIndex = idx - } - } - Component.onCompleted: { - const idx = AssetScanController.profileIds.indexOf(AssetScanController.selectedProfileId) - if (idx >= 0) - currentIndex = idx - } } } diff --git a/src/AppSettingsKeys.h b/src/AppSettingsKeys.h index d9dfc52e7..3d0083bf9 100644 --- a/src/AppSettingsKeys.h +++ b/src/AppSettingsKeys.h @@ -105,6 +105,13 @@ inline const QString& validationPlatformProfileId() return k; } +/** @brief Bumped when Validation profile picker defaults or migration rules change. */ +inline const QString& validationPlatformProfilePickerVersion() +{ + static const QString k(QStringLiteral("Validation/platformProfilePickerVersion")); + return k; +} + } // namespace AppSettingsKeys #endif // APP_SETTINGS_KEYS_H diff --git a/src/AssetScanController.cpp b/src/AssetScanController.cpp index 1047502bd..6588f8caa 100644 --- a/src/AssetScanController.cpp +++ b/src/AssetScanController.cpp @@ -11,12 +11,15 @@ #include #include #include +#include #include #include #include namespace { +constexpr int kProfilePickerSettingsVersion = 2; + QString defaultValidationProfileId() { return QStringLiteral("modern-console"); @@ -28,6 +31,30 @@ bool isUiExcludedProfileId(const QString& id) return id.startsWith(QStringLiteral("example-")); } +QString sanitizeProfileLabel(const QString& displayName, const QString& id) +{ + QString label = displayName.trimmed(); + if (label.isEmpty()) + label = id; + + static const QRegularExpression suffixRe( + QStringLiteral(R"(\s*\((?:source\s+)?validation\)\s*$)"), + QRegularExpression::CaseInsensitiveOption); + label.remove(suffixRe); + return label.trimmed(); +} + +void sortUiProfiles(QStringList& ids, QStringList& labels) +{ + const int defaultIdx = ids.indexOf(defaultValidationProfileId()); + if (defaultIdx > 0) { + const QString defaultId = ids.takeAt(defaultIdx); + const QString defaultLabel = labels.takeAt(defaultIdx); + ids.prepend(defaultId); + labels.prepend(defaultLabel); + } +} + } // namespace AssetScanController* AssetScanController::m_pSingleton = nullptr; @@ -58,7 +85,17 @@ AssetScanController::AssetScanController(QObject* parent) reloadProfiles(); QSettings settings; - const QString saved = settings.value(AppSettingsKeys::validationPlatformProfileId()).toString(); + const int pickerVersion = + settings.value(AppSettingsKeys::validationPlatformProfilePickerVersion(), 0).toInt(); + QString saved = settings.value(AppSettingsKeys::validationPlatformProfileId()).toString(); + + // One-time migration: older builds could persist an unintended first-launch profile. + if (pickerVersion < kProfilePickerSettingsVersion) { + saved.clear(); + settings.setValue(AppSettingsKeys::validationPlatformProfilePickerVersion(), + kProfilePickerSettingsVersion); + } + if (!saved.isEmpty() && !isUiExcludedProfileId(saved) && m_profileIds.contains(saved)) setSelectedProfileId(saved); else if (m_profileIds.contains(defaultValidationProfileId())) @@ -80,10 +117,10 @@ void AssetScanController::reloadProfiles() if (!loaded.ok) continue; m_profileIds.append(loaded.profile.id); - const QString label = loaded.profile.displayName.trimmed(); - m_profileLabels.append(label.isEmpty() ? loaded.profile.id : label); + m_profileLabels.append(sanitizeProfileLabel(loaded.profile.displayName, loaded.profile.id)); } + sortUiProfiles(m_profileIds, m_profileLabels); emit profilesChanged(); } diff --git a/src/AssetScanController_test.cpp b/src/AssetScanController_test.cpp index 0a1f3f44b..bf38e0ca5 100644 --- a/src/AssetScanController_test.cpp +++ b/src/AssetScanController_test.cpp @@ -80,6 +80,22 @@ TEST(AssetScanControllerTest, UiProfileList_ExcludesExampleProfilesAndDefaultsMo EXPECT_FALSE(ids.contains(QStringLiteral("example-texture-inspect"))); EXPECT_TRUE(ids.contains(QStringLiteral("modern-console"))); EXPECT_EQ(controller->selectedProfileId(), QStringLiteral("modern-console")); + EXPECT_EQ(controller->profileLabels().first(), QStringLiteral("Modern Console")); + EXPECT_FALSE(controller->profileLabels().first().contains(QStringLiteral("validation"))); + + AssetScanController::kill(); +} + +TEST(AssetScanControllerTest, PickerVersionMigration_ResetsStaleSavedProfileToModernConsole) +{ + AssetScanController::kill(); + QSettings settings; + settings.setValue(AppSettingsKeys::validationPlatformProfileId(), QStringLiteral("ps1")); + settings.setValue(AppSettingsKeys::validationPlatformProfilePickerVersion(), 1); + + auto* controller = AssetScanController::instance(); + EXPECT_EQ(controller->selectedProfileId(), QStringLiteral("modern-console")); + EXPECT_EQ(settings.value(AppSettingsKeys::validationPlatformProfilePickerVersion()).toInt(), 2); AssetScanController::kill(); } From a58fc85c86025e246ed2ecb9e743ba4f15b8e685 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 8 Jun 2026 22:26:05 -0400 Subject: [PATCH 6/7] Collapse Asset Folder Scan section by default in Validation mode. Co-authored-by: Cursor --- qml/PropertiesPanel.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 9b94c41ef..8d29fb8bb 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -453,7 +453,7 @@ Rectangle { sectionVisible: root.modeToolSectionVisible( EditorModeController.ValidationMode, true) - expanded: true + expanded: false Component.onCompleted: content = assetScanComponent } From 6e212a8539abf8b9e1e25ed888d645a889c0c370 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 8 Jun 2026 22:32:46 -0400 Subject: [PATCH 7/7] Expand AssetScanController tests and address PR review feedback. Cover JSON parsing, profile picker state, scan validation, and shared scan-config builder; add subprocess timeout, sanitize Sentry breadcrumbs, and QML keyboard/feedback fixes. Co-authored-by: Cursor --- qml/PropertiesPanel.qml | 43 +++++++--- src/AssetScanController.cpp | 25 +++++- src/AssetScanController.h | 6 ++ src/AssetScanController_test.cpp | 139 +++++++++++++++++++++++++++++-- 4 files changed, 191 insertions(+), 22 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 8d29fb8bb..6879ad131 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4315,10 +4315,14 @@ Rectangle { width: 60 height: 24 radius: 3 - color: folderBrowseMouse.containsMouse + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: "Browse asset folder" + color: folderBrowseMouse.containsMouse || activeFocus ? Qt.lighter(PropertiesPanelController.inputColor, 1.3) : PropertiesPanelController.inputColor - border.color: PropertiesPanelController.borderColor + border.color: activeFocus ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor border.width: 1 Text { anchors.centerIn: parent @@ -4332,15 +4336,22 @@ Rectangle { hoverEnabled: true onClicked: AssetBrowserController.browseForDirectory() } + Keys.onSpacePressed: folderBrowseMouse.clicked(null) + Keys.onReturnPressed: folderBrowseMouse.clicked(null) + Keys.onEnterPressed: folderBrowseMouse.clicked(null) } } Rectangle { width: parent.width - 16; height: 24; radius: 3 - color: browserMouse.containsMouse + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: "Open Asset Browser panel" + color: browserMouse.containsMouse || activeFocus ? Qt.lighter(PropertiesPanelController.controlBgColor, 1.08) : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor + border.color: activeFocus ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor border.width: 1 Text { anchors.centerIn: parent @@ -4354,13 +4365,20 @@ Rectangle { hoverEnabled: true onClicked: root.revealBottomTool("assetBrowser") } + Keys.onSpacePressed: browserMouse.clicked(null) + Keys.onReturnPressed: browserMouse.clicked(null) + Keys.onEnterPressed: browserMouse.clicked(null) } Rectangle { width: parent.width - 16; height: 28; radius: 3 + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: "Scan asset folder" color: scanMouse.pressed ? Qt.darker(PropertiesPanelController.highlightColor, 1.2) - : scanMouse.containsMouse ? Qt.lighter(PropertiesPanelController.highlightColor, 1.1) - : PropertiesPanelController.highlightColor + : (scanMouse.containsMouse || activeFocus) + ? Qt.lighter(PropertiesPanelController.highlightColor, 1.1) + : PropertiesPanelController.highlightColor opacity: AssetScanController.scanning ? 0.55 : 1.0 Text { anchors.centerIn: parent @@ -4375,6 +4393,9 @@ Rectangle { enabled: !AssetScanController.scanning onClicked: AssetScanController.scanFolder(AssetBrowserController.rootPath) } + Keys.onSpacePressed: if (!AssetScanController.scanning) scanMouse.clicked(null) + Keys.onReturnPressed: if (!AssetScanController.scanning) scanMouse.clicked(null) + Keys.onEnterPressed: if (!AssetScanController.scanning) scanMouse.clicked(null) } Text { @@ -4401,7 +4422,9 @@ Rectangle { Column { width: parent.width - 16 spacing: 3 - visible: AssetScanController.hasResults && AssetScanController.findings.length > 0 + visible: AssetScanController.hasResults + && !AssetScanController.scanning + && AssetScanController.findings.length > 0 Repeater { model: AssetScanController.findings @@ -4443,10 +4466,8 @@ Rectangle { assetScanFeedback.text = msg } function onScanFinished(ok, message) { - if (ok) { - assetScanFeedback.color = "#60c060" - assetScanFeedback.text = message - } + assetScanFeedback.color = ok ? "#60c060" : "#c06060" + assetScanFeedback.text = message } } } diff --git a/src/AssetScanController.cpp b/src/AssetScanController.cpp index 6588f8caa..c7dcff4b5 100644 --- a/src/AssetScanController.cpp +++ b/src/AssetScanController.cpp @@ -287,7 +287,14 @@ static ScanSubprocessOutcome runScanSubprocessSync(const QString& rootPath, cons outcome.message = QStringLiteral("Could not start scan process: %1").arg(process.errorString()); return outcome; } - process.waitForFinished(-1); + constexpr int kScanTimeoutMs = 10 * 60 * 1000; + if (!process.waitForFinished(kScanTimeoutMs)) { + process.kill(); + process.waitForFinished(5000); + outcome.message = QStringLiteral("Scan process timed out after %1s") + .arg(kScanTimeoutMs / 1000); + return outcome; + } outcome.jsonBytes = process.readAllStandardOutput(); const QByteArray stderrBytes = process.readAllStandardError(); @@ -336,8 +343,10 @@ void AssetScanController::scanFolder(const QString& rootPath) return; } + const QString rootName = QFileInfo(absRoot).fileName(); SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), - QStringLiteral("asset scan start profile=%1 root=%2").arg(m_selectedProfileId, absRoot)); + QStringLiteral("asset scan start profile=%1 rootName=%2") + .arg(m_selectedProfileId, rootName.isEmpty() ? QStringLiteral("") : rootName)); setScanning(true); m_hasResults = false; @@ -362,3 +371,15 @@ void AssetScanController::scanFolder(const QString& rootPath) worker->start(); } + +#ifdef QTMESH_UNIT_TESTS +void AssetScanController::ingestScanReportJsonForTest(const QByteArray& jsonBytes) +{ + applyScanReport(jsonBytes); +} + +QString AssetScanController::sanitizeProfileLabelForTest(const QString& displayName, const QString& id) +{ + return sanitizeProfileLabel(displayName, id); +} +#endif diff --git a/src/AssetScanController.h b/src/AssetScanController.h index 3743ea212..9932fe078 100644 --- a/src/AssetScanController.h +++ b/src/AssetScanController.h @@ -54,6 +54,12 @@ class AssetScanController : public QObject int* scanned, int* passed, int* warnings, int* errors, QVariantList* findings, QString* errorOut); +#ifdef QTMESH_UNIT_TESTS + /// Test seam for applyScanReport (summary/findings properties). + void ingestScanReportJsonForTest(const QByteArray& jsonBytes); + static QString sanitizeProfileLabelForTest(const QString& displayName, const QString& id); +#endif + private: explicit AssetScanController(QObject* parent = nullptr); ~AssetScanController() override = default; diff --git a/src/AssetScanController_test.cpp b/src/AssetScanController_test.cpp index bf38e0ca5..8d1441ee7 100644 --- a/src/AssetScanController_test.cpp +++ b/src/AssetScanController_test.cpp @@ -6,11 +6,26 @@ #include "ScanConfig.h" #include +#include #include #include #include +#include #include +namespace { + +void resetAssetScanControllerState() +{ + AssetScanController::kill(); + QSettings settings; + settings.remove(AppSettingsKeys::validationPlatformProfileId()); + settings.remove(AppSettingsKeys::validationPlatformProfilePickerVersion()); + settings.sync(); +} + +} // namespace + TEST(AssetScanControllerTest, ParseScanJsonReport_ExtractsSummaryAndFindings) { const QByteArray json = R"({ @@ -49,28 +64,71 @@ TEST(AssetScanControllerTest, ParseScanJsonReport_ExtractsSummaryAndFindings) EXPECT_EQ(findings.at(1).toMap().value(QStringLiteral("severity")).toString(), QStringLiteral("warning")); } +TEST(AssetScanControllerTest, ParseScanJsonReport_InvalidJson_ReturnsFalse) +{ + QString error; + EXPECT_FALSE(AssetScanController::parseScanJsonReport(QByteArray("{not json"), nullptr, nullptr, + nullptr, nullptr, nullptr, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(AssetScanControllerTest, SanitizeProfileLabel_StripsValidationSuffixes) +{ + EXPECT_EQ(AssetScanController::sanitizeProfileLabelForTest(QStringLiteral("PlayStation 1 (validation)"), + QStringLiteral("ps1")), + QStringLiteral("PlayStation 1")); + EXPECT_EQ(AssetScanController::sanitizeProfileLabelForTest( + QStringLiteral("Modern Console (source validation)"), QStringLiteral("modern-console")), + QStringLiteral("Modern Console")); + EXPECT_EQ(AssetScanController::sanitizeProfileLabelForTest(QString(), QStringLiteral("vr")), + QStringLiteral("vr")); +} + TEST(AssetScanControllerTest, SelectedProfileId_PersistsInQSettings) { - AssetScanController::kill(); - QSettings settings; - settings.remove(AppSettingsKeys::validationPlatformProfileId()); + resetAssetScanControllerState(); auto* controller = AssetScanController::instance(); ASSERT_FALSE(controller->profileIds().isEmpty()); - const QString id = controller->profileIds().first(); + const QString id = QStringLiteral("mobile-low"); + ASSERT_TRUE(controller->profileIds().contains(id)); controller->setSelectedProfileId(id); EXPECT_EQ(controller->selectedProfileId(), id); - EXPECT_EQ(settings.value(AppSettingsKeys::validationPlatformProfileId()).toString(), id); + + QSettings().sync(); + const QSettings reloaded; + EXPECT_EQ(reloaded.value(AppSettingsKeys::validationPlatformProfileId()).toString(), id); AssetScanController::kill(); } -TEST(AssetScanControllerTest, UiProfileList_ExcludesExampleProfilesAndDefaultsModernConsole) +TEST(AssetScanControllerTest, SetSelectedProfileId_RejectsUnknownProfile) { + resetAssetScanControllerState(); + + auto* controller = AssetScanController::instance(); + const QString before = controller->selectedProfileId(); + controller->setSelectedProfileId(QStringLiteral("not-a-real-profile-id")); + EXPECT_EQ(controller->selectedProfileId(), before); + AssetScanController::kill(); - QSettings settings; - settings.remove(AppSettingsKeys::validationPlatformProfileId()); +} + +TEST(AssetScanControllerTest, ProfileDescription_PopulatedForModernConsole) +{ + resetAssetScanControllerState(); + + auto* controller = AssetScanController::instance(); + controller->setSelectedProfileId(QStringLiteral("modern-console")); + EXPECT_FALSE(controller->profileDescription().isEmpty()); + + AssetScanController::kill(); +} + +TEST(AssetScanControllerTest, UiProfileList_ExcludesExampleProfilesAndDefaultsModernConsole) +{ + resetAssetScanControllerState(); auto* controller = AssetScanController::instance(); const QStringList ids = controller->profileIds(); @@ -80,6 +138,7 @@ TEST(AssetScanControllerTest, UiProfileList_ExcludesExampleProfilesAndDefaultsMo EXPECT_FALSE(ids.contains(QStringLiteral("example-texture-inspect"))); EXPECT_TRUE(ids.contains(QStringLiteral("modern-console"))); EXPECT_EQ(controller->selectedProfileId(), QStringLiteral("modern-console")); + EXPECT_EQ(controller->profileIds().first(), QStringLiteral("modern-console")); EXPECT_EQ(controller->profileLabels().first(), QStringLiteral("Modern Console")); EXPECT_FALSE(controller->profileLabels().first().contains(QStringLiteral("validation"))); @@ -88,10 +147,11 @@ TEST(AssetScanControllerTest, UiProfileList_ExcludesExampleProfilesAndDefaultsMo TEST(AssetScanControllerTest, PickerVersionMigration_ResetsStaleSavedProfileToModernConsole) { - AssetScanController::kill(); + resetAssetScanControllerState(); QSettings settings; settings.setValue(AppSettingsKeys::validationPlatformProfileId(), QStringLiteral("ps1")); settings.setValue(AppSettingsKeys::validationPlatformProfilePickerVersion(), 1); + settings.sync(); auto* controller = AssetScanController::instance(); EXPECT_EQ(controller->selectedProfileId(), QStringLiteral("modern-console")); @@ -100,6 +160,51 @@ TEST(AssetScanControllerTest, PickerVersionMigration_ResetsStaleSavedProfileToMo AssetScanController::kill(); } +TEST(AssetScanControllerTest, IngestScanReportJsonForTest_UpdatesSummaryProperties) +{ + resetAssetScanControllerState(); + + auto* controller = AssetScanController::instance(); + const QByteArray json = R"({ + "summary": { "scanned": 3, "passed": 2, "warnings": 1, "errors": 0 }, + "assets": [] + })"; + + controller->ingestScanReportJsonForTest(json); + EXPECT_TRUE(controller->hasResults()); + EXPECT_EQ(controller->summaryScanned(), 3); + EXPECT_EQ(controller->summaryPassed(), 2); + EXPECT_EQ(controller->summaryWarnings(), 1); + EXPECT_EQ(controller->summaryErrors(), 0); + EXPECT_TRUE(controller->findings().isEmpty()); + + AssetScanController::kill(); +} + +TEST(AssetScanControllerTest, ScanFolder_RejectsInvalidDirectory) +{ + resetAssetScanControllerState(); + + auto* controller = AssetScanController::instance(); + QSignalSpy finishedSpy(controller, &AssetScanController::scanFinished); + QSignalSpy errorSpy(controller, &AssetScanController::error); + + controller->scanFolder(QStringLiteral("/path/that/does/not/exist/for/qtmesh-scan")); + ASSERT_EQ(finishedSpy.count(), 1); + EXPECT_FALSE(finishedSpy.at(0).at(0).toBool()); + EXPECT_GE(errorSpy.count(), 1); + EXPECT_FALSE(controller->scanning()); + + AssetScanController::kill(); +} + +TEST(AssetScanControllerTest, ResolveCliBinary_ReturnsExistingPath) +{ + const QString binary = AssetScanController::resolveCliBinaryForTest(); + EXPECT_FALSE(binary.isEmpty()); + EXPECT_TRUE(QFileInfo::exists(binary)); +} + TEST(PlatformProfileScanSetupTest, BuildScanConfigWithPlatformProfile_MatchesLoader) { const QStringList ids = PlatformProfileLoader::listBuiltinIds(); @@ -118,3 +223,19 @@ TEST(PlatformProfileScanSetupTest, BuildScanConfigWithPlatformProfile_MatchesLoa EXPECT_EQ(setup.config.maxTriangleCount, expected.maxTriangleCount); EXPECT_EQ(setup.config.allowedFormats, expected.allowedFormats); } + +TEST(PlatformProfileScanSetupTest, BuildScanConfigWithPlatformProfile_EmptyId_ReturnsDefaults) +{ + const PlatformProfileScanSetup setup = buildScanConfigWithPlatformProfile(QString()); + EXPECT_TRUE(setup.ok); + EXPECT_TRUE(setup.profileId.isEmpty()); + EXPECT_EQ(setup.config.maxVertexCount, ScanConfig::defaults().maxVertexCount); +} + +TEST(PlatformProfileScanSetupTest, BuildScanConfigWithPlatformProfile_InvalidId_Fails) +{ + const PlatformProfileScanSetup setup = + buildScanConfigWithPlatformProfile(QStringLiteral("definitely-not-a-profile")); + EXPECT_FALSE(setup.ok); + EXPECT_FALSE(setup.error.isEmpty()); +}