Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/actions/qtmesh/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ inputs:
description: 'Subcommand: info, fix, convert, anim, validate, lod, pose, scan'
required: true
input-file:
description: 'Input file or directory path (relative to workspace)'
required: true
description: 'Directory or file to scan (relative to workspace). Defaults to .'
required: false
default: '.'
output-file:
description: 'Output file path (for convert/fix/anim rename/merge)'
required: false
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0)
cmake_policy(SET CMP0005 NEW)
cmake_policy(SET CMP0048 NEW) # manages project version

project(QtMeshEditor VERSION 2.26.0 LANGUAGES C CXX)
project(QtMeshEditor VERSION 2.26.1 LANGUAGES C CXX)
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")

set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh scan ./assets

### ☁️ QtMesh Cloud Badges (Recommended)

Register your repository in [QtMesh Cloud](https://qtmesh.ftonon.uk) to publish real scan badges from CI.
Register your repository in [QtMesh Cloud](https://qtmesh.dev) to publish real scan badges from CI.

1. Sign in at [qtmesh.ftonon.uk](https://qtmesh.ftonon.uk) and create a project (choose a slug like `my-game-assets`).
1. Sign in at [qtmesh.dev](https://qtmesh.dev) and create a project (choose a slug like `my-game-assets`).
2. Create a project token in QtMesh Cloud.
3. Add the token as a GitHub secret named `QTMESH_CLOUD_TOKEN`.
4. Upload each `scan` JSON report from CI to `https://api.qtmesh.ftonon.uk/v1/ingest/scan`.
4. Upload each `scan` JSON report from CI to `https://api.qtmesh.dev/v1/ingest/scan`.

Example upload step:

Expand All @@ -99,7 +99,7 @@ Example upload step:
- name: Upload scan to QtMesh Cloud
env:
QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }}
QTMESH_CLOUD_API_URL: https://api.qtmesh.ftonon.uk
QTMESH_CLOUD_API_URL: https://api.qtmesh.dev
run: |
jq --arg branch "${GITHUB_REF_NAME}" \
--arg sha "${GITHUB_SHA}" \
Expand All @@ -117,9 +117,9 @@ Example upload step:
Badge markdown (replace `<project-slug>`):

```md
[![qtmesh status](https://api.qtmesh.ftonon.uk/v1/projects/<project-slug>/badges/qtmesh-status.svg)](https://qtmesh.ftonon.uk)
[![qtmesh errors](https://api.qtmesh.ftonon.uk/v1/projects/<project-slug>/badges/qtmesh-errors.svg)](https://qtmesh.ftonon.uk)
[![qtmesh warnings](https://api.qtmesh.ftonon.uk/v1/projects/<project-slug>/badges/qtmesh-warnings.svg)](https://qtmesh.ftonon.uk)
[![qtmesh status](https://api.qtmesh.dev/v1/projects/<project-slug>/badges/qtmesh-status.svg)](https://qtmesh.dev)
[![qtmesh errors](https://api.qtmesh.dev/v1/projects/<project-slug>/badges/qtmesh-errors.svg)](https://qtmesh.dev)
[![qtmesh warnings](https://api.qtmesh.dev/v1/projects/<project-slug>/badges/qtmesh-warnings.svg)](https://qtmesh.dev)
```

### 🏷️ Self-Hosted Scan Badges (Legacy)
Expand Down
16 changes: 14 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ inputs:
description: 'Subcommand: scan, info, validate, convert, fix, anim, lod, pose'
required: true
input-file:
description: 'Input file or directory path (relative to workspace)'
required: true
description: 'Directory or file to scan (relative to workspace). Defaults to . (workspace root).'
required: false
default: '.'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
output-file:
description: 'Output file path (for convert/fix/anim/pose)'
required: false
Expand Down Expand Up @@ -132,6 +133,17 @@ runs:
}

# Build command args safely via arrays to prevent injection
if [ "$INPUT_COMMAND" = "scan" ]; then
if [ -z "${INPUT_FILE:-}" ]; then
INPUT_FILE="."
fi
else
if [ -z "${INPUT_FILE:-}" ] || [ "${INPUT_FILE}" = "." ]; then
echo "Error: input-file is required for command '$INPUT_COMMAND' (only scan defaults to '.')"
exit 2
fi
fi

cmd=("$INPUT_COMMAND" "/workspace/$INPUT_FILE")
if [ -n "$INPUT_OUTPUT_FILE" ]; then
cmd+=("-o" "/workspace/$INPUT_OUTPUT_FILE")
Expand Down
74 changes: 37 additions & 37 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ void CLIPipeline::printUsage()
" Export a single posed frame as static mesh\n"
" pose <file> --animation <name> --count N -o <pattern>\n"
" Export N evenly spaced frames (use %02d in pattern)\n"
" scan [path] [options] Scan directory for 3D asset issues (CI linting)\n"
" scan [path] [options] Scan directory for 3D asset issues (default path: .)\n"
"\n"
"Scan options:\n"
" --config <file> Config file (default: qtmesh.yml, qtmesh.json)\n"
Expand Down Expand Up @@ -286,10 +286,11 @@ void CLIPipeline::printUsage()
" --no-upload Skip POSTing scan JSON to QtMesh Cloud when a token is set\n"
" --strict-upload Exit 1 if cloud upload fails (default: warn only)\n"
"\n"
" Cloud rules: if no --config and no local qtmesh.yml|yaml|json, QTMESH_TOKEN loads\n"
" remote rules from the API; otherwise built-in defaults apply if the API is unreachable.\n"
" --config or a local file skips fetching remote rules; scan JSON still uploads when a\n"
" token is set (unless --no-upload). Override API base with QTMESH_API_BASE.\n"
" Cloud rules: if no --config and QTMESH_TOKEN or --token is set, remote rules are\n"
" fetched first (local qtmesh.yml is ignored). If the API fails, built-in defaults apply.\n"
" Without a token, qtmesh.yml|yaml|json in the cwd is used if present.\n"
" --config always wins. Scan JSON uploads when a token is set (unless --no-upload).\n"
" Override API base with QTMESH_API_BASE.\n"
"\n"
"Fix flags:\n"
" --remove-degenerates Remove degenerate triangles\n"
Expand Down Expand Up @@ -2024,8 +2025,8 @@ int CLIPipeline::cmdScan(int argc, char* argv[])

// Load config (precedence):
// 1) --config path (never fetch remote rules)
// 2) Else local qtmesh.yml | yaml | json in cwd (never fetch remote rules)
// 3) Else if ingest token set → GET /v1/ingest/rules, or defaults if API fails
// 2) Else if ingest token set → GET /v1/ingest/rules (skips local qtmesh.yml|yaml|json)
// 3) Else local qtmesh.yml | yaml | json in cwd
// 4) Else built-in defaults
ScanConfig config;
if (!configPath.isEmpty()) {
Expand All @@ -2040,37 +2041,36 @@ int CLIPipeline::cmdScan(int argc, char* argv[])
<< Qt::endl;
}
} else {
QString localAutoPath;
if (QFileInfo::exists(QStringLiteral("qtmesh.yml")))
localAutoPath = QStringLiteral("qtmesh.yml");
else if (QFileInfo::exists(QStringLiteral("qtmesh.yaml")))
localAutoPath = QStringLiteral("qtmesh.yaml");
else if (QFileInfo::exists(QStringLiteral("qtmesh.json")))
localAutoPath = QStringLiteral("qtmesh.json");

if (!localAutoPath.isEmpty()) {
config = ScanConfig::loadFromFile(localAutoPath);
err() << "Note: Using local " << localAutoPath
<< " — QtMesh Cloud remote rules are not used for validation." << Qt::endl;
} else {
const QString ingestForRules = resolveIngestToken(tokenArg);
if (!ingestForRules.isEmpty()) {
const QString ingestForRules = resolveIngestToken(tokenArg);
if (!ingestForRules.isEmpty()) {
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: requested"));
const auto rules = QtMeshCloudClient::fetchRules(ingestForRules);
if (rules.ok) {
config = ScanConfig::fromJson(rules.config);
err() << "Note: Using QtMesh Cloud rules (source: " << rules.source << ")." << Qt::endl;
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: requested"));
const auto rules = QtMeshCloudClient::fetchRules(ingestForRules);
if (rules.ok) {
config = ScanConfig::fromJson(rules.config);
err() << "Note: Using QtMesh Cloud rules (source: " << rules.source << ")." << Qt::endl;
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: ok source=%1").arg(rules.source));
} else {
err() << "Warning: Could not load QtMesh Cloud rules (" << rules.errorString
<< "). Using built-in defaults." << Qt::endl;
config = ScanConfig::defaults();
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: failed %1").arg(rules.errorString),
QStringLiteral("warning"));
}
QStringLiteral("QtMesh Cloud fetchRules: ok source=%1").arg(rules.source));
} else {
err() << "Warning: Could not load QtMesh Cloud rules (" << rules.errorString
<< "). Using built-in defaults." << Qt::endl;
config = ScanConfig::defaults();
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: failed %1").arg(rules.errorString),
QStringLiteral("warning"));
}
} else {
QString localAutoPath;
if (QFileInfo::exists(QStringLiteral("qtmesh.yml")))
localAutoPath = QStringLiteral("qtmesh.yml");
else if (QFileInfo::exists(QStringLiteral("qtmesh.yaml")))
localAutoPath = QStringLiteral("qtmesh.yaml");
else if (QFileInfo::exists(QStringLiteral("qtmesh.json")))
localAutoPath = QStringLiteral("qtmesh.json");

if (!localAutoPath.isEmpty()) {
config = ScanConfig::loadFromFile(localAutoPath);
err() << "Note: Using local " << localAutoPath << "." << Qt::endl;
} else {
config = ScanConfig::defaults();
}
Expand Down
34 changes: 32 additions & 2 deletions src/CLIPipeline_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2337,7 +2337,7 @@ TEST(CLIPipelineCmdScanCloud, UploadFailureDoesNotChangeExitCodeWithoutStrict)
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 0);
}

TEST(CLIPipelineCmdScanCloud, LocalYmlOverridesRemoteTokenForRules)
TEST(CLIPipelineCmdScanCloud, LocalQtmeshYmlAppliesWhenNoToken)
{
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
Expand All @@ -2357,10 +2357,40 @@ TEST(CLIPipelineCmdScanCloud, LocalYmlOverridesRemoteTokenForRules)
yml.close();

ScopedCurrentDir scoped(tmpDir.path());
ScopedEnvVar clearTok("QTMESH_TOKEN", "");
ScopedEnvVar clearCloud("QTMESH_CLOUD_TOKEN", "");
ScopedEnvVar api("QTMESH_API_BASE", "http://127.0.0.1:1");
ScopedEnvVar tok("QTMESH_TOKEN", "test-token");

QByteArray rootBa = rootPath.toUtf8();
TestArgv args({"qtmesh", "scan", rootBa.constData()});
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 1);
}

TEST(CLIPipelineCmdScanCloud, TokenSkipsLocalQtmeshYmlUsesDefaultsWhenApiFails)
{
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
const QString rootPath = QDir(tmpDir.path()).filePath("assets");
ASSERT_TRUE(QDir().mkpath(rootPath));
ASSERT_FALSE(writeMinimalObj(rootPath, "scan_mesh.obj").isEmpty());

const QString ymlPath = QDir(tmpDir.path()).filePath("qtmesh.yml");
QFile yml(ymlPath);
ASSERT_TRUE(yml.open(QIODevice::WriteOnly | QIODevice::Text));
yml.write(
"scan:\n"
" include:\n"
" - \"**/*.obj\"\n"
"rules:\n"
" max_vertex_count: 2\n");
yml.close();

ScopedCurrentDir scoped(tmpDir.path());
ScopedEnvVar api("QTMESH_API_BASE", "http://127.0.0.1:1");
ScopedEnvVar tok("QTMESH_TOKEN", "test-token");

QByteArray rootBa = rootPath.toUtf8();
TestArgv args({"qtmesh", "scan", rootBa.constData()});
// Cloud fetch fails → defaults (no max_vertex_count) → scan passes.
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 0);
}
50 changes: 48 additions & 2 deletions src/ScanConfig.cpp
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
#include "ScanConfig.h"
#include "ScanEngine.h"

#include <assimp/Importer.hpp>
#include <assimp/importerdesc.h>

#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonDocument>
#include <QRegularExpression>
#include <QSet>
#include <QTextStream>

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -280,6 +284,47 @@
// ScanConfig loading
// ---------------------------------------------------------------------------

ScanConfig::ScanConfig()
: includePatterns(ScanConfig::defaultIncludePatternsForAssimpImports())

Check warning on line 288 in src/ScanConfig.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use the constructor's initializer list for data member "includePatterns". Use the in-class initializer instead.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2RXki7sht6J4U0Ax9T&open=AZ2RXki7sht6J4U0Ax9T&pullRequest=289
{
}

QStringList ScanConfig::defaultIncludePatternsForAssimpImports()
{
static const QStringList cached = []() {

Check warning on line 294 in src/ScanConfig.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This lambda has 29 lines, which is greater than the 20 lines authorized. Split it into several lambdas or functions, or make it a named function.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2RXki7sht6J4U0Ax9U&open=AZ2RXki7sht6J4U0Ax9U&pullRequest=289
QSet<QString> extSet;
Assimp::Importer importer;
for (unsigned i = 0; i < importer.GetImporterCount(); ++i) {
const aiImporterDesc* desc = importer.GetImporterInfo(i);
if (!desc || !desc->mFileExtensions)
continue;
const QString extList = QString::fromLatin1(desc->mFileExtensions);
static const QRegularExpression sep(QStringLiteral("[;\\s,]+"));
const QStringList parts = extList.split(sep, Qt::SkipEmptyParts);
for (const QString& raw : parts) {
QString ext = raw.trimmed().toLower();
if (ext.startsWith(QLatin1Char('.')))
ext.remove(0, 1);
if (ext.isEmpty())
continue;
extSet.insert(ext);
}
}
// Ogre mesh formats used by the editor (may or may not appear as separate Assimp importers)
extSet.insert(QStringLiteral("mesh"));
extSet.insert(QStringLiteral("mesh.xml"));

QStringList globs;
globs.reserve(extSet.size());
for (const QString& ext : extSet) {
globs.append(QStringLiteral("**/*.") + ext);
}
globs.sort(Qt::CaseInsensitive);
return globs;
}();
return cached;
}

ScanConfig ScanConfig::defaults()
{
return ScanConfig();
Expand Down Expand Up @@ -319,8 +364,9 @@
if (!scan.isEmpty()) {
if (scan.contains("roots"))
config.roots = scan.value("roots").toStringList();
if (scan.contains("include"))
if (scan.contains("include")) {
config.includePatterns = scan.value("include").toStringList();
}
if (scan.contains("exclude"))
config.excludePatterns = scan.value("exclude").toStringList();
}
Expand Down
11 changes: 7 additions & 4 deletions src/ScanConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@

// scan section
QStringList roots;
QStringList includePatterns = {
"**/*.fbx", "**/*.glb", "**/*.glb2", "**/*.gltf", "**/*.gltf2",
"**/*.obj", "**/*.dae", "**/*.stl", "**/*.ply", "**/*.3ds", "**/*.mesh"
};
/// Glob patterns; default ctor fills with all Assimp import extensions (plus Ogre .mesh / .mesh.xml).
QStringList includePatterns;
QStringList excludePatterns = {
"**/node_modules/**", "**/.git/**", "**/build/**", "**/Build/**"
};
Expand Down Expand Up @@ -69,6 +67,11 @@
QString sarifOutput;
QString failOn = "error"; // info, warning, error, never

ScanConfig();

/// `**/*.<ext>` for every file extension registered by Assimp importers, plus `mesh` / `mesh.xml`.

Check warning on line 72 in src/ScanConfig.h

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the misleading "/*" characters.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2RXkfMsht6J4U0Ax9S&open=AZ2RXkfMsht6J4U0Ax9S&pullRequest=289
static QStringList defaultIncludePatternsForAssimpImports();

static ScanConfig defaults();
static ScanConfig loadFromFile(const QString& path);
static ScanConfig fromVariantMap(const QVariantMap& map);
Expand Down
17 changes: 17 additions & 0 deletions src/ScanEngine_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,23 @@ TEST(ScanConfigTest, LoadFromVariantMap)
EXPECT_EQ(config.failOn, "warning");
}

TEST(ScanConfigTest, DefaultConstructorIncludesAssimpGlobPatterns)
{
const ScanConfig c;
EXPECT_GT(c.includePatterns.size(), 8);
bool hasMeshGlob = false;
bool hasFbxGlob = false;
for (const QString& p : c.includePatterns) {
if (p.endsWith(QStringLiteral("/mesh"), Qt::CaseInsensitive)
|| p.endsWith(QStringLiteral(".mesh"), Qt::CaseInsensitive))
hasMeshGlob = true;
if (p.contains(QStringLiteral("fbx"), Qt::CaseInsensitive))
hasFbxGlob = true;
}
EXPECT_TRUE(hasFbxGlob);
EXPECT_TRUE(hasMeshGlob);
}

// ---------------------------------------------------------------------------
// Glob matching tests
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading