diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 711535aac..52c771d21 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -31,13 +31,17 @@ THE SOFTWARE. #include #include #include +#include #include +#include #include #include #include #include +#include #include #include +#include #include #include "OgreXML/OgreXMLMeshSerializer.h" @@ -64,6 +68,7 @@ THE SOFTWARE. #include "EditModeController.h" #include #include +#include #ifndef WIN32 #include @@ -1206,6 +1211,113 @@ static QString firstMaterialNameInOgreMaterialScript(const QByteArray& script) return {}; } +/// Load `texPath` into Ogre as a manual texture under `resourceName`, replacing any existing +/// Builds an Ogre texture resource name scoped to the source asset path so that two RSDs +/// referencing different files that happen to share a basename (e.g. `Wood.jpg`) don't +/// collide in Ogre's global texture registry. Returns a name of the form +/// `__<8charHashOfAbsPath>.` which preserves the human-readable basename +/// for inspection while guaranteeing uniqueness across assets. The `__` portion is +/// stripped by `rsdResourceNameToBasename()` on export. +static QString scopedRsdResourceName(const QString& texPath) +{ + const QFileInfo fi(texPath); + const QString abs = fi.absoluteFilePath(); + const QString hash = QString::fromLatin1( + QCryptographicHash::hash(abs.toUtf8(), QCryptographicHash::Sha1).toHex()).left(8); + QString suffix = fi.suffix().toLower(); + if (suffix.isEmpty()) + suffix = QStringLiteral("tex"); + return fi.completeBaseName() + QStringLiteral("__") + hash + + QStringLiteral(".") + suffix; +} + +/// Inverse of `scopedRsdResourceName()` — strips the `__<8hexchars>` segment a scoped +/// resource name carries so we can recover the original `Wood.jpg`-style basename when +/// writing sidecar files next to an exported RSD. Names that don't match the scoped +/// pattern are returned as `QFileInfo::fileName(name)` (i.e. a best-effort basename) so +/// non-RSD textures still get a sensible filename. +static QString rsdResourceNameToBasename(const QString& resName) +{ + static const QRegularExpression scoped( + QStringLiteral("^(.+)__[0-9a-f]{8}\\.([^.]+)$")); + const auto m = scoped.match(resName); + if (m.hasMatch()) + return m.captured(1) + QStringLiteral(".") + m.captured(2); + return QFileInfo(resName).fileName(); +} + +/// entry. Handles common 2D formats (PNG/JPG/BMP/TGA/...) by leaning on Qt's QImage decoder +/// when the file extension is not one Ogre's built-in codecs already register. Returns true +/// on success. +static bool loadExternalTextureForRsd(const QString& texPath, + const Ogre::String& resourceName, + QString* outError = nullptr) +{ + if (texPath.isEmpty() || !QFileInfo::exists(texPath)) { + if (outError) *outError = QStringLiteral("Texture file not found: %1").arg(texPath); + return false; + } + Ogre::Image img; + + // Try Ogre's native codecs first via the file extension (cheap, no QImage decode). + const QString ext = QFileInfo(texPath).suffix().toLower(); + bool loaded = false; + if (!ext.isEmpty()) { + try { + QFile f(texPath); + if (f.open(QIODevice::ReadOnly)) { + const QByteArray bytes = f.readAll(); + Ogre::DataStreamPtr ds(new Ogre::MemoryDataStream( + const_cast(bytes.constData()), + static_cast(bytes.size()), + /*freeOnClose*/ false, + /*readOnly*/ true)); + img.load(ds, ext.toStdString()); + loaded = (img.getWidth() > 0 && img.getHeight() > 0); + } + } catch (const Ogre::Exception&) { + loaded = false; + } + } + + if (!loaded) { + // Fall back to QImage so e.g. .jpg works even when no codec plugin is registered. + QImage qi(texPath); + if (qi.isNull()) { + if (outError) *outError = QStringLiteral("Could not decode image: %1").arg(texPath); + return false; + } + QImage rgba = qi.convertToFormat(QImage::Format_RGBA8888); + const size_t bytes = static_cast(rgba.sizeInBytes()); + // Ogre takes ownership of `data` when autoDelete=true (loadDynamicImage with autoDelete). + Ogre::uchar* data = OGRE_ALLOC_T(Ogre::uchar, bytes, Ogre::MEMCATEGORY_GENERAL); + std::memcpy(data, rgba.constBits(), bytes); + try { + img.loadDynamicImage(data, + static_cast(rgba.width()), + static_cast(rgba.height()), + 1, // depth + Ogre::PF_BYTE_RGBA, + true /* autoDelete: Ogre owns `data` */); + } catch (const Ogre::Exception& e) { + OGRE_FREE(data, Ogre::MEMCATEGORY_GENERAL); + if (outError) *outError = QString::fromStdString(e.getFullDescription()); + return false; + } + } + + auto& tm = Ogre::TextureManager::getSingleton(); + if (tm.resourceExists(resourceName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) + tm.remove(resourceName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + try { + tm.loadImage(resourceName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, img); + } catch (const Ogre::Exception& e) { + if (outError) *outError = QString::fromStdString(e.getFullDescription()); + return false; + } + return true; +} + static void applyTextureMaterialToEntity(Ogre::Entity* entity, const QString& materialName, const QString& textureResourceNameOrEmpty) @@ -1411,8 +1523,9 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad continue; } - // Preload referenced TIM textures (best-effort). Geometry import below may also load TIMs - // (e.g. PS1TMD sibling auto-load), but RSD frequently points to differently named TIMs. + // Preload referenced textures. RSD/TEX[] may point at PS1 TIMs (Psy-Q toolchains) + // OR at modern raster formats like JPG/PNG (Blender-RSD exporter pipeline) — we + // try TIM first, then fall back to QImage-decoded loaders. const QString rsdDir = file.absolutePath(); const auto resolve = [&rsdDir](const QString& rel) -> QString { if (rel.isEmpty()) return {}; @@ -1420,36 +1533,97 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad return fi.isAbsolute() ? fi.absoluteFilePath() : QDir(rsdDir).filePath(rel); }; + struct RsdTextureSlot { + QString resourceName; ///< Ogre resource name (or empty when not loaded). + int width = 0; + int height = 0; + }; + std::vector rsdTexSlots(rsd.textures.size()); QString firstTimResource; - for (const QString& texRel : rsd.textures) { - const QString timPath = resolve(texRel); - if (timPath.isEmpty() || !QFileInfo::exists(timPath)) + for (int ti = 0; ti < rsd.textures.size(); ++ti) { + const QString texRel = rsd.textures[ti]; + const QString texPath = resolve(texRel); + if (texPath.isEmpty() || !QFileInfo::exists(texPath)) { + SentryReporter::addBreadcrumb( + QStringLiteral("file.import"), + QStringLiteral("RSD TEX[%1] missing: %2").arg(ti).arg(texRel)); continue; + } + Ogre::Image img; + bool loaded = false; + const QString suffix = QFileInfo(texPath).suffix().toLower(); QString timErr; - if (!PS1TIM::loadTimToOgreImage(timPath, img, &timErr)) - continue; + if (suffix == QStringLiteral("tim")) + loaded = PS1TIM::loadTimToOgreImage(texPath, img, &timErr); - const QString resName = QFileInfo(timPath).completeBaseName() + QStringLiteral(".tim"); - // Create/replace in the default group so TMD materials can reference it. + // Resource name is scoped by the absolute texture path so two RSDs that + // both ship a "Wood.jpg" do not clobber each other in Ogre's global + // TextureManager registry. + const QString resName = scopedRsdResourceName(texPath); const Ogre::String ogreName = resName.toStdString(); - if (Ogre::TextureManager::getSingleton().resourceExists(ogreName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) - Ogre::TextureManager::getSingleton().remove(ogreName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - Ogre::TexturePtr tex = Ogre::TextureManager::getSingleton().loadImage( - ogreName, - Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, - img); - if (!firstTimResource.isEmpty()) - continue; - firstTimResource = resName; + + SentryReporter::addBreadcrumb( + QStringLiteral("file.import"), + QStringLiteral("RSD TEX[%1] load: %2").arg(ti).arg(QFileInfo(texPath).fileName())); + + if (loaded) { + if (Ogre::TextureManager::getSingleton().resourceExists( + ogreName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) + Ogre::TextureManager::getSingleton().remove( + ogreName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::TexturePtr tex = Ogre::TextureManager::getSingleton().loadImage( + ogreName, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, + img); + rsdTexSlots[ti].resourceName = resName; + rsdTexSlots[ti].width = static_cast(img.getWidth()); + rsdTexSlots[ti].height = static_cast(img.getHeight()); + SentryReporter::addBreadcrumb( + QStringLiteral("file.import"), + QStringLiteral("RSD TEX[%1] TIM loaded (%2x%3)") + .arg(ti).arg(rsdTexSlots[ti].width).arg(rsdTexSlots[ti].height)); + } else if (suffix == QStringLiteral("tim")) { + SentryReporter::addBreadcrumb( + QStringLiteral("file.import"), + QStringLiteral("RSD TEX[%1] TIM load failed: %2").arg(ti).arg(timErr)); + } else { + // Non-TIM raster format (PNG/JPG/BMP/TGA…) + QString extErr; + if (loadExternalTextureForRsd(texPath, ogreName, &extErr)) { + rsdTexSlots[ti].resourceName = resName; + // Reuse the texture we just loaded — no need to redecode the + // file with QImage just to measure dimensions. + if (auto tex = Ogre::TextureManager::getSingleton().getByName( + ogreName, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) { + rsdTexSlots[ti].width = static_cast(tex->getWidth()); + rsdTexSlots[ti].height = static_cast(tex->getHeight()); + } + SentryReporter::addBreadcrumb( + QStringLiteral("file.import"), + QStringLiteral("RSD TEX[%1] external loaded (%2x%3)") + .arg(ti).arg(rsdTexSlots[ti].width).arg(rsdTexSlots[ti].height)); + } else { + Ogre::LogManager::getSingleton().logMessage( + "Warning: RSD texture load failed for " + + texPath.toStdString() + ": " + extErr.toStdString()); + SentryReporter::addBreadcrumb( + QStringLiteral("file.import"), + QStringLiteral("RSD TEX[%1] external load failed: %2").arg(ti).arg(extErr)); + } + } + if (firstTimResource.isEmpty() && !rsdTexSlots[ti].resourceName.isEmpty()) + firstTimResource = rsdTexSlots[ti].resourceName; } // If a MAT sidecar exists, try to interpret it. // - If it looks like an Ogre material script, load it into the RSD directory group. - // - Otherwise, treat it as a PS1/Psy-Q descriptor and fall back to a simple unlit textured material. + // - Otherwise, treat it as a PS1/Psy-Q descriptor (typed entries with UVs and colours). const QString matPath = resolve(rsd.matPath); QString rsdMaterialFromScript; - QVector rsdFaceColors; + QVector rsdMatEntries; ///< Full Psy-Q MAT entries (UVs + colours + texIdx). + QVector rsdFaceColors; ///< Per-face flat colour fallback (back-compat). if (!matPath.isEmpty() && QFileInfo::exists(matPath)) { QFile matFile(matPath); if (matFile.open(QIODevice::ReadOnly)) { @@ -1475,13 +1649,13 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad } } - // Psy-Q MAT (not an Ogre script): extract a representative color so the mesh isn't blank. + // Psy-Q MAT (not an Ogre script): keep the full per-face descriptor so we can + // route UVs + texture indices into the textured PLY import path. if (rsdMaterialFromScript.isEmpty()) { - QVector mats; QString matErr; - if (PS1MAT::parseMatFile(matPath, mats, &matErr) && !mats.isEmpty()) { - rsdFaceColors.reserve(mats.size()); - for (const auto& me : mats) + if (PS1MAT::parseMatFile(matPath, rsdMatEntries, &matErr) && !rsdMatEntries.isEmpty()) { + rsdFaceColors.reserve(rsdMatEntries.size()); + for (const auto& me : rsdMatEntries) rsdFaceColors.push_back(me.rgb); } } @@ -1498,6 +1672,21 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad continue; } + // Decide whether to use the textured import path. We do so when MAT entries were + // parsed AND at least one of them is textured (T/H/D) — otherwise we fall back to + // the simpler vertex-color path for performance and to keep submesh count low. + bool useTexturedMatPath = false; + if (!rsdMatEntries.isEmpty()) { + for (const auto& me : rsdMatEntries) { + if (me.textured && me.textureIndex >= 0 + && me.textureIndex < static_cast(rsdTexSlots.size()) + && !rsdTexSlots[me.textureIndex].resourceName.isEmpty()) { + useTexturedMatPath = true; + break; + } + } + } + Ogre::MeshPtr mesh; const QFileInfo geomFi(geomPath); if (!geomFi.suffix().compare(QStringLiteral("tmd"), Qt::CaseInsensitive)) { @@ -1506,9 +1695,57 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad } else if (!geomFi.suffix().compare(QStringLiteral("ply"), Qt::CaseInsensitive) && PS1PLY::isPsyqPlyFile(geomPath)) { const std::string meshName = (file.baseName() + QStringLiteral("_rsd_ply")).toStdString(); - mesh = rsdFaceColors.isEmpty() - ? PS1PLY::importPsyqPly(geomPath, meshName) - : PS1PLY::importPsyqPlyWithFaceColors(geomPath, meshName, rsdFaceColors); + if (useTexturedMatPath) { + // Convert the Psy-Q MAT entries into per-face PLY material bindings: each MAT + // entry maps 1:1 to a PLY face (in declaration order). UVs are normalised by + // the bound texture's width/height; colours retain the PS1 corner ordering. + QVector faceMats(rsdMatEntries.size()); + for (int fi = 0; fi < rsdMatEntries.size(); ++fi) { + const PS1MAT::MatEntry& me = rsdMatEntries[fi]; + PS1PLY::FaceMaterial fm; + // Sanitise per-face texture references: a single MAT entry that + // claims to be textured but points at a missing slot would force + // importPsyqPlyWithFaceMaterials() to bin every face from that + // batch into a malformed submesh. Downgrade such faces to solid + // so the rest of the mesh still gets its proper textures. + const bool hasValidTexture = + me.textured + && me.textureIndex >= 0 + && me.textureIndex < static_cast(rsdTexSlots.size()) + && !rsdTexSlots[me.textureIndex].resourceName.isEmpty(); + fm.textured = hasValidTexture; + fm.textureIndex = hasValidTexture ? me.textureIndex : -1; + int texW = 256, texH = 256; + if (hasValidTexture) { + if (rsdTexSlots[me.textureIndex].width > 0) + texW = rsdTexSlots[me.textureIndex].width; + if (rsdTexSlots[me.textureIndex].height > 0) + texH = rsdTexSlots[me.textureIndex].height; + } + for (int k = 0; k < me.uvs.size() && k < 4; ++k) { + fm.u[k] = float(me.uvs[k].u) / float(texW); + fm.v[k] = float(me.uvs[k].v) / float(texH); + } + if (!me.vertColors.isEmpty()) { + fm.vertColors = me.vertColors; + fm.color = me.vertColors.first(); + } else if (me.rgb.isValid()) { + fm.color = me.rgb; + } + faceMats[fi] = fm; + } + mesh = PS1PLY::importPsyqPlyWithFaceMaterials(geomPath, meshName, faceMats); + // Fall back to the simpler vertex-colour path if the textured importer + // rejected the file (e.g. face-count mismatch). + if (!mesh) + mesh = rsdFaceColors.isEmpty() + ? PS1PLY::importPsyqPly(geomPath, meshName) + : PS1PLY::importPsyqPlyWithFaceColors(geomPath, meshName, rsdFaceColors); + } else { + mesh = rsdFaceColors.isEmpty() + ? PS1PLY::importPsyqPly(geomPath, meshName) + : PS1PLY::importPsyqPlyWithFaceColors(geomPath, meshName, rsdFaceColors); + } } else { AssimpToOgreImporter importer; bool convertLH = (geomFi.suffix().compare("x", Qt::CaseInsensitive) != 0); @@ -1539,13 +1776,58 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad if (auto* se = const_cast(en->getSubEntity(si))) se->setMaterialName(rsdMaterialFromScript.toStdString(), rsdDir.toStdString()); } - } else if (!rsdFaceColors.isEmpty()) { - // Colors were baked into vertex colors; keep lighting on and let vertex colors drive diffuse. + } else if (useTexturedMatPath) { + // Textured PLY path emits one submesh per texture group with material names + // shaped `PLY/_texN` or `PLY/_solid`. Bind the matching + // RSD texture slot to each textured submesh's first texture unit; untextured + // submeshes keep their vertex-colour material. + for (unsigned int si = 0; si < en->getNumSubEntities(); ++si) { + Ogre::SubEntity* se = const_cast(en->getSubEntity(si)); + Ogre::MaterialPtr mat = se->getMaterial(); + if (mat.isNull() || mat->getNumTechniques() == 0 + || mat->getTechnique(0)->getNumPasses() == 0) + continue; + Ogre::Pass* pass = mat->getTechnique(0)->getPass(0); + if (!pass) + continue; + + const QString mname = QString::fromStdString(mat->getName()); + static const QRegularExpression kTexSlotRe( + QStringLiteral("_tex(\\d+)$")); + const auto m = kTexSlotRe.match(mname); + if (!m.hasMatch()) + continue; // untextured submesh — leave as is. + bool ok = false; + const int slot = m.captured(1).toInt(&ok); + if (!ok || slot < 0 || slot >= static_cast(rsdTexSlots.size()) + || rsdTexSlots[slot].resourceName.isEmpty()) + continue; + + // Replace any existing texture unit states so re-imports refresh cleanly. + pass->removeAllTextureUnitStates(); + pass->createTextureUnitState(rsdTexSlots[slot].resourceName.toStdString()); + pass->setLightingEnabled(true); + pass->setAmbient(1.0f, 1.0f, 1.0f); + pass->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); + // Preserve per-corner colours that the textured PLY import already baked + // into the submesh (Psy-Q H/D/G shaded materials encode their tint via + // vertex colour). Force TVC_NONE only when the submesh has no VES_DIFFUSE + // stream, otherwise the texture would render as a plain unshaded image. + const Ogre::SubMesh* sm = en->getMesh()->getSubMesh(si); + const Ogre::VertexData* vdSm = + (sm && sm->useSharedVertices) ? en->getMesh()->sharedVertexData + : (sm ? sm->vertexData : nullptr); + const bool hasVC = vdSm && vdSm->vertexDeclaration + && vdSm->vertexDeclaration->findElementBySemantic(Ogre::VES_DIFFUSE); + pass->setVertexColourTracking(hasVC ? Ogre::TVC_DIFFUSE : Ogre::TVC_NONE); + mat->compile(); + } } - // Best-effort: if we loaded a TIM, bind it as the first texture on materials that have UVs - // but no explicit texture yet. - if (!firstTimResource.isEmpty()) { + // Single-texture legacy fallback: if no per-face MAT routing happened but a TIM + // is available, bind it on materials lacking explicit texture units. Skipped when + // the textured MAT path already produced per-submesh materials. + if (!useTexturedMatPath && !firstTimResource.isEmpty()) { for (unsigned int si = 0; si < en->getNumSubEntities(); ++si) { Ogre::SubEntity* se = const_cast(en->getSubEntity(si)); Ogre::MaterialPtr mat = se->getMaterial(); @@ -1565,7 +1847,8 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad // Final fallback: if MAT exists but we couldn't parse it (no script, no per-face colours) and the mesh // is still effectively untextured, force a simple unlit textured material so the asset isn't blank. - if (!firstTimResource.isEmpty() && rsdMaterialFromScript.isEmpty() && rsdFaceColors.isEmpty() + if (!useTexturedMatPath && !firstTimResource.isEmpty() + && rsdMaterialFromScript.isEmpty() && rsdFaceColors.isEmpty() && !matPath.isEmpty() && QFileInfo::exists(matPath)) { applyTextureMaterialToEntity(const_cast(en), QStringLiteral("PS1/RSD/") + file.baseName(), @@ -1898,37 +2181,291 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u const QString matPath = base + QStringLiteral(".mat"); QVector faceColors; + QVector faceTexInfos; QString err; - if (!PS1PLY::exportPsyqPlyFromEntity(e, plyPath, &faceColors, &err)) { + if (!PS1PLY::exportPsyqPlyFromEntity(e, plyPath, &faceColors, &faceTexInfos, &err)) { Ogre::LogManager::getSingleton().logError("Failed to write Psy-Q PLY: " + err.toStdString()); return -1; } - if (!faceColors.isEmpty()) { - QVector entries; - entries.reserve(faceColors.size()); - for (const QColor& c : faceColors) { + // Build the RSD texture table from textured submeshes' first texture unit. Each + // unique texture name gets one RSD slot; we copy the source image next to the + // .rsd output so the descriptor stays self-contained. + struct OutTex { + QString resourceName; ///< Ogre resource name (e.g. "Wood.jpg" or "tex.tim"). + QString outFile; ///< File name written next to the .rsd. + int width = 0; + int height = 0; + }; + std::vector rsdOutTextures; + std::unordered_map submeshToTexSlot; ///< submeshIndex -> rsd slot. + std::unordered_map resourceToSlot; + + for (unsigned int si = 0; si < e->getNumSubEntities(); ++si) { + const Ogre::SubEntity* se = e->getSubEntity(si); + if (!se) + continue; + Ogre::MaterialPtr mat = se->getMaterial(); + if (mat.isNull() || mat->getNumTechniques() == 0 + || mat->getTechnique(0)->getNumPasses() == 0) + continue; + Ogre::Pass* pass = mat->getTechnique(0)->getPass(0); + if (!pass || pass->getNumTextureUnitStates() == 0) + continue; + const Ogre::TextureUnitState* tus = pass->getTextureUnitState(0); + if (!tus || tus->getContentType() != Ogre::TextureUnitState::CONTENT_NAMED) + continue; + const std::string texName = tus->getTextureName(); + if (texName.empty()) + continue; + + auto rit = resourceToSlot.find(texName); + int slot = -1; + if (rit != resourceToSlot.end()) { + slot = rit->second; + } else { + OutTex ot; + ot.resourceName = QString::fromStdString(texName); + // Strip any asset-scoping (`__`) we may have added at import time so + // the sidecar lands next to the .rsd with a clean, human-readable filename. + // For non-RSD textures this is a best-effort basename of whatever the + // texture resource was registered under. + QString candidate = rsdResourceNameToBasename(ot.resourceName); + // Two distinct textures can collapse to the same stripped basename (e.g. two + // different imports both shipping a `Wood.jpg`). Detect collisions against + // previously-claimed outFiles and fall back to the scoped resource name so + // each RSD TEX[] slot writes to its own sidecar instead of clobbering it. + if (std::any_of(rsdOutTextures.begin(), rsdOutTextures.end(), + [&candidate](const OutTex& prev) { + return prev.outFile == candidate; + })) + candidate = QFileInfo(ot.resourceName).fileName(); + ot.outFile = candidate; + auto tex = Ogre::TextureManager::getSingleton().getByName(texName); + if (tex) { + ot.width = static_cast(tex->getWidth()); + ot.height = static_cast(tex->getHeight()); + } + slot = static_cast(rsdOutTextures.size()); + rsdOutTextures.push_back(ot); + resourceToSlot.emplace(texName, slot); + } + submeshToTexSlot[static_cast(si)] = slot; + } + + // Synthesise MAT entries: one per output PLY face. Pick the most specific Psy-Q + // type that preserves the source data: + // * Textured + smooth corner colours -> 'H' (textured smooth, per-corner tint) + // * Textured + uniform corner colour -> 'D' (textured flat colour) + // * Textured + no colour -> 'T' (textured, no colour) + // * Untextured + smooth corner colours -> 'G' (smooth Gouraud) + // * Untextured + uniform colour -> 'C' (flat colour) + // Without this, every face would collapse to 'T' or 'C', losing baked AO / vertex- + // shading gradients (e.g. the wall corners in the Blender RSD example). + const bool haveColors = !faceColors.isEmpty() + && faceColors.size() == static_cast(faceTexInfos.size()); + const bool haveTexInfo = !faceTexInfos.isEmpty(); + + // Two corner colours are considered the same when their 8-bit channels match exactly. + // Tolerance kept tight so PS1's 8-bit-per-channel input doesn't degrade Gouraud info. + auto cornersUniform = [](const PS1PLY::ExportFaceTexture& eft) { + if (!eft.hasCornerColors) + return true; + const int n = std::clamp(eft.cornerCount, 1, 4); + const QColor& c0 = eft.cornerColors[0]; + for (int k = 1; k < n; ++k) { + const QColor& ck = eft.cornerColors[static_cast(k)]; + if (c0.red() != ck.red() || c0.green() != ck.green() || c0.blue() != ck.blue()) + return false; + } + return true; + }; + + QVector entries; + if (haveTexInfo || haveColors) { + const int nFaces = haveTexInfo ? faceTexInfos.size() : faceColors.size(); + entries.reserve(nFaces); + for (int fi = 0; fi < nFaces; ++fi) { PS1MAT::MatEntry me; - me.rgb = c; + + const PS1PLY::ExportFaceTexture& eft = haveTexInfo + ? faceTexInfos[fi] + : PS1PLY::ExportFaceTexture{}; + const QColor faceColor = haveColors ? faceColors[fi] : QColor(255, 255, 255); + const int corners = (eft.cornerCount == 4) ? 4 : 3; + const bool gotCornerColors = eft.hasCornerColors; + const bool smoothShade = gotCornerColors && !cornersUniform(eft); + + const int slotIt = (eft.textured && submeshToTexSlot.count(eft.submeshIndex)) + ? submeshToTexSlot[eft.submeshIndex] + : -1; + // shadingChar stays 'F' (flat normals) to match the Blender RSD exporter + // convention: Psy-Q PLY stores per-face normals, so Gouraud-style smoothness + // is encoded purely via the typeChar (G/H), not via normal interpolation. + me.shadingChar = 'F'; + if (eft.textured && slotIt >= 0) { + me.textured = true; + me.textureIndex = slotIt; + const OutTex& ot = rsdOutTextures[slotIt]; + const int texW = ot.width > 0 ? ot.width : 256; + const int texH = ot.height > 0 ? ot.height : 256; + me.uvs.resize(corners); + for (int k = 0; k < corners; ++k) { + me.uvs[k].u = static_cast(std::lround(double(eft.u[k]) * double(texW))); + me.uvs[k].v = static_cast(std::lround(double(eft.v[k]) * double(texH))); + } + if (smoothShade) { + me.typeChar = 'H'; // textured smooth (per-corner tint) + me.vertColors.reserve(corners); + for (int k = 0; k < corners; ++k) + me.vertColors.push_back(eft.cornerColors[static_cast(k)]); + me.rgb = me.vertColors.first(); + } else if (gotCornerColors) { + me.typeChar = 'D'; // textured flat colour + const QColor c = eft.cornerColors[0].isValid() ? eft.cornerColors[0] + : QColor(255, 255, 255); + me.vertColors.push_back(c); + me.rgb = c; + } else { + me.typeChar = 'T'; // textured, no colour + me.rgb = QColor(255, 255, 255); + } + } else if (smoothShade) { + me.typeChar = 'G'; // smooth Gouraud + me.vertColors.reserve(corners); + for (int k = 0; k < corners; ++k) + me.vertColors.push_back(eft.cornerColors[static_cast(k)]); + me.rgb = me.vertColors.first(); + } else { + me.typeChar = 'C'; // flat colour + const QColor c = gotCornerColors ? eft.cornerColors[0] : faceColor; + me.vertColors.push_back(c.isValid() ? c : QColor(255, 255, 255)); + me.rgb = me.vertColors.first(); + } entries.push_back(me); } + } + + if (!entries.isEmpty()) { if (!PS1MAT::writeMatFile(matPath, entries, &err)) { Ogre::LogManager::getSingleton().logError("Failed to write MAT: " + err.toStdString()); return -1; } } - // Optional TIM sibling (best-effort, may not exist). - const QString timPath = base + QStringLiteral(".tim"); + // Copy referenced textures next to the .rsd so the descriptor stays self-contained. + // PS1 hardware/emulators only consume TIM, so always emit a 16bpp TIM regardless of + // the source image format (JPG/PNG/BMP/TGA/etc.) — the legacy PNG sidecar code is + // kept only as a defensive fallback when the TIM writer fails (e.g. zero-byte + // image, exception in pixel conversion). + // + // A sidecar write failure must abort the whole RSD export: writing the descriptor + // anyway would leave TEX[] entries pointing to nonexistent / stale files, breaking + // any later round-trip through the RSD importer or third-party tools. + const auto writePngFallback = [](const Ogre::Image& img, const QString& pngPath) { + std::vector rgba; + if (img.getFormat() != Ogre::PF_BYTE_RGBA) { + const size_t pixels = static_cast(img.getWidth()) * img.getHeight(); + rgba.resize(pixels * 4); + Ogre::PixelBox src(img.getWidth(), img.getHeight(), 1, + img.getFormat(), + const_cast(img.getData())); + Ogre::PixelBox dst(img.getWidth(), img.getHeight(), 1, + Ogre::PF_BYTE_RGBA, rgba.data()); + Ogre::PixelUtil::bulkPixelConversion(src, dst); + } + const uint8_t* rgbaData = rgba.empty() ? img.getData() : rgba.data(); + const QImage qi(rgbaData, + static_cast(img.getWidth()), + static_cast(img.getHeight()), + static_cast(img.getWidth()) * 4, + QImage::Format_RGBA8888); + return qi.copy().save(pngPath, "PNG"); + }; + + // Returns true on success and updates ot.outFile to the actual on-disk filename. + // Returns false when both the TIM and PNG fallback writes fail — caller must abort. + const auto writeSidecar = [&](OutTex& ot) -> bool { + auto tex = Ogre::TextureManager::getSingleton().getByName(ot.resourceName.toStdString()); + if (!tex) + return true; // No bound texture — leave ot.outFile alone, descriptor still references the previous file. + Ogre::Image img; + tex->convertToImage(img, true); + if (img.getWidth() == 0 || img.getHeight() == 0) + return true; + + const QString basename = QFileInfo(ot.outFile).completeBaseName(); + const QString dirSep = QDir::separator(); + + // Primary: 16bpp PS1 TIM. PS1 emulators / hardware can't decode JPG/PNG, so + // this is what every well-formed RSD ships next to the descriptor. + const QString tim = outFi.absolutePath() + dirSep + basename + QStringLiteral(".tim"); + QString timErr; + if (PS1TIM::saveOgreImageToTim16(img, tim, &timErr)) { + ot.outFile = QFileInfo(tim).fileName(); + return true; + } + Ogre::LogManager::getSingleton().logError( + "RSD TIM sidecar write failed: " + timErr.toStdString() + + " — falling back to PNG so the .rsd still references a real file."); + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("RSD TIM sidecar write failed, falling back to PNG: %1") + .arg(timErr)); + + const QString png = outFi.absolutePath() + dirSep + basename + QStringLiteral(".png"); + if (writePngFallback(img, png)) { + ot.outFile = QFileInfo(png).fileName(); + return true; + } + Ogre::LogManager::getSingleton().logError( + "Failed to write RSD texture sidecar (TIM and PNG both failed): " + + png.toStdString()); + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("RSD texture sidecar write failed: %1") + .arg(QFileInfo(png).fileName())); + return false; + }; + + bool sidecarWriteFailed = false; + for (auto& ot : rsdOutTextures) { + bool ok = false; + try { + ok = writeSidecar(ot); + } catch (const Ogre::Exception& ex) { + Ogre::LogManager::getSingleton().logError( + std::string("RSD texture sidecar Ogre exception: ") + ex.what()); + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("RSD texture sidecar Ogre exception: %1") + .arg(QString::fromUtf8(ex.what()))); + } + if (!ok) { + sidecarWriteFailed = true; + break; + } + } + if (sidecarWriteFailed) + return -1; PS1RSD::RsdDescriptor rsd; rsd.headerId = QStringLiteral("@RSD940102"); rsd.plyPath = QFileInfo(plyPath).fileName(); - if (!faceColors.isEmpty()) + if (!entries.isEmpty()) rsd.matPath = QFileInfo(matPath).fileName(); - if (QFileInfo::exists(timPath)) { - rsd.ntex = 1; - rsd.textures = { QFileInfo(timPath).fileName() }; + if (!rsdOutTextures.empty()) { + rsd.ntex = static_cast(rsdOutTextures.size()); + rsd.textures.reserve(static_cast(rsdOutTextures.size())); + for (const auto& ot : rsdOutTextures) + rsd.textures.push_back(ot.outFile); + } else { + // Legacy fallback: if a same-basename .tim already sits next to the output, reference it. + const QString timPath = base + QStringLiteral(".tim"); + if (QFileInfo::exists(timPath)) { + rsd.ntex = 1; + rsd.textures = { QFileInfo(timPath).fileName() }; + } } if (!PS1RSD::writeRsdFile(_uri, rsd, &err)) { diff --git a/src/PS1/PS1MAT.cpp b/src/PS1/PS1MAT.cpp index 369be1eed..f399d1146 100644 --- a/src/PS1/PS1MAT.cpp +++ b/src/PS1/PS1MAT.cpp @@ -10,76 +10,264 @@ The MIT License #include "PS1/PS1MAT.h" #include +#include #include #include #include +#include + +#include "SentryReporter.h" + namespace PS1MAT { -static bool isSkippable(const QString& line) +namespace { + +constexpr int kMaxPolyEntries = 1 << 20; // sanity guard against hostile counts. + +bool isSkippable(const QString& line) { const QString t = line.trimmed(); - return t.isEmpty() || t.startsWith('#') || t.startsWith("//") || t.startsWith(';'); + return t.isEmpty() || t.startsWith('#') || t.startsWith(QStringLiteral("//")) || t.startsWith(';'); } -bool parseMatFile(const QString& matPath, QVector& outEntries, QString* outError) +/** Heuristic: a single token of length 1 made up of A-Z is a shading or type char. */ +bool isSingleLetter(const QString& s) { - outEntries.clear(); + return s.size() == 1 && s[0].isLetter(); +} - QFile f(matPath); - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { - if (outError) *outError = QStringLiteral("Could not open MAT file."); +QColor clampedRgb(int r, int g, int b) +{ + return QColor(qBound(0, r, 255), qBound(0, g, 255), qBound(0, b, 255)); +} + +/** Read a single trailing color triple from `ints` starting at offset `off`. */ +bool readRgb(const QVector& ints, int off, QColor& outColor) +{ + if (off < 0 || off + 2 >= ints.size()) return false; + outColor = clampedRgb(ints[off], ints[off + 1], ints[off + 2]); + return true; +} + +/** Decode the rest-of-line payload for a parsed MAT entry. Returns false on malformed input. */ +bool decodePayload(MatEntry& e, const QVector& ints) +{ + // Layout summary (number of trailing ints after the type char): + // C : 3 -> 1 colour + // G : 9 or 12 -> 3 or 4 colours + // T : 9 -> texIdx + 8 UVs + // D : 12 -> texIdx + 8 UVs + 1 colour + // H : 21 -> texIdx + 8 UVs + 12 colours (4 corners; tris pad with the last colour) + // + // We accept slightly degenerate counts (e.g. 6-int H tris with no padding) by + // greedily reading what's present. + e.uvs.clear(); + e.vertColors.clear(); + e.textured = false; + e.textureIndex = -1; + + const int n = ints.size(); + + auto readUvBlock = [&](int off, int uvCount) { + for (int i = 0; i < uvCount && off + 1 < n; ++i, off += 2) + e.uvs.push_back({ints[off], ints[off + 1]}); + }; + + auto readColorBlock = [&](int off, int colCount) { + for (int i = 0; i < colCount && off + 2 < n; ++i, off += 3) + e.vertColors.push_back(clampedRgb(ints[off], ints[off + 1], ints[off + 2])); + }; + + switch (e.typeChar) { + case 'C': { + QColor c; + if (!readRgb(ints, n - 3, c)) + return false; + e.vertColors.push_back(c); + e.rgb = c; + return true; + } + case 'G': { + // 9 ints = tri (3 RGB), 12 ints = quad (4 RGB). + const int rgbCount = (n >= 12) ? 4 : (n >= 9 ? 3 : 0); + if (rgbCount == 0) + return false; + readColorBlock(0, rgbCount); + if (e.vertColors.isEmpty()) + return false; + e.rgb = e.vertColors.first(); + return true; + } + case 'T': { + // texIdx + 8 UVs = 9 ints. + if (n < 9) + return false; + e.textured = true; + e.textureIndex = ints[0]; + readUvBlock(1, 4); + // T entries have no colour data; pick white so back-compat callers don't render black. + e.rgb = QColor(255, 255, 255); + return true; + } + case 'D': { + // texIdx + 8 UVs + 3 RGB = 12 ints. + if (n < 12) + return false; + e.textured = true; + e.textureIndex = ints[0]; + readUvBlock(1, 4); + QColor c; + if (!readRgb(ints, 9, c)) + return false; + e.vertColors.push_back(c); + e.rgb = c; + return true; + } + case 'H': { + // texIdx + 8 UVs + 12 RGB = 21 ints (tris pad with last RGB + dummy zeroes). + if (n < 9) + return false; + e.textured = true; + e.textureIndex = ints[0]; + readUvBlock(1, 4); + // Remaining ints are colours. Floor-divide by 3 to get colour count. + const int colsStart = 9; + const int colorInts = std::max(0, n - colsStart); + const int colCount = std::min(4, colorInts / 3); + readColorBlock(colsStart, colCount); + if (e.vertColors.isEmpty()) + e.rgb = QColor(255, 255, 255); + else + e.rgb = e.vertColors.first(); + return true; + } + default: + return false; } +} - const QString text = QString::fromLatin1(f.readAll()); - const QStringList lines = text.split(QRegularExpression(QStringLiteral(R"(\r\n|\n|\r)"))); +bool tryParseEntryLine(const QString& line, MatEntry& outEntry) +{ + const QStringList parts = line.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + if (parts.size() < 5) // at least: idx flag shading type one-color-component + return false; - int expected = -1; - bool sawHeader = false; - for (int i = 0; i < lines.size(); ++i) { - const QString t = lines[i].trimmed(); + bool okIdx = false; + parts[0].toInt(&okIdx, 10); + if (!okIdx) + return false; + + // Find the type char: scan from the start past the leading numerics and pick the + // SECOND single-letter token (first is shadingChar, second is typeChar). + int letterCount = 0; + int typeCharIdx = -1; + char shadingChar = 'F'; + char typeChar = 'C'; + for (int i = 1; i < parts.size(); ++i) { + const QString& p = parts[i]; + if (isSingleLetter(p)) { + const char c = p.at(0).toUpper().toLatin1(); + if (letterCount == 0) + shadingChar = c; + else if (letterCount == 1) { + typeChar = c; + typeCharIdx = i; + break; + } + ++letterCount; + } + } + if (typeCharIdx < 0) + return false; + + QVector trailing; + trailing.reserve(parts.size() - (typeCharIdx + 1)); + for (int i = typeCharIdx + 1; i < parts.size(); ++i) { + bool ok = false; + const int v = parts[i].toInt(&ok, 10); + if (!ok) + return false; + trailing.push_back(v); + } + + outEntry = MatEntry{}; + outEntry.shadingChar = shadingChar; + outEntry.typeChar = typeChar; + return decodePayload(outEntry, trailing); +} + +bool readExpectedCount(const QStringList& lines, int& outCount, bool& outSawHeader) +{ + outSawHeader = false; + outCount = -1; + for (const QString& line : lines) { + const QString t = line.trimmed(); if (t.startsWith('@')) { if (t.startsWith(QStringLiteral("@MAT"), Qt::CaseInsensitive)) - sawHeader = true; + outSawHeader = true; continue; } if (isSkippable(t)) continue; - bool ok = false; - const int n = t.toInt(&ok, 10); - if (ok) { - expected = n; - break; + // Lone integer => entry count. + const QStringList parts = t.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + if (parts.size() == 1) { + bool ok = false; + const int n = parts[0].toInt(&ok, 10); + if (ok && n > 0 && n <= kMaxPolyEntries) { + outCount = n; + return true; + } } + // First non-count, non-comment, non-header line — we've gone too far. + if (outCount < 0) + return false; } + return outCount > 0; +} - if (!sawHeader) { - if (outError) *outError = QStringLiteral("Missing @MAT header."); +} // namespace + +bool parseMatFile(const QString& matPath, QVector& outEntries, QString* outError) +{ + outEntries.clear(); + + const QString matName = QFileInfo(matPath).fileName(); + QFile f(matPath); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + if (outError) *outError = QStringLiteral("Could not open MAT file."); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("PS1 MAT open failed: %1").arg(matName)); return false; } - if (expected <= 0) { + const QString text = QString::fromLatin1(f.readAll()); + const QStringList lines = text.split(QRegularExpression(QStringLiteral(R"(\r\n|\n|\r)"))); + + int expected = -1; + bool sawHeader = false; + if (!readExpectedCount(lines, expected, sawHeader)) { if (outError) *outError = QStringLiteral("Missing item count."); return false; } - // Parse material lines: we only require at least 3 trailing ints as RGB. + if (!sawHeader) { + if (outError) *outError = QStringLiteral("Missing @MAT header."); + return false; + } + for (const QString& line : lines) { const QString t = line.trimmed(); if (isSkippable(t) || t.startsWith('@')) continue; const QStringList parts = t.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); - if (parts.size() < 3) - continue; - bool okR=false, okG=false, okB=false; - const int r = parts[parts.size()-3].toInt(&okR, 10); - const int g = parts[parts.size()-2].toInt(&okG, 10); - const int b = parts[parts.size()-1].toInt(&okB, 10); - if (!okR || !okG || !okB) - continue; + if (parts.size() == 1) + continue; // lone count MatEntry e; - e.rgb = QColor(qBound(0, r, 255), qBound(0, g, 255), qBound(0, b, 255)); + if (!tryParseEntryLine(t, e)) + continue; outEntries.push_back(e); if (outEntries.size() >= expected) break; @@ -98,14 +286,19 @@ bool parseMatFile(const QString& matPath, QVector& outEntries, QString return false; } + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("PS1 MAT parsed: %1 (%2 entries)").arg(matName).arg(outEntries.size())); return true; } bool writeMatFile(const QString& matPath, const QVector& entries, QString* outError) { + const QString matName = QFileInfo(matPath).fileName(); QFile f(matPath); if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { if (outError) *outError = QStringLiteral("Could not open MAT file for writing."); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("PS1 MAT write open failed: %1").arg(matName)); return false; } @@ -114,18 +307,108 @@ bool writeMatFile(const QString& matPath, const QVector& entries, QStr ts << "@MAT940801\n"; ts << entries.size() << "\n"; + for (int i = 0; i < entries.size(); ++i) { - const QColor c = entries[i].rgb; - ts << i << " 0 F C " - << qBound(0, c.red(), 255) << " " - << qBound(0, c.green(), 255) << " " - << qBound(0, c.blue(), 255) << "\n"; + const MatEntry& e = entries[i]; + const char shading = (e.shadingChar == 'S' || e.shadingChar == 'G') ? 'S' : 'F'; + ts << i << " 1 " << shading << " "; + + // Effective type char: keep what the caller provided unless it's nonsensical. + char typeChar = e.typeChar; + if (typeChar != 'C' && typeChar != 'G' && typeChar != 'T' + && typeChar != 'D' && typeChar != 'H') + typeChar = 'C'; + ts << typeChar; + + auto writeUvs = [&]() { + if (e.uvs.size() >= 4) { + for (int k = 0; k < 4; ++k) + ts << " " << e.uvs[k].u << " " << e.uvs[k].v; + } else if (e.uvs.size() == 3) { + for (int k = 0; k < 3; ++k) + ts << " " << e.uvs[k].u << " " << e.uvs[k].v; + // Pad the 4th UV by repeating the last (PS1 quad-shaped slot). + ts << " " << e.uvs[2].u << " " << e.uvs[2].v; + } else { + ts << " 0 0 0 0 0 0 0 0"; + } + }; + + auto writeColor = [&](const QColor& c) { + ts << " " << qBound(0, c.red(), 255) + << " " << qBound(0, c.green(), 255) + << " " << qBound(0, c.blue(), 255); + }; + + switch (typeChar) { + case 'C': { + const QColor c = !e.vertColors.isEmpty() ? e.vertColors.first() : e.rgb; + writeColor(c.isValid() ? c : QColor(255, 255, 255)); + break; + } + case 'G': { + const int nC = (e.vertColors.size() >= 4) ? 4 + : (e.vertColors.size() >= 3 ? 3 : 0); + if (nC == 0) { + // Fall back to repeating the back-compat colour 4 times so we still produce a valid quad row. + const QColor c = e.rgb.isValid() ? e.rgb : QColor(255, 255, 255); + for (int k = 0; k < 4; ++k) + writeColor(c); + } else { + for (int k = 0; k < nC; ++k) + writeColor(e.vertColors[k]); + // Match the Blender RSD exporter convention: pad tri G entries to a + // 4-corner layout with a trailing `0 0 0` so downstream tooling that + // assumes a fixed 4-colour stride (PS1 emulators, third-party loaders) + // can read every G line with the same parsing path. + if (nC == 3) + ts << " 0 0 0"; + } + break; + } + case 'T': { + ts << " " << std::max(0, e.textureIndex); + writeUvs(); + break; + } + case 'D': { + ts << " " << std::max(0, e.textureIndex); + writeUvs(); + const QColor c = !e.vertColors.isEmpty() ? e.vertColors.first() : e.rgb; + writeColor(c.isValid() ? c : QColor(255, 255, 255)); + break; + } + case 'H': { + ts << " " << std::max(0, e.textureIndex); + writeUvs(); + const int nC = (e.vertColors.size() >= 4) ? 4 + : (e.vertColors.size() >= 3 ? 3 : 0); + if (nC == 0) { + const QColor c = e.rgb.isValid() ? e.rgb : QColor(255, 255, 255); + for (int k = 0; k < 4; ++k) + writeColor(c); + } else { + for (int k = 0; k < nC; ++k) + writeColor(e.vertColors[k]); + // Pad tris (3 colours) to the 4-corner shape for layout stability. + if (nC == 3) + ts << " 0 0 0"; + } + break; + } + } + + ts << "\n"; } if (ts.status() != QTextStream::Ok) { if (outError) *outError = QStringLiteral("Write failed."); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("PS1 MAT write failed: %1").arg(matName)); return false; } + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("PS1 MAT written: %1 (%2 entries)").arg(matName).arg(entries.size())); return true; } diff --git a/src/PS1/PS1MAT.h b/src/PS1/PS1MAT.h index 8ea01d636..aa882724a 100644 --- a/src/PS1/PS1MAT.h +++ b/src/PS1/PS1MAT.h @@ -14,28 +14,54 @@ The MIT License #include #include +#include + /** * Sony PlayStation / Psy-Q MAT (material list) support (ASCII). * - * Common format: - * @MAT940801 - * # Number of Items - * 425 - * # Materials - * 0 0 F C 255 255 0 - * 1 0 F C 255 255 0 + * Common format (per polygon, one entry per PLY face, in order): + * + * + * Where: + * - typeChar 'C' = flat solid color (payload: R G B) + * - typeChar 'G' = smooth (Gouraud) color (payload: 3*RGB for tri or 4*RGB for quad) + * - typeChar 'T' = textured, no color (payload: texIndex u0 v0 u1 v1 u2 v2 u3 v3) + * - typeChar 'D' = textured + flat color (payload: texIndex 8*UV R G B) + * - typeChar 'H' = textured + smooth color (payload: texIndex 8*UV 12*RGB for quad / 9*RGB padded for tri) + * + * UV coordinates are PS1 raw pixel offsets (top-origin); the importer divides by + * texture width/height to get normalized 0..1 coords (no V flip needed - the + * Blender exporter already flipped V into PS1 conventions on the way out). * - * We currently extract only RGB (last 3 ints) per entry. + * The legacy `rgb` field on MatEntry is filled with a representative colour + * (single colour for C; first vertex colour for G/D/H) so callers that only + * care about a per-face flat colour keep working. */ namespace PS1MAT { +struct UV { + int u = 0; + int v = 0; +}; + struct MatEntry { - QColor rgb; // 0..255 + QColor rgb; ///< representative RGB (back-compat); first vert colour for smooth shaded entries. + char shadingChar = 'F'; ///< 'F' (flat) or 'S' / 'G' (smooth) - Psy-Q ASCII field, kept verbatim. + char typeChar = 'C'; ///< 'C', 'G', 'T', 'H', or 'D' - see header doc. + bool textured = false; ///< true for T/H/D. + int textureIndex = -1; ///< RSD TEX[i] index for textured polygons; -1 otherwise. + QVector uvs; ///< 0, 3 or 4 entries (textured polygons only). + QVector vertColors;///< 1 (C/D), 3 (G/H tri), or 4 (G/H quad). Always populated when the entry has colour data. }; bool parseMatFile(const QString& matPath, QVector& outEntries, QString* outError = nullptr); /// Write a minimal Psy-Q MAT file with one RGB entry per face. +/// +/// Entries are serialised as ` 1 ` matching the +/// canonical Psy-Q / Blender-RSD ASCII layout. Textured entries (`textureIndex >= 0` +/// and `uvs.size() >= 3`) write the texture index + 8 UV ints, padding with the +/// final UV (or `0 0`) for triangles to keep the on-disk shape stable. bool writeMatFile(const QString& matPath, const QVector& entries, QString* outError = nullptr); } // namespace PS1MAT diff --git a/src/PS1/PS1MAT_test.cpp b/src/PS1/PS1MAT_test.cpp new file mode 100644 index 000000000..eee418047 --- /dev/null +++ b/src/PS1/PS1MAT_test.cpp @@ -0,0 +1,253 @@ +#include + +#include +#include +#include + +#include "PS1/PS1MAT.h" + +namespace { + +QString writeMatFile(const QString& dir, const QByteArray& body) +{ + const QString path = QDir(dir).filePath(QStringLiteral("test.mat")); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) + return {}; + f.write(body); + return path; +} + +} // namespace + +TEST(PS1MAT, ParseFlatColorEntries) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QByteArray body = + "@MAT940801\n" + "3\n" + "0 1 F C 207 207 207\n" + "1 1 F C 58 58 58\n" + "2 1 F C 152 152 152\n"; + + const QString path = writeMatFile(dir.path(), body); + QVector entries; + QString err; + ASSERT_TRUE(PS1MAT::parseMatFile(path, entries, &err)) << err.toStdString(); + ASSERT_EQ(entries.size(), 3); + + EXPECT_EQ(entries[0].typeChar, 'C'); + EXPECT_FALSE(entries[0].textured); + EXPECT_EQ(entries[0].textureIndex, -1); + EXPECT_TRUE(entries[0].uvs.isEmpty()); + ASSERT_EQ(entries[0].vertColors.size(), 1); + EXPECT_EQ(entries[0].vertColors[0], QColor(207, 207, 207)); + EXPECT_EQ(entries[0].rgb, QColor(207, 207, 207)); +} + +TEST(PS1MAT, ParseSmoothColorQuadEntries) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // G-type quad: 4 RGB triples after the type char. + const QByteArray body = + "@MAT940801\n" + "1\n" + "0 1 F G 152 152 152 197 197 197 84 84 84 120 120 120\n"; + + const QString path = writeMatFile(dir.path(), body); + QVector entries; + QString err; + ASSERT_TRUE(PS1MAT::parseMatFile(path, entries, &err)) << err.toStdString(); + ASSERT_EQ(entries.size(), 1); + + const auto& e = entries[0]; + EXPECT_EQ(e.typeChar, 'G'); + EXPECT_FALSE(e.textured); + ASSERT_EQ(e.vertColors.size(), 4); + EXPECT_EQ(e.vertColors[0], QColor(152, 152, 152)); + EXPECT_EQ(e.vertColors[1], QColor(197, 197, 197)); + EXPECT_EQ(e.vertColors[2], QColor(84, 84, 84)); + EXPECT_EQ(e.vertColors[3], QColor(120, 120, 120)); + // Back-compat rgb is the first vert colour. + EXPECT_EQ(e.rgb, QColor(152, 152, 152)); +} + +TEST(PS1MAT, ParseTexturedQuadEntry_HType) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // H-type quad: texIdx=0, 8 UVs, 4 RGB triples = 21 trailing ints. + const QByteArray body = + "@MAT940801\n" + "1\n" + "0 1 F H 0 0 127 0 0 127 127 127 0 72 72 72 72 72 72 79 79 79 80 80 80\n"; + + const QString path = writeMatFile(dir.path(), body); + QVector entries; + QString err; + ASSERT_TRUE(PS1MAT::parseMatFile(path, entries, &err)) << err.toStdString(); + ASSERT_EQ(entries.size(), 1); + + const auto& e = entries[0]; + EXPECT_EQ(e.typeChar, 'H'); + EXPECT_TRUE(e.textured); + EXPECT_EQ(e.textureIndex, 0); + ASSERT_EQ(e.uvs.size(), 4); + EXPECT_EQ(e.uvs[0].u, 0); EXPECT_EQ(e.uvs[0].v, 127); + EXPECT_EQ(e.uvs[1].u, 0); EXPECT_EQ(e.uvs[1].v, 0); + EXPECT_EQ(e.uvs[2].u, 127); EXPECT_EQ(e.uvs[2].v, 127); + EXPECT_EQ(e.uvs[3].u, 127); EXPECT_EQ(e.uvs[3].v, 0); + ASSERT_EQ(e.vertColors.size(), 4); + EXPECT_EQ(e.vertColors[0], QColor(72, 72, 72)); + EXPECT_EQ(e.vertColors[3], QColor(80, 80, 80)); +} + +TEST(PS1MAT, ParseTexturedTriEntry_TType) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // T-type tri: texIdx=2 + 8 UVs (last is padding for tri). + const QByteArray body = + "@MAT940801\n" + "1\n" + "0 1 F T 2 10 20 30 40 50 60 0 0\n"; + + const QString path = writeMatFile(dir.path(), body); + QVector entries; + QString err; + ASSERT_TRUE(PS1MAT::parseMatFile(path, entries, &err)) << err.toStdString(); + ASSERT_EQ(entries.size(), 1); + + const auto& e = entries[0]; + EXPECT_EQ(e.typeChar, 'T'); + EXPECT_TRUE(e.textured); + EXPECT_EQ(e.textureIndex, 2); + ASSERT_EQ(e.uvs.size(), 4); + EXPECT_EQ(e.uvs[0].u, 10); EXPECT_EQ(e.uvs[0].v, 20); + EXPECT_EQ(e.uvs[1].u, 30); EXPECT_EQ(e.uvs[1].v, 40); + EXPECT_EQ(e.uvs[2].u, 50); EXPECT_EQ(e.uvs[2].v, 60); + EXPECT_TRUE(e.vertColors.isEmpty()); +} + +TEST(PS1MAT, WriteAndRoundTrip_MixedEntries) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("rt.mat")); + + QVector in; + { + PS1MAT::MatEntry c; + c.typeChar = 'C'; + c.rgb = QColor(100, 110, 120); + c.vertColors.push_back(c.rgb); + in.push_back(c); + } + { + PS1MAT::MatEntry t; + t.typeChar = 'T'; + t.textured = true; + t.textureIndex = 1; + t.uvs = { {0, 0}, {127, 0}, {127, 127}, {0, 127} }; + in.push_back(t); + } + { + PS1MAT::MatEntry h; + h.typeChar = 'H'; + h.textured = true; + h.textureIndex = 0; + h.uvs = { {10, 20}, {30, 40}, {50, 60}, {70, 80} }; + h.vertColors = { + QColor(255, 0, 0), QColor(0, 255, 0), QColor(0, 0, 255), QColor(128, 128, 128) + }; + h.rgb = h.vertColors.first(); + in.push_back(h); + } + + QString err; + ASSERT_TRUE(PS1MAT::writeMatFile(path, in, &err)) << err.toStdString(); + + QVector out; + ASSERT_TRUE(PS1MAT::parseMatFile(path, out, &err)) << err.toStdString(); + ASSERT_EQ(out.size(), in.size()); + + EXPECT_EQ(out[0].typeChar, 'C'); + EXPECT_EQ(out[0].rgb, QColor(100, 110, 120)); + + EXPECT_EQ(out[1].typeChar, 'T'); + EXPECT_TRUE(out[1].textured); + EXPECT_EQ(out[1].textureIndex, 1); + ASSERT_EQ(out[1].uvs.size(), 4); + EXPECT_EQ(out[1].uvs[2].u, 127); + + EXPECT_EQ(out[2].typeChar, 'H'); + EXPECT_TRUE(out[2].textured); + EXPECT_EQ(out[2].textureIndex, 0); + ASSERT_EQ(out[2].uvs.size(), 4); + ASSERT_EQ(out[2].vertColors.size(), 4); + EXPECT_EQ(out[2].vertColors[0], QColor(255, 0, 0)); + EXPECT_EQ(out[2].vertColors[2], QColor(0, 0, 255)); +} + +TEST(PS1MAT, WritesGouraudTriWithFourCornerPadding) +{ + // Repro for Example Project.rsd round-trip: the Blender RSD exporter pads tri G + // entries with a trailing `0 0 0` so every G row has 12 colour ints. Match that + // convention so PS1 emulators and third-party tools see a stable layout. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + QVector in; + { + PS1MAT::MatEntry g; + g.typeChar = 'G'; + g.shadingChar = 'F'; + g.vertColors = { + QColor(245, 245, 245), QColor(107, 107, 107), QColor(84, 84, 84) + }; + g.rgb = g.vertColors.first(); + in.push_back(g); + } + + const QString path = QDir(dir.path()).filePath(QStringLiteral("triG.mat")); + QString err; + ASSERT_TRUE(PS1MAT::writeMatFile(path, in, &err)) << err.toStdString(); + + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString text = QString::fromLatin1(f.readAll()); + EXPECT_TRUE(text.contains(QStringLiteral("0 1 F G 245 245 245 107 107 107 84 84 84 0 0 0"))) + << "tri G entry must be padded to 4 corners with a trailing 0 0 0 -- got:\n" + << text.toStdString(); + + // Round-trip should still parse the padding back into a 4-colour vector; the + // importer is responsible for truncating to the PLY face shape. + QVector out; + ASSERT_TRUE(PS1MAT::parseMatFile(path, out, &err)) << err.toStdString(); + ASSERT_EQ(out.size(), 1); + EXPECT_EQ(out[0].typeChar, 'G'); + ASSERT_EQ(out[0].vertColors.size(), 4); + EXPECT_EQ(out[0].vertColors[3], QColor(0, 0, 0)); +} + +TEST(PS1MAT, RejectsMissingHeader) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QByteArray body = + "3\n" + "0 1 F C 207 207 207\n"; + + const QString path = writeMatFile(dir.path(), body); + QVector entries; + QString err; + EXPECT_FALSE(PS1MAT::parseMatFile(path, entries, &err)); + EXPECT_FALSE(err.isEmpty()); +} diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index d5e5be65c..caf041bc4 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -11,6 +11,7 @@ The MIT License #include "PS1/PS1PLY.h" #include "EditableMesh.h" +#include "SentryReporter.h" #include #include @@ -23,6 +24,7 @@ The MIT License #include #include +#include #include #include #include @@ -880,6 +882,15 @@ struct PsyqExportFace { uint32_t n[4] = {}; QColor color; bool hasColor = false; + bool hasUv = false; ///< true when the source submesh provided UVs. + int submeshIndex = -1; ///< Source submesh index (for RSD texture-slot lookup). + std::array u{}; ///< Per-corner UV (PLY corner order); zero-padded for tris. + std::array v_uv{}; + /// Per-corner vertex colours (matches v[] order). Populated when the source submesh + /// exposes a VES_DIFFUSE stream — drives smooth-shaded MAT entries on the RSD export + /// path. Slots beyond the active corner count are default-constructed. + bool hasCornerColors = false; + std::array cornerColors{}; }; struct PsyqWeldedTri { @@ -911,6 +922,26 @@ uint32_t normalIndexForWeldedPos(uint32_t posIdx, const PsyqWeldedTri& A, const return nb != std::numeric_limits::max() ? nb : 0u; } +/// Resolve the per-corner colour for a merged quad vertex by looking up which of the +/// two source triangles touched the welded position index. Falls back to triangle B's +/// match, then to a default QColor when neither tri references the index. +static QColor cornerColorForWeldedPos(uint32_t posIdx, + const std::array& triAPos, + const std::array& triBPos, + const std::array& triAColors, + const std::array& triBColors) +{ + for (int c = 0; c < 3; ++c) { + if (triAPos[static_cast(c)] == posIdx) + return triAColors[static_cast(c)]; + } + for (int c = 0; c < 3; ++c) { + if (triBPos[static_cast(c)] == posIdx) + return triBColors[static_cast(c)]; + } + return QColor(); +} + static void mergeSubmeshTrisToQuads(const std::vector& I0, const std::vector& I1, const std::vector& I2, @@ -919,6 +950,7 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, const std::vector& N2, const std::vector& weldPos, const QVector* triFaceColors, + const std::vector>* triCornerColors, std::vector& outFaces) { const size_t n = I0.size(); @@ -928,6 +960,7 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, std::vector used(n, 0); const float minDot = 0.94f; const bool haveTriColors = (triFaceColors && triFaceColors->size() == static_cast(n)); + const bool haveCornerColors = (triCornerColors && triCornerColors->size() == n); auto triRgb = [&](size_t i) -> QColor { return haveTriColors ? (*triFaceColors)[static_cast(i)] : QColor(); @@ -969,6 +1002,15 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, f.color = QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2); f.hasColor = true; } + if (haveCornerColors) { + f.hasCornerColors = true; + for (int k = 0; k < 4; ++k) { + f.cornerColors[static_cast(k)] = cornerColorForWeldedPos( + q[static_cast(k)], + triA.pw, triB.pw, + (*triCornerColors)[i], (*triCornerColors)[j]); + } + } outFaces.push_back(f); used[i] = used[j] = 1; merged = true; @@ -987,6 +1029,12 @@ static void mergeSubmeshTrisToQuads(const std::vector& I0, f.color = triRgb(i); f.hasColor = true; } + if (haveCornerColors) { + f.hasCornerColors = true; + f.cornerColors[0] = (*triCornerColors)[i][0]; + f.cornerColors[1] = (*triCornerColors)[i][1]; + f.cornerColors[2] = (*triCornerColors)[i][2]; + } outFaces.push_back(f); used[i] = 1; } @@ -1045,9 +1093,617 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, return buildMeshFromTriSoup(meshName, soup, layoutPtr); } +namespace { + +struct PsyqFace { + int verts[4] = {0, 0, 0, 0}; + int norms[4] = {0, 0, 0, 0}; + int corners = 3; // 3 or 4 +}; + +/** + * Parse a Psy-Q PLY into raw vertex/normal tables + per-face index arrays. + * Returns false on malformed input. + */ +static bool parsePsyqPlyTopology(const QStringList& lines, + std::vector& verts, + std::vector& norms, + std::vector& faces) +{ + verts.clear(); + norms.clear(); + faces.clear(); + static const QRegularExpression kHeaderRe(QStringLiteral("^@PLY\\d*\\s*$"), + QRegularExpression::CaseInsensitiveOption); + int idx = 0; + while (idx < lines.size()) { + if (kHeaderRe.match(lines[idx]).hasMatch()) { + ++idx; + break; + } + ++idx; + } + if (idx >= lines.size()) + return false; + + int nV = 0, nN = 0, nF = 0; + while (idx < lines.size()) { + if (parseCountsLine(lines[idx], nV, nN, nF)) + break; + ++idx; + } + if (idx >= lines.size() || nV <= 0 || nN < 0 || nF <= 0) + return false; + ++idx; + + verts.resize(static_cast(nV)); + for (int vi = 0; vi < nV; ++vi) { + if (idx >= lines.size()) + return false; + if (!parseVertexLine(lines[idx], verts[static_cast(vi)])) + return false; + ++idx; + } + + norms.resize(static_cast(nN)); + for (int ni = 0; ni < nN; ++ni) { + if (idx >= lines.size()) + return false; + if (!parseVertexLine(lines[idx], norms[static_cast(ni)])) + return false; + ++idx; + } + + faces.reserve(static_cast(nF)); + for (int fi = 0; fi < nF; ++fi) { + if (idx >= lines.size()) + return false; + const std::vector tok = parseIntTokens(lines[idx]); + ++idx; + if (tok.empty()) + return false; + + PsyqFace pf{}; + const int kind = tok[0]; + if (kind == 0) { + if (tok.size() < 9) + return false; + const bool psyq = (tok[4] == 0 && tok[8] == 0); + if (psyq) { + pf.verts[0] = tok[1]; pf.verts[1] = tok[2]; pf.verts[2] = tok[3]; + pf.norms[0] = tok[5]; pf.norms[1] = tok[6]; pf.norms[2] = tok[7]; + } else { + // Blender/RSD layout: 0 v0 v2 v1 ... n0 n2 n1 end + pf.verts[0] = tok[1]; pf.verts[1] = tok[3]; pf.verts[2] = tok[2]; + pf.norms[0] = tok[5]; pf.norms[1] = tok[7]; pf.norms[2] = tok[6]; + } + pf.corners = 3; + } else if (kind == 1) { + if (tok.size() < 9) + return false; + pf.verts[0] = tok[1]; pf.verts[1] = tok[2]; pf.verts[2] = tok[3]; pf.verts[3] = tok[4]; + pf.norms[0] = tok[5]; pf.norms[1] = tok[6]; pf.norms[2] = tok[7]; pf.norms[3] = tok[8]; + pf.corners = 4; + } else if (kind == 3 || kind == 4) { + const int cnt = kind; + if (tok.size() < static_cast(1 + cnt)) + return false; + for (int k = 0; k < cnt; ++k) + pf.verts[k] = tok[1 + k]; + if (nN > 0 && tok.size() >= static_cast(1 + cnt + cnt)) { + const size_t base = tok.size() - static_cast(cnt); + for (int k = 0; k < cnt; ++k) + pf.norms[k] = tok[base + k]; + } + pf.corners = cnt; + } else + return false; + + for (int k = 0; k < pf.corners; ++k) { + if (pf.verts[k] < 0 || pf.verts[k] >= nV) + return false; + if (pf.norms[k] < 0 || pf.norms[k] >= nN) + return false; + } + faces.push_back(pf); + } + return true; +} + +struct TexturedCorner { + Ogre::Vector3 pos; + Ogre::Vector3 nrm; + float u = 0.0f; + float v = 0.0f; + Ogre::RGBA color = 0; +}; + +/// Tracks the original Psy-Q face that produced a given triangle (3 corners) in the +/// soup, so `buildMeshFromTexturedSoups` can recover the original tri/quad polygon +/// topology and persist it as `qtme.faces.` n-gon bindings. Without this the +/// re-export path would only see triangulated geometry and the heuristic tri→quad +/// merger would fail to restore the original quads (visible as diagonal seams in +/// smooth-shaded surfaces like room walls). +struct TexturedTriProvenance { + int parentFaceIdx = -1; ///< Index into the input `faces` array. + uint8_t parentCorners = 3; ///< 3 (tri) or 4 (quad). + std::array parentCornerSlots{0, 1, 2}; ///< Maps each tri-corner to its source-face corner. +}; + +struct TexturedSubmeshSoup { + int textureIndex = -1; ///< -1 = untextured submesh + bool hasColor = false; + std::vector corners; ///< multiple of 3 (triangle list). + std::vector triProv; ///< One entry per tri (corners.size() / 3). +}; + +/// Walk the per-tri provenance metadata and reconstruct the source Psy-Q polygon +/// list (tris and quads in original corner order) from the welded indices the +/// `buildMeshFromTexturedSoups` weld step produced. Factored out of the build +/// loop so the latter stays under SonarQube's nesting / complexity ceilings. +static EditableSubMesh recoverPolygonsFromProvenance(const TexturedSubmeshSoup& soup, + const std::vector& weldedIndices) +{ + EditableSubMesh editable; + if (soup.triProv.size() * 3 != soup.corners.size() || soup.triProv.empty()) + return editable; + + std::unordered_map faceByParent; + faceByParent.reserve(soup.triProv.size()); + std::vector faceOrder; + faceOrder.reserve(soup.triProv.size()); + + for (size_t ti = 0; ti < soup.triProv.size(); ++ti) { + const TexturedTriProvenance& prov = soup.triProv[ti]; + if (prov.parentFaceIdx < 0) + continue; + EditableFace& ef = faceByParent.try_emplace(prov.parentFaceIdx).first->second; + if (ef.indices.empty()) { + ef.indices.assign(prov.parentCorners, std::numeric_limits::max()); + faceOrder.push_back(prov.parentFaceIdx); + } + for (int k = 0; k < 3; ++k) { + const uint8_t slot = prov.parentCornerSlots[static_cast(k)]; + if (slot < ef.indices.size()) + ef.indices[slot] = weldedIndices[ti * 3 + static_cast(k)]; + } + } + + const auto isComplete = [](const EditableFace& ef) { + return std::none_of(ef.indices.begin(), ef.indices.end(), + [](unsigned int vi) { + return vi == std::numeric_limits::max(); + }); + }; + + editable.faces.reserve(faceOrder.size()); + for (int parent : faceOrder) { + EditableFace& ef = faceByParent[parent]; + if (isComplete(ef) && ef.isValid()) + editable.faces.push_back(std::move(ef)); + } + return editable; +} + +static Ogre::RGBA qColorToRgba(const QColor& c) +{ + const float r = qBound(0, c.red(), 255) / 255.0f; + const float g = qBound(0, c.green(), 255) / 255.0f; + const float b = qBound(0, c.blue(), 255) / 255.0f; + return Ogre::ColourValue(r, g, b, 1.0f).getAsBYTE(); +} + +/** Compute the canonical face normal of the triangle (after world transform). */ +static Ogre::Vector3 faceNormalAfterTransform(const Ogre::Vector3& v0, + const Ogre::Vector3& v1, + const Ogre::Vector3& v2) +{ + Ogre::Vector3 p0 = v0, p1 = v1, p2 = v2; + applyPlyImportWorldTransform(p0); + applyPlyImportWorldTransform(p1); + applyPlyImportWorldTransform(p2); + Ogre::Vector3 fn = (p1 - p0).crossProduct(p2 - p0); + const float len = fn.length(); + if (len > 1e-10f) + fn /= len; + return fn; +} + +/** + * Append a single triangle (with per-corner UV/color) to the given submesh + * soup, flipping winding to match the supplied normals when needed (matches + * appendTriMaybeFlip semantics so textured and untextured paths produce the + * same surface). + */ +static void appendTexturedTri(TexturedSubmeshSoup& out, + const std::vector& verts, + const std::vector& norms, + int v0, int v1, int v2, + int n0, int n1, int n2, + float u0, float vc0, float u1, float vc1, float u2, float vc2, + Ogre::RGBA c0, Ogre::RGBA c1, Ogre::RGBA c2, + bool hasColor, + TexturedTriProvenance prov = {}) +{ + const Ogre::Vector3 fn = faceNormalAfterTransform(verts[v0], verts[v1], verts[v2]); + Ogre::Vector3 an = norms[n0] + norms[n1] + norms[n2]; + applyPlyImportWorldTransformNormal(an); + const bool shouldFlip = (!an.isZeroLength() && fn.length() > 1e-10f + && fn.dotProduct(an) < 0.0f); + + auto pushCorner = [&](int vi, int ni, float u, float v, Ogre::RGBA c) { + TexturedCorner tc; + tc.pos = verts[vi]; + tc.nrm = norms[ni]; + applyPlyImportWorldTransform(tc.pos); + applyPlyImportWorldTransformNormal(tc.nrm); + tc.u = u; + tc.v = v; + tc.color = c; + out.corners.push_back(tc); + }; + + if (!shouldFlip) { + pushCorner(v0, n0, u0, vc0, c0); + pushCorner(v1, n1, u1, vc1, c1); + pushCorner(v2, n2, u2, vc2, c2); + } else { + pushCorner(v0, n0, u0, vc0, c0); + pushCorner(v2, n2, u2, vc2, c2); + pushCorner(v1, n1, u1, vc1, c1); + // Provenance slots follow the actual push order so the build step can map each + // welded corner back to its source-face corner (drives qtme.faces ngon recovery). + std::swap(prov.parentCornerSlots[1], prov.parentCornerSlots[2]); + } + out.triProv.push_back(prov); + if (hasColor) + out.hasColor = true; +} + +/** Build an Ogre mesh from a vector of textured submesh soups. */ +static Ogre::MeshPtr buildMeshFromTexturedSoups( + const std::string& meshName, + const std::vector& soups) +{ + bool anyData = false; + for (const auto& s : soups) { + if (!s.corners.empty()) { + anyData = true; + break; + } + } + if (!anyData) + return {}; + + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( + meshName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + // Per-submesh n-gon polygon lists, indexed by built submesh order. Populated below + // from `soup.triProv` after the per-corner weld assigns each corner a final vertex + // index. Empty soups are skipped — `editableSubMeshes` only grows when we actually + // append a submesh, so its indexing matches `mesh->getNumSubMeshes()` afterwards. + std::vector editableSubMeshes; + editableSubMeshes.reserve(soups.size()); + + Ogre::AxisAlignedBox bounds; + for (size_t si = 0; si < soups.size(); ++si) { + const TexturedSubmeshSoup& soup = soups[si]; + if (soup.corners.empty()) + continue; + + const bool textured = (soup.textureIndex >= 0); + const bool hasColor = soup.hasColor; + + // Per-corner weld: positions + normals + (uv) + (color). PS1 PLYs frequently share + // 3D points across many faces with distinct UVs / shading, so unique corners must + // be discriminated by all attributes simultaneously. + struct CKey { + int32_t px, py, pz; + int32_t nx, ny, nz; + int32_t u, v; + int32_t crgba; + bool operator==(const CKey& o) const noexcept { + return px == o.px && py == o.py && pz == o.pz + && nx == o.nx && ny == o.ny && nz == o.nz + && u == o.u && v == o.v && crgba == o.crgba; + } + }; + struct CKeyHash { + size_t operator()(const CKey& 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.u); mix(k.v); mix(k.crgba); + return h; + } + }; + + std::vector uniqCorners; + std::vector indices; + std::unordered_map weld; + uniqCorners.reserve(soup.corners.size()); + indices.reserve(soup.corners.size()); + weld.reserve(soup.corners.size()); + + const float kUvScale = 100000.0f; + for (const TexturedCorner& c : soup.corners) { + CKey k{ + quantizeWorld(c.pos.x), quantizeWorld(c.pos.y), quantizeWorld(c.pos.z), + quantizeWorld(c.nrm.x), quantizeWorld(c.nrm.y), quantizeWorld(c.nrm.z), + static_cast(std::lround(double(c.u) * kUvScale)), + static_cast(std::lround(double(c.v) * kUvScale)), + hasColor ? static_cast(c.color) : 0 + }; + const auto it = weld.find(k); + if (it == weld.end()) { + const uint32_t ni = static_cast(uniqCorners.size()); + weld.emplace(k, ni); + uniqCorners.push_back(c); + indices.push_back(ni); + } else + indices.push_back(it->second); + } + + const size_t nVert = uniqCorners.size(); + const size_t nIdx = indices.size(); + + // Recover the original Psy-Q face polygons (tris and quads in source corner + // order) so the export path can re-emit them verbatim via `qtme.faces.`, + // instead of running the heuristic tri→quad merger (which fails to restore + // adjacent coplanar quads — e.g. flat room walls in the Blender RSD sample). + editableSubMeshes.push_back(recoverPolygonsFromProvenance(soup, indices)); + + Ogre::SubMesh* sm = mesh->createSubMesh(); + const std::string slotSuffix = textured + ? std::string("_tex") + std::to_string(soup.textureIndex) + : std::string("_solid"); + const std::string matName = std::string("PLY/") + meshName + slotSuffix; + // Ensure a fresh material exists with this name so the post-import RSD texture-binding + // pass (in MeshImporterExporter) can resolve `_texN` submeshes by regex. Clone from + // BaseMaterial when available, otherwise create one outright so the CLI path (which + // does not preload BaseMaterial) still gets unique submesh materials. + try { + if (auto existing = Ogre::MaterialManager::getSingleton().getByName(matName)) + Ogre::MaterialManager::getSingleton().remove(existing); + Ogre::MaterialPtr fresh; + if (auto base = Ogre::MaterialManager::getSingleton().getByName("BaseMaterial")) { + fresh = base->clone(matName); + } else { + fresh = Ogre::MaterialManager::getSingleton().create( + matName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + } + (void)fresh; + } catch (...) {} + sm->setMaterialName(matName); + sm->useSharedVertices = false; + + sm->vertexData = new Ogre::VertexData(); + sm->vertexData->vertexCount = static_cast(nVert); + auto* decl = sm->vertexData->vertexDeclaration; + auto* bind = sm->vertexData->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); + if (textured) { + decl->addElement(0, off, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT2); + } + if (hasColor) { + decl->addElement(0, off, Ogre::VET_COLOUR, Ogre::VES_DIFFUSE); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_COLOUR); + } + const size_t vsize = decl->getVertexSize(0); + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + vsize, nVert, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint8_t* dst = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_DISCARD)); + for (size_t i = 0; i < nVert; ++i) { + uint8_t* row = dst + i * vsize; + float* pf = nullptr; + decl->findElementBySemantic(Ogre::VES_POSITION)->baseVertexPointerToElement(row, &pf); + pf[0] = uniqCorners[i].pos.x; + pf[1] = uniqCorners[i].pos.y; + pf[2] = uniqCorners[i].pos.z; + decl->findElementBySemantic(Ogre::VES_NORMAL)->baseVertexPointerToElement(row, &pf); + pf[0] = uniqCorners[i].nrm.x; + pf[1] = uniqCorners[i].nrm.y; + pf[2] = uniqCorners[i].nrm.z; + if (textured) { + decl->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES)->baseVertexPointerToElement(row, &pf); + pf[0] = uniqCorners[i].u; + pf[1] = uniqCorners[i].v; + } + if (hasColor) { + Ogre::RGBA* cp = nullptr; + decl->findElementBySemantic(Ogre::VES_DIFFUSE)->baseVertexPointerToElement(row, (void**)&cp); + *cp = uniqCorners[i].color; + } + bounds.merge(uniqCorners[i].pos); + } + vbuf->unlock(); + bind->setBinding(0, vbuf); + + // Configure cloned material so vertex colours track diffuse (matches buildMeshFromTriSoup). + try { + if (auto mat = Ogre::MaterialManager::getSingleton().getByName(matName)) { + if (!mat->isLoaded()) + mat->load(); + if (mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { + Ogre::Pass* p0 = mat->getTechnique(0)->getPass(0); + if (p0) { + p0->setLightingEnabled(true); + p0->setAmbient(1.0f, 1.0f, 1.0f); + p0->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); + p0->setEmissive(0.0f, 0.0f, 0.0f); + p0->setVertexColourTracking(hasColor ? (Ogre::TVC_AMBIENT | Ogre::TVC_DIFFUSE) + : Ogre::TVC_NONE); + } + } + } + } catch (...) {} + + const bool use32 = nVert > 65535; + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + 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 < 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 < nIdx; ++i) + ip[i] = static_cast(indices[i]); + ibuf->unlock(); + } + sm->indexData->indexBuffer = ibuf; + sm->indexData->indexCount = static_cast(nIdx); + sm->indexData->indexStart = 0; + } + + mesh->_setBounds(bounds); + mesh->_setBoundingSphereRadius(bounds.getHalfSize().length()); + mesh->load(); + + // Persist per-submesh polygon topology so the RSD exporter can recover original + // quad/tri shapes verbatim (round-trip preserves smooth-shaded quads without the + // heuristic merger's failure modes on adjacent coplanar surfaces). + bool anyFaces = false; + for (const auto& sub : editableSubMeshes) { + if (!sub.faces.empty()) { + anyFaces = true; + break; + } + } + if (anyFaces) + writeNgonFacesToMesh(mesh.get(), editableSubMeshes); + + return mesh; +} + +} // namespace + +Ogre::MeshPtr importPsyqPlyWithFaceMaterials(const QString& filePath, + const std::string& meshName, + const QVector& faceMaterials) +{ + const QString fileName = QFileInfo(filePath).fileName(); + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("PS1 PLY open failed: %1").arg(fileName)); + return {}; + } + const QString text = QString::fromLatin1(f.readAll()); + const QStringList lines = readNonEmptyLines(text); + + std::vector verts; + std::vector norms; + std::vector faces; + if (!parsePsyqPlyTopology(lines, verts, norms, faces)) + return {}; + + if (faceMaterials.size() != static_cast(faces.size())) + return {}; + + // Bucket faces by texture index (-1 = untextured). Keep insertion order so untextured + // submesh appears first when present and material assignment is deterministic. + std::vector soups; + std::unordered_map texToSoup; + auto getSoup = [&](int texIndex) -> TexturedSubmeshSoup& { + const auto it = texToSoup.find(texIndex); + if (it != texToSoup.end()) + return soups[it->second]; + TexturedSubmeshSoup s; + s.textureIndex = texIndex; + const size_t idx = soups.size(); + soups.push_back(std::move(s)); + texToSoup.emplace(texIndex, idx); + return soups[idx]; + }; + + for (size_t fi = 0; fi < faces.size(); ++fi) { + const PsyqFace& pf = faces[fi]; + const FaceMaterial& fm = faceMaterials[static_cast(fi)]; + // Treat textured-with-invalid-index as untextured to avoid silently binding + // every malformed face to slot 0; -1 routes the face to the solid bucket. + const int submeshKey = (fm.textured && fm.textureIndex >= 0) ? fm.textureIndex : -1; + TexturedSubmeshSoup& soup = getSoup(submeshKey); + + // Psy-Q MAT G/H entries are typically written with 4 RGB triples regardless + // of face shape — the Blender RSD exporter pads triangles with a trailing + // `0 0 0` slot. Treat the MAT as authoritative for the first `pf.corners` + // colours; extra trailing values are ignored. Without this, padded tris + // would lose their per-corner shading and collapse to a single flat colour + // (visible as faceted walls in the imported `Example Project.rsd`). + const bool hasFaceColors = (fm.vertColors.size() >= pf.corners); + auto cornerColor = [&](int corner) -> Ogre::RGBA { + if (hasFaceColors && corner < fm.vertColors.size()) + return qColorToRgba(fm.vertColors[corner]); + if (fm.color.isValid()) + return qColorToRgba(fm.color); + return qColorToRgba(QColor(255, 255, 255)); + }; + const bool hasColorOnFace = hasFaceColors || fm.color.isValid(); + + if (pf.corners == 3) { + TexturedTriProvenance prov; + prov.parentFaceIdx = static_cast(fi); + prov.parentCorners = 3; + prov.parentCornerSlots = {0, 1, 2}; + appendTexturedTri(soup, + verts, norms, + pf.verts[0], pf.verts[1], pf.verts[2], + pf.norms[0], pf.norms[1], pf.norms[2], + fm.u[0], fm.v[0], fm.u[1], fm.v[1], fm.u[2], fm.v[2], + cornerColor(0), cornerColor(1), cornerColor(2), + hasColorOnFace, prov); + } else if (pf.corners == 4) { + // Match TMD quad triangulation: (v0,v1,v2) + (v1,v2,v3). + TexturedTriProvenance prov0; + prov0.parentFaceIdx = static_cast(fi); + prov0.parentCorners = 4; + prov0.parentCornerSlots = {0, 1, 2}; + appendTexturedTri(soup, + verts, norms, + pf.verts[0], pf.verts[1], pf.verts[2], + pf.norms[0], pf.norms[1], pf.norms[2], + fm.u[0], fm.v[0], fm.u[1], fm.v[1], fm.u[2], fm.v[2], + cornerColor(0), cornerColor(1), cornerColor(2), + hasColorOnFace, prov0); + TexturedTriProvenance prov1; + prov1.parentFaceIdx = static_cast(fi); + prov1.parentCorners = 4; + prov1.parentCornerSlots = {1, 2, 3}; + appendTexturedTri(soup, + verts, norms, + pf.verts[1], pf.verts[2], pf.verts[3], + pf.norms[1], pf.norms[2], pf.norms[3], + fm.u[1], fm.v[1], fm.u[2], fm.v[2], fm.u[3], fm.v[3], + cornerColor(1), cornerColor(2), cornerColor(3), + hasColorOnFace, prov1); + } + } + + Ogre::MeshPtr outMesh = buildMeshFromTexturedSoups(meshName, soups); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("PS1 PLY (textured) imported: %1 (%2 faces, %3 submeshes)") + .arg(fileName) + .arg(faces.size()) + .arg(static_cast(soups.size()))); + return outMesh; +} + bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const QString& plyPath, QVector* outFaceColors, + QVector* outFaceTextures, QString* outError) { if (!entity || !entity->getMesh().get()) { @@ -1064,11 +1720,14 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const Ogre::VertexElement* posEl = nullptr; const Ogre::VertexElement* nrmEl = nullptr; const Ogre::VertexElement* colEl = nullptr; + const Ogre::VertexElement* uvEl = nullptr; Ogre::HardwareVertexBufferSharedPtr posBuf; Ogre::HardwareVertexBufferSharedPtr nrmBuf; Ogre::HardwareVertexBufferSharedPtr colBuf; - size_t posStride = 0, nrmStride = 0, colStride = 0; + Ogre::HardwareVertexBufferSharedPtr uvBuf; + size_t posStride = 0, nrmStride = 0, colStride = 0, uvStride = 0; uint32_t vCount = 0; + int sourceIndex = -1; ///< Original submesh index in the entity (for RSD slot lookup). }; std::vector subs; @@ -1089,20 +1748,25 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, sd.posEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); sd.nrmEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_NORMAL); sd.colEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_DIFFUSE); + sd.uvEl = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES); if (!sd.posEl || !sd.nrmEl) continue; sd.vCount = static_cast(vd->vertexCount); totalFaces += static_cast(sd.id->indexCount / 3); + sd.sourceIndex = static_cast(si); sd.posBuf = vd->vertexBufferBinding->getBuffer(sd.posEl->getSource()); sd.nrmBuf = vd->vertexBufferBinding->getBuffer(sd.nrmEl->getSource()); if (sd.colEl) sd.colBuf = vd->vertexBufferBinding->getBuffer(sd.colEl->getSource()); + if (sd.uvEl) + sd.uvBuf = vd->vertexBufferBinding->getBuffer(sd.uvEl->getSource()); sd.posStride = sd.posBuf->getVertexSize(); sd.nrmStride = sd.nrmBuf->getVertexSize(); sd.colStride = sd.colBuf ? sd.colBuf->getVertexSize() : 0; + sd.uvStride = sd.uvBuf ? sd.uvBuf->getVertexSize() : 0; subs.push_back(sd); } @@ -1112,24 +1776,29 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, return false; } - 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; - } + // Read per-submesh n-gon bindings (qtme.faces.) and validate every polygon. + // When every submesh exposes a binding with in-range corner indices, we can emit + // the source tri/quad topology verbatim — critical for PS1 RSDs where Blender's + // exporter writes quads with per-corner shading. Without this the heuristic + // tri→quad merger reconstructs the wrong diagonals on flat coplanar surfaces + // (e.g. room walls) and the round-trip shows diagonal seams. + const auto ngonFacesValid = [](const std::vector>& faces, + uint32_t maxVid) { + const auto polyValid = [maxVid](const std::vector& poly) { + return poly.size() >= 3 + && std::all_of(poly.begin(), poly.end(), + [maxVid](unsigned int vid) { return vid < maxVid; }); + }; + return std::all_of(faces.begin(), faces.end(), polyValid); + }; + + std::vector>> perSubNgonFaces(subs.size()); + bool useNgonExport = !subs.empty(); + for (size_t si = 0; si < subs.size() && useNgonExport; ++si) { + const bool gotBinding = readNgonFacesFromMesh(mesh.get(), + static_cast(subs[si].sourceIndex), + perSubNgonFaces[si]); + useNgonExport = gotBinding && ngonFacesValid(perSubNgonFaces[si], subs[si].vCount); } std::vector weldedPos; @@ -1166,7 +1835,13 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, }; if (useNgonExport) { - SubData& sd = subs[0]; + // Process each submesh independently — buffer locks, corner pools, and the + // ngon poly list are per-submesh. Welding pools (weldedPos / weldedNrm) stay + // shared across submeshes so identical positions/normals collapse to one + // PLY vertex/normal index regardless of which submesh produced them. + for (size_t subIdx = 0; subIdx < subs.size(); ++subIdx) { + const SubData& sd = subs[subIdx]; + const std::vector>& ngonFaces = perSubNgonFaces[subIdx]; 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()) { @@ -1184,8 +1859,39 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, colBase = static_cast(sd.colBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); } } + const uint8_t* uvBase = nullptr; + if (sd.uvBuf) { + const unsigned short uvSrc = sd.uvEl->getSource(); + if (uvSrc == sd.posEl->getSource()) + uvBase = posBase; + else if (uvSrc == sd.nrmEl->getSource() && sd.nrmEl->getSource() != sd.posEl->getSource()) + uvBase = nrmBase; + else if (sd.colBuf && uvSrc == sd.colEl->getSource() + && sd.colEl->getSource() != sd.posEl->getSource() + && sd.colEl->getSource() != sd.nrmEl->getSource()) + uvBase = colBase; + else + uvBase = static_cast(sd.uvBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + } + const bool subHasUv = (uvBase != nullptr); const bool collectFaceColors = (outFaceColors != nullptr && sd.colEl && colBase); + auto readUv = [&](uint32_t vi, float& outU, float& outV) { + const uint8_t* row = uvBase + size_t(vi) * sd.uvStride; + Ogre::Real* uvf = nullptr; + sd.uvEl->baseVertexPointerToElement(const_cast(row), &uvf); + outU = static_cast(uvf[0]); + outV = static_cast(uvf[1]); + }; + auto setPsyqUv = [&](PsyqExportFace& pf, const std::vector& corners) { + if (!subHasUv) + return; + pf.hasUv = true; + pf.submeshIndex = sd.sourceIndex; + const size_t n = std::min(corners.size(), 4u); + for (size_t k = 0; k < n; ++k) + readUv(corners[k], pf.u[k], pf.v_uv[k]); + }; auto readPN = [&](uint32_t ii, Ogre::Vector3& p, Ogre::Vector3& n) { const uint8_t* prow = posBase + size_t(ii) * sd.posStride; @@ -1226,6 +1932,13 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, mc.nrmIdx = weldNrmOnly(n); }; + auto readCornerColor = [&](uint32_t vi) -> QColor { + const Ogre::ColourValue cv = + decodePackedColour(sd.colEl, static_cast(cornerCrgba(vi))); + return QColor::fromRgbF(std::clamp(cv.r, 0.0f, 1.0f), + std::clamp(cv.g, 0.0f, 1.0f), + std::clamp(cv.b, 0.0f, 1.0f), 1.0f); + }; auto avgRgbCorners = [&](const std::vector& corners) -> QColor { Ogre::ColourValue acc(0, 0, 0, 1.0f); for (unsigned int vi : corners) { @@ -1238,6 +1951,15 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const float inv = 1.0f / float(corners.size()); return QColor::fromRgbF(acc.r * inv, acc.g * inv, acc.b * inv, 1.0f); }; + auto setPsyqCornerColors = [&](PsyqExportFace& pf, + const std::vector& corners) { + if (!collectFaceColors) + return; + pf.hasCornerColors = true; + const size_t cn = std::min(corners.size(), 4u); + for (size_t k = 0; k < cn; ++k) + pf.cornerColors[k] = readCornerColor(corners[k]); + }; allExportFaces.reserve(ngonFaces.size() * 2); @@ -1259,6 +1981,8 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, f.color = avgRgbCorners(poly); f.hasColor = true; } + setPsyqUv(f, poly); + setPsyqCornerColors(f, poly); allExportFaces.push_back(f); } else if (ps == 4) { for (unsigned int c : poly) @@ -1277,6 +2001,8 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, f.color = avgRgbCorners(poly); f.hasColor = true; } + setPsyqUv(f, poly); + setPsyqCornerColors(f, poly); allExportFaces.push_back(f); } else { ensureMeshCorner(poly[0]); @@ -1299,11 +2025,23 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, t.color = avgRgbCorners(tri); t.hasColor = true; } + const std::vector triCorners{poly[0], poly[static_cast(k)], + poly[static_cast(k + 1)]}; + setPsyqUv(t, triCorners); + setPsyqCornerColors(t, triCorners); allExportFaces.push_back(t); } } } + if (sd.uvBuf && uvBase + && sd.uvEl->getSource() != sd.posEl->getSource() + && !(sd.uvEl->getSource() == sd.nrmEl->getSource() && sd.nrmEl->getSource() != sd.posEl->getSource()) + && !(sd.colBuf && sd.colEl->getSource() != sd.posEl->getSource() + && sd.colEl->getSource() != sd.nrmEl->getSource() + && sd.uvEl->getSource() == sd.colEl->getSource())) { + sd.uvBuf->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(); @@ -1311,6 +2049,7 @@ 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]; @@ -1332,6 +2071,21 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, colBase = static_cast(sd.colBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); } } + const uint8_t* uvBase = nullptr; + if (sd.uvBuf) { + const unsigned short uvSrc = sd.uvEl->getSource(); + if (uvSrc == sd.posEl->getSource()) + uvBase = posBase; + else if (uvSrc == sd.nrmEl->getSource() && sd.nrmEl->getSource() != sd.posEl->getSource()) + uvBase = nrmBase; + else if (sd.colBuf && uvSrc == sd.colEl->getSource() + && sd.colEl->getSource() != sd.posEl->getSource() + && sd.colEl->getSource() != sd.nrmEl->getSource()) + uvBase = colBase; + else + uvBase = static_cast(sd.uvBuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + } + const bool subHasUv = (uvBase != nullptr); auto ibuf = sd.id->indexBuffer; const uint8_t* idxBase = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); @@ -1346,13 +2100,20 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, std::vector smN1; std::vector smN2; QVector smFaceCols; + std::vector> smCornerCols; ///< Per-tri-corner colours (v0,v1,v2) when colBase available. + std::vector> smUv; ///< (u0,v0,u1,v1,u2,v2) per tri when subHasUv. smI0.reserve(triCount); smI1.reserve(triCount); smI2.reserve(triCount); smN0.reserve(triCount); smN1.reserve(triCount); smN2.reserve(triCount); + if (subHasUv) + smUv.reserve(triCount); const bool collectFaceColors = (outFaceColors != nullptr && sd.colEl && colBase); + const bool collectCornerColors = (outFaceTextures != nullptr && sd.colEl && colBase); + if (collectCornerColors) + smCornerCols.reserve(triCount); for (size_t t = 0; t < triCount; ++t) { uint32_t i0, i1, i2; @@ -1412,26 +2173,101 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, smN1.push_back(wn1); smN2.push_back(wn2); - if (collectFaceColors) { + if (subHasUv) { + auto readUv = [&](uint32_t ii) -> std::pair { + const uint8_t* row = uvBase + size_t(ii) * sd.uvStride; + Ogre::Real* uvf = nullptr; + sd.uvEl->baseVertexPointerToElement(const_cast(row), &uvf); + return {static_cast(uvf[0]), static_cast(uvf[1])}; + }; + const auto uv0 = readUv(i0); + const auto uv1 = readUv(i1); + const auto uv2 = readUv(i2); + smUv.push_back({uv0.first, uv0.second, uv1.first, uv1.second, uv2.first, uv2.second}); + } + + if (collectFaceColors || collectCornerColors) { 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)); + if (collectFaceColors) { + 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)); + } + if (collectCornerColors) { + std::array cc{ + QColor::fromRgbF(std::clamp(cv0.r, 0.0f, 1.0f), + std::clamp(cv0.g, 0.0f, 1.0f), + std::clamp(cv0.b, 0.0f, 1.0f), 1.0f), + QColor::fromRgbF(std::clamp(cv1.r, 0.0f, 1.0f), + std::clamp(cv1.g, 0.0f, 1.0f), + std::clamp(cv1.b, 0.0f, 1.0f), 1.0f), + QColor::fromRgbF(std::clamp(cv2.r, 0.0f, 1.0f), + std::clamp(cv2.g, 0.0f, 1.0f), + std::clamp(cv2.b, 0.0f, 1.0f), 1.0f)}; + smCornerCols.push_back(cc); + } } } std::vector subFaces; QVector* colorMerge = collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) ? &smFaceCols : nullptr; - - mergeSubmeshTrisToQuads(smI0, smI1, smI2, smN0, smN1, smN2, weldedPos, colorMerge, subFaces); + const std::vector>* cornerColorMerge = + (collectCornerColors && smCornerCols.size() == smI0.size()) ? &smCornerCols : nullptr; + + if (subHasUv) { + // Textured submesh: do not merge tris to quads — UV merge is ambiguous and + // would lose per-corner UV identity. Emit each tri as-is with its UVs. + subFaces.reserve(smI0.size()); + for (size_t ti = 0; ti < smI0.size(); ++ti) { + PsyqExportFace pf; + pf.isQuad = false; + pf.v[0] = smI0[ti]; pf.v[1] = smI1[ti]; pf.v[2] = smI2[ti]; + pf.n[0] = smN0[ti]; pf.n[1] = smN1[ti]; pf.n[2] = smN2[ti]; + pf.hasUv = true; + pf.submeshIndex = sd.sourceIndex; + if (ti < smUv.size()) { + pf.u[0] = smUv[ti][0]; + pf.v_uv[0] = smUv[ti][1]; + pf.u[1] = smUv[ti][2]; + pf.v_uv[1] = smUv[ti][3]; + pf.u[2] = smUv[ti][4]; + pf.v_uv[2] = smUv[ti][5]; + } + if (colorMerge && ti < static_cast(colorMerge->size())) { + pf.color = (*colorMerge)[static_cast(ti)]; + pf.hasColor = true; + } + if (cornerColorMerge && ti < cornerColorMerge->size()) { + pf.hasCornerColors = true; + pf.cornerColors[0] = (*cornerColorMerge)[ti][0]; + pf.cornerColors[1] = (*cornerColorMerge)[ti][1]; + pf.cornerColors[2] = (*cornerColorMerge)[ti][2]; + } + subFaces.push_back(pf); + } + } else { + mergeSubmeshTrisToQuads(smI0, smI1, smI2, smN0, smN1, smN2, weldedPos, + colorMerge, cornerColorMerge, subFaces); + for (auto& pf : subFaces) + pf.submeshIndex = sd.sourceIndex; + } allExportFaces.insert(allExportFaces.end(), subFaces.begin(), subFaces.end()); ibuf->unlock(); + if (sd.uvBuf && uvBase + && sd.uvEl->getSource() != sd.posEl->getSource() + && !(sd.nrmEl->getSource() != sd.posEl->getSource() + && sd.uvEl->getSource() == sd.nrmEl->getSource()) + && !(sd.colBuf && sd.colEl->getSource() != sd.posEl->getSource() + && sd.colEl->getSource() != sd.nrmEl->getSource() + && sd.uvEl->getSource() == sd.colEl->getSource())) { + sd.uvBuf->unlock(); + } if (sd.colBuf && colBase && sd.colEl->getSource() != sd.posEl->getSource() && !(sd.colEl->getSource() == sd.nrmEl->getSource() && sd.nrmEl->getSource() != sd.posEl->getSource())) { @@ -1459,13 +2295,32 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, } } + if (outFaceTextures) { + outFaceTextures->clear(); + outFaceTextures->reserve(static_cast(allExportFaces.size())); + for (const PsyqExportFace& ef : allExportFaces) { + ExportFaceTexture eft; + eft.textured = ef.hasUv; + eft.submeshIndex = ef.submeshIndex; + eft.cornerCount = ef.isQuad ? 4 : 3; + eft.u = ef.u; + eft.v = ef.v_uv; + eft.hasCornerColors = ef.hasCornerColors; + eft.cornerColors = ef.cornerColors; + outFaceTextures->push_back(eft); + } + } + const uint32_t nV = static_cast(weldedPos.size()); const uint32_t nN = static_cast(weldedNrm.size()); const uint32_t nWrittenFaces = static_cast(allExportFaces.size()); + const QString plyName = QFileInfo(plyPath).fileName(); QFile f(plyPath); if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { if (outError) *outError = QStringLiteral("Could not open PLY file for writing."); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("PS1 PLY write open failed: %1").arg(plyName)); return false; } @@ -1491,8 +2346,13 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, if (ts.status() != QTextStream::Ok) { if (outError) *outError = QStringLiteral("Write failed."); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("PS1 PLY write failed: %1").arg(plyName)); return false; } + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("PS1 PLY written: %1 (%2 faces, %3 verts)") + .arg(plyName).arg(nWrittenFaces).arg(nV)); return true; } diff --git a/src/PS1/PS1PLY.h b/src/PS1/PS1PLY.h index 3ef570dec..220e57e3a 100644 --- a/src/PS1/PS1PLY.h +++ b/src/PS1/PS1PLY.h @@ -15,6 +15,9 @@ The MIT License #include #include #include +#include + +#include /** * Sony Psy-Q "PLY" polygon mesh (ASCII) — not Stanford PLY. @@ -42,6 +45,45 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, const std::string& meshName, const QVector& faceColors); +/** Per-face material binding for the textured import path. */ +struct FaceMaterial { + bool textured = false; ///< true when the face references a texture slot. + int textureIndex = -1; ///< RSD TEX[] index (only used when `textured` is true). + std::array u{}; ///< per-corner U (normalised 0..1, in PLY corner order v0..vN). + std::array v{}; ///< per-corner V (normalised 0..1, top-origin). + QColor color; ///< per-face flat colour fallback (used when no vertex colours are supplied). + QVector vertColors; ///< 0, 3 or 4 per-corner colours (in PLY corner order); empty when N/A. +}; + +/** + * Import with per-face material binding (UVs + texture index + colours). + * + * The mesh is split into one submesh per `textureIndex` group, plus one + * submesh for untextured faces. The caller is responsible for binding a + * texture-aware material to each submesh after creation; this routine + * only stores UVs on textured submesh vertices. + * + * `faceMaterials` length must match the PLY face count. + */ +Ogre::MeshPtr importPsyqPlyWithFaceMaterials(const QString& filePath, + const std::string& meshName, + const QVector& faceMaterials); + +/// Per-output-face UV + texture-slot info gathered alongside the PLY export. +struct ExportFaceTexture { + bool textured = false; ///< Source submesh has UVs + a bound texture. + int submeshIndex = -1; ///< Submesh that produced this face (for RSD slot lookup). + int cornerCount = 3; ///< 3 or 4 — matches the written PLY face shape. + std::array u{}; ///< Per-corner U (0..1), zero-padded for tris. + std::array v{}; ///< Per-corner V (0..1), zero-padded for tris. + /// Per-corner colours (matches PLY corner order). Populated when the source submesh has + /// a VES_DIFFUSE stream. Slots beyond `cornerCount` are default-constructed. Lets the + /// caller emit smooth-shaded MAT entries (Psy-Q `G` / `H`) instead of averaging the + /// corners into a single flat colour — preserves baked AO / vertex shading on round-trip. + bool hasCornerColors = false; + std::array cornerColors{}; +}; + /// 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. @@ -49,10 +91,12 @@ Ogre::MeshPtr importPsyqPlyWithFaceColors(const QString& filePath, /// 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). +/// written face is filled (for a MAT sidecar). If outFaceTextures is provided, per-face +/// UV + submesh metadata is filled (for the textured MAT/RSD export path). bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const QString& plyPath, QVector* outFaceColors = nullptr, + QVector* outFaceTextures = nullptr, QString* outError = nullptr); } // namespace PS1PLY diff --git a/src/PS1/PS1PLY_test.cpp b/src/PS1/PS1PLY_test.cpp index 6664367bf..4f16ee58f 100644 --- a/src/PS1/PS1PLY_test.cpp +++ b/src/PS1/PS1PLY_test.cpp @@ -266,7 +266,7 @@ TEST_F(PS1PLYOgreTest, ExportHeuristicMergeProducesOneQuadAndSharedNormalPool) const QString path = outPly.fileName(); QString err; - ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, path, nullptr, &err)) << err.toUtf8().constData(); + ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, path, nullptr, nullptr, &err)) << err.toUtf8().constData(); mgr->destroySceneNode(QStringLiteral("PS1PlyQuadHeuristicNode")); Ogre::MeshManager::getSingleton().remove(meshName); @@ -301,7 +301,7 @@ TEST_F(PS1PLYOgreTest, ExportHeuristicSkipsQuadMergeWhenSharedEdgeNormalsDisagre const QString path = outPly.fileName(); QString err; - ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, path, nullptr, &err)) << err.toUtf8().constData(); + ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, path, nullptr, nullptr, &err)) << err.toUtf8().constData(); mgr->destroySceneNode(QStringLiteral("PS1PlySplitNormalHeuristicNode")); Ogre::MeshManager::getSingleton().remove(meshName); @@ -313,6 +313,242 @@ TEST_F(PS1PLYOgreTest, ExportHeuristicSkipsQuadMergeWhenSharedEdgeNormalsDisagre EXPECT_TRUE(face0.startsWith(QLatin1String("0 "))); } +TEST_F(PS1PLYOgreTest, ImportWithFaceMaterials_SplitsTexturedAndSolidIntoSubmeshes) +{ + ASSERT_TRUE(canLoadMeshFiles()); + + // Two triangles sharing an edge: face 0 is textured (TEX[0]), face 1 is solid. + // PLY layout: 4 verts, 1 normal (+Z), 2 triangle face lines. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString plyIn = QDir(dir.path()).filePath(QStringLiteral("mixed.ply")); + { + QFile wf(plyIn); + ASSERT_TRUE(wf.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream ts(&wf); + ts << "@PLY940102\n"; + ts << "4 1 2\n"; + ts << "0 0 0\n1 0 0\n1 1 0\n0 1 0\n"; + ts << "0 0 1\n"; + // Psy-Q triangle face format: "0 v0 v1 v2 0 n0 n1 n2 0". + ts << "0 0 1 2 0 0 0 0 0\n"; + ts << "0 0 2 3 0 0 0 0 0\n"; + } + + const std::string meshName = "PS1PlyMixedImportMesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + QVector faceMats(2); + // Textured face: full UV square (0..1). + faceMats[0].textured = true; + faceMats[0].textureIndex = 0; + faceMats[0].u = {0.0f, 1.0f, 1.0f, 0.0f}; + faceMats[0].v = {0.0f, 0.0f, 1.0f, 0.0f}; + // Solid face: red. + faceMats[1].textured = false; + faceMats[1].color = QColor(255, 0, 0); + + Ogre::MeshPtr mesh = PS1PLY::importPsyqPlyWithFaceMaterials(plyIn, meshName, faceMats); + ASSERT_TRUE(mesh); + // One submesh per bucket: textured (tex0) + untextured (solid) = 2. + ASSERT_EQ(mesh->getNumSubMeshes(), 2u); + + bool foundTex = false; + bool foundSolid = false; + for (unsigned int si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sm = mesh->getSubMesh(si); + const std::string matName = sm->getMaterialName(); + const bool isTex = (matName.find("_tex0") != std::string::npos); + const bool isSolid = (matName.find("_solid") != std::string::npos); + EXPECT_TRUE(isTex || isSolid) << "Unexpected submesh material: " << matName; + if (isTex) foundTex = true; + if (isSolid) foundSolid = true; + + // Each submesh has its own vertex data with at least position+normal. + ASSERT_NE(sm->vertexData, nullptr); + EXPECT_GE(sm->vertexData->vertexCount, 3u); + + const Ogre::VertexElement* uvEl = + sm->vertexData->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES); + if (isTex) + EXPECT_NE(uvEl, nullptr) << "Textured submesh missing UV element"; + else + EXPECT_EQ(uvEl, nullptr) << "Solid submesh should not carry UVs"; + } + EXPECT_TRUE(foundTex); + EXPECT_TRUE(foundSolid); + + Ogre::MeshManager::getSingleton().remove(meshName); +} + +TEST_F(PS1PLYOgreTest, TexturedPlyRoundTrip_ExportRecoversPerFaceUvAndTextureFlag) +{ + ASSERT_TRUE(canLoadMeshFiles()); + + // Same mixed (1 textured + 1 solid) PLY as the import test above. We + // import, then export through exportPsyqPlyFromEntity with the texture + // metadata sink and verify per-face UVs/submesh indices survive. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString plyIn = QDir(dir.path()).filePath(QStringLiteral("mixed_rt.ply")); + { + QFile wf(plyIn); + ASSERT_TRUE(wf.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream ts(&wf); + ts << "@PLY940102\n"; + ts << "4 1 2\n"; + ts << "0 0 0\n1 0 0\n1 1 0\n0 1 0\n"; + ts << "0 0 1\n"; + ts << "0 0 1 2 0 0 0 0 0\n"; + ts << "0 0 2 3 0 0 0 0 0\n"; + } + + QVector faceMats(2); + faceMats[0].textured = true; + faceMats[0].textureIndex = 0; + faceMats[0].u = {0.0f, 1.0f, 1.0f, 0.0f}; + faceMats[0].v = {0.0f, 0.0f, 1.0f, 0.0f}; + faceMats[1].textured = false; + faceMats[1].color = QColor(0, 200, 0); + + const std::string meshName = "PS1PlyMixedRoundTripMesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + Ogre::MeshPtr mesh = PS1PLY::importPsyqPlyWithFaceMaterials(plyIn, meshName, faceMats); + ASSERT_TRUE(mesh); + ASSERT_EQ(mesh->getNumSubMeshes(), 2u); + + auto* mgr = Manager::getSingleton(); + Ogre::SceneNode* node = mgr->addSceneNode(QStringLiteral("PS1PlyMixedRoundTripNode")); + ASSERT_NE(node, nullptr); + Ogre::Entity* ent = mgr->createEntity(node, mesh); + ASSERT_NE(ent, nullptr); + + QTemporaryFile outPly(QDir::tempPath() + QStringLiteral("/qtmesh_ps1ply_rt_XXXXXX.ply")); + outPly.setAutoRemove(true); + ASSERT_TRUE(outPly.open()); + outPly.close(); + + QVector faceColors; + QVector faceTex; + QString err; + ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, outPly.fileName(), &faceColors, &faceTex, &err)) + << err.toUtf8().constData(); + + // Two faces in -> two faces out (the exporter disables quad merging for textured + // submeshes so each triangle survives independently). + ASSERT_EQ(faceTex.size(), 2); + + int texturedFaces = 0; + int untexturedFaces = 0; + for (const auto& f : faceTex) { + if (f.textured) { + ++texturedFaces; + // UVs are written in the welded-corner order — verify the textured face's + // UV envelope is the full [0..1] square we configured on import. + float minU = 1.f, maxU = 0.f, minV = 1.f, maxV = 0.f; + for (int k = 0; k < f.cornerCount; ++k) { + minU = std::min(minU, f.u[k]); + maxU = std::max(maxU, f.u[k]); + minV = std::min(minV, f.v[k]); + maxV = std::max(maxV, f.v[k]); + } + EXPECT_NEAR(minU, 0.0f, 1e-3f); + EXPECT_NEAR(maxU, 1.0f, 1e-3f); + EXPECT_NEAR(minV, 0.0f, 1e-3f); + EXPECT_NEAR(maxV, 1.0f, 1e-3f); + EXPECT_GE(f.submeshIndex, 0); + } else { + ++untexturedFaces; + } + } + EXPECT_EQ(texturedFaces, 1); + EXPECT_EQ(untexturedFaces, 1); + + mgr->destroySceneNode(QStringLiteral("PS1PlyMixedRoundTripNode")); + Ogre::MeshManager::getSingleton().remove(meshName); +} + +TEST_F(PS1PLYOgreTest, ExportSurfacesPerCornerVertexColours) +{ + // Build a single-quad PLY imported through the textured-face-material path with three + // *different* per-corner colours. The exporter must surface those colours via + // ExportFaceTexture::cornerColors (not the legacy averaged faceColors path) so the + // caller can emit Psy-Q G/H smooth-shaded MAT entries instead of collapsing to flat C. + ASSERT_TRUE(canLoadMeshFiles()); + + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString plyIn = QDir(dir.path()).filePath(QStringLiteral("smooth_quad.ply")); + { + QFile wf(plyIn); + ASSERT_TRUE(wf.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream ts(&wf); + ts << "@PLY940102\n"; + ts << "4 1 1\n"; + ts << "0 0 0\n1 0 0\n1 1 0\n0 1 0\n"; + ts << "0 0 1\n"; + ts << "1 0 1 2 3 0 0 0 0\n"; + } + + QVector faceMats(1); + faceMats[0].textured = false; + faceMats[0].vertColors = { QColor(40, 40, 40), + QColor(120, 120, 120), + QColor(200, 200, 200), + QColor(160, 160, 160) }; + + const std::string meshName = "PS1PlySmoothQuadMesh"; + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + Ogre::MeshPtr mesh = PS1PLY::importPsyqPlyWithFaceMaterials(plyIn, meshName, faceMats); + ASSERT_TRUE(mesh); + + auto* mgr = Manager::getSingleton(); + Ogre::SceneNode* node = mgr->addSceneNode(QStringLiteral("PS1PlySmoothQuadNode")); + ASSERT_NE(node, nullptr); + Ogre::Entity* ent = mgr->createEntity(node, mesh); + ASSERT_NE(ent, nullptr); + + QTemporaryFile outPly(QDir::tempPath() + QStringLiteral("/qtmesh_ps1ply_smooth_XXXXXX.ply")); + outPly.setAutoRemove(true); + ASSERT_TRUE(outPly.open()); + outPly.close(); + + QVector faceColors; + QVector faceTex; + QString err; + ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, outPly.fileName(), &faceColors, &faceTex, &err)) + << err.toUtf8().constData(); + + // The untextured path goes through the heuristic merge — a single coplanar quad can + // either come back as 1 quad or 2 tris depending on whether normals agree on the seam. + // In either shape the exporter must emit per-corner colours, *and* at least one + // corner of one output face must differ from the others (i.e. the gradient survives). + ASSERT_FALSE(faceTex.isEmpty()); + + bool anyCornerColors = false; + bool anyGradient = false; + for (const auto& f : faceTex) { + if (!f.hasCornerColors) + continue; + anyCornerColors = true; + const int n = std::min(f.cornerCount, 4); + for (int k = 1; k < n; ++k) { + const QColor& a = f.cornerColors[0]; + const QColor& b = f.cornerColors[static_cast(k)]; + if (a.red() != b.red() || a.green() != b.green() || a.blue() != b.blue()) + anyGradient = true; + } + } + EXPECT_TRUE(anyCornerColors) << "Exporter did not surface per-corner vertex colours."; + EXPECT_TRUE(anyGradient) << "Per-corner colour gradient was collapsed during export."; + + mgr->destroySceneNode(QStringLiteral("PS1PlySmoothQuadNode")); + Ogre::MeshManager::getSingleton().remove(meshName); +} + TEST_F(PS1PLYOgreTest, ImportQuadThenExportKeepsSingleQuadFaceLine) { ASSERT_TRUE(canLoadMeshFiles()); @@ -350,7 +586,7 @@ TEST_F(PS1PLYOgreTest, ImportQuadThenExportKeepsSingleQuadFaceLine) outPly.close(); QString err; - ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, outPly.fileName(), nullptr, &err)) << err.toUtf8().constData(); + ASSERT_TRUE(PS1PLY::exportPsyqPlyFromEntity(ent, outPly.fileName(), nullptr, nullptr, &err)) << err.toUtf8().constData(); mgr->destroySceneNode(QStringLiteral("PS1PlyQuadImportNode")); Ogre::MeshManager::getSingleton().remove(meshName); diff --git a/src/PS1/PS1RSD_test.cpp b/src/PS1/PS1RSD_test.cpp index a5b00eef6..a66156c19 100644 --- a/src/PS1/PS1RSD_test.cpp +++ b/src/PS1/PS1RSD_test.cpp @@ -68,3 +68,35 @@ TEST(PS1RSD, WriteAndParse_RoundTrip) EXPECT_EQ(out.textures[0], "T0.TIM"); } +TEST(PS1RSD, ParseBlenderExporterTextureLayout) +{ + // Reproduces the descriptor produced by the PlayStation-RSD-Blender exporter when + // a single texture is bound (e.g. Wood.jpg). The parser must accept .jpg / .png + // entries even though the historical Psy-Q toolchain emitted .tim. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QString rsdPath = QDir(dir.path()).filePath(QStringLiteral("blender.rsd")); + const QByteArray rsdBytes = + "#RSD data describing the relationships to the PLY, MAT, and texture files\n" + "@RSD940102 \n" + "PLY=Example Project.ply\n" + "MAT=Example Project.mat\n" + "NTEX=1\n" + "TEX[0]=Wood.jpg\n"; + + QFile f(rsdPath); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + ASSERT_EQ(f.write(rsdBytes), rsdBytes.size()); + f.close(); + + PS1RSD::RsdDescriptor d; + QString err; + ASSERT_TRUE(PS1RSD::parseRsdFile(rsdPath, d, &err)) << err.toStdString(); + EXPECT_EQ(d.ntex, 1); + ASSERT_EQ(d.textures.size(), 1); + EXPECT_EQ(d.textures[0], "Wood.jpg"); + EXPECT_EQ(d.plyPath, "Example Project.ply"); + EXPECT_EQ(d.matPath, "Example Project.mat"); +} + diff --git a/src/test_main.cpp b/src/test_main.cpp index 60c6ba76c..e5bf4dccb 100644 --- a/src/test_main.cpp +++ b/src/test_main.cpp @@ -59,6 +59,14 @@ static void testMessageHandler(QtMsgType type, const QMessageLogContext& ctx, co int main(int argc, char **argv) { +#ifdef Q_OS_LINUX + // Force xcb on Linux so that Ogre's externalWindowHandle path + // receives a real X11 XID (Wayland's wl_surface handle is incompatible + // and silently breaks createRenderWindow under Xvfb / desktop sessions). + if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "xcb"); +#endif + QApplication app(argc, argv); // Suppress Ogre log output (debug spam from Root, RenderSystem, plugins).