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
4 changes: 4 additions & 0 deletions qtmesh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ scan:
- "**/*.vrm"
- "**/*.obj"
- "**/*.mesh"
# PlayStation / Psy-Q (QtMeshEditor importers; not Assimp extensions)
- "**/*.tmd"
- "**/*.rsd"
- "**/*.ply"
exclude:
# robot.mesh uses Ogre MeshSerializer v1.40 which Assimp cannot read
- "**/robot.mesh"
Expand Down
2 changes: 1 addition & 1 deletion src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ if(BUILD_TESTS)
${QML_RESOURCE_SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/test_main.cpp
)
target_compile_definitions(UnitTests PRIVATE BATCH_EXPORTER_TEST_SEAM
target_compile_definitions(UnitTests PRIVATE BATCH_EXPORTER_TEST_SEAM QTMESH_UNIT_TESTS
"QTMESH_UT_SOURCE_ROOT=\"${CMAKE_SOURCE_DIR}\"")

# Link against Google Test libraries (no gtest_main - we provide our own main)
Expand Down
5 changes: 5 additions & 0 deletions src/Manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ void Manager::kill()
}
}

QString Manager::defaultImportExtensions()
{
return mValidFileExtention;
}

////////////////////////////////////////
// Constructor & Destructor

Expand Down
3 changes: 3 additions & 0 deletions src/Manager.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ class Manager : public QObject
static Manager* getSingletonPtr(); // Get singleton without creating (returns nullptr if doesn't exist)
static void kill();

/// Default File → Import extensions as space-separated `".ext"` tokens (static; safe before `getSingleton()`).
static QString defaultImportExtensions();

Ogre::Root* getRoot() const;
Ogre::SceneManager* getSceneMgr() const;
MainWindow* getMainWindow() const;
Expand Down
9 changes: 9 additions & 0 deletions src/Manager_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ class ManagerHeadlessTest : public ::testing::Test {
};

// Test the forbidden name function without creating full Manager
TEST_F(ManagerTest, DefaultImportExtensions_IncludesPlayStationFormats)
{
const QString exts = Manager::defaultImportExtensions();
EXPECT_FALSE(exts.isEmpty());
EXPECT_TRUE(exts.contains(QStringLiteral(".tmd")));
EXPECT_TRUE(exts.contains(QStringLiteral(".rsd")));
EXPECT_TRUE(exts.contains(QStringLiteral(".ply")));
}

TEST_F(ManagerTest, Forbidden_Name)
{
// Test static functionality that doesn't require full initialization
Expand Down
11 changes: 8 additions & 3 deletions src/MeshImporterExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1753,10 +1753,10 @@ QString MeshImporterExporter::exportFileDialogFilter()
return filter;
}

QString MeshImporterExporter::importFileDialogFilter()
QString MeshImporterExporter::importFileDialogFilterFromExtensionList(
const QString& spaceSeparatedDotExtensions)
{
const QStringList parts =
Manager::getSingleton()->getValidFileExtention().split(' ', Qt::SkipEmptyParts);
const QStringList parts = spaceSeparatedDotExtensions.split(' ', Qt::SkipEmptyParts);
QStringList globs;
globs.reserve(parts.size());
for (QString ext : parts) {
Expand All @@ -1772,6 +1772,11 @@ QString MeshImporterExporter::importFileDialogFilter()
.arg(allSupported);
}

QString MeshImporterExporter::importFileDialogFilter()
{
return importFileDialogFilterFromExtensionList(Manager::getSingleton()->getValidFileExtention());
}

QString MeshImporterExporter::exporter(const Ogre::SceneNode *_sn)
{
if(!_sn)
Expand Down
3 changes: 3 additions & 0 deletions src/MeshImporterExporter.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class MeshImporterExporter
/// Multi-pattern filter for File → Import (includes PlayStation group + All files).
static QString importFileDialogFilter();

/// Same filter layout as `importFileDialogFilter()` using a space-separated `".ext"` list (no Manager required).
static QString importFileDialogFilterFromExtensionList(const QString& spaceSeparatedDotExtensions);

/// Export the current animated pose of an entity as a static mesh (no skeleton/animation).
/// Reads software-skinned vertex positions, builds a new mesh, and exports it.
/// Returns 0 on success, non-zero on error.
Expand Down
8 changes: 8 additions & 0 deletions src/MeshImporterExporter_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,14 @@ TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ContainsAllForma
EXPECT_TRUE(filter.contains("glTF 2.0 Binary (*.glb)"));
}

TEST(MeshImporterExporterStandaloneTest, ImportFileDialogFilterFromExtensionList_BuildsRows)
{
QString f = MeshImporterExporter::importFileDialogFilterFromExtensionList(QStringLiteral(".fbx .obj"));
EXPECT_TRUE(f.startsWith(QStringLiteral("All supported (*.fbx *.obj);;")));
EXPECT_TRUE(f.contains(QStringLiteral("PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply)")));
EXPECT_TRUE(f.endsWith(QStringLiteral("All files (*.*)")));
}

TEST(MeshImporterExporterStandaloneTest, FormatFileURI_FBXFormat) {
QString result = MeshImporterExporter::formatFileURI("/path/to/model", "FBX Binary (*.fbx)");
EXPECT_EQ(result, "/path/to/model.fbx");
Expand Down
64 changes: 35 additions & 29 deletions src/PS1/PS1PLY.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -882,33 +882,33 @@
bool hasColor = false;
};

static uint32_t normalIndexForWeldedPos(uint32_t posIdx,
uint32_t Ap0,
uint32_t Ap1,
uint32_t Ap2,
uint32_t An0,
uint32_t An1,
uint32_t An2,
uint32_t Bp0,
uint32_t Bp1,
uint32_t Bp2,
uint32_t Bn0,
uint32_t Bn1,
uint32_t Bn2)
struct PsyqWeldedTri {
std::array<uint32_t, 3> pw{};
std::array<uint32_t, 3> nw{};
};

uint32_t weldedNormalAtWeldPos(uint32_t wpos, const PsyqWeldedTri& t)
{
for (int c = 0; c < 3; ++c) {
if (t.pw[static_cast<size_t>(c)] == wpos)
return t.nw[static_cast<size_t>(c)];
}
return std::numeric_limits<uint32_t>::max();
}

bool weldedNormalsAgreeOnInteriorEdge(uint32_t e0, uint32_t e1, const PsyqWeldedTri& A, const PsyqWeldedTri& B)
{
if (Ap0 == posIdx)
return An0;
if (Ap1 == posIdx)
return An1;
if (Ap2 == posIdx)
return An2;
if (Bp0 == posIdx)
return Bn0;
if (Bp1 == posIdx)
return Bn1;
if (Bp2 == posIdx)
return Bn2;
return 0;
return weldedNormalAtWeldPos(e0, A) == weldedNormalAtWeldPos(e0, B)
&& weldedNormalAtWeldPos(e1, A) == weldedNormalAtWeldPos(e1, B);
}

uint32_t normalIndexForWeldedPos(uint32_t posIdx, const PsyqWeldedTri& A, const PsyqWeldedTri& B)
{
const uint32_t na = weldedNormalAtWeldPos(posIdx, A);
if (na != std::numeric_limits<uint32_t>::max())

Check warning on line 908 in src/PS1/PS1PLY.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ4Qe16wJxyiBuPnDXHW&open=AZ4Qe16wJxyiBuPnDXHW&pullRequest=478
return na;
const uint32_t nb = weldedNormalAtWeldPos(posIdx, B);
return nb != std::numeric_limits<uint32_t>::max() ? nb : 0u;
}

static void mergeSubmeshTrisToQuads(const std::vector<uint32_t>& I0,
Expand Down Expand Up @@ -946,16 +946,22 @@
if (!tryMergeTrisToQuad({I0[i], I1[i], I2[i]}, {I0[j], I1[j], I2[j]}, weldPos, minDot, q))
continue;

const PsyqWeldedTri triA{{I0[i], I1[i], I2[i]}, {N0[i], N1[i], N2[i]}};
const PsyqWeldedTri triB{{I0[j], I1[j], I2[j]}, {N0[j], N1[j], N2[j]}};

// Do not merge if the two triangles disagree on welded normal indices along the
// shared interior edge (split / hard-edge shading must stay as two tris).
if (!weldedNormalsAgreeOnInteriorEdge(q[1], q[2], triA, triB))
continue;

PsyqExportFace f;
f.isQuad = true;
f.v[0] = q[0];
f.v[1] = q[1];
f.v[2] = q[2];
f.v[3] = q[3];
for (int k = 0; k < 4; ++k) {
f.n[static_cast<size_t>(k)] = normalIndexForWeldedPos(
q[static_cast<size_t>(k)], I0[i], I1[i], I2[i], N0[i], N1[i], N2[i], I0[j], I1[j], I2[j], N0[j], N1[j],
N2[j]);
f.n[static_cast<size_t>(k)] = normalIndexForWeldedPos(q[static_cast<size_t>(k)], triA, triB);
}
if (haveTriColors) {
const QColor a = triRgb(i);
Expand Down
102 changes: 80 additions & 22 deletions src/PS1/PS1PLY_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ static void ensureBaseMaterialForPlyImport()
m->getTechnique(0)->getPass(0)->setAmbient(1.0f, 1.0f, 1.0f);
}

/** Two triangles (0,1,2) and (1,2,3) — PS1 quad split; flat +Z normal. */
static Ogre::MeshPtr createTwoTriQuadMesh(const std::string& name)
static Ogre::MeshPtr createInterleavedPosNormalMesh(const std::string& name,
const float (*vertexRows)[6],
int nVerts,
const uint16_t* indices,
int indexCount)
{
if (auto old = Ogre::MeshManager::getSingleton().getByName(name))
Ogre::MeshManager::getSingleton().remove(old);
Expand All @@ -50,7 +53,7 @@ static Ogre::MeshPtr createTwoTriQuadMesh(const std::string& name)

Ogre::VertexData* vd = new Ogre::VertexData();
sm->vertexData = vd;
vd->vertexCount = 4;
vd->vertexCount = static_cast<unsigned>(nVerts);
Ogre::VertexDeclaration* decl = vd->vertexDeclaration;
Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding;
size_t off = 0;
Expand All @@ -60,35 +63,29 @@ static Ogre::MeshPtr createTwoTriQuadMesh(const std::string& name)
off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3);
const size_t vsize = decl->getVertexSize(0);
auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer(
vsize, 4, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
vsize, static_cast<size_t>(nVerts), Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
uint8_t* dst = static_cast<uint8_t*>(vbuf->lock(Ogre::HardwareBuffer::HBL_DISCARD));
const float corners[][6] = {
{0.f, 0.f, 0.f, 0.f, 0.f, 1.f},
{1.f, 0.f, 0.f, 0.f, 0.f, 1.f},
{1.f, 1.f, 0.f, 0.f, 0.f, 1.f},
{0.f, 1.f, 0.f, 0.f, 0.f, 1.f},
};
for (int i = 0; i < 4; ++i) {
uint8_t* row = dst + i * vsize;
for (int i = 0; i < nVerts; ++i) {
uint8_t* row = dst + static_cast<size_t>(i) * vsize;
float* pf = nullptr;
decl->findElementBySemantic(Ogre::VES_POSITION)->baseVertexPointerToElement(row, &pf);
pf[0] = corners[i][0];
pf[1] = corners[i][1];
pf[2] = corners[i][2];
pf[0] = vertexRows[i][0];
pf[1] = vertexRows[i][1];
pf[2] = vertexRows[i][2];
decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &pf);
pf[0] = corners[i][3];
pf[1] = corners[i][4];
pf[2] = corners[i][5];
pf[0] = vertexRows[i][3];
pf[1] = vertexRows[i][4];
pf[2] = vertexRows[i][5];
}
vbuf->unlock();
bind->setBinding(0, vbuf);

auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer(
Ogre::HardwareIndexBuffer::IT_16BIT, 6, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
const uint16_t idx[] = {0, 1, 2, 1, 2, 3};
ibuf->writeData(0, sizeof(idx), idx);
Ogre::HardwareIndexBuffer::IT_16BIT, static_cast<size_t>(indexCount),
Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
ibuf->writeData(0, static_cast<size_t>(indexCount) * sizeof(uint16_t), indices);
sm->indexData->indexBuffer = ibuf;
sm->indexData->indexCount = 6;
sm->indexData->indexCount = static_cast<unsigned>(indexCount);
sm->indexData->indexStart = 0;

mesh->_setBounds(Ogre::AxisAlignedBox(0, 0, 0, 1, 1, 0));
Expand All @@ -97,6 +94,34 @@ static Ogre::MeshPtr createTwoTriQuadMesh(const std::string& name)
return mesh;
}

/** Two triangles (0,1,2) and (1,2,3) — PS1 quad split; flat +Z normal. */
static Ogre::MeshPtr createTwoTriQuadMesh(const std::string& name)
{
static const float corners[][6] = {
{0.f, 0.f, 0.f, 0.f, 0.f, 1.f},
{1.f, 0.f, 0.f, 0.f, 0.f, 1.f},
{1.f, 1.f, 0.f, 0.f, 0.f, 1.f},
{0.f, 1.f, 0.f, 0.f, 0.f, 1.f},
};
static const uint16_t idx[] = {0, 1, 2, 1, 2, 3};
return createInterleavedPosNormalMesh(name, corners, 4, idx, 6);
}

/** Two coplanar tris sharing a geometric edge with different normals on that edge (6 verts). */
static Ogre::MeshPtr createSplitNormalTwoTriMesh(const std::string& name)
{
static const float rows[][6] = {
{0.f, 0.f, 0.f, 0.f, 0.f, 1.f},
{1.f, 0.f, 0.f, 0.f, 0.f, 1.f},
{1.f, 1.f, 0.f, 0.f, 0.f, 1.f},
{1.f, 0.f, 0.f, 1.f, 0.f, 0.f},
{1.f, 1.f, 0.f, 1.f, 0.f, 0.f},
{0.f, 1.f, 0.f, 0.f, 0.f, 1.f},
};
static const uint16_t idx[] = {0, 1, 2, 3, 4, 5};
return createInterleavedPosNormalMesh(name, rows, 6, idx, 6);
}

static bool readPsyqPlyCountsAndFirstFace(const QString& path, int& nV, int& nN, int& nF, QString& firstFaceLine)
{
QFile file(path);
Expand Down Expand Up @@ -255,6 +280,39 @@ TEST_F(PS1PLYOgreTest, ExportHeuristicMergeProducesOneQuadAndSharedNormalPool)
EXPECT_TRUE(face0.startsWith(QLatin1String("1 ")));
}

TEST_F(PS1PLYOgreTest, ExportHeuristicSkipsQuadMergeWhenSharedEdgeNormalsDisagree)
{
ASSERT_TRUE(canLoadMeshFiles());

const std::string meshName = "PS1PlySplitNormalHeuristicMesh";
Ogre::MeshPtr mesh = createSplitNormalTwoTriMesh(meshName);
ASSERT_TRUE(mesh);

auto* mgr = Manager::getSingleton();
Ogre::SceneNode* node = mgr->addSceneNode(QStringLiteral("PS1PlySplitNormalHeuristicNode"));
ASSERT_NE(node, nullptr);
Ogre::Entity* ent = mgr->createEntity(node, mesh);
ASSERT_NE(ent, nullptr);

QTemporaryFile outPly(QDir::tempPath() + QStringLiteral("/qtmesh_ps1ply_splitnorm_XXXXXX.ply"));
outPly.setAutoRemove(true);
ASSERT_TRUE(outPly.open());
outPly.close();
const QString path = outPly.fileName();

QString err;
ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, path, nullptr, &err)) << err.toUtf8().constData();

mgr->destroySceneNode(QStringLiteral("PS1PlySplitNormalHeuristicNode"));
Ogre::MeshManager::getSingleton().remove(meshName);

int nV = 0, nN = 0, nF = 0;
QString face0;
ASSERT_TRUE(readPsyqPlyCountsAndFirstFace(path, nV, nN, nF, face0));
EXPECT_EQ(nF, 2);
EXPECT_TRUE(face0.startsWith(QLatin1String("0 ")));
}

TEST_F(PS1PLYOgreTest, ImportQuadThenExportKeepsSingleQuadFaceLine)
{
ASSERT_TRUE(canLoadMeshFiles());
Expand Down
Loading
Loading