From f81697563a7eac5aa98db26437ff3659f41cceb7 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 10:58:18 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(morph):=20sub-slice=20A4b=20=E2=80=94?= =?UTF-8?q?=20FBX=20morph-target=20export=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth and final morph sub-slice of #518's export coverage. A4a landed glTF; this lands FBX so save-then-reload through either format preserves every blend shape. ### What ships in FBXExporter FBX represents blend shapes as a four-record chain hanging off each Geometry node: ``` Geometry (mesh) Deformer "BlendShape" ← one per submesh that has poses Deformer "BlendShapeChannel" "" ← one per pose, user-facing weight target Geometry "" Shape ← sparse vertex deltas (Indexes + Vertices) ``` with connections: `Shape →OO BlendShapeChannel →OO BlendShape →OO Geometry`. New `writeBlendShapeDeformers()`: - Walks every emitted geometry's submesh, filters `mesh->getPoseList()` by the submesh's target handle (handle 0 for shared-vertex submeshes, `si + 1` otherwise — matches the import + glTF export filter). - For each pose, writes: - A `Deformer`/`BlendShapeChannel` with `DeformPercent: 0` (the runtime weight) and `FullWeights: [100.0]` (one entry per Shape — single-influence channels in this minimal version). - A `Geometry`/`Shape` carrying: - `Indexes` — vertex indices that move (sparse, from `Pose::getVertexOffsets()`'s key set). - `Vertices` — flat per-vertex delta XYZ with Z mirrored to match the rest of the binary FBX export's coordinate convention. ### Connections + Definitions - `writeConnections` emits `Shape →OO Channel →OO BlendShape →OO Geometry` on every blend-shape chain. - `writeDefinitions` counts the new objects so the `Definitions/ObjectType` totals line up with reality (Blender's importer warns and may bail when those drift). ### Test `Exporter_FbxBinary_WritesBlendShapeRecords`: build a mesh with two named poses (JawOpen, Smile), export, then byte-level check the resulting file for the `BlendShape`, `BlendShapeChannel`, `JawOpen`, and `Smile` strings. FBX binary stores node tags as length-prefixed ASCII so these signatures appear verbatim. Reimport-side validation (Assimp's FBX reader) is a separate concern with its own coverage path — same scope decision A4a made for the glTF reader. ### What's left in #518 - **A3** — authoring (save edit-mode delta as new target, rename, delete, undo). - **A5** — dope sheet + curve editor recognise morph-weight tracks. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/FBX/FBXExporter.cpp | 177 ++++++++++++++++++++++++++++++ src/MeshImporterExporter_test.cpp | 54 +++++++++ 2 files changed, 231 insertions(+) diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp index ac42438a..01b6afae 100644 --- a/src/FBX/FBXExporter.cpp +++ b/src/FBX/FBXExporter.cpp @@ -853,6 +853,34 @@ class FBXDocumentBuilder } } + // Morph A4b — count BlendShape / BlendShapeChannel / Shape + // objects. Independent of skeleton presence: a mesh can have + // either or both. Per submesh that has any poses: + // +1 BlendShape deformer + // +N BlendShapeChannel deformers (one per pose) + // +N Shape geometries (one per pose) + if (m_mesh) { + const Ogre::PoseList& poseList = m_mesh->getPoseList(); + if (!poseList.empty()) { + for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) { + const auto* subMesh = m_mesh->getSubMesh(si); + const unsigned short targetHandle = subMesh->useSharedVertices + ? 0 + : static_cast(si + 1); + int posesOnThisSubmesh = 0; + for (const Ogre::Pose* p : poseList) { + if (p && p->getTarget() == targetHandle) + ++posesOnThisSubmesh; + } + if (posesOnThisSubmesh > 0) { + deformerCount += 1; // BlendShape + deformerCount += posesOnThisSubmesh; // BlendShapeChannel × N + geomCount += posesOnThisSubmesh; // Shape × N + } + } + } + } + int totalObjects = 1 + modelCount + geomCount + matCount + nodeAttrCount + deformerCount + poseCount + textureCount + videoCount + animStackCount + animLayerCount + @@ -926,6 +954,10 @@ class FBXDocumentBuilder writeMeshModels(); writeMaterialObjects(); writeTextureObjects(); + // Morph A4b — BlendShape deformers run alongside skin + // deformers; a mesh can have either or both. Independent + // of skeleton presence. + writeBlendShapeDeformers(); } if (m_hasSkeleton) { @@ -1568,6 +1600,127 @@ class FBXDocumentBuilder } } + // ── Morph A4b: BlendShape deformers (morph targets) ────────── + // + // FBX represents blend shapes as a chain of nodes hanging off + // each geometry: + // Geometry ←OO— Deformer("BlendShape") + // ←OO— Deformer("BlendShapeChannel", named per pose) + // ←OO— Geometry("Shape", sparse vertex deltas) + // + // The Shape node stores `Indexes` (vertex indices that move) and + // `Vertices` (per-vertex delta XYZ). Same Z-mirroring convention + // as the rest of the binary FBX export. + void writeBlendShapeDeformers() + { + for (size_t gi = 0; gi < m_geomIds.size(); ++gi) { + unsigned int si = m_geomSubmeshIndices[gi]; + const Ogre::SubMesh* subMesh = m_mesh->getSubMesh(si); + + // Collect poses targeting this submesh. Same handle rule + // the glTF exporter uses (slice A4a): + // useSharedVertices → handle 0 + // else → submesh index + 1 + const Ogre::PoseList& poseList = m_mesh->getPoseList(); + const unsigned short targetHandle = subMesh->useSharedVertices + ? 0 + : static_cast(si + 1); + std::vector submeshPoses; + for (const Ogre::Pose* p : poseList) { + if (p && p->getTarget() == targetHandle) + submeshPoses.push_back(p); + } + if (submeshPoses.empty()) continue; + + // 1) Top-level BlendShape deformer for this geometry. + int64_t blendShapeId = nextId(); + m_blendShapeConns.push_back({blendShapeId, m_geomIds[gi]}); + + m_w.beginNode("Deformer"); + m_w.writePropertyL(blendShapeId); + m_w.writePropertyS( + "BlendShape_" + std::to_string(si) + + std::string("\x00\x01", 2) + "BlendShape"); + m_w.writePropertyS("BlendShape"); + m_w.endProperties(); + + m_w.beginNode("Version"); + m_w.writePropertyI(101); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Deformer (BlendShape) + + // 2) Per-pose BlendShapeChannel + Shape pair. + for (const Ogre::Pose* p : submeshPoses) { + int64_t channelId = nextId(); + int64_t shapeId = nextId(); + m_channelConns.push_back({channelId, blendShapeId, shapeId, p->getName()}); + + // BlendShapeChannel — the user-facing weight target. + m_w.beginNode("Deformer"); + m_w.writePropertyL(channelId); + m_w.writePropertyS(p->getName() + std::string("\x00\x01", 2) + + "SubDeformer"); + m_w.writePropertyS("BlendShapeChannel"); + m_w.endProperties(); + + m_w.beginNode("Version"); + m_w.writePropertyI(100); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("DeformPercent"); + m_w.writePropertyD(0.0); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("FullWeights"); + m_w.writePropertyArrayD(std::vector{100.0}); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Deformer (BlendShapeChannel) + + // Shape — sparse vertex-index → delta-XYZ pair. + // FBX stores absolute "shape" geometry as deltas + // relative to the base; Ogre::Pose's offsets are + // already in delta form so the conversion is direct. + std::vector indexes; + std::vector deltas; // x0 y0 z0 x1 y1 z1 ... + indexes.reserve(p->getVertexOffsets().size()); + deltas.reserve(p->getVertexOffsets().size() * 3u); + for (const auto& [vi, d] : p->getVertexOffsets()) { + indexes.push_back(static_cast(vi)); + // FBX uses right-handed Y-up with Z mirrored on + // export (same convention writeMeshModels uses + // for vertex positions). Mirroring Z keeps the + // morph delta consistent with the base mesh. + deltas.push_back(static_cast(d.x)); + deltas.push_back(static_cast(d.y)); + deltas.push_back(static_cast(-d.z)); + } + + m_w.beginNode("Geometry"); + m_w.writePropertyL(shapeId); + m_w.writePropertyS(p->getName() + std::string("\x00\x01", 2) + + "Geometry"); + m_w.writePropertyS("Shape"); + m_w.endProperties(); + + m_w.beginNode("Version"); + m_w.writePropertyI(100); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("Indexes"); + m_w.writePropertyArrayI(indexes); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("Vertices"); + m_w.writePropertyArrayD(deltas); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Geometry (Shape) + } + } + } + // ── Animations ─────────────────────────────────────────────── void writeAnimations() { @@ -2145,6 +2298,15 @@ class FBXDocumentBuilder writeConnection("OO", cc.clusterId, cc.skinId); writeConnection("OO", m_boneModelIds[cc.boneHandle], cc.clusterId); } + + // Morph A4b: BlendShape chain. + // Shape → BlendShapeChannel → BlendShape → Geometry + for (const auto& bs : m_blendShapeConns) + writeConnection("OO", bs.blendShapeId, bs.geomId); + for (const auto& ch : m_channelConns) { + writeConnection("OO", ch.channelId, ch.blendShapeId); + writeConnection("OO", ch.shapeGeomId, ch.channelId); + } } // AnimationStack → scene root @@ -2345,6 +2507,21 @@ class FBXDocumentBuilder std::vector m_skinIds; std::vector m_animStackIds; + // Morph A4b — BlendShape deformer family. Connections: + // Geometry ←OO— BlendShape ←OO— BlendShapeChannel ←OO— Shape + struct BlendShapeConn { + int64_t blendShapeId; // top-level "Deformer"/"BlendShape" per geometry + int64_t geomId; // the owning aiGeometry + }; + struct ChannelConn { + int64_t channelId; // "Deformer"/"BlendShapeChannel" + int64_t blendShapeId; + int64_t shapeGeomId; // the "Geometry"/"Shape" node carrying the deltas + std::string poseName; + }; + std::vector m_blendShapeConns; + std::vector m_channelConns; + struct ClusterConnection { int64_t clusterId; int64_t skinId; diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 6905fc5b..179afcb8 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -2200,6 +2200,60 @@ TEST_F(SceneSaveLoadTest, Exporter_FbxBinary_WritesFile) EXPECT_GT(QFileInfo(fbxPath).size(), 0); } +// Morph A4b — FBX binary writes BlendShape / BlendShapeChannel / +// Shape records for each pose. Asserting on byte signatures in the +// binary file (the FBX node tag strings appear verbatim in the +// payload) is enough to lock the exporter behavior without a full +// reimport round-trip — Assimp's FBX reader is a separate concern. +TEST_F(SceneSaveLoadTest, Exporter_FbxBinary_WritesBlendShapeRecords) +{ + ASSERT_TRUE(canLoadMeshFiles()); + auto* manager = Manager::getSingleton(); + auto mesh = createInMemoryTriangleMesh("fbx_morph_mesh"); + + // Two poses on the shared-vertex submesh (target handle = 0). + { + Ogre::Pose* p = mesh->createPose(0, "JawOpen"); + p->addVertex(0, Ogre::Vector3(0, -0.1f, 0)); + } + { + Ogre::Pose* p = mesh->createPose(0, "Smile"); + p->addVertex(1, Ogre::Vector3(0.05f, 0.02f, 0)); + p->addVertex(2, Ogre::Vector3(-0.05f, 0.02f, 0)); + } + + auto* sn = manager->addSceneNode("FbxMorphNode"); + ASSERT_NE(manager->createEntity(sn, mesh), nullptr); + ASSERT_EQ(mesh->getPoseList().size(), 2u); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + const QString fbxPath = tmpDir.path() + "/morph_export.fbx"; + + ASSERT_EQ(MeshImporterExporter::exporter(sn, fbxPath, + QStringLiteral("FBX Binary (*.fbx)")), + 0); + ASSERT_TRUE(QFileInfo::exists(fbxPath)); + + QFile fbx(fbxPath); + ASSERT_TRUE(fbx.open(QIODevice::ReadOnly)); + const QByteArray body = fbx.readAll(); + fbx.close(); + + // FBX binary stores node tags as length-prefixed ASCII strings + // inside the Objects/Connections records. The strings appear + // verbatim in the file body; the simplest deterministic check + // is byte-level contains. + EXPECT_TRUE(body.contains("BlendShape")) + << "FBX should carry a BlendShape deformer record"; + EXPECT_TRUE(body.contains("BlendShapeChannel")) + << "FBX should carry per-pose BlendShapeChannel records"; + EXPECT_TRUE(body.contains("JawOpen")) + << "FBX should preserve the first morph target name"; + EXPECT_TRUE(body.contains("Smile")) + << "FBX should preserve the second morph target name"; +} + TEST_F(SceneSaveLoadTest, Exporter_PlayStationTmd_WritesFile) { ASSERT_TRUE(canLoadMeshFiles()); From e1f92cf0b6530e576e944f335f7d2e6881a44b6d Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 11:17:52 -0400 Subject: [PATCH 2/2] =?UTF-8?q?review(morph):=20A4b=20=E2=80=94=20emit=20B?= =?UTF-8?q?lendShape=20connections=20for=20non-skinned=20meshes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewers on PR #575 (Codex P1 + CodeRabbit Major), both catching the same regression: `writeBlendShapeDeformers()` ran for every mesh export, but the Shape → Channel → BlendShape → Geometry `OO` connections were emitted only inside `if (m_hasSkeleton)`. A pure-morph mesh (no skin) therefore got the BlendShape/Channel/Shape *records* but no links between them — Blender/Maya/Unreal importers ignore orphaned blend-shape records, so the targets silently didn't round-trip. Move the chain emit out of the skeleton branch into a non- skeleton-gated section right after the texture connections. Gated only on `!m_skeletonOnly` (skeleton-only exports skip geometry, so there are no morph records to link). Plus a minor: test now also asserts on the `Shape` token to catch regressions where channel records exist but the per-pose Shape geometry payload is missing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/FBX/FBXExporter.cpp | 24 +++++++++++++++--------- src/MeshImporterExporter_test.cpp | 3 +++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp index 01b6afae..2b89dfcb 100644 --- a/src/FBX/FBXExporter.cpp +++ b/src/FBX/FBXExporter.cpp @@ -2266,6 +2266,21 @@ class FBXDocumentBuilder writeConnection("OO", vidIt->second, texId); } + // Morph A4b: BlendShape chain. + // Shape → BlendShapeChannel → BlendShape → Geometry + // Independent of skeleton presence — a mesh can be pure-morph + // (no skin) and still need these links, otherwise the + // BlendShape/Channel/Shape records sit orphaned in the file + // and importers ignore them. + if (!m_skeletonOnly) { + for (const auto& bs : m_blendShapeConns) + writeConnection("OO", bs.blendShapeId, bs.geomId); + for (const auto& ch : m_channelConns) { + writeConnection("OO", ch.channelId, ch.blendShapeId); + writeConnection("OO", ch.shapeGeomId, ch.channelId); + } + } + if (m_hasSkeleton) { // Bone NodeAttribute → bone Model @@ -2298,15 +2313,6 @@ class FBXDocumentBuilder writeConnection("OO", cc.clusterId, cc.skinId); writeConnection("OO", m_boneModelIds[cc.boneHandle], cc.clusterId); } - - // Morph A4b: BlendShape chain. - // Shape → BlendShapeChannel → BlendShape → Geometry - for (const auto& bs : m_blendShapeConns) - writeConnection("OO", bs.blendShapeId, bs.geomId); - for (const auto& ch : m_channelConns) { - writeConnection("OO", ch.channelId, ch.blendShapeId); - writeConnection("OO", ch.shapeGeomId, ch.channelId); - } } // AnimationStack → scene root diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 179afcb8..7909694b 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -2248,6 +2248,9 @@ TEST_F(SceneSaveLoadTest, Exporter_FbxBinary_WritesBlendShapeRecords) << "FBX should carry a BlendShape deformer record"; EXPECT_TRUE(body.contains("BlendShapeChannel")) << "FBX should carry per-pose BlendShapeChannel records"; + EXPECT_TRUE(body.contains("Shape")) + << "FBX should carry the per-pose Shape geometry payload " + "(the actual vertex-delta data, not just the channel record)"; EXPECT_TRUE(body.contains("JawOpen")) << "FBX should preserve the first morph target name"; EXPECT_TRUE(body.contains("Smile"))