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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/Assimp/MeshProcessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@
}
}

// Process morph targets / blend shapes. Assimp parks each shape's
// deformed positions in `aiAnimMesh::mVertices`; we store them
// verbatim and convert to per-vertex deltas later, at mesh-build
// time, where we already have the base positions in hand for the
// Ogre::Pose constructor. Apply the same Z-up axis bake the base
// vertex pass uses so the shape and base agree on coordinate frame.
for(auto am = 0u; am < mesh->mNumAnimMeshes; am++) {
const aiAnimMesh* anim = mesh->mAnimMeshes[am];
if (!anim || !anim->mVertices || anim->mNumVertices != mesh->mNumVertices) continue;
MorphTargetData target;
target.name = anim->mName.length > 0 ? anim->mName.C_Str()
: (std::string("Shape_") + std::to_string(am));
target.positions.reserve(anim->mNumVertices);
for (auto i = 0u; i < anim->mNumVertices; i++) {
Ogre::Vector3 v(anim->mVertices[i].x, anim->mVertices[i].y, anim->mVertices[i].z);
target.positions.push_back(m_isZup ? R_x90 * v : v);
}
subMeshData->morphTargets.push_back(std::move(target));
}

return subMeshData;
}

Expand Down Expand Up @@ -241,6 +261,83 @@
ogreMesh->_setBounds(Ogre::AxisAlignedBox(minCoords, maxCoords));
ogreMesh->_setBoundingSphereRadius((maxCoords - minCoords).length() / 2.0f);

// Morph targets / blend shapes → Ogre::Pose entries on the mesh.
// Build per-submesh delta poses: Ogre `Pose` stores per-vertex
// offsets relative to the base mesh. Each pose targets a specific
// submesh (index 1-based in Ogre; 0 means "shared vertex data",
// which our importer never uses — every aiMesh gets its own
// SubMesh::vertexData).
//
// For each pose we also create a single VAT_POSE animation
// ("MorphTrack_<name>") with a one-keyframe track at full
// influence; the MorphAnimationManager will use the matching
// AnimationState weight to drive live preview without us having
// to mutate vertex buffers ourselves.
for (size_t s = 0; s < subMeshesData.size(); ++s) {
const auto* smd = subMeshesData[s];
if (smd->morphTargets.empty()) continue;
// Submesh handle is 1-based: 0 = shared, 1..N = per-submesh.
const unsigned short targetSubmesh = static_cast<unsigned short>(s + 1);

Check warning on line 280 in src/Assimp/MeshProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the redundant type with "auto".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41Ql9niLpuLITv1E9N&open=AZ41Ql9niLpuLITv1E9N&pullRequest=569
for (const auto& mt : smd->morphTargets) {
if (mt.positions.size() != smd->vertices.size()) continue;
// Lazy `createPose`: scan for the first non-zero delta
// before allocating the Ogre::Pose. Identical-to-base
// targets (rare with hand-authored content, more common
// with auto-exported FBX) would otherwise leave an empty
// pose on the mesh + an empty Animation referencing it.
Ogre::Pose* pose = nullptr;
for (size_t vi = 0; vi < mt.positions.size(); ++vi) {
const Ogre::Vector3 delta = mt.positions[vi] - smd->vertices[vi];
if (delta.squaredLength() <= 1e-12f) continue;

Check failure on line 291 in src/Assimp/MeshProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41Ql9niLpuLITv1E9M&open=AZ41Ql9niLpuLITv1E9M&pullRequest=569
if (!pose) pose = ogreMesh->createPose(targetSubmesh, mt.name);

Check failure on line 292 in src/Assimp/MeshProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41ZLDsL63nvjzRkKI5&open=AZ41ZLDsL63nvjzRkKI5&pullRequest=569
pose->addVertex(vi, delta);

Check warning on line 293 in src/Assimp/MeshProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

implicit conversion loses integer precision: 'size_t' (aka 'unsigned long') to 'uint32' (aka 'unsigned int')

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41Ql9niLpuLITv1E9L&open=AZ41Ql9niLpuLITv1E9L&pullRequest=569
}
}
}

// One Ogre::Animation per *unique* morph-target name. Same-named
// poses across different submeshes (e.g. a "Smile" target on both
// body and head) all need to drive together off the same
// AnimationState weight, so they share one Animation with one
// VAT_POSE track per affected submesh. Without grouping, the
// second-and-later same-named poses would create a new
// Ogre::Animation but Ogre::Mesh enforces unique animation names —
// we'd either skip them (and they'd never move) or fail to import.
const auto& poseList = ogreMesh->getPoseList();
for (unsigned short pi = 0; pi < poseList.size(); ++pi) {
const Ogre::Pose* pose = poseList[pi];
if (!pose) continue;
const Ogre::String animName = pose->getName();
if (animName.empty()) continue;

// First sighting of this name → create the Animation. Later
// sightings find it via hasAnimation and just append a track.
Ogre::Animation* anim = nullptr;
if (ogreMesh->hasAnimation(animName)) {
anim = ogreMesh->getAnimation(animName);
} else {
anim = ogreMesh->createAnimation(animName, /*length=*/0.0f);
}
if (!anim) continue;

Ogre::VertexAnimationTrack* track = nullptr;
const unsigned short handle = pose->getTarget();
if (anim->hasVertexTrack(handle)) {

Check warning on line 325 in src/Assimp/MeshProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "handle" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41UD9VbrWrxHmeh5UZ&open=AZ41UD9VbrWrxHmeh5UZ&pullRequest=569
// Same submesh + same name → rare content error. Append
// the pose reference to the existing keyframe so the user
// still gets some movement (better than silently dropping).
track = anim->getVertexTrack(handle);
} else {
track = anim->createVertexTrack(handle, Ogre::VAT_POSE);
}
if (!track) continue;
auto* kf = track->getNumKeyFrames() > 0
? static_cast<Ogre::VertexPoseKeyFrame*>(track->getKeyFrame(0))
: track->createVertexPoseKeyFrame(0.0f);
// Full influence on this pose; AnimationState weight scales it.
kf->addPoseReference(pi, 1.0f);
}

// Compile the mesh
ogreMesh->load();

Expand Down
11 changes: 11 additions & 0 deletions src/Assimp/MeshProcessor.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
#include <assimp/scene.h>
#include "MaterialProcessor.h"

/// One blend-shape / morph target on a SubMeshData. Stored as the
/// absolute per-vertex positions of the deformed shape (matches what
/// Assimp gives us via `aiAnimMesh::mVertices`); MeshProcessor
/// converts these to per-vertex deltas relative to `vertices` when
/// creating the Ogre::Pose entries at mesh-build time.
struct MorphTargetData {
std::string name; ///< From `aiAnimMesh::mName`; empty when the source had no name.
std::vector<Ogre::Vector3> positions; ///< Absolute deformed positions, same vertex order as `SubMeshData::vertices`.
};

struct SubMeshData {
std::vector<Ogre::Vector3> vertices;
std::vector<Ogre::Vector3> normals;
Expand All @@ -13,6 +23,7 @@ struct SubMeshData {
std::vector<Ogre::ColourValue> colors;
std::vector<unsigned long> indices;
std::vector<Ogre::VertexBoneAssignment> boneAssignments;
std::vector<MorphTargetData> morphTargets; ///< Empty when source had no blend shapes.
unsigned int materialIndex;
};

Expand Down
99 changes: 99 additions & 0 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include "TexturePaintBuffer.h"
#include "VertexColorBaker.h"
#include "VATBaker.h"
#include "MorphAnimationManager.h"
#include "QtMeshCloudClient.h"
#include <OgreMaterialSerializer.h>
#include <QApplication>
Expand Down Expand Up @@ -673,6 +674,14 @@
" triangle, rasterizes barycentric-interpolated vertex colors,\n"
" then dilates outward by N pixels to mask seam bleed at MIP time.\n"
" Default resolution=1024, dilation=4. Output PNG is RGBA.\n"
" vat <file> --anim <name> [--fps N] [--encoding rgba8|rgba16]\n"
" [--target agnostic|unity|unreal|godot] [--normals] [-o <dir>] [--json]\n"
" Bake a skeletal animation into a Vertex Animation Texture\n"
" (position PNG + JSON sidecar; optional normal PNG). Engine\n"
" targets: agnostic (default), unity (.meta + UV-V flip),\n"
" unreal (Y/Z axis swap), godot (.gdshader template).\n"
" morph <file> --list [--json] List morph targets / blend shapes on a mesh. (Set/add/delete\n"
" land in follow-up slices once authoring is in place.)\n"
"\n"
"Global options:\n"
" --help, -h Show this help\n"
Expand Down Expand Up @@ -1067,6 +1076,7 @@
else if (cmd == "optimize") rc = cmdOptimize(argc, argv);
else if (cmd == "bake-vertex-colors") rc = cmdBakeVertexColors(argc, argv);
else if (cmd == "vat") rc = cmdVat(argc, argv);
else if (cmd == "morph") rc = cmdMorph(argc, argv);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (rc < 0) {
err() << "Error: Unknown command '" << cmd << "'" << Qt::endl;
Expand Down Expand Up @@ -4946,3 +4956,92 @@
}
return 0;
}

int CLIPipeline::cmdMorph(int argc, char* argv[])

Check failure on line 4960 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 36 to the 25 allowed.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41QmCUiLpuLITv1E9a&open=AZ41QmCUiLpuLITv1E9a&pullRequest=569
{
// Parse: morph <file> --list [--json]

Check warning on line 4962 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the commented out code.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41QmCUiLpuLITv1E9c&open=AZ41QmCUiLpuLITv1E9c&pullRequest=569
QString filePath;
bool listMode = false;
bool jsonOutput = false;

for (int i = 1; i < argc; ++i) {
QString arg(argv[i]);
if (arg == "morph" || arg == "--cli") continue;
if (arg == "--list") { listMode = true; continue; }
if (arg == "--json") { jsonOutput = true; continue; }
if (!arg.startsWith("-") && filePath.isEmpty()) {
filePath = arg; continue;
}
}

if (filePath.isEmpty()) {
err() << "Error: No input file specified." << Qt::endl;
err() << "Usage: qtmesh morph <file> --list [--json]" << Qt::endl;
return 2;
}
if (!listMode) {
err() << "Error: morph subcommand requires --list (other modes land in follow-up slices)." << Qt::endl;
return 2;
}

QFileInfo fi(filePath);
if (!fi.exists()) {
err() << "Error: File not found: " << filePath << Qt::endl;
return 1;
}

if (!initOgreHeadless()) return 1;

SentryReporter::addBreadcrumb("cli.morph",
QString("Morph list .%1").arg(fi.suffix()));
SentryReporter::addBreadcrumb("file.import",
QString("Importing %1 for morph list").arg(fi.absoluteFilePath()));

MeshImporterExporter::importer({fi.absoluteFilePath()});

// Walk every imported entity, not just the first — multi-entity
// files (e.g. FBX with separate body + head meshes that both
// carry blend shapes) would otherwise lose the targets on the
// entities the loop missed.
auto& movables = Manager::getSingleton()->getEntities();

Check warning on line 5006 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a reference-to-const. The current type of "movables" is "class QList<class Ogre::Entity *> &".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ41UEBZbrWrxHmeh5Ua&open=AZ41UEBZbrWrxHmeh5Ua&pullRequest=569
QList<Ogre::Entity*> entities;
for (auto* obj : movables) {
if (obj && obj->getMovableType() == "Entity")
entities.append(static_cast<Ogre::Entity*>(obj));
}
if (entities.isEmpty()) {
err() << "Error: Failed to load file: " << filePath << Qt::endl;
return 1;
}

QStringList targets;
QSet<QString> seen;
for (Ogre::Entity* entity : entities) {
const QStringList ents = MorphAnimationManager::instance()->morphTargetsFor(entity);
for (const QString& n : ents) {
if (!seen.contains(n)) {
seen.insert(n);
targets.append(n);
}
}
}

if (jsonOutput) {
QJsonArray arr;
for (const QString& n : targets) arr.append(n);
QJsonObject root;
root["file"] = filePath;
root["count"] = static_cast<int>(targets.size());
root["morphTargets"] = arr;
cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)));
} else {
if (targets.isEmpty()) {
cliWrite(QStringLiteral("No morph targets / blend shapes found.\n"));
} else {
cliWrite(QStringLiteral("Morph targets (%1):\n").arg(targets.size()));
for (const QString& n : targets)
cliWrite(QStringLiteral(" %1\n").arg(n));
}
}
return 0;
}
5 changes: 5 additions & 0 deletions src/CLIPipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ class CLIPipeline {
/// positions only.
static int cmdVat(int argc, char* argv[]);

/// List the morph targets / blend shapes on a mesh file. Slice A1
/// surfaces a `--list` mode only; subsequent slices add `--set`,
/// `--add`, `--delete` once the in-memory authoring path lands.
static int cmdMorph(int argc, char* argv[]);

/// Map file extension to MeshImporterExporter format string.
static QString formatForExtension(const QString& path);
};
Expand Down
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ TexturePaintController.cpp
VertexColorBaker.cpp
VATBaker.cpp
VATBakerController.cpp
MorphAnimationManager.cpp
ApplyAtlas.cpp
EmbeddedTextureCache.cpp
NormalMapGenerator.cpp
Expand Down
Loading
Loading