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
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ commands/BoneTransformCommand.cpp
commands/AddKeyframeCommand.cpp
commands/ApplyMaterialCommand.cpp
commands/DeleteKeyframeCommand.cpp
commands/MorphCommands.cpp
commands/SkeletonResolver.cpp
BoneDragRelease.cpp
PropertiesPanelController.cpp
Expand Down
7 changes: 7 additions & 0 deletions src/EditableMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,13 @@ bool EditableMesh::loadFromMesh(const Ogre::MeshPtr& meshPtr)
readVertexData(subMesh->vertexData, editSub.vertices);
}

// Snapshot the bind positions so morph-target authoring can
// recover the pre-edit baseline even after edit-mode ops have
// already committed their changes back to the GPU buffer.
editSub.originalPositions.reserve(editSub.vertices.size());
for (const auto& v : editSub.vertices)
editSub.originalPositions.push_back(v.position);

if (mesh->hasSkeleton()) {
const Ogre::SubMesh::VertexBoneAssignmentList& boneAssignments =
subMesh->useSharedVertices ? mesh->getBoneAssignments() : subMesh->getBoneAssignments();
Expand Down
8 changes: 8 additions & 0 deletions src/EditableMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ struct EditableSubMesh {
std::vector<EditableFace> faces;
std::string materialName;
bool usesSharedVertices = false;

/// Per-vertex bind positions captured at `loadFromOgreMesh()` time.
/// Immutable after load — edit-mode ops mutate `vertices[].position`
/// but never touch this array. Morph-target authoring diffs
/// `vertices[].position - originalPositions[i]` to recover the
/// pre-edit-vs-current delta even after commits have written the
/// edits back to the GPU buffer.
std::vector<Ogre::Vector3> originalPositions;
};

/**
Expand Down
23 changes: 23 additions & 0 deletions src/EditableMesh_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,29 @@ TEST_F(EditableMeshTest, LoadFromEntityTriangleMesh) {
// The in-memory triangle mesh uses shared vertices
EXPECT_TRUE(editMesh.subMeshes()[0].usesSharedVertices);

// `originalPositions` snapshot is captured at load time and used by
// morph-target authoring to recover the pre-edit baseline even after
// edit-mode ops have already committed back to the GPU buffer.
// Same size as `vertices`, and equal element-wise immediately after
// load. Shared-vertex submeshes get a copy of the shared-pool
// baseline, so this assertion covers Codex slice A3 P1 #2 too.
const auto& sub = editMesh.subMeshes()[0];
ASSERT_EQ(sub.originalPositions.size(), sub.vertices.size());
for (size_t i = 0; i < sub.vertices.size(); ++i) {
EXPECT_FLOAT_EQ(sub.originalPositions[i].x, sub.vertices[i].position.x);
EXPECT_FLOAT_EQ(sub.originalPositions[i].y, sub.vertices[i].position.y);
EXPECT_FLOAT_EQ(sub.originalPositions[i].z, sub.vertices[i].position.z);
}

// Mutating `vertices[].position` (simulates an edit-mode op) must
// NOT touch the snapshot. This is the invariant the morph diff
// path depends on — without it, `vertices - originalPositions`
// would always be zero after the first commit.
editMesh.subMeshes()[0].vertices[0].position = Ogre::Vector3(99, 88, 77);
EXPECT_FLOAT_EQ(editMesh.subMeshes()[0].originalPositions[0].x, 0.0f);
EXPECT_FLOAT_EQ(editMesh.subMeshes()[0].originalPositions[0].y, 0.0f);
EXPECT_FLOAT_EQ(editMesh.subMeshes()[0].originalPositions[0].z, 0.0f);

// Cleanup
Manager::getSingleton()->destroySceneNode("EditableMesh_triangle_node");
}
Expand Down
154 changes: 154 additions & 0 deletions src/MorphAnimationManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@

#include "MorphAnimationManager.h"

#include "EditModeController.h"
#include "EditableMesh.h"
#include "SelectionSet.h"
#include "SentryReporter.h"
#include "UndoManager.h"
#include "commands/MorphCommands.h"

#include <QCoreApplication>
#include <QThread>
Expand Down Expand Up @@ -158,3 +162,153 @@
if (ents.isEmpty()) return false;
return setWeight(ents.first(), name, static_cast<float>(w));
}

namespace {

// Walk the per-submesh edited positions on the EditableMesh and diff
// against the bind-position snapshot taken at `loadFromOgreMesh` time
// (`EditableSubMesh::originalPositions`). Returns the sparse delta
// map per submesh. Submeshes that didn't change at all get no slice
// entry, mirroring the importer's "lazy createPose" rule.
//
// We diff against the captured snapshot rather than re-reading the
// live mesh vertex buffer because edit-mode ops continuously
// `commitToEntity` — by the time the user clicks "save morph", the
// GPU buffer already holds the edited positions, so a re-read would
// produce a zero diff for every vertex. The snapshot was captured
// once at edit-mode entry, before any mutation, so it remains a
// valid baseline regardless of how many edit ops ran.
//
// Submeshes with `useSharedVertices=true` share a single vertex
// pool. EditableMesh::loadFromOgreMesh handles that by copying the
// shared positions into each affected submesh's `vertices` and
// `originalPositions`, so this code path doesn't need to special-
// case it — every submesh carries its own baseline.
std::vector<MorphPoseSlice> capturePoseSlicesFromEdit(
Ogre::Entity* entity, const EditableMesh* edit)

Check warning on line 188 in src/MorphAnimationManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this parameter a pointer-to-const. The current type of "entity" is "class Ogre::Entity *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ43hNISBO_6kV5uGhqC&open=AZ43hNISBO_6kV5uGhqC&pullRequest=578
{
std::vector<MorphPoseSlice> slices;
if (!entity || !edit) return slices;
Ogre::MeshPtr mesh = entity->getMesh();
if (!mesh) return slices;

const auto& subs = edit->subMeshes();
const size_t meshSubCount = mesh->getNumSubMeshes();
const size_t n = std::min(subs.size(), meshSubCount);

for (size_t s = 0; s < n; ++s) {
const auto& bindPositions = subs[s].originalPositions;
// Mismatch typically means the submesh was modified
// topologically (insert/delete vertex) — we can't meaningfully
// diff against a different-shaped baseline, so skip it.
if (bindPositions.size() != subs[s].vertices.size()) continue;

MorphPoseSlice slice;
slice.submeshHandle = static_cast<unsigned short>(s + 1);
for (size_t vi = 0; vi < bindPositions.size(); ++vi) {
const Ogre::Vector3 delta =
subs[s].vertices[vi].position - bindPositions[vi];
if (delta.squaredLength() <= 1e-12f) continue;
slice.offsets[static_cast<unsigned int>(vi)] =
Ogre::Vector3f(delta.x, delta.y, delta.z);
}
if (!slice.offsets.empty()) slices.push_back(std::move(slice));
}
return slices;
}

// Reject names that would collide with an existing pose on the mesh.
// Same-named poses across submeshes are allowed by Ogre, but for
// authoring we treat "name already in use" as a no-op so the UI
// can show "rename existing" instead of silently shadowing.
bool nameAlreadyInUse(Ogre::Mesh* mesh, const QString& name)

Check warning on line 224 in src/MorphAnimationManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this parameter a pointer-to-const. The current type of "mesh" is "class Ogre::Mesh *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ43hNISBO_6kV5uGhqD&open=AZ43hNISBO_6kV5uGhqD&pullRequest=578
{
if (!mesh) return false;
const std::string sn = name.toStdString();
const auto& poseList = mesh->getPoseList();
for (const Ogre::Pose* p : poseList) {

Check warning on line 229 in src/MorphAnimationManager.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this range for-loop by "std::any_of".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ43hNISBO_6kV5uGhqE&open=AZ43hNISBO_6kV5uGhqE&pullRequest=578
if (p && p->getName() == sn) return true;
}
return false;
}

} // namespace

bool MorphAnimationManager::addMorphTargetFromCurrentEdit(const QString& name)
{
assertMainThread();
if (name.trimmed().isEmpty()) return false;

auto* sel = SelectionSet::getSingleton();

Check warning on line 242 in src/MorphAnimationManager.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=AZ43hNISBO_6kV5uGhqF&open=AZ43hNISBO_6kV5uGhqF&pullRequest=578
if (!sel) return false;
auto ents = sel->getResolvedEntities();
if (ents.isEmpty() || !ents.first()) return false;
Ogre::Entity* entity = ents.first();
Ogre::MeshPtr mesh = entity->getMesh();
if (!mesh) return false;

if (nameAlreadyInUse(mesh.get(), name)) return false;

auto* edit = EditModeController::instance();

Check warning on line 252 in src/MorphAnimationManager.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 "edit" is "class EditModeController *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ43hNISBO_6kV5uGhqG&open=AZ43hNISBO_6kV5uGhqG&pullRequest=578
if (!edit) return false;
EditableMesh* editable = edit->currentMesh();

Check warning on line 254 in src/MorphAnimationManager.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 "editable" is "class EditableMesh *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ43hNISBO_6kV5uGhqH&open=AZ43hNISBO_6kV5uGhqH&pullRequest=578
if (!editable) return false;

auto slices = capturePoseSlicesFromEdit(entity, editable);
if (slices.empty()) return false;

auto* undo = UndoManager::getSingleton();
if (!undo) return false;
undo->push(new AddMorphTargetCommand(entity, name, slices));

emit morphTargetsChanged();
return true;
}

bool MorphAnimationManager::renameMorphTarget(const QString& oldName,
const QString& newName)
{
assertMainThread();
const QString trimmedNew = newName.trimmed();
if (oldName.isEmpty() || trimmedNew.isEmpty() || oldName == trimmedNew)
return false;

auto* sel = SelectionSet::getSingleton();

Check warning on line 276 in src/MorphAnimationManager.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=AZ43hNISBO_6kV5uGhqI&open=AZ43hNISBO_6kV5uGhqI&pullRequest=578
if (!sel) return false;
auto ents = sel->getResolvedEntities();
if (ents.isEmpty() || !ents.first()) return false;
Ogre::Entity* entity = ents.first();
Ogre::MeshPtr mesh = entity->getMesh();
if (!mesh) return false;
if (!nameAlreadyInUse(mesh.get(), oldName)) return false;
if (nameAlreadyInUse(mesh.get(), trimmedNew)) return false;

auto* undo = UndoManager::getSingleton();
if (!undo) return false;
undo->push(new RenameMorphTargetCommand(entity, oldName, trimmedNew));

emit morphTargetsChanged();
return true;
}

bool MorphAnimationManager::deleteMorphTarget(const QString& name)
{
assertMainThread();
if (name.isEmpty()) return false;

auto* sel = SelectionSet::getSingleton();

Check warning on line 299 in src/MorphAnimationManager.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=AZ43hNISBO_6kV5uGhqJ&open=AZ43hNISBO_6kV5uGhqJ&pullRequest=578
if (!sel) return false;
auto ents = sel->getResolvedEntities();
if (ents.isEmpty() || !ents.first()) return false;
Ogre::Entity* entity = ents.first();
Ogre::MeshPtr mesh = entity->getMesh();
if (!mesh) return false;
if (!nameAlreadyInUse(mesh.get(), name)) return false;

auto* undo = UndoManager::getSingleton();
if (!undo) return false;
undo->push(new DeleteMorphTargetCommand(entity, name));

emit morphTargetsChanged();
return true;
}
23 changes: 23 additions & 0 deletions src/MorphAnimationManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ class MorphAnimationManager : public QObject
Q_INVOKABLE double weightForSelection(const QString& name) const;
Q_INVOKABLE bool setWeightForSelection(const QString& name, double w);

/// Authoring (slice A3). All three push a QUndoCommand on the
/// shared UndoManager stack so Ctrl+Z reverses the change. All
/// return false on no-op (entity missing, name collision, etc.).

/// Create a new morph target whose vertex positions match the
/// current edit state. Snapshots `EditableMesh` (or whatever the
/// current edit state of the selected entity is) against the
/// mesh's bind positions and stores the non-zero deltas as a new
/// Ogre::Pose + matching VAT_POSE Animation. Falls back to a
/// no-op if the user isn't in edit mode for the entity, or no
/// vertex actually moved. `name` must be unique on the mesh.
Q_INVOKABLE bool addMorphTargetFromCurrentEdit(const QString& name);

/// Rename a morph target. Internally destroys + recreates the
/// same-named Pose + Animation under the new name (Ogre 14.5
/// doesn't expose `setName` on Pose).
Q_INVOKABLE bool renameMorphTarget(const QString& oldName,
const QString& newName);

/// Delete a morph target. Drops the matching Pose(s) and
/// Animation, and resets any AnimationState that referenced it.
Q_INVOKABLE bool deleteMorphTarget(const QString& name);

signals:
/// Emitted when a morph weight on any entity is changed via
/// `setWeight`. QML uses this to re-fetch values.
Expand Down
Loading
Loading