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
62 changes: 61 additions & 1 deletion src/MCPServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,8 @@
{QStringLiteral("delete_pose"), &MCPServer::toolDeletePose},
{QStringLiteral("mirror_pose"), &MCPServer::toolMirrorPose},
{QStringLiteral("save_pose_library"), &MCPServer::toolSavePoseLibrary},
{QStringLiteral("load_pose_library"), &MCPServer::toolLoadPoseLibrary}
{QStringLiteral("load_pose_library"), &MCPServer::toolLoadPoseLibrary},
{QStringLiteral("apply_pose_masked"), &MCPServer::toolApplyPoseMasked}
};
return handlers;
}
Expand Down Expand Up @@ -4701,6 +4702,45 @@
QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented)));
}

QJsonObject MCPServer::toolApplyPoseMasked(const QJsonObject &args)

Check warning on line 4705 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=AZ46da__sk2-35lssK3V&open=AZ46da__sk2-35lssK3V&pullRequest=609
{
SentryReporter::addBreadcrumb("ai.tool_call", "apply_pose_masked");
const QString name = args.value("name").toString();
if (name.isEmpty())
return makeErrorResult("Error: missing required 'name' argument");
if (!args.contains("bones"))
return makeErrorResult("Error: missing required 'bones' argument");
const QJsonValue bonesV = args.value("bones");
if (!bonesV.isArray())
return makeErrorResult("Error: 'bones' must be an array of bone-name strings");

// Strict-parse: every entry must be a string. A non-string slot
// would silently degrade to empty (toString returns "") and the
// mask would match unintended bones.
QStringList boneNames;
const QJsonArray arr = bonesV.toArray();
for (int i = 0; i < arr.size(); ++i) {
if (!arr[i].isString())
return makeErrorResult(
QString("Error: 'bones'[%1] must be a string").arg(i));
boneNames << arr[i].toString();
}

auto* lib = PoseLibrary::instance();
if (!lib->applyPoseMaskedForSelection(name, boneNames))

Check warning on line 4730 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=AZ46da__sk2-35lssK3U&open=AZ46da__sk2-35lssK3U&pullRequest=609
return makeErrorResult(
QString("Error: failed to apply pose '%1' masked "
"(no selection, no skeleton, or pose not found)")
.arg(name));

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

QJsonArray MCPServer::buildToolsList()
{
QJsonArray tools;
Expand Down Expand Up @@ -6008,6 +6048,26 @@
);
}

// apply_pose_masked
{
QJsonObject props;
props["name"] = QJsonObject{{"type", "string"}, {"description", "Pose name (use list_poses to enumerate)."}};
props["bones"] = QJsonObject{{"type", "array"}, {"description", "Bone names to apply. Bones NOT in this list keep their current TRS. Empty list = no-op (returns success but touches no bones)."}};
QJsonArray required;
required.append("name");
required.append("bones");
appendTool(
"apply_pose_masked",
"Apply a saved pose to ONLY the listed bones. Use case: "
"snap to a facial expression without disturbing the "
"current body pose, or apply an arm gesture without "
"re-posing legs. Bones in the list but missing from the "
"skeleton (LOD change) are skipped silently.",
props,
required
);
}

// bake_vat
{
QJsonObject props;
Expand Down
6 changes: 6 additions & 0 deletions src/MCPServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ private slots:
/// in memory. Args: `path`.
QJsonObject toolLoadPoseLibrary(const QJsonObject &args);

/// Pose-lib D5: apply a saved pose only to a subset of bones.
/// Args: `name` (pose), `bones` (JSON array of bone-name
/// strings). Use case: facial expression without disturbing
/// the body pose.
QJsonObject toolApplyPoseMasked(const QJsonObject &args);

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

TEST_F(MCPServerTest, ApplyPoseMasked_MissingNameRejected)
{
QJsonObject args;
args["bones"] = QJsonArray{"Spine"};
QJsonObject r = server->callTool("apply_pose_masked", args);
EXPECT_TRUE(isError(r));
EXPECT_TRUE(getResultText(r).contains("name"));
}

TEST_F(MCPServerTest, ApplyPoseMasked_MissingBonesRejected)
{
QJsonObject args;
args["name"] = "MCP_AnyPose";
QJsonObject r = server->callTool("apply_pose_masked", args);
EXPECT_TRUE(isError(r));
EXPECT_TRUE(getResultText(r).contains("bones"));
}

TEST_F(MCPServerTest, ApplyPoseMasked_NonArrayBonesRejected)
{
QJsonObject args;
args["name"] = "MCP_AnyPose";
args["bones"] = "not an array";
QJsonObject r = server->callTool("apply_pose_masked", args);
EXPECT_TRUE(isError(r));
EXPECT_TRUE(getResultText(r).contains("bones"));
}

TEST_F(MCPServerTest, ApplyPoseMasked_NonStringBoneRejected)
{
QJsonObject args;
args["name"] = "MCP_AnyPose";
args["bones"] = QJsonArray{"OK", 42}; // int instead of string
QJsonObject r = server->callTool("apply_pose_masked", args);
EXPECT_TRUE(isError(r));
EXPECT_TRUE(getResultText(r).contains("bones"));
}
11 changes: 11 additions & 0 deletions src/PoseLibrary.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,17 @@
return applyPose(ents.first(), name);
}

bool PoseLibrary::applyPoseMaskedForSelection(const QString& name,
const QStringList& boneNames)
{
auto* sel = SelectionSet::getSingleton();

Check warning on line 528 in src/PoseLibrary.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 "sel" is "class SelectionSet *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46dbK8sk2-35lssK3W&open=AZ46dbK8sk2-35lssK3W&pullRequest=609
if (!sel) return false;
auto ents = sel->getResolvedEntities();
if (ents.isEmpty()) return false;
QSet<QString> mask(boneNames.cbegin(), boneNames.cend());
return applyPoseMasked(ents.first(), name, mask);
}

bool PoseLibrary::deletePoseForSelection(const QString& name)
{
auto* sel = SelectionSet::getSingleton();
Expand Down
6 changes: 6 additions & 0 deletions src/PoseLibrary.h
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ class PoseLibrary : public QObject
/// "Pose Library" subgroup.
Q_INVOKABLE bool savePoseForSelection(const QString& name);
Q_INVOKABLE bool applyPoseForSelection(const QString& name);

/// Selection wrapper for D5 apply-with-mask. `boneNames` is a
/// QML/MCP-friendly QStringList; internally converted to QSet
/// for the underlying `applyPoseMasked` call.
Q_INVOKABLE bool applyPoseMaskedForSelection(const QString& name,
const QStringList& boneNames);
Q_INVOKABLE bool deletePoseForSelection(const QString& name);
Q_INVOKABLE bool mirrorPoseForSelection(const QString& srcName,
const QString& dstName);
Expand Down
Loading