diff --git a/.github/actions/qtmesh/action.yml b/.github/actions/qtmesh/action.yml index 9f1eaa8aa..45725f5b3 100644 --- a/.github/actions/qtmesh/action.yml +++ b/.github/actions/qtmesh/action.yml @@ -3,7 +3,7 @@ description: 'Run qtmesh CLI for 3D mesh operations (info, convert, fix, anim, s inputs: command: - description: 'Subcommand: info, fix, convert, anim, validate, lod, pose, scan' + description: 'Subcommand: info, fix, convert, anim, validate, lod, pose, turntable, scan, material, optimize, …' required: true input-file: description: 'Directory or file to scan (relative to workspace). Defaults to .' diff --git a/CLAUDE.md b/CLAUDE.md index d40b16595..987135c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,8 @@ qtmesh anim model.fbx --bake-fps 30 -o uniform.fbx # re-grid every track to qtmesh anim model.fbx --bake-fps 60 --animation "Run" -o out.fbx # bake one animation at 60 FPS qtmesh pose model.fbx --animation "Walk" --time 0.5 -o posed.stl # export single frame qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # export N evenly spaced frames +qtmesh turntable model.fbx -o turntable.png # PNG sprite sheet (12 frames default) +qtmesh turntable model.fbx -o frame_%02d.png --frames 24 --axis y --camera-height 25 qtmesh validate model.fbx # validate mesh (exit 1 if errors found) qtmesh validate model.fbx --json # validation results as JSON qtmesh lod model.fbx --info # show LOD levels @@ -85,7 +87,7 @@ qtmesh optimize character.fbx --target-tris 5000 --simplify-rotation-deg-tol 1.0 qtmesh optimize character.fbx --simplify-preset aggressive -o lo.fbx # 1e-2/1°/1e-2 — ~20× key reduction, visible drift ``` -CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. +CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `turntable`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. If Xcode SDK is updated, clear CMake cache (`rm build_local/CMakeCache.txt`) and reconfigure. @@ -188,7 +190,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas ### CLI Pipeline - **CLIPipeline** (`src/CLIPipeline.h/cpp`): Headless command-line interface for mesh operations. All static methods — entry point is `CLIPipeline::run(argc, argv)`. -- Subcommands: `info`, `fix`, `convert`, `anim` (list/rename/merge), `validate`, `lod`, `pose`, `scan`, `material`, `pack-textures`, `normal-from-height`, `memory`, `analyze`, `vertex-cache`, `decimate`, `atlas`, `atlas-apply`, `optimize`. +- Subcommands: `info`, `fix`, `convert`, `anim` (list/rename/merge), `validate`, `lod`, `pose`, `turntable`, `scan`, `material`, `pack-textures`, `normal-from-height`, `memory`, `analyze`, `vertex-cache`, `decimate`, `atlas`, `atlas-apply`, `optimize`. - Activated via `qtmesh` symlink (created at build time), `--cli` flag, or recognized subcommand as first arg. - Redirects stdout to stderr (Ogre/Qt noise) and writes CLI output to the original stdout fd. Uses `_exit()` to avoid Ogre static destructor crashes on macOS. - **AnimationMerger** (`src/AnimationMerger.h/cpp`): Public `renameAnimation()` static method used by both CLI and GUI for animation renaming. diff --git a/action.yml b/action.yml index 9907c2e6b..c44c3dac5 100644 --- a/action.yml +++ b/action.yml @@ -8,7 +8,7 @@ branding: inputs: command: - description: 'Subcommand: scan, info, validate, convert, fix, anim, lod, pose' + description: 'Subcommand: scan, info, validate, convert, fix, anim, lod, pose, turntable' required: true input-file: description: 'Directory or file to scan (relative to workspace). Defaults to . (workspace root).' diff --git a/src/Assimp/MaterialProcessor.cpp b/src/Assimp/MaterialProcessor.cpp index d17916c6f..33ff42fd1 100644 --- a/src/Assimp/MaterialProcessor.cpp +++ b/src/Assimp/MaterialProcessor.cpp @@ -57,26 +57,33 @@ Ogre::MaterialPtr MaterialProcessor::processMaterial(const aiMaterial *material, Ogre::Pass* xPass = ensureFirstPass(existingMaterial); if (!xPass) return existingMaterial; - // Normal map (legacy DIFFUSE/HEIGHT/NORMAL_CAMERA path → RTSS). - aiString existingNormalPath; - if(AI_SUCCESS == material->GetTexture(aiTextureType_NORMALS, 0, &existingNormalPath) - || AI_SUCCESS == material->GetTexture(aiTextureType_HEIGHT, 0, &existingNormalPath) - || AI_SUCCESS == material->GetTexture(aiTextureType_NORMAL_CAMERA, 0, &existingNormalPath)) { - std::string normalTexPath = existingNormalPath.C_Str(); - std::string normalFilename = normalTexPath.substr(normalTexPath.find_last_of("/\\") + 1); - Ogre::TexturePtr normalTexPtr = Ogre::TextureManager::getSingleton().getByName(normalFilename); - if(!normalTexPtr) { + Ogre::String stagedNormalTex; + auto stageNormalFromAssimp = [&](aiTextureType type) -> bool { + aiString path; + if (material->GetTexture(type, 0, &path) != AI_SUCCESS) + return false; + const std::string texPath = path.C_Str(); + const std::string filename = + texPath.substr(texPath.find_last_of("/\\") + 1); + if (filename.empty()) + return false; + Ogre::TexturePtr tex = Ogre::TextureManager::getSingleton().getByName(filename); + if (!tex) { try { - normalTexPtr = loadTexture(normalFilename, existingNormalPath, scene); + tex = loadTexture(filename, path, scene); } catch (...) { - Ogre::LogManager::getSingleton().logMessage("MaterialProcessor: Failed to load normal map '" + normalFilename + "' for existing material '" + materialName + "'"); + return false; } } - if(normalTexPtr) { - Ogre::LogManager::getSingleton().logMessage("MaterialProcessor: Applying RTSS normal map '" + normalFilename + "' to existing material '" + materialName + "'"); - applyRTSSNormalMap(existingMaterial, normalTexPtr->getName()); - } - } + if (!tex) + return false; + stagedNormalTex = tex->getName(); + return true; + }; + if (!stageNormalFromAssimp(aiTextureType_NORMALS)) + stageNormalFromAssimp(aiTextureType_NORMAL_CAMERA); + if (stagedNormalTex.empty()) + stageNormalFromAssimp(aiTextureType_HEIGHT); // PBR slots: add any that are missing on the existing material. // Without this, reimporting an FBX whose material already exists @@ -115,13 +122,8 @@ Ogre::MaterialPtr MaterialProcessor::processMaterial(const aiMaterial *material, addMissingSlot(aiTextureType_EMISSION_COLOR, "emissive"); addMissingSlot(aiTextureType_BASE_COLOR, "albedo"); - // Wire FFP slot operations on the augmented material so the - // newly-added PBR TUS render the same as they would after a - // no-op Apply in the Material Editor. Guarded behind the render- - // system check (lightweight test fixture has Ogre::Root only). if (Ogre::Root::getSingletonPtr() && Ogre::Root::getSingletonPtr()->getRenderSystem()) { - RTShaderHelper::wirePbrSlotsForFFP(existingMaterial.get()); - existingMaterial->compile(); + RTShaderHelper::finalizeShaderGenMaterial(existingMaterial, stagedNormalTex); } return existingMaterial; @@ -178,24 +180,32 @@ Ogre::MaterialPtr MaterialProcessor::processMaterial(const aiMaterial *material, // reimport, so this lets a Maya-Stingray-styled // FBX round-trip its normal map back into our // RTSS pipeline. - aiString normalPath; - bool hasNormalMap = (AI_SUCCESS == material->GetTexture(aiTextureType_NORMALS, 0, &normalPath)) - || (AI_SUCCESS == material->GetTexture(aiTextureType_HEIGHT, 0, &normalPath)) - || (AI_SUCCESS == material->GetTexture(aiTextureType_NORMAL_CAMERA, 0, &normalPath)); - if(hasNormalMap) { - std::string normalTexPath = normalPath.C_Str(); - std::string normalFilename = normalTexPath.substr(normalTexPath.find_last_of("/\\") + 1); - Ogre::TexturePtr normalTexPtr = Ogre::TextureManager::getSingleton().getByName(normalFilename); - if(!normalTexPtr) { + Ogre::String stagedNormalTex; + auto stageNormalFromAssimp = [&](aiTextureType type) -> bool { + aiString path; + if (material->GetTexture(type, 0, &path) != AI_SUCCESS) + return false; + const std::string texPath = path.C_Str(); + const std::string filename = texPath.substr(texPath.find_last_of("/\\") + 1); + if (filename.empty()) + return false; + Ogre::TexturePtr tex = Ogre::TextureManager::getSingleton().getByName(filename); + if (!tex) { try { - normalTexPtr = loadTexture(normalFilename, normalPath, scene); + tex = loadTexture(filename, path, scene); } catch (...) { - Ogre::LogManager::getSingleton().logMessage("MaterialProcessor: Failed to load normal map '" + normalFilename + "'"); + return false; } } - if(normalTexPtr) - applyRTSSNormalMap(ogreMaterial, normalTexPtr->getName()); - } + if (!tex) + return false; + stagedNormalTex = tex->getName(); + return true; + }; + if (!stageNormalFromAssimp(aiTextureType_NORMALS)) + stageNormalFromAssimp(aiTextureType_NORMAL_CAMERA); + if (stagedNormalTex.empty()) + stageNormalFromAssimp(aiTextureType_HEIGHT); // Slice F3: read PBR-specific texture types from Assimp and bind // them to the slice E canonical slot names so the user can see @@ -349,8 +359,7 @@ Ogre::MaterialPtr MaterialProcessor::processMaterial(const aiMaterial *material, // subsequent compile both walk paths that segfault on that fixture. // In real app use the render system is always up by import time. if (Ogre::Root::getSingletonPtr() && Ogre::Root::getSingletonPtr()->getRenderSystem()) { - RTShaderHelper::wirePbrSlotsForFFP(ogreMaterial.get()); - ogreMaterial->compile(); + RTShaderHelper::finalizeShaderGenMaterial(ogreMaterial, stagedNormalTex); } return ogreMaterial; @@ -410,21 +419,12 @@ Ogre::TexturePtr MaterialProcessor::loadTexture(const Ogre::String &filename, co void MaterialProcessor::applyRTSSNormalMap(Ogre::MaterialPtr mat, const Ogre::String& normalMapName) { - RTShaderHelper::applyNormalMap(mat, normalMapName); - - // Stash the normal-map texture name on the material's first pass user- - // object bindings so the FBX exporter can find it on round-trip. Without - // this, when MaterialProcessor and the export pipeline reach the - // material through different resource-group instances of the same name - // (common when a .material script and an FBX both define the material), - // the export-side `sub->getMaterial()` returns the script-loaded - // instance — which never had the RTSS normal-map TUS added — and the - // normal map is silently dropped on export. The UOB hint survives the - // resource-group disagreement because it's keyed on the *material* name - // and re-applied by the importer on every load. Issue #508. + // Legacy entry point — full RTSS wiring happens in finalizeShaderGenMaterial + // after all PBR slots exist. Keep UOB for export round-trip (#508). if (mat && mat->getNumTechniques() > 0 && mat->getTechnique(0)->getNumPasses() > 0) { Ogre::Pass* pass = mat->getTechnique(0)->getPass(0); pass->getUserObjectBindings().setUserAny( "qtme.normal_map", Ogre::Any(normalMapName)); } + RTShaderHelper::finalizeShaderGenMaterial(mat, normalMapName); } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 1af02aceb..4edcf82bf 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -25,6 +25,7 @@ #include "MorphAnimationManager.h" #include "NodeAnimationManager.h" #include "PoseLibrary.h" +#include "ModelTurntableRenderer.h" #include "QtMeshCloudClient.h" #include #include @@ -561,6 +562,11 @@ void CLIPipeline::printUsage() " pose --library apply --lib --apply -o \n" " Load mesh, apply named pose from sidecar to the\n" " skeleton, export the posed mesh. Requires a skinned mesh.\n" + " turntable -o [--frames N] [--size WxH] [--columns C]\n" + " Render a PNG turntable (default: horizontal sprite sheet)\n" + " turntable -o Use %02d in -o to write separate frame PNGs\n" + " Options: --axis y|x|z, --elevation/--camera-height ,\n" + " --width/--height, --json\n" " scan [path] [options] Scan directory for 3D asset issues (default path: .)\n" " material --preset [-o ]\n" " Apply a built-in material preset to every sub-entity\n" @@ -740,6 +746,93 @@ QString CLIPipeline::formatForExtension(const QString& path) return "Ogre Mesh (*.mesh)"; } +namespace { + +bool parseCliInt(const QString& text, int* out) +{ + bool ok = false; + const int v = text.toInt(&ok); + if (ok && out) + *out = v; + return ok; +} + +bool parseCliFloat(const QString& text, float* out) +{ + bool ok = false; + const float v = text.toFloat(&ok); + if (ok && out) + *out = v; + return ok; +} + +bool turntableUsesFrameSequencePattern(const QString& path) +{ + for (int i = 0; i < path.size(); ++i) { + if (path[i] != QLatin1Char('%')) + continue; + if (i + 1 < path.size() && path[i + 1] == QLatin1Char('%')) { + ++i; + continue; + } + return true; + } + return false; +} + +bool validateTurntableSequencePattern(const QString& pattern, QString* errorOut) +{ + int conversions = 0; + for (int i = 0; i < pattern.size(); ++i) { + if (pattern[i] != QLatin1Char('%')) + continue; + if (i + 1 < pattern.size() && pattern[i + 1] == QLatin1Char('%')) { + ++i; + continue; + } + ++conversions; + if (conversions > 1) { + if (errorOut) + *errorOut = QStringLiteral( + "Output pattern must contain exactly one frame index (use %% for a literal %)"); + return false; + } + ++i; + while (i < pattern.size() + && (pattern[i].isDigit() || pattern[i] == QLatin1Char('.') || pattern[i] == QLatin1Char('*') + || pattern[i] == QLatin1Char('-'))) + ++i; + if (i >= pattern.size()) { + if (errorOut) + *errorOut = QStringLiteral("Output pattern ends with an incomplete conversion"); + return false; + } + const QChar spec = pattern[i]; + if (spec != QLatin1Char('d') && spec != QLatin1Char('i') && spec != QLatin1Char('u')) { + if (errorOut) + *errorOut = QStringLiteral( + "Output pattern frame index must use %%d (for example frame_%%02d.png)"); + return false; + } + } + if (conversions != 1) { + if (errorOut) + *errorOut = QStringLiteral( + "Sequence output requires exactly one frame index (for example frame_%%02d.png)"); + return false; + } + return true; +} + +QString formatTurntableFramePath(const QString& pattern, int frameIndex) +{ + char buf[2048]; + snprintf(buf, sizeof(buf), pattern.toUtf8().constData(), frameIndex); + return QString::fromUtf8(buf); +} + +} // namespace + bool CLIPipeline::initOgreHeadless() { // Suppress Ogre log output unless --verbose was given. @@ -761,40 +854,51 @@ bool CLIPipeline::initOgreHeadless() return false; } - // Already have a render window (e.g. from tryInitOgre() in tests) — nothing to do. auto* root = Manager::getSingleton()->getRoot(); + bool hasRenderWindow = false; if (root) { try { if (root->getRenderTarget("TestHidden") || root->getRenderTarget("CLIHidden")) - return true; + hasRenderWindow = true; } catch (...) { // getRenderTarget may throw if not found in some Ogre versions } } - static QWidget* hiddenWidget = nullptr; - if (!hiddenWidget) { - hiddenWidget = new QWidget(); - hiddenWidget->setAttribute(Qt::WA_DontShowOnScreen); - hiddenWidget->resize(1, 1); - hiddenWidget->show(); - } + if (!hasRenderWindow) { + static QWidget* hiddenWidget = nullptr; + if (!hiddenWidget) { + hiddenWidget = new QWidget(); + hiddenWidget->setAttribute(Qt::WA_DontShowOnScreen); + hiddenWidget->resize(1, 1); + hiddenWidget->show(); + } - try { - Ogre::NameValuePairList params; - params["externalWindowHandle"] = Ogre::StringConverter::toString( - static_cast(hiddenWidget->winId())); + try { + Ogre::NameValuePairList params; + params["externalWindowHandle"] = Ogre::StringConverter::toString( + static_cast(hiddenWidget->winId())); #ifdef Q_OS_MACOS - params["macAPI"] = "cocoa"; - params["macAPICocoaUseNSView"] = "true"; + params["macAPI"] = "cocoa"; + params["macAPICocoaUseNSView"] = "true"; #endif - Manager::getSingleton()->getRoot()->createRenderWindow( - "CLIHidden", 1, 1, false, ¶ms); - return true; - } catch (...) { - err() << "Error: Failed to create render window." << Qt::endl; - return false; + Manager::getSingleton()->getRoot()->createRenderWindow( + "CLIHidden", 1, 1, false, ¶ms); + } catch (...) { + err() << "Error: Failed to create render window." << Qt::endl; + return false; + } + } + + // Match GUI startup: RTSS and media need a GL context (MainWindow calls + // loadResources() after createRenderWindow). Without this, CLI turntable + // renders MSN_SHADERGEN with normal maps still in the FFP multitexture chain. + static bool cliResourcesLoaded = false; + if (!cliResourcesLoaded) { + Manager::getSingleton()->loadResources(); + cliResourcesLoaded = true; } + return true; } MeshInfo CLIPipeline::extractMeshInfo(const Ogre::Entity* entity, const QString& fileName) @@ -1077,6 +1181,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "validate") rc = cmdValidate(argc, argv); else if (cmd == "lod") rc = cmdLod(argc, argv); else if (cmd == "pose") rc = cmdPose(argc, argv); + else if (cmd == "turntable") rc = cmdTurntable(argc, argv); else if (cmd == "scan") rc = cmdScan(argc, argv); else if (cmd == "material") rc = cmdMaterial(argc, argv); else if (cmd == "pack-textures") rc = cmdPackTextures(argc, argv); @@ -2782,6 +2887,231 @@ int CLIPipeline::cmdPose(int argc, char* argv[]) } } +int CLIPipeline::cmdTurntable(int argc, char* argv[]) +{ + // turntable -o [--frames N] [--size WxH] [--width W] [--height H] + // [--columns C] [--axis y|x|z] [--elevation deg] [--camera-height deg] [--json] + QString inputPath, outputPath; + int frameCount = 12; + int width = 512; + int height = 512; + int columns = 0; + float elevation = 20.0f; + bool jsonOutput = false; + TurntableAxis axis = TurntableAxis::Y; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "turntable" || arg == "--cli") + continue; + if (arg == "--json") { + jsonOutput = true; + continue; + } + if (arg == "-o" && i + 1 < argc) { + outputPath = QString(argv[++i]); + continue; + } + if (arg == "--frames" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &frameCount)) { + err() << "Error: Invalid value for --frames." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--columns" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &columns)) { + err() << "Error: Invalid value for --columns." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--width" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &width)) { + err() << "Error: Invalid value for --width." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--height" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &height)) { + err() << "Error: Invalid value for --height." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--size" && i + 1 < argc) { + const QString sizeArg = QString(argv[++i]); + const int xPos = sizeArg.indexOf(QLatin1Char('x')); + if (xPos > 0) { + if (!parseCliInt(sizeArg.left(xPos), &width) || !parseCliInt(sizeArg.mid(xPos + 1), &height)) { + err() << "Error: Invalid value for --size (expected WxH)." << Qt::endl; + return 2; + } + } else if (!parseCliInt(sizeArg, &width)) { + err() << "Error: Invalid value for --size." << Qt::endl; + return 2; + } else { + height = width; + } + continue; + } + if (arg == "--elevation" && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &elevation)) { + err() << "Error: Invalid value for --elevation." << Qt::endl; + return 2; + } + continue; + } + if ((arg == "--camera-height" || arg == "--camera_height") && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &elevation)) { + err() << "Error: Invalid value for --camera-height." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--axis" && i + 1 < argc) { + TurntableAxis parsed = TurntableAxis::Y; + if (!ModelTurntableRenderer::parseAxis(QString(argv[++i]), &parsed)) { + err() << "Error: --axis must be y, x, or z." << Qt::endl; + return 2; + } + axis = parsed; + continue; + } + if (!arg.startsWith(QLatin1Char('-')) && inputPath.isEmpty()) { + inputPath = arg; + continue; + } + } + + if (inputPath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh turntable -o [--frames N] [--size WxH]" << Qt::endl; + return 2; + } + if (outputPath.isEmpty()) { + err() << "Error: Output path required (-o)." << Qt::endl; + err() << "Usage: qtmesh turntable -o [--frames N]" << Qt::endl; + return 2; + } + + const bool sequenceOutput = turntableUsesFrameSequencePattern(outputPath); + if (sequenceOutput) { + QString patternError; + if (!validateTurntableSequencePattern(outputPath, &patternError)) { + err() << "Error: " << patternError << Qt::endl; + return 2; + } + } + + QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: File not found: " << inputPath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) + return 1; + + SentryReporter::addBreadcrumb("cli.turntable", + QString("Turntable .%1 frames=%2").arg(fi.suffix()).arg(frameCount)); + SentryReporter::addBreadcrumb("file.import", fi.absoluteFilePath()); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + + QList entityList; + for (auto *obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entityList.append(static_cast(obj)); + } + if (entityList.isEmpty()) { + SentryReporter::captureMessage(QString("CLI turntable: import failed (.%1)").arg(fi.suffix()), + "error"); + err() << "Error: Failed to load file: " << inputPath << Qt::endl; + return 1; + } + + TurntableOptions options; + options.width = width; + options.height = height; + options.frameCount = qBound(1, frameCount, 360); + options.axis = axis; + options.elevationDegrees = elevation; + + QList frames; + QString renderError; + if (!ModelTurntableRenderer::renderToImages(entityList, options, &frames, &renderError)) { + ModelTurntableRenderer::shutdown(); + err() << "Error: " << renderError << Qt::endl; + return 1; + } + + QStringList writtenPaths; + + if (sequenceOutput) { + for (int f = 0; f < frames.size(); ++f) { + const QString framePath = formatTurntableFramePath(outputPath, f); + if (!frames.at(f).save(framePath)) { + ModelTurntableRenderer::shutdown(); + err() << "Error: Failed to write " << framePath << Qt::endl; + return 1; + } + writtenPaths << framePath; + } + } else if (frames.size() == 1) { + if (!frames.first().save(outputPath)) { + ModelTurntableRenderer::shutdown(); + err() << "Error: Failed to write " << outputPath << Qt::endl; + return 1; + } + writtenPaths << outputPath; + } else { + const QImage sheet = ModelTurntableRenderer::composeSpriteSheet(frames, columns); + if (sheet.isNull() || !sheet.save(outputPath)) { + ModelTurntableRenderer::shutdown(); + err() << "Error: Failed to write sprite sheet " << outputPath << Qt::endl; + return 1; + } + writtenPaths << outputPath; + } + + ModelTurntableRenderer::shutdown(); + + for (const QString& written : writtenPaths) + SentryReporter::addBreadcrumb("file.export", written); + + if (jsonOutput) { + QJsonObject root; + root["input"] = fi.absoluteFilePath(); + root["frames"] = frames.size(); + root["width"] = width; + root["height"] = height; + root["elevation"] = elevation; + root["axis"] = axis == TurntableAxis::X ? "x" : axis == TurntableAxis::Z ? "z" : "y"; + root["sequence"] = sequenceOutput; + QJsonArray paths; + for (const QString &p : writtenPaths) + paths.append(p); + root["outputs"] = paths; + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)) + "\n"); + } else { + if (sequenceOutput) { + cliWrite(QString("Wrote %1 turntable frame(s) to %2\n") + .arg(writtenPaths.size()) + .arg(QFileInfo(outputPath).absolutePath())); + } else if (frames.size() == 1) { + cliWrite(QString("Wrote turntable PNG: %1\n").arg(QFileInfo(outputPath).fileName())); + } else { + cliWrite(QString("Wrote turntable sprite sheet (%1 frames): %2\n") + .arg(frames.size()) + .arg(QFileInfo(outputPath).fileName())); + } + } + + return 0; +} + int CLIPipeline::cmdMaterial(int argc, char* argv[]) { // Parse: diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index e4bab2840..d06ba8f92 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -75,6 +75,8 @@ class CLIPipeline { static int cmdValidate(int argc, char* argv[]); static int cmdLod(int argc, char* argv[]); static int cmdPose(int argc, char* argv[]); + /// Render a mesh turntable as PNG frame(s) or a horizontal sprite sheet (#294). + static int cmdTurntable(int argc, char* argv[]); static int cmdScan(int argc, char* argv[]); static int cmdMaterial(int argc, char* argv[]); /// Slice G: pack 1-4 grayscale source images into a single RGBA diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 243f6208e..1af743cf3 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -17,7 +17,9 @@ #include "MeshLodController.h" #include "SelectionSet.h" #include +#include #include "CLIPipeline.h" +#include "ModelTurntableRenderer.h" #include "MeshImporterExporter.h" #include "SentryReporter.h" #include "TestHelpers.h" @@ -751,6 +753,7 @@ TEST_F(CLIPipelineInitTest, InitOgreHeadless_Idempotent) { // Should return true even though tryInitOgre() already created a render window EXPECT_TRUE(CLIPipeline::initOgreHeadless()); + EXPECT_NE(Ogre::RTShader::ShaderGenerator::getSingletonPtr(), nullptr); } TEST_F(CLIPipelineInitTest, InitOgreHeadless_CalledTwice) @@ -902,6 +905,210 @@ TEST(CLIPipelineCmdInfoError, NoFile) EXPECT_EQ(CLIPipeline::cmdInfo(args.argc(), args.argv()), 2); } +TEST(CLIPipelineCmdTurntableError, NoOutput) +{ + TestArgv args({"qtmesh", "turntable", "model.fbx"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdTurntableError, NoFile) +{ + TestArgv args({"qtmesh", "turntable", "-o", "out.png"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdTurntableError, InvalidAxis) +{ + TestArgv args({"qtmesh", "turntable", "model.fbx", "-o", "out.png", "--axis", "diagonal"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 2); +} + +TEST(ModelTurntableAxisParse, ValidAxes) +{ + TurntableAxis axis = TurntableAxis::Y; + EXPECT_TRUE(ModelTurntableRenderer::parseAxis("y", &axis)); + EXPECT_EQ(axis, TurntableAxis::Y); + EXPECT_TRUE(ModelTurntableRenderer::parseAxis("X", &axis)); + EXPECT_EQ(axis, TurntableAxis::X); + EXPECT_TRUE(ModelTurntableRenderer::parseAxis("z", &axis)); + EXPECT_EQ(axis, TurntableAxis::Z); + EXPECT_FALSE(ModelTurntableRenderer::parseAxis("invalid", &axis)); +} + +TEST(CLIPipelineCmdTurntableError, NonexistentInputFile) +{ + TestArgv args({"qtmesh", "turntable", "/nonexistent/path/model_xyz.obj", "-o", "out.png"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 1); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSpriteSheet) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("sheet.png").toUtf8(); + + TestArgv args({"qtmesh", "turntable", meshArg.constData(), + "-o", outArg.constData(), + "--frames", "2", + "--size", "48"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 0); + ASSERT_TRUE(QFile::exists(QString::fromUtf8(outArg))); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 96); // 2 × 48 + EXPECT_EQ(img.height(), 48); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSizeWxHParses) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_sizex.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("sizex.png").toUtf8(); + + TestArgv args({"qtmesh", "turntable", meshArg.constData(), + "-o", outArg.constData(), + "--frames", "2", + "--size", "32x16"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 64); // 2 cols × 32 + EXPECT_EQ(img.height(), 16); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSequenceAndAxis) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_seq.obj").toUtf8(); + const QByteArray patternArg = tmp.filePath("frm_%02d.png").toUtf8(); + + TestArgv seq({"qtmesh", "turntable", meshArg.constData(), + "-o", patternArg.constData(), + "--frames", "3", + "--width", "32", + "--height", "32", + "--axis", "z", + "--json"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(seq.argc(), seq.argv()), 0); + + EXPECT_TRUE(QFile::exists(tmp.filePath("frm_00.png"))); + EXPECT_TRUE(QFile::exists(tmp.filePath("frm_01.png"))); + EXPECT_TRUE(QFile::exists(tmp.filePath("frm_02.png"))); + + QImage f0(tmp.filePath("frm_00.png")); + ASSERT_FALSE(f0.isNull()); + EXPECT_EQ(f0.width(), 32); + EXPECT_EQ(f0.height(), 32); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_SkipsCliFlagAndCameraHeightAlias) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_cli_flag.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("cli_flag.png").toUtf8(); + + TestArgv args({"qtmesh", "--cli", "turntable", meshArg.constData(), + "-o", outArg.constData(), + "--frames", "2", + "--size", "24", + "--camera_height", "10"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 48); + EXPECT_EQ(img.height(), 24); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSpriteSheetColumnsAndCameraHeight) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_cols.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("cols.png").toUtf8(); + + TestArgv args({"qtmesh", "turntable", meshArg.constData(), + "-o", outArg.constData(), + "--frames", "4", + "--size", "40", + "--columns", "2", + "--axis", "x", + "--camera-height", "15"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 80); // 2 cols × 40 + EXPECT_EQ(img.height(), 80); // 2 rows × 40 +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_FbxFromTestDataRendersWithoutError) +{ + const QString fbxPath = testDataDir() + QStringLiteral("/Twist Dance.fbx"); + ASSERT_TRUE(QFile::exists(fbxPath)) << "Fixture missing: " << fbxPath.toStdString(); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outArg = tmp.filePath("twist_turntable.png").toUtf8(); + TestArgv args({"qtmesh", "turntable", fbxPath.toUtf8().constData(), + "-o", outArg.constData(), + "--frames", "2", + "--size", "128"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 256); + EXPECT_EQ(img.height(), 128); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_RejectsInvalidFramesFlag) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_bad_frames.obj").toUtf8(); + TestArgv args({"qtmesh", "turntable", meshArg.constData(), + "-o", tmp.filePath("out.png").toUtf8().constData(), + "--frames", "not-a-number"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 2); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_RejectsInvalidSequencePattern) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_bad_seq.obj").toUtf8(); + TestArgv args({"qtmesh", "turntable", meshArg.constData(), + "-o", tmp.filePath("frame_%s.png").toUtf8().constData(), + "--frames", "2"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 2); +} + +TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSingleFrameWritesOnePng) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "turntable_one.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("single.png").toUtf8(); + + TestArgv args({"qtmesh", "turntable", meshArg.constData(), + "-o", outArg.constData(), + "--frames", "1", + "--size", "24"}); + EXPECT_EQ(CLIPipeline::cmdTurntable(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 24); + EXPECT_EQ(img.height(), 24); +} + TEST(CLIPipelineCmdInfoError, NonexistentFile) { TestArgv args({"qtmesh", "info", "/tmp/nonexistent_cli_test_file_12345.fbx"}); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ead227a71..5c7cb9a20 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -110,6 +110,7 @@ ScanEngine.cpp QtMeshCloudClient.cpp AssetBrowserController.cpp MaterialPreviewRenderer.cpp +ModelTurntableRenderer.cpp EditableMesh.cpp EditModeController.cpp EditorModeController.cpp @@ -210,6 +211,7 @@ ScanEngine.h QtMeshCloudClient.h AssetBrowserController.h MaterialPreviewRenderer.h +ModelTurntableRenderer.h EditableMesh.h EditModeController.h EditorModeController.h diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 9355648a0..57d371fca 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1294,31 +1294,45 @@ void MeshImporterExporter::applyNormalMapsToEntity(const Ogre::Entity* en) if (mat->getNumTechniques() == 0) continue; auto* pass = mat->getTechnique(0)->getPass(0); if (!pass) continue; + + std::string texName; for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { auto* tus = pass->getTextureUnitState(i); const auto& tusName = tus->getName(); if (tusName == "normal_map" || tusName == "NormalMap") { - std::string texName = tus->getTextureName(); - log.logMessage("applyNormalMapsToEntity: found normal map TUS '" + - tusName + "' tex='" + texName + "' on mat='" + mat->getName() + "'", - Ogre::LML_TRIVIAL); - if (texName.empty()) break; - // Ensure the texture is loaded before RTSS inspects it. - auto tex = Ogre::TextureManager::getSingleton().getByName(texName); - if (!tex || !tex->isLoaded()) { - try { - Ogre::TextureManager::getSingleton().load(texName, mat->getGroup()); - } catch (...) { - try { - Ogre::TextureManager::getSingleton().load( - texName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - } catch (...) {} - } - } - RTShaderHelper::applyNormalMap(mat, texName); + texName = tus->getTextureName(); break; } } + if (texName.empty()) { + const auto& bindings = pass->getUserObjectBindings(); + auto any = bindings.getUserAny("qtme.normal_map"); + if (any.has_value()) { + try { + texName = Ogre::any_cast(any); + } catch (...) { + } + } + } + + if (texName.empty()) + continue; + + log.logMessage("applyNormalMapsToEntity: normal map tex='" + texName + "' on mat='" + + mat->getName() + "'", + Ogre::LML_TRIVIAL); + auto tex = Ogre::TextureManager::getSingleton().getByName(texName); + if (!tex || !tex->isLoaded()) { + try { + Ogre::TextureManager::getSingleton().load(texName, mat->getGroup()); + } catch (...) { + try { + Ogre::TextureManager::getSingleton().load( + texName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + } catch (...) {} + } + } + RTShaderHelper::finalizeShaderGenMaterial(mat, texName); } } diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp new file mode 100644 index 000000000..e3353e88a --- /dev/null +++ b/src/ModelTurntableRenderer.cpp @@ -0,0 +1,538 @@ +#include "ModelTurntableRenderer.h" + +#include "GlobalDefinitions.h" +#include "Manager.h" +#include "MeshImporterExporter.h" +#include "RTShaderHelper.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include + +#include +#include +#include + +#include +#include +#include + +namespace { + +Ogre::SceneManager *sceneMgr() +{ + return Manager::getSingletonPtr() ? Manager::getSingleton()->getSceneMgr() : nullptr; +} + +struct TurntableState { + Ogre::SceneNode *pivotNode = nullptr; + Ogre::Camera *camera = nullptr; + Ogre::SceneNode *cameraNode = nullptr; + Ogre::Light *light = nullptr; + Ogre::SceneNode *lightNode = nullptr; + Ogre::TexturePtr rttTexture; + Ogre::RenderTarget *renderTarget = nullptr; + int rttWidth = 0; + int rttHeight = 0; + Ogre::ColourValue savedAmbient; + bool hasSavedAmbient = false; +}; + +TurntableState &state() +{ + static TurntableState s; + return s; +} + +void prepareSceneForCapture(const QList &entities) +{ + SelectionSet::getSingleton()->clear(); + + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + if (Ogre::SceneNode *node = entity->getParentSceneNode()) + node->showBoundingBox(false); + } +} + +void applyTurntableLighting(Ogre::SceneManager *sm) +{ + TurntableState &st = state(); + st.savedAmbient = sm->getAmbientLight(); + st.hasSavedAmbient = true; + sm->setAmbientLight(Ogre::ColourValue(1.0f, 1.0f, 1.0f)); + + if (!st.light) { + st.light = sm->createLight("ModelTurntableLight"); + st.light->setType(Ogre::Light::LT_DIRECTIONAL); + st.lightNode = sm->getRootSceneNode()->createChildSceneNode("ModelTurntableLightNode"); + st.lightNode->attachObject(st.light); + } + st.light->setDiffuseColour(0.85f, 0.85f, 0.85f); + st.light->setSpecularColour(0.35f, 0.35f, 0.35f); + st.lightNode->setDirection(Ogre::Vector3(-0.35f, -0.85f, -0.4f).normalisedCopy()); +} + +void restoreTurntableLighting(Ogre::SceneManager *sm) +{ + TurntableState &st = state(); + if (st.hasSavedAmbient) { + sm->setAmbientLight(st.savedAmbient); + st.hasSavedAmbient = false; + } +} + +bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QString *errorOut) +{ + auto *sm = sceneMgr(); + auto *root = Ogre::Root::getSingletonPtr(); + if (!sm || !root || !root->getRenderSystem()) { + if (errorOut) + *errorOut = QStringLiteral("Ogre is not initialized"); + return false; + } + + TurntableState &st = state(); + if (st.renderTarget && st.rttWidth == width && st.rttHeight == height) { + if (st.renderTarget->getNumViewports() > 0) { + Ogre::Viewport *vp = st.renderTarget->getViewport(0); + vp->setBackgroundColour(bg); + vp->setMaterialScheme(Ogre::MSN_SHADERGEN); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + } + return true; + } + + ModelTurntableRenderer::shutdown(); + + try { + st.rttTexture = Ogre::TextureManager::getSingleton().createManual( + "ModelTurntableRTT", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, Ogre::TEX_TYPE_2D, + static_cast(width), static_cast(height), 0, Ogre::PF_BYTE_RGBA, + Ogre::TU_RENDERTARGET); + st.renderTarget = st.rttTexture->getBuffer()->getRenderTarget(); + st.rttWidth = width; + st.rttHeight = height; + + if (!st.camera) { + st.camera = sm->createCamera("ModelTurntableCamera"); + st.camera->setNearClipDistance(0.01f); + st.camera->setFarClipDistance(100000.0f); + st.camera->setFOVy(Ogre::Degree(45.0f)); + st.pivotNode = sm->getRootSceneNode()->createChildSceneNode("ModelTurntablePivot"); + st.cameraNode = st.pivotNode->createChildSceneNode("ModelTurntableCameraNode"); + st.cameraNode->attachObject(st.camera); + } + + if (st.renderTarget->getNumViewports() == 0) { + Ogre::Viewport *vp = st.renderTarget->addViewport(st.camera); + vp->setClearEveryFrame(true); + vp->setBackgroundColour(bg); + vp->setOverlaysEnabled(false); + vp->setShadowsEnabled(true); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + // Match the main editor viewport so RTSS normal maps / PBR are used + // instead of FFP multi-texture blending (normal map as a flat layer). + vp->setMaterialScheme(Ogre::MSN_SHADERGEN); + } else { + Ogre::Viewport *vp = st.renderTarget->getViewport(0); + vp->setBackgroundColour(bg); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + vp->setMaterialScheme(Ogre::MSN_SHADERGEN); + } + + const Ogre::Real aspect = + height > 0 ? static_cast(width) / static_cast(height) : 1.0f; + st.camera->setAspectRatio(aspect); + return true; + } catch (const Ogre::Exception &e) { + ModelTurntableRenderer::shutdown(); + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + return false; + } catch (...) { + ModelTurntableRenderer::shutdown(); + if (errorOut) + *errorOut = QStringLiteral("Failed to create turntable render target"); + return false; + } +} + +void refreshEntityBounds(const QList &entities) +{ + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + if (Ogre::SceneNode *node = entity->getParentSceneNode()) + node->_update(true, true); + } +} + +Ogre::AxisAlignedBox combinedWorldBounds(const QList &entities) +{ + Ogre::AxisAlignedBox box; + box.setNull(); + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + box.merge(entity->getWorldBoundingBox(true)); + } + return box; +} + +/// Move loaded entities so the combined bounds center sits at the world origin. +void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisAlignedBox &bounds) +{ + if (bounds.isNull() || bounds.isInfinite()) + return; + + const Ogre::Vector3 center = bounds.getCenter(); + if (center.squaredLength() < 1e-10f) + return; + + std::unordered_set shifted; + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + Ogre::SceneNode *node = entity->getParentSceneNode(); + if (!node || !shifted.insert(node).second) + continue; + node->translate(-center, Ogre::Node::TS_WORLD); + } + + bounds.setExtents(bounds.getMinimum() - center, bounds.getMaximum() - center); + refreshEntityBounds(entities); +} + +Ogre::Vector3 orbitAxisVector(TurntableAxis axis) +{ + switch (axis) { + case TurntableAxis::X: + return Ogre::Vector3::UNIT_X; + case TurntableAxis::Z: + return Ogre::Vector3::UNIT_Z; + case TurntableAxis::Y: + default: + return Ogre::Vector3::UNIT_Y; + } +} + +/// Look-at / orbit point. After recentering this is near the origin; a small upward +/// bias keeps typical upright characters visually centered in the frame. +Ogre::Vector3 turntablePivotPoint(const Ogre::AxisAlignedBox &bounds) +{ + Ogre::Vector3 point = bounds.getCenter(); + const Ogre::Real height = bounds.getMaximum().y - bounds.getMinimum().y; + point.y += height * 0.12f; + return point; +} + +/// Camera rest offset before orbit rotation (orbit applied around `axis` through center). +Ogre::Vector3 cameraRestOffset(TurntableAxis axis, float horizDistance, float axialDistance) +{ + switch (axis) { + case TurntableAxis::X: + return Ogre::Vector3(axialDistance, 0.0f, horizDistance); + case TurntableAxis::Z: + return Ogre::Vector3(horizDistance, 0.0f, axialDistance); + case TurntableAxis::Y: + default: + return Ogre::Vector3(0.0f, axialDistance, horizDistance); + } +} + +/// Build an orthonormal camera basis (GLM-style: right = forward x up). +void cameraAxesFromViewDir(const Ogre::Vector3 &viewDir, const Ogre::Vector3 &worldUp, Ogre::Vector3 &outSide, + Ogre::Vector3 &outUp) +{ + Ogre::Vector3 forward = viewDir; + if (forward.squaredLength() < 1e-8f) + forward = Ogre::Vector3(0.0f, 0.0f, 1.0f); + forward.normalise(); + + Ogre::Vector3 side = forward.crossProduct(worldUp); + if (side.squaredLength() < 1e-8f) + side = forward.crossProduct(Ogre::Vector3::UNIT_X); + side.normalise(); + outSide = side; + outUp = side.crossProduct(forward); + outUp.normalise(); +} + +/// Minimum orbit radius so every AABB corner fits in the camera frustum from `viewDir`. +Ogre::Real fitOrbitDistance(const Ogre::AxisAlignedBox &bounds, const Ogre::Vector3 &pivotPoint, + const Ogre::Vector3 &viewDir, Ogre::Camera *camera, float paddingFactor) +{ + const Ogre::Vector3 center = pivotPoint; + Ogre::Vector3 dir = viewDir; + if (dir.squaredLength() < 1e-8f) + dir = Ogre::Vector3(0.0f, 0.0f, 1.0f); + dir.normalise(); + + Ogre::Vector3 side; + Ogre::Vector3 up; + cameraAxesFromViewDir(dir, Ogre::Vector3::UNIT_Y, side, up); + + const Ogre::Radian fovY = camera->getFOVy(); + const Ogre::Real aspect = camera->getAspectRatio(); + const float tanHalfY = std::tan(fovY.valueRadians() * 0.5f); + const float tanHalfX = tanHalfY * aspect; + + const Ogre::Vector3 &bmin = bounds.getMinimum(); + const Ogre::Vector3 &bmax = bounds.getMaximum(); + Ogre::Real required = 0.1f; + for (int xi = 0; xi < 2; ++xi) { + for (int yi = 0; yi < 2; ++yi) { + for (int zi = 0; zi < 2; ++zi) { + const Ogre::Vector3 corner(xi ? bmax.x : bmin.x, yi ? bmax.y : bmin.y, zi ? bmax.z : bmin.z); + const Ogre::Vector3 rel = corner - center; + const float depthAlongView = rel.dotProduct(dir); + const float x = std::abs(rel.dotProduct(side)); + const float y = std::abs(rel.dotProduct(up)); + const float need = depthAlongView + std::max(x / tanHalfX, y / tanHalfY); + required = std::max(required, need); + } + } + } + return required * paddingFactor; +} + +void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, TurntableAxis axis, + float elevationRadians, float paddingFactor) +{ + TurntableState &st = state(); + if (!st.camera || !st.cameraNode || !st.pivotNode || bounds.isNull() || bounds.isInfinite()) + return; + + const Ogre::Vector3 pivotPoint = turntablePivotPoint(bounds); + + const float horizUnit = std::cos(elevationRadians); + const float axialUnit = std::sin(elevationRadians); + Ogre::Vector3 restOffset = cameraRestOffset(axis, horizUnit, axialUnit); + if (restOffset.squaredLength() < 1e-8f) + restOffset = cameraRestOffset(axis, 1.0f, 0.0f); + + Ogre::Vector3 localViewDir = -restOffset; + localViewDir.normalise(); + const Ogre::Quaternion orbitRot(Ogre::Radian(angleRadians), orbitAxisVector(axis)); + Ogre::Vector3 viewDir = orbitRot * localViewDir; + + const Ogre::Real distance = fitOrbitDistance(bounds, pivotPoint, viewDir, st.camera, paddingFactor); + const float horiz = distance * horizUnit; + const float axial = distance * axialUnit; + + // Pivot at the framing point; rotate on one axis; child camera always looks at pivot origin. + st.pivotNode->setPosition(pivotPoint); + st.pivotNode->setOrientation(Ogre::Quaternion(Ogre::Radian(angleRadians), orbitAxisVector(axis))); + st.cameraNode->setPosition(cameraRestOffset(axis, horiz, axial)); + st.cameraNode->lookAt(Ogre::Vector3::ZERO, Ogre::Node::TS_PARENT); +} + +void prepareMaterialsForTurntable(const QList &entities) +{ + std::unordered_set processed; + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + MeshImporterExporter::applyNormalMapsToEntity(entity); + for (unsigned int sub = 0; sub < entity->getNumSubEntities(); ++sub) { + Ogre::MaterialPtr mat = entity->getSubEntity(sub)->getMaterial(); + if (!mat) + continue; + if (!processed.insert(mat.get()).second) + continue; + RTShaderHelper::finalizeShaderGenMaterial(mat); + } + } +} + +QImage readRenderTarget(int width, int height) +{ + QImage image(width, height, QImage::Format_RGBA8888); + image.fill(Qt::transparent); + TurntableState &st = state(); + if (!st.renderTarget) + return image; + + Ogre::PixelBox pb(static_cast(width), static_cast(height), 1, + Ogre::PF_BYTE_RGBA, image.bits()); + st.renderTarget->copyContentsToMemory(Ogre::Box(0, 0, width, height), pb, + Ogre::RenderTarget::FB_AUTO); + return image; +} + +} // namespace + +bool ModelTurntableRenderer::parseAxis(const QString &text, TurntableAxis *outAxis) +{ + if (!outAxis) + return false; + const QString key = text.trimmed().toLower(); + if (key == QLatin1String("y")) { + *outAxis = TurntableAxis::Y; + return true; + } + if (key == QLatin1String("x")) { + *outAxis = TurntableAxis::X; + return true; + } + if (key == QLatin1String("z")) { + *outAxis = TurntableAxis::Z; + return true; + } + return false; +} + +void ModelTurntableRenderer::shutdown() +{ + TurntableState &st = state(); + auto *sm = sceneMgr(); + if (st.renderTarget) { + st.renderTarget->removeAllViewports(); + st.renderTarget = nullptr; + } + if (st.rttTexture) { + Ogre::TextureManager::getSingleton().remove(st.rttTexture); + st.rttTexture.reset(); + } + st.rttWidth = 0; + st.rttHeight = 0; + + if (sm) { + if (st.hasSavedAmbient) { + sm->setAmbientLight(st.savedAmbient); + st.hasSavedAmbient = false; + } + if (st.light) { + if (st.lightNode) { + st.lightNode->detachObject(st.light); + sm->destroySceneNode(st.lightNode); + } + sm->destroyLight(st.light); + } + if (st.camera) { + if (st.cameraNode) + st.cameraNode->detachObject(st.camera); + sm->destroyCamera(st.camera); + } + if (st.cameraNode) + sm->destroySceneNode(st.cameraNode); + if (st.pivotNode) + sm->destroySceneNode(st.pivotNode); + } + st.lightNode = nullptr; + st.light = nullptr; + st.camera = nullptr; + st.cameraNode = nullptr; + st.pivotNode = nullptr; +} + +bool ModelTurntableRenderer::renderToImages(const QList &entities, + const TurntableOptions &options, QList *outFrames, + QString *errorOut) +{ + if (!outFrames) { + if (errorOut) + *errorOut = QStringLiteral("Output frame list is null"); + return false; + } + outFrames->clear(); + + if (entities.isEmpty()) { + if (errorOut) + *errorOut = QStringLiteral("No entities to render"); + return false; + } + + auto *sm = sceneMgr(); + if (!sm) { + if (errorOut) + *errorOut = QStringLiteral("Ogre scene manager is not available"); + return false; + } + + const int width = std::max(16, options.width); + const int height = std::max(16, options.height); + const int frameCount = std::clamp(options.frameCount, 1, 360); + + if (!ensureRenderTarget(width, height, options.background, errorOut)) + return false; + + refreshEntityBounds(entities); + Ogre::AxisAlignedBox bounds = combinedWorldBounds(entities); + if (bounds.isNull() || bounds.isInfinite()) { + if (errorOut) + *errorOut = QStringLiteral("Could not compute model bounds"); + return false; + } + + recenterEntitiesAtOrigin(entities, bounds); + bounds = combinedWorldBounds(entities); + + const float elevationRad = + Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); + + prepareSceneForCapture(entities); + // Tangents + RTSS normal wiring, then strip any duplicate normal-map TUS that + // would still modulate in the FFP chain (common on FBX like Jump.fbx). + prepareMaterialsForTurntable(entities); + applyTurntableLighting(sm); + + SentryReporter::addBreadcrumb("cli.turntable", + QStringLiteral("render start frames=%1").arg(frameCount)); + + outFrames->reserve(frameCount); + try { + for (int i = 0; i < frameCount; ++i) { + const float angle = Ogre::Math::TWO_PI * static_cast(i) / static_cast(frameCount); + placeCameraOnAxis(bounds, angle, options.axis, elevationRad, 1.25f); + state().renderTarget->update(); + outFrames->append(readRenderTarget(width, height)); + } + restoreTurntableLighting(sm); + SentryReporter::addBreadcrumb("cli.turntable", + QStringLiteral("render ok frames=%1").arg(outFrames->size())); + return true; + } catch (const Ogre::Exception &e) { + outFrames->clear(); + restoreTurntableLighting(sm); + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + SentryReporter::addBreadcrumb("cli.turntable", QStringLiteral("render failed: Ogre exception")); + return false; + } catch (...) { + outFrames->clear(); + restoreTurntableLighting(sm); + if (errorOut) + *errorOut = QStringLiteral("Turntable render failed"); + SentryReporter::addBreadcrumb("cli.turntable", QStringLiteral("render failed")); + return false; + } +} + +QImage ModelTurntableRenderer::composeSpriteSheet(const QList &frames, int columns) +{ + if (frames.isEmpty()) + return {}; + + const int frameW = frames.first().width(); + const int frameH = frames.first().height(); + const int count = frames.size(); + const int cols = columns > 0 ? columns : count; + const int rows = (count + cols - 1) / cols; + + QImage sheet(cols * frameW, rows * frameH, QImage::Format_RGBA8888); + sheet.fill(Qt::transparent); + + QPainter painter(&sheet); + for (int i = 0; i < count; ++i) { + const QImage &src = frames.at(i); + if (src.width() != frameW || src.height() != frameH) + continue; + const int col = i % cols; + const int row = i / cols; + painter.drawImage(col * frameW, row * frameH, src); + } + return sheet; +} diff --git a/src/ModelTurntableRenderer.h b/src/ModelTurntableRenderer.h new file mode 100644 index 000000000..0b0a284af --- /dev/null +++ b/src/ModelTurntableRenderer.h @@ -0,0 +1,49 @@ +#ifndef MODELTURNTABLERENDERER_H +#define MODELTURNTABLERENDERER_H + +#include +#include +#include + +#include + +/** + * Headless Ogre render-to-texture turntable for mesh previews (#294). + * + * Orbits a camera around the combined world bounding box of one or more + * entities and returns RGBA frames suitable for PNG export (CLI / batch). + */ +enum class TurntableAxis { + Y, ///< Orbit in the XZ plane (default product-shot turntable). + X, ///< Orbit in the YZ plane (spin around model X). + Z ///< Orbit in the XY plane (spin around model Z). +}; + +struct TurntableOptions { + int width = 512; + int height = 512; + int frameCount = 12; + TurntableAxis axis = TurntableAxis::Y; + /// Camera angle above the orbit plane, in degrees (see axis). + float elevationDegrees = 20.0f; + Ogre::ColourValue background{0.12f, 0.12f, 0.13f, 1.0f}; +}; + +class ModelTurntableRenderer +{ +public: + /// Render `frameCount` views around the chosen axis. Returns false on failure. + static bool renderToImages(const QList &entities, const TurntableOptions &options, + QList *outFrames, QString *errorOut = nullptr); + + /// Destroy turntable camera / RTT resources (safe to call repeatedly). + static void shutdown(); + + /// Lay out equal-sized frames in a single horizontal strip (columns=0 → one row). + static QImage composeSpriteSheet(const QList &frames, int columns = 0); + + /// Parse `--axis` value (`y`, `x`, `z`). Returns false if invalid. + static bool parseAxis(const QString &text, TurntableAxis *outAxis); +}; + +#endif // MODELTURNTABLERENDERER_H diff --git a/src/ModelTurntableRenderer_test.cpp b/src/ModelTurntableRenderer_test.cpp new file mode 100644 index 000000000..27c2079d8 --- /dev/null +++ b/src/ModelTurntableRenderer_test.cpp @@ -0,0 +1,211 @@ +#include + +#include "Manager.h" +#include "ModelTurntableRenderer.h" +#include "PrimitiveObject.h" +#include "RTShaderHelper.h" +#include "TestHelpers.h" + +#include +#include + +class ModelTurntableRendererTest : public ::testing::Test { +protected: + void SetUp() override + { + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + ModelTurntableRenderer::shutdown(); + } + + void TearDown() override { ModelTurntableRenderer::shutdown(); } +}; + +TEST_F(ModelTurntableRendererTest, RejectsEmptyEntityList) +{ + QList frames; + QString err; + EXPECT_FALSE(ModelTurntableRenderer::renderToImages({}, TurntableOptions{}, &frames, &err)); + EXPECT_FALSE(err.isEmpty()); + EXPECT_TRUE(frames.isEmpty()); +} + +TEST_F(ModelTurntableRendererTest, RejectsNullBoundsWhenEntitiesAreNull) +{ + QList frames; + QString err; + + QList entities; + entities.append(nullptr); + + EXPECT_FALSE(ModelTurntableRenderer::renderToImages(entities, TurntableOptions{}, &frames, &err)); + EXPECT_FALSE(err.isEmpty()); + EXPECT_TRUE(frames.isEmpty()); +} + +TEST_F(ModelTurntableRendererTest, ClampsMinimumSizeAndFrameCount) +{ + PrimitiveObject::createCube(QStringLiteral("TurntableMinClampCube")); + + QList entities; + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + ASSERT_FALSE(entities.isEmpty()); + + TurntableOptions options; + options.width = 1; + options.height = 1; + options.frameCount = 0; + + QList frames; + QString err; + ASSERT_TRUE(ModelTurntableRenderer::renderToImages(entities, options, &frames, &err)) << err.toStdString(); + ASSERT_EQ(frames.size(), 1); + EXPECT_EQ(frames.first().width(), 16); + EXPECT_EQ(frames.first().height(), 16); +} + +TEST_F(ModelTurntableRendererTest, RendersFramesForPrimitive) +{ + PrimitiveObject::createSphere(QStringLiteral("TurntableTestSphere")); + + QList entities; + for (auto *obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + ASSERT_FALSE(entities.isEmpty()); + + TurntableOptions options; + options.width = 128; + options.height = 128; + options.frameCount = 4; + + QList frames; + QString err; + ASSERT_TRUE(ModelTurntableRenderer::renderToImages(entities, options, &frames, &err)) << err.toStdString(); + ASSERT_EQ(frames.size(), 4); + for (const QImage &img : frames) { + EXPECT_EQ(img.width(), 128); + EXPECT_EQ(img.height(), 128); + EXPECT_FALSE(img.isNull()); + } +} + +TEST_F(ModelTurntableRendererTest, ComposeSpriteSheet) +{ + QList frames; + frames << QImage(4, 4, QImage::Format_RGBA8888); + frames << QImage(4, 4, QImage::Format_RGBA8888); + const QImage sheet = ModelTurntableRenderer::composeSpriteSheet(frames, 0); + EXPECT_EQ(sheet.width(), 8); + EXPECT_EQ(sheet.height(), 4); +} + +TEST(ModelTurntableParseAxisStatic, NullOutReturnsFalse) +{ + EXPECT_FALSE(ModelTurntableRenderer::parseAxis("y", nullptr)); +} + +TEST(ModelTurntableParseAxisStatic, TrimsWhitespace) +{ + TurntableAxis axis = TurntableAxis::Y; + ASSERT_TRUE(ModelTurntableRenderer::parseAxis(" Z ", &axis)); + EXPECT_EQ(axis, TurntableAxis::Z); +} + +TEST_F(ModelTurntableRendererTest, RejectsNullOutputFrameList) +{ + QString err; + QList entities; + EXPECT_FALSE(ModelTurntableRenderer::renderToImages(entities, TurntableOptions{}, nullptr, &err)); + EXPECT_FALSE(err.isEmpty()); +} + +TEST_F(ModelTurntableRendererTest, RendersFramesAxisXAndZ) +{ + PrimitiveObject::createCube(QStringLiteral("TurntableAxisCube")); + + QList entities; + for (auto *obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + ASSERT_FALSE(entities.isEmpty()); + + TurntableOptions opt; + opt.width = 64; + opt.height = 64; + opt.frameCount = 3; + opt.axis = TurntableAxis::X; + + QList frames; + QString err; + ASSERT_TRUE(ModelTurntableRenderer::renderToImages(entities, opt, &frames, &err)) << err.toStdString(); + ASSERT_EQ(static_cast(frames.size()), 3); + + opt.axis = TurntableAxis::Z; + frames.clear(); + ASSERT_TRUE(ModelTurntableRenderer::renderToImages(entities, opt, &frames, &err)) << err.toStdString(); + EXPECT_EQ(static_cast(frames.size()), 3); +} + +TEST(ModelTurntableComposeSheetStatic, EmptyReturnsNullImage) +{ + EXPECT_TRUE(ModelTurntableRenderer::composeSpriteSheet({}, 4).isNull()); +} + +TEST(ModelTurntableComposeSheetStatic, ColumnsLayoutTwoByTwo) +{ + QList frames; + for (int i = 0; i < 4; ++i) + frames << QImage(10, 10, QImage::Format_RGBA8888); + const QImage sheet = ModelTurntableRenderer::composeSpriteSheet(frames, 2); + EXPECT_FALSE(sheet.isNull()); + EXPECT_EQ(sheet.width(), 20); + EXPECT_EQ(sheet.height(), 20); +} + +TEST(ModelTurntableComposeSheetStatic, SkipsMismatchedFrameSizes) +{ + QList frames; + frames << QImage(8, 8, QImage::Format_RGBA8888); + frames << QImage(16, 16, QImage::Format_RGBA8888); // skipped in painter loop + const QImage sheet = ModelTurntableRenderer::composeSpriteSheet(frames, 0); + ASSERT_FALSE(sheet.isNull()); + EXPECT_EQ(sheet.width(), 16); // only first frame drawn (second skipped → transparent hole) + EXPECT_EQ(sheet.height(), 8); +} + +TEST_F(ModelTurntableRendererTest, ShutdownIsIdempotent) +{ + ModelTurntableRenderer::shutdown(); + ModelTurntableRenderer::shutdown(); +} + +TEST_F(ModelTurntableRendererTest, ExcludeNormalMapFromFfpChainRemovesDuplicateUnits) +{ + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + ASSERT_NE(sceneMgr, nullptr); + RTShaderHelper::initialize(sceneMgr); + + Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create( + "TurntableDupNormalMat", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* pass = mat->getTechnique(0)->getPass(0); + pass->createTextureUnitState("diffuse.png")->setName("diffuse_map"); + pass->createTextureUnitState("body_normal.png")->setName("NormalMap"); + pass->createTextureUnitState("body_normal.png")->setName("NormalMap"); + + RTShaderHelper::finalizeShaderGenMaterial(mat, "body_normal.png"); + + unsigned short normalUnits = 0; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + const auto& n = pass->getTextureUnitState(i)->getName(); + if (n == "normal_map" || n == "NormalMap") + ++normalUnits; + } + EXPECT_EQ(normalUnits, 1u); + + RTShaderHelper::shutdown(sceneMgr); +} diff --git a/src/RTShaderHelper.cpp b/src/RTShaderHelper.cpp index d26638caa..51a77bf88 100644 --- a/src/RTShaderHelper.cpp +++ b/src/RTShaderHelper.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include // Scheme resolver listener — generates RTSS shader techniques on demand @@ -139,6 +140,12 @@ static void addRTSSResources() void RTShaderHelper::initialize(Ogre::SceneManager* sceneMgr) { + if (auto* existing = Ogre::RTShader::ShaderGenerator::getSingletonPtr()) { + if (sceneMgr) + existing->addSceneManager(sceneMgr); + return; + } + if (!Ogre::RTShader::ShaderGenerator::initialize()) { Ogre::LogManager::getSingleton().logMessage("RTSS: ShaderGenerator failed to initialize"); @@ -236,8 +243,237 @@ bool isMetallicRoughnessMaterial(const Ogre::MaterialPtr& mat) || (tag.empty() && detectMetallicRoughnessByLayout(pass)); } +bool isAlbedoSlotName(const Ogre::String& name) +{ + return name == "diffuse_map" || name == "albedo" || name == "Diffuse" || name == "BaseColor"; +} + +bool isNormalSlotName(const Ogre::String& name) +{ + return name == "normal_map" || name == "NormalMap" || name == "Bump" || name == "bump" + || name == "BumpMap" || name == "height_map"; +} + +bool textureNameLooksLikeNormalMap(const Ogre::String& texName) +{ + if (texName.empty()) + return false; + const QString q = QString::fromStdString(texName).toLower(); + return q.contains(QStringLiteral("normal")) || q.contains(QStringLiteral("bump")) + || q.contains(QStringLiteral("nrm")); +} + +void markNormalUnitNonFfp(Ogre::TextureUnitState* tus) +{ + if (!tus) + return; + if (auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr()) + Ogre::RTShader::ShaderGenerator::_markNonFFP(tus); + // Prevent FFP multitexture from modulating diffuse with the normal RGB. + tus->setColourOperationEx(Ogre::LBX_SOURCE1, Ogre::LBS_CURRENT, Ogre::LBS_CURRENT); +} + +Ogre::String resolveNormalMapTextureName(Ogre::Pass* pass) +{ + if (!pass) + return {}; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + if (isNormalSlotName(tus->getName()) && !tus->getTextureName().empty()) + return tus->getTextureName(); + } + const auto& bindings = pass->getUserObjectBindings(); + auto any = bindings.getUserAny("qtme.normal_map"); + if (any.has_value()) { + try { + return Ogre::any_cast(any); + } catch (...) { + } + } + return {}; +} + +void dedupeAlbedoDiffuseFfp(Ogre::Pass* pass) +{ + if (!pass) + return; + Ogre::TextureUnitState* diffuseTus = nullptr; + Ogre::TextureUnitState* albedoTus = nullptr; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + if (tus->getName() == "diffuse_map") + diffuseTus = tus; + else if (tus->getName() == "albedo") + albedoTus = tus; + } + if (!diffuseTus || !albedoTus) + return; + if (diffuseTus->getTextureName().empty() || albedoTus->getTextureName().empty()) + return; + if (diffuseTus->getTextureName() != albedoTus->getTextureName()) + return; + markNormalUnitNonFfp(diffuseTus); +} + +void removeShaderGenTechnique(Ogre::MaterialPtr& mat) +{ + auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (!shaderGen || !mat) + return; + for (auto* tech : mat->getTechniques()) { + if (tech->getSchemeName() == Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME) { + shaderGen->removeShaderBasedTechnique( + tech, Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME); + break; + } + } +} + } // namespace +void RTShaderHelper::excludeNormalMapFromFfpChain(Ogre::MaterialPtr& mat) +{ + auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (!shaderGen || !mat) + return; + + if (!mat->isLoaded()) { + try { + mat->load(); + } catch (...) { + return; + } + } + if (mat->getNumTechniques() == 0) + return; + auto* pass = mat->getTechnique(0)->getPass(0); + if (!pass) + return; + + Ogre::String canonicalTex; + int16_t canonicalIdx = -1; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + if (!isNormalSlotName(tus->getName()) || tus->getTextureName().empty()) + continue; + canonicalIdx = static_cast(i); + canonicalTex = tus->getTextureName(); + break; + } + if (canonicalTex.empty()) { + const auto& bindings = pass->getUserObjectBindings(); + auto any = bindings.getUserAny("qtme.normal_map"); + if (any.has_value()) { + try { + canonicalTex = Ogre::any_cast(any); + } catch (...) { + } + } + } + if (canonicalIdx < 0 && !canonicalTex.empty()) { + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + if (isAlbedoSlotName(tus->getName())) + continue; + if (tus->getTextureName() == canonicalTex) { + canonicalIdx = static_cast(i); + break; + } + } + } + if (canonicalTex.empty()) + return; + + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + const auto& slot = tus->getName(); + const bool normalRole = isNormalSlotName(slot) + || tus->getTextureName() == canonicalTex + || textureNameLooksLikeNormalMap(tus->getTextureName()); + if (normalRole && !isAlbedoSlotName(slot)) + markNormalUnitNonFfp(tus); + } + + for (int i = static_cast(pass->getNumTextureUnitStates()) - 1; i >= 0; --i) { + if (i == canonicalIdx) + continue; + auto* tus = pass->getTextureUnitState(static_cast(i)); + if (isAlbedoSlotName(tus->getName())) + continue; + const bool extraNormalSlot = isNormalSlotName(tus->getName()) + || tus->getTextureName() == canonicalTex + || textureNameLooksLikeNormalMap(tus->getTextureName()); + if (!extraNormalSlot) + continue; + pass->removeTextureUnitState(static_cast(i)); + if (i < canonicalIdx) + --canonicalIdx; + } + + if (canonicalIdx < 0) + return; + + auto* canonicalTus = pass->getTextureUnitState(static_cast(canonicalIdx)); + canonicalTus->setName("normal_map"); + markNormalUnitNonFfp(canonicalTus); + + const Ogre::String scheme = Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME; + auto* renderState = shaderGen->getRenderState(scheme, *mat, 0); + if (renderState) { + if (auto* normalSrs = renderState->getSubRenderState(Ogre::RTShader::SRS_NORMALMAP)) + normalSrs->setParameter("texture_index", std::to_string(canonicalIdx)); + shaderGen->invalidateMaterial(scheme, mat->getName()); + shaderGen->validateMaterial(scheme, *mat); + } +} + +void RTShaderHelper::finalizeShaderGenMaterial(Ogre::MaterialPtr& mat, + const Ogre::String& normalMapTexName) +{ + auto* shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (!shaderGen || !mat) + return; + + if (!mat->isLoaded()) { + try { + mat->load(); + } catch (...) { + return; + } + } + if (mat->getNumTechniques() == 0) + return; + auto* pass = mat->getTechnique(0)->getPass(0); + if (!pass) + return; + + Ogre::String normalTex = normalMapTexName; + if (normalTex.empty()) + normalTex = resolveNormalMapTextureName(pass); + if (!normalTex.empty()) { + pass->getUserObjectBindings().setUserAny("qtme.normal_map", Ogre::Any(normalTex)); + } + + excludeNormalMapFromFfpChain(mat); + dedupeAlbedoDiffuseFfp(pass); + removeShaderGenTechnique(mat); + + if (!normalTex.empty()) { + applyNormalMap(mat, normalTex); + } else { + bool created = shaderGen->createShaderBasedTechnique( + *mat, Ogre::MaterialManager::DEFAULT_SCHEME_NAME, + Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME); + if (created) { + shaderGen->validateMaterial(Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, + *mat); + } + } + + wirePbrSlotsForFFP(mat.get()); + mat->compile(); +} + void RTShaderHelper::wirePbrSlotsForFFP(Ogre::Material* mat) { if (!mat) return; @@ -247,9 +483,8 @@ void RTShaderHelper::wirePbrSlotsForFFP(Ogre::Material* mat) for (unsigned short i = 0; i < p->getNumTextureUnitStates(); ++i) { auto* tus = p->getTextureUnitState(i); const std::string& n = tus->getName(); - if (n == "normal_map" || n == "NormalMap") { - if (Ogre::RTShader::ShaderGenerator::getSingletonPtr()) - Ogre::RTShader::ShaderGenerator::_markNonFFP(tus); + if (isNormalSlotName(n)) { + markNormalUnitNonFfp(tus); } else if (n == "albedo") { tus->setColourOperationEx( Ogre::LBX_MODULATE, @@ -470,6 +705,8 @@ void RTShaderHelper::applyNormalMap(Ogre::MaterialPtr& mat, const std::string& n Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, *mat); + excludeNormalMapFromFfpChain(mat); + Ogre::LogManager::getSingleton().logMessage( "RTShaderHelper: Normal map '" + normalMapTexName + "' applied to '" + mat->getName() + "'"); } catch (const Ogre::Exception& e) { diff --git a/src/RTShaderHelper.h b/src/RTShaderHelper.h index ec1355dc5..0ed1aabd4 100644 --- a/src/RTShaderHelper.h +++ b/src/RTShaderHelper.h @@ -38,4 +38,17 @@ namespace RTShaderHelper { /// editor produces, so a freshly-imported PBR FBX doesn't render /// darker than the same material after a no-op Apply. void wirePbrSlotsForFFP(Ogre::Material* mat); + + /// FBX imports may leave a normal-map texture in the FFP multitexture chain + /// (e.g. duplicate NormalMap units from a .material script) while RTSS also + /// samples it via SRS_NORMALMAP — the "second layer" look in turntable/CLI. + /// Marks every normal-related TUS non-FFP, removes duplicate units that + /// reuse the same texture, and refreshes SRS_NORMALMAP texture_index. + void excludeNormalMapFromFfpChain(Ogre::MaterialPtr& mat); + + /// Call after all texture units are in place (end of import / turntable). + /// Strips normal/bump from the FFP chain, dedupes diffuse+albedo, removes + /// stale RTSS programs, and rebuilds ShaderGenerator shading once. + void finalizeShaderGenMaterial(Ogre::MaterialPtr& mat, + const Ogre::String& normalMapTexName = {}); } diff --git a/src/main.cpp b/src/main.cpp index a86efee96..6156fe927 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -86,7 +86,7 @@ int main(int argc, char *argv[]) if (arg.startsWith("-")) continue; // skip flags like --verbose if (arg == "info" || arg == "fix" || arg == "convert" || arg == "anim" - || arg == "validate" || arg == "lod" || arg == "pose" + || arg == "validate" || arg == "lod" || arg == "pose" || arg == "turntable" || arg == "scan" || arg == "material" || arg == "pack-textures" || arg == "normal-from-height" || arg == "memory" || arg == "analyze" || arg == "vertex-cache" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9aa63fc82..e35cda1f4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -121,6 +121,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanEngine.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetBrowserController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelTurntableRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditModeController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorModeController.cpp @@ -238,6 +239,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanEngine.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetBrowserController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelTurntableRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditModeController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorModeController.h diff --git a/website/src/App.jsx b/website/src/App.jsx index 9152e9447..d52d8f448 100644 --- a/website/src/App.jsx +++ b/website/src/App.jsx @@ -124,6 +124,7 @@ function App() { { id: 'fix', label: 'Fix', title: 'Fix and optimize', code: pipelineExamples.fix, language: 'fix' }, { id: 'convert', label: 'Convert', title: 'Convert formats', code: pipelineExamples.convert, language: 'convert' }, { id: 'merge', label: 'Merge', title: 'Merge animation clips', code: pipelineExamples.merge, language: 'anim' }, + { id: 'turntable', label: 'Turntable', title: 'Render turntable PNG', code: pipelineExamples.turntable, language: 'turntable' }, { id: 'docker', label: 'Docker', title: 'Docker workflow', code: pipelineExamples.docker, language: 'docker' }, { id: 'gha', label: 'GitHub Actions', title: 'GitHub Actions workflow', code: githubActionExample, language: 'yaml' }, ], diff --git a/website/src/DocsApp.jsx b/website/src/DocsApp.jsx index 140bf393a..235d29497 100644 --- a/website/src/DocsApp.jsx +++ b/website/src/DocsApp.jsx @@ -16,6 +16,7 @@ const NAV = [ { id: 'cmd-validate', label: 'validate' }, { id: 'cmd-lod', label: 'lod' }, { id: 'cmd-pose', label: 'pose' }, + { id: 'cmd-turntable', label: 'turntable' }, { id: 'cmd-scan', label: 'scan' }, ]}, { section: 'Performance', items: [ @@ -216,7 +217,10 @@ qtmesh anim base.fbx --merge walk.fbx run.fbx -o merged.fbx qtmesh validate character.fbx # Scan a directory for asset issues -qtmesh scan ./assets --fail-on error`} +qtmesh scan ./assets --fail-on error + +# Render a product-shot turntable PNG +qtmesh turntable character.fbx -o preview.png --frames 12 --size 512`}

Exit Codes

@@ -397,6 +401,42 @@ qtmesh pose --animation --count N -o `} ]} /> + Headless render-to-texture turntable for mesh previews. Orbits a camera around the model's bounding box and writes PNG frames — either a single horizontal sprite sheet (default for multiple frames) or separate files when -o contains a frame index pattern such as frame_%02d.png. Uses the same Ogre RTSS material path as the editor viewport (normal maps via SRS_NORMALMAP, not as a flat FFP overlay). Supports FBX, glTF, OBJ, and other formats the editor imports.} + synopsis={`qtmesh turntable -o [--frames N] [--size WxH] [--columns C] +qtmesh turntable -o frame_%02d.png [--frames N] [--axis y|x|z]`} + options={[ + ['-o ', 'Output PNG path. One file for a single frame or sprite sheet; use exactly one %d-style index (e.g. frame_%02d.png) for a numbered sequence.'], + ['--frames N', 'Number of views around the orbit (1–360, default 12)'], + ['--size WxH', 'Frame width and height (default 512×512). A single number sets a square size.'], + ['--width N / --height N', 'Override width or height independently'], + ['--columns C', 'Sprite-sheet columns (0 = one row, default for multi-frame)'], + ['--axis y|x|z', 'Orbit axis: Y = XZ turntable (default), X = YZ, Z = XY'], + ['--elevation ', 'Camera angle above the orbit plane in degrees (default 20)'], + ['--camera-height ', 'Alias for --elevation'], + ['--json', 'Emit paths and settings as JSON'], + ]} + examples={[ + 'qtmesh turntable character.fbx -o preview.png --frames 12 --size 512', + 'qtmesh turntable prop.glb -o sheet.png --frames 8 --columns 4 --size 256', + 'qtmesh turntable character.fbx -o frames/frame_%02d.png --frames 24 --axis y --elevation 15', + 'qtmesh turntable character.fbx -o thumb.png --frames 1 --size 128 --json', + ]} + > +

Output modes

+
+ + + + + + +
Frames-o patternResult
1preview.pngSingle PNG
N > 1sheet.png (no %d)One horizontal sprite sheet (N tiles in a row unless --columns is set)
N > 1frame_%02d.pngN separate PNGs (frame_00.png …)
+

+ Sequence patterns must contain exactly one integer conversion (%d, %02d, etc.). + Use %% in the path for a literal percent sign. Invalid numeric flags (e.g. --frames abc) return exit code 2. +

+ + Recursively scan a directory for 3D asset issues. Think of it as ESLint for 3D assets. Checks format restrictions, complexity limits, naming conventions, skeleton/animation content, and more. Supports YAML configuration, scoped rules per folder, JSON output, and auto-fix. Also available as a GitHub Action: {qtmeshActionRef}.} synopsis={`qtmesh scan [path] [options]`} options={[ @@ -1303,7 +1343,11 @@ docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh \\ # Validate geometry docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh \\ - validate model.fbx`} + validate model.fbx + +# Turntable preview PNG +docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh \\ + turntable model.fbx -o preview.png --frames 12 --size 512`}
diff --git a/website/src/data/content.js b/website/src/data/content.js index b472e40a4..27d66569a 100644 --- a/website/src/data/content.js +++ b/website/src/data/content.js @@ -107,6 +107,7 @@ export const pipelineExamples = { fix: `qtmesh fix model.fbx -o fixed.fbx\nqtmesh fix model.fbx --all -o fixed.fbx`, convert: `qtmesh convert model.fbx -o model.glb2\nqtmesh convert model.dae -o model.mesh`, merge: `qtmesh anim base.fbx \\\n --merge walk.fbx run.fbx jump.fbx idle.fbx \\\n -o merged.fbx`, + turntable: `qtmesh turntable character.fbx -o preview.png --frames 12 --size 512\nqtmesh turntable character.fbx -o frames/frame_%02d.png --frames 24`, docker: `docker run --rm --user "$(id -u):$(id -g)" -v $(pwd):/workspace \\\n ghcr.io/fernandotonon/qtmesh scan ./assets --fail-on error`, githubAction: `name: QtMesh Scan\n\non:\n push:\n branches: [ "master" ]\n\njobs:\n scan-assets-qtmesh:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - name: Run QtMesh scan\n uses: __QTMESH_ACTION_REF__\n with:\n command: scan\n image-tag: "__QTMESH_IMAGE_TAG__"\n env:\n QTMESH_CLOUD_TOKEN: \${{ secrets.QTMESH_CLOUD_TOKEN }}`, scanFixConvert: `qtmesh scan ./assets --fail-on error\nqtmesh fix character.fbx --all -o character_fixed.fbx\nqtmesh convert character_fixed.fbx -o character.glb2`