From 03582a3a7f8eb7b0cf54d969094a28c850fdc949 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 19 May 2026 23:14:45 -0400 Subject: [PATCH 01/13] Add turntable PNG export CLI (#294) Headless Ogre render-to-texture captures orbit frames from imported meshes. New `qtmesh turntable` writes a sprite sheet or a %02d frame sequence. Co-authored-by: Cursor --- CLAUDE.md | 2 + action.yml | 2 +- src/CLIPipeline.cpp | 182 +++++++++++++++++++ src/CLIPipeline.h | 2 + src/CLIPipeline_test.cpp | 12 ++ src/CMakeLists.txt | 2 + src/ModelTurntableRenderer.cpp | 260 ++++++++++++++++++++++++++++ src/ModelTurntableRenderer.h | 38 ++++ src/ModelTurntableRenderer_test.cpp | 66 +++++++ src/main.cpp | 2 +- tests/CMakeLists.txt | 2 + 11 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 src/ModelTurntableRenderer.cpp create mode 100644 src/ModelTurntableRenderer.h create mode 100644 src/ModelTurntableRenderer_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index d40b16595..7352f7d06 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 # separate PNG sequence 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 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/CLIPipeline.cpp b/src/CLIPipeline.cpp index 31288c3be..c848022c5 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,10 @@ 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: --width/--height, --elevation , --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" @@ -1076,6 +1081,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); @@ -2781,6 +2787,182 @@ 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] [--elevation deg] [--json] + QString inputPath, outputPath; + int frameCount = 12; + int width = 512; + int height = 512; + int columns = 0; + float elevation = 20.0f; + bool jsonOutput = false; + + 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) { + frameCount = QString(argv[++i]).toInt(); + continue; + } + if (arg == "--columns" && i + 1 < argc) { + columns = QString(argv[++i]).toInt(); + continue; + } + if (arg == "--width" && i + 1 < argc) { + width = QString(argv[++i]).toInt(); + continue; + } + if (arg == "--height" && i + 1 < argc) { + height = QString(argv[++i]).toInt(); + continue; + } + if (arg == "--size" && i + 1 < argc) { + const QString sizeArg = QString(argv[++i]); + const int xPos = sizeArg.indexOf(QLatin1Char('x')); + if (xPos > 0) { + width = sizeArg.left(xPos).toInt(); + height = sizeArg.mid(xPos + 1).toInt(); + } else { + width = height = sizeArg.toInt(); + } + continue; + } + if (arg == "--elevation" && i + 1 < argc) { + elevation = QString(argv[++i]).toFloat(); + 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; + } + + 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)); + + 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 = std::clamp(frameCount, 1, 360); + options.elevationDegrees = elevation; + + QList frames; + QString renderError; + if (!ModelTurntableRenderer::renderToImages(entityList, options, &frames, &renderError)) { + ModelTurntableRenderer::shutdown(); + err() << "Error: " << renderError << Qt::endl; + return 1; + } + + const bool sequenceOutput = outputPath.contains(QLatin1Char('%')); + QStringList writtenPaths; + + if (sequenceOutput) { + for (int f = 0; f < frames.size(); ++f) { + char buf[2048]; + snprintf(buf, sizeof(buf), outputPath.toUtf8().constData(), f); + const QString framePath = QString::fromUtf8(buf); + 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(); + + if (jsonOutput) { + QJsonObject root; + root["input"] = fi.absoluteFilePath(); + root["frames"] = frames.size(); + root["width"] = width; + root["height"] = height; + root["elevation"] = elevation; + 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..4497b6c88 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -902,6 +902,18 @@ 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(CLIPipelineCmdInfoError, NonexistentFile) { TestArgv args({"qtmesh", "info", "/tmp/nonexistent_cli_test_file_12345.fbx"}); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c659ed910..0c0ca78da 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/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp new file mode 100644 index 000000000..08fc4914c --- /dev/null +++ b/src/ModelTurntableRenderer.cpp @@ -0,0 +1,260 @@ +#include "ModelTurntableRenderer.h" + +#include "Manager.h" + +#include + +#include +#include +#include + +#include +#include + +namespace { + +Ogre::SceneManager *sceneMgr() +{ + return Manager::getSingletonPtr() ? Manager::getSingleton()->getSceneMgr() : nullptr; +} + +struct TurntableState { + Ogre::Camera *camera = nullptr; + Ogre::SceneNode *cameraNode = nullptr; + Ogre::TexturePtr rttTexture; + Ogre::RenderTarget *renderTarget = nullptr; + int rttWidth = 0; + int rttHeight = 0; +}; + +TurntableState &state() +{ + static TurntableState s; + return s; +} + +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) + return true; + + 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.cameraNode = sm->getRootSceneNode()->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); + } else { + st.renderTarget->getViewport(0)->setBackgroundColour(bg); + } + + 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) { + shutdown(); + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + return false; + } catch (...) { + shutdown(); + if (errorOut) + *errorOut = QStringLiteral("Failed to create turntable render target"); + return false; + } +} + +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; +} + +void placeCameraForYaw(Ogre::Entity * /*entity*/, const Ogre::AxisAlignedBox &bounds, float yawRadians, + float elevationRadians, float paddingFactor) +{ + TurntableState &st = state(); + if (!st.camera || !st.cameraNode || bounds.isNull() || bounds.isInfinite()) + return; + + const Ogre::Vector3 center = bounds.getCenter(); + Ogre::Real radius = (bounds.getMaximum() - bounds.getMinimum()).length() * 0.5f; + if (radius < 0.1f) + radius = 1.0f; + + const Ogre::Radian fovY = st.camera->getFOVy(); + const Ogre::Real aspect = st.camera->getAspectRatio(); + const Ogre::Radian fovX = Ogre::Radian(2.0f * std::atan(std::tan(fovY.valueRadians() * 0.5f) * aspect)); + const Ogre::Radian fov = std::min(fovX, fovY); + Ogre::Real distance = radius / std::sin(fov.valueRadians() * 0.5f); + distance *= paddingFactor; + + const float cosElev = std::cos(elevationRadians); + const float sinElev = std::sin(elevationRadians); + const Ogre::Vector3 offset(distance * cosElev * std::sin(yawRadians), distance * sinElev, + distance * cosElev * std::cos(yawRadians)); + + st.cameraNode->setPosition(center + offset); + st.cameraNode->lookAt(center, Ogre::Node::TS_WORLD); +} + +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 + +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 && st.camera) { + if (st.cameraNode) { + st.cameraNode->detachObject(st.camera); + sm->destroySceneNode(st.cameraNode); + st.cameraNode = nullptr; + } + sm->destroyCamera(st.camera); + st.camera = 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; + } + + 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; + + const Ogre::AxisAlignedBox bounds = combinedWorldBounds(entities); + if (bounds.isNull() || bounds.isInfinite()) { + if (errorOut) + *errorOut = QStringLiteral("Could not compute model bounds"); + return false; + } + + const float elevationRad = + Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); + + outFrames->reserve(frameCount); + try { + for (int i = 0; i < frameCount; ++i) { + const float yaw = Ogre::Math::TWO_PI * static_cast(i) / static_cast(frameCount); + placeCameraForYaw(entities.first(), bounds, yaw, elevationRad, 1.25f); + state().renderTarget->update(); + outFrames->append(readRenderTarget(width, height)); + } + return true; + } catch (const Ogre::Exception &e) { + outFrames->clear(); + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + return false; + } catch (...) { + outFrames->clear(); + if (errorOut) + *errorOut = QStringLiteral("Turntable 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..4ec63b119 --- /dev/null +++ b/src/ModelTurntableRenderer.h @@ -0,0 +1,38 @@ +#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). + */ +struct TurntableOptions { + int width = 512; + int height = 512; + int frameCount = 12; + float elevationDegrees = 20.0f; + Ogre::ColourValue background{0.12f, 0.12f, 0.13f, 1.0f}; +}; + +class ModelTurntableRenderer +{ +public: + /// Render `frameCount` views around the Y 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); +}; + +#endif // MODELTURNTABLERENDERER_H diff --git a/src/ModelTurntableRenderer_test.cpp b/src/ModelTurntableRenderer_test.cpp new file mode 100644 index 000000000..a0fbd4fe3 --- /dev/null +++ b/src/ModelTurntableRenderer_test.cpp @@ -0,0 +1,66 @@ +#include + +#include "Manager.h" +#include "ModelTurntableRenderer.h" +#include "PrimitiveObject.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, 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); +} 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 e3b44da2c..fc586f889 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 @@ -235,6 +236,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 From d6017a8df2659dd6a4ae69b1c36aeac4d14ba026 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 00:37:42 -0400 Subject: [PATCH 02/13] Fix turntable shutdown call in anonymous namespace Qualify ModelTurntableRenderer::shutdown() so ensureRenderTarget compiles. Co-authored-by: Cursor --- src/ModelTurntableRenderer.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index 08fc4914c..de21933c1 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -47,7 +47,7 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr if (st.renderTarget && st.rttWidth == width && st.rttHeight == height) return true; - shutdown(); + ModelTurntableRenderer::shutdown(); try { st.rttTexture = Ogre::TextureManager::getSingleton().createManual( @@ -82,12 +82,12 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr st.camera->setAspectRatio(aspect); return true; } catch (const Ogre::Exception &e) { - shutdown(); + ModelTurntableRenderer::shutdown(); if (errorOut) *errorOut = QString::fromStdString(e.getFullDescription()); return false; } catch (...) { - shutdown(); + ModelTurntableRenderer::shutdown(); if (errorOut) *errorOut = QStringLiteral("Failed to create turntable render target"); return false; From 3a58c31a1cc8a524869a0007c6b977ca60dc5091 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 00:55:18 -0400 Subject: [PATCH 03/13] Improve turntable lighting, axis control, and selection cleanup Use white ambient plus directional light, hide Ogre selection bounding boxes, orbit on a single selectable axis (y/x/z), and accept --camera-height as an elevation alias. Co-authored-by: Cursor --- CLAUDE.md | 6 +- src/CLIPipeline.cpp | 23 +- src/CLIPipeline_test.cpp | 19 ++ src/ModelTurntableRenderer.cpp | 495 ++++++++++++++++++++------------- src/ModelTurntableRenderer.h | 35 ++- 5 files changed, 367 insertions(+), 211 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7352f7d06..987135c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ qtmesh anim model.fbx --bake-fps 60 --animation "Run" -o out.fbx # bake one ani 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 # separate PNG sequence +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 @@ -87,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. @@ -190,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/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index c848022c5..d545b29ae 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -565,7 +565,8 @@ void CLIPipeline::printUsage() " 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: --width/--height, --elevation , --json\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" @@ -2790,7 +2791,7 @@ 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] [--elevation deg] [--json] + // [--columns C] [--axis y|x|z] [--elevation deg] [--camera-height deg] [--json] QString inputPath, outputPath; int frameCount = 12; int width = 512; @@ -2798,6 +2799,7 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) 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]); @@ -2842,6 +2844,19 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) elevation = QString(argv[++i]).toFloat(); continue; } + if ((arg == "--camera-height" || arg == "--camera_height") && i + 1 < argc) { + elevation = QString(argv[++i]).toFloat(); + 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; @@ -2888,7 +2903,8 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) TurntableOptions options; options.width = width; options.height = height; - options.frameCount = std::clamp(frameCount, 1, 360); + options.frameCount = qBound(1, frameCount, 360); + options.axis = axis; options.elevationDegrees = elevation; QList frames; @@ -2940,6 +2956,7 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) 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) diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 4497b6c88..c49080437 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -18,6 +18,7 @@ #include "SelectionSet.h" #include #include "CLIPipeline.h" +#include "ModelTurntableRenderer.h" #include "MeshImporterExporter.h" #include "SentryReporter.h" #include "TestHelpers.h" @@ -914,6 +915,24 @@ TEST(CLIPipelineCmdTurntableError, NoFile) 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(CLIPipelineCmdInfoError, NonexistentFile) { TestArgv args({"qtmesh", "info", "/tmp/nonexistent_cli_test_file_12345.fbx"}); diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index de21933c1..5b9ea5b66 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -1,6 +1,8 @@ #include "ModelTurntableRenderer.h" +#include "GlobalDefinitions.h" #include "Manager.h" +#include "SelectionSet.h" #include @@ -15,246 +17,353 @@ namespace { Ogre::SceneManager *sceneMgr() { - return Manager::getSingletonPtr() ? Manager::getSingleton()->getSceneMgr() : nullptr; + return Manager::getSingletonPtr() ? Manager::getSingleton()->getSceneMgr() : nullptr; } struct TurntableState { - Ogre::Camera *camera = nullptr; - Ogre::SceneNode *cameraNode = nullptr; - Ogre::TexturePtr rttTexture; - Ogre::RenderTarget *renderTarget = nullptr; - int rttWidth = 0; - int rttHeight = 0; + 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; + 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; + 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) + 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.cameraNode = sm->getRootSceneNode()->createChildSceneNode("ModelTurntableCameraNode"); + st.cameraNode->attachObject(st.camera); } - TurntableState &st = state(); - if (st.renderTarget && st.rttWidth == width && st.rttHeight == height) - return true; + 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); + } else { + Ogre::Viewport *vp = st.renderTarget->getViewport(0); + vp->setBackgroundColour(bg); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + } + 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(); - - 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.cameraNode = sm->getRootSceneNode()->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); - } else { - st.renderTarget->getViewport(0)->setBackgroundColour(bg); - } - - 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; - } + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + return false; + } catch (...) { + ModelTurntableRenderer::shutdown(); + if (errorOut) + *errorOut = QStringLiteral("Failed to create turntable render target"); + return false; + } } 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; + Ogre::AxisAlignedBox box; + box.setNull(); + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + box.merge(entity->getWorldBoundingBox(true)); + } + return box; } -void placeCameraForYaw(Ogre::Entity * /*entity*/, const Ogre::AxisAlignedBox &bounds, float yawRadians, +void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, TurntableAxis axis, float elevationRadians, float paddingFactor) { - TurntableState &st = state(); - if (!st.camera || !st.cameraNode || bounds.isNull() || bounds.isInfinite()) - return; - - const Ogre::Vector3 center = bounds.getCenter(); - Ogre::Real radius = (bounds.getMaximum() - bounds.getMinimum()).length() * 0.5f; - if (radius < 0.1f) - radius = 1.0f; - - const Ogre::Radian fovY = st.camera->getFOVy(); - const Ogre::Real aspect = st.camera->getAspectRatio(); - const Ogre::Radian fovX = Ogre::Radian(2.0f * std::atan(std::tan(fovY.valueRadians() * 0.5f) * aspect)); - const Ogre::Radian fov = std::min(fovX, fovY); - Ogre::Real distance = radius / std::sin(fov.valueRadians() * 0.5f); - distance *= paddingFactor; - - const float cosElev = std::cos(elevationRadians); - const float sinElev = std::sin(elevationRadians); - const Ogre::Vector3 offset(distance * cosElev * std::sin(yawRadians), distance * sinElev, - distance * cosElev * std::cos(yawRadians)); - - st.cameraNode->setPosition(center + offset); - st.cameraNode->lookAt(center, Ogre::Node::TS_WORLD); + TurntableState &st = state(); + if (!st.camera || !st.cameraNode || bounds.isNull() || bounds.isInfinite()) + return; + + const Ogre::Vector3 center = bounds.getCenter(); + Ogre::Real radius = (bounds.getMaximum() - bounds.getMinimum()).length() * 0.5f; + if (radius < 0.1f) + radius = 1.0f; + + const Ogre::Radian fovY = st.camera->getFOVy(); + const Ogre::Real aspect = st.camera->getAspectRatio(); + const Ogre::Radian fovX = Ogre::Radian(2.0f * std::atan(std::tan(fovY.valueRadians() * 0.5f) * aspect)); + const Ogre::Radian fov = std::min(fovX, fovY); + Ogre::Real distance = radius / std::sin(fov.valueRadians() * 0.5f); + distance *= paddingFactor; + + const float horiz = distance * std::cos(elevationRadians); + const float axial = distance * std::sin(elevationRadians); + const float s = std::sin(angleRadians); + const float c = std::cos(angleRadians); + + Ogre::Vector3 offset = Ogre::Vector3::ZERO; + switch (axis) { + case TurntableAxis::Y: + offset = Ogre::Vector3(horiz * s, axial, horiz * c); + break; + case TurntableAxis::X: + offset = Ogre::Vector3(axial, horiz * s, horiz * c); + break; + case TurntableAxis::Z: + offset = Ogre::Vector3(horiz * s, horiz * c, axial); + break; + } + + st.cameraNode->setPosition(center + offset); + st.cameraNode->lookAt(center, Ogre::Node::TS_WORLD); } 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); + 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; + 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.rttTexture) { - Ogre::TextureManager::getSingleton().remove(st.rttTexture); - st.rttTexture.reset(); + if (st.light) { + if (st.lightNode) { + st.lightNode->detachObject(st.light); + sm->destroySceneNode(st.lightNode); + st.lightNode = nullptr; + } + sm->destroyLight(st.light); + st.light = nullptr; } - st.rttWidth = 0; - st.rttHeight = 0; - - if (sm && st.camera) { - if (st.cameraNode) { - st.cameraNode->detachObject(st.camera); - sm->destroySceneNode(st.cameraNode); - st.cameraNode = nullptr; - } - sm->destroyCamera(st.camera); - st.camera = nullptr; + if (st.camera) { + if (st.cameraNode) { + st.cameraNode->detachObject(st.camera); + sm->destroySceneNode(st.cameraNode); + st.cameraNode = nullptr; + } + sm->destroyCamera(st.camera); + st.camera = 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; + 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; + + const Ogre::AxisAlignedBox bounds = combinedWorldBounds(entities); + if (bounds.isNull() || bounds.isInfinite()) { + if (errorOut) + *errorOut = QStringLiteral("Could not compute model bounds"); + return false; + } + + const float elevationRad = + Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); + + prepareSceneForCapture(entities); + applyTurntableLighting(sm); + + 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); + return true; + } catch (const Ogre::Exception &e) { outFrames->clear(); - - if (entities.isEmpty()) { - if (errorOut) - *errorOut = QStringLiteral("No entities to render"); - 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; - - const Ogre::AxisAlignedBox bounds = combinedWorldBounds(entities); - if (bounds.isNull() || bounds.isInfinite()) { - if (errorOut) - *errorOut = QStringLiteral("Could not compute model bounds"); - return false; - } - - const float elevationRad = - Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); - - outFrames->reserve(frameCount); - try { - for (int i = 0; i < frameCount; ++i) { - const float yaw = Ogre::Math::TWO_PI * static_cast(i) / static_cast(frameCount); - placeCameraForYaw(entities.first(), bounds, yaw, elevationRad, 1.25f); - state().renderTarget->update(); - outFrames->append(readRenderTarget(width, height)); - } - return true; - } catch (const Ogre::Exception &e) { - outFrames->clear(); - if (errorOut) - *errorOut = QString::fromStdString(e.getFullDescription()); - return false; - } catch (...) { - outFrames->clear(); - if (errorOut) - *errorOut = QStringLiteral("Turntable render failed"); - return false; - } + restoreTurntableLighting(sm); + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + return false; + } catch (...) { + outFrames->clear(); + restoreTurntableLighting(sm); + if (errorOut) + *errorOut = QStringLiteral("Turntable 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; + 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 index 4ec63b119..0b0a284af 100644 --- a/src/ModelTurntableRenderer.h +++ b/src/ModelTurntableRenderer.h @@ -13,26 +13,37 @@ * 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; - float elevationDegrees = 20.0f; - Ogre::ColourValue background{0.12f, 0.12f, 0.13f, 1.0f}; + 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 Y axis. Returns false on failure. - static bool renderToImages(const QList &entities, const TurntableOptions &options, - QList *outFrames, QString *errorOut = nullptr); + /// 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(); - /// 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); - /// 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 From 742cdcb1a5957f8258ed7c0f4c05bfa3723fca22 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 02:33:24 -0400 Subject: [PATCH 04/13] Fix turntable single-axis orbit and RTSS normal maps Orbit camera with a stable world-up view matrix instead of lookAt roll, and render through the ShaderGenerator viewport scheme with per-material RTSS validation so normal maps shade correctly. Co-authored-by: Cursor --- src/ModelTurntableRenderer.cpp | 137 +++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 16 deletions(-) diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index 5b9ea5b66..ebb29d12e 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -2,16 +2,19 @@ #include "GlobalDefinitions.h" #include "Manager.h" +#include "RTShaderHelper.h" #include "SelectionSet.h" #include #include #include +#include #include #include #include +#include namespace { @@ -89,8 +92,14 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr } TurntableState &st = state(); - if (st.renderTarget && st.rttWidth == width && st.rttHeight == height) + if (st.renderTarget && st.rttWidth == width && st.rttHeight == height) { + if (st.renderTarget->getNumViewports() > 0) { + Ogre::Viewport *vp = st.renderTarget->getViewport(0); + vp->setMaterialScheme(Ogre::MSN_SHADERGEN); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + } return true; + } ModelTurntableRenderer::shutdown(); @@ -119,10 +128,14 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr 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 = @@ -154,6 +167,55 @@ Ogre::AxisAlignedBox combinedWorldBounds(const QList &entities) return box; } +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; + } +} + +/// 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); + } +} + +/// Ogre cameras look down local -Z; keep world +Y as up so the horizon stays level. +void orientCameraToward(Ogre::SceneNode *cameraNode, const Ogre::Vector3 &eye, const Ogre::Vector3 &target, + const Ogre::Vector3 &worldUp) +{ + Ogre::Vector3 forward = target - eye; + if (forward.squaredLength() < 1e-8f) + return; + forward.normalise(); + + Ogre::Vector3 side = worldUp.crossProduct(forward); + if (side.squaredLength() < 1e-8f) { + // Looking straight up/down — pick a fallback up. + side = Ogre::Vector3::UNIT_X.crossProduct(forward); + } + side.normalise(); + const Ogre::Vector3 up = forward.crossProduct(side); + + Ogre::Matrix3 rot; + rot.FromAxes(side, up, -forward); + cameraNode->setOrientation(Ogre::Quaternion(rot)); +} + void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, TurntableAxis axis, float elevationRadians, float paddingFactor) { @@ -175,24 +237,66 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T const float horiz = distance * std::cos(elevationRadians); const float axial = distance * std::sin(elevationRadians); - const float s = std::sin(angleRadians); - const float c = std::cos(angleRadians); + const Ogre::Vector3 localOffset = cameraRestOffset(axis, horiz, axial); + const Ogre::Quaternion orbit(Ogre::Radian(angleRadians), orbitAxisVector(axis)); + const Ogre::Vector3 eye = center + orbit * localOffset; - Ogre::Vector3 offset = Ogre::Vector3::ZERO; - switch (axis) { - case TurntableAxis::Y: - offset = Ogre::Vector3(horiz * s, axial, horiz * c); - break; - case TurntableAxis::X: - offset = Ogre::Vector3(axial, horiz * s, horiz * c); - break; - case TurntableAxis::Z: - offset = Ogre::Vector3(horiz * s, horiz * c, axial); - break; + st.cameraNode->setPosition(eye); + orientCameraToward(st.cameraNode, eye, center, Ogre::Vector3::UNIT_Y); +} + +std::string normalMapTextureName(const Ogre::MaterialPtr &mat) +{ + if (!mat || mat->getNumTechniques() == 0) + return {}; + Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); + if (!pass) + return {}; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + Ogre::TextureUnitState *tus = pass->getTextureUnitState(i); + const Ogre::String &slot = tus->getName(); + if ((slot == "normal_map" || slot == "NormalMap") && !tus->getTextureName().empty()) + return tus->getTextureName(); } + return {}; +} + +void prepareRtssMaterials(const QList &entities) +{ + auto *shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (!shaderGen) + return; + + std::unordered_set processed; + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + for (unsigned int sub = 0; sub < entity->getNumSubEntities(); ++sub) { + Ogre::MaterialPtr mat = entity->getSubEntity(sub)->getMaterial(); + if (!mat) + continue; + const std::string key = mat->getName(); + if (!processed.insert(key).second) + continue; + + RTShaderHelper::wirePbrSlotsForFFP(mat.get()); + if (RTShaderHelper::applyPbrIfTagged(mat)) { + mat->compile(); + continue; + } - st.cameraNode->setPosition(center + offset); - st.cameraNode->lookAt(center, Ogre::Node::TS_WORLD); + const std::string normalTex = normalMapTextureName(mat); + if (!normalTex.empty()) { + RTShaderHelper::applyNormalMap(mat, normalTex); + } else { + shaderGen->createShaderBasedTechnique( + *mat, Ogre::MaterialManager::DEFAULT_SCHEME_NAME, + Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME); + shaderGen->validateMaterial(Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, *mat); + } + mat->compile(); + } + } } QImage readRenderTarget(int width, int height) @@ -315,6 +419,7 @@ bool ModelTurntableRenderer::renderToImages(const QList &entitie Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); prepareSceneForCapture(entities); + prepareRtssMaterials(entities); applyTurntableLighting(sm); outFrames->reserve(frameCount); From 0e430d3146458f25a56d99d8dc2a84254aa04b3b Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 02:39:20 -0400 Subject: [PATCH 05/13] Center turntable subject and fit camera to AABB corners Recenter imported meshes at the world origin before capture and compute orbit distance from all eight bounding-box corners so the model stays framed in the viewport at any elevation. Co-authored-by: Cursor --- src/ModelTurntableRenderer.cpp | 105 ++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 15 deletions(-) diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index ebb29d12e..c2f8341b1 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -155,6 +155,16 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr } } +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; @@ -167,6 +177,27 @@ Ogre::AxisAlignedBox combinedWorldBounds(const QList &entities) 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; + + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + if (Ogre::SceneNode *node = entity->getParentSceneNode()) + node->translate(-center, Ogre::Node::TS_WORLD); + } + + bounds.setExtents(bounds.getMinimum() - center, bounds.getMaximum() - center); + refreshEntityBounds(entities); +} + Ogre::Vector3 orbitAxisVector(TurntableAxis axis) { switch (axis) { @@ -216,6 +247,50 @@ void orientCameraToward(Ogre::SceneNode *cameraNode, const Ogre::Vector3 &eye, c cameraNode->setOrientation(Ogre::Quaternion(rot)); } +/// Minimum orbit radius so every AABB corner fits in the camera frustum from `viewDir`. +Ogre::Real fitOrbitDistance(const Ogre::AxisAlignedBox &bounds, const Ogre::Vector3 &viewDir, + Ogre::Camera *camera, float paddingFactor) +{ + const Ogre::Vector3 center = bounds.getCenter(); + Ogre::Vector3 dir = viewDir; + if (dir.squaredLength() < 1e-8f) + dir = Ogre::Vector3(0.0f, 0.0f, 1.0f); + dir.normalise(); + + Ogre::Vector3 up = Ogre::Vector3::UNIT_Y; + Ogre::Vector3 side = up.crossProduct(dir); + if (side.squaredLength() < 1e-8f) { + up = Ogre::Vector3::UNIT_X; + side = up.crossProduct(dir); + } + side.normalise(); + up = dir.crossProduct(side); + up.normalise(); + + 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) { @@ -224,20 +299,16 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T return; const Ogre::Vector3 center = bounds.getCenter(); - Ogre::Real radius = (bounds.getMaximum() - bounds.getMinimum()).length() * 0.5f; - if (radius < 0.1f) - radius = 1.0f; - - const Ogre::Radian fovY = st.camera->getFOVy(); - const Ogre::Real aspect = st.camera->getAspectRatio(); - const Ogre::Radian fovX = Ogre::Radian(2.0f * std::atan(std::tan(fovY.valueRadians() * 0.5f) * aspect)); - const Ogre::Radian fov = std::min(fovX, fovY); - Ogre::Real distance = radius / std::sin(fov.valueRadians() * 0.5f); - distance *= paddingFactor; - - const float horiz = distance * std::cos(elevationRadians); - const float axial = distance * std::sin(elevationRadians); - const Ogre::Vector3 localOffset = cameraRestOffset(axis, horiz, axial); + + const float horizUnit = std::cos(elevationRadians); + const float axialUnit = std::sin(elevationRadians); + Ogre::Vector3 restDir = cameraRestOffset(axis, horizUnit, axialUnit); + if (restDir.squaredLength() < 1e-8f) + restDir = cameraRestOffset(axis, 1.0f, 0.0f); + restDir.normalise(); + + const Ogre::Real distance = fitOrbitDistance(bounds, restDir, st.camera, paddingFactor); + const Ogre::Vector3 localOffset = cameraRestOffset(axis, distance * horizUnit, distance * axialUnit); const Ogre::Quaternion orbit(Ogre::Radian(angleRadians), orbitAxisVector(axis)); const Ogre::Vector3 eye = center + orbit * localOffset; @@ -408,13 +479,17 @@ bool ModelTurntableRenderer::renderToImages(const QList &entitie if (!ensureRenderTarget(width, height, options.background, errorOut)) return false; - const Ogre::AxisAlignedBox bounds = combinedWorldBounds(entities); + 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(); From 7fed63930a0908f3c722e9c62560681a73c9e06c Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 02:44:06 -0400 Subject: [PATCH 06/13] Fix turntable framing with pivot rig and unique recenter Use a pivot node so the camera always looks at the orbit point, dedupe scene-node translation on multi-entity imports, and apply a small vertical framing bias for upright assets. Co-authored-by: Cursor --- src/ModelTurntableRenderer.cpp | 96 +++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index c2f8341b1..2247ed713 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -24,6 +24,7 @@ Ogre::SceneManager *sceneMgr() } struct TurntableState { + Ogre::SceneNode *pivotNode = nullptr; Ogre::Camera *camera = nullptr; Ogre::SceneNode *cameraNode = nullptr; Ogre::Light *light = nullptr; @@ -117,7 +118,8 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr st.camera->setNearClipDistance(0.01f); st.camera->setFarClipDistance(100000.0f); st.camera->setFOVy(Ogre::Degree(45.0f)); - st.cameraNode = sm->getRootSceneNode()->createChildSceneNode("ModelTurntableCameraNode"); + st.pivotNode = sm->getRootSceneNode()->createChildSceneNode("ModelTurntablePivot"); + st.cameraNode = st.pivotNode->createChildSceneNode("ModelTurntableCameraNode"); st.cameraNode->attachObject(st.camera); } @@ -187,11 +189,14 @@ void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisA if (center.squaredLength() < 1e-10f) return; + std::unordered_set shifted; for (Ogre::Entity *entity : entities) { if (!entity) continue; - if (Ogre::SceneNode *node = entity->getParentSceneNode()) - node->translate(-center, Ogre::Node::TS_WORLD); + 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); @@ -211,6 +216,16 @@ Ogre::Vector3 orbitAxisVector(TurntableAxis axis) } } +/// 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) { @@ -225,47 +240,37 @@ Ogre::Vector3 cameraRestOffset(TurntableAxis axis, float horizDistance, float ax } } -/// Ogre cameras look down local -Z; keep world +Y as up so the horizon stays level. -void orientCameraToward(Ogre::SceneNode *cameraNode, const Ogre::Vector3 &eye, const Ogre::Vector3 &target, - const Ogre::Vector3 &worldUp) +/// 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 = target - eye; + Ogre::Vector3 forward = viewDir; if (forward.squaredLength() < 1e-8f) - return; + forward = Ogre::Vector3(0.0f, 0.0f, 1.0f); forward.normalise(); - Ogre::Vector3 side = worldUp.crossProduct(forward); - if (side.squaredLength() < 1e-8f) { - // Looking straight up/down — pick a fallback up. - side = Ogre::Vector3::UNIT_X.crossProduct(forward); - } + Ogre::Vector3 side = forward.crossProduct(worldUp); + if (side.squaredLength() < 1e-8f) + side = forward.crossProduct(Ogre::Vector3::UNIT_X); side.normalise(); - const Ogre::Vector3 up = forward.crossProduct(side); - - Ogre::Matrix3 rot; - rot.FromAxes(side, up, -forward); - cameraNode->setOrientation(Ogre::Quaternion(rot)); + 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 &viewDir, - Ogre::Camera *camera, float paddingFactor) +Ogre::Real fitOrbitDistance(const Ogre::AxisAlignedBox &bounds, const Ogre::Vector3 &pivotPoint, + const Ogre::Vector3 &viewDir, Ogre::Camera *camera, float paddingFactor) { - const Ogre::Vector3 center = bounds.getCenter(); + 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 up = Ogre::Vector3::UNIT_Y; - Ogre::Vector3 side = up.crossProduct(dir); - if (side.squaredLength() < 1e-8f) { - up = Ogre::Vector3::UNIT_X; - side = up.crossProduct(dir); - } - side.normalise(); - up = dir.crossProduct(side); - up.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(); @@ -295,10 +300,10 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T float elevationRadians, float paddingFactor) { TurntableState &st = state(); - if (!st.camera || !st.cameraNode || bounds.isNull() || bounds.isInfinite()) + if (!st.camera || !st.cameraNode || !st.pivotNode || bounds.isNull() || bounds.isInfinite()) return; - const Ogre::Vector3 center = bounds.getCenter(); + const Ogre::Vector3 pivotPoint = turntablePivotPoint(bounds); const float horizUnit = std::cos(elevationRadians); const float axialUnit = std::sin(elevationRadians); @@ -307,13 +312,15 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T restDir = cameraRestOffset(axis, 1.0f, 0.0f); restDir.normalise(); - const Ogre::Real distance = fitOrbitDistance(bounds, restDir, st.camera, paddingFactor); - const Ogre::Vector3 localOffset = cameraRestOffset(axis, distance * horizUnit, distance * axialUnit); - const Ogre::Quaternion orbit(Ogre::Radian(angleRadians), orbitAxisVector(axis)); - const Ogre::Vector3 eye = center + orbit * localOffset; + const Ogre::Real distance = fitOrbitDistance(bounds, pivotPoint, restDir, st.camera, paddingFactor); + const float horiz = distance * horizUnit; + const float axial = distance * axialUnit; - st.cameraNode->setPosition(eye); - orientCameraToward(st.cameraNode, eye, center, Ogre::Vector3::UNIT_Y); + // 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); } std::string normalMapTextureName(const Ogre::MaterialPtr &mat) @@ -437,14 +444,19 @@ void ModelTurntableRenderer::shutdown() st.light = nullptr; } if (st.camera) { - if (st.cameraNode) { + if (st.cameraNode) st.cameraNode->detachObject(st.camera); - sm->destroySceneNode(st.cameraNode); - st.cameraNode = nullptr; - } sm->destroyCamera(st.camera); st.camera = nullptr; } + if (st.cameraNode) { + sm->destroySceneNode(st.cameraNode); + st.cameraNode = nullptr; + } + if (st.pivotNode) { + sm->destroySceneNode(st.pivotNode); + st.pivotNode = nullptr; + } } } From c38ae5e3bc6701705b8c84f23612f6d36c2a1fd5 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 02:50:48 -0400 Subject: [PATCH 07/13] Turntable: stop re-mutating materials for RTSS Import already wires RTSS like the editor; re-applying wirePbrSlotsForFFP and applyNormalMap could leave the normal map in the FFP texture stack while RTSS also sampled it. Rely on MSN_SHADERGEN viewport + imported materials. Co-authored-by: Cursor --- src/ModelTurntableRenderer.cpp | 61 +++------------------------------- 1 file changed, 4 insertions(+), 57 deletions(-) diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index 2247ed713..a8cf759a8 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -2,14 +2,12 @@ #include "GlobalDefinitions.h" #include "Manager.h" -#include "RTShaderHelper.h" #include "SelectionSet.h" #include #include #include -#include #include #include @@ -323,60 +321,6 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T st.cameraNode->lookAt(Ogre::Vector3::ZERO, Ogre::Node::TS_PARENT); } -std::string normalMapTextureName(const Ogre::MaterialPtr &mat) -{ - if (!mat || mat->getNumTechniques() == 0) - return {}; - Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); - if (!pass) - return {}; - for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { - Ogre::TextureUnitState *tus = pass->getTextureUnitState(i); - const Ogre::String &slot = tus->getName(); - if ((slot == "normal_map" || slot == "NormalMap") && !tus->getTextureName().empty()) - return tus->getTextureName(); - } - return {}; -} - -void prepareRtssMaterials(const QList &entities) -{ - auto *shaderGen = Ogre::RTShader::ShaderGenerator::getSingletonPtr(); - if (!shaderGen) - return; - - std::unordered_set processed; - for (Ogre::Entity *entity : entities) { - if (!entity) - continue; - for (unsigned int sub = 0; sub < entity->getNumSubEntities(); ++sub) { - Ogre::MaterialPtr mat = entity->getSubEntity(sub)->getMaterial(); - if (!mat) - continue; - const std::string key = mat->getName(); - if (!processed.insert(key).second) - continue; - - RTShaderHelper::wirePbrSlotsForFFP(mat.get()); - if (RTShaderHelper::applyPbrIfTagged(mat)) { - mat->compile(); - continue; - } - - const std::string normalTex = normalMapTextureName(mat); - if (!normalTex.empty()) { - RTShaderHelper::applyNormalMap(mat, normalTex); - } else { - shaderGen->createShaderBasedTechnique( - *mat, Ogre::MaterialManager::DEFAULT_SCHEME_NAME, - Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME); - shaderGen->validateMaterial(Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, *mat); - } - mat->compile(); - } - } -} - QImage readRenderTarget(int width, int height) { QImage image(width, height, QImage::Format_RGBA8888); @@ -506,7 +450,10 @@ bool ModelTurntableRenderer::renderToImages(const QList &entitie Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); prepareSceneForCapture(entities); - prepareRtssMaterials(entities); + // Materials match the in-editor path: MeshImporterExporter / MaterialProcessor + // already wire RTSS (applyRTSSNormalMap, wirePbrSlotsForFFP). Do not mutate + // materials here — re-applying RTSS can leave the normal map in the FFP + // multi-texture chain while also using SRS_NORMALMAP (double layer look). applyTurntableLighting(sm); outFrames->reserve(frameCount); From d5fdbd9216daf4b98426c26bc291bdd0cb38383d Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 02:53:21 -0400 Subject: [PATCH 08/13] Add turntable CLI and renderer unit tests Cover cmdTurntable success paths (sprite sheet, sequence, columns, JSON), parseAxis edge cases, composeSpriteSheet layouts, axis orbit variants, and null output rejection. Co-authored-by: Cursor --- src/CLIPipeline_test.cpp | 93 +++++++++++++++++++++++++++++ src/ModelTurntableRenderer_test.cpp | 81 +++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index c49080437..504628485 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -933,6 +933,99 @@ TEST(ModelTurntableAxisParse, ValidAxes) 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_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_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_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/ModelTurntableRenderer_test.cpp b/src/ModelTurntableRenderer_test.cpp index a0fbd4fe3..5b3b7eef4 100644 --- a/src/ModelTurntableRenderer_test.cpp +++ b/src/ModelTurntableRenderer_test.cpp @@ -64,3 +64,84 @@ TEST_F(ModelTurntableRendererTest, ComposeSpriteSheet) 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(); +} From 10c9db6b7ec0915b660fcc01c9bfb7304dccb1b3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 03:49:18 -0400 Subject: [PATCH 09/13] Extend turntable tests for remaining CLI and renderer branches Cover --size WxH parsing, --cli flag handling, camera_height alias, null-only entity lists, and turntable option clamping. Co-authored-by: Cursor --- src/CLIPipeline_test.cpp | 39 +++++++++++++++++++++++++++++ src/ModelTurntableRenderer_test.cpp | 37 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 504628485..9aad974c5 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -959,6 +959,25 @@ TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSpriteSheet) 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; @@ -985,6 +1004,26 @@ TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSequenceAndAxis) 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; diff --git a/src/ModelTurntableRenderer_test.cpp b/src/ModelTurntableRenderer_test.cpp index 5b3b7eef4..226533564 100644 --- a/src/ModelTurntableRenderer_test.cpp +++ b/src/ModelTurntableRenderer_test.cpp @@ -28,6 +28,43 @@ TEST_F(ModelTurntableRendererTest, RejectsEmptyEntityList) 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")); From 52bca5cbbc9bee86fe257a6074406788f6ca73ea Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 08:37:55 -0400 Subject: [PATCH 10/13] Fix turntable normal map showing as duplicate FFP layer Strip duplicate normal-map texture units from the FFP multitexture chain while keeping SRS_NORMALMAP sampling, and run that sync before turntable capture. Also resolve normal maps via qtme.normal_map UOB when TUS names are missing (typical on FBX imports like Jump.fbx). Co-authored-by: Cursor --- src/Assimp/MaterialProcessor.cpp | 1 + src/CLIPipeline_test.cpp | 21 +++++ src/MeshImporterExporter.cpp | 51 ++++++++---- src/ModelTurntableRenderer.cpp | 30 ++++++- src/ModelTurntableRenderer_test.cpp | 26 ++++++ src/RTShaderHelper.cpp | 125 ++++++++++++++++++++++++++++ src/RTShaderHelper.h | 7 ++ 7 files changed, 239 insertions(+), 22 deletions(-) diff --git a/src/Assimp/MaterialProcessor.cpp b/src/Assimp/MaterialProcessor.cpp index d17916c6f..326439949 100644 --- a/src/Assimp/MaterialProcessor.cpp +++ b/src/Assimp/MaterialProcessor.cpp @@ -427,4 +427,5 @@ void MaterialProcessor::applyRTSSNormalMap(Ogre::MaterialPtr mat, const Ogre::St pass->getUserObjectBindings().setUserAny( "qtme.normal_map", Ogre::Any(normalMapName)); } + RTShaderHelper::excludeNormalMapFromFfpChain(mat); } diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 9aad974c5..94bd2583f 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -1046,6 +1046,27 @@ TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSpriteSheetColumnsAndCameraHei EXPECT_EQ(img.height(), 80); // 2 rows × 40 } +TEST_F(CLIPipelineCmdTest, CmdTurntable_JumpFbxRendersWithoutError) +{ + const QString jumpPath = QStringLiteral("/home/fernando/Downloads/Jump.fbx"); + if (!QFile::exists(jumpPath)) + GTEST_SKIP() << "Jump.fbx not present at " << jumpPath.toStdString(); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outArg = tmp.filePath("jump_turntable.png").toUtf8(); + TestArgv args({"qtmesh", "turntable", jumpPath.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_MinimalObjSingleFrameWritesOnePng) { QTemporaryDir tmp; diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 6a2208699..c1ad9f258 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1285,31 +1285,46 @@ 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::applyNormalMap(mat, texName); + RTShaderHelper::excludeNormalMapFromFfpChain(mat); } } diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index a8cf759a8..220ce6e03 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -2,6 +2,8 @@ #include "GlobalDefinitions.h" #include "Manager.h" +#include "MeshImporterExporter.h" +#include "RTShaderHelper.h" #include "SelectionSet.h" #include @@ -321,6 +323,27 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T 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; + const std::string key = mat->getName(); + if (!processed.insert(key).second) + continue; + RTShaderHelper::excludeNormalMapFromFfpChain(mat); + RTShaderHelper::wirePbrSlotsForFFP(mat.get()); + mat->compile(); + } + } +} + QImage readRenderTarget(int width, int height) { QImage image(width, height, QImage::Format_RGBA8888); @@ -450,10 +473,9 @@ bool ModelTurntableRenderer::renderToImages(const QList &entitie Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); prepareSceneForCapture(entities); - // Materials match the in-editor path: MeshImporterExporter / MaterialProcessor - // already wire RTSS (applyRTSSNormalMap, wirePbrSlotsForFFP). Do not mutate - // materials here — re-applying RTSS can leave the normal map in the FFP - // multi-texture chain while also using SRS_NORMALMAP (double layer look). + // 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); outFrames->reserve(frameCount); diff --git a/src/ModelTurntableRenderer_test.cpp b/src/ModelTurntableRenderer_test.cpp index 226533564..407eea9db 100644 --- a/src/ModelTurntableRenderer_test.cpp +++ b/src/ModelTurntableRenderer_test.cpp @@ -3,6 +3,7 @@ #include "Manager.h" #include "ModelTurntableRenderer.h" #include "PrimitiveObject.h" +#include "RTShaderHelper.h" #include "TestHelpers.h" #include @@ -182,3 +183,28 @@ 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::applyNormalMap(mat, "body_normal.png"); + RTShaderHelper::excludeNormalMapFromFfpChain(mat); + + 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); +} diff --git a/src/RTShaderHelper.cpp b/src/RTShaderHelper.cpp index d26638caa..d355eba97 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 @@ -236,8 +237,130 @@ 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); +} + } // 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; + if (tus->getTextureName() == canonicalTex) + 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::wirePbrSlotsForFFP(Ogre::Material* mat) { if (!mat) return; @@ -470,6 +593,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..939f6a254 100644 --- a/src/RTShaderHelper.h +++ b/src/RTShaderHelper.h @@ -38,4 +38,11 @@ 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); } From 5e6b6d97db178be9b9f6f30d72736643f5620980 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 08:52:25 -0400 Subject: [PATCH 11/13] Rebuild RTSS after import layout to fix normal map double layer Defer ShaderGenerator wiring until all PBR texture units exist, remove extra Bump/NormalMap units, dedupe diffuse+albedo FFP modulation, and rebuild the shader technique on turntable capture (Jump.fbx and similar). Co-authored-by: Cursor --- src/Assimp/MaterialProcessor.cpp | 101 ++++++++++++------------ src/MeshImporterExporter.cpp | 3 +- src/ModelTurntableRenderer.cpp | 4 +- src/ModelTurntableRenderer_test.cpp | 3 +- src/RTShaderHelper.cpp | 116 ++++++++++++++++++++++++++-- src/RTShaderHelper.h | 6 ++ 6 files changed, 170 insertions(+), 63 deletions(-) diff --git a/src/Assimp/MaterialProcessor.cpp b/src/Assimp/MaterialProcessor.cpp index 326439949..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,22 +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::excludeNormalMapFromFfpChain(mat); + RTShaderHelper::finalizeShaderGenMaterial(mat, normalMapName); } diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index c1ad9f258..1bc5c6931 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1323,8 +1323,7 @@ void MeshImporterExporter::applyNormalMapsToEntity(const Ogre::Entity* en) } catch (...) {} } } - RTShaderHelper::applyNormalMap(mat, texName); - RTShaderHelper::excludeNormalMapFromFfpChain(mat); + RTShaderHelper::finalizeShaderGenMaterial(mat, texName); } } diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index 220ce6e03..89c7ba208 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -337,9 +337,7 @@ void prepareMaterialsForTurntable(const QList &entities) const std::string key = mat->getName(); if (!processed.insert(key).second) continue; - RTShaderHelper::excludeNormalMapFromFfpChain(mat); - RTShaderHelper::wirePbrSlotsForFFP(mat.get()); - mat->compile(); + RTShaderHelper::finalizeShaderGenMaterial(mat); } } } diff --git a/src/ModelTurntableRenderer_test.cpp b/src/ModelTurntableRenderer_test.cpp index 407eea9db..d09f3df22 100644 --- a/src/ModelTurntableRenderer_test.cpp +++ b/src/ModelTurntableRenderer_test.cpp @@ -197,8 +197,7 @@ TEST_F(ModelTurntableRendererTest, ExcludeNormalMapFromFfpChainRemovesDuplicateU pass->createTextureUnitState("body_normal.png")->setName("NormalMap"); pass->createTextureUnitState("body_normal.png")->setName("NormalMap"); - RTShaderHelper::applyNormalMap(mat, "body_normal.png"); - RTShaderHelper::excludeNormalMapFromFfpChain(mat); + RTShaderHelper::finalizeShaderGenMaterial(mat, "body_normal.png"); unsigned short normalUnits = 0; for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { diff --git a/src/RTShaderHelper.cpp b/src/RTShaderHelper.cpp index d355eba97..d9ba89444 100644 --- a/src/RTShaderHelper.cpp +++ b/src/RTShaderHelper.cpp @@ -267,6 +267,62 @@ void markNormalUnitNonFfp(Ogre::TextureUnitState* tus) 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) @@ -338,8 +394,12 @@ void RTShaderHelper::excludeNormalMapFromFfpChain(Ogre::MaterialPtr& mat) auto* tus = pass->getTextureUnitState(static_cast(i)); if (isAlbedoSlotName(tus->getName())) continue; - if (tus->getTextureName() == canonicalTex) - pass->removeTextureUnitState(static_cast(i)); + const bool extraNormalSlot = isNormalSlotName(tus->getName()) + || tus->getTextureName() == canonicalTex + || textureNameLooksLikeNormalMap(tus->getTextureName()); + if (!extraNormalSlot) + continue; + pass->removeTextureUnitState(static_cast(i)); if (i < canonicalIdx) --canonicalIdx; } @@ -361,6 +421,53 @@ void RTShaderHelper::excludeNormalMapFromFfpChain(Ogre::MaterialPtr& 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; @@ -370,9 +477,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, diff --git a/src/RTShaderHelper.h b/src/RTShaderHelper.h index 939f6a254..0ed1aabd4 100644 --- a/src/RTShaderHelper.h +++ b/src/RTShaderHelper.h @@ -45,4 +45,10 @@ namespace RTShaderHelper { /// 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 = {}); } From 3d793be0e0cf7faa340e23448125022981174a8b Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 15:02:17 -0400 Subject: [PATCH 12/13] fix(turntable): init RTSS in CLI and address PR review feedback Load Ogre resources after the headless render window so turntable matches the GUI normal-map path. Harden CLI flag parsing, sequence output patterns, orbit framing per angle, and CI tests (no skipped Jump.fbx fixture). Co-authored-by: Cursor --- .github/actions/qtmesh/action.yml | 2 +- src/CLIPipeline.cpp | 199 +++++++++++++++++++++++----- src/CLIPipeline_test.cpp | 35 ++++- src/ModelTurntableRenderer.cpp | 44 +++--- src/ModelTurntableRenderer_test.cpp | 2 + src/RTShaderHelper.cpp | 6 + 6 files changed, 230 insertions(+), 58 deletions(-) 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/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index d545b29ae..eb313a7b7 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -745,6 +745,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. @@ -766,40 +853,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) @@ -2814,38 +2912,61 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) continue; } if (arg == "--frames" && i + 1 < argc) { - frameCount = QString(argv[++i]).toInt(); + if (!parseCliInt(QString(argv[++i]), &frameCount)) { + err() << "Error: Invalid value for --frames." << Qt::endl; + return 2; + } continue; } if (arg == "--columns" && i + 1 < argc) { - columns = QString(argv[++i]).toInt(); + if (!parseCliInt(QString(argv[++i]), &columns)) { + err() << "Error: Invalid value for --columns." << Qt::endl; + return 2; + } continue; } if (arg == "--width" && i + 1 < argc) { - width = QString(argv[++i]).toInt(); + if (!parseCliInt(QString(argv[++i]), &width)) { + err() << "Error: Invalid value for --width." << Qt::endl; + return 2; + } continue; } if (arg == "--height" && i + 1 < argc) { - height = QString(argv[++i]).toInt(); + 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) { - width = sizeArg.left(xPos).toInt(); - height = sizeArg.mid(xPos + 1).toInt(); + 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 { - width = height = sizeArg.toInt(); + height = width; } continue; } if (arg == "--elevation" && i + 1 < argc) { - elevation = QString(argv[++i]).toFloat(); + 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) { - elevation = QString(argv[++i]).toFloat(); + if (!parseCliFloat(QString(argv[++i]), &elevation)) { + err() << "Error: Invalid value for --camera-height." << Qt::endl; + return 2; + } continue; } if (arg == "--axis" && i + 1 < argc) { @@ -2874,6 +2995,15 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) 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; @@ -2885,6 +3015,7 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) SentryReporter::addBreadcrumb("cli.turntable", QString("Turntable .%1 frames=%2").arg(fi.suffix()).arg(frameCount)); + SentryReporter::addBreadcrumb("file.import", fi.absoluteFilePath()); MeshImporterExporter::importer({fi.absoluteFilePath()}); @@ -2915,14 +3046,11 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) return 1; } - const bool sequenceOutput = outputPath.contains(QLatin1Char('%')); QStringList writtenPaths; if (sequenceOutput) { for (int f = 0; f < frames.size(); ++f) { - char buf[2048]; - snprintf(buf, sizeof(buf), outputPath.toUtf8().constData(), f); - const QString framePath = QString::fromUtf8(buf); + const QString framePath = formatTurntableFramePath(outputPath, f); if (!frames.at(f).save(framePath)) { ModelTurntableRenderer::shutdown(); err() << "Error: Failed to write " << framePath << Qt::endl; @@ -2949,6 +3077,9 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) ModelTurntableRenderer::shutdown(); + for (const QString& written : writtenPaths) + SentryReporter::addBreadcrumb("file.export", written); + if (jsonOutput) { QJsonObject root; root["input"] = fi.absoluteFilePath(); diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 94bd2583f..1af743cf3 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -17,6 +17,7 @@ #include "MeshLodController.h" #include "SelectionSet.h" #include +#include #include "CLIPipeline.h" #include "ModelTurntableRenderer.h" #include "MeshImporterExporter.h" @@ -752,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) @@ -1046,16 +1048,15 @@ TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSpriteSheetColumnsAndCameraHei EXPECT_EQ(img.height(), 80); // 2 rows × 40 } -TEST_F(CLIPipelineCmdTest, CmdTurntable_JumpFbxRendersWithoutError) +TEST_F(CLIPipelineCmdTest, CmdTurntable_FbxFromTestDataRendersWithoutError) { - const QString jumpPath = QStringLiteral("/home/fernando/Downloads/Jump.fbx"); - if (!QFile::exists(jumpPath)) - GTEST_SKIP() << "Jump.fbx not present at " << jumpPath.toStdString(); + 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("jump_turntable.png").toUtf8(); - TestArgv args({"qtmesh", "turntable", jumpPath.toUtf8().constData(), + const QByteArray outArg = tmp.filePath("twist_turntable.png").toUtf8(); + TestArgv args({"qtmesh", "turntable", fbxPath.toUtf8().constData(), "-o", outArg.constData(), "--frames", "2", "--size", "128"}); @@ -1067,6 +1068,28 @@ TEST_F(CLIPipelineCmdTest, CmdTurntable_JumpFbxRendersWithoutError) 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; diff --git a/src/ModelTurntableRenderer.cpp b/src/ModelTurntableRenderer.cpp index 89c7ba208..e3353e88a 100644 --- a/src/ModelTurntableRenderer.cpp +++ b/src/ModelTurntableRenderer.cpp @@ -5,6 +5,7 @@ #include "MeshImporterExporter.h" #include "RTShaderHelper.h" #include "SelectionSet.h" +#include "SentryReporter.h" #include @@ -96,6 +97,7 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr 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); } @@ -307,12 +309,16 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T const float horizUnit = std::cos(elevationRadians); const float axialUnit = std::sin(elevationRadians); - Ogre::Vector3 restDir = cameraRestOffset(axis, horizUnit, axialUnit); - if (restDir.squaredLength() < 1e-8f) - restDir = cameraRestOffset(axis, 1.0f, 0.0f); - restDir.normalise(); + 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, restDir, st.camera, paddingFactor); + const Ogre::Real distance = fitOrbitDistance(bounds, pivotPoint, viewDir, st.camera, paddingFactor); const float horiz = distance * horizUnit; const float axial = distance * axialUnit; @@ -325,7 +331,7 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T void prepareMaterialsForTurntable(const QList &entities) { - std::unordered_set processed; + std::unordered_set processed; for (Ogre::Entity *entity : entities) { if (!entity) continue; @@ -334,8 +340,7 @@ void prepareMaterialsForTurntable(const QList &entities) Ogre::MaterialPtr mat = entity->getSubEntity(sub)->getMaterial(); if (!mat) continue; - const std::string key = mat->getName(); - if (!processed.insert(key).second) + if (!processed.insert(mat.get()).second) continue; RTShaderHelper::finalizeShaderGenMaterial(mat); } @@ -403,26 +408,24 @@ void ModelTurntableRenderer::shutdown() if (st.lightNode) { st.lightNode->detachObject(st.light); sm->destroySceneNode(st.lightNode); - st.lightNode = nullptr; } sm->destroyLight(st.light); - st.light = nullptr; } if (st.camera) { if (st.cameraNode) st.cameraNode->detachObject(st.camera); sm->destroyCamera(st.camera); - st.camera = nullptr; } - if (st.cameraNode) { + if (st.cameraNode) sm->destroySceneNode(st.cameraNode); - st.cameraNode = nullptr; - } - if (st.pivotNode) { + if (st.pivotNode) sm->destroySceneNode(st.pivotNode); - st.pivotNode = nullptr; - } } + st.lightNode = nullptr; + st.light = nullptr; + st.camera = nullptr; + st.cameraNode = nullptr; + st.pivotNode = nullptr; } bool ModelTurntableRenderer::renderToImages(const QList &entities, @@ -476,6 +479,9 @@ bool ModelTurntableRenderer::renderToImages(const QList &entitie 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) { @@ -485,18 +491,22 @@ bool ModelTurntableRenderer::renderToImages(const QList &entitie 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; } } diff --git a/src/ModelTurntableRenderer_test.cpp b/src/ModelTurntableRenderer_test.cpp index d09f3df22..27c2079d8 100644 --- a/src/ModelTurntableRenderer_test.cpp +++ b/src/ModelTurntableRenderer_test.cpp @@ -206,4 +206,6 @@ TEST_F(ModelTurntableRendererTest, ExcludeNormalMapFromFfpChainRemovesDuplicateU ++normalUnits; } EXPECT_EQ(normalUnits, 1u); + + RTShaderHelper::shutdown(sceneMgr); } diff --git a/src/RTShaderHelper.cpp b/src/RTShaderHelper.cpp index d9ba89444..51a77bf88 100644 --- a/src/RTShaderHelper.cpp +++ b/src/RTShaderHelper.cpp @@ -140,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"); From d00bf793071f61cc0a8cdad90b592d371425e8d4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 20 May 2026 15:35:26 -0400 Subject: [PATCH 13/13] docs(website): document qtmesh turntable CLI command Add turntable to the CLI reference, quick start, Docker examples, and the landing-page pipeline tab with usage for sprite sheets and frame sequences. Co-authored-by: Cursor --- website/src/App.jsx | 1 + website/src/DocsApp.jsx | 48 +++++++++++++++++++++++++++++++++++-- website/src/data/content.js | 1 + 3 files changed, 48 insertions(+), 2 deletions(-) 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`