From 92190cc7a21ca402378152ed57fb3a1c5693030a Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 9 May 2026 16:46:20 -0400 Subject: [PATCH 01/12] feat(PS1): weld PLY import vertices; merge coplanar tris to quads on export - buildMeshFromTriSoup: indexed mesh with welded corners (quantized pos/normal/colour) - exportPsyqPlyFromEntity: per-submesh quad merge (shared directed edge, normal agreement) with Psy-Q quad face lines; MAT face colours only if all exported faces have colour data - Remove duplicate WeldKey/quantize definitions after moving welding keys earlier Co-authored-by: Cursor --- src/PS1/PS1PLY.cpp | 315 ++++++++++++++++++++++++++++++++++++--------- src/PS1/PS1PLY.h | 6 +- 2 files changed, 260 insertions(+), 61 deletions(-) diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 0d76d2dd4..3fd79da48 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -25,8 +25,10 @@ The MIT License #include #include +#include #include #include +#include #include namespace { @@ -37,6 +39,39 @@ struct TriSoup { std::vector col; // optional; if present must match pos size }; +static int32_t quantizeWorld(Ogre::Real v) +{ + return static_cast(std::lround(double(v) * 100000.0)); +} + +struct WeldKey { + int32_t px, py, pz, nx, ny, nz; + int32_t crgba; + + bool operator==(const WeldKey& o) const + { + return px == o.px && py == o.py && pz == o.pz && nx == o.nx && ny == o.ny && nz == o.nz && crgba == o.crgba; + } +}; + +struct WeldKeyHash { + size_t operator()(const WeldKey& k) const noexcept + { + size_t h = 1469598103934665603ull; + auto mix = [&](int32_t x) { + h ^= static_cast(static_cast(x)) * 1099511628211ull; + }; + mix(k.px); + mix(k.py); + mix(k.pz); + mix(k.nx); + mix(k.ny); + mix(k.nz); + mix(k.crgba); + return h; + } +}; + static void applyPlyImportWorldTransform(Ogre::Vector3& p) { p *= PS1PLY::kPsyqPlyEditorUniformScale; @@ -213,6 +248,43 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri return {}; const bool haveColors = (!soup.col.empty() && soup.col.size() == soup.pos.size()); + std::vector uniqPos; + std::vector uniqNrm; + std::vector uniqCol; + std::vector indices; + uniqPos.reserve(soup.pos.size()); + uniqNrm.reserve(soup.nrm.size()); + indices.reserve(soup.pos.size()); + if (haveColors) + uniqCol.reserve(soup.pos.size()); + + std::unordered_map cornerWeld; + cornerWeld.reserve(soup.pos.size() / 2); + + for (size_t i = 0; i < soup.pos.size(); ++i) { + int32_t crgba = 0; + if (haveColors) + crgba = static_cast(soup.col[i]); + const WeldKey key{quantizeWorld(soup.pos[i].x), quantizeWorld(soup.pos[i].y), quantizeWorld(soup.pos[i].z), + quantizeWorld(soup.nrm[i].x), quantizeWorld(soup.nrm[i].y), quantizeWorld(soup.nrm[i].z), + crgba}; + const auto it = cornerWeld.find(key); + if (it == cornerWeld.end()) { + const uint32_t ni = static_cast(uniqPos.size()); + cornerWeld.emplace(key, ni); + uniqPos.push_back(soup.pos[i]); + uniqNrm.push_back(soup.nrm[i]); + if (haveColors) + uniqCol.push_back(soup.col[i]); + indices.push_back(ni); + } else + indices.push_back(it->second); + } + + const size_t nVert = uniqPos.size(); + const size_t nIdx = indices.size(); + const size_t nTri = nIdx / 3; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) Ogre::MeshManager::getSingleton().remove(old); @@ -231,7 +303,6 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri sm->setMaterialName(Ogre::MaterialManager::getSingleton().getByName(plyMatName) ? plyMatName : "BaseMaterial"); sm->useSharedVertices = false; - const size_t nVert = soup.pos.size(); sm->vertexData = new Ogre::VertexData(); sm->vertexData->vertexCount = static_cast(nVert); auto* decl = sm->vertexData->vertexDeclaration; @@ -253,17 +324,17 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri uint8_t* row = dst + i * vsize; float* pf = nullptr; decl->findElementBySemantic(Ogre::VES_POSITION)->baseVertexPointerToElement(row, &pf); - pf[0] = soup.pos[i].x; - pf[1] = soup.pos[i].y; - pf[2] = soup.pos[i].z; + pf[0] = uniqPos[i].x; + pf[1] = uniqPos[i].y; + pf[2] = uniqPos[i].z; decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &pf); - pf[0] = soup.nrm[i].x; - pf[1] = soup.nrm[i].y; - pf[2] = soup.nrm[i].z; + pf[0] = uniqNrm[i].x; + pf[1] = uniqNrm[i].y; + pf[2] = uniqNrm[i].z; if (haveColors) { Ogre::RGBA* cp = nullptr; decl->findElementBySemantic(Ogre::VES_DIFFUSE)->baseVertexPointerToElement(row, (void**)&cp); - *cp = soup.col[i]; + *cp = uniqCol[i]; } } vbuf->unlock(); @@ -276,7 +347,6 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { Ogre::Pass* p0 = mat->getTechnique(0)->getPass(0); if (p0) { - // Lighting ON by default; vertex colors (if present) modulate diffuse/ambient. p0->setLightingEnabled(true); p0->setAmbient(1.0f, 1.0f, 1.0f); p0->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); @@ -288,20 +358,19 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri } catch (...) { } - const size_t nTri = nVert / 3; const bool use32 = nVert > 65535; auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( - use32 ? Ogre::HardwareIndexBuffer::IT_32BIT : Ogre::HardwareIndexBuffer::IT_16BIT, nTri * 3, + use32 ? Ogre::HardwareIndexBuffer::IT_32BIT : Ogre::HardwareIndexBuffer::IT_16BIT, nIdx, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); if (use32) { auto* ip = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); - for (size_t i = 0; i < nVert; ++i) - ip[i] = static_cast(i); + for (size_t i = 0; i < nIdx; ++i) + ip[i] = indices[i]; ibuf->unlock(); } else { auto* ip = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); - for (size_t i = 0; i < nVert; ++i) - ip[i] = static_cast(i); + for (size_t i = 0; i < nIdx; ++i) + ip[i] = static_cast(indices[i]); ibuf->unlock(); } sm->indexData->indexBuffer = ibuf; @@ -309,7 +378,7 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri sm->indexData->indexStart = 0; Ogre::AxisAlignedBox bounds; - for (const auto& v : soup.pos) + for (const auto& v : uniqPos) bounds.merge(v); mesh->_setBounds(bounds); mesh->_setBoundingSphereRadius(bounds.getHalfSize().length()); @@ -527,38 +596,136 @@ static Ogre::ColourValue decodePackedColour(const Ogre::VertexElement* colEl, Og return cv; } -static int32_t quantizeWorld(Ogre::Real v) +static Ogre::Vector3 triFaceNormalWelded(const Ogre::Vector3& p0, const Ogre::Vector3& p1, const Ogre::Vector3& p2) { - return static_cast(std::lround(double(v) * 100000.0)); + Ogre::Vector3 n = (p1 - p0).crossProduct(p2 - p0); + const float len = n.normalise(); + if (len <= 1e-20f) + return Ogre::Vector3::ZERO; + return n; } -struct WeldKey { - int32_t px, py, pz, nx, ny, nz; - int32_t crgba; // raw packed diffuse, or 0 if mesh has no per-vertex colour +static bool hasDirectedEdge(uint32_t e0, uint32_t e1, uint32_t x, uint32_t y, uint32_t z) +{ + return (x == e0 && y == e1) || (y == e0 && z == e1) || (z == e0 && x == e1); +} - bool operator==(const WeldKey& o) const - { - return px == o.px && py == o.py && pz == o.pz && nx == o.nx && ny == o.ny && nz == o.nz && crgba == o.crgba; +static bool tryMergeTrisToQuad(const std::array& A, + const std::array& B, + const std::vector& wp, + float minNormalDot, + std::array& quad) +{ + for (int e = 0; e < 3; ++e) { + const uint32_t o = A[static_cast(e)]; + const uint32_t e0 = A[static_cast((e + 1) % 3)]; + const uint32_t e1 = A[static_cast((e + 2) % 3)]; + + if (!hasDirectedEdge(e0, e1, B[0], B[1], B[2])) + continue; + + uint32_t d = 0; + bool foundD = false; + for (uint32_t t : {B[0], B[1], B[2]}) { + if (t != e0 && t != e1) { + d = t; + foundD = true; + break; + } + } + if (!foundD || d == o) + continue; + if (o == e0 || o == e1) + continue; + + std::unordered_set uniq({o, e0, e1, d}); + if (uniq.size() != 4) + continue; + + const Ogre::Vector3 nA = triFaceNormalWelded(wp[o], wp[e0], wp[e1]); + const Ogre::Vector3 nB = triFaceNormalWelded(wp[e0], wp[e1], wp[d]); + if (nA.isZeroLength() || nB.isZeroLength()) + continue; + if (nA.dotProduct(nB) < minNormalDot) + continue; + + quad = {o, e0, e1, d}; + return true; } + return false; +} + +struct PsyqExportFace { + bool isQuad = false; + uint32_t v[4] = {}; + QColor color; + bool hasColor = false; }; -struct WeldKeyHash { - size_t operator()(const WeldKey& k) const noexcept - { - size_t h = 1469598103934665603ull; - auto mix = [&](int32_t x) { - h ^= static_cast(static_cast(x)) * 1099511628211ull; - }; - mix(k.px); - mix(k.py); - mix(k.pz); - mix(k.nx); - mix(k.ny); - mix(k.nz); - mix(k.crgba); - return h; +static void mergeSubmeshTrisToQuads(const std::vector& I0, + const std::vector& I1, + const std::vector& I2, + const std::vector& weldPos, + const QVector* triFaceColors, + std::vector& outFaces) +{ + const size_t n = I0.size(); + if (I1.size() != n || I2.size() != n || n == 0) + return; + + std::vector used(n, 0); + const float minDot = 0.98f; + const bool haveTriColors = (triFaceColors && triFaceColors->size() == static_cast(n)); + + auto triRgb = [&](size_t i) -> QColor { + return haveTriColors ? (*triFaceColors)[static_cast(i)] : QColor(); + }; + + for (size_t i = 0; i < n; ++i) { + if (used[i]) + continue; + + bool merged = false; + for (size_t j = i + 1; j < n && !merged; ++j) { + if (used[j]) + continue; + + std::array q{}; + if (!tryMergeTrisToQuad({I0[i], I1[i], I2[i]}, {I0[j], I1[j], I2[j]}, weldPos, minDot, q)) + 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]; + if (haveTriColors) { + const QColor a = triRgb(i); + const QColor b = triRgb(j); + f.color = QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2); + f.hasColor = true; + } + outFaces.push_back(f); + used[i] = used[j] = 1; + merged = true; + } + + if (!merged) { + PsyqExportFace f; + f.isQuad = false; + f.v[0] = I0[i]; + f.v[1] = I1[i]; + f.v[2] = I2[i]; + if (haveTriColors) { + f.color = triRgb(i); + f.hasColor = true; + } + outFaces.push_back(f); + used[i] = 1; + } } -}; +} } // namespace @@ -678,14 +845,8 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, std::unordered_map weld; weld.reserve(size_t(totalFaces) * 3u); - std::vector triI0, triI1, triI2; - triI0.reserve(totalFaces); - triI1.reserve(totalFaces); - triI2.reserve(totalFaces); - if (outFaceColors) { - outFaceColors->clear(); - outFaceColors->reserve(static_cast(totalFaces)); - } + std::vector allExportFaces; + allExportFaces.reserve(totalFaces); auto weldCorner = [&](const Ogre::Vector3& p, const Ogre::Vector3& n, int32_t crgba) -> uint32_t { const WeldKey key{quantizeWorld(p.x), quantizeWorld(p.y), quantizeWorld(p.z), @@ -725,6 +886,15 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const unsigned ist = sd.id->indexStart; const size_t triCount = sd.id->indexCount / 3; + std::vector smI0; + std::vector smI1; + std::vector smI2; + QVector smFaceCols; + smI0.reserve(triCount); + smI1.reserve(triCount); + smI2.reserve(triCount); + const bool collectFaceColors = (outFaceColors != nullptr && sd.colEl && colBase); + for (size_t t = 0; t < triCount; ++t) { uint32_t i0, i1, i2; if (idx32) { @@ -773,11 +943,11 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const uint32_t w0 = weldCorner(p0, n0, c0); const uint32_t w1 = weldCorner(p1, n1, c1); const uint32_t w2 = weldCorner(p2, n2, c2); - triI0.push_back(w0); - triI1.push_back(w1); - triI2.push_back(w2); + smI0.push_back(w0); + smI1.push_back(w1); + smI2.push_back(w2); - if (outFaceColors && sd.colEl && colBase) { + if (collectFaceColors) { const Ogre::ColourValue cv0 = decodePackedColour(sd.colEl, static_cast(c0)); const Ogre::ColourValue cv1 = decodePackedColour(sd.colEl, static_cast(c1)); const Ogre::ColourValue cv2 = decodePackedColour(sd.colEl, static_cast(c2)); @@ -785,10 +955,18 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, (cv0.g + cv1.g + cv2.g) / 3.0f, (cv0.b + cv1.b + cv2.b) / 3.0f, 1.0f); - outFaceColors->push_back(QColor::fromRgbF(ca.r, ca.g, ca.b, 1.0)); + smFaceCols.push_back(QColor::fromRgbF(ca.r, ca.g, ca.b, 1.0)); } } + std::vector subFaces; + mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, + collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) + ? &smFaceCols + : nullptr, + subFaces); + allExportFaces.insert(allExportFaces.end(), subFaces.begin(), subFaces.end()); + ibuf->unlock(); if (sd.colBuf && colBase && sd.colEl->getSource() != sd.posEl->getSource() && !(sd.colEl->getSource() == sd.nrmEl->getSource() && sd.nrmEl->getSource() != sd.posEl->getSource())) { @@ -799,8 +977,24 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, sd.posBuf->unlock(); } + if (outFaceColors) { + outFaceColors->clear(); + bool allColored = !allExportFaces.empty(); + for (const PsyqExportFace& ef : allExportFaces) { + if (!ef.hasColor) { + allColored = false; + break; + } + } + if (allColored) { + outFaceColors->reserve(static_cast(allExportFaces.size())); + for (const PsyqExportFace& ef : allExportFaces) + outFaceColors->push_back(ef.color); + } + } + const uint32_t nV = static_cast(weldedPos.size()); - const uint32_t nWrittenFaces = static_cast(triI0.size()); + const uint32_t nWrittenFaces = static_cast(allExportFaces.size()); QFile f(plyPath); if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { @@ -818,11 +1012,14 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, for (const Ogre::Vector3& n : weldedNrm) ts << n.x << " " << n.y << " " << n.z << "\n"; - for (uint32_t fi = 0; fi < nWrittenFaces; ++fi) { - const uint32_t v0 = triI0[fi]; - const uint32_t v1 = triI1[fi]; - const uint32_t v2 = triI2[fi]; - ts << "0 " << v0 << " " << v1 << " " << v2 << " 0 " << v0 << " " << v1 << " " << v2 << " 0\n"; + for (const PsyqExportFace& ef : allExportFaces) { + if (ef.isQuad) { + ts << "1 " << ef.v[0] << " " << ef.v[1] << " " << ef.v[2] << " " << ef.v[3] << " " << ef.v[0] << " " + << ef.v[1] << " " << ef.v[2] << " " << ef.v[3] << "\n"; + } else { + ts << "0 " << ef.v[0] << " " << ef.v[1] << " " << ef.v[2] << " 0 " << ef.v[0] << " " << ef.v[1] << " " + << ef.v[2] << " 0\n"; + } } if (ts.status() != QTextStream::Ok) { diff --git a/src/PS1/PS1PLY.h b/src/PS1/PS1PLY.h index b0d517292..949d1cc48 100644 --- a/src/PS1/PS1PLY.h +++ b/src/PS1/PS1PLY.h @@ -38,8 +38,10 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, const std::string& meshName, const QVector& faceColors); -/// Export an Ogre entity as Psy-Q PLY. If outFaceColors is provided and vertex colours exist, -/// one averaged RGB entry per written face is appended (for writing a MAT sidecar). +/// Export an Ogre entity as Psy-Q PLY. Welds corners that share the same quantized +/// position, normal, and (if present) vertex colour, then merges coplanar triangle pairs +/// into quad face records (type 1) where possible. If outFaceColors is provided and vertex +/// colours exist on all submeshes, one RGB per written face is filled (for a MAT sidecar). bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const QString& plyPath, QVector* outFaceColors = nullptr, From 4c6bafa106f226146486e436795525c1d97c832d Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 9 May 2026 17:12:08 -0400 Subject: [PATCH 02/12] fix(PS1): preserve Psy-Q quad topology on mesh for reliable PLY export - Record per-face vertex count (3 or 4) when parsing PLY; store blob on mesh under kPsyqPlyFaceLayoutUserKey after import - Export uses stored layout on single-submesh meshes when triangle count still matches; emit type-1 quad lines without heuristic merge; fall back if winding no longer matches - Document that Ogre still renders triangle lists; quads are not n-gons in GPU Co-authored-by: Cursor --- src/PS1/PS1PLY.cpp | 206 ++++++++++++++++++++++++++++++++++++++++++--- src/PS1/PS1PLY.h | 16 +++- 2 files changed, 207 insertions(+), 15 deletions(-) diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 3fd79da48..401dbbb4a 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -17,6 +17,7 @@ The MIT License #include #include #include +#include #include #include @@ -386,9 +387,88 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri return mesh; } -static bool parsePsyqPlyLines(const QStringList& lines, TriSoup& outSoup, const QVector* faceColors) +static size_t triCountFromPsyqFaceLayout(const std::vector& layout) +{ + size_t t = 0; + for (uint8_t c : layout) { + if (c == 3) + ++t; + else if (c == 4) + t += 2; + } + return t; +} + +static std::string serializePsyqPlyFaceLayout(const std::vector& layout) +{ + // Magic "QP1F" + little-endian uint32 count + raw bytes (each 3 or 4). + std::string s; + s.resize(8 + layout.size()); + s[0] = 'Q'; + s[1] = 'P'; + s[2] = '1'; + s[3] = 'F'; + const uint32_t n = static_cast(layout.size()); + s[4] = static_cast(n & 0xFF); + s[5] = static_cast((n >> 8) & 0xFF); + s[6] = static_cast((n >> 16) & 0xFF); + s[7] = static_cast((n >> 24) & 0xFF); + for (size_t i = 0; i < layout.size(); ++i) + s[8 + i] = static_cast(layout[i]); + return s; +} + +static bool deserializePsyqPlyFaceLayout(const std::string& blob, std::vector& outLayout) +{ + outLayout.clear(); + if (blob.size() < 8 || blob[0] != 'Q' || blob[1] != 'P' || blob[2] != '1' || blob[3] != 'F') + return false; + const uint32_t n = static_cast(static_cast(blob[4])) + | (static_cast(static_cast(blob[5])) << 8) + | (static_cast(static_cast(blob[6])) << 16) + | (static_cast(static_cast(blob[7])) << 24); + if (blob.size() != 8u + static_cast(n)) + return false; + outLayout.resize(n); + for (uint32_t i = 0; i < n; ++i) { + const uint8_t c = static_cast(blob[8u + i]); + if (c != 3 && c != 4) + return false; + outLayout[i] = c; + } + return true; +} + +static bool tryLoadPsyqPlyFaceLayoutFromMesh(const Ogre::MeshPtr& mesh, std::vector& outLayout) +{ + outLayout.clear(); + if (!mesh) + return false; + const Ogre::Any& a = mesh->getUserObjectBindings().getUserAny(PS1PLY::kPsyqPlyFaceLayoutUserKey); + if (a.isEmpty()) + return false; + try { + const std::string& blob = Ogre::any_cast(a); + return deserializePsyqPlyFaceLayout(blob, outLayout); + } catch (...) { + return false; + } +} + +static void storePsyqPlyFaceLayoutOnMesh(const Ogre::MeshPtr& mesh, const std::vector& layout) +{ + if (!mesh || layout.empty()) + return; + mesh->getUserObjectBindings().setUserAny(PS1PLY::kPsyqPlyFaceLayoutUserKey, + Ogre::Any(serializePsyqPlyFaceLayout(layout))); +} + +static bool parsePsyqPlyLines(const QStringList& lines, TriSoup& outSoup, const QVector* faceColors, + std::vector* logicalFaceVertCounts = nullptr) { outSoup = {}; + if (logicalFaceVertCounts) + logicalFaceVertCounts->clear(); static const QRegularExpression kHeaderRe(QStringLiteral("^@PLY\\d*\\s*$"), QRegularExpression::CaseInsensitiveOption); int idx = 0; @@ -471,6 +551,8 @@ static bool parsePsyqPlyLines(const QStringList& lines, TriSoup& outSoup, const return false; if (n0 >= nN || n1 >= nN || n2 >= nN) return false; + if (logicalFaceVertCounts) + logicalFaceVertCounts->push_back(3); if (useFaceColors) appendTriMaybeFlipColored(soup, verts, norms, v0, v1, v2, n0, n1, n2, faceRgba); else @@ -489,6 +571,8 @@ static bool parsePsyqPlyLines(const QStringList& lines, TriSoup& outSoup, const return false; if (n0 >= nN || n1 >= nN || n2 >= nN || n3 >= nN) return false; + if (logicalFaceVertCounts) + logicalFaceVertCounts->push_back(4); if (useFaceColors) { appendTriMaybeFlipColored(soup, verts, norms, v0, v1, v2, n0, n1, n2, faceRgba); appendTriMaybeFlipColored(soup, verts, norms, v1, v2, v3, n1, n2, n3, faceRgba); @@ -530,11 +614,15 @@ static bool parsePsyqPlyLines(const QStringList& lines, TriSoup& outSoup, const return false; if (cnt == 3) { + if (logicalFaceVertCounts) + logicalFaceVertCounts->push_back(3); if (useFaceColors) appendTriMaybeFlipColored(soup, verts, norms, v0, v1, v2, n0, n1, n2, faceRgba); else appendTri(soup, verts, norms, v0, v1, v2, n0, n1, n2); } else { + if (logicalFaceVertCounts) + logicalFaceVertCounts->push_back(4); if (useFaceColors) { appendTriMaybeFlipColored(soup, verts, norms, v0, v1, v2, n0, n1, n2, faceRgba); appendTriMaybeFlipColored(soup, verts, norms, v1, v2, v3, n1, n2, n3, faceRgba); @@ -662,6 +750,79 @@ struct PsyqExportFace { bool hasColor = false; }; +/** Rebuild Psy-Q face list from stored import layout + current welded triangle indices (export order). */ +static bool buildExportFacesFromLayout(const std::vector& I0, + const std::vector& I1, + const std::vector& I2, + const std::vector& layout, + const QVector* triFaceColors, + std::vector& outFaces) +{ + outFaces.clear(); + size_t ti = 0; + const bool haveCol = triFaceColors && triFaceColors->size() == static_cast(I0.size()); + + for (uint8_t nc : layout) { + if (nc == 3) { + if (ti >= I0.size()) + return false; + PsyqExportFace f; + f.isQuad = false; + f.v[0] = I0[ti]; + f.v[1] = I1[ti]; + f.v[2] = I2[ti]; + if (haveCol) { + f.color = (*triFaceColors)[static_cast(ti)]; + f.hasColor = true; + } + outFaces.push_back(f); + ++ti; + } else if (nc == 4) { + if (ti + 1 >= I0.size()) + return false; + const uint32_t q0 = I0[ti], q1 = I1[ti], q2 = I2[ti]; + const uint32_t b0 = I0[ti + 1], b1 = I1[ti + 1], b2 = I2[ti + 1]; + if (q1 == b0 && q2 == b1) { + PsyqExportFace f; + f.isQuad = true; + f.v[0] = q0; + f.v[1] = q1; + f.v[2] = q2; + f.v[3] = b2; + if (haveCol) { + const QColor& a = (*triFaceColors)[static_cast(ti)]; + const QColor& b = (*triFaceColors)[static_cast(ti + 1)]; + f.color = QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2); + f.hasColor = true; + } + outFaces.push_back(f); + } else { + PsyqExportFace t1; + t1.isQuad = false; + t1.v[0] = q0; + t1.v[1] = q1; + t1.v[2] = q2; + PsyqExportFace t2; + t2.isQuad = false; + t2.v[0] = b0; + t2.v[1] = b1; + t2.v[2] = b2; + if (haveCol) { + t1.color = (*triFaceColors)[static_cast(ti)]; + t1.hasColor = true; + t2.color = (*triFaceColors)[static_cast(ti + 1)]; + t2.hasColor = true; + } + outFaces.push_back(t1); + outFaces.push_back(t2); + } + ti += 2; + } else + return false; + } + return ti == I0.size(); +} + static void mergeSubmeshTrisToQuads(const std::vector& I0, const std::vector& I1, const std::vector& I2, @@ -674,7 +835,7 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, return; std::vector used(n, 0); - const float minDot = 0.98f; + const float minDot = 0.94f; const bool haveTriColors = (triFaceColors && triFaceColors->size() == static_cast(n)); auto triRgb = [&](size_t i) -> QColor { @@ -750,10 +911,15 @@ Ogre::MeshPtr importPsyqPly(const QString& filePath, const std::string& meshName const QString text = QString::fromLatin1(f.readAll()); const QStringList lines = readNonEmptyLines(text); TriSoup soup; - if (!parsePsyqPlyLines(lines, soup, nullptr)) + std::vector faceLayout; + if (!parsePsyqPlyLines(lines, soup, nullptr, &faceLayout)) return {}; - return buildMeshFromTriSoup(meshName, soup); + Ogre::MeshPtr mesh = buildMeshFromTriSoup(meshName, soup); + if (mesh && !faceLayout.empty() + && triCountFromPsyqFaceLayout(faceLayout) == soup.pos.size() / 3u) + storePsyqPlyFaceLayoutOnMesh(mesh, faceLayout); + return mesh; } Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, @@ -766,9 +932,14 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, const QString text = QString::fromLatin1(f.readAll()); const QStringList lines = readNonEmptyLines(text); TriSoup soup; - if (!parsePsyqPlyLines(lines, soup, &faceColors)) + std::vector faceLayout; + if (!parsePsyqPlyLines(lines, soup, &faceColors, &faceLayout)) return {}; - return buildMeshFromTriSoup(meshName, soup); + Ogre::MeshPtr mesh = buildMeshFromTriSoup(meshName, soup); + if (mesh && !faceLayout.empty() + && triCountFromPsyqFaceLayout(faceLayout) == soup.pos.size() / 3u) + storePsyqPlyFaceLayoutOnMesh(mesh, faceLayout); + return mesh; } bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, @@ -838,6 +1009,11 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, return false; } + std::vector meshFaceLayout; + const bool haveMeshLayout = + (numSub == 1u && tryLoadPsyqPlyFaceLayoutFromMesh(mesh, meshFaceLayout) + && triCountFromPsyqFaceLayout(meshFaceLayout) == static_cast(totalFaces)); + std::vector weldedPos; std::vector weldedNrm; weldedPos.reserve(size_t(totalFaces) * 3u); @@ -861,7 +1037,8 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, return idx; }; - for (SubData& sd : subs) { + for (unsigned si = 0; si < subs.size(); ++si) { + SubData& sd = subs[si]; const uint8_t* posBase = static_cast(sd.posBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); const uint8_t* nrmBase = nullptr; if (sd.nrmEl->getSource() == sd.posEl->getSource()) { @@ -960,11 +1137,16 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, } std::vector subFaces; - mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, - collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) - ? &smFaceCols - : nullptr, - subFaces); + QVector* colorMerge = + collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) ? &smFaceCols : nullptr; + + if (haveMeshLayout && numSub == 1u && si == 0u + && triCountFromPsyqFaceLayout(meshFaceLayout) == smI0.size()) { + if (!buildExportFacesFromLayout(smI0, smI1, smI2, meshFaceLayout, colorMerge, subFaces)) + mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, colorMerge, subFaces); + } else { + mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, colorMerge, subFaces); + } allExportFaces.insert(allExportFaces.end(), subFaces.begin(), subFaces.end()); ibuf->unlock(); diff --git a/src/PS1/PS1PLY.h b/src/PS1/PS1PLY.h index 949d1cc48..bd9f51e4d 100644 --- a/src/PS1/PS1PLY.h +++ b/src/PS1/PS1PLY.h @@ -23,9 +23,17 @@ The MIT License * PlayStation-RSD-Blender exporter: @PLY header, vertex/normal counts, * vertices, normals (per-vertex then per-face), face lines (0=triangle, * 1=quad) with separate normal indices. + * + * Rendering: Ogre stores triangle index buffers, so quads from a PLY are expanded to + * two triangles at import. The original face layout (triangle vs quad) is stored on + * the mesh (see kPsyqPlyFaceLayoutUserKey) so Psy-Q export can write quad lines back + * without guessing from topology. */ namespace PS1PLY { +/// Ogre::Mesh UserObjectBindings key: std::string blob (see PS1PLY.cpp) listing 3 or 4 per logical face. +inline constexpr const char kPsyqPlyFaceLayoutUserKey[] = "qtme.psyq_ply_face_layout"; + /// Uniform scale for RSD sidecar Psy-Q PLY geometry (kept at 1× so ring-style assets stay editor-sized). constexpr float kPsyqPlyEditorUniformScale = 1.0f; @@ -39,9 +47,11 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, const QVector& faceColors); /// Export an Ogre entity as Psy-Q PLY. Welds corners that share the same quantized -/// position, normal, and (if present) vertex colour, then merges coplanar triangle pairs -/// into quad face records (type 1) where possible. If outFaceColors is provided and vertex -/// colours exist on all submeshes, one RGB per written face is filled (for a MAT sidecar). +/// position, normal, and (if present) vertex colour. If the mesh has kPsyqPlyFaceLayoutUserKey +/// from a prior Psy-Q import and triangle order still matches, quad face records (type 1) +/// are written from that layout; otherwise coplanar triangle pairs are merged heuristically. +/// If outFaceColors is provided and vertex colours exist on all submeshes, one RGB per +/// written face is filled (for a MAT sidecar). bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const QString& plyPath, QVector* outFaceColors = nullptr, From 5ce70dba0bbe97bf6f7fe5aedddfdcb6f940c9ce Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 9 May 2026 19:24:03 -0400 Subject: [PATCH 03/12] feat(PS1): Psy-Q PLY quad/ngon round-trip via qtme.faces cache - On import, rebuild polygon lists from Psy-Q face layout and welded triangle indices, then writeNgonFacesToMesh (same qtme.faces. binding as FBX) so Edit Mode and export see true quads. - Recover quad corner order as PS1/TMD split (v0,v1,v2)+(v1,v2,v3) after per-triangle winding flips, instead of sorting corners by angle. - Export prefers readNgonFacesFromMesh for single-submesh meshes; fan n-gons to Psy-Q triangles; fall back to heuristic quad merge otherwise. - readNgonFacesFromMesh: catch std::bad_cast on wrong Any payload. - Drop the separate Psy-Q face-layout UserAny blob; qtme.faces is canonical. Co-authored-by: Cursor --- src/EditableMesh.cpp | 4 +- src/PS1/PS1PLY.cpp | 653 +++++++++++++++++++++++++++---------------- src/PS1/PS1PLY.h | 14 +- 3 files changed, 414 insertions(+), 257 deletions(-) diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 0648c26b4..a27084325 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -310,8 +310,8 @@ bool readNgonFacesFromMesh(const Ogre::Mesh* mesh, try { outFaces = Ogre::any_cast(any); } catch (const Ogre::Exception&) { - // Wrong payload type stored under our key — bail out, exporter - // will fall back to the triangle index buffer. + return false; + } catch (const std::bad_cast&) { return false; } return !outFaces.empty(); diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 401dbbb4a..9328911a9 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -10,6 +10,8 @@ The MIT License #include "PS1/PS1PLY.h" +#include "EditableMesh.h" + #include #include #include @@ -17,7 +19,6 @@ The MIT License #include #include #include -#include #include #include @@ -28,6 +29,7 @@ The MIT License #include #include +#include #include #include #include @@ -243,7 +245,161 @@ static std::vector parseIntTokens(const QString& line) return t; } -static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const TriSoup& soup) +static size_t triCountFromPsyqFaceLayout(const std::vector& layout) +{ + size_t t = 0; + for (uint8_t c : layout) { + if (c == 3) + ++t; + else if (c == 4) + t += 2; + } + return t; +} + +/** True if welded triangle (a0,a1,a2) is (c0,c1,c2) up to cyclic rotation and/or reversal. */ +static bool weldedTriMatchesCanonical(uint32_t a0, uint32_t a1, uint32_t a2, uint32_t c0, uint32_t c1, uint32_t c2) +{ + const uint32_t a[3] = {a0, a1, a2}; + for (int r = 0; r < 3; ++r) { + if (a[r] == c0 && a[(r + 1) % 3] == c1 && a[(r + 2) % 3] == c2) + return true; + if (a[r] == c0 && a[(r + 2) % 3] == c1 && a[(r + 1) % 3] == c2) + return true; + } + return false; +} + +/** + * Recover PS1/TMD quad corner order (v0,v1,v2,v3) from two welded triangles that were + * emitted as (v0,v1,v2) + (v1,v2,v3) before welding (each tri may be winding-flipped). + */ +static bool mergeWeldedTriPairToQuad(const std::vector& pos, + const std::vector& nrm, + uint32_t a0, + uint32_t a1, + uint32_t a2, + uint32_t b0, + uint32_t b1, + uint32_t b2, + std::vector& quadOut) +{ + quadOut.clear(); + const uint32_t triA[3] = {a0, a1, a2}; + const uint32_t triB[3] = {b0, b1, b2}; + const std::unordered_set sb{triB[0], triB[1], triB[2]}; + const std::unordered_set sa{triA[0], triA[1], triA[2]}; + + uint32_t v0 = UINT32_MAX; + for (uint32_t x : triA) { + if (!sb.count(x)) + v0 = x; + } + uint32_t v3 = UINT32_MAX; + for (uint32_t x : triB) { + if (!sa.count(x)) + v3 = x; + } + if (v0 == UINT32_MAX || v3 == UINT32_MAX) + return false; + + uint32_t p = UINT32_MAX, q = UINT32_MAX; + for (uint32_t x : triA) { + if (x != v0) { + if (p == UINT32_MAX) + p = x; + else + q = x; + } + } + if (p == UINT32_MAX || q == UINT32_MAX || p == q) + return false; + if (!sb.count(p) || !sb.count(q)) + return false; + + const std::unordered_set uniq{v0, p, q, v3}; + if (uniq.size() != 4) + return false; + + auto scoreAgainstRef = [&](uint32_t cv0, uint32_t cv1, uint32_t cv2) -> float { + const Ogre::Vector3& P0 = pos[cv0]; + const Ogre::Vector3& P1 = pos[cv1]; + const Ogre::Vector3& P2 = pos[cv2]; + Ogre::Vector3 fn = (P1 - P0).crossProduct(P2 - P0); + const float len = fn.length(); + if (len > 1e-20f) + fn /= len; + Ogre::Vector3 an = nrm[cv0] + nrm[cv1] + nrm[cv2]; + if (!an.isZeroLength()) + an.normalise(); + else + return 0.f; + return fn.dotProduct(an); + }; + + const std::array, 2> candidates{{{v0, p, q, v3}, {v0, q, p, v3}}}; + int bestIdx = -1; + float bestScore = -2.f; + for (int ci = 0; ci < 2; ++ci) { + const uint32_t qv0 = candidates[ci][0]; + const uint32_t qv1 = candidates[ci][1]; + const uint32_t qv2 = candidates[ci][2]; + const uint32_t qv3 = candidates[ci][3]; + if (!weldedTriMatchesCanonical(a0, a1, a2, qv0, qv1, qv2)) + continue; + if (!weldedTriMatchesCanonical(b0, b1, b2, qv1, qv2, qv3)) + continue; + const float s = scoreAgainstRef(qv0, qv1, qv2) + scoreAgainstRef(qv1, qv2, qv3); + if (s > bestScore) { + bestScore = s; + bestIdx = ci; + } + } + if (bestIdx < 0) + return false; + quadOut.assign(candidates[static_cast(bestIdx)].begin(), candidates[static_cast(bestIdx)].end()); + return true; +} + +static bool buildNgonPayloadFromWeldedTriangles(const std::vector& triIndices, + const std::vector& layout, + const std::vector& uniqPos, + const std::vector& uniqNrm, + std::vector>& outFaces) +{ + outFaces.clear(); + const size_t nTri = triIndices.size() / 3; + size_t triIdx = 0; + for (uint8_t nc : layout) { + if (nc == 3) { + if (triIdx >= nTri) + return false; + outFaces.push_back({static_cast(triIndices[triIdx * 3]), + static_cast(triIndices[triIdx * 3 + 1]), + static_cast(triIndices[triIdx * 3 + 2])}); + ++triIdx; + } else if (nc == 4) { + if (triIdx + 1 >= nTri) + return false; + const uint32_t a0 = triIndices[triIdx * 3], a1 = triIndices[triIdx * 3 + 1], a2 = triIndices[triIdx * 3 + 2]; + const uint32_t b0 = triIndices[(triIdx + 1) * 3], b1 = triIndices[(triIdx + 1) * 3 + 1], + b2 = triIndices[(triIdx + 1) * 3 + 2]; + std::vector quad; + if (mergeWeldedTriPairToQuad(uniqPos, uniqNrm, a0, a1, a2, b0, b1, b2, quad)) + outFaces.push_back(std::move(quad)); + else { + outFaces.push_back({a0, a1, a2}); + outFaces.push_back({b0, b1, b2}); + } + triIdx += 2; + } else + return false; + } + return triIdx == nTri; +} + +static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const TriSoup& soup, + const std::vector* psyqFaceLayoutForNgons = nullptr) { if (soup.pos.empty() || soup.pos.size() % 3u != 0 || soup.nrm.size() != soup.pos.size()) return {}; @@ -384,83 +540,26 @@ static Ogre::MeshPtr buildMeshFromTriSoup(const std::string& meshName, const Tri mesh->_setBounds(bounds); mesh->_setBoundingSphereRadius(bounds.getHalfSize().length()); mesh->load(); - return mesh; -} -static size_t triCountFromPsyqFaceLayout(const std::vector& layout) -{ - size_t t = 0; - for (uint8_t c : layout) { - if (c == 3) - ++t; - else if (c == 4) - t += 2; - } - return t; -} - -static std::string serializePsyqPlyFaceLayout(const std::vector& layout) -{ - // Magic "QP1F" + little-endian uint32 count + raw bytes (each 3 or 4). - std::string s; - s.resize(8 + layout.size()); - s[0] = 'Q'; - s[1] = 'P'; - s[2] = '1'; - s[3] = 'F'; - const uint32_t n = static_cast(layout.size()); - s[4] = static_cast(n & 0xFF); - s[5] = static_cast((n >> 8) & 0xFF); - s[6] = static_cast((n >> 16) & 0xFF); - s[7] = static_cast((n >> 24) & 0xFF); - for (size_t i = 0; i < layout.size(); ++i) - s[8 + i] = static_cast(layout[i]); - return s; -} - -static bool deserializePsyqPlyFaceLayout(const std::string& blob, std::vector& outLayout) -{ - outLayout.clear(); - if (blob.size() < 8 || blob[0] != 'Q' || blob[1] != 'P' || blob[2] != '1' || blob[3] != 'F') - return false; - const uint32_t n = static_cast(static_cast(blob[4])) - | (static_cast(static_cast(blob[5])) << 8) - | (static_cast(static_cast(blob[6])) << 16) - | (static_cast(static_cast(blob[7])) << 24); - if (blob.size() != 8u + static_cast(n)) - return false; - outLayout.resize(n); - for (uint32_t i = 0; i < n; ++i) { - const uint8_t c = static_cast(blob[8u + i]); - if (c != 3 && c != 4) - return false; - outLayout[i] = c; - } - return true; -} - -static bool tryLoadPsyqPlyFaceLayoutFromMesh(const Ogre::MeshPtr& mesh, std::vector& outLayout) -{ - outLayout.clear(); - if (!mesh) - return false; - const Ogre::Any& a = mesh->getUserObjectBindings().getUserAny(PS1PLY::kPsyqPlyFaceLayoutUserKey); - if (a.isEmpty()) - return false; - try { - const std::string& blob = Ogre::any_cast(a); - return deserializePsyqPlyFaceLayout(blob, outLayout); - } catch (...) { - return false; + if (psyqFaceLayoutForNgons && !psyqFaceLayoutForNgons->empty() + && triCountFromPsyqFaceLayout(*psyqFaceLayoutForNgons) == nTri) { + std::vector> ngonPayload; + if (buildNgonPayloadFromWeldedTriangles(indices, *psyqFaceLayoutForNgons, uniqPos, uniqNrm, ngonPayload) + && !ngonPayload.empty()) { + std::vector es(1); + es[0].faces.reserve(ngonPayload.size()); + for (auto& poly : ngonPayload) { + EditableFace ef; + ef.indices = std::move(poly); + if (ef.isValid()) + es[0].faces.push_back(std::move(ef)); + } + if (!es[0].faces.empty()) + writeNgonFacesToMesh(mesh.get(), es); + } } -} -static void storePsyqPlyFaceLayoutOnMesh(const Ogre::MeshPtr& mesh, const std::vector& layout) -{ - if (!mesh || layout.empty()) - return; - mesh->getUserObjectBindings().setUserAny(PS1PLY::kPsyqPlyFaceLayoutUserKey, - Ogre::Any(serializePsyqPlyFaceLayout(layout))); + return mesh; } static bool parsePsyqPlyLines(const QStringList& lines, TriSoup& outSoup, const QVector* faceColors, @@ -750,79 +849,6 @@ struct PsyqExportFace { bool hasColor = false; }; -/** Rebuild Psy-Q face list from stored import layout + current welded triangle indices (export order). */ -static bool buildExportFacesFromLayout(const std::vector& I0, - const std::vector& I1, - const std::vector& I2, - const std::vector& layout, - const QVector* triFaceColors, - std::vector& outFaces) -{ - outFaces.clear(); - size_t ti = 0; - const bool haveCol = triFaceColors && triFaceColors->size() == static_cast(I0.size()); - - for (uint8_t nc : layout) { - if (nc == 3) { - if (ti >= I0.size()) - return false; - PsyqExportFace f; - f.isQuad = false; - f.v[0] = I0[ti]; - f.v[1] = I1[ti]; - f.v[2] = I2[ti]; - if (haveCol) { - f.color = (*triFaceColors)[static_cast(ti)]; - f.hasColor = true; - } - outFaces.push_back(f); - ++ti; - } else if (nc == 4) { - if (ti + 1 >= I0.size()) - return false; - const uint32_t q0 = I0[ti], q1 = I1[ti], q2 = I2[ti]; - const uint32_t b0 = I0[ti + 1], b1 = I1[ti + 1], b2 = I2[ti + 1]; - if (q1 == b0 && q2 == b1) { - PsyqExportFace f; - f.isQuad = true; - f.v[0] = q0; - f.v[1] = q1; - f.v[2] = q2; - f.v[3] = b2; - if (haveCol) { - const QColor& a = (*triFaceColors)[static_cast(ti)]; - const QColor& b = (*triFaceColors)[static_cast(ti + 1)]; - f.color = QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2); - f.hasColor = true; - } - outFaces.push_back(f); - } else { - PsyqExportFace t1; - t1.isQuad = false; - t1.v[0] = q0; - t1.v[1] = q1; - t1.v[2] = q2; - PsyqExportFace t2; - t2.isQuad = false; - t2.v[0] = b0; - t2.v[1] = b1; - t2.v[2] = b2; - if (haveCol) { - t1.color = (*triFaceColors)[static_cast(ti)]; - t1.hasColor = true; - t2.color = (*triFaceColors)[static_cast(ti + 1)]; - t2.hasColor = true; - } - outFaces.push_back(t1); - outFaces.push_back(t2); - } - ti += 2; - } else - return false; - } - return ti == I0.size(); -} - static void mergeSubmeshTrisToQuads(const std::vector& I0, const std::vector& I1, const std::vector& I2, @@ -915,11 +941,10 @@ Ogre::MeshPtr importPsyqPly(const QString& filePath, const std::string& meshName if (!parsePsyqPlyLines(lines, soup, nullptr, &faceLayout)) return {}; - Ogre::MeshPtr mesh = buildMeshFromTriSoup(meshName, soup); - if (mesh && !faceLayout.empty() - && triCountFromPsyqFaceLayout(faceLayout) == soup.pos.size() / 3u) - storePsyqPlyFaceLayoutOnMesh(mesh, faceLayout); - return mesh; + const std::vector* layoutPtr = nullptr; + if (!faceLayout.empty() && triCountFromPsyqFaceLayout(faceLayout) == soup.pos.size() / 3u) + layoutPtr = &faceLayout; + return buildMeshFromTriSoup(meshName, soup, layoutPtr); } Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, @@ -935,11 +960,10 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, std::vector faceLayout; if (!parsePsyqPlyLines(lines, soup, &faceColors, &faceLayout)) return {}; - Ogre::MeshPtr mesh = buildMeshFromTriSoup(meshName, soup); - if (mesh && !faceLayout.empty() - && triCountFromPsyqFaceLayout(faceLayout) == soup.pos.size() / 3u) - storePsyqPlyFaceLayoutOnMesh(mesh, faceLayout); - return mesh; + const std::vector* layoutPtr = nullptr; + if (!faceLayout.empty() && triCountFromPsyqFaceLayout(faceLayout) == soup.pos.size() / 3u) + layoutPtr = &faceLayout; + return buildMeshFromTriSoup(meshName, soup, layoutPtr); } bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, @@ -1009,10 +1033,25 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, return false; } - std::vector meshFaceLayout; - const bool haveMeshLayout = - (numSub == 1u && tryLoadPsyqPlyFaceLayoutFromMesh(mesh, meshFaceLayout) - && triCountFromPsyqFaceLayout(meshFaceLayout) == static_cast(totalFaces)); + std::vector> ngonFaces; + bool useNgonExport = + (numSub == 1u && subs.size() == 1u && readNgonFacesFromMesh(mesh.get(), 0, ngonFaces)); + if (useNgonExport) { + for (const auto& poly : ngonFaces) { + if (poly.size() < 3) { + useNgonExport = false; + break; + } + for (unsigned int vid : poly) { + if (vid >= subs[0].vCount) { + useNgonExport = false; + break; + } + } + if (!useNgonExport) + break; + } + } std::vector weldedPos; std::vector weldedNrm; @@ -1037,8 +1076,8 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, return idx; }; - for (unsigned si = 0; si < subs.size(); ++si) { - SubData& sd = subs[si]; + if (useNgonExport) { + SubData& sd = subs[0]; const uint8_t* posBase = static_cast(sd.posBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); const uint8_t* nrmBase = nullptr; if (sd.nrmEl->getSource() == sd.posEl->getSource()) { @@ -1057,99 +1096,103 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, } } - auto ibuf = sd.id->indexBuffer; - const uint8_t* idxBase = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); - const bool idx32 = (ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT); - const unsigned ist = sd.id->indexStart; - const size_t triCount = sd.id->indexCount / 3; - - std::vector smI0; - std::vector smI1; - std::vector smI2; - QVector smFaceCols; - smI0.reserve(triCount); - smI1.reserve(triCount); - smI2.reserve(triCount); const bool collectFaceColors = (outFaceColors != nullptr && sd.colEl && colBase); - for (size_t t = 0; t < triCount; ++t) { - uint32_t i0, i1, i2; - if (idx32) { - const auto* ip = reinterpret_cast(idxBase); - i0 = ip[ist + t * 3 + 0]; - i1 = ip[ist + t * 3 + 1]; - i2 = ip[ist + t * 3 + 2]; - } else { - const auto* ip = reinterpret_cast(idxBase); - i0 = ip[ist + t * 3 + 0]; - i1 = ip[ist + t * 3 + 1]; - i2 = ip[ist + t * 3 + 2]; - } - if (i0 >= sd.vCount || i1 >= sd.vCount || i2 >= sd.vCount) - continue; + auto readPN = [&](uint32_t ii, Ogre::Vector3& p, Ogre::Vector3& n) { + const uint8_t* prow = posBase + size_t(ii) * sd.posStride; + Ogre::Real* pf = nullptr; + sd.posEl->baseVertexPointerToElement(const_cast(prow), &pf); + p = Ogre::Vector3(pf[0], pf[1], pf[2]); + applyPlyExportWorldTransform(p); + const uint8_t* nrow = nrmBase + size_t(ii) * sd.nrmStride; + Ogre::Real* nf = nullptr; + sd.nrmEl->baseVertexPointerToElement(const_cast(nrow), &nf); + n = Ogre::Vector3(nf[0], nf[1], nf[2]); + applyPlyExportWorldTransformNormal(n); + }; - auto readPN = [&](uint32_t ii, Ogre::Vector3& p, Ogre::Vector3& n) { - const uint8_t* prow = posBase + size_t(ii) * sd.posStride; - Ogre::Real* pf = nullptr; - sd.posEl->baseVertexPointerToElement(const_cast(prow), &pf); - p = Ogre::Vector3(pf[0], pf[1], pf[2]); - applyPlyExportWorldTransform(p); - const uint8_t* nrow = nrmBase + size_t(ii) * sd.nrmStride; - Ogre::Real* nf = nullptr; - sd.nrmEl->baseVertexPointerToElement(const_cast(nrow), &nf); - n = Ogre::Vector3(nf[0], nf[1], nf[2]); - applyPlyExportWorldTransformNormal(n); - }; - - Ogre::Vector3 p0, p1, p2, n0, n1, n2; - readPN(i0, p0, n0); - readPN(i1, p1, n1); - readPN(i2, p2, n2); - - int32_t c0 = 0, c1 = 0, c2 = 0; + auto cornerCrgba = [&](uint32_t vi) -> int32_t { + int32_t c = 0; if (sd.colEl && colBase) { Ogre::RGBA* cp = nullptr; - sd.colEl->baseVertexPointerToElement(const_cast(colBase + size_t(i0) * sd.colStride), &cp); - c0 = static_cast(*cp); - sd.colEl->baseVertexPointerToElement(const_cast(colBase + size_t(i1) * sd.colStride), &cp); - c1 = static_cast(*cp); - sd.colEl->baseVertexPointerToElement(const_cast(colBase + size_t(i2) * sd.colStride), &cp); - c2 = static_cast(*cp); + sd.colEl->baseVertexPointerToElement(const_cast(colBase + size_t(vi) * sd.colStride), &cp); + c = static_cast(*cp); } + return c; + }; - const uint32_t w0 = weldCorner(p0, n0, c0); - const uint32_t w1 = weldCorner(p1, n1, c1); - const uint32_t w2 = weldCorner(p2, n2, c2); - smI0.push_back(w0); - smI1.push_back(w1); - smI2.push_back(w2); - - if (collectFaceColors) { - const Ogre::ColourValue cv0 = decodePackedColour(sd.colEl, static_cast(c0)); - const Ogre::ColourValue cv1 = decodePackedColour(sd.colEl, static_cast(c1)); - const Ogre::ColourValue cv2 = decodePackedColour(sd.colEl, static_cast(c2)); - const Ogre::ColourValue ca((cv0.r + cv1.r + cv2.r) / 3.0f, - (cv0.g + cv1.g + cv2.g) / 3.0f, - (cv0.b + cv1.b + cv2.b) / 3.0f, - 1.0f); - smFaceCols.push_back(QColor::fromRgbF(ca.r, ca.g, ca.b, 1.0)); + constexpr uint32_t kMeshVertUnwelded = std::numeric_limits::max(); + std::vector meshToFile(sd.vCount, kMeshVertUnwelded); + + auto fileWeldForMeshVert = [&](uint32_t vi) -> uint32_t { + if (meshToFile[vi] != kMeshVertUnwelded) + return meshToFile[vi]; + Ogre::Vector3 p, n; + readPN(vi, p, n); + const uint32_t w = weldCorner(p, n, cornerCrgba(vi)); + meshToFile[vi] = w; + return w; + }; + + auto avgRgbCorners = [&](const std::vector& corners) -> QColor { + Ogre::ColourValue acc(0, 0, 0, 1.0f); + for (unsigned int vi : corners) { + const Ogre::ColourValue cv = + decodePackedColour(sd.colEl, static_cast(cornerCrgba(vi))); + acc.r += cv.r; + acc.g += cv.g; + acc.b += cv.b; } - } + const float inv = 1.0f / float(corners.size()); + return QColor::fromRgbF(acc.r * inv, acc.g * inv, acc.b * inv, 1.0f); + }; - std::vector subFaces; - QVector* colorMerge = - collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) ? &smFaceCols : nullptr; + allExportFaces.reserve(ngonFaces.size() * 2); - if (haveMeshLayout && numSub == 1u && si == 0u - && triCountFromPsyqFaceLayout(meshFaceLayout) == smI0.size()) { - if (!buildExportFacesFromLayout(smI0, smI1, smI2, meshFaceLayout, colorMerge, subFaces)) - mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, colorMerge, subFaces); - } else { - mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, colorMerge, subFaces); + for (const auto& poly : ngonFaces) { + const size_t ps = poly.size(); + if (ps == 3) { + PsyqExportFace f; + f.isQuad = false; + f.v[0] = fileWeldForMeshVert(poly[0]); + f.v[1] = fileWeldForMeshVert(poly[1]); + f.v[2] = fileWeldForMeshVert(poly[2]); + if (collectFaceColors) { + f.color = avgRgbCorners(poly); + f.hasColor = true; + } + allExportFaces.push_back(f); + } else if (ps == 4) { + PsyqExportFace f; + f.isQuad = true; + f.v[0] = fileWeldForMeshVert(poly[0]); + f.v[1] = fileWeldForMeshVert(poly[1]); + f.v[2] = fileWeldForMeshVert(poly[2]); + f.v[3] = fileWeldForMeshVert(poly[3]); + if (collectFaceColors) { + f.color = avgRgbCorners(poly); + f.hasColor = true; + } + allExportFaces.push_back(f); + } else { + const uint32_t hub = fileWeldForMeshVert(poly[0]); + for (size_t k = 1; k + 1 < ps; ++k) { + PsyqExportFace t; + t.isQuad = false; + t.v[0] = hub; + t.v[1] = fileWeldForMeshVert(poly[k]); + t.v[2] = fileWeldForMeshVert(poly[k + 1]); + if (collectFaceColors) { + const std::vector tri{poly[0], poly[static_cast(k)], + poly[static_cast(k + 1)]}; + t.color = avgRgbCorners(tri); + t.hasColor = true; + } + allExportFaces.push_back(t); + } + } } - allExportFaces.insert(allExportFaces.end(), subFaces.begin(), subFaces.end()); - ibuf->unlock(); if (sd.colBuf && colBase && sd.colEl->getSource() != sd.posEl->getSource() && !(sd.colEl->getSource() == sd.nrmEl->getSource() && sd.nrmEl->getSource() != sd.posEl->getSource())) { sd.colBuf->unlock(); @@ -1157,6 +1200,124 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, if (sd.nrmEl->getSource() != sd.posEl->getSource()) sd.nrmBuf->unlock(); sd.posBuf->unlock(); + } else { + for (unsigned si = 0; si < subs.size(); ++si) { + SubData& sd = subs[si]; + const uint8_t* posBase = static_cast(sd.posBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const uint8_t* nrmBase = nullptr; + if (sd.nrmEl->getSource() == sd.posEl->getSource()) { + nrmBase = posBase; + } else { + nrmBase = static_cast(sd.nrmBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + } + const uint8_t* colBase = nullptr; + if (sd.colBuf) { + if (sd.colEl->getSource() == sd.posEl->getSource()) { + colBase = posBase; + } else if (sd.colEl->getSource() == sd.nrmEl->getSource() + && sd.nrmEl->getSource() != sd.posEl->getSource()) { + colBase = nrmBase; + } else { + colBase = static_cast(sd.colBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + } + } + + auto ibuf = sd.id->indexBuffer; + const uint8_t* idxBase = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + const bool idx32 = (ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT); + const unsigned ist = sd.id->indexStart; + const size_t triCount = sd.id->indexCount / 3; + + std::vector smI0; + std::vector smI1; + std::vector smI2; + QVector smFaceCols; + smI0.reserve(triCount); + smI1.reserve(triCount); + smI2.reserve(triCount); + const bool collectFaceColors = (outFaceColors != nullptr && sd.colEl && colBase); + + for (size_t t = 0; t < triCount; ++t) { + uint32_t i0, i1, i2; + if (idx32) { + const auto* ip = reinterpret_cast(idxBase); + i0 = ip[ist + t * 3 + 0]; + i1 = ip[ist + t * 3 + 1]; + i2 = ip[ist + t * 3 + 2]; + } else { + const auto* ip = reinterpret_cast(idxBase); + i0 = ip[ist + t * 3 + 0]; + i1 = ip[ist + t * 3 + 1]; + i2 = ip[ist + t * 3 + 2]; + } + if (i0 >= sd.vCount || i1 >= sd.vCount || i2 >= sd.vCount) + continue; + + auto readPN = [&](uint32_t ii, Ogre::Vector3& p, Ogre::Vector3& n) { + const uint8_t* prow = posBase + size_t(ii) * sd.posStride; + Ogre::Real* pf = nullptr; + sd.posEl->baseVertexPointerToElement(const_cast(prow), &pf); + p = Ogre::Vector3(pf[0], pf[1], pf[2]); + applyPlyExportWorldTransform(p); + const uint8_t* nrow = nrmBase + size_t(ii) * sd.nrmStride; + Ogre::Real* nf = nullptr; + sd.nrmEl->baseVertexPointerToElement(const_cast(nrow), &nf); + n = Ogre::Vector3(nf[0], nf[1], nf[2]); + applyPlyExportWorldTransformNormal(n); + }; + + Ogre::Vector3 p0, p1, p2, n0, n1, n2; + readPN(i0, p0, n0); + readPN(i1, p1, n1); + readPN(i2, p2, n2); + + int32_t c0 = 0, c1 = 0, c2 = 0; + if (sd.colEl && colBase) { + Ogre::RGBA* cp = nullptr; + sd.colEl->baseVertexPointerToElement(const_cast(colBase + size_t(i0) * sd.colStride), &cp); + c0 = static_cast(*cp); + sd.colEl->baseVertexPointerToElement(const_cast(colBase + size_t(i1) * sd.colStride), &cp); + c1 = static_cast(*cp); + sd.colEl->baseVertexPointerToElement(const_cast(colBase + size_t(i2) * sd.colStride), &cp); + c2 = static_cast(*cp); + } + + const uint32_t w0 = weldCorner(p0, n0, c0); + const uint32_t w1 = weldCorner(p1, n1, c1); + const uint32_t w2 = weldCorner(p2, n2, c2); + smI0.push_back(w0); + smI1.push_back(w1); + smI2.push_back(w2); + + if (collectFaceColors) { + const Ogre::ColourValue cv0 = decodePackedColour(sd.colEl, static_cast(c0)); + const Ogre::ColourValue cv1 = decodePackedColour(sd.colEl, static_cast(c1)); + const Ogre::ColourValue cv2 = decodePackedColour(sd.colEl, static_cast(c2)); + const Ogre::ColourValue ca((cv0.r + cv1.r + cv2.r) / 3.0f, + (cv0.g + cv1.g + cv2.g) / 3.0f, + (cv0.b + cv1.b + cv2.b) / 3.0f, + 1.0f); + smFaceCols.push_back(QColor::fromRgbF(ca.r, ca.g, ca.b, 1.0)); + } + } + + std::vector subFaces; + QVector* colorMerge = + collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) ? &smFaceCols : nullptr; + + mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, colorMerge, subFaces); + allExportFaces.insert(allExportFaces.end(), subFaces.begin(), subFaces.end()); + + ibuf->unlock(); + if (sd.colBuf && colBase && sd.colEl->getSource() != sd.posEl->getSource() + && !(sd.colEl->getSource() == sd.nrmEl->getSource() + && sd.nrmEl->getSource() != sd.posEl->getSource())) { + sd.colBuf->unlock(); + } + if (sd.nrmEl->getSource() != sd.posEl->getSource()) + sd.nrmBuf->unlock(); + sd.posBuf->unlock(); + } } if (outFaceColors) { diff --git a/src/PS1/PS1PLY.h b/src/PS1/PS1PLY.h index bd9f51e4d..0f7430528 100644 --- a/src/PS1/PS1PLY.h +++ b/src/PS1/PS1PLY.h @@ -25,15 +25,11 @@ The MIT License * 1=quad) with separate normal indices. * * Rendering: Ogre stores triangle index buffers, so quads from a PLY are expanded to - * two triangles at import. The original face layout (triangle vs quad) is stored on - * the mesh (see kPsyqPlyFaceLayoutUserKey) so Psy-Q export can write quad lines back - * without guessing from topology. + * two triangles at import. Polygon topology (tri/quad and higher) is written with + * the same `qtme.faces.` n-gon binding as FBX (see EditableMesh / HalfEdgeMesh). */ namespace PS1PLY { -/// Ogre::Mesh UserObjectBindings key: std::string blob (see PS1PLY.cpp) listing 3 or 4 per logical face. -inline constexpr const char kPsyqPlyFaceLayoutUserKey[] = "qtme.psyq_ply_face_layout"; - /// Uniform scale for RSD sidecar Psy-Q PLY geometry (kept at 1× so ring-style assets stay editor-sized). constexpr float kPsyqPlyEditorUniformScale = 1.0f; @@ -47,9 +43,9 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, const QVector& faceColors); /// Export an Ogre entity as Psy-Q PLY. Welds corners that share the same quantized -/// position, normal, and (if present) vertex colour. If the mesh has kPsyqPlyFaceLayoutUserKey -/// from a prior Psy-Q import and triangle order still matches, quad face records (type 1) -/// are written from that layout; otherwise coplanar triangle pairs are merged heuristically. +/// position, normal, and (if present) vertex colour. For a single submesh, if +/// `readNgonFacesFromMesh` finds `qtme.faces.0`, Psy-Q face lines follow those polygons +/// (tri / quad / n-gon fanned to tris); otherwise coplanar triangle pairs are merged heuristically. /// If outFaceColors is provided and vertex colours exist on all submeshes, one RGB per /// written face is filled (for a MAT sidecar). bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, From f223a5d587c43db9c19afca65e2b1a6af5a09c71 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 9 May 2026 19:34:32 -0400 Subject: [PATCH 04/12] feat(PS1): Psy-Q PLY export with split vertex/normal pools - Weld positions and normals in separate tables (PosWeldKey / NrmWeldKey); header line uses nV and nN independently like original Psy-Q files. - Face lines emit distinct vertex and normal index tuples (tri and quad). - Heuristic quad merge carries parallel normal indices; merged quads pick per-corner normals from the source triangle that owns each welded position. - Import-time corner weld still uses combined pos+normal+colour (unchanged). Co-authored-by: Cursor --- src/PS1/PS1PLY.cpp | 206 +++++++++++++++++++++++++++++++++++---------- src/PS1/PS1PLY.h | 8 +- 2 files changed, 167 insertions(+), 47 deletions(-) diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 9328911a9..b7700dcae 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -47,10 +47,44 @@ static int32_t quantizeWorld(Ogre::Real v) return static_cast(std::lround(double(v) * 100000.0)); } +struct PosWeldKey { + int32_t px, py, pz; + bool operator==(const PosWeldKey& o) const { return px == o.px && py == o.py && pz == o.pz; } +}; + +struct PosWeldKeyHash { + size_t operator()(const PosWeldKey& k) const noexcept + { + size_t h = 1469598103934665603ull; + auto mix = [&](int32_t x) { h ^= static_cast(static_cast(x)) * 1099511628211ull; }; + mix(k.px); + mix(k.py); + mix(k.pz); + return h; + } +}; + +struct NrmWeldKey { + int32_t nx, ny, nz; + bool operator==(const NrmWeldKey& o) const { return nx == o.nx && ny == o.ny && nz == o.nz; } +}; + +struct NrmWeldKeyHash { + size_t operator()(const NrmWeldKey& k) const noexcept + { + size_t h = 1469598103934665603ull; + auto mix = [&](int32_t x) { h ^= static_cast(static_cast(x)) * 1099511628211ull; }; + mix(k.nx); + mix(k.ny); + mix(k.nz); + return h; + } +}; + +/** Import-time corner weld: position + normal + optional colour (matches Ogre mesh corners). */ struct WeldKey { int32_t px, py, pz, nx, ny, nz; int32_t crgba; - bool operator==(const WeldKey& o) const { return px == o.px && py == o.py && pz == o.pz && nx == o.nx && ny == o.ny && nz == o.nz && crgba == o.crgba; @@ -61,9 +95,7 @@ struct WeldKeyHash { size_t operator()(const WeldKey& k) const noexcept { size_t h = 1469598103934665603ull; - auto mix = [&](int32_t x) { - h ^= static_cast(static_cast(x)) * 1099511628211ull; - }; + auto mix = [&](int32_t x) { h ^= static_cast(static_cast(x)) * 1099511628211ull; }; mix(k.px); mix(k.py); mix(k.pz); @@ -845,19 +877,52 @@ static bool tryMergeTrisToQuad(const std::array& A, struct PsyqExportFace { bool isQuad = false; uint32_t v[4] = {}; + uint32_t n[4] = {}; QColor color; 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) +{ + 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; +} + static void mergeSubmeshTrisToQuads(const std::vector& I0, const std::vector& I1, const std::vector& I2, + const std::vector& N0, + const std::vector& N1, + const std::vector& N2, const std::vector& weldPos, const QVector* triFaceColors, std::vector& outFaces) { const size_t n = I0.size(); - if (I1.size() != n || I2.size() != n || n == 0) + if (I1.size() != n || I2.size() != n || N0.size() != n || N1.size() != n || N2.size() != n || n == 0) return; std::vector used(n, 0); @@ -887,6 +952,11 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, 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(k)] = normalIndexForWeldedPos( + q[static_cast(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]); + } if (haveTriColors) { const QColor a = triRgb(i); const QColor b = triRgb(j); @@ -904,6 +974,9 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, f.v[0] = I0[i]; f.v[1] = I1[i]; f.v[2] = I2[i]; + f.n[0] = N0[i]; + f.n[1] = N1[i]; + f.n[2] = N2[i]; if (haveTriColors) { f.color = triRgb(i); f.hasColor = true; @@ -1057,21 +1130,31 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, std::vector weldedNrm; weldedPos.reserve(size_t(totalFaces) * 3u); weldedNrm.reserve(size_t(totalFaces) * 3u); - std::unordered_map weld; - weld.reserve(size_t(totalFaces) * 3u); + std::unordered_map posWeld; + std::unordered_map nrmWeld; + posWeld.reserve(size_t(totalFaces) * 3u); + nrmWeld.reserve(size_t(totalFaces) * 3u); std::vector allExportFaces; allExportFaces.reserve(totalFaces); - auto weldCorner = [&](const Ogre::Vector3& p, const Ogre::Vector3& n, int32_t crgba) -> uint32_t { - const WeldKey key{quantizeWorld(p.x), quantizeWorld(p.y), quantizeWorld(p.z), - quantizeWorld(n.x), quantizeWorld(n.y), quantizeWorld(n.z), crgba}; - const auto it = weld.find(key); - if (it != weld.end()) + auto weldPosOnly = [&](const Ogre::Vector3& p) -> uint32_t { + const PosWeldKey key{quantizeWorld(p.x), quantizeWorld(p.y), quantizeWorld(p.z)}; + const auto it = posWeld.find(key); + if (it != posWeld.end()) return it->second; const uint32_t idx = static_cast(weldedPos.size()); - weld.emplace(key, idx); + posWeld.emplace(key, idx); weldedPos.push_back(p); + return idx; + }; + auto weldNrmOnly = [&](const Ogre::Vector3& n) -> uint32_t { + const NrmWeldKey key{quantizeWorld(n.x), quantizeWorld(n.y), quantizeWorld(n.z)}; + const auto it = nrmWeld.find(key); + if (it != nrmWeld.end()) + return it->second; + const uint32_t idx = static_cast(weldedNrm.size()); + nrmWeld.emplace(key, idx); weldedNrm.push_back(n); return idx; }; @@ -1121,17 +1204,20 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, return c; }; - constexpr uint32_t kMeshVertUnwelded = std::numeric_limits::max(); - std::vector meshToFile(sd.vCount, kMeshVertUnwelded); + struct MeshCornerPools { + uint32_t posIdx = std::numeric_limits::max(); + uint32_t nrmIdx = std::numeric_limits::max(); + }; + std::vector meshCorner(sd.vCount); - auto fileWeldForMeshVert = [&](uint32_t vi) -> uint32_t { - if (meshToFile[vi] != kMeshVertUnwelded) - return meshToFile[vi]; + auto ensureMeshCorner = [&](uint32_t vi) { + MeshCornerPools& mc = meshCorner[vi]; + if (mc.posIdx != std::numeric_limits::max()) + return; Ogre::Vector3 p, n; readPN(vi, p, n); - const uint32_t w = weldCorner(p, n, cornerCrgba(vi)); - meshToFile[vi] = w; - return w; + mc.posIdx = weldPosOnly(p); + mc.nrmIdx = weldNrmOnly(n); }; auto avgRgbCorners = [&](const std::vector& corners) -> QColor { @@ -1152,36 +1238,55 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, for (const auto& poly : ngonFaces) { const size_t ps = poly.size(); if (ps == 3) { + ensureMeshCorner(poly[0]); + ensureMeshCorner(poly[1]); + ensureMeshCorner(poly[2]); PsyqExportFace f; f.isQuad = false; - f.v[0] = fileWeldForMeshVert(poly[0]); - f.v[1] = fileWeldForMeshVert(poly[1]); - f.v[2] = fileWeldForMeshVert(poly[2]); + f.v[0] = meshCorner[poly[0]].posIdx; + f.v[1] = meshCorner[poly[1]].posIdx; + f.v[2] = meshCorner[poly[2]].posIdx; + f.n[0] = meshCorner[poly[0]].nrmIdx; + f.n[1] = meshCorner[poly[1]].nrmIdx; + f.n[2] = meshCorner[poly[2]].nrmIdx; if (collectFaceColors) { f.color = avgRgbCorners(poly); f.hasColor = true; } allExportFaces.push_back(f); } else if (ps == 4) { + for (unsigned int c : poly) + ensureMeshCorner(c); PsyqExportFace f; f.isQuad = true; - f.v[0] = fileWeldForMeshVert(poly[0]); - f.v[1] = fileWeldForMeshVert(poly[1]); - f.v[2] = fileWeldForMeshVert(poly[2]); - f.v[3] = fileWeldForMeshVert(poly[3]); + f.v[0] = meshCorner[poly[0]].posIdx; + f.v[1] = meshCorner[poly[1]].posIdx; + f.v[2] = meshCorner[poly[2]].posIdx; + f.v[3] = meshCorner[poly[3]].posIdx; + f.n[0] = meshCorner[poly[0]].nrmIdx; + f.n[1] = meshCorner[poly[1]].nrmIdx; + f.n[2] = meshCorner[poly[2]].nrmIdx; + f.n[3] = meshCorner[poly[3]].nrmIdx; if (collectFaceColors) { f.color = avgRgbCorners(poly); f.hasColor = true; } allExportFaces.push_back(f); } else { - const uint32_t hub = fileWeldForMeshVert(poly[0]); + ensureMeshCorner(poly[0]); + const uint32_t hubP = meshCorner[poly[0]].posIdx; + const uint32_t hubN = meshCorner[poly[0]].nrmIdx; for (size_t k = 1; k + 1 < ps; ++k) { + ensureMeshCorner(poly[k]); + ensureMeshCorner(poly[static_cast(k + 1)]); PsyqExportFace t; t.isQuad = false; - t.v[0] = hub; - t.v[1] = fileWeldForMeshVert(poly[k]); - t.v[2] = fileWeldForMeshVert(poly[k + 1]); + t.v[0] = hubP; + t.v[1] = meshCorner[poly[k]].posIdx; + t.v[2] = meshCorner[poly[static_cast(k + 1)]].posIdx; + t.n[0] = hubN; + t.n[1] = meshCorner[poly[k]].nrmIdx; + t.n[2] = meshCorner[poly[static_cast(k + 1)]].nrmIdx; if (collectFaceColors) { const std::vector tri{poly[0], poly[static_cast(k)], poly[static_cast(k + 1)]}; @@ -1231,10 +1336,16 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, std::vector smI0; std::vector smI1; std::vector smI2; + std::vector smN0; + std::vector smN1; + std::vector smN2; QVector smFaceCols; smI0.reserve(triCount); smI1.reserve(triCount); smI2.reserve(triCount); + smN0.reserve(triCount); + smN1.reserve(triCount); + smN2.reserve(triCount); const bool collectFaceColors = (outFaceColors != nullptr && sd.colEl && colBase); for (size_t t = 0; t < triCount; ++t) { @@ -1282,12 +1393,18 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, c2 = static_cast(*cp); } - const uint32_t w0 = weldCorner(p0, n0, c0); - const uint32_t w1 = weldCorner(p1, n1, c1); - const uint32_t w2 = weldCorner(p2, n2, c2); - smI0.push_back(w0); - smI1.push_back(w1); - smI2.push_back(w2); + const uint32_t wpos0 = weldPosOnly(p0); + const uint32_t wpos1 = weldPosOnly(p1); + const uint32_t wpos2 = weldPosOnly(p2); + const uint32_t wn0 = weldNrmOnly(n0); + const uint32_t wn1 = weldNrmOnly(n1); + const uint32_t wn2 = weldNrmOnly(n2); + smI0.push_back(wpos0); + smI1.push_back(wpos1); + smI2.push_back(wpos2); + smN0.push_back(wn0); + smN1.push_back(wn1); + smN2.push_back(wn2); if (collectFaceColors) { const Ogre::ColourValue cv0 = decodePackedColour(sd.colEl, static_cast(c0)); @@ -1305,7 +1422,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, QVector* colorMerge = collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) ? &smFaceCols : nullptr; - mergeSubmeshTrisToQuads(smI0, smI1, smI2, weldedPos, colorMerge, subFaces); + mergeSubmeshTrisToQuads(smI0, smI1, smI2, smN0, smN1, smN2, weldedPos, colorMerge, subFaces); allExportFaces.insert(allExportFaces.end(), subFaces.begin(), subFaces.end()); ibuf->unlock(); @@ -1337,6 +1454,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, } const uint32_t nV = static_cast(weldedPos.size()); + const uint32_t nN = static_cast(weldedNrm.size()); const uint32_t nWrittenFaces = static_cast(allExportFaces.size()); QFile f(plyPath); @@ -1348,7 +1466,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, QTextStream ts(&f); ts.setEncoding(QStringConverter::Latin1); ts << "@PLY940102\n"; - ts << nV << " " << nV << " " << nWrittenFaces << "\n"; + ts << nV << " " << nN << " " << nWrittenFaces << "\n"; for (const Ogre::Vector3& p : weldedPos) ts << p.x << " " << p.y << " " << p.z << "\n"; @@ -1357,11 +1475,11 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, for (const PsyqExportFace& ef : allExportFaces) { if (ef.isQuad) { - ts << "1 " << ef.v[0] << " " << ef.v[1] << " " << ef.v[2] << " " << ef.v[3] << " " << ef.v[0] << " " - << ef.v[1] << " " << ef.v[2] << " " << ef.v[3] << "\n"; + ts << "1 " << ef.v[0] << " " << ef.v[1] << " " << ef.v[2] << " " << ef.v[3] << " " << ef.n[0] << " " + << ef.n[1] << " " << ef.n[2] << " " << ef.n[3] << "\n"; } else { - ts << "0 " << ef.v[0] << " " << ef.v[1] << " " << ef.v[2] << " 0 " << ef.v[0] << " " << ef.v[1] << " " - << ef.v[2] << " 0\n"; + ts << "0 " << ef.v[0] << " " << ef.v[1] << " " << ef.v[2] << " 0 " << ef.n[0] << " " << ef.n[1] << " " + << ef.n[2] << " 0\n"; } } diff --git a/src/PS1/PS1PLY.h b/src/PS1/PS1PLY.h index 0f7430528..3ef570dec 100644 --- a/src/PS1/PS1PLY.h +++ b/src/PS1/PS1PLY.h @@ -42,9 +42,11 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, const std::string& meshName, const QVector& faceColors); -/// Export an Ogre entity as Psy-Q PLY. Welds corners that share the same quantized -/// position, normal, and (if present) vertex colour. For a single submesh, if -/// `readNgonFacesFromMesh` finds `qtme.faces.0`, Psy-Q face lines follow those polygons +/// Export an Ogre entity as Psy-Q PLY. Writes separate vertex and normal tables (counts +/// `nV` and `nN` may differ): positions and normals are welded independently by quantized +/// float, so shared 3D points can reuse one vertex index with distinct per-corner normals. +/// For a single submesh, if `readNgonFacesFromMesh` finds `qtme.faces.0`, Psy-Q face lines +/// follow those polygons /// (tri / quad / n-gon fanned to tris); otherwise coplanar triangle pairs are merged heuristically. /// If outFaceColors is provided and vertex colours exist on all submeshes, one RGB per /// written face is filled (for a MAT sidecar). From ab36f1325d056f289632e17861b277c2bbd658aa Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 9 May 2026 19:46:51 -0400 Subject: [PATCH 05/12] docs(PS1): RSD/Psy-Q PLY guide, website nav, PLY export tests - Add documentation/playstation-rsd-ply.md (RSD descriptor, PLY/MAT, qtme.faces vs quad merge, normal pool welding) - README + DocsApp: PlayStation RSD, clarify .ply dispatch, link to doc - PS1PLY_test: Ogre fixture tests for heuristic quad merge and quad import re-export - WelcomeDialog: include .ply and .rsd in quick-open filter Co-authored-by: Cursor --- README.md | 6 +- documentation/playstation-rsd-ply.md | 55 ++++++ src/PS1/PS1PLY_test.cpp | 246 +++++++++++++++++++++++++++ src/WelcomeDialog.cpp | 2 +- website/src/DocsApp.jsx | 29 +++- 5 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 documentation/playstation-rsd-ply.md diff --git a/README.md b/README.md index 3de0d3387..9eb02d39e 100755 --- a/README.md +++ b/README.md @@ -212,8 +212,12 @@ Split View|Skeleton Animation Controls | STL | .stl | ✅ | ✅ | — | | Ogre Mesh | .mesh / .mesh.xml | ✅ | ✅ | ✅ | | PlayStation TMD | .tmd | ✅ | ✅ | — | +| PlayStation RSD | .rsd | ✅ | ✅ | — | | 3DS | .3ds | ✅ | ✅ | — | -| PLY | .ply | ✅ | ✅ | — | +| Stanford PLY | .ply | ✅ | ✅ | — | +| Psy-Q PLY (PlayStation) | .ply | ✅ | ✅ | — | + +`.ply` is dispatched by content: **Stanford** (`ply` header) vs **Psy-Q** (`@PLY…` header). See [documentation/playstation-rsd-ply.md](documentation/playstation-rsd-ply.md). Import supports all formats provided by Assimp (40+). diff --git a/documentation/playstation-rsd-ply.md b/documentation/playstation-rsd-ply.md new file mode 100644 index 000000000..a39fa44af --- /dev/null +++ b/documentation/playstation-rsd-ply.md @@ -0,0 +1,55 @@ +# PlayStation RSD, Psy-Q PLY, and MAT sidecars + +QtMeshEditor treats **PlayStation RSD** (`.rsd`) as a small **descriptor** that points at a mesh and optional texture/material sidecars. The mesh is usually a **TMD** (`.tmd`) or a **Sony Psy-Q PLY** (`.ply`). These are **not** Stanford PLY files: Psy-Q PLY uses an `@PLY…` header, separate **vertex** and **normal** tables, and face lines with **independent** vertex and normal indices. + +## RSD (`.rsd`) + +An RSD file lists paths or basenames for: + +- **Model** — typically `.tmd` or Psy-Q `.ply` +- **Texture / TIM** — PlayStation 4-bit / 8-bit / 16-bit images (`.tim`), applied when loading through the RSD path +- **Material** — optional `.mat` companion with per-face or material parameters used by classic toolchains + +Importing `.rsd` resolves those references, loads the mesh (TMD or Psy-Q PLY), and attaches TIM-driven materials when possible. Exporting to `.rsd` writes the descriptor plus best-effort sidecar filenames (`.tmd` / `.tim` / etc.) next to the output. + +## Psy-Q PLY import + +1. **Header** — e.g. `@PLY940102` (variant digits allowed). +2. **Counts** — one line: `nV nN nF` (number of **positions**, **normals**, **faces**). Positions and normals are **separate pools**; `nV` and `nN` need not match. +3. **Vertices** — `nV` lines of `x y z` (model space; import applies the editor’s Psy-Q PLY world transform). +4. **Normals** — `nN` lines of `nx ny nz`. +5. **Faces** — per face: + - `0 v0 v1 v2 pad n0 n1 n2 pad` — triangle + - `1 v0 v1 v2 v3 n0 n1 n2 n3` — quad (PlayStation-style split into two triangles internally is `(v0,v1,v2)` and `(v1,v2,v3)`) + +Corner attributes (position + normal + optional colour) are **welded** on import so Ogre’s indexed triangle list matches shading boundaries. + +When the file encodes **quads or higher polygons**, the importer records logical face sizes and stores **n-gon topology** on the mesh (`qtme.faces.*`, same mechanism as FBX n-gons). That preserves artist intent for later export. + +## Psy-Q PLY export + +Export walks the renderable mesh and writes: + +1. **Welded position table** — unique positions after **quantized** welding (fixed-point style bucket in world space). +2. **Welded normal table** — **independent** pool: same quantization, but normals deduplicate separately from positions. Many corners can share one quantized normal, so **`nN` is often smaller than `nV`**, even when the source file listed more normal lines. That is expected and usually means redundant normals collapsed to one index after quantization. +3. **Face lines** — triangles (`0 …`) or quads (`1 …`) with **separate** vertex and normal indices per corner. + +### Why fewer normals than the original file is OK + +Original Psy-Q assets often duplicate the same direction many times with tiny float differences. After import, Ogre may still carry one normal per corner. On export, each corner’s normal is quantized and **welded into the normal pool**. If four corners of a flat quad all map to the same quantized `(nx,ny,nz)`, the file shows **one** normal line and four indices pointing at it — **fewer lines than a verbose original**, but equivalent shading for that polygon. + +### `qtme.faces` vs heuristic quad restore + +- If **`qtme.faces.0`** (etc.) is present — for example after importing a Psy-Q file with quad lines, or after editing in mesh mode — export uses those **n-gons** directly. Quads stay quads; larger n-gons are fanned to triangles in the file as required by the format. +- If that metadata is **missing** — e.g. a mesh that only exists as two triangles in the index buffer — export runs a **heuristic**: pairs of adjacent triangles that form a convex quad, share an edge in the PS1 `(v0,v1,v2)+(v1,v2,v3)` pattern, and have **nearly parallel** face normals (dot ≥ 0.94) are merged back into a **single quad line**. That restores many flat quads lost when DCC tools triangulate, without inventing quads across sharp creases. + +Together, explicit **n-gon** metadata and the **merge heuristic** are why round-trips through QtMeshEditor often match or **improve** on the original polygon layout: fewer spurious triangle pairs, and normal pools that collapse true duplicates after quantization. + +## MAT sidecars + +When vertex colours exist on all submeshes, Psy-Q PLY export can optionally emit **per-face colours** (for tooling that builds a `.mat` from face colours). RSD export paths wire this where the pipeline supplies a colour buffer. + +## See also + +- `PS1PLY.h` / `PS1PLY.cpp` — detection, import, export, merge heuristic +- `PS1RSD` module — RSD parse/load/save and sidecar resolution diff --git a/src/PS1/PS1PLY_test.cpp b/src/PS1/PS1PLY_test.cpp index cdbac88de..80b55f8ab 100644 --- a/src/PS1/PS1PLY_test.cpp +++ b/src/PS1/PS1PLY_test.cpp @@ -1,10 +1,141 @@ #include +#include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Manager.h" #include "PS1/PS1PLY.h" +#include "SelectionSet.h" +#include "TestHelpers.h" + +namespace { + +constexpr unsigned long kSingletonSettleMs = 30; + +static void ensureBaseMaterialForPlyImport() +{ + if (Ogre::MaterialManager::getSingleton().getByName( + "BaseMaterial", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) { + return; + } + Ogre::MaterialPtr m = Ogre::MaterialManager::getSingleton().create( + "BaseMaterial", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + m->getTechnique(0)->getPass(0)->setDiffuse(1, 1, 1, 1); + m->getTechnique(0)->getPass(0)->setAmbient(1, 1, 1, 1); +} + +/** Two triangles (0,1,2) and (1,2,3) — PS1 quad split; flat +Z normal. */ +static Ogre::MeshPtr createTwoTriQuadMesh(const std::string& name) +{ + if (auto old = Ogre::MeshManager::getSingleton().getByName(name)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::SubMesh* sm = mesh->createSubMesh(); + sm->setMaterialName("BaseWhite"); + sm->useSharedVertices = false; + + Ogre::VertexData* vd = new Ogre::VertexData(); + sm->vertexData = vd; + vd->vertexCount = 4; + Ogre::VertexDeclaration* decl = vd->vertexDeclaration; + Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + 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); + uint8_t* dst = static_cast(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; + 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]; + decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &pf); + pf[0] = corners[i][3]; + pf[1] = corners[i][4]; + pf[2] = corners[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); + sm->indexData->indexBuffer = ibuf; + sm->indexData->indexCount = 6; + sm->indexData->indexStart = 0; + + mesh->_setBounds(Ogre::AxisAlignedBox(0, 0, 0, 1, 1, 0)); + mesh->_setBoundingSphereRadius(2.0f); + mesh->load(); + return mesh; +} + +static bool readPsyqPlyCountsAndFirstFace(const QString& path, int& nV, int& nN, int& nF, QString& firstFaceLine) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return false; + QTextStream ts(&file); + QStringList lines; + while (!ts.atEnd()) + lines.append(ts.readLine().trimmed()); + + int idx = 0; + while (idx < lines.size() && !lines[idx].contains(QStringLiteral("@PLY"), Qt::CaseInsensitive)) + ++idx; + if (idx >= lines.size()) + return false; + ++idx; + while (idx < lines.size() + && (lines[idx].isEmpty() || lines[idx].startsWith(QLatin1Char('#')))) + ++idx; + if (idx >= lines.size()) + return false; + + const QStringList countParts = lines[idx].split(QLatin1Char(' '), Qt::SkipEmptyParts); + if (countParts.size() < 3) + return false; + nV = countParts[0].toInt(); + nN = countParts[1].toInt(); + nF = countParts[2].toInt(); + ++idx; + idx += nV + nN; + while (idx < lines.size() && lines[idx].isEmpty()) + ++idx; + if (idx >= lines.size()) + return false; + firstFaceLine = lines[idx]; + return nV > 0 && nN > 0 && nF > 0; +} + +} // namespace TEST(PS1PLY, IsPsyqPlyFile_TrueWhenHeaderPresent) { @@ -58,3 +189,118 @@ TEST(PS1PLY, IsPsyqPlyFile_FalseForStanfordPly) f.close(); EXPECT_FALSE(PS1PLY::isPsyqPlyFile(path)); } + +class PS1PLYOgreTest : public ::testing::Test { +protected: + QApplication* app = nullptr; + + void SetUp() override + { + SelectionSet::kill(); + Manager::kill(); + QThread::msleep(kSingletonSettleMs); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed"; + createStandardOgreMaterials(); + ensureBaseMaterialForPlyImport(); + } + + void TearDown() override + { + if (Manager::getSingletonPtr()) + SelectionSet::getSingleton()->clear(); + SelectionSet::kill(); + Manager::kill(); + if (app) + app->processEvents(); + QThread::msleep(kSingletonSettleMs); + } +}; + +TEST_F(PS1PLYOgreTest, ExportHeuristicMergeProducesOneQuadAndSharedNormalPool) +{ + ASSERT_TRUE(canLoadMeshFiles()); + + const std::string meshName = "PS1PlyQuadHeuristicMesh"; + Ogre::MeshPtr mesh = createTwoTriQuadMesh(meshName); + ASSERT_TRUE(mesh); + + auto* mgr = Manager::getSingleton(); + Ogre::SceneNode* node = mgr->addSceneNode(QStringLiteral("PS1PlyQuadHeuristicNode")); + ASSERT_NE(node, nullptr); + Ogre::Entity* ent = mgr->createEntity(node, mesh); + ASSERT_NE(ent, nullptr); + + QTemporaryFile outPly(QDir::tempPath() + QStringLiteral("/qtmesh_ps1ply_heur_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("PS1PlyQuadHeuristicNode")); + 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, 1); + EXPECT_EQ(nV, 4); + EXPECT_EQ(nN, 1); + EXPECT_TRUE(face0.startsWith(QLatin1String("1 "))); +} + +TEST_F(PS1PLYOgreTest, ImportQuadThenExportKeepsSingleQuadFaceLine) +{ + ASSERT_TRUE(canLoadMeshFiles()); + + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString plyIn = QDir(dir.path()).filePath(QStringLiteral("quad_in.ply")); + { + QFile wf(plyIn); + ASSERT_TRUE(wf.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream ts(&wf); + ts << "@PLY940102\n"; + ts << "4 4 1\n"; + ts << "0 0 0\n1 0 0\n1 1 0\n0 1 0\n"; + ts << "0 0 1\n0 0 1\n0 0 1\n0 0 1\n"; + ts << "1 0 1 2 3 0 1 2 3\n"; + } + + const std::string meshName = "PS1PlyQuadImportMesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = PS1PLY::importPsyqPly(plyIn, meshName); + ASSERT_TRUE(mesh); + + auto* mgr = Manager::getSingleton(); + Ogre::SceneNode* node = mgr->addSceneNode(QStringLiteral("PS1PlyQuadImportNode")); + ASSERT_NE(node, nullptr); + Ogre::Entity* ent = mgr->createEntity(node, mesh); + ASSERT_NE(ent, nullptr); + + QTemporaryFile outPly(QDir::tempPath() + QStringLiteral("/qtmesh_ps1ply_ngon_XXXXXX.ply")); + outPly.setAutoRemove(true); + ASSERT_TRUE(outPly.open()); + outPly.close(); + + QString err; + ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, outPly.fileName(), nullptr, &err)) << err.toUtf8().constData(); + + mgr->destroySceneNode(QStringLiteral("PS1PlyQuadImportNode")); + Ogre::MeshManager::getSingleton().remove(meshName); + + int nV = 0, nN = 0, nF = 0; + QString face0; + ASSERT_TRUE(readPsyqPlyCountsAndFirstFace(outPly.fileName(), nV, nN, nF, face0)); + EXPECT_EQ(nF, 1); + EXPECT_LE(nN, 4); + EXPECT_TRUE(face0.startsWith(QLatin1String("1 "))); +} diff --git a/src/WelcomeDialog.cpp b/src/WelcomeDialog.cpp index eb81488a4..6166d9ea5 100644 --- a/src/WelcomeDialog.cpp +++ b/src/WelcomeDialog.cpp @@ -68,7 +68,7 @@ WelcomeDialog::WelcomeDialog(QWidget* parent) connect(openFileBtn, &QPushButton::clicked, this, [this]() { QString file = QFileDialog::getOpenFileName( this, "Open 3D File", QString(), - "3D Files (*.fbx *.gltf *.glb *.vrm *.obj *.dae *.stl *.mesh *.3ds *.x *.tmd);;All Files (*)"); + "3D Files (*.fbx *.gltf *.glb *.vrm *.obj *.dae *.stl *.mesh *.3ds *.x *.ply *.tmd *.rsd);;All Files (*)"); if (!file.isEmpty()) { m_action = OpenFile; m_selectedFile = file; diff --git a/website/src/DocsApp.jsx b/website/src/DocsApp.jsx index c7edb3581..5a26f1545 100644 --- a/website/src/DocsApp.jsx +++ b/website/src/DocsApp.jsx @@ -6,6 +6,7 @@ const NAV = [ { section: 'Getting Started', items: [ { id: 'installation', label: 'Installation' }, { id: 'quick-start', label: 'Quick Start' }, + { id: 'playstation-rsd-ply', label: 'PlayStation RSD / Psy-Q PLY' }, ]}, { section: 'CLI Commands', items: [ { id: 'cmd-info', label: 'info' }, @@ -230,6 +231,31 @@ qtmesh scan ./assets --fail-on error`} +
+

PlayStation RSD, Psy-Q PLY, and MAT

+

+ RSD (.rsd) is a PlayStation-era descriptor that references a mesh (often .tmd or Psy-Q .ply) plus optional .tim textures and .mat sidecars. + Import resolves those paths, loads geometry, and applies TIM-driven materials when possible. Export writes the .rsd and companion filenames next to the output. +

+

+ Psy-Q PLY is not Stanford PLY: it uses an @PLY… header, separate vertex and normal count lines (nV nN nF), then face lines where type 0 is a triangle and 1 is a quad with independent v/n indices. + Quads use the classic split (v0,v1,v2) + (v1,v2,v3) when expanded to triangles in the engine. +

+

+ Import welds corners (position + normal ± colour) and stores quad/ngon topology as qtme.faces.* when the file encodes it, so artist intent is preserved. +

+

+ Export writes welded position and normal pools separately (quantized floats). Many corners share one quantized normal, so nN is often smaller than nV and smaller than in a verbose original — that is deduplication, not loss of shading, for flat or smooth regions. +

+

+ If qtme.faces exists, face lines follow those polygons. Otherwise coplanar triangle pairs matching the PS1 adjacency pattern with nearly parallel normals (dot ≥ 0.94) are merged back into quads, which often recovers cleaner layouts than triangle-only dumps. +

+

+ Full write-up in the repository:{' '} + documentation/playstation-rsd-ply.md. +

+
+ {/* ─── CLI Commands ─── */} ['.dae', 'Collada', 'Yes', 'Yes'], ['.obj', 'Wavefront OBJ', 'Yes', 'Yes'], ['.stl', 'STL', 'Yes', 'Yes'], - ['.ply', 'Stanford PLY', 'Yes', 'Yes'], + ['.ply', 'Stanford PLY or Psy-Q PLY (by content)', 'Yes', 'Yes'], ['.tmd', 'PlayStation TMD', 'Yes', 'Yes'], + ['.rsd', 'PlayStation RSD (descriptor + sidecars)', 'Yes', 'Yes'], ['.3ds', '3D Studio', 'Yes', 'No'], ['.mesh', 'Ogre Mesh', 'Yes', 'No'], ].map(([ext, fmt, imp, exp], i) => {ext}{fmt}{imp}{exp})} From ad3d2c9f13b750df177f42a6e09bac3a3a700dc9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 9 May 2026 19:58:17 -0400 Subject: [PATCH 06/12] test(PS1PLY): fix BaseMaterial setup for Ogre 14 Pass::setAmbient arity Co-authored-by: Cursor --- src/PS1/PS1PLY_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PS1/PS1PLY_test.cpp b/src/PS1/PS1PLY_test.cpp index 80b55f8ab..1cb46550f 100644 --- a/src/PS1/PS1PLY_test.cpp +++ b/src/PS1/PS1PLY_test.cpp @@ -32,8 +32,8 @@ static void ensureBaseMaterialForPlyImport() } Ogre::MaterialPtr m = Ogre::MaterialManager::getSingleton().create( "BaseMaterial", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - m->getTechnique(0)->getPass(0)->setDiffuse(1, 1, 1, 1); - m->getTechnique(0)->getPass(0)->setAmbient(1, 1, 1, 1); + m->getTechnique(0)->getPass(0)->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); + 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. */ From 1ddc725c98c1e7e83b865f8985fd581d01f4e349 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 00:02:32 -0400 Subject: [PATCH 07/12] fix(scan): PS1 mesh scan, qtmesh.yml includes, and import file filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScanEngine: inspect TMD, Psy-Q PLY, and RSD-referenced geometry via Ogre headless context; default Assimp include set adds tmd/rsd extensions. - ScanConfig: merge **/*.tmd, **/*.rsd, **/*.ply into explicit scan.include from qtmesh.yml so local configs do not drop PlayStation assets. - qtmesh.yml: document PlayStation include patterns for repo scans. - WelcomeDialog + File→Import: use MeshImporterExporter import filter helper and Manager::defaultImportExtensions() so startup dialog matches Import. - Tests: ScanEngine (RSD/TMD/Psyq PLY), ScanConfig include merge, Manager, MeshImporterExporter filter builder. Co-authored-by: Cursor --- qtmesh.yml | 4 + src/Manager.cpp | 5 + src/Manager.h | 3 + src/Manager_test.cpp | 9 ++ src/MeshImporterExporter.cpp | 11 +- src/MeshImporterExporter.h | 3 + src/MeshImporterExporter_test.cpp | 8 ++ src/ScanConfig.cpp | 40 ++++++ src/ScanConfig.h | 6 +- src/ScanEngine.cpp | 202 ++++++++++++++++++++++++++- src/ScanEngine.h | 3 +- src/ScanEngine_test.cpp | 222 ++++++++++++++++++++++++++++++ src/WelcomeDialog.cpp | 8 +- src/mainwindow.cpp | 2 +- 14 files changed, 516 insertions(+), 10 deletions(-) diff --git a/qtmesh.yml b/qtmesh.yml index 78c4c6fc5..6e7efc313 100644 --- a/qtmesh.yml +++ b/qtmesh.yml @@ -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" diff --git a/src/Manager.cpp b/src/Manager.cpp index 4f28c2114..38be8b36e 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -111,6 +111,11 @@ void Manager::kill() } } +QString Manager::defaultImportExtensions() +{ + return mValidFileExtention; +} + //////////////////////////////////////// // Constructor & Destructor diff --git a/src/Manager.h b/src/Manager.h index 3fd0ada35..6638b0c2c 100755 --- a/src/Manager.h +++ b/src/Manager.h @@ -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; diff --git a/src/Manager_test.cpp b/src/Manager_test.cpp index ba91769c5..ce89ac063 100644 --- a/src/Manager_test.cpp +++ b/src/Manager_test.cpp @@ -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 diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index cb5d536dc..711535aac 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -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) { @@ -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) diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index e802d118b..9ebc0c82b 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -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. diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 683b6bbb2..35574ce95 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -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"); diff --git a/src/ScanConfig.cpp b/src/ScanConfig.cpp index 22a44a4d3..36328bb66 100644 --- a/src/ScanConfig.cpp +++ b/src/ScanConfig.cpp @@ -292,6 +292,41 @@ ScanConfig ScanConfig::withScopeOverrides(const QString& relativePath) const // ScanConfig loading // --------------------------------------------------------------------------- +namespace { + +/** + * When a project sets `scan.include`, it replaces the default Assimp-based globs. + * PlayStation `.tmd` / `.rsd` and Psy-Q `.ply` are not Assimp extensions, so they + * would never be scanned unless we merge these patterns in when missing. + */ +void appendEditorOnlyMeshScanGlobsIfMissing(QStringList& patterns) +{ + if (patterns.isEmpty()) + return; + + const QLatin1String kExtras[] = { + QLatin1String("tmd"), + QLatin1String("rsd"), + QLatin1String("ply"), + }; + + auto hasExtensionGlob = [&](QLatin1String extNoDot) -> bool { + const QString token = QStringLiteral("*.") + QString(extNoDot); + for (const QString& p : patterns) { + if (p.contains(token, Qt::CaseInsensitive)) + return true; + } + return false; + }; + + for (QLatin1String ext : kExtras) { + if (!hasExtensionGlob(ext)) + patterns.append(QStringLiteral("**/*.") + QString(ext)); + } +} + +} // namespace + ScanConfig::ScanConfig() : includePatterns(ScanConfig::defaultIncludePatternsForAssimpImports()) { @@ -322,6 +357,10 @@ QStringList ScanConfig::defaultIncludePatternsForAssimpImports() extSet.insert(QStringLiteral("mesh")); extSet.insert(QStringLiteral("mesh.xml")); + // PlayStation / Psy-Q sidecars — imported by QtMeshEditor, not Assimp extensions + extSet.insert(QStringLiteral("tmd")); + extSet.insert(QStringLiteral("rsd")); + QStringList globs; globs.reserve(extSet.size()); for (const QString& ext : extSet) { @@ -374,6 +413,7 @@ ScanConfig ScanConfig::fromVariantMap(const QVariantMap& root) config.roots = scan.value("roots").toStringList(); if (scan.contains("include")) { config.includePatterns = scan.value("include").toStringList(); + appendEditorOnlyMeshScanGlobsIfMissing(config.includePatterns); } if (scan.contains("exclude")) config.excludePatterns = scan.value("exclude").toStringList(); diff --git a/src/ScanConfig.h b/src/ScanConfig.h index 1ddd09035..f18ed0dcf 100644 --- a/src/ScanConfig.h +++ b/src/ScanConfig.h @@ -18,7 +18,8 @@ struct ScanConfig { // scan section QStringList roots; - /// Glob patterns; default ctor fills with all Assimp import extensions (plus Ogre .mesh / .mesh.xml). + /// Glob patterns; default ctor fills with Assimp import extensions plus Ogre `.mesh` / `.mesh.xml` + /// and PlayStation `.tmd` / `.rsd` (same set the editor can import). QStringList includePatterns; QStringList excludePatterns = { "**/node_modules/**", "**/.git/**", "**/build/**", "**/Build/**", @@ -78,7 +79,8 @@ struct ScanConfig { ScanConfig(); - /// `**/*.` for every file extension registered by Assimp importers, plus `mesh` / `mesh.xml`. + /// `**/*.` for every file extension registered by Assimp importers, plus `mesh` / `mesh.xml`, + /// and PlayStation `tmd` / `rsd` (not Assimp-registered). static QStringList defaultIncludePatternsForAssimpImports(); static ScanConfig defaults(); diff --git a/src/ScanEngine.cpp b/src/ScanEngine.cpp index cb01fcd65..fc8bdbeec 100644 --- a/src/ScanEngine.cpp +++ b/src/ScanEngine.cpp @@ -29,9 +29,20 @@ #include "FBX/FBXExporter.h" #include +#include +#include +#include +#include + +#include "PS1/PS1PLY.h" +#include "PS1/PS1TMD.h" +#include "PS1/PS1RSD.h" +#include "TestHelpers.h" #include +#include #include +#include #include #include @@ -117,6 +128,110 @@ static bool pathEndsWithInsensitive(const QString& p, QLatin1String suf) return p.endsWith(suf, Qt::CaseInsensitive); } +static std::atomic g_scanInspectMeshSeq{0}; + +static void ensureBaseMaterialForScanInspect() +{ + if (Ogre::MaterialManager::getSingleton().getByName( + "BaseMaterial", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) { + return; + } + Ogre::MaterialPtr m = Ogre::MaterialManager::getSingleton().create( + "BaseMaterial", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + m->getTechnique(0)->getPass(0)->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); + m->getTechnique(0)->getPass(0)->setAmbient(1.0f, 1.0f, 1.0f); +} + +static bool ensureOgreMaterialsForScanInspect(QString* outErr) +{ + if (!ensureOgreHeadlessQuiet()) { + if (outErr) + *outErr = QStringLiteral("Ogre headless init failed (needed for PlayStation mesh scan)"); + return false; + } + createStandardOgreMaterials(); + ensureBaseMaterialForScanInspect(); + return true; +} + +static void fillAssetInfoFromOgreMesh(AssetInfo& info, const Ogre::MeshPtr& mesh) +{ + info.loadError = false; + info.errorMessage.clear(); + info.meshCount = 1; + info.materialCount = mesh->getNumSubMeshes(); + info.animationCount = 0; + info.vertexCount = 0; + info.faceCount = 0; + info.boneCount = 0; + info.hasSkeleton = false; + info.hasEmbeddedTextures = false; + info.materialNames.clear(); + info.texturePaths.clear(); + info.textureRefCount = 0; + info.animationNames.clear(); + info.animationDurations.clear(); + info.animationKeyframeCounts.clear(); + info.boneNames.clear(); + info.animationRedundantKeyframeRatio = 0.0; + info.totalKeyframes = 0; + info.redundantKeyframes = 0; + + const unsigned nSub = mesh->getNumSubMeshes(); + const bool useShared = mesh->sharedVertexData != nullptr; + if (useShared && mesh->sharedVertexData) + info.vertexCount = mesh->sharedVertexData->vertexCount; + + for (unsigned i = 0; i < nSub; ++i) { + Ogre::SubMesh* sm = mesh->getSubMesh(i); + if (!sm || !sm->indexData) + continue; + if (!useShared) { + if (sm->vertexData) + info.vertexCount += sm->vertexData->vertexCount; + } + info.faceCount += sm->indexData->indexCount / 3; + } + + for (unsigned i = 0; i < nSub; ++i) { + if (Ogre::SubMesh* sm = mesh->getSubMesh(i)) { + const Ogre::String& mat = sm->getMaterialName(); + if (!mat.empty()) + info.materialNames.append(QString::fromStdString(mat)); + } + } +} + +static bool loadAndFillOgreInspect(AssetInfo& info, + const std::function& importFn, + QString* detailErr) +{ + QString ogreErr; + if (!ensureOgreMaterialsForScanInspect(&ogreErr)) { + info.loadError = true; + info.errorMessage = ogreErr; + if (detailErr) + *detailErr = ogreErr; + return false; + } + const std::string meshName = std::string("_qtmesh_scan_") + std::to_string(++g_scanInspectMeshSeq); + Ogre::MeshPtr mesh = importFn(meshName); + if (!mesh) { + info.loadError = true; + info.errorMessage = QStringLiteral("Could not import mesh geometry"); + if (detailErr) + *detailErr = info.errorMessage; + return false; + } + fillAssetInfoFromOgreMesh(info, mesh); + try { + Ogre::MeshManager::getSingleton().remove(meshName, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + } catch (...) { + } + return true; +} + // --------------------------------------------------------------------------- // Glob matching // --------------------------------------------------------------------------- @@ -433,7 +548,8 @@ static void analyzeAnimationRedundancy(const aiAnimation* anim, } // namespace // --------------------------------------------------------------------------- -// Asset inspection via Assimp (lightweight — no Ogre needed) +// Asset inspection: Assimp for most formats; PlayStation TMD / Psy-Q PLY / RSD +// use the same Ogre importers as the editor (headless Ogre context). // --------------------------------------------------------------------------- bool ScanEngine::isAssimpResultLoadFailure(const aiScene* scene, const char* assimpErrorString, @@ -466,6 +582,90 @@ AssetInfo ScanEngine::inspectAsset(const QString& filePath, const QString& scanR info.format = QFileInfo(filePath).suffix().toLower(); info.fileSize = QFileInfo(filePath).size(); + const QString extLower = info.format; + + if (extLower == QLatin1String("rsd")) { + PS1RSD::RsdDescriptor d; + QString rsdErr; + if (!PS1RSD::parseRsdFile(filePath, d, &rsdErr)) { + info.loadError = true; + info.errorMessage = rsdErr; + return info; + } + const QFileInfo fiRsd(filePath); + const QString rsdDir = fiRsd.absolutePath(); + auto resolveGeom = [&](const QString& rel) -> QString { + if (rel.isEmpty()) + return {}; + const QFileInfo r(rel); + return r.isAbsolute() ? r.absoluteFilePath() : QDir(rsdDir).filePath(rel); + }; + const QString geomPath = resolveGeom(d.plyPath); + if (geomPath.isEmpty() || !QFileInfo::exists(geomPath)) { + info.loadError = true; + info.errorMessage = QStringLiteral("RSD does not reference an existing geometry file (PLY=...)"); + return info; + } + const QFileInfo gfi(geomPath); + const QString gext = gfi.suffix().toLower(); + QString detailErr; + if (gext == QLatin1String("tmd")) { + loadAndFillOgreInspect( + info, [&](const std::string& mn) { return PS1TMD::importTmd(geomPath, mn); }, &detailErr); + return info; + } + if (gext == QLatin1String("ply") && PS1PLY::isPsyqPlyFile(geomPath)) { + loadAndFillOgreInspect( + info, [&](const std::string& mn) { return PS1PLY::importPsyqPly(geomPath, mn); }, &detailErr); + return info; + } + if (gext == QLatin1String("rsd")) { + info.loadError = true; + info.errorMessage = QStringLiteral("RSD references another RSD as geometry (not supported for scan)"); + return info; + } + AssetInfo inner = ScanEngine::inspectAsset(geomPath, QFileInfo(geomPath).absolutePath()); + info.loadError = inner.loadError; + info.errorMessage = inner.errorMessage; + info.meshCount = inner.meshCount; + info.materialCount = inner.materialCount; + info.animationCount = inner.animationCount; + info.vertexCount = inner.vertexCount; + info.faceCount = inner.faceCount; + info.boneCount = inner.boneCount; + info.textureRefCount = inner.textureRefCount; + info.hasSkeleton = inner.hasSkeleton; + info.hasEmbeddedTextures = inner.hasEmbeddedTextures; + info.materialNames = inner.materialNames; + info.texturePaths = inner.texturePaths; + info.animationNames = inner.animationNames; + info.animationDurations = inner.animationDurations; + info.animationKeyframeCounts = inner.animationKeyframeCounts; + info.boneNames = inner.boneNames; + info.animationRedundantKeyframeRatio = inner.animationRedundantKeyframeRatio; + info.totalKeyframes = inner.totalKeyframes; + info.redundantKeyframes = inner.redundantKeyframes; + info.filePath = filePath; + info.relativePath = QDir(scanRoot).relativeFilePath(filePath); + info.format = extLower; + info.fileSize = QFileInfo(filePath).size(); + return info; + } + + if (extLower == QLatin1String("tmd")) { + QString detailErr; + loadAndFillOgreInspect( + info, [&](const std::string& mn) { return PS1TMD::importTmd(filePath, mn); }, &detailErr); + return info; + } + + if (extLower == QLatin1String("ply") && PS1PLY::isPsyqPlyFile(filePath)) { + QString detailErr; + loadAndFillOgreInspect( + info, [&](const std::string& mn) { return PS1PLY::importPsyqPly(filePath, mn); }, &detailErr); + return info; + } + Assimp::Importer importer; importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false); diff --git a/src/ScanEngine.h b/src/ScanEngine.h index 319232906..c8be1dbdb 100644 --- a/src/ScanEngine.h +++ b/src/ScanEngine.h @@ -99,7 +99,8 @@ class ScanEngine { /// Recursively enumerate asset files under scanRoot filtered by config patterns. static QStringList enumerateFiles(const ScanConfig& config, const QString& scanRoot); - /// Inspect a single asset file using Assimp (lightweight, no Ogre needed). + /// Inspect a single asset file (Assimp for most formats; PlayStation TMD / Psy-Q PLY / RSD + /// use the same Ogre importers as the editor, with a headless render target when needed). static AssetInfo inspectAsset(const QString& filePath, const QString& scanRoot); /// After `Assimp::Importer::ReadFile`, whether the result would make `inspectAsset` set diff --git a/src/ScanEngine_test.cpp b/src/ScanEngine_test.cpp index c12935ecc..2e2529f29 100644 --- a/src/ScanEngine_test.cpp +++ b/src/ScanEngine_test.cpp @@ -2,6 +2,12 @@ #include "ScanConfig.h" #include "ScanEngine.h" +#include + +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" + #include #include @@ -11,6 +17,10 @@ #include #include #include +#include +#include + +#include namespace { QString writeMinimalObj(const QString& dirPath, const QString& fileName) @@ -183,6 +193,45 @@ TEST(ScanConfigTest, LoadRedundantKeyframesRule) EXPECT_DOUBLE_EQ(config.redundantKeyframesScaleTol, 0.0005); } +TEST(ScanConfigTest, YamlExplicitInclude_AddsPlayStationGlobsWhenMissing) +{ + const QString yaml = + "scan:\n" + " include:\n" + " - \"**/*.fbx\"\n"; + + const ScanConfig c = ScanConfig::fromVariantMap(parseSimpleYaml(yaml)); + EXPECT_TRUE(std::find(c.includePatterns.cbegin(), c.includePatterns.cend(), + QStringLiteral("**/*.fbx")) + != c.includePatterns.cend()); + EXPECT_TRUE(std::find(c.includePatterns.cbegin(), c.includePatterns.cend(), + QStringLiteral("**/*.tmd")) + != c.includePatterns.cend()); + EXPECT_TRUE(std::find(c.includePatterns.cbegin(), c.includePatterns.cend(), + QStringLiteral("**/*.rsd")) + != c.includePatterns.cend()); + EXPECT_TRUE(std::find(c.includePatterns.cbegin(), c.includePatterns.cend(), + QStringLiteral("**/*.ply")) + != c.includePatterns.cend()); +} + +TEST(ScanConfigTest, YamlExplicitInclude_DoesNotDuplicatePly) +{ + const QString yaml = + "scan:\n" + " include:\n" + " - \"**/*.ply\"\n" + " - \"**/*.fbx\"\n"; + + const ScanConfig c = ScanConfig::fromVariantMap(parseSimpleYaml(yaml)); + int plyCount = 0; + for (const QString& p : c.includePatterns) { + if (p.compare(QStringLiteral("**/*.ply"), Qt::CaseInsensitive) == 0) + ++plyCount; + } + EXPECT_EQ(plyCount, 1); +} + TEST(ScanConfigTest, DefaultConstructorIncludesAssimpGlobPatterns) { const ScanConfig c; @@ -190,15 +239,23 @@ TEST(ScanConfigTest, DefaultConstructorIncludesAssimpGlobPatterns) EXPECT_GT(c.includePatterns.size(), 8); bool hasMeshGlob = false; bool hasFbxGlob = false; + bool hasTmdGlob = false; + bool hasRsdGlob = false; for (const QString& p : c.includePatterns) { if (p.endsWith(QStringLiteral("/mesh"), Qt::CaseInsensitive) || p.endsWith(QStringLiteral(".mesh"), Qt::CaseInsensitive)) hasMeshGlob = true; if (p.contains(QStringLiteral("fbx"), Qt::CaseInsensitive)) hasFbxGlob = true; + if (p.compare(QStringLiteral("**/*.tmd"), Qt::CaseInsensitive) == 0) + hasTmdGlob = true; + if (p.compare(QStringLiteral("**/*.rsd"), Qt::CaseInsensitive) == 0) + hasRsdGlob = true; } EXPECT_TRUE(hasFbxGlob); EXPECT_TRUE(hasMeshGlob); + EXPECT_TRUE(hasTmdGlob); + EXPECT_TRUE(hasRsdGlob); } // --------------------------------------------------------------------------- @@ -1749,3 +1806,168 @@ TEST(ScanConfigLoadTest, LoadFromFile_InvalidJsonFallsBackToDefaults) ScanConfig d = ScanConfig::defaults(); EXPECT_EQ(c.version, d.version); } + +namespace { + +static void writeU32le(uint8_t* p, uint32_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); + p[2] = uint8_t((v >> 16) & 0xFF); + p[3] = uint8_t((v >> 24) & 0xFF); +} + +static void writeU16le(uint8_t* p, uint16_t v) +{ + p[0] = uint8_t(v & 0xFF); + p[1] = uint8_t((v >> 8) & 0xFF); +} + +static void writeVertex8(int16_t x, int16_t y, int16_t z, uint8_t* out8) +{ + writeU16le(out8 + 0, static_cast(x)); + writeU16le(out8 + 2, static_cast(y)); + writeU16le(out8 + 4, static_cast(z)); + writeU16le(out8 + 6, 0); +} + +/** Minimal TMD: one G3 triangle (same layout as PS1TMD_test). */ +static QByteArray makeMinimalG3TmdBlob() +{ + constexpr uint32_t kTmdId = 0x41u; + constexpr size_t kHead = 12u; + constexpr size_t kObjH = 28u; + const size_t vAbs = kHead + kObjH; + const size_t nAbs = vAbs + 3u * 8u; + const size_t pAbs = nAbs + 3u * 8u; + const uint32_t vOff = static_cast(vAbs - 12u); + const uint32_t nOff = static_cast(nAbs - 12u); + const uint32_t pOff = static_cast(pAbs - 12u); + + QByteArray buf(static_cast(pAbs + 20u), '\0'); + uint8_t* d = reinterpret_cast(buf.data()); + + writeU32le(d, kTmdId); + writeU32le(d + 4, 0); + writeU32le(d + 8, 1); + + uint8_t* oh = d + kHead; + writeU32le(oh, vOff); + writeU32le(oh + 4, 3); + writeU32le(oh + 8, nOff); + writeU32le(oh + 12, 3); + writeU32le(oh + 16, pOff); + writeU32le(oh + 20, 1); + writeU32le(oh + 24, 0); + + writeVertex8(0, 0, 0, d + vAbs); + writeVertex8(4096, 0, 0, d + vAbs + 8); + writeVertex8(0, 4096, 0, d + vAbs + 16); + + writeVertex8(0, 0, 4096, d + nAbs); + writeVertex8(0, 0, 4096, d + nAbs + 8); + writeVertex8(0, 0, 4096, d + nAbs + 16); + + uint8_t* pkt = d + pAbs; + pkt[0] = 6; + pkt[1] = 4; + pkt[2] = 0; + pkt[3] = 0x30; + pkt[4] = 200; + pkt[5] = 200; + pkt[6] = 200; + pkt[7] = 0x30; + writeU16le(pkt + 8, 0); + writeU16le(pkt + 10, 0); + writeU16le(pkt + 12, 1); + writeU16le(pkt + 14, 1); + writeU16le(pkt + 16, 2); + writeU16le(pkt + 18, 2); + + return buf; +} + +} // namespace + +TEST(ScanEngineTest, InspectAsset_RsdWithObjGeometry_KeepsRsdFormatAndCounts) +{ + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + ASSERT_FALSE(writeMinimalObj(tmpDir.path(), "child.obj").isEmpty()); + + const QString rsdPath = QDir(tmpDir.path()).filePath("pack.rsd"); + QFile rf(rsdPath); + ASSERT_TRUE(rf.open(QIODevice::WriteOnly | QIODevice::Text)); + rf.write("@RSD940102\nPLY=child.obj\n"); + rf.close(); + + const AssetInfo info = ScanEngine::inspectAsset(rsdPath, tmpDir.path()); + ASSERT_FALSE(info.loadError) << qPrintable(info.errorMessage); + EXPECT_EQ(info.format, QStringLiteral("rsd")); + EXPECT_EQ(info.relativePath, QStringLiteral("pack.rsd")); + EXPECT_EQ(info.vertexCount, 3u); + EXPECT_EQ(info.faceCount, 1u); +} + +TEST(ScanEngineTest, InspectAsset_MinimalTmd_LoadsGeometry) +{ + SelectionSet::kill(); + Manager::kill(); + QThread::msleep(30); + + ASSERT_TRUE(tryInitOgre()); + createStandardOgreMaterials(); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + const QString path = QDir(tmpDir.path()).filePath("scan_min.tmd"); + QFile wf(path); + ASSERT_TRUE(wf.open(QIODevice::WriteOnly)); + const QByteArray blob = makeMinimalG3TmdBlob(); + ASSERT_EQ(wf.write(blob), blob.size()); + wf.close(); + + const AssetInfo info = ScanEngine::inspectAsset(path, tmpDir.path()); + ASSERT_FALSE(info.loadError) << qPrintable(info.errorMessage); + EXPECT_EQ(info.format, QStringLiteral("tmd")); + EXPECT_GE(info.vertexCount, 3u); + EXPECT_GE(info.faceCount, 1u); + + Manager::kill(); + SelectionSet::kill(); + QThread::msleep(30); +} + +TEST(ScanEngineTest, InspectAsset_PsyqPly_LoadsGeometry) +{ + SelectionSet::kill(); + Manager::kill(); + QThread::msleep(30); + + ASSERT_TRUE(tryInitOgre()); + createStandardOgreMaterials(); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + const QString path = QDir(tmpDir.path()).filePath("scan_psyq.ply"); + { + QFile wf(path); + ASSERT_TRUE(wf.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream ts(&wf); + ts << "@PLY940102\n"; + ts << "4 4 1\n"; + ts << "0 0 0\n1 0 0\n1 1 0\n0 1 0\n"; + ts << "0 0 1\n0 0 1\n0 0 1\n0 0 1\n"; + ts << "1 0 1 2 3 0 1 2 3\n"; + } + + const AssetInfo info = ScanEngine::inspectAsset(path, tmpDir.path()); + ASSERT_FALSE(info.loadError) << qPrintable(info.errorMessage); + EXPECT_EQ(info.format, QStringLiteral("ply")); + EXPECT_GE(info.vertexCount, 4u); + EXPECT_GE(info.faceCount, 2u); + + Manager::kill(); + SelectionSet::kill(); + QThread::msleep(30); +} diff --git a/src/WelcomeDialog.cpp b/src/WelcomeDialog.cpp index 6166d9ea5..bb78b3dc0 100644 --- a/src/WelcomeDialog.cpp +++ b/src/WelcomeDialog.cpp @@ -1,4 +1,6 @@ #include "WelcomeDialog.h" +#include "Manager.h" +#include "MeshImporterExporter.h" #include "SentryReporter.h" #include @@ -66,9 +68,11 @@ WelcomeDialog::WelcomeDialog(QWidget* parent) auto* openFileBtn = new QPushButton("Open File..."); openFileBtn->setMinimumHeight(36); connect(openFileBtn, &QPushButton::clicked, this, [this]() { + const QString filter = MeshImporterExporter::importFileDialogFilterFromExtensionList( + Manager::defaultImportExtensions()); QString file = QFileDialog::getOpenFileName( - this, "Open 3D File", QString(), - "3D Files (*.fbx *.gltf *.glb *.vrm *.obj *.dae *.stl *.mesh *.3ds *.x *.ply *.tmd *.rsd);;All Files (*)"); + this, tr("Open 3D File"), QString(), filter, nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::HideNameFilterDetails); if (!file.isEmpty()) { m_action = OpenFile; m_selectedFile = file; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 53b234b06..e9cc025b4 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2378,7 +2378,7 @@ void MainWindow::on_actionImport_triggered() QStringList fileNames = QFileDialog::getOpenFileNames(this, tr("Select a mesh file to import"), "", - QString("Model ( "+ Manager::getSingleton()->getValidFileExtention().replace(".","*.") + " )"), + MeshImporterExporter::importFileDialogFilter(), nullptr, QFileDialog::DontUseNativeDialog|QFileDialog::HideNameFilterDetails); for (const QString& f : fileNames) From fe773173b60dd45f777cfb16ee1cf90a1976b6a3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 00:06:38 -0400 Subject: [PATCH 08/12] ci: trigger Deploy workflow for PR checks Co-authored-by: Cursor From 166cc0148c7f37d5f28666524b2f6b2e5ab7d7c3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 00:48:57 -0400 Subject: [PATCH 09/12] fix(scan): count local submesh verts with shared pool; fix(ps1-ply): skip quad merge on split normals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fillAssetInfoFromOgreMesh adds vertexData for submeshes with useSharedVertices=false when sharedVertexData exists (CodeRabbit P2). - Heuristic tri→quad merge requires matching welded normal indices on the shared interior edge (CodeRabbit P1). - Tests: ScanEngine Ogre inspect count helper (QTMESH_UNIT_TESTS), PS1PLY export split-edge case; TestHelpers mesh factory. Co-authored-by: Cursor --- src/CMakeLists.txt | 2 +- src/PS1/PS1PLY.cpp | 44 +++++++++++++++++++ src/PS1/PS1PLY_test.cpp | 96 +++++++++++++++++++++++++++++++++++++++++ src/ScanEngine.cpp | 21 +++++++-- src/ScanEngine.h | 9 ++++ src/ScanEngine_test.cpp | 30 +++++++++++++ src/TestHelpers.h | 73 +++++++++++++++++++++++++++++++ 7 files changed, 271 insertions(+), 4 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9a2ef851c..bb51f24cb 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index b7700dcae..8f55ef450 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -882,6 +882,44 @@ struct PsyqExportFace { bool hasColor = false; }; +static uint32_t weldedNormalAtWeldPos(uint32_t wpos, + uint32_t w0, + uint32_t w1, + uint32_t w2, + uint32_t n0, + uint32_t n1, + uint32_t n2) +{ + if (w0 == wpos) + return n0; + if (w1 == wpos) + return n1; + if (w2 == wpos) + return n2; + return std::numeric_limits::max(); +} + +static bool weldedNormalsAgreeOnInteriorEdge(uint32_t e0, + uint32_t e1, + uint32_t Aw0, + uint32_t Aw1, + uint32_t Aw2, + uint32_t An0, + uint32_t An1, + uint32_t An2, + uint32_t Bw0, + uint32_t Bw1, + uint32_t Bw2, + uint32_t Bn0, + uint32_t Bn1, + uint32_t Bn2) +{ + return weldedNormalAtWeldPos(e0, Aw0, Aw1, Aw2, An0, An1, An2) + == weldedNormalAtWeldPos(e0, Bw0, Bw1, Bw2, Bn0, Bn1, Bn2) + && weldedNormalAtWeldPos(e1, Aw0, Aw1, Aw2, An0, An1, An2) + == weldedNormalAtWeldPos(e1, Bw0, Bw1, Bw2, Bn0, Bn1, Bn2); +} + static uint32_t normalIndexForWeldedPos(uint32_t posIdx, uint32_t Ap0, uint32_t Ap1, @@ -946,6 +984,12 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, if (!tryMergeTrisToQuad({I0[i], I1[i], I2[i]}, {I0[j], I1[j], I2[j]}, weldPos, minDot, q)) continue; + // 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], I0[i], I1[i], I2[i], N0[i], N1[i], N2[i], I0[j], I1[j], + I2[j], N0[j], N1[j], N2[j])) + continue; + PsyqExportFace f; f.isQuad = true; f.v[0] = q[0]; diff --git a/src/PS1/PS1PLY_test.cpp b/src/PS1/PS1PLY_test.cpp index 1cb46550f..7f0a7ada2 100644 --- a/src/PS1/PS1PLY_test.cpp +++ b/src/PS1/PS1PLY_test.cpp @@ -97,6 +97,69 @@ static Ogre::MeshPtr createTwoTriQuadMesh(const std::string& name) return mesh; } +/** Two coplanar tris sharing a geometric edge with different normals on that edge (6 verts). */ +static Ogre::MeshPtr createSplitNormalTwoTriMesh(const std::string& name) +{ + if (auto old = Ogre::MeshManager::getSingleton().getByName(name)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::SubMesh* sm = mesh->createSubMesh(); + sm->setMaterialName("BaseWhite"); + sm->useSharedVertices = false; + + Ogre::VertexData* vd = new Ogre::VertexData(); + sm->vertexData = vd; + vd->vertexCount = 6; + Ogre::VertexDeclaration* decl = vd->vertexDeclaration; + Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + const size_t vsize = decl->getVertexSize(0); + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + vsize, 6, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint8_t* dst = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); + 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}, + }; + for (int i = 0; i < 6; ++i) { + uint8_t* row = dst + i * vsize; + float* pf = nullptr; + decl->findElementBySemantic(Ogre::VES_POSITION)->baseVertexPointerToElement(row, &pf); + pf[0] = rows[i][0]; + pf[1] = rows[i][1]; + pf[2] = rows[i][2]; + decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &pf); + pf[0] = rows[i][3]; + pf[1] = rows[i][4]; + pf[2] = rows[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, 3, 4, 5}; + ibuf->writeData(0, sizeof(idx), idx); + sm->indexData->indexBuffer = ibuf; + sm->indexData->indexCount = 6; + sm->indexData->indexStart = 0; + + mesh->_setBounds(Ogre::AxisAlignedBox(0, 0, 0, 1, 1, 0)); + mesh->_setBoundingSphereRadius(2.0f); + mesh->load(); + return mesh; +} + static bool readPsyqPlyCountsAndFirstFace(const QString& path, int& nV, int& nN, int& nF, QString& firstFaceLine) { QFile file(path); @@ -255,6 +318,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()); diff --git a/src/ScanEngine.cpp b/src/ScanEngine.cpp index fc8bdbeec..0981fe2e5 100644 --- a/src/ScanEngine.cpp +++ b/src/ScanEngine.cpp @@ -178,17 +178,20 @@ static void fillAssetInfoFromOgreMesh(AssetInfo& info, const Ogre::MeshPtr& mesh info.redundantKeyframes = 0; const unsigned nSub = mesh->getNumSubMeshes(); - const bool useShared = mesh->sharedVertexData != nullptr; - if (useShared && mesh->sharedVertexData) + const bool hasShared = mesh->sharedVertexData != nullptr; + if (hasShared && mesh->sharedVertexData) info.vertexCount = mesh->sharedVertexData->vertexCount; for (unsigned i = 0; i < nSub; ++i) { Ogre::SubMesh* sm = mesh->getSubMesh(i); if (!sm || !sm->indexData) continue; - if (!useShared) { + if (!hasShared) { if (sm->vertexData) info.vertexCount += sm->vertexData->vertexCount; + } else if (!sm->useSharedVertices && sm->vertexData) { + // Shared pool is already counted; add submesh-local vertex buffers. + info.vertexCount += sm->vertexData->vertexCount; } info.faceCount += sm->indexData->indexCount / 3; } @@ -202,6 +205,18 @@ static void fillAssetInfoFromOgreMesh(AssetInfo& info, const Ogre::MeshPtr& mesh } } +#ifdef QTMESH_UNIT_TESTS +void ScanEngine::testApplyOgreMeshInspectCounts(AssetInfo& info, const Ogre::MeshPtr& mesh) +{ + if (!mesh) { + info.vertexCount = 0; + info.faceCount = 0; + return; + } + fillAssetInfoFromOgreMesh(info, mesh); +} +#endif + static bool loadAndFillOgreInspect(AssetInfo& info, const std::function& importFn, QString* detailErr) diff --git a/src/ScanEngine.h b/src/ScanEngine.h index c8be1dbdb..9f7698764 100644 --- a/src/ScanEngine.h +++ b/src/ScanEngine.h @@ -8,6 +8,10 @@ #include #include +#ifdef QTMESH_UNIT_TESTS +#include +#endif + struct aiScene; enum class Severity { Info, Warning, Error }; @@ -137,6 +141,11 @@ class ScanEngine { static bool matchesWildcard(const QString& text, const QString& pattern); static bool checkNameCase(const QString& fileName, const QString& convention); static QString convertNameToCase(const QString& fileName, const QString& convention); + +#ifdef QTMESH_UNIT_TESTS + /// Fills \a info geometry fields from an in-memory Ogre mesh (same logic as scan Ogre inspect). + static void testApplyOgreMeshInspectCounts(AssetInfo& info, const Ogre::MeshPtr& mesh); +#endif }; #endif // SCANENGINE_H diff --git a/src/ScanEngine_test.cpp b/src/ScanEngine_test.cpp index 2e2529f29..c3aecc650 100644 --- a/src/ScanEngine_test.cpp +++ b/src/ScanEngine_test.cpp @@ -20,6 +20,8 @@ #include #include +#include + #include namespace { @@ -1938,6 +1940,34 @@ TEST(ScanEngineTest, InspectAsset_MinimalTmd_LoadsGeometry) QThread::msleep(30); } +TEST(ScanEngineTest, TestApplyOgreMeshInspectCounts_IncludesLocalSubmeshWithSharedPool) +{ + SelectionSet::kill(); + Manager::kill(); + QThread::msleep(30); + + ASSERT_TRUE(tryInitOgre()); + + const std::string meshName = "ScanEngineInspectSharedLocalUT"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = createInMemoryMeshSharedVertsPlusLocalSubmesh(meshName); + ASSERT_TRUE(mesh); + + AssetInfo info; + ScanEngine::testApplyOgreMeshInspectCounts(info, mesh); + + EXPECT_EQ(info.vertexCount, 6u); + EXPECT_EQ(info.faceCount, 2u); + + Ogre::MeshManager::getSingleton().remove(meshName); + + Manager::kill(); + SelectionSet::kill(); + QThread::msleep(30); +} + TEST(ScanEngineTest, InspectAsset_PsyqPly_LoadsGeometry) { SelectionSet::kill(); diff --git a/src/TestHelpers.h b/src/TestHelpers.h index 32b4b076c..f25aee830 100644 --- a/src/TestHelpers.h +++ b/src/TestHelpers.h @@ -279,6 +279,79 @@ static inline Ogre::MeshPtr createInMemoryTriangleMesh(const std::string& name) return mesh; } +/** + * Mesh with sharedVertexData (3 verts) on submesh 0 and a second submesh with its own + * vertex buffer (3 verts). Used to validate scan-style vertex totals (shared + local). + */ +static inline Ogre::MeshPtr createInMemoryMeshSharedVertsPlusLocalSubmesh(const std::string& name) +{ + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + mesh->sharedVertexData = new Ogre::VertexData(); + auto* sharedDecl = mesh->sharedVertexData->vertexDeclaration; + size_t sharedOffset = 0; + sharedDecl->addElement(0, sharedOffset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + sharedOffset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + sharedDecl->addElement(0, sharedOffset, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + sharedOffset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + sharedDecl->addElement(0, sharedOffset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + + auto sharedVbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + sharedDecl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float sharedVerts[] = { + 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, + }; + sharedVbuf->writeData(0, sizeof(sharedVerts), sharedVerts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, sharedVbuf); + mesh->sharedVertexData->vertexCount = 3; + + auto* sub0 = mesh->createSubMesh(); + auto sharedIbuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t sharedIdx[] = {0, 1, 2}; + sharedIbuf->writeData(0, sizeof(sharedIdx), sharedIdx); + sub0->useSharedVertices = true; + sub0->indexData->indexBuffer = sharedIbuf; + sub0->indexData->indexCount = 3; + + auto* sub1 = mesh->createSubMesh(); + sub1->useSharedVertices = false; + sub1->vertexData = new Ogre::VertexData(); + auto* decl1 = sub1->vertexData->vertexDeclaration; + size_t offset1 = 0; + decl1->addElement(0, offset1, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + offset1 += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl1->addElement(0, offset1, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + offset1 += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl1->addElement(0, offset1, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + + auto sub1Vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl1->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + float sub1Verts[] = { + 10,0,0, 0,0,1, 0.0f,0.0f, + 11,0,0, 0,0,1, 1.0f,0.0f, + 10,1,0, 0,0,1, 0.0f,1.0f, + }; + sub1Vbuf->writeData(0, sizeof(sub1Verts), sub1Verts); + sub1->vertexData->vertexBufferBinding->setBinding(0, sub1Vbuf); + sub1->vertexData->vertexCount = 3; + + auto sub1Ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t sub1Idx[] = {0, 1, 2}; + sub1Ibuf->writeData(0, sizeof(sub1Idx), sub1Idx); + sub1->indexData->indexBuffer = sub1Ibuf; + sub1->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1, -1, -1, 12, 2, 2)); + mesh->_setBoundingSphereRadius(12.0f); + mesh->load(); + return mesh; +} + /** * Creates an in-memory triangle mesh with positions, normals, UVs, and vertex colors. * From e017a47741922f167cb35aaa82c77fb056d88404 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 01:13:24 -0400 Subject: [PATCH 10/12] refactor(tests): dedupe Ogre mesh helpers for Sonar maintainability - PS1PLY tests: single createInterleavedPosNormalMesh builder for two-tri fixtures. - TransformCommands_test: reuse TestHelpers createInMemoryMeshSharedVertsPlusLocalSubmesh. Co-authored-by: Cursor --- src/PS1/PS1PLY_test.cpp | 108 ++++++++---------------- src/commands/TransformCommands_test.cpp | 71 +--------------- 2 files changed, 36 insertions(+), 143 deletions(-) diff --git a/src/PS1/PS1PLY_test.cpp b/src/PS1/PS1PLY_test.cpp index 7f0a7ada2..6664367bf 100644 --- a/src/PS1/PS1PLY_test.cpp +++ b/src/PS1/PS1PLY_test.cpp @@ -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); @@ -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(nVerts); Ogre::VertexDeclaration* decl = vd->vertexDeclaration; Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding; size_t off = 0; @@ -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(nVerts), Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); uint8_t* dst = static_cast(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(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(indexCount), + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + ibuf->writeData(0, static_cast(indexCount) * sizeof(uint16_t), indices); sm->indexData->indexBuffer = ibuf; - sm->indexData->indexCount = 6; + sm->indexData->indexCount = static_cast(indexCount); sm->indexData->indexStart = 0; mesh->_setBounds(Ogre::AxisAlignedBox(0, 0, 0, 1, 1, 0)); @@ -97,33 +94,23 @@ 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) { - if (auto old = Ogre::MeshManager::getSingleton().getByName(name)) - Ogre::MeshManager::getSingleton().remove(old); - - Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( - name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - Ogre::SubMesh* sm = mesh->createSubMesh(); - sm->setMaterialName("BaseWhite"); - sm->useSharedVertices = false; - - Ogre::VertexData* vd = new Ogre::VertexData(); - sm->vertexData = vd; - vd->vertexCount = 6; - Ogre::VertexDeclaration* decl = vd->vertexDeclaration; - Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding; - size_t off = 0; - decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); - off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); - decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); - off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); - const size_t vsize = decl->getVertexSize(0); - auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( - vsize, 6, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - uint8_t* dst = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); - const float rows[][6] = { + 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}, @@ -131,33 +118,8 @@ static Ogre::MeshPtr createSplitNormalTwoTriMesh(const std::string& name) {1.f, 1.f, 0.f, 1.f, 0.f, 0.f}, {0.f, 1.f, 0.f, 0.f, 0.f, 1.f}, }; - for (int i = 0; i < 6; ++i) { - uint8_t* row = dst + i * vsize; - float* pf = nullptr; - decl->findElementBySemantic(Ogre::VES_POSITION)->baseVertexPointerToElement(row, &pf); - pf[0] = rows[i][0]; - pf[1] = rows[i][1]; - pf[2] = rows[i][2]; - decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &pf); - pf[0] = rows[i][3]; - pf[1] = rows[i][4]; - pf[2] = rows[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, 3, 4, 5}; - ibuf->writeData(0, sizeof(idx), idx); - sm->indexData->indexBuffer = ibuf; - sm->indexData->indexCount = 6; - sm->indexData->indexStart = 0; - - mesh->_setBounds(Ogre::AxisAlignedBox(0, 0, 0, 1, 1, 0)); - mesh->_setBoundingSphereRadius(2.0f); - mesh->load(); - return mesh; + 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) diff --git a/src/commands/TransformCommands_test.cpp b/src/commands/TransformCommands_test.cpp index 56f118b58..1a25e1a40 100644 --- a/src/commands/TransformCommands_test.cpp +++ b/src/commands/TransformCommands_test.cpp @@ -28,75 +28,6 @@ class TransformCommandsTests : public ::testing::Test { } }; -static inline Ogre::MeshPtr createInMemoryTwoSubMeshMesh(const std::string& name) -{ - auto mesh = Ogre::MeshManager::getSingleton().createManual( - name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - - mesh->sharedVertexData = new Ogre::VertexData(); - auto* sharedDecl = mesh->sharedVertexData->vertexDeclaration; - size_t sharedOffset = 0; - sharedDecl->addElement(0, sharedOffset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); - sharedOffset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); - sharedDecl->addElement(0, sharedOffset, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); - sharedOffset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); - sharedDecl->addElement(0, sharedOffset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); - - auto sharedVbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( - sharedDecl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - float sharedVerts[] = { - 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, - }; - sharedVbuf->writeData(0, sizeof(sharedVerts), sharedVerts); - mesh->sharedVertexData->vertexBufferBinding->setBinding(0, sharedVbuf); - mesh->sharedVertexData->vertexCount = 3; - - auto* sub0 = mesh->createSubMesh(); - auto sharedIbuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( - Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - uint16_t sharedIdx[] = {0, 1, 2}; - sharedIbuf->writeData(0, sizeof(sharedIdx), sharedIdx); - sub0->useSharedVertices = true; - sub0->indexData->indexBuffer = sharedIbuf; - sub0->indexData->indexCount = 3; - - auto* sub1 = mesh->createSubMesh(); - sub1->useSharedVertices = false; - sub1->vertexData = new Ogre::VertexData(); - auto* decl1 = sub1->vertexData->vertexDeclaration; - size_t offset1 = 0; - decl1->addElement(0, offset1, Ogre::VET_FLOAT3, Ogre::VES_POSITION); - offset1 += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); - decl1->addElement(0, offset1, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); - offset1 += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); - decl1->addElement(0, offset1, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); - - auto sub1Vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( - decl1->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - float sub1Verts[] = { - 10,0,0, 0,0,1, 0.0f,0.0f, - 11,0,0, 0,0,1, 1.0f,0.0f, - 10,1,0, 0,0,1, 0.0f,1.0f, - }; - sub1Vbuf->writeData(0, sizeof(sub1Verts), sub1Verts); - sub1->vertexData->vertexBufferBinding->setBinding(0, sub1Vbuf); - sub1->vertexData->vertexCount = 3; - - auto sub1Ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( - Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - uint16_t sub1Idx[] = {0, 1, 2}; - sub1Ibuf->writeData(0, sizeof(sub1Idx), sub1Idx); - sub1->indexData->indexBuffer = sub1Ibuf; - sub1->indexData->indexCount = 3; - - mesh->_setBounds(Ogre::AxisAlignedBox(-1, -1, -1, 12, 2, 2)); - mesh->_setBoundingSphereRadius(12.0); - mesh->load(); - return mesh; -} - // ---- TranslateCommand ---- TEST_F(TransformCommandsTests, TranslateCommand_RedoMovesNode) { @@ -1243,7 +1174,7 @@ TEST_F(TransformCommandsTests, SubMeshTransformCommand_NonZeroSubMeshIndexTarget ASSERT_TRUE(canLoadMeshFiles()); Manager* mgr = Manager::getSingleton(); - auto mesh = createInMemoryTwoSubMeshMesh("SubMeshCmdTwoSubMeshIndex"); + auto mesh = createInMemoryMeshSharedVertsPlusLocalSubmesh("SubMeshCmdTwoSubMeshIndex"); auto* entity = mgr->getSceneMgr()->createEntity(mesh); auto* node = mgr->addSceneNode("SubMeshCmdTwoSubMeshNode"); ASSERT_NE(entity, nullptr); From f52279eb888b3640e914f2ba7d1f9ffcf9d8d95b Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 01:44:11 -0400 Subject: [PATCH 11/12] fix(sonar): simplify welded-tri helpers; std::array + NOSONAR in scan test mesh - PS1PLY: PsyqWeldedTri bundles corners; drops 14-arg function and redundant static. - TestHelpers shared+local mesh: constexpr std::array buffers; NOSONAR for Ogre-owned VertexData. Co-authored-by: Cursor --- src/PS1/PS1PLY.cpp | 86 +++++++++++++--------------------------------- src/TestHelpers.h | 26 +++++++------- 2 files changed, 38 insertions(+), 74 deletions(-) diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 8f55ef450..d5e5be65c 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -882,71 +882,33 @@ struct PsyqExportFace { bool hasColor = false; }; -static uint32_t weldedNormalAtWeldPos(uint32_t wpos, - uint32_t w0, - uint32_t w1, - uint32_t w2, - uint32_t n0, - uint32_t n1, - uint32_t n2) +struct PsyqWeldedTri { + std::array pw{}; + std::array nw{}; +}; + +uint32_t weldedNormalAtWeldPos(uint32_t wpos, const PsyqWeldedTri& t) { - if (w0 == wpos) - return n0; - if (w1 == wpos) - return n1; - if (w2 == wpos) - return n2; + for (int c = 0; c < 3; ++c) { + if (t.pw[static_cast(c)] == wpos) + return t.nw[static_cast(c)]; + } return std::numeric_limits::max(); } -static bool weldedNormalsAgreeOnInteriorEdge(uint32_t e0, - uint32_t e1, - uint32_t Aw0, - uint32_t Aw1, - uint32_t Aw2, - uint32_t An0, - uint32_t An1, - uint32_t An2, - uint32_t Bw0, - uint32_t Bw1, - uint32_t Bw2, - uint32_t Bn0, - uint32_t Bn1, - uint32_t Bn2) +bool weldedNormalsAgreeOnInteriorEdge(uint32_t e0, uint32_t e1, const PsyqWeldedTri& A, const PsyqWeldedTri& B) { - return weldedNormalAtWeldPos(e0, Aw0, Aw1, Aw2, An0, An1, An2) - == weldedNormalAtWeldPos(e0, Bw0, Bw1, Bw2, Bn0, Bn1, Bn2) - && weldedNormalAtWeldPos(e1, Aw0, Aw1, Aw2, An0, An1, An2) - == weldedNormalAtWeldPos(e1, Bw0, Bw1, Bw2, Bn0, Bn1, Bn2); + return weldedNormalAtWeldPos(e0, A) == weldedNormalAtWeldPos(e0, B) + && weldedNormalAtWeldPos(e1, A) == weldedNormalAtWeldPos(e1, B); } -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) +uint32_t normalIndexForWeldedPos(uint32_t posIdx, 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; + const uint32_t na = weldedNormalAtWeldPos(posIdx, A); + if (na != std::numeric_limits::max()) + return na; + const uint32_t nb = weldedNormalAtWeldPos(posIdx, B); + return nb != std::numeric_limits::max() ? nb : 0u; } static void mergeSubmeshTrisToQuads(const std::vector& I0, @@ -984,10 +946,12 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, 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], I0[i], I1[i], I2[i], N0[i], N1[i], N2[i], I0[j], I1[j], - I2[j], N0[j], N1[j], N2[j])) + if (!weldedNormalsAgreeOnInteriorEdge(q[1], q[2], triA, triB)) continue; PsyqExportFace f; @@ -997,9 +961,7 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, f.v[2] = q[2]; f.v[3] = q[3]; for (int k = 0; k < 4; ++k) { - f.n[static_cast(k)] = normalIndexForWeldedPos( - q[static_cast(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(k)] = normalIndexForWeldedPos(q[static_cast(k)], triA, triB); } if (haveTriColors) { const QColor a = triRgb(i); diff --git a/src/TestHelpers.h b/src/TestHelpers.h index f25aee830..52588d87b 100644 --- a/src/TestHelpers.h +++ b/src/TestHelpers.h @@ -1,6 +1,8 @@ #ifndef TEST_HELPERS_H #define TEST_HELPERS_H +#include + #include #include #include @@ -288,7 +290,7 @@ static inline Ogre::MeshPtr createInMemoryMeshSharedVertsPlusLocalSubmesh(const auto mesh = Ogre::MeshManager::getSingleton().createManual( name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - mesh->sharedVertexData = new Ogre::VertexData(); + mesh->sharedVertexData = new Ogre::VertexData(); // NOSONAR(cpp:S5025) — Ogre::Mesh owns VertexData auto* sharedDecl = mesh->sharedVertexData->vertexDeclaration; size_t sharedOffset = 0; sharedDecl->addElement(0, sharedOffset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); @@ -299,27 +301,27 @@ static inline Ogre::MeshPtr createInMemoryMeshSharedVertsPlusLocalSubmesh(const auto sharedVbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( sharedDecl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - float sharedVerts[] = { + static constexpr std::array sharedVerts{{ 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, - }; - sharedVbuf->writeData(0, sizeof(sharedVerts), sharedVerts); + }}; + sharedVbuf->writeData(0, sharedVerts.size() * sizeof(float), sharedVerts.data()); mesh->sharedVertexData->vertexBufferBinding->setBinding(0, sharedVbuf); mesh->sharedVertexData->vertexCount = 3; auto* sub0 = mesh->createSubMesh(); auto sharedIbuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - uint16_t sharedIdx[] = {0, 1, 2}; - sharedIbuf->writeData(0, sizeof(sharedIdx), sharedIdx); + static constexpr std::array sharedIdx{{0, 1, 2}}; + sharedIbuf->writeData(0, sharedIdx.size() * sizeof(uint16_t), sharedIdx.data()); sub0->useSharedVertices = true; sub0->indexData->indexBuffer = sharedIbuf; sub0->indexData->indexCount = 3; auto* sub1 = mesh->createSubMesh(); sub1->useSharedVertices = false; - sub1->vertexData = new Ogre::VertexData(); + sub1->vertexData = new Ogre::VertexData(); // NOSONAR(cpp:S5025) — Ogre::SubMesh owns VertexData auto* decl1 = sub1->vertexData->vertexDeclaration; size_t offset1 = 0; decl1->addElement(0, offset1, Ogre::VET_FLOAT3, Ogre::VES_POSITION); @@ -330,19 +332,19 @@ static inline Ogre::MeshPtr createInMemoryMeshSharedVertsPlusLocalSubmesh(const auto sub1Vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( decl1->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - float sub1Verts[] = { + static constexpr std::array sub1Verts{{ 10,0,0, 0,0,1, 0.0f,0.0f, 11,0,0, 0,0,1, 1.0f,0.0f, 10,1,0, 0,0,1, 0.0f,1.0f, - }; - sub1Vbuf->writeData(0, sizeof(sub1Verts), sub1Verts); + }}; + sub1Vbuf->writeData(0, sub1Verts.size() * sizeof(float), sub1Verts.data()); sub1->vertexData->vertexBufferBinding->setBinding(0, sub1Vbuf); sub1->vertexData->vertexCount = 3; auto sub1Ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - uint16_t sub1Idx[] = {0, 1, 2}; - sub1Ibuf->writeData(0, sizeof(sub1Idx), sub1Idx); + static constexpr std::array sub1Idx{{0, 1, 2}}; + sub1Ibuf->writeData(0, sub1Idx.size() * sizeof(uint16_t), sub1Idx.data()); sub1->indexData->indexBuffer = sub1Ibuf; sub1->indexData->indexCount = 3; From a40225e576af57c7d07f82df1ff2891b36e4cb38 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 10 May 2026 02:11:15 -0400 Subject: [PATCH 12/12] fix(scan): template loadAndFillOgreInspect; explicit lambda captures; scan mesh id - Replace std::function import callback with a template (Sonar cpp:S5213). - nextScanInspectMeshId() replaces mutable file-scope atomic. - RSD/TMD/PLY inspect lambdas capture paths explicitly; const SubMesh pointers in counts. Co-authored-by: Cursor --- src/ScanEngine.cpp | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/ScanEngine.cpp b/src/ScanEngine.cpp index 0981fe2e5..3701bc16a 100644 --- a/src/ScanEngine.cpp +++ b/src/ScanEngine.cpp @@ -42,7 +42,6 @@ #include #include #include -#include #include #include @@ -128,7 +127,11 @@ static bool pathEndsWithInsensitive(const QString& p, QLatin1String suf) return p.endsWith(suf, Qt::CaseInsensitive); } -static std::atomic g_scanInspectMeshSeq{0}; +static int nextScanInspectMeshId() +{ + static std::atomic seq{0}; + return ++seq; +} static void ensureBaseMaterialForScanInspect() { @@ -183,7 +186,7 @@ static void fillAssetInfoFromOgreMesh(AssetInfo& info, const Ogre::MeshPtr& mesh info.vertexCount = mesh->sharedVertexData->vertexCount; for (unsigned i = 0; i < nSub; ++i) { - Ogre::SubMesh* sm = mesh->getSubMesh(i); + const Ogre::SubMesh* sm = mesh->getSubMesh(i); if (!sm || !sm->indexData) continue; if (!hasShared) { @@ -197,7 +200,7 @@ static void fillAssetInfoFromOgreMesh(AssetInfo& info, const Ogre::MeshPtr& mesh } for (unsigned i = 0; i < nSub; ++i) { - if (Ogre::SubMesh* sm = mesh->getSubMesh(i)) { + if (const Ogre::SubMesh* sm = mesh->getSubMesh(i)) { const Ogre::String& mat = sm->getMaterialName(); if (!mat.empty()) info.materialNames.append(QString::fromStdString(mat)); @@ -217,9 +220,8 @@ void ScanEngine::testApplyOgreMeshInspectCounts(AssetInfo& info, const Ogre::Mes } #endif -static bool loadAndFillOgreInspect(AssetInfo& info, - const std::function& importFn, - QString* detailErr) +template +static bool loadAndFillOgreInspect(AssetInfo& info, ImportFn&& importFn, QString* detailErr) { QString ogreErr; if (!ensureOgreMaterialsForScanInspect(&ogreErr)) { @@ -229,7 +231,7 @@ static bool loadAndFillOgreInspect(AssetInfo& info, *detailErr = ogreErr; return false; } - const std::string meshName = std::string("_qtmesh_scan_") + std::to_string(++g_scanInspectMeshSeq); + const std::string meshName = std::string("_qtmesh_scan_") + std::to_string(nextScanInspectMeshId()); Ogre::MeshPtr mesh = importFn(meshName); if (!mesh) { info.loadError = true; @@ -609,7 +611,7 @@ AssetInfo ScanEngine::inspectAsset(const QString& filePath, const QString& scanR } const QFileInfo fiRsd(filePath); const QString rsdDir = fiRsd.absolutePath(); - auto resolveGeom = [&](const QString& rel) -> QString { + auto resolveGeom = [rsdDir](const QString& rel) -> QString { if (rel.isEmpty()) return {}; const QFileInfo r(rel); @@ -626,12 +628,12 @@ AssetInfo ScanEngine::inspectAsset(const QString& filePath, const QString& scanR QString detailErr; if (gext == QLatin1String("tmd")) { loadAndFillOgreInspect( - info, [&](const std::string& mn) { return PS1TMD::importTmd(geomPath, mn); }, &detailErr); + info, [geomPath](const std::string& mn) { return PS1TMD::importTmd(geomPath, mn); }, &detailErr); return info; } if (gext == QLatin1String("ply") && PS1PLY::isPsyqPlyFile(geomPath)) { loadAndFillOgreInspect( - info, [&](const std::string& mn) { return PS1PLY::importPsyqPly(geomPath, mn); }, &detailErr); + info, [geomPath](const std::string& mn) { return PS1PLY::importPsyqPly(geomPath, mn); }, &detailErr); return info; } if (gext == QLatin1String("rsd")) { @@ -670,14 +672,14 @@ AssetInfo ScanEngine::inspectAsset(const QString& filePath, const QString& scanR if (extLower == QLatin1String("tmd")) { QString detailErr; loadAndFillOgreInspect( - info, [&](const std::string& mn) { return PS1TMD::importTmd(filePath, mn); }, &detailErr); + info, [filePath](const std::string& mn) { return PS1TMD::importTmd(filePath, mn); }, &detailErr); return info; } if (extLower == QLatin1String("ply") && PS1PLY::isPsyqPlyFile(filePath)) { QString detailErr; loadAndFillOgreInspect( - info, [&](const std::string& mn) { return PS1PLY::importPsyqPly(filePath, mn); }, &detailErr); + info, [filePath](const std::string& mn) { return PS1PLY::importPsyqPly(filePath, mn); }, &detailErr); return info; }