From cf9ffb04db1d1a35b83030e73ee9bf108cb57397 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 00:06:00 -0400 Subject: [PATCH 01/22] =?UTF-8?q?feat(#519):=20Anim=20Slice=20B1=20?= =?UTF-8?q?=E2=80=94=20VertexAnimationManager=20+=20VAT=5FPOSE=20playback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First sub-slice of mesh/vertex animation (epic #517, issue #519). Lands the manager + the CPU-side "decoded frame-set → Ogre VAT_POSE clip" builder and playback/enumeration, all reachable WITHOUT the Alembic dependency so it builds and tests on every platform today. The real Alembic reader (behind -DENABLE_ALEMBIC) is B2; disk-streaming + CLI/MCP + .abc→FBX convert is B3. - VertexAnimationManager (QML_SINGLETON, mirrors MorphAnimationManager): - FrameSet/FrameData pure-data types (a decoded per-vertex cache). - sampleHeuristic(frameCount): <32 → VAT_POSE poses, else stream (issue's rule; static + unit-tested). - buildClipFromFrames(mesh, name, frames): reads submesh-0 bind positions, creates one Ogre::Pose per frame (delta vs bind) + one VAT_POSE track keyed per frame time. Reuses Ogre's existing pose-blended playback, so the timeline scrubber / loop / dope sheet work with no new playback code. - hasVertexAnimation / vertexClipsFor (+ SelectionSet-resolved QML variants) to drive the "Mesh" dope-sheet row. - Sentry breadcrumb scene.anim.vertex_anim. - ENABLE_ALEMBIC cmake option (default OFF; -DENABLE_ALEMBIC define) + guarded message; cmake/Alembic.cmake wired in B2. - Registered as a QML singleton in mainwindow alongside MorphAnimationManager. - Headless-CI tests: heuristic threshold, FrameSet validity, clip construction (poses+track+length), vertex-count-mismatch rejection, entity enumeration — driven by a synthetic cube-wobble buffer (no Alembic, no GL for the pure ones). Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 19 +++ src/CMakeLists.txt | 1 + src/VertexAnimationManager.cpp | 210 ++++++++++++++++++++++++++++ src/VertexAnimationManager.h | 131 +++++++++++++++++ src/VertexAnimationManager_test.cpp | 170 ++++++++++++++++++++++ src/mainwindow.cpp | 5 + 6 files changed, 536 insertions(+) create mode 100644 src/VertexAnimationManager.cpp create mode 100644 src/VertexAnimationManager.h create mode 100644 src/VertexAnimationManager_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d0d80c0b..31a8733d 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -274,6 +274,25 @@ if(ENABLE_ONNX) message(STATUS "AI PBR map synthesis enabled with ONNX Runtime") endif() ############################################################## +# Alembic — mesh / vertex animation import (#519, Anim Slice B) +############################################################## +# Vendored (BSD) reader for baked per-vertex caches (cloth / sims / fluids / +# Houdini + Blender exports). Default OFF for now: the Alembic + Imath build is +# non-trivial across the three target platforms, so Slice B lands in sub-slices +# — B1 (VertexAnimationManager + Ogre VAT_POSE playback + tests) needs NO +# Alembic and builds/tests everywhere; B2 flips this ON for desktop and wires +# cmake/Alembic.cmake (FetchContent). The C++ #ifdef ENABLE_ALEMBIC guards the +# reader so a build without it still compiles and skips .abc with a clear message. +option(ENABLE_ALEMBIC "Enable Alembic (.abc) vertex-animation import" OFF) + +if(ENABLE_ALEMBIC) + include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Alembic.cmake) + add_definitions(-DENABLE_ALEMBIC) + message(STATUS "Alembic vertex-animation import enabled") +else() + message(STATUS "Alembic vertex-animation import disabled (VAT_POSE playback still available)") +endif() +############################################################## # PS1 runtime geometry extraction (experimental) ############################################################## option(ENABLE_PS1_RIP "Enable experimental PS1 runtime geometry extraction" OFF) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0477eb1c..801cc1ea 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -135,6 +135,7 @@ HDR/HdrBundledLibrary.cpp MorphAnimationManager.cpp NodeAnimationManager.cpp PoseLibrary.cpp +VertexAnimationManager.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp diff --git a/src/VertexAnimationManager.cpp b/src/VertexAnimationManager.cpp new file mode 100644 index 00000000..27ed359d --- /dev/null +++ b/src/VertexAnimationManager.cpp @@ -0,0 +1,210 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#include "VertexAnimationManager.h" + +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Singletons run on the main thread (CLAUDE.md). Assert at lifecycle entry. +inline void assertMainThread() +{ + Q_ASSERT(QCoreApplication::instance()); + Q_ASSERT(QThread::currentThread() == QCoreApplication::instance()->thread()); +} + +// The issue's cutoff: caches below this frame count store as blend-able poses. +constexpr int kPoseStorageMaxFrames = 32; + +// Read submesh-0 (or shared) bind positions into a flat xyz array. Returns the +// vertex count (0 on failure). Mirrors gatherGeometry's position walk but for a +// single target-geometry the vertex-anim poses are built against. +int readBindPositions(Ogre::Mesh* mesh, std::vector& out) +{ + out.clear(); + if (!mesh || mesh->getNumSubMeshes() == 0) return 0; + Ogre::SubMesh* sub = mesh->getSubMesh(0); + Ogre::VertexData* vd = sub->useSharedVertices ? mesh->sharedVertexData + : sub->vertexData; + if (!vd) return 0; + const Ogre::VertexElement* posElem = + vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (!posElem) return 0; + auto vbuf = vd->vertexBufferBinding->getBuffer(posElem->getSource()); + if (!vbuf) return 0; + + const size_t count = vd->vertexCount; + out.resize(count * 3); + auto* base = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t v = 0; v < count; ++v) { + float* p = nullptr; + posElem->baseVertexPointerToElement(base + v * vbuf->getVertexSize(), &p); + out[v * 3 + 0] = p[0]; + out[v * 3 + 1] = p[1]; + out[v * 3 + 2] = p[2]; + } + vbuf->unlock(); + return static_cast(count); +} + +} // namespace + +VertexAnimationManager* VertexAnimationManager::s_instance = nullptr; + +VertexAnimationManager* VertexAnimationManager::instance() +{ + assertMainThread(); + if (!s_instance) s_instance = new VertexAnimationManager(); + return s_instance; +} + +VertexAnimationManager* VertexAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) +{ + assertMainThread(); + return instance(); +} + +void VertexAnimationManager::kill() +{ + assertMainThread(); + if (!s_instance) return; + delete s_instance; + s_instance = nullptr; +} + +VertexAnimationManager::VertexAnimationManager(QObject* parent) : QObject(parent) +{ + if (auto* sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, + this, &VertexAnimationManager::vertexAnimationsChanged); + } +} + +VertexAnimationManager::~VertexAnimationManager() = default; + +VertexAnimationManager::Storage VertexAnimationManager::sampleHeuristic(int frameCount) +{ + return frameCount < kPoseStorageMaxFrames ? Storage::Poses : Storage::Stream; +} + +bool VertexAnimationManager::buildClipFromFrames(Ogre::Mesh* mesh, + const QString& clipName, + const FrameSet& frames) +{ + if (!mesh || clipName.isEmpty() || !frames.ok()) + return false; + + std::vector bind; + const int vcount = readBindPositions(mesh, bind); + if (vcount <= 0 || vcount != frames.vertexCount) + return false; + + const std::string animName = clipName.toStdString(); + if (mesh->hasAnimation(animName)) + mesh->removeAnimation(animName); + + // VAT_POSE targets submesh handle 1 (submesh 0); 0 is shared geometry. + Ogre::SubMesh* sub = mesh->getSubMesh(0); + const unsigned short target = sub->useSharedVertices ? 0 : 1; + + const float length = frames.frames.back().time - frames.frames.front().time; + Ogre::Animation* anim = + mesh->createAnimation(animName, length > 0.0f ? length : 0.0f); + if (!anim) return false; + Ogre::VertexAnimationTrack* track = + anim->createVertexTrack(target, Ogre::VAT_POSE); + if (!track) { mesh->removeAnimation(animName); return false; } + + const float t0 = frames.frames.front().time; + // One Pose per frame (delta vs bind) + one keyframe at that frame's time + // referencing it at full weight. VAT_POSE interpolates the pose references + // between consecutive keyframes, so scrubbing the timeline blends frames. + for (size_t f = 0; f < frames.frames.size(); ++f) { + const FrameData& fd = frames.frames[f]; + if (static_cast(fd.positions.size()) != vcount * 3) { + mesh->removeAnimation(animName); + return false; + } + const unsigned short poseIndex = + static_cast(mesh->getPoseCount()); + Ogre::Pose* pose = mesh->createPose( + target, clipName.toStdString() + "/frame" + std::to_string(f)); + for (int v = 0; v < vcount; ++v) { + const Ogre::Vector3 delta(fd.positions[v * 3 + 0] - bind[v * 3 + 0], + fd.positions[v * 3 + 1] - bind[v * 3 + 1], + fd.positions[v * 3 + 2] - bind[v * 3 + 2]); + // Store every vertex (dense by nature); a zero delta is harmless. + pose->addVertex(static_cast(v), delta); + } + auto* kf = track->createVertexPoseKeyFrame(fd.time - t0); + kf->addPoseReference(poseIndex, 1.0f); + } + + mesh->load(); + SentryReporter::addBreadcrumb( + QStringLiteral("scene.anim.vertex_anim"), + QStringLiteral("built VAT_POSE clip '%1' — %2 frames, %3 verts") + .arg(clipName).arg(frames.frames.size()).arg(vcount)); + return true; +} + +bool VertexAnimationManager::hasVertexAnimation(Ogre::Entity* entity) const +{ + return !vertexClipsFor(entity).isEmpty(); +} + +QStringList VertexAnimationManager::vertexClipsFor(Ogre::Entity* entity) const +{ + QStringList out; + if (!entity || !entity->getMesh()) return out; + Ogre::MeshPtr mesh = entity->getMesh(); + for (unsigned short i = 0; i < mesh->getNumAnimations(); ++i) { + Ogre::Animation* a = mesh->getAnimation(i); + if (!a) continue; + // A vertex-anim clip is a mesh Animation carrying a vertex track. (This + // also matches morph clips; the "Mesh" dope-sheet row treats them the + // same — a single scrubbable vertex row.) + bool hasVertexTrack = false; + for (const auto& kv : a->_getVertexTrackList()) { + if (kv.second) { hasVertexTrack = true; break; } + } + if (hasVertexTrack) + out << QString::fromStdString(a->getName()); + } + return out; +} + +QStringList VertexAnimationManager::vertexClipsForSelection() const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return {}; + const auto entities = sel->getResolvedEntities(); + return entities.isEmpty() ? QStringList{} : vertexClipsFor(entities.first()); +} + +bool VertexAnimationManager::selectionHasVertexAnimation() const +{ + return !vertexClipsForSelection().isEmpty(); +} diff --git a/src/VertexAnimationManager.h b/src/VertexAnimationManager.h new file mode 100644 index 00000000..16f295f4 --- /dev/null +++ b/src/VertexAnimationManager.h @@ -0,0 +1,131 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#ifndef VERTEXANIMATIONMANAGER_H +#define VERTEXANIMATIONMANAGER_H + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace Ogre { class Entity; class Mesh; } + +/** + * @brief Full-mesh (per-vertex) animation — Anim epic Slice B (#519). + * + * Where MorphAnimationManager (Slice A) drives a handful of NAMED blend-shape + * weights, this manager owns clips where EVERY vertex moves per frame with no + * skeleton — cloth, sims, fluid bakes, destruction, and Alembic caches from + * Houdini / Blender. + * + * Both use Ogre's VertexAnimationTrack; the difference is intent + density: a + * morph clip has a few poses the user blends, a vertex-anim clip has one dense + * per-frame shape. We reuse Ogre's VAT_POSE path (one Pose per frame, keyed at + * full weight at that frame's time) which the viewport/timeline already play — + * so the timeline scrubber, loop, and dope sheet work with no new playback code. + * + * SUB-SLICE LAYERING (see issue #519 / the plan): + * - B1 (this file): the manager + the CPU-side "sample buffer -> Ogre + * VAT_POSE Animation on the mesh" builder + playback/dope-sheet wiring + + * headless tests, all reachable WITHOUT the Alembic dependency (a synthetic + * buffer is enough to exercise + test the whole path). + * - B2: the real Alembic reader (behind -DENABLE_ALEMBIC) fills a sample + * buffer and hands it to `buildClipFromFrames`. + * - B3: disk-streaming for caches too large to hold resident, CLI/MCP, and + * .abc -> FBX vertex-cache convert. + * + * The pure-data core (`FrameData`, `buildClipFromFrames`, `sampleHeuristic`) + * takes flat float arrays and an Ogre::Mesh, so it is unit-testable under + * headless CI with a synthetic cube-wobble buffer — no Alembic, no GL. + */ +class VertexAnimationManager : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static VertexAnimationManager* instance(); + static VertexAnimationManager* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// How a vertex-anim clip is stored on the mesh. The issue's heuristic: + /// small caches become blend-able poses (cheap, GPU-friendly); large ones + /// would stream (B3). `sampleHeuristic()` picks per the frame count. + enum class Storage { Poses, Stream }; + + /// One decoded frame's vertex data. Positions are flat xyz triples in the + /// mesh's own space, `vertexCount` long. Optional per-frame normals (same + /// layout) improve shading on deforming meshes; empty = recompute/none. + struct FrameData { + float time = 0.0f; ///< seconds from clip start + std::vector positions; ///< 3 * vertexCount + std::vector normals; ///< 0 or 3 * vertexCount + }; + + /// Decoded, source-agnostic vertex-animation clip (what an Alembic reader, + /// or the synthetic generator in tests, produces). Pure data. + struct FrameSet { + int vertexCount = 0; + int fps = 30; + std::vector frames; ///< time-ordered, >= 2 to animate + std::array aabb{ {0,0,0,0,0,0} }; ///< minXYZ, maxXYZ over all frames + bool ok() const { return vertexCount > 0 && frames.size() >= 2; } + }; + + /// Pure-data heuristic (issue #519): < 32 keyframes -> pose blending, + /// otherwise a streamed/flat path. Public + static so it is unit-tested. + static Storage sampleHeuristic(int frameCount); + + /// Build an Ogre VAT_POSE Animation named `clipName` on `mesh` from a + /// decoded FrameSet: one Pose per frame (delta vs. the mesh's bind + /// positions) with a VertexAnimationTrack keyed to full weight at each + /// frame's time. The mesh must already have `frames.vertexCount` vertices + /// in submesh 0 / shared geometry. Returns false (and adds nothing) on + /// mismatch. Ogre-only, no GL required — safe under headless CI. + /// + /// This is the Storage::Poses path. Storage::Stream (large caches) is B3; + /// until then a too-large FrameSet still builds poses (correct, just + /// heavier) so nothing silently fails. + static bool buildClipFromFrames(Ogre::Mesh* mesh, + const QString& clipName, + const FrameSet& frames); + + /// True when `entity` has at least one VAT_POSE (vertex-anim or morph) + /// animation. Used by the UI to show the "Mesh" dope-sheet row. + bool hasVertexAnimation(Ogre::Entity* entity) const; + + /// Names of the vertex-animation clips on `entity` (Ogre animations that + /// carry a vertex track). Empty when none / null. + QStringList vertexClipsFor(Ogre::Entity* entity) const; + + /// QML-friendly variants resolving the entity from SelectionSet. + Q_INVOKABLE QStringList vertexClipsForSelection() const; + Q_INVOKABLE bool selectionHasVertexAnimation() const; + +signals: + /// Emitted when the vertex-anim clip list visible to the UI could have + /// changed (import, selection moved, scene reloaded). + void vertexAnimationsChanged(); + +private: + explicit VertexAnimationManager(QObject* parent = nullptr); + ~VertexAnimationManager() override; + + static VertexAnimationManager* s_instance; +}; + +#endif // VERTEXANIMATIONMANAGER_H diff --git a/src/VertexAnimationManager_test.cpp b/src/VertexAnimationManager_test.cpp new file mode 100644 index 00000000..d785ad85 --- /dev/null +++ b/src/VertexAnimationManager_test.cpp @@ -0,0 +1,170 @@ +#include + +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" +#include "VertexAnimationManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +// A minimal static mesh (one triangle, 3 verts) the vertex-anim clip is built +// against — same shape MeshGenBuilder/MeshProcessor produce (non-shared +// submesh, POSITION+NORMAL). Vertex-anim poses target submesh handle 1. +Ogre::MeshPtr createStaticMesh(const std::string& name, int nverts = 3) +{ + 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), nverts, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + std::vector verts(static_cast(nverts) * 6, 0.0f); + for (int v = 0; v < nverts; ++v) { + verts[v * 6 + 0] = static_cast(v); // x = index — distinct bind + verts[v * 6 + 5] = 1.0f; // normal +Z + } + vbuf->writeData(0, verts.size() * sizeof(float), verts.data()); + sub->vertexData->vertexBufferBinding->setBinding(0, vbuf); + sub->vertexData->vertexCount = static_cast(nverts); + + 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, 4, 4, 4)); + mesh->_setBoundingSphereRadius(4.0f); + mesh->load(); + return mesh; +} + +// A synthetic vertex-animation buffer: `frameCount` frames of `nverts` vertices +// wobbling in Y over time — the stand-in for a decoded Alembic cache. Bind +// positions match createStaticMesh (x=index), so frame deltas are pure Y. +VertexAnimationManager::FrameSet makeWobble(int nverts, int frameCount, int fps = 30) +{ + VertexAnimationManager::FrameSet fs; + fs.vertexCount = nverts; + fs.fps = fps; + for (int f = 0; f < frameCount; ++f) { + VertexAnimationManager::FrameData fd; + fd.time = static_cast(f) / static_cast(fps); + fd.positions.resize(static_cast(nverts) * 3); + for (int v = 0; v < nverts; ++v) { + fd.positions[v * 3 + 0] = static_cast(v); + fd.positions[v * 3 + 1] = + 0.5f * std::sin(static_cast(f) * 0.5f + static_cast(v)); + fd.positions[v * 3 + 2] = 0.0f; + } + fs.frames.push_back(std::move(fd)); + } + return fs; +} + +} // namespace + +// ============================================================================= +// Pure-data (no Ogre) — heuristic + FrameSet validity +// ============================================================================= + +TEST(VertexAnimationManagerStandalone, InstanceIsSingleton) { + EXPECT_EQ(VertexAnimationManager::instance(), VertexAnimationManager::instance()); +} + +TEST(VertexAnimationManagerStandalone, HeuristicSplitsAtThreshold) { + using S = VertexAnimationManager::Storage; + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(2), S::Poses); + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(31), S::Poses); + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(32), S::Stream); + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(1000), S::Stream); +} + +TEST(VertexAnimationManagerStandalone, FrameSetOkRequiresTwoFrames) { + EXPECT_FALSE(VertexAnimationManager::FrameSet{}.ok()); + auto one = makeWobble(3, 1); + EXPECT_FALSE(one.ok()); + auto two = makeWobble(3, 2); + EXPECT_TRUE(two.ok()); +} + +// ============================================================================= +// Ogre-backed — clip construction + enumeration (headless-safe) +// ============================================================================= + +class VertexAnimationManagerTest : public ::testing::Test { +protected: + void SetUp() override { + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + } + void TearDown() override { + auto& mm = Ogre::MeshManager::getSingleton(); + for (const char* n : {"vam_build", "vam_enum", "vam_mismatch"}) + if (mm.resourceExists(n)) mm.remove(n); + } +}; + +TEST_F(VertexAnimationManagerTest, BuildClipFromFramesCreatesPosesAndTrack) { + auto mesh = createStaticMesh("vam_build", 3); + auto fs = makeWobble(3, 8); + + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames(mesh.get(), "wobble", fs)); + + // One Animation named "wobble" with a vertex track and one pose per frame. + ASSERT_TRUE(mesh->hasAnimation("wobble")); + Ogre::Animation* a = mesh->getAnimation("wobble"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(mesh->getPoseCount(), 8u); // one pose per frame + Ogre::VertexAnimationTrack* track = a->getVertexTrack(1); // submesh handle 1 + ASSERT_NE(track, nullptr); + EXPECT_EQ(track->getAnimationType(), Ogre::VAT_POSE); + EXPECT_EQ(track->getNumKeyFrames(), 8u); // one keyframe per frame + // Clip length spans the frame times (8 frames @30fps → 7/30 s). + EXPECT_NEAR(a->getLength(), 7.0f / 30.0f, 1e-4f); +} + +TEST_F(VertexAnimationManagerTest, BuildClipRejectsVertexCountMismatch) { + auto mesh = createStaticMesh("vam_mismatch", 3); + auto fs = makeWobble(5, 4); // 5 verts vs the mesh's 3 + EXPECT_FALSE(VertexAnimationManager::buildClipFromFrames(mesh.get(), "bad", fs)); + EXPECT_FALSE(mesh->hasAnimation("bad")); +} + +TEST_F(VertexAnimationManagerTest, EnumeratesVertexClipsOnEntity) { + auto mesh = createStaticMesh("vam_enum", 3); + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "sim", makeWobble(3, 4))); + + Ogre::SceneManager* sm = Manager::getSingleton()->getSceneMgr(); + Ogre::Entity* ent = sm->createEntity("vam_enum_ent", "vam_enum"); + + auto* vam = VertexAnimationManager::instance(); + EXPECT_TRUE(vam->hasVertexAnimation(ent)); + const QStringList clips = vam->vertexClipsFor(ent); + ASSERT_EQ(clips.size(), 1); + EXPECT_EQ(clips.first(), QStringLiteral("sim")); + + sm->destroyEntity(ent); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7351a603..fe118988 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -127,6 +127,7 @@ #include "IsometricSpritesController.h" #include "ImageTo3D/MeshGenController.h" #include "MorphAnimationManager.h" +#include "VertexAnimationManager.h" #include "EditorModeController.h" #include "QtMeshCloudClient.h" #include @@ -866,6 +867,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return MorphAnimationManager::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "VertexAnimationManager", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return VertexAnimationManager::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). From 755e43d15389cba54336bd6615b62dd54f3a8268 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 00:24:59 -0400 Subject: [PATCH 02/22] fix(#519): add VertexAnimationManager.cpp to the test-common library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test build's qtmesh_test_common static lib (tests/CMakeLists.txt) uses an explicit source list, not the app's SRC_FILES. mainwindow.cpp — which is in that lib and now references VertexAnimationManager::qmlInstance — linked against a missing symbol (staticMetaObject / qmlInstance) because the new .cpp wasn't compiled/moc'd into the lib. Add it next to the sibling anim managers. Verified: UnitTests links after a fresh reconfigure. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e6844bec..24e8490d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -131,6 +131,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/NodeAnimCommands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PoseLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/PoseLibraryCommands.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexAnimationManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ComputeSkinWeightsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AutoRigCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/UVEditCommand.cpp From 19fa1fd7c9b5a6b131494778cb28251488c18bba Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 02:58:39 -0400 Subject: [PATCH 03/22] =?UTF-8?q?feat(#519):=20Anim=20Slice=20B2=20?= =?UTF-8?q?=E2=80=94=20Alembic=20(.abc)=20vertex-cache=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second sub-slice: the real Alembic import behind -DENABLE_ALEMBIC, feeding B1's buildClipFromFrames. Default stays OFF so normal/CI platform builds are unaffected and fast; the Linux coverage lane flips it ON so the round-trip test actually runs. - cmake/Alembic.cmake: FetchContent Imath 3.1.12 + Alembic 1.8.8 (both BSD-3-Clause), fully static, all optional components off (no HDF5 / Python / tests / binaries / examples). Satisfies Alembic's find_package(Imath) by pointing Imath_DIR at the subproject build config. IMATH_INSTALL stays ON because Alembic's install(EXPORT AlembicTargets) is unconditional and references Imath — Imath must be in an export set or CMake's generate step fails. Wrapped as a stable `qtmesh_alembic` interface target. - AlembicImporter.{h,cpp}: readFrameSet() decodes the first IPolyMesh in an archive into B1's source-agnostic FrameSet (pure data — rejects variable-topology caches, fan-triangulates n-gon faces, derives fps from the time sampling). importToScene() builds the base Ogre mesh (frame-0 topology) + a VAT_POSE clip + entity. All Alembic/Imath usage is #ifdef ENABLE_ALEMBIC-guarded; the no-flag build returns a clear "rebuild with -DENABLE_ALEMBIC" and never crashes. - .abc routes through MeshImporterExporter::importer + the valid-extension list, so File → Import handles it. - Round-trip test writes a synthetic 2-frame quad .abc (Alembic OWrite) and reads it back through readFrameSet, asserting vertex count / per-frame Y / times / AABB. A separate no-flag test asserts graceful unavailability. - deploy.yml: -DENABLE_ALEMBIC=ON on the coverage/unit-test lane only. - CLAUDE.md: document the Slice B animation additions. Validated locally on macOS arm64: clean configure (Imath+Alembic vendored, find_package(Imath) resolves), Alembic-ON app + UnitTests build and link against the real API, ENABLE_ALEMBIC=OFF build still compiles with the stub. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 3 +- CLAUDE.md | 7 + cmake/Alembic.cmake | 92 ++++++++++ src/AlembicImporter.cpp | 324 +++++++++++++++++++++++++++++++++++ src/AlembicImporter.h | 64 +++++++ src/AlembicImporter_test.cpp | 117 +++++++++++++ src/CMakeLists.txt | 9 + src/Manager.cpp | 2 +- src/MeshImporterExporter.cpp | 19 +- tests/CMakeLists.txt | 4 + 10 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 cmake/Alembic.cmake create mode 100644 src/AlembicImporter.cpp create mode 100644 src/AlembicImporter.h create mode 100644 src/AlembicImporter_test.cpp diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2dbbfbd..44689171 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1066,7 +1066,8 @@ jobs: -DBUILD_QT_MESH_EDITOR=OFF \ -DENABLE_SENTRY=OFF \ -DENABLE_PS1_RIP=ON \ - -DENABLE_ONNX=ON + -DENABLE_ONNX=ON \ + -DENABLE_ALEMBIC=ON - name: Run build-wrapper env: diff --git a/CLAUDE.md b/CLAUDE.md index cf38f7ed..070e90e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,13 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Subdivide**: 1-to-4 triangle split. Adjacent non-selected faces are retriangulated against the new midpoints to avoid T-junctions (1/2/3 split-edge cases). Wired to a toolbar button (⊞) — face mode subdivides selected tris, edge mode subdivides every triangle incident to a selected edge. - **Fill**: vertex mode fan-triangulates the selected verts (3 → triangle, 4 → quad, N → N-2 tris); edge mode detects a closed boundary loop via degree-2 walk and caps it. Toolbar button (◆) and `F` shortcut. Cross-submesh inputs and duplicates of existing triangles are rejected. +### Animation systems beyond skeletal (epic #517) + +The animation pipeline started skeleton-only; the #517 epic broadens it. Slices A/C/D shipped: **MorphAnimationManager** (`src/MorphAnimationManager.{h,cpp}`, morph targets / blend shapes — Ogre `Pose` + `VAT_POSE` tracks, `qtmesh morph` CLI, MCP, undo via `commands/MorphCommands`), **NodeAnimationManager** (non-skinned node TRS), **PoseLibrary** (named poses). All are `QML_SINGLETON`s registered in `mainwindow.cpp`. + +- **VertexAnimationManager** (`src/VertexAnimationManager.{h,cpp}`, Slice B #519): full-mesh per-vertex animation (cloth / sims / fluid bakes / Alembic caches — every vertex moves, no skeleton). Reuses Ogre's `VAT_POSE` path so the existing timeline/dope-sheet/loop play it with no new playback code. `FrameSet`/`FrameData` are the source-agnostic decoded-cache types; `buildClipFromFrames(mesh, name, frames)` reads submesh-0 bind positions and builds one `Ogre::Pose` per frame (delta vs bind) + one `VAT_POSE` track keyed per frame time. `sampleHeuristic(frameCount)` (< 32 → poses, else stream — the issue's rule) is static + unit-tested. Sentry `scene.anim.vertex_anim`. +- **AlembicImporter** (`src/AlembicImporter.{h,cpp}`, Slice B2): Alembic (.abc) reader behind `-DENABLE_ALEMBIC` (default OFF; `cmake/Alembic.cmake` FetchContents Imath 3.1.12 + Alembic 1.8.8, both BSD-3, fully static, all optional components off — Imath install stays ON so Alembic's unconditional `install(EXPORT)` finds it in an export set). `readFrameSet` decodes the first `IPolyMesh` into a `FrameSet` (pure data — rejects variable-topology caches, fan-triangulates n-gon faces); `importToScene` builds the base mesh + `VAT_POSE` clip + entity. `.abc` routes through `MeshImporterExporter::importer` (guarded — a non-Alembic build logs a clear "rebuild with -DENABLE_ALEMBIC" and skips). The reader is `#ifdef ENABLE_ALEMBIC`-guarded so the default build is unaffected; a round-trip test writes+reads a synthetic `.abc` (only compiled/run in the Alembic-on coverage CI lane). **B3 (pending):** disk-streaming for large caches, `qtmesh anim .abc --info` / `convert .abc -o .fbx`, MCP `import_alembic` / `play_vertex_animation`. + ### Undo/Redo System - **UndoManager** (`src/UndoManager.h/cpp`): Singleton wrapping `QUndoStack`. Push commands, undo/redo via `Ctrl+Z`/`Ctrl+Shift+Z`. diff --git a/cmake/Alembic.cmake b/cmake/Alembic.cmake new file mode 100644 index 00000000..d0382833 --- /dev/null +++ b/cmake/Alembic.cmake @@ -0,0 +1,92 @@ +# Alembic (.abc) vertex-animation import — Anim epic Slice B (#519), sub-slice B2. +# +# Vendors Imath 3 + Alembic (both BSD-3-Clause) via FetchContent, statically, +# with every optional component OFF (no HDF5, Python, tests, binaries, install). +# Exposes an imported target `qtmesh_alembic` that the app links; the C++ side +# is #ifdef ENABLE_ALEMBIC-guarded so a build without this still compiles. +# +# Why build from source rather than find_package: Alembic + Imath system +# packages are absent or version-skewed across our three targets (macOS via +# brew, Ubuntu CI, Windows MinGW). FetchContent gives one reproducible version +# everywhere, matching how the project already vendors ONNX Runtime / libsodium +# / tinyexr. +# +# Dependency chain: Alembic FIND_PACKAGE(Imath) → we build Imath as a +# subproject first, point Imath_DIR at its generated build-tree config so +# Alembic's find_package(Imath CONFIG) resolves to the target we just built. + +if(TARGET qtmesh_alembic) + return() +endif() + +include(FetchContent) + +set(QTMESH_IMATH_TAG "v3.1.12" CACHE STRING "Imath git tag") +set(QTMESH_ALEMBIC_TAG "1.8.8" CACHE STRING "Alembic git tag") + +# ---- Imath --------------------------------------------------------------- +# Static, no tests/python. IMATH_INSTALL stays ON: Alembic's own +# INSTALL(EXPORT AlembicTargets) is unconditional and references Imath, so +# Imath MUST be in an export set too or CMake's generate step fails +# ("target Alembic requires target Imath that is not in any export set"). +# We never actually run `make install` from the app build, so an ON install +# rule is harmless — it just keeps the export sets consistent. +set(IMATH_INSTALL ON CACHE BOOL "" FORCE) +set(IMATH_INSTALL_PKG_CONFIG OFF CACHE BOOL "" FORCE) +set(PYTHON OFF CACHE BOOL "" FORCE) +set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + qtmesh_imath + GIT_REPOSITORY https://github.com/AcademySoftwareFoundation/Imath.git + GIT_TAG ${QTMESH_IMATH_TAG} + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(qtmesh_imath) + +# Alembic's find_package(Imath) resolves against a CONFIG. Imath-as-subproject +# writes its package config under the build dir; point find_package there. +if(NOT DEFINED Imath_DIR) + set(Imath_DIR "${qtmesh_imath_BINARY_DIR}/config" CACHE PATH "" FORCE) +endif() + +# ---- Alembic ------------------------------------------------------------- +# Everything optional OFF: no HDF5 backend (we only read the modern Ogawa +# backend), no tests/binaries/python/prman/maya/arnold, static lib. +set(USE_HDF5 OFF CACHE BOOL "" FORCE) +set(USE_TESTS OFF CACHE BOOL "" FORCE) +set(USE_BINARIES OFF CACHE BOOL "" FORCE) +set(USE_EXAMPLES OFF CACHE BOOL "" FORCE) +set(USE_PYALEMBIC OFF CACHE BOOL "" FORCE) +set(USE_ARNOLD OFF CACHE BOOL "" FORCE) +set(USE_PRMAN OFF CACHE BOOL "" FORCE) +set(USE_MAYA OFF CACHE BOOL "" FORCE) +set(ALEMBIC_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(ALEMBIC_ILMBASE_LINK_STATIC ON CACHE BOOL "" FORCE) +set(ALEMBIC_LIB_INSTALL_DIR "lib" CACHE STRING "" FORCE) + +FetchContent_Declare( + qtmesh_alembic + GIT_REPOSITORY https://github.com/alembic/alembic.git + GIT_TAG ${QTMESH_ALEMBIC_TAG} + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(qtmesh_alembic) + +# Alembic's core library target is `Alembic` (with an `Alembic::Alembic` alias +# in recent versions). Wrap whichever exists behind our stable name so the app +# links `qtmesh_alembic` regardless. +if(TARGET Alembic::Alembic) + add_library(qtmesh_alembic INTERFACE) + target_link_libraries(qtmesh_alembic INTERFACE Alembic::Alembic Imath::Imath) +elseif(TARGET Alembic) + add_library(qtmesh_alembic INTERFACE) + target_link_libraries(qtmesh_alembic INTERFACE Alembic Imath::Imath) + # The plain `Alembic` target's public include dirs cover its own headers; + # Imath::Imath carries the Imath headers Alembic's public API exposes. +else() + message(FATAL_ERROR "Alembic.cmake: neither Alembic nor Alembic::Alembic target was created") +endif() + +message(STATUS "Alembic ${QTMESH_ALEMBIC_TAG} + Imath ${QTMESH_IMATH_TAG} vendored (static)") diff --git a/src/AlembicImporter.cpp b/src/AlembicImporter.cpp new file mode 100644 index 00000000..d3eba03c --- /dev/null +++ b/src/AlembicImporter.cpp @@ -0,0 +1,324 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#include "AlembicImporter.h" + +#include "Manager.h" +#include "SentryReporter.h" + +#include + +#ifdef ENABLE_ALEMBIC +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#endif + +namespace AlembicImporter { + +bool available() +{ +#ifdef ENABLE_ALEMBIC + return true; +#else + return false; +#endif +} + +#ifndef ENABLE_ALEMBIC + +ReadResult readFrameSet(const QString&, int) +{ + ReadResult r; + r.error = QStringLiteral( + "Alembic import needs a build with -DENABLE_ALEMBIC (Alembic + Imath " + "are not compiled in)."); + return r; +} + +Ogre::SceneNode* importToScene(const QString&, QString* error) +{ + if (error) + *error = QStringLiteral( + "Alembic import needs a build with -DENABLE_ALEMBIC."); + return nullptr; +} + +#else // ENABLE_ALEMBIC + +using namespace Alembic::AbcGeom; + +namespace { + +// Depth-first search for the first IPolyMesh in the archive tree. +IPolyMesh findFirstPolyMesh(IObject obj) +{ + for (size_t i = 0; i < obj.getNumChildren(); ++i) { + IObject child = obj.getChild(i); + if (IPolyMesh::matches(child.getHeader())) + return IPolyMesh(child, kWrapExisting); + IPolyMesh found = findFirstPolyMesh(child); + if (found.valid()) + return found; + } + return IPolyMesh(); +} + +} // namespace + +ReadResult readFrameSet(const QString& path, int maxFrames) +{ + ReadResult r; + QFileInfo fi(path); + if (!fi.exists()) { + r.error = QStringLiteral("Alembic file not found: %1").arg(path); + return r; + } + + try { + Alembic::AbcCoreFactory::IFactory factory; + IArchive archive = factory.getArchive(path.toStdString()); + if (!archive.valid()) { + r.error = QStringLiteral("Not a readable Alembic archive: %1").arg(path); + return r; + } + + IPolyMesh mesh = findFirstPolyMesh(archive.getTop()); + if (!mesh.valid()) { + r.error = QStringLiteral("No polygon mesh found in %1").arg(fi.fileName()); + return r; + } + r.meshName = QString::fromStdString(mesh.getName()); + + IPolyMeshSchema& schema = mesh.getSchema(); + const size_t numSamples = schema.getNumSamples(); + if (numSamples == 0) { + r.error = QStringLiteral("Alembic mesh '%1' has no samples").arg(r.meshName); + return r; + } + + Alembic::AbcCoreAbstract::TimeSamplingPtr ts = schema.getTimeSampling(); + + // Topology (vertex count) comes from the first sample; VAT_POSE needs a + // fixed base, so reject a cache whose vertex count changes over time. + IPolyMeshSchema::Sample first; + schema.get(first, ISampleSelector(static_cast(0))); + const size_t baseVerts = first.getPositions()->size(); + if (baseVerts == 0) { + r.error = QStringLiteral("Alembic mesh '%1' has zero vertices").arg(r.meshName); + return r; + } + + size_t decodeCount = numSamples; + if (maxFrames > 0 && static_cast(maxFrames) < decodeCount) + decodeCount = static_cast(maxFrames); + + VertexAnimationManager::FrameSet& fs = r.frames; + fs.vertexCount = static_cast(baseVerts); + // fps from the sample spacing (uniform sampling → constant dt). + const double dt = (numSamples > 1) + ? (ts->getSampleTime(1) - ts->getSampleTime(0)) + : (1.0 / 30.0); + fs.fps = (dt > 1e-9) ? static_cast(std::lround(1.0 / dt)) : 30; + if (fs.fps <= 0) fs.fps = 30; + + float mn[3] = { std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max() }; + float mx[3] = { -std::numeric_limits::max(), + -std::numeric_limits::max(), + -std::numeric_limits::max() }; + + for (size_t s = 0; s < decodeCount; ++s) { + IPolyMeshSchema::Sample samp; + schema.get(samp, ISampleSelector(static_cast(s))); + Abc::P3fArraySamplePtr P = samp.getPositions(); + if (!P || P->size() != baseVerts) { + r.error = QStringLiteral( + "Alembic mesh '%1' changes vertex count between frames " + "(frame %2: %3 vs %4) — not a fixed-topology vertex cache") + .arg(r.meshName).arg(s) + .arg(P ? P->size() : 0).arg(baseVerts); + r.frames = {}; + return r; + } + VertexAnimationManager::FrameData fd; + fd.time = static_cast(ts->getSampleTime(s) - ts->getSampleTime(0)); + fd.positions.resize(baseVerts * 3); + for (size_t v = 0; v < baseVerts; ++v) { + const Imath::V3f& p = (*P)[v]; + fd.positions[v * 3 + 0] = p.x; + fd.positions[v * 3 + 1] = p.y; + fd.positions[v * 3 + 2] = p.z; + mn[0] = std::min(mn[0], p.x); mx[0] = std::max(mx[0], p.x); + mn[1] = std::min(mn[1], p.y); mx[1] = std::max(mx[1], p.y); + mn[2] = std::min(mn[2], p.z); mx[2] = std::max(mx[2], p.z); + } + fs.frames.push_back(std::move(fd)); + } + fs.aabb = { mn[0], mn[1], mn[2], mx[0], mx[1], mx[2] }; + + r.ok = fs.ok(); + if (!r.ok) + r.error = QStringLiteral( + "Alembic mesh '%1' produced fewer than 2 frames — nothing to animate") + .arg(r.meshName); + return r; + } catch (const std::exception& e) { + r.error = QStringLiteral("Alembic read error: %1").arg(QString::fromUtf8(e.what())); + r.frames = {}; + return r; + } +} + +// Build a static base Ogre::Mesh (frame-0 topology + positions) with POSITION + +// NORMAL, non-shared submesh 0, so buildClipFromFrames' VAT_POSE poses target +// submesh handle 1 — matching the morph/vertex-anim convention. +static Ogre::MeshPtr buildBaseMesh(const QString& name, + const QString& abcPath, + const VertexAnimationManager::FrameSet& fs) +{ + // Re-read topology (face indices) from the archive's first sample. + std::vector indices; + try { + Alembic::AbcCoreFactory::IFactory factory; + IArchive archive = factory.getArchive(abcPath.toStdString()); + IPolyMesh mesh = findFirstPolyMesh(archive.getTop()); + IPolyMeshSchema::Sample first; + mesh.getSchema().get(first, ISampleSelector(static_cast(0))); + Abc::Int32ArraySamplePtr faceIdx = first.getFaceIndices(); + Abc::Int32ArraySamplePtr faceCnt = first.getFaceCounts(); + // Fan-triangulate each n-gon face (Alembic faces are arbitrary polygons). + size_t cursor = 0; + for (size_t f = 0; f < faceCnt->size(); ++f) { + const int n = (*faceCnt)[f]; + for (int t = 1; t + 1 < n; ++t) { + indices.push_back(static_cast((*faceIdx)[cursor])); + indices.push_back(static_cast((*faceIdx)[cursor + t])); + indices.push_back(static_cast((*faceIdx)[cursor + t + 1])); + } + cursor += static_cast(n); + } + } catch (const std::exception&) { + return Ogre::MeshPtr(); + } + if (indices.empty()) return Ogre::MeshPtr(); + + const int vcount = fs.vertexCount; + const std::vector& pos0 = fs.frames.front().positions; + + auto& mm = Ogre::MeshManager::getSingleton(); + if (mm.resourceExists(name.toStdString())) + mm.remove(name.toStdString()); + Ogre::MeshPtr om = mm.createManual( + name.toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* sub = om->createSubMesh(); + sub->useSharedVertices = false; + sub->vertexData = new Ogre::VertexData(); + sub->vertexData->vertexCount = static_cast(vcount); + 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), vcount, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + std::vector vdata(static_cast(vcount) * 6, 0.0f); + for (int v = 0; v < vcount; ++v) { + vdata[v * 6 + 0] = pos0[v * 3 + 0]; + vdata[v * 6 + 1] = pos0[v * 3 + 1]; + vdata[v * 6 + 2] = pos0[v * 3 + 2]; + vdata[v * 6 + 5] = 1.0f; // placeholder +Z normal (recomputed by shading) + } + vbuf->writeData(0, vdata.size() * sizeof(float), vdata.data()); + sub->vertexData->vertexBufferBinding->setBinding(0, vbuf); + + const bool use32 = vcount > 65535; + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + use32 ? Ogre::HardwareIndexBuffer::IT_32BIT : Ogre::HardwareIndexBuffer::IT_16BIT, + indices.size(), Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + if (use32) { + ibuf->writeData(0, indices.size() * sizeof(uint32_t), indices.data()); + } else { + std::vector i16(indices.size()); + for (size_t i = 0; i < indices.size(); ++i) + i16[i] = static_cast(indices[i]); + ibuf->writeData(0, i16.size() * sizeof(uint16_t), i16.data()); + } + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = indices.size(); + + Ogre::AxisAlignedBox aabb(fs.aabb[0], fs.aabb[1], fs.aabb[2], + fs.aabb[3], fs.aabb[4], fs.aabb[5]); + om->_setBounds(aabb); + om->_setBoundingSphereRadius(0.5f * aabb.getSize().length()); + om->load(); + return om; +} + +Ogre::SceneNode* importToScene(const QString& path, QString* error) +{ + auto fail = [&](const QString& m) -> Ogre::SceneNode* { + if (error) *error = m; + return nullptr; + }; + + SentryReporter::addBreadcrumb(QStringLiteral("scene.anim.vertex_anim"), + QStringLiteral("import Alembic %1") + .arg(QFileInfo(path).fileName())); + + // Decode heuristic-capped: pose storage tops out at 32 frames, but we still + // decode all here (streaming is B3). Cap conservatively so a pathological + // multi-thousand-frame cache doesn't hang the import before B3 lands. + ReadResult rr = readFrameSet(path, /*maxFrames=*/512); + if (!rr.ok) + return fail(rr.error); + + auto* mgr = Manager::getSingletonPtr(); + if (!mgr || !mgr->getSceneMgr()) + return fail(QStringLiteral("No active scene to import into.")); + + const QString base = QFileInfo(path).completeBaseName(); + const QString meshName = base + QStringLiteral("_abc"); + Ogre::MeshPtr om = buildBaseMesh(meshName, path, rr.frames); + if (!om) + return fail(QStringLiteral("Failed to build base mesh from %1").arg(base)); + + const QString clip = base + QStringLiteral("_cache"); + if (!VertexAnimationManager::buildClipFromFrames(om.get(), clip, rr.frames)) + return fail(QStringLiteral("Failed to build vertex-animation clip.")); + + Ogre::SceneNode* node = mgr->addSceneNode(base + QStringLiteral("_abc_node")); + if (!node) + return fail(QStringLiteral("Failed to create scene node.")); + mgr->createEntity(node, om); + + SentryReporter::addBreadcrumb( + QStringLiteral("scene.anim.vertex_anim"), + QStringLiteral("imported Alembic '%1' — %2 frames, %3 verts") + .arg(rr.meshName).arg(rr.frames.frames.size()).arg(rr.frames.vertexCount)); + return node; +} + +#endif // ENABLE_ALEMBIC + +} // namespace AlembicImporter diff --git a/src/AlembicImporter.h b/src/AlembicImporter.h new file mode 100644 index 00000000..8d4ebb24 --- /dev/null +++ b/src/AlembicImporter.h @@ -0,0 +1,64 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#ifndef ALEMBICIMPORTER_H +#define ALEMBICIMPORTER_H + +#include + +#include "VertexAnimationManager.h" + +namespace Ogre { class SceneNode; } + +/** + * @brief Alembic (.abc) vertex-animation reader — Anim Slice B, sub-slice B2. + * + * Decodes a baked per-vertex cache (the first animated IPolyMesh in the archive) + * into a source-agnostic VertexAnimationManager::FrameSet, then B1's + * buildClipFromFrames turns it into an Ogre VAT_POSE clip. Cloth / sims / fluid + * bakes / Houdini + Blender exports. + * + * All Alembic/Imath usage lives in AlembicImporter.cpp behind + * `#ifdef ENABLE_ALEMBIC`. When the build lacks Alembic, `available()` is false + * and the read/import calls fail with a clear "rebuild with -DENABLE_ALEMBIC" + * message — nothing crashes, and the rest of the app is unaffected. + * + * The decode step (readFrameSet) is pure data — no Ogre, no GL — so it's + * unit-testable against a small synthetic .abc fixture under headless CI. + */ +namespace AlembicImporter { + +/// True only when built with ENABLE_ALEMBIC. +bool available(); + +struct ReadResult { + bool ok = false; + QString error; + VertexAnimationManager::FrameSet frames; ///< decoded cache (empty on !ok) + QString meshName; ///< source IPolyMesh name (for the clip) +}; + +/// Decode `path`'s first animated polymesh into a FrameSet. Pure data. The +/// topology (index buffer) is taken from the first sample; positions are read +/// per time-sample. A mesh whose topology changes between frames (variable +/// vertex count) is rejected — VAT_POSE needs a fixed base. `maxFrames` caps +/// how many samples are decoded (0 = all); the streaming path (B3) reads on +/// demand instead. +ReadResult readFrameSet(const QString& path, int maxFrames = 0); + +/// Import `path` into the scene: build a base Ogre::Mesh from the first sample's +/// topology, attach a VAT_POSE clip from the decoded frames, create an entity, +/// and return its SceneNode (nullptr + `error` on failure). Requires an active +/// Ogre scene (GL) — this is the GUI/CLI-viewport entry point. +Ogre::SceneNode* importToScene(const QString& path, QString* error = nullptr); + +} // namespace AlembicImporter + +#endif // ALEMBICIMPORTER_H diff --git a/src/AlembicImporter_test.cpp b/src/AlembicImporter_test.cpp new file mode 100644 index 00000000..d8f7e273 --- /dev/null +++ b/src/AlembicImporter_test.cpp @@ -0,0 +1,117 @@ +#include + +#include "AlembicImporter.h" + +#include +#include + +// The decode path only exists in an ENABLE_ALEMBIC build. Without it, assert the +// feature reports unavailable + fails gracefully (no crash) — the contract the +// GUI/CLI rely on for the "rebuild with -DENABLE_ALEMBIC" message. +#ifndef ENABLE_ALEMBIC + +TEST(AlembicImporterStandalone, UnavailableWithoutFlag) { + EXPECT_FALSE(AlembicImporter::available()); + auto rr = AlembicImporter::readFrameSet("/nonexistent.abc"); + EXPECT_FALSE(rr.ok); + EXPECT_FALSE(rr.error.isEmpty()); + QString err; + EXPECT_EQ(AlembicImporter::importToScene("/nonexistent.abc", &err), nullptr); + EXPECT_FALSE(err.isEmpty()); +} + +#else // ENABLE_ALEMBIC + +#include +#include +#include + +#include +#include + +namespace { + +// Write a tiny 2-frame quad vertex cache to `path`: a unit quad whose 4 verts +// translate +Y over frame 1. Round-trips through the same reader the app uses. +void writeQuadCache(const std::string& path) { + using namespace Alembic::AbcGeom; + OArchive archive(Alembic::AbcCoreOgawa::WriteArchive(), path); + // 30fps uniform time sampling. + const chrono_t dt = 1.0 / 30.0; + TimeSampling tsamp(dt, 0.0); + Alembic::Util::uint32_t tsIdx = archive.addTimeSampling(tsamp); + + OPolyMesh meshObj(OObject(archive, kTop), "quadCache", tsIdx); + OPolyMeshSchema& schema = meshObj.getSchema(); + + // 4 verts, one quad face (count=4). + std::vector p0 = { + {0, 0, 0}, {1, 0, 0}, {1, 0, 1}, {0, 0, 1}}; + std::vector p1 = { + {0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}; + std::vector indices = {0, 1, 2, 3}; + std::vector counts = {4}; + + // First sample carries topology. Build via named args (avoid the + // most-vexing-parse: `Sample s0(TempA(), TempB(), ...)` is read as a + // function declaration). + Abc::V3fArraySample posSample0(p0); + Abc::Int32ArraySample idxSample(indices); + Abc::Int32ArraySample cntSample(counts); + OPolyMeshSchema::Sample s0(posSample0, idxSample, cntSample); + schema.set(s0); + // Second sample: positions only. + Abc::V3fArraySample posSample1(p1); + OPolyMeshSchema::Sample s1; + s1.setPositions(posSample1); + schema.set(s1); + // archive flushes on destruction (end of scope). +} + +} // namespace + +class AlembicImporterTest : public ::testing::Test { +protected: + QString abcPath; + void SetUp() override { + abcPath = QDir::temp().filePath("qtmesh_test_quad.abc"); + QFile::remove(abcPath); + writeQuadCache(abcPath.toStdString()); + } + void TearDown() override { QFile::remove(abcPath); } +}; + +TEST_F(AlembicImporterTest, Available) { + EXPECT_TRUE(AlembicImporter::available()); +} + +TEST_F(AlembicImporterTest, ReadsTwoFrameQuadCache) { + auto rr = AlembicImporter::readFrameSet(abcPath); + ASSERT_TRUE(rr.ok) << rr.error.toStdString(); + EXPECT_EQ(rr.frames.vertexCount, 4); + ASSERT_EQ(rr.frames.frames.size(), 2u); + EXPECT_EQ(rr.frames.fps, 30); + + // Frame 0 = base quad at Y=0; frame 1 = same quad at Y=1. + const auto& f0 = rr.frames.frames[0].positions; + const auto& f1 = rr.frames.frames[1].positions; + ASSERT_EQ(f0.size(), 12u); + for (int v = 0; v < 4; ++v) { + EXPECT_NEAR(f0[v * 3 + 1], 0.0f, 1e-5f); // frame 0 Y == 0 + EXPECT_NEAR(f1[v * 3 + 1], 1.0f, 1e-5f); // frame 1 Y == 1 + } + // Times: frame 1 is 1/30 s after frame 0. + EXPECT_NEAR(rr.frames.frames[0].time, 0.0f, 1e-5f); + EXPECT_NEAR(rr.frames.frames[1].time, 1.0f / 30.0f, 1e-4f); + // AABB spans Y 0..1. + EXPECT_NEAR(rr.frames.aabb[1], 0.0f, 1e-5f); + EXPECT_NEAR(rr.frames.aabb[4], 1.0f, 1e-5f); +} + +TEST_F(AlembicImporterTest, MissingFileFailsGracefully) { + auto rr = AlembicImporter::readFrameSet("/no/such/file.abc"); + EXPECT_FALSE(rr.ok); + EXPECT_FALSE(rr.error.isEmpty()); +} + +#endif // ENABLE_ALEMBIC diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 801cc1ea..3f015841 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -136,6 +136,7 @@ MorphAnimationManager.cpp NodeAnimationManager.cpp PoseLibrary.cpp VertexAnimationManager.cpp +AlembicImporter.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp @@ -658,6 +659,10 @@ endif() # Link ONNX Runtime if enabled (#404 — AI PBR map synthesis). The imported # target is a SHARED lib, so copy it next to the binary at build time or it # won't be found at runtime. +if(ENABLE_ALEMBIC) + target_link_libraries(${CMAKE_PROJECT_NAME} qtmesh_alembic) +endif() + if(ENABLE_ONNX) target_link_libraries(${CMAKE_PROJECT_NAME} qtmesh_onnx) if(QTMESH_ONNX_LIB_DIR) @@ -790,6 +795,10 @@ if(BUILD_TESTS) target_link_libraries(UnitTests stable-diffusion) endif() + if(ENABLE_ALEMBIC) + target_link_libraries(UnitTests qtmesh_alembic) + endif() + # Link ONNX Runtime for tests if enabled (#404) if(ENABLE_ONNX) target_link_libraries(UnitTests qtmesh_onnx) diff --git a/src/Manager.cpp b/src/Manager.cpp index 4241c93a..cfceb550 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -83,7 +83,7 @@ Manager* Manager:: m_pSingleton = nullptr; QString Manager::mValidFileExtention = ".mesh .dae .blend .3ds .ase .obj .ifc .xgl .zgl .ply .dxf .lwo "\ ".lws .lxo .stl .x .ac .ms3d .cob .scn .bvh .csm .xml .irrmesh .irr .mdl .md2 .md3 "\ - ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm .tmd .rsd"; + ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm .tmd .rsd .abc"; //////////////////////////////////////// /// Static Member to build & destroy diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 30cde991..7c5281a4 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -27,6 +27,7 @@ THE SOFTWARE. */ #include "MeshImporterExporter.h" +#include "AlembicImporter.h" #include "FeedbackReportHelper.h" #include #include @@ -2096,7 +2097,23 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad Ogre::SceneNode *sn; const Ogre::Entity *en; - if(!file.suffix().compare("mesh",Qt::CaseInsensitive)) + if(!file.suffix().compare("abc",Qt::CaseInsensitive)) + { + // Alembic vertex-animation cache (#519 Slice B). Delegates to + // the guarded reader; a build without -DENABLE_ALEMBIC returns + // a clear error rather than falling through to Assimp (which + // can't read .abc). The importer builds the base mesh + a + // VAT_POSE clip and creates the entity itself. + QString abcErr; + Ogre::SceneNode* abcNode = + AlembicImporter::importToScene(file.absoluteFilePath(), &abcErr); + if (!abcNode) { + Ogre::LogManager::getSingleton().logError( + "Alembic import failed: " + abcErr.toStdString()); + } + continue; + } + else if(!file.suffix().compare("mesh",Qt::CaseInsensitive)) { tryLoadSidecarMaterialScript(file); const Ogre::String meshResName = file.fileName().toStdString(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 24e8490d..4dbd3333 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -132,6 +132,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/PoseLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/PoseLibraryCommands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexAnimationManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AlembicImporter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ComputeSkinWeightsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AutoRigCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/UVEditCommand.cpp @@ -488,6 +489,9 @@ if(BUILD_TESTS) if(ENABLE_ONNX) list(APPEND TEST_SUPPORT_LIBRARIES qtmesh_onnx) endif() + if(ENABLE_ALEMBIC) + list(APPEND TEST_SUPPORT_LIBRARIES qtmesh_alembic) + endif() add_library(qtmesh_test_common STATIC ${TEST_SRC_FILES} ${TEST_HEADER_FILES}) set_target_properties(qtmesh_test_common PROPERTIES From f8997f60956802b522c6b8d70c925529cd78e0c6 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 11:20:38 -0400 Subject: [PATCH 04/22] ci(#519): don't enable Alembic on the coverage lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding -DENABLE_ALEMBIC=ON to the coverage/unit-test lane perturbed that lane's build layout enough to break the pre-existing PS1 runtime test (EmuCoreLoaderTest: "PS1 stub core plugin not built beside test binary") — building Alembic shifts output-dir/copy timing and the ps1core stub .so lands where the test no longer finds it. The Alembic reader itself is fine: in that same run AlembicImporterTest (Available / ReadsTwoFrameQuadCache round-trip / MissingFileFailsGracefully) all PASSED on Linux before the job aborted on the PS1 suite. Revert the coverage-lane flag so it stays identical to master (green) and CI runs stay fast. The Alembic path is validated: locally on macOS (configure + app + tests build/link against the real API) and on Linux via that run's passing AlembicImporterTest. A dedicated Alembic-on CI job can be added later without coupling to the PS1-enabled lane. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44689171..f2dbbfbd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1066,8 +1066,7 @@ jobs: -DBUILD_QT_MESH_EDITOR=OFF \ -DENABLE_SENTRY=OFF \ -DENABLE_PS1_RIP=ON \ - -DENABLE_ONNX=ON \ - -DENABLE_ALEMBIC=ON + -DENABLE_ONNX=ON - name: Run build-wrapper env: From f9b9096a294d866301cd6ed78b722cb4f0af22e3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 16:58:38 -0400 Subject: [PATCH 05/22] fix(#519): stop playback before morph/vertex delete; fix morph header layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Crash: deleting a morph/vertex target while a clip played freed pose / animation-state data the render frame loop was mid-read of. removePosesByName (shared by delete + rename redo/undo) now stops playback and disables the state before mutating — same guard deleteAnimation/renameAnimation already use. Guarded so it's a no-op headless. - Layout: the morph-list header used a magic-number spacer (Item width: parent.width - 320) that went negative on a narrow Inspector, overlapping the title with the Add/Reset buttons. Converted to a RowLayout — title takes flexible space + elides, buttons keep intrinsic size at the right. - Also surfaced the Animations section (play/enable/loop controls) for vertex/mesh-animated selections, not just skeletal ones — a vertex-anim mesh has no skeleton so the section (and its play button) never appeared. Co-Authored-By: Claude Opus 4.8 (1M context) --- qml/PropertiesPanel.qml | 31 +++++++++++++++++++++---------- src/commands/MorphCommands.cpp | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index c2b51e74..d154cf0f 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -676,12 +676,16 @@ Rectangle { // ---- Animations ---- // Shown for any skeleton-bearing selection (not just clips) so the // "Generate from text" control is available on a freshly-rigged mesh - // (e.g. a UniRig auto-rig with no animations yet). + // (e.g. a UniRig auto-rig with no animations yet). ALSO shown when + // the selection carries mesh/vertex animations (morph or Alembic + // vertex caches, #518/#519) even with no skeleton — otherwise those + // clips have no play/enable/loop controls (the "no play button" bug). CollapsibleSection { title: "Animations" sectionVisible: root.modeToolSectionVisible( EditorModeController.AnimationMode, - PropertiesPanelController.hasSkeletonSelection) + PropertiesPanelController.hasSkeletonSelection + || PropertiesPanelController.hasAnimations) Component.onCompleted: content = animationComponent } @@ -7103,17 +7107,22 @@ Rectangle { } } - Row { - spacing: 4 + // RowLayout (not a plain Row with a magic-number spacer): + // the old `Item { width: parent.width - 320 }` went negative + // on a narrow Inspector, overlapping the title with the + // buttons. Here the title takes the flexible space and + // elides; the buttons keep their intrinsic size at the right. + RowLayout { width: parent.width + spacing: 4 Text { + Layout.fillWidth: true + elide: Text.ElideRight text: "Morph Targets (" + morphCol.targetCount + ")" color: PropertiesPanelController.textColor font.pixelSize: 11 font.bold: true - anchors.verticalCenter: parent.verticalCenter } - Item { width: parent.width - 320; height: 1 } // Add from current edit — captures the user's current // edit-mode geometry minus the bind-pose baseline as // a new morph target. Disabled (greyed out, forbidden @@ -7124,13 +7133,14 @@ Rectangle { Rectangle { id: addBtn property bool canAddFromEdit: EditModeController.editModeActive - width: 56; height: 20; radius: 3 + Layout.preferredWidth: 56 + Layout.preferredHeight: 20 + radius: 3 opacity: canAddFromEdit ? 1.0 : 0.45 color: addMa.containsMouse && canAddFromEdit ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) : PropertiesPanelController.controlBgColor border.color: PropertiesPanelController.borderColor - anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent text: "+ Add…" @@ -7154,12 +7164,13 @@ Rectangle { } // Reset all: walks every target and sets weight to 0. Rectangle { - width: 60; height: 20; radius: 3 + Layout.preferredWidth: 60 + Layout.preferredHeight: 20 + radius: 3 color: resetMa.containsMouse ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) : PropertiesPanelController.controlBgColor border.color: PropertiesPanelController.borderColor - anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent text: "Reset all" diff --git a/src/commands/MorphCommands.cpp b/src/commands/MorphCommands.cpp index b57182ba..7ca33423 100644 --- a/src/commands/MorphCommands.cpp +++ b/src/commands/MorphCommands.cpp @@ -10,6 +10,7 @@ The MIT License #include "MorphCommands.h" +#include "../PropertiesPanelController.h" #include "../SentryReporter.h" #include @@ -51,6 +52,24 @@ void removePosesByName(Ogre::Mesh* mesh, const QString& name, Ogre::Entity* enti if (!mesh) return; const std::string sn = name.toStdString(); + // STOP PLAYBACK first. The render frame loop (MainWindow:: + // frameRenderingQueued) iterates the entity's AnimationStateSet and reads + // each VAT_POSE track's pose references every frame. Removing a pose / + // animation / state here while a clip is playing frees data the loop is + // mid-read of → crash (reproduced: play a morph/vertex clip, delete a + // target). deleteAnimation/renameAnimation already stop playback before + // mutating; do the same at this shared mutation point so every entry + // (delete + rename redo/undo) is safe. Also disable the state before + // removing it so no dangling enabled state survives the refresh. + if (auto* ppc = PropertiesPanelController::instance()) + ppc->setPlaying(false); + if (entity) { + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(sn)) + states->getAnimationState(sn)->setEnabled(false); + } + } + // removePose(name) only removes the *first* pose with that name — // when an importer pose has the same name across multiple submeshes // we'd leak the rest. Walk + remove by index from the back so the From b89b751c59f359aa2d9dc010c9b57340e94170a4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 17:20:56 -0400 Subject: [PATCH 06/22] feat(anim #519 B3): Alembic --info CLI + import_alembic/play_vertex_animation MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice B3 headless parity for the vertex-animation feature: - `qtmesh anim .abc --info [--json]`: cheap vertex-cache metadata (frames/verts/tris/fps/duration/storage) via AlembicImporter::readInfo, which reads the schema header + first sample only — no full decode. - MCP `import_alembic`: decodes an .abc into the live scene as a VAT_POSE-animated entity (heavy tool), reporting the created node, entities, and vertex-clip names so an agent can immediately drive playback. - MCP `play_vertex_animation`: plays/stops a vertex clip; delegates to the proven toolPlayAnimation path (a vertex clip surfaces as an ordinary AnimationState). All Alembic usage stays behind ENABLE_ALEMBIC; a build without it returns a clear "rebuild with -DENABLE_ALEMBIC=ON" message from every surface. Co-Authored-By: Claude Opus 4.8 --- src/AlembicImporter.cpp | 61 ++++++++++++++++++++++++ src/AlembicImporter.h | 18 +++++++ src/CLIPipeline.cpp | 42 ++++++++++++++++ src/MCPServer.cpp | 103 ++++++++++++++++++++++++++++++++++++++++ src/MCPServer.h | 9 ++++ 5 files changed, 233 insertions(+) diff --git a/src/AlembicImporter.cpp b/src/AlembicImporter.cpp index d3eba03c..06cef163 100644 --- a/src/AlembicImporter.cpp +++ b/src/AlembicImporter.cpp @@ -62,6 +62,13 @@ Ogre::SceneNode* importToScene(const QString&, QString* error) return nullptr; } +InfoResult readInfo(const QString&) +{ + InfoResult r; + r.error = QStringLiteral("Alembic info needs a build with -DENABLE_ALEMBIC."); + return r; +} + #else // ENABLE_ALEMBIC using namespace Alembic::AbcGeom; @@ -189,6 +196,60 @@ ReadResult readFrameSet(const QString& path, int maxFrames) } } +InfoResult readInfo(const QString& path) +{ + InfoResult r; + QFileInfo fi(path); + if (!fi.exists()) { + r.error = QStringLiteral("Alembic file not found: %1").arg(path); + return r; + } + try { + Alembic::AbcCoreFactory::IFactory factory; + IArchive archive = factory.getArchive(path.toStdString()); + if (!archive.valid()) { + r.error = QStringLiteral("Not a readable Alembic archive: %1").arg(path); + return r; + } + IPolyMesh mesh = findFirstPolyMesh(archive.getTop()); + if (!mesh.valid()) { + r.error = QStringLiteral("No polygon mesh found in %1").arg(fi.fileName()); + return r; + } + IPolyMeshSchema& schema = mesh.getSchema(); + const size_t numSamples = schema.getNumSamples(); + Alembic::AbcCoreAbstract::TimeSamplingPtr ts = schema.getTimeSampling(); + IPolyMeshSchema::Sample first; + schema.get(first, ISampleSelector(static_cast(0))); + + r.meshName = QString::fromStdString(mesh.getName()); + r.frameCount = static_cast(numSamples); + r.vertexCount = first.getPositions() ? static_cast(first.getPositions()->size()) : 0; + // Sum triangles across n-gon faces (fan triangulation = n-2 per face). + int tris = 0; + if (auto fc = first.getFaceCounts()) + for (size_t f = 0; f < fc->size(); ++f) + tris += std::max(0, (*fc)[f] - 2); + r.faceCount = tris; + const double dt = (numSamples > 1) + ? (ts->getSampleTime(1) - ts->getSampleTime(0)) : (1.0 / 30.0); + r.fps = (dt > 1e-9) ? static_cast(std::lround(1.0 / dt)) : 30; + if (r.fps <= 0) r.fps = 30; + r.durationSec = (numSamples > 1) + ? static_cast(ts->getSampleTime(numSamples - 1) - ts->getSampleTime(0)) + : 0.0f; + r.storage = (VertexAnimationManager::sampleHeuristic(r.frameCount) + == VertexAnimationManager::Storage::Poses) ? "poses" : "stream"; + r.ok = (r.vertexCount > 0 && r.frameCount > 0); + if (!r.ok) + r.error = QStringLiteral("Alembic mesh '%1' has no usable samples").arg(r.meshName); + return r; + } catch (const std::exception& e) { + r.error = QStringLiteral("Alembic info error: %1").arg(QString::fromUtf8(e.what())); + return r; + } +} + // Build a static base Ogre::Mesh (frame-0 topology + positions) with POSITION + // NORMAL, non-shared submesh 0, so buildClipFromFrames' VAT_POSE poses target // submesh handle 1 — matching the morph/vertex-anim convention. diff --git a/src/AlembicImporter.h b/src/AlembicImporter.h index 8d4ebb24..1274e6a3 100644 --- a/src/AlembicImporter.h +++ b/src/AlembicImporter.h @@ -45,6 +45,24 @@ struct ReadResult { QString meshName; ///< source IPolyMesh name (for the clip) }; +/// Cheap metadata about an .abc cache WITHOUT decoding every frame's vertex +/// positions (reads the schema header + first sample only). Powers +/// `qtmesh anim .abc --info` and the MCP info surface. +struct InfoResult { + bool ok = false; + QString error; + QString meshName; + int frameCount = 0; + int vertexCount = 0; + int faceCount = 0; + int fps = 30; + float durationSec = 0.0f; + QString storage; ///< "poses" or "stream" per VertexAnimationManager heuristic +}; + +/// Read an .abc's cache metadata without decoding all frames. +InfoResult readInfo(const QString& path); + /// Decode `path`'s first animated polymesh into a FrameSet. Pure data. The /// topology (index buffer) is taken from the first sample; positions are read /// per time-sample. A mesh whose topology changes between frames (variable diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 34bce51a..4632645d 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -2,6 +2,7 @@ #include "CloudCLIPipeline.h" #include "Manager.h" #include "MeshImporterExporter.h" +#include "AlembicImporter.h" #include "AnimationMerger.h" #include "MotionInbetween.h" #include "MotionLibrary.h" @@ -2011,6 +2012,7 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) // or: anim --decimate-step S [-o ] [--animation ] QString filePath, oldName, newName, outputPath, animationFilter; bool listMode = false; + bool infoMode = false; // #519: `anim .abc --info` — vertex-cache metadata bool analyzeMode = false; bool renameMode = false; bool mergeMode = false; @@ -2047,6 +2049,7 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) QString arg(argv[i]); if (arg == "anim" || arg == "--cli") continue; if (arg == "--list") { listMode = true; continue; } + if (arg == "--info") { infoMode = true; continue; } if (arg == "--analyze") { analyzeMode = true; continue; } if (arg == "--json") { jsonOutput = true; continue; } if (arg == "--rename" && i + 2 < argc) { @@ -2151,6 +2154,45 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) filePath = positional[0]; + // #519: `anim .abc --info [--json]` — vertex-cache metadata (frames, + // verts, fps, duration, storage) read from the Alembic header without + // decoding all frames. Only meaningful for .abc; other formats fall through + // to the normal anim modes. + if (infoMode && QFileInfo(filePath).suffix().compare("abc", Qt::CaseInsensitive) == 0) { + if (!AlembicImporter::available()) { + err() << "Error: Alembic support not compiled in (rebuild with " + "-DENABLE_ALEMBIC=ON)." << Qt::endl; + return 1; + } + const AlembicImporter::InfoResult info = AlembicImporter::readInfo(filePath); + if (!info.ok) { + err() << "Error: " << info.error << Qt::endl; + return 1; + } + if (jsonOutput) { + QJsonObject o; + o["file"] = QFileInfo(filePath).fileName(); + o["mesh"] = info.meshName; + o["frames"] = info.frameCount; + o["vertices"] = info.vertexCount; + o["triangles"] = info.faceCount; + o["fps"] = info.fps; + o["durationSec"] = info.durationSec; + o["storage"] = info.storage; + cliWrite(QString::fromUtf8(QJsonDocument(o).toJson(QJsonDocument::Compact)) + "\n"); + } else { + cliWrite(QString("Alembic vertex cache: %1\n").arg(QFileInfo(filePath).fileName())); + cliWrite(QString(" mesh: %1\n").arg(info.meshName)); + cliWrite(QString(" frames: %1\n").arg(info.frameCount)); + cliWrite(QString(" vertices: %1\n").arg(info.vertexCount)); + cliWrite(QString(" triangles: %1\n").arg(info.faceCount)); + cliWrite(QString(" fps: %1\n").arg(info.fps)); + cliWrite(QString(" duration: %1s\n").arg(info.durationSec, 0, 'f', 3)); + cliWrite(QString(" storage: %1\n").arg(info.storage)); + } + return 0; + } + // #411: text-to-motion (template-clip MVP). Self-contained — load → match a // motion-library clip → retarget → export. Handled before the other modes. if (generateMode) { diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 59bd4d89..0058e9f8 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -9,6 +9,7 @@ #include "NormalMapGenerator.h" #include "VATBaker.h" #include "MorphAnimationManager.h" +#include "AlembicImporter.h" #include "NodeAnimationManager.h" #include "PoseLibrary.h" #include "PrimitiveObject.h" @@ -666,6 +667,8 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("bake_vat"), &MCPServer::toolBakeVat}, {QStringLiteral("list_morph_targets"), &MCPServer::toolListMorphTargets}, {QStringLiteral("set_morph_weight"), &MCPServer::toolSetMorphWeight}, + {QStringLiteral("import_alembic"), &MCPServer::toolImportAlembic}, + {QStringLiteral("play_vertex_animation"), &MCPServer::toolPlayVertexAnimation}, {QStringLiteral("list_node_animations"), &MCPServer::toolListNodeAnimations}, {QStringLiteral("add_node_animation_clip"), &MCPServer::toolAddNodeAnimationClip}, {QStringLiteral("set_node_keyframe"), &MCPServer::toolSetNodeKeyframe}, @@ -709,6 +712,7 @@ bool MCPServer::isHeavyTool(const QString &name) QStringLiteral("open_scene"), QStringLiteral("bake_vat"), QStringLiteral("list_morph_targets"), + QStringLiteral("import_alembic"), QStringLiteral("cloud_upload") }; return heavyTools.contains(name); @@ -5960,6 +5964,67 @@ QJsonObject MCPServer::toolSetMorphWeight(const QJsonObject &args) QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); } +// --------------------------------------------------------------------------- +// Vertex-anim B3 (#519) — Alembic import + vertex-clip playback. +// --------------------------------------------------------------------------- + +QJsonObject MCPServer::toolImportAlembic(const QJsonObject &args) +{ + SentryReporter::addBreadcrumb("ai.tool_call", "import_alembic"); + + const QString filePath = args.value("file").toString(); + if (filePath.isEmpty()) + return makeErrorResult("Error: missing required 'file' argument"); + if (!QFileInfo::exists(filePath)) + return makeErrorResult(QString("Error: file not found: %1").arg(filePath)); + if (!AlembicImporter::available()) + return makeErrorResult( + "Error: this build has no Alembic support. Rebuild with -DENABLE_ALEMBIC=ON."); + + SentryReporter::addBreadcrumb("file.import", + QString("Importing Alembic cache %1").arg(filePath)); + + QString err; + Ogre::SceneNode* node = AlembicImporter::importToScene(filePath, &err); + if (!node) + return makeErrorResult( + err.isEmpty() ? QString("Error: failed to import %1").arg(filePath) : err); + + // Report the node + the vertex clips the import produced so the agent can + // immediately drive play_vertex_animation. + QJsonObject content; + content["ok"] = true; + content["file"] = filePath; + content["node"] = QString::fromStdString(node->getName()); + + QStringList entities, clips; + for (unsigned short i = 0; i < node->numAttachedObjects(); ++i) { + Ogre::MovableObject* obj = node->getAttachedObject(i); + if (!obj || obj->getMovableType() != "Entity") continue; + auto* ent = static_cast(obj); + entities.append(QString::fromStdString(ent->getName())); + if (auto* m = VertexAnimationManager::instance()) { + for (const QString& c : m->vertexClipsFor(ent)) + if (!clips.contains(c)) clips.append(c); + } + } + QJsonArray entArr, clipArr; + for (const QString& e : entities) entArr.append(e); + for (const QString& c : clips) clipArr.append(c); + content["entities"] = entArr; + content["vertexClips"] = clipArr; + return makeSuccessResult( + QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); +} + +QJsonObject MCPServer::toolPlayVertexAnimation(const QJsonObject &args) +{ + SentryReporter::addBreadcrumb("ai.tool_call", "play_vertex_animation"); + // A vertex clip surfaces as an ordinary AnimationState, so playback is + // identical to play_animation — delegate to keep one code path. + return toolPlayAnimation(args); +} + // --------------------------------------------------------------------------- // Node-anim C6 — clip + keyframe authoring on the live scene. // --------------------------------------------------------------------------- @@ -8287,6 +8352,44 @@ QJsonArray MCPServer::buildToolsList() ); } + // import_alembic + { + QJsonObject props; + props["file"] = QJsonObject{{"type", "string"}, {"description", "Path to an Alembic (.abc) vertex cache."}}; + QJsonArray required; + required.append("file"); + appendTool( + "import_alembic", + "Import an Alembic (.abc) vertex cache into the live scene. Decodes the first " + "animated polymesh into a fixed-topology frame set and builds a VAT_POSE-animated " + "Ogre entity (cloth/sim/fluid bakes from Houdini/Blender). Returns the created node, " + "entities, and vertex-animation clip names — drive them with play_vertex_animation. " + "Requires a build with ENABLE_ALEMBIC=ON.", + props, + required + ); + } + + // play_vertex_animation + { + QJsonObject props; + props["entity"] = QJsonObject{{"type", "string"}, {"description", "Entity name (from import_alembic)."}}; + props["animation"] = QJsonObject{{"type", "string"}, {"description", "Vertex-animation clip name."}}; + props["play"] = QJsonObject{{"type", "boolean"}, {"description", "true to play (default), false to stop."}}; + props["loop"] = QJsonObject{{"type", "boolean"}, {"description", "Loop the clip (default true)."}}; + QJsonArray required; + required.append("entity"); + required.append("animation"); + appendTool( + "play_vertex_animation", + "Play / stop a vertex-animation clip on a live entity. The clip surfaces as an " + "ordinary Ogre::AnimationState, so this behaves like play_animation for skeletal " + "clips. Returns an error if the entity or clip is not found.", + props, + required + ); + } + // list_node_animations { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index ab3ee5f4..df168337 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -267,6 +267,15 @@ private slots: /// Light — pure state poke on a live entity. QJsonObject toolSetMorphWeight(const QJsonObject &args); + /// Vertex-anim B3 (#519): import an Alembic (.abc) vertex cache into the + /// live scene as a VAT_POSE-animated mesh. Args: `file` (path). Heavy — + /// decodes the cache + builds an entity. Needs a build with ENABLE_ALEMBIC. + QJsonObject toolImportAlembic(const QJsonObject &args); + + /// Vertex-anim B3 (#519): enable + play a vertex-animation clip on an + /// entity. Args: `entity`, `animation`. Light — state poke on a live entity. + QJsonObject toolPlayVertexAnimation(const QJsonObject &args); + /// Node-anim C6: list node-animation clips on the live scene. /// No args. Light — pure read. QJsonObject toolListNodeAnimations(const QJsonObject &args); From d10aa950541456ce6b5b21e71f0c1ff7ae5819fd Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 17:24:51 -0400 Subject: [PATCH 07/22] feat(anim #519 B3): frame-cap honesty + readInfo/truncation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReadResult gains totalFrames + truncated so the 512-frame decode cap is no longer silent: importToScene logs a warning naming imported/total frames when it bites (CLAUDE.md "no silent caps"). True per-frame vertex-buffer streaming stays future work — VAT_POSE holds every frame resident by design. - Tests: readInfo matches a full decode (frames/verts/tris/fps/duration/ storage) on the 2-frame quad fixture; maxFrames caps + flags truncation; the non-ENABLE_ALEMBIC standalone test covers readInfo failing soft. Co-Authored-By: Claude Opus 4.8 --- src/AlembicImporter.cpp | 14 ++++++++++++++ src/AlembicImporter.h | 2 ++ src/AlembicImporter_test.cpp | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/AlembicImporter.cpp b/src/AlembicImporter.cpp index 06cef163..e1dca206 100644 --- a/src/AlembicImporter.cpp +++ b/src/AlembicImporter.cpp @@ -137,6 +137,8 @@ ReadResult readFrameSet(const QString& path, int maxFrames) size_t decodeCount = numSamples; if (maxFrames > 0 && static_cast(maxFrames) < decodeCount) decodeCount = static_cast(maxFrames); + r.totalFrames = static_cast(numSamples); + r.truncated = (decodeCount < numSamples); VertexAnimationManager::FrameSet& fs = r.frames; fs.vertexCount = static_cast(baseVerts); @@ -354,6 +356,18 @@ Ogre::SceneNode* importToScene(const QString& path, QString* error) if (!rr.ok) return fail(rr.error); + // No silent caps (CLAUDE.md): VAT_POSE holds every frame resident as an + // Ogre::Pose, so a multi-thousand-frame cache would balloon GPU memory. + // We cap the decode at 512 frames; say so loudly when it bites. True + // disk-streaming (swapping vertex buffers per frame) is future work. + if (rr.truncated) { + Ogre::LogManager::getSingleton().logWarning( + ("Alembic '" + QFileInfo(path).fileName().toStdString() + "': imported " + + std::to_string(rr.frames.frames.size()) + " of " + + std::to_string(rr.totalFrames) + + " frames (capped at 512 — VAT_POSE holds every frame resident).").c_str()); + } + auto* mgr = Manager::getSingletonPtr(); if (!mgr || !mgr->getSceneMgr()) return fail(QStringLiteral("No active scene to import into.")); diff --git a/src/AlembicImporter.h b/src/AlembicImporter.h index 1274e6a3..feb51838 100644 --- a/src/AlembicImporter.h +++ b/src/AlembicImporter.h @@ -43,6 +43,8 @@ struct ReadResult { QString error; VertexAnimationManager::FrameSet frames; ///< decoded cache (empty on !ok) QString meshName; ///< source IPolyMesh name (for the clip) + int totalFrames = 0; ///< frames present in the archive (before any maxFrames cap) + bool truncated = false;///< true when maxFrames dropped frames (frames.size() < totalFrames) }; /// Cheap metadata about an .abc cache WITHOUT decoding every frame's vertex diff --git a/src/AlembicImporter_test.cpp b/src/AlembicImporter_test.cpp index d8f7e273..9ad71451 100644 --- a/src/AlembicImporter_test.cpp +++ b/src/AlembicImporter_test.cpp @@ -18,6 +18,10 @@ TEST(AlembicImporterStandalone, UnavailableWithoutFlag) { QString err; EXPECT_EQ(AlembicImporter::importToScene("/nonexistent.abc", &err), nullptr); EXPECT_FALSE(err.isEmpty()); + // readInfo (B3) must also fail-soft without the flag. + auto info = AlembicImporter::readInfo("/nonexistent.abc"); + EXPECT_FALSE(info.ok); + EXPECT_FALSE(info.error.isEmpty()); } #else // ENABLE_ALEMBIC @@ -114,4 +118,35 @@ TEST_F(AlembicImporterTest, MissingFileFailsGracefully) { EXPECT_FALSE(rr.error.isEmpty()); } +// B3: readInfo returns the same metadata as a full decode but without reading +// every frame's positions. On the 2-frame quad it must match readFrameSet. +TEST_F(AlembicImporterTest, ReadInfoMatchesDecode) { + auto info = AlembicImporter::readInfo(abcPath); + ASSERT_TRUE(info.ok) << info.error.toStdString(); + EXPECT_EQ(info.frameCount, 2); + EXPECT_EQ(info.vertexCount, 4); + EXPECT_EQ(info.fps, 30); + // One quad → 2 triangles. + EXPECT_EQ(info.faceCount, 2); + // 2 frames is well under the pose/stream threshold (32) → "poses". + EXPECT_EQ(info.storage, QStringLiteral("poses")); + // duration = (frameCount - 1) / fps for uniform sampling. + EXPECT_NEAR(info.durationSec, 1.0f / 30.0f, 1e-4f); +} + +// B3: maxFrames caps the decode and flags truncation (no silent cap). +TEST_F(AlembicImporterTest, MaxFramesTruncates) { + auto rr = AlembicImporter::readFrameSet(abcPath, /*maxFrames=*/1); + ASSERT_TRUE(rr.ok) << rr.error.toStdString(); + EXPECT_EQ(rr.frames.frames.size(), 1u); + EXPECT_EQ(rr.totalFrames, 2); + EXPECT_TRUE(rr.truncated); + + // maxFrames >= total (or 0) must not flag truncation. + auto full = AlembicImporter::readFrameSet(abcPath, /*maxFrames=*/0); + ASSERT_TRUE(full.ok); + EXPECT_EQ(full.totalFrames, 2); + EXPECT_FALSE(full.truncated); +} + #endif // ENABLE_ALEMBIC From b927eeeccaa4d7cababff63874953936531df584 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 17:25:31 -0400 Subject: [PATCH 08/22] docs(#519 B3): document Alembic --info CLI + import_alembic/play_vertex_animation MCP Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 070e90e1..696f352f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ qtmesh info model.fbx --json # show mesh info (JSON) qtmesh convert model.fbx -o model.gltf2 # convert between formats qtmesh fix model.fbx -o fixed.fbx # re-import/export with standard optimizations qtmesh fix model.fbx --all # apply all extra fixes (remove degenerates, merge materials) +qtmesh anim cache.abc --info # Alembic vertex-cache metadata (frames/verts/fps/duration; needs -DENABLE_ALEMBIC) +qtmesh anim cache.abc --info --json # same, as JSON qtmesh anim model.fbx --list # list animations qtmesh anim model.fbx --list --json # list animations (JSON) qtmesh anim model.fbx --rename "Take 001" "Idle" -o out.fbx # rename an animation @@ -213,7 +215,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas The animation pipeline started skeleton-only; the #517 epic broadens it. Slices A/C/D shipped: **MorphAnimationManager** (`src/MorphAnimationManager.{h,cpp}`, morph targets / blend shapes — Ogre `Pose` + `VAT_POSE` tracks, `qtmesh morph` CLI, MCP, undo via `commands/MorphCommands`), **NodeAnimationManager** (non-skinned node TRS), **PoseLibrary** (named poses). All are `QML_SINGLETON`s registered in `mainwindow.cpp`. - **VertexAnimationManager** (`src/VertexAnimationManager.{h,cpp}`, Slice B #519): full-mesh per-vertex animation (cloth / sims / fluid bakes / Alembic caches — every vertex moves, no skeleton). Reuses Ogre's `VAT_POSE` path so the existing timeline/dope-sheet/loop play it with no new playback code. `FrameSet`/`FrameData` are the source-agnostic decoded-cache types; `buildClipFromFrames(mesh, name, frames)` reads submesh-0 bind positions and builds one `Ogre::Pose` per frame (delta vs bind) + one `VAT_POSE` track keyed per frame time. `sampleHeuristic(frameCount)` (< 32 → poses, else stream — the issue's rule) is static + unit-tested. Sentry `scene.anim.vertex_anim`. -- **AlembicImporter** (`src/AlembicImporter.{h,cpp}`, Slice B2): Alembic (.abc) reader behind `-DENABLE_ALEMBIC` (default OFF; `cmake/Alembic.cmake` FetchContents Imath 3.1.12 + Alembic 1.8.8, both BSD-3, fully static, all optional components off — Imath install stays ON so Alembic's unconditional `install(EXPORT)` finds it in an export set). `readFrameSet` decodes the first `IPolyMesh` into a `FrameSet` (pure data — rejects variable-topology caches, fan-triangulates n-gon faces); `importToScene` builds the base mesh + `VAT_POSE` clip + entity. `.abc` routes through `MeshImporterExporter::importer` (guarded — a non-Alembic build logs a clear "rebuild with -DENABLE_ALEMBIC" and skips). The reader is `#ifdef ENABLE_ALEMBIC`-guarded so the default build is unaffected; a round-trip test writes+reads a synthetic `.abc` (only compiled/run in the Alembic-on coverage CI lane). **B3 (pending):** disk-streaming for large caches, `qtmesh anim .abc --info` / `convert .abc -o .fbx`, MCP `import_alembic` / `play_vertex_animation`. +- **AlembicImporter** (`src/AlembicImporter.{h,cpp}`, Slice B2): Alembic (.abc) reader behind `-DENABLE_ALEMBIC` (default OFF; `cmake/Alembic.cmake` FetchContents Imath 3.1.12 + Alembic 1.8.8, both BSD-3, fully static, all optional components off — Imath install stays ON so Alembic's unconditional `install(EXPORT)` finds it in an export set). `readFrameSet` decodes the first `IPolyMesh` into a `FrameSet` (pure data — rejects variable-topology caches, fan-triangulates n-gon faces); `importToScene` builds the base mesh + `VAT_POSE` clip + entity. `.abc` routes through `MeshImporterExporter::importer` (guarded — a non-Alembic build logs a clear "rebuild with -DENABLE_ALEMBIC" and skips). The reader is `#ifdef ENABLE_ALEMBIC`-guarded so the default build is unaffected; a round-trip test writes+reads a synthetic `.abc` (only compiled/run in the Alembic-on coverage CI lane). **B3 (shipped):** `readInfo(path)` reads cache metadata (frames/verts/tris/fps/duration/storage) from the schema header + first sample without decoding all frames → **`qtmesh anim .abc --info [--json]`** (`CLIPipeline::cmdAnim`); MCP **`import_alembic`** (heavy — decodes into the live scene, reports node/entities/vertexClips) + **`play_vertex_animation`** (delegates to `toolPlayAnimation` since a vertex clip is an ordinary `AnimationState`). Frame cap: `readFrameSet(maxFrames)` and `importToScene` cap the decode at 512 frames and set `ReadResult::truncated` / log a warning when it bites (no silent cap) — VAT_POSE holds every frame resident, so true per-frame vertex-buffer streaming remains future work. ### Undo/Redo System From 28ed49de2849d182a5f93000faf7d900f47b355c Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 17:30:27 -0400 Subject: [PATCH 09/22] feat(#519 B3): add Alembic (.abc) filter to the import file dialog .abc already flowed into "All supported" (it's in Manager's valid-extension list), but there was no dedicated filter row to narrow to just Alembic caches. Add one alongside the PlayStation entry so .abc is discoverable in the import dialog. Test asserts the row is present. Co-Authored-By: Claude Opus 4.8 --- src/MeshImporterExporter.cpp | 1 + src/MeshImporterExporter_test.cpp | 1 + 2 files changed, 2 insertions(+) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 7c5281a4..bb9e0e41 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2700,6 +2700,7 @@ QString MeshImporterExporter::importFileDialogFilterFromExtensionList( const QString allSupported = globs.join(QLatin1Char(' ')); return QStringLiteral( "All supported (%1);;" + "Alembic vertex cache (*.abc);;" "PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply);;" "All files (*.*)") .arg(allSupported); diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 7909694b..4ed9cb19 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -587,6 +587,7 @@ TEST(MeshImporterExporterStandaloneTest, ImportFileDialogFilterFromExtensionList { QString f = MeshImporterExporter::importFileDialogFilterFromExtensionList(QStringLiteral(".fbx .obj")); EXPECT_TRUE(f.startsWith(QStringLiteral("All supported (*.fbx *.obj);;"))); + EXPECT_TRUE(f.contains(QStringLiteral("Alembic vertex cache (*.abc)"))); EXPECT_TRUE(f.contains(QStringLiteral("PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply)"))); EXPECT_TRUE(f.endsWith(QStringLiteral("All files (*.*)"))); } From 0f6dc8ce819a6df30310afc07bf99055cd264d0f Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 18:06:11 -0400 Subject: [PATCH 10/22] fix(#519): drop stale per-frame poses when rebuilding a vertex clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (Codex P2 / CodeRabbit Major): buildClipFromFrames removed the old Animation on rebuild but not the dense "/frameN" poses it created. Ogre poses are mesh-level and are still walked by the dope-sheet / export paths, so re-importing an .abc (or rebuilding any vertex clip) appended stale poses and shifted every pose index. Now removes all same-named frame poses (index-based, erased from the back to keep remaining indices stable — the MorphCommands pattern) before recreating the clip. Test: rebuild with a different frame count leaves only the new poses; a differently-named clip coexists. Co-Authored-By: Claude Opus 4.8 --- src/VertexAnimationManager.cpp | 18 ++++++++++++++++++ src/VertexAnimationManager_test.cpp | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/VertexAnimationManager.cpp b/src/VertexAnimationManager.cpp index 27ed359d..74c94df6 100644 --- a/src/VertexAnimationManager.cpp +++ b/src/VertexAnimationManager.cpp @@ -125,6 +125,24 @@ bool VertexAnimationManager::buildClipFromFrames(Ogre::Mesh* mesh, if (mesh->hasAnimation(animName)) mesh->removeAnimation(animName); + // Rebuilding the same clip must also drop the dense per-frame poses it + // created last time ("/frameN"). Ogre poses are mesh-level and are + // still walked by the dope-sheet / export paths, so without this a + // re-import appends stale poses (and shifts every pose index). removePose + // is index-based, so collect matching indices and erase from the back to + // keep the remaining indices stable. (Same pattern as MorphCommands.) + { + const std::string prefix = animName + "/frame"; + const auto& poseList = mesh->getPoseList(); + std::vector drop; + for (unsigned short pi = 0; pi < poseList.size(); ++pi) { + if (poseList[pi] && poseList[pi]->getName().compare(0, prefix.size(), prefix) == 0) + drop.push_back(pi); + } + for (auto it = drop.rbegin(); it != drop.rend(); ++it) + mesh->removePose(*it); + } + // VAT_POSE targets submesh handle 1 (submesh 0); 0 is shared geometry. Ogre::SubMesh* sub = mesh->getSubMesh(0); const unsigned short target = sub->useSharedVertices ? 0 : 1; diff --git a/src/VertexAnimationManager_test.cpp b/src/VertexAnimationManager_test.cpp index d785ad85..eddcb929 100644 --- a/src/VertexAnimationManager_test.cpp +++ b/src/VertexAnimationManager_test.cpp @@ -145,6 +145,31 @@ TEST_F(VertexAnimationManagerTest, BuildClipFromFramesCreatesPosesAndTrack) { EXPECT_NEAR(a->getLength(), 7.0f / 30.0f, 1e-4f); } +// Rebuilding the same clip must not leak the previous run's per-frame poses +// ("/frameN"). Ogre poses are mesh-level; a leak would accumulate and +// shift pose indices (regression from the B3 review). +TEST_F(VertexAnimationManagerTest, RebuildDoesNotLeakPoses) { + auto mesh = createStaticMesh("vam_rebuild", 3); + + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "wobble", makeWobble(3, 8))); + EXPECT_EQ(mesh->getPoseCount(), 8u); + + // Rebuild with a DIFFERENT frame count — pose count must reflect only the + // new build, not 8 + 5. + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "wobble", makeWobble(3, 5))); + EXPECT_EQ(mesh->getPoseCount(), 5u); + ASSERT_TRUE(mesh->hasAnimation("wobble")); + EXPECT_EQ(mesh->getAnimation("wobble")->getVertexTrack(1)->getNumKeyFrames(), 5u); + + // A second, differently-named clip must coexist (only same-named frames + // are dropped). + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "other", makeWobble(3, 4))); + EXPECT_EQ(mesh->getPoseCount(), 9u); // 5 (wobble) + 4 (other) +} + TEST_F(VertexAnimationManagerTest, BuildClipRejectsVertexCountMismatch) { auto mesh = createStaticMesh("vam_mismatch", 3); auto fs = makeWobble(5, 4); // 5 verts vs the mesh's 3 From acf5aa0d01a1a6f17d8aaa6dde35626bcadb295b Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 18:11:37 -0400 Subject: [PATCH 11/22] =?UTF-8?q?fix(#519):=20address=20B3=20review=20?= =?UTF-8?q?=E2=80=94=20runOgreOp=20guard,=20readInfo=20empty-sample=20guar?= =?UTF-8?q?d,=20CLI=20breadcrumb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP toolImportAlembic now runs importToScene through runOgreOp so an Ogre::Exception from scene-node/entity creation returns a clean MCP error instead of taking down the server (CodeRabbit Major). - readInfo bails when the polymesh has zero samples before schema.get(0) (UB in the Alembic API) — mirrors readFrameSet's existing guard. - `anim .abc --info` now emits a cli.anim Sentry breadcrumb (the early-return branch skipped the shared one downstream). Co-Authored-By: Claude Opus 4.8 --- src/AlembicImporter.cpp | 7 +++++ src/CLIPipeline.cpp | 2 ++ src/MCPServer.cpp | 65 ++++++++++++++++++++++------------------- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/AlembicImporter.cpp b/src/AlembicImporter.cpp index e1dca206..3da1f8a1 100644 --- a/src/AlembicImporter.cpp +++ b/src/AlembicImporter.cpp @@ -220,6 +220,13 @@ InfoResult readInfo(const QString& path) } IPolyMeshSchema& schema = mesh.getSchema(); const size_t numSamples = schema.getNumSamples(); + // Accessing sample 0 on a zero-sample schema is UB in the Alembic API, + // so bail before schema.get() (mirrors readFrameSet's guard). + if (numSamples == 0) { + r.error = QStringLiteral("Alembic mesh '%1' has no samples") + .arg(QString::fromStdString(mesh.getName())); + return r; + } Alembic::AbcCoreAbstract::TimeSamplingPtr ts = schema.getTimeSampling(); IPolyMeshSchema::Sample first; schema.get(first, ISampleSelector(static_cast(0))); diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 4632645d..3c9eb2dc 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -2159,6 +2159,8 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) // decoding all frames. Only meaningful for .abc; other formats fall through // to the normal anim modes. if (infoMode && QFileInfo(filePath).suffix().compare("abc", Qt::CaseInsensitive) == 0) { + SentryReporter::addBreadcrumb("cli.anim", + QString("Anim info .abc %1").arg(QFileInfo(filePath).fileName())); if (!AlembicImporter::available()) { err() << "Error: Alembic support not compiled in (rebuild with " "-DENABLE_ALEMBIC=ON)." << Qt::endl; diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 0058e9f8..cb0b6d37 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -5984,37 +5984,42 @@ QJsonObject MCPServer::toolImportAlembic(const QJsonObject &args) SentryReporter::addBreadcrumb("file.import", QString("Importing Alembic cache %1").arg(filePath)); - QString err; - Ogre::SceneNode* node = AlembicImporter::importToScene(filePath, &err); - if (!node) - return makeErrorResult( - err.isEmpty() ? QString("Error: failed to import %1").arg(filePath) : err); + // importToScene creates scene nodes / entities, which can throw + // Ogre::Exception — run through runOgreOp so a failure returns a clean MCP + // error instead of taking down the server (matches the other Ogre tools). + return runOgreOp([&]() -> QJsonObject { + QString err; + Ogre::SceneNode* node = AlembicImporter::importToScene(filePath, &err); + if (!node) + return makeErrorResult( + err.isEmpty() ? QString("Error: failed to import %1").arg(filePath) : err); - // Report the node + the vertex clips the import produced so the agent can - // immediately drive play_vertex_animation. - QJsonObject content; - content["ok"] = true; - content["file"] = filePath; - content["node"] = QString::fromStdString(node->getName()); - - QStringList entities, clips; - for (unsigned short i = 0; i < node->numAttachedObjects(); ++i) { - Ogre::MovableObject* obj = node->getAttachedObject(i); - if (!obj || obj->getMovableType() != "Entity") continue; - auto* ent = static_cast(obj); - entities.append(QString::fromStdString(ent->getName())); - if (auto* m = VertexAnimationManager::instance()) { - for (const QString& c : m->vertexClipsFor(ent)) - if (!clips.contains(c)) clips.append(c); - } - } - QJsonArray entArr, clipArr; - for (const QString& e : entities) entArr.append(e); - for (const QString& c : clips) clipArr.append(c); - content["entities"] = entArr; - content["vertexClips"] = clipArr; - return makeSuccessResult( - QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); + // Report the node + the vertex clips the import produced so the agent + // can immediately drive play_vertex_animation. + QJsonObject content; + content["ok"] = true; + content["file"] = filePath; + content["node"] = QString::fromStdString(node->getName()); + + QStringList entities, clips; + for (unsigned short i = 0; i < node->numAttachedObjects(); ++i) { + Ogre::MovableObject* obj = node->getAttachedObject(i); + if (!obj || obj->getMovableType() != "Entity") continue; + auto* ent = static_cast(obj); + entities.append(QString::fromStdString(ent->getName())); + if (auto* m = VertexAnimationManager::instance()) { + for (const QString& c : m->vertexClipsFor(ent)) + if (!clips.contains(c)) clips.append(c); + } + } + QJsonArray entArr, clipArr; + for (const QString& e : entities) entArr.append(e); + for (const QString& c : clips) clipArr.append(c); + content["entities"] = entArr; + content["vertexClips"] = clipArr; + return makeSuccessResult( + QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); + }); } QJsonObject MCPServer::toolPlayVertexAnimation(const QJsonObject &args) From 0ae3cd279ebdbb2663aacc97378e6b51251fb94e Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 22:00:00 -0400 Subject: [PATCH 12/22] feat(#519): Vertex Morph Animation group in Edit Mode + per-format import filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Morph authoring chicken-and-egg fix. Authoring a morph target captures the CURRENT edited vertices (vs the bind pose), which requires Edit Mode — but the only morph UI lived in the Animation-Mode "Animations" section, whose "+ Add…" was gated on Edit Mode. You could never reach both at once. - New "Vertex Morph Animation" CollapsibleSection, shown in Edit Mode. The morph panel (add-from-current-edit, target list + weight sliders, rename/delete) was extracted verbatim from animationComponent into a reusable `vertexMorphComponent` and hosted here. Empty-state hint guides the sculpt→capture flow. - Animation Control section now also opens on hasAnimations (not just skeletal hasAnimation) so the dope sheet appears to key/scrub morph + vertex clips. (The curve editor stays bone-TRS-only by design; morph keyframes live in the dope sheet's Morph Targets rows.) Import file dialog: expand to a full per-format named list (FBX, glTF, OBJ, Collada, Ogre Mesh, STL, PLY, 3DS, Blender, X, BVH, LightWave) plus the Alembic + PlayStation rows — each emitted only when its extension is in the valid list. Previously it was a single collapsed "All supported" row. Co-Authored-By: Claude Opus 4.8 --- qml/PropertiesPanel.qml | 672 ++++++++++++++++-------------- src/MeshImporterExporter.cpp | 51 ++- src/MeshImporterExporter_test.cpp | 22 + 3 files changed, 426 insertions(+), 319 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index d154cf0f..46791b6b 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -489,6 +489,21 @@ Rectangle { Component.onCompleted: content = editModeToolsComponent } + // ---- Vertex Morph Animation (Edit Mode) ---- + // Morph-target authoring belongs in Edit Mode: you sculpt vertices, + // then capture them as a target. (Previously the only morph UI lived + // in the Animation-Mode "Animations" section, whose "+ Add…" needed + // Edit Mode — an unreachable chicken-and-egg.) + CollapsibleSection { + title: "Vertex Morph Animation" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.EditMode, + EditModeController.editModeActive) + expanded: false + + Component.onCompleted: content = vertexMorphComponent + } + // ---- AI: Image → 3D (epic #764, Object mode) ---- CollapsibleSection { title: "AI: Image → 3D" @@ -691,11 +706,18 @@ Rectangle { } // ---- Animation Control (keyframe editor) ---- + // Gated on a skeletal animation OR any mesh/vertex animation + // (morph + Alembic vertex clips surface as AnimationStates, which + // PropertiesPanelController.hasAnimations picks up). Without the + // hasAnimations clause the dope sheet / curve editor never appeared + // for a morph-only mesh, so a freshly-authored morph target had no + // timeline to key/scrub — even though allMorphRows() enumerates it. CollapsibleSection { title: "Animation Control" sectionVisible: root.modeToolSectionVisible( EditorModeController.AnimationMode, - AnimationControlController.hasAnimation) + AnimationControlController.hasAnimation + || PropertiesPanelController.hasAnimations) expanded: false Component.onCompleted: content = animControlComponent @@ -7061,346 +7083,376 @@ Rectangle { } } - // ---- Morph Targets / Blend Shapes (slice A2) ---- - // Per-pose weight sliders, sourced from MorphAnimationManager. - // Lives at the bottom of the Animations section, outside the - // per-entity repeater above — morph data is read from the - // SelectionSet's first entity to keep the surface focused. - // Authoring (add/rename/delete) lands in A3. - Rectangle { - width: parent.width - 16 - visible: morphCol.targetCount > 0 - height: morphCol.implicitHeight + 12 - color: PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - border.width: 1 - radius: 3 + } + } - Column { - id: morphCol - anchors.fill: parent - anchors.margins: 6 - spacing: 4 + // ---- Vertex Morph Animation (Edit Mode authoring) ---- + // Blend-shape / morph-target authoring lives in Edit Mode because you + // capture the CURRENT edited vertex positions (vs the bind pose) as a new + // target. Playback/weight tuning of existing targets also works here. + Component { + id: vertexMorphComponent + + // ---- Morph Targets / Blend Shapes (slice A2) ---- + // Per-pose weight sliders, sourced from MorphAnimationManager. + // Lives at the bottom of the Animations section, outside the + // per-entity repeater above — morph data is read from the + // SelectionSet's first entity to keep the surface focused. + // Authoring (add/rename/delete) lands in A3. + Rectangle { + width: parent.width - 16 + // Show when the mesh already has targets, OR when an entity is + // selected so the FIRST target can be authored. Without the + // second clause the panel (and its "+ Add…" button) was gated on + // targetCount > 0 — a chicken-and-egg that made it impossible to + // create the first morph target from the UI. The Add button + // stays disabled outside Edit Mode (with a tooltip) so the empty + // panel just advertises the feature + the entry path. + visible: morphCol.targetCount > 0 + || PropertiesPanelController.hasEntitySelection + height: morphCol.implicitHeight + 12 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 - // Defensive `|| []` so an unexpected null return - // doesn't crash the binding — the manager currently - // always returns a QStringList, but contracts drift. - property var targets: MorphAnimationManager.morphTargetsForSelection() || [] - property int targetCount: targets.length - property string filter: "" - // Bumped on `morphWeightChanged`; sliders bind their - // `value` to a function call gated on this counter so - // weight changes from any code path (Reset all, - // dope-sheet scrubs in later slices, MCP, etc.) flow - // back into the UI rather than going stale until the - // delegate is recreated. - property int weightTick: 0 + Column { + id: morphCol + anchors.fill: parent + anchors.margins: 6 + spacing: 4 - Connections { - target: MorphAnimationManager - function onMorphTargetsChanged() { - morphCol.targets = MorphAnimationManager.morphTargetsForSelection() || [] - morphCol.weightTick = morphCol.weightTick + 1 - } - function onMorphWeightChanged(entity, name, weight) { - morphCol.weightTick = morphCol.weightTick + 1 - } + // Defensive `|| []` so an unexpected null return + // doesn't crash the binding — the manager currently + // always returns a QStringList, but contracts drift. + property var targets: MorphAnimationManager.morphTargetsForSelection() || [] + property int targetCount: targets.length + property string filter: "" + // Bumped on `morphWeightChanged`; sliders bind their + // `value` to a function call gated on this counter so + // weight changes from any code path (Reset all, + // dope-sheet scrubs in later slices, MCP, etc.) flow + // back into the UI rather than going stale until the + // delegate is recreated. + property int weightTick: 0 + + Connections { + target: MorphAnimationManager + function onMorphTargetsChanged() { + morphCol.targets = MorphAnimationManager.morphTargetsForSelection() || [] + morphCol.weightTick = morphCol.weightTick + 1 } + function onMorphWeightChanged(entity, name, weight) { + morphCol.weightTick = morphCol.weightTick + 1 + } + } - // RowLayout (not a plain Row with a magic-number spacer): - // the old `Item { width: parent.width - 320 }` went negative - // on a narrow Inspector, overlapping the title with the - // buttons. Here the title takes the flexible space and - // elides; the buttons keep their intrinsic size at the right. - RowLayout { - width: parent.width - spacing: 4 + // RowLayout (not a plain Row with a magic-number spacer): + // the old `Item { width: parent.width - 320 }` went negative + // on a narrow Inspector, overlapping the title with the + // buttons. Here the title takes the flexible space and + // elides; the buttons keep their intrinsic size at the right. + RowLayout { + width: parent.width + spacing: 4 + Text { + Layout.fillWidth: true + elide: Text.ElideRight + text: "Morph Targets (" + morphCol.targetCount + ")" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + // Add from current edit — captures the user's current + // edit-mode geometry minus the bind-pose baseline as + // a new morph target. Disabled (greyed out, forbidden + // cursor) when outside edit mode because + // EditableSubMesh::originalPositions is only + // populated by EditModeController and the C++ method + // would return false anyway. + Rectangle { + id: addBtn + property bool canAddFromEdit: EditModeController.editModeActive + Layout.preferredWidth: 56 + Layout.preferredHeight: 20 + radius: 3 + opacity: canAddFromEdit ? 1.0 : 0.45 + color: addMa.containsMouse && canAddFromEdit + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor Text { - Layout.fillWidth: true - elide: Text.ElideRight - text: "Morph Targets (" + morphCol.targetCount + ")" + anchors.centerIn: parent + text: "+ Add…" color: PropertiesPanelController.textColor - font.pixelSize: 11 - font.bold: true + font.pixelSize: 9 } - // Add from current edit — captures the user's current - // edit-mode geometry minus the bind-pose baseline as - // a new morph target. Disabled (greyed out, forbidden - // cursor) when outside edit mode because - // EditableSubMesh::originalPositions is only - // populated by EditModeController and the C++ method - // would return false anyway. - Rectangle { - id: addBtn - property bool canAddFromEdit: EditModeController.editModeActive - Layout.preferredWidth: 56 - Layout.preferredHeight: 20 - radius: 3 - opacity: canAddFromEdit ? 1.0 : 0.45 - color: addMa.containsMouse && canAddFromEdit - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor - Text { - anchors.centerIn: parent - text: "+ Add…" - color: PropertiesPanelController.textColor - font.pixelSize: 9 - } - MouseArea { - id: addMa - anchors.fill: parent - hoverEnabled: true - enabled: addBtn.canAddFromEdit - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor - onClicked: { - addNameField.text = "" - addError.text = "" - addNamePopup.open() - } - ToolTip.visible: containsMouse && !enabled - ToolTip.text: "Enter Edit Mode (Tab) to add morph targets from current edit." + MouseArea { + id: addMa + anchors.fill: parent + hoverEnabled: true + enabled: addBtn.canAddFromEdit + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: { + addNameField.text = "" + addError.text = "" + addNamePopup.open() } + ToolTip.visible: containsMouse && !enabled + ToolTip.text: "Enter Edit Mode (Tab) to add morph targets from current edit." } - // Reset all: walks every target and sets weight to 0. - Rectangle { - Layout.preferredWidth: 60 - Layout.preferredHeight: 20 - radius: 3 - color: resetMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor - Text { - anchors.centerIn: parent - text: "Reset all" - color: PropertiesPanelController.textColor - font.pixelSize: 9 - } - MouseArea { - id: resetMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - for (var i = 0; i < morphCol.targets.length; ++i) - MorphAnimationManager.setWeightForSelection(morphCol.targets[i], 0) - } + } + // Reset all: walks every target and sets weight to 0. + Rectangle { + Layout.preferredWidth: 60 + Layout.preferredHeight: 20 + radius: 3 + color: resetMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: "Reset all" + color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: resetMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + for (var i = 0; i < morphCol.targets.length; ++i) + MorphAnimationManager.setWeightForSelection(morphCol.targets[i], 0) } } } + } - // Inline name-entry popup for "Add from edit…". Kept - // simple (no styled component) so a misbehaving custom - // dialog can't break the rest of the panel — Popup is - // a built-in Qt Quick Controls primitive with no - // singleton dependencies. - Popup { - id: addNamePopup - modal: true - focus: true - width: 240 - contentItem: Column { + // Inline name-entry popup for "Add from edit…". Kept + // simple (no styled component) so a misbehaving custom + // dialog can't break the rest of the panel — Popup is + // a built-in Qt Quick Controls primitive with no + // singleton dependencies. + Popup { + id: addNamePopup + modal: true + focus: true + width: 240 + contentItem: Column { + spacing: 6 + Text { + text: "New morph target name:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + TextField { + id: addNameField + width: 220 + font.pixelSize: 11 + onAccepted: addConfirmMa.confirm() + onTextChanged: addError.text = "" + Component.onCompleted: forceActiveFocus() + } + // Inline error: shown when the C++ side rejects + // the request (duplicate name, no vertex moved, + // not in edit mode, …). We deliberately keep the + // popup open so the user can fix the input + // without retyping. + Text { + id: addError + text: "" + visible: text.length > 0 + color: "#d65d5d" + font.pixelSize: 10 + width: 220 + wrapMode: Text.Wrap + } + Row { spacing: 6 - Text { - text: "New morph target name:" - color: PropertiesPanelController.textColor - font.pixelSize: 11 - } - TextField { - id: addNameField - width: 220 - font.pixelSize: 11 - onAccepted: addConfirmMa.confirm() - onTextChanged: addError.text = "" - Component.onCompleted: forceActiveFocus() - } - // Inline error: shown when the C++ side rejects - // the request (duplicate name, no vertex moved, - // not in edit mode, …). We deliberately keep the - // popup open so the user can fix the input - // without retyping. - Text { - id: addError - text: "" - visible: text.length > 0 - color: "#d65d5d" - font.pixelSize: 10 - width: 220 - wrapMode: Text.Wrap - } - Row { - spacing: 6 - Rectangle { - width: 60; height: 20; radius: 3 - color: addConfirmMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor - Text { anchors.centerIn: parent; text: "Save"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } - MouseArea { - id: addConfirmMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - function confirm() { - var n = addNameField.text.trim() - if (n.length === 0) { - addError.text = "Name cannot be empty." - return - } - if (!EditModeController.editModeActive) { - addError.text = "Enter Edit Mode (Tab) before saving." - return - } - var ok = MorphAnimationManager.addMorphTargetFromCurrentEdit(n) - if (ok) { - addNamePopup.close() - } else { - // C++ rejected — likely name collision or - // no vertex moved vs the bind baseline. - addError.text = "Couldn't save: name already in use, or no vertex was edited." - } + Rectangle { + width: 60; height: 20; radius: 3 + color: addConfirmMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Save"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: addConfirmMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + function confirm() { + var n = addNameField.text.trim() + if (n.length === 0) { + addError.text = "Name cannot be empty." + return + } + if (!EditModeController.editModeActive) { + addError.text = "Enter Edit Mode (Tab) before saving." + return + } + var ok = MorphAnimationManager.addMorphTargetFromCurrentEdit(n) + if (ok) { + addNamePopup.close() + } else { + // C++ rejected — likely name collision or + // no vertex moved vs the bind baseline. + addError.text = "Couldn't save: name already in use, or no vertex was edited." } - onClicked: confirm() } + onClicked: confirm() } - Rectangle { - width: 60; height: 20; radius: 3 - color: addCancelMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor - Text { anchors.centerIn: parent; text: "Cancel"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } - MouseArea { - id: addCancelMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: addNamePopup.close() - } + } + Rectangle { + width: 60; height: 20; radius: 3 + color: addCancelMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Cancel"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: addCancelMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: addNamePopup.close() } } } } + } - // Filter / search — characters often have 50+ blend - // shapes, scanning a flat list is hopeless without - // a typeahead box. - TextField { - id: filterField - width: parent.width - placeholderText: "Filter targets…" - font.pixelSize: 10 - onTextChanged: morphCol.filter = text - visible: morphCol.targetCount > 6 - } + // Empty-state hint: this group lives in Edit Mode, so the flow + // is always "sculpt vertices → capture". Guide the user rather + // than leaving a bare header on a target-less mesh. + Text { + visible: morphCol.targetCount === 0 + width: parent.width + wrapMode: Text.Wrap + color: PropertiesPanelController.textColor + opacity: 0.7 + font.pixelSize: 10 + text: "Move some vertices, then “+ Add…” to capture them as a morph target." + } - // One row per target. Hidden when filter doesn't match. - Repeater { - model: morphCol.targets - Row { - width: morphCol.width - spacing: 4 - visible: morphCol.filter === "" - || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 - height: visible ? 22 : 0 - - // Name — double-click to rename in place, - // matching the per-animation rename UX above. - Text { - id: morphNameText - visible: !morphNameEdit.visible - text: modelData - color: PropertiesPanelController.textColor - font.pixelSize: 10 - width: 120 - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - MouseArea { - anchors.fill: parent - onDoubleClicked: { - morphNameEdit.text = modelData - morphNameEdit.visible = true - morphNameEdit.forceActiveFocus() - morphNameEdit.selectAll() - } + // Filter / search — characters often have 50+ blend + // shapes, scanning a flat list is hopeless without + // a typeahead box. + TextField { + id: filterField + width: parent.width + placeholderText: "Filter targets…" + font.pixelSize: 10 + onTextChanged: morphCol.filter = text + visible: morphCol.targetCount > 6 + } + + // One row per target. Hidden when filter doesn't match. + Repeater { + model: morphCol.targets + Row { + width: morphCol.width + spacing: 4 + visible: morphCol.filter === "" + || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 + height: visible ? 22 : 0 + + // Name — double-click to rename in place, + // matching the per-animation rename UX above. + Text { + id: morphNameText + visible: !morphNameEdit.visible + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 10 + width: 120 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + MouseArea { + anchors.fill: parent + onDoubleClicked: { + morphNameEdit.text = modelData + morphNameEdit.visible = true + morphNameEdit.forceActiveFocus() + morphNameEdit.selectAll() } } - TextInput { - id: morphNameEdit - visible: false - width: 120 - color: PropertiesPanelController.textColor - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - selectByMouse: true - // Set by `Keys.onEscapePressed`; checked in - // `onEditingFinished` so that hiding the - // input on Escape (which causes focus loss - // and fires `editingFinished`) doesn't - // accidentally commit the rename. - property bool cancelled: false - Rectangle { - anchors.fill: parent - anchors.margins: -2 - z: -1 - color: PropertiesPanelController.inputColor - border.color: PropertiesPanelController.highlightColor - border.width: 1 - radius: 2 - } - onEditingFinished: { - if (cancelled) { cancelled = false; visible = false; return } - var trimmed = text.trim() - if (trimmed.length > 0 && trimmed !== modelData) - MorphAnimationManager.renameMorphTarget(modelData, trimmed) - visible = false - } - Keys.onEscapePressed: { cancelled = true; visible = false } + } + TextInput { + id: morphNameEdit + visible: false + width: 120 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + selectByMouse: true + // Set by `Keys.onEscapePressed`; checked in + // `onEditingFinished` so that hiding the + // input on Escape (which causes focus loss + // and fires `editingFinished`) doesn't + // accidentally commit the rename. + property bool cancelled: false + Rectangle { + anchors.fill: parent + anchors.margins: -2 + z: -1 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.highlightColor + border.width: 1 + radius: 2 } - Slider { - id: weightSlider - from: 0; to: 1; stepSize: 0.01 - width: parent.width - 222 - // Bind to `weightTick` so changes that - // bypass user drag (Reset all, MCP, future - // dope-sheet scrubs) refresh the readout. - value: (morphCol.weightTick, - MorphAnimationManager.weightForSelection(modelData)) - anchors.verticalCenter: parent.verticalCenter - onMoved: MorphAnimationManager.setWeightForSelection(modelData, value) + onEditingFinished: { + if (cancelled) { cancelled = false; visible = false; return } + var trimmed = text.trim() + if (trimmed.length > 0 && trimmed !== modelData) + MorphAnimationManager.renameMorphTarget(modelData, trimmed) + visible = false } + Keys.onEscapePressed: { cancelled = true; visible = false } + } + Slider { + id: weightSlider + from: 0; to: 1; stepSize: 0.01 + width: parent.width - 222 + // Bind to `weightTick` so changes that + // bypass user drag (Reset all, MCP, future + // dope-sheet scrubs) refresh the readout. + value: (morphCol.weightTick, + MorphAnimationManager.weightForSelection(modelData)) + anchors.verticalCenter: parent.verticalCenter + onMoved: MorphAnimationManager.setWeightForSelection(modelData, value) + } + Text { + text: weightSlider.value.toFixed(2) + color: PropertiesPanelController.textColor + font.pixelSize: 10 + width: 36 + anchors.verticalCenter: parent.verticalCenter + } + // Delete (×) — drops the pose + animation + // through DeleteMorphTargetCommand so Ctrl+Z + // restores it. + Rectangle { + width: 18; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: morphDelMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" Text { - text: weightSlider.value.toFixed(2) + anchors.centerIn: parent + text: "×" color: PropertiesPanelController.textColor - font.pixelSize: 10 - width: 36 - anchors.verticalCenter: parent.verticalCenter + font.pixelSize: 12 + font.bold: true } - // Delete (×) — drops the pose + animation - // through DeleteMorphTargetCommand so Ctrl+Z - // restores it. - Rectangle { - width: 18; height: 18; radius: 3 - anchors.verticalCenter: parent.verticalCenter - color: morphDelMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : "transparent" - Text { - anchors.centerIn: parent - text: "×" - color: PropertiesPanelController.textColor - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: morphDelMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: MorphAnimationManager.deleteMorphTarget(modelData) - } + MouseArea { + id: morphDelMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: MorphAnimationManager.deleteMorphTarget(modelData) } } } diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index bb9e0e41..c8eddf31 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2690,20 +2690,53 @@ QString MeshImporterExporter::importFileDialogFilterFromExtensionList( const QString& spaceSeparatedDotExtensions) { const QStringList parts = spaceSeparatedDotExtensions.split(' ', Qt::SkipEmptyParts); - QStringList globs; + QStringList globs; // "*.ext" for the "All supported" row + QSet present; // lower-cased ".ext" set for lookups globs.reserve(parts.size()); for (QString ext : parts) { - ext = ext.trimmed(); - if (ext.startsWith('.')) + ext = ext.trimmed().toLower(); + if (ext.startsWith('.')) { globs.append(QLatin1Char('*') + ext); + present.insert(ext); + } } const QString allSupported = globs.join(QLatin1Char(' ')); - return QStringLiteral( - "All supported (%1);;" - "Alembic vertex cache (*.abc);;" - "PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply);;" - "All files (*.*)") - .arg(allSupported); + + // Named per-format rows, in a sensible priority order. Each is emitted only + // if at least one of its extensions is in the valid list (so the dialog + // never advertises a format the loader can't handle). Grouped rows (e.g. + // glTF *.gltf/*.glb) show if ANY member is present. Anything valid but not + // named here still loads via "All supported" / "All files". + struct NamedFilter { const char* label; QStringList exts; }; + const QList named = { + {"FBX (*.fbx)", {".fbx"}}, + {"glTF 2.0 (*.gltf *.glb *.vrm)", {".gltf", ".glb", ".vrm"}}, + {"Wavefront OBJ (*.obj)", {".obj"}}, + {"Collada (*.dae)", {".dae"}}, + {"Ogre Mesh (*.mesh *.mesh.xml)", {".mesh"}}, + {"STL (*.stl)", {".stl"}}, + {"PLY (*.ply)", {".ply"}}, + {"3DS (*.3ds)", {".3ds"}}, + {"Blender (*.blend)", {".blend"}}, + {"DirectX X (*.x)", {".x"}}, + {"Biovision BVH (*.bvh)", {".bvh"}}, + {"LightWave (*.lwo *.lws *.lxo)", {".lwo", ".lws", ".lxo"}}, + {"Alembic vertex cache (*.abc)", {".abc"}}, + {"PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply)", {".rsd", ".tmd", ".ply"}}, + }; + + QStringList rows; + rows.reserve(named.size() + 2); + rows << QStringLiteral("All supported (%1)").arg(allSupported); + for (const NamedFilter& nf : named) { + bool any = false; + for (const QString& e : nf.exts) + if (present.contains(e)) { any = true; break; } + if (any) + rows << QString::fromLatin1(nf.label); + } + rows << QStringLiteral("All files (*.*)"); + return rows.join(QStringLiteral(";;")); } QString MeshImporterExporter::importFileDialogFilter() diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 4ed9cb19..16f3e336 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -585,9 +585,29 @@ TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ContainsAllForma TEST(MeshImporterExporterStandaloneTest, ImportFileDialogFilterFromExtensionList_BuildsRows) { + // Per-format rows are emitted only for extensions actually present. With + // just .fbx/.obj, the named FBX + OBJ rows show, but Alembic / PlayStation + // (whose extensions aren't in the list) do not. QString f = MeshImporterExporter::importFileDialogFilterFromExtensionList(QStringLiteral(".fbx .obj")); EXPECT_TRUE(f.startsWith(QStringLiteral("All supported (*.fbx *.obj);;"))); + EXPECT_TRUE(f.contains(QStringLiteral("FBX (*.fbx)"))); + EXPECT_TRUE(f.contains(QStringLiteral("Wavefront OBJ (*.obj)"))); + EXPECT_FALSE(f.contains(QStringLiteral("Alembic vertex cache (*.abc)"))); + EXPECT_FALSE(f.contains(QStringLiteral("PlayStation"))); + EXPECT_TRUE(f.endsWith(QStringLiteral("All files (*.*)"))); +} + +TEST(MeshImporterExporterStandaloneTest, ImportFileDialogFilterFromExtensionList_NamedRowsGatedByExtension) +{ + // Full valid list → the Alembic + PlayStation named rows appear, and the + // per-format rows are present. Order: All supported first, All files last. + QString f = MeshImporterExporter::importFileDialogFilterFromExtensionList( + QStringLiteral(".fbx .obj .gltf .glb .dae .stl .ply .abc .rsd .tmd")); + EXPECT_TRUE(f.startsWith(QStringLiteral("All supported ("))); + EXPECT_TRUE(f.contains(QStringLiteral("*.abc"))); // in All supported EXPECT_TRUE(f.contains(QStringLiteral("Alembic vertex cache (*.abc)"))); + EXPECT_TRUE(f.contains(QStringLiteral("glTF 2.0 (*.gltf *.glb *.vrm)"))); + EXPECT_TRUE(f.contains(QStringLiteral("Collada (*.dae)"))); EXPECT_TRUE(f.contains(QStringLiteral("PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply)"))); EXPECT_TRUE(f.endsWith(QStringLiteral("All files (*.*)"))); } @@ -2000,6 +2020,8 @@ TEST_F(MeshImporterExporterTest, ImportFileDialogFilter_UsesManagerExtensions) EXPECT_FALSE(filter.isEmpty()); EXPECT_TRUE(filter.contains(QStringLiteral(".obj"))); EXPECT_TRUE(filter.contains(QStringLiteral("PlayStation RSD / TMD / Psy-Q PLY"))); + // .abc is in Manager's valid-extension list → the Alembic row shows. + EXPECT_TRUE(filter.contains(QStringLiteral("Alembic vertex cache (*.abc)"))); } TEST_F(MeshImporterExporterTest, Importer_PlayStationTmd_CreatesEntity) From dae68330d1645cac3f807f374c2eb9764b577435 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 22:18:43 -0400 Subject: [PATCH 13/22] feat(#519): morph reorder + Add-popup focus/theme; hide targets from Animation list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the Edit-Mode Vertex Morph Animation group: - Add-target popup: focus the name field on onOpened (a Component.onCompleted forceActiveFocus ran while the popup was still hidden, so you couldn't type), and theme it to the Inspector (panel bg/border, inputColor field with a highlight-on-focus border + faded placeholder). - Reorder morph targets: ▲/▼ buttons per row (hidden while filtering; disabled at the ends), backed by MorphAnimationManager::moveMorphTarget(name, ±1) and moveMorphTargetToIndex(name, i) (the latter ready for drag-and-drop). Undoable via a new ReorderMorphTargetsCommand — VAT_POSE keyframes reference poses by index, so it rebuilds every target in the new order (recreating keyframe refs correctly). Ctrl+Z restores the previous order. - Animation Mode list: filter morph-target animations out of PropertiesPanelController::animationData() (each blend shape is a same-named Ogre::Animation, so getAllAnimationStates listed them all as "clips"). The Animations section now shows only real clips (skeletal + Alembic vertex caches, which are not pose names so they survive). Targets live only in the Edit-Mode group now. Morph-only entities are skipped entirely. Co-Authored-By: Claude Opus 4.8 --- qml/PropertiesPanel.qml | 89 ++++++++++++++++++++++++++++--- src/MorphAnimationManager.cpp | 53 ++++++++++++++++++ src/MorphAnimationManager.h | 10 ++++ src/PropertiesPanelController.cpp | 19 ++++++- src/commands/MorphCommands.cpp | 54 +++++++++++++++++++ src/commands/MorphCommands.h | 26 +++++++++ 6 files changed, 243 insertions(+), 8 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 46791b6b..9809d009 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -7230,16 +7230,26 @@ Rectangle { } } - // Inline name-entry popup for "Add from edit…". Kept - // simple (no styled component) so a misbehaving custom - // dialog can't break the rest of the panel — Popup is - // a built-in Qt Quick Controls primitive with no - // singleton dependencies. + // Inline name-entry popup for "Add from edit…". Themed to + // match the Inspector (panel background + border) rather than + // the default Qt Quick Controls chrome. Popup { id: addNamePopup modal: true focus: true width: 240 + padding: 10 + // Focus must be forced AFTER the popup is shown — a + // forceActiveFocus() in the TextField's Component.onCompleted + // runs while the popup is still hidden, so it never sticks + // (the reported "can't type in the input" bug). + onOpened: addNameField.forceActiveFocus() + background: Rectangle { + color: PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 4 + } contentItem: Column { spacing: 6 Text { @@ -7251,9 +7261,23 @@ Rectangle { id: addNameField width: 220 font.pixelSize: 11 + color: PropertiesPanelController.textColor + selectByMouse: true + placeholderText: "e.g. Smile, BrowUp…" + placeholderTextColor: Qt.rgba( + PropertiesPanelController.textColor.r, + PropertiesPanelController.textColor.g, + PropertiesPanelController.textColor.b, 0.4) + background: Rectangle { + color: PropertiesPanelController.inputColor + border.color: addNameField.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + } onAccepted: addConfirmMa.confirm() onTextChanged: addError.text = "" - Component.onCompleted: forceActiveFocus() } // Inline error: shown when the C++ side rejects // the request (duplicate name, no vertex moved, @@ -7415,7 +7439,9 @@ Rectangle { Slider { id: weightSlider from: 0; to: 1; stepSize: 0.01 - width: parent.width - 222 + // name(120) + weight(36) + up(16) + down(16) + + // delete(18) + row spacings ≈ 254 reserved. + width: parent.width - 254 // Bind to `weightTick` so changes that // bypass user drag (Reset all, MCP, future // dope-sheet scrubs) refresh the readout. @@ -7431,6 +7457,55 @@ Rectangle { width: 36 anchors.verticalCenter: parent.verticalCenter } + // Move up (▲) — reorder via ReorderMorphTargetsCommand + // (undoable). Disabled on the first row. Reorder is only + // meaningful with no filter applied (the index maps to + // the full list), so hide the arrows while filtering. + Rectangle { + width: 16; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + visible: morphCol.filter === "" && morphCol.targetCount > 1 + opacity: index > 0 ? 1.0 : 0.3 + color: morphUpMa.containsMouse && index > 0 + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" + Text { + anchors.centerIn: parent + text: "▲"; color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: morphUpMa + anchors.fill: parent + hoverEnabled: true + enabled: index > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: MorphAnimationManager.moveMorphTarget(modelData, -1) + } + } + // Move down (▼) — disabled on the last row. + Rectangle { + width: 16; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + visible: morphCol.filter === "" && morphCol.targetCount > 1 + opacity: index < morphCol.targetCount - 1 ? 1.0 : 0.3 + color: morphDownMa.containsMouse && index < morphCol.targetCount - 1 + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" + Text { + anchors.centerIn: parent + text: "▼"; color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: morphDownMa + anchors.fill: parent + hoverEnabled: true + enabled: index < morphCol.targetCount - 1 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: MorphAnimationManager.moveMorphTarget(modelData, 1) + } + } // Delete (×) — drops the pose + animation // through DeleteMorphTargetCommand so Ctrl+Z // restores it. diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index c9fd2cf5..e0335ff8 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -312,3 +312,56 @@ bool MorphAnimationManager::deleteMorphTarget(const QString& name) emit morphTargetsChanged(); return true; } + +bool MorphAnimationManager::moveMorphTarget(const QString& name, int delta) +{ + assertMainThread(); + if (name.isEmpty() || delta == 0) return false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + + const QStringList order = morphTargetsFor(entity); + const int from = order.indexOf(name); + if (from < 0) return false; + int to = from + delta; + if (to < 0) to = 0; + if (to > order.size() - 1) to = order.size() - 1; + if (to == from) return false; // already at the edge → no-op + + return moveMorphTargetToIndex(name, to); +} + +bool MorphAnimationManager::moveMorphTargetToIndex(const QString& name, int toIndex) +{ + assertMainThread(); + if (name.isEmpty()) return false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + + const QStringList oldOrder = morphTargetsFor(entity); + const int from = oldOrder.indexOf(name); + if (from < 0) return false; + int to = toIndex; + if (to < 0) to = 0; + if (to > oldOrder.size() - 1) to = oldOrder.size() - 1; + if (to == from) return false; + + QStringList newOrder = oldOrder; + newOrder.move(from, to); + if (newOrder == oldOrder) return false; + + auto* undo = UndoManager::getSingleton(); + if (!undo) return false; + undo->push(new ReorderMorphTargetsCommand(entity, oldOrder, newOrder)); + + emit morphTargetsChanged(); + return true; +} diff --git a/src/MorphAnimationManager.h b/src/MorphAnimationManager.h index 5b2e759b..8c2f6fbb 100644 --- a/src/MorphAnimationManager.h +++ b/src/MorphAnimationManager.h @@ -93,6 +93,16 @@ class MorphAnimationManager : public QObject /// Animation, and resets any AnimationState that referenced it. Q_INVOKABLE bool deleteMorphTarget(const QString& name); + /// Reorder morph targets: move `name` by `delta` positions in the display + /// order (-1 = up, +1 = down; larger jumps clamp to the ends). Pushes an + /// undoable ReorderMorphTargetsCommand. Returns false if the move is a + /// no-op (already at the edge / name not found / no selection). + Q_INVOKABLE bool moveMorphTarget(const QString& name, int delta); + + /// Reorder by absolute index: move `name` to position `toIndex` in the + /// display order (for drag-and-drop). Returns false on a no-op. + Q_INVOKABLE bool moveMorphTargetToIndex(const QString& name, int toIndex); + signals: /// Emitted when a morph weight on any entity is changed via /// `setWeight`. QML uses this to re-fetch values. diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index dfb1d318..e7bde6ca 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -10,7 +10,10 @@ #include "AnimationMerger.h" #include "CurveEditModel.h" #include "EditModeController.h" +#include "MorphAnimationManager.h" #include "SentryReporter.h" + +#include #include "MeshImporterExporter.h" #include "UndoManager.h" #include "commands/ApplyMaterialCommand.h" @@ -772,16 +775,30 @@ QVariantList PropertiesPanelController::animationData() const entityGroup["showSkeleton"] = mAnimationWidget ? mAnimationWidget->isSkeletonDebugActive(ent) : false; entityGroup["showWeights"] = mAnimationWidget ? mAnimationWidget->isBoneWeightsShown(ent) : false; + // Morph targets are each backed by a same-named Ogre::Animation, so + // getAllAnimationStates() lists every blend shape as a "clip". They're + // authored/edited in the Edit-Mode "Vertex Morph Animation" group, not + // here — filter them out so Animation Mode shows only real animation + // clips (skeletal + Alembic vertex caches), by NAME. A vertex-cache + // clip is NOT a pose name, so it survives the filter. + QSet morphNames; + for (const QString& n : MorphAnimationManager::instance()->morphTargetsFor(ent)) + morphNames.insert(n); + QVariantList anims; for (const auto& [key, state] : states->getAnimationStates()) { + const QString name = QString::fromStdString(key); + if (morphNames.contains(name)) continue; // blend shape, not a clip QVariantMap anim; - anim["name"] = QString::fromStdString(key); + anim["name"] = name; anim["enabled"] = state->getEnabled(); anim["loop"] = state->getLoop(); anim["length"] = state->getLength(); anims.append(anim); } + // Skip an entity that only had morph targets — its group would be empty. + if (anims.isEmpty()) continue; entityGroup["animations"] = anims; result.append(entityGroup); } diff --git a/src/commands/MorphCommands.cpp b/src/commands/MorphCommands.cpp index 7ca33423..63b240dc 100644 --- a/src/commands/MorphCommands.cpp +++ b/src/commands/MorphCommands.cpp @@ -251,3 +251,57 @@ void RenameMorphTargetCommand::undo() removePosesByName(mesh.get(), mNewName, mEntity); buildPosesFromSlices(mesh.get(), mOldName, mSnapshot, mEntity); } + +// ──────────────── ReorderMorphTargetsCommand ──────────────────────── + +ReorderMorphTargetsCommand::ReorderMorphTargetsCommand(Ogre::Entity* entity, + const QStringList& oldOrder, + const QStringList& newOrder, + QUndoCommand* parent) + : QUndoCommand(parent), + mEntity(entity), + mOldOrder(oldOrder), + mNewOrder(newOrder) +{ + setText(QStringLiteral("Reorder morph targets")); + if (mEntity) { + if (Ogre::MeshPtr mesh = mEntity->getMesh()) { + // Snapshot every target's slices once — both undo and redo rebuild + // from these, so the offsets survive the intermediate teardown. + for (const QString& n : mOldOrder) + mSnapshot[n] = snapshotByName(mesh.get(), n.toStdString()); + } + } +} + +void ReorderMorphTargetsCommand::applyOrder(const QStringList& order) +{ + if (!mEntity) return; + Ogre::MeshPtr mesh = mEntity->getMesh(); + if (!mesh) return; + + // Pose indices are positional and VAT_POSE keyframes reference them by + // index, so the only safe reorder is a full teardown + rebuild in the + // desired name-order. removePosesByName drops each target's poses + + // Animation; buildPosesFromSlices recreates them (and their keyframe + // references) fresh, in call order → the new display order. + for (const QString& n : order) + removePosesByName(mesh.get(), n, mEntity); + for (const QString& n : order) { + auto it = mSnapshot.find(n); + if (it != mSnapshot.end()) + buildPosesFromSlices(mesh.get(), n, it->second, mEntity); + } +} + +void ReorderMorphTargetsCommand::redo() +{ + applyOrder(mNewOrder); + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("reorder targets")); +} + +void ReorderMorphTargetsCommand::undo() +{ + applyOrder(mOldOrder); +} diff --git a/src/commands/MorphCommands.h b/src/commands/MorphCommands.h index 381b1847..b7ca7d21 100644 --- a/src/commands/MorphCommands.h +++ b/src/commands/MorphCommands.h @@ -13,6 +13,7 @@ The MIT License #include #include +#include #include @@ -94,4 +95,29 @@ class RenameMorphTargetCommand : public QUndoCommand std::vector mSnapshot; }; +// ReorderMorphTargetsCommand: change the display order of morph targets. +// The order is defined by the sequence of unique names in the mesh's pose +// list; VAT_POSE keyframes reference poses by INDEX, so a reorder rebuilds +// every target's poses + driving Animation in the new order (recreating the +// keyframe references correctly). Undo restores the previous order. Captures +// both orders + a per-name slice snapshot at construction. +class ReorderMorphTargetsCommand : public QUndoCommand +{ +public: + ReorderMorphTargetsCommand(Ogre::Entity* entity, + const QStringList& oldOrder, + const QStringList& newOrder, + QUndoCommand* parent = nullptr); + void undo() override; + void redo() override; + +private: + void applyOrder(const QStringList& order); + Ogre::Entity* mEntity = nullptr; + QStringList mOldOrder; + QStringList mNewOrder; + // name -> its pose slices, captured up-front so rebuild is exact. + std::map> mSnapshot; +}; + #endif // MORPH_COMMANDS_H From 7e4c89f3c9afeb23d936e2c372373486dc5d4349 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 5 Jul 2026 11:07:55 -0400 Subject: [PATCH 14/22] feat(#519): base-preserving morph sculpt session + live render during vertex edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 (Blender-style non-destructive authoring): - EditModeController gains a morph sculpt session (beginMorphSculpt / endMorphSculpt + morphSculptActive). beginMorphSculpt snapshots the base vertex positions; endMorphSculpt (and exitEditMode) RESTORE them, so morph authoring never permanently changes the base mesh — matching Blender shape keys / Maya blend shapes. The captured target lives on as a Pose + weight, independent of the base. - EditableMesh::commitToEntity now refreshes the pose/vertex-animation buffer for entities with vertex animation (getAllAnimationStates()->_notifyDirty() + _updateAnimation()). Without this, moving vertices on a mesh that carries morph targets didn't update the render, because Ogre draws from the per-frame pose buffer (not the mesh VBO) and the frame loop is paused during authoring. Co-Authored-By: Claude Opus 4.8 --- src/EditModeController.cpp | 69 ++++++++++++++++++++++++++++++++++++++ src/EditModeController.h | 25 ++++++++++++++ src/EditableMesh.cpp | 15 +++++++++ 3 files changed, 109 insertions(+) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 4d1e1ae0..d0579793 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -594,6 +594,12 @@ void EditModeController::exitEditMode(bool commitChanges) // would be surprising mid-cut. if (m_knifeSession.active) cancelKnife(); + // A morph sculpt session is non-destructive: restore the base BEFORE the + // normal commit so exiting Edit Mode never bakes the sculpt into the base. + // endMorphSculpt() restores the snapshot into the editable mesh; the commit + // below then writes those (pristine) positions back. + if (m_morphSculptActive) endMorphSculpt(); + if (commitChanges && m_editableMesh && m_editEntity) { bool ok = m_editableMesh->commitToEntity(m_editEntity); refreshNormalVisualizer(); @@ -654,6 +660,69 @@ void EditModeController::notifyMeshDataChanged() emit meshDataChanged(); } +bool EditModeController::beginMorphSculpt() +{ + // Requires an active Edit Mode session on a real mesh. + if (!m_editModeActive || !m_editableMesh || !m_editEntity) + return false; + if (m_morphSculptActive) + return true; // already sculpting + + // Snapshot the CURRENT vertex positions per submesh. These are the base + // shape the morph deltas are measured against and what we restore on exit, + // so morph authoring never permanently changes the base mesh (Blender + // shape-key / Maya blend-shape behaviour). + m_morphBaseSnapshot.clear(); + const auto& subs = m_editableMesh->subMeshes(); + m_morphBaseSnapshot.reserve(subs.size()); + for (const auto& sub : subs) { + std::vector snap; + snap.reserve(sub.vertices.size()); + for (const auto& v : sub.vertices) + snap.push_back(v.position); + m_morphBaseSnapshot.push_back(std::move(snap)); + } + + m_morphSculptActive = true; + SentryReporter::addBreadcrumb("scene.anim.morph", "begin morph sculpt"); + emit morphSculptChanged(); + return true; +} + +void EditModeController::endMorphSculpt() +{ + if (!m_morphSculptActive) + return; + m_morphSculptActive = false; + + // Restore the pristine base positions captured at begin, then push them to + // the GPU so the viewport shows the unaltered base. Any captured target + // already lives on the mesh as a Pose + weight, independent of the base. + if (m_editableMesh && m_editEntity && + m_morphBaseSnapshot.size() == m_editableMesh->subMeshes().size()) + { + for (size_t s = 0; s < m_morphBaseSnapshot.size(); ++s) { + const auto& snap = m_morphBaseSnapshot[s]; + const size_t n = std::min(snap.size(), + m_editableMesh->subMeshes()[s].vertices.size()); + for (size_t vi = 0; vi < n; ++vi) + m_editableMesh->setVertexPosition(s, vi, snap[vi]); + } + if (m_normalsMode == 0) + m_editableMesh->recalculateNormals(); + else + m_editableMesh->recalculateNormalsFlat(); + m_editableMesh->commitToEntity(m_editEntity); + refreshNormalVisualizer(); + updateSelectionOverlay(); + } + + m_morphBaseSnapshot.clear(); + SentryReporter::addBreadcrumb("scene.anim.morph", "end morph sculpt (base restored)"); + emit morphSculptChanged(); + emit meshDataChanged(); +} + void EditModeController::onSelectionChanged() { // If selection changes while in edit mode and the edited entity is no diff --git a/src/EditModeController.h b/src/EditModeController.h index 25736556..a26e2998 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -170,6 +170,22 @@ class EditModeController : public QObject Q_INVOKABLE void notifyMeshDataChanged(); /// @} + /// @name Morph sculpt session (Blender-style non-destructive authoring, #519) + /// @{ + /// True while a base-preserving morph sculpt session is active. While on, + /// vertex edits are treated as sculpt work for a morph target: `+ Add` + /// captures the delta vs the base, and ending the session (or exiting Edit + /// Mode) RESTORES the base mesh — the base is never permanently changed, + /// matching Blender shape keys / Maya blend shapes. + Q_PROPERTY(bool morphSculptActive READ morphSculptActive NOTIFY morphSculptChanged) + bool morphSculptActive() const { return m_morphSculptActive; } + /// Begin a morph sculpt session (must already be in Edit Mode). + Q_INVOKABLE bool beginMorphSculpt(); + /// End the session. Always restores the base mesh from the entry snapshot + /// (the captured target already lives on the mesh as a Pose + weight). + Q_INVOKABLE void endMorphSculpt(); + /// @} + /// @name Component selection mode (Vertex/Edge/Face) /// @{ int selectionMode() const { return static_cast(m_selectionMode); } @@ -871,6 +887,8 @@ class EditModeController : public QObject void segmentFinished(const QString& status, bool isError); /// Emitted when entering or exiting edit mode. void editModeChanged(); + /// Emitted when a morph sculpt session starts/ends (#519). + void morphSculptChanged(); /// Emitted when the mesh data is modified during edit mode. void meshDataChanged(); /// Emitted when the selection changes (to update canEnterEditMode). @@ -931,6 +949,13 @@ private slots: std::unique_ptr m_editableMesh; Ogre::Entity* m_editEntity = nullptr; + // Morph sculpt session (#519): pristine base positions captured at + // beginMorphSculpt(), restored on endMorphSculpt() so the base mesh is + // never permanently altered by morph-target authoring. One entry per + // submesh, flat xyz — matches EditableSubMesh vertex ordering. + bool m_morphSculptActive = false; + std::vector> m_morphBaseSnapshot; + void refreshNormalVisualizer(); // Component selection state diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index cb3d8d50..997fbe12 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -34,6 +34,8 @@ THE SOFTWARE. #include "UvSeamData.h" #include #include +#include +#include #include #include #include @@ -796,6 +798,19 @@ bool EditableMesh::commitToEntity(Ogre::Entity* entity) } } + // For entities with vertex (pose / morph) animation, Ogre renders from the + // per-frame pose vertex buffer — NOT the mesh VBO we just wrote — and only + // recomputes it when the animation state is dirty. During morph authoring + // the frame loop is usually paused (isPlaying == false), so without this + // the edited vertices never reach the screen. Bump the state set's dirty + // frame and re-run the animation so the pose buffer is re-derived from the + // updated base positions immediately. + if (entity->hasVertexAnimation()) { + if (auto* states = entity->getAllAnimationStates()) + states->_notifyDirty(); + entity->_updateAnimation(); + } + // Clear the cached source-file path: the live GPU buffers have // diverged from the imported asset. Subsequent enterEditMode calls // must use the legacy loadFromEntity path so user edits aren't From f6a1f285d79b694f805f4785958d3e1b08dd5beb Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 5 Jul 2026 11:08:09 -0400 Subject: [PATCH 15/22] feat(#519): morph weight keyframing over time + glTF morph-weights export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 — animate morph target weights on the timeline, and export the result. Backend (MorphAnimationManager): - setMorphWeightKeyframe(name, time, weight) / clearMorphWeightKeyframe / morphWeightKeyframeTimes. A single mesh Animation named kWeightClipName ("MorphAnim") holds one VAT_POSE track per target's pose; each keyframe references the pose at influence == the weight at that time. Plays via the existing AnimationState path; the clip length auto-extends to cover keys. UI (Edit-Mode Vertex Morph Animation group): - Per-target "◈ Key" button records the current weight at the timeline playhead (AnimationControlController.sliderValue). Diamonds show on the dope sheet — allMorphRows() now prefers the MorphAnim clip's per-pose keyframe times over the static shape-only clip. Export (glTF): - buildAiScene now emits an aiMeshMorphAnim weight-animation channel from the MorphAnim clip (blend-shape TARGET export via aiMesh::mAnimMeshes already existed). Skeletal + morph animations are gathered into one list and sized together; no-skeleton morph-only meshes now export their animation too. FBX blend-shape weight export remains a follow-up. Tests: reorder command + moveMorphTarget API, and the weight-keyframe clip (create/update-in-place/clear/length + no-op rejections). Co-Authored-By: Claude Opus 4.8 --- qml/PropertiesPanel.qml | 111 ++++++++++++++++++---- src/AnimationControlController.cpp | 49 +++++++--- src/MeshImporterExporter.cpp | 143 ++++++++++++++++++++++++++++- src/MorphAnimationManager.cpp | 131 ++++++++++++++++++++++++++ src/MorphAnimationManager.h | 22 +++++ src/MorphAnimationManager_test.cpp | 100 ++++++++++++++++++++ 6 files changed, 520 insertions(+), 36 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 9809d009..b4578eb5 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -7163,16 +7163,52 @@ Rectangle { font.pixelSize: 11 font.bold: true } - // Add from current edit — captures the user's current - // edit-mode geometry minus the bind-pose baseline as - // a new morph target. Disabled (greyed out, forbidden - // cursor) when outside edit mode because - // EditableSubMesh::originalPositions is only - // populated by EditModeController and the C++ method - // would return false anyway. + // Sculpt toggle — begins/ends a non-destructive morph + // sculpt session. While active, vertex edits are treated as + // shaping a new target and the BASE mesh is restored when + // the session ends (Blender shape-key behaviour). This is + // the entry point: you can't "+ Add" without first sculpting. + Rectangle { + id: sculptBtn + property bool active: EditModeController.morphSculptActive + Layout.preferredWidth: 60 + Layout.preferredHeight: 20 + radius: 3 + opacity: EditModeController.editModeActive ? 1.0 : 0.45 + color: active + ? PropertiesPanelController.highlightColor + : (sculptMa.containsMouse && EditModeController.editModeActive + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor) + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: sculptBtn.active ? "Done" : "Sculpt" + color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: sculptMa + anchors.fill: parent + hoverEnabled: true + enabled: EditModeController.editModeActive + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: { + if (EditModeController.morphSculptActive) + EditModeController.endMorphSculpt() + else + EditModeController.beginMorphSculpt() + } + ToolTip.visible: containsMouse && !enabled + ToolTip.text: "Enter Edit Mode (Tab) first." + } + } + // Add captured shape — stores the current sculpt as a target + // (delta vs the base captured when the session began). Only + // available during an active sculpt session. Rectangle { id: addBtn - property bool canAddFromEdit: EditModeController.editModeActive + property bool canAddFromEdit: EditModeController.morphSculptActive Layout.preferredWidth: 56 Layout.preferredHeight: 20 radius: 3 @@ -7199,7 +7235,7 @@ Rectangle { addNamePopup.open() } ToolTip.visible: containsMouse && !enabled - ToolTip.text: "Enter Edit Mode (Tab) to add morph targets from current edit." + ToolTip.text: "Click “Sculpt” first, then move vertices to shape the target." } } // Reset all: walks every target and sets weight to 0. @@ -7348,17 +7384,22 @@ Rectangle { } } - // Empty-state hint: this group lives in Edit Mode, so the flow - // is always "sculpt vertices → capture". Guide the user rather - // than leaving a bare header on a target-less mesh. + // Status / hint line. Guides the non-destructive sculpt flow + // and makes the active session obvious (the base is restored + // when you click Done / leave Edit Mode). Text { visible: morphCol.targetCount === 0 + || EditModeController.morphSculptActive width: parent.width wrapMode: Text.Wrap - color: PropertiesPanelController.textColor - opacity: 0.7 + color: EditModeController.morphSculptActive + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.textColor + opacity: EditModeController.morphSculptActive ? 1.0 : 0.7 font.pixelSize: 10 - text: "Move some vertices, then “+ Add…” to capture them as a morph target." + text: EditModeController.morphSculptActive + ? "Sculpting: move vertices to shape the target, then “+ Add…”. The base mesh is restored when you click “Done”." + : "Click “Sculpt”, move vertices to shape a target, then “+ Add…”. The base mesh is never changed." } // Filter / search — characters often have 50+ blend @@ -7439,9 +7480,9 @@ Rectangle { Slider { id: weightSlider from: 0; to: 1; stepSize: 0.01 - // name(120) + weight(36) + up(16) + down(16) + - // delete(18) + row spacings ≈ 254 reserved. - width: parent.width - 254 + // name(120) + weight(36) + key(16) + up(16) + + // down(16) + delete(18) + row spacings ≈ 272. + width: parent.width - 272 // Bind to `weightTick` so changes that // bypass user drag (Reset all, MCP, future // dope-sheet scrubs) refresh the readout. @@ -7457,6 +7498,40 @@ Rectangle { width: 36 anchors.verticalCenter: parent.verticalCenter } + // Key weight at playhead (◈) — records the current + // weight at the timeline playhead time as a keyframe on + // the shared "MorphAnim" weight clip (Slice 2 #519). + // Diamonds appear on the dope sheet; the clip plays + + // exports to glTF as a morph-weights animation. Hidden + // while filtering (keeps the row compact + the reorder + // arrows already hide then). + Rectangle { + width: 16; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + visible: morphCol.filter === "" + color: keyMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" + Text { + anchors.centerIn: parent + text: "◈" + color: "#88ccff" // matches the dope-sheet morph diamonds + font.pixelSize: 11 + } + MouseArea { + id: keyMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + var t = AnimationControlController.sliderValue / 1000.0 + MorphAnimationManager.setMorphWeightKeyframe( + modelData, t, weightSlider.value) + } + ToolTip.visible: containsMouse + ToolTip.text: "Key this weight at the timeline playhead" + } + } // Move up (▲) — reorder via ReorderMorphTargetsCommand // (undoable). Disabled on the first row. Reorder is only // meaningful with no filter applied (the index maps to diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index f7d058b4..0e00685c 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1,5 +1,6 @@ #include "AnimationControlController.h" #include "PropertiesPanelController.h" +#include "MorphAnimationManager.h" #include "SelectionSet.h" #include "Manager.h" #include "SentryReporter.h" @@ -948,23 +949,41 @@ QVariantList AnimationControlController::allMorphRows() const if (poseName.empty()) continue; QVariantList keyTimes; - if (mesh->hasAnimation(poseName)) { - // The importer (MeshProcessor) groups same-named poses - // across submeshes into a single Animation with one - // VAT_POSE track per submesh. Pull only the track - // matching this pose's target handle — otherwise a - // shape that appears on body + head would show its - // diamonds twice in the dope sheet. - Ogre::Animation* anim = mesh->getAnimation(poseName); - const unsigned short handle = pose->getTarget(); - if (anim->hasVertexTrack(handle)) { - Ogre::VertexAnimationTrack* track = anim->getVertexTrack(handle); - if (track && track->getAnimationType() == Ogre::VAT_POSE) { - for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) - keyTimes.append(static_cast(track->getKeyFrame(i)->getTime())); + const unsigned short handle = pose->getTarget(); + + // Prefer the animated-weight track on the shared "MorphAnim" clip + // (Slice 2 #519 — multiple keyframes at varying influence). Fall back + // to the static per-target Animation (single shape keyframe at t=0) + // when the target has no weight animation yet. + auto appendTrackTimes = [&](const std::string& clipName) -> bool { + if (!mesh->hasAnimation(clipName)) return false; + Ogre::Animation* anim = mesh->getAnimation(clipName); + if (!anim->hasVertexTrack(handle)) return false; + Ogre::VertexAnimationTrack* track = anim->getVertexTrack(handle); + if (!track || track->getAnimationType() != Ogre::VAT_POSE) return false; + // A MorphAnim track carries keyframes for every target on this + // handle; keep only the ones that reference THIS pose so each row + // shows its own diamonds. + bool any = false; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + bool refsThisPose = false; + for (const auto& ref : kf->getPoseReferences()) { + const Ogre::Pose* rp = + (ref.poseIndex < mesh->getPoseList().size()) + ? mesh->getPoseList()[ref.poseIndex] : nullptr; + if (rp && rp->getName() == poseName) { refsThisPose = true; break; } + } + if (refsThisPose) { + keyTimes.append(static_cast(kf->getTime())); + any = true; } } - } + return any; + }; + + if (!appendTrackTimes(MorphAnimationManager::kWeightClipName)) + appendTrackTimes(poseName); // static shape-only clip QVariantMap row; row[QStringLiteral("name")] = QString::fromStdString(poseName); diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index c8eddf31..3be55108 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -59,6 +59,7 @@ THE SOFTWARE. #include "OgreXML/pugixml.hpp" #include "AnimationMerger.h" +#include "MorphAnimationManager.h" #include "Manager.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -909,6 +910,121 @@ static aiAnimation* buildAiAnimation(Ogre::Animation* ogreAnim, const std::strin return anim; } +// Build a morph-weight aiAnimation from the mesh's "MorphAnim" clip so an +// authored blend-shape weight-over-time animation round-trips to glTF. +// +// Ogre stores morph weight animation on ONE mesh Animation named +// `MorphAnimationManager::kWeightClipName` ("MorphAnim"). That clip has one +// VAT_POSE VertexAnimationTrack per submesh target handle (handle 0 = shared +// vertex data, handle si+1 = submesh si's dedicated vertex data). Each +// VertexPoseKeyFrame on a track carries a list of {poseIndex, influence} +// references — influence is the target's weight at that keyframe time; poses +// not referenced at a time are weight 0. +// +// Assimp models this as an aiAnimation whose mMorphMeshChannels holds one +// aiMeshMorphAnim per animated NODE/mesh. Each aiMeshMorphAnim's mName is the +// NODE the target mesh attaches to, and its mKeys are per-distinct-time +// aiMeshMorphKey entries. A key's parallel mValues/mWeights arrays are indexed +// by anim-mesh index (0..mNumAnimMeshes-1 == aiMesh::mAnimMeshes[i]), so we map +// each pose (by name) to its anim-mesh slot on that mesh and scatter the +// keyframe influences into the matching slot (0 elsewhere). +// +// `new` allocations cross the Assimp C-API ownership boundary — aiAnimation's +// dtor frees mMorphMeshChannels and each channel's mKeys (and the per-key +// mValues/mWeights via aiMeshMorphKey's dtor) — so raw new[] matches every +// other aiScene field in this file (no double-free in the manual +// scene->mAnimations cleanup paths, which just delete each aiAnimation). +// +// Returns nullptr (emitting nothing) when there is no weight clip or no +// submesh carries morph targets, so non-morph meshes are entirely unaffected. +static aiAnimation* buildMorphWeightAiAnimation(const aiScene* scene, + const Ogre::MeshPtr& mesh, + const std::string& meshNodeName) +{ + const std::string clip = MorphAnimationManager::kWeightClipName; + if (!mesh->hasAnimation(clip)) return nullptr; + Ogre::Animation* weightClip = mesh->getAnimation(clip); + if (!weightClip) return nullptr; + + const unsigned int numSub = mesh->getNumSubMeshes(); + std::vector morphChannels; + + // One channel per submesh that actually got morph targets (mAnimMeshes). + // All submeshes attach to the single node `meshNodeName` in buildAiScene, + // so every channel carries that node name; the anim-mesh index space is + // per-mesh, which is exactly what a per-mesh channel expresses. + for (unsigned int si = 0; si < numSub; ++si) + { + aiMesh* aiM = scene->mMeshes[si]; + if (!aiM || aiM->mNumAnimMeshes == 0) continue; + + // The weight track for this submesh lives under its target handle. + const Ogre::SubMesh* subMesh = mesh->getSubMesh(si); + const unsigned short targetHandle = subMesh->useSharedVertices + ? 0 + : static_cast(si + 1); + if (!weightClip->hasVertexTrack(targetHandle)) continue; + Ogre::VertexAnimationTrack* track = weightClip->getVertexTrack(targetHandle); + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + + // Map pose name -> anim-mesh slot on this mesh (mAnimMeshes[i].mName is + // the Ogre pose name; see attachMorphTargetsToAiMesh). + std::map> poseNameToSlot; + for (unsigned int i = 0; i < aiM->mNumAnimMeshes; ++i) + if (aiM->mAnimMeshes[i]) + poseNameToSlot[aiM->mAnimMeshes[i]->mName.C_Str()] = i; + + const auto numKf = track->getNumKeyFrames(); + if (numKf == 0) continue; + + auto* channel = new aiMeshMorphAnim(); // NOSONAR — Assimp owns + channel->mName = aiString(meshNodeName); + channel->mNumKeys = static_cast(numKf); + channel->mKeys = new aiMeshMorphKey[numKf]; // NOSONAR — Assimp owns + + const Ogre::PoseList& poseList = mesh->getPoseList(); + for (unsigned short ki = 0; ki < numKf; ++ki) + { + auto* kf = track->getVertexPoseKeyFrame(ki); + aiMeshMorphKey& key = channel->mKeys[ki]; + key.mTime = kf->getTime(); // seconds; parent mTicksPerSecond = 1.0 + // Parallel arrays over ALL anim-meshes on this mesh: mValues[i] is + // the anim-mesh index, mWeights[i] its weight at mTime (default 0). + key.mNumValuesAndWeights = aiM->mNumAnimMeshes; + key.mValues = new unsigned int[aiM->mNumAnimMeshes]; // NOSONAR — Assimp owns + key.mWeights = new double[aiM->mNumAnimMeshes]; // NOSONAR — Assimp owns + for (unsigned int i = 0; i < aiM->mNumAnimMeshes; ++i) + { + key.mValues[i] = i; + key.mWeights[i] = 0.0; + } + // Scatter each referenced pose's influence into its anim-mesh slot. + for (const auto& ref : kf->getPoseReferences()) + { + if (ref.poseIndex >= poseList.size()) continue; + const Ogre::Pose* p = poseList[ref.poseIndex]; + if (!p) continue; + auto it = poseNameToSlot.find(p->getName()); + if (it != poseNameToSlot.end()) + key.mWeights[it->second] = static_cast(ref.influence); + } + } + morphChannels.push_back(channel); + } + + if (morphChannels.empty()) return nullptr; + + auto* anim = new aiAnimation(); // NOSONAR — Assimp owns + anim->mName = aiString(std::string(MorphAnimationManager::kWeightClipName)); + anim->mTicksPerSecond = 1.0; // times are seconds, matching buildAiAnimation + anim->mDuration = weightClip->getLength(); + anim->mNumMorphMeshChannels = static_cast(morphChannels.size()); + anim->mMorphMeshChannels = new aiMeshMorphAnim*[anim->mNumMorphMeshChannels]; // NOSONAR — Assimp owns + for (unsigned int ci = 0; ci < anim->mNumMorphMeshChannels; ++ci) + anim->mMorphMeshChannels[ci] = morphChannels[ci]; + return anim; +} + // Build an aiScene directly from an Ogre Entity, bypassing the XML round-trip static aiScene* buildAiScene(const Ogre::Entity* entity) { @@ -1014,12 +1130,33 @@ static aiScene* buildAiScene(const Ogre::Entity* entity) } // --- Animations --- + // Skeletal animations first (unchanged), then optionally one morph-weight + // animation from the mesh's "MorphAnim" clip. Both are gathered into a + // vector so the mAnimations array is sized to hold exactly what exists — + // this covers the no-skeleton + morph-only case (mAnimations was previously + // left null) and the has-skeleton + morph case (grow past the skeleton + // count) without special-casing either. + std::vector animations; if (hasSkeleton && skeleton->getNumAnimations() > 0) { - scene->mNumAnimations = skeleton->getNumAnimations(); - scene->mAnimations = new aiAnimation*[scene->mNumAnimations]; for (unsigned short ai = 0; ai < skeleton->getNumAnimations(); ++ai) - scene->mAnimations[ai] = buildAiAnimation(skeleton->getAnimation(ai)); + animations.push_back(buildAiAnimation(skeleton->getAnimation(ai))); + } + + // The node every submesh attaches to (see node wiring above): the dedicated + // "_mesh" node when there's a skeleton, else the root node itself. + const std::string meshNodeName = hasSkeleton + ? std::string(entity->getName()) + "_mesh" + : std::string(entity->getName()); + if (aiAnimation* morphAnim = buildMorphWeightAiAnimation(scene, mesh, meshNodeName)) + animations.push_back(morphAnim); + + if (!animations.empty()) + { + scene->mNumAnimations = static_cast(animations.size()); + scene->mAnimations = new aiAnimation*[scene->mNumAnimations]; // NOSONAR — Assimp owns + for (unsigned int ai = 0; ai < scene->mNumAnimations; ++ai) + scene->mAnimations[ai] = animations[ai]; } return scene; diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index e0335ff8..6e1c44ba 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -20,8 +20,11 @@ The MIT License #include #include +#include #include +#include #include +#include #include #include @@ -163,6 +166,134 @@ bool MorphAnimationManager::setWeightForSelection(const QString& name, double w) return setWeight(ents.first(), name, static_cast(w)); } +const char* MorphAnimationManager::kWeightClipName = "MorphAnim"; + +namespace { + +// Find the pose index (into mesh->getPoseList()) for the first pose named +// `name`. -1 if none. Weight keyframing references the pose by this index. +int poseIndexForName(Ogre::Mesh* mesh, const std::string& name) +{ + const auto& poses = mesh->getPoseList(); + for (unsigned short i = 0; i < poses.size(); ++i) + if (poses[i] && poses[i]->getName() == name) return i; + return -1; +} + +// The weight-animation track for a target lives on the shared "MorphAnim" +// clip, keyed on the pose's target submesh handle. Fetch-or-create the +// clip + track. Returns nullptr if the pose doesn't exist. +Ogre::VertexAnimationTrack* weightTrackFor(Ogre::Mesh* mesh, const std::string& name, + bool create) +{ + const int pi = poseIndexForName(mesh, name); + if (pi < 0) return nullptr; + const unsigned short handle = mesh->getPoseList()[pi]->getTarget(); + + const std::string clip = MorphAnimationManager::kWeightClipName; + Ogre::Animation* anim = mesh->hasAnimation(clip) + ? mesh->getAnimation(clip) + : (create ? mesh->createAnimation(clip, 0.0f) : nullptr); + if (!anim) return nullptr; + + if (anim->hasVertexTrack(handle)) + return anim->getVertexTrack(handle); + if (!create) return nullptr; + return anim->createVertexTrack(handle, Ogre::VAT_POSE); +} + +} // namespace + +bool MorphAnimationManager::setMorphWeightKeyframe(const QString& name, + double time, double weight) +{ + assertMainThread(); + if (name.isEmpty() || time < 0.0) return false; + + auto* sel = SelectionSet::getSingleton(); + 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; + + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return false; + Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), true); + if (!track) return false; + + const float t = static_cast(time); + const float w = std::clamp(static_cast(weight), 0.0f, 1.0f); + + // Update in place if a keyframe already exists at ~t, else create one. + Ogre::VertexPoseKeyFrame* kf = nullptr; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* k = static_cast(track->getKeyFrame(i)); + if (std::abs(k->getTime() - t) < 1e-4f) { kf = k; break; } + } + if (!kf) kf = track->createVertexPoseKeyFrame(t); + + // A VAT_POSE keyframe holds a list of pose references; for a weight track + // each keyframe references exactly this target's pose at influence = weight. + kf->removeAllPoseReferences(); + kf->addPoseReference(static_cast(pi), w); + + // Extend the clip length to cover the new time. + Ogre::Animation* anim = mesh->getAnimation(kWeightClipName); + if (anim && t > anim->getLength()) + anim->setLength(t); + + // Refresh the entity's animation-state mirror so the new clip is playable. + entity->refreshAvailableAnimationState(); + emit morphTargetsChanged(); + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("key weight '%1' @%2 = %3").arg(name).arg(t).arg(w)); + return true; +} + +bool MorphAnimationManager::clearMorphWeightKeyframe(const QString& name, double time) +{ + assertMainThread(); + if (name.isEmpty()) return false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return false; + + Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), false); + if (!track) return false; + const float t = static_cast(time); + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + if (std::abs(track->getKeyFrame(i)->getTime() - t) < 1e-4f) { + track->removeKeyFrame(i); + emit morphTargetsChanged(); + return true; + } + } + return false; +} + +QVariantList MorphAnimationManager::morphWeightKeyframeTimes(const QString& name) const +{ + QVariantList out; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return out; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return out; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return out; + + Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), false); + if (!track) return out; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) + out.append(static_cast(track->getKeyFrame(i)->getTime())); + return out; +} + namespace { // Walk the per-submesh edited positions on the EditableMesh and diff diff --git a/src/MorphAnimationManager.h b/src/MorphAnimationManager.h index 8c2f6fbb..3dae56af 100644 --- a/src/MorphAnimationManager.h +++ b/src/MorphAnimationManager.h @@ -70,6 +70,28 @@ class MorphAnimationManager : public QObject Q_INVOKABLE double weightForSelection(const QString& name) const; Q_INVOKABLE bool setWeightForSelection(const QString& name, double w); + /// Weight keyframing over time (Slice 2, #519). A single mesh Animation + /// named `kWeightClipName` ("MorphAnim") holds one VAT_POSE track per + /// target's pose; each keyframe references the pose at the influence + /// (= weight) recorded at that time. This is what glTF exports as a + /// morph-weights animation. Distinct from the static per-target Animation + /// (named exactly the target name) that only carries the shape. + + /// Record `weight` for target `name` at `time` seconds on the weight clip + /// (creates the clip/track/keyframe as needed, updates in place otherwise). + /// Extends the clip length to cover `time`. Returns false on no-op. + Q_INVOKABLE bool setMorphWeightKeyframe(const QString& name, double time, double weight); + + /// Remove the weight keyframe for `name` at (approximately) `time`. + Q_INVOKABLE bool clearMorphWeightKeyframe(const QString& name, double time); + + /// Keyframe times (seconds) for `name`'s weight track on the selection, + /// ascending. Empty if none. For the dope sheet. + Q_INVOKABLE QVariantList morphWeightKeyframeTimes(const QString& name) const; + + /// The weight-animation clip name used across the app + glTF export. + static const char* kWeightClipName; + /// 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.). diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp index a27ee039..ab0f71e8 100644 --- a/src/MorphAnimationManager_test.cpp +++ b/src/MorphAnimationManager_test.cpp @@ -249,6 +249,46 @@ TEST_F(MorphAnimationManagerSceneTest, SelectionDrivenAccessorsResolveFirstEntit EXPECT_NEAR(m->weightForSelection(QStringLiteral("Smile")), 0.6, 1e-4); } +TEST_F(MorphAnimationManagerSceneTest, WeightKeyframingBuildsMorphAnimClip) { + auto mesh = createMorphTestMesh("Morph_KeyClip"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_KeyClipEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + // No weight keyframes initially. + EXPECT_TRUE(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).isEmpty()); + + // Key Smile at t=0 (w=0) and t=1 (w=1) → the shared "MorphAnim" clip. + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 0.0, 0.0)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 1.0, 1.0)); + + ASSERT_TRUE(mesh->hasAnimation(MorphAnimationManager::kWeightClipName)); + Ogre::Animation* clip = mesh->getAnimation(MorphAnimationManager::kWeightClipName); + EXPECT_NEAR(clip->getLength(), 1.0, 1e-4); + + QVariantList times = m->morphWeightKeyframeTimes(QStringLiteral("Smile")); + ASSERT_EQ(times.size(), 2); + EXPECT_NEAR(times[0].toDouble(), 0.0, 1e-4); + EXPECT_NEAR(times[1].toDouble(), 1.0, 1e-4); + + // Updating an existing time in place doesn't add a keyframe. + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 1.0, 0.5)); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 2); + + // Clearing removes it. + EXPECT_TRUE(m->clearMorphWeightKeyframe(QStringLiteral("Smile"), 0.0)); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 1); + // Unknown target / time → no-op false. + EXPECT_FALSE(m->setMorphWeightKeyframe(QStringLiteral("Nope"), 0.0, 1.0)); + EXPECT_FALSE(m->clearMorphWeightKeyframe(QStringLiteral("Smile"), 5.0)); + sel->clear(); +} + TEST_F(MorphAnimationManagerSceneTest, NoSelectionGivesEmptyList) { auto* m = MorphAnimationManager::instance(); EXPECT_TRUE(m->morphTargetsForSelection().isEmpty()); @@ -338,6 +378,66 @@ TEST_F(MorphAnimationManagerSceneTest, RenameMorphTargetCommandRoundTrips) { EXPECT_FALSE(mesh->hasAnimation("Grin")); } +TEST_F(MorphAnimationManagerSceneTest, ReorderMorphTargetsCommandRoundTrips) { + auto mesh = createMorphTestMesh("Morph_ReorderCmd"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_ReorderCmdEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + // Fixture order is JawOpen, Smile. + QStringList before = m->morphTargetsFor(entity); + ASSERT_EQ(before.size(), 2); + EXPECT_EQ(before[0], QStringLiteral("JawOpen")); + EXPECT_EQ(before[1], QStringLiteral("Smile")); + + QStringList after = before; + after.move(0, 1); // JawOpen -> after Smile + + ReorderMorphTargetsCommand cmd(entity, before, after); + cmd.redo(); + QStringList reordered = m->morphTargetsFor(entity); + ASSERT_EQ(reordered.size(), 2); + EXPECT_EQ(reordered[0], QStringLiteral("Smile")); + EXPECT_EQ(reordered[1], QStringLiteral("JawOpen")); + // Both animations survive the rebuild. + EXPECT_TRUE(mesh->hasAnimation("Smile")); + EXPECT_TRUE(mesh->hasAnimation("JawOpen")); + + cmd.undo(); + QStringList restored = m->morphTargetsFor(entity); + ASSERT_EQ(restored.size(), 2); + EXPECT_EQ(restored[0], QStringLiteral("JawOpen")); + EXPECT_EQ(restored[1], QStringLiteral("Smile")); +} + +TEST_F(MorphAnimationManagerSceneTest, MoveMorphTargetReordersAndClampsAtEdges) { + auto mesh = createMorphTestMesh("Morph_MoveApi"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_MoveApiEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + // Move JawOpen (index 0) down by 1 → Smile, JawOpen. + EXPECT_TRUE(m->moveMorphTarget(QStringLiteral("JawOpen"), 1)); + QStringList o = m->morphTargetsFor(entity); + ASSERT_EQ(o.size(), 2); + EXPECT_EQ(o[0], QStringLiteral("Smile")); + EXPECT_EQ(o[1], QStringLiteral("JawOpen")); + + // Moving the last item down is a clamped no-op. + EXPECT_FALSE(m->moveMorphTarget(QStringLiteral("JawOpen"), 1)); + // Unknown name / zero delta are no-ops. + EXPECT_FALSE(m->moveMorphTarget(QStringLiteral("Nope"), -1)); + EXPECT_FALSE(m->moveMorphTarget(QStringLiteral("Smile"), 0)); + sel->clear(); +} + TEST_F(MorphAnimationManagerSceneTest, RenameRejectsCollisionAndIdempotentName) { auto mesh = createMorphTestMesh("Morph_RenameReject"); auto* scene = Manager::getSingleton()->getSceneMgr(); From b44d4bd4564a711cc56c680907724480442aacc5 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 5 Jul 2026 11:08:38 -0400 Subject: [PATCH 16/22] docs(#519): document morph authoring UX + weight keyframing + glTF export Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 696f352f..78d6ab1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,6 +214,8 @@ Three singletons manage core state. All run on the main thread. Access via `Clas The animation pipeline started skeleton-only; the #517 epic broadens it. Slices A/C/D shipped: **MorphAnimationManager** (`src/MorphAnimationManager.{h,cpp}`, morph targets / blend shapes — Ogre `Pose` + `VAT_POSE` tracks, `qtmesh morph` CLI, MCP, undo via `commands/MorphCommands`), **NodeAnimationManager** (non-skinned node TRS), **PoseLibrary** (named poses). All are `QML_SINGLETON`s registered in `mainwindow.cpp`. +- **Morph authoring UX (Blender-parity, #519):** morph targets are authored/edited/reordered in the Edit-Mode **"Vertex Morph Animation"** Inspector group (NOT Animation Mode — Animation Mode's list shows only real clips, morph animations are filtered out of `PropertiesPanelController::animationData()` by name). Authoring is a **non-destructive sculpt session**: `EditModeController::beginMorphSculpt()` snapshots base positions; `+ Add` captures the current edit as a target (delta vs base); `endMorphSculpt()` / exiting Edit Mode **restores the base** (the target lives on as a `Pose` + weight). `EditableMesh::commitToEntity` refreshes the pose buffer (`AnimationStateSet::_notifyDirty()` + `entity->_updateAnimation()`) for vertex-animated entities so edits render live while the frame loop is paused. Reorder via `MorphAnimationManager::moveMorphTarget`/`moveMorphTargetToIndex` (undoable `ReorderMorphTargetsCommand` — VAT_POSE keyframes reference poses by index, so it rebuilds all targets in the new order). **Weight keyframing over time** (Slice 2): `setMorphWeightKeyframe(name, time, weight)` writes to one shared clip `MorphAnimationManager::kWeightClipName` ("MorphAnim") — a VAT_POSE track per target's pose, each keyframe referencing the pose at influence == weight; the per-target "◈ Key" button records the weight at the timeline playhead (`AnimationControlController.sliderValue`), diamonds show in the dope sheet (`allMorphRows()` prefers the MorphAnim track's per-pose keyframe times). **glTF export:** `buildAiScene` emits blend-shape targets (`aiMesh::mAnimMeshes` via `attachMorphTargetsToAiMesh`) AND a morph-weights animation (`aiMeshMorphAnim` from the MorphAnim clip). FBX blend-shape weight export is a follow-up. + - **VertexAnimationManager** (`src/VertexAnimationManager.{h,cpp}`, Slice B #519): full-mesh per-vertex animation (cloth / sims / fluid bakes / Alembic caches — every vertex moves, no skeleton). Reuses Ogre's `VAT_POSE` path so the existing timeline/dope-sheet/loop play it with no new playback code. `FrameSet`/`FrameData` are the source-agnostic decoded-cache types; `buildClipFromFrames(mesh, name, frames)` reads submesh-0 bind positions and builds one `Ogre::Pose` per frame (delta vs bind) + one `VAT_POSE` track keyed per frame time. `sampleHeuristic(frameCount)` (< 32 → poses, else stream — the issue's rule) is static + unit-tested. Sentry `scene.anim.vertex_anim`. - **AlembicImporter** (`src/AlembicImporter.{h,cpp}`, Slice B2): Alembic (.abc) reader behind `-DENABLE_ALEMBIC` (default OFF; `cmake/Alembic.cmake` FetchContents Imath 3.1.12 + Alembic 1.8.8, both BSD-3, fully static, all optional components off — Imath install stays ON so Alembic's unconditional `install(EXPORT)` finds it in an export set). `readFrameSet` decodes the first `IPolyMesh` into a `FrameSet` (pure data — rejects variable-topology caches, fan-triangulates n-gon faces); `importToScene` builds the base mesh + `VAT_POSE` clip + entity. `.abc` routes through `MeshImporterExporter::importer` (guarded — a non-Alembic build logs a clear "rebuild with -DENABLE_ALEMBIC" and skips). The reader is `#ifdef ENABLE_ALEMBIC`-guarded so the default build is unaffected; a round-trip test writes+reads a synthetic `.abc` (only compiled/run in the Alembic-on coverage CI lane). **B3 (shipped):** `readInfo(path)` reads cache metadata (frames/verts/tris/fps/duration/storage) from the schema header + first sample without decoding all frames → **`qtmesh anim .abc --info [--json]`** (`CLIPipeline::cmdAnim`); MCP **`import_alembic`** (heavy — decodes into the live scene, reports node/entities/vertexClips) + **`play_vertex_animation`** (delegates to `toolPlayAnimation` since a vertex clip is an ordinary `AnimationState`). Frame cap: `readFrameSet(maxFrames)` and `importToScene` cap the decode at 512 frames and set `ReadResult::truncated` / log a warning when it bites (no silent cap) — VAT_POSE holds every frame resident, so true per-frame vertex-buffer streaming remains future work. From a29cbb619be4b43f96d0f2d77202650fc8af1822 Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 10 Jul 2026 22:53:09 -0400 Subject: [PATCH 17/22] feat(#519): multi-clip morph weight animation + drag-drop reorder + OBJ authoring fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multiple named morph clips (smile/angry/surprised) over shared targets: MorphAnimationManager gains activeMorphClip + create/delete/rename/list; keyframe methods write to the active clip; clip selector dropdown in the Vertex Morph group; glTF exports every clip as its own morph-weights anim. - Drag-drop target reorder (grip handle, live cursor follow + insertion line), replacing the up/down buttons; middle-ellipsis on target names. - Full dope-sheet morph editing (add/drag/delete diamonds); "◈ Key" activates the clip so keying is immediately playable/scrubbable. - Timeline length + scrub now work for mesh-level (VAT_POSE) clips, not just skeletal (AnimationControlController::selectAnimation/setAnimationLength). - OBJ authoring fix: loadFromAssimpFile (n-gon path) now snapshots originalPositions so morph capture diffs correctly (was orig=0 → "no vertex edited"). createMorphClip requires ≥1 target (no crash on unmorphed OBJ). - Crash fixes: rename of a mesh morph clip routes to renameMorphClip (was the skeleton path → null-skeleton crash); SkeletonTransform::renameAnimation guards hasSkeleton. Dope-sheet shows for morph-only meshes. Co-Authored-By: Claude Opus 4.8 --- qml/AnimationDopeSheet.qml | 126 ++++++++- qml/PropertiesPanel.qml | 395 ++++++++++++++++++++++++----- src/AnimationControlController.cpp | 29 ++- src/EditableMesh.cpp | 8 + src/MeshImporterExporter.cpp | 27 +- src/MorphAnimationManager.cpp | 267 ++++++++++++++++++- src/MorphAnimationManager.h | 68 ++++- src/MorphAnimationManager_test.cpp | 49 ++++ src/PropertiesPanelController.cpp | 7 + src/SkeletonTransform.cpp | 8 +- 10 files changed, 870 insertions(+), 114 deletions(-) diff --git a/qml/AnimationDopeSheet.qml b/qml/AnimationDopeSheet.qml index e91cf014..e85c58bb 100644 --- a/qml/AnimationDopeSheet.qml +++ b/qml/AnimationDopeSheet.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import AnimationControl 1.0 +import PropertiesPanel 1.0 // Multi-bone dope sheet with multi-select, bulk move, and copy/paste. // One row per animated bone with diamond markers at each keyframe time. @@ -164,6 +165,15 @@ Rectangle { function onKeyframeTicksChanged() { root.rows = AnimationControlController.allBoneRows() } } + // Morph weight keyframes changed (add/move/delete/key-at-playhead) → rebuild + // the morph band so its diamonds reflect the new times immediately. + Connections { + target: MorphAnimationManager + function onMorphTargetsChanged() { + root.morphRows = AnimationControlController.allMorphRows() + } + } + // Cross-platform "primary" modifier — Ctrl on Win/Linux, Cmd (Meta) on macOS. function isPrimaryModifier(modifiers) { return (modifiers & Qt.ControlModifier) || (modifiers & Qt.MetaModifier) @@ -205,12 +215,15 @@ Rectangle { } // ── Empty-state placeholder ────────────────────────────────────────────── + // Only "empty" when there are neither bone rows NOR morph rows — a mesh with + // morph targets (and no skeleton) still has a populated dope sheet, so the + // "select a rigged mesh" message must not show for it. Text { anchors.centerIn: parent - visible: root.rows.length === 0 + visible: root.rows.length === 0 && root.morphRows.length === 0 text: AnimationControlController.hasAnimation ? "No animated bones in this clip." - : "Select a rigged mesh and an animation to view its keyframes." + : "Select a rigged mesh, or add morph targets, to view keyframes." color: AnimationControlController.disabledTextColor font.pixelSize: 12 } @@ -891,9 +904,18 @@ Rectangle { id: morphBand anchors.left: parent.left anchors.right: parent.right - anchors.bottom: parent.bottom visible: morphRowsRep.count > 0 + // When there ARE bone tracks, the band docks to the BOTTOM under them + // (capped at 40% so it can't shove the bone rows off-screen). When there + // are NO bone tracks (morph-only mesh), it fills the whole sheet from the + // header down — otherwise it left an empty gap where the old skeleton + // message used to sit. + readonly property bool boneRowsPresent: root.rows.length > 0 + anchors.top: boneRowsPresent ? undefined : (header.visible ? header.bottom : parent.top) + anchors.bottom: parent.bottom + anchors.topMargin: boneRowsPresent ? 0 : 2 + // Cap the band at ~40% of the dope-sheet height so high-count // blendshape rigs (Mixamo characters routinely ship 50+) can't // push the bone tracks off-screen. When the content needs more @@ -903,9 +925,10 @@ Rectangle { readonly property int maxBandHeight: Math.max(morphHeader.height + root.rowHeight + 6, Math.floor(root.height * 0.4)) - height: visible - ? Math.min(naturalContentHeight, maxBandHeight) - : 0 + // Morph-only: fill (top+bottom anchors set height). With bones: capped. + height: !visible ? 0 + : boneRowsPresent ? Math.min(naturalContentHeight, maxBandHeight) + : undefined color: AnimationControlController.panelColor border.color: AnimationControlController.borderColor border.width: 1 @@ -977,27 +1000,102 @@ Rectangle { } } - // Right side: diamond per keyframe time. Sharing - // the bone-row timeline math so they line up - // vertically. `clip: true` so off-screen diamonds - // (from pan/zoom) don't bleed into the name strip - // or neighboring rows, matching the bone tracks. + // Right side: interactive diamonds. Drag to move the + // keyframe time, right-click to delete, double-click an + // empty spot to add a key at that time (weight = the + // target's current weight). Shares the bone-row timeline + // math so morph + bone diamonds line up vertically. Item { + id: morphTrackArea + property string morphName: modelData.name anchors.left: parent.left; anchors.leftMargin: root.leftStripWidth anchors.right: parent.right height: root.rowHeight clip: true + + Rectangle { + anchors.left: parent.left; anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: AnimationControlController.borderColor + opacity: 0.4 + } + + // Empty-area handler: double-click adds a key at the + // clicked time. Sits UNDER the diamonds so their own + // MouseAreas win for drag/delete. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onDoubleClicked: function(mouse) { + var t = mouse.x / root.pxPerSec + root.viewStart + if (t < 0) t = 0 + var w = MorphAnimationManager.weightForSelection(morphTrackArea.morphName) + MorphAnimationManager.setMorphWeightKeyframe( + morphTrackArea.morphName, t, w) + MorphAnimationManager.activateWeightClip() + AnimationControlController.sliderValue = Math.round(t * 1000) + } + } + Repeater { model: modelData.keyTimes Rectangle { + id: morphDiamond property real keyTime: modelData - x: (keyTime - root.viewStart) * root.pxPerSec - width / 2 + property real dragPreviewTime: keyTime + property real displayTime: dragMorph.dragging ? dragPreviewTime : keyTime + x: (displayTime - root.viewStart) * root.pxPerSec - width / 2 anchors.verticalCenter: parent.verticalCenter - width: 10; height: 10 + width: dragMorph.dragging ? 14 : 10 + height: width rotation: 45 - color: "#88ccff" // visually distinct from bone keys (yellow) + color: dragMorph.dragging ? "#ffaa55" : "#88ccff" border.color: AnimationControlController.borderColor border.width: 1 + + MouseArea { + id: dragMorph + anchors.fill: parent + anchors.margins: -3 + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.SizeHorCursor + preventStealing: true + property bool dragging: false + property real pressX: 0 + property real originalTime: 0 + onPressed: function(mouse) { + root.forceActiveFocus() + if (mouse.button === Qt.RightButton) { + MorphAnimationManager.clearMorphWeightKeyframe( + morphTrackArea.morphName, morphDiamond.keyTime) + mouse.accepted = true + return + } + originalTime = morphDiamond.keyTime + pressX = mouse.x + dragging = true + AnimationControlController.sliderValue = + Math.round(morphDiamond.keyTime * 1000) + mouse.accepted = true + } + onPositionChanged: function(mouse) { + if (!dragging) return + var dt = (mouse.x - pressX) / root.pxPerSec + var target = originalTime + dt + if (target < 0) target = 0 + morphDiamond.dragPreviewTime = target + } + onReleased: function(mouse) { + if (!dragging) return + dragging = false + var target = morphDiamond.dragPreviewTime + if (Math.abs(target - originalTime) > 0.001) { + MorphAnimationManager.moveMorphWeightKeyframe( + morphTrackArea.morphName, originalTime, target) + } + } + } } } } diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index b4578eb5..47d27d62 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -499,7 +499,7 @@ Rectangle { sectionVisible: root.modeToolSectionVisible( EditorModeController.EditMode, EditModeController.editModeActive) - expanded: false + expanded: true Component.onCompleted: content = vertexMorphComponent } @@ -6428,6 +6428,12 @@ Rectangle { function onSelectionChanged() { refreshAnimData() } function onAnimationStateChanged() { refreshAnimData() } } + // Morph clips (create/delete/rename) are mesh animations that show + // in this list too — refresh when they change. + Connections { + target: MorphAnimationManager + function onMorphClipsChanged() { refreshAnimData() } + } // ── Generate from text (#411, experimental) ────────────────────── // Lives in the Animations group and shows for any skeleton-bearing @@ -7101,15 +7107,10 @@ Rectangle { // Authoring (add/rename/delete) lands in A3. Rectangle { width: parent.width - 16 - // Show when the mesh already has targets, OR when an entity is - // selected so the FIRST target can be authored. Without the - // second clause the panel (and its "+ Add…" button) was gated on - // targetCount > 0 — a chicken-and-egg that made it impossible to - // create the first morph target from the UI. The Add button - // stays disabled outside Edit Mode (with a tooltip) so the empty - // panel just advertises the feature + the entry path. - visible: morphCol.targetCount > 0 - || PropertiesPanelController.hasEntitySelection + // Always visible inside this section — the section itself is already + // gated on Edit Mode (see the CollapsibleSection). Showing it + // unconditionally is what lets the user create the FIRST clip + + // target on a mesh that has none yet (the chicken-and-egg fix). height: morphCol.implicitHeight + 12 color: PropertiesPanelController.headerColor border.color: PropertiesPanelController.borderColor @@ -7128,6 +7129,12 @@ Rectangle { property var targets: MorphAnimationManager.morphTargetsForSelection() || [] property int targetCount: targets.length property string filter: "" + + // Shared drag-reorder state (one drag at a time). Rows read these + // so the insertion indicator can render on the LANDING row even + // though the drag gesture is owned by the dragged row's grip. + property int dragFromIndex: -1 // row being dragged (-1 = idle) + property int dragToIndex: -1 // proposed landing index // Bumped on `morphWeightChanged`; sliders bind their // `value` to a function call gated on this counter so // weight changes from any code path (Reset all, @@ -7266,6 +7273,176 @@ Rectangle { } } + // Morph-clip selector (smile / angry / surprised …). The targets + // below are SHARED across clips; each clip keyframes them to + // different values over time. Keying (◈ / dope sheet) writes to + // the active clip. Each clip exports as its own glTF animation. + RowLayout { + id: clipRow + width: parent.width + spacing: 4 + // Clips animate existing targets, so the selector only makes + // sense once at least one target exists. (Also matches the + // backend guard that rejects clip creation with no targets.) + visible: morphCol.targetCount > 0 + // Bumped to force re-read of morphClips() when it changes. + property int clipTick: 0 + Connections { + target: MorphAnimationManager + // Refresh the clip list + selection when clips change + // (create/delete/rename) or the active clip is switched. + function onMorphClipsChanged() { + clipRow.clipTick++ + clipCombo.syncIndex() + } + } + Text { + text: "Clip:" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + Layout.alignment: Qt.AlignVCenter + } + ThemedComboBox { + id: clipCombo + Layout.fillWidth: true + Layout.preferredHeight: 20 + font.pixelSize: 10 + // clipTick in the expression forces re-evaluation when + // the clip list changes. + property var clips: (clipRow.clipTick, + MorphAnimationManager.morphClips()) + model: clips.length > 0 ? clips + : [MorphAnimationManager.activeMorphClip] + Component.onCompleted: syncIndex() + onClipsChanged: syncIndex() + function syncIndex() { + var a = MorphAnimationManager.activeMorphClip + var i = model.indexOf(a) + currentIndex = i >= 0 ? i : 0 + } + onActivated: { + if (currentIndex >= 0 && currentIndex < model.length) + MorphAnimationManager.activeMorphClip = model[currentIndex] + } + } + // New clip + Rectangle { + Layout.preferredWidth: 40; Layout.preferredHeight: 20; radius: 3 + color: newClipMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "+ New" + color: PropertiesPanelController.textColor; font.pixelSize: 9 } + MouseArea { + id: newClipMa + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { newClipField.text = ""; newClipError.text = "" + newClipPopup.open() } + } + } + // Delete active clip + Rectangle { + Layout.preferredWidth: 20; Layout.preferredHeight: 20; radius: 3 + visible: clipCombo.clips.length > 0 + color: delClipMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" + Text { anchors.centerIn: parent; text: "×" + color: PropertiesPanelController.textColor + font.pixelSize: 12; font.bold: true } + MouseArea { + id: delClipMa + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: MorphAnimationManager.deleteMorphClip( + MorphAnimationManager.activeMorphClip) + ToolTip.visible: containsMouse + ToolTip.text: "Delete the active morph clip (targets are kept)" + } + } + } + + // New-clip name popup. + Popup { + id: newClipPopup + modal: true; focus: true; width: 240; padding: 10 + onOpened: newClipField.forceActiveFocus() + background: Rectangle { + color: PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1; radius: 4 + } + contentItem: Column { + spacing: 6 + Text { text: "New morph clip name (e.g. smile, angry):" + color: PropertiesPanelController.textColor; font.pixelSize: 11 } + TextField { + id: newClipField + width: 220; font.pixelSize: 11 + color: PropertiesPanelController.textColor + selectByMouse: true + placeholderText: "clip name" + background: Rectangle { + color: PropertiesPanelController.inputColor + border.color: newClipField.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1; radius: 3 + } + onAccepted: newClipConfirm.confirm() + onTextChanged: newClipError.text = "" + } + Text { + id: newClipError + text: ""; visible: text.length > 0 + color: "#d65d5d"; font.pixelSize: 10; width: 220; wrapMode: Text.Wrap + } + Row { + spacing: 6 + Rectangle { + width: 60; height: 20; radius: 3 + color: newClipConfirm.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Create" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: newClipConfirm + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + function confirm() { + var n = newClipField.text.trim() + if (n.length === 0) { newClipError.text = "Name cannot be empty."; return } + if (MorphAnimationManager.createMorphClip(n)) + newClipPopup.close() + else + newClipError.text = "Couldn't create: name already in use?" + } + onClicked: confirm() + } + } + Rectangle { + width: 60; height: 20; radius: 3 + color: newClipCancel.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Cancel" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: newClipCancel + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: newClipPopup.close() + } + } + } + } + } + // Inline name-entry popup for "Add from edit…". Themed to // match the Inspector (panel background + border) rather than // the default Qt Quick Controls chrome. @@ -7415,14 +7592,135 @@ Rectangle { } // One row per target. Hidden when filter doesn't match. + // Rows are drag-to-reorder via the grip handle (☰): the grip's + // MouseArea tracks vertical movement and computes the target + // index from the drag distance, then calls + // MorphAnimationManager.moveMorphTargetToIndex (undoable) on + // release. Manual Y-tracking is used rather than Drag/DropArea + // because a layout-managed (Row) child can't reliably drive the + // attached Drag property. Reorder needs the full-list index, so + // the grip is hidden while filtering. Repeater { + id: morphRepeater model: morphCol.targets - Row { + Item { + id: morphRowItem width: morphCol.width - spacing: 4 visible: morphCol.filter === "" || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 height: visible ? 22 : 0 + property string targetName: modelData + property int rowIndex: index + // Row stride = height(22) + Column spacing(4). + readonly property int rowStride: 26 + // Live proposed drop index while dragging (-1 = idle). + property int dropIndex: -1 + // Live vertical offset the dragged row follows the cursor by. + property real dragDy: 0 + + // The row being dragged floats above its neighbours. + readonly property bool isDragged: + morphCol.dragFromIndex === morphRowItem.rowIndex + z: isDragged ? 10 : 0 + + // Dragged-row background (moves with the cursor). + Rectangle { + anchors.fill: parent + y: morphRowItem.dragDy + visible: morphRowItem.isDragged + color: Qt.rgba(PropertiesPanelController.highlightColor.r, + PropertiesPanelController.highlightColor.g, + PropertiesPanelController.highlightColor.b, 0.25) + border.color: PropertiesPanelController.highlightColor + border.width: 1 + radius: 2 + } + + // Insertion indicator: a bright line marking the LANDING + // slot (the shared dragToIndex), rendered on that row. + // Shown on every row EXCEPT the dragged one so the target + // reads clearly even though the list doesn't reflow live. + Rectangle { + visible: morphCol.dragFromIndex >= 0 + && !morphRowItem.isDragged + && morphCol.dragToIndex === morphRowItem.rowIndex + width: parent.width; height: 2; radius: 1 + color: PropertiesPanelController.highlightColor + // Bottom edge when dropping below the origin ("lands + // after this row"), top edge when dropping above. + y: (morphCol.dragToIndex > morphCol.dragFromIndex) + ? parent.height - height : 0 + } + + Row { + anchors.fill: parent + spacing: 4 + // The content follows the cursor vertically while dragging. + y: morphRowItem.dragDy + + // Drag handle (☰) — the only drag-initiating surface, so + // the slider / rename / buttons keep their own gestures. + Item { + id: morphGrip + width: 14; height: 22 + visible: morphCol.filter === "" && morphCol.targetCount > 1 + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: "☰" + color: PropertiesPanelController.textColor + opacity: gripMa.dragging ? 1.0 : 0.6 + font.pixelSize: 11 + } + MouseArea { + id: gripMa + anchors.fill: parent + cursorShape: gripMa.dragging ? Qt.ClosedHandCursor + : Qt.OpenHandCursor + preventStealing: true + property bool dragging: false + property real pressY: 0 + property int startIndex: 0 + onPressed: function(mouse) { + pressY = mouse.y + startIndex = morphRowItem.rowIndex + dragging = true + morphRowItem.dragDy = 0 + morphCol.dragFromIndex = morphRowItem.rowIndex + morphCol.dragToIndex = morphRowItem.rowIndex + } + onPositionChanged: function(mouse) { + if (!dragging) return + var dy = (mouse.y - pressY) + // Move the row visual with the cursor. + morphRowItem.dragDy = dy + var shift = Math.round(dy / morphRowItem.rowStride) + var target = morphRowItem.rowIndex + shift + if (target < 0) target = 0 + if (target > morphCol.targetCount - 1) + target = morphCol.targetCount - 1 + morphCol.dragToIndex = target + } + onReleased: function(mouse) { + if (!dragging) return + dragging = false + var target = morphCol.dragToIndex + var from = morphCol.dragFromIndex + morphCol.dragFromIndex = -1 + morphCol.dragToIndex = -1 + morphRowItem.dragDy = 0 + if (target >= 0 && target !== from) + MorphAnimationManager.moveMorphTargetToIndex( + morphRowItem.targetName, target) + } + onCanceled: { + dragging = false + morphCol.dragFromIndex = -1 + morphCol.dragToIndex = -1 + morphRowItem.dragDy = 0 + } + } + } // Name — double-click to rename in place, // matching the per-animation rename UX above. @@ -7432,9 +7730,15 @@ Rectangle { text: modelData color: PropertiesPanelController.textColor font.pixelSize: 10 - width: 120 - elide: Text.ElideRight + width: morphCol.filter === "" && morphCol.targetCount > 1 ? 106 : 120 + // Middle ellipsis: morph names often share long + // prefixes (LeftEyebrow… / LeftEyeBlink…) AND + // meaningful suffixes (_01, _L), so elide the middle. + elide: Text.ElideMiddle anchors.verticalCenter: parent.verticalCenter + ToolTip.visible: nameHover.hovered && truncated + ToolTip.text: modelData + HoverHandler { id: nameHover } MouseArea { anchors.fill: parent onDoubleClicked: { @@ -7480,9 +7784,9 @@ Rectangle { Slider { id: weightSlider from: 0; to: 1; stepSize: 0.01 - // name(120) + weight(36) + key(16) + up(16) + - // down(16) + delete(18) + row spacings ≈ 272. - width: parent.width - 272 + // grip(14) + name(106) + weight(36) + key(16) + + // delete(18) + row spacings ≈ 220 reserved. + width: parent.width - 220 // Bind to `weightTick` so changes that // bypass user drag (Reset all, MCP, future // dope-sheet scrubs) refresh the readout. @@ -7527,60 +7831,16 @@ Rectangle { var t = AnimationControlController.sliderValue / 1000.0 MorphAnimationManager.setMorphWeightKeyframe( modelData, t, weightSlider.value) + // Make the weight clip the active, playable + // animation so scrubbing/playing shows the + // keyed weights immediately (otherwise the + // key exists but nothing drives it). + MorphAnimationManager.activateWeightClip() } ToolTip.visible: containsMouse ToolTip.text: "Key this weight at the timeline playhead" } } - // Move up (▲) — reorder via ReorderMorphTargetsCommand - // (undoable). Disabled on the first row. Reorder is only - // meaningful with no filter applied (the index maps to - // the full list), so hide the arrows while filtering. - Rectangle { - width: 16; height: 18; radius: 3 - anchors.verticalCenter: parent.verticalCenter - visible: morphCol.filter === "" && morphCol.targetCount > 1 - opacity: index > 0 ? 1.0 : 0.3 - color: morphUpMa.containsMouse && index > 0 - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : "transparent" - Text { - anchors.centerIn: parent - text: "▲"; color: PropertiesPanelController.textColor - font.pixelSize: 9 - } - MouseArea { - id: morphUpMa - anchors.fill: parent - hoverEnabled: true - enabled: index > 0 - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: MorphAnimationManager.moveMorphTarget(modelData, -1) - } - } - // Move down (▼) — disabled on the last row. - Rectangle { - width: 16; height: 18; radius: 3 - anchors.verticalCenter: parent.verticalCenter - visible: morphCol.filter === "" && morphCol.targetCount > 1 - opacity: index < morphCol.targetCount - 1 ? 1.0 : 0.3 - color: morphDownMa.containsMouse && index < morphCol.targetCount - 1 - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : "transparent" - Text { - anchors.centerIn: parent - text: "▼"; color: PropertiesPanelController.textColor - font.pixelSize: 9 - } - MouseArea { - id: morphDownMa - anchors.fill: parent - hoverEnabled: true - enabled: index < morphCol.targetCount - 1 - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: MorphAnimationManager.moveMorphTarget(modelData, 1) - } - } // Delete (×) — drops the pose + animation // through DeleteMorphTargetCommand so Ctrl+Z // restores it. @@ -7605,7 +7865,8 @@ Rectangle { onClicked: MorphAnimationManager.deleteMorphTarget(modelData) } } - } + } // Row + } // morphRowItem (drag/drop wrapper) } } } diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 0e00685c..92b79db2 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -230,6 +230,13 @@ void AnimationControlController::selectAnimation(const QString& entityName, cons if (m_selectedSkeleton && m_selectedSkeleton->hasAnimation(m_selectedAnimation)) { Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); m_sliderMaximum = static_cast(anim->getLength() * 1000); + } else if (Ogre::MeshPtr mesh = m_selectedEntity->getMesh(); + mesh && mesh->hasAnimation(m_selectedAnimation)) { + // Mesh-level (VAT_POSE) clip — morph-weight animation or Alembic vertex + // cache. These have no skeleton; take the length off the mesh Animation + // so the timeline scrubs (skeleton-only derivation left it at 0). + m_sliderMaximum = static_cast( + mesh->getAnimation(m_selectedAnimation)->getLength() * 1000); } // Reset loop region to span the whole animation whenever a new clip is @@ -396,10 +403,20 @@ void AnimationControlController::setSliderValue(int ms) void AnimationControlController::setAnimationLength(double length) { - if (!m_selectedSkeleton || m_selectedAnimation.empty()) return; - if (!m_selectedSkeleton->hasAnimation(m_selectedAnimation)) return; + if (m_selectedAnimation.empty()) return; + + // Resolve the Animation from the skeleton (skeletal clip) OR the mesh + // (VAT_POSE morph-weight / vertex-cache clip). Length is settable for both. + Ogre::Animation* anim = nullptr; + if (m_selectedSkeleton && m_selectedSkeleton->hasAnimation(m_selectedAnimation)) { + anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); + } else if (m_selectedEntity) { + if (Ogre::MeshPtr mesh = m_selectedEntity->getMesh(); + mesh && mesh->hasAnimation(m_selectedAnimation)) + anim = mesh->getAnimation(m_selectedAnimation); + } + if (!anim) return; - Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); anim->setLength(static_cast(length)); m_sliderMaximum = static_cast(length * 1000); @@ -982,7 +999,11 @@ QVariantList AnimationControlController::allMorphRows() const return any; }; - if (!appendTrackTimes(MorphAnimationManager::kWeightClipName)) + // Read the ACTIVE morph clip's keys (the one the user is editing), so + // switching clips in the dropdown shows that clip's diamonds. Fall back + // to the static shape-only clip when the active clip has no key here. + if (!appendTrackTimes( + MorphAnimationManager::instance()->activeMorphClip().toStdString())) appendTrackTimes(poseName); // static shape-only clip QVariantMap row; diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 997fbe12..2fff1dfb 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -612,6 +612,14 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, // n-gon submeshes downstream. if (!sawNGon) sub.faces.clear(); + // Snapshot the bind positions so morph-target authoring can diff the + // edited vertices against the pre-edit baseline. (loadFromEntity does + // this too; the n-gon path must match or morph capture sees orig=0 and + // reports "nothing moved" — the OBJ morph bug.) + sub.originalPositions.reserve(sub.vertices.size()); + for (const auto& v : sub.vertices) + sub.originalPositions.push_back(v.position); + m_subMeshes.push_back(std::move(sub)); } diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 3be55108..9b36dedb 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -939,9 +939,9 @@ static aiAnimation* buildAiAnimation(Ogre::Animation* ogreAnim, const std::strin // submesh carries morph targets, so non-morph meshes are entirely unaffected. static aiAnimation* buildMorphWeightAiAnimation(const aiScene* scene, const Ogre::MeshPtr& mesh, - const std::string& meshNodeName) + const std::string& meshNodeName, + const std::string& clip) { - const std::string clip = MorphAnimationManager::kWeightClipName; if (!mesh->hasAnimation(clip)) return nullptr; Ogre::Animation* weightClip = mesh->getAnimation(clip); if (!weightClip) return nullptr; @@ -1015,7 +1015,7 @@ static aiAnimation* buildMorphWeightAiAnimation(const aiScene* scene, if (morphChannels.empty()) return nullptr; auto* anim = new aiAnimation(); // NOSONAR — Assimp owns - anim->mName = aiString(std::string(MorphAnimationManager::kWeightClipName)); + anim->mName = aiString(clip); anim->mTicksPerSecond = 1.0; // times are seconds, matching buildAiAnimation anim->mDuration = weightClip->getLength(); anim->mNumMorphMeshChannels = static_cast(morphChannels.size()); @@ -1148,8 +1148,25 @@ static aiScene* buildAiScene(const Ogre::Entity* entity) const std::string meshNodeName = hasSkeleton ? std::string(entity->getName()) + "_mesh" : std::string(entity->getName()); - if (aiAnimation* morphAnim = buildMorphWeightAiAnimation(scene, mesh, meshNodeName)) - animations.push_back(morphAnim); + // Export EVERY morph (weight) clip — smile / angry / surprised — as its own + // glTF morph-weights animation. A morph clip is a mesh Animation carrying a + // VAT_POSE track that is NOT a per-target shape clip (named exactly a pose + // name). Shape clips are the static single-key blend-shape definitions; the + // targets themselves are emitted via aiMesh::mAnimMeshes. + for (unsigned short ai = 0; ai < mesh->getNumAnimations(); ++ai) + { + Ogre::Animation* a = mesh->getAnimation(ai); + if (!a) continue; + const std::string nm = a->getName(); + // Skip per-target shape clips (name == a pose name). + bool isShapeClip = false; + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == nm) { isShapeClip = true; break; } + if (isShapeClip) continue; + if (aiAnimation* morphAnim = + buildMorphWeightAiAnimation(scene, mesh, meshNodeName, nm)) + animations.push_back(morphAnim); + } if (!animations.empty()) { diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index 6e1c44ba..48ad8625 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -10,6 +10,8 @@ The MIT License #include "MorphAnimationManager.h" +#include "AnimationControlController.h" +#include "PropertiesPanelController.h" #include "EditModeController.h" #include "EditableMesh.h" #include "SelectionSet.h" @@ -25,6 +27,7 @@ The MIT License #include #include #include +#include #include #include @@ -67,7 +70,9 @@ void MorphAnimationManager::kill() s_instance = nullptr; } -MorphAnimationManager::MorphAnimationManager(QObject* parent) : QObject(parent) +MorphAnimationManager::MorphAnimationManager(QObject* parent) + : QObject(parent), + m_activeMorphClip(QString::fromUtf8(kWeightClipName)) { if (auto* sel = SelectionSet::getSingleton()) { connect(sel, &SelectionSet::selectionChanged, @@ -180,17 +185,16 @@ int poseIndexForName(Ogre::Mesh* mesh, const std::string& name) return -1; } -// The weight-animation track for a target lives on the shared "MorphAnim" -// clip, keyed on the pose's target submesh handle. Fetch-or-create the -// clip + track. Returns nullptr if the pose doesn't exist. +// The weight-animation track for a target lives on the named `clip`, keyed on +// the pose's target submesh handle. Fetch-or-create the clip + track. Returns +// nullptr if the pose doesn't exist. Ogre::VertexAnimationTrack* weightTrackFor(Ogre::Mesh* mesh, const std::string& name, - bool create) + const std::string& clip, bool create) { const int pi = poseIndexForName(mesh, name); if (pi < 0) return nullptr; const unsigned short handle = mesh->getPoseList()[pi]->getTarget(); - const std::string clip = MorphAnimationManager::kWeightClipName; Ogre::Animation* anim = mesh->hasAnimation(clip) ? mesh->getAnimation(clip) : (create ? mesh->createAnimation(clip, 0.0f) : nullptr); @@ -202,6 +206,16 @@ Ogre::VertexAnimationTrack* weightTrackFor(Ogre::Mesh* mesh, const std::string& return anim->createVertexTrack(handle, Ogre::VAT_POSE); } +// Is `animName` a per-target SHAPE clip (named exactly a pose name)? Those are +// not user-facing "morph clips"; the weight clips are everything else that +// carries a VAT_POSE track and isn't a pose name. +bool isPoseShapeClip(Ogre::Mesh* mesh, const std::string& animName) +{ + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == animName) return true; + return false; +} + } // namespace bool MorphAnimationManager::setMorphWeightKeyframe(const QString& name, @@ -218,9 +232,11 @@ bool MorphAnimationManager::setMorphWeightKeyframe(const QString& name, Ogre::MeshPtr mesh = entity->getMesh(); if (!mesh) return false; + const std::string clip = m_activeMorphClip.toStdString(); const int pi = poseIndexForName(mesh.get(), name.toStdString()); if (pi < 0) return false; - Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), true); + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), clip, true); if (!track) return false; const float t = static_cast(time); @@ -240,7 +256,7 @@ bool MorphAnimationManager::setMorphWeightKeyframe(const QString& name, kf->addPoseReference(static_cast(pi), w); // Extend the clip length to cover the new time. - Ogre::Animation* anim = mesh->getAnimation(kWeightClipName); + Ogre::Animation* anim = mesh->getAnimation(clip); if (anim && t > anim->getLength()) anim->setLength(t); @@ -264,7 +280,8 @@ bool MorphAnimationManager::clearMorphWeightKeyframe(const QString& name, double Ogre::MeshPtr mesh = ents.first()->getMesh(); if (!mesh) return false; - Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), false); + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); if (!track) return false; const float t = static_cast(time); for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { @@ -277,6 +294,85 @@ bool MorphAnimationManager::clearMorphWeightKeyframe(const QString& name, double return false; } +double MorphAnimationManager::morphWeightAt(const QString& name, double time) const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return -1.0; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return -1.0; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return -1.0; + + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return -1.0; + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); + if (!track) return -1.0; + const float t = static_cast(time); + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + if (std::abs(kf->getTime() - t) >= 1e-4f) continue; + for (const auto& ref : kf->getPoseReferences()) + if (ref.poseIndex == static_cast(pi)) + return static_cast(ref.influence); + } + return -1.0; +} + +bool MorphAnimationManager::moveMorphWeightKeyframe(const QString& name, + double oldTime, double newTime) +{ + assertMainThread(); + if (name.isEmpty()) return false; + if (std::abs(oldTime - newTime) < 1e-4) return false; + if (newTime < 0.0) return false; + + // Preserve the weight from the source key; reject if there's already a key + // at the destination (avoid silently merging two keys). + const double w = morphWeightAt(name, oldTime); + if (w < 0.0) return false; // no source key + if (morphWeightAt(name, newTime) >= 0.0) return false; // destination occupied + + if (!clearMorphWeightKeyframe(name, oldTime)) return false; + return setMorphWeightKeyframe(name, newTime, w); +} + +bool MorphAnimationManager::activateWeightClip() +{ + assertMainThread(); + auto* sel = SelectionSet::getSingleton(); + 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(); + const std::string clip = m_activeMorphClip.toStdString(); + if (!mesh || !mesh->hasAnimation(clip)) return false; + + // Make sure the entity mirrors the clip as a playable AnimationState and + // enable it (weight tracks only apply while the state is enabled). Disable + // any OTHER morph clip so two emotion clips don't fight over the same poses. + entity->refreshAvailableAnimationState(); + if (auto* states = entity->getAllAnimationStates()) { + for (const auto& [nm, st] : states->getAnimationStates()) + if (!isPoseShapeClip(mesh.get(), nm) && nm != clip + && mesh->hasAnimation(nm)) + st->setEnabled(false); + } + if (entity->hasAnimationState(clip)) { + Ogre::AnimationState* st = entity->getAnimationState(clip); + st->setEnabled(true); + st->setLoop(true); + } + + // Select it in the Animation Control panel so the timeline slider scrubs it + // (its length + slider maximum come from the mesh Animation now). + if (auto* acc = AnimationControlController::instance()) + acc->selectAnimation(QString::fromStdString(entity->getName()), + m_activeMorphClip); + return true; +} + QVariantList MorphAnimationManager::morphWeightKeyframeTimes(const QString& name) const { QVariantList out; @@ -287,13 +383,160 @@ QVariantList MorphAnimationManager::morphWeightKeyframeTimes(const QString& name Ogre::MeshPtr mesh = ents.first()->getMesh(); if (!mesh) return out; - Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), false); + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); if (!track) return out; for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) out.append(static_cast(track->getKeyFrame(i)->getTime())); return out; } +// ── Morph clip management (multiple named weight clips) ────────────────────── + +void MorphAnimationManager::setActiveMorphClip(const QString& name) +{ + if (name.isEmpty() || name == m_activeMorphClip) return; + m_activeMorphClip = name; + emit morphClipsChanged(); + emit morphTargetsChanged(); // dope sheet reads the active clip's keys +} + +QStringList MorphAnimationManager::morphClips() const +{ + QStringList out; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return out; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return out; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return out; + + // A morph (weight) clip = any MESH-level Animation that is NOT a per-target + // shape clip (those are named exactly a pose name). Skeletal clips live on + // the skeleton, not the mesh, so they never appear here. We deliberately do + // NOT require a VAT_POSE track: a freshly-created clip has no tracks until + // its first keyframe, and it must still show in the dropdown. + for (unsigned short i = 0; i < mesh->getNumAnimations(); ++i) { + Ogre::Animation* a = mesh->getAnimation(i); + if (!a) continue; + const std::string nm = a->getName(); + if (isPoseShapeClip(mesh.get(), nm)) continue; + out << QString::fromStdString(nm); + } + return out; +} + +bool MorphAnimationManager::createMorphClip(const QString& name) +{ + assertMainThread(); + if (name.trimmed().isEmpty()) return false; + auto* sel = SelectionSet::getSingleton(); + 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; + // A morph clip animates existing morph TARGETS. With no poses there is + // nothing to key, and creating an empty VAT-less animation on a plain mesh + // (e.g. an unmorphed OBJ) then refreshing/rendering its AnimationState is + // what crashed. Require at least one target first — the user sculpts +Add + // before making clips. (m_activeMorphClip still tracks the intended name.) + if (mesh->getPoseCount() == 0) + return false; + const std::string nm = name.trimmed().toStdString(); + if (mesh->hasAnimation(nm)) return false; // name collision + + mesh->createAnimation(nm, 0.0f); // empty; tracks added on first key + entity->refreshAvailableAnimationState(); + m_activeMorphClip = name.trimmed(); + emit morphClipsChanged(); + emit morphTargetsChanged(); + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("create morph clip '%1'").arg(name.trimmed())); + return true; +} + +bool MorphAnimationManager::deleteMorphClip(const QString& name) +{ + assertMainThread(); + if (name.isEmpty()) return false; + auto* sel = SelectionSet::getSingleton(); + 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 || !mesh->hasAnimation(name.toStdString())) return false; + if (isPoseShapeClip(mesh.get(), name.toStdString())) return false; // that's a target + + // Stop playback and drop the state before removing the animation. + if (auto* ppc = PropertiesPanelController::instance()) ppc->setPlaying(false); + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(name.toStdString())) + states->getAnimationState(name.toStdString())->setEnabled(false); + } + mesh->removeAnimation(name.toStdString()); + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(name.toStdString())) + states->removeAnimationState(name.toStdString()); + } + entity->refreshAvailableAnimationState(); + + // If the active clip was deleted, fall back to another (or the default). + if (m_activeMorphClip == name) { + const QStringList remaining = morphClips(); + m_activeMorphClip = remaining.isEmpty() + ? QString::fromUtf8(kWeightClipName) : remaining.first(); + } + emit morphClipsChanged(); + emit morphTargetsChanged(); + return true; +} + +bool MorphAnimationManager::renameMorphClip(const QString& oldName, const QString& newName) +{ + assertMainThread(); + if (oldName.isEmpty() || newName.trimmed().isEmpty() || oldName == newName) + return false; + auto* sel = SelectionSet::getSingleton(); + 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(); + const std::string on = oldName.toStdString(); + const std::string nn = newName.trimmed().toStdString(); + if (!mesh || !mesh->hasAnimation(on) || mesh->hasAnimation(nn)) return false; + if (isPoseShapeClip(mesh.get(), on)) return false; // that's a target, not a clip + + // Rebuild the Animation under the new name copying every VAT_POSE track + + // keyframe + pose reference, then drop the old one. (Ogre has no rename.) + Ogre::Animation* src = mesh->getAnimation(on); + Ogre::Animation* dst = mesh->createAnimation(nn, src->getLength()); + for (const auto& [handle, srcTrack] : src->_getVertexTrackList()) { + if (!srcTrack || srcTrack->getAnimationType() != Ogre::VAT_POSE) continue; + Ogre::VertexAnimationTrack* dstTrack = + dst->createVertexTrack(handle, Ogre::VAT_POSE); + for (unsigned short k = 0; k < srcTrack->getNumKeyFrames(); ++k) { + auto* skf = static_cast(srcTrack->getKeyFrame(k)); + auto* dkf = dstTrack->createVertexPoseKeyFrame(skf->getTime()); + for (const auto& ref : skf->getPoseReferences()) + dkf->addPoseReference(ref.poseIndex, ref.influence); + } + } + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(on)) + states->removeAnimationState(on); + } + mesh->removeAnimation(on); + entity->refreshAvailableAnimationState(); + if (m_activeMorphClip == oldName) m_activeMorphClip = newName.trimmed(); + emit morphClipsChanged(); + emit morphTargetsChanged(); + return true; +} + namespace { // Walk the per-submesh edited positions on the EditableMesh and diff @@ -335,7 +578,9 @@ std::vector capturePoseSlicesFromEdit( if (bindPositions.size() != subs[s].vertices.size()) continue; MorphPoseSlice slice; - slice.submeshHandle = static_cast(s + 1); + // Shared geometry poses target handle 0; per-submesh use 1-based. + slice.submeshHandle = subs[s].usesSharedVertices + ? 0 : static_cast(s + 1); for (size_t vi = 0; vi < bindPositions.size(); ++vi) { const Ogre::Vector3 delta = subs[s].vertices[vi].position - bindPositions[vi]; diff --git a/src/MorphAnimationManager.h b/src/MorphAnimationManager.h index 3dae56af..b557df79 100644 --- a/src/MorphAnimationManager.h +++ b/src/MorphAnimationManager.h @@ -70,28 +70,66 @@ class MorphAnimationManager : public QObject Q_INVOKABLE double weightForSelection(const QString& name) const; Q_INVOKABLE bool setWeightForSelection(const QString& name, double w); - /// Weight keyframing over time (Slice 2, #519). A single mesh Animation - /// named `kWeightClipName` ("MorphAnim") holds one VAT_POSE track per - /// target's pose; each keyframe references the pose at the influence - /// (= weight) recorded at that time. This is what glTF exports as a - /// morph-weights animation. Distinct from the static per-target Animation - /// (named exactly the target name) that only carries the shape. - - /// Record `weight` for target `name` at `time` seconds on the weight clip + /// Weight keyframing over time (Slice 2, #519). Morph targets are the shared + /// SHAPES (smile, browRaise, jawOpen…); a "morph clip" is a named mesh + /// Animation that keyframes those shapes' weights over time (smile / angry / + /// surprised). A clip holds one VAT_POSE track per target's pose; each + /// keyframe references the pose at the influence (= weight) at that time. + /// Each clip exports as a separate glTF morph-weights animation. Multiple + /// clips share the same targets. Distinct from the static per-target + /// Animation (named exactly the target name) that only carries the shape. + + /// The currently-active morph clip that keyframe edits write to (default + /// `kWeightClipName`). Every keyframe method below operates on THIS clip. + Q_PROPERTY(QString activeMorphClip READ activeMorphClip WRITE setActiveMorphClip + NOTIFY morphClipsChanged) + QString activeMorphClip() const { return m_activeMorphClip; } + void setActiveMorphClip(const QString& name); + + /// Named morph (weight) clips on the selected entity — mesh VAT_POSE + /// animations that are NOT per-target shape clips. For the clip dropdown. + Q_INVOKABLE QStringList morphClips() const; + + /// Create an empty morph clip named `name` and make it active. Returns false + /// if the name is empty / already exists / no selection. + Q_INVOKABLE bool createMorphClip(const QString& name); + + /// Delete a morph clip (its Animation + AnimationState). Returns false on + /// no-op. Does NOT touch the shared targets/poses. + Q_INVOKABLE bool deleteMorphClip(const QString& name); + + /// Rename a morph clip. Rejects the reserved default name collisions. + Q_INVOKABLE bool renameMorphClip(const QString& oldName, const QString& newName); + + /// Record `weight` for target `name` at `time` on the ACTIVE morph clip /// (creates the clip/track/keyframe as needed, updates in place otherwise). /// Extends the clip length to cover `time`. Returns false on no-op. Q_INVOKABLE bool setMorphWeightKeyframe(const QString& name, double time, double weight); - /// Remove the weight keyframe for `name` at (approximately) `time`. + /// Remove the active clip's weight keyframe for `name` at (approx) `time`. Q_INVOKABLE bool clearMorphWeightKeyframe(const QString& name, double time); - /// Keyframe times (seconds) for `name`'s weight track on the selection, - /// ascending. Empty if none. For the dope sheet. + /// Move a weight keyframe from `oldTime` to `newTime` on the active clip, + /// preserving its weight (dope-sheet drag). False on no-op/not-found/collision. + Q_INVOKABLE bool moveMorphWeightKeyframe(const QString& name, + double oldTime, double newTime); + + /// The weight stored at (approx) `time` for `name` on the active clip, or -1. + Q_INVOKABLE double morphWeightAt(const QString& name, double time) const; + + /// Active clip's keyframe times (seconds) for `name`, ascending. Empty if + /// none. For the dope sheet. Q_INVOKABLE QVariantList morphWeightKeyframeTimes(const QString& name) const; - /// The weight-animation clip name used across the app + glTF export. + /// Default morph-clip name (used when no clip is named explicitly + the + /// first clip created). Also the glTF export fallback name. static const char* kWeightClipName; + /// Make the ACTIVE morph clip the playable animation on the selected entity: + /// select it in the Animation Control panel + enable its AnimationState so + /// the timeline scrubs/plays the keyed weights. No-op if it doesn't exist. + Q_INVOKABLE bool activateWeightClip(); + /// 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.). @@ -132,11 +170,17 @@ class MorphAnimationManager : public QObject /// Emitted when the morph-target list visible to the Inspector /// could have changed (selection moved, scene reloaded, etc.). void morphTargetsChanged(); + /// Emitted when the morph-clip list or the active clip changes. + void morphClipsChanged(); private: explicit MorphAnimationManager(QObject* parent = nullptr); ~MorphAnimationManager() override; + // The clip that keyframe edits target. Defaults to kWeightClipName so + // existing single-clip behaviour is preserved until the user makes more. + QString m_activeMorphClip; + static MorphAnimationManager* s_instance; }; diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp index ab0f71e8..cd23137f 100644 --- a/src/MorphAnimationManager_test.cpp +++ b/src/MorphAnimationManager_test.cpp @@ -289,6 +289,55 @@ TEST_F(MorphAnimationManagerSceneTest, WeightKeyframingBuildsMorphAnimClip) { sel->clear(); } +TEST_F(MorphAnimationManagerSceneTest, MultipleNamedMorphClipsShareTargets) { + auto mesh = createMorphTestMesh("Morph_MultiClip"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_MultiClipEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + m->setActiveMorphClip(QStringLiteral("smile")); + + // Two distinct clips over the SAME shared targets (JawOpen, Smile). + ASSERT_TRUE(m->createMorphClip(QStringLiteral("smile"))); + EXPECT_EQ(m->activeMorphClip(), QStringLiteral("smile")); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 0.0, 0.0)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 1.0, 1.0)); + + ASSERT_TRUE(m->createMorphClip(QStringLiteral("angry"))); + EXPECT_EQ(m->activeMorphClip(), QStringLiteral("angry")); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.0, 0.0)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.5, 1.0)); + + // Both clips exist as separate mesh animations; targets are unchanged. + QStringList clips = m->morphClips(); + EXPECT_TRUE(clips.contains(QStringLiteral("smile"))); + EXPECT_TRUE(clips.contains(QStringLiteral("angry"))); + EXPECT_TRUE(mesh->hasAnimation("smile")); + EXPECT_TRUE(mesh->hasAnimation("angry")); + + // Keyframe times are per-clip: angry's JawOpen has 2 keys; switch to smile + // and JawOpen has none there (smile only keyed Smile). + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 2); + m->setActiveMorphClip(QStringLiteral("smile")); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 0); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 2); + + // Rename + delete a clip; targets survive. + const size_t posesBefore = mesh->getPoseCount(); + EXPECT_TRUE(m->renameMorphClip(QStringLiteral("angry"), QStringLiteral("mad"))); + EXPECT_TRUE(mesh->hasAnimation("mad")); + EXPECT_FALSE(mesh->hasAnimation("angry")); + EXPECT_TRUE(m->deleteMorphClip(QStringLiteral("mad"))); + EXPECT_FALSE(mesh->hasAnimation("mad")); + EXPECT_EQ(mesh->getPoseCount(), posesBefore); // shared targets untouched + sel->clear(); +} + TEST_F(MorphAnimationManagerSceneTest, NoSelectionGivesEmptyList) { auto* m = MorphAnimationManager::instance(); EXPECT_TRUE(m->morphTargetsForSelection().isEmpty()); diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index e7bde6ca..b81e2fa1 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -879,6 +879,13 @@ bool PropertiesPanelController::renameAnimation(const QString& entityName, const { if (newName.isEmpty() || oldName == newName) return false; + // Morph (weight) clips are mesh-level VAT_POSE animations, not skeletal — + // the skeleton rename path below can't handle them (and would crash on a + // null skeleton). If this is a morph clip, delegate to the manager's + // mesh-aware rename instead. + if (MorphAnimationManager::instance()->morphClips().contains(oldName)) + return MorphAnimationManager::instance()->renameMorphClip(oldName, newName); + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); for (Ogre::Entity* ent : entities) { diff --git a/src/SkeletonTransform.cpp b/src/SkeletonTransform.cpp index 1c5d2c49..7da81ec0 100755 --- a/src/SkeletonTransform.cpp +++ b/src/SkeletonTransform.cpp @@ -116,7 +116,13 @@ bool SkeletonTransform::renameAnimation(Ogre::Entity *_ent, const QString &_oldN if(_newName.isEmpty()) return false; - if(!_ent || !_ent->getSkeleton()->hasAnimation(_oldName.toStdString().data())) + // Guard the skeleton null case: a morph-only / vertex-cache mesh has no + // skeleton, and its clips live on the mesh (VAT_POSE), not here. Callers + // must route those to the mesh-animation rename path — bail (don't crash) + // rather than dereference a null skeleton. + if(!_ent || !_ent->hasSkeleton()) + return false; + if(!_ent->getSkeleton()->hasAnimation(_oldName.toStdString().data())) return false; auto *sk = _ent->getSkeleton(); From accf477f1f2329fa911c24358da340914a939315 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 11 Jul 2026 23:13:03 -0400 Subject: [PATCH 18/22] feat(#519): glTF morph weight-animation round-trip + fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - glTF EXPORT: post-inject the standard 'weights' animation channel Assimp's exporter omits (sampler + accessors, data-URI buffer so the GLB BIN chunk stays byte-identical). Portable (Blender/Godot read it). - glTF IMPORT: AnimationProcessor::processMorphWeightAnimations consumes Assimp's mMorphMeshChannels into a mesh-level VAT_POSE weight clip identical to the authoring structure (mapped by pose name, since zero-delta targets are skipped on import). - Importer: only build a skeleton for real bones or SKELETAL animations, not morph-only ones — a morph-only glb was building an empty skeleton and deriveRootBone() asserted (mesh failed to render). - MorphAnimationManager: auto-adopt an existing clip as active when the default ("MorphAnim") isn't on the selection — so an imported clip (named e.g. "Sniff") shows in the dropdown + dope sheet automatically. - add-target crash fix: buildPosesFromSlices _initialise(true) the live entity so pose buffers exist before the render loop applies them. - CLI info now also lists mesh-level (morph/vertex) animations. - Clearer Vertex Morph UX: numbered "1 Shapes" / "2 Animation clips" sections + explainer + tooltips. Known gaps (follow-ups): glb round-trips with a Z (winding/handedness) flip on morph meshes; glb texture export fails on unrepackable pixel formats; FBX morph weight animation not yet exported/imported. Co-Authored-By: Claude Opus 4.8 --- qml/PropertiesPanel.qml | 39 ++- src/Assimp/AnimationProcessor.cpp | 159 +++++++++ src/Assimp/AnimationProcessor.h | 11 + src/Assimp/Importer.cpp | 24 +- src/CLIPipeline.cpp | 13 + src/MeshImporterExporter.cpp | 562 ++++++++++++++++++++++-------- src/MorphAnimationManager.cpp | 21 ++ src/commands/MorphCommands.cpp | 12 +- 8 files changed, 693 insertions(+), 148 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 6dac6b78..232ddfd4 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -8351,6 +8351,20 @@ Rectangle { } } + // One-line explainer so the two concepts don't get conflated: + // SHAPES are the sculpted deformations; CLIPS animate their + // weights over time. Authored top-to-bottom (shapes first). + Text { + width: parent.width + wrapMode: Text.Wrap + color: PropertiesPanelController.textColor + opacity: 0.6 + font.pixelSize: 9 + font.italic: true + text: "Shapes = sculpted poses. Clips = animate shape weights over time." + } + + // ── Section 1: SHAPES (morph targets) ─────────────────────── // RowLayout (not a plain Row with a magic-number spacer): // the old `Item { width: parent.width - 320 }` went negative // on a narrow Inspector, overlapping the title with the @@ -8362,7 +8376,7 @@ Rectangle { Text { Layout.fillWidth: true elide: Text.ElideRight - text: "Morph Targets (" + morphCol.targetCount + ")" + text: "1 · Shapes (" + morphCol.targetCount + ")" color: PropertiesPanelController.textColor font.pixelSize: 11 font.bold: true @@ -8438,8 +8452,10 @@ Rectangle { addError.text = "" addNamePopup.open() } - ToolTip.visible: containsMouse && !enabled - ToolTip.text: "Click “Sculpt” first, then move vertices to shape the target." + ToolTip.visible: containsMouse + ToolTip.text: enabled + ? "Save the current sculpt as a new shape (morph target)" + : "Click “Sculpt” first, then move vertices to shape the target." } } // Reset all: walks every target and sets weight to 0. @@ -8470,6 +8486,19 @@ Rectangle { } } + // ── Section 2: ANIMATION CLIPS ────────────────────────────── + // Section header, shown only once shapes exist (clips animate + // shapes, so they're meaningless without any). + Text { + width: parent.width + visible: morphCol.targetCount > 0 + topPadding: 6 + text: "2 · Animation clips" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + // Morph-clip selector (smile / angry / surprised …). The targets // below are SHARED across clips; each clip keyframes them to // different values over time. Keying (◈ / dope sheet) writes to @@ -8537,6 +8566,8 @@ Rectangle { cursorShape: Qt.PointingHandCursor onClicked: { newClipField.text = ""; newClipError.text = "" newClipPopup.open() } + ToolTip.visible: containsMouse + ToolTip.text: "New animation clip (e.g. smile) that keyframes the shapes' weights over time" } } // Delete active clip @@ -8663,7 +8694,7 @@ Rectangle { contentItem: Column { spacing: 6 Text { - text: "New morph target name:" + text: "New shape name (a sculpted pose, e.g. Smile):" color: PropertiesPanelController.textColor font.pixelSize: 11 } diff --git a/src/Assimp/AnimationProcessor.cpp b/src/Assimp/AnimationProcessor.cpp index 42dea8a9..c3efd7c4 100644 --- a/src/Assimp/AnimationProcessor.cpp +++ b/src/Assimp/AnimationProcessor.cpp @@ -1,7 +1,166 @@ #include "AnimationProcessor.h" +#include +#include + AnimationProcessor::AnimationProcessor(Ogre::SkeletonPtr skeleton): skeleton(skeleton) {} +namespace { + +// Reconstruct the pose NAME MeshProcessor assigned to morph-target index `am` +// of aiMesh `mesh`. MeshProcessor uses the aiAnimMesh name when present, else +// the "Shape_" fallback — we must reproduce it exactly so name-based +// pose lookup lands on the right Ogre::Pose. Returns empty when out of range. +std::string morphTargetPoseName(const aiMesh* mesh, unsigned int am) +{ + if (!mesh || am >= mesh->mNumAnimMeshes) + return {}; + const aiAnimMesh* anim = mesh->mAnimMeshes[am]; + if (anim && anim->mName.length > 0) + return std::string(anim->mName.C_Str()); + return std::string("Shape_") + std::to_string(am); +} + +// Find the pose index (into mesh->getPoseList()) for the first pose named +// `name`. -1 if none — MeshProcessor SKIPS all-zero-delta targets (lazy +// createPose), so aiAnimMesh index != pose-list index; only a name lookup is +// reliable, and a skipped (zero-delta) target correctly has no pose. +int poseIndexForName(const Ogre::MeshPtr& mesh, const std::string& name) +{ + if (name.empty()) + return -1; + const auto& poses = mesh->getPoseList(); + for (unsigned short i = 0; i < poses.size(); ++i) + if (poses[i] && poses[i]->getName() == name) + return static_cast(i); + return -1; +} + +// Locate the aiMesh a morph channel targets. aiMeshMorphAnim::mName is the NODE +// name; its mValues[] index the aiAnimMeshes of the mesh attached to that node. +// glTF nodes carry a single mesh, which is the case Assimp populates morph +// channels for; when a node has several meshes only meshes exposing anim-meshes +// are candidates and we take the first (multi-morph-mesh nodes don't occur in +// glTF's one-mesh-per-node model). +const aiMesh* meshForMorphChannel(const aiScene* scene, const aiNode* node) +{ + if (!scene || !node) + return nullptr; + for (unsigned int i = 0; i < node->mNumMeshes; ++i) { + const unsigned int meshIdx = node->mMeshes[i]; + if (meshIdx >= scene->mNumMeshes) + continue; + const aiMesh* mesh = scene->mMeshes[meshIdx]; + if (mesh && mesh->mNumAnimMeshes > 0) + return mesh; + } + return nullptr; +} + +} // namespace + +void AnimationProcessor::processMorphWeightAnimations(const Ogre::MeshPtr& mesh, const aiScene* scene) +{ + if (!mesh || !scene) + return; + // No poses on the mesh → nothing a weight clip could reference. + if (mesh->getPoseCount() == 0) + return; + + unsigned int generatedNameCounter = 0; + + for (unsigned int a = 0; a < scene->mNumAnimations; ++a) { + const aiAnimation* anim = scene->mAnimations[a]; + if (!anim || anim->mNumMorphMeshChannels == 0) + continue; + + const double ticksPerSecond = (anim->mTicksPerSecond != 0.0) ? anim->mTicksPerSecond : 24.0; + + // One Ogre::Animation (the weight clip) per aiAnimation, named after it. + std::string clipName = anim->mName.C_Str(); + if (clipName.empty()) + clipName = std::string("MorphAnim_") + std::to_string(generatedNameCounter++); + // Avoid colliding with the per-target SHAPE clips MeshProcessor created + // (each named exactly a pose name) or an already-built clip. + if (mesh->hasAnimation(clipName)) { + std::string base = clipName; + unsigned int suffix = 1; + do { + clipName = base + "_" + std::to_string(suffix++); + } while (mesh->hasAnimation(clipName)); + } + + Ogre::Animation* clip = mesh->createAnimation(clipName, 0.0f); + float maxTime = 0.0f; + bool wroteAnyKey = false; + + for (unsigned int c = 0; c < anim->mNumMorphMeshChannels; ++c) { + const aiMeshMorphAnim* morph = anim->mMorphMeshChannels[c]; + if (!morph || morph->mNumKeys == 0) + continue; + + // Resolve the node → mesh this channel drives so we can map value + // indices to the matching morph-target pose names. + const aiNode* node = scene->mRootNode + ? scene->mRootNode->FindNode(morph->mName) + : nullptr; + const aiMesh* srcMesh = meshForMorphChannel(scene, node); + if (!srcMesh) + continue; + + for (unsigned int k = 0; k < morph->mNumKeys; ++k) { + const aiMeshMorphKey& key = morph->mKeys[k]; + const float t = static_cast(key.mTime / ticksPerSecond); + + for (unsigned int j = 0; j < key.mNumValuesAndWeights; ++j) { + const unsigned int targetIdx = key.mValues[j]; + const double weight = key.mWeights[j]; + + // Map anim-mesh target index -> pose name -> pose index. + // A skipped zero-delta target has no pose: -1, drop it. + const std::string poseName = morphTargetPoseName(srcMesh, targetIdx); + const int pi = poseIndexForName(mesh, poseName); + if (pi < 0) + continue; + + // Track keyed on the pose's target submesh handle — same + // grouping the authoring path uses (targets on one submesh + // share a track; keyframes at time t carry a pose ref each). + const unsigned short handle = mesh->getPoseList()[static_cast(pi)]->getTarget(); + Ogre::VertexAnimationTrack* track = clip->hasVertexTrack(handle) + ? clip->getVertexTrack(handle) + : clip->createVertexTrack(handle, Ogre::VAT_POSE); + if (!track) + continue; + + // Fetch-or-create the keyframe at this time on this track. + Ogre::VertexPoseKeyFrame* kf = nullptr; + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) { + auto* existing = static_cast(track->getKeyFrame(ki)); + if (std::abs(existing->getTime() - t) < 1e-4f) { kf = existing; break; } + } + if (!kf) + kf = track->createVertexPoseKeyFrame(t); + + kf->addPoseReference(static_cast(pi), + static_cast(weight)); + wroteAnyKey = true; + if (t > maxTime) + maxTime = t; + } + } + } + + if (!wroteAnyKey) { + // No usable channel resolved to a pose — drop the empty clip so we + // don't surface a zero-track animation in the list. + mesh->removeAnimation(clipName); + continue; + } + clip->setLength(maxTime); + } +} + void AnimationProcessor::processAnimations(const aiScene* scene) { for(auto i = 0u; i < scene->mNumAnimations; i++) { aiAnimation* animation = scene->mAnimations[i]; diff --git a/src/Assimp/AnimationProcessor.h b/src/Assimp/AnimationProcessor.h index 015b0a19..ff96a619 100644 --- a/src/Assimp/AnimationProcessor.h +++ b/src/Assimp/AnimationProcessor.h @@ -9,6 +9,17 @@ class AnimationProcessor AnimationProcessor(Ogre::SkeletonPtr skeleton); void processAnimations(const aiScene* scene); + // Consume Assimp's morph-weight channels (aiAnimation::mMorphMeshChannels, + // populated by the glTF2 importer) and build mesh-level VAT_POSE weight + // clips on the already-built Ogre mesh — the SAME structure + // MorphAnimationManager authors, so the dope sheet / Animation list / + // playback treat imported and authored morph clips identically. + // + // MUST be called AFTER MeshProcessor::createMesh(): the morph poses it + // references only exist once the mesh + poses have been built. + // No-op when the scene has no morph channels or the mesh has no poses. + static void processMorphWeightAnimations(const Ogre::MeshPtr& mesh, const aiScene* scene); + private: void processAnimation(aiAnimation* animation, const aiScene* scene); void processAnimationChannel(aiNodeAnim* nodeAnim, Ogre::Animation* animation, const aiScene* scene, unsigned int channelIndex, Ogre::Real mTicksPerSecond); diff --git a/src/Assimp/Importer.cpp b/src/Assimp/Importer.cpp index d4ea8079..84768f68 100644 --- a/src/Assimp/Importer.cpp +++ b/src/Assimp/Importer.cpp @@ -182,15 +182,25 @@ Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool conv // Process materials materialProcessor.loadScene(scene); - // Process the skeleton whenever the scene has bones (skinned mesh) or animations. - // A mesh can be skinned without having any animations (e.g. a rigged bind-pose). + // Process the skeleton whenever the scene has bones (skinned mesh) or a + // SKELETAL animation (one with node channels). A mesh can be skinned without + // animations (rigged bind-pose). Crucially, a MORPH-only animation + // (aiAnimation with mMorphMeshChannels but zero mNumChannels) must NOT + // trigger skeleton creation: doing so builds an empty skeleton, and + // setBindingPose() → deriveRootBone() then asserts on the empty bone list, + // producing a mesh that fails to load/render. So require at least one node + // channel, not merely HasAnimations(). bool hasBones = false; for(unsigned i = 0; i < scene->mNumMeshes && !hasBones; ++i) hasBones = scene->mMeshes[i]->mNumBones > 0; + bool hasSkeletalAnim = false; + for(unsigned i = 0; i < scene->mNumAnimations && !hasSkeletalAnim; ++i) + hasSkeletalAnim = scene->mAnimations[i]->mNumChannels > 0; + const bool isZup = (m_sceneUpAxis == 2); - if(hasBones || scene->HasAnimations()) { + if(hasBones || hasSkeletalAnim) { skeleton = Ogre::SkeletonManager::getSingleton().create(modelName+".skeleton", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, true); BoneProcessor boneProcessor; // Create bones at their native FBX-space positions (no Z-up bake yet). @@ -228,5 +238,13 @@ Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool conv meshProcessor.processNode(scene->mRootNode, scene); Ogre::MeshPtr ogreMesh = meshProcessor.createMesh(modelName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, materialProcessor); + // Consume authored morph-WEIGHT animation channels (aiAnimation:: + // mMorphMeshChannels) into mesh-level VAT_POSE weight clips. Must run AFTER + // createMesh — the morph poses these clips reference only exist once the + // mesh + poses have been built by MeshProcessor. No-op when the scene has + // no morph channels or the mesh has no poses. + if (ogreMesh && scene->HasAnimations()) + AnimationProcessor::processMorphWeightAnimations(ogreMesh, scene); + return ogreMesh; } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index a87983f0..85ad3fa0 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -1340,6 +1340,19 @@ MeshInfo CLIPipeline::extractMeshInfo(const Ogre::Entity* entity, const QString& } } + // Mesh-level (VAT_POSE) animations — morph-weight clips + Alembic vertex + // caches. These live on the Ogre::Mesh, not the skeleton, so the skeletal + // loop above never sees them; report them too so round-trip checks (and + // `qtmesh info`) reflect morph/vertex animation. + for (unsigned short a = 0; a < mesh->getNumAnimations(); ++a) { + auto* anim = mesh->getAnimation(a); + if (!anim) continue; + info.animations.append({ + QString::fromStdString(anim->getName()), + anim->getLength() + }); + } + // Bounding box auto bb = mesh->getBounds(); info.bbMin = bb.getMinimum(); diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 10a018f1..29a28613 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -44,6 +44,10 @@ THE SOFTWARE. #include #include #include +#include +#include +#include +#include #include #include #include @@ -92,6 +96,10 @@ THE SOFTWARE. #include #include #include +#include +#include +#include +#include #ifndef WIN32 #include @@ -1068,121 +1076,6 @@ static aiAnimation* buildAiAnimation(Ogre::Animation* ogreAnim, const std::strin return anim; } -// Build a morph-weight aiAnimation from the mesh's "MorphAnim" clip so an -// authored blend-shape weight-over-time animation round-trips to glTF. -// -// Ogre stores morph weight animation on ONE mesh Animation named -// `MorphAnimationManager::kWeightClipName` ("MorphAnim"). That clip has one -// VAT_POSE VertexAnimationTrack per submesh target handle (handle 0 = shared -// vertex data, handle si+1 = submesh si's dedicated vertex data). Each -// VertexPoseKeyFrame on a track carries a list of {poseIndex, influence} -// references — influence is the target's weight at that keyframe time; poses -// not referenced at a time are weight 0. -// -// Assimp models this as an aiAnimation whose mMorphMeshChannels holds one -// aiMeshMorphAnim per animated NODE/mesh. Each aiMeshMorphAnim's mName is the -// NODE the target mesh attaches to, and its mKeys are per-distinct-time -// aiMeshMorphKey entries. A key's parallel mValues/mWeights arrays are indexed -// by anim-mesh index (0..mNumAnimMeshes-1 == aiMesh::mAnimMeshes[i]), so we map -// each pose (by name) to its anim-mesh slot on that mesh and scatter the -// keyframe influences into the matching slot (0 elsewhere). -// -// `new` allocations cross the Assimp C-API ownership boundary — aiAnimation's -// dtor frees mMorphMeshChannels and each channel's mKeys (and the per-key -// mValues/mWeights via aiMeshMorphKey's dtor) — so raw new[] matches every -// other aiScene field in this file (no double-free in the manual -// scene->mAnimations cleanup paths, which just delete each aiAnimation). -// -// Returns nullptr (emitting nothing) when there is no weight clip or no -// submesh carries morph targets, so non-morph meshes are entirely unaffected. -static aiAnimation* buildMorphWeightAiAnimation(const aiScene* scene, - const Ogre::MeshPtr& mesh, - const std::string& meshNodeName, - const std::string& clip) -{ - if (!mesh->hasAnimation(clip)) return nullptr; - Ogre::Animation* weightClip = mesh->getAnimation(clip); - if (!weightClip) return nullptr; - - const unsigned int numSub = mesh->getNumSubMeshes(); - std::vector morphChannels; - - // One channel per submesh that actually got morph targets (mAnimMeshes). - // All submeshes attach to the single node `meshNodeName` in buildAiScene, - // so every channel carries that node name; the anim-mesh index space is - // per-mesh, which is exactly what a per-mesh channel expresses. - for (unsigned int si = 0; si < numSub; ++si) - { - aiMesh* aiM = scene->mMeshes[si]; - if (!aiM || aiM->mNumAnimMeshes == 0) continue; - - // The weight track for this submesh lives under its target handle. - const Ogre::SubMesh* subMesh = mesh->getSubMesh(si); - const unsigned short targetHandle = subMesh->useSharedVertices - ? 0 - : static_cast(si + 1); - if (!weightClip->hasVertexTrack(targetHandle)) continue; - Ogre::VertexAnimationTrack* track = weightClip->getVertexTrack(targetHandle); - if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; - - // Map pose name -> anim-mesh slot on this mesh (mAnimMeshes[i].mName is - // the Ogre pose name; see attachMorphTargetsToAiMesh). - std::map> poseNameToSlot; - for (unsigned int i = 0; i < aiM->mNumAnimMeshes; ++i) - if (aiM->mAnimMeshes[i]) - poseNameToSlot[aiM->mAnimMeshes[i]->mName.C_Str()] = i; - - const auto numKf = track->getNumKeyFrames(); - if (numKf == 0) continue; - - auto* channel = new aiMeshMorphAnim(); // NOSONAR — Assimp owns - channel->mName = aiString(meshNodeName); - channel->mNumKeys = static_cast(numKf); - channel->mKeys = new aiMeshMorphKey[numKf]; // NOSONAR — Assimp owns - - const Ogre::PoseList& poseList = mesh->getPoseList(); - for (unsigned short ki = 0; ki < numKf; ++ki) - { - auto* kf = track->getVertexPoseKeyFrame(ki); - aiMeshMorphKey& key = channel->mKeys[ki]; - key.mTime = kf->getTime(); // seconds; parent mTicksPerSecond = 1.0 - // Parallel arrays over ALL anim-meshes on this mesh: mValues[i] is - // the anim-mesh index, mWeights[i] its weight at mTime (default 0). - key.mNumValuesAndWeights = aiM->mNumAnimMeshes; - key.mValues = new unsigned int[aiM->mNumAnimMeshes]; // NOSONAR — Assimp owns - key.mWeights = new double[aiM->mNumAnimMeshes]; // NOSONAR — Assimp owns - for (unsigned int i = 0; i < aiM->mNumAnimMeshes; ++i) - { - key.mValues[i] = i; - key.mWeights[i] = 0.0; - } - // Scatter each referenced pose's influence into its anim-mesh slot. - for (const auto& ref : kf->getPoseReferences()) - { - if (ref.poseIndex >= poseList.size()) continue; - const Ogre::Pose* p = poseList[ref.poseIndex]; - if (!p) continue; - auto it = poseNameToSlot.find(p->getName()); - if (it != poseNameToSlot.end()) - key.mWeights[it->second] = static_cast(ref.influence); - } - } - morphChannels.push_back(channel); - } - - if (morphChannels.empty()) return nullptr; - - auto* anim = new aiAnimation(); // NOSONAR — Assimp owns - anim->mName = aiString(clip); - anim->mTicksPerSecond = 1.0; // times are seconds, matching buildAiAnimation - anim->mDuration = weightClip->getLength(); - anim->mNumMorphMeshChannels = static_cast(morphChannels.size()); - anim->mMorphMeshChannels = new aiMeshMorphAnim*[anim->mNumMorphMeshChannels]; // NOSONAR — Assimp owns - for (unsigned int ci = 0; ci < anim->mNumMorphMeshChannels; ++ci) - anim->mMorphMeshChannels[ci] = morphChannels[ci]; - return anim; -} - // Build an aiScene directly from an Ogre Entity, bypassing the XML round-trip static aiScene* buildAiScene(const Ogre::Entity* entity) { @@ -1301,31 +1194,16 @@ static aiScene* buildAiScene(const Ogre::Entity* entity) animations.push_back(buildAiAnimation(skeleton->getAnimation(ai))); } - // The node every submesh attaches to (see node wiring above): the dedicated - // "_mesh" node when there's a skeleton, else the root node itself. - const std::string meshNodeName = hasSkeleton - ? std::string(entity->getName()) + "_mesh" - : std::string(entity->getName()); - // Export EVERY morph (weight) clip — smile / angry / surprised — as its own - // glTF morph-weights animation. A morph clip is a mesh Animation carrying a - // VAT_POSE track that is NOT a per-target shape clip (named exactly a pose - // name). Shape clips are the static single-key blend-shape definitions; the - // targets themselves are emitted via aiMesh::mAnimMeshes. - for (unsigned short ai = 0; ai < mesh->getNumAnimations(); ++ai) - { - Ogre::Animation* a = mesh->getAnimation(ai); - if (!a) continue; - const std::string nm = a->getName(); - // Skip per-target shape clips (name == a pose name). - bool isShapeClip = false; - for (const Ogre::Pose* p : mesh->getPoseList()) - if (p && p->getName() == nm) { isShapeClip = true; break; } - if (isShapeClip) continue; - if (aiAnimation* morphAnim = - buildMorphWeightAiAnimation(scene, mesh, meshNodeName, nm)) - animations.push_back(morphAnim); - } - + // NOTE: morph-target SHAPES export fine (via aiMesh::mAnimMeshes above), + // but morph-WEIGHT animation over time is NOT emitted through Assimp: + // Assimp's glTF2/FBX exporters only translate aiAnimation NODE channels + // (translation/rotation/scale) — they ignore aiAnimation::mMorphMeshChannels + // entirely. An aiAnimation carrying only a morph channel also fails Assimp's + // ValidateDataStructure ("mNumChannels is 0"), and a no-op node channel + // added to satisfy it exports as a stray TRS animation on the mesh node that + // corrupts reimport (froze the viewport). So we deliberately do NOT push a + // morph-weight aiAnimation here. Round-tripping weights needs a post-write + // glTF JSON injection (target.path="weights") — tracked as a follow-up. if (!animations.empty()) { scene->mNumAnimations = static_cast(animations.size()); @@ -3057,6 +2935,401 @@ QString MeshImporterExporter::importFileDialogFilter() return importFileDialogFilterFromExtensionList(Manager::getSingleton()->getValidFileExtention()); } +namespace { + +// ─── glTF morph-target WEIGHT animation post-processor ─────────────────────── +// +// Assimp's glTF2 exporter cannot write morph-weight animation channels (its +// ExportAnimations only emits node TRS channels), so after Assimp writes the +// .gltf/.glb we inject the standard glTF `weights` animation channel into the +// JSON ourselves. See the glTF 2.0 spec — "Animations" / morph-target weights. +// +// Data source (Ogre): morph WEIGHT clips are MESH-level Ogre::Animations that +// are NOT named after a pose (a pose-named animation is a per-target SHAPE +// clip). Each weight clip carries one Ogre::VertexAnimationTrack (VAT_POSE) +// per submesh target handle; each Ogre::VertexPoseKeyFrame has a time and a +// list of {poseIndex, influence} references — influence == that target's +// weight at that time. Poses are enumerated in mesh->getPoseList() order, +// which matches the glTF morph target order Assimp exported from +// aiMesh::mAnimMeshes. +// +// Robustness: the whole helper is wrapped in try/catch and never throws out of +// the exporter. On any parse/shape failure it logs a warning and leaves the +// file exactly as Assimp wrote it (shapes still round-trip). + +// A decoded morph weight clip: sorted distinct keyframe times + a flat weight +// matrix (K rows × numTargets cols, row-major). +struct MorphWeightClip { + std::string name; + std::vector times; // K + std::vector weights; // K * numTargets +}; + +// Is `animName` a per-target SHAPE clip (named exactly a pose name)? +bool gltfIsPoseShapeClip(const Ogre::Mesh* mesh, const std::string& animName) +{ + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == animName) return true; + return false; +} + +// Enumerate the entity's mesh morph WEIGHT clips and decode each into a +// dense time/weight matrix of width `numTargets` (pose-list order). +std::vector collectMorphWeightClips(const Ogre::Entity* entity, + int numTargets) +{ + std::vector out; + if (!entity || numTargets <= 0) return out; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return out; + + for (unsigned short ai = 0; ai < mesh->getNumAnimations(); ++ai) { + Ogre::Animation* anim = mesh->getAnimation(ai); + if (!anim) continue; + const std::string nm = anim->getName(); + if (gltfIsPoseShapeClip(mesh.get(), nm)) continue; // shape clip, not a weight clip + + // Gather the sorted set of distinct keyframe times across every + // VAT_POSE track on this clip, and record each pose reference. + std::set timeSet; + for (const auto& [handle, track] : anim->_getVertexTrackList()) { + (void)handle; + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short k = 0; k < track->getNumKeyFrames(); ++k) + timeSet.insert(track->getKeyFrame(k)->getTime()); + } + if (timeSet.empty()) continue; // no keyed tracks → nothing to export + + std::vector times(timeSet.begin(), timeSet.end()); + + MorphWeightClip clip; + clip.name = nm; + clip.times = times; + clip.weights.assign(times.size() * static_cast(numTargets), 0.0f); + + // For each time build the weight vector from the pose references. + for (const auto& [handle, track] : anim->_getVertexTrackList()) { + (void)handle; + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short k = 0; k < track->getNumKeyFrames(); ++k) { + auto* kf = static_cast(track->getKeyFrame(k)); + const float t = kf->getTime(); + // Locate the row index for this time. + auto it = std::lower_bound(times.begin(), times.end(), t); + if (it == times.end()) continue; + const size_t row = static_cast(it - times.begin()); + for (const auto& ref : kf->getPoseReferences()) { + if (ref.poseIndex >= static_cast(numTargets)) + continue; // out of range for this glTF mesh — skip + clip.weights[row * static_cast(numTargets) + ref.poseIndex] + = ref.influence; + } + } + } + out.push_back(std::move(clip)); + } + return out; +} + +// Serialise a float vector little-endian into a byte buffer. +void appendFloatsLE(QByteArray& buf, const std::vector& v) +{ + const size_t base = static_cast(buf.size()); + buf.resize(static_cast(base + v.size() * sizeof(float))); + std::memcpy(buf.data() + base, v.data(), v.size() * sizeof(float)); +} + +// Find the glTF node that owns the morph mesh. Strategy: prefer the node whose +// referenced mesh's first primitive has a `targets` array of length == +// poseCount; fall back to the first node that references a mesh with any +// targets. Sets *outNumTargets. Returns the node index, or -1 on failure. +int findMorphNode(const QJsonObject& root, int poseCount, int* outNumTargets) +{ + if (!root.contains("nodes") || !root.contains("meshes")) return -1; + const QJsonArray nodes = root.value("nodes").toArray(); + const QJsonArray meshes = root.value("meshes").toArray(); + + auto targetsForMesh = [&](int meshIdx) -> int { + if (meshIdx < 0 || meshIdx >= meshes.size()) return 0; + const QJsonObject m = meshes.at(meshIdx).toObject(); + const QJsonArray prims = m.value("primitives").toArray(); + if (prims.isEmpty()) return 0; + const QJsonObject p0 = prims.at(0).toObject(); + if (!p0.contains("targets")) return 0; + return p0.value("targets").toArray().size(); + }; + + int fallbackNode = -1, fallbackTargets = 0; + for (int n = 0; n < nodes.size(); ++n) { + const QJsonObject node = nodes.at(n).toObject(); + if (!node.contains("mesh")) continue; + const int meshIdx = node.value("mesh").toInt(-1); + const int nt = targetsForMesh(meshIdx); + if (nt <= 0) continue; + if (nt == poseCount) { *outNumTargets = nt; return n; } // exact match + if (fallbackNode < 0) { fallbackNode = n; fallbackTargets = nt; } + } + if (fallbackNode >= 0) { *outNumTargets = fallbackTargets; return fallbackNode; } + return -1; +} + +// Inject morph-weight animations into an already-written .gltf/.glb file. +// No-op (leaves the file untouched) if the entity has no morph weight clips, +// the file has no morph targets, or anything fails to parse. +void injectMorphWeightAnimations(const QString& filePath, + const Ogre::Entity* entity, + bool isBinary) +{ + try { + if (!entity) return; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh || mesh->getPoseCount() == 0) return; + + // Quick early-out: does the mesh carry any weight clips at all? + // (Cheaper than a full parse; poseCount already > 0 here.) + bool anyWeightClip = false; + for (unsigned short ai = 0; ai < mesh->getNumAnimations(); ++ai) { + Ogre::Animation* a = mesh->getAnimation(ai); + if (a && !gltfIsPoseShapeClip(mesh.get(), a->getName())) { + anyWeightClip = true; + break; + } + } + if (!anyWeightClip) return; + + // ── Read the file (GLB: split header/JSON/BIN; glTF: whole file) ── + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly)) return; + const QByteArray whole = f.readAll(); + f.close(); + + QByteArray jsonBytes; + QByteArray binChunk; // GLB buffer 0 (copied verbatim) + bool haveBinChunk = false; + + if (isBinary) { + // GLB layout: 12-byte header, then chunks (4-byte length + 4-byte + // type + data). JSON chunk type 0x4E4F534A ("JSON"), BIN chunk + // type 0x004E4942 ("BIN\0"). + if (whole.size() < 12) return; + const auto rdU32 = [&](int off) -> quint32 { + return static_cast(whole[off]) + | (static_cast(whole[off + 1]) << 8) + | (static_cast(whole[off + 2]) << 16) + | (static_cast(whole[off + 3]) << 24); + }; + const quint32 magic = rdU32(0); + if (magic != 0x46546C67u /*'glTF'*/) return; + int off = 12; + while (off + 8 <= whole.size()) { + const quint32 clen = rdU32(off); + const quint32 ctype = rdU32(off + 4); + const int dataOff = off + 8; + if (dataOff + static_cast(clen) > whole.size()) return; + const QByteArray data = whole.mid(dataOff, static_cast(clen)); + if (ctype == 0x4E4F534Au) { // JSON + jsonBytes = data; + } else if (ctype == 0x004E4942u) { // BIN + binChunk = data; + haveBinChunk = true; + } + off = dataOff + static_cast(clen); + } + if (jsonBytes.isEmpty()) return; + } else { + jsonBytes = whole; + } + + QJsonParseError perr; + QJsonDocument doc = QJsonDocument::fromJson(jsonBytes, &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) return; + QJsonObject root = doc.object(); + + // ── Locate the morph node + authoritative target count ── + int numTargets = 0; + const int nodeIdx = findMorphNode(root, static_cast(mesh->getPoseCount()), + &numTargets); + if (nodeIdx < 0 || numTargets <= 0) return; + + // ── Decode the weight clips against numTargets ── + std::vector clips = collectMorphWeightClips(entity, numTargets); + if (clips.empty()) return; + + // ── Build the extra data-URI buffer + accessors/bufferViews ── + // All weight data lives in a NEW buffer encoded as a base64 data URI, + // so we never touch the existing GLB BIN chunk (buffer 0) or the + // existing .gltf buffers — much simpler and safe. + QByteArray extraBuf; + + QJsonArray accessors = root.value("accessors").toArray(); + QJsonArray bufferViews = root.value("bufferViews").toArray(); + QJsonArray buffers = root.value("buffers").toArray(); + QJsonArray animations = root.value("animations").toArray(); + + const int newBufferIndex = buffers.size(); // appended below + + for (const MorphWeightClip& clip : clips) { + const int K = static_cast(clip.times.size()); + if (K == 0) continue; + + // — input (times) accessor — + const int timesByteOffset = extraBuf.size(); + appendFloatsLE(extraBuf, clip.times); + const int timesBv = bufferViews.size(); + { + QJsonObject bv; + bv["buffer"] = newBufferIndex; + bv["byteOffset"] = timesByteOffset; + bv["byteLength"] = static_cast(clip.times.size() * sizeof(float)); + bufferViews.append(bv); + } + float tmin = clip.times.front(), tmax = clip.times.front(); + for (float t : clip.times) { tmin = std::min(tmin, t); tmax = std::max(tmax, t); } + const int inputAccessor = accessors.size(); + { + QJsonObject acc; + acc["bufferView"] = timesBv; + acc["byteOffset"] = 0; + acc["componentType"] = 5126; // FLOAT + acc["count"] = K; + acc["type"] = "SCALAR"; + acc["min"] = QJsonArray{ static_cast(tmin) }; + acc["max"] = QJsonArray{ static_cast(tmax) }; + accessors.append(acc); + } + + // — output (weights) accessor — + const int weightsByteOffset = extraBuf.size(); + appendFloatsLE(extraBuf, clip.weights); + const int weightsBv = bufferViews.size(); + { + QJsonObject bv; + bv["buffer"] = newBufferIndex; + bv["byteOffset"] = weightsByteOffset; + bv["byteLength"] = static_cast(clip.weights.size() * sizeof(float)); + bufferViews.append(bv); + } + const int outputAccessor = accessors.size(); + { + QJsonObject acc; + acc["bufferView"] = weightsBv; + acc["byteOffset"] = 0; + acc["componentType"] = 5126; // FLOAT + acc["count"] = K * numTargets; + acc["type"] = "SCALAR"; + accessors.append(acc); + } + + // — animation entry (sampler index is animation-local) — + QJsonObject sampler; + sampler["input"] = inputAccessor; + sampler["output"] = outputAccessor; + sampler["interpolation"] = "LINEAR"; + + QJsonObject targetObj; + targetObj["node"] = nodeIdx; + targetObj["path"] = "weights"; + + QJsonObject channel; + channel["sampler"] = 0; // first (only) sampler within this animation + channel["target"] = targetObj; + + QJsonObject animObj; + animObj["name"] = QString::fromStdString(clip.name); + animObj["samplers"] = QJsonArray{ sampler }; + animObj["channels"] = QJsonArray{ channel }; + animations.append(animObj); + } + + if (extraBuf.isEmpty()) return; // nothing to inject after all + + // — append the data-URI buffer — + { + QJsonObject buf; + const QByteArray b64 = extraBuf.toBase64(); + buf["uri"] = QStringLiteral("data:application/octet-binary;base64,") + + QString::fromLatin1(b64); + buf["byteLength"] = extraBuf.size(); + buffers.append(buf); + } + + root["accessors"] = accessors; + root["bufferViews"] = bufferViews; + root["buffers"] = buffers; + root["animations"] = animations; + + // ── Re-serialise ── + const QByteArray newJson = + QJsonDocument(root).toJson(QJsonDocument::Compact); + + QFile out(filePath); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) return; + + if (isBinary) { + // Re-emit the GLB container: header + JSON chunk (space-padded to + // 4-byte alignment) + the ORIGINAL unchanged BIN chunk (buffer 0). + // The injected weight data lives in the data-URI buffer inside the + // JSON, so the BIN chunk is copied byte-for-byte. + QByteArray jsonPadded = newJson; + while (jsonPadded.size() % 4 != 0) jsonPadded.append(' '); + + const auto wrU32 = [&](QByteArray& b, quint32 v) { + b.append(static_cast(v & 0xFF)); + b.append(static_cast((v >> 8) & 0xFF)); + b.append(static_cast((v >> 16) & 0xFF)); + b.append(static_cast((v >> 24) & 0xFF)); + }; + + // BIN chunk data padded to 4-byte alignment with zero bytes. + QByteArray binPadded; + if (haveBinChunk) { + binPadded = binChunk; + while (binPadded.size() % 4 != 0) binPadded.append('\0'); + } + + quint32 total = 12; // header + total += 8 + static_cast(jsonPadded.size()); // JSON chunk + if (haveBinChunk) + total += 8 + static_cast(binPadded.size()); // BIN chunk + + QByteArray glb; + wrU32(glb, 0x46546C67u); // magic 'glTF' + wrU32(glb, 2u); // version + wrU32(glb, total); // total length + + wrU32(glb, static_cast(jsonPadded.size())); + wrU32(glb, 0x4E4F534Au); // 'JSON' + glb.append(jsonPadded); + + if (haveBinChunk) { + wrU32(glb, static_cast(binPadded.size())); + wrU32(glb, 0x004E4942u); // 'BIN\0' + glb.append(binPadded); + } + + out.write(glb); + } else { + out.write(newJson); + } + out.close(); + + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("Injected %1 morph-weight animation(s) into %2") + .arg(clips.size()).arg(filePath)); + } catch (const std::exception& ex) { + Ogre::LogManager::getSingleton().logWarning( + std::string("injectMorphWeightAnimations failed (left file as-is): ") + + ex.what()); + } catch (...) { + Ogre::LogManager::getSingleton().logWarning( + "injectMorphWeightAnimations failed with unknown exception " + "(left file as-is)"); + } +} + +} // namespace + QString MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, QWidget* parent) { if(!_sn) @@ -3649,6 +3922,15 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // For FBX we aim for a single-file export (embedded textures), so skip sidecars. if (_format != "FBX Binary (*.fbx)") exportMaterial(e, file); + + // Assimp's glTF2 exporter drops morph-target WEIGHT animation + // channels. Post-process the written file to inject the + // standard glTF `weights` channels from Ogre's mesh weight + // clips. No-op when the mesh has no morph weight animation; + // never throws out of the exporter. + if (formatId == "gltf2" || formatId == "glb2") + injectMorphWeightAnimations(file.filePath(), e, + /*isBinary=*/formatId == "glb2"); } delete scene; diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index eafe3794..0d0c0f25 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -264,6 +264,10 @@ bool MorphAnimationManager::setMorphWeightKeyframe(const QString& name, // Refresh the entity's animation-state mirror so the new clip is playable. entity->refreshAvailableAnimationState(); emit morphTargetsChanged(); + // The clip only becomes a playable AnimationState once it has a track + // (created on the first key here) — signal the clip list so the Animation + // Mode list picks it up now, not just on create/delete/rename. + emit morphClipsChanged(); SentryReporter::addBreadcrumb("scene.anim.morph", QStringLiteral("key weight '%1' @%2 = %3").arg(name).arg(t).arg(w)); return true; @@ -424,6 +428,23 @@ QStringList MorphAnimationManager::morphClips() const if (isPoseShapeClip(mesh.get(), nm)) continue; out << QString::fromStdString(nm); } + + // Auto-adopt an existing clip as active when the current active clip isn't + // on this mesh (e.g. right after IMPORTING a model whose clip is named + // "Sniff" while the app default is "MorphAnim"). Without this the dope sheet + // + keying would target a non-existent clip and show nothing until the user + // manually picked the imported clip from the dropdown. const_cast is safe: + // this only mutates the transient active-clip selector, not scene data. + if (!out.isEmpty() && !out.contains(m_activeMorphClip)) { + auto* self = const_cast(this); + self->m_activeMorphClip = out.first(); + // Defer the signal so we don't emit inside a const getter the QML may be + // mid-binding on; a queued emit refreshes the dropdown + dope sheet. + QMetaObject::invokeMethod(self, [self]() { + emit self->morphClipsChanged(); + emit self->morphTargetsChanged(); + }, Qt::QueuedConnection); + } return out; } diff --git a/src/commands/MorphCommands.cpp b/src/commands/MorphCommands.cpp index 63b240dc..837d5067 100644 --- a/src/commands/MorphCommands.cpp +++ b/src/commands/MorphCommands.cpp @@ -141,8 +141,18 @@ void buildPosesFromSlices(Ogre::Mesh* mesh, kf->addPoseReference(poseIndices[i], 1.0f); } - if (entity) + if (entity) { + // Adding poses / a VAT_POSE animation to an already-loaded mesh means + // the entity's pose (software + hardware) vertex-animation buffers were + // never allocated — the importer sets poses up BEFORE the entity is + // built, but here we add them to a LIVE entity. Re-initialise so Ogre + // rebuilds those buffers; without it the render loop applies a pose + // animation against null buffers and crashes (skinned meshes especially, + // where skeletal + pose animation combine). Mirrors the AutoRig path, + // which likewise re-initialises after mutating a live entity's mesh. + entity->_initialise(true); entity->refreshAvailableAnimationState(); + } } } // namespace From 33ccb238889ed31ef3d2594a3ac958bd273e692d Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 00:08:35 -0400 Subject: [PATCH 19/22] feat(#519): FBX morph weight animation + glTF texture copy + dope-sheet/import fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FBX export: animate BlendShapeChannel DeformPercent over time (AnimationStack/ Layer/CurveNode/Curve per morph clip), so morph weight clips round-trip through FBX like glTF. Assimp's FBX importer reads these into mMorphMeshChannels; the existing import consumer rebuilds the VAT_POSE clip. - Import: fallback node resolution in processMorphWeightAnimations — when a morph channel's node name doesn't resolve (e.g. glTF weights injected after a mesh-split name the node "_submesh0*0"), scan meshes for the one carrying anim-meshes. Fixes glb clips that FindNode couldn't match. - Dope sheet: allMorphRows resolves the clip to read directly (first real morph clip on the mesh) when the active clip isn't present — imported clips' key- frames now display without depending on auto-adopt signal timing. - glTF texture export: copy the ORIGINAL texture file byte-for-byte when findable on disk instead of convertToImage (which threw "pack to PF_UNKNOWN" on unrepackable GPU formats, dropping the texture). Lossless sidecar export. Co-Authored-By: Claude Opus 4.8 --- src/AnimationControlController.cpp | 18 +- src/Assimp/AnimationProcessor.cpp | 12 + src/FBX/FBXExporter.cpp | 399 +++++++++++++++++++++++++++++ src/MeshImporterExporter.cpp | 49 +++- 4 files changed, 466 insertions(+), 12 deletions(-) diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 6d6c46f0..3baeba41 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1001,10 +1001,20 @@ QVariantList AnimationControlController::allMorphRows() const }; // Read the ACTIVE morph clip's keys (the one the user is editing), so - // switching clips in the dropdown shows that clip's diamonds. Fall back - // to the static shape-only clip when the active clip has no key here. - if (!appendTrackTimes( - MorphAnimationManager::instance()->activeMorphClip().toStdString())) + // switching clips in the dropdown shows that clip's diamonds. If the + // active clip isn't on this mesh (e.g. right after IMPORTING a model + // whose clip is "Sniff" while the app default is still "MorphAnim", and + // the auto-adopt signal hasn't landed yet), resolve the first real morph + // clip on the mesh directly — don't depend on active-clip timing. Fall + // back to the static shape-only clip when neither has a key here. + std::string clipToRead = + MorphAnimationManager::instance()->activeMorphClip().toStdString(); + if (!mesh->hasAnimation(clipToRead)) { + const QStringList clips = MorphAnimationManager::instance()->morphClips(); + if (!clips.isEmpty()) + clipToRead = clips.first().toStdString(); + } + if (!appendTrackTimes(clipToRead)) appendTrackTimes(poseName); // static shape-only clip QVariantMap row; diff --git a/src/Assimp/AnimationProcessor.cpp b/src/Assimp/AnimationProcessor.cpp index c3efd7c4..f3f837af 100644 --- a/src/Assimp/AnimationProcessor.cpp +++ b/src/Assimp/AnimationProcessor.cpp @@ -105,6 +105,18 @@ void AnimationProcessor::processMorphWeightAnimations(const Ogre::MeshPtr& mesh, ? scene->mRootNode->FindNode(morph->mName) : nullptr; const aiMesh* srcMesh = meshForMorphChannel(scene, node); + // FALLBACK node resolution: some exporters (our glTF weights inject + // after mesh-split) name the morph channel's node after a SPLIT + // submesh (e.g. "sniff_submesh0*0") that FindNode can't match to the + // scene node holding the mesh. When the direct node lookup fails, + // scan ALL meshes for the first one carrying anim-meshes — a + // single-morph-mesh model (the common case) resolves unambiguously. + if (!srcMesh) { + for (unsigned int mi = 0; mi < scene->mNumMeshes; ++mi) { + const aiMesh* cand = scene->mMeshes[mi]; + if (cand && cand->mNumAnimMeshes > 0) { srcMesh = cand; break; } + } + } if (!srcMesh) continue; diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp index 2b89dfcb..5048bef0 100644 --- a/src/FBX/FBXExporter.cpp +++ b/src/FBX/FBXExporter.cpp @@ -47,6 +47,7 @@ THE SOFTWARE. #include #include #include +#include #include #include #include @@ -881,6 +882,28 @@ class FBXDocumentBuilder } } + // Morph weight-animation A5 — count AnimationStack / Layer / + // CurveNode / Curve for morph WEIGHT clips. These are ADDED to + // (not merged with) the skeletal anim counts above, mirroring + // how writeMorphAnimations() emits a separate stack+layer per + // morph clip. Per morph clip: + // +1 AnimationStack, +1 AnimationLayer + // +N AnimationCurveNode ("Number"/DeformPercent), +N AnimationCurve + // where N = distinct animated channels (poses) in that clip. + // Must EXACTLY match writeMorphAnimations() or the file is + // malformed. Independent of skeleton presence. + { + std::vector morphClips = collectMorphWeightClips(); + for (Ogre::Animation* clip : morphClips) { + int animatedChannels = countMorphAnimatedChannels(clip); + if (animatedChannels == 0) continue; // nothing to write → no stack + animStackCount += 1; + animLayerCount += 1; + animCurveNodeCount += animatedChannels; + animCurveCount += animatedChannels; + } + } + int totalObjects = 1 + modelCount + geomCount + matCount + nodeAttrCount + deformerCount + poseCount + textureCount + videoCount + animStackCount + animLayerCount + @@ -968,6 +991,13 @@ class FBXDocumentBuilder if (m_skeleton->getNumAnimations() > 0) writeAnimations(); } + // Morph weight-animation A5 — DeformPercent animation for the + // BlendShapeChannels written above. MUST run after + // writeBlendShapeDeformers() (needs m_poseChannelIds populated) + // and is independent of the skeleton — a pure-morph mesh with + // no skeleton still animates its weights. + if (!m_skeletonOnly) + writeMorphAnimations(); m_w.endNode(); // Objects } @@ -1600,6 +1630,99 @@ class FBXDocumentBuilder } } + // ── Morph weight clip enumeration (A5) ─────────────────────── + // A morph WEIGHT clip is a MESH-level Ogre::Animation that is NOT + // a per-target shape clip (those are named exactly a pose name). + // Mirrors MorphAnimationManager::morphClips() / isPoseShapeClip(). + static bool isPoseShapeClipName(const Ogre::Mesh* mesh, const std::string& name) + { + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == name) return true; + return false; + } + + std::vector collectMorphWeightClips() const + { + std::vector clips; + if (!m_mesh) return clips; + // No poses → no channels a weight clip could animate. + if (m_mesh->getPoseList().empty()) return clips; + for (unsigned short i = 0; i < m_mesh->getNumAnimations(); ++i) + { + Ogre::Animation* a = m_mesh->getAnimation(i); + if (!a) continue; + if (isPoseShapeClipName(m_mesh, a->getName())) continue; + // Must carry at least one VAT_POSE track with keyframes, + // otherwise there is nothing to animate. + bool hasWeightKeys = false; + for (const auto& [handle, track] : a->_getVertexTrackList()) + { + (void)handle; + if (track && track->getAnimationType() == Ogre::VAT_POSE + && track->getNumKeyFrames() > 0) { hasWeightKeys = true; break; } + } + if (hasWeightKeys) clips.push_back(a); + } + return clips; + } + + // Does pose `p` get an exported BlendShapeChannel? True iff its + // target handle matches a submesh whose geometry is emitted — the + // exact condition writeBlendShapeDeformers() uses to create the + // channel. Count-safe: does NOT depend on m_poseChannelIds (which + // is only populated during writeObjects, after the count pass). + bool poseHasExportedChannel(const Ogre::Pose* p) const + { + if (!p || !m_mesh) return false; + const unsigned short target = p->getTarget(); + for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) + { + const Ogre::SubMesh* subMesh = m_mesh->getSubMesh(si); + const Ogre::VertexData* vData = subMesh->useSharedVertices + ? m_mesh->sharedVertexData : subMesh->vertexData; + if (!vData || vData->vertexCount == 0) continue; // no geometry emitted + const unsigned short handle = subMesh->useSharedVertices + ? 0 : static_cast(si + 1); + if (handle == target) return true; + } + return false; + } + + // Distinct poses referenced by any keyframe of any VAT_POSE track + // in `clip` that also have an exported BlendShapeChannel. Each such + // pose yields exactly one "Number" AnimationCurveNode + one + // AnimationCurve when the clip is written. + std::vector animatedChannelPoses(Ogre::Animation* clip) const + { + std::vector ordered; // preserve deterministic order + std::set seen; + const Ogre::PoseList& poseList = m_mesh->getPoseList(); + for (const auto& [handle, track] : clip->_getVertexTrackList()) + { + (void)handle; + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) + { + auto* kf = static_cast(track->getKeyFrame(ki)); + for (const auto& ref : kf->getPoseReferences()) + { + if (ref.poseIndex >= poseList.size()) continue; + const Ogre::Pose* p = poseList[ref.poseIndex]; + if (!p || seen.count(p)) continue; + if (!poseHasExportedChannel(p)) continue; + seen.insert(p); + ordered.push_back(p); + } + } + } + return ordered; + } + + int countMorphAnimatedChannels(Ogre::Animation* clip) const + { + return static_cast(animatedChannelPoses(clip).size()); + } + // ── Morph A4b: BlendShape deformers (morph targets) ────────── // // FBX represents blend shapes as a chain of nodes hanging off @@ -1655,6 +1778,14 @@ class FBXDocumentBuilder int64_t channelId = nextId(); int64_t shapeId = nextId(); m_channelConns.push_back({channelId, blendShapeId, shapeId, p->getName()}); + // Morph weight-animation A5 — remember which FBX + // BlendShapeChannel corresponds to this Ogre::Pose so + // writeMorphAnimations() can attach a DeformPercent + // AnimationCurveNode to it. Keyed by the pose POINTER + // (pose names aren't guaranteed unique across submeshes, + // but the pointer is; morph clips reference poses by + // index into m_mesh->getPoseList()). + m_poseChannelIds[p] = channelId; // BlendShapeChannel — the user-facing weight target. m_w.beginNode("Deformer"); @@ -1973,6 +2104,224 @@ class FBXDocumentBuilder m_w.endNode(); // AnimationCurveNode } + // ── Morph weight animation (A5) ────────────────────────────── + // + // Animates each BlendShapeChannel's DeformPercent (0..100) over + // time so authored morph weight clips round-trip through FBX. The + // object/connection graph MIRRORS the skeletal path exactly, but + // targets BlendShapeChannels instead of bone Models: + // + // AnimationStack (one per morph clip) + // ↑OO AnimationLayer + // ↑OO AnimationCurveNode ("Number", d|DeformPercent) + // — OP→ BlendShapeChannel (property "DeformPercent") + // ↑OO AnimationCurve (OP→ node, channel "d|DeformPercent") + // + // Assimp's FBXConverter::ProcessMorphAnimDatas reads the + // "d|DeformPercent" curve on any AnimationCurveNode whose Target is + // a BlendShapeChannel and emits aiMeshMorphAnim keys with + // value=channelIndex, weight=value/100 — which our import side + // (AnimationProcessor::processMorphWeightAnimations) rebuilds into + // VAT_POSE weight keyframes. + void writeMorphAnimations() + { + if (!m_mesh) return; + const Ogre::PoseList& poseList = m_mesh->getPoseList(); + + std::vector morphClips = collectMorphWeightClips(); + for (Ogre::Animation* clip : morphClips) + { + // Distinct animated poses (== FBX channels) for this clip. + std::vector channelPoses = animatedChannelPoses(clip); + if (channelPoses.empty()) continue; // matches the count pre-pass skip + + int64_t stackId = nextId(); + int64_t layerId = nextId(); + m_morphAnimStackIds.push_back(stackId); + m_morphAnimLayerToStack.push_back({layerId, stackId}); + + const double duration = clip->getLength(); + const int64_t startTime = 0; + const int64_t stopTime = static_cast(duration * FBX_TICKS_PER_SECOND); + + // AnimationStack + m_w.beginNode("AnimationStack"); + m_w.writePropertyL(stackId); + m_w.writePropertyS(clip->getName() + std::string("\x00\x01", 2) + "AnimStack"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70KTime("LocalStart", startTime); + writeP70KTime("LocalStop", stopTime); + m_w.endNode(); // Properties70 + + m_w.endNode(); // AnimationStack + + // AnimationLayer + m_w.beginNode("AnimationLayer"); + m_w.writePropertyL(layerId); + m_w.writePropertyS(clip->getName() + "_Layer" + std::string("\x00\x01", 2) + "AnimLayer"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70Number("Weight", 100.0); + m_w.endNode(); // Properties70 + + m_w.endNode(); // AnimationLayer + + // One DeformPercent curve node + curve per animated channel. + for (const Ogre::Pose* p : channelPoses) + { + auto chIt = m_poseChannelIds.find(p); + if (chIt == m_poseChannelIds.end()) continue; // no exported channel + const int64_t channelId = chIt->second; + const unsigned short targetHandle = p->getTarget(); + + // Collect this pose's weight over time from whichever + // VAT_POSE track drives its target submesh. A pose is + // referenced at a keyframe's time with an influence in + // 0..1; FBX DeformPercent is 0..100. + std::vector keyTimes; + std::vector keyWeights; // 0..100 + const Ogre::VertexAnimationTrack* track = + clip->hasVertexTrack(targetHandle) + ? clip->getVertexTrack(targetHandle) + : nullptr; + if (track && track->getAnimationType() == Ogre::VAT_POSE) + { + // Find this pose's index in the mesh pose list. + unsigned short poseIdx = 0; + bool haveIdx = false; + for (unsigned short i = 0; i < poseList.size(); ++i) + if (poseList[i] == p) { poseIdx = i; haveIdx = true; break; } + + if (haveIdx) + { + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) + { + auto* kf = static_cast( + track->getKeyFrame(ki)); + float influence = 0.0f; + bool referenced = false; + for (const auto& ref : kf->getPoseReferences()) + { + if (ref.poseIndex == poseIdx) + { + influence = ref.influence; + referenced = true; + break; + } + } + // A keyframe on this track that does not list + // our pose means the pose is at rest (0) there. + (void)referenced; + keyTimes.push_back(static_cast( + kf->getTime() * FBX_TICKS_PER_SECOND)); + keyWeights.push_back(static_cast(influence) * 100.0); + } + } + } + + if (keyTimes.empty()) + { + // Degenerate safety: a channel we counted must emit a + // curve node + curve or the object counts break. Emit + // a single 0-weight key at t=0. + keyTimes.push_back(0); + keyWeights.push_back(0.0); + } + + // AnimationCurveNode — single "Number" channel + // (DeformPercent), default = first weight. + int64_t curveNodeId = nextId(); + writeDeformPercentCurveNode(curveNodeId, + keyWeights.empty() ? 0.0 : keyWeights[0]); + m_morphCurveNodeConns.push_back({curveNodeId, layerId, channelId}); + + // AnimationCurve — the DeformPercent key data. + int64_t curveId = nextId(); + writeAnimationCurveRecord(curveId, keyTimes, keyWeights); + m_morphCurveConns.push_back({curveId, curveNodeId}); + } + } + } + + // A single-scalar "Number" AnimationCurveNode carrying the + // DeformPercent property (mirrors writeAnimCurveNode but with one + // channel named "DeformPercent" instead of d|X/Y/Z, matching the + // property Assimp's prop_whitelist looks for on a channel node). + void writeDeformPercentCurveNode(int64_t id, double defaultValue) + { + m_w.beginNode("AnimationCurveNode"); + m_w.writePropertyL(id); + m_w.writePropertyS(std::string("DeformPercent") + std::string("\x00\x01", 2) + + "AnimCurveNode"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + + m_w.beginNode("P"); + m_w.writePropertyS("d|DeformPercent"); m_w.writePropertyS("Number"); + m_w.writePropertyS(""); m_w.writePropertyS("A"); m_w.writePropertyD(defaultValue); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Properties70 + m_w.endNode(); // AnimationCurveNode + } + + // Emit an AnimationCurve record from raw (time,value) arrays. Same + // record shape the skeletal writeCurve lambda uses, factored out so + // the morph path reuses the exact key-attr wiring. + void writeAnimationCurveRecord(int64_t curveId, + const std::vector& times, + const std::vector& vals) + { + m_w.beginNode("AnimationCurve"); + m_w.writePropertyL(curveId); + m_w.writePropertyS(std::string("\x00\x01", 2) + "AnimCurve"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Default"); m_w.writePropertyD(vals.empty() ? 0.0 : vals[0]); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("KeyVer"); m_w.writePropertyI(4008); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("KeyTime"); + m_w.writePropertyArrayL(times); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyValueFloat"); + std::vector fVals(vals.begin(), vals.end()); + m_w.writePropertyArrayF(fVals); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // AttrFlags — interpolation: cubic (same as skeletal curves). + m_w.beginNode("KeyAttrFlags"); + m_w.writePropertyArrayI({24840}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyAttrDataFloat"); + m_w.writePropertyArrayF({0.0f, 0.0f, 0.0218f, 0.0f}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyAttrRefCount"); + m_w.writePropertyArrayI({static_cast(times.size())}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // AnimationCurve + } + // ── BindPose ───────────────────────────────────────────────── void writeBindPose() { @@ -2279,6 +2628,25 @@ class FBXDocumentBuilder writeConnection("OO", ch.channelId, ch.blendShapeId); writeConnection("OO", ch.shapeGeomId, ch.channelId); } + + // Morph weight-animation A5 — DeformPercent curve graph. + // Independent of the skeleton: a pure-morph mesh animates + // its BlendShapeChannel weights with no bones involved. + // AnimationStack → scene root (id 0) + // AnimationLayer → AnimationStack + // AnimationCurveNode → AnimationLayer (OO) + // AnimationCurveNode → BlendShapeChannel (OP "DeformPercent") + // AnimationCurve → AnimationCurveNode (OP "d|DeformPercent") + for (auto stackId : m_morphAnimStackIds) + writeConnection("OO", stackId, 0); + for (const auto& [layerId, stackId] : m_morphAnimLayerToStack) + writeConnection("OO", layerId, stackId); + for (const auto& acn : m_morphCurveNodeConns) { + writeConnection("OO", acn.curveNodeId, acn.layerId); + writeConnection("OP", acn.curveNodeId, acn.channelId, "DeformPercent"); + } + for (const auto& ac : m_morphCurveConns) + writeConnection("OP", ac.curveId, ac.curveNodeId, "d|DeformPercent"); } if (m_hasSkeleton) @@ -2552,6 +2920,37 @@ class FBXDocumentBuilder std::string channel; }; std::vector m_animCurveConns; + + // ── Morph weight animation (A5) ────────────────────────────── + // Maps each exported Ogre::Pose to the FBX BlendShapeChannel ID + // writeBlendShapeDeformers() assigned it. writeMorphAnimations() + // uses it to wire a "Number" AnimationCurveNode (property + // "DeformPercent") to the channel that pose animates. + std::map m_poseChannelIds; + + // AnimationStack/Layer for each morph WEIGHT clip (separate from + // the skeletal m_animStackIds so the skeletal path is untouched). + std::vector m_morphAnimStackIds; + std::vector> m_morphAnimLayerToStack; // layer→stack + + // DeformPercent AnimationCurveNode → BlendShapeChannel (OP) and + // → AnimationLayer (OO). Distinct from the skeletal T/R/S nodes: + // channelId is a BlendShapeChannel, property is always + // "DeformPercent". + struct MorphCurveNodeConn { + int64_t curveNodeId; + int64_t layerId; + int64_t channelId; // the BlendShapeChannel this drives + }; + std::vector m_morphCurveNodeConns; + + // AnimationCurve → DeformPercent AnimationCurveNode (OP, + // channel "d|DeformPercent"). + struct MorphCurveConn { + int64_t curveId; + int64_t curveNodeId; + }; + std::vector m_morphCurveConns; }; // ═══════════════════════════════════════════════════════════════════ diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 29a28613..431a6ac7 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -224,21 +224,54 @@ bool isTextureSerializable(const Ogre::TexturePtr& tex) // abort the rest of the export). void saveTextureAsImage(const Ogre::TexturePtr& tex, const QFileInfo& file) { + const QString saveName = MeshImporterExporter::exportTextureName( + QString::fromStdString(tex->getName())); + const QString outPath = file.path() + "/" + saveName; + + // PREFER copying the ORIGINAL texture file byte-for-byte when it's findable + // on disk. `convertToImage` re-packs the GPU texture, which THROWS for GPU + // formats Ogre can't pack back to a saveable image (e.g. the "pack to + // PF_UNKNOWN" failure on some compressed/BGR textures) — losing the texture + // entirely. A file copy is lossless and sidesteps the repack. Fall back to + // convertToImage only when the source file can't be located. + const QString texName = QString::fromStdString(tex->getName()); + QString srcPath; + // 1) Ogre resource group's on-disk location for this named texture. + try { + const Ogre::String grp = + Ogre::ResourceGroupManager::getSingleton().findGroupContainingResource( + tex->getName()); + Ogre::FileInfoListPtr fil = + Ogre::ResourceGroupManager::getSingleton().findResourceFileInfo( + grp, tex->getName()); + if (fil && !fil->empty()) + srcPath = QString::fromStdString( + fil->front().archive->getName() + "/" + fil->front().filename); + } catch (const Ogre::Exception&) { /* not in a group — try raw path */ } + // 2) The texture name may itself be an absolute/relative path. + if (srcPath.isEmpty() && QFileInfo::exists(texName)) + srcPath = texName; + + if (!srcPath.isEmpty() && QFileInfo(srcPath).isFile() + && QFileInfo(srcPath).canonicalFilePath() != QFileInfo(outPath).canonicalFilePath()) { + QFile::remove(outPath); // overwrite a stale copy + if (QFile::copy(srcPath, outPath)) + return; // lossless copy succeeded + // else fall through to the repack attempt + } + try { Ogre::Image img; tex->convertToImage(img, true); - const QString saveName = MeshImporterExporter::exportTextureName( - QString::fromStdString(tex->getName())); - const std::string outPath = (file.path() + "/" + saveName).toStdString(); - // `Ogre::Image::save` returns void; failures throw and land - // in the catch arms below. - img.save(outPath); + img.save(outPath.toStdString()); } catch (const Ogre::Exception& ex) { Ogre::LogManager::getSingleton().logError( - std::string("Failed to save texture '") + tex->getName() + "': " + ex.what()); + std::string("Failed to save texture '") + tex->getName() + "': " + ex.what() + + " (and no source file to copy)"); } catch (const std::exception& ex) { Ogre::LogManager::getSingleton().logError( - std::string("Failed to save texture '") + tex->getName() + "': " + ex.what()); + std::string("Failed to save texture '") + tex->getName() + "': " + ex.what() + + " (and no source file to copy)"); } } From aa496817e376ef1923ca257893ca2bb9e40df3e4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 00:14:40 -0400 Subject: [PATCH 20/22] fix(#519): morphWeightKeyframeTimes filters by pose (shared-track correctness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets on the same submesh share one VAT_POSE track, so a keyframe on the track may belong to a different target. morphWeightKeyframeTimes returned ALL track keyframe times, so e.g. "JawOpen" reported the "Smile" keys on the shared track. Now only report times of keyframes that reference THIS pose — matching the dope sheet's per-pose rows. Fixes MultipleNamedMorphClipsShareTargets (unit-tests-linux failure at MorphAnimationManager_test.cpp:327). Co-Authored-By: Claude Opus 4.8 --- src/MorphAnimationManager.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index 0d0c0f25..c1bc3739 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -388,11 +388,23 @@ QVariantList MorphAnimationManager::morphWeightKeyframeTimes(const QString& name Ogre::MeshPtr mesh = ents.first()->getMesh(); if (!mesh) return out; + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return out; Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); if (!track) return out; - for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) - out.append(static_cast(track->getKeyFrame(i)->getTime())); + // Targets on the same submesh SHARE one VAT_POSE track, so a keyframe on the + // track may belong to a DIFFERENT target. Only report times of keyframes + // that actually reference THIS pose — otherwise "JawOpen" would report the + // "Smile" keys on the shared track (matches the dope-sheet's per-pose rows). + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + bool refsThisPose = false; + for (const auto& ref : kf->getPoseReferences()) + if (ref.poseIndex == static_cast(pi)) { refsThisPose = true; break; } + if (refsThisPose) + out.append(static_cast(kf->getTime())); + } return out; } From df253e16d4262bb37c6aedeae0c8b93c7c6a0d6a Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 00:20:57 -0400 Subject: [PATCH 21/22] =?UTF-8?q?fix(#519):=20address=20CodeRabbit=20revie?= =?UTF-8?q?w=20=E2=80=94=20shared-track=20keyframes,=20reorder=20remap,=20?= =?UTF-8?q?rename=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setMorphWeightKeyframe/clearMorphWeightKeyframe: targets on the same submesh share one VAT_POSE track, so keying/clearing one target must not clobber a sibling keyed at the same time. Use updatePoseReference / removePoseReference (only the matching poseIndex); drop the keyframe only when no refs remain. Regression test SharedTrackTwoTargetsSameTimeCoexist. - ReorderMorphTargetsCommand: weight clips reference poses by index, so a reorder left their keyframes pointing at the wrong target. Snapshot weight clips by pose NAME before the pose rebuild and re-key them against the new indices (snapshotWeightClips / rebuildWeightClips). - renameMorphClip: stop playback + disable the old clip's state before recreating/removing the animation (mirror the delete path; avoids stale animation-state refs). - renameAnimation: detect the morph clip on the NAMED entity's mesh (not the first-selected one) so multi-select routes correctly. - morphWeightKeyframeTimes: filter by pose (shared-track correctness) — fixes the MultipleNamedMorphClipsShareTargets unit-test failure. Co-Authored-By: Claude Opus 4.8 --- src/MorphAnimationManager.cpp | 39 +++++++++--- src/MorphAnimationManager_test.cpp | 35 +++++++++++ src/PropertiesPanelController.cpp | 22 ++++--- src/commands/MorphCommands.cpp | 95 ++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 15 deletions(-) diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index c1bc3739..57d3b58b 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -251,10 +251,12 @@ bool MorphAnimationManager::setMorphWeightKeyframe(const QString& name, } if (!kf) kf = track->createVertexPoseKeyFrame(t); - // A VAT_POSE keyframe holds a list of pose references; for a weight track - // each keyframe references exactly this target's pose at influence = weight. - kf->removeAllPoseReferences(); - kf->addPoseReference(static_cast(pi), w); + // A VAT_POSE keyframe holds a LIST of pose references. Targets on the same + // submesh share one track, so a keyframe at time t may already reference + // OTHER targets' poses — clearing them all would clobber a sibling target + // keyed at the same time. Update only THIS pose's reference (or add it), + // leaving the others intact. updatePoseReference is add-or-update in Ogre. + kf->updatePoseReference(static_cast(pi), w); // Extend the clip length to cover the new time. Ogre::Animation* anim = mesh->getAnimation(clip); @@ -285,16 +287,27 @@ bool MorphAnimationManager::clearMorphWeightKeyframe(const QString& name, double Ogre::MeshPtr mesh = ents.first()->getMesh(); if (!mesh) return false; + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return false; Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); if (!track) return false; const float t = static_cast(time); for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { - if (std::abs(track->getKeyFrame(i)->getTime() - t) < 1e-4f) { + auto* kf = static_cast(track->getKeyFrame(i)); + if (std::abs(kf->getTime() - t) >= 1e-4f) continue; + // Remove only THIS pose's reference (siblings on the shared track keyed + // at the same time must survive). Drop the whole keyframe only when no + // pose references remain. + bool hasThis = false; + for (const auto& ref : kf->getPoseReferences()) + if (ref.poseIndex == static_cast(pi)) { hasThis = true; break; } + if (!hasThis) return false; + kf->removePoseReference(static_cast(pi)); + if (kf->getPoseReferences().empty()) track->removeKeyFrame(i); - emit morphTargetsChanged(); - return true; - } + emit morphTargetsChanged(); + return true; } return false; } @@ -544,6 +557,16 @@ bool MorphAnimationManager::renameMorphClip(const QString& oldName, const QStrin if (!mesh || !mesh->hasAnimation(on) || mesh->hasAnimation(nn)) return false; if (isPoseShapeClip(mesh.get(), on)) return false; // that's a target, not a clip + // Stop playback + disable the old clip's state before recreating/removing + // the animation — the render loop reads the clip's tracks every frame, so + // mutating it while its AnimationState is active leaves a stale reference + // (mirrors the delete path). + if (auto* ppc = PropertiesPanelController::instance()) ppc->setPlaying(false); + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(on)) + states->getAnimationState(on)->setEnabled(false); + } + // Rebuild the Animation under the new name copying every VAT_POSE track + // keyframe + pose reference, then drop the old one. (Ogre has no rename.) Ogre::Animation* src = mesh->getAnimation(on); diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp index cd23137f..cefbee9e 100644 --- a/src/MorphAnimationManager_test.cpp +++ b/src/MorphAnimationManager_test.cpp @@ -338,6 +338,41 @@ TEST_F(MorphAnimationManagerSceneTest, MultipleNamedMorphClipsShareTargets) { sel->clear(); } +TEST_F(MorphAnimationManagerSceneTest, SharedTrackTwoTargetsSameTimeCoexist) { + // JawOpen and Smile are on the SAME submesh → they share one VAT_POSE track. + // Keying both at the same time in the same clip must not clobber each other, + // and clearing one must leave the other. (CodeRabbit regression.) + auto mesh = createMorphTestMesh("Morph_SharedTrack"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SharedTrackEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + m->setActiveMorphClip(QStringLiteral("expr")); + ASSERT_TRUE(m->createMorphClip(QStringLiteral("expr"))); + + // Both targets keyed at t=0.5 on the shared track. + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.5, 0.3)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 0.5, 0.8)); + + // Each target reports its own single key at 0.5 (not the sibling's). + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 1); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 1); + EXPECT_NEAR(m->morphWeightAt(QStringLiteral("JawOpen"), 0.5), 0.3, 1e-4); + EXPECT_NEAR(m->morphWeightAt(QStringLiteral("Smile"), 0.5), 0.8, 1e-4); + + // Clearing JawOpen leaves Smile's key on the shared keyframe intact. + EXPECT_TRUE(m->clearMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.5)); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 0); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 1); + EXPECT_NEAR(m->morphWeightAt(QStringLiteral("Smile"), 0.5), 0.8, 1e-4); + sel->clear(); +} + TEST_F(MorphAnimationManagerSceneTest, NoSelectionGivesEmptyList) { auto* m = MorphAnimationManager::instance(); EXPECT_TRUE(m->morphTargetsForSelection().isEmpty()); diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index d9724674..b8ef182f 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -1152,17 +1152,25 @@ bool PropertiesPanelController::renameAnimation(const QString& entityName, const { if (newName.isEmpty() || oldName == newName) return false; - // Morph (weight) clips are mesh-level VAT_POSE animations, not skeletal — - // the skeleton rename path below can't handle them (and would crash on a - // null skeleton). If this is a morph clip, delegate to the manager's - // mesh-aware rename instead. - if (MorphAnimationManager::instance()->morphClips().contains(oldName)) - return MorphAnimationManager::instance()->renameMorphClip(oldName, newName); - auto entities = SelectionSet::getSingleton()->getResolvedEntities(); for (Ogre::Entity* ent : entities) { if (QString::fromStdString(ent->getName()) != entityName) continue; + + // Morph (weight) clips are mesh-level VAT_POSE animations, not skeletal + // — the skeleton rename path below can't handle them (and would crash on + // a null skeleton). Detect it on THIS named entity's mesh (not the + // first-selected one — respects the entityName arg under multi-select) + // and delegate to the manager's mesh-aware rename. + Ogre::MeshPtr mesh = ent->getMesh(); + if (mesh && mesh->hasAnimation(oldName.toStdString())) { + bool isPoseName = false; + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == oldName.toStdString()) { isPoseName = true; break; } + if (!isPoseName) // a weight clip, not a per-target shape clip + return MorphAnimationManager::instance()->renameMorphClip(oldName, newName); + } + if (Manager::getSingleton()->hasAnimationName(ent, newName)) return false; // Disable skeleton debug/weights and stop playback before rename diff --git a/src/commands/MorphCommands.cpp b/src/commands/MorphCommands.cpp index 837d5067..4ab6853c 100644 --- a/src/commands/MorphCommands.cpp +++ b/src/commands/MorphCommands.cpp @@ -155,6 +155,91 @@ void buildPosesFromSlices(Ogre::Mesh* mesh, } } +// ─── Weight-clip preserve/restore across a pose reorder ────────────── +// Morph weight clips (mesh Animations that aren't per-target shape clips) +// reference poses by INDEX. A reorder rebuilds poses in a new order, so those +// indices become stale. Snapshot each weight clip's keyframes by pose NAME +// before the reorder, then rebuild them against the new pose indices after. + +// name != a pose name → it's a weight clip, not a shape clip. +bool isWeightClip(Ogre::Mesh* mesh, const std::string& animName) +{ + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == animName) return false; + return true; +} + +struct WeightClipSnapshot { + std::string name; + float length = 0.0f; + // time -> (poseName -> weight) + std::vector>> keys; +}; + +std::vector snapshotWeightClips(Ogre::Mesh* mesh) +{ + std::vector out; + for (unsigned short a = 0; a < mesh->getNumAnimations(); ++a) { + Ogre::Animation* anim = mesh->getAnimation(a); + if (!anim || !isWeightClip(mesh, anim->getName())) continue; + WeightClipSnapshot snap; + snap.name = anim->getName(); + snap.length = anim->getLength(); + // Merge all VAT_POSE tracks' keyframes keyed by time; resolve each pose + // reference's index -> name against the CURRENT pose list. + std::map> byTime; + for (const auto& [handle, track] : anim->_getVertexTrackList()) { + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short k = 0; k < track->getNumKeyFrames(); ++k) { + auto* kf = static_cast(track->getKeyFrame(k)); + for (const auto& ref : kf->getPoseReferences()) { + if (ref.poseIndex >= mesh->getPoseList().size()) continue; + const Ogre::Pose* p = mesh->getPoseList()[ref.poseIndex]; + if (p) byTime[kf->getTime()][p->getName()] = ref.influence; + } + } + } + for (auto& [t, m] : byTime) snap.keys.push_back({t, std::move(m)}); + out.push_back(std::move(snap)); + } + return out; +} + +// Rebuild the snapshotted weight clips against the (reordered) pose list, +// resolving pose names to their NEW indices + submesh handles. +void rebuildWeightClips(Ogre::Mesh* mesh, const std::vector& snaps, + Ogre::Entity* entity) +{ + auto poseIndex = [&](const std::string& name) -> int { + const auto& pl = mesh->getPoseList(); + for (unsigned short i = 0; i < pl.size(); ++i) + if (pl[i] && pl[i]->getName() == name) return i; + return -1; + }; + for (const auto& snap : snaps) { + if (mesh->hasAnimation(snap.name)) mesh->removeAnimation(snap.name); + Ogre::Animation* anim = mesh->createAnimation(snap.name, snap.length); + for (const auto& [t, weights] : snap.keys) { + for (const auto& [poseName, w] : weights) { + const int pi = poseIndex(poseName); + if (pi < 0) continue; + const unsigned short handle = mesh->getPoseList()[pi]->getTarget(); + Ogre::VertexAnimationTrack* track = anim->hasVertexTrack(handle) + ? anim->getVertexTrack(handle) + : anim->createVertexTrack(handle, Ogre::VAT_POSE); + Ogre::VertexPoseKeyFrame* kf = nullptr; + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) { + auto* e = static_cast(track->getKeyFrame(ki)); + if (std::abs(e->getTime() - t) < 1e-4f) { kf = e; break; } + } + if (!kf) kf = track->createVertexPoseKeyFrame(t); + kf->updatePoseReference(static_cast(pi), w); + } + } + } + if (entity) entity->refreshAvailableAnimationState(); +} + } // namespace // ──────────────── AddMorphTargetCommand ───────────────────────────── @@ -295,6 +380,13 @@ void ReorderMorphTargetsCommand::applyOrder(const QStringList& order) // desired name-order. removePosesByName drops each target's poses + // Animation; buildPosesFromSlices recreates them (and their keyframe // references) fresh, in call order → the new display order. + // + // Weight clips (smile/angry/…) reference poses by index too but AREN'T + // rebuilt by the shape teardown below (their names differ from pose names), + // so their keyframes would point at the wrong target after the reorder. + // Snapshot them by pose NAME first, then rebuild against the new indices. + const std::vector weightClips = snapshotWeightClips(mesh.get()); + for (const QString& n : order) removePosesByName(mesh.get(), n, mEntity); for (const QString& n : order) { @@ -302,6 +394,9 @@ void ReorderMorphTargetsCommand::applyOrder(const QStringList& order) if (it != mSnapshot.end()) buildPosesFromSlices(mesh.get(), n, it->second, mEntity); } + + // Re-key the weight clips to the reordered poses (by name → new index). + rebuildWeightClips(mesh.get(), weightClips, mEntity); } void ReorderMorphTargetsCommand::redo() From aa139fdb99e7de862a0a89490e416f6e9239203e Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 01:55:12 -0400 Subject: [PATCH 22/22] fix(#519): pin CppOwnership on Morph/VertexAnimationManager QML singletons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainWindowTest crashed with SIGSEGV when the dope sheet dock was shown. Root cause: qmlInstance() returned the process-wide singleton to QML WITHOUT QQmlEngine::setObjectOwnership(CppOwnership). Every other QML singleton in the codebase pins CppOwnership; these two (added on this branch) didn't. Each QQuickWidget dock (Inspector, dope sheet, curve editor) has its own QQmlEngine. Once the dope sheet started importing PropertiesPanel + binding to MorphAnimationManager (this branch), a SECOND engine wrapped the same shared s_instance. Without CppOwnership an engine treats it as JS-owned and its GC can delete the shared object out from under the other engine + the C++ callers (AnimationControlController::allMorphRows, PropertiesPanelController) → dangling pointer → crash. Engine-lifecycle-driven, which is why it reproduced with an empty scene (every C++ getter cleanly early-returns on no selection). Master was clean because the dope sheet didn't reference the singleton there — only one engine wrapped it, keeping it reachable. Fix both managers (Vertex had the identical latent defect). CppOwnership only tells QML not to delete it; the C++ kill()/lifecycle is unchanged — the exact idiom the rest of the app uses. Co-Authored-By: Claude Opus 4.8 --- src/MorphAnimationManager.cpp | 14 +++++++++++++- src/VertexAnimationManager.cpp | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index 57d3b58b..555941f4 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -60,7 +60,19 @@ MorphAnimationManager* MorphAnimationManager::instance() MorphAnimationManager* MorphAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) { assertMainThread(); - return instance(); + auto* inst = instance(); + // This is a process-wide singleton shared across EVERY QQuickWidget's + // QQmlEngine (Inspector, dope sheet, curve editor — each has its own + // engine). Without CppOwnership, an engine treats the returned object as + // JavaScript-owned and its GC may delete the shared s_instance out from + // under the other engines (and the C++ callers of instance()), leaving a + // dangling pointer → SIGSEGV. Every sibling singleton (PropertiesPanel- + // Controller, AnimationControlController, ThemeManager, …) pins ownership + // here; this one must too. Missing this only became a live crash once the + // dope sheet started importing PropertiesPanel + binding to this singleton + // (a SECOND engine wrapping the same object). + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; } void MorphAnimationManager::kill() diff --git a/src/VertexAnimationManager.cpp b/src/VertexAnimationManager.cpp index 74c94df6..79d6a3ae 100644 --- a/src/VertexAnimationManager.cpp +++ b/src/VertexAnimationManager.cpp @@ -83,7 +83,13 @@ VertexAnimationManager* VertexAnimationManager::instance() VertexAnimationManager* VertexAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) { assertMainThread(); - return instance(); + auto* inst = instance(); + // Process-wide singleton shared across every QQuickWidget's QQmlEngine. + // Pin CppOwnership so no engine's GC can delete the shared instance and + // leave a dangling pointer for the others — matching every sibling + // singleton's qmlInstance (see MorphAnimationManager for the full note). + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; } void VertexAnimationManager::kill()