From 0f75869d9e6b9cecbd298a6927b5105edc753cca Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 05:09:29 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(morph):=20sub-slice=20A1=20=E2=80=94?= =?UTF-8?q?=20import=20detection=20+=20manager=20+=20CLI=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First sub-slice of #518 / #517 Slice A. The repo had no morph- target handling at all (no `Ogre::Pose` / `MorphAnimationTrack` usage anywhere in `src/`); this lays the foundation the rest of the slice — Inspector sliders, dope-sheet wiring, authoring, export — will build on top of. ### What ships **Importer (MeshProcessor):** - `aiMesh::mAnimMeshes` (Assimp's blend-shape array) is now collected per submesh into a new `MorphTargetData` struct, with the same Z-up axis bake the base vertex pass uses. - At mesh-build time, each target becomes an `Ogre::Pose` storing per-vertex deltas relative to the base mesh, attached to the owning submesh. Zero-delta vertices are skipped to keep the pose sparse. - One `Ogre::Animation` per pose (single VAT_POSE track, one keyframe at t=0 referencing that pose at influence 1.0). Animation name = pose name = the upstream morph-target name. This gives every pose its own `Ogre::AnimationState` weight, which the manager drives. **`MorphAnimationManager` (QML_SINGLETON):** - `morphTargetsFor(entity)` — enumerate names in mesh-pose-list order. - `weight(entity, name)` / `setWeight(entity, name, w)` — read/write a single morph weight via the matching AnimationState (clamps to [0..1], enables the state, pins time to 0). Emits `morphWeightChanged` and `morphTargetsChanged`. - Selection-driven QML helpers (`morphTargetsForSelection`, `weightForSelection`, `setWeightForSelection`) for the future Inspector subgroup. - Sentry breadcrumbs `scene.anim.morph`. **CLI subcommand:** - `qtmesh morph --list [--json]` — load the file headlessly, print the morph-target names (or zero count when there are none). Other modes (`--set`, `--add`, `--delete`) land in follow-up sub-slices once the authoring code is in place. ### Tests (10) Standalone (no Ogre): singleton, null-entity handling, empty-name rejection. Scene-fixture: builds a 3-vertex mesh with two named poses ("JawOpen", "Smile") + matching VAT_POSE animations — same shape MeshProcessor produces. Verifies `morphTargetsFor` ordering, default-zero weights, `setWeight` clamping + value retrieval, unknown-name rejection, `morphWeightChanged` signal, selection-driven QML helpers. ### What's NOT in this PR (deferred to A2–A6) - Inspector "Morph Targets" subgroup with sliders - Authoring (save edit-mode delta as new target, rename, delete) - Export round-trip (FBX/glTF preserves morph targets) - Dope sheet + curve editor recognise morph-weight tracks - MCP tools Each becomes its own focused PR built on this foundation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Assimp/MeshProcessor.cpp | 71 +++++++++ src/Assimp/MeshProcessor.h | 11 ++ src/CLIPipeline.cpp | 79 ++++++++++ src/CLIPipeline.h | 5 + src/CMakeLists.txt | 1 + src/MorphAnimationManager.cpp | 132 +++++++++++++++++ src/MorphAnimationManager.h | 88 +++++++++++ src/MorphAnimationManager_test.cpp | 227 +++++++++++++++++++++++++++++ src/main.cpp | 2 +- src/mainwindow.cpp | 5 + tests/CMakeLists.txt | 1 + 11 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 src/MorphAnimationManager.cpp create mode 100644 src/MorphAnimationManager.h create mode 100644 src/MorphAnimationManager_test.cpp diff --git a/src/Assimp/MeshProcessor.cpp b/src/Assimp/MeshProcessor.cpp index 3250f43b..092d54e9 100644 --- a/src/Assimp/MeshProcessor.cpp +++ b/src/Assimp/MeshProcessor.cpp @@ -129,6 +129,26 @@ SubMeshData* MeshProcessor::processMesh(aiMesh* mesh, const aiScene* scene) { } } + // Process morph targets / blend shapes. Assimp parks each shape's + // deformed positions in `aiAnimMesh::mVertices`; we store them + // verbatim and convert to per-vertex deltas later, at mesh-build + // time, where we already have the base positions in hand for the + // Ogre::Pose constructor. Apply the same Z-up axis bake the base + // vertex pass uses so the shape and base agree on coordinate frame. + for(auto am = 0u; am < mesh->mNumAnimMeshes; am++) { + const aiAnimMesh* anim = mesh->mAnimMeshes[am]; + if (!anim || !anim->mVertices || anim->mNumVertices != mesh->mNumVertices) continue; + MorphTargetData target; + target.name = anim->mName.length > 0 ? anim->mName.C_Str() + : (std::string("Shape_") + std::to_string(am)); + target.positions.reserve(anim->mNumVertices); + for (auto i = 0u; i < anim->mNumVertices; i++) { + Ogre::Vector3 v(anim->mVertices[i].x, anim->mVertices[i].y, anim->mVertices[i].z); + target.positions.push_back(m_isZup ? R_x90 * v : v); + } + subMeshData->morphTargets.push_back(std::move(target)); + } + return subMeshData; } @@ -241,6 +261,57 @@ Ogre::MeshPtr MeshProcessor::createMesh(const Ogre::String& name, const Ogre::St ogreMesh->_setBounds(Ogre::AxisAlignedBox(minCoords, maxCoords)); ogreMesh->_setBoundingSphereRadius((maxCoords - minCoords).length() / 2.0f); + // Morph targets / blend shapes → Ogre::Pose entries on the mesh. + // Build per-submesh delta poses: Ogre `Pose` stores per-vertex + // offsets relative to the base mesh. Each pose targets a specific + // submesh (index 1-based in Ogre; 0 means "shared vertex data", + // which our importer never uses — every aiMesh gets its own + // SubMesh::vertexData). + // + // For each pose we also create a single VAT_POSE animation + // ("MorphTrack_") with a one-keyframe track at full + // influence; the MorphAnimationManager will use the matching + // AnimationState weight to drive live preview without us having + // to mutate vertex buffers ourselves. + for (size_t s = 0; s < subMeshesData.size(); ++s) { + const auto* smd = subMeshesData[s]; + if (smd->morphTargets.empty()) continue; + // Submesh handle is 1-based: 0 = shared, 1..N = per-submesh. + const unsigned short targetSubmesh = static_cast(s + 1); + for (const auto& mt : smd->morphTargets) { + if (mt.positions.size() != smd->vertices.size()) continue; + // Skip a target that's identical to the base (rare but + // happens with badly-authored FBX) — Ogre's pose blender + // would do nothing useful and the per-vertex delta vector + // would be all-zero. + Ogre::Pose* pose = ogreMesh->createPose(targetSubmesh, + mt.name); + for (size_t vi = 0; vi < mt.positions.size(); ++vi) { + const Ogre::Vector3 delta = mt.positions[vi] - smd->vertices[vi]; + if (delta.squaredLength() <= 1e-12f) continue; + pose->addVertex(vi, delta); + } + } + } + + // One Ogre::Animation per pose so AnimationState weights drive + // each morph target independently. Animation name is the + // canonical morph-target name (MorphAnimationManager keys on it). + const auto& poseList = ogreMesh->getPoseList(); + for (unsigned short pi = 0; pi < poseList.size(); ++pi) { + const Ogre::Pose* pose = poseList[pi]; + if (!pose) continue; + const Ogre::String animName = pose->getName(); + if (animName.empty()) continue; + if (ogreMesh->hasAnimation(animName)) continue; // duplicate pose name; skip + Ogre::Animation* anim = ogreMesh->createAnimation(animName, /*length=*/0.0f); + Ogre::VertexAnimationTrack* track = anim->createVertexTrack( + pose->getTarget(), Ogre::VAT_POSE); + auto* kf = track->createVertexPoseKeyFrame(0.0f); + // Full influence on this pose; AnimationState weight scales it. + kf->addPoseReference(pi, 1.0f); + } + // Compile the mesh ogreMesh->load(); diff --git a/src/Assimp/MeshProcessor.h b/src/Assimp/MeshProcessor.h index ac5f35d8..ef4c112d 100644 --- a/src/Assimp/MeshProcessor.h +++ b/src/Assimp/MeshProcessor.h @@ -4,6 +4,16 @@ #include #include "MaterialProcessor.h" +/// One blend-shape / morph target on a SubMeshData. Stored as the +/// absolute per-vertex positions of the deformed shape (matches what +/// Assimp gives us via `aiAnimMesh::mVertices`); MeshProcessor +/// converts these to per-vertex deltas relative to `vertices` when +/// creating the Ogre::Pose entries at mesh-build time. +struct MorphTargetData { + std::string name; ///< From `aiAnimMesh::mName`; empty when the source had no name. + std::vector positions; ///< Absolute deformed positions, same vertex order as `SubMeshData::vertices`. +}; + struct SubMeshData { std::vector vertices; std::vector normals; @@ -13,6 +23,7 @@ struct SubMeshData { std::vector colors; std::vector indices; std::vector boneAssignments; + std::vector morphTargets; ///< Empty when source had no blend shapes. unsigned int materialIndex; }; diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 862854ad..ee5edfc1 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -22,6 +22,7 @@ #include "TexturePaintBuffer.h" #include "VertexColorBaker.h" #include "VATBaker.h" +#include "MorphAnimationManager.h" #include "QtMeshCloudClient.h" #include #include @@ -1067,6 +1068,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "optimize") rc = cmdOptimize(argc, argv); else if (cmd == "bake-vertex-colors") rc = cmdBakeVertexColors(argc, argv); else if (cmd == "vat") rc = cmdVat(argc, argv); + else if (cmd == "morph") rc = cmdMorph(argc, argv); if (rc < 0) { err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; @@ -4946,3 +4948,80 @@ int CLIPipeline::cmdVat(int argc, char* argv[]) } return 0; } + +int CLIPipeline::cmdMorph(int argc, char* argv[]) +{ + // Parse: morph --list [--json] + QString filePath; + bool listMode = false; + bool jsonOutput = false; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "morph" || arg == "--cli") continue; + if (arg == "--list") { listMode = true; continue; } + if (arg == "--json") { jsonOutput = true; continue; } + if (!arg.startsWith("-") && filePath.isEmpty()) { + filePath = arg; continue; + } + } + + if (filePath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh morph --list [--json]" << Qt::endl; + return 2; + } + if (!listMode) { + err() << "Error: morph subcommand requires --list (other modes land in follow-up slices)." << Qt::endl; + return 2; + } + + QFileInfo fi(filePath); + if (!fi.exists()) { + err() << "Error: File not found: " << filePath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb("cli.morph", + QString("Morph list .%1").arg(fi.suffix())); + SentryReporter::addBreadcrumb("file.import", + QString("Importing %1 for morph list").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + + auto& entities = Manager::getSingleton()->getEntities(); + Ogre::Entity* entity = nullptr; + for (auto* obj : entities) { + if (obj && obj->getMovableType() == "Entity") { + entity = static_cast(obj); + break; + } + } + if (!entity) { + err() << "Error: Failed to load file: " << filePath << Qt::endl; + return 1; + } + + const QStringList targets = MorphAnimationManager::instance()->morphTargetsFor(entity); + + if (jsonOutput) { + QJsonArray arr; + for (const QString& n : targets) arr.append(n); + QJsonObject root; + root["file"] = filePath; + root["count"] = static_cast(targets.size()); + root["morphTargets"] = arr; + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented))); + } else { + if (targets.isEmpty()) { + cliWrite(QStringLiteral("No morph targets / blend shapes found.\n")); + } else { + cliWrite(QStringLiteral("Morph targets (%1):\n").arg(targets.size())); + for (const QString& n : targets) + cliWrite(QStringLiteral(" %1\n").arg(n)); + } + } + return 0; +} diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index b5319f6c..e3742021 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -125,6 +125,11 @@ class CLIPipeline { /// positions only. static int cmdVat(int argc, char* argv[]); + /// List the morph targets / blend shapes on a mesh file. Slice A1 + /// surfaces a `--list` mode only; subsequent slices add `--set`, + /// `--add`, `--delete` once the in-memory authoring path lands. + static int cmdMorph(int argc, char* argv[]); + /// Map file extension to MeshImporterExporter format string. static QString formatForExtension(const QString& path); }; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f2e7a57c..7647888f 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -87,6 +87,7 @@ TexturePaintController.cpp VertexColorBaker.cpp VATBaker.cpp VATBakerController.cpp +MorphAnimationManager.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp new file mode 100644 index 00000000..21d97543 --- /dev/null +++ b/src/MorphAnimationManager.cpp @@ -0,0 +1,132 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#include "MorphAnimationManager.h" + +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include +#include +#include + +#include + +MorphAnimationManager* MorphAnimationManager::s_instance = nullptr; + +MorphAnimationManager* MorphAnimationManager::instance() +{ + if (!s_instance) s_instance = new MorphAnimationManager(); + return s_instance; +} + +MorphAnimationManager* MorphAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) +{ + return instance(); +} + +void MorphAnimationManager::kill() +{ + if (!s_instance) return; + delete s_instance; + s_instance = nullptr; +} + +MorphAnimationManager::MorphAnimationManager(QObject* parent) : QObject(parent) +{ + if (auto* sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, + this, &MorphAnimationManager::morphTargetsChanged); + } +} + +MorphAnimationManager::~MorphAnimationManager() = default; + +QStringList MorphAnimationManager::morphTargetsFor(Ogre::Entity* entity) const +{ + QStringList out; + if (!entity) return out; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return out; + const auto& poseList = mesh->getPoseList(); + for (const Ogre::Pose* p : poseList) { + if (!p) continue; + const Ogre::String n = p->getName(); + if (!n.empty()) out << QString::fromStdString(n); + } + return out; +} + +float MorphAnimationManager::weight(Ogre::Entity* entity, const QString& name) const +{ + if (!entity || name.isEmpty()) return 0.0f; + auto* states = entity->getAllAnimationStates(); + if (!states) return 0.0f; + const std::string sn = name.toStdString(); + if (!states->hasAnimationState(sn)) return 0.0f; + auto* state = states->getAnimationState(sn); + return state ? state->getWeight() : 0.0f; +} + +bool MorphAnimationManager::setWeight(Ogre::Entity* entity, const QString& name, float w) +{ + if (!entity || name.isEmpty()) return false; + auto* states = entity->getAllAnimationStates(); + if (!states) return false; + const std::string sn = name.toStdString(); + if (!states->hasAnimationState(sn)) return false; + auto* state = states->getAnimationState(sn); + if (!state) return false; + + const float clamped = std::clamp(w, 0.0f, 1.0f); + const float current = state->getWeight(); + const bool wasEnabled = state->getEnabled(); + if (std::abs(clamped - current) < 1e-6f && wasEnabled) return true; + + state->setEnabled(true); + state->setWeight(clamped); + // The pose track has its only keyframe at t=0; pin the state + // there so the weight actually drives the pose. + state->setTimePosition(0.0f); + + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("set '%1' weight = %2").arg(name).arg(clamped, 0, 'f', 3)); + + emit morphWeightChanged(entity, name, static_cast(clamped)); + return true; +} + +QStringList MorphAnimationManager::morphTargetsForSelection() const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return {}; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty()) return {}; + return morphTargetsFor(ents.first()); +} + +double MorphAnimationManager::weightForSelection(const QString& name) const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return 0.0; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty()) return 0.0; + return static_cast(weight(ents.first(), name)); +} + +bool MorphAnimationManager::setWeightForSelection(const QString& name, double w) +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty()) return false; + return setWeight(ents.first(), name, static_cast(w)); +} diff --git a/src/MorphAnimationManager.h b/src/MorphAnimationManager.h new file mode 100644 index 00000000..b271dc4d --- /dev/null +++ b/src/MorphAnimationManager.h @@ -0,0 +1,88 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#ifndef MORPHANIMATIONMANAGER_H +#define MORPHANIMATIONMANAGER_H + +#include +#include +#include +#include +#include + +namespace Ogre { class Entity; } + +/** + * @brief QML_SINGLETON owning per-entity morph-target weight state. + * + * Wraps the Ogre::Pose + per-pose VAT_POSE animation pattern the + * importer (MeshProcessor) sets up. Each named morph target on an + * entity is backed by an `Ogre::Animation` of the same name; weight = + * the matching `Ogre::AnimationState::getWeight()`. + * + * Slice A1 surface — minimal but complete enough that the rest of + * the slice (Inspector sliders, dope sheet, authoring, export) can + * build on top: + * - `morphTargetsFor(entity)` — list of target names. + * - `weight(entity, name)` / `setWeight(entity, name, w)` — + * read / write a single weight. + * - Signals on change so QML can bind. + * + * Authoring (create new targets from edit-mode deltas), export, and + * dope-sheet wiring come in follow-up sub-slices. + */ +class MorphAnimationManager : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static MorphAnimationManager* instance(); + static MorphAnimationManager* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// Enumerate morph-target names on an entity, in mesh-pose-list order. + /// Returns empty when the entity has no morph targets or is null. + QStringList morphTargetsFor(Ogre::Entity* entity) const; + + /// Read the current weight for `name` on `entity`. Returns 0.0 + /// when the entity has no such target or weight tracking isn't + /// available (e.g. the per-pose AnimationState wasn't found). + float weight(Ogre::Entity* entity, const QString& name) const; + + /// Set a single morph-target weight. Enables the matching + /// AnimationState so the weight actually applies, and clamps + /// the input to [0..1]. Emits `morphWeightChanged` on real + /// changes. Returns false when the entity has no such target. + bool setWeight(Ogre::Entity* entity, const QString& name, float w); + + /// QML-friendly variants that resolve the entity from + /// SelectionSet's first entity. Used by the Inspector subgroup. + Q_INVOKABLE QStringList morphTargetsForSelection() const; + Q_INVOKABLE double weightForSelection(const QString& name) const; + Q_INVOKABLE bool setWeightForSelection(const QString& name, double w); + +signals: + /// Emitted when a morph weight on any entity is changed via + /// `setWeight`. QML uses this to re-fetch values. + void morphWeightChanged(Ogre::Entity* entity, const QString& name, double weight); + /// Emitted when the morph-target list visible to the Inspector + /// could have changed (selection moved, scene reloaded, etc.). + void morphTargetsChanged(); + +private: + explicit MorphAnimationManager(QObject* parent = nullptr); + ~MorphAnimationManager() override; + + static MorphAnimationManager* s_instance; +}; + +#endif // MORPHANIMATIONMANAGER_H diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp new file mode 100644 index 00000000..7f175766 --- /dev/null +++ b/src/MorphAnimationManager_test.cpp @@ -0,0 +1,227 @@ +#include + +#include + +#include "Manager.h" +#include "MorphAnimationManager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Build a tiny mesh with two named morph targets attached as Ogre::Pose +// + per-pose VAT_POSE animations, mirroring what `MeshProcessor` does +// at import time. Same shape the manager is contracted against, so +// the test exercises the public API end-to-end without dragging in +// Assimp. +Ogre::MeshPtr createMorphTestMesh(const std::string& name) +{ + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + sub->useSharedVertices = false; + sub->vertexData = new Ogre::VertexData(); + auto* decl = sub->vertexData->vertexDeclaration; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = { + 0,0,0, 0,0,1, + 1,0,0, 0,0,1, + 0,1,0, 0,0,1, + }; + vbuf->writeData(0, sizeof(verts), verts); + sub->vertexData->vertexBufferBinding->setBinding(0, vbuf); + sub->vertexData->vertexCount = 3; + + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t idx[] = {0, 1, 2}; + ibuf->writeData(0, sizeof(idx), idx); + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,2)); + mesh->_setBoundingSphereRadius(2.0f); + mesh->load(); + + // Two named poses on submesh 1. + { + Ogre::Pose* p = mesh->createPose(/*target=*/1, "JawOpen"); + p->addVertex(0, Ogre::Vector3(0, -0.1f, 0)); + } + { + Ogre::Pose* p = mesh->createPose(/*target=*/1, "Smile"); + p->addVertex(1, Ogre::Vector3(0.05f, 0.02f, 0)); + p->addVertex(2, Ogre::Vector3(-0.05f, 0.02f, 0)); + } + + // One Animation per pose, mirroring MeshProcessor's pattern. + const auto& poses = mesh->getPoseList(); + for (unsigned short pi = 0; pi < poses.size(); ++pi) { + Ogre::Animation* a = mesh->createAnimation(poses[pi]->getName(), 0.0f); + auto* track = a->createVertexTrack(poses[pi]->getTarget(), Ogre::VAT_POSE); + auto* kf = track->createVertexPoseKeyFrame(0.0f); + kf->addPoseReference(pi, 1.0f); + } + + return mesh; +} + +} // namespace + +// ============================================================================= +// Standalone (no Ogre) +// ============================================================================= + +TEST(MorphAnimationManagerStandalone, InstanceIsSingleton) { + auto* a = MorphAnimationManager::instance(); + auto* b = MorphAnimationManager::instance(); + EXPECT_EQ(a, b); + EXPECT_NE(a, nullptr); +} + +TEST(MorphAnimationManagerStandalone, NullEntityReturnsEmptyAndZero) { + auto* m = MorphAnimationManager::instance(); + EXPECT_TRUE(m->morphTargetsFor(nullptr).isEmpty()); + EXPECT_FLOAT_EQ(m->weight(nullptr, QStringLiteral("JawOpen")), 0.0f); + EXPECT_FALSE(m->setWeight(nullptr, QStringLiteral("JawOpen"), 0.5f)); +} + +TEST(MorphAnimationManagerStandalone, EmptyNameRejected) { + auto* m = MorphAnimationManager::instance(); + EXPECT_FALSE(m->setWeight(nullptr, QString(), 0.5f)); +} + +// ============================================================================= +// Scene fixture — tests need a real Ogre entity with poses. +// ============================================================================= + +class MorphAnimationManagerSceneTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ASSERT_TRUE(tryInitOgre()); + ASSERT_TRUE(canLoadMeshFiles()); + if (auto* sel = SelectionSet::getSingleton()) sel->clear(); + } + void TearDown() override + { + if (auto* sel = SelectionSet::getSingleton()) sel->clear(); + if (auto* mgr = Manager::getSingletonPtr()) { + if (auto* scene = mgr->getSceneMgr()) { + try { scene->destroyAllEntities(); } catch (...) {} + try { scene->getRootSceneNode()->removeAndDestroyAllChildren(); } catch (...) {} + } + } + } +}; + +TEST_F(MorphAnimationManagerSceneTest, ListsMorphTargetsInPoseOrder) { + auto mesh = createMorphTestMesh("Morph_ListOrder"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_ListOrderEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + QStringList names = m->morphTargetsFor(entity); + ASSERT_EQ(names.size(), 2); + EXPECT_EQ(names[0], QStringLiteral("JawOpen")); + EXPECT_EQ(names[1], QStringLiteral("Smile")); +} + +TEST_F(MorphAnimationManagerSceneTest, WeightDefaultIsZeroBeforeSet) { + auto mesh = createMorphTestMesh("Morph_DefaultZero"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_DefaultZeroEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("JawOpen")), 0.0f); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("Smile")), 0.0f); +} + +TEST_F(MorphAnimationManagerSceneTest, SetWeightStoresClampedValue) { + auto mesh = createMorphTestMesh("Morph_SetClamp"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SetClampEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.75f)); + EXPECT_NEAR(m->weight(entity, QStringLiteral("JawOpen")), 0.75f, 1e-5f); + // Clamp to [0..1]. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("Smile"), 2.0f)); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("Smile")), 1.0f); + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("Smile"), -0.5f)); + EXPECT_FLOAT_EQ(m->weight(entity, QStringLiteral("Smile")), 0.0f); +} + +TEST_F(MorphAnimationManagerSceneTest, SetWeightUnknownNameReturnsFalse) { + auto mesh = createMorphTestMesh("Morph_Unknown"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_UnknownEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_FALSE(m->setWeight(entity, QStringLiteral("NotARealShape"), 0.5f)); +} + +TEST_F(MorphAnimationManagerSceneTest, SetWeightEmitsSignalOnRealChange) { + auto mesh = createMorphTestMesh("Morph_Signal"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SignalEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + QSignalSpy spy(m, &MorphAnimationManager::morphWeightChanged); + m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f); + EXPECT_GE(spy.count(), 1); +} + +TEST_F(MorphAnimationManagerSceneTest, SelectionDrivenAccessorsResolveFirstEntity) { + auto mesh = createMorphTestMesh("Morph_Sel"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SelEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* sel = SelectionSet::getSingleton(); + ASSERT_NE(sel, nullptr); + sel->append(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_EQ(m->morphTargetsForSelection().size(), 2); + EXPECT_TRUE(m->setWeightForSelection(QStringLiteral("Smile"), 0.6)); + EXPECT_NEAR(m->weightForSelection(QStringLiteral("Smile")), 0.6, 1e-4); +} + +TEST_F(MorphAnimationManagerSceneTest, NoSelectionGivesEmptyList) { + auto* m = MorphAnimationManager::instance(); + EXPECT_TRUE(m->morphTargetsForSelection().isEmpty()); + EXPECT_DOUBLE_EQ(m->weightForSelection(QStringLiteral("X")), 0.0); + EXPECT_FALSE(m->setWeightForSelection(QStringLiteral("X"), 0.5)); +} diff --git a/src/main.cpp b/src/main.cpp index 1dc1f2f3..26de0a06 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -92,7 +92,7 @@ int main(int argc, char *argv[]) || arg == "analyze" || arg == "vertex-cache" || arg == "decimate" || arg == "atlas" || arg == "atlas-apply" || arg == "optimize" || arg == "bake-vertex-colors" - || arg == "vat") + || arg == "vat" || arg == "morph") cliMode = true; break; // first non-flag arg determines mode } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index b37c5072..c757a87c 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -75,6 +75,7 @@ #include "TexturePaintController.h" #include "PaintBufferImageProvider.h" #include "VATBakerController.h" +#include "MorphAnimationManager.h" #include "EditorModeController.h" #include #include @@ -547,6 +548,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return VATBakerController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "MorphAnimationManager", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return MorphAnimationManager::qmlInstance(engine, nullptr); + }); // Same image provider the detached editor window uses — serves the // live paint buffer as a QImage view (no PNG encode, no base64). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 633e6e3f..a5547ba0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -96,6 +96,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBakerController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MorphAnimationManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ApplyAtlas.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EmbeddedTextureCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp From 2b38a27c161f85dbdad1c853dc1709a980c25fc1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 05:28:30 -0400 Subject: [PATCH 2/3] review(morph): address Codex findings on slice A1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Codex on PR #569: **P1 (MeshProcessor)** — duplicate pose names across submeshes were dropped. Multi-submesh meshes where, e.g., body + head both carry a "Smile" target would only get the first pose registered; subsequent same-named poses were skipped via `hasAnimation`, leaving the second submesh's pose unreferenced. Since `MorphAnimationManager::setWeight` drives morphs via `AnimationState`-name lookup, the same-named target would deform only one submesh. Group same-named poses into one Animation with one VAT_POSE track per affected submesh. Each track has its own pose reference at full influence. The Animation's single AnimationState weight then drives all submeshes simultaneously, which is exactly what users author when they paint a "Smile" target across multiple meshes in a DCC. Also defends the rare same-submesh duplicate case (same name on the same submesh — content error) by reusing the existing keyframe rather than failing the second `createVertexTrack`. **P2 (CLI)** — `qtmesh morph --list` exited after the first imported entity, so files that import multiple entities with blend shapes on the later entities reported false zeros. Walk every entity and union the target names (de-duped, first- sighting order preserved). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Assimp/MeshProcessor.cpp | 41 +++++++++++++++++++++++++++++------- src/CLIPipeline.cpp | 30 ++++++++++++++++++-------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/Assimp/MeshProcessor.cpp b/src/Assimp/MeshProcessor.cpp index 092d54e9..1cc14e67 100644 --- a/src/Assimp/MeshProcessor.cpp +++ b/src/Assimp/MeshProcessor.cpp @@ -294,20 +294,45 @@ Ogre::MeshPtr MeshProcessor::createMesh(const Ogre::String& name, const Ogre::St } } - // One Ogre::Animation per pose so AnimationState weights drive - // each morph target independently. Animation name is the - // canonical morph-target name (MorphAnimationManager keys on it). + // One Ogre::Animation per *unique* morph-target name. Same-named + // poses across different submeshes (e.g. a "Smile" target on both + // body and head) all need to drive together off the same + // AnimationState weight, so they share one Animation with one + // VAT_POSE track per affected submesh. Without grouping, the + // second-and-later same-named poses would create a new + // Ogre::Animation but Ogre::Mesh enforces unique animation names — + // we'd either skip them (and they'd never move) or fail to import. const auto& poseList = ogreMesh->getPoseList(); for (unsigned short pi = 0; pi < poseList.size(); ++pi) { const Ogre::Pose* pose = poseList[pi]; if (!pose) continue; const Ogre::String animName = pose->getName(); if (animName.empty()) continue; - if (ogreMesh->hasAnimation(animName)) continue; // duplicate pose name; skip - Ogre::Animation* anim = ogreMesh->createAnimation(animName, /*length=*/0.0f); - Ogre::VertexAnimationTrack* track = anim->createVertexTrack( - pose->getTarget(), Ogre::VAT_POSE); - auto* kf = track->createVertexPoseKeyFrame(0.0f); + + // First sighting of this name → create the Animation. Later + // sightings find it via hasAnimation and just append a track. + Ogre::Animation* anim = nullptr; + if (ogreMesh->hasAnimation(animName)) { + anim = ogreMesh->getAnimation(animName); + } else { + anim = ogreMesh->createAnimation(animName, /*length=*/0.0f); + } + if (!anim) continue; + + Ogre::VertexAnimationTrack* track = nullptr; + const unsigned short handle = pose->getTarget(); + if (anim->hasVertexTrack(handle)) { + // Same submesh + same name → rare content error. Append + // the pose reference to the existing keyframe so the user + // still gets some movement (better than silently dropping). + track = anim->getVertexTrack(handle); + } else { + track = anim->createVertexTrack(handle, Ogre::VAT_POSE); + } + if (!track) continue; + auto* kf = track->getNumKeyFrames() > 0 + ? static_cast(track->getKeyFrame(0)) + : track->createVertexPoseKeyFrame(0.0f); // Full influence on this pose; AnimationState weight scales it. kf->addPoseReference(pi, 1.0f); } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index ee5edfc1..699fd50b 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -4991,20 +4991,32 @@ int CLIPipeline::cmdMorph(int argc, char* argv[]) MeshImporterExporter::importer({fi.absoluteFilePath()}); - auto& entities = Manager::getSingleton()->getEntities(); - Ogre::Entity* entity = nullptr; - for (auto* obj : entities) { - if (obj && obj->getMovableType() == "Entity") { - entity = static_cast(obj); - break; - } + // Walk every imported entity, not just the first — multi-entity + // files (e.g. FBX with separate body + head meshes that both + // carry blend shapes) would otherwise lose the targets on the + // entities the loop missed. + auto& movables = Manager::getSingleton()->getEntities(); + QList entities; + for (auto* obj : movables) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); } - if (!entity) { + if (entities.isEmpty()) { err() << "Error: Failed to load file: " << filePath << Qt::endl; return 1; } - const QStringList targets = MorphAnimationManager::instance()->morphTargetsFor(entity); + QStringList targets; + QSet seen; + for (Ogre::Entity* entity : entities) { + const QStringList ents = MorphAnimationManager::instance()->morphTargetsFor(entity); + for (const QString& n : ents) { + if (!seen.contains(n)) { + seen.insert(n); + targets.append(n); + } + } + } if (jsonOutput) { QJsonArray arr; From 660e9fd76c837c1497b6ad639d0c822f638cf914 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 05:50:20 -0400 Subject: [PATCH 3/3] review(morph): address CodeRabbit findings on slice A1 + fix test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from CodeRabbit on PR #569 + one CI failure: **CI** — `WeightDefaultIsZeroBeforeSet` failed: Ogre's `AnimationState` defaults to weight=1.0 (disabled), but the user-facing mental model is "untouched morph = 0". Change `MorphAnimationManager::weight()` to return 0 when the state is disabled — disabled ⇔ "not yet driving the pose". Tests + QML sliders see the expected 0→1 range. **Minor (MeshProcessor)** — `createPose` was called before confirming any non-zero delta, leaving empty poses on the mesh when an FBX exports an identical-to-base target. Lazy-create the pose on first non-zero delta. **Minor (CLI help)** — `printUsage()` listed neither the `vat` (slice 3) nor the new `morph` subcommand. Add both with concise descriptions. **Minor (test)** — `EmptyNameRejected` only tested with a null entity, so a regression where empty-name handling becomes entity-dependent could slip through. Add a scene-fixture variant that exercises the rejection with a valid live entity. **Major (singleton thread safety)** — Per CLAUDE.md "All run on the main thread", `instance()` / `qmlInstance()` / `kill()` should assert main-thread access. Adds an `assertMainThread()` helper using QCoreApplication's thread, called at each lifecycle entry point. **Nit (test)** — `SetWeightEmitsSignalOnRealChange` only proved ≥1 emission. Tighten to assert idempotent writes don't re-emit, and different writes do. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Assimp/MeshProcessor.cpp | 13 +++++++------ src/CLIPipeline.cpp | 8 ++++++++ src/MorphAnimationManager.cpp | 30 ++++++++++++++++++++++++++++- src/MorphAnimationManager_test.cpp | 31 ++++++++++++++++++++++++++++-- 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/Assimp/MeshProcessor.cpp b/src/Assimp/MeshProcessor.cpp index 1cc14e67..1493b6dd 100644 --- a/src/Assimp/MeshProcessor.cpp +++ b/src/Assimp/MeshProcessor.cpp @@ -280,15 +280,16 @@ Ogre::MeshPtr MeshProcessor::createMesh(const Ogre::String& name, const Ogre::St const unsigned short targetSubmesh = static_cast(s + 1); for (const auto& mt : smd->morphTargets) { if (mt.positions.size() != smd->vertices.size()) continue; - // Skip a target that's identical to the base (rare but - // happens with badly-authored FBX) — Ogre's pose blender - // would do nothing useful and the per-vertex delta vector - // would be all-zero. - Ogre::Pose* pose = ogreMesh->createPose(targetSubmesh, - mt.name); + // Lazy `createPose`: scan for the first non-zero delta + // before allocating the Ogre::Pose. Identical-to-base + // targets (rare with hand-authored content, more common + // with auto-exported FBX) would otherwise leave an empty + // pose on the mesh + an empty Animation referencing it. + Ogre::Pose* pose = nullptr; for (size_t vi = 0; vi < mt.positions.size(); ++vi) { const Ogre::Vector3 delta = mt.positions[vi] - smd->vertices[vi]; if (delta.squaredLength() <= 1e-12f) continue; + if (!pose) pose = ogreMesh->createPose(targetSubmesh, mt.name); pose->addVertex(vi, delta); } } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 699fd50b..967b00fe 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -674,6 +674,14 @@ void CLIPipeline::printUsage() " triangle, rasterizes barycentric-interpolated vertex colors,\n" " then dilates outward by N pixels to mask seam bleed at MIP time.\n" " Default resolution=1024, dilation=4. Output PNG is RGBA.\n" + " vat --anim [--fps N] [--encoding rgba8|rgba16]\n" + " [--target agnostic|unity|unreal|godot] [--normals] [-o ] [--json]\n" + " Bake a skeletal animation into a Vertex Animation Texture\n" + " (position PNG + JSON sidecar; optional normal PNG). Engine\n" + " targets: agnostic (default), unity (.meta + UV-V flip),\n" + " unreal (Y/Z axis swap), godot (.gdshader template).\n" + " morph --list [--json] List morph targets / blend shapes on a mesh. (Set/add/delete\n" + " land in follow-up slices once authoring is in place.)\n" "\n" "Global options:\n" " --help, -h Show this help\n" diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index 21d97543..01c4ee0c 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -13,6 +13,9 @@ The MIT License #include "SelectionSet.h" #include "SentryReporter.h" +#include +#include + #include #include #include @@ -20,21 +23,38 @@ The MIT License #include +namespace { + +// Per the project's singleton-on-main-thread convention (CLAUDE.md: +// "All run on the main thread."), assert any cross-thread access at +// the lifecycle entry points so a regression surfaces loudly in +// debug builds. +inline void assertMainThread() +{ + Q_ASSERT(QCoreApplication::instance()); + Q_ASSERT(QThread::currentThread() == QCoreApplication::instance()->thread()); +} + +} // namespace + MorphAnimationManager* MorphAnimationManager::s_instance = nullptr; MorphAnimationManager* MorphAnimationManager::instance() { + assertMainThread(); if (!s_instance) s_instance = new MorphAnimationManager(); return s_instance; } MorphAnimationManager* MorphAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) { + assertMainThread(); return instance(); } void MorphAnimationManager::kill() { + assertMainThread(); if (!s_instance) return; delete s_instance; s_instance = nullptr; @@ -73,7 +93,15 @@ float MorphAnimationManager::weight(Ogre::Entity* entity, const QString& name) c const std::string sn = name.toStdString(); if (!states->hasAnimationState(sn)) return 0.0f; auto* state = states->getAnimationState(sn); - return state ? state->getWeight() : 0.0f; + if (!state) return 0.0f; + // Ogre's AnimationState defaults to weight=1.0 but is disabled + // until first use. From a user-facing perspective, "this morph + // target hasn't been touched yet" should read as 0.0, not 1.0 — + // the slider in the Inspector starts at 0, not at full influence. + // We only surface the live weight once the state is enabled (which + // setWeight() flips), so the contract is: disabled ⇒ 0; enabled ⇒ + // whatever we wrote. + return state->getEnabled() ? state->getWeight() : 0.0f; } bool MorphAnimationManager::setWeight(Ogre::Entity* entity, const QString& name, float w) diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp index 7f175766..06bca351 100644 --- a/src/MorphAnimationManager_test.cpp +++ b/src/MorphAnimationManager_test.cpp @@ -198,8 +198,35 @@ TEST_F(MorphAnimationManagerSceneTest, SetWeightEmitsSignalOnRealChange) { auto* m = MorphAnimationManager::instance(); QSignalSpy spy(m, &MorphAnimationManager::morphWeightChanged); - m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f); - EXPECT_GE(spy.count(), 1); + + // First write emits. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f)); + const int afterFirst = spy.count(); + EXPECT_GE(afterFirst, 1); + + // Idempotent write must NOT emit again — same value, same enabled state. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f)); + EXPECT_EQ(spy.count(), afterFirst); + + // Different value DOES emit. + EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.7f)); + EXPECT_GT(spy.count(), afterFirst); +} + +TEST_F(MorphAnimationManagerSceneTest, EmptyNameRejectedForValidEntity) { + // Empty-name rejection is also exercised standalone with a null + // entity, but a passing test there could hide a regression where + // empty-name handling becomes entity-dependent. Re-test with a + // valid live entity to lock the contract. + auto mesh = createMorphTestMesh("Morph_EmptyName"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_EmptyNameEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + EXPECT_FALSE(m->setWeight(entity, QString(), 0.5f)); + EXPECT_FLOAT_EQ(m->weight(entity, QString()), 0.0f); } TEST_F(MorphAnimationManagerSceneTest, SelectionDrivenAccessorsResolveFirstEntity) {