From eca23375ad961f7801cd13cbc48d3c113e1a6e87 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 3 Mar 2026 13:41:20 -0400 Subject: [PATCH 1/7] Add custom FBX Binary v7300 exporter with skeleton and animation support Replace Assimp's broken FBX exporter with a custom implementation that writes correct FBX binary format directly from Ogre data. This fixes skeleton data corruption and animation playback issues on reimport. Key changes: - New src/FBX/ module: FBXExporter writes FBX v7300 binary with proper geometry, skeleton (LimbNode hierarchy), skin deformers, animations, and materials - Fix Euler angle decomposition to match Assimp's R=Rz*Ry*Rx convention - Add Euler angle unrolling to prevent keyframe discontinuities - Call skeleton->setBindingPose() after Assimp import so animation deltas are applied relative to correct base transforms - Route "FBX Binary (*.fbx)" export through new exporter in MeshImporterExporter - Add FBX format to MCP server export_mesh tool - Comprehensive unit tests for Euler math, continuity, and FBX output Co-Authored-By: Claude Opus 4.6 --- src/Assimp/Importer.cpp | 6 + src/CMakeLists.txt | 1 + src/FBX/CMakeLists.txt | 21 + src/FBX/FBXExporter.cpp | 2001 +++++++++++++++++++++++++++++ src/FBX/FBXExporter.h | 41 + src/FBX/FBXExporter_test.cpp | 404 ++++++ src/MCPServer.cpp | 3 +- src/MeshImporterExporter.cpp | 9 +- src/MeshImporterExporter.h | 1 + src/MeshImporterExporter_test.cpp | 2 +- 10 files changed, 2486 insertions(+), 3 deletions(-) create mode 100644 src/FBX/CMakeLists.txt create mode 100644 src/FBX/FBXExporter.cpp create mode 100644 src/FBX/FBXExporter.h create mode 100644 src/FBX/FBXExporter_test.cpp diff --git a/src/Assimp/Importer.cpp b/src/Assimp/Importer.cpp index 2cedf5cf1..b3fda01c4 100644 --- a/src/Assimp/Importer.cpp +++ b/src/Assimp/Importer.cpp @@ -80,6 +80,12 @@ Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool conv BoneProcessor boneProcessor; boneProcessor.processBones(skeleton, scene); + // Save the bind pose so that animation deltas are applied relative to + // the correct base transforms. Binary SkeletonSerializer calls this + // automatically on load, but for in-memory skeletons we must do it + // explicitly before creating animations. + skeleton->setBindingPose(); + // Process animations AnimationProcessor animationProcessor(skeleton); animationProcessor.processAnimations(scene); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 321195cee..5b4e8e620 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -95,6 +95,7 @@ set(TEST_SOURCES "") ADD_SUBDIRECTORY("${CMAKE_CURRENT_SOURCE_DIR}/OgreXML") ADD_SUBDIRECTORY("${CMAKE_CURRENT_SOURCE_DIR}/Assimp") +ADD_SUBDIRECTORY("${CMAKE_CURRENT_SOURCE_DIR}/FBX") #file(GLOB UI_FILES ./ui_files/*.ui) # if we don't include this CMake will not include ui headers properly: diff --git a/src/FBX/CMakeLists.txt b/src/FBX/CMakeLists.txt new file mode 100644 index 000000000..4975d9917 --- /dev/null +++ b/src/FBX/CMakeLists.txt @@ -0,0 +1,21 @@ +############################################################## +# adding the files +############################################################## + +set(SRC_FILES +${SRC_FILES} +${CMAKE_CURRENT_SOURCE_DIR}/FBXExporter.cpp +PARENT_SCOPE +) + +set(HEADER_FILES +${HEADER_FILES} +${CMAKE_CURRENT_SOURCE_DIR}/FBXExporter.h +PARENT_SCOPE +) + +set(TEST_SOURCES +${TEST_SOURCES} +${CMAKE_CURRENT_SOURCE_DIR}/FBXExporter_test.cpp +PARENT_SCOPE +) diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp new file mode 100644 index 000000000..b3a1ddb7c --- /dev/null +++ b/src/FBX/FBXExporter.cpp @@ -0,0 +1,2001 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +----------------------------------------------------------------------------------- +*/ + +#include "FBXExporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +// FBX time: 1 second = 46186158000 FBX ticks +static constexpr int64_t FBX_TICKS_PER_SECOND = 46186158000LL; + +// FBX version 7300 (v7.3) +static constexpr uint32_t FBX_VERSION = 7300; + +// ─── Z-mirror helpers ──────────────────────────────────────────── +// Ogre stores data that was ConvertToLeftHanded during Assimp import +// (Z negated, UVs flipped, winding reversed). To produce a correct +// FBX file that round-trips through Assimp reimport (which applies +// ConvertToLeftHanded again), we must undo the LH transform here: +// negate Z positions/normals, flip UV V, reverse winding, +// and mirror bone transforms across Z. + +// Build a 4×4 local transform matrix from position, scale, orientation +static Ogre::Matrix4 buildLocalMatrix(const Ogre::Vector3& pos, + const Ogre::Vector3& scl, + const Ogre::Quaternion& ori) +{ + Ogre::Matrix3 rot3; + ori.ToRotationMatrix(rot3); + return Ogre::Matrix4( + rot3[0][0]*scl.x, rot3[0][1]*scl.y, rot3[0][2]*scl.z, pos.x, + rot3[1][0]*scl.x, rot3[1][1]*scl.y, rot3[1][2]*scl.z, pos.y, + rot3[2][0]*scl.x, rot3[2][1]*scl.y, rot3[2][2]*scl.z, pos.z, + 0, 0, 0, 1 + ); +} + +// Compute the global bind-pose matrix for a bone from initial (bind) transforms. +// Unlike _getFullTransform() this is immune to the current animation state. +static Ogre::Matrix4 computeGlobalBindPose(Ogre::Bone* bone) +{ + Ogre::Matrix4 local = buildLocalMatrix(bone->getInitialPosition(), + bone->getInitialScale(), + bone->getInitialOrientation()); + auto* parent = dynamic_cast(bone->getParent()); + if (parent) + return computeGlobalBindPose(parent) * local; + return local; +} + +// Mirror a quaternion across the Z-plane: +// rotation axis (ax,ay,az) → (ax,ay,-az) and angle negates +// ⇒ q(w,x,y,z) → q(w,-x,-y,z) +static Ogre::Quaternion mirrorZ(const Ogre::Quaternion& q) +{ + return Ogre::Quaternion(q.w, -q.x, -q.y, q.z); +} + +// Quaternion → Euler XYZ (degrees) +// Assimp's FBX RotOrder_EulerXYZ composes: R = Rz * Ry * Rx +// (see FBXConverter.cpp GetRotationMatrix — order is inverted for left-multiply). +// Decompose the rotation matrix accordingly. +static void quaternionToEulerXYZ(const Ogre::Quaternion& q, + double& rx, double& ry, double& rz) +{ + double w = q.w, x = q.x, y = q.y, z = q.z; + // For R = Rz * Ry * Rx: R[2][0] = -sin(ry) + // From quaternion: R[2][0] = 2(xz - wy) + double sinp = std::clamp(2.0 * (w * y - x * z), -1.0, 1.0); + ry = std::asin(sinp); + + double cosp = std::cos(ry); + if (cosp > 1e-6) + { + // rx = atan2(R[2][1], R[2][2]) = atan2(2(yz + wx), 1 - 2(x² + y²)) + rx = std::atan2(2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y)); + // rz = atan2(R[1][0], R[0][0]) = atan2(2(xy + wz), 1 - 2(y² + z²)) + rz = std::atan2(2.0 * (x * y + w * z), 1.0 - 2.0 * (y * y + z * z)); + } + else + { + // Gimbal lock: set rz = 0, solve rx from remaining elements + rz = 0.0; + rx = std::atan2(-(2.0 * (x * y - w * z)), 1.0 - 2.0 * (x * x + z * z)); + } + rx *= 180.0 / M_PI; + ry *= 180.0 / M_PI; + rz *= 180.0 / M_PI; +} + +// 4x4 matrix → 16 doubles (row-major) with Z-mirror applied. +// Z-mirror: M' = S * M * S where S = diag(1,1,-1,1). +// Elements [0][2],[1][2],[2][0],[2][1],[2][3],[3][2] are negated. +static void matrix4ToDoublesMirrorZ(const Ogre::Matrix4& m, double* out) +{ + // FBX uses row-vector convention (v' = v * M) with translation in the last + // row. Ogre uses column-vector convention (v' = M * v) with translation in + // the last column. Writing transposed maps Ogre column-major → FBX row-major. + Ogre::Matrix4 mz = m; + // Z-mirror: negate the six off-diagonal Z elements (undo ConvertToLeftHanded) + mz[0][2] = -mz[0][2]; + mz[1][2] = -mz[1][2]; + mz[2][0] = -mz[2][0]; + mz[2][1] = -mz[2][1]; + mz[2][3] = -mz[2][3]; + mz[3][2] = -mz[3][2]; + // Write transposed so translation lands in the last row for FBX + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + out[c * 4 + r] = mz[r][c]; +} + +// ═══════════════════════════════════════════════════════════════════ +// FBX Binary Writer +// ═══════════════════════════════════════════════════════════════════ + +class FBXBinaryWriter +{ +public: + explicit FBXBinaryWriter(std::ofstream& out) : m_out(out) {} + + // ── Header ─────────────────────────────────────────────────── + void writeHeader() + { + // "Kaydara FBX Binary \x00" (21 chars) + 0x1A 0x00 + const char magic[] = "Kaydara FBX Binary "; + m_out.write(magic, 21); // 21 bytes including trailing \0 + char pad[2] = {0x1A, 0x00}; + m_out.write(pad, 2); + writeU32(FBX_VERSION); // 4 bytes → total 27 + } + + // ── Node begin/end with endOffset backpatching ─────────────── + void beginNode(const std::string& name) + { + NodeInfo ni; + ni.endOffsetPos = static_cast(m_out.tellp()); + writeU32(0); // endOffset placeholder + ni.numPropsPos = static_cast(m_out.tellp()); + writeU32(0); // numProperties placeholder + ni.propListLenPos = static_cast(m_out.tellp()); + writeU32(0); // propertyListLen placeholder + uint8_t nameLen = static_cast(name.size()); + m_out.write(reinterpret_cast(&nameLen), 1); + m_out.write(name.data(), nameLen); + ni.propStartPos = static_cast(m_out.tellp()); + ni.numProps = 0; + m_nodeStack.push_back(ni); + } + + void endNode() + { + auto& ni = m_nodeStack.back(); + + // Write null sentinel (13 zero bytes) to terminate children + writeNullRecord(); + + uint32_t endOff = static_cast(m_out.tellp()); + uint32_t propListLen = ni.propEndPos - ni.propStartPos; + + // Backpatch + m_out.seekp(ni.endOffsetPos); + writeU32(endOff); + m_out.seekp(ni.numPropsPos); + writeU32(ni.numProps); + m_out.seekp(ni.propListLenPos); + writeU32(propListLen); + m_out.seekp(endOff); + + m_nodeStack.pop_back(); + } + + // endNode variant for leaf nodes (no children, no null record) + void endNodeLeaf() + { + auto& ni = m_nodeStack.back(); + + uint32_t endOff = static_cast(m_out.tellp()); + uint32_t propListLen = ni.propEndPos - ni.propStartPos; + + m_out.seekp(ni.endOffsetPos); + writeU32(endOff); + m_out.seekp(ni.numPropsPos); + writeU32(ni.numProps); + m_out.seekp(ni.propListLenPos); + writeU32(propListLen); + m_out.seekp(endOff); + + m_nodeStack.pop_back(); + } + + // Mark that properties are done (for propEndPos tracking) + void endProperties() + { + if (!m_nodeStack.empty()) + m_nodeStack.back().propEndPos = static_cast(m_out.tellp()); + } + + // ── Property writers ───────────────────────────────────────── + void writePropertyBool(bool v) + { + char type = 'C'; + m_out.write(&type, 1); + uint8_t val = v ? 1 : 0; + m_out.write(reinterpret_cast(&val), 1); + incrPropCount(); + } + + void writePropertyI(int32_t v) + { + char type = 'I'; + m_out.write(&type, 1); + m_out.write(reinterpret_cast(&v), 4); + incrPropCount(); + } + + void writePropertyL(int64_t v) + { + char type = 'L'; + m_out.write(&type, 1); + m_out.write(reinterpret_cast(&v), 8); + incrPropCount(); + } + + void writePropertyF(float v) + { + char type = 'F'; + m_out.write(&type, 1); + m_out.write(reinterpret_cast(&v), 4); + incrPropCount(); + } + + void writePropertyD(double v) + { + char type = 'D'; + m_out.write(&type, 1); + m_out.write(reinterpret_cast(&v), 8); + incrPropCount(); + } + + void writePropertyS(const std::string& s) + { + char type = 'S'; + m_out.write(&type, 1); + uint32_t len = static_cast(s.size()); + m_out.write(reinterpret_cast(&len), 4); + m_out.write(s.data(), len); + incrPropCount(); + } + + void writePropertyR(const std::vector& data) + { + char type = 'R'; + m_out.write(&type, 1); + uint32_t len = static_cast(data.size()); + m_out.write(reinterpret_cast(&len), 4); + m_out.write(reinterpret_cast(data.data()), len); + incrPropCount(); + } + + void writePropertyArrayD(const std::vector& arr) + { + char type = 'd'; + m_out.write(&type, 1); + uint32_t count = static_cast(arr.size()); + writeU32(count); + writeU32(0); // encoding = 0 (uncompressed) + uint32_t byteLen = count * 8; + writeU32(byteLen); + m_out.write(reinterpret_cast(arr.data()), byteLen); + incrPropCount(); + } + + void writePropertyArrayI(const std::vector& arr) + { + char type = 'i'; + m_out.write(&type, 1); + uint32_t count = static_cast(arr.size()); + writeU32(count); + writeU32(0); // encoding = 0 (uncompressed) + uint32_t byteLen = count * 4; + writeU32(byteLen); + m_out.write(reinterpret_cast(arr.data()), byteLen); + incrPropCount(); + } + + void writePropertyArrayF(const std::vector& arr) + { + char type = 'f'; + m_out.write(&type, 1); + uint32_t count = static_cast(arr.size()); + writeU32(count); + writeU32(0); // encoding = 0 (uncompressed) + uint32_t byteLen = count * 4; + writeU32(byteLen); + m_out.write(reinterpret_cast(arr.data()), byteLen); + incrPropCount(); + } + + void writePropertyArrayL(const std::vector& arr) + { + char type = 'l'; + m_out.write(&type, 1); + uint32_t count = static_cast(arr.size()); + writeU32(count); + writeU32(0); + uint32_t byteLen = count * 8; + writeU32(byteLen); + m_out.write(reinterpret_cast(arr.data()), byteLen); + incrPropCount(); + } + + // ── Null record (13 zero bytes for v7300) ──────────────────── + void writeNullRecord() + { + char zeros[13] = {}; + m_out.write(zeros, 13); + } + + // ── Footer ─────────────────────────────────────────────────── + void writeFooter() + { + // Top-level null sentinel + writeNullRecord(); + + // Footer: generate padding and unknown footer bytes + // Pad to 16-byte alignment with 0 bytes, then write footer + auto pos = m_out.tellp(); + int mod = static_cast(pos) % 16; + if (mod != 0) + { + int padLen = 16 - mod; + std::vector pad(padLen, 0); + m_out.write(pad.data(), padLen); + } + + // 4 bytes of padding + uint32_t zero = 0; + m_out.write(reinterpret_cast(&zero), 4); + + // FBX footer magic: version + some fixed bytes + // Standard 16-byte footer ID + const uint8_t footerId[] = { + 0xF8, 0x5A, 0x8C, 0x6A, 0xDE, 0xF5, 0xD9, 0x7E, + 0xEC, 0xE9, 0x0C, 0xE3, 0x75, 0x8F, 0x29, 0x0B + }; + m_out.write(reinterpret_cast(footerId), 16); + + // Pad with zeros to another 16-byte boundary + 4 + pos = m_out.tellp(); + mod = static_cast(pos) % 16; + if (mod != 0) + { + int padLen = 16 - mod; + std::vector pad(padLen, 0); + m_out.write(pad.data(), padLen); + } + + // Final version stamp + writeU32(FBX_VERSION); + // 120 bytes of zeros + char finalZeros[120] = {}; + m_out.write(finalZeros, 120); + // Footer magic repeated + m_out.write(reinterpret_cast(footerId), 16); + } + +private: + void writeU32(uint32_t v) + { + m_out.write(reinterpret_cast(&v), 4); + } + + void incrPropCount() + { + if (!m_nodeStack.empty()) + { + m_nodeStack.back().numProps++; + m_nodeStack.back().propEndPos = static_cast(m_out.tellp()); + } + } + + struct NodeInfo { + uint32_t endOffsetPos = 0; + uint32_t numPropsPos = 0; + uint32_t propListLenPos = 0; + uint32_t propStartPos = 0; + uint32_t propEndPos = 0; + uint32_t numProps = 0; + }; + + std::ofstream& m_out; + std::vector m_nodeStack; +}; + +// ═══════════════════════════════════════════════════════════════════ +// FBX Document Builder +// ═══════════════════════════════════════════════════════════════════ + +class FBXDocumentBuilder +{ +public: + explicit FBXDocumentBuilder(FBXBinaryWriter& w) : m_w(w) {} + + bool build(const Ogre::Entity* entity) + { + const Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return false; + + m_hasSkeleton = entity->hasSkeleton(); + m_skeleton = m_hasSkeleton ? mesh->getSkeleton().get() : nullptr; + m_entity = entity; + m_mesh = mesh.get(); + + // Reset skeleton to bind pose before reading transforms + if (m_skeleton) + m_skeleton->reset(); + + // Collect which bones have vertex assignments (deforming bones). + // Bones WITHOUT assignments (e.g. "Armature") are root-container bones + // that BoneProcessor recreates from parentNode->mTransformation.inverse(). + if (m_hasSkeleton) + { + for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) + { + const auto* subMesh = m_mesh->getSubMesh(si); + const auto& assignments = subMesh->useSharedVertices + ? m_mesh->getBoneAssignments() : subMesh->getBoneAssignments(); + for (const auto& [_, vba] : assignments) + m_bonesWithAssignments.insert(vba.boneIndex); + } + } + + // Build material index map (sorted by name, matching std::map iteration order + // which is the same order materials will be connected to the mesh model) + { + std::set matNames; + for (const auto* sub : m_entity->getSubEntities()) + matNames.insert(sub->getMaterial()->getName()); + int idx = 0; + for (const auto& name : matNames) + m_materialIndexMap[name] = idx++; + } + + m_w.writeHeader(); + + writeHeaderExtension(); + writeGlobalSettings(); + writeDocuments(); + writeReferences(); + writeDefinitions(); + writeObjects(); + writeConnections(); + + m_w.writeFooter(); + return true; + } + +private: + int64_t nextId() { return m_nextId++; } + + // ── FBXHeaderExtension ─────────────────────────────────────── + void writeHeaderExtension() + { + m_w.beginNode("FBXHeaderExtension"); + m_w.endProperties(); + + // FBXHeaderVersion + m_w.beginNode("FBXHeaderVersion"); + m_w.writePropertyI(1003); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // FBXVersion + m_w.beginNode("FBXVersion"); + m_w.writePropertyI(static_cast(FBX_VERSION)); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // EncryptionType + m_w.beginNode("EncryptionType"); + m_w.writePropertyI(0); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // CreationTimeStamp + m_w.beginNode("CreationTimeStamp"); + m_w.endProperties(); + m_w.beginNode("Version"); m_w.writePropertyI(1000); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Year"); m_w.writePropertyI(2025); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Month"); m_w.writePropertyI(1); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Day"); m_w.writePropertyI(1); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Hour"); m_w.writePropertyI(0); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Minute"); m_w.writePropertyI(0); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Second"); m_w.writePropertyI(0); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Millisecond"); m_w.writePropertyI(0); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.endNode(); // CreationTimeStamp + + // Creator + m_w.beginNode("Creator"); + m_w.writePropertyS("QtMeshEditor FBX Exporter"); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // FBXHeaderExtension + } + + // ── GlobalSettings ─────────────────────────────────────────── + void writeGlobalSettings() + { + m_w.beginNode("GlobalSettings"); + m_w.endProperties(); + + m_w.beginNode("Version"); + m_w.writePropertyI(1000); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + + writeP70int("UpAxis", 1); + writeP70int("UpAxisSign", 1); + writeP70int("FrontAxis", 2); + writeP70int("FrontAxisSign", 1); + writeP70int("CoordAxis", 0); + writeP70int("CoordAxisSign", 1); + writeP70int("OriginalUpAxis", 1); + writeP70int("OriginalUpAxisSign", 1); + // Ogre stores positions in meters (Assimp's FBX importer applied + // UnitScaleFactor*0.01 on the original import, converting cm→m). + // Setting UnitScaleFactor=100 tells the reimporter that 1 unit = 1 m, + // so the root-node scale becomes 100*0.01 = 1.0 (no additional scaling). + writeP70double("UnitScaleFactor", 100.0); + writeP70double("OriginalUnitScaleFactor", 100.0); + writeP70int("TimeMode", 6); // 30 fps + writeP70enum("TimeProtocol", 2); + writeP70enum("SnapOnFrameMode", 0); + writeP70KTime("TimeSpanStart", 0); + writeP70KTime("TimeSpanStop", FBX_TICKS_PER_SECOND); + writeP70double("CustomFrameRate", -1.0); + + m_w.endNode(); // Properties70 + m_w.endNode(); // GlobalSettings + } + + // ── Documents ──────────────────────────────────────────────── + void writeDocuments() + { + m_w.beginNode("Documents"); + m_w.endProperties(); + + m_w.beginNode("Count"); + m_w.writePropertyI(1); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("Document"); + m_w.writePropertyL(m_documentId); + m_w.writePropertyS("Scene"); + m_w.writePropertyS("Scene"); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70compound("SourceObject", ""); + writeP70string("ActiveAnimStackName", ""); + m_w.endNode(); // Properties70 + + m_w.beginNode("RootNode"); + m_w.writePropertyL(0); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // Document + m_w.endNode(); // Documents + } + + // ── References ─────────────────────────────────────────────── + void writeReferences() + { + m_w.beginNode("References"); + m_w.endProperties(); + m_w.endNode(); + } + + // ── Definitions ────────────────────────────────────────────── + void writeDefinitions() + { + // Count object types + int defCount = 1; // GlobalSettings always + int modelCount = 1; // root mesh model + int geomCount = m_mesh->getNumSubMeshes(); + int matCount = 0; + int deformerCount = 0; + int nodeAttrCount = 0; + int poseCount = 0; + int textureCount = 0; + int videoCount = 0; + int animStackCount = 0; + int animLayerCount = 0; + int animCurveNodeCount = 0; + int animCurveCount = 0; + + // Count unique materials and textures + std::set matNames; + std::set texNames; + for (const auto* sub : m_entity->getSubEntities()) + { + auto mat = sub->getMaterial(); + matNames.insert(mat->getName()); + if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) + { + auto* pass = mat->getTechnique(0)->getPass(0); + for (unsigned short ti = 0; ti < pass->getNumTextureUnitStates(); ++ti) + { + auto texName = pass->getTextureUnitState(ti)->getTextureName(); + if (!texName.empty()) + texNames.insert(texName); + } + } + } + matCount = static_cast(matNames.size()); + textureCount = static_cast(texNames.size()); + videoCount = textureCount; + + if (m_hasSkeleton) + { + unsigned short numBones = m_skeleton->getNumBones(); + modelCount += numBones; // bone models + nodeAttrCount = numBones; // bone node attributes + poseCount = 1; // BindPose + + // Skin deformers (1 per submesh) + cluster deformers (1 per bone-per-submesh that has weights) + deformerCount = geomCount; // skin deformers + for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) + { + const auto* subMesh = m_mesh->getSubMesh(si); + const auto& boneAssignments = subMesh->useSharedVertices + ? m_mesh->getBoneAssignments() : subMesh->getBoneAssignments(); + std::set boneIndices; + for (const auto& [_, vba] : boneAssignments) + boneIndices.insert(vba.boneIndex); + deformerCount += static_cast(boneIndices.size()); + } + + if (m_skeleton->getNumAnimations() > 0) + { + animStackCount = m_skeleton->getNumAnimations(); + animLayerCount = animStackCount; + // Per animation: per bone track → 3 curve nodes (T, R, S) + 9 curves (XYZ each) + for (unsigned short ai = 0; ai < m_skeleton->getNumAnimations(); ++ai) + { + auto* anim = m_skeleton->getAnimation(ai); + auto numTracks = static_cast(anim->_getNodeTrackList().size()); + animCurveNodeCount += numTracks * 3; + animCurveCount += numTracks * 9; + } + } + } + + int totalObjects = 1 + modelCount + geomCount + matCount + nodeAttrCount + + deformerCount + poseCount + textureCount + videoCount + + animStackCount + animLayerCount + + animCurveNodeCount + animCurveCount; + + defCount += (modelCount > 0 ? 1 : 0); + defCount += (geomCount > 0 ? 1 : 0); + defCount += (matCount > 0 ? 1 : 0); + defCount += (nodeAttrCount > 0 ? 1 : 0); + defCount += (deformerCount > 0 ? 1 : 0); + defCount += (poseCount > 0 ? 1 : 0); + defCount += (textureCount > 0 ? 1 : 0); + defCount += (videoCount > 0 ? 1 : 0); + defCount += (animStackCount > 0 ? 1 : 0); + defCount += (animLayerCount > 0 ? 1 : 0); + defCount += (animCurveNodeCount > 0 ? 1 : 0); + defCount += (animCurveCount > 0 ? 1 : 0); + + m_w.beginNode("Definitions"); + m_w.endProperties(); + + m_w.beginNode("Version"); + m_w.writePropertyI(100); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("Count"); + m_w.writePropertyI(totalObjects); + m_w.endProperties(); + m_w.endNodeLeaf(); + + writeObjectType("GlobalSettings", 1); + if (modelCount > 0) writeObjectType("Model", modelCount); + if (geomCount > 0) writeObjectType("Geometry", geomCount); + if (matCount > 0) writeObjectType("Material", matCount); + if (nodeAttrCount > 0) writeObjectType("NodeAttribute", nodeAttrCount); + if (deformerCount > 0) writeObjectType("Deformer", deformerCount); + if (poseCount > 0) writeObjectType("Pose", poseCount); + if (textureCount > 0) writeObjectType("Texture", textureCount); + if (videoCount > 0) writeObjectType("Video", videoCount); + if (animStackCount > 0) writeObjectType("AnimationStack", animStackCount); + if (animLayerCount > 0) writeObjectType("AnimationLayer", animLayerCount); + if (animCurveNodeCount > 0) writeObjectType("AnimationCurveNode", animCurveNodeCount); + if (animCurveCount > 0) writeObjectType("AnimationCurve", animCurveCount); + + m_w.endNode(); // Definitions + } + + void writeObjectType(const std::string& typeName, int count) + { + m_w.beginNode("ObjectType"); + m_w.writePropertyS(typeName); + m_w.endProperties(); + + m_w.beginNode("Count"); + m_w.writePropertyI(count); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); + } + + // ── Objects ────────────────────────────────────────────────── + void writeObjects() + { + m_w.beginNode("Objects"); + m_w.endProperties(); + + writeGeometryObjects(); + writeMeshModel(); + writeMaterialObjects(); + writeTextureObjects(); + if (m_hasSkeleton) + { + writeBoneModels(); + writeSkinDeformers(); + writeBindPose(); + if (m_skeleton->getNumAnimations() > 0) + writeAnimations(); + } + + m_w.endNode(); // Objects + } + + // ── Geometry objects (one per submesh) ──────────────────────── + void writeGeometryObjects() + { + for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) + { + const Ogre::SubMesh* subMesh = m_mesh->getSubMesh(si); + const Ogre::VertexData* vData = subMesh->useSharedVertices + ? m_mesh->sharedVertexData : subMesh->vertexData; + if (!vData || vData->vertexCount == 0) continue; + + int64_t geomId = nextId(); + m_geomIds.push_back(geomId); + + std::string geomName = std::string(m_entity->getName()) + + "_submesh" + std::to_string(si); + + m_w.beginNode("Geometry"); + m_w.writePropertyL(geomId); + m_w.writePropertyS(geomName + std::string("\x00\x01", 2) + "Geometry"); + m_w.writePropertyS("Mesh"); + m_w.endProperties(); + + // ── Vertices (Z-mirrored, no unit scaling) ── + std::vector positions; + const auto* posElem = vData->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (posElem) + { + auto vbuf = vData->vertexBufferBinding->getBuffer(posElem->getSource()); + auto* base = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + positions.resize(vData->vertexCount * 3); + for (size_t j = 0; j < vData->vertexCount; ++j) + { + const Ogre::Real* p; + posElem->baseVertexPointerToElement( + const_cast(base + j * vbuf->getVertexSize()), &p); + positions[j * 3 + 0] = p[0]; + positions[j * 3 + 1] = p[1]; + positions[j * 3 + 2] = -static_cast(p[2]); // negate Z + } + vbuf->unlock(); + } + m_w.beginNode("Vertices"); + m_w.writePropertyArrayD(positions); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // ── PolygonVertexIndex (winding reversed for Z-mirror) ── + std::vector polyIndices; + const Ogre::IndexData* iData = subMesh->indexData; + if (iData && iData->indexCount > 0) + { + auto ibuf = iData->indexBuffer; + auto* ibase = static_cast( + ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + bool use32 = ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT; + polyIndices.resize(iData->indexCount); + for (size_t f = 0; f < iData->indexCount / 3; ++f) + { + // Read original triangle indices + int32_t i0, i1, i2; + if (use32) { + i0 = static_cast(reinterpret_cast(ibase)[f * 3 + 0]); + i1 = static_cast(reinterpret_cast(ibase)[f * 3 + 1]); + i2 = static_cast(reinterpret_cast(ibase)[f * 3 + 2]); + } else { + i0 = static_cast(reinterpret_cast(ibase)[f * 3 + 0]); + i1 = static_cast(reinterpret_cast(ibase)[f * 3 + 1]); + i2 = static_cast(reinterpret_cast(ibase)[f * 3 + 2]); + } + // Reverse winding: (v0, v1, v2) → (v0, v2, v1) + // FBX convention: last index is -(idx+1) + polyIndices[f * 3 + 0] = i0; + polyIndices[f * 3 + 1] = i2; + polyIndices[f * 3 + 2] = -(i1 + 1); + } + ibuf->unlock(); + } + m_w.beginNode("PolygonVertexIndex"); + m_w.writePropertyArrayI(polyIndices); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // ── LayerElementNormal ── + const auto* normElem = vData->vertexDeclaration->findElementBySemantic(Ogre::VES_NORMAL); + if (normElem) + { + std::vector normals(vData->vertexCount * 3); + auto vbuf = vData->vertexBufferBinding->getBuffer(normElem->getSource()); + auto* base = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t j = 0; j < vData->vertexCount; ++j) + { + const Ogre::Real* p; + normElem->baseVertexPointerToElement( + const_cast(base + j * vbuf->getVertexSize()), &p); + normals[j * 3 + 0] = p[0]; + normals[j * 3 + 1] = p[1]; + normals[j * 3 + 2] = -static_cast(p[2]); // negate Z + } + vbuf->unlock(); + + m_w.beginNode("LayerElementNormal"); + m_w.writePropertyI(0); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(101); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Name"); m_w.writePropertyS(""); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("MappingInformationType"); m_w.writePropertyS("ByPolygonVertex"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("ReferenceInformationType"); m_w.writePropertyS("Direct"); m_w.endProperties(); m_w.endNodeLeaf(); + + // Expand normals to per-polygon-vertex with reversed winding + // PolygonVertexIndex stores (v0, v2, v1) per triangle, so + // normals must be expanded in the same order. + std::vector expandedNormals; + if (iData && iData->indexCount > 0) + { + expandedNormals.resize(iData->indexCount * 3); + auto ibuf = iData->indexBuffer; + auto* ibase2 = static_cast( + ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + bool use32 = ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT; + for (size_t f = 0; f < iData->indexCount / 3; ++f) + { + uint32_t vi0 = use32 + ? reinterpret_cast(ibase2)[f * 3 + 0] + : reinterpret_cast(ibase2)[f * 3 + 0]; + uint32_t vi1 = use32 + ? reinterpret_cast(ibase2)[f * 3 + 1] + : reinterpret_cast(ibase2)[f * 3 + 1]; + uint32_t vi2 = use32 + ? reinterpret_cast(ibase2)[f * 3 + 2] + : reinterpret_cast(ibase2)[f * 3 + 2]; + // Reversed winding: (v0, v2, v1) to match PolygonVertexIndex + size_t base = f * 9; + expandedNormals[base + 0] = normals[vi0 * 3 + 0]; + expandedNormals[base + 1] = normals[vi0 * 3 + 1]; + expandedNormals[base + 2] = normals[vi0 * 3 + 2]; + expandedNormals[base + 3] = normals[vi2 * 3 + 0]; + expandedNormals[base + 4] = normals[vi2 * 3 + 1]; + expandedNormals[base + 5] = normals[vi2 * 3 + 2]; + expandedNormals[base + 6] = normals[vi1 * 3 + 0]; + expandedNormals[base + 7] = normals[vi1 * 3 + 1]; + expandedNormals[base + 8] = normals[vi1 * 3 + 2]; + } + ibuf->unlock(); + } + else + { + expandedNormals = normals; + } + + m_w.beginNode("Normals"); + m_w.writePropertyArrayD(expandedNormals); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // LayerElementNormal + } + + // ── LayerElementUV ── + const auto* tcElem = vData->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES); + if (tcElem) + { + std::vector uvs(vData->vertexCount * 2); + auto vbuf = vData->vertexBufferBinding->getBuffer(tcElem->getSource()); + auto* base = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t j = 0; j < vData->vertexCount; ++j) + { + const Ogre::Real* p; + tcElem->baseVertexPointerToElement( + const_cast(base + j * vbuf->getVertexSize()), &p); + uvs[j * 2 + 0] = p[0]; + uvs[j * 2 + 1] = 1.0 - p[1]; // flip V + } + vbuf->unlock(); + + m_w.beginNode("LayerElementUV"); + m_w.writePropertyI(0); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(101); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Name"); m_w.writePropertyS("UVMap"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("MappingInformationType"); m_w.writePropertyS("ByPolygonVertex"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("ReferenceInformationType"); m_w.writePropertyS("IndexToDirect"); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("UV"); + m_w.writePropertyArrayD(uvs); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // UV index with reversed winding to match PolygonVertexIndex + std::vector uvIndex; + if (iData && iData->indexCount > 0) + { + auto ibuf = iData->indexBuffer; + auto* ibase2 = static_cast( + ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + bool use32 = ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT; + uvIndex.resize(iData->indexCount); + for (size_t f = 0; f < iData->indexCount / 3; ++f) + { + uint32_t vi0 = use32 + ? reinterpret_cast(ibase2)[f * 3 + 0] + : reinterpret_cast(ibase2)[f * 3 + 0]; + uint32_t vi1 = use32 + ? reinterpret_cast(ibase2)[f * 3 + 1] + : reinterpret_cast(ibase2)[f * 3 + 1]; + uint32_t vi2 = use32 + ? reinterpret_cast(ibase2)[f * 3 + 2] + : reinterpret_cast(ibase2)[f * 3 + 2]; + // Reversed winding: (v0, v2, v1) + uvIndex[f * 3 + 0] = static_cast(vi0); + uvIndex[f * 3 + 1] = static_cast(vi2); + uvIndex[f * 3 + 2] = static_cast(vi1); + } + ibuf->unlock(); + } + + m_w.beginNode("UVIndex"); + m_w.writePropertyArrayI(uvIndex); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // LayerElementUV + } + + // ── LayerElementMaterial ── + { + int matIndex = 0; + auto* subEnt = m_entity->getSubEntity(si); + auto matIt = m_materialIndexMap.find(subEnt->getMaterial()->getName()); + if (matIt != m_materialIndexMap.end()) + matIndex = matIt->second; + + m_w.beginNode("LayerElementMaterial"); + m_w.writePropertyI(0); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(101); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Name"); m_w.writePropertyS(""); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("MappingInformationType"); m_w.writePropertyS("AllSame"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("ReferenceInformationType"); m_w.writePropertyS("IndexToDirect"); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("Materials"); + m_w.writePropertyArrayI({matIndex}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // LayerElementMaterial + } + + // ── Layer ── + m_w.beginNode("Layer"); + m_w.writePropertyI(0); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(100); m_w.endProperties(); m_w.endNodeLeaf(); + + if (normElem) + { + m_w.beginNode("LayerElement"); + m_w.endProperties(); + m_w.beginNode("Type"); m_w.writePropertyS("LayerElementNormal"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("TypedIndex"); m_w.writePropertyI(0); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.endNode(); + } + if (tcElem) + { + m_w.beginNode("LayerElement"); + m_w.endProperties(); + m_w.beginNode("Type"); m_w.writePropertyS("LayerElementUV"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("TypedIndex"); m_w.writePropertyI(0); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.endNode(); + } + { + m_w.beginNode("LayerElement"); + m_w.endProperties(); + m_w.beginNode("Type"); m_w.writePropertyS("LayerElementMaterial"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("TypedIndex"); m_w.writePropertyI(0); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.endNode(); + } + + m_w.endNode(); // Layer + m_w.endNode(); // Geometry + } + } + + // ── Mesh Model ─────────────────────────────────────────────── + void writeMeshModel() + { + m_meshModelId = nextId(); + std::string modelName = std::string(m_entity->getName()); + + m_w.beginNode("Model"); + m_w.writePropertyL(m_meshModelId); + m_w.writePropertyS(modelName + std::string("\x00\x01", 2) + "Model"); + m_w.writePropertyS("Mesh"); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(232); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70LclTranslation(0.0, 0.0, 0.0); + writeP70LclRotation(0.0, 0.0, 0.0); + writeP70LclScaling(1.0, 1.0, 1.0); + m_w.endNode(); // Properties70 + + m_w.beginNode("Shading"); m_w.writePropertyBool(true); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Culling"); m_w.writePropertyS("CullingOff"); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Model + } + + // ── Material objects ───────────────────────────────────────── + void writeMaterialObjects() + { + std::set seen; + for (const auto* sub : m_entity->getSubEntities()) + { + auto mat = sub->getMaterial(); + if (!seen.insert(mat->getName()).second) continue; + + int64_t matId = nextId(); + m_materialIds[mat->getName()] = matId; + + m_w.beginNode("Material"); + m_w.writePropertyL(matId); + m_w.writePropertyS(mat->getName() + std::string("\x00\x01", 2) + "Material"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(102); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("ShadingModel"); m_w.writePropertyS("Phong"); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + + if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) + { + auto* pass = mat->getTechnique(0)->getPass(0); + auto d = pass->getDiffuse(); + writeP70Color("DiffuseColor", d.r, d.g, d.b); + auto s = pass->getSpecular(); + writeP70Color("SpecularColor", s.r, s.g, s.b); + auto a = pass->getAmbient(); + writeP70Color("AmbientColor", a.r, a.g, a.b); + auto e = pass->getSelfIllumination(); + writeP70Color("EmissiveColor", e.r, e.g, e.b); + writeP70Number("Shininess", pass->getShininess()); + writeP70Number("Opacity", d.a); + } + + m_w.endNode(); // Properties70 + m_w.endNode(); // Material + } + } + + // ── Bone Models ────────────────────────────────────────────── + void writeBoneModels() + { + for (unsigned short bi = 0; bi < m_skeleton->getNumBones(); ++bi) + { + auto* bone = m_skeleton->getBone(bi); + int64_t boneModelId = nextId(); + int64_t boneAttrId = nextId(); + m_boneModelIds[bone->getHandle()] = boneModelId; + m_boneAttrIds[bone->getHandle()] = boneAttrId; + + // NodeAttribute (LimbNode) + m_w.beginNode("NodeAttribute"); + m_w.writePropertyL(boneAttrId); + m_w.writePropertyS(std::string(bone->getName()) + std::string("\x00\x01", 2) + "NodeAttribute"); + m_w.writePropertyS("LimbNode"); + m_w.endProperties(); + + m_w.beginNode("TypeFlags"); m_w.writePropertyS("Skeleton"); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // NodeAttribute + + // Model (LimbNode) — Z-mirrored, using initial (bind pose) values. + // Non-deforming bones (no vertex weights, e.g. "Armature") need their + // transform INVERTED before writing. BoneProcessor on reimport creates + // these from parentNode->mTransformation.inverse(), so writing the + // inverse here ensures the double-inversion recovers the original. + bool isNonDeforming = m_bonesWithAssignments.find(bone->getHandle()) + == m_bonesWithAssignments.end(); + + Ogre::Vector3 pos; + Ogre::Quaternion ori; + Ogre::Vector3 scl; + + if (isNonDeforming) + { + Ogre::Matrix4 localMat = buildLocalMatrix( + bone->getInitialPosition(), + bone->getInitialScale(), + bone->getInitialOrientation()); + Ogre::Matrix4 invMat = localMat.inverse(); + Ogre::Affine3 aff(invMat); + aff.decomposition(pos, scl, ori); + } + else + { + pos = bone->getInitialPosition(); + ori = bone->getInitialOrientation(); + scl = bone->getInitialScale(); + } + + Ogre::Quaternion mirroredRot = mirrorZ(ori); + double rx, ry, rz; + quaternionToEulerXYZ(mirroredRot, rx, ry, rz); + + m_w.beginNode("Model"); + m_w.writePropertyL(boneModelId); + m_w.writePropertyS(std::string(bone->getName()) + std::string("\x00\x01", 2) + "Model"); + m_w.writePropertyS("LimbNode"); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(232); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + + writeP70LclTranslation(pos.x, pos.y, -pos.z); // negate Z + writeP70LclRotation(rx, ry, rz); + writeP70LclScaling(scl.x, scl.y, scl.z); + + m_w.endNode(); // Properties70 + + m_w.beginNode("Shading"); m_w.writePropertyBool(true); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Culling"); m_w.writePropertyS("CullingOff"); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Model + } + } + + // ── Skin Deformers ─────────────────────────────────────────── + void writeSkinDeformers() + { + for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) + { + if (si >= m_geomIds.size()) break; + const Ogre::SubMesh* subMesh = m_mesh->getSubMesh(si); + + int64_t skinId = nextId(); + m_skinIds.push_back(skinId); + + m_w.beginNode("Deformer"); + m_w.writePropertyL(skinId); + m_w.writePropertyS("Skin_" + std::to_string(si) + std::string("\x00\x01", 2) + "Deformer"); + m_w.writePropertyS("Skin"); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(101); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Link_DeformAcuracy"); m_w.writePropertyD(50.0); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Deformer (Skin) + + // Collect bone assignments grouped by bone index + const auto& boneAssignments = subMesh->useSharedVertices + ? m_mesh->getBoneAssignments() : subMesh->getBoneAssignments(); + std::map>> boneWeightsMap; + for (const auto& [vertIdx, vba] : boneAssignments) + { + boneWeightsMap[vba.boneIndex].push_back( + {static_cast(vba.vertexIndex), vba.weight}); + } + + // Per-bone Cluster sub-deformers + for (const auto& [boneIdx, weights] : boneWeightsMap) + { + auto* bone = m_skeleton->getBone(boneIdx); + int64_t clusterId = nextId(); + + // Store connection info + m_clusterConnections.push_back({clusterId, skinId, boneIdx, si}); + + m_w.beginNode("Deformer"); + m_w.writePropertyL(clusterId); + m_w.writePropertyS("Cluster_" + std::string(bone->getName()) + + "_" + std::to_string(si) + std::string("\x00\x01", 2) + "SubDeformer"); + m_w.writePropertyS("Cluster"); + m_w.endProperties(); + + m_w.beginNode("Version"); m_w.writePropertyI(100); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("UserData"); m_w.writePropertyS(""); m_w.writePropertyS(""); m_w.endProperties(); m_w.endNodeLeaf(); + + // Indexes and Weights + std::vector indices; + std::vector weightValues; + indices.reserve(weights.size()); + weightValues.reserve(weights.size()); + for (const auto& [vi, w] : weights) + { + indices.push_back(vi); + weightValues.push_back(w); + } + + m_w.beginNode("Indexes"); + m_w.writePropertyArrayI(indices); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("Weights"); + m_w.writePropertyArrayD(weightValues); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // Transform = mesh bind pose transform (identity — mesh is at origin) + double identityArr[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; + m_w.beginNode("Transform"); + m_w.writePropertyArrayD(std::vector(identityArr, identityArr + 16)); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // TransformLink = bone's global bind pose, Z-mirrored + // Use computeGlobalBindPose (from initial transforms) instead of + // _getFullTransform() which may reflect the current animation state + Ogre::Matrix4 boneGlobal = computeGlobalBindPose(bone); + double transformLinkArr[16]; + matrix4ToDoublesMirrorZ(boneGlobal, transformLinkArr); + m_w.beginNode("TransformLink"); + m_w.writePropertyArrayD(std::vector(transformLinkArr, transformLinkArr + 16)); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // Deformer (Cluster) + } + } + } + + // ── Animations ─────────────────────────────────────────────── + void writeAnimations() + { + for (unsigned short ai = 0; ai < m_skeleton->getNumAnimations(); ++ai) + { + auto* ogreAnim = m_skeleton->getAnimation(ai); + int64_t stackId = nextId(); + int64_t layerId = nextId(); + + m_animStackIds.push_back(stackId); + m_animLayerToStack.push_back({layerId, stackId}); + + double duration = ogreAnim->getLength(); + int64_t startTime = 0; + int64_t stopTime = static_cast(duration * FBX_TICKS_PER_SECOND); + + // AnimationStack + m_w.beginNode("AnimationStack"); + m_w.writePropertyL(stackId); + m_w.writePropertyS(ogreAnim->getName() + std::string("\x00\x01", 2) + "AnimStack"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70KTime("LocalStart", startTime); + writeP70KTime("LocalStop", stopTime); + m_w.endNode(); // Properties70 + + m_w.endNode(); // AnimationStack + + // AnimationLayer + m_w.beginNode("AnimationLayer"); + m_w.writePropertyL(layerId); + m_w.writePropertyS(ogreAnim->getName() + "_Layer" + std::string("\x00\x01", 2) + "AnimLayer"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70Number("Weight", 100.0); + m_w.endNode(); // Properties70 + + m_w.endNode(); // AnimationLayer + + // Per-bone tracks + for (const auto& [handle, track] : ogreAnim->_getNodeTrackList()) + { + auto* bone = dynamic_cast(track->getAssociatedNode()); + if (!bone) continue; + + auto boneIt = m_boneModelIds.find(bone->getHandle()); + if (boneIt == m_boneModelIds.end()) continue; + int64_t boneModelId = boneIt->second; + + Ogre::Vector3 bindPos = bone->getInitialPosition(); + Ogre::Quaternion bindRot = bone->getInitialOrientation(); + + auto numKF = track->getNumKeyFrames(); + + // Collect keyframe data + std::vector times(numKF); + std::vector tx(numKF), ty(numKF), tz(numKF); + std::vector rxArr(numKF), ryArr(numKF), rzArr(numKF); + std::vector sx(numKF), sy(numKF), sz(numKF); + + for (unsigned short ki = 0; ki < numKF; ++ki) + { + auto* kf = track->getNodeKeyFrame(ki); + times[ki] = static_cast(kf->getTime() * FBX_TICKS_PER_SECOND); + + Ogre::Vector3 pos = bindPos + kf->getTranslate(); + tx[ki] = pos.x; + ty[ki] = pos.y; + tz[ki] = -static_cast(pos.z); // negate Z + + Ogre::Quaternion rot = mirrorZ(bindRot * kf->getRotation()); + rot.normalise(); + double erx, ery, erz; + quaternionToEulerXYZ(rot, erx, ery, erz); + + // Euler angle continuity: keep each axis within 180° + // of the previous keyframe to avoid sudden full-rotation + // jumps caused by equivalent Euler representations. + if (ki > 0) + { + auto unroll = [](double prev, double cur) { + double d = cur - prev; + if (d > 180.0) cur -= 360.0 * std::ceil((d - 180.0) / 360.0); + else if (d < -180.0) cur += 360.0 * std::ceil((-d - 180.0) / 360.0); + return cur; + }; + erx = unroll(rxArr[ki - 1], erx); + ery = unroll(ryArr[ki - 1], ery); + erz = unroll(rzArr[ki - 1], erz); + } + + rxArr[ki] = erx; + ryArr[ki] = ery; + rzArr[ki] = erz; + + Ogre::Vector3 scl = kf->getScale(); + sx[ki] = scl.x; + sy[ki] = scl.y; + sz[ki] = scl.z; + } + + // AnimationCurveNode T + int64_t cnT = nextId(); + writeAnimCurveNode(cnT, "T", "d|X", "d|Y", "d|Z", + tx.empty() ? 0 : tx[0], + ty.empty() ? 0 : ty[0], + tz.empty() ? 0 : tz[0]); + m_animCurveNodeConns.push_back({cnT, layerId, boneModelId, "Lcl Translation"}); + + // AnimationCurveNode R + int64_t cnR = nextId(); + writeAnimCurveNode(cnR, "R", "d|X", "d|Y", "d|Z", + rxArr.empty() ? 0 : rxArr[0], + ryArr.empty() ? 0 : ryArr[0], + rzArr.empty() ? 0 : rzArr[0]); + m_animCurveNodeConns.push_back({cnR, layerId, boneModelId, "Lcl Rotation"}); + + // AnimationCurveNode S + int64_t cnS = nextId(); + writeAnimCurveNode(cnS, "S", "d|X", "d|Y", "d|Z", + sx.empty() ? 1 : sx[0], + sy.empty() ? 1 : sy[0], + sz.empty() ? 1 : sz[0]); + m_animCurveNodeConns.push_back({cnS, layerId, boneModelId, "Lcl Scaling"}); + + // 9 AnimationCurves: TX, TY, TZ, RX, RY, RZ, SX, SY, SZ + auto writeCurve = [&](const std::vector& t, const std::vector& vals, + int64_t curveNodeId, const std::string& channel) + { + int64_t curveId = nextId(); + m_w.beginNode("AnimationCurve"); + m_w.writePropertyL(curveId); + m_w.writePropertyS(std::string("\x00\x01", 2) + "AnimCurve"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Default"); m_w.writePropertyD(vals.empty() ? 0.0 : vals[0]); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("KeyVer"); m_w.writePropertyI(4008); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("KeyTime"); + m_w.writePropertyArrayL(t); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyValueFloat"); + std::vector fVals(vals.begin(), vals.end()); + m_w.writePropertyArrayF(fVals); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // AttrFlags — interpolation: cubic + m_w.beginNode("KeyAttrFlags"); + m_w.writePropertyArrayI({24840}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyAttrDataFloat"); + m_w.writePropertyArrayF({0.0f, 0.0f, 0.0218f, 0.0f}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyAttrRefCount"); + m_w.writePropertyArrayI({static_cast(t.size())}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // AnimationCurve + + m_animCurveConns.push_back({curveId, curveNodeId, channel}); + }; + + writeCurve(times, tx, cnT, "d|X"); + writeCurve(times, ty, cnT, "d|Y"); + writeCurve(times, tz, cnT, "d|Z"); + writeCurve(times, rxArr, cnR, "d|X"); + writeCurve(times, ryArr, cnR, "d|Y"); + writeCurve(times, rzArr, cnR, "d|Z"); + writeCurve(times, sx, cnS, "d|X"); + writeCurve(times, sy, cnS, "d|Y"); + writeCurve(times, sz, cnS, "d|Z"); + } + } + } + + void writeAnimCurveNode(int64_t id, const std::string& name, + const std::string& , const std::string& , const std::string& , + double dx, double dy, double dz) + { + m_w.beginNode("AnimationCurveNode"); + m_w.writePropertyL(id); + m_w.writePropertyS(name + std::string("\x00\x01", 2) + "AnimCurveNode"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + + // d|X, d|Y, d|Z properties + m_w.beginNode("P"); + m_w.writePropertyS("d|X"); m_w.writePropertyS("Number"); m_w.writePropertyS(""); + m_w.writePropertyS("A"); m_w.writePropertyD(dx); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("P"); + m_w.writePropertyS("d|Y"); m_w.writePropertyS("Number"); m_w.writePropertyS(""); + m_w.writePropertyS("A"); m_w.writePropertyD(dy); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("P"); + m_w.writePropertyS("d|Z"); m_w.writePropertyS("Number"); m_w.writePropertyS(""); + m_w.writePropertyS("A"); m_w.writePropertyD(dz); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Properties70 + m_w.endNode(); // AnimationCurveNode + } + + // ── BindPose ───────────────────────────────────────────────── + void writeBindPose() + { + int64_t poseId = nextId(); + int poseNodeCount = 1 + m_skeleton->getNumBones(); // mesh + all bones + + m_w.beginNode("Pose"); + m_w.writePropertyL(poseId); + m_w.writePropertyS("BIND_POSES" + std::string("\x00\x01", 2) + "Pose"); + m_w.writePropertyS("BindPose"); + m_w.endProperties(); + + m_w.beginNode("Type"); + m_w.writePropertyS("BindPose"); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("Version"); + m_w.writePropertyI(100); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("NbPoseNodes"); + m_w.writePropertyI(poseNodeCount); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // Mesh model PoseNode (identity — mesh has no transform) + { + double identity[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; + writePoseNode(m_meshModelId, identity); + } + + // Bone PoseNodes (global bind pose, Z-mirrored) + for (unsigned short bi = 0; bi < m_skeleton->getNumBones(); ++bi) + { + auto* bone = m_skeleton->getBone(bi); + Ogre::Matrix4 globalBind = computeGlobalBindPose(bone); + double mat[16]; + matrix4ToDoublesMirrorZ(globalBind, mat); + writePoseNode(m_boneModelIds[bone->getHandle()], mat); + } + + m_w.endNode(); // Pose + } + + void writePoseNode(int64_t nodeId, const double* mat16) + { + m_w.beginNode("PoseNode"); + m_w.endProperties(); + + m_w.beginNode("Node"); + m_w.writePropertyL(nodeId); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("Matrix"); + m_w.writePropertyArrayD(std::vector(mat16, mat16 + 16)); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // PoseNode + } + + // ── Texture objects ───────────────────────────────────────── + void writeTextureObjects() + { + std::set seen; + for (const auto* sub : m_entity->getSubEntities()) + { + auto mat = sub->getMaterial(); + if (mat->getNumTechniques() == 0 || mat->getTechnique(0)->getNumPasses() == 0) + continue; + + auto* pass = mat->getTechnique(0)->getPass(0); + for (unsigned short ti = 0; ti < pass->getNumTextureUnitStates(); ++ti) + { + std::string texName = pass->getTextureUnitState(ti)->getTextureName(); + if (texName.empty() || !seen.insert(texName).second) + continue; + + int64_t texId = nextId(); + int64_t vidId = nextId(); + m_textureIds[texName] = texId; + m_videoIds[texName] = vidId; + + // Texture object + m_w.beginNode("Texture"); + m_w.writePropertyL(texId); + m_w.writePropertyS(texName + std::string("\x00\x01", 2) + "Texture"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Type"); m_w.writePropertyS("TextureVideoClip"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Version"); m_w.writePropertyI(202); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("TextureName"); m_w.writePropertyS(texName); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("FileName"); m_w.writePropertyS(texName); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("RelativeFilename"); m_w.writePropertyS(texName); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70string("UVSet", "UVMap"); + m_w.endNode(); // Properties70 + + m_w.endNode(); // Texture + + // Video (clip) object + m_w.beginNode("Video"); + m_w.writePropertyL(vidId); + m_w.writePropertyS(texName + std::string("\x00\x01", 2) + "Video"); + m_w.writePropertyS("Clip"); + m_w.endProperties(); + + m_w.beginNode("Type"); m_w.writePropertyS("Clip"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("FileName"); m_w.writePropertyS(texName); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("RelativeFilename"); m_w.writePropertyS(texName); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Video + } + } + } + + // ── Connections ────────────────────────────────────────────── + void writeConnections() + { + m_w.beginNode("Connections"); + m_w.endProperties(); + + // Mesh model → root (id 0) + writeConnection("OO", m_meshModelId, 0); + + // Geometry → mesh model + for (auto geomId : m_geomIds) + writeConnection("OO", geomId, m_meshModelId); + + // Materials → mesh model + for (const auto& [name, matId] : m_materialIds) + writeConnection("OO", matId, m_meshModelId); + + // Texture → Material (OP with "DiffuseColor") — connect to ALL materials that use each texture + { + std::set> texMatPairs; + for (const auto* sub : m_entity->getSubEntities()) + { + auto mat = sub->getMaterial(); + if (mat->getNumTechniques() == 0 || mat->getTechnique(0)->getNumPasses() == 0) continue; + auto* pass = mat->getTechnique(0)->getPass(0); + for (unsigned short ti = 0; ti < pass->getNumTextureUnitStates(); ++ti) + { + std::string texName = pass->getTextureUnitState(ti)->getTextureName(); + if (!texName.empty()) + texMatPairs.insert({texName, mat->getName()}); + } + } + for (const auto& [texName, matName] : texMatPairs) + { + auto texIt = m_textureIds.find(texName); + auto matIt = m_materialIds.find(matName); + if (texIt != m_textureIds.end() && matIt != m_materialIds.end()) + writeConnection("OP", texIt->second, matIt->second, "DiffuseColor"); + } + } + // Video → Texture (OO) + for (const auto& [texName, texId] : m_textureIds) + { + auto vidIt = m_videoIds.find(texName); + if (vidIt != m_videoIds.end()) + writeConnection("OO", vidIt->second, texId); + } + + if (m_hasSkeleton) + { + // Bone NodeAttribute → bone Model + for (const auto& [handle, attrId] : m_boneAttrIds) + writeConnection("OO", attrId, m_boneModelIds[handle]); + + // Bone hierarchy: root bones → root (id 0), child bones → parent bone model + for (unsigned short bi = 0; bi < m_skeleton->getNumBones(); ++bi) + { + auto* bone = m_skeleton->getBone(bi); + int64_t boneModelId = m_boneModelIds[bone->getHandle()]; + if (!bone->getParent()) + writeConnection("OO", boneModelId, 0); + else + { + auto* parentBone = dynamic_cast(bone->getParent()); + if (parentBone) + writeConnection("OO", boneModelId, m_boneModelIds[parentBone->getHandle()]); + } + } + + // Skin → Geometry + for (size_t i = 0; i < m_skinIds.size() && i < m_geomIds.size(); ++i) + writeConnection("OO", m_skinIds[i], m_geomIds[i]); + + // Cluster → Skin, Bone → Cluster + for (const auto& cc : m_clusterConnections) + { + writeConnection("OO", cc.clusterId, cc.skinId); + writeConnection("OO", m_boneModelIds[cc.boneHandle], cc.clusterId); + } + + // AnimationStack → scene root + for (auto stackId : m_animStackIds) + writeConnection("OO", stackId, 0); + + // Animation connections + for (const auto& [layerId, stackId] : m_animLayerToStack) + writeConnection("OO", layerId, stackId); + + for (const auto& acn : m_animCurveNodeConns) + { + writeConnection("OO", acn.curveNodeId, acn.layerId); + writeConnection("OP", acn.curveNodeId, acn.boneModelId, acn.property); + } + + for (const auto& ac : m_animCurveConns) + writeConnection("OP", ac.curveId, ac.curveNodeId, ac.channel); + } + + m_w.endNode(); // Connections + } + + void writeConnection(const std::string& type, int64_t child, int64_t parent, + const std::string& property = "") + { + m_w.beginNode("C"); + m_w.writePropertyS(type); + m_w.writePropertyL(child); + m_w.writePropertyL(parent); + if (!property.empty()) + m_w.writePropertyS(property); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + // ── P70 helpers ────────────────────────────────────────────── + void writeP70int(const std::string& name, int val) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("int"); + m_w.writePropertyS("Integer"); + m_w.writePropertyS(""); + m_w.writePropertyI(val); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70double(const std::string& name, double val) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("double"); + m_w.writePropertyS("Number"); + m_w.writePropertyS(""); + m_w.writePropertyD(val); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70enum(const std::string& name, int val) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("enum"); + m_w.writePropertyS(""); + m_w.writePropertyS(""); + m_w.writePropertyI(val); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70KTime(const std::string& name, int64_t val) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("KTime"); + m_w.writePropertyS("Time"); + m_w.writePropertyS(""); + m_w.writePropertyL(val); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70string(const std::string& name, const std::string& val) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("KString"); + m_w.writePropertyS(""); + m_w.writePropertyS(""); + m_w.writePropertyS(val); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70compound(const std::string& name, const std::string& val) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("Compound"); + m_w.writePropertyS(""); + m_w.writePropertyS(""); + m_w.writePropertyS(val); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70LclTranslation(double x, double y, double z) + { + m_w.beginNode("P"); + m_w.writePropertyS("Lcl Translation"); + m_w.writePropertyS("Lcl Translation"); + m_w.writePropertyS(""); + m_w.writePropertyS("A"); + m_w.writePropertyD(x); + m_w.writePropertyD(y); + m_w.writePropertyD(z); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70LclRotation(double x, double y, double z) + { + m_w.beginNode("P"); + m_w.writePropertyS("Lcl Rotation"); + m_w.writePropertyS("Lcl Rotation"); + m_w.writePropertyS(""); + m_w.writePropertyS("A"); + m_w.writePropertyD(x); + m_w.writePropertyD(y); + m_w.writePropertyD(z); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70LclScaling(double x, double y, double z) + { + m_w.beginNode("P"); + m_w.writePropertyS("Lcl Scaling"); + m_w.writePropertyS("Lcl Scaling"); + m_w.writePropertyS(""); + m_w.writePropertyS("A"); + m_w.writePropertyD(x); + m_w.writePropertyD(y); + m_w.writePropertyD(z); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70Color(const std::string& name, double r, double g, double b) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("Color"); + m_w.writePropertyS(""); + m_w.writePropertyS("A"); + m_w.writePropertyD(r); + m_w.writePropertyD(g); + m_w.writePropertyD(b); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + void writeP70Number(const std::string& name, double val) + { + m_w.beginNode("P"); + m_w.writePropertyS(name); + m_w.writePropertyS("Number"); + m_w.writePropertyS(""); + m_w.writePropertyS("A"); + m_w.writePropertyD(val); + m_w.endProperties(); + m_w.endNodeLeaf(); + } + + // ── Member data ────────────────────────────────────────────── + FBXBinaryWriter& m_w; + const Ogre::Entity* m_entity = nullptr; + const Ogre::Mesh* m_mesh = nullptr; + Ogre::Skeleton* m_skeleton = nullptr; + bool m_hasSkeleton = false; + + int64_t m_nextId = 1000000; + int64_t m_documentId = 100000; + int64_t m_meshModelId = 0; + + std::vector m_geomIds; + std::map m_materialIds; + std::map m_materialIndexMap; // matName → index (matching connection order) + std::map m_textureIds; + std::map m_videoIds; + std::map m_boneModelIds; + std::map m_boneAttrIds; + std::set m_bonesWithAssignments; + std::vector m_skinIds; + std::vector m_animStackIds; + + struct ClusterConnection { + int64_t clusterId; + int64_t skinId; + unsigned short boneHandle; + unsigned int submeshIndex; + }; + std::vector m_clusterConnections; + + std::vector> m_animLayerToStack; // layer→stack + + struct AnimCurveNodeConn { + int64_t curveNodeId; + int64_t layerId; + int64_t boneModelId; + std::string property; + }; + std::vector m_animCurveNodeConns; + + struct AnimCurveConn { + int64_t curveId; + int64_t curveNodeId; + std::string channel; + }; + std::vector m_animCurveConns; +}; + +// ═══════════════════════════════════════════════════════════════════ +// Public API +// ═══════════════════════════════════════════════════════════════════ + +bool FBXExporter::exportFBX(const Ogre::Entity* entity, const QString& filePath) +{ + if (!entity || filePath.isEmpty()) + return false; + + std::ofstream out(filePath.toStdString(), std::ios::binary); + if (!out.is_open()) + { + Ogre::LogManager::getSingleton().logError( + "FBXExporter: failed to open " + filePath.toStdString() + " for writing"); + return false; + } + + FBXBinaryWriter writer(out); + FBXDocumentBuilder builder(writer); + bool ok = builder.build(entity); + + out.close(); + + if (!ok) + { + Ogre::LogManager::getSingleton().logError( + "FBXExporter: failed to build FBX document for " + std::string(entity->getName())); + } + + return ok; +} diff --git a/src/FBX/FBXExporter.h b/src/FBX/FBXExporter.h new file mode 100644 index 000000000..4860f0c8b --- /dev/null +++ b/src/FBX/FBXExporter.h @@ -0,0 +1,41 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +----------------------------------------------------------------------------------- +*/ + +#ifndef FBXEXPORTER_H +#define FBXEXPORTER_H + +#include +#include + +class FBXExporter +{ +public: + static bool exportFBX(const Ogre::Entity* entity, const QString& filePath); +}; + +#endif // FBXEXPORTER_H diff --git a/src/FBX/FBXExporter_test.cpp b/src/FBX/FBXExporter_test.cpp new file mode 100644 index 000000000..8fd1d244d --- /dev/null +++ b/src/FBX/FBXExporter_test.cpp @@ -0,0 +1,404 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "FBXExporter.h" +#include "../Manager.h" +#include "../MeshImporterExporter.h" +#include "../TestHelpers.h" + +// ── Standalone tests (no Ogre needed) ──────────────────────────── + +TEST(FBXExporterStandaloneTest, ExportFBX_NullEntity_ReturnsFalse) { + EXPECT_FALSE(FBXExporter::exportFBX(nullptr, "/tmp/test.fbx")); +} + +TEST(FBXExporterStandaloneTest, ExportFBX_EmptyPath_ReturnsFalse) { + // Can't create a real entity without Ogre, but empty path should fail + EXPECT_FALSE(FBXExporter::exportFBX(nullptr, "")); +} + +// ── Euler decomposition tests ──────────────────────────────────── +// The FBX exporter decomposes quaternions to Euler XYZ angles where +// Assimp reconstructs as R = Rz * Ry * Rx (FBX RotOrder_EulerXYZ). +// These standalone tests replicate the decomposition and verify it +// round-trips correctly through Assimp's convention. + +namespace { + +// Replicate the static quaternionToEulerXYZ from FBXExporter.cpp +void testQuaternionToEulerXYZ(const Ogre::Quaternion& q, + double& rx, double& ry, double& rz) +{ + double w = q.w, x = q.x, y = q.y, z = q.z; + double sinp = std::clamp(2.0 * (w * y - x * z), -1.0, 1.0); + ry = std::asin(sinp); + + double cosp = std::cos(ry); + if (cosp > 1e-6) + { + rx = std::atan2(2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y)); + rz = std::atan2(2.0 * (x * y + w * z), 1.0 - 2.0 * (y * y + z * z)); + } + else + { + rz = 0.0; + rx = std::atan2(-(2.0 * (x * y - w * z)), 1.0 - 2.0 * (x * x + z * z)); + } + rx *= 180.0 / M_PI; + ry *= 180.0 / M_PI; + rz *= 180.0 / M_PI; +} + +// Reconstruct quaternion from Euler angles using Assimp's convention: +// R = Rz * Ry * Rx (same as FBXConverter::GetRotationMatrix for EulerXYZ) +Ogre::Quaternion eulerToQuatAssimp(double rxDeg, double ryDeg, double rzDeg) +{ + double rx = rxDeg * M_PI / 180.0; + double ry = ryDeg * M_PI / 180.0; + double rz = rzDeg * M_PI / 180.0; + + Ogre::Matrix3 mx, my, mz; + mx.FromAngleAxis(Ogre::Vector3::UNIT_X, Ogre::Radian(rx)); + my.FromAngleAxis(Ogre::Vector3::UNIT_Y, Ogre::Radian(ry)); + mz.FromAngleAxis(Ogre::Vector3::UNIT_Z, Ogre::Radian(rz)); + + // R = Rz * Ry * Rx + Ogre::Matrix3 combined = mz * my * mx; + Ogre::Quaternion result; + result.FromRotationMatrix(combined); + return result; +} + +// Check if two quaternions represent the same rotation (q and -q are equivalent) +bool quaternionsEqual(const Ogre::Quaternion& a, const Ogre::Quaternion& b, double tol = 1e-4) +{ + double dot = a.w * b.w + a.x * b.x + a.y * b.y + a.z * b.z; + return std::abs(std::abs(dot) - 1.0) < tol; +} + +} // anonymous namespace + +TEST(FBXEulerTest, Identity) { + Ogre::Quaternion q = Ogre::Quaternion::IDENTITY; + double rx, ry, rz; + testQuaternionToEulerXYZ(q, rx, ry, rz); + EXPECT_NEAR(rx, 0.0, 0.01); + EXPECT_NEAR(ry, 0.0, 0.01); + EXPECT_NEAR(rz, 0.0, 0.01); +} + +TEST(FBXEulerTest, PureXRotation_90) { + Ogre::Quaternion q(Ogre::Radian(Ogre::Degree(90)), Ogre::Vector3::UNIT_X); + double rx, ry, rz; + testQuaternionToEulerXYZ(q, rx, ry, rz); + EXPECT_NEAR(rx, 90.0, 0.01); + EXPECT_NEAR(ry, 0.0, 0.01); + EXPECT_NEAR(rz, 0.0, 0.01); +} + +TEST(FBXEulerTest, PureYRotation_45) { + Ogre::Quaternion q(Ogre::Radian(Ogre::Degree(45)), Ogre::Vector3::UNIT_Y); + double rx, ry, rz; + testQuaternionToEulerXYZ(q, rx, ry, rz); + EXPECT_NEAR(rx, 0.0, 0.01); + EXPECT_NEAR(ry, 45.0, 0.01); + EXPECT_NEAR(rz, 0.0, 0.01); +} + +TEST(FBXEulerTest, PureZRotation_60) { + Ogre::Quaternion q(Ogre::Radian(Ogre::Degree(60)), Ogre::Vector3::UNIT_Z); + double rx, ry, rz; + testQuaternionToEulerXYZ(q, rx, ry, rz); + EXPECT_NEAR(rx, 0.0, 0.01); + EXPECT_NEAR(ry, 0.0, 0.01); + EXPECT_NEAR(rz, 60.0, 0.01); +} + +TEST(FBXEulerTest, CombinedRotation_RoundTrip) { + // 30° X then 45° Y (intrinsic) = qx * qy + Ogre::Quaternion qx(Ogre::Radian(Ogre::Degree(30)), Ogre::Vector3::UNIT_X); + Ogre::Quaternion qy(Ogre::Radian(Ogre::Degree(45)), Ogre::Vector3::UNIT_Y); + Ogre::Quaternion q = qx * qy; // combined rotation + + double rx, ry, rz; + testQuaternionToEulerXYZ(q, rx, ry, rz); + + // Reconstruct using Assimp's convention: R = Rz * Ry * Rx + Ogre::Quaternion reconstructed = eulerToQuatAssimp(rx, ry, rz); + EXPECT_TRUE(quaternionsEqual(q, reconstructed)) + << "Original: (" << q.w << "," << q.x << "," << q.y << "," << q.z << ")" + << " Reconstructed: (" << reconstructed.w << "," << reconstructed.x + << "," << reconstructed.y << "," << reconstructed.z << ")"; +} + +TEST(FBXEulerTest, ArbitraryRotation_RoundTrip) { + // Arbitrary rotation: 25° X, 50° Y, 35° Z via Assimp convention + Ogre::Quaternion original = eulerToQuatAssimp(25.0, 50.0, 35.0); + + double rx, ry, rz; + testQuaternionToEulerXYZ(original, rx, ry, rz); + + Ogre::Quaternion reconstructed = eulerToQuatAssimp(rx, ry, rz); + EXPECT_TRUE(quaternionsEqual(original, reconstructed)) + << "Euler angles: (" << rx << "," << ry << "," << rz << ")"; +} + +TEST(FBXEulerTest, NegativeAngles_RoundTrip) { + Ogre::Quaternion original = eulerToQuatAssimp(-30.0, 15.0, -120.0); + + double rx, ry, rz; + testQuaternionToEulerXYZ(original, rx, ry, rz); + + Ogre::Quaternion reconstructed = eulerToQuatAssimp(rx, ry, rz); + EXPECT_TRUE(quaternionsEqual(original, reconstructed)); +} + +TEST(FBXEulerTest, LargeAngles_RoundTrip) { + Ogre::Quaternion original = eulerToQuatAssimp(170.0, -80.0, 160.0); + + double rx, ry, rz; + testQuaternionToEulerXYZ(original, rx, ry, rz); + + Ogre::Quaternion reconstructed = eulerToQuatAssimp(rx, ry, rz); + EXPECT_TRUE(quaternionsEqual(original, reconstructed)); +} + +TEST(FBXEulerTest, NearGimbalLock_RoundTrip) { + // Near gimbal lock: ry close to 90° + Ogre::Quaternion original = eulerToQuatAssimp(10.0, 89.0, 20.0); + + double rx, ry, rz; + testQuaternionToEulerXYZ(original, rx, ry, rz); + + Ogre::Quaternion reconstructed = eulerToQuatAssimp(rx, ry, rz); + EXPECT_TRUE(quaternionsEqual(original, reconstructed)); +} + +TEST(FBXEulerTest, ManyRandomRotations_RoundTrip) { + // Test a grid of rotations to verify decomposition works broadly + int failures = 0; + for (int ax = -150; ax <= 150; ax += 30) { + for (int ay = -80; ay <= 80; ay += 20) { + for (int az = -150; az <= 150; az += 30) { + Ogre::Quaternion original = eulerToQuatAssimp(ax, ay, az); + double rx, ry, rz; + testQuaternionToEulerXYZ(original, rx, ry, rz); + Ogre::Quaternion reconstructed = eulerToQuatAssimp(rx, ry, rz); + if (!quaternionsEqual(original, reconstructed)) { + failures++; + } + } + } + } + EXPECT_EQ(failures, 0) << failures << " rotation(s) failed round-trip"; +} + +// ── Euler continuity (unrolling) tests ─────────────────────────── + +TEST(FBXEulerContinuityTest, UnrollPreventsBigJump) { + // Simulate two consecutive Euler angles that jump across 360° boundary + // The unroll lambda from the exporter: + auto unroll = [](double prev, double cur) { + double d = cur - prev; + if (d > 180.0) cur -= 360.0 * std::ceil((d - 180.0) / 360.0); + else if (d < -180.0) cur += 360.0 * std::ceil((-d - 180.0) / 360.0); + return cur; + }; + + // Jump from 170° to -170° (should become 190°, delta = 20°) + EXPECT_NEAR(unroll(170.0, -170.0), 190.0, 0.01); + + // Jump from -170° to 170° (should become -190°, delta = -20°) + EXPECT_NEAR(unroll(-170.0, 170.0), -190.0, 0.01); + + // No jump needed: 10° to 30° + EXPECT_NEAR(unroll(10.0, 30.0), 30.0, 0.01); + + // Large jump: 350° to 10° (should become 370°) + EXPECT_NEAR(unroll(350.0, 10.0), 370.0, 0.01); + + // Jump from 10° to 350° (should become -10°) + EXPECT_NEAR(unroll(10.0, 350.0), -10.0, 0.01); +} + +// ── Tests requiring Ogre ───────────────────────────────────────── + +class FBXExporterTest : public ::testing::Test { +protected: + QApplication* app = nullptr; + + void SetUp() override { + Manager::kill(); + QThread::msleep(50); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + if (!tryInitOgre()) { + GTEST_SKIP() << "Skipping: Ogre initialization failed"; + } + createStandardOgreMaterials(); + } + + void TearDown() override { + Manager::kill(); + + if (app) { + app->processEvents(); + } + QThread::msleep(50); + } +}; + +TEST_F(FBXExporterTest, ExportFBX_InvalidPath_ReturnsFalse) { + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + } + + QStringList uri{"./media/models/Rumba Dancing.fbx"}; + MeshImporterExporter::importer(uri); + auto* sn = Manager::getSingleton()->getSceneNodes().last(); + auto* entity = Manager::getSingleton()->getSceneMgr()->getEntity(sn->getName()); + + EXPECT_FALSE(FBXExporter::exportFBX(entity, "/nonexistent_dir/sub/test.fbx")); +} + +TEST_F(FBXExporterTest, ExportFBX_BinaryHeader) { + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + } + + QStringList uri{"./media/models/Rumba Dancing.fbx"}; + MeshImporterExporter::importer(uri); + auto* sn = Manager::getSingleton()->getSceneNodes().last(); + auto* entity = Manager::getSingleton()->getSceneMgr()->getEntity(sn->getName()); + + QString outPath = "./fbx_header_test.fbx"; + ASSERT_TRUE(FBXExporter::exportFBX(entity, outPath)); + + // Verify FBX binary header + std::ifstream in(outPath.toStdString(), std::ios::binary); + ASSERT_TRUE(in.is_open()); + + char magic[21]; + in.read(magic, 21); + EXPECT_EQ(std::string(magic, 20), "Kaydara FBX Binary "); + EXPECT_EQ(magic[20], '\0'); + + char pad[2]; + in.read(pad, 2); + EXPECT_EQ(pad[0], '\x1A'); + EXPECT_EQ(pad[1], '\x00'); + + uint32_t version; + in.read(reinterpret_cast(&version), 4); + EXPECT_EQ(version, 7300u); + + in.close(); + QFile::remove(outPath); +} + +TEST_F(FBXExporterTest, ExportFBX_NonZeroFileSize) { + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + } + + QStringList uri{"./media/models/Rumba Dancing.fbx"}; + MeshImporterExporter::importer(uri); + auto* sn = Manager::getSingleton()->getSceneNodes().last(); + auto* entity = Manager::getSingleton()->getSceneMgr()->getEntity(sn->getName()); + + QString outPath = "./fbx_size_test.fbx"; + ASSERT_TRUE(FBXExporter::exportFBX(entity, outPath)); + + QFile file(outPath); + EXPECT_TRUE(file.exists()); + EXPECT_GT(file.size(), 1000); // Should be a substantial file + + QFile::remove(outPath); +} + +TEST_F(FBXExporterTest, ExportFBX_WithSkeleton) { + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + } + + QStringList uri{"./media/models/Rumba Dancing.fbx"}; + MeshImporterExporter::importer(uri); + auto* sn = Manager::getSingleton()->getSceneNodes().last(); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* entity = sceneMgr->getEntity(sn->getName()); + ASSERT_TRUE(entity->hasSkeleton()); + + QString outPath = "./fbx_skeleton_test.fbx"; + ASSERT_TRUE(FBXExporter::exportFBX(entity, outPath)); + + QFile file(outPath); + EXPECT_GT(file.size(), 5000); // Skeleton data should make it larger + + QFile::remove(outPath); +} + +TEST_F(FBXExporterTest, ExportFBX_ViaMeshImporterExporter) { + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + } + + QStringList uri{"./media/models/Rumba Dancing.fbx"}; + MeshImporterExporter::importer(uri); + auto* sn = Manager::getSingleton()->getSceneNodes().last(); + + QString outPath = "./fbx_integration_test.fbx"; + int result = MeshImporterExporter::exporter(sn, outPath, "FBX Binary (*.fbx)"); + EXPECT_EQ(result, 0); + + QFile file(outPath); + EXPECT_TRUE(file.exists()); + EXPECT_GT(file.size(), 1000); + + QFile::remove(outPath); + QFile::remove("./fbx_integration_test.material"); +} + +TEST_F(FBXExporterTest, ExportFBX_SimpleMesh) { + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + } + + // Import a simple mesh without skeleton (the Twist Dance also has skeleton, + // but let's create a simple cube to test non-skeleton path) + QStringList uri{"./media/models/Twist Dance.fbx"}; + MeshImporterExporter::importer(uri); + auto* sn = Manager::getSingleton()->getSceneNodes().last(); + auto* entity = Manager::getSingleton()->getSceneMgr()->getEntity(sn->getName()); + + QString outPath = "./fbx_simple_test.fbx"; + ASSERT_TRUE(FBXExporter::exportFBX(entity, outPath)); + + QFile file(outPath); + EXPECT_TRUE(file.exists()); + EXPECT_GT(file.size(), 100); + + QFile::remove(outPath); +} + +TEST(FBXExporterStandaloneTest, ExportFBX_FormatFileURI) { + QString uri = "/path/to/file"; + QString format = "FBX Binary (*.fbx)"; + EXPECT_EQ(MeshImporterExporter::formatFileURI(uri, format), "/path/to/file.fbx"); + + // Already has extension + uri = "/path/to/file.fbx"; + EXPECT_EQ(MeshImporterExporter::formatFileURI(uri, format), "/path/to/file.fbx"); +} diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 961131f63..d4ff3a91f 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -2165,7 +2165,8 @@ QJsonArray MCPServer::buildToolsList() "'Ogre Mesh v1.7+(*.mesh)', 'Ogre Mesh v1.4+(*.mesh)', 'Ogre Mesh v1.0+(*.mesh)', " "'Ogre XML (*.mesh.xml)', 'Collada (*.dae)', 'X (*.x)', 'OBJ (*.obj)', " "'OBJ without MTL (*.objnomtl)', 'STL (*.stl)', 'PLY (*.ply)', '3DS (*.3ds)', " - "'glTF 2.0 (*.gltf2)', 'glTF 2.0 Binary (*.glb2)', 'Assimp Binary (*.assbin)'. " + "'glTF 2.0 (*.gltf2)', 'glTF 2.0 Binary (*.glb2)', 'Assimp Binary (*.assbin)', " + "'FBX Binary (*.fbx)'. " "Default: 'Ogre Mesh (*.mesh)'"}}; inputSchema["properties"] = properties; inputSchema["required"] = QJsonArray{"path"}; diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 4dad2fc27..f8cc165b1 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -65,7 +65,8 @@ const QMap MeshImporterExporter::exportFormats = { {"3DS (*.3ds)", ".3ds"}, {"glTF 2.0 (*.gltf2)", ".gltf2"}, {"glTF 2.0 Binary (*.glb2)", ".glb2"}, - {"Assimp Binary (*.assbin)", ".assbin"} + {"Assimp Binary (*.assbin)", ".assbin"}, + {"FBX Binary (*.fbx)", ".fbx"} }; void MeshImporterExporter::configureCamera(const Ogre::Entity *en) @@ -944,6 +945,12 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u m.exportMesh(e->getMesh().get(),_uri.toStdString().data(),(Ogre::MeshVersion)version); exportMaterial(e, file); + } else if (_format == "FBX Binary (*.fbx)") { + bool ok = FBXExporter::exportFBX(e, _uri); + if (ok) + exportMaterial(e, file); + else + return -1; } else { // Export using Assimp — build aiScene directly from Ogre mesh data try { diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index 46988f7ca..0fa517e87 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -34,6 +34,7 @@ THE SOFTWARE. #include #include "mainwindow.h" +#include "FBX/FBXExporter.h" class MeshImporterExporter { diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index a53e2bdd5..9b86fc77b 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -115,7 +115,7 @@ TEST(MeshImporterExporterStandaloneTest, FormatFileURI_UnknownFormat_ReturnsURIW } TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ReturnsFilterString) { - QString expected = "3DS (*.3ds);;Assimp Binary (*.assbin);;Collada (*.dae);;OBJ (*.obj);;OBJ without MTL (*.objnomtl);;Ogre Mesh (*.mesh);;Ogre Mesh v1.0+(*.mesh);;Ogre Mesh v1.10+(*.mesh);;Ogre Mesh v1.4+(*.mesh);;Ogre Mesh v1.7+(*.mesh);;Ogre Mesh v1.8+(*.mesh);;Ogre XML (*.mesh.xml);;PLY (*.ply);;STL (*.stl);;X (*.x);;glTF 2.0 (*.gltf2);;glTF 2.0 Binary (*.glb2)"; + QString expected = "3DS (*.3ds);;Assimp Binary (*.assbin);;Collada (*.dae);;FBX Binary (*.fbx);;OBJ (*.obj);;OBJ without MTL (*.objnomtl);;Ogre Mesh (*.mesh);;Ogre Mesh v1.0+(*.mesh);;Ogre Mesh v1.10+(*.mesh);;Ogre Mesh v1.4+(*.mesh);;Ogre Mesh v1.7+(*.mesh);;Ogre Mesh v1.8+(*.mesh);;Ogre XML (*.mesh.xml);;PLY (*.ply);;STL (*.stl);;X (*.x);;glTF 2.0 (*.gltf2);;glTF 2.0 Binary (*.glb2)"; QString result = MeshImporterExporter::exportFileDialogFilter(); From a1a87e7039f6e0aa57bdc8c7df40e5ac83341027 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 3 Mar 2026 13:42:26 -0400 Subject: [PATCH 2/7] Update docs to list FBX as a supported export format Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37788fd09..8dcbe7a1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,8 @@ Three singletons manage core state. All run on the main thread. Access via `Clas ### Mesh Import/Export -- **MeshImporterExporter** (`src/MeshImporterExporter.h/cpp`): Static methods. Supports .mesh, .obj, .dae, .gltf via custom Assimp processors in `src/Assimp/`. +- **MeshImporterExporter** (`src/MeshImporterExporter.h/cpp`): Static methods. Supports .mesh, .obj, .dae, .gltf, .fbx via custom Assimp processors in `src/Assimp/`. +- **FBXExporter** (`src/FBX/FBXExporter.h/cpp`): Custom FBX Binary v7300 exporter that writes directly from Ogre data. Handles geometry, skeleton, skin deformers, animations, and materials. Replaces Assimp's broken FBX exporter. ### Local LLM diff --git a/README.md b/README.md index 90830bb09..e4d7a42d9 100755 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ QtMeshEditor helps you prepare 3D assets for your game or project: | Format | Extension | Import | Export | Skeleton/Animation | |--------|-----------|--------|--------|--------------------| -| FBX | .fbx | Yes | No | Yes | +| FBX Binary | .fbx | Yes | Yes | Yes | | glTF 2.0 | .gltf2 | Yes | Yes | Yes | | glTF 2.0 Binary | .glb2 | Yes | Yes | Yes | | Collada | .dae | Yes | Yes | Yes | From 643c90f75043463be8af1f926f0f6743053e662e Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 3 Mar 2026 13:44:50 -0400 Subject: [PATCH 3/7] Bump version to 2.9.0 for FBX Binary export feature Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index dabfdedfd..e3e35a609 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 2.8.1 LANGUAGES CXX) +project(QtMeshEditor VERSION 2.9.0 LANGUAGES CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") From 99c43feefab686ead3cc13b0cd344f6b21e3b9dc Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 3 Mar 2026 16:12:04 -0400 Subject: [PATCH 4/7] Fix CI build: add FBXExporter sources to tests/CMakeLists.txt The separate test targets in tests/ have their own source file lists and were missing FBXExporter.cpp, causing undefined reference errors. Co-Authored-By: Claude Opus 4.6 --- tests/CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0d978f765..72414b9f7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -183,6 +183,15 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/Assimp/MeshProcessor.h ) + # Add FBX sources (matching src/FBX/CMakeLists.txt) + set(TEST_SRC_FILES ${TEST_SRC_FILES} + ${CMAKE_CURRENT_SOURCE_DIR}/../src/FBX/FBXExporter.cpp + ) + + set(TEST_HEADER_FILES ${TEST_HEADER_FILES} + ${CMAKE_CURRENT_SOURCE_DIR}/../src/FBX/FBXExporter.h + ) + # Add Qt resources (matching src/CMakeLists.txt) qt_add_resources(TEST_RESOURCE_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/../resources/resource.qrc") qt_add_resources(TEST_QML_RESOURCE_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/../src/qml_resources.qrc") @@ -239,6 +248,7 @@ if(BUILD_TESTS) ${OGRE_PROCEDURAL_LIB_DIR}include ${CMAKE_CURRENT_SOURCE_DIR}/../src/OgreXML ${CMAKE_CURRENT_SOURCE_DIR}/../src/Assimp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/FBX ) target_link_libraries(${target_name} ${COMMON_TEST_LIBRARIES}) @@ -268,6 +278,7 @@ if(BUILD_TESTS) ${OGRE_PROCEDURAL_LIB_DIR}include ${CMAKE_CURRENT_SOURCE_DIR}/../src/OgreXML ${CMAKE_CURRENT_SOURCE_DIR}/../src/Assimp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/FBX ) target_link_libraries(${target_name} ${COMMON_TEST_LIBRARIES}) From 0e87d72307720d49c62b2a1b92df3a1cb9ab5d87 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 3 Mar 2026 16:21:06 -0400 Subject: [PATCH 5/7] Fix submesh-to-geometry mapping in skin deformers When a submesh was skipped in writeGeometryObjects (empty vertex data), m_geomIds had fewer entries than the raw submesh count, causing writeSkinDeformers to map skin data to the wrong geometry. Track the actual submesh index for each geometry entry and iterate over geometry entries instead of raw submesh indices. Co-Authored-By: Claude Opus 4.6 --- src/FBX/FBXExporter.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp index b3a1ddb7c..e20f227c6 100644 --- a/src/FBX/FBXExporter.cpp +++ b/src/FBX/FBXExporter.cpp @@ -797,6 +797,7 @@ class FBXDocumentBuilder int64_t geomId = nextId(); m_geomIds.push_back(geomId); + m_geomSubmeshIndices.push_back(si); std::string geomName = std::string(m_entity->getName()) + "_submesh" + std::to_string(si); @@ -1227,9 +1228,9 @@ class FBXDocumentBuilder // ── Skin Deformers ─────────────────────────────────────────── void writeSkinDeformers() { - for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) + for (size_t gi = 0; gi < m_geomIds.size(); ++gi) { - if (si >= m_geomIds.size()) break; + unsigned int si = m_geomSubmeshIndices[gi]; const Ogre::SubMesh* subMesh = m_mesh->getSubMesh(si); int64_t skinId = nextId(); @@ -1932,6 +1933,7 @@ class FBXDocumentBuilder int64_t m_meshModelId = 0; std::vector m_geomIds; + std::vector m_geomSubmeshIndices; // submesh index for each entry in m_geomIds std::map m_materialIds; std::map m_materialIndexMap; // matName → index (matching connection order) std::map m_textureIds; From ddab98bd5853a312a90691bf205fc810a7afc8e9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 3 Mar 2026 17:01:28 -0400 Subject: [PATCH 6/7] Fix AnimationMergerTest CI failures by using in-memory meshes MeshManager::create() leaves meshes unloaded, causing FileNotFoundException when createEntity() tries to load from disk in CI. Switch to createManual() with minimal vertex/index data so meshes are fully loaded in memory. Co-Authored-By: Claude Opus 4.6 --- src/AnimationMerger_test.cpp | 62 ++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index caa3fe8b9..e1d522cc9 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include class AnimationMergerTest : public ::testing::Test { protected: @@ -31,6 +32,43 @@ class AnimationMergerTest : public ::testing::Test { QApplication* app = nullptr; + // Helper: create an in-memory mesh with a minimal triangle so Ogre + // doesn't try to load the resource from disk when creating an Entity. + Ogre::MeshPtr createInMemoryMesh(const std::string& name, + const Ogre::SkeletonPtr& skel) + { + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,0, 1,0,0, 0,1,0}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_notifySkeleton(skel); + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,1,1,1)); + mesh->_setBoundingSphereRadius(1.0); + mesh->load(); + + return mesh; + } + // Helper: create a skeleton with given bone names and animations Ogre::SkeletonPtr createTestSkeleton(const std::string& name, const std::vector& boneNames, @@ -97,20 +135,12 @@ TEST_F(AnimationMergerTest, NullSkeletons) TEST_F(AnimationMergerTest, MergeAnimationsBasic) { - if (!canLoadMeshFiles()) - GTEST_SKIP() << "Skipping: cannot load mesh files in this environment"; - // Create two meshes sharing compatible skeletons auto skelA = createTestSkeleton("merge_skel_a", {"root", "spine"}, {"idle"}); auto skelB = createTestSkeleton("merge_skel_b", {"root", "spine"}, {"walk"}); - auto meshA = Ogre::MeshManager::getSingleton().create("merge_mesh_a", - Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - meshA->_notifySkeleton(skelA); - - auto meshB = Ogre::MeshManager::getSingleton().create("merge_mesh_b", - Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - meshB->_notifySkeleton(skelB); + auto meshA = createInMemoryMesh("merge_mesh_a", skelA); + auto meshB = createInMemoryMesh("merge_mesh_b", skelB); auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); auto* nodeA = sceneMgr->getRootSceneNode()->createChildSceneNode("baseNode"); @@ -148,20 +178,12 @@ TEST_F(AnimationMergerTest, MergeAnimationsBasic) TEST_F(AnimationMergerTest, MergeAnimationsNameCollision) { - if (!canLoadMeshFiles()) - GTEST_SKIP() << "Skipping: cannot load mesh files in this environment"; - // Both skeletons have an animation that would result in the same name auto skelA = createTestSkeleton("collision_skel_a", {"root"}, {"idle"}); auto skelB = createTestSkeleton("collision_skel_b", {"root"}, {"idle"}); - auto meshA = Ogre::MeshManager::getSingleton().create("collision_mesh_a", - Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - meshA->_notifySkeleton(skelA); - - auto meshB = Ogre::MeshManager::getSingleton().create("collision_mesh_b", - Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - meshB->_notifySkeleton(skelB); + auto meshA = createInMemoryMesh("collision_mesh_a", skelA); + auto meshB = createInMemoryMesh("collision_mesh_b", skelB); auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); // Name the node "idle" so it collides with base's "idle" animation From 7b6898d73e201c9191726745d237b990b567d203 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 3 Mar 2026 22:26:08 -0400 Subject: [PATCH 7/7] Add comprehensive FBX exporter test coverage with in-memory meshes Add 36 new test cases in FBXExporterCoverageTest that use in-memory Ogre meshes (no file loading) to exercise ~98% of FBXExporter.cpp. Includes a lightweight FBX binary parser for output verification and 8 mesh helper functions covering: geometry (Z-mirror, winding, normals, UVs, 32-bit indices, non-shared vertices), materials, skeleton (bone transforms, deforming/non-deforming, hierarchy), skin deformers, animations (stacks, curves, Euler continuity), bind pose, textures, and connections. Co-Authored-By: Claude Opus 4.6 --- src/FBX/FBXExporter_test.cpp | 1969 ++++++++++++++++++++++++++++++++++ 1 file changed, 1969 insertions(+) diff --git a/src/FBX/FBXExporter_test.cpp b/src/FBX/FBXExporter_test.cpp index 8fd1d244d..caf54beab 100644 --- a/src/FBX/FBXExporter_test.cpp +++ b/src/FBX/FBXExporter_test.cpp @@ -3,15 +3,27 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include +#include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include #include "FBXExporter.h" #include "../Manager.h" #include "../MeshImporterExporter.h" @@ -402,3 +414,1960 @@ TEST(FBXExporterStandaloneTest, ExportFBX_FormatFileURI) { uri = "/path/to/file.fbx"; EXPECT_EQ(MeshImporterExporter::formatFileURI(uri, format), "/path/to/file.fbx"); } + +// ═══════════════════════════════════════════════════════════════════ +// Lightweight FBX Binary Parser (for test verification) +// ═══════════════════════════════════════════════════════════════════ + +namespace { + +struct FBXProperty { + char type = 0; + bool boolVal = false; + int32_t intVal = 0; + int64_t longVal = 0; + float floatVal = 0; + double doubleVal = 0; + std::string stringVal; + std::vector doubleArray; + std::vector intArray; + std::vector floatArray; + std::vector longArray; +}; + +struct FBXNode { + std::string name; + std::vector properties; + std::vector children; + + const FBXNode* find(const std::string& n) const { + for (const auto& c : children) + if (c.name == n) return &c; + return nullptr; + } + + std::vector findAll(const std::string& n) const { + std::vector result; + for (const auto& c : children) + if (c.name == n) result.push_back(&c); + return result; + } +}; + +FBXProperty readProperty(std::ifstream& in) +{ + FBXProperty p; + in.read(&p.type, 1); + switch (p.type) { + case 'C': { uint8_t v; in.read(reinterpret_cast(&v), 1); p.boolVal = v != 0; break; } + case 'I': in.read(reinterpret_cast(&p.intVal), 4); break; + case 'L': in.read(reinterpret_cast(&p.longVal), 8); break; + case 'F': in.read(reinterpret_cast(&p.floatVal), 4); break; + case 'D': in.read(reinterpret_cast(&p.doubleVal), 8); break; + case 'S': case 'R': { + uint32_t len; in.read(reinterpret_cast(&len), 4); + p.stringVal.resize(len); + in.read(p.stringVal.data(), len); + break; + } + case 'd': { + uint32_t count; in.read(reinterpret_cast(&count), 4); + uint32_t encoding; in.read(reinterpret_cast(&encoding), 4); + uint32_t byteLen; in.read(reinterpret_cast(&byteLen), 4); + p.doubleArray.resize(count); + in.read(reinterpret_cast(p.doubleArray.data()), byteLen); + break; + } + case 'i': { + uint32_t count; in.read(reinterpret_cast(&count), 4); + uint32_t encoding; in.read(reinterpret_cast(&encoding), 4); + uint32_t byteLen; in.read(reinterpret_cast(&byteLen), 4); + p.intArray.resize(count); + in.read(reinterpret_cast(p.intArray.data()), byteLen); + break; + } + case 'f': { + uint32_t count; in.read(reinterpret_cast(&count), 4); + uint32_t encoding; in.read(reinterpret_cast(&encoding), 4); + uint32_t byteLen; in.read(reinterpret_cast(&byteLen), 4); + p.floatArray.resize(count); + in.read(reinterpret_cast(p.floatArray.data()), byteLen); + break; + } + case 'l': { + uint32_t count; in.read(reinterpret_cast(&count), 4); + uint32_t encoding; in.read(reinterpret_cast(&encoding), 4); + uint32_t byteLen; in.read(reinterpret_cast(&byteLen), 4); + p.longArray.resize(count); + in.read(reinterpret_cast(p.longArray.data()), byteLen); + break; + } + default: break; + } + return p; +} + +FBXNode readNode(std::ifstream& in) +{ + FBXNode node; + uint32_t endOffset, numProps, propListLen; + in.read(reinterpret_cast(&endOffset), 4); + in.read(reinterpret_cast(&numProps), 4); + in.read(reinterpret_cast(&propListLen), 4); + uint8_t nameLen; + in.read(reinterpret_cast(&nameLen), 1); + node.name.resize(nameLen); + in.read(node.name.data(), nameLen); + + for (uint32_t i = 0; i < numProps; ++i) + node.properties.push_back(readProperty(in)); + + // Read child nodes until endOffset + while (static_cast(in.tellg()) < endOffset) { + // Check for null record (13 zero bytes) + auto pos = in.tellg(); + uint32_t testEnd; + in.read(reinterpret_cast(&testEnd), 4); + if (testEnd == 0) { + // Likely null sentinel — skip remaining 9 bytes + in.seekg(pos); + char sentinel[13]; + in.read(sentinel, 13); + break; + } + in.seekg(pos); + node.children.push_back(readNode(in)); + } + + // Ensure we're at endOffset + in.seekg(endOffset); + return node; +} + +std::vector parseFBX(const std::string& path) +{ + std::vector nodes; + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) return nodes; + + // Skip 27-byte header + in.seekg(27); + + while (in.good()) { + auto pos = in.tellg(); + uint32_t endOffset; + in.read(reinterpret_cast(&endOffset), 4); + if (endOffset == 0) break; // null sentinel = end of top-level nodes + in.seekg(pos); + nodes.push_back(readNode(in)); + } + return nodes; +} + +const FBXNode* findTopLevel(const std::vector& nodes, const std::string& name) { + for (const auto& n : nodes) + if (n.name == name) return &n; + return nullptr; +} + +// Recursively find all nodes with a given name +void findAllRecursive(const FBXNode& node, const std::string& name, + std::vector& result) { + if (node.name == name) result.push_back(&node); + for (const auto& c : node.children) + findAllRecursive(c, name, result); +} + +std::vector findAllInTree(const std::vector& nodes, + const std::string& name) { + std::vector result; + for (const auto& n : nodes) + findAllRecursive(n, name, result); + return result; +} + +// Find P (property) nodes with a given first property string +const FBXNode* findP70(const FBXNode& props70, const std::string& propName) { + for (const auto& p : props70.children) { + if (p.name == "P" && !p.properties.empty() && p.properties[0].stringVal == propName) + return &p; + } + return nullptr; +} + +} // anonymous namespace (FBX parser) + +// ═══════════════════════════════════════════════════════════════════ +// In-Memory Mesh Coverage Tests +// ═══════════════════════════════════════════════════════════════════ + +class FBXExporterCoverageTest : public ::testing::Test { +protected: + QApplication* app = nullptr; + int meshCounter = 0; + + void SetUp() override { + Manager::kill(); + QThread::msleep(50); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + if (!tryInitOgre()) { + GTEST_SKIP() << "Skipping: Ogre initialization failed"; + } + createStandardOgreMaterials(); + } + + void TearDown() override { + Manager::kill(); + if (app) app->processEvents(); + QThread::msleep(50); + } + + std::string uniqueName(const std::string& base) { + return base + "_" + std::to_string(meshCounter++); + } + + // Export entity to temp file and parse the FBX + struct ExportResult { + std::vector nodes; + QString path; + bool success = false; + }; + + ExportResult exportAndParse(Ogre::Entity* entity) { + ExportResult r; + r.path = QString("/tmp/fbx_coverage_%1.fbx").arg(meshCounter); + r.success = FBXExporter::exportFBX(entity, r.path); + if (r.success) + r.nodes = parseFBX(r.path.toStdString()); + return r; + } + + void cleanup(const ExportResult& r) { + QFile::remove(r.path); + } + + // ── Mesh creation helpers ─────────────────────────────────── + + // Triangle with positions + normals + UVs, 16-bit indices, shared vertex data + Ogre::Entity* createSimpleMesh(const std::string& name) { + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + size_t offset = 0; + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, offset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + // pos(3) + normal(3) + uv(2) = 8 floats per vertex + float verts[] = { + 0,0,0, 0,0,1, 0.0f,0.0f, + 1,0,0, 0,0,1, 1.0f,0.0f, + 0,1,0, 0,0,1, 0.0f,1.0f, + }; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,1,1,1)); + mesh->_setBoundingSphereRadius(2.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // Triangle with positions only (no normals, no UVs) + Ogre::Entity* createMeshNoNormalsNoUVs(const std::string& name) { + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,0, 1,0,0, 0,1,0}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,1,1,1)); + mesh->_setBoundingSphereRadius(2.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // Triangle with per-submesh vertex data (useSharedVertices=false) + Ogre::Entity* createMeshNonShared(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 offset = 0; + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = { + 2,0,0, 0,1,0, + 3,0,0, 0,1,0, + 2,1,0, 0,1,0, + }; + 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,4,2,1)); + mesh->_setBoundingSphereRadius(4.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // Triangle with 32-bit index buffer + Ogre::Entity* createMesh32BitIndices(const std::string& name) { + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,0, 1,0,0, 0,1,0}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->vertexCount = 3; + + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_32BIT, 3, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint32_t idx[] = {0, 1, 2}; + ibuf->writeData(0, sizeof(idx), idx); + sub->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,1,1,1)); + mesh->_setBoundingSphereRadius(2.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // 2 submeshes with different materials + Ogre::Entity* createMultiSubmeshMesh(const std::string& name) { + // Create two materials + auto matA = Ogre::MaterialManager::getSingleton().create( + name + "_matA", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + matA->getTechnique(0)->getPass(0)->setDiffuse(1.0f, 0.0f, 0.0f, 1.0f); + + auto matB = Ogre::MaterialManager::getSingleton().create( + name + "_matB", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + matB->getTechnique(0)->getPass(0)->setDiffuse(0.0f, 0.0f, 1.0f, 1.0f); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 6, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = { + 0,0,0, 1,0,0, 0,1,0, // triangle 1 + 2,0,0, 3,0,0, 2,1,0, // triangle 2 + }; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->vertexCount = 6; + + // Submesh 0 + auto* sub0 = mesh->createSubMesh(); + sub0->useSharedVertices = true; + sub0->setMaterialName(name + "_matA"); + auto ibuf0 = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t idx0[] = {0, 1, 2}; + ibuf0->writeData(0, sizeof(idx0), idx0); + sub0->indexData->indexBuffer = ibuf0; + sub0->indexData->indexCount = 3; + + // Submesh 1 + auto* sub1 = mesh->createSubMesh(); + sub1->useSharedVertices = true; + sub1->setMaterialName(name + "_matB"); + auto ibuf1 = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t idx1[] = {3, 4, 5}; + ibuf1->writeData(0, sizeof(idx1), idx1); + sub1->indexData->indexBuffer = ibuf1; + sub1->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,4,2,1)); + mesh->_setBoundingSphereRadius(4.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // Mesh with skeleton: 3 bones (root/spine/head), bone assignments on spine + Ogre::Entity* createSkeletonMesh(const std::string& name) { + auto skel = Ogre::SkeletonManager::getSingleton().create( + name + "_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* root = skel->createBone("root", 0); + root->setPosition(Ogre::Vector3(0, 0, 0)); + + auto* spine = skel->createBone("spine", 1); + spine->setPosition(Ogre::Vector3(0, 1, 0.5)); + root->addChild(spine); + + auto* head = skel->createBone("head", 2); + head->setPosition(Ogre::Vector3(0, 0.5, 0)); + spine->addChild(head); + + skel->setBindingPose(); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + size_t offset = 0; + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, offset, 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.5f, 0,0,1, + 1,0,0.5f, 0,0,1, + 0,1,0.5f, 0,0,1, + }; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + // Bone assignments: all vertices assigned to spine (bone 1) + Ogre::VertexBoneAssignment vba; + vba.boneIndex = 1; + vba.weight = 1.0f; + for (unsigned short v = 0; v < 3; ++v) { + vba.vertexIndex = v; + mesh->addBoneAssignment(vba); + } + + mesh->_notifySkeleton(skel); + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,2)); + mesh->_setBoundingSphereRadius(3.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // Skeleton mesh with a "walk" animation (3 keyframes) + Ogre::Entity* createAnimatedMesh(const std::string& name) { + auto skel = Ogre::SkeletonManager::getSingleton().create( + name + "_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* root = skel->createBone("root", 0); + root->setPosition(Ogre::Vector3(0, 0, 0)); + + auto* spine = skel->createBone("spine", 1); + spine->setPosition(Ogre::Vector3(0, 1, 0)); + root->addChild(spine); + + skel->setBindingPose(); + + // Create animation with 3 keyframes + auto* anim = skel->createAnimation("walk", 1.0f); + auto* track = anim->createNodeTrack(1); + track->setAssociatedNode(spine); + + auto* kf0 = track->createNodeKeyFrame(0.0f); + kf0->setTranslate(Ogre::Vector3::ZERO); + kf0->setRotation(Ogre::Quaternion::IDENTITY); + kf0->setScale(Ogre::Vector3::UNIT_SCALE); + + auto* kf1 = track->createNodeKeyFrame(0.5f); + kf1->setTranslate(Ogre::Vector3(0.5f, 0, 0)); + kf1->setRotation(Ogre::Quaternion(Ogre::Radian(Ogre::Degree(30)), + Ogre::Vector3::UNIT_Y)); + kf1->setScale(Ogre::Vector3::UNIT_SCALE); + + auto* kf2 = track->createNodeKeyFrame(1.0f); + kf2->setTranslate(Ogre::Vector3::ZERO); + kf2->setRotation(Ogre::Quaternion::IDENTITY); + kf2->setScale(Ogre::Vector3::UNIT_SCALE); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,0, 1,0,0, 0,1,0}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + // Bone assignments: vertices to spine + Ogre::VertexBoneAssignment vba; + vba.boneIndex = 1; + vba.weight = 1.0f; + for (unsigned short v = 0; v < 3; ++v) { + vba.vertexIndex = v; + mesh->addBoneAssignment(vba); + } + + mesh->_notifySkeleton(skel); + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,1)); + mesh->_setBoundingSphereRadius(3.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // Mesh with a material that has a TextureUnitState + Ogre::Entity* createTexturedMesh(const std::string& name) { + auto mat = Ogre::MaterialManager::getSingleton().create( + name + "_mat", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + mat->getTechnique(0)->getPass(0)->setDiffuse(0.8f, 0.8f, 0.8f, 1.0f); + mat->getTechnique(0)->getPass(0)->createTextureUnitState("diffuse_tex.png"); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + sub->setMaterialName(name + "_mat"); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + size_t offset = 0; + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, offset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = { + 0,0,0, 0.0f,0.0f, + 1,0,0, 1.0f,0.0f, + 0,1,0, 0.0f,1.0f, + }; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,1,1,1)); + mesh->_setBoundingSphereRadius(2.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } + + // Mesh with material having known diffuse/specular/shininess + Ogre::Entity* createMaterialTestMesh(const std::string& name) { + auto mat = Ogre::MaterialManager::getSingleton().create( + name + "_mat", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + mat->getTechnique(0)->getPass(0)->setDiffuse(0.9f, 0.1f, 0.2f, 1.0f); + mat->getTechnique(0)->getPass(0)->setSpecular(0.5f, 0.6f, 0.7f, 1.0f); + mat->getTechnique(0)->getPass(0)->setAmbient(0.1f, 0.2f, 0.3f); + mat->getTechnique(0)->getPass(0)->setSelfIllumination(0.05f, 0.06f, 0.07f); + mat->getTechnique(0)->getPass(0)->setShininess(64.0f); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + sub->setMaterialName(name + "_mat"); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,0, 1,0,0, 0,1,0}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,1,1,1)); + mesh->_setBoundingSphereRadius(2.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + return entity; + } +}; + +// ── Group A: Document Structure ───────────────────────────────── + +TEST_F(FBXExporterCoverageTest, TopLevelNodes) { + auto name = uniqueName("tln"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + ASSERT_GE(r.nodes.size(), 7u); + + // Expected top-level nodes in order + EXPECT_EQ(r.nodes[0].name, "FBXHeaderExtension"); + EXPECT_EQ(r.nodes[1].name, "GlobalSettings"); + EXPECT_EQ(r.nodes[2].name, "Documents"); + EXPECT_EQ(r.nodes[3].name, "References"); + EXPECT_EQ(r.nodes[4].name, "Definitions"); + EXPECT_EQ(r.nodes[5].name, "Objects"); + EXPECT_EQ(r.nodes[6].name, "Connections"); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, HeaderExtension) { + auto name = uniqueName("hdr"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* hdr = findTopLevel(r.nodes, "FBXHeaderExtension"); + ASSERT_NE(hdr, nullptr); + + auto* hdrVer = hdr->find("FBXHeaderVersion"); + ASSERT_NE(hdrVer, nullptr); + EXPECT_EQ(hdrVer->properties[0].intVal, 1003); + + auto* fbxVer = hdr->find("FBXVersion"); + ASSERT_NE(fbxVer, nullptr); + EXPECT_EQ(fbxVer->properties[0].intVal, 7300); + + auto* enc = hdr->find("EncryptionType"); + ASSERT_NE(enc, nullptr); + EXPECT_EQ(enc->properties[0].intVal, 0); + + auto* creator = hdr->find("Creator"); + ASSERT_NE(creator, nullptr); + EXPECT_EQ(creator->properties[0].stringVal, "QtMeshEditor FBX Exporter"); + + auto* cts = hdr->find("CreationTimeStamp"); + ASSERT_NE(cts, nullptr); + EXPECT_NE(cts->find("Version"), nullptr); + EXPECT_NE(cts->find("Year"), nullptr); + EXPECT_NE(cts->find("Month"), nullptr); + EXPECT_NE(cts->find("Day"), nullptr); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, GlobalSettings) { + auto name = uniqueName("gs"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* gs = findTopLevel(r.nodes, "GlobalSettings"); + ASSERT_NE(gs, nullptr); + + auto* props = gs->find("Properties70"); + ASSERT_NE(props, nullptr); + + auto* upAxis = findP70(*props, "UpAxis"); + ASSERT_NE(upAxis, nullptr); + EXPECT_EQ(upAxis->properties[4].intVal, 1); + + auto* unitScale = findP70(*props, "UnitScaleFactor"); + ASSERT_NE(unitScale, nullptr); + EXPECT_NEAR(unitScale->properties[4].doubleVal, 100.0, 0.01); + + auto* timeMode = findP70(*props, "TimeMode"); + ASSERT_NE(timeMode, nullptr); + EXPECT_EQ(timeMode->properties[4].intVal, 6); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, Documents) { + auto name = uniqueName("doc"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* docs = findTopLevel(r.nodes, "Documents"); + ASSERT_NE(docs, nullptr); + + auto* count = docs->find("Count"); + ASSERT_NE(count, nullptr); + EXPECT_EQ(count->properties[0].intVal, 1); + + auto* doc = docs->find("Document"); + ASSERT_NE(doc, nullptr); + EXPECT_NE(doc->find("RootNode"), nullptr); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, Definitions_MeshOnly) { + auto name = uniqueName("def"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* defs = findTopLevel(r.nodes, "Definitions"); + ASSERT_NE(defs, nullptr); + + // Check ObjectType nodes exist + auto objectTypes = defs->findAll("ObjectType"); + ASSERT_GE(objectTypes.size(), 4u); // GlobalSettings, Model, Geometry, Material + + // Verify GlobalSettings, Model, Geometry, Material are present + bool hasGS = false, hasModel = false, hasGeom = false, hasMat = false; + for (const auto* ot : objectTypes) { + if (!ot->properties.empty()) { + if (ot->properties[0].stringVal == "GlobalSettings") hasGS = true; + if (ot->properties[0].stringVal == "Model") hasModel = true; + if (ot->properties[0].stringVal == "Geometry") hasGeom = true; + if (ot->properties[0].stringVal == "Material") hasMat = true; + } + } + EXPECT_TRUE(hasGS); + EXPECT_TRUE(hasModel); + EXPECT_TRUE(hasGeom); + EXPECT_TRUE(hasMat); + + cleanup(r); +} + +// ── Group B: Geometry ────────────────────────────────────────── + +TEST_F(FBXExporterCoverageTest, Vertices_ZMirrored) { + auto name = uniqueName("vz"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + ASSERT_NE(objects, nullptr); + + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 1u); + + auto* verts = geomNodes[0]->find("Vertices"); + ASSERT_NE(verts, nullptr); + ASSERT_EQ(verts->properties[0].doubleArray.size(), 9u); // 3 vertices * 3 components + + // Original z values were 0, 0, 0 → negated should be -0, -0, -0 + // v0: (0,0,-0), v1: (1,0,-0), v2: (0,1,-0) + auto& v = verts->properties[0].doubleArray; + EXPECT_NEAR(v[0], 0.0, 0.001); // v0.x + EXPECT_NEAR(v[1], 0.0, 0.001); // v0.y + EXPECT_NEAR(v[2], 0.0, 0.001); // v0.z (was 0, negated is -0) + EXPECT_NEAR(v[3], 1.0, 0.001); // v1.x + EXPECT_NEAR(v[7], 1.0, 0.001); // v2.y + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, PolygonIndices_WindingReversed) { + auto name = uniqueName("pi"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 1u); + + auto* polyIdx = geomNodes[0]->find("PolygonVertexIndex"); + ASSERT_NE(polyIdx, nullptr); + auto& pi = polyIdx->properties[0].intArray; + ASSERT_EQ(pi.size(), 3u); + + // Original indices: 0, 1, 2 + // Reversed winding: (i0, i2, -(i1+1)) = (0, 2, -2) + EXPECT_EQ(pi[0], 0); + EXPECT_EQ(pi[1], 2); + EXPECT_EQ(pi[2], -(1 + 1)); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, Normals_ExpandedByPolygonVertex) { + auto name = uniqueName("norm"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 1u); + + auto* normLayer = geomNodes[0]->find("LayerElementNormal"); + ASSERT_NE(normLayer, nullptr); + + auto* mapping = normLayer->find("MappingInformationType"); + ASSERT_NE(mapping, nullptr); + EXPECT_EQ(mapping->properties[0].stringVal, "ByPolygonVertex"); + + auto* ref = normLayer->find("ReferenceInformationType"); + ASSERT_NE(ref, nullptr); + EXPECT_EQ(ref->properties[0].stringVal, "Direct"); + + auto* normals = normLayer->find("Normals"); + ASSERT_NE(normals, nullptr); + // 1 triangle * 3 vertices = 3 normals * 3 components = 9 doubles + ASSERT_EQ(normals->properties[0].doubleArray.size(), 9u); + + // Original normal is (0,0,1) → Z-mirrored: (0,0,-1) + // Expanded in reversed winding order: v0(0,0,-1), v2(0,0,-1), v1(0,0,-1) + auto& n = normals->properties[0].doubleArray; + EXPECT_NEAR(n[2], -1.0, 0.001); // v0 normal Z + EXPECT_NEAR(n[5], -1.0, 0.001); // v2 normal Z + EXPECT_NEAR(n[8], -1.0, 0.001); // v1 normal Z + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, UVs_VFlipped) { + auto name = uniqueName("uv"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + auto* uvLayer = geomNodes[0]->find("LayerElementUV"); + ASSERT_NE(uvLayer, nullptr); + + auto* uvs = uvLayer->find("UV"); + ASSERT_NE(uvs, nullptr); + ASSERT_EQ(uvs->properties[0].doubleArray.size(), 6u); // 3 verts * 2 components + + auto& uv = uvs->properties[0].doubleArray; + // v0 original UV: (0.0, 0.0) → V-flip: (0.0, 1.0) + EXPECT_NEAR(uv[0], 0.0, 0.001); + EXPECT_NEAR(uv[1], 1.0, 0.001); + // v1 original UV: (1.0, 0.0) → V-flip: (1.0, 1.0) + EXPECT_NEAR(uv[2], 1.0, 0.001); + EXPECT_NEAR(uv[3], 1.0, 0.001); + // v2 original UV: (0.0, 1.0) → V-flip: (0.0, 0.0) + EXPECT_NEAR(uv[4], 0.0, 0.001); + EXPECT_NEAR(uv[5], 0.0, 0.001); + + // Check UVIndex has reversed winding + auto* uvIdx = uvLayer->find("UVIndex"); + ASSERT_NE(uvIdx, nullptr); + auto& ui = uvIdx->properties[0].intArray; + ASSERT_EQ(ui.size(), 3u); + // Original: 0,1,2 → Reversed winding: (0, 2, 1) + EXPECT_EQ(ui[0], 0); + EXPECT_EQ(ui[1], 2); + EXPECT_EQ(ui[2], 1); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, NoNormals_SkipsNormalLayer) { + auto name = uniqueName("nonorm"); + auto* entity = createMeshNoNormalsNoUVs(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 1u); + + // Should have no LayerElementNormal + EXPECT_EQ(geomNodes[0]->find("LayerElementNormal"), nullptr); + // Should have no LayerElementUV + EXPECT_EQ(geomNodes[0]->find("LayerElementUV"), nullptr); + + // Layer should not have Normal or UV LayerElement entries + auto* layer = geomNodes[0]->find("Layer"); + ASSERT_NE(layer, nullptr); + + // Only LayerElementMaterial should be in the Layer + auto layerElems = layer->findAll("LayerElement"); + bool hasNormalType = false, hasUVType = false; + for (const auto* le : layerElems) { + auto* typeNode = le->find("Type"); + if (typeNode && typeNode->properties[0].stringVal == "LayerElementNormal") + hasNormalType = true; + if (typeNode && typeNode->properties[0].stringVal == "LayerElementUV") + hasUVType = true; + } + EXPECT_FALSE(hasNormalType); + EXPECT_FALSE(hasUVType); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, Indices32Bit) { + auto name = uniqueName("i32"); + auto* entity = createMesh32BitIndices(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 1u); + + auto* polyIdx = geomNodes[0]->find("PolygonVertexIndex"); + ASSERT_NE(polyIdx, nullptr); + auto& pi = polyIdx->properties[0].intArray; + ASSERT_EQ(pi.size(), 3u); + // Same winding reversal: (0, 2, -(1+1)) + EXPECT_EQ(pi[0], 0); + EXPECT_EQ(pi[1], 2); + EXPECT_EQ(pi[2], -2); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, NonSharedVertexData) { + auto name = uniqueName("ns"); + auto* entity = createMeshNonShared(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 1u); + + auto* verts = geomNodes[0]->find("Vertices"); + ASSERT_NE(verts, nullptr); + ASSERT_EQ(verts->properties[0].doubleArray.size(), 9u); + + // Positions from non-shared data: (2,0,0), (3,0,0), (2,1,0) + // Z-mirrored: (2,0,-0), (3,0,-0), (2,1,-0) + auto& v = verts->properties[0].doubleArray; + EXPECT_NEAR(v[0], 2.0, 0.001); + EXPECT_NEAR(v[3], 3.0, 0.001); + EXPECT_NEAR(v[6], 2.0, 0.001); + EXPECT_NEAR(v[7], 1.0, 0.001); + + cleanup(r); +} + +// ── Group C: Materials ───────────────────────────────────────── + +TEST_F(FBXExporterCoverageTest, MaterialProperties) { + auto name = uniqueName("matp"); + auto* entity = createMaterialTestMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto matNodes = objects->findAll("Material"); + ASSERT_EQ(matNodes.size(), 1u); + + auto* props = matNodes[0]->find("Properties70"); + ASSERT_NE(props, nullptr); + + // DiffuseColor: 0.9, 0.1, 0.2 + auto* diffuse = findP70(*props, "DiffuseColor"); + ASSERT_NE(diffuse, nullptr); + EXPECT_NEAR(diffuse->properties[4].doubleVal, 0.9, 0.01); + EXPECT_NEAR(diffuse->properties[5].doubleVal, 0.1, 0.01); + EXPECT_NEAR(diffuse->properties[6].doubleVal, 0.2, 0.01); + + // SpecularColor: 0.5, 0.6, 0.7 + auto* specular = findP70(*props, "SpecularColor"); + ASSERT_NE(specular, nullptr); + EXPECT_NEAR(specular->properties[4].doubleVal, 0.5, 0.01); + EXPECT_NEAR(specular->properties[5].doubleVal, 0.6, 0.01); + EXPECT_NEAR(specular->properties[6].doubleVal, 0.7, 0.01); + + // Shininess: 64.0 + auto* shininess = findP70(*props, "Shininess"); + ASSERT_NE(shininess, nullptr); + EXPECT_NEAR(shininess->properties[4].doubleVal, 64.0, 0.01); + + // AmbientColor: 0.1, 0.2, 0.3 + auto* ambient = findP70(*props, "AmbientColor"); + ASSERT_NE(ambient, nullptr); + EXPECT_NEAR(ambient->properties[4].doubleVal, 0.1, 0.01); + + // EmissiveColor: 0.05, 0.06, 0.07 + auto* emissive = findP70(*props, "EmissiveColor"); + ASSERT_NE(emissive, nullptr); + EXPECT_NEAR(emissive->properties[4].doubleVal, 0.05, 0.01); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, MultipleMaterials) { + auto name = uniqueName("mm"); + auto* entity = createMultiSubmeshMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto matNodes = objects->findAll("Material"); + ASSERT_EQ(matNodes.size(), 2u); + + // Each should have Properties70 with DiffuseColor + for (const auto* mat : matNodes) { + auto* props = mat->find("Properties70"); + ASSERT_NE(props, nullptr); + auto* dc = findP70(*props, "DiffuseColor"); + ASSERT_NE(dc, nullptr); + } + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, MeshModelNode) { + auto name = uniqueName("model"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto modelNodes = objects->findAll("Model"); + ASSERT_GE(modelNodes.size(), 1u); + + // Find the "Mesh" type model + const FBXNode* meshModel = nullptr; + for (const auto* m : modelNodes) { + if (m->properties.size() >= 3 && m->properties[2].stringVal == "Mesh") + meshModel = m; + } + ASSERT_NE(meshModel, nullptr); + + auto* props = meshModel->find("Properties70"); + ASSERT_NE(props, nullptr); + + // Check LclTranslation, LclRotation, LclScaling + EXPECT_NE(findP70(*props, "Lcl Translation"), nullptr); + EXPECT_NE(findP70(*props, "Lcl Rotation"), nullptr); + EXPECT_NE(findP70(*props, "Lcl Scaling"), nullptr); + + // Check Shading and Culling + auto* shading = meshModel->find("Shading"); + ASSERT_NE(shading, nullptr); + EXPECT_EQ(shading->properties[0].boolVal, true); + + auto* culling = meshModel->find("Culling"); + ASSERT_NE(culling, nullptr); + EXPECT_EQ(culling->properties[0].stringVal, "CullingOff"); + + cleanup(r); +} + +// ── Group D: Skeleton ────────────────────────────────────────── + +TEST_F(FBXExporterCoverageTest, BoneNodeAttributes) { + auto name = uniqueName("bna"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto nodeAttrs = objects->findAll("NodeAttribute"); + // 3 bones = 3 NodeAttribute nodes + ASSERT_EQ(nodeAttrs.size(), 3u); + + for (const auto* na : nodeAttrs) { + auto* typeFlags = na->find("TypeFlags"); + ASSERT_NE(typeFlags, nullptr); + EXPECT_EQ(typeFlags->properties[0].stringVal, "Skeleton"); + } + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, DeformingBoneTransform) { + auto name = uniqueName("dbt"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto modelNodes = objects->findAll("Model"); + + // Find the spine bone (LimbNode type, name contains "spine") + const FBXNode* spineModel = nullptr; + for (const auto* m : modelNodes) { + if (m->properties.size() >= 3 && m->properties[2].stringVal == "LimbNode") { + // Check if name contains "spine" + if (m->properties[1].stringVal.find("spine") != std::string::npos) + spineModel = m; + } + } + ASSERT_NE(spineModel, nullptr); + + auto* props = spineModel->find("Properties70"); + ASSERT_NE(props, nullptr); + + auto* lclT = findP70(*props, "Lcl Translation"); + ASSERT_NE(lclT, nullptr); + // Spine position: (0, 1, 0.5) → Z-mirrored: (0, 1, -0.5) + EXPECT_NEAR(lclT->properties[4].doubleVal, 0.0, 0.01); + EXPECT_NEAR(lclT->properties[5].doubleVal, 1.0, 0.01); + EXPECT_NEAR(lclT->properties[6].doubleVal, -0.5, 0.01); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, NonDeformingBoneTransform) { + auto name = uniqueName("ndbt"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto modelNodes = objects->findAll("Model"); + + // Find the root bone (no vertex assignments → non-deforming → inverted transform) + const FBXNode* rootModel = nullptr; + for (const auto* m : modelNodes) { + if (m->properties.size() >= 3 && m->properties[2].stringVal == "LimbNode") { + if (m->properties[1].stringVal.find("root") != std::string::npos) + rootModel = m; + } + } + ASSERT_NE(rootModel, nullptr); + + auto* props = rootModel->find("Properties70"); + ASSERT_NE(props, nullptr); + + // Root bone at origin with identity rotation → inverse is also identity + auto* lclT = findP70(*props, "Lcl Translation"); + ASSERT_NE(lclT, nullptr); + // Root position (0,0,0), inverted → still (0,0,-0) + EXPECT_NEAR(lclT->properties[4].doubleVal, 0.0, 0.01); + EXPECT_NEAR(lclT->properties[5].doubleVal, 0.0, 0.01); + EXPECT_NEAR(lclT->properties[6].doubleVal, 0.0, 0.01); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, BoneHierarchy) { + auto name = uniqueName("bh"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* conn = findTopLevel(r.nodes, "Connections"); + ASSERT_NE(conn, nullptr); + + // Collect all OO connections + auto cNodes = conn->findAll("C"); + std::vector> ooConns; + for (const auto* c : cNodes) { + if (c->properties.size() >= 3 && c->properties[0].stringVal == "OO") + ooConns.push_back({c->properties[1].longVal, c->properties[2].longVal}); + } + + // There should be at least bone hierarchy connections + // root→0 (scene root), spine→root model, head→spine model + // We verify at least one connection to 0 (scene root) from a LimbNode model + bool hasRootConnection = false; + for (const auto& [child, parent] : ooConns) { + if (parent == 0 && child != 0) + hasRootConnection = true; + } + EXPECT_TRUE(hasRootConnection); + + cleanup(r); +} + +// ── Group E: Skin Deformers ──────────────────────────────────── + +TEST_F(FBXExporterCoverageTest, SkinDeformer) { + auto name = uniqueName("skin"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + + // Find Deformer nodes with "Skin" type + auto deformerNodes = objects->findAll("Deformer"); + bool hasSkin = false; + for (const auto* d : deformerNodes) { + if (d->properties.size() >= 3 && d->properties[2].stringVal == "Skin") { + hasSkin = true; + auto* ver = d->find("Version"); + ASSERT_NE(ver, nullptr); + EXPECT_EQ(ver->properties[0].intVal, 101); + } + } + EXPECT_TRUE(hasSkin); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, ClusterData) { + auto name = uniqueName("clus"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto deformerNodes = objects->findAll("Deformer"); + + // Find Cluster deformers + bool hasCluster = false; + for (const auto* d : deformerNodes) { + if (d->properties.size() >= 3 && d->properties[2].stringVal == "Cluster") { + hasCluster = true; + + // Should have Indexes, Weights, Transform, TransformLink + auto* indexes = d->find("Indexes"); + ASSERT_NE(indexes, nullptr); + EXPECT_FALSE(indexes->properties[0].intArray.empty()); + + auto* weights = d->find("Weights"); + ASSERT_NE(weights, nullptr); + EXPECT_FALSE(weights->properties[0].doubleArray.empty()); + + // Verify weights are all 1.0 (we assigned weight=1.0) + for (double w : weights->properties[0].doubleArray) + EXPECT_NEAR(w, 1.0, 0.001); + + auto* transform = d->find("Transform"); + ASSERT_NE(transform, nullptr); + EXPECT_EQ(transform->properties[0].doubleArray.size(), 16u); + + auto* transformLink = d->find("TransformLink"); + ASSERT_NE(transformLink, nullptr); + EXPECT_EQ(transformLink->properties[0].doubleArray.size(), 16u); + } + } + EXPECT_TRUE(hasCluster); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, ClusterConnections) { + auto name = uniqueName("clcon"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* conn = findTopLevel(r.nodes, "Connections"); + ASSERT_NE(conn, nullptr); + + // There should be OO connections for cluster→skin and bone→cluster + auto cNodes = conn->findAll("C"); + int ooConnCount = 0; + for (const auto* c : cNodes) { + if (c->properties.size() >= 3 && c->properties[0].stringVal == "OO") + ooConnCount++; + } + // At minimum: mesh→0, geom→mesh, mat→mesh, nodeAttr→bone(x3), + // root→0, spine→root, head→spine, skin→geom, cluster→skin, bone→cluster + EXPECT_GT(ooConnCount, 10); + + cleanup(r); +} + +// ── Group F: Animations ──────────────────────────────────────── + +TEST_F(FBXExporterCoverageTest, AnimationStack) { + auto name = uniqueName("astack"); + auto* entity = createAnimatedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto stacks = objects->findAll("AnimationStack"); + ASSERT_EQ(stacks.size(), 1u); + + auto* props = stacks[0]->find("Properties70"); + ASSERT_NE(props, nullptr); + + auto* localStart = findP70(*props, "LocalStart"); + ASSERT_NE(localStart, nullptr); + EXPECT_EQ(localStart->properties[4].longVal, 0); + + auto* localStop = findP70(*props, "LocalStop"); + ASSERT_NE(localStop, nullptr); + // 1.0 second * 46186158000 ticks/sec + EXPECT_EQ(localStop->properties[4].longVal, 46186158000LL); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, AnimationCurveNodes) { + auto name = uniqueName("acn"); + auto* entity = createAnimatedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto curveNodes = objects->findAll("AnimationCurveNode"); + // 1 bone track → 3 curve nodes (T, R, S) + ASSERT_EQ(curveNodes.size(), 3u); + + // Each should have Properties70 with d|X, d|Y, d|Z + for (const auto* cn : curveNodes) { + auto* props = cn->find("Properties70"); + ASSERT_NE(props, nullptr); + EXPECT_NE(findP70(*props, "d|X"), nullptr); + EXPECT_NE(findP70(*props, "d|Y"), nullptr); + EXPECT_NE(findP70(*props, "d|Z"), nullptr); + } + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, AnimationCurves) { + auto name = uniqueName("ac"); + auto* entity = createAnimatedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto curves = objects->findAll("AnimationCurve"); + // 1 bone track → 9 curves (TX,TY,TZ,RX,RY,RZ,SX,SY,SZ) + ASSERT_EQ(curves.size(), 9u); + + for (const auto* curve : curves) { + // Should have KeyTime, KeyValueFloat, KeyAttrFlags + auto* keyTime = curve->find("KeyTime"); + ASSERT_NE(keyTime, nullptr); + EXPECT_EQ(keyTime->properties[0].longArray.size(), 3u); // 3 keyframes + + auto* keyValue = curve->find("KeyValueFloat"); + ASSERT_NE(keyValue, nullptr); + EXPECT_EQ(keyValue->properties[0].floatArray.size(), 3u); + + auto* keyFlags = curve->find("KeyAttrFlags"); + ASSERT_NE(keyFlags, nullptr); + // Cubic interpolation flag: 24840 + EXPECT_EQ(keyFlags->properties[0].intArray[0], 24840); + } + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, EulerContinuity) { + // Create an animated mesh where rotation crosses the 180° boundary + auto name = uniqueName("euler_cont"); + + auto skel = Ogre::SkeletonManager::getSingleton().create( + name + "_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* root = skel->createBone("root", 0); + root->setPosition(Ogre::Vector3::ZERO); + + auto* bone = skel->createBone("bone", 1); + bone->setPosition(Ogre::Vector3(0, 1, 0)); + root->addChild(bone); + + skel->setBindingPose(); + + auto* anim = skel->createAnimation("spin", 1.0f); + auto* track = anim->createNodeTrack(1); + track->setAssociatedNode(bone); + + // Keyframe 0: rotation 170° Y + auto* kf0 = track->createNodeKeyFrame(0.0f); + kf0->setTranslate(Ogre::Vector3::ZERO); + kf0->setRotation(Ogre::Quaternion(Ogre::Radian(Ogre::Degree(170)), + Ogre::Vector3::UNIT_Y)); + kf0->setScale(Ogre::Vector3::UNIT_SCALE); + + // Keyframe 1: rotation 190° Y (crosses 180° boundary) + auto* kf1 = track->createNodeKeyFrame(0.5f); + kf1->setTranslate(Ogre::Vector3::ZERO); + kf1->setRotation(Ogre::Quaternion(Ogre::Radian(Ogre::Degree(190)), + Ogre::Vector3::UNIT_Y)); + kf1->setScale(Ogre::Vector3::UNIT_SCALE); + + // Keyframe 2: rotation 210° Y + auto* kf2 = track->createNodeKeyFrame(1.0f); + kf2->setTranslate(Ogre::Vector3::ZERO); + kf2->setRotation(Ogre::Quaternion(Ogre::Radian(Ogre::Degree(210)), + Ogre::Vector3::UNIT_Y)); + kf2->setScale(Ogre::Vector3::UNIT_SCALE); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,0, 1,0,0, 0,1,0}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + Ogre::VertexBoneAssignment vba; + vba.boneIndex = 1; vba.weight = 1.0f; + for (unsigned short v = 0; v < 3; ++v) { + vba.vertexIndex = v; + mesh->addBoneAssignment(vba); + } + mesh->_notifySkeleton(skel); + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,1)); + mesh->_setBoundingSphereRadius(3.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + // Find the RY curve and verify no large jumps between keyframes + auto* objects = findTopLevel(r.nodes, "Objects"); + auto curves = objects->findAll("AnimationCurve"); + + // Curves are in order: TX,TY,TZ,RX,RY,RZ,SX,SY,SZ + // RY is the 5th curve (index 4) + ASSERT_GE(curves.size(), 6u); + auto* ryCurve = curves[4]; + auto* keyValue = ryCurve->find("KeyValueFloat"); + ASSERT_NE(keyValue, nullptr); + auto& vals = keyValue->properties[0].floatArray; + ASSERT_EQ(vals.size(), 3u); + + // Verify no jump > 90° between consecutive RY values + for (size_t i = 1; i < vals.size(); ++i) { + double diff = std::abs(static_cast(vals[i]) - static_cast(vals[i-1])); + EXPECT_LT(diff, 90.0) << "RY jump too large between keyframe " << (i-1) + << " and " << i << ": " << vals[i-1] << " -> " << vals[i]; + } + + cleanup(r); +} + +// ── Group G: Bind Pose & Textures ────────────────────────────── + +TEST_F(FBXExporterCoverageTest, BindPose) { + auto name = uniqueName("bp"); + auto* entity = createSkeletonMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto poses = objects->findAll("Pose"); + ASSERT_EQ(poses.size(), 1u); + + auto* nbPoseNodes = poses[0]->find("NbPoseNodes"); + ASSERT_NE(nbPoseNodes, nullptr); + // 1 mesh + 3 bones = 4 + EXPECT_EQ(nbPoseNodes->properties[0].intVal, 4); + + auto poseNodes = poses[0]->findAll("PoseNode"); + ASSERT_EQ(poseNodes.size(), 4u); + + // Each PoseNode should have Node (id) and Matrix (16 doubles) + for (const auto* pn : poseNodes) { + auto* nodeId = pn->find("Node"); + ASSERT_NE(nodeId, nullptr); + + auto* matrix = pn->find("Matrix"); + ASSERT_NE(matrix, nullptr); + EXPECT_EQ(matrix->properties[0].doubleArray.size(), 16u); + } + + // First PoseNode (mesh) should have identity matrix + auto& meshMatrix = poseNodes[0]->find("Matrix")->properties[0].doubleArray; + EXPECT_NEAR(meshMatrix[0], 1.0, 0.001); + EXPECT_NEAR(meshMatrix[5], 1.0, 0.001); + EXPECT_NEAR(meshMatrix[10], 1.0, 0.001); + EXPECT_NEAR(meshMatrix[15], 1.0, 0.001); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, TextureAndVideo) { + auto name = uniqueName("tex"); + auto* entity = createTexturedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + + auto texNodes = objects->findAll("Texture"); + ASSERT_EQ(texNodes.size(), 1u); + + auto* texName = texNodes[0]->find("TextureName"); + ASSERT_NE(texName, nullptr); + EXPECT_EQ(texName->properties[0].stringVal, "diffuse_tex.png"); + + auto* fileName = texNodes[0]->find("FileName"); + ASSERT_NE(fileName, nullptr); + EXPECT_EQ(fileName->properties[0].stringVal, "diffuse_tex.png"); + + auto vidNodes = objects->findAll("Video"); + ASSERT_EQ(vidNodes.size(), 1u); + + auto* vidType = vidNodes[0]->find("Type"); + ASSERT_NE(vidType, nullptr); + EXPECT_EQ(vidType->properties[0].stringVal, "Clip"); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, TextureConnections) { + auto name = uniqueName("texcon"); + auto* entity = createTexturedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* conn = findTopLevel(r.nodes, "Connections"); + ASSERT_NE(conn, nullptr); + + auto cNodes = conn->findAll("C"); + + // Find OP connection with "DiffuseColor" (texture→material) + bool hasDiffuseConn = false; + // Find OO connection (video→texture) + bool hasVideoConn = false; + + for (const auto* c : cNodes) { + if (c->properties.size() >= 4 && c->properties[0].stringVal == "OP") { + if (c->properties[3].stringVal == "DiffuseColor") + hasDiffuseConn = true; + } + if (c->properties.size() >= 3 && c->properties[0].stringVal == "OO") { + hasVideoConn = true; // Can't distinguish easily, but OO connections exist + } + } + EXPECT_TRUE(hasDiffuseConn); + EXPECT_TRUE(hasVideoConn); + + cleanup(r); +} + +// ── Group H: Connections ─────────────────────────────────────── + +TEST_F(FBXExporterCoverageTest, BasicConnections) { + auto name = uniqueName("bcon"); + auto* entity = createSimpleMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* conn = findTopLevel(r.nodes, "Connections"); + ASSERT_NE(conn, nullptr); + + auto cNodes = conn->findAll("C"); + + // Collect connections + bool hasMeshToRoot = false; + int geomToMesh = 0; + int matToMesh = 0; + + // First connection should be mesh model → root (0) + if (!cNodes.empty() && cNodes[0]->properties.size() >= 3) { + if (cNodes[0]->properties[0].stringVal == "OO" && cNodes[0]->properties[2].longVal == 0) + hasMeshToRoot = true; + } + + // Count geometry→mesh and material→mesh connections + for (const auto* c : cNodes) { + if (c->properties.size() >= 3 && c->properties[0].stringVal == "OO") { + // All OO connections to the mesh model ID (non-zero, non-root) + if (c->properties[2].longVal != 0) + geomToMesh++; // Counts both geom and mat + } + } + + EXPECT_TRUE(hasMeshToRoot); + EXPECT_GE(geomToMesh, 2); // at least 1 geometry + 1 material + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, SkeletalConnections) { + auto name = uniqueName("scon"); + auto* entity = createAnimatedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* conn = findTopLevel(r.nodes, "Connections"); + ASSERT_NE(conn, nullptr); + + auto cNodes = conn->findAll("C"); + + int ooCount = 0, opCount = 0; + for (const auto* c : cNodes) { + if (c->properties[0].stringVal == "OO") ooCount++; + if (c->properties[0].stringVal == "OP") opCount++; + } + + // Should have many OO connections: mesh→0, geom→mesh, mat→mesh, + // nodeAttr→bone(x2), bones to parent(x2), skin→geom, cluster→skin, + // bone→cluster, animStack→0, layer→stack, curveNode→layer(x3) + EXPECT_GT(ooCount, 12); + + // Should have OP connections: curveNode→bone (x3: T,R,S) + curve→curveNode (x9) + EXPECT_GE(opCount, 12); + + cleanup(r); +} + +// ── Group I: Edge Cases ──────────────────────────────────────── + +TEST_F(FBXExporterCoverageTest, MultiSubmeshGeometry) { + auto name = uniqueName("msub"); + auto* entity = createMultiSubmeshMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 2u); + + // Each geometry should have Vertices, PolygonVertexIndex, LayerElementMaterial + for (const auto* g : geomNodes) { + EXPECT_NE(g->find("Vertices"), nullptr); + EXPECT_NE(g->find("PolygonVertexIndex"), nullptr); + EXPECT_NE(g->find("LayerElementMaterial"), nullptr); + } + + // Verify material indices — since materials are sorted by name, + // matA (name _matA) and matB (name _matB) will be indexed 0 and 1 + for (size_t i = 0; i < geomNodes.size(); ++i) { + auto* matLayer = geomNodes[i]->find("LayerElementMaterial"); + auto* materials = matLayer->find("Materials"); + ASSERT_NE(materials, nullptr); + auto& matIdx = materials->properties[0].intArray; + ASSERT_EQ(matIdx.size(), 1u); + // Material index should be valid (0 or 1) + EXPECT_GE(matIdx[0], 0); + EXPECT_LE(matIdx[0], 1); + } + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, GimbalLockRotation) { + // Bone at exactly 90° Y rotation → gimbal lock branch in quaternionToEulerXYZ + auto name = uniqueName("gimbal"); + + auto skel = Ogre::SkeletonManager::getSingleton().create( + name + "_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* root = skel->createBone("root", 0); + root->setPosition(Ogre::Vector3::ZERO); + + auto* bone = skel->createBone("bone", 1); + bone->setPosition(Ogre::Vector3(0, 1, 0)); + // Set orientation to exactly 90° Y (gimbal lock) + bone->setOrientation(Ogre::Quaternion(Ogre::Radian(Ogre::Degree(90)), + Ogre::Vector3::UNIT_Y)); + root->addChild(bone); + + skel->setBindingPose(); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,0, 1,0,0, 0,1,0}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + Ogre::VertexBoneAssignment vba; + vba.boneIndex = 1; vba.weight = 1.0f; + for (unsigned short v = 0; v < 3; ++v) { + vba.vertexIndex = v; + mesh->addBoneAssignment(vba); + } + mesh->_notifySkeleton(skel); + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,1)); + mesh->_setBoundingSphereRadius(3.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + // Verify the file exported successfully and has expected structure + auto* objects = findTopLevel(r.nodes, "Objects"); + auto modelNodes = objects->findAll("Model"); + + // Find the bone LimbNode + const FBXNode* boneModel = nullptr; + for (const auto* m : modelNodes) { + if (m->properties.size() >= 3 && m->properties[2].stringVal == "LimbNode") { + if (m->properties[1].stringVal.find("bone") != std::string::npos) + boneModel = m; + } + } + ASSERT_NE(boneModel, nullptr); + + auto* props = boneModel->find("Properties70"); + ASSERT_NE(props, nullptr); + + // The rotation should be decomposed (even in gimbal lock) + auto* lclR = findP70(*props, "Lcl Rotation"); + ASSERT_NE(lclR, nullptr); + // Verify the decomposition produced some rotation values + // (exact values depend on the gimbal lock fallback path) + EXPECT_TRUE(lclR->properties.size() >= 7); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, Definitions_Skeletal) { + auto name = uniqueName("def_skel"); + auto* entity = createAnimatedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* defs = findTopLevel(r.nodes, "Definitions"); + ASSERT_NE(defs, nullptr); + + auto objectTypes = defs->findAll("ObjectType"); + + // Verify additional skeletal types are present + bool hasNodeAttr = false, hasDeformer = false, hasPose = false; + bool hasAnimStack = false, hasAnimLayer = false, hasAnimCurveNode = false, hasAnimCurve = false; + for (const auto* ot : objectTypes) { + if (!ot->properties.empty()) { + const auto& typeName = ot->properties[0].stringVal; + if (typeName == "NodeAttribute") hasNodeAttr = true; + if (typeName == "Deformer") hasDeformer = true; + if (typeName == "Pose") hasPose = true; + if (typeName == "AnimationStack") hasAnimStack = true; + if (typeName == "AnimationLayer") hasAnimLayer = true; + if (typeName == "AnimationCurveNode") hasAnimCurveNode = true; + if (typeName == "AnimationCurve") hasAnimCurve = true; + } + } + EXPECT_TRUE(hasNodeAttr); + EXPECT_TRUE(hasDeformer); + EXPECT_TRUE(hasPose); + EXPECT_TRUE(hasAnimStack); + EXPECT_TRUE(hasAnimLayer); + EXPECT_TRUE(hasAnimCurveNode); + EXPECT_TRUE(hasAnimCurve); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, AnimationLayer) { + auto name = uniqueName("alayer"); + auto* entity = createAnimatedMesh(name); + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto layers = objects->findAll("AnimationLayer"); + ASSERT_EQ(layers.size(), 1u); + + auto* props = layers[0]->find("Properties70"); + ASSERT_NE(props, nullptr); + + auto* weight = findP70(*props, "Weight"); + ASSERT_NE(weight, nullptr); + EXPECT_NEAR(weight->properties[4].doubleVal, 100.0, 0.01); + + cleanup(r); +} + +TEST_F(FBXExporterCoverageTest, VerticesZMirrored_WithNonZeroZ) { + // Create mesh with non-zero Z values to verify Z-negation + auto name = uniqueName("vzn"); + + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float verts[] = {0,0,1.5f, 1,0,2.5f, 0,1,3.5f}; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->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->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,4)); + mesh->_setBoundingSphereRadius(4.0); + mesh->load(); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(name + "_node"); + auto* entity = sceneMgr->createEntity(name + "_entity", mesh); + node->attachObject(entity); + + auto r = exportAndParse(entity); + ASSERT_TRUE(r.success); + + auto* objects = findTopLevel(r.nodes, "Objects"); + auto geomNodes = objects->findAll("Geometry"); + ASSERT_EQ(geomNodes.size(), 1u); + + auto* vertsNode = geomNodes[0]->find("Vertices"); + ASSERT_NE(vertsNode, nullptr); + auto& v = vertsNode->properties[0].doubleArray; + ASSERT_EQ(v.size(), 9u); + + // Z values should be negated: 1.5→-1.5, 2.5→-2.5, 3.5→-3.5 + EXPECT_NEAR(v[2], -1.5, 0.001); + EXPECT_NEAR(v[5], -2.5, 0.001); + EXPECT_NEAR(v[8], -3.5, 0.001); + + cleanup(r); +}