From cf9ffb04db1d1a35b83030e73ee9bf108cb57397 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 4 Jul 2026 00:06:00 -0400 Subject: [PATCH 01/16] =?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/16] 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/16] =?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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] =?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/16] 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/16] 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/16] 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/16] 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/16] 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.