From cf1d1a31fc899ca53c536030a53fad35de718287 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 01:30:14 -0400 Subject: [PATCH 01/10] feat(PS1/RSD): textured RSD import/export round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full texture support to the Sony Psy-Q RSD importer and exporter so assets produced by the PlayStation-RSD Blender exporter (e.g. Wood.jpg referenced by Example Project.rsd) round-trip with materials and UVs preserved. PS1MAT - Promote MatEntry from a single QColor to a full material description: shadingChar (F/S/G), typeChar (C/G/T/D/H), textured flag, textureIndex, per-corner UVs (PS1 pixel coordinates), and 1-4 per-vertex colours. - Rewrite parseMatFile and writeMatFile to handle every Psy-Q material type with its correct payload (single colour C, smooth-shaded G, plain texture T, textured-flat-colour D, textured-smooth-colour H), tolerating the slight count variants tris/quads use in the wild. PS1PLY - Add importPsyqPlyWithFaceMaterials(): one submesh per textureIndex bucket plus one for untextured faces, each with per-corner positions, normals, UVs and vertex colours. Submesh materials are named PLY/_texN / PLY/_solid so the RSD importer can rebind Ogre textures. - Extend exportPsyqPlyFromEntity() with an optional outFaceTextures sink that captures per-face textured flag, source submesh index, corner count and UVs. Textured submeshes skip the heuristic quad-merge so UVs stay intact, while untextured paths keep existing behaviour. MeshImporterExporter - Load non-TIM RSD textures (JPG/PNG/BMP/...) via Ogre codecs with a QImage fallback and stash dimensions so UVs can be normalised on import. - When the parsed MAT exposes textured entries, build PS1PLY::FaceMaterial per face and route imports through the new textured PLY path; after the entity is created, bind the right Ogre texture to each _texN submesh. - On RSD export, synthesise MAT entries (T for textured faces, C for untextured), copy referenced texture images next to the .rsd, and emit matching NTEX / TEX[] descriptor entries. Tests - New PS1MAT_test.cpp covering C/G/T/H parse paths plus a mixed write-and-reread round trip. - PS1RSD: new ParseBlenderExporterTextureLayout case for the NTEX + external JPG layout produced by the Blender exporter. - PS1PLY: new Ogre-backed tests (ImportWithFaceMaterials_SplitsTexturedAndSolidIntoSubmeshes, TexturedPlyRoundTrip_ExportRecoversPerFaceUvAndTextureFlag) exercising the textured import + export sinks end-to-end. - test_main.cpp: force QT_QPA_PLATFORM=xcb on Linux so Ogre's externalWindowHandle path gets a valid X11 XID under Xvfb / Wayland sessions — without this, UnitTests was failing locally with "tryInitOgre() failed". Manual verification with the user-supplied 'PlayStation-RSD-exporter-for-Blender-3.2.1/Example Project.rsd': qtmesh info reports 2 submeshes (solid + tex0) with Wood.jpg in textures; qtmesh convert round-trips the file and emits RoundTrip.rsd referencing RoundTrip.{ply,mat} and a copied Wood.png. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 422 ++++++++++++++++++++++--- src/PS1/PS1MAT.cpp | 336 +++++++++++++++++--- src/PS1/PS1MAT.h | 44 ++- src/PS1/PS1MAT_test.cpp | 212 +++++++++++++ src/PS1/PS1PLY.cpp | 584 ++++++++++++++++++++++++++++++++++- src/PS1/PS1PLY.h | 40 ++- src/PS1/PS1PLY_test.cpp | 163 +++++++++- src/PS1/PS1RSD_test.cpp | 32 ++ src/test_main.cpp | 8 + 9 files changed, 1742 insertions(+), 99 deletions(-) create mode 100644 src/PS1/PS1MAT_test.cpp diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 711535aac..91c05668e 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -32,12 +32,15 @@ THE SOFTWARE. #include #include #include +#include #include #include #include #include +#include #include #include +#include #include #include "OgreXML/OgreXMLMeshSerializer.h" @@ -1206,6 +1209,79 @@ static QString firstMaterialNameInOgreMaterialScript(const QByteArray& script) return {}; } +/// Load `texPath` into Ogre as a manual texture under `resourceName`, replacing any existing +/// 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 +1487,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 +1497,69 @@ 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)) continue; + Ogre::Image img; - QString timErr; - if (!PS1TIM::loadTimToOgreImage(timPath, img, &timErr)) - continue; + bool loaded = false; + const QString suffix = QFileInfo(texPath).suffix().toLower(); + if (suffix == QStringLiteral("tim")) { + QString timErr; + 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. + const QString resName = QFileInfo(texPath).completeBaseName() + + QStringLiteral(".") + + (suffix.isEmpty() ? QStringLiteral("tex") : suffix); 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; + + 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()); + } else { + // Non-TIM raster format (PNG/JPG/BMP/TGA…) + QString extErr; + if (loadExternalTextureForRsd(texPath, ogreName, &extErr)) { + const QImage qprobe(texPath); + rsdTexSlots[ti].resourceName = resName; + rsdTexSlots[ti].width = qprobe.isNull() ? 0 : qprobe.width(); + rsdTexSlots[ti].height = qprobe.isNull() ? 0 : qprobe.height(); + } else { + Ogre::LogManager::getSingleton().logMessage( + "Warning: RSD texture load failed for " + + texPath.toStdString() + ": " + extErr.toStdString()); + } + } + 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 +1585,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 +1608,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 +1631,49 @@ 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; + fm.textured = me.textured; + fm.textureIndex = me.textureIndex; + int texW = 256, texH = 256; + if (me.textured + && me.textureIndex >= 0 + && me.textureIndex < static_cast(rsdTexSlots.size())) { + 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 +1704,48 @@ 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); + pass->setVertexColourTracking(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 +1765,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 +2099,164 @@ 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); + ot.outFile = ot.resourceName; + 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. Mix textured (T) and untextured (C) + // types based on whether the source submesh provided UVs + a texture. Texture indices + // map through `submeshToTexSlot` so a single texture used by multiple submeshes still + // collapses to a single RSD slot. + const bool haveColors = !faceColors.isEmpty() + && faceColors.size() == static_cast(faceTexInfos.size()); + const bool haveTexInfo = !faceTexInfos.isEmpty(); + + 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; + me.shadingChar = 'F'; + + const PS1PLY::ExportFaceTexture& eft = haveTexInfo + ? faceTexInfos[fi] + : PS1PLY::ExportFaceTexture{}; + const QColor faceColor = haveColors ? faceColors[fi] : QColor(255, 255, 255); + + const int slotIt = (eft.textured && submeshToTexSlot.count(eft.submeshIndex)) + ? submeshToTexSlot[eft.submeshIndex] + : -1; + if (eft.textured && slotIt >= 0) { + me.typeChar = 'T'; + 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; + const int corners = (eft.cornerCount == 4) ? 4 : 3; + 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))); + } + me.rgb = QColor(255, 255, 255); + } else { + me.typeChar = 'C'; + me.vertColors.push_back(faceColor); + me.rgb = faceColor; + } 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. + // We use Ogre's in-memory image (Image::loadDynamicImage from the bound texture) and + // write a PNG when the original encoding is unknown. + for (auto& ot : rsdOutTextures) { + const QString outPath = outFi.absolutePath() + QDir::separator() + ot.outFile; + if (QFileInfo::exists(outPath)) + continue; // Skip if a file with that name already lives in the output dir. + try { + auto tex = Ogre::TextureManager::getSingleton().getByName(ot.resourceName.toStdString()); + if (!tex) + continue; + Ogre::Image img; + tex->convertToImage(img, true); + if (img.getWidth() == 0 || img.getHeight() == 0) + continue; + const QImage qi(img.getData(), + static_cast(img.getWidth()), + static_cast(img.getHeight()), + QImage::Format_RGBA8888); + // Always save a PNG copy with the same basename (lossless, widely supported). + const QString png = outFi.absolutePath() + QDir::separator() + + QFileInfo(ot.outFile).completeBaseName() + QStringLiteral(".png"); + qi.copy().save(png, "PNG"); + ot.outFile = QFileInfo(png).fileName(); + } catch (const std::exception&) { + // ignored — texture remains referenced by its original resource name. + } + } 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..5fe5ea3da 100644 --- a/src/PS1/PS1MAT.cpp +++ b/src/PS1/PS1MAT.cpp @@ -14,72 +14,254 @@ The MIT License #include #include +#include + 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(); + + QFile f(matPath); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + if (outError) *outError = QStringLiteral("Could not open MAT file."); 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; @@ -114,12 +296,92 @@ 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]); + } + 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) { 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..0b0bbf272 --- /dev/null +++ b/src/PS1/PS1MAT_test.cpp @@ -0,0 +1,212 @@ +#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, 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..879c018c6 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -880,6 +880,10 @@ 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{}; }; struct PsyqWeldedTri { @@ -1045,9 +1049,494 @@ 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; +}; + +struct TexturedSubmeshSoup { + int textureIndex = -1; ///< -1 = untextured submesh + bool hasColor = false; + std::vector corners; ///< multiple of 3 (triangle list). +}; + +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) +{ + 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); + } + 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); + + 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(); + + 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(); + return mesh; +} + +} // namespace + +Ogre::MeshPtr importPsyqPlyWithFaceMaterials(const QString& filePath, + const std::string& meshName, + const QVector& faceMaterials) +{ + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) + 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)]; + const int submeshKey = fm.textured ? std::max(0, fm.textureIndex) : -1; + TexturedSubmeshSoup& soup = getSoup(submeshKey); + + const bool hasFaceColors = (fm.vertColors.size() == pf.corners); + auto cornerColor = [&](int corner) -> Ogre::RGBA { + if (hasFaceColors) + 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) { + 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); + } else if (pf.corners == 4) { + // Match TMD quad triangulation: (v0,v1,v2) + (v1,v2,v3). + 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); + 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); + } + } + + return buildMeshFromTexturedSoups(meshName, soups); +} + bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, const QString& plyPath, QVector* outFaceColors, + QVector* outFaceTextures, QString* outError) { if (!entity || !entity->getMesh().get()) { @@ -1064,11 +1553,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 +1581,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); } @@ -1332,6 +1829,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,12 +1858,15 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, std::vector smN1; std::vector smN2; QVector smFaceCols; + 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); for (size_t t = 0; t < triCount; ++t) { @@ -1412,6 +1927,19 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, smN1.push_back(wn1); smN2.push_back(wn2); + 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) { const Ogre::ColourValue cv0 = decodePackedColour(sd.colEl, static_cast(c0)); const Ogre::ColourValue cv1 = decodePackedColour(sd.colEl, static_cast(c1)); @@ -1428,10 +1956,48 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, QVector* colorMerge = collectFaceColors && smFaceCols.size() == static_cast(smI0.size()) ? &smFaceCols : nullptr; - mergeSubmeshTrisToQuads(smI0, smI1, smI2, smN0, smN1, smN2, weldedPos, colorMerge, subFaces); + 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; + } + subFaces.push_back(pf); + } + } else { + mergeSubmeshTrisToQuads(smI0, smI1, smI2, smN0, smN1, smN2, weldedPos, colorMerge, 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,6 +2025,20 @@ 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; + 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()); diff --git a/src/PS1/PS1PLY.h b/src/PS1/PS1PLY.h index 3ef570dec..fda9db031 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,39 @@ 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. +}; + /// 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 +85,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..2ca47fac2 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,163 @@ 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, ImportQuadThenExportKeepsSingleQuadFaceLine) { ASSERT_TRUE(canLoadMeshFiles()); @@ -350,7 +507,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). From cb8f2e7f12c61ea548c0f8606037412533d998dc Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 01:54:12 -0400 Subject: [PATCH 02/10] fix(PS1): address CodeRabbit review on RSD texture support Correctness: - PS1PLY: reject negative textureIndex in importPsyqPlyWithFaceMaterials (route invalid index to untextured bucket instead of slot 0). - PS1PLY: preserve per-corner UVs in the Ngon export path so polygons and fanned triangles populate PsyqExportFace::hasUv + u/v_uv. - MeshImporterExporter: convert non-RGBA Ogre image data via PixelUtil::bulkPixelConversion before constructing the QImage used for texture PNG sidecars, and pass an explicit row stride. Nitpick: - MeshImporterExporter: read texture width/height from the loaded Ogre::TexturePtr instead of redecoding the source file with QImage. Instrumentation: - Add Sentry breadcrumbs (file.import / file.export) around PS1 MAT and PS1 PLY open/parse/write success and failure paths. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 31 ++++++++++++++-- src/PS1/PS1MAT.cpp | 15 ++++++++ src/PS1/PS1PLY.cpp | 72 ++++++++++++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 91c05668e..4487ab2d0 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -67,6 +67,7 @@ THE SOFTWARE. #include "EditModeController.h" #include #include +#include #ifndef WIN32 #include @@ -1539,10 +1540,15 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad // Non-TIM raster format (PNG/JPG/BMP/TGA…) QString extErr; if (loadExternalTextureForRsd(texPath, ogreName, &extErr)) { - const QImage qprobe(texPath); rsdTexSlots[ti].resourceName = resName; - rsdTexSlots[ti].width = qprobe.isNull() ? 0 : qprobe.width(); - rsdTexSlots[ti].height = qprobe.isNull() ? 0 : qprobe.height(); + // 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()); + } } else { Ogre::LogManager::getSingleton().logMessage( "Warning: RSD texture load failed for " @@ -2226,9 +2232,26 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u tex->convertToImage(img, true); if (img.getWidth() == 0 || img.getHeight() == 0) continue; - const QImage qi(img.getData(), + // Force RGBA8 layout — Ogre textures may live in PF_A8R8G8B8 / BGRA / DXT / + // float formats and feeding any of those into QImage::Format_RGBA8888 would + // either misorder channels or, for compressed/non-byte formats, walk past + // the buffer end during the save. + 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); // Always save a PNG copy with the same basename (lossless, widely supported). const QString png = outFi.absolutePath() + QDir::separator() diff --git a/src/PS1/PS1MAT.cpp b/src/PS1/PS1MAT.cpp index 5fe5ea3da..dcfd266c1 100644 --- a/src/PS1/PS1MAT.cpp +++ b/src/PS1/PS1MAT.cpp @@ -10,12 +10,15 @@ The MIT License #include "PS1/PS1MAT.h" #include +#include #include #include #include #include +#include "SentryReporter.h" + namespace PS1MAT { namespace { @@ -231,9 +234,12 @@ bool parseMatFile(const QString& matPath, QVector& outEntries, QString { 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; } @@ -280,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; } @@ -386,8 +397,12 @@ bool writeMatFile(const QString& matPath, const QVector& entries, QStr 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/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 879c018c6..e14b5a667 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 @@ -1456,9 +1458,13 @@ 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)) + 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); @@ -1490,7 +1496,9 @@ Ogre::MeshPtr importPsyqPlyWithFaceMaterials(const QString& filePath, for (size_t fi = 0; fi < faces.size(); ++fi) { const PsyqFace& pf = faces[fi]; const FaceMaterial& fm = faceMaterials[static_cast(fi)]; - const int submeshKey = fm.textured ? std::max(0, fm.textureIndex) : -1; + // 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); const bool hasFaceColors = (fm.vertColors.size() == pf.corners); @@ -1530,7 +1538,13 @@ Ogre::MeshPtr importPsyqPlyWithFaceMaterials(const QString& filePath, } } - return buildMeshFromTexturedSoups(meshName, soups); + 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, @@ -1681,8 +1695,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; @@ -1756,6 +1801,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, f.color = avgRgbCorners(poly); f.hasColor = true; } + setPsyqUv(f, poly); allExportFaces.push_back(f); } else if (ps == 4) { for (unsigned int c : poly) @@ -1774,6 +1820,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, f.color = avgRgbCorners(poly); f.hasColor = true; } + setPsyqUv(f, poly); allExportFaces.push_back(f); } else { ensureMeshCorner(poly[0]); @@ -1796,11 +1843,22 @@ 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); 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(); @@ -2043,9 +2101,12 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, 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; } @@ -2071,8 +2132,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; } From 4d6fade4118a89f6b98df460996aeae711ec2d28 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 02:20:00 -0400 Subject: [PATCH 03/10] fix(PS1): address CodeRabbit review 2 on RSD texture support Correctness: - Scope preloaded RSD texture resources by absolute path. Adds scopedRsdResourceName() (hash-suffixed: `Wood__abcd1234.jpg`) so two RSDs that both ship a `Wood.jpg` don't collide in Ogre's global TextureManager registry. rsdResourceNameToBasename() recovers the clean basename on export. - Preserve per-corner vertex colours on textured submeshes after applying the RSD texture: only force TVC_NONE when the submesh has no VES_DIFFUSE stream; otherwise leave TVC_DIFFUSE so Psy-Q H/D/G materials still tint the texture. - Always refresh texture sidecars on export. Drops the QFileInfo::exists short-circuit (which silently left the .rsd referencing stale content from a previous export) and normalises ot.outFile via rsdResourceNameToBasename() so scoped resource names land as plain filenames next to the .rsd. Instrumentation: - Add per-texture Sentry breadcrumbs (file.import) for missing, TIM-loaded, externally-loaded, and failed cases inside the RSD texture preload loop. Verified with the Blender exporter Example Project.rsd: import + round- trip now generates Wood.png next to RoundTrip.rsd and re-import returns 2 submeshes / 110 triangles as expected. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 97 +++++++++++++++++++++++++++++++----- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 4487ab2d0..2a5cd9703 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -31,6 +31,7 @@ THE SOFTWARE. #include #include #include +#include #include #include #include @@ -1211,6 +1212,40 @@ static QString firstMaterialNameInOgreMaterialScript(const QByteArray& script) } /// 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. @@ -1508,22 +1543,30 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad 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)) + 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(); - if (suffix == QStringLiteral("tim")) { - QString timErr; + QString timErr; + if (suffix == QStringLiteral("tim")) loaded = PS1TIM::loadTimToOgreImage(texPath, img, &timErr); - } - const QString resName = QFileInfo(texPath).completeBaseName() - + QStringLiteral(".") - + (suffix.isEmpty() ? QStringLiteral("tex") : suffix); + // 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(); + 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)) @@ -1536,6 +1579,14 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad 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; @@ -1549,10 +1600,17 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad 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()) @@ -1743,7 +1801,17 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad pass->setLightingEnabled(true); pass->setAmbient(1.0f, 1.0f, 1.0f); pass->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); - pass->setVertexColourTracking(Ogre::TVC_NONE); + // 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(); } } @@ -2150,7 +2218,11 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u } else { OutTex ot; ot.resourceName = QString::fromStdString(texName); - ot.outFile = ot.resourceName; + // 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. + ot.outFile = rsdResourceNameToBasename(ot.resourceName); auto tex = Ogre::TextureManager::getSingleton().getByName(texName); if (tex) { ot.width = static_cast(tex->getWidth()); @@ -2219,11 +2291,10 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // Copy referenced textures next to the .rsd so the descriptor stays self-contained. // We use Ogre's in-memory image (Image::loadDynamicImage from the bound texture) and - // write a PNG when the original encoding is unknown. + // write a PNG when the original encoding is unknown. We do not short-circuit on + // existing files: re-exporting should always refresh the sidecar so the new .rsd + // never references stale image content from a previous export. for (auto& ot : rsdOutTextures) { - const QString outPath = outFi.absolutePath() + QDir::separator() + ot.outFile; - if (QFileInfo::exists(outPath)) - continue; // Skip if a file with that name already lives in the output dir. try { auto tex = Ogre::TextureManager::getSingleton().getByName(ot.resourceName.toStdString()); if (!tex) From 03961d2734656fdb6f71e60acf380cce1b48aac8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 02:37:16 -0400 Subject: [PATCH 04/10] fix(PS1): address CodeRabbit review 3 on RSD texture support Correctness: - Sanitise per-face MAT texture references before calling the textured PLY importer: a single entry that claims to be textured but points at a missing slot now downgrades to solid (textureIndex=-1) for that face only, instead of forcing importPsyqPlyWithFaceMaterials() to reject the whole batch and drop textures on every face. - Verify QImage::save() succeeded before mutating ot.outFile. QImage signals write failure via the return value (no exception), so the previous unconditional update could leave the .rsd referencing a sidecar that was never written. Failures now log an Ogre error and emit a Sentry breadcrumb; ot.outFile keeps its previous basename so callers can fall back to the originally referenced texture. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 2a5cd9703..ca8d54252 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1703,12 +1703,20 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad for (int fi = 0; fi < rsdMatEntries.size(); ++fi) { const PS1MAT::MatEntry& me = rsdMatEntries[fi]; PS1PLY::FaceMaterial fm; - fm.textured = me.textured; - fm.textureIndex = me.textureIndex; - int texW = 256, texH = 256; - if (me.textured + // 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())) { + && 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) @@ -2325,10 +2333,21 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u static_cast(img.getWidth()) * 4, QImage::Format_RGBA8888); // Always save a PNG copy with the same basename (lossless, widely supported). + // QImage::save() returns false on failure without throwing — only commit the + // updated outFile name when the write succeeded, otherwise the .rsd would + // reference a sidecar that was never created. const QString png = outFi.absolutePath() + QDir::separator() + QFileInfo(ot.outFile).completeBaseName() + QStringLiteral(".png"); - qi.copy().save(png, "PNG"); - ot.outFile = QFileInfo(png).fileName(); + if (qi.copy().save(png, "PNG")) { + ot.outFile = QFileInfo(png).fileName(); + } else { + Ogre::LogManager::getSingleton().logError( + "Failed to write RSD texture sidecar: " + png.toStdString()); + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("RSD texture sidecar write failed: %1") + .arg(QFileInfo(png).fileName())); + } } catch (const std::exception&) { // ignored — texture remains referenced by its original resource name. } From e8f343b8ec26a976731b4a49bc16d25c9a55276a Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 15:46:32 -0400 Subject: [PATCH 05/10] feat(PS1/RSD): preserve per-corner vertex colours on round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blender's RSD exporter encodes baked AO / Gouraud shading as per-corner vertex colours inside Psy-Q `G` (smooth) and `H` (textured smooth) MAT entries -- e.g. a wall quad with corners `152 197 84 120` carries a soft shadow gradient. Our previous exporter averaged the corners into a single `avgRgbCorners()` colour per face and emitted only `C` (flat) or `T` (textured-no-colour) entries, so re-imported meshes lost the gradient and the walls rendered as flat grey instead of the smoothly-shaded original. The fix plumbs per-corner colours through the PLY → MAT pipeline: PS1/PS1PLY: - `ExportFaceTexture` and `PsyqExportFace` gain `hasCornerColors` + `cornerColors[4]` (matches the PLY corner order, zero-padded for tris). - The NGON export path adds `setPsyqCornerColors()` next to the existing `setPsyqUv()` helper -- populates each tri/quad/fanned-tri face. - The non-NGON path collects per-tri-corner colours in `smCornerCols` and threads them through `mergeSubmeshTrisToQuads`, which now resolves each output quad corner from whichever source triangle touched the welded position (`cornerColorForWeldedPos`). Single (non-merged) tris carry their three corner colours straight through. MeshImporterExporter: - The MAT synthesis loop now picks the proper Psy-Q type from the corner- colour variance: textured + smooth corners -> `H` (per-corner tint) textured + uniform corner -> `D` (flat colour) textured + no colour -> `T` untextured + smooth corners -> `G` (smooth Gouraud) untextured + uniform -> `C` - `shadingChar` stays `'F'` to match the Blender exporter convention -- PLY stores per-face normals, so smoothness is encoded via typeChar only. Verified end-to-end with `Example Project.rsd`: the round-trip MAT now preserves all 12 H quads as 24 H tris and keeps 56 of the 50 G quads as smooth G tris (the rest correctly degrade to C only when a single tri's three corners happen to be uniform after the quad split). Adds `PS1PLYOgreTest.ExportSurfacesPerCornerVertexColours` covering the gradient-survival contract via the non-NGON heuristic-merge path. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 68 +++++++++++++++++++---- src/PS1/PS1PLY.cpp | 105 ++++++++++++++++++++++++++++++++--- src/PS1/PS1PLY.h | 6 ++ src/PS1/PS1PLY_test.cpp | 79 ++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index ca8d54252..1978bc639 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2243,48 +2243,94 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u submeshToTexSlot[static_cast(si)] = slot; } - // Synthesise MAT entries: one per output PLY face. Mix textured (T) and untextured (C) - // types based on whether the source submesh provided UVs + a texture. Texture indices - // map through `submeshToTexSlot` so a single texture used by multiple submeshes still - // collapses to a single RSD 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.shadingChar = 'F'; 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.typeChar = 'T'; 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; - const int corners = (eft.cornerCount == 4) ? 4 : 3; 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))); } - me.rgb = QColor(255, 255, 255); + 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'; - me.vertColors.push_back(faceColor); - me.rgb = faceColor; + 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); } diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index e14b5a667..008733f54 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -886,6 +886,11 @@ struct PsyqExportFace { 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 { @@ -917,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, @@ -925,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(); @@ -934,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(); @@ -975,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; @@ -993,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; } @@ -1768,6 +1810,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) { @@ -1780,6 +1829,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); @@ -1802,6 +1860,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, f.hasColor = true; } setPsyqUv(f, poly); + setPsyqCornerColors(f, poly); allExportFaces.push_back(f); } else if (ps == 4) { for (unsigned int c : poly) @@ -1821,6 +1880,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, f.hasColor = true; } setPsyqUv(f, poly); + setPsyqCornerColors(f, poly); allExportFaces.push_back(f); } else { ensureMeshCorner(poly[0]); @@ -1846,6 +1906,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, 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); } } @@ -1916,6 +1977,7 @@ 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); @@ -1926,6 +1988,9 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, 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; @@ -1998,21 +2063,38 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, smUv.push_back({uv0.first, uv0.second, uv1.first, uv1.second, uv2.first, uv2.second}); } - if (collectFaceColors) { + 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; + 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 @@ -2037,10 +2119,17 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, 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, subFaces); + mergeSubmeshTrisToQuads(smI0, smI1, smI2, smN0, smN1, smN2, weldedPos, + colorMerge, cornerColorMerge, subFaces); for (auto& pf : subFaces) pf.submeshIndex = sd.sourceIndex; } @@ -2093,6 +2182,8 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, 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); } } diff --git a/src/PS1/PS1PLY.h b/src/PS1/PS1PLY.h index fda9db031..220e57e3a 100644 --- a/src/PS1/PS1PLY.h +++ b/src/PS1/PS1PLY.h @@ -76,6 +76,12 @@ struct ExportFaceTexture { 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 diff --git a/src/PS1/PS1PLY_test.cpp b/src/PS1/PS1PLY_test.cpp index 2ca47fac2..4f16ee58f 100644 --- a/src/PS1/PS1PLY_test.cpp +++ b/src/PS1/PS1PLY_test.cpp @@ -470,6 +470,85 @@ TEST_F(PS1PLYOgreTest, TexturedPlyRoundTrip_ExportRecoversPerFaceUvAndTextureFla 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()); From f994d9342d214389319c760c8a093b06e9e75db3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 16:06:15 -0400 Subject: [PATCH 06/10] fix(MeshImporterExporter): harden RSD texture-sidecar export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CodeRabbit findings on the smooth-shading commit, both around the texture-sidecar write loop: 1. Output filename collision (`ot.outFile`). `rsdResourceNameToBasename()` strips the asset-scoping hash, so two distinct resources that share a source filename (e.g. two different `Wood.jpg` imports living in `Ogre::TextureManager` under unique `Wood__.jpg` names) collapse to the same `outFile`. The second sidecar overwrites the first and a later TEX[] slot ends up pointing at the wrong image. Now we check the candidate basename against already-claimed entries in `rsdOutTextures` and fall back to the scoped resource filename when a collision is detected — keeps the common case clean (no hash suffix) while staying correct. 2. `.rsd` was written even when the texture sidecar PNG failed to save. `QImage::save()` returns false (it does not throw); previously we logged + breadcrumbed but kept going, leaving TEX[] entries pointing at files that were never written. The export now sets a `sidecarWriteFailed` flag and returns -1 before emitting the `.rsd`. The catch block is upgraded the same way (and now logs/breadcrumbs instead of swallowing the exception). Verified the Example Project.rsd round-trip still succeeds end-to-end and all 26 PS1 tests still pass. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 1978bc639..7cb2812e3 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2230,7 +2230,20 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // 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. - ot.outFile = rsdResourceNameToBasename(ot.resourceName); + 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. + auto basenameAlreadyUsed = [&candidate, &rsdOutTextures] { + for (const auto& prev : rsdOutTextures) + if (prev.outFile == candidate) + return true; + return false; + }; + if (basenameAlreadyUsed()) + candidate = QFileInfo(ot.resourceName).fileName(); + ot.outFile = candidate; auto tex = Ogre::TextureManager::getSingleton().getByName(texName); if (tex) { ot.width = static_cast(tex->getWidth()); @@ -2348,6 +2361,11 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // write a PNG when the original encoding is unknown. We do not short-circuit on // existing files: re-exporting should always refresh the sidecar so the new .rsd // never references stale image content from a previous export. + // + // 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. + bool sidecarWriteFailed = false; for (auto& ot : rsdOutTextures) { try { auto tex = Ogre::TextureManager::getSingleton().getByName(ot.resourceName.toStdString()); @@ -2393,11 +2411,22 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u QStringLiteral("file.export"), QStringLiteral("RSD texture sidecar write failed: %1") .arg(QFileInfo(png).fileName())); + sidecarWriteFailed = true; + break; } - } catch (const std::exception&) { - // ignored — texture remains referenced by its original resource name. + } catch (const std::exception& ex) { + Ogre::LogManager::getSingleton().logError( + std::string("RSD texture sidecar export exception: ") + ex.what()); + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("RSD texture sidecar export exception: %1") + .arg(QString::fromUtf8(ex.what()))); + sidecarWriteFailed = true; + break; } } + if (sidecarWriteFailed) + return -1; PS1RSD::RsdDescriptor rsd; rsd.headerId = QStringLiteral("@RSD940102"); From 1cb59ad90186f5c8b34c78800b6908d2f4c6f67a Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 17:28:46 -0400 Subject: [PATCH 07/10] fix(PS1/RSD): preserve original tri/quad topology and tri G shading on round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blender's RSD exporter writes G entries with 4 RGB triples regardless of face shape, padding triangles with a trailing `0 0 0`. Our import path treated those padded entries as size-mismatched (4 colours but 3-corner PLY tri) and fell back to a single flat colour, so original Gouraud tris showed up as faceted walls in the round-trip render. In parallel, Ogre triangulates every imported quad into two triangles and the post-export heuristic tri→quad merger failed to reconstruct adjacent coplanar quads (e.g. room walls), leaving visible diagonal seams in smooth-shaded surfaces. Fixes: - `importPsyqPlyWithFaceMaterials`: relax the corner-colour check from `==` to `>= pf.corners` so padded MAT entries still populate per-corner shading on tris (extra trailing triples are ignored). - Track parent-face provenance per emitted triangle in the textured submesh soup so `buildMeshFromTexturedSoups` can recover the original Psy-Q tri/quad polygons after welding and persist them as `qtme.faces.` n-gon bindings (the same mechanism FBX/OBJ already use). - `exportPsyqPlyFromEntity`: lift the single-submesh restriction on the n-gon export path and run it per submesh when every submesh exposes a valid binding. The heuristic merger remains as a fallback for meshes imported through other paths. - `PS1MAT::writeMatFile`: pad tri G entries to 4 corners with a trailing `0 0 0` to match the Blender exporter / PS1 emulator convention. Verified: `Example Project.rsd` (24 tris + 43 quads, 5 C + 50 G + 12 H) round-trips to a multiset-identical MAT/PLY pair (zero entries differ). Co-authored-by: Cursor --- src/PS1/PS1MAT.cpp | 6 ++ src/PS1/PS1MAT_test.cpp | 41 +++++++++++ src/PS1/PS1PLY.cpp | 151 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 184 insertions(+), 14 deletions(-) diff --git a/src/PS1/PS1MAT.cpp b/src/PS1/PS1MAT.cpp index dcfd266c1..f399d1146 100644 --- a/src/PS1/PS1MAT.cpp +++ b/src/PS1/PS1MAT.cpp @@ -357,6 +357,12 @@ bool writeMatFile(const QString& matPath, const QVector& entries, QStr } 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; } diff --git a/src/PS1/PS1MAT_test.cpp b/src/PS1/PS1MAT_test.cpp index 0b0bbf272..eee418047 100644 --- a/src/PS1/PS1MAT_test.cpp +++ b/src/PS1/PS1MAT_test.cpp @@ -195,6 +195,47 @@ TEST(PS1MAT, WriteAndRoundTrip_MixedEntries) 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; diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 008733f54..c76809eb6 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -1218,10 +1218,23 @@ struct TexturedCorner { 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 corners; ///< multiple of 3 (triangle list). + std::vector triProv; ///< One entry per tri (corners.size() / 3). }; static Ogre::RGBA qColorToRgba(const QColor& c) @@ -1261,7 +1274,8 @@ static void appendTexturedTri(TexturedSubmeshSoup& out, 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) + bool hasColor, + TexturedTriProvenance prov = {}) { const Ogre::Vector3 fn = faceNormalAfterTransform(verts[v0], verts[v1], verts[v2]); Ogre::Vector3 an = norms[n0] + norms[n1] + norms[n2]; @@ -1289,7 +1303,11 @@ static void appendTexturedTri(TexturedSubmeshSoup& out, 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; } @@ -1315,6 +1333,13 @@ static Ogre::MeshPtr buildMeshFromTexturedSoups( 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]; @@ -1378,6 +1403,54 @@ static Ogre::MeshPtr buildMeshFromTexturedSoups( const size_t nVert = uniqCorners.size(); const size_t nIdx = indices.size(); + // Recover the original Psy-Q face polygons by walking `triProv` in tri order: + // for each parent face we collect the welded vertex index that landed in each + // corner slot (0..2 for tris, 0..3 for quads). The resulting `EditableFace` + // list is persisted as `qtme.faces.` after the submesh is added below, so + // the RSD/PS1 exporter can re-emit the source tri/quad shape verbatim 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). + EditableSubMesh editable; + if (soup.triProv.size() * 3 == soup.corners.size() && !soup.triProv.empty()) { + std::unordered_map faceByParent; + faceByParent.reserve(soup.triProv.size()); + std::vector faceOrder; // Preserve first-seen parent order for stable output. + 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; + auto it = faceByParent.find(prov.parentFaceIdx); + if (it == faceByParent.end()) { + EditableFace ef; + ef.indices.assign(prov.parentCorners, std::numeric_limits::max()); + auto [ins, _] = faceByParent.emplace(prov.parentFaceIdx, std::move(ef)); + it = ins; + faceOrder.push_back(prov.parentFaceIdx); + } + EditableFace& ef = it->second; + for (int k = 0; k < 3; ++k) { + const uint8_t slot = prov.parentCornerSlots[static_cast(k)]; + if (slot < ef.indices.size()) + ef.indices[slot] = indices[ti * 3 + static_cast(k)]; + } + } + editable.faces.reserve(faceOrder.size()); + for (int parent : faceOrder) { + EditableFace& ef = faceByParent[parent]; + bool complete = true; + for (unsigned int vi : ef.indices) { + if (vi == std::numeric_limits::max()) { + complete = false; + break; + } + } + if (complete && ef.isValid()) + editable.faces.push_back(std::move(ef)); + } + } + editableSubMeshes.push_back(std::move(editable)); + Ogre::SubMesh* sm = mesh->createSubMesh(); const std::string slotSuffix = textured ? std::string("_tex") + std::to_string(soup.textureIndex) @@ -1491,6 +1564,20 @@ static Ogre::MeshPtr buildMeshFromTexturedSoups( 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; } @@ -1543,9 +1630,15 @@ Ogre::MeshPtr importPsyqPlyWithFaceMaterials(const QString& filePath, const int submeshKey = (fm.textured && fm.textureIndex >= 0) ? fm.textureIndex : -1; TexturedSubmeshSoup& soup = getSoup(submeshKey); - const bool hasFaceColors = (fm.vertColors.size() == pf.corners); + // 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) + if (hasFaceColors && corner < fm.vertColors.size()) return qColorToRgba(fm.vertColors[corner]); if (fm.color.isValid()) return qColorToRgba(fm.color); @@ -1554,29 +1647,41 @@ Ogre::MeshPtr importPsyqPlyWithFaceMaterials(const QString& filePath, 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); + 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); + 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); + hasColorOnFace, prov1); } } @@ -1665,17 +1770,28 @@ 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) { + // 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. + std::vector>> perSubNgonFaces(subs.size()); + bool useNgonExport = !subs.empty(); + for (size_t si = 0; si < subs.size() && useNgonExport; ++si) { + if (!readNgonFacesFromMesh(mesh.get(), + static_cast(subs[si].sourceIndex), + perSubNgonFaces[si])) { + useNgonExport = false; + break; + } + for (const auto& poly : perSubNgonFaces[si]) { if (poly.size() < 3) { useNgonExport = false; break; } for (unsigned int vid : poly) { - if (vid >= subs[0].vCount) { + if (vid >= subs[si].vCount) { useNgonExport = false; break; } @@ -1719,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) { + 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()) { @@ -1927,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]; From 151279a733f4dab4d410b602a170812e0e392ef4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 17:46:17 -0400 Subject: [PATCH 08/10] feat(RSD): always emit TIM sidecars for exported textures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PS1 hardware and emulators only consume the native TIM texture format, so the previous PNG sidecar produced an .rsd that loaded fine in QtMeshEditor but failed on any real PS1 toolchain. Switch the RSD export path to always invoke `PS1TIM::saveOgreImageToTim16`, regardless of the source format (JPG/PNG/BMP/TGA/etc.) — the 16bpp BGR555 layout matches what the importer already decodes via `PS1TIM::loadTimToOgreImage`, so the round-trip stays self-consistent. The legacy PNG-writing branch is kept as a defensive fallback for the unlikely case the TIM encoder fails (e.g. zero-byte image, exception in pixel conversion), so the descriptor never references a missing file. Verified: `Example Project.rsd` (originally referencing `Wood.jpg`) exports as `TEX[0]=Wood.tim` with a valid 16bpp TIM payload, and the re-import → re-export loop preserves the same `Wood.tim` reference. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 44 ++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 7cb2812e3..96aa04268 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2357,10 +2357,10 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u } // Copy referenced textures next to the .rsd so the descriptor stays self-contained. - // We use Ogre's in-memory image (Image::loadDynamicImage from the bound texture) and - // write a PNG when the original encoding is unknown. We do not short-circuit on - // existing files: re-exporting should always refresh the sidecar so the new .rsd - // never references stale image content from a previous export. + // 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 @@ -2375,10 +2375,28 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u tex->convertToImage(img, true); if (img.getWidth() == 0 || img.getHeight() == 0) continue; - // Force RGBA8 layout — Ogre textures may live in PF_A8R8G8B8 / BGRA / DXT / - // float formats and feeding any of those into QImage::Format_RGBA8888 would - // either misorder channels or, for compressed/non-byte formats, walk past - // the buffer end during the save. + + 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(); + continue; + } + 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)); + + // Defensive fallback: write a PNG so the .rsd at least references something + // a future re-import can decode (the original PNG sidecar path). std::vector rgba; if (img.getFormat() != Ogre::PF_BYTE_RGBA) { const size_t pixels = static_cast(img.getWidth()) * img.getHeight(); @@ -2396,17 +2414,13 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u static_cast(img.getHeight()), static_cast(img.getWidth()) * 4, QImage::Format_RGBA8888); - // Always save a PNG copy with the same basename (lossless, widely supported). - // QImage::save() returns false on failure without throwing — only commit the - // updated outFile name when the write succeeded, otherwise the .rsd would - // reference a sidecar that was never created. - const QString png = outFi.absolutePath() + QDir::separator() - + QFileInfo(ot.outFile).completeBaseName() + QStringLiteral(".png"); + const QString png = outFi.absolutePath() + dirSep + basename + QStringLiteral(".png"); if (qi.copy().save(png, "PNG")) { ot.outFile = QFileInfo(png).fileName(); } else { Ogre::LogManager::getSingleton().logError( - "Failed to write RSD texture sidecar: " + png.toStdString()); + "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") From 4688ca41bf147bdfb284b6bfb6526b5c5fcb986d Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 17:58:33 -0400 Subject: [PATCH 09/10] refactor(PS1/RSD): reduce nesting and complexity for SonarCloud Extract three helpers to keep the new ngon-binding / TIM-sidecar paths under SonarCloud's nesting (S134) and cognitive-complexity (S3776) ceilings without changing behaviour: - `recoverPolygonsFromProvenance` (PS1PLY.cpp): hoists the per-tri parent-face recovery out of `buildMeshFromTexturedSoups`'s body so the build loop only calls a single helper and pushes the resulting `EditableSubMesh` into the binding list. Also switches the inner unordered_map lookup from `find`/`emplace` to `try_emplace` (S6030) and replaces the manual completeness loop with `std::none_of`. - `ngonFacesValid` (PS1PLY.cpp): collapses the multi-submesh n-gon pre-validation loop's nested `break`s into a single `std::all_of` call, removing the S134/S924 violations the loop introduced. - `writeSidecar` / `writePngFallback` (MeshImporterExporter.cpp): pull the TIM-then-PNG sidecar logic out of the export loop so the latter is just `try { ok = writeSidecar(ot); } catch (Ogre::Exception&) {}` with a single break on failure. Also catches `Ogre::Exception` specifically instead of `std::exception` (S1181) since that's the only exception path through `TextureManager::getByName` / `convertToImage` / `PixelUtil::bulkPixelConversion`. The texture-slot collision check now uses `std::any_of` (S5566) instead of a hand-rolled range-for loop. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 145 ++++++++++++++++++----------------- src/PS1/PS1PLY.cpp | 134 ++++++++++++++++---------------- 2 files changed, 143 insertions(+), 136 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 96aa04268..e89b904aa 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2235,13 +2235,10 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // 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. - auto basenameAlreadyUsed = [&candidate, &rsdOutTextures] { - for (const auto& prev : rsdOutTextures) - if (prev.outFile == candidate) - return true; - return false; + const auto collides = [&candidate](const OutTex& prev) { + return prev.outFile == candidate; }; - if (basenameAlreadyUsed()) + if (std::any_of(rsdOutTextures.begin(), rsdOutTextures.end(), collides)) candidate = QFileInfo(ot.resourceName).fileName(); ot.outFile = candidate; auto tex = Ogre::TextureManager::getSingleton().getByName(texName); @@ -2365,76 +2362,86 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // 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 { - auto tex = Ogre::TextureManager::getSingleton().getByName(ot.resourceName.toStdString()); - if (!tex) - continue; - Ogre::Image img; - tex->convertToImage(img, true); - if (img.getWidth() == 0 || img.getHeight() == 0) - continue; - - 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(); - continue; - } - 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)); - - // Defensive fallback: write a PNG so the .rsd at least references something - // a future re-import can decode (the original PNG sidecar path). - 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); - const QString png = outFi.absolutePath() + dirSep + basename + QStringLiteral(".png"); - if (qi.copy().save(png, "PNG")) { - ot.outFile = QFileInfo(png).fileName(); - } else { - 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())); - sidecarWriteFailed = true; - break; - } - } catch (const std::exception& ex) { + ok = writeSidecar(ot); + } catch (const Ogre::Exception& ex) { Ogre::LogManager::getSingleton().logError( - std::string("RSD texture sidecar export exception: ") + ex.what()); + std::string("RSD texture sidecar Ogre exception: ") + ex.what()); SentryReporter::addBreadcrumb( QStringLiteral("file.export"), - QStringLiteral("RSD texture sidecar export exception: %1") + QStringLiteral("RSD texture sidecar Ogre exception: %1") .arg(QString::fromUtf8(ex.what()))); + } + if (!ok) { sidecarWriteFailed = true; break; } diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index c76809eb6..9f63ae87d 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -1237,6 +1237,54 @@ struct TexturedSubmeshSoup { 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; @@ -1403,53 +1451,11 @@ static Ogre::MeshPtr buildMeshFromTexturedSoups( const size_t nVert = uniqCorners.size(); const size_t nIdx = indices.size(); - // Recover the original Psy-Q face polygons by walking `triProv` in tri order: - // for each parent face we collect the welded vertex index that landed in each - // corner slot (0..2 for tris, 0..3 for quads). The resulting `EditableFace` - // list is persisted as `qtme.faces.` after the submesh is added below, so - // the RSD/PS1 exporter can re-emit the source tri/quad shape verbatim 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). - EditableSubMesh editable; - if (soup.triProv.size() * 3 == soup.corners.size() && !soup.triProv.empty()) { - std::unordered_map faceByParent; - faceByParent.reserve(soup.triProv.size()); - std::vector faceOrder; // Preserve first-seen parent order for stable output. - 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; - auto it = faceByParent.find(prov.parentFaceIdx); - if (it == faceByParent.end()) { - EditableFace ef; - ef.indices.assign(prov.parentCorners, std::numeric_limits::max()); - auto [ins, _] = faceByParent.emplace(prov.parentFaceIdx, std::move(ef)); - it = ins; - faceOrder.push_back(prov.parentFaceIdx); - } - EditableFace& ef = it->second; - for (int k = 0; k < 3; ++k) { - const uint8_t slot = prov.parentCornerSlots[static_cast(k)]; - if (slot < ef.indices.size()) - ef.indices[slot] = indices[ti * 3 + static_cast(k)]; - } - } - editable.faces.reserve(faceOrder.size()); - for (int parent : faceOrder) { - EditableFace& ef = faceByParent[parent]; - bool complete = true; - for (unsigned int vi : ef.indices) { - if (vi == std::numeric_limits::max()) { - complete = false; - break; - } - } - if (complete && ef.isValid()) - editable.faces.push_back(std::move(ef)); - } - } - editableSubMeshes.push_back(std::move(editable)); + // 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 @@ -1776,29 +1782,23 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, // 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) { - if (!readNgonFacesFromMesh(mesh.get(), - static_cast(subs[si].sourceIndex), - perSubNgonFaces[si])) { - useNgonExport = false; - break; - } - for (const auto& poly : perSubNgonFaces[si]) { - if (poly.size() < 3) { - useNgonExport = false; - break; - } - for (unsigned int vid : poly) { - if (vid >= subs[si].vCount) { - useNgonExport = false; - break; - } - } - if (!useNgonExport) - break; - } + const bool gotBinding = readNgonFacesFromMesh(mesh.get(), + static_cast(subs[si].sourceIndex), + perSubNgonFaces[si]); + useNgonExport = gotBinding && ngonFacesValid(perSubNgonFaces[si], subs[si].vCount); } std::vector weldedPos; From c13551557025f4469eb2759a38600e3b5796d4e1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 18:16:27 -0400 Subject: [PATCH 10/10] chore(PS1/RSD): minor Sonar cleanups in refactored sidecar/ngon paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make `sd` a `const SubData&` in the per-submesh ngon export loop (cpp:S5350) — the body never mutates the struct, only locks/unlocks buffers via its shared-ptr members. - Inline the texture-slot collision lambda directly into the `std::any_of` call (cpp:S6004) so the predicate's scope is bounded to the if statement that uses it. Co-authored-by: Cursor --- src/MeshImporterExporter.cpp | 8 ++++---- src/PS1/PS1PLY.cpp | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index e89b904aa..52c771d21 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2235,10 +2235,10 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // 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. - const auto collides = [&candidate](const OutTex& prev) { - return prev.outFile == candidate; - }; - if (std::any_of(rsdOutTextures.begin(), rsdOutTextures.end(), collides)) + 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); diff --git a/src/PS1/PS1PLY.cpp b/src/PS1/PS1PLY.cpp index 9f63ae87d..caf041bc4 100644 --- a/src/PS1/PS1PLY.cpp +++ b/src/PS1/PS1PLY.cpp @@ -1840,7 +1840,7 @@ bool exportPsyqPlyFromEntity(const Ogre::Entity* entity, // 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) { - SubData& sd = subs[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;