diff --git a/src/Assimp/MeshProcessor.cpp b/src/Assimp/MeshProcessor.cpp index 3250f43b..1493b6dd 100644 --- a/src/Assimp/MeshProcessor.cpp +++ b/src/Assimp/MeshProcessor.cpp @@ -129,6 +129,26 @@ SubMeshData* MeshProcessor::processMesh(aiMesh* mesh, const aiScene* scene) { } } + // Process morph targets / blend shapes. Assimp parks each shape's + // deformed positions in `aiAnimMesh::mVertices`; we store them + // verbatim and convert to per-vertex deltas later, at mesh-build + // time, where we already have the base positions in hand for the + // Ogre::Pose constructor. Apply the same Z-up axis bake the base + // vertex pass uses so the shape and base agree on coordinate frame. + for(auto am = 0u; am < mesh->mNumAnimMeshes; am++) { + const aiAnimMesh* anim = mesh->mAnimMeshes[am]; + if (!anim || !anim->mVertices || anim->mNumVertices != mesh->mNumVertices) continue; + MorphTargetData target; + target.name = anim->mName.length > 0 ? anim->mName.C_Str() + : (std::string("Shape_") + std::to_string(am)); + target.positions.reserve(anim->mNumVertices); + for (auto i = 0u; i < anim->mNumVertices; i++) { + Ogre::Vector3 v(anim->mVertices[i].x, anim->mVertices[i].y, anim->mVertices[i].z); + target.positions.push_back(m_isZup ? R_x90 * v : v); + } + subMeshData->morphTargets.push_back(std::move(target)); + } + return subMeshData; } @@ -241,6 +261,83 @@ Ogre::MeshPtr MeshProcessor::createMesh(const Ogre::String& name, const Ogre::St ogreMesh->_setBounds(Ogre::AxisAlignedBox(minCoords, maxCoords)); ogreMesh->_setBoundingSphereRadius((maxCoords - minCoords).length() / 2.0f); + // Morph targets / blend shapes → Ogre::Pose entries on the mesh. + // Build per-submesh delta poses: Ogre `Pose` stores per-vertex + // offsets relative to the base mesh. Each pose targets a specific + // submesh (index 1-based in Ogre; 0 means "shared vertex data", + // which our importer never uses — every aiMesh gets its own + // SubMesh::vertexData). + // + // For each pose we also create a single VAT_POSE animation + // ("MorphTrack_") with a one-keyframe track at full + // influence; the MorphAnimationManager will use the matching + // AnimationState weight to drive live preview without us having + // to mutate vertex buffers ourselves. + for (size_t s = 0; s < subMeshesData.size(); ++s) { + const auto* smd = subMeshesData[s]; + if (smd->morphTargets.empty()) continue; + // Submesh handle is 1-based: 0 = shared, 1..N = per-submesh. + const unsigned short targetSubmesh = static_cast(s + 1); + for (const auto& mt : smd->morphTargets) { + if (mt.positions.size() != smd->vertices.size()) continue; + // Lazy `createPose`: scan for the first non-zero delta + // before allocating the Ogre::Pose. Identical-to-base + // targets (rare with hand-authored content, more common + // with auto-exported FBX) would otherwise leave an empty + // pose on the mesh + an empty Animation referencing it. + Ogre::Pose* pose = nullptr; + for (size_t vi = 0; vi < mt.positions.size(); ++vi) { + const Ogre::Vector3 delta = mt.positions[vi] - smd->vertices[vi]; + if (delta.squaredLength() <= 1e-12f) continue; + if (!pose) pose = ogreMesh->createPose(targetSubmesh, mt.name); + pose->addVertex(vi, delta); + } + } + } + + // One Ogre::Animation per *unique* morph-target name. Same-named + // poses across different submeshes (e.g. a "Smile" target on both + // body and head) all need to drive together off the same + // AnimationState weight, so they share one Animation with one + // VAT_POSE track per affected submesh. Without grouping, the + // second-and-later same-named poses would create a new + // Ogre::Animation but Ogre::Mesh enforces unique animation names — + // we'd either skip them (and they'd never move) or fail to import. + const auto& poseList = ogreMesh->getPoseList(); + for (unsigned short pi = 0; pi < poseList.size(); ++pi) { + const Ogre::Pose* pose = poseList[pi]; + if (!pose) continue; + const Ogre::String animName = pose->getName(); + if (animName.empty()) continue; + + // First sighting of this name → create the Animation. Later + // sightings find it via hasAnimation and just append a track. + Ogre::Animation* anim = nullptr; + if (ogreMesh->hasAnimation(animName)) { + anim = ogreMesh->getAnimation(animName); + } else { + anim = ogreMesh->createAnimation(animName, /*length=*/0.0f); + } + if (!anim) continue; + + Ogre::VertexAnimationTrack* track = nullptr; + const unsigned short handle = pose->getTarget(); + if (anim->hasVertexTrack(handle)) { + // Same submesh + same name → rare content error. Append + // the pose reference to the existing keyframe so the user + // still gets some movement (better than silently dropping). + track = anim->getVertexTrack(handle); + } else { + track = anim->createVertexTrack(handle, Ogre::VAT_POSE); + } + if (!track) continue; + auto* kf = track->getNumKeyFrames() > 0 + ? static_cast(track->getKeyFrame(0)) + : track->createVertexPoseKeyFrame(0.0f); + // Full influence on this pose; AnimationState weight scales it. + kf->addPoseReference(pi, 1.0f); + } + // Compile the mesh ogreMesh->load(); diff --git a/src/Assimp/MeshProcessor.h b/src/Assimp/MeshProcessor.h index ac5f35d8..ef4c112d 100644 --- a/src/Assimp/MeshProcessor.h +++ b/src/Assimp/MeshProcessor.h @@ -4,6 +4,16 @@ #include #include "MaterialProcessor.h" +/// One blend-shape / morph target on a SubMeshData. Stored as the +/// absolute per-vertex positions of the deformed shape (matches what +/// Assimp gives us via `aiAnimMesh::mVertices`); MeshProcessor +/// converts these to per-vertex deltas relative to `vertices` when +/// creating the Ogre::Pose entries at mesh-build time. +struct MorphTargetData { + std::string name; ///< From `aiAnimMesh::mName`; empty when the source had no name. + std::vector positions; ///< Absolute deformed positions, same vertex order as `SubMeshData::vertices`. +}; + struct SubMeshData { std::vector vertices; std::vector normals; @@ -13,6 +23,7 @@ struct SubMeshData { std::vector colors; std::vector indices; std::vector boneAssignments; + std::vector morphTargets; ///< Empty when source had no blend shapes. unsigned int materialIndex; }; diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 862854ad..967b00fe 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -22,6 +22,7 @@ #include "TexturePaintBuffer.h" #include "VertexColorBaker.h" #include "VATBaker.h" +#include "MorphAnimationManager.h" #include "QtMeshCloudClient.h" #include #include @@ -673,6 +674,14 @@ void CLIPipeline::printUsage() " triangle, rasterizes barycentric-interpolated vertex colors,\n" " then dilates outward by N pixels to mask seam bleed at MIP time.\n" " Default resolution=1024, dilation=4. Output PNG is RGBA.\n" + " vat --anim [--fps N] [--encoding rgba8|rgba16]\n" + " [--target agnostic|unity|unreal|godot] [--normals] [-o ] [--json]\n" + " Bake a skeletal animation into a Vertex Animation Texture\n" + " (position PNG + JSON sidecar; optional normal PNG). Engine\n" + " targets: agnostic (default), unity (.meta + UV-V flip),\n" + " unreal (Y/Z axis swap), godot (.gdshader template).\n" + " morph --list [--json] List morph targets / blend shapes on a mesh. (Set/add/delete\n" + " land in follow-up slices once authoring is in place.)\n" "\n" "Global options:\n" " --help, -h Show this help\n" @@ -1067,6 +1076,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "optimize") rc = cmdOptimize(argc, argv); else if (cmd == "bake-vertex-colors") rc = cmdBakeVertexColors(argc, argv); else if (cmd == "vat") rc = cmdVat(argc, argv); + else if (cmd == "morph") rc = cmdMorph(argc, argv); if (rc < 0) { err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; @@ -4946,3 +4956,92 @@ int CLIPipeline::cmdVat(int argc, char* argv[]) } return 0; } + +int CLIPipeline::cmdMorph(int argc, char* argv[]) +{ + // Parse: morph --list [--json] + QString filePath; + bool listMode = false; + bool jsonOutput = false; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "morph" || arg == "--cli") continue; + if (arg == "--list") { listMode = true; continue; } + if (arg == "--json") { jsonOutput = true; continue; } + if (!arg.startsWith("-") && filePath.isEmpty()) { + filePath = arg; continue; + } + } + + if (filePath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh morph --list [--json]" << Qt::endl; + return 2; + } + if (!listMode) { + err() << "Error: morph subcommand requires --list (other modes land in follow-up slices)." << Qt::endl; + return 2; + } + + QFileInfo fi(filePath); + if (!fi.exists()) { + err() << "Error: File not found: " << filePath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb("cli.morph", + QString("Morph list .%1").arg(fi.suffix())); + SentryReporter::addBreadcrumb("file.import", + QString("Importing %1 for morph list").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + + // Walk every imported entity, not just the first — multi-entity + // files (e.g. FBX with separate body + head meshes that both + // carry blend shapes) would otherwise lose the targets on the + // entities the loop missed. + auto& movables = Manager::getSingleton()->getEntities(); + QList entities; + for (auto* obj : movables) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << filePath << Qt::endl; + return 1; + } + + QStringList targets; + QSet seen; + for (Ogre::Entity* entity : entities) { + const QStringList ents = MorphAnimationManager::instance()->morphTargetsFor(entity); + for (const QString& n : ents) { + if (!seen.contains(n)) { + seen.insert(n); + targets.append(n); + } + } + } + + if (jsonOutput) { + QJsonArray arr; + for (const QString& n : targets) arr.append(n); + QJsonObject root; + root["file"] = filePath; + root["count"] = static_cast(targets.size()); + root["morphTargets"] = arr; + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented))); + } else { + if (targets.isEmpty()) { + cliWrite(QStringLiteral("No morph targets / blend shapes found.\n")); + } else { + cliWrite(QStringLiteral("Morph targets (%1):\n").arg(targets.size())); + for (const QString& n : targets) + cliWrite(QStringLiteral(" %1\n").arg(n)); + } + } + return 0; +} diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index b5319f6c..e3742021 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -125,6 +125,11 @@ class CLIPipeline { /// positions only. static int cmdVat(int argc, char* argv[]); + /// List the morph targets / blend shapes on a mesh file. Slice A1 + /// surfaces a `--list` mode only; subsequent slices add `--set`, + /// `--add`, `--delete` once the in-memory authoring path lands. + static int cmdMorph(int argc, char* argv[]); + /// Map file extension to MeshImporterExporter format string. static QString formatForExtension(const QString& path); }; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f2e7a57c..7647888f 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -87,6 +87,7 @@ TexturePaintController.cpp VertexColorBaker.cpp VATBaker.cpp VATBakerController.cpp +MorphAnimationManager.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp new file mode 100644 index 00000000..01c4ee0c --- /dev/null +++ b/src/MorphAnimationManager.cpp @@ -0,0 +1,160 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#include "MorphAnimationManager.h" + +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include + +#include +#include +#include +#include + +#include + +namespace { + +// Per the project's singleton-on-main-thread convention (CLAUDE.md: +// "All run on the main thread."), assert any cross-thread access at +// the lifecycle entry points so a regression surfaces loudly in +// debug builds. +inline void assertMainThread() +{ + Q_ASSERT(QCoreApplication::instance()); + Q_ASSERT(QThread::currentThread() == QCoreApplication::instance()->thread()); +} + +} // namespace + +MorphAnimationManager* MorphAnimationManager::s_instance = nullptr; + +MorphAnimationManager* MorphAnimationManager::instance() +{ + assertMainThread(); + if (!s_instance) s_instance = new MorphAnimationManager(); + return s_instance; +} + +MorphAnimationManager* MorphAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) +{ + assertMainThread(); + return instance(); +} + +void MorphAnimationManager::kill() +{ + assertMainThread(); + if (!s_instance) return; + delete s_instance; + s_instance = nullptr; +} + +MorphAnimationManager::MorphAnimationManager(QObject* parent) : QObject(parent) +{ + if (auto* sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, + this, &MorphAnimationManager::morphTargetsChanged); + } +} + +MorphAnimationManager::~MorphAnimationManager() = default; + +QStringList MorphAnimationManager::morphTargetsFor(Ogre::Entity* entity) const +{ + QStringList out; + if (!entity) return out; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return out; + const auto& poseList = mesh->getPoseList(); + for (const Ogre::Pose* p : poseList) { + if (!p) continue; + const Ogre::String n = p->getName(); + if (!n.empty()) out << QString::fromStdString(n); + } + return out; +} + +float MorphAnimationManager::weight(Ogre::Entity* entity, const QString& name) const +{ + if (!entity || name.isEmpty()) return 0.0f; + auto* states = entity->getAllAnimationStates(); + if (!states) return 0.0f; + const std::string sn = name.toStdString(); + if (!states->hasAnimationState(sn)) return 0.0f; + auto* state = states->getAnimationState(sn); + if (!state) return 0.0f; + // Ogre's AnimationState defaults to weight=1.0 but is disabled + // until first use. From a user-facing perspective, "this morph + // target hasn't been touched yet" should read as 0.0, not 1.0 — + // the slider in the Inspector starts at 0, not at full influence. + // We only surface the live weight once the state is enabled (which + // setWeight() flips), so the contract is: disabled ⇒ 0; enabled ⇒ + // whatever we wrote. + return state->getEnabled() ? state->getWeight() : 0.0f; +} + +bool MorphAnimationManager::setWeight(Ogre::Entity* entity, const QString& name, float w) +{ + if (!entity || name.isEmpty()) return false; + auto* states = entity->getAllAnimationStates(); + if (!states) return false; + const std::string sn = name.toStdString(); + if (!states->hasAnimationState(sn)) return false; + auto* state = states->getAnimationState(sn); + if (!state) return false; + + const float clamped = std::clamp(w, 0.0f, 1.0f); + const float current = state->getWeight(); + const bool wasEnabled = state->getEnabled(); + if (std::abs(clamped - current) < 1e-6f && wasEnabled) return true; + + state->setEnabled(true); + state->setWeight(clamped); + // The pose track has its only keyframe at t=0; pin the state + // there so the weight actually drives the pose. + state->setTimePosition(0.0f); + + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("set '%1' weight = %2").arg(name).arg(clamped, 0, 'f', 3)); + + emit morphWeightChanged(entity, name, static_cast(clamped)); + return true; +} + +QStringList MorphAnimationManager::morphTargetsForSelection() const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return {}; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty()) return {}; + return morphTargetsFor(ents.first()); +} + +double MorphAnimationManager::weightForSelection(const QString& name) const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return 0.0; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty()) return 0.0; + return static_cast(weight(ents.first(), name)); +} + +bool MorphAnimationManager::setWeightForSelection(const QString& name, double w) +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty()) return false; + return setWeight(ents.first(), name, static_cast(w)); +} diff --git a/src/MorphAnimationManager.h b/src/MorphAnimationManager.h new file mode 100644 index 00000000..b271dc4d --- /dev/null +++ b/src/MorphAnimationManager.h @@ -0,0 +1,88 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#ifndef MORPHANIMATIONMANAGER_H +#define MORPHANIMATIONMANAGER_H + +#include +#include +#include +#include +#include + +namespace Ogre { class Entity; } + +/** + * @brief QML_SINGLETON owning per-entity morph-target weight state. + * + * Wraps the Ogre::Pose + per-pose VAT_POSE animation pattern the + * importer (MeshProcessor) sets up. Each named morph target on an + * entity is backed by an `Ogre::Animation` of the same name; weight = + * the matching `Ogre::AnimationState::getWeight()`. + * + * Slice A1 surface — minimal but complete enough that the rest of + * the slice (Inspector sliders, dope sheet, authoring, export) can + * build on top: + * - `morphTargetsFor(entity)` — list of target names. + * - `weight(entity, name)` / `setWeight(entity, name, w)` — + * read / write a single weight. + * - Signals on change so QML can bind. + * + * Authoring (create new targets from edit-mode deltas), export, and + * dope-sheet wiring come in follow-up sub-slices. + */ +class MorphAnimationManager : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static MorphAnimationManager* instance(); + static MorphAnimationManager* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// Enumerate morph-target names on an entity, in mesh-pose-list order. + /// Returns empty when the entity has no morph targets or is null. + QStringList morphTargetsFor(Ogre::Entity* entity) const; + + /// Read the current weight for `name` on `entity`. Returns 0.0 + /// when the entity has no such target or weight tracking isn't + /// available (e.g. the per-pose AnimationState wasn't found). + float weight(Ogre::Entity* entity, const QString& name) const; + + /// Set a single morph-target weight. Enables the matching + /// AnimationState so the weight actually applies, and clamps + /// the input to [0..1]. Emits `morphWeightChanged` on real + /// changes. Returns false when the entity has no such target. + bool setWeight(Ogre::Entity* entity, const QString& name, float w); + + /// QML-friendly variants that resolve the entity from + /// SelectionSet's first entity. Used by the Inspector subgroup. + Q_INVOKABLE QStringList morphTargetsForSelection() const; + Q_INVOKABLE double weightForSelection(const QString& name) const; + Q_INVOKABLE bool setWeightForSelection(const QString& name, double w); + +signals: + /// Emitted when a morph weight on any entity is changed via + /// `setWeight`. QML uses this to re-fetch values. + void morphWeightChanged(Ogre::Entity* entity, const QString& name, double weight); + /// Emitted when the morph-target list visible to the Inspector + /// could have changed (selection moved, scene reloaded, etc.). + void morphTargetsChanged(); + +private: + explicit MorphAnimationManager(QObject* parent = nullptr); + ~MorphAnimationManager() override; + + static MorphAnimationManager* s_instance; +}; + +#endif // MORPHANIMATIONMANAGER_H diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp new file mode 100644 index 00000000..06bca351 --- /dev/null +++ b/src/MorphAnimationManager_test.cpp @@ -0,0 +1,254 @@ +#include + +#include + +#include "Manager.h" +#include "MorphAnimationManager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Build a tiny mesh with two named morph targets attached as Ogre::Pose +// + per-pose VAT_POSE animations, mirroring what `MeshProcessor` does +// at import time. Same shape the manager is contracted against, so +// the test exercises the public API end-to-end without dragging in +// Assimp. +Ogre::MeshPtr createMorphTestMesh(const std::string& name) +{ + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + sub->useSharedVertices = false; + sub->vertexData = new Ogre::VertexData(); + auto* decl = sub->vertexData->vertexDeclaration; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = { + 0,0,0, 0,0,1, + 1,0,0, 0,0,1, + 0,1,0, 0,0,1, + }; + vbuf->writeData(0, sizeof(verts), verts); + sub->vertexData->vertexBufferBinding->setBinding(0, vbuf); + sub->vertexData->vertexCount = 3; + + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t idx[] = {0, 1, 2}; + ibuf->writeData(0, sizeof(idx), idx); + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,2)); + mesh->_setBoundingSphereRadius(2.0f); + mesh->load(); + + // Two named poses on submesh 1. + { + Ogre::Pose* p = mesh->createPose(/*target=*/1, "JawOpen"); + p->addVertex(0, Ogre::Vector3(0, -0.1f, 0)); + } + { + Ogre::Pose* p = mesh->createPose(/*target=*/1, "Smile"); + p->addVertex(1, Ogre::Vector3(0.05f, 0.02f, 0)); + p->addVertex(2, Ogre::Vector3(-0.05f, 0.02f, 0)); + } + + // One Animation per pose, mirroring MeshProcessor's pattern. + const auto& poses = mesh->getPoseList(); + for (unsigned short pi = 0; pi < poses.size(); ++pi) { + Ogre::Animation* a = mesh->createAnimation(poses[pi]->getName(), 0.0f); + auto* track = a->createVertexTrack(poses[pi]->getTarget(), Ogre::VAT_POSE); + auto* kf = track->createVertexPoseKeyFrame(0.0f); + kf->addPoseReference(pi, 1.0f); + } + + return mesh; +} + +} // namespace + +// ============================================================================= +// Standalone (no Ogre) +// ============================================================================= + +TEST(MorphAnimationManagerStandalone, InstanceIsSingleton) { + auto* a = MorphAnimationManager::instance(); + auto* b = MorphAnimationManager::instance(); + EXPECT_EQ(a, b); + EXPECT_NE(a, nullptr); +} + +TEST(MorphAnimationManagerStandalone, NullEntityReturnsEmptyAndZero) { + auto* m = MorphAnimationManager::instance(); + EXPECT_TRUE(m->morphTargetsFor(nullptr).isEmpty()); + EXPECT_FLOAT_EQ(m->weight(nullptr, QStringLiteral("JawOpen")), 0.0f); + EXPECT_FALSE(m->setWeight(nullptr, QStringLiteral("JawOpen"), 0.5f)); +} + +TEST(MorphAnimationManagerStandalone, EmptyNameRejected) { + auto* m = MorphAnimationManager::instance(); + EXPECT_FALSE(m->setWeight(nullptr, QString(), 0.5f)); +} + +// ============================================================================= +// Scene fixture — tests need a real Ogre entity with poses. +// ============================================================================= + +class MorphAnimationManagerSceneTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ASSERT_TRUE(tryInitOgre()); + ASSERT_TRUE(canLoadMeshFiles()); + if (auto* sel = SelectionSet::getSingleton()) sel->clear(); + } + void TearDown() override + { + if (auto* sel = SelectionSet::getSingleton()) sel->clear(); + if (auto* mgr = Manager::getSingletonPtr()) { + if (auto* scene = mgr->getSceneMgr()) { + try { scene->destroyAllEntities(); } catch (...) {} + try { scene->getRootSceneNode()->removeAndDestroyAllChildren(); } catch (...) {} + } + } + } +}; + +TEST_F(MorphAnimationManagerSceneTest, ListsMorphTargetsInPoseOrder) { + auto mesh = createMorphTestMesh("Morph_ListOrder"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_ListOrderEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + QStringList names = m->morphTargetsFor(entity); + ASSERT_EQ(names.size(), 2); + EXPECT_EQ(names[0], QStringLiteral("JawOpen")); + EXPECT_EQ(names[1], QStringLiteral("Smile")); +} + +TEST_F(MorphAnimationManagerSceneTest, WeightDefaultIsZeroBeforeSet) { + auto mesh = createMorphTestMesh("Morph_DefaultZero"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_DefaultZeroEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("JawOpen")), 0.0f); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("Smile")), 0.0f); +} + +TEST_F(MorphAnimationManagerSceneTest, SetWeightStoresClampedValue) { + auto mesh = createMorphTestMesh("Morph_SetClamp"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SetClampEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.75f)); + EXPECT_NEAR(m->weight(entity, QStringLiteral("JawOpen")), 0.75f, 1e-5f); + // Clamp to [0..1]. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("Smile"), 2.0f)); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("Smile")), 1.0f); + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("Smile"), -0.5f)); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("Smile")), 0.0f); +} + +TEST_F(MorphAnimationManagerSceneTest, SetWeightUnknownNameReturnsFalse) { + auto mesh = createMorphTestMesh("Morph_Unknown"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_UnknownEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_FALSE(m->setWeight(entity, QStringLiteral("NotARealShape"), 0.5f)); +} + +TEST_F(MorphAnimationManagerSceneTest, SetWeightEmitsSignalOnRealChange) { + auto mesh = createMorphTestMesh("Morph_Signal"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SignalEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + QSignalSpy spy(m, &MorphAnimationManager::morphWeightChanged); + + // First write emits. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f)); + const int afterFirst = spy.count(); + EXPECT_GE(afterFirst, 1); + + // Idempotent write must NOT emit again — same value, same enabled state. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f)); + EXPECT_EQ(spy.count(), afterFirst); + + // Different value DOES emit. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.7f)); + EXPECT_GT(spy.count(), afterFirst); +} + +TEST_F(MorphAnimationManagerSceneTest, EmptyNameRejectedForValidEntity) { + // Empty-name rejection is also exercised standalone with a null + // entity, but a passing test there could hide a regression where + // empty-name handling becomes entity-dependent. Re-test with a + // valid live entity to lock the contract. + auto mesh = createMorphTestMesh("Morph_EmptyName"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_EmptyNameEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_FALSE(m->setWeight(entity, QString(), 0.5f)); + EXPECT_FLOAT_EQ(m->weight(entity, QString()), 0.0f); +} + +TEST_F(MorphAnimationManagerSceneTest, SelectionDrivenAccessorsResolveFirstEntity) { + auto mesh = createMorphTestMesh("Morph_Sel"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SelEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* sel = SelectionSet::getSingleton(); + ASSERT_NE(sel, nullptr); + sel->append(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_EQ(m->morphTargetsForSelection().size(), 2); + EXPECT_TRUE(m->setWeightForSelection(QStringLiteral("Smile"), 0.6)); + EXPECT_NEAR(m->weightForSelection(QStringLiteral("Smile")), 0.6, 1e-4); +} + +TEST_F(MorphAnimationManagerSceneTest, NoSelectionGivesEmptyList) { + auto* m = MorphAnimationManager::instance(); + EXPECT_TRUE(m->morphTargetsForSelection().isEmpty()); + EXPECT_DOUBLE_EQ(m->weightForSelection(QStringLiteral("X")), 0.0); + EXPECT_FALSE(m->setWeightForSelection(QStringLiteral("X"), 0.5)); +} diff --git a/src/main.cpp b/src/main.cpp index 1dc1f2f3..26de0a06 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -92,7 +92,7 @@ int main(int argc, char *argv[]) || arg == "analyze" || arg == "vertex-cache" || arg == "decimate" || arg == "atlas" || arg == "atlas-apply" || arg == "optimize" || arg == "bake-vertex-colors" - || arg == "vat") + || arg == "vat" || arg == "morph") cliMode = true; break; // first non-flag arg determines mode } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index b37c5072..c757a87c 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -75,6 +75,7 @@ #include "TexturePaintController.h" #include "PaintBufferImageProvider.h" #include "VATBakerController.h" +#include "MorphAnimationManager.h" #include "EditorModeController.h" #include #include @@ -547,6 +548,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return VATBakerController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "MorphAnimationManager", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return MorphAnimationManager::qmlInstance(engine, nullptr); + }); // Same image provider the detached editor window uses — serves the // live paint buffer as a QImage view (no PNG encode, no base64). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 633e6e3f..a5547ba0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -96,6 +96,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBakerController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MorphAnimationManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ApplyAtlas.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EmbeddedTextureCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp