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
93 changes: 91 additions & 2 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,9 @@
" 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"
" pose <library.poselib> --library list [--json]\n"
" List pose names in a `.poselib` sidecar JSON file.\n"
" No mesh load needed; reads the file directly.\n"
" scan [path] [options] Scan directory for 3D asset issues (default path: .)\n"
" material <file> --preset <name> [-o <output>]\n"
" Apply a built-in material preset to every sub-entity\n"
Expand Down Expand Up @@ -2399,9 +2402,20 @@

int CLIPipeline::cmdPose(int argc, char* argv[])
{
// Parse: pose <file> --animation <name> --time <t> -o <output>
// pose <file> --animation <name> --count N -o <pattern>
// Two modes:
// pose <file> --animation <name> --time <t> -o <output>
// pose <file> --animation <name> --count N -o <pattern>
// (skeleton-anim frame export — pre-existing path)
//
// pose <library.poselib> --library list [--json]
// (list pose names in a `.poselib` sidecar JSON, no mesh
// load needed — added with D-Project. Future modes will
// extend this with --library apply <name> -o out.fbx
// once the round-trip exporter lands.)
QString filePath, outputPath, animName;
QString libraryOp; // "list" only for now
bool libraryMode = false;
bool jsonOutput = false;
float time = -1.0f;
int count = 0;

Expand All @@ -2420,6 +2434,12 @@
count = QString(argv[++i]).toInt();
continue;
}
if (arg == "--library" && i + 1 < argc) {
libraryMode = true;
libraryOp = QString(argv[++i]);
continue;
}
if (arg == "--json") { jsonOutput = true; continue; }
if ((arg == "-o" || arg == "--output") && i + 1 < argc) {
outputPath = QString(argv[++i]);
continue;
Expand All @@ -2430,6 +2450,75 @@
}
}

if (libraryMode) {
// Library mode: filePath is the .poselib sidecar, not a
// mesh. The PoseLibrary singleton needs an entity to key
// poses against; we use a temporary "CLI pose-list anchor"
// entity for this — no skeleton, just an anchor.
if (libraryOp != QStringLiteral("list")) {
err() << "Error: --library currently supports only 'list'." << Qt::endl;
err() << " --library apply needs the round-trip exporter (follow-up)." << Qt::endl;
return 2;
}
if (filePath.isEmpty()) {
err() << "Error: No input file specified." << Qt::endl;
err() << "Usage: qtmesh pose <library.poselib> --library list [--json]" << Qt::endl;
return 2;
}
QFileInfo libFi(filePath);
if (!libFi.exists()) {
err() << "Error: File not found: " << filePath << Qt::endl;
return 1;
}
// Read JSON directly — we don't need an Ogre scene at all.
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly)) {
err() << "Error: Cannot open " << filePath << Qt::endl;
return 1;
}
const QByteArray bytes = f.readAll();
f.close();
QJsonParseError pe{};
const QJsonDocument doc = QJsonDocument::fromJson(bytes, &pe);
if (pe.error != QJsonParseError::NoError || !doc.isObject()) {
err() << "Error: malformed JSON in " << filePath << Qt::endl;
return 1;
}
const QJsonObject root = doc.object();
if (root.value("schema").toString() != QStringLiteral("qtmesheditor.poselib.v1")) {
err() << "Error: schema mismatch in " << filePath << Qt::endl;
return 1;
}
const QJsonArray poses = root.value("poses").toArray();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid pose library payload before listing

In --library list mode, the parser only checks the schema string and then does root.value("poses").toArray(), which silently yields an empty array when poses is missing or not an array. That makes malformed .poselib files report success as "No poses in library" instead of failing, which can hide file corruption and produce incorrect automation results when callers rely on a non-zero exit code for invalid libraries.

Useful? React with 👍 / 👎.

QStringList names;
for (const QJsonValue& v : poses) {
const QString n = v.toObject().value("name").toString();
if (!n.isEmpty()) names << n;
}

SentryReporter::addBreadcrumb("cli.pose",
QString("Library list .%1 (%2 poses)").arg(libFi.suffix()).arg(names.size()));

if (jsonOutput) {
QJsonArray arr;
for (const QString& n : names) arr.append(n);
QJsonObject out;
out["file"] = filePath;
out["count"] = static_cast<int>(names.size());
out["poses"] = arr;
cliWrite(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented)));
} else {
if (names.isEmpty()) {
cliWrite(QStringLiteral("No poses in library.\n"));
} else {
cliWrite(QStringLiteral("Poses (%1):\n").arg(names.size()));
for (const QString& n : names)

Check failure on line 2515 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46G5O8GtJnIfb6O8vV&open=AZ46G5O8GtJnIfb6O8vV&pullRequest=604
cliWrite(QStringLiteral(" %1\n").arg(n));
}
}
return 0;
}

if (filePath.isEmpty()) {
err() << "Error: No input file specified." << Qt::endl;
err() << "Usage: qtmesh pose <file> --animation <name> --time <t> -o <output>" << Qt::endl;
Expand Down
82 changes: 81 additions & 1 deletion src/MCPServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,9 @@
{QStringLiteral("save_pose"), &MCPServer::toolSavePose},
{QStringLiteral("apply_pose"), &MCPServer::toolApplyPose},
{QStringLiteral("delete_pose"), &MCPServer::toolDeletePose},
{QStringLiteral("mirror_pose"), &MCPServer::toolMirrorPose}
{QStringLiteral("mirror_pose"), &MCPServer::toolMirrorPose},
{QStringLiteral("save_pose_library"), &MCPServer::toolSavePoseLibrary},
{QStringLiteral("load_pose_library"), &MCPServer::toolLoadPoseLibrary}
};
return handlers;
}
Expand Down Expand Up @@ -4656,6 +4658,49 @@
QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented)));
}

QJsonObject MCPServer::toolSavePoseLibrary(const QJsonObject &args)

Check warning on line 4661 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function should be declared "const".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46G4-eGtJnIfb6O8vS&open=AZ46G4-eGtJnIfb6O8vS&pullRequest=604
{
SentryReporter::addBreadcrumb("ai.tool_call", "save_pose_library");
const QString path = args.value("path").toString();
if (path.isEmpty())
return makeErrorResult("Error: missing required 'path' argument");

auto* lib = PoseLibrary::instance();

Check warning on line 4668 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "lib" is "class PoseLibrary *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46G4-eGtJnIfb6O8vT&open=AZ46G4-eGtJnIfb6O8vT&pullRequest=604
if (!lib->savePoseLibraryForSelection(path))

Check warning on line 4669 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "lib" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46G4-eGtJnIfb6O8vR&open=AZ46G4-eGtJnIfb6O8vR&pullRequest=604
return makeErrorResult(
QString("Error: failed to save library to '%1' "
"(no selection, empty library, or unwritable path)")
.arg(path));

QJsonObject content;
content["ok"] = true;
content["path"] = path;
return makeSuccessResult(
QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented)));
}

QJsonObject MCPServer::toolLoadPoseLibrary(const QJsonObject &args)

Check warning on line 4682 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function should be declared "const".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46G4-eGtJnIfb6O8vU&open=AZ46G4-eGtJnIfb6O8vU&pullRequest=604
{
SentryReporter::addBreadcrumb("ai.tool_call", "load_pose_library");
const QString path = args.value("path").toString();
if (path.isEmpty())
return makeErrorResult("Error: missing required 'path' argument");

auto* lib = PoseLibrary::instance();
if (!lib->loadPoseLibraryForSelection(path))
return makeErrorResult(
QString("Error: failed to load library from '%1' "
"(no selection, file missing, or malformed JSON/schema)")
.arg(path));

QJsonObject content;
content["ok"] = true;
content["path"] = path;
content["count"] = static_cast<int>(lib->listPosesForSelection().size());
return makeSuccessResult(
QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented)));
}

QJsonArray MCPServer::buildToolsList()
{
QJsonArray tools;
Expand Down Expand Up @@ -5928,6 +5973,41 @@
);
}

// save_pose_library
{
QJsonObject props;
props["path"] = QJsonObject{{"type", "string"}, {"description", "Destination `.poselib` file path. Written atomically via QSaveFile. Side-by-side with the asset is the recommended location."}};
QJsonArray required;
required.append("path");
appendTool(
"save_pose_library",
"Persist the first selected entity's pose library to a "
"`.poselib` sidecar JSON (schema `qtmesheditor.poselib.v1`). "
"Returns error when there's no selection, the library is "
"empty, or the path is unwritable.",
props,
required
);
}

// load_pose_library
{
QJsonObject props;
props["path"] = QJsonObject{{"type", "string"}, {"description", "Source `.poselib` file path. Schema-validated; malformed files don't disturb in-memory library."}};
QJsonArray required;
required.append("path");
appendTool(
"load_pose_library",
"Load a `.poselib` sidecar JSON into the first selected "
"entity's library, **replacing** the in-memory contents. "
"Returns error when there's no selection, the file is "
"missing, or the JSON / schema is malformed (in-memory "
"library is preserved on parse failure).",
props,
required
);
}

// bake_vat
{
QJsonObject props;
Expand Down
9 changes: 9 additions & 0 deletions src/MCPServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ private slots:
/// Args: `src` (existing pose), `dst` (output pose name).
QJsonObject toolMirrorPose(const QJsonObject &args);

/// Pose-lib D-Project: write the first-selected entity's pose
/// library to a `.poselib` sidecar JSON file. Args: `path`.
QJsonObject toolSavePoseLibrary(const QJsonObject &args);

/// Pose-lib D-Project: load a `.poselib` sidecar into the
/// first-selected entity's library, replacing whatever was
/// in memory. Args: `path`.
QJsonObject toolLoadPoseLibrary(const QJsonObject &args);

// Animation
struct NodeAnimation {
Ogre::SceneNode* node;
Expand Down
24 changes: 24 additions & 0 deletions src/MCPServer_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7310,3 +7310,27 @@ TEST_F(MCPServerTest, MirrorPose_UnknownSrcRejected)
QJsonObject r = server->callTool("mirror_pose", args);
EXPECT_TRUE(isError(r));
}

TEST_F(MCPServerTest, SavePoseLibrary_MissingPathRejected)
{
QJsonObject empty;
QJsonObject r = server->callTool("save_pose_library", empty);
EXPECT_TRUE(isError(r));
EXPECT_TRUE(getResultText(r).contains("path"));
}

TEST_F(MCPServerTest, LoadPoseLibrary_MissingPathRejected)
{
QJsonObject empty;
QJsonObject r = server->callTool("load_pose_library", empty);
EXPECT_TRUE(isError(r));
EXPECT_TRUE(getResultText(r).contains("path"));
}

TEST_F(MCPServerTest, LoadPoseLibrary_MissingFileRejected)
{
QJsonObject args;
args["path"] = "/nonexistent/path/no.poselib";
QJsonObject r = server->callTool("load_pose_library", args);
EXPECT_TRUE(isError(r));
}
Loading