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
146 changes: 146 additions & 0 deletions src/PoseLibrary.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
#include "SentryReporter.h"

#include <QCoreApplication>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
#include <QThread>

#include <OgreBone.h>
Expand Down Expand Up @@ -304,6 +309,147 @@
}
}

namespace {

// Schema string written into / verified out of the sidecar JSON.
// Bump when the format changes incompatibly so loadPoseLibrary can
// reject older files cleanly.
constexpr const char* kPoseLibSchemaV1 = "qtmesheditor.poselib.v1";

} // namespace

bool PoseLibrary::savePoseLibrary(Ogre::Entity* entity, const QString& filePath) const
{
assertMainThread();
if (!entity || filePath.isEmpty()) return false;
auto entIt = m_byEntity.constFind(entity);
if (entIt == m_byEntity.constEnd()) return false;
if (entIt->order.isEmpty()) return false;

QJsonArray poses;
for (const QString& name : entIt->order) {
auto poseIt = entIt->byName.constFind(name);
if (poseIt == entIt->byName.constEnd()) continue;
QJsonObject bones;
for (auto bIt = poseIt->cbegin(); bIt != poseIt->cend(); ++bIt) {
const auto& trs = bIt.value();
QJsonObject bone;
bone["t"] = QJsonArray{ trs.translate.x, trs.translate.y, trs.translate.z };
bone["r"] = QJsonArray{ trs.rotation.w, trs.rotation.x,
trs.rotation.y, trs.rotation.z };
bone["s"] = QJsonArray{ trs.scale.x, trs.scale.y, trs.scale.z };
bones[bIt.key()] = bone;
}
QJsonObject poseObj;
poseObj["name"] = name;
poseObj["bones"] = bones;
poses.append(poseObj);
}

QJsonObject root;
root["schema"] = kPoseLibSchemaV1;
root["poses"] = poses;

// QSaveFile gives atomic write: temp file + rename on commit, so
// a power loss / kill -9 mid-write doesn't leave a half-written
// sidecar that loadPoseLibrary later rejects.
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false;
if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) < 0) {
file.cancelWriting();
return false;
}
if (!file.commit()) return false;

SentryReporter::addBreadcrumb("scene.anim.pose",
QStringLiteral("save library to '%1' (%2 poses)")
.arg(filePath).arg(entIt->order.size()));
Comment on lines +364 to +366
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use file I/O breadcrumb categories required by policy.

For save/load sidecar operations, breadcrumb categories should be file.export and file.import instead of scene.anim.pose.

Proposed fix
-    SentryReporter::addBreadcrumb("scene.anim.pose",
+    SentryReporter::addBreadcrumb("file.export",
         QStringLiteral("save library to '%1' (%2 poses)")
             .arg(filePath).arg(entIt->order.size()));

@@
-    SentryReporter::addBreadcrumb("scene.anim.pose",
+    SentryReporter::addBreadcrumb("file.import",
         QStringLiteral("load library from '%1' (%2 poses)")
             .arg(filePath).arg(store.order.size()));

As per coding guidelines: "All user-facing actions and significant operations must be tracked with SentryReporter::addBreadcrumb() using appropriate categories ... 'file.import'/'file.export' for I/O operations".

Also applies to: 428-430

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PoseLibrary.cpp` around lines 364 - 366, The breadcrumb category used in
SentryReporter::addBreadcrumb for file save/load is incorrect: replace the
current "scene.anim.pose" category in the SentryReporter::addBreadcrumb call
(the one that builds the message with filePath and entIt->order.size()) with
"file.export" for save operations, and similarly ensure the corresponding
load-side SentryReporter::addBreadcrumb calls use "file.import" (also update the
other occurrences around the load code path where addBreadcrumb is called); keep
the existing message text and arguments (filePath and entIt->order.size()) but
only change the first argument (category) to the appropriate "file.export" or
"file.import".

return true;
}

bool PoseLibrary::loadPoseLibrary(Ogre::Entity* entity, const QString& filePath)
{
assertMainThread();
if (!entity || filePath.isEmpty()) return false;
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) return false;
const QByteArray bytes = file.readAll();
file.close();
QJsonParseError parseError{};
const QJsonDocument doc = QJsonDocument::fromJson(bytes, &parseError);
if (parseError.error != QJsonParseError::NoError) return false;
if (!doc.isObject()) return false;
const QJsonObject root = doc.object();
if (root.value("schema").toString() != QString::fromLatin1(kPoseLibSchemaV1))
return false;
const QJsonArray poses = root.value("poses").toArray();

// Wipe the existing per-entity entry — the file is canonical
// for this load. Partial overlay would be confusing UX (which
// pose wins on name collision?), so we go with replacement.
m_byEntity.remove(entity);
Comment on lines +385 to +390
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject non-array poses payload before replacing library

When the file has a valid schema but poses is missing or not an array (for example after a bad manual edit or merge), toArray() yields an empty array and the function still returns true after wiping the current in-memory library. That silently drops existing poses while signaling success, which is data loss for callers that trust the return value. Validate that root["poses"] is actually an array (and fail early) before m_byEntity.remove(entity).

Useful? React with 👍 / 👎.

auto& store = m_byEntity[entity];

Comment on lines +385 to +392
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate full payload before wiping existing poses.

loadPoseLibrary removes current poses at Line 390 before it strictly validates the poses payload/entries, so a schema-matching but malformed sidecar can still clear in-memory data.

Proposed fix
-    const QJsonArray poses = root.value("poses").toArray();
+    if (!root.contains("poses") || !root.value("poses").isArray()) return false;
+    const QJsonArray poses = root.value("poses").toArray();
 
-    m_byEntity.remove(entity);
-    auto& store = m_byEntity[entity];
+    EntityPoses parsed;
+    QSet<QString> seenNames;
 
@@
-    for (const QJsonValue& p : poses) {
+    for (const QJsonValue& p : poses) {
+        if (!p.isObject()) return false;
         const QJsonObject pObj = p.toObject();
         const QString name = pObj.value("name").toString();
-        if (name.isEmpty()) continue;
+        if (name.isEmpty() || seenNames.contains(name)) return false;
+        if (!pObj.value("bones").isObject()) return false;
+        seenNames.insert(name);
         PoseSnapshot snapshot;
         const QJsonObject bones = pObj.value("bones").toObject();
@@
-        store.byName.insert(name, snapshot);
-        store.order.append(name);
+        parsed.byName.insert(name, snapshot);
+        parsed.order.append(name);
     }
+    m_byEntity.insert(entity, parsed);

Also applies to: 407-426

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PoseLibrary.cpp` around lines 385 - 392, The code currently calls
m_byEntity.remove(entity) and then obtains auto& store = m_byEntity[entity]
before validating the QJsonArray poses, which can clear in-memory poses on
malformed payloads; in loadPoseLibrary, first fully validate the top-level
payload and every entry in the poses array (required keys, types, ranges, and
any schema invariants) and build the new pose data into a temporary container,
and only if validation succeeds perform the removal and assign/swap the
temporary container into m_byEntity[entity]; apply the same pattern to the other
load path referenced around the 407-426 region so no in-memory data is wiped
until validation passes.

auto readVec3 = [](const QJsonArray& a, const Ogre::Vector3& def) -> Ogre::Vector3 {

Check warning on line 393 in src/PoseLibrary.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the redundant return type of this lambda.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ459org_YNLq2w-fCtI&open=AZ459org_YNLq2w-fCtI&pullRequest=602
if (a.size() != 3) return def;
return Ogre::Vector3(static_cast<Ogre::Real>(a[0].toDouble()),
static_cast<Ogre::Real>(a[1].toDouble()),
static_cast<Ogre::Real>(a[2].toDouble()));
};
auto readQuat = [](const QJsonArray& a, const Ogre::Quaternion& def) -> Ogre::Quaternion {

Check warning on line 399 in src/PoseLibrary.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the redundant return type of this lambda.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ459org_YNLq2w-fCtJ&open=AZ459org_YNLq2w-fCtJ&pullRequest=602
if (a.size() != 4) return def;
return Ogre::Quaternion(static_cast<Ogre::Real>(a[0].toDouble()),
static_cast<Ogre::Real>(a[1].toDouble()),
static_cast<Ogre::Real>(a[2].toDouble()),
static_cast<Ogre::Real>(a[3].toDouble()));
};

for (const QJsonValue& p : poses) {
const QJsonObject pObj = p.toObject();
const QString name = pObj.value("name").toString();
if (name.isEmpty()) continue;
PoseSnapshot snapshot;
const QJsonObject bones = pObj.value("bones").toObject();
for (auto it = bones.constBegin(); it != bones.constEnd(); ++it) {
const QJsonObject boneObj = it.value().toObject();
BonePoseSnapshot trs;
trs.translate = readVec3(boneObj.value("t").toArray(),
Ogre::Vector3::ZERO);
trs.rotation = readQuat(boneObj.value("r").toArray(),
Ogre::Quaternion::IDENTITY);
trs.scale = readVec3(boneObj.value("s").toArray(),
Ogre::Vector3(1, 1, 1));
snapshot.insert(it.key(), trs);
}
store.byName.insert(name, snapshot);
store.order.append(name);
Comment on lines +424 to +425
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 Deduplicate pose names while rebuilding insertion order

Loading appends every parsed pose name into order even when the same name appears multiple times in the sidecar. Because byName keeps only the last value, order can contain duplicates, which later makes list/delete behavior inconsistent (e.g., a phantom name can remain in listPoses after deletePose removes the hash entry). Guard order.append(name) with an overwrite check (or reject duplicate names) to preserve internal invariants.

Useful? React with 👍 / 👎.

}

SentryReporter::addBreadcrumb("scene.anim.pose",
QStringLiteral("load library from '%1' (%2 poses)")
.arg(filePath).arg(store.order.size()));
emit posesChanged(entity);
return true;
}

bool PoseLibrary::savePoseLibraryForSelection(const QString& filePath) const
{
auto* sel = SelectionSet::getSingleton();

Check warning on line 437 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=AZ459org_YNLq2w-fCtK&open=AZ459org_YNLq2w-fCtK&pullRequest=602
if (!sel) return false;
auto ents = sel->getResolvedEntities();
if (ents.isEmpty()) return false;
return savePoseLibrary(ents.first(), filePath);
}

bool PoseLibrary::loadPoseLibraryForSelection(const QString& filePath)
{
auto* sel = SelectionSet::getSingleton();

Check warning on line 446 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=AZ459org_YNLq2w-fCtL&open=AZ459org_YNLq2w-fCtL&pullRequest=602
if (!sel) return false;
auto ents = sel->getResolvedEntities();
if (ents.isEmpty()) return false;
return loadPoseLibrary(ents.first(), filePath);
}

bool PoseLibrary::savePoseForSelection(const QString& name)
{
auto* sel = SelectionSet::getSingleton();
Expand Down
21 changes: 21 additions & 0 deletions src/PoseLibrary.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ class PoseLibrary : public QObject
/// All pose names on `entity` in save-order.
QStringList listPoses(Ogre::Entity* entity) const;

/// Persist this entity's pose library to a `.poselib` sidecar
/// JSON file. Returns false on write error (path unwritable,
/// no entity, no poses to save). The file format is
/// `qtmesheditor.poselib.v1` — see `loadPoseLibrary` for the
/// shape contract. Side-by-side with the source asset is the
/// recommended location so the library follows the asset
/// through version control.
Q_INVOKABLE bool savePoseLibrary(Ogre::Entity* entity, const QString& filePath) const;

/// Load a `.poselib` sidecar JSON file and replace this
/// entity's in-memory library with its contents. Returns false
/// on read / parse error (file missing, JSON malformed, schema
/// mismatch). Existing poses on the entity are wiped first so
/// the result reflects the file 1:1 (no partial-overlay).
/// Emits `posesChanged` so the Inspector / dope-sheet refresh.
Q_INVOKABLE bool loadPoseLibrary(Ogre::Entity* entity, const QString& filePath);

/// Selection wrappers — same pattern as save/apply/delete.
Q_INVOKABLE bool savePoseLibraryForSelection(const QString& filePath) const;
Q_INVOKABLE bool loadPoseLibraryForSelection(const QString& filePath);

/// Mirror a saved pose across the YZ plane (X = symmetry axis,
/// the convention every common rig follows). Reads `srcName`
/// from the library on `entity`, flips each bone's TRS by:
Expand Down
109 changes: 109 additions & 0 deletions src/PoseLibrary_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
#include "TestHelpers.h"
#include "commands/PoseLibraryCommands.h"

#include <QTemporaryDir>

#include <OgreBone.h>
#include <OgreEntity.h>
#include <OgreSkeleton.h>
Expand Down Expand Up @@ -212,6 +214,113 @@ TEST_F(PoseLibrarySceneTest, ForgetEntityDropsEverythingForThatEntity) {
EXPECT_FALSE(lib->forgetEntity(entity));
}

// ─── Slice D-Project — .poselib sidecar persistence ─────────────────

TEST_F(PoseLibrarySceneTest, SaveAndLoadLibraryRoundTripsViaSidecar) {
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString path = tmp.path() + "/test.poselib";

Ogre::Entity* entity = createAnimatedTestEntity("PoseLib_Sidecar");
ASSERT_NE(entity, nullptr);
auto* skel = entity->getSkeleton();
auto* lib = PoseLibrary::instance();

skel->getBone(0)->setPosition(Ogre::Vector3(3, 0, 0));
ASSERT_TRUE(lib->savePose(entity, QStringLiteral("Pose1")));
skel->getBone(0)->setPosition(Ogre::Vector3(0, 5, 0));
ASSERT_TRUE(lib->savePose(entity, QStringLiteral("Pose2")));

EXPECT_TRUE(lib->savePoseLibrary(entity, path));

// Wipe the in-memory library and reload from disk.
lib->forgetEntity(entity);
EXPECT_TRUE(lib->listPoses(entity).isEmpty());

EXPECT_TRUE(lib->loadPoseLibrary(entity, path));
QStringList names = lib->listPoses(entity);
ASSERT_EQ(names.size(), 2);
EXPECT_EQ(names[0], QStringLiteral("Pose1"));
EXPECT_EQ(names[1], QStringLiteral("Pose2"));

// Apply the first pose to confirm TRS round-tripped.
skel->getBone(0)->setPosition(Ogre::Vector3::ZERO);
lib->applyPose(entity, QStringLiteral("Pose1"));
EXPECT_FLOAT_EQ(skel->getBone(0)->getPosition().x, 3.0f);
EXPECT_FLOAT_EQ(skel->getBone(0)->getPosition().y, 0.0f);
}

TEST_F(PoseLibrarySceneTest, SaveLibraryRejectsEmptyLibraryOrInvalidPath) {
Ogre::Entity* entity = createAnimatedTestEntity("PoseLib_SidecarReject");
ASSERT_NE(entity, nullptr);
auto* lib = PoseLibrary::instance();

QTemporaryDir tmp;
const QString validPath = tmp.path() + "/empty.poselib";

// No poses saved yet → false (don't write an empty library file).
EXPECT_FALSE(lib->savePoseLibrary(entity, validPath));

// Empty path → false.
ASSERT_TRUE(lib->savePose(entity, QStringLiteral("X")));
EXPECT_FALSE(lib->savePoseLibrary(entity, QString()));

// Null entity → false.
EXPECT_FALSE(lib->savePoseLibrary(nullptr, validPath));
}

TEST_F(PoseLibrarySceneTest, LoadLibraryRejectsMissingFileAndBadSchema) {
Ogre::Entity* entity = createAnimatedTestEntity("PoseLib_SidecarLoadReject");
ASSERT_NE(entity, nullptr);
auto* lib = PoseLibrary::instance();

QTemporaryDir tmp;
// Missing file
EXPECT_FALSE(lib->loadPoseLibrary(entity, tmp.path() + "/nope.poselib"));

// Bad JSON
{
const QString p = tmp.path() + "/bad.poselib";
QFile f(p); ASSERT_TRUE(f.open(QIODevice::WriteOnly));
f.write("{ this is not JSON");
f.close();
EXPECT_FALSE(lib->loadPoseLibrary(entity, p));
}

// Valid JSON, wrong schema string
{
const QString p = tmp.path() + "/wrongschema.poselib";
QFile f(p); ASSERT_TRUE(f.open(QIODevice::WriteOnly));
f.write("{\"schema\": \"wrong\", \"poses\": []}");
f.close();
EXPECT_FALSE(lib->loadPoseLibrary(entity, p));
}
}

TEST_F(PoseLibrarySceneTest, LoadLibraryWipesExistingPosesFirst) {
QTemporaryDir tmp;
Ogre::Entity* entity = createAnimatedTestEntity("PoseLib_SidecarWipe");
ASSERT_NE(entity, nullptr);
auto* lib = PoseLibrary::instance();

// Build a sidecar with one pose "FromFile".
ASSERT_TRUE(lib->savePose(entity, QStringLiteral("FromFile")));
const QString path = tmp.path() + "/one.poselib";
ASSERT_TRUE(lib->savePoseLibrary(entity, path));

// Add a different in-memory pose that's NOT in the file.
lib->forgetEntity(entity);
ASSERT_TRUE(lib->savePose(entity, QStringLiteral("InMemoryOnly")));
EXPECT_EQ(lib->listPoses(entity).size(), 1);

// Load wipes "InMemoryOnly" and replaces with file's "FromFile".
EXPECT_TRUE(lib->loadPoseLibrary(entity, path));
QStringList names = lib->listPoses(entity);
ASSERT_EQ(names.size(), 1);
EXPECT_EQ(names[0], QStringLiteral("FromFile"));
EXPECT_FALSE(lib->hasPose(entity, QStringLiteral("InMemoryOnly")));
}

// ─── Slice D4 — bone name flip + mirror pose ─────────────────────────

TEST(PoseLibraryStandalone, FlipBoneName_MixamoLowercaseSuffix) {
Expand Down
Loading