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/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..6879ad131 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: false + + 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,251 @@ 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: "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 { + 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 + 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] + } + ToolTip.visible: hovered && AssetScanController.profileDescription.length > 0 + ToolTip.text: AssetScanController.profileDescription + ToolTip.delay: 400 + } + } + + 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 + } + + Row { + spacing: 6 + width: parent.width - 16 + + 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 + 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: activeFocus ? PropertiesPanelController.highlightColor + : 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() + } + Keys.onSpacePressed: folderBrowseMouse.clicked(null) + Keys.onReturnPressed: folderBrowseMouse.clicked(null) + Keys.onEnterPressed: folderBrowseMouse.clicked(null) + } + } + + Rectangle { + width: parent.width - 16; height: 24; radius: 3 + 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: activeFocus ? PropertiesPanelController.highlightColor + : 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") + } + 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 || activeFocus) + ? 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) + } + 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 { + 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.scanning + && 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) { + assetScanFeedback.color = ok ? "#60c060" : "#c06060" + assetScanFeedback.text = message + } + } + } + } + } + // ---- Mesh Validation Content ---- Component { id: validationComponent diff --git a/src/AppSettingsKeys.h b/src/AppSettingsKeys.h index 544c8c328..3d0083bf9 100644 --- a/src/AppSettingsKeys.h +++ b/src/AppSettingsKeys.h @@ -98,6 +98,20 @@ 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; +} + +/** @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 new file mode 100644 index 000000000..c7dcff4b5 --- /dev/null +++ b/src/AssetScanController.cpp @@ -0,0 +1,385 @@ +#include "AssetScanController.h" + +#include "AppSettingsKeys.h" +#include "PlatformProfile.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kProfilePickerSettingsVersion = 2; + +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-")); +} + +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; + +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 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())) + setSelectedProfileId(defaultValidationProfileId()); + 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) { + if (isUiExcludedProfileId(id)) + continue; + const PlatformProfileLoadResult loaded = PlatformProfileLoader::load(id); + if (!loaded.ok) + continue; + m_profileIds.append(loaded.profile.id); + m_profileLabels.append(sanitizeProfileLabel(loaded.profile.displayName, loaded.profile.id)); + } + + sortUiProfiles(m_profileIds, m_profileLabels); + 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; + } + 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(); + 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; + } + + const QString rootName = QFileInfo(absRoot).fileName(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("asset scan start profile=%1 rootName=%2") + .arg(m_selectedProfileId, rootName.isEmpty() ? QStringLiteral("") : rootName)); + + 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(); +} + +#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 new file mode 100644 index 000000000..9932fe078 --- /dev/null +++ b/src/AssetScanController.h @@ -0,0 +1,95 @@ +#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); + +#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; + + 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..8d1441ee7 --- /dev/null +++ b/src/AssetScanController_test.cpp @@ -0,0 +1,241 @@ +#include + +#include "AssetScanController.h" +#include "AppSettingsKeys.h" +#include "PlatformProfile.h" +#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"({ + "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, 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) +{ + resetAssetScanControllerState(); + + auto* controller = AssetScanController::instance(); + ASSERT_FALSE(controller->profileIds().isEmpty()); + + const QString id = QStringLiteral("mobile-low"); + ASSERT_TRUE(controller->profileIds().contains(id)); + controller->setSelectedProfileId(id); + EXPECT_EQ(controller->selectedProfileId(), id); + + QSettings().sync(); + const QSettings reloaded; + EXPECT_EQ(reloaded.value(AppSettingsKeys::validationPlatformProfileId()).toString(), id); + + AssetScanController::kill(); +} + +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(); +} + +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(); + 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")); + EXPECT_EQ(controller->profileIds().first(), 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) +{ + 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")); + EXPECT_EQ(settings.value(AppSettingsKeys::validationPlatformProfilePickerVersion()).toInt(), 2); + + 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(); + 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); +} + +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()); +} 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); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dc1c6b3f6..3839840b0 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 @@ -275,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