Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/FBX/FBXExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,34 @@
}
}

// 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<unsigned short>(si + 1);
int posesOnThisSubmesh = 0;
for (const Ogre::Pose* p : poseList) {

Check failure on line 871 in src/FBX/FBXExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ42fmJ9GVej_5GpXv0r&open=AZ42fmJ9GVej_5GpXv0r&pullRequest=575
if (p && p->getTarget() == targetHandle)
++posesOnThisSubmesh;
}
if (posesOnThisSubmesh > 0) {

Check failure on line 875 in src/FBX/FBXExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ42fmJ9GVej_5GpXv0s&open=AZ42fmJ9GVej_5GpXv0s&pullRequest=575
deformerCount += 1; // BlendShape
deformerCount += posesOnThisSubmesh; // BlendShapeChannel × N
geomCount += posesOnThisSubmesh; // Shape × N
}
}
}
}

int totalObjects = 1 + modelCount + geomCount + matCount + nodeAttrCount +
deformerCount + poseCount + textureCount + videoCount +
animStackCount + animLayerCount +
Expand Down Expand Up @@ -926,6 +954,10 @@
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)
{
Expand Down Expand Up @@ -1568,6 +1600,127 @@
}
}

// ── 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<unsigned short>(si + 1);
std::vector<const Ogre::Pose*> 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<double>{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<int32_t> indexes;
std::vector<double> 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<int32_t>(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<double>(d.x));
deltas.push_back(static_cast<double>(d.y));
deltas.push_back(static_cast<double>(-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()
{
Expand Down Expand Up @@ -2113,6 +2266,21 @@
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
Expand Down Expand Up @@ -2345,6 +2513,21 @@
std::vector<int64_t> m_skinIds;
std::vector<int64_t> 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<BlendShapeConn> m_blendShapeConns;
std::vector<ChannelConn> m_channelConns;

struct ClusterConnection {
int64_t clusterId;
int64_t skinId;
Expand Down
57 changes: 57 additions & 0 deletions src/MeshImporterExporter_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2200,6 +2200,63 @@ 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("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"))
<< "FBX should preserve the second morph target name";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

TEST_F(SceneSaveLoadTest, Exporter_PlayStationTmd_WritesFile)
{
ASSERT_TRUE(canLoadMeshFiles());
Expand Down
Loading